1use std::mem;
22use std::ops::Range;
23use std::str::FromStr;
24use std::sync::OnceLock;
25
26use regex::Regex;
27
28use crate::compat::arg_shape::ArgShape;
29use crate::erlang_macros::{MacroTable, expand_value_macro_to_atom, expand_value_macro_to_mf};
30use crate::model::names::{Arity, FunctionName, Mfa, ModuleName, RecordName, TypeName};
31use crate::model::symbol::{LineClass, RefContext, RefOrigin, SymbolRef};
32
33pub use backhopper_erlang_scan::{
35 ScanArity, ScannedArgs, scan_top_level_args, split_top_level_args,
36};
37use backhopper_erlang_scan::{
38 count_top_level_items, hash_inside_number, is_bare_atom, literal_span, matching_bracket,
39 opens_type_ref_context, quoted_atom_span, scan_arity, skip_char_literal_span,
40 split_top_level_commas, string_span,
41};
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum DynamicCall {
45 Apply,
48 VariableDispatch,
51}
52
53pub(crate) fn call_re() -> &'static Regex {
54 static R: OnceLock<Regex> = OnceLock::new();
55 R.get_or_init(|| {
56 Regex::new(r"\b([a-z][a-zA-Z0-9_@]*)\s*:\s*([a-z][a-zA-Z0-9_@]*)\s*\(").expect("regex")
57 })
58}
59
60fn record_re() -> &'static Regex {
61 static R: OnceLock<Regex> = OnceLock::new();
62 R.get_or_init(|| Regex::new(r"#([a-z][a-zA-Z0-9_@]*)\b").expect("regex"))
63}
64
65fn macro_re() -> &'static Regex {
66 static R: OnceLock<Regex> = OnceLock::new();
67 R.get_or_init(|| Regex::new(r"\?([A-Z_][A-Za-z0-9_]*)\b").expect("regex"))
68}
69
70fn fun_def_re() -> &'static Regex {
72 static R: OnceLock<Regex> = OnceLock::new();
73 R.get_or_init(|| Regex::new(r"(?m)^([a-z][a-zA-Z0-9_@]*)\s*\(").expect("regex"))
74}
75
76fn apply_bif_re() -> &'static Regex {
77 static R: OnceLock<Regex> = OnceLock::new();
78 R.get_or_init(|| {
79 Regex::new(r"\b(apply|spawn|spawn_link|spawn_monitor|spawn_opt|hibernate)\s*\(")
80 .expect("regex")
81 })
82}
83
84fn var_module_call_re() -> &'static Regex {
85 static R: OnceLock<Regex> = OnceLock::new();
86 R.get_or_init(|| {
87 Regex::new(r"\b[A-Z][a-zA-Z0-9_@]*\s*:\s*[a-zA-Z_][a-zA-Z0-9_@]*\s*\(").expect("regex")
88 })
89}
90
91fn var_function_call_re() -> &'static Regex {
92 static R: OnceLock<Regex> = OnceLock::new();
93 R.get_or_init(|| {
94 Regex::new(r"\b[a-z][a-zA-Z0-9_@]*\s*:\s*[A-Z][a-zA-Z0-9_@]*\s*\(").expect("regex")
95 })
96}
97
98pub fn extract_into(line: &str, out: &mut Vec<SymbolRef>) {
99 extract_into_with_macros(line, empty_macros(), out);
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct QualifiedCallSite {
105 pub mfa: Mfa,
106 pub line: u32,
107}
108
109pub fn extract_qualified_calls(src: &str, line_map: &[u32]) -> Vec<QualifiedCallSite> {
120 extract_qualified_calls_with_context(src, line_map, &line_context(src))
121}
122
123pub fn extract_qualified_calls_with_context(
129 src: &str,
130 line_map: &[u32],
131 ctx: &[LineClass],
132) -> Vec<QualifiedCallSite> {
133 let mut out = Vec::new();
134 for run in body_runs_from_context(src, line_map, ctx) {
135 let mut calls = Vec::new();
136 push_call_args_with_offsets(&run.text, &mut calls);
137 for ((mfa, _shapes), offset) in calls {
138 out.push(QualifiedCallSite {
139 mfa,
140 line: run_line_at(&run.line_starts, offset),
141 });
142 }
143 }
144 for run in attribute_runs_from_context(src, line_map, ctx) {
145 let mut tuples = Vec::new();
146 push_mfa_tuples_with_offsets(&run.text, empty_macros(), &mut tuples);
147 for (mfa, offset) in tuples {
148 out.push(QualifiedCallSite {
149 mfa,
150 line: run_line_at(&run.line_starts, offset),
151 });
152 }
153 }
154 out
155}
156
157#[derive(Debug)]
162pub(crate) struct BodyRun {
163 pub(crate) text: String,
164 pub(crate) line_starts: Vec<(usize, u32)>,
165}
166
167pub(crate) fn body_runs_with<'a>(
173 src: &'a str,
174 line_map: &[u32],
175 strip: impl Fn(&'a str) -> &'a str,
176 mut is_body: impl FnMut(&str) -> bool,
177) -> Vec<BodyRun> {
178 debug_assert_eq!(
179 line_map.len(),
180 src.lines().count(),
181 "line map length must equal the line count"
182 );
183 let mut out = Vec::new();
184 let mut run = String::new();
185 let mut run_lines: Vec<(usize, u32)> = Vec::new();
186 let mut prev_body_file_line: Option<u32> = None;
187 for (idx, line) in src.lines().enumerate() {
188 let stripped = strip(line);
189 let body = is_body(stripped);
190 let file_line = line_map.get(idx).copied();
191 let adjacent =
192 matches!((prev_body_file_line, file_line), (Some(prev), Some(cur)) if cur == prev + 1);
193 if (!body || !adjacent) && !run.is_empty() {
194 out.push(BodyRun {
195 text: mem::take(&mut run),
196 line_starts: mem::take(&mut run_lines),
197 });
198 }
199 prev_body_file_line = body.then_some(file_line).flatten();
200 if !body {
201 continue;
202 }
203 run_lines.push((run.len(), (idx + 1) as u32));
204 run.push_str(stripped);
205 run.push('\n');
206 }
207 if !run.is_empty() {
208 out.push(BodyRun {
209 text: run,
210 line_starts: run_lines,
211 });
212 }
213 out
214}
215
216pub fn line_context(src: &str) -> Vec<LineClass> {
224 let mut scanner = AttrCtxScanner::new();
225 src.lines()
226 .map(|line| scanner.classify(strip_line_comment(line)))
227 .collect()
228}
229
230pub(crate) fn body_runs_from_context(
238 src: &str,
239 line_map: &[u32],
240 ctx: &[LineClass],
241) -> Vec<BodyRun> {
242 let has_spans = ctx.iter().any(|c| !c.type_spans.is_empty());
243 let masked;
244 let text: &str = if has_spans {
245 masked = mask_call_view(src, ctx);
246 &masked
247 } else {
248 src
249 };
250 runs_of_lines(text, line_map, ctx, |c| c.context.holds_calls())
253}
254
255pub(crate) fn attribute_runs_from_context(
259 src: &str,
260 line_map: &[u32],
261 ctx: &[LineClass],
262) -> Vec<BodyRun> {
263 runs_of_lines(src, line_map, ctx, |c| {
264 c.context == RefContext::OtherAttribute
265 })
266}
267
268fn runs_of_lines(
272 text: &str,
273 line_map: &[u32],
274 ctx: &[LineClass],
275 keep: impl Fn(&LineClass) -> bool,
276) -> Vec<BodyRun> {
277 let mut idx = 0usize;
278 body_runs_with(text, line_map, strip_line_comment, |_line| {
279 let keep = ctx.get(idx).is_some_and(&keep);
280 idx += 1;
281 keep
282 })
283}
284
285fn mask_call_view(src: &str, ctx: &[LineClass]) -> String {
290 let mut out = String::with_capacity(src.len());
291 for (idx, line) in src.lines().enumerate() {
292 let spans: &[Range<usize>] = ctx.get(idx).map_or(&[], |c| &c.type_spans);
293 out.push_str(&mask_ranges(line, spans));
294 out.push('\n');
295 }
296 out
297}
298
299fn mask_ranges(line: &str, spans: &[Range<usize>]) -> String {
304 if spans.is_empty() {
305 return line.to_owned();
306 }
307 let mut bytes = line.as_bytes().to_vec();
308 for span in spans {
309 let end = span.end.min(bytes.len());
310 let start = span.start.min(end);
311 bytes[start..end].fill(b' ');
312 }
313 String::from_utf8(bytes).expect("masking only touches ascii delimiter boundaries")
314}
315
316fn mask_outside_ranges(line: &str, spans: &[Range<usize>]) -> String {
319 let mut bytes = vec![b' '; line.len()];
320 for span in spans {
321 let end = span.end.min(line.len());
322 let start = span.start.min(end);
323 bytes[start..end].copy_from_slice(&line.as_bytes()[start..end]);
324 }
325 String::from_utf8(bytes).expect("masking only touches ascii delimiter boundaries")
326}
327
328fn value_at_or_before<T: Copy>(entries: &[(usize, T)], offset: usize, default: T) -> T {
332 match entries.partition_point(|(start, _)| *start <= offset) {
333 0 => default,
334 count => entries[count - 1].1,
335 }
336}
337
338fn scanned_call(scan: ScanArity, module: ModuleName, function: FunctionName) -> SymbolRef {
341 match scan {
342 ScanArity::Exact(arity) => {
343 SymbolRef::function(Mfa::new(module, function, Arity::new(arity)))
344 }
345 ScanArity::Unterminated => SymbolRef::function_any_arity(module, function),
346 }
347}
348
349pub(crate) fn run_line_at(run_lines: &[(usize, u32)], offset: usize) -> u32 {
351 value_at_or_before(run_lines, offset, run_lines.first().map_or(1, |(_, l)| *l))
352}
353
354#[derive(Debug, Default, Clone, Copy)]
368pub struct AttrCtxScanner {
369 region: Option<AttrRegion>,
370}
371
372#[derive(Debug, Clone, Copy)]
374enum Resume {
375 Open(RefContext),
376 Record(FieldSplit),
377}
378
379#[derive(Debug, Clone, Copy)]
383enum AttrRegion {
384 Open(RefContext),
386 Record(FieldSplit),
388 String { resume: Resume, quotes: usize },
391}
392
393#[derive(Debug, Clone, Copy)]
405struct FieldSplit {
406 depth: i32,
407 angle: i32,
408 in_type: bool,
409}
410
411impl FieldSplit {
412 fn new() -> Self {
413 Self {
414 depth: -1,
415 angle: 0,
416 in_type: false,
417 }
418 }
419}
420
421impl AttrCtxScanner {
422 pub fn new() -> Self {
423 Self { region: None }
424 }
425
426 pub fn classify(&mut self, line: &str) -> LineClass {
427 match self.region {
428 Some(AttrRegion::String { resume, quotes }) => {
429 if line_closes_quote_run(line, quotes) {
430 self.region = (!line_closes_attribute(line)).then_some(match resume {
431 Resume::Open(attr) => AttrRegion::Open(attr),
432 Resume::Record(split) => AttrRegion::Record(split),
433 });
434 }
435 LineClass::new(RefContext::AttributeString)
436 }
437 Some(AttrRegion::Open(attr)) => self.continue_open(attr, line),
438 Some(AttrRegion::Record(mut split)) => self.continue_record(&mut split, line),
439 None if line_opens_type_attr(line) => {
440 self.continue_open(RefContext::TypeAttribute, line)
441 }
442 None if line_opens_record_attr(line) => {
443 self.continue_record(&mut FieldSplit::new(), line)
444 }
445 None if line_opens_other_attr(line) => {
446 self.continue_open(RefContext::OtherAttribute, line)
447 }
448 None => LineClass::new(RefContext::Body),
449 }
450 }
451
452 fn continue_open(&mut self, attr: RefContext, line: &str) -> LineClass {
456 self.region = match trailing_quote_run(line) {
457 Some(quotes) => Some(AttrRegion::String {
458 resume: Resume::Open(attr),
459 quotes,
460 }),
461 None if line_closes_attribute(line) => None,
462 None => Some(AttrRegion::Open(attr)),
463 };
464 LineClass::new(attr)
465 }
466
467 fn continue_record(&mut self, split: &mut FieldSplit, line: &str) -> LineClass {
471 let type_spans = record_field_spans(line, split);
472 self.region = match trailing_quote_run(line) {
473 Some(quotes) => Some(AttrRegion::String {
474 resume: Resume::Record(*split),
475 quotes,
476 }),
477 None if line_closes_attribute(line) => None,
478 None => Some(AttrRegion::Record(*split)),
479 };
480 LineClass {
481 context: RefContext::RecordAttribute,
482 type_spans,
483 }
484 }
485}
486
487fn record_field_spans(line: &str, split: &mut FieldSplit) -> Vec<Range<usize>> {
494 let bytes = line.as_bytes();
495 let mut spans = Vec::new();
496 let mut span_start = split.in_type.then_some(0);
497 let mut i = 0usize;
498 while i < bytes.len() {
499 match bytes[i] {
500 b'"' | b'~' => {
501 if let Some(len) = string_span(bytes, i) {
502 i += len;
503 continue;
504 }
505 }
506 b'\'' => {
507 if let Some(len) = quoted_atom_span(bytes, i) {
508 i += len;
509 continue;
510 }
511 }
512 b'$' => {
513 i += skip_char_literal_span(bytes, i);
514 continue;
515 }
516 _ => {}
517 }
518 let at_field_level = split.depth == 0 && split.angle == 0;
519 match bytes[i] {
520 b'<' if bytes.get(i + 1) == Some(&b'<') => {
521 split.angle += 1;
522 i += 2;
523 continue;
524 }
525 b'>' if bytes.get(i + 1) == Some(&b'>') && split.angle > 0 => {
526 split.angle -= 1;
527 i += 2;
528 continue;
529 }
530 b'{' if split.depth < 0 => {
531 split.depth = 0;
534 i += 1;
535 continue;
536 }
537 b'(' | b'[' | b')' | b']' if split.depth < 0 => {
538 i += 1;
540 continue;
541 }
542 b'(' | b'[' | b'{' => {
543 split.depth += 1;
544 i += 1;
545 continue;
546 }
547 b')' | b']' | b'}' => {
548 if at_field_level && split.in_type {
549 spans.push(close_span(&mut span_start, &mut split.in_type, i));
550 }
551 split.depth = (split.depth - 1).max(-1);
552 i += 1;
553 continue;
554 }
555 b':' if at_field_level && !split.in_type && bytes.get(i + 1) == Some(&b':') => {
556 i += 2;
557 span_start = Some(i);
558 split.in_type = true;
559 continue;
560 }
561 b',' if at_field_level && split.in_type => {
562 spans.push(close_span(&mut span_start, &mut split.in_type, i));
563 i += 1;
564 continue;
565 }
566 _ => {}
567 }
568 i += 1;
569 }
570 if split.in_type {
571 let start = span_start.expect("in_type implies span_start is set");
572 spans.push(start..bytes.len());
573 }
574 spans.retain(|span| !span.is_empty());
575 spans
576}
577
578fn close_span(span_start: &mut Option<usize>, in_type: &mut bool, at: usize) -> Range<usize> {
582 let start = span_start
583 .take()
584 .expect("in_type implies span_start is set");
585 *in_type = false;
586 start..at
587}
588
589fn trailing_quote_run(line: &str) -> Option<usize> {
594 let run = strip_line_comment(line)
595 .trim_end()
596 .bytes()
597 .rev()
598 .take_while(|b| *b == b'"')
599 .count();
600 (run >= 3).then_some(run)
601}
602
603fn line_closes_quote_run(line: &str, open: usize) -> bool {
606 line.trim_start().bytes().take_while(|b| *b == b'"').count() >= open
607}
608
609fn line_opens_type_attr(line: &str) -> bool {
610 let trimmed = line.trim_start();
611 let Some(rest) = trimmed.strip_prefix('-') else {
612 return false;
613 };
614 let name_end = rest
615 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
616 .unwrap_or(rest.len());
617 if !opens_type_ref_context(&rest[..name_end]) {
618 return false;
619 }
620 matches!(rest[name_end..].chars().next(), Some(c) if c == '(' || c.is_whitespace())
621}
622
623fn line_opens_record_attr(line: &str) -> bool {
627 let trimmed = line.trim_start();
628 let Some(rest) = trimmed.strip_prefix("-record") else {
629 return false;
630 };
631 matches!(rest.chars().next(), Some(c) if c == '(' || c.is_whitespace())
632}
633
634fn line_opens_other_attr(line: &str) -> bool {
637 let trimmed = line.trim_start();
638 let mut chars = trimmed.chars();
639 chars.next() == Some('-') && chars.next().is_some_and(|c| c.is_ascii_lowercase())
640}
641
642fn line_closes_attribute(line: &str) -> bool {
643 let stripped = strip_line_comment(line);
644 stripped.trim_end().ends_with('.')
645}
646
647pub fn strip_line_comment(line: &str) -> &str {
650 let bytes = line.as_bytes();
651 let mut in_str = false;
652 let mut in_atom = false;
653 let mut i = 0usize;
654 while i < bytes.len() {
655 let b = bytes[i];
656 if in_str || in_atom {
657 match b {
658 b'\\' => i += 1,
659 b'"' if in_str => in_str = false,
660 b'\'' if in_atom => in_atom = false,
661 _ => {}
662 }
663 i += 1;
664 continue;
665 }
666 match b {
667 b'"' => in_str = true,
668 b'\'' => in_atom = true,
669 b'$' => {
670 i += skip_char_literal_span(bytes, i);
671 continue;
672 }
673 b'%' => return &line[..i],
674 _ => {}
675 }
676 i += 1;
677 }
678 line
679}
680
681pub fn extract_type_refs_into(line: &str, out: &mut Vec<SymbolRef>) {
692 let line = strip_line_comment(line);
693 let mut with_offsets = Vec::new();
694 push_type_refs_with_offsets(line, &mut with_offsets);
695 out.extend(with_offsets.into_iter().map(|(r, _)| r));
696}
697
698fn push_type_refs_with_offsets(text: &str, out: &mut Vec<(SymbolRef, usize)>) {
701 for caps in call_re().captures_iter(text) {
702 let group = caps.get(0).expect("capture");
703 let module = &caps[1];
704 let ident = &caps[2];
705 let ScanArity::Exact(arity) = scan_arity(&text[group.end()..]) else {
708 continue;
709 };
710 if let (Ok(m), Ok(t)) = (ModuleName::from_str(module), TypeName::from_str(ident)) {
711 out.push((SymbolRef::type_ref(m, t, Arity::new(arity)), group.start()));
712 }
713 }
714 for caps in macro_re().captures_iter(text) {
715 let group = caps.get(0).expect("capture");
716 out.push((SymbolRef::macro_use(caps[1].to_owned()), group.start()));
717 }
718}
719
720fn empty_macros() -> &'static MacroTable {
721 static EMPTY: OnceLock<MacroTable> = OnceLock::new();
722 EMPTY.get_or_init(MacroTable::new)
723}
724
725pub fn extract_into_with_macros(line: &str, macros: &MacroTable, out: &mut Vec<SymbolRef>) {
731 let line = strip_line_comment(line);
732 let mut with_offsets = Vec::new();
733 push_refs_with_offsets(line, macros, &mut with_offsets);
734 out.extend(with_offsets.into_iter().map(|(r, _)| r));
735}
736
737fn push_refs_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(SymbolRef, usize)>) {
741 for caps in call_re().captures_iter(text) {
742 let group = caps.get(0).expect("capture");
743 let module = &caps[1];
744 let function = &caps[2];
745 let after = &text[group.end()..];
746 let (Ok(m), Ok(f)) = (
747 ModuleName::from_str(module),
748 FunctionName::from_str(function),
749 ) else {
750 continue;
751 };
752 out.push((scanned_call(scan_arity(after), m, f), group.start()));
753 }
754 push_fun_refs_with_offsets(text, macros, out);
755 for caps in record_re().captures_iter(text) {
756 let start = caps.get(0).expect("capture").start();
757 if hash_inside_number(text.as_bytes(), start) {
759 continue;
760 }
761 if let Ok(name) = RecordName::from_str(&caps[1]) {
762 out.push((SymbolRef::record(name), start));
763 }
764 }
765 for caps in macro_re().captures_iter(text) {
766 let group = caps.get(0).expect("capture");
767 let name = &caps[1];
768 out.push((SymbolRef::macro_use(name.to_owned()), group.start()));
769 let mut resolved = Vec::new();
770 try_resolve_macro_call(name, &text[group.end()..], macros, &mut resolved);
771 out.extend(resolved.into_iter().map(|r| (r, group.start())));
772 }
773 push_apply_family_with_offsets(text, macros, out);
774}
775
776fn fun_ref_re() -> &'static Regex {
777 static R: OnceLock<Regex> = OnceLock::new();
778 R.get_or_init(|| {
779 Regex::new(
780 r"\bfun\s+(\??[A-Za-z_][a-zA-Z0-9_@]*)\s*:\s*([A-Za-z_][a-zA-Z0-9_@]*)\s*/\s*([0-9]+|[A-Za-z_][a-zA-Z0-9_@]*)",
781 )
782 .expect("regex")
783 })
784}
785
786fn push_fun_refs_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(SymbolRef, usize)>) {
790 for caps in fun_ref_re().captures_iter(text) {
791 let module_raw = &caps[1];
792 let function_raw = &caps[2];
793 let arity_raw = &caps[3];
794 let Ok(arity) = arity_raw.parse::<u8>() else {
795 continue;
796 };
797 if !function_raw.starts_with(|c: char| c.is_ascii_lowercase()) {
798 continue;
799 }
800 let module = if let Some(macro_name) = module_raw.strip_prefix('?') {
801 let Some(expanded) = expand_value_macro_to_atom(macros, macro_name) else {
802 continue;
803 };
804 let Ok(m) = ModuleName::from_str(&expanded) else {
805 continue;
806 };
807 m
808 } else if module_raw.starts_with(|c: char| c.is_ascii_lowercase()) {
809 let Ok(m) = ModuleName::from_str(module_raw) else {
810 continue;
811 };
812 m
813 } else {
814 continue;
815 };
816 let Ok(f) = FunctionName::from_str(function_raw) else {
817 continue;
818 };
819 let start = caps.get(0).expect("capture").start();
820 out.push((
821 SymbolRef::function(Mfa::new(module, f, Arity::new(arity))),
822 start,
823 ));
824 }
825}
826
827fn try_resolve_macro_call(name: &str, tail: &str, macros: &MacroTable, out: &mut Vec<SymbolRef>) {
828 let trimmed = tail.trim_start();
829 if let Some(rest) = trimmed.strip_prefix('(') {
830 let Some((m, f)) = expand_value_macro_to_mf(macros, name) else {
831 return;
832 };
833 let (Ok(mn), Ok(fn_)) = (ModuleName::from_str(&m), FunctionName::from_str(&f)) else {
834 return;
835 };
836 out.push(scanned_call(scan_arity(rest), mn, fn_));
837 return;
838 }
839 if let Some(rest) = trimmed.strip_prefix(':') {
840 let rest = rest.trim_start();
841 let (function_str, after_fn) = split_lowercase_ident(rest);
842 if function_str.is_empty() {
843 return;
844 }
845 let after = after_fn.trim_start();
846 if let Some(after_open) = after.strip_prefix('(') {
847 if let Some(m) = expand_value_macro_to_atom(macros, name) {
848 if let (Ok(mn), Ok(fn_)) = (
849 ModuleName::from_str(&m),
850 FunctionName::from_str(function_str),
851 ) {
852 out.push(scanned_call(scan_arity(after_open), mn, fn_));
853 }
854 }
855 }
856 }
857}
858
859fn split_lowercase_ident(s: &str) -> (&str, &str) {
860 let end = s
861 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
862 .unwrap_or(s.len());
863 if end == 0 || !s.starts_with(|c: char| c.is_ascii_lowercase()) {
864 return ("", s);
865 }
866 s.split_at(end)
867}
868
869fn push_apply_family_with_offsets(
870 text: &str,
871 macros: &MacroTable,
872 out: &mut Vec<(SymbolRef, usize)>,
873) {
874 for caps in apply_family_re().captures_iter(text) {
875 let group = caps.get(0).expect("capture");
876 let name = &caps[1];
877 let after_open = &text[group.end()..];
878 let ScannedArgs::Terminated { args, .. } = scan_top_level_args(after_open) else {
880 continue;
881 };
882 if let Some(mfa) = resolve_apply_family(name, &args, macros) {
883 out.push((SymbolRef::function(mfa), group.start()));
884 }
885 }
886}
887
888fn apply_family_re() -> &'static Regex {
889 static R: OnceLock<Regex> = OnceLock::new();
890 R.get_or_init(|| {
891 Regex::new(
892 r"\b(?:erlang\s*:\s*)?(apply|spawn|spawn_link|spawn_monitor|spawn_opt|hibernate)\s*\(",
893 )
894 .expect("regex")
895 })
896}
897
898fn resolve_apply_family(name: &str, args: &[&str], macros: &MacroTable) -> Option<Mfa> {
899 let (m_raw, f_raw, a_raw) = match (name, args.len()) {
900 ("apply", 3)
901 | ("spawn" | "spawn_link" | "spawn_monitor" | "hibernate", 3)
902 | ("spawn_opt", 4) => (args[0], args[1], args[2]),
903 ("spawn" | "spawn_link" | "spawn_monitor", 4) | ("spawn_opt", 5) => {
904 (args[1], args[2], args[3])
905 }
906 _ => return None,
907 };
908 let module = atom_or_macro_to::<ModuleName>(m_raw, macros)?;
909 let function = atom_or_macro_to::<FunctionName>(f_raw, macros)?;
910 let arity = literal_list_length(a_raw)?;
911 Some(Mfa::new(module, function, Arity::new(arity)))
912}
913
914fn atom_or_macro_to<T: FromStr>(raw: &str, macros: &MacroTable) -> Option<T> {
916 let s = raw.trim();
917 if let Some(name) = s.strip_prefix('?') {
918 return expand_value_macro_to_atom(macros, name).and_then(|a| T::from_str(&a).ok());
919 }
920 if is_bare_atom(s) {
921 return T::from_str(s).ok();
922 }
923 if s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2 {
924 return T::from_str(&s[1..s.len() - 1]).ok();
925 }
926 None
927}
928
929fn literal_list_length(raw: &str) -> Option<u8> {
930 let s = raw.trim();
931 let inner = s.strip_prefix('[')?.strip_suffix(']')?;
932 let trimmed = inner.trim();
933 if trimmed.is_empty() {
934 return Some(0);
935 }
936 u8::try_from(count_top_level_items(s, '[', ']')).ok()
937}
938
939fn push_mfa_tuples_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(Mfa, usize)>) {
944 let bytes = text.as_bytes();
945 let mut i = 0usize;
946 while i < bytes.len() {
947 if let Some(span) = literal_span(bytes, i) {
948 i += span;
949 continue;
950 }
951 if bytes[i] == b'{'
952 && let Some(close) = matching_bracket(bytes, i)
953 {
954 try_push_mfa_tuple(&text[i + 1..close], i, macros, out);
955 }
956 i += 1;
957 }
958}
959
960pub fn extract_mfa_tuples_with_macros(text: &str, macros: &MacroTable) -> Vec<Mfa> {
963 let mut out = Vec::new();
964 push_mfa_tuples_with_offsets(text, macros, &mut out);
965 out.into_iter().map(|(mfa, _)| mfa).collect()
966}
967
968fn try_push_mfa_tuple(
972 inner: &str,
973 tuple_offset: usize,
974 macros: &MacroTable,
975 out: &mut Vec<(Mfa, usize)>,
976) {
977 let items = split_top_level_commas(inner);
978 let [m_raw, f_raw, args_raw] = items.as_slice() else {
979 return;
980 };
981 let Some(module) = atom_or_macro_to::<ModuleName>(m_raw, macros) else {
982 return;
983 };
984 let Some(function) = atom_or_macro_to::<FunctionName>(f_raw, macros) else {
985 return;
986 };
987 let Some(arity) = literal_list_length(args_raw) else {
988 return;
989 };
990 out.push((Mfa::new(module, function, Arity::new(arity)), tuple_offset));
991}
992
993pub fn extract_call_args_into(line: &str, out: &mut Vec<(Mfa, Vec<ArgShape>)>) {
998 let line = strip_line_comment(line);
999 let mut with_offsets = Vec::new();
1000 push_call_args_with_offsets(line, &mut with_offsets);
1001 out.extend(with_offsets.into_iter().map(|(c, _)| c));
1002}
1003
1004fn push_call_args_with_offsets(text: &str, out: &mut Vec<((Mfa, Vec<ArgShape>), usize)>) {
1007 for caps in call_re().captures_iter(text) {
1008 let group = caps.get(0).expect("capture");
1009 let module = &caps[1];
1010 let function = &caps[2];
1011 let Some((args, _consumed)) = extract_arg_shapes(&text[group.end()..]) else {
1013 continue;
1014 };
1015 let arity = u8::try_from(args.len()).unwrap_or(u8::MAX);
1016 if let (Ok(m), Ok(f)) = (
1017 ModuleName::from_str(module),
1018 FunctionName::from_str(function),
1019 ) {
1020 out.push(((Mfa::new(m, f, Arity::new(arity)), args), group.start()));
1021 }
1022 }
1023}
1024
1025fn extract_arg_shapes(after_open_paren: &str) -> Option<(Vec<ArgShape>, usize)> {
1026 match scan_top_level_args(after_open_paren) {
1027 ScannedArgs::Terminated { args, consumed } => {
1028 Some((args.iter().map(|a| classify_arg(a)).collect(), consumed))
1029 }
1030 ScannedArgs::Unterminated { .. } => None,
1031 }
1032}
1033
1034pub fn classify_arg(raw: &str) -> ArgShape {
1035 let s = raw.trim();
1036 if s.is_empty() {
1037 return ArgShape::Unknown;
1038 }
1039 let first = s.chars().next().expect("non-empty");
1040 if first == '#' {
1041 let rest = &s[1..];
1042 let name_end = rest
1043 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
1044 .unwrap_or(rest.len());
1045 let name = &rest[..name_end];
1046 if let Ok(record_name) = RecordName::from_str(name) {
1047 return ArgShape::Record { name: record_name };
1048 }
1049 return ArgShape::Unknown;
1050 }
1051 if first == '{' {
1052 let size = count_top_level_items(s, '{', '}');
1053 return ArgShape::Tuple { size };
1054 }
1055 if first == '[' {
1056 return ArgShape::List;
1057 }
1058 if first == '"' {
1059 return ArgShape::String;
1060 }
1061 if first == '<' && s.starts_with("<<") {
1062 return ArgShape::Binary;
1063 }
1064 if first == '\'' {
1065 let inner = s.trim_start_matches('\'').trim_end_matches('\'');
1066 return ArgShape::Atom {
1067 name: inner.to_string(),
1068 };
1069 }
1070 if first.is_ascii_digit()
1071 || (first == '-' && s.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
1072 {
1073 return if s.contains('.') {
1074 ArgShape::Float
1075 } else {
1076 ArgShape::Integer
1077 };
1078 }
1079 if first.is_ascii_uppercase() || first == '_' {
1080 return ArgShape::Variable;
1081 }
1082 if first.is_ascii_lowercase() {
1083 let bare = is_bare_identifier(s);
1084 if bare {
1085 return ArgShape::Atom {
1086 name: s.to_string(),
1087 };
1088 }
1089 if s.starts_with("fun ") || s == "fun" {
1090 return ArgShape::Fun;
1091 }
1092 return ArgShape::Unknown;
1093 }
1094 ArgShape::Unknown
1095}
1096
1097fn is_bare_identifier(s: &str) -> bool {
1098 s.chars()
1099 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
1100}
1101
1102pub fn extract_dynamic_into(line: &str, out: &mut Vec<DynamicCall>) {
1103 let line = strip_line_comment(line);
1104 for _ in apply_bif_re().captures_iter(line) {
1105 out.push(DynamicCall::Apply);
1106 }
1107 for _ in var_module_call_re().find_iter(line) {
1108 out.push(DynamicCall::VariableDispatch);
1109 }
1110 for _ in var_function_call_re().find_iter(line) {
1111 out.push(DynamicCall::VariableDispatch);
1112 }
1113 for caps in fun_ref_re().captures_iter(line) {
1114 let variable_slot = |s: &str| s.starts_with(|c: char| c.is_ascii_uppercase() || c == '_');
1115 let arity_raw = &caps[3];
1116 let variable_arity = !arity_raw.bytes().all(|b| b.is_ascii_digit());
1117 if variable_slot(&caps[1]) || variable_slot(&caps[2]) || variable_arity {
1118 out.push(DynamicCall::VariableDispatch);
1119 }
1120 }
1121}
1122
1123pub fn extract_definitions_into(text: &str, out: &mut Vec<SymbolRef>) {
1124 for caps in fun_def_re().captures_iter(text) {
1125 let head_end = caps.get(0).expect("capture").end();
1126 let after = &text[head_end..];
1127 let Ok(f) = FunctionName::from_str(&caps[1]) else {
1128 continue;
1129 };
1130 let module = local_placeholder_module().clone();
1131 match scan_arity(after) {
1132 ScanArity::Exact(arity) => {
1133 out.push(SymbolRef::function(Mfa::new(module, f, Arity::new(arity))));
1134 }
1135 ScanArity::Unterminated => {
1136 out.push(SymbolRef::function_any_arity(module, f));
1137 }
1138 }
1139 }
1140}
1141
1142fn local_placeholder_module() -> &'static ModuleName {
1143 static M: OnceLock<ModuleName> = OnceLock::new();
1144 M.get_or_init(|| ModuleName::from_str("_local").expect("valid"))
1145}
1146
1147#[derive(Debug, Default)]
1150pub struct HunkScan {
1151 pub referenced: Vec<SymbolRef>,
1152 pub defined: Vec<SymbolRef>,
1153 pub dynamic_calls: Vec<DynamicCall>,
1154 pub call_args: Vec<(Mfa, Vec<ArgShape>)>,
1155}
1156
1157pub fn scan_hunk(lines: &[(RefOrigin, &str)], macros: &MacroTable) -> HunkScan {
1169 let mut out = HunkScan::default();
1170 let mut scanner = AttrCtxScanner::new();
1171 let mut text = String::new();
1172 let mut line_origins: Vec<(usize, RefOrigin)> = Vec::new();
1173 let mut run_ctx: Option<bool> = None;
1177 let mut record_text = String::new();
1178 let mut record_origins: Vec<(usize, RefOrigin)> = Vec::new();
1179 for &(origin, raw) in lines {
1180 let stripped = strip_line_comment(raw);
1181 let class = scanner.classify(stripped);
1182 if class.context != RefContext::RecordAttribute && !record_text.is_empty() {
1183 flush_run(true, &record_text, &record_origins, macros, &mut out);
1184 record_text.clear();
1185 record_origins.clear();
1186 }
1187 if class.context == RefContext::AttributeString {
1191 if let Some(prev) = run_ctx.take() {
1192 flush_run(prev, &text, &line_origins, macros, &mut out);
1193 text.clear();
1194 line_origins.clear();
1195 }
1196 continue;
1197 }
1198 let type_attr = class.context == RefContext::TypeAttribute;
1199 if run_ctx != Some(type_attr) {
1200 if let Some(prev) = run_ctx {
1201 flush_run(prev, &text, &line_origins, macros, &mut out);
1202 }
1203 text.clear();
1204 line_origins.clear();
1205 run_ctx = Some(type_attr);
1206 }
1207 if class.context == RefContext::RecordAttribute {
1208 line_origins.push((text.len(), origin));
1209 text.push_str(&mask_ranges(stripped, &class.type_spans));
1210 text.push('\n');
1211 if !class.type_spans.is_empty() {
1212 record_origins.push((record_text.len(), origin));
1213 record_text.push_str(&mask_outside_ranges(stripped, &class.type_spans));
1214 record_text.push('\n');
1215 }
1216 } else {
1217 line_origins.push((text.len(), origin));
1218 text.push_str(stripped);
1219 text.push('\n');
1220 }
1221 }
1222 if let Some(prev) = run_ctx {
1223 flush_run(prev, &text, &line_origins, macros, &mut out);
1224 }
1225 if !record_text.is_empty() {
1226 flush_run(true, &record_text, &record_origins, macros, &mut out);
1227 }
1228 out
1229}
1230
1231fn flush_run(
1232 type_attr: bool,
1233 text: &str,
1234 line_origins: &[(usize, RefOrigin)],
1235 macros: &MacroTable,
1236 out: &mut HunkScan,
1237) {
1238 if type_attr {
1239 let mut refs = Vec::new();
1240 push_type_refs_with_offsets(text, &mut refs);
1241 tag_by_origin(refs, line_origins, &mut out.referenced);
1242 return;
1243 }
1244 let mut refs = Vec::new();
1245 push_refs_with_offsets(text, macros, &mut refs);
1246 tag_by_origin(refs, line_origins, &mut out.referenced);
1247 extract_definitions_into(text, &mut out.defined);
1248 extract_dynamic_into(text, &mut out.dynamic_calls);
1249 let mut args = Vec::new();
1250 push_call_args_with_offsets(text, &mut args);
1251 for (call, offset) in args {
1253 if origin_at(line_origins, offset) == RefOrigin::Added {
1254 out.call_args.push(call);
1255 }
1256 }
1257}
1258
1259fn tag_by_origin(
1260 refs: Vec<(SymbolRef, usize)>,
1261 line_origins: &[(usize, RefOrigin)],
1262 out: &mut Vec<SymbolRef>,
1263) {
1264 for (r, offset) in refs {
1265 out.push(r.with_origin(origin_at(line_origins, offset)));
1266 }
1267}
1268
1269fn origin_at(line_origins: &[(usize, RefOrigin)], offset: usize) -> RefOrigin {
1272 value_at_or_before(line_origins, offset, RefOrigin::Context)
1273}