use super::*;
use std::path::PathBuf;
fn parse(source: &str) -> FileAnalysis {
use tree_sitter::Parser;
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
crate::builder::build(&tree, source.as_bytes())
}
#[test]
fn test_refs_to_finds_sub_across_workspace_files() {
let store = FileStore::new();
let path_a = PathBuf::from("/tmp/resolve_test_a.pm");
let path_b = PathBuf::from("/tmp/resolve_test_b.pm");
let fa_a = parse("package A;\nour @EXPORT_OK = qw/foo/;\nsub foo { 42 }\n1;\n");
store.insert_workspace(path_a.clone(), fa_a);
let fa_b = parse("package B;\nuse A qw/foo/;\nsub bar { foo(); }\n1;\n");
store.insert_workspace(path_b.clone(), fa_b);
let results = refs_to(
&store,
None,
&TargetRef {
name: "foo".to_string(),
kind: TargetKind::Sub {
package: Some("A".to_string()),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results
.iter()
.any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_a)
&& r.access == AccessKind::Declaration),
"expected declaration of foo in file A, got {:?}",
results,
);
assert!(
results
.iter()
.any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_b)
&& r.access == AccessKind::Read),
"expected call to foo in file B, got {:?}",
results,
);
}
#[test]
fn test_refs_to_exporter_extensible_cross_file() {
let store = FileStore::new();
let path_a = PathBuf::from("/tmp/resolve_ext_a.pm");
let path_b = PathBuf::from("/tmp/resolve_ext_b.pm");
let fa_a = parse(
"package Ext;\nuse Exporter::Extensible -exporter_setup => 1;\nexport(qw/foo/);\nsub foo { 42 }\nsub bar :Export {}\n1;\n",
);
store.insert_workspace(path_a.clone(), fa_a);
let fa_b = parse("package C;\nuse Ext qw/foo/;\nsub baz { foo(); }\n1;\n");
store.insert_workspace(path_b.clone(), fa_b);
let results = refs_to(
&store,
None,
&TargetRef {
name: "foo".to_string(),
kind: TargetKind::Sub { package: Some("Ext".to_string()) },
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_a)
&& r.access == AccessKind::Declaration),
"expected declaration of foo in Ext, got {:?}",
results,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_b)
&& r.access == AccessKind::Read),
"expected call to foo in consumer, got {:?}",
results,
);
}
#[test]
fn test_refs_to_exporter_declare_cross_file() {
let store = FileStore::new();
let path_a = PathBuf::from("/tmp/resolve_decl_a.pm");
let path_b = PathBuf::from("/tmp/resolve_decl_b.pm");
let fa_a = parse(
"package Decl;\nuse Exporter::Declare;\ndefault_export foo => sub { 42 };\nsub foo { 42 }\n1;\n",
);
store.insert_workspace(path_a.clone(), fa_a);
let fa_b = parse("package C;\nuse Decl qw/foo/;\nsub baz { foo(); }\n1;\n");
store.insert_workspace(path_b.clone(), fa_b);
let results = refs_to(
&store,
None,
&TargetRef {
name: "foo".to_string(),
kind: TargetKind::Sub { package: Some("Decl".to_string()) },
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_a)),
"expected def of foo in Decl, got {:?}",
results,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_b)
&& r.access == AccessKind::Read),
"expected call to foo in consumer, got {:?}",
results,
);
}
#[test]
fn test_refs_to_importer_consumer_cross_file() {
let store = FileStore::new();
let path_a = PathBuf::from("/tmp/resolve_imp_src.pm");
let path_b = PathBuf::from("/tmp/resolve_imp_consumer.pm");
let fa_a = parse("package Src::Mod;\nour @EXPORT_OK = qw/foo/;\nsub foo { 42 }\n1;\n");
store.insert_workspace(path_a.clone(), fa_a);
let fa_b = parse(
"package C;\nuse Importer 'Src::Mod' => qw/foo/;\nsub baz { foo(); }\n1;\n",
);
store.insert_workspace(path_b.clone(), fa_b);
let results = refs_to(
&store,
None,
&TargetRef {
name: "foo".to_string(),
kind: TargetKind::Sub { package: Some("Src::Mod".to_string()) },
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_a)
&& r.access == AccessKind::Declaration),
"expected decl of foo in Src::Mod, got {:?}",
results,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_b)),
"expected import/call of foo in consumer pinned to Src::Mod, got {:?}",
results,
);
}
#[test]
fn test_refs_to_export_not_registered_without_use() {
let store = FileStore::new();
let path_a = PathBuf::from("/tmp/resolve_noexport.pm");
let fa_a = parse("package Plain;\nsub export {}\nexport('phantom');\n1;\n");
store.insert_workspace(path_a.clone(), fa_a);
let results = refs_to(
&store,
None,
&TargetRef {
name: "phantom".to_string(),
kind: TargetKind::Sub { package: Some("Plain".to_string()) },
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(results.is_empty(), "no phantom export, got {:?}", results);
}
#[test]
fn refs_to_helper_leaf_excludes_unrelated_route_with_same_method_name() {
let store = FileStore::new();
let path = PathBuf::from("/tmp/helper_route_overlap.pm");
let fa = parse(
r#"
package MyApp;
use Mojolicious::Lite;
$app->helper('users.create', sub ($c, $user) {});
$app->routes->post('/users')->to(controller => 'Users', action => 'create');
"#,
);
store.insert_workspace(path.clone(), fa);
let helper_results = refs_to(
&store,
None,
&TargetRef {
name: "create".to_string(),
kind: TargetKind::Method {
class: "Mojolicious::Controller::_Helper::users".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
for r in &helper_results {
let col = r.span.start.column;
assert!(
!(r.span.start.row == 5 && col > 50),
"gr on helper leaf _Helper::users::create picked up the \
route's Users::create ref (line {}, col {}) — unrelated \
class, shouldn't appear",
r.span.start.row,
col,
);
}
let route_results = refs_to(
&store,
None,
&TargetRef {
name: "create".to_string(),
kind: TargetKind::Method {
class: "Users".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
for r in &route_results {
assert!(
!(r.span.start.row == 4 && r.span.start.column < 30),
"gr on route Users::create picked up the helper leaf \
_Helper::users::create (line {}, col {}) — unrelated class",
r.span.start.row,
r.span.start.column,
);
}
}
#[test]
fn refs_to_method_is_class_scoped_plain_packages() {
let store = FileStore::new();
let path = PathBuf::from("/tmp/two_classes_same_method.pm");
let fa = parse(
r#"
package Foo;
sub new { bless {}, shift }
sub run { "foo" }
package Bar;
sub new { bless {}, shift }
sub run { "bar" }
package main;
my $f = Foo->new;
my $b = Bar->new;
$f->run;
$b->run;
1;
"#,
);
store.insert_workspace(path.clone(), fa);
let foo_results = refs_to(
&store,
None,
&TargetRef {
name: "run".to_string(),
kind: TargetKind::Method {
class: "Foo".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let foo_lines: Vec<usize> = foo_results.iter().map(|r| r.span.start.row).collect();
assert!(
foo_lines.contains(&3), "Foo::run decl (line 3) missing from Foo results: {:?}",
foo_lines
);
assert!(
foo_lines.contains(&12), "$f->run call (line 12) missing from Foo results: {:?}",
foo_lines
);
assert!(
!foo_lines.contains(&7), "Bar::run decl (line 7) wrongly included in Foo results: {:?}",
foo_lines
);
assert!(
!foo_lines.contains(&13), "$b->run call (line 13) wrongly included in Foo results: {:?}",
foo_lines
);
let bar_results = refs_to(
&store,
None,
&TargetRef {
name: "run".to_string(),
kind: TargetKind::Method {
class: "Bar".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let bar_lines: Vec<usize> = bar_results.iter().map(|r| r.span.start.row).collect();
assert!(
bar_lines.contains(&7),
"Bar::run decl (line 7) missing from Bar results: {:?}",
bar_lines
);
assert!(
bar_lines.contains(&13),
"$b->run call (line 13) missing from Bar results: {:?}",
bar_lines
);
assert!(
!bar_lines.contains(&3),
"Foo::run decl (line 3) wrongly included in Bar results: {:?}",
bar_lines
);
assert!(
!bar_lines.contains(&12),
"$f->run call (line 12) wrongly included in Bar results: {:?}",
bar_lines
);
}
#[test]
fn refs_to_method_excludes_untyped_invocant_includes_self() {
let store = FileStore::new();
let path = PathBuf::from("/tmp/nav_untyped_invocant.pm");
let fa = parse(
r#"package Widget;
sub frobnicate { 1 }
sub run {
my $self = shift;
my $w = $ENV{X} ? external() : undef;
$w->frobnicate;
$w->frobnicate;
$self->frobnicate;
}
1;
"#,
);
store.insert_workspace(path.clone(), fa);
let results = refs_to(
&store,
None,
&TargetRef {
name: "frobnicate".to_string(),
kind: TargetKind::Method {
class: "Widget".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let lines: Vec<usize> = results.iter().map(|r| r.span.start.row).collect();
assert!(
lines.contains(&1),
"frobnicate decl (row 1) missing: {:?}",
lines
);
assert!(
lines.contains(&7),
"$self->frobnicate (row 7) missing: {:?}",
lines
);
assert!(
!lines.contains(&5) && !lines.contains(&6),
"untyped $w->frobnicate sites (rows 5,6) wrongly included: {:?}",
lines
);
assert_eq!(
results.len(),
2,
"expected exactly decl + $self call, got {:?}",
lines
);
}
#[test]
fn refs_to_method_typed_same_file_invocant_resolves_fully() {
let store = FileStore::new();
let path = PathBuf::from("/tmp/nav_typed_invocant.pm");
let fa = parse(
r#"package Widget;
sub new { bless {}, shift }
sub frobnicate { 1 }
sub run {
my $w = Widget->new;
$w->frobnicate;
$w->frobnicate;
}
1;
"#,
);
store.insert_workspace(path.clone(), fa);
let results = refs_to(
&store,
None,
&TargetRef {
name: "frobnicate".to_string(),
kind: TargetKind::Method {
class: "Widget".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let lines: Vec<usize> = results.iter().map(|r| r.span.start.row).collect();
assert!(lines.contains(&2), "frobnicate decl (row 2) missing: {:?}", lines);
assert!(
lines.iter().filter(|&&l| l == 5).count() == 1
&& lines.iter().filter(|&&l| l == 6).count() == 1,
"both typed $w->frobnicate sites (rows 5,6) must resolve: {:?}",
lines
);
}
#[test]
fn goto_def_untyped_receiver_is_honest_miss() {
use tree_sitter::{Parser, Point};
let src = r#"package Foo;
sub m { 1 }
package main;
my $x = external();
$x->m();
"#;
let mut parser = Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let tree = parser.parse(src, None).unwrap();
let fa = crate::builder::build(&tree, src.as_bytes());
let row = src.lines().position(|l| l.starts_with("$x->m")).unwrap();
let col = "$x->".len();
let def = fa.find_definition(Point::new(row, col), None);
assert_eq!(
def, None,
"untyped `$x->m` (where $x = external()) must be an honest miss, \
never a same-name jump to Foo::m. got: {:?}",
def,
);
}
#[test]
fn goto_def_untyped_receiver_multi_candidate_no_flood() {
use tree_sitter::{Parser, Point};
let src = r#"package A;
sub frob { 1 }
package B;
sub frob { 2 }
package main;
my $x = make_something();
$x->frob;
"#;
let mut parser = Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let tree = parser.parse(src, None).unwrap();
let fa = crate::builder::build(&tree, src.as_bytes());
let row = src.lines().position(|l| l.starts_with("$x->frob")).unwrap();
let col = "$x->".len();
let def = fa.find_definition(Point::new(row, col), None);
assert_eq!(
def, None,
"untyped `$x->frob` with two unrelated `frob` definitions must \
be None — no same-name flood. got: {:?}",
def,
);
}
#[test]
fn refs_to_method_is_class_scoped_corinna() {
let store = FileStore::new();
let path = PathBuf::from("/tmp/corinna_classes.pm");
let fa = parse(
r#"use v5.38;
class Sner {
method hi {}
}
class Bler {
method hi {}
}
my $s = Sner->new;
my $b = Bler->new;
$s->hi;
$b->hi;
Sner->new->hi;
Bler->new->hi;
1;
"#,
);
store.insert_workspace(path.clone(), fa);
let sner_results = refs_to(
&store,
None,
&TargetRef {
name: "hi".to_string(),
kind: TargetKind::Method {
class: "Sner".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let sner_lines: Vec<usize> = sner_results.iter().map(|r| r.span.start.row).collect();
assert!(
sner_lines.contains(&3),
"Sner::hi decl missing: {:?}",
sner_lines
);
assert!(
sner_lines.contains(&11),
"$s->hi (variable-bound) missing: {:?}",
sner_lines
);
assert!(
sner_lines.contains(&13),
"Sner->new->hi (inline chain) missing — chain invocant resolution \
via build-time invocant_class should cover this: {:?}",
sner_lines
);
assert!(
!sner_lines.contains(&6),
"Bler::hi decl wrongly in Sner results: {:?}",
sner_lines
);
assert!(
!sner_lines.contains(&12),
"$b->hi wrongly in Sner results: {:?}",
sner_lines
);
assert!(
!sner_lines.contains(&14),
"Bler->new->hi wrongly in Sner results: {:?}",
sner_lines
);
let bler_results = refs_to(
&store,
None,
&TargetRef {
name: "hi".to_string(),
kind: TargetKind::Method {
class: "Bler".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let bler_lines: Vec<usize> = bler_results.iter().map(|r| r.span.start.row).collect();
assert!(
bler_lines.contains(&6),
"Bler::hi decl missing: {:?}",
bler_lines
);
assert!(bler_lines.contains(&12), "$b->hi missing: {:?}", bler_lines);
assert!(
bler_lines.contains(&14),
"Bler->new->hi (inline chain) missing: {:?}",
bler_lines
);
assert!(
!bler_lines.contains(&3),
"Sner::hi decl wrongly in Bler results: {:?}",
bler_lines
);
assert!(
!bler_lines.contains(&11),
"$s->hi wrongly in Bler results: {:?}",
bler_lines
);
assert!(
!bler_lines.contains(&13),
"Sner->new->hi wrongly in Bler results: {:?}",
bler_lines
);
}
#[test]
fn all_four_lsp_paths_class_scope_on_shared_method_name() {
use crate::file_analysis::RenameKind;
use tree_sitter::Parser;
let src = r#"package Foo;
sub new { bless {}, shift }
sub run { "foo" }
package Bar;
sub new { bless {}, shift }
sub run { "bar" }
package main;
my $f = Foo->new;
my $b = Bar->new;
$f->run;
$b->run;
1;
"#;
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let fa = crate::builder::build(&tree, src.as_bytes());
let _run_in_foo_decl = tree_sitter::Point { row: 2, column: 4 };
let _run_in_bar_decl = tree_sitter::Point { row: 6, column: 4 };
let f_run_call = tree_sitter::Point { row: 11, column: 4 };
let b_run_call = tree_sitter::Point { row: 12, column: 4 };
let hover = fa
.hover_info(f_run_call, src, None)
.expect("hover on $f->run returns something");
assert!(
hover.contains("Foo"),
"hover on $f->run should mention Foo; got: {:?}",
hover
);
assert!(
!hover.contains("Bar"),
"hover on $f->run leaked Bar into the content: {:?}",
hover
);
let hover_b = fa
.hover_info(b_run_call, src, None)
.expect("hover on $b->run returns something");
assert!(
hover_b.contains("Bar"),
"hover on $b->run should mention Bar; got: {:?}",
hover_b
);
assert!(
!hover_b.contains("Foo"),
"hover on $b->run leaked Foo into the content: {:?}",
hover_b
);
let gd_f = fa
.find_definition(f_run_call, None)
.expect("gd on $f->run resolves");
assert_eq!(
gd_f.start.row, 2,
"gd on $f->run jumped to line {} (expected 2 = Foo::run)",
gd_f.start.row
);
let gd_b = fa
.find_definition(b_run_call, None)
.expect("gd on $b->run resolves");
assert_eq!(
gd_b.start.row, 6,
"gd on $b->run jumped to line {} (expected 6 = Bar::run)",
gd_b.start.row
);
let target_from_f = match fa.rename_kind_at(f_run_call, None) {
Some(RenameKind::Method { name, class }) => TargetRef {
name,
kind: TargetKind::Method { class },
method_classes: Vec::new(),
},
other => panic!(
"rename_kind_at($f->run) should be Method{{class=Foo}}, got {:?}",
other
),
};
if let TargetKind::Method { ref class } = target_from_f.kind {
assert_eq!(
class, "Foo",
"rename_kind_at($f->run) resolved class as {:?}, expected Foo",
class
);
}
let edits = fa.rename_method_in_class("run", "Foo", "renamed_run", None);
let edit_lines: Vec<usize> = edits.iter().map(|(span, _)| span.start.row).collect();
assert!(
edit_lines.contains(&2),
"rename Foo::run missed the decl: {:?}",
edit_lines
);
assert!(
edit_lines.contains(&11),
"rename Foo::run missed the $f->run call: {:?}",
edit_lines
);
assert!(
!edit_lines.contains(&6),
"rename Foo::run wrongly rewrote Bar::run decl: {:?}",
edit_lines
);
assert!(
!edit_lines.contains(&12),
"rename Foo::run wrongly rewrote $b->run call: {:?}",
edit_lines
);
let store = FileStore::new();
let path = PathBuf::from("/tmp/lsp_paths_fixture.pm");
store.insert_workspace(path.clone(), fa);
let foo_refs = refs_to(&store, None, &target_from_f, RoleMask::EDITABLE);
let foo_lines: Vec<usize> = foo_refs.iter().map(|r| r.span.start.row).collect();
assert!(
foo_lines.contains(&2), "gr(Foo::run) missed decl: {:?}",
foo_lines
);
assert!(
foo_lines.contains(&11), "gr(Foo::run) missed $f->run call: {:?}",
foo_lines
);
assert!(
!foo_lines.contains(&6), "gr(Foo::run) wrongly included Bar::run decl: {:?}",
foo_lines
);
assert!(
!foo_lines.contains(&12), "gr(Foo::run) wrongly included $b->run call: {:?}",
foo_lines
);
}
#[test]
fn sub_refs_respect_import_graph_not_just_name() {
use crate::file_analysis::RenameKind;
use tree_sitter::Parser;
let src = r#"package Sner {
our @EXPORT_OK = qw/hi/;
sub hi {}
}
package Bler {
our @EXPORT_OK = qw/hi/;
sub hi {}
}
package Xler {
sub hi {}
}
use Sner;
use Bler qw/hi/;
hi();
"#;
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let fa = crate::builder::build(&tree, src.as_bytes());
let hi_call = tree_sitter::Point { row: 15, column: 0 };
let kind = fa.rename_kind_at(hi_call, None);
let hover = fa.hover_info(hi_call, src, None);
let gd = fa.find_definition(hi_call, None);
let target = match kind.as_ref() {
Some(RenameKind::Function { name, package }) => TargetRef {
name: name.clone(),
kind: TargetKind::Sub {
package: package.clone(),
},
method_classes: Vec::new(),
},
Some(RenameKind::Method { name, class }) => TargetRef {
name: name.clone(),
kind: TargetKind::Method {
class: class.clone(),
},
method_classes: Vec::new(),
},
other => panic!("unexpected rename_kind_at = {:?}", other),
};
let rename_edits = match &kind {
Some(RenameKind::Function { name, package }) => {
fa.rename_sub_in_package(name, package, "renamed_hi", None)
}
Some(RenameKind::Method { name, class }) => {
fa.rename_method_in_class(name, class, "renamed_hi", None)
}
_ => Vec::new(),
};
let store = FileStore::new();
let path = PathBuf::from("/tmp/sub_import_fixture.pm");
store.insert_workspace(path.clone(), fa);
let refs_result = refs_to(&store, None, &target, RoleMask::EDITABLE);
let mut failures: Vec<String> = Vec::new();
match &hover {
Some(h) if h.contains("Bler") && !h.contains("Sner") && !h.contains("Xler") => { }
Some(h) => failures.push(format!(
"hover on `hi()` is ambiguous or names the wrong package; got: {:?} \
(should mention Bler; must not mention Sner or Xler)",
h
)),
None => failures.push("hover on `hi()` returned None".into()),
}
match gd {
Some(s) if s.start.row == 6 => { }
Some(s) if s.start.row == 2 => failures.push(format!(
"gd jumped to Sner::hi (row 2) — \
should follow imports to Bler::hi (row 6)"
)),
Some(s) if s.start.row == 9 => failures.push(format!(
"gd jumped to Xler::hi (row 9) — \
Xler isn't imported, it should be ignored"
)),
Some(s) => failures.push(format!(
"gd jumped to row {} — expected row 6 (Bler::hi)",
s.start.row
)),
None => failures.push("gd returned None".into()),
}
let ref_rows: Vec<usize> = refs_result.iter().map(|r| r.span.start.row).collect();
if !ref_rows.contains(&15) {
failures.push(format!("gr missed the hi() call at row 15: {:?}", ref_rows));
}
if !ref_rows.contains(&6) {
failures.push(format!("gr missed Bler::hi decl at row 6: {:?}", ref_rows));
}
if ref_rows.contains(&2) {
failures.push(format!(
"gr wrongly unioned Sner::hi decl at row 2: {:?}",
ref_rows
));
}
if ref_rows.contains(&9) {
failures.push(format!(
"gr wrongly unioned Xler::hi decl at row 9: {:?}",
ref_rows
));
}
let rename_rows: Vec<usize> = rename_edits.iter().map(|(s, _)| s.start.row).collect();
if !rename_rows.contains(&15) {
failures.push(format!(
"rename missed hi() call at row 15: {:?}",
rename_rows
));
}
if !rename_rows.contains(&6) {
failures.push(format!(
"rename missed Bler::hi decl at row 6: {:?}",
rename_rows
));
}
if rename_rows.contains(&2) {
failures.push(format!(
"rename wrongly rewrote Sner::hi decl at row 2: {:?}",
rename_rows
));
}
if rename_rows.contains(&9) {
failures.push(format!(
"rename wrongly rewrote Xler::hi decl at row 9: {:?}",
rename_rows
));
}
assert!(
failures.is_empty(),
"import-graph-aware resolution broken across paths:\n - {}",
failures.join("\n - "),
);
}
#[test]
fn document_highlight_respects_scope_on_shared_method_name() {
use tree_sitter::Parser;
let src = r#"
package MyApp;
use Mojolicious::Lite;
use Mojolicious;
my $app = Mojolicious->new;
$app->helper('users.create' => sub ($c, $name, $email) {});
my $r = app->routes;
$r->post('/users')->to(controller => 'Users', action => 'create');
"#;
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let fa = crate::builder::build(&tree, src.as_bytes());
let line_helper = 6;
let line_route = 9;
let src_line = |n: usize| src.lines().nth(n).unwrap_or("");
let helper_col = src_line(line_helper)
.find("create")
.expect("'create' on helper line");
let route_col = src_line(line_route)
.rfind("create")
.expect("'create' on route line");
let helper_pt = tree_sitter::Point {
row: line_helper,
column: helper_col + 2,
};
let helper_highlights = fa.find_highlights(helper_pt, None);
let helper_rows: Vec<usize> = helper_highlights.iter().map(|(s, _)| s.start.row).collect();
assert!(
helper_rows.contains(&line_helper),
"helper highlight missed its own site: {:?}",
helper_rows
);
assert!(
!helper_rows.contains(&line_route),
"helper highlight leaked into the route line — class-scoping broken: {:?}",
helper_rows
);
let route_pt = tree_sitter::Point {
row: line_route,
column: route_col + 2,
};
let route_highlights = fa.find_highlights(route_pt, None);
let route_rows: Vec<usize> = route_highlights.iter().map(|(s, _)| s.start.row).collect();
assert!(
route_rows.contains(&line_route),
"route highlight missed its own site: {:?}",
route_rows
);
assert!(
!route_rows.contains(&line_helper),
"route highlight leaked into the helper line — class-scoping broken: {:?}",
route_rows
);
}
#[test]
fn references_cross_file_method_respects_class_scope() {
use crate::file_analysis::RenameKind;
use tree_sitter::Parser;
let store = FileStore::new();
let parse_build = |source: &str| {
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
crate::builder::build(&tree, source.as_bytes())
};
let f1_src = r#"
package MyApp;
use Mojolicious::Lite;
$app->helper('users.create' => sub ($c, $name) {});
my $r = app->routes;
$r->post('/users')->to(controller => 'Users', action => 'create');
"#;
let f1 = parse_build(f1_src);
store.insert_workspace(PathBuf::from("/tmp/app.pm"), f1);
let f2_src = r#"
package Users;
sub create {
my ($self, %args) = @_;
return { ok => 1 };
}
sub list { }
1;
"#;
let f2 = parse_build(f2_src);
store.insert_workspace(PathBuf::from("/tmp/users.pm"), f2);
let f3_src = r#"
package Consumer;
use Users;
my $u = Users->new;
$u->create(name => 'alice');
1;
"#;
let f3 = parse_build(f3_src);
store.insert_workspace(PathBuf::from("/tmp/consumer.pm"), f3);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let f1_tree = parser.parse(f1_src, None).unwrap();
let f1_fa = crate::builder::build(&f1_tree, f1_src.as_bytes());
let route_row = 7usize;
let line_route = f1_src.lines().nth(route_row).unwrap_or("");
let create_col = line_route.rfind("create").expect("'create' on route line");
let cursor = tree_sitter::Point {
row: route_row,
column: create_col + 2,
};
let kind = f1_fa.rename_kind_at(cursor, None);
let target = match kind {
Some(RenameKind::Method { name, class }) => TargetRef {
name,
kind: TargetKind::Method { class },
method_classes: Vec::new(),
},
other => panic!("expected Method, got {:?}", other),
};
if let TargetKind::Method { ref class } = target.kind {
assert_eq!(class, "Users", "target class should be Users");
}
let refs = refs_to(&store, None, &target, RoleMask::EDITABLE);
let hits: Vec<(String, usize)> = refs
.iter()
.map(|r| {
let fname = match &r.key {
FileKey::Path(p) => p.file_name().unwrap().to_str().unwrap().to_string(),
FileKey::Url(u) => u.to_string(),
};
(fname, r.span.start.row)
})
.collect();
let has_users_decl = hits.iter().any(|(f, _)| f == "users.pm");
let has_consumer_call = hits.iter().any(|(f, _)| f == "consumer.pm");
let has_route_ref = hits.iter().any(|(f, r)| f == "app.pm" && *r == route_row);
assert!(
has_users_decl,
"gr missed Users::create decl (users.pm row 2): {:?}",
hits
);
assert!(
has_consumer_call,
"gr missed $u->create caller (consumer.pm row 4): {:?}",
hits
);
assert!(has_route_ref, "gr missed route ref in app.pm: {:?}", hits);
let helper_row = f1_src
.lines()
.enumerate()
.find(|(_, l)| l.contains("$app->helper"))
.map(|(i, _)| i)
.unwrap();
let leaked_helper = hits.iter().any(|(f, r)| f == "app.pm" && *r == helper_row);
assert!(
!leaked_helper,
"gr wrongly included the helper on a different class at row {}: {:?}",
helper_row, hits
);
}
#[test]
fn test_refs_to_empty_when_no_hits() {
let store = FileStore::new();
let fa = parse("package Only;\nsub bar { 1 }\n1;\n");
store.insert_workspace(PathBuf::from("/tmp/resolve_no_hits.pm"), fa);
let results = refs_to(
&store,
None,
&TargetRef {
name: "nonexistent".to_string(),
kind: TargetKind::Sub { package: None },
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(results.is_empty());
}
#[test]
fn test_refs_to_finds_hash_key_def_and_access_same_file() {
let store = FileStore::new();
let path = PathBuf::from("/tmp/resolve_hash_same.pm");
let fa = parse(
"package Lib;\nsub get_config { return { host => 1, port => 2 } }\nmy $cfg = get_config();\nmy $h = $cfg->{host};\n1;\n",
);
store.insert_workspace(path.clone(), fa);
let results = refs_to(
&store,
None,
&TargetRef {
name: "host".to_string(),
kind: TargetKind::HashKeyOfSub {
package: Some("Lib".to_string()),
name: "get_config".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let has_decl = results.iter().any(|r| r.access == AccessKind::Declaration);
let has_access = results.iter().any(|r| r.access == AccessKind::Read);
assert!(has_decl, "expected HashKeyDef decl, got {:?}", results);
assert!(has_access, "expected HashKeyAccess, got {:?}", results);
}
#[test]
fn test_refs_to_finds_cross_file_hash_key_def() {
let store = FileStore::new();
let path_lib = PathBuf::from("/tmp/resolve_hash_cross_lib.pm");
let fa_lib = parse("package Lib;\nsub get_config { return { host => 1, port => 2 } }\n1;\n");
store.insert_workspace(path_lib.clone(), fa_lib);
let results = refs_to(
&store,
None,
&TargetRef {
name: "host".to_string(),
kind: TargetKind::HashKeyOfSub {
package: Some("Lib".to_string()),
name: "get_config".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results
.iter()
.any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_lib)),
"expected HashKeyDef match in Lib, got {:?}",
results,
);
}
#[test]
fn test_refs_to_package_qualified_sub_owner_isolates_name_collisions() {
let store = FileStore::new();
let path_a = PathBuf::from("/tmp/resolve_pkg_a.pm");
let path_b = PathBuf::from("/tmp/resolve_pkg_b.pm");
let fa_a = parse("package Alpha;\nsub get_config { return { host => 'alpha' } }\n1;\n");
store.insert_workspace(path_a.clone(), fa_a);
let fa_b = parse("package Beta;\nsub get_config { return { host => 'beta' } }\n1;\n");
store.insert_workspace(path_b.clone(), fa_b);
let results = refs_to(
&store,
None,
&TargetRef {
name: "host".to_string(),
kind: TargetKind::HashKeyOfSub {
package: Some("Alpha".to_string()),
name: "get_config".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results
.iter()
.any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_a)),
"expected Alpha hit, got {:?}",
results,
);
assert!(
!results
.iter()
.any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_b)),
"Beta's get_config must NOT show up in Alpha's references, got {:?}",
results,
);
}
#[test]
fn test_refs_to_qualified_call_resolves_to_def() {
let store = FileStore::new();
let path_a = PathBuf::from("/tmp/resolve_qual_a.pm");
let path_b = PathBuf::from("/tmp/resolve_qual_b.pm");
let fa_a = parse("package A;\nsub foo { 42 }\n1;\n");
store.insert_workspace(path_a.clone(), fa_a);
let fa_b = parse("package B;\nsub bar { A::foo(); A::foo() }\n1;\n");
store.insert_workspace(path_b.clone(), fa_b);
let results = refs_to(
&store,
None,
&TargetRef {
name: "foo".to_string(),
kind: TargetKind::Sub {
package: Some("A".to_string()),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results
.iter()
.any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_a)),
"expected the def in A, got {:?}",
results,
);
let b_hits = results
.iter()
.filter(|r| matches!(&r.key, FileKey::Path(p) if p == &path_b))
.count();
assert_eq!(b_hits, 2, "expected both A::foo() call sites, got {:?}", results);
}
#[test]
fn test_refs_to_qualified_call_isolates_package() {
let store = FileStore::new();
let path_b = PathBuf::from("/tmp/resolve_qual_iso_b.pm");
let fa_b = parse("package B;\nsub bar { A::foo(); C::foo() }\n1;\n");
store.insert_workspace(path_b.clone(), fa_b);
let results = refs_to(
&store,
None,
&TargetRef {
name: "foo".to_string(),
kind: TargetKind::Sub {
package: Some("A".to_string()),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let b_hits = results
.iter()
.filter(|r| matches!(&r.key, FileKey::Path(p) if p == &path_b))
.count();
assert_eq!(b_hits, 1, "only A::foo() should match, got {:?}", results);
}
#[test]
fn test_refs_to_role_mask_excludes_workspace() {
let store = FileStore::new();
let fa = parse("package P;\nsub foo {}\n1;\n");
store.insert_workspace(PathBuf::from("/tmp/masked.pm"), fa);
let results = refs_to(
&store,
None,
&TargetRef {
name: "foo".to_string(),
kind: TargetKind::Sub { package: None },
method_classes: Vec::new(),
},
RoleMask::OPEN,
);
assert!(results.is_empty());
}
#[test]
fn references_cross_file_invocant_resolved_post_enrichment() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let producer_src = r#"
package B;
use Exporter 'import';
our @EXPORT_OK = qw(make_b);
sub new { return bless {}, shift }
sub make_b { return B->new }
sub touch { 1 }
1;
"#;
let consumer_src = r#"
use B qw(make_b);
my $b = make_b();
$b->touch();
1;
"#;
let producer_path = PathBuf::from("/tmp/refs_xfile_b.pm");
let consumer_path = PathBuf::from("/tmp/refs_xfile_a.pm");
let idx = ModuleIndex::new_for_test();
let producer_fa = parse(producer_src);
idx.register_workspace_module(producer_path.clone(), Arc::new(producer_fa));
let mut consumer_fa = parse(consumer_src);
consumer_fa.enrich_imported_types_with_keys(Some(&idx));
let b_type = consumer_fa.inferred_type_via_bag(
"$b",
tree_sitter::Point { row: 4, column: 0 },
);
assert_eq!(
b_type.as_ref().and_then(|t| t.class_name()),
Some("B"),
"precondition: enrichment must type $$b as B; got {:?}",
b_type,
);
let store = FileStore::new();
store.insert_workspace(producer_path, parse(producer_src));
store.insert_workspace(consumer_path.clone(), consumer_fa);
let target = TargetRef {
name: "touch".to_string(),
kind: TargetKind::Method { class: "B".to_string() },
method_classes: Vec::new(),
};
let refs = refs_to(&store, Some(&idx), &target, RoleMask::WORKSPACE);
let consumer_hit = refs.iter().any(|r| {
matches!(&r.key, FileKey::Path(p) if p == &consumer_path)
});
assert!(
consumer_hit,
"refs_to(B::touch) missed consumer's $$b->touch() call site. \
invocant_class on the MethodCall ref is None because the \
consumer was built before B's return types were known, and \
enrichment does not refresh ref fields. hits: {:?}",
refs.iter().map(|r| (&r.key, r.span.start.row)).collect::<Vec<_>>(),
);
}
#[test]
fn find_highlights_cross_file_invocant_resolved_post_enrichment() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let producer_src = r#"
package B;
use Exporter 'import';
our @EXPORT_OK = qw(make_b);
sub new { return bless {}, shift }
sub make_b { return B->new }
sub touch { 1 }
1;
"#;
let consumer_src = r#"
use B qw(make_b);
my $b = make_b();
$b->touch();
$b->touch();
1;
"#;
let producer_path = PathBuf::from("/tmp/highlights_xfile_b.pm");
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(producer_path, Arc::new(parse(producer_src)));
let mut consumer_fa = parse(consumer_src);
consumer_fa.enrich_imported_types_with_keys(Some(&idx));
let highlights = consumer_fa.find_highlights(
tree_sitter::Point { row: 3, column: 4 },
Some(&idx));
assert_eq!(
highlights.len(),
2,
"find_highlights should match both $$b->touch() sites once \
enrichment has typed $$b: B. got {:?}",
highlights,
);
}
#[test]
fn refs_to_cross_file_invocant_inherited_method() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let parent_src = r#"
package P;
sub new { return bless {}, shift }
sub ping { "pong" }
1;
"#;
let child_src = r#"
package C;
use parent 'P';
use Exporter 'import';
our @EXPORT_OK = qw(make_c);
sub make_c { return C->new }
1;
"#;
let consumer_src = r#"
use C qw(make_c);
my $x = make_c();
$x->ping();
1;
"#;
let parent_path = PathBuf::from("/tmp/multihop_p.pm");
let child_path = PathBuf::from("/tmp/multihop_c.pm");
let consumer_path = PathBuf::from("/tmp/multihop_consumer.pm");
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(parent_path.clone(), Arc::new(parse(parent_src)));
idx.register_workspace_module(child_path.clone(), Arc::new(parse(child_src)));
let mut consumer_fa = parse(consumer_src);
consumer_fa.enrich_imported_types_with_keys(Some(&idx));
let x_type = consumer_fa.inferred_type_via_bag(
"$x",
tree_sitter::Point { row: 4, column: 0 },
);
assert_eq!(
x_type.as_ref().and_then(|t| t.class_name()),
Some("C"),
"precondition: enrichment must type $$x as C; got {:?}",
x_type,
);
let store = FileStore::new();
store.insert_workspace(parent_path, parse(parent_src));
store.insert_workspace(child_path, parse(child_src));
store.insert_workspace(consumer_path.clone(), consumer_fa);
let refs = refs_to(
&store,
Some(&idx),
&TargetRef {
name: "ping".to_string(),
kind: TargetKind::Method { class: "C".to_string() },
method_classes: Vec::new(),
},
RoleMask::WORKSPACE,
);
assert!(
refs.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &consumer_path)),
"refs_to(C::ping) missed consumer's $$x->ping() call site \
(multi-hop: $$x typed as C only post-enrichment). hits: {:?}",
refs.iter().map(|r| (&r.key, r.span.start.row)).collect::<Vec<_>>(),
);
}
#[cfg(feature = "perf_bench")]
#[test]
fn bench_refs_to_invocant_resolution() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
use std::time::Instant;
const N_FILES: usize = 200;
const M_CALLS: usize = 50;
const R_REPS: usize = 50;
let producer_src = r#"
package B;
use Exporter 'import';
our @EXPORT_OK = qw(make_b);
sub new { return bless {}, shift }
sub make_b { return B->new }
sub touch { 1 }
1;
"#;
let store = FileStore::new();
let idx = ModuleIndex::new_for_test();
let producer_path = PathBuf::from("/tmp/bench_b.pm");
idx.register_workspace_module(producer_path.clone(), Arc::new(parse(producer_src)));
store.insert_workspace(producer_path, parse(producer_src));
for f in 0..N_FILES {
let mut src = String::from(
"package Consumer;\nuse B qw(make_b);\nsub run {\n my $self = shift;\n my $b = make_b();\n",
);
for c in 0..M_CALLS {
match c % 5 {
0 => src.push_str(" $b->touch();\n"),
1 => src.push_str(" B->touch();\n"),
2 => src.push_str(" $self->touch();\n"),
3 => src.push_str(" $b->touch()->touch();\n"),
_ => src.push_str(" make_b()->touch();\n"),
}
}
src.push_str("}\n1;\n");
let path = PathBuf::from(format!("/tmp/bench_consumer_{}.pm", f));
let mut fa = parse(&src);
fa.enrich_imported_types_with_keys(Some(&idx));
store.insert_workspace(path, fa);
}
let target = TargetRef {
name: "touch".to_string(),
kind: TargetKind::Method { class: "B".to_string() },
method_classes: Vec::new(),
};
let _ = refs_to(&store, Some(&idx), &target, RoleMask::WORKSPACE);
let t0 = Instant::now();
let mut total_hits: usize = 0;
for _ in 0..R_REPS {
let refs = refs_to(&store, Some(&idx), &target, RoleMask::WORKSPACE);
total_hits += refs.len();
}
let elapsed = t0.elapsed();
let per_call = elapsed / (R_REPS as u32);
eprintln!(
"BENCH refs_to: {} files × {} calls × {} reps = {} hits in {:?} ({:?}/call avg)",
N_FILES, M_CALLS, R_REPS, total_hits, elapsed, per_call,
);
}
#[test]
fn refs_to_cross_file_chain_hop_post_enrichment() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let producer_src = r#"
package P;
use Exporter 'import';
our @EXPORT_OK = qw(makeP);
sub new { return bless {}, shift }
sub makeP { return P->new }
sub makeFoo { return P->new }
sub ping { 1 }
1;
"#;
let consumer_src = r#"
use P qw(makeP);
my $x = makeP();
$x->makeFoo()->ping();
1;
"#;
let producer_path = PathBuf::from("/tmp/chain_xfile_p.pm");
let consumer_path = PathBuf::from("/tmp/chain_xfile_consumer.pm");
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(producer_path.clone(), Arc::new(parse(producer_src)));
let mut consumer_fa = parse(consumer_src);
consumer_fa.enrich_imported_types_with_keys(Some(&idx));
let x_type = consumer_fa.inferred_type_via_bag(
"$x",
tree_sitter::Point { row: 4, column: 0 },
);
assert_eq!(
x_type.as_ref().and_then(|t| t.class_name()),
Some("P"),
"precondition: enrichment must type $$x as P; got {:?}",
x_type,
);
let store = FileStore::new();
store.insert_workspace(producer_path, parse(producer_src));
store.insert_workspace(consumer_path.clone(), consumer_fa);
let refs = refs_to(
&store,
Some(&idx),
&TargetRef {
name: "ping".to_string(),
kind: TargetKind::Method { class: "P".to_string() },
method_classes: Vec::new(),
},
RoleMask::WORKSPACE,
);
assert!(
refs.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &consumer_path)),
"refs_to(P::ping) missed the chain hop $$x->makeFoo()->ping(). \
The inner $$x->makeFoo() invocant ($$x) was typed only by \
cross-file enrichment; chain typing must compose through the \
bag at read time. hits: {:?}",
refs.iter().map(|r| (&r.key, r.span.start.row)).collect::<Vec<_>>(),
);
}
#[test]
fn find_highlights_cross_file_chain_hop_post_enrichment() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let producer_src = r#"
package P;
use Exporter 'import';
our @EXPORT_OK = qw(makeP);
sub new { return bless {}, shift }
sub makeP { return P->new }
sub makeFoo { return P->new }
sub ping { 1 }
1;
"#;
let consumer_src = r#"
use P qw(makeP);
my $x = makeP();
$x->makeFoo()->ping();
$x->makeFoo()->ping();
1;
"#;
let producer_path = PathBuf::from("/tmp/highlights_chain_p.pm");
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(producer_path, Arc::new(parse(producer_src)));
let mut consumer_fa = parse(consumer_src);
consumer_fa.enrich_imported_types_with_keys(Some(&idx));
let highlights = consumer_fa.find_highlights(
tree_sitter::Point { row: 3, column: 18 },
Some(&idx));
assert_eq!(
highlights.len(),
2,
"find_highlights should match both $$x->makeFoo()->ping() sites \
once chain typing through cross-file enrichment is threaded \
via module_index. got {:?}",
highlights,
);
}
#[test]
fn refs_to_handler_fans_out_across_files() {
let store = FileStore::new();
let path_reg = PathBuf::from("/tmp/resolve_minion_reg.pm");
let path_call = PathBuf::from("/tmp/resolve_minion_call.pm");
let fa_reg = parse(
"package App::Tasks;\nuse Minion;\n\
sub setup ($minion) {\n $minion->add_task('send_email' => sub ($job, $to) { 1 });\n}\n1;\n",
);
store.insert_workspace(path_reg.clone(), fa_reg);
let fa_call = parse(
"package App::Caller;\nuse Minion;\n\
sub fire ($minion) {\n $minion->enqueue('send_email' => ['a@b']);\n}\n1;\n",
);
store.insert_workspace(path_call.clone(), fa_call);
let results = refs_to(
&store,
None,
&TargetRef {
name: "send_email".to_string(),
kind: TargetKind::Handler {
owner: crate::file_analysis::HandlerOwner::Class("Minion".to_string()),
name: "send_email".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results
.iter()
.any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_reg)),
"expected the add_task registration in the registry file, got {:?}",
results,
);
assert!(
results
.iter()
.any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_call)),
"expected the cross-file enqueue caller, got {:?}",
results,
);
}
#[test]
fn refs_to_handler_finds_dispatch_in_unenriched_cross_file_subclass() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let store = FileStore::new();
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
let minion_sub_path = PathBuf::from("/tmp/rg_my_minion.pm");
let minion_sub_fa = parse("package My::Minion;\nuse parent 'Minion';\nsub new { bless {}, shift }\n1;\n");
idx.register_workspace_module(minion_sub_path, Arc::new(minion_sub_fa));
let path_reg = PathBuf::from("/tmp/rg_minion_reg.pm");
let fa_reg = parse(
"package App::Tasks;\nuse Minion;\n\
sub setup ($minion) {\n $minion->add_task('send_email' => sub ($job, $to) { 1 });\n}\n1;\n",
);
store.insert_workspace(path_reg.clone(), fa_reg);
let path_call = PathBuf::from("/tmp/rg_minion_call.pm");
let fa_call = parse(
"package App::Worker;\n\
sub fire {\n my $self = shift;\n my $minion = My::Minion->new;\n $minion->enqueue('send_email' => ['a@b']);\n}\n1;\n",
);
assert!(
!fa_call.refs.iter().any(|r|
matches!(&r.kind, crate::file_analysis::RefKind::DispatchCall { .. })),
"precondition: the caller must have NO materialized DispatchCall ref",
);
store.insert_workspace(path_call.clone(), fa_call);
let results = refs_to(
&store,
Some(&idx),
&TargetRef {
name: "send_email".to_string(),
kind: TargetKind::Handler {
owner: crate::file_analysis::HandlerOwner::Class("Minion".to_string()),
name: "send_email".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_reg)),
"expected the add_task registration; got {:?}",
results,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_call)),
"expected the cross-file enqueue call-site in the unenriched subclass \
worker to surface via query-time gated resolution; got {:?}",
results,
);
}
#[test]
fn refs_to_fans_runtime_exported_sub_to_consumer() {
let store = FileStore::new();
let path_def = PathBuf::from("/tmp/runtime_export_def.pm");
let path_use = PathBuf::from("/tmp/runtime_export_use.pm");
let fa_def = parse(
"package Sugar::Sub;\n\
use Sub::Exporter -setup => { exports => [qw/sweeten/] };\n\
sub sweeten { 42 }\n1;\n",
);
store.insert_workspace(path_def.clone(), fa_def);
let fa_use = parse(
"package Consumer;\n\
use Sugar::Sub qw/sweeten/;\n\
sub run { sweeten(); }\n1;\n",
);
store.insert_workspace(path_use.clone(), fa_use);
let results = refs_to(
&store,
None,
&TargetRef {
name: "sweeten".to_string(),
kind: TargetKind::Sub { package: Some("Sugar::Sub".to_string()) },
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_def)
&& r.access == AccessKind::Declaration),
"expected declaration of sweeten in the exporting package; got {:?}",
results,
);
assert!(
results.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &path_use)),
"expected the consumer's import/call of sweeten to fan out; got {:?}",
results,
);
}
#[test]
fn references_cross_file_sub_fans_out_and_stays_package_scoped() {
let store = FileStore::new();
let def = PathBuf::from("/tmp/xsub_def.pm");
let caller1 = PathBuf::from("/tmp/xsub_c1.pm");
let caller2 = PathBuf::from("/tmp/xsub_c2.pm");
let decoy = PathBuf::from("/tmp/xsub_decoy.pm");
store.insert_workspace(
def.clone(),
parse("package TaskInfo;\nuse Exporter 'import';\nour @EXPORT_OK = qw/info_to_task/;\nsub info_to_task { 1 }\n1;\n"),
);
store.insert_workspace(
caller1.clone(),
parse("package TaskProxy;\nuse TaskInfo qw/info_to_task/;\nsub run { info_to_task(); }\n1;\n"),
);
store.insert_workspace(
caller2.clone(),
parse("use TaskInfo qw/info_to_task/;\ninfo_to_task();\n1;\n"),
);
store.insert_workspace(
decoy.clone(),
parse("package Other;\nsub info_to_task { 99 }\nsub use_it { info_to_task(); }\n1;\n"),
);
let target = TargetRef {
name: "info_to_task".to_string(),
kind: TargetKind::Sub { package: Some("TaskInfo".to_string()) },
method_classes: Vec::new(),
};
let refs = refs_to(&store, None, &target, RoleMask::EDITABLE);
let hit = |p: &PathBuf| refs.iter().any(|r| matches!(&r.key, FileKey::Path(x) if x == p));
assert!(hit(&def), "missed TaskInfo def. hits: {:?}", refs);
assert!(hit(&caller1), "missed TaskProxy caller. hits: {:?}", refs);
assert!(hit(&caller2), "missed top-level caller. hits: {:?}", refs);
assert!(
!hit(&decoy),
"cross-linked Other::info_to_task (unrelated package). hits: {:?}",
refs,
);
}
#[test]
fn references_cross_file_method_matches_inheriting_invocant() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let role_src = "package Role::REST;\nsub success { 1 }\n1;\n";
let child_src = "package Ctrl;\nuse parent 'Role::REST';\nsub find ($c) { $c->success(); }\n1;\n";
let decoy_src = "package Loner;\nsub new { bless {}, shift }\nsub success { 0 }\nsub go { my $x = Loner->new; $x->success(); }\n1;\n";
let role_path = PathBuf::from("/tmp/inh_role.pm");
let child_path = PathBuf::from("/tmp/inh_child.pm");
let decoy_path = PathBuf::from("/tmp/inh_decoy.pm");
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(role_path.clone(), Arc::new(parse(role_src)));
idx.register_workspace_module(child_path.clone(), Arc::new(parse(child_src)));
let store = FileStore::new();
store.insert_workspace(role_path.clone(), parse(role_src));
store.insert_workspace(child_path.clone(), parse(child_src));
store.insert_workspace(decoy_path.clone(), parse(decoy_src));
let target = TargetRef {
name: "success".to_string(),
kind: TargetKind::Method { class: "Role::REST".to_string() },
method_classes: Vec::new(),
};
let refs = refs_to(&store, Some(&idx), &target, RoleMask::EDITABLE);
let hit = |p: &PathBuf| refs.iter().any(|r| matches!(&r.key, FileKey::Path(x) if x == p));
assert!(hit(&role_path), "missed Role::REST::success decl. hits: {:?}", refs);
assert!(
hit(&child_path),
"missed $c->success() in child controller (inherited from Role::REST). hits: {:?}",
refs,
);
assert!(
!hit(&decoy_path),
"cross-linked Loner::success (unrelated class, own method). hits: {:?}",
refs,
);
}
#[test]
fn references_mask_scopes_to_editable_for_project_symbols() {
let store = FileStore::new();
let def = PathBuf::from("/tmp/mask_def.pm");
store.insert_workspace(
def.clone(),
parse("package Proj;\nsub thing { 1 }\n1;\n"),
);
let in_ws = TargetRef {
name: "thing".to_string(),
kind: TargetKind::Sub { package: Some("Proj".to_string()) },
method_classes: Vec::new(),
};
assert_eq!(
references_mask_for(&store, None, &in_ws).bits(),
RoleMask::EDITABLE.bits(),
"project-declared sub should scope to EDITABLE",
);
let dep_only = TargetRef {
name: "nowhere".to_string(),
kind: TargetKind::Sub { package: Some("CPAN::Thing".to_string()) },
method_classes: Vec::new(),
};
assert_eq!(
references_mask_for(&store, None, &dep_only).bits(),
RoleMask::VISIBLE.bits(),
"symbol with no editable decl should widen to VISIBLE",
);
}
#[test]
fn rename_base_method_includes_child_call_sites() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let base_src = r#"
package Base;
sub new { bless {}, shift }
sub ping { "pong" }
1;
"#;
let child_src = r#"
package Child;
use parent 'Base';
1;
"#;
let consumer_src = r#"
package Consumer;
use Child;
my $c = Child->new;
$c->ping;
1;
"#;
let decoy_src = r#"
package Decoy;
sub new { bless {}, shift }
sub ping { "decoy" }
my $d = Decoy->new;
$d->ping;
1;
"#;
let base_path = PathBuf::from("/tmp/rename_base.pm");
let child_path = PathBuf::from("/tmp/rename_child.pm");
let consumer_path = PathBuf::from("/tmp/rename_consumer.pm");
let decoy_path = PathBuf::from("/tmp/rename_decoy.pm");
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(base_path.clone(), Arc::new(parse(base_src)));
idx.register_workspace_module(child_path.clone(), Arc::new(parse(child_src)));
let mut consumer_fa = parse(consumer_src);
consumer_fa.enrich_imported_types_with_keys(Some(&idx));
let store = FileStore::new();
store.insert_workspace(base_path.clone(), parse(base_src));
store.insert_workspace(child_path.clone(), parse(child_src));
store.insert_workspace(consumer_path.clone(), consumer_fa);
store.insert_workspace(decoy_path.clone(), parse(decoy_src));
let target = TargetRef {
name: "ping".to_string(),
kind: TargetKind::Method { class: "Base".to_string() },
method_classes: Vec::new(),
};
let locs = refs_to(&store, Some(&idx), &target, RoleMask::EDITABLE);
let hits: Vec<(&str, usize)> = locs.iter().map(|r| {
let fname = match &r.key {
FileKey::Path(p) => p.file_name().unwrap().to_str().unwrap(),
FileKey::Url(_) => "url",
};
(fname, r.span.start.row)
}).collect();
assert!(
hits.iter().any(|(f, _)| *f == "rename_base.pm"),
"rename missed Base::ping declaration: {:?}", hits,
);
assert!(
hits.iter().any(|(f, _)| *f == "rename_consumer.pm"),
"rename missed Consumer's $$c->ping call (inherited from Base via Child): {:?}", hits,
);
assert!(
!hits.iter().any(|(f, _)| *f == "rename_decoy.pm"),
"rename wrongly included Decoy::ping (unrelated class): {:?}", hits,
);
}
#[test]
fn rename_does_not_edit_dep_files() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let dep_src = r#"
package Base;
sub new { bless {}, shift }
sub ping { "pong" }
1;
"#;
let dep_path = PathBuf::from("/tmp/rename_dep_base.pm");
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(dep_path.clone(), Arc::new(parse(dep_src)));
let consumer_src = r#"
package Consumer;
my $b = Base->new;
$b->ping;
1;
"#;
let consumer_path = PathBuf::from("/tmp/rename_dep_consumer.pm");
let store = FileStore::new();
store.insert_workspace(consumer_path.clone(), parse(consumer_src));
let target = TargetRef {
name: "ping".to_string(),
kind: TargetKind::Method { class: "Base".to_string() },
method_classes: Vec::new(),
};
let editable_locs = refs_to(&store, Some(&idx), &target, RoleMask::EDITABLE);
for loc in &editable_locs {
assert!(
!matches!(&loc.key, FileKey::Path(p) if p == &dep_path),
"rename emitted an edit for the dep file (read-only): {:?}", editable_locs,
);
}
let visible_locs = refs_to(&store, Some(&idx), &target, RoleMask::VISIBLE);
assert!(
visible_locs.iter().any(|r| matches!(&r.key, FileKey::Path(p) if p == &dep_path)),
"sanity: VISIBLE should see dep file's ping decl: {:?}", visible_locs,
);
}
#[test]
fn rename_and_references_agree_on_same_base_method_target() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let base_src = r#"
package Greeter;
sub new { bless {}, shift }
sub hello { "hi" }
1;
"#;
let child_src = r#"
package ChildGreeter;
use parent 'Greeter';
1;
"#;
let consumer_src = r#"
package Main;
use ChildGreeter;
my $g = ChildGreeter->new;
$g->hello;
1;
"#;
let base_path = PathBuf::from("/tmp/agree_greeter.pm");
let child_path = PathBuf::from("/tmp/agree_child.pm");
let consumer_path = PathBuf::from("/tmp/agree_consumer.pm");
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(base_path.clone(), Arc::new(parse(base_src)));
idx.register_workspace_module(child_path.clone(), Arc::new(parse(child_src)));
let mut consumer_fa = parse(consumer_src);
consumer_fa.enrich_imported_types_with_keys(Some(&idx));
let store = FileStore::new();
store.insert_workspace(base_path.clone(), parse(base_src));
store.insert_workspace(child_path.clone(), parse(child_src));
store.insert_workspace(consumer_path.clone(), consumer_fa);
let target = TargetRef {
name: "hello".to_string(),
kind: TargetKind::Method { class: "Greeter".to_string() },
method_classes: Vec::new(),
};
let ref_mask = references_mask_for(&store, Some(&idx), &target);
assert_eq!(ref_mask.bits(), RoleMask::EDITABLE.bits(),
"precondition: references_mask_for should be EDITABLE when def is in workspace");
let rename_locs = refs_to(&store, Some(&idx), &target, RoleMask::EDITABLE);
let ref_locs = refs_to(&store, Some(&idx), &target, ref_mask);
let to_set = |v: &[crate::resolve::RefLocation]| -> std::collections::BTreeSet<(String, usize)> {
v.iter().map(|r| {
let p = match &r.key {
FileKey::Path(p) => p.display().to_string(),
FileKey::Url(u) => u.to_string(),
};
(p, r.span.start.row)
}).collect()
};
let rename_set = to_set(&rename_locs);
let ref_set = to_set(&ref_locs);
assert_eq!(
rename_set, ref_set,
"rename and references disagree on target set for Greeter::hello\n\
rename-only: {:?}\nrefs-only: {:?}",
rename_set.difference(&ref_set).collect::<Vec<_>>(),
ref_set.difference(&rename_set).collect::<Vec<_>>(),
);
assert!(
rename_set.iter().any(|(f, _)| f.contains("agree_consumer")),
"both rename and references must include consumer's $$g->hello call: {:?}",
rename_set,
);
}
#[test]
fn rename_from_child_call_site_includes_inherited_base_declaration() {
use crate::file_analysis::RenameKind;
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let base_src = "package BaseWorker;\nsub new { bless {}, shift }\nsub process { 1 }\n1;\n";
let child_src = "package MyWorker;\nuse parent 'BaseWorker';\n1;\n";
let script_src = "use MyWorker;\nmy $worker = MyWorker->new;\n$worker->process();\n";
let base_path = PathBuf::from("/tmp/cs_base.pm");
let child_path = PathBuf::from("/tmp/cs_child.pm");
let script_path = PathBuf::from("/tmp/cs_script.pl");
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(base_path.clone(), Arc::new(parse(base_src)));
idx.register_workspace_module(child_path.clone(), Arc::new(parse(child_src)));
let mut script_fa = parse(script_src);
script_fa.enrich_imported_types_with_keys(Some(&idx));
let call_point = tree_sitter::Point { row: 2, column: 9 };
let rk = script_fa.rename_kind_at(call_point, Some(&idx));
assert!(
matches!(&rk, Some(RenameKind::Method { class, .. }) if class == "MyWorker"),
"precondition: call-site rename_kind_at should resolve invocant class MyWorker, got {:?}",
rk,
);
let target = TargetRef::from_rename_kind(rk.unwrap(), &script_fa, Some(&idx))
.expect("Method maps to a target");
assert!(
target.method_classes.iter().any(|c| c == "BaseWorker"),
"chain must reach the defining ancestor BaseWorker, got {:?}",
target.method_classes,
);
let store = FileStore::new();
store.insert_workspace(base_path.clone(), parse(base_src));
store.insert_workspace(child_path.clone(), parse(child_src));
store.insert_workspace(script_path.clone(), script_fa);
let locs = refs_to(&store, Some(&idx), &target, RoleMask::EDITABLE);
let hit = |p: &PathBuf| locs.iter().any(|r| matches!(&r.key, FileKey::Path(x) if x == p));
assert!(
hit(&base_path),
"rename from child call site dropped the BaseWorker::process declaration edit. hits: {:?}",
locs,
);
assert!(
hit(&script_path),
"rename from child call site missed the $worker->process() call edit. hits: {:?}",
locs,
);
}
#[test]
fn test_resolve_symbol_kinds() {
let src = "\
package Counter;
sub new { my ($class) = @_; return bless { count => 0 }, $class }
sub bump { my ($self) = @_; $self->{count}++; my $local = 1; return $local }
1;
";
let fa = parse(src);
let at = |row, col| resolve_symbol(&fa, tree_sitter::Point { row, column: col }, None);
match at(2, 5) {
Some(ResolvedTarget::Target(t)) => {
assert_eq!(t.name, "bump");
assert!(
matches!(&t.kind, TargetKind::Sub { package: Some(p) } if p == "Counter"),
"expected Sub scoped to Counter, got {:?}",
t.kind,
);
assert!(t.supports_cross_file_rename());
}
other => panic!("expected callable target for bump decl, got {:?}", other),
}
match at(0, 9) {
Some(ResolvedTarget::Target(t)) => {
assert!(matches!(t.kind, TargetKind::Package));
assert!(t.supports_cross_file_rename());
}
other => panic!("expected Package target, got {:?}", other),
}
let local_col = src.lines().nth(2).unwrap().find("$local").unwrap() + 1;
assert!(
matches!(at(2, local_col), Some(ResolvedTarget::Local)),
"expected Local for lexical $local, got {:?}",
at(2, local_col),
);
}
#[test]
fn test_resolve_symbol_owned_hash_key() {
let src = "\
package Widget;
use Moo;
has size => (is => 'ro');
sub describe { my ($self) = @_; return $self->{size} }
1;
";
let fa = parse(src);
let col = src.lines().nth(3).unwrap().find("{size}").unwrap() + 1;
match resolve_symbol(&fa, tree_sitter::Point { row: 3, column: col }, None) {
Some(ResolvedTarget::Target(t)) => {
assert_eq!(t.name, "size");
assert!(
matches!(&t.kind, TargetKind::HashKeyOfClass(c) if c == "Widget"),
"expected HashKeyOfClass(Widget), got {:?}",
t.kind,
);
assert!(!t.supports_cross_file_rename());
}
other => panic!("expected owned hash-key target, got {:?}", other),
}
}
#[test]
fn test_field_group_unions_across_files() {
let store = FileStore::new();
let point_path = PathBuf::from("/tmp/fieldgroup_point.pm");
let user_path = PathBuf::from("/tmp/fieldgroup_user.pl");
let point_src = "\
use v5.38;
class Point {
field $x :param :reader;
method magnitude () { return $x * $x; }
}
1;
";
let user_src = "\
use Point;
my $p = Point->new(x => 3);
my $val = $p->x;
";
let point_fa = parse(point_src);
let user_fa = parse(user_src);
store.insert_workspace(point_path.clone(), point_fa);
store.insert_workspace(user_path.clone(), user_fa);
let origin_fa = store.workspace_raw().get(&point_path).unwrap().value().clone();
let resolved = resolve_symbol(&origin_fa, tree_sitter::Point { row: 2, column: 11 }, None)
.expect("field decl resolves");
let ResolvedTarget::Group { local_spans, pinned_spans, members } = resolved else {
panic!("expected Group, got {:?}", resolved);
};
assert!(pinned_spans.is_empty(), "local mint has no pinned spans");
assert!(!local_spans.is_empty(), "field var spellings present");
assert_eq!(members.len(), 2, "reader + ctor-key members: {:?}", members);
let locs = group_refs(
&store,
None,
&FileKey::Path(point_path.clone()),
&local_spans,
&pinned_spans,
&members,
None,
);
let in_user: Vec<_> = locs
.iter()
.filter(|l| matches!(&l.key, FileKey::Path(p) if p == &user_path))
.map(|l| (l.span.start.row, l.span.start.column))
.collect();
assert!(
in_user.contains(&(1, 19)),
"consumer ctor key `x` included; user-file hits: {:?}",
in_user,
);
assert!(
in_user.contains(&(2, 14)),
"consumer reader call `->x` included; user-file hits: {:?}",
in_user,
);
}
#[test]
fn test_consumer_cursor_mints_group_from_class_analysis() {
let point_src = "\
use v5.38;
class Point {
field $x :param :reader;
method magnitude () { return $x * $x; }
}
1;
";
let idx = crate::module_index::ModuleIndex::new_for_test();
let class_path = PathBuf::from("/tmp/grp_mint_point.pm");
idx.insert_cache(
"Point",
Some(std::sync::Arc::new(crate::module_index::CachedModule::new(
class_path.clone(),
std::sync::Arc::new(parse(point_src)),
))),
);
let consumer = parse("use Point;\nmy $p = Point->new(x => 3);\nmy $v = $p->x;\n");
let resolved = resolve_symbol(&consumer, tree_sitter::Point { row: 1, column: 19 }, Some(&idx))
.expect("consumer key resolves");
let ResolvedTarget::Group { local_spans, pinned_spans, members } = resolved else {
panic!("expected Group from consumer key, got {:?}", resolved);
};
assert!(local_spans.is_empty(), "remote mint: no origin spans");
assert_eq!(members.len(), 2, "reader + ctor-key members");
assert!(
pinned_spans.iter().all(|(p, _)| p == &class_path),
"pinned to the class file: {:?}",
pinned_spans,
);
let pinned_rows: Vec<usize> = pinned_spans.iter().map(|(_, s)| s.start.row).collect();
assert!(
pinned_rows.contains(&2) && pinned_rows.contains(&3),
"field decl + body use pinned: {:?}",
pinned_rows,
);
let resolved = resolve_symbol(&consumer, tree_sitter::Point { row: 2, column: 12 }, Some(&idx))
.expect("consumer accessor resolves");
assert!(
matches!(resolved, ResolvedTarget::Group { ref pinned_spans, .. } if !pinned_spans.is_empty()),
"accessor-call cursor mints the remote group, got {:?}",
resolved,
);
}
#[test]
fn test_group_rename_rederives_mapped_members_cross_file() {
let store = FileStore::new();
let class_path = PathBuf::from("/tmp/grp_map_widget.pm");
let user_path = PathBuf::from("/tmp/grp_map_user.pl");
store.insert_workspace(
class_path.clone(),
parse("package Widget;\nuse Moo;\nhas size => (is => 'ro', predicate => 1);\n1;\n"),
);
store.insert_workspace(
user_path.clone(),
parse("use Widget;\nmy $w = Widget->new(size => 3);\nprint $w->size if $w->has_size;\n"),
);
let class_fa = store.workspace_raw().get(&class_path).unwrap().value().clone();
let resolved = resolve_symbol(&class_fa, tree_sitter::Point { row: 2, column: 4 }, None)
.expect("attr decl resolves");
let ResolvedTarget::Group { local_spans, pinned_spans, members } = resolved else {
panic!("expected Group, got {:?}", resolved);
};
let edits = group_rename_edits(
&store,
None,
&FileKey::Path(class_path.clone()),
&local_spans,
&pinned_spans,
&members,
"extent",
);
let user_edits: Vec<_> = edits
.iter()
.filter(|(l, _)| matches!(&l.key, FileKey::Path(p) if p == &user_path))
.map(|(l, t)| (l.span.start.row, l.span.start.column, t.clone()))
.collect();
assert!(
user_edits.contains(&(2, 22, "has_extent".to_string())),
"consumer predicate call re-derived; user edits: {:?}",
user_edits,
);
assert!(
user_edits.iter().any(|(r, _, t)| *r == 1 && t == "extent"),
"consumer ctor key renamed bare; user edits: {:?}",
user_edits,
);
}
#[test]
fn test_internal_slot_pokes_join_group_cross_file() {
let store = FileStore::new();
let class_path = PathBuf::from("/tmp/grp_slot_widget.pm");
let sub_path = PathBuf::from("/tmp/grp_slot_subclass.pm");
store.insert_workspace(
class_path.clone(),
parse("package Widget;\nuse Moo;\nhas size => (is => 'rw');\n1;\n"),
);
store.insert_workspace(
sub_path.clone(),
parse("package Gadget;\nuse Moo;\nextends 'Widget';\nsub poke { my ($self) = @_; return $self->{size}; }\n1;\n"),
);
let class_fa = store.workspace_raw().get(&class_path).unwrap().value().clone();
let resolved = resolve_symbol(&class_fa, tree_sitter::Point { row: 2, column: 4 }, None)
.expect("attr decl resolves");
let ResolvedTarget::Group { local_spans, pinned_spans, members } = resolved else {
panic!("expected Group, got {:?}", resolved);
};
assert!(
members.iter().any(|m| matches!(m.target.kind, TargetKind::InternalHashKey { .. })),
"internal-key member minted: {:?}",
members,
);
let edits = group_rename_edits(
&store,
None,
&FileKey::Path(class_path.clone()),
&local_spans,
&pinned_spans,
&members,
"extent",
);
assert!(
edits.iter().any(|(l, t)| {
matches!(&l.key, FileKey::Path(p) if p == &sub_path) && t == "extent"
}),
"subclass slot poke renamed; edits: {:?}",
edits,
);
}
#[test]
fn test_implementations_of_role_requires_fans_out_to_composers() {
use crate::module_index::{CachedModule, ModuleIndex};
use std::sync::Arc;
let idx = ModuleIndex::new_for_test();
let insert = |name: &str, src: &str| {
let analysis = Arc::new(parse(src));
idx.insert_cache(
name,
Some(Arc::new(CachedModule::new(
PathBuf::from(format!("/fake/{}.pm", name.replace("::", "/"))),
analysis,
))),
);
};
insert("My::Role", "package My::Role;\nuse Moo::Role;\nrequires 'fetch';\n1;\n");
insert(
"My::Composer",
"package My::Composer;\nuse Moo;\nwith 'My::Role';\nsub fetch { 42 }\n1;\n",
);
insert(
"My::SubRole",
"package My::SubRole;\nuse Moo::Role;\nwith 'My::Role';\nrequires 'fetch';\n1;\n",
);
insert("My::Deep", "package My::Deep;\nuse Moo;\nwith 'My::SubRole';\nsub fetch { 7 }\n1;\n");
let target = TargetRef {
name: "fetch".to_string(),
kind: TargetKind::Method { class: "My::Role".to_string() },
method_classes: Vec::new(),
};
let origin = parse("package Probe;\n1;\n");
let results = implementations_of(&origin, Some(&idx), &target);
let files: Vec<String> = results
.iter()
.map(|r| match &r.key {
FileKey::Path(p) => p.display().to_string(),
FileKey::Url(u) => u.to_string(),
})
.collect();
assert_eq!(
files,
vec!["/fake/My/Composer.pm", "/fake/My/Deep.pm"],
"direct + transitive composer defs, sorted; the SubRole re-requires marker excluded",
);
let pkg_target = TargetRef::new("My::Role".to_string(), TargetKind::Package);
assert!(implementations_of(&origin, Some(&idx), &pkg_target).is_empty());
}