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    /// 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}