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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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()),
};
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, Some(&tree), Some(src.as_bytes()), 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 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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()),
};
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()),
};
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<_>>(),
);
}