Skip to main content

json_colorizer/
lib.rs

1//! # json-colorizer
2//!
3//! A fast, lightweight JSON formatter, pretty-printer, colorizer, and query library for Rust.
4//!
5//! Use it to pretty-print JSON with syntax highlighting in the terminal,
6//! compact-print JSON, or query nested values with dot-path notation.
7//!
8//! ## Quick Start
9//!
10//! ```rust
11//! use json_colorizer::{format_json, format_json_compact, query, FormatOptions};
12//! use serde_json::json;
13//!
14//! let value = json!({"name": "Alice", "scores": [95, 87, 100]});
15//!
16//! // Pretty-print with colors
17//! let output = format_json(&value, &FormatOptions::default());
18//! println!("{}", output);
19//!
20//! // Compact output
21//! let compact = format_json_compact(&value);
22//! println!("{}", compact);
23//!
24//! // Query a nested value
25//! let score = query(&value, ".scores[0]").unwrap();
26//! assert_eq!(score, &json!(95));
27//! ```
28
29use colored::Colorize;
30use serde_json::Value;
31
32// ═══════════════════════════════════════════════════════════════
33//  Public types
34// ═══════════════════════════════════════════════════════════════
35
36/// Options for controlling JSON output formatting.
37#[derive(Debug, Clone)]
38pub struct FormatOptions {
39    /// Number of spaces per indentation level (default: 2).
40    pub indent: usize,
41    /// Whether to colorize the output (default: true).
42    pub color: bool,
43}
44
45impl Default for FormatOptions {
46    fn default() -> Self {
47        Self {
48            indent: 2,
49            color: true,
50        }
51    }
52}
53
54/// Error returned when a query path is invalid or does not match the JSON.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum QueryError {
57    /// A key was not found in an object.
58    KeyNotFound(String),
59    /// An index was out of bounds in an array.
60    IndexOutOfBounds(usize),
61    /// The query string could not be parsed.
62    InvalidQuery(String),
63}
64
65impl std::fmt::Display for QueryError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            QueryError::KeyNotFound(key) => write!(f, "key '{}' not found", key),
69            QueryError::IndexOutOfBounds(idx) => write!(f, "index [{}] out of bounds", idx),
70            QueryError::InvalidQuery(msg) => write!(f, "invalid query: {}", msg),
71        }
72    }
73}
74
75impl std::error::Error for QueryError {}
76
77// ═══════════════════════════════════════════════════════════════
78//  Core public API
79// ═══════════════════════════════════════════════════════════════
80
81/// Format a JSON value as a pretty-printed string.
82///
83/// When `opts.color` is `true`, output includes ANSI color codes suitable
84/// for terminal display (**cyan** keys, **green** strings, **yellow** numbers,
85/// **magenta** booleans, **red** null).
86///
87/// ```rust
88/// use json_colorizer::{format_json, FormatOptions};
89/// use serde_json::json;
90///
91/// let val = json!({"greeting": "hello"});
92/// let out = format_json(&val, &FormatOptions { indent: 4, color: false });
93/// assert!(out.contains("greeting"));
94/// ```
95pub fn format_json(value: &Value, opts: &FormatOptions) -> String {
96    let mut buf = String::new();
97    if opts.color {
98        write_colored(&mut buf, value, 0, false, opts.indent);
99    } else {
100        write_plain(&mut buf, value, 0, false, opts.indent);
101    }
102    buf
103}
104
105/// Format a JSON value as a compact (single-line, no whitespace) string.
106///
107/// ```rust
108/// use json_colorizer::format_json_compact;
109/// use serde_json::json;
110///
111/// let val = json!({"a": 1});
112/// assert_eq!(format_json_compact(&val), r#"{"a":1}"#);
113/// ```
114pub fn format_json_compact(value: &Value) -> String {
115    serde_json::to_string(value).unwrap_or_default()
116}
117
118/// Query a nested value inside `root` using a dot-path string.
119///
120/// Supported syntax:
121/// - `.key` — object key access
122/// - `[n]` — array index access
123/// - Chaining: `.data.users[0].name`
124///
125/// ```rust
126/// use json_colorizer::query;
127/// use serde_json::json;
128///
129/// let data = json!({"users": [{"name": "Alice"}, {"name": "Bob"}]});
130/// let name = query(&data, ".users[1].name").unwrap();
131/// assert_eq!(name, &json!("Bob"));
132/// ```
133pub fn query<'a>(root: &'a Value, path: &str) -> Result<&'a Value, QueryError> {
134    let segments = parse_query(path)?;
135    let mut current = root;
136
137    for seg in &segments {
138        match seg {
139            Segment::Key(key) => {
140                current = current
141                    .get(key.as_str())
142                    .ok_or_else(|| QueryError::KeyNotFound(key.clone()))?;
143            }
144            Segment::Index(idx) => {
145                current = current
146                    .get(*idx)
147                    .ok_or(QueryError::IndexOutOfBounds(*idx))?;
148            }
149        }
150    }
151    Ok(current)
152}
153
154/// Parse a raw JSON string and return the formatted (colorized) output.
155///
156/// This is a convenience function combining [`serde_json::from_str`] with
157/// [`format_json`].
158pub fn parse_and_format(json_str: &str, opts: &FormatOptions) -> Result<String, serde_json::Error> {
159    let value: Value = serde_json::from_str(json_str)?;
160    Ok(format_json(&value, opts))
161}
162
163// ═══════════════════════════════════════════════════════════════
164//  Query parser internals
165// ═══════════════════════════════════════════════════════════════
166
167#[derive(Debug)]
168enum Segment {
169    Key(String),
170    Index(usize),
171}
172
173fn parse_query(query: &str) -> Result<Vec<Segment>, QueryError> {
174    let mut segments = Vec::new();
175    let q = query.strip_prefix('.').unwrap_or(query);
176
177    if q.is_empty() {
178        return Ok(segments); // root query
179    }
180
181    let mut chars = q.chars().peekable();
182    let mut buf = String::new();
183
184    while let Some(&ch) = chars.peek() {
185        match ch {
186            '.' => {
187                if !buf.is_empty() {
188                    segments.push(Segment::Key(buf.clone()));
189                    buf.clear();
190                }
191                chars.next();
192            }
193            '[' => {
194                if !buf.is_empty() {
195                    segments.push(Segment::Key(buf.clone()));
196                    buf.clear();
197                }
198                chars.next(); // consume '['
199                let mut idx_buf = String::new();
200                let mut found_bracket = false;
201                while let Some(&c) = chars.peek() {
202                    if c == ']' {
203                        chars.next();
204                        found_bracket = true;
205                        break;
206                    }
207                    idx_buf.push(c);
208                    chars.next();
209                }
210                if !found_bracket {
211                    return Err(QueryError::InvalidQuery(
212                        "unclosed bracket".to_string(),
213                    ));
214                }
215                if let Ok(idx) = idx_buf.parse::<usize>() {
216                    segments.push(Segment::Index(idx));
217                } else {
218                    // bracket notation for keys: ["key"] or ['key']
219                    let key = idx_buf.trim_matches('"').trim_matches('\'').to_string();
220                    segments.push(Segment::Key(key));
221                }
222            }
223            _ => {
224                buf.push(ch);
225                chars.next();
226            }
227        }
228    }
229    if !buf.is_empty() {
230        segments.push(Segment::Key(buf));
231    }
232
233    Ok(segments)
234}
235
236// ═══════════════════════════════════════════════════════════════
237//  Colorized writer
238// ═══════════════════════════════════════════════════════════════
239
240fn pad(buf: &mut String, indent_size: usize, level: usize) {
241    for _ in 0..(indent_size * level) {
242        buf.push(' ');
243    }
244}
245
246fn write_colored(buf: &mut String, value: &Value, level: usize, trailing_comma: bool, indent_size: usize) {
247    let comma = if trailing_comma { "," } else { "" };
248
249    match value {
250        Value::Null => {
251            buf.push_str(&format!("{}{}", "null".red().dimmed(), comma));
252        }
253        Value::Bool(b) => {
254            buf.push_str(&format!("{}{}", b.to_string().magenta().bold(), comma));
255        }
256        Value::Number(n) => {
257            buf.push_str(&format!("{}{}", n.to_string().yellow(), comma));
258        }
259        Value::String(s) => {
260            buf.push_str(&format!(
261                "{}{}",
262                format!("\"{}\"", escape_json_string(s)).green(),
263                comma
264            ));
265        }
266        Value::Array(arr) => {
267            if arr.is_empty() {
268                buf.push_str(&format!("[]{}", comma));
269                return;
270            }
271            buf.push_str("[\n");
272            for (i, item) in arr.iter().enumerate() {
273                pad(buf, indent_size, level + 1);
274                let has_comma = i < arr.len() - 1;
275                write_colored(buf, item, level + 1, has_comma, indent_size);
276                buf.push('\n');
277            }
278            pad(buf, indent_size, level);
279            buf.push_str(&format!("]{}", comma));
280        }
281        Value::Object(map) => {
282            if map.is_empty() {
283                buf.push_str(&format!("{{}}{}", comma));
284                return;
285            }
286            buf.push_str("{\n");
287            let len = map.len();
288            for (i, (key, val)) in map.iter().enumerate() {
289                let has_comma = i < len - 1;
290                pad(buf, indent_size, level + 1);
291                buf.push_str(&format!("{}: ", format!("\"{}\"", key).cyan().bold()));
292                write_colored(buf, val, level + 1, has_comma, indent_size);
293                buf.push('\n');
294            }
295            pad(buf, indent_size, level);
296            buf.push_str(&format!("}}{}", comma));
297        }
298    }
299}
300
301// ═══════════════════════════════════════════════════════════════
302//  Plain (no-color) writer
303// ═══════════════════════════════════════════════════════════════
304
305fn write_plain(buf: &mut String, value: &Value, level: usize, trailing_comma: bool, indent_size: usize) {
306    let comma = if trailing_comma { "," } else { "" };
307
308    match value {
309        Value::Null => {
310            buf.push_str(&format!("null{}", comma));
311        }
312        Value::Bool(b) => {
313            buf.push_str(&format!("{}{}", b, comma));
314        }
315        Value::Number(n) => {
316            buf.push_str(&format!("{}{}", n, comma));
317        }
318        Value::String(s) => {
319            buf.push_str(&format!("\"{}\"{}",  escape_json_string(s), comma));
320        }
321        Value::Array(arr) => {
322            if arr.is_empty() {
323                buf.push_str(&format!("[]{}", comma));
324                return;
325            }
326            buf.push_str("[\n");
327            for (i, item) in arr.iter().enumerate() {
328                pad(buf, indent_size, level + 1);
329                let has_comma = i < arr.len() - 1;
330                write_plain(buf, item, level + 1, has_comma, indent_size);
331                buf.push('\n');
332            }
333            pad(buf, indent_size, level);
334            buf.push_str(&format!("]{}", comma));
335        }
336        Value::Object(map) => {
337            if map.is_empty() {
338                buf.push_str(&format!("{{}}{}", comma));
339                return;
340            }
341            buf.push_str("{\n");
342            let len = map.len();
343            for (i, (key, val)) in map.iter().enumerate() {
344                let has_comma = i < len - 1;
345                pad(buf, indent_size, level + 1);
346                buf.push_str(&format!("\"{}\": ", key));
347                write_plain(buf, val, level + 1, has_comma, indent_size);
348                buf.push('\n');
349            }
350            pad(buf, indent_size, level);
351            buf.push_str(&format!("}}{}", comma));
352        }
353    }
354}
355
356// ═══════════════════════════════════════════════════════════════
357//  Shared helpers
358// ═══════════════════════════════════════════════════════════════
359
360fn escape_json_string(s: &str) -> String {
361    let mut out = String::with_capacity(s.len());
362    for ch in s.chars() {
363        match ch {
364            '"' => out.push_str("\\\""),
365            '\\' => out.push_str("\\\\"),
366            '\n' => out.push_str("\\n"),
367            '\r' => out.push_str("\\r"),
368            '\t' => out.push_str("\\t"),
369            c if c.is_control() => {
370                out.push_str(&format!("\\u{:04x}", c as u32));
371            }
372            c => out.push(c),
373        }
374    }
375    out
376}
377
378// ═══════════════════════════════════════════════════════════════
379//  Tests
380// ═══════════════════════════════════════════════════════════════
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use serde_json::json;
386
387    #[test]
388    fn test_compact_output() {
389        let val = json!({"a": 1, "b": [2, 3]});
390        let out = format_json_compact(&val);
391        assert!(out.contains("\"a\":1"));
392        assert!(!out.contains('\n'));
393    }
394
395    #[test]
396    fn test_pretty_plain_output() {
397        let val = json!({"name": "Alice"});
398        let opts = FormatOptions { indent: 2, color: false };
399        let out = format_json(&val, &opts);
400        assert!(out.contains("\"name\""));
401        assert!(out.contains("\"Alice\""));
402        assert!(out.contains('\n'));
403    }
404
405    #[test]
406    fn test_query_simple_key() {
407        let val = json!({"greeting": "hello"});
408        let result = query(&val, ".greeting").unwrap();
409        assert_eq!(result, &json!("hello"));
410    }
411
412    #[test]
413    fn test_query_nested() {
414        let val = json!({"a": {"b": {"c": 42}}});
415        let result = query(&val, ".a.b.c").unwrap();
416        assert_eq!(result, &json!(42));
417    }
418
419    #[test]
420    fn test_query_array_index() {
421        let val = json!({"items": [10, 20, 30]});
422        let result = query(&val, ".items[1]").unwrap();
423        assert_eq!(result, &json!(20));
424    }
425
426    #[test]
427    fn test_query_mixed() {
428        let val = json!({"users": [{"name": "Alice"}, {"name": "Bob"}]});
429        let result = query(&val, ".users[1].name").unwrap();
430        assert_eq!(result, &json!("Bob"));
431    }
432
433    #[test]
434    fn test_query_root() {
435        let val = json!({"a": 1});
436        let result = query(&val, ".").unwrap();
437        assert_eq!(result, &val);
438    }
439
440    #[test]
441    fn test_query_key_not_found() {
442        let val = json!({"a": 1});
443        let err = query(&val, ".b").unwrap_err();
444        assert!(matches!(err, QueryError::KeyNotFound(_)));
445    }
446
447    #[test]
448    fn test_query_index_out_of_bounds() {
449        let val = json!([1, 2]);
450        let err = query(&val, "[5]").unwrap_err();
451        assert!(matches!(err, QueryError::IndexOutOfBounds(5)));
452    }
453
454    #[test]
455    fn test_parse_and_format() {
456        let json_str = r#"{"key": "value"}"#;
457        let opts = FormatOptions { indent: 2, color: false };
458        let result = parse_and_format(json_str, &opts).unwrap();
459        assert!(result.contains("\"key\""));
460    }
461
462    #[test]
463    fn test_parse_and_format_invalid() {
464        let opts = FormatOptions::default();
465        assert!(parse_and_format("not json", &opts).is_err());
466    }
467
468    #[test]
469    fn test_empty_object_and_array() {
470        let opts = FormatOptions { indent: 2, color: false };
471        assert_eq!(format_json(&json!({}), &opts), "{}");
472        assert_eq!(format_json(&json!([]), &opts), "[]");
473    }
474
475    #[test]
476    fn test_escape_special_characters() {
477        let val = json!({"msg": "line1\nline2\ttab"});
478        let opts = FormatOptions { indent: 2, color: false };
479        let out = format_json(&val, &opts);
480        assert!(out.contains("\\n"));
481        assert!(out.contains("\\t"));
482    }
483}