f"…" Has Holes, the Grammar Parses Them as Code, and Every Analysis Sees ThemStatus: 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)
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:
"…" may hold a hole, then every brace in every string - JSON, CSS, a
regular expression - has to be doubled, the type of a literal changes when a brace is added to
it, and every analysis has to open every literal to find out whether there is code in it.nikaia-std has no
parser, so every analysis that walks a body would stay Rust for that one reason
(ADR-294).sync inference walks the calls of a body; a pausing call inside a
hole that the walk does not see makes the ledger claim sync = "inferred" for a body that pauses -
a false claim of the safe answer, in a file a library ships. println(f"{add(1)}") and
let n = add(1) are the same mistake, and both have to be refused.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.
| 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) |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
sync, throws and provenance look inside a literal only where somebody wrote an
f. A program that prints JSON writes print("{}").f"…" as a child node, with no help from the
compiler - which is what moving sync, throws, touches, locks, keep and sharing to
Nikaia waits on (#125).f"…"
is reported by the parser, where it is.\" inside a hole is refused (D7) and rewritten with plain quotes.f_str_lit is a rule of its own beside str_lit, so the parser’s also possible here list
carries both ` " ` and ` f" `.NK1111 is a migration warning the checker keeps, and a plain string whose
brace group names a local is warned about even where the brace was meant.NK1111 goes. Part III C lists it, and the corpus trips it only where
Part I 2.5 shows the difference on purpose.dsl html template, which is ADR-017’s; the checker
holds it Unknown.dsl html gets a grammar path of its own (D10).