March Docs

String

String module: functions for working with UTF-8 encoded strings.

March strings are immutable UTF-8 byte sequences. Every string is a refcounted heap allocation with a 24-byte header — there is NO small-string optimisation, so even a 3-byte string costs a malloc. (This comment previously claimed a fat-pointer representation with 15-byte inline storage. That was never true; see specs/2026-07-26-string-performance-profile.md for what the representation actually costs, measured.)

Key naming conventions:

  • Functions with byte_ in their name operate on raw byte offsets (O(1)).
  • codepoint_count counts Unicode codepoints (NOT user-perceived
graphemes — combining characters and emoji clusters count as multiple
codepoints).  `grapheme_count` is a legacy ALIAS for it and counts
codepoints too, despite the name: `"e" ++ combining-acute` is one grapheme
but both functions return 2, and a ZWJ emoji sequence is one grapheme but
both return 3.  Prefer `codepoint_count`, whose name matches what it does.
  • Use byte_size for performance-critical paths.

Functions

fnbyte_sizebyte_size(s) do string_byte_length(s) end#

Return the number of bytes in the UTF-8 encoding of s.

march> String.byte_size("hello")
5
march> String.byte_size("é")  -- 2-byte UTF-8 sequence
2
march> String.byte_size("")
0

Cost: O(1) — reads a stored length field.

fncodepoint_countcodepoint_count(s) do string_grapheme_count(s) end#

Count the number of Unicode codepoints in the UTF-8 string s.

For ASCII strings this equals the number of visible characters. For strings with multi-byte UTF-8 sequences, this counts codepoints, NOT grapheme clusters — combining marks (á = a + ◌́), emoji modifiers, and ZWJ sequences each count as multiple codepoints.

march> String.codepoint_count("hello")
5
march> String.codepoint_count("é")  -- NFC: one codepoint, two bytes
1
march> String.codepoint_count("e" ++ Option.unwrap(String.from_codepoint(769)))  -- NFD: e + combining acute
2
march> String.codepoint_count("")
0

Use a dedicated Unicode library when you need true grapheme cluster segmentation (for cursor-position arithmetic in a text editor, for example).

Cost: O(n) — scans the byte sequence.

fnconcatconcat(a, b) do string_concat(a, b) end#

Concatenate two strings.

march> String.concat("foo", "bar")
"foobar"

Prefer the ++ operator for inline concatenation. Use IOList when building many string segments to avoid O(n²) copying.

Cost: O(|a| + |b|) — allocates a new string.

fncontainscontains(s, substring) do string_contains(s, substring) end#

Return true if substring appears anywhere in s.

march> String.contains("hello world", "world")
true
march> String.contains("hello world", "xyz")
false
march> String.contains("abc", "")  -- empty is always found
true

Cost: O(n * m) worst case, where n = byte_size(s) and m = byte_size(substring).

fnends_withends_with(s, suffix) do string_ends_with(s, suffix) end#

Return true if s ends with suffix.

march> String.ends_with("hello", "lo")
true
march> String.ends_with("hello", "he")
false
march> String.ends_with("hello", "")
true

Cost: O(|suffix|).

fnfrom_codepointfrom_codepoint(cp : Int) : Option(String)#

Encode a Unicode codepoint as a UTF-8 string. Returns Some(string) if the codepoint is valid, None otherwise.

Valid codepoints are in the range 0x0 to 0x10FFFF, excluding surrogate pairs (0xD800 to 0xDFFF).

march> String.from_codepoint(65)  -- U+0041
Some("A")
march> String.from_codepoint(233) == Some("é")  -- U+00E9
true
march> String.from_codepoint(128512) == Some("😀")  -- U+1F600
true
march> String.from_codepoint(-1)  -- out of range
None
march> String.from_codepoint(55296)  -- U+D800, a surrogate
None

Cost: O(1).

fnfrom_floatfrom_float(f) do float_to_string(f) end#

Convert a float to a string using OCaml's default float formatting.

march> String.from_float(3.14)
"3.14"
march> String.from_float(1.0)
"1."
march> String.from_float(10000000000.0)
"10000000000."

The exact format matches OCaml's string_of_float. Cost: O(1).

fnfrom_intfrom_int(n) do int_to_string(n) end#

Convert an integer to its decimal string representation.

march> String.from_int(42)
"42"
march> String.from_int(-7)
"-7"
march> String.from_int(0)
"0"

Cost: O(digits).

fngrapheme_countgrapheme_count(s) do codepoint_count(s) end#

Legacy alias for codepoint_count. Despite the name, this function counts codepoints, not grapheme clusters; new code should call codepoint_count directly. Retained for backward compatibility.

