use super::*;
use std::path::PathBuf;
use std::sync::Arc;
use tree_sitter::{Parser, Point, Tree};
use crate::file_analysis::ParametricType;
use crate::file_store::{FileKey, FileStore};
use crate::module_index::ModuleIndex;
use crate::resolve::{refs_to, RoleMask, TargetKind, TargetRef};
fn parse(source: &str) -> FileAnalysis {
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())
}
fn parse_with_tree(source: &str) -> (FileAnalysis, Tree) {
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let fa = crate::builder::build(&tree, source.as_bytes());
(fa, tree)
}
fn point_at(source: &str, needle: &str) -> Point {
let byte = source
.find(needle)
.unwrap_or_else(|| panic!("needle {:?} not in source:\n{}", needle, source));
let row = source[..byte].matches('\n').count();
let col = byte - source[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0);
Point::new(row, col)
}
const USERS_RESULT: &str = "package Schema::Result::Users;
use base 'DBIx::Class::Core';
__PACKAGE__->add_columns(
name => { data_type => 'varchar' },
email => { data_type => 'varchar' },
);
1;
";
const NAME_COL_DEF_ROW: usize = 3;
const EMAIL_COL_DEF_ROW: usize = 4;
#[test]
fn parametric_resultset_carries_base_and_row() {
let src = "
package main;
my $schema;
$schema->resultset('Schema::Result::Users');
";
let fa = parse(src);
let rs_idx = fa
.refs
.iter()
.position(|r| {
matches!(r.kind, RefKind::MethodCall { .. }) && r.target_name == "resultset"
})
.expect("resultset MethodCall ref");
let ty = fa
.method_call_return_type_via_bag(rs_idx, None)
.expect("resultset's return type is bag-resolvable");
match ty {
InferredType::Parametric(ParametricType::ResultSet { base, row }) => {
assert_eq!(base, "DBIx::Class::ResultSet");
assert_eq!(row, "Schema::Result::Users");
}
other => panic!(
"expected `Parametric(ResultSet {{ base, row }})`; got {:?}",
other
),
}
}
#[test]
fn goto_def_through_resultset_chain() {
let src = format!(
"{}
package main;
my $schema;
$schema->resultset('Schema::Result::Users')->search({{ name => 'X' }});
",
USERS_RESULT,
);
let (fa, _tree) = parse_with_tree(&src);
let pt = point_at(&src, "name => 'X'");
let def = fa.find_definition(pt, None);
assert_eq!(
def.map(|s| s.start.row),
Some(NAME_COL_DEF_ROW),
"cursor on `name` in $$schema->resultset('Users')->search({{ name => ... }}) \
should land on the `name` column def in add_columns",
);
}
#[test]
fn goto_def_via_rebound_resultset_variable() {
let src = format!(
"{}
package main;
my $schema;
my $rs = $schema->resultset('Schema::Result::Users');
$rs->search({{ email => 'x@y' }});
",
USERS_RESULT,
);
let (fa, _tree) = parse_with_tree(&src);
let pt = point_at(&src, "email => 'x@y'");
let def = fa.find_definition(pt, None);
assert_eq!(
def.map(|s| s.start.row),
Some(EMAIL_COL_DEF_ROW),
"the parametric type must thread through `my $$rs = …->resultset(…)`; \
got def: {:?}",
def,
);
}
#[test]
fn goto_def_through_chained_search() {
let src = format!(
"{}
package main;
my $schema;
$schema->resultset('Schema::Result::Users')->search({{ name => 'A' }})->search({{ email => 'B' }});
",
USERS_RESULT,
);
let (fa, _tree) = parse_with_tree(&src);
let pt = point_at(&src, "email => 'B'");
let def = fa.find_definition(pt, None);
assert_eq!(
def.map(|s| s.start.row),
Some(EMAIL_COL_DEF_ROW),
"chained search must keep the parametric type — `email` on the second \
hop must still resolve against Users",
);
}
#[test]
fn goto_def_through_find() {
let src = format!(
"{}
package main;
my $schema;
$schema->resultset('Schema::Result::Users')->find({{ name => 'X' }});
",
USERS_RESULT,
);
let (fa, _tree) = parse_with_tree(&src);
let pt = point_at(&src, "name => 'X'");
let def = fa.find_definition(pt, None);
assert_eq!(
def.map(|s| s.start.row),
Some(NAME_COL_DEF_ROW),
"`->find` must thread the parametric type the same way `->search` does",
);
}
#[test]
fn receiver_method_dispatch_not_redirected_to_row_class() {
let src = format!(
"{}
package main;
my $schema;
$schema->resultset('Schema::Result::Users')->all();
",
USERS_RESULT,
);
let (fa, _tree) = parse_with_tree(&src);
let pt = point_at(&src, "all()");
let def = fa.find_definition(pt, None);
if let Some(span) = def {
assert!(
span.start.row > EMAIL_COL_DEF_ROW + 1,
"`->all()` must not resolve to a position inside the Users \
row-class definition. got span at row {}",
span.start.row,
);
}
}
#[test]
fn goto_def_for_unknown_column_returns_nothing() {
let src = format!(
"{}
package main;
my $schema;
$schema->resultset('Schema::Result::Users')->search({{ definitely_not_a_column => 1 }});
",
USERS_RESULT,
);
let (fa, _tree) = parse_with_tree(&src);
let pt = point_at(&src, "definitely_not_a_column");
let def = fa.find_definition(pt, None);
assert!(
def.is_none(),
"unknown column must not resolve to anything; got: {:?}",
def,
);
}
#[test]
fn cross_file_resultset_column_resolution() {
let producer_src = USERS_RESULT;
let consumer_src = "use Schema::Result::Users;
package main;
my $schema;
$schema->resultset('Schema::Result::Users')->search({ name => 'X' });
1;
";
let producer_path = PathBuf::from("/tmp/parametric_users.pm");
let consumer_path = PathBuf::from("/tmp/parametric_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 store = FileStore::new();
store.insert_workspace(producer_path.clone(), parse(producer_src));
store.insert_workspace(consumer_path.clone(), consumer_fa);
let target = TargetRef {
name: "name".to_string(),
kind: TargetKind::HashKeyOfClass("Schema::Result::Users".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(HashKeyOfClass(Schema::Result::Users), name) must include the \
consumer's $$schema->resultset('…')->search({{ name => … }}) call site. \
the parametric type must compose through cross-file enrichment. hits: {:?}",
refs.iter().map(|r| (&r.key, r.span.start.row)).collect::<Vec<_>>(),
);
let producer_hit = refs
.iter()
.any(|r| matches!(&r.key, FileKey::Path(p) if p == &producer_path));
assert!(
producer_hit,
"refs_to must include the producer's `name` column def. hits: {:?}",
refs.iter().map(|r| (&r.key, r.span.start.row)).collect::<Vec<_>>(),
);
}
#[test]
fn method_dispatch_through_find_resolves_to_row_class() {
let src = format!(
"{}
package main;
my $schema;
my $name = $schema->resultset('Schema::Result::Users')->find(1)->name;
",
USERS_RESULT,
);
let (fa, _tree) = parse_with_tree(&src);
let pt = point_at(&src, "->name");
let pt = Point::new(pt.row, pt.column + 2);
let def = fa.find_definition(pt, None);
assert_eq!(
def.map(|s| s.start.row),
Some(NAME_COL_DEF_ROW),
"$$row->name where $$row = $$rs->find(...) must dispatch \
against the row class. Requires `find` to project \
Parametric{{ResultSet, [Users]}} → ClassName(Users) at \
the return-type. got: {:?}",
def,
);
}
#[test]
fn chained_method_return_dispatch_resolves_via_stamped_edge() {
let src = format!(
"{}
package main;
my $schema;
my $name = $schema->resultset('Schema::Result::Users')->find(1)->name;
",
USERS_RESULT,
);
let (fa, _tree) = parse_with_tree(&src);
let name_ref = fa
.refs
.iter()
.find(|r| matches!(&r.kind, RefKind::MethodCall { invocant, .. }
if invocant.ends_with("->find(1)"))
&& r.target_name == "name")
.expect("trailing ->name MethodCall ref");
assert!(
matches!(
&name_ref.resolved_method_target,
Some(crate::file_analysis::MethodTarget::Local { invocant_class, .. })
if invocant_class == "Schema::Result::Users"
),
"chained `$$rs->find(1)->name` must carry a stamped Local edge \
to the Row accessor (class Schema::Result::Users), proving \
resolution rides the edge not the deleted same-name fallback. \
got: {:?}",
name_ref.resolved_method_target,
);
let store = FileStore::new();
store.insert_workspace(PathBuf::from("/tmp/nav_chained_return.pm"), fa);
let refs = refs_to(
&store,
None,
&TargetRef {
name: "name".to_string(),
kind: TargetKind::Method {
class: "Schema::Result::Users".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let call_row = point_at(&src, "->name").row;
assert!(
refs.iter().any(|r| r.span.start.row == call_row),
"references for the Row `name` method must include the chained \
`->name` call site (row {call_row}). got: {:?}",
refs.iter().map(|r| r.span.start.row).collect::<Vec<_>>(),
);
}
#[test]
fn goto_def_offers_custom_resultset_method() {
let src = r#"
package Schema::Result::Users;
use base 'DBIx::Class::Core';
__PACKAGE__->add_columns(
name => { data_type => 'varchar' },
);
package Schema::ResultSet::Users;
use base 'DBIx::Class::ResultSet';
sub admins { my $self = shift; $self->search({ is_admin => 1 }) }
package main;
my $schema;
$schema->resultset('Schema::Result::Users')->admins;
"#;
let (fa, _tree) = parse_with_tree(src);
let pt = point_at(src, "->admins");
let pt = Point::new(pt.row, pt.column + 2);
let def = fa.find_definition(pt, None);
assert!(
def.is_some(),
"$$schema->resultset('Users')->admins must resolve to the \
custom resultset_class's `admins` method. got: {:?}. \
Requires discovering `*::ResultSet::Users` as the \
Parametric base instead of hard-coding `DBIx::Class::ResultSet`.",
def,
);
}
#[test]
fn unrelated_method_does_not_emit_parametric() {
let src = format!(
"{}
package main;
my $schema;
$schema->frobnicate('Schema::Result::Users')->search({{ name => 'X' }});
",
USERS_RESULT,
);
let (fa, _tree) = parse_with_tree(&src);
let pt = point_at(&src, "name => 'X'");
let def = fa.find_definition(pt, None);
assert!(
def.is_none(),
"`frobnicate('Users')` must not be treated as resultset; \
goto-def on the chained search arg should return None. got: {:?}",
def,
);
}
#[test]
fn const_folded_resultset_arg_resolves_columns() {
let src = "
package Schema::Result::Sner;
use base 'DBIx::Class::Core';
__PACKAGE__->add_columns(
name => { data_type => 'varchar' },
);
package main;
my $schema;
my $sner = 'Schema::Result::Sner';
$schema->resultset($sner)->search({ name => 'foo' });
";
let (fa, _tree) = parse_with_tree(src);
let pt = point_at(src, "name => 'foo'");
let def = fa.find_definition(pt, None);
assert!(
def.is_some(),
"const-folded `resultset($$sner)` must thread Parametric \
through to column completion. got: {:?}",
def,
);
assert_eq!(def.unwrap().start.row, 4);
}
#[test]
fn const_folded_resultset_find_to_row_method_dispatch() {
let src = "
package Schema::Result::Sner;
use base 'DBIx::Class::Core';
__PACKAGE__->add_columns(
name => { data_type => 'varchar' },
);
package main;
my $schema;
my $sner = 'Schema::Result::Sner';
my $row = $schema->resultset($sner)->find(1);
$row->name;
";
let (fa, _tree) = parse_with_tree(src);
let pt = point_at(src, "->name");
let pt = Point::new(pt.row, pt.column + 2);
let def = fa.find_definition(pt, None);
assert!(
def.is_some(),
"const-fold + find→row composition must let `$$row->name` \
resolve to the column accessor. got: {:?}",
def,
);
assert_eq!(def.unwrap().start.row, 4);
}
#[test]
fn mojo_helper_returning_resultset_composes_in_file() {
let src = "
package Schema::Result::Sner;
use base 'DBIx::Class::Core';
__PACKAGE__->add_columns(
name => { data_type => 'varchar' },
);
package MyApp;
use Mojo::Base 'Mojolicious';
my $schema;
my $app;
$app->helper(sner_r => sub {
my $sner = 'Schema::Result::Sner';
return $schema->resultset($sner);
});
package MyApp::Controller::X;
use Mojo::Base 'Mojolicious::Controller';
sub action {
my $c = shift;
$c->sner_r->search({ name => 'foo' });
}
1;
";
let (fa, _tree) = parse_with_tree(src);
let pt = point_at(src, "name => 'foo'");
let def = fa.find_definition(pt, None);
assert!(
def.is_some(),
"in-file helper composition (helper synth + return_via_edge \
+ Parametric body inference + parent-chain dispatch + row-\
class hash-key narrowing) must let `$$c->sner_r->search\
({{ name }})` resolve to Sner's column. got: {:?}",
def,
);
assert_eq!(def.unwrap().start.row, 4);
}
#[test]
fn mojo_helper_returning_resultset_via_rebound_coderef_composes() {
let src = "
package Schema::Result::Sner;
use base 'DBIx::Class::Core';
__PACKAGE__->add_columns(
name => { data_type => 'varchar' },
);
package MyApp;
use Mojo::Base 'Mojolicious';
my $schema;
my $app;
my $body = sub {
my $sner = 'Schema::Result::Sner';
return $schema->resultset($sner);
};
$app->helper(sner_r => $body);
package MyApp::Controller::X;
use Mojo::Base 'Mojolicious::Controller';
sub action {
my $c = shift;
$c->sner_r->search({ name => 'foo' });
}
1;
";
let (fa, _tree) = parse_with_tree(src);
let pt = point_at(src, "name => 'foo'");
let def = fa.find_definition(pt, None);
assert!(
def.is_some(),
"rebound-coderef helper must compose the same as a literal \
in-arg-slot helper: `my $$body = sub {{...}}; \
$$app->helper(name => $$body)` should let \
`$$c->sner_r->search({{ name }})` resolve to Sner's column. \
got: {:?}",
def,
);
assert_eq!(def.unwrap().start.row, 4);
}
#[test]
fn mojo_helper_returning_resultset_composes_cross_file() {
let producer_src = "
package Schema::Result::Sner;
use base 'DBIx::Class::Core';
__PACKAGE__->add_columns(
name => { data_type => 'varchar' },
);
package MyApp;
use Mojo::Base 'Mojolicious';
my $schema;
my $app;
$app->helper(sner_r => sub {
my $sner = 'Schema::Result::Sner';
return $schema->resultset($sner);
});
1;
";
let consumer_src = "
package MyApp::Controller::X;
use Mojo::Base 'Mojolicious::Controller';
sub action {
my $c = shift;
$c->sner_r->search({ name => 'foo' });
}
1;
";
let producer_path = PathBuf::from("/tmp/composition_producer.pm");
let consumer_path = PathBuf::from("/tmp/composition_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 store = FileStore::new();
store.insert_workspace(producer_path.clone(), parse(producer_src));
store.insert_workspace(consumer_path.clone(), consumer_fa);
let target = TargetRef {
name: "name".to_string(),
kind: TargetKind::HashKeyOfClass("Schema::Result::Sner".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,
"Mojo helper returning Parametric ResultSet must compose \
cross-file via the namespace bridge + edge-back-pointer + \
enrichment. hits: {:?}",
refs.iter().map(|r| (&r.key, r.span.start.row)).collect::<Vec<_>>(),
);
}
#[test]
fn mojo_helper_with_named_sub_reference_composes_cross_file() {
let producer_src = "
package Schema::Result::Sner;
use base 'DBIx::Class::Core';
__PACKAGE__->add_columns(
name => { data_type => 'varchar' },
);
package Producer;
my $schema;
sub build_rs {
my $sner = 'Schema::Result::Sner';
return $schema->resultset($sner);
}
package MyApp;
use Mojo::Base 'Mojolicious';
my $app;
$app->helper(sner_r => \\&Producer::build_rs);
1;
";
let consumer_src = "
package MyApp::Controller::X;
use Mojo::Base 'Mojolicious::Controller';
sub action {
my $c = shift;
$c->sner_r->search({ name => 'foo' });
}
1;
";
let producer_path = PathBuf::from("/tmp/named_ref_producer.pm");
let consumer_path = PathBuf::from("/tmp/named_ref_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 store = FileStore::new();
store.insert_workspace(producer_path.clone(), parse(producer_src));
store.insert_workspace(consumer_path.clone(), consumer_fa);
let target = TargetRef {
name: "name".to_string(),
kind: TargetKind::HashKeyOfClass("Schema::Result::Sner".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,
"Mojo helper with `\\&Foo::bar` named-sub reference must compose \
cross-file: the helper's return edge points at \
MethodOnClass{{class: Producer, name: build_rs}}, the bag \
chases that through module_index to find the row class, and \
column-key resolution finds the call site. hits: {:?}",
refs.iter().map(|r| (&r.key, r.span.start.row)).collect::<Vec<_>>(),
);
}
#[test]
fn cross_file_inherited_fluent_chain_types_lexical() {
let parent_src = "
package Minion;
use Mojo::Base 'Mojo::EventEmitter';
has app => sub { 1 }, weak => 1;
has 'backend';
sub enqueue { my $self = shift; return 1; }
1;
";
let child_src = "
package Clove::Minion;
use Mojo::Base 'Minion';
sub class_for_task { my $self = shift; return 'X'; }
1;
";
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
PathBuf::from("/tmp/cf_minion.pm"),
Arc::new(parse(parent_src)),
);
idx.register_workspace_module(
PathBuf::from("/tmp/cf_clove_minion.pm"),
Arc::new(parse(child_src)),
);
let consumer_src = "
package MyApp::Plugin;
use Mojo::Base 'Mojolicious::Plugin';
sub register {
my ($self, $app) = @_;
my $minion = Clove::Minion->new(Pg => 1)->app($app);
my $after = 1;
}
1;
";
let mut fa = parse(consumer_src);
fa.enrich_imported_types_with_keys(Some(&idx));
let pt = point_at(consumer_src, "$after");
let minion_t = fa.inferred_type_via_bag_ctx("$minion", pt, Some(&idx));
assert_eq!(
minion_t,
Some(InferredType::ClassName("Clove::Minion".to_string())),
"`my $$minion = Clove::Minion->new->app($$app)` must type $$minion as \
Clove::Minion — the inherited fluent `app` writer (on the cross-file \
parent Minion) returns the invocant class. got: {:?}",
minion_t,
);
}
#[test]
fn fluent_chain_lexical_is_typed() {
let src = "
package LocalThing;
use Mojo::Base -base;
has 'configured';
sub greet { my $self = shift; return 'hi'; }
package Main;
my $thing = LocalThing->new->configured(1);
my $after = 1;
";
let fa = parse(src);
let pt = point_at(src, "$after");
assert_eq!(
fa.inferred_type_via_bag("$thing", pt),
Some(InferredType::ClassName("LocalThing".to_string())),
"`my $$thing = LocalThing->new->configured(1)` must type \
$$thing as LocalThing — the fluent `has` accessor returns \
ClassName(current_package). raw: {:?}",
fa.inferred_type_via_bag("$thing", pt),
);
}
#[test]
fn mojo_helper_returning_closed_over_lexical_composes() {
let src = "
package LocalThing;
use Mojo::Base -base;
has 'configured';
sub greet { my $self = shift; return 'hi'; }
package MyApp;
use Mojo::Base 'Mojolicious';
my $app;
my $thing = LocalThing->new->configured(1);
$app->helper(thing => sub { $thing });
package MyApp::Controller::X;
use Mojo::Base 'Mojolicious::Controller';
sub action {
my $c = shift;
$c->thing->greet;
}
1;
";
let (fa, _tree) = parse_with_tree(src);
let pt = point_at(src, "greet;");
let def = fa.find_definition(pt, None);
assert!(
def.is_some(),
"`$$c->thing->greet` must resolve to LocalThing::greet — the \
helper's closure returns the closed-over fluent-chain lexical \
$$thing (typed LocalThing). got: {:?}",
def,
);
assert_eq!(def.unwrap().start.row, 4);
}
#[test]
fn row_hashref_deref_resolves_column() {
let src = format!(
"{}{}",
USERS_RESULT,
"package main;
my $rs = $schema->resultset('Schema::Result::Users');
my $row = $rs->find(1);
my $n = $row->{name};
"
);
let (fa, _tree) = parse_with_tree(&src);
let key = point_at(&src, "name};");
let def = fa.find_definition(key, None);
assert!(
def.is_some(),
"$row->{{name}} resolves to the column def; ref at cursor: {:?}",
fa.ref_at(key),
);
assert_eq!(def.unwrap().start.row, NAME_COL_DEF_ROW, "lands on the name column");
}
#[test]
fn row_hashref_deref_negative_space() {
let src = format!(
"{}{}",
USERS_RESULT,
"package main;
my $rs = $schema->resultset('Schema::Result::Users');
my $row = $rs->find(1);
my $x = $row->{not_a_column};
my $plain = { adhoc => 1 };
my $y = $plain->{adhoc};
"
);
let (fa, _tree) = parse_with_tree(&src);
let bad = point_at(&src, "not_a_column}");
assert!(
fa.find_definition(bad, None).is_none(),
"non-column key stays unresolved",
);
let adhoc = point_at(&src, "adhoc};");
let r = fa.ref_at(adhoc).expect("adhoc key ref");
assert!(
!matches!(
r.kind,
crate::file_analysis::RefKind::HashKeyAccess {
owner: Some(crate::file_analysis::HashKeyOwner::Class(_)),
..
}
),
"untyped hashref deref not promoted to a class owner: {:?}",
r.kind,
);
}