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