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