hocon/adapters/jsonc.rs
1//! JSON with comments and trailing commas — the dialect VS Code and TypeScript
2//! use for their config files — as HOCON config.
3//!
4//! Plain JSON needs no adapter: HOCON is a JSON superset, so `hocon::parse`
5//! already accepts a `.json` file. This exists for the two things HOCON does
6//! not accept, block comments and trailing commas.
7//!
8//! Comments are **token-separating** (spec F3.2): a comment is replaced by
9//! whitespace, never deleted, so it can never splice its neighbors into one
10//! token. `{"a": 1/*x*/2}` is a syntax error rather than the number `12`.
11
12use indexmap::IndexMap;
13use serde_json::Value as JsonValue;
14
15use super::{config_from_object, AdapterError};
16use crate::value::{HoconValue, ScalarValue};
17use crate::Config;
18
19/// Read JSONC text.
20pub fn parse(input: &str, origin: Option<&str>) -> Result<Config, AdapterError> {
21 let cleaned = strip_trailing_commas(&strip_comments(super::strip_bom(input))?);
22 let doc: JsonValue =
23 serde_json::from_str(&cleaned).map_err(|e| AdapterError::new(format!("jsonc: {e}")))?;
24 if !doc.is_object() {
25 return Err(AdapterError::new(
26 "jsonc: document root must be an object (spec F0.3)",
27 ));
28 }
29 Ok(config_from_object(convert(&doc, "")?, origin))
30}
31
32/// Read a JSONC file, using its path as the origin description.
33pub fn parse_file(path: impl AsRef<std::path::Path>) -> Result<Config, AdapterError> {
34 let path = path.as_ref();
35 let text = std::fs::read_to_string(path)
36 .map_err(|e| AdapterError::new(format!("jsonc: {}: {e}", path.display())))?;
37 parse(&text, Some(&path.display().to_string()))
38}
39
40fn convert(v: &JsonValue, at: &str) -> Result<HoconValue, AdapterError> {
41 match v {
42 JsonValue::Object(m) => {
43 let mut out: IndexMap<String, HoconValue> = IndexMap::new();
44 for (k, e) in m {
45 let path = if at.is_empty() {
46 k.clone()
47 } else {
48 format!("{at}.{k}")
49 };
50 out.insert(k.clone(), convert(e, &path)?);
51 }
52 Ok(HoconValue::Object(out))
53 }
54 JsonValue::Array(items) => {
55 let mut out = Vec::with_capacity(items.len());
56 for (i, e) in items.iter().enumerate() {
57 out.push(convert(e, &format!("{at}[{i}]"))?);
58 }
59 Ok(HoconValue::Array(out))
60 }
61 JsonValue::String(s) => Ok(HoconValue::Scalar(ScalarValue::string(s.clone()))),
62 JsonValue::Bool(b) => Ok(HoconValue::Scalar(ScalarValue::boolean(*b))),
63 JsonValue::Null => Ok(HoconValue::Scalar(ScalarValue::null())),
64 // serde_json keeps the integer/float distinction, so F0.5's rule holds
65 // without re-reading the source text.
66 JsonValue::Number(n) => {
67 if let Some(i) = n.as_i64() {
68 Ok(HoconValue::Scalar(ScalarValue::number(i.to_string())))
69 } else if let Some(f) = n.as_f64() {
70 if f.is_nan() || f.is_infinite() {
71 return Err(AdapterError::new(format!(
72 "jsonc: at {at}: {f} is not representable in HOCON (spec F0.6)"
73 )));
74 }
75 Ok(HoconValue::Scalar(ScalarValue::number(n.to_string())))
76 } else {
77 Err(AdapterError::new(format!(
78 "jsonc: at {at}: {n} does not fit in i64 (spec F0.5)"
79 )))
80 }
81 }
82 }
83}
84
85/// Remove `//` line comments and block comments, leaving string literals
86/// alone.
87///
88/// Two invariants, both load-bearing:
89///
90/// 1. **A comment becomes whitespace, never the empty string.** Erasing it
91/// outright would splice its neighbours into one token (`1/*x*/2` → `12`,
92/// which is valid JSON), so each block comment leaves at least one space
93/// behind (spec F3.2). A `//` comment keeps its terminator, which already
94/// separates tokens.
95/// 2. **Line structure is preserved exactly.** Every line terminator inside a
96/// removed span is re-emitted verbatim and in order, so a `\r\n` survives
97/// as a pair and a lone `\r` survives as itself. The stripped text
98/// therefore has the same lines as the source and a decoder's reported
99/// line numbers point at the right source line.
100///
101/// Column offsets within a line are deliberately *not* preserved — a comment
102/// body collapses to a single space — so only line numbers are meaningful,
103/// which is the same trade `//` stripping has always made.
104fn strip_comments(src: &str) -> Result<String, AdapterError> {
105 let b: Vec<char> = src.chars().collect();
106 let mut out = String::with_capacity(src.len());
107 let mut i = 0;
108 while i < b.len() {
109 match b[i] {
110 '"' => {
111 let end = end_of_string(&b, i)?;
112 out.extend(&b[i..end]);
113 i = end;
114 }
115 '/' if i + 1 < b.len() && b[i + 1] == '/' => {
116 // A lone CR ends the comment too: a classic-Mac or otherwise
117 // CR-delimited file would otherwise have the rest of the
118 // document swallowed by the first `//` (spec F3.2). The
119 // terminator itself is left in place, so it still separates
120 // tokens.
121 while i < b.len() && b[i] != '\n' && b[i] != '\r' {
122 i += 1;
123 }
124 }
125 '/' if i + 1 < b.len() && b[i + 1] == '*' => {
126 let mut j = i + 2;
127 loop {
128 if j + 1 >= b.len() {
129 return Err(AdapterError::new("jsonc: unterminated block comment"));
130 }
131 if b[j] == '*' && b[j + 1] == '/' {
132 break;
133 }
134 // Re-emit terminators verbatim so a `\r\n` stays a pair
135 // and a lone `\r` is not silently dropped. Keeping only
136 // `\n` collapsed CRLF and lost classic-Mac line breaks
137 // outright, which contradicted the invariant above.
138 if b[j] == '\n' || b[j] == '\r' {
139 out.push(b[j]);
140 }
141 j += 1;
142 }
143 out.push(' ');
144 i = j + 2;
145 }
146 c => {
147 out.push(c);
148 i += 1;
149 }
150 }
151 }
152 Ok(out)
153}
154
155fn end_of_string(b: &[char], i: usize) -> Result<usize, AdapterError> {
156 let mut j = i + 1;
157 while j < b.len() {
158 match b[j] {
159 '\\' => j += 2,
160 '"' => return Ok(j + 1),
161 _ => j += 1,
162 }
163 }
164 Err(AdapterError::new("jsonc: unterminated string literal"))
165}
166
167/// Drop a comma whose next meaningful character closes its object or array.
168fn strip_trailing_commas(src: &str) -> String {
169 let b: Vec<char> = src.chars().collect();
170 let mut out = String::with_capacity(src.len());
171 let mut i = 0;
172 while i < b.len() {
173 if b[i] == '"' {
174 match end_of_string(&b, i) {
175 Ok(end) => {
176 out.extend(&b[i..end]);
177 i = end;
178 continue;
179 }
180 Err(_) => {
181 out.extend(&b[i..]);
182 return out;
183 }
184 }
185 }
186 if b[i] == ',' {
187 let mut j = i + 1;
188 while j < b.len() && b[j].is_whitespace() {
189 j += 1;
190 }
191 if j < b.len() && (b[j] == '}' || b[j] == ']') {
192 i += 1;
193 continue;
194 }
195 }
196 out.push(b[i]);
197 i += 1;
198 }
199 out
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 /// The sequence of line terminators, which is what "same line structure"
207 /// means: a `\r\n` collapsed to `\n`, or a lone `\r` dropped, both show up
208 /// here as a difference.
209 fn terminators(s: &str) -> String {
210 s.chars().filter(|c| *c == '\r' || *c == '\n').collect()
211 }
212
213 /// Both strip passes must preserve line structure exactly. Only the block
214 /// comment branch was ever wrong, but pinning the whole pipeline is what
215 /// makes that a property of the module rather than of one branch.
216 #[test]
217 fn stripping_preserves_line_structure() {
218 for src in [
219 // block comments, each line ending
220 "{\n /* a\n b */\n \"k\": 1\n}",
221 "{\r\n /* a\r\n b */\r\n \"k\": 1\r\n}",
222 "{\r /* a\r b */\r \"k\": 1\r}",
223 // a lone CR inside a comment on one line
224 "{\"k\": /*a\rb*/ 1}",
225 // mixed, and a CR immediately before a CRLF
226 "{\"k\": /*a\r\r\nb*/ 1}",
227 // line comments, whose terminator must survive as itself
228 "{\n // a\n \"k\": 1\n}",
229 "{\r\n // a\r\n \"k\": 1\r\n}",
230 "{\r // a\r \"k\": 1\r}",
231 // trailing commas removed next to line breaks
232 "{\r\n \"k\": 1,\r\n}",
233 "[\r 1,\r]",
234 // comment markers inside strings are data, not comments
235 "{\"k\": \"a/*b*/c\",\r\n \"j\": 2}",
236 ] {
237 let stripped = strip_trailing_commas(&strip_comments(src).expect(src));
238 assert_eq!(
239 terminators(&stripped),
240 terminators(src),
241 "line structure changed for {src:?} -> {stripped:?}"
242 );
243 }
244 }
245
246 /// The token-separation invariant, at the same level as the one above.
247 #[test]
248 fn a_comment_never_joins_its_neighbours() {
249 for src in ["1/*x*/2", "1/*\r*/2", "1/*\r\n*/2", "tr/*x*/ue"] {
250 let stripped = strip_comments(src).expect(src);
251 assert!(
252 !stripped.contains("12") && !stripped.contains("true"),
253 "{src:?} spliced into {stripped:?}"
254 );
255 }
256 }
257}