Skip to main content

lean_ctx_fs/
jsonc.rs

1// SPDX-License-Identifier: Apache-2.0
2use serde_json::Value;
3
4/// Strip `//` line comments, `/* */` block comments and trailing commas from
5/// JSONC, then parse with serde_json. String contents are preserved verbatim.
6///
7/// This makes lean-ctx tolerant of the JSONC dialect that editors like VS Code
8/// use for `settings.json` / `mcp.json` (comments + trailing commas are valid
9/// there but rejected by strict JSON). See issue #311.
10///
11/// A single leading UTF-8 BOM is also accepted. Windows editors emit it
12/// routinely, serde_json rejects it, and callers used to read that rejection as
13/// "config is corrupt" and scaffold a fresh one over the user's file (#1586).
14pub fn parse_jsonc(input: &str) -> Result<Value, serde_json::Error> {
15    let input = input.strip_prefix('\u{feff}').unwrap_or(input);
16    let stripped = strip_json_comments(input);
17    let cleaned = strip_trailing_commas(&stripped);
18    serde_json::from_str(&cleaned)
19}
20
21fn strip_json_comments(input: &str) -> String {
22    let bytes = input.as_bytes();
23    let len = bytes.len();
24    let mut out = String::with_capacity(len);
25    let mut i = 0;
26    let mut seg = 0;
27
28    while i < len {
29        let b = bytes[i];
30
31        if b == b'"' {
32            i += 1;
33            while i < len {
34                let c = bytes[i];
35                i += 1;
36                if c == b'\\' && i < len {
37                    i += 1;
38                } else if c == b'"' {
39                    break;
40                }
41            }
42            continue;
43        }
44
45        if b == b'/' && i + 1 < len {
46            if bytes[i + 1] == b'/' {
47                out.push_str(&input[seg..i]);
48                i += 2;
49                while i < len && bytes[i] != b'\n' {
50                    i += 1;
51                }
52                seg = i;
53                continue;
54            }
55            if bytes[i + 1] == b'*' {
56                out.push_str(&input[seg..i]);
57                i += 2;
58                while i + 1 < len {
59                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
60                        i += 2;
61                        break;
62                    }
63                    i += 1;
64                }
65                seg = i;
66                continue;
67            }
68        }
69
70        i += 1;
71    }
72
73    out.push_str(&input[seg..]);
74    out
75}
76
77/// Remove trailing commas that appear before a closing `}` or `]`.
78/// String contents are preserved verbatim (commas inside strings are ignored).
79///
80/// Operates on already comment-stripped input. Uses byte-segment copying so
81/// multi-byte UTF-8 sequences are never split (all decision bytes are ASCII).
82fn strip_trailing_commas(input: &str) -> String {
83    let bytes = input.as_bytes();
84    let len = bytes.len();
85    let mut out = String::with_capacity(len);
86    let mut i = 0;
87    let mut seg = 0;
88
89    while i < len {
90        let b = bytes[i];
91
92        if b == b'"' {
93            i += 1;
94            while i < len {
95                let c = bytes[i];
96                i += 1;
97                if c == b'\\' && i < len {
98                    i += 1;
99                } else if c == b'"' {
100                    break;
101                }
102            }
103            continue;
104        }
105
106        if b == b',' {
107            let mut j = i + 1;
108            while j < len && bytes[j].is_ascii_whitespace() {
109                j += 1;
110            }
111            if j < len && (bytes[j] == b'}' || bytes[j] == b']') {
112                out.push_str(&input[seg..i]);
113                i += 1;
114                seg = i;
115                continue;
116            }
117        }
118
119        i += 1;
120    }
121
122    out.push_str(&input[seg..]);
123    out
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn leading_bom_is_accepted() {
132        // #1586: Windows editors save configs BOM-prefixed. serde_json rejects
133        // that, and callers read the rejection as "file is corrupt".
134        let v = parse_jsonc("\u{feff}{\n  // hi\n  \"a\": 1,\n}").expect("BOM config must parse");
135        assert_eq!(v["a"], 1);
136    }
137
138    #[test]
139    fn only_one_leading_bom_is_stripped() {
140        assert!(parse_jsonc("\u{feff}\u{feff}{\"a\":1}").is_err());
141    }
142
143    #[test]
144    fn strips_line_comments() {
145        let input = r#"{
146  // this is a comment
147  "key": "value"
148}"#;
149        let v = parse_jsonc(input).unwrap();
150        assert_eq!(v["key"], "value");
151    }
152
153    #[test]
154    fn strips_block_comments() {
155        let input = r#"{
156  /* block
157     comment */
158  "key": "value"
159}"#;
160        let v = parse_jsonc(input).unwrap();
161        assert_eq!(v["key"], "value");
162    }
163
164    #[test]
165    fn preserves_slashes_in_strings() {
166        let input = r#"{"url": "https://example.com/path"}"#;
167        let v = parse_jsonc(input).unwrap();
168        assert_eq!(v["url"], "https://example.com/path");
169    }
170
171    #[test]
172    fn preserves_comment_like_content_in_strings() {
173        let input = r#"{"note": "see // inline", "code": "/* not a comment */"}"#;
174        let v = parse_jsonc(input).unwrap();
175        assert_eq!(v["note"], "see // inline");
176        assert_eq!(v["code"], "/* not a comment */");
177    }
178
179    #[test]
180    fn handles_escaped_quotes_in_strings() {
181        let input = r#"{"msg": "say \"hello\" // world"}"#;
182        let v = parse_jsonc(input).unwrap();
183        assert_eq!(v["msg"], r#"say "hello" // world"#);
184    }
185
186    #[test]
187    fn handles_trailing_comma_free_json() {
188        let input = r#"{
189  "a": 1,
190  // comment between entries
191  "b": 2
192}"#;
193        let v = parse_jsonc(input).unwrap();
194        assert_eq!(v["a"], 1);
195        assert_eq!(v["b"], 2);
196    }
197
198    #[test]
199    fn empty_input() {
200        assert!(parse_jsonc("").is_err());
201    }
202
203    // --- #311: trailing comma support (VS Code / JSONC dialect) ---
204
205    #[test]
206    fn strips_trailing_comma_in_object() {
207        let input = r#"{
208  "a": 1,
209  "b": 2,
210}"#;
211        let v = parse_jsonc(input).unwrap();
212        assert_eq!(v["a"], 1);
213        assert_eq!(v["b"], 2);
214    }
215
216    #[test]
217    fn strips_trailing_comma_in_array() {
218        let input = r#"{"list": [1, 2, 3,]}"#;
219        let v = parse_jsonc(input).unwrap();
220        assert_eq!(v["list"][2], 3);
221    }
222
223    #[test]
224    fn strips_trailing_comma_with_whitespace_and_newlines() {
225        let input = "{\n  \"a\": 1  ,\n\n}";
226        let v = parse_jsonc(input).unwrap();
227        assert_eq!(v["a"], 1);
228    }
229
230    #[test]
231    fn strips_nested_trailing_commas() {
232        let input = r#"{
233  "outer": {
234    "inner": [
235      "x",
236      "y",
237    ],
238  },
239}"#;
240        let v = parse_jsonc(input).unwrap();
241        assert_eq!(v["outer"]["inner"][1], "y");
242    }
243
244    #[test]
245    fn preserves_comma_inside_string_before_brace() {
246        // A comma inside a string value must not be treated as trailing.
247        let input = r#"{"msg": "hello, world"}"#;
248        let v = parse_jsonc(input).unwrap();
249        assert_eq!(v["msg"], "hello, world");
250    }
251
252    #[test]
253    fn issue_311_vscode_settings_with_trailing_comma_and_comments() {
254        // Mirrors the real VS Code user settings.json that triggered #311:
255        // "trailing comma at line 4 column 5" plus JSONC comments.
256        let input = r#"{
257  // editor settings
258  "editor.fontSize": 14,
259  "editor.tabSize": 2,
260  "chat.mcp.enabled": true,
261}"#;
262        let v = parse_jsonc(input).unwrap();
263        assert_eq!(v["editor.fontSize"], 14);
264        assert!(v["chat.mcp.enabled"].as_bool().unwrap());
265    }
266
267    #[test]
268    fn pure_json_passthrough() {
269        let input = r#"{"key": "value", "num": 42}"#;
270        let v = parse_jsonc(input).unwrap();
271        assert_eq!(v["key"], "value");
272        assert_eq!(v["num"], 42);
273    }
274
275    #[test]
276    fn real_opencode_config_with_comments() {
277        let input = r#"{
278  // OpenCode configuration
279  "$schema": "https://opencode.ai/config.json",
280  "mcp": {
281    /* existing tool */
282    "my-tool": {
283      "type": "local",
284      "command": ["my-tool"],
285      "enabled": true
286    }
287  }
288}"#;
289        let v = parse_jsonc(input).unwrap();
290        assert_eq!(v["$schema"], "https://opencode.ai/config.json");
291        assert!(v["mcp"]["my-tool"]["enabled"].as_bool().unwrap());
292    }
293
294    #[test]
295    fn utf8_umlauts_preserved() {
296        let input = "{\n  // German names\n  \"name\": \"Müller\",\n  \"city\": \"Zürich\"\n}";
297        let v = parse_jsonc(input).unwrap();
298        assert_eq!(v["name"], "Müller");
299        assert_eq!(v["city"], "Zürich");
300    }
301
302    #[test]
303    fn utf8_cjk_with_block_comment() {
304        let input = "{\n  /* 日本語コメント */\n  \"desc\": \"日本語テスト\"\n}";
305        let v = parse_jsonc(input).unwrap();
306        assert_eq!(v["desc"], "日本語テスト");
307    }
308
309    #[test]
310    fn utf8_emoji_between_comments() {
311        let input = "{\n  // before\n  \"icon\": \"🚀🔥\",\n  /* after */\n  \"ok\": true\n}";
312        let v = parse_jsonc(input).unwrap();
313        assert_eq!(v["icon"], "🚀🔥");
314        assert!(v["ok"].as_bool().unwrap());
315    }
316
317    #[test]
318    fn utf8_in_comment_stripped_cleanly() {
319        let input = "{\n  // Achtung: ä ö ü ß\n  \"key\": \"value\"\n}";
320        let v = parse_jsonc(input).unwrap();
321        assert_eq!(v["key"], "value");
322    }
323
324    #[test]
325    fn utf8_in_key() {
326        let input = "{\"straße\": \"Hauptstraße 42\"}";
327        let v = parse_jsonc(input).unwrap();
328        assert_eq!(v["straße"], "Hauptstraße 42");
329    }
330
331    #[test]
332    fn mixed_ascii_and_utf8_values() {
333        let input = "{\n  // config\n  \"en\": \"hello\",\n  \"ru\": \"привет\",\n  \"jp\": \"こんにちは\"\n}";
334        let v = parse_jsonc(input).unwrap();
335        assert_eq!(v["en"], "hello");
336        assert_eq!(v["ru"], "привет");
337        assert_eq!(v["jp"], "こんにちは");
338    }
339
340    #[test]
341    fn escaped_unicode_unchanged() {
342        let input = r#"{"test": "\u00e4\u00f6\u00fc"}"#;
343        let v = parse_jsonc(input).unwrap();
344        assert_eq!(v["test"], "\u{00e4}\u{00f6}\u{00fc}");
345    }
346
347    #[test]
348    fn utf8_at_comment_boundary() {
349        let input = "{\n  \"before\": \"текст\"// комментарий\n, \"after\": 1\n}";
350        let v = parse_jsonc(input).unwrap();
351        assert_eq!(v["before"], "текст");
352        assert_eq!(v["after"], 1);
353    }
354
355    #[test]
356    fn empty_string_after_utf8_comment() {
357        let input = "{\n  // Ü\n  \"key\": \"\"\n}";
358        let v = parse_jsonc(input).unwrap();
359        assert_eq!(v["key"], "");
360    }
361
362    #[test]
363    fn real_claude_settings_with_german_paths() {
364        let input = r#"{
365  // Claude Code Einstellungen
366  "mcpServers": {
367    /* Lean-CTX Konfiguration für /Users/müller/Projekte */
368    "lean-ctx": {
369      "command": "/Users/müller/.local/bin/lean-ctx",
370      "args": ["--project", "/Users/müller/Projekte/größtes-projekt"]
371    }
372  }
373}"#;
374        let v = parse_jsonc(input).unwrap();
375        assert_eq!(
376            v["mcpServers"]["lean-ctx"]["command"],
377            "/Users/müller/.local/bin/lean-ctx"
378        );
379        let args = v["mcpServers"]["lean-ctx"]["args"].as_array().unwrap();
380        assert_eq!(args[1], "/Users/müller/Projekte/größtes-projekt");
381    }
382}