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::{sigil_span, skip_char_literal_span, 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 the sigil string starting at `~`, or the single byte when
129    /// `~` is an ordinary operator here.
130    pub fn consume_sigil_or_advance(&mut self) {
131        debug_assert_eq!(self.peek(), Some(b'~'));
132        let span = sigil_span(self.bytes, self.pos.byte_offset as usize).unwrap_or(1);
133        for _ in 0..span {
134            self.advance();
135        }
136    }
137
138    /// Consume a quoted-atom literal starting at `'`, leaving the cursor
139    /// just past the closing quote. Does not validate the contents.
140    pub fn consume_quoted_atom(&mut self) {
141        debug_assert_eq!(self.peek(), Some(b'\''));
142        self.advance();
143        while let Some(b) = self.peek() {
144            self.advance();
145            if b == b'\\' {
146                self.advance();
147                continue;
148            }
149            if b == b'\'' {
150                break;
151            }
152        }
153    }
154
155    /// Consume a `$c`, `$\n`, or `$\^X` character literal.
156    pub fn consume_char_literal(&mut self) {
157        debug_assert_eq!(self.peek(), Some(b'$'));
158        let at = self.pos.byte_offset as usize;
159        let span = skip_char_literal_span(self.bytes, at);
160        // advance byte by byte to keep the position counters honest
161        for _ in 0..span {
162            self.advance();
163        }
164    }
165
166    /// Read an unquoted identifier (atom or variable). Returns the slice
167    /// of source covered.
168    pub fn consume_identifier(&mut self) -> &'a str {
169        let start = self.pos.byte_offset as usize;
170        while let Some(b) = self.peek() {
171            if b.is_ascii_alphanumeric() || b == b'_' || b == b'@' {
172                self.advance();
173            } else {
174                break;
175            }
176        }
177        let end = self.pos.byte_offset as usize;
178        &self.src[start..end]
179    }
180
181    /// Read a `?MACRO` reference returning the macro name without the `?`.
182    pub fn consume_macro_ref(&mut self) -> &'a str {
183        debug_assert_eq!(self.peek(), Some(b'?'));
184        self.advance();
185        let start = self.pos.byte_offset as usize;
186        while let Some(b) = self.peek() {
187            if b.is_ascii_alphanumeric() || b == b'_' {
188                self.advance();
189            } else {
190                break;
191            }
192        }
193        let end = self.pos.byte_offset as usize;
194        &self.src[start..end]
195    }
196
197    /// Skip a balanced parenthesized expression, beginning at `(`.
198    /// Tracks nested `()`, `[]`, `{}`, strings, and atoms.
199    pub fn skip_balanced_parens(&mut self) -> u32 {
200        debug_assert_eq!(self.peek(), Some(b'('));
201        self.advance();
202        let mut depth: i32 = 1;
203        let mut top_level_commas: u32 = 0;
204        while let Some(b) = self.peek() {
205            match b {
206                b'(' => {
207                    depth += 1;
208                    self.advance();
209                }
210                b')' => {
211                    depth -= 1;
212                    self.advance();
213                    if depth == 0 {
214                        return top_level_commas;
215                    }
216                }
217                b'[' | b'{' => {
218                    // Inner brackets: track depth through them.
219                    let opener = b;
220                    let closer = if opener == b'[' { b']' } else { b'}' };
221                    self.advance();
222                    let mut inner = 1i32;
223                    while let Some(b2) = self.peek() {
224                        match b2 {
225                            x if x == opener => {
226                                inner += 1;
227                                self.advance();
228                            }
229                            x if x == closer => {
230                                inner -= 1;
231                                self.advance();
232                                if inner == 0 {
233                                    break;
234                                }
235                            }
236                            b'"' => self.consume_string(),
237                            b'~' => self.consume_sigil_or_advance(),
238                            b'\'' => self.consume_quoted_atom(),
239                            b'$' => self.consume_char_literal(),
240                            b'%' => self.skip_line(),
241                            _ => {
242                                self.advance();
243                            }
244                        }
245                    }
246                }
247                b',' if depth == 1 => {
248                    top_level_commas += 1;
249                    self.advance();
250                }
251                b'"' => self.consume_string(),
252                b'~' => self.consume_sigil_or_advance(),
253                b'\'' => self.consume_quoted_atom(),
254                b'$' => self.consume_char_literal(),
255                b'%' => self.skip_line(),
256                _ => {
257                    self.advance();
258                }
259            }
260        }
261        top_level_commas
262    }
263}
264
265/// Outcome of a `walk_paren_group` pass over a `(...)` group.
266#[derive(Debug, Clone, Copy)]
267pub struct ParenWalk {
268    pub commas: u32,
269    pub had_content: bool,
270    pub closed: bool,
271}
272
273/// Scans the balanced `(...)` group at the current `(`, which must be the
274/// next byte. Tracks nested `()`, `[]`, `{}`, and `<<>>`; skips strings,
275/// quoted atoms, char literals, and `%` comments. At each byte that can
276/// begin an identifier or `?macro` use, `on_ident` runs with the cursor on
277/// that byte so callers can surface nested call sites; if it returns false
278/// the byte is consumed and the walk continues. Reports the top-level comma
279/// count, whether any non-whitespace content appeared, and whether the
280/// matching `)` was reached.
281pub fn walk_paren_group(
282    sc: &mut Scanner<'_>,
283    mut on_ident: impl FnMut(&mut Scanner<'_>) -> bool,
284) -> ParenWalk {
285    debug_assert_eq!(sc.peek(), Some(b'('));
286    sc.advance();
287    let mut depth = 1i32;
288    let mut commas: u32 = 0;
289    let mut had_content = false;
290    let mut closed = false;
291    while let Some(byte) = sc.peek() {
292        match byte {
293            b'(' => {
294                depth += 1;
295                had_content = true;
296                sc.advance();
297            }
298            b')' => {
299                depth -= 1;
300                sc.advance();
301                if depth == 0 {
302                    closed = true;
303                    break;
304                }
305            }
306            b'[' | b'{' => {
307                depth += 1;
308                had_content = true;
309                sc.advance();
310            }
311            b']' | b'}' => {
312                depth -= 1;
313                sc.advance();
314            }
315            // << and >> nest like brackets so commas inside a binary literal are not counted as arguments.
316            b'<' if sc.peek_at(1) == Some(b'<') => {
317                depth += 1;
318                had_content = true;
319                sc.advance();
320                sc.advance();
321            }
322            b'>' if sc.peek_at(1) == Some(b'>') => {
323                depth -= 1;
324                sc.advance();
325                sc.advance();
326            }
327            b',' if depth == 1 => {
328                commas += 1;
329                sc.advance();
330            }
331            b'"' => {
332                sc.consume_string();
333                had_content = true;
334            }
335            b'\'' => {
336                sc.consume_quoted_atom();
337                had_content = true;
338            }
339            b'$' => {
340                sc.consume_char_literal();
341                had_content = true;
342            }
343            b'%' => sc.skip_line(),
344            b'?' | b'a'..=b'z' | b'A'..=b'Z' | b'_' => {
345                had_content = true;
346                if !on_ident(sc) {
347                    sc.advance();
348                }
349            }
350            _ => {
351                if !byte.is_ascii_whitespace() {
352                    had_content = true;
353                }
354                sc.advance();
355            }
356        }
357    }
358    ParenWalk {
359        commas,
360        had_content,
361        closed,
362    }
363}
364
365/// Returns the arity inferred from the argument-list source `(a, b, c)`.
366/// Empty parens yields 0; one or more top-level commas yields commas+1.
367pub fn arity_from_commas(top_level_commas: u32, had_any_content: bool) -> u32 {
368    if had_any_content {
369        top_level_commas + 1
370    } else {
371        0
372    }
373}