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 /// Project the wrapped [`TriviaKind::Shebang`] body as `&str`, or
88 /// `None` on the sibling two arms ([`TriviaKind::BlankLine`] +
89 /// [`TriviaKind::LineComment`]) — the trivia-envelope-scoped surface
90 /// every downstream authoring consumer that only needs the shebang
91 /// body (a `caixa-fmt` shebang-verbatim writer-half emit that reaches
92 /// past the derived-discriminator predicate for the payload text at
93 /// `caixa-fmt/src/printer.rs:135`, a deferred `caixa-lint`
94 /// no-shebang-below-line-1 / shebang-must-name-a-known-interpreter
95 /// rule, a future `caixa-lsp` hover pop-up that renders the shebang
96 /// verbatim on mouse-over, a future `feira lint --list-shebangs`
97 /// operator-facing enumeration verb) partitions on.
98 ///
99 /// `pub const fn` — closes const-eval discipline on the [`Trivia`]
100 /// envelope-scoped projection axis onto the second (and only remaining)
101 /// payload-carrying arm, peer with the sibling
102 /// [`Self::comment_text`] `pub const fn` accessor already carrying
103 /// the same discipline on the [`TriviaKind::LineComment`] payload
104 /// arm. The body reaches for [`String::as_str`] on the
105 /// [`TriviaKind::Shebang`] borrowed-`String` slot — const-stable
106 /// since Rust 1.87, well before this workspace's 1.89 MSRV floor —
107 /// so the promotion is a body-preserving type-signature widening,
108 /// byte-for-byte identical to the sibling
109 /// [`Self::comment_text`] shape on the paired payload arm.
110 ///
111 /// Together with [`Self::comment_text`] this closes the two-payload-
112 /// arm per-envelope projection family on [`Trivia`]: every payload-
113 /// carrying [`TriviaKind`] arm now surfaces its borrowed `String`
114 /// slot through one substrate-primitive `Option<&str>` accessor on
115 /// the [`Trivia`] envelope, and the field-less [`TriviaKind::BlankLine`]
116 /// arm surfaces as `None` on both accessors — the exhaustive-arm
117 /// partition matching the paired [`gen_platform::IsVariant`]-derived
118 /// per-arm predicate roster ([`TriviaKind::is_line_comment`] /
119 /// [`TriviaKind::is_blank_line`] / [`TriviaKind::is_shebang`]) on
120 /// the trivia-arm-discriminator surface.
121 ///
122 /// Until this lift landed the shebang-body projection sat inline at
123 /// [`caixa-fmt`]'s writer-half emit (`caixa-fmt/src/printer.rs:135`,
124 /// `TriviaKind::Shebang(text) => { self.out.push_str(text); ... }`)
125 /// with no compile-time link to the paired [`Self::comment_text`]
126 /// projection on the sibling payload arm and no substrate-primitive
127 /// dispatch a future non-writer-side consumer (a caixa-lint shebang-
128 /// content rule, an LSP hover, a `feira` list-shebangs verb) could
129 /// reach for. A rename or shape change on the [`TriviaKind::Shebang`]
130 /// payload (a promotion of `String` to a typed `ShebangLine` newtype
131 /// once the substrate grows a per-shebang interpreter-parse gate,
132 /// a split of `Shebang` into `ShebangPound` / `ShebangInline` peers
133 /// as the M4 dialect surface widens) would have had to be threaded
134 /// through every open-coded inline projection in lockstep or the
135 /// writer half and the future consumers would silently disagree on
136 /// the shape they read. Lifting the projection to a typed method on
137 /// the [`Trivia`] envelope means every downstream consumer reaches
138 /// one typed dispatch on the substrate primitive — same discipline
139 /// the peer [`Self::comment_text`] lift already established for the
140 /// paired payload arm.
141 #[must_use]
142 pub const fn shebang_text(&self) -> Option<&str> {
143 match &self.kind {
144 TriviaKind::Shebang(s) => Some(s.as_str()),
145 TriviaKind::BlankLine | TriviaKind::LineComment(_) => None,
146 }
147 }
148}
149
150#[cfg(test)]
151mod is_variant_tests {
152 use super::*;
153
154 fn all_variants() -> Vec<(TriviaKind, &'static str)> {
155 vec![
156 (TriviaKind::LineComment("hello".into()), "LineComment"),
157 (TriviaKind::BlankLine, "BlankLine"),
158 (
159 TriviaKind::Shebang("#!/usr/bin/env tatara-script".into()),
160 "Shebang",
161 ),
162 ]
163 }
164
165 fn predicate_row(k: &TriviaKind) -> [bool; 3] {
166 [k.is_line_comment(), k.is_blank_line(), k.is_shebang()]
167 }
168
169 // Fail-before-pass-after pin on the [`gen_platform::IsVariant`]
170 // derive-generated per-arm predicate partition — for every variant in
171 // `all_variants()`, the observed 3-slot predicate row must equal a
172 // one-hot row with the `true` at exactly the same index as the
173 // variant's declaration order. Expected rows are generated live from
174 // the enumeration rather than transcribed by hand, so a copy-paste
175 // flip that reroutes one arm through the wrong predicate lane trips
176 // at the identity-diagonal assertion the way every peer sibling
177 // [`crate::NodeKind`] / `CaixaKind` / `CaixaDialeto` /
178 // `PathShapeViolation` / `RestartStrategy` partition pin already does.
179 #[test]
180 fn trivia_kind_is_variant_predicates_partition_the_arm_set() {
181 let variants = all_variants();
182 for (idx, (variant, name)) in variants.iter().enumerate() {
183 let observed = predicate_row(variant);
184 let mut expected = [false; 3];
185 expected[idx] = true;
186 assert_eq!(
187 observed, expected,
188 "TriviaKind::{name} at declaration-order slot {idx} must \
189 satisfy exactly one is_* predicate (its own); observed \
190 row must equal the one-hot expected row"
191 );
192 }
193 }
194
195 // Fail-before-pass-after pin on [`Trivia::comment_text`]'s
196 // `const`-eval-surface posture. The projection routes the wrapped
197 // [`TriviaKind::LineComment`] borrowed-`String` slot through the
198 // `pub const fn` [`String::as_str`] (const-stable since Rust 1.87,
199 // well within the workspace's 1.89 MSRV floor) — any future
200 // accidental downgrade to non-`const` fails
201 // `comment_text_via_const_fn` at caixa-ast build time with E0015
202 // (`cannot call non-const method`), strictly stronger than a runtime
203 // `assert!`. Sibling of the peer per-source-position-primitive
204 // `const`-eval-surface passes on the caixa-ast surface
205 // ([`crate::Span::new`] / [`crate::Span::point`] / [`crate::Span::len`]
206 // / [`crate::Span::is_empty`] / [`crate::Span::contains`] /
207 // [`crate::Span::union`] on the byte-offset axis,
208 // [`crate::Position::new`] / [`crate::Position::origin`] /
209 // [`crate::Position::line_column`] on the 1-indexed line/column
210 // axis, [`crate::NodeKind::seq_delims`] /
211 // [`crate::NodeKind::reader_macro_prefix`] /
212 // [`crate::NodeKind::as_keyword`] / [`crate::NodeKind::as_symbol`]
213 // / [`crate::NodeKind::as_str`] on the outer-NodeKind writer-half
214 // projection axis). The sweep exercises all three
215 // [`TriviaKind`] arms (a populated `LineComment` body, the field-
216 // less `BlankLine`, a populated `Shebang` body) so a copy-paste flip
217 // that reroutes one arm's return through the wrong projection lane
218 // trips at caixa-ast test time under `PartialEq` on the
219 // `Option<&str>` return shape rather than at a downstream
220 // caixa-fmt / caixa-lint / caixa-lsp consumer-observable drift.
221 #[test]
222 fn trivia_comment_text_projection_is_const_fn() {
223 const fn comment_text_via_const_fn(t: &Trivia) -> Option<&str> {
224 t.comment_text()
225 }
226 for (variant, expected) in [
227 (TriviaKind::LineComment("hello".into()), Some("hello")),
228 (TriviaKind::BlankLine, None),
229 (
230 TriviaKind::Shebang("#!/usr/bin/env tatara-script".into()),
231 None,
232 ),
233 ] {
234 let trivia = Trivia {
235 kind: variant,
236 span: Span::default(),
237 };
238 assert_eq!(
239 comment_text_via_const_fn(&trivia),
240 expected,
241 "Trivia::comment_text must project through the pub const \
242 fn body byte-equal to the pre-lift open-coded match on \
243 the same fixture",
244 );
245 assert_eq!(comment_text_via_const_fn(&trivia), trivia.comment_text());
246 }
247 }
248
249 // Fail-before-pass-after pin on [`Trivia::shebang_text`]'s
250 // `const`-eval-surface posture. The projection routes the wrapped
251 // [`TriviaKind::Shebang`] borrowed-`String` slot through the
252 // `pub const fn` [`String::as_str`] (const-stable since Rust 1.87,
253 // well within the workspace's 1.89 MSRV floor) — any future
254 // accidental downgrade to non-`const` fails
255 // `shebang_text_via_const_fn` at caixa-ast build time with E0015
256 // (`cannot call non-const method`), strictly stronger than a
257 // runtime `assert!`. Sibling of the peer
258 // [`Trivia::comment_text`] const-eval-surface pin
259 // ([`trivia_comment_text_projection_is_const_fn`]) — same shape
260 // extended onto the second (and only remaining) payload-carrying
261 // trivia arm. The sweep exercises all three [`TriviaKind`] arms
262 // (a populated `LineComment` body, the field-less `BlankLine`, a
263 // populated `Shebang` body) so a copy-paste flip that reroutes
264 // one arm's return through the wrong projection lane trips at
265 // caixa-ast test time under `PartialEq` on the `Option<&str>`
266 // return shape rather than at a downstream caixa-fmt / caixa-lint
267 // / caixa-lsp consumer-observable drift.
268 #[test]
269 fn trivia_shebang_text_projection_is_const_fn() {
270 const fn shebang_text_via_const_fn(t: &Trivia) -> Option<&str> {
271 t.shebang_text()
272 }
273 for (variant, expected) in [
274 (TriviaKind::LineComment("hello".into()), None),
275 (TriviaKind::BlankLine, None),
276 (
277 TriviaKind::Shebang("#!/usr/bin/env tatara-script".into()),
278 Some("#!/usr/bin/env tatara-script"),
279 ),
280 ] {
281 let trivia = Trivia {
282 kind: variant,
283 span: Span::default(),
284 };
285 assert_eq!(
286 shebang_text_via_const_fn(&trivia),
287 expected,
288 "Trivia::shebang_text must project through the pub const \
289 fn body byte-equal to the pre-lift open-coded match on \
290 the same fixture",
291 );
292 assert_eq!(shebang_text_via_const_fn(&trivia), trivia.shebang_text());
293 }
294 }
295
296 // Fail-before-pass-after cross-projection pin on the paired
297 // [`Trivia::comment_text`] + [`Trivia::shebang_text`] per-envelope
298 // projection family: for every [`TriviaKind`] arm, exactly one of
299 // the two `Option<&str>` accessors returns `Some(_)` on the two
300 // payload-carrying arms and both return `None` on the field-less
301 // [`TriviaKind::BlankLine`] arm. Refuses any future accessor drift
302 // that would make the two projections overlap on a shared arm (e.g.
303 // an accidental `TriviaKind::Shebang(s) => Some(s.as_str())` arm
304 // slipped into `comment_text`, or a copy-paste flip inverting the
305 // two accessors' `Some`/`None` arms) — a partition invariant every
306 // downstream consumer that fans on "which per-envelope projection
307 // owns this arm's borrowed-`String` slot?" (a future
308 // `caixa-lint --list-trivia-bodies` verb summing per-arm bodies
309 // through both accessors without double-counting, a caixa-lsp
310 // per-envelope hover pop-up composing the two projections into
311 // one text panel, an M4 authoring-side per-arm formatter overlay
312 // reaching each envelope through the paired accessor for the arm
313 // it owns) depends on. Sibling in shape to the peer per-arm
314 // partition pin
315 // [`trivia_kind_is_variant_predicates_partition_the_arm_set`]
316 // above on the paired [`gen_platform::IsVariant`]-derived
317 // arm-discriminator axis, extended onto the [`Trivia`]-envelope
318 // per-payload-arm projection axis.
319 #[test]
320 fn trivia_comment_text_and_shebang_text_partition_the_payload_arms() {
321 for (kind, name) in all_variants() {
322 let trivia = Trivia {
323 kind: kind.clone(),
324 span: Span::default(),
325 };
326 let comment = trivia.comment_text();
327 let shebang = trivia.shebang_text();
328 let both_some = comment.is_some() && shebang.is_some();
329 assert!(
330 !both_some,
331 "Trivia::{{comment_text, shebang_text}} must never both \
332 return Some on the same TriviaKind::{name} — the two \
333 per-envelope projection accessors partition the two \
334 payload-carrying arms",
335 );
336 match kind {
337 TriviaKind::LineComment(ref s) => {
338 assert_eq!(
339 comment,
340 Some(s.as_str()),
341 "Trivia::comment_text must return Some(_) on the \
342 TriviaKind::LineComment arm",
343 );
344 assert_eq!(
345 shebang, None,
346 "Trivia::shebang_text must return None on the \
347 TriviaKind::LineComment arm",
348 );
349 }
350 TriviaKind::BlankLine => {
351 assert_eq!(
352 comment, None,
353 "Trivia::comment_text must return None on the \
354 field-less TriviaKind::BlankLine arm",
355 );
356 assert_eq!(
357 shebang, None,
358 "Trivia::shebang_text must return None on the \
359 field-less TriviaKind::BlankLine arm",
360 );
361 }
362 TriviaKind::Shebang(ref s) => {
363 assert_eq!(
364 comment, None,
365 "Trivia::comment_text must return None on the \
366 TriviaKind::Shebang arm",
367 );
368 assert_eq!(
369 shebang,
370 Some(s.as_str()),
371 "Trivia::shebang_text must return Some(_) on the \
372 TriviaKind::Shebang arm",
373 );
374 }
375 }
376 }
377 }
378
379 // Byte-parity pin on the two field-agnostic `matches!` shapes this
380 // lift replaces at production call sites: the `TriviaKind::BlankLine`
381 // gate (caixa-fmt/src/printer.rs `trim_leading_blanks` take-while)
382 // and the `TriviaKind::LineComment(_)` gate (caixa-fmt/src/printer.rs
383 // `contains_comment_trivia` any). Refuses a future accidental split
384 // between the derived predicate and its pre-lift `matches!` shape
385 // (a hand-rolled shadow `impl` that overrides one path, an accidental
386 // rebrand of one converged call site back to the `matches!` form) on
387 // the two load-bearing trivia-arm-discriminator axes every downstream
388 // authoring consumer (caixa-fmt today, caixa-lint tomorrow) keys off.
389 #[test]
390 fn trivia_kind_is_blank_line_and_is_line_comment_byte_equal_pre_lift_matches_shape() {
391 for (variant, name) in all_variants() {
392 let via_matches_blank = matches!(variant, TriviaKind::BlankLine);
393 let via_predicate_blank = variant.is_blank_line();
394 assert_eq!(
395 via_predicate_blank, via_matches_blank,
396 "TriviaKind::{name}.is_blank_line() must byte-equal \
397 matches!(_, TriviaKind::BlankLine) — otherwise the \
398 converged trim_leading_blanks call site in caixa-fmt \
399 would silently disagree with its pre-lift shape"
400 );
401 let via_matches_line = matches!(variant, TriviaKind::LineComment(_));
402 let via_predicate_line = variant.is_line_comment();
403 assert_eq!(
404 via_predicate_line, via_matches_line,
405 "TriviaKind::{name}.is_line_comment() must byte-equal \
406 matches!(_, TriviaKind::LineComment(_)) — otherwise the \
407 converged contains_comment_trivia call site in caixa-fmt \
408 would silently disagree with its pre-lift shape"
409 );
410 }
411 }
412}