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