Skip to main content

tanzim_parse/
json.rs

1//! JSON parser (`json` feature).
2//!
3//! **Format:** `json`
4//!
5//! # Behaviour
6//!
7//! - Parses standard JSON with source spans. Objects become maps, arrays become lists, and
8//!   strings/numbers/booleans become the matching scalar values; integers and floats are
9//!   distinguished.
10//! - Every node — root, map values, and list items — carries its span as a [`Location`]
11//!   (line/column); for single-line input the line/column are omitted.
12//! - JSON `null` is rejected with [`Error::UnsupportedNull`], since the config model has no null.
13//!   Non-UTF-8 input fails with [`Error::InvalidUtf8`], and any syntax error becomes
14//!   [`Error::Parse`] with the failing position.
15//! - [`is_format_supported`](crate::Parse::is_format_supported) returns `Some(true)` when
16//!   the bytes parse as JSON, else `Some(false)`.
17//!
18//! # Example
19//!
20//! ```
21//! use tanzim_parse::{Parse, json::Json};
22//! use tanzim_source::SourceBuilder;
23//!
24//! let source = SourceBuilder::new()
25//!     .with_source("file")
26//!     .with_resource("config.json")
27//!     .build()
28//!     .unwrap();
29//! let value = Json::new()
30//!     .parse(&source, br#"{"host":"127.0.0.1"}"#)
31//!     .unwrap();
32//! assert_eq!(
33//!     value.value.as_map().unwrap().get("host").unwrap().value.as_string().unwrap(),
34//!     "127.0.0.1"
35//! );
36//! ```
37
38use crate::span::is_single_line;
39use crate::{Parse, Source};
40use cfg_if::cfg_if;
41use spanned_json_parser::value::Value as JsonValue;
42use spanned_json_parser::{Position, parse};
43use tanzim_value::{Error, LocatedValue, Location, Map, Value};
44
45/// Parser for the `json` format: standard JSON into a source-located value tree.
46///
47/// Objects, arrays, and scalars map to the value tree with a per-node span [`Location`]; JSON
48/// `null` is rejected with [`Error::UnsupportedNull`]. Stateless — construct with [`Json::new`].
49///
50/// ```
51/// use tanzim_parse::{Parse, json::Json};
52/// use tanzim_source::SourceBuilder;
53///
54/// let source = SourceBuilder::new()
55///     .with_source("file")
56///     .with_resource("config.json")
57///     .build()
58///     .unwrap();
59/// let value = Json::new().parse(&source, br#"{"port":8080}"#).unwrap();
60/// let port = value.value.as_map().unwrap().get("port").unwrap();
61/// assert_eq!(port.value.as_int().unwrap(), 8080);
62/// ```
63#[derive(Clone, Copy, Default)]
64pub struct Json;
65
66impl Json {
67    /// Create a JSON parser.
68    pub fn new() -> Self {
69        Self
70    }
71}
72
73impl Parse for Json {
74    fn name(&self) -> &str {
75        "JSON"
76    }
77
78    fn supported_format_list(&self) -> Vec<String> {
79        vec!["json".into()]
80    }
81
82    fn parse(&self, src: &Source, bytes: &[u8]) -> Result<LocatedValue, Error> {
83        #[cfg(any(feature = "tracing", feature = "logging"))]
84        let source = src.source();
85        #[cfg(any(feature = "tracing", feature = "logging"))]
86        let resource = src.resource();
87        cfg_if! {
88            if #[cfg(feature = "tracing")] {
89                tracing::debug!(msg = "Parsing JSON configuration", source = source, resource = resource, bytes = bytes.len());
90            } else if #[cfg(feature = "logging")] {
91                log::debug!("msg=\"Parsing JSON configuration\" source={source} resource={resource} bytes={}", bytes.len());
92            }
93        }
94        let text = match std::str::from_utf8(bytes) {
95            Ok(value) => value,
96            Err(_) => {
97                return Err(Error::InvalidUtf8 {
98                    location: Box::new(Location::in_source(src.clone(), None, None, None)),
99                });
100            }
101        };
102        let single_line = is_single_line(bytes);
103        let parsed = match parse(text) {
104            Ok(value) => value,
105            Err(error) => {
106                return Err(Error::Parse {
107                    text: text.to_string(),
108                    location: Some(Box::new(location_from_position(
109                        src,
110                        single_line,
111                        &error.start,
112                        Some(&error.end),
113                    ))),
114                    message: format!("{:?}", error.kind),
115                });
116            }
117        };
118        let location = location_from_position(src, single_line, &parsed.start, Some(&parsed.end));
119        let result = convert_value(
120            src,
121            text,
122            single_line,
123            parsed.value,
124            &parsed.start,
125            location,
126        );
127        if result.is_ok() {
128            cfg_if! {
129                if #[cfg(feature = "tracing")] {
130                    tracing::trace!(msg = "Parsed JSON configuration", source = source, resource = resource);
131                } else if #[cfg(feature = "logging")] {
132                    log::trace!("msg=\"Parsed JSON configuration\" source={source} resource={resource}");
133                }
134            }
135        }
136        result
137    }
138
139    fn is_format_supported(&self, bytes: &[u8]) -> Option<bool> {
140        match std::str::from_utf8(bytes) {
141            Ok(text) => Some(parse(text).is_ok()),
142            Err(_) => Some(false),
143        }
144    }
145}
146
147/// Serialize a [`Value`] tree into pretty-printed JSON (2-space indent).
148///
149/// Accepts a [`Value`], `&Value`, [`LocatedValue`], or `&LocatedValue`. `source` is
150/// accepted for signature symmetry with [`Parse::parse`] but is unused here.
151///
152/// ```
153/// use tanzim_parse::json::unparse;
154/// use tanzim_source::SourceBuilder;
155/// use tanzim_value::{Map, LocatedValue, Location, Value};
156///
157/// let source = SourceBuilder::new().with_source("file").build().unwrap();
158/// let mut map = Map::new();
159/// map.insert("port".into(), LocatedValue {
160///     value: Value::Int(8080),
161///     location: Location::at("file", "", None, None, None),
162/// });
163/// let text = unparse(&source, Value::Map(map)).unwrap();
164/// assert_eq!(text, "{\n  \"port\": 8080\n}");
165/// ```
166pub fn unparse<V: AsRef<Value>>(
167    _source: &Source,
168    value: V,
169) -> Result<String, Box<dyn std::error::Error + Send + Sync + 'static>> {
170    let mut out = String::new();
171    write_json(&mut out, value.as_ref(), 0)?;
172    Ok(out)
173}
174
175fn write_json(
176    out: &mut String,
177    value: &Value,
178    indent: usize,
179) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
180    match value {
181        Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
182        Value::Int(value) => out.push_str(&value.to_string()),
183        Value::Float(value) => {
184            if !value.is_finite() {
185                return Err(format!("cannot serialize non-finite float {value} as JSON").into());
186            }
187            out.push_str(&format!("{value:?}"));
188        }
189        Value::String(value) => write_json_string(out, value),
190        Value::List(values) => {
191            if values.is_empty() {
192                out.push_str("[]");
193                return Ok(());
194            }
195            out.push_str("[\n");
196            for (index, item) in values.iter().enumerate() {
197                push_indent(out, indent + 1);
198                write_json(out, &item.value, indent + 1)?;
199                if index + 1 < values.len() {
200                    out.push(',');
201                }
202                out.push('\n');
203            }
204            push_indent(out, indent);
205            out.push(']');
206        }
207        Value::Map(map) => {
208            let entries = map.entries();
209            if entries.is_empty() {
210                out.push_str("{}");
211                return Ok(());
212            }
213            out.push_str("{\n");
214            for (index, (key, item)) in entries.iter().enumerate() {
215                push_indent(out, indent + 1);
216                write_json_string(out, key);
217                out.push_str(": ");
218                write_json(out, &item.value, indent + 1)?;
219                if index + 1 < entries.len() {
220                    out.push(',');
221                }
222                out.push('\n');
223            }
224            push_indent(out, indent);
225            out.push('}');
226        }
227    }
228    Ok(())
229}
230
231fn push_indent(out: &mut String, indent: usize) {
232    for _ in 0..indent {
233        out.push_str("  ");
234    }
235}
236
237fn write_json_string(out: &mut String, value: &str) {
238    out.push('"');
239    for ch in value.chars() {
240        match ch {
241            '"' => out.push_str("\\\""),
242            '\\' => out.push_str("\\\\"),
243            '\n' => out.push_str("\\n"),
244            '\r' => out.push_str("\\r"),
245            '\t' => out.push_str("\\t"),
246            control if (control as u32) < 0x20 => {
247                out.push_str(&format!("\\u{:04x}", control as u32));
248            }
249            other => out.push(other),
250        }
251    }
252    out.push('"');
253}
254
255fn convert_value(
256    source: &Source,
257    text: &str,
258    single_line: bool,
259    value: JsonValue,
260    _start: &Position,
261    location: Location,
262) -> Result<LocatedValue, Error> {
263    match value {
264        JsonValue::Null => Err(Error::UnsupportedNull {
265            text: text.to_string(),
266            location: Box::new(location),
267        }),
268        JsonValue::Bool(value) => Ok(LocatedValue {
269            value: Value::Bool(value),
270            location,
271        }),
272        JsonValue::Number(number) => match number {
273            spanned_json_parser::value::Number::PosInt(value) => Ok(LocatedValue {
274                value: Value::Int(value as isize),
275                location,
276            }),
277            spanned_json_parser::value::Number::NegInt(value) => Ok(LocatedValue {
278                value: Value::Int(value as isize),
279                location,
280            }),
281            spanned_json_parser::value::Number::Float(value) => Ok(LocatedValue {
282                value: Value::Float(value),
283                location,
284            }),
285        },
286        JsonValue::String(value) => Ok(LocatedValue {
287            value: Value::String(value),
288            location,
289        }),
290        JsonValue::Array(values) => {
291            let mut list = Vec::new();
292            for item in &values {
293                let item_location =
294                    location_from_position(source, single_line, &item.start, Some(&item.end));
295                let converted = convert_value(
296                    source,
297                    text,
298                    single_line,
299                    item.value.clone(),
300                    &item.start,
301                    item_location,
302                )?;
303                list.push(converted);
304            }
305            Ok(LocatedValue {
306                value: Value::List(list),
307                location,
308            })
309        }
310        JsonValue::Object(values) => {
311            let mut map = Map::new();
312            for (key, item) in values {
313                let item_location =
314                    location_from_position(source, single_line, &item.start, Some(&item.end));
315                let converted = convert_value(
316                    source,
317                    text,
318                    single_line,
319                    item.value.clone(),
320                    &item.start,
321                    item_location,
322                )?;
323                map.insert(key, converted);
324            }
325            Ok(LocatedValue {
326                value: Value::Map(map),
327                location,
328            })
329        }
330    }
331}
332
333fn location_from_position(
334    source: &Source,
335    single_line: bool,
336    start: &Position,
337    end: Option<&Position>,
338) -> Location {
339    if single_line {
340        return Location::in_source(source.clone(), None, None, None);
341    }
342    let mut length = None;
343    if let Some(end) = end
344        && start.line == end.line
345        && end.col >= start.col
346    {
347        length = Some(end.col - start.col + 1);
348    }
349    Location::in_source(source.clone(), Some(start.line), Some(start.col), length)
350}
351
352#[cfg(all(test, feature = "json"))]
353mod tests {
354    use super::*;
355    use tanzim_source::SourceBuilder;
356
357    fn file_source(resource: &str) -> Source {
358        SourceBuilder::new()
359            .with_source("file")
360            .with_resource(resource)
361            .build()
362            .unwrap()
363    }
364
365    fn loc(value: Value) -> LocatedValue {
366        LocatedValue {
367            value,
368            location: Location::at("file", "test", None, None, None),
369        }
370    }
371
372    #[test]
373    fn unparses_complex_json() {
374        let mut nested = Map::new();
375        nested.insert("key".into(), loc(Value::String("va\"lue".into())));
376        let mut map = Map::new();
377        map.insert("name".into(), loc(Value::String("tanzim".into())));
378        map.insert("port".into(), loc(Value::Int(8080)));
379        map.insert("ratio".into(), loc(Value::Float(0.5)));
380        map.insert("debug".into(), loc(Value::Bool(true)));
381        map.insert(
382            "tags".into(),
383            loc(Value::List(vec![
384                loc(Value::String("a".into())),
385                loc(Value::String("b".into())),
386            ])),
387        );
388        map.insert("nested".into(), loc(Value::Map(nested)));
389
390        let text = unparse(&file_source("out.json"), Value::Map(map)).unwrap();
391        assert_eq!(
392            text,
393            "{\n  \"name\": \"tanzim\",\n  \"port\": 8080,\n  \"ratio\": 0.5,\n  \"debug\": true,\n  \"tags\": [\n    \"a\",\n    \"b\"\n  ],\n  \"nested\": {\n    \"key\": \"va\\\"lue\"\n  }\n}"
394        );
395    }
396
397    #[test]
398    fn parses_json_object() {
399        let parsed = Json::new()
400            .parse(&file_source("config.json"), br#"{"hello":"world"}"#)
401            .unwrap();
402        assert_eq!(
403            parsed
404                .value
405                .as_map()
406                .unwrap()
407                .get("hello")
408                .unwrap()
409                .value
410                .as_string()
411                .unwrap(),
412            "world"
413        );
414    }
415
416    #[test]
417    fn detects_json_format() {
418        let parser = Json::new();
419        assert_eq!(parser.is_format_supported(br#"{"a":1}"#), Some(true));
420        assert_eq!(parser.is_format_supported(b"not json"), Some(false));
421    }
422
423    #[test]
424    fn single_line_json_omits_position() {
425        let root = Json::new()
426            .parse(&file_source("a.json"), br#"{"a":1}"#)
427            .unwrap();
428        let map = root.value.as_map().unwrap();
429        let entry = map.get("a").unwrap();
430        assert_eq!(entry.location.line, None);
431        assert_eq!(entry.location.column, None);
432    }
433
434    #[test]
435    fn rejects_null() {
436        let error = Json::new()
437            .parse(&file_source("a.json"), b"{\n  \"a\": null\n}")
438            .unwrap_err();
439        assert!(matches!(error, Error::UnsupportedNull { .. }));
440        let message = format!("{error:#}");
441        assert!(message.contains('^'));
442        assert!(message.contains("null"));
443    }
444
445    #[test]
446    fn syntax_error_has_location() {
447        let error = Json::new()
448            .parse(&file_source("a.json"), b"{\n  \"a\":\n}\n")
449            .unwrap_err();
450        if let Error::Parse { ref location, .. } = error {
451            let location = location.as_ref().expect("syntax error location");
452            assert!(location.line.is_some());
453            assert!(location.column.is_some());
454        } else {
455            panic!("expected parse error");
456        }
457        let message = format!("{error:#}");
458        assert!(message.contains('^'));
459    }
460}