Skip to main content

m2_syn/
pretty.rs

1//! Terminal-oriented inspection of source, raw tokens, and typed syntax trees.
2//!
3//! [`PrettyReport`] combines the three useful views of a parsed file. The
4//! renderer deliberately owns only presentation data; the generated typed CST
5//! remains the source of truth and implements [`PrettyTree`] directly.
6
7use std::fmt::{Display, Formatter, Result as FmtResult, Write as FmtWrite};
8use std::io::{self, IsTerminal as _, Write as IoWrite};
9
10use crate::{
11    CommentTrivia, DelimiterKind, DoubleSpan, Punctuated, Span, Spanned, TokenStream, TokenTree,
12    Trivia, TriviaKind, token_stream::delim::Delimiter,
13};
14
15const RESET: &str = "\u{1b}[0m";
16const BOLD_CYAN: &str = "\u{1b}[1;36m";
17const BRIGHT_YELLOW: &str = "\u{1b}[93m";
18const BRIGHT_BLACK: &str = "\u{1b}[90m";
19const RED: &str = "\u{1b}[31m";
20const GREEN: &str = "\u{1b}[32m";
21const YELLOW: &str = "\u{1b}[33m";
22const BLUE: &str = "\u{1b}[34m";
23const MAGENTA: &str = "\u{1b}[35m";
24const CYAN: &str = "\u{1b}[36m";
25const WHITE: &str = "\u{1b}[37m";
26const NON_TERMINAL_BLUE: &str = BLUE;
27const DIM: &str = "\u{1b}[2m";
28
29/// Converts typed syntax into the structural presentation used by the pretty
30/// printer.
31///
32/// Implementations for generated nodes are emitted from `syntax!`, so
33/// field names, punctuation, and newly added concrete node types appear in the
34/// tree without a handwritten registry. Category enums are intentionally
35/// transparent because they are storage details rather than CST nodes.
36pub trait PrettyTree {
37    /// Builds the presentation node for this syntax value.
38    fn pretty_tree(&self) -> PrettyNode;
39}
40
41/// One owned node in a syntax presentation tree.
42///
43/// The structure is independent of terminal layout. External renderers can
44/// recursively inspect [`Self::children`], use [`Self::role`] and
45/// [`PrettyDetail::role`] for styling, and apply their own width and glyph
46/// policies.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct PrettyNode {
49    name: String,
50    detail: Option<PrettyDetail>,
51    span: Option<Span>,
52    role: PrettyNodeRole,
53    children: Vec<PrettyEdge>,
54}
55
56/// Additional text displayed alongside a [`PrettyNode`].
57///
58/// Token nodes use details for their source spelling, while virtual syntax
59/// elements use them for their conventional display glyph.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct PrettyDetail {
62    text: String,
63    role: PrettyDetailRole,
64}
65
66/// The semantic role of a [`PrettyDetail`].
67///
68/// Renderers can use this role to select their own highlight group without
69/// depending on the built-in ANSI palette.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum PrettyDetailRole {
72    /// Source text belonging to a lexical token.
73    Text,
74    /// A conventional glyph representing a virtual syntax element.
75    Glyph,
76}
77
78/// The semantic role of a [`PrettyNode`].
79///
80/// This is presentation metadata rather than a replacement for the generated
81/// typed syntax hierarchy. It lets external renderers distinguish structural
82/// nodes, lexical leaves, collection wrappers, and absent optional values.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum PrettyNodeRole {
85    /// A concrete node from the generated typed syntax hierarchy.
86    Syntax,
87    /// A lexical token or virtual syntax leaf.
88    Token,
89    /// A transparent syntax container such as a list or delimiter pair.
90    Collection,
91    /// An optional syntax value that is not present.
92    Absent,
93}
94
95/// A labeled parent-to-child edge in a [`PrettyNode`] tree.
96///
97/// The label is normally the generated field name or the index of an element
98/// in a collection.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct PrettyEdge {
101    label: String,
102    node: PrettyNode,
103}
104
105impl PrettyDetail {
106    /// Returns the text carried by this detail.
107    pub fn text(&self) -> &str {
108        &self.text
109    }
110
111    /// Returns the semantic role of this detail.
112    pub fn role(&self) -> PrettyDetailRole {
113        self.role
114    }
115}
116
117impl PrettyEdge {
118    /// Returns the field name or collection index labeling this edge.
119    pub fn label(&self) -> &str {
120        &self.label
121    }
122
123    /// Returns the child presentation node.
124    pub fn node(&self) -> &PrettyNode {
125        &self.node
126    }
127}
128
129impl PrettyNode {
130    /// Returns the display name of this node.
131    pub fn name(&self) -> &str {
132        &self.name
133    }
134
135    /// Returns the optional token spelling or virtual-element glyph.
136    pub fn detail(&self) -> Option<&PrettyDetail> {
137        self.detail.as_ref()
138    }
139
140    /// Returns the source span represented by this node, when available.
141    pub fn span(&self) -> Option<&Span> {
142        self.span.as_ref()
143    }
144
145    /// Returns the semantic presentation role of this node.
146    pub fn role(&self) -> PrettyNodeRole {
147        self.role
148    }
149
150    /// Returns the labeled children in source-tree order.
151    pub fn children(&self) -> &[PrettyEdge] {
152        &self.children
153    }
154
155    /// Creates a generated concrete syntax node.
156    #[doc(hidden)]
157    pub fn syntax(name: impl Into<String>, span: Option<Span>) -> Self {
158        Self::new(name, span, PrettyNodeRole::Syntax)
159    }
160
161    /// Creates a generated enum-variant node.
162    #[doc(hidden)]
163    pub fn variant(
164        _category: &str,
165        _variant: &str,
166        span: Option<Span>,
167        mut value: PrettyNode,
168    ) -> Self {
169        value.span = span;
170        value
171    }
172
173    /// Creates a delimiter collection with explicit opening and closing boundaries.
174    #[doc(hidden)]
175    pub fn delimiter(kind: DelimiterKind, spans: DoubleSpan) -> Self {
176        let delimiter = Delimiter::new(kind, spans);
177        let mut node = Self::collection(
178            format!("{}…{} · 2 tokens", kind.opening(), kind.closing()),
179            delimiter.try_span(),
180        );
181        node.push_node(
182            "opening",
183            Self::token("Token", kind.opening(), delimiter.opening_span()),
184        );
185        if let Some(closing) = delimiter.closing_span() {
186            node.push_node("closing", Self::token("Token", kind.closing(), closing));
187        }
188        node
189    }
190
191    /// Creates a leaf containing token or source text.
192    #[doc(hidden)]
193    pub fn token(name: impl Into<String>, text: impl Into<String>, span: Span) -> Self {
194        let name = name.into();
195        let text = text.into();
196        if name == "Delimiter"
197            && let Some(kind) = delimiter_notation(&text)
198        {
199            return Self::delimiter(kind, DoubleSpan::new(span.clone(), span));
200        }
201        let role = if matches!(text.as_str(), "∅" | "EOC" | "EOF") {
202            PrettyDetailRole::Glyph
203        } else {
204            PrettyDetailRole::Text
205        };
206        let mut node = Self::new(name, Some(span), PrettyNodeRole::Token);
207        node.detail = Some(PrettyDetail { text, role });
208        node
209    }
210
211    /// Creates a collection wrapper.
212    #[doc(hidden)]
213    pub fn collection(name: impl Into<String>, span: Option<Span>) -> Self {
214        Self::new(name, span, PrettyNodeRole::Collection)
215    }
216
217    /// Creates an absent optional value.
218    #[doc(hidden)]
219    pub fn absent() -> Self {
220        Self::new("None", None, PrettyNodeRole::Absent)
221    }
222
223    fn new(name: impl Into<String>, span: Option<Span>, role: PrettyNodeRole) -> Self {
224        Self {
225            name: name.into(),
226            detail: None,
227            span,
228            role,
229            children: Vec::new(),
230        }
231    }
232
233    /// Adds a named child to this node.
234    #[doc(hidden)]
235    pub fn push(&mut self, label: impl Into<String>, child: impl PrettyTree) {
236        self.push_node(label, child.pretty_tree());
237    }
238
239    /// Adds an already constructed named child to this node.
240    #[doc(hidden)]
241    pub fn push_node(&mut self, label: impl Into<String>, node: PrettyNode) {
242        let mut node = node;
243        if node.span.is_none() {
244            node.span.clone_from(&self.span);
245        }
246        self.children.push(PrettyEdge {
247            label: label.into(),
248            node,
249        });
250    }
251}
252
253fn delimiter_notation(notation: &str) -> Option<DelimiterKind> {
254    match notation {
255        "(…)" => Some(DelimiterKind::Parenthesis),
256        "[…]" => Some(DelimiterKind::Bracket),
257        "{…}" => Some(DelimiterKind::Brace),
258        "<|…|>" => Some(DelimiterKind::AngleBar),
259        _ => None,
260    }
261}
262
263impl<T: PrettyTree + ?Sized> PrettyTree for &T {
264    fn pretty_tree(&self) -> PrettyNode {
265        T::pretty_tree(self)
266    }
267}
268
269impl<T: PrettyTree> PrettyTree for Box<T> {
270    fn pretty_tree(&self) -> PrettyNode {
271        T::pretty_tree(self)
272    }
273}
274
275impl<T: PrettyTree + Spanned> PrettyTree for Option<T> {
276    fn pretty_tree(&self) -> PrettyNode {
277        match self {
278            Some(value) => value.pretty_tree(),
279            None => PrettyNode::absent(),
280        }
281    }
282}
283
284impl<T: PrettyTree + Spanned> PrettyTree for Vec<T> {
285    fn pretty_tree(&self) -> PrettyNode {
286        let span = Span::join_all(self.iter().map(Spanned::span));
287        let mut node = PrettyNode::collection(item_count("List", self.len()), span);
288        for (index, value) in self.iter().enumerate() {
289            node.push(format!("[{index}]"), value);
290        }
291        node
292    }
293}
294
295impl PrettyTree for Trivia {
296    fn pretty_tree(&self) -> PrettyNode {
297        let name = match self.kind() {
298            TriviaKind::LineComment => "LineCommentBlock",
299            TriviaKind::BlockComment => "BlockComment",
300        };
301        PrettyNode::token(name, self.text(), self.span())
302    }
303}
304
305impl PrettyTree for CommentTrivia {
306    fn pretty_tree(&self) -> PrettyNode {
307        let count = self.outer().len() + self.inner().len();
308        let mut node = PrettyNode::collection(item_count("Comments", count), self.try_span());
309        for (index, comment) in self.outer().iter().enumerate() {
310            node.push(format!("outer[{index}]"), comment);
311        }
312        for (index, comment) in self.inner().iter().enumerate() {
313            node.push(format!("inner[{index}]"), comment);
314        }
315        node
316    }
317}
318
319impl<T, P> PrettyTree for Punctuated<T, P>
320where
321    T: PrettyTree + Spanned,
322    P: PrettyTree + Spanned,
323{
324    fn pretty_tree(&self) -> PrettyNode {
325        let mut node =
326            PrettyNode::collection(item_count("Punctuated", self.len()), self.try_span());
327        node.push("[0]", self.first());
328        for (index, (punctuation, value)) in self.rest().iter().enumerate() {
329            node.push("punctuation", punctuation);
330            node.push(format!("[{}]", index + 1), value);
331        }
332        node
333    }
334}
335
336fn item_count(name: &str, count: usize) -> String {
337    let suffix = if count == 1 { "item" } else { "items" };
338    format!("{name} · {count} {suffix}")
339}
340
341/// Formatting choices shared by token, CST, and combined reports.
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub struct PrettyOptions {
344    /// Whether to emit ANSI color escapes.
345    pub color: bool,
346    /// Whether to display source spans.
347    pub spans: bool,
348    /// Whether to include trivia in raw token views.
349    pub trivia: bool,
350    /// The preferred output width.
351    pub width: usize,
352}
353
354impl PrettyOptions {
355    /// Plain, deterministic output suitable for tests and redirected output.
356    pub const PLAIN: Self = Self {
357        color: false,
358        spans: true,
359        trivia: false,
360        width: 150,
361    };
362
363    /// ANSI-colored output intended for an interactive terminal.
364    pub const ANSI: Self = Self {
365        color: true,
366        spans: true,
367        trivia: false,
368        width: 150,
369    };
370}
371
372impl Default for PrettyOptions {
373    fn default() -> Self {
374        Self::PLAIN
375    }
376}
377
378/// A displayable three-part inspection report for one parsed source file.
379///
380/// The report shows the retained input snapshot, a recursively flattened raw
381/// token table, and the complete generated typed CST. Use [`Self::ansi`] for
382/// explicit terminal color or [`crate::ParsedFile::print_pretty`] for automatic
383/// terminal detection.
384pub struct PrettyReport<'source> {
385    source: &'source str,
386    tokens: TokenStream,
387    cst: PrettyNode,
388    options: PrettyOptions,
389}
390
391impl<'source> PrettyReport<'source> {
392    /// Creates a plain report from its three projections.
393    #[doc(hidden)]
394    pub fn new(source: &'source str, tokens: TokenStream, cst: PrettyNode) -> Self {
395        Self {
396            source,
397            tokens,
398            cst,
399            options: PrettyOptions::PLAIN,
400        }
401    }
402
403    /// Enables or disables ANSI terminal styling.
404    pub fn ansi(mut self, enabled: bool) -> Self {
405        self.options.color = enabled;
406        self
407    }
408
409    /// Enables or disables source ranges.
410    pub fn spans(mut self, enabled: bool) -> Self {
411        self.options.spans = enabled;
412        self
413    }
414
415    /// Includes or hides coalesced trivia in the flat token view.
416    pub fn trivia(mut self, enabled: bool) -> Self {
417        self.options.trivia = enabled;
418        self
419    }
420
421    /// Sets the preferred report width used to wrap horizontal token bands.
422    pub fn width(mut self, width: usize) -> Self {
423        self.options.width = width.max(40);
424        self
425    }
426}
427
428impl Display for PrettyReport<'_> {
429    fn fmt(&self, output: &mut Formatter<'_>) -> FmtResult {
430        let mut source = String::new();
431        write_source(&mut source, self.source)?;
432        write_box(output, "SOURCE", &source, self.options)?;
433        writeln!(output)?;
434
435        let mut tokens = String::new();
436        write_tokens(&mut tokens, &self.tokens, self.options)?;
437        write_box(output, "TOKEN STREAM", &tokens, self.options)?;
438        writeln!(output)?;
439
440        let mut cst = String::new();
441        write_tree(&mut cst, &self.cst, self.options)?;
442        write_box(output, "TYPED CST", &cst, self.options)
443    }
444}
445
446pub(crate) fn print_report(report: PrettyReport<'_>) -> io::Result<()> {
447    let mut output = io::stdout().lock();
448    let width = std::env::var("COLUMNS")
449        .ok()
450        .and_then(|width| width.parse().ok())
451        .unwrap_or(120);
452    let report = report.ansi(output.is_terminal()).width(width);
453    write!(output, "{report}")
454}
455
456pub(crate) fn format_tree(node: &PrettyNode, options: PrettyOptions) -> String {
457    let mut output = String::new();
458    write_tree(&mut output, node, options).expect("writing to a string cannot fail");
459    output
460}
461
462/// Formats one typed syntax value as a Unicode tree without terminal colors.
463pub fn format_pretty_tree(value: &impl PrettyTree) -> String {
464    format_tree(&value.pretty_tree(), PrettyOptions::PLAIN)
465}
466
467fn write_box(
468    output: &mut impl FmtWrite,
469    title: &str,
470    contents: &str,
471    options: PrettyOptions,
472) -> FmtResult {
473    let inner_width = options.width.saturating_sub(4);
474    writeln!(
475        output,
476        "{}{}{}{}",
477        paint(DIM, "╭─ ", options),
478        paint(BOLD_CYAN, title, options),
479        paint(
480            DIM,
481            &format!(
482                " {}",
483                "─".repeat(options.width.saturating_sub(title.chars().count() + 5))
484            ),
485            options,
486        ),
487        paint(DIM, "╮", options),
488    )?;
489    for line in contents.lines() {
490        let line = clip_ansi(line, inner_width);
491        let line_width = ansi_width(&line);
492        writeln!(
493            output,
494            "{} {}{} {}",
495            paint(DIM, "│", options),
496            line,
497            " ".repeat(inner_width.saturating_sub(line_width)),
498            paint(DIM, "│", options),
499        )?;
500    }
501    writeln!(
502        output,
503        "{}{}{}",
504        paint(DIM, "╰", options),
505        paint(DIM, &"─".repeat(options.width.saturating_sub(2)), options),
506        paint(DIM, "╯", options),
507    )
508}
509
510fn ansi_width(text: &str) -> usize {
511    let mut characters = text.chars().peekable();
512    let mut width = 0;
513    while let Some(character) = characters.next() {
514        if character == '\u{1b}' && characters.peek() == Some(&'[') {
515            for escaped in characters.by_ref() {
516                if escaped == 'm' {
517                    break;
518                }
519            }
520        } else {
521            width += 1;
522        }
523    }
524    width
525}
526
527fn clip_ansi(text: &str, width: usize) -> String {
528    if ansi_width(text) <= width {
529        return text.to_owned();
530    }
531
532    let limit = width.saturating_sub(1);
533    let mut output = String::new();
534    let mut characters = text.chars().peekable();
535    let mut visible = 0;
536    let mut styled = false;
537    while let Some(character) = characters.next() {
538        if character == '\u{1b}' && characters.peek() == Some(&'[') {
539            styled = true;
540            output.push(character);
541            for escaped in characters.by_ref() {
542                output.push(escaped);
543                if escaped == 'm' {
544                    break;
545                }
546            }
547        } else if visible < limit {
548            output.push(character);
549            visible += 1;
550        } else {
551            break;
552        }
553    }
554    output.push('…');
555    if styled {
556        output.push_str(RESET);
557    }
558    output
559}
560
561fn write_source(output: &mut impl FmtWrite, source: &str) -> FmtResult {
562    let width = source.lines().count().max(1).to_string().len();
563    if source.is_empty() {
564        writeln!(output, "∅")?;
565    } else {
566        for (index, line) in source.lines().enumerate() {
567            writeln!(output, "{:>width$} │ {}", index + 1, line)?;
568        }
569    }
570    Ok(())
571}
572
573#[derive(Clone, Copy)]
574enum FlatKind {
575    Adj,
576    Open,
577    Close,
578    Ident,
579    Literal,
580    Punct,
581    Trivia,
582}
583
584#[derive(Clone)]
585struct FlatToken {
586    depth: usize,
587    kind: FlatKind,
588    text: String,
589    span: Span,
590}
591
592pub(crate) fn format_tokens(tokens: &TokenStream, options: PrettyOptions) -> String {
593    let mut output = String::new();
594    write_tokens(&mut output, tokens, options).expect("writing to a string cannot fail");
595    output
596}
597
598/// Formats a raw token stream as a table with explicit group boundaries.
599pub fn format_pretty_tokens(tokens: &TokenStream) -> String {
600    format_tokens(tokens, PrettyOptions::PLAIN)
601}
602
603fn write_tokens(
604    output: &mut impl FmtWrite,
605    tokens: &TokenStream,
606    options: PrettyOptions,
607) -> FmtResult {
608    let mut flat = Vec::new();
609    flatten(tokens, 0, &mut flat);
610    write_flat(output, flat, options)
611}
612
613fn write_flat(
614    output: &mut impl FmtWrite,
615    mut flat: Vec<FlatToken>,
616    options: PrettyOptions,
617) -> FmtResult {
618    if !options.trivia {
619        flat.retain(|token| !matches!(token.kind, FlatKind::Trivia));
620    }
621    if flat.is_empty() {
622        return writeln!(output, "{}", paint(DIM, "∅", options));
623    }
624    let available = options.width.saturating_sub(12).max(24);
625    let mut band = Vec::new();
626    let mut used = 0;
627    let mut wrote_band = false;
628    for chunk in flat.iter().map(std::slice::from_ref) {
629        let chunk_width = token_band_width(chunk);
630        if !band.is_empty() && used + 1 + chunk_width > available {
631            if wrote_band {
632                writeln!(output)?;
633            }
634            write_token_band(output, &band, options)?;
635            wrote_band = true;
636            band.clear();
637            used = 0;
638        }
639        if !band.is_empty() {
640            used += 1;
641        }
642        used += chunk_width;
643        band.extend_from_slice(chunk);
644    }
645    if !band.is_empty() {
646        if wrote_band {
647            writeln!(output)?;
648        }
649        write_token_band(output, &band, options)?;
650    }
651    Ok(())
652}
653
654fn token_band_width(tokens: &[FlatToken]) -> usize {
655    tokens.iter().map(token_column_width).sum::<usize>() + tokens.len().saturating_sub(1)
656}
657
658fn token_column_width(token: &FlatToken) -> usize {
659    visible_text(&token.text)
660        .chars()
661        .count()
662        .max(flat_kind(token.kind).chars().count())
663        .clamp(3, 24)
664        + 2
665}
666
667fn write_token_band(
668    output: &mut impl FmtWrite,
669    tokens: &[FlatToken],
670    options: PrettyOptions,
671) -> FmtResult {
672    write!(
673        output,
674        "{} {} ",
675        paint(BRIGHT_YELLOW, "token", options),
676        paint(DIM, "│", options)
677    )?;
678    for (index, token) in tokens.iter().enumerate() {
679        if index != 0 {
680            write!(output, "{}", paint(DIM, "│", options))?;
681        }
682        let width = token_column_width(token) - 2;
683        let text = clipped(&visible_text(&token.text), width);
684        let cell = centered_cell(&text, width);
685        write!(output, "{}", paint(token_color(token.kind), &cell, options))?;
686    }
687    writeln!(output)?;
688
689    write!(
690        output,
691        "{} {} ",
692        paint(BRIGHT_YELLOW, " type", options),
693        paint(DIM, "│", options)
694    )?;
695    for (index, token) in tokens.iter().enumerate() {
696        if index != 0 {
697            write!(output, "{}", paint(DIM, "│", options))?;
698        }
699        let width = token_column_width(token) - 2;
700        let kind = clipped(flat_kind(token.kind), width);
701        let cell = centered_cell(&kind, width);
702        write!(output, "{}", paint(token_color(token.kind), &cell, options))?;
703    }
704    writeln!(output)
705}
706
707fn centered_cell(text: &str, width: usize) -> String {
708    let padding = width.saturating_sub(text.chars().count());
709    let left = padding / 2;
710    let right = padding - left;
711    format!(" {}{}{} ", " ".repeat(left), text, " ".repeat(right))
712}
713
714fn clipped(text: &str, width: usize) -> String {
715    let count = text.chars().count();
716    if count <= width {
717        return text.to_owned();
718    }
719    text.chars()
720        .take(width.saturating_sub(1))
721        .chain(std::iter::once('…'))
722        .collect()
723}
724
725fn flatten(tokens: &TokenStream, depth: usize, output: &mut Vec<FlatToken>) {
726    flatten_trivia(tokens.prefix_trivia(), depth, output);
727    for token in tokens.iter() {
728        flatten_trivia(token.leading_trivia().as_slice(), depth, output);
729        match token {
730            TokenTree::Group(group) => {
731                let kind = group.delim_kind();
732                push_flat(
733                    output,
734                    FlatToken {
735                        depth,
736                        kind: FlatKind::Open,
737                        text: kind.opening().into(),
738                        span: group.delimiter().opening_span(),
739                    },
740                );
741                flatten(group.stream(), depth + 1, output);
742                if let Some(closing) = group.delimiter().closing_span() {
743                    push_flat(
744                        output,
745                        FlatToken {
746                            depth,
747                            kind: FlatKind::Close,
748                            text: kind.closing().into(),
749                            span: closing,
750                        },
751                    );
752                }
753            }
754            TokenTree::Ident(token) => push_flat(
755                output,
756                FlatToken {
757                    depth,
758                    kind: FlatKind::Ident,
759                    text: token.text().into(),
760                    span: token.expect_span(),
761                },
762            ),
763            TokenTree::Literal(token) => push_flat(
764                output,
765                FlatToken {
766                    depth,
767                    kind: FlatKind::Literal,
768                    text: token.text().into(),
769                    span: token.expect_span(),
770                },
771            ),
772            TokenTree::Punct(token) => push_flat(
773                output,
774                FlatToken {
775                    depth,
776                    kind: if token.text().is_empty() {
777                        FlatKind::Adj
778                    } else {
779                        FlatKind::Punct
780                    },
781                    text: if token.text().is_empty() {
782                        "Adj".into()
783                    } else {
784                        token.text().into()
785                    },
786                    span: token.expect_span(),
787                },
788            ),
789        }
790        flatten_trivia(token.trailing_trivia().as_slice(), depth, output);
791    }
792    flatten_trivia(tokens.suffix_trivia(), depth, output);
793}
794
795fn flatten_trivia(trivia: &[crate::Trivia], depth: usize, output: &mut Vec<FlatToken>) {
796    for token in trivia {
797        push_flat(
798            output,
799            FlatToken {
800                depth,
801                kind: FlatKind::Trivia,
802                text: token.text().into(),
803                span: token.expect_span(),
804            },
805        );
806    }
807}
808
809fn push_flat(output: &mut Vec<FlatToken>, token: FlatToken) {
810    if matches!(token.kind, FlatKind::Trivia)
811        && let Some(previous) = output.last_mut()
812        && matches!(previous.kind, FlatKind::Trivia)
813        && previous.depth == token.depth
814    {
815        previous.text.push_str(&token.text);
816        if let Some(span) = previous.span.join(token.span.clone()) {
817            previous.span = span;
818        } else {
819            output.push(token);
820        }
821    } else {
822        output.push(token);
823    }
824}
825
826fn flat_kind(kind: FlatKind) -> &'static str {
827    match kind {
828        FlatKind::Adj => "ADJ",
829        FlatKind::Open => "DELIM->",
830        FlatKind::Close => "<-DELIM",
831        FlatKind::Ident => "IDENT",
832        FlatKind::Literal => "LITERAL",
833        FlatKind::Punct => "PUNCT",
834        FlatKind::Trivia => "TRIVIA",
835    }
836}
837
838fn token_color(kind: FlatKind) -> &'static str {
839    match kind {
840        FlatKind::Adj => BRIGHT_BLACK,
841        FlatKind::Open => BLUE,
842        FlatKind::Close => MAGENTA,
843        FlatKind::Ident => CYAN,
844        FlatKind::Literal => YELLOW,
845        FlatKind::Punct => GREEN,
846        FlatKind::Trivia => RED,
847    }
848}
849
850fn visible_text(text: &str) -> String {
851    let mut visible = String::new();
852    for character in text.chars() {
853        match character {
854            ' ' => visible.push_str("' '"),
855            '\t' => visible.push('⇥'),
856            '\n' => visible.push('↵'),
857            '\r' => visible.push('␍'),
858            character => visible.push(character),
859        }
860    }
861    visible
862}
863
864fn write_tree(output: &mut impl FmtWrite, root: &PrettyNode, options: PrettyOptions) -> FmtResult {
865    write_node(output, root, "", None, true, options)
866}
867
868fn write_node(
869    output: &mut impl FmtWrite,
870    node: &PrettyNode,
871    prefix: &str,
872    label: Option<&str>,
873    last: bool,
874    options: PrettyOptions,
875) -> FmtResult {
876    let connector = if label.is_none() {
877        ""
878    } else if last {
879        "└─ "
880    } else {
881        "├─ "
882    };
883    write!(
884        output,
885        "{}{}",
886        paint(DIM, prefix, options),
887        paint(DIM, connector, options)
888    )?;
889    if let Some(detail) = &node.detail {
890        write!(
891            output,
892            "{}  ",
893            paint(
894                detail_color(detail.role),
895                &visible_text(&detail.text),
896                options
897            )
898        )?;
899    }
900    if let Some(label) = label.filter(|label| !label.is_empty()) {
901        write!(
902            output,
903            "{} {} ",
904            paint(BRIGHT_YELLOW, label, options),
905            paint(DIM, "→", options)
906        )?;
907    }
908    write!(
909        output,
910        "{}",
911        paint(node_color(node.role), &node.name, options)
912    )?;
913    if options.spans {
914        write!(
915            output,
916            "  {}",
917            paint(
918                DIM,
919                &node.span.as_ref().map_or_else(|| "@?".into(), format_span),
920                options,
921            )
922        )?;
923    }
924    writeln!(output)?;
925
926    let child_prefix = if label.is_none() {
927        String::new()
928    } else {
929        format!("{prefix}{}", if last { "   " } else { "│  " })
930    };
931    let child_count = node.children.len();
932    for (index, child) in node.children.iter().enumerate() {
933        write_node(
934            output,
935            &child.node,
936            &child_prefix,
937            Some(&child.label),
938            index + 1 == child_count,
939            options,
940        )?;
941    }
942    Ok(())
943}
944
945fn node_color(role: PrettyNodeRole) -> &'static str {
946    match role {
947        PrettyNodeRole::Syntax => NON_TERMINAL_BLUE,
948        PrettyNodeRole::Token => BOLD_CYAN,
949        PrettyNodeRole::Collection => BRIGHT_YELLOW,
950        PrettyNodeRole::Absent => DIM,
951    }
952}
953
954fn detail_color(role: PrettyDetailRole) -> &'static str {
955    match role {
956        PrettyDetailRole::Text => WHITE,
957        PrettyDetailRole::Glyph => BRIGHT_BLACK,
958    }
959}
960
961fn format_span(span: &Span) -> String {
962    let Some(range) = span.line_column_range() else {
963        return "@unlocated".into();
964    };
965    format!("@{range}")
966}
967
968fn paint(style: &'static str, text: &str, options: PrettyOptions) -> String {
969    if options.color && !text.is_empty() {
970        format!("{style}{text}{RESET}")
971    } else {
972        text.to_owned()
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979    use crate::lex_str;
980
981    #[test]
982    fn adjacent_trivia_is_hidden_or_rendered_as_one_token() {
983        let source = "-- note";
984        let tokens = lex_str(source).unwrap();
985
986        let hidden = format_tokens(&tokens, PrettyOptions::PLAIN);
987        assert!(!hidden.contains("TRIVIA"));
988        let shown = format_tokens(
989            &tokens,
990            PrettyOptions {
991                trivia: true,
992                ..PrettyOptions::PLAIN
993            },
994        );
995        assert_eq!(shown.matches("TRIVIA").count(), 1);
996    }
997
998    #[test]
999    fn token_bands_use_distinct_class_colors() {
1000        let source = "name = 1\n(next) -- note";
1001        let tokens = lex_str(source).unwrap();
1002        let plain = format_tokens(&tokens, PrettyOptions::PLAIN);
1003
1004        assert!(plain.contains("token │"));
1005
1006        let colored = format_tokens(
1007            &tokens,
1008            PrettyOptions {
1009                trivia: true,
1010                ..PrettyOptions::ANSI
1011            },
1012        );
1013        for color in [RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN] {
1014            assert!(colored.contains(color));
1015        }
1016    }
1017
1018    #[test]
1019    fn raw_tokens_have_no_virtual_cell_boundaries() {
1020        let source = "first\nsecond";
1021        let tokens = lex_str(source).unwrap();
1022        let rendered = format_tokens(&tokens, PrettyOptions::PLAIN);
1023
1024        assert_eq!(rendered.matches("token │").count(), 1);
1025        assert!(!rendered.contains("EOC"));
1026        assert!(rendered.contains("second"));
1027    }
1028
1029    #[test]
1030    fn token_cells_are_centered() {
1031        assert_eq!(centered_cell("x", 5), "   x   ");
1032        assert_eq!(centered_cell("xy", 5), "  xy   ");
1033    }
1034
1035    #[test]
1036    fn enum_presentation_is_transparent() {
1037        let span = Span::for_text("x");
1038        let value = PrettyNode::token("Symbol", "x", span.clone());
1039        let rendered = PrettyNode::variant("Expr", "Symbol", Some(span), value);
1040
1041        assert_eq!(rendered.name, "Symbol");
1042    }
1043
1044    #[test]
1045    fn tree_values_lead_their_field_and_distinguish_text_from_glyphs() {
1046        let source = Span::for_text(";");
1047        let mut root = PrettyNode::syntax("Root", Some(source.clone()));
1048        root.push_node(
1049            "semicolon",
1050            PrettyNode::token("Token![;]", ";", source.clone()),
1051        );
1052        root.push_node("operator", PrettyNode::token("Adj", "∅", source.at(1)));
1053
1054        let plain = format_tree(&root, PrettyOptions::PLAIN);
1055        assert!(plain.contains(";  semicolon → Token![;]"));
1056        assert!(plain.contains("∅  operator → Adj"));
1057
1058        let colored = format_tree(&root, PrettyOptions::ANSI);
1059        assert!(colored.contains(&format!("{WHITE};{RESET}")));
1060        assert!(colored.contains(&format!("{BRIGHT_BLACK}∅{RESET}")));
1061    }
1062
1063    #[test]
1064    fn delimiter_is_presented_as_a_collection() {
1065        let source = Span::for_text("()");
1066        let delimiter = PrettyNode::delimiter(
1067            DelimiterKind::Parenthesis,
1068            DoubleSpan::new(source.subspan(0, 1), source.subspan(1, 2)),
1069        );
1070
1071        assert_eq!(delimiter.role, PrettyNodeRole::Collection);
1072        assert_eq!(delimiter.name, "(…) · 2 tokens");
1073        assert!(!format_tree(&delimiter, PrettyOptions::PLAIN).contains("Delimiter"));
1074    }
1075
1076    #[test]
1077    fn report_boxes_have_four_fixed_width_walls() {
1078        let mut rendered = String::new();
1079        let long_line = "x".repeat(PrettyOptions::PLAIN.width * 2);
1080        write_box(
1081            &mut rendered,
1082            "TEST",
1083            &format!("one\ntwo\n{long_line}"),
1084            PrettyOptions::PLAIN,
1085        )
1086        .unwrap();
1087
1088        for line in rendered.lines() {
1089            assert_eq!(line.chars().count(), PrettyOptions::PLAIN.width);
1090        }
1091        assert!(rendered.contains('…'));
1092        assert!(rendered.starts_with("╭─ TEST "));
1093        assert!(rendered.lines().next().unwrap().ends_with('╮'));
1094        assert!(rendered.lines().nth(1).unwrap().ends_with('│'));
1095        assert!(rendered.lines().last().unwrap().ends_with('╯'));
1096    }
1097}