Skip to main content

aft/
jsonc.rs

1//! Helpers for preprocessing JSONC before handing it to `serde_json`.
2//!
3//! These helpers strip `//` and `/* ... */` comments while preserving comment-like
4//! text inside strings and honoring escape sequences, then remove trailing commas
5//! that appear immediately before `]` or `}`.
6
7pub(crate) fn strip_jsonc(source: &str) -> String {
8    strip_trailing_commas(&strip_jsonc_comments(source))
9}
10
11pub(crate) fn strip_jsonc_comments(source: &str) -> String {
12    let mut output = String::with_capacity(source.len());
13    let mut chars = source.chars().peekable();
14    let mut in_string = false;
15    let mut escaped = false;
16
17    while let Some(ch) = chars.next() {
18        if in_string {
19            output.push(ch);
20            if escaped {
21                escaped = false;
22            } else if ch == '\\' {
23                escaped = true;
24            } else if ch == '"' {
25                in_string = false;
26            }
27            continue;
28        }
29
30        if ch == '"' {
31            in_string = true;
32            output.push(ch);
33            continue;
34        }
35
36        if ch == '/' {
37            match chars.peek().copied() {
38                Some('/') => {
39                    chars.next();
40                    for next in chars.by_ref() {
41                        if next == '\n' {
42                            output.push('\n');
43                            break;
44                        }
45                    }
46                }
47                Some('*') => {
48                    chars.next();
49                    let mut previous = '\0';
50                    for next in chars.by_ref() {
51                        if next == '\n' {
52                            output.push('\n');
53                        }
54                        if previous == '*' && next == '/' {
55                            break;
56                        }
57                        previous = next;
58                    }
59                }
60                _ => output.push(ch),
61            }
62            continue;
63        }
64
65        output.push(ch);
66    }
67
68    output
69}
70
71pub(crate) fn strip_trailing_commas(source: &str) -> String {
72    let chars = source.chars().collect::<Vec<_>>();
73    let mut output = String::with_capacity(source.len());
74    let mut index = 0usize;
75    let mut in_string = false;
76    let mut escaped = false;
77
78    while index < chars.len() {
79        let ch = chars[index];
80        if in_string {
81            output.push(ch);
82            if escaped {
83                escaped = false;
84            } else if ch == '\\' {
85                escaped = true;
86            } else if ch == '"' {
87                in_string = false;
88            }
89            index += 1;
90            continue;
91        }
92
93        if ch == '"' {
94            in_string = true;
95            output.push(ch);
96            index += 1;
97            continue;
98        }
99
100        if ch == ',' {
101            let mut next = index + 1;
102            while next < chars.len() && chars[next].is_whitespace() {
103                next += 1;
104            }
105            if next < chars.len() && matches!(chars[next], '}' | ']') {
106                index += 1;
107                continue;
108            }
109        }
110
111        output.push(ch);
112        index += 1;
113    }
114
115    output
116}
117
118#[cfg(test)]
119mod tests {
120    use serde_json::Value;
121
122    use super::{strip_jsonc, strip_jsonc_comments, strip_trailing_commas};
123
124    #[test]
125    fn strip_jsonc_comments_preserves_comment_like_text_inside_strings() {
126        let source = r#"{
127  "url": "https://example.com//path",
128  "escaped": "\"// not a comment\"",
129  "block": "/* not a comment */"
130}
131// real comment
132"#;
133
134        assert_eq!(
135            strip_jsonc_comments(source),
136            r#"{
137  "url": "https://example.com//path",
138  "escaped": "\"// not a comment\"",
139  "block": "/* not a comment */"
140}
141
142"#
143        );
144    }
145
146    #[test]
147    fn strip_jsonc_comments_preserves_newlines_from_block_comments() {
148        let source = "{\n/* first\nsecond */\n\"enabled\": true\n}\n";
149
150        assert_eq!(
151            strip_jsonc_comments(source),
152            "{\n\n\n\"enabled\": true\n}\n"
153        );
154    }
155
156    #[test]
157    fn strip_trailing_commas_removes_only_commas_before_closing_brackets() {
158        let source = "{\n  \"items\": [1, 2,],\n  \"nested\": {\n    \"ok\": true,\n  },\n}";
159
160        assert_eq!(
161            strip_trailing_commas(source),
162            "{\n  \"items\": [1, 2],\n  \"nested\": {\n    \"ok\": true\n  }\n}"
163        );
164    }
165
166    #[test]
167    fn strip_trailing_commas_preserves_commas_inside_strings() {
168        let source = r#"{"message": "comma, // and /* stay */",}"#;
169
170        assert_eq!(
171            strip_trailing_commas(source),
172            r#"{"message": "comma, // and /* stay */"}"#
173        );
174    }
175
176    #[test]
177    fn strip_jsonc_removes_comments_and_trailing_commas() {
178        let source = r#"{
179  // line comment
180  "search_index": true,
181  "formatter": {
182    "rust": "rustfmt", /* block comment */
183  },
184}"#;
185
186        let value = serde_json::from_str::<Value>(&strip_jsonc(source)).unwrap();
187        assert_eq!(value["search_index"], Value::Bool(true));
188        assert_eq!(value["formatter"]["rust"], Value::String("rustfmt".into()));
189    }
190}