Strings

String type

tape
let greeting: string = "Hello, world!";

In t1/t2, string is an owned, heap-allocated UTF-8 sequence.

A string literal doesn’t allocate. A string holding a literal points at the program’s own bytes, which are never freed, and copying it copies only that reference, so a literal stored in a variable, a field or a struct literal (t.value = "==", Token { value: "" }) costs no allocation. Text you build does allocate: concatenation, interpolation, and str.from_view give a string with a buffer of its own, freed when it goes out of scope. An interpolated string is one allocation: its numbers are written without one, and the buffer has room for every piece (a long string piece can make it grow).

String interpolation

tape
let name = "tape";
let version = 2;
let msg = "Welcome to {name} v{version}!";

Expressions inside {} are evaluated and converted to their string representation:

ValueBecomes
string, []const u8its text
integersdecimal (-42)
bool, comparisonstrue or false
an enum with descriptionsthe description (.desc())
any other enumthe variant name (.name())
f64, f32the shortest decimal that reads back as the same value
tape
enum IoError : u8 { NotFound = 1: "file not found"; }
let err = IoError.NotFound;
let ok = false;
let line = "error: {err}, retry: {ok}, code: {err as u8}";
// "error: file not found, retry: false, code: 1"

A float prints the fewest digits that read back as exactly the same value, with no trailing .0, and in scientific notation below 0.000001 and from 1e21 up. An f32 gets the shortest digits for an f32, which are often fewer than for the same value widened to f64:

tape
let x: f64 = 3.0;
let y: f64 = 0.1 + 0.2;
let z: f32 = 0.1;
let line = "{x} {y} {z} {z as f64} {1e21} {0.0000001}";
// "3 0.30000000000000004 0.1 0.10000000149011612 1e21 1e-7"

This is conv.fmt_f64 / conv.fmt_f32 with precision -1 (conv); for a fixed number of digits after the point, format with conv.fmt_f64(buf[..], x, 2) yourself.

Any expression works inside {}, string literals included, and those may interpolate in turn:

tape
let line = "fields: {str.join(names, ", ")}";
let tag = "<{label("id", "{n + 1}")}>";

