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    CellStream, DelimiterKind, Punctuated, Span, Spanned, TextPoint, TextRange, TokenStream,
12    TokenTree, punct::Pair, 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 NON_TERMINAL_BLUE: &str = "\u{1b}[38;5;75m";
19const DIM: &str = "\u{1b}[2m";
20const IDENT_COLOR: &str = "\u{1b}[38;5;45m";
21const LITERAL_COLOR: &str = "\u{1b}[38;5;207m";
22const PUNCT_COLOR: &str = "\u{1b}[38;5;82m";
23const OPEN_COLOR: &str = "\u{1b}[38;5;75m";
24const CLOSE_COLOR: &str = "\u{1b}[38;5;213m";
25const EOC_COLOR: &str = "\u{1b}[38;5;208m";
26const TRIVIA_COLOR: &str = "\u{1b}[38;5;245m";
27
28/// Converts typed syntax into the structural presentation used by the pretty
29/// printer.
30///
31/// Implementations for generated nodes are emitted from `syntax_schema!`, so
32/// field names, punctuation, and newly added concrete node types appear in the
33/// tree without a handwritten registry. Category enums are intentionally
34/// transparent because they are storage details rather than CST nodes.
35pub trait PrettyTree {
36    /// Builds the presentation node for this syntax value.
37    fn pretty_tree(&self) -> PrettyNode;
38}
39
40/// One owned node in the terminal presentation tree.
41///
42/// This is primarily useful to implementations of [`PrettyTree`]. Most users
43/// obtain it through [`crate::ParsedFile::pretty_cst`] or [`PrettyReport`].
44#[doc(hidden)]
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PrettyNode {
47    name: String,
48    detail: Option<String>,
49    span: Span,
50    role: NodeRole,
51    children: Vec<PrettyEdge>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum NodeRole {
56    Syntax,
57    Token,
58    Collection,
59    Absent,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63struct PrettyEdge {
64    label: String,
65    node: PrettyNode,
66}
67
68impl PrettyNode {
69    /// Creates a generated concrete syntax node.
70    #[doc(hidden)]
71    pub fn syntax(name: impl Into<String>, span: Span) -> Self {
72        Self::new(name, span, NodeRole::Syntax)
73    }
74
75    /// Creates a generated enum-variant node.
76    #[doc(hidden)]
77    pub fn variant(_category: &str, _variant: &str, span: Span, mut value: PrettyNode) -> Self {
78        if !matches!(span, Span::Detached) {
79            value.span = span;
80        }
81        value
82    }
83
84    /// Creates a delimiter node with explicit opening and closing boundaries.
85    #[doc(hidden)]
86    pub fn delimiter(kind: DelimiterKind, span: Span) -> Self {
87        let delimiter = Delimiter::new(kind, span);
88        let mut node = Self::new("Delimiter", span, NodeRole::Token);
89        if !kind.opening().is_empty() {
90            node.push_node(
91                "opening",
92                Self::token("Token", kind.opening(), delimiter.opening_span()),
93            );
94        }
95        if !kind.closing().is_empty() {
96            node.push_node(
97                "closing",
98                Self::token("Token", kind.closing(), delimiter.closing_span()),
99            );
100        }
101        node
102    }
103
104    /// Creates a leaf containing token or source text.
105    #[doc(hidden)]
106    pub fn token(name: impl Into<String>, text: impl Into<String>, span: Span) -> Self {
107        let name = name.into();
108        let text = text.into();
109        if name == "Delimiter"
110            && let Some(kind) = delimiter_notation(&text)
111        {
112            return Self::delimiter(kind, span);
113        }
114        let mut node = Self::new(name, span, NodeRole::Token);
115        node.detail = Some(text);
116        node
117    }
118
119    /// Creates a collection wrapper.
120    #[doc(hidden)]
121    pub fn collection(name: impl Into<String>, span: Span) -> Self {
122        Self::new(name, span, NodeRole::Collection)
123    }
124
125    /// Creates an absent optional value.
126    #[doc(hidden)]
127    pub fn absent() -> Self {
128        Self::new("None", Span::detached(), NodeRole::Absent)
129    }
130
131    fn new(name: impl Into<String>, span: Span, role: NodeRole) -> Self {
132        Self {
133            name: name.into(),
134            detail: None,
135            span,
136            role,
137            children: Vec::new(),
138        }
139    }
140
141    /// Adds a named child to this node.
142    #[doc(hidden)]
143    pub fn push(&mut self, label: impl Into<String>, child: impl PrettyTree) {
144        self.push_node(label, child.pretty_tree());
145    }
146
147    /// Adds an already constructed named child to this node.
148    #[doc(hidden)]
149    pub fn push_node(&mut self, label: impl Into<String>, node: PrettyNode) {
150        let mut node = node;
151        if matches!(node.span, Span::Detached) {
152            node.span = self.span;
153        }
154        self.children.push(PrettyEdge {
155            label: label.into(),
156            node,
157        });
158    }
159}
160
161fn delimiter_notation(notation: &str) -> Option<DelimiterKind> {
162    match notation {
163        "…" => Some(DelimiterKind::Empty),
164        "…;" => Some(DelimiterKind::Semicolon),
165        "(…)" => Some(DelimiterKind::Parenthesis),
166        "[…]" => Some(DelimiterKind::Bracket),
167        "{…}" => Some(DelimiterKind::Brace),
168        "<|…|>" => Some(DelimiterKind::AngleBar),
169        _ => None,
170    }
171}
172
173impl<T: PrettyTree + ?Sized> PrettyTree for &T {
174    fn pretty_tree(&self) -> PrettyNode {
175        T::pretty_tree(self)
176    }
177}
178
179impl<T: PrettyTree> PrettyTree for Box<T> {
180    fn pretty_tree(&self) -> PrettyNode {
181        T::pretty_tree(self)
182    }
183}
184
185impl<T: PrettyTree + Spanned> PrettyTree for Option<T> {
186    fn pretty_tree(&self) -> PrettyNode {
187        match self {
188            Some(value) => value.pretty_tree(),
189            None => PrettyNode::absent(),
190        }
191    }
192}
193
194impl<T: PrettyTree + Spanned> PrettyTree for Vec<T> {
195    fn pretty_tree(&self) -> PrettyNode {
196        let mut node = PrettyNode::collection(item_count("List", self.len()), self.span());
197        for (index, value) in self.iter().enumerate() {
198            node.push(format!("[{index}]"), value);
199        }
200        node
201    }
202}
203
204impl<T: PrettyTree + Spanned> PrettyTree for Punctuated<T> {
205    fn pretty_tree(&self) -> PrettyNode {
206        let mut node = PrettyNode::collection(item_count("Punctuated", self.len()), self.span());
207        let values = self
208            .pairs()
209            .map(|pair| match pair {
210                Pair::Punctuated(value, comma) => (value.pretty_tree(), Some(comma.pretty_tree())),
211                Pair::End(value) => (value.pretty_tree(), None),
212            })
213            .collect::<Vec<_>>();
214        for (index, (value, comma)) in values.iter().enumerate() {
215            node.push_node(format!("[{index}]"), value.clone());
216            if let Some(comma) = comma {
217                let mut comma = comma.clone();
218                if matches!(comma.span, Span::Detached) {
219                    let right = values
220                        .get(index + 1)
221                        .map_or(node.span, |(value, _)| value.span);
222                    comma.span = infer_token_span(value.span, right, ",").unwrap_or(node.span);
223                }
224                node.push_node("comma", comma);
225            }
226        }
227        node
228    }
229}
230
231fn item_count(name: &str, count: usize) -> String {
232    let suffix = if count == 1 { "item" } else { "items" };
233    format!("{name} · {count} {suffix}")
234}
235
236/// Formatting choices shared by token, CST, and combined reports.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub struct PrettyOptions {
239    /// Emits ANSI terminal styling when enabled.
240    pub color: bool,
241    /// Includes source ranges beside tokens and CST nodes.
242    pub spans: bool,
243    /// Includes coalesced whitespace and comment trivia in the flat token view.
244    pub trivia: bool,
245    /// Preferred width of source headings and horizontal token bands.
246    pub width: usize,
247}
248
249impl PrettyOptions {
250    /// Plain, deterministic output suitable for tests and redirected output.
251    pub const PLAIN: Self = Self {
252        color: false,
253        spans: true,
254        trivia: false,
255        width: 120,
256    };
257
258    /// ANSI-colored output intended for an interactive terminal.
259    pub const ANSI: Self = Self {
260        color: true,
261        spans: true,
262        trivia: false,
263        width: 120,
264    };
265}
266
267impl Default for PrettyOptions {
268    fn default() -> Self {
269        Self::PLAIN
270    }
271}
272
273/// A displayable three-part inspection report for one parsed source file.
274///
275/// The report shows the retained input snapshot, a recursively flattened raw
276/// token table, and the complete generated typed CST. Use [`Self::ansi`] for
277/// explicit terminal color or [`crate::ParsedFile::print_pretty`] for automatic
278/// terminal detection.
279pub struct PrettyReport<'source> {
280    source: &'source str,
281    cells: CellStream,
282    cst: PrettyNode,
283    options: PrettyOptions,
284}
285
286impl<'source> PrettyReport<'source> {
287    /// Creates a plain report from its three projections.
288    #[doc(hidden)]
289    pub fn new(source: &'source str, cells: CellStream, cst: PrettyNode) -> Self {
290        Self {
291            source,
292            cells,
293            cst,
294            options: PrettyOptions::PLAIN,
295        }
296    }
297
298    /// Enables or disables ANSI terminal styling.
299    pub fn ansi(mut self, enabled: bool) -> Self {
300        self.options.color = enabled;
301        self
302    }
303
304    /// Enables or disables source ranges.
305    pub fn spans(mut self, enabled: bool) -> Self {
306        self.options.spans = enabled;
307        self
308    }
309
310    /// Includes or hides coalesced trivia in the flat token view.
311    pub fn trivia(mut self, enabled: bool) -> Self {
312        self.options.trivia = enabled;
313        self
314    }
315
316    /// Sets the preferred report width used to wrap horizontal token bands.
317    pub fn width(mut self, width: usize) -> Self {
318        self.options.width = width.max(40);
319        self
320    }
321}
322
323impl Display for PrettyReport<'_> {
324    fn fmt(&self, output: &mut Formatter<'_>) -> FmtResult {
325        section_title(output, "SOURCE", self.options)?;
326        write_source(output, self.source, self.options)?;
327        section_title(output, "TOKEN STREAM", self.options)?;
328        write_cell_tokens(output, &self.cells, self.options)?;
329        section_title(output, "TYPED CST", self.options)?;
330        write_tree(output, &self.cst, self.options)
331    }
332}
333
334pub(crate) fn print_report(report: PrettyReport<'_>) -> io::Result<()> {
335    let mut output = io::stdout().lock();
336    let width = std::env::var("COLUMNS")
337        .ok()
338        .and_then(|width| width.parse().ok())
339        .unwrap_or(120);
340    let report = report.ansi(output.is_terminal()).width(width);
341    write!(output, "{report}")
342}
343
344pub(crate) fn format_tree(node: &PrettyNode, options: PrettyOptions) -> String {
345    let mut output = String::new();
346    write_tree(&mut output, node, options).expect("writing to a string cannot fail");
347    output
348}
349
350fn section_title(output: &mut impl FmtWrite, title: &str, options: PrettyOptions) -> FmtResult {
351    writeln!(
352        output,
353        "{}╭─ {} {}",
354        paint(DIM, "", options),
355        paint(BOLD_CYAN, title, options),
356        paint(
357            DIM,
358            &"─".repeat(options.width.saturating_sub(title.len() + 5)),
359            options
360        ),
361    )
362}
363
364fn write_source(output: &mut impl FmtWrite, source: &str, options: PrettyOptions) -> FmtResult {
365    let width = source.lines().count().max(1).to_string().len();
366    if source.is_empty() {
367        writeln!(output, "{}", paint(DIM, "│  ∅", options))?;
368    } else {
369        for (index, line) in source.lines().enumerate() {
370            writeln!(
371                output,
372                "{} {:>width$} {} {}",
373                paint(DIM, "│", options),
374                index + 1,
375                paint(DIM, "│", options),
376                line,
377            )?;
378        }
379    }
380    writeln!(output, "{}", paint(DIM, "╰─", options))
381}
382
383#[derive(Clone, Copy)]
384enum FlatKind {
385    Open(DelimiterKind),
386    Close(DelimiterKind),
387    Ident,
388    Literal,
389    Punct,
390    Trivia,
391    Eoc,
392    Eof,
393}
394
395#[derive(Clone)]
396struct FlatToken {
397    depth: usize,
398    kind: FlatKind,
399    text: String,
400    span: Span,
401}
402
403pub(crate) fn format_cells(cells: &CellStream, options: PrettyOptions) -> String {
404    let mut output = String::new();
405    write_cell_tokens(&mut output, cells, options).expect("writing to a string cannot fail");
406    output
407}
408
409fn write_cell_tokens(
410    output: &mut impl FmtWrite,
411    cells: &CellStream,
412    options: PrettyOptions,
413) -> FmtResult {
414    let mut flat = Vec::new();
415    flatten_cells(cells, &mut flat);
416    write_flat(output, flat, options)
417}
418
419fn write_flat(
420    output: &mut impl FmtWrite,
421    mut flat: Vec<FlatToken>,
422    options: PrettyOptions,
423) -> FmtResult {
424    if !options.trivia {
425        flat.retain(|token| !matches!(token.kind, FlatKind::Trivia));
426    }
427    if flat.is_empty() {
428        return writeln!(output, "{}", paint(DIM, "∅", options));
429    }
430    let chunks = structural_chunks(&flat);
431    let available = options.width.saturating_sub(8).max(24);
432    let mut band = Vec::new();
433    let mut used = 0;
434    let mut wrote_band = false;
435    for chunk in chunks {
436        let chunk_width = token_band_width(chunk);
437        if !band.is_empty() && used + 1 + chunk_width > available {
438            if wrote_band {
439                writeln!(output)?;
440            }
441            write_token_band(output, &band, options)?;
442            wrote_band = true;
443            band.clear();
444            used = 0;
445        }
446        if !band.is_empty() {
447            used += 1;
448        }
449        used += chunk_width;
450        band.extend_from_slice(chunk);
451
452        if chunk
453            .last()
454            .is_some_and(|token| matches!(token.kind, FlatKind::Eoc))
455        {
456            if wrote_band {
457                writeln!(output)?;
458            }
459            write_token_band(output, &band, options)?;
460            wrote_band = true;
461            band.clear();
462            used = 0;
463        }
464    }
465    if !band.is_empty() {
466        if wrote_band {
467            writeln!(output)?;
468        }
469        write_token_band(output, &band, options)?;
470    }
471    Ok(())
472}
473
474fn structural_chunks(tokens: &[FlatToken]) -> Vec<&[FlatToken]> {
475    let mut chunks = Vec::new();
476    let mut start = 0;
477    for (index, token) in tokens.iter().enumerate() {
478        if matches!(
479            token.kind,
480            FlatKind::Open(_) | FlatKind::Close(_) | FlatKind::Eoc
481        ) {
482            chunks.push(&tokens[start..=index]);
483            start = index + 1;
484        }
485    }
486    if start < tokens.len() {
487        chunks.push(&tokens[start..]);
488    }
489    chunks
490}
491
492fn token_band_width(tokens: &[FlatToken]) -> usize {
493    tokens.iter().map(token_column_width).sum::<usize>() + tokens.len().saturating_sub(1)
494}
495
496fn token_column_width(token: &FlatToken) -> usize {
497    visible_text(&token.text)
498        .chars()
499        .count()
500        .max(flat_kind(token.kind).chars().count())
501        .clamp(3, 24)
502        + 2
503}
504
505fn write_token_band(
506    output: &mut impl FmtWrite,
507    tokens: &[FlatToken],
508    options: PrettyOptions,
509) -> FmtResult {
510    write!(
511        output,
512        "{} {} ",
513        paint(BRIGHT_YELLOW, "token", options),
514        paint(DIM, "│", options)
515    )?;
516    for (index, token) in tokens.iter().enumerate() {
517        if index != 0 {
518            write!(output, "{}", paint(DIM, "│", options))?;
519        }
520        let width = token_column_width(token) - 2;
521        let text = clipped(&visible_text(&token.text), width);
522        let cell = centered_cell(&text, width);
523        write!(output, "{}", paint(BRIGHT_YELLOW, &cell, options))?;
524    }
525    writeln!(output)?;
526
527    write!(
528        output,
529        "{} {} ",
530        paint(BRIGHT_YELLOW, " type", options),
531        paint(DIM, "│", options)
532    )?;
533    for (index, token) in tokens.iter().enumerate() {
534        if index != 0 {
535            write!(output, "{}", paint(DIM, "│", options))?;
536        }
537        let width = token_column_width(token) - 2;
538        let kind = clipped(&flat_kind(token.kind), width);
539        let cell = centered_cell(&kind, width);
540        write!(output, "{}", paint(token_color(token.kind), &cell, options))?;
541    }
542    writeln!(output)
543}
544
545fn centered_cell(text: &str, width: usize) -> String {
546    let padding = width.saturating_sub(text.chars().count());
547    let left = padding / 2;
548    let right = padding - left;
549    format!(" {}{}{} ", " ".repeat(left), text, " ".repeat(right))
550}
551
552fn clipped(text: &str, width: usize) -> String {
553    let count = text.chars().count();
554    if count <= width {
555        return text.to_owned();
556    }
557    text.chars()
558        .take(width.saturating_sub(1))
559        .chain(std::iter::once('…'))
560        .collect()
561}
562
563fn infer_token_span(left: Span, right: Span, text: &str) -> Option<Span> {
564    let (source, start) = located_end(left)?;
565    let (right_source, limit) = located_start(right)?;
566    if source != right_source || start.line != limit.line {
567        return None;
568    }
569    let end = TextPoint::new(
570        start.line,
571        start.column.saturating_add(text.chars().count() as u32),
572        start.byte.saturating_add(text.len()),
573    );
574    (end <= limit).then(|| Span::new(source, TextRange::new(start, end)))
575}
576
577fn located_start(span: Span) -> Option<(crate::SourceId, TextPoint)> {
578    span.source().ok().zip(span.start_point().ok())
579}
580
581fn located_end(span: Span) -> Option<(crate::SourceId, TextPoint)> {
582    span.source().ok().zip(span.end_point().ok())
583}
584
585fn flatten(tokens: &TokenStream, depth: usize, output: &mut Vec<FlatToken>) {
586    for token in tokens.iter() {
587        match token {
588            TokenTree::Group(group) => {
589                let kind = group.delim_kind();
590                push_flat(
591                    output,
592                    FlatToken {
593                        depth,
594                        kind: FlatKind::Open(kind),
595                        text: kind.opening().into(),
596                        span: group.delimiter().opening_span(),
597                    },
598                );
599                flatten(group.stream(), depth + 1, output);
600                push_flat(
601                    output,
602                    FlatToken {
603                        depth,
604                        kind: FlatKind::Close(kind),
605                        text: kind.closing().into(),
606                        span: group.delimiter().closing_span(),
607                    },
608                );
609            }
610            TokenTree::Ident(token) => push_flat(
611                output,
612                FlatToken {
613                    depth,
614                    kind: FlatKind::Ident,
615                    text: token.text().into(),
616                    span: token.span(),
617                },
618            ),
619            TokenTree::Literal(token) => push_flat(
620                output,
621                FlatToken {
622                    depth,
623                    kind: FlatKind::Literal,
624                    text: token.text().into(),
625                    span: token.span(),
626                },
627            ),
628            TokenTree::Punct(token) => push_flat(
629                output,
630                FlatToken {
631                    depth,
632                    kind: FlatKind::Punct,
633                    text: token.text().into(),
634                    span: token.span(),
635                },
636            ),
637            TokenTree::Trivia(token) => push_flat(
638                output,
639                FlatToken {
640                    depth,
641                    kind: FlatKind::Trivia,
642                    text: token.text().into(),
643                    span: token.span(),
644                },
645            ),
646            TokenTree::Eof(span) => push_flat(
647                output,
648                FlatToken {
649                    depth,
650                    kind: FlatKind::Eof,
651                    text: String::new(),
652                    span: *span,
653                },
654            ),
655        }
656    }
657}
658
659fn flatten_cells(cells: &CellStream, output: &mut Vec<FlatToken>) {
660    for cell in cells.iter() {
661        flatten(cell.stream(), 0, output);
662        if cell.delim_kind() == DelimiterKind::Semicolon {
663            push_flat(
664                output,
665                FlatToken {
666                    depth: 0,
667                    kind: FlatKind::Close(DelimiterKind::Semicolon),
668                    text: ";".into(),
669                    span: cell.delimiter().closing_span(),
670                },
671            );
672        }
673        push_flat(
674            output,
675            FlatToken {
676                depth: 0,
677                kind: FlatKind::Eoc,
678                text: "EOC".into(),
679                span: cell.span(),
680            },
681        );
682    }
683}
684
685fn push_flat(output: &mut Vec<FlatToken>, token: FlatToken) {
686    if matches!(token.kind, FlatKind::Trivia)
687        && let Some(previous) = output.last_mut()
688        && matches!(previous.kind, FlatKind::Trivia)
689        && previous.depth == token.depth
690    {
691        previous.text.push_str(&token.text);
692        previous.span = previous.span.join(token.span);
693    } else {
694        output.push(token);
695    }
696}
697
698fn flat_kind(kind: FlatKind) -> String {
699    match kind {
700        FlatKind::Open(kind) => format!("OPEN·{kind:?}"),
701        FlatKind::Close(kind) => format!("CLOSE·{kind:?}"),
702        FlatKind::Ident => "IDENT".into(),
703        FlatKind::Literal => "LITERAL".into(),
704        FlatKind::Punct => "PUNCT".into(),
705        FlatKind::Trivia => "TRIVIA".into(),
706        FlatKind::Eoc => "EOC".into(),
707        FlatKind::Eof => "EOF".into(),
708    }
709}
710
711fn token_color(kind: FlatKind) -> &'static str {
712    match kind {
713        FlatKind::Open(_) => OPEN_COLOR,
714        FlatKind::Close(_) => CLOSE_COLOR,
715        FlatKind::Ident => IDENT_COLOR,
716        FlatKind::Literal => LITERAL_COLOR,
717        FlatKind::Punct => PUNCT_COLOR,
718        FlatKind::Trivia => TRIVIA_COLOR,
719        FlatKind::Eoc => EOC_COLOR,
720        FlatKind::Eof => DIM,
721    }
722}
723
724fn visible_text(text: &str) -> String {
725    text.chars()
726        .flat_map(|character| match character {
727            ' ' => "␠".chars().collect::<Vec<_>>(),
728            '\t' => "⇥".chars().collect(),
729            '\n' => "↵".chars().collect(),
730            '\r' => "␍".chars().collect(),
731            character => vec![character],
732        })
733        .collect()
734}
735
736fn write_tree(output: &mut impl FmtWrite, root: &PrettyNode, options: PrettyOptions) -> FmtResult {
737    write_node(output, root, "", None, true, options)
738}
739
740fn write_node(
741    output: &mut impl FmtWrite,
742    node: &PrettyNode,
743    prefix: &str,
744    label: Option<&str>,
745    last: bool,
746    options: PrettyOptions,
747) -> FmtResult {
748    let connector = if label.is_none() {
749        ""
750    } else if last {
751        "└─ "
752    } else {
753        "├─ "
754    };
755    write!(
756        output,
757        "{}{}",
758        paint(DIM, prefix, options),
759        paint(DIM, connector, options)
760    )?;
761    if let Some(label) = label.filter(|label| !label.is_empty()) {
762        write!(
763            output,
764            "{} {} ",
765            paint(BRIGHT_YELLOW, label, options),
766            paint(DIM, "→", options)
767        )?;
768    }
769    if let Some(detail) = &node.detail {
770        write!(
771            output,
772            "{}  ",
773            paint(BRIGHT_YELLOW, &visible_text(detail), options)
774        )?;
775    }
776    write!(
777        output,
778        "{}",
779        paint(node_color(node.role), &node.name, options)
780    )?;
781    if options.spans {
782        write!(output, "  {}", paint(DIM, &format_span(node.span), options))?;
783    }
784    writeln!(output)?;
785
786    let child_prefix = if label.is_none() {
787        String::new()
788    } else {
789        format!("{prefix}{}", if last { "   " } else { "│  " })
790    };
791    let child_count = node.children.len();
792    for (index, child) in node.children.iter().enumerate() {
793        write_node(
794            output,
795            &child.node,
796            &child_prefix,
797            Some(&child.label),
798            index + 1 == child_count,
799            options,
800        )?;
801    }
802    Ok(())
803}
804
805fn node_color(role: NodeRole) -> &'static str {
806    match role {
807        NodeRole::Syntax => NON_TERMINAL_BLUE,
808        NodeRole::Token => BOLD_CYAN,
809        NodeRole::Collection => BRIGHT_YELLOW,
810        NodeRole::Absent => DIM,
811    }
812}
813
814fn format_span(span: Span) -> String {
815    let Ok(range) = span.range() else {
816        return "@detached".into();
817    };
818    let (Some(start), Some(end)) = (range.start(), range.end()) else {
819        return "@empty".into();
820    };
821    let source = span
822        .source()
823        .map(|source| format!("s{}·", source.0))
824        .unwrap_or_default();
825    if start == end {
826        format!("@{source}{}", point(start))
827    } else if start.line == end.line {
828        format!(
829            "@{source}{}:{}–{}",
830            start.line + 1,
831            start.column + 1,
832            end.column + 1
833        )
834    } else {
835        format!("@{source}{}–{}", point(start), point(end))
836    }
837}
838
839fn point(point: TextPoint) -> String {
840    format!("{}:{}", point.line + 1, point.column + 1)
841}
842
843fn paint(style: &'static str, text: &str, options: PrettyOptions) -> String {
844    if options.color && !text.is_empty() {
845        format!("{style}{text}{RESET}")
846    } else {
847        text.to_owned()
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854    use crate::{CellBlock, SourceId, Trivia};
855
856    #[test]
857    fn adjacent_trivia_is_hidden_or_rendered_as_one_token() {
858        let mut tokens = TokenStream::new();
859        tokens.push_trivia(Trivia::new(
860            crate::TriviaKind::Whitespace,
861            " ",
862            Span::detached(),
863        ));
864        tokens.push_trivia(Trivia::new(
865            crate::TriviaKind::LineBreak,
866            "\n",
867            Span::detached(),
868        ));
869
870        let cells = CellStream::new(
871            vec![CellBlock::new(
872                Delimiter::new(DelimiterKind::Empty, Span::detached()),
873                tokens,
874            )],
875            SourceId(1),
876        );
877
878        let hidden = format_cells(&cells, PrettyOptions::PLAIN);
879        assert!(!hidden.contains("TRIVIA"));
880        assert!(hidden.contains("EOC"));
881        let shown = format_cells(
882            &cells,
883            PrettyOptions {
884                trivia: true,
885                ..PrettyOptions::PLAIN
886            },
887        );
888        assert_eq!(shown.matches("TRIVIA").count(), 1);
889        assert!(shown.contains("␠↵"));
890    }
891
892    #[test]
893    fn token_bands_end_at_cells_and_use_distinct_class_colors() {
894        let cells = crate::lex_str("name = 1\nnext", SourceId(2)).unwrap();
895        let plain = format_cells(&cells, PrettyOptions::PLAIN);
896
897        assert_eq!(plain.matches("token │").count(), 2);
898        assert_eq!(plain.matches(" EOC ").count(), 4);
899
900        let colored = format_cells(&cells, PrettyOptions::ANSI);
901        assert!(colored.contains(&format!("{BRIGHT_YELLOW} name ")));
902        for color in [IDENT_COLOR, PUNCT_COLOR, LITERAL_COLOR, EOC_COLOR] {
903            assert!(colored.contains(color));
904        }
905    }
906
907    #[test]
908    fn token_cells_are_centered() {
909        assert_eq!(centered_cell("x", 5), "   x   ");
910        assert_eq!(centered_cell("xy", 5), "  xy   ");
911    }
912
913    #[test]
914    fn enum_presentation_is_transparent() {
915        let value = PrettyNode::token("Symbol", "x", Span::detached());
916        let rendered = PrettyNode::variant("Expr", "Symbol", Span::detached(), value);
917
918        assert_eq!(rendered.name, "Symbol");
919    }
920}