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_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));
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));
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));
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));
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));
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, None, 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, None, 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_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 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_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_eq!(
ty,
Some(InferredType::HashRef),
"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_eq!(
ty,
Some(InferredType::ArrayRef),
"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_eq!(ty, Some(InferredType::HashRef));
}
#[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_eq!(ty, Some(InferredType::ArrayRef));
}
#[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_eq!(
ty,
Some(InferredType::ArrayRef),
"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_eq!(ty, Some(InferredType::ArrayRef));
}
#[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_eq!(ty, Some(InferredType::HashRef));
}
#[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_eq!(ty, Some(InferredType::ArrayRef));
}
#[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_eq!(ty, Some(InferredType::HashRef));
}
#[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_eq!(
ty,
Some(InferredType::ArrayRef),
"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_eq!(
fa.sub_return_type_at_arity("get_config", None),
Some(InferredType::HashRef)
);
}
#[test]
fn test_return_type_arrayref() {
let fa = build_fa("sub get_tags {\n return [1, 2, 3];\n}");
assert_eq!(
fa.sub_return_type_at_arity("get_tags", None),
Some(InferredType::ArrayRef)
);
}
#[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_eq!(
fa.sub_return_type_at_arity("get_data", None),
Some(InferredType::HashRef)
);
}
#[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_eq!(
fa.sub_return_type_at_arity("consistent", None),
Some(InferredType::HashRef)
);
}
#[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_eq!(
fa.sub_return_type_at_arity("get_config", None),
Some(InferredType::HashRef)
);
let ty = fa.inferred_type_via_bag("$cfg", Point::new(4, 0));
assert_eq!(
ty,
Some(InferredType::HashRef),
"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_eq!(
fa.sub_return_type_at_arity("get_config", None),
Some(InferredType::HashRef)
);
}
#[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_eq!(
fa.sub_return_type_at_arity("maybe", None),
Some(InferredType::HashRef)
);
}
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 = fa.resolve_expression_type(call_node, src.as_bytes(), None);
assert_eq!(ty, Some(InferredType::HashRef));
}
#[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 = fa.resolve_expression_type(scalar_node, src.as_bytes(), None);
assert_eq!(ty, Some(InferredType::HashRef));
}
#[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 = fa.resolve_expression_type(n, src.as_bytes(), None);
assert_eq!(ty, Some(InferredType::HashRef));
}
#[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 = fa.resolve_expression_type(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_eq!(
get_config_rt,
Some(InferredType::HashRef),
"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 = fa.resolve_expression_type(base, src.as_bytes(), None);
assert_eq!(
ty,
Some(InferredType::HashRef),
"the chain $calc->get_self->get_config should resolve to HashRef"
);
}
#[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_find_def_variable() {
let fa = build_fa("my $x = 1;\nprint $x;");
let def = fa.find_definition(Point::new(1, 7), None, None, 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, None, 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, None, 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, None, 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, None, 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,
Some(&tree),
Some(src.as_bytes()),
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, None, 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), Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), 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, Some(&tree), Some(src.as_bytes()), None);
assert!(
refs_from_usage.len() >= 2,
"should find def + usage, got {} refs",
refs_from_usage.len()
);
}
#[test]
fn test_hash_key_refs_chained_tree_fallback() {
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 chained_refs: Vec<_> = fa
.refs
.iter()
.filter(|r| {
r.target_name == "host" && matches!(r.kind, RefKind::HashKeyAccess { owner: None, .. })
})
.collect();
assert!(
!chained_refs.is_empty(),
"chained hash access should have owner: None, refs: {:?}",
fa.refs
.iter()
.filter(|r| r.target_name == "host")
.collect::<Vec<_>>()
);
let host_def_point = host_defs[0].selection_span.start;
let refs = fa.find_references(host_def_point, Some(&tree), Some(src.as_bytes()), None);
assert!(
refs.len() >= 1,
"should find chained usage via tree fallback, 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, None, 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, 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, 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, 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, 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, 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, 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", None, None);
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, None, 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, None, 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, None, 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), Some(&tree), Some(src.as_bytes()), 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), Some(&tree), Some(src.as_bytes()), 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_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::file_analysis::InferredType;
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_eq!(rt, Some(InferredType::HashRef));
}
#[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_eq!(ty, Some(InferredType::HashRef));
}
#[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_eq!(name_ty, Some(InferredType::HashRef));
}
#[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_eq!(ty, Some(InferredType::HashRef));
}
#[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_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_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_eq!(
fa.find_method_return_type("Sour", "flavor", None, Some(0)),
Some(InferredType::ArrayRef),
"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_eq!(
rt,
Some(InferredType::ArrayRef),
"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_eq!(
rt,
Some(InferredType::HashRef),
"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_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_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(),
},
},
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("Mojolicious::Controller"),
"canonical home is Mojolicious::Controller"
);
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!(
bridge_classes.contains("Mojolicious::Controller"),
"namespace bridges Controller"
);
assert!(
bridge_classes.contains("Mojolicious"),
"namespace bridges Mojolicious (the app class)"
);
}
#[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("Mojolicious::Controller")
})
.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("Mojolicious::Controller")
})
.expect("admin on Controller");
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, None, 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("Mojolicious::Controller");
assert!(
mods.iter().any(|m| m == "MyApp"),
"MyApp module should be listed as bridged to \
Mojolicious::Controller; 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` emitted on Mojolicious::Controller in \
MyApp.pm should complete on subclasses; 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("Mojolicious::Controller"),
"helper Method must be packaged on the shared controller base; \
that's what lets every subclass pick it up via inheritance walk"
);
assert!(matches!(&greet.namespace, Namespace::Framework { id } if id == "mojo-helpers"));
}
#[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 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("Mojolicious::Controller");
assert!(
mods.iter().any(|m| m == "MyApp"),
"workspace index must list MyApp.pm bridged to Controller; 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);
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, None, 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(&tree), Some(&idx));
let def = fa.find_definition(point, Some(&tree), Some(src.as_bytes()), 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(&tree), 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("Mojolicious::Controller".into()))
&& n.bridges.contains(&Bridge::Class("Mojolicious".into()))
})
.expect("an `app` namespace must bridge both Controller and Mojolicious");
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 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 = app_fa
.resolve_expression_type(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(&tree),
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,
);
}