Skip to main content

backhopper_xref_reader/
scanner.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Byte-level scanner for Erlang source.
6//!
7//! Tracks position (line, column, byte offset), skips comments and strings,
8//! and exposes helpers the reader uses to find function clauses and call
9//! sites. Not a full Erlang tokenizer: just what the v1 xref reader needs.
10//!
11//! A sibling block-oriented tokenizer lives in `backhopper_erlang::tokenizer`.
12//! Consolidation is deferred until a third consumer needs it.
13
14use backhopper_xref_graph::Position;
15
16#[derive(Debug, Clone)]
17pub struct Scanner<'a> {
18    src: &'a str,
19    bytes: &'a [u8],
20    pos: Position,
21}
22
23impl<'a> Scanner<'a> {
24    pub fn new(src: &'a str) -> Self {
25        Self {
26            src,
27            bytes: src.as_bytes(),
28            pos: Position::zero(),
29        }
30    }
31
32    pub fn pos(&self) -> Position {
33        self.pos
34    }
35
36    pub fn src(&self) -> &'a str {
37        self.src
38    }
39
40    pub fn src_slice(&self, start: usize, end: usize) -> &'a str {
41        &self.src[start..end]
42    }
43
44    /// Set position. Used for backtracking when a heuristic match fails.
45    pub fn set_pos(&mut self, pos: Position) {
46        self.pos = pos;
47    }
48
49    pub fn is_eof(&self) -> bool {
50        self.pos.byte_offset as usize >= self.bytes.len()
51    }
52
53    pub fn peek(&self) -> Option<u8> {
54        self.bytes.get(self.pos.byte_offset as usize).copied()
55    }
56
57    pub fn peek_at(&self, offset: usize) -> Option<u8> {
58        self.bytes
59            .get(self.pos.byte_offset as usize + offset)
60            .copied()
61    }
62
63    pub fn advance(&mut self) -> Option<u8> {
64        let p = self.pos.byte_offset as usize;
65        if p >= self.bytes.len() {
66            return None;
67        }
68        let b = self.bytes[p];
69        self.pos.byte_offset += 1;
70        if b == b'\n' {
71            self.pos.line += 1;
72            self.pos.column = 1;
73        } else {
74            self.pos.column += 1;
75        }
76        Some(b)
77    }
78
79    pub fn skip_line(&mut self) {
80        while let Some(b) = self.peek() {
81            self.advance();
82            if b == b'\n' {
83                break;
84            }
85        }
86    }
87
88    /// Skip whitespace and Erlang `% ...` comments. Stops at the first
89    /// non-trivia byte.
90    pub fn skip_trivia(&mut self) {
91        loop {
92            match self.peek() {
93                Some(b) if b.is_ascii_whitespace() => {
94                    self.advance();
95                }
96                Some(b'%') => self.skip_line(),
97                _ => break,
98            }
99        }
100    }
101
102    /// Consume a string literal starting at `"`, leaving the cursor
103    /// just past the closing quote. Handles escaped characters minimally.
104    pub fn consume_string(&mut self) {
105        debug_assert_eq!(self.peek(), Some(b'"'));
106        self.advance();
107        while let Some(b) = self.peek() {
108            self.advance();
109            if b == b'\\' {
110                self.advance();
111                continue;
112            }
113            if b == b'"' {
114                break;
115            }
116        }
117    }
118
119    /// Consume a quoted-atom literal starting at `'`, leaving the cursor
120    /// just past the closing quote. Does not validate the contents.
121    pub fn consume_quoted_atom(&mut self) {
122        debug_assert_eq!(self.peek(), Some(b'\''));
123        self.advance();
124        while let Some(b) = self.peek() {
125            self.advance();
126            if b == b'\\' {
127                self.advance();
128                continue;
129            }
130            if b == b'\'' {
131                break;
132            }
133        }
134    }
135
136    /// Consume a `$c`, `$\n`, or `$\^X` character literal.
137    pub fn consume_char_literal(&mut self) {
138        debug_assert_eq!(self.peek(), Some(b'$'));
139        self.advance();
140        if self.peek() == Some(b'\\') {
141            self.advance();
142            if self.peek() == Some(b'^') {
143                self.advance();
144            }
145            self.advance();
146        } else {
147            self.advance();
148        }
149    }
150
151    /// Read an unquoted identifier (atom or variable). Returns the slice
152    /// of source covered.
153    pub fn consume_identifier(&mut self) -> &'a str {
154        let start = self.pos.byte_offset as usize;
155        while let Some(b) = self.peek() {
156            if b.is_ascii_alphanumeric() || b == b'_' || b == b'@' {
157                self.advance();
158            } else {
159                break;
160            }
161        }
162        let end = self.pos.byte_offset as usize;
163        &self.src[start..end]
164    }
165
166    /// Read a `?MACRO` reference returning the macro name without the `?`.
167    pub fn consume_macro_ref(&mut self) -> &'a str {
168        debug_assert_eq!(self.peek(), Some(b'?'));
169        self.advance();
170        let start = self.pos.byte_offset as usize;
171        while let Some(b) = self.peek() {
172            if b.is_ascii_alphanumeric() || b == b'_' {
173                self.advance();
174            } else {
175                break;
176            }
177        }
178        let end = self.pos.byte_offset as usize;
179        &self.src[start..end]
180    }
181
182    /// Skip a balanced parenthesized expression, beginning at `(`.
183    /// Tracks nested `()`, `[]`, `{}`, strings, and atoms.
184    pub fn skip_balanced_parens(&mut self) -> u32 {
185        debug_assert_eq!(self.peek(), Some(b'('));
186        self.advance();
187        let mut depth: i32 = 1;
188        let mut top_level_commas: u32 = 0;
189        while let Some(b) = self.peek() {
190            match b {
191                b'(' => {
192                    depth += 1;
193                    self.advance();
194                }
195                b')' => {
196                    depth -= 1;
197                    self.advance();
198                    if depth == 0 {
199                        return top_level_commas;
200                    }
201                }
202                b'[' | b'{' => {
203                    // Inner brackets: track depth through them.
204                    let opener = b;
205                    let closer = if opener == b'[' { b']' } else { b'}' };
206                    self.advance();
207                    let mut inner = 1i32;
208                    while let Some(b2) = self.peek() {
209                        match b2 {
210                            x if x == opener => {
211                                inner += 1;
212                                self.advance();
213                            }
214                            x if x == closer => {
215                                inner -= 1;
216                                self.advance();
217                                if inner == 0 {
218                                    break;
219                                }
220                            }
221                            b'"' => self.consume_string(),
222                            b'\'' => self.consume_quoted_atom(),
223                            b'$' => self.consume_char_literal(),
224                            b'%' => self.skip_line(),
225                            _ => {
226                                self.advance();
227                            }
228                        }
229                    }
230                }
231                b',' if depth == 1 => {
232                    top_level_commas += 1;
233                    self.advance();
234                }
235                b'"' => self.consume_string(),
236                b'\'' => self.consume_quoted_atom(),
237                b'$' => self.consume_char_literal(),
238                b'%' => self.skip_line(),
239                _ => {
240                    self.advance();
241                }
242            }
243        }
244        top_level_commas
245    }
246}
247
248/// Outcome of a `walk_paren_group` pass over a `(...)` group.
249#[derive(Debug, Clone, Copy)]
250pub struct ParenWalk {
251    pub commas: u32,
252    pub had_content: bool,
253    pub closed: bool,
254}
255
256/// Walks the balanced `(...)` group at the current `(`, which must be the
257/// next byte. Tracks nested `()`, `[]`, `{}`, and `<<>>`; skips strings,
258/// quoted atoms, char literals, and `%` comments. At each byte that can
259/// begin an identifier or `?macro` use, `on_ident` runs with the cursor on
260/// that byte so callers can surface nested call sites; if it returns false
261/// the byte is consumed and the walk continues. Reports the top-level comma
262/// count, whether any non-whitespace content appeared, and whether the
263/// matching `)` was reached.
264pub fn walk_paren_group(
265    sc: &mut Scanner<'_>,
266    mut on_ident: impl FnMut(&mut Scanner<'_>) -> bool,
267) -> ParenWalk {
268    debug_assert_eq!(sc.peek(), Some(b'('));
269    sc.advance();
270    let mut depth = 1i32;
271    let mut commas: u32 = 0;
272    let mut had_content = false;
273    let mut closed = false;
274    while let Some(byte) = sc.peek() {
275        match byte {
276            b'(' => {
277                depth += 1;
278                had_content = true;
279                sc.advance();
280            }
281            b')' => {
282                depth -= 1;
283                sc.advance();
284                if depth == 0 {
285                    closed = true;
286                    break;
287                }
288            }
289            b'[' | b'{' => {
290                depth += 1;
291                had_content = true;
292                sc.advance();
293            }
294            b']' | b'}' => {
295                depth -= 1;
296                sc.advance();
297            }
298            // << and >> are binary delimiters: nest like brackets so the commas
299            // inside a binary literal are not counted as arguments.
300            b'<' if sc.peek_at(1) == Some(b'<') => {
301                depth += 1;
302                had_content = true;
303                sc.advance();
304                sc.advance();
305            }
306            b'>' if sc.peek_at(1) == Some(b'>') => {
307                depth -= 1;
308                sc.advance();
309                sc.advance();
310            }
311            b',' if depth == 1 => {
312                commas += 1;
313                sc.advance();
314            }
315            b'"' => {
316                sc.consume_string();
317                had_content = true;
318            }
319            b'\'' => {
320                sc.consume_quoted_atom();
321                had_content = true;
322            }
323            b'$' => {
324                sc.consume_char_literal();
325                had_content = true;
326            }
327            b'%' => sc.skip_line(),
328            b'?' | b'a'..=b'z' | b'A'..=b'Z' | b'_' => {
329                had_content = true;
330                if !on_ident(sc) {
331                    sc.advance();
332                }
333            }
334            _ => {
335                if !byte.is_ascii_whitespace() {
336                    had_content = true;
337                }
338                sc.advance();
339            }
340        }
341    }
342    ParenWalk {
343        commas,
344        had_content,
345        closed,
346    }
347}
348
349/// Returns the arity inferred from the argument-list source `(a, b, c)`.
350/// Empty parens yields 0; one or more top-level commas yields commas+1.
351pub fn arity_from_commas(top_level_commas: u32, had_any_content: bool) -> u32 {
352    if had_any_content {
353        top_level_commas + 1
354    } else {
355        0
356    }
357}