Skip to main content

active_core/
fmt.rs

1//! Dual-style formatter (C-022).
2//!
3//! Re-emits a parsed module in **brace style**, **indentation style**, or
4//! the **source's own style**, preserving every comment (with its normative
5//! attachment, spec/dual-syntax.md §7) and canonical blank-line structure.
6//!
7//! Canonical layout (spec §8):
8//! - indentation style: two spaces per level, `key:` headers;
9//! - brace style: `[name]` headers at the top level, `key = value` pairs,
10//!   nested tables as `name { … }` blocks;
11//! - no blank lines inside a block; one blank line between top-level
12//!   entries when either side is a table;
13//! - a leading comment sits directly above its statement, a trailing
14//!   comment on the same line after the value.
15//!
16//! The guarantees tested in C-022 are: idempotence (`fmt(fmt(d)) == fmt(d)`),
17//! byte-for-byte preservation of canonical documents, and comment survival
18//! in every mode.
19
20use crate::ir::*;
21use std::collections::HashMap;
22
23const INDENT: &str = "  ";
24
25/// Format a module in the requested style. `Preserve` uses the style the
26/// source predominantly used.
27pub fn format(module: &Module, style: Style) -> String {
28    let style = match style {
29        Style::Preserve => module.source_style,
30        other => other,
31    };
32    let mut f = Formatter {
33        module,
34        style,
35        names: build_names(module),
36        leading: HashMap::new(),
37        trailing: HashMap::new(),
38        trailing_block: HashMap::new(),
39        trailing_document: Vec::new(),
40        out: String::new(),
41    };
42    f.index_trivia();
43    f.emit_module();
44    f.out
45}
46
47struct Formatter<'a> {
48    module: &'a Module,
49    style: Style,
50    /// Reverse map: node id → the name it is bound or keyed under.
51    names: HashMap<NodeId, String>,
52    leading: HashMap<String, Vec<String>>,
53    trailing: HashMap<String, Vec<String>>,
54    trailing_block: HashMap<String, Vec<String>>,
55    trailing_document: Vec<String>,
56    out: String,
57}
58
59/// Map every named node back to its name (let bindings and table keys,
60/// including profile trees so profile-internal references render).
61fn build_names(module: &Module) -> HashMap<NodeId, String> {
62    fn walk(module: &Module, id: NodeId, names: &mut HashMap<NodeId, String>) {
63        if let Some(Node::Table { fields }) = module.get(id) {
64            for (key, child) in fields {
65                names.entry(*child).or_insert_with(|| key.clone());
66                walk(module, *child, names);
67            }
68        }
69    }
70    let mut names = HashMap::new();
71    for (name, id) in &module.bindings {
72        names.entry(*id).or_insert_with(|| name.clone());
73    }
74    if let Some(root) = module.root {
75        walk(module, root, &mut names);
76    }
77    for id in module.profiles.values() {
78        walk(module, *id, &mut names);
79    }
80    names
81}
82
83impl<'a> Formatter<'a> {
84    // Indexes key by path only (no file): the formatter parses
85    // single-file documents (resolve_includes=false), so paths are
86    // unique by construction. Multi-file merging would need
87    // file-qualified keys — see the parser's (line, file) matching.
88    fn index_trivia(&mut self) {
89        for c in &self.module.trivia {
90            match &c.attachment {
91                Attachment::Leading(key) => self
92                    .leading
93                    .entry(key.clone())
94                    .or_default()
95                    .push(c.text.clone()),
96                Attachment::Trailing(key) => {
97                    // Duplicate keys share one surviving statement; keep
98                    // every comment so none is lost.
99                    self.trailing
100                        .entry(key.clone())
101                        .or_default()
102                        .push(c.text.clone());
103                }
104                Attachment::TrailingBlock(path) => self
105                    .trailing_block
106                    .entry(path.clone())
107                    .or_default()
108                    .push(c.text.clone()),
109                Attachment::TrailingDocument => self.trailing_document.push(c.text.clone()),
110            }
111        }
112    }
113
114    fn emit_module(&mut self) {
115        // Let bindings first, in source order.
116        let mut lets: Vec<(&String, &NodeId)> = self.module.bindings.iter().collect();
117        lets.sort_by_key(|(_, id)| {
118            self.module
119                .node_spans
120                .get(id)
121                .map(|s| (s.line, s.column))
122                .unwrap_or((u32::MAX, 0))
123        });
124        for (name, id) in lets {
125            let id = *id;
126            self.emit_leading(name);
127            let value = self.render_expr(id);
128            self.out.push_str(&format!("let {name} = {value}"));
129            self.emit_trailing(name);
130            self.out.push('\n');
131        }
132
133        // The root schema block, when the document declares one
134        // (spec §2.10). Schemas are metadata: position is canonical.
135        if let Some(schema) = self.module.schema {
136            self.emit_leading("schema");
137            self.emit_schema_table(schema, 0);
138        }
139
140        // Profile blocks, in source order (spec §2.8). They merge at
141        // evaluation and never export, but formatting preserves them.
142        let profiles: Vec<(String, NodeId)> = self
143            .module
144            .profiles
145            .iter()
146            .map(|(k, v)| (k.clone(), *v))
147            .collect();
148        for (name, id) in &profiles {
149            self.emit_leading(name);
150            self.emit_profile(*id, name, 0);
151        }
152
153        // Patch directives, in source order (spec §2.9). They apply
154        // post-evaluation and never export, but formatting preserves
155        // them so the composition structure survives a fmt pass.
156        // Root includes and patches emit together sorted by source line:
157        // patch application order is positional, so hoisting includes
158        // above textually-earlier patches would change values on
159        // reparse. Includes in nested scopes emit with their scope.
160        let mut root_items: Vec<(u32, bool, NodeId, Option<IncludeStmt>)> = Vec::new();
161        for id in &self.module.patches {
162            let line = self
163                .module
164                .node_spans
165                .get(id)
166                .map(|s| s.line)
167                .unwrap_or(u32::MAX);
168            root_items.push((line, false, *id, None));
169        }
170        for inc in self
171            .module
172            .includes
173            .iter()
174            .filter(|inc| inc.scope_path.is_empty())
175        {
176            root_items.push((inc.line, true, NodeId(u32::MAX), Some(inc.clone())));
177        }
178        root_items.sort_by_key(|(line, is_include, id, _)| (*line, *is_include, id.0));
179        for (_, is_include, id, inc) in &root_items {
180            if *is_include {
181                let inc = inc.as_ref().expect("include entry");
182                self.emit_include(inc, 0);
183            } else {
184                self.emit_patch(*id);
185            }
186        }
187
188        if let Some(root) = self.module.root {
189            let has_entries = !self.out.is_empty()
190                && matches!(self.module.get(root), Some(Node::Table { fields }) if !fields.is_empty());
191            if has_entries && !self.out.ends_with("\n\n") {
192                self.out.push('\n');
193            }
194            // Root dependency metadata leads the body (spec/linter.md
195            // §2), like the root schema block above.
196            self.emit_depends_on(root, 0);
197            self.emit_table_body(root, 0, "", true);
198        }
199        for text in self.trailing_document.clone() {
200            self.out.push_str(&text);
201            self.out.push('\n');
202        }
203    }
204
205    /// Emit a table's entries. `path` is the dotted path of the table
206    /// (empty for the document root); `is_root` marks the document level.
207    /// `schema` is this table's schema node for parallel emission:
208    /// a `schema = { … }` block leads the body (spec §2.10).
209    fn emit_table_body(&mut self, id: NodeId, depth: usize, path: &str, is_root: bool) {
210        let fields = match self.module.get(id) {
211            Some(Node::Table { fields }) => fields.clone(),
212            _ => return,
213        };
214        // Include statements recorded for this scope come first, in
215        // source order: under the defaults rule (spec §2.7) their
216        // position relative to keys never changes semantics. Root
217        // includes emit with root patches (positional application
218        // order), so only nested scopes emit here.
219        if !path.is_empty() {
220            let mut includes: Vec<&IncludeStmt> = self
221                .module
222                .includes
223                .iter()
224                .filter(|inc| inc.scope_path.join(".") == path)
225                .collect();
226            includes.sort_by_key(|inc| inc.line);
227            for inc in includes {
228                self.emit_include(inc, depth);
229            }
230            // A nested `schema` block leads its table's body (spec
231            // §2.10). It was detached from value fields at parse, so
232            // re-emit it here from the schema tree. The root schema
233            // emits once from emit_module instead.
234            if let Some(schema) = self.schema_for_path(path) {
235                self.emit_schema_table(schema, depth);
236            }
237            // Dependency metadata follows the schema (spec/linter.md
238            // §2), ahead of the keys it constrains.
239            self.emit_depends_on(id, depth);
240        }
241        let is_table_node =
242            |f: &Formatter, n: &NodeId| matches!(f.module.get(*n), Some(Node::Table { .. }));
243        // At the document root, scalars must be hoisted above tables: a
244        // `key = value` line after a table header would otherwise be
245        // absorbed by that table (spec §5.1). Inside a block, the original
246        // order is preserved.
247        let ordered: Vec<(String, NodeId)> = if is_root {
248            let mut scalars: Vec<(String, NodeId)> = Vec::new();
249            let mut tables: Vec<(String, NodeId)> = Vec::new();
250            for (k, v) in &fields {
251                if is_table_node(self, v) {
252                    tables.push((k.clone(), *v));
253                } else {
254                    scalars.push((k.clone(), *v));
255                }
256            }
257            scalars.into_iter().chain(tables).collect()
258        } else {
259            fields.iter().map(|(k, v)| (k.clone(), *v)).collect()
260        };
261        let tables: Vec<(String, NodeId)> = ordered
262            .iter()
263            .filter(|(_, v)| is_table_node(self, v))
264            .cloned()
265            .collect();
266
267        // Scalars-first at the root, source order inside blocks.
268        let mut first = true;
269        for (key, node) in ordered.iter() {
270            let child_path = if path.is_empty() {
271                key.clone()
272            } else {
273                format!("{path}.{key}")
274            };
275            let is_table = tables.iter().any(|(k, _)| k == key);
276            if is_root && !first {
277                self.out.push('\n');
278            }
279            first = false;
280
281            let qkey = child_path.clone();
282            self.emit_leading(&qkey);
283            if is_table {
284                self.emit_table_block(*node, &child_path, key, depth, is_root);
285            } else {
286                let value = self.render_expr(*node);
287                let indent = INDENT.repeat(depth);
288                self.out.push_str(&format!("{indent}{key} = {value}"));
289                self.emit_trailing(&qkey);
290                self.out.push('\n');
291            }
292        }
293
294        if let Some(comments) = self.trailing_block.get(path).cloned() {
295            for text in comments {
296                self.out.push_str(&INDENT.repeat(depth));
297                self.out.push_str(&text);
298                self.out.push('\n');
299            }
300        }
301    }
302
303    /// Emit a `depends_on` metadata line for a table scope
304    /// (spec/linter.md §2). Detached at parse like schemas, so it
305    /// re-emits here from the side table; a no-op for scopes without
306    /// declarations.
307    fn emit_depends_on(&mut self, id: NodeId, depth: usize) {
308        let Some(dep) = self.module.depends_on.get(&id).cloned() else {
309            return;
310        };
311        let quoted: Vec<std::string::String> = dep
312            .paths
313            .iter()
314            .map(|p| format!("\"{}\"", escape(p)))
315            .collect();
316        self.out.push_str(&format!(
317            "{}depends_on = [{}]",
318            INDENT.repeat(depth),
319            quoted.join(", ")
320        ));
321        self.out.push('\n');
322    }
323
324    /// Look up this table's schema node by dotted path, descending from
325    /// the root schema through SchemaTable fields (spec §2.10). Returns
326    /// None when no schema covers this table.
327    fn schema_for_path(&self, path: &str) -> Option<NodeId> {
328        let mut id = self.module.schema?;
329        if path.is_empty() {
330            return Some(id);
331        }
332        for segment in path.split('.') {
333            match self.module.get(id) {
334                Some(Node::SchemaTable { fields, .. }) => {
335                    id = *fields.get(segment)?;
336                }
337                _ => return None,
338            }
339        }
340        Some(id)
341    }
342
343    /// Emit a `schema` block at the given depth (spec §2.10). Indentation
344    /// style uses `schema:` with an indented body; brace style uses
345    /// `schema { … }`. A closed table leads with `closed = true`.
346    /// Entries keep source order: the schema tree preserves it. An empty
347    /// schema emits inline (`schema = {}`) in every style: indentation
348    /// style cannot express a bodyless block.
349    fn emit_schema_table(&mut self, id: NodeId, depth: usize) {
350        let (fields, closed) = match self.module.get(id) {
351            Some(Node::SchemaTable { fields, closed }) => (fields.clone(), *closed),
352            _ => return,
353        };
354        if fields.is_empty() && !closed {
355            let indent = INDENT.repeat(depth);
356            self.out.push_str(&format!("{indent}schema = {{}}"));
357            self.emit_trailing("schema");
358            self.out.push('\n');
359            return;
360        }
361        let indent = INDENT.repeat(depth);
362        match self.style {
363            Style::Indent => {
364                self.out.push_str(&format!("{indent}schema:"));
365                self.emit_trailing("schema");
366                self.out.push('\n');
367                let inner = INDENT.repeat(depth + 1);
368                if closed {
369                    self.out.push_str(&format!("{inner}closed = true\n"));
370                }
371                for (name, entry) in &fields {
372                    let typ = self.render_schema_type(*entry);
373                    self.out.push_str(&format!("{inner}{name} = {typ}\n"));
374                }
375            }
376            _ => {
377                self.out.push_str(&format!("{indent}schema {{"));
378                self.emit_trailing("schema");
379                self.out.push('\n');
380                let inner = INDENT.repeat(depth + 1);
381                if closed {
382                    self.out.push_str(&format!("{inner}closed = true\n"));
383                }
384                for (name, entry) in &fields {
385                    let typ = self.render_schema_type(*entry);
386                    self.out.push_str(&format!("{inner}{name} = {typ}\n"));
387                }
388                self.out.push_str(&format!("{indent}}}\n"));
389            }
390        }
391    }
392
393    /// Render one schema type expression: a field (`Int(min = …)`),
394    /// a nested table (`Table { … }`), or an element schema
395    /// (`Array<…>`), recursing as needed.
396    fn render_schema_type(&self, id: NodeId) -> String {
397        match self.module.get(id).cloned() {
398            Some(Node::SchemaField { field }) => self.render_schema_field(&field),
399            Some(Node::SchemaArray { element }) => {
400                format!("Array<{}>", self.render_schema_type(element))
401            }
402            Some(Node::SchemaTable { fields, closed }) => {
403                let mut parts: Vec<std::string::String> = Vec::new();
404                if closed {
405                    parts.push("closed = true".to_string());
406                }
407                for (name, entry) in fields {
408                    parts.push(format!("{name} = {}", self.render_schema_type(entry)));
409                }
410                if parts.is_empty() {
411                    "Table {}".to_string()
412                } else {
413                    format!("Table {{ {} }}", parts.join(", "))
414                }
415            }
416            _ => "Any".to_string(),
417        }
418    }
419
420    /// Render a leaf field type with its present constraint arguments.
421    /// Absent arguments are omitted (never `None` placeholders).
422    fn render_schema_field(&self, field: &crate::ir::SchemaField) -> String {
423        use crate::types::ActiveType::*;
424        // `deprecated` renders inside the parens for every leaf type
425        // (spec/linter.md §3), including Enum's positional list.
426        let deprecated = match &field.deprecated {
427            None => std::string::String::new(),
428            Some(reason) if reason.is_empty() => ", deprecated = true".to_string(),
429            Some(reason) => format!(", deprecated = \"{}\"", escape(reason)),
430        };
431        if let Enum(values) = &field.typ {
432            let quoted: Vec<std::string::String> = values
433                .iter()
434                .map(|v| format!("\"{}\"", escape(v)))
435                .collect();
436            return format!("Enum({}{})", quoted.join(", "), deprecated);
437        }
438        let mut args: Vec<std::string::String> = Vec::new();
439        for (name, slot) in [
440            ("min", field.min),
441            ("max", field.max),
442            ("min_len", field.min_len),
443            ("max_len", field.max_len),
444            ("pattern", field.pattern),
445            ("default", field.default),
446        ] {
447            if let Some(arg) = slot {
448                args.push(format!("{name} = {}", self.render_expr(arg)));
449            }
450        }
451        match &field.deprecated {
452            None => {}
453            Some(reason) if reason.is_empty() => args.push("deprecated = true".to_string()),
454            Some(reason) => args.push(format!("deprecated = \"{}\"", escape(reason))),
455        }
456        let base = match &field.typ {
457            Table { closed: true } => "Table(closed)".to_string(),
458            other => other.name(),
459        };
460        if args.is_empty() {
461            base
462        } else {
463            format!("{base}({})", args.join(", "))
464        }
465    }
466
467    fn emit_table_block(&mut self, id: NodeId, path: &str, key: &str, depth: usize, is_root: bool) {
468        match self.style {
469            Style::Indent => {
470                // Indentation style cannot express a bodyless block, so an
471                // empty table is emitted in its inline form (`key = {}`).
472                let empty = matches!(
473                    self.module.get(id),
474                    Some(Node::Table { fields }) if fields.is_empty()
475                );
476                if empty {
477                    self.out
478                        .push_str(&format!("{}{key} = {{}}", INDENT.repeat(depth)));
479                    self.emit_trailing(path);
480                    self.out.push('\n');
481                    // An empty table can still own trailing comments.
482                    if let Some(comments) = self.trailing_block.get(path).cloned() {
483                        for text in comments {
484                            self.out.push_str(&INDENT.repeat(depth));
485                            self.out.push_str(&text);
486                            self.out.push('\n');
487                        }
488                    }
489                    return;
490                }
491                self.out
492                    .push_str(&format!("{}{key}:", INDENT.repeat(depth)));
493                self.emit_trailing(path);
494                self.out.push('\n');
495                self.emit_table_body(id, depth + 1, path, false);
496            }
497            _ => {
498                if is_root && depth == 0 {
499                    // Top-level tables use `[name]` headers (TOML-ish); a
500                    // trailing comment stays on the header line.
501                    self.out.push_str(&format!("[{key}]"));
502                    self.emit_trailing(path);
503                    self.out.push('\n');
504                    self.emit_table_body(id, 0, path, false);
505                } else {
506                    self.out
507                        .push_str(&format!("{}{key} {{", INDENT.repeat(depth)));
508                    self.emit_trailing(path);
509                    self.out.push('\n');
510                    self.emit_table_body(id, depth + 1, path, false);
511                    self.out.push_str(&format!("{}}}\n", INDENT.repeat(depth)));
512                }
513            }
514        }
515    }
516
517    /// Emit a `profile "name"` block (spec §2.8). Brace style uses
518    /// `profile "name" { … }`; indentation style uses `profile "name":`
519    /// with an indented body. The body's comment paths are the profile
520    /// name (parse_profile pushes it), so trivia survives. Names escape
521    /// like interpolation-safe literals so quotes, dollars, and newlines
522    /// round-trip.
523    fn emit_profile(&mut self, id: NodeId, name: &str, depth: usize) {
524        let qname = escape_interp_literal(name);
525        let empty = matches!(
526            self.module.get(id),
527            Some(Node::Table { fields }) if fields.is_empty()
528        );
529        match self.style {
530            Style::Indent if !empty => {
531                self.out
532                    .push_str(&format!("{}profile \"{qname}\":", INDENT.repeat(depth)));
533                self.emit_trailing(name);
534                self.out.push('\n');
535                self.emit_table_body(id, depth + 1, name, false);
536            }
537            _ => {
538                if empty {
539                    self.out
540                        .push_str(&format!("{}profile \"{qname}\" {{}}", INDENT.repeat(depth)));
541                    self.emit_trailing(name);
542                    self.out.push('\n');
543                } else {
544                    self.out
545                        .push_str(&format!("{}profile \"{qname}\" {{", INDENT.repeat(depth)));
546                    self.emit_trailing(name);
547                    self.out.push('\n');
548                    self.emit_table_body(id, depth + 1, name, false);
549                    self.out.push_str(&format!("{}}}\n", INDENT.repeat(depth)));
550                }
551            }
552        }
553    }
554
555    /// Emit one `include` statement (spec §2.7) at the given depth.
556    fn emit_include(&mut self, inc: &IncludeStmt, depth: usize) {
557        self.out.push_str(&INDENT.repeat(depth));
558        self.out.push_str(&format!("include {:?}", inc.path));
559        if let Some(alias) = &inc.alias {
560            self.out.push_str(&format!(" as {alias}"));
561        }
562        if inc.private {
563            self.out.push_str(" private");
564        }
565        self.out.push('\n');
566    }
567
568    /// Emit one patch directive (`@patch` / `@remove` / `@rename`,
569    /// spec §2.9). Paths render as quoted strings; patch values render
570    /// as expressions. Statement comments attach under the `@op(path)`
571    /// key recorded at parse time.
572    fn emit_patch(&mut self, id: NodeId) {
573        fn q(s: &str) -> String {
574            format!("\"{}\"", escape(s))
575        }
576        let (text, key) = match self.module.get(id) {
577            Some(Node::PatchDirective { op }) => match op {
578                PatchOp::Set { path, value } => (
579                    format!("@patch({}, {})", q(path), self.render_expr(*value)),
580                    format!("@patch({path})"),
581                ),
582                PatchOp::Remove { path } => {
583                    (format!("@remove({})", q(path)), format!("@remove({path})"))
584                }
585                PatchOp::Rename { from, to } => (
586                    format!("@rename({}, {})", q(from), q(to)),
587                    format!("@rename({from})"),
588                ),
589            },
590            _ => return,
591        };
592        self.emit_leading(&key);
593        self.out.push_str(&text);
594        self.emit_trailing(&key);
595        self.out.push('\n');
596    }
597
598    fn emit_leading(&mut self, key: &str) {
599        if let Some(comments) = self.leading.get(key).cloned() {
600            for text in comments {
601                self.out.push_str(&text);
602                self.out.push('\n');
603            }
604        }
605    }
606
607    fn emit_trailing(&mut self, key: &str) {
608        if let Some(texts) = self.trailing.get(key).cloned() {
609            for text in texts {
610                self.out.push(' ');
611                self.out.push_str(&text);
612            }
613        }
614    }
615
616    // ------------------------------------------------------------------
617    // Expressions
618    // ------------------------------------------------------------------
619
620    fn render_expr(&self, id: NodeId) -> String {
621        match self.module.get(id) {
622            Some(Node::Literal(v)) => render_literal(v),
623            Some(Node::Env { name, default }) => match default {
624                Some(d) => {
625                    let d = self.render_expr(*d);
626                    format!("env.{name} ?: {d}")
627                }
628                None => format!("env.{name}"),
629            },
630            Some(Node::Secret { locator }) => {
631                format!("secret({})", self.render_expr(*locator))
632            }
633            Some(Node::Ref(target)) => self
634                .names
635                .get(target)
636                .cloned()
637                .unwrap_or_else(|| "<?>".into()),
638            Some(Node::Ident(name)) => name.clone(),
639            Some(Node::Interpolate { parts }) => {
640                let mut s = String::from("\"");
641                for part in parts {
642                    match part {
643                        // A literal `${` can only have come from an escaped
644                        // dollar; re-escape it so it stays literal.
645                        InterpPart::Literal(t) => s.push_str(&escape_interp_literal(t)),
646                        InterpPart::Expr(e) => {
647                            s.push_str("${");
648                            let inner = self.render_expr(*e);
649                            s.push_str(&escape_embedded(&inner));
650                            s.push('}');
651                        }
652                    }
653                }
654                s.push('"');
655                s
656            }
657            Some(Node::Binary { .. }) => self.render_binary(id),
658            Some(Node::Unary { op, expr }) => {
659                let symbol = match op {
660                    UnOp::Neg => "-",
661                    UnOp::Not => "!",
662                };
663                format!("{symbol}{}", self.render_child(*expr, 7, false))
664            }
665            Some(Node::Array { elements }) => {
666                let parts: Vec<String> = elements.iter().map(|e| self.render_expr(*e)).collect();
667                format!("[{}]", parts.join(", "))
668            }
669            Some(Node::Table { fields }) => {
670                let parts: Vec<String> = fields
671                    .iter()
672                    .map(|(k, v)| format!("{k} = {}", self.render_expr(*v)))
673                    .collect();
674                format!("{{ {} }}", parts.join(", "))
675            }
676            Some(Node::Quantity { value, unit }) => {
677                format!("{}{}", self.render_expr(*value), unit.suffix())
678            }
679            Some(Node::Semantic { tag, inner }) => {
680                format!("@{} {}", tag.as_str(), self.render_ml(*inner))
681            }
682            _ => "<?>".into(),
683        }
684    }
685
686    /// Render a multi-line string payload (spec §1.4.1): `"""` form for
687    /// interpolated strings, `'''` verbatim form for plain literals that
688    /// avoid the delimiter, with escaping fallback when both delimiters
689    /// appear in the content.
690    fn render_ml(&self, id: NodeId) -> String {
691        match self.module.get(id) {
692            Some(Node::Interpolate { parts }) => {
693                let mut s = String::from("\"\"\"");
694                for part in parts {
695                    match part {
696                        InterpPart::Literal(t) => s.push_str(&escape_ml_literal(t)),
697                        InterpPart::Expr(e) => {
698                            s.push_str("${");
699                            let inner = self.render_expr(*e);
700                            s.push_str(&escape_embedded(&inner));
701                            s.push('}');
702                        }
703                    }
704                }
705                s.push_str("\"\"\"");
706                s
707            }
708            Some(Node::Literal(Value::String(text))) if !text.contains("'''") => {
709                format!("'''{text}'''")
710            }
711            Some(Node::Literal(Value::String(text))) => {
712                format!("\"\"\"{}\"\"\"", escape_ml_literal(text))
713            }
714            _ => self.render_expr(id),
715        }
716    }
717
718    /// Render a binary expression by walking its left spine iteratively, so
719    /// a long left-associative chain (`1 + 1 + …`) cannot exhaust the stack.
720    /// Right-nested trees only arise through parentheses, which the parser
721    /// bounds at MAX_EXPR_DEPTH.
722    fn render_binary(&self, id: NodeId) -> String {
723        let top_prec = match self.module.get(id) {
724            Some(Node::Binary { op, .. }) => prec(*op),
725            _ => 0,
726        };
727        let mut rights: Vec<String> = Vec::new();
728        let mut ops: Vec<BinOp> = Vec::new();
729        let mut cur = id;
730        loop {
731            match self.module.get(cur) {
732                Some(Node::Binary { op, lhs, rhs }) => {
733                    ops.push(*op);
734                    rights.push(self.render_child(*rhs, prec(*op), true));
735                    // Only continue along the spine while the left child is
736                    // the same precedence (a left-associative chain); a
737                    // tighter or looser left child is an operand that may
738                    // need parentheses.
739                    let next = *lhs;
740                    match self.module.get(next) {
741                        Some(Node::Binary { op: next_op, .. }) if prec(*next_op) == top_prec => {
742                            cur = next;
743                        }
744                        _ => {
745                            let leftmost = self.render_child(next, top_prec, false);
746                            rights.push(leftmost);
747                            break;
748                        }
749                    }
750                }
751                _ => {
752                    let leftmost = self.render_child(cur, top_prec, false);
753                    rights.push(leftmost);
754                    break;
755                }
756            }
757        }
758        // rights/ops are innermost-first; emit outermost-first.
759        rights.reverse();
760        ops.reverse();
761        let mut out = rights[0].clone();
762        for (i, op) in ops.iter().enumerate() {
763            out.push_str(&format!(" {} {}", op_symbol(*op), rights[i + 1]));
764        }
765        out
766    }
767
768    /// Render a binary operand, parenthesizing when precedence requires it.
769    /// An `env` node with a default consumes an expression, so it binds
770    /// looser than any operator and is always parenthesized as an operand.
771    fn render_child(&self, id: NodeId, parent_prec: u8, is_right: bool) -> String {
772        let text = self.render_expr(id);
773        match self.module.get(id) {
774            Some(Node::Binary { op, .. }) => {
775                let child = prec(*op);
776                if child < parent_prec || (child == parent_prec && is_right) {
777                    return format!("({text})");
778                }
779            }
780            Some(Node::Env {
781                default: Some(_), ..
782            }) => {
783                return format!("({text})");
784            }
785            _ => {}
786        }
787        text
788    }
789}
790
791fn prec(op: BinOp) -> u8 {
792    match op {
793        BinOp::Or => 1,
794        BinOp::And => 2,
795        BinOp::Eq | BinOp::Ne => 3,
796        BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => 4,
797        BinOp::Add | BinOp::Sub => 5,
798        BinOp::Mul | BinOp::Div => 6,
799    }
800}
801
802fn op_symbol(op: BinOp) -> &'static str {
803    match op {
804        BinOp::Add => "+",
805        BinOp::Sub => "-",
806        BinOp::Mul => "*",
807        BinOp::Div => "/",
808        BinOp::Eq => "==",
809        BinOp::Ne => "!=",
810        BinOp::Lt => "<",
811        BinOp::Le => "<=",
812        BinOp::Gt => ">",
813        BinOp::Ge => ">=",
814        BinOp::And => "&&",
815        BinOp::Or => "||",
816    }
817}
818
819fn render_literal(v: &Value) -> String {
820    match v {
821        Value::Null => "null".into(),
822        Value::Bool(b) => b.to_string(),
823        Value::Int(i) => i.to_string(),
824        Value::Float(f) => {
825            // Display never uses scientific notation (which the lexer does
826            // not accept) and round-trips exactly; ensure a decimal point so
827            // the value stays a float.
828            let mut s = format!("{f}");
829            if !s.contains('.') {
830                s.push_str(".0");
831            }
832            s
833        }
834        Value::String(s) => {
835            // A string containing `${` must not interpolate when re-read, so
836            // emit it single-quoted (single-quoted strings are literal).
837            if s.contains("${") {
838                format!("'{}'", escape_single(s))
839            } else {
840                format!("\"{}\"", escape(s))
841            }
842        }
843        Value::DurationMs(ms) => format!("{ms}ms"),
844        Value::Quantity(n, unit) => format!("{n}{}", unit.suffix()),
845        Value::Array(a) => {
846            let parts: Vec<String> = a.iter().map(render_literal).collect();
847            format!("[{}]", parts.join(", "))
848        }
849        Value::Table(t) => {
850            let parts: Vec<String> = t
851                .iter()
852                .map(|(k, v)| format!("{k} = {}", render_literal(v)))
853                .collect();
854            format!("{{ {} }}", parts.join(", "))
855        }
856        // Never emit secret material; the marker is the canonical form.
857        Value::Secret(_) => "\"***SECRET***\"".into(),
858    }
859}
860
861fn escape(s: &str) -> String {
862    let mut out = String::with_capacity(s.len());
863    for ch in s.chars() {
864        match ch {
865            '"' => out.push_str("\\\""),
866            '\\' => out.push_str("\\\\"),
867            '\n' => out.push_str("\\n"),
868            '\t' => out.push_str("\\t"),
869            '\r' => out.push_str("\\r"),
870            _ => out.push(ch),
871        }
872    }
873    out
874}
875
876/// Escape for a single-quoted string. Only `'` and `\` are special there:
877/// the single-quote lexer does not decode `\n`/`\t`/`\r`, so those are
878/// emitted literally (a single-quoted string may contain them).
879fn escape_single(s: &str) -> String {
880    let mut out = String::with_capacity(s.len());
881    for ch in s.chars() {
882        match ch {
883            '\'' => out.push_str("\\'"),
884            '\\' => out.push_str("\\\\"),
885            _ => out.push(ch),
886        }
887    }
888    out
889}
890
891/// Escape the literal text of an interpolation part so a `${` that was
892/// escaped in the source stays literal.
893fn escape_interp_literal(s: &str) -> String {
894    escape(s).replace("${", "\\${")
895}
896
897/// Escape a literal part for the `"""` multi-line form. This is exactly
898/// `escape_interp_literal`: `escape` already covers `"`, `\`, and newlines,
899/// and the `${` rule keeps literal dollars from becoming interpolation.
900/// No further substitution — an extra pass would double-escape (e.g. `\"`
901/// becoming `\\"` and corrupting the content on reparse).
902fn escape_ml_literal(s: &str) -> String {
903    escape_interp_literal(s)
904}
905
906/// Escape a rendered sub-expression for embedding inside a double-quoted
907/// interpolation: the outer lexer must see the original expression text.
908fn escape_embedded(s: &str) -> String {
909    let mut out = String::with_capacity(s.len());
910    for ch in s.chars() {
911        match ch {
912            '"' => out.push_str("\\\""),
913            '\\' => out.push_str("\\\\"),
914            _ => out.push(ch),
915        }
916    }
917    out
918}