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.

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.