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