ADR-035: The f Goes on the String, Because the Mark Belongs on the Construct

Status: Superseded by ADR-309 — its D1, D2, D3, D4, D5 are D1, D4, D2, D3, D15 there Supersedes: ADR-032 D5 — a brace deciding a literal’s type Related: ADR-032 (a hole is code), ADR-024 D5 (what the checker says a literal’s type is), ADR-017 (templates, which keep their bare braces)


1. Context

Every string literal in Nikaia was a template. "hello {name}" interpolated, and so did "{ margin: 0 }", "\d{3}" and every line of JSON a program ever printed — which is why examples/json/src/main.nika, a program whose entire job is to emit braces, was written like this:

print("{{}}")           // prints {}
print("{{\n")           // prints {
print("\n{pad}}}")      // one hole and one brace, side by side

and examples/1brc.nika ended on

println("{{{body}}}")   // three braces open. Two of them are tax.

Measured over the corpus — 45 .nika files, 212 string literals:

     
holds a hole 56 26 %
plain text, unaffected 154 72 %
only {{ escapes — paying for a feature it does not use 2  

Three quarters of the literals in this repository were templates that interpolated nothing, and every string that wanted a brace had to double it whether or not anything in the file interpolated at all.


2. The root cause, which is not the brace

The distinction already existed everywhere except where it is written:

let plain = "nur text"                    →   let plain = "nur text";
let woven = "text der {n} interpoliert"   →   let woven = format!("text der {} interpoliert", n);

The emitter branches. The type branches — ADR-024 D5 made "a" a view of static text and "a {b}" a String, so adding a brace to a piece of text changed its type. And since ADR-032, every analysis branches: literal_expressions has to open every string literal, because only its contents say whether there is code in it.

Three places decide the same question by scanning text, and the one party who cannot see the answer is the person writing the string.

The mark belongs on the construct, and Nikaia already knows that. dsl html { <p>{x}</p> } eod is marked; its holes are unambiguous because the construct said there would be holes. A grammar is marked. A spawn is marked. The bare string literal is the one place the rule was forgotten — and ADR-017 inherited that gap two ADRs later, which is how ADR-032’s blindness came to cover templates as well.


3. Prior art

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
Nikaia, before this none — {} everywhere {{ always required

Nikaia stood with Kotlin, and worse off than Kotlin: $ is rare in prose, { is not. Rust is the instructive one — the mark sits on the construct, which is exactly what dsl html does here.


4. Options considered

(a) Leave it, and teach {{. What the corpus says about that is §1: the two most ordinary programs in the repository were the two hardest to read.

(b) A different delimiter for plain text (Ruby’s '…', JavaScript’s backtick). Rejected: it marks the common case — three quarters of all literals — and leaves the rare, code-bearing one looking ordinary. The mark should be on the thing that surprises.

(c) f"…" marks the interpolated string; "…" is inert. Chosen. It marks 26 % rather than 72 %, it is the majority design in languages people arrive from, and it gives the type, the lowering and every analysis a syntactic answer to a question they were each deriving from text.


5. Decisions

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

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

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

print is a macro below, and a brace in its format string is a hole of Rust’s own. The doubling is a transcription detail (rust_format_escape), not a rule anybody writes — and an escape is copied whole, \u{0041} included, or Rust’s lexer would be handed a \u with nothing after it.

D3 — The type is read off the first character, not the contents.

"…" is a view of static text; f"…" is a String, hole or no hole. This retires ADR-032 D5, which was the same fact stated as a consequence of the text: under that rule, adding a brace to a message silently changed its type.

D4 — The f and the quote are one token, and a template keeps its bare braces.

f"x" interpolates; f "x" is a variable beside a string. Whitespace never decides what a program means. And dsl html { … } needs no f on anything: the construct is the mark, which is the rule in §2 read a second time.

f"…" is not reachable where a constant is required — a Kap 5.1 default, a match pattern, a grammar’s literal. The grammar says so rather than a later pass discovering it.

D5 — The break is a warning, for one release, and then it goes.

A string written before this looks exactly like one that meant its braces, and the change is invisible at compile time: println("hallo {name}") still compiles and now prints {name}. Part III C.1 calls a silent change of meaning a bug in this compiler.

So NK1111, the first thing this checker has ever warned about rather than refused, in the two shapes the change has: a plain string holding a brace group, and a plain string holding a doubled brace. The help is the fixed string, ready to paste.

Why not an error. "{ margin: 0 }" is correct CSS and "\d{3}" a correct regular expression. Refusing either would break the property the whole checker rests on — it never rejects a program that is right (Part III C.4). So the test is narrow: the braces must hold something that parses as an expression and resolves to something that is actually here — a variable in scope, or a function some ledger knows. A name nobody declared is text, and text says nothing.

Why not permanent. After the corpus has moved, NK1111 fires only on code written against a language that no longer exists, and a warning nobody can trigger is a warning nobody reads. It is removed in the release after this one, together with Severity::Warning if nothing else has claimed it by then.


6. Consequences

examples/json/src/main.nika reads like what it is. print("{}") prints {}. That single line is the clearest statement of what this buys.

The 56 literals were migrated mechanically and the corpus is clean — no file in this repository raises NK1111. The transformation is meaning-preserving by construction: a literal with a real hole gained an f and kept its text; a plain literal with {{ had its braces undoubled; everything else was untouched. Comments, grammar patterns and template bodies were excluded, and the three places where an automated pass got that wrong were caught by the test suite rather than by review.

Three quarters of all literals are now inert at a glance, which is an analysis property and not only a reading one: sync, throws and provenance have to look inside a literal only where somebody wrote an f. ADR-032 made every analysis open every string; this means they open the ones that said there was something to find.

A diagnostic got better on the way. Folding f"…" into str_lit as a second alternative silently dropped ` " ` from the parser’s “also possible here” list — a rule whose body is one sequence reports what it can start with, and one built from alternatives did not. f_str_lit is therefore its own rule, and the list now carries both ` " ` and ` f" `.

What this does not do: holes are still parsed in the emitter rather than living in the AST. ADR-032 §3 said why that waits, and the f does not change the argument — it makes the eventual change smaller, because the variant that would carry the parts already exists.

This site is open source. Improve this page.