ADR-309: Only an f"…" Has Holes, the Grammar Parses Them as Code, and Every Analysis Sees Them

Status: Accepted Date: October 3, 2026 History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: whether a string literal interpolates by its contents or by a mark on the literal; where the holes of an interpolated string are parsed - by the grammar, into the tree, or afterwards from the literal’s text (#107 item 2); and which analyses have to see the code inside a literal, and how Related: ADR-017 (the dsl html template, whose holes keep their bare braces); ADR-024 D1, D5 (Unknown is the absence of a claim; what the checker says a literal’s type is); ADR-288 D2 (inferred sync fails closed); ADR-010 D1 (an analysis that fails open is a vulnerability generator); ADR-296 (a dsl block’s content is another language’s syntax); ADR-294 (Stage 1, which needs the walks written in Nikaia); ADR-229 D1 (the tier pass’s wraps of a hole); ADR-258 (the four weights)

1. Question

Which string literals carry code, who parses that code, and which parts of the compiler have to see it?

Three answers have to agree, which is why they are one record:

2. How others do it

The mark.

Language Mark A brace in plain text
Python f"…" "{" just works
C# $"…" "{" just works
Scala s"…" "{" just works
JavaScript a different delimiter (backtick) "{" just works
Ruby "#{}" vs '…' '{' just works
Swift \(x) - an escape nobody types by accident "{" just works
Rust the macro is the mark, not the literal doubled only inside one
Kotlin none - "$x" everywhere \$ required

Unmarked interpolation stands with Kotlin, and is worse there than in Kotlin: $ is rare in prose, { is not. Rust is the instructive case - the mark sits on the construct, which is what a dsl html block already does here.

The parse.

Language / tool Its answer What it costs there Source
Python ≤ 3.11 the tokenizer keeps an f-string as one token; a second pass parses each field no nested quote of the same kind, approximate error positions, two parsers to keep in step PEP 498; from memory, not checked
Python 3.12 the grammar parses f-strings; a field is an ordinary expression, nested quotes allowed a tokenizer with a mode stack PEP 701, which moved from the row above to this one; from memory, not checked
JavaScript the tokenizer splits a template literal; the tree holds quasis and expressions none worth naming ESTree TemplateLiteral; from memory
C#, Kotlin, Swift the front end parses interpolations into parts of the syntax tree none worth naming from memory, not checked
Rust format! a macro parses its format string when it expands a second grammar, and spans into a string the language below

What transfers: every language that started with the second pass and kept going moved the parse into the grammar, for the reason PEP 701 gives - two parsers that must agree, and positions that are guesses. What does not: format! is a macro over Rust tokens; Nikaia has no macros, and an f"…" is syntax.

3. Options, weighed

Question Option Verdict
the mark leave every "…" a template and teach {{ rejected: the programs that print JSON were the hardest in the corpus to read, and a brace changed a literal’s type
  a different delimiter for plain text ('…', backtick) rejected: it marks the common case and leaves the rare, code-bearing one looking ordinary - the mark belongs on what surprises
  f"…" marks the interpolated string; "…" is inert chosen: of the corpus’s string literals a quarter held a hole and nearly three quarters were plain text, so the mark goes on the quarter; it is the majority design among the languages people arrive from
the parse the grammar parses the holes into the tree chosen: one parser for all code, a broken hole is a parse error at its own position, a walk in Nikaia sees a hole as a child node
  parse afterwards and store the result in the node rejected: keeps the second, hand-written parser it exists to remove
  a side table from literal to holes rejected: a literal has no position to key it on, and every walk would carry it
  nothing rejected: leaves Stage 1 unreachable by the route ADR-294 chose
the analyses each analysis opens literals itself rejected: a construct the emitter learns to read is invisible upstream by default
  one function answers “what code is inside this expression?” chosen: the next literal that holds code has a place to go
the break an error for a plain string that looks like it had a hole rejected: "{ margin: 0 }" is correct CSS and "\d{3}" a correct regular expression; the checker never rejects a program that is right (Part III C.4)
  silence rejected: println("hallo {name}") still compiles and prints {name} - a silent change of meaning, which Part III C.1 calls a bug in this compiler
  a narrow warning chosen (D15)

4. Decision

D1 — f"…" interpolates. "…" is text, and a brace is a brace.

{{ and }} are escapes inside an f"…", where there is something to escape from. A plain string needs no escape: "{" is one brace and "{{" is two.

D2 — A literal’s type is read off its first character, not its contents.

Changed by ADR-319 D4: where the use asks for an fs::Path, an f"…" builds one.

"…" is a view of static text; f"…" is a String, hole or no hole, or an fs::Path where its use asks for one. Adding a brace to a piece of text cannot change its type.

D3 — The f and the quote are one token, a template keeps its bare braces, and an f"…" is not a constant.

f"x" interpolates; f "x" is a variable beside a string - whitespace never decides what a program means. dsl html { … } needs no f: the construct is the mark. An f"…" is not reachable where a constant is required - a parameter’s default, a match pattern, a grammar’s literal - and the grammar says so rather than a later pass discovering it.

D4 — A plain string’s braces are doubled on the way into Rust, and nowhere else.

A brace in the format string of a macro below is a hole of Rust’s own, so the emitter doubles it (rust_format_escape). That is transcription, not a rule anybody writes, and an escape is copied whole - \u{0041} included - so Rust’s lexer is never handed a \u with nothing after it.

D5 — f"…" is a rule of the grammar: text and holes.

Text is lexical: characters other than ", \, { and }, an escape from Part I’s closed set, and {{ and }}, each of which is one brace. A hole is {, an expression parsed by the expression rule, optionally : and a format, and }. The : that starts a format is the first one the expression rule does not consume - Part I’s first colon that is not inside a call or an index, said by the grammar.

D6 — The tree holds the parts.

Expr::LitInterpolated is a list of parts, each a text run or a hole with its format and the byte its { stands at. A walk sees a hole as a child of the literal, exactly as it sees an argument of a call.

D7 — A hole is written as code is written.

Between { and } stands Nikaia source, not string content: a string inside a hole has plain quotes, f"{emphasised("Ada")}". A \" inside a hole is not code and is refused, with a help that names the plain quote.

D8 — A hole of an f"…" that does not parse is a parse error.

At the hole’s own position, in the grammar’s words. Nothing reaches an analysis that the parser did not accept.

D9 — The emitter builds from the parts, and nothing reads the text again.

The format string, template::literal_length and the String::with_capacity size are computed from the parts. There is no scanner that finds holes in an f"…"’s text; a change to these paths is held to benches/template.nika and benches/page.nika, and a regression is not accepted.

D10 — A template’s holes are not the grammar’s.

The content of a dsl block is another language’s syntax (ADR-296), which the grammar does not know. A dsl html template’s holes are found by template::split and parsed one by one, and a deferred-parameter statement’s braces are not holes at all.

D11 — emit::literal_expressions(parsed, expr) is the single place that opens a literal.

Given any expression it returns the expressions inside it: an f"…"’s holes as the grammar parsed them (D6), with the tier pass’s wraps applied (ADR-229 D1); a dsl html template’s holes, including those inside a for body and the collection it iterates; for anything else, nothing. Every analysis calls it, and none re-implements it.

D12 — A template hole that does not parse is not an analysis’s business.

literal_expressions drops a template hole whose text does not parse and reports nothing; the emitter diagnoses it, with the span and message it has. An analysis inventing a second error for the same text would be two errors for one mistake - the absence of a claim, not a claim of absence (ADR-024 D1). For an f"…" the case does not arise (D8).

D13 — The type checker checks a hole exactly as it checks a statement.

Checker::holes walks D11’s result through the ordinary expression path, so NK1101 and every other finding fire inside an f"…" and inside a template. A hole produces the identical diagnostic to the same expression written outside one - equal, not merely both errors; the test a_hole_in_a_string_is_checked_like_anything_else holds that equality.

D14 — The sync walk descends into holes, in both directions.

contracts::sync takes parsed, so it sees the same holes. The inference loses the claim when a hole can pause, and the check holds an asserted sync to its holes, so NK2202 names a pausing call inside a literal like any other (ADR-288 D2).

D15 — A plain string that looks as though it meant a hole is a warning, NK1111.

A plain string that meant a hole looks exactly like one that meant its braces: println("hallo {name}") compiles and prints {name}. NK1111 warns, in two shapes - a plain string holding a doubled brace, and a plain string holding a brace group - with the fixed string as the help, ready to paste.

It is a warning and not an error, and its test is narrow: the braces must hold something that parses as an expression and names something that is here - a variable in scope or a function some ledger knows. A name nobody declared is text, so "{ margin: 0 }" and "\d{3}" pass silently.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.