march> String.grapheme_count("héllo")
5
march> String.grapheme_count("e" ++ Option.unwrap(String.from_codepoint(769)))  -- one grapheme, two codepoints
2
fnindex_ofindex_of(s, substring) do string_index_of(s, substring) end#

Find the byte offset of the first occurrence of substring in s. Returns Some(offset) if found, None if not found.

march> String.index_of("hello", "ll")
Some(2)
march> String.index_of("hello", "xyz")
None
march> String.index_of("hello", "")
Some(0)

Cost: O(n * m) worst case.

fnindex_of_fromindex_of_from(s, substring, start)#

Find the first occurrence of substring at or after byte offset start.

The result is an index into s itself, not relative to start, so it can be fed straight back in as the next start when tokenizing.

Prefer this over slicing off the tail and searching again: the tail form re-copies the remaining bytes on every step, making a full tokenize O(n²) in bytes copied.

march> String.index_of_from("a,b,c", ",", 0)
Some(1)
march> String.index_of_from("a,b,c", ",", 2)
Some(3)
march> String.index_of_from("a,b,c", ",", 4)
None

A negative start is treated as 0; a start past the end returns None; an empty substring matches at start.

Cost: O((n - start) * m) worst case.

fnis_emptyis_empty(s) do string_is_empty(s) end#

Return true if s contains no bytes.

march> String.is_empty("")
true
march> String.is_empty("x")
false
march> String.is_empty("  ")
false

Cost: O(1).

fnjoinjoin(xs, sep) do string_join(xs, sep) end#

Join a list of strings with a separator inserted between each element.

march> String.join(["a", "b", "c"], "-")
"a-b-c"
march> String.join([], ",")
""
march> String.join(["solo"], ",")
"solo"

Cost: O(total bytes in list + (n-1) * |sep|).

fnlast_index_oflast_index_of(s, sub) do string_last_index_of(s, sub) end#

Find the byte offset of the last occurrence of substring in s. Returns Some(offset) if found, None if not found.

march> String.last_index_of("hello.world.txt", ".")
Some(11)
march> String.last_index_of("hello", "xyz")
None
march> String.last_index_of("hello", "")
Some(5)

Cost: O(n * m) worst case.

fnpad_leftpad_left(s, width, fill) do string_pad_left(s, width, fill) end#

Pad s on the left with fill until the byte length reaches width. If byte_size(s) >= width, s is returned unchanged.

march> String.pad_left("hi", 5, "0")
"000hi"
march> String.pad_left("hello", 3, "0")  -- already long enough
"hello"

fill must be a single-byte string (single ASCII character).

Cost: O(width).

fnpad_rightpad_right(s, width, fill) do string_pad_right(s, width, fill) end#

Pad s on the right with fill until the byte length reaches width. If byte_size(s) >= width, s is returned unchanged.

march> String.pad_right("hi", 5, ".")
"hi..."
march> String.pad_right("hello", 3, ".")
"hello"

fill must be a single-byte string (single ASCII character).

Cost: O(width).

fnrepeatrepeat(s, n) do string_repeat(s, n) end#

Repeat s exactly n times.

march> String.repeat("ab", 3)
"ababab"
march> String.repeat("x", 0)
""
march> String.repeat("x", 1)
"x"

Cost: O(n * |s|) — allocates a new string.

fnreplacereplace(s, old, new) do string_replace(s, old, new) end#

Replace the first occurrence of old in s with new.

march> String.replace("aabbaa", "a", "x")
"xabbaa"
march> String.replace("hello", "xyz", "!")  -- no match
"hello"

If old is the empty string, s is returned unchanged. See replace_all to replace every occurrence.

Cost: O(n) scan + O(n) for the result string.

fnreplace_allreplace_all(s, old, new) do string_replace_all(s, old, new) end#

Replace every non-overlapping occurrence of old in s with new.

march> String.replace_all("aabbaa", "a", "x")
"xxbbxx"
march> String.replace_all("hello", "l", "r")
"herro"

If old is the empty string, s is returned unchanged.

Cost: O(n) where n = byte_size(s).

fnreversereverse(s) do string_reverse(s) end#

Return s with its bytes reversed.

march> String.reverse("hello")
"olleh"
march> String.reverse("racecar")
"racecar"

Note: byte-reversal is not the same as grapheme-reversal for multi-byte UTF-8 codepoints. This function is primarily useful for ASCII strings.

Cost: O(n).

fnsliceslice(s, start, len) do string_slice(s, start, len) end#

Alias for slice_bytes: extracts len bytes starting at byte offset start, with the same clamping. Offsets are bytes, not codepoints, so a slice can split a multi-byte character.

