1use crate::ast::{Node, NodeKind};
22use perl_semantic_facts::{
23 AnchorId, Confidence, FileId, ImportKind, ImportSpec, ImportSymbols, Provenance,
24};
25
26pub struct ImportExtractor;
29
30impl ImportExtractor {
31 pub fn extract(ast: &Node, file_id: FileId) -> Vec<ImportSpec> {
37 let mut specs = Vec::new();
38 Self::walk(ast, file_id, &mut specs);
39 specs
40 }
41
42 fn walk(node: &Node, file_id: FileId, out: &mut Vec<ImportSpec>) {
45 if let NodeKind::Use { module, args, .. } = &node.kind {
47 if let Some(spec) = Self::classify_use(module, args, file_id, node) {
48 out.push(spec);
49 }
50 }
51
52 if let Some(spec) = Self::try_classify_standalone_class_import(node, file_id) {
63 out.push(spec);
64 }
65
66 match &node.kind {
70 NodeKind::Program { statements } | NodeKind::Block { statements } => {
71 Self::walk_statements(statements, file_id, out);
72 }
73 NodeKind::Package { block: Some(block), .. } => {
74 if let NodeKind::Block { statements } = &block.kind {
75 Self::walk_statements(statements, file_id, out);
76 }
77 }
78 _ => {}
79 }
80
81 for child in node.children() {
82 Self::walk(child, file_id, out);
83 }
84 }
85
86 fn try_classify_standalone_class_import(node: &Node, file_id: FileId) -> Option<ImportSpec> {
101 let (object, method, args) = match &node.kind {
102 NodeKind::MethodCall { object, method, args } => (object, method, args),
103 _ => return None,
104 };
105
106 if method != "import" {
107 return None;
108 }
109
110 let class_name = match &object.kind {
112 NodeKind::Identifier { name } => name.as_str(),
113 _ => return None,
114 };
115
116 let symbols = Self::extract_import_call_symbols(args);
118
119 if !matches!(symbols, ImportSymbols::Dynamic) {
123 return None;
124 }
125
126 let anchor_id = Self::anchor_from_node(node);
127 Some(ImportSpec {
128 module: class_name.to_string(),
129 kind: ImportKind::ManualImport,
132 symbols,
133 provenance: Provenance::DynamicBoundary,
134 confidence: Confidence::Low,
135 file_id: Some(file_id),
136 anchor_id: Some(anchor_id),
137 scope_id: None,
138 span_start_byte: Some(node.location.start as u32),
139 })
140 }
141
142 fn walk_statements(statements: &[Node], file_id: FileId, out: &mut Vec<ImportSpec>) {
152 let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
155
156 for (i, stmt) in statements.iter().enumerate() {
157 if consumed.contains(&i) {
158 continue;
159 }
160
161 let expr = Self::unwrap_expression_statement(stmt);
163
164 let (require_node, require_args) = match &expr.kind {
166 NodeKind::FunctionCall { name, args } if name == "require" => (stmt, args),
167 _ => continue,
168 };
169
170 if Self::is_dynamic_require(require_args) {
172 out.push(Self::make_dynamic_require(file_id, require_node));
173 consumed.insert(i);
174 continue;
175 }
176
177 let module_name = match Self::extract_require_module_name(require_args) {
179 Some(name) => name,
180 None => continue,
181 };
182
183 let import_spec = if let Some(next_stmt) = statements.get(i + 1) {
185 let next_expr = Self::unwrap_expression_statement(next_stmt);
186 Self::try_match_import_call(next_expr, &module_name)
187 } else {
188 None
189 };
190
191 if let Some((symbols, import_node)) = import_spec {
192 let provenance = if matches!(symbols, ImportSymbols::Dynamic) {
201 Provenance::ExactAst
202 } else {
203 Provenance::LiteralRequireImport
204 };
205 let anchor_id = Self::anchor_from_node(require_node);
206 let confidence = Self::confidence_for_symbols(&symbols);
207 out.push(ImportSpec {
208 module: module_name,
209 kind: ImportKind::RequireThenImport,
210 symbols,
211 provenance,
212 confidence,
213 file_id: Some(file_id),
214 anchor_id: Some(anchor_id),
215 scope_id: None,
216 span_start_byte: Some(require_node.location.start as u32),
217 });
218 consumed.insert(i);
219 consumed.insert(i + 1);
220 let _ = import_node;
223 } else {
224 let anchor_id = Self::anchor_from_node(require_node);
226 out.push(ImportSpec {
227 module: module_name,
228 kind: ImportKind::Require,
229 symbols: ImportSymbols::Default,
230 provenance: Provenance::ExactAst,
231 confidence: Confidence::High,
232 file_id: Some(file_id),
233 anchor_id: Some(anchor_id),
234 scope_id: None,
235 span_start_byte: Some(require_node.location.start as u32),
236 });
237 consumed.insert(i);
238 }
239 }
240 }
241
242 fn unwrap_expression_statement(node: &Node) -> &Node {
247 match &node.kind {
248 NodeKind::ExpressionStatement { expression } => expression,
249 _ => node,
250 }
251 }
252
253 fn is_dynamic_require(args: &[Node]) -> bool {
256 match args.first() {
257 Some(arg) => matches!(&arg.kind, NodeKind::Variable { .. }),
258 None => false,
259 }
260 }
261
262 fn extract_require_module_name(args: &[Node]) -> Option<String> {
268 let arg = args.first()?;
269 match &arg.kind {
270 NodeKind::Identifier { name } => Some(name.clone()),
271 NodeKind::String { value, .. } => {
272 let cleaned = value.trim_matches('\'').trim_matches('"').trim();
274 let module = cleaned.trim_end_matches(".pm").replace('/', "::");
275 Some(module)
276 }
277 _ => None,
278 }
279 }
280
281 fn make_dynamic_require(file_id: FileId, node: &Node) -> ImportSpec {
289 let anchor_id = Self::anchor_from_node(node);
290 ImportSpec {
291 module: String::new(),
292 kind: ImportKind::DynamicRequire,
293 symbols: ImportSymbols::Dynamic,
294 provenance: Provenance::DynamicBoundary,
295 confidence: Confidence::Low,
296 file_id: Some(file_id),
297 anchor_id: Some(anchor_id),
298 scope_id: None,
299 span_start_byte: Some(node.location.start as u32),
300 }
301 }
302
303 fn try_match_import_call<'a>(
308 node: &'a Node,
309 expected_module: &str,
310 ) -> Option<(ImportSymbols, &'a Node)> {
311 let (object, method, args) = match &node.kind {
312 NodeKind::MethodCall { object, method, args } => (object, method, args),
313 _ => return None,
314 };
315
316 if method != "import" {
317 return None;
318 }
319
320 let obj_name = match &object.kind {
322 NodeKind::Identifier { name } => name.as_str(),
323 _ => return None,
324 };
325
326 if obj_name != expected_module {
327 return None;
328 }
329
330 let symbols = Self::extract_import_call_symbols(args);
332 Some((symbols, node))
333 }
334
335 fn extract_import_call_symbols(args: &[Node]) -> ImportSymbols {
337 if args.is_empty() {
338 return ImportSymbols::Default;
339 }
340
341 let mut names: Vec<String> = Vec::new();
342 let mut tags: Vec<String> = Vec::new();
343 let mut has_dynamic_arg = false;
344
345 for arg in args {
346 has_dynamic_arg |= Self::collect_import_arg_symbols(arg, &mut names, &mut tags);
347 }
348
349 if has_dynamic_arg {
350 return ImportSymbols::Dynamic;
351 }
352
353 if names.is_empty() && tags.is_empty() {
354 return ImportSymbols::Default;
355 }
356
357 if !tags.is_empty() && names.is_empty() {
358 return ImportSymbols::Tags(tags);
359 }
360
361 if !tags.is_empty() && !names.is_empty() {
362 return ImportSymbols::Mixed { tags, names };
363 }
364
365 ImportSymbols::Explicit(names)
366 }
367
368 fn collect_import_arg_symbols(
374 arg: &Node,
375 names: &mut Vec<String>,
376 tags: &mut Vec<String>,
377 ) -> bool {
378 match &arg.kind {
379 NodeKind::String { value, .. } => {
380 let bare = value.trim_matches('\'').trim_matches('"');
381 if let Some(tag) = bare.strip_prefix(':') {
382 tags.push(tag.to_string());
383 } else if !bare.is_empty() {
384 names.push(bare.to_string());
385 }
386 false
387 }
388 NodeKind::Identifier { name } => {
389 if let Some(inner) = Self::parse_qw_content(name) {
391 for word in inner.split_whitespace() {
392 if let Some(tag) = word.strip_prefix(':') {
393 tags.push(tag.to_string());
394 } else {
395 names.push(word.to_string());
396 }
397 }
398 } else if let Some(tag) = name.strip_prefix(':') {
399 tags.push(tag.to_string());
400 } else if !name.is_empty() {
401 names.push(name.clone());
402 }
403 false
404 }
405 NodeKind::Variable { .. } => {
406 true
409 }
410 NodeKind::ArrayLiteral { elements } => {
411 let mut has_dynamic_arg = false;
413 for el in elements {
414 has_dynamic_arg |= Self::collect_import_arg_symbols(el, names, tags);
415 }
416 has_dynamic_arg
417 }
418 _ => true,
419 }
420 }
421
422 fn confidence_for_symbols(symbols: &ImportSymbols) -> Confidence {
423 if matches!(symbols, ImportSymbols::Dynamic) { Confidence::Low } else { Confidence::High }
424 }
425
426 fn classify_use(
433 module: &str,
434 args: &[String],
435 file_id: FileId,
436 node: &Node,
437 ) -> Option<ImportSpec> {
438 if Self::is_version_pragma(module) {
440 return None;
441 }
442
443 let anchor_id = Self::anchor_from_node(node);
444
445 if module == "constant" {
447 return Some(Self::classify_use_constant(args, file_id, anchor_id));
448 }
449
450 let (kind, symbols) = Self::classify_args(args, module, node);
452
453 Some(ImportSpec {
454 module: module.to_string(),
455 kind,
456 symbols,
457 provenance: Provenance::ExactAst,
458 confidence: Confidence::High,
459 file_id: Some(file_id),
460 anchor_id: Some(anchor_id),
461 scope_id: None,
462 span_start_byte: Some(node.location.start as u32),
463 })
464 }
465
466 fn classify_args(args: &[String], module: &str, node: &Node) -> (ImportKind, ImportSymbols) {
468 if args.is_empty() {
469 let bare_len = "use ".len() + module.len() + 1; let span_len = node.location.end.saturating_sub(node.location.start);
476 if span_len > bare_len {
477 return (ImportKind::UseEmpty, ImportSymbols::None);
480 }
481 return (ImportKind::Use, ImportSymbols::Default);
483 }
484
485 let mut explicit_names: Vec<String> = Vec::new();
487 let mut tags: Vec<String> = Vec::new();
488
489 for arg in args {
490 let trimmed = arg.trim();
491
492 if let Some(inner) = Self::parse_qw_content(trimmed) {
494 let words: Vec<String> = inner.split_whitespace().map(|w| w.to_string()).collect();
495 for word in words {
496 if let Some(tag) = word.strip_prefix(':') {
497 tags.push(tag.to_string());
498 } else {
499 explicit_names.push(word);
500 }
501 }
502 continue;
503 }
504
505 let unquoted = Self::unquote(trimmed);
507 if let Some(tag) = unquoted.strip_prefix(':') {
508 tags.push(tag.to_string());
509 continue;
510 }
511
512 if trimmed == "=>" || trimmed == "," || trimmed == "\\" {
515 continue;
516 }
517
518 if Self::looks_like_symbol_name(trimmed) {
520 explicit_names.push(Self::unquote(trimmed).to_string());
521 }
522 }
523
524 if explicit_names.is_empty() && tags.is_empty() && !args.is_empty() {
526 let has_any_symbol = args.iter().any(|a| {
530 let t = a.trim();
531 Self::looks_like_symbol_name(t) || Self::parse_qw_content(t).is_some()
532 });
533 if !has_any_symbol {
534 return (ImportKind::UseEmpty, ImportSymbols::None);
535 }
536 }
537
538 if !tags.is_empty() && explicit_names.is_empty() {
540 return (ImportKind::UseTag, ImportSymbols::Tags(tags));
541 }
542
543 if !tags.is_empty() && !explicit_names.is_empty() {
545 return (
546 ImportKind::UseExplicitList,
547 ImportSymbols::Mixed { tags, names: explicit_names },
548 );
549 }
550
551 if !explicit_names.is_empty() {
553 return (ImportKind::UseExplicitList, ImportSymbols::Explicit(explicit_names));
554 }
555
556 (ImportKind::Use, ImportSymbols::Default)
558 }
559
560 fn classify_use_constant(args: &[String], file_id: FileId, anchor_id: AnchorId) -> ImportSpec {
562 let mut constant_names: Vec<String> = Vec::new();
563
564 if args.is_empty() {
565 return ImportSpec {
567 module: "constant".to_string(),
568 kind: ImportKind::UseConstant,
569 symbols: ImportSymbols::None,
570 provenance: Provenance::ExactAst,
571 confidence: Confidence::High,
572 file_id: Some(file_id),
573 anchor_id: Some(anchor_id),
574 scope_id: None,
575 span_start_byte: None, };
577 }
578
579 let starts_hash_form = args.first().map(|a| a.as_str()) == Some("{")
582 || args.first().map(|a| a.as_str()) == Some("+{")
583 || (args.first().map(|a| a.as_str()) == Some("+")
584 && args.get(1).map(|a| a.as_str()) == Some("{"));
585 if starts_hash_form {
586 let mut i = 0;
587 while i < args.len() {
588 let token = args[i].trim();
589 if Self::is_constant_hash_punctuation(token) {
590 i += 1;
591 continue;
592 }
593 if i + 1 < args.len()
594 && args[i + 1].trim() == "=>"
595 && let Some(name) = Self::constant_name_candidate(token)
596 {
597 constant_names.push(name);
598 i += 2;
599 let mut nesting = 0usize;
600 while i < args.len() {
601 let value_token = args[i].trim();
602 if nesting == 0 && (value_token == "," || value_token == "}") {
603 break;
604 }
605 match value_token {
606 "{" | "[" | "(" => nesting += 1,
607 "}" | "]" | ")" if nesting > 0 => nesting -= 1,
608 "}" if nesting == 0 => break,
609 _ => {}
610 }
611 i += 1;
612 }
613 } else {
614 i += 1;
615 }
616 }
617 }
618 else if let Some(inner) = args.first().and_then(|a| Self::parse_qw_content(a.trim())) {
620 let words: Vec<String> = inner.split_whitespace().map(|w| w.to_string()).collect();
621 constant_names.extend(words);
622 }
623 else if let Some(name) = args.first() {
626 let trimmed = name.trim();
627 if let Some(name) = Self::constant_name_candidate(trimmed) {
628 constant_names.push(name);
629 }
630 }
631
632 let mut seen = std::collections::HashSet::new();
634 constant_names.retain(|n| seen.insert(n.clone()));
635
636 let symbols = if constant_names.is_empty() {
637 ImportSymbols::None
638 } else {
639 ImportSymbols::Explicit(constant_names)
640 };
641
642 ImportSpec {
643 module: "constant".to_string(),
644 kind: ImportKind::UseConstant,
645 symbols,
646 provenance: Provenance::ExactAst,
647 confidence: Confidence::High,
648 file_id: Some(file_id),
649 anchor_id: Some(anchor_id),
650 scope_id: None,
651 span_start_byte: None, }
653 }
654
655 fn anchor_from_node(node: &Node) -> AnchorId {
659 AnchorId(node.location.start as u64)
662 }
663
664 fn is_version_pragma(module: &str) -> bool {
666 if module.chars().next().is_some_and(|c| c.is_ascii_digit()) {
668 return true;
669 }
670 if module.starts_with('v')
672 && module.len() > 1
673 && module[1..].chars().all(|c| c.is_ascii_digit() || c == '.')
674 {
675 return true;
676 }
677 false
678 }
679
680 fn parse_qw_content(s: &str) -> Option<&str> {
689 Self::parse_quote_operator_content(s, "qw")
690 }
691
692 fn parse_quote_operator_content<'a>(s: &'a str, operator: &str) -> Option<&'a str> {
697 perl_parser_core::parse_quote_operator_content(s, operator)
698 }
699
700 fn unquote(s: &str) -> &str {
702 if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
703 if s.len() >= 2 {
704 return &s[1..s.len() - 1];
705 }
706 }
707 s
708 }
709
710 fn looks_like_symbol_name(s: &str) -> bool {
712 let s = Self::unquote(s);
713 if s.is_empty() {
714 return false;
715 }
716 if s.starts_with(':') {
718 return true;
719 }
720 if s.starts_with('$')
722 || s.starts_with('@')
723 || s.starts_with('%')
724 || s.starts_with('&')
725 || s.starts_with('*')
726 {
727 return true;
728 }
729 s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
731 }
732
733 fn looks_like_constant_name(s: &str) -> bool {
737 if s.is_empty() {
738 return false;
739 }
740 s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
741 }
742
743 fn constant_name_candidate(s: &str) -> Option<String> {
744 let name = Self::unquote(s.trim());
745 Self::looks_like_constant_name(name).then(|| name.to_string())
746 }
747
748 fn is_constant_hash_punctuation(s: &str) -> bool {
749 matches!(s, "+" | "+{" | "{" | "}" | "=>" | ",")
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use crate::Parser;
757
758 fn parse_and_extract(code: &str) -> Vec<ImportSpec> {
760 let mut parser = Parser::new(code);
761 let ast = match parser.parse() {
762 Ok(ast) => ast,
763 Err(_) => return Vec::new(),
764 };
765 ImportExtractor::extract(&ast, FileId(1))
766 }
767
768 #[test]
771 fn test_use_explicit_list_qw() -> Result<(), String> {
772 let specs = parse_and_extract("use List::Util qw(first reduce any);");
773 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
774
775 assert_eq!(spec.module, "List::Util");
776 assert_eq!(spec.kind, ImportKind::UseExplicitList);
777 if let ImportSymbols::Explicit(names) = &spec.symbols {
778 assert!(names.contains(&"first".to_string()), "missing 'first' in {names:?}");
779 assert!(names.contains(&"reduce".to_string()), "missing 'reduce' in {names:?}");
780 assert!(names.contains(&"any".to_string()), "missing 'any' in {names:?}");
781 } else {
782 return Err(format!("expected Explicit, got {:?}", spec.symbols));
783 }
784 assert_eq!(spec.file_id, Some(FileId(1)));
785 assert!(spec.anchor_id.is_some());
786 Ok(())
787 }
788
789 #[test]
790 fn test_use_explicit_list_quoted_strings() -> Result<(), String> {
791 let specs = parse_and_extract("use Exporter 'import';");
792 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
793
794 assert_eq!(spec.module, "Exporter");
795 assert_eq!(spec.kind, ImportKind::UseExplicitList);
796 if let ImportSymbols::Explicit(names) = &spec.symbols {
797 assert!(names.contains(&"import".to_string()), "missing 'import' in {names:?}");
798 } else {
799 return Err(format!("expected Explicit, got {:?}", spec.symbols));
800 }
801 Ok(())
802 }
803
804 #[test]
812 fn test_use_empty_parens() -> Result<(), String> {
813 let specs = parse_and_extract("use POSIX ();");
814 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
815
816 assert_eq!(spec.module, "POSIX");
817 assert_eq!(spec.kind, ImportKind::UseEmpty);
821 assert_eq!(spec.symbols, ImportSymbols::None);
822 Ok(())
823 }
824
825 #[test]
828 fn test_use_tag_single() -> Result<(), String> {
829 let specs = parse_and_extract("use POSIX ':sys_wait_h';");
830 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
831
832 assert_eq!(spec.module, "POSIX");
833 assert_eq!(spec.kind, ImportKind::UseTag);
834 if let ImportSymbols::Tags(tags) = &spec.symbols {
835 assert!(tags.contains(&"sys_wait_h".to_string()), "missing tag in {tags:?}");
836 } else {
837 return Err(format!("expected Tags, got {:?}", spec.symbols));
838 }
839 Ok(())
840 }
841
842 #[test]
843 fn test_use_tag_in_qw() -> Result<(), String> {
844 let specs = parse_and_extract("use Fcntl qw(:flock);");
845 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
846
847 assert_eq!(spec.module, "Fcntl");
848 assert_eq!(spec.kind, ImportKind::UseTag);
849 if let ImportSymbols::Tags(tags) = &spec.symbols {
850 assert!(tags.contains(&"flock".to_string()), "missing tag in {tags:?}");
851 } else {
852 return Err(format!("expected Tags, got {:?}", spec.symbols));
853 }
854 Ok(())
855 }
856
857 #[test]
860 fn test_use_bare() -> Result<(), String> {
861 let specs = parse_and_extract("use strict;");
862 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
863
864 assert_eq!(spec.module, "strict");
865 assert_eq!(spec.kind, ImportKind::Use);
866 assert_eq!(spec.symbols, ImportSymbols::Default);
867 Ok(())
868 }
869
870 #[test]
871 fn test_use_bare_qualified() -> Result<(), String> {
872 let specs = parse_and_extract("use Data::Dumper;");
873 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
874
875 assert_eq!(spec.module, "Data::Dumper");
876 assert_eq!(spec.kind, ImportKind::Use);
877 assert_eq!(spec.symbols, ImportSymbols::Default);
878 Ok(())
879 }
880
881 #[test]
884 fn test_use_constant_scalar() -> Result<(), String> {
885 let specs = parse_and_extract("use constant PI => 3.14;");
886 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
887
888 assert_eq!(spec.module, "constant");
889 assert_eq!(spec.kind, ImportKind::UseConstant);
890 if let ImportSymbols::Explicit(names) = &spec.symbols {
891 assert!(names.contains(&"PI".to_string()), "missing 'PI' in {names:?}");
892 } else {
893 return Err(format!("expected Explicit, got {:?}", spec.symbols));
894 }
895 Ok(())
896 }
897
898 #[test]
899 fn test_use_constant_hash_ref() -> Result<(), String> {
900 let specs = parse_and_extract("use constant { FOO => 1, BAR => 2 };");
901 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
902
903 assert_eq!(spec.module, "constant");
904 assert_eq!(spec.kind, ImportKind::UseConstant);
905 if let ImportSymbols::Explicit(names) = &spec.symbols {
906 assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
907 assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
908 } else {
909 return Err(format!("expected Explicit, got {:?}", spec.symbols));
910 }
911 Ok(())
912 }
913
914 #[test]
915 fn test_use_constant_quoted_scalar() -> Result<(), String> {
916 let specs = parse_and_extract("use constant 'HTTP_OK' => 200;");
917 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
918
919 assert_eq!(spec.module, "constant");
920 assert_eq!(spec.kind, ImportKind::UseConstant);
921 if let ImportSymbols::Explicit(names) = &spec.symbols {
922 assert!(names.contains(&"HTTP_OK".to_string()), "missing 'HTTP_OK' in {names:?}");
923 } else {
924 return Err(format!("expected Explicit, got {:?}", spec.symbols));
925 }
926 Ok(())
927 }
928
929 #[test]
930 fn test_use_constant_quoted_hash_ref() -> Result<(), String> {
931 let specs = parse_and_extract(r#"use constant { 'FOO' => 1, "BAR" => 2 };"#);
932 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
933
934 assert_eq!(spec.module, "constant");
935 assert_eq!(spec.kind, ImportKind::UseConstant);
936 if let ImportSymbols::Explicit(names) = &spec.symbols {
937 assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
938 assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
939 } else {
940 return Err(format!("expected Explicit, got {:?}", spec.symbols));
941 }
942 Ok(())
943 }
944
945 #[test]
946 fn test_use_constant_plus_hash_ref() -> Result<(), String> {
947 let specs = parse_and_extract("use constant +{ FOO => 1, BAR => 2 };");
948 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
949
950 assert_eq!(spec.module, "constant");
951 assert_eq!(spec.kind, ImportKind::UseConstant);
952 if let ImportSymbols::Explicit(names) = &spec.symbols {
953 assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
954 assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
955 } else {
956 return Err(format!("expected Explicit, got {:?}", spec.symbols));
957 }
958 Ok(())
959 }
960
961 #[test]
962 fn test_use_constant_hash_ref_ignores_nested_fat_comma_values() -> Result<(), String> {
963 let specs = parse_and_extract("use constant { FOO => { nested => 1 }, BAR => 2 };");
964 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
965
966 assert_eq!(spec.module, "constant");
967 assert_eq!(spec.kind, ImportKind::UseConstant);
968 if let ImportSymbols::Explicit(names) = &spec.symbols {
969 assert_eq!(names, &vec!["FOO".to_string(), "BAR".to_string()]);
970 } else {
971 return Err(format!("expected Explicit, got {:?}", spec.symbols));
972 }
973 Ok(())
974 }
975
976 #[test]
977 fn test_use_constant_empty() -> Result<(), String> {
978 let specs = parse_and_extract("use constant;");
979 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
980
981 assert_eq!(spec.module, "constant");
982 assert_eq!(spec.kind, ImportKind::UseConstant);
983 assert_eq!(spec.symbols, ImportSymbols::None);
984 Ok(())
985 }
986
987 #[test]
990 fn test_version_pragma_skipped() -> Result<(), String> {
991 let specs = parse_and_extract("use 5.036;");
992 assert!(specs.is_empty(), "version pragma should not produce ImportSpec");
993 Ok(())
994 }
995
996 #[test]
997 fn test_vstring_pragma_skipped() -> Result<(), String> {
998 let specs = parse_and_extract("use v5.38;");
999 assert!(specs.is_empty(), "v-string pragma should not produce ImportSpec");
1000 Ok(())
1001 }
1002
1003 #[test]
1006 fn test_multiple_use_statements() -> Result<(), String> {
1007 let code = r#"
1008use strict;
1009use warnings;
1010use List::Util qw(first any);
1011use POSIX ();
1012use constant MAX => 100;
1013"#;
1014 let specs = parse_and_extract(code);
1015 assert_eq!(specs.len(), 5, "expected 5 ImportSpecs, got {}", specs.len());
1016
1017 assert_eq!(specs[0].module, "strict");
1019 assert_eq!(specs[0].kind, ImportKind::Use);
1020
1021 assert_eq!(specs[1].module, "warnings");
1023 assert_eq!(specs[1].kind, ImportKind::Use);
1024
1025 assert_eq!(specs[2].module, "List::Util");
1027 assert_eq!(specs[2].kind, ImportKind::UseExplicitList);
1028
1029 assert_eq!(specs[3].module, "POSIX");
1031 assert_eq!(specs[3].kind, ImportKind::UseEmpty);
1032
1033 assert_eq!(specs[4].module, "constant");
1035 assert_eq!(specs[4].kind, ImportKind::UseConstant);
1036
1037 Ok(())
1038 }
1039
1040 #[test]
1043 fn test_anchor_and_file_id_populated() -> Result<(), String> {
1044 let specs = parse_and_extract("use Foo::Bar qw(baz);");
1045 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
1046
1047 assert_eq!(spec.file_id, Some(FileId(1)));
1048 assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1049 assert_eq!(spec.provenance, Provenance::ExactAst);
1050 assert_eq!(spec.confidence, Confidence::High);
1051 Ok(())
1052 }
1053
1054 #[test]
1057 fn test_use_inside_package_block() -> Result<(), String> {
1058 let code = r#"
1059package MyModule;
1060use Exporter 'import';
1061our @EXPORT = qw(foo);
10621;
1063"#;
1064 let specs = parse_and_extract(code);
1065 let exporter_spec =
1066 specs.iter().find(|s| s.module == "Exporter").ok_or("expected Exporter ImportSpec")?;
1067
1068 assert_eq!(exporter_spec.kind, ImportKind::UseExplicitList);
1069 if let ImportSymbols::Explicit(names) = &exporter_spec.symbols {
1070 assert!(names.contains(&"import".to_string()));
1071 } else {
1072 return Err(format!("expected Explicit, got {:?}", exporter_spec.symbols));
1073 }
1074 Ok(())
1075 }
1076
1077 #[test]
1080 fn test_use_mixed_tags_and_names() -> Result<(), String> {
1081 let specs = parse_and_extract("use Fcntl qw(:flock LOCK_EX LOCK_NB);");
1082 let spec = specs.first().ok_or("expected at least one ImportSpec")?;
1083
1084 assert_eq!(spec.module, "Fcntl");
1085 assert_eq!(spec.kind, ImportKind::UseExplicitList);
1086 if let ImportSymbols::Mixed { tags, names } = &spec.symbols {
1087 assert!(tags.contains(&"flock".to_string()), "missing tag 'flock' in {tags:?}");
1088 assert!(names.contains(&"LOCK_EX".to_string()), "missing 'LOCK_EX' in {names:?}");
1089 assert!(names.contains(&"LOCK_NB".to_string()), "missing 'LOCK_NB' in {names:?}");
1090 } else {
1091 return Err(format!("expected Mixed, got {:?}", spec.symbols));
1092 }
1093 Ok(())
1094 }
1095
1096 #[test]
1099 fn test_require_bare_module() -> Result<(), String> {
1100 let specs = parse_and_extract("require Foo::Bar;");
1101 let spec = specs
1102 .iter()
1103 .find(|s| s.module == "Foo::Bar")
1104 .ok_or("expected ImportSpec for Foo::Bar")?;
1105
1106 assert_eq!(spec.kind, ImportKind::Require);
1107 assert_eq!(spec.symbols, ImportSymbols::Default);
1108 assert_eq!(spec.provenance, Provenance::ExactAst);
1109 assert_eq!(spec.confidence, Confidence::High);
1110 assert_eq!(spec.file_id, Some(FileId(1)));
1111 assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1112 Ok(())
1113 }
1114
1115 #[test]
1118 fn test_require_then_import_with_qw() -> Result<(), String> {
1119 let code = r#"
1120require Foo::Bar;
1121Foo::Bar->import(qw(alpha beta));
1122"#;
1123 let specs = parse_and_extract(code);
1124 let spec = specs
1125 .iter()
1126 .find(|s| s.module == "Foo::Bar")
1127 .ok_or("expected ImportSpec for Foo::Bar")?;
1128
1129 assert_eq!(spec.kind, ImportKind::RequireThenImport);
1130 if let ImportSymbols::Explicit(names) = &spec.symbols {
1131 assert!(names.contains(&"alpha".to_string()), "missing 'alpha' in {names:?}");
1132 assert!(names.contains(&"beta".to_string()), "missing 'beta' in {names:?}");
1133 } else {
1134 return Err(format!("expected Explicit, got {:?}", spec.symbols));
1135 }
1136 assert_eq!(spec.provenance, Provenance::LiteralRequireImport);
1138 assert_eq!(spec.confidence, Confidence::High);
1139 Ok(())
1140 }
1141
1142 #[test]
1143 fn test_require_then_import_bare() -> Result<(), String> {
1144 let code = r#"
1145require Some::Module;
1146Some::Module->import();
1147"#;
1148 let specs = parse_and_extract(code);
1149 let spec = specs
1150 .iter()
1151 .find(|s| s.module == "Some::Module")
1152 .ok_or("expected ImportSpec for Some::Module")?;
1153
1154 assert_eq!(spec.kind, ImportKind::RequireThenImport);
1155 assert_eq!(spec.symbols, ImportSymbols::Default);
1156 Ok(())
1157 }
1158
1159 #[test]
1160 fn test_require_then_import_quoted_strings() -> Result<(), String> {
1161 let code = r#"
1162require Foo::Bar;
1163Foo::Bar->import('alpha', 'beta');
1164"#;
1165 let specs = parse_and_extract(code);
1166 let spec = specs
1167 .iter()
1168 .find(|s| s.module == "Foo::Bar")
1169 .ok_or("expected ImportSpec for Foo::Bar")?;
1170
1171 assert_eq!(spec.kind, ImportKind::RequireThenImport);
1172 if let ImportSymbols::Explicit(names) = &spec.symbols {
1173 assert!(names.contains(&"alpha".to_string()), "missing 'alpha' in {names:?}");
1174 assert!(names.contains(&"beta".to_string()), "missing 'beta' in {names:?}");
1175 } else {
1176 return Err(format!("expected Explicit, got {:?}", spec.symbols));
1177 }
1178 assert_eq!(spec.provenance, Provenance::LiteralRequireImport);
1180 assert_eq!(spec.confidence, Confidence::High);
1181 Ok(())
1182 }
1183
1184 #[test]
1185 fn test_require_then_import_dynamic_symbol_list() -> Result<(), String> {
1186 let code = r#"
1187require Foo::Bar;
1188Foo::Bar->import(@names);
1189"#;
1190 let specs = parse_and_extract(code);
1191 let spec = specs
1192 .iter()
1193 .find(|s| s.module == "Foo::Bar")
1194 .ok_or("expected ImportSpec for Foo::Bar")?;
1195
1196 assert_eq!(spec.kind, ImportKind::RequireThenImport);
1197 assert_eq!(spec.symbols, ImportSymbols::Dynamic);
1198 assert_eq!(spec.confidence, Confidence::Low);
1199 Ok(())
1200 }
1201
1202 #[test]
1205 fn test_require_dynamic_variable() -> Result<(), String> {
1206 let specs = parse_and_extract("require $module;");
1207 let spec = specs
1208 .iter()
1209 .find(|s| s.kind == ImportKind::DynamicRequire)
1210 .ok_or("expected DynamicRequire ImportSpec")?;
1211
1212 assert_eq!(spec.module, "");
1213 assert_eq!(spec.symbols, ImportSymbols::Dynamic);
1214 assert_eq!(spec.provenance, Provenance::DynamicBoundary);
1217 assert_eq!(spec.confidence, Confidence::Low);
1218 assert_eq!(spec.file_id, Some(FileId(1)));
1219 assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1220 Ok(())
1221 }
1222
1223 #[test]
1226 fn test_mixed_use_and_require() -> Result<(), String> {
1227 let code = r#"
1228use strict;
1229use warnings;
1230require Foo::Bar;
1231Foo::Bar->import(qw(baz));
1232require $dynamic;
1233"#;
1234 let specs = parse_and_extract(code);
1235
1236 let strict_spec =
1238 specs.iter().find(|s| s.module == "strict").ok_or("expected strict ImportSpec")?;
1239 assert_eq!(strict_spec.kind, ImportKind::Use);
1240
1241 let warnings_spec =
1243 specs.iter().find(|s| s.module == "warnings").ok_or("expected warnings ImportSpec")?;
1244 assert_eq!(warnings_spec.kind, ImportKind::Use);
1245
1246 let foo_spec =
1248 specs.iter().find(|s| s.module == "Foo::Bar").ok_or("expected Foo::Bar ImportSpec")?;
1249 assert_eq!(foo_spec.kind, ImportKind::RequireThenImport);
1250 if let ImportSymbols::Explicit(names) = &foo_spec.symbols {
1251 assert!(names.contains(&"baz".to_string()), "missing 'baz' in {names:?}");
1252 } else {
1253 return Err(format!("expected Explicit, got {:?}", foo_spec.symbols));
1254 }
1255
1256 let dyn_spec = specs
1258 .iter()
1259 .find(|s| s.kind == ImportKind::DynamicRequire)
1260 .ok_or("expected DynamicRequire ImportSpec")?;
1261 assert_eq!(dyn_spec.symbols, ImportSymbols::Dynamic);
1262
1263 Ok(())
1264 }
1265
1266 #[test]
1269 fn test_require_string_path() -> Result<(), String> {
1270 let specs = parse_and_extract(r#"require "Foo/Bar.pm";"#);
1271 let spec = specs
1272 .iter()
1273 .find(|s| s.module == "Foo::Bar")
1274 .ok_or("expected ImportSpec for Foo::Bar")?;
1275
1276 assert_eq!(spec.kind, ImportKind::Require);
1277 assert_eq!(spec.symbols, ImportSymbols::Default);
1278 Ok(())
1279 }
1280
1281 #[test]
1284 fn standalone_class_dynamic_import_produces_dynamic_spec() -> Result<(), String> {
1285 let specs = parse_and_extract(r#"Foo->import(@names);"#);
1289 let spec = specs
1290 .iter()
1291 .find(|s| s.module == "Foo" && matches!(s.symbols, ImportSymbols::Dynamic))
1292 .ok_or("expected Dynamic ImportSpec for Foo")?;
1293
1294 assert_eq!(spec.provenance, Provenance::DynamicBoundary);
1295 assert_eq!(spec.confidence, Confidence::Low);
1296 assert_eq!(
1297 spec.kind,
1298 ImportKind::ManualImport,
1299 "Class->import(@names) must use ManualImport, not Use"
1300 );
1301 Ok(())
1302 }
1303
1304 #[test]
1305 fn standalone_class_explicit_import_produces_no_dynamic_spec() -> Result<(), String> {
1306 let specs = parse_and_extract(r#"Foo->import('bar');"#);
1309 let dynamic_specs: Vec<_> =
1310 specs.iter().filter(|s| matches!(s.symbols, ImportSymbols::Dynamic)).collect();
1311
1312 assert!(dynamic_specs.is_empty(), "explicit import args must not produce a Dynamic spec");
1313 Ok(())
1314 }
1315
1316 #[test]
1317 fn variable_class_import_does_not_produce_standalone_spec() -> Result<(), String> {
1318 let specs = parse_and_extract(r#"$var->import(@names);"#);
1321 let standalone_dynamic: Vec<_> = specs
1324 .iter()
1325 .filter(|s| matches!(s.symbols, ImportSymbols::Dynamic) && s.module.is_empty())
1326 .collect();
1327
1328 assert!(
1331 standalone_dynamic.is_empty(),
1332 "variable-class import without require must not produce standalone Dynamic spec"
1333 );
1334 Ok(())
1335 }
1336
1337 #[test]
1345 fn parse_quote_operator_content_compact_paren() {
1346 assert_eq!(
1348 ImportExtractor::parse_quote_operator_content("qw(foo bar)", "qw"),
1349 Some("foo bar")
1350 );
1351 }
1352
1353 #[test]
1354 fn parse_quote_operator_content_compact_bracket() {
1355 assert_eq!(
1357 ImportExtractor::parse_quote_operator_content("qw[foo bar]", "qw"),
1358 Some("foo bar")
1359 );
1360 }
1361
1362 #[test]
1363 fn parse_quote_operator_content_compact_brace() {
1364 assert_eq!(
1366 ImportExtractor::parse_quote_operator_content("qw{foo bar}", "qw"),
1367 Some("foo bar")
1368 );
1369 }
1370
1371 #[test]
1372 fn parse_quote_operator_content_compact_slash() {
1373 assert_eq!(
1375 ImportExtractor::parse_quote_operator_content("qw/foo bar/", "qw"),
1376 Some("foo bar")
1377 );
1378 }
1379
1380 #[test]
1381 fn parse_quote_operator_content_space_before_paren() {
1382 assert_eq!(
1384 ImportExtractor::parse_quote_operator_content("qw (foo bar)", "qw"),
1385 Some("foo bar")
1386 );
1387 }
1388
1389 #[test]
1390 fn parse_quote_operator_content_space_before_bracket() {
1391 assert_eq!(
1393 ImportExtractor::parse_quote_operator_content("qw [foo bar]", "qw"),
1394 Some("foo bar")
1395 );
1396 }
1397
1398 #[test]
1399 fn parse_quote_operator_content_space_before_slash() {
1400 assert_eq!(
1402 ImportExtractor::parse_quote_operator_content("qw /foo bar/", "qw"),
1403 Some("foo bar")
1404 );
1405 }
1406
1407 #[test]
1408 fn parse_quote_operator_content_alphanumeric_delimiter_rejected() {
1409 assert_eq!(ImportExtractor::parse_quote_operator_content("qwfoo", "qw"), None);
1411 }
1412
1413 #[test]
1414 fn parse_quote_operator_content_underscore_delimiter_rejected() {
1415 assert_eq!(ImportExtractor::parse_quote_operator_content("qw_foo_", "qw"), None);
1417 }
1418
1419 #[test]
1420 fn parse_quote_operator_content_wrong_operator_returns_none() {
1421 assert_eq!(ImportExtractor::parse_quote_operator_content("qq(foo bar)", "qw"), None);
1423 }
1424
1425 #[test]
1426 fn parse_quote_operator_content_mismatched_close_returns_none() {
1427 assert_eq!(ImportExtractor::parse_quote_operator_content("qw(foo bar]", "qw"), None);
1429 }
1430}