1use serde_json::Value;
3
4pub 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
77fn 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 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 #[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 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 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}