Skip to main content

backhopper_erlang_scan/
lib.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//! Erlang lexical scanning primitives.
6//!
7//! One home for the byte-level state machine the compatibility and
8//! call-graph layers share: strings, quoted atoms, backslash escapes,
9//! `$c` char literals, `%` comments, and `()` `[]` `{}` `<<>>` balancing.
10//! No model types, so the scanners return only offsets and slices and
11//! cannot construct a domain primitive.
12
13use std::borrow::Cow;
14
15/// The attribute keywords that declare a type name: their visibility
16/// and equivalence rules differ, which does not bear on whether a
17/// reference to the name resolves.
18pub fn is_type_declaration_keyword(name: &str) -> bool {
19    matches!(name, "type" | "opaque" | "nominal")
20}
21
22/// The attribute keywords whose bodies are type expressions, so a
23/// `mod:t(...)` inside them is a type reference, not a call.
24pub fn opens_type_ref_context(name: &str) -> bool {
25    is_type_declaration_keyword(name) || matches!(name, "spec" | "callback")
26}
27
28/// Byte length of the `$`-led char literal starting at `at`, covering
29/// `$x`, the single-char escapes, `$\^X`, `$\xHH`, `$\x{...}`, and one
30/// to three octal digits. `$` consumes exactly one char-or-escape.
31pub fn skip_char_literal_span(bytes: &[u8], at: usize) -> usize {
32    debug_assert_eq!(bytes[at], b'$');
33    let next = at + 1;
34    if next >= bytes.len() {
35        return 1;
36    }
37    if bytes[next] != b'\\' {
38        return 2;
39    }
40    let esc = next + 1;
41    if esc >= bytes.len() {
42        return 2;
43    }
44    match bytes[esc] {
45        b'^' => {
46            if esc + 1 < bytes.len() {
47                4
48            } else {
49                3
50            }
51        }
52        b'x' if bytes.get(esc + 1) == Some(&b'{') => {
53            let mut i = esc + 2;
54            while i < bytes.len() && bytes[i] != b'}' {
55                i += 1;
56            }
57            if i < bytes.len() { i + 1 - at } else { i - at }
58        }
59        b'x' => {
60            let mut i = esc + 1;
61            let stop = (esc + 3).min(bytes.len());
62            while i < stop && bytes[i].is_ascii_hexdigit() {
63                i += 1;
64            }
65            i - at
66        }
67        b'0'..=b'7' => {
68            let mut i = esc + 1;
69            let stop = (esc + 3).min(bytes.len());
70            while i < stop && (b'0'..=b'7').contains(&bytes[i]) {
71                i += 1;
72            }
73            i - at
74        }
75        _ => 3,
76    }
77}
78
79/// Byte length of the string literal starting at `at`: an ordinary
80/// escape-aware `"` string, a triple-quoted string, or a `~` sigil
81/// string. `None` when `at` starts none of them. An unterminated
82/// ordinary string runs to the end of the input.
83pub fn string_span(bytes: &[u8], at: usize) -> Option<usize> {
84    match bytes.get(at)? {
85        b'"' => {
86            if let Some(span) = triple_quoted_span(bytes, at) {
87                return Some(span);
88            }
89            Some(quoted_span(bytes, at, b'"'))
90        }
91        b'~' => sigil_span(bytes, at),
92        _ => None,
93    }
94}
95
96/// Byte length of the quoted atom starting at `at`, escape-aware;
97/// quoted atoms legally span newlines. `None` when `at` is not `'`.
98/// An unterminated atom runs to the end of the input.
99pub fn quoted_atom_span(bytes: &[u8], at: usize) -> Option<usize> {
100    if bytes.get(at) != Some(&b'\'') {
101        return None;
102    }
103    Some(quoted_span(bytes, at, b'\''))
104}
105
106/// Byte length of the string, quoted atom, or char literal opening at
107/// `at`, or `None` when none of them does. A scan that walks bytes and
108/// must not read structure out of literal contents steps over the span
109/// this returns.
110pub fn literal_span(bytes: &[u8], at: usize) -> Option<usize> {
111    match *bytes.get(at)? {
112        b'"' | b'~' => string_span(bytes, at),
113        b'\'' => quoted_atom_span(bytes, at),
114        b'$' => Some(skip_char_literal_span(bytes, at)),
115        _ => None,
116    }
117}
118
119fn quoted_span(bytes: &[u8], at: usize, quote: u8) -> usize {
120    let mut i = at + 1;
121    while i < bytes.len() {
122        match bytes[i] {
123            b'\\' => i = (i + 2).min(bytes.len()),
124            b if b == quote => return i + 1 - at,
125            _ => i += 1,
126        }
127    }
128    i - at
129}
130
131/// True when the byte at `i` is a `.` that ends a form: the next byte
132/// is whitespace, a `%` comment opener, or the end of the input. This
133/// is `erl_scan`'s rule.
134pub fn dot_terminates(bytes: &[u8], i: usize) -> bool {
135    if bytes.get(i) != Some(&b'.') {
136        return false;
137    }
138    match bytes.get(i + 1) {
139        None => true,
140        Some(b) => b.is_ascii_whitespace() || *b == b'%',
141    }
142}
143
144/// Digit value for `number_span`'s based forms: `0-9`, then `a-z` and
145/// `A-Z` as 10 to 35. `None` for anything else.
146fn based_digit_value(b: u8) -> Option<u32> {
147    match b {
148        b'0'..=b'9' => Some(u32::from(b - b'0')),
149        b'a'..=b'z' => Some(u32::from(b - b'a') + 10),
150        b'A'..=b'Z' => Some(u32::from(b - b'A') + 10),
151        _ => None,
152    }
153}
154
155/// Consume digits valid under `base` starting at `from`, with `_`
156/// allowed between digits. Returns the position after the run.
157fn based_digit_run(bytes: &[u8], from: usize, base: u32) -> usize {
158    let is_digit = |b: u8| based_digit_value(b).is_some_and(|v| v < base);
159    let mut i = from;
160    while i < bytes.len() {
161        if is_digit(bytes[i]) {
162            i += 1;
163        } else if bytes[i] == b'_' && i > from && bytes.get(i + 1).copied().is_some_and(is_digit) {
164            i += 1;
165        } else {
166            break;
167        }
168    }
169    i
170}
171
172/// Byte length of the numeric literal starting at `at`, or `None` when
173/// `at` is not an ASCII digit. Covers integers with `_` separators,
174/// `base#digits` for bases 2 to 36, decimal floats with optional
175/// signed exponents, and based floats (`base#int.frac` with an
176/// optional `#e` signed exponent). A malformed tail ends the span at
177/// the last valid position.
178pub fn number_span(bytes: &[u8], at: usize) -> Option<usize> {
179    if !bytes.get(at)?.is_ascii_digit() {
180        return None;
181    }
182    let int_end = based_digit_run(bytes, at, 10);
183    if bytes.get(int_end) == Some(&b'#') {
184        let base: Option<u32> = std::str::from_utf8(&bytes[at..int_end])
185            .ok()
186            .and_then(|s| s.replace('_', "").parse().ok());
187        let Some(base) = base.filter(|b| (2..=36).contains(b)) else {
188            return Some(int_end - at);
189        };
190        let digits_end = based_digit_run(bytes, int_end + 1, base);
191        if digits_end == int_end + 1 {
192            return Some(int_end - at);
193        }
194        let mut end = digits_end;
195        if bytes.get(end) == Some(&b'.')
196            && bytes
197                .get(end + 1)
198                .copied()
199                .is_some_and(|b| based_digit_value(b).is_some_and(|v| v < base))
200        {
201            end = based_digit_run(bytes, end + 1, base);
202            if bytes.get(end) == Some(&b'#')
203                && let Some(exp) = exponent_run(bytes, end + 1)
204            {
205                end = exp;
206            }
207        }
208        return Some(end - at);
209    }
210    let mut end = int_end;
211    if bytes.get(end) == Some(&b'.')
212        && bytes
213            .get(end + 1)
214            .copied()
215            .is_some_and(|b| b.is_ascii_digit())
216    {
217        end = based_digit_run(bytes, end + 1, 10);
218        if let Some(exp) = exponent_run(bytes, end) {
219            end = exp;
220        }
221    }
222    Some(end - at)
223}
224
225/// Consume `e|E [+-] digits` starting at `from`; `None` when the shape
226/// is not a complete exponent.
227fn exponent_run(bytes: &[u8], from: usize) -> Option<usize> {
228    if !matches!(bytes.get(from), Some(b'e') | Some(b'E')) {
229        return None;
230    }
231    let mut i = from + 1;
232    if matches!(bytes.get(i), Some(b'+') | Some(b'-')) {
233        i += 1;
234    }
235    if !bytes.get(i).copied().is_some_and(|b| b.is_ascii_digit()) {
236        return None;
237    }
238    Some(based_digit_run(bytes, i, 10))
239}
240
241/// True when the `#` at `at` sits inside one numeric literal, such as
242/// the base or exponent hash of `16#ff` or `16#fe.fe#e16`. The scan
243/// walks back to the token start, crossing name bytes and `.` or `#`
244/// flanked by name bytes; the guard fires only when that start is a
245/// digit at a token boundary and the number's span covers the `#`.
246pub fn hash_inside_number(bytes: &[u8], at: usize) -> bool {
247    debug_assert_eq!(bytes.get(at), Some(&b'#'));
248    let mut start = at;
249    while start > 0 {
250        let prev = bytes[start - 1];
251        if is_name_byte(prev) {
252            start -= 1;
253        } else if (prev == b'.' || prev == b'#')
254            && start >= 2
255            && is_name_byte(bytes[start - 2])
256            && bytes.get(start).copied().is_some_and(is_name_byte)
257        {
258            start -= 1;
259        } else {
260            break;
261        }
262    }
263    if start == at || !bytes[start].is_ascii_digit() {
264        return false;
265    }
266    number_span(bytes, start).is_some_and(|n| start + n > at)
267}
268
269/// Length of the run of `"` bytes starting at `at`, zero when `at` is
270/// not a quote or is past the end.
271fn quote_run_len(bytes: &[u8], at: usize) -> usize {
272    let mut i = at;
273    while i < bytes.len() && bytes[i] == b'"' {
274        i += 1;
275    }
276    i - at
277}
278
279fn skip_horizontal_ws(bytes: &[u8], mut i: usize) -> usize {
280    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
281        i += 1;
282    }
283    i
284}
285
286/// Byte length of the EEP-64 triple-quoted string starting at `at`, or
287/// `None` when `at` does not start one. Total: any position that does
288/// not begin a run of three or more `"` returns `None`.
289///
290/// The string ends at the next line whose first non-whitespace content
291/// is a run of at least as many quotes as the opener, and the span
292/// covers through the end of that run. Content in between is verbatim:
293/// an `-export([...]).` line inside a documentation block is string
294/// content, not an attribute. An opener with no closer runs to the end
295/// of the input, which is what a real parse of that source concludes.
296pub fn triple_quoted_span(bytes: &[u8], at: usize) -> Option<usize> {
297    let open = quote_run_len(bytes, at);
298    if open < 3 {
299        return None;
300    }
301    let mut i = at + open;
302    while i < bytes.len() {
303        while i < bytes.len() && bytes[i] != b'\n' {
304            i += 1;
305        }
306        if i == bytes.len() {
307            break;
308        }
309        i += 1;
310        let content = skip_horizontal_ws(bytes, i);
311        let close = quote_run_len(bytes, content);
312        if close >= open {
313            return Some(content + close - at);
314        }
315    }
316    Some(bytes.len() - at)
317}
318
319/// The closing delimiter matching an EEP-66 sigil's opening one, or
320/// `None` when the byte cannot open a sigil string.
321fn sigil_closer(open: u8) -> Option<u8> {
322    match open {
323        b'(' => Some(b')'),
324        b'[' => Some(b']'),
325        b'{' => Some(b'}'),
326        b'<' => Some(b'>'),
327        b'/' | b'|' | b'#' | b'`' | b'\'' | b'"' => Some(open),
328        _ => None,
329    }
330}
331
332/// Byte length of the EEP-66 sigil string (prefix, string, suffix)
333/// starting at the `~` at `at`, or `None` when `at` does not start one.
334/// Only the empty, `b`, and `s` prefixes process backslash escapes,
335/// matching `erl_scan`; an unterminated sigil runs to end of input.
336pub fn sigil_span(bytes: &[u8], at: usize) -> Option<usize> {
337    if bytes.get(at) != Some(&b'~') {
338        return None;
339    }
340    let mut i = at + 1;
341    while i < bytes.len() && is_name_byte(bytes[i]) {
342        i += 1;
343    }
344    let open = *bytes.get(i)?;
345    let closer = sigil_closer(open)?;
346    let prefix = &bytes[at + 1..i];
347    let escaped = matches!(prefix, b"" | b"b" | b"s");
348    if open == b'"'
349        && let Some(span) = triple_quoted_span(bytes, i)
350    {
351        i += span;
352    } else {
353        i += 1;
354        loop {
355            let Some(&b) = bytes.get(i) else {
356                return Some(bytes.len() - at);
357            };
358            i += 1;
359            if b == b'\\' && escaped {
360                i += 1;
361            } else if b == closer {
362                break;
363            }
364        }
365    }
366    while i < bytes.len() && is_name_byte(bytes[i]) {
367        i += 1;
368    }
369    Some(i - at)
370}
371
372/// Bracketless block tracking: `case`, `if`, `begin`, `receive`, and
373/// `try` open a region that one `end` closes, and the expression forms
374/// of `fun` and `maybe` do too. A comma inside such a region separates
375/// statements or clauses, never arguments.
376#[derive(Debug, Clone, Copy, Default)]
377pub struct BlockDepth(i32);
378
379impl BlockDepth {
380    pub fn in_block(self) -> bool {
381        self.0 > 0
382    }
383
384    /// Consume the identifier starting at `at` and adjust the depth.
385    /// Returns the bytes consumed; the caller guarantees `at` is a
386    /// token start on a lowercase letter outside literals.
387    pub fn observe(&mut self, bytes: &[u8], at: usize) -> usize {
388        let mut j = at;
389        while j < bytes.len() && is_name_byte(bytes[j]) {
390            j += 1;
391        }
392        // a keyword after `#` is a native-record name, not a block token
393        if preceded_by_hash(bytes, at) {
394            return j - at;
395        }
396        match &bytes[at..j] {
397            b"case" | b"if" | b"begin" | b"receive" | b"try" => self.0 += 1,
398            b"end" => self.0 = (self.0 - 1).max(0),
399            b"fun" if fun_opens_block(bytes, j) => self.0 += 1,
400            b"maybe" if maybe_opens_block(bytes, j) => self.0 += 1,
401            _ => {}
402        }
403        j - at
404    }
405}
406
407fn preceded_by_hash(bytes: &[u8], at: usize) -> bool {
408    let mut i = at;
409    while i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
410        i -= 1;
411    }
412    i > 0 && bytes[i - 1] == b'#'
413}
414
415// words that continue an expression rather than begin one: after atom
416// `maybe` they are the only legal word followers
417const EXPRESSION_CONTINUERS: [&[u8]; 19] = [
418    b"of", b"when", b"andalso", b"orelse", b"end", b"after", b"else", b"div", b"rem", b"band",
419    b"bor", b"bxor", b"bsl", b"bsr", b"and", b"or", b"xor", b"not", b"bnot",
420];
421
422/// `maybe` is a block opener since OTP 27 and a legal atom before it
423/// (and after `-feature(maybe_expr, disable)`). The gate fails closed:
424/// it opens a block only when the follower can begin a block body and
425/// cannot follow an atom; every uncertain case, hunk truncation
426/// included, keeps the atom reading and today's contained miscount.
427fn maybe_opens_block(bytes: &[u8], after_maybe: usize) -> bool {
428    let k = skip_ws(bytes, after_maybe);
429    let Some(&b) = bytes.get(k) else {
430        return false;
431    };
432    if b == b'<' {
433        return bytes.get(k + 1) == Some(&b'<');
434    }
435    match b {
436        b'[' | b'{' | b'"' | b'\'' | b'$' | b'~' => true,
437        _ if b.is_ascii_uppercase() || b == b'_' || b.is_ascii_digit() => true,
438        _ if b.is_ascii_lowercase() => {
439            let mut m = k;
440            while m < bytes.len() && is_name_byte(bytes[m]) {
441                m += 1;
442            }
443            !EXPRESSION_CONTINUERS.contains(&&bytes[k..m])
444        }
445        _ => false,
446    }
447}
448
449pub fn is_name_byte(b: u8) -> bool {
450    b.is_ascii_alphanumeric() || b == b'_' || b == b'@'
451}
452
453/// True when `s` is a bare atom needing no quotes: a lowercase start
454/// followed by name bytes only.
455pub fn is_bare_atom(s: &str) -> bool {
456    let bytes = s.as_bytes();
457    match bytes.first() {
458        Some(b) if b.is_ascii_lowercase() => {}
459        _ => return false,
460    }
461    bytes.iter().all(|&b| is_name_byte(b))
462}
463
464/// Invokes `visit` at each byte of `s` sitting at bracket depth zero,
465/// outside strings (ordinary, triple-quoted, and sigil), quoted atoms,
466/// `$c` char literals, and `%` comments. Depth counts `()` `[]` `{}`
467/// and `<<`/`>>` pairs. A `true` return stops the walk and yields that
468/// byte's offset; `None` means the walk reached the end.
469pub fn for_each_top_level_byte(s: &str, mut visit: impl FnMut(usize) -> bool) -> Option<usize> {
470    let bytes = s.as_bytes();
471    let mut depth = 0i32;
472    let mut depth_angle = 0i32;
473    let mut i = 0usize;
474    while i < bytes.len() {
475        match bytes[i] {
476            b'"' | b'~' => {
477                if let Some(span) = string_span(bytes, i) {
478                    i += span;
479                    continue;
480                }
481            }
482            b'\'' => {
483                if let Some(span) = quoted_atom_span(bytes, i) {
484                    i += span;
485                    continue;
486                }
487            }
488            b'$' => {
489                i += skip_char_literal_span(bytes, i);
490                continue;
491            }
492            b'%' => {
493                i += line_comment_span(bytes, i);
494                continue;
495            }
496            _ => {}
497        }
498        match bytes[i] {
499            b'<' if bytes.get(i + 1) == Some(&b'<') => {
500                depth_angle += 1;
501                i += 2;
502                continue;
503            }
504            b'>' if bytes.get(i + 1) == Some(&b'>') && depth_angle > 0 => {
505                depth_angle -= 1;
506                i += 2;
507                continue;
508            }
509            b'(' | b'[' | b'{' => depth += 1,
510            b')' | b']' | b'}' => depth -= 1,
511            _ if depth == 0 && depth_angle == 0 => {
512                if visit(i) {
513                    return Some(i);
514                }
515            }
516            _ => {}
517        }
518        i += 1;
519    }
520    None
521}
522
523/// `s` with every `%`-to-newline comment outside literals removed.
524/// List entries keep the comments the splitter saw around them; parsing
525/// an entry needs them gone.
526pub fn remove_line_comments(s: &str) -> Cow<'_, str> {
527    let bytes = s.as_bytes();
528    let mut out: Option<String> = None;
529    let mut i = 0usize;
530    while i < bytes.len() {
531        let span = match bytes[i] {
532            b'%' => {
533                out.get_or_insert_with(|| s[..i].to_string());
534                i += line_comment_span(bytes, i);
535                continue;
536            }
537            b'"' | b'~' => string_span(bytes, i).unwrap_or(1),
538            b'\'' => quoted_atom_span(bytes, i).unwrap_or(1),
539            b'$' => skip_char_literal_span(bytes, i),
540            _ => 1,
541        };
542        // a span may end inside a multi-byte char (`$รค` reads two bytes)
543        let mut end = (i + span).min(bytes.len());
544        while !s.is_char_boundary(end) {
545            end += 1;
546        }
547        if let Some(acc) = out.as_mut() {
548            acc.push_str(&s[i..end]);
549        }
550        i = end;
551    }
552    match out {
553        Some(acc) => Cow::Owned(acc),
554        None => Cow::Borrowed(s),
555    }
556}
557
558/// Bytes from the `%` at `at` to the end of the line, newline excluded.
559/// Attribute bodies reach the scanners with their comments in place, so
560/// a `% maybe` note in an export list must never feed the block depth.
561fn line_comment_span(bytes: &[u8], at: usize) -> usize {
562    let mut i = at;
563    while i < bytes.len() && bytes[i] != b'\n' {
564        i += 1;
565    }
566    i - at
567}
568
569fn skip_ws(bytes: &[u8], mut i: usize) -> usize {
570    loop {
571        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
572            i += 1;
573        }
574        if i < bytes.len() && bytes[i] == b'%' {
575            i += line_comment_span(bytes, i);
576            continue;
577        }
578        return i;
579    }
580}
581
582/// The expression forms `fun (...) ->`, `fun Name(...) ->`, and their
583/// guarded first clauses open a block; `fun m:f/1`-style references
584/// and the type forms `fun()` and `fun((...) -> ...)` do not. What
585/// follows the balanced paren group decides: an expression clause
586/// continues with `->` or a `when` guard, a type or reference does
587/// not, because a type's own `->` sits inside the group.
588fn fun_opens_block(bytes: &[u8], after_fun: usize) -> bool {
589    let mut j = skip_ws(bytes, after_fun);
590    if bytes
591        .get(j)
592        .is_some_and(|b| b.is_ascii_uppercase() || *b == b'_')
593    {
594        while j < bytes.len() && is_name_byte(bytes[j]) {
595            j += 1;
596        }
597        j = skip_ws(bytes, j);
598    }
599    if bytes.get(j) != Some(&b'(') {
600        return false;
601    }
602    let Some(close) = matching_bracket(bytes, j) else {
603        return false;
604    };
605    let j = skip_ws(bytes, close + 1);
606    bytes[j..].starts_with(b"->")
607        || (bytes[j..].starts_with(b"when") && !bytes.get(j + 4).copied().is_some_and(is_name_byte))
608}
609
610/// Byte offset of the bracket closing the `(`, `[`, or `{` at `open`,
611/// tracking nesting, strings, quoted atoms, and char literals. Only the
612/// opener's own kind is counted, so a bracket of another kind inside the
613/// group cannot close it early.
614pub fn matching_bracket(bytes: &[u8], open: usize) -> Option<usize> {
615    let opener = *bytes.get(open)?;
616    let closer = match opener {
617        b'(' => b')',
618        b'[' => b']',
619        b'{' => b'}',
620        _ => return None,
621    };
622    let mut depth = 0i32;
623    let mut in_str = false;
624    let mut in_atom = false;
625    let mut i = open;
626    while i < bytes.len() {
627        let b = bytes[i];
628        if in_str || in_atom {
629            match b {
630                b'\\' => i += 1,
631                b'"' if in_str => in_str = false,
632                b'\'' if in_atom => in_atom = false,
633                _ => {}
634            }
635            i += 1;
636            continue;
637        }
638        match b {
639            b'"' => {
640                if let Some(span) = triple_quoted_span(bytes, i) {
641                    i += span;
642                    continue;
643                }
644                in_str = true;
645            }
646            b'\'' => in_atom = true,
647            b'$' => {
648                i += skip_char_literal_span(bytes, i);
649                continue;
650            }
651            b'%' => {
652                i += line_comment_span(bytes, i);
653                continue;
654            }
655            b'~' => {
656                if let Some(span) = sigil_span(bytes, i) {
657                    i += span;
658                    continue;
659                }
660            }
661            _ if b == opener => depth += 1,
662            _ if b == closer => {
663                depth -= 1;
664                if depth == 0 {
665                    return Some(i);
666                }
667            }
668            _ => {}
669        }
670        i += 1;
671    }
672    None
673}
674
675/// Result of scanning one call's argument list after the opening `(`.
676/// `Unterminated` means the closing paren is not in the input, so any
677/// arity derived from it would be a guess.
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub enum ScannedArgs<'a> {
680    Terminated { args: Vec<&'a str>, consumed: usize },
681    Unterminated { args: Vec<&'a str> },
682}
683
684/// Arity evidence for one extracted call site.
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686pub enum ScanArity {
687    Exact(u8),
688    Unterminated,
689}
690
691/// The one argument-list scanner. Tracks nested `()` `[]` `{}` and
692/// `<<>>`, strings, quoted atoms, backslash escapes, and `$c` char
693/// literals (so `$,` `$(` `$"` stay inert).
694pub fn scan_top_level_args(after_open_paren: &str) -> ScannedArgs<'_> {
695    let bytes = after_open_paren.as_bytes();
696    let mut args: Vec<&str> = Vec::new();
697    let mut depth = 1i32;
698    let mut blocks = BlockDepth::default();
699    let mut in_str = false;
700    let mut in_atom = false;
701    let mut start = 0usize;
702    let mut i = 0usize;
703    while i < bytes.len() {
704        let c = bytes[i];
705        if in_str || in_atom {
706            match c {
707                b'\\' => i += 1,
708                b'"' if in_str => in_str = false,
709                b'\'' if in_atom => in_atom = false,
710                _ => {}
711            }
712            i += 1;
713            continue;
714        }
715        if c.is_ascii_lowercase() && (i == 0 || !is_name_byte(bytes[i - 1])) {
716            i += blocks.observe(bytes, i);
717            continue;
718        }
719        match c {
720            b'"' => {
721                if let Some(span) = triple_quoted_span(bytes, i) {
722                    i += span;
723                    continue;
724                }
725                in_str = true;
726            }
727            b'\'' => in_atom = true,
728            b'$' => {
729                i += skip_char_literal_span(bytes, i);
730                continue;
731            }
732            b'%' => {
733                i += line_comment_span(bytes, i);
734                continue;
735            }
736            b'~' => {
737                if let Some(span) = sigil_span(bytes, i) {
738                    i += span;
739                    continue;
740                }
741            }
742            b'<' if bytes.get(i + 1) == Some(&b'<') => {
743                depth += 1;
744                i += 1;
745            }
746            b'>' if bytes.get(i + 1) == Some(&b'>') && depth > 1 => {
747                depth -= 1;
748                i += 1;
749            }
750            b'(' | b'[' | b'{' => depth += 1,
751            b']' | b'}' => depth -= 1,
752            b')' => {
753                depth -= 1;
754                // a close at depth zero inside an unclosed block means a
755                // missing `end`: no terminated read, so no guessed arity
756                if depth == 0 && !blocks.in_block() {
757                    let slice = &after_open_paren[start..i];
758                    if !slice.trim().is_empty() || !args.is_empty() {
759                        args.push(slice);
760                    }
761                    return ScannedArgs::Terminated {
762                        args,
763                        consumed: i + 1,
764                    };
765                }
766            }
767            b',' if depth == 1 && !blocks.in_block() => {
768                args.push(&after_open_paren[start..i]);
769                start = i + 1;
770            }
771            _ => {}
772        }
773        i += 1;
774    }
775    if start < bytes.len() {
776        let slice = &after_open_paren[start..];
777        if !slice.trim().is_empty() {
778            args.push(slice);
779        }
780    }
781    ScannedArgs::Unterminated { args }
782}
783
784pub fn scan_arity(after_open_paren: &str) -> ScanArity {
785    match scan_top_level_args(after_open_paren) {
786        ScannedArgs::Terminated { args, .. } => {
787            ScanArity::Exact(u8::try_from(args.len()).unwrap_or(u8::MAX))
788        }
789        ScannedArgs::Unterminated { .. } => ScanArity::Unterminated,
790    }
791}
792
793/// Splits the source after an already-consumed opening `(` into
794/// top-level argument slices. Lenient about a missing close paren:
795/// callers that must distinguish use `scan_top_level_args`.
796pub fn split_top_level_args(after_open_paren: &str) -> Vec<&str> {
797    match scan_top_level_args(after_open_paren) {
798        ScannedArgs::Terminated { args, .. } | ScannedArgs::Unterminated { args } => args,
799    }
800}
801
802/// Number of top-level items in `s[open..close]`, e.g. tuple elements.
803pub fn count_top_level_items(s: &str, open: char, close: char) -> usize {
804    let body = match (s.find(open), s.rfind(close)) {
805        (Some(a), Some(b)) if b > a => &s[a + 1..b],
806        _ => return 0,
807    };
808    if body.trim().is_empty() {
809        return 0;
810    }
811    match scan_top_level_args(body) {
812        ScannedArgs::Terminated { args, .. } | ScannedArgs::Unterminated { args } => args.len(),
813    }
814}
815
816/// Invoke `on_comma` at each byte index of a comma sitting at bracket
817/// depth zero, outside strings, atoms, char literals, binaries, and
818/// `end`-closed block expressions. The shared separator definition
819/// used by both the splitter and the counter.
820fn for_each_top_level_comma(s: &str, mut on_comma: impl FnMut(usize)) {
821    let bytes = s.as_bytes();
822    let mut depth_paren = 0i32;
823    let mut depth_brace = 0i32;
824    let mut depth_brack = 0i32;
825    let mut depth_angle = 0i32;
826    let mut blocks = BlockDepth::default();
827    let mut in_string = false;
828    let mut in_atom_quote = false;
829    let mut prev_back = false;
830    let mut i = 0usize;
831    while i < bytes.len() {
832        let ch = bytes[i] as char;
833        if prev_back {
834            prev_back = false;
835            i += 1;
836            continue;
837        }
838        if !in_string && !in_atom_quote && ch == '$' {
839            i += skip_char_literal_span(bytes, i);
840            continue;
841        }
842        if !in_string && !in_atom_quote && ch == '%' {
843            i += line_comment_span(bytes, i);
844            continue;
845        }
846        if !in_string
847            && !in_atom_quote
848            && ch == '"'
849            && let Some(span) = triple_quoted_span(bytes, i)
850        {
851            i += span;
852            continue;
853        }
854        if !in_string
855            && !in_atom_quote
856            && ch == '~'
857            && let Some(span) = sigil_span(bytes, i)
858        {
859            i += span;
860            continue;
861        }
862        if !in_string
863            && !in_atom_quote
864            && ch.is_ascii_lowercase()
865            && (i == 0 || !is_name_byte(bytes[i - 1]))
866        {
867            i += blocks.observe(bytes, i);
868            continue;
869        }
870        if !in_string && !in_atom_quote && ch == '<' && bytes.get(i + 1) == Some(&b'<') {
871            depth_angle += 1;
872            i += 2;
873            continue;
874        }
875        if !in_string && !in_atom_quote && ch == '>' && bytes.get(i + 1) == Some(&b'>') {
876            depth_angle -= 1;
877            i += 2;
878            continue;
879        }
880        match ch {
881            '\\' if in_string || in_atom_quote => prev_back = true,
882            '"' if !in_atom_quote => in_string = !in_string,
883            '\'' if !in_string => in_atom_quote = !in_atom_quote,
884            '(' if !in_string && !in_atom_quote => depth_paren += 1,
885            ')' if !in_string && !in_atom_quote => depth_paren -= 1,
886            '{' if !in_string && !in_atom_quote => depth_brace += 1,
887            '}' if !in_string && !in_atom_quote => depth_brace -= 1,
888            '[' if !in_string && !in_atom_quote => depth_brack += 1,
889            ']' if !in_string && !in_atom_quote => depth_brack -= 1,
890            ',' if !in_string
891                && !in_atom_quote
892                && depth_paren == 0
893                && depth_brace == 0
894                && depth_brack == 0
895                && depth_angle == 0
896                && !blocks.in_block() =>
897            {
898                on_comma(i);
899            }
900            _ => {}
901        }
902        i += 1;
903    }
904}
905
906/// Split `s` at top-level commas, trimming each piece and dropping empty
907/// ones. For a bracketed list's inner text with no closing paren to stop
908/// at, such as a record field list or an attribute argument list.
909pub fn split_top_level_commas(s: &str) -> Vec<&str> {
910    let mut out: Vec<&str> = Vec::new();
911    let mut start = 0usize;
912    for_each_top_level_comma(s, |i| {
913        out.push(s[start..i].trim());
914        start = i + 1;
915    });
916    if start < s.len() {
917        out.push(s[start..].trim());
918    }
919    out.into_iter().filter(|x| !x.is_empty()).collect()
920}
921
922/// Count top-level commas in `s`. Arity counters add one for a non-empty
923/// argument list.
924pub fn count_top_level_commas(s: &str) -> usize {
925    let mut n = 0usize;
926    for_each_top_level_comma(s, |_| n += 1);
927    n
928}
929
930/// Result of scanning one list literal's elements after the opening
931/// `[`. `ImproperTail` means a top-level `|`, so the element count is
932/// not the list's length. `Unterminated` means the closing bracket is
933/// not in the input.
934#[derive(Debug, Clone, PartialEq, Eq)]
935pub enum ScannedList<'a> {
936    Terminated {
937        elements: Vec<&'a str>,
938        consumed: usize,
939    },
940    ImproperTail,
941    Unterminated,
942}
943
944/// The list-literal form of `scan_top_level_args`: splits the source
945/// after an already-consumed opening `[` into top-level element slices,
946/// with the same string, atom, char-literal, and binary handling.
947pub fn scan_list_elements(after_open_bracket: &str) -> ScannedList<'_> {
948    let bytes = after_open_bracket.as_bytes();
949    let mut elements: Vec<&str> = Vec::new();
950    let mut depth = 1i32;
951    let mut in_str = false;
952    let mut in_atom = false;
953    let mut start = 0usize;
954    let mut i = 0usize;
955    while i < bytes.len() {
956        let c = bytes[i];
957        if in_str || in_atom {
958            match c {
959                b'\\' => i += 1,
960                b'"' if in_str => in_str = false,
961                b'\'' if in_atom => in_atom = false,
962                _ => {}
963            }
964            i += 1;
965            continue;
966        }
967        match c {
968            b'"' => {
969                if let Some(span) = triple_quoted_span(bytes, i) {
970                    i += span;
971                    continue;
972                }
973                in_str = true;
974            }
975            b'\'' => in_atom = true,
976            b'$' => {
977                i += skip_char_literal_span(bytes, i);
978                continue;
979            }
980            b'%' => {
981                i += line_comment_span(bytes, i);
982                continue;
983            }
984            b'~' => {
985                if let Some(span) = sigil_span(bytes, i) {
986                    i += span;
987                    continue;
988                }
989            }
990            b'<' if bytes.get(i + 1) == Some(&b'<') => {
991                depth += 1;
992                i += 1;
993            }
994            b'>' if bytes.get(i + 1) == Some(&b'>') && depth > 1 => {
995                depth -= 1;
996                i += 1;
997            }
998            b'(' | b'[' | b'{' => depth += 1,
999            b')' | b'}' => depth -= 1,
1000            b']' => {
1001                depth -= 1;
1002                if depth == 0 {
1003                    let slice = &after_open_bracket[start..i];
1004                    if !slice.trim().is_empty() || !elements.is_empty() {
1005                        elements.push(slice);
1006                    }
1007                    return ScannedList::Terminated {
1008                        elements,
1009                        consumed: i + 1,
1010                    };
1011                }
1012            }
1013            // || is a comprehension, not a cons tail, and makes the element count meaningless
1014            b'|' if depth == 1 => return ScannedList::ImproperTail,
1015            b',' if depth == 1 => {
1016                elements.push(&after_open_bracket[start..i]);
1017                start = i + 1;
1018            }
1019            _ => {}
1020        }
1021        i += 1;
1022    }
1023    ScannedList::Unterminated
1024}
1025
1026/// A `-spec` or `-callback` body reduced to its name, arity, and the
1027/// canonical `name(Args) -> Ret` signature string.
1028#[derive(Debug, Clone, PartialEq, Eq)]
1029pub struct ParsedSignature {
1030    pub name: String,
1031    pub arity: u8,
1032    pub signature: String,
1033}
1034
1035/// Splits a callable head `name(Args)Rest` into the name, the inner
1036/// argument text, and the trailing text after the closing `)`. Returns
1037/// `None` when the head does not start with an unqualified name followed
1038/// by `(`: module-qualified forms (`Mod:f(...)`) fail because the `:`
1039/// ends the name and the next character is not `(`.
1040/// Splits a leading Erlang name off the front of `s`, returning the name
1041/// and the remaining text (both trimmed). The name may be empty when `s`
1042/// does not start with a name character.
1043pub fn split_leading_name(s: &str) -> (&str, &str) {
1044    let trimmed = s.trim_start();
1045    let name_end = trimmed
1046        .char_indices()
1047        .find(|(_, c)| !c.is_ascii_alphanumeric() && *c != '_' && *c != '@' && *c != '\'')
1048        .map(|(i, _)| i)
1049        .unwrap_or(trimmed.len());
1050    (&trimmed[..name_end], trimmed[name_end..].trim_start())
1051}
1052
1053pub fn split_name_and_args(body: &str) -> Option<(&str, &str, &str)> {
1054    let (name, after_name) = split_leading_name(body);
1055    if name.is_empty() || !after_name.starts_with('(') {
1056        return None;
1057    }
1058    let (args, rest) = take_balanced_parens(after_name)?;
1059    Some((name, args, rest.trim_start()))
1060}
1061
1062/// Top-level argument count for the inner text between a call's parens.
1063pub fn arity_of_args(args: &str) -> usize {
1064    count_top_level_commas(args) + if args.trim().is_empty() { 0 } else { 1 }
1065}
1066
1067/// Parses a `-spec` or `-callback` body (`name(Args) -> Ret`, no
1068/// leading attribute keyword, no trailing `.`). Module-qualified forms
1069/// return `None`.
1070pub fn parse_callable_signature(body: &str) -> Option<ParsedSignature> {
1071    let (name, args, rest) = split_name_and_args(body)?;
1072    let after_arrow = rest.strip_prefix("->")?.trim_start();
1073    let signature = format!("{}({}) -> {}", name, args.trim(), after_arrow.trim());
1074    Some(ParsedSignature {
1075        name: name.to_string(),
1076        arity: arity_of_args(args).min(255) as u8,
1077        signature,
1078    })
1079}
1080
1081/// Given `s` starting at `(`, returns the inner source and the rest
1082/// after the matching `)`, tracking nested parens, strings, and atoms.
1083pub fn take_balanced_parens(s: &str) -> Option<(&str, &str)> {
1084    if !s.starts_with('(') {
1085        return None;
1086    }
1087    let bytes = s.as_bytes();
1088    let mut depth = 0i32;
1089    let mut in_string = false;
1090    let mut in_atom_quote = false;
1091    let mut i = 0usize;
1092    while i < bytes.len() {
1093        let ch = bytes[i] as char;
1094        if !in_string && !in_atom_quote && ch == '$' {
1095            i += skip_char_literal_span(bytes, i);
1096            continue;
1097        }
1098        if !in_string && !in_atom_quote && ch == '%' {
1099            i += line_comment_span(bytes, i);
1100            continue;
1101        }
1102        if !in_string
1103            && !in_atom_quote
1104            && ch == '"'
1105            && let Some(span) = triple_quoted_span(bytes, i)
1106        {
1107            i += span;
1108            continue;
1109        }
1110        if !in_string
1111            && !in_atom_quote
1112            && ch == '~'
1113            && let Some(span) = sigil_span(bytes, i)
1114        {
1115            i += span;
1116            continue;
1117        }
1118        match ch {
1119            '\\' if in_string || in_atom_quote => {
1120                i += 2;
1121                continue;
1122            }
1123            '"' if !in_atom_quote => in_string = !in_string,
1124            '\'' if !in_string => in_atom_quote = !in_atom_quote,
1125            '(' if !in_string && !in_atom_quote => depth += 1,
1126            ')' if !in_string && !in_atom_quote => {
1127                depth -= 1;
1128                if depth == 0 {
1129                    return Some((&s[1..i], &s[i + 1..]));
1130                }
1131            }
1132            _ => {}
1133        }
1134        i += 1;
1135    }
1136    None
1137}