Issue #659: Decision: is a text position that only literals fill a view of static text, or text of its own?

Open and answer this issue on GitHub.

ready · opened 2026-10-18 · updated 2026-10-19 · 3 comments

What this is about

A text literal like "Hamburg" is part of the compiled program: the bytes already sit in the binary, for as long as the program runs. Where a program only reads a literal (println("Hamburg"), greet("Ada")), nothing is allocated. The question is what happens where a literal is stored — in a struct field, as a function’s result, in a list — and nothing but literals is ever stored there.

pub struct Row {
    name: String,
    note: String,
}

fn main() {
    let mut rows = Vec()
    for i in 0..<n {
        rows.push(Row { name: "Hamburg", note: "a note with <angles>" })
    }
    ...
}

Today every Row gets two fresh heap copies of text the binary already holds:

pub struct Row { name: String, note: String }
rows.push(Row { name: String::from("Hamburg"), note: String::from("a note with <angles>") });

With ref String written on the fields (as benches/template.nika does today), the same program lowers to views of the static text and copies nothing:

pub struct Row<'a, 'a1> { name: &'a str, note: &'a1 str }
rows.push(Row { name: "Hamburg", note: "a note with <angles>" });

Where the question comes from

#654 (ADR-282 D2: String is a view wherever a view suffices, so ref String is to go away) needs every ref String in our programs to be droppable without changing the lowering. The #654 builder (0.0.1022) found that this one position does change: benches/template.nika’s Row gets copies when ref is dropped. It is the only remaining class besides option defaults (#654 step 0 c1, being built). Nothing breaks either way — it is a cost question, and a question of what the language promises about literals. It needs an answer before NK1261 turns ref String into a refusal (#654 step 6), because after that a program has no way to ask for the view.

What is decided today

what is stored lowered today
a field filled only by literals String::from("…")
a result if big { "large" } else { "small" } String::from in each branch
Team { names: ["Ada", "Grace"] } into Vec[String] vec[String::from(…), …]
a local Vec filled by push("Ada"), then put in a Vec[String] field refused, NK1106 — contradicts D16 (“a list that already has a position is linked to where it goes”); a defect whichever option is chosen

Measured

benches/template.nika (n rows through dsl html), both variants lowered by today’s compiler, rustc -C opt-level=3, no LTO, against the release nikaia_std; same output byte for byte. callgrind --branch-sim=yes:

rows view (&str) copy (String) copy / view
2 000 2 891 572 instr., 30 642 mispredicts 4 589 236 instr., 37 256 mispredicts 1.59×
20 000 23 550 344 instr., 228 721 mispredicts 41 035 481 instr., 289 312 mispredicts 1.74×

That is ≈ 874 instructions per row, ≈ 437 per stored literal (an allocation, a copy, a free) — in a benchmark that also escapes and renders HTML for every row. Overflow checks on in both.

How others do it

Go, Java, Swift, Python and JavaScript store a literal without copying it: the string value points at the static bytes (Java and Python intern them, Swift marks them immortal, Go’s string header points into read-only data). Rust makes the programmer choose (&'static str, String, or Cow<'static, str>). C++’s std::string copies (small strings inline), std::string_view does not. A script-like language whose literal costs an allocation the moment it is stored is the exception.

Options

A — keep it: a stored literal is text of its own (Part I 2.3 as it stands). Every literal kept in a field, result or list is allocated, once per evaluation. Nothing changes in the specification; D13’s sentence is reworded to say so. benches/template and every similar program pay ≈ 437 instructions per stored literal, and after NK1261 there is no way to avoid it.

B — a position only literals flow into is a view of static text (D13’s table as written). Below it is &'static str — no lifetime parameter, since static text outlives everything. As soon as text of its own also flows in, the position is mixed (EitherText, D14) or owned, as today. Part I 2.3’s “constructed there” applies only where text of its own also flows in; D3, D4, D13, D17 are reworded. Risk: for a published struct, field or result, the representation depends on what the package itself puts there. A library whose pub struct is filled only with literals publishes a view of static text; a caller outside the package that wants to put its own f"…" there is refused (NK1106) until the library changes, which is a ledger diff (ADR-282 D2) — the same as for every other view today, but a new kind of surprise for a literal.

