use super::*;
fn parse(source: &str) -> Tree {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
parser.parse(source, None).unwrap()
}
fn build_fa(source: &str) -> FileAnalysis {
let tree = parse(source);
build(&tree, source.as_bytes())
}
#[test]
fn debug_moo_name_refs() {
let src = std::fs::read_to_string("test_files/frameworks.pl").unwrap();
let fa = build_fa(&src);
for r in &fa.refs {
if r.target_name == "name" || r.target_name == "new" {
eprintln!(
"REF: target={} kind={:?} span={:?} resolves_to={:?}",
r.target_name, r.kind, r.span, r.resolves_to
);
}
}
for s in &fa.symbols {
if s.name == "name"
|| (matches!(s.kind, SymKind::HashKeyDef)
&& s.span.start.row > 5
&& s.span.start.row < 25)
{
eprintln!(
"SYM: name={} kind={:?} span={:?} sel_span={:?} detail={:?}",
s.name, s.kind, s.span, s.selection_span, s.detail
);
}
}
}
#[test]
fn sigil_disambiguation_across_access_forms() {
let src = "\
my ($foo, @foo, %foo);
$foo;
$foo[0];
$foo{hi};
@foo[0..1];
@foo{qw/hi there/};
$#foo;
%foo[0..1];
%foo{a};
";
let fa = build_fa(src);
let decls: std::collections::HashMap<&str, _> = fa
.symbols
.iter()
.filter(|s| {
s.kind == SymKind::Variable
&& s.scope == ScopeId(0)
&& matches!(s.name.as_str(), "$foo" | "@foo" | "%foo")
})
.map(|s| (s.name.as_str(), s.id))
.collect();
assert!(decls.contains_key("$foo"), "missing scalar decl");
assert!(decls.contains_key("@foo"), "missing array decl");
assert!(decls.contains_key("%foo"), "missing hash decl");
let mut refs_by_line: std::collections::HashMap<usize, Vec<&str>> = Default::default();
for r in &fa.refs {
if !matches!(r.kind, RefKind::Variable | RefKind::ContainerAccess) {
continue;
}
if r.access == AccessKind::Declaration {
continue;
}
refs_by_line
.entry(r.span.start.row)
.or_default()
.push(r.target_name.as_str());
}
let expected: &[(usize, &str, &str)] = &[
(1, "$foo", "$foo"), (2, "$foo[0]", "@foo"), (3, "$foo{hi}", "%foo"), (4, "@foo[0..1]", "@foo"), (5, "@foo{qw/hi there/}", "%foo"), (6, "$#foo", "@foo"), (7, "%foo[0..1]", "@foo"), (8, "%foo{a}", "%foo"), ];
let mut failures: Vec<String> = Vec::new();
for (line, form, want) in expected {
let got = refs_by_line.get(line).cloned().unwrap_or_default();
if got.as_slice() != [*want] {
failures.push(format!(
" line {} `{}` → want [{}], got {:?}",
line, form, want, got
));
}
}
assert!(
failures.is_empty(),
"sigil disambiguation failures:\n{}",
failures.join("\n")
);
}
#[test]
fn braced_var_declaration_names_match_bare_form() {
let fa = build_fa("my ${foo} = 1;\n$foo;\n");
let decls: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Variable && s.name == "$foo")
.collect();
assert_eq!(
decls.len(),
1,
"expected one $foo symbol, got {:?}",
fa.symbols
.iter()
.filter(|s| s.kind == SymKind::Variable)
.map(|s| &s.name)
.collect::<Vec<_>>()
);
}
#[test]
fn parse_instance_of_single_quoted() {
assert_eq!(
parse_instance_of("InstanceOf['Foo::Bar']").as_deref(),
Some("Foo::Bar")
);
}
#[test]
fn parse_instance_of_double_quoted() {
assert_eq!(
parse_instance_of("InstanceOf[\"Foo::Bar\"]").as_deref(),
Some("Foo::Bar")
);
}
#[test]
fn parse_instance_of_rejects_non_instance_of() {
assert_eq!(parse_instance_of("Str"), None);
assert_eq!(parse_instance_of("ArrayRef[Int]"), None);
assert_eq!(parse_instance_of("My::Class"), None);
}
#[test]
fn test_file_scope() {
let fa = build_fa("my $x = 1;");
assert_eq!(fa.scopes.len(), 1);
assert_eq!(fa.scopes[0].kind, ScopeKind::File);
}
#[test]
fn test_sub_creates_scope() {
let fa = build_fa("sub foo { my $x = 1; }");
let sub_scopes: Vec<_> = fa
.scopes
.iter()
.filter(|s| matches!(&s.kind, ScopeKind::Sub { name } if name == "foo"))
.collect();
assert_eq!(sub_scopes.len(), 1);
assert_eq!(sub_scopes[0].parent, Some(ScopeId(0))); }
#[test]
fn test_class_creates_scope() {
let fa = build_fa("use v5.38;\nclass Point {\n field $x :param;\n}");
let class_scopes: Vec<_> = fa
.scopes
.iter()
.filter(|s| matches!(&s.kind, ScopeKind::Class { name } if name == "Point"))
.collect();
assert_eq!(class_scopes.len(), 1);
assert_eq!(class_scopes[0].package, Some("Point".to_string()));
}
#[test]
fn test_package_sets_scope_package() {
let fa = build_fa("package Foo;\nsub bar { 1 }");
let sub_scopes: Vec<_> = fa
.scopes
.iter()
.filter(|s| matches!(&s.kind, ScopeKind::Sub { name } if name == "bar"))
.collect();
assert_eq!(sub_scopes.len(), 1);
assert_eq!(sub_scopes[0].package, Some("Foo".to_string()));
}
#[test]
fn test_for_loop_scope() {
let fa = build_fa("for my $i (1..10) { print $i; }");
let for_scopes: Vec<_> = fa
.scopes
.iter()
.filter(|s| matches!(&s.kind, ScopeKind::ForLoop { .. }))
.collect();
assert_eq!(for_scopes.len(), 1);
}
#[test]
fn test_variable_symbol() {
let fa = build_fa("my $x = 1;");
let vars: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Variable && s.name == "$x")
.collect();
assert_eq!(vars.len(), 1);
if let SymbolDetail::Variable { sigil, decl_kind } = &vars[0].detail {
assert_eq!(*sigil, '$');
assert_eq!(*decl_kind, DeclKind::My);
} else {
panic!("expected Variable detail");
}
}
#[test]
fn test_sub_symbol_with_params() {
let fa = build_fa("sub connect($self, %opts) { }");
let subs: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Sub && s.name == "connect")
.collect();
assert_eq!(subs.len(), 1);
if let SymbolDetail::Sub {
params, is_method, ..
} = &subs[0].detail
{
assert!(!is_method);
assert_eq!(params.len(), 2);
assert_eq!(params[0].name, "$self");
assert_eq!(params[1].name, "%opts");
assert!(params[1].is_slurpy);
} else {
panic!("expected Sub detail");
}
}
#[test]
fn test_legacy_sub_params() {
let fa = build_fa("sub new {\n my ($class, %args) = @_;\n}");
let subs: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Sub && s.name == "new")
.collect();
assert_eq!(subs.len(), 1);
if let SymbolDetail::Sub { params, .. } = &subs[0].detail {
assert_eq!(params.len(), 2);
assert_eq!(params[0].name, "$class");
assert_eq!(params[1].name, "%args");
assert!(params[1].is_slurpy);
} else {
panic!("expected Sub detail");
}
}
#[test]
fn test_package_symbol() {
let fa = build_fa("package Foo;");
let pkgs: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Package && s.name == "Foo")
.collect();
assert_eq!(pkgs.len(), 1);
}
#[test]
fn test_class_symbol() {
let fa = build_fa("use v5.38;\nclass Point {\n field $x :param;\n field $y :param;\n method magnitude() { }\n}");
let classes: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Class && s.name == "Point")
.collect();
assert_eq!(classes.len(), 1);
if let SymbolDetail::Class { fields, parent, .. } = &classes[0].detail {
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].name, "$x");
assert_eq!(fields[1].name, "$y");
assert!(fields[0].attributes.contains(&"param".to_string()));
assert!(parent.is_none());
} else {
panic!("expected Class detail");
}
}
#[test]
fn test_field_symbol() {
let fa = build_fa("use v5.38;\nclass Point {\n field $x :param;\n}");
let fields: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Field)
.collect();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].name, "$x");
}
#[test]
fn test_field_reader_synthesizes_method() {
let fa = build_fa(
"use v5.38;\nclass Point {\n field $x :param :reader;\n field $y :param;\n}",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Method)
.collect();
assert_eq!(
methods.len(),
1,
"got: {:?}",
methods.iter().map(|m| &m.name).collect::<Vec<_>>()
);
assert_eq!(methods[0].name, "x");
}
#[test]
fn test_implicit_self_in_method() {
let source = "use v5.38;\nclass Point {\n field $x :param :reader;\n method magnitude () {\n $self->x;\n }\n}\n";
let fa = build_fa(source);
let resolved = fa.resolve_variable("$self", Point::new(4, 8));
assert!(
resolved.is_some(),
"$self should resolve inside method body"
);
}
#[test]
fn test_implicit_self_type_inference() {
let source = "use v5.38;\nclass Point {\n field $x :param :reader;\n method magnitude () {\n $self->x;\n }\n}\n";
let fa = build_fa(source);
let inferred = fa.inferred_type_via_bag("$self", Point::new(4, 8));
assert!(inferred.is_some(), "$self type should be inferred");
match inferred.unwrap() {
InferredType::ClassName(name) => assert_eq!(name, "Point"),
InferredType::FirstParam { package } => assert_eq!(package, "Point"),
other => panic!("expected ClassName or FirstParam, got {:?}", other),
}
}
#[test]
fn test_self_completion_walks_ancestors_in_fallback() {
let source = "package Base;\nsub inherited_m { 1 }\npackage Child;\nuse parent -norequire, 'Base';\nsub own_m {\n my $self = $class->SUPER::new;\n $self->\n}\n";
let fa = build_fa(source);
let names: Vec<String> = fa
.complete_methods("$self", Point::new(6, 9), None)
.into_iter()
.map(|c| c.label)
.collect();
assert!(names.iter().any(|n| n == "own_m"), "own method missing: {names:?}");
assert!(
names.iter().any(|n| n == "inherited_m"),
"inherited (ancestor) method missing from untyped-$self fallback: {names:?}"
);
}
#[test]
fn test_self_completion_inside_method() {
let source = "use v5.38;\nclass Point {\n field $x :param :reader;\n method magnitude () { }\n method to_string () {\n $self->;\n }\n}\n";
let fa = build_fa(source);
let candidates = fa.complete_methods("$self", Point::new(5, 14), None);
let names: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
assert!(
names.contains(&"magnitude"),
"missing magnitude, got: {:?}",
names
);
assert!(
names.contains(&"to_string"),
"missing to_string, got: {:?}",
names
);
assert!(names.contains(&"x"), "missing reader x, got: {:?}", names);
}
#[test]
fn test_field_writer_synthesizes_method() {
let fa =
build_fa("use v5.38;\nclass Point {\n field $label :reader :writer = \"point\";\n}");
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Method)
.map(|s| s.name.clone())
.collect();
assert!(
methods.contains(&"label".to_string()),
"missing reader, got: {:?}",
methods
);
assert!(
methods.contains(&"set_label".to_string()),
"missing writer, got: {:?}",
methods
);
}
#[test]
fn test_complete_methods_in_class() {
let fa = build_fa("use v5.38;\nclass Point {\n field $x :param :reader;\n field $y :param;\n method magnitude() { }\n method to_string() { }\n}\nmy $p = Point->new(x => 1);\n$p->;\n");
let candidates = fa.complete_methods("$p", Point::new(8, 4), None);
let names: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
assert!(names.contains(&"new"), "missing new, got: {:?}", names);
assert!(
names.contains(&"magnitude"),
"missing magnitude, got: {:?}",
names
);
assert!(
names.contains(&"to_string"),
"missing to_string, got: {:?}",
names
);
assert!(names.contains(&"x"), "missing reader x, got: {:?}", names);
}
#[test]
fn test_complete_methods_sample_file_layout() {
let source = r#"use v5.38;
class Point {
field $x :param :reader;
field $y :param;
method magnitude () { }
method to_string () { }
}
my $p = Point->new(x => 3, y => 4);
$p->;
"#;
let fa = build_fa(source);
let inferred = fa.inferred_type_via_bag("$p", Point::new(8, 4));
assert!(inferred.is_some(), "type inference for $p should resolve");
let candidates = fa.complete_methods("$p", Point::new(10, 4), None);
let names: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
assert!(
names.contains(&"magnitude"),
"missing magnitude, got: {:?}",
names
);
assert!(
names.contains(&"to_string"),
"missing to_string, got: {:?}",
names
);
assert!(names.contains(&"x"), "missing reader x, got: {:?}", names);
}
#[test]
fn test_complete_methods_class_after_package_main() {
let source = r#"package main;
my $calc = Calculator->new();
1;
use v5.38;
class Point {
field $x :param :reader;
field $y :param;
method magnitude () { }
method to_string () { }
}
my $p = Point->new(x => 3, y => 4);
$p->;
"#;
let fa = build_fa(source);
let candidates = fa.complete_methods("$p", Point::new(11, 4), None);
let names: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
assert!(names.contains(&"new"), "missing new, got: {:?}", names);
assert!(
names.contains(&"magnitude"),
"missing magnitude, got: {:?}",
names
);
assert!(
names.contains(&"to_string"),
"missing to_string, got: {:?}",
names
);
assert!(names.contains(&"x"), "missing reader x, got: {:?}", names);
}
#[test]
fn test_complete_methods_flat_class() {
let source = "use v5.38;\nclass Foo;\nmethod bar () { }\nmethod baz () { }\n";
let fa = build_fa(source);
let candidates = fa.complete_methods("Foo", Point::new(3, 0), None);
let names: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
assert!(names.contains(&"bar"), "missing bar, got: {:?}", names);
assert!(names.contains(&"baz"), "missing baz, got: {:?}", names);
}
#[test]
fn test_goto_def_method_after_package_main() {
let source = "package main;\n1;\nuse v5.38;\nclass Point {\n field $x :param :reader;\n method magnitude () { }\n}\nmy $p = Point->new(x => 3);\n$p->magnitude();\n";
let fa = build_fa(source);
let def = fa.find_definition(Point::new(8, 5), None);
assert!(def.is_some(), "should find definition for magnitude");
let span = def.unwrap();
assert_eq!(
span.start.row, 5,
"should point to method declaration line, got row {}",
span.start.row
);
}
#[test]
fn test_field_reader_goto_def() {
let fa = build_fa("use v5.38;\nclass Point {\n field $x :param :reader;\n method mag() { }\n}\nmy $p = Point->new(x => 1);\n$p->x;");
let def = fa.find_definition(Point::new(6, 5), None); assert!(def.is_some(), "should find definition for reader method");
let span = def.unwrap();
assert_eq!(span.start.row, 2, "should point to field declaration line");
}
#[test]
fn test_goto_def_unknown_method_is_honest_miss_not_package_jump() {
let source = "package Foo;\nsub new { bless { email => undef }, shift }\nsub to { my $self = shift; $self->{email}->totallyunknownmethod(1); }\n1;\n";
let fa = build_fa(source);
let def = fa.find_definition(Point::new(2, 48), None);
assert!(
def.is_none(),
"unknown method must return None (honest miss), not jump to package decl; got {:?}",
def
);
}
#[test]
fn test_goto_def_typed_same_file_method_resolves() {
let source = "package Widget;\nsub new { bless {}, shift }\nsub frobnicate { 1 }\nsub run { my $w = Widget->new; $w->frobnicate; }\n1;\n";
let fa = build_fa(source);
let row = 3usize;
let line = source.lines().nth(row).unwrap();
let col = line.find("$w->frobnicate").unwrap() + "$w->".len() + 2;
let def = fa.find_definition(Point::new(row, col), None);
assert!(def.is_some(), "typed $w->frobnicate must resolve to the decl");
assert_eq!(
def.unwrap().start.row,
2,
"should land on `sub frobnicate` (row 2)"
);
}
#[test]
fn test_hash_element_extracted_to_scalar_is_not_container_class() {
let source = "package Foo;\nsub new { bless {}, shift }\nsub use_it { my $self = shift; $self->{helper} = Helper->new; my $h = $self->{helper}; $h->do_thing(); }\n1;\n";
let fa = build_fa(source);
let line = source.lines().nth(2).unwrap();
let h_decl_col = line.find("my $h").unwrap();
let probe = tree_sitter::Point::new(2, h_decl_col + "my $h = $self->{helper}; ".len());
let ty = fa.inferred_type("$h", probe);
assert!(
!matches!(ty, Some(InferredType::ClassName(c)) if c == "Foo"),
"$h must NOT be typed as the container's class Foo; got {:?}",
ty
);
let do_thing_col = line.rfind("do_thing").unwrap();
let def = fa.find_definition(tree_sitter::Point::new(2, do_thing_col + 1), None);
assert!(
def.is_none(),
"$h->do_thing must be an honest miss, not a confident jump to a Foo sub; got {:?}",
def
);
}
#[test]
fn slot_type_write_then_extract_resolves_method() {
let source = "package Helper;\nsub new { bless {}, shift }\nsub do_thing { 1 }\npackage Foo;\nsub new { bless {}, shift }\nsub use_it { my $self = shift; $self->{helper} = Helper->new; my $h = $self->{helper}; $h->do_thing(); }\n1;\n";
let fa = build_fa(source);
let line = source.lines().nth(5).unwrap();
let probe = tree_sitter::Point::new(
5,
line.find("my $h").unwrap() + "my $h = $self->{helper}; ".len(),
);
let ty = fa.inferred_type("$h", probe);
assert_eq!(
ty.as_ref().and_then(|t| t.class_name()),
Some("Helper"),
"$h must type as Helper via the consumed SlotType; got {:?}",
ty
);
let def = fa.find_definition(
tree_sitter::Point::new(5, line.rfind("do_thing").unwrap() + 1),
None);
assert!(
matches!(&def, Some(d) if d.start.row == 2),
"$h->do_thing must resolve to Helper::do_thing on row 2; got {:?}",
def
);
}
#[test]
fn test_use_symbol() {
let fa = build_fa("use Foo::Bar;");
let modules: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Module)
.collect();
assert_eq!(modules.len(), 1);
assert_eq!(modules[0].name, "Foo::Bar");
}
#[test]
fn test_variable_ref() {
let fa = build_fa("my $x = 1;\nprint $x;");
let var_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "$x" && matches!(r.kind, RefKind::Variable))
.collect();
assert!(var_refs.len() >= 2, "got {} refs for $x", var_refs.len());
assert!(var_refs.iter().any(|r| r.access == AccessKind::Declaration));
assert!(var_refs.iter().any(|r| r.access == AccessKind::Read));
}
#[test]
fn test_function_call_ref() {
let fa = build_fa("sub foo { }\nfoo();");
let call_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "foo" && matches!(r.kind, RefKind::FunctionCall { .. }))
.collect();
assert_eq!(call_refs.len(), 1);
}
#[test]
fn call_ref_in_concatenation_operand() {
let fa = build_fa("sub Format_Number { }\nprint \"<td>\".Format_Number($x).\"</td>\";\n");
let call_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "Format_Number" && matches!(r.kind, RefKind::FunctionCall { .. }))
.collect();
assert_eq!(
call_refs.len(),
1,
"a call inside `.`-concatenation must emit exactly one FunctionCall ref"
);
assert!(call_refs[0].span.start.row == 1);
}
#[test]
fn call_ref_in_ternary_operands() {
let fa = build_fa("sub foo { }\nsub bar { }\nmy $y = $cond ? foo() : bar();\n");
let foo_refs = fa
.refs
.iter()
.filter(|r| r.target_name == "foo" && matches!(r.kind, RefKind::FunctionCall { .. }))
.count();
let bar_refs = fa
.refs
.iter()
.filter(|r| r.target_name == "bar" && matches!(r.kind, RefKind::FunctionCall { .. }))
.count();
assert_eq!(foo_refs, 1, "ternary consequent call must emit a ref");
assert_eq!(bar_refs, 1, "ternary alternative call must emit a ref");
}
#[test]
fn method_call_ref_in_concatenation_operand() {
let fa = build_fa("my $s = \"x\" . $obj->fmt($n) . \"y\";\n");
let m_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "fmt" && matches!(r.kind, RefKind::MethodCall { .. }))
.collect();
assert_eq!(m_refs.len(), 1, "method call inside concat must emit one MethodCall ref");
}
#[test]
fn call_refs_count_across_expression_positions() {
let src = "\
sub Format_Number { my $n = shift; return $n; }
print \"<td>\".Format_Number($a).\"</td>\";
print \"<td>\".Format_Number($b).\"</td><td>x</td>\";
my $r = \"a\" . Format_Number($c) . \"b\" . Format_Number($d);
my $t = $cond ? Format_Number($e) : 0;
my %h = (Format_Number => 1);
";
let fa = build_fa(src);
let call_refs = fa
.refs
.iter()
.filter(|r| r.target_name == "Format_Number" && matches!(r.kind, RefKind::FunctionCall { .. }))
.count();
assert_eq!(
call_refs, 5,
"every call-position occurrence must emit a FunctionCall ref; the hash-key bareword must not"
);
}
#[test]
fn statement_level_call_emits_single_ref() {
let fa = build_fa("sub debug { }\ndebug(\"hello\");\n");
let call_refs = fa
.refs
.iter()
.filter(|r| r.target_name == "debug" && matches!(r.kind, RefKind::FunctionCall { .. }))
.count();
assert_eq!(call_refs, 1, "statement-level call must emit exactly one ref");
}
#[test]
fn test_method_call_ref() {
let fa = build_fa("$obj->method();");
let method_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "method" && matches!(r.kind, RefKind::MethodCall { .. }))
.collect();
assert_eq!(method_refs.len(), 1);
if let RefKind::MethodCall { ref invocant, .. } = method_refs[0].kind {
assert_eq!(invocant, "$obj");
}
}
#[test]
fn test_hash_key_ref() {
let fa = build_fa("my %h;\n$h{foo};");
let key_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "foo" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
assert_eq!(key_refs.len(), 1);
}
#[test]
fn test_scope_at() {
let fa = build_fa("sub foo {\n my $x = 1;\n}");
let scope = fa.scope_at(Point::new(1, 8)).unwrap();
let s = fa.scope(scope);
assert!(matches!(&s.kind, ScopeKind::Sub { name } if name == "foo"));
}
#[test]
fn test_resolve_variable() {
let fa = build_fa("my $x = 1;\nsub foo {\n my $x = 2;\n print $x;\n}");
let sym = fa.resolve_variable("$x", Point::new(3, 10)).unwrap();
assert_eq!(sym.selection_span.start.row, 2);
}
#[test]
fn test_resolve_variable_outer() {
let fa = build_fa("my $x = 1;\nsub foo {\n print $x;\n}");
let sym = fa.resolve_variable("$x", Point::new(2, 10)).unwrap();
assert_eq!(sym.selection_span.start.row, 0);
}
#[test]
fn test_type_inference_constructor() {
let fa = build_fa("use v5.38;\nclass Point { }\nmy $p = Point->new();");
let ty = fa.inferred_type_via_bag("$p", Point::new(2, 20));
assert!(ty.is_some(), "should infer type for $p");
if let Some(InferredType::ClassName(cn)) = ty {
assert_eq!(cn, "Point");
} else {
panic!("expected ClassName, got {:?}", ty);
}
}
#[test]
fn test_type_inference_first_param() {
let fa = build_fa("package Calculator;\nsub new {\n my ($self) = @_;\n}");
let ty = fa.inferred_type_via_bag("$self", Point::new(2, 10));
assert_eq!(ty, Some(InferredType::ClassName("Calculator".into())));
}
#[test]
fn test_bless_promotes_var_to_class() {
let src = "package Point;\nsub new {\n my $class = shift;\n my $self = {};\n bless $self, $class;\n return $self;\n}\n";
let fa = build_fa(src);
let ty = fa.inferred_type_via_bag("$self", Point::new(5, 9));
assert_eq!(
ty,
Some(InferredType::ClassName("Point".into())),
"post-bless $self should be ClassName(Point), got {:?}",
ty
);
}
#[test]
fn test_bless_fat_arrow_and_package() {
let src = "package Widget;\nsub build {\n my $self = {};\n bless $self => __PACKAGE__;\n return $self;\n}\n";
let fa = build_fa(src);
let ty = fa.inferred_type_via_bag("$self", Point::new(4, 9));
assert_eq!(ty, Some(InferredType::ClassName("Widget".into())));
}
#[test]
fn test_bless_literal_class() {
let src = "package Factory;\nsub mk {\n my $self = {};\n bless $self, \"Other\";\n return $self;\n}\n";
let fa = build_fa(src);
let ty = fa.inferred_type_via_bag("$self", Point::new(4, 9));
assert_eq!(ty, Some(InferredType::ClassName("Other".into())));
}
#[test]
fn test_return_bless_anon_hash_class() {
let src = "package Maker;\nsub new {\n my $class = shift;\n return bless {}, $class;\n}\n";
let fa = build_fa(src);
let ty = fa.sub_return_type_at_arity("new", Some(0));
assert_eq!(
ty,
Some(InferredType::ClassName("Maker".into())),
"return bless should type the sub return, got {:?}",
ty
);
}
#[test]
fn test_bless_into_ref_invocant_types_clone_return() {
let src = "package DateTime;\nsub clone { bless { %{ $_[0] } }, ref $_[0] }\n";
let fa = build_fa(src);
let ty = fa.sub_return_type_at_arity("clone", Some(1));
assert_eq!(
ty,
Some(InferredType::ClassName("DateTime".into())),
"bless ..., ref $_[0] should type the clone return, got {:?}",
ty
);
}
#[test]
fn test_forward_declaration_does_not_duplicate_symbol() {
let fa = build_fa("package P;\nsub foo;\nsub foo { my ($self, $x) = @_; $x }\n");
let foos: Vec<_> = fa
.symbols
.iter()
.filter(|s| matches!(s.kind, SymKind::Sub | SymKind::Method) && s.name == "foo")
.collect();
assert_eq!(
foos.len(),
1,
"expected one `foo` symbol (the definition), got {:?}",
foos.iter().map(|s| s.span.start.row).collect::<Vec<_>>()
);
assert_eq!(foos[0].span.start.row, 2, "the symbol should be the bodied def on line 2");
}
#[test]
fn test_receiver_polymorphic_ctor_types_to_subclass() {
let fa = build_fa(
"package Base;\nsub new { my $class = shift; bless {}, ref $class || $class }\npackage Child;\nuse parent -norequire, 'Base';\n",
);
assert_eq!(
fa.find_method_return_type("Child", "new", None, Some(0)),
Some(InferredType::ClassName("Child".into())),
"Child->new (inherited ctor) must type as Child, not Base"
);
assert_eq!(
fa.find_method_return_type("Base", "new", None, Some(0)),
Some(InferredType::ClassName("Base".into())),
"Base->new still types as Base"
);
}
#[test]
fn test_non_bless_hashref_stays_hashref() {
let src = "sub mk {\n my $h = {};\n return $h;\n}\n";
let fa = build_fa(src);
let ty = fa.inferred_type_via_bag("$h", Point::new(2, 9));
assert_eq!(ty, Some(InferredType::HashRef), "unblessed hashref stays HashRef");
}
#[test]
fn test_extract_hashref_literal() {
let fa = build_fa("my $href = {};");
let ty = fa.inferred_type_via_bag("$href", Point::new(0, 14));
assert_eq!(ty, Some(InferredType::HashRef), "empty hash ref literal");
let fa = build_fa("my $href = { a => 1, b => 2 };");
let ty = fa.inferred_type_via_bag("$href", Point::new(0, 30));
assert!(
ty.is_some_and(|t| t.is_hash_shaped()),
"populated hash ref literal",
);
}
#[test]
fn test_extract_arrayref_literal() {
let fa = build_fa("my $aref = [];");
let ty = fa.inferred_type_via_bag("$aref", Point::new(0, 14));
assert_eq!(ty, Some(InferredType::ArrayRef), "empty array ref literal");
let fa = build_fa("my $aref = [1, 2, 3];");
let ty = fa.inferred_type_via_bag("$aref", Point::new(0, 21));
assert!(
ty.is_some_and(|t| t.is_array_shaped()),
"populated array ref literal",
);
}
#[test]
fn test_extract_coderef_literal() {
let fa = build_fa("my $cref = sub { 42 };");
let ty = fa.inferred_type_via_bag("$cref", Point::new(0, 22));
assert!(
matches!(ty, Some(InferredType::CodeRef { return_edge: Some(_) })),
"anonymous sub: got {:?}",
ty
);
}
#[test]
fn test_extract_regexp_literal() {
let fa = build_fa("my $re = qr/pattern/;");
let ty = fa.inferred_type_via_bag("$re", Point::new(0, 21));
assert_eq!(ty, Some(InferredType::Regexp), "qr// literal");
}
#[test]
fn test_extract_reassignment_type_change() {
let fa = build_fa("my $x = {};\n$x = [];");
let ty = fa.inferred_type_via_bag("$x", Point::new(0, 11));
assert_eq!(ty, Some(InferredType::HashRef), "initial hashref");
let ty = fa.inferred_type_via_bag("$x", Point::new(1, 8));
assert_eq!(ty, Some(InferredType::ArrayRef), "reassigned to arrayref");
}
#[test]
fn test_extract_constructor_still_works() {
let fa = build_fa("my $obj = Foo->new();");
let ty = fa.inferred_type_via_bag("$obj", Point::new(0, 21));
assert_eq!(ty, Some(InferredType::ClassName("Foo".into())));
}
#[test]
fn test_arrow_hash_deref_infers_hashref() {
let fa = build_fa("my $x;\n$x->{key};");
let ty = fa.inferred_type_via_bag("$x", Point::new(1, 10));
assert!(ty.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_arrow_array_deref_infers_arrayref() {
let fa = build_fa("my $x;\n$x->[0];");
let ty = fa.inferred_type_via_bag("$x", Point::new(1, 8));
assert!(ty.is_some_and(|t| t.is_array_shaped()), "array-shaped");
}
#[test]
fn test_arrow_code_deref_infers_coderef() {
let fa = build_fa("my $x;\n$x->(1, 2);");
let ty = fa.inferred_type_via_bag("$x", Point::new(1, 10));
assert_eq!(ty, Some(InferredType::CodeRef { return_edge: None }));
}
#[test]
fn test_coderef_call_propagates_return_type() {
let fa = build_fa("my $cb = sub { [1,2] };\nmy $r = $cb->();\nmy $z;");
let ty = fa.inferred_type_via_bag("$r", Point::new(2, 0));
assert!(
ty.as_ref().is_some_and(|t| t.is_array_shaped()),
"coderef call must inherit the callable's return type via return_edge: got {:?}",
ty,
);
}
#[test]
fn test_postfix_array_deref_infers_arrayref() {
let fa = build_fa("my $x;\nmy @a = $x->@*;\nmy $z;");
let ty = fa.inferred_type_via_bag("$x", Point::new(2, 0));
assert!(ty.is_some_and(|t| t.is_array_shaped()), "array-shaped");
}
#[test]
fn test_postfix_hash_deref_infers_hashref() {
let fa = build_fa("my $y;\nmy %h = $y->%*;\nmy $z;");
let ty = fa.inferred_type_via_bag("$y", Point::new(2, 0));
assert!(ty.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_binary_numeric_ops_infer_numeric() {
let fa = build_fa("my $x;\nmy $a = $x + 1;\nmy $z;");
let ty = fa.inferred_type_via_bag("$x", Point::new(2, 0));
assert_eq!(ty, Some(InferredType::Numeric), "+ operator");
let fa = build_fa("my $x;\nmy $a = $x * 2;\nmy $z;");
let ty = fa.inferred_type_via_bag("$x", Point::new(2, 0));
assert_eq!(ty, Some(InferredType::Numeric), "* operator");
}
#[test]
fn test_assignment_from_binary_numeric_infers_result() {
let fa = build_fa("my $a = 1;\nmy $b = 2;\nmy $result = $a + $b;\n$result;");
let ty = fa.inferred_type_via_bag("$result", Point::new(3, 0));
assert_eq!(
ty,
Some(InferredType::Numeric),
"$result = $a + $b should be Numeric"
);
}
#[test]
fn test_assignment_from_string_concat_infers_result() {
let fa = build_fa("my $a = 'x';\nmy $b = 'y';\nmy $s = $a . $b;\n$s;");
let ty = fa.inferred_type_via_bag("$s", Point::new(3, 0));
assert_eq!(
ty,
Some(InferredType::String),
"$s = $a . $b should be String"
);
}
#[test]
fn test_string_concat_infers_string() {
let fa = build_fa("my $s;\nmy $a = $s . \"x\";\nmy $z;");
let ty = fa.inferred_type_via_bag("$s", Point::new(2, 0));
assert_eq!(ty, Some(InferredType::String), ". operator");
}
#[test]
fn test_string_repeat_infers_string() {
let fa = build_fa("my $s;\n$s x 3;\nmy $z;");
let ty = fa.inferred_type_via_bag("$s", Point::new(2, 0));
assert_eq!(ty, Some(InferredType::String), "x operator");
}
#[test]
fn test_numeric_comparison_infers_numeric() {
let fa = build_fa("my $x;\nmy $y;\n$x == $y;\nmy $z;");
assert_eq!(
fa.inferred_type_via_bag("$x", Point::new(3, 0)),
Some(InferredType::Numeric)
);
assert_eq!(
fa.inferred_type_via_bag("$y", Point::new(3, 0)),
Some(InferredType::Numeric)
);
}
#[test]
fn test_string_comparison_infers_string() {
let fa = build_fa("my $x;\nmy $y;\n$x eq $y;\nmy $z;");
assert_eq!(
fa.inferred_type_via_bag("$x", Point::new(3, 0)),
Some(InferredType::String)
);
assert_eq!(
fa.inferred_type_via_bag("$y", Point::new(3, 0)),
Some(InferredType::String)
);
}
#[test]
fn test_increment_infers_numeric() {
let fa = build_fa("my $x;\n$x++;\nmy $z;");
let ty = fa.inferred_type_via_bag("$x", Point::new(2, 0));
assert_eq!(ty, Some(InferredType::Numeric));
}
#[test]
fn test_regex_match_infers_string() {
let fa = build_fa("my $s;\n$s =~ /pattern/;\nmy $z;");
let ty = fa.inferred_type_via_bag("$s", Point::new(2, 0));
assert_eq!(ty, Some(InferredType::String));
}
#[test]
fn test_preinc_infers_numeric() {
let fa = build_fa("my $x;\n++$x;\nmy $z;");
let ty = fa.inferred_type_via_bag("$x", Point::new(2, 0));
assert_eq!(ty, Some(InferredType::Numeric));
}
#[test]
fn test_block_array_deref_infers_arrayref() {
let fa = build_fa("my $x;\nmy @items = @{$x};\nmy $z;");
let ty = fa.inferred_type_via_bag("$x", Point::new(2, 0));
assert!(ty.is_some_and(|t| t.is_array_shaped()), "array-shaped");
}
#[test]
fn test_block_hash_deref_infers_hashref() {
let fa = build_fa("my $y;\nmy %t = %{$y};\nmy $z;");
let ty = fa.inferred_type_via_bag("$y", Point::new(2, 0));
assert!(ty.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_block_code_deref_infers_coderef() {
let fa = build_fa("my $z;\n&{$z}();\nmy $w;");
let ty = fa.inferred_type_via_bag("$z", Point::new(2, 0));
assert_eq!(ty, Some(InferredType::CodeRef { return_edge: None }));
}
#[test]
fn test_no_numeric_on_array_variable() {
let fa = build_fa("my @arr;\nmy $n = @arr + 1;\nmy $z;");
let ty = fa.inferred_type_via_bag("@arr", Point::new(2, 0));
assert_eq!(ty, None, "@arr should not get Numeric constraint");
}
#[test]
fn test_builtin_push_infers_arrayref() {
let fa = build_fa("my $aref;\npush @{$aref}, 1;\nmy $z;");
let ty = fa.inferred_type_via_bag("$aref", Point::new(2, 0));
assert!(
ty.is_some_and(|t| t.is_array_shaped()),
"push deref should infer ArrayRef",
);
}
#[test]
fn test_builtin_length_infers_string_arg() {
let fa = build_fa("my $s;\nmy $n = length($s);\nmy $z;");
let ty = fa.inferred_type_via_bag("$s", Point::new(2, 0));
assert_eq!(
ty,
Some(InferredType::String),
"length arg should be String"
);
}
#[test]
fn test_builtin_abs_infers_numeric_arg() {
let fa = build_fa("my $x;\nmy $n = abs($x);\nmy $z;");
let ty = fa.inferred_type_via_bag("$x", Point::new(2, 0));
assert_eq!(ty, Some(InferredType::Numeric), "abs arg should be Numeric");
}
#[test]
fn test_builtin_return_type_propagates() {
let fa = build_fa("my $t = time();\n$t;");
let ty = fa.inferred_type_via_bag("$t", Point::new(1, 0));
assert_eq!(
ty,
Some(InferredType::Numeric),
"time() should return Numeric"
);
}
#[test]
fn test_builtin_join_return_type() {
let fa = build_fa("my $s = join(',', @arr);\n$s;");
let ty = fa.inferred_type_via_bag("$s", Point::new(1, 0));
assert_eq!(
ty,
Some(InferredType::String),
"join() should return String"
);
}
#[test]
fn test_builtin_length_return_type() {
let fa = build_fa("my $n = length('hello');\n$n;");
let ty = fa.inferred_type_via_bag("$n", Point::new(1, 0));
assert_eq!(
ty,
Some(InferredType::Numeric),
"length() should return Numeric"
);
}
#[test]
fn test_return_type_hashref() {
let fa = build_fa("sub get_config {\n return { host => \"localhost\" };\n}");
assert!(
fa.sub_return_type_at_arity("get_config", None).is_some_and(|t| t.is_hash_shaped()),
"hash-shaped",
);
}
#[test]
fn test_return_type_arrayref() {
let fa = build_fa("sub get_tags {\n return [1, 2, 3];\n}");
assert!(
fa.sub_return_type_at_arity("get_tags", None).is_some_and(|t| t.is_array_shaped()),
"array-shaped",
);
}
#[test]
fn test_return_type_coderef() {
let fa = build_fa("sub get_handler {\n return sub { 1 };\n}");
let ty = fa.sub_return_type_at_arity("get_handler", None);
assert!(
matches!(ty, Some(InferredType::CodeRef { return_edge: Some(_) })),
"got {:?}",
ty
);
}
#[test]
fn test_return_type_implicit_last_expr() {
let fa = build_fa("sub get_data {\n { key => \"val\" };\n}");
assert!(
fa.sub_return_type_at_arity("get_data", None).is_some_and(|t| t.is_hash_shaped()),
"hash-shaped",
);
}
#[test]
fn test_return_type_conflicting_returns_unknown() {
let fa = build_fa("sub ambiguous {\n if (1) { return {} }\n return [];\n}");
assert_eq!(fa.sub_return_type_at_arity("ambiguous", None), None);
}
#[test]
fn test_return_type_consistent_returns() {
let fa =
build_fa("sub consistent {\n if (1) { return { a => 1 } }\n return { b => 2 };\n}");
assert!(
fa.sub_return_type_at_arity("consistent", None).is_some_and(|t| t.is_hash_shaped()),
"hash-shaped",
);
}
#[test]
fn test_return_type_propagation_to_call_site() {
let fa =
build_fa("sub get_config {\n return { host => 1 };\n}\nmy $cfg = get_config();\nmy $z;");
assert!(
fa.sub_return_type_at_arity("get_config", None).is_some_and(|t| t.is_hash_shaped()),
"hash-shaped",
);
let ty = fa.inferred_type_via_bag("$cfg", Point::new(4, 0));
assert!(
ty.is_some_and(|t| t.is_hash_shaped()),
"call site should get return type",
);
}
#[test]
fn test_return_type_propagation_method_call() {
let src = "package Calculator;\nsub new { bless {}, shift }\nsub add {\n my ($self, $a, $b) = @_;\n my $result = $a + $b;\n return $result;\n}\npackage main;\nmy $calc = Calculator->new();\nmy $sum = $calc->add(2, 3);\n$sum;";
let fa = build_fa(src);
assert_eq!(
fa.sub_return_type_at_arity("add", None),
Some(InferredType::Numeric),
"add should return Numeric"
);
let ty = fa.inferred_type_via_bag("$sum", Point::new(10, 0));
assert_eq!(
ty,
Some(InferredType::Numeric),
"$sum should be Numeric via method call binding"
);
}
#[test]
fn test_return_type_constructor() {
let fa = build_fa("package User;\nsub new { bless {}, shift }\npackage main;\nsub get_user {\n return User->new();\n}");
assert_eq!(
fa.sub_return_type_at_arity("get_user", None),
Some(InferredType::ClassName("User".into()))
);
}
#[test]
fn test_return_type_self_variable() {
let fa = build_fa("package Foo;\nsub new { bless {}, shift }\nsub clone {\n my ($self) = @_;\n return $self;\n}");
assert_eq!(
fa.sub_return_type_at_arity("clone", None),
Some(InferredType::ClassName("Foo".into())),
);
}
#[test]
fn test_return_type_bare_return_filtered() {
let fa = build_fa("sub get_config {\n return unless 1;\n return { host => 1 };\n}");
assert!(
fa.sub_return_type_at_arity("get_config", None).is_some_and(|t| t.is_hash_shaped()),
"hash-shaped",
);
}
#[test]
fn test_return_type_all_bare_returns() {
let fa = build_fa("sub noop {\n return;\n}");
assert_eq!(fa.sub_return_type_at_arity("noop", None), None);
}
#[test]
fn test_return_type_undef_filtered() {
let fa = build_fa("sub maybe {\n return undef unless 1;\n return { a => 1 };\n}");
assert!(
fa.sub_return_type_at_arity("maybe", None).is_some_and(|t| t.is_hash_shaped()),
"hash-shaped",
);
}
fn find_node_at<'a>(
node: tree_sitter::Node<'a>,
point: Point,
kind: &str,
) -> Option<tree_sitter::Node<'a>> {
if node.kind() == kind && node.start_position() >= point {
return Some(node);
}
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
if let Some(found) = find_node_at(child, point, kind) {
return Some(found);
}
}
}
None
}
#[test]
fn test_resolve_expr_type_function_call() {
let src = "sub get_config {\n return { host => 1 };\n}\nget_config();\n";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let call_node = find_node_at(
tree.root_node(),
Point::new(3, 0),
"function_call_expression",
)
.expect("should find function_call_expression");
let ty = crate::cursor_context::resolve_expression_type(&fa, call_node, src.as_bytes(), None);
assert!(ty.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_resolve_expr_type_method_call_return() {
let src = "package Foo;\nsub new { bless {}, shift }\nsub get_bar {\n return Bar->new();\n}\npackage Bar;\nsub new { bless {}, shift }\nsub do_thing { }\npackage main;\nmy $f = Foo->new();\n";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
assert_eq!(
fa.sub_return_type_at_arity("get_bar", None),
Some(InferredType::ClassName("Bar".into()))
);
}
#[test]
fn test_resolve_expr_type_scalar_variable() {
let src = "my $x = {};\n$x;\n";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let scalar_node =
find_node_at(tree.root_node(), Point::new(1, 0), "scalar").expect("should find scalar");
let ty = crate::cursor_context::resolve_expression_type(&fa, scalar_node, src.as_bytes(), None);
assert!(ty.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_resolve_expr_type_chained_method() {
let src = "package Foo;\nsub new { bless {}, shift }\nsub get_bar {\n return Bar->new();\n}\npackage Bar;\nsub new { bless {}, shift }\nsub get_name {\n return { name => 'test' };\n}\npackage main;\nmy $f = Foo->new();\n$f->get_bar()->get_name();\n";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let node = tree
.root_node()
.descendant_for_point_range(Point::new(12, 0), Point::new(12, 25))
.expect("should find node");
let mut n = node;
while n.kind() != "method_call_expression"
|| n.parent()
.map_or(false, |p| p.kind() == "method_call_expression")
{
n = match n.parent() {
Some(p) => p,
None => panic!("should find outermost method_call_expression"),
};
}
assert_eq!(n.kind(), "method_call_expression");
let ty = crate::cursor_context::resolve_expression_type(&fa, n, src.as_bytes(), None);
assert!(ty.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_resolve_expr_type_constructor() {
let src = "package Foo;\nsub new { bless {}, shift }\npackage main;\nFoo->new();\n";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let call = find_node_at(tree.root_node(), Point::new(3, 0), "method_call_expression")
.expect("should find method_call_expression");
let ty = crate::cursor_context::resolve_expression_type(&fa, call, src.as_bytes(), None);
assert_eq!(ty, Some(InferredType::ClassName("Foo".into())));
}
#[test]
fn test_resolve_expr_type_triple_chain() {
let src = "\
package Calculator;
sub new { bless {}, shift }
sub get_self {
my ($self) = @_;
return $self;
}
sub get_config {
return { host => 'localhost', port => 5432 };
}
package main;
my $calc = Calculator->new();
$calc->get_self->get_config->{host};
";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let get_self_rt = fa.sub_return_type_at_arity("get_self", None);
assert_eq!(
get_self_rt.as_ref().and_then(|t| t.class_name()),
Some("Calculator"),
"get_self should return Calculator"
);
let get_config_rt = fa.sub_return_type_at_arity("get_config", None);
assert!(
get_config_rt.is_some_and(|t| t.is_hash_shaped()),
"get_config should return HashRef",
);
let node = tree
.root_node()
.descendant_for_point_range(Point::new(11, 0), Point::new(11, 0))
.expect("should find node");
let mut n = node;
loop {
if n.kind() == "hash_element_expression" {
break;
}
n = n.parent().expect("should find hash_element_expression");
}
let base = n.named_child(0).expect("should have base");
assert_eq!(base.kind(), "method_call_expression");
let ty = crate::cursor_context::resolve_expression_type(&fa, base, src.as_bytes(), None);
assert!(
ty.is_some_and(|t| t.is_hash_shaped()),
"the chain $calc->get_self->get_config should resolve to HashRef",
);
}
#[test]
fn invocant_class_and_resolve_expression_type_agree_tree_free() {
let src = "\
package Foo;
sub new { bless {}, shift }
sub kid { return Foo->new(); }
sub cfg { return { host => 'x' }; }
package main;
sub mk { return Foo->new(); }
my $f = Foo->new();
my @arr;
push @arr, Foo->new();
my %h = (it => $f);
$f->kid();
$f->kid()->kid();
$arr[0]->kid();
mk()->kid();
$h{it}->kid();
";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let mut checked_shapes = 0;
for r in &fa.refs {
let RefKind::MethodCall { invocant_span: Some(sp), .. } = &r.kind else {
continue;
};
let invocant_node = tree
.root_node()
.descendant_for_point_range(sp.start, sp.end)
.expect("invocant span maps to a node");
if invocant_node.start_position() != sp.start
|| invocant_node.end_position() != sp.end
{
continue;
}
let via_ref = fa.method_call_invocant_class(r, None);
let via_node =
crate::cursor_context::resolve_expression_type(&fa, invocant_node, src.as_bytes(), None)
.and_then(|t| t.class_name().map(|s| s.to_string()));
assert_eq!(
via_ref, via_node,
"invocant-class (tree-free) vs resolve_expression_type disagree \
for invocant `{}` (kind {})",
invocant_node.utf8_text(src.as_bytes()).unwrap_or("?"),
invocant_node.kind(),
);
checked_shapes += 1;
}
assert!(
checked_shapes >= 5,
"expected to compare at least the 5 invocant shapes, got {}",
checked_shapes,
);
let kid_on_scalar = fa.refs.iter().find(|r| {
matches!(&r.kind, RefKind::MethodCall { invocant, .. } if invocant == "$f")
&& r.target_name == "kid"
});
assert_eq!(
kid_on_scalar.and_then(|r| fa.method_call_invocant_class(r, None)).as_deref(),
Some("Foo"),
"scalar invocant `$f->kid` should type as Foo, tree-free",
);
let kid_on_array = fa.refs.iter().find(|r| {
matches!(&r.kind, RefKind::MethodCall { invocant, .. } if invocant.starts_with("$arr"))
&& r.target_name == "kid"
});
assert_eq!(
kid_on_array.and_then(|r| fa.method_call_invocant_class(r, None)).as_deref(),
Some("Foo"),
"array-element invocant `$arr[0]->kid` should type as Foo, tree-free",
);
}
#[test]
fn test_package_at() {
let fa = build_fa("package Foo;\nsub bar { }");
let pkg = fa.package_at(Point::new(1, 5));
assert_eq!(pkg, Some("Foo"));
}
#[test]
fn test_variable_resolves_to() {
let fa = build_fa("my $x = 1;\nprint $x;");
let read_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "$x" && r.access == AccessKind::Read)
.collect();
assert!(!read_refs.is_empty());
assert!(
read_refs[0].resolves_to.is_some(),
"read ref should resolve to declaration"
);
}
#[test]
fn test_fold_ranges() {
let fa = build_fa("sub foo {\n my $x = 1;\n}\nsub bar {\n my $y = 2;\n}");
assert!(
fa.fold_ranges.len() >= 2,
"should have fold ranges for sub blocks, got {}",
fa.fold_ranges.len()
);
}
#[test]
fn test_visible_symbols() {
let fa = build_fa("my $outer = 1;\nsub foo {\n my $inner = 2;\n}");
let visible = fa.visible_symbols(Point::new(2, 10));
let names: Vec<&str> = visible.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"$inner"),
"should see $inner, got: {:?}",
names
);
assert!(
names.contains(&"$outer"),
"should see $outer, got: {:?}",
names
);
}
#[test]
fn test_two_packages_scoped() {
let fa = build_fa("package Foo;\nsub alpha { }\npackage Bar;\nsub beta { }");
let pkg = fa.package_at(Point::new(3, 5));
assert_eq!(pkg, Some("Bar"));
let pkg = fa.package_at(Point::new(1, 5));
assert_eq!(pkg, Some("Foo"));
}
#[test]
fn test_block_scoped_package_reverts() {
let src = "package Outer;\n{\n package Inner;\n sub i { }\n}\nsub o { }\n";
let fa = build_fa(src);
let o = fa.symbols.iter().find(|s| s.name == "o").expect("sub o");
assert_eq!(o.package.as_deref(), Some("Outer"), "sub o must be in Outer, not Inner");
let i = fa.symbols.iter().find(|s| s.name == "i").expect("sub i");
assert_eq!(i.package.as_deref(), Some("Inner"), "sub i must be in Inner");
assert_eq!(fa.package_at(Point::new(5, 4)), Some("Outer"));
assert_eq!(fa.package_at(Point::new(3, 6)), Some("Inner"));
}
#[test]
fn test_non_block_package_unaffected() {
let fa = build_fa("package Foo;\nsub a { }\npackage Bar;\nsub b { }\nsub c { }\n");
let b = fa.symbols.iter().find(|s| s.name == "b").expect("sub b");
let c = fa.symbols.iter().find(|s| s.name == "c").expect("sub c");
assert_eq!(b.package.as_deref(), Some("Bar"));
assert_eq!(c.package.as_deref(), Some("Bar"));
}
#[test]
fn test_find_def_variable() {
let fa = build_fa("my $x = 1;\nprint $x;");
let def = fa.find_definition(Point::new(1, 7), None);
assert!(def.is_some(), "should find definition for $x");
let span = def.unwrap();
assert_eq!(span.start.row, 0, "definition should be on line 0");
}
#[test]
fn test_find_def_sub() {
let fa = build_fa("sub greet { }\ngreet();");
let def = fa.find_definition(Point::new(1, 1), None);
assert!(def.is_some(), "should find definition for greet");
let span = def.unwrap();
assert_eq!(span.start.row, 0, "definition should be on line 0");
}
#[test]
fn test_find_def_method_in_class() {
let src = "package Foo;\nsub new { bless {}, shift }\nsub hello { }\npackage main;\nmy $f = Foo->new();\n$f->hello();";
let fa = build_fa(src);
let def = fa.find_definition(Point::new(5, 5), None);
assert!(def.is_some(), "should find definition for hello method");
let span = def.unwrap();
assert_eq!(span.start.row, 2, "hello definition should be on line 2");
}
#[test]
fn test_find_def_scoped_variable() {
let src = "my $x = 'outer';\nsub foo {\n my $x = 'inner';\n print $x;\n}";
let fa = build_fa(src);
let def = fa.find_definition(Point::new(3, 11), None);
assert!(def.is_some());
let span = def.unwrap();
assert_eq!(span.start.row, 2, "should resolve to inner $x on line 2");
}
#[test]
fn test_find_references_variable() {
let src = "my $x = 1;\nprint $x;\n$x = 2;";
let fa = build_fa(src);
let refs = fa.find_references(Point::new(0, 4), None);
assert!(
refs.len() >= 2,
"should find at least declaration + usage, got {}",
refs.len()
);
}
#[test]
fn test_hash_key_def_implicit_return_gets_sub_owner() {
let src = "sub get_config { { host => 'localhost', port => 5432 } }\nmy $cfg = get_config();\n$cfg->{host};\n";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let host_defs: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "host" && matches!(s.detail, SymbolDetail::HashKeyDef { .. }))
.collect();
assert!(!host_defs.is_empty(), "should find HashKeyDef for 'host'");
if let SymbolDetail::HashKeyDef { ref owner, .. } = host_defs[0].detail {
assert_eq!(
*owner,
HashKeyOwner::Sub {
package: Some("main".to_string()),
name: "get_config".to_string()
},
"implicit return hash key should have Sub get_config owner, got {:?}",
owner
);
}
let host_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "host" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
assert!(
!host_refs.is_empty(),
"should find HashKeyAccess for 'host'"
);
let def = fa.find_definition(
host_refs[0].span.start,
None);
assert!(def.is_some(), "should find definition for host");
assert_eq!(def.unwrap().start.row, 0, "host def should be on line 0");
}
#[test]
fn test_find_references_sub() {
let src = "sub greet { }\ngreet();\ngreet();";
let fa = build_fa(src);
let refs = fa.find_references(Point::new(0, 5), None);
assert!(
refs.len() >= 2,
"should find definition + calls, got {}",
refs.len()
);
}
#[test]
fn test_find_references_method_through_chain() {
let src = "\
package Foo;
sub new { bless {}, shift }
sub bar { 42 }
package main;
sub get_foo { return Foo->new() }
my $f = Foo->new();
$f->bar();
get_foo()->bar();
";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let refs = fa.find_references(Point::new(2, 5), None);
let ref_lines: Vec<usize> = refs.iter().map(|s| s.start.row).collect();
assert!(
refs.len() >= 2,
"should find at least 2 refs, got {} at lines {:?}",
refs.len(),
ref_lines
);
assert!(
ref_lines.contains(&7),
"should find chained get_foo()->bar() at line 7, got {:?}",
ref_lines
);
}
#[test]
fn test_hash_key_def_in_return_gets_sub_owner() {
let src = "sub get_config {\n return { host => 'localhost', port => 5432 };\n}\nmy $cfg = get_config();\n$cfg->{host};\n";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let host_defs: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "host" && matches!(s.detail, SymbolDetail::HashKeyDef { .. }))
.collect();
assert!(!host_defs.is_empty(), "should find HashKeyDef for 'host'");
if let SymbolDetail::HashKeyDef { ref owner, .. } = host_defs[0].detail {
assert_eq!(
*owner,
HashKeyOwner::Sub {
package: Some("main".to_string()),
name: "get_config".to_string()
},
"host def should have Sub get_config owner, got {:?}",
owner
);
}
let host_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "host" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
assert!(
!host_refs.is_empty(),
"should find HashKeyAccess for 'host'"
);
if let RefKind::HashKeyAccess { ref owner, .. } = host_refs[0].kind {
assert_eq!(
*owner,
Some(HashKeyOwner::Sub {
package: Some("main".to_string()),
name: "get_config".to_string()
}),
"host ref should have Sub get_config owner, got {:?}",
owner
);
}
let host_def_point = host_defs[0].selection_span.start;
let refs = fa.find_references(host_def_point, None);
assert!(
refs.len() >= 1,
"should find at least 1 usage, got {} refs",
refs.len()
);
let host_ref_point = host_refs[0].span.start;
let refs_from_usage = fa.find_references(host_ref_point, None);
assert!(
refs_from_usage.len() >= 2,
"should find def + usage, got {} refs",
refs_from_usage.len()
);
}
#[test]
fn test_hash_key_refs_chained_resolved_at_build() {
let src = r#"package Calculator;
sub new { bless {}, shift }
sub get_self { my ($self) = @_; return $self; }
sub get_config { return { host => "localhost", port => 5432 }; }
package main;
my $calc = Calculator->new();
$calc->get_self->get_config->{host};
"#;
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let host_defs: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "host" && matches!(s.detail, SymbolDetail::HashKeyDef { .. }))
.collect();
assert!(!host_defs.is_empty(), "should find HashKeyDef for 'host'");
let owner = fa
.refs
.iter()
.find_map(|r| match &r.kind {
RefKind::HashKeyAccess { owner: Some(o), .. } if r.target_name == "host" => {
Some(o.clone())
}
_ => None,
})
.expect("chained hash access should carry a resolved owner");
assert_eq!(
owner,
HashKeyOwner::Sub {
package: Some("Calculator".to_string()),
name: "get_config".to_string(),
},
"owner should be the return-hash sub of the last chain hop, got {:?}",
owner
);
let host_def_point = host_defs[0].selection_span.start;
let refs = fa.find_references(host_def_point, None);
assert!(
refs.len() >= 1,
"should find chained usage, got {} refs",
refs.len()
);
}
#[test]
fn test_highlights_read_write() {
let src = "my $x = 1;\nprint $x;\n$x = 2;";
let fa = build_fa(src);
let highlights = fa.find_highlights(Point::new(0, 4), None);
assert!(!highlights.is_empty(), "should have highlights");
let has_write = highlights
.iter()
.any(|(_, a)| matches!(a, AccessKind::Write));
let has_read = highlights
.iter()
.any(|(_, a)| matches!(a, AccessKind::Read));
assert!(
highlights.len() >= 2,
"should have at least 2 highlights, got {}",
highlights.len()
);
let _ = (has_write, has_read); }
#[test]
fn test_hover_variable() {
let src = "my $greeting = 'hello';\nprint $greeting;";
let fa = build_fa(src);
let hover = fa.hover_info(Point::new(1, 8), src, None);
assert!(hover.is_some(), "should have hover info");
let text = hover.unwrap();
assert!(
text.contains("$greeting"),
"hover should contain variable name, got: {}",
text
);
}
#[test]
fn test_hover_sub() {
let src = "sub greet { }\ngreet();";
let fa = build_fa(src);
let hover = fa.hover_info(Point::new(1, 1), src, None);
assert!(hover.is_some(), "should have hover info for function call");
let text = hover.unwrap();
assert!(
text.contains("greet"),
"hover should contain sub name, got: {}",
text
);
}
#[test]
fn test_hover_shows_inferred_type() {
let src =
"package Point;\nsub new { bless {}, shift }\npackage main;\nmy $p = Point->new();\n$p;";
let fa = build_fa(src);
let hover = fa.hover_info(Point::new(4, 1), src, None);
assert!(hover.is_some(), "should have hover info");
let text = hover.unwrap();
assert!(
text.contains("Point"),
"hover should show inferred type Point, got: {}",
text
);
}
#[test]
fn test_hover_type_at_usage_after_reassignment() {
let src = "package Point;\nsub new { bless {}, shift }\npackage Foo;\nsub new { bless {}, shift }\npackage main;\nmy $x = Point->new();\n$x;\n$x = Foo->new();\n$x;";
let fa = build_fa(src);
let hover1 = fa.hover_info(Point::new(6, 1), src, None);
assert!(hover1.is_some());
let text1 = hover1.unwrap();
assert!(
text1.contains("Point"),
"at line 6 should be Point, got: {}",
text1
);
let hover2 = fa.hover_info(Point::new(8, 1), src, None);
assert!(hover2.is_some());
let text2 = hover2.unwrap();
assert!(
text2.contains("Foo"),
"at line 8 should be Foo, got: {}",
text2
);
}
#[test]
fn test_hover_shows_return_type() {
let src = "package Foo;\nsub make { return Foo->new() }\nsub new { bless {}, shift }\npackage main;\nmake();";
let fa = build_fa(src);
let hover = fa.hover_info(Point::new(1, 5), src, None);
assert!(hover.is_some(), "should have hover info for sub");
let text = hover.unwrap();
assert!(
text.contains("returns"),
"hover should show return type, got: {}",
text
);
assert!(
text.contains("Foo"),
"hover return type should mention Foo, got: {}",
text
);
}
#[test]
fn test_rename_variable() {
let src = "my $x = 1;\nprint $x;";
let fa = build_fa(src);
let edits = fa.rename_at(Point::new(0, 4), "y");
assert!(edits.is_some(), "should produce rename edits");
let edits = edits.unwrap();
assert!(
edits.len() >= 2,
"should rename at least declaration + usage"
);
for (_, new_text) in &edits {
assert_eq!(new_text, "y", "all edits should use new name");
}
}
#[test]
fn test_rename_sub_finds_both_function_and_method_calls() {
let fa = build_fa(
"
package Foo;
sub emit { }
sub test {
my $self = shift;
emit('event');
$self->emit('done');
}
",
);
let edits = fa.rename_sub_in_package("emit", &Some("Foo".to_string()), "fire", None);
assert!(
edits.len() >= 3,
"rename_sub_in_package should find def + function call + method call, got {} edits",
edits.len()
);
for (_, text) in &edits {
assert_eq!(text, "fire");
}
}
#[test]
fn test_moo_has_creates_constructor_hash_key_def() {
let fa = build_fa(
"
package MyApp;
use Moo;
has username => (is => 'ro');
has password => (is => 'rw');
",
);
let key_defs: Vec<_> = fa
.symbols
.iter()
.filter(|s| matches!(s.detail, SymbolDetail::HashKeyDef { .. }))
.collect();
let names: Vec<&str> = key_defs.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"username"),
"should have HashKeyDef for username, got: {:?}",
names
);
assert!(
names.contains(&"password"),
"should have HashKeyDef for password, got: {:?}",
names
);
if let SymbolDetail::HashKeyDef { ref owner, .. } = key_defs[0].detail {
assert_eq!(
owner,
&HashKeyOwner::Sub {
package: Some("MyApp".to_string()),
name: "new".to_string(),
}
);
}
}
#[test]
fn test_error_recovery_sub_outside_error() {
let source = "package Foo;\nmy $x = [\nuse List::Util qw(max);\nsub process { }\n";
let fa = build_fa(source);
let subs: Vec<&str> = fa
.symbols
.iter()
.filter(|s| matches!(s.kind, SymKind::Sub | SymKind::Method))
.map(|s| s.name.as_str())
.collect();
assert!(
subs.contains(&"process"),
"sub process should survive (outside ERROR)"
);
}
#[test]
fn test_error_recovery_sub_outside_error_survives() {
let source = "package Foo;\nmy $x = [\nuse List::Util qw(max);\nsub process { }\n";
let fa = build_fa(source);
let subs: Vec<&str> = fa
.symbols
.iter()
.filter(|s| matches!(s.kind, SymKind::Sub | SymKind::Method))
.map(|s| s.name.as_str())
.collect();
assert!(
subs.contains(&"process"),
"sub process should survive (outside ERROR)"
);
}
#[test]
fn test_error_node_does_not_panic() {
let source = "package Foo;\nmy $x = [\nmy $y = [\nsub process { }\n";
let fa = build_fa(source);
let pkgs: Vec<&str> = fa
.symbols
.iter()
.filter(|s| matches!(s.kind, SymKind::Package))
.map(|s| s.name.as_str())
.collect();
assert!(pkgs.contains(&"Foo"), "package Foo should survive");
}
#[test]
fn test_error_recovery_sub_inside_error() {
let source = "package Foo;\nmy $x = [\nmy $y = [\nsub process { }\n";
let fa = build_fa(source);
let subs: Vec<&str> = fa
.symbols
.iter()
.filter(|s| matches!(s.kind, SymKind::Sub | SymKind::Method))
.map(|s| s.name.as_str())
.collect();
assert!(
subs.contains(&"process"),
"sub process should be recovered from ERROR"
);
}
#[test]
fn test_error_recovery_import_inside_error() {
let source = "package Foo;\nmy $x = [\nuse List::Util qw(max);\nsub process { }\n";
let fa = build_fa(source);
let imports: Vec<&str> = fa.imports.iter().map(|i| i.module_name.as_str()).collect();
assert!(
imports.contains(&"List::Util"),
"use List::Util should be recovered from ERROR"
);
}
#[test]
fn test_error_recovery_package_inside_error() {
let source = "my $x = [\npackage Bar;\nuse Moose;\nsub bar { }\n";
let fa = build_fa(source);
let pkgs: Vec<&str> = fa
.symbols
.iter()
.filter(|s| matches!(s.kind, SymKind::Package))
.map(|s| s.name.as_str())
.collect();
assert!(
pkgs.contains(&"Bar"),
"package Bar should be recovered from ERROR"
);
}
#[test]
fn test_find_def_bareword_class() {
let src = "package Point;\nsub new { bless {}, shift }\npackage main;\nPoint->new();";
let fa = build_fa(src);
let def = fa.find_definition(Point::new(3, 8), None);
assert!(def.is_some(), "should find definition for new");
}
#[test]
fn test_deref_block_produces_inner_variable_ref() {
let fa = build_fa("my @data = (1,2,3);\nmy $arr = \\@data;\npush @{$arr}, 4;");
let inner_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| {
r.target_name == "$arr"
&& matches!(r.kind, RefKind::Variable)
&& r.access == AccessKind::Read
})
.collect();
assert!(
!inner_refs.is_empty(),
"should find $arr ref inside @{{$arr}}"
);
let bogus: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name.contains("{$arr}"))
.collect();
assert!(
bogus.is_empty(),
"should not record bogus ref for whole deref expression"
);
}
#[test]
fn test_deref_block_produces_hash_key_ref() {
let fa = build_fa("my %h = (items => []);\n@{$h{items}};");
let key_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "items" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
assert!(
!key_refs.is_empty(),
"should find hash key ref 'items' inside deref block"
);
}
#[test]
fn test_deref_block_resolves_variable() {
let fa = build_fa("my @xs = (1,2);\nmy $ref = \\@xs;\nprint @{$ref};");
let inner_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "$ref" && r.access == AccessKind::Read)
.collect();
assert!(!inner_refs.is_empty(), "$ref ref should exist");
assert!(
inner_refs[0].resolves_to.is_some(),
"$ref inside deref should resolve to declaration"
);
}
#[test]
fn test_deref_self_and_hash_key() {
let src = "package Calculator;\nsub new {\n my ($class, %args) = @_;\n my $self = bless {\n history => [],\n verbose => 0,\n }, $class;\n return $self;\n}\nsub add {\n my ($self, $a, $b) = @_;\n my $result = $a + $b;\n push @{$self->{history}}, \"add\";\n return $result;\n}";
let fa = build_fa(src);
let def_self = fa.find_definition(Point::new(12, 12), None);
assert!(
def_self.is_some(),
"should find definition for $self in deref"
);
assert_eq!(
def_self.unwrap().start.row,
10,
"$self should resolve to declaration on line 10"
);
let def_history = fa.find_definition(Point::new(12, 20), None);
assert!(
def_history.is_some(),
"should find definition for history hash key"
);
assert_eq!(
def_history.unwrap().start.row,
4,
"history key should resolve to definition on line 4"
);
}
#[test]
fn test_imports_qw() {
let source = "use List::Util qw(first any all);\nuse Scalar::Util qw(blessed);\n";
let fa = build_fa(source);
assert_eq!(fa.imports.len(), 2);
assert_eq!(fa.imports[0].module_name, "List::Util");
let names0: Vec<&str> = fa.imports[0]
.imported_symbols
.iter()
.map(|s| s.local_name.as_str())
.collect();
assert_eq!(names0, vec!["first", "any", "all"]);
assert_eq!(fa.imports[1].module_name, "Scalar::Util");
let names1: Vec<&str> = fa.imports[1]
.imported_symbols
.iter()
.map(|s| s.local_name.as_str())
.collect();
assert_eq!(names1, vec!["blessed"]);
}
#[test]
fn test_imports_qw_close_paren_position() {
let source = "use List::Util qw(first);\n";
let fa = build_fa(source);
assert_eq!(fa.imports.len(), 1);
let imp = &fa.imports[0];
assert!(imp.qw_close_paren.is_some(), "qw_close_paren should be set");
let pos = imp.qw_close_paren.unwrap();
assert_eq!(pos.row, 0);
assert_eq!(pos.column, 23, "close paren should be at column 23");
}
#[test]
fn test_imports_bare() {
let source = "use strict;\nuse warnings;\nuse Carp;\n";
let fa = build_fa(source);
let carp = fa.imports.iter().find(|i| i.module_name == "Carp");
assert!(carp.is_some());
assert!(carp.unwrap().imported_symbols.is_empty());
}
#[test]
fn test_imports_module_symbol_created() {
let source = "use List::Util qw(first);\n";
let fa = build_fa(source);
let module_syms: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Module && s.name == "List::Util")
.collect();
assert_eq!(module_syms.len(), 1);
assert_eq!(fa.imports.len(), 1);
let names: Vec<&str> = fa.imports[0]
.imported_symbols
.iter()
.map(|s| s.local_name.as_str())
.collect();
assert_eq!(names, vec!["first"]);
}
#[test]
fn test_goto_def_slurpy_hash_arg_at_call_site() {
let src = r#"package Calculator;
sub new {
my ($class, %args) = @_;
my $self = bless {
verbose => $args{verbose} // 0,
}, $class;
return $self;
}
package main;
my $calc = Calculator->new(verbose => 1);
"#;
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let def = fa.find_definition(Point::new(9, 27), None);
assert!(
def.is_some(),
"should find definition for verbose at call site"
);
assert_eq!(
def.unwrap().start.row,
4,
"verbose should resolve to bless hash key def on line 4, not sub new"
);
}
#[test]
fn test_goto_def_param_field_at_call_site() {
let src = r#"use v5.38;
class Point {
field $x :param :reader;
field $y :param;
method magnitude() { }
}
my $p = Point->new(x => 3, y => 4);
"#;
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let def = fa.find_definition(Point::new(6, 19), None);
assert!(def.is_some(), "should find definition for x at call site");
assert_eq!(
def.unwrap().start.row,
2,
"x should resolve to field $x on line 2, not the class"
);
}
#[test]
fn test_dunder_package_resolution() {
let fa = build_fa(
"
package Mojo::File;
sub path { __PACKAGE__->new(@_) }
",
);
let rt = fa.sub_return_type_at_arity("path", None);
assert_eq!(rt, Some(InferredType::ClassName("Mojo::File".into())));
}
#[test]
fn test_dunder_package_method_invocant() {
let fa = build_fa(
"
package Foo;
__PACKAGE__->some_method();
",
);
let method_ref = fa
.refs
.iter()
.find(|r| r.target_name == "some_method")
.unwrap();
match &method_ref.kind {
RefKind::MethodCall { invocant, .. } => {
assert_eq!(
invocant, "Foo",
"invocant should be resolved from __PACKAGE__"
);
}
_ => panic!("expected MethodCall ref"),
}
}
#[test]
fn test_shift_params() {
let fa = build_fa(
"
sub process {
my $self = shift;
my $file = shift;
my $opts = shift || {};
}
",
);
let sig = fa
.signature_for_call("process", false, None, Point::new(0, 0), None)
.unwrap();
assert!(sig.is_method, "should detect method from $self first param");
assert_eq!(sig.params.len(), 2);
assert_eq!(sig.params[0].name, "$file");
assert_eq!(sig.params[1].name, "$opts");
assert_eq!(sig.params[1].default, Some("{}".into()));
let sub_sym = fa.symbols.iter().find(|s| s.name == "process").unwrap();
if let SymbolDetail::Sub { ref params, .. } = sub_sym.detail {
assert_eq!(params.len(), 3);
assert_eq!(params[0].name, "$self");
assert_eq!(params[1].name, "$file");
assert_eq!(params[2].name, "$opts");
assert_eq!(params[2].default, Some("{}".into()));
} else {
panic!("expected Sub detail");
}
}
#[test]
fn test_shift_then_list_assign() {
let fa = build_fa(
"
sub process {
my $self = shift;
my ($file, @opts) = @_;
}
",
);
let sig = fa
.signature_for_call("process", false, None, Point::new(0, 0), None)
.unwrap();
assert!(sig.is_method);
assert_eq!(
sig.params.len(),
2,
"should have $file and @opts (stripped $self)"
);
assert_eq!(sig.params[0].name, "$file");
assert_eq!(sig.params[1].name, "@opts");
assert!(sig.params[1].is_slurpy);
let sub_sym = fa.symbols.iter().find(|s| s.name == "process").unwrap();
if let SymbolDetail::Sub { ref params, .. } = sub_sym.detail {
assert_eq!(params.len(), 3);
assert_eq!(params[0].name, "$self");
} else {
panic!("expected Sub detail");
}
}
#[test]
fn test_shift_with_double_pipe_default() {
let fa = build_fa(
"
sub handler {
my $self = shift;
my $timeout = shift || 30;
}
",
);
let sig = fa
.signature_for_call("handler", false, None, Point::new(0, 0), None)
.unwrap();
assert_eq!(sig.params.len(), 1, "stripped $self");
assert_eq!(sig.params[0].name, "$timeout");
assert_eq!(sig.params[0].default, Some("30".into()));
}
#[test]
fn test_shift_with_defined_or_default() {
let fa = build_fa(
"
sub handler {
my $self = shift;
my $verbose = shift // 0;
}
",
);
let sig = fa
.signature_for_call("handler", false, None, Point::new(0, 0), None)
.unwrap();
assert_eq!(sig.params.len(), 1, "stripped $self");
assert_eq!(sig.params[0].name, "$verbose");
assert_eq!(sig.params[0].default, Some("0".into()));
}
#[test]
fn test_subscript_param() {
let fa = build_fa(
"
sub handler {
my $self = $_[0];
my $data = $_[1];
}
",
);
let sig = fa
.signature_for_call("handler", false, None, Point::new(0, 0), None)
.unwrap();
assert_eq!(sig.params.len(), 1, "stripped $self");
assert_eq!(sig.params[0].name, "$data");
}
#[test]
fn test_legacy_at_params_still_work() {
let fa = build_fa(
"
sub process {
my ($first, $file, @opts) = @_;
}
",
);
let sig = fa
.signature_for_call("process", false, None, Point::new(0, 0), None)
.unwrap();
assert_eq!(sig.params.len(), 3);
assert_eq!(sig.params[0].name, "$first");
assert_eq!(sig.params[1].name, "$file");
assert_eq!(sig.params[2].name, "@opts");
}
#[test]
fn test_tail_pod_item_method() {
let fa = build_fa(
"
package WWW::Mech;
sub get { }
sub post { }
=head1 METHODS
=over
=item $mech->get($url)
Performs a GET request.
=item $mech->post($url)
Performs a POST request.
=back
=cut
",
);
let get_doc = fa
.symbols
.iter()
.find(|s| s.name == "get")
.and_then(|s| match &s.detail {
SymbolDetail::Sub { doc, .. } => doc.as_ref(),
_ => None,
});
assert!(get_doc.is_some(), "get should have doc from =item");
assert!(get_doc.unwrap().contains("GET request"));
}
#[test]
fn test_pod_doc_extracted_per_function() {
let src = "\
package DemoUtils;
use Exporter 'import';
our @EXPORT_OK = qw(fetch_data transform);
=head2 fetch_data
Fetches data from the given URL.
=head2 transform
Transforms items.
=cut
sub fetch_data { }
sub transform { }
";
let fa = build_fa(src);
let fd = fa.symbols.iter().find(|s| s.name == "fetch_data").unwrap();
if let SymbolDetail::Sub { ref doc, .. } = fd.detail {
let d = doc.as_ref().expect("fetch_data should have doc");
assert!(
d.contains("Fetches data"),
"should have fetch_data doc, got: {}",
d
);
assert!(
!d.contains("Transforms items"),
"should NOT have transform doc, got: {}",
d
);
} else {
panic!("fetch_data should be a Sub");
}
}
#[test]
fn test_use_parent_single() {
let fa = build_fa(
"
package Child;
use parent 'Parent';
sub child_method { }
",
);
assert_eq!(
fa.package_parents.get("Child").unwrap(),
&vec!["Parent".to_string()]
);
}
#[test]
fn test_use_parent_multiple() {
let fa = build_fa(
"
package Multi;
use parent qw(Foo Bar);
",
);
assert_eq!(
fa.package_parents.get("Multi").unwrap(),
&vec!["Foo".to_string(), "Bar".to_string()]
);
}
#[test]
fn test_use_parent_emits_package_refs() {
let fa = build_fa(
"
package Child;
use parent 'Parent';
",
);
let refs: Vec<_> = fa
.refs
.iter()
.filter(|r| matches!(r.kind, RefKind::PackageRef) && r.target_name == "Parent")
.collect();
assert_eq!(refs.len(), 1, "should emit PackageRef for parent class");
}
#[test]
fn test_use_parent_qw_emits_package_refs() {
let fa = build_fa(
"
package Multi;
use parent qw(Foo Bar);
",
);
let foo_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| matches!(r.kind, RefKind::PackageRef) && r.target_name == "Foo")
.collect();
let bar_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| matches!(r.kind, RefKind::PackageRef) && r.target_name == "Bar")
.collect();
assert_eq!(foo_refs.len(), 1, "should emit PackageRef for Foo");
assert_eq!(bar_refs.len(), 1, "should emit PackageRef for Bar");
}
#[test]
fn test_use_parent_norequire() {
let fa = build_fa(
"
package Local;
use parent -norequire, 'My::Base';
",
);
assert_eq!(
fa.package_parents.get("Local").unwrap(),
&vec!["My::Base".to_string()]
);
}
#[test]
fn test_use_base() {
let fa = build_fa(
"
package Old;
use base 'Legacy::Base';
",
);
assert_eq!(
fa.package_parents.get("Old").unwrap(),
&vec!["Legacy::Base".to_string()]
);
}
#[test]
fn test_isa_assignment() {
let fa = build_fa(
"
package Direct;
our @ISA = ('Alpha', 'Beta');
",
);
assert_eq!(
fa.package_parents.get("Direct").unwrap(),
&vec!["Alpha".to_string(), "Beta".to_string()]
);
}
#[test]
fn test_class_isa_populates_package_parents() {
let fa = build_fa(
"
class Child :isa(Parent) { }
",
);
assert_eq!(
fa.package_parents.get("Child").unwrap(),
&vec!["Parent".to_string()]
);
}
#[test]
fn test_class_does_populates_package_parents() {
let fa = build_fa(
"
class MyClass :does(Printable) :does(Serializable) { }
",
);
let parents = fa.package_parents.get("MyClass").unwrap();
assert!(parents.contains(&"Printable".to_string()));
assert!(parents.contains(&"Serializable".to_string()));
}
#[test]
fn test_class_isa_and_does_combined() {
let fa = build_fa(
"
class Child :isa(Parent) :does(Role) { }
",
);
let parents = fa.package_parents.get("Child").unwrap();
assert_eq!(parents, &vec!["Parent".to_string(), "Role".to_string()]);
}
#[test]
fn test_with_role_populates_package_parents() {
let fa = build_fa(
"
package MyApp;
use Moo;
with 'My::Role::Logging';
",
);
let parents = fa.package_parents.get("MyApp").unwrap();
assert!(parents.contains(&"My::Role::Logging".to_string()));
}
#[test]
fn test_with_multiple_roles() {
let fa = build_fa(
"
package MyApp;
use Moose;
with 'Role::A', 'Role::B';
",
);
let parents = fa.package_parents.get("MyApp").unwrap();
assert!(parents.contains(&"Role::A".to_string()));
assert!(parents.contains(&"Role::B".to_string()));
}
#[test]
fn test_moox_option_synthesizes_accessor_and_ctor_key() {
let fa = build_fa(
"
package MyApp;
use Moo;
use MooX::Options;
option 'verbose' => (is => 'ro', format => 'i', doc => 'noisy');
option name => (is => 'rw', isa => 'Str');
",
);
let methods: Vec<&str> = fa
.symbols
.iter()
.filter(|s| s.kind == crate::file_analysis::SymKind::Method)
.map(|s| s.name.as_str())
.collect();
assert!(methods.contains(&"verbose"), "ro accessor: {methods:?}");
assert!(methods.contains(&"name"), "rw accessor: {methods:?}");
let ctor_keys: Vec<&str> = fa
.symbols
.iter()
.filter(|s| {
matches!(
&s.detail,
crate::file_analysis::SymbolDetail::HashKeyDef {
owner: crate::file_analysis::HashKeyOwner::Sub { name, .. },
..
} if name == "new"
)
})
.map(|s| s.name.as_str())
.collect();
assert!(ctor_keys.contains(&"verbose"), "ctor key verbose: {ctor_keys:?}");
assert!(ctor_keys.contains(&"name"), "ctor key name: {ctor_keys:?}");
}
#[test]
fn test_option_without_moox_is_not_an_accessor() {
let fa = build_fa(
"
package MyApp;
use Moo;
option 'verbose' => (is => 'ro');
",
);
let methods: Vec<&str> = fa
.symbols
.iter()
.filter(|s| s.kind == crate::file_analysis::SymKind::Method)
.map(|s| s.name.as_str())
.collect();
assert!(!methods.contains(&"verbose"), "no synthesis without MooX::Options: {methods:?}");
}
#[test]
fn test_moox_options_plain_has_still_works() {
let fa = build_fa(
"
package MyApp;
use Moo;
use MooX::Options;
has plain => (is => 'ro', isa => 'Int');
",
);
let methods: Vec<&str> = fa
.symbols
.iter()
.filter(|s| s.kind == crate::file_analysis::SymKind::Method)
.map(|s| s.name.as_str())
.collect();
assert!(methods.contains(&"plain"), "plain has accessor: {methods:?}");
}
#[test]
fn test_with_role_method_resolves_cross_file() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"My::Role::Logging",
Some(fake_cached_for_class(
"My::Role::Logging",
&PathBuf::from("/fake/My/Role/Logging.pm"),
&["log_it"],
&[],
)),
);
let fa = build_fa(
"
package MyApp;
use Moo;
with 'My::Role::Logging';
sub run { my $self = shift; }
",
);
let methods = fa.complete_methods_for_class("MyApp", Some(&idx));
let names: Vec<&str> = methods.iter().map(|c| c.label.as_str()).collect();
assert!(names.contains(&"log_it"), "role method in completion: {names:?}");
let res = fa.resolve_method_in_ancestors("MyApp", "log_it", Some(&idx));
assert!(
matches!(res, Some(crate::file_analysis::MethodResolution::CrossFile { ref class, .. }) if class == "My::Role::Logging"),
"expected CrossFile to the role, got {res:?}"
);
}
#[test]
fn test_load_components_bare() {
let fa = build_fa(
"
package MySchema::Result::User;
use base 'DBIx::Class::Core';
__PACKAGE__->load_components('InflateColumn::DateTime', 'TimeStamp');
",
);
let parents = fa.package_parents.get("MySchema::Result::User").unwrap();
assert!(parents.contains(&"DBIx::Class::Core".to_string()));
assert!(parents.contains(&"DBIx::Class::InflateColumn::DateTime".to_string()));
assert!(parents.contains(&"DBIx::Class::TimeStamp".to_string()));
}
#[test]
fn test_load_components_plus_prefix() {
let fa = build_fa(
"
package MySchema::Result::User;
use base 'DBIx::Class::Core';
__PACKAGE__->load_components('+My::Custom::Component');
",
);
let parents = fa.package_parents.get("MySchema::Result::User").unwrap();
assert!(parents.contains(&"My::Custom::Component".to_string()));
}
#[test]
fn test_load_components_qw() {
let fa = build_fa(
"
package MySchema::ResultSet::User;
use base 'DBIx::Class::Core';
__PACKAGE__->load_components(qw(Helper::ResultSet::Shortcut Helper::ResultSet::Me));
",
);
let parents = fa.package_parents.get("MySchema::ResultSet::User").unwrap();
assert!(parents.contains(&"DBIx::Class::Helper::ResultSet::Shortcut".to_string()));
assert!(parents.contains(&"DBIx::Class::Helper::ResultSet::Me".to_string()));
}
#[test]
fn test_inherited_method_completion() {
let fa = build_fa(
"
package Animal;
sub speak { }
sub eat { }
package Dog;
use parent 'Animal';
sub fetch { }
",
);
let methods = fa.complete_methods_for_class("Dog", None);
let names: Vec<&str> = methods.iter().map(|c| c.label.as_str()).collect();
assert!(names.contains(&"fetch"), "own method");
assert!(names.contains(&"speak"), "inherited from Animal");
assert!(names.contains(&"eat"), "inherited from Animal");
}
#[test]
fn test_child_method_overrides_parent() {
let fa = build_fa(
"
package Base;
sub greet { }
package Override;
use parent 'Base';
sub greet { }
",
);
let methods = fa.complete_methods_for_class("Override", None);
let greet_count = methods.iter().filter(|c| c.label == "greet").count();
assert_eq!(greet_count, 1, "child override should shadow parent");
}
#[test]
fn test_find_method_in_parent() {
let fa = build_fa(
"
package Base;
sub base_method { }
package Child;
use parent 'Base';
",
);
let span = fa.find_method_in_class("Child", "base_method");
assert!(span.is_some(), "should find inherited method");
}
#[test]
fn test_inherited_return_type() {
let fa = build_fa(
"
package Factory;
sub create { Factory->new(@_) }
package SpecialFactory;
use parent 'Factory';
",
);
let rt = fa.find_method_return_type("SpecialFactory", "create", None, None);
assert!(rt.is_some(), "should find return type from parent");
}
#[test]
fn test_multi_level_inheritance() {
let fa = build_fa(
"
package A;
sub from_a { }
package B;
use parent 'A';
sub from_b { }
package C;
use parent 'B';
sub from_c { }
",
);
let methods = fa.complete_methods_for_class("C", None);
let names: Vec<&str> = methods.iter().map(|c| c.label.as_str()).collect();
assert!(names.contains(&"from_a"));
assert!(names.contains(&"from_b"));
assert!(names.contains(&"from_c"));
}
#[test]
fn test_class_isa_inherits_methods() {
let fa = build_fa(
"
class Parent {
method greet() { }
}
class Child :isa(Parent) {
method wave() { }
}
",
);
let methods = fa.complete_methods_for_class("Child", None);
let names: Vec<&str> = methods.iter().map(|c| c.label.as_str()).collect();
assert!(names.contains(&"wave"), "own method");
assert!(names.contains(&"greet"), "inherited from Parent");
}
fn fake_cached_for_class(
package_name: &str,
path: &std::path::Path,
subs: &[&str],
parents: &[&str],
) -> std::sync::Arc<crate::module_index::CachedModule> {
let mut source = format!("package {};\n", package_name);
if !parents.is_empty() {
source.push_str(&format!("use parent '{}';\n", parents.join("', '")));
}
for sub in subs {
source.push_str(&format!("sub {} {{ my $self = shift; }}\n", sub));
}
source.push_str("1;\n");
let fa = build_fa(&source);
std::sync::Arc::new(crate::module_index::CachedModule::new(
path.to_path_buf(),
std::sync::Arc::new(fa),
))
}
#[test]
fn test_cross_file_inherited_method_completion() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"DBI",
Some(fake_cached_for_class(
"DBI",
&PathBuf::from("/fake/DBI.pm"),
&["connect"],
&[],
)),
);
idx.insert_cache(
"DBI::db",
Some(fake_cached_for_class(
"DBI::db",
&PathBuf::from("/fake/DBI/db.pm"),
&["prepare"],
&["DBI"],
)),
);
let fa = build_fa(
"
package MyDB;
use parent 'DBI::db';
sub custom_query { }
",
);
let methods = fa.complete_methods_for_class("MyDB", Some(&idx));
let names: Vec<&str> = methods.iter().map(|c| c.label.as_str()).collect();
assert!(names.contains(&"custom_query"), "own method");
assert!(names.contains(&"prepare"), "from DBI::db");
assert!(names.contains(&"connect"), "from DBI (grandparent)");
}
#[test]
fn test_cross_file_method_override() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"Base::Worker",
Some(fake_cached_for_class(
"Base::Worker",
&PathBuf::from("/fake/Base/Worker.pm"),
&["process"],
&[],
)),
);
let fa = build_fa(
"
package MyWorker;
use parent 'Base::Worker';
sub process { }
",
);
let methods = fa.complete_methods_for_class("MyWorker", Some(&idx));
let process_count = methods.iter().filter(|c| c.label == "process").count();
assert_eq!(process_count, 1, "local override should shadow parent");
}
#[test]
fn test_cross_file_return_type_through_inheritance() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
let source = r#"
package Fetcher;
sub fetch {
my $self = shift;
return { status => 1, body => 'ok' };
}
1;
"#;
let fa_parent = build_fa(source);
idx.insert_cache(
"Fetcher",
Some(std::sync::Arc::new(crate::module_index::CachedModule::new(
PathBuf::from("/fake/Fetcher.pm"),
std::sync::Arc::new(fa_parent),
))),
);
let fa = build_fa(
"
package MyFetcher;
use parent 'Fetcher';
",
);
let rt = fa.find_method_return_type("MyFetcher", "fetch", Some(&idx), None);
assert!(rt.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_parents_cached() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"Child::Mod",
Some(fake_cached_for_class(
"Child::Mod",
&PathBuf::from("/fake/Child/Mod.pm"),
&[],
&["Parent::Mod", "Mixin::Role"],
)),
);
let parents = idx.parents_cached("Child::Mod");
assert_eq!(parents, vec!["Parent::Mod", "Mixin::Role"]);
assert!(idx.parents_cached("Unknown::Mod").is_empty());
}
#[test]
fn test_cross_bag_inheritance_cycle_does_not_overflow() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"Cycle::A",
Some(fake_cached_for_class(
"Cycle::A",
&PathBuf::from("/fake/Cycle/A.pm"),
&[],
&["Cycle::B"],
)),
);
idx.insert_cache(
"Cycle::B",
Some(fake_cached_for_class(
"Cycle::B",
&PathBuf::from("/fake/Cycle/B.pm"),
&[],
&["Cycle::A"],
)),
);
let fa = build_fa("package main; 1;");
assert_eq!(
fa.find_method_return_type("Cycle::A", "no_such_method", Some(&idx), None),
None,
);
assert_eq!(
fa.find_method_return_type("Cycle::B", "no_such_method", Some(&idx), None),
None,
);
}
#[test]
fn test_method_call_return_type_propagates() {
let fa = build_fa(
"
package Foo;
sub new { bless {}, shift }
sub get_config {
return { host => 'localhost', port => 5432 };
}
package main;
my $f = Foo->new();
my $cfg = $f->get_config();
$cfg;
",
);
let ty = fa.inferred_type_via_bag("$cfg", Point::new(9, 0));
assert!(ty.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_method_call_chain_propagation() {
let fa = build_fa(
"
package Foo;
sub new { bless {}, shift }
sub get_bar { return Bar->new() }
package Bar;
sub new { bless {}, shift }
sub get_name { return { name => 'test' } }
package main;
my $f = Foo->new();
my $bar = $f->get_bar();
my $name = $bar->get_name();
$name;
",
);
let bar_ty = fa.inferred_type_via_bag("$bar", Point::new(10, 0));
assert_eq!(bar_ty, Some(InferredType::ClassName("Bar".into())));
let name_ty = fa.inferred_type_via_bag("$name", Point::new(11, 0));
assert!(name_ty.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_self_method_call_return_type() {
let fa = build_fa(
"
package Foo;
sub new { bless {}, shift }
sub get_config { return { host => 1 } }
sub run {
my ($self) = @_;
my $cfg = $self->get_config();
$cfg;
}
",
);
let ty = fa.inferred_type_via_bag("$cfg", Point::new(7, 4));
assert!(ty.is_some_and(|t| t.is_hash_shaped()), "hash-shaped");
}
#[test]
fn test_moo_has_ro() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'name' => (is => 'ro');
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 1, "should synthesize one getter");
if let SymbolDetail::Sub {
ref params,
is_method,
..
} = methods[0].detail
{
assert!(is_method);
assert!(params.is_empty(), "ro getter has no params");
}
}
#[test]
fn test_moo_has_rw() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'name' => (is => 'rw');
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 2, "should synthesize getter + setter");
}
#[test]
fn test_moo_has_isa_type() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'count' => (is => 'ro', isa => 'Int');
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "count" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 1);
let __r = fa.symbol_return_type_via_bag(methods[0].id, None);
let return_type = __r.as_ref();
if matches!(methods[0].detail, SymbolDetail::Sub { .. }) {
assert_eq!(return_type, Some(&InferredType::Numeric));
}
}
#[test]
fn test_moo_has_multiple_qw() {
let fa = build_fa(
"
package Foo;
use Moo;
has [qw(foo bar)] => (is => 'ro');
",
);
let foo: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "foo" && s.kind == SymKind::Method)
.collect();
let bar: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "bar" && s.kind == SymKind::Method)
.collect();
assert_eq!(foo.len(), 1, "should synthesize foo accessor");
assert_eq!(bar.len(), 1, "should synthesize bar accessor");
}
#[test]
fn test_moo_has_bare_no_accessor() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'internal' => (is => 'bare');
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "internal" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 0, "bare should not synthesize accessor");
}
#[test]
fn test_moo_no_accessor_without_is() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'internal';
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "internal" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 0, "no `is` should not synthesize accessor");
}
#[test]
fn test_moose_has_classname_isa() {
let fa = build_fa(
"
package Foo;
use Moose;
has 'db' => (is => 'ro', isa => 'DBI::db');
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "db" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 1);
let __r = fa.symbol_return_type_via_bag(methods[0].id, None);
let return_type = __r.as_ref();
if matches!(methods[0].detail, SymbolDetail::Sub { .. }) {
assert_eq!(
return_type,
Some(&InferredType::ClassName("DBI::db".into()))
);
}
}
#[test]
fn test_moo_has_instanceof() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'logger' => (is => 'ro', isa => \"InstanceOf['Log::Any']\");
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "logger" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 1);
let __r = fa.symbol_return_type_via_bag(methods[0].id, None);
let return_type = __r.as_ref();
if matches!(methods[0].detail, SymbolDetail::Sub { .. }) {
assert_eq!(
return_type,
Some(&InferredType::ClassName("Log::Any".into()))
);
}
}
#[test]
fn test_moo_has_rwp() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'status' => (is => 'rwp');
",
);
let getter: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "status" && s.kind == SymKind::Method)
.collect();
let writer: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "_set_status" && s.kind == SymKind::Method)
.collect();
assert_eq!(getter.len(), 1, "rwp should synthesize getter");
assert_eq!(writer.len(), 1, "rwp should synthesize _set_name writer");
}
#[test]
fn test_moo_has_accessor_keyword() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'x' => (is => 'rw', accessor => 'get_set_x');
",
);
let acc: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "get_set_x" && s.kind == SymKind::Method)
.collect();
assert_eq!(acc.len(), 1, "accessor keyword should synthesize get_set_x method");
let default_acc: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "x" && s.kind == SymKind::Method)
.collect();
assert!(!default_acc.is_empty(), "default attr accessor still synthesized");
}
#[test]
fn test_moo_has_ro_does_not_synthesize_ro_symbol() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'y' => (is => 'ro');
",
);
let ro_sym: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "ro")
.collect();
assert!(ro_sym.is_empty(), "`is => 'ro'` must not mint a symbol named `ro`");
}
#[test]
fn test_mojo_has_basic() {
let fa = build_fa(
"
package Foo;
use Mojo::Base -base;
has 'name';
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 2, "Mojo::Base synthesizes getter + setter");
let getter = methods
.iter()
.find(|m| {
if let SymbolDetail::Sub { ref params, .. } = m.detail {
params.is_empty()
} else {
false
}
})
.expect("should have getter");
let __r = fa.symbol_return_type_via_bag(getter.id, None);
let return_type = __r.as_ref();
if let SymbolDetail::Sub { is_method, .. } = getter.detail {
assert!(is_method);
assert!(return_type.is_none(), "getter has no return type");
}
let setter = methods
.iter()
.find(|m| {
if let SymbolDetail::Sub { ref params, .. } = m.detail {
params.len() == 1
} else {
false
}
})
.expect("should have setter");
let __r = fa.symbol_return_type_via_bag(setter.id, None);
let return_type = __r.as_ref();
if let SymbolDetail::Sub { is_method, .. } = setter.detail {
assert!(is_method);
assert_eq!(
return_type,
Some(&InferredType::ClassName("Foo".into()))
);
}
}
#[test]
fn test_mojo_base_writer_returns_invocant_via_bag() {
use crate::file_analysis::TypeProvenance;
let fa = build_fa(
"
package MyLog;
use Mojo::Base -base;
has level => 'info'; # default value gives getter type = String
has app; # no default; getter has no return type
",
);
assert_eq!(
fa.sub_return_type_at_arity("level", Some(1)),
Some(InferredType::ClassName("MyLog".into())),
"Mojo::Base writer at arity=1 must return the invocant class for fluent chaining"
);
assert_eq!(
fa.sub_return_type_at_arity("app", Some(1)),
Some(InferredType::ClassName("MyLog".into())),
"Mojo::Base writer with no default still returns the invocant class"
);
assert_eq!(
fa.sub_return_type_at_arity("level", Some(0)),
Some(InferredType::String),
"Mojo::Base getter at arity=0 returns the default-value type"
);
let getter = fa
.symbols
.iter()
.find(|s| s.name == "level" && matches!(&s.detail, SymbolDetail::Sub { params, .. } if params.is_empty()))
.expect("getter symbol");
let writer = fa
.symbols
.iter()
.find(|s| s.name == "level" && matches!(&s.detail, SymbolDetail::Sub { params, .. } if params.len() == 1))
.expect("writer symbol");
let getter_witnesses = fa
.witnesses
.for_attachment(&crate::witnesses::WitnessAttachment::Symbol(getter.id))
.len();
let writer_witnesses = fa
.witnesses
.for_attachment(&crate::witnesses::WitnessAttachment::Symbol(writer.id))
.len();
assert!(getter_witnesses > 0, "getter must have at least one bag witness");
assert!(writer_witnesses > 0, "writer must have at least one bag witness");
match fa.return_type_provenance(writer.id) {
TypeProvenance::FrameworkSynthesis { framework, reason } => {
assert_eq!(framework, "Mojo::Base");
assert!(reason.contains("level"), "reason names the attribute");
assert!(
reason.contains("fluent") || reason.contains("writer"),
"reason describes the writer role"
);
}
other => panic!("writer provenance must be FrameworkSynthesis, got {other:?}"),
}
match fa.return_type_provenance(getter.id) {
TypeProvenance::FrameworkSynthesis { framework, .. } => {
assert_eq!(framework, "Mojo::Base");
}
other => panic!("getter provenance must be FrameworkSynthesis, got {other:?}"),
}
}
#[test]
fn test_moo_rw_writer_returns_isa_type_via_bag() {
use crate::file_analysis::TypeProvenance;
let fa = build_fa(
"
package Thing;
use Moo;
has size => (is => 'rw', isa => 'Int');
",
);
assert_eq!(
fa.sub_return_type_at_arity("size", Some(0)),
Some(InferredType::Numeric),
"Moo getter returns isa type"
);
assert_eq!(
fa.sub_return_type_at_arity("size", Some(1)),
Some(InferredType::Numeric),
"Moo rw writer returns isa type"
);
let writer = fa
.symbols
.iter()
.find(|s| s.name == "size" && matches!(&s.detail, SymbolDetail::Sub { params, .. } if params.len() == 1))
.expect("writer symbol");
match fa.return_type_provenance(writer.id) {
TypeProvenance::FrameworkSynthesis { framework, .. } => {
assert_eq!(framework, "Moo");
}
other => panic!("Moo writer provenance must be FrameworkSynthesis, got {other:?}"),
}
}
#[test]
fn test_mojo_base_parent_inheritance() {
let fa = build_fa(
"
package MyApp;
use Mojo::Base 'Mojolicious';
has 'config';
",
);
assert_eq!(
fa.package_parents.get("MyApp").map(|v| v.as_slice()),
Some(["Mojolicious".to_string()].as_slice())
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "config" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 2, "Mojo::Base synthesizes getter + setter");
}
#[test]
fn test_mojo_base_strict_no_accessor() {
let fa = build_fa(
"
package Foo;
use Mojo::Base -strict;
has 'name';
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.collect();
assert_eq!(
methods.len(),
0,
"-strict should not trigger accessor synthesis"
);
}
#[test]
fn test_no_accessor_without_framework() {
let fa = build_fa(
"
package Foo;
has 'name' => (is => 'ro');
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 0, "no framework = no accessor synthesis");
}
#[test]
fn test_dbic_add_columns() {
let fa = build_fa(
"
package Schema::Result::User;
use base 'DBIx::Class::Core';
__PACKAGE__->add_columns(
id => { data_type => 'integer' },
name => { data_type => 'varchar' },
email => { data_type => 'varchar' },
);
",
);
let id: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "id" && s.kind == SymKind::Method)
.collect();
let name: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.collect();
let email: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "email" && s.kind == SymKind::Method)
.collect();
assert_eq!(id.len(), 1, "should synthesize id accessor");
assert_eq!(name.len(), 1, "should synthesize name accessor");
assert_eq!(email.len(), 1, "should synthesize email accessor");
}
#[test]
fn test_dbic_has_many() {
let fa = build_fa(
"
package Schema::Result::Post;
use base 'DBIx::Class::Core';
__PACKAGE__->has_many(comments => 'Schema::Result::Comment', 'post_id');
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "comments" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 1);
let __r = fa.symbol_return_type_via_bag(methods[0].id, None);
let return_type = __r.as_ref();
if matches!(methods[0].detail, SymbolDetail::Sub { .. }) {
assert_eq!(
return_type,
Some(&InferredType::ClassName("DBIx::Class::ResultSet".into()))
);
}
}
#[test]
fn test_dbic_belongs_to() {
let fa = build_fa(
"
package Schema::Result::Comment;
use base 'DBIx::Class::Core';
__PACKAGE__->belongs_to(author => 'Schema::Result::User', 'author_id');
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "author" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 1);
let __r = fa.symbol_return_type_via_bag(methods[0].id, None);
let return_type = __r.as_ref();
if matches!(methods[0].detail, SymbolDetail::Sub { .. }) {
assert_eq!(
return_type,
Some(&InferredType::ClassName("Schema::Result::User".into()))
);
}
}
#[test]
fn test_dbic_instance_add_columns_does_not_synthesize() {
let fa = build_fa(
"
package My::RS;
use base 'DBIx::Class::ResultSet';
__PACKAGE__->add_columns('decl_col');
sub widen {
my $self = shift;
$self->add_columns('runtime_col');
}
",
);
let decl: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "decl_col" && s.kind == SymKind::Method)
.collect();
let runtime: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "runtime_col" && s.kind == SymKind::Method)
.collect();
assert_eq!(decl.len(), 1, "class-level __PACKAGE__->add_columns declares");
assert_eq!(
runtime.len(),
0,
"instance $rs->add_columns is a runtime op, not a declaration"
);
}
#[test]
fn test_accessor_return_type_propagation() {
let src = r#"
package Moo::Config;
use Moo;
has 'host' => (is => 'ro', isa => 'Str');
sub dsn { my ($self) = @_; return "x"; }
package Moo::Service;
use Moo;
has 'config' => (is => 'ro', isa => "InstanceOf['Moo::Config']");
sub run {
my ($self) = @_;
my $cfg = $self->config;
my $dsn = $cfg->dsn;
}
"#;
let fa = build_fa(src);
let config_methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "config" && s.kind == SymKind::Method)
.collect();
assert_eq!(config_methods.len(), 1, "should have 1 config accessor");
assert_eq!(config_methods[0].package.as_deref(), Some("Moo::Service"));
let __r = fa.symbol_return_type_via_bag(config_methods[0].id, None);
let return_type = __r.as_ref();
if matches!(config_methods[0].detail, SymbolDetail::Sub { .. }) {
assert_eq!(
return_type,
Some(&InferredType::ClassName("Moo::Config".into())),
"config accessor should return Moo::Config"
);
}
let cfg_binding = fa
.method_call_bindings
.iter()
.find(|b| b.variable == "$cfg");
assert!(
cfg_binding.is_some(),
"should have method call binding for $cfg"
);
assert!(
fa.call_bindings
.iter()
.find(|b| b.variable == "$cfg")
.is_none(),
"$cfg should NOT be a function call binding"
);
let cfg_type = fa.inferred_type_via_bag("$cfg", tree_sitter::Point::new(13, 0));
assert_eq!(
cfg_type,
Some(InferredType::ClassName("Moo::Config".into())),
"$cfg should be Moo::Config, not Moo::Service"
);
let dsn_binding = fa
.method_call_bindings
.iter()
.find(|b| b.variable == "$dsn");
assert!(
dsn_binding.is_some(),
"should have method call binding for $dsn"
);
}
#[test]
fn test_mojo_getter_setter_distinct() {
let fa = build_fa(
"
package Foo;
use Mojo::Base -base;
has 'name';
",
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 2, "should synthesize getter + setter");
let getter = methods.iter().find(|m| {
if let SymbolDetail::Sub { ref params, .. } = m.detail {
params.is_empty()
} else {
false
}
});
let setter = methods.iter().find(|m| {
if let SymbolDetail::Sub { ref params, .. } = m.detail {
params.len() == 1
} else {
false
}
});
assert!(getter.is_some(), "should have a 0-param getter");
assert!(setter.is_some(), "should have a 1-param setter");
let __r = fa.symbol_return_type_via_bag(getter.unwrap().id, None);
let return_type = __r.as_ref();
if let SymbolDetail::Sub { .. } = getter.unwrap().detail {
assert!(return_type.is_none());
}
let __r = fa.symbol_return_type_via_bag(setter.unwrap().id, None);
let return_type = __r.as_ref();
if let SymbolDetail::Sub { .. } = setter.unwrap().detail {
assert_eq!(
return_type,
Some(&InferredType::ClassName("Foo".into()))
);
}
}
#[test]
fn test_mojo_fluent_chain_resolves() {
let src = "
package Foo;
use Mojo::Base -base;
has 'name';
has 'age';
sub greet {
my ($self) = @_;
my $result = $self->name('Bob')->age;
return $result;
}
";
let fa = build_fa(src);
let method_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "age" && matches!(r.kind, RefKind::MethodCall { .. }))
.collect();
assert!(
!method_refs.is_empty(),
"should have method call ref for 'age'"
);
}
#[test]
fn test_moo_rw_arity_resolution() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'name' => (is => 'rw', isa => 'Str');
",
);
let rt_getter = fa.find_method_return_type("Foo", "name", None, Some(0));
assert_eq!(rt_getter, Some(InferredType::String));
let rt_setter = fa.find_method_return_type("Foo", "name", None, Some(1));
assert_eq!(rt_setter, Some(InferredType::String));
let rt_default = fa.find_method_return_type("Foo", "name", None, None);
assert_eq!(rt_default, Some(InferredType::String));
}
#[test]
fn method_on_class_disambiguates_same_name_across_classes() {
let fa = build_fa(
"
package Sweet;
use Mojo::Base -base;
has flavor => 'caramel';
package Sour;
use Mojo::Base -base;
has flavor => sub { [1, 2, 3] };
",
);
let sweet_getter_sym = fa
.symbols
.iter()
.find(|s| {
s.name == "flavor"
&& s.package.as_deref() == Some("Sweet")
&& matches!(&s.detail, SymbolDetail::Sub { params, .. } if params.is_empty())
})
.map(|s| s.id);
let sour_getter_sym = fa
.symbols
.iter()
.find(|s| {
s.name == "flavor"
&& s.package.as_deref() == Some("Sour")
&& matches!(&s.detail, SymbolDetail::Sub { params, .. } if params.is_empty())
})
.map(|s| s.id);
assert!(sweet_getter_sym.is_some(), "Sweet getter sym must exist");
assert!(sour_getter_sym.is_some(), "Sour getter sym must exist");
assert_ne!(sweet_getter_sym, sour_getter_sym);
assert_eq!(
fa.find_method_return_type("Sweet", "flavor", None, Some(0)),
Some(InferredType::String),
"Sweet::flavor getter returns String (from 'caramel' default), \
not Sour's ArrayRef"
);
assert!(
fa.find_method_return_type("Sour", "flavor", None, Some(0)).is_some_and(|t| t.is_array_shaped()),
"Sour::flavor getter returns ArrayRef (from sub-returning-array \
default), not Sweet's String",
);
assert_eq!(
fa.find_method_return_type("Sweet", "flavor", None, Some(1)),
Some(InferredType::ClassName("Sweet".into())),
);
assert_eq!(
fa.find_method_return_type("Sour", "flavor", None, Some(1)),
Some(InferredType::ClassName("Sour".into())),
);
}
#[test]
fn for_each_ancestor_class_walks_left_to_right_isa_order() {
let fa = build_fa(
"
package A;
sub m { return 'a' }
package B;
sub m { return 1 }
package C;
our @ISA = ('A', 'B');
",
);
assert_eq!(
fa.find_method_return_type("C", "m", None, None),
Some(InferredType::String),
"C->m must walk @ISA left-to-right and pick A::m, not B::m"
);
}
#[test]
fn test_mojo_arity_resolution() {
let fa = build_fa(
"
package Bar;
use Mojo::Base -base;
has 'title';
",
);
let rt_getter = fa.find_method_return_type("Bar", "title", None, Some(0));
assert!(rt_getter.is_none(), "getter should have no return type");
let rt_setter = fa.find_method_return_type("Bar", "title", None, Some(1));
assert_eq!(rt_setter, Some(InferredType::ClassName("Bar".into())));
let rt_default = fa.find_method_return_type("Bar", "title", None, None);
assert!(rt_default.is_none(), "default should return getter type");
}
#[test]
fn test_mojo_default_string_infers_type() {
let fa = build_fa(
"
package App;
use Mojo::Base -base;
has name => 'default';
",
);
let rt = fa.find_method_return_type("App", "name", None, Some(0));
assert_eq!(
rt,
Some(InferredType::String),
"string default → String getter"
);
}
#[test]
fn test_mojo_default_arrayref_infers_type() {
let fa = build_fa(
"
package App;
use Mojo::Base -base;
has items => sub { [] };
",
);
let rt = fa.find_method_return_type("App", "items", None, Some(0));
assert!(
rt.is_some_and(|t| t.is_array_shaped()),
"sub {{ [] }} default → ArrayRef getter",
);
}
#[test]
fn test_mojo_default_hashref_infers_type() {
let fa = build_fa(
"
package App;
use Mojo::Base -base;
has config => sub { {} };
",
);
let rt = fa.find_method_return_type("App", "config", None, Some(0));
assert!(
rt.is_some_and(|t| t.is_hash_shaped()),
"sub {{{{ }}}} default → HashRef getter",
);
}
#[test]
fn test_mojo_default_constructor_infers_type() {
let fa = build_fa(
"
package App;
use Mojo::Base -base;
has ua => sub { Mojo::UserAgent->new };
",
);
let rt = fa.find_method_return_type("App", "ua", None, Some(0));
assert_eq!(
rt,
Some(InferredType::ClassName("Mojo::UserAgent".into())),
"sub {{ Foo->new }} default → ClassName getter"
);
}
#[test]
fn test_mojo_default_number_infers_type() {
let fa = build_fa(
"
package App;
use Mojo::Base -base;
has timeout => 30;
",
);
let rt = fa.find_method_return_type("App", "timeout", None, Some(0));
assert_eq!(
rt,
Some(InferredType::Numeric),
"number default → Numeric getter"
);
}
#[test]
fn test_mojo_default_no_value_no_type() {
let fa = build_fa(
"
package App;
use Mojo::Base -base;
has 'name';
",
);
let rt = fa.find_method_return_type("App", "name", None, Some(0));
assert!(rt.is_none(), "no default → no getter type");
}
#[test]
fn test_builder_extracts_exports_qw() {
let fa = build_fa(
"
package Foo;
use Exporter 'import';
our @EXPORT = qw(delta);
our @EXPORT_OK = qw(alpha beta gamma);
",
);
assert_eq!(fa.export, vec!["delta"]);
assert_eq!(fa.export_ok, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn test_builder_extracts_exports_paren() {
let fa = build_fa(
"
package Bar;
our @EXPORT_OK = ('foo', 'bar', 'baz');
",
);
assert_eq!(fa.export_ok, vec!["foo", "bar", "baz"]);
}
#[test]
fn test_push_exports() {
let fa = build_fa(
"
package Foo;
use Exporter 'import';
our @EXPORT_OK = qw(foo);
push @EXPORT_OK, 'bar', 'baz';
",
);
assert_eq!(fa.export_ok, vec!["foo", "bar", "baz"]);
}
#[test]
fn test_exporter_extensible_export_call() {
let fa = build_fa(
"
package My::Ext;
use Exporter::Extensible -exporter_setup => 1;
export(qw( foo $bar @baz -tag ));
sub foo { 1 }
",
);
assert_eq!(fa.export_ok, vec!["foo"]);
}
#[test]
fn test_exporter_extensible_attribute() {
let fa = build_fa(
"
package My::Ext;
use Exporter::Extensible -exporter_setup => 1;
sub foo : Export(-tag) { 1 }
sub bar :Export { 2 }
sub plain { 3 }
",
);
assert!(fa.export_ok.contains(&"foo".to_string()));
assert!(fa.export_ok.contains(&"bar".to_string()));
assert!(!fa.export_ok.contains(&"plain".to_string()));
}
#[test]
fn test_exporter_declare_export_pair() {
let fa = build_fa(
"
package My::Decl;
use Exporter::Declare;
default_export foo => sub { 1 };
export bar => sub { 2 };
exports qw/a b/;
",
);
assert!(fa.export_ok.contains(&"foo".to_string()));
assert!(fa.export_ok.contains(&"bar".to_string()));
assert!(fa.export_ok.contains(&"a".to_string()));
assert!(fa.export_ok.contains(&"b".to_string()));
}
#[test]
fn test_exporter_call_gated_on_use() {
let fa = build_fa(
"
package Plain;
sub export { 1 }
export('not_an_export');
",
);
assert!(fa.export_ok.is_empty());
}
#[test]
fn test_export_ok_resolves_cross_file() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let provider_fa = build_fa(
"package Data::Fetcher;\nour @EXPORT_OK = qw(fetch_data);\nsub fetch_data { my ($url) = @_; }\n1;\n",
);
assert!(
provider_fa.export_ok.contains(&"fetch_data".to_string()),
"provider must record fetch_data in export_ok",
);
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"Data::Fetcher",
Some(std::sync::Arc::new(crate::module_index::CachedModule::new(
PathBuf::from("/fake/Data/Fetcher.pm"),
std::sync::Arc::new(provider_fa),
))),
);
let consumer_fa = build_fa(
"package My::App;\nuse Data::Fetcher;\nfetch_data('http://example.com');\n",
);
let sig = consumer_fa.signature_for_call(
"fetch_data",
false,
None,
tree_sitter::Point::new(2, 0),
Some(&idx),
);
assert!(
sig.is_some(),
"signature_for_call must resolve fetch_data via @EXPORT_OK, got None",
);
}
#[test]
fn test_importer_consumer_retargets_source() {
let fa = build_fa(
"
package My::Consumer;
use Importer 'Some::Module' => qw/foo bar/;
",
);
let imp = fa
.imports
.iter()
.find(|i| i.module_name == "Some::Module")
.expect("Import re-targeted to Some::Module");
let names: Vec<&str> = imp
.imported_symbols
.iter()
.map(|s| s.local_name.as_str())
.collect();
assert!(names.contains(&"foo"));
assert!(names.contains(&"bar"));
assert!(fa.imports.iter().all(|i| i.module_name != "Importer"));
}
#[test]
fn test_importer_menu_advertised_names() {
let fa = build_fa(
"
package My::Menu;
sub IMPORTER_MENU {
return (
export => [qw/foo bar/],
export_ok => ['baz'],
export_anon => { quux => sub { 1 } },
);
}
sub foo { 1 }
",
);
assert!(fa.export_ok.contains(&"foo".to_string()));
assert!(fa.export_ok.contains(&"bar".to_string()));
assert!(fa.export_ok.contains(&"baz".to_string()));
assert!(!fa.export_ok.contains(&"quux".to_string()));
}
#[test]
fn test_use_constant_string() {
let fa = build_fa(
"
package Foo;
use constant NAME => 'hello';
use parent NAME;
",
);
assert_eq!(
fa.package_parents.get("Foo").unwrap(),
&vec!["hello".to_string()]
);
}
#[test]
fn test_constant_array_our() {
let fa = build_fa(
"
our @THINGS = qw(a b);
our @EXPORT_OK = (@THINGS, 'c');
",
);
assert_eq!(fa.export_ok, vec!["a", "b", "c"]);
}
#[test]
fn test_constant_array_my() {
let fa = build_fa(
"
my @THINGS = qw(a b);
our @EXPORT_OK = (@THINGS, 'c');
",
);
assert_eq!(fa.export_ok, vec!["a", "b", "c"]);
}
#[test]
fn test_constant_array_in_exports() {
let fa = build_fa(
"
package Foo;
use Exporter 'import';
my @COMMON = qw(alpha beta);
our @EXPORT_OK = (@COMMON, 'gamma');
",
);
assert_eq!(fa.export_ok, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn test_recursive_constant_resolution() {
let fa = build_fa(
"
package Foo;
use Exporter 'import';
use constant BASE => qw(a b);
use constant ALL => (BASE, 'c');
our @EXPORT_OK = (ALL);
",
);
assert_eq!(fa.export_ok, vec!["a", "b", "c"]);
}
#[test]
fn test_glob_export_literal_name() {
let fa = build_fa(
r#"
package Data::Printer;
sub np { }
sub p { }
sub import {
my $class = shift;
my $caller = caller;
{ no strict 'refs';
*{"${caller}::p"} = \&p;
*{"${caller}::np"} = \&np;
}
}
"#,
);
assert!(
fa.export.contains(&"p".to_string()),
"should detect p export: {:?}",
fa.export
);
assert!(
fa.export.contains(&"np".to_string()),
"should detect np export: {:?}",
fa.export
);
}
#[test]
fn test_glob_export_variable_name() {
let fa = build_fa(
r#"
package Data::Printer;
sub p { }
sub import {
my $class = shift;
my $caller = caller;
my $imported = 'dump_it';
{ no strict 'refs';
*{"$caller\::$imported"} = \&p;
}
}
"#,
);
assert!(
fa.export.contains(&"dump_it".to_string()),
"should resolve aliased export: {:?}",
fa.export
);
}
#[test]
fn test_glob_export_loop_pattern() {
let fa = build_fa(
r#"
package Try::Tiny;
sub try { }
sub catch { }
sub finally { }
sub import {
my $class = shift;
my $caller = caller;
for my $name (qw(try catch finally)) {
no strict 'refs';
*{"${caller}::${name}"} = \&$name;
}
}
"#,
);
assert!(
fa.export.contains(&"try".to_string()),
"should detect try: {:?}",
fa.export
);
assert!(
fa.export.contains(&"catch".to_string()),
"should detect catch: {:?}",
fa.export
);
assert!(
fa.export.contains(&"finally".to_string()),
"should detect finally: {:?}",
fa.export
);
}
#[test]
fn test_glob_export_fallback_to_rhs() {
let fa = build_fa(
r#"
package Foo;
sub bar { }
sub import {
my $caller = caller;
*{$caller . '::bar'} = \&bar;
}
"#,
);
assert!(
fa.export.contains(&"bar".to_string()),
"should fall back to RHS name: {:?}",
fa.export
);
}
#[test]
fn test_glob_export_only_inside_import() {
let fa = build_fa(
r#"
package Foo;
sub setup {
my $caller = caller;
*{"${caller}::thing"} = \&thing;
}
"#,
);
assert!(
fa.export.is_empty(),
"should not export from non-import sub: {:?}",
fa.export
);
}
#[test]
fn test_mojo_has_accessor_writer_hidden_from_outline() {
let fa = build_fa("package Msg;\nuse Mojo::Base -base;\nhas 'content';\n");
let visible: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "content" && s.kind == SymKind::Method)
.filter(|s| !matches!(&s.detail, SymbolDetail::Sub { hide_in_outline: true, .. }))
.collect();
assert_eq!(
visible.len(),
1,
"exactly one visible `content` accessor expected, got {}",
visible.len()
);
let total = fa.symbols.iter().filter(|s| s.name == "content" && s.kind == SymKind::Method).count();
assert_eq!(total, 2, "getter + (hidden) writer both retained");
}
#[test]
fn test_strict_mojo_base_shift_not_invocant() {
let fa = build_fa(
"package MyStrict;\nuse Mojo::Base -strict;\nsub helper {\n my $tx = shift;\n return $tx->res;\n}\n",
);
assert_eq!(
fa.inferred_type_via_bag("$tx", Point::new(4, 9)),
None,
"shift in a -strict (non-OO) module must not type as the package"
);
}
#[test]
fn test_mojo_base_base_and_strict_still_oo() {
for src in [
"package C;\nuse Mojo::Base -base, -strict;\nsub greet {\n my $x = shift;\n return $x->name;\n}\n",
"package C;\nuse Mojo::Base -strict, -base;\nsub greet {\n my $x = shift;\n return $x->name;\n}\n",
] {
let fa = build_fa(src);
assert_eq!(
fa.inferred_type_via_bag("$x", Point::new(4, 9)),
Some(InferredType::ClassName("C".into())),
"-base makes the package OO regardless of a redundant -strict: {src}"
);
}
}
#[test]
fn test_list_shift_pair_params_extracted() {
let fa = build_fa("package P;\nsub cookie {\n my ($self, $name) = (shift, shift);\n}\n");
let sub = fa.symbols.iter().find(|s| s.name == "cookie").expect("cookie sym");
let params: Vec<&str> = match &sub.detail {
SymbolDetail::Sub { params, .. } => params.iter().map(|p| p.name.as_str()).collect(),
_ => panic!("cookie not a Sub"),
};
assert!(params.contains(&"$self") && params.contains(&"$name"),
"expected $self + $name from (shift, shift), got {params:?}");
}
#[test]
fn test_goto_amp_fq_sub_emits_call_ref() {
let fa = build_fa("package Child;\nsub import { goto &Parent::Thing::setup; }\n");
let r = fa.refs.iter().find(|r|
matches!(r.kind, RefKind::FunctionCall { .. }) && r.target_name == "Parent::Thing::setup");
assert!(r.is_some(), "expected FunctionCall ref to Parent::Thing::setup (& stripped), got {:?}",
fa.refs.iter().filter(|r| matches!(r.kind, RefKind::FunctionCall{..})).map(|r| &r.target_name).collect::<Vec<_>>());
}
#[test]
fn test_glob_assigned_sub_ternary_rhs_registers() {
let fa = build_fa(
r#"
package Demo;
sub bar { 1 }
*_subname = $su ? \&Sub::Util::set_subname : sub { $_[1] };
"#,
);
assert!(
fa.symbols
.iter()
.any(|s| s.kind == SymKind::Sub && s.name == "_subname"),
"ternary glob-assign should register `_subname` as a sub: {:?}",
fa.symbols.iter().filter(|s| s.kind == SymKind::Sub).map(|s| &s.name).collect::<Vec<_>>()
);
}
#[test]
fn test_loop_variable_constant_folding() {
let fa = build_fa(
"
package Foo;
sub test {
my $self = shift;
for my $attr (qw(name email)) {
my $getter = \"get_$attr\";
$self->$getter();
}
}
",
);
let method_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| matches!(r.kind, RefKind::MethodCall { .. }))
.map(|r| r.target_name.as_str())
.collect();
assert!(method_refs.contains(&"get_name"), "should resolve get_name");
assert!(
method_refs.contains(&"get_email"),
"should resolve get_email"
);
}
#[test]
fn test_dynamic_method_dispatch() {
let fa = build_fa(
"
package Foo;
my $method = 'get_name';
sub test { my $self = shift; $self->$method() }
",
);
let method_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| matches!(r.kind, RefKind::MethodCall { .. }) && r.target_name == "get_name")
.collect();
assert!(
!method_refs.is_empty(),
"dynamic method call should resolve to get_name"
);
}
#[test]
fn plugin_mojo_events_on_literal_emits_handler_symbol() {
let src = r#"
package My::Emitter;
use parent 'Mojo::EventEmitter';
sub new {
my $class = shift;
my $self = bless {}, $class;
$self->on('connect', sub { ... });
$self->on('message', sub { ... });
$self;
}
1;
"#;
let fa = build_fa(src);
let handlers: Vec<&Symbol> = fa
.symbols
.iter()
.filter(|s| {
s.kind == SymKind::Handler
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-events")
})
.collect();
let names: std::collections::HashSet<&str> = handlers.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains("connect"),
"mojo-events should emit Handler for 'connect'; got: {:?}",
names
);
assert!(
names.contains("message"),
"mojo-events should emit Handler for 'message'; got: {:?}",
names
);
for h in &handlers {
if let SymbolDetail::Handler { dispatchers, .. } = &h.detail {
assert!(
dispatchers.iter().any(|d| d == "emit"),
"Handler for {} should declare `emit` dispatcher",
h.name
);
} else {
panic!("expected Handler detail on {}", h.name);
}
}
}
#[test]
fn plugin_mojo_events_dynamic_name_does_not_emit() {
let src = r#"
package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire {
my ($self, $name) = @_;
$self->on($name, sub { ... });
}
1;
"#;
let fa = build_fa(src);
let plugin_handlers: Vec<&Symbol> = fa
.symbols
.iter()
.filter(|s| {
s.kind == SymKind::Handler
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-events")
})
.collect();
assert!(
plugin_handlers.is_empty(),
"dynamic event name must not emit handlers; got: {:?}",
plugin_handlers.iter().map(|s| &s.name).collect::<Vec<_>>()
);
}
#[test]
fn plugin_mojo_events_const_folds_scalar_event_name() {
let src = r#"
package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire {
my $self = shift;
my $evt = 'disconnect';
$self->on($evt, sub { ... });
}
1;
"#;
let fa = build_fa(src);
let names: std::collections::HashSet<&str> = fa
.symbols
.iter()
.filter(|s| {
s.kind == SymKind::Handler
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-events")
})
.map(|s| s.name.as_str())
.collect();
assert!(
names.contains("disconnect"),
"const-folded event name should emit 'disconnect'; got: {:?}",
names
);
assert!(
!names.contains("$evt"),
"variable text must not leak through as symbol name"
);
}
#[test]
fn plugin_mojo_events_triggers_through_transitive_parent() {
let src = r#"
package Mid;
use parent 'Mojo::EventEmitter';
package Leaf;
use parent 'Mid';
sub wire {
my $self = shift;
$self->on('ready', sub { ... });
}
1;
"#;
let fa = build_fa(src);
let ready: Vec<&Symbol> = fa
.symbols
.iter()
.filter(|s| {
s.kind == SymKind::Handler
&& s.name == "ready"
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-events")
})
.collect();
assert_eq!(
ready.len(),
1,
"Leaf extends Mid extends Mojo::EventEmitter — plugin must fire transitively"
);
}
#[test]
fn plugin_mojo_events_cross_file_ref_pairing() {
use crate::file_analysis::HandlerOwner;
use crate::file_store::FileStore;
use crate::resolve::{refs_to, RoleMask, TargetKind, TargetRef};
use std::path::PathBuf;
let producer_src = r#"
package Producer;
use parent 'Mojo::EventEmitter';
sub new {
my $class = shift;
my $self = bless {}, $class;
$self->on('ready', sub { warn "ready" });
return $self;
}
1;
"#;
let consumer_src = r#"
package Consumer;
use parent 'Mojo::EventEmitter';
sub run {
my $p = Producer->new;
$p->emit('ready');
$p->unsubscribe('ready');
}
1;
"#;
let store = FileStore::new();
let producer_path = PathBuf::from("/tmp/plugin_producer.pm");
let consumer_path = PathBuf::from("/tmp/plugin_consumer.pm");
store.insert_workspace(producer_path.clone(), build_fa(producer_src));
store.insert_workspace(consumer_path.clone(), build_fa(consumer_src));
let results = refs_to(
&store,
None,
&TargetRef {
name: "ready".to_string(),
kind: TargetKind::Handler {
owner: HandlerOwner::Class("Producer".to_string()),
name: "ready".to_string(),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
let producer_hits = results
.iter()
.filter(|r| matches!(&r.key, crate::file_store::FileKey::Path(p) if p == &producer_path))
.count();
let consumer_hits = results
.iter()
.filter(|r| matches!(&r.key, crate::file_store::FileKey::Path(p) if p == &consumer_path))
.count();
assert!(
producer_hits >= 1,
"producer should have ≥1 hit (the ->on Handler def); results: {:?}",
results
);
assert!(
consumer_hits >= 1,
"consumer should have ≥1 hit (the ->emit DispatchCall); results: {:?}",
results
);
}
#[test]
fn plugin_mojo_helpers_registers_method_on_controller() {
let src = r#"
package MyApp::Lite;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(current_user => sub {
my ($c, $extra) = @_;
return { id => 1 };
});
"#;
let fa = build_fa(src);
let helpers: Vec<&Symbol> = fa
.symbols
.iter()
.filter(|s| {
s.kind == SymKind::Method
&& s.name == "current_user"
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-helpers")
})
.collect();
assert_eq!(
helpers.len(),
1,
"one Method per helper (no fan-out — Phase 2)"
);
let helper = helpers[0];
assert_eq!(
helper.package.as_deref(),
Some(crate::file_analysis::APP_SURFACE_CLASS),
"canonical home is the fictional app surface"
);
if let SymbolDetail::Sub {
params, display, ..
} = &helper.detail
{
let names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect();
assert_eq!(
names,
vec!["$c", "$extra"],
"helper's sub params flow through to the Method signature"
);
assert_eq!(
*display,
Some(crate::file_analysis::HandlerDisplay::Helper),
"helpers render as HandlerDisplay::Helper — the LSP kind is \
FUNCTION (the enum doesn't have Helper), the outline word \
is 'helper'. See HandlerDisplay::outline_word.",
);
} else {
panic!("helper detail should be Sub");
}
let ns = fa
.plugin_namespaces
.iter()
.find(|n| n.plugin_id == "mojo-helpers" && n.entities.contains(&helper.id))
.expect("helper belongs to a mojo-helpers namespace");
let bridge_classes: std::collections::HashSet<&str> = ns
.bridges
.iter()
.map(|Bridge::Class(c)| c.as_str())
.collect();
assert_eq!(
bridge_classes,
std::iter::once(crate::file_analysis::APP_SURFACE_CLASS).collect(),
"namespace bridges ONLY the app surface — open consumer set lives in core"
);
for consumer in ["Mojolicious", "Mojolicious::Controller"] {
let res = fa.resolve_method_in_ancestors(consumer, "current_user", None);
match res {
Some(crate::file_analysis::MethodResolution::Local { sym_id, .. }) => {
assert_eq!(
sym_id, helper.id,
"{consumer}->current_user must resolve to the helper via the app surface"
);
}
other => panic!(
"{consumer}->current_user should resolve to the helper Local; got {other:?}"
),
}
}
}
fn first_param_type(fa: &FileAnalysis, var: &str, body_line: usize, col: usize) -> Option<InferredType> {
fa.inferred_type_via_bag(var, Point::new(body_line, col))
}
#[test]
fn plugin_mojo_helpers_named_sub_typed_via_shift() {
let src = r#"
package MyApp::Lite;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(greet => \&_greet);
sub _greet {
my $c = shift;
$c->render(text => 'hi');
}
"#;
let fa = build_fa(src);
let ty = first_param_type(&fa, "$c", 8, 14);
assert_eq!(
ty,
Some(InferredType::ClassName("Mojolicious::Controller".into())),
"named-sub helper's first positional types as the controller"
);
}
#[test]
fn plugin_mojo_helpers_named_sub_typed_via_signature() {
let src = r#"
package MyApp::Lite;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(greet => \&_greet);
sub _greet ($c, $name) {
$c->render(text => $name);
}
"#;
let fa = build_fa(src);
let ty = first_param_type(&fa, "$c", 8, 8);
assert_eq!(
ty,
Some(InferredType::ClassName("Mojolicious::Controller".into())),
"signature-form named-sub helper's first positional types as the controller"
);
}
#[test]
fn plugin_mojo_helpers_named_sub_plain_comma() {
let src = r#"
package MyApp::Lite;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper('greet', \&_greet);
sub _greet {
my $c = shift;
$c->render;
}
"#;
let fa = build_fa(src);
let ty = first_param_type(&fa, "$c", 8, 14);
assert_eq!(
ty,
Some(InferredType::ClassName("Mojolicious::Controller".into())),
"plain-comma helper registration types the named sub identically"
);
}
#[test]
fn plugin_mojo_helpers_inline_callback_still_typed() {
let src = r#"
package MyApp::Lite;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(greet => sub {
my $c = shift;
$c->render;
});
"#;
let fa = build_fa(src);
let ty = first_param_type(&fa, "$c", 6, 14);
assert_eq!(
ty,
Some(InferredType::ClassName("Mojolicious::Controller".into())),
"inline-callback helper still types its first positional"
);
}
#[test]
fn plugin_mojo_helpers_non_helper_named_sub_unaffected() {
let src = r#"
package MyApp::Lite;
use Mojolicious::Lite;
my $cb = \&_other;
sub _other {
my $c = shift;
return $c;
}
"#;
let fa = build_fa(src);
let ty = first_param_type(&fa, "$c", 7, 14);
assert_ne!(
ty,
Some(InferredType::ClassName("Mojolicious::Controller".into())),
"a non-helper named sub must not be typed as a controller"
);
}
#[test]
fn plugin_mojo_helpers_dotted_chain_with_shared_prefix_dedup() {
let src = r#"
package MyApp::Lite;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper('thing.hi' => sub { my ($c, $arg_a) = @_; });
$app->helper('thing.there' => sub { my ($c, $arg_b) = @_; });
"#;
let fa = build_fa(src);
let thing_syms: Vec<&Symbol> = fa
.symbols
.iter()
.filter(|s| {
s.name == "thing"
&& s.kind == SymKind::Method
&& s.package.as_deref() == Some(crate::file_analysis::APP_SURFACE_CLASS)
})
.collect();
assert_eq!(
thing_syms.len(),
1,
"shared prefix must dedup: one `thing` method, got {}",
thing_syms.len()
);
match fa.symbol_return_type_via_bag(thing_syms[0].id, None) {
Some(InferredType::ClassName(n)) => {
assert_eq!(n, "Mojolicious::Controller::_Helper::thing");
}
_ => panic!("thing's return type should be the shared proxy class"),
}
let hi = fa
.symbols
.iter()
.find(|s| s.name == "hi" && s.kind == SymKind::Method)
.expect("hi leaf emitted");
let there = fa
.symbols
.iter()
.find(|s| s.name == "there" && s.kind == SymKind::Method)
.expect("there leaf emitted");
assert_eq!(
hi.package.as_deref(),
Some("Mojolicious::Controller::_Helper::thing")
);
assert_eq!(
there.package.as_deref(),
Some("Mojolicious::Controller::_Helper::thing")
);
if let SymbolDetail::Sub { params, .. } = &hi.detail {
let names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["$c", "$arg_a"]);
}
if let SymbolDetail::Sub { params, .. } = &there.detail {
let names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["$c", "$arg_b"]);
}
}
#[test]
fn plugin_mojo_helpers_three_level_dotted_chain() {
let src = r#"
package MyApp::Lite;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper('admin.users.purge' => sub { my ($c, $force) = @_; });
"#;
let fa = build_fa(src);
let admin = fa
.symbols
.iter()
.find(|s| {
s.name == "admin"
&& s.kind == SymKind::Method
&& s.package.as_deref() == Some(crate::file_analysis::APP_SURFACE_CLASS)
})
.expect("admin on app surface (chain root)");
let users = fa
.symbols
.iter()
.find(|s| {
s.name == "users"
&& s.kind == SymKind::Method
&& s.package.as_deref() == Some("Mojolicious::Controller::_Helper::admin")
})
.expect("users on admin proxy");
let purge = fa
.symbols
.iter()
.find(|s| {
s.name == "purge"
&& s.kind == SymKind::Method
&& s.package.as_deref() == Some("Mojolicious::Controller::_Helper::admin::users")
})
.expect("purge leaf on admin.users proxy");
if let Some(InferredType::ClassName(n)) =
fa.symbol_return_type_via_bag(admin.id, None)
{
assert_eq!(n, "Mojolicious::Controller::_Helper::admin");
}
if let Some(InferredType::ClassName(n)) =
fa.symbol_return_type_via_bag(users.id, None)
{
assert_eq!(n, "Mojolicious::Controller::_Helper::admin::users");
}
if let SymbolDetail::Sub { params, .. } = &purge.detail {
let names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["$c", "$force"]);
}
}
#[test]
fn plugin_mojo_lite_autoimports_verbs() {
let src = r#"
package main;
use Mojolicious::Lite;
get '/x' => sub {};
post '/y' => sub {};
helper foo => sub {};
"#;
let fa = build_fa(src);
for verb in &[
"get",
"post",
"put",
"del",
"patch",
"any",
"under",
"websocket",
"app",
"helper",
"hook",
"plugin",
"group",
] {
assert!(
fa.framework_imports.contains(*verb),
"{} must be autoimported by use Mojolicious::Lite",
verb
);
}
}
#[test]
fn plugin_mojo_lite_registers_handlers_for_routes() {
let src = r#"
package main;
use Mojolicious::Lite;
get '/users' => sub {
my ($c, $arg) = @_;
$c->render(text => 'hi');
};
post '/login' => sub {
my ($c, $user, $pw) = @_;
};
app->start;
"#;
let fa = build_fa(src);
let route_handlers: Vec<&Symbol> = fa
.symbols
.iter()
.filter(|s| {
s.kind == SymKind::Handler
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-lite")
})
.collect();
let names: std::collections::HashSet<&str> =
route_handlers.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains("/users"),
"GET /users handler emitted; got: {:?}",
names
);
assert!(
names.contains("/login"),
"POST /login handler emitted; got: {:?}",
names
);
for h in &route_handlers {
if let SymbolDetail::Handler { dispatchers, .. } = &h.detail {
assert!(
dispatchers.iter().any(|d| d == "url_for"),
"handler {} should dispatch via url_for",
h.name
);
}
}
let login = route_handlers.iter().find(|h| h.name == "/login").unwrap();
if let SymbolDetail::Handler { params, .. } = &login.detail {
let names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["$c", "$user", "$pw"]);
}
}
#[test]
fn plugin_mojo_routes_emits_both_ref_and_handler_symbol() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#list');
$r->post('/users')->to(controller => 'Users', action => 'create');
"#;
let fa = build_fa(src);
let method_refs: Vec<&Ref> = fa
.refs
.iter()
.filter(|r| {
matches!(r.kind, RefKind::MethodCall { .. })
&& (r.target_name == "list" || r.target_name == "create")
})
.collect();
assert_eq!(method_refs.len(), 2, "one MethodCallRef per route");
let route_syms: Vec<&Symbol> = fa
.symbols
.iter()
.filter(|s| {
s.kind == SymKind::Handler
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-routes")
})
.collect();
assert_eq!(
route_syms.len(),
2,
"one Handler symbol per route so outline + workspace-symbol find them"
);
let names: std::collections::HashSet<&str> =
route_syms.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains("Users#list"),
"route identity `Users#list` present; got: {:?}",
names
);
assert!(
names.contains("Users#create"),
"route identity `Users#create` present; got: {:?}",
names
);
for s in &route_syms {
if let SymbolDetail::Handler {
dispatchers, owner, ..
} = &s.detail
{
assert!(
dispatchers.iter().any(|d| d == "url_for"),
"route {} should dispatch via url_for",
s.name
);
assert!(matches!(owner, HandlerOwner::Class(c) if c == "Mojolicious::Controller"),
"route owner is Mojolicious::Controller (shared base for url_for), not declaring package");
}
}
}
#[test]
fn plugin_mojo_routes_gd_reaches_workspace_target() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
use tower_lsp::lsp_types::Position;
let app_src = r#"
package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#list');
"#;
let users_src = r#"
package Users;
sub list { my ($c) = @_; }
1;
"#;
let app_fa = build_fa(app_src);
let users_fa = build_fa(users_src);
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Users.pm"),
Arc::new(users_fa),
);
let res = app_fa.resolve_method_in_ancestors("Users", "list", Some(&idx));
assert!(
res.is_some(),
"Users::list must resolve cross-file after workspace register"
);
let route_ref = app_fa
.refs
.iter()
.find(|r| matches!(r.kind, RefKind::MethodCall { .. }) && r.target_name == "list")
.expect("mojo-routes MethodCallRef for 'list'");
let _ = route_ref;
let _ = Position::default();
}
#[test]
fn plugin_mojo_routes_short_form_emits_method_call_ref() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#list');
"#;
let fa = build_fa(src);
let route_refs: Vec<&Ref> = fa
.refs
.iter()
.filter(|r| matches!(r.kind, RefKind::MethodCall { .. }) && r.target_name == "list")
.collect();
assert!(
!route_refs.is_empty(),
"at least one MethodCall ref for 'list'"
);
let r = route_refs
.iter()
.find(|r| matches!(&r.kind, RefKind::MethodCall { invocant, .. } if invocant == "Users"))
.expect("MethodCall with invocant=Users");
assert!(
r.span.end.column > r.span.start.column,
"method ref has non-empty span"
);
}
#[test]
fn plugin_mojo_routes_long_form_emits_method_call_ref() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to(controller => 'Users', action => 'list');
"#;
let fa = build_fa(src);
let has_ref = fa.refs.iter().any(|r| {
matches!(&r.kind, RefKind::MethodCall { invocant, .. } if invocant == "Users")
&& r.target_name == "list"
});
assert!(
has_ref,
"long-form ->to(controller=>, action=>) must produce MethodCall ref"
);
}
#[test]
fn plugin_mojo_routes_name_registers_url_for_handle() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#list')->name('users_list');
"#;
let fa = build_fa(src);
let route_name_handler = fa
.symbols
.iter()
.find(|s| s.kind == SymKind::Handler && s.name == "users_list");
assert!(
route_name_handler.is_some(),
"->name('users_list') must emit a Handler; handlers: {:?}",
fa.symbols
.iter()
.filter(|s| s.kind == SymKind::Handler)
.map(|s| &s.name)
.collect::<Vec<_>>()
);
let sym = route_name_handler.unwrap();
if let SymbolDetail::Handler { dispatchers, .. } = &sym.detail {
assert!(
dispatchers.iter().any(|d| d == "url_for"),
"named route must dispatch via url_for"
);
assert!(
dispatchers.iter().any(|d| d == "redirect_to"),
"named route must dispatch via redirect_to"
);
} else {
panic!("route-name symbol should be Handler; got {:?}", sym.detail);
}
let ns = fa
.plugin_namespaces
.iter()
.find(|n| n.plugin_id == "mojo-routes" && n.entities.contains(&sym.id));
assert!(
ns.is_some(),
"named route must belong to a mojo-routes namespace"
);
}
#[test]
fn plugin_mojo_routes_to_dispatches_via_redirect_to_too() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#list');
"#;
let fa = build_fa(src);
let route_handler = fa
.symbols
.iter()
.find(|s| s.kind == SymKind::Handler && s.name == "Users#list")
.expect("Users#list Handler");
if let SymbolDetail::Handler { dispatchers, .. } = &route_handler.detail {
assert!(
dispatchers.iter().any(|d| d == "url_for"),
"->to route must dispatch via url_for"
);
assert!(
dispatchers.iter().any(|d| d == "redirect_to"),
"->to route must dispatch via redirect_to"
);
} else {
panic!("route symbol should be Handler");
}
}
#[test]
fn mojo_lite_chain_off_real_routes_preserves_real_method_chain() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let app_src = r#"
package main;
use Mojolicious::Lite;
use Mojolicious;
my $routes = Mojolicious::Routes->new;
$routes->get('/users')->to('Users#list');
"#;
let route_pm_src = r#"
package Mojolicious::Routes::Route;
use Mojo::Base -base;
sub get {
my $self = shift;
return $self;
}
sub to {
my $self = shift;
return $self;
}
1;
"#;
let routes_pm_src = r#"
package Mojolicious::Routes;
use Mojo::Base 'Mojolicious::Routes::Route';
1;
"#;
let app_fa = build_fa(app_src);
let route_fa = build_fa(route_pm_src);
let routes_fa = build_fa(routes_pm_src);
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Routes/Route.pm"),
Arc::new(route_fa),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Routes.pm"),
Arc::new(routes_fa),
);
let get_rt = app_fa.find_method_return_type("Mojolicious::Routes", "get", Some(&idx), None);
assert_eq!(
get_rt.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes::Route"),
"`$$routes->get` must chain through the REAL Mojolicious::Routes::Route::get — \
not the plugin's imaginary top-level `get` Sub. got: {:?}",
get_rt,
);
let to_rt =
app_fa.find_method_return_type("Mojolicious::Routes::Route", "to", Some(&idx), None);
assert_eq!(
to_rt.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes::Route"),
"`->get('/x')->to(...)` must stay intelligent — Route::to is fluent, and \
further hops depend on it. got: {:?}",
to_rt,
);
}
#[test]
fn mojo_lite_dsl_verb_hover_uses_real_method_doc() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let app_src = r#"
package main;
use Mojolicious::Lite;
get '/users' => sub { my $c = shift; };
"#;
let route_pm_src = r#"
package Mojolicious::Routes::Route;
=head2 get
my $route = $r->get('/:foo' => sub ($c) {...});
Generate route matching only GET requests. Shortcut for
L<Mojolicious::Routes::Route/"any">.
=cut
sub get { my $self = shift; return $self; }
1;
"#;
let app_fa = build_fa(app_src);
let route_fa = build_fa(route_pm_src);
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Routes/Route.pm"),
Arc::new(route_fa),
);
let (row, line) = app_src
.lines()
.enumerate()
.find(|(_, l)| l.starts_with("get "))
.expect("`get` call line");
let col = line.find("get").unwrap() + 1;
let point = tree_sitter::Point { row, column: col };
let hover = app_fa
.hover_info(point, app_src, Some(&idx))
.expect("hover on DSL verb `get` returns text");
assert!(
hover.contains("Generate route matching only GET requests"),
"hover on `get` must surface the real Mojolicious::Routes::Route::get POD \
(verb is an alias, not an imaginary stub). got: {:?}",
hover,
);
}
#[test]
fn mojo_lite_app_bareword_invocant_types_as_mojolicious() {
let src = r#"
package main;
use Mojolicious::Lite;
my $x = app;
"#;
let fa = build_fa(src);
let ty = fa
.inferred_type("$x", tree_sitter::Point::new(4, 0))
.expect("$x must carry a type sourced from `app`'s return type");
assert!(
matches!(ty, InferredType::ClassName(c) if c == "Mojolicious"),
"`$$x = app` must type as Mojolicious — bareword `app` resolves to the \
plugin's typed Sub. got: {:?}",
ty,
);
}
#[test]
fn mojo_lite_app_routes_chain_is_fully_intelligent_to_the_end() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let app_src = r#"
package main;
use Mojolicious::Lite;
app->routes->get('/users')->to('Users#list');
"#;
let mojolicious_pm_src = r#"
package Mojolicious;
use Mojo::Base -base;
has routes => sub { Mojolicious::Routes->new };
1;
"#;
let routes_pm_src = r#"
package Mojolicious::Routes;
use Mojo::Base 'Mojolicious::Routes::Route';
1;
"#;
let route_pm_src = r#"
package Mojolicious::Routes::Route;
use Mojo::Base -base;
sub get { my $self = shift; return $self; }
sub to { my $self = shift; return $self; }
1;
"#;
let app_fa = build_fa(app_src);
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious.pm"),
Arc::new(build_fa(mojolicious_pm_src)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Routes.pm"),
Arc::new(build_fa(routes_pm_src)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Routes/Route.pm"),
Arc::new(build_fa(route_pm_src)),
);
let app_sym = app_fa
.symbols
.iter()
.find(|s| {
s.name == "app"
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-lite")
})
.expect("mojo-lite plugin must synthesize `app`");
let rt = app_fa
.symbol_return_type_via_bag(app_sym.id, None)
.expect("hop 1: `app` must carry a typed return");
assert_eq!(
rt.class_name(),
Some("Mojolicious"),
"hop 1: `app` must type as Mojolicious — the one plugin stub the chain leans on"
);
let routes_rt = app_fa.find_method_return_type("Mojolicious", "routes", Some(&idx), None);
assert_eq!(
routes_rt.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes"),
"hop 2: `Mojolicious::routes` must resolve cross-file to the real Mojo::Base \
accessor and return Mojolicious::Routes. got: {:?}",
routes_rt,
);
let get_rt = app_fa.find_method_return_type("Mojolicious::Routes", "get", Some(&idx), None);
assert_eq!(
get_rt.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes::Route"),
"hop 3: `$$routes->get` must chain through the REAL \
Mojolicious::Routes::Route::get (fluent) — not the plugin's \
imaginary top-level `get` Sub. got: {:?}",
get_rt,
);
let to_rt =
app_fa.find_method_return_type("Mojolicious::Routes::Route", "to", Some(&idx), None);
assert_eq!(
to_rt.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes::Route"),
"hop 4: `->to(...)` must chain through the real fluent `to` on \
Mojolicious::Routes::Route — preserving intelligence for further \
hops (->name, ->via, ...). got: {:?}",
to_rt,
);
}
#[test]
fn helper_and_route_with_same_leaf_name_do_not_cross_link() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
$app->helper('users.create', sub ($c, $user) {});
$app->routes->post('/users')->to(controller => 'Users', action => 'create');
"#;
let fa = build_fa(src);
let helper_create: Vec<&Symbol> = fa
.symbols
.iter()
.filter(|s| {
s.name == "create"
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-helpers")
})
.collect();
assert_eq!(helper_create.len(), 1, "one helper-leaf named 'create'");
let helper_create = helper_create[0];
assert_eq!(
helper_create.package.as_deref(),
Some("Mojolicious::Controller::_Helper::users"),
"helper leaf lives on the proxy class, NOT on Users"
);
let route_ref = fa
.refs
.iter()
.find(|r| {
matches!(&r.kind, RefKind::MethodCall { invocant, .. } if invocant == "Users")
&& r.target_name == "create"
})
.expect("route should emit MethodCall create@Users");
if let Some(target_sid) = route_ref.resolves_to {
assert_ne!(
target_sid, helper_create.id,
"route MethodCall(create @ Users) must NOT resolve to the \
helper-leaf on _Helper::users — they share a name only"
);
}
let refs_to_helper = fa.refs_to(helper_create.id);
for r in &refs_to_helper {
assert_ne!(
(r.span.start.row, r.span.start.column),
(route_ref.span.start.row, route_ref.span.start.column),
"route ref showed up as a reference to the helper — cross-link bug. \
Helper is on _Helper::users, route targets Users, they shouldn't mix."
);
}
let resolution = fa.resolve_method_in_ancestors("Users", "create", None);
if let Some(crate::file_analysis::MethodResolution::Local { sym_id, .. }) = resolution {
assert_ne!(
sym_id, helper_create.id,
"resolve_method_in_ancestors(Users, create) returned the helper — \
class-awareness broken"
);
}
}
#[test]
fn plugin_mojo_helpers_reachable_cross_file_from_controller() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let lite_src = r#"
package MyApp;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(greet => sub { my ($c, $who) = @_; });
"#;
let ctrl_src = r#"
package MyApp::Controller::Home;
use parent 'Mojolicious::Controller';
1;
"#;
let lite_fa = Arc::new(build_fa(lite_src));
let ctrl_fa = build_fa(ctrl_src);
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(std::path::PathBuf::from("/tmp/MyApp.pm"), lite_fa.clone());
let mods = idx.modules_bridging_to(crate::file_analysis::APP_SURFACE_CLASS);
assert!(
mods.iter().any(|m| m == "MyApp"),
"MyApp module should be listed as bridged to the app surface; got: {:?}",
mods
);
let candidates = ctrl_fa.complete_methods_for_class("MyApp::Controller::Home", Some(&idx));
let labels: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
assert!(
labels.contains(&"greet"),
"helper `greet` on the app surface in MyApp.pm should complete on \
controller subclasses via the synthetic-parent edge; got: {:?}",
labels
);
}
#[test]
fn plugin_mojo_helpers_land_on_controller_package() {
let src = r#"
package MyApp::Lite;
use Mojolicious::Lite;
app->helper(greet => sub {
my ($c, $name) = @_;
return "hello, $name";
});
1;
"#;
let fa = build_fa(src);
let greet = fa
.symbols
.iter()
.find(|s| s.name == "greet" && s.kind == SymKind::Method)
.expect("helper must emit a Method named greet");
assert_eq!(
greet.package.as_deref(),
Some(crate::file_analysis::APP_SURFACE_CLASS),
"helper Method is packaged on the app surface; the synthetic-parent \
edge lets every consumer class pick it up via the inheritance walk"
);
assert!(matches!(&greet.namespace, Namespace::Framework { id } if id == "mojo-helpers"));
}
#[test]
fn plugin_mojo_helpers_return_type_via_app_surface() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(model => sub { my ($c) = @_; return MyApp::Model->new; });
"#;
let src = format!("{src}\npackage MyApp::Web;\nuse parent 'Mojolicious';\n1;\n");
let fa = build_fa(&src);
for class in ["Mojolicious", "Mojolicious::Controller", "MyApp::Web"] {
let rt = fa.find_method_return_type(class, "model", None, None);
assert_eq!(
rt,
Some(crate::file_analysis::InferredType::ClassName("MyApp::Model".into())),
"`{class}->model` must return MyApp::Model via the app surface; got {rt:?}",
);
}
}
#[test]
fn plugin_app_surface_minion_enqueue_resolves_when_app_typed() {
use crate::file_analysis::HandlerOwner;
use std::path::PathBuf;
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
PathBuf::from("/tmp/as_acme_minion.pm"),
std::sync::Arc::new(build_fa("package Acme::Minion;\nuse Mojo::Base 'Minion';\n1;\n")),
);
let mut fa = build_fa(
"package MyApp;\nuse Mojolicious::Lite;\n\
my $app = Mojolicious->new;\n\
$app->helper(minion => sub { my ($c) = @_; return Acme::Minion->new; });\n\
$app->minion->enqueue('send_email' => ['alice']);\n1;\n",
);
let mref = fa.refs.iter().find(|r| {
matches!(&r.kind, RefKind::MethodCall { .. }) && r.target_name == "minion"
});
assert!(mref.is_some(), "an `$app->minion` MethodCall ref must exist");
fa.enrich_imported_types_with_keys(Some(&idx));
let has_materialized = fa.refs.iter().any(|r|
matches!(&r.kind, RefKind::DispatchCall { dispatcher, owner: Some(HandlerOwner::Class(c)) }
if dispatcher == "enqueue" && c == "Minion")
&& r.target_name == "send_email");
let has_gated = fa.applicable_dispatches(Some(&idx)).iter().any(|a|
a.name == "send_email" && a.owner == HandlerOwner::Class("Minion".into()));
assert!(
has_materialized ^ has_gated,
"`$app->minion->enqueue` must surface as a Minion dispatch exactly once — \
via the emit-hook ref OR the gated candidate; materialized={has_materialized} \
gated={has_gated}",
);
}
#[test]
fn plugin_mojo_helpers_complete_on_app_class_too() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(current_user => sub { my ($c) = @_; });
$app->helper('users.create' => sub { my ($c, $name) = @_; });
"#;
let fa = build_fa(src);
for class in ["Mojolicious::Controller", "Mojolicious"] {
let candidates = fa.complete_methods_for_class(class, None);
let labels: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
assert!(
labels.contains(&"current_user"),
"`current_user` must complete on {}; got: {:?}",
class,
labels,
);
assert!(
labels.contains(&"users"),
"`users` (dotted-helper root) must complete on {}; got: {:?}",
class,
labels,
);
}
}
#[test]
fn plugin_mojo_routes_url_for_completion_offers_route_names() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let app_src = r#"package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#list')->name('users_list');
$r->post('/users')->to(controller => 'Users', action => 'create');
get '/hello' => sub { my ($c) = @_; };
"#;
let app_fa = std::sync::Arc::new(build_fa(app_src));
let ctrl_src = r#"package Users;
use parent 'Mojolicious::Controller';
sub list {
my ($c) = @_;
my $u = $c->url_for('x');
}
"#;
let ctrl_fa = build_fa(ctrl_src);
let idx = std::sync::Arc::new(crate::module_index::ModuleIndex::new_for_test());
idx.register_workspace_module(std::path::PathBuf::from("/tmp/app.pl"), app_fa);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Users.pm"),
std::sync::Arc::new(build_fa(ctrl_src)),
);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(ctrl_src, None).unwrap();
let pos = Position {
line: 5,
character: 25,
};
let items = crate::symbols::completion_items(&ctrl_fa, &tree, ctrl_src, pos, &idx, None);
let labels: Vec<String> = items.iter().map(|it| it.label.clone()).collect();
for expected in &["users_list", "Users#list", "/hello"] {
assert!(
labels.iter().any(|l| l == expected),
"url_for('|') inside Users::list must offer `{}` (route declared in MyApp); got: {:?}",
expected,
labels
);
}
}
#[test]
fn plugin_mojo_routes_url_for_completion_survives_typed_prefix() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let app_src = r#"package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#list')->name('users_list');
$r->get('/admin/users/purge')->to('Admin#purge')->name('admin_users_purge');
get '/hello' => sub { my ($c) = @_; };
"#;
let app_fa = std::sync::Arc::new(build_fa(app_src));
let idx = std::sync::Arc::new(crate::module_index::ModuleIndex::new_for_test());
idx.register_workspace_module(std::path::PathBuf::from("/tmp/app.pl"), app_fa);
let empty_src = r#"package Users;
use parent 'Mojolicious::Controller';
sub list {
my ($c) = @_;
my $u = $c->url_for('');
}
"#;
let empty_fa = build_fa(empty_src);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Users_empty.pm"),
std::sync::Arc::new(build_fa(empty_src)),
);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(empty_src, None).unwrap();
let items = crate::symbols::completion_items(
&empty_fa,
&tree,
empty_src,
Position {
line: 5,
character: 25,
},
&idx,
None,
);
let labels: Vec<String> = items.iter().map(|it| it.label.clone()).collect();
for expected in &["users_list", "admin_users_purge", "Users#list", "/hello"] {
assert!(
labels.iter().any(|l| l == expected),
"empty url_for('|') must offer `{}`; got: {:?}",
expected,
labels
);
}
let typed_src = r#"package Users;
use parent 'Mojolicious::Controller';
sub list {
my ($c) = @_;
my $u = $c->url_for('adm');
}
"#;
let typed_fa = build_fa(typed_src);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Users_typed.pm"),
std::sync::Arc::new(build_fa(typed_src)),
);
let tree = parser.parse(typed_src, None).unwrap();
let items = crate::symbols::completion_items(
&typed_fa,
&tree,
typed_src,
Position {
line: 5,
character: 27,
},
&idx,
None,
);
let labels: Vec<String> = items.iter().map(|it| it.label.clone()).collect();
assert!(
labels.iter().any(|l| l == "admin_users_purge"),
"typed prefix `adm` must still surface `admin_users_purge` from the \
server so client-side filter_text matching can narrow to it; got: {:?}",
labels
);
let adm_item = items
.iter()
.find(|it| it.label == "admin_users_purge")
.expect("admin_users_purge must be in returned items");
assert_eq!(
adm_item.filter_text.as_deref(),
Some("admin_users_purge"),
"dispatch handler `filter_text` must be the bare label so the \
typed `adm` (no quote) matches — otherwise starting to type the \
string kills completion",
);
}
#[test]
fn plugin_mojo_lite_url_dispatch_emits_refs() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
get '/hello' => sub {
my ($c) = @_;
$c->render(text => 'hi');
};
sub after {
my ($c) = @_;
$c->redirect_to('/hello');
my $u = $c->url_for('/hello');
}
"#;
let fa = build_fa(src);
let dispatch_refs: Vec<&crate::file_analysis::Ref> = fa
.refs
.iter()
.filter(|r| matches!(&r.kind, RefKind::DispatchCall { .. }))
.filter(|r| r.target_name == "/hello")
.collect();
let dispatchers: Vec<&str> = dispatch_refs
.iter()
.map(|r| match &r.kind {
RefKind::DispatchCall { dispatcher, .. } => dispatcher.as_str(),
_ => unreachable!(),
})
.collect();
assert!(
dispatchers.contains(&"redirect_to"),
"redirect_to('/hello') must emit a DispatchCall ref; got: {:?}",
dispatchers,
);
assert!(
dispatchers.contains(&"url_for"),
"url_for('/hello') must emit a DispatchCall ref; got: {:?}",
dispatchers,
);
}
#[test]
fn plugin_mojo_events_triggers_gate_emission() {
let src = r#"
package My::Unrelated;
sub new {
my $class = shift;
my $self = bless {}, $class;
$self->on('connect', sub { ... });
$self;
}
1;
"#;
let fa = build_fa(src);
let plugin_syms: Vec<&Symbol> = fa
.symbols
.iter()
.filter(|s| {
matches!(&s.namespace,
Namespace::Framework { id } if id == "mojo-events")
})
.collect();
assert!(
plugin_syms.is_empty(),
"untriggered package must not get plugin emissions; got: {:?}",
plugin_syms.iter().map(|s| &s.name).collect::<Vec<_>>()
);
}
#[test]
fn plugin_minion_add_task_registers_handler() {
let src = r#"
package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub {
my ($job, $to, $subject) = @_;
$job->finish;
});
"#;
let fa = build_fa(src);
let handler = fa
.symbols
.iter()
.find(|s| {
s.kind == SymKind::Handler
&& s.name == "send_email"
&& matches!(&s.namespace, Namespace::Framework { id } if id == "minion")
})
.expect("add_task must emit a Handler named send_email");
let SymbolDetail::Handler {
ref owner,
ref dispatchers,
ref params,
ref display,
..
} = handler.detail
else {
panic!("handler detail should be Handler")
};
assert!(matches!(owner, HandlerOwner::Class(c) if c == "Minion"));
assert!(dispatchers.iter().any(|d| d == "enqueue"));
assert!(
matches!(display, HandlerDisplay::Task),
"minion tasks render as HandlerDisplay::Task (LSP kind FUNCTION, outline word 'task')"
);
let names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["$job", "$to", "$subject"]);
assert!(
params[0].is_invocant,
"Minion::Job is the callback's invocant"
);
let dc = fa.refs.iter()
.find(|r| matches!(&r.kind, RefKind::DispatchCall { dispatcher, .. } if dispatcher == "add_task"))
.expect("add_task must emit a DispatchCall ref");
assert_eq!(dc.target_name, "send_email");
}
#[test]
fn plugin_minion_enqueue_emits_dispatch_call() {
let src = r#"
package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub { my ($job) = @_; });
$minion->enqueue(send_email => ['alice']);
$minion->enqueue_p(send_email => ['bob']);
"#;
let fa = build_fa(src);
let dispatchers: Vec<&str> = fa
.refs
.iter()
.filter_map(|r| match &r.kind {
RefKind::DispatchCall { dispatcher, .. } if r.target_name == "send_email" => {
Some(dispatcher.as_str())
}
_ => None,
})
.collect();
assert!(
dispatchers.contains(&"enqueue"),
"enqueue('send_email', ...) must emit a DispatchCall; got: {:?}",
dispatchers
);
assert!(
dispatchers.contains(&"enqueue_p"),
"enqueue_p must emit a DispatchCall too; got: {:?}",
dispatchers
);
}
#[test]
fn gated_dispatch_resolves_on_subclass_receiver_query_time() {
use crate::file_analysis::HandlerOwner;
use std::path::PathBuf;
let base = build_fa("package Acme::Minion;\nuse Mojo::Base 'Minion';\n1;\n");
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
PathBuf::from("/tmp/b_acme_minion.pm"),
std::sync::Arc::new(base),
);
let fa = build_fa(
"package Worker;\nsub go {\n my $m = Acme::Minion->new;\n $m->enqueue('send_email' => ['a']);\n}\n1;\n",
);
assert!(
!fa.refs.iter().any(|r| matches!(&r.kind, RefKind::DispatchCall { .. })),
"no DispatchCall ref should exist (plugin trigger didn't fire)",
);
let applied = fa.applicable_dispatches(Some(&idx));
assert_eq!(
applied.iter().filter(|a|
a.name == "send_email" && a.owner == HandlerOwner::Class("Minion".into())).count(),
1,
"query-time resolution must surface exactly one Minion dispatch for \
enqueue on a Minion-subclass receiver, even with no enrichment; got {:?}",
applied,
);
}
#[test]
fn gated_dispatch_resolves_cross_file_receiver_query_time() {
use crate::file_analysis::HandlerOwner;
use std::path::PathBuf;
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
PathBuf::from("/tmp/b_acme_minion.pm"),
std::sync::Arc::new(build_fa("package Acme::Minion;\nuse Mojo::Base 'Minion';\n1;\n")),
);
idx.register_workspace_module(
PathBuf::from("/tmp/b_box.pm"),
std::sync::Arc::new(build_fa(
"package Box;\nsub new { bless {}, shift }\nsub minion ($self) { return Acme::Minion->new; }\n1;\n",
)),
);
let fa = build_fa(
"package Worker;\nsub go {\n my $b = Box->new;\n $b->minion->enqueue('send_email' => ['a']);\n}\n1;\n",
);
let applied = fa.applicable_dispatches(Some(&idx));
assert!(
applied.iter().any(|a|
a.name == "send_email" && a.owner == HandlerOwner::Class("Minion".into())),
"query-time resolution must resolve the cross-file receiver `$b->minion` \
(Acme::Minion isa Minion) and surface the dispatch; got {:?}",
applied,
);
}
#[test]
fn plugin_minion_subclass_receiver_still_wires() {
let src = r#"
package MyApp;
use Minion;
my $minion = Acme::Minion->new;
$minion->add_task(send_email => sub { my ($job) = @_; });
$minion->enqueue(send_email => ['alice']);
"#;
let fa = build_fa(src);
let handler = fa.symbols.iter().find(|s| {
s.kind == SymKind::Handler
&& s.name == "send_email"
&& matches!(&s.detail, SymbolDetail::Handler { owner: HandlerOwner::Class(c), .. } if c == "Minion")
});
assert!(
handler.is_some(),
"add_task on a Minion subclass receiver must still register a Minion-owned Handler",
);
let has_enqueue_dc = fa.refs.iter().any(|r| matches!(
&r.kind, RefKind::DispatchCall { dispatcher, .. }
if dispatcher == "enqueue" && r.target_name == "send_email"
));
assert!(
has_enqueue_dc,
"enqueue on a Minion subclass receiver must still emit a DispatchCall",
);
}
#[test]
fn plugin_minion_types_job_inside_task_body() {
let src = r#"
package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub {
my ($job) = @_;
$job->finish;
});
"#;
let fa = build_fa(src);
let ty = fa
.inferred_type("$job", tree_sitter::Point::new(8, 0))
.expect("$job must carry a type inside add_task callback");
assert!(
matches!(ty, InferredType::ClassName(c) if c == "Minion::Job"),
"type should be plugin-declared ClassName(Minion::Job), got {:?}",
ty,
);
}
#[test]
fn plugin_minion_enqueue_options_hashkeys_emitted() {
let src = r#"
package MyApp;
use Minion;
my $minion = Minion->new;
$minion->enqueue(task_x => ['arg'] => { priority => 10 });
"#;
let fa = build_fa(src);
let option_names: Vec<&str> = fa
.symbols
.iter()
.filter(|s| {
s.kind == SymKind::HashKeyDef
&& matches!(&s.namespace, Namespace::Framework { id } if id == "minion")
})
.map(|s| s.name.as_str())
.collect();
for expected in &[
"priority", "queue", "delay", "attempts", "notes", "parents", "expire", "lax",
] {
assert!(
option_names.contains(expected),
"enqueue option `{}` must be emitted; got: {:?}",
expected,
option_names
);
}
}
#[test]
fn plugin_mojo_helpers_cross_file_chain_completion() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let lite_src = r#"package MyApp;
use strict;
use warnings;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(current_user => sub { my ($c, $fallback) = @_; });
$app->helper('users.create' => sub { my ($c, $name, $email) = @_; });
$app->helper('users.delete' => sub { my ($c, $id) = @_; });
$app->helper('admin.users.purge' => sub { my ($c, $force) = @_; });
"#;
let lite_fa = build_fa(lite_src);
let src = r#"package Users;
use strict;
use warnings;
use parent 'Mojolicious::Controller';
sub list {
my ($c) = @_;
$c->;
$c->users->;
$c->admin->;
}
"#;
let fa = build_fa(src);
let users_subs: Vec<&str> = fa
.symbols
.iter()
.filter(|s| matches!(s.kind, SymKind::Method | SymKind::Sub))
.map(|s| s.name.as_str())
.collect();
assert_eq!(users_subs, vec!["list"], "Users.pm owns only `list`");
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let idx = std::sync::Arc::new(crate::module_index::ModuleIndex::new_for_test());
let lite_fa = std::sync::Arc::new(lite_fa);
idx.register_workspace_module(std::path::PathBuf::from("/tmp/MyApp.pm"), lite_fa.clone());
let users_fa = std::sync::Arc::new(build_fa(src));
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/lib/Users.pm"),
users_fa.clone(),
);
let ctrl_src = r#"package Mojolicious::Controller;
sub render { my ($self, %args) = @_; }
sub stash { my ($self, $key) = @_; }
sub req { my ($self) = @_; }
sub res { my ($self) = @_; }
sub session { my ($self, $key) = @_; }
1;
"#;
let ctrl_fa = std::sync::Arc::new(build_fa(ctrl_src));
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Controller.pm"),
ctrl_fa,
);
let mods = idx.modules_bridging_to(crate::file_analysis::APP_SURFACE_CLASS);
assert!(
mods.iter().any(|m| m == "MyApp"),
"workspace index must list MyApp.pm bridged to the app surface; got: {:?}",
mods
);
let pos = |row: u32, col: u32| Position {
line: row,
character: col,
};
let call_label_set = |items: &[tower_lsp::lsp_types::CompletionItem]| -> Vec<String> {
items.iter().map(|it| it.label.clone()).collect()
};
let items = crate::symbols::completion_items(&fa, &tree, src, pos(7, 8), &idx, None);
let labels = call_label_set(&items);
for expected in &["list", "render", "stash", "current_user", "users", "admin"] {
assert!(
labels.iter().any(|l| l == expected),
"$c-> must offer `{}`; got: {:?}",
expected,
labels
);
}
let items = crate::symbols::completion_items(&fa, &tree, src, pos(8, 15), &idx, None);
let labels = call_label_set(&items);
assert_eq!(
labels.iter().collect::<std::collections::HashSet<_>>(),
["create", "delete"]
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.iter()
.collect::<std::collections::HashSet<_>>(),
"$c->users-> must offer exactly the helper chain leaves (create/delete); got: {:?}",
labels,
);
assert!(
!labels.iter().any(|l| l == "list"),
"$c->users-> must NOT fall back to Users.pm's own `list`; got: {:?}",
labels
);
let items = crate::symbols::completion_items(&fa, &tree, src, pos(9, 15), &idx, None);
let labels = call_label_set(&items);
assert_eq!(
labels,
vec!["users"],
"$c->admin-> must offer exactly `users`; got: {:?}",
labels
);
let items = crate::symbols::completion_items(&fa, &tree, src, pos(7, 8), &idx, None);
let users_item = items.iter().find(|it| it.label == "users").unwrap();
let admin_item = items.iter().find(|it| it.label == "admin").unwrap();
for (name, item) in [("users", users_item), ("admin", admin_item)] {
let d = item.detail.as_deref().unwrap_or("");
assert!(
!d.contains("_Helper"),
"opaque_return must suppress proxy class in `{}`'s detail cross-file; got: {:?}",
name,
d
);
}
let diags = crate::symbols::collect_diagnostics(&fa, &idx, Default::default());
for diag in &diags {
let msg = &diag.message;
assert!(
!msg.contains("'users' is not defined"),
"no diagnostic for helper middle hop `users`; got: {}",
msg
);
assert!(
!msg.contains("'admin' is not defined"),
"no diagnostic for helper middle hop `admin`; got: {}",
msg
);
assert!(
!msg.contains("'current_user' is not defined"),
"no diagnostic for helper `current_user`; got: {}",
msg
);
}
}
#[test]
fn method_call_highlight_uses_method_name_span_only() {
let src = r#"package MyApp;
sub do_thing { }
sub run {
my ($self, $x) = @_;
$self->do_thing($x, 1, 2);
$self->do_thing(3);
}
"#;
let fa = build_fa(src);
let row = 4; let col = src.lines().nth(row).unwrap().find("do_thing").unwrap();
let point = tree_sitter::Point::new(row, col + 1);
let hits = fa.find_highlights(point, None);
assert!(!hits.is_empty(), "should highlight at least one occurrence");
for (span, _access) in &hits {
assert_eq!(
span.start.row, span.end.row,
"highlight must not span multiple lines; got: {:?}",
span
);
let width = span.end.column - span.start.column;
assert_eq!(
width,
"do_thing".len(),
"highlight width must match method identifier; got {}: {:?}",
width,
span
);
}
}
#[test]
fn plugin_mojo_helpers_chained_proxy_completion() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper('admin.users.purge' => sub { my ($c, $force) = @_; });
"#;
let fa = build_fa(src);
let admin_proxy = fa
.find_method_return_type("Mojolicious", "admin", None, None)
.expect("admin on Mojolicious has a return_type");
let admin_class = admin_proxy
.class_name()
.expect("proxy return_type is a ClassName");
assert_eq!(admin_class, "Mojolicious::Controller::_Helper::admin");
let candidates = fa.complete_methods_for_class(admin_class, None);
let labels: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
assert!(
labels.contains(&"users"),
"chain completion on admin proxy must surface `users`; got: {:?}",
labels
);
let users_cand = candidates.iter().find(|c| c.label == "users").unwrap();
assert!(
!users_cand
.detail
.as_deref()
.unwrap_or("")
.contains("_Helper"),
"opaque_return must hide the proxy class from detail: {:?}",
users_cand.detail,
);
let users_proxy = fa
.find_method_return_type(admin_class, "users", None, None)
.expect("users on admin proxy has a return_type");
let users_class = users_proxy.class_name().unwrap();
assert_eq!(
users_class,
"Mojolicious::Controller::_Helper::admin::users"
);
let leaf_candidates = fa.complete_methods_for_class(users_class, None);
let leaf_labels: Vec<&str> = leaf_candidates.iter().map(|c| c.label.as_str()).collect();
assert!(
leaf_labels.contains(&"purge"),
"leaf proxy must offer `purge`; got: {:?}",
leaf_labels
);
}
#[test]
fn outline_detail_names_the_semantic_kind() {
use tower_lsp::lsp_types::SymbolKind;
let src = r#"package MyApp;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(current_user => sub { my ($c) = @_; });
my $r = app->routes;
$r->get('/x')->to('Users#list');
get '/home' => sub { my $c = shift; };
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub { my ($job) = @_; });
package MyEmitter;
use parent 'Mojo::EventEmitter';
sub new {
my $self = bless {}, shift;
$self->on('ready', sub { my ($s) = @_; });
$self;
}
"#;
let fa = build_fa(src);
let outline = fa.document_symbols();
fn flatten<'a>(
out: &'a [crate::file_analysis::OutlineSymbol],
acc: &mut Vec<&'a crate::file_analysis::OutlineSymbol>,
) {
for s in out {
acc.push(s);
flatten(&s.children, acc);
}
}
let mut all = Vec::new();
flatten(&outline, &mut all);
let lsp_kind = |os: &crate::file_analysis::OutlineSymbol| -> SymbolKind {
crate::symbols::outline_lsp_kind(os)
};
let helper = all
.iter()
.find(|s| s.name.contains("current_user"))
.expect("helper must be in outline of its declaring file");
assert_eq!(lsp_kind(helper), SymbolKind::FUNCTION);
assert!(
helper.detail.as_deref().unwrap_or("").contains("helper"),
"helper outline detail must contain 'helper'; got: {:?}",
helper.detail
);
let term_route = all
.iter()
.find(|s| s.name.contains("/home"))
.expect("mojo-lite terminal route must be in outline");
assert_eq!(lsp_kind(term_route), SymbolKind::FUNCTION);
assert_eq!(
term_route.detail.as_deref(),
Some("route"),
"terminal mojo-lite route word is 'route'; got: {:?}",
term_route.detail
);
let action = all
.iter()
.find(|s| s.name.contains("Users#list"))
.expect("->to('Users#list') action must be in outline");
assert_eq!(lsp_kind(action), SymbolKind::FUNCTION);
assert_eq!(
action.detail.as_deref(),
Some("action"),
"->to(...) word is 'action' (distinct from a terminal route); got: {:?}",
action.detail
);
let task = all
.iter()
.find(|s| s.name.contains("send_email"))
.expect("task must be in outline of its declaring file");
assert_eq!(lsp_kind(task), SymbolKind::FUNCTION);
assert!(
task.detail.as_deref().unwrap_or("").contains("task"),
"task outline detail must contain 'task'; got: {:?}",
task.detail
);
let event = all
.iter()
.find(|s| s.name.contains("ready"))
.expect("event must be in outline of its declaring file");
assert_eq!(
lsp_kind(event),
SymbolKind::EVENT,
"events stay EVENT — the one LSP kind that fits"
);
}
#[test]
fn shift_as_self_in_method_body_resolves_to_current_package() {
let src = r#"
package Mojolicious::Routes::Route;
sub get { shift->_generate_route(GET => @_) }
sub _generate_route {
my $self = shift;
return $self;
}
"#;
let fa = build_fa(src);
let gr_ref = fa
.refs
.iter()
.find(|r| {
matches!(r.kind, RefKind::MethodCall { .. }) && r.target_name == "_generate_route"
})
.expect("MethodCall ref for `_generate_route`");
if matches!(gr_ref.kind, RefKind::MethodCall { .. }) {
let invocant_class = fa.method_call_invocant_class(gr_ref, None);
assert_eq!(
invocant_class.as_deref(),
Some("Mojolicious::Routes::Route"),
"`shift->_generate_route` must resolve its invocant to \
the enclosing package. got invocant_class: {:?}",
invocant_class,
);
} else {
panic!("expected MethodCall ref");
}
}
#[test]
fn dollar_underscore_zero_as_self_resolves_to_current_package() {
let src = r#"
package Mojolicious::Routes::Route;
sub is_endpoint {
$_[0]->inline;
}
sub inline {
my $self = shift;
return $self;
}
"#;
let fa = build_fa(src);
let inline_ref = fa
.refs
.iter()
.find(|r| matches!(r.kind, RefKind::MethodCall { .. }) && r.target_name == "inline")
.expect("MethodCall ref for `inline`");
if matches!(inline_ref.kind, RefKind::MethodCall { .. }) {
let invocant_class = fa.method_call_invocant_class(inline_ref, None);
assert_eq!(
invocant_class.as_deref(),
Some("Mojolicious::Routes::Route"),
"`$$_[0]->inline` must resolve its invocant to the \
enclosing package. got invocant_class: {:?}",
invocant_class,
);
} else {
panic!("expected MethodCall ref");
}
}
#[test]
fn enrichment_twice_does_not_crash_on_stale_indices() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let app_src = r#"
package main;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#list');
"#;
let mojolicious_pm = r#"
package Mojolicious;
use Mojo::Base -base;
has routes => sub { Mojolicious::Routes->new };
1;
"#;
let routes_pm = r#"
package Mojolicious::Routes;
use Mojo::Base 'Mojolicious::Routes::Route';
1;
"#;
let route_pm = r#"
package Mojolicious::Routes::Route;
use Mojo::Base -base;
sub get { my $self = shift; return $self; }
sub to { my $self = shift; return $self; }
1;
"#;
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious.pm"),
Arc::new(build_fa(mojolicious_pm)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Routes.pm"),
Arc::new(build_fa(routes_pm)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Routes/Route.pm"),
Arc::new(build_fa(route_pm)),
);
let mut fa = build_fa(app_src);
fa.enrich_imported_types_with_keys(Some(&idx));
fa.enrich_imported_types_with_keys(Some(&idx));
let r_type = fa.inferred_type_via_bag("$r", tree_sitter::Point { row: 5, column: 0 });
assert!(
r_type.as_ref().and_then(|t| t.class_name()) == Some("Mojolicious::Routes"),
"after two enrichments, $$r should still be typed as Mojolicious::Routes; got: {:?}",
r_type,
);
}
#[test]
fn mojo_demo_lines_70_71_all_tokens_intelligent() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("test_files/plugin_mojo_demo.pl");
let src = std::fs::read_to_string(&path).unwrap();
let fa = build_fa(&src);
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let _tree = parser.parse(&src, None).unwrap();
let mojolicious_pm = r#"
package Mojolicious;
use Mojo::Base -base;
=head2 routes
Returns the router.
=cut
has routes => sub { Mojolicious::Routes->new };
=head2 helper
Register a helper.
=cut
sub helper { my $self = shift; }
1;
"#;
let routes_pm = r#"
package Mojolicious::Routes;
use Mojo::Base 'Mojolicious::Routes::Route';
1;
"#;
let route_pm = r#"
package Mojolicious::Routes::Route;
use Mojo::Base -base;
=head2 get
my $route = $r->get('/:foo' => sub ($c) {...});
Generate route matching only C<GET> requests.
=cut
sub get { my $self = shift; return $self; }
=head2 to
$r->to('Users#list');
Set the route's target.
=cut
sub to { my $self = shift; return $self; }
1;
"#;
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious.pm"),
Arc::new(build_fa(mojolicious_pm)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Routes.pm"),
Arc::new(build_fa(routes_pm)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Mojolicious/Routes/Route.pm"),
Arc::new(build_fa(route_pm)),
);
let mut fa = fa;
fa.enrich_imported_types_with_keys(Some(&idx));
let fa = fa;
let (row_app_routes, line_app_routes) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("my $r = app->routes;"))
.map(|(i, l)| (i, l))
.expect("demo must contain `my $r = app->routes;`");
let (row_r_get_to, line_r_get_to) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("$r->get('/users')->to('Users#list');"))
.map(|(i, l)| (i, l))
.expect("demo must contain `$r->get('/users')->to('Users#list');`");
let col_of =
|line: &str, needle: &str| -> usize { line.find(needle).expect("needle in line") + 1 };
let probe = |row: usize, col: usize| tree_sitter::Point { row, column: col };
let check = |label: &str, point: tree_sitter::Point| {
let hover = fa.hover_info(point, &src, Some(&idx));
let def = fa.find_definition(point, Some(&idx));
assert!(
hover.is_some() || def.is_some(),
"[{label}] @ ({},{}) is a dead token — NO hover AND NO gd. \
Chain-resolution hit a wall here. src: {:?}",
point.row,
point.column,
src.lines().nth(point.row).unwrap_or("<oob>"),
);
};
check(
"app bareword",
probe(row_app_routes, col_of(line_app_routes, "app")),
);
check(
"routes accessor",
probe(row_app_routes, col_of(line_app_routes, "routes")),
);
check(
"$r receiver",
probe(row_r_get_to, col_of(line_r_get_to, "$r")),
);
check(
"get method",
probe(row_r_get_to, col_of(line_r_get_to, "->get") + 2),
); check(
"to method",
probe(row_r_get_to, col_of(line_r_get_to, "->to") + 2),
);
let app_point = probe(row_app_routes, col_of(line_app_routes, "app"));
let app_hover = fa.hover_info(app_point, &src, Some(&idx));
let app_hover_text = app_hover.as_deref().unwrap_or("");
assert!(
app_hover_text.contains("The Mojolicious application instance"),
"hover on `app` must surface the plugin-emitted Sub's doc \
— proving ref_at picked the narrow FunctionCall ref, not \
the outer MethodCall for `routes`. got: {:?}",
app_hover,
);
let tokens = fa.semantic_tokens();
let app_row = row_app_routes;
let app_col_start = line_app_routes.find("app").unwrap();
let app_col_end = app_col_start + "app".len();
let app_has_token = tokens.iter().any(|t| {
t.span.start.row == app_row
&& t.span.start.column == app_col_start
&& t.span.end.column == app_col_end
});
assert!(
app_has_token,
"semantic token must fire on the `app` bareword span — \
user reported no highlight and traced it to a missing \
Ref at the invocant. tokens near row {}: {:?}",
app_row,
tokens
.iter()
.filter(|t| t.span.start.row == app_row)
.collect::<Vec<_>>(),
);
let r_point = probe(row_r_get_to, col_of(line_r_get_to, "$r"));
let r_type = fa.inferred_type_via_bag("$r", r_point);
assert_eq!(
r_type.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes"),
"`$$r` must be typed as Mojolicious::Routes at the `$$r->get` \
call site. Without this, the rest of line 71 is dead. got: {:?}",
r_type,
);
let get_rt = fa.find_method_return_type("Mojolicious::Routes", "get", Some(&idx), None);
assert_eq!(
get_rt.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes::Route"),
"`$$r->get` must resolve to Mojolicious::Routes::Route::get \
via inheritance. got: {:?}",
get_rt,
);
}
#[test]
fn enqueue_sighelp_line_118_of_demo() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("test_files/plugin_mojo_demo.pl");
let src = std::fs::read_to_string(&path).unwrap();
let fa = build_fa(&src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(&src, None).unwrap();
let idx = crate::module_index::ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("$minion->enqueue(send_email"))
.map(|(i, l)| (i as u32, l))
.expect("demo must contain the send_email enqueue site");
let cases: &[(&str, &str, Option<u32>)] = &[
("alice@example.com", "'alice", Some(0)),
("hi", "'hi'", Some(1)),
("body", "'body'", Some(2)),
];
for (slot_label, needle, expected) in cases {
let col = (line.find(needle).unwrap() + 2) as u32;
let pos = Position {
line: line_idx,
character: col,
};
let sig = crate::symbols::signature_help(&fa, &tree, &src, pos, &idx)
.unwrap_or_else(|| panic!("[{slot_label}] sig help must fire"));
assert!(
sig.signatures[0].label.contains("send_email"),
"[{slot_label}] task sig expected; got {:?}",
sig.signatures[0].label
);
assert_eq!(
sig.active_parameter, *expected,
"[{slot_label}] wrong slot; got {:?}",
sig.active_parameter
);
}
let col = (line.rfind(']').unwrap() + 1) as u32;
let pos = Position {
line: line_idx,
character: col,
};
if let Some(s) = crate::symbols::signature_help(&fa, &tree, &src, pos, &idx) {
let lbl = &s.signatures[0].label;
assert!(
!lbl.contains("send_email"),
"past `]`: task sig must not leak; got {lbl:?}"
);
}
}
#[test]
fn enqueue_sighelp_separator_agnostic() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let cases: &[(&str, &str)] = &[
(
"literal-comma",
"$minion->enqueue('send_email', [ 'alice' ], {})",
),
(
"fat-comma",
"$minion->enqueue(send_email => [ 'alice' ], , , , )",
),
];
let header = "package MyApp;\nuse Minion;\nmy $minion = Minion->new;\n\
$minion->add_task(send_email => sub { my ($job, $to, $subject, $body) = @_; });\n";
let dump = std::env::var("DUMP_SWEEP").is_ok();
let mut dump_out = String::new();
for (label, call_line) in cases {
let src = format!("{}{};\n", header, call_line);
let fa = build_fa(&src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(&src, None).unwrap();
let idx = crate::module_index::ModuleIndex::new_for_test();
let line_idx = src
.lines()
.position(|l| l.starts_with("$minion->enqueue"))
.unwrap();
let line = src.lines().nth(line_idx).unwrap();
let in_alice = line.find("'alice'").unwrap() + 3;
let pos = Position {
line: line_idx as u32,
character: in_alice as u32,
};
let sig = crate::symbols::signature_help(&fa, &tree, &src, pos, &idx)
.unwrap_or_else(|| panic!("[{label}] cursor in 'alice' must fire task sig"));
assert!(
sig.signatures[0].label.contains("send_email"),
"[{label}] in 'alice' → task sig; got: {:?}",
sig.signatures[0].label
);
assert_eq!(
sig.active_parameter,
Some(0),
"[{label}] in 'alice' → $to (slot 0); got {:?}",
sig.active_parameter
);
let past_bracket = line.find(']').unwrap() + 2;
let pos = Position {
line: line_idx as u32,
character: past_bracket as u32,
};
let sig = crate::symbols::signature_help(&fa, &tree, &src, pos, &idx);
if let Some(s) = &sig {
let lbl = &s.signatures[0].label;
assert!(
!lbl.contains("send_email"),
"[{label}] past `]`: task sig must NOT show; got: {:?}",
lbl
);
}
if *label == "fat-comma" {
let start = line.find(']').unwrap() + 1;
let end = line.rfind(')').unwrap();
for col in start..=end {
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let sig = crate::symbols::signature_help(&fa, &tree, &src, pos, &idx);
if let Some(s) = &sig {
let lbl = &s.signatures[0].label;
assert!(!lbl.contains("send_email"),
"[{label}] col {col}: task sig leaked into trailing-comma region; got: {:?}",
lbl);
}
}
}
if dump {
dump_out.push_str(&format!("\n=== {} ===\n{}\n", label, line));
for col in 0..=line.len() {
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let sig = crate::symbols::signature_help(&fa, &tree, &src, pos, &idx);
let label_str = match &sig {
None => "<none>".to_string(),
Some(s) => format!(
"ap={:?} sig={}",
s.active_parameter,
s.signatures
.first()
.map(|si| si.label.as_str())
.unwrap_or("")
),
};
let ch = line
.chars()
.nth(col)
.map(|c| c.to_string())
.unwrap_or_else(|| "<eol>".into());
dump_out.push_str(&format!("col {:>3} ({:<5}): {}\n", col, ch, label_str));
}
}
}
if dump {
panic!("{}", dump_out);
}
}
#[test]
fn minion_registers_task_handler() {
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub { my ($job, $to) = @_; });
"#;
let fa = build_fa(src);
let h = fa
.symbols
.iter()
.find(|s| s.kind == SymKind::Handler && s.name == "send_email")
.expect("handler exists");
let SymbolDetail::Handler {
dispatchers,
display,
..
} = &h.detail
else {
panic!("detail shape");
};
assert!(
dispatchers.iter().any(|d| d == "enqueue"),
"must list enqueue as a dispatcher; got: {:?}",
dispatchers
);
assert!(
matches!(display, HandlerDisplay::Task),
"task handlers display as Task; got: {:?}",
display
);
}
#[test]
fn enqueue_arrayref_sig_help_active_param_inside_string() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub {
my ($job, $to, $subject, $body) = @_;
});
$minion->enqueue(send_email => ['alice', 'hi', 'body']);
"#;
let fa = build_fa(src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let line_idx = src
.lines()
.position(|l| l.contains("enqueue(send_email"))
.expect("enqueue line present");
let line = src.lines().nth(line_idx).unwrap();
let col = line.find("'hi'").unwrap() + 2; let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let idx = crate::module_index::ModuleIndex::new_for_test();
let sig = crate::symbols::signature_help(&fa, &tree, src, pos, &idx)
.expect("sig help must fire inside a string-literal arrayref arg");
let info = &sig.signatures[0];
assert!(
info.label.contains("send_email"),
"label references the task, not enqueue; got: {:?}",
info.label
);
assert!(
info.label.contains("$subject"),
"label surfaces the task's params; got: {:?}",
info.label
);
assert_eq!(
sig.active_parameter,
Some(1),
"cursor inside `'hi'` → $subject (index 1), NOT $to. \
If you see 0 here, sig help isn't recognizing it's inside \
the arrayref at slot 1; got: {:?}",
sig.active_parameter
);
}
#[test]
fn enqueue_arrayref_sig_help_active_param_inside_last_string() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub {
my ($job, $to, $subject, $body) = @_;
});
$minion->enqueue(send_email => ['alice', 'hi', 'body']);
"#;
let fa = build_fa(src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let line_idx = src
.lines()
.position(|l| l.contains("enqueue(send_email"))
.unwrap();
let line = src.lines().nth(line_idx).unwrap();
let col = line.find("'body'").unwrap() + 3; let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let idx = crate::module_index::ModuleIndex::new_for_test();
let sig = crate::symbols::signature_help(&fa, &tree, src, pos, &idx)
.expect("sig help fires inside the last string too");
assert_eq!(
sig.active_parameter,
Some(2),
"cursor inside `'body'` → $body (index 2); got: {:?}",
sig.active_parameter
);
}
#[test]
fn enqueue_options_hash_completion_empty() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(task_x => sub { my ($job, $a) = @_; });
$minion->enqueue(task_x => ['a'], { });
"#;
let fa = build_fa(src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let line_idx = src
.lines()
.position(|l| l.contains("enqueue(task_x"))
.expect("enqueue line");
let line = src.lines().nth(line_idx).unwrap();
let col = line.find("{ }").unwrap() + 2; let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let idx = crate::module_index::ModuleIndex::new_for_test();
let items = crate::symbols::completion_items(&fa, &tree, src, pos, &idx, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
for expected in &["priority", "queue", "delay", "attempts"] {
assert!(
labels.contains(expected),
"empty-hash: `{}` must complete; got: {:?}",
expected,
labels
);
}
}
#[test]
fn enqueue_options_hash_completion_with_existing_keys() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(task_x => sub { my ($job, $a) = @_; });
$minion->enqueue(task_x => ['a'], { priority => 10, });
"#;
let fa = build_fa(src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let line_idx = src
.lines()
.position(|l| l.contains("enqueue(task_x"))
.expect("enqueue line");
let line = src.lines().nth(line_idx).unwrap();
let col = line.find("priority => 10, ").unwrap() + "priority => 10, ".len();
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let idx = crate::module_index::ModuleIndex::new_for_test();
let items = crate::symbols::completion_items(&fa, &tree, src, pos, &idx, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"queue"),
"with-existing: `queue` must still complete; got: {:?}",
labels
);
assert!(
labels.contains(&"delay"),
"with-existing: `delay` must still complete; got: {:?}",
labels
);
assert!(
!labels.contains(&"priority"),
"with-existing: `priority` is already used — must NOT re-appear; got: {:?}",
labels
);
}
#[test]
fn mojo_helpers_emits_app_plugin_namespace() {
use crate::file_analysis::Bridge;
let src = r#"package MyApp;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(current_user => sub { my ($c) = @_; });
$app->helper('users.create' => sub { my ($c) = @_; });
"#;
let fa = build_fa(src);
let ns = fa
.plugin_namespaces
.iter()
.find(|n| {
n.kind == "app"
&& n.bridges
.contains(&Bridge::Class(crate::file_analysis::APP_SURFACE_CLASS.into()))
})
.expect("an `app` namespace must bridge the app surface");
let entity_names: Vec<&str> = ns
.entities
.iter()
.map(|id| fa.symbol(*id).name.as_str())
.collect();
assert!(
entity_names.contains(&"current_user"),
"simple helper must land in the namespace; got: {:?}",
entity_names
);
assert!(
entity_names.contains(&"users"),
"dotted-helper root must land in the namespace; got: {:?}",
entity_names
);
let count = fa
.plugin_namespaces
.iter()
.filter(|n| n.plugin_id == ns.plugin_id && n.id == ns.id)
.count();
assert_eq!(count, 1, "one namespace per app, not one per helper");
}
#[test]
fn plugin_namespaces_are_populated_but_not_in_outline() {
let src = r#"package MyApp;
use Mojolicious::Lite;
app->helper(current_user => sub { my ($c) = @_; });
get '/home' => sub { my $c = shift; };
"#;
let fa = build_fa(src);
assert!(
fa.plugin_namespaces.iter().any(|n| n.kind == "app"),
"app namespace should still exist in FileAnalysis; got: {:?}",
fa.plugin_namespaces
.iter()
.map(|n| &n.id)
.collect::<Vec<_>>()
);
let outline = fa.document_symbols();
let plugin_ns_in_outline: Vec<&str> = outline
.iter()
.filter(|o| o.kind == SymKind::Namespace)
.map(|o| o.name.as_str())
.filter(|n| n.starts_with('['))
.collect();
assert!(
plugin_ns_in_outline.is_empty(),
"plugin namespaces must not surface in outline; leaked: {:?}",
plugin_ns_in_outline,
);
fn walk<'a>(xs: &'a [crate::file_analysis::OutlineSymbol], out: &mut Vec<&'a str>) {
for x in xs {
out.push(x.name.as_str());
walk(&x.children, out);
}
}
let mut all = Vec::new();
walk(&outline, &mut all);
assert!(
all.iter().any(|n| n.contains("current_user")),
"helper must still appear flat in outline; got: {:?}",
all
);
assert!(
all.iter().any(|n| n.contains("/home")),
"route must still appear flat in outline; got: {:?}",
all
);
}
#[test]
fn mojo_events_emits_emitter_plugin_namespace() {
use crate::file_analysis::Bridge;
let src = r#"package My::Emitter;
use parent 'Mojo::EventEmitter';
sub register {
my $self = shift;
$self->on(connect => sub { my ($e) = @_; });
$self->on(disconnect => sub { my ($e) = @_; });
$self->once(ready => sub { my ($e) = @_; });
}
"#;
let fa = build_fa(src);
let ns = fa
.plugin_namespaces
.iter()
.find(|n| n.kind == "events" && n.bridges.contains(&Bridge::Class("My::Emitter".into())))
.expect("an `events` namespace must bridge My::Emitter");
let entity_names: Vec<&str> = ns
.entities
.iter()
.map(|id| fa.symbol(*id).name.as_str())
.collect();
for ev in ["connect", "disconnect", "ready"] {
assert!(
entity_names.contains(&ev),
"event `{}` must land in the namespace; got: {:?}",
ev,
entity_names
);
}
let count = fa
.plugin_namespaces
.iter()
.filter(|n| n.plugin_id == ns.plugin_id && n.id == ns.id)
.count();
assert_eq!(count, 1, "one namespace per emitter, not one per wire-up");
}
#[test]
fn mojo_routes_emits_app_plugin_namespace() {
use crate::file_analysis::Bridge;
let src = r#"package MyApp;
use Mojolicious;
sub startup {
my $self = shift;
my $r = $self->routes;
$r->get('/users')->to('Users#list');
$r->post('/users')->to('Users#create');
}
"#;
let fa = build_fa(src);
let ns = fa
.plugin_namespaces
.iter()
.find(|n| {
n.kind == "routes"
&& n.bridges
.contains(&Bridge::Class("Mojolicious::Controller".into()))
&& n.entities
.iter()
.any(|id| fa.symbol(*id).name.contains('#'))
})
.expect(
"a `routes` namespace must bridge Mojolicious::Controller with Ctrl#action entities",
);
let entity_names: Vec<&str> = ns
.entities
.iter()
.map(|id| fa.symbol(*id).name.as_str())
.collect();
assert!(
entity_names.contains(&"Users#list"),
"route Users#list must land in the namespace; got: {:?}",
entity_names
);
assert!(
entity_names.contains(&"Users#create"),
"route Users#create must land in the namespace; got: {:?}",
entity_names
);
let count = fa
.plugin_namespaces
.iter()
.filter(|n| n.plugin_id == ns.plugin_id && n.id == ns.id)
.count();
assert_eq!(
count, 1,
"one namespace per declaring package, not one per route"
);
}
#[test]
fn mojo_lite_emits_app_plugin_namespace() {
use crate::file_analysis::Bridge;
let src = r#"package main;
use Mojolicious::Lite;
get '/users' => sub { my $c = shift; };
post '/login' => sub { my $c = shift; };
"#;
let fa = build_fa(src);
let ns = fa
.plugin_namespaces
.iter()
.find(|n| {
n.kind == "routes"
&& n.bridges
.contains(&Bridge::Class("Mojolicious::Controller".into()))
&& n.entities
.iter()
.any(|id| fa.symbol(*id).name.starts_with('/'))
})
.expect(
"a Lite `routes` namespace must bridge Mojolicious::Controller with /path entities",
);
let entity_names: Vec<&str> = ns
.entities
.iter()
.map(|id| fa.symbol(*id).name.as_str())
.collect();
assert!(
entity_names.contains(&"/users"),
"route /users must land in the namespace; got: {:?}",
entity_names
);
assert!(
entity_names.contains(&"/login"),
"route /login must land in the namespace; got: {:?}",
entity_names
);
}
#[test]
fn minion_emits_tasks_plugin_namespace() {
use crate::file_analysis::Bridge;
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub { my ($job) = @_; });
$minion->add_task(resize_image => sub { my ($job) = @_; });
"#;
let fa = build_fa(src);
let ns = fa
.plugin_namespaces
.iter()
.find(|n| n.kind == "tasks" && n.bridges.contains(&Bridge::Class("Minion".into())))
.expect("a `tasks` namespace must bridge Minion");
let entity_names: Vec<&str> = ns
.entities
.iter()
.map(|id| fa.symbol(*id).name.as_str())
.collect();
assert!(
entity_names.contains(&"send_email"),
"task send_email must land in the namespace; got: {:?}",
entity_names
);
assert!(
entity_names.contains(&"resize_image"),
"task resize_image must land in the namespace; got: {:?}",
entity_names
);
let count = fa
.plugin_namespaces
.iter()
.filter(|n| n.plugin_id == ns.plugin_id && n.id == ns.id)
.count();
assert_eq!(count, 1, "one namespace per package, not one per add_task");
}
#[test]
fn enqueue_options_hash_sig_help_is_enqueue_not_task() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub {
my ($job, $to, $subject) = @_;
});
$minion->enqueue(send_email => ['a', 'b'], { });
"#;
let fa = build_fa(src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let line_idx = src
.lines()
.position(|l| l.contains("enqueue(send_email"))
.unwrap();
let line = src.lines().nth(line_idx).unwrap();
let col = line.find("{ }").unwrap() + 2;
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let idx = crate::module_index::ModuleIndex::new_for_test();
let sig = crate::symbols::signature_help(&fa, &tree, src, pos, &idx);
assert!(
sig.is_none(),
"plugin `Silent` on the options-hash slot must suppress native \
sig help entirely; got: {:?}",
sig
);
}
#[test]
fn enqueue_arg0_offers_task_names_only() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub { my ($job) = @_; });
$minion->add_task(resize_image => sub { my ($job) = @_; });
$minion->enqueue();
"#;
let fa = build_fa(src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let minion_src = r#"package Minion;
sub new { my $class = shift; bless {}, $class }
sub enqueue { my ($self, $task, $args, $opts) = @_; }
sub enqueue_p { my ($self, $task, $args, $opts) = @_; }
sub perform_jobs { my ($self) = @_; }
sub backend { my ($self) = @_; }
sub reset { my ($self) = @_; }
sub stats { my ($self) = @_; }
sub worker { my ($self) = @_; }
sub repair { my ($self) = @_; }
sub foreground { my ($self, $id) = @_; }
1;
"#;
let minion_fa = std::sync::Arc::new(build_fa(minion_src));
let idx = std::sync::Arc::new(crate::module_index::ModuleIndex::new_for_test());
idx.register_workspace_module(std::path::PathBuf::from("/tmp/Minion.pm"), minion_fa);
let line_idx = src.lines().position(|l| l.ends_with("enqueue();")).unwrap();
let line = src.lines().nth(line_idx).unwrap();
let col = line.find("enqueue(").unwrap() + "enqueue(".len();
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let items = crate::symbols::completion_items(&fa, &tree, src, pos, &idx, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"send_email"),
"task names must appear at enqueue's arg 0; got: {:?}",
labels
);
assert!(
labels.contains(&"resize_image"),
"every registered task name must be offered; got: {:?}",
labels
);
for label in &labels {
assert!(
*label == "send_email" || *label == "resize_image",
"only task names should appear at enqueue's arg 0; \
got unexpected `{}` in {:?}",
label,
labels,
);
}
}
#[test]
fn plugin_mojo_helpers_sig_help_strips_invocant() {
use tower_lsp::lsp_types::Position;
use tree_sitter::Parser;
let src = r#"package MyApp;
use Mojolicious::Lite;
my $app = Mojolicious->new;
$app->helper(current_user => sub {
my ($c, $fallback) = @_;
});
sub act {
my ($c) = @_;
$c->current_user();
}
"#;
let fa = build_fa(src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let (row, col) = src
.lines()
.enumerate()
.find_map(|(r, l)| {
l.find("current_user()")
.map(|c| (r, c + "current_user(".len()))
})
.expect("find call site");
let pos = Position {
line: row as u32,
character: col as u32,
};
let idx = crate::module_index::ModuleIndex::new_for_test();
let sig = crate::symbols::signature_help(&fa, &tree, src, pos, &idx)
.expect("sig help fires on helper call");
let info = &sig.signatures[0];
assert!(
info.label.contains("current_user"),
"label: {:?}",
info.label
);
assert!(
info.label.contains("$fallback"),
"sig should show declared param `$fallback`; got: {:?}",
info.label
);
assert!(
!info.label.contains("$c"),
"`$c` must be stripped as invocant; got: {:?}",
info.label
);
}
#[test]
fn plugin_minion_sig_help_on_enqueue_array_args() {
use tower_lsp::lsp_types::Position;
use tree_sitter::{Parser, Point};
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->add_task(send_email => sub {
my ($job, $to, $subject, $body) = @_;
});
$minion->enqueue(send_email => [ ]);
"#;
let fa = build_fa(src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let mut cursor_point: Option<Point> = None;
for (row, line) in src.lines().enumerate() {
if line.contains("enqueue(send_email") {
if let Some(col) = line.find("[ ") {
cursor_point = Some(Point::new(row, col + 1));
}
}
}
let cursor_point = cursor_point.expect("locate cursor inside [ ]");
let pos = Position {
line: cursor_point.row as u32,
character: cursor_point.column as u32,
};
let idx = crate::module_index::ModuleIndex::new_for_test();
let sig = crate::symbols::signature_help(&fa, &tree, src, pos, &idx)
.expect("sig help must fire inside enqueue's arrayref");
assert!(!sig.signatures.is_empty(), "at least one signature");
let info = &sig.signatures[0];
let label = &info.label;
assert!(
label.contains("send_email"),
"sig label must reference the handler name: {:?}",
label
);
assert!(
label.contains("$to"),
"sig should surface task params (`$to`): {:?}",
label
);
assert!(
!label.contains("$job"),
"invocant `$job` must be stripped from display: {:?}",
label
);
}
#[test]
fn plugin_minion_hashkey_help_on_enqueue_options() {
use tree_sitter::{Parser, Point};
let src = r#"package MyApp;
use Minion;
my $minion = Minion->new;
$minion->enqueue(task_x => ['arg'] => { });
"#;
let fa = build_fa(src);
let mut parser = Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let src_bytes = src.as_bytes();
let mut cursor: Option<Point> = None;
for (row, line) in src.lines().enumerate() {
if let Some(col) = line.find("{ ") {
cursor = Some(Point::new(row, col + 2));
}
}
let cursor = cursor.expect("find the `{ ` in the source");
let ctx = crate::cursor_context::detect_cursor_context_tree(&tree, src_bytes, cursor, &fa)
.expect("context should be detected inside hash literal");
match ctx {
crate::cursor_context::CursorContext::HashKey { source_sub, .. } => {
assert_eq!(
source_sub.as_deref(),
Some("enqueue"),
"nested {{ }} at call-arg position routes to the callee"
);
}
other => panic!("expected HashKey context, got {:?}", other),
}
let candidates = fa.complete_hash_keys_for_sub("enqueue", cursor);
let labels: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
for expected in &["priority", "queue", "delay", "attempts"] {
assert!(
labels.contains(expected),
"enqueue option `{}` must complete; got: {:?}",
expected,
labels
);
}
}
#[test]
fn plugin_override_patches_return_type_on_matching_method() {
let src = "\
package Mojolicious::Routes::Route;
sub _route {
my $self = shift;
# Real impl uses an array slice that inference can't model.
return $self;
}
1;
";
let fa = build_fa(src);
let route_sym = fa
.symbols
.iter()
.find(|s| s.name == "_route" && matches!(s.kind, SymKind::Sub | SymKind::Method))
.expect("_route must be parsed as a sub");
match &route_sym.detail {
SymbolDetail::Sub { .. } => {
assert_eq!(
fa.symbol_return_type_via_bag(route_sym.id, None),
Some(InferredType::ClassName(
"Mojolicious::Routes::Route".into()
)),
"override must rewrite return_type to ClassName(Mojolicious::Routes::Route)",
);
}
other => panic!("_route must be a Sub detail; got {:?}", other),
}
}
#[test]
fn plugin_override_records_provenance_with_plugin_id_and_reason() {
let src = "\
package Mojolicious::Routes::Route;
sub _route { my $self = shift; $self }
1;
";
let fa = build_fa(src);
let route_id = fa
.symbols
.iter()
.find(|s| s.name == "_route")
.expect("_route present")
.id;
match fa.return_type_provenance(route_id) {
TypeProvenance::PluginOverride { plugin_id, reason } => {
assert_eq!(plugin_id, "mojo-routes");
assert!(
!reason.is_empty(),
"reason must explain why override exists"
);
}
other => panic!("expected PluginOverride provenance; got {:?}", other),
}
}
#[test]
fn plugin_override_does_not_touch_unrelated_subs() {
let src = "\
package Some::Other::Package;
sub _route { my ($x) = @_; { id => $x } }
1;
";
let fa = build_fa(src);
let id = fa
.symbols
.iter()
.find(|s| s.name == "_route")
.expect("_route present")
.id;
assert!(
!matches!(
fa.return_type_provenance(id),
TypeProvenance::PluginOverride { .. }
),
"override must not bleed across packages; provenance: {:?}",
fa.return_type_provenance(id),
);
}
#[test]
fn plugin_override_does_not_touch_other_methods_in_target_class() {
let src = "\
package Mojolicious::Routes::Route;
sub other_method { my $self = shift; { ok => 1 } }
1;
";
let fa = build_fa(src);
let id = fa
.symbols
.iter()
.find(|s| s.name == "other_method")
.expect("other_method present")
.id;
assert!(
!matches!(
fa.return_type_provenance(id),
TypeProvenance::PluginOverride { .. }
),
"override must not bleed across method names; got {:?}",
fa.return_type_provenance(id),
);
}
#[test]
fn plugin_override_visible_via_find_method_return_type() {
let src = "\
package Mojolicious::Routes::Route;
sub _route { my $self = shift; $self }
1;
";
let fa = build_fa(src);
let rt = fa.find_method_return_type("Mojolicious::Routes::Route", "_route", None, None);
assert_eq!(
rt,
Some(InferredType::ClassName("Mojolicious::Routes::Route".into())),
"find_method_return_type must surface the override-supplied type",
);
}
#[test]
fn plugin_override_wins_over_inferred_return_type() {
let src = "\
package Mojolicious::Routes::Route;
sub _route {
return { stub => 1 };
}
1;
";
let fa = build_fa(src);
let sym = fa
.symbols
.iter()
.find(|s| s.name == "_route")
.expect("_route present");
match &sym.detail {
SymbolDetail::Sub { .. } => {
assert_eq!(
fa.symbol_return_type_via_bag(sym.id, None),
Some(InferredType::ClassName(
"Mojolicious::Routes::Route".into()
)),
"override must replace inferred HashRef, not be skipped",
);
}
_ => unreachable!(),
}
}
#[test]
fn plugin_data_printer_synthesizes_p_np_on_use_data_printer() {
let src = "\
use Data::Printer;
p $foo;
np \\%bar;
";
let fa = build_fa(src);
let dp_import = fa.imports.iter().find(|i| {
i.module_name == "Data::Printer" && i.imported_symbols.iter().any(|s| s.local_name == "p")
});
assert!(
dp_import.is_some(),
"plugin must emit Import for Data::Printer carrying `p`; got: {:?}",
fa.imports
);
let names: Vec<&str> = dp_import
.unwrap()
.imported_symbols
.iter()
.map(|s| s.local_name.as_str())
.collect();
assert!(names.contains(&"p"));
assert!(names.contains(&"np"));
}
#[test]
fn plugin_data_printer_aliases_ddp_to_data_printer() {
let src = "\
use DDP;
p $foo;
";
let fa = build_fa(src);
let dp_import = fa.imports.iter().find(|i| {
i.module_name == "Data::Printer" && i.imported_symbols.iter().any(|s| s.local_name == "p")
});
assert!(
dp_import.is_some(),
"use DDP must produce an Import for Data::Printer (alias resolution); got: {:?}",
fa.imports
.iter()
.map(|i| (
i.module_name.clone(),
i.imported_symbols
.iter()
.map(|s| s.local_name.clone())
.collect::<Vec<_>>(),
))
.collect::<Vec<_>>()
);
}
#[test]
fn plugin_data_printer_skips_unrelated_use_statements() {
let src = "use List::Util qw(max);";
let fa = build_fa(src);
assert!(
fa.imports
.iter()
.find(|i| i.module_name == "Data::Printer")
.is_none(),
"plugin must not synthesize a Data::Printer import unless DDP/Data::Printer was used"
);
}
#[test]
fn plugin_dancer2_autoimports_dsl_keywords() {
let src = r#"
package main;
use Dancer2;
get '/users' => sub { return template 'users' };
post '/login' => sub { my $u = param('user'); session user => $u; };
"#;
let fa = build_fa(src);
for kw in &[
"get", "post", "put", "del", "patch", "any", "options",
"prefix",
"hook",
"request", "response", "param", "params",
"body_parameters", "query_parameters", "route_parameters",
"header", "headers", "content_type", "status",
"redirect", "forward", "pass", "halt",
"template", "send_file", "send_as",
"config", "set", "setting",
"session", "cookie", "cookies",
"to_json", "from_json", "to_yaml", "from_yaml",
"var", "vars", "uri_for", "splat", "captures", "upload",
"push_response_header",
"app", "dancer_app", "dsl", "engine",
"delayed", "flush",
"debug", "info", "warning", "error",
"true", "false",
"dance", "to_app", "start",
"content", "send_error", "response_header", "request_header",
"uri_for_route", "prepare_app", "encode_json", "decode_json",
"to_dumper", "from_dumper", "push_header", "response_headers",
"psgi_app", "runner", "done", "context",
"dancer_version", "dancer_major_version",
"mime", "request_data",
] {
assert!(
fa.framework_imports.contains(*kw),
"`{}` must be autoimported by `use Dancer2`; framework_imports={:?}",
kw,
fa.framework_imports,
);
}
}
#[test]
fn plugin_dancer2_typed_stubs_have_return_types() {
use crate::file_analysis::InferredType;
let src = r#"
package main;
use Dancer2;
"#;
let fa = build_fa(src);
let request_sym = fa
.symbols
.iter()
.find(|s| s.name == "request" && matches!(s.kind, crate::file_analysis::SymKind::Sub));
assert!(
request_sym.is_some(),
"dancer plugin must synthesize a `request` Sub symbol"
);
let rt = fa.sub_return_type_at_arity("request", None);
assert_eq!(
rt,
Some(InferredType::ClassName("Dancer2::Core::Request".into())),
"`request` must return Dancer2::Core::Request; got {:?}",
rt
);
let rt = fa.sub_return_type_at_arity("app", None);
assert_eq!(
rt,
Some(InferredType::ClassName("Dancer2::Core::App".into())),
"`app` must return Dancer2::Core::App; got {:?}",
rt
);
let rt = fa.sub_return_type_at_arity("session", None);
assert_eq!(
rt,
Some(InferredType::ClassName("Dancer2::Core::Session".into())),
"`session` must return Dancer2::Core::Session; got {:?}",
rt
);
let rt = fa.sub_return_type_at_arity("config", None);
assert_eq!(
rt,
Some(InferredType::HashRef),
"`config` must return HashRef; got {:?}",
rt
);
let rt = fa.sub_return_type_at_arity("uri_for_route", None);
assert_eq!(
rt,
Some(InferredType::String),
"`uri_for_route` must return String; got {:?}",
rt
);
let rt = fa.sub_return_type_at_arity("encode_json", None);
assert_eq!(
rt,
Some(InferredType::String),
"`encode_json` must return String; got {:?}",
rt
);
let rt = fa.sub_return_type_at_arity("decode_json", None);
assert_eq!(
rt,
Some(InferredType::HashRef),
"`decode_json` must return HashRef; got {:?}",
rt
);
let rt = fa.sub_return_type_at_arity("runner", None);
assert_eq!(
rt,
Some(InferredType::ClassName("Dancer2::Core::Runner".into())),
"`runner` must return Dancer2::Core::Runner; got {:?}",
rt
);
}
#[test]
fn plugin_dancer2_plugin_also_autoimports() {
let src = r#"
package MyApp::Plugin::Foo;
use Dancer2::Plugin;
register my_keyword => sub { my $dsl = shift; $dsl->param('x') };
"#;
let fa = build_fa(src);
for kw in &["get", "post", "param", "request", "session", "config", "debug"] {
assert!(
fa.framework_imports.contains(*kw),
"`{}` must be autoimported by `use Dancer2::Plugin`; got {:?}",
kw,
fa.framework_imports,
);
}
}
#[test]
fn plugin_dancer2_skips_unrelated_use() {
let src = r#"
package main;
use Mojolicious::Lite;
"#;
let fa = build_fa(src);
let dancer_stubs = fa
.symbols
.iter()
.filter(|s| {
s.name == "dancer_app"
&& matches!(
&s.namespace,
crate::file_analysis::Namespace::Framework { id } if id == "dancer"
)
})
.count();
assert_eq!(
dancer_stubs, 0,
"dancer plugin must not emit stubs for `use Mojolicious::Lite`"
);
}
#[test]
fn red_pin_my_resolves_across_statement_packages() {
let src = "\
package Calculator;
my $pi = 3.14159;
sub circumference { my ($self, $r) = @_; return 2 * $pi * $r }
package main;
print \"pi is $pi\\n\";
";
let fa = build_fa(src);
let pi_sym = fa
.symbols
.iter()
.find(|s| s.name == "$pi" && s.kind == SymKind::Variable)
.expect("$pi Variable symbol");
let pi_refs: Vec<_> = fa.refs.iter().filter(|r| r.target_name == "$pi").collect();
assert_eq!(pi_refs.len(), 3, "decl + body use + interpolation = 3 refs");
for r in &pi_refs {
assert_eq!(
r.resolves_to,
Some(pi_sym.id),
"ref at {:?} (scope {:?}) didn't resolve to the lexical decl — \
sibling Package scopes are leaking into variable lookup",
r.span.start,
r.scope,
);
}
}
#[test]
fn red_pin_our_does_not_resolve_across_statement_packages() {
let src = "\
package Calculator;
our $version = 1;
sub bump { $version++ }
package main;
print \"v=$version\\n\";
";
let fa = build_fa(src);
let our_sym = fa
.symbols
.iter()
.find(|s| s.name == "$version" && s.kind == SymKind::Variable)
.expect("$version Variable symbol");
let bump_use = fa
.refs
.iter()
.find(|r| r.target_name == "$version" && r.span.start.row == 2)
.expect("ref inside Calculator's bump");
assert_eq!(
bump_use.resolves_to,
Some(our_sym.id),
"bare $version inside the same package as the `our` decl \
must still resolve to it (lexical alias)"
);
let main_use = fa
.refs
.iter()
.find(|r| r.target_name == "$version" && r.span.start.row == 5)
.expect("ref inside package main's print");
assert_eq!(
main_use.resolves_to, None,
"bare $version under a sibling `package main;` must not \
reach Calculator's `our $version` — that's $Calculator::version, \
a different binding"
);
}
#[test]
fn red_pin_call_arg_emits_hash_key_access_when_def_exists() {
let src = "\
package MooApp;
use Moo;
has name => (is => 'ro');
package main;
my $m = MooApp->new(name => 'alice');
";
let fa = build_fa(src);
let name_access: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "name" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
assert!(
!name_access.is_empty(),
"no HashKeyAccess emitted for `name` in MooApp->new(name => 'alice')",
);
let RefKind::HashKeyAccess {
owner: Some(owner), ..
} = &name_access[0].kind
else {
panic!("HashKeyAccess emitted with no owner");
};
assert_eq!(
*owner,
HashKeyOwner::Sub {
package: Some("MooApp".to_string()),
name: "new".to_string()
},
"constructor-key owner should be Sub{{class, method}}, matching the has-emitted def",
);
let count_access: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "count" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
let src_no_def = "\
package Plain;
sub run {}
package main;
my $p = Plain->run(count => 1);
";
let fa2 = build_fa(src_no_def);
let no_emit: Vec<_> = fa2
.refs
.iter()
.filter(|r| r.target_name == "count" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
assert!(
no_emit.is_empty(),
"no HashKeyDef registered for Plain::run/count → \
must not emit a phantom HashKeyAccess (would shadow other resolution paths)",
);
assert!(
count_access.is_empty(),
"MooApp has no `count` HashKeyDef → no HashKeyAccess emission expected",
);
}
#[test]
fn red_pin_hash_key_access_emission_is_position_based() {
let fat_comma_src = "\
package MooApp;
use Moo;
has name => (is => 'ro');
package main;
my $a = MooApp->new(name => 'alice');
";
let bare_comma_src = "\
package MooApp;
use Moo;
has name => (is => 'ro');
package main;
my $a = MooApp->new('name', 'alice');
";
let fa_fat = build_fa(fat_comma_src);
let fa_bare = build_fa(bare_comma_src);
fn name_access_at_call<'a>(fa: &'a FileAnalysis) -> Vec<&'a Ref> {
fa.refs
.iter()
.filter(|r| {
r.target_name == "name"
&& matches!(r.kind, RefKind::HashKeyAccess { .. })
&& r.span.start.row == 5
})
.collect()
}
let fat_refs = name_access_at_call(&fa_fat);
let bare_refs = name_access_at_call(&fa_bare);
assert_eq!(
fat_refs.len(),
1,
"fat-comma form should emit exactly one HashKeyAccess at the call site",
);
assert_eq!(
bare_refs.len(),
1,
"bare-comma form (`'name', 'alice'`) must emit the same HashKeyAccess — \
`=>` is autoquoting sugar, not a structural marker",
);
let owner_of = |r: &Ref| match &r.kind {
RefKind::HashKeyAccess { owner: Some(o), .. } => o.clone(),
_ => panic!("expected HashKeyAccess with owner"),
};
assert_eq!(
owner_of(fat_refs[0]),
owner_of(bare_refs[0]),
"both forms must produce the same Sub{{MooApp, new}} owner",
);
for fa in [&fa_fat, &fa_bare] {
let alice_access: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "alice" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
assert!(
alice_access.is_empty(),
"value-position arg must never become a HashKeyAccess",
);
}
let multi_src = "\
package MooApp;
use Moo;
has a => (is => 'ro');
has b => (is => 'ro');
package main;
my $m = MooApp->new('a', 1, 'b', 2);
";
let fa_multi = build_fa(multi_src);
let call_keys: Vec<&Ref> = fa_multi
.refs
.iter()
.filter(|r| {
matches!(r.kind, RefKind::HashKeyAccess { .. })
&& r.span.start.row == 6
&& (r.target_name == "a" || r.target_name == "b")
})
.collect();
assert_eq!(
call_keys.len(),
2,
"both even-position args (`'a'`, `'b'`) must emit HashKeyAccess",
);
}
#[test]
fn forward_reference_call_in_sub_return_resolves() {
let src = r#"
package main;
sub longmess {
if ($_[0]) {
return longmess_heavy(@_);
}
else {
return longmess_heavy(@_);
}
}
sub longmess_heavy { return "ouch"; }
"#;
let fa = build_fa(src);
let rt = fa.sub_return_type_at_arity("longmess", None);
assert_eq!(
rt,
Some(InferredType::String),
"longmess must fold to String through both arms — \
got {:?}. Walk-order regression: longmess_heavy is \
defined after longmess.",
rt,
);
}
#[test]
fn forward_reference_implicit_return_resolves() {
let src = r#"
package main;
sub caller_sub { forward_sub() }
sub forward_sub { return "ok"; }
"#;
let fa = build_fa(src);
assert_eq!(
fa.sub_return_type_at_arity("caller_sub", None),
Some(InferredType::String),
);
}
#[test]
fn forward_reference_in_ternary_arms_resolves() {
let src = r#"
package main;
sub dispatch {
return $_[0] ? handle_a() : handle_b();
}
sub handle_a { return "a"; }
sub handle_b { return "b"; }
"#;
let fa = build_fa(src);
assert_eq!(
fa.sub_return_type_at_arity("dispatch", None),
Some(InferredType::String),
);
}
#[test]
fn forward_reference_scoped_identifier_call_resolves() {
let src = r#"
package main;
sub bridge { return Helper::canon(); }
package Helper;
sub canon { return "yes"; }
"#;
let fa = build_fa(src);
assert_eq!(
fa.sub_return_type_at_arity("bridge", None),
Some(InferredType::String),
);
}
#[test]
fn forward_reference_self_method_call_resolves() {
let src = r#"
package Box;
sub new { my $class = shift; return bless {}, $class; }
sub head {
my ($self) = @_;
return $self->tail();
}
sub tail {
my ($self) = @_;
return helper();
}
sub helper { return "fin"; }
"#;
let fa = build_fa(src);
assert_eq!(
fa.sub_return_type_at_arity("tail", None),
Some(InferredType::String),
);
}
mod synthetic_use {
use super::*;
use crate::file_analysis::Namespace;
use crate::plugin::{
CompletionQueryContext, EmitAction, FrameworkPlugin, PluginCompletionAnswer,
PluginRegistry, PluginSigHelpAnswer, SigHelpQueryContext, Trigger, UseContext,
};
use std::sync::Arc;
struct CoBasePlugin;
impl FrameworkPlugin for CoBasePlugin {
fn id(&self) -> &str { "co-base-test" }
fn triggers(&self) -> &[Trigger] {
static T: [Trigger; 1] = [Trigger::Always];
&T
}
fn on_use(&self, ctx: &UseContext) -> Vec<EmitAction> {
if ctx.module_name != "Co::Base" { return Vec::new(); }
let is_class = ctx.raw_args.iter().any(|a| a == "-Class");
if !is_class { return Vec::new(); }
vec![EmitAction::SyntheticUse {
module: "Moo".into(),
args: vec![],
imports: vec![],
span: ctx.span,
}]
}
fn on_signature_help(&self, _: &SigHelpQueryContext) -> Option<PluginSigHelpAnswer> { None }
fn on_completion(&self, _: &CompletionQueryContext) -> Option<PluginCompletionAnswer> { None }
}
fn registry_with_co_base() -> Arc<PluginRegistry> {
let mut reg = PluginRegistry::new();
reg.register(Box::new(CoBasePlugin));
Arc::new(reg)
}
fn build_with(source: &str, plugins: Arc<PluginRegistry>) -> FileAnalysis {
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let tree = parser.parse(source, None).unwrap();
super::super::build_with_plugins(&tree, source.as_bytes(), plugins)
}
#[test]
fn synthetic_use_moo_matches_literal_use_moo() {
let kit_src = r#"
package Foo;
use Co::Base -Class;
has 'name' => (is => 'ro');
"#;
let lit_src = r#"
package Foo;
use Moo;
has 'name' => (is => 'ro');
"#;
let kit = build_with(kit_src, registry_with_co_base());
let lit = build_with(lit_src, registry_with_co_base());
assert_eq!(
kit.package_framework.get("Foo"),
lit.package_framework.get("Foo"),
"kit (`use Co::Base -Class`) and literal (`use Moo`) must agree on package_framework"
);
assert!(
kit.package_framework.contains_key("Foo"),
"Foo's framework should be set by SyntheticUse \"Moo\"; package_framework={:?}",
kit.package_framework,
);
for kw in &["has", "with", "extends", "around", "before", "after"] {
assert!(
kit.framework_imports.contains(*kw),
"SyntheticUse \"Moo\" must populate framework_imports[{kw}]; got {:?}",
kit.framework_imports,
);
}
let kit_methods: Vec<&str> = kit.symbols.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.map(|s| s.name.as_str())
.collect();
let lit_methods: Vec<&str> = lit.symbols.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.map(|s| s.name.as_str())
.collect();
assert_eq!(
kit_methods, lit_methods,
"`has 'name'` should synthesize the same accessor Methods under \
SyntheticUse \"Moo\" as under literal `use Moo`",
);
assert_eq!(kit_methods.len(), 1, "ro getter is exactly one Method");
let kit_moo = kit.symbols.iter()
.find(|s| s.kind == SymKind::Module && s.name == "Moo")
.expect("kit build must have a Module symbol for synthesized `use Moo`");
assert_eq!(
kit_moo.namespace,
Namespace::framework("co-base-test"),
"synthesized Module must carry the emitting plugin's namespace tag"
);
let lit_moo = lit.symbols.iter()
.find(|s| s.kind == SymKind::Module && s.name == "Moo")
.expect("literal build must have a Module symbol for `use Moo`");
assert_eq!(
lit_moo.namespace,
Namespace::Language,
"literal-source Module must stay on Namespace::Language (no plugin tag)"
);
}
#[test]
fn synthetic_use_self_cycle_is_bounded() {
struct LoopPlugin;
impl FrameworkPlugin for LoopPlugin {
fn id(&self) -> &str { "loop-test" }
fn triggers(&self) -> &[Trigger] {
static T: [Trigger; 1] = [Trigger::Always];
&T
}
fn on_use(&self, ctx: &UseContext) -> Vec<EmitAction> {
if ctx.module_name != "Co::Base" { return Vec::new(); }
vec![EmitAction::SyntheticUse {
module: "Co::Base".into(),
args: vec![],
imports: vec![],
span: ctx.span,
}]
}
fn on_signature_help(&self, _: &SigHelpQueryContext) -> Option<PluginSigHelpAnswer> { None }
fn on_completion(&self, _: &CompletionQueryContext) -> Option<PluginCompletionAnswer> { None }
}
let mut reg = PluginRegistry::new();
reg.register(Box::new(LoopPlugin));
let fa = build_with("package Foo; use Co::Base;\n", Arc::new(reg));
let co_base_imports = fa.imports.iter()
.filter(|i| i.module_name == "Co::Base")
.count();
assert_eq!(
co_base_imports, 1,
"self-cycle must collapse to one Import entry; use_dedup gate kicked in"
);
let module_syms = fa.symbols.iter()
.filter(|s| s.kind == SymKind::Module && s.name == "Co::Base")
.count();
assert_eq!(module_syms, 1, "self-cycle must emit one Module symbol");
let co_base_uses = fa.symbols.iter()
.filter(|s| s.kind == SymKind::Module && s.name == "Co::Base")
.count();
assert_eq!(
co_base_uses, 1,
"package_uses-equivalent (Module symbol count) should match Import count"
);
assert!(
fa.framework_imports.is_empty()
|| fa.framework_imports.iter().all(|s| !s.starts_with("co_base_")),
"cycle on a non-framework module should not leak Co::Base-tagged keywords \
into framework_imports; got {:?}",
fa.framework_imports,
);
}
#[test]
fn synthetic_use_distinct_imports_both_emit() {
struct ImportPlugin;
impl FrameworkPlugin for ImportPlugin {
fn id(&self) -> &str { "imports-test" }
fn triggers(&self) -> &[Trigger] {
static T: [Trigger; 1] = [Trigger::Always];
&T
}
fn on_use(&self, ctx: &UseContext) -> Vec<EmitAction> {
if ctx.module_name != "Trigger::Kit" { return Vec::new(); }
vec![
EmitAction::SyntheticUse {
module: "Foo".into(),
args: vec![],
imports: vec!["a".into()],
span: ctx.span,
},
EmitAction::SyntheticUse {
module: "Foo".into(),
args: vec![],
imports: vec!["b".into()],
span: ctx.span,
},
]
}
fn on_signature_help(&self, _: &SigHelpQueryContext) -> Option<PluginSigHelpAnswer> { None }
fn on_completion(&self, _: &CompletionQueryContext) -> Option<PluginCompletionAnswer> { None }
}
let mut reg = PluginRegistry::new();
reg.register(Box::new(ImportPlugin));
let fa = build_with("package Foo; use Trigger::Kit;\n", Arc::new(reg));
let foo_imports: Vec<&crate::file_analysis::Import> = fa.imports.iter()
.filter(|i| i.module_name == "Foo")
.collect();
assert_eq!(
foo_imports.len(), 2,
"two SyntheticUse \"Foo\" with distinct imports must both produce \
Import entries — dedup must NOT collide on the args-only key. \
Got imports: {:?}",
foo_imports.iter().map(|i| &i.imported_symbols).collect::<Vec<_>>(),
);
let all_names: std::collections::HashSet<&str> = foo_imports.iter()
.flat_map(|i| i.imported_symbols.iter().map(|s| s.local_name.as_str()))
.collect();
assert!(all_names.contains("a"), "missing import name 'a': {:?}", all_names);
assert!(all_names.contains("b"), "missing import name 'b': {:?}", all_names);
}
}
#[test]
fn spike_array_hop_with_helper_and_cross_file_completion() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
use std::sync::Arc;
let user_pm = r#"
package Some::User;
use Mojo::Base -base;
has 'name';
sub greet { my $self = shift; "hi $self->{name}" }
sub email { my $self = shift; "$self->{name}\@x.com" }
1;
"#;
let user_fa = build_fa(user_pm);
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
PathBuf::from("/tmp/Some/User.pm"),
Arc::new(user_fa),
);
let app_src = r#"
package MyApp;
use Mojolicious::Lite;
use constant DEFAULT_NAME => 'alice';
my $app = Mojolicious->new;
$app->helper(make_user => sub {
my ($c, $name) = @_;
return Some::User->new(name => $name);
});
sub action {
my $c = Mojolicious::Controller->new;
my @users;
push @users, $c->make_user(DEFAULT_NAME);
push @users, $c->make_user('bob');
$users[0]->greet();
}
"#;
let app_fa = build_fa(app_src);
let tree = parse(app_src);
fn find_array_element<'a>(node: tree_sitter::Node<'a>) -> Option<tree_sitter::Node<'a>> {
if node.kind() == "array_element_expression" {
return Some(node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(hit) = find_array_element(child) {
return Some(hit);
}
}
None
}
let elem_node = find_array_element(tree.root_node())
.expect("test source contains `$users[0]`");
let resolved = crate::cursor_context::resolve_expression_type(&app_fa, elem_node, app_src.as_bytes(), Some(&idx))
.expect("$users[0] resolves to a type");
assert_eq!(
resolved.class_name(),
Some("Some::User"),
"the array hop survives the chain: helper(coderef) → push → \
$users[0] → Some::User. got: {:?}",
resolved,
);
let methods = app_fa.complete_methods_for_class("Some::User", Some(&idx));
let method_names: std::collections::HashSet<&str> =
methods.iter().map(|c| c.label.as_str()).collect();
assert!(method_names.contains("greet"), "cross-file user method 'greet' missing");
assert!(method_names.contains("email"), "cross-file user method 'email' missing");
assert!(method_names.contains("name"), "Mojo::Base accessor 'name' missing");
let keys = app_fa.complete_hash_keys_for_class("Some::User", Point::new(0, 0));
let key_names: std::collections::HashSet<&str> =
keys.iter().map(|c| c.label.as_str()).collect();
let _ = key_names;
fn find_method_call<'a>(
node: tree_sitter::Node<'a>,
src: &[u8],
method: &str,
) -> Option<tree_sitter::Node<'a>> {
if node.kind() == "method_call_expression" {
if let Some(m) = node.child_by_field_name("method") {
if m.utf8_text(src).ok() == Some(method) {
return Some(node);
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(hit) = find_method_call(child, src, method) {
return Some(hit);
}
}
None
}
let greet_call = find_method_call(tree.root_node(), app_src.as_bytes(), "greet")
.expect("test source contains `$users[0]->greet()`");
let method_node = greet_call
.child_by_field_name("method")
.expect("method-call has a method child");
let hover = app_fa.hover_info(
method_node.start_position(),
app_src,
Some(&idx),
);
let hover_text = hover.expect("hover on `$users[0]->greet` returns text");
assert!(
hover_text.contains("Some::User"),
"hover on `$users[0]->greet` should mention Some::User; got: {}",
hover_text,
);
assert!(
hover_text.contains("greet"),
"hover should include the method name; got: {}",
hover_text,
);
}
#[test]
fn moo_instanceof_isa_types_accessor_to_inner_class() {
let fa = build_fa(
"package T;\nuse Moo;\nuse Types::Standard qw/InstanceOf/;\nhas thing => (is => 'ro', isa => InstanceOf['My::Thing']);\n1;\n",
);
assert_eq!(
fa.sub_return_type_at_arity("thing", Some(0)),
Some(InferredType::ClassName("My::Thing".to_string())),
"InstanceOf['My::Thing'] isa must give the getter a My::Thing return",
);
}
#[test]
fn instanceof_expression_is_a_type_constraint_not_the_class() {
let fa = build_fa(
"package T;\nuse Moo;\nuse Types::Standard qw/InstanceOf/;\nmy $t = InstanceOf['My::Thing'];\n1;\n",
);
let ty = fa
.inferred_type_via_bag("$t", Point::new(3, 20))
.expect("$t should carry a type");
assert!(
matches!(&ty, InferredType::TypeConstraintOf(inner)
if matches!(inner.as_ref(), InferredType::ClassName(c) if c == "My::Thing")),
"InstanceOf['My::Thing'] is a TypeConstraintOf(ClassName(My::Thing)), got {:?}",
ty,
);
assert!(
ty.constrained_inner().and_then(|i| i.class_name()) == Some("My::Thing"),
"constrained_inner projects the class for the isa→accessor path",
);
}
#[test]
fn moo_isa_via_constraint_variable_projects_inner() {
let fa = build_fa(
"package T;\nuse Moo;\nuse Types::Standard qw/InstanceOf/;\nmy $t = InstanceOf['My::Thing'];\nhas thing => (is => 'ro', isa => $t);\n1;\n",
);
assert_eq!(
fa.sub_return_type_at_arity("thing", Some(0)),
Some(InferredType::ClassName("My::Thing".to_string())),
"isa => $constraint_var must project the constrained inner onto the accessor",
);
}
#[test]
fn moo_string_isa_forms_still_resolve() {
let fa = build_fa(
"package T;\nuse Moo;\nhas s => (is=>'ro', isa=>'Str');\nhas i => (is=>'ro', isa=>'Int');\nhas h => (is=>'ro', isa=>'HashRef');\n1;\n",
);
assert_eq!(fa.sub_return_type_at_arity("s", Some(0)), Some(InferredType::String));
assert_eq!(fa.sub_return_type_at_arity("i", Some(0)), Some(InferredType::Numeric));
assert_eq!(fa.sub_return_type_at_arity("h", Some(0)), Some(InferredType::HashRef));
}
#[test]
fn moo_instanceof_isa_types_both_getter_and_writer() {
let fa = build_fa(
"package T;\nuse Moo;\nuse Types::Standard qw/InstanceOf/;\nhas thing => (is=>'rw', isa=>InstanceOf['My::Thing']);\n1;\n",
);
let want = Some(InferredType::ClassName("My::Thing".to_string()));
assert_eq!(fa.sub_return_type_at_arity("thing", Some(0)), want.clone(), "getter");
assert_eq!(fa.sub_return_type_at_arity("thing", Some(1)), want, "rw writer");
}
#[test]
fn moo_maybe_instanceof_isa_unwraps_to_inner_class() {
let fa = build_fa(
"package T;\nuse Moo;\nuse Types::Standard qw/Maybe InstanceOf/;\nhas thing => (is=>'ro', isa=>Maybe[InstanceOf['My::Thing']]);\n1;\n",
);
assert_eq!(
fa.sub_return_type_at_arity("thing", Some(0)),
Some(InferredType::ClassName("My::Thing".to_string())),
"Maybe[InstanceOf['My::Thing']] must unwrap to a My::Thing accessor return",
);
}
#[test]
fn moo_consumerof_isa_types_accessor() {
let fa = build_fa(
"package T;\nuse Moo;\nuse Types::Standard qw/ConsumerOf/;\nhas r => (is=>'ro', isa=>ConsumerOf['My::Role']);\n1;\n",
);
assert_eq!(
fa.sub_return_type_at_arity("r", Some(0)),
Some(InferredType::ClassName("My::Role".to_string())),
);
}
#[test]
fn moo_instanceof_isa_handles_space_before_bracket() {
let fa = build_fa(
"package T;\nuse Moo;\nuse Types::Standard qw/InstanceOf/;\nhas thing => (is=>'ro', isa=>InstanceOf ['My::Thing']);\n1;\n",
);
assert_eq!(
fa.sub_return_type_at_arity("thing", Some(0)),
Some(InferredType::ClassName("My::Thing".to_string())),
);
}
#[test]
fn moose_instanceof_isa_types_accessor() {
let fa = build_fa(
"package T;\nuse Moose;\nuse Types::Standard qw/InstanceOf/;\nhas thing => (is=>'ro', isa=>InstanceOf['My::Thing']);\n1;\n",
);
assert_eq!(
fa.sub_return_type_at_arity("thing", Some(0)),
Some(InferredType::ClassName("My::Thing".to_string())),
);
}
#[test]
fn moo_coderef_isa_leaves_accessor_untyped() {
let fa = build_fa(
"package T;\nuse Moo;\nhas thing => (is=>'ro', isa=>sub { die unless ref $_[0] });\n1;\n",
);
assert_eq!(
fa.sub_return_type_at_arity("thing", Some(0)),
None,
"a coderef constraint has no class denotation",
);
}
#[test]
fn moo_unknown_constructor_isa_falls_through() {
let fa = build_fa(
"package T;\nuse Moo;\nhas thing => (is=>'ro', isa=>SomeUnknownType['X']);\n1;\n",
);
assert_eq!(fa.sub_return_type_at_arity("thing", Some(0)), None);
}
#[test]
fn instanceof_accessor_chains_into_method_call() {
let src = "package Other;\nuse Moo;\nsub greet ($self) { return 'hi'; }\n\npackage T;\nuse Moo;\nuse Types::Standard qw/InstanceOf/;\nhas other => (is=>'ro', isa=>InstanceOf['Other']);\nsub use_it ($self) { return $self->other->greet; }\n1;\n";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let idx = crate::module_index::ModuleIndex::new_for_test();
fn find_call<'a>(n: tree_sitter::Node<'a>, src: &[u8], m: &str) -> Option<tree_sitter::Node<'a>> {
if n.kind() == "method_call_expression" {
if let Some(mn) = n.child_by_field_name("method") {
if mn.utf8_text(src).ok() == Some(m) { return Some(n); }
}
}
for i in 0..n.named_child_count() {
if let Some(c) = n.named_child(i) {
if let Some(f) = find_call(c, src, m) { return Some(f); }
}
}
None
}
let call = find_call(tree.root_node(), src.as_bytes(), "greet").expect("has $self->other->greet");
let method_node = call.child_by_field_name("method").unwrap();
let hover = fa
.hover_info(method_node.start_position(), src, Some(&idx))
.expect("hover on ->greet resolves");
assert!(
hover.contains("Other"),
"->greet on an InstanceOf['Other'] accessor must resolve against Other; got: {hover}",
);
}
#[test]
fn provisional_dispatch_resolves_helper_returned_receiver() {
use crate::file_analysis::HandlerOwner;
use std::path::PathBuf;
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
PathBuf::from("/tmp/b_hr_minion.pm"),
std::sync::Arc::new(build_fa("package Acme::Minion;\nuse Mojo::Base 'Minion';\n1;\n")),
);
idx.register_workspace_module(
PathBuf::from("/tmp/b_hr_plugin.pm"),
std::sync::Arc::new(build_fa(
"package Acme::Plugin;\nuse Mojo::Base 'Mojolicious::Plugin';\nsub register ($self, $app, $conf) {\n my $m = Acme::Minion->new;\n $app->helper(minion => sub {$m});\n $app->minion->add_task('Task.go' => sub ($job) { 1 });\n}\n1;\n",
)),
);
let fa = build_fa(
"package Acme::Ctrl;\nuse Mojo::Base 'Mojolicious::Controller';\nsub act ($c) {\n $c->minion->enqueue('Task.go');\n}\n1;\n",
);
let has_materialized = fa.refs.iter().any(|r|
matches!(&r.kind, RefKind::DispatchCall { dispatcher, owner: Some(HandlerOwner::Class(c)) }
if dispatcher == "enqueue" && c == "Minion")
&& r.target_name == "Task.go");
let applied = fa.applicable_dispatches(Some(&idx));
let has_gated = applied.iter().any(|a|
a.name == "Task.go" && a.owner == HandlerOwner::Class("Minion".into()));
assert!(
has_materialized ^ has_gated,
"the helper-returned receiver $c->minion (Acme::Minion isa Minion) enqueue \
must surface exactly once — via the emit-hook ref OR the gated candidate, \
never both; materialized={has_materialized} gated={has_gated} applied={:?}",
applied,
);
assert!(
has_materialized,
"this file hits a Mojo trigger, so the emit-hook materializes the dispatch",
);
}
mod param_types_manifest {
use super::*;
use crate::plugin::{
CompletionQueryContext, FrameworkPlugin, ParamType, PluginCompletionAnswer,
PluginRegistry, PluginSigHelpAnswer, SigHelpQueryContext, Trigger,
};
use std::sync::Arc;
struct UpgradeRolePlugin;
impl FrameworkPlugin for UpgradeRolePlugin {
fn id(&self) -> &str { "upgrade-role-test" }
fn triggers(&self) -> &[Trigger] {
static T: [Trigger; 1] = [Trigger::Always];
&T
}
fn param_types(&self) -> &[ParamType] {
use std::sync::OnceLock;
static PT: OnceLock<Vec<ParamType>> = OnceLock::new();
PT.get_or_init(|| {
vec![ParamType {
method: Some("run_upgrade".into()),
in_role: "My::Upgrade::Role".into(),
param: 1,
type_class: "Mojolicious".into(),
requires_action_attr: false,
from_loader_config: false,
}]
})
}
fn on_signature_help(&self, _: &SigHelpQueryContext) -> Option<PluginSigHelpAnswer> { None }
fn on_completion(&self, _: &CompletionQueryContext) -> Option<PluginCompletionAnswer> { None }
}
fn build_with_upgrade(source: &str) -> FileAnalysis {
let mut reg = PluginRegistry::new();
reg.register(Box::new(UpgradeRolePlugin));
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let tree = parser.parse(source, None).unwrap();
crate::builder::build_with_plugins(&tree, source.as_bytes(), Arc::new(reg))
}
#[test]
fn role_doer_run_upgrade_app_param_typed() {
let fa = build_with_upgrade(
"package My::Doer;\nuse Moo;\nwith 'My::Upgrade::Role';\nsub run_upgrade ($self, $app) {\n my $x = $app;\n}\n1;\n",
);
let ty = fa
.inferred_type_via_bag("$app", Point::new(4, 10))
.expect("$app should be typed by the param_types manifest");
assert!(
matches!(&ty, InferredType::ClassName(c) if c == "Mojolicious"),
"role-contract param typing should make $app a Mojolicious, got {:?}",
ty,
);
}
#[test]
fn non_doer_same_method_name_not_typed() {
let fa = build_with_upgrade(
"package Other;\nsub run_upgrade ($self, $app) {\n my $x = $app;\n}\n1;\n",
);
assert_eq!(
fa.inferred_type_via_bag("$app", Point::new(2, 10)),
None,
"a class that doesn't do the role must not get the contract param type",
);
}
#[test]
#[ignore = "cross-file ClassIsa trigger: architectural, see docs/prompt-enrichment-inheritance-residual.md"]
fn probe_class_isa_trigger_through_cross_file_parent() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"Mid",
Some(fake_cached_for_class(
"Mid",
&PathBuf::from("/fake/Mid.pm"),
&[],
&["Mojo::EventEmitter"],
)),
);
let src = "package Leaf;\nuse parent 'Mid';\nsub wire {\n my $self = shift;\n $self->on('ready', sub { 1 });\n}\n1;\n";
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let tree = parser.parse(src, None).unwrap();
let mut fa = crate::builder::build(&tree, src.as_bytes());
fa.enrich_imported_types_with_keys(Some(&idx));
let ready = fa.symbols.iter().filter(|s| {
s.kind == SymKind::Handler && s.name == "ready"
&& matches!(&s.namespace, Namespace::Framework { id } if id == "mojo-events")
}).count();
assert_eq!(
ready, 1,
"mojo-events ClassIsa trigger should fire via cross-file parent chain"
);
}
#[test]
fn dispatch_resolves_query_time_in_unenriched_workspace_file() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"My::Minion",
Some(fake_cached_for_class(
"My::Minion",
&PathBuf::from("/fake/My/Minion.pm"),
&["new"],
&["Minion"],
)),
);
let src = "package My::Worker;\nsub run {\n my $self = shift;\n my $minion = My::Minion->new;\n $minion->enqueue('send_email');\n}\n1;\n";
let fa = build_fa(src);
let applied = fa.applicable_dispatches(Some(&idx));
assert_eq!(
applied.len(), 1,
"workspace-indexed file (no enrichment) should resolve its enqueue \
dispatch at query time via the cross-file receiver isa — else \
cross-file handler references miss it; got {:?}",
applied,
);
assert_eq!(applied[0].name, "send_email");
}
struct CatalystPlugin;
impl FrameworkPlugin for CatalystPlugin {
fn id(&self) -> &str { "catalyst-test" }
fn triggers(&self) -> &[Trigger] {
static T: [Trigger; 1] = [Trigger::Always];
&T
}
fn param_types(&self) -> &[ParamType] {
use std::sync::OnceLock;
static PT: OnceLock<Vec<ParamType>> = OnceLock::new();
PT.get_or_init(|| {
vec![ParamType {
method: None, in_role: "Catalyst::Controller".into(),
param: 1,
type_class: "Catalyst".into(),
requires_action_attr: true,
from_loader_config: false,
}]
})
}
fn on_signature_help(&self, _: &SigHelpQueryContext) -> Option<PluginSigHelpAnswer> { None }
fn on_completion(&self, _: &CompletionQueryContext) -> Option<PluginCompletionAnswer> { None }
}
fn build_with_catalyst(source: &str) -> FileAnalysis {
let mut reg = PluginRegistry::new();
reg.register(Box::new(CatalystPlugin));
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let tree = parser.parse(source, None).unwrap();
crate::builder::build_with_plugins(&tree, source.as_bytes(), Arc::new(reg))
}
#[test]
fn catalyst_action_c_typed_via_wildcard_manifest() {
let fa = build_with_catalyst(
"package MyApp::Controller::Foo;\nuse parent 'Catalyst::Controller';\nsub index :Local {\n my ($self, $c) = @_;\n my $req = $c;\n}\n1;\n",
);
let ty = fa
.inferred_type_via_bag("$c", Point::new(4, 14))
.expect("$c should be typed by wildcard param_types manifest");
assert!(
matches!(&ty, InferredType::ClassName(c) if c == "Catalyst"),
"wildcard param_types should make $c a Catalyst in every controller action, got {:?}",
ty,
);
}
#[test]
fn catalyst_wildcard_typed_for_any_action_name() {
let fa = build_with_catalyst(
"package MyApp::Controller::Bar;\nuse parent 'Catalyst::Controller';\nsub list :Local {\n my ($self, $c) = @_;\n my $x = $c;\n}\n1;\n",
);
let ty = fa
.inferred_type_via_bag("$c", Point::new(4, 12))
.expect("$c should be typed regardless of action method name");
assert!(
matches!(&ty, InferredType::ClassName(c) if c == "Catalyst"),
"wildcard param_types must apply to any action name, got {:?}",
ty,
);
}
#[test]
fn catalyst_wildcard_c_typed_through_cross_file_base() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"MyApp::ControllerBase",
Some(fake_cached_for_class(
"MyApp::ControllerBase",
&PathBuf::from("/fake/MyApp/ControllerBase.pm"),
&[],
&["Catalyst::Controller"],
)),
);
let src = "package MyApp::Controller::Deep;\nuse parent 'MyApp::ControllerBase';\nsub show :Local {\n my ($self, $c) = @_;\n my $req = $c;\n}\n1;\n";
let fa = build_with_catalyst(src);
let ty = fa
.inferred_type_via_bag_ctx("$c", Point::new(4, 14), Some(&idx))
.expect("$c should type via the cross-file Catalyst::Controller ancestry");
assert!(
matches!(&ty, InferredType::ClassName(c) if c == "Catalyst"),
"wildcard param_types must type $c when Catalyst::Controller is a \
cross-file ancestor, got {:?}",
ty,
);
}
#[test]
fn catalyst_wildcard_not_applied_outside_controller() {
let fa = build_with_catalyst(
"package OtherPackage;\nsub index {\n my ($self, $c) = @_;\n my $x = $c;\n}\n1;\n",
);
assert_eq!(
fa.inferred_type_via_bag("$c", Point::new(3, 12)),
None,
"wildcard rule must not type $c in a package that doesn't isa Catalyst::Controller",
);
}
#[test]
fn catalyst_c_typed_through_workspace_intermediate_via_hover() {
use crate::module_index::ModuleIndex;
use std::path::PathBuf;
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.register_workspace_module(
PathBuf::from("/fake/MetaCPAN/Web/Controller.pm"),
std::sync::Arc::new(build_fa(
"package MetaCPAN::Web::Controller;\nuse parent 'Catalyst::Controller';\nsub pageset {\n my ($self, $page) = @_;\n}\n1;\n",
)),
);
let src = "package MetaCPAN::Web::Controller::Author;\nuse parent 'MetaCPAN::Web::Controller';\nsub root :Chained {\n my ($self, $c, $id) = @_;\n my $x = $c;\n}\n1;\n";
let fa = build_with_catalyst(src);
let hover = fa
.hover_info(Point::new(4, 12), src, Some(&idx))
.expect("hover should produce info for $c");
assert!(
hover.contains("type: Catalyst"),
"3-hop cross-file ancestry through a workspace base must type $c in \
hover (the path that dropped the index), got: {}",
hover,
);
}
#[test]
fn catalyst_non_action_helper_second_param_not_typed() {
let fa = build_with_catalyst(
"package MyApp::Controller::Foo;\nuse parent 'Catalyst::Controller';\nsub show :Local {\n my ($self, $c) = @_;\n my $a = $c;\n}\nsub pageset {\n my ($self, $page) = @_;\n my $b = $page;\n}\n1;\n",
);
let c_ty = fa
.inferred_type_via_bag("$c", Point::new(4, 12))
.expect("action $c should be typed");
assert!(
matches!(&c_ty, InferredType::ClassName(c) if c == "Catalyst"),
"action method's $c must type as Catalyst, got {:?}",
c_ty,
);
assert_eq!(
fa.inferred_type_via_bag("$page", Point::new(8, 12)),
None,
"a non-action helper's 2nd param must NOT be typed Catalyst (P1.3 \
over-application); only attribute-carrying actions receive $c",
);
}
#[test]
fn catalyst_private_action_names_type_c_without_attr() {
let fa = build_with_catalyst(
"package MyApp::Controller::Root;\nuse parent 'Catalyst::Controller';\nsub end {\n my ($self, $c) = @_;\n my $r = $c;\n}\n1;\n",
);
let ty = fa
.inferred_type_via_bag("$c", Point::new(4, 12))
.expect("private-action end: $c should be typed even without an attribute");
assert!(
matches!(&ty, InferredType::ClassName(c) if c == "Catalyst"),
"sub end without action attr must type $c as Catalyst, got {:?}",
ty,
);
}
#[test]
fn catalyst_plain_helper_not_a_private_action() {
let fa = build_with_catalyst(
"package MyApp::Controller::Root;\nuse parent 'Catalyst::Controller';\nsub helper {\n my ($self, $x) = @_;\n my $r = $x;\n}\n1;\n",
);
assert_eq!(
fa.inferred_type_via_bag("$x", Point::new(4, 12)),
None,
"non-action, non-private-action helper must NOT have $x typed as Catalyst",
);
}
}
#[test]
fn not_operator_emits_no_function_call_ref() {
let fa = build_fa("my $x = 1;\nmy $y = not $x;\n");
let not_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "not" && matches!(r.kind, RefKind::FunctionCall { .. }))
.collect();
assert!(
not_refs.is_empty(),
"`not` is an operator now; no FunctionCall ref should exist; got refs: {:?}",
fa.refs.iter().map(|r| (&r.target_name, &r.kind)).collect::<Vec<_>>(),
);
assert!(
fa.refs.iter().any(|r| r.target_name == "$x"),
"operand $x should still be referenced",
);
}
#[test]
fn refgen_bare_name_emits_function_call_ref() {
let fa = build_fa("sub handler { 1 }\nmy $cb = \\&handler;\n");
let refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "handler" && matches!(r.kind, RefKind::FunctionCall { .. }))
.collect();
assert_eq!(
refs.len(),
1,
"\\&handler should emit exactly one FunctionCall ref for `handler`; got: {:?}",
fa.refs
.iter()
.filter(|r| r.target_name == "handler")
.map(|r| &r.kind)
.collect::<Vec<_>>(),
);
}
#[test]
fn refgen_qualified_name_emits_function_call_ref() {
let fa = build_fa("my $cb = \\&Foo::handler;\n");
let refs: Vec<_> = fa
.refs
.iter()
.filter(|r| {
r.target_name == "handler" || r.target_name == "Foo::handler"
})
.collect();
assert!(
!refs.is_empty(),
"\\&Foo::handler should emit a FunctionCall ref; got refs: {:?}",
fa.refs.iter().map(|r| (&r.target_name, &r.kind)).collect::<Vec<_>>(),
);
}
#[test]
fn refgen_goto_def_lands_on_sub_definition() {
let src = "sub handler { 1 }\nmy $cb = \\&handler;\n";
let fa = build_fa(src);
let sub_sym = fa
.symbols
.iter()
.find(|s| s.name == "handler" && matches!(s.kind, SymKind::Sub))
.expect("handler sub should be defined");
let def_span = fa.find_definition(
Point::new(1, 11),
None);
assert_eq!(
def_span,
Some(sub_sym.selection_span),
"goto-def on \\&handler should land on the handler sub; sym={:?}",
sub_sym,
);
}
#[test]
fn split_qualified_basics() {
use crate::file_analysis::split_qualified;
assert_eq!(split_qualified("Foo::Bar::baz"), (Some("Foo::Bar"), "baz"));
assert_eq!(split_qualified("baz"), (None, "baz"));
assert_eq!(split_qualified("Foo::bar"), (Some("Foo"), "bar"));
assert_eq!(split_qualified("::foo"), (Some(""), "foo"));
}
#[test]
fn fq_scalar_read_resolves_same_file() {
let src = "package Pkg;\nour $x = 1;\npackage Main;\nmy $a = $Pkg::x;\n";
let fa = build_fa(src);
let decl = fa
.symbols
.iter()
.find(|s| s.name == "$x" && s.package.as_deref() == Some("Pkg"))
.expect("our $x in Pkg should be a symbol");
let read = fa
.refs
.iter()
.find(|r| r.target_name == "$Pkg::x")
.expect("$Pkg::x should emit a Variable ref");
assert_eq!(
read.resolves_to,
Some(decl.id),
"FQ scalar read should resolve to the Pkg::x declaration"
);
let def = fa.find_definition(read.span.start, None);
assert_eq!(def, Some(decl.selection_span));
}
#[test]
fn fq_array_read_resolves_same_file() {
let src = "package Pkg;\nour @arr = (1, 2);\npackage Main;\nmy @b = @Pkg::arr;\n";
let fa = build_fa(src);
let decl = fa
.symbols
.iter()
.find(|s| s.name == "@arr" && s.package.as_deref() == Some("Pkg"))
.expect("our @arr in Pkg should be a symbol");
let read = fa
.refs
.iter()
.find(|r| r.target_name == "@Pkg::arr")
.expect("@Pkg::arr should emit a Variable ref");
assert_eq!(read.resolves_to, Some(decl.id));
}
#[test]
fn fq_var_ref_span_narrowed_to_tail() {
let src = "package Pkg;\nour $x = 1;\npackage Main;\nmy $a = $Pkg::x;\n";
let fa = build_fa(src);
let read = fa
.refs
.iter()
.find(|r| r.target_name == "$Pkg::x")
.expect("$Pkg::x ref");
assert_eq!(read.span.start.row, 3);
assert_eq!(read.span.start.column, 14, "span should start at the `x` tail");
}
#[test]
fn unqualified_var_still_resolves_lexically() {
let fa = build_fa("my $x = 1;\nprint $x;\n");
let read = fa
.refs
.iter()
.find(|r| r.target_name == "$x" && r.access == AccessKind::Read)
.expect("plain $x read");
assert!(read.resolves_to.is_some(), "unqualified read still resolves");
}
#[test]
fn around_modifier_second_param_typed_as_class() {
let src = r#"
package Dog;
use Moo;
sub speak { "woof" }
around speak => sub {
my ($orig, $self) = @_;
return $self->speak_loudly();
};
sub speak_loudly { "WOOF" }
"#;
let fa = build_fa(src);
let ty = fa.inferred_type_via_bag("$self", Point::new(8, 12));
assert!(
ty.is_some(),
"$self inside `around` body should have an inferred type; got None.\
\nAll TCs: {:?}",
fa.refs
.iter()
.filter(|r| r.target_name == "$self")
.collect::<Vec<_>>(),
);
match ty.unwrap() {
InferredType::ClassName(name) => assert_eq!(name, "Dog", "$self should be Dog"),
InferredType::FirstParam { package } => {
assert_eq!(package, "Dog", "$self FirstParam should be Dog")
}
other => panic!("expected ClassName/FirstParam for $self, got {:?}", other),
}
}
#[test]
fn before_modifier_first_param_typed_as_class() {
let src = r#"
package Cat;
use Moo;
sub meow { "mrrp" }
before meow => sub {
my ($self) = @_;
$self->hiss();
};
sub hiss { "ssss" }
"#;
let fa = build_fa(src);
let ty = fa.inferred_type_via_bag("$self", Point::new(8, 4));
assert!(
ty.is_some(),
"$self inside `before` body should have an inferred type",
);
match ty.unwrap() {
InferredType::ClassName(name) => assert_eq!(name, "Cat"),
InferredType::FirstParam { package } => assert_eq!(package, "Cat"),
other => panic!("expected ClassName/FirstParam, got {:?}", other),
}
}
#[test]
fn sub_exporter_use_setup_records_exports() {
let fa = build_fa(
"package My::Exporter;\n\
use Sub::Exporter -setup => { exports => [qw/alpha beta/] };\n\
sub alpha { 1 }\n\
sub beta { 2 }\n\
1;\n",
);
assert!(fa.export_ok.contains(&"alpha".to_string()),
"export_ok should contain alpha; got {:?}", fa.export_ok);
assert!(fa.export_ok.contains(&"beta".to_string()),
"export_ok should contain beta; got {:?}", fa.export_ok);
}
#[test]
fn sub_exporter_setup_exporter_call_records_exports() {
let fa = build_fa(
"package My::Exporter;\n\
use Sub::Exporter ();\n\
Sub::Exporter::setup_exporter({ exports => [qw/gamma/] });\n\
sub gamma { 3 }\n\
1;\n",
);
assert!(fa.export_ok.contains(&"gamma".to_string()),
"export_ok should contain gamma; got {:?}", fa.export_ok);
}
#[test]
fn sub_exporter_generator_hashref_records_keys() {
let fa = build_fa(
"package My::Exporter;\n\
use Sub::Exporter -setup => { exports => { delta => \\&_gen_delta } };\n\
sub _gen_delta { sub { 4 } }\n\
1;\n",
);
assert!(fa.export_ok.contains(&"delta".to_string()),
"export_ok should contain generator name delta; got {:?}", fa.export_ok);
}
#[test]
fn sub_exporter_exports_plain_comma_members_join_surface() {
for exports in [
"[ 'foo', bar => \\&_gen ]",
"[ 'foo', 'bar', \\&_gen ]",
] {
let src = format!(
"package My::Exp;\n\
use Sub::Exporter -setup => {{ exports => {exports} }};\n\
sub foo {{}}\n\
sub bar {{}}\n\
sub _gen {{}}\n\
1;\n",
);
let fa = build_fa(&src);
for name in ["foo", "bar"] {
assert!(
fa.exports_name(name),
"exports `{exports}`: `{name}` must join the surface; export_ok={:?}",
fa.export_ok,
);
}
}
}
#[test]
fn sub_exporter_setup_array_members_and_groups_join_surface() {
let fa = build_fa(
"package My::Exp;\n\
use Sub::Exporter -setup => {\n\
exports => [ qw(foo bar), baz => \\&_build_baz ],\n\
groups => { default => [qw(foo)], extra => [qw(bar baz)] },\n\
};\n\
sub foo {}\n\
sub bar {}\n\
sub _build_baz {}\n\
1;\n",
);
for name in ["foo", "bar", "baz"] {
assert!(
fa.exports_name(name),
"exports_name({name}) should be true; export_ok={:?}",
fa.export_ok
);
}
assert!(
!fa.export_ok.contains(&"default".to_string())
&& !fa.export_ok.contains(&"extra".to_string()),
"group selector keys must not join the surface; got {:?}",
fa.export_ok
);
}
#[test]
fn sub_exporter_member_refs_local_subs() {
let fa = build_fa(
"package My::Exp;\n\
use Sub::Exporter -setup => {\n\
exports => [ qw(foo bar), baz => \\&_build_baz ],\n\
groups => { extra => [qw(bar baz)] },\n\
};\n\
sub foo {}\n\
sub bar {}\n\
sub baz {}\n\
1;\n",
);
let count = |name: &str| {
fa.refs
.iter()
.filter(|r| {
r.target_name == name
&& matches!(
&r.kind,
RefKind::FunctionCall { resolved_package }
if resolved_package.as_deref() == Some("My::Exp")
)
})
.count()
};
assert_eq!(count("foo"), 1, "foo member ref; got refs {:?}", fa.refs.iter().filter(|r| r.target_name=="foo").collect::<Vec<_>>());
assert_eq!(count("bar"), 2, "bar in exports + group extra");
assert_eq!(count("baz"), 2, "baz in exports + group extra");
}
#[test]
fn sub_exporter_member_goto_def_and_references() {
use crate::file_store::FileStore;
use crate::resolve::{refs_to, RoleMask, TargetKind, TargetRef};
use std::path::PathBuf;
let src = "package My::Exp;\n\
use Sub::Exporter -setup => { exports => [ qw(foo bar) ] };\n\
sub foo {}\n\
sub bar {}\n\
1;\n";
let fa = build_fa(src);
let foo_def_span = fa
.symbols
.iter()
.find(|s| s.name == "foo")
.map(|s| s.selection_span)
.expect("foo sub symbol");
let export_ref = fa
.refs
.iter()
.find(|r| {
r.target_name == "foo"
&& matches!(&r.kind, RefKind::FunctionCall { .. })
&& r.span != foo_def_span
})
.expect("an export-list FunctionCall ref for foo");
let r = fa
.ref_at(export_ref.span.start)
.expect("ref_at the export member token");
assert_eq!(r.target_name, "foo");
let store = FileStore::new();
let path = PathBuf::from("/tmp/qa_sub_exporter.pm");
store.insert_workspace(path.clone(), fa);
let results = refs_to(
&store,
None,
&TargetRef {
name: "foo".to_string(),
kind: TargetKind::Sub {
package: Some("My::Exp".to_string()),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert_eq!(
results.len(),
2,
"references on foo should list the def and its exports-list mention; got {results:?}"
);
}
#[test]
fn sub_exporter_setup_exporter_call_with_groups() {
let fa = build_fa(
"package My::Exp;\n\
use Sub::Exporter ();\n\
Sub::Exporter::setup_exporter({\n\
exports => [qw/gamma delta/],\n\
groups => { all => [qw/gamma delta/] },\n\
});\n\
sub gamma {}\n\
sub delta {}\n\
1;\n",
);
assert!(fa.exports_name("gamma") && fa.exports_name("delta"),
"setup_exporter exports should join surface; got {:?}", fa.export_ok);
assert!(!fa.export_ok.contains(&"all".to_string()),
"group selector `all` must not join the surface");
}
#[test]
fn non_sub_exporter_use_unaffected() {
let fa = build_fa(
"package My::Thing;\n\
use Some::Other -setup => { exports => [qw/leak/] };\n\
sub leak {}\n\
1;\n",
);
assert!(!fa.export_ok.contains(&"leak".to_string()),
"non-Sub::Exporter use must not record exports; got {:?}", fa.export_ok);
let leak_refs = fa.refs.iter().filter(|r| r.target_name == "leak"
&& matches!(&r.kind, RefKind::FunctionCall { .. })).count();
assert_eq!(leak_refs, 0, "no member ref for an unrelated use's pseudo-export");
}
#[test]
fn moose_exporter_setup_import_methods_records_exports() {
let fa = build_fa(
"package My::Sugar;\n\
use Moose::Exporter;\n\
Moose::Exporter->setup_import_methods(\n\
with_meta => ['has_table'],\n\
as_is => [qw/col belongs_to/],\n\
);\n\
sub has_table { }\n\
sub col { }\n\
sub belongs_to { }\n\
1;\n",
);
for name in ["has_table", "col", "belongs_to"] {
assert!(fa.export_ok.contains(&name.to_string()),
"export_ok should contain {}; got {:?}", name, fa.export_ok);
}
}
#[test]
fn type_library_add_type_records_named_export() {
let fa = build_fa(
"package My::Types;\n\
use Type::Library -base;\n\
__PACKAGE__->add_type({ name => 'PositiveInt' });\n\
__PACKAGE__->add_type({ name => 'Email' });\n\
1;\n",
);
assert!(fa.export_ok.contains(&"PositiveInt".to_string()),
"export_ok should contain PositiveInt; got {:?}", fa.export_ok);
assert!(fa.export_ok.contains(&"Email".to_string()),
"export_ok should contain Email; got {:?}", fa.export_ok);
}
#[test]
fn non_exporter_setup_does_not_pollute_exports() {
let fa = build_fa(
"package My::Thing;\n\
My::Thing->configure({ name => 'nope', exports => [qw/leak/] });\n\
1;\n",
);
assert!(!fa.export_ok.contains(&"leak".to_string()),
"unrelated method call must not record exports; got {:?}", fa.export_ok);
assert!(!fa.export_ok.contains(&"nope".to_string()));
}
#[test]
fn setup_verb_name_without_exporter_use_does_not_pollute_exports() {
let fa = build_fa(
"package My::Registry;\n\
my $schema = build_schema();\n\
$schema->add_type({ name => 'Widget' });\n\
__PACKAGE__->setup_import_methods(as_is => [qw/leak/]);\n\
1;\n",
);
assert!(!fa.export_ok.contains(&"Widget".to_string()),
"add_type without Type::Library use must not record exports; got {:?}", fa.export_ok);
assert!(!fa.export_ok.contains(&"leak".to_string()),
"setup_import_methods without Moose::Exporter use must not record exports; got {:?}", fa.export_ok);
}
#[test]
fn export_ok_array_assignment_unions_with_runtime_exports() {
let fa = build_fa(
"package My::Mixed;\n\
use Exporter::Extensible;\n\
sub attr_export :Export { }\n\
our @EXPORT_OK = ('array_export');\n\
sub array_export { }\n\
1;\n",
);
assert!(fa.export_ok.contains(&"attr_export".to_string()),
"runtime :Export attr survives the array assignment; got {:?}", fa.export_ok);
assert!(fa.export_ok.contains(&"array_export".to_string()),
"array-assigned name recorded; got {:?}", fa.export_ok);
}
#[test]
fn test_moo_has_predicate_string() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'name' => (is => 'ro', predicate => 'has_name');
",
);
let pred: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "has_name" && s.kind == SymKind::Method)
.collect();
assert_eq!(pred.len(), 1, "explicit predicate string synthesizes method");
if let SymbolDetail::Sub { ref params, is_method, .. } = pred[0].detail {
assert!(is_method);
assert!(params.is_empty(), "predicate takes no args");
}
}
#[test]
fn test_moo_has_predicate_shorthand() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'email' => (is => 'ro', predicate => 1);
",
);
let pred: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "has_email" && s.kind == SymKind::Method)
.collect();
assert_eq!(pred.len(), 1, "predicate => 1 derives has_<attr>");
}
#[test]
fn test_moo_has_predicate_private_attr_shorthand() {
let fa = build_fa(
"
package Foo;
use Moo;
has '_token' => (is => 'ro', predicate => 1);
",
);
let pred: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "_has_token" && s.kind == SymKind::Method)
.collect();
assert_eq!(pred.len(), 1, "predicate => 1 on _attr derives _has_<rest>");
}
#[test]
fn test_moo_has_clearer_string() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'cache' => (is => 'rw', clearer => 'clear_cache');
",
);
let clearer: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "clear_cache" && s.kind == SymKind::Method)
.collect();
assert_eq!(clearer.len(), 1, "explicit clearer string synthesizes method");
}
#[test]
fn test_moo_has_clearer_shorthand() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'items' => (is => 'rw', clearer => 1);
",
);
let clearer: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "clear_items" && s.kind == SymKind::Method)
.collect();
assert_eq!(clearer.len(), 1, "clearer => 1 derives clear_<attr>");
}
#[test]
fn test_moo_has_clearer_private_shorthand() {
let fa = build_fa(
"
package Foo;
use Moo;
has '_session' => (is => 'rw', clearer => 1);
",
);
let clearer: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "_clear_session" && s.kind == SymKind::Method)
.collect();
assert_eq!(clearer.len(), 1, "clearer => 1 on _attr derives _clear_<rest>");
}
#[test]
fn test_moo_has_writer_option() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'color' => (is => 'ro', writer => 'set_color');
",
);
let writer: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "set_color" && s.kind == SymKind::Method)
.collect();
assert_eq!(writer.len(), 1, "writer option synthesizes method");
if let SymbolDetail::Sub { ref params, .. } = writer[0].detail {
assert_eq!(params.len(), 1, "writer has one param");
}
}
#[test]
fn test_moo_has_reader_option() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'size' => (is => 'ro', reader => 'get_size');
",
);
let reader: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "get_size" && s.kind == SymKind::Method)
.collect();
assert_eq!(reader.len(), 1, "reader option synthesizes method");
if let SymbolDetail::Sub { ref params, is_method, .. } = reader[0].detail {
assert!(is_method);
assert!(params.is_empty(), "reader takes no args");
}
}
#[test]
fn test_moo_has_builder_shorthand() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'items' => (is => 'ro', builder => 1);
sub _build_items { return [] }
",
);
let builder_sym: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "_build_items" && s.kind == SymKind::Method)
.collect();
assert!(
!builder_sym.is_empty(),
"_build_items must exist (synthesized or user-written)"
);
}
#[test]
fn test_moo_has_builder_string() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'cache' => (is => 'lazy', builder => '_make_cache');
",
);
let builder_sym: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "_make_cache" && s.kind == SymKind::Method)
.collect();
assert_eq!(builder_sym.len(), 1, "explicit builder name synthesizes method");
}
#[test]
fn test_moo_has_auxiliaries_without_is() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'flag' => (predicate => 'has_flag', clearer => 'clear_flag');
",
);
let pred: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "has_flag" && s.kind == SymKind::Method)
.collect();
let clearer: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "clear_flag" && s.kind == SymKind::Method)
.collect();
assert_eq!(pred.len(), 1, "predicate synthesized without is");
assert_eq!(clearer.len(), 1, "clearer synthesized without is");
}
#[test]
fn test_moo_has_auxiliaries_with_bare() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'secret' => (is => 'bare', predicate => 'has_secret');
",
);
let accessors: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "secret" && s.kind == SymKind::Method)
.collect();
assert_eq!(accessors.len(), 0, "bare suppresses default accessor");
let pred: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "has_secret" && s.kind == SymKind::Method)
.collect();
assert_eq!(pred.len(), 1, "predicate synthesized even with is => bare");
}
#[test]
fn test_moo_has_handles_hashref() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'logger' => (is => 'ro', isa => 'Log::Any', handles => { log => 'debug', warning => 'warn' });
",
);
let log_sym: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "log" && s.kind == SymKind::Method)
.collect();
let warning_sym: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "warning" && s.kind == SymKind::Method)
.collect();
assert_eq!(log_sym.len(), 1, "handles hashref synthesizes 'log' method");
assert_eq!(warning_sym.len(), 1, "handles hashref synthesizes 'warning' method");
}
#[test]
fn test_moose_has_handles_arrayref() {
let fa = build_fa(
"
package Foo;
use Moose;
has 'db' => (is => 'ro', isa => 'DBI::db', handles => [qw(prepare execute)]);
",
);
let prepare: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "prepare" && s.kind == SymKind::Method)
.collect();
let execute: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "execute" && s.kind == SymKind::Method)
.collect();
assert_eq!(prepare.len(), 1, "handles arrayref synthesizes 'prepare'");
assert_eq!(execute.len(), 1, "handles arrayref synthesizes 'execute'");
}
#[test]
fn test_moo_has_handles_instanceof_edges_return_type() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'logger' => (is => 'ro', isa => \"InstanceOf['Log::Any']\", handles => { log => 'debug' });
",
);
let log_sym: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "log" && s.kind == SymKind::Method)
.collect();
assert_eq!(log_sym.len(), 1, "handles delegation synthesizes method");
match fa.return_type_provenance(log_sym[0].id) {
TypeProvenance::FrameworkSynthesis { framework, reason } => {
assert!(
framework == "Moo" || framework == "Moose",
"provenance framework should be Moo/Moose, got {}",
framework
);
assert!(reason.contains("handles"), "reason should mention handles");
}
TypeProvenance::Inferred => {
}
other => panic!("unexpected provenance: {other:?}"),
}
}
#[test]
fn test_moo_has_no_phantom_method_from_data_options() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'name' => (is => 'ro', isa => 'Str', default => 'bob', lazy => 1, required => 1);
",
);
for phantom in ["ro", "rw", "lazy", "bare", "Str", "bob", "1"] {
let hits: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == phantom && s.kind == SymKind::Method)
.collect();
assert!(
hits.is_empty(),
"option value `{phantom}` must not become a method, got {} symbol(s)",
hits.len()
);
}
let name: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.collect();
assert_eq!(name.len(), 1, "the real `name` accessor must still synthesize");
}
#[test]
fn test_moose_has_lazy_build_expands_trio() {
let fa = build_fa(
"
package Foo;
use Moose;
has 'cache' => (is => 'ro', lazy_build => 1);
",
);
for (name, what) in [
("_build_cache", "builder"),
("clear_cache", "clearer"),
("has_cache", "predicate"),
] {
let hits: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == name && s.kind == SymKind::Method)
.collect();
assert_eq!(hits.len(), 1, "lazy_build must synthesize the {what} `{name}`");
}
for phantom in ["lazy_build", "ro", "1"] {
assert!(
!fa.symbols.iter().any(|s| s.name == phantom && s.kind == SymKind::Method),
"`{phantom}` must not become a method"
);
}
}
#[test]
fn test_moo_has_accessor_selection_span_is_attr_name() {
let fa = build_fa("package Foo;\nuse Moo;\nhas name => (\n is => 'ro',\n);\n");
let name = fa
.symbols
.iter()
.find(|s| s.name == "name" && s.kind == SymKind::Method)
.expect("name accessor");
assert_eq!(
name.selection_span.start.row, 2,
"selection_span must point at the `has name` line, not the options line"
);
}
#[test]
fn test_dancer2_plugin_has_synthesizes_accessor() {
let fa = build_fa(
"
package My::Plugin;
use Dancer2::Plugin;
has my_setting => (is => 'ro', isa => 'Str');
",
);
let acc: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "my_setting" && s.kind == SymKind::Method)
.collect();
assert_eq!(acc.len(), 1, "Dancer2::Plugin `has` must synthesize the accessor");
for phantom in ["ro", "Str"] {
assert!(
!fa.symbols.iter().any(|s| s.name == phantom && s.kind == SymKind::Method),
"`{phantom}` must not become a method under Dancer2::Plugin either"
);
}
}
#[test]
fn use_constant_scalar_form_registers_sub_symbol() {
let fa = build_fa("use constant DEBUG => 1;\nmy $y = DEBUG && 2;\n");
assert!(
fa.symbols.iter().any(|s| s.name == "DEBUG" && s.kind == SymKind::Sub),
"DEBUG must be registered as a Sub symbol; got: {:?}",
fa.symbols.iter().map(|s| (&s.name, s.kind)).collect::<Vec<_>>(),
);
}
#[test]
fn use_constant_block_form_registers_each_name() {
let fa = build_fa("use constant { A => 1, B => 2, C => 3 };\n");
for n in ["A", "B", "C"] {
assert!(
fa.symbols.iter().any(|s| s.name == n && s.kind == SymKind::Sub),
"block-form constant `{n}` must be a Sub symbol; got: {:?}",
fa.symbols.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
);
}
}
#[test]
fn use_constant_block_plain_comma_keys_register() {
let fa = build_fa("use constant { 'GAMMA', 3, 'DELTA', 4, A => 1, B => 2 };\n");
for n in ["GAMMA", "DELTA", "A", "B"] {
assert!(
fa.symbols.iter().any(|s| s.name == n && s.kind == SymKind::Sub),
"plain-comma block constant `{n}` must be a Sub symbol; got: {:?}",
fa.symbols.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
);
}
}
#[test]
fn use_constant_block_plain_comma_goto_def_and_references() {
use crate::file_store::FileStore;
use crate::resolve::{refs_to, RoleMask, TargetKind, TargetRef};
use std::path::PathBuf;
let src = r#"package Foo;
use constant { 'GAMMA', 3, DELTA => 4 };
sub go {
my $a = GAMMA;
return DELTA;
}
"#;
let fa = build_fa(src);
for n in ["GAMMA", "DELTA"] {
assert!(
fa.refs.iter().any(|r| {
r.target_name == n
&& matches!(&r.kind, RefKind::FunctionCall { resolved_package }
if resolved_package.as_deref() == Some("Foo"))
}),
"usage of plain/fat-comma constant `{n}` must get a FunctionCall ref; refs: {:?}",
fa.refs.iter().filter(|r| r.target_name == n).collect::<Vec<_>>(),
);
}
let store = FileStore::new();
store.insert_workspace(PathBuf::from("/tmp/qa_const_plain.pm"), fa);
for name in ["GAMMA", "DELTA"] {
let results = refs_to(
&store,
None,
&TargetRef {
name: name.to_string(),
kind: TargetKind::Sub { package: Some("Foo".to_string()) },
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert_eq!(
results.len(), 2,
"references on `{name}` should list its def + 1 usage; got {results:?}",
);
}
}
#[test]
fn use_constant_block_does_not_mispair_values_as_keys() {
let fa = build_fa("use constant { A => 1, 'B', 2 };\n");
let const_subs: Vec<&str> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Sub)
.map(|s| s.name.as_str())
.collect();
assert!(
const_subs.contains(&"A") && const_subs.contains(&"B"),
"keys A and B must register; got {:?}",
const_subs,
);
assert!(
!const_subs.iter().any(|n| *n == "1" || *n == "2"),
"value tokens must never register as constant names; got {:?}",
const_subs,
);
}
#[test]
fn use_constant_between_subs_at_file_scope() {
let src = "sub one {}\nuse constant MID => 'x';\nsub two {}\n";
let fa = build_fa(src);
assert!(
fa.symbols.iter().any(|s| s.name == "MID" && s.kind == SymKind::Sub),
"MID declared between subs must register as a Sub symbol",
);
}
#[test]
fn multiple_name_form_use_constants_each_register() {
let src = r#"package Foo;
use constant ALPHA => 1;
use constant BETA => 2;
use constant GAMMA => 3;
sub go {
my $a = ALPHA;
my $b = BETA;
my $c = GAMMA;
}
"#;
let fa = build_fa(src);
for n in ["ALPHA", "BETA", "GAMMA"] {
assert!(
fa.symbols.iter().any(|s| s.name == n && s.kind == SymKind::Sub),
"every separate NAME-form constant must register a Sub symbol; `{n}` missing. got: {:?}",
fa.symbols.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
);
assert!(
fa.refs.iter().any(|r| {
r.target_name == n
&& matches!(
&r.kind,
RefKind::FunctionCall { resolved_package } if resolved_package.as_deref() == Some("Foo")
)
}),
"usage of `{n}` must get a FunctionCall ref to its def; refs for {n}: {:?}",
fa.refs.iter().filter(|r| r.target_name == n).collect::<Vec<_>>(),
);
}
}
#[test]
fn multiple_name_form_use_constants_goto_def_and_references() {
use crate::file_store::FileStore;
use crate::resolve::{refs_to, RoleMask, TargetKind, TargetRef};
use std::path::PathBuf;
let src = r#"package Foo;
use constant ALPHA => 1;
use constant BETA => 2;
use constant GAMMA => 3;
sub go {
my $a = ALPHA;
my $b = BETA;
return GAMMA;
}
"#;
let fa = build_fa(src);
let store = FileStore::new();
store.insert_workspace(PathBuf::from("/tmp/qa_multi_const.pm"), fa);
for name in ["ALPHA", "BETA", "GAMMA"] {
let results = refs_to(
&store,
None,
&TargetRef {
name: name.to_string(),
kind: TargetKind::Sub { package: Some("Foo".to_string()) },
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert_eq!(
results.len(),
2,
"references on `{name}` should list its def + 1 usage; got {results:?}"
);
}
}
#[test]
fn indirect_object_filehandle_not_a_function_ref() {
for src in [
"print STDERR \"hi\";\n",
"printf STDERR \"%s\", $x;\n",
"say STDOUT \"hi\";\n",
] {
let fa = build_fa(src);
let fh = src.split_whitespace().nth(1).unwrap().trim_matches(|c| c == '"');
assert!(
!fa.refs.iter().any(|r|
matches!(r.kind, RefKind::FunctionCall { .. }) && r.target_name == fh),
"filehandle `{fh}` must not be a FunctionCall ref for `{}`; refs: {:?}",
src.trim(),
fa.refs.iter().filter(|r| matches!(r.kind, RefKind::FunctionCall { .. }))
.map(|r| r.target_name.clone()).collect::<Vec<_>>(),
);
}
}
#[test]
fn print_with_paren_call_still_emits_function_ref() {
let fa = build_fa("print foo(\"x\");\n");
assert!(
fa.refs.iter().any(|r|
matches!(r.kind, RefKind::FunctionCall { .. }) && r.target_name == "foo"),
"parenthesized call `foo(...)` inside print must keep its FunctionCall ref",
);
}
#[test]
fn shift_invocant_typed_like_at_underscore() {
let at_point = tree_sitter::Point { row: 2, column: 28 };
let is_class_w = |fa: &FileAnalysis| {
matches!(
fa.inferred_type_via_bag("$self", at_point),
Some(InferredType::ClassName(ref c)) if c == "W"
)
};
let shift_fa =
build_fa("package W;\nsub go { 1 }\nsub f { my $self = shift; $self->go(); }\n");
let at_fa =
build_fa("package W;\nsub go { 1 }\nsub f { my ($self) = @_; $self->go(); }\n");
assert!(is_class_w(&shift_fa), "shift-extracted $self must type as ClassName(W)");
assert!(is_class_w(&at_fa), "@_-extracted $self must type as ClassName(W)");
}
#[test]
fn test_moo_role_requires_is_framework_import() {
let fa = build_fa(
"
package My::Role;
use Moo::Role;
requires 'must_implement';
",
);
assert!(
fa.framework_imports.contains("requires"),
"Moo::Role exports `requires` — should register as a framework import"
);
}
#[test]
fn test_moose_role_requires_is_framework_import() {
let fa = build_fa(
"
package My::Role;
use Moose::Role;
requires 'foo';
",
);
assert!(fa.framework_imports.contains("requires"));
}
#[test]
fn test_role_tiny_behaves_like_moo_role() {
let fa = build_fa(
"
package My::Role;
use Role::Tiny;
requires 'bar';
with 'Other::Role';
",
);
assert!(
fa.framework_imports.contains("requires"),
"Role::Tiny exports `requires`"
);
assert!(
fa.framework_imports.contains("with"),
"Role::Tiny exports `with`"
);
}
#[test]
fn test_role_tiny_with_behaves_like_moo_role() {
let fa = build_fa(
"
package My::Class;
use Role::Tiny::With;
with 'Some::Role';
",
);
assert!(fa.framework_imports.contains("with"));
}
#[test]
fn test_dbic_two_level_ancestry_synthesizes_columns() {
let fa = build_fa(
"
package My::Schema::BaseResult;
use base 'DBIx::Class::Core';
package My::Schema::Result::User;
use base 'My::Schema::BaseResult';
__PACKAGE__->add_columns(qw/id username/);
",
);
let id: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "id" && s.kind == SymKind::Method)
.collect();
let username: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "username" && s.kind == SymKind::Method)
.collect();
assert_eq!(id.len(), 1, "2-level DBIC inheritance should synthesize `id`");
assert_eq!(username.len(), 1, "and `username`");
}
#[test]
fn test_mk_group_accessors_synthesizes_methods() {
let fa = build_fa(
"
package My::Thing;
use base 'Class::Accessor::Grouped';
__PACKAGE__->mk_group_accessors('simple', qw/alpha beta/);
__PACKAGE__->mk_group_ro_accessors('inflated', 'gamma', 'delta');
",
);
for name in ["alpha", "beta", "gamma", "delta"] {
let hits: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == name && s.kind == SymKind::Method)
.collect();
assert_eq!(hits.len(), 1, "mk_group accessor `{name}` should be synthesized");
}
assert!(
!fa.symbols.iter().any(|s| s.name == "simple" && s.kind == SymKind::Method),
"the leading group name must not become an accessor"
);
}
#[test]
fn test_mk_classdata_synthesizes_method() {
let fa = build_fa(
"
package My::Thing;
use base 'Class::Accessor::Grouped';
__PACKAGE__->mk_classdata('config');
",
);
let hits: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "config" && s.kind == SymKind::Method)
.collect();
assert_eq!(hits.len(), 1, "mk_classdata should synthesize the named accessor");
}
#[test]
fn test_use_module_dash_base_registers_parent_and_mojo_behavior() {
let fa = build_fa(
"
package My::Emitter;
use Mojo::EventEmitter -base;
has 'value';
",
);
assert!(
fa.package_parents
.get("My::Emitter")
.map(|v| v.iter().any(|p| p == "Mojo::EventEmitter"))
.unwrap_or(false),
"`use X -base` should register X as a parent"
);
let methods: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "value" && s.kind == SymKind::Method)
.collect();
assert_eq!(methods.len(), 2, "`-base` pulls Mojo::Base has-synthesis");
}
#[test]
fn test_mojo_base_dash_base_carries_mojo_base_as_parent() {
let fa = build_fa(
"
package My::Class;
use Mojo::Base -base;
has 'x';
",
);
assert!(
fa.package_parents
.get("My::Class")
.map(|v| v.iter().any(|p| p == "Mojo::Base"))
.unwrap_or(false),
"`Mojo::Base -base` should carry Mojo::Base itself as a parent so tap/attr/new resolve"
);
}
#[test]
fn test_moo_has_comma_form_synthesizes_accessor() {
let fa = build_fa(
"
package Foo;
use Moo;
has 'name', is => 'ro', default => sub { 1 };
has age => (is => 'rw');
",
);
let name_acc: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "name" && s.kind == SymKind::Method)
.collect();
assert_eq!(
name_acc.len(),
1,
"comma-form `has 'name', is => 'ro'` should synthesize a `name` accessor"
);
let age_acc: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "age" && s.kind == SymKind::Method)
.collect();
assert_eq!(age_acc.len(), 2, "fat-arrow rw form still synthesizes getter+setter");
assert!(
!fa.symbols.iter().any(|s| (s.name == "is" || s.name == "ro") && s.kind == SymKind::Method),
"option keywords/values must not mint phantom methods in comma form"
);
}
fn has_sub(fa: &FileAnalysis, name: &str) -> bool {
fa.symbols
.iter()
.any(|s| s.name == name && s.kind == SymKind::Sub)
}
#[test]
fn glob_static_name_sub() {
let fa = build_fa("*greet = sub { return 'hi' };\n");
assert!(has_sub(&fa, "greet"), "static *name = sub {{...}} must mint a Sub symbol");
}
#[test]
fn glob_alias_to_existing_sub() {
let fa = build_fa("*alias = \\&Other::func;\n*local_alias = \\ℜ\n");
assert!(has_sub(&fa, "alias"), "*name = \\&Other::func glob alias must mint a Sub symbol");
assert!(has_sub(&fa, "local_alias"), "*name = \\&func glob alias must mint a Sub symbol");
}
#[test]
fn glob_qualified_name_installs_tail() {
let fa = build_fa("*Other::foo = sub { 1 };\n");
assert!(has_sub(&fa, "foo"), "qualified glob must register the unqualified tail");
assert!(!has_sub(&fa, "Other::foo"), "must not register the fully-qualified string as a name");
}
#[test]
fn glob_loop_over_qw() {
let src = "for my $m (qw/red green blue/) {\n no strict 'refs';\n *$m = sub { 1 };\n}\n";
let fa = build_fa(src);
for name in ["red", "green", "blue"] {
assert!(has_sub(&fa, name), "loop-installed glob `{name}` must mint a Sub symbol");
}
}
#[test]
fn glob_begin_constant_style() {
let src = "BEGIN {\n *_FORCE_WRITABLE = sub () { 1 };\n}\n";
let fa = build_fa(src);
assert!(
has_sub(&fa, "_FORCE_WRITABLE"),
"constant-style glob sub in BEGIN must mint a Sub symbol"
);
}
#[test]
fn glob_literal_block_name() {
let fa = build_fa("*{ 'is_thing' } = sub { 1 };\n");
assert!(has_sub(&fa, "is_thing"), "`*{{ 'literal' }}` glob must mint a Sub symbol");
}
#[test]
fn glob_scalar_rhs_coderef() {
let fa = build_fa("*handler = $coderef;\n");
assert!(has_sub(&fa, "handler"), "*name = $coderef must mint a Sub symbol");
}
#[test]
fn glob_dynamic_name_skipped() {
let fa = build_fa("*{ $runtime } = sub { 1 };\n");
assert!(
!fa.symbols.iter().any(|s| s.kind == SymKind::Sub && s.name != "(anon)"),
"fully-dynamic glob name must be skipped, not guessed"
);
}
#[test]
fn glob_unfoldable_concat_skipped() {
let fa = build_fa("*{ 'is_' . $type } = sub { 1 };\n");
assert!(
!fa.symbols.iter().any(|s| s.kind == SymKind::Sub && s.name.starts_with("is_")),
"unfoldable concat name must be skipped, not guessed with a partial prefix"
);
}
#[test]
fn glob_concat_with_loop_var_foldable() {
let src =
"for my $kind (qw/foo bar/) {\n *{ 'is_' . $kind } = sub { 1 };\n}\n";
let fa = build_fa(src);
assert!(has_sub(&fa, "is_foo"), "foldable concat over loop var must mint is_foo");
assert!(has_sub(&fa, "is_bar"), "foldable concat over loop var must mint is_bar");
}
#[test]
fn normal_assignment_unaffected() {
let fa = build_fa("my $x = 42;\nmy $cb = sub { 1 };\n");
assert!(
!fa.symbols.iter().any(|s| s.name == "x" && s.kind == SymKind::Sub),
"plain scalar assignment must not mint a Sub symbol"
);
assert!(
!fa.symbols.iter().any(|s| s.name == "cb" && s.kind == SymKind::Sub),
"lexical `my $cb = sub {{...}}` must not be treated as a glob install"
);
}
#[test]
fn glob_loop_over_local_qw_sub() {
let src = "\
foreach my $tag (_all_html_tags()) {
no strict 'refs';
*$tag = sub { 1 };
}
sub _all_html_tags { return qw(div span br); }
";
let fa = build_fa(src);
for name in ["div", "span", "br"] {
assert!(has_sub(&fa, name), "loop over local qw-returning sub must mint `{name}`");
}
}
#[test]
fn glob_loop_over_local_list_sub() {
let src = "\
for my $m (_names()) {
*$m = sub { 1 };
}
sub _names { ('alpha', 'beta') }
";
let fa = build_fa(src);
assert!(has_sub(&fa, "alpha"), "loop over local list-returning sub must mint alpha");
assert!(has_sub(&fa, "beta"), "loop over local list-returning sub must mint beta");
}
#[test]
fn glob_loop_over_nonliteral_local_sub_skipped() {
let src = "\
for my $m (_dynamic()) {
*$m = sub { 1 };
}
sub _dynamic { return map { lc } @ARGV; }
";
let fa = build_fa(src);
assert!(
!fa.symbols.iter().any(|s| s.kind == SymKind::Sub && s.name != "(anon)" && s.name != "_dynamic"),
"non-literal local sub return must not synthesize glob names"
);
}
#[test]
fn glob_loop_over_unknown_sub_skipped() {
let src = "\
for my $m (Some::Other::tags()) {
*$m = sub { 1 };
}
";
let fa = build_fa(src);
assert!(
!fa.symbols.iter().any(|s| s.kind == SymKind::Sub && s.name != "(anon)"),
"unresolvable loop-source sub must not synthesize glob names"
);
}
#[test]
fn glob_loop_can_rhs_synthesizes_under_current_pkg() {
let src = "\
for my $sub (qw/foo bar/) {
*{ 'DateTime::' . $sub } = __PACKAGE__->can($sub);
}
";
let fa = build_fa(src);
assert!(has_sub(&fa, "foo"), "->can RHS over loop var must mint foo (tail)");
assert!(has_sub(&fa, "bar"), "->can RHS over loop var must mint bar (tail)");
assert!(!has_sub(&fa, "DateTime::foo"), "must not register the fully-qualified name");
}
#[test]
fn glob_can_on_package_invocant() {
let fa = build_fa("*alias = Foo::Bar->can('helper');\n");
assert!(has_sub(&fa, "alias"), "*name = Pkg->can(...) must mint a Sub symbol");
}
#[test]
fn glob_non_can_method_rhs_skipped() {
let fa = build_fa("*thing = $obj->build_something();\n");
assert!(!has_sub(&fa, "thing"), "non-can method RHS must not mint a Sub symbol");
}
fn count_method(fa: &FileAnalysis, name: &str) -> usize {
fa.symbols.iter().filter(|s| s.name == name && s.kind == SymKind::Method).count()
}
#[test]
fn mk_classdata_postfix_for_qw() {
let fa = build_fa(
"\
package My::App;
use base 'Class::Accessor::Grouped';
__PACKAGE__->mk_classdata($_) for qw/setup_finished params/;
",
);
assert_eq!(count_method(&fa, "setup_finished"), 1, "loop mk_classdata must mint setup_finished once");
assert_eq!(count_method(&fa, "params"), 1, "loop mk_classdata must mint params once");
}
#[test]
fn mk_classdata_postfix_for_list() {
let fa = build_fa(
"\
package My::Controller;
use base 'Class::Accessor::Grouped';
mk_classdata($_) for ('action_namespace', 'path_prefix');
",
);
assert_eq!(count_method(&fa, "action_namespace"), 1, "bare-call loop must mint action_namespace");
assert_eq!(count_method(&fa, "path_prefix"), 1, "bare-call loop must mint path_prefix");
}
#[test]
fn mk_classdata_postfix_for_nonliteral_skipped() {
let fa = build_fa(
"\
package My::App;
use base 'Class::Accessor::Grouped';
__PACKAGE__->mk_classdata($_) for @dynamic_names;
",
);
assert!(
!fa.symbols.iter().any(|s| s.kind == SymKind::Method),
"non-literal loop list must not synthesize accessors"
);
}
#[test]
fn postfix_for_non_accessor_call_synthesizes_nothing() {
let fa = build_fa(
"\
package My::App;
print(\"$_\\n\") for qw/a b c/;
",
);
assert!(
!fa.symbols.iter().any(|s| s.kind == SymKind::Method),
"non-accessor postfix-for loop must not synthesize accessors"
);
}
#[test]
fn test_class_tiny_list_form_synthesizes_accessors() {
let fa = build_fa(
"
package Foo;
use Class::Tiny qw( resolvers cache );
",
);
for attr in ["resolvers", "cache"] {
let acc: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == attr && s.kind == SymKind::Method)
.collect();
assert_eq!(
acc.len(),
1,
"Class::Tiny qw list should synthesize one rw accessor for `{attr}`"
);
let key_def: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == attr && matches!(s.detail, SymbolDetail::HashKeyDef { .. }))
.collect();
assert!(
!key_def.is_empty(),
"Class::Tiny attr `{attr}` should mint a constructor HashKeyDef"
);
if let SymbolDetail::HashKeyDef { ref owner, .. } = key_def[0].detail {
assert_eq!(
owner,
&HashKeyOwner::Sub {
package: Some("Foo".to_string()),
name: "new".to_string(),
}
);
}
}
}
#[test]
fn test_class_tiny_hashref_form_synthesizes_accessors_from_keys() {
let fa = build_fa(
"
package Foo;
use Class::Tiny {
name => 'default',
builder => sub { [] },
};
",
);
for attr in ["name", "builder"] {
let acc: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == attr && s.kind == SymKind::Method)
.collect();
assert_eq!(
acc.len(),
1,
"Class::Tiny hashref key `{attr}` should synthesize an accessor"
);
}
assert!(
!fa.symbols
.iter()
.any(|s| s.name == "default" && s.kind == SymKind::Method),
"hashref default value must not mint a phantom accessor"
);
}
#[test]
fn test_class_tiny_combined_list_and_hashref() {
let fa = build_fa(
"
package Foo;
use Class::Tiny qw( ssn ), { name => undef };
",
);
for attr in ["ssn", "name"] {
assert!(
fa.symbols
.iter()
.any(|s| s.name == attr && s.kind == SymKind::Method),
"combined qw+hashref form should synthesize accessor `{attr}`"
);
}
}
#[test]
fn test_non_class_tiny_use_unaffected() {
let fa = build_fa(
"
package Foo;
use List::Util qw( max min );
",
);
assert!(
!fa.symbols
.iter()
.any(|s| (s.name == "max" || s.name == "min") && s.kind == SymKind::Method),
"non-Class::Tiny use must not synthesize accessor methods"
);
}
#[test]
fn const_usage_name_form_emits_function_call_ref() {
let src = r#"
package QA::C;
use constant MAX_RETRIES => 5;
sub retry {
my $limit = MAX_RETRIES;
return _attempt($limit, MAX_RETRIES);
}
sub _attempt { return 1 }
"#;
let fa = build_fa(src);
let usages: Vec<&Ref> = fa
.refs
.iter()
.filter(|r| {
r.target_name == "MAX_RETRIES"
&& matches!(
&r.kind,
RefKind::FunctionCall { resolved_package } if resolved_package.as_deref() == Some("QA::C")
)
})
.collect();
assert_eq!(
usages.len(),
2,
"both MAX_RETRIES usages (plain + call-arg) should ref the const def; got {:?}",
fa.refs
.iter()
.filter(|r| r.target_name == "MAX_RETRIES")
.collect::<Vec<_>>()
);
}
#[test]
fn const_usage_block_form_emits_function_call_ref() {
let src = r#"
package QA::C;
use constant {
TIMEOUT => 30,
BACKOFF => 2,
};
sub run {
my $t = TIMEOUT;
return $t + BACKOFF;
}
"#;
let fa = build_fa(src);
for name in ["TIMEOUT", "BACKOFF"] {
let n = fa
.refs
.iter()
.filter(|r| {
r.target_name == name
&& matches!(&r.kind, RefKind::FunctionCall { .. })
})
.count();
assert_eq!(n, 1, "{name} usage should ref the block-form const def");
}
}
#[test]
fn const_usage_goto_def_and_references() {
use crate::file_store::FileStore;
use crate::resolve::{refs_to, RoleMask, TargetKind, TargetRef};
use std::path::PathBuf;
let src = r#"package QA::C;
use constant MAX_RETRIES => 5;
sub retry {
my $limit = MAX_RETRIES;
return MAX_RETRIES;
}
"#;
let fa = build_fa(src);
let usage_pt = Point::new(3, 16); let r = fa
.ref_at(usage_pt)
.expect("a ref should sit on the constant usage");
assert_eq!(r.target_name, "MAX_RETRIES");
assert!(matches!(r.kind, RefKind::FunctionCall { .. }));
let store = FileStore::new();
let path = PathBuf::from("/tmp/qa_const.pm");
store.insert_workspace(path.clone(), fa);
let results = refs_to(
&store,
None,
&TargetRef {
name: "MAX_RETRIES".to_string(),
kind: TargetKind::Sub {
package: Some("QA::C".to_string()),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert_eq!(
results.len(),
3,
"references on MAX_RETRIES should list the def and both usages; got {results:?}"
);
}
#[test]
fn non_constant_bareword_gets_no_const_ref() {
let src = r#"
package QA::C;
use constant MAX_RETRIES => 5;
sub run {
my $x = SOME_OTHER;
return $x;
}
"#;
let fa = build_fa(src);
assert!(
!fa.refs
.iter()
.any(|r| r.target_name == "SOME_OTHER"),
"a non-constant bareword must not get a constant-usage ref"
);
}
#[test]
fn export_list_members_ref_local_subs() {
let src = r#"
package QA::E;
use Exporter 'import';
our @EXPORT = qw(always_on);
our @EXPORT_OK = qw(opt_a opt_b opt_c);
our %EXPORT_TAGS = (
group_one => [qw(opt_a opt_b)],
group_two => [qw(opt_c)],
);
sub always_on { 1 }
sub opt_a { 'a' }
sub opt_b { 'b' }
sub opt_c { 'c' }
"#;
let fa = build_fa(src);
let count = |name: &str| {
fa.refs
.iter()
.filter(|r| {
r.target_name == name
&& matches!(
&r.kind,
RefKind::FunctionCall { resolved_package } if resolved_package.as_deref() == Some("QA::E")
)
})
.count()
};
assert_eq!(count("always_on"), 1, "@EXPORT member should ref its sub");
assert_eq!(count("opt_a"), 2, "opt_a appears in @EXPORT_OK and a tag array");
assert_eq!(count("opt_b"), 2, "opt_b appears in @EXPORT_OK and a tag array");
assert_eq!(count("opt_c"), 2, "opt_c appears in @EXPORT_OK and a tag array");
}
#[test]
fn export_member_goto_def_and_references() {
use crate::file_store::FileStore;
use crate::resolve::{refs_to, RoleMask, TargetKind, TargetRef};
use std::path::PathBuf;
let src = r#"package QA::E;
use Exporter 'import';
our @EXPORT_OK = qw(opt_a opt_b);
sub opt_a { 'a' }
sub opt_b { 'b' }
"#;
let fa = build_fa(src);
let opt_a_def_span = fa
.symbols
.iter()
.find(|s| s.name == "opt_a")
.map(|s| s.selection_span)
.expect("opt_a sub symbol");
let export_ref = fa
.refs
.iter()
.find(|r| {
r.target_name == "opt_a"
&& matches!(&r.kind, RefKind::FunctionCall { .. })
&& r.span != opt_a_def_span
})
.expect("an export-list FunctionCall ref for opt_a");
let r = fa
.ref_at(export_ref.span.start)
.expect("ref_at the export member token");
assert_eq!(r.target_name, "opt_a");
let store = FileStore::new();
let path = PathBuf::from("/tmp/qa_export.pm");
store.insert_workspace(path.clone(), fa);
let results = refs_to(
&store,
None,
&TargetRef {
name: "opt_a".to_string(),
kind: TargetKind::Sub {
package: Some("QA::E".to_string()),
},
method_classes: Vec::new(),
},
RoleMask::EDITABLE,
);
assert_eq!(
results.len(),
2,
"references on opt_a should list the def and its @EXPORT_OK mention; got {results:?}"
);
}
#[test]
fn export_tag_name_key_gets_no_ref() {
let src = r#"
package QA::E;
use Exporter 'import';
our %EXPORT_TAGS = (
group_one => [qw(opt_a)],
);
sub opt_a { 'a' }
sub group_one { 'not a tag' }
"#;
let fa = build_fa(src);
let group_one_refs = fa
.refs
.iter()
.filter(|r| {
r.target_name == "group_one"
&& matches!(&r.kind, RefKind::FunctionCall { .. })
})
.count();
assert_eq!(
group_one_refs, 0,
"a tag-name key must not be reffed even when a same-named sub exists"
);
}
#[test]
fn qualified_export_globals_populate_surface() {
let src = r#"
package Bugzilla::Util;
@Bugzilla::Util::EXPORT = qw(trick_taint detaint_natural);
@Bugzilla::Util::EXPORT_OK = qw(opt_util);
%Bugzilla::Util::EXPORT_TAGS = (all => [qw(trick_taint opt_util)]);
sub trick_taint { 1 }
sub detaint_natural { 2 }
sub opt_util { 3 }
"#;
let fa = build_fa(src);
assert!(
fa.export.contains(&"trick_taint".to_string())
&& fa.export.contains(&"detaint_natural".to_string()),
"qualified @Pkg::EXPORT must populate the default set; got export={:?}",
fa.export,
);
assert!(
fa.export_ok.contains(&"opt_util".to_string()),
"qualified @Pkg::EXPORT_OK must populate the optional set; got export_ok={:?}",
fa.export_ok,
);
let surface = fa.export_surface();
let all = surface.tag_members("all").expect("all tag present");
assert!(
all.contains(&"trick_taint") && all.contains(&"opt_util"),
"qualified %Pkg::EXPORT_TAGS must record per-tag members; got {:?}",
all,
);
let default = surface.tag_members("DEFAULT").expect(":DEFAULT synthesized");
assert!(
default.contains(&"trick_taint") && default.contains(&"detaint_natural"),
":DEFAULT must equal @EXPORT; got {:?}",
default,
);
}
#[test]
fn export_tags_plain_comma_folds_members() {
for table in [
"( all => [qw(foo bar)] )",
"( 'all', [qw(foo bar)] )",
] {
let src = format!(
"package P;\nour %EXPORT_TAGS = {table};\nsub foo {{ 1 }}\nsub bar {{ 2 }}\n",
);
let fa = build_fa(&src);
let surface = fa.export_surface();
let all = surface
.tag_members("all")
.unwrap_or_else(|| panic!("`all` tag must fold for table `{table}`"));
assert!(
all.contains(&"foo") && all.contains(&"bar"),
"table `{table}`: :all members foo+bar must fold; got {:?}",
all,
);
assert!(
fa.export_ok.contains(&"foo".to_string()),
"table `{table}`: tag members join the export surface; got export_ok={:?}",
fa.export_ok,
);
}
}
#[test]
fn const_call_form_not_double_reffed() {
let src = r#"
package QA::C;
use constant MAX_RETRIES => 5;
sub run { return MAX_RETRIES(); }
"#;
let fa = build_fa(src);
let n = fa
.refs
.iter()
.filter(|r| {
r.target_name == "MAX_RETRIES" && matches!(&r.kind, RefKind::FunctionCall { .. })
})
.count();
assert_eq!(n, 1, "MAX_RETRIES() call must get exactly one FunctionCall ref");
}
#[test]
fn autoloader_data_section_subs_synthesized() {
let src = "package My::AL;\n\
use AutoLoader qw(AUTOLOAD);\n\
sub uses_them { want_read(); }\n\
1;\n\
__END__\n\
sub want_read { return 42 }\n\
sub get_https { do_httpx2(GET => 1, @_) }\n\
=pod\n\
junk\n\
=cut\n\
sub after_pod ($;$) { return 1 }\n";
let fa = build_fa(src);
let names: std::collections::HashSet<&str> = fa
.symbols
.iter()
.filter(|s| s.kind == SymKind::Sub)
.map(|s| s.name.as_str())
.collect();
assert!(names.contains("want_read"), "want_read must be synthesized");
assert!(names.contains("get_https"), "get_https must be synthesized");
assert!(names.contains("after_pod"), "sub after POD must be synthesized");
let want_read = fa
.symbols
.iter()
.find(|s| s.name == "want_read" && s.kind == SymKind::Sub)
.expect("want_read symbol");
assert_eq!(want_read.selection_span.start.row, 5, "want_read at file row 5");
assert_eq!(want_read.package.as_deref(), Some("My::AL"));
let def = fa.find_definition(Point::new(2, 16), None);
assert_eq!(
def.map(|s| s.start.row),
Some(5),
"goto-def on want_read() should land on the data-section sub"
);
}
#[test]
fn non_autoloader_data_section_synthesizes_nothing() {
let src = "package My::Plain;\n\
use strict;\n\
sub real_sub { 1 }\n\
1;\n\
__END__\n\
sub looks_like_a_sub { return 99 }\n\
=pod\n\
docs\n\
=cut\n\
plain documentation text\n";
let fa = build_fa(src);
assert!(
fa.symbols
.iter()
.all(|s| s.name != "looks_like_a_sub"),
"data-section subs must NOT be synthesized without AutoLoader/SelfLoader"
);
assert!(
fa.symbols.iter().any(|s| s.name == "real_sub"),
"the real pre-__END__ sub is still present"
);
}
#[test]
fn autoloader_via_use_base_enables_synthesis() {
let src = "package My::Sub;\n\
use base 'AutoLoader';\n\
1;\n\
__END__\n\
sub inherited_loader_sub { return 1 }\n";
let fa = build_fa(src);
assert!(
fa.symbols
.iter()
.any(|s| s.name == "inherited_loader_sub" && s.kind == SymKind::Sub),
"use base 'AutoLoader' must enable data-section synthesis"
);
}
#[test]
fn chained_method_call_hash_key_emits_owned_ref() {
let src = "\
package Config;
sub new { bless { host => 'localhost', port => 5432 }, shift }
package Foo;
sub new { bless {}, shift }
sub get_config { return Config->new() }
package main;
my $obj = Foo->new();
$obj->get_config->{host};
";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let host_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "host" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
assert!(
!host_refs.is_empty(),
"chained hash-key access should emit a HashKeyAccess ref for 'host'"
);
let owner = host_refs
.iter()
.find_map(|r| match &r.kind {
RefKind::HashKeyAccess { owner: Some(o), .. } => Some(o.clone()),
_ => None,
})
.expect("chained hash-key ref should carry a resolved owner");
assert_eq!(
owner,
HashKeyOwner::Class("Config".to_string()),
"owner should be the chain receiver's class, got {:?}",
owner
);
let key_ref = host_refs[0];
let def = fa.find_definition(key_ref.span.start, None);
assert!(
def.is_some(),
"goto-def on chained `->{{host}}` should resolve to Config's key def"
);
assert_eq!(def.unwrap().start.row, 1, "host def is the bless key on line 1");
}
#[test]
fn blessed_hash_plain_comma_keys_emit_hash_key_defs() {
let src = "\
package Config;
sub new { bless { 'host', 'localhost', port => 5432 }, shift }
package main;
my $c = Config->new();
$c->{host};
$c->{port};
";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let expected = HashKeyOwner::Sub { package: Some("Config".to_string()), name: "new".to_string() };
for key in ["host", "port"] {
assert!(
fa.symbols.iter().any(|s| s.name == key
&& matches!(&s.detail, SymbolDetail::HashKeyDef { owner, .. } if *owner == expected)),
"plain/fat-comma bless key `{key}` must emit a HashKeyDef owned by Config::new; got: {:?}",
fa.symbols.iter()
.filter(|s| matches!(s.detail, SymbolDetail::HashKeyDef { .. }))
.map(|s| (s.name.clone(), s.detail.clone())).collect::<Vec<_>>(),
);
}
}
#[test]
fn untyped_chain_emits_no_hash_key_ref() {
let src = "\
package main;
my $obj = bless {}, 'Foo';
$obj->totally_unknown_method->{host};
";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let host_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| r.target_name == "host" && matches!(r.kind, RefKind::HashKeyAccess { .. }))
.collect();
assert!(
host_refs.is_empty(),
"untyped chain must not emit a hash-key ref, got {:?}",
host_refs
);
}
#[test]
fn cross_package_glob_synthesizes_under_target_package() {
let src = r#"package DateTime::PP;
sub _ymd2rd { 1 }
sub _rd2ymd { 2 }
my @subs = qw( _ymd2rd _rd2ymd );
for my $sub (@subs) {
no strict 'refs';
*{ 'DateTime::' . $sub } = __PACKAGE__->can($sub);
}
1;
"#;
let fa = build_fa(src);
for tail in ["_ymd2rd", "_rd2ymd"] {
let under_datetime = fa.symbols.iter().any(|s| {
s.name == tail
&& matches!(s.kind, SymKind::Sub)
&& s.package.as_deref() == Some("DateTime")
});
assert!(
under_datetime,
"glob-synthesized `{}` should be attributed to DateTime, symbols: {:?}",
tail,
fa.symbols
.iter()
.filter(|s| s.name == tail)
.map(|s| (&s.name, &s.package))
.collect::<Vec<_>>()
);
}
assert!(
fa.symbols.iter().any(|s| s.name == "_ymd2rd"
&& matches!(s.kind, SymKind::Sub)
&& s.package.as_deref() == Some("DateTime::PP")),
"the original DateTime::PP::_ymd2rd sub must still exist"
);
}
#[test]
fn chained_method_call_hash_key_owned_at_arbitrary_depth() {
let src = "\
package Config;
sub new { bless { host => 'localhost' }, shift }
package Foo;
sub new { bless {}, shift }
sub me { return $_[0] }
sub get_config { return Config->new() }
package main;
my $obj = Foo->new();
$obj->get_config->{host};
$obj->me->me->get_config->{host};
";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let owners: Vec<_> = fa.refs.iter().filter_map(|r| match &r.kind {
RefKind::HashKeyAccess { owner: Some(o), .. } if r.target_name == "host" => Some(o.clone()),
_ => None,
}).collect();
assert_eq!(owners.len(), 2, "both 1-hop and 3-hop chained ->{{host}} should emit an owned ref, got {:?}", owners);
assert!(owners.iter().all(|o| *o == HashKeyOwner::Class("Config".to_string())), "every depth's owner must be Config, got {:?}", owners);
}
#[test]
fn chained_hash_key_mixed_depth_method_key() {
let src = "\
package Inner;
sub new { bless { host => 'localhost' }, shift }
package Deep;
sub new { bless {}, shift }
sub cfg { return Inner->new() }
package Config;
sub new { bless {}, shift }
sub deep { return Deep->new() }
package Foo;
sub new { bless {}, shift }
sub get_config { return Config->new() }
package main;
my $obj = Foo->new();
$obj->get_config->deep->cfg->{host};
";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let owners: Vec<_> = fa.refs.iter().filter_map(|r| match &r.kind {
RefKind::HashKeyAccess { owner: Some(o), .. } if r.target_name == "host" => Some(o.clone()),
_ => None,
}).collect();
assert_eq!(owners, vec![HashKeyOwner::Class("Inner".to_string())],
"mixed-depth chain must resolve host's owner to Inner, got {:?}", owners);
}
#[test]
fn chained_hash_key_untyped_deep_chain_no_owner() {
let src = "\
package Foo;
sub new { bless {}, shift }
sub mystery { return $_[0]->some_unknown_thing() }
package main;
my $obj = Foo->new();
$obj->mystery->mystery->{host};
";
let tree = parse(src);
let fa = build(&tree, src.as_bytes());
let owned: Vec<_> = fa.refs.iter().filter(|r| matches!(&r.kind,
RefKind::HashKeyAccess { owner: Some(_), .. }) && r.target_name == "host").collect();
assert!(owned.is_empty(), "untyped deep chain must not latch a wrong owner, got {:?}",
owned.iter().map(|r| &r.kind).collect::<Vec<_>>());
}
#[test]
fn same_package_glob_synthesizes_under_current_package() {
let src = r#"package Acme::Widget;
*frobnicate = sub { 42 };
1;
"#;
let fa = build_fa(src);
assert!(
fa.symbols.iter().any(|s| s.name == "frobnicate"
&& matches!(s.kind, SymKind::Sub)
&& s.package.as_deref() == Some("Acme::Widget")),
"same-package glob must stay under the current package, symbols: {:?}",
fa.symbols
.iter()
.filter(|s| s.name == "frobnicate")
.map(|s| (&s.name, &s.package))
.collect::<Vec<_>>()
);
}
fn sub_names(fa: &FileAnalysis) -> Vec<String> {
fa.symbols
.iter()
.filter(|s| matches!(s.kind, SymKind::Sub | SymKind::Method))
.map(|s| s.name.clone())
.collect()
}
#[test]
fn dollar_at_block_interp_bleed_recovers_following_subs() {
let src = r#"package Foo;
my $x = "err ${@} more text here";
sub alpha { return 1; }
sub beta { return 2; }
sub gamma { my $self = shift; return $self; }
1;
"#;
let fa = build_fa(src);
let names = sub_names(&fa);
for want in ["alpha", "beta", "gamma"] {
assert!(
names.iter().any(|n| n == want),
"sub `{want}` must survive the ${{@}} bleed; recovered: {names:?}"
);
}
}
#[test]
fn dollar_at_block_interp_recovered_sub_has_correct_position() {
let src = r#"package Foo;
my $x = "err ${@}";
sub alpha { return 1; }
1;
"#;
let fa = build_fa(src);
let alpha = fa
.symbols
.iter()
.find(|s| s.name == "alpha" && matches!(s.kind, SymKind::Sub))
.expect("alpha recovered");
assert_eq!(alpha.selection_span.start.row, 2, "alpha row");
assert_eq!(alpha.selection_span.start.column, 4, "alpha name column");
assert_eq!(alpha.package.as_deref(), Some("Foo"), "alpha package");
}
#[test]
fn dollar_at_block_interp_bleed_keeps_package() {
let src = r#"package Net::DNS::RR;
my $e = "${@}in $stmnt\n";
sub new { }
sub decode { }
sub encode { }
1;
"#;
let fa = build_fa(src);
assert!(
fa.symbols
.iter()
.any(|s| s.name == "Net::DNS::RR" && matches!(s.kind, SymKind::Package)),
"package survives the bleed"
);
for want in ["new", "decode", "encode"] {
assert!(
fa.symbols.iter().any(|s| s.name == want
&& matches!(s.kind, SymKind::Sub | SymKind::Method)
&& s.package.as_deref() == Some("Net::DNS::RR")),
"sub `{want}` recovered under Net::DNS::RR"
);
}
}
#[test]
fn normal_parse_unaffected_by_error_text_recovery() {
let src = r#"package Foo;
sub one { 1 }
sub two { 2 }
1;
"#;
let fa = build_fa(src);
let mut names = sub_names(&fa);
names.sort();
assert_eq!(names, vec!["one".to_string(), "two".to_string()]);
}
#[test]
fn error_text_recovery_does_not_duplicate_a_recovered_sub() {
let src = "package Foo;\nif (\nsub kept { 1 }\n";
let fa = build_fa(src);
let kept: Vec<_> = fa
.symbols
.iter()
.filter(|s| s.name == "kept" && matches!(s.kind, SymKind::Sub | SymKind::Method))
.collect();
assert_eq!(kept.len(), 1, "no duplicate `kept`: {kept:?}");
}
fn slot_type(fa: &FileAnalysis, class: &str, key: &str) -> Option<InferredType> {
use crate::witnesses::{
BagContext, FrameworkFact, ReducedValue, ReducerQuery, ReducerRegistry, WitnessAttachment,
};
let att = WitnessAttachment::SlotType {
class: class.to_string(),
key: key.to_string(),
};
let ctx = BagContext {
scopes: &fa.scopes,
package_framework: &fa.package_framework,
module_index: None,
package_parents: &fa.package_parents,
app_surface_consumers: &fa.app_surface_consumers,
};
let q = ReducerQuery {
attachment: &att,
point: None,
framework: FrameworkFact::Plain,
arity_hint: None,
receiver: None,
context: Some(&ctx),
};
let reg = ReducerRegistry::with_defaults();
match reg.query(&fa.witnesses, &q) {
ReducedValue::Type(t) => Some(t),
_ => None,
}
}
#[test]
fn slot_type_single_typed_write() {
let src = "package Foo;\nsub init {\n my $self = shift;\n $self->{h} = Helper->new;\n}\n";
let fa = build_fa(src);
let t = slot_type(&fa, "Foo", "h").expect("SlotType{Foo,h} should fold");
assert_eq!(t.class_name(), Some("Helper"), "got {t:?}");
}
#[test]
fn slot_type_two_agreeing_writes() {
let src = "package Foo;\nsub a {\n my $self = shift;\n $self->{h} = Helper->new;\n}\nsub b {\n my $self = shift;\n $self->{h} = Helper->new;\n}\n";
let fa = build_fa(src);
let t = slot_type(&fa, "Foo", "h").expect("agreeing writes fold to the agreed type");
assert_eq!(t.class_name(), Some("Helper"), "got {t:?}");
}
#[test]
fn slot_type_two_disagreeing_writes_none() {
let src = "package Foo;\nsub a {\n my $self = shift;\n $self->{h} = Helper->new;\n}\nsub b {\n my $self = shift;\n $self->{h} = Other->new;\n}\n";
let fa = build_fa(src);
assert_eq!(slot_type(&fa, "Foo", "h"), None);
}
#[test]
fn slot_type_unknown_rhs_no_slot() {
let src = "package Foo;\nsub init {\n my $self = shift;\n my $param = shift;\n $self->{h} = $param;\n}\n";
let fa = build_fa(src);
assert_eq!(slot_type(&fa, "Foo", "h"), None);
}
#[test]
fn slot_type_keyed_by_owner_class() {
let src = "package Bar;\nsub mk {\n my $self = shift;\n my $o = Foo->new;\n $o->{h} = Helper->new;\n $self->{h} = Sidecar->new;\n}\n";
let fa = build_fa(src);
let foo_h = slot_type(&fa, "Foo", "h").expect("SlotType keyed by owner class Foo");
assert_eq!(foo_h.class_name(), Some("Helper"), "got {foo_h:?}");
let bar_h = slot_type(&fa, "Bar", "h").expect("SlotType{Bar,h} from $self write");
assert_eq!(bar_h.class_name(), Some("Sidecar"), "got {bar_h:?}");
}
#[test]
fn test_braced_invocant_bless_is_receiver_poly() {
let fa = build_fa(
"package Base;\nsub new { my $class = shift; bless {}, ref ${class} || ${class} }\npackage Child;\nuse parent -norequire, 'Base';\n",
);
assert_eq!(
fa.find_method_return_type("Child", "new", None, Some(0)),
Some(InferredType::ClassName("Child".into())),
"braced-self inherited ctor must type Child->new as Child"
);
let fa2 = build_fa(
"package Base;\nsub new { my $ref = \\'X'; bless {}, ${$ref} }\npackage Child;\nuse parent -norequire, 'Base';\n",
);
assert_ne!(
fa2.find_method_return_type("Child", "new", None, Some(0)),
Some(InferredType::ClassName("Child".into())),
"a sigil-deref bless target must NOT be treated as the receiver"
);
}
#[test]
fn test_super_new_types_to_calling_class() {
let fa = build_fa(
"package Base;\nsub new { my $class = shift; bless {}, ref $class || $class }\nsub parse { $_[0] }\npackage Child;\nuse parent -norequire, 'Base';\nsub new { my $self = shift; @_ > 1 ? $self->SUPER::new->parse(@_) : $self->SUPER::new }\nsub clone { my $self = shift; my $c = $self->new; @$c{qw(a)} = (1); return $c }\n",
);
assert_eq!(
fa.find_method_return_type("Child", "new", None, Some(0)),
Some(InferredType::ClassName("Child".into())),
"SUPER::new on a receiver-polymorphic parent ctor blesses into the subclass"
);
assert_eq!(
fa.find_method_return_type("Child", "clone", None, Some(0)),
Some(InferredType::ClassName("Child".into())),
"clone's $self->new composes through the SUPER hop back to the subclass"
);
}
#[test]
fn test_fq_method_call_dispatches_from_named_class() {
let fa = build_fa(
"package Maker;\nsub build { my $class = shift; return bless {}, ref $class || $class }\npackage Invoker;\nsub new { my $c = shift; return bless {}, ref $c || $c }\nsub build { return 42 }\npackage main;\nmy $obj = Invoker->new;\nmy $r = $obj->Maker::build();\n",
);
assert_eq!(
fa.inferred_type_via_bag("$r", Point::new(7, 4)),
Some(InferredType::ClassName("Invoker".into())),
"FQ call dispatches build from Maker (receiver-poly → invocant), not from Invoker"
);
}
#[test]
fn test_bless_return_strands_class_arg_recovered() {
let lit = build_fa("package P;\nsub make { return bless {}, 'Widget' }\n");
assert_eq!(
lit.find_method_return_type("P", "make", None, Some(0)),
Some(InferredType::ClassName("Widget".into())),
"return bless {{}}, 'Widget' must type to Widget, not the enclosing package"
);
let poly = build_fa(
"package Base;\nsub new { my $class = shift; return bless {}, ref $class || $class }\npackage Child;\nuse parent -norequire, 'Base';\nsub make { my $self = shift; return $self->new }\n",
);
assert_eq!(
poly.find_method_return_type("Child", "make", None, Some(0)),
Some(InferredType::ClassName("Child".into())),
"inherited receiver-poly ctor (return bless {{}}, ref $class || $class) types to the subclass"
);
}
#[test]
fn test_bless_positional_self_is_receiver() {
let fa = build_fa(
"package Base;\nsub new { return bless {}, ref $_[0] || $_[0] }\npackage Child;\nuse parent -norequire, 'Base';\nsub make { my $self = shift; return $self->new }\n",
);
assert_eq!(
fa.find_method_return_type("Child", "make", None, Some(0)),
Some(InferredType::ClassName("Child".into())),
"bless {{}}, ref $_[0] || $_[0] is receiver-polymorphic via the positional self"
);
}
#[test]
fn braced_scalar_invocant_canonicalizes_and_resolves() {
let src = "\
package main;
my $sner = Foo->new;
${sner}->thing;
my $ref = \\$sner;
${$ref}->other;
";
let fa = build_fa(src);
let thing = fa
.refs
.iter()
.find(|r| r.target_name == "thing")
.expect("MethodCall ref for thing");
let RefKind::MethodCall { ref invocant, .. } = thing.kind else {
panic!("expected MethodCall, got {:?}", thing.kind);
};
assert_eq!(invocant, "$sner");
assert_eq!(
fa.method_call_invocant_class(thing, None).as_deref(),
Some("Foo"),
"braced spelling resolves through the variable's type",
);
let other = fa
.refs
.iter()
.find(|r| r.target_name == "other")
.expect("MethodCall ref for other");
let RefKind::MethodCall { ref invocant, .. } = other.kind else {
panic!("expected MethodCall, got {:?}", other.kind);
};
assert_eq!(invocant, "${$ref}", "deref block keeps raw text");
}
#[test]
fn const_folded_scalar_invocant_pins_class() {
let src = "\
package Counter;
sub bump { 1 }
package main;
my $c = 'Counter';
$c->bump;
";
let fa = build_fa(src);
let bump = fa
.refs
.iter()
.find(|r| r.target_name == "bump" && matches!(r.kind, RefKind::MethodCall { .. }))
.expect("MethodCall ref for bump");
assert_eq!(
fa.method_call_invocant_class(bump, None).as_deref(),
Some("Counter"),
"const-folded invocant should dispatch on Counter",
);
}
#[test]
fn corinna_field_group_rename_ties_all_spellings() {
let src = "\
use v5.38;
class Point {
field $x :param :reader;
field $y :param;
method magnitude () { return sqrt($x**2 + $y**2); }
}
my $p = Point->new(x => 3, y => 4);
my $val = $p->x;
";
let fa = build_fa(src);
let find = |row: usize, col: usize| {
fa.rename_at(Point::new(row, col), "coord")
.map(|mut v| {
v.sort_by_key(|(s, _)| (s.start.row, s.start.column));
v
})
.expect("rename produces edits")
};
let from_decl = find(2, 11);
let rows: Vec<usize> = from_decl.iter().map(|(s, _)| s.start.row).collect();
assert_eq!(rows, vec![2, 4, 6, 7], "decl rename covers all spellings: {:?}", from_decl);
assert_eq!(from_decl[0].0.start.column, 11);
assert!(from_decl.iter().all(|(_, t)| t == "coord"));
assert_eq!(find(6, 19), from_decl, "ctor-key rename == decl rename");
assert_eq!(find(4, 39), from_decl, "body-use rename == decl rename");
assert!(
!from_decl.iter().any(|(s, _)| s.start.row == 3),
"y's decl must not be in x's group"
);
let src2 = "\
use v5.38;
class Q {
field $label = \"q\";
method tag () { return $label; }
}
my $q = Q->new();
";
let fa2 = build_fa(src2);
let edits = fa2.rename_at(Point::new(2, 11), "name").expect("plain field renames");
assert_eq!(edits.len(), 2, "decl + body use only: {:?}", edits);
}
#[test]
fn moo_attr_group_rename_ties_all_spellings() {
let src = "\
package Widget;
use Moo;
has size => (is => 'ro');
sub describe { my ($self) = @_; return $self->size; }
package main;
my $w = Widget->new(size => 3);
my $s = $w->size;
";
let fa = build_fa(src);
let find = |row: usize, col: usize| {
fa.rename_at(Point::new(row, col), "extent")
.map(|mut v| {
v.sort_by_key(|(s, _)| (s.start.row, s.start.column));
v
})
.expect("rename produces edits")
};
let from_decl = find(2, 5);
let rows: Vec<usize> = from_decl.iter().map(|(s, _)| s.start.row).collect();
assert_eq!(rows, vec![2, 3, 5, 6], "decl rename covers all spellings: {:?}", from_decl);
assert_eq!(find(5, 21), from_decl, "ctor-key rename == decl rename");
assert_eq!(find(3, 47), from_decl, "accessor-call rename == decl rename");
}
#[test]
fn moo_mapped_predicate_joins_group_rename() {
let src = "\
package Widget;
use Moo;
has size => (is => 'ro', predicate => 1);
package main;
my $w = Widget->new(size => 3);
if ($w->has_size) { print $w->size; }
";
let fa = build_fa(src);
let edits = fa
.rename_at(Point::new(2, 5), "extent")
.expect("rename produces edits");
let predicate_edit = edits
.iter()
.find(|(s, _)| s.start.row == 5 && s.start.column == 8)
.expect("has_size call edited");
assert_eq!(predicate_edit.1, "has_extent");
assert!(
edits.iter().filter(|(_, t)| t == "extent").count() >= 3,
"bare spellings renamed too: {:?}",
edits,
);
let mut spans: Vec<_> = edits.iter().map(|(s, _)| (s.start.row, s.start.column)).collect();
spans.sort();
spans.dedup();
assert_eq!(spans.len(), edits.len(), "no duplicate-span edits: {:?}", edits);
let refs = fa.find_references(Point::new(2, 5), None);
assert!(
refs.iter().any(|s| s.start.row == 5 && s.start.column == 8),
"references include has_size call: {:?}",
refs,
);
}
#[test]
fn hash_literal_structural_typing_and_narrowing() {
let src = "\
my $config = { db => { host => 'localhost', port => 5432 }, debug => 1 };
my $db = $config->{db};
my $host = $db->{host};
my $port = $config->{db}->{port};
my $open = { %$config, extra => 'x' };
";
let fa = build_fa(src);
let cfg = fa
.inferred_type_via_bag("$config", Point::new(1, 0))
.expect("$config typed");
let db_ty = cfg.key_value_type("db").expect("db key present").expect("db value typed");
assert!(
matches!(db_ty, InferredType::HashWithKeys { open: false, .. }),
"nested literal rides the value slot: {:?}",
db_ty,
);
assert!(cfg.key_value_type("typo").is_none(), "closed shape: unknown key is no key");
let db = fa
.inferred_type_via_bag("$db", Point::new(2, 0))
.expect("$db typed from ->{db}");
assert!(matches!(db, InferredType::HashWithKeys { .. }), "got {:?}", db);
let host = fa
.inferred_type_via_bag("$host", Point::new(3, 0))
.expect("$host typed from ->{host}");
assert_eq!(host, InferredType::String);
let port = fa
.inferred_type_via_bag("$port", Point::new(4, 0))
.expect("$port typed from ->{db}->{port}");
assert_eq!(port, InferredType::Numeric);
let open = fa
.inferred_type_via_bag("$open", Point::new(4, 9))
.expect("$open typed");
assert!(
matches!(open, InferredType::HashWithKeys { open: true, .. }),
"spread flips open: {:?}",
open,
);
}
#[test]
fn mutation_extension_on_closed_shapes() {
let src = "\
my $ext = { host => 'x' };
my $before = $ext->{host};
$ext->{added} = 42;
my $after = $ext->{added};
my $cond = { host => 'x' };
$cond->{maybe} = 1 if $ENV{X};
my $dyn = { host => 'x' };
$dyn->{$ENV{K}} = 1;
";
let fa = build_fa(src);
let t0 = fa.inferred_type_via_bag("$ext", Point::new(1, 0)).expect("$ext typed");
assert!(
matches!(&t0, InferredType::HashWithKeys { keys, open: false } if keys.len() == 1),
"pre-write shape: {:?}",
t0,
);
let t1 = fa.inferred_type_via_bag("$ext", Point::new(3, 0)).expect("$ext typed");
let InferredType::HashWithKeys { keys, open: false } = &t1 else {
panic!("post-write shape: {:?}", t1)
};
assert_eq!(keys.len(), 2, "{:?}", keys);
assert_eq!(keys[1].0, "added");
assert_eq!(keys[1].1.as_deref(), Some(&InferredType::Numeric));
let after = fa.inferred_type_via_bag("$after", Point::new(4, 0)).expect("$after typed");
assert_eq!(after, InferredType::Numeric, "read drills the extended key");
let tc = fa.inferred_type_via_bag("$cond", Point::new(6, 0)).expect("$cond typed");
assert!(
matches!(tc, InferredType::HashWithKeys { open: true, .. }),
"conditional write opens: {:?}",
tc,
);
let td = fa.inferred_type_via_bag("$dyn", Point::new(8, 0)).expect("$dyn typed");
assert!(
matches!(td, InferredType::HashWithKeys { open: true, .. }),
"dynamic key opens: {:?}",
td,
);
}
#[test]
fn literal_hash_structural_typing() {
let src = "\
my %config = (host => 'x', port => 5432);
my $v = $config{host};
$config{added} = 42;
my $a = $config{added};
my %spread = (default => 1, @_);
";
let fa = build_fa(src);
let t = fa.inferred_type_via_bag("%config", Point::new(1, 0)).expect("%config typed");
assert!(
matches!(&t, InferredType::HashWithKeys { keys, open: false } if keys.len() == 2),
"literal-list shape: {:?}",
t,
);
let v = fa.inferred_type_via_bag("$v", Point::new(2, 0)).expect("$v typed");
assert_eq!(v, InferredType::String, "container-form read projects");
let t2 = fa.inferred_type_via_bag("%config", Point::new(3, 0)).expect("%config typed");
assert!(
matches!(&t2, InferredType::HashWithKeys { keys, open: false } if keys.len() == 3),
"write extends: {:?}",
t2,
);
let a = fa.inferred_type_via_bag("$a", Point::new(4, 0)).expect("$a typed");
assert_eq!(a, InferredType::Numeric, "extended key value type");
let sp = fa.inferred_type_via_bag("%spread", Point::new(5, 0)).expect("%spread typed");
assert!(
matches!(sp, InferredType::HashWithKeys { open: true, .. }),
"array spread opens: {:?}",
sp,
);
}
#[test]
fn slice_writes_open_closed_shapes() {
let src = "\
my %h = (a => 1);
@h{qw(b c)} = (1, 2);
my $r = { a => 1 };
$r->@{qw(d e)} = (3, 4);
my $s = { a => 1 };
@$s{qw(f g)} = (5, 6);
";
let fa = build_fa(src);
for (var, line) in [("%h", 2), ("$r", 4), ("$s", 6)] {
let t = fa
.inferred_type_via_bag(var, Point::new(line, 0))
.unwrap_or_else(|| panic!("{var} typed"));
assert!(
matches!(t, InferredType::HashWithKeys { open: true, .. }),
"slice write opens {var}: {:?}",
t,
);
}
}
#[test]
fn sequence_index_writes_retype_and_append() {
let src = "\
my $t = [1, 'x'];
$t->[0] = 'str';
$t->[2] = 99;
my $a = $t->[0];
my $b = $t->[2];
my $c = [1];
$c->[0] = 'maybe' if $ENV{X};
";
let fa = build_fa(src);
let t = fa.inferred_type_via_bag("$t", Point::new(3, 0)).expect("$t typed");
let InferredType::Sequence(elems) = &t else { panic!("{:?}", t) };
assert_eq!(
elems.as_slice(),
&[InferredType::String, InferredType::String, InferredType::Numeric],
"slot 0 retyped, slot 2 appended",
);
let a = fa.inferred_type_via_bag("$a", Point::new(4, 0)).expect("$a typed");
assert_eq!(a, InferredType::String);
let b = fa.inferred_type_via_bag("$b", Point::new(5, 0)).expect("$b typed");
assert_eq!(b, InferredType::Numeric);
let c = fa.inferred_type_via_bag("$c", Point::new(7, 0)).expect("$c typed");
let InferredType::Sequence(ce) = &c else { panic!("{:?}", c) };
assert_eq!(ce.as_slice(), &[InferredType::Numeric], "conditional write unmodeled");
}
#[test]
fn hash_literal_narrows_through_sub_return() {
let src = "\
sub cfg { return { host => 'x', port => 1 } }
my $h = cfg()->{host};
";
let fa = build_fa(src);
let h = fa
.inferred_type_via_bag("$h", Point::new(2, 0))
.expect("$h typed through cfg()->{host}");
assert_eq!(h, InferredType::String);
}
#[test]
fn array_element_narrowing_and_mixed_drill() {
let src = "\
my $x = [1, 'a'];
my $n = $x->[0];
my $s = $x->[1];
my $obj = { users => [ { name => 'A', id => 1 } ] };
my $name = $obj->{users}->[0]->{name};
my $id = $obj->{users}->[0]->{id};
";
let fa = build_fa(src);
assert_eq!(
fa.inferred_type_via_bag("$n", Point::new(2, 0)),
Some(InferredType::Numeric),
"heterogeneous tuple projects per index",
);
assert_eq!(
fa.inferred_type_via_bag("$s", Point::new(2, 8)),
Some(InferredType::String),
);
assert_eq!(
fa.inferred_type_via_bag("$name", Point::new(5, 0)),
Some(InferredType::String),
"mixed drill end-to-end",
);
assert_eq!(
fa.inferred_type_via_bag("$id", Point::new(5, 30)),
Some(InferredType::Numeric),
);
}
#[test]
fn array_element_narrowing_negative_space() {
let src = "\
my $x = [1, 'a'];
my $oob = $x->[7];
my $mixed = [1, some_call()];
";
let fa = build_fa(src);
assert_eq!(
fa.inferred_type_via_bag("$oob", Point::new(2, 0)),
None,
"out-of-range projection stays honest",
);
let m = fa.inferred_type_via_bag("$mixed", Point::new(2, 10));
assert_eq!(m, Some(InferredType::ArrayRef), "untypable element degrades whole literal");
}
#[test]
fn map_built_role_parents() {
let src = "\
package My::Class;
use Moo;
with map \"My::Roles::$_\", qw/Alpha Beta/;
";
let fa = build_fa(src);
let parents = fa.package_parents.get("My::Class").expect("parents recorded");
assert_eq!(
parents.as_slice(),
&["My::Roles::Alpha".to_string(), "My::Roles::Beta".to_string()],
);
}
#[test]
fn bareword_promotes_to_function_ref() {
let src = "\
sub get_config { return { host => 1 } }
my $a = get_config;
my $b = get_config->{host};
my @l = (get_config, 1);
my $f = UNRESOLVED_BAREWORD_FH;
";
let fa = build_fa(src);
let call_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| {
r.target_name == "get_config" && matches!(r.kind, RefKind::FunctionCall { .. })
})
.collect();
assert_eq!(
call_refs.len(),
3,
"three value-position barewords promote; the decl name does not",
);
assert!(
!fa.refs.iter().any(|r| r.target_name == "UNRESOLVED_BAREWORD_FH"),
"unresolvable barewords stay untouched",
);
}
#[test]
fn lite_group_under_route_inheritance() {
let src = "\
use Mojolicious::Lite;
under('/auth')->to('login#check');
group {
under('/n')->to('notifications#under');
get('/x')->to('#missing_fnsku');
};
get('/y')->to('#after_group');
";
let fa = {
let mut parser = super::create_parser();
let tree = parser.parse(src, None).unwrap();
super::build_with_plugins(&tree, src.as_bytes(), super::default_plugin_registry())
};
let invocant_of = |action: &str| -> String {
fa.refs
.iter()
.find_map(|r| {
if r.target_name != action {
return None;
}
let RefKind::MethodCall { ref invocant, .. } = r.kind else { return None };
Some(format!("{:?}", invocant))
})
.unwrap_or_else(|| panic!("no MethodCall ref for {action}"))
};
assert!(
invocant_of("missing_fnsku").contains("notifications"),
"in-group partial inherits the group's under",
);
assert!(
invocant_of("after_group").contains("login"),
"post-group partial inherits the OUTER under — the group frame popped",
);
}
#[test]
fn lite_plugin_name_emits_register_ref() {
let src = "\
use Mojolicious::Lite;
plugin 'WasLoaded';
plugin 'Foo::BarBaz';
";
let fa = {
let mut parser = super::create_parser();
let tree = parser.parse(src, None).unwrap();
super::build_with_plugins(&tree, src.as_bytes(), super::default_plugin_registry())
};
let invocants: Vec<String> = fa
.refs
.iter()
.filter(|r| r.target_name == "register")
.filter_map(|r| {
let RefKind::MethodCall { ref invocant, .. } = r.kind else { return None };
Some(format!("{:?}", invocant))
})
.collect();
assert_eq!(invocants.len(), 2, "{:?}", invocants);
assert!(invocants[0].contains("was_loaded"), "{:?}", invocants);
assert!(invocants[1].contains("foo-bar_baz"), "{:?}", invocants);
}
#[test]
fn mojo_framework_assigned_attrs_type() {
let src = "\
package My::App::Plugin::Demo;
use Mojo::Base 'Mojolicious::Plugin';
sub register {
my ($self, $app, $conf) = @_;
return $app;
}
1;
";
let fa = {
let mut parser = super::create_parser();
let tree = parser.parse(src, None).unwrap();
super::build_with_plugins(&tree, src.as_bytes(), super::default_plugin_registry())
};
let idx = crate::module_index::ModuleIndex::new_for_test();
let t = fa.inferred_type_via_bag_ctx("$app", Point::new(4, 10), Some(&idx));
assert_eq!(
t,
Some(InferredType::ClassName("Mojolicious".into())),
"register's $app is the application",
);
}
#[test]
fn interpolation_deref_code_gets_refs() {
let src = "\
package T;
sub filetype { 'csv' }
sub run {
my $self = shift;
my @m;
grep {s/_to_${\\$self->filetype}$//} @m;
my $y = \"x_${\\$self->filetype}_z\";
return $y;
}
1;
";
let fa = build_fa(src);
let calls = fa
.refs
.iter()
.filter(|r| {
r.target_name == "filetype" && matches!(r.kind, RefKind::MethodCall { .. })
})
.count();
assert_eq!(calls, 2, "regex-pattern and string interpolations both ref");
assert!(
!fa.refs.iter().any(|r| r.target_name.contains("${")),
"no junk ref for the outer interpolation scalar",
);
}
#[test]
fn test_plugin_declared_role_maker_marks_consumer_as_role() {
let plugin_src = r#"
fn id() { "house-role-kit" }
fn triggers() { [ #{ UsesModule: "My::CustomRole" } ] }
fn role_makers() { ["My::CustomRole"] }
"#;
let engine = std::sync::Arc::new(crate::plugin::rhai_host::make_engine());
let plugin = crate::plugin::rhai_host::RhaiPlugin::from_source(plugin_src, engine)
.expect("plugin compiles");
let mut reg = crate::plugin::PluginRegistry::new();
reg.register(Box::new(plugin));
let source = "package House::Role;\nuse My::CustomRole;\n1;\n";
let mut parser = create_parser();
let tree = parser.parse(source, None).unwrap();
let fa = build_with_plugins(&tree, source.as_bytes(), std::sync::Arc::new(reg));
assert!(
fa.is_role_package("House::Role"),
"plugin-declared role maker must mark the consumer as a role",
);
assert!(
!fa.is_role_package("My::CustomRole"),
"the maker module itself is not thereby a role",
);
}
#[test]
fn test_bundled_moo_manifest_carries_base_role_engines() {
let source = "package R1;\nuse Moo::Role;\npackage R2;\nuse Moose::Role;\n\
package R3;\nuse Mouse::Role;\npackage R4;\nuse Role::Tiny;\n\
package C1;\nuse Moo;\npackage C2;\nuse Role::Tiny::With;\n1;\n";
let mut parser = create_parser();
let tree = parser.parse(source, None).unwrap();
let fa = build(&tree, source.as_bytes());
for role in ["R1", "R2", "R3", "R4"] {
assert!(fa.is_role_package(role), "{role} should be a role");
}
for class in ["C1", "C2"] {
assert!(!fa.is_role_package(class), "{class} should NOT be a role");
}
}
#[test]
fn plugin_loads_recorded_trigger_independent_and_multivalue() {
use crate::file_analysis::SymKind;
let src = "package My::Plugin::All;\n\
use Mojo::Base 'Mojolicious::Plugin';\n\
use constant EXTRA => 'Gizmos';\n\
sub register {\n\
my ($self, $app, $conf) = @_;\n\
$app->plugin('FeatureFlags');\n\
$app->plugin($_) for qw/SheetReaders ImportTasks ExportTasks/;\n\
$app->plugin(EXTRA);\n\
}\n\
1;\n";
let mut parser = create_parser();
let tree = parser.parse(src, None).unwrap();
let fa = build(&tree, src.as_bytes());
let mut loads: Vec<String> = fa.plugin_loads.iter().map(|f| f.name.clone()).collect();
loads.sort();
assert_eq!(
loads,
vec!["ExportTasks", "FeatureFlags", "Gizmos", "ImportTasks", "SheetReaders"],
"all three forms (literal, qw-loop, folded constant) recorded; got {:?}",
fa.plugin_loads,
);
let _ = SymKind::Sub;
}