# 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:

```loup
@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*

```text
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:

```loup,does_not_compile
@mode(host.scripted)

let word = "cat";
println(word.length);
```

```text
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:

```loup,throws
@mode(host.scripted)

var word = "cat";
word[0] = "b";
println(word);
```

```text
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:

```loup
#~ @mode(host.scripted)
var word = "cat";
word = "b" + word[1:];
println(word);
```

```text
bat
```

## Writing String Literals

Strings are written in double or single quotes. Both forms process the same escape sequences:

```loup
#~ @mode(host.scripted)
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);
```

```text
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:

```loup,does_not_compile
@mode(host.scripted)

println("snowman: \u2603");
```

```text
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:

```loup
#~ @mode(host.scripted)
let docs = r"C:\Users\Alice\Documents";
let version = r"\d+\.\d+\.\d+";
println(docs);
println(version);
```

```text
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:

```loup
@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*

```text
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:

```loup
#~ @mode(host.scripted)
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());
```

```text
66
C
false
4
89504e47
hello
```

### Mistake: converting a number to a `char` with `as`

```loup,does_not_compile
@mode(host.scripted)

let code = 65;
let letter = code as char;
```

```text
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

```loup
@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*

```text
[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:

```loup
#~ @mode(host.scripted)
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));
```

```text
[    42]
[name....]
Hello world
2
12
0
```

## Building Strings

`+` joins two strings, and only two strings:

```loup,does_not_compile
@mode(host.scripted)

let count = 3;
println("You have " + count + " new messages");
```

```text
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:

```loup
#~ @mode(host.scripted)
let count = 3;
println("You have " + str(count) + " new messages");
println(i"You have {$ count $} new messages");
```

```text
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 `$}`:

```loup
@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*

```text
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:

```loup,not_desired_behavior
@mode(host.scripted)

let name = "Ada";
println("Hello, {$ name $}!");
println(i"Hello, {name}!");
```

```text
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:

```loup
#~ @mode(host.scripted)
let name = "Ada";
println(i"Hello, {$ name $}!");
```

```text
Hello, Ada!
```

Python's `f"…"` prefix does not exist either, and that one the parser does catch:

```loup,does_not_compile
@mode(host.scripted)

let name = "Ada";
println(f"Hello, {name}!");
```

```text
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:

```loup
#~ @mode(host.scripted)
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 $}]");
```

```text
[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:

```loup
@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*

```text
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

```loup,does_not_compile
@mode(host.scripted)

println(i"{$ "release notes" | capitalize $}");
```

```text
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:

```loup
#~ @mode(host.scripted)
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) $}");
```

```text
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:

````loup
@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*

```text
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

````loup,not_desired_behavior
@mode(host.scripted)

let greeting = ```(text):(none)
Hello!
```;
println(greeting);
````

```text
__Template()
```

A fence is a `Template` value, not a string. Call `render`:

````loup
#~ @mode(host.scripted)
let greeting = ```(text):(none)
Hello!
```;
println(greeting.render({}));
````

```text
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:

````loup,does_not_compile
@mode(host.scripted)

let row = ```(html):(none)
<li>{$ name $}</li>
```;
println(row.render({"name": "Widget"}));
````

```text
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:

````loup
#~ @mode(host.scripted)
func row(name: string): string {
    let t = ```(html):(none)
    <li>{$ name $}</li>
    ```;
    return t.render({});
}

println(row("Widget"));
println(row("Gadget"));
````

```text
<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:

````loup,not_desired_behavior
@mode(host.scripted)

let box = ```html
<div>hi</div>
```;
println(box.render({}));
````

```text
<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:

````loup,not_desired_behavior
@mode(host.scripted)

let comment = "<script>alert('hi')</script>";
let page = ```(html):(none)
<p class="comment">{$ comment $}</p>
```;
println(page.render({}));
````

```text
<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:

````loup
#~ @mode(host.scripted)
let comment = "<script>alert('hi')</script>";
let page = ```(html):(none)
<p class="comment">{$ comment | escape $}</p>
```;
println(page.render({}));
````

```text
<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.

````loup
@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*

```text
<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.
