module strings

module lib/strings.mu

import "strings"

strings provides a collection of string manipulation functions.

Imports

Values

whitespace_chars#

whitespace_chars

whitespace_chars is the whitespace table trim and split use, filled on first use by ensure_whitespace_chars. It is a list of single characters, so a multi-byte entry such as U+00A0 is one element rather than two bytes.

Source lib/strings.mu:8
whitespace_chars := []

Functions

contains#

fn contains(text, segment)

contains checks whether segment appears in text.

Source lib/strings.mu:242
fn contains(text, segment) {
    found := index_of(text, segment)
    if is_error(found) {
        return found
    }
    return found >= 0
}

ends_with#

fn ends_with(text, suffix)

ends_with reports whether text concludes with suffix.

Source lib/strings.mu:271
fn ends_with(text, suffix) {
    if !typing.is_str(text) {
        return error("strings.ends_with expects string text, got " + type(text))
    }
    if !typing.is_str(suffix) {
        return error("strings.ends_with expects string suffix, got " + type(suffix))
    }
    value := text
    needle := suffix
    needle_len := len(needle)
    if needle_len == 0 {
        return true
    }
    total := len(value)
    if needle_len > total {
        return false
    }
    return ends_with_at(value, needle, total)
}

ends_with_at#

fn ends_with_at(text, suffix, end_index)

ends_with_at checks if suffix ends at end_index within text.

Source lib/strings.mu:79
fn ends_with_at(text, suffix, end_index) {
    suf_len := len(suffix)
    if suf_len == 0 {
        return false
    }
    if end_index < suf_len {
        return false
    }
    start := end_index - suf_len
    i := 0
    while i < suf_len {
        if text[start+i] != suffix[i] {
            return false
        }
        i = i + 1
    }
    return true
}

ensure_whitespace_chars#

fn ensure_whitespace_chars()

ensure_whitespace_chars lazily populates whitespace_chars before any checks run.

Source lib/strings.mu:11
fn ensure_whitespace_chars() {
    if len(whitespace_chars) != 0 {
        return
    }
    append(whitespace_chars, chr(9))
    append(whitespace_chars, chr(10))
    append(whitespace_chars, chr(11))
    append(whitespace_chars, chr(12))
    append(whitespace_chars, chr(13))
    append(whitespace_chars, " ")
    // Non-ASCII whitespace is appended as real UTF-8 via chr(): mu has no \u
    // escape, so "\u00a0" was never U+00A0 — it was the literal text u00a0,
    // which is_whitespace could never match against a single character.
    append(whitespace_chars, chr(0x0085))
    append(whitespace_chars, chr(0x00a0))
    append(whitespace_chars, chr(0x1680))
    append(whitespace_chars, chr(0x180e))
    append(whitespace_chars, chr(0x2000))
    append(whitespace_chars, chr(0x2001))
    append(whitespace_chars, chr(0x2002))
    append(whitespace_chars, chr(0x2003))
    append(whitespace_chars, chr(0x2004))
    append(whitespace_chars, chr(0x2005))
    append(whitespace_chars, chr(0x2006))
    append(whitespace_chars, chr(0x2007))
    append(whitespace_chars, chr(0x2008))
    append(whitespace_chars, chr(0x2009))
    append(whitespace_chars, chr(0x200a))
    append(whitespace_chars, chr(0x2028))
    append(whitespace_chars, chr(0x2029))
    append(whitespace_chars, chr(0x202f))
    append(whitespace_chars, chr(0x205f))
    append(whitespace_chars, chr(0x3000))
}

find_newline#

fn find_newline(text)

find_newline returns [index, width] for the first line ending in text, where width is 1 for a bare LF or CR and 2 for CRLF. It answers [-1, 0] when there is no complete line ending -- including a trailing CR, which cannot be classified until the next byte arrives, so an incremental reader must keep it and ask again.

