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