Skip to main content

apif_parser/
json_mod.rs

1use serde_json::Value;
2
3/// Parse JSON5 string into serde_json::Value
4/// Supports: comments (`//`, `#`, `/* */`), trailing commas, unquoted keys
5pub fn from_str(json_str: &str) -> Result<Value, anyhow::Error> {
6    let cleaned = tokenize_strip_comments(json_str);
7    json5::from_str(&cleaned).map_err(|e| anyhow::anyhow!("Failed to parse JSON5: {}", e))
8}
9
10/// Tokenize JSON5 content, stripping all comments.
11/// This is a single-pass state machine — no regex, no string hacks.
12///
13/// States:
14///   Normal → String → Escaped
15///   Normal → LineComment (`//`, `#`) → end of line
16///   Normal → BlockComment (`/*`) → `*/`
17fn tokenize_strip_comments(input: &str) -> String {
18    let mut out = String::with_capacity(input.len());
19    let mut chars = input.chars().peekable();
20
21    while let Some(ch) = chars.next() {
22        match ch {
23            '"' => {
24                out.push(ch);
25                while let Some(c) = chars.next() {
26                    out.push(c);
27                    if c == '\\' {
28                        if let Some(escaped) = chars.next() {
29                            out.push(escaped);
30                        }
31                    } else if c == '"' {
32                        break;
33                    }
34                }
35            }
36            '/' => {
37                if let Some(kind) = chars.next_if_map(|next| match next {
38                    '/' | '*' => Ok(next),
39                    _ => Err(next),
40                }) {
41                    if kind == '/' {
42                        // Line comment — skip to end of line
43                        for c in chars.by_ref() {
44                            if c == '\n' {
45                                out.push(c);
46                                break;
47                            }
48                        }
49                    } else {
50                        // Block comment — skip until */
51                        loop {
52                            match chars.next() {
53                                Some('*') => {
54                                    if chars.next_if_eq(&'/').is_some() {
55                                        break;
56                                    }
57                                }
58                                Some(c) if c == '\n' => {
59                                    out.push(c);
60                                }
61                                Some(_) => {}
62                                None => break,
63                            }
64                        }
65                    }
66                } else {
67                    out.push(ch)
68                }
69            }
70            '#' => {
71                // Line comment (GCTF-style) — skip to end of line
72                for c in chars.by_ref() {
73                    if c == '\n' {
74                        out.push(c);
75                        break;
76                    }
77                }
78            }
79            c => {
80                out.push(c);
81            }
82        }
83    }
84
85    out
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use serde_json::json;
92
93    #[test]
94    fn test_parse_json5_simple() {
95        let input = r#"{key: "value"}"#;
96        let expected = json!({"key": "value"});
97        assert_eq!(from_str(input).unwrap(), expected);
98    }
99
100    #[test]
101    fn test_parse_json5_comments() {
102        let input = r#"{
103            // This is a comment
104            key: "value" /* block comment */
105        }"#;
106        let expected = json!({"key": "value"});
107        assert_eq!(from_str(input).unwrap(), expected);
108    }
109
110    #[test]
111    fn test_parse_json5_trailing_comma() {
112        let input = r#"{
113            key: "value",
114        }"#;
115        let expected = json!({"key": "value"});
116        assert_eq!(from_str(input).unwrap(), expected);
117    }
118
119    #[test]
120    fn test_parse_json5_unquoted_keys() {
121        let input = r#"{
122            key: "value",
123            number: 123,
124        }"#;
125        let expected = json!({
126            "key": "value",
127            "number": 123
128        });
129        assert_eq!(from_str(input).unwrap(), expected);
130    }
131
132    #[test]
133    fn test_parse_hash_comments() {
134        let input = r#"{
135            key: "value", # inline comment
136            num: 1
137        }"#;
138        let expected = json!({"key": "value", "num": 1});
139        assert_eq!(from_str(input).unwrap(), expected);
140    }
141
142    #[test]
143    fn test_hash_in_string_not_comment() {
144        let input = r#"{
145            url: "https://example.com/path#anchor"
146        }"#;
147        let expected = json!({"url": "https://example.com/path#anchor"});
148        assert_eq!(from_str(input).unwrap(), expected);
149    }
150
151    #[test]
152    fn test_tokenize_inline_slash_comment() {
153        let input = r#"{
154  "ipsToDecorations": {
155    "10.0.0.1": {
156      "decoration": "web-frontend",
157      // "environment": "production"
158    }
159  }
160}"#;
161        let result = from_str(input).unwrap();
162        assert_eq!(
163            result["ipsToDecorations"]["10.0.0.1"]["decoration"],
164            "web-frontend"
165        );
166    }
167
168    #[test]
169    fn test_tokenize_trailing_comment_after_json() {
170        let input = r#"{
171  "key": "value"
172}
173// trailing comment
174"#;
175        let result = from_str(input).unwrap();
176        assert_eq!(result["key"], "value");
177    }
178
179    #[test]
180    fn test_tokenize_block_comment_multiline() {
181        let input = r#"{
182  /* this is
183     a multiline
184     block comment */
185  "key": "value"
186}"#;
187        let result = from_str(input).unwrap();
188        assert_eq!(result["key"], "value");
189    }
190
191    #[test]
192    fn test_tokenize_slash_in_string_preserved() {
193        let input = r#"{"url": "http://example.com", "path": "a/b/c"}"#;
194        let result = from_str(input).unwrap();
195        assert_eq!(result["url"], "http://example.com");
196        assert_eq!(result["path"], "a/b/c");
197    }
198
199    #[test]
200    fn test_tokenize_escaped_quotes_in_string() {
201        let input = r#"{"text": "say \"hello\" // not a comment"}"#;
202        let result = from_str(input).unwrap();
203        assert_eq!(result["text"], "say \"hello\" // not a comment");
204    }
205
206    #[test]
207    fn test_tokenize_hash_preserves_newlines() {
208        let input = "{\n  # comment line 1\n  # comment line 2\n  \"key\": \"value\"\n}";
209        let result = from_str(input).unwrap();
210        assert_eq!(result["key"], "value");
211    }
212}