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 blocks = 0u32;
290    let mut had_content = false;
291    let mut closed = false;
292    let mut in_word = false;
293    while let Some(byte) = sc.peek() {
294        let at_word_start = !in_word;
295        in_word = byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'@');
296        if at_word_start && byte.is_ascii_lowercase() {
297            match block_effect(sc) {
298                BlockEffect::Open(len) => {
299                    blocks += 1;
300                    had_content = true;
301                    advance_by(sc, len);
302                    in_word = false;
303                    continue;
304                }
305                BlockEffect::Close(len) => {
306                    blocks = blocks.saturating_sub(1);
307                    had_content = true;
308                    advance_by(sc, len);
309                    in_word = false;
310                    continue;
311                }
312                BlockEffect::None => {}
313            }
314        }
315        match byte {
316            b'(' => {
317                depth += 1;
318                had_content = true;
319                sc.advance();
320            }
321            b')' => {
322                depth -= 1;
323                sc.advance();
324                if depth == 0 {
325                    closed = true;
326                    break;
327                }
328            }
329            b'[' | b'{' => {
330                depth += 1;
331                had_content = true;
332                sc.advance();
333            }
334            b']' | b'}' => {
335                depth -= 1;
336                sc.advance();
337            }
338            // << and >> nest like brackets so commas inside a binary literal are not counted as arguments.
339            b'<' if sc.peek_at(1) == Some(b'<') => {
340                depth += 1;
341                had_content = true;
342                sc.advance();
343                sc.advance();
344            }
345            b'>' if sc.peek_at(1) == Some(b'>') => {
346                depth -= 1;
347                sc.advance();
348                sc.advance();
349            }
350            b',' if depth == 1 && blocks == 0 => {
351                commas += 1;
352                sc.advance();
353            }
354            b'"' => {
355                sc.consume_string();
356                had_content = true;
357            }
358            b'\'' => {
359                sc.consume_quoted_atom();
360                had_content = true;
361            }
362            b'$' => {
363                sc.consume_char_literal();
364                had_content = true;
365            }
366            b'%' => sc.skip_line(),
367            b'?' | b'a'..=b'z' | b'A'..=b'Z' | b'_' => {
368                had_content = true;
369                if !on_ident(sc) {
370                    sc.advance();
371                }
372            }
373            _ => {
374                if !byte.is_ascii_whitespace() {
375                    had_content = true;
376                }
377                sc.advance();
378            }
379        }
380    }
381    ParenWalk {
382        commas,
383        had_content,
384        closed,
385    }
386}
387
388enum BlockEffect {
389    Open(usize),
390    Close(usize),
391    None,
392}
393
394const BLOCK_OPENERS: &[&str] = &["case", "if", "receive", "try", "begin", "maybe"];
395
396/// Classifies the word at the cursor: a keyword that opens an `end`-closed
397/// block (a `fun` only when it is a fun expression, not `fun m:f/a`), the
398/// `end` that closes one, or neither. Commas inside a block are statement
399/// separators, not arguments.
400fn block_effect(sc: &mut Scanner<'_>) -> BlockEffect {
401    let start = sc.pos();
402    let word = sc.consume_identifier();
403    let len = word.len();
404    let effect = if word == "end" {
405        BlockEffect::Close(len)
406    } else if BLOCK_OPENERS.contains(&word) || (word == "fun" && opens_fun_expression(sc)) {
407        BlockEffect::Open(len)
408    } else {
409        BlockEffect::None
410    };
411    sc.set_pos(start);
412    effect
413}
414
415/// After `fun`: `(` starts a fun expression, and so does a name followed by
416/// `(` (a named fun); `m:f/a` and `f/a` do not.
417fn opens_fun_expression(sc: &mut Scanner<'_>) -> bool {
418    sc.skip_trivia();
419    if sc.peek() == Some(b'(') {
420        return true;
421    }
422    let name = sc.consume_identifier();
423    if name.is_empty() {
424        return false;
425    }
426    sc.skip_trivia();
427    sc.peek() == Some(b'(')
428}
429
430fn advance_by(sc: &mut Scanner<'_>, len: usize) {
431    for _ in 0..len {
432        sc.advance();
433    }
434}
435
436/// Returns the arity inferred from the argument-list source `(a, b, c)`.
437/// Empty parens yields 0; one or more top-level commas yields commas+1.
438pub fn arity_from_commas(top_level_commas: u32, had_any_content: bool) -> u32 {
439    if had_any_content {
440        top_level_commas + 1
441    } else {
442        0
443    }
444}