Chapter 12: Strings, Interpolation, and Template Fences
What you'll learn: how Loup strings work, the string literal forms, the everyday string methods, interpolated strings with filters, and template fences — multi-line templates with {% if %} and {% for %} that produce HTML, CSS or plain text.
Scripts spend much of their time producing text: reports, log lines, configuration files, e-mails, web pages. This chapter covers the tools for that, and is explicit about one gap you must know before generating HTML: in Loup 1.25, template fences do not escape values for you.
Strings Are Immutable Unicode Text
A string is an immutable sequence of Unicode scalar values, stored as UTF-8. Methods that "change" a string return a new one:
@mode(host.scripted)
let file = "naïve-report.csv";
println(file.length()); # scalar values
println(file.byteLength()); # bytes of UTF-8
println(file[2]); # one scalar value, as a string
println(file[-3:]); # a slice: the last three
println(file.toUpperCase());
println(file); # unchanged
Listing 12-1: Length, indexing and slicing
16
17
ï
csv
NAÏVE-REPORT.CSV
naïve-report.csv
length() counts scalar values, so the two-byte ï counts once; byteLength() counts bytes. Indexing and slicing work as they do on lists (Chapter 5): a negative index counts from the end, and an index past the end throws IndexError.
Mistake: length without parentheses
length is a method, not a property:
@mode(host.scripted)
let word = "cat";
println(word.length);
main.lpscr:4:9: error[E0001]: type 'string' does not support member access
The message says that string has no fields at all — only methods. Call it: word.length().
Mistake: assigning to one character
Because strings are immutable, you cannot replace a character in place. Loup 1.25 does not reject this at compile time; the interpreter stops when it reaches the assignment:
@mode(host.scripted)
var word = "cat";
word[0] = "b";
println(word);
Uncaught RuntimeError: internal interpreter limitation: type error: SetIndex on non-indexable value: Str("cat")
Build a new string instead, from a replacement and a slice of the old one:
var word = "cat";
word = "b" + word[1:];
println(word);
bat
Writing String Literals
Strings are written in double or single quotes. Both forms process the same escape sequences:
let quote = "She said \"hello\"";
let other = 'It\'s fine';
let path = "C:\\Users\\ada";
let table = "name\tqty\nbolts\t4";
let face = "\u{1F600}";
println(quote);
println(other);
println(path);
println(table);
println(face);
She said "hello"
It's fine
C:\Users\ada
name qty
bolts 4
😀
| Escape | Meaning |
|---|---|
\n, \r, \t |
newline, carriage return, tab |
\\, \", \' |
backslash, double quote, single quote |
\0 |
the NUL character |
\u{…} |
a Unicode scalar value, 1–6 hex digits |
Mistake: a four-digit \u escape
Many languages write a Unicode escape as \u2603. Loup needs the braces:
@mode(host.scripted)
println("snowman: \u2603");
main.lpscr:3:19: error[E0004]: invalid escape sequence '\u' (expected \u{NNNN}, 1-6 hex digits)
Write "snowman: \u{2603}". (The Language Reference, §1-3.6, also lists the four-digit form; the 1.25 compiler accepts only the braced one.)
Raw strings
A raw string, prefixed with r, processes no escapes at all. It is the natural way to write Windows paths and regular expressions:
let docs = r"C:\Users\Alice\Documents";
let version = r"\d+\.\d+\.\d+";
println(docs);
println(version);
C:\Users\Alice\Documents
\d+\.\d+\.\d+
Platform note. Raw strings work in
loup-bootstrap(which isloupon macOS, Windows and Linux arm64). The self-hostedloupon Linux x86-64 does not accept therprefix yet; there, run the script withloup-bootstrap runor double the backslashes.
Multiline strings
Triple quotes start a string that spans lines. The indentation of the line holding the opening """ is the anchor: every content line must start with it, and the anchor is removed from each line. The closing """ goes on its own line, at the anchor, and the newlines right after the opener and right before the closer are not part of the string:
@mode(host.scripted)
func usage(tool: string): string {
let text = """
Usage: TOOL [--lines] FILE...
Counts the words in each FILE.
""";
return text.replace("TOOL", tool);
}
println(usage("wc"));
Listing 12-2: A help text kept at the function's indentation
Usage: wc [--lines] FILE...
Counts the words in each FILE.
Indentation deeper than the anchor is kept, as the second line shows. A content line that starts left of the anchor gets a warning and is taken as written (§1-14).
Characters and bytes
A char is one Unicode scalar value, written c"A". A bytes value is a sequence of raw bytes, written b"…", where \xHH gives one byte:
let grade: char = c"B";
println(grade.toInt());
println(char.of(67));
println(grade.isDigit());
let magic = b"\x89PNG";
println(magic.length());
println(magic.toHex());
println(b"hello".toString());
66
C
false
4
89504e47
hello
Mistake: converting a number to a char with as
@mode(host.scripted)
let code = 65;
let letter = code as char;
main.lpscr:4:14: error[E0001]: E_CHAR_CAST: `as` does not convert to or from `char` — use `char.of(n)`, `c.toInt()`, `char.fromString(s)` or `c.toString()` (§1-4.1.5)
The message lists the four conversion functions. Here the fix is let letter = char.of(code);.
Everyday String Methods
@mode(host.scripted)
let line = " Total: 42 items, 3 boxes ";
println(i"[{$ line.trim() $}]");
println(line.contains("items"));
println(line.trim().startsWith("Total"));
println(line.indexOf("items"));
println(line.indexOf("crates"));
println(line.replace("boxes", "crates"));
println(line.trim().split(", "));
println(line.substring(2, 7));
println("ab".repeat(3));
println(["2026", "10", "19"].join("-"));
println("apple" < "banana");
Listing 12-3: Searching, splitting and joining
[Total: 42 items, 3 boxes]
true
true
12
-1
Total: 42 items, 3 crates
["Total: 42 items", "3 boxes"]
Total
ababab
2026-10-19
true
| Method | Returns |
|---|---|
length(), byteLength(), isEmpty() |
sizes |
toUpperCase(), toLowerCase() |
a new string in the other case |
trim(), trimStart(), trimEnd() |
whitespace removed from both ends, the start, or the end |
contains(s), startsWith(s), endsWith(s) |
bool |
indexOf(s), lastIndexOf(s) |
the position, or -1 |
replace(from, to) |
every occurrence replaced |
split(sep) |
list[string] |
substring(start, end), slice(start, end), s[a:b] |
a piece |
chars() |
each scalar value as a one-character string |
repeat(n) |
the string n times |
toInt(), toFloat() |
the parsed number — 0 if the text is not a number; use parseInt (below) to tell |
The methods also answer to snake_case names (to_uppercase, starts_with, index_of, …). Strings compare with == by content and with < and > by code point.
The system.text module adds functions that are not methods, among them padStart, padEnd, capitalize, count, and parseInt/parseFloat, which return a Result instead of throwing:
from system.text import (padStart, padEnd, capitalize, count, parseInt);
println("[" + padStart("42", 6, " ") + "]");
println("[" + padEnd("name", 8, ".") + "]");
println(capitalize("hello world"));
println(count("banana", "an"));
println(parseInt("12").unwrap_or(0));
println(parseInt("12x").unwrap_or(0));
[ 42]
[name....]
Hello world
2
12
0
Building Strings
+ joins two strings, and only two strings:
@mode(host.scripted)
let count = 3;
println("You have " + count + " new messages");
main.lpscr:4:9: error[E_OPERATOR_MISSING]: binary `+` on a string requires a string operand, found `int`; there is no implicit conversion — use `.to_string()`
Loup never converts a number to text behind your back. Convert it with str() (or .to_string()), or — better — use an interpolated string:
let count = 3;
println("You have " + str(count) + " new messages");
println(i"You have {$ count $} new messages");
You have 3 new messages
You have 3 new messages
To build a string from many pieces, collect them in a list and call join once rather than adding to a string in a loop.
Interpolated Strings
An interpolated string starts with i and inserts the value of any expression written between {$ and $}:
@mode(host.scripted)
struct Point {
x: int;
y: int;
}
let user = "ada";
let unread = 3;
let alarm: Option[string] = None;
println(i"Hello, {$ user $}! You have {$ unread $} new messages.");
println(i"Status: {$ if (unread > 0) { "busy" } else { "idle" } $}");
println(i"Inbox: {$ ["one", "two"] $}, start {$ Point(x = 0, y = 4) $}");
println(i"Alarm: [{$ alarm $}]");
Listing 12-4: Interpolating values of several types
Hello, ada! You have 3 new messages.
Status: busy
Inbox: ["one", "two"], start Point(x: 0, y: 4)
Alarm: []
Values are rendered the same way println renders them (§1-16.4): numbers and strings as themselves, collections element by element, a struct or class instance as Name(field: value, …), Some(v) as v and None as nothing. An Err value is not rendered — it throws RenderError, so handle a Result before you print it (Chapter 10).
Mistake: forgetting the i, or the $
Interpolation happens only inside an i"…" string, and only between {$ and $}. Anything else is literal text, and no diagnostic warns you:
@mode(host.scripted)
let name = "Ada";
println("Hello, {$ name $}!");
println(i"Hello, {name}!");
Hello, {$ name $}!
Hello, {name}!
The first string has no i prefix; the second uses {name}, which is Python's and JavaScript's spelling, not Loup's. Both need the two parts together:
let name = "Ada";
println(i"Hello, {$ name $}!");
Hello, Ada!
Python's f"…" prefix does not exist either, and that one the parser does catch:
@mode(host.scripted)
let name = "Ada";
println(f"Hello, {name}!");
main.lpscr:4:10: error[E0001]: expected RParen, found StringLiteral { quote: 34, is_raw: false }
main.lpscr:4:10: error[E0003]: expected `;`
The parser read f as a variable name followed by a stray string literal.
Filters
A filter post-processes an interpolated value: {$ expr | filter $}. Filters chain from left to right:
let name = " ada lovelace ";
let tags = ["cli", "text"];
println(i"[{$ name | trim | title $}]");
println(i"[{$ name | trim | upper $}]");
println(i"[{$ tags | join(", ") $}]");
println(i"[{$ "a very long subject line" | truncate(10) $}]");
println(i"[{$ "" | default("n/a") $}]");
println(i"[{$ "<b>&" | escape $}]");
[Ada Lovelace]
[ADA LOVELACE]
[cli, text]
[a very lon…]
[n/a]
[<b>&]
The built-in filters (§1-16.4) are upper, lower, title, trim (alias strip), trim_start, trim_end, escape (alias escape_html), raw, join(sep), wrap(open, close), default(fallback), truncate(n), replace(from, to), reverse, length, and, for bytes, hex and base64.
Any function whose first parameter is a string can be used as a filter; further arguments go in parentheses. Listing 12-5 uses two to print an aligned table:
@mode(host.scripted)
from system.text import (padStart, padEnd);
func left(value: string, width: int): string {
return padEnd(value, width, " ");
}
func right(value: string, width: int): string {
return padStart(value, width, " ");
}
let stock = [("bolts", 1200), ("nuts", 85), ("washers", 7)];
println(i"{$ "item" | left(10) $}{$ "count" | right(6) $}");
for ((name, count) in stock) {
println(i"{$ name | left(10) $}{$ str(count) | right(6) $}");
}
Listing 12-5: User-defined filters that pad columns
item count
bolts 1200
nuts 85
washers 7
The Language Reference says a filter receives the rendered text of the value. In Loup 1.25 a user-defined filter receives the value itself, so {$ count | right(6) $} with an int is a type error (argument 1 of 'right': expected 'string', found 'int'); convert first, as Listing 12-5 does with str(count).
Mistake: a filter that does not exist
@mode(host.scripted)
println(i"{$ "release notes" | capitalize $}");
main.lpscr:3:32: error[E0001]: undefined identifier `capitalize`
A filter name is looked up like a function. capitalize is not a built-in filter (the built-in is title), and the system.text function of that name is not imported here. Use | title, or import capitalize from system.text and the same line compiles.
Formatting numbers
There are no format specifiers inside {$ … $}; numbers render in their plain form. Format them with ordinary functions. For money, keep an int count of cents and format it yourself:
from system.text import (padStart);
## 1999 -> "19.99"
func money(cents: int): string {
let dollars = cents // 100;
let rest = cents % 100;
return i"{$ dollars $}.{$ padStart(str(rest), 2, "0") $}";
}
println(i"Total: ${$ money(1999) $}");
println(i"Total: ${$ money(500) $}");
Total: $19.99
Total: $5.00
⚠ Planned. The Language Reference (§1-15, §1-16.4) also specifies trim flags (
{$- expr -$}), which remove the whitespace around an interpolation, and theRenderableinterface, which lets a class decide how it renders. Neither works in Loup 1.25: the trim flags fail to compile, andRenderContext, the parameter type ofRenderable.render, is undefined. Until they land, call a method of your own (i"{$ price.label() $}").
Template Fences
A template fence is a multi-line template written directly in Loup source. It opens with three backticks and a descriptor — the target language and the formatter — and closes with three backticks on a line of their own:
@mode(host.scripted)
func welcomeMail(name: string, plan: string, trialDays: int): string {
let mail = ```(text):(none)
Hello {$ name | title $},
Welcome to the {$ plan $} plan.
{% if (trialDays > 0) %}
Your trial ends in {$ trialDays $} days.
{% else %}
Your first invoice is attached.
{% end %}
-- The Billing Team
```;
return mail.render({});
}
println(welcomeMail("ada", "Team", 14));
Listing 12-6: A plain-text e-mail template
Hello Ada,
Welcome to the Team plan.
Your trial ends in 14 days.
-- The Billing Team
- The fence is an expression whose value is a
Template.render({})turns it into astring. {$ … $}and filters work as in interpolated strings. Names resolve in the surrounding code, so the fence sees the function's parameters.{% … %}lines are directives:{% if (…) %},{% elif (…) %},{% else %},{% for (x in xs) %},{% while (…) %}, each closed by{% end %}.{% let x = …; %},{% var x = …; %}and{% do x = …; %}run a statement without producing output.- The anchor rule of multiline strings applies: the indentation of the line holding the opening backticks is removed from every line.
- A line holding a directive leaves an empty line in the output, as the e-mail above shows. The Language Reference's trim markers for directives (
{%- … -%}) are not accepted by the 1.25 parser.
The descriptor names the language: (text), (string), (html) and (css) all work. (none) means "no formatter".
Mistake: printing the template instead of its text
@mode(host.scripted)
let greeting = ```(text):(none)
Hello!
```;
println(greeting);
__Template()
A fence is a Template value, not a string. Call render:
let greeting = ```(text):(none)
Hello!
```;
println(greeting.render({}));
Hello!
Mistake: passing the values to render
render takes a dictionary, so it is natural to expect the dictionary to supply the template's names. It does not; every name must exist where the fence is written:
@mode(host.scripted)
let row = ```(html):(none)
<li>{$ name $}</li>
```;
println(row.render({"name": "Widget"}));
main.lpscr:4:8: error[E0001]: undefined identifier `name`
To reuse a template with different values, put the fence in a function and make the values parameters, as welcomeMail does in Listing 12-6:
func row(name: string): string {
let t = ```(html):(none)
<li>{$ name $}</li>
```;
return t.render({});
}
println(row("Widget"));
println(row("Gadget"));
<li>Widget</li>
<li>Gadget</li>
Mistake: Markdown-style fence opener
The descriptor must be in parentheses. A Markdown habit — the language name straight after the backticks — compiles but does not do what you meant:
@mode(host.scripted)
let box = ```html
<div>hi</div>
```;
println(box.render({}));
<div />
Write ```(html):(none) as the opener.
HTML fences do not escape (yet)
The Language Reference (§1-16.6) says an html fence escapes every interpolated value for the place where it appears, so that a <script> tag in user input can never become markup. Loup 1.25 does not do this. Values are inserted exactly as they are:
@mode(host.scripted)
let comment = "<script>alert('hi')</script>";
let page = ```(html):(none)
<p class="comment">{$ comment $}</p>
```;
println(page.render({}));
<p class="comment"><script>alert('hi')</script></p>
Until auto-escaping lands, apply the escape filter to every value that did not come from your own code:
let comment = "<script>alert('hi')</script>";
let page = ```(html):(none)
<p class="comment">{$ comment | escape $}</p>
```;
println(page.render({}));
<p class="comment"><script>alert('hi')</script></p>
The same holds for css fences (§1-16.7): values are not escaped. Chapter 19 returns to this as a safety rule.
⚠ Planned. Also specified but not in Loup 1.25:
SafeHtmlandUrlwrapper types for trusted markup and checked URLs, the{% match %}directive,Template.concat, and fences that contain fences (a four-backtick opener currently fails witherror[E0004]: unterminated template fence).
A Worked Example: An Invoice Page
Listing 12-7 puts the pieces together: a struct for the data, a money formatter, and an html fence with a loop that escapes the one value a customer controls.
@mode(host.scripted)
from system.text import (padStart);
struct LineItem {
name: string;
qty: int;
cents: int;
}
func money(cents: int): string {
let rest = cents % 100;
return i"{$ cents // 100 $}.{$ padStart(str(rest), 2, "0") $}";
}
func invoiceHtml(customer: string, items: list[LineItem]): string {
var total = 0;
for (item in items) {
total = total + item.qty * item.cents;
}
let page = ```(html):(none)
<h1>Invoice for {$ customer | escape $}</h1>
<table>
{% for (item in items) %}
<tr><td>{$ item.name $}</td><td>{$ item.qty $}</td><td>{$ money(item.qty * item.cents) $}</td></tr>
{% end %}
</table>
<p>Total: {$ money(total) $}</p>
```;
return page.render({});
}
let items = [
LineItem(name = "Bolts (100)", qty = 2, cents = 450),
LineItem(name = "Hinges", qty = 4, cents = 1299),
];
println(invoiceHtml("Ada & Co <ada@example.com>", items));
Listing 12-7: An HTML invoice from a template fence
<h1>Invoice for Ada & Co <ada@example.com></h1>
<table>
<tr><td>Bolts (100)</td><td>2</td><td>9.00</td></tr>
<tr><td>Hinges</td><td>4</td><td>51.96</td></tr>
</table>
<p>Total: 60.96</p>
The item names come from the program's own catalogue, so they are inserted as they are; the customer name came from outside, so it goes through escape.
Summary
- A
stringis immutable UTF-8 text;length()counts scalar values andbyteLength()bytes. Index and slice it like a list; build a new string to "change" one. - Literals:
"…"or'…'with escapes (Unicode as\u{…}), rawr"…", multiline"""…"""anchored at the opener's indentation,c"x"for achar,b"…"forbytes. +joins strings only; convert numbers withstr()or use interpolation.i"…{$ expr $}…"interpolates any expression; without thei, or with{x}, the text is literal. Filters (| upper,| join(", "),| escape, your own functions) post-process values. There are no format specifiers.- A template fence
```(html):(none) … ```is aTemplate;render({})produces the text. Directives{% if %},{% for %},{% while %},{% let %}control it, and names come from the surrounding scope, not from therenderargument. - In Loup 1.25,
htmlandcssfences do not escape values: use| escapeon anything that came from outside.
Try It Yourself
- Write
slug(title: string): stringthat lower-cases a title, trims it and replaces spaces with-. Use it as a filter:i"/posts/{$ title | slug $}". - Print a table of three products with
leftandrightfrom Listing 12-5, adding a price column formatted withmoney. - Turn Listing 12-7 into a
host.dualprogram (invoice.loup) whosemain()reads the customer name fromruntime.args(). Pass"<b>Evil</b>"and check that it comes out escaped.