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