Skip to main content

tfparser_core/exporter/
json.rs

1//! Canonical-JSON renderer for the Parquet `attributes_json` column.
2//!
3//! Per [10-data-model.md § 4] and [20-parquet-exporter.md § 3.3]:
4//!
5//! - keys sorted alphabetically at every object level;
6//! - numbers rendered with `ryu` (shortest exact `f64` representation);
7//! - HCL `null`/bool/string/list/map → JSON equivalents;
8//! - [`Expression::Unresolved`] → `{"__unresolved__": "<source>", "__kind__": "Var|Local|..."}`;
9//! - [`Expression::FuncCall`] → `{"__unresolved_func__": "<name>", "args": [...]}`;
10//! - the renderer never panics: any non-finite `f64` (NaN, ±∞) is rendered as JSON `null` to keep
11//!   the artefact strict-JSON valid.
12//!
13//! The renderer is deterministic: same input → byte-identical output.
14//!
15//! [10-data-model.md § 4]: ../../../specs/10-data-model.md
16//! [20-parquet-exporter.md § 3.3]: ../../../specs/20-parquet-exporter.md
17
18use std::{cmp::Ordering, fmt::Write as _};
19
20use crate::ir::{AttributeMap, Expression, SymbolKind, Value};
21
22/// Render an [`AttributeMap`] as canonical JSON object into `out`.
23///
24/// `out` is appended to (not cleared) so callers can pool the buffer
25/// across rows.
26pub fn render_attribute_map(map: &AttributeMap, out: &mut String) {
27    let mut entries: Vec<(&str, &Expression)> = map.iter().map(|(k, v)| (k.as_ref(), v)).collect();
28    entries.sort_by(|(a, _), (b, _)| str::cmp(a, b));
29    out.push('{');
30    for (idx, (key, expr)) in entries.iter().enumerate() {
31        if idx > 0 {
32            out.push(',');
33        }
34        write_json_string(key, out);
35        out.push(':');
36        write_expression(expr, out);
37    }
38    out.push('}');
39}
40
41/// Convenience: produce the canonical JSON of an [`AttributeMap`] as a
42/// freshly-allocated [`String`].
43#[must_use]
44pub fn attribute_map_to_string(map: &AttributeMap) -> String {
45    let mut s = String::with_capacity(64);
46    render_attribute_map(map, &mut s);
47    s
48}
49
50fn write_expression(expr: &Expression, out: &mut String) {
51    match expr {
52        Expression::Literal(v) => write_value(v, out),
53        Expression::Unresolved(s) => write_unresolved(s, out),
54        Expression::FuncCall(call) => {
55            out.push_str(r#"{"__unresolved_func__":"#);
56            write_json_string(call.name.as_ref(), out);
57            out.push_str(r#","args":"#);
58            write_expression_list(&call.args, out);
59            out.push('}');
60        }
61        Expression::BinaryOp { op, lhs, rhs, .. } => {
62            out.push_str(r#"{"__binary_op__":"#);
63            write_json_string(&format!("{op:?}"), out);
64            out.push_str(r#","lhs":"#);
65            write_expression(lhs, out);
66            out.push_str(r#","rhs":"#);
67            write_expression(rhs, out);
68            out.push('}');
69        }
70        Expression::UnaryOp { op, operand, .. } => {
71            out.push_str(r#"{"__unary_op__":"#);
72            write_json_string(&format!("{op:?}"), out);
73            out.push_str(r#","operand":"#);
74            write_expression(operand, out);
75            out.push('}');
76        }
77        Expression::TemplateConcat(parts) => {
78            out.push_str(r#"{"__template_concat__":"#);
79            write_expression_list(parts, out);
80            out.push('}');
81        }
82        Expression::Array(items) => write_expression_list(items, out),
83        Expression::Object(entries) => write_object(entries, out),
84        Expression::Conditional(c) => {
85            out.push_str(r#"{"__conditional__":{"cond":"#);
86            write_expression(&c.cond, out);
87            out.push_str(r#","else":"#);
88            write_expression(&c.else_branch, out);
89            out.push_str(r#","then":"#);
90            write_expression(&c.then_branch, out);
91            out.push_str("}}");
92        }
93        Expression::For(f) => write_for(f, out),
94    }
95}
96
97fn write_unresolved(s: &crate::ir::Symbolic, out: &mut String) {
98    out.push_str(r#"{"__kind__":"#);
99    write_json_string(symbol_kind_str(s.kind), out);
100    out.push_str(r#","__unresolved__":"#);
101    write_json_string(s.source.as_ref(), out);
102    out.push('}');
103}
104
105fn write_expression_list(items: &[Expression], out: &mut String) {
106    out.push('[');
107    for (idx, item) in items.iter().enumerate() {
108        if idx > 0 {
109            out.push(',');
110        }
111        write_expression(item, out);
112    }
113    out.push(']');
114}
115
116#[allow(clippy::indexing_slicing)]
117fn write_object(entries: &[(Expression, Expression)], out: &mut String) {
118    // Keys may themselves be expressions; render the alpha-sorted
119    // string projection of each key for stability.
120    let mut indexed: Vec<(usize, String)> = entries
121        .iter()
122        .enumerate()
123        .map(|(i, (k, _))| (i, expression_to_canonical(k)))
124        .collect();
125    indexed.sort_by(|(_, a), (_, b)| Ord::cmp(a, b));
126    out.push('{');
127    for (n, (orig_idx, key_str)) in indexed.iter().enumerate() {
128        if n > 0 {
129            out.push(',');
130        }
131        write_json_string(key_str, out);
132        out.push(':');
133        write_expression(&entries[*orig_idx].1, out);
134    }
135    out.push('}');
136}
137
138fn write_for(f: &crate::ir::ForExpr, out: &mut String) {
139    out.push_str(r#"{"__for__":{"binders":["#);
140    for (idx, b) in f.binders.iter().enumerate() {
141        if idx > 0 {
142            out.push(',');
143        }
144        write_json_string(b.as_ref(), out);
145    }
146    out.push_str(r#"],"collection":"#);
147    write_expression(&f.collection, out);
148    out.push_str(r#","object_form":"#);
149    out.push_str(if f.object_form { "true" } else { "false" });
150    out.push_str(r#","value":"#);
151    write_expression(&f.value, out);
152    out.push_str("}}");
153}
154
155fn write_value(v: &Value, out: &mut String) {
156    match v {
157        Value::Null => out.push_str("null"),
158        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
159        Value::Int(n) => {
160            // i64::MIN..=i64::MAX renders as ASCII digits — never errors.
161            let _ = write!(out, "{n}");
162        }
163        Value::Number(f) => {
164            if f.is_finite() {
165                let mut buf = ryu::Buffer::new();
166                out.push_str(buf.format(*f));
167            } else {
168                // Strict JSON has no NaN / Inf; render as null.
169                out.push_str("null");
170            }
171        }
172        Value::Str(s) => write_json_string(s.as_ref(), out),
173        Value::List(items) => {
174            out.push('[');
175            for (idx, item) in items.iter().enumerate() {
176                if idx > 0 {
177                    out.push(',');
178                }
179                write_value(item, out);
180            }
181            out.push(']');
182        }
183        Value::Map(entries) => {
184            // Stable alpha order over UTF-8 string keys (insertion order is
185            // preserved in the IR, but canonical JSON commits to alpha).
186            let mut sorted: Vec<(&str, &Value)> =
187                entries.iter().map(|(k, v)| (k.as_ref(), v)).collect();
188            sorted.sort_by(|(a, _), (b, _)| match (a, b) {
189                (a, b) if a == b => Ordering::Equal,
190                (a, b) => str::cmp(a, b),
191            });
192            out.push('{');
193            for (n, (k, val)) in sorted.iter().enumerate() {
194                if n > 0 {
195                    out.push(',');
196                }
197                write_json_string(k, out);
198                out.push(':');
199                write_value(val, out);
200            }
201            out.push('}');
202        }
203    }
204}
205
206fn write_json_string(s: &str, out: &mut String) {
207    out.push('"');
208    for ch in s.chars() {
209        match ch {
210            '"' => out.push_str("\\\""),
211            '\\' => out.push_str("\\\\"),
212            '\n' => out.push_str("\\n"),
213            '\r' => out.push_str("\\r"),
214            '\t' => out.push_str("\\t"),
215            '\x08' => out.push_str("\\b"),
216            '\x0c' => out.push_str("\\f"),
217            c if (c as u32) < 0x20 => {
218                let _ = write!(out, "\\u{:04x}", c as u32);
219            }
220            c => out.push(c),
221        }
222    }
223    out.push('"');
224}
225
226const fn symbol_kind_str(k: SymbolKind) -> &'static str {
227    match k {
228        SymbolKind::Var => "Var",
229        SymbolKind::Local => "Local",
230        SymbolKind::Resource => "Resource",
231        SymbolKind::Data => "Data",
232        SymbolKind::Module => "Module",
233        SymbolKind::Path => "Path",
234        SymbolKind::Iteration => "Iteration",
235        SymbolKind::Terraform => "Terraform",
236        SymbolKind::TerragruntDependency => "TerragruntDependency",
237        SymbolKind::Other => "Other",
238    }
239}
240
241/// Compact canonical projection of an arbitrary [`Expression`] used purely
242/// for sort-key generation when an [`Expression::Object`] has expression keys.
243fn expression_to_canonical(expr: &Expression) -> String {
244    let mut s = String::new();
245    write_expression(expr, &mut s);
246    s
247}
248
249#[cfg(test)]
250#[allow(
251    clippy::unwrap_used,
252    clippy::expect_used,
253    clippy::panic,
254    clippy::indexing_slicing
255)]
256mod tests {
257    use std::sync::Arc;
258
259    use super::*;
260    use crate::ir::{Span, Symbolic};
261
262    fn s(src: &str, kind: SymbolKind) -> Expression {
263        Expression::Unresolved(
264            Symbolic::builder()
265                .kind(kind)
266                .source(Arc::<str>::from(src))
267                .span(Span::synthetic())
268                .build(),
269        )
270    }
271
272    #[test]
273    fn test_should_render_literal_attribute_map() {
274        let map: AttributeMap = vec![
275            (
276                Arc::<str>::from("name"),
277                Expression::Literal(Value::Str(Arc::from("svc"))),
278            ),
279            (
280                Arc::<str>::from("count"),
281                Expression::Literal(Value::Int(3)),
282            ),
283            (
284                Arc::<str>::from("enabled"),
285                Expression::Literal(Value::Bool(true)),
286            ),
287        ];
288        let out = attribute_map_to_string(&map);
289        // Keys are alpha-sorted: count, enabled, name.
290        assert_eq!(out, r#"{"count":3,"enabled":true,"name":"svc"}"#);
291    }
292
293    #[test]
294    fn test_should_render_unresolved_with_sentinel() {
295        let map: AttributeMap =
296            vec![(Arc::<str>::from("region"), s("var.region", SymbolKind::Var))];
297        let out = attribute_map_to_string(&map);
298        assert_eq!(
299            out,
300            r#"{"region":{"__kind__":"Var","__unresolved__":"var.region"}}"#
301        );
302    }
303
304    #[test]
305    fn test_should_render_func_call_with_unresolved_func_sentinel() {
306        let map: AttributeMap = vec![(
307            Arc::<str>::from("payload"),
308            Expression::FuncCall(Box::new(crate::ir::FuncCall {
309                name: Arc::from("jsonencode"),
310                args: vec![Expression::Literal(Value::Str(Arc::from("hi")))],
311                span: Span::synthetic(),
312            })),
313        )];
314        let out = attribute_map_to_string(&map);
315        assert_eq!(
316            out,
317            r#"{"payload":{"__unresolved_func__":"jsonencode","args":["hi"]}}"#
318        );
319    }
320
321    #[test]
322    fn test_should_be_deterministic_for_same_input() {
323        let map: AttributeMap = vec![
324            (Arc::<str>::from("z"), Expression::Literal(Value::Int(1))),
325            (Arc::<str>::from("a"), Expression::Literal(Value::Int(2))),
326            (Arc::<str>::from("m"), Expression::Literal(Value::Int(3))),
327        ];
328        let a = attribute_map_to_string(&map);
329        let b = attribute_map_to_string(&map);
330        assert_eq!(a, b);
331        assert_eq!(a, r#"{"a":2,"m":3,"z":1}"#);
332    }
333
334    #[test]
335    fn test_should_render_nested_value_map_alpha_sorted() {
336        let map: AttributeMap = vec![(
337            Arc::<str>::from("tags"),
338            Expression::Literal(Value::Map(vec![
339                (Arc::from("Owner"), Value::Str(Arc::from("y"))),
340                (Arc::from("Service"), Value::Str(Arc::from("x"))),
341            ])),
342        )];
343        let out = attribute_map_to_string(&map);
344        assert_eq!(out, r#"{"tags":{"Owner":"y","Service":"x"}}"#);
345    }
346
347    #[test]
348    fn test_should_escape_control_characters_in_strings() {
349        let map: AttributeMap = vec![(
350            Arc::<str>::from("msg"),
351            Expression::Literal(Value::Str(Arc::from("hi\n\"world"))),
352        )];
353        let out = attribute_map_to_string(&map);
354        assert!(out.contains(r"\n"));
355        assert!(out.contains(r#"\""#));
356    }
357
358    #[test]
359    fn test_should_render_array_of_mixed_resolved_and_unresolved() {
360        let map: AttributeMap = vec![(
361            Arc::<str>::from("cidrs"),
362            Expression::Array(vec![
363                Expression::Literal(Value::Str(Arc::from("10.0.0.0/8"))),
364                s("var.extra", SymbolKind::Var),
365            ]),
366        )];
367        let out = attribute_map_to_string(&map);
368        assert_eq!(
369            out,
370            r#"{"cidrs":["10.0.0.0/8",{"__kind__":"Var","__unresolved__":"var.extra"}]}"#
371        );
372    }
373
374    #[test]
375    fn test_should_render_finite_floats_via_ryu_and_nan_as_null() {
376        let map: AttributeMap = vec![
377            (
378                Arc::<str>::from("ratio"),
379                Expression::Literal(Value::Number(1.5)),
380            ),
381            (
382                Arc::<str>::from("nan"),
383                Expression::Literal(Value::Number(f64::NAN)),
384            ),
385        ];
386        let out = attribute_map_to_string(&map);
387        assert!(out.contains("1.5"), "{out}");
388        assert!(out.contains(r#""nan":null"#), "{out}");
389    }
390
391    #[test]
392    fn test_should_render_unresolved_keys_in_alpha_byte_order() {
393        // `__kind__` < `__unresolved__` under ASCII byte order — both start
394        // with `__` so the next char decides. The byte string must be
395        // exactly this; future refactors that flip the order would silently
396        // break downstream consumers that rely on byte-determinism.
397        let map: AttributeMap = vec![(Arc::<str>::from("r"), s("var.x", SymbolKind::Var))];
398        let out = attribute_map_to_string(&map);
399        assert_eq!(out, r#"{"r":{"__kind__":"Var","__unresolved__":"var.x"}}"#);
400    }
401
402    #[test]
403    fn test_should_render_func_call_keys_in_alpha_byte_order() {
404        // `_` (0x5F) < `a` (0x61), so `__unresolved_func__` < `args`.
405        let map: AttributeMap = vec![(
406            Arc::<str>::from("p"),
407            Expression::FuncCall(Box::new(crate::ir::FuncCall {
408                name: Arc::from("base64encode"),
409                args: vec![],
410                span: Span::synthetic(),
411            })),
412        )];
413        let out = attribute_map_to_string(&map);
414        assert_eq!(
415            out,
416            r#"{"p":{"__unresolved_func__":"base64encode","args":[]}}"#
417        );
418    }
419
420    #[test]
421    fn test_should_render_binary_op_keys_in_alpha_byte_order() {
422        let map: AttributeMap = vec![(
423            Arc::<str>::from("flag"),
424            Expression::BinaryOp {
425                op: crate::ir::BinaryOp::Eq,
426                lhs: Box::new(Expression::Literal(crate::ir::Value::Int(1))),
427                rhs: Box::new(Expression::Literal(crate::ir::Value::Int(2))),
428                span: Span::synthetic(),
429            },
430        )];
431        let out = attribute_map_to_string(&map);
432        // `__binary_op__` < `lhs` < `rhs` byte-lexically.
433        assert_eq!(out, r#"{"flag":{"__binary_op__":"Eq","lhs":1,"rhs":2}}"#);
434    }
435
436    #[test]
437    fn test_should_parse_back_through_serde_json() {
438        let map: AttributeMap = vec![
439            (
440                Arc::<str>::from("name"),
441                Expression::Literal(Value::Str(Arc::from("svc"))),
442            ),
443            (Arc::<str>::from("region"), s("var.region", SymbolKind::Var)),
444        ];
445        let out = attribute_map_to_string(&map);
446        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
447        assert_eq!(v["name"], "svc");
448        assert_eq!(v["region"]["__unresolved__"], "var.region");
449        assert_eq!(v["region"]["__kind__"], "Var");
450    }
451}