Skip to main content

rich_ext/data/
mod.rs

1//! Structured data: a format-neutral document tree, parsers that fill it, and
2//! views that render it.
3//!
4//! Every format parses into the same [`Node`] tree — JSON, INI and dotenv with
5//! the `data` feature; YAML, TOML and XML with their own features — so one set
6//! of views covers them all:
7//!
8//! - [`Explorer`]: a width-aware tree (or table) with folding and limits
9//! - [`TableView`]: records as a table ([`table`], [`print_table`])
10//! - [`FlatView`], [`flatten`] / [`unflatten`]: `path = value` leaves
11//! - [`SearchResults`], [`search`]: key / path / value search with highlights
12//! - [`Selectors`]: pluggable selection expressions (JSONPath with `jsonpath`)
13//! - [`DiffView`], [`diff`]: leaf-level differences
14//! - [`Redaction`]: masking secrets before display
15//! - [`ConfigFileView`]: INI and dotenv files as `section | key | value`
16//!
17//! Serde values join in through [`from_serialize`] and the zero-boilerplate
18//! helpers [`print_json`], [`print_table`] and [`print_tree`].
19//!
20//! ```
21//! use rich::Console;
22//! use rich_ext::data::{parse, Explorer, Format};
23//!
24//! let node = parse(Format::Json, r#"{"name": "demo", "ports": [80, 443]}"#).unwrap();
25//! let console = Console::builder().width(40).build();
26//! let out = console.render_export(&Explorer::new(&node));
27//! assert_eq!(
28//!     out,
29//!     "{…} 2 keys\n├── name: \"demo\"\n└── ports\n    ├── [0]: 80\n    └── [1]: 443\n"
30//! );
31//! ```
32//!
33//! Styles resolve through the console theme. Values reuse core's JSON names
34//! (`json.key`, `json.str`, `json.number`, `json.bool_true`, `json.bool_false`,
35//! `json.null`) so a JSON theme applies here too; this module's own names and
36//! their fallbacks are listed in [`DATA_STYLES`].
37
38use std::borrow::Cow;
39use std::fmt;
40
41use rich::{Console, Style};
42
43use crate::event::theme_style;
44
45mod config;
46mod diff;
47mod dotenv;
48mod explorer;
49mod flatten;
50mod helpers;
51mod ini;
52mod json;
53mod redact;
54mod search;
55pub mod select;
56mod ser;
57mod table;
58#[cfg(feature = "jsonpath")]
59pub mod transform;
60
61#[cfg(feature = "toml")]
62mod toml_doc;
63#[cfg(feature = "xml")]
64mod xml;
65#[cfg(feature = "yaml")]
66mod yaml;
67
68pub use config::ConfigFileView;
69pub use diff::{diff, Change, ChangeKind, DiffView};
70pub use explorer::{Explorer, View};
71pub use flatten::{flatten, unflatten, FlatView, UnflattenError};
72pub use helpers::{
73    json, print_json, print_json_to, print_table, print_table_to, print_tree, print_tree_to, table,
74    tree,
75};
76pub use redact::SECRET_KEYS;
77pub use redact::{Redaction, Redactor};
78pub use search::{search, MatchKind, SearchMatch, SearchQuery, SearchResults};
79#[cfg(feature = "jsonpath")]
80pub use select::{JsonPath, JsonPathSelector};
81pub use select::{SelectError, Selector, SelectorBackend, Selectors};
82pub use table::{TableOptions, TableView};
83
84/// How deeply YAML and XML documents may nest. Parsing is iterative, but
85/// dropping, cloning and converting a [`Node`] recurse, so an adversarial
86/// document must not build a tree deep enough to overflow the stack. (serde
87/// JSON stops at 128 levels and the `toml` parser has its own limit.)
88#[cfg(any(feature = "yaml", feature = "xml"))]
89pub(crate) const MAX_DEPTH: usize = 512;
90
91/// Style names this module uses beyond core's `json.*` names, with the
92/// fallback each gets when the console theme does not define it.
93pub const DATA_STYLES: &[(&str, &str)] = &[
94    ("data.match", "bold reverse yellow"),
95    ("data.anchor", "dim cyan"),
96    ("data.alias", "dim cyan"),
97    ("data.attribute", "not italic yellow"),
98    ("data.index", "dim"),
99    ("data.comment", "dim"),
100    ("data.path", "dim"),
101    ("data.type", "dim italic"),
102    ("data.summary", "dim"),
103    ("data.null", "dim"),
104    ("data.datetime", "magenta"),
105    ("data.section", "bold"),
106    ("data.added", "green"),
107    ("data.removed", "red"),
108    ("data.changed", "yellow"),
109];
110
111/// Core's JSON style names with the defaults `rich::Json` uses.
112const JSON_STYLES: &[(&str, &str)] = &[
113    ("json.key", "bold blue"),
114    ("json.str", "not bold not italic green"),
115    ("json.number", "bold not italic cyan"),
116    ("json.bool_true", "italic bright_green"),
117    ("json.bool_false", "italic bright_red"),
118    ("json.null", "italic magenta"),
119];
120
121/// The theme style for one of this module's names (or a `json.*` name).
122pub(crate) fn style(console: &Console, key: &str) -> Style {
123    let fallback = DATA_STYLES
124        .iter()
125        .chain(JSON_STYLES)
126        .find(|(name, _)| *name == key)
127        .map_or("", |(_, spec)| *spec);
128    theme_style(console, key, fallback)
129}
130
131// ---------------------------------------------------------------------------
132// The model
133// ---------------------------------------------------------------------------
134
135/// A place in the source text. Both fields are 1-based; the column counts
136/// characters, not bytes.
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
138pub struct Position {
139    pub line: usize,
140    pub column: usize,
141}
142
143impl Position {
144    pub fn new(line: usize, column: usize) -> Self {
145        Position { line, column }
146    }
147}
148
149impl fmt::Display for Position {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(f, "{}:{}", self.line, self.column)
152    }
153}
154
155/// What an XML-derived node was in the source.
156#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
157pub enum XmlKind {
158    /// An element (`<a>…</a>`).
159    Element,
160    /// An attribute, stored under an `@name` key.
161    Attribute,
162    /// Text content of a mixed element, stored under `#text`.
163    Text,
164}
165
166/// Where a node came from and what the source said about it.
167#[derive(Clone, Debug, Default, PartialEq)]
168pub struct Meta {
169    /// The node's position (for a map entry, its key's).
170    pub position: Option<Position>,
171    /// The YAML anchor defined on this node (`&name`).
172    pub anchor: Option<String>,
173    /// For a YAML alias (`*name`), the anchor it refers to. The node holds a
174    /// copy of the anchored value.
175    pub alias: Option<String>,
176    /// A comment attached to this entry (INI and dotenv).
177    pub comment: Option<String>,
178    /// For XML documents, what the node was.
179    pub xml: Option<XmlKind>,
180}
181
182/// A value in the document tree.
183#[derive(Clone, Debug, PartialEq)]
184pub enum Value {
185    Null,
186    Bool(bool),
187    Int(i64),
188    /// An unsigned integer above `i64::MAX`.
189    UInt(u64),
190    Float(f64),
191    String(String),
192    /// A TOML date, time or date-time, exactly as written.
193    DateTime(String),
194    Seq(Vec<Node>),
195    /// Entries in insertion (document) order.
196    Map(Vec<(String, Node)>),
197}
198
199/// A value plus its [`Meta`].
200#[derive(Clone, Debug, PartialEq)]
201pub struct Node {
202    pub value: Value,
203    pub meta: Meta,
204}
205
206impl From<Value> for Node {
207    fn from(value: Value) -> Self {
208        Node::new(value)
209    }
210}
211
212impl<'a> From<Node> for Cow<'a, Node> {
213    fn from(node: Node) -> Self {
214        Cow::Owned(node)
215    }
216}
217
218impl<'a> From<&'a Node> for Cow<'a, Node> {
219    fn from(node: &'a Node) -> Self {
220        Cow::Borrowed(node)
221    }
222}
223
224impl Node {
225    /// A node with no metadata.
226    pub fn new(value: Value) -> Self {
227        Node {
228            value,
229            meta: Meta::default(),
230        }
231    }
232
233    /// A node with metadata.
234    pub fn with_meta(value: Value, meta: Meta) -> Self {
235        Node { value, meta }
236    }
237
238    /// Builder: set the source position.
239    pub fn at_position(mut self, position: Option<Position>) -> Self {
240        self.meta.position = position;
241        self
242    }
243
244    /// Whether this is a sequence or a map.
245    pub fn is_container(&self) -> bool {
246        matches!(self.value, Value::Seq(_) | Value::Map(_))
247    }
248
249    /// The number of children of a container (0 for scalars).
250    pub fn len(&self) -> usize {
251        match &self.value {
252            Value::Seq(items) => items.len(),
253            Value::Map(entries) => entries.len(),
254            _ => 0,
255        }
256    }
257
258    /// Whether this is an empty container or a scalar.
259    pub fn is_empty(&self) -> bool {
260        self.len() == 0
261    }
262
263    /// The value of `key`, for a map.
264    pub fn get(&self, key: &str) -> Option<&Node> {
265        match &self.value {
266            Value::Map(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v),
267            _ => None,
268        }
269    }
270
271    /// Item `index`, for a sequence.
272    pub fn index(&self, index: usize) -> Option<&Node> {
273        match &self.value {
274            Value::Seq(items) => items.get(index),
275            _ => None,
276        }
277    }
278
279    /// The node at `path`.
280    pub fn at(&self, path: &Path) -> Option<&Node> {
281        path.segments()
282            .iter()
283            .try_fold(self, |node, segment| match segment {
284                PathSegment::Key(key) => node.get(key),
285                PathSegment::Index(index) => node.index(*index),
286            })
287    }
288
289    /// The string, for a string or date-time.
290    pub fn as_str(&self) -> Option<&str> {
291        match &self.value {
292            Value::String(s) | Value::DateTime(s) => Some(s),
293            _ => None,
294        }
295    }
296
297    /// A short type name: `null`, `bool`, `int`, `float`, `str`, `datetime`,
298    /// `seq` or `map`.
299    pub fn type_name(&self) -> &'static str {
300        match &self.value {
301            Value::Null => "null",
302            Value::Bool(_) => "bool",
303            Value::Int(_) | Value::UInt(_) => "int",
304            Value::Float(_) => "float",
305            Value::String(_) => "str",
306            Value::DateTime(_) => "datetime",
307            Value::Seq(_) => "seq",
308            Value::Map(_) => "map",
309        }
310    }
311
312    /// The value as JSON. Non-finite floats become `null` and date-times
313    /// strings; metadata is dropped.
314    pub fn to_json(&self) -> serde_json::Value {
315        use serde_json::Value as J;
316        match &self.value {
317            Value::Null => J::Null,
318            Value::Bool(b) => J::Bool(*b),
319            Value::Int(i) => J::from(*i),
320            Value::UInt(u) => J::from(*u),
321            Value::Float(f) => serde_json::Number::from_f64(*f).map_or(J::Null, J::Number),
322            Value::String(s) | Value::DateTime(s) => J::String(s.clone()),
323            Value::Seq(items) => J::Array(items.iter().map(Node::to_json).collect()),
324            Value::Map(entries) => J::Object(
325                entries
326                    .iter()
327                    .map(|(k, v)| (k.clone(), v.to_json()))
328                    .collect(),
329            ),
330        }
331    }
332
333    /// Visit every node, parents before children, with its path.
334    pub fn walk<'a>(&'a self, mut visit: impl FnMut(&Path, &'a Node)) {
335        // An explicit stack: parsers accept deeper documents than the call
336        // stack would.
337        let mut stack: Vec<(Path, &'a Node)> = vec![(Path::root(), self)];
338        while let Some((path, node)) = stack.pop() {
339            visit(&path, node);
340            match &node.value {
341                Value::Seq(items) => {
342                    for (i, item) in items.iter().enumerate().rev() {
343                        stack.push((path.child_index(i), item));
344                    }
345                }
346                Value::Map(entries) => {
347                    for (key, value) in entries.iter().rev() {
348                        stack.push((path.child_key(key), value));
349                    }
350                }
351                _ => {}
352            }
353        }
354    }
355
356    /// A copy with every string or number leaf that `redactor` masks
357    /// replaced, keeping the structure.
358    pub fn redacted(&self, redactor: &dyn Redactor) -> Node {
359        redact::apply(self, redactor)
360    }
361}
362
363/// Values compare equal ignoring metadata; floats by bit pattern so `NaN`
364/// equals itself; `Int` and `UInt` by numeric value.
365pub(crate) fn value_eq(a: &Node, b: &Node) -> bool {
366    match (&a.value, &b.value) {
367        (Value::Float(x), Value::Float(y)) => x.to_bits() == y.to_bits() || x == y,
368        (Value::Int(x), Value::UInt(y)) | (Value::UInt(y), Value::Int(x)) => {
369            u64::try_from(*x).is_ok_and(|x| x == *y)
370        }
371        (Value::Seq(x), Value::Seq(y)) => {
372            x.len() == y.len() && x.iter().zip(y).all(|(x, y)| value_eq(x, y))
373        }
374        (Value::Map(x), Value::Map(y)) => {
375            x.len() == y.len()
376                && x.iter()
377                    .zip(y)
378                    .all(|((kx, vx), (ky, vy))| kx == ky && value_eq(vx, vy))
379        }
380        (x, y) => x == y,
381    }
382}
383
384impl From<&serde_json::Value> for Node {
385    fn from(value: &serde_json::Value) -> Self {
386        use serde_json::Value as J;
387        Node::new(match value {
388            J::Null => Value::Null,
389            J::Bool(b) => Value::Bool(*b),
390            J::Number(n) => {
391                if let Some(i) = n.as_i64() {
392                    Value::Int(i)
393                } else if let Some(u) = n.as_u64() {
394                    Value::UInt(u)
395                } else {
396                    Value::Float(n.as_f64().unwrap_or(f64::NAN))
397                }
398            }
399            J::String(s) => Value::String(s.clone()),
400            J::Array(items) => Value::Seq(items.iter().map(Node::from).collect()),
401            J::Object(entries) => Value::Map(
402                entries
403                    .iter()
404                    .map(|(k, v)| (k.clone(), Node::from(v)))
405                    .collect(),
406            ),
407        })
408    }
409}
410
411impl From<serde_json::Value> for Node {
412    fn from(value: serde_json::Value) -> Self {
413        Node::from(&value)
414    }
415}
416
417/// Convert any `Serialize` value into a [`Node`].
418///
419/// This is a direct serializer, so `u64` above `i64::MAX` stays exact, map
420/// keys that are not strings are stringified (`1` → `"1"`), and field order
421/// is kept. `i128`/`u128` values outside the 64-bit range become floats.
422///
423/// ```
424/// use rich_ext::data::{from_serialize, Value};
425/// use std::collections::BTreeMap;
426///
427/// let map = BTreeMap::from([(1, u64::MAX)]);
428/// let node = from_serialize(&map).unwrap();
429/// assert_eq!(node.get("1").unwrap().value, Value::UInt(u64::MAX));
430/// ```
431pub fn from_serialize<T: serde::Serialize + ?Sized>(value: &T) -> Result<Node, DataError> {
432    value.serialize(ser::NodeSerializer)
433}
434
435// ---------------------------------------------------------------------------
436// Scalars as text
437// ---------------------------------------------------------------------------
438
439/// A scalar's display text and the style name it takes. Strings are quoted
440/// JSON-style when `quote` is set, so control characters never reach the
441/// terminal raw.
442pub(crate) fn scalar_text(value: &Value, quote: bool) -> (String, &'static str) {
443    match value {
444        Value::Null => ("null".into(), "json.null"),
445        Value::Bool(true) => ("true".into(), "json.bool_true"),
446        Value::Bool(false) => ("false".into(), "json.bool_false"),
447        Value::Int(i) => (i.to_string(), "json.number"),
448        Value::UInt(u) => (u.to_string(), "json.number"),
449        Value::Float(f) => (rich::pyformat::float_repr(*f), "json.number"),
450        Value::String(s) if quote => (quote_str(s), "json.str"),
451        Value::String(s) => (escape_controls(s), "json.str"),
452        Value::DateTime(s) => (s.clone(), "data.datetime"),
453        Value::Seq(_) | Value::Map(_) => (String::new(), "data.summary"),
454    }
455}
456
457/// A node's value on one line, as the explorer shows it: a scalar as JSON
458/// text (strings quoted, controls escaped), a container as its summary
459/// (`{…} 3 keys`, `[]`).
460///
461/// ```
462/// use rich_ext::data::{display_value, parse, Format};
463///
464/// let node = parse(Format::Json, r#"{"a": "x\ny", "b": [1, 2]}"#).unwrap();
465/// assert_eq!(display_value(node.get("a").unwrap()), r#""x\ny""#);
466/// assert_eq!(display_value(node.get("b").unwrap()), "[…] 2 items");
467/// ```
468pub fn display_value(node: &Node) -> String {
469    if node.is_container() {
470        summary(node)
471    } else {
472        scalar_text(&node.value, true).0
473    }
474}
475
476/// A node's value as text to copy: a string as it is (unquoted), another
477/// scalar as JSON text, a container as indented JSON.
478///
479/// ```
480/// use rich_ext::data::{copy_text, parse, Format};
481///
482/// let node = parse(Format::Json, r#"{"a": "hi", "b": {"c": 1}}"#).unwrap();
483/// assert_eq!(copy_text(node.get("a").unwrap()), "hi");
484/// assert_eq!(copy_text(node.get("b").unwrap()), "{\n  \"c\": 1\n}");
485/// ```
486pub fn copy_text(node: &Node) -> String {
487    match &node.value {
488        Value::String(s) | Value::DateTime(s) => s.clone(),
489        _ if node.is_container() => {
490            serde_json::to_string_pretty(&node.to_json()).unwrap_or_default()
491        }
492        value => scalar_text(value, true).0,
493    }
494}
495
496/// `s` as a JSON string literal. serde_json escapes only U+0000–U+001F, so
497/// DEL and the C1 controls (U+0080–U+009F, among them the one-character
498/// CSI U+009B) are escaped here too: no control character reaches the
499/// terminal raw.
500pub(crate) fn quote_str(s: &str) -> String {
501    let quoted = serde_json::to_string(s).unwrap_or_else(|_| format!("{s:?}"));
502    if !quoted.chars().any(char::is_control) {
503        return quoted;
504    }
505    let mut out = String::with_capacity(quoted.len() + 8);
506    for c in quoted.chars() {
507        if c.is_control() {
508            out.push_str(&format!("\\u{:04x}", c as u32));
509        } else {
510            out.push(c);
511        }
512    }
513    out
514}
515
516/// `s` with newlines, tabs and other control characters escaped, for
517/// unquoted single-line display.
518pub(crate) fn escape_controls(s: &str) -> String {
519    if !s.chars().any(char::is_control) {
520        return s.to_string();
521    }
522    let quoted = quote_str(s);
523    // Drop the quotes but keep the escapes; `\"` stays escaped, harmlessly.
524    quoted[1..quoted.len() - 1].replace("\\\"", "\"")
525}
526
527/// A folded container's summary: `{…} 3 keys`, `[…] 1 item`, `{}`, `[]`.
528pub(crate) fn summary(node: &Node) -> String {
529    let plural = |n: usize, word: &str| {
530        if n == 1 {
531            format!("{n} {word}")
532        } else {
533            format!("{n} {word}s")
534        }
535    };
536    match &node.value {
537        Value::Map(e) if e.is_empty() => "{}".into(),
538        Value::Seq(i) if i.is_empty() => "[]".into(),
539        Value::Map(e) => format!("{{…}} {}", plural(e.len(), "key")),
540        Value::Seq(i) => format!("[…] {}", plural(i.len(), "item")),
541        _ => String::new(),
542    }
543}
544
545/// `text` cut to at most `max` characters, with an ellipsis when cut.
546pub(crate) fn truncate_chars(text: &str, max: usize) -> Cow<'_, str> {
547    match text.char_indices().nth(max) {
548        Some((cut, _)) => Cow::Owned(format!("{}…", &text[..cut])),
549        None => Cow::Borrowed(text),
550    }
551}
552
553// ---------------------------------------------------------------------------
554// Paths
555// ---------------------------------------------------------------------------
556
557/// One step of a [`Path`].
558#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
559pub enum PathSegment {
560    Key(String),
561    Index(usize),
562}
563
564/// A route from the root to a node. Displays as `servers[0].name`, quoting
565/// keys that are not identifiers: `a["weird key"]`. The root is the empty
566/// path and displays as an empty string.
567#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
568pub struct Path(Vec<PathSegment>);
569
570impl Path {
571    /// The empty path.
572    pub fn root() -> Self {
573        Path(Vec::new())
574    }
575
576    pub fn segments(&self) -> &[PathSegment] {
577        &self.0
578    }
579
580    pub fn is_root(&self) -> bool {
581        self.0.is_empty()
582    }
583
584    pub fn len(&self) -> usize {
585        self.0.len()
586    }
587
588    pub fn is_empty(&self) -> bool {
589        self.0.is_empty()
590    }
591
592    pub fn push(&mut self, segment: PathSegment) {
593        self.0.push(segment);
594    }
595
596    /// This path plus `key`.
597    pub fn child_key(&self, key: &str) -> Path {
598        let mut path = self.clone();
599        path.0.push(PathSegment::Key(key.to_string()));
600        path
601    }
602
603    /// This path plus `[index]`.
604    pub fn child_index(&self, index: usize) -> Path {
605        let mut path = self.clone();
606        path.0.push(PathSegment::Index(index));
607        path
608    }
609
610    /// The path without its last segment.
611    pub fn parent(&self) -> Option<Path> {
612        (!self.0.is_empty()).then(|| Path(self.0[..self.0.len() - 1].to_vec()))
613    }
614
615    pub fn last(&self) -> Option<&PathSegment> {
616        self.0.last()
617    }
618
619    /// The last key on the path (skipping trailing indexes): `tokens` for
620    /// `auth.tokens[1]`.
621    pub fn last_key(&self) -> Option<&str> {
622        self.0.iter().rev().find_map(|s| match s {
623            PathSegment::Key(k) => Some(k.as_str()),
624            PathSegment::Index(_) => None,
625        })
626    }
627}
628
629impl From<Vec<PathSegment>> for Path {
630    fn from(segments: Vec<PathSegment>) -> Self {
631        Path(segments)
632    }
633}
634
635pub(crate) fn is_identifier(key: &str) -> bool {
636    let mut chars = key.chars();
637    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
638        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
639}
640
641/// How one segment displays after `previous` segments.
642pub(crate) fn segment_text(segment: &PathSegment, first: bool) -> String {
643    match segment {
644        PathSegment::Index(i) => format!("[{i}]"),
645        PathSegment::Key(k) if is_identifier(k) && first => k.clone(),
646        PathSegment::Key(k) if is_identifier(k) => format!(".{k}"),
647        PathSegment::Key(k) => format!("[{}]", quote_str(k)),
648    }
649}
650
651impl fmt::Display for Path {
652    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
653        for (i, segment) in self.0.iter().enumerate() {
654            f.write_str(&segment_text(segment, i == 0))?;
655        }
656        Ok(())
657    }
658}
659
660impl std::str::FromStr for Path {
661    type Err = String;
662
663    /// Parse the display form back: `a.b[0]["c d"]`. A leading `$` or `.` is
664    /// accepted.
665    fn from_str(s: &str) -> Result<Self, Self::Err> {
666        let chars: Vec<char> = s.chars().collect();
667        let mut i = 0;
668        let mut path = Path::root();
669        if chars.first() == Some(&'$') {
670            i = 1;
671        }
672        let mut first = true;
673        while i < chars.len() {
674            match chars[i] {
675                '.' => {
676                    i += 1;
677                    let start = i;
678                    while i < chars.len() && chars[i] != '.' && chars[i] != '[' {
679                        i += 1;
680                    }
681                    if start == i {
682                        return Err(format!("empty key at column {}", start + 1));
683                    }
684                    path.push(PathSegment::Key(chars[start..i].iter().collect()));
685                }
686                '[' => {
687                    i += 1;
688                    if chars.get(i) == Some(&'"') {
689                        let start = i;
690                        i += 1;
691                        while i < chars.len() && chars[i] != '"' {
692                            if chars[i] == '\\' {
693                                i += 1;
694                            }
695                            i += 1;
696                        }
697                        let literal: String =
698                            chars[start..(i + 1).min(chars.len())].iter().collect();
699                        let key: String = serde_json::from_str(&literal)
700                            .map_err(|_| format!("bad quoted key at column {}", start + 1))?;
701                        i += 1;
702                        path.push(PathSegment::Key(key));
703                    } else {
704                        let start = i;
705                        while i < chars.len() && chars[i].is_ascii_digit() {
706                            i += 1;
707                        }
708                        let digits: String = chars[start..i].iter().collect();
709                        let index = digits
710                            .parse()
711                            .map_err(|_| format!("expected an index at column {}", start + 1))?;
712                        path.push(PathSegment::Index(index));
713                    }
714                    if chars.get(i) != Some(&']') {
715                        return Err(format!("expected `]` at column {}", i + 1));
716                    }
717                    i += 1;
718                }
719                _ if first => {
720                    let start = i;
721                    while i < chars.len() && chars[i] != '.' && chars[i] != '[' {
722                        i += 1;
723                    }
724                    path.push(PathSegment::Key(chars[start..i].iter().collect()));
725                }
726                c => return Err(format!("unexpected `{c}` at column {}", i + 1)),
727            }
728            first = false;
729        }
730        Ok(path)
731    }
732}
733
734// ---------------------------------------------------------------------------
735// Formats, detection and errors
736// ---------------------------------------------------------------------------
737
738/// A source format.
739#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
740pub enum Format {
741    Json,
742    Yaml,
743    Toml,
744    Xml,
745    Ini,
746    Dotenv,
747}
748
749impl Format {
750    /// Every format, whether or not its feature is compiled in.
751    pub const ALL: [Format; 6] = [
752        Format::Json,
753        Format::Yaml,
754        Format::Toml,
755        Format::Xml,
756        Format::Ini,
757        Format::Dotenv,
758    ];
759
760    /// The lowercase name: `json`, `yaml`, `toml`, `xml`, `ini`, `dotenv`.
761    pub fn name(self) -> &'static str {
762        match self {
763            Format::Json => "json",
764            Format::Yaml => "yaml",
765            Format::Toml => "toml",
766            Format::Xml => "xml",
767            Format::Ini => "ini",
768            Format::Dotenv => "dotenv",
769        }
770    }
771
772    /// A format by name, case-insensitively; `yml` and `env` are accepted.
773    pub fn from_name(name: &str) -> Option<Format> {
774        match name.to_ascii_lowercase().as_str() {
775            "json" => Some(Format::Json),
776            "yaml" | "yml" => Some(Format::Yaml),
777            "toml" => Some(Format::Toml),
778            "xml" => Some(Format::Xml),
779            "ini" => Some(Format::Ini),
780            "dotenv" | "env" => Some(Format::Dotenv),
781            _ => None,
782        }
783    }
784
785    /// A format by file extension, with or without the dot: `json`, `yaml`,
786    /// `yml`, `toml`, `xml`, `ini`, `cfg`, `env`.
787    pub fn from_extension(extension: &str) -> Option<Format> {
788        let extension = extension.strip_prefix('.').unwrap_or(extension);
789        match extension.to_ascii_lowercase().as_str() {
790            "json" => Some(Format::Json),
791            "yaml" | "yml" => Some(Format::Yaml),
792            "toml" => Some(Format::Toml),
793            "xml" => Some(Format::Xml),
794            "ini" | "cfg" => Some(Format::Ini),
795            "env" => Some(Format::Dotenv),
796            _ => None,
797        }
798    }
799
800    /// A format by file name: its extension, or `.env` / `.env.*` for dotenv.
801    pub fn from_file_name(name: &str) -> Option<Format> {
802        let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
803        if base == ".env" || base.starts_with(".env.") {
804            return Some(Format::Dotenv);
805        }
806        let (stem, extension) = base.rsplit_once('.')?;
807        if stem.is_empty() {
808            return None;
809        }
810        Format::from_extension(extension)
811    }
812
813    /// Whether this build can parse the format (its feature is enabled).
814    pub fn is_enabled(self) -> bool {
815        match self {
816            Format::Json | Format::Ini | Format::Dotenv => true,
817            Format::Yaml => cfg!(feature = "yaml"),
818            Format::Toml => cfg!(feature = "toml"),
819            Format::Xml => cfg!(feature = "xml"),
820        }
821    }
822
823    /// Guess the format of `content`, conservatively.
824    ///
825    /// A `name_hint` whose file name maps to an enabled format wins outright
826    /// (a parse error then explains the problem better than a guess would).
827    /// Otherwise the content is tried against each enabled format in this
828    /// order, most distinctive first:
829    ///
830    /// 1. **JSON** — starts with `{` or `[` and parses as an object or array.
831    ///    First because every JSON document is also YAML.
832    /// 2. **XML** — starts with `<?xml` or `<` and a name, and parses.
833    /// 3. **TOML** — parses and defines at least one key or table. Before
834    ///    dotenv and INI because a document valid as TOML *and* as either of
835    ///    those gets typed values from TOML.
836    /// 4. **dotenv** — every non-comment line is `KEY=VALUE` (optionally
837    ///    `export KEY=VALUE`), `KEY` matching `[A-Za-z_][A-Za-z0-9_.]*`, with
838    ///    no spaces around `=` and unquoted values free of whitespace.
839    /// 5. **INI** — has a `[section]` header, at least one `key = value` or
840    ///    `key: value` line, and nothing else but comments and continuations.
841    ///    Only reached when the text is not TOML (if TOML is compiled in).
842    /// 6. **YAML** — last, because it accepts almost anything. Every
843    ///    top-level line must be structural (`key:` with an identifier-like
844    ///    key, `- item`, `---`, a comment), there must be at least two
845    ///    such lines and at least one `key:` line, and every document must be
846    ///    a mapping or sequence. A lone `key: value` line, prose, a bullet
847    ///    list and Markdown are rejected.
848    ///
849    /// CSV and other tabular text is not a supported format: it matches none
850    /// of the rules above, so `detect` returns `None` for it.
851    ///
852    /// ```
853    /// use rich_ext::data::Format;
854    ///
855    /// assert_eq!(Format::detect("{\"a\": 1}", None), Some(Format::Json));
856    /// assert_eq!(Format::detect("A=1\nB=two", None).is_some(), true);
857    /// assert_eq!(Format::detect("just some prose", None), None);
858    /// assert_eq!(Format::detect("name,age\nbob,3", None), None);
859    /// assert_eq!(Format::detect("anything", Some("app.json")), Some(Format::Json));
860    /// ```
861    pub fn detect(content: &str, name_hint: Option<&str>) -> Option<Format> {
862        if let Some(format) = name_hint.and_then(Format::from_file_name) {
863            if format.is_enabled() {
864                return Some(format);
865            }
866        }
867        let trimmed = content.trim_start_matches('\u{feff}').trim();
868        if trimmed.is_empty() {
869            return None;
870        }
871        if (trimmed.starts_with('{') || trimmed.starts_with('['))
872            && serde_json::from_str::<serde_json::Value>(trimmed)
873                .is_ok_and(|v| v.is_object() || v.is_array())
874        {
875            return Some(Format::Json);
876        }
877        #[cfg(feature = "xml")]
878        if xml::looks_like(trimmed) && xml::parse(content).is_ok() {
879            return Some(Format::Xml);
880        }
881        #[cfg(feature = "toml")]
882        if toml_doc::looks_like(content) {
883            return Some(Format::Toml);
884        }
885        if dotenv::looks_like(content) {
886            return Some(Format::Dotenv);
887        }
888        if ini::looks_like(content) {
889            return Some(Format::Ini);
890        }
891        #[cfg(feature = "yaml")]
892        if yaml::looks_like(content) {
893            return Some(Format::Yaml);
894        }
895        None
896    }
897}
898
899impl fmt::Display for Format {
900    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
901        f.write_str(match self {
902            Format::Json => "JSON",
903            Format::Yaml => "YAML",
904            Format::Toml => "TOML",
905            Format::Xml => "XML",
906            Format::Ini => "INI",
907            Format::Dotenv => "dotenv",
908        })
909    }
910}
911
912/// A parse (or conversion) failure.
913#[derive(Clone, Debug, PartialEq, Eq)]
914pub struct DataError {
915    pub format: Format,
916    pub message: String,
917    pub position: Option<Position>,
918}
919
920impl DataError {
921    /// Control characters in `message` are escaped (`\u001b`): parser
922    /// messages often repeat the offending input, which must not reach the
923    /// terminal raw.
924    pub fn new(format: Format, message: impl Into<String>, position: Option<Position>) -> Self {
925        DataError {
926            format,
927            message: escape_controls(&message.into()),
928            position,
929        }
930    }
931
932    /// This error as a diagnostic over `source` (shown as `name`), with the
933    /// offending line underlined when the position is known.
934    ///
935    /// ```
936    /// use rich::Console;
937    /// use rich_ext::data::{parse, Format};
938    ///
939    /// let source = "{\n  \"a\" 1\n}";
940    /// let error = parse(Format::Json, source).unwrap_err();
941    /// let out = Console::builder()
942    ///     .width(60)
943    ///     .build()
944    ///     .render_export(&error.to_diagnostic(source, "a.json"));
945    /// assert!(out.contains("a.json:2:7"), "{out}");
946    /// assert!(out.contains("2 |   \"a\" 1\n  |       ^ expected `:`"), "{out}");
947    /// ```
948    pub fn to_diagnostic(&self, source: &str, name: &str) -> crate::diagnostic::Diagnostic {
949        use crate::diagnostic::{Diagnostic, Location, SourceSnippet};
950        use crate::event::EventView;
951        let mut diagnostic =
952            Diagnostic::error(format!("invalid {}: {}", self.format, self.message))
953                .view(EventView::Expanded);
954        let Some(position) = self.position else {
955            return diagnostic;
956        };
957        diagnostic = diagnostic.location(Location::new(
958            name,
959            Some(position.line),
960            Some(position.column),
961        ));
962        let start = byte_offset(source, position);
963        let end = source[start..]
964            .chars()
965            .next()
966            .filter(|c| *c != '\n' && *c != '\r')
967            .map_or(start, |c| start + c.len_utf8());
968        // The snippet shows source lines as they are, so control characters
969        // become one-column pictures first (keeping the caret aligned).
970        let (source, [start, end]) = picture_controls(source, [start, end]);
971        if let Ok(snippet) = SourceSnippet::new(name.to_string(), source, start..end, 1) {
972            diagnostic = diagnostic.snippet(snippet.primary_label(self.message.clone()));
973        }
974        diagnostic
975    }
976}
977
978/// `source` with each control character but newline, tab and a CR ending a
979/// line replaced by one visible character: its Control Picture (`␛` for
980/// ESC, `␡` for DEL), or `�` for a C1 control. `offsets` (byte offsets
981/// into `source`) come back mapped into the result.
982fn picture_controls(source: &str, mut offsets: [usize; 2]) -> (String, [usize; 2]) {
983    let mut out = String::with_capacity(source.len());
984    let original = offsets;
985    let mut chars = source.char_indices().peekable();
986    while let Some((i, c)) = chars.next() {
987        for (offset, at) in offsets.iter_mut().zip(original) {
988            if at == i {
989                *offset = out.len();
990            }
991        }
992        let keep = matches!(c, '\n' | '\t')
993            || (c == '\r' && matches!(chars.peek(), None | Some((_, '\n'))));
994        out.push(match c {
995            _ if keep || !c.is_control() => c,
996            '\u{0}'..='\u{1f}' => char::from_u32(0x2400 + c as u32).unwrap_or('\u{fffd}'),
997            '\u{7f}' => '\u{2421}',
998            _ => '\u{fffd}',
999        });
1000    }
1001    for (offset, at) in offsets.iter_mut().zip(original) {
1002        if at >= source.len() {
1003            *offset = out.len();
1004        }
1005    }
1006    (out, offsets)
1007}
1008
1009impl fmt::Display for DataError {
1010    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1011        // `message` is public, so escape again for errors built by hand.
1012        let message = escape_controls(&self.message);
1013        match self.position {
1014            Some(p) => write!(
1015                f,
1016                "invalid {} at line {}, column {}: {message}",
1017                self.format, p.line, p.column
1018            ),
1019            None => write!(f, "invalid {}: {message}", self.format),
1020        }
1021    }
1022}
1023
1024impl std::error::Error for DataError {}
1025
1026/// Line starts of a source, for byte offset → [`Position`].
1027///
1028/// Parsers ask for positions in increasing order, so the last answer is kept
1029/// and a later offset on the same line counts characters from there. Counting
1030/// from the line start instead made every lookup cost the whole line, which is
1031/// quadratic on minified (single-line) XML or TOML.
1032#[cfg(any(feature = "toml", feature = "xml"))]
1033pub(crate) struct LineIndex<'a> {
1034    source: &'a str,
1035    starts: Vec<usize>,
1036    /// The last (byte offset, line, column) answered.
1037    last: std::cell::Cell<(usize, usize, usize)>,
1038}
1039
1040#[cfg(any(feature = "toml", feature = "xml"))]
1041impl<'a> LineIndex<'a> {
1042    pub(crate) fn new(source: &'a str) -> Self {
1043        let mut starts = vec![0];
1044        starts.extend(source.match_indices('\n').map(|(i, _)| i + 1));
1045        LineIndex {
1046            source,
1047            starts,
1048            last: std::cell::Cell::new((0, 1, 1)),
1049        }
1050    }
1051
1052    pub(crate) fn position(&self, offset: usize) -> Position {
1053        let mut offset = offset.min(self.source.len());
1054        while !self.source.is_char_boundary(offset) {
1055            offset -= 1;
1056        }
1057        let line = self.starts.partition_point(|s| *s <= offset);
1058        let start = self.starts[line - 1];
1059        let (last_offset, last_line, last_column) = self.last.get();
1060        let column = if last_line == line && last_offset >= start && last_offset <= offset {
1061            last_column + self.source[last_offset..offset].chars().count()
1062        } else {
1063            self.source[start..offset].chars().count() + 1
1064        };
1065        self.last.set((offset, line, column));
1066        Position::new(line, column)
1067    }
1068}
1069
1070/// The byte offset of `position` in `source`, clamped to the line's end.
1071pub(crate) fn byte_offset(source: &str, position: Position) -> usize {
1072    let mut offset = 0;
1073    for _ in 1..position.line {
1074        match source[offset..].find('\n') {
1075            Some(i) => offset += i + 1,
1076            None => return source.len(),
1077        }
1078    }
1079    let line_end = source[offset..]
1080        .find('\n')
1081        .map_or(source.len(), |i| offset + i);
1082    source[offset..line_end]
1083        .char_indices()
1084        .nth(position.column.saturating_sub(1))
1085        .map_or(line_end, |(i, _)| offset + i)
1086}
1087
1088/// Parse `content` as `format`.
1089///
1090/// ```
1091/// use rich_ext::data::{parse, Format, Value};
1092///
1093/// let node = parse(Format::Dotenv, "export NAME=\"a\\nb\"\n").unwrap();
1094/// assert_eq!(node.get("NAME").unwrap().value, Value::String("a\nb".into()));
1095/// ```
1096pub fn parse(format: Format, content: &str) -> Result<Node, DataError> {
1097    match format {
1098        Format::Json => json::parse(content),
1099        Format::Ini => ini::parse(content),
1100        Format::Dotenv => dotenv::parse(content),
1101        #[cfg(feature = "yaml")]
1102        Format::Yaml => yaml::parse(content),
1103        #[cfg(feature = "toml")]
1104        Format::Toml => toml_doc::parse(content),
1105        #[cfg(feature = "xml")]
1106        Format::Xml => xml::parse(content),
1107        #[allow(unreachable_patterns)]
1108        other => Err(DataError::new(
1109            other,
1110            format!(
1111                "{} support is not compiled in (enable the `{}` feature)",
1112                other,
1113                other.name()
1114            ),
1115            None,
1116        )),
1117    }
1118}
1119
1120/// Parse JSON (object key order is kept; the last duplicate key wins). The
1121/// integer `-0` reads as `Int(0)`, as Python's `json` reads it; `-0.0` stays
1122/// a float.
1123pub fn parse_json(content: &str) -> Result<Node, DataError> {
1124    json::parse(content)
1125}
1126
1127/// Parse an INI file. See [`ConfigFileView`] for the shape it produces.
1128///
1129/// Sections (`[name]`, `[a.b]` kept literally) become maps under the root;
1130/// keys before the first section sit in the root. `key = value` and
1131/// `key: value` both work, values are strings (no type guessing, no inline
1132/// comment stripping), indented lines continue the previous value, and a
1133/// repeated key keeps its first slot but takes the last value and position.
1134/// `;` and `#` comment lines directly above an entry (no blank line between)
1135/// become its `meta.comment`. A section named like a key before the first
1136/// section is an error: both would live in the root map.
1137pub fn parse_ini(content: &str) -> Result<Node, DataError> {
1138    ini::parse(content)
1139}
1140
1141/// Parse a dotenv file.
1142///
1143/// `KEY=VALUE` and `export KEY=VALUE`; single-quoted values are literal,
1144/// double-quoted values understand `\n`, `\t`, `\r`, `\"` and `\\` (and may
1145/// span lines), unquoted values lose a trailing ` #comment`. Values are
1146/// strings and **no variable expansion** is done: `$HOME` stays `$HOME`.
1147/// Comment lines directly above an entry (and an inline comment) become its
1148/// `meta.comment`. A repeated key keeps its first slot but takes the last
1149/// value and position.
1150pub fn parse_dotenv(content: &str) -> Result<Node, DataError> {
1151    dotenv::parse(content)
1152}
1153
1154/// Parse YAML (1.2 core schema). See the `yaml` feature.
1155///
1156/// Anchors and aliases are recorded in [`Meta`]; an alias node holds a copy
1157/// of its anchor's value, and expansion stops with an error past one million
1158/// copied nodes or 64 MiB of copied strings (the "billion laughs" guard;
1159/// only anchors that an alias uses are copied, and those copies count too).
1160/// Merge keys (`<<`) are kept as ordinary keys; a key repeated in one mapping
1161/// is an error. Several documents parse to a sequence of documents.
1162/// Comments are not kept. Nesting deeper than 512 levels is an error.
1163#[cfg(feature = "yaml")]
1164pub fn parse_yaml(content: &str) -> Result<Node, DataError> {
1165    yaml::parse(content)
1166}
1167
1168/// Parse TOML; tables keep document order and date-times keep their text.
1169#[cfg(feature = "toml")]
1170pub fn parse_toml(content: &str) -> Result<Node, DataError> {
1171    toml_doc::parse(content)
1172}
1173
1174/// Parse XML into `{root: …}`: attributes as `@name` keys, repeated child
1175/// elements as sequences, text as the element's value or, beside
1176/// attributes or children, under `#text`. Namespace prefixes are kept as
1177/// written; comments and processing instructions are dropped, and text
1178/// outside the root element is an error. Parsing
1179/// streams, so size is not limited here (the views have limits), but nesting
1180/// deeper than 512 elements is an error.
1181#[cfg(feature = "xml")]
1182pub fn parse_xml(content: &str) -> Result<Node, DataError> {
1183    xml::parse(content)
1184}
1185
1186#[cfg(all(test, any(feature = "toml", feature = "xml")))]
1187mod line_index_tests {
1188    use super::{LineIndex, Position};
1189
1190    /// The uncached answer: count characters from the line's start.
1191    fn naive(source: &str, offset: usize) -> Position {
1192        let mut offset = offset.min(source.len());
1193        while !source.is_char_boundary(offset) {
1194            offset -= 1;
1195        }
1196        let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
1197        let line = source[..offset].matches('\n').count() + 1;
1198        Position::new(line, source[start..offset].chars().count() + 1)
1199    }
1200
1201    #[test]
1202    fn cached_positions_match_counting_from_the_line_start() {
1203        let source = "ab\ncafé 👩‍👩‍👧 x\n\n<a b=\"é\">tëxt</a>\nlast";
1204        let index = LineIndex::new(source);
1205        // Increasing, repeated, backwards, past the end and mid-character
1206        // offsets, in a fixed pseudo-random order after a forward sweep.
1207        let mut offsets: Vec<usize> = (0..=source.len() + 2).collect();
1208        let mut state = 7u64;
1209        for _ in 0..400 {
1210            state = state
1211                .wrapping_mul(6364136223846793005)
1212                .wrapping_add(1442695040888963407);
1213            offsets.push((state >> 33) as usize % (source.len() + 3));
1214        }
1215        for offset in offsets {
1216            assert_eq!(
1217                index.position(offset),
1218                naive(source, offset),
1219                "offset {offset}"
1220            );
1221        }
1222    }
1223}