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 std::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}
179
180fn write_indent(out: &mut String, state: &TreeState, depth: usize, is_leaf: bool) {
181    if depth < 1 {
182        return;
183    }
184    out.push_str(ANSI_DIM);
185    for idx in 0..depth {
186        if idx == depth - 1 {
187            out.push_str(if is_leaf { "└─" } else { "├─" });
188        } else if state.active.get(idx).copied().unwrap_or(false) {
189            out.push_str("│ ");
190        } else {
191            out.push_str("  ");
192        }
193    }
194    out.push_str(ANSI_RESET);
195}
196
197/// How a node is identified by its parent.
198enum NodeIndex<'a> {
199    Key(&'a str),
200    Index(usize),
201}
202
203fn write_node(
204    out: &mut String,
205    doc: &Document,
206    id: NodeId,
207    index: &NodeIndex<'_>,
208    depth: usize,
209    is_leaf: bool,
210    state: &mut TreeState,
211) {
212    let label = node_label(doc, id);
213    write_indent(out, state, depth, is_leaf);
214
215    match index {
216        NodeIndex::Key(key) => {
217            let _ = write!(out, "{ANSI_BOLD}{key}{ANSI_RESET} ({label})");
218        }
219        NodeIndex::Index(idx) => {
220            let _ = write!(
221                out,
222                "{ANSI_DIM}[{ANSI_RESET}{ANSI_BOLD}{idx}{ANSI_RESET}{ANSI_DIM}]{ANSI_RESET} ({label})"
223            );
224        }
225    }
226
227    let resolved = doc.resolve(id);
228    let node = doc.node(resolved);
229
230    // A scalar, or an alias to one, ends the line with its value.
231    if !node.is_mapping() && !node.is_sequence() {
232        out.push_str(&scalar_preview(node.as_str().unwrap_or("")));
233        out.push('\n');
234        return;
235    }
236    out.push('\n');
237
238    if state.active.len() <= depth {
239        state.active.resize(depth + 1, false);
240    }
241    state.active[depth] = true;
242
243    match &node.data {
244        NodeData::Mapping { entries, .. } => {
245            let entries = entries.clone();
246            let last = entries.len().saturating_sub(1);
247            for (position, entry) in entries.iter().enumerate() {
248                let leaf = position == last;
249                if leaf {
250                    state.active[depth] = false;
251                }
252                let key = doc.resolved(entry.key).as_str().unwrap_or("<complex key>").to_string();
253                write_node(out, doc, entry.value, &NodeIndex::Key(&key), depth + 1, leaf, state);
254            }
255        }
256        NodeData::Sequence { items, .. } => {
257            let items = items.clone();
258            let last = items.len().saturating_sub(1);
259            for (position, item) in items.iter().enumerate() {
260                let leaf = position == last;
261                if leaf {
262                    state.active[depth] = false;
263                }
264                write_node(out, doc, *item, &NodeIndex::Index(position), depth + 1, leaf, state);
265            }
266        }
267        _ => {}
268    }
269}
270
271/// Render one block's table.
272fn write_block(out: &mut String, reader: &Reader, index: usize, verify: bool) {
273    let Ok(block) = reader.block(index) else { return };
274    let header = &block.header;
275
276    write_border(out, Border::Top);
277    write_field(out, Align::Center, &format!("Block #{index}"));
278    write_border(out, Border::Middle);
279    write_field(out, Align::Left, &format!("flags: 0x{:08x}", header.flags));
280    write_border(out, Border::Middle);
281
282    // Upstream prints this with `%.*s` over the four-byte field, and printf
283    // stops at the first NUL -- so an uncompressed block shows `""` rather
284    // than four padding bytes.
285    write_field(out, Align::Left, &format!("compression: \"{}\"", header.compression_name()));
286    write_border(out, Border::Middle);
287
288    write_field(out, Align::Left, &format!("allocated_size: {}", header.allocated_size));
289    write_border(out, Border::Middle);
290    write_field(out, Align::Left, &format!("used_size: {}", header.used_size));
291    write_border(out, Border::Middle);
292    write_field(out, Align::Left, &format!("data_size: {}", header.data_size));
293    write_border(out, Border::Middle);
294
295    let checksum: String = header.checksum.iter().map(|b| format!("{b:02x}")).collect();
296    let mark = if verify {
297        match reader.verify_block_checksum(index) {
298            Ok((ChecksumStatus::Valid, _)) => format!(" {COLOR_GREEN}✓{ANSI_RESET}"),
299            Ok((ChecksumStatus::Absent, _)) => String::new(),
300            _ => format!(" {COLOR_RED}✗{ANSI_RESET}"),
301        }
302    } else {
303        String::new()
304    };
305    write_field(out, Align::Left, &format!("checksum: {checksum}{mark}"));
306    write_border(out, Border::Bottom);
307}
308
309/// Render a file's information as a string.
310pub fn render(reader: &Reader, options: InfoOptions) -> crate::Result<String> {
311    let mut out = String::new();
312
313    if options.print_tree
314        && let Some(doc) = reader.tree()?
315        && let Some(root) = doc.root()
316    {
317        let mut state = TreeState { active: vec![false; 16] };
318        write_node(&mut out, &doc, root, &NodeIndex::Key("root"), 0, true, &mut state);
319    }
320
321    if options.print_blocks {
322        for index in 0..reader.block_count() {
323            write_block(&mut out, reader, index, options.verify_checksums);
324        }
325    }
326    Ok(out)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    /// Strip ANSI escapes, for assertions about structure rather than styling.
334    fn plain(s: &str) -> String {
335        let mut out = String::new();
336        let bytes = s.as_bytes();
337        let mut idx = 0;
338        while idx < bytes.len() {
339            if bytes[idx] == 0x1b && idx + 1 < bytes.len() && bytes[idx + 1] == b'[' {
340                idx += 2;
341                while idx < bytes.len() && bytes[idx] != b'm' {
342                    idx += 1;
343                }
344                idx += 1;
345                continue;
346            }
347            let start = idx;
348            idx += 1;
349            while idx < bytes.len() && bytes[idx] & 0xc0 == 0x80 {
350                idx += 1;
351            }
352            out.push_str(std::str::from_utf8(&bytes[start..idx]).unwrap_or("?"));
353        }
354        out
355    }
356
357    fn build_file(tree: &str) -> Vec<u8> {
358        let mut buf = Vec::new();
359        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
360        buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
361        buf.extend_from_slice(tree.as_bytes());
362        buf.extend_from_slice(b"...\n");
363        buf
364    }
365
366    #[test]
367    fn visible_len_ignores_escapes_and_counts_characters() {
368        assert_eq!(visible_len("abc"), 3);
369        assert_eq!(visible_len("\x1b[1mabc\x1b[0m"), 3);
370        // A multi-byte character is one column.
371        assert_eq!(visible_len("✓"), 1);
372        assert_eq!(visible_len("\x1b[32m✓\x1b[0m"), 1);
373    }
374
375    #[test]
376    fn box_rows_are_all_the_same_visible_width() {
377        let mut out = String::new();
378        write_border(&mut out, Border::Top);
379        write_field(&mut out, Align::Center, "Block #0");
380        write_border(&mut out, Border::Middle);
381        write_field(&mut out, Align::Left, "flags: 0x00000000");
382        write_border(&mut out, Border::Bottom);
383
384        // Upstream emits the newline *before* the trailing reset, so each
385        // reset lands at the start of the following line. That is part of the
386        // byte-exact output, so the check tolerates it rather than "fixing" it.
387        for line in out.lines() {
388            let line = line.strip_prefix(ANSI_RESET).unwrap_or(line);
389            // The final reset trails the last newline, leaving an empty tail.
390            if line.is_empty() {
391                continue;
392            }
393            assert_eq!(
394                visible_len(line),
395                BOX_WIDTH,
396                "row {line:?} is not {BOX_WIDTH} columns wide"
397            );
398        }
399    }
400
401    #[test]
402    fn scalar_previews_collapse_control_characters() {
403        assert_eq!(scalar_preview("hello"), ": hello");
404        assert_eq!(scalar_preview("a\nb"), ": a b");
405        assert_eq!(scalar_preview("a\n\n\tb"), ": a b");
406        // A leading run is dropped rather than turned into a space.
407        assert_eq!(scalar_preview("\n\nabc"), ": abc");
408    }
409
410    #[test]
411    fn long_scalars_are_truncated() {
412        let long = "x".repeat(100);
413        let preview = scalar_preview(&long);
414        assert!(preview.ends_with("..."));
415        assert_eq!(preview.len(), 2 + SCALAR_PREVIEW_MAX + 3);
416    }
417
418    #[test]
419    fn renders_a_simple_tree() {
420        let file = build_file("a: 1\nb:\n  c: two\n");
421        let reader = Reader::from_bytes(file).unwrap();
422        let out = render(&reader, InfoOptions::default()).unwrap();
423        let text = plain(&out);
424
425        assert!(text.starts_with("root (tag:stsci.edu:asdf/core/asdf-1.1.0)\n"), "{text}");
426        assert!(text.contains("├─a (scalar): 1\n"), "{text}");
427        assert!(text.contains("└─b (mapping)\n"), "{text}");
428        // The last child of the last child uses the corner and a blank
429        // continuation from its parent.
430        assert!(text.contains("  └─c (scalar): two\n"), "{text}");
431    }
432
433    #[test]
434    fn continuation_bars_track_remaining_siblings() {
435        let file = build_file("a:\n  x: 1\n  y: 2\nb: 3\n");
436        let reader = Reader::from_bytes(file).unwrap();
437        let text = plain(&render(&reader, InfoOptions::default()).unwrap());
438
439        // `a` still has sibling `b` to come, so its children carry `│ `.
440        assert!(text.contains("│ ├─x (scalar): 1\n"), "{text}");
441        assert!(text.contains("│ └─y (scalar): 2\n"), "{text}");
442        assert!(text.contains("└─b (scalar): 3\n"), "{text}");
443    }
444
445    #[test]
446    fn sequences_are_indexed() {
447        let file = build_file("s: [10, 20]\n");
448        let reader = Reader::from_bytes(file).unwrap();
449        let text = plain(&render(&reader, InfoOptions::default()).unwrap());
450        assert!(text.contains("├─[0] (scalar): 10\n"), "{text}");
451        assert!(text.contains("└─[1] (scalar): 20\n"), "{text}");
452    }
453
454    #[test]
455    fn tagged_nodes_show_their_tag() {
456        let file = build_file("d: !core/ndarray-1.1.0\n  source: 0\n");
457        let reader = Reader::from_bytes(file).unwrap();
458        let text = plain(&render(&reader, InfoOptions::default()).unwrap());
459        assert!(text.contains("d (tag:stsci.edu:asdf/core/ndarray-1.1.0)"), "{text}");
460    }
461
462    #[test]
463    fn the_tree_can_be_suppressed() {
464        let file = build_file("a: 1\n");
465        let reader = Reader::from_bytes(file).unwrap();
466        let options = InfoOptions { print_tree: false, ..Default::default() };
467        assert!(render(&reader, options).unwrap().is_empty());
468    }
469}