The Loup Scripting Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: Variables and Data Types
  3. Chapter 3: Functions and Closures
  4. Chapter 4: Control Flow
  5. Chapter 5: Collections
  6. Chapter 6: Structs
  7. Chapter 7: Enums and Pattern Matching
  8. Chapter 8: Classes and Objects
  9. Chapter 9: Interfaces and Generics
  10. Chapter 10: Error Handling
  11. Chapter 11: Modules and Packages
  12. Chapter 12: Strings, Interpolation, and Template Fences
  13. Chapter 13: Concurrency
  14. Chapter 14: Reflection and Dynamic Compilation
  15. Chapter 15: Reactive UI
  16. Chapter 16: Testing
  17. Chapter 17: JavaScript Host Interop
  18. Chapter 18: Building Script Tools
  19. Chapter 19: Memory Safety and Resource Management

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:

This code does not compile
@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:

This code throws at run time
@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:

This code does not compile
@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 is loup on macOS, Windows and Linux arm64). The self-hosted loup on Linux x86-64 does not accept the r prefix yet; there, run the script with loup-bootstrap run or 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

This code does not compile
@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:

This code does not compile
@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:

This code does not do what it should
@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:

This code does not compile
@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]
[&lt;b&gt;&amp;]

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

This code does not compile
@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 the Renderable interface, which lets a class decide how it renders. Neither works in Loup 1.25: the trim flags fail to compile, and RenderContext, the parameter type of Renderable.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 a string.
  • {$ … $} 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

This code does not do what it should
@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:

This code does not compile
@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:

This code does not do what it should
@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:

This code does not do what it should
@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">&lt;script&gt;alert(&#x27;hi&#x27;)&lt;/script&gt;</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: SafeHtml and Url wrapper types for trusted markup and checked URLs, the {% match %} directive, Template.concat, and fences that contain fences (a four-backtick opener currently fails with error[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 &amp; Co &lt;ada@example.com&gt;</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 string is immutable UTF-8 text; length() counts scalar values and byteLength() bytes. Index and slice it like a list; build a new string to "change" one.
  • Literals: "…" or '…' with escapes (Unicode as \u{…}), raw r"…", multiline """…""" anchored at the opener's indentation, c"x" for a char, b"…" for bytes.
  • + joins strings only; convert numbers with str() or use interpolation.
  • i"…{$ expr $}…" interpolates any expression; without the i, 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 a Template; render({}) produces the text. Directives {% if %}, {% for %}, {% while %}, {% let %} control it, and names come from the surrounding scope, not from the render argument.
  • In Loup 1.25, html and css fences do not escape values: use | escape on anything that came from outside.

Try It Yourself

  1. Write slug(title: string): string that lower-cases a title, trims it and replaces spaces with -. Use it as a filter: i"/posts/{$ title | slug $}".
  2. Print a table of three products with left and right from Listing 12-5, adding a price column formatted with money.
  3. Turn Listing 12-7 into a host.dual program (invoice.loup) whose main() reads the customer name from runtime.args(). Pass "<b>Evil</b>" and check that it comes out escaped.