Source lib/strings.mu:139
fn find_newline(text) {
    i := 0
    total := len(text)
    while i < total {
        ch := text[i]
        if ch == "\n" {
            if i > 0 && text[i-1] == "\r" {
                return [i-1, 2]
            }
            return [i, 1]
        }
        if ch == "\r" {
            if i == total-1 {
                return [-1, 0]
            }
            if text[i+1] == "\n" {
                return [i, 2]
            }
            return [i, 1]
        }
        i = i + 1
    }
    return [-1, 0]
}

index_of#

fn index_of(text, segment)

index_of returns the byte offset of the first occurrence of segment in text, or -1 when it does not occur. An empty segment is found at 0.

Source lib/strings.mu:198
fn index_of(text, segment) {
    if !typing.is_str(text) {
        return error("strings.index_of expects string text, got " + type(text))
    }
    if !typing.is_str(segment) {
        return error("strings.index_of expects string segment, got " + type(segment))
    }
    if len(segment) == 0 {
        return 0
    }
    limit := len(text) - len(segment)
    i := 0
    while i <= limit {
        if matches_at(text, segment, i) {
            return i
        }
        i = i + 1
    }
    return -1
}

is_alnum#

fn is_alnum(ch)

is_alnum reports whether ch is a single ASCII letter or digit.

Source lib/strings.mu:488
fn is_alnum(ch) {
    return is_alpha(ch) || is_digit(ch)
}

is_alpha#

fn is_alpha(ch)

is_alpha reports whether ch is a single ASCII letter.

Source lib/strings.mu:483
fn is_alpha(ch) {
    return _is_ascii_class(ch, "a", "z") || _is_ascii_class(ch, "A", "Z")
}

is_digit#

fn is_digit(ch)

is_digit reports whether ch is a single ASCII decimal digit.

Source lib/strings.mu:478
fn is_digit(ch) {
    return _is_ascii_class(ch, "0", "9")
}

is_whitespace#

fn is_whitespace(ch)

is_whitespace checks whether ch is recognized as a whitespace rune. ch is a whole character: a single byte for ASCII, or a complete multi-byte UTF-8 sequence (as rune_at yields). A lone continuation byte matches nothing.

Source lib/strings.mu:344
fn is_whitespace(ch) {
    ensure_whitespace_chars()
    i := 0
    while i < len(whitespace_chars) {
        if whitespace_chars[i] == ch {
            return true
        }
        i = i + 1
    }
    return false
}

join#

fn join(parts, sep)

join concatenates string parts using sep, enforcing string arguments.

It joins by halves rather than by accumulating left to right. Strings are immutable, so out = out + part copies everything joined so far on every step -- quadratic in the total length, which is ruinous for the thousands of chunks a whole-file read produces. Halving copies each byte log2(n) times instead.

Source lib/strings.mu:105
fn join(parts, sep) {
    if !typing.is_list(parts) {
        return error("strings.join expects list parts, got " + type(parts))
    }
    if !typing.is_str(sep) {
        return error("strings.join expects string sep, got " + type(sep))
    }
    i := 0
    while i < len(parts) {
        if !typing.is_str(parts[i]) {
            return error("strings.join expects all list elements to be strings, got " + type(parts[i]))
        }
        i = i + 1
    }
    return _join_range(parts, sep, 0, len(parts))
}

last_index_of#

fn last_index_of(text, segment)

last_index_of returns the byte offset of the LAST occurrence of segment in text, or -1 when it does not occur.

Source lib/strings.mu:221
fn last_index_of(text, segment) {
    if !typing.is_str(text) {
        return error("strings.last_index_of expects string text, got " + type(text))
    }
    if !typing.is_str(segment) {
        return error("strings.last_index_of expects string segment, got " + type(segment))
    }
    if len(segment) == 0 {
        return len(text)
    }
    i := len(text) - len(segment)
    while i >= 0 {
        if matches_at(text, segment, i) {
            return i
        }
        i = i - 1
    }
    return -1
}

