Loft strings can embed expressions inside {...} braces. A colon after the expression introduces a format specifier that controls width, alignment, precision, number base, and output style.
Basic interpolation
Any expression inside {...} is evaluated and converted to text.
struct Point { x: integer, y: integer }
A type that receives the PARTS of a format string. See the last section.
struct Query {
const parts: vector<text>,
const values: vector<text>,
}
lit gets the bytes you wrote in the source file.
fn lit(self: Query, s: text) { self.parts += [s]; }
hole_text gets an interpolated value. It is never added to parts.
fn hole_text(self: Query, v: text?) { self.values += [v ?? ""]; }
fn hole_int(self: Query, v: integer) { self.values += ["{v}"]; }
Show the two lists separately, so you can see what went where.
fn shape(self: Query) -> text {
out = "";
for q_part in self.parts { out += "<{q_part}>" }
for q_val in self.values { out += "[{q_val}]" }
return out;
}
fn main() {
name = "world";
assert("hello {name}" == "hello world", "basic interpolation");
assert("1 + 2 = {1 + 2}" == "1 + 2 = 3", "expression in braces");
Width and alignment
A number after : sets the minimum width. The value is padded with spaces to fill the width. {val:6} — right-aligned (default for numbers) {val:>6} — right-aligned (explicit) {val:<6} — left-aligned {val:^6} — centered For text, the default alignment is left. For numbers, right.
assert("{42:6}" == " 42", "default number align is right");
assert("{42:>6}" == " 42", "explicit right-align");
assert("{42:<6}" == "42 ", "left-align number");
assert("{42:^6}" == " 42 ", "center-align number");
s = "hi";
assert("{s:6}" == "hi ", "default text align is left");
assert("{s:>6}" == " hi", "right-align text");
assert("{s:^6}" == " hi ", "center-align text");
Zero padding
Prefix the width with 0 to pad with zeros instead of spaces.
assert("{7:03}" == "007", "zero-padded 3 digits");
assert("{42:05}" == "00042", "zero-padded 5 digits");
assert("{-1:04}" == "-001", "zero-padded negative: sign before zeros");
Signed format
+ forces a sign on positive numbers.
assert("{42:+}" == "+42", "explicit positive sign");
assert("{-42:+}" == "-42", "negative sign always shown");
assert("{0:+}" == "+0", "sign on zero");
Hexadecimal, binary, octal
:x for lowercase hex, :#x for hex with 0x prefix. :b for binary, :o for octal.
assert("{255:x}" == "ff", "hex lowercase");
assert("{255:#x}" == "0xff", "hex with prefix");
assert("{10:b}" == "1010", "binary");
assert("{8:o}" == "10", "octal");
Float precision
.N after the colon limits decimal places.
assert("{3.125:.1}" == "3.1", "1 decimal place");
assert("{3.125:.2}" == "3.12", "2 decimal places");
assert("{3.125:.0}" == "3", "0 decimal places");
assert("{0.0:.3}" == "0.000", "trailing zeros");
Width + precision
Combine width and precision: {val:W.P} where W is total width and P is decimal places.
assert("{3.125:8.2}" == " 3.12", "width 8 precision 2");
assert("{-3.125:8.2}" == " -3.12", "negative width+precision");
JSON format
:j serialises a struct or value as JSON. :j serialises a struct as JSON. Use it on struct values, not primitives. See the JSON documentation page for full details.
Vector format
Vectors are formatted as [a,b,c] by default. A format specifier inside a for loop applies to each element.
v = [1, 2, 3];
assert("{v}" == "[1,2,3]", "default vector format");
assert("{for fmt_n in 1..4 {fmt_n * 10}:04}" == "[0010,0020,0030]", "formatted vector elements");
Large integers and single-precision floats
integer is a 64-bit type, so even large values format like any other number; single-precision floats use the same specifiers too.
n = 1000000;
assert("{n}" == "1000000", "integer default");
assert("{n:>10}" == " 1000000", "integer right-aligned");
f = 1.5f;
assert("{f}" == "1.5", "single default");
Pretty-printing a struct
The :# specifier expands a struct across a spaced, readable layout instead of the compact default form.
p = Point { x: 1, y: 2 };
assert("{p}" == "{{x:1,y:2}}", "default struct format is compact");
assert("{p:#}" == "{{ x: 1, y: 2 }}", "`:#` pretty-prints with spaces");
Character format
c = 'A';
assert("{c}" == "A", "character default");
Boolean format
assert("{true}" == "true", "boolean true");
assert("{false}" == "false", "boolean false");
Building a value instead of text
Everything above joins the pieces into one text. When the type you assign to says so, the format string builds that type instead, and the type is told which bytes you wrote and which came from a value.
A type opts in by defining lit (for your bytes) and one hole_… method per value kind it accepts: hole_text, hole_int, hole_float, hole_single, hole_boolean, hole_character. There is no new syntax — the type you assign to decides — and plain text is unchanged.
who = "ada";
q: Query = "SELECT * FROM t WHERE name = {who}";
The text you wrote is in parts; the value is in values, never in the text. That separation is the point: a value cannot turn into syntax, because the only way into the text is lit, and lit only ever receives bytes from your source file.
assert(q.shape() == "<SELECT * FROM t WHERE name = >[ada]",
"the literal and the value stay apart");
The value can be anything at all, including text that would otherwise change the meaning of the statement. It is still just a value.
danger = "'; DROP TABLE t; --";
q2: Query = "WHERE name = {danger}";
assert(q2.shape() == "<WHERE name = >['; DROP TABLE t; --]",
"a value that looks like syntax is still a value");
Each kind goes to its own method, so the type sees the real type.
q3: Query = "id={7} name={who}";
assert(q3.shape() == "<id=>< name=>[7][ada]", "an integer hole calls hole_int");
The database clients use this, so a query needs no placeholders to count: q: SqlText = "SELECT id FROM users WHERE name = {name}"; db.db_rows(q)
}