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}