Skip to main content

backhopper_core/compat/
source_attributes.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 scanners for the two Erlang attributes that drive
6//! `HeaderFileMissing` and `BehaviourModuleMissing`.
7//!
8//! Both attributes have stable, line-oriented shapes:
9//!
10//!   * `-behaviour(custom_ct_hook).` (also spelled `-behavior(...)` in
11//!     OTP-style sources)
12//!   * `-include("rel/path.hrl").` and
13//!     `-include_lib("app/include/file.hrl").`
14//!
15//! We scan the source byte by byte, balancing strings, char literals,
16//! and `%` line comments. Anything more complex (macro-substituted
17//! attributes, `parse_transform`-injected values) is not visible to the
18//! scanner: those cases are left to a follow-up
19//! `--deep-helper-resolution` pass.
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::path::{Path, PathBuf};
23use std::str;
24use std::str::FromStr;
25
26use backhopper_erlang_scan::{
27    arity_of_args, count_top_level_commas, dot_terminates, hash_inside_number, is_name_byte,
28    is_type_declaration_keyword, parse_callable_signature, quoted_atom_span,
29    skip_char_literal_span, string_span, take_balanced_parens,
30};
31
32use crate::compat::target_tree_index::TargetTreeIndex;
33use crate::compat::test_suite::module_resolves;
34use crate::model::names::{Arity, FunctionName, ModuleName, RelativePath, TypeName};
35use crate::model::symbol::RefContext;
36use crate::model::verdict::IncludeDirective;
37use crate::snapshot::spec_normalize::normalize_signature;
38
39/// One scanned `-behaviour(M)` declaration, with the line number for
40/// the user-facing report.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct BehaviourRef {
43    pub behaviour: ModuleName,
44    pub line: u32,
45}
46
47/// One scanned `-include(...)` or `-include_lib(...)` declaration,
48/// with the line number for the user-facing report.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct IncludeRef {
51    pub directive: IncludeDirective,
52    pub line: u32,
53}
54
55/// One scanned `?MACRO` or `#record` use, with its line for the report.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ScannedUse {
58    pub name: String,
59    pub line: u32,
60}
61
62/// Macro names defined via `-define(NAME, ...)` or
63/// `-define(NAME(Args), ...)`. Conditional nesting is ignored: a macro
64/// with any `-define` counts as present, so the resolver never
65/// false-flags a defined macro (the rare inactive-branch define is the
66/// one documented miss).
67pub fn extract_defined_macros(src: &str) -> BTreeSet<String> {
68    iter_attribute_bodies(src, &["define"])
69        .into_iter()
70        .filter_map(|hit| parse_macro_define_name(hit.body))
71        .collect()
72}
73
74/// One `-define(NAME, ...)` or `-define(NAME(Args), ...)` form:
75/// `params` is `None` for the object-like form, and `body` is the
76/// definition text with comments stripped and whitespace collapsed.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct MacroDef {
79    pub params: Option<usize>,
80    pub body: String,
81}
82
83/// Every `-define` in `src` with its normalized body, keyed by name.
84/// A name defined more than once (`-ifdef` branches, or coexisting
85/// object-like and function-like forms) keeps every definition, so the
86/// caller can withhold instead of guessing which one is live.
87pub fn extract_defined_macro_values(src: &str) -> BTreeMap<String, Vec<MacroDef>> {
88    let mut out: BTreeMap<String, Vec<MacroDef>> = BTreeMap::new();
89    for hit in iter_attribute_bodies(src, &["define"]) {
90        let Some((name, params, value)) = parse_macro_define(hit.body) else {
91            continue;
92        };
93        out.entry(name).or_default().push(MacroDef {
94            params,
95            body: normalize_macro_body(value),
96        });
97    }
98    out
99}
100
101/// Splits a `-define` body into name, parameter count, and value text.
102fn parse_macro_define(body: &str) -> Option<(String, Option<usize>, &str)> {
103    let trimmed = body.trim_start();
104    let name_end = trimmed
105        .char_indices()
106        .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '@'))
107        .map(|(i, _)| i)
108        .unwrap_or(trimmed.len());
109    if name_end == 0 {
110        return None;
111    }
112    let name = trimmed[..name_end].to_owned();
113    let rest = trimmed[name_end..].trim_start();
114    let (params, after) = if rest.starts_with('(') {
115        let (args, after_args) = take_balanced_parens(rest)?;
116        let count = if args.trim().is_empty() {
117            0
118        } else {
119            count_top_level_commas(args) + 1
120        };
121        (Some(count), after_args.trim_start())
122    } else {
123        (None, rest)
124    };
125    let value = after.strip_prefix(',')?;
126    Some((name, params, value))
127}
128
129/// Comments stripped, whitespace runs collapsed to one space, ends
130/// trimmed: enough to equate reformatting without equating bodies that
131/// differ.
132fn normalize_macro_body(body: &str) -> String {
133    let mut out = String::with_capacity(body.len());
134    let mut chars = body.chars();
135    let mut in_string = false;
136    let mut in_atom = false;
137    while let Some(c) = chars.next() {
138        if in_string || in_atom {
139            out.push(c);
140            match c {
141                '\\' => {
142                    if let Some(next) = chars.next() {
143                        out.push(next);
144                    }
145                }
146                '"' if in_string => in_string = false,
147                '\'' if in_atom => in_atom = false,
148                _ => {}
149            }
150            continue;
151        }
152        match c {
153            '%' => {
154                for next in chars.by_ref() {
155                    if next == '\n' {
156                        break;
157                    }
158                }
159                if !out.ends_with(' ') {
160                    out.push(' ');
161                }
162            }
163            '"' => {
164                in_string = true;
165                out.push(c);
166            }
167            '\'' => {
168                in_atom = true;
169                out.push(c);
170            }
171            '$' => {
172                out.push(c);
173                if let Some(next) = chars.next() {
174                    out.push(next);
175                }
176            }
177            c if c.is_whitespace() => {
178                if !out.ends_with(' ') {
179                    out.push(' ');
180                }
181            }
182            c => out.push(c),
183        }
184    }
185    out.trim().to_owned()
186}
187
188/// Every `?MACRO` reference in `src`, comments and strings skipped.
189/// `??NAME` (stringify) counts as a use of `NAME`.
190pub fn extract_macro_uses(src: &str) -> Vec<ScannedUse> {
191    scan_uses(src, b'?', |bytes, j| {
192        let mut k = j;
193        while k < bytes.len() && bytes[k] == b'?' {
194            k += 1;
195        }
196        let start = k;
197        while k < bytes.len() && is_name_byte(bytes[k]) {
198            k += 1;
199        }
200        (start, k)
201    })
202}
203
204/// Every `#record` use in `src` (creation, match, update, field
205/// access), comments and strings skipped. `#{...}` maps have no name
206/// and are ignored.
207pub fn extract_record_uses(src: &str) -> Vec<ScannedUse> {
208    scan_uses(src, b'#', |bytes, j| {
209        let mut start = j;
210        if bytes.get(start) == Some(&b'\'') {
211            start += 1;
212            let mut k = start;
213            while k < bytes.len() && bytes[k] != b'\'' {
214                k += 1;
215            }
216            return (start, k);
217        }
218        // `#_{...}` wildcards and `#Var{...}` native records declare no
219        // lowercase record name: reporting one flags a missing record
220        match bytes.get(start) {
221            Some(b) if b.is_ascii_lowercase() => {}
222            _ => return (start, start),
223        }
224        let mut k = start;
225        while k < bytes.len() && is_name_byte(bytes[k]) {
226            k += 1;
227        }
228        (start, k)
229    })
230}
231
232/// Record names defined via `-record(name, {...})`.
233pub fn extract_defined_records(src: &str) -> BTreeSet<String> {
234    iter_attribute_bodies(src, &["record"])
235        .into_iter()
236        .filter_map(|hit| parse_single_atom_argument(hit.body))
237        .collect()
238}
239
240/// Walk `src` for `sigil`-prefixed uses, skipping comments, strings,
241/// and char literals. `name_span` returns the byte range of the name
242/// just past the sigil; an empty range (e.g. a `#{` map) yields no use.
243fn scan_uses(
244    src: &str,
245    sigil: u8,
246    name_span: impl Fn(&[u8], usize) -> (usize, usize),
247) -> Vec<ScannedUse> {
248    let bytes = src.as_bytes();
249    let mut out = Vec::new();
250    let mut i = 0;
251    let mut line = 1u32;
252    while i < bytes.len() {
253        match bytes[i] {
254            b'\n' => {
255                line += 1;
256                i += 1;
257            }
258            b'%' => {
259                while i < bytes.len() && bytes[i] != b'\n' {
260                    i += 1;
261                }
262            }
263            b'"' | b'~' => {
264                let end = i + string_span(bytes, i).unwrap_or(1);
265                line += newline_count(&bytes[i..end]);
266                i = end;
267            }
268            b'$' => i += skip_char_literal_span(bytes, i),
269            b'\'' => {
270                let end = i + quoted_atom_span(bytes, i).unwrap_or(1);
271                line += newline_count(&bytes[i..end]);
272                i = end;
273            }
274            b'#' if sigil == b'#' && hash_inside_number(bytes, i) => i += 1,
275            b if b == sigil => {
276                let (start, end) = name_span(bytes, i + 1);
277                if end > start {
278                    out.push(ScannedUse {
279                        name: String::from_utf8_lossy(&bytes[start..end]).into_owned(),
280                        line,
281                    });
282                }
283                i = end.max(i + 1);
284            }
285            _ => i += 1,
286        }
287    }
288    out
289}
290
291/// One `name(Args)` occurrence: a clause head when followed by `->` or
292/// `when`, a call otherwise. Arity is the top-level argument count.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct FunctionSignature {
295    pub name: String,
296    pub arity: usize,
297    pub line: u32,
298    pub is_definition: bool,
299}
300
301/// Every scanned `name(Args)` occurrence, classified into definitions and
302/// calls. Qualified (`mod:f(`), macro (`?f(`), and `fun name(` forms
303/// are skipped, as are reserved words. Variables (`Var(...)`) and
304/// attribute or spec forms (`-export(...)`, `-spec ... term()`) hold no
305/// local calls, so they are consumed without emitting a signature.
306pub fn extract_function_signatures(src: &str) -> Vec<FunctionSignature> {
307    signatures_with_line_context(src, None)
308}
309
310/// `extract_function_signatures` for an added-lines blob, with each
311/// blob line's hunk-walked classification. The byte-level attribute
312/// tracking alone misreads a blob whose attribute opener or terminator
313/// sits on a dropped context line; `ctx` is authoritative at each line
314/// start and the intra-line transitions still apply within a line.
315pub fn extract_function_signatures_with_context(
316    src: &str,
317    ctx: &[RefContext],
318) -> Vec<FunctionSignature> {
319    signatures_with_line_context(src, Some(ctx))
320}
321
322fn signatures_with_line_context(src: &str, ctx: Option<&[RefContext]>) -> Vec<FunctionSignature> {
323    let bytes = src.as_bytes();
324    let mut out = Vec::new();
325    let mut i = 0;
326    let mut line = 1u32;
327    // inside an attribute or spec form type names look like zero-arity calls
328    let mut in_attribute = line_starts_attribute(ctx, line);
329    while i < bytes.len() {
330        match bytes[i] {
331            b'\n' => {
332                line += 1;
333                if ctx.is_some() {
334                    in_attribute = line_starts_attribute(ctx, line);
335                }
336                i += 1;
337            }
338            b'%' => {
339                while i < bytes.len() && bytes[i] != b'\n' {
340                    i += 1;
341                }
342            }
343            b'"' | b'~' => {
344                let end = i + string_span(bytes, i).unwrap_or(1);
345                line += count_newlines(&bytes[i..end]);
346                i = end;
347            }
348            b'$' => i += skip_char_literal_span(bytes, i),
349            b'\'' => {
350                let end = i + quoted_atom_span(bytes, i).unwrap_or(1);
351                line += count_newlines(&bytes[i..end]);
352                i = end;
353            }
354            b'-' if !in_attribute && at_form_start(bytes, i) => {
355                in_attribute = true;
356                i += 1;
357            }
358            b'.' if in_attribute && dot_terminates(bytes, i) => {
359                in_attribute = false;
360                i += 1;
361            }
362            b if b.is_ascii_uppercase() || b == b'_' => {
363                // consume the whole variable name so its tail is not re-lexed
364                while i < bytes.len() && is_name_byte(bytes[i]) {
365                    i += 1;
366                }
367            }
368            b if b.is_ascii_lowercase() => {
369                let start = i;
370                let mut j = i;
371                while j < bytes.len() && is_name_byte(bytes[j]) {
372                    j += 1;
373                }
374                let name = &bytes[start..j];
375                let qualified = start > 0 && matches!(bytes[start - 1], b':' | b'?' | b'#');
376                if bytes.get(j) == Some(&b'(') && !qualified && !is_reserved_word(name) {
377                    if let Some(close) = match_parens(bytes, j + 1) {
378                        if !in_attribute {
379                            let arity = arity_of_args(&src[j + 1..close]);
380                            // a column-zero occurrence is a clause head even when
381                            // its arrow or guard sits on a line the input lacks
382                            let is_definition = followed_by_clause_arrow(bytes, close + 1)
383                                || starts_line(bytes, start);
384                            out.push(FunctionSignature {
385                                name: String::from_utf8_lossy(name).into_owned(),
386                                arity,
387                                line,
388                                is_definition,
389                            });
390                        }
391                        line += count_newlines(&bytes[start..=close]);
392                        i = close + 1;
393                        continue;
394                    }
395                }
396                i = j;
397            }
398            _ => i += 1,
399        }
400    }
401    out
402}
403
404/// Whether `line` (1-based) opens inside an attribute region per the
405/// supplied classification; without one the byte tracking stands alone.
406fn line_starts_attribute(ctx: Option<&[RefContext]>, line: u32) -> bool {
407    ctx.is_some_and(|ctx| {
408        ctx.get(line.saturating_sub(1) as usize)
409            .is_some_and(|c| c.is_attribute())
410    })
411}
412
413/// True when `start` is the first byte of its line.
414fn starts_line(bytes: &[u8], start: usize) -> bool {
415    start == 0 || bytes[start - 1] == b'\n'
416}
417
418/// True when only whitespace precedes `pos` back to the last newline:
419/// a `-` here opens an attribute or spec form, not a subtraction.
420fn at_form_start(bytes: &[u8], pos: usize) -> bool {
421    bytes[..pos]
422        .iter()
423        .rev()
424        .take_while(|&&b| b != b'\n')
425        .all(|&b| b.is_ascii_whitespace())
426}
427
428/// A function brought into a module's namespace by `-import(Module,
429/// [f/a, ...])`. The module is retained so a patch-added import can be
430/// resolved as the cross-module reference `m:f/a` it actually is.
431#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
432pub struct ImportedFunction {
433    pub module: ModuleName,
434    pub function: FunctionName,
435    pub arity: Arity,
436}
437
438/// The functions imported via `-import(mod, [f/a, ...])`, each carrying
439/// its source module. An entry whose module or name fails newtype
440/// validation is dropped: it would not compile, so nothing resolves
441/// against it.
442pub fn extract_imports(src: &str) -> BTreeSet<ImportedFunction> {
443    let mut out = BTreeSet::new();
444    for hit in iter_attribute_bodies(src, &["import"]) {
445        let Some(open) = hit.body.find('[') else {
446            continue;
447        };
448        let Some(close) = hit.body[open..].find(']') else {
449            continue;
450        };
451        let module_text = hit.body[..open].trim().trim_end_matches(',').trim();
452        let Ok(module) = ModuleName::from_str(module_text) else {
453            continue;
454        };
455        for entry in hit.body[open + 1..open + close].split(',') {
456            if let Some((name, arity)) = parse_fun_arity(entry)
457                && let (Ok(function), Ok(arity)) =
458                    (FunctionName::from_str(&name), Arity::try_from(arity))
459            {
460                out.insert(ImportedFunction {
461                    module: module.clone(),
462                    function,
463                    arity,
464                });
465            }
466        }
467    }
468    out
469}
470
471/// True when the module declares a `parse_transform`, which can inject
472/// functions the scanner cannot see: the caller suppresses local-call
473/// flagging for such a module.
474#[must_use]
475pub fn declares_parse_transform(src: &str) -> bool {
476    iter_attribute_bodies(src, &["compile"])
477        .iter()
478        .any(|hit| hit.body.contains("parse_transform"))
479}
480
481/// A module's exported `f/a` set, with `complete = false` when the true
482/// export surface cannot be read from source.
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct ExportSet {
485    pub exports: BTreeSet<(FunctionName, Arity)>,
486    pub complete: bool,
487}
488
489/// Functions a module exports via `-export([f/a, ...])`. `complete` is
490/// false when the export surface is not fully knowable from source:
491/// `-compile(export_all)` (or `nowarn_export_all`), a declared
492/// `parse_transform` (which can inject exports), or an `-export` entry
493/// naming a macro the scanner cannot expand. A `complete = false` set
494/// means the caller must withhold rather than treat an unlisted `f/a`
495/// as undefined.
496pub fn extract_exports(src: &str) -> ExportSet {
497    let mut exports = BTreeSet::new();
498    let mut complete = true;
499    for hit in iter_attribute_bodies(src, &["compile"]) {
500        if hit.body.contains("export_all") || hit.body.contains("parse_transform") {
501            complete = false;
502        }
503    }
504    for hit in iter_attribute_bodies(src, &["export"]) {
505        let Some(open) = hit.body.find('[') else {
506            continue;
507        };
508        let Some(close) = hit.body[open..].find(']') else {
509            continue;
510        };
511        for entry in hit.body[open + 1..open + close].split(',') {
512            let entry = entry.trim();
513            if entry.is_empty() {
514                continue;
515            }
516            // A macro in the export list hides which f/a it names.
517            if entry.contains('?') {
518                complete = false;
519                continue;
520            }
521            if let Some((name, arity)) = parse_fun_arity(entry)
522                && let (Ok(function), Ok(arity)) =
523                    (FunctionName::from_str(&name), Arity::try_from(arity))
524            {
525                exports.insert((function, arity));
526            }
527        }
528    }
529    ExportSet { exports, complete }
530}
531
532/// A type a module exports, and the line its entry sits on.
533#[derive(Debug, Clone, PartialEq, Eq)]
534pub struct ExportedType {
535    pub name: TypeName,
536    pub arity: Arity,
537    pub line: u32,
538}
539
540/// A module's `-export_type([t/a, ...])` set. `complete` is false when
541/// the surface cannot be read from source: a declared `parse_transform`,
542/// or an entry naming a macro the scanner cannot expand.
543/// `-compile(export_all)` exports functions, not types, so it does not
544/// make this set incomplete.
545#[derive(Debug, Clone, PartialEq, Eq)]
546pub struct ExportedTypeSet {
547    pub types: Vec<ExportedType>,
548    pub complete: bool,
549}
550
551/// The types a module exports. An entry whose name fails newtype
552/// validation is dropped: it would not compile, so nothing resolves
553/// against it.
554pub fn extract_exported_types(src: &str) -> ExportedTypeSet {
555    let mut types = Vec::new();
556    let mut complete = !declares_parse_transform(src);
557    for hit in iter_attribute_bodies(src, &["export_type"]) {
558        let Some(open) = hit.body.find('[') else {
559            continue;
560        };
561        let Some(close) = hit.body[open..].find(']') else {
562            continue;
563        };
564        for entry in hit.body[open + 1..open + close].split(',') {
565            let entry = entry.trim();
566            if entry.is_empty() {
567                continue;
568            }
569            // A macro in the export list hides which type it names.
570            if entry.contains('?') {
571                complete = false;
572                continue;
573            }
574            if let Some((name, arity)) = parse_fun_arity(entry)
575                && let (Ok(name), Ok(arity)) = (TypeName::from_str(&name), Arity::try_from(arity))
576            {
577                types.push(ExportedType {
578                    name,
579                    arity,
580                    line: hit.line,
581                });
582            }
583        }
584    }
585    ExportedTypeSet { types, complete }
586}
587
588/// True when the source declares a macro-expanded attribute form
589/// (`-?PROTOCOL_TYPE(t()).`), which can expand to any attribute:
590/// `rabbit_framing` declares its whole type surface this way. No
591/// attribute-derived set from such a file is knowable.
592pub fn has_macro_expanded_attribute(src: &str) -> bool {
593    let bytes = src.as_bytes();
594    let mut i = 0;
595    while i < bytes.len() {
596        match bytes[i] {
597            b'%' => {
598                while i < bytes.len() && bytes[i] != b'\n' {
599                    i += 1;
600                }
601            }
602            b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
603            b'$' => i += skip_char_literal_span(bytes, i),
604            b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
605            b'-' if at_form_start(bytes, i) => {
606                let rest = &bytes[i + 1..];
607                let after_space = rest.iter().position(|b| !b.is_ascii_whitespace());
608                if after_space.is_some_and(|p| rest[p] == b'?') {
609                    return true;
610                }
611                i += 1;
612            }
613            _ => i += 1,
614        }
615    }
616    false
617}
618
619/// The types a module declares. Walks form starts, not parenthesised
620/// attribute bodies: `-type t() :: term().` is not an `-attr(...)`
621/// form, though the equivalent `-type(t() :: term()).` is also legal.
622pub fn extract_defined_types(src: &str) -> BTreeSet<(TypeName, Arity)> {
623    let bytes = src.as_bytes();
624    let mut out = BTreeSet::new();
625    let mut i = 0;
626    while i < bytes.len() {
627        match bytes[i] {
628            b'%' => {
629                while i < bytes.len() && bytes[i] != b'\n' {
630                    i += 1;
631                }
632            }
633            b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
634            b'$' => i += skip_char_literal_span(bytes, i),
635            b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
636            b'-' if at_form_start(bytes, i) => {
637                let name_start = i + 1;
638                let mut name_end = name_start;
639                while name_end < bytes.len() && is_attribute_name_char(bytes[name_end]) {
640                    name_end += 1;
641                }
642                let name = str::from_utf8(&bytes[name_start..name_end]).unwrap_or("");
643                if !is_type_declaration_keyword(name) {
644                    i += 1;
645                    continue;
646                }
647                let body_start = name_end;
648                let body_end = form_end(bytes, body_start);
649                let body = str::from_utf8(&bytes[body_start..body_end]).unwrap_or("");
650                if let Some((name, arity)) = parse_type_name_arity(strip_outer_parens(body))
651                    && let (Ok(name), Ok(arity)) =
652                        (TypeName::from_str(&name), Arity::try_from(arity))
653                {
654                    out.insert((name, arity));
655                }
656                i = body_end;
657            }
658            _ => i += 1,
659        }
660    }
661    out
662}
663
664/// `name(Arg, ...) :: Body` to `(name, arity)`. The `::` is required:
665/// without it the form does not declare a type.
666fn parse_type_name_arity(body: &str) -> Option<(String, usize)> {
667    let trimmed = body.trim();
668    let name_end = trimmed
669        .bytes()
670        .position(|b| !is_name_byte(b))
671        .unwrap_or(trimmed.len());
672    if name_end == 0 {
673        return None;
674    }
675    let name = &trimmed[..name_end];
676    let (args, rest) = take_balanced_parens(trimmed[name_end..].trim_start())?;
677    rest.trim_start().strip_prefix("::")?;
678    let arity = if args.trim().is_empty() {
679        0
680    } else {
681        count_top_level_commas(args) + 1
682    };
683    Some((name.to_owned(), arity))
684}
685
686/// A module's `-spec` signatures, keyed by `(function, arity)`.
687pub type SpecTable = BTreeMap<(FunctionName, Arity), String>;
688
689/// A module's `-spec name(Args) -> Ret.` signatures, keyed by
690/// `(function, arity)`. Parsing is `backhopper-erlang-scan`'s
691/// `parse_callable_signature`: the same grammar the snapshot pipeline
692/// uses, so the live axis and the snapshot axis cannot disagree on
693/// what a spec is or which arity it has. Module-qualified forms parse
694/// to `None` and are simply absent, which withholds downstream.
695pub fn extract_specs(src: &str) -> SpecTable {
696    extract_signature_forms(src, b"spec")
697}
698
699/// A behaviour module's `-callback name(Args) -> Ret.` signatures,
700/// keyed by `(function, arity)`. Same walker and grammar as
701/// `extract_specs`.
702pub fn extract_callbacks(src: &str) -> SpecTable {
703    extract_signature_forms(src, b"callback")
704}
705
706/// The `(function, arity)` pairs a behaviour lists in
707/// `-optional_callbacks([f/a, ...])`.
708pub fn extract_optional_callbacks(src: &str) -> BTreeSet<(FunctionName, Arity)> {
709    let mut out = BTreeSet::new();
710    for hit in iter_attribute_bodies(src, &["optional_callbacks"]) {
711        let Some(open) = hit.body.find('[') else {
712            continue;
713        };
714        let Some(close) = hit.body[open..].find(']') else {
715            continue;
716        };
717        for entry in hit.body[open + 1..open + close].split(',') {
718            if let Some((name, arity)) = parse_fun_arity(entry)
719                && let (Ok(function), Ok(arity)) =
720                    (FunctionName::from_str(&name), Arity::try_from(arity))
721            {
722                out.insert((function, arity));
723            }
724        }
725    }
726    out
727}
728
729fn extract_signature_forms(src: &str, keyword: &[u8]) -> SpecTable {
730    let bytes = src.as_bytes();
731    let mut out = SpecTable::new();
732    let mut i = 0;
733    while i < bytes.len() {
734        match bytes[i] {
735            b'%' => {
736                while i < bytes.len() && bytes[i] != b'\n' {
737                    i += 1;
738                }
739            }
740            b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
741            b'$' => i += skip_char_literal_span(bytes, i),
742            b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
743            b'-' if at_form_start(bytes, i) && at_keyword(bytes, i + 1, keyword) => {
744                let body_start = i + 1 + keyword.len();
745                let body_end = form_end(bytes, body_start);
746                let body = str::from_utf8(&bytes[body_start..body_end]).unwrap_or("");
747                if let Some(sig) = parse_callable_signature(strip_outer_parens(body))
748                    && let Ok(function) = FunctionName::from_str(&sig.name)
749                {
750                    out.insert(
751                        (function, Arity::new(sig.arity)),
752                        normalize_signature(&sig.signature),
753                    );
754                }
755                i = body_end;
756            }
757            _ => i += 1,
758        }
759    }
760    out
761}
762
763/// True when `keyword` starts at `pos` and is not a prefix of a longer
764/// attribute name (`-specs_of(...)` must not match `spec`).
765fn at_keyword(bytes: &[u8], pos: usize, keyword: &[u8]) -> bool {
766    bytes[pos..].starts_with(keyword)
767        && !bytes
768            .get(pos + keyword.len())
769            .copied()
770            .is_some_and(is_attribute_name_char)
771}
772
773/// The parenthesised attribute form: `-spec(f(Args) -> Ret).` wraps
774/// the body once; the snapshot pipeline strips the same layer.
775fn strip_outer_parens(body: &str) -> &str {
776    let trimmed = body.trim();
777    match trimmed.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
778        Some(inner) => inner.trim(),
779        None => trimmed,
780    }
781}
782
783/// Byte index of the `.` ending the form that starts at `start`, or
784/// end of input; comments, strings, quoted atoms, and char literals
785/// are skipped.
786fn form_end(bytes: &[u8], start: usize) -> usize {
787    let mut i = start;
788    while i < bytes.len() {
789        match bytes[i] {
790            b'%' => {
791                while i < bytes.len() && bytes[i] != b'\n' {
792                    i += 1;
793                }
794            }
795            b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
796            b'$' => i += skip_char_literal_span(bytes, i),
797            b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
798            b'.' if dot_terminates(bytes, i) => return i,
799            _ => i += 1,
800        }
801    }
802    i
803}
804
805fn parse_fun_arity(entry: &str) -> Option<(String, usize)> {
806    let (name, arity) = entry.trim().split_once('/')?;
807    let name = name.trim();
808    if name.is_empty() || !name.bytes().all(is_name_byte) {
809        return None;
810    }
811    Some((name.to_owned(), arity.trim().parse().ok()?))
812}
813
814fn is_reserved_word(name: &[u8]) -> bool {
815    matches!(
816        name,
817        b"after"
818            | b"and"
819            | b"andalso"
820            | b"band"
821            | b"begin"
822            | b"bnot"
823            | b"bor"
824            | b"bsl"
825            | b"bsr"
826            | b"bxor"
827            | b"case"
828            | b"catch"
829            | b"cond"
830            | b"div"
831            | b"end"
832            | b"fun"
833            | b"if"
834            | b"let"
835            | b"maybe"
836            | b"not"
837            | b"of"
838            | b"or"
839            | b"orelse"
840            | b"receive"
841            | b"rem"
842            | b"try"
843            | b"when"
844            | b"xor"
845    )
846}
847
848/// True when, after skipping whitespace, the bytes at `pos` begin a
849/// clause arrow (`->`) or a `when` guard, which marks a definition.
850fn followed_by_clause_arrow(bytes: &[u8], pos: usize) -> bool {
851    let mut i = pos;
852    while i < bytes.len() && (bytes[i] as char).is_whitespace() {
853        i += 1;
854    }
855    bytes[i..].starts_with(b"->") || bytes[i..].starts_with(b"when")
856}
857
858fn count_newlines(bytes: &[u8]) -> u32 {
859    let mut n = 0u32;
860    for &b in bytes {
861        if b == b'\n' {
862            n += 1;
863        }
864    }
865    n
866}
867
868/// `erlc` predefines these, so a reference to one is never undefined.
869#[must_use]
870pub fn is_predefined_macro(name: &str) -> bool {
871    matches!(
872        name,
873        "MODULE"
874            | "MODULE_STRING"
875            | "FILE"
876            | "LINE"
877            | "MACHINE"
878            | "FUNCTION_NAME"
879            | "FUNCTION_ARITY"
880            | "OTP_RELEASE"
881            | "FEATURE_AVAILABLE"
882            | "FEATURE_ENABLED"
883    )
884}
885
886fn parse_macro_define_name(body: &str) -> Option<String> {
887    let trimmed = body.trim_start();
888    let mut chars = trimmed.char_indices();
889    let (_, first) = chars.next()?;
890    if first == '\'' {
891        let end = trimmed[1..].find('\'')?;
892        return Some(trimmed[1..=end].to_owned());
893    }
894    if !(first.is_ascii_alphanumeric() || first == '_' || first == '@') {
895        return None;
896    }
897    let end = trimmed
898        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
899        .unwrap_or(trimmed.len());
900    Some(trimmed[..end].to_owned())
901}
902
903/// Extract every `-behaviour(M)` / `-behavior(M)` declaration in
904/// `src`. Macro-valued and parse-transform-injected attributes are
905/// invisible to the syntactic walk.
906pub fn extract_behaviours(src: &str) -> Vec<BehaviourRef> {
907    extract_attribute_atom_argument(src, &["behaviour", "behavior"])
908        .into_iter()
909        .filter_map(|(atom, line)| {
910            ModuleName::new(atom)
911                .ok()
912                .map(|m| BehaviourRef { behaviour: m, line })
913        })
914        .collect()
915}
916
917/// Extract every `-include("…")` and `-include_lib("app/…")`
918/// declaration in `src`. Only literal string arguments are
919/// recognised; `-include(?MACRO)` is skipped.
920pub fn extract_includes(src: &str) -> Vec<IncludeRef> {
921    let mut out = Vec::new();
922    for (name, path, line) in extract_attribute_string_argument(src, &["include", "include_lib"]) {
923        let directive = match name.as_str() {
924            "include" => IncludeDirective::Include { path },
925            "include_lib" => IncludeDirective::IncludeLib { path },
926            _ => continue,
927        };
928        out.push(IncludeRef { directive, line });
929    }
930    out
931}
932
933/// Resolve `behaviour` against the target tree using the same
934/// `test_helper_search_paths` globs as the SUITE helper resolver.
935pub fn behaviour_resolves(
936    target: &TargetTreeIndex,
937    search_path_globs: &[String],
938    behaviour: &ModuleName,
939) -> bool {
940    module_resolves(target, search_path_globs, behaviour)
941}
942
943/// Try to find the on-target path the include directive points at.
944/// Returns `Ok(found)` on resolution, `Err(attempted_paths)` when none
945/// of the candidate target paths exist.
946///
947/// `source_path` is the touched `.erl` or `.hrl` that carries the
948/// directive: needed to resolve `-include(path)` relative to the
949/// source file's directory.
950pub fn resolve_include(
951    target: &TargetTreeIndex,
952    source_path: &RelativePath,
953    directive: &IncludeDirective,
954) -> Result<RelativePath, Vec<RelativePath>> {
955    let candidates = include_candidates(source_path, directive);
956    for c in &candidates {
957        if target.contains_path(Path::new(c.as_str())) {
958            return Ok(c.clone());
959        }
960    }
961    Err(candidates)
962}
963
964/// Generate the ordered list of target paths an include might resolve
965/// to. The first match wins in `resolve_include`.
966fn include_candidates(
967    source_path: &RelativePath,
968    directive: &IncludeDirective,
969) -> Vec<RelativePath> {
970    match directive {
971        IncludeDirective::Include { path } => {
972            let source_dir = Path::new(source_path.as_str())
973                .parent()
974                .map(Path::to_path_buf)
975                .unwrap_or_default();
976            let joined = source_dir.join(path);
977            normalised_relative_path(&joined).into_iter().collect()
978        }
979        IncludeDirective::IncludeLib { path } => include_lib_candidates(path),
980    }
981}
982
983/// `-include_lib("app/include/file.hrl")` resolves the leading
984/// `<app>` component through OTP's lib search path. On the target
985/// tree we map `<app>` to any of: `deps/<app>`, `apps/<app>`,
986/// `lib/<app>` (OTP itself uses the last form). The remainder of the
987/// path follows verbatim.
988fn include_lib_candidates(path: &str) -> Vec<RelativePath> {
989    let mut segs = path.splitn(2, '/');
990    let app = match segs.next() {
991        Some(s) if !s.is_empty() => s,
992        _ => return Vec::new(),
993    };
994    let rest = match segs.next() {
995        Some(s) => s,
996        None => return Vec::new(),
997    };
998    let mut out = Vec::new();
999    for prefix in ["deps", "apps", "lib"] {
1000        let candidate = PathBuf::from(prefix).join(app).join(rest);
1001        if let Some(rp) = normalised_relative_path(&candidate) {
1002            out.push(rp);
1003        }
1004    }
1005    out
1006}
1007
1008fn normalised_relative_path(p: &Path) -> Option<RelativePath> {
1009    let s = p.to_string_lossy().replace('\\', "/");
1010    RelativePath::new(s).ok()
1011}
1012
1013fn extract_attribute_atom_argument(src: &str, names: &[&str]) -> Vec<(String, u32)> {
1014    let mut out = Vec::new();
1015    for hit in iter_attribute_bodies(src, names) {
1016        if let Some(atom) = parse_single_atom_argument(hit.body) {
1017            out.push((atom, hit.line));
1018        }
1019    }
1020    out
1021}
1022
1023fn extract_attribute_string_argument(src: &str, names: &[&str]) -> Vec<(String, String, u32)> {
1024    let mut out = Vec::new();
1025    for hit in iter_attribute_bodies(src, names) {
1026        if let Some(path) = parse_single_string_argument(hit.body) {
1027            out.push((hit.name.to_owned(), path, hit.line));
1028        }
1029    }
1030    out
1031}
1032
1033#[derive(Debug, Clone, Copy)]
1034struct AttrHit<'a> {
1035    name: &'a str,
1036    body: &'a str,
1037    line: u32,
1038}
1039
1040/// Iterate every `-<name>(...)` the source declares, where `<name>`
1041/// is one of `names`. `body` is the bytes between the opening `(` and
1042/// the matching `)`. Skips comments, strings, char literals.
1043fn iter_attribute_bodies<'a>(src: &'a str, names: &'a [&'a str]) -> Vec<AttrHit<'a>> {
1044    let bytes = src.as_bytes();
1045    let mut out = Vec::new();
1046    let mut i = 0;
1047    let mut line = 1u32;
1048    while i < bytes.len() {
1049        let b = bytes[i];
1050        if b == b'\n' {
1051            line += 1;
1052            i += 1;
1053            continue;
1054        }
1055        if b == b'%' {
1056            while i < bytes.len() && bytes[i] != b'\n' {
1057                i += 1;
1058            }
1059            continue;
1060        }
1061        if b == b'"' || b == b'~' {
1062            let end = i + string_span(bytes, i).unwrap_or(1);
1063            line += newline_count(&bytes[i..end]);
1064            i = end;
1065            continue;
1066        }
1067        if b == b'$' {
1068            i += skip_char_literal_span(bytes, i);
1069            continue;
1070        }
1071        if b == b'\'' {
1072            let end = i + quoted_atom_span(bytes, i).unwrap_or(1);
1073            line += newline_count(&bytes[i..end]);
1074            i = end;
1075            continue;
1076        }
1077        if b == b'-'
1078            && let Some(name) = match_attribute_keyword(bytes, i + 1, names)
1079        {
1080            let name_end = i + 1 + name.len();
1081            let mut j = name_end;
1082            while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
1083                j += 1;
1084            }
1085            if j >= bytes.len() || bytes[j] != b'(' {
1086                i = name_end;
1087                continue;
1088            }
1089            let body_start = j + 1;
1090            if let Some(body_end) = match_parens(bytes, body_start) {
1091                let body = str::from_utf8(&bytes[body_start..body_end]).unwrap_or("");
1092                out.push(AttrHit { name, body, line });
1093                line += newline_count(&bytes[i..=body_end]);
1094                i = body_end + 1;
1095                continue;
1096            }
1097            i = name_end;
1098            continue;
1099        }
1100        i += 1;
1101    }
1102    out
1103}
1104
1105fn match_attribute_keyword<'a>(
1106    bytes: &[u8],
1107    start: usize,
1108    names: &'a [&'a str],
1109) -> Option<&'a str> {
1110    for name in names {
1111        let nb = name.as_bytes();
1112        if start + nb.len() > bytes.len() {
1113            continue;
1114        }
1115        if &bytes[start..start + nb.len()] != nb {
1116            continue;
1117        }
1118        let after = start + nb.len();
1119        let next = bytes.get(after).copied().unwrap_or(b' ');
1120        if !is_attribute_name_char(next) {
1121            return Some(name);
1122        }
1123    }
1124    None
1125}
1126
1127fn is_attribute_name_char(b: u8) -> bool {
1128    b.is_ascii_alphanumeric() || b == b'_'
1129}
1130
1131fn match_parens(bytes: &[u8], start: usize) -> Option<usize> {
1132    let mut depth = 1i32;
1133    let mut i = start;
1134    while i < bytes.len() && depth > 0 {
1135        let b = bytes[i];
1136        match b {
1137            b'(' => depth += 1,
1138            b')' => {
1139                depth -= 1;
1140                if depth == 0 {
1141                    return Some(i);
1142                }
1143            }
1144            b'"' | b'~' => {
1145                i += string_span(bytes, i).unwrap_or(1);
1146                continue;
1147            }
1148            b'$' => {
1149                i += skip_char_literal_span(bytes, i);
1150                continue;
1151            }
1152            b'\'' => {
1153                i += quoted_atom_span(bytes, i).unwrap_or(1);
1154                continue;
1155            }
1156            b'%' => {
1157                while i < bytes.len() && bytes[i] != b'\n' {
1158                    i += 1;
1159                }
1160                continue;
1161            }
1162            _ => {}
1163        }
1164        i += 1;
1165    }
1166    None
1167}
1168
1169fn newline_count(span: &[u8]) -> u32 {
1170    let mut n = 0;
1171    for &b in span {
1172        if b == b'\n' {
1173            n += 1;
1174        }
1175    }
1176    n
1177}
1178
1179fn parse_single_atom_argument(body: &str) -> Option<String> {
1180    let trimmed = body.trim();
1181    if trimmed.is_empty() {
1182        return None;
1183    }
1184    let mut chars = trimmed.chars();
1185    let first = chars.next()?;
1186    if first == '\'' {
1187        let end = trimmed[1..].find('\'')?;
1188        return Some(trimmed[1..=end].to_owned());
1189    }
1190    if !first.is_ascii_lowercase() {
1191        return None;
1192    }
1193    let mut end = 1;
1194    for c in chars {
1195        if c.is_ascii_alphanumeric() || c == '_' || c == '@' {
1196            end += c.len_utf8();
1197            continue;
1198        }
1199        break;
1200    }
1201    Some(trimmed[..end].to_owned())
1202}
1203
1204fn parse_single_string_argument(body: &str) -> Option<String> {
1205    let trimmed = body.trim();
1206    let first = trimmed.chars().next()?;
1207    if first != '"' {
1208        return None;
1209    }
1210    let bytes = trimmed.as_bytes();
1211    let mut i = 1usize;
1212    let mut out = String::new();
1213    while i < bytes.len() {
1214        let b = bytes[i];
1215        if b == b'\\' && i + 1 < bytes.len() {
1216            out.push(bytes[i + 1] as char);
1217            i += 2;
1218            continue;
1219        }
1220        if b == b'"' {
1221            return Some(out);
1222        }
1223        out.push(b as char);
1224        i += 1;
1225    }
1226    None
1227}