1use std::mem;
22use std::str::FromStr;
23use std::sync::OnceLock;
24
25use regex::Regex;
26
27use crate::compat::arg_shape::ArgShape;
28use crate::erlang_macros::{MacroTable, expand_value_macro_to_atom, expand_value_macro_to_mf};
29use crate::model::names::{Arity, FunctionName, Mfa, ModuleName, RecordName, TypeName};
30use crate::model::symbol::{RefContext, RefOrigin, SymbolRef};
31
32pub use backhopper_erlang_scan::{
34 ScanArity, ScannedArgs, scan_top_level_args, split_top_level_args,
35};
36use backhopper_erlang_scan::{count_top_level_items, scan_arity};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum DynamicCall {
40 Apply,
43 VariableDispatch,
46}
47
48pub(crate) fn call_re() -> &'static Regex {
49 static R: OnceLock<Regex> = OnceLock::new();
50 R.get_or_init(|| {
51 Regex::new(r"\b([a-z][a-zA-Z0-9_@]*)\s*:\s*([a-z][a-zA-Z0-9_@]*)\s*\(").expect("regex")
52 })
53}
54
55fn record_re() -> &'static Regex {
56 static R: OnceLock<Regex> = OnceLock::new();
57 R.get_or_init(|| Regex::new(r"#([a-z][a-zA-Z0-9_@]*)\b").expect("regex"))
58}
59
60fn macro_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 fun_def_re() -> &'static Regex {
67 static R: OnceLock<Regex> = OnceLock::new();
68 R.get_or_init(|| Regex::new(r"(?m)^([a-z][a-zA-Z0-9_@]*)\s*\(").expect("regex"))
69}
70
71fn apply_bif_re() -> &'static Regex {
72 static R: OnceLock<Regex> = OnceLock::new();
73 R.get_or_init(|| {
74 Regex::new(r"\b(apply|spawn|spawn_link|spawn_monitor|spawn_opt|hibernate)\s*\(")
75 .expect("regex")
76 })
77}
78
79fn var_module_call_re() -> &'static Regex {
80 static R: OnceLock<Regex> = OnceLock::new();
81 R.get_or_init(|| {
82 Regex::new(r"\b[A-Z][a-zA-Z0-9_@]*\s*:\s*[a-zA-Z_][a-zA-Z0-9_@]*\s*\(").expect("regex")
83 })
84}
85
86fn var_function_call_re() -> &'static Regex {
87 static R: OnceLock<Regex> = OnceLock::new();
88 R.get_or_init(|| {
89 Regex::new(r"\b[a-z][a-zA-Z0-9_@]*\s*:\s*[A-Z][a-zA-Z0-9_@]*\s*\(").expect("regex")
90 })
91}
92
93pub fn extract_into(line: &str, out: &mut Vec<SymbolRef>) {
94 extract_into_with_macros(line, empty_macros(), out);
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct QualifiedCallSite {
100 pub mfa: Mfa,
101 pub line: u32,
102}
103
104pub fn extract_qualified_calls(src: &str, line_map: &[u32]) -> Vec<QualifiedCallSite> {
115 extract_qualified_calls_with_context(src, line_map, &line_context(src))
116}
117
118pub fn extract_qualified_calls_with_context(
124 src: &str,
125 line_map: &[u32],
126 ctx: &[RefContext],
127) -> Vec<QualifiedCallSite> {
128 let mut out = Vec::new();
129 for run in body_runs_from_context(src, line_map, ctx) {
130 let mut calls = Vec::new();
131 push_call_args_with_offsets(&run.text, &mut calls);
132 for ((mfa, _shapes), offset) in calls {
133 out.push(QualifiedCallSite {
134 mfa,
135 line: run_line_at(&run.line_starts, offset),
136 });
137 }
138 }
139 out
140}
141
142#[derive(Debug)]
147pub(crate) struct BodyRun {
148 pub(crate) text: String,
149 pub(crate) line_starts: Vec<(usize, u32)>,
150}
151
152pub(crate) fn body_runs_with<'a>(
158 src: &'a str,
159 line_map: &[u32],
160 strip: impl Fn(&'a str) -> &'a str,
161 mut is_body: impl FnMut(&str) -> bool,
162) -> Vec<BodyRun> {
163 debug_assert_eq!(
164 line_map.len(),
165 src.lines().count(),
166 "line map length must equal the line count"
167 );
168 let mut out = Vec::new();
169 let mut run = String::new();
170 let mut run_lines: Vec<(usize, u32)> = Vec::new();
171 let mut prev_body_file_line: Option<u32> = None;
172 for (idx, line) in src.lines().enumerate() {
173 let stripped = strip(line);
174 let body = is_body(stripped);
175 let file_line = line_map.get(idx).copied();
176 let adjacent =
177 matches!((prev_body_file_line, file_line), (Some(prev), Some(cur)) if cur == prev + 1);
178 if (!body || !adjacent) && !run.is_empty() {
179 out.push(BodyRun {
180 text: mem::take(&mut run),
181 line_starts: mem::take(&mut run_lines),
182 });
183 }
184 prev_body_file_line = body.then_some(file_line).flatten();
185 if !body {
186 continue;
187 }
188 run_lines.push((run.len(), (idx + 1) as u32));
189 run.push_str(stripped);
190 run.push('\n');
191 }
192 if !run.is_empty() {
193 out.push(BodyRun {
194 text: run,
195 line_starts: run_lines,
196 });
197 }
198 out
199}
200
201pub fn line_context(src: &str) -> Vec<RefContext> {
209 let mut scanner = AttrCtxScanner::new();
210 src.lines()
211 .map(|line| scanner.classify(strip_line_comment(line)))
212 .collect()
213}
214
215pub(crate) fn body_runs_from_context(
220 src: &str,
221 line_map: &[u32],
222 ctx: &[RefContext],
223) -> Vec<BodyRun> {
224 let mut idx = 0usize;
225 body_runs_with(src, line_map, strip_line_comment, |_line| {
226 let body = ctx.get(idx).is_some_and(|c| c.holds_calls());
228 idx += 1;
229 body
230 })
231}
232
233fn value_at_or_before<T: Copy>(entries: &[(usize, T)], offset: usize, default: T) -> T {
237 match entries.partition_point(|(start, _)| *start <= offset) {
238 0 => default,
239 count => entries[count - 1].1,
240 }
241}
242
243fn scanned_call(scan: ScanArity, module: ModuleName, function: FunctionName) -> SymbolRef {
246 match scan {
247 ScanArity::Exact(arity) => {
248 SymbolRef::function(Mfa::new(module, function, Arity::new(arity)))
249 }
250 ScanArity::Unterminated => SymbolRef::function_any_arity(module, function),
251 }
252}
253
254pub(crate) fn run_line_at(run_lines: &[(usize, u32)], offset: usize) -> u32 {
256 value_at_or_before(run_lines, offset, run_lines.first().map_or(1, |(_, l)| *l))
257}
258
259#[derive(Debug, Default, Clone, Copy)]
271pub struct AttrCtxScanner {
272 region: Option<AttrRegion>,
273}
274
275#[derive(Debug, Clone, Copy)]
279enum AttrRegion {
280 Open(RefContext),
282 String { attr: RefContext, quotes: usize },
285}
286
287impl AttrCtxScanner {
288 pub fn new() -> Self {
289 Self { region: None }
290 }
291
292 pub fn classify(&mut self, line: &str) -> RefContext {
293 match self.region {
294 Some(AttrRegion::String { attr, quotes }) => {
295 if line_closes_quote_run(line, quotes) {
296 self.region = (!line_closes_attribute(line)).then_some(AttrRegion::Open(attr));
297 }
298 RefContext::AttributeString
299 }
300 Some(AttrRegion::Open(attr)) => self.continue_attribute(attr, line),
301 None => {
302 let opened = if line_opens_type_attr(line) {
303 RefContext::TypeAttribute
304 } else if line_opens_other_attr(line) {
305 RefContext::OtherAttribute
306 } else {
307 return RefContext::Body;
308 };
309 self.continue_attribute(opened, line)
310 }
311 }
312 }
313
314 fn continue_attribute(&mut self, attr: RefContext, line: &str) -> RefContext {
318 self.region = match trailing_quote_run(line) {
319 Some(quotes) => Some(AttrRegion::String { attr, quotes }),
320 None if line_closes_attribute(line) => None,
321 None => Some(AttrRegion::Open(attr)),
322 };
323 attr
324 }
325}
326
327fn trailing_quote_run(line: &str) -> Option<usize> {
332 let run = strip_line_comment(line)
333 .trim_end()
334 .bytes()
335 .rev()
336 .take_while(|b| *b == b'"')
337 .count();
338 (run >= 3).then_some(run)
339}
340
341fn line_closes_quote_run(line: &str, open: usize) -> bool {
344 line.trim_start().bytes().take_while(|b| *b == b'"').count() >= open
345}
346
347fn line_opens_type_attr(line: &str) -> bool {
348 let trimmed = line.trim_start();
349 if !trimmed.starts_with('-') {
350 return false;
351 }
352 for prefix in ["-spec", "-callback", "-type", "-opaque"] {
353 if let Some(rest) = trimmed.strip_prefix(prefix) {
354 match rest.chars().next() {
355 Some(c) if c == '(' || c.is_whitespace() => return true,
356 _ => {}
357 }
358 }
359 }
360 false
361}
362
363fn line_opens_other_attr(line: &str) -> bool {
366 let trimmed = line.trim_start();
367 let mut chars = trimmed.chars();
368 chars.next() == Some('-') && chars.next().is_some_and(|c| c.is_ascii_lowercase())
369}
370
371fn line_closes_attribute(line: &str) -> bool {
372 let stripped = strip_line_comment(line);
373 stripped.trim_end().ends_with('.')
374}
375
376pub fn strip_line_comment(line: &str) -> &str {
379 let bytes = line.as_bytes();
380 let mut in_str = false;
381 let mut in_atom = false;
382 let mut i = 0usize;
383 while i < bytes.len() {
384 let b = bytes[i];
385 if in_str || in_atom {
386 match b {
387 b'\\' => i += 1,
388 b'"' if in_str => in_str = false,
389 b'\'' if in_atom => in_atom = false,
390 _ => {}
391 }
392 i += 1;
393 continue;
394 }
395 match b {
396 b'"' => in_str = true,
397 b'\'' => in_atom = true,
398 b'$' => {
399 i += 1;
400 if bytes.get(i) == Some(&b'\\') {
401 i += 1;
402 }
403 }
404 b'%' => return &line[..i],
405 _ => {}
406 }
407 i += 1;
408 }
409 line
410}
411
412pub fn extract_type_refs_into(line: &str, out: &mut Vec<SymbolRef>) {
423 let line = strip_line_comment(line);
424 let mut with_offsets = Vec::new();
425 push_type_refs_with_offsets(line, &mut with_offsets);
426 out.extend(with_offsets.into_iter().map(|(r, _)| r));
427}
428
429fn push_type_refs_with_offsets(text: &str, out: &mut Vec<(SymbolRef, usize)>) {
432 for caps in call_re().captures_iter(text) {
433 let group = caps.get(0).expect("capture");
434 let module = &caps[1];
435 let ident = &caps[2];
436 let ScanArity::Exact(arity) = scan_arity(&text[group.end()..]) else {
439 continue;
440 };
441 if let (Ok(m), Ok(t)) = (ModuleName::from_str(module), TypeName::from_str(ident)) {
442 out.push((SymbolRef::type_ref(m, t, Arity::new(arity)), group.start()));
443 }
444 }
445 for caps in macro_re().captures_iter(text) {
446 let group = caps.get(0).expect("capture");
447 out.push((SymbolRef::macro_use(caps[1].to_owned()), group.start()));
448 }
449}
450
451fn empty_macros() -> &'static MacroTable {
452 static EMPTY: OnceLock<MacroTable> = OnceLock::new();
453 EMPTY.get_or_init(MacroTable::new)
454}
455
456pub fn extract_into_with_macros(line: &str, macros: &MacroTable, out: &mut Vec<SymbolRef>) {
462 let line = strip_line_comment(line);
463 let mut with_offsets = Vec::new();
464 push_refs_with_offsets(line, macros, &mut with_offsets);
465 out.extend(with_offsets.into_iter().map(|(r, _)| r));
466}
467
468fn push_refs_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(SymbolRef, usize)>) {
472 for caps in call_re().captures_iter(text) {
473 let group = caps.get(0).expect("capture");
474 let module = &caps[1];
475 let function = &caps[2];
476 let after = &text[group.end()..];
477 let (Ok(m), Ok(f)) = (
478 ModuleName::from_str(module),
479 FunctionName::from_str(function),
480 ) else {
481 continue;
482 };
483 out.push((scanned_call(scan_arity(after), m, f), group.start()));
484 }
485 push_fun_refs_with_offsets(text, macros, out);
486 for caps in record_re().captures_iter(text) {
487 let start = caps.get(0).expect("capture").start();
488 if let Ok(name) = RecordName::from_str(&caps[1]) {
489 out.push((SymbolRef::record(name), start));
490 }
491 }
492 for caps in macro_re().captures_iter(text) {
493 let group = caps.get(0).expect("capture");
494 let name = &caps[1];
495 out.push((SymbolRef::macro_use(name.to_owned()), group.start()));
496 let mut resolved = Vec::new();
497 try_resolve_macro_call(name, &text[group.end()..], macros, &mut resolved);
498 out.extend(resolved.into_iter().map(|r| (r, group.start())));
499 }
500 push_apply_family_with_offsets(text, macros, out);
501}
502
503fn fun_ref_re() -> &'static Regex {
504 static R: OnceLock<Regex> = OnceLock::new();
505 R.get_or_init(|| {
506 Regex::new(
507 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_@]*)",
508 )
509 .expect("regex")
510 })
511}
512
513fn push_fun_refs_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(SymbolRef, usize)>) {
517 for caps in fun_ref_re().captures_iter(text) {
518 let module_raw = &caps[1];
519 let function_raw = &caps[2];
520 let arity_raw = &caps[3];
521 let Ok(arity) = arity_raw.parse::<u8>() else {
522 continue;
523 };
524 if !function_raw.starts_with(|c: char| c.is_ascii_lowercase()) {
525 continue;
526 }
527 let module = if let Some(macro_name) = module_raw.strip_prefix('?') {
528 let Some(expanded) = expand_value_macro_to_atom(macros, macro_name) else {
529 continue;
530 };
531 let Ok(m) = ModuleName::from_str(&expanded) else {
532 continue;
533 };
534 m
535 } else if module_raw.starts_with(|c: char| c.is_ascii_lowercase()) {
536 let Ok(m) = ModuleName::from_str(module_raw) else {
537 continue;
538 };
539 m
540 } else {
541 continue;
542 };
543 let Ok(f) = FunctionName::from_str(function_raw) else {
544 continue;
545 };
546 let start = caps.get(0).expect("capture").start();
547 out.push((
548 SymbolRef::function(Mfa::new(module, f, Arity::new(arity))),
549 start,
550 ));
551 }
552}
553
554fn try_resolve_macro_call(name: &str, tail: &str, macros: &MacroTable, out: &mut Vec<SymbolRef>) {
555 let trimmed = tail.trim_start();
556 if let Some(rest) = trimmed.strip_prefix('(') {
557 let Some((m, f)) = expand_value_macro_to_mf(macros, name) else {
558 return;
559 };
560 let (Ok(mn), Ok(fn_)) = (ModuleName::from_str(&m), FunctionName::from_str(&f)) else {
561 return;
562 };
563 out.push(scanned_call(scan_arity(rest), mn, fn_));
564 return;
565 }
566 if let Some(rest) = trimmed.strip_prefix(':') {
567 let rest = rest.trim_start();
568 let (function_str, after_fn) = split_lowercase_ident(rest);
569 if function_str.is_empty() {
570 return;
571 }
572 let after = after_fn.trim_start();
573 if let Some(after_open) = after.strip_prefix('(') {
574 if let Some(m) = expand_value_macro_to_atom(macros, name) {
575 if let (Ok(mn), Ok(fn_)) = (
576 ModuleName::from_str(&m),
577 FunctionName::from_str(function_str),
578 ) {
579 out.push(scanned_call(scan_arity(after_open), mn, fn_));
580 }
581 }
582 }
583 }
584}
585
586fn split_lowercase_ident(s: &str) -> (&str, &str) {
587 let end = s
588 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
589 .unwrap_or(s.len());
590 if end == 0 || !s.starts_with(|c: char| c.is_ascii_lowercase()) {
591 return ("", s);
592 }
593 s.split_at(end)
594}
595
596fn push_apply_family_with_offsets(
597 text: &str,
598 macros: &MacroTable,
599 out: &mut Vec<(SymbolRef, usize)>,
600) {
601 for caps in apply_family_re().captures_iter(text) {
602 let group = caps.get(0).expect("capture");
603 let name = &caps[1];
604 let after_open = &text[group.end()..];
605 let ScannedArgs::Terminated { args, .. } = scan_top_level_args(after_open) else {
607 continue;
608 };
609 if let Some(mfa) = resolve_apply_family(name, &args, macros) {
610 out.push((SymbolRef::function(mfa), group.start()));
611 }
612 }
613}
614
615fn apply_family_re() -> &'static Regex {
616 static R: OnceLock<Regex> = OnceLock::new();
617 R.get_or_init(|| {
618 Regex::new(
619 r"\b(?:erlang\s*:\s*)?(apply|spawn|spawn_link|spawn_monitor|spawn_opt|hibernate)\s*\(",
620 )
621 .expect("regex")
622 })
623}
624
625fn resolve_apply_family(name: &str, args: &[&str], macros: &MacroTable) -> Option<Mfa> {
626 let (m_raw, f_raw, a_raw) = match (name, args.len()) {
627 ("apply", 3)
628 | ("spawn" | "spawn_link" | "spawn_monitor" | "hibernate", 3)
629 | ("spawn_opt", 4) => (args[0], args[1], args[2]),
630 ("spawn" | "spawn_link" | "spawn_monitor", 4) | ("spawn_opt", 5) => {
631 (args[1], args[2], args[3])
632 }
633 _ => return None,
634 };
635 let module = atom_or_macro_to::<ModuleName>(m_raw, macros)?;
636 let function = atom_or_macro_to::<FunctionName>(f_raw, macros)?;
637 let arity = literal_list_length(a_raw)?;
638 Some(Mfa::new(module, function, Arity::new(arity)))
639}
640
641fn atom_or_macro_to<T: FromStr>(raw: &str, macros: &MacroTable) -> Option<T> {
643 let s = raw.trim();
644 if let Some(name) = s.strip_prefix('?') {
645 return expand_value_macro_to_atom(macros, name).and_then(|a| T::from_str(&a).ok());
646 }
647 if looks_like_lowercase_atom(s) {
648 return T::from_str(s).ok();
649 }
650 if s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2 {
651 return T::from_str(&s[1..s.len() - 1]).ok();
652 }
653 None
654}
655
656fn literal_list_length(raw: &str) -> Option<u8> {
657 let s = raw.trim();
658 let inner = s.strip_prefix('[')?.strip_suffix(']')?;
659 let trimmed = inner.trim();
660 if trimmed.is_empty() {
661 return Some(0);
662 }
663 u8::try_from(count_top_level_items(s, '[', ']')).ok()
664}
665
666fn looks_like_lowercase_atom(s: &str) -> bool {
667 let mut chars = s.chars();
668 match chars.next() {
669 Some(c) if c.is_ascii_lowercase() => {}
670 _ => return false,
671 }
672 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
673}
674
675pub fn extract_call_args_into(line: &str, out: &mut Vec<(Mfa, Vec<ArgShape>)>) {
680 let line = strip_line_comment(line);
681 let mut with_offsets = Vec::new();
682 push_call_args_with_offsets(line, &mut with_offsets);
683 out.extend(with_offsets.into_iter().map(|(c, _)| c));
684}
685
686fn push_call_args_with_offsets(text: &str, out: &mut Vec<((Mfa, Vec<ArgShape>), usize)>) {
689 for caps in call_re().captures_iter(text) {
690 let group = caps.get(0).expect("capture");
691 let module = &caps[1];
692 let function = &caps[2];
693 let Some((args, _consumed)) = extract_arg_shapes(&text[group.end()..]) else {
695 continue;
696 };
697 let arity = u8::try_from(args.len()).unwrap_or(u8::MAX);
698 if let (Ok(m), Ok(f)) = (
699 ModuleName::from_str(module),
700 FunctionName::from_str(function),
701 ) {
702 out.push(((Mfa::new(m, f, Arity::new(arity)), args), group.start()));
703 }
704 }
705}
706
707fn extract_arg_shapes(after_open_paren: &str) -> Option<(Vec<ArgShape>, usize)> {
708 match scan_top_level_args(after_open_paren) {
709 ScannedArgs::Terminated { args, consumed } => {
710 Some((args.iter().map(|a| classify_arg(a)).collect(), consumed))
711 }
712 ScannedArgs::Unterminated { .. } => None,
713 }
714}
715
716pub fn classify_arg(raw: &str) -> ArgShape {
717 let s = raw.trim();
718 if s.is_empty() {
719 return ArgShape::Unknown;
720 }
721 let first = s.chars().next().expect("non-empty");
722 if first == '#' {
723 let rest = &s[1..];
724 let name_end = rest
725 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
726 .unwrap_or(rest.len());
727 let name = &rest[..name_end];
728 if let Ok(record_name) = RecordName::from_str(name) {
729 return ArgShape::Record { name: record_name };
730 }
731 return ArgShape::Unknown;
732 }
733 if first == '{' {
734 let size = count_top_level_items(s, '{', '}');
735 return ArgShape::Tuple { size };
736 }
737 if first == '[' {
738 return ArgShape::List;
739 }
740 if first == '"' {
741 return ArgShape::String;
742 }
743 if first == '<' && s.starts_with("<<") {
744 return ArgShape::Binary;
745 }
746 if first == '\'' {
747 let inner = s.trim_start_matches('\'').trim_end_matches('\'');
748 return ArgShape::Atom {
749 name: inner.to_string(),
750 };
751 }
752 if first.is_ascii_digit()
753 || (first == '-' && s.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
754 {
755 return if s.contains('.') {
756 ArgShape::Float
757 } else {
758 ArgShape::Integer
759 };
760 }
761 if first.is_ascii_uppercase() || first == '_' {
762 return ArgShape::Variable;
763 }
764 if first.is_ascii_lowercase() {
765 let bare = is_bare_identifier(s);
766 if bare {
767 return ArgShape::Atom {
768 name: s.to_string(),
769 };
770 }
771 if s.starts_with("fun ") || s == "fun" {
772 return ArgShape::Fun;
773 }
774 return ArgShape::Unknown;
775 }
776 ArgShape::Unknown
777}
778
779fn is_bare_identifier(s: &str) -> bool {
780 s.chars()
781 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
782}
783
784pub fn extract_dynamic_into(line: &str, out: &mut Vec<DynamicCall>) {
785 let line = strip_line_comment(line);
786 for _ in apply_bif_re().captures_iter(line) {
787 out.push(DynamicCall::Apply);
788 }
789 for _ in var_module_call_re().find_iter(line) {
790 out.push(DynamicCall::VariableDispatch);
791 }
792 for _ in var_function_call_re().find_iter(line) {
793 out.push(DynamicCall::VariableDispatch);
794 }
795 for caps in fun_ref_re().captures_iter(line) {
796 let variable_slot = |s: &str| s.starts_with(|c: char| c.is_ascii_uppercase() || c == '_');
797 let arity_raw = &caps[3];
798 let variable_arity = !arity_raw.bytes().all(|b| b.is_ascii_digit());
799 if variable_slot(&caps[1]) || variable_slot(&caps[2]) || variable_arity {
800 out.push(DynamicCall::VariableDispatch);
801 }
802 }
803}
804
805pub fn extract_definitions_into(text: &str, out: &mut Vec<SymbolRef>) {
806 for caps in fun_def_re().captures_iter(text) {
807 let head_end = caps.get(0).expect("capture").end();
808 let after = &text[head_end..];
809 let Ok(f) = FunctionName::from_str(&caps[1]) else {
810 continue;
811 };
812 let module = local_placeholder_module().clone();
813 match scan_arity(after) {
814 ScanArity::Exact(arity) => {
815 out.push(SymbolRef::function(Mfa::new(module, f, Arity::new(arity))));
816 }
817 ScanArity::Unterminated => {
818 out.push(SymbolRef::function_any_arity(module, f));
819 }
820 }
821 }
822}
823
824fn local_placeholder_module() -> &'static ModuleName {
825 static M: OnceLock<ModuleName> = OnceLock::new();
826 M.get_or_init(|| ModuleName::from_str("_local").expect("valid"))
827}
828
829#[derive(Debug, Default)]
832pub struct HunkScan {
833 pub referenced: Vec<SymbolRef>,
834 pub defined: Vec<SymbolRef>,
835 pub dynamic_calls: Vec<DynamicCall>,
836 pub call_args: Vec<(Mfa, Vec<ArgShape>)>,
837}
838
839pub fn scan_hunk(lines: &[(RefOrigin, &str)], macros: &MacroTable) -> HunkScan {
847 let mut out = HunkScan::default();
848 let mut scanner = AttrCtxScanner::new();
849 let mut text = String::new();
850 let mut line_origins: Vec<(usize, RefOrigin)> = Vec::new();
851 let mut run_ctx: Option<bool> = None;
854 for &(origin, raw) in lines {
855 let stripped = strip_line_comment(raw);
856 let ctx = scanner.classify(stripped);
857 if ctx == RefContext::AttributeString {
861 if let Some(prev) = run_ctx.take() {
862 flush_run(prev, &text, &line_origins, macros, &mut out);
863 text.clear();
864 line_origins.clear();
865 }
866 continue;
867 }
868 let type_attr = ctx == RefContext::TypeAttribute;
869 if run_ctx != Some(type_attr) {
870 if let Some(prev) = run_ctx {
871 flush_run(prev, &text, &line_origins, macros, &mut out);
872 }
873 text.clear();
874 line_origins.clear();
875 run_ctx = Some(type_attr);
876 }
877 line_origins.push((text.len(), origin));
878 text.push_str(stripped);
879 text.push('\n');
880 }
881 if let Some(prev) = run_ctx {
882 flush_run(prev, &text, &line_origins, macros, &mut out);
883 }
884 out
885}
886
887fn flush_run(
888 type_attr: bool,
889 text: &str,
890 line_origins: &[(usize, RefOrigin)],
891 macros: &MacroTable,
892 out: &mut HunkScan,
893) {
894 if type_attr {
895 let mut refs = Vec::new();
896 push_type_refs_with_offsets(text, &mut refs);
897 tag_by_origin(refs, line_origins, &mut out.referenced);
898 return;
899 }
900 let mut refs = Vec::new();
901 push_refs_with_offsets(text, macros, &mut refs);
902 tag_by_origin(refs, line_origins, &mut out.referenced);
903 extract_definitions_into(text, &mut out.defined);
904 extract_dynamic_into(text, &mut out.dynamic_calls);
905 let mut args = Vec::new();
906 push_call_args_with_offsets(text, &mut args);
907 for (call, offset) in args {
909 if origin_at(line_origins, offset) == RefOrigin::Added {
910 out.call_args.push(call);
911 }
912 }
913}
914
915fn tag_by_origin(
916 refs: Vec<(SymbolRef, usize)>,
917 line_origins: &[(usize, RefOrigin)],
918 out: &mut Vec<SymbolRef>,
919) {
920 for (r, offset) in refs {
921 out.push(r.with_origin(origin_at(line_origins, offset)));
922 }
923}
924
925fn origin_at(line_origins: &[(usize, RefOrigin)], offset: usize) -> RefOrigin {
928 value_at_or_before(line_origins, offset, RefOrigin::Context)
929}