Skip to content

Strings

UTF-8 text, characters, searching, splitting, transformation, and borrowed byte views.

Updated View as Markdown

string is owned, immutable UTF-8 text. char is one Unicode scalar value. substring is a borrowed byte view into a string that costs nothing to create.

fn main(): void {
    name := "Ångström"
    println("${name.length()} characters, ${name.len()} bytes")
}

That distinction is the first thing to get right. length() walks the buffer and counts Unicode scalars; len() reads the encoded byte count from the block header and is O(1). For ASCII they agree; for the string above they are 8 and 10.

Characters and safe access

Every accessor that could run off the end returns an Option, so text handling never panics and never needs a bounds check written by hand. char_at(index) is the accessor that lowers today.

fn first_char(text: string): char? => text.char_at(0u32)

fn last_char(text: string): char? {
    if text.is_empty() { return None }
    return text.char_at((text.length() - 1).to_u32())
}

fn initials(first: string, last: string): string {
    a := first_char(first) ?? '?'
    b := first_char(last) ?? '?'
    return "${a}${b}"
}

fn main(): void {
    println(initials("Ada", "Lovelace"))
    println("${last_char("hello") ?? '?'}")
    println("${first_char("") ?? '?'}")
}

Character positions use the CharIndex alias, which is u32, so an int index needs .to_u32(). Byte positions use Index, also u32. Both aliases are declared in stubs/index.at; you will see them in every string and list signature, and mixing one with a bare int in a range is a type error.

fn head(text: string): string {
    n := 4
    return text.slice(0u32..n)?.to_string() ?? ""
}

Indexing with [] is not available on string at all — the byte/character ambiguity would be silent, so the language does not offer it:

fn first_char(text: string): char {
    return text[0]
}

char classification is ASCII-oriented today: is_digit, is_alphabetic, is_alphanumeric, is_whitespace, is_upper_case, and is_lower_case, plus to_u32/to_int/to_string conversions. (char.to_upper_case and char.to_lower_case do not lower — go through to_string().to_upper_ascii().)

fn is_identifier(text: string): bool {
    match text.char_at(0u32) {
        None => return false
        Some(head) => if !head.is_alphabetic() && head != '_' { return false }
    }
    for c in text {
        if !c.is_alphanumeric() && c != '_' { return false }
    }
    return true
}

fn main(): void {
    println("${is_identifier("total_count")}")   // true
    println("${is_identifier("9lives")}")        // false
    println("${is_identifier("")}")              // false
}

char.from_u32 rejects surrogates and anything above U+10FFFF:

fn decode_escape(codepoint: u32): string {
    match char.from_u32(codepoint) {
        Some(c) => return c.to_string()
        None => return "?"
    }
}

fn main(): void {
    println(decode_escape(9731u32))         // ☃
    println(decode_escape(0xD800u32))       // ? — lone surrogate
}

Iterating characters

string implements Iterable[char], so for walks the codepoints in place without allocating a list. This is the workhorse: the character combinators (map, filter, fold, count, any, all, enumerate) are declared but do not lower, and the loop is what they would have compiled to anyway.

fn digit_sum(text: string): int {
    mut total := 0
    for c in text {
        if c.is_digit() {
            total = total + (c.to_int() - '0'.to_int())
        }
    }
    return total
}

fn main(): void {
    println("${digit_sum("a1b2c3")}")    // 6
}

A filtering pass appends into a mut string, which is the substitute for both filter and map:

fn strip_punctuation(text: string): string {
    mut out := ""
    for c in text {
        if c.is_alphanumeric() || c.is_whitespace() {
            out.append_string(c.to_string())
        }
    }
    return out
}

fn count_where(text: string, keep: fn(char) -> bool): int {
    mut n := 0
    for c in text {
        if keep(c) { n = n + 1 }
    }
    return n
}

fn main(): void {
    println(strip_punctuation("hello, world!"))       // hello world
    println("${count_where("banana", c => c == 'a')}")   // 3
    println("${count_where("hello", c => "aeiou".contains(c.to_string()))}")
}

Materializing a []char — the substitute for chars() / to_list() — is the same loop with a list as the accumulator. Reach for it only when you need random access or a second pass:

fn reversed(text: string): string {
    mut chars: []char = []
    for c in text {
        chars.add(c)
    }
    mut out := ""
    mut i := chars.len() - 1
    for i >= 0 {
        out.append_string((chars.get(i.to_u32()) ?? ' ').to_string())
        i = i - 1
    }
    return out
}

fn main(): void {
    println(reversed("stressed"))    // desserts
}

