Skip to main content

cel_parser/
trivia.rs

1//! Shared trivia recovery: turns the raw source text of a grammatically-constrained gap
2//! (whitespace, comments, and a known fixed sequence of literal tokens) into an ordered sequence
3//! of [`GapPiece`]s, and converts `proc_macro2` span positions to byte offsets. Used by
4//! `cel-parser`'s own expression formatter and by `adam-lang`'s declaration formatter, so both
5//! recover comments the same way. See <https://github.com/stlab/cel-rs/issues/201>.
6
7use proc_macro2::LineColumn;
8
9/// A recovered `//`/`/* */` comment, remembering which delimiter style the source used so a
10/// formatter can reproduce it instead of normalizing every comment to one style.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub enum Comment {
13    /// A line comment's text — its `//` and surrounding whitespace stripped. A producer that
14    /// merges a run of consecutive `//` lines (e.g. adam-lang's trivia recovery) stores them
15    /// joined with `\n`; cel-parser's `scan_gap` emits one `Line` per `//` line.
16    Line(String),
17    /// A `/* text */` block comment (single- or multi-line), inner text `\n`-joined with the
18    /// outer `/*`/`*/` delimiters and per-line indentation stripped. Nested delimiters remain.
19    Block(String),
20}
21
22/// One element of a scanned gap: either a recovered comment or one of the literal tokens the
23/// caller declared it expected to find there.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum GapPiece {
26    /// A comment found in the gap.
27    Comment(Comment),
28    /// One expected literal token (an operator symbol, delimiter, comma, or keyword).
29    Punct(&'static str),
30}
31
32/// Scans the raw text of a grammatically-constrained gap — whitespace, `//`/`/* */` comments, and
33/// exactly the literal tokens in `expected`, in that order — returning every comment found plus
34/// every `expected` token, interleaved in source order. Block comments nest; their inner
35/// delimiters and text remain intact apart from per-line whitespace normalization.
36///
37/// - Postcondition: the result contains each element of `expected` exactly once, as a
38///   `GapPiece::Punct`, in `expected`'s order, regardless of how much of `gap` actually matched
39///   (an empty or synthetic `gap` still yields all expected tokens, so a hand-built `Expr` with no
40///   real source re-synthesizes its separators).
41///
42/// - Complexity: O(n) in `gap.len()`.
43pub fn scan_gap(gap: &str, expected: &[&'static str]) -> Vec<GapPiece> {
44    let mut pieces = Vec::new();
45    let mut next = 0usize;
46    let mut rest = gap;
47    loop {
48        rest = rest.trim_start();
49        if rest.is_empty() {
50            break;
51        }
52        if let Some(after) = rest.strip_prefix("//") {
53            let end = after.find('\n').unwrap_or(after.len());
54            pieces.push(GapPiece::Comment(Comment::Line(
55                after[..end].trim().to_string(),
56            )));
57            rest = &after[end..];
58        } else if let Some((inner, after)) = take_block_comment(rest) {
59            pieces.push(GapPiece::Comment(Comment::Block(normalize_block(inner))));
60            rest = after;
61        } else if next < expected.len() && rest.starts_with(expected[next]) {
62            pieces.push(GapPiece::Punct(expected[next]));
63            rest = &rest[expected[next].len()..];
64            next += 1;
65        } else {
66            // The gap didn't match the next expected token (synthetic/empty source, or an
67            // unforeseen shape). Stop scanning; remaining expected tokens are synthesized below.
68            break;
69        }
70    }
71    while next < expected.len() {
72        pieces.push(GapPiece::Punct(expected[next]));
73        next += 1;
74    }
75    pieces
76}
77
78/// Splits a leading, nesting-aware block comment into its inner text and remaining source.
79///
80/// Returns `None` when `source` does not start with `/*`. An unterminated comment consumes
81/// the remaining source as inner text, preserving trivia recovery's best-effort behavior.
82///
83/// - Complexity: O(n) in the leading comment's byte length, with O(1) additional space.
84pub(crate) fn take_block_comment(source: &str) -> Option<(&str, &str)> {
85    let inner = source.strip_prefix("/*")?;
86    let bytes = inner.as_bytes();
87    let mut depth = 1usize;
88    let mut offset = 0usize;
89    while offset + 1 < bytes.len() {
90        match &bytes[offset..offset + 2] {
91            b"/*" => {
92                depth += 1;
93                offset += 2;
94            }
95            b"*/" => {
96                depth -= 1;
97                if depth == 0 {
98                    return Some((&inner[..offset], &inner[offset + 2..]));
99                }
100                offset += 2;
101            }
102            _ => offset += 1,
103        }
104    }
105    Some((inner, ""))
106}
107
108/// Normalizes a block comment's inner text (between `/*` and `*/`): trims each line and joins the
109/// non-empty ones with `\n`, matching `adam-lang`'s existing `Comment::Block` convention.
110///
111/// - Complexity: O(n) in `inner.len()`.
112fn normalize_block(inner: &str) -> String {
113    inner
114        .lines()
115        .map(str::trim)
116        .filter(|l| !l.is_empty())
117        .collect::<Vec<_>>()
118        .join("\n")
119}
120
121/// Returns the byte offset of the start of each line in `source`: `result[line - 1]` is the start
122/// of 1-based line `line` (matching [`proc_macro2::LineColumn::line`]'s convention).
123///
124/// - Complexity: O(n) in `source.len()`.
125pub fn line_start_byte_offsets(source: &str) -> Vec<usize> {
126    let mut offsets = vec![0usize];
127    let mut byte = 0usize;
128    for line in source.split_inclusive('\n') {
129        byte += line.len();
130        offsets.push(byte);
131    }
132    offsets
133}
134
135/// Converts a [`LineColumn`] (1-based line, 0-based character column) to a byte offset in
136/// `source`, using `line_starts` (from [`line_start_byte_offsets`]).
137///
138/// - Precondition: `line_starts` was built from exactly `source`, and `pos.line - 1` is in range.
139///
140/// - Complexity: O(k) in `pos.column`.
141pub fn line_column_to_byte(source: &str, line_starts: &[usize], pos: LineColumn) -> usize {
142    debug_assert!(
143        pos.line >= 1 && pos.line - 1 < line_starts.len(),
144        "pos.line out of range for line_starts"
145    );
146    let line_start = line_starts[pos.line - 1];
147    line_start
148        + source[line_start..]
149            .chars()
150            .take(pos.column)
151            .map(char::len_utf8)
152            .sum::<usize>()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn comments(pieces: Vec<GapPiece>) -> Vec<Comment> {
160        pieces
161            .into_iter()
162            .filter_map(|p| match p {
163                GapPiece::Comment(c) => Some(c),
164                GapPiece::Punct(_) => None,
165            })
166            .collect()
167    }
168
169    fn puncts(pieces: &[GapPiece]) -> Vec<&'static str> {
170        pieces
171            .iter()
172            .filter_map(|p| match p {
173                GapPiece::Punct(s) => Some(*s),
174                GapPiece::Comment(_) => None,
175            })
176            .collect()
177    }
178
179    #[test]
180    fn empty_gap_yields_just_the_expected_tokens() {
181        let pieces = scan_gap(" ", &["+"]);
182        assert_eq!(puncts(&pieces), vec!["+"]);
183        assert!(comments(pieces).is_empty());
184    }
185
186    #[test]
187    fn a_block_comment_before_the_token_is_recovered_in_order() {
188        let pieces = scan_gap(" /* a */ + ", &["+"]);
189        assert!(matches!(pieces[0], GapPiece::Comment(Comment::Block(ref t)) if t == "a"));
190        assert!(matches!(pieces[1], GapPiece::Punct("+")));
191    }
192
193    #[test]
194    fn a_block_comment_after_the_token_is_recovered_in_order() {
195        let pieces = scan_gap(" + /* b */ ", &["+"]);
196        assert!(matches!(pieces[0], GapPiece::Punct("+")));
197        assert!(matches!(pieces[1], GapPiece::Comment(Comment::Block(ref t)) if t == "b"));
198    }
199
200    #[test]
201    fn comments_on_both_sides_of_the_token_keep_source_order() {
202        let pieces = scan_gap(" /* a */ + /* b */ ", &["+"]);
203        assert!(matches!(pieces[0], GapPiece::Comment(Comment::Block(ref t)) if t == "a"));
204        assert!(matches!(pieces[1], GapPiece::Punct("+")));
205        assert!(matches!(pieces[2], GapPiece::Comment(Comment::Block(ref t)) if t == "b"));
206    }
207
208    #[test]
209    fn two_consecutive_block_comments_are_two_pieces() {
210        let pieces = scan_gap(" /* a */ /* b */ + ", &["+"]);
211        assert_eq!(comments(pieces.clone()).len(), 2);
212        assert!(matches!(pieces[2], GapPiece::Punct("+")));
213    }
214
215    /// Verifies nested delimiters stay in one comment without swallowing adjacent tokens.
216    #[test]
217    fn nested_block_comments_preserve_text_and_token_order() {
218        assert_eq!(
219            scan_gap(
220                " /* outer /* α /* deep */ β */ outer */ . /* after /* inner */ end */ ",
221                &["."],
222            ),
223            vec![
224                GapPiece::Comment(Comment::Block(
225                    "outer /* α /* deep */ β */ outer".to_string(),
226                )),
227                GapPiece::Punct("."),
228                GapPiece::Comment(Comment::Block("after /* inner */ end".to_string())),
229            ],
230        );
231    }
232
233    /// Verifies comment splitting handles empty, adjacent, Unicode, and incomplete input.
234    #[test]
235    fn block_comment_splitting_preserves_boundaries() {
236        for (source, expected) in [
237            ("", None),
238            ("/", None),
239            ("// line", None),
240            ("/**/.x", Some(("", ".x"))),
241            ("/*/**/*/.x", Some(("/**/", ".x"))),
242            ("/* α /* β */ γ */δ", Some((" α /* β */ γ ", "δ"))),
243            ("/* one *//* two */", Some((" one ", "/* two */"))),
244            ("/*", Some(("", ""))),
245            ("/* α", Some((" α", ""))),
246            ("/* outer /* inner */", Some((" outer /* inner */", ""))),
247            ("/*/*/", Some(("/*/", ""))),
248        ] {
249            assert_eq!(take_block_comment(source), expected, "{source}");
250        }
251    }
252
253    #[test]
254    fn two_consecutive_line_comments_are_two_pieces() {
255        let pieces = scan_gap(" // a\n // b\n + ", &["+"]);
256        assert_eq!(comments(pieces.clone()).len(), 2);
257        assert!(matches!(pieces.last(), Some(GapPiece::Punct("+"))));
258    }
259
260    #[test]
261    fn a_line_comment_is_recovered_as_comment_line() {
262        let pieces = scan_gap(" + // hi\n", &["+"]);
263        assert!(matches!(pieces[0], GapPiece::Punct("+")));
264        assert!(matches!(pieces[1], GapPiece::Comment(Comment::Line(ref t)) if t == "hi"));
265    }
266
267    #[test]
268    fn a_multi_line_block_comment_joins_its_inner_lines() {
269        let pieces = scan_gap(" /*\n  one\n  two\n*/ + ", &["+"]);
270        assert!(matches!(pieces[0], GapPiece::Comment(Comment::Block(ref t)) if t == "one\ntwo"));
271    }
272
273    #[test]
274    fn multiple_expected_tokens_are_returned_in_order() {
275        // Between a callee and its first arg with a trailing comma case:
276        let pieces = scan_gap(" ( /* c */ ", &["("]);
277        assert!(matches!(pieces[0], GapPiece::Punct("(")));
278        assert!(matches!(pieces[1], GapPiece::Comment(Comment::Block(ref t)) if t == "c"));
279    }
280
281    #[test]
282    fn missing_expected_token_is_still_synthesized() {
283        // A synthetic/empty gap (hand-built Expr) must still yield every expected token.
284        let pieces = scan_gap("", &["(", ")"]);
285        assert_eq!(puncts(&pieces), vec!["(", ")"]);
286    }
287
288    #[test]
289    fn line_column_to_byte_maps_a_position_on_the_second_line() {
290        let source = "ab\ncd";
291        let starts = line_start_byte_offsets(source);
292        // line 2 (1-based), column 1 (0-based) => the 'd', byte offset 4.
293        let pos = LineColumn { line: 2, column: 1 };
294        assert_eq!(line_column_to_byte(source, &starts, pos), 4);
295    }
296}