f"…"Status: Superseded by ADR-309 — its D1-D6 are D5-D10 there
Date: October 1, 2026
Answers: where the holes of an f"…" are parsed - by the grammar, into the
tree, or afterwards from the literal’s text
(#107 item 2)
Supersedes / Related:
ADR-032 §3 option (a), rejected for now and not on merit, is
this record’s decision; its D2 (a hole that does not parse is not an analysis’s
business) no longer holds for an f"…", and its D1 (one place opens a literal)
does. ADR-296 (a dsl block’s content is another language’s
syntax). ADR-017 (a template’s holes). ADR-294 (the
road to Stage 1, which this unblocks). ADR-258 (the four weights)
Is a hole of an f"…" parsed by the grammar, as part of the expression it
stands in, or found in the literal’s text after parsing and parsed again on its
own?
Today the grammar reads f"…" as one lexical token and keeps the text:
Expr::LitInterpolated(String). A hand-written scanner in the emitter
(emit::interpolation) finds the holes in that text - escapes, \u{…}, {{,
the : before a format - and each hole is parsed on its own
(Parsed::hole, cached). Every walk that has to see the code inside a literal
asks ADR-032 D1’s emit::literal_expressions, which re-parses.
That was the smaller change when ADR-032 was written. It stops being small the
moment the walks move to Nikaia (ADR-294): a walk written in
Nikaia lives in nikaia-std, and the parser is the compiler’s, so it cannot
parse a hole at all. Every analysis that walks a body - sync, throws,
touches, locks, keep, sharing - stays Rust for that one reason.
| Language / tool | Its answer | What it costs there | Source |
|---|---|---|---|
| Python ≤ 3.11 | the tokenizer keeps an f-string as one string token; a second pass parses each replacement field | a nested quote of the same kind is impossible, error positions are approximate, two parsers to keep in step | PEP 498; from memory, not checked |
| Python 3.12 | the grammar parses f-strings: text parts and replacement fields are tokens, a field is an ordinary expression, nested quotes are allowed | a tokenizer with a mode stack | PEP 701, which moved exactly from the row above to this one; from memory, not checked |
| JavaScript | the tokenizer splits a template literal; the tree holds quasis (text) and expressions |
none worth naming | ESTree TemplateLiteral; from memory |
| C#, Kotlin, Swift | the front end parses interpolations into parts in the syntax tree | none worth naming | from memory, not checked |
Rust format! |
a macro: the format string is parsed when the macro expands, by a parser of its own | 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, and the reason Python gives in PEP 701 is the
one here - two parsers that must agree, and positions that are guesses. What
does not transfer: format! is a macro over Rust tokens; Nikaia has no macros
and an f"…" is syntax.
println(f"{name}: {data.len():>8} - {emphasised("Ada")}")
f"…" is a rule: text runs, and holes { expr (":" spec)? } where expr is
the ordinary expression rule. The tree holds the parts.
The token stays; after parsing, each literal’s holes are parsed and stored in the node beside its text. The emitter’s scanner stays.
The compiler parses the holes and hands a walk a table from literal to holes.
Walks that must see a hole stay Rust.
| D1 the special case pays | D2 the compiler works | D3 no cost for the common case | D4 consistent with itself | If it is wrong | |
|---|---|---|---|---|---|
| A | a hole is an expression like any other; nothing special remains | a broken hole is a parse error at its position, in the grammar’s words | the emitter builds the format string from the parts; measured by benches/template.nika and page.nika before the text goes |
one parser for all code; a hole is written as code is written | the parts are an ordinary tree shape; going back is a revert |
| B | two parsers still, one of them hand-written | the error still comes from the emitter | unchanged | the scanner and the grammar must keep agreeing about where a hole ends | cheap to undo, but it is A’s first half without A’s point |
| C | every walk carries a table | - | - | a literal has no position of its own to key on | a table nobody wants to own |
| D | Stage 1 is not reachable this way | - | - | - | - |
A is what PEP 701 chose for the same two reasons, and the one weight it costs anything on is D3, where it is measured rather than assumed.
f"…" is a rule of the grammar.Its body is a sequence of 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, which is the
rule Part I already states (the first colon that is not inside a call or an
index) said by the grammar instead of by a scanner.
Expr::LitInterpolated holds a list of parts, each a text run or a hole with
its format. A walk sees a hole as a child of the literal, exactly as it sees an
argument of a call. Until the emitter builds from the parts (D5) the node also
keeps the text as written, so the emitter’s measured paths and the golden files
do not move in the same change as the grammar.
Between { and } stands Nikaia source, not string content: a string inside a
hole is written with plain quotes, f"{emphasised("Ada")}", as in any other
expression. The \" the scanner used to undo inside a hole is not code and is
refused there, with a help that names the plain quote. Part I §2.5 says so in a
sentence.
At the hole’s own position, in the grammar’s words. ADR-032 D2 - the emitter
diagnoses a broken hole and every analysis ignores it - no longer applies to an
f"…", because 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. The kept text and emit::interpolation’s
scanner are removed once benches/template.nika and benches/page.nika
measure the same as before; a regression is not accepted.
The content of a dsl block is another language’s syntax (ADR-296),
and the grammar does not know it; dsl html’s holes are still found by
template::split and parsed on their own, and emit::literal_expressions
(ADR-032 D1) stays the one place that opens a literal of either kind.
B lost because it keeps the second parser it exists to remove. C lost because a literal has no position to key a table on, and every walk would carry one. D lost because it leaves Stage 1 unreachable by the route ADR-294 chose.
f"…" with no help from the
compiler, which is what moving sync, throws, touches, locks, keep
and sharing waits on (#125).\" inside a hole
(examples/escaping) and is rewritten with plain quotes; a program outside
the corpus that does the same gets D3’s refusal and its help.text_tiers’ wraps of a hole become a rewrite of the hole’s node instead of
a table keyed by the hole’s text.dsl html gets a grammar path of its own (D6).