A { in a string always starts an interpolation, including in a string inside {}; write \{ for a literal brace.

Escape sequences

EscapeMeaning
\nNewline
\tTab
\rCarriage return
\\Backslash
\"Double quote
\0Null byte
\xHHHex byte
\u{H..}A Unicode code point (1 to 6 hex digits), as its UTF-8 bytes: "\u{2026}" is …
\{Literal { (suppresses interpolation)
\}Literal }

Any other backslash is a compile error rather than a backslash kept: "C:\Users" does not compile, so write "C:\\Users" (or a raw string).

Use \{ and \} when you need literal braces alongside interpolation:

tape
let json = "\{\"name\": \"{name}\", \"age\": {age}\}";
// Output: {"name": "tape", "age": 25}

Triple-quoted strings

Triple-quoted strings can span multiple lines and contain " without escaping:

tape
let html = """
<div class="container">
    <p>Hello, {name}!</p>
</div>
""";

Escape sequences and interpolation are active inside triple-quoted strings.

A line break inside any string literal is \n, however the file ends its lines: a source file saved or checked out with Windows line endings (CRLF) gives the same strings as one with \n. To put a carriage return in a string, write \r.

Raw strings

Prefix triple quotes with r to disable all escape sequences and interpolation:

tape
let json = r"""{"name": "tape", "version": 2}""";

Raw strings can span multiple lines and contain quotes freely:

tape
let shader = r"""
#version 330 core
void main() {
    gl_Position = vec4(0.0);
}
""";

let config = r"""
{
    "host": "localhost",
    "port": 8080
}
""";

Rules:

  • No escape sequences — backslash is a literal character
  • No interpolation — braces are literal characters
  • Multi-line — each line break is \n, also in a file with CRLF line endings
  • Can contain " freely — ends only at """
  • Cannot contain the literal sequence """
  • Same type as regular strings (string in t1/t2, []const u8 in t0)

Strings as byte views

A string can be used wherever a []const u8 is expected: as an argument, an initializer, in an assignment, as a return value or as a struct field. The compiler takes a view of the string’s bytes (its pointer and length). Nothing is copied, and the string keeps ownership:

tape
fn count_spaces(s: []const u8) -> i64 {
    var n: i64 = 0;
    for (i in 0..s.len) {
        if (s[i] == 32) { n = n + 1; }
    }
    return n;
}

let greeting: string = "hello, " + name;
count_spaces(greeting);           // a view of greeting's bytes
count_spaces("a b c");            // a view of the literal
let v: []const u8 = greeting;

The view is taken at that moment. Assigning a new value to the string afterwards doesn’t change what an existing view sees, and the view must not outlive the string it came from.

It only works in this direction, and only for []const u8:

  • []u8 doesn’t accept a string; the bytes may be read-only (literals are).
  • A []const u8 doesn’t become a string by itself. Copying the bytes into a new owned string is an explicit call, or a concatenation (below).

Indexing a string

s[i] is byte i of s, a u8, bounds-checked in t1/t2 like a slice index. The bytes are read-only: s[i] = x is a compile error, and &s[i] is a *const u8 pointing at byte i of the string’s buffer. Like a view, that pointer must not outlive the string.

tape
let s: string = "ab" + "cd";
let p: *const u8 = &s[0];     // p[0] == 97, p[3] == 100

Slicing a string

s[a..b] is a []const u8 view of bytes a up to (not including) b of s. Nothing is copied. s[..b], s[a..] (to the end), s[a..=b] and s[..] work as for any slice, and the range is bounds-checked like any slice: in t1/t2 a range outside 0 <= a <= b <= s.len traps.

tape
let path: string = dir + "/" + file;
let ext: []const u8 = path[path.len - 4..];   // no copy
let head = "hello world"[..5];                // a []const u8

The view is always []const u8: a string’s bytes are read-only, so let v: []u8 = s[0..2]; (or passing a string to a []u8 parameter) is a compile error.

A slice is a view, so it must not outlive the string: don’t keep it after the string is reassigned or goes out of scope. For the same reason, in t1/t2 only a named string (a local, parameter, global or field) or a literal can be sliced. Slicing a temporary, such as a call result, a concatenation or an interpolation, is a compile error: bind it to a name first. In t0, where a string is itself a view, any string can be sliced.

The same holds for a view made from a whole string. A []const u8 local initialized with a temporary keeps it alive as long as itself:

tape
let v: []const u8 = make_name(id);    // fine: the string lives as long as v

Anywhere else, a view of a temporary would outlive it, and it is a compile error (“a temporary string would be freed while the view uses it”): assigning one to a view variable, field or global (v = make_name(id);), returning one from a function that returns []const u8, or putting one in a struct or fixed array literal of views that is stored or returned (let r = Ref { name: make_name(id) };, or through a call that returns a view of it: let r = Ref { name: str.trim(make_name(id)) };), or storing a view part of such a literal (let n = Ref { name: make_name(id) }.name;). Bind the string to a name first. Where the literal is only used within its statement, passed straight to a call (print_ref(Ref { name: make_name(id) })), called a method on (Ref { name: make_name(id) }.show()) or compared (if (Ref { name: make_name(id) }.name == "x")), the temporary lives until the statement ends and is freed there (a while condition’s, each time the condition is evaluated).

Concatenating with views

+ joins a string with a string or with a []const u8 on either side. The result is a new owned string:

tape
let name: []const u8 = path[0..4];
let a: string = "file: " + name;
let b: string = name + ".bak";

Two views don’t concatenate by themselves (v + w is an error): start from a string, "" + v + w, or use a str.Builder.

Nothing else joins with +: "n=" + n of a number (or a bool) is a compile error. Interpolate it instead, "n={n}".

Comparing text

== and != compare text by bytes, whatever holds it: a string with a string or a []const u8 view, and two views. Nothing is copied:

tape
for (word in str.split(line, " ")) {
    if (word == "cake") { found = true; }     // word is a []const u8
}

The ordering operators do not apply to text; bytes.compare(a, b) orders two views by their bytes.

Strings in t0

In t0 there is no heap, so string is not an owned string: it is another name for []const u8, a read-only view (pointer + length) into memory someone else owns. String literals work, and so does passing a t1 string to a t0 function that takes string or []const u8 — the caller keeps ownership:

tape
@profile(t0);

let msg: string = "Hello from kernel";      // same as []const u8
fn log(text: string) { /* reads text, never frees it */ }

Anything that would allocate a new string is a compile error in t0: concatenation (a + b), interpolation, and the copying string operations; == and != work on views as on strings (they compare bytes and copy nothing). A t0 function that returns string returns a view; a t1 caller receives it as []const u8, not as an owned string.

String operations

Searching, comparing, trimming and splitting work on views and never copy: they’re in bytes. Building a new string is str, and number formatting beyond "{x}" is fmt:

tape
import str from "str";
import bytes from "bytes";

let csv: string = str.join(fields, ",");         // fields: []const []const u8
let shout: string = str.to_upper("hello");       // "HELLO"
let line: string = str.pad_right(name, 12, 32);  // padded with spaces
let found: ?i64 = bytes.index_of(csv, ",");      // a view search, no copy

var b: str.Builder = str.builder();
b.append("total: ");
b.append_i64(42);
let report: string = b.finish();

str.from_view(v) copies a []const u8 into a new owned string. A NUL-terminated C string from a C library becomes a view with cstr.as_view(p); see cstr.

A string is not a C string: its bytes have no terminating NUL, so a string never converts to a pointer. To hand text to C, pass a string literal where a *const u8 is declared (it is NUL-terminated in read-only data), or make a NUL-terminated copy with cstr.from_view(s) and release it with cstr.free; see Linking.

Last modified: