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()),
},
},
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 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(),
},
},
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(),
},
},
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(),
},
},
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(),
},
},
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_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(),
},
},
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(),
},
},
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, Some(&tree), 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, Some(&tree), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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 },
},
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, Some(&tree), None);
let gd = fa.find_definition(hi_call, Some(&tree), Some(src.as_bytes()), None);
let target = match kind.as_ref() {
Some(RenameKind::Function { name, package }) => TargetRef {
name: name.clone(),
kind: TargetKind::Sub {
package: package.clone(),
},
},
Some(RenameKind::Method { name, class }) => TargetRef {
name: name.clone(),
kind: TargetKind::Method {
class: class.clone(),
},
},
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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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 },
},
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 },
},
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(),
},
},
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(),
},
},
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(),
},
},
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_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 },
},
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() },
};
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 },
None,
None,
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() },
},
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() },
};
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() },
},
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 },
None,
None,
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,
);
}