Skip to main content

lean_ctx/core/
jsonc.rs

1use serde_json::Value;
2
3/// Strip `//` line comments and `/* */` block comments from JSONC,
4/// then parse with serde_json. String contents are preserved verbatim.
5pub fn parse_jsonc(input: &str) -> Result<Value, serde_json::Error> {
6    let stripped = strip_json_comments(input);
7    serde_json::from_str(&stripped)
8}
9
10fn strip_json_comments(input: &str) -> String {
11    let bytes = input.as_bytes();
12    let len = bytes.len();
13    let mut out = String::with_capacity(len);
14    let mut i = 0;
15    let mut seg = 0;
16
17    while i < len {
18        let b = bytes[i];
19
20        if b == b'"' {
21            i += 1;
22            while i < len {
23                let c = bytes[i];
24                i += 1;
25                if c == b'\\' && i < len {
26                    i += 1;
27                } else if c == b'"' {
28                    break;
29                }
30            }
31            continue;
32        }
33
34        if b == b'/' && i + 1 < len {
35            if bytes[i + 1] == b'/' {
36                out.push_str(&input[seg..i]);
37                i += 2;
38                while i < len && bytes[i] != b'\n' {
39                    i += 1;
40                }
41                seg = i;
42                continue;
43            }
44            if bytes[i + 1] == b'*' {
45                out.push_str(&input[seg..i]);
46                i += 2;
47                while i + 1 < len {
48                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
49                        i += 2;
50                        break;
51                    }
52                    i += 1;
53                }
54                seg = i;
55                continue;
56            }
57        }
58
59        i += 1;
60    }
61
62    out.push_str(&input[seg..]);
63    out
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn strips_line_comments() {
72        let input = r#"{
73  // this is a comment
74  "key": "value"
75}"#;
76        let v = parse_jsonc(input).unwrap();
77        assert_eq!(v["key"], "value");
78    }
79
80    #[test]
81    fn strips_block_comments() {
82        let input = r#"{
83  /* block
84     comment */
85  "key": "value"
86}"#;
87        let v = parse_jsonc(input).unwrap();
88        assert_eq!(v["key"], "value");
89    }
90
91    #[test]
92    fn preserves_slashes_in_strings() {
93        let input = r#"{"url": "https://example.com/path"}"#;
94        let v = parse_jsonc(input).unwrap();
95        assert_eq!(v["url"], "https://example.com/path");
96    }
97
98    #[test]
99    fn preserves_comment_like_content_in_strings() {
100        let input = r#"{"note": "see // inline", "code": "/* not a comment */"}"#;
101        let v = parse_jsonc(input).unwrap();
102        assert_eq!(v["note"], "see // inline");
103        assert_eq!(v["code"], "/* not a comment */");
104    }
105
106    #[test]
107    fn handles_escaped_quotes_in_strings() {
108        let input = r#"{"msg": "say \"hello\" // world"}"#;
109        let v = parse_jsonc(input).unwrap();
110        assert_eq!(v["msg"], r#"say "hello" // world"#);
111    }
112
113    #[test]
114    fn handles_trailing_comma_free_json() {
115        let input = r#"{
116  "a": 1,
117  // comment between entries
118  "b": 2
119}"#;
120        let v = parse_jsonc(input).unwrap();
121        assert_eq!(v["a"], 1);
122        assert_eq!(v["b"], 2);
123    }
124
125    #[test]
126    fn empty_input() {
127        assert!(parse_jsonc("").is_err());
128    }
129
130    #[test]
131    fn pure_json_passthrough() {
132        let input = r#"{"key": "value", "num": 42}"#;
133        let v = parse_jsonc(input).unwrap();
134        assert_eq!(v["key"], "value");
135        assert_eq!(v["num"], 42);
136    }
137
138    #[test]
139    fn real_opencode_config_with_comments() {
140        let input = r#"{
141  // OpenCode configuration
142  "$schema": "https://opencode.ai/config.json",
143  "mcp": {
144    /* existing tool */
145    "my-tool": {
146      "type": "local",
147      "command": ["my-tool"],
148      "enabled": true
149    }
150  }
151}"#;
152        let v = parse_jsonc(input).unwrap();
153        assert_eq!(v["$schema"], "https://opencode.ai/config.json");
154        assert!(v["mcp"]["my-tool"]["enabled"].as_bool().unwrap());
155    }
156
157    #[test]
158    fn utf8_umlauts_preserved() {
159        let input = "{\n  // German names\n  \"name\": \"Müller\",\n  \"city\": \"Zürich\"\n}";
160        let v = parse_jsonc(input).unwrap();
161        assert_eq!(v["name"], "Müller");
162        assert_eq!(v["city"], "Zürich");
163    }
164
165    #[test]
166    fn utf8_cjk_with_block_comment() {
167        let input = "{\n  /* 日本語コメント */\n  \"desc\": \"日本語テスト\"\n}";
168        let v = parse_jsonc(input).unwrap();
169        assert_eq!(v["desc"], "日本語テスト");
170    }
171
172    #[test]
173    fn utf8_emoji_between_comments() {
174        let input = "{\n  // before\n  \"icon\": \"🚀🔥\",\n  /* after */\n  \"ok\": true\n}";
175        let v = parse_jsonc(input).unwrap();
176        assert_eq!(v["icon"], "🚀🔥");
177        assert!(v["ok"].as_bool().unwrap());
178    }
179
180    #[test]
181    fn utf8_in_comment_stripped_cleanly() {
182        let input = "{\n  // Achtung: ä ö ü ß\n  \"key\": \"value\"\n}";
183        let v = parse_jsonc(input).unwrap();
184        assert_eq!(v["key"], "value");
185    }
186
187    #[test]
188    fn utf8_in_key() {
189        let input = "{\"straße\": \"Hauptstraße 42\"}";
190        let v = parse_jsonc(input).unwrap();
191        assert_eq!(v["straße"], "Hauptstraße 42");
192    }
193
194    #[test]
195    fn mixed_ascii_and_utf8_values() {
196        let input = "{\n  // config\n  \"en\": \"hello\",\n  \"ru\": \"привет\",\n  \"jp\": \"こんにちは\"\n}";
197        let v = parse_jsonc(input).unwrap();
198        assert_eq!(v["en"], "hello");
199        assert_eq!(v["ru"], "привет");
200        assert_eq!(v["jp"], "こんにちは");
201    }
202
203    #[test]
204    fn escaped_unicode_unchanged() {
205        let input = r#"{"test": "\u00e4\u00f6\u00fc"}"#;
206        let v = parse_jsonc(input).unwrap();
207        assert_eq!(v["test"], "\u{00e4}\u{00f6}\u{00fc}");
208    }
209
210    #[test]
211    fn utf8_at_comment_boundary() {
212        let input = "{\n  \"before\": \"текст\"// комментарий\n, \"after\": 1\n}";
213        let v = parse_jsonc(input).unwrap();
214        assert_eq!(v["before"], "текст");
215        assert_eq!(v["after"], 1);
216    }
217
218    #[test]
219    fn empty_string_after_utf8_comment() {
220        let input = "{\n  // Ü\n  \"key\": \"\"\n}";
221        let v = parse_jsonc(input).unwrap();
222        assert_eq!(v["key"], "");
223    }
224
225    #[test]
226    fn real_claude_settings_with_german_paths() {
227        let input = r#"{
228  // Claude Code Einstellungen
229  "mcpServers": {
230    /* Lean-CTX Konfiguration für /Users/müller/Projekte */
231    "lean-ctx": {
232      "command": "/Users/müller/.local/bin/lean-ctx",
233      "args": ["--project", "/Users/müller/Projekte/größtes-projekt"]
234    }
235  }
236}"#;
237        let v = parse_jsonc(input).unwrap();
238        assert_eq!(
239            v["mcpServers"]["lean-ctx"]["command"],
240            "/Users/müller/.local/bin/lean-ctx"
241        );
242        let args = v["mcpServers"]["lean-ctx"]["args"].as_array().unwrap();
243        assert_eq!(args[1], "/Users/müller/Projekte/größtes-projekt");
244    }
245}