1use 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#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct BehaviourRef {
46 pub behaviour: ModuleName,
47 pub line: u32,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct IncludeRef {
54 pub directive: IncludeDirective,
55 pub line: u32,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ScannedUse {
61 pub name: String,
62 pub line: u32,
63}
64
65pub 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
83pub 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
101pub 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
117pub 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 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
145pub 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
153fn 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#[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
214pub fn extract_function_signatures(src: &str) -> Vec<FunctionSignature> {
220 signatures_with_line_context(src, None)
221}
222
223pub 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 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 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 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
320fn 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
329fn starts_line(bytes: &[u8], start: usize) -> bool {
331 start == 0 || bytes[start - 1] == b'\n'
332}
333
334fn 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
348pub struct ImportedFunction {
349 pub module: ModuleName,
350 pub function: FunctionName,
351 pub arity: Arity,
352}
353
354pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum Unreadable {
397 ExportAll,
400 ParseTransform,
403 MacroInExportList,
405}
406
407impl Unreadable {
408 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum Presence {
421 Present,
422 Absent,
423 Unknowable(Unreadable),
426}
427
428#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct Surface<T> {
434 listed: BTreeSet<T>,
435 unreadable: Option<Unreadable>,
436}
437
438impl<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 #[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 #[must_use]
489 pub fn listed(&self) -> &BTreeSet<T> {
490 &self.listed
491 }
492
493 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
506pub type ExportSurface = Surface<(FunctionName, Arity)>;
508
509pub 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#[derive(Debug, Clone, PartialEq, Eq)]
546pub struct ExportedType {
547 pub name: TypeName,
548 pub arity: Arity,
549 pub line: u32,
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub enum TypesUnreadable {
556 ParseTransform,
557 MacroInExportList,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq)]
564pub enum ExportedTypes {
565 Listed(Vec<ExportedType>),
566 Unreadable(TypesUnreadable),
567}
568
569pub 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
607pub 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
638pub 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
683fn 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
705pub type SpecTable = BTreeMap<(FunctionName, Arity), String>;
707
708pub fn extract_specs(src: &str) -> SpecTable {
715 extract_signature_forms(src, b"spec")
716}
717
718pub fn extract_callbacks(src: &str) -> SpecTable {
722 extract_signature_forms(src, b"callback")
723}
724
725pub 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
779fn 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
789fn 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
799fn 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
821struct AttributeList {
825 head: String,
826 entries: Vec<ListEntry>,
827}
828
829enum ListEntry {
831 NameArity(String, usize),
832 Macro,
833 Unparsed,
834}
835
836impl AttributeList {
837 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 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
911fn 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#[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
966pub 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
980pub 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
996pub 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
1006pub 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
1027fn 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
1046fn 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
1103fn 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}