C — B inside a package, EitherText at a published position. Unpublished positions as in B. A published position only literals flow into is mixed (Cow<'static, str> below): no allocation, any caller may store its own text, one branch per read. Costs a second rule and a branch on every read of such a field.

In every option, the NK1106 on a pushed-to local list (table above) is a defect and is fixed.

Recommendation

B. It makes “a literal is a view of static text” (Part I 2.5, 2.4’s example) true wherever the literal goes instead of only where it is read, it is what D13’s table already says, it removes ≈ 437 instructions per stored literal, and it is what every comparable language does. The published-position risk is the general rule for views (ADR-282 D2, NK1106), and the ledger shows the change in review; if that proves to bite real libraries, C can be added later without changing any unpublished position.

To build (once decided — written for B)

  1. Spec: Part I 2.3 — a literal is constructed where it is kept and text of its own also flows there; a position only literals flow into holds a view of static text. ADR-282: reword D3, D4, D13 (the “literal alone” sentence), D17; a new D stating the decision and why A and C were rejected.
  2. The tier walk counts a literal as a view of static text; a position with only such views lowers to &'static str (no lifetime parameter). Test: benches/template.nika with ref dropped lowers byte-identical except &'static str for the generic lifetimes; the all-literal result and the list literal above lower without String::from.
  3. A local list linked to a field (D16) takes the field’s tier; the pushed-to Vec above compiles. Test: that probe, and the same with one f"…" pushed (mixed).
  4. Ledgers of examples and tools/*.nika regenerated; each change looked at.
  5. crates/nikaia/tests/measure.rs gets the A/B above as a measurement.

Comments

keywan-ghadami, 2026-10-18

Decided by the owner: option B. A literal is a view of the program’s own text wherever it goes, not only where it is read.

To build

  1. The tier walk counts a literal as a view of static text; a position into which only literals (and other views) flow lowers to &'static str (no lifetime parameter): a field, a result (also an all-literal if/match), a let, a list element, a map key or value. Test: benches/template.nika with ref dropped from Row lowers without String::from and with &'static str fields; fn label(big: bool) -> String { if big { "large" } else { "small" } } and Team { names: ["Ada", "Grace"] } lower without String::from; with one f"…" beside the literals, the literals are constructed as today.
  2. A published position only literals flow into is a view in the ledger (D22); with text of its own also flowing in, it stays text of its own and the literals are constructed. Test: one of each, ledger lines checked.
  3. A local list linked to a field (D16) takes the field’s tier: let mut names = Vec(); names.push("Ada"); Team { names: names } compiles (no NK1106). Test: that probe; the same with an f"…" pushed.
  4. benches/template.nika drops its ref String; ledgers of examples and tools/*.nika regenerated and each change looked at; lower-std diff committed.
  5. crates/nikaia/tests/measure.rs gets the A/B of the issue body as a measurement.
  6. The specification’s own examples still pass (--test specification; regenerate tests/specification/EXPECTED.txt / COMPILES.txt only for what this changes).

Generated by Claude Code

keywan-ghadami, 2026-10-18

Steps 1 and 3 built in 9b428ef, version 0.0.1036 (7623620).

Still to build (D29 not yet met)

  1. Published positions (step 2). The tier walk reads one file, but a package spans files: CxByValue is filled with literals in cexport_model.nika and with owned text in cexport_node.nika, so a view there broke lower-std. Today a pub position stays String. To build: run the walk over the whole package (all files of the unit together), then a pub position only literals flow into is a view in the ledger. Test: one pub field of each kind across two files, ledger lines checked.
  2. Map keys and values. Never &'static str today: HashMap<&str, V> looked up with a String failed rustc (&str: Borrow<String> does not hold). To build: look up with .as_str() (&'static str: Borrow<str>), then let keys and values only literals fill be views. Test: a map filled with literal keys, read with an owned key and a literal.
  3. Literals with null. if c { "a" } else { null } stays String? (the checker types it owned, arms_meet_at_null). To build: a view Option<&'static str>. Test: that probe.
  4. Steps 4-6 of the list above (migration of benches/, examples and tools/*.nika; measure.rs; the specification’s expected files).

Generated by Claude Code

keywan-ghadami, 2026-10-19

Item 2 (map keys and values) is built in c8cddf7, version 0.0.1060.

Still open:

Found on the way, filed separately:


Generated by Claude Code

Back to Open Issues