Searching

contains, starts_with, and ends_with return bool. index_of and last_index_of return CharIndex?.

fn is_json_request(path: string, accept: string): bool {
    return path.starts_with("/api/") && accept.contains("application/json")
}

fn extension(filename: string): string {
    match filename.last_index_of(".") {
        None => return ""
        Some(dot) => {
            rest := filename.slice(dot + 1u32..filename.len().to_u32())
            return rest?.to_string() ?? ""
        }
    }
}

fn main(): void {
    println("${is_json_request("/api/users", "application/json")}")
    println(extension("archive.tar.gz"))    // gz
    println("[${extension("README")}]")     // []
}

Truncation is a slice, not a take — clamp the length yourself and keep the result on a byte boundary you control:

fn truncate(text: string, limit: int): string {
    if text.len() <= limit { return text }
    keep: u32 = (limit - 1).to_u32()
    return (text.slice(0u32..keep)?.to_string() ?? "") + "…"
}

fn main(): void {
    println(truncate("a very long headline indeed", 12))
    println(truncate("short", 12))
}

Case-insensitive comparison is a distinct operation, not a mode on ==. The equals_ignore_case / compare_to_ignore_case pair does not lower; fold both sides with to_lower_ascii instead. Plain == is content equality, and compare_to is the lexicographic byte-order comparison behind sorting.

fn header_matches(name: string, expected: string): bool {
    return name.to_lower_ascii() == expected.to_lower_ascii()
}

fn main(): void {
    println("${header_matches("Content-Type", "content-type")}")
    println("${"abc" == "abc"} ${"abc" < "abd"} ${"abc".compare_to("abd")}")
    for n in ["delta", "Alpha", "charlie", "Bravo"].sorted_by(n => n.to_lower_ascii()) {
        println(n)
    }
}

Slicing: byte views

substring(start, end) and slice(range) are byte-indexed and return substring?, a borrowed view into the original buffer. They allocate nothing.

fn main(): void {
    record := "id=42;name=ada"
    match record.slice(0u32..5u32) {
        None => println("too short")
        Some(view) => {
            println("${view.byte_length()} ${view.is_empty()}")
            println("${view.slice(3u32..5u32)?.to_string() ?? ""}")   // 42
            println("${view.bytes().byte_len()} ${view.to_bytes().len()}")
            println(view.to_string())                                  // id=42
        }
    }
}

The result is a substring, not a string, and the compiler will not let you mix them up:

fn prefix(text: string): string {
    return text.substring(0, 2)
}

A substring offers len/size/byte_length, is_empty, is_not_empty, nested slice, bytes() for a [..]byte view, to_bytes() for an owned byte list, and to_string() for an owned copy. Out-of-bounds ranges give None; the range endpoints are not checked for UTF-8 character boundaries, so only decode a view whose byte range you know is aligned. Splitting "Ångström" at byte 1 lands mid-sequence:

fn main(): void {
    name := "Ångström"
    println("${name.slice(0u32..2u32)?.to_string() ?? "?"}")   // Å — 2 bytes
    println("${name.slice(0u32..1u32)?.to_string() ?? "?"}")   // split codepoint
    println("${name.char_at(0u32) ?? '?'}")                    // Å — by character
}

Because a substring borrows its parent’s storage, use it inside the parent’s scope and call to_string() (or to_bytes()) when the value must outlive it.

Splitting

split(separator) returns List[string] and preserves empty segments, exactly like Python and Rust. It is the only splitter that lowers: split_with_limit, lines, and words do not.

fn main(): void {
    println("${"a,b,,c".split(",").len()}")    // 4 — empty segment preserved
    for line in "one\ntwo\n\nthree".split("\n") {
        if line.trim().is_empty() { continue }
        println(line)
    }
}

Split-once — the key=value case where the value may itself contain the separator — is index_of plus two slices:

fn split_once(text: string, sep: string): (string, string)? {
    match text.index_of(sep) {
        None => return None
        Some(at) => {
            head := text.slice(0u32..at)?.to_string() ?? ""
            after := at + sep.len().to_u32()
            tail := text.slice(after..text.len().to_u32())?.to_string() ?? ""
            return Some((head, tail))
        }
    }
}

fn main(): void {
    match split_once("dsn = postgres://host/db?x=1", "=") {
        Some(pair) => println("${pair.0.trim()} -> ${pair.1.trim()}")
        None => println("no separator")
    }
    match split_once("nope", "=") {
        Some(pair) => println("${pair.0}")
        None => println("no separator")
    }
}

