1use serde_json::Value;
2
3pub 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}