lower#

fn lower(text)

lower returns text with every ASCII letter folded to lower case.

ASCII only, deliberately. mu strings are byte-indexed UTF-8, and case folding the rest of Unicode needs tables that do not belong in a module this size -- and would still be wrong for the locale-sensitive cases. Bytes outside A-Z are passed through untouched, so multi-byte sequences survive.

Source lib/strings.mu:448
fn lower(text) {
    return _map_ascii_case(text, "strings.lower", 65, 90, 32)
}

matches_at#

fn matches_at(text, segment, index)

matches_at reports whether segment matches text starting exactly at index.

Source lib/strings.mu:60
fn matches_at(text, segment, index) {
    seg_len := len(segment)
    if seg_len == 0 {
        return false
    }
    if index+seg_len > len(text) {
        return false
    }
    i := 0
    while i < seg_len {
        if text[index+i] != segment[i] {
            return false
        }
        i = i + 1
    }
    return true
}

pad_left#

fn pad_left(text, width, pad)

pad_left returns text padded on the LEFT with pad until it is width long, so the text ends up right-aligned. Text already that long is returned as it is -- padding never truncates.

Source lib/strings.mu:507
fn pad_left(text, width, pad) {
    return _pad(text, width, pad, "strings.pad_left", true)
}

pad_right#

fn pad_right(text, width, pad)

pad_right returns text padded on the RIGHT with pad until it is width long, so the text ends up left-aligned.

Source lib/strings.mu:513
fn pad_right(text, width, pad) {
    return _pad(text, width, pad, "strings.pad_right", false)
}

repeat#

fn repeat(text, count)

repeat returns text repeated count times (empty string when count<=0).

Source lib/strings.mu:292
fn repeat(text, count) {
    if !typing.is_str(text) {
        return error("strings.repeat expects string text, got " + type(text))
    }
    value := text
    if count <= 0 {
        return ""
    }
    result := ""
    i := 0
    while i < count {
        result = result + value
        i = i + 1
    }
    return result
}

replace#

fn replace(text, old, new)

replace substitutes every occurrence of old in text with new.

Source lib/strings.mu:310
fn replace(text, old, new) {
    if !typing.is_str(text) {
        return error("strings.replace expects string text, got " + type(text))
    }
    if !typing.is_str(old) {
        return error("strings.replace expects string old, got " + type(old))
    }
    value := text
    needle := old
    if len(needle) == 0 {
        return error("strings.replace old segment must not be empty")
    }
    if !typing.is_str(new) {
        return error("strings.replace expects string new, got " + type(new))
    }
    result := ""
    i := 0
    total := len(value)
    needle_len := len(needle)
    while i < total {
        if i+needle_len <= total && matches_at(value, needle, i) {
            result = result + new
            i = i + needle_len
            continue
        }
        result = result + value[i]
        i = i + 1
    }
    return result
}   

rune_at#

fn rune_at(text, i)

rune_at returns the whole character beginning at index i.

Source lib/strings.mu:396
fn rune_at(text, i) {
    w := rune_width(text, i)
    if i + w > len(text) {
        w = 1
    }
    return substring(text, i, i + w)
}

rune_start#

fn rune_start(text, end)

rune_start returns the index at which the UTF-8 sequence ending just before end begins, by walking back over continuation bytes (0x80-0xbf). It never steps back more than the three continuations a sequence can have, so malformed input still makes progress.

Source lib/strings.mu:381
fn rune_start(text, end) {
    i := end - 1
    steps := 0
    while i > 0 && steps < 3 {
        b := ord(text[i])
        if b < 0x80 || b >= 0xc0 {
            return i
        }
        i = i - 1
        steps = steps + 1
    }
    return i
}

rune_width#

fn rune_width(text, i)

rune_width returns the length in bytes of the UTF-8 sequence beginning at index i. Strings are byte-indexed, so scanning by rune means stepping by this width. A byte that is not a well-formed lead byte reports 1 so that callers always advance over malformed input instead of looping.

