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