std.str
The complete UTF-8 string toolkit. Every operation is character-indexed, so it is correct for multi-byte text.
import std.str
str.title("the quick fox") # "The Quick Fox"
str.pad_start("7", 4, "0") # "0007"
str.substring("hello world", -5) # "world"
str.replace_first("a-b-c", "-", "/") # "a/b-c"
str.count("banana", "na") # 2
The most common operations are global built-ins already - upper, lower, trim, split, join, replace, contains, starts_with, ends_with, chars, lines, reverse, index_of, len. Import this module for the fuller set.
import std.str binds str, so the module's functions are str.capitalize(s) and so on, and the global string() conversion stays available alongside it.
Case
upper, lower, capitalize (first letter up, rest down), title (each word), swapcase, and eq_ignore_case(a, b).
eq_ignore_case compares Unicode lowercased forms. It does not perform full case folding, so German ß does not compare equal to ss.
Trim
str.trim(s) # whitespace
str.trim(s, "x") # any character in the set
str.trim("xxhixx", "x") # "hi"
str.trim_prefix(s, "www.") # a fixed affix, once
str.trim_suffix(s, ".com")
trim, trim_start, trim_end take an optional character set, not a substring - trim(s, "ab") strips any a or b from the ends.
Pad
str.pad_start(s, 8) # fill defaults to a space
str.pad_end(s, 8, ".")
str.center(s, 20, "-") # odd leftover goes right
str.zfill("-5", 4) # "-005"
zfill pads with zeros after any leading sign, which is the one case pad_start(s, w, "0") gets wrong. A multi-character fill cycles and truncates to the gap.
Extract
str.substring(s, 1, 4) # clamps
str.substring(s, -5) # negative from the end
str.char_at(s, 0) # errors out of range
substring clamps; char_at errors. Same asymmetry as slices versus indexing.
Code points
str.ord("A") # 65
str.ord("𝄞") # 119070 - a non-BMP character
str.chr(65) # "A"
Scalar values, not bytes, so the pair round-trips for any character. ord("") errors; chr errors on a negative value, above U+10FFFF, or in the surrogate range.
Search
contains, starts_with, ends_with, index_of and last_index_of (character offset, or -1), count(s, sub) (non-overlapping).
Split and join
str.split("a,b,c", ",", 1) # ["a", "b,c"] limit caps the splits
str.rsplit("a,b,c", ",", 1) # ["a,b", "c"] counted from the right
str.partition("k=v=w", "=") # ["k", "=", "v=w"]
str.rpartition("k=v=w", "=") # ["k=v", "=", "w"]
str.split_whitespace(s)
str.lines(s)
str.join(xs, ", ")
partition and rpartition split once and always return a 3-list, so a destructure never fails:
let (key, sep, value) = str.partition(line, "=")
With no match, partition keeps the string in the head and rpartition in the tail.
Character-class tests
True only if the string is non-empty and every character is in the class: is_digit (Unicode numeric), is_alpha, is_alnum, is_space, is_ascii.
is_upper and is_lower need at least one cased character and no cased character of the opposite case - so "A1!" is upper, and "123" is neither.
The empty string is false for every predicate, one uniform rule. For "empty or whitespace" use str.is_blank.
Unicode
str.normalize(s, "NFC") # one visible character, one spelling
str.category("é") # "Ll" - the Unicode general category
Unicode lets "é" be written two ways: U+00E9, or "e" followed by the combining acute U+0301. They render identically, compare unequal, and report different lengths. Which one arrives is not your program's choice - macOS filenames are decomposed, most of the web is composed, and a model returns whatever its tokenizer produced. Normalize before you compare, key a map, or hash.
Four forms, from UAX #15, and the name is not case-sensitive. NFC and NFD are canonical and round-trip - use NFC for what you store. NFKC and NFKD also fold presentation differences (the "fi" ligature becomes "fi", "②" becomes "2"), which is lossy on purpose - use those for search and matching, not for storage. ASCII is unchanged by all four.
category takes exactly one character and returns the two-letter abbreviation from the character database: "Lu", "Ll", "Nd", "Mn", "Zs". It pairs with NFD, which splits a marked letter into a base plus its marks - and the marks are exactly the characters whose category is "Mn":
fn strip_marks(s) {
str.chars(str.normalize(s, "NFD"))
|> filter(fn(c) {
str.category(c) != "Mn"
})
|> join("")
}
strip_marks("café naïve") # -> "cafe naive"
Info and convert
len, is_empty, is_blank, repeat(s, n), from(x).
From bytes
str.from_utf8(b) # strict decode, catchable
str.from_utf8_lossy(b) # opt-in replacement characters
See Bytes.