Skip to content

Strings

The String type, its operators, and the std.string method surface -- length, indexing, comparison, concatenation, slicing, iteration, searching, and parsing.

Last verified

String is an owned, move-only runtime type carrying a pointer and a byte length. It has operators, properties, and methods; all of them are ordinary std.string functions underneath, and every one of them needs import std.string.

prismio
import std.io
import std.string

fn main() -> Int {
    let greeting = "Hello, World"

    println(greeting.length)          // 12
    println(greeting[0])              // H
    println(greeting == "Hello, World")

    let shout = greeting.toUpper()
    println(shout)

    for c in greeting {
        if (c.isSpace) { println("space") }
    }
    return 0
}

Passing a result straight on

An owned result can be passed straight into another call. The compiler releases it after the call returns, or at the end of the enclosing block when the callee may hand back a view of it:

WrittenResult
println(text.trim())correct
println(text.split(',').length)correct — the Vec is released after length is read
println(text.stripPrefix("x") ?? "none")correct — the optional lives to the end of the block
x.trim().toUpper()correct — both results are released

Two shapes still leak, and binding the value with let fixes both:

  • a temporary evaluated after another call in the same expression, when the callee may hand back a view of it: n = a.size() + pick(f(), d).length, where pick returns one of its arguments (moving f() ahead of a.size() could reorder their effects, so the compiler does not);
  • a view of a temporary that is kept — pushed into a Vec or assigned to a variable outside the block. The value is kept alive rather than freed early, so this is a leak, never a stale read.

Every table below marks which entries allocate. Build with --verify and run the binary to check: the ledger line reads N allocated, N released, N leaked, N violation(s). Read violations before leaked — a leak costs bytes, a violation corrupts.

Operators

Five operators work on String. Each is rewritten during semantic analysis into the std.string call it means, so ownership, overload resolution, and code generation see an ordinary call — so + and [a..b] allocate, and are released, exactly as a method's result is.

OperatorMeansAllocates
a == b, a != bcontent equality, same answer as a.equals(b)no
a < b, a <= b, a > b, a >= bsign of a.compare(b), byte orderno
a + ba.concat(b)yes
s[i]s.charAt(i) — the byte at i, NUL out of rangeno
s[start..<end]s.slice(start, end) — stops before end, clampedyes
s[start..end]s.slice(start, end + 1) — includes end, clampedyes
prismio
import std.io
import std.string

fn main() -> Int {
    let path = "src/main.psm"

    if (path.endsWith(".psm")) { println("prismio source") }

    let name = path[4..<12]
    println(name)

    let label = "file: " + name
    println(label)

    if (name < "n") { println("sorts before n") }
    return 0
}

== compares content, not addresses

A String is a pointer, so a raw address comparison would quietly answer "not equal" for two equal strings. == is the content comparison; there is no way to spell the address one.

A chain of + is a single allocation

a + b + c becomes one a.concat(b, c), not two nested calls. This is a correctness measure rather than an optimisation: the intermediate of a nested concatenation is a value nothing names, so the pairwise form would leak once per +. Chains of up to six parts are supported; past that, build a Vec<String> and call join on it.

prismio
import std.io
import std.string

fn main() -> Int {
    let user = "ada"
    let host = "example.com"

    // One allocation, not two.
    let address = user + "@" + host
    println(address)
    return 0
}

+ requires both sides to be Strings. The language does not promote anything implicitly, so convert first — "n = " + count.toString().

Properties

These are declared prop, so they are read without parentheses — s.length, never s.length() (properties). A property never allocates; anything that does is a method and takes (), so s.trim is a compile error naming the fix.

PropertyTypeMeaning
s.lengthIntbyte length; a multi-byte UTF-8 character counts as its bytes
s.isEmptyBoollength is zero
s.isNotEmptyBoollength is not zero
s.isBlankBoolempty, or nothing but whitespace
s.first, s.lastCharfirst/last byte, NUL when empty

Iteration

for c in s yields each byte as a Char. The string is borrowed, not moved, so it is still usable afterwards.