Word splitting on whitespace runs — the substitute for words() — collapses consecutive separators, which split(" ") does not:

fn words(text: string): []string {
    mut out: []string = []
    mut current := ""
    for c in text {
        if c.is_whitespace() {
            if current.is_not_empty() { out.add(current) }
            current = ""
        } else {
            current.append_string(c.to_string())
        }
    }
    if current.is_not_empty() { out.add(current) }
    return out
}

fn main(): void {
    for w in words("  the   quick\tbrown  ") {
        println("[${w}]")
    }
}

Transformation

Group Methods Status
ASCII case to_upper_ascii, to_lower_ascii lowers
Unicode case to_upper_case, to_lower_case, capitalize, title_case declared, no lowering path
Whitespace trim, trim_start, trim_end lowers
Whitespace is_blank declared, no lowering path — use trim().is_empty()
Shape repeat lowers
Shape pad_start, pad_end, reversed declared, no lowering path
Substitution replace, replace_first declared, no lowering path

The *_ascii case methods map only A-Z/a-z and pass every other byte through unchanged. They are the right choice for protocol tokens — HTTP header names, enum tags, file extensions — where Unicode case folding would be wrong or merely slower. They are also the only case methods that compile today.

fn normalize_header(name: string): string {
    return name.trim().to_lower_ascii()
}

fn banner(title: string): string {
    return title.to_upper_ascii() + "\n" + "=".repeat(title.length())
}

fn main(): void {
    println(normalize_header("  Content-Type  "))
    println(banner("release notes"))
}

Padding is repeat plus a width computation, and capitalization is a first-character slice:

fn pad_start(text: string, width: int, fill: string): string {
    missing := width - text.length()
    if missing <= 0 { return text }
    return fill.repeat(missing) + text
}

fn capitalize(text: string): string {
    match text.char_at(0u32) {
        None => return text
        Some(head) => {
            rest := text.slice(head.to_string().len().to_u32()..text.len().to_u32())
            return head.to_string().to_upper_ascii() + (rest?.to_string() ?? "")
        }
    }
}

fn main(): void {
    println("[${pad_start("42", 6, " ")}]")        // [    42]
    println("[${pad_start("1234567", 6, " ")}]")   // [1234567]
    println(capitalize("release notes"))            // Release notes
    println("[${capitalize("")}]")                  // []
}

Substitution is split followed by a rejoin. Nothing mutates a string in place, so every one of these returns a new value:

fn replace(text: string, needle: string, to: string): string {
    if needle.is_empty() { return text }
    mut out := ""
    mut first := true
    for piece in text.split(needle) {
        if !first { out.append_string(to) }
        out.append_string(piece)
        first = false
    }
    return out
}

fn to_snake_case(header: string): string {
    return replace(replace(header.trim().to_lower_ascii(), "-", "_"), " ", "_")
}

fn main(): void {
    println(replace("a-b-c", "-", "_"))          // a_b_c
    println(to_snake_case("  Content-Type  "))   // content_type
}

Building strings

Concatenation with + works, and append_string mutates a mut string binding in place. string.join and the list’s join/join_of are declared but do not lower, so the separator bookkeeping is a two-line loop:

fn join(parts: []string, separator: string): string {
    mut out := ""
    mut first := true
    for part in parts {
        if !first { out.append_string(separator) }
        out.append_string(part)
        first = false
    }
    return out
}

fn csv_row(fields: []string): string {
    mut out := ""
    for i, field in fields.zip_with_index() {
        if i > 0u32 { out.append_string(",") }
        out.append_string(field)
    }
    return out
}

fn main(): void {
    println(join(["a", "b", "c"], ", "))              // a, b, c
    println(csv_row(["id", "name", "email"]))         // id,name,email
    println(join([1, 2, 3].map(v => "#${v}"), " "))   // #1 #2 #3
}

When you are assembling large output byte by byte — a serializer, a protocol frame — build into a mut []byte instead and decode once at the end. See Bytes.

Conversion

From To Method Status
string int? / float? to_int, to_float lowers
string bool? to_bool declared, no lowering path
string []byte (owned copy) to_bytes lowers
string [..]byte (borrowed) bytes lowers
string List[char] chars, to_list declared, no lowering path
List[char] string string.from_chars declared, no lowering path
List[byte] string? string.from_bytes declared, no lowering path — use decode_utf8()

The parsers are total: malformed or out-of-range text yields None rather than a fallback or a hidden exception.

error SettingError { Invalid { key: string } }

fn read_int(raw: string, key: string): int ! SettingError {
    match raw.trim().to_int() {
        Some(v) => return v
        None => error Invalid { key: key }
    }
}

