1use std::{cmp::Ordering, fmt::Write as _};
19
20use crate::ir::{AttributeMap, Expression, SymbolKind, Value};
21
22pub 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#[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 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 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 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 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
241fn 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 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 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 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 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}