prismio
import std.io
import std.string

fn main() -> Int {
    let text = "a1b2c3"
    let mut digits = 0

    for c in text {
        if (c.isDigit) { digits = digits + 1 }
    }

    println(digits)
    println(text.length)
    return 0
}

The collection has to be a name, not an expression. for line in text.lines() is refused, because the loop needs the collection more than once and the result of lines() must be bound anyway under the rule above:

prismio
import std.io
import std.string

fn main() -> Int {
    let report = "alpha\nbeta\ngamma\n"
    let lines = report.lines()

    for line in lines {
        println(line)
    }
    return 0
}

for (i, c) in s gives the index beside each byte, and for i in 0..<s.length remains available when only the index is wanted.

Access and conversion

MethodReturnsAllocates
s.charAt(i)Char — NUL out of rangeno
s.byteAt(i)Int — no upper-bound check, use inside an established boundno
s.get(i)Char? — none out of rangeno
s.clone()Stringyes
s.chars()Vec<Char>yes
s.bytes()Vec<Int>yes

s[i] and s.charAt(i) answer NUL rather than trapping, which is unambiguous because a Prismio String cannot contain a NUL. s.get(i) is the form that makes the absent case unavoidable.

Comparison

MethodReturns
a.equals(b)Bool — same as a == b
a.equalsIgnoreCase(b)Bool — equal once both are fully case-folded: "Straße" matches "STRASSE"
a.compare(b)Int — negative, zero, or positive, by byte value

Searching

None of these allocate.

MethodReturns
s.contains(needle)Bool — needle may be a String or a Char
s.startsWith(p), s.endsWith(p)Bool — String or Char
s.indexOf(needle)Int — first index, or -1
s.indexOf(needle, from)Int — first index at or after from, or -1
s.lastIndexOf(needle)Int — last index, or -1
s.find(needle)Int? — the same search without the sentinel
s.count(needle)Int — non-overlapping occurrences
s.matchesAt(needle, at)Bool — whether needle occurs exactly at at
prismio
import std.io
import std.string

fn main() -> Int {
    let line = "key = value"

    let at = line.indexOf('=')
    if (at < 0) { return 1 }

    let key = line[0..<at].trim()
    let value = line[at + 1..<line.length].trim()

    println(key)
    println(value)
    return 0
}

indexOf and find are the same search. -1 is the sentinel form and the default, and find is there for callers who would rather be made to handle the absent case: s.find(needle) ?? -1 is s.indexOf(needle).

Producing new strings

Every method here allocates and returns an owned String. Bind the result.

MethodMeaning
s.concat(other)same as a + b
s.substring(start, length)length-based; a range past the end is clamped
s.slice(start, end)end-exclusive; same as s[start..<end]
s.trim(), s.trimStart(), s.trimEnd()space, tab, newline, carriage return
s.trimChars(set)trim any byte appearing in set, from both ends
s.toUpper(), s.toLower()full Unicode case mapping; the result can be longer or shorter
s.capitalize()first character titlecased, the rest untouched
s.reverse()by byte
s.repeat(n)n copies
s.padStart(w, pad), s.padEnd(w, pad), s.padCenter(w, pad)pad to width w
s.replace(old, new)every occurrence
s.replaceFirst(old, new)the first occurrence
s.insert(at, other)at past the end appends
s.removeRange(start, length)clamped
s.stripPrefix(p), s.stripSuffix(p)String? — none when absent

substring takes a length and slice takes an end. Both exist because reading a range as a length is the mistake the pair is meant to prevent: s[a..<b] and s.slice(a, b) stop before b, and s[a..b] includes it.

replace does not rescan its own replacement, so replacing a with aa terminates.

Splitting and joining

MethodReturns
s.split(sep: Char)Vec<String>
s.split(sep: String)Vec<String>
s.splitWhitespace()Vec<String> — splits on runs, discards empty parts
s.lines()Vec<String>
parts.join(sep)String — the inverse, on a Vec<String> receiver

