Skip to main content

caixa_ast/
trivia.rs

1//! Trivia — whitespace, blank lines, and comments attached to nodes.
2
3use crate::span::Span;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Trivia {
7    pub kind: TriviaKind,
8    pub span: Span,
9}
10
11/// The typed variant discriminator on the caixa-ast trivia surface — every
12/// [`Trivia`]'s carrying-shape (line comment, blank line, shebang) projects
13/// through this closed three-arm partition.
14///
15/// The [`gen_platform::IsVariant`] derive emits per-arm arm-discriminator
16/// predicates — [`Self::is_line_comment`], [`Self::is_blank_line`],
17/// [`Self::is_shebang`] — so every downstream consumer that only needs the
18/// arm-discriminator projection (not the borrowed field value) reaches for
19/// one typed dispatch on the substrate primitive rather than a hand-rolled
20/// `matches!(t.kind, TriviaKind::X(_))` literal. Peer of the sibling
21/// [`crate::NodeKind`] `IsVariant` lift already on the caixa-ast surface
22/// (7f6aa98) — extends the same discipline onto the trivia axis every
23/// downstream authoring consumer (`caixa-fmt` blank-line skip + line-comment
24/// detection, a future `caixa-lint` no-shebang-below-line-1 rule) partitions
25/// on.
26#[derive(Debug, Clone, PartialEq, Eq, gen_platform::IsVariant)]
27pub enum TriviaKind {
28    /// `; comment` — to end of line.
29    LineComment(String),
30    /// A run of ≥ 2 newlines — significant for preserving paragraph breaks.
31    BlankLine,
32    /// `#!/usr/bin/env tatara-script` on the first line of an executable
33    /// script, held VERBATIM.
34    ///
35    /// Not a comment: it carries no `;` and re-emitting it as one would
36    /// stop the kernel recognising the file, so the script would no longer
37    /// run. Five corpus files are executable scripts the canonical
38    /// interpreter runs happily and this reader refused outright — the
39    /// formatter could not read them at all.
40    Shebang(String),
41}
42
43impl Trivia {
44    /// Project the wrapped [`TriviaKind::LineComment`] body as `&str`, or
45    /// `None` on the sibling two arms ([`TriviaKind::BlankLine`] +
46    /// [`TriviaKind::Shebang`]) — the trivia-envelope-scoped surface every
47    /// downstream authoring consumer that only needs the line-comment body
48    /// (a `caixa-fmt` block-comment paragraph-fill pass over the collected
49    /// trivia list, a `caixa-lint` doc-comment / rustdoc-shape probe, a
50    /// deferred `caixa-lsp` hover pop-up that renders the comment body on
51    /// mouse-over) partitions on.
52    ///
53    /// `pub const fn` — closes const-eval discipline on the [`Trivia`]
54    /// envelope-scoped projection axis, peer with the sibling
55    /// [`crate::TriviaKind`] `IsVariant`-derived per-arm predicate
56    /// discriminators ([`TriviaKind::is_line_comment`] +
57    /// [`TriviaKind::is_blank_line`] + [`TriviaKind::is_shebang`]) on the
58    /// same trivia-arm-discriminator surface. The body reaches for
59    /// [`String::as_str`] on the [`TriviaKind::LineComment`] borrowed-
60    /// `String` slot — const-stable since Rust 1.87, well before this
61    /// workspace's 1.89 MSRV floor — so the promotion is a body-preserving
62    /// type-signature widening. Matches the sibling per-source-position-
63    /// primitive const-eval-surface family on the caixa-ast surface
64    /// ([`crate::Span::new`] / [`crate::Span::point`] / [`crate::Span::len`]
65    /// / [`crate::Span::is_empty`] / [`crate::Span::contains`] /
66    /// [`crate::Span::union`] on the byte-offset axis,
67    /// [`crate::Position::new`] / [`crate::Position::origin`] on the 1-
68    /// indexed line/column axis, [`crate::Position::line_column`] on the
69    /// `Position` projection axis, [`crate::NodeKind::seq_delims`] /
70    /// [`crate::NodeKind::reader_macro_prefix`] / [`crate::NodeKind::as_keyword`]
71    /// / [`crate::NodeKind::as_symbol`] / [`crate::NodeKind::as_str`] on
72    /// the outer-NodeKind writer-half projection axis) — every downstream
73    /// consumer that wants a compile-time line-comment-body fixture (a
74    /// `const HEADER: Option<&str> = TRIVIA.comment_text();` compile-time
75    /// oracle a caixa-fmt paragraph-fill pass keys off, a per-lint const-
76    /// context doc-comment shape probe a future admission webhook
77    /// consults) now reads through one substrate-primitive const dispatch
78    /// rather than being forced onto the runtime code path.
79    #[must_use]
80    pub const fn comment_text(&self) -> Option<&str> {
81        match &self.kind {
82            TriviaKind::LineComment(s) => Some(s.as_str()),
83            TriviaKind::BlankLine | TriviaKind::Shebang(_) => None,
84        }
85    }
86}
87
88#[cfg(test)]
89mod is_variant_tests {
90    use super::*;
91
92    fn all_variants() -> Vec<(TriviaKind, &'static str)> {
93        vec![
94            (TriviaKind::LineComment("hello".into()), "LineComment"),
95            (TriviaKind::BlankLine, "BlankLine"),
96            (
97                TriviaKind::Shebang("#!/usr/bin/env tatara-script".into()),
98                "Shebang",
99            ),
100        ]
101    }
102
103    fn predicate_row(k: &TriviaKind) -> [bool; 3] {
104        [k.is_line_comment(), k.is_blank_line(), k.is_shebang()]
105    }
106
107    // Fail-before-pass-after pin on the [`gen_platform::IsVariant`]
108    // derive-generated per-arm predicate partition — for every variant in
109    // `all_variants()`, the observed 3-slot predicate row must equal a
110    // one-hot row with the `true` at exactly the same index as the
111    // variant's declaration order. Expected rows are generated live from
112    // the enumeration rather than transcribed by hand, so a copy-paste
113    // flip that reroutes one arm through the wrong predicate lane trips
114    // at the identity-diagonal assertion the way every peer sibling
115    // [`crate::NodeKind`] / `CaixaKind` / `CaixaDialeto` /
116    // `PathShapeViolation` / `RestartStrategy` partition pin already does.
117    #[test]
118    fn trivia_kind_is_variant_predicates_partition_the_arm_set() {
119        let variants = all_variants();
120        for (idx, (variant, name)) in variants.iter().enumerate() {
121            let observed = predicate_row(variant);
122            let mut expected = [false; 3];
123            expected[idx] = true;
124            assert_eq!(
125                observed, expected,
126                "TriviaKind::{name} at declaration-order slot {idx} must \
127                 satisfy exactly one is_* predicate (its own); observed \
128                 row must equal the one-hot expected row"
129            );
130        }
131    }
132
133    // Fail-before-pass-after pin on [`Trivia::comment_text`]'s
134    // `const`-eval-surface posture. The projection routes the wrapped
135    // [`TriviaKind::LineComment`] borrowed-`String` slot through the
136    // `pub const fn` [`String::as_str`] (const-stable since Rust 1.87,
137    // well within the workspace's 1.89 MSRV floor) — any future
138    // accidental downgrade to non-`const` fails
139    // `comment_text_via_const_fn` at caixa-ast build time with E0015
140    // (`cannot call non-const method`), strictly stronger than a runtime
141    // `assert!`. Sibling of the peer per-source-position-primitive
142    // `const`-eval-surface passes on the caixa-ast surface
143    // ([`crate::Span::new`] / [`crate::Span::point`] / [`crate::Span::len`]
144    // / [`crate::Span::is_empty`] / [`crate::Span::contains`] /
145    // [`crate::Span::union`] on the byte-offset axis,
146    // [`crate::Position::new`] / [`crate::Position::origin`] /
147    // [`crate::Position::line_column`] on the 1-indexed line/column
148    // axis, [`crate::NodeKind::seq_delims`] /
149    // [`crate::NodeKind::reader_macro_prefix`] /
150    // [`crate::NodeKind::as_keyword`] / [`crate::NodeKind::as_symbol`]
151    // / [`crate::NodeKind::as_str`] on the outer-NodeKind writer-half
152    // projection axis). The sweep exercises all three
153    // [`TriviaKind`] arms (a populated `LineComment` body, the field-
154    // less `BlankLine`, a populated `Shebang` body) so a copy-paste flip
155    // that reroutes one arm's return through the wrong projection lane
156    // trips at caixa-ast test time under `PartialEq` on the
157    // `Option<&str>` return shape rather than at a downstream
158    // caixa-fmt / caixa-lint / caixa-lsp consumer-observable drift.
159    #[test]
160    fn trivia_comment_text_projection_is_const_fn() {
161        const fn comment_text_via_const_fn(t: &Trivia) -> Option<&str> {
162            t.comment_text()
163        }
164        for (variant, expected) in [
165            (TriviaKind::LineComment("hello".into()), Some("hello")),
166            (TriviaKind::BlankLine, None),
167            (
168                TriviaKind::Shebang("#!/usr/bin/env tatara-script".into()),
169                None,
170            ),
171        ] {
172            let trivia = Trivia {
173                kind: variant,
174                span: Span::default(),
175            };
176            assert_eq!(
177                comment_text_via_const_fn(&trivia),
178                expected,
179                "Trivia::comment_text must project through the pub const \
180                 fn body byte-equal to the pre-lift open-coded match on \
181                 the same fixture",
182            );
183            assert_eq!(comment_text_via_const_fn(&trivia), trivia.comment_text());
184        }
185    }
186
187    // Byte-parity pin on the two field-agnostic `matches!` shapes this
188    // lift replaces at production call sites: the `TriviaKind::BlankLine`
189    // gate (caixa-fmt/src/printer.rs `trim_leading_blanks` take-while)
190    // and the `TriviaKind::LineComment(_)` gate (caixa-fmt/src/printer.rs
191    // `contains_comment_trivia` any). Refuses a future accidental split
192    // between the derived predicate and its pre-lift `matches!` shape
193    // (a hand-rolled shadow `impl` that overrides one path, an accidental
194    // rebrand of one converged call site back to the `matches!` form) on
195    // the two load-bearing trivia-arm-discriminator axes every downstream
196    // authoring consumer (caixa-fmt today, caixa-lint tomorrow) keys off.
197    #[test]
198    fn trivia_kind_is_blank_line_and_is_line_comment_byte_equal_pre_lift_matches_shape() {
199        for (variant, name) in all_variants() {
200            let via_matches_blank = matches!(variant, TriviaKind::BlankLine);
201            let via_predicate_blank = variant.is_blank_line();
202            assert_eq!(
203                via_predicate_blank, via_matches_blank,
204                "TriviaKind::{name}.is_blank_line() must byte-equal \
205                 matches!(_, TriviaKind::BlankLine) — otherwise the \
206                 converged trim_leading_blanks call site in caixa-fmt \
207                 would silently disagree with its pre-lift shape"
208            );
209            let via_matches_line = matches!(variant, TriviaKind::LineComment(_));
210            let via_predicate_line = variant.is_line_comment();
211            assert_eq!(
212                via_predicate_line, via_matches_line,
213                "TriviaKind::{name}.is_line_comment() must byte-equal \
214                 matches!(_, TriviaKind::LineComment(_)) — otherwise the \
215                 converged contains_comment_trivia call site in caixa-fmt \
216                 would silently disagree with its pre-lift shape"
217            );
218        }
219    }
220}