std.string
std.string provides read-only helpers over NUL-terminated strings, a clamped substring, integer parsing and formatting, StringBuilder, a growable heap-backed string with concatenation, and, as of 1.5.3, a byte-oriented toolkit that searches from either end, orders, trims, splits, joins, replaces, repeats, and case folds. The module lives at lib/std/string.dusk and is written in Dusk.
@import std.stringA dusk string is a pointer to a NUL-terminated buffer of char, a read-only view that costs one machine word. String literals do not heap allocate; the literal bytes live in static storage. A string value is immutable: to build or join strings at runtime you use StringBuilder, added in 0.2.0. See Types for the string type itself.
The whole 1.5.3 toolkit is pure Dusk written over the builder you already had. Nothing about the compiler, the runtime, or the string representation changed to make room for it. Every toolkit function that returns a string returns a fresh heap allocation you own and free, with no borrowed views among them, and the sections below say so function by function.
The std.vector import
Section titled “The std.vector import”std.string imports std.vector as of 1.5.3, because str_split returns a vector and str_join reads one. That import is not an internal detail you can ignore. Nearly every other standard library module imports std.string, so a program that imports any of them now sees the vec_* functions and the Vector type too, whether or not it asked for them. Importing std.io alone is enough to make vec_new and vec_push resolve.
This is the one change in 1.5.3 that can break a program that built under 1.5.2, and it breaks it as a name collision. A program that defined its own top-level vec_len, or its own Vector, compiled before and now fails at check time:
dup.dusk: 1:1: error: duplicate definition of 'vec_len'The fix is to rename your private copy. Expect a second, noisier error underneath the first, because the library’s definition is the one that stays in scope and your call sites are then handing the wrong argument types to it; the duplicate definition is the real error and renaming clears both. Nothing else in this release touches a program that does not use the new functions.
Read-only helpers
Section titled “Read-only helpers”| Function | Description |
|---|---|
str_len(s: string) -> int64 | Length up to the NUL terminator. |
str_eq(a: string, b: string) -> bool | True when both strings hold the same bytes. |
A string’s length is found by scanning to the NUL, which is what str_len does. str_eq compares byte by byte and requires the strings to end together. As of 1.2.0 the == and != operators compare string content too, so a == b gives the same answer as str_eq(a, b); str_eq stays as an explicit, import-named helper.
@paradigm procedural@import std.string
func main() -> int32 { n := str_len("hello") println(n) // 5
if str_eq("dusk", "dusk") { println("same") // same } if !str_eq("dusk", "dawn") { println("different") // different } return 0}Slicing
Section titled “Slicing”| Function | Description |
|---|---|
substring(s: string, lo: int64, hi: int64) -> string | A fresh heap string of the bytes s[lo, hi), with the range clamped to s. |
substring copies the half-open byte range s[lo, hi) out of s and hands you a fresh NUL-terminated heap string you own and free. The range is byte-oriented like the rest of the module, so slicing through the middle of a multibyte UTF-8 sequence splits it; see Unicode and runes when you need scalar boundaries.
The range clamps rather than faulting, and it clamps in a specific order that is worth knowing. A lo below zero becomes zero, a hi past the length becomes the length, and if lo still sits above hi after that, the two collapse together and you get the empty string. Every combination of the two therefore lands on a valid window: substring("hello", -4, 5) is hello, substring("hello", 6, 999) is the empty string, and a backwards range like substring("hello", 8, 2) is the empty string too. None of them fault, and the result is always an allocation you free, the empty ones included.
substring or a range slice
Section titled “substring or a range slice”s[lo..hi] slices a string too, and the two are not interchangeable. The range slice is a borrowed char[] view over the string’s own bytes: it allocates nothing and costs nothing, but it is bounds checked against lo <= hi <= len and faults with index out of bounds on a window that runs off the end. substring allocates, clamps, and returns a string rather than a char[].
That last difference is the one that usually decides it. A char[] prints as its text, which makes the two feel closer than they are, but it is not a string anywhere else: == on a slice is refused outright, cannot compare a slice; compare its parts instead, and the only function on this page that accepts one is str_from_chars, the one that copies it into a string. So substring(s, a, b) is str_from_chars(s[a..b]) with the fault traded for a clamp, and it is the shorter way to write it.
Reach for the range slice when you want a cheap read-only look at bytes you already know are in range, and let the fault catch you when they are not. Reach for substring when you want an owned string to keep, or when lo and hi came out of arithmetic on a str_find result that might run past the end and you would rather be clamped than killed. See Types for the checked string range and Memory for free.
@paradigm procedural@import std.string
func main() -> int32 { s: string = "hello world"
mid: string = substring(s, 6, 11) println(mid) // world free(mid)
head: string = substring(s, -4, 5) println(head) // hello, lo clamped up to 0 tail: string = substring(s, 6, 999) println(tail) // world, hi clamped down to the length backwards: string = substring(s, 8, 2) println(backwards) // the empty string, still an allocation free(head) free(tail) free(backwards)
view: char[] = s[0..5] // borrowed, no allocation, faults if out of range copied: string = str_from_chars(view) println(copied) // hello free(copied) return 0}Searching
Section titled “Searching”These helpers look for one string inside another. str_find and str_contains arrived in 1.3.0; str_rfind and ends_with joined them in 1.5.3.
| Function | Description |
|---|---|
str_find(s: string, needle: string) -> int64 | The byte offset of the first occurrence of needle, or -1 when absent. |
str_rfind(s: string, needle: string) -> int64 | The byte offset of the last occurrence of needle, or -1 when absent. |
str_contains(s: string, needle: string) -> bool | True when needle occurs anywhere in s. |
starts_with(s: string, prefix: string) -> bool | True when s begins with prefix. |
ends_with(s: string, suffix: string) -> bool | True when s ends with suffix. |
str_find returns the byte offset where needle first appears in s, scanning front to back, and returns -1 when it does not appear at all. The empty needle matches at offset 0, the convention strstr follows. str_contains folds that offset down to a yes or no: it is true exactly when str_find returns a non-negative offset.
str_rfind finds the last occurrence the way str_find finds the first. It walks candidate offsets down from the last one that could fit and returns the first that matches, so on "abcabcabc" the needle "bc" gives 1 from str_find and 7 from str_rfind. An absent needle is -1 from both. The empty needle is where the two part ways: str_find matches it at offset 0, the start, and str_rfind matches it at str_len(s), the end. That mirrors rather than contradicts, since an empty needle sits at both ends at once.
ends_with is the tail mirror of starts_with. Both compare NUL-terminated bytes and neither reads past a terminator, so a prefix or suffix longer than s is simply false rather than a fault. The empty string is both a prefix and a suffix of every string, so ends_with("hi", "") is true.
@paradigm procedural@import std.string
func main() -> int32 { i: int64 = str_find("hello world", "world") println(i) // 6 j: int64 = str_find("hello", "xyz") println(j) // -1 if str_contains("dusk and dawn", "dawn") { println("found") // found }
hay := "abcabcabc" println(str_find(hay, "bc")) // 1 println(str_rfind(hay, "bc")) // 7 println(str_rfind(hay, "zz")) // -1 println(str_rfind("abc", "")) // 3, the end
if ends_with("readme.md", ".md") { println("markdown") // markdown } return 0}Ordering
Section titled “Ordering”| Function | Description |
|---|---|
str_cmp(a: string, b: string) -> int32 | Negative when a sorts before b, zero when equal, positive when after. |
Strings have no ordering operators: < and > between two strings reject, and == compares content. str_cmp, added in 1.5.3, is where string ordering lives instead. It compares a and b lexicographically as sequences of unsigned bytes and returns -1, 0, or 1.
Two consequences follow from reading bytes unsigned. A byte of a multibyte UTF-8 sequence is 128 or above, so it orders above every ASCII byte rather than below it, which is what you want and is not what a signed byte comparison would give you. And a shorter string that is a prefix of a longer one sorts first, because the comparison ends at the shorter one’s NUL, so str_cmp("ab", "abc") is negative.
The return type is the reason to care. str_cmp is shaped as an int32 comparator, which is exactly what vec_sort takes, so a Vector<string> sorts by passing str_cmp straight in with no wrapper and no glue function of your own:
vec_sort(parts, str_cmp)This pairing is the point of the function, and the sample under Splitting and joining puts it to work.
Trimming
Section titled “Trimming”| Function | Description |
|---|---|
trim_start(s: string) -> string | Strip ASCII whitespace from the front. |
trim_end(s: string) -> string | Strip ASCII whitespace from the end. |
trim(s: string) -> string | Strip ASCII whitespace from both ends. |
The three trims, added in 1.5.3, each strip whitespace from the end their name points at: the front, the back, or both. Whitespace here means the four ASCII bytes space, horizontal tab, carriage return, and line feed, and nothing else; no Unicode space separator is recognized.
Each one always returns a fresh heap string that you free, even when nothing strips. trim on a string with no whitespace still hands you a copy rather than the original view, and a string that is entirely whitespace trims to the empty string, which is still an allocation you own. That uniformity is deliberate: you free the result of a trim unconditionally, without checking whether it did any work.
Splitting and joining
Section titled “Splitting and joining”| Function | Description |
|---|---|
str_split(s: string, sep: string) -> *Vector<string> | The pieces of s between each occurrence of sep. |
str_join(parts: *Vector<string>, sep: string) -> string | The elements of parts concatenated with sep between each pair. |
str_split splits on every occurrence of sep and returns the pieces in order. str_join is its inverse. Both arrived in 1.5.3 and both are why std.string now imports std.vector.
The edges are where a split surprises people, so they are worth stating plainly. Adjacent separators yield an empty string between them. A leading separator yields a leading empty piece and a trailing separator yields a trailing empty piece, so str_split("a,,b,", ",") gives you four elements, a, the empty string, b, and the empty string again, not two. These are the same pieces Go’s strings.Split produces. An empty separator is not scanned for at all, since it would match between every byte; it gives you a single-element vector holding one copy of s.
str_join reads its elements and never takes them, so parts and the strings inside it stay yours to free after the join returns. An empty vector joins to the empty string, and a single element joins to a copy of itself with no separator anywhere.
Naming
Section titled “Naming”Both carry the str_ prefix rather than being a bare split and join because join is the thread builtin’s call form, the same reserved set that hash joined in 1.5.2. A top-level function may not take a builtin’s name, so the prefix is what keeps these two out of that collision.
Freeing a split
Section titled “Freeing a split”A vector from str_split is two levels of ownership, and both are yours. The vector itself is a fresh allocation, and every element inside it is a separate fresh heap string. Freeing it correctly means freeing each element first, then the vector’s backing buffer with vec_free, then the vector struct with free:
mut i: int64 = 0while i < vec_len(parts) { free(vec_get(parts, i)) // every element is its own allocation i = i + 1}vec_free(parts) // the vector's backing bufferfree(parts) // the vector struct itselfFreeing the vector without walking its elements first leaks every piece. See Memory for alloc and free, and std.vector for the vector’s own two-allocation shape.
The sample below runs the toolkit end to end: a trim, a split, a sort through str_cmp, and a join back.
@paradigm procedural@import std.string@import std.vector
func main() -> int32 { row: string = trim(" pear,apple,fig ") println(row) // pear,apple,fig
parts: *Vector<string> = str_split(row, ",") println(vec_len(parts)) // 3
vec_sort(parts, str_cmp) // str_cmp is already the comparator shape line: string = str_join(parts, " < ") println(line) // apple < fig < pear
mut i: int64 = 0 while i < vec_len(parts) { free(vec_get(parts, i)) // every element is its own allocation i = i + 1 } vec_free(parts) // the vector's backing buffer free(parts) // the vector struct itself
free(line) free(row)
bar: string = repeat("-", 14) println(bar) // -------------- free(bar)
swapped: string = replace_all("dusk and dawn", "and", "&") shout: string = to_upper(swapped) println(shout) // DUSK & DAWN free(shout) free(swapped) return 0}Replacing and repeating
Section titled “Replacing and repeating”| Function | Description |
|---|---|
replace_all(s: string, old: string, new_s: string) -> string | Replace every occurrence of old with new_s. |
repeat(s: string, n: int64) -> string | s concatenated with itself n times. |
replace_all, added in 1.5.3, replaces every occurrence of old in s with new_s, scanning left to right. The occurrences are non-overlapping: after a match the scan resumes past the whole matched run, so replace_all("aaa", "aa", "b") gives ba and not bb. An empty old copies the input rather than looping forever, since a pattern that matches between every byte has no sensible replacement, and an old that never occurs also gives you a plain copy. Either way you get a fresh heap string to free.
repeat concatenates s with itself n times, so repeat("ab", 3) is ababab. A count of zero or less yields the empty string, still a fresh allocation rather than a literal, so you free the result the same way whatever the count was.
ASCII case folding
Section titled “ASCII case folding”| Function | Description |
|---|---|
to_upper(s: string) -> string | Uppercase the ASCII letters a to z. |
to_lower(s: string) -> string | Lowercase the ASCII letters A to Z. |
These two, added in 1.5.3, fold ASCII letters and nothing else. Every other byte copies through unchanged, and that includes every byte of a multibyte UTF-8 sequence, since those are all 128 or above. A non-ASCII scalar therefore survives the fold intact rather than being mangled: to_upper("café") is CAFé, with the accented e passed through untouched while the letters around it fold.
This is a deliberate limit, not an oversight. A full Unicode case fold is not in the module and is not planned for it, the same posture the unicode tables took: correct case folding is locale-dependent and table-heavy, and std.string stays a byte-oriented module. Reach for to_upper and to_lower when you are folding ASCII identifiers, extensions, or keywords, which is what they are for, and do not reach for them to case fold arbitrary human text.
Parsing numbers
Section titled “Parsing numbers”| Function | Description |
|---|---|
parse_int(s: string) -> (int64, error) | Parse a signed base 10 integer. |
parse_int_radix(s: string, base: int64) -> (int64, error) | Parse a signed integer in a base from 2 to 36. |
parse_float(s: string) -> (float64, error) | Parse a base 10 float. Builtin, no import needed. |
Each parser returns the value paired with an error that you must handle: resolve it with exists, check, or ignore before it goes out of scope. See Error handling.
parse_int_radix accepts a base from 2 to 36; a base outside that range is an error. An optional leading - or + sign is accepted. Digits 0 to 9 map to 0 to 9 and letters to 10 to 35, upper or lower case. A base with a canonical prefix accepts it (0x or 0X for 16, 0o or 0O for 8, 0b or 0B for 2), but the base is never inferred from the prefix. A prefix that does not match the base, like 0x under base 10, is read as digits and fails on the bad digit. An empty input, a digit the base rejects, or a value that overflows int64 is an error.
parse_int is parse_int_radix with base 10, so a 0x, 0o, or 0b prefix fails on the prefix letter.
parse_float is a builtin rather than a library function, so it is available everywhere without an import, the same way print is. The std.string reference lists it here because it belongs with the other parsers; see Builtins for the full builtin list.
@paradigm procedural@import std.string
func main() -> int32 { n, e := parse_int("42") e.ignore() println(n) // 42
h, he := parse_int_radix("0xFF", 16) he.ignore() println(h) // 255
bad, be := parse_int("0xFF") if be.exists() { println("not base 10") // not base 10 } else { println(bad) }
f, fe := parse_float("3.5") fe.ignore() println(f) // 3.5 return 0}Formatting numbers
Section titled “Formatting numbers”| Function | Description |
|---|---|
int_to_string(n: int64) -> string | The base 10 text of a signed integer. |
int_to_string is the direction parse_int does not go: it turns an int64 into its base 10 text, with a leading - when the value is negative, and returns a fresh heap string you free. It is the module’s only number formatter. There is no float formatter in std.string and none anywhere else in the standard library, so a float64 reaches text through println or through a foreign "C" call to snprintf of your own.
The one sharp edge here is an edge the function already handles for you. Formatting a negative number the obvious way negates it first and then reads off the digits of the magnitude, which overflows silently for the most negative int64, the single value with no positive counterpart. int_to_string accumulates its digits in the non-positive range instead, where that value needs no negation, so int_to_string(-9223372036854775807 - 1) gives you the right nineteen digits rather than a wrapped answer. That fix landed in 0.6.0 after the bug was found and recorded; sb_push_int carries the same guard for the same reason.
Reach for sb_push_int instead when the number is headed straight into a StringBuilder, since it appends the digits directly and skips the intermediate string and its free.
@paradigm procedural@import std.string
func main() -> int32 { n: string = int_to_string(4096) println(n) // 4096 free(n)
neg: string = int_to_string(-42) println(neg) // -42 free(neg)
// The most negative int64 formats without overflowing. floor: string = int_to_string(-9223372036854775807 - 1) println(floor) // -9223372036854775808 free(floor)
// The round trip back through parse_int. back, e := parse_int("4096") e.ignore() println(back) // 4096 return 0}The literal -9223372036854775808 does not lex, since the minus is an operator applied to a positive literal that already overflows, so that floor is written -9223372036854775807 - 1.
StringBuilder
Section titled “StringBuilder”StringBuilder is a growable, heap-backed string:
export struct StringBuilder { data: *raw char, len: int64, cap: int64,}Build it on the heap with alloc(sb_new()) and pass it by pointer so growth persists across calls, the same shape std.vector uses. The buffer always holds a NUL after the last character, so sb_cstr hands back a valid string view with no extra work.
| Function | Description |
|---|---|
sb_new() -> StringBuilder | A fresh empty builder. |
sb_push_char(s: *StringBuilder, c: char) -> void | Append one character. |
sb_push(s: *StringBuilder, t: string) -> void | Append every character of a string, up to its NUL. |
sb_push_int(s: *StringBuilder, n: int64) -> void | Append the base 10 text of a signed integer. |
sb_size(s: *StringBuilder) -> int64 | The number of characters built. |
sb_cstr(s: *StringBuilder) -> string | View the built bytes as a string. |
sb_free(s: *StringBuilder) -> void | Free the backing buffer. |
concat(a: string, b: string) -> *StringBuilder | Join two strings into a fresh heap builder. |
@paradigm procedural@import std.string
func main() -> int32 { g: *StringBuilder = alloc(sb_new()) sb_push(g, "dusk") sb_push_char(g, 32) // a space sb_push(g, "and dawn") println(sb_cstr(g)) // dusk and dawn println(sb_size(g)) // 13 sb_free(g) // frees the buffer free(g) // frees the builder struct
r: *StringBuilder = concat("hello, ", "world") println(sb_cstr(r)) // hello, world sb_free(r) free(r)
n: *StringBuilder = alloc(sb_new()) sb_push(n, "port ") sb_push_int(n, 8080) // no intermediate string println(sb_cstr(n)) // port 8080 sb_free(n) free(n) return 0}sb_push_int is the one entry in that table that is not just a push. It writes the base 10 digits of n straight into the buffer, most significant first, so building "port 8080" never mints the "8080" that int_to_string would hand you and never asks you to free it. It carries the same non-positive accumulation guard int_to_string does, so the most negative int64 pushes correctly rather than wrapping.
Growth and views
Section titled “Growth and views”The buffer starts at capacity 8 and doubles when a pushed character and its NUL terminator would not fit. Growth allocates a new buffer, copies the built characters, and frees the old one; the NUL terminator is rewritten after every push.
sb_cstr is a zero-cost reinterpret: because the buffer is always NUL-terminated, viewing it as a string does no copying. Underneath it uses the cstr builtin, which reinterprets a NUL-terminated *char buffer as a string at no runtime cost. The view borrows the buffer, so it is valid only until the builder next grows or is freed. Copy the bytes out or finish using the view before pushing again.
Ownership
Section titled “Ownership”A builder owns its buffer, and a heap builder is two allocations: the buffer and the builder struct. Free both: sb_free releases the buffer and free releases the struct. See Memory for how alloc and free behave.
Concatenation
Section titled “Concatenation”concat(a, b) appends both strings into a fresh heap builder and returns it. Ownership moves to the caller, who frees the buffer with sb_free and the builder struct with free, as in the example above. Read the result with sb_cstr, or keep pushing onto it. The return value is an ordinary *StringBuilder.
As of 1.2.0, + and += also join strings. a + b on two strings mints a fresh heap string you free with an ordinary free, and += rebinds a mut string in place. concat and sb_push stay useful when you are already building on a StringBuilder or want to keep pushing more onto the result.
Conversions
Section titled “Conversions”Two helpers bridge a string and the representations on either side of it: a stack-held char[] on one side, a raw C buffer on the other.
| Function | Description |
|---|---|
str_from_chars(cs: char[]) -> string | Copy a char slice into a fresh heap string. |
cbuf(s: string) -> *raw char | Copy a string into a fresh NUL-terminated raw buffer. |
str_from_chars, added in 1.1.0, copies a char[] slice into a fresh heap string the caller owns. It is the bridge from stack text back into the dynamic string world: a char[N] initialized from a string literal slices to a char[] and lands here as an owned string. See Types for the fixed char array. Free the result with free once you are done with it.
@paradigm procedural@import std.string
func main() -> int32 { buf: char[5] = "Hello" s: string = str_from_chars(buf[0..5]) println(s) // Hello free(s) return 0}cbuf, added in 1.4.0, copies a string into a fresh NUL-terminated raw buffer that a foreign call can read, then hands you the *raw char. A string view is typed apart from a raw pointer, and a foreign "C" signature takes only a scalar, a *raw T, or a *void, so a string that crosses the boundary crosses as this copied buffer. The caller owns it and frees it with free once the call that reads it has returned. It replaced the old private to_cbuf helper. See Foreign functions for the boundary rules.
@paradigm procedural@import std.string
foreign "C" { func strlen(s: *raw char) -> int64}
func main() -> int32 { p: *raw char = cbuf("hello") n: int64 = strlen(p) println(n) // 5 free(p) return 0}Utilities the module grew while serving the compiler
Section titled “Utilities the module grew while serving the compiler”Three exports here are not general purpose text handling, and it is fairer to say so than to shelve them next to trim and let you find out. They arrived in 0.6.0, the release that started rewriting the compiler in Dusk, and they exist because that compiler had to emit LLVM IR whose float constants matched the old Rust compiler’s byte for byte. They are exported, so they are public surface and you can call them, but nothing about a normal program wants them. See the overview for the bootstrap they came out of.
| Function | Description |
|---|---|
int_to_hex16(n: int64) -> string | n as 0x followed by 16 uppercase hex digits of its raw 64-bit word. |
f64_to_ir_hex(x: float64) -> string | The IR constant token for x at float64 width. |
f32_to_ir_hex(x: float64) -> string | The IR constant token for the float64 that x rounds to at float32 width. |
int_to_hex16 is the one of the three you could reasonably want on its own. It formats n as 0x and exactly sixteen uppercase hex digits, reading it as a raw 64-bit word rather than a signed number, so -1 is 0xFFFFFFFFFFFFFFFF and never a -0x1. But notice how rigid that shape is: always sixteen digits, always uppercase, always the 0x, never a width you choose. That is not a formatter designed for you, it is the shape of an IR token, and it is fixed because the token it reproduces is fixed. If what you want is hex for a person to read, you are better served writing the loop you actually want than bending this one.
The other two are thin wrappers over it and are what it was really built for. f64_to_ir_hex reads the IEEE 754 bits of x and hands them to int_to_hex16, reproducing the exact token a float64 constant lowers to in the emitted IR. f32_to_ir_hex does the same through a float32 round trip, so it gives you the double a float32 literal actually holds once rounded. Both take a float64; there is no float32 parameter on either, including on the f32 one.
The reason the pair exists rather than one function is a detail of how a float constant is emitted: a float32 constant goes into the IR as the float64 bits of the parsed value and gets narrowed by a later fptrunc, so reproducing the emitted token for a float32 literal actually calls f64_to_ir_hex, not f32_to_ir_hex. f32_to_ir_hex is for the consumer that wants the post-rounding value instead. If that distinction reads as compiler trivia, that is because it is, and it is the clearest sign these three are not aimed at your program.
@paradigm procedural@import std.string
func main() -> int32 { w: string = int_to_hex16(255) println(w) // 0x00000000000000FF free(w)
// A negative value is its two's complement word, never a signed form. m: string = int_to_hex16(-1) println(m) // 0xFFFFFFFFFFFFFFFF free(m)
// The token the compiler emits for a float64 constant. d: string = f64_to_ir_hex(0.1) println(d) // 0x3FB999999999999A free(d)
// The double the same literal rounds to at float32 width. f: string = f32_to_ir_hex(0.1) println(f) // 0x3FB99999A0000000 free(f) return 0}Each returns a fresh heap string you free, the same as every other allocating function on this page.