s.lines() is not s.split('\n'), and the two differences are the ones that bite when reading a file: a trailing newline ends the last line rather than starting an empty one, and a CRLF file does not leave a \r on every element.

prismio
import std.io
import std.string

fn main() -> Int {
    let csv = "ada,grace,alan"
    let names = csv.split(',')

    let rejoined = names.join(" and ")
    println(rejoined)
    return 0
}

Scanning with a closure

MethodReturns
s.countIf(f)Int — bytes satisfying f
s.indexWhere(f)Int — first index satisfying f, or -1
s.allChars(f), s.anyChars(f)Bool
prismio
import std.io
import std.string

fn main() -> Int {
    let token = "abc123"
    println(token.countIf(|c: Char| c.isDigit))
    println(token.allChars(|c: Char| c.isAlnum))
    return 0
}

There is no each: a closure's return type has no Void spelling yet, so |c: Char| println(c) does not typecheck. Use for c in s, which needs no closure at all.

Char

Char is a byte, not a Unicode scalar. Every predicate below is ASCII-only.

MethodReturns
c.isDigit, c.isAlpha, c.isAlnumBool (properties)
c.isLower, c.isUpper, c.isSpaceBool (properties)
c.digitValueInt (property)
c.toUpper(), c.toLower()Char
c.codeInt — the byte value
c.toString()String — allocates

Parsing and formatting

MethodReturns
s.parseInt()Int?
s.parseIntOr(fallback)Int
s.parseFloat()Float?
s.parseBool()Bool? — exactly "true"/"false", case-insensitively
s.parseInt(radix)Int? — base 2 through 36
s.parseI8(), parseI16(), parseI64(), parseIsize()I8?, I16?, I64?, Isize?, each with a (radix) form
s.parseU8(), parseU16(), parseU32(), parseU64(), parseUsize()U8? … Usize?, each with a (radix) form
n.toString()String — on every number type, Bool, Char and String; allocates past twelve bytes
n.toString(radix)String — on every integer type
n.toHex() / toOctal() / toBinary()String — on U64
f.toString(decimals)String — fixed-point, on Float

parseInt returns Int? rather than a number, which is the distinction a C atoi cannot make: it returns 0 for "0" and 0 for "banana". The ? is a scalar optional, a flag beside the value, so a parse allocates nothing. s as Int? is the same call, and n as String is n.toString(): see conversions.

prismio
import std.io
import std.string

fn main() -> Int {
    let port = "8080".parseIntOr(80)
    println(port)

    let maybe = "not a number".parseInt()
    if (maybe == none) { println("rejected") }
    println("300".parseU8())           // none: out of range
    println("255".parseU8())           // 255
    return 0
}

Every parse checks its range. "99999999999".parseInt() is none, not a wrapped number — a parse that reported success while producing a value the text does not say is the defect the optional exists to prevent, and overflow is that defect by another route. The same holds at the edges: I64's most negative value parses, and one past it does not.

parseFloat is correctly rounded, reads what strtod reads (decimal, hexadecimal such as 0x1p-3, inf, nan), and is stricter than C: the whole string must be a number, so "1.5kg" and " 1.5" are none. It is Prismio, not the C library, so it also works in a freestanding program.

Float text

prismio
import std.io
import std.string

fn main() -> Int {
    println(0.1 + 0.2)
    println(1.0 / 3.0)
    println((1.0 / 3.0).toString(2))
    println(2.5.toString(2))
    return 0
}
code
0.3
0.333333333333333
0.33
2.50

A Float prints as the shortest decimal that reads back as the same number, with at most fifteen significant digits. Fifteen is what a double always holds exactly, so every literal prints as written and 0.1 + 0.2 prints 0.3. The digits after that are rounding noise from binary arithmetic. A value that needs more than fifteen digits to be exact, such as 1.0 / 3.0, prints its first fifteen, so the text is not a lossless copy of every Float. print(f) and f.toString() always agree.

v.toString(decimals) is the presentation form for a column: a fixed number of decimals, trailing zeros kept.