march> String.slice("hello world", 0, 5)
"hello"
march> String.slice("hello", 3, 100)
"lo"
fnslice_bytesslice_bytes(s, start, len) do string_slice(s, start, len) end#

Return a byte-indexed substring of s starting at byte offset start with length len bytes.

march> String.slice_bytes("hello world", 6, 5)
"world"
march> String.slice_bytes("hello", 0, 100)  -- clamped
"hello"

Both start and len are clamped to valid byte ranges; no exception is raised for out-of-bounds values. The returned string is a copy (the interpreter does not yet implement GC slices).

Cost: O(len) for the copy.

fnsplitsplit(s, sep) do string_split(s, sep) end#

Split s on every occurrence of sep, returning a List(String).

march> String.split("a,b,c", ",")
["a", "b", "c"]
march> String.split("abc", "")  -- empty sep: one string per byte
["a", "b", "c"]
march> String.split("aXbXc", "X")
["a", "b", "c"]

Adjacent separators produce empty strings in the result list.

An empty sep splits into single bytes, not characters: ASCII text comes apart one character per string, but a multi-byte UTF-8 character is cut into invalid pieces (String.split("é", "") gives two one-byte strings). To work character by character, use String.to_codepoints.

Cost: O(n) where n = byte_size(s).

fnsplit_firstsplit_first(s, sep) do string_split_first(s, sep) end#

Split s on the first occurrence of sep. Returns Some((head, tail)) if sep is found, or None if not found.

march> String.split_first("a:b:c", ":")
Some(("a", "b:c"))
march> String.split_first("hello", ":")
None
march> String.split_first("key=val", "=")
Some(("key", "val"))

Useful for parsing key-value pairs or protocols where only the first delimiter matters.

Cost: O(n) scan.

fnstarts_withstarts_with(s, prefix) do string_starts_with(s, prefix) end#

Return true if s begins with prefix.

march> String.starts_with("hello", "he")
true
march> String.starts_with("hello", "lo")
false
march> String.starts_with("hello", "")
true

Cost: O(|prefix|).

fnto_codepointsto_codepoints(s : String) : List(Int)#

Extract all Unicode codepoints from a string as an Int list. Each codepoint is yielded as a separate integer in the range 0x0 to 0x10FFFF.

Returns a list ordered left-to-right. Invalid UTF-8 sequences are treated as individual byte values (0x0 to 0xFF); well-formed March strings will not encounter this case.

march> String.to_codepoints("A")
[65]
march> String.to_codepoints("é")
[233]
march> String.to_codepoints("AB")
[65, 66]
march> String.to_codepoints("😀")
[128512]

Cost: O(n) where n is the byte length of the string.

fnto_floatto_float(s)#

Parse s as a floating-point number. Returns Ok(f) on success, Err(msg) if the string is not a valid float.

march> String.to_float("3.14")
Ok(3.14)
march> String.to_float("1e10") == Ok(10000000000.0)
true
march> String.to_float("abc")
Err("not a valid float: abc")

Cost: O(n).

fnto_intto_int(s)#

Parse s as a decimal integer. Returns Ok(n) on success, Err(msg) if the string is not a valid integer.

march> String.to_int("42")
Ok(42)
march> String.to_int("-7")
Ok(-7)
march> String.to_int("abc")
Err("not a valid integer: abc")

Cost: O(n).

fnto_lowercaseto_lowercase(s) do string_to_lowercase(s) end#

Convert all ASCII letters in s to lowercase.

march> String.to_lowercase("HELLO WORLD")
"hello world"

Only ASCII letters are affected.

Cost: O(n).

fnto_uppercaseto_uppercase(s) do string_to_uppercase(s) end#

Convert all ASCII letters in s to uppercase.

march> String.to_uppercase("Hello World")
"HELLO WORLD"

Only ASCII letters (A–Z, a–z) are affected. For full Unicode case mapping use an external library.

Cost: O(n).

fntrimtrim(s) do string_trim(s) end#

Remove leading and trailing ASCII whitespace (spaces, tabs, newlines, CR).

march> String.trim("  hello  ")
"hello"
march> String.trim("\thello\n")
"hello"
march> String.trim("hello")
"hello"

Cost: O(n).

fntrim_endtrim_end(s) do string_trim_end(s) end#

Remove trailing ASCII whitespace only.

march> String.trim_end("  hello  ")
"  hello"

Cost: O(n).

fntrim_starttrim_start(s) do string_trim_start(s) end#

Remove leading ASCII whitespace only.

march> String.trim_start("  hello  ")
"hello  "

Cost: O(n).