Source lib/strings.mu:360
fn rune_width(text, i) {
    b := ord(text[i])
    if b < 0x80 {
        return 1
    }
    if b >= 0xf0 && b <= 0xf7 {
        return 4
    }
    if b >= 0xe0 && b <= 0xef {
        return 3
    }
    if b >= 0xc0 && b <= 0xdf {
        return 2
    }
    return 1
}

split#

fn split(text, sep)

split divides text by sep, returning all substrings (sep must not be empty).

Source lib/strings.mu:165
fn split(text, sep) {
    if !typing.is_str(text) {
        return error("strings.split expects string text, got " + type(text))
    }
    if !typing.is_str(sep) {
        return error("strings.split expects string sep, got " + type(sep))
    }
    value := text
    separator := sep
    sep_len := len(separator)
    if sep_len == 0 {
        return error("strings.split expects a non-empty separator")
    }
    result := []
    current := ""
    i := 0
    total := len(value)
    while i < total {
        if i+sep_len <= total && matches_at(value, separator, i) {
            append(result, current)
            current = ""
            i = i + sep_len
            continue
        }
        current = current + value[i]
        i = i + 1
    }
    append(result, current)
    return result
}

starts_with#

fn starts_with(text, prefix)

starts_with reports whether text begins with prefix.

Source lib/strings.mu:251
fn starts_with(text, prefix) {
    if !typing.is_str(text) {
        return error("strings.starts_with expects string text, got " + type(text))
    }
    if !typing.is_str(prefix) {
        return error("strings.starts_with expects string prefix, got " + type(prefix))
    }
    value := text
    needle := prefix
    needle_len := len(needle)
    if needle_len == 0 {
        return true
    }
    if needle_len > len(value) {
        return false
    }
    return matches_at(value, needle, 0)
}

substring#

fn substring(text, start, end)

substring returns text[start:end] without extra checks so callers can compose safely.

Source lib/strings.mu:49
fn substring(text, start, end) {
    res := ""
    i := start
    while i < end {
        res = res + text[i]
        i = i + 1
    }
    return res
}

trim#

fn trim(text)

trim removes leading and trailing whitespace runes. It advances a whole UTF-8 sequence at a time: trimming byte by byte could only ever match the ASCII entries in the whitespace table, and could split a multi-byte character in half.

Source lib/strings.mu:408
fn trim(text) {
    if !typing.is_str(text) {
        return error("strings.trim expects string text, got " + type(text))
    }
    value := text
    length := len(value)
    start := 0
    end := length
    while start < end {
        w := rune_width(value, start)
        if start + w > end {
            w = 1
        }
        if !is_whitespace(substring(value, start, start + w)) {
            break
        }
        start = start + w
    }
    while end > start {
        s := rune_start(value, end)
        if s < start {
            s = start
        }
        if !is_whitespace(substring(value, s, end)) {
            break
        }
        end = s
    }
    if start >= end {
        return ""
    }
    return substring(value, start, end)
}

upper#

fn upper(text)

upper returns text with every ASCII letter folded to upper case. See lower for why this stops at ASCII.

Source lib/strings.mu:454
fn upper(text) {
    return _map_ascii_case(text, "strings.upper", 97, 122, -32)
}

Internal helpers

Underscore-prefixed names are implementation detail. They are listed so the module's source reads without surprises, not as API — they may change at any time.

_is_ascii_class(ch, low, high)_is_ascii_class reports whether ch is exactly one byte within [low, high].
_join_range(parts, sep, lo, hi)_join_range joins parts[lo:hi], which the caller has already type-checked.
_map_ascii_case(text, caller, low, high, delta)_map_ascii_case shifts every byte within [low, high] by delta.
_pad(text, width, pad, caller, on_left)_pad builds the filler once and puts it on the requested side.