ADR-262: The Grammar Parses the Holes of an 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)

1. Question

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.

2. How others do it

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.

3. Options, weighed

Option A - the grammar parses the holes

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.

Option B - parse afterwards, keep the result in the node

The token stays; after parsing, each literal’s holes are parsed and stored in the node beside its text. The emitter’s scanner stays.

Option C - a side table

The compiler parses the holes and hands a walk a table from literal to holes.

Option D - nothing

Walks that must see a hole stay Rust.

Weighed

  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.

4. Decision

D1 - 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.

D2 - The tree holds the parts.

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.

D3 - A hole is written as code is written.

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.

D4 - A hole that does not parse is a parse error.

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.

D5 - The emitter builds from the parts, and then the text goes.

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.

D6 - A template’s holes are not this record’s.

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.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.