Skip to main content

asdf_core/
info.rs

1//! Rendering a human-readable view of an ASDF file.
2//!
3//! This reproduces the output of libasdf's `asdf info` byte for byte,
4//! including its ANSI styling and box drawing, so the two tools can be
5//! compared directly and upstream's committed expected-output fixtures serve
6//! as tests.
7
8use core::fmt::Write as _;
9
10use asdf_yaml::{Document, NodeData, NodeId};
11
12use crate::reader::{ChecksumStatus, Reader};
13
14const ANSI_RESET: &str = "\x1b[0m";
15const ANSI_BOLD: &str = "\x1b[1m";
16const ANSI_DIM: &str = "\x1b[2m";
17const COLOR_GREEN: &str = "\x1b[32m";
18const COLOR_RED: &str = "\x1b[31m";
19
20/// Display columns of a scalar shown in the tree preview.
21///
22/// Scalars may be arbitrarily long and may contain newlines; printed verbatim
23/// they would wrap the terminal and break the tree drawing.
24const SCALAR_PREVIEW_MAX: usize = 64;
25
26/// Total width of the block table, including both borders.
27const BOX_WIDTH: usize = 50;
28
29/// What to include in the rendering.
30#[derive(Clone, Copy, Debug)]
31pub struct InfoOptions {
32    /// Render the YAML tree.
33    pub print_tree: bool,
34    /// Render a table for each binary block.
35    pub print_blocks: bool,
36    /// Verify each block's checksum and mark it in the table.
37    pub verify_checksums: bool,
38}
39
40impl Default for InfoOptions {
41    fn default() -> Self {
42        Self { print_tree: true, print_blocks: false, verify_checksums: false }
43    }
44}
45
46/// Which border row to draw.
47#[derive(Clone, Copy)]
48enum Border {
49    Top,
50    Middle,
51    Bottom,
52}
53
54/// How to place a field's text in the box.
55#[derive(Clone, Copy)]
56enum Align {
57    Left,
58    Center,
59}
60
61/// The display width of a string, ignoring ANSI escapes and counting a UTF-8
62/// character as one column.
63fn visible_len(s: &str) -> usize {
64    let bytes = s.as_bytes();
65    let mut len = 0;
66    let mut idx = 0;
67    while idx < bytes.len() {
68        // Skip an escape sequence up to its terminating 'm'.
69        if bytes[idx] == 0x1b && idx + 1 < bytes.len() && bytes[idx + 1] == b'[' {
70            idx += 2;
71            while idx < bytes.len() && bytes[idx] != b'm' {
72                idx += 1;
73            }
74            if idx < bytes.len() {
75                idx += 1;
76            }
77            continue;
78        }
79        // Count leading bytes only, so a multi-byte character is one column.
80        if bytes[idx] & 0xc0 != 0x80 {
81            len += 1;
82        }
83        idx += 1;
84    }
85    len
86}
87
88fn write_border(out: &mut String, border: Border) {
89    out.push_str(ANSI_DIM);
90    let (left, right) = match border {
91        Border::Top => ("┌", "┐"),
92        Border::Middle => ("├", "┤"),
93        Border::Bottom => ("└", "┘"),
94    };
95    out.push_str(left);
96    for _ in 1..BOX_WIDTH - 1 {
97        out.push('─');
98    }
99    out.push_str(right);
100    out.push('\n');
101    out.push_str(ANSI_RESET);
102}
103
104fn write_field(out: &mut String, align: Align, text: &str) {
105    let len = visible_len(text);
106    let _ = write!(out, "{ANSI_DIM}│{ANSI_RESET}");
107    match align {
108        Align::Left => {
109            // One leading space, then pad to the inner width.
110            let pad = BOX_WIDTH.saturating_sub(len + 3);
111            let _ = write!(out, " {text}{:pad$}", "", pad = pad);
112        }
113        Align::Center => {
114            let left = (BOX_WIDTH.saturating_sub(len)) / 2 - 1;
115            let right = BOX_WIDTH.saturating_sub(len + left + 2);
116            let _ = write!(out, "{:left$}{text}{:right$}", "", "", left = left, right = right);
117        }
118    }
119    let _ = writeln!(out, "{ANSI_DIM}│{ANSI_RESET}");
120}
121
122/// A single-line, column-limited preview of a scalar.
123///
124/// Runs of control characters collapse to one space, and the text is cut at
125/// [`SCALAR_PREVIEW_MAX`] columns with an ellipsis.
126fn scalar_preview(value: &str) -> String {
127    let mut out = String::from(": ");
128    let mut cols = 0usize;
129    let mut pending_space = false;
130    let mut any = false;
131
132    for ch in value.chars() {
133        if (ch as u32) < 0x20 || ch as u32 == 0x7f {
134            // A run before any real output is dropped entirely.
135            if any {
136                pending_space = true;
137            }
138            continue;
139        }
140        if cols >= SCALAR_PREVIEW_MAX {
141            out.push_str("...");
142            return out;
143        }
144        if pending_space {
145            out.push(' ');
146            pending_space = false;
147            cols += 1;
148            if cols >= SCALAR_PREVIEW_MAX {
149                // A real character still follows, so content is being dropped.
150                out.push_str("...");
151                return out;
152            }
153        }
154        out.push(ch);
155        cols += 1;
156        any = true;
157    }
158    out
159}
160
161/// The label shown in parentheses after a node's name.
162fn node_label(doc: &Document, id: NodeId) -> String {
163    if let Some(tag) = doc.tag_of(id) {
164        return tag.full();
165    }
166    match &doc.resolved(id).data {
167        NodeData::Mapping { .. } => "mapping".into(),
168        NodeData::Sequence { .. } => "sequence".into(),
169        _ => "scalar".into(),
170    }
171}
172
173/// State carried down the tree walk.
174struct TreeState {
175    /// Whether each ancestor level still has siblings to come, deciding
176    /// between a continuing `│ ` and a blank `  `.
177    active: Vec<bool>,
178    /// The containers currently being rendered, innermost last.
179    ///
180    /// A YAML alias may point back at an ancestor -- `a: &a\n  b: *a` is
181    /// six words and perfectly well-formed -- and following it is infinite
182    /// descent. Recursion is bounded by the stack, and overflowing the
183    /// stack aborts the process; it does not unwind, so the C ABI's panic
184    /// guard cannot catch it either. A node already on the path is rendered
185    /// as the cycle it is and not entered again.
186    path: Vec<NodeId>,
187    /// How much rendered output is still allowed.
188    ///
189    /// Aliases need not be cyclic to be explosive: ten levels of ten-way
190    /// nesting is 10^10 nodes from a few hundred bytes, and the expansion
191    /// is what `asdf info` is *for*, so it cannot simply be refused.
192    /// Legitimate files never come close to this; a bomb stops at it.
193    budget: usize,
194}
195
196/// The rendered size at which tree output is cut short.
197///
198/// 64 MiB is far beyond any real tree -- the largest in the reference corpus
199/// renders in single-digit kilobytes -- and small enough that reaching it
200/// costs a moment rather than the machine.
201const TREE_OUTPUT_BUDGET: usize = 64 << 20;
202
203/// The deepest tree that is rendered, as a second bound independent of the
204/// cycle check: a legitimately deep tree is still a recursion this cannot
205/// afford to follow.
206const TREE_MAX_DEPTH: usize = 256;
207
208fn write_indent(out: &mut String, state: &TreeState, depth: usize, is_leaf: bool) {
209    if depth < 1 {
210        return;
211    }
212    out.push_str(ANSI_DIM);
213    for idx in 0..depth {
214        if idx == depth - 1 {
215            out.push_str(if is_leaf { "└─" } else { "├─" });
216        } else if state.active.get(idx).copied().unwrap_or(false) {
217            out.push_str("│ ");
218        } else {
219            out.push_str("  ");
220        }
221    }
222    out.push_str(ANSI_RESET);
223}
224
225/// How a node is identified by its parent.
226enum NodeIndex<'a> {
227    Key(&'a str),
228    Index(usize),
229}
230
231fn write_node(
232    out: &mut String,
233    doc: &Document,
234    id: NodeId,
235    index: &NodeIndex<'_>,
236    depth: usize,
237    is_leaf: bool,
238    state: &mut TreeState,
239) {
240    let resolved_id = doc.resolve(id);
241
242    if depth > TREE_MAX_DEPTH || state.path.contains(&resolved_id) {
243        write_indent(out, state, depth, is_leaf);
244        let _ = match index {
245            NodeIndex::Key(key) => writeln!(out, "{ANSI_BOLD}{key}{ANSI_RESET} (...)"),
246            NodeIndex::Index(idx) => writeln!(
247                out,
248                "{ANSI_DIM}[{ANSI_RESET}{ANSI_BOLD}{idx}{ANSI_RESET}{ANSI_DIM}]{ANSI_RESET} (...)"
249            ),
250        };
251        return;
252    }
253    if out.len() >= state.budget {
254        return;
255    }
256
257    let label = node_label(doc, id);
258    write_indent(out, state, depth, is_leaf);
259
260    match index {
261        NodeIndex::Key(key) => {
262            let _ = write!(out, "{ANSI_BOLD}{key}{ANSI_RESET} ({label})");
263        }
264        NodeIndex::Index(idx) => {
265            let _ = write!(
266                out,
267                "{ANSI_DIM}[{ANSI_RESET}{ANSI_BOLD}{idx}{ANSI_RESET}{ANSI_DIM}]{ANSI_RESET} ({label})"
268            );
269        }
270    }
271
272    let resolved = resolved_id;
273    let node = doc.node(resolved);
274
275    // A scalar, or an alias to one, ends the line with its value.
276    if !node.is_mapping() && !node.is_sequence() {
277        out.push_str(&scalar_preview(node.as_str().unwrap_or("")));
278        out.push('\n');
279        return;
280    }
281    out.push('\n');
282
283    if state.active.len() <= depth {
284        state.active.resize(depth + 1, false);
285    }
286    state.active[depth] = true;
287    state.path.push(resolved);
288
289    match &node.data {
290        NodeData::Mapping { entries, .. } => {
291            let entries = entries.clone();
292            let last = entries.len().saturating_sub(1);
293            for (position, entry) in entries.iter().enumerate() {
294                let leaf = position == last;
295                if leaf {
296                    state.active[depth] = false;
297                }
298                let key = doc.resolved(entry.key).as_str().unwrap_or("<complex key>").to_string();
299                write_node(out, doc, entry.value, &NodeIndex::Key(&key), depth + 1, leaf, state);
300            }
301        }
302        NodeData::Sequence { items, .. } => {
303            let items = items.clone();
304            let last = items.len().saturating_sub(1);
305            for (position, item) in items.iter().enumerate() {
306                let leaf = position == last;
307                if leaf {
308                    state.active[depth] = false;
309                }
310                write_node(out, doc, *item, &NodeIndex::Index(position), depth + 1, leaf, state);
311            }
312        }
313        _ => {}
314    }
315
316    state.path.pop();
317}
318
319/// Render one block's table.
320fn write_block(out: &mut String, reader: &Reader, index: usize, verify: bool) {
321    let Ok(block) = reader.block(index) else { return };
322    let header = &block.header;
323
324    write_border(out, Border::Top);
325    write_field(out, Align::Center, &format!("Block #{index}"));
326    write_border(out, Border::Middle);
327    write_field(out, Align::Left, &format!("flags: 0x{:08x}", header.flags));
328    write_border(out, Border::Middle);
329
330    // Upstream prints this with `%.*s` over the four-byte field, and printf
331    // stops at the first NUL -- so an uncompressed block shows `""` rather
332    // than four padding bytes.
333    write_field(out, Align::Left, &format!("compression: \"{}\"", header.compression_name()));
334    write_border(out, Border::Middle);
335
336    write_field(out, Align::Left, &format!("allocated_size: {}", header.allocated_size));
337    write_border(out, Border::Middle);
338    write_field(out, Align::Left, &format!("used_size: {}", header.used_size));
339    write_border(out, Border::Middle);
340    write_field(out, Align::Left, &format!("data_size: {}", header.data_size));
341    write_border(out, Border::Middle);
342
343    let checksum: String = header.checksum.iter().map(|b| format!("{b:02x}")).collect();
344    let mark = if verify {
345        match reader.verify_block_checksum(index) {
346            Ok((ChecksumStatus::Valid, _)) => format!(" {COLOR_GREEN}✓{ANSI_RESET}"),
347            Ok((ChecksumStatus::Absent, _)) => String::new(),
348            _ => format!(" {COLOR_RED}✗{ANSI_RESET}"),
349        }
350    } else {
351        String::new()
352    };
353    write_field(out, Align::Left, &format!("checksum: {checksum}{mark}"));
354    write_border(out, Border::Bottom);
355}
356
357/// Render a file's information as a string.
358pub fn render(reader: &Reader, options: InfoOptions) -> crate::Result<String> {
359    let mut out = String::new();
360
361    if options.print_tree
362        && let Some(doc) = reader.tree()?
363        && let Some(root) = doc.root()
364    {
365        let mut state =
366            TreeState { active: vec![false; 16], path: Vec::new(), budget: TREE_OUTPUT_BUDGET };
367        write_node(&mut out, &doc, root, &NodeIndex::Key("root"), 0, true, &mut state);
368    }
369
370    if options.print_blocks {
371        for index in 0..reader.block_count() {
372            write_block(&mut out, reader, index, options.verify_checksums);
373        }
374    }
375    Ok(out)
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    /// Strip ANSI escapes, for assertions about structure rather than styling.
383    fn plain(s: &str) -> String {
384        let mut out = String::new();
385        let bytes = s.as_bytes();
386        let mut idx = 0;
387        while idx < bytes.len() {
388            if bytes[idx] == 0x1b && idx + 1 < bytes.len() && bytes[idx + 1] == b'[' {
389                idx += 2;
390                while idx < bytes.len() && bytes[idx] != b'm' {
391                    idx += 1;
392                }
393                idx += 1;
394                continue;
395            }
396            let start = idx;
397            idx += 1;
398            while idx < bytes.len() && bytes[idx] & 0xc0 == 0x80 {
399                idx += 1;
400            }
401            out.push_str(core::str::from_utf8(&bytes[start..idx]).unwrap_or("?"));
402        }
403        out
404    }
405
406    fn build_file(tree: &str) -> Vec<u8> {
407        let mut buf = Vec::new();
408        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
409        buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
410        buf.extend_from_slice(tree.as_bytes());
411        buf.extend_from_slice(b"...\n");
412        buf
413    }
414
415    #[test]
416    fn visible_len_ignores_escapes_and_counts_characters() {
417        assert_eq!(visible_len("abc"), 3);
418        assert_eq!(visible_len("\x1b[1mabc\x1b[0m"), 3);
419        // A multi-byte character is one column.
420        assert_eq!(visible_len("✓"), 1);
421        assert_eq!(visible_len("\x1b[32m✓\x1b[0m"), 1);
422    }
423
424    #[test]
425    fn box_rows_are_all_the_same_visible_width() {
426        let mut out = String::new();
427        write_border(&mut out, Border::Top);
428        write_field(&mut out, Align::Center, "Block #0");
429        write_border(&mut out, Border::Middle);
430        write_field(&mut out, Align::Left, "flags: 0x00000000");
431        write_border(&mut out, Border::Bottom);
432
433        // Upstream emits the newline *before* the trailing reset, so each
434        // reset lands at the start of the following line. That is part of the
435        // byte-exact output, so the check tolerates it rather than "fixing" it.
436        for line in out.lines() {
437            let line = line.strip_prefix(ANSI_RESET).unwrap_or(line);
438            // The final reset trails the last newline, leaving an empty tail.
439            if line.is_empty() {
440                continue;
441            }
442            assert_eq!(
443                visible_len(line),
444                BOX_WIDTH,
445                "row {line:?} is not {BOX_WIDTH} columns wide"
446            );
447        }
448    }
449
450    #[test]
451    fn scalar_previews_collapse_control_characters() {
452        assert_eq!(scalar_preview("hello"), ": hello");
453        assert_eq!(scalar_preview("a\nb"), ": a b");
454        assert_eq!(scalar_preview("a\n\n\tb"), ": a b");
455        // A leading run is dropped rather than turned into a space.
456        assert_eq!(scalar_preview("\n\nabc"), ": abc");
457    }
458
459    #[test]
460    fn long_scalars_are_truncated() {
461        let long = "x".repeat(100);
462        let preview = scalar_preview(&long);
463        assert!(preview.ends_with("..."));
464        assert_eq!(preview.len(), 2 + SCALAR_PREVIEW_MAX + 3);
465    }
466
467    #[test]
468    fn renders_a_simple_tree() {
469        let file = build_file("a: 1\nb:\n  c: two\n");
470        let reader = Reader::from_bytes(file).unwrap();
471        let out = render(&reader, InfoOptions::default()).unwrap();
472        let text = plain(&out);
473
474        assert!(text.starts_with("root (tag:stsci.edu:asdf/core/asdf-1.1.0)\n"), "{text}");
475        assert!(text.contains("├─a (scalar): 1\n"), "{text}");
476        assert!(text.contains("└─b (mapping)\n"), "{text}");
477        // The last child of the last child uses the corner and a blank
478        // continuation from its parent.
479        assert!(text.contains("  └─c (scalar): two\n"), "{text}");
480    }
481
482    #[test]
483    fn continuation_bars_track_remaining_siblings() {
484        let file = build_file("a:\n  x: 1\n  y: 2\nb: 3\n");
485        let reader = Reader::from_bytes(file).unwrap();
486        let text = plain(&render(&reader, InfoOptions::default()).unwrap());
487
488        // `a` still has sibling `b` to come, so its children carry `│ `.
489        assert!(text.contains("│ ├─x (scalar): 1\n"), "{text}");
490        assert!(text.contains("│ └─y (scalar): 2\n"), "{text}");
491        assert!(text.contains("└─b (scalar): 3\n"), "{text}");
492    }
493
494    #[test]
495    fn sequences_are_indexed() {
496        let file = build_file("s: [10, 20]\n");
497        let reader = Reader::from_bytes(file).unwrap();
498        let text = plain(&render(&reader, InfoOptions::default()).unwrap());
499        assert!(text.contains("├─[0] (scalar): 10\n"), "{text}");
500        assert!(text.contains("└─[1] (scalar): 20\n"), "{text}");
501    }
502
503    #[test]
504    fn tagged_nodes_show_their_tag() {
505        let file = build_file("d: !core/ndarray-1.1.0\n  source: 0\n");
506        let reader = Reader::from_bytes(file).unwrap();
507        let text = plain(&render(&reader, InfoOptions::default()).unwrap());
508        assert!(text.contains("d (tag:stsci.edu:asdf/core/ndarray-1.1.0)"), "{text}");
509    }
510
511    #[test]
512    fn the_tree_can_be_suppressed() {
513        let file = build_file("a: 1\n");
514        let reader = Reader::from_bytes(file).unwrap();
515        let options = InfoOptions { print_tree: false, ..Default::default() };
516        assert!(render(&reader, options).unwrap().is_empty());
517    }
518}