Skip to main content

brink_format/inkt/
write.rs

1//! Textual (.inkt) writer for `StoryData`.
2//!
3//! Produces a WAT-inspired, section-based, indented mnemonic representation
4//! of compiled story data for debugging and inspection.
5//!
6//! The output is lossless — every field in `StoryData` is represented so that
7//! `read_inkt(write_inkt(story))` is an exact roundtrip.
8
9use core::fmt;
10
11use std::collections::HashMap;
12
13use crate::counting::CountingFlags;
14use crate::definition::{
15    AddressDef, AddressPath, AliasEntry, CallAtom, CapabilityParam, ContainerDef, DebugInfoSection,
16    DirectEffects, EffectRowEntry, ExternalFnDef, FileSurface, FrameShapeDef, GlobalVarDef,
17    LineEntry, ListDef, ListItemDef, StructShapeDef,
18};
19use crate::id::DefinitionId;
20use crate::line::{LineContent, LinePart, SelectKey};
21use crate::opcode::{ChoiceFlags, Opcode, SequenceKind};
22use crate::story::StoryData;
23use crate::value::{ListValue, MapKey, ProjSegment, Value, ValueType};
24
25/// Write the textual (.inkt) representation of a compiled story.
26pub fn write_inkt(story: &StoryData, w: &mut dyn fmt::Write) -> fmt::Result {
27    if story.source_checksum != 0 {
28        writeln!(w, "(story checksum=0x{:08x}", story.source_checksum)?;
29    } else {
30        writeln!(w, "(story")?;
31    }
32
33    write_name_table(w, &story.name_table)?;
34    write_globals(w, &story.variables)?;
35    write_lists(w, &story.list_defs)?;
36    write_list_items(w, &story.list_items)?;
37    write_externals(w, &story.externals)?;
38    write_addresses(w, &story.addresses)?;
39    write_address_paths(w, &story.address_paths)?;
40    write_list_literals(w, &story.list_literals)?;
41    write_literal_pool(w, &story.literal_pool)?;
42    write_struct_shapes(w, &story.struct_shapes)?;
43    write_visibility(w, &story.private_defs)?;
44    write_alias_table(w, &story.alias_table)?;
45    write_effect_rows(w, &story.effect_rows)?;
46    write_frame_shapes(w, &story.frame_shapes)?;
47    write_debug_info(w, story.debug_info.as_ref())?;
48    write_line_variant_groups(w, &story.line_variant_groups)?;
49
50    // Build a lookup from scope_id → line table for writing
51    let line_map: HashMap<DefinitionId, &[LineEntry]> = story
52        .line_tables
53        .iter()
54        .map(|lt| (lt.scope_id, lt.lines.as_slice()))
55        .collect();
56
57    for container in &story.containers {
58        // Only write lines on the scope-owning container (scope_id == id).
59        let lines = if container.scope_id == container.id {
60            line_map.get(&container.scope_id).copied().unwrap_or(&[])
61        } else {
62            &[]
63        };
64        write_container(w, container, lines)?;
65    }
66
67    write!(w, ")")
68}
69
70impl fmt::Display for StoryData {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write_inkt(self, f)
73    }
74}
75
76// ── Sections ─────────────────────────────────────────────────────────────────
77
78fn write_name_table(w: &mut dyn fmt::Write, names: &[String]) -> fmt::Result {
79    if names.is_empty() {
80        return Ok(());
81    }
82    writeln!(w)?;
83    writeln!(w, "  (name_table")?;
84    for (i, name) in names.iter().enumerate() {
85        writeln!(w, "    {i} \"{}\"", escape_string(name))?;
86    }
87    writeln!(w, "  )")
88}
89
90fn write_globals(w: &mut dyn fmt::Write, globals: &[GlobalVarDef]) -> fmt::Result {
91    if globals.is_empty() {
92        return Ok(());
93    }
94    writeln!(w)?;
95    writeln!(w, "  (globals")?;
96    for g in globals {
97        write!(
98            w,
99            "    (global {} :{} ",
100            g.id,
101            value_type_name(g.value_type)
102        )?;
103        write_value(w, &g.default_value)?;
104        if g.mutable {
105            write!(w, " mutable")?;
106        }
107        if g.local {
108            write!(w, " local")?;
109        }
110        writeln!(w)?;
111        writeln!(w, "      (name {}))", g.name.0)?;
112    }
113    writeln!(w, "  )")
114}
115
116fn write_visibility(w: &mut dyn fmt::Write, private_defs: &[DefinitionId]) -> fmt::Result {
117    if private_defs.is_empty() {
118        return Ok(());
119    }
120    writeln!(w)?;
121    writeln!(w, "  (visibility")?;
122    for id in private_defs {
123        writeln!(w, "    (private {id})")?;
124    }
125    writeln!(w, "  )")
126}
127
128fn write_lists(w: &mut dyn fmt::Write, list_defs: &[ListDef]) -> fmt::Result {
129    if list_defs.is_empty() {
130        return Ok(());
131    }
132    writeln!(w)?;
133    writeln!(w, "  (lists")?;
134    for ld in list_defs {
135        writeln!(w, "    (list {}", ld.id)?;
136        writeln!(w, "      (name {})", ld.name.0)?;
137        for (item_name, ordinal) in &ld.items {
138            writeln!(w, "      (item name={} ordinal={ordinal})", item_name.0)?;
139        }
140        writeln!(w, "    )")?;
141    }
142    writeln!(w, "  )")
143}
144
145fn write_list_items(w: &mut dyn fmt::Write, list_items: &[ListItemDef]) -> fmt::Result {
146    if list_items.is_empty() {
147        return Ok(());
148    }
149    writeln!(w)?;
150    writeln!(w, "  (list_items")?;
151    for li in list_items {
152        writeln!(
153            w,
154            "    (list_item {} (origin {}) (ordinal {}) (name {}))",
155            li.id, li.origin, li.ordinal, li.name.0
156        )?;
157    }
158    writeln!(w, "  )")
159}
160
161fn write_list_literals(w: &mut dyn fmt::Write, list_literals: &[ListValue]) -> fmt::Result {
162    if list_literals.is_empty() {
163        return Ok(());
164    }
165    writeln!(w)?;
166    writeln!(w, "  (list_literals")?;
167    for lv in list_literals {
168        write!(w, "    (list (items")?;
169        for item in &lv.items {
170            write!(w, " {item}")?;
171        }
172        write!(w, ") (origins")?;
173        for origin in &lv.origins {
174            write!(w, " {origin}")?;
175        }
176        writeln!(w, "))")?;
177    }
178    writeln!(w, "  )")
179}
180
181/// Write the T1b literal pool section (`docs/format-v4-rfc.md` §2) —
182/// printed only when present, matching the RFC's section discipline.
183fn write_literal_pool(w: &mut dyn fmt::Write, literal_pool: &[Value]) -> fmt::Result {
184    if literal_pool.is_empty() {
185        return Ok(());
186    }
187    writeln!(w)?;
188    writeln!(w, "  (literal_pool")?;
189    for v in literal_pool {
190        write!(w, "    ")?;
191        write_value(w, v)?;
192        writeln!(w)?;
193    }
194    writeln!(w, "  )")
195}
196
197/// TM-4 (`docs/format-v4-rfc.md` §1): mirrors the `.inkb` `StructShapes`
198/// section — shape id, name, then its ordered field `NameId`s. The reader
199/// lands with the writer in the same PR (the #742/#883 lesson): this
200/// section used to round-trip through `.inkb` only, with `.inkt` silently
201/// dropping it entirely.
202fn write_struct_shapes(w: &mut dyn fmt::Write, struct_shapes: &[StructShapeDef]) -> fmt::Result {
203    if struct_shapes.is_empty() {
204        return Ok(());
205    }
206    writeln!(w)?;
207    writeln!(w, "  (struct_shapes")?;
208    for shape in struct_shapes {
209        writeln!(w, "    (struct {}", shape.id.0)?;
210        writeln!(w, "      (name {})", shape.name.0)?;
211        for field in &shape.fields {
212            writeln!(w, "      (field {})", field.0)?;
213        }
214        writeln!(w, "    )")?;
215    }
216    writeln!(w, "  )")
217}
218
219fn write_externals(w: &mut dyn fmt::Write, externals: &[ExternalFnDef]) -> fmt::Result {
220    if externals.is_empty() {
221        return Ok(());
222    }
223    writeln!(w)?;
224    writeln!(w, "  (externals")?;
225    for ext in externals {
226        write!(w, "    (extern {} argc={}", ext.id, ext.arg_count)?;
227        writeln!(w)?;
228        writeln!(w, "      (name {})", ext.name.0)?;
229        if let Some(fb) = ext.fallback {
230            writeln!(w, "      (fallback {fb})")?;
231        }
232        writeln!(w, "    )")?;
233    }
234    writeln!(w, "  )")
235}
236
237fn write_addresses(w: &mut dyn fmt::Write, addresses: &[AddressDef]) -> fmt::Result {
238    if addresses.is_empty() {
239        return Ok(());
240    }
241    writeln!(w)?;
242    writeln!(w, "  (addresses")?;
243    for addr in addresses {
244        writeln!(
245            w,
246            "    (address {} -> {} +{})",
247            addr.id, addr.container_id, addr.byte_offset
248        )?;
249    }
250    writeln!(w, "  )")
251}
252
253fn write_address_paths(w: &mut dyn fmt::Write, address_paths: &[AddressPath]) -> fmt::Result {
254    if address_paths.is_empty() {
255        return Ok(());
256    }
257    writeln!(w)?;
258    writeln!(w, "  (address_paths")?;
259    for ap in address_paths {
260        writeln!(w, "    (path {} -> {})", ap.path.0, ap.target)?;
261    }
262    writeln!(w, "  )")
263}
264
265/// M-3 (`docs/modules-spec.md` §5): `#@was`-derived old→new `DefinitionId`
266/// rename records. Mirrors [`write_addresses`]'s `id -> target` shape.
267fn write_alias_table(w: &mut dyn fmt::Write, aliases: &[AliasEntry]) -> fmt::Result {
268    if aliases.is_empty() {
269        return Ok(());
270    }
271    writeln!(w)?;
272    writeln!(w, "  (alias_table")?;
273    for a in aliases {
274        writeln!(w, "    (alias {} -> {})", a.old, a.new)?;
275    }
276    writeln!(w, "  )")
277}
278
279/// T2-3 (`docs/effects-spec.md` §11): the factored `EffectRows` table. Written
280/// only when non-empty, mirroring the other optional sections. The reader lands
281/// with the writer in the same PR (the #742 lesson) — this section is fully
282/// round-tripped through `.inkt`.
283fn write_effect_rows(w: &mut dyn fmt::Write, rows: &[EffectRowEntry]) -> fmt::Result {
284    if rows.is_empty() {
285        return Ok(());
286    }
287    writeln!(w)?;
288    writeln!(w, "  (effect_rows")?;
289    for row in rows {
290        // #882 freeze bit: `internal` prints only when the row is NOT a host
291        // entry point (`#@private` — see `EffectRowEntry::is_entry`'s doc).
292        let internal = if row.is_entry { "" } else { " internal" };
293        writeln!(w, "    (row {}{internal}", row.def)?;
294        write_direct_effects(w, &row.direct, 6)?;
295        for d in &row.dispatches {
296            let narrowable = if d.narrowable { " narrowable" } else { "" };
297            writeln!(w, "      (dispatch {}{}", d.cell, narrowable)?;
298            write_direct_effects(w, &d.fallback, 8)?;
299            writeln!(w, "      )")?;
300        }
301        writeln!(w, "    )")?;
302    }
303    writeln!(w, "  )")
304}
305
306/// FS-3 (`docs/flow-suspension-spec.md` §4/§11): the `FrameShapes` table.
307/// Written only when non-empty, mirroring the other optional sections. The
308/// reader lands with the writer in the same PR (the #742 lesson) — this
309/// section is fully round-tripped through `.inkt`. Each entry is the `await`
310/// site's stable `DefinitionId` (the synthesized continuation container id)
311/// followed by its name-keyed crossing-local slots (interned `NameId`s).
312fn write_frame_shapes(w: &mut dyn fmt::Write, shapes: &[FrameShapeDef]) -> fmt::Result {
313    if shapes.is_empty() {
314        return Ok(());
315    }
316    writeln!(w)?;
317    writeln!(w, "  (frame_shapes")?;
318    for shape in shapes {
319        write!(w, "    (frame {}", shape.site)?;
320        for slot in &shape.slots {
321            write!(w, " {}", slot.0)?;
322        }
323        writeln!(w, ")")?;
324    }
325    writeln!(w, "  )")
326}
327
328/// D6 `DebugInfo` (`docs/debugger-spec.md` §2, issue #3184): the section-local
329/// file table plus one per-container entry/locals table, in `Containers`
330/// order. Written only when `Some` (debug info was requested at compile
331/// time) — distinct from every other optional section here, which key on
332/// #3273: render `(line_variant_groups (group $id base (dims d0 d1 ...)) ...)`.
333/// Skipped entirely when empty, mirroring the `.inkb` section's
334/// omitted-when-empty contract, so stories without variant groups render
335/// byte-identically to before the section existed.
336fn write_line_variant_groups(
337    w: &mut dyn fmt::Write,
338    groups: &[crate::LineVariantGroup],
339) -> fmt::Result {
340    if groups.is_empty() {
341        return Ok(());
342    }
343    writeln!(w)?;
344    writeln!(w, "  (line_variant_groups")?;
345    for group in groups {
346        write!(w, "    (group {} {} (dims", group.scope_id, group.base)?;
347        for dim in &group.dims {
348            write!(w, " {dim}")?;
349        }
350        writeln!(w, "))")?;
351    }
352    writeln!(w, "  )")?;
353    Ok(())
354}
355
356/// emptiness rather than `Option`, matching [`crate::StoryData::debug_info`]'s
357/// own "presence tracks whether it was requested" semantics. The reader
358/// lands with the writer in this same PR (the #742 lesson).
359fn write_debug_info(w: &mut dyn fmt::Write, debug_info: Option<&DebugInfoSection>) -> fmt::Result {
360    let Some(debug_info) = debug_info else {
361        return Ok(());
362    };
363    writeln!(w)?;
364    writeln!(w, "  (debug_info")?;
365    if !debug_info.files.is_empty() {
366        writeln!(w, "    (files")?;
367        for (idx, file) in debug_info.files.iter().enumerate() {
368            // #3261: `source_hash` and the line index ride along, so an
369            // `.inkt` dump stays a lossless view of the section rather than
370            // a lossy one — the round-trip proptest depends on it.
371            write!(
372                w,
373                "      (file {idx} {} \"{}\" {}",
374                debug_file_surface_name(file.surface),
375                escape_string(&file.path),
376                file.source_hash
377            )?;
378            if !file.line_starts.is_empty() {
379                write!(w, " (lines")?;
380                for start in &file.line_starts {
381                    write!(w, " {start}")?;
382                }
383                write!(w, ")")?;
384            }
385            writeln!(w, ")")?;
386        }
387        writeln!(w, "    )")?;
388    }
389    for (container_idx, table) in debug_info.containers.iter().enumerate() {
390        writeln!(w, "    (dcontainer {container_idx}")?;
391        for entry in &table.entries {
392            writeln!(
393                w,
394                "      (entry {} {} {} {} {} {})",
395                entry.bytecode_offset,
396                entry.file_idx,
397                entry.range_start,
398                entry.range_len,
399                entry.kind_token,
400                entry.flags
401            )?;
402        }
403        if !table.locals.is_empty() {
404            writeln!(w, "      (locals")?;
405            for local in &table.locals {
406                write!(
407                    w,
408                    "        (local {} \"{}\"",
409                    local.slot,
410                    escape_string(&local.name)
411                )?;
412                if local.synthetic {
413                    write!(w, " synthetic")?;
414                }
415                if let Some((file_idx, range_start, range_len)) = local.declaring_range {
416                    write!(w, " (range {file_idx} {range_start} {range_len})")?;
417                }
418                writeln!(w, ")")?;
419            }
420            writeln!(w, "      )")?;
421        }
422        writeln!(w, "    )")?;
423    }
424    writeln!(w, "  )")
425}
426
427fn debug_file_surface_name(surface: FileSurface) -> &'static str {
428    match surface {
429        FileSurface::Synthetic => "synthetic",
430        FileSurface::Ink => "ink",
431        FileSurface::Native => "native",
432    }
433}
434
435/// Write a [`DirectEffects`] block (`(reads …) (writes …) (calls …) opaque?`)
436/// at the given indent.
437fn write_direct_effects(
438    w: &mut dyn fmt::Write,
439    direct: &DirectEffects,
440    indent: usize,
441) -> fmt::Result {
442    let pad = " ".repeat(indent);
443    write!(w, "{pad}(reads")?;
444    for id in &direct.reads {
445        write!(w, " {id}")?;
446    }
447    writeln!(w, ")")?;
448    write!(w, "{pad}(writes")?;
449    for id in &direct.writes {
450        write!(w, " {id}")?;
451    }
452    writeln!(w, ")")?;
453    write!(w, "{pad}(calls")?;
454    for atom in &direct.calls {
455        write_call_atom(w, atom)?;
456    }
457    writeln!(w, ")")?;
458    if direct.opaque {
459        writeln!(w, "{pad}opaque")?;
460    }
461    // NS-A2 (issue #1108): the emits/tags/faults dimension flags, printed
462    // as bare optional tokens like `opaque`.
463    if direct.emits {
464        writeln!(w, "{pad}emits")?;
465    }
466    if direct.tags {
467        writeln!(w, "{pad}tags")?;
468    }
469    if direct.faults {
470        writeln!(w, "{pad}faults")?;
471    }
472    Ok(())
473}
474
475/// Write a single [`CallAtom`]: `(call <name> any)`. The capability-parameter
476/// slot renders as `any` (the v1 value); the reserved handle-parameter slot is
477/// `None` in v1 and therefore omitted.
478fn write_call_atom(w: &mut dyn fmt::Write, atom: &CallAtom) -> fmt::Result {
479    let cap = match atom.capability {
480        CapabilityParam::Any => "any",
481    };
482    write!(w, " (call {} {cap})", atom.name.0)
483}
484
485fn write_container(w: &mut dyn fmt::Write, c: &ContainerDef, lines: &[LineEntry]) -> fmt::Result {
486    writeln!(w)?;
487    writeln!(w, "  (container {}", c.id)?;
488
489    // Scope (only when different from container id)
490    if c.scope_id != c.id {
491        writeln!(w, "    (scope {})", c.scope_id)?;
492    }
493
494    // Container name (for scope-owning containers)
495    if let Some(name_id) = c.name {
496        writeln!(w, "    (name {})", name_id.0)?;
497    }
498
499    // Counting flags
500    if !c.counting_flags.is_empty() {
501        write!(w, "    (flags")?;
502        if c.counting_flags.contains(CountingFlags::VISITS) {
503            write!(w, " visits")?;
504        }
505        if c.counting_flags.contains(CountingFlags::TURNS) {
506            write!(w, " turns")?;
507        }
508        if c.counting_flags.contains(CountingFlags::COUNT_START_ONLY) {
509            write!(w, " start_only")?;
510        }
511        if c.counting_flags.contains(CountingFlags::INVISIBLE) {
512            write!(w, " invisible")?;
513        }
514        writeln!(w, ")")?;
515    }
516
517    // Path hash (for shuffle RNG seeding)
518    if c.path_hash != 0 {
519        writeln!(w, "    (path_hash {})", c.path_hash)?;
520    }
521
522    // Declared parameter count (parameterized knots/stitches/functions).
523    // When per-param name/mode metadata is present (T1c, #700 — carried so
524    // rehydration can validate function values), dump it too for parity.
525    if c.param_count != 0 {
526        if c.params.is_empty() {
527            writeln!(w, "    (params {})", c.param_count)?;
528        } else {
529            write!(w, "    (params {}", c.param_count)?;
530            for p in &c.params {
531                let mode = if p.is_ref { "ref" } else { "val" };
532                write!(w, " ({mode} {} {})", p.name.0, p.slot)?;
533            }
534            writeln!(w, ")")?;
535        }
536    }
537
538    // Flow-private scope default (`#@local` knot/stitch)
539    if c.local {
540        writeln!(w, "    local")?;
541    }
542
543    // Line table
544    if !lines.is_empty() {
545        writeln!(w, "    (lines")?;
546        for (i, entry) in lines.iter().enumerate() {
547            write!(w, "      {i} ")?;
548            write_line_content(w, &entry.content)?;
549            write!(w, " @{:016x}", entry.source_hash)?;
550            if let Some(audio) = &entry.audio_ref {
551                write!(w, " (audio \"{}\")", escape_string(audio))?;
552            }
553            if !entry.slot_info.is_empty() {
554                write!(w, " (slots")?;
555                for slot in &entry.slot_info {
556                    write!(w, " {}:\"{}\"", slot.index, escape_string(&slot.name))?;
557                }
558                write!(w, ")")?;
559            }
560            if let Some(loc) = &entry.source_location {
561                write!(
562                    w,
563                    " (source \"{}\" {}..{})",
564                    escape_string(&loc.file),
565                    loc.range_start,
566                    loc.range_end,
567                )?;
568            }
569            writeln!(w)?;
570        }
571        writeln!(w, "    )")?;
572    }
573
574    // Bytecode
575    if !c.bytecode.is_empty() {
576        writeln!(w, "    (code")?;
577        write_bytecode(w, &c.bytecode)?;
578        writeln!(w, "    )")?;
579    }
580
581    writeln!(w, "  )")
582}
583
584// ── Line content ─────────────────────────────────────────────────────────────
585
586fn write_line_content(w: &mut dyn fmt::Write, content: &LineContent) -> fmt::Result {
587    match content {
588        LineContent::Plain(s) => write!(w, "\"{}\"", escape_string(s)),
589        LineContent::Template(parts) => {
590            write!(w, "(template")?;
591            for part in parts {
592                write!(w, " ")?;
593                write_line_part(w, part)?;
594            }
595            write!(w, ")")
596        }
597    }
598}
599
600fn write_line_part(w: &mut dyn fmt::Write, part: &LinePart) -> fmt::Result {
601    match part {
602        LinePart::Literal(s) => write!(w, "(lit \"{}\")", escape_string(s)),
603        LinePart::Slot(idx) => write!(w, "(slot {idx})"),
604        LinePart::Select {
605            slot,
606            variants,
607            default,
608        } => {
609            write!(w, "(select slot={slot}")?;
610            for (key, text) in variants {
611                write!(w, " (")?;
612                write_select_key(w, key)?;
613                write!(w, " \"{}\")", escape_string(text))?;
614            }
615            write!(w, " (default \"{}\"))", escape_string(default))
616        }
617        LinePart::Span {
618            name,
619            attrs,
620            children,
621        } => {
622            write!(w, "(span \"{}\"", escape_string(name))?;
623            for (k, v) in attrs {
624                write!(
625                    w,
626                    " (attr \"{}\" \"{}\")",
627                    escape_string(k),
628                    escape_string(v)
629                )?;
630            }
631            for child in children {
632                write!(w, " ")?;
633                write_line_part(w, child)?;
634            }
635            write!(w, ")")
636        }
637    }
638}
639
640fn write_select_key(w: &mut dyn fmt::Write, key: &SelectKey) -> fmt::Result {
641    match key {
642        SelectKey::Cardinal(cat) => write!(w, "cardinal:{cat:?}"),
643        SelectKey::Ordinal(cat) => write!(w, "ordinal:{cat:?}"),
644        SelectKey::Exact(n) => write!(w, "={n}"),
645        SelectKey::Keyword(k) => write!(w, "keyword:{k}"),
646    }
647}
648
649// ── Bytecode disassembly ─────────────────────────────────────────────────────
650
651fn write_bytecode(w: &mut dyn fmt::Write, bytecode: &[u8]) -> fmt::Result {
652    let mut offset = 0;
653    while offset < bytecode.len() {
654        match Opcode::decode(bytecode, &mut offset) {
655            Ok(op) => {
656                write!(w, "      ")?;
657                write_opcode(w, &op)?;
658                writeln!(w)?;
659            }
660            Err(e) => {
661                writeln!(w, "      <decode error: {e}>")?;
662                break;
663            }
664        }
665    }
666    Ok(())
667}
668
669#[expect(clippy::too_many_lines)]
670fn write_opcode(w: &mut dyn fmt::Write, op: &Opcode) -> fmt::Result {
671    match op {
672        // Stack & literals
673        Opcode::PushInt(v) => write!(w, "push_int {v}"),
674        Opcode::PushFloat(v) => write!(w, "push_float {v}"),
675        Opcode::PushBool(v) => write!(w, "push_bool {v}"),
676        Opcode::PushString(idx) => write!(w, "push_string {idx}"),
677        Opcode::PushList(idx) => write!(w, "push_list {idx}"),
678        Opcode::PushDivertTarget(id) => write!(w, "push_divert_target {id}"),
679        Opcode::PushNull => write!(w, "push_null"),
680        Opcode::Pop => write!(w, "pop"),
681        Opcode::Duplicate => write!(w, "duplicate"),
682
683        // Arithmetic
684        Opcode::Add => write!(w, "add"),
685        Opcode::Subtract => write!(w, "subtract"),
686        Opcode::Multiply => write!(w, "multiply"),
687        Opcode::Divide => write!(w, "divide"),
688        Opcode::Modulo => write!(w, "modulo"),
689        Opcode::Negate => write!(w, "negate"),
690
691        // Comparison
692        Opcode::Equal => write!(w, "equal"),
693        Opcode::NotEqual => write!(w, "not_equal"),
694        Opcode::Greater => write!(w, "greater"),
695        Opcode::GreaterOrEqual => write!(w, "greater_or_equal"),
696        Opcode::Less => write!(w, "less"),
697        Opcode::LessOrEqual => write!(w, "less_or_equal"),
698
699        // Logic
700        Opcode::Not => write!(w, "not"),
701        Opcode::And => write!(w, "and"),
702        Opcode::Or => write!(w, "or"),
703
704        // Global vars
705        Opcode::GetGlobal(id) => write!(w, "get_global {id}"),
706        Opcode::SetGlobal(id) => write!(w, "set_global {id}"),
707
708        // Temp vars
709        Opcode::DeclareTemp(idx) => write!(w, "declare_temp {idx}"),
710        Opcode::GetTemp(idx) => write!(w, "get_temp {idx}"),
711        Opcode::SetTemp(idx) => write!(w, "set_temp {idx}"),
712        Opcode::GetTempRaw(idx) => write!(w, "get_temp_raw {idx}"),
713
714        // Variable pointers
715        Opcode::PushVarPointer(id) => write!(w, "push_var_pointer {id}"),
716        Opcode::PushTempPointer(slot) => write!(w, "push_temp_pointer {slot}"),
717
718        // Control flow
719        Opcode::Jump(off) => write!(w, "jump {off}"),
720        Opcode::JumpIfFalse(off) => write!(w, "jump_if_false {off}"),
721        Opcode::Goto(id) => write!(w, "goto {id}"),
722        Opcode::GotoIf(id) => write!(w, "goto_if {id}"),
723        Opcode::GotoVariable => write!(w, "goto_variable"),
724
725        // Container flow
726        Opcode::EnterContainer(id) => write!(w, "enter_container {id}"),
727        Opcode::ExitContainer => write!(w, "exit_container"),
728
729        // Functions / tunnels
730        Opcode::Call(id) => write!(w, "call {id}"),
731        Opcode::Return => write!(w, "return"),
732        Opcode::TunnelCall(id) => write!(w, "tunnel_call {id}"),
733        Opcode::TunnelReturn => write!(w, "tunnel_return"),
734        Opcode::TunnelCallVariable => write!(w, "tunnel_call_variable"),
735        Opcode::CallVariable(argc) => write!(w, "call_variable argc={argc}"),
736
737        // Threads
738        Opcode::ThreadCall(id) => write!(w, "thread_call {id}"),
739        Opcode::ThreadStart => write!(w, "thread_start"),
740        Opcode::ThreadDone => write!(w, "thread_done"),
741
742        // Output
743        Opcode::EmitLine(idx, slots) => write!(w, "emit_line {idx} {slots}"),
744        Opcode::EmitValue => write!(w, "emit_value"),
745        Opcode::EmitNewline => write!(w, "emit_newline"),
746        Opcode::EmitLineNl(idx, slots) => write!(w, "emit_line_nl {idx} {slots}"),
747        // `kind=` rather than a bare word: a bare `add` would parse as a trailing
748        // operand of the previous instruction and swallow the `add` mnemonic on
749        // the next line (#3273's hazard); `kv_operand` is the grammar's safe
750        // form for word-valued operands.
751        Opcode::BinaryImm(kind, imm) => write!(w, "binary_imm kind={} {imm}", kind.mnemonic()),
752        Opcode::BinaryJumpIfFalse(kind, rel) => {
753            write!(w, "binary_jump_if_false kind={} {rel}", kind.mnemonic())
754        }
755        Opcode::BinaryImmJumpIfFalse(kind, imm, rel) => {
756            write!(
757                w,
758                "binary_imm_jump_if_false kind={} {imm} {rel}",
759                kind.mnemonic()
760            )
761        }
762        Opcode::GetTempBinaryImm(slot, kind, imm) => {
763            write!(
764                w,
765                "get_temp_binary_imm {slot} kind={} {imm}",
766                kind.mnemonic()
767            )
768        }
769        Opcode::GetTempBinaryImmJumpIfFalse(slot, kind, imm, rel) => write!(
770            w,
771            "get_temp_binary_imm_jump_if_false {slot} kind={} {imm} {rel}",
772            kind.mnemonic()
773        ),
774        Opcode::DuplicateBinaryImmJumpIfFalse(kind, imm, rel) => write!(
775            w,
776            "duplicate_binary_imm_jump_if_false kind={} {imm} {rel}",
777            kind.mnemonic()
778        ),
779        Opcode::Spring => write!(w, "spring"),
780        Opcode::Glue => write!(w, "glue"),
781        Opcode::BeginTag => write!(w, "begin_tag"),
782        Opcode::EndTag => write!(w, "end_tag"),
783        Opcode::EvalLine(idx, slots) => write!(w, "eval_line {idx} {slots}"),
784        Opcode::BeginFragment => write!(w, "begin_fragment"),
785        Opcode::EndFragment => write!(w, "end_fragment"),
786        Opcode::AttachElement => write!(w, "attach_element"),
787        Opcode::EndElementRun => write!(w, "end_element_run"),
788
789        // Choices
790        Opcode::BeginChoice(flags, target) => {
791            write!(w, "begin_choice {} {target}", format_choice_flags(*flags))
792        }
793        Opcode::EndChoice => write!(w, "end_choice"),
794
795        // Sequences
796        Opcode::Sequence(kind, count) => {
797            write!(w, "sequence {} {count}", format_sequence_kind(*kind))
798        }
799        Opcode::SequenceBranch(off) => write!(w, "sequence_branch {off}"),
800
801        // Intrinsics
802        Opcode::VisitCount => write!(w, "visit_count"),
803        Opcode::TurnsSince => write!(w, "turns_since"),
804        Opcode::TurnIndex => write!(w, "turn_index"),
805        Opcode::ChoiceCount => write!(w, "choice_count"),
806        Opcode::Random => write!(w, "random"),
807        Opcode::SeedRandom => write!(w, "seed_random"),
808
809        // Casts / math
810        Opcode::CastToInt => write!(w, "cast_to_int"),
811        Opcode::CastToFloat => write!(w, "cast_to_float"),
812        Opcode::Floor => write!(w, "floor"),
813        Opcode::Ceiling => write!(w, "ceiling"),
814        Opcode::Pow => write!(w, "pow"),
815        Opcode::Min => write!(w, "min"),
816        Opcode::Max => write!(w, "max"),
817
818        // External fns
819        Opcode::CallExternal(id, argc) => write!(w, "call_external {id} argc={argc}"),
820
821        // List ops
822        Opcode::ListContains => write!(w, "list_contains"),
823        Opcode::ListNotContains => write!(w, "list_not_contains"),
824        Opcode::ListIntersect => write!(w, "list_intersect"),
825        Opcode::ListAll => write!(w, "list_all"),
826        Opcode::ListInvert => write!(w, "list_invert"),
827        Opcode::ListCount => write!(w, "list_count"),
828        Opcode::ListMin => write!(w, "list_min"),
829        Opcode::ListMax => write!(w, "list_max"),
830        Opcode::ListValue => write!(w, "list_value"),
831        Opcode::ListRange => write!(w, "list_range"),
832        Opcode::ListFromInt => write!(w, "list_from_int"),
833        Opcode::ListRandom => write!(w, "list_random"),
834
835        // Collections (T1b)
836        Opcode::ArrayNew(n) => write!(w, "array_new {n}"),
837        Opcode::MapNew(n) => write!(w, "map_new {n}"),
838        Opcode::IndexGet => write!(w, "index_get"),
839        Opcode::IndexSet => write!(w, "index_set"),
840        Opcode::CollectionLen => write!(w, "collection_len"),
841        Opcode::MapGet => write!(w, "map_get"),
842        Opcode::MapInsert => write!(w, "map_insert"),
843        Opcode::MapRemove => write!(w, "map_remove"),
844        Opcode::MapContains => write!(w, "map_contains"),
845        Opcode::CollectionKeys => write!(w, "collection_keys"),
846        Opcode::CollectionValues => write!(w, "collection_values"),
847        Opcode::PushLiteral(idx) => write!(w, "push_literal {idx}"),
848
849        // Sharing discipline (T1b-4)
850        Opcode::TakeGlobal(id) => write!(w, "take_global {id}"),
851        Opcode::TakeTemp(idx) => write!(w, "take_temp {idx}"),
852
853        // Lifecycle
854        Opcode::Done => write!(w, "done"),
855        Opcode::Yield => write!(w, "yield"),
856        Opcode::End => write!(w, "end"),
857        Opcode::Nop => write!(w, "nop"),
858
859        // String eval
860        Opcode::BeginStringEval => write!(w, "begin_string_eval"),
861        Opcode::EndStringEval => write!(w, "end_string_eval"),
862
863        // Visit
864        Opcode::CurrentVisitCount => write!(w, "current_visit_count"),
865        Opcode::TouchVisit => write!(w, "touch_visit"),
866        Opcode::ShuffleIndexOf => write!(w, "shuffle_index_of"),
867
868        // Records (TM-4)
869        Opcode::RecordNew(shape_id) => write!(w, "record_new {shape_id}"),
870        Opcode::RecordGetDyn(name_id) => write!(w, "record_get_dyn {name_id}"),
871        Opcode::RecordSetDyn(name_id) => write!(w, "record_set_dyn {name_id}"),
872        Opcode::RecordGet(offset) => write!(w, "record_get {offset}"),
873        Opcode::RecordSet(offset) => write!(w, "record_set {offset}"),
874
875        // Conversion intrinsics (TM-3 completion, #659)
876        Opcode::ConvertInt => write!(w, "convert_int"),
877        Opcode::ConvertFloat => write!(w, "convert_float"),
878        Opcode::ConvertString => write!(w, "convert_string"),
879
880        // Function values (T1c, #700)
881        Opcode::PushFnRef(id) => write!(w, "push_fn_ref {id}"),
882        Opcode::MakeClosure {
883            target,
884            bound_count,
885        } => write!(w, "make_closure {target} bound={bound_count}"),
886        Opcode::CallValue(argc) => write!(w, "call_value argc={argc}"),
887        Opcode::BindValue(argc) => write!(w, "bind_value argc={argc}"),
888
889        // Path projections (T1e)
890        Opcode::MakeProjection {
891            root,
892            segment_count,
893        } => write!(w, "make_projection {root} segments={segment_count}"),
894        Opcode::ProjRead => write!(w, "proj_read"),
895        Opcode::ProjWrite => write!(w, "proj_write"),
896
897        // Stdlib slice 1 completion (#857)
898        Opcode::CharAt => write!(w, "char_at"),
899
900        // NS-A1 Option + stdlib flips
901        Opcode::PushNone => write!(w, "push_none"),
902        Opcode::MakeSome => write!(w, "make_some"),
903        Opcode::StrFind => write!(w, "str_find"),
904        Opcode::SeqIndexOf => write!(w, "seq_index_of"),
905        Opcode::SeqMin => write!(w, "seq_min"),
906        Opcode::SeqMax => write!(w, "seq_max"),
907        Opcode::SeqFirst => write!(w, "seq_first"),
908        Opcode::SeqLast => write!(w, "seq_last"),
909        Opcode::SeqPop => write!(w, "seq_pop"),
910        Opcode::MapGetOpt => write!(w, "map_get_opt"),
911        Opcode::MapContainsValue => write!(w, "map_contains_value"),
912        Opcode::MapClear => write!(w, "map_clear"),
913        // B1 `or`-coalescing, short-circuited (issue #1471).
914        Opcode::CoalesceSome(off) => write!(w, "coalesce_some {off}"),
915        Opcode::OptionBind(slot) => write!(w, "option_bind {slot}"),
916        // Seq `remove_at` (issue #1484).
917        Opcode::SeqRemoveAt => write!(w, "seq_remove_at"),
918        // NS-A6 rand verbs (#1112).
919        Opcode::RandFloat => write!(w, "rand_float"),
920        Opcode::RandChance => write!(w, "rand_chance"),
921        Opcode::RandPick => write!(w, "rand_pick"),
922        Opcode::RandShuffle => write!(w, "rand_shuffle"),
923        Opcode::RangeMakeExcl => write!(w, "range_make_excl"),
924        Opcode::RangeMakeIncl => write!(w, "range_make_incl"),
925        Opcode::RangeNonEmpty => write!(w, "range_non_empty"),
926        // NS-A4 ordering verbs (#1110).
927        Opcode::SeqSorted => write!(w, "seq_sorted"),
928        Opcode::SeqSortedBy => write!(w, "seq_sorted_by"),
929
930        // NS-A8 numeric tower: the kind's own mnemonic IS the instruction
931        // word (`make_vec2` … `tower_lerp`) — one wire opcode, thirteen
932        // spellings, `TowerOp::mnemonic`/`from_mnemonic` the single pairing.
933        Opcode::Tower(op) => write!(w, "{}", op.mnemonic()),
934
935        // NS-A7 collections+: same one-opcode-per-kind-mnemonic pattern as
936        // the tower — `CollectOp::mnemonic`/`from_mnemonic` the single
937        // pairing (`weighted_new` … `heap_peek`).
938        Opcode::Collect(op) => write!(w, "{}", op.mnemonic()),
939
940        // The fn-value verbs (issue #1679): same one-opcode-per-kind-
941        // mnemonic pattern — the mnemonic IS the source spelling, for all
942        // six kinds (`map`/`filter`/`fold`/`filter_map`/`each`/`map_each`),
943        // `SeqVerbOp::mnemonic`/`from_mnemonic` the single pairing.
944        Opcode::SeqVerb(op) => write!(w, "{}", op.mnemonic()),
945    }
946}
947
948// ── Helpers ──────────────────────────────────────────────────────────────────
949
950fn format_choice_flags(flags: ChoiceFlags) -> String {
951    let mut parts = Vec::new();
952    if flags.has_condition {
953        parts.push("cond");
954    }
955    if flags.has_start_content {
956        parts.push("start");
957    }
958    if flags.has_choice_only_content {
959        parts.push("choice_only");
960    }
961    if flags.once_only {
962        parts.push("once");
963    }
964    if flags.is_invisible_default {
965        parts.push("invis_default");
966    }
967    if parts.is_empty() {
968        "none".to_owned()
969    } else {
970        parts.join("+")
971    }
972}
973
974fn format_sequence_kind(kind: SequenceKind) -> &'static str {
975    match kind {
976        SequenceKind::Cycle => "cycle",
977        SequenceKind::Stopping => "stopping",
978        SequenceKind::OnceOnly => "once_only",
979        SequenceKind::Shuffle => "shuffle",
980    }
981}
982
983fn value_type_name(vt: ValueType) -> &'static str {
984    match vt {
985        ValueType::Int => "int",
986        ValueType::Float => "float",
987        ValueType::Bool => "bool",
988        ValueType::String => "string",
989        ValueType::List => "list",
990        ValueType::DivertTarget => "divert_target",
991        ValueType::VariablePointer => "var_pointer",
992        ValueType::TempPointer => "temp_pointer",
993        ValueType::Null => "null",
994        ValueType::FragmentRef => "fragment_ref",
995        ValueType::Array => "array",
996        ValueType::Map => "map",
997        ValueType::Record => "record",
998        ValueType::FnRef => "fn_ref",
999        ValueType::Closure => "closure",
1000        ValueType::Handle => "handle",
1001        ValueType::Projection => "projection",
1002        ValueType::Option => "option",
1003        ValueType::Range => "range",
1004        ValueType::Vec2 => "vec2",
1005        ValueType::Vec3 => "vec3",
1006        ValueType::Vec4 => "vec4",
1007        ValueType::Quat => "quat",
1008        ValueType::Mat2 => "mat2",
1009        ValueType::Mat3 => "mat3",
1010        ValueType::Mat4 => "mat4",
1011        ValueType::Weighted => "weighted",
1012    }
1013}
1014
1015#[expect(
1016    clippy::too_many_lines,
1017    reason = "one atom arm per Value variant — the NS-A7 Weighted arm pushed this past 100"
1018)]
1019fn write_value(w: &mut dyn fmt::Write, v: &Value) -> fmt::Result {
1020    match v {
1021        Value::Int(n) => write!(w, "{n}"),
1022        Value::Float(n) => write_float_atom(w, *n),
1023        Value::Bool(b) => write!(w, "{b}"),
1024        Value::String(s) => write!(w, "\"{}\"", escape_string(s)),
1025        Value::List(lv) => {
1026            write!(w, "(list (items")?;
1027            for item in &lv.items {
1028                write!(w, " {item}")?;
1029            }
1030            write!(w, ") (origins")?;
1031            for origin in &lv.origins {
1032                write!(w, " {origin}")?;
1033            }
1034            write!(w, "))")
1035        }
1036        Value::DivertTarget(id) => write!(w, "{id}"),
1037        Value::VariablePointer(id) => write!(w, "(var_pointer {id})"),
1038        Value::TempPointer { slot, frame_depth } => {
1039            write!(w, "(temp_pointer {slot} {frame_depth})")
1040        }
1041        Value::Null => write!(w, "null"),
1042        Value::FragmentRef(idx) => write!(w, "(fragment_ref {idx})"),
1043        // Array/Map render as nested s-expressions — the textual mirror of the
1044        // v4 `.inkb`/transcript tree encoding (`docs/format-v4-rfc.md` §1). A
1045        // collection reaches the dump whenever a binding/external return value
1046        // (which since #525 can be a collection) is stored or emitted.
1047        Value::Array(items) => {
1048            write!(w, "(array")?;
1049            for item in items.iter() {
1050                write!(w, " ")?;
1051                write_value(w, item)?;
1052            }
1053            write!(w, ")")
1054        }
1055        Value::Map(map) => {
1056            write!(w, "(map")?;
1057            for (key, value) in map.iter() {
1058                write!(w, " (")?;
1059                write_map_key(w, key)?;
1060                write!(w, " ")?;
1061                write_value(w, value)?;
1062                write!(w, ")")?;
1063            }
1064            write!(w, ")")
1065        }
1066        Value::Record { shape, fields } => {
1067            write!(w, "(record {}", shape.0)?;
1068            for field in fields.iter() {
1069                write!(w, " ")?;
1070                write_value(w, field)?;
1071            }
1072            write!(w, ")")
1073        }
1074        // Function values (T1c, #700): a fn value can reach the dump as a
1075        // binding/global default value the same way a collection can.
1076        Value::FnRef(target) => write!(w, "(fn_ref {target})"),
1077        Value::Closure(c) => {
1078            write!(w, "(closure {}", c.target)?;
1079            for entry in &c.env {
1080                let mode = if entry.is_ref { "ref" } else { "val" };
1081                write!(w, " ({mode} {} ", entry.name.0)?;
1082                write_value(w, &entry.payload)?;
1083                write!(w, ")")?;
1084            }
1085            write!(w, ")")
1086        }
1087        // Handle values (T1d, `docs/t1d-spec.md` §2): `(handle <kind> <id>)`
1088        // — kind as its raw NameId (the human-readable kind name lives in the
1089        // name table, resolved by tooling that has it, not by this dump).
1090        Value::Handle { kind, id } => write!(w, "(handle {} {id})", kind.0),
1091        // Projection values (T1e, `docs/t1e-spec.md` §3): `(projection <cell>
1092        // (segments <seg>…))`, each segment `(index <n>)` or `(key <value>)`
1093        // — the reader parity this dump exists for (dump/reader parity, the
1094        // #742 lesson).
1095        Value::Projection(p) => {
1096            write!(w, "(projection {} (segments", p.cell)?;
1097            for seg in &p.segments {
1098                write!(w, " ")?;
1099                write_proj_segment(w, seg)?;
1100            }
1101            write!(w, "))")
1102        }
1103        // Option values (NS-A1, `docs/stdlib-spec.md` §1.4): `(some
1104        // <value>)` / `(option_none)` — reader lands with the writer in this
1105        // same PR (the #742 dump/reader parity lesson). `option_none`, not
1106        // bare `none`, because `none` is already a choice-flags token in the
1107        // grammar.
1108        Value::OptionVal(inner) => match inner {
1109            None => write!(w, "(option_none)"),
1110            Some(v) => {
1111                write!(w, "(some ")?;
1112                write_value(w, v)?;
1113                write!(w, ")")
1114            }
1115        },
1116        // Range values (NS-A5, `docs/stdlib-spec.md` §7, F7): `(range
1117        // <start> <end> incl|excl)` — the written form is preserved via the
1118        // incl/excl token; reader lands with the writer in this same PR
1119        // (the #742 dump/reader parity lesson).
1120        Value::Range {
1121            start,
1122            end,
1123            inclusive,
1124        } => {
1125            let form = if *inclusive { "incl" } else { "excl" };
1126            write!(w, "(range {start} {end} {form})")
1127        }
1128        // Tower values (NS-A8, `docs/tower-mini-spec.md` T5): `(vec2 <x>
1129        // <y>)` … `(mat4 <16 column-major lanes>)` — the textual mirror of
1130        // the VAL_VEC2..VAL_MAT4 wire tags, reader landing with the writer
1131        // in this same PR (the #742 dump/reader parity lesson). Lanes come
1132        // from glam's explicit array conversions, never its memory layout.
1133        Value::Vec2(v) => write_tower_lanes(w, "vec2", &v.to_array()),
1134        Value::Vec3(v) => write_tower_lanes(w, "vec3", &v.to_array()),
1135        Value::Vec4(v) => write_tower_lanes(w, "vec4", &v.to_array()),
1136        Value::Quat(q) => write_tower_lanes(w, "quat", &q.to_array()),
1137        Value::Mat2(m) => write_tower_lanes(w, "mat2", &m.to_cols_array()),
1138        Value::Mat3(m) => write_tower_lanes(w, "mat3", &m.to_cols_array()),
1139        Value::Mat4(m) => write_tower_lanes(w, "mat4", &m.to_cols_array()),
1140        // Weighted tables (NS-A7, `docs/stdlib-spec.md` §8): `(weighted
1141        // (<weight> <value>)+)`, entries in construction order.
1142        Value::Weighted(wt) => {
1143            write!(w, "(weighted")?;
1144            for (weight, value) in &wt.entries {
1145                write!(w, " ({weight} ")?;
1146                write_value(w, value)?;
1147                write!(w, ")")?;
1148            }
1149            write!(w, ")")
1150        }
1151    }
1152}
1153
1154/// Write one f32 with a guaranteed decimal point (unambiguous float atom) —
1155/// the exact convention `write_value`'s `Float` arm has always used, shared
1156/// with the NS-A8 tower lanes.
1157fn write_float_atom(w: &mut dyn fmt::Write, n: f32) -> fmt::Result {
1158    let s = format!("{n}");
1159    if s.contains('.') || s.contains("inf") || s.contains("NaN") {
1160        write!(w, "{s}")
1161    } else {
1162        write!(w, "{s}.0")
1163    }
1164}
1165
1166/// NS-A8 tower atom body: the tag word then the flat lanes in the pinned
1167/// order (`docs/tower-mini-spec.md` T5 — vec/quat `x y (z w)`, matrices
1168/// column-major), each lane through [`write_float_atom`].
1169fn write_tower_lanes(w: &mut dyn fmt::Write, tag: &str, lanes: &[f32]) -> fmt::Result {
1170    write!(w, "({tag}")?;
1171    for lane in lanes {
1172        write!(w, " ")?;
1173        write_float_atom(w, *lane)?;
1174    }
1175    write!(w, ")")
1176}
1177
1178fn write_proj_segment(w: &mut dyn fmt::Write, seg: &ProjSegment) -> fmt::Result {
1179    match seg {
1180        ProjSegment::Index(n) => write!(w, "(index {n})"),
1181        ProjSegment::Key(v) => {
1182            write!(w, "(key ")?;
1183            write_value(w, v)?;
1184            write!(w, ")")
1185        }
1186    }
1187}
1188
1189fn write_map_key(w: &mut dyn fmt::Write, key: &MapKey) -> fmt::Result {
1190    match key {
1191        MapKey::Int(n) => write!(w, "{n}"),
1192        MapKey::Str(s) => write!(w, "\"{}\"", escape_string(s)),
1193        MapKey::Bool(b) => write!(w, "{b}"),
1194    }
1195}
1196
1197pub(crate) fn escape_string(s: &str) -> String {
1198    let mut out = String::with_capacity(s.len());
1199    for c in s.chars() {
1200        match c {
1201            '\\' => out.push_str("\\\\"),
1202            '"' => out.push_str("\\\""),
1203            '\n' => out.push_str("\\n"),
1204            '\t' => out.push_str("\\t"),
1205            '\r' => out.push_str("\\r"),
1206            other => out.push(other),
1207        }
1208    }
1209    out
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::*;
1215    use crate::id::{DefinitionId, DefinitionTag};
1216
1217    #[test]
1218    fn definition_id_display() {
1219        let id = DefinitionId::new(DefinitionTag::Address, 0xDEAD_BEEF);
1220        assert_eq!(format!("{id}"), "$01_000000deadbeef");
1221    }
1222
1223    #[test]
1224    fn escape_special_chars() {
1225        assert_eq!(escape_string("hello"), "hello");
1226        assert_eq!(escape_string("a\"b"), "a\\\"b");
1227        assert_eq!(escape_string("a\\b"), "a\\\\b");
1228        assert_eq!(escape_string("a\nb"), "a\\nb");
1229        assert_eq!(escape_string("a\tb"), "a\\tb");
1230    }
1231
1232    #[test]
1233    fn empty_story() {
1234        let story = StoryData {
1235            containers: vec![],
1236            line_tables: vec![],
1237            variables: vec![],
1238            list_defs: vec![],
1239            list_items: vec![],
1240            externals: vec![],
1241            addresses: vec![],
1242            address_paths: vec![],
1243            name_table: vec![],
1244            list_literals: vec![],
1245            literal_pool: vec![],
1246            struct_shapes: vec![],
1247            private_defs: vec![],
1248            alias_table: vec![],
1249            effect_rows: vec![],
1250            frame_shapes: Vec::new(),
1251            debug_info: None,
1252            line_variant_groups: Vec::new(),
1253            source_checksum: 0,
1254        };
1255        let mut buf = String::new();
1256        write_inkt(&story, &mut buf).unwrap();
1257        assert_eq!(buf, "(story\n)");
1258    }
1259
1260    #[test]
1261    fn choice_flags_formatting() {
1262        let flags = ChoiceFlags {
1263            has_condition: true,
1264            has_start_content: false,
1265            has_choice_only_content: false,
1266            once_only: true,
1267            is_invisible_default: false,
1268        };
1269        assert_eq!(format_choice_flags(flags), "cond+once");
1270
1271        let empty = ChoiceFlags {
1272            has_condition: false,
1273            has_start_content: false,
1274            has_choice_only_content: false,
1275            once_only: false,
1276            is_invisible_default: false,
1277        };
1278        assert_eq!(format_choice_flags(empty), "none");
1279    }
1280}