Skip to main content

ezu_style/
jsonc.rs

1//! Comments in a style document.
2//!
3//! A style is a long, mostly declarative file, and the interesting part
4//! of a node is rarely what it does — it is why the author chose that
5//! width, that colour, that zoom cutoff. JSON has nowhere to put that,
6//! so styles carry `//` line comments and `/* … */` block comments and
7//! this module removes them before the JSON parser sees the text.
8//!
9//! Removal means *blanking*: each comment byte becomes a space, and
10//! newlines stay where they are. Nothing moves, so the line and column
11//! in a parse error still point at the author's file rather than at a
12//! shortened copy of it. Comments are stripped on the way in and the
13//! file is never rewritten, so nothing can drop them.
14//!
15//! Only comments are added to JSON here — trailing commas and unquoted
16//! keys stay errors, so a style remains close enough to JSON that
17//! ordinary tooling can still read it once the comments are gone.
18
19use std::borrow::Cow;
20
21/// Blank every comment in `src`, leaving all other bytes at their
22/// original offsets. Returns the input untouched when it holds no
23/// comments.
24///
25/// `//` and `/*` inside a JSON string are text, not comments — a URL
26/// like `"https://example.com/{z}/{x}/{y}"` comes through whole.
27pub fn blank_comments(src: &str) -> Result<Cow<'_, str>, CommentError> {
28    let bytes = src.as_bytes();
29    let mut spans: Vec<(usize, usize)> = Vec::new();
30    let mut i = 0;
31    while i < bytes.len() {
32        match bytes[i] {
33            b'"' => i = skip_string(bytes, i),
34            b'/' if bytes.get(i + 1) == Some(&b'/') => {
35                let start = i;
36                while i < bytes.len() && bytes[i] != b'\n' {
37                    i += 1;
38                }
39                spans.push((start, i));
40            }
41            b'/' if bytes.get(i + 1) == Some(&b'*') => {
42                let start = i;
43                i += 2;
44                loop {
45                    if i + 1 >= bytes.len() {
46                        return Err(CommentError {
47                            line: line_of(bytes, start),
48                        });
49                    }
50                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
51                        i += 2;
52                        break;
53                    }
54                    i += 1;
55                }
56                spans.push((start, i));
57            }
58            _ => i += 1,
59        }
60    }
61    if spans.is_empty() {
62        return Ok(Cow::Borrowed(src));
63    }
64    let mut out = src.as_bytes().to_vec();
65    for (start, end) in spans {
66        for b in &mut out[start..end] {
67            // Newlines and carriage returns survive so every following
68            // line keeps its number.
69            if *b != b'\n' && *b != b'\r' {
70                *b = b' ';
71            }
72        }
73    }
74    // Only ASCII bytes were replaced, and only with ASCII, so whatever
75    // was valid UTF-8 still is.
76    Ok(Cow::Owned(
77        String::from_utf8(out).expect("blanking preserves UTF-8"),
78    ))
79}
80
81/// A block comment that never closed. Reported on its own rather than
82/// left to the JSON parser, which would blame whatever the runaway
83/// comment swallowed.
84#[derive(Debug, Clone, Copy, thiserror::Error)]
85#[error("unterminated block comment opened on line {line}")]
86pub struct CommentError {
87    pub line: usize,
88}
89
90/// Index just past the string literal starting at `open` (the opening
91/// quote). An unterminated string is left to the JSON parser, which
92/// already words that error well.
93fn skip_string(bytes: &[u8], open: usize) -> usize {
94    let mut i = open + 1;
95    while i < bytes.len() {
96        match bytes[i] {
97            // Skip whatever follows a backslash, so `\"` and `\\` do not
98            // look like the end of the string.
99            b'\\' => i += 2,
100            b'"' => return i + 1,
101            _ => i += 1,
102        }
103    }
104    i
105}
106
107fn line_of(bytes: &[u8], offset: usize) -> usize {
108    1 + bytes[..offset].iter().filter(|&&b| b == b'\n').count()
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// Blank `src`, checking the invariants that hold for every input:
116    /// nothing moves, and the JSON that comes out is the JSON the author
117    /// meant. Returns the blanked text for the caller to inspect.
118    fn blank_to(src: &str, want_json: &str) -> String {
119        let out = blank_comments(src).expect("no comment error").into_owned();
120        assert_eq!(src.len(), out.len(), "byte length changed");
121        let newlines = |s: &str| s.bytes().filter(|&b| b == b'\n').count();
122        assert_eq!(newlines(src), newlines(&out), "newline count changed");
123        let got: serde_json::Value = serde_json::from_str(&out).expect("blanked text parses");
124        let want: serde_json::Value = serde_json::from_str(want_json).expect("expectation parses");
125        assert_eq!(got, want);
126        out
127    }
128
129    #[test]
130    fn line_comments_go_but_their_lines_stay() {
131        let out = blank_to("{\n  // the base coat\n  \"a\": 1\n}", r#"{"a": 1}"#);
132        assert!(!out.contains("base coat"));
133        assert_eq!(out.lines().nth(1).unwrap().trim(), "");
134    }
135
136    #[test]
137    fn block_comments_go_inline_and_across_lines() {
138        blank_to("[1, /* two */ 3]", "[1, 3]");
139        let out = blank_to(
140            "{\n/* why\n   this\n   width */\n\"a\": 1\n}",
141            r#"{"a": 1}"#,
142        );
143        assert!(!out.contains("width"));
144    }
145
146    #[test]
147    fn comment_openers_inside_strings_are_text() {
148        // The case that matters most in practice: tile URL templates.
149        let src = r#"{"url": "https://example.com/{z}/{x}/{y}.pbf", "b": "/* not a comment */"}"#;
150        assert_eq!(blank_comments(src).unwrap(), src);
151    }
152
153    #[test]
154    fn escapes_do_not_end_a_string_early() {
155        // The `\"` must not read as the closing quote, or the `//` after
156        // it would look like a comment.
157        let src = r#"{"a": "say \"hi\" // here", "b": 1}"#;
158        assert_eq!(blank_comments(src).unwrap(), src);
159        // An escaped backslash does end the string, so what follows is a
160        // comment.
161        let out = blank_to("{\"a\": \"back\\\\\" // gone\n}", r#"{"a": "back\\"}"#);
162        assert!(!out.contains("gone"));
163    }
164
165    #[test]
166    fn a_comment_may_end_the_file() {
167        blank_to("{} // done", "{}");
168        blank_to("{} /* done */", "{}");
169    }
170
171    #[test]
172    fn non_ascii_comment_text_survives_blanking() {
173        let out = blank_to("{\n  // 幅は 1.2 px\n  \"a\": 1\n}", r#"{"a": 1}"#);
174        assert!(!out.contains('幅'));
175    }
176
177    #[test]
178    fn crlf_line_endings_are_preserved() {
179        let out = blank_to("{\r\n  // note\r\n  \"a\": 1\r\n}", r#"{"a": 1}"#);
180        assert_eq!(out.matches("\r\n").count(), 3);
181    }
182
183    #[test]
184    fn a_document_without_comments_is_not_copied() {
185        let src = r#"{"a": 1}"#;
186        assert!(matches!(blank_comments(src).unwrap(), Cow::Borrowed(_)));
187    }
188
189    #[test]
190    fn unterminated_block_comment_names_its_line() {
191        let err = blank_comments("{\n\"a\": 1\n/* and then nothing").unwrap_err();
192        assert_eq!(err.line, 3);
193    }
194}