use super::*;
use tree_sitter::Point;
fn fa_with_constraints(constraints: Vec<TypeConstraint>) -> FileAnalysis {
let mut fa = FileAnalysis::new(
vec![Scope {
id: ScopeId(0),
parent: None,
kind: ScopeKind::File,
span: Span {
start: Point::new(0, 0),
end: Point::new(10, 0),
},
package: None,
}],
vec![],
vec![],
vec![],
vec![],
vec![],
HashMap::new(),
vec![],
HashSet::new(),
vec![],
vec![],
vec![],
HashMap::new(),
HashMap::new(),
vec![],
);
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(
vec![Scope {
id: ScopeId(0),
parent: None,
kind: ScopeKind::File,
span: Span {
start: Point::new(0, 0),
end: Point::new(10, 0),
},
package: None,
}],
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,
},
namespace: Namespace::Language,
outline_label: None,
}],
vec![],
vec![],
vec![],
vec![],
HashMap::new(),
vec![],
HashSet::new(),
vec![],
vec![],
vec![],
HashMap::new(),
HashMap::new(),
vec![],
);
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_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, None, 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
[MODULE] use strict @L35
[MODULE] use warnings @L36
[MODULE] use Mojolicious::Lite @L37
[MODULE] use Mojolicious @L38
[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
[MODULE] use Minion @L104
[VARIABLE] $minion @L105
[FUNCTION] <task> send_email ($to, $subject, $body) @L107
[FUNCTION] <task> resize_image ($path, $width, $height) @L113
[NAMESPACE] MyApp::Progress @L131
[MODULE] use parent @L132
[FUNCTION] new @L134
[VARIABLE] $class @L135
[VARIABLE] $self @L136
[EVENT] <event> ready ($ctx) @L137
[EVENT] <event> step ($n, $total) @L138
[EVENT] <event> done ($result) @L139
[FUNCTION] tick @L143
[VARIABLE] $self @L144
[VARIABLE] $n @L144
";
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 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 = fa.resolve_expression_type(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_eq!(
t,
Some(InferredType::HashRef),
"without class evidence, $cfg is plain HashRef"
);
}
#[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()]);
}