Skip to main content

luft_core/
json_to_lua.rs

1//! Encode `serde_json::Value` as a Lua expression.
2//!
3//! Used by workflow tool consumers to inject user-supplied `args` into the Lua
4//! global `_G._args` so that workflows can read it as a regular Lua table.
5//!
6//! Limitations:
7//! - JSON `null` becomes Lua `nil`.
8//! - JSON numbers are emitted as Rust `i64`/`u64` when representable, otherwise
9//!   as the default float formatting. Lossy beyond ~15 significant digits.
10//! - JSON object keys that are not valid Lua identifiers are emitted as
11//!   `["key"]` string-indexed entries.
12//! - String contents are escaped for double-quoted Lua string literals;
13//!   control characters below 0x20 are emitted as `\xHH`.
14
15use serde_json::Value;
16use std::fmt::Write;
17
18pub fn json_to_lua(value: &Value) -> String {
19    let mut out = String::new();
20    write_value(value, &mut out);
21    out
22}
23
24fn write_value(value: &Value, out: &mut String) {
25    match value {
26        Value::Null => out.push_str("nil"),
27        Value::Bool(true) => out.push_str("true"),
28        Value::Bool(false) => out.push_str("false"),
29        Value::Number(n) => {
30            if let Some(i) = n.as_i64() {
31                let _ = write!(out, "{i}");
32            } else if let Some(u) = n.as_u64() {
33                let _ = write!(out, "{u}");
34            } else {
35                let _ = write!(out, "{n}");
36            }
37        }
38        Value::String(s) => write_string(s, out),
39        Value::Array(items) => {
40            out.push('{');
41            for (i, item) in items.iter().enumerate() {
42                if i > 0 {
43                    out.push_str(", ");
44                }
45                write_value(item, out);
46            }
47            out.push('}');
48        }
49        Value::Object(map) => {
50            out.push('{');
51            let mut first = true;
52            for (k, v) in map {
53                if !first {
54                    out.push_str(", ");
55                }
56                first = false;
57                write_object_key(k, out);
58                out.push_str(" = ");
59                write_value(v, out);
60            }
61            out.push('}');
62        }
63    }
64}
65
66fn write_string(s: &str, out: &mut String) {
67    out.push('"');
68    for ch in s.chars() {
69        match ch {
70            '"' => out.push_str("\\\""),
71            '\\' => out.push_str("\\\\"),
72            '\n' => out.push_str("\\n"),
73            '\r' => out.push_str("\\r"),
74            '\t' => out.push_str("\\t"),
75            '\x08' => out.push_str("\\b"),
76            '\x0c' => out.push_str("\\f"),
77            '\x07' => out.push_str("\\a"),
78            '\x0b' => out.push_str("\\v"),
79            c if (c as u32) < 0x20 => {
80                let _ = write!(out, "\\x{:02x}", c as u32);
81            }
82            c => out.push(c),
83        }
84    }
85    out.push('"');
86}
87
88fn write_object_key(k: &str, out: &mut String) {
89    if is_valid_lua_ident(k) {
90        out.push_str(k);
91    } else {
92        write_string(k, out);
93    }
94}
95
96fn is_valid_lua_ident(s: &str) -> bool {
97    let mut chars = s.chars();
98    match chars.next() {
99        Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
100        _ => return false,
101    }
102    chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serde_json::json;
109
110    #[test]
111    fn primitives() {
112        assert_eq!(json_to_lua(&json!(null)), "nil");
113        assert_eq!(json_to_lua(&json!(true)), "true");
114        assert_eq!(json_to_lua(&json!(false)), "false");
115        assert_eq!(json_to_lua(&json!(42)), "42");
116        assert_eq!(json_to_lua(&json!(-7)), "-7");
117        assert_eq!(json_to_lua(&json!(0)), "0");
118    }
119
120    #[test]
121    fn floats_emit_as_decimal() {
122        assert_eq!(json_to_lua(&json!(3.5)), "3.5");
123    }
124
125    #[test]
126    fn large_unsigned() {
127        assert_eq!(json_to_lua(&json!(9_000_000_000_u64)), "9000000000");
128    }
129
130    #[test]
131    fn empty_collections() {
132        assert_eq!(json_to_lua(&json!([])), "{}");
133        assert_eq!(json_to_lua(&json!({})), "{}");
134    }
135
136    #[test]
137    fn array_of_ints() {
138        assert_eq!(json_to_lua(&json!([1, 2, 3])), "{1, 2, 3}");
139    }
140
141    #[test]
142    fn mixed_array() {
143        assert_eq!(
144            json_to_lua(&json!(["a", 1, true, null])),
145            "{\"a\", 1, true, nil}"
146        );
147    }
148
149    #[test]
150    fn object_simple_keys() {
151        assert_eq!(
152            json_to_lua(&json!({"topic": "rust", "n": 10})),
153            "{n = 10, topic = \"rust\"}"
154        );
155    }
156
157    #[test]
158    fn object_invalid_ident_keys_quoted() {
159        assert_eq!(
160            json_to_lua(&json!({"with-dash": 1, "9starts_with_digit": 2})),
161            "{\"9starts_with_digit\" = 2, \"with-dash\" = 1}"
162        );
163    }
164
165    #[test]
166    fn string_escaping() {
167        assert_eq!(
168            json_to_lua(&json!("hello \"world\"\n\t\\end")),
169            "\"hello \\\"world\\\"\\n\\t\\\\end\""
170        );
171    }
172
173    #[test]
174    fn string_with_control_char() {
175        assert_eq!(json_to_lua(&json!("\x01ok")), "\"\\x01ok\"");
176    }
177
178    #[test]
179    fn nested_structure() {
180        assert_eq!(
181            json_to_lua(&json!({
182                "topic": "rust",
183                "tags": ["async", "tokio"],
184                "opts": {"depth": 2, "recursive": false}
185            })),
186            "{opts = {depth = 2, recursive = false}, tags = {\"async\", \"tokio\"}, topic = \"rust\"}"
187        );
188    }
189
190    #[test]
191    fn lua_ident_edge_cases() {
192        assert!(is_valid_lua_ident("_x"));
193        assert!(is_valid_lua_ident("x9"));
194        assert!(!is_valid_lua_ident("9x"));
195        assert!(!is_valid_lua_ident("x-y"));
196        assert!(!is_valid_lua_ident(""));
197        assert!(!is_valid_lua_ident("with space"));
198    }
199
200    #[test]
201    fn roundtrip_object_count() {
202        // The Lua table should preserve the number of fields
203        let v = json!({"a": 1, "b": 2, "c": 3});
204        let lua = json_to_lua(&v);
205        assert_eq!(lua.matches(" = ").count(), 3);
206    }
207}