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