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//! candidate 5 (`HeaderFileMissing`) and candidate 6
7//! (`BehaviourModuleMissing`) of `018_ci_signal_gaps`.
8//!
9//! Both attributes have stable, line-oriented shapes:
10//!
11//!   * `-behaviour(custom_ct_hook).` (also spelled `-behavior(...)` in
12//!     OTP-style sources)
13//!   * `-include("rel/path.hrl").` and
14//!     `-include_lib("app/include/file.hrl").`
15//!
16//! We scan for these by walking the source byte by byte, balancing
17//! strings, char literals, and `%` line comments. Anything more
18//! complex (macro-substituted attributes, `parse_transform`-injected
19//! values) is invisible to the scanner: those cases land in a
20//! follow-up `--deep-helper-resolution` pass.
21
22use std::collections::{BTreeMap, BTreeSet};
23use std::path::{Path, PathBuf};
24use std::str;
25use std::str::FromStr;
26
27use backhopper_erlang_scan::{
28    count_top_level_commas, parse_callable_signature, take_balanced_parens,
29};
30
31use crate::compat::target_tree_index::TargetTreeIndex;
32use crate::compat::test_suite::module_resolves;
33use crate::model::names::{Arity, FunctionName, ModuleName, RelativePath, TypeName};
34use crate::model::verdict::IncludeDirective;
35use crate::snapshot::spec_normalize::normalize_signature;
36
37/// One `-behaviour(M)` declaration the scanner saw, with the line
38/// number for 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 `-include(...)` or `-include_lib(...)` declaration the scanner
46/// saw, 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 `name(Args)` the scanner saw, 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    let bytes = src.as_bytes();
299    let mut out = Vec::new();
300    let mut i = 0;
301    let mut line = 1u32;
302    // True between an attribute or spec's leading `-` and its closing
303    // `.`: type names there look like zero-arity calls but are not.
304    let mut in_attribute = false;
305    while i < bytes.len() {
306        match bytes[i] {
307            b'\n' => {
308                line += 1;
309                i += 1;
310            }
311            b'%' => {
312                while i < bytes.len() && bytes[i] != b'\n' {
313                    i += 1;
314                }
315            }
316            b'"' => i = skip_string(bytes, i, b'"'),
317            b'$' => i += 2.min(bytes.len() - i),
318            b'\'' => i = skip_string(bytes, i, b'\''),
319            b'-' if !in_attribute && at_form_start(bytes, i) => {
320                in_attribute = true;
321                i += 1;
322            }
323            b'.' if in_attribute && is_form_terminator(bytes, i) => {
324                in_attribute = false;
325                i += 1;
326            }
327            b if b.is_ascii_uppercase() || b == b'_' => {
328                // A variable or `_`-name: consume whole so its tail is
329                // not re-lexed, and never count it as a local call.
330                while i < bytes.len() && is_name_char(bytes[i]) {
331                    i += 1;
332                }
333            }
334            b if b.is_ascii_lowercase() => {
335                let start = i;
336                let mut j = i;
337                while j < bytes.len() && is_name_char(bytes[j]) {
338                    j += 1;
339                }
340                let name = &bytes[start..j];
341                let qualified = start > 0 && matches!(bytes[start - 1], b':' | b'?' | b'#');
342                if bytes.get(j) == Some(&b'(') && !qualified && !is_reserved_word(name) {
343                    if let Some(close) = match_parens(bytes, j + 1) {
344                        if !in_attribute {
345                            let arity = top_level_arity(&bytes[j + 1..close]);
346                            let is_definition = followed_by_clause_arrow(bytes, close + 1);
347                            out.push(FunctionSignature {
348                                name: String::from_utf8_lossy(name).into_owned(),
349                                arity,
350                                line,
351                                is_definition,
352                            });
353                        }
354                        line += count_newlines(&bytes[start..=close]);
355                        i = close + 1;
356                        continue;
357                    }
358                }
359                i = j;
360            }
361            _ => i += 1,
362        }
363    }
364    out
365}
366
367/// True when only whitespace precedes `pos` back to the last newline:
368/// a `-` here opens an attribute or spec form, not a subtraction.
369fn at_form_start(bytes: &[u8], pos: usize) -> bool {
370    bytes[..pos]
371        .iter()
372        .rev()
373        .take_while(|&&b| b != b'\n')
374        .all(|&b| b.is_ascii_whitespace())
375}
376
377/// True when `.` at `pos` ends a form: followed by whitespace or end of
378/// input, not a `..` range or a `mod.field` dot.
379fn is_form_terminator(bytes: &[u8], pos: usize) -> bool {
380    match bytes.get(pos + 1) {
381        None => true,
382        Some(b) => b.is_ascii_whitespace(),
383    }
384}
385
386/// A function brought into a module's namespace by `-import(Module,
387/// [f/a, ...])`. The module is retained so a patch-added import can be
388/// resolved as the cross-module reference `m:f/a` it actually is.
389#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
390pub struct ImportedFunction {
391    pub module: ModuleName,
392    pub function: FunctionName,
393    pub arity: Arity,
394}
395
396/// The functions imported via `-import(mod, [f/a, ...])`, each carrying
397/// its source module. An entry whose module or name fails newtype
398/// validation is dropped: it would not compile, so nothing resolves
399/// against it.
400pub fn extract_imports(src: &str) -> BTreeSet<ImportedFunction> {
401    let mut out = BTreeSet::new();
402    for hit in iter_attribute_bodies(src, &["import"]) {
403        let Some(open) = hit.body.find('[') else {
404            continue;
405        };
406        let Some(close) = hit.body[open..].find(']') else {
407            continue;
408        };
409        let module_text = hit.body[..open].trim().trim_end_matches(',').trim();
410        let Ok(module) = ModuleName::from_str(module_text) else {
411            continue;
412        };
413        for entry in hit.body[open + 1..open + close].split(',') {
414            if let Some((name, arity)) = parse_fun_arity(entry)
415                && let (Ok(function), Ok(arity)) =
416                    (FunctionName::from_str(&name), Arity::try_from(arity))
417            {
418                out.insert(ImportedFunction {
419                    module: module.clone(),
420                    function,
421                    arity,
422                });
423            }
424        }
425    }
426    out
427}
428
429/// True when the module declares a `parse_transform`, which can inject
430/// functions the scanner cannot see: the caller suppresses local-call
431/// flagging for such a module.
432#[must_use]
433pub fn declares_parse_transform(src: &str) -> bool {
434    iter_attribute_bodies(src, &["compile"])
435        .iter()
436        .any(|hit| hit.body.contains("parse_transform"))
437}
438
439/// A module's exported `f/a` set, with `complete = false` when the true
440/// export surface cannot be read from source.
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct ExportSet {
443    pub exports: BTreeSet<(FunctionName, Arity)>,
444    pub complete: bool,
445}
446
447/// Functions a module exports via `-export([f/a, ...])`. `complete` is
448/// false when the export surface is not fully knowable from source:
449/// `-compile(export_all)` (or `nowarn_export_all`), a declared
450/// `parse_transform` (which can inject exports), or an `-export` entry
451/// naming a macro the scanner cannot expand. A `complete = false` set
452/// means the caller must withhold rather than treat an unlisted `f/a`
453/// as undefined.
454pub fn extract_exports(src: &str) -> ExportSet {
455    let mut exports = BTreeSet::new();
456    let mut complete = true;
457    for hit in iter_attribute_bodies(src, &["compile"]) {
458        if hit.body.contains("export_all") || hit.body.contains("parse_transform") {
459            complete = false;
460        }
461    }
462    for hit in iter_attribute_bodies(src, &["export"]) {
463        let Some(open) = hit.body.find('[') else {
464            continue;
465        };
466        let Some(close) = hit.body[open..].find(']') else {
467            continue;
468        };
469        for entry in hit.body[open + 1..open + close].split(',') {
470            let entry = entry.trim();
471            if entry.is_empty() {
472                continue;
473            }
474            // A macro in the export list hides which `f/a` it names.
475            if entry.contains('?') {
476                complete = false;
477                continue;
478            }
479            if let Some((name, arity)) = parse_fun_arity(entry)
480                && let (Ok(function), Ok(arity)) =
481                    (FunctionName::from_str(&name), Arity::try_from(arity))
482            {
483                exports.insert((function, arity));
484            }
485        }
486    }
487    ExportSet { exports, complete }
488}
489
490/// A type a module exports, and the line its entry sits on.
491#[derive(Debug, Clone, PartialEq, Eq)]
492pub struct ExportedType {
493    pub name: TypeName,
494    pub arity: Arity,
495    pub line: u32,
496}
497
498/// A module's `-export_type([t/a, ...])` set. `complete` is false when
499/// the surface cannot be read from source: a declared `parse_transform`,
500/// or an entry naming a macro the scanner cannot expand.
501/// `-compile(export_all)` exports functions, not types, so it does not
502/// make this set incomplete.
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub struct ExportedTypeSet {
505    pub types: Vec<ExportedType>,
506    pub complete: bool,
507}
508
509/// The types a module exports. An entry whose name fails newtype
510/// validation is dropped: it would not compile, so nothing resolves
511/// against it.
512pub fn extract_exported_types(src: &str) -> ExportedTypeSet {
513    let mut types = Vec::new();
514    let mut complete = !declares_parse_transform(src);
515    for hit in iter_attribute_bodies(src, &["export_type"]) {
516        let Some(open) = hit.body.find('[') else {
517            continue;
518        };
519        let Some(close) = hit.body[open..].find(']') else {
520            continue;
521        };
522        for entry in hit.body[open + 1..open + close].split(',') {
523            let entry = entry.trim();
524            if entry.is_empty() {
525                continue;
526            }
527            // A macro in the export list hides which type it names.
528            if entry.contains('?') {
529                complete = false;
530                continue;
531            }
532            if let Some((name, arity)) = parse_fun_arity(entry)
533                && let (Ok(name), Ok(arity)) = (TypeName::from_str(&name), Arity::try_from(arity))
534            {
535                types.push(ExportedType {
536                    name,
537                    arity,
538                    line: hit.line,
539                });
540            }
541        }
542    }
543    ExportedTypeSet { types, complete }
544}
545
546/// All three declare a name; their visibility and equivalence rules
547/// differ, which does not bear on whether an export resolves.
548const TYPE_DECL_KEYWORDS: [&[u8]; 3] = [b"type", b"opaque", b"nominal"];
549
550/// True when the source declares a macro-expanded attribute form
551/// (`-?PROTOCOL_TYPE(t()).`), which can expand to any attribute:
552/// `rabbit_framing` declares its whole type surface this way. No
553/// attribute-derived set from such a file is knowable.
554pub fn has_macro_expanded_attribute(src: &str) -> bool {
555    let bytes = src.as_bytes();
556    let mut i = 0;
557    while i < bytes.len() {
558        match bytes[i] {
559            b'%' => {
560                while i < bytes.len() && bytes[i] != b'\n' {
561                    i += 1;
562                }
563            }
564            b'"' => i = skip_string(bytes, i, b'"'),
565            b'$' => i += 2.min(bytes.len() - i),
566            b'\'' => i = skip_string(bytes, i, b'\''),
567            b'-' if at_form_start(bytes, i) => {
568                let rest = &bytes[i + 1..];
569                let after_space = rest.iter().position(|b| !b.is_ascii_whitespace());
570                if after_space.is_some_and(|p| rest[p] == b'?') {
571                    return true;
572                }
573                i += 1;
574            }
575            _ => i += 1,
576        }
577    }
578    false
579}
580
581/// The types a module declares. Walks form starts, not parenthesised
582/// attribute bodies: `-type t() :: term().` is not an `-attr(...)`
583/// form, though the equivalent `-type(t() :: term()).` is also legal.
584pub fn extract_defined_types(src: &str) -> BTreeSet<(TypeName, Arity)> {
585    let bytes = src.as_bytes();
586    let mut out = BTreeSet::new();
587    let mut i = 0;
588    while i < bytes.len() {
589        match bytes[i] {
590            b'%' => {
591                while i < bytes.len() && bytes[i] != b'\n' {
592                    i += 1;
593                }
594            }
595            b'"' => i = skip_string(bytes, i, b'"'),
596            b'$' => i += 2.min(bytes.len() - i),
597            b'\'' => i = skip_string(bytes, i, b'\''),
598            b'-' if at_form_start(bytes, i) => {
599                let Some(keyword) = TYPE_DECL_KEYWORDS
600                    .iter()
601                    .find(|k| at_keyword(bytes, i + 1, k))
602                else {
603                    i += 1;
604                    continue;
605                };
606                let body_start = i + 1 + keyword.len();
607                let body_end = form_end(bytes, body_start);
608                let body = str::from_utf8(&bytes[body_start..body_end]).unwrap_or("");
609                if let Some((name, arity)) = parse_type_name_arity(strip_outer_parens(body))
610                    && let (Ok(name), Ok(arity)) =
611                        (TypeName::from_str(&name), Arity::try_from(arity))
612                {
613                    out.insert((name, arity));
614                }
615                i = body_end;
616            }
617            _ => i += 1,
618        }
619    }
620    out
621}
622
623/// `name(Arg, ...) :: Body` to `(name, arity)`. The `::` is required:
624/// without it the form does not declare a type.
625fn parse_type_name_arity(body: &str) -> Option<(String, usize)> {
626    let trimmed = body.trim();
627    let name_end = trimmed
628        .bytes()
629        .position(|b| !is_name_char(b))
630        .unwrap_or(trimmed.len());
631    if name_end == 0 {
632        return None;
633    }
634    let name = &trimmed[..name_end];
635    let (args, rest) = take_balanced_parens(trimmed[name_end..].trim_start())?;
636    rest.trim_start().strip_prefix("::")?;
637    let arity = if args.trim().is_empty() {
638        0
639    } else {
640        count_top_level_commas(args) + 1
641    };
642    Some((name.to_owned(), arity))
643}
644
645/// A module's `-spec` signatures, keyed by `(function, arity)`.
646pub type SpecTable = BTreeMap<(FunctionName, Arity), String>;
647
648/// A module's `-spec name(Args) -> Ret.` signatures, keyed by
649/// `(function, arity)`. Parsing is `backhopper-erlang-scan`'s
650/// `parse_callable_signature`: the same grammar the snapshot pipeline
651/// uses, so the live axis and the snapshot axis cannot disagree on
652/// what a spec is or which arity it has. Module-qualified forms parse
653/// to `None` and are simply absent, which withholds downstream.
654pub fn extract_specs(src: &str) -> SpecTable {
655    extract_signature_forms(src, b"spec")
656}
657
658/// A behaviour module's `-callback name(Args) -> Ret.` signatures,
659/// keyed by `(function, arity)`. Same walker and grammar as
660/// `extract_specs`.
661pub fn extract_callbacks(src: &str) -> SpecTable {
662    extract_signature_forms(src, b"callback")
663}
664
665/// The `(function, arity)` pairs a behaviour lists in
666/// `-optional_callbacks([f/a, ...])`.
667pub fn extract_optional_callbacks(src: &str) -> BTreeSet<(FunctionName, Arity)> {
668    let mut out = BTreeSet::new();
669    for hit in iter_attribute_bodies(src, &["optional_callbacks"]) {
670        let Some(open) = hit.body.find('[') else {
671            continue;
672        };
673        let Some(close) = hit.body[open..].find(']') else {
674            continue;
675        };
676        for entry in hit.body[open + 1..open + close].split(',') {
677            if let Some((name, arity)) = parse_fun_arity(entry)
678                && let (Ok(function), Ok(arity)) =
679                    (FunctionName::from_str(&name), Arity::try_from(arity))
680            {
681                out.insert((function, arity));
682            }
683        }
684    }
685    out
686}
687
688fn extract_signature_forms(src: &str, keyword: &[u8]) -> SpecTable {
689    let bytes = src.as_bytes();
690    let mut out = SpecTable::new();
691    let mut i = 0;
692    while i < bytes.len() {
693        match bytes[i] {
694            b'%' => {
695                while i < bytes.len() && bytes[i] != b'\n' {
696                    i += 1;
697                }
698            }
699            b'"' => i = skip_string(bytes, i, b'"'),
700            b'$' => i += 2.min(bytes.len() - i),
701            b'\'' => i = skip_string(bytes, i, b'\''),
702            b'-' if at_form_start(bytes, i) && at_keyword(bytes, i + 1, keyword) => {
703                let body_start = i + 1 + keyword.len();
704                let body_end = form_end(bytes, body_start);
705                let body = str::from_utf8(&bytes[body_start..body_end]).unwrap_or("");
706                if let Some(sig) = parse_callable_signature(strip_outer_parens(body))
707                    && let Ok(function) = FunctionName::from_str(&sig.name)
708                {
709                    out.insert(
710                        (function, Arity::new(sig.arity)),
711                        normalize_signature(&sig.signature),
712                    );
713                }
714                i = body_end;
715            }
716            _ => i += 1,
717        }
718    }
719    out
720}
721
722/// True when `keyword` starts at `pos` and is not a prefix of a longer
723/// attribute name (`-specs_of(...)` must not match `spec`).
724fn at_keyword(bytes: &[u8], pos: usize, keyword: &[u8]) -> bool {
725    bytes[pos..].starts_with(keyword)
726        && !bytes
727            .get(pos + keyword.len())
728            .copied()
729            .is_some_and(is_attribute_name_char)
730}
731
732/// The parenthesised attribute form: `-spec(f(Args) -> Ret).` wraps
733/// the body once; the snapshot pipeline strips the same layer.
734fn strip_outer_parens(body: &str) -> &str {
735    let trimmed = body.trim();
736    match trimmed.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
737        Some(inner) => inner.trim(),
738        None => trimmed,
739    }
740}
741
742/// Byte index of the `.` ending the form that starts at `start`, or
743/// end of input; comments, strings, quoted atoms, and char literals
744/// are skipped.
745fn form_end(bytes: &[u8], start: usize) -> usize {
746    let mut i = start;
747    while i < bytes.len() {
748        match bytes[i] {
749            b'%' => {
750                while i < bytes.len() && bytes[i] != b'\n' {
751                    i += 1;
752                }
753            }
754            b'"' => i = skip_string(bytes, i, b'"'),
755            b'$' => i += 2.min(bytes.len() - i),
756            b'\'' => i = skip_string(bytes, i, b'\''),
757            b'.' if is_form_terminator(bytes, i) => return i,
758            _ => i += 1,
759        }
760    }
761    i
762}
763
764fn parse_fun_arity(entry: &str) -> Option<(String, usize)> {
765    let (name, arity) = entry.trim().split_once('/')?;
766    let name = name.trim();
767    if name.is_empty() || !name.bytes().all(is_name_char) {
768        return None;
769    }
770    Some((name.to_owned(), arity.trim().parse().ok()?))
771}
772
773fn is_reserved_word(name: &[u8]) -> bool {
774    matches!(
775        name,
776        b"after"
777            | b"and"
778            | b"andalso"
779            | b"band"
780            | b"begin"
781            | b"bnot"
782            | b"bor"
783            | b"bsl"
784            | b"bsr"
785            | b"bxor"
786            | b"case"
787            | b"catch"
788            | b"cond"
789            | b"div"
790            | b"end"
791            | b"fun"
792            | b"if"
793            | b"let"
794            | b"maybe"
795            | b"not"
796            | b"of"
797            | b"or"
798            | b"orelse"
799            | b"receive"
800            | b"rem"
801            | b"try"
802            | b"when"
803            | b"xor"
804    )
805}
806
807/// True when, after skipping whitespace, the bytes at `pos` begin a
808/// clause arrow (`->`) or a `when` guard: the marks of a definition.
809fn followed_by_clause_arrow(bytes: &[u8], pos: usize) -> bool {
810    let mut i = pos;
811    while i < bytes.len() && (bytes[i] as char).is_whitespace() {
812        i += 1;
813    }
814    bytes[i..].starts_with(b"->") || bytes[i..].starts_with(b"when")
815}
816
817fn top_level_arity(args: &[u8]) -> usize {
818    if args.iter().all(|b| b.is_ascii_whitespace()) {
819        return 0;
820    }
821    let mut depth = 0i32;
822    let mut commas = 0usize;
823    let mut i = 0;
824    while i < args.len() {
825        match args[i] {
826            b'(' | b'[' | b'{' => depth += 1,
827            b')' | b']' | b'}' => depth -= 1,
828            // << and >> are binary delimiters: nest like brackets so
829            // interior commas are not counted as argument separators
830            b'<' if args.get(i + 1) == Some(&b'<') => {
831                depth += 1;
832                i += 2;
833                continue;
834            }
835            b'>' if args.get(i + 1) == Some(&b'>') => {
836                depth -= 1;
837                i += 2;
838                continue;
839            }
840            b'"' => {
841                i = skip_string(args, i, b'"');
842                continue;
843            }
844            b'\'' => {
845                i = skip_string(args, i, b'\'');
846                continue;
847            }
848            b'$' => {
849                i += 2;
850                continue;
851            }
852            b',' if depth == 0 => commas += 1,
853            _ => {}
854        }
855        i += 1;
856    }
857    commas + 1
858}
859
860fn count_newlines(bytes: &[u8]) -> u32 {
861    let mut n = 0u32;
862    for &b in bytes {
863        if b == b'\n' {
864            n += 1;
865        }
866    }
867    n
868}
869
870/// `erlc` predefines these, so a reference to one is never undefined.
871#[must_use]
872pub fn is_predefined_macro(name: &str) -> bool {
873    matches!(
874        name,
875        "MODULE"
876            | "MODULE_STRING"
877            | "FILE"
878            | "LINE"
879            | "MACHINE"
880            | "FUNCTION_NAME"
881            | "FUNCTION_ARITY"
882            | "OTP_RELEASE"
883            | "FEATURE_AVAILABLE"
884            | "FEATURE_ENABLED"
885    )
886}
887
888fn is_name_char(b: u8) -> bool {
889    b.is_ascii_alphanumeric() || b == b'_' || b == b'@'
890}
891
892fn parse_macro_define_name(body: &str) -> Option<String> {
893    let trimmed = body.trim_start();
894    let mut chars = trimmed.char_indices();
895    let (_, first) = chars.next()?;
896    if first == '\'' {
897        let end = trimmed[1..].find('\'')?;
898        return Some(trimmed[1..=end].to_owned());
899    }
900    if !(first.is_ascii_alphanumeric() || first == '_' || first == '@') {
901        return None;
902    }
903    let end = trimmed
904        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
905        .unwrap_or(trimmed.len());
906    Some(trimmed[..end].to_owned())
907}
908
909/// Extract every `-behaviour(M)` / `-behavior(M)` declaration in
910/// `src`. Macro-valued and parse-transform-injected attributes are
911/// invisible to the syntactic walk.
912pub fn extract_behaviours(src: &str) -> Vec<BehaviourRef> {
913    extract_attribute_atom_argument(src, &["behaviour", "behavior"])
914        .into_iter()
915        .filter_map(|(atom, line)| {
916            ModuleName::new(atom)
917                .ok()
918                .map(|m| BehaviourRef { behaviour: m, line })
919        })
920        .collect()
921}
922
923/// Extract every `-include("…")` and `-include_lib("app/…")`
924/// declaration in `src`. Only literal string arguments are
925/// recognised; `-include(?MACRO)` is skipped.
926pub fn extract_includes(src: &str) -> Vec<IncludeRef> {
927    let mut out = Vec::new();
928    for (name, path, line) in extract_attribute_string_argument(src, &["include", "include_lib"]) {
929        let directive = match name.as_str() {
930            "include" => IncludeDirective::Include { path },
931            "include_lib" => IncludeDirective::IncludeLib { path },
932            _ => continue,
933        };
934        out.push(IncludeRef { directive, line });
935    }
936    out
937}
938
939/// Resolve `behaviour` against the target tree using the same
940/// `test_helper_search_paths` globs as the SUITE helper resolver
941/// plus, in practice, the stdlib allowlist (callers filter ahead).
942pub fn behaviour_resolves(
943    target: &TargetTreeIndex,
944    search_path_globs: &[String],
945    behaviour: &ModuleName,
946) -> bool {
947    module_resolves(target, search_path_globs, behaviour)
948}
949
950/// Try to find the on-target path the include directive points at.
951/// Returns `Ok(found)` on resolution, `Err(attempted_paths)` when none
952/// of the candidate target paths exist.
953///
954/// `source_path` is the touched `.erl` or `.hrl` that carries the
955/// directive: needed to resolve `-include(path)` relative to the
956/// source file's directory.
957pub fn resolve_include(
958    target: &TargetTreeIndex,
959    source_path: &RelativePath,
960    directive: &IncludeDirective,
961) -> Result<RelativePath, Vec<RelativePath>> {
962    let candidates = include_candidates(source_path, directive);
963    for c in &candidates {
964        if target.contains_path(Path::new(c.as_str())) {
965            return Ok(c.clone());
966        }
967    }
968    Err(candidates)
969}
970
971/// Generate the ordered list of target paths an include might resolve
972/// to. The first match wins in `resolve_include`.
973fn include_candidates(
974    source_path: &RelativePath,
975    directive: &IncludeDirective,
976) -> Vec<RelativePath> {
977    match directive {
978        IncludeDirective::Include { path } => {
979            let source_dir = Path::new(source_path.as_str())
980                .parent()
981                .map(Path::to_path_buf)
982                .unwrap_or_default();
983            let joined = source_dir.join(path);
984            normalised_relative_path(&joined).into_iter().collect()
985        }
986        IncludeDirective::IncludeLib { path } => include_lib_candidates(path),
987    }
988}
989
990/// `-include_lib("app/include/file.hrl")` resolves the leading
991/// `<app>` component through OTP's lib search path. On the target
992/// tree we map `<app>` to any of: `deps/<app>`, `apps/<app>`,
993/// `lib/<app>` (OTP itself uses the last form). The remainder of the
994/// path follows verbatim.
995fn include_lib_candidates(path: &str) -> Vec<RelativePath> {
996    let mut segs = path.splitn(2, '/');
997    let app = match segs.next() {
998        Some(s) if !s.is_empty() => s,
999        _ => return Vec::new(),
1000    };
1001    let rest = match segs.next() {
1002        Some(s) => s,
1003        None => return Vec::new(),
1004    };
1005    let mut out = Vec::new();
1006    for prefix in ["deps", "apps", "lib"] {
1007        let candidate = PathBuf::from(prefix).join(app).join(rest);
1008        if let Some(rp) = normalised_relative_path(&candidate) {
1009            out.push(rp);
1010        }
1011    }
1012    out
1013}
1014
1015fn normalised_relative_path(p: &Path) -> Option<RelativePath> {
1016    let s = p.to_string_lossy().replace('\\', "/");
1017    RelativePath::new(s).ok()
1018}
1019
1020fn extract_attribute_atom_argument(src: &str, names: &[&str]) -> Vec<(String, u32)> {
1021    let mut out = Vec::new();
1022    for hit in iter_attribute_bodies(src, names) {
1023        if let Some(atom) = parse_single_atom_argument(hit.body) {
1024            out.push((atom, hit.line));
1025        }
1026    }
1027    out
1028}
1029
1030fn extract_attribute_string_argument(src: &str, names: &[&str]) -> Vec<(String, String, u32)> {
1031    let mut out = Vec::new();
1032    for hit in iter_attribute_bodies(src, names) {
1033        if let Some(path) = parse_single_string_argument(hit.body) {
1034            out.push((hit.name.to_owned(), path, hit.line));
1035        }
1036    }
1037    out
1038}
1039
1040#[derive(Debug, Clone, Copy)]
1041struct AttrHit<'a> {
1042    name: &'a str,
1043    body: &'a str,
1044    line: u32,
1045}
1046
1047/// Iterate every `-<name>(...)` the source declares, where `<name>`
1048/// is one of `names`. `body` is the bytes between the opening `(` and
1049/// the matching `)`. Skips comments, strings, char literals.
1050fn iter_attribute_bodies<'a>(src: &'a str, names: &'a [&'a str]) -> Vec<AttrHit<'a>> {
1051    let bytes = src.as_bytes();
1052    let mut out = Vec::new();
1053    let mut i = 0;
1054    let mut line = 1u32;
1055    while i < bytes.len() {
1056        let b = bytes[i];
1057        if b == b'\n' {
1058            line += 1;
1059            i += 1;
1060            continue;
1061        }
1062        if b == b'%' {
1063            while i < bytes.len() && bytes[i] != b'\n' {
1064                i += 1;
1065            }
1066            continue;
1067        }
1068        if b == b'"' {
1069            let end = skip_string(bytes, i, b'"');
1070            line += newline_count(&bytes[i..end]);
1071            i = end;
1072            continue;
1073        }
1074        if b == b'\'' {
1075            let end = skip_string(bytes, i, b'\'');
1076            line += newline_count(&bytes[i..end]);
1077            i = end;
1078            continue;
1079        }
1080        if b == b'-'
1081            && let Some(name) = match_attribute_keyword(bytes, i + 1, names)
1082        {
1083            let name_end = i + 1 + name.len();
1084            let mut j = name_end;
1085            while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
1086                j += 1;
1087            }
1088            if j >= bytes.len() || bytes[j] != b'(' {
1089                i = name_end;
1090                continue;
1091            }
1092            let body_start = j + 1;
1093            if let Some(body_end) = match_parens(bytes, body_start) {
1094                let body = str::from_utf8(&bytes[body_start..body_end]).unwrap_or("");
1095                out.push(AttrHit { name, body, line });
1096                line += newline_count(&bytes[i..=body_end]);
1097                i = body_end + 1;
1098                continue;
1099            }
1100            i = name_end;
1101            continue;
1102        }
1103        i += 1;
1104    }
1105    out
1106}
1107
1108fn match_attribute_keyword<'a>(
1109    bytes: &[u8],
1110    start: usize,
1111    names: &'a [&'a str],
1112) -> Option<&'a str> {
1113    for name in names {
1114        let nb = name.as_bytes();
1115        if start + nb.len() > bytes.len() {
1116            continue;
1117        }
1118        if &bytes[start..start + nb.len()] != nb {
1119            continue;
1120        }
1121        let after = start + nb.len();
1122        let next = bytes.get(after).copied().unwrap_or(b' ');
1123        if !is_attribute_name_char(next) {
1124            return Some(name);
1125        }
1126    }
1127    None
1128}
1129
1130fn is_attribute_name_char(b: u8) -> bool {
1131    b.is_ascii_alphanumeric() || b == b'_'
1132}
1133
1134fn match_parens(bytes: &[u8], start: usize) -> Option<usize> {
1135    let mut depth = 1i32;
1136    let mut i = start;
1137    while i < bytes.len() && depth > 0 {
1138        let b = bytes[i];
1139        match b {
1140            b'(' => depth += 1,
1141            b')' => {
1142                depth -= 1;
1143                if depth == 0 {
1144                    return Some(i);
1145                }
1146            }
1147            b'"' => {
1148                i = skip_string(bytes, i, b'"');
1149                continue;
1150            }
1151            b'\'' => {
1152                i = skip_string(bytes, i, b'\'');
1153                continue;
1154            }
1155            b'%' => {
1156                while i < bytes.len() && bytes[i] != b'\n' {
1157                    i += 1;
1158                }
1159                continue;
1160            }
1161            _ => {}
1162        }
1163        i += 1;
1164    }
1165    None
1166}
1167
1168fn skip_string(bytes: &[u8], start: usize, quote: u8) -> usize {
1169    let mut i = start + 1;
1170    while i < bytes.len() {
1171        if bytes[i] == b'\\' && i + 1 < bytes.len() {
1172            i += 2;
1173            continue;
1174        }
1175        if bytes[i] == quote {
1176            return i + 1;
1177        }
1178        i += 1;
1179    }
1180    i
1181}
1182
1183fn newline_count(span: &[u8]) -> u32 {
1184    let mut n = 0;
1185    for &b in span {
1186        if b == b'\n' {
1187            n += 1;
1188        }
1189    }
1190    n
1191}
1192
1193fn parse_single_atom_argument(body: &str) -> Option<String> {
1194    let trimmed = body.trim();
1195    if trimmed.is_empty() {
1196        return None;
1197    }
1198    let mut chars = trimmed.chars();
1199    let first = chars.next()?;
1200    if first == '\'' {
1201        let end = trimmed[1..].find('\'')?;
1202        return Some(trimmed[1..=end].to_owned());
1203    }
1204    if !first.is_ascii_lowercase() {
1205        return None;
1206    }
1207    let mut end = 1;
1208    for c in chars {
1209        if c.is_ascii_alphanumeric() || c == '_' || c == '@' {
1210            end += c.len_utf8();
1211            continue;
1212        }
1213        break;
1214    }
1215    Some(trimmed[..end].to_owned())
1216}
1217
1218fn parse_single_string_argument(body: &str) -> Option<String> {
1219    let trimmed = body.trim();
1220    let first = trimmed.chars().next()?;
1221    if first != '"' {
1222        return None;
1223    }
1224    let bytes = trimmed.as_bytes();
1225    let mut i = 1usize;
1226    let mut out = String::new();
1227    while i < bytes.len() {
1228        let b = bytes[i];
1229        if b == b'\\' && i + 1 < bytes.len() {
1230            out.push(bytes[i + 1] as char);
1231            i += 2;
1232            continue;
1233        }
1234        if b == b'"' {
1235            return Some(out);
1236        }
1237        out.push(b as char);
1238        i += 1;
1239    }
1240    None
1241}