Skip to main content

rich_ext/data/
explorer.rs

1//! A width-aware tree (or table) over a [`Node`], with folding and limits.
2
3use std::borrow::Cow;
4use std::collections::{HashMap, HashSet};
5
6use rich::cells::{cell_len, char_cell_width};
7use rich::measure::Measurement;
8use rich::{Console, ConsoleOptions, Justify, Overflow, Renderable, Segment, Style, Table, Text};
9
10use super::table::{TableOptions, TableView, cell_text};
11use super::{Node, Path, Value, XmlKind, quote_str, scalar_text, style, summary};
12use crate::event::flatten as join_lines;
13use crate::table::StyleRules;
14
15// The thin guides of core's `Tree` (upstream `TREE_GUIDES[0]`).
16const SPACE: &str = "    ";
17const CONTINUE: &str = "│   ";
18const FORK: &str = "├── ";
19const END: &str = "└── ";
20
21/// How an [`Explorer`] lays the document out.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub enum View {
24    /// A tree with guide lines.
25    #[default]
26    Tree,
27    /// A table: records (a sequence of maps) as rows, anything else as
28    /// `path | value` rows.
29    Table,
30}
31
32/// A renderable view of a document tree.
33///
34/// Scalars reuse core's JSON styles, keys `json.key`, XML attributes
35/// `data.attribute`; YAML anchors and aliases show as dim `&name` / `*name`
36/// badges and INI/dotenv comments as a dim `# comment`. Every line is cut to
37/// the available width: long strings shrink first (keeping their quotes),
38/// then the line ends in `…`, so nothing wraps.
39///
40/// ```
41/// use rich::Console;
42/// use rich_ext::data::{parse, Explorer, Format};
43///
44/// let node = parse(Format::Json, r#"{"a": {"b": 1, "c": 2}, "d": [1, 2, 3]}"#).unwrap();
45/// let explorer = Explorer::new(&node).max_depth(1).root_label("doc");
46/// let out = Console::builder().width(40).build().render_export(&explorer);
47/// assert_eq!(out, "doc\n├── a: {…} 2 keys\n└── d: […] 3 items\n");
48/// ```
49#[derive(Clone, Debug)]
50#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
51pub struct Explorer<'a> {
52    node: Cow<'a, Node>,
53    max_depth: Option<usize>,
54    max_length: Option<usize>,
55    max_string: Option<usize>,
56    show_paths: bool,
57    show_types: bool,
58    folded: HashSet<Path>,
59    highlighted: HashMap<Path, Style>,
60    root_label: Option<String>,
61    view: View,
62    rules: StyleRules,
63}
64
65enum Key<'n> {
66    Root,
67    Name(&'n str),
68    Index(usize),
69}
70
71enum Item<'n> {
72    Node {
73        key: Key<'n>,
74        node: &'n Node,
75        path: Path,
76        depth: usize,
77    },
78    More(usize),
79}
80
81/// One rendered tree line: guide prefix, label, and the label's minimum.
82struct Line {
83    prefix: String,
84    label: Text,
85    minimum: usize,
86}
87
88impl<'a> Explorer<'a> {
89    /// Explore an owned or borrowed node.
90    pub fn new(node: impl Into<Cow<'a, Node>>) -> Self {
91        Explorer {
92            node: node.into(),
93            max_depth: None,
94            max_length: None,
95            max_string: None,
96            show_paths: false,
97            show_types: false,
98            folded: HashSet::new(),
99            highlighted: HashMap::new(),
100            root_label: None,
101            view: View::Tree,
102            rules: StyleRules::new(),
103        }
104    }
105
106    /// Fold containers this deep (the root is depth 0) to a summary like
107    /// `{…} 3 keys`.
108    pub fn max_depth(mut self, depth: usize) -> Self {
109        self.max_depth = Some(depth);
110        self
111    }
112
113    /// Show at most this many children per container, then `… N more`.
114    pub fn max_length(mut self, length: usize) -> Self {
115        self.max_length = Some(length);
116        self
117    }
118
119    /// Cut strings to this many characters (with `…`). Strings are also cut
120    /// to the available width regardless.
121    pub fn max_string(mut self, length: usize) -> Self {
122        self.max_string = Some(length);
123        self
124    }
125
126    /// Append each leaf's path, dim.
127    pub fn show_paths(mut self, show: bool) -> Self {
128        self.show_paths = show;
129        self
130    }
131
132    /// Append each node's type (`str`, `int`, `map`, …), dim.
133    pub fn show_types(mut self, show: bool) -> Self {
134        self.show_types = show;
135        self
136    }
137
138    /// Fold the container at `path`.
139    pub fn fold(mut self, path: Path) -> Self {
140        self.folded.insert(path);
141        self
142    }
143
144    /// Style the tree line of the node at `path`, such as the selection of a
145    /// `data::transform::Highlight`. The table view ignores it.
146    pub fn highlight(mut self, path: Path, style: Style) -> Self {
147        self.highlighted.insert(path, style);
148        self
149    }
150
151    /// The root line's label (default: the root's summary).
152    pub fn root_label(mut self, label: impl Into<String>) -> Self {
153        self.root_label = Some(label.into());
154        self
155    }
156
157    /// Tree or table.
158    pub fn view(mut self, view: View) -> Self {
159        self.view = view;
160        self
161    }
162
163    /// Conditional styles ([`table::rules`](crate::table::rules)) for the
164    /// table view of records, by header. The tree ignores them.
165    pub fn style_rules(mut self, rules: StyleRules) -> Self {
166        self.rules = rules;
167        self
168    }
169
170    /// The node being explored.
171    pub fn node(&self) -> &Node {
172        &self.node
173    }
174
175    fn is_folded(&self, path: &Path, depth: usize) -> bool {
176        self.max_depth.is_some_and(|max| depth >= max) || self.folded.contains(path)
177    }
178
179    fn expanded(&self, node: &Node, path: &Path, depth: usize) -> bool {
180        node.is_container() && !node.is_empty() && !self.is_folded(path, depth)
181    }
182
183    /// The label of one tree line, cut to `available` cells. Returns the
184    /// label and its minimum useful width (its head plus an ellipsis).
185    fn label(
186        &self,
187        console: &Console,
188        key: &Key<'_>,
189        node: &Node,
190        path: &Path,
191        expanded: bool,
192        available: usize,
193    ) -> (Text, usize) {
194        let mut head = Text::new("");
195        match key {
196            Key::Root => {
197                if let Some(label) = &self.root_label {
198                    head.append(&super::escape_controls(label), None);
199                }
200            }
201            Key::Name(name) => {
202                let key_style = match node.meta.xml {
203                    Some(XmlKind::Attribute) => "data.attribute",
204                    Some(XmlKind::Text) => "data.comment",
205                    _ => "json.key",
206                };
207                head.append(
208                    &super::escape_controls(name),
209                    Some(style(console, key_style).into()),
210                );
211            }
212            Key::Index(index) => {
213                head.append(
214                    &format!("[{index}]"),
215                    Some(style(console, "data.index").into()),
216                );
217            }
218        }
219
220        // The value: a scalar, or a summary for a folded, empty or unlabelled
221        // root container.
222        let root_summary = matches!(key, Key::Root) && self.root_label.is_none();
223        let value: Option<(String, &'static str, bool)> = if node.is_container() {
224            (!expanded || root_summary).then(|| (summary(node), "data.summary", false))
225        } else {
226            let is_string = matches!(node.value, Value::String(_));
227            let text = match &node.value {
228                Value::String(s) => fit_quoted(s, self.max_string, None),
229                other => scalar_text(other, true).0,
230            };
231            Some((text, scalar_text(&node.value, true).1, is_string))
232        };
233        let separator = if value.is_some() && !head.is_empty() {
234            ": "
235        } else {
236            ""
237        };
238
239        let mut badges = Text::new("");
240        if let Some(anchor) = &node.meta.anchor {
241            badges.append(
242                &format!(" &{}", super::escape_controls(anchor)),
243                Some(style(console, "data.anchor").into()),
244            );
245        }
246        if let Some(alias) = &node.meta.alias {
247            badges.append(
248                &format!(" *{}", super::escape_controls(alias)),
249                Some(style(console, "data.alias").into()),
250            );
251        }
252        if self.show_types {
253            badges.append(
254                &format!(" ({})", node.type_name()),
255                Some(style(console, "data.type").into()),
256            );
257        }
258
259        let mut extras = Text::new("");
260        if let Some(comment) = &node.meta.comment {
261            let comment = super::escape_controls(&comment.replace('\n', " "));
262            extras.append(
263                &format!("  # {comment}"),
264                Some(style(console, "data.comment").into()),
265            );
266        }
267        if self.show_paths && !expanded && !path.is_root() {
268            extras.append(
269                &format!("  {path}"),
270                Some(style(console, "data.path").into()),
271            );
272        }
273
274        let fixed = head.cell_len() + cell_len(separator) + badges.cell_len();
275        let minimum = head.cell_len() + 1;
276        let mut text = head;
277        text.append(separator, None);
278        if let Some((mut value, value_style, is_string)) = value {
279            if is_string
280                && fixed + cell_len(&value) > available
281                && let (Some(budget), Value::String(s)) =
282                    (available.checked_sub(fixed), &node.value)
283            {
284                value = fit_quoted(s, self.max_string, Some(budget));
285            }
286            text.append(&value, Some(style(console, value_style).into()));
287        }
288        let text = text.append_text(&badges).append_text(&extras);
289        let mut text = text;
290        text.truncate(available, Some(Overflow::Ellipsis), false);
291        if let Some(style) = self.highlighted.get(path) {
292            let end = text.plain().len();
293            text.stylize(style.clone(), 0, end);
294        }
295        (text, minimum)
296    }
297
298    fn tree_lines(&self, console: &Console, width: usize) -> Vec<Line> {
299        let mut lines = Vec::new();
300        let mut stack: Vec<(Item<'_>, String, String)> = vec![(
301            Item::Node {
302                key: Key::Root,
303                node: &self.node,
304                path: Path::root(),
305                depth: 0,
306            },
307            String::new(),
308            String::new(),
309        )];
310        while let Some((item, first, rest)) = stack.pop() {
311            let available = width.saturating_sub(cell_len(&first));
312            let (key, node, path, depth) = match item {
313                Item::More(count) => {
314                    let mut label =
315                        Text::styled(format!("… {count} more"), style(console, "data.summary"));
316                    label.truncate(available, Some(Overflow::Ellipsis), false);
317                    lines.push(Line {
318                        prefix: first,
319                        label,
320                        minimum: 1,
321                    });
322                    continue;
323                }
324                Item::Node {
325                    key,
326                    node,
327                    path,
328                    depth,
329                } => (key, node, path, depth),
330            };
331            let expanded = self.expanded(node, &path, depth);
332            let (label, minimum) = self.label(console, &key, node, &path, expanded, available);
333            lines.push(Line {
334                prefix: first,
335                label,
336                minimum,
337            });
338            if !expanded {
339                continue;
340            }
341            let limit = self.max_length.unwrap_or(usize::MAX);
342            let mut children: Vec<Item<'_>> = match &node.value {
343                Value::Seq(items) => items
344                    .iter()
345                    .enumerate()
346                    .take(limit)
347                    .map(|(i, child)| Item::Node {
348                        key: Key::Index(i),
349                        node: child,
350                        path: path.child_index(i),
351                        depth: depth + 1,
352                    })
353                    .collect(),
354                Value::Map(entries) => entries
355                    .iter()
356                    .take(limit)
357                    .map(|(k, child)| Item::Node {
358                        key: Key::Name(k),
359                        node: child,
360                        path: path.child_key(k),
361                        depth: depth + 1,
362                    })
363                    .collect(),
364                _ => Vec::new(),
365            };
366            if node.len() > limit {
367                children.push(Item::More(node.len() - limit));
368            }
369            let last = children.len().saturating_sub(1);
370            for (index, child) in children.into_iter().enumerate().rev() {
371                let (fork, space) = if index == last {
372                    (END, SPACE)
373                } else {
374                    (FORK, CONTINUE)
375                };
376                stack.push((child, format!("{rest}{fork}"), format!("{rest}{space}")));
377            }
378        }
379        lines
380    }
381
382    fn table_view(&self, console: &Console) -> Table {
383        let records = matches!(&self.node.value, Value::Seq(items)
384            if !items.is_empty() && items.iter().all(|i| matches!(i.value, Value::Map(_))));
385        if records {
386            let options = TableOptions {
387                max_rows: self.max_length,
388                max_string: self.max_string,
389                ..TableOptions::default()
390            };
391            return TableView::new(&*self.node)
392                .options(options)
393                .style_rules(self.rules.clone())
394                .to_table(console);
395        }
396        let mut table = Table::new();
397        table.add_column_text(Text::new("path"), Justify::Left);
398        table.add_column_text(Text::new("value"), Justify::Left);
399        if self.show_types {
400            table.add_column_text(Text::new("type"), Justify::Left);
401        }
402        let dim = |text: String| Text::styled(text, style(console, "data.path"));
403        let mut stack: Vec<(&Node, Path, usize)> = vec![(&self.node, Path::root(), 0)];
404        while let Some((node, path, depth)) = stack.pop() {
405            if depth != usize::MAX && self.expanded(node, &path, depth) {
406                let limit = self.max_length.unwrap_or(usize::MAX);
407                if node.len() > limit {
408                    // Pushed first, so it pops after the shown children.
409                    stack.push((&self.node, path.clone(), usize::MAX));
410                }
411                match &node.value {
412                    Value::Seq(items) => {
413                        for (i, item) in items.iter().enumerate().take(limit).rev() {
414                            stack.push((item, path.child_index(i), depth + 1));
415                        }
416                    }
417                    Value::Map(entries) => {
418                        for (k, v) in entries.iter().take(limit).rev() {
419                            stack.push((v, path.child_key(k), depth + 1));
420                        }
421                    }
422                    _ => {}
423                }
424                continue;
425            }
426            let shown_path = if path.is_root() {
427                "(root)".to_string()
428            } else {
429                path.to_string()
430            };
431            let mut row = vec![Text::new(super::escape_controls(&shown_path))];
432            if depth == usize::MAX {
433                // The `… N more` marker for `path`'s container.
434                let container = self.node.at(&path).map_or(0, Node::len);
435                let limit = self.max_length.unwrap_or(usize::MAX);
436                row.push(dim(format!("… {} more", container.saturating_sub(limit))));
437            } else if node.is_container() {
438                row.push(Text::styled(summary(node), style(console, "data.summary")));
439            } else {
440                row.push(cell_text(console, node, self.max_string));
441            }
442            if self.show_types {
443                row.push(dim(if depth == usize::MAX {
444                    String::new()
445                } else {
446                    node.type_name().to_string()
447                }));
448            }
449            table.add_row_text(row);
450        }
451        table
452    }
453}
454
455/// `raw` as a quoted string, cut to `max_chars` characters and then to
456/// `budget` cells, ending `…"` when cut.
457pub(crate) fn fit_quoted(raw: &str, max_chars: Option<usize>, budget: Option<usize>) -> String {
458    let (content, cut) = match max_chars.and_then(|n| raw.char_indices().nth(n)) {
459        Some((i, _)) => (&raw[..i], true),
460        None => (raw, false),
461    };
462    let quoted = quote_str(content);
463    let escaped = &quoted[1..quoted.len() - 1];
464    let whole = cell_len(escaped) + 2 + usize::from(cut);
465    match budget {
466        Some(budget) if whole > budget => {
467            let allowed = budget.saturating_sub(3);
468            let mut kept = String::new();
469            let mut used = 0;
470            for c in escaped.chars() {
471                let w = char_cell_width(c);
472                if used + w > allowed {
473                    break;
474                }
475                used += w;
476                kept.push(c);
477            }
478            // Never leave half an escape sequence.
479            let trailing = kept.chars().rev().take_while(|c| *c == '\\').count();
480            if trailing % 2 == 1 {
481                kept.pop();
482            }
483            format!("\"{kept}…\"")
484        }
485        _ if cut => format!("\"{escaped}…\""),
486        _ => quoted,
487    }
488}
489
490impl Renderable for Explorer<'_> {
491    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
492        if options.max_width == 0 {
493            return Vec::new();
494        }
495        if self.view == View::Table {
496            return self.table_view(console).rich_render(console, options);
497        }
498        let guide = style(console, "tree.line");
499        let rows = self
500            .tree_lines(console, options.max_width)
501            .into_iter()
502            .map(|line| {
503                let mut row = Vec::new();
504                if !line.prefix.is_empty() {
505                    row.push(Segment::new(line.prefix, Some(guide.clone())));
506                }
507                row.extend(line.label.render(console.theme(), &Style::new()));
508                row
509            })
510            .collect();
511        join_lines(rows)
512    }
513
514    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
515        if self.view == View::Table {
516            return Measurement::new(options.max_width, options.max_width);
517        }
518        let lines = self.tree_lines(console, usize::MAX / 2);
519        let (mut minimum, mut maximum) = (0, 0);
520        for line in &lines {
521            let prefix = cell_len(&line.prefix);
522            minimum = minimum.max(prefix + line.minimum);
523            maximum = maximum.max(prefix + line.label.cell_len());
524        }
525        Measurement::new(minimum.min(maximum), maximum).with_maximum(options.max_width)
526    }
527}