fn read_flag(raw: string): bool {
    normalized := raw.trim().to_lower_ascii()
    return normalized == "true" || normalized == "1" || normalized == "yes"
}

fn main(): void {
    match read_int(" 8080 ", "port") {
        Ok(v) => println("${v}")
        Err(e) => println("invalid")
    }
    match read_int("12abc", "port") {
        Ok(v) => println("${v}")
        Err(e) => println("invalid")
    }
    println("${read_flag(" YES ")} ${read_flag("off")}")
}

Bytes back to text goes through decode_utf8, which validates strictly — overlong encodings, surrogates, truncated sequences, stray continuation bytes, and values above U+10FFFF are all rejected:

error WireError { NotUtf8 }

fn decode_frame(payload: []byte): string ! WireError {
    match payload.decode_utf8() {
        Some(text) => return text
        None => error NotUtf8
    }
}

fn main(): void {
    payload := "hi".to_bytes()
    println("${payload.is_valid_utf8()}")
    match decode_frame(payload) {
        Ok(text) => println(text)
        Err(e) => println("not utf-8")
    }
}

bytes() borrows the string’s storage with no copy; to_bytes() allocates an owned list. Choose based on whether the result must outlive the string.

A worked example

A header-block parser: searching for the colon, byte slicing on either side of it, ASCII case normalization for the name, and a typed error for the malformed line.

struct Header { name: string, value: string }

error HeaderError { Malformed { line: string } }

fn parse_header(line: string): Header ! HeaderError {
    match line.index_of(":") {
        None => error Malformed { line: line }
        Some(colon) => {
            raw_name := line.slice(0u32..colon)?.to_string() ?? ""
            raw_value := line.slice(colon + 1u32..line.len().to_u32())?.to_string() ?? ""
            name := raw_name.trim().to_lower_ascii()
            if name.is_empty() { error Malformed { line: line } }
            return Header { name: name, value: raw_value.trim() }
        }
    }
}

fn parse_headers(block: string): []Header ! HeaderError {
    mut out: []Header = []
    for line in block.split("\n") {
        if line.trim().is_empty() { continue }
        out.add(parse_header(line)?)
    }
    return out
}

fn lookup(headers: []Header, name: string): string? {
    wanted := name.to_lower_ascii()
    return headers.find(h => h.name == wanted).map(h => h.value)
}

fn main(): void {
    block := "Content-Type: application/json\nX-Request-Id: abc123\n"
    match parse_headers(block) {
        Err(e) => println("malformed header block")
        Ok(headers) => {
            for h in headers {
                println("${h.name} = ${h.value}")
            }
            println("json? ${lookup(headers, "Content-Type") ?? "none"}")
        }
    }

    match parse_headers("no colon here\n") {
        Err(e) => println("malformed header block")
        Ok(headers) => println("${headers.len()}")
    }
}

Availability

These signatures are installed for semantic checking but have no lowering path in the wasm backend today. atoll check accepts a call to any of them; atoll build fails with ATOLL2004: builtin method ... has no lowering path.

Signature Working substitute
first(), last() char_at(0u32), char_at((length() - 1).to_u32())
take(n), take_last(n), drop(n), drop_last(n) slice(range) — but note it is byte-indexed where these are character-indexed
map(f), filter(f) for c in text appending into a mut string
fold(init, f), count(f), any(f), all(f), none(f) for c in text with an accumulator
enumerate() for c in text with a mut i
chars(), to_list() for c in text { out.add(c) }
lines() split("\n")
words() the whitespace-run loop shown above
split_with_limit(sep, n) index_of + slice, as in split_once
is_blank() trim().is_empty()
to_upper_case(), to_lower_case() to_upper_ascii(), to_lower_ascii() (ASCII only)
capitalize(), title_case() first-character slice + to_upper_ascii()
pad_start(w, c), pad_end(w, c) repeat + width arithmetic
replace(a, b), replace_first(a, b) split(a) + rejoin loop
reversed() materialize []char, walk backwards
equals_ignore_case, compare_to_ignore_case compare to_lower_ascii() on both sides
to_bool() match the trimmed, lowercased text
string.join(items, sep) the join loop shown above
string.from_chars(chars) append c.to_string() in a loop
string.from_bytes(bytes) bytes.decode_utf8()
char.to_upper_case(), char.to_lower_case() c.to_string().to_upper_ascii()

Length, byte access, char_at, UTF-8 conversion, search, trimming, split, repeat, ASCII case conversion, comparison, and hashing are all implementation-backed.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close