Interpolation

prismio
import std.io
import std.string
import std.display

fn main() -> Int {
    let name = "world"
    let count = 42

    println("hello, ${name}!")
    println("${count} items, ${count + 1} after the next")
    return 0
}
code
hello, world!
42 items, 43 after the next

Any expression goes inside ${...}. Each value is converted with show, the Display trait's method — so a type of your own interpolates as soon as it implements Display, and import std.display is what an interpolated string needs beyond std.string.

\$ writes a literal ${. That is the one migration hazard: a string that contained ${ before this existed now interpolates, and \${ is the fix.

By the time anything but the parser sees it, "a${b}c" is concat("a", show(b), "c") — no format-string parsing at run time, no allocation the plain call would not have made, and a mistake inside ${...} underlines the expression you wrote.

Bytes and characters

length, charAt, substring and the operators that lower to them are byte indexed. That is what makes a scan one comparison per byte, and it is a trap for text that is not ASCII, because a byte index can fall inside a character. These are the second reading:

MethodReturns
s.isAsciiBool
s.isValidUtf8Bool — well-formed, so overlong forms and surrogates are refused
s.scalarCountInt — characters, as opposed to length's bytes
s.scalars()Vec<Int> — code points; an ill-formed byte becomes U+FFFD
s.scalarAt(byteIndex)Int — the code point there, or -1
s.scalarWidthAt(byteIndex)Int — 1 to 4, or 0
s.isCharBoundary(byteIndex)Bool — ask before substring
s.scalarSubstring(start, count)String — counted in characters, so it cannot cut one in half
strFromScalar(code)String — one code point as UTF-8
prismio
import std.io
import std.string

fn main() -> Int {
    let greeting = "héllo"
    println(greeting.length)
    println(greeting.scalarCount)
    println(greeting.reverse())
    println(greeting.scalarSubstring(1, 3))
    return 0
}
code
6
5
olléh
éll

reverse moves characters rather than bytes — reversing the bytes of that string would put half of an é where a character belongs, which is not a different string but an invalid one. padStart, padEnd and padCenter count characters for the same reason: a column with one accented name in it lined up a character short when they counted bytes.

A width here is still not a display width. An East Asian character occupies two terminal columns and a combining mark none, which needs tables this library does not carry.

Methods are the interface

Every operation on this page is a method or an operator. The str* and char* free functions that earlier versions exposed — strTrim(s), strFromInt(n), charIsDigit(c) — are internal to the standard library now, and calling one reports that it is:

prismio
import std.string

fn main() -> Int {
    return strTrim("  x ").length
}

error: `strTrim` is internal to the package that declares it — write " x ".trim(). The number conversions are methods on the number: n.toString(), n.toString(16), n.toHex(), f.toString(2). The character predicates are methods on Char: c.isDigit, c.toUpper(), c.digitValue.

Three free functions remain, because none of them has a receiver to hang on: join(parts, sep) (also written parts.join(sep)), strFromScalar(code), and, in std.unicode, scalarWidth(code).

A program that defines its own trim still reaches the library's with the module path: std.string.trim(s). An unqualified call prefers the program's own function, and the operators always mean the library's.

Building text from pieces

Joining text a piece at a time needs nothing special when the text is in a local variable:

prismio
let mut out = ""
for word in words { out = out + word + " " }

That loop is linear. The compiler appends in place when an assignment replaces the variable it reads, and doubles the capacity when it runs out: 2,000 appends of ten bytes cost 13 allocations, not 2,000.

Through a struct field the same line is not linear, and it leaks. log.text = log.text + piece copies the whole text on every append, and assigning a field does not release the value it replaces. Measured with 200 appends through an inout parameter, it read 201 allocated, 1 released, 200 leaked. At 320,000 appends it was killed at 13.8 GB.

Keep a StringBuilder in the field instead. It holds the pieces and joins them once, when you ask for the text:

prismio
import std.io
import std.string

struct Report {
    title: String,
    body: StringBuilder
}

fn addRow(inout r: Report, name: String, score: Int) {
    r.body.append(name)
    r.body.append(": ")
    r.body.appendLine(score.toString())
}

fn main() -> Int {
    let mut r = Report { title: "Scores", body: StringBuilder.new() }
    addRow(r, "ada", 36)
    addRow(r, "grace", 85)
    println(r.title)
    print(r.body.toString())        // ada: 36, then grace: 85, each on its own line
    println(r.body.length)          // 18
    return 0
}
On a StringBuilderDoes
StringBuilder.new()an empty builder
b.append(piece)adds a copy of piece
b.appendLine(piece)adds piece and a newline
b.length, b.isEmptythe text's length in bytes so far, and whether it is empty; properties
b.toString()the text so far, as a new String; the builder keeps its pieces and can go on growing

append and appendLine change the builder, so a function that appends to one it was given takes it inout, as addRow does. 640,000 appends take 73 ms and release every allocation.

Which to use: a local s = s + x for text built in one function, and a StringBuilder for text kept in a struct or grown across calls. Both are linear.

Case

toUpper, toLower, capitalize and equalsIgnoreCase follow Unicode's default, locale-independent case operations (The Unicode Standard, section 3.13), from the Unicode 18.0.0 data the compiler pins. A mapping can change the length of a string, and one rule depends on the text around a letter:

prismio
import std.io
import std.string

fn main() -> Int {
    println("straße".toUpper())                 // STRASSE
    println("ΟΔΥΣΣΕΥΣ".toLower())               // οδυσσευς: a final Σ is ς
    println("džemal".capitalize())               // Džemal: titlecase, not uppercase
    println("fine".toUpper())                    // FINE
    println("Straße".equalsIgnoreCase("STRASSE"))  // true
    println("café".equalsIgnoreCase("cafe"))       // false: é is not e
    return 0
}

equalsIgnoreCase is caseless matching, not canonical equivalence: é written as one scalar and as e plus a combining accent are different strings to it. std.unicode's equalsNormalized answers that question.

A byte that is not UTF-8 is kept as it is, so case mapping never mangles text it could not read. Methods on Char stay ASCII: a Char is a byte.

Performance

std.string is native Prismio down to three compiler primitives — read a byte, write a byte, allocate the buffer.

String carries its byte length, so s.length is a field read rather than a scan. On an Apple-silicon benchmark, removing the old strlen call took the uppercase workload from 0.520s to 0.271s, level with C at 0.272s. Substring search uses Crochemore-Perrin Two-Way with a SIMD candidate prefilter; measured against pure C and pure Rust programs it is the fastest of the four on every search workload.

Every producing function allocates its result once and fills it in a single pass, so each is linear in the length of its output.

Case mapping takes the cheapest pass that can finish. ASCII text is one vectorised pass; text whose letters map to letters of the same UTF-8 width -- most Latin, Greek and Cyrillic -- is one pass through a two-stage lookup table; only a mapping that changes width (ı to I) sends the rest of the string through a measuring pass. Over 828 KB of mixed Cyrillic, Greek and accented Latin on Apple silicon, toUpper takes 1.9 ms and toLower 1.7 ms, against 3.1 ms and 2.6 ms for Rust's to_uppercase and to_lowercase; equalsIgnoreCase compares ASCII sixty-four bytes at a time and allocates nothing.

Not implemented

A regex module and locale-aware collation.

Language-specific case rules: Turkish and Azeri dotted and dotless i, and the Lithuanian rules for accented i. They depend on the text's language, which a String does not carry, so the default mappings apply — "I".toLower() is "i".

Grapheme clusters, terminal width and NFC/NFD normalization are in std.unicode rather than here, because they carry 100 KB of generated tables that a program doing none of it should not build.

FFI caution

A Prismio String is a pointer-and-length pair and should not be assumed to match an arbitrary C char * contract. Declare borrow, consume, retain, alias, or produce(free_fn) accurately, and write a C adapter when terminators, encoding, allocation, or lifetime differ.