Skip to main content

delvewright_dsl/
fmt.rs

1//! `delvec fmt` — the canonical form for authored Delvewright JSON.
2//!
3//! # Why this exists
4//!
5//! Authored JSON was formatted by whatever wrote it last. A three-key insertion
6//! into `nobodys-cave-island/l10n/zh-cn.json` produced a **103-insertion /
7//! 100-deletion** diff, because the file was not canonically ordered and the
8//! writing tool's `sort_keys` re-laid the whole thing out. A canonical order
9//! makes an insertion a one-line insertion and makes two authors editing
10//! different keys a non-conflict.
11//!
12//! # The hard constraint
13//!
14//! **Only OBJECT KEYS may be sorted.** Array order is semantic everywhere in
15//! this DSL — `quests[]`, `objectives[]`, `effects[]`, `options[]`, `steps[]`
16//! are ordered, and reordering one changes the game. So a code path that could
17//! reorder an array is a correctness bug, not a style bug, and this module does
18//! not merely avoid one — it *proves* the absence on every file it writes:
19//! [`format_text`] re-parses its own output and runs [`equivalent`], which
20//! compares arrays **index-wise** and objects as key→value maps. A formatter
21//! that sorted an array would fail its own check and refuse to write, rather
22//! than shipping a silently reordered campaign. (`DW0772`.)
23//!
24//! # The canonical form
25//!
26//! Every choice below is argued from the two goals — *minimal diff on
27//! insertion* and *no semantic change ever* — never from taste.
28//!
29//! | rule | why |
30//! |---|---|
31//! | object keys sorted by Unicode scalar value | the whole point: an inserted key lands in exactly one place, so the diff is one line. Byte order of UTF-8 == code-point order, so Rust's `str` `Ord` and Python's `sorted()` agree — the existing Python authoring tools already emit this order. |
32//! | two-space indent, one value per line | the motivating file and every `tools/*.py` writer already use `indent=2` (and it is `serde_json`'s pretty default), so the one-time normalization is near-zero on the largest authored files. One value per line makes an inserted array element a whole-line insertion instead of a rewrite of a long line. |
33//! | non-ASCII written raw, never `\uXXXX` | the campaigns are half Chinese. Escaping would triple every sidecar and make its diffs unreadable — a direct defeat of the task's own motivation. |
34//! | control characters escaped, shortest form (`\n`, `\t`, `\b`, `\f`, `\r`, else `\u00xx`) | required by JSON; the shortest form is the one every other writer here emits, so it is already the fixed point. |
35//! | **number literals preserved byte-for-byte** | the only rule that is *not* about diffs. Re-rendering a number through `f64` loses integers above 2^53 and can move the last digit of a decimal — a silent semantic change, which the hard constraint forbids outright. No author writes numbers in a way that churns a diff, so there is nothing to gain against a real risk. |
36//! | exactly one trailing newline | POSIX text; and without it, appending anything rewrites the last line. |
37//! | duplicate object keys refused (`DW0771`) | JSON's own grammar allows them and `serde_json` silently keeps the last, so today a duplicate key is data the compiler discards without a word. Formatting it would make that loss permanent and invisible, so `fmt` refuses instead. |
38//!
39//! Deliberately NOT canonicalized: number literals (above), string Unicode
40//! normalization (NFC vs NFD is the author's text, not the formatter's), and
41//! anything schema-shaped. The formatter knows the JSON grammar and nothing
42//! about the DSL — it must format an l10n sidecar, a stage document, a prefab
43//! metadata card and whatever stage 8 turns out to be, with no per-schema list
44//! to keep in step.
45//!
46//! # Determinism (ADR-0006)
47//!
48//! Output is a pure function of input bytes: no wall clock, no RNG, no
49//! `HashMap` iteration (keys are sorted explicitly), no absolute paths in
50//! output. Directory discovery sorts entries rather than trusting `read_dir`.
51
52use crate::diagnostic::{DwCode, ExitTier};
53use std::cmp::Ordering;
54use std::path::{Path, PathBuf};
55
56/// Indent width of the canonical form. Not configurable: a formatter with
57/// options has as many canonical forms as it has flag combinations, and the
58/// point of this one is that there is exactly one.
59pub const INDENT: usize = 2;
60
61/// A JSON document, parsed for formatting.
62///
63/// Deliberately **not** `serde_json::Value`: that type parses a number into
64/// `i64`/`u64`/`f64` and loses the literal the author wrote, which is precisely
65/// the byte the "no semantic change ever" constraint says must survive. It also
66/// silently accepts duplicate object keys.
67#[derive(Debug, Clone, PartialEq)]
68pub enum Node {
69    Null,
70    Bool(bool),
71    /// The number literal, **verbatim** — never re-rendered.
72    Number(String),
73    /// A string with all escapes decoded.
74    Str(String),
75    /// Ordered, and it stays ordered. See the module docs.
76    Array(Vec<Node>),
77    /// Key → value in the order the file had them; sorted only when written.
78    Object(Vec<(String, Node)>),
79}
80
81/// A parse failure, located for an author.
82#[derive(Debug, Clone, PartialEq)]
83pub struct ParseError {
84    /// `DW0771` for a duplicate object key, `DW0770` for a syntax error.
85    pub code: DwCode,
86    /// 1-based line.
87    pub line: usize,
88    /// 1-based column, in characters.
89    pub col: usize,
90    pub message: String,
91}
92
93impl std::fmt::Display for ParseError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        write!(f, "{}:{}: {}", self.line, self.col, self.message)
96    }
97}
98
99/// `DW0770`: authored JSON that is not valid JSON (`delvec fmt`).
100pub const DW_FMT_PARSE: DwCode = DwCode::new("DW0770", ExitTier::Build);
101/// `DW0771`: a duplicate object key in authored JSON (`delvec fmt`).
102pub const DW_FMT_DUPLICATE_KEY: DwCode = DwCode::new("DW0771", ExitTier::Build);
103/// `DW0772`: the formatter's own output is not equivalent to its input —
104/// internal error, nothing is written (`delvec fmt`).
105pub const DW_FMT_NOT_EQUIVALENT: DwCode = DwCode::new("DW0772", ExitTier::Build);
106/// `DW0773`: a file is not in canonical form (`delvec fmt --check`).
107pub const DW_FMT_UNFORMATTED: DwCode = DwCode::new("DW0773", ExitTier::Build);
108/// `DW0774`: `delvec fmt` matched no files — a formatter or a check that binds
109/// to nothing is vacuous, not a pass (CLAUDE.md).
110pub const DW_FMT_NO_BINDING: DwCode = DwCode::new("DW0774", ExitTier::Build);
111
112// ---------------------------------------------------------------- parsing --
113
114struct Parser<'a> {
115    src: &'a [u8],
116    pos: usize,
117    line: usize,
118    col: usize,
119}
120
121impl<'a> Parser<'a> {
122    fn new(src: &'a str) -> Self {
123        Parser {
124            src: src.as_bytes(),
125            pos: 0,
126            line: 1,
127            col: 1,
128        }
129    }
130
131    fn err(&self, code: DwCode, message: impl Into<String>) -> ParseError {
132        ParseError {
133            code,
134            line: self.line,
135            col: self.col,
136            message: message.into(),
137        }
138    }
139
140    fn peek(&self) -> Option<u8> {
141        self.src.get(self.pos).copied()
142    }
143
144    /// Advance one byte, keeping line/col honest. Column counts characters, so
145    /// UTF-8 continuation bytes (`10xxxxxx`) do not advance it.
146    fn bump(&mut self) -> Option<u8> {
147        let b = self.src.get(self.pos).copied()?;
148        self.pos += 1;
149        if b == b'\n' {
150            self.line += 1;
151            self.col = 1;
152        } else if b & 0xC0 != 0x80 {
153            self.col += 1;
154        }
155        Some(b)
156    }
157
158    fn skip_ws(&mut self) {
159        while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
160            self.bump();
161        }
162    }
163
164    fn expect(&mut self, want: u8) -> Result<(), ParseError> {
165        match self.peek() {
166            Some(b) if b == want => {
167                self.bump();
168                Ok(())
169            }
170            Some(b) => Err(self.err(
171                DW_FMT_PARSE,
172                format!(
173                    "expected `{}`, found `{}`",
174                    want as char,
175                    escape_for_message(b)
176                ),
177            )),
178            None => Err(self.err(
179                DW_FMT_PARSE,
180                format!("expected `{}`, found end of file", want as char),
181            )),
182        }
183    }
184
185    fn literal(&mut self, word: &str, node: Node) -> Result<Node, ParseError> {
186        if self.src[self.pos..].starts_with(word.as_bytes()) {
187            for _ in 0..word.len() {
188                self.bump();
189            }
190            Ok(node)
191        } else {
192            Err(self.err(DW_FMT_PARSE, format!("expected `{word}`")))
193        }
194    }
195
196    fn value(&mut self) -> Result<Node, ParseError> {
197        self.skip_ws();
198        match self.peek() {
199            Some(b'{') => self.object(),
200            Some(b'[') => self.array(),
201            Some(b'"') => Ok(Node::Str(self.string()?)),
202            Some(b't') => self.literal("true", Node::Bool(true)),
203            Some(b'f') => self.literal("false", Node::Bool(false)),
204            Some(b'n') => self.literal("null", Node::Null),
205            Some(b'-' | b'0'..=b'9') => self.number(),
206            Some(b) => Err(self.err(
207                DW_FMT_PARSE,
208                format!("unexpected `{}`", escape_for_message(b)),
209            )),
210            None => Err(self.err(DW_FMT_PARSE, "unexpected end of file")),
211        }
212    }
213
214    fn object(&mut self) -> Result<Node, ParseError> {
215        self.expect(b'{')?;
216        let mut entries: Vec<(String, Node)> = Vec::new();
217        self.skip_ws();
218        if self.peek() == Some(b'}') {
219            self.bump();
220            return Ok(Node::Object(entries));
221        }
222        loop {
223            self.skip_ws();
224            let key_line = self.line;
225            let key_col = self.col;
226            let key = self.string()?;
227            // A duplicate key is data the compiler already discards silently
228            // (serde_json keeps the last). Formatting would erase the evidence,
229            // so refuse and say which key and where.
230            if entries.iter().any(|(k, _)| *k == key) {
231                return Err(ParseError {
232                    code: DW_FMT_DUPLICATE_KEY,
233                    line: key_line,
234                    col: key_col,
235                    message: format!(
236                        "duplicate object key `{key}`. JSON allows it and the compiler's \
237                         parser silently keeps the LAST one, so one of these two values is \
238                         already being discarded without a word. Formatting would make that \
239                         loss permanent and invisible, so `delvec fmt` refuses: delete or \
240                         rename whichever occurrence is wrong."
241                    ),
242                });
243            }
244            self.skip_ws();
245            self.expect(b':')?;
246            let value = self.value()?;
247            entries.push((key, value));
248            self.skip_ws();
249            match self.peek() {
250                Some(b',') => {
251                    self.bump();
252                    self.skip_ws();
253                    if self.peek() == Some(b'}') {
254                        return Err(self.err(
255                            DW_FMT_PARSE,
256                            "trailing comma before `}` (JSON has no trailing commas)",
257                        ));
258                    }
259                }
260                Some(b'}') => {
261                    self.bump();
262                    return Ok(Node::Object(entries));
263                }
264                Some(b) => {
265                    return Err(self.err(
266                        DW_FMT_PARSE,
267                        format!("expected `,` or `}}`, found `{}`", escape_for_message(b)),
268                    ));
269                }
270                None => {
271                    return Err(self.err(DW_FMT_PARSE, "unterminated object"));
272                }
273            }
274        }
275    }
276
277    fn array(&mut self) -> Result<Node, ParseError> {
278        self.expect(b'[')?;
279        let mut items = Vec::new();
280        self.skip_ws();
281        if self.peek() == Some(b']') {
282            self.bump();
283            return Ok(Node::Array(items));
284        }
285        loop {
286            items.push(self.value()?);
287            self.skip_ws();
288            match self.peek() {
289                Some(b',') => {
290                    self.bump();
291                    self.skip_ws();
292                    if self.peek() == Some(b']') {
293                        return Err(self.err(
294                            DW_FMT_PARSE,
295                            "trailing comma before `]` (JSON has no trailing commas)",
296                        ));
297                    }
298                }
299                Some(b']') => {
300                    self.bump();
301                    return Ok(Node::Array(items));
302                }
303                Some(b) => {
304                    return Err(self.err(
305                        DW_FMT_PARSE,
306                        format!("expected `,` or `]`, found `{}`", escape_for_message(b)),
307                    ));
308                }
309                None => {
310                    return Err(self.err(DW_FMT_PARSE, "unterminated array"));
311                }
312            }
313        }
314    }
315
316    /// The JSON number grammar. The raw literal is captured and kept verbatim —
317    /// see the module docs for why it is never re-rendered.
318    fn number(&mut self) -> Result<Node, ParseError> {
319        let start = self.pos;
320        if self.peek() == Some(b'-') {
321            self.bump();
322        }
323        match self.peek() {
324            Some(b'0') => {
325                self.bump();
326            }
327            Some(b'1'..=b'9') => {
328                while matches!(self.peek(), Some(b'0'..=b'9')) {
329                    self.bump();
330                }
331            }
332            _ => return Err(self.err(DW_FMT_PARSE, "expected a digit after `-`")),
333        }
334        if self.peek() == Some(b'.') {
335            self.bump();
336            if !matches!(self.peek(), Some(b'0'..=b'9')) {
337                return Err(self.err(DW_FMT_PARSE, "expected a digit after `.`"));
338            }
339            while matches!(self.peek(), Some(b'0'..=b'9')) {
340                self.bump();
341            }
342        }
343        if matches!(self.peek(), Some(b'e' | b'E')) {
344            self.bump();
345            if matches!(self.peek(), Some(b'+' | b'-')) {
346                self.bump();
347            }
348            if !matches!(self.peek(), Some(b'0'..=b'9')) {
349                return Err(self.err(DW_FMT_PARSE, "expected a digit in the exponent"));
350            }
351            while matches!(self.peek(), Some(b'0'..=b'9')) {
352                self.bump();
353            }
354        }
355        // Every byte consumed above is ASCII, so the slice is valid UTF-8.
356        let raw = std::str::from_utf8(&self.src[start..self.pos])
357            .expect("a JSON number literal is ASCII")
358            .to_string();
359        Ok(Node::Number(raw))
360    }
361
362    fn string(&mut self) -> Result<String, ParseError> {
363        self.expect(b'"')?;
364        let mut out = String::new();
365        loop {
366            let Some(b) = self.peek() else {
367                return Err(self.err(DW_FMT_PARSE, "unterminated string"));
368            };
369            match b {
370                b'"' => {
371                    self.bump();
372                    return Ok(out);
373                }
374                b'\\' => {
375                    self.bump();
376                    let Some(esc) = self.bump() else {
377                        return Err(self.err(DW_FMT_PARSE, "unterminated escape"));
378                    };
379                    match esc {
380                        b'"' => out.push('"'),
381                        b'\\' => out.push('\\'),
382                        b'/' => out.push('/'),
383                        b'b' => out.push('\u{0008}'),
384                        b'f' => out.push('\u{000c}'),
385                        b'n' => out.push('\n'),
386                        b'r' => out.push('\r'),
387                        b't' => out.push('\t'),
388                        b'u' => {
389                            let hi = self.hex4()?;
390                            let ch = if (0xD800..0xDC00).contains(&hi) {
391                                // A high surrogate must be followed by `\uDCxx`.
392                                if self.peek() != Some(b'\\') {
393                                    return Err(self.err(
394                                        DW_FMT_PARSE,
395                                        "lone high surrogate: `\\uD800`–`\\uDBFF` must be \
396                                         followed by a low surrogate escape",
397                                    ));
398                                }
399                                self.bump();
400                                if self.peek() != Some(b'u') {
401                                    return Err(self.err(
402                                        DW_FMT_PARSE,
403                                        "lone high surrogate: expected `\\u` low surrogate",
404                                    ));
405                                }
406                                self.bump();
407                                let lo = self.hex4()?;
408                                if !(0xDC00..0xE000).contains(&lo) {
409                                    return Err(self.err(
410                                        DW_FMT_PARSE,
411                                        "high surrogate not followed by a low surrogate",
412                                    ));
413                                }
414                                let cp = 0x1_0000u32
415                                    + ((hi as u32 - 0xD800) << 10)
416                                    + (lo as u32 - 0xDC00);
417                                char::from_u32(cp)
418                                    .ok_or_else(|| self.err(DW_FMT_PARSE, "invalid code point"))?
419                            } else if (0xDC00..0xE000).contains(&hi) {
420                                return Err(self.err(
421                                    DW_FMT_PARSE,
422                                    "lone low surrogate escape (`\\uDC00`–`\\uDFFF`)",
423                                ));
424                            } else {
425                                char::from_u32(hi as u32)
426                                    .ok_or_else(|| self.err(DW_FMT_PARSE, "invalid code point"))?
427                            };
428                            out.push(ch);
429                        }
430                        other => {
431                            return Err(self.err(
432                                DW_FMT_PARSE,
433                                format!("invalid escape `\\{}`", escape_for_message(other)),
434                            ));
435                        }
436                    }
437                }
438                0x00..=0x1F => {
439                    return Err(self.err(
440                        DW_FMT_PARSE,
441                        format!("raw control character U+{b:04X} in a string; escape it"),
442                    ));
443                }
444                _ => {
445                    // Copy the whole UTF-8 sequence byte by byte; the source was
446                    // a `&str`, so it is well-formed by construction.
447                    let start = self.pos;
448                    self.bump();
449                    while matches!(self.peek(), Some(c) if c & 0xC0 == 0x80) {
450                        self.bump();
451                    }
452                    out.push_str(
453                        std::str::from_utf8(&self.src[start..self.pos])
454                            .expect("the source was a &str"),
455                    );
456                }
457            }
458        }
459    }
460
461    fn hex4(&mut self) -> Result<u16, ParseError> {
462        let mut v: u16 = 0;
463        for _ in 0..4 {
464            let Some(b) = self.bump() else {
465                return Err(self.err(DW_FMT_PARSE, "truncated `\\u` escape"));
466            };
467            let d = match b {
468                b'0'..=b'9' => b - b'0',
469                b'a'..=b'f' => b - b'a' + 10,
470                b'A'..=b'F' => b - b'A' + 10,
471                _ => {
472                    return Err(self.err(
473                        DW_FMT_PARSE,
474                        format!(
475                            "`\\u` escape needs 4 hex digits, found `{}`",
476                            escape_for_message(b)
477                        ),
478                    ));
479                }
480            };
481            v = v * 16 + d as u16;
482        }
483        Ok(v)
484    }
485}
486
487fn escape_for_message(b: u8) -> String {
488    if (0x20..0x7F).contains(&b) {
489        (b as char).to_string()
490    } else {
491        format!("\\x{b:02x}")
492    }
493}
494
495/// Parse authored JSON, keeping number literals verbatim and refusing duplicate
496/// object keys.
497pub fn parse(text: &str) -> Result<Node, ParseError> {
498    let mut p = Parser::new(text);
499    // A UTF-8 BOM is invisible in an editor and makes every JSON parser in the
500    // pipeline fail somewhere less obvious than here.
501    if p.src.starts_with(&[0xEF, 0xBB, 0xBF]) {
502        return Err(p.err(
503            DW_FMT_PARSE,
504            "file starts with a UTF-8 BOM; Delvewright JSON is plain UTF-8 with no BOM",
505        ));
506    }
507    let node = p.value()?;
508    p.skip_ws();
509    if p.pos != p.src.len() {
510        return Err(p.err(DW_FMT_PARSE, "trailing content after the top-level value"));
511    }
512    Ok(node)
513}
514
515// ---------------------------------------------------------------- writing --
516
517/// Render a node in canonical form, including the single trailing newline.
518pub fn canonical(node: &Node) -> String {
519    let mut out = String::new();
520    write_node(node, 0, &mut out);
521    out.push('\n');
522    out
523}
524
525fn write_node(node: &Node, depth: usize, out: &mut String) {
526    match node {
527        Node::Null => out.push_str("null"),
528        Node::Bool(true) => out.push_str("true"),
529        Node::Bool(false) => out.push_str("false"),
530        Node::Number(raw) => out.push_str(raw),
531        Node::Str(s) => write_string(s, out),
532        Node::Array(items) => {
533            if items.is_empty() {
534                out.push_str("[]");
535                return;
536            }
537            out.push_str("[\n");
538            for (i, item) in items.iter().enumerate() {
539                // NOTE: `items` is written in its own order and is never sorted.
540                // See the module docs; `equivalent` proves it on every write.
541                indent(depth + 1, out);
542                write_node(item, depth + 1, out);
543                if i + 1 < items.len() {
544                    out.push(',');
545                }
546                out.push('\n');
547            }
548            indent(depth, out);
549            out.push(']');
550        }
551        Node::Object(entries) => {
552            if entries.is_empty() {
553                out.push_str("{}");
554                return;
555            }
556            // The ONE sort in this module. By Unicode scalar value, which for
557            // Rust `&str` is UTF-8 byte order — the same order Python's
558            // `sorted()` gives, so the Python authoring tools already agree.
559            let mut sorted: Vec<&(String, Node)> = entries.iter().collect();
560            sorted.sort_by(|a, b| cmp_key(&a.0, &b.0));
561            out.push_str("{\n");
562            for (i, (key, value)) in sorted.iter().enumerate() {
563                indent(depth + 1, out);
564                write_string(key, out);
565                out.push_str(": ");
566                write_node(value, depth + 1, out);
567                if i + 1 < sorted.len() {
568                    out.push(',');
569                }
570                out.push('\n');
571            }
572            indent(depth, out);
573            out.push('}');
574        }
575    }
576}
577
578/// Total order on object keys: Unicode scalar value. Extracted so there is one
579/// place to look when asking "what is canonical key order".
580fn cmp_key(a: &str, b: &str) -> Ordering {
581    a.cmp(b)
582}
583
584fn indent(depth: usize, out: &mut String) {
585    for _ in 0..depth * INDENT {
586        out.push(' ');
587    }
588}
589
590/// Write a JSON string: the shortest legal escape for what must be escaped, and
591/// nothing else. Non-ASCII goes out as raw UTF-8 — see the module docs.
592fn write_string(s: &str, out: &mut String) {
593    out.push('"');
594    for ch in s.chars() {
595        match ch {
596            '"' => out.push_str("\\\""),
597            '\\' => out.push_str("\\\\"),
598            '\u{0008}' => out.push_str("\\b"),
599            '\u{000c}' => out.push_str("\\f"),
600            '\n' => out.push_str("\\n"),
601            '\r' => out.push_str("\\r"),
602            '\t' => out.push_str("\\t"),
603            c if (c as u32) < 0x20 => {
604                use std::fmt::Write as _;
605                let _ = write!(out, "\\u{:04x}", c as u32);
606            }
607            c => out.push(c),
608        }
609    }
610    out.push('"');
611}
612
613// ------------------------------------------------------------ equivalence --
614
615/// Prove two documents mean the same thing: **arrays compared index-wise**
616/// (so any reordering is a failure), objects compared as key→value maps (so the
617/// key sort is the one difference allowed).
618///
619/// `Err` carries a JSON-pointer-ish path to the first divergence.
620pub fn equivalent(before: &Node, after: &Node) -> Result<(), String> {
621    fn walk(a: &Node, b: &Node, path: &str) -> Result<(), String> {
622        match (a, b) {
623            (Node::Null, Node::Null) => Ok(()),
624            (Node::Bool(x), Node::Bool(y)) if x == y => Ok(()),
625            (Node::Number(x), Node::Number(y)) if x == y => Ok(()),
626            (Node::Str(x), Node::Str(y)) if x == y => Ok(()),
627            (Node::Array(x), Node::Array(y)) => {
628                if x.len() != y.len() {
629                    return Err(format!(
630                        "{path}: array length changed ({} → {})",
631                        x.len(),
632                        y.len()
633                    ));
634                }
635                for (i, (xi, yi)) in x.iter().zip(y.iter()).enumerate() {
636                    // Index-wise, deliberately: this is the check that makes
637                    // "arrays are never reordered" a machine fact.
638                    walk(xi, yi, &format!("{path}/{i}"))?;
639                }
640                Ok(())
641            }
642            (Node::Object(x), Node::Object(y)) => {
643                let mut xs: Vec<&(String, Node)> = x.iter().collect();
644                let mut ys: Vec<&(String, Node)> = y.iter().collect();
645                xs.sort_by(|p, q| cmp_key(&p.0, &q.0));
646                ys.sort_by(|p, q| cmp_key(&p.0, &q.0));
647                if xs.len() != ys.len() {
648                    return Err(format!(
649                        "{path}: object key count changed ({} → {})",
650                        xs.len(),
651                        ys.len()
652                    ));
653                }
654                for (xe, ye) in xs.iter().zip(ys.iter()) {
655                    if xe.0 != ye.0 {
656                        return Err(format!("{path}: key `{}` became `{}`", xe.0, ye.0));
657                    }
658                    walk(&xe.1, &ye.1, &format!("{path}/{}", xe.0))?;
659                }
660                Ok(())
661            }
662            _ => Err(format!("{path}: value kind or content changed")),
663        }
664    }
665    walk(before, after, "")
666}
667
668/// The whole formatter: parse, render canonically, and **prove** the render did
669/// not change what the document means before returning it.
670///
671/// The self-check is not belt-and-braces. It is the reason a reviewer can trust
672/// the array rule without reading the writer: any future edit that reorders an
673/// array — a `sort_by` added to the wrong `Vec`, a `BTreeMap` substituted for a
674/// `Vec` — fails here on the first real file instead of shipping a campaign
675/// whose objectives run in a new order.
676pub fn format_text(text: &str) -> Result<String, ParseError> {
677    format_with(text, canonical)
678}
679
680/// [`format_text`] with the renderer injected, so the equivalence guard can be
681/// shown to FIRE — a guard nobody has watched fail is a guard nobody knows
682/// works. `tests::the_guard_catches_a_renderer_that_sorts_arrays` hands this a
683/// deliberately array-sorting renderer and asserts `DW0772`.
684fn format_with(text: &str, render: impl Fn(&Node) -> String) -> Result<String, ParseError> {
685    let mut before = parse(text)?;
686    stamp_version(&mut before);
687    let out = render(&before);
688    let after = parse(&out).map_err(|e| ParseError {
689        code: DW_FMT_NOT_EQUIVALENT,
690        line: e.line,
691        col: e.col,
692        message: format!(
693            "the formatter emitted JSON it cannot itself parse: {}",
694            e.message
695        ),
696    })?;
697    if let Err(why) = equivalent(&before, &after) {
698        return Err(ParseError {
699            code: DW_FMT_NOT_EQUIVALENT,
700            line: 1,
701            col: 1,
702            message: format!(
703                "internal error: formatting would change what this document means \
704                 ({why}). Nothing was written. This is a compiler bug — arrays are \
705                 ordered and must never be reordered; please report it."
706            ),
707        });
708    }
709    Ok(out)
710}
711
712/// **`delvec fmt` writes the number** (ADR-0024, spec-0059 §9): an envelope's
713/// `dsl_version` is rewritten to the one this engine implements, so adopting a
714/// campaign to a new surface is `delvec fmt` plus the edit the surface asks
715/// for, and `--check` reds a document that still declares the old one. Only a
716/// top-level string `dsl_version` is touched — that key is the envelope's, and
717/// nothing else in this DSL spells it — and a document with none is not one.
718fn stamp_version(node: &mut Node) {
719    if let Node::Object(fields) = node {
720        for (key, value) in fields.iter_mut() {
721            if key == "dsl_version"
722                && let Node::Str(v) = value
723                && v != crate::DSL_VERSION
724            {
725                *v = crate::DSL_VERSION.to_string();
726            }
727        }
728    }
729}
730
731// -------------------------------------------------------------- discovery --
732
733/// A `delvec build` output root is marked by the `manifest.json` the compiler
734/// itself writes there. Directory discovery stops at one: emitted trees are not
735/// authored content, some are checked in (`campaigns/*/out/`), and rewriting one
736/// would break the byte-identity contract it exists to record.
737pub const BUILD_OUTPUT_MARKER: &str = "manifest.json";
738
739/// Every `*.json` file `delvec fmt <path>` would format, in a deterministic
740/// order.
741///
742/// * a file argument is taken as given — you pointed at it;
743/// * a directory is walked recursively, entries sorted by name (never
744///   `read_dir` order — ADR-0006);
745/// * dot-directories (`.git`, `.github`) are skipped;
746/// * a directory holding [`BUILD_OUTPUT_MARKER`] is skipped whole.
747pub fn discover(root: &Path) -> std::io::Result<Vec<PathBuf>> {
748    let mut out = Vec::new();
749    if root.is_file() {
750        out.push(root.to_path_buf());
751        return Ok(out);
752    }
753    walk(root, &mut out)?;
754    Ok(out)
755}
756
757fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
758    if dir.join(BUILD_OUTPUT_MARKER).is_file() {
759        return Ok(());
760    }
761    let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
762        .map(|e| e.map(|e| e.path()))
763        .collect::<Result<_, _>>()?;
764    entries.sort();
765    for path in entries {
766        let name = path
767            .file_name()
768            .and_then(|n| n.to_str())
769            .unwrap_or_default();
770        if name.starts_with('.') {
771            continue;
772        }
773        // `symlink_metadata` so a symlinked directory is not followed: the
774        // content repo is symlinked into this one at `campaigns/`, and a walk
775        // that followed it would silently reach a second repository.
776        let meta = std::fs::symlink_metadata(&path)?;
777        if meta.is_dir() {
778            walk(&path, out)?;
779        } else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") {
780            out.push(path);
781        }
782    }
783    Ok(())
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    /// `delvec fmt` writes the number: an envelope declaring any other
791    /// `dsl_version` comes out declaring this engine's, and only the envelope's
792    /// own key is touched — a nested `dsl_version` is content and stays.
793    #[test]
794    fn the_formatter_writes_the_one_dsl_version() {
795        let src = r#"{"dsl_version":"0.2.0","stage":"world","content":{"dsl_version":"0.2.0"}}"#;
796        let out = format_text(src).unwrap();
797        let stamped = format!(r#"  "dsl_version": "{}","#, crate::DSL_VERSION);
798        assert!(out.contains(&stamped), "{out}");
799        assert!(
800            out.contains(r#"    "dsl_version": "0.2.0""#),
801            "nested content stayed: {out}"
802        );
803        let already = format_text(&out).unwrap();
804        assert_eq!(already, out, "a stamped document is canonical");
805    }
806
807    #[test]
808    fn object_keys_are_sorted_arrays_are_not() {
809        let src = r#"{"b":1,"a":[3,1,2],"c":{"z":0,"y":0}}"#;
810        assert_eq!(
811            format_text(src).unwrap(),
812            "{\n  \"a\": [\n    3,\n    1,\n    2\n  ],\n  \"b\": 1,\n  \"c\": {\n    \"y\": 0,\n    \"z\": 0\n  }\n}\n"
813        );
814    }
815
816    #[test]
817    fn number_literals_survive_verbatim() {
818        // 2^53 + 1 and a trailing-zero decimal: both change under an f64
819        // round-trip, and neither may change here.
820        let src = r#"{"big":9007199254740993,"exact":1.50,"exp":1e3,"neg":-0.0}"#;
821        let out = format_text(src).unwrap();
822        assert!(out.contains("9007199254740993"), "{out}");
823        assert!(out.contains("1.50"), "{out}");
824        assert!(out.contains("1e3"), "{out}");
825        assert!(out.contains("-0.0"), "{out}");
826    }
827
828    #[test]
829    fn non_ascii_is_never_escaped_and_escapes_are_decoded() {
830        let src = r#"{"zh":"洞中公羊","emoji":"😀"}"#;
831        let out = format_text(src).unwrap();
832        assert!(out.contains("洞中公羊"), "{out}");
833        assert!(out.contains('\u{1F600}'), "{out}");
834        assert!(!out.contains("\\u"), "{out}");
835    }
836
837    #[test]
838    fn control_characters_keep_their_shortest_escape() {
839        let src = "{\"a\":\"x\\ny\\tz\\u0001\"}";
840        let out = format_text(src).unwrap();
841        assert_eq!(out, "{\n  \"a\": \"x\\ny\\tz\\u0001\"\n}\n");
842    }
843
844    #[test]
845    fn idempotent() {
846        let src = r#"{"b":[{"q":1,"p":[2,1]}],"a":"é"}"#;
847        let once = format_text(src).unwrap();
848        assert_eq!(format_text(&once).unwrap(), once);
849    }
850
851    #[test]
852    fn duplicate_key_is_refused() {
853        let e = format_text(r#"{"a":1,"a":2}"#).unwrap_err();
854        assert_eq!(e.code, "DW0771");
855    }
856
857    #[test]
858    fn syntax_errors_are_located() {
859        let e = format_text("{\n  \"a\": 1,\n}\n").unwrap_err();
860        assert_eq!(e.code, "DW0770");
861        assert_eq!(e.line, 3);
862    }
863
864    #[test]
865    fn empty_containers_stay_on_one_line() {
866        assert_eq!(
867            format_text(r#"{"a":[],"b":{}}"#).unwrap(),
868            "{\n  \"a\": [],\n  \"b\": {}\n}\n"
869        );
870    }
871
872    /// The red demonstration. Swap in a renderer that sorts arrays — the exact
873    /// correctness bug the hard constraint forbids — and the formatter must
874    /// refuse with `DW0772` rather than write the file.
875    #[test]
876    fn the_guard_catches_a_renderer_that_sorts_arrays() {
877        fn sorting_render(node: &Node) -> String {
878            fn sabotage(n: &Node) -> Node {
879                match n {
880                    Node::Array(items) => {
881                        let mut v: Vec<Node> = items.iter().map(sabotage).collect();
882                        v.sort_by_key(|n| match n {
883                            Node::Number(raw) => raw.clone(),
884                            Node::Str(s) => s.clone(),
885                            _ => String::new(),
886                        });
887                        Node::Array(v)
888                    }
889                    Node::Object(e) => {
890                        Node::Object(e.iter().map(|(k, v)| (k.clone(), sabotage(v))).collect())
891                    }
892                    other => other.clone(),
893                }
894            }
895            canonical(&sabotage(node))
896        }
897        // Sanity: the honest renderer is accepted on the same input.
898        let src = r#"{"objectives":["3","1","2"]}"#;
899        assert!(format_with(src, canonical).is_ok());
900        let e = format_with(src, sorting_render).unwrap_err();
901        assert_eq!(e.code, "DW0772");
902        assert!(e.message.contains("/objectives/0"), "{}", e.message);
903    }
904
905    #[test]
906    fn equivalent_catches_a_reordered_array() {
907        // The guard that makes the array rule a machine fact rather than a
908        // promise: hand it a reordering and it must refuse.
909        let a = parse("[1,2,3]").unwrap();
910        let b = parse("[3,2,1]").unwrap();
911        assert!(equivalent(&a, &b).is_err());
912        assert!(equivalent(&a, &a).is_ok());
913    }
914}