use super::*;
use tree_sitter::Point;
fn fa_with_constraints(constraints: Vec<TypeConstraint>) -> FileAnalysis {
let mut fa = FileAnalysis::new(FileAnalysisParts {
scopes: vec![Scope {
id: ScopeId(0),
parent: None,
kind: ScopeKind::File,
span: Span {
start: Point::new(0, 0),
end: Point::new(10, 0),
},
package: None,
}],
..Default::default()
});
for tc in constraints {
fa.push_type_constraint(tc);
}
fa
}
fn constraint(var: &str, row: usize, inferred_type: InferredType) -> TypeConstraint {
TypeConstraint {
variable: var.to_string(),
scope: ScopeId(0),
constraint_span: Span {
start: Point::new(row, 0),
end: Point::new(row, 20),
},
inferred_type,
}
}
#[test]
fn test_resolve_hashref_type() {
let fa = fa_with_constraints(vec![constraint("$href", 0, InferredType::HashRef)]);
assert_eq!(
fa.inferred_type_via_bag("$href", Point::new(1, 0)),
Some(InferredType::HashRef)
);
}
#[test]
fn test_resolve_arrayref_type() {
let fa = fa_with_constraints(vec![constraint("$aref", 0, InferredType::ArrayRef)]);
assert_eq!(
fa.inferred_type_via_bag("$aref", Point::new(1, 0)),
Some(InferredType::ArrayRef)
);
}
#[test]
fn test_resolve_coderef_type() {
let fa = fa_with_constraints(vec![constraint(
"$cref",
0,
InferredType::CodeRef { return_edge: None },
)]);
assert_eq!(
fa.inferred_type_via_bag("$cref", Point::new(1, 0)),
Some(InferredType::CodeRef { return_edge: None })
);
}
#[test]
fn test_resolve_regexp_type() {
let fa = fa_with_constraints(vec![constraint("$re", 0, InferredType::Regexp)]);
assert_eq!(
fa.inferred_type_via_bag("$re", Point::new(1, 0)),
Some(InferredType::Regexp)
);
}
#[test]
fn test_resolve_numeric_type() {
let fa = fa_with_constraints(vec![constraint("$n", 0, InferredType::Numeric)]);
assert_eq!(
fa.inferred_type_via_bag("$n", Point::new(1, 0)),
Some(InferredType::Numeric)
);
}
#[test]
fn test_resolve_string_type() {
let fa = fa_with_constraints(vec![constraint("$s", 0, InferredType::String)]);
assert_eq!(
fa.inferred_type_via_bag("$s", Point::new(1, 0)),
Some(InferredType::String)
);
}
#[test]
fn test_resolve_reassignment_changes_type() {
let fa = fa_with_constraints(vec![
constraint("$x", 0, InferredType::HashRef),
constraint("$x", 3, InferredType::ArrayRef),
]);
assert_eq!(
fa.inferred_type_via_bag("$x", Point::new(1, 0)),
Some(InferredType::HashRef)
);
assert_eq!(
fa.inferred_type_via_bag("$x", Point::new(4, 0)),
Some(InferredType::ArrayRef)
);
}
#[test]
fn test_resolve_sub_return_type() {
use crate::witnesses::{Witness, WitnessAttachment, WitnessPayload, WitnessSource};
let mut fa = FileAnalysis::new(FileAnalysisParts {
scopes: vec![Scope {
id: ScopeId(0),
parent: None,
kind: ScopeKind::File,
span: Span {
start: Point::new(0, 0),
end: Point::new(10, 0),
},
package: None,
}],
symbols: vec![Symbol {
id: SymbolId(0),
name: "get_config".to_string(),
kind: SymKind::Sub,
span: Span {
start: Point::new(0, 0),
end: Point::new(2, 1),
},
selection_span: Span {
start: Point::new(0, 4),
end: Point::new(0, 14),
},
scope: ScopeId(0),
package: None,
detail: SymbolDetail::Sub {
params: vec![],
is_method: false,
doc: None,
display: None,
hide_in_outline: false,
opaque_return: false,
is_constant: false,
lexical: false,
},
namespace: Namespace::Language,
outline_label: None,
}],
..Default::default()
});
let zero_span = Span {
start: Point::new(0, 0),
end: Point::new(0, 0),
};
fa.witnesses.push(Witness {
attachment: WitnessAttachment::Symbol(SymbolId(0)),
source: WitnessSource::Builder("local_return".into()),
payload: WitnessPayload::InferredType(InferredType::HashRef),
span: zero_span,
});
assert_eq!(
fa.sub_return_type_at_arity("get_config", None),
Some(InferredType::HashRef)
);
assert_eq!(fa.sub_return_type_at_arity("nonexistent", None), None);
}
#[test]
fn test_resolve_return_type_all_agree() {
assert_eq!(
resolve_return_type(&[InferredType::HashRef, InferredType::HashRef]),
Some(InferredType::HashRef),
);
}
#[test]
fn test_resolve_return_type_disagreement() {
assert_eq!(
resolve_return_type(&[InferredType::HashRef, InferredType::ArrayRef]),
None,
);
}
#[test]
fn test_resolve_return_type_empty() {
assert_eq!(resolve_return_type(&[]), None);
}
#[test]
fn test_resolve_return_type_object_subsumes_hashref() {
assert_eq!(
resolve_return_type(&[InferredType::ClassName("Foo".into()), InferredType::HashRef,]),
Some(InferredType::ClassName("Foo".into())),
);
assert_eq!(
resolve_return_type(&[InferredType::HashRef, InferredType::ClassName("Foo".into()),]),
Some(InferredType::ClassName("Foo".into())),
);
}
#[test]
fn test_resolve_return_type_object_does_not_subsume_arrayref() {
assert_eq!(
resolve_return_type(&[
InferredType::ClassName("Foo".into()),
InferredType::ArrayRef,
]),
None,
);
}
#[test]
fn test_resolve_return_type_single() {
assert_eq!(
resolve_return_type(&[InferredType::CodeRef { return_edge: None }]),
Some(InferredType::CodeRef { return_edge: None }),
);
}
#[test]
fn test_class_name_helper() {
assert_eq!(
InferredType::ClassName("Foo".into()).class_name(),
Some("Foo")
);
assert_eq!(
InferredType::FirstParam {
package: "Bar".into()
}
.class_name(),
Some("Bar")
);
assert_eq!(InferredType::HashRef.class_name(), None);
assert_eq!(InferredType::ArrayRef.class_name(), None);
assert_eq!(InferredType::CodeRef { return_edge: None }.class_name(), None);
assert_eq!(InferredType::Regexp.class_name(), None);
assert_eq!(InferredType::Numeric.class_name(), None);
assert_eq!(InferredType::String.class_name(), None);
}
fn build_fa_from_source(source: &str) -> FileAnalysis {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
crate::builder::build(&tree, source.as_bytes())
}
#[test]
fn test_qualified_call_local_goto_def_and_references() {
let src = "package Foo;\nsub baz { 42 }\npackage main;\nFoo::baz();\n";
let fa = build_fa_from_source(src);
let def = fa
.find_definition(Point::new(3, 6), None)
.expect("goto-def on Foo::baz() should resolve to the local sub");
assert_eq!(def.start.row, 1, "should land on `sub baz`, got {:?}", def);
let refs = fa.find_references(Point::new(3, 6), None);
assert!(
refs.iter().any(|s| s.start.row == 3),
"references should include the Foo::baz() call site, got {:?}",
refs,
);
assert!(
refs.iter().any(|s| s.start.row == 1),
"references should include the sub baz declaration, got {:?}",
refs,
);
}
#[test]
fn test_fq_method_call_nav_dispatches_from_named_class() {
use crate::file_analysis::{RefKind, RenameKind};
let src = "package Widget;\nsub build { my ($class, $n) = @_; return 1 }\npackage main;\nmy $obj = get_thing();\nmy $r = $obj->Widget::build();\n";
let fa = build_fa_from_source(src);
let mref = fa
.refs
.iter()
.find(|r| matches!(r.kind, RefKind::MethodCall { .. }) && r.target_name == "Widget::build")
.expect("FQ method ref present");
assert_eq!(
fa.method_call_invocant_class(mref, None).as_deref(),
Some("Widget"),
"FQ call dispatches from the named class, not the (untyped) invocant"
);
if let RefKind::MethodCall { method_name_span, .. } = &mref.kind {
assert_eq!(method_name_span.start.column, 22, "rename span is the bare tail");
}
let def = fa
.find_definition(Point::new(4, 22), None)
.expect("FQ method goto-def resolves to the local sub");
assert_eq!(def.start.row, 1, "should land on `sub build`, got {:?}", def);
match fa.rename_kind_at(Point::new(4, 22), None) {
Some(RenameKind::Method { name, class }) => {
assert_eq!(name, "build");
assert_eq!(class, "Widget");
}
other => panic!("expected Method rename, got {:?}", other),
}
}
#[test]
fn test_super_resolves_across_all_parents() {
let src = "package A;\nsub a_only { 1 }\npackage B;\nsub b_only { 2 }\npackage Child;\nuse parent -norequire, 'A', 'B';\nsub go { my $self = shift; return $self->SUPER::b_only }\n";
let fa = build_fa_from_source(src);
assert_eq!(
fa.resolve_super_method("Child", "b_only", None).map(|r| r.class().to_string()).as_deref(),
Some("B"),
"SUPER must search all parents, not just the first"
);
assert_eq!(
fa.resolve_super_method("Child", "a_only", None).map(|r| r.class().to_string()).as_deref(),
Some("A"),
);
assert!(
fa.resolve_super_method("Child", "go", None).is_none(),
"SUPER skips the current package"
);
}
#[test]
fn test_super_method_nav_resolves_to_parent() {
use crate::file_analysis::{RefKind, RenameKind};
let src = "package Base;\nsub greet { return 1 }\npackage Child;\nuse parent -norequire, 'Base';\nsub greet { my $self = shift; return $self->SUPER::greet }\n";
let fa = build_fa_from_source(src);
let sref = fa
.refs
.iter()
.find(|r| matches!(r.kind, RefKind::MethodCall { .. }) && r.target_name == "SUPER::greet")
.expect("SUPER method ref present");
assert_eq!(
fa.method_call_invocant_class(sref, None).as_deref(),
Some("Base"),
"SUPER dispatches to the enclosing package's parent"
);
let span = if let RefKind::MethodCall { method_name_span, .. } = &sref.kind {
*method_name_span
} else {
unreachable!()
};
let def = fa
.find_definition(span.start, None)
.expect("SUPER goto-def resolves");
assert_eq!(def.start.row, 1, "SUPER::greet resolves to Base::greet, got {:?}", def);
match fa.rename_kind_at(span.start, None) {
Some(RenameKind::Method { name, class }) => {
assert_eq!(name, "greet");
assert_eq!(class, "Base");
}
other => panic!("expected Method rename on Base, got {:?}", other),
}
}
#[test]
fn test_qualified_call_does_not_cross_package() {
let src = "package Foo;\nsub baz { 1 }\npackage Bar;\nsub baz { 2 }\npackage main;\nBar::baz();\n";
let fa = build_fa_from_source(src);
let def = fa
.find_definition(Point::new(5, 6), None)
.expect("Bar::baz() should resolve");
assert_eq!(def.start.row, 3, "should land on Bar's sub baz, got {:?}", def);
}
#[test]
fn test_phase5_dynamic_method_call_via_constant_folding() {
let fa = build_fa_from_source(
r#"
package Demo::dyn;
sub get_config { return { host => 'localhost', port => 5432 } }
my $method = 'get_config';
my $c = __PACKAGE__->$method();
my $h = $c->{host};
"#,
);
let def = fa
.symbols
.iter()
.find(|s| {
s.name == "host"
&& s.kind == SymKind::HashKeyDef
&& matches!(&s.detail, SymbolDetail::HashKeyDef {
owner: HashKeyOwner::Sub { name, .. }, ..
} if name == "get_config")
})
.expect("HashKeyDef host owned by get_config");
let indexed = fa.refs_to_symbol(def.id);
assert!(
!indexed.is_empty(),
"dynamic method call via $method='get_config' should link $c->{{host}} back to the def",
);
}
#[test]
fn test_phase5_consumer_side_cross_file_resolves_via_enrichment() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let mut fa = build_fa_from_source(
r#"
use TestExporter qw(get_config);
my $c = get_config();
my $h = $c->{host};
"#,
);
let hka_before = fa
.refs
.iter()
.find(|r| matches!(r.kind, RefKind::HashKeyAccess { .. }) && r.target_name == "host")
.expect("HashKeyAccess host");
assert!(
hka_before.resolves_to.is_none(),
"pre-enrichment, access should not be resolved"
);
let test_exporter_pm = r#"
package TestExporter;
use Exporter 'import';
our @EXPORT_OK = qw(get_config);
sub get_config { return { host => 'h', port => 1, name => 'n' }; }
1;
"#;
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/TestExporter.pm"),
Arc::new(build_fa_from_source(test_exporter_pm)),
);
fa.enrich_imported_types_with_keys(Some(&idx));
let def = fa
.symbols
.iter()
.find(|s| {
s.name == "host"
&& s.kind == SymKind::HashKeyDef
&& matches!(&s.detail, SymbolDetail::HashKeyDef {
owner: HashKeyOwner::Sub { package: None, name }, ..
} if name == "get_config")
})
.expect("synthetic HashKeyDef host");
let indexed = fa.refs_to_symbol(def.id);
assert!(
!indexed.is_empty(),
"consumer-side $c->{{host}} access should link to synthetic HashKeyDef after enrichment, got empty refs_by_target entry",
);
}
#[test]
fn test_phase5_demo_file_shape_resolves_all_access_sites() {
let fa = build_fa_from_source(
r#"
package Demo::phase5;
sub get_config {
return { host => 'localhost', port => 5432, name => 'mydb' };
}
my $c = Demo::phase5::get_config();
my $h1 = $c->{host};
my $h2 = $c->{host};
my $p = $c->{port};
"#,
);
let def_host = fa
.symbols
.iter()
.find(|s| {
s.name == "host"
&& s.kind == SymKind::HashKeyDef
&& matches!(&s.detail, SymbolDetail::HashKeyDef {
owner: HashKeyOwner::Sub { package: Some(p), name }, ..
} if p == "Demo::phase5" && name == "get_config")
})
.expect("Demo::phase5::get_config's HashKeyDef `host`");
let indexed = fa.refs_to_symbol(def_host.id);
assert_eq!(
indexed.len(),
2,
"expected 2 $c->{{host}} accesses to link via refs_by_target, got {} (indexes: {:?})",
indexed.len(),
indexed,
);
let def_port = fa
.symbols
.iter()
.find(|s| {
s.name == "port"
&& s.kind == SymKind::HashKeyDef
&& matches!(&s.detail, SymbolDetail::HashKeyDef {
owner: HashKeyOwner::Sub { name, .. }, ..
} if name == "get_config")
})
.expect("HashKeyDef port");
let port_refs = fa.refs_to_symbol(def_port.id);
assert_eq!(
port_refs.len(),
1,
"expected exactly one $c->{{port}} access"
);
}
#[test]
fn test_phase5_qualified_sub_call_links_hash_key_access() {
let fa = build_fa_from_source(
r#"
package Demo::phase5;
sub get_config { return { host => 'localhost', port => 5432 } }
my $c = Demo::phase5::get_config();
my $h = $c->{host};
"#,
);
let def = fa
.symbols
.iter()
.find(|s| {
s.name == "host"
&& s.kind == SymKind::HashKeyDef
&& matches!(&s.detail, SymbolDetail::HashKeyDef {
owner: HashKeyOwner::Sub { package: Some(p), name }, ..
} if p == "Demo::phase5" && name == "get_config")
})
.expect("HashKeyDef host owned by Sub{Some(Demo::phase5), get_config}");
let indexed = fa.refs_to_symbol(def.id);
assert!(
!indexed.is_empty(),
"qualified call `Demo::phase5::get_config()` should link $c->{{host}} to the def via refs_by_target",
);
}
#[test]
fn test_phase5_hash_key_owner_flows_through_intermediate_sub() {
let fa = build_fa_from_source(
r#"
package Demo::phase5;
sub get_config { return { host => 'localhost', port => 5432 } }
sub chain_helper { return get_config() }
my $c = chain_helper();
my $h = $c->{host};
"#,
);
let def = fa
.symbols
.iter()
.find(|s| {
s.name == "host"
&& s.kind == SymKind::HashKeyDef
&& matches!(&s.detail, SymbolDetail::HashKeyDef {
owner: HashKeyOwner::Sub { name, .. }, ..
} if name == "get_config")
})
.expect("host HashKeyDef owned by get_config");
let indexed = fa.refs_to_symbol(def.id);
assert!(
!indexed.is_empty(),
"chain_helper → get_config delegation should flow $c->{{host}} to get_config's HashKeyDef",
);
}
#[test]
fn test_phase5_hash_key_access_links_to_def_symbol() {
let fa = build_fa_from_source(
r#"
sub get_config { return { host => 'localhost', port => 5432 } }
my $cfg = get_config();
my $h = $cfg->{host};
"#,
);
let def = fa.symbols.iter().find(|s| {
s.name == "host"
&& s.kind == SymKind::HashKeyDef
&& matches!(&s.detail, SymbolDetail::HashKeyDef {
owner: HashKeyOwner::Sub { name, .. }, ..
} if name == "get_config")
});
assert!(
def.is_some(),
"HashKeyDef 'host' owned by Sub get_config should exist"
);
let def_id = def.unwrap().id;
let indexed = fa.refs_to_symbol(def_id);
assert!(
!indexed.is_empty(),
"refs_by_target should index $cfg->{{host}} access under the HashKeyDef, got empty"
);
let access = &fa.refs[indexed[0]];
assert!(matches!(access.kind, RefKind::HashKeyAccess { .. }));
assert_eq!(access.target_name, "host");
}
#[test]
fn test_phase5_find_references_on_hash_key_def_without_tree() {
let fa = build_fa_from_source(
r#"
sub get_config { return { host => 1, port => 2 } }
my $cfg = get_config();
my $h = $cfg->{host};
my $p = $cfg->{port};
"#,
);
let def_host = fa
.symbols
.iter()
.find(|s| {
s.name == "host"
&& s.kind == SymKind::HashKeyDef
&& matches!(&s.detail, SymbolDetail::HashKeyDef {
owner: HashKeyOwner::Sub { name, .. }, ..
} if name == "get_config")
})
.expect("host HashKeyDef");
let point = def_host.selection_span.start;
let refs = fa.find_references(point, None);
assert!(
!refs.is_empty(),
"expected at least one access for 'host' via refs_by_target (no tree), got empty",
);
assert!(
refs.iter().any(|s| s.start.row == 3),
"expected to find the access on row 3 ($cfg->{{host}}), got {:?}",
refs,
);
}
#[test]
fn plugin_mojo_demo_outline_pinned() {
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).expect("plugin_mojo_demo.pl is checked into the repo");
let fa = build_fa_from_source(&src);
let rendered = render_outline(&fa.document_symbols());
let expected = "\
[NAMESPACE] MyApp @L34
[VARIABLE] $app @L44
[FUNCTION] <helper> current_user ($fallback) @L45
[FUNCTION] <helper> users.create ($name, $email) @L52
[FUNCTION] <helper> users.delete ($id) @L56
[FUNCTION] <helper> admin.users.purge ($force) @L62
[VARIABLE] $r @L70
[FUNCTION] <action> Users#list @L71
[FUNCTION] <action> Users#create @L72
[FUNCTION] <action> Admin::Dashboard#index @L73
[FUNCTION] <route> GET /users/profile @L79
[FUNCTION] <route> POST /users/profile ($form_data) @L84
[FUNCTION] <route> ANY /fallback @L90
[VARIABLE] $minion @L105
[FUNCTION] <task> send_email ($to, $subject, $body) @L107
[FUNCTION] <task> resize_image ($path, $width, $height) @L113
[NAMESPACE] MyApp::Progress @L131
[FUNCTION] new @L134
[EVENT] <event> ready ($ctx) @L137
[EVENT] <event> step ($n, $total) @L138
[EVENT] <event> done ($result) @L139
[FUNCTION] tick @L143
";
assert_eq!(
rendered, expected,
"\n---- ACTUAL ----\n{}\n---- EXPECTED ----\n{}",
rendered, expected,
);
}
fn render_outline(items: &[OutlineSymbol]) -> String {
fn walk(o: &OutlineSymbol, depth: usize, buf: &mut String) {
let kind = crate::symbols::outline_lsp_kind(o);
let name = lsp_kind_name(kind);
let pad = " ".repeat(depth);
buf.push_str(&format!(
"{}[{}] {} @L{}\n",
pad,
name,
o.name,
o.span.start.row + 1,
));
for c in &o.children {
walk(c, depth + 1, buf);
}
}
let mut buf = String::new();
for o in items {
walk(o, 0, &mut buf);
}
buf
}
fn lsp_kind_name(k: tower_lsp::lsp_types::SymbolKind) -> &'static str {
use tower_lsp::lsp_types::SymbolKind as K;
match k {
K::FUNCTION => "FUNCTION",
K::METHOD => "METHOD",
K::CLASS => "CLASS",
K::NAMESPACE => "NAMESPACE",
K::MODULE => "MODULE",
K::VARIABLE => "VARIABLE",
K::EVENT => "EVENT",
K::FIELD => "FIELD",
K::PROPERTY => "PROPERTY",
K::CONSTANT => "CONSTANT",
K::KEY => "KEY",
_ => "OTHER",
}
}
#[test]
fn outline_nests_subs_under_their_package() {
let src = "\
sub top_level {}
package Alpha;
sub one {}
sub two {}
package Beta;
sub three {}
";
let fa = build_fa_from_source(src);
let rendered = render_outline(&fa.document_symbols());
let expected = "\
[FUNCTION] top_level @L1
[NAMESPACE] Alpha @L3
[FUNCTION] one @L4
[FUNCTION] two @L5
[NAMESPACE] Beta @L7
[FUNCTION] three @L8
";
assert_eq!(
rendered, expected,
"\n---- ACTUAL ----\n{}\n---- EXPECTED ----\n{}",
rendered, expected,
);
let alpha = fa
.document_symbols()
.into_iter()
.find(|s| s.name == "Alpha")
.expect("Alpha package in outline");
assert!(
alpha.span.end.row >= 4,
"package span should widen to cover nested subs, got end {:?}",
alpha.span.end,
);
}
#[test]
fn use_module_name_emits_package_ref() {
use crate::file_analysis::RefKind;
let fa = build_fa_from_source("use File::Copy;\n");
let r = fa
.ref_at(Point::new(0, 6))
.expect("a ref on the module name 'File::Copy'");
assert!(matches!(r.kind, RefKind::PackageRef), "got {:?}", r.kind);
assert_eq!(r.target_name, "File::Copy");
assert_eq!(fa.find_definition(Point::new(0, 6), None), None);
}
#[test]
fn test_phase5_refs_by_target_index_populated_for_variables() {
let fa = build_fa_from_source(
r#"
my $x = 1;
$x = 2;
my $y = $x + 1;
"#,
);
let x_sym = fa
.symbols
.iter()
.find(|s| s.name == "$x" && s.kind == SymKind::Variable)
.expect("$x variable symbol");
let indexed = fa.refs_to_symbol(x_sym.id);
assert!(
indexed.len() >= 2,
"expected >= 2 refs to $x via refs_by_target, got {}: {:?}",
indexed.len(),
indexed,
);
}
#[test]
fn test_witnesses_mojo_self_stays_class_on_hashref_access() {
let fa = build_fa_from_source(
r#"
package Mojolicious::Routes::Route;
use Mojo::Base -base;
sub name {
my $self = shift;
return $self->{name} unless @_;
$self->{name} = shift;
$self;
}
"#,
);
assert_eq!(
fa.package_framework.get("Mojolicious::Routes::Route"),
Some(&crate::witnesses::FrameworkFact::MojoBase),
"Mojo::Base package should record MojoBase framework"
);
let self_wits: Vec<_> = fa
.witnesses
.all()
.iter()
.filter(|w| {
matches!(&w.attachment,
crate::witnesses::WitnessAttachment::Variable { name, .. } if name == "$self")
})
.collect();
assert!(
!self_wits.is_empty(),
"expected witnesses for $self, got none (bag size: {})",
fa.witnesses.len()
);
let point_in_body = Point { row: 5, column: 15 };
let t = fa.inferred_type_via_bag("$self", point_in_body);
assert!(
matches!(t, Some(InferredType::ClassName(ref n)) if n == "Mojolicious::Routes::Route"),
"via-bag type at body point should be ClassName(Route), got {:?}",
t
);
}
#[test]
fn test_witnesses_fluent_chain_seeds_expression_witnesses() {
let fa = build_fa_from_source(
r#"
package MyApp::Route;
sub get { my $self = shift; return $self; }
sub to { my $self = shift; return $self; }
package main;
my $r = MyApp::Route->new;
$r->get('/x')->to('Y#z');
"#,
);
let expr_wits: Vec<_> = fa
.witnesses
.all()
.iter()
.filter(|w| {
matches!(
w.attachment,
crate::witnesses::WitnessAttachment::Expression(_)
)
})
.collect();
assert!(
expr_wits.len() >= 3,
"expected >= 3 Expression witnesses (new, get, to), got {}",
expr_wits.len()
);
let get_ref_idx = fa
.refs
.iter()
.position(|r| matches!(r.kind, RefKind::MethodCall { .. }) && r.target_name == "get")
.expect("->get ref should exist");
let t = fa.method_call_return_type_via_bag(get_ref_idx, None);
assert!(
matches!(t, Some(InferredType::ClassName(ref n)) if n == "MyApp::Route"),
"->get's return type via bag should be ClassName(MyApp::Route), got {:?}",
t
);
}
#[test]
fn test_witnesses_mutated_keys_on_mojo_class() {
let fa = build_fa_from_source(
r#"
package Mojolicious::Routes::Route;
use Mojo::Base -base;
sub boot {
my $self = shift;
$self->{name} = 'root';
$self->{custom} = 42;
$self->{counter} = 0;
return $self;
}
"#,
);
let mut keys = fa.mutated_keys_on_class("Mojolicious::Routes::Route");
keys.sort();
assert!(
!keys.is_empty(),
"expected some mutated keys for Mojolicious::Routes::Route, got {:?} \
(hash-key witnesses in bag: {})",
keys,
fa.witnesses
.all()
.iter()
.filter(|w| matches!(
&w.attachment,
crate::witnesses::WitnessAttachment::HashKey { .. }
))
.count(),
);
}
#[test]
fn test_wiring_fluent_chain_resolves_through_expression_type() {
let source = r#"
package MyApp::Route;
use Mojo::Base -base;
sub get {
my $self = shift;
$self->{_pattern} = shift;
return $self;
}
sub to {
my $self = shift;
$self->{_target} = shift;
return $self;
}
package main;
my $r = MyApp::Route->new;
$r->get('/x')->to('Y#z');
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let fa = crate::builder::build(&tree, source.as_bytes());
fn find_to_node<'a>(node: tree_sitter::Node<'a>) -> 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(&[]).ok() == Some("to") {
return Some(node);
}
}
}
for i in 0..node.named_child_count() {
if let Some(c) = node.named_child(i) {
if let Some(r) = find_to_node(c) {
return Some(r);
}
}
}
None
}
let source_bytes = source.as_bytes();
let to_node = {
let mut stack: Vec<tree_sitter::Node> = vec![tree.root_node()];
let mut found: Option<tree_sitter::Node> = None;
while let Some(n) = stack.pop() {
if n.kind() == "method_call_expression" {
if let Some(m) = n.child_by_field_name("method") {
if m.utf8_text(source_bytes).ok() == Some("to") {
found = Some(n);
break;
}
}
}
for i in 0..n.named_child_count() {
if let Some(c) = n.named_child(i) {
stack.push(c);
}
}
}
found.expect("found ->to node")
};
let invocant = to_node.child_by_field_name("invocant").expect("invocant");
let ty = crate::cursor_context::resolve_expression_type(&fa, invocant, source_bytes, None);
let class = ty.as_ref().and_then(|t| t.class_name());
assert_eq!(
class,
Some("MyApp::Route"),
"fluent chain: invocant type of `->to` should resolve to \
MyApp::Route (ClassName or FirstParam), got {:?}",
ty
);
let _ = find_to_node; }
#[test]
fn test_wiring_mojo_self_invocant_class_via_public_api() {
let fa = build_fa_from_source(
r#"
package Mojolicious::Routes::Route;
use Mojo::Base -base;
sub name {
my $self = shift;
return $self->{name} unless @_;
$self->{name} = shift;
$self;
}
"#,
);
let point = Point { row: 5, column: 15 };
let scope = fa.scope_at(point).expect("scope");
let resolved = fa.resolve_invocant_class_test("$self", scope, point);
assert_eq!(
resolved.as_deref(),
Some("Mojolicious::Routes::Route"),
"resolve_invocant_class (public path) should see $self as the class, \
not HashRef — got {:?}",
resolved
);
}
#[test]
fn test_witnesses_ternary_return_on_sub_symbol() {
let fa = build_fa_from_source(
r#"
package MyApp::Widget;
sub choose {
my $self = shift;
my $flag = shift;
return $flag ? $self : $self;
}
sub literal_mix {
my ($flag) = @_;
return $flag ? 1 : 2;
}
sub disagree {
my ($flag) = @_;
return $flag ? 1 : "two";
}
"#,
);
let choose = fa
.symbols
.iter()
.find(|s| s.name == "choose" && matches!(s.kind, SymKind::Sub))
.expect("sub choose");
let return_type = fa.symbol_return_type_via_bag(choose.id, None);
assert!(
matches!(&return_type, Some(InferredType::FirstParam { package }) if package == "MyApp::Widget")
|| matches!(&return_type, Some(InferredType::ClassName(n)) if n == "MyApp::Widget"),
"choose's return: ternary arms both $self → class; got {:?}",
return_type
);
let literal = fa
.symbols
.iter()
.find(|s| s.name == "literal_mix" && matches!(s.kind, SymKind::Sub))
.expect("sub literal_mix");
let return_type = fa.symbol_return_type_via_bag(literal.id, None);
assert_eq!(
return_type,
Some(InferredType::Numeric),
"literal_mix: both arms Numeric"
);
let disagree = fa
.symbols
.iter()
.find(|s| s.name == "disagree" && matches!(s.kind, SymKind::Sub))
.expect("sub disagree");
let return_type = fa.symbol_return_type_via_bag(disagree.id, None);
assert_eq!(
return_type, None,
"disagree: arms Numeric vs String → None"
);
}
#[test]
fn test_witnesses_ternary_agreeing_arms_yield_type() {
let fa = build_fa_from_source(
r#"
my $c = 1;
my $n = $c ? 10 : 20;
my $s = $c ? "a" : "b";
my $x = $c ? 10 : "oops";
"#,
);
let p_n = Point { row: 2, column: 22 };
let t_n = fa.inferred_type_via_bag("$n", p_n);
assert_eq!(t_n, Some(InferredType::Numeric), "agreeing numeric arms");
let p_s = Point { row: 3, column: 22 };
let t_s = fa.inferred_type_via_bag("$s", p_s);
assert_eq!(t_s, Some(InferredType::String), "agreeing string arms");
let p_x = Point { row: 4, column: 22 };
let t_x = fa.inferred_type_via_bag("$x", p_x);
assert_eq!(
t_x, None,
"disagreeing arms: bag returns None, legacy has no answer"
);
}
#[test]
fn test_witnesses_if_else_branch_arm_on_sub() {
let fa = build_fa_from_source(
r#"
sub pick {
my $c = shift;
if ($c) { return 10 }
else { return 20 }
}
sub mix {
my $c = shift;
if ($c) { return 10 }
else { return "nope" }
}
"#,
);
let pick_sym = fa
.symbols
.iter()
.find(|s| s.name == "pick" && matches!(s.kind, SymKind::Sub))
.expect("sub pick");
let mix_sym = fa
.symbols
.iter()
.find(|s| s.name == "mix" && matches!(s.kind, SymKind::Sub))
.expect("sub mix");
use crate::witnesses::{
FrameworkFact, ReducedValue, ReducerQuery, ReducerRegistry, WitnessAttachment,
};
let reg = ReducerRegistry::with_defaults();
let att_p = WitnessAttachment::Symbol(pick_sym.id);
let q_p = ReducerQuery {
attachment: &att_p,
point: None,
framework: FrameworkFact::Plain,
arity_hint: None, receiver: None, context: None,
};
assert_eq!(
reg.query(&fa.witnesses, &q_p),
ReducedValue::Type(InferredType::Numeric),
"pick: both arms Numeric → Numeric"
);
let att_m = WitnessAttachment::Symbol(mix_sym.id);
let q_m = ReducerQuery {
attachment: &att_m,
point: None,
framework: FrameworkFact::Plain,
arity_hint: None, receiver: None, context: None,
};
assert_eq!(
reg.query(&fa.witnesses, &q_m),
ReducedValue::None,
"mix: disagreement (Numeric vs String) → None"
);
}
#[test]
fn test_witnesses_arity_exact_n_variants() {
let fa = build_fa_from_source(
r#"
sub postfix_eq {
return "zero" unless @_;
return "one" if @_ == 1;
return "two" if @_ == 2;
return "many";
}
sub postfix_scalar {
return "zero" if !@_;
return "one" if scalar(@_) == 1;
return "dflt";
}
sub explicit_if {
my ($self) = @_;
if (@_ == 1) { return "alpha" }
if (@_ == 2) { return "beta" }
return "gamma";
}
"#,
);
assert_eq!(
fa.sub_return_type_at_arity("postfix_eq", Some(0)),
Some(InferredType::String),
"postfix: arity 0 via `unless @_`"
);
assert_eq!(
fa.sub_return_type_at_arity("postfix_eq", Some(1)),
Some(InferredType::String),
"postfix: arity 1 via `if @_ == 1`"
);
assert_eq!(
fa.sub_return_type_at_arity("postfix_eq", Some(2)),
Some(InferredType::String),
"postfix: arity 2 via `if @_ == 2`"
);
assert_eq!(
fa.sub_return_type_at_arity("postfix_eq", Some(3)),
Some(InferredType::String),
"postfix: arity 3 → default"
);
assert_eq!(
fa.sub_return_type_at_arity("postfix_scalar", Some(0)),
Some(InferredType::String),
"postfix: arity 0 via `if !@_`"
);
assert_eq!(
fa.sub_return_type_at_arity("postfix_scalar", Some(1)),
Some(InferredType::String),
"postfix: arity 1 via `if scalar(@_) == 1`"
);
assert_eq!(
fa.sub_return_type_at_arity("explicit_if", Some(1)),
Some(InferredType::String),
"explicit: arity 1 via if (@_ == 1) return arm"
);
assert_eq!(
fa.sub_return_type_at_arity("explicit_if", Some(2)),
Some(InferredType::String),
"explicit: arity 2"
);
}
#[test]
fn test_witnesses_fluent_arity_dispatch_literal_returns() {
let fa = build_fa_from_source(
r#"
package MyApp::Route;
sub name {
my $self = shift;
return "configured" unless @_;
return 42;
}
"#,
);
let t0 = fa.sub_return_type_at_arity("name", Some(0));
let t1 = fa.sub_return_type_at_arity("name", Some(1));
let td = fa.sub_return_type_at_arity("name", None);
assert_eq!(
t0,
Some(InferredType::String),
"arity=0 fires the `unless @_` branch (String return)"
);
assert_eq!(
t1,
Some(InferredType::Numeric),
"arity=1 falls through to default branch (Numeric return)"
);
assert_eq!(
td,
Some(InferredType::Numeric),
"no arity hint also falls through to default"
);
}
#[test]
fn test_witnesses_plain_hashref_without_class_evidence() {
let fa = build_fa_from_source(
r#"
my $cfg = { host => 'localhost' };
my $host = $cfg->{host};
"#,
);
let point = Point { row: 2, column: 20 };
let t = fa.inferred_type_via_bag("$cfg", point);
assert!(
t.as_ref().is_some_and(|t| t.is_hash_shaped() && t.class_name().is_none()),
"without class evidence, $cfg is hash-shaped and classless: {:?}",
t,
);
}
#[test]
fn red_pin_method_rename_chain_walks_to_defining_ancestor() {
let src = "\
package Animal;
sub speak { return '...' }
sub breathe { return 1 }
package Dog;
our @ISA = ('Animal');
sub speak { return 'Woof!' }
# `breathe` inherited; no override
package main;
my $dog = bless {}, 'Dog';
$dog->breathe();
$dog->speak();
";
let fa = build_fa_from_source(src);
let chain = fa.method_rename_chain("Dog", "breathe", None);
assert_eq!(
chain,
vec!["Dog".to_string(), "Animal".to_string()],
"inherited method: chain must reach the defining ancestor",
);
let chain = fa.method_rename_chain("Dog", "speak", None);
assert_eq!(
chain,
vec!["Dog".to_string()],
"overridden method: chain must stop at the defining (= child) class, \
not include the parent's same-named method",
);
let chain = fa.method_rename_chain("Dog", "nonexistent", None);
assert_eq!(chain, vec!["Dog".to_string()]);
}
#[test]
fn test_camelize_controller_matches_mojo_util() {
let cases = [
("login", "Login"),
("sales_reports", "SalesReports"),
("integrations-ads_api", "Integrations::AdsApi"),
("foo_bar-baz", "FooBar::Baz"),
("users", "Users"),
("FooBar", "FooBar"),
("Foo::Bar", "Foo::Bar"),
("sales_REPORTS", "SalesReports"),
];
for (input, want) in cases {
assert_eq!(
camelize_controller(input),
want,
"camelize_controller({input:?})",
);
}
}
#[test]
fn test_module_tail_matches() {
assert!(module_tail_matches("Clove::Controller::Login", "Login"));
assert!(module_tail_matches(
"App::Controller::Integrations::AdsApi",
"Integrations::AdsApi",
));
assert!(module_tail_matches("Login", "Login"));
assert!(!module_tail_matches("Clove::Controller::AdminLogin", "Login"));
assert!(!module_tail_matches("Clove::Controller::Logins", "Login"));
}
#[test]
fn test_route_controller_token_resolves_cross_file() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let app = build_fa_from_source(
r#"
package Some::App;
use Mojolicious::Lite;
sub startup {
my $self = shift;
$self->routes->get('/users')->to('users#list');
}
1;
"#,
);
let controller = build_fa_from_source(
r#"
package Some::App::Controller::Users;
use Mojo::Base 'Mojolicious::Controller';
sub list { my $c = shift; }
1;
"#,
);
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Some_App_Controller_Users.pm"),
Arc::new(controller),
);
let to_ref = app
.refs
.iter()
.find(|r| {
r.target_name == "list"
&& matches!(&r.kind, RefKind::MethodCall { invocant, .. } if invocant == "users")
})
.expect("plugin emits MethodCall {invocant: users, method: list} for ->to('users#list')");
let class = app.method_call_invocant_class(to_ref, Some(&idx));
assert_eq!(
class.as_deref(),
Some("Some::App::Controller::Users"),
"controller token `users` should camelize + workspace-resolve to the owning controller class",
);
}
#[test]
fn test_partial_route_brand_composes_with_camelize_cross_file() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let app = build_fa_from_source(
r#"
package Clove::App;
use Mojo::Base 'Mojolicious';
sub startup {
my $self = shift;
my $r = $self->routes;
my $alerts_r = $r->any('/alerts')->to('alerts#');
$alerts_r->get('/')->to('#list');
my $crud = $alerts_r->under('/:type')->to('#get_alert');
$crud->get('/settings')->to('#read_settings');
my $billing_r = $r->any('/billing')->to('billing#');
$billing_r->get('/')->to('#index');
}
1;
"#,
);
let alerts = build_fa_from_source(
r#"
package Clove::Controller::Alerts;
use Mojo::Base 'Mojolicious::Controller';
sub list { my $c = shift; }
sub get_alert { my $c = shift; }
sub read_settings { my $c = shift; }
1;
"#,
);
let billing = build_fa_from_source(
r#"
package Clove::Controller::Billing;
use Mojo::Base 'Mojolicious::Controller';
sub index { my $c = shift; }
1;
"#,
);
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Clove_Controller_Alerts.pm"),
Arc::new(alerts),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Clove_Controller_Billing.pm"),
Arc::new(billing),
);
for action in ["list", "get_alert", "read_settings"] {
let to_ref = app
.refs
.iter()
.find(|r| {
r.target_name == action
&& matches!(&r.kind, RefKind::MethodCall { invocant, .. } if invocant == "alerts")
})
.unwrap_or_else(|| {
panic!("partial ->to('#{action}') should emit MethodCall {{invocant: alerts, method: {action}}}")
});
let class = app.method_call_invocant_class(to_ref, Some(&idx));
assert_eq!(
class.as_deref(),
Some("Clove::Controller::Alerts"),
"partial ->to('#{action}') should inherit controller `alerts`, camelize, and resolve cross-file",
);
}
let index_ref = app
.refs
.iter()
.find(|r| {
r.target_name == "index"
&& matches!(&r.kind, RefKind::MethodCall { invocant, .. } if invocant == "billing")
})
.expect("sibling group partial ->to('#index') should inherit `billing`");
assert_eq!(
app.method_call_invocant_class(index_ref, Some(&idx)).as_deref(),
Some("Clove::Controller::Billing"),
"sibling group must re-brand to `billing`, not leak `alerts`",
);
}
#[test]
fn test_route_controller_token_disambiguates_by_ownership() {
use crate::module_index::ModuleIndex;
use std::sync::Arc;
let app = build_fa_from_source(
r#"
package Some::App;
use Mojolicious::Lite;
sub startup {
my $self = shift;
$self->routes->get('/r')->to('reports#monthly');
}
1;
"#,
);
let decoy = build_fa_from_source(
r#"
package Alpha::Controller::Reports;
sub daily { }
1;
"#,
);
let owner = build_fa_from_source(
r#"
package Beta::Controller::Reports;
sub monthly { }
1;
"#,
);
let idx = ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Alpha_Reports.pm"),
Arc::new(decoy),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/Beta_Reports.pm"),
Arc::new(owner),
);
let to_ref = app
.refs
.iter()
.find(|r| {
r.target_name == "monthly"
&& matches!(&r.kind, RefKind::MethodCall { invocant, .. } if invocant == "reports")
})
.expect("MethodCall for ->to('reports#monthly')");
let class = app.method_call_invocant_class(to_ref, Some(&idx));
assert_eq!(
class.as_deref(),
Some("Beta::Controller::Reports"),
"ownership of `monthly` should pick Beta over the Alpha decoy",
);
}
#[test]
fn receiver_gated_applies_on_exact_gate() {
let pp: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
let gated = ReceiverGated::new("Minion", 7u32);
assert_eq!(
gated.resolve_for(Some("Minion"), &pp, None),
GateResult::Applies(&7u32),
);
}
#[test]
fn receiver_gated_applies_through_local_ancestry() {
let mut pp: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
pp.insert("My::Minion".into(), vec!["Minion".into()]);
let gated = ReceiverGated::new("Minion", "payload");
assert_eq!(
gated.resolve_for(Some("My::Minion"), &pp, None),
GateResult::Applies(&"payload"),
);
}
#[test]
fn receiver_gated_does_not_apply_for_unrelated_class() {
let pp: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
let gated = ReceiverGated::new("Minion", 1u8);
assert_eq!(
gated.resolve_for(Some("Some::Other"), &pp, None),
GateResult::DoesNotApply,
);
}
#[test]
fn receiver_gated_untyped_for_unknown_receiver() {
let pp: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
let gated = ReceiverGated::new("Minion", 1u8);
assert_eq!(gated.resolve_for(None, &pp, None), GateResult::ReceiverUntyped);
assert_eq!(gated.resolve_for(Some(""), &pp, None), GateResult::ReceiverUntyped);
}
#[test]
fn export_tags_plain_hash_members_are_exported() {
let fa = build_fa_from_source(
r#"
package M;
our @EXPORT_OK = qw( foo );
our %EXPORT_TAGS = (
all => [ @EXPORT_OK ],
data_conversion => [ qw{ hashify words_from_string } ],
);
sub foo { 1 }
sub hashify { 2 }
sub words_from_string { 3 }
1;
"#,
);
assert!(fa.exports_name("hashify"), "tag member hashify should export");
assert!(fa.exports_name("words_from_string"));
assert!(fa.exports_name("foo"));
assert!(!fa.exports_name("data_conversion"));
assert!(!fa.exports_name("all"));
}
#[test]
fn export_tags_readonly_wrapped_members_are_exported() {
let fa = build_fa_from_source(
r#"
package M;
Readonly::Array our @EXPORT_OK => qw( interpolate );
Readonly::Hash our %EXPORT_TAGS => (
all => [ @EXPORT_OK ],
data_conversion => [ qw{ hashify words_from_string interpolate } ],
);
sub interpolate { 1 }
sub hashify { 2 }
sub words_from_string { 3 }
1;
"#,
);
assert!(fa.exports_name("hashify"), "Readonly-wrapped tag member should export");
assert!(fa.exports_name("words_from_string"));
assert!(fa.exports_name("interpolate"));
assert!(!fa.exports_name("data_conversion"));
assert!(!fa.exports_name("all"));
}
#[test]
fn name_in_no_export_or_tag_is_not_exported() {
let fa = build_fa_from_source(
r#"
package M;
our @EXPORT_OK = qw( foo );
our %EXPORT_TAGS = (
data_conversion => [ qw{ hashify } ],
);
sub foo { 1 }
sub hashify { 2 }
sub private_helper { 3 }
1;
"#,
);
assert!(fa.exports_name("hashify"));
assert!(fa.exports_name("foo"));
assert!(!fa.exports_name("private_helper"), "non-exported sub must stay unexported");
}
#[test]
fn cross_file_slot_write_types_the_read() {
use std::sync::Arc;
let idx = crate::module_index::ModuleIndex::new_for_test();
let parent_src = "package Parent;\nsub init {\n my ($self) = @_;\n $self->{conn} = My::Conn->new;\n}\n1;\n";
{
let mut parser = crate::builder::create_parser();
let tree = parser.parse(parent_src, None).unwrap();
let fa = crate::builder::build(&tree, parent_src.as_bytes());
idx.insert_cache(
"Parent",
Some(Arc::new(crate::file_analysis::CachedModule::new(
std::path::PathBuf::from("/fake/Parent.pm"),
Arc::new(fa),
))),
);
}
let child_src = "package Child;\nuse Moo;\nextends 'Parent';\nsub go {\n my ($self) = @_;\n my $x = $self->{conn};\n}\n1;\n";
let mut parser = crate::builder::create_parser();
let tree = parser.parse(child_src, None).unwrap();
let fa = crate::builder::build(&tree, child_src.as_bytes());
let read_span = fa
.refs
.iter()
.find(|r| {
matches!(r.kind, RefKind::HashKeyAccess { .. })
&& r.target_name == "conn"
&& r.span.start.row == 5
})
.map(|r| r.span)
.expect("read-site hash key ref");
let t = fa.expr_type_at_span(
Span { start: Point { row: 5, column: 12 }, end: Point { row: 5, column: 25 } },
Some(&idx),
);
assert_eq!(
t,
Some(InferredType::ClassName("My::Conn".into())),
"read at {read_span:?} should narrow via the parent file's slot write",
);
let t2 = fa.inferred_type_via_bag_ctx(
"$x",
Point { row: 6, column: 0 },
Some(&idx),
);
assert_eq!(t2, Some(InferredType::ClassName("My::Conn".into())));
}
#[test]
fn loader_config_types_register_conf_cross_file() {
use std::sync::Arc;
let idx = crate::module_index::ModuleIndex::new_for_test();
let app_src = "use Mojolicious::Lite;\nplugin 'CloveApp', { minion => 1, redis => 'r' };\napp->start;\n";
{
let mut parser = crate::builder::create_parser();
let tree = parser.parse(app_src, None).unwrap();
let fa = crate::builder::build(&tree, app_src.as_bytes());
assert!(
fa.plugin_loads.iter().any(|f| f.name == "CloveApp" && f.config_span.is_some()),
"the lite plugin arm should record the loader fact: {:?}",
fa.plugin_loads,
);
idx.register_workspace_module(
std::path::PathBuf::from("/fake/conf/app.pl.pm-shim"),
Arc::new(fa),
);
}
let app_src2 = "package MyApp::Boot;\nuse Mojolicious::Lite;\nplugin 'CloveApp', { minion => 1, redis => 'r' };\n1;\n";
{
let mut parser = crate::builder::create_parser();
let tree = parser.parse(app_src2, None).unwrap();
let fa = crate::builder::build(&tree, app_src2.as_bytes());
idx.insert_cache(
"MyApp::Boot",
Some(Arc::new(crate::file_analysis::CachedModule::new(
std::path::PathBuf::from("/fake/conf/Boot.pm"),
Arc::new(fa),
))),
);
}
let plugin_src = "package Mojolicious::Plugin::CloveApp;\nuse Mojo::Base 'Mojolicious::Plugin';\n\nsub register {\n my ($self, $app, $conf) = @_;\n}\n1;\n";
let mut parser = crate::builder::create_parser();
let tree = parser.parse(plugin_src, None).unwrap();
let mut fa = crate::builder::build(&tree, plugin_src.as_bytes());
assert!(
!fa.loader_config_params.is_empty(),
"the param_types from_loader_config rule should mint a marker",
);
fa.enrich_imported_types_with_keys(Some(&idx));
let t = fa.inferred_type_via_bag("$conf", Point { row: 5, column: 0 });
match t {
Some(InferredType::HashWithKeys { keys, .. }) => {
let mut names: Vec<&str> = keys.iter().map(|(k, _)| k.as_str()).collect();
names.sort();
assert_eq!(names, vec!["minion", "redis"]);
}
other => panic!("expected the gathered config shape, got {other:?}"),
}
}