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