use super::*;
use crate::builder;
fn parse_analysis(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();
builder::build(&tree, source.as_bytes())
}
fn fake_cached(
path: &str,
exports: &[&str],
exports_ok: &[&str],
) -> std::sync::Arc<crate::module_index::CachedModule> {
let mut source = String::from("package Fake;\n");
if !exports.is_empty() {
source.push_str(&format!("our @EXPORT = qw({});\n", exports.join(" ")));
}
if !exports_ok.is_empty() {
source.push_str(&format!("our @EXPORT_OK = qw({});\n", exports_ok.join(" ")));
}
for n in exports.iter().chain(exports_ok.iter()) {
source.push_str(&format!("sub {} {{}}\n", n));
}
source.push_str("1;\n");
std::sync::Arc::new(crate::module_index::CachedModule::new(
std::path::PathBuf::from(path),
std::sync::Arc::new(parse_analysis(&source)),
))
}
#[test]
fn test_builtins_sorted() {
for window in PERL_BUILTINS.windows(2) {
assert!(
window[0] < window[1],
"PERL_BUILTINS not sorted: '{}' >= '{}'",
window[0],
window[1],
);
}
}
#[test]
fn test_is_perl_builtin() {
assert!(is_perl_builtin("print"));
assert!(is_perl_builtin("chomp"));
assert!(is_perl_builtin("die"));
assert!(!is_perl_builtin("frobnicate"));
assert!(!is_perl_builtin("my_custom_sub"));
}
#[test]
fn test_diagnostics_skips_builtins() {
let source = "use Carp qw(croak);\nprint 'hello';\ndie 'oops';\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert!(
diags.is_empty(),
"Expected no diagnostics for builtins/imported, got: {:?}",
diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
);
}
#[test]
fn test_diagnostics_unresolved_function() {
let source = "frobnicate();\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::HINT));
assert!(diags[0].message.contains("frobnicate"));
}
#[test]
fn unresolved_dispatch_fires_only_when_enabled_and_only_on_untyped_receiver() {
use crate::symbols::DiagnosticOptions;
let source = "package W;\nsub fire {\n my ($self, $minion) = @_;\n $minion->enqueue('send_email');\n}\n1;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let default_diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert!(
!default_diags.iter().any(|d|
matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-dispatch")),
"unresolved-dispatch must be off by default; got {:?}",
default_diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
);
let on = DiagnosticOptions { unresolved_dispatch: true };
let diags = collect_diagnostics(&analysis, &module_index, on);
let dispatch_diags: Vec<_> = diags.iter().filter(|d|
matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-dispatch")).collect();
assert_eq!(
dispatch_diags.len(), 1,
"expected one unresolved-dispatch on the untyped receiver; got {:?}",
diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
);
}
#[test]
fn unresolved_dispatch_silent_on_does_not_apply() {
use crate::symbols::DiagnosticOptions;
let source = "package W;\nsub fire {\n my $x = Some::Other->new;\n $x->enqueue('send_email');\n}\npackage Some::Other;\nsub new { bless {}, shift }\nsub enqueue { 1 }\n1;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let on = DiagnosticOptions { unresolved_dispatch: true };
let diags = collect_diagnostics(&analysis, &module_index, on);
assert!(
!diags.iter().any(|d|
matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-dispatch")),
"DoesNotApply (typed, unrelated receiver) must never diagnose; got {:?}",
diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
);
}
#[test]
fn test_diagnostics_skips_local_sub() {
let source = "sub helper { 1 }\nhelper();\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert!(
diags.is_empty(),
"Locally defined sub should not produce diagnostic, got: {:?}",
diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
);
}
#[test]
fn test_diagnostics_skips_package_qualified() {
let source = "Foo::Bar::baz();\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert!(
diags.is_empty(),
"Package-qualified calls should not produce diagnostic",
);
}
#[test]
fn test_diagnostics_no_unresolved_for_not_operator() {
let source = "my $x = 1;\nmy $y = not $x;\nif (not $x) { }\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let not_diags: Vec<_> = diags.iter().filter(|d| d.message.contains("not")).collect();
assert!(
not_diags.is_empty(),
"`not` should not produce an unresolved-function diagnostic; got: {:?}",
not_diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
);
}
#[test]
fn test_diagnostics_no_unresolved_for_super_method() {
let source = r#"
package Animal;
sub speak { "..." }
package Dog;
use parent -norequire, 'Animal';
sub speak {
my ($self) = @_;
my $parent = $self->SUPER::speak();
return "Woof! $parent";
}
1;
"#;
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let super_diags: Vec<_> = diags
.iter()
.filter(|d| d.message.contains("SUPER"))
.collect();
assert!(
super_diags.is_empty(),
"`SUPER::speak` should not produce an unresolved-method diagnostic; got: {:?}",
super_diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
);
}
#[test]
fn test_code_action_from_diagnostic() {
let source = "use Carp qw(croak);\ncarp('oops');\n";
let analysis = parse_analysis(source);
let uri = Url::parse("file:///test.pl").unwrap();
let diag = Diagnostic {
range: Range {
start: Position {
line: 1,
character: 0,
},
end: Position {
line: 1,
character: 4,
},
},
severity: Some(DiagnosticSeverity::HINT),
code: Some(NumberOrString::String("unresolved-function".into())),
source: Some("perl-lsp".into()),
message: "'carp' is exported by Carp but not imported".into(),
data: Some(serde_json::json!({"module": "Carp", "function": "carp"})),
..Default::default()
};
let actions = code_actions(&[diag], &analysis, &uri);
assert_eq!(actions.len(), 1);
if let CodeActionOrCommand::CodeAction(action) = &actions[0] {
assert_eq!(action.title, "Import 'carp' from Carp");
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
assert_eq!(action.is_preferred, Some(true));
let edit = action.edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let text_edits = changes.get(&uri).unwrap();
assert_eq!(text_edits.len(), 1);
assert_eq!(text_edits[0].new_text, " carp");
} else {
panic!("Expected CodeAction, got Command");
}
}
#[test]
fn test_code_action_new_use_statement() {
let source = "use strict;\nuse warnings;\nfrobnicate();\n";
let analysis = parse_analysis(source);
let uri = Url::parse("file:///test.pl").unwrap();
let diag = Diagnostic {
range: Range {
start: Position {
line: 2,
character: 0,
},
end: Position {
line: 2,
character: 11,
},
},
severity: Some(DiagnosticSeverity::HINT),
code: Some(NumberOrString::String("unresolved-function".into())),
source: Some("perl-lsp".into()),
message: "'frobnicate' is exported by Some::Module (not yet imported)".into(),
data: Some(serde_json::json!({
"modules": ["Some::Module"],
"function": "frobnicate",
})),
..Default::default()
};
let actions = code_actions(&[diag], &analysis, &uri);
assert_eq!(actions.len(), 1);
if let CodeActionOrCommand::CodeAction(action) = &actions[0] {
assert_eq!(action.title, "Add 'use Some::Module qw(frobnicate)'");
assert_eq!(action.is_preferred, Some(true));
let edit = action.edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let text_edits = changes.get(&uri).unwrap();
assert_eq!(text_edits[0].new_text, "use Some::Module qw(frobnicate);\n");
assert_eq!(text_edits[0].range.start.line, 2);
} else {
panic!("Expected CodeAction");
}
}
#[test]
fn test_unimported_completion_with_auto_import() {
let source = "use strict;\nuse warnings;\n\nfir\n";
let analysis = parse_analysis(source);
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"List::Util",
Some(fake_cached(
"/usr/lib/perl5/List/Util.pm",
&[],
&["first", "max", "min"],
)),
);
let tree = crate::document::Document::new(source.to_string())
.unwrap()
.tree;
let items = completion_items(
&analysis,
&tree,
source,
Position {
line: 3,
character: 3,
},
&idx,
None,
);
let first_item = items.iter().find(|i| i.label == "first");
assert!(
first_item.is_some(),
"Should offer 'first' from unimported List::Util"
);
let first_item = first_item.unwrap();
assert!(
first_item.detail.as_ref().unwrap().contains("List::Util"),
"Detail should mention the module"
);
assert!(
first_item.detail.as_ref().unwrap().contains("auto-import"),
"Detail should indicate auto-import"
);
let edits = first_item.additional_text_edits.as_ref().unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "use List::Util qw(first);\n");
assert_eq!(edits[0].range.start.line, 2);
}
#[test]
fn test_unimported_completion_skips_imported_modules() {
let source = "use List::Util qw(max);\nfir\n";
let analysis = parse_analysis(source);
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(None);
idx.insert_cache(
"List::Util",
Some(fake_cached(
"/usr/lib/perl5/List/Util.pm",
&[],
&["first", "max", "min"],
)),
);
idx.insert_cache(
"Scalar::Util",
Some(fake_cached(
"/usr/lib/perl5/Scalar/Util.pm",
&[],
&["blessed", "reftype"],
)),
);
let tree = crate::document::Document::new(source.to_string())
.unwrap()
.tree;
let items = completion_items(
&analysis,
&tree,
source,
Position {
line: 1,
character: 3,
},
&idx,
None,
);
let first_items: Vec<_> = items.iter().filter(|i| i.label == "first").collect();
assert!(!first_items.is_empty(), "Should offer 'first'");
for item in &first_items {
if let Some(ref detail) = item.detail {
assert!(
!detail.contains("auto-import") || detail.contains("List::Util"),
"first should come from List::Util context"
);
}
}
let blessed_item = items.iter().find(|i| i.label == "blessed");
assert!(
blessed_item.is_some(),
"Should offer 'blessed' from unimported Scalar::Util"
);
let blessed_item = blessed_item.unwrap();
assert!(blessed_item
.detail
.as_ref()
.unwrap()
.contains("Scalar::Util"));
let edits = blessed_item.additional_text_edits.as_ref().unwrap();
assert!(edits[0].new_text.contains("use Scalar::Util qw(blessed)"));
}
#[test]
fn test_qualified_path_completion_narrows_to_package() {
let source = "\
package Math::Util;
use constant PI => 3.14159;
sub square { my ($n) = @_; $n * $n }
sub cube { my ($n) = @_; $n * $n * $n }
sub circle_area {
my ($r) = @_;
return PI * $r * $r; # const-folded arg flows through
}
package main;
use constant TAU => Math::Util::PI() * 2;
my $sq = Math::Util::s
";
let analysis = parse_analysis(source);
let module_index = ModuleIndex::new_for_test();
module_index.set_workspace_root(None);
module_index.insert_cache(
"Scalar::Util",
Some(fake_cached(
"/usr/lib/perl5/Scalar/Util.pm",
&[],
&["blessed", "reftype"],
)),
);
let tree = crate::document::Document::new(source.to_string())
.unwrap()
.tree;
let line_text = source.lines().nth(10).unwrap();
let cursor_col = line_text.len() as u32;
let items = completion_items(
&analysis,
&tree,
source,
Position { line: 10, character: cursor_col },
&module_index,
None,
);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"square"),
"completion after `Math::Util::` should include `square`, got {:?}",
labels,
);
assert!(
labels.contains(&"cube"),
"completion after `Math::Util::` should include `cube`, got {:?}",
labels,
);
assert!(
labels.contains(&"circle_area"),
"completion after `Math::Util::` should include `circle_area` \
(the const-folded sub), got {:?}",
labels,
);
assert!(
!labels.contains(&"blessed"),
"completion after `Math::Util::` must NOT flood unrelated workspace \
symbols (`blessed` is from Scalar::Util), got {:?}",
labels,
);
assert!(
items.len() <= 20,
"completion after `Math::Util::` should narrow tightly to the package; \
got {} items: {:?}",
items.len(),
labels,
);
}
#[test]
fn test_qualified_path_completion_offers_sub_packages() {
let source = "\
package Math::Util;
sub square { my ($n) = @_; $n * $n }
package Math::Helpers; # in-file sub-package, no module index entry
sub clamp { my ($x, $lo, $hi) = @_; $x }
package main;
my $x = Math::
";
let analysis = parse_analysis(source);
let module_index = ModuleIndex::new_for_test();
module_index.set_workspace_root(None);
module_index.insert_cache(
"Math::Stats",
Some(fake_cached("/usr/lib/perl5/Math/Stats.pm", &[], &["mean", "stddev"])),
);
module_index.insert_cache(
"Scalar::Util",
Some(fake_cached(
"/usr/lib/perl5/Scalar/Util.pm",
&[],
&["blessed", "reftype"],
)),
);
let tree = crate::document::Document::new(source.to_string())
.unwrap()
.tree;
let last_line_idx = source.lines().count() as u32 - 1;
let line_text = source.lines().last().unwrap();
let items = completion_items(
&analysis,
&tree,
source,
Position { line: last_line_idx, character: line_text.len() as u32 },
&module_index,
None,
);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"Util"),
"`Math::` should offer in-file sub-package `Util`, got {:?}",
labels,
);
assert!(
labels.contains(&"Helpers"),
"`Math::` should offer in-file sub-package `Helpers`, got {:?}",
labels,
);
assert!(
labels.contains(&"Stats"),
"`Math::` should offer cross-file sub-package `Stats`, got {:?}",
labels,
);
assert!(
!labels.contains(&"Scalar::Util") && !labels.contains(&"blessed"),
"`Math::` must NOT bleed unrelated workspace symbols, got {:?}",
labels,
);
for item in &items {
if matches!(item.label.as_str(), "Util" | "Helpers" | "Stats") {
assert_eq!(
item.kind,
Some(tower_lsp::lsp_types::CompletionItemKind::MODULE),
"sub-package `{}` should be SymbolKind::MODULE",
item.label,
);
}
}
}
#[test]
#[ignore = "needs ClassName-from-string-invocant inference; tracked separately"]
fn test_const_folded_package_resolves_for_method_completion() {
let source = "\
package Math::Util;
sub square { my ($n) = @_; $n * $n }
sub cube { my ($n) = @_; $n * $n * $n }
package main;
my $pkg = 'Math::Util';
$pkg->squ
";
let analysis = parse_analysis(source);
let module_index = ModuleIndex::new_for_test();
module_index.set_workspace_root(None);
let tree = crate::document::Document::new(source.to_string())
.unwrap()
.tree;
let line_text = source.lines().nth(5).unwrap();
let cursor_col = line_text.len() as u32;
let items = completion_items(
&analysis,
&tree,
source,
Position { line: 5, character: cursor_col },
&module_index,
None,
);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"square"),
"method completion on const-folded `$pkg` (= 'Math::Util') \
should offer `square`, got {:?}",
labels,
);
assert!(
labels.contains(&"cube"),
"method completion on const-folded `$pkg` (= 'Math::Util') \
should offer `cube`, got {:?}",
labels,
);
}
#[test]
fn test_document_symbol_name_is_bare_identifier() {
let source = "\
package Demo::Symbols;
sub alpha { return 1 }
sub beta { my ($x) = @_; $x * 2 }
1;
";
let analysis = parse_analysis(source);
let names: Vec<String> = analysis
.document_symbols()
.iter()
.flat_map(|sym| {
let mut acc = vec![sym.name.clone()];
for c in &sym.children {
acc.push(c.name.clone());
}
acc
})
.collect();
assert!(
names.iter().any(|n| n == "alpha"),
"expected bare 'alpha' in document symbols, got {:?}",
names,
);
assert!(
names.iter().any(|n| n == "beta"),
"expected bare 'beta' in document symbols, got {:?}",
names,
);
for n in &names {
assert!(
!n.starts_with("<sub>") && !n.starts_with("<method>"),
"DocumentSymbol.name should not carry `<sub>`/`<method>` prefix (got {:?})",
n,
);
}
}
#[test]
fn test_hover_on_builtin_uses_module_index() {
let source = "push @items, 4;\n";
let analysis = parse_analysis(source);
let module_index = ModuleIndex::new_for_test();
module_index.set_workspace_root(None);
module_index.seed_builtin_for_test(
"push",
"```perl\npush ARRAY,LIST\n```\n\nAppends LIST to ARRAY.",
);
let _tree = crate::document::Document::new(source.to_string())
.unwrap()
.tree;
let hover = hover_info(
&analysis,
source,
Position { line: 0, character: 0 },
&module_index,
)
.expect("expected hover on `push`");
let text = match hover.contents {
HoverContents::Markup(m) => m.value,
_ => panic!("expected markdown hover"),
};
assert!(text.contains("push ARRAY,LIST"), "hover body missing: {text}");
assert!(text.contains("Appends LIST"), "hover body missing: {text}");
}
#[test]
fn test_code_action_multiple_exporters_not_preferred() {
let source = "use strict;\nfirst();\n";
let analysis = parse_analysis(source);
let uri = Url::parse("file:///test.pl").unwrap();
let diag = Diagnostic {
range: Range {
start: Position {
line: 1,
character: 0,
},
end: Position {
line: 1,
character: 5,
},
},
severity: Some(DiagnosticSeverity::HINT),
code: Some(NumberOrString::String("unresolved-function".into())),
source: Some("perl-lsp".into()),
message: "...".into(),
data: Some(serde_json::json!({
"modules": ["List::Util", "List::MoreUtils"],
"function": "first",
})),
..Default::default()
};
let actions = code_actions(&[diag], &analysis, &uri);
assert_eq!(actions.len(), 2);
for action in &actions {
if let CodeActionOrCommand::CodeAction(a) = action {
assert_eq!(a.is_preferred, Some(false));
}
}
}
#[test]
fn sig_help_returns_handler_params_for_emit() {
let src = r#"package My::Emitter;
use parent 'Mojo::EventEmitter';
sub register {
my $self = shift;
$self->on('ready', sub {
my ($self_in, $msg, $when) = @_;
warn $msg;
});
}
sub fire {
my $self = shift;
$self->emit('ready', 'hi', )
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let pos = {
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->emit('ready'"))
.unwrap();
let col = line.find(", )").unwrap() + 2;
Position {
line: line_idx as u32,
character: col as u32,
}
};
let sig = signature_help(&analysis, &tree, src, pos, &idx)
.expect("sig help should surface handler sig");
assert_eq!(sig.signatures.len(), 1, "one registered handler");
let s = &sig.signatures[0];
assert!(
s.label.starts_with("emit('ready'"),
"label should show the call shape starting with emit('ready'): {}",
s.label
);
if let Some(Documentation::String(ref d)) = s.documentation {
assert!(
d.contains("My::Emitter"),
"doc should name the owning class: {}",
d
);
} else {
panic!("expected Documentation::String, got {:?}", s.documentation);
}
let params = s.parameters.as_ref().expect("has params");
assert_eq!(params.len(), 2, "drops implicit $self_in");
assert!(matches!(¶ms[0].label, ParameterLabel::Simple(s) if s == "$msg"));
assert!(matches!(¶ms[1].label, ParameterLabel::Simple(s) if s == "$when"));
}
#[test]
fn sig_help_stacks_multiple_handler_defs() {
let src = r#"package My::Emitter;
use parent 'Mojo::EventEmitter';
sub new {
my $self = bless {}, shift;
$self->on('tick', sub {
my ($self_in, $count) = @_;
});
$self->on('tick', sub {
my ($self_in, $count, $unit) = @_;
});
$self;
}
sub go {
my $self = shift;
$self->emit('tick', )
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let pos = {
let line_idx = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->emit('tick'"))
.map(|(i, _)| i)
.unwrap();
let line = src.lines().nth(line_idx).unwrap();
let col = line.find(", )").unwrap() + 2;
Position {
line: line_idx as u32,
character: col as u32,
}
};
let sig = signature_help(&analysis, &tree, src, pos, &idx).expect("sig help should fire");
assert_eq!(
sig.signatures.len(),
2,
"stacked handlers: one signature per ->on call"
);
let labels: Vec<&str> = sig.signatures.iter().map(|s| s.label.as_str()).collect();
assert!(
labels.iter().all(|l| l.starts_with("emit('tick'")),
"every signature uses emit('tick', ...) call shape: {:?}",
labels
);
}
#[test]
fn sig_help_fires_in_empty_second_slot() {
let src = r#"package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire {
my $self = shift;
$self->on('connect', sub {
my ($self_in, $sock, $remote_ip) = @_;
});
}
sub fire {
my $self = shift;
$self->emit('connect', );
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->emit('connect'"))
.unwrap();
let col = line.find(", )").unwrap() + 2; let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let sig = signature_help(&analysis, &tree, src, pos, &idx)
.expect("empty arg slot after comma should offer handler sig");
let s = &sig.signatures[0];
assert!(
s.label.starts_with("emit('connect'"),
"baseline: label identifies emit handler call: {}",
s.label
);
}
#[test]
fn sig_help_follows_const_folding_like_hover_does() {
let src = r#"package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire {
my $self = shift;
$self->on('connect', sub {
my ($self_in, $sock, $remote_ip) = @_;
});
}
sub fire {
my $self = shift;
my $dynamic = 'connect';
$self->emit($dynamic, 'hi', );
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->emit($dynamic"))
.unwrap();
let col = line.find(", )").unwrap() + 2;
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let sig = signature_help(&analysis, &tree, src, pos, &idx)
.expect("sig help must follow const folding like hover does");
let s = &sig.signatures[0];
assert!(
s.label.starts_with("emit('connect'"),
"const-folded: $dynamic → 'connect' → emit('connect', ...) label; got: {}",
s.label
);
let params = s.parameters.as_ref().unwrap();
assert_eq!(
params.len(),
2,
"$sock, $remote_ip (implicit $self_in dropped)"
);
}
#[test]
fn sig_help_fires_from_inside_second_string_arg() {
let src = r#"package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire {
my $self = shift;
$self->on('connect', sub {
my ($self_in, $sock, $remote_ip) = @_;
});
}
sub fire {
my $self = shift;
$self->emit('connect', 'soc' );
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->emit('connect', 'soc'"))
.unwrap();
let col = line.find("'soc'").unwrap() + 2; let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let sig = signature_help(&analysis, &tree, src, pos, &idx)
.expect("cursor in 2nd string arg should still surface handler sig");
let s = &sig.signatures[0];
assert!(
s.label.starts_with("emit('connect'"),
"label should still be the emit(handler) form: {}",
s.label
);
let params = s.parameters.as_ref().unwrap();
assert_eq!(params.len(), 2, "handler params ($sock, $remote_ip)");
}
#[test]
fn completion_offers_handler_names_at_dispatch_arg0() {
let src = r#"package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire {
my $self = shift;
$self->on('connect', sub { my ($s, $sock, $ip) = @_; });
$self->on('disconnect', sub { my ($s) = @_; });
}
sub fire {
my $self = shift;
$self->emit();
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->emit()"))
.unwrap();
let col = line.find("emit(").unwrap() + "emit(".len();
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let items = completion_items(&analysis, &tree, src, pos, &idx, None);
let connect = items
.iter()
.find(|i| i.label == "connect")
.expect("connect handler should be offered at emit arg-0");
let disconnect = items
.iter()
.find(|i| i.label == "disconnect")
.expect("disconnect handler should be offered at emit arg-0");
assert_eq!(
connect.kind,
Some(CompletionItemKind::EVENT),
"handler completion kind is EVENT (matches outline)"
);
assert_eq!(
connect.insert_text.as_deref(),
Some("'connect'"),
"insert should include quotes so the user doesn't type them"
);
assert!(
connect
.detail
.as_deref()
.unwrap_or("")
.contains("My::Emitter"),
"detail should name the owning class: {:?}",
connect.detail
);
assert!(
connect.detail.as_deref().unwrap_or("").contains("$sock"),
"detail should expose handler params: {:?}",
connect.detail
);
assert!(
connect
.sort_text
.as_deref()
.unwrap_or("zzz")
.starts_with(' '),
"handler sort should lead with space to outrank digit-prefixed sort_text: {:?}",
connect.sort_text
);
assert!(disconnect
.sort_text
.as_deref()
.unwrap_or("zzz")
.starts_with(' '));
}
#[test]
fn completion_dispatch_filter_text_matches_bare_name() {
let src = r#"
package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire { my $self = shift; $self->on('connect', sub {}); }
sub fire { my $self = shift; $self->emit(); }
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->emit()"))
.unwrap();
let col = line.find("emit(").unwrap() + "emit(".len();
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let items = completion_items(&analysis, &tree, src, pos, &idx, None);
let connect = items
.iter()
.find(|i| i.label == "connect")
.expect("connect handler offered");
assert_eq!(
connect.filter_text.as_deref(),
Some("connect"),
"filter_text must be the bare label, not the quoted insert_text"
);
assert_eq!(
connect.insert_text.as_deref(),
Some("'connect'"),
"insert_text still quotes for the bare-parens case"
);
}
#[test]
fn completion_dispatch_inside_quotes_does_not_double_quote() {
let src = r#"
package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire {
my $self = shift;
$self->on('connect', sub { my ($s) = @_; });
}
sub fire {
my $self = shift;
$self->emit('');
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->emit('')"))
.unwrap();
let col = line.find("('").unwrap() + 2;
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let items = completion_items(&analysis, &tree, src, pos, &idx, None);
let connect = items
.iter()
.find(|i| i.label == "connect")
.expect("connect handler offered inside '|'");
assert_eq!(
connect.insert_text, None,
"cursor is inside quotes; insert_text is cleared in favor of textEdit"
);
use tower_lsp::lsp_types::CompletionTextEdit;
let Some(CompletionTextEdit::Edit(ref te)) = connect.text_edit else {
panic!(
"expected a TextEdit for mid-string dispatch item; got {:?}",
connect.text_edit
);
};
assert_eq!(
te.new_text, "connect",
"textEdit.newText is the bare label — not `'connect'` (would double-quote inside '|')"
);
}
#[test]
fn completion_dispatch_textedit_handles_non_keyword_labels() {
use crate::module_index::ModuleIndex;
use tower_lsp::lsp_types::CompletionTextEdit;
let app_src = r#"package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#list');
get '/users/profile' => sub { my ($c) = @_; };
"#;
let app_fa = std::sync::Arc::new(crate::builder::build(
&{
let mut p = tree_sitter::Parser::new();
p.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
p.parse(app_src, None).unwrap()
},
app_src.as_bytes(),
));
let idx = std::sync::Arc::new(ModuleIndex::new_for_test());
idx.register_workspace_module(std::path::PathBuf::from("/tmp/app.pl"), app_fa);
let ctrl_src = r#"package Users;
use parent 'Mojolicious::Controller';
sub list {
my ($c) = @_;
$c->url_for('/users/profile');
}
"#;
let ctrl_fa = crate::builder::build(
&{
let mut p = tree_sitter::Parser::new();
p.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
p.parse(ctrl_src, None).unwrap()
},
ctrl_src.as_bytes(),
);
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(ctrl_src, None).unwrap();
let line_idx = 5u32; let line = ctrl_src.lines().nth(line_idx as usize).unwrap();
let quote_start = line.find("'/users/profile").unwrap();
let pr_col = (quote_start + 1 + "/users/pr".len()) as u32;
let pos = Position {
line: line_idx,
character: pr_col,
};
let items = completion_items(&ctrl_fa, &tree, ctrl_src, pos, &idx, None);
let path_item = items
.iter()
.find(|i| i.label == "/users/profile")
.expect("/users/profile must be offered (dispatch completion inside string)");
assert_eq!(
path_item.insert_text, None,
"insert_text cleared — textEdit takes precedence for non-keyword-char labels"
);
let Some(CompletionTextEdit::Edit(ref te)) = path_item.text_edit else {
panic!(
"expected textEdit for `/users/profile`; got {:?}",
path_item.text_edit
);
};
assert_eq!(
te.new_text, "/users/profile",
"textEdit.newText is the bare label, no surrounding quotes"
);
assert_eq!(te.range.start.line, line_idx);
assert_eq!(te.range.end.line, line_idx);
assert_eq!(
te.range.start.character,
(quote_start + 1) as u32,
"range start hugs the char just after the opening quote",
);
assert_eq!(
te.range.end.character,
(quote_start + 1 + "/users/profile".len()) as u32,
"range end hugs the closing quote — replacement stays INSIDE the existing quotes",
);
let ctrl_src_hash = r#"package Users;
use parent 'Mojolicious::Controller';
sub list {
my ($c) = @_;
$c->url_for('Users#list');
}
"#;
let ctrl_fa_hash = crate::builder::build(
&parser.parse(ctrl_src_hash, None).unwrap(),
ctrl_src_hash.as_bytes(),
);
let tree_hash = parser.parse(ctrl_src_hash, None).unwrap();
let line = ctrl_src_hash.lines().nth(5).unwrap();
let quote_start = line.find("'Users#list").unwrap();
let past_hash_col = (quote_start + 1 + "Users#li".len()) as u32;
let pos = Position {
line: 5,
character: past_hash_col,
};
let items = completion_items(&ctrl_fa_hash, &tree_hash, ctrl_src_hash, pos, &idx, None);
let hash_item = items
.iter()
.find(|i| i.label == "Users#list")
.expect("Users#list must be offered when cursor is past the #");
assert_eq!(hash_item.insert_text, None);
let Some(CompletionTextEdit::Edit(ref te)) = hash_item.text_edit else {
panic!(
"expected textEdit for `Users#list`; got {:?}",
hash_item.text_edit
);
};
assert_eq!(te.new_text, "Users#list");
}
#[test]
fn completion_dispatch_textedit_range_at_content_boundary() {
use crate::module_index::ModuleIndex;
use tower_lsp::lsp_types::CompletionTextEdit;
let app_src = r#"package MyApp;
use Mojolicious::Lite;
any '/fallback' => sub { my ($c) = @_; };
"#;
let app_fa = std::sync::Arc::new(crate::builder::build(
&{
let mut p = tree_sitter::Parser::new();
p.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
p.parse(app_src, None).unwrap()
},
app_src.as_bytes(),
));
let idx = std::sync::Arc::new(ModuleIndex::new_for_test());
idx.register_workspace_module(std::path::PathBuf::from("/tmp/app.pl"), app_fa);
let ctrl_src = r#"package Users;
use parent 'Mojolicious::Controller';
sub list {
my ($c) = @_;
$c->url_for('/fall');
}
"#;
let ctrl_fa = crate::builder::build(
&{
let mut p = tree_sitter::Parser::new();
p.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
p.parse(ctrl_src, None).unwrap()
},
ctrl_src.as_bytes(),
);
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(ctrl_src, None).unwrap();
let line_idx = 5u32;
let line = ctrl_src.lines().nth(line_idx as usize).unwrap();
let quote_start = line.find("'/fall'").unwrap();
let content_start = (quote_start + 1) as u32; let content_end = content_start + "/fall".len() as u32; let closing_quote_col = content_end;
let cursor_variants = [
("inside content", content_start + 3), ("end of content", content_end), ("on closing quote", closing_quote_col),
];
for (label, col) in cursor_variants {
let items = completion_items(
&ctrl_fa,
&tree,
ctrl_src,
Position {
line: line_idx,
character: col,
},
&idx,
None,
);
let item = items
.iter()
.find(|i| i.label == "/fallback")
.unwrap_or_else(|| {
panic!(
"{}: /fallback must be offered at col {}; \
got labels: {:?}",
label,
col,
items.iter().map(|i| &i.label).collect::<Vec<_>>()
)
});
let Some(CompletionTextEdit::Edit(ref te)) = item.text_edit else {
panic!("{}: expected textEdit; got {:?}", label, item.text_edit);
};
assert_eq!(
te.range.start.character, content_start,
"{}: range start must hug the first content char; got range {:?}",
label, te.range,
);
assert_eq!(
te.range.end.character, content_end,
"{}: range end must hug the closing quote (exclusive of it); got range {:?}",
label, te.range,
);
assert_eq!(
te.new_text, "/fallback",
"{}: newText is the bare label — no seasonal redundancy",
label,
);
}
}
#[test]
fn completion_after_comma_in_dispatch_call_suppresses_firehose() {
let src = r#"
package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire_one {}
sub wire_two {}
sub completely_unrelated {}
sub wire {
my $self = shift;
$self->on('connect', sub { my ($s, $sock) = @_; });
}
sub fire {
my $self = shift;
$self->emit('connect', );
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->emit('connect',"))
.unwrap();
let col = line.find(", )").unwrap() + 2;
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let items = completion_items(&analysis, &tree, src, pos, &idx, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
!labels.contains(&"completely_unrelated"),
"unrelated sub must not appear in dispatch arg completion: {:?}",
labels
);
assert!(
!labels.contains(&"wire_one"),
"wire_one leak — dispatch arg completion should stay quiet: {:?}",
labels
);
}
#[test]
fn completion_mid_string_route_target_scoped_to_invocant() {
let src = r#"
package Users;
sub list {}
sub login {}
sub logout {}
sub delete_user {}
package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Users#lis');
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("Users#lis"))
.unwrap();
let col = line.find("Users#lis").unwrap() + "Users#lis".len();
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let items = completion_items(&analysis, &tree, src, pos, &idx, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"list"),
"list must be offered for prefix `lis`: {:?}",
labels
);
assert!(
!labels.contains(&"login"),
"login does NOT start with `lis` — must be filtered out: {:?}",
labels
);
assert!(
!labels.contains(&"logout"),
"logout does NOT start with `lis` — must be filtered out: {:?}",
labels
);
assert!(
!labels.contains(&"delete_user"),
"delete_user is unrelated — must not appear: {:?}",
labels
);
let list = items.iter().find(|i| i.label == "list").unwrap();
assert!(
list.sort_text
.as_deref()
.unwrap_or("zzz")
.starts_with("000"),
"mid-string method completion should be top-priority: {:?}",
list.sort_text
);
}
#[test]
fn completion_mid_string_before_hash_falls_through() {
let src = r#"
package MyApp;
use Mojolicious::Lite;
my $r = app->routes;
$r->get('/users')->to('Us');
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("'Us'"))
.unwrap();
let col = line.find("'Us'").unwrap() + "'Us".len();
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let items = completion_items(&analysis, &tree, src, pos, &idx, None);
let _ = items;
}
#[test]
fn completion_skips_non_dispatcher_method() {
let src = r#"package My::Emitter;
use parent 'Mojo::EventEmitter';
sub wire {
my $self = shift;
$self->on('connect', sub { my ($s) = @_; });
}
sub other {
my $self = shift;
$self->unrelated_method();
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("->unrelated_method()"))
.unwrap();
let col = line.find("method(").unwrap() + "method(".len();
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let items = completion_items(&analysis, &tree, src, pos, &idx, None);
assert!(
!items.iter().any(|i| i.label == "connect"),
"non-dispatcher method must not surface handler completions"
);
}
#[test]
fn sig_help_returns_none_when_no_handler_registered() {
let src = r#"package My::Emitter;
use parent 'Mojo::EventEmitter';
sub fire {
my $self = shift;
$self->emit('never_registered', )
}
"#;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let idx = ModuleIndex::new_for_test();
let pos = {
let line_idx = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("never_registered"))
.map(|(i, _)| i)
.unwrap();
let line = src.lines().nth(line_idx).unwrap();
let col = line.find(", )").unwrap() + 2;
Position {
line: line_idx as u32,
character: col as u32,
}
};
let sig = signature_help(&analysis, &tree, src, pos, &idx);
assert!(
sig.is_none(),
"no handler_params → no string-dispatch sig; also no local ->emit def"
);
}
fn cached_under(name: &str, source: &str) -> std::sync::Arc<crate::module_index::CachedModule> {
let analysis = parse_analysis(source);
std::sync::Arc::new(crate::module_index::CachedModule::new(
std::path::PathBuf::from(format!("/fake/{}.pm", name.replace("::", "/"))),
std::sync::Arc::new(analysis),
))
}
#[test]
fn data_printer_use_ddp_resolves_p_to_data_printer() {
let source = "use DDP;\np $foo;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
module_index.insert_cache(
"Data::Printer",
Some(cached_under(
"Data::Printer",
"package Data::Printer;\nsub p { my (undef, %props) = @_; }\nsub np { my (undef, %props) = @_; }\n1;\n",
)),
);
let resolved = resolve_imported_function(&analysis, "p", &module_index);
assert!(
resolved.is_some(),
"use DDP must alias to Data::Printer; resolve_imported_function for `p` returned None — \
imports were: {:?}",
analysis
.imports
.iter()
.map(|i| (
i.module_name.clone(),
i.imported_symbols
.iter()
.map(|s| s.local_name.clone())
.collect::<Vec<_>>(),
))
.collect::<Vec<_>>()
);
let (import, _path, remote) = resolved.unwrap();
assert_eq!(
import.module_name, "Data::Printer",
"alias must route to Data::Printer, not DDP"
);
assert_eq!(remote, "p", "local `p` maps to remote `p`");
let np = resolve_imported_function(&analysis, "np", &module_index);
assert!(
np.is_some(),
"use DDP must also resolve `np` to Data::Printer"
);
assert_eq!(np.unwrap().0.module_name, "Data::Printer");
}
#[test]
fn data_printer_use_data_printer_resolves_p_to_data_printer() {
let source = "use Data::Printer;\np $foo;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
module_index.insert_cache(
"Data::Printer",
Some(cached_under(
"Data::Printer",
"package Data::Printer;\nsub p { my (undef, %props) = @_; }\nsub np { my (undef, %props) = @_; }\n1;\n",
)),
);
let resolved = resolve_imported_function(&analysis, "p", &module_index);
assert!(
resolved.is_some(),
"use Data::Printer (no qw list) must still let resolve_imported_function find p"
);
assert_eq!(resolved.unwrap().0.module_name, "Data::Printer");
}
#[test]
fn data_printer_use_line_options_completion() {
let source = "use DDP { };\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let pos = Position {
line: 0,
character: 10,
};
let items = completion_items(&analysis, &tree, source, pos, &module_index, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
for key in &["caller_info", "colored", "class_method", "output"] {
assert!(
labels.iter().any(|l| l == key),
"use DDP {{ }} option completion must offer `{}`; got: {:?}",
key,
labels,
);
}
}
#[test]
fn test_demo_file_chain_to_resolves_on_line_71() {
use std::fs;
use std::path::PathBuf;
let root: PathBuf = env!("CARGO_MANIFEST_DIR").into();
let demo = root.join("test_files/plugin_mojo_demo.pl");
let demo_source = fs::read_to_string(&demo).expect("demo file present");
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(Some(root.to_str().unwrap()));
let files = crate::file_store::FileStore::new();
let _indexed = crate::module_resolver::index_workspace_with_index(
&root.join("test_files"),
&files,
Some(&idx),
);
let inc_paths = crate::module_resolver::discover_inc_paths();
let insert_real = |name: &str| -> bool {
let mut p = crate::module_resolver::create_parser();
match crate::module_resolver::resolve_and_parse(&inc_paths, name, &mut p) {
Some(cached) => {
idx.insert_cache(name, Some(cached));
true
}
None => false,
}
};
let have_mojo = insert_real("Mojolicious")
&& insert_real("Mojolicious::Routes")
&& insert_real("Mojolicious::Routes::Route")
&& insert_real("Mojolicious::Lite");
if !have_mojo {
eprintln!("SKIP: Mojolicious not installed in @INC");
return;
}
let _ = PathBuf::new();
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(&demo_source, None).unwrap();
let mut analysis = crate::builder::build(&tree, demo_source.as_bytes());
analysis.enrich_imported_types_with_keys(Some(&idx));
let (line_idx, chain_line) = demo_source
.lines()
.enumerate()
.find(|(_, l)| l.contains("$r->get('/users')") && l.contains("->to('Users#list')"))
.expect("chain line present in demo");
let to_col = chain_line.find("->to(").unwrap() + 2;
let r_col = chain_line.find("$r").unwrap();
let get_col = chain_line.find("->get(").unwrap() + 2;
let pt = |col: usize| tree_sitter::Point {
row: line_idx,
column: col,
};
let r_ty_bag = analysis.inferred_type_via_bag("$r", pt(r_col));
let r_ty_legacy = analysis.inferred_type("$r", pt(r_col)).cloned();
let mcb_for_r: Vec<_> = analysis
.method_call_bindings
.iter()
.filter(|b| b.variable == "$r")
.collect();
let cb_for_r: Vec<_> = analysis
.call_bindings
.iter()
.filter(|b| b.variable == "$r")
.collect();
let app_known = analysis.symbols.iter().any(|s| s.name == "app");
let mojo_cached = idx.get_cached("Mojolicious").is_some();
let routes_cached = idx.get_cached("Mojolicious::Routes").is_some();
let route_cached = idx.get_cached("Mojolicious::Routes::Route").is_some();
eprintln!(
"DIAG: $r bag={:?} legacy={:?} mcbs={:?} cbs={:?} app_sym={} \
mojo_cached={} routes_cached={} route_cached={}",
r_ty_bag,
r_ty_legacy,
mcb_for_r
.iter()
.map(|b| format!("{}.{}", b.invocant_var, b.method_name))
.collect::<Vec<_>>(),
cb_for_r.iter().map(|b| &b.func_name).collect::<Vec<_>>(),
app_known,
mojo_cached,
routes_cached,
route_cached,
);
let r_ty = r_ty_bag;
let r_class = r_ty.as_ref().and_then(|t| t.class_name());
assert!(
r_class.is_some(),
"$r should be typed (any class) at {}:{}; got {:?}",
line_idx + 1,
r_col,
r_ty,
);
let get_ref = analysis.ref_at(pt(get_col)).expect("ref at ->get");
assert_eq!(get_ref.target_name, "get");
if matches!(get_ref.kind, crate::file_analysis::RefKind::MethodCall { .. }) {
let _ = (&tree, demo_source.as_bytes(), &idx, get_col);
let klass = analysis.method_call_invocant_class(get_ref, Some(&idx));
assert!(
klass.is_some(),
"`->get`'s invocant (= $r) should resolve to SOME class; got {:?}",
klass,
);
}
let to_ref = analysis.ref_at(pt(to_col)).expect("ref at ->to");
assert_eq!(to_ref.target_name, "to");
assert!(
matches!(
to_ref.kind,
crate::file_analysis::RefKind::MethodCall { .. }
),
"ref at ->to is a MethodCall"
);
if matches!(to_ref.kind, crate::file_analysis::RefKind::MethodCall { .. }) {
let _ = (&tree, demo_source.as_bytes(), &idx, to_col);
let klass = analysis.method_call_invocant_class(to_ref, Some(&idx));
eprintln!(
"DIAG: ->to invocant class (real Mojo): {:?} \
(None expected until deep chain through \
_generate_route/requires/to is resolved)",
klass,
);
}
}
#[test]
fn data_printer_use_line_options_completion_for_data_printer_module() {
let source = "use Data::Printer { };\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let pos = Position {
line: 0,
character: 20,
};
let items = completion_items(&analysis, &tree, source, pos, &module_index, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| *l == "caller_info"),
"use Data::Printer {{ }} must surface options too; got: {:?}",
labels,
);
}
#[test]
fn test_route_pm_chain_decomposition() {
use std::fs;
use std::path::PathBuf;
let inc = crate::module_resolver::discover_inc_paths();
let route_path = inc
.iter()
.map(|p| p.join("Mojolicious/Routes/Route.pm"))
.find(|p| p.exists());
let route_path: PathBuf = match route_path {
Some(p) => p,
None => {
eprintln!("SKIP: Mojo not installed");
return;
}
};
let src = fs::read_to_string(&route_path).unwrap();
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(&src, None).unwrap();
let analysis = crate::builder::build(&tree, src.as_bytes());
let inspect_sym = |name: &str| {
for sym in &analysis.symbols {
if sym.name != name {
continue;
}
if !matches!(
sym.kind,
crate::file_analysis::SymKind::Sub | crate::file_analysis::SymKind::Method
) {
continue;
}
if matches!(&sym.detail, crate::file_analysis::SymbolDetail::Sub { .. }) {
let return_type = analysis.symbol_return_type_via_bag(sym.id, None);
eprintln!(" sym[{:24}] return_type={:?}", name, return_type);
return;
}
}
eprintln!(" sym[{:24}] NOT FOUND", name);
};
eprintln!("======== symbol return types in Route.pm ========");
for name in [
"get",
"post",
"any",
"to",
"name",
"requires",
"_generate_route",
"_route",
"add_child",
"pattern",
"is_reserved",
"root",
] {
inspect_sym(name);
}
fn find_sub_body<'t>(
n: tree_sitter::Node<'t>,
src: &[u8],
name: &str,
) -> Option<tree_sitter::Node<'t>> {
if n.kind() == "subroutine_declaration_statement" {
if let Some(nm) = n.child_by_field_name("name") {
if nm.utf8_text(src).ok() == Some(name) {
return n.child_by_field_name("body");
}
}
}
for i in 0..n.named_child_count() {
if let Some(c) = n.named_child(i) {
if let Some(r) = find_sub_body(c, src, name) {
return Some(r);
}
}
}
None
}
let body = find_sub_body(tree.root_node(), src.as_bytes(), "_generate_route")
.expect("_generate_route body");
fn find_var_decl_for<'t>(
n: tree_sitter::Node<'t>,
src: &[u8],
var: &str,
) -> Option<tree_sitter::Node<'t>> {
if n.kind() == "assignment_expression" {
if let Some(left) = n.child_by_field_name("left") {
if left.utf8_text(src).map(|s| s.trim()).ok() == Some(&format!("my {}", var)) {
return n.child_by_field_name("right");
}
}
}
for i in 0..n.named_child_count() {
if let Some(c) = n.named_child(i) {
if let Some(r) = find_var_decl_for(c, src, var) {
return Some(r);
}
}
}
None
}
let route_rhs = find_var_decl_for(body, src.as_bytes(), "$route").expect("my $route = ... RHS");
eprintln!();
eprintln!("======== `my $route = RHS` decomposition ========");
eprintln!(
"RHS shape: {} kind={}",
route_rhs.utf8_text(src.as_bytes()).unwrap_or(""),
route_rhs.kind()
);
fn report_node_type(
label: &str,
n: tree_sitter::Node,
analysis: &crate::file_analysis::FileAnalysis,
src: &[u8],
) {
let text = n.utf8_text(src).unwrap_or("").trim();
let ty = crate::cursor_context::resolve_expression_type(&analysis, n, src, None);
eprintln!(
" [{label:>12}] `{text:.60}`\n kind={} → ty={:?}",
n.kind(),
ty
);
}
let mut cur = Some(route_rhs);
let mut depth = 0;
while let Some(n) = cur {
let label = match depth {
0 => "outer",
1 => "mid1",
2 => "mid2",
3 => "mid3",
_ => "inner",
};
report_node_type(label, n, &analysis, src.as_bytes());
if n.kind() == "method_call_expression" {
cur = n.child_by_field_name("invocant");
depth += 1;
} else {
break;
}
}
eprintln!();
eprintln!("======== return TERNARY probe ========");
fn find_return<'t>(n: tree_sitter::Node<'t>) -> Option<tree_sitter::Node<'t>> {
if n.kind() == "return_expression" {
return Some(n);
}
for i in 0..n.named_child_count() {
if let Some(c) = n.named_child(i) {
if let Some(r) = find_return(c) {
return Some(r);
}
}
}
None
}
let ret = find_return(body).expect("return in _generate_route");
let ternary = ret.named_child(0).expect("return child");
eprintln!(
" return child kind = {} text = `{}`",
ternary.kind(),
ternary.utf8_text(src.as_bytes()).unwrap_or("").trim()
);
if ternary.kind() == "conditional_expression" {
let consequent = ternary.child_by_field_name("consequent");
let alternative = ternary.child_by_field_name("alternative");
if let Some(a) = consequent {
report_node_type("then-arm", a, &analysis, src.as_bytes());
}
if let Some(b) = alternative {
report_node_type("else-arm", b, &analysis, src.as_bytes());
}
}
}
#[test]
fn test_demo_chain_empirical_truth_table() {
use std::fs;
use std::path::PathBuf;
let root: PathBuf = env!("CARGO_MANIFEST_DIR").into();
let demo = root.join("test_files/plugin_mojo_demo.pl");
let demo_source = fs::read_to_string(&demo).expect("demo file");
let idx = ModuleIndex::new_for_test();
idx.set_workspace_root(Some(root.to_str().unwrap()));
let files = crate::file_store::FileStore::new();
let _ = crate::module_resolver::index_workspace_with_index(
&root.join("test_files"),
&files,
Some(&idx),
);
let inc = crate::module_resolver::discover_inc_paths();
let install = |name: &str| -> bool {
let mut p = crate::module_resolver::create_parser();
match crate::module_resolver::resolve_and_parse(&inc, name, &mut p) {
Some(c) => {
idx.insert_cache(name, Some(c));
true
}
None => false,
}
};
if !(install("Mojolicious")
&& install("Mojolicious::Routes")
&& install("Mojolicious::Routes::Route")
&& install("Mojolicious::Lite"))
{
eprintln!("SKIP: Mojolicious not installed");
return;
}
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&ts_parser_perl::LANGUAGE.into())
.unwrap();
let tree = parser.parse(&demo_source, None).unwrap();
let mut analysis = crate::builder::build(&tree, demo_source.as_bytes());
analysis.enrich_imported_types_with_keys(Some(&idx));
let (line_idx, chain_line) = demo_source
.lines()
.enumerate()
.find(|(_, l)| l.contains("$r->get('/users')") && l.contains("->to('Users#list')"))
.expect("demo chain line present");
let r_col = chain_line.find("$r").unwrap();
let get_col = chain_line.find("->get(").unwrap() + 2;
let to_col = chain_line.find("->to(").unwrap() + 2;
let pt = |c: usize| tree_sitter::Point {
row: line_idx,
column: c,
};
let r_ty = analysis.inferred_type_via_bag("$r", pt(r_col));
let r_class = r_ty
.as_ref()
.and_then(|t| t.class_name())
.map(|s| s.to_string());
let get_ref = analysis.ref_at(pt(get_col)).expect("ref at ->get");
let get_invocant_class = if matches!(get_ref.kind, crate::file_analysis::RefKind::MethodCall { .. }) {
analysis.method_call_invocant_class(get_ref, Some(&idx))
} else {
None
};
let mcall_node = {
fn find_getcall<'a>(n: tree_sitter::Node<'a>, src: &[u8]) -> Option<tree_sitter::Node<'a>> {
if n.kind() == "method_call_expression" {
if let Some(m) = n.child_by_field_name("method") {
if m.utf8_text(src).ok() == Some("get") {
return Some(n);
}
}
}
for i in 0..n.named_child_count() {
if let Some(c) = n.named_child(i) {
if let Some(r) = find_getcall(c, src) {
return Some(r);
}
}
}
None
}
find_getcall(tree.root_node(), demo_source.as_bytes()).expect("->get node")
};
let get_return_ty =
crate::cursor_context::resolve_expression_type(&analysis, mcall_node, demo_source.as_bytes(), Some(&idx));
let to_ref = analysis.ref_at(pt(to_col)).expect("ref at ->to");
let to_invocant_class = if matches!(to_ref.kind, crate::file_analysis::RefKind::MethodCall { .. }) {
analysis.method_call_invocant_class(to_ref, Some(&idx))
} else {
None
};
let route_cached = idx.get_cached("Mojolicious::Routes::Route").unwrap();
let inspect = |name: &str| -> Option<InferredType> {
for sym in &route_cached.analysis.symbols {
if sym.name != name {
continue;
}
if !matches!(
sym.kind,
crate::file_analysis::SymKind::Sub | crate::file_analysis::SymKind::Method
) {
continue;
}
if matches!(&sym.detail, crate::file_analysis::SymbolDetail::Sub { .. }) {
return route_cached.analysis.symbol_return_type_via_bag(sym.id, None);
}
}
None
};
let gen_rt = inspect("_generate_route");
let get_rt = inspect("get");
let to_rt = inspect("to");
let requires_rt = inspect("requires");
let _route_rt = inspect("_route");
eprintln!("======== chain truth table ========");
eprintln!(" $r class = {:?}", r_class);
eprintln!(" ->get invocant class = {:?}", get_invocant_class);
eprintln!(" ->get RETURN type = {:?}", get_return_ty);
eprintln!(" ->to invocant class = {:?}", to_invocant_class);
eprintln!(" ---- cached Route symbols ----");
eprintln!(" get rt={:?}", get_rt);
eprintln!(" _generate_route rt={:?}", gen_rt);
eprintln!(" requires rt={:?}", requires_rt);
eprintln!(" to rt={:?}", to_rt);
eprintln!(" _route rt={:?}", _route_rt);
eprintln!("====================================");
assert!(
r_class.is_some(),
"(link 1) $r must resolve to a class; got None"
);
assert_eq!(r_class.as_deref(), Some("Mojolicious::Routes"));
assert!(
get_invocant_class.is_some(),
"(link 2) ->get's invocant class must resolve; got None"
);
assert!(
get_return_ty.is_some(),
"(link 3) ->get's RETURN type must resolve through \
_generate_route → _route's plugin override"
);
assert_eq!(
get_return_ty.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes::Route"),
"->get returns the Route class so ->to can chain off it"
);
assert!(
to_invocant_class.is_some(),
"(link 4) ->to's invocant class must resolve — THIS is \
the chain hop the spike was unblocking"
);
assert_eq!(
to_invocant_class.as_deref(),
Some("Mojolicious::Routes::Route"),
"->to is invoked on a Route, so cursor-on-`to` \
completion / hover / goto-def all reach \
Mojolicious::Routes::Route::to"
);
assert_eq!(
_route_rt.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes::Route"),
"_route is the override anchor",
);
assert_eq!(
gen_rt.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes::Route"),
"_generate_route folds because $route is now typed",
);
assert_eq!(
get_rt.as_ref().and_then(|t| t.class_name()),
Some("Mojolicious::Routes::Route"),
"get tail-delegates to _generate_route which has a type",
);
}
#[test]
fn test_e2e_mojo_style_chain_completion_offers_chained_class_methods() {
let src = r#"package MyApp::Route;
sub new { my $c = shift; bless {}, $c }
sub get {
my $self = shift;
$self->{_path} = shift;
return $self;
}
sub to {
my $self = shift;
$self->{_target} = shift;
return $self;
}
sub name {
my $self = shift;
$self->{_name} = shift;
return $self;
}
package main;
my $r = MyApp::Route->new;
$r->get('/users')->
"#;
let analysis = parse_analysis(src);
let tree = crate::document::Document::new(src.to_string())
.unwrap()
.tree;
let idx = ModuleIndex::new_for_test();
let (line_idx, line) = src
.lines()
.enumerate()
.find(|(_, l)| l.contains("$r->get('/users')->"))
.unwrap();
let col = line.rfind("->").unwrap() + 2;
let pos = Position {
line: line_idx as u32,
character: col as u32,
};
let items = completion_items(&analysis, &tree, src, pos, &idx, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
for expected in &["to", "name", "get"] {
assert!(
labels.contains(expected),
"expected `{}` in completion after `$r->get('/users')->`, \
got {} items: {:?}",
expected,
labels.len(),
labels
);
}
}
#[test]
fn cross_file_plugin_helper_goto_def_resolves() {
let provider_src = "package My::Plugin;\n\
use Mojo::Base 'Mojolicious::Plugin';\n\
sub register ($self, $app, $conf) {\n\
$app->helper(widget => sub ($c) { return Widget->new; });\n\
}\n\
1;\n";
let provider = parse_analysis(provider_src);
assert!(
provider.plugin_namespaces.iter().any(|ns| ns
.bridges
.iter()
.any(|b| matches!(b, crate::file_analysis::Bridge::Class(c) if c == crate::file_analysis::APP_SURFACE_CLASS))),
"provider must bridge a namespace to the app surface",
);
let idx = crate::module_index::ModuleIndex::new_for_test();
let provider_path = std::path::PathBuf::from("/tmp/perl_lsp_pin_My_Plugin.pm");
idx.register_workspace_module(provider_path.clone(), std::sync::Arc::new(provider));
let consumer_src = "package My::Ctrl;\n\
use Mojo::Base 'Mojolicious::Controller';\n\
sub action ($c) {\n\
my $w = $c->widget;\n\
return $w;\n\
}\n\
1;\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("widget;").expect("call site present");
let prefix = &consumer_src[..byte];
let pos = Position {
line: prefix.matches('\n').count() as u32,
character: (byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32,
};
let uri = Url::parse("file:///consumer.pl").unwrap();
let resp = find_definition(&consumer, pos, &uri, &idx);
let loc = match resp {
Some(GotoDefinitionResponse::Scalar(loc)) => loc,
Some(GotoDefinitionResponse::Array(mut v)) if !v.is_empty() => v.remove(0),
other => panic!("expected a goto-def location for cross-file helper, got {other:?}"),
};
assert!(
loc.uri.path().ends_with("My_Plugin.pm"),
"goto-def should land in the provider file, got {}",
loc.uri,
);
}
#[test]
fn cross_package_glob_method_resolves_cross_file() {
let provider_src = "package DateTime::PP;\n\
sub _ymd2rd { my ($class, $y, $m, $d) = @_; return 1; }\n\
my @subs = qw( _ymd2rd );\n\
for my $sub (@subs) {\n\
no strict 'refs';\n\
*{ 'DateTime::' . $sub } = __PACKAGE__->can($sub);\n\
}\n\
1;\n";
let provider = parse_analysis(provider_src);
assert!(
provider.symbols.iter().any(|s| s.name == "_ymd2rd"
&& matches!(s.kind, crate::file_analysis::SymKind::Sub)
&& s.package.as_deref() == Some("DateTime")),
"provider must attribute the glob-installed _ymd2rd to DateTime",
);
let idx = crate::module_index::ModuleIndex::new_for_test();
let provider_path = std::path::PathBuf::from("/tmp/perl_lsp_pin_DateTime_PP.pm");
idx.register_workspace_module(provider_path.clone(), std::sync::Arc::new(provider));
let consumer_src = "package DateTime;\n\
sub new { my $class = shift; return bless {}, $class; }\n\
sub day_of_week {\n\
my $self = shift;\n\
return $self->_ymd2rd( 2024, 1, 1 );\n\
}\n\
1;\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("_ymd2rd( 2024").expect("call site present");
let prefix = &consumer_src[..byte];
let pos = Position {
line: prefix.matches('\n').count() as u32,
character: (byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32,
};
let uri = Url::parse("file:///datetime.pl").unwrap();
let resp = find_definition(&consumer, pos, &uri, &idx);
let loc = match resp {
Some(GotoDefinitionResponse::Scalar(loc)) => loc,
Some(GotoDefinitionResponse::Array(mut v)) if !v.is_empty() => v.remove(0),
other => panic!("expected goto-def for cross-package glob method, got {other:?}"),
};
assert!(
loc.uri.path().ends_with("DateTime_PP.pm"),
"goto-def should land in the provider (PP) file, got {}",
loc.uri,
);
assert_eq!(
loc.range.start.line, 1,
"should land on the real `sub _ymd2rd` (line 1), got {}",
loc.range.start.line
);
}
#[test]
fn fq_variable_read_resolves_cross_file() {
let provider_src = "package My::Vars;\n\
our $config = { host => 'localhost' };\n\
our @servers = ('a', 'b');\n\
1;\n";
let provider = parse_analysis(provider_src);
let idx = crate::module_index::ModuleIndex::new_for_test();
let provider_path = std::path::PathBuf::from("/tmp/perl_lsp_pin_My_Vars.pm");
idx.register_workspace_module(provider_path, std::sync::Arc::new(provider));
let consumer_src = "package Main;\n\
my $h = $My::Vars::config;\n\
my @s = @My::Vars::servers;\n\
1;\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("config;").expect("read site present");
let prefix = &consumer_src[..byte];
let pos = Position {
line: prefix.matches('\n').count() as u32,
character: (byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32,
};
let uri = Url::parse("file:///consumer.pl").unwrap();
let resp = find_definition(&consumer, pos, &uri, &idx);
let loc = match resp {
Some(GotoDefinitionResponse::Scalar(loc)) => loc,
other => panic!("expected goto-def for FQ var, got {other:?}"),
};
assert!(
loc.uri.path().ends_with("My_Vars.pm"),
"goto-def should land in the provider file, got {}",
loc.uri,
);
assert_eq!(
loc.range.start.line, 1,
"should land on `our $config` (line 1)"
);
}
#[test]
fn fq_variable_read_unknown_package_is_honest_miss() {
let idx = crate::module_index::ModuleIndex::new_for_test();
let consumer_src = "package Main;\nmy $x = $No::Such::Pkg::thing;\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("thing;").unwrap();
let prefix = &consumer_src[..byte];
let pos = Position {
line: prefix.matches('\n').count() as u32,
character: (byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32,
};
let uri = Url::parse("file:///consumer.pl").unwrap();
let resp = find_definition(&consumer, pos, &uri, &idx);
assert!(resp.is_none(), "unknown package must be an honest miss, got {resp:?}");
}
#[test]
fn cross_file_plugin_helper_hover_resolves() {
let provider_src = "package My::Plugin;\n\
use Mojo::Base 'Mojolicious::Plugin';\n\
sub register ($self, $app, $conf) {\n\
$app->helper(widget => sub ($c) { return Widget->new; });\n\
}\n\
1;\n";
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_hover_My_Plugin.pm"),
std::sync::Arc::new(parse_analysis(provider_src)),
);
let consumer_src = "package My::Ctrl;\n\
use Mojo::Base 'Mojolicious::Controller';\n\
sub action ($c) {\n\
my $w = $c->widget;\n\
return $w;\n\
}\n\
1;\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("widget;").expect("call site present");
let prefix = &consumer_src[..byte];
let point = tree_sitter::Point {
row: prefix.matches('\n').count(),
column: byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0),
};
let hover = consumer
.hover_info(point, consumer_src, Some(&idx))
.expect("cross-file hover should resolve the bridged helper");
assert!(hover.contains("widget"), "hover should mention the helper, got: {hover}");
assert!(
hover.contains(crate::file_analysis::APP_SURFACE_CLASS),
"hover should show the app surface as the bridging class, got: {hover}",
);
}
#[test]
fn cross_file_helper_return_type_needs_module_index() {
let other_src = "package My::Other;\n\
use Mojo::Base -base;\n\
sub fluent ($self) { return $self; }\n\
1;\n";
let provider_src = "package My::Plugin;\n\
use Mojo::Base 'Mojolicious::Plugin';\n\
sub register ($self, $app, $conf) {\n\
$app->helper(thing => sub ($c) { return My::Other->new->fluent; });\n\
}\n\
1;\n";
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_ret_Other.pm"),
std::sync::Arc::new(parse_analysis(other_src)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_ret_Plugin.pm"),
std::sync::Arc::new(parse_analysis(provider_src)),
);
let consumer_src = "package My::Ctrl;\n\
use Mojo::Base 'Mojolicious::Controller';\n\
sub action ($c) {\n\
my $x = $c->thing;\n\
return $x;\n\
}\n\
1;\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("thing;").expect("call site");
let prefix = &consumer_src[..byte];
let point = tree_sitter::Point {
row: prefix.matches('\n').count(),
column: byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0),
};
let hover = consumer
.hover_info(point, consumer_src, Some(&idx))
.expect("cross-file hover should resolve");
assert!(
hover.contains("My::Other"),
"return type should resolve cross-file to My::Other, got: {hover}",
);
}
#[test]
fn cross_file_lexical_chain_return_type() {
let other_src = "package My::Other;\n\
use Mojo::Base -base;\n\
sub fluent ($self) { return $self; }\n\
1;\n";
let provider_src = "package My::Plugin;\n\
use Mojo::Base 'Mojolicious::Plugin';\n\
sub register ($self, $app, $conf) {\n\
$app->helper(thing => sub ($c) { my $g = My::Other->new->fluent; return $g; });\n\
}\n\
1;\n";
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_lex_Other.pm"),
std::sync::Arc::new(parse_analysis(other_src)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_lex_Plugin.pm"),
std::sync::Arc::new(parse_analysis(provider_src)),
);
let consumer_src = "package My::Ctrl;\n\
use Mojo::Base 'Mojolicious::Controller';\n\
sub action ($c) {\n\
my $x = $c->thing;\n\
return $x;\n\
}\n\
1;\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("thing;").expect("call site");
let prefix = &consumer_src[..byte];
let point = tree_sitter::Point {
row: prefix.matches('\n').count(),
column: byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0),
};
let hover = consumer
.hover_info(point, consumer_src, Some(&idx))
.expect("cross-file hover should resolve");
assert!(
hover.contains("My::Other"),
"lexical-chain return type should resolve cross-file to My::Other, got: {hover}",
);
}
#[test]
fn cross_file_fluent_accessor_chain_return_type() {
let other_src = "package My::Other;\n\
use Mojo::Base -base;\n\
has acc => sub { {} };\n\
1;\n";
let provider_src = "package My::Plugin;\n\
use Mojo::Base 'Mojolicious::Plugin';\n\
sub register ($self, $app, $conf) {\n\
$app->helper(thing => sub ($c) { return My::Other->new->acc($x); });\n\
}\n\
1;\n";
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_acc_Other.pm"),
std::sync::Arc::new(parse_analysis(other_src)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_acc_Plugin.pm"),
std::sync::Arc::new(parse_analysis(provider_src)),
);
let consumer_src = "package My::Ctrl;\n\
use Mojo::Base 'Mojolicious::Controller';\n\
sub action ($c) {\n\
my $x = $c->thing;\n\
return $x;\n\
}\n\
1;\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("thing;").expect("call site");
let prefix = &consumer_src[..byte];
let point = tree_sitter::Point {
row: prefix.matches('\n').count(),
column: byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0),
};
let hover = consumer
.hover_info(point, consumer_src, Some(&idx))
.expect("cross-file hover should resolve");
assert!(
hover.contains("My::Other"),
"fluent-accessor chain return type should resolve to My::Other (not the \
consumer's call-site receiver), got: {hover}",
);
}
#[test]
fn cross_file_inherited_fluent_accessor_returns_child() {
let base_src = "package My::Base;\n\
use Mojo::Base -base;\n\
has acc => sub { {} };\n\
1;\n";
let child_src = "package My::Child;\n\
use Mojo::Base 'My::Base';\n\
1;\n";
let provider_src = "package My::Plugin;\n\
use Mojo::Base 'Mojolicious::Plugin';\n\
sub register ($self, $app, $conf) {\n\
$app->helper(thing => sub ($c) { return My::Child->new->acc($x); });\n\
}\n\
1;\n";
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_inh_Base.pm"),
std::sync::Arc::new(parse_analysis(base_src)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_inh_Child.pm"),
std::sync::Arc::new(parse_analysis(child_src)),
);
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_inh_Plugin.pm"),
std::sync::Arc::new(parse_analysis(provider_src)),
);
let consumer_src = "package My::Ctrl;\n\
use Mojo::Base 'Mojolicious::Controller';\n\
sub action ($c) {\n\
my $x = $c->thing;\n\
return $x;\n\
}\n\
1;\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("thing;").expect("call site");
let prefix = &consumer_src[..byte];
let point = tree_sitter::Point {
row: prefix.matches('\n').count(),
column: byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0),
};
let hover = consumer
.hover_info(point, consumer_src, Some(&idx))
.expect("cross-file hover should resolve");
assert!(
hover.contains("My::Child"),
"inherited fluent accessor must return the dispatch (child) class, got: {hover}",
);
}
#[test]
fn brand_partial_route_targets_inherit_controller() {
let src = r#"package Mojolicious::Routes::Route;
sub new { my $class = shift; return bless {}, $class; }
sub any { my $self = shift; return $self; }
sub get { my $self = shift; return $self; }
sub under { my $self = shift; return $self; }
sub to { my $self = shift; return $self; }
package alerts;
sub list { my $c = shift; }
sub get_alert { my $c = shift; }
sub read_settings { my $c = shift; }
package other;
sub thing { my $c = shift; }
package MyApp;
use Mojolicious::Lite;
sub startup {
my $self = shift;
my $r = Mojolicious::Routes::Route->new;
my $alerts_r = $r->any('/alerts')->to('alerts#', section => 'admin');
$alerts_r->get('/')->to('#list');
my $crud = $alerts_r->under('/:type')->to('#get_alert');
$crud->get('/settings')->to('#read_settings');
my $other_r = $r->any('/other')->to('other#');
$other_r->get('/x')->to('#thing');
}
1;
"#;
let fa = parse_analysis(src);
let idx = crate::module_index::ModuleIndex::new_for_test();
let inherited = |action: &str| -> Option<String> {
fa.refs.iter().find_map(|r| {
if let crate::file_analysis::RefKind::MethodCall { .. } = &r.kind {
if r.target_name == action {
return fa.method_call_invocant_class(r, Some(&idx));
}
}
None
})
};
assert_eq!(inherited("list").as_deref(), Some("alerts"),
"partial '#list' must inherit the parent's 'alerts' controller");
assert_eq!(inherited("get_alert").as_deref(), Some("alerts"),
"partial '#get_alert' on $crud (under $alerts_r) inherits 'alerts'");
assert_eq!(inherited("read_settings").as_deref(), Some("alerts"),
"nested partial '#read_settings' still inherits 'alerts'");
assert_eq!(inherited("thing").as_deref(), Some("other"),
"sibling group's '#thing' inherits 'other', not 'alerts'");
let ty_at = |needle: &str, var: &str| -> Option<crate::file_analysis::InferredType> {
let at = src.find(needle).unwrap();
let pre = &src[..at];
let pt = tree_sitter::Point {
row: pre.matches('\n').count(),
column: at - pre.rfind('\n').map(|i| i + 1).unwrap_or(0),
};
fa.inferred_type_via_bag(var, pt)
};
assert!(
matches!(ty_at("$alerts_r->get", "$alerts_r"),
Some(crate::file_analysis::InferredType::BrandedRoute { ref controller, .. })
if controller.as_deref() == Some("alerts")),
"$alerts_r must type as a BrandedRoute carrying controller='alerts'");
assert!(
matches!(ty_at("$crud->get", "$crud"),
Some(crate::file_analysis::InferredType::BrandedRoute { ref controller, .. })
if controller.as_deref() == Some("alerts")),
"$crud (nested under $alerts_r) inherits the 'alerts' brand");
assert_eq!(
ty_at("$crud->get", "$crud").as_ref().and_then(|t| t.route_default("section")),
Some("admin"),
"inherited stash default 'section' is readable off $crud's brand");
assert_eq!(
ty_at("$crud->get", "$crud").as_ref().and_then(|t| t.route_default("controller")),
Some("alerts"),
"route_default('controller') reads the distinguished controller key");
let uri = Url::parse("file:///app.pl").unwrap();
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(src, None).unwrap();
let list_at = src.find("'#list'").unwrap() + "'#".len();
let pre = &src[..list_at];
let pos = Position {
line: pre.matches('\n').count() as u32,
character: (list_at - pre.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32,
};
let resp = find_definition(&fa, pos, &uri, &idx);
let loc = match resp {
Some(GotoDefinitionResponse::Scalar(loc)) => loc,
Some(GotoDefinitionResponse::Array(mut v)) if !v.is_empty() => v.remove(0),
other => panic!("expected goto-def on partial '#list', got {other:?}"),
};
let list_line = src[..src.find("sub list").unwrap()].matches('\n').count() as u32;
assert_eq!(loc.range.start.line, list_line,
"goto-def on '#list' lands on `sub list` in the alerts controller");
}
#[test]
fn types_standard_explicit_import_suppresses_diagnostic() {
let source = "use Types::Standard qw/Str Int/;\nStr();\nInt();\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let names: Vec<&str> = diags.iter()
.filter_map(|d| {
if matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-function") {
Some(d.message.as_str())
} else {
None
}
})
.collect();
assert!(
names.is_empty(),
"Str()/Int() explicitly imported from Types::Standard must not produce unresolved-function; got: {:?}",
names,
);
}
#[test]
fn types_standard_all_flag_suppresses_diagnostic() {
let source = "use Types::Standard '-all';\nInstanceOf(['Foo']);\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let unresolved: Vec<&str> = diags.iter()
.filter_map(|d| {
if matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-function") {
Some(d.message.as_str())
} else {
None
}
})
.collect();
assert!(
unresolved.is_empty(),
"InstanceOf() with '-all' must not produce unresolved-function; got: {:?}",
unresolved,
);
}
#[test]
fn types_common_string_numeric_explicit_import_suppresses_diagnostic() {
let source = concat!(
"use Types::Common::String qw/NonEmptyStr/;\n",
"use Types::Common::Numeric qw/PositiveInt/;\n",
"NonEmptyStr();\nPositiveInt();\n",
);
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let unresolved: Vec<&str> = diags.iter()
.filter_map(|d| {
if matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-function") {
Some(d.message.as_str())
} else {
None
}
})
.collect();
assert!(
unresolved.is_empty(),
"Types::Common String/Numeric names must not produce unresolved-function; got: {:?}",
unresolved,
);
}
#[test]
fn types_standard_instanceof_constraint_typing_still_works() {
use crate::file_analysis::InferredType;
let source = concat!(
"package T;\nuse Moo;\n",
"use Types::Standard qw/Str Int InstanceOf/;\n",
"has x => (is => 'ro', isa => InstanceOf['Foo']);\n",
"my $t = Str;\n1;\n",
);
let analysis = parse_analysis(source);
assert_eq!(
analysis.sub_return_type_at_arity("x", Some(0)),
Some(InferredType::ClassName("Foo".to_string())),
"InstanceOf['Foo'] isa must give the accessor a Foo return type",
);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let unresolved: Vec<&str> = diags.iter()
.filter_map(|d| {
if matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-function") {
Some(d.message.as_str())
} else {
None
}
})
.collect();
assert!(
unresolved.is_empty(),
"No unresolved-function expected for Types::Standard imports; got: {:?}",
unresolved,
);
}
#[test]
fn bare_use_suppresses_export_ok_names() {
let source = "use FakeTypeConstraints;\nsubtype('Foo', as => 'Str', where => sub { 1 });\nas('Str');\ncoerce('Foo', from => 'Int', via => sub { \"$_[0]\" });\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let cached = fake_cached("/fake/FakeTypeConstraints.pm", &[], &["subtype", "as", "where", "coerce"]);
module_index.insert_cache("FakeTypeConstraints", Some(cached));
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let unresolved: Vec<&str> = diags.iter()
.filter_map(|d| {
if matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-function") {
Some(d.message.as_str())
} else {
None
}
})
.collect();
assert!(
unresolved.is_empty(),
"bare use of a runtime-exporter module must not produce unresolved-function for export_ok names; got: {:?}",
unresolved,
);
}
#[test]
fn genuinely_undefined_still_flags_with_export_ok_module_in_scope() {
let source = "use FakeTypeConstraints;\ntruly_undefined_fn();\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let cached = fake_cached("/fake/FakeTypeConstraints.pm", &[], &["subtype", "as"]);
module_index.insert_cache("FakeTypeConstraints", Some(cached));
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let unresolved: Vec<&str> = diags.iter()
.filter_map(|d| {
if matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-function") {
Some(d.message.as_str())
} else {
None
}
})
.collect();
assert!(
unresolved.iter().any(|m| m.contains("truly_undefined_fn")),
"truly_undefined_fn must still produce an unresolved-function diagnostic; got: {:?}",
unresolved,
);
}
#[test]
fn does_method_not_flagged_unresolved() {
let source = "package M;\nuse Moose;\nsub check {\n my ($self, $role) = @_;\n return $self->does($role);\n}\n1;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let unresolved_method: Vec<&str> = diags.iter()
.filter_map(|d| {
if matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-method") {
Some(d.message.as_str())
} else {
None
}
})
.collect();
assert!(
!unresolved_method.iter().any(|m| m.contains("does")),
"`does` must be in the universal-methods skip list and not flagged; got: {:?}",
unresolved_method,
);
}
fn unresolved_method_messages(diags: &[Diagnostic]) -> Vec<String> {
diags.iter()
.filter(|d| matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-method"))
.map(|d| d.message.clone())
.collect()
}
#[test]
fn self_invocant_unresolvable_parent_no_unresolved_method_use_base() {
let source = "package Child;\nuse base qw(MyDep);\nsub new { bless {}, shift }\nsub local_thing {\n my $self = shift;\n return $self->dep_method();\n}\n1;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let um = unresolved_method_messages(&diags);
assert!(
um.is_empty(),
"$self->dep_method with unresolvable `use base` parent must stay silent; got: {:?}",
um,
);
}
#[test]
fn self_invocant_unresolvable_parent_no_unresolved_method_use_parent() {
let source = "package Child;\nuse parent -norequire, 'MyDep';\nsub new { bless {}, shift }\nsub local_thing {\n my $self = shift;\n return $self->dep_method();\n}\n1;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let um = unresolved_method_messages(&diags);
assert!(um.is_empty(), "unresolvable `use parent` parent must stay silent; got: {:?}", um);
}
#[test]
fn self_invocant_unresolvable_parent_no_unresolved_method_at_isa() {
let source = "package Child;\nour @ISA = ('MyDep');\nsub new { bless {}, shift }\nsub local_thing {\n my $self = shift;\n return $self->dep_method();\n}\n1;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let um = unresolved_method_messages(&diags);
assert!(um.is_empty(), "unresolvable `our @ISA` parent must stay silent; got: {:?}", um);
}
#[test]
fn direct_invocant_unresolvable_parent_no_unresolved_method() {
let source = "package Child;\nuse base qw(MyDep);\nsub new { bless {}, shift }\nsub callers {\n Child->dep_method();\n my $c = Child->new;\n $c->dep_method();\n}\n1;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let um = unresolved_method_messages(&diags);
assert!(
um.is_empty(),
"direct-invocant calls on a class with an unresolvable parent must stay silent; got: {:?}",
um,
);
}
#[test]
fn no_parents_missing_method_still_flags() {
let source = "package Foo;\nsub new { bless {}, shift }\nsub real { 1 }\npackage main;\nmy $f = Foo->new;\n$f->totally_bogus_xyz();\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let um = unresolved_method_messages(&diags);
assert!(
um.iter().any(|m| m.contains("totally_bogus_xyz")),
"a parentless class calling a missing method must still flag; got: {:?}",
um,
);
}
#[test]
fn fully_resolved_two_hop_chain_resolves_inherited_and_flags_missing() {
let source = "package GrandPa;\nsub new { bless {}, shift }\nsub gp_method { 1 }\npackage Pa;\nuse parent -norequire, 'GrandPa';\npackage Kid;\nuse parent -norequire, 'Pa';\nsub use_things {\n my $self = shift;\n $self->gp_method();\n $self->missing_method();\n}\n1;\n";
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let um = unresolved_method_messages(&diags);
assert!(
!um.iter().any(|m| m.contains("gp_method")),
"gp_method is inherited 2 hops on a fully-resolved chain — must NOT flag; got: {:?}",
um,
);
assert!(
um.iter().any(|m| m.contains("missing_method")),
"missing_method on a fully-resolved chain must still flag; got: {:?}",
um,
);
}
#[test]
fn classic_perl_filehandle_fps_suppressed() {
let module_index = crate::module_index::ModuleIndex::new_for_test();
for src in [
"print STDERR \"hi\";\n",
"printf STDERR \"%s\", $x;\n",
"say STDOUT \"hi\";\n",
"print DATA;\n",
"STDOUT->autoflush(1);\n",
"my $t = -t STDIN;\n",
] {
let analysis = parse_analysis(src);
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert!(
diags.is_empty(),
"filehandle FP for `{}`: {:?}",
src.trim(),
diags.iter().map(|d| d.message.clone()).collect::<Vec<_>>(),
);
}
}
#[test]
fn print_with_real_call_in_list_still_flags() {
let module_index = crate::module_index::ModuleIndex::new_for_test();
let analysis = parse_analysis("print STDERR \"a\", frobnicate();\n");
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert!(
diags.iter().any(|d| d.message.contains("frobnicate")),
"real call `frobnicate` in print list must still flag; got: {:?}",
diags.iter().map(|d| d.message.clone()).collect::<Vec<_>>(),
);
}
#[test]
fn use_constant_callsites_not_flagged() {
let module_index = crate::module_index::ModuleIndex::new_for_test();
for src in [
"use constant DEBUG => 1;\nmy $y = DEBUG && 2;\n",
"use constant { A => 1, B => 2 };\nmy $z = A() + B();\n",
] {
let analysis = parse_analysis(src);
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert!(
diags.is_empty(),
"use-constant callsite FP for `{}`: {:?}",
src.trim(),
diags.iter().map(|d| d.message.clone()).collect::<Vec<_>>(),
);
}
}
#[test]
fn require_bareword_not_flagged() {
let module_index = crate::module_index::ModuleIndex::new_for_test();
for src in ["require Carp;\n", "require Foo::Bar;\n"] {
let analysis = parse_analysis(src);
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert!(
diags.is_empty(),
"require-bareword FP for `{}`: {:?}",
src.trim(),
diags.iter().map(|d| d.message.clone()).collect::<Vec<_>>(),
);
}
}
fn cached_from_source(path: &str, source: &str) -> std::sync::Arc<crate::module_index::CachedModule> {
std::sync::Arc::new(crate::module_index::CachedModule::new(
std::path::PathBuf::from(path),
std::sync::Arc::new(parse_analysis(source)),
))
}
#[test]
fn export_tags_tag_import_diagnostic_agrees() {
let producer = r#"
package Perl::Critic::Utils;
Readonly::Array our @EXPORT_OK => qw( interpolate );
Readonly::Hash our %EXPORT_TAGS => (
all => [ @EXPORT_OK ],
data_conversion => [ qw{ hashify words_from_string interpolate } ],
);
sub hashify { 1 }
sub words_from_string { 2 }
sub interpolate { 3 }
1;
"#;
let consumer = "use Perl::Critic::Utils qw(:data_conversion);\nmy %h = hashify(@list);\n";
let analysis = parse_analysis(consumer);
let module_index = crate::module_index::ModuleIndex::new_for_test();
module_index.insert_cache(
"Perl::Critic::Utils",
Some(cached_from_source("/usr/lib/perl5/Perl/Critic/Utils.pm", producer)),
);
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let hashify_diags: Vec<_> = diags.iter().filter(|d| d.message.contains("hashify")).collect();
assert!(
hashify_diags.is_empty(),
"tag-imported `hashify` must not be flagged unresolved; got {:?}",
hashify_diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
);
let (_import, path, remote) =
resolve_imported_function(&analysis, "hashify", &module_index)
.expect("hashify resolves through the folded export surface");
assert_eq!(remote, "hashify");
assert!(path.ends_with("Perl/Critic/Utils.pm"));
}
#[test]
fn export_tags_non_member_still_unresolved() {
let producer = r#"
package Util::Tagged;
Readonly::Hash our %EXPORT_TAGS => (
data_conversion => [ qw{ hashify } ],
);
sub hashify { 1 }
sub private_helper { 2 }
1;
"#;
let consumer = "use Util::Tagged qw(:data_conversion);\nprivate_helper();\n";
let analysis = parse_analysis(consumer);
let module_index = crate::module_index::ModuleIndex::new_for_test();
module_index.insert_cache(
"Util::Tagged",
Some(cached_from_source("/usr/lib/perl5/Util/Tagged.pm", producer)),
);
assert!(
resolve_imported_function(&analysis, "private_helper", &module_index).is_none(),
"non-exported sub must not resolve through the export surface",
);
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
assert!(
diags.iter().any(|d| d.message.contains("private_helper")),
"non-exported sub must still be flagged; got {:?}",
diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
);
}
#[test]
fn imported_function_call_goto_def_reaches_module_sub() {
let provider_src = "package My::Util;\n\
our @EXPORT_OK = qw(helper_fn);\n\
sub helper_fn {\n\
my ($x) = @_;\n\
return $x * 2;\n\
}\n\
1;\n";
let idx = crate::module_index::ModuleIndex::new_for_test();
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_My_Util.pm"),
std::sync::Arc::new(parse_analysis(provider_src)),
);
let consumer_src = "use My::Util qw(helper_fn);\n\
my $v = helper_fn(21);\n";
let consumer = parse_analysis(consumer_src);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(consumer_src, None).unwrap();
let byte = consumer_src.find("helper_fn(21)").expect("call site present");
let prefix = &consumer_src[..byte];
let pos = Position {
line: prefix.matches('\n').count() as u32,
character: (byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32,
};
let uri = Url::parse("file:///consumer.pl").unwrap();
let resp = find_definition(&consumer, pos, &uri, &idx);
let loc = match resp {
Some(GotoDefinitionResponse::Scalar(loc)) => loc,
other => panic!("expected a single-hop Scalar to the module sub, got {other:?}"),
};
assert!(
loc.uri.path().ends_with("My_Util.pm"),
"goto-def should land in the provider file, got {}",
loc.uri,
);
assert_eq!(
loc.range.start.line, 2,
"should land on the defining `sub helper_fn` line, not the consumer's use stmt",
);
}
#[test]
fn class_invocant_goto_def_reaches_package_decl() {
let source = "package Foo;\n\
sub bar { 42 }\n\
sub new { bless {}, shift }\n\
package main;\n\
Foo->bar();\n";
let analysis = parse_analysis(source);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(source, None).unwrap();
let idx = crate::module_index::ModuleIndex::new_for_test();
let byte = source.find("Foo->bar").expect("invocant present");
let prefix = &source[..byte];
let pos = Position {
line: prefix.matches('\n').count() as u32,
character: (byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32 + 1,
};
let uri = Url::parse("file:///test.pl").unwrap();
let resp = find_definition(&analysis, pos, &uri, &idx);
let loc = match resp {
Some(GotoDefinitionResponse::Scalar(loc)) => loc,
other => panic!("expected goto-def on class invocant, got {other:?}"),
};
assert_eq!(
loc.range.start.line, 0,
"`Foo` invocant should resolve to `package Foo;` (line 0), not the constructor or method",
);
}
#[test]
fn method_token_goto_def_unaffected_by_invocant_package_ref() {
let source = "package Foo;\n\
sub bar { 42 }\n\
package main;\n\
Foo->bar();\n";
let analysis = parse_analysis(source);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(source, None).unwrap();
let idx = crate::module_index::ModuleIndex::new_for_test();
let byte = source.rfind("bar()").expect("method token present");
let prefix = &source[..byte];
let pos = Position {
line: prefix.matches('\n').count() as u32,
character: (byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32,
};
let uri = Url::parse("file:///test.pl").unwrap();
let resp = find_definition(&analysis, pos, &uri, &idx);
let loc = match resp {
Some(GotoDefinitionResponse::Scalar(loc)) => loc,
other => panic!("expected goto-def on method token, got {other:?}"),
};
assert_eq!(
loc.range.start.line, 1,
"`bar` method token should resolve to `sub bar` (line 1), not the package decl",
);
}
fn modx_index() -> crate::module_index::ModuleIndex {
let idx = crate::module_index::ModuleIndex::new_for_test();
let src = "package ModX;\n\
our @EXPORT = qw(always_here);\n\
our @EXPORT_OK = qw(opt_here);\n\
our %EXPORT_TAGS = (all => [qw(always_here opt_here)]);\n\
sub always_here { 1 }\n\
sub opt_here { 2 }\n\
1;\n";
idx.register_workspace_module(
std::path::PathBuf::from("/tmp/perl_lsp_pin_ModX.pm"),
std::sync::Arc::new(parse_analysis(src)),
);
idx
}
fn flags_fn(source: &str, name: &str, idx: &crate::module_index::ModuleIndex) -> bool {
let analysis = parse_analysis(source);
collect_diagnostics(&analysis, idx, Default::default())
.iter()
.any(|d| {
matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-function")
&& d.message.contains(&format!("'{}'", name))
})
}
fn gd_resolves(source: &str, name: &str, idx: &crate::module_index::ModuleIndex) -> bool {
let analysis = parse_analysis(source);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(source, None).unwrap();
let byte = source.rfind(&format!("{}(", name)).expect("call site present");
let prefix = &source[..byte];
let pos = Position {
line: prefix.matches('\n').count() as u32,
character: (byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32,
};
let uri = Url::parse("file:///consumer.pl").unwrap();
let loc = match find_definition(&analysis, pos, &uri, idx) {
Some(GotoDefinitionResponse::Scalar(loc)) => Some(loc),
Some(GotoDefinitionResponse::Array(mut v)) if !v.is_empty() => Some(v.remove(0)),
_ => None,
};
loc.map_or(false, |l| l.uri.path().ends_with("ModX.pm"))
}
#[test]
fn bare_use_binds_export_default_no_fp_and_gd_resolves() {
let idx = modx_index();
let src = "use ModX;\nalways_here();\n";
assert!(!flags_fn(src, "always_here", &idx), "bare use binds @EXPORT — no FP");
assert!(gd_resolves(src, "always_here", &idx), "goto-def resolves @EXPORT name");
}
#[test]
fn named_import_still_works_regression() {
let idx = modx_index();
let src = "use ModX qw(opt_here);\nopt_here();\n";
assert!(!flags_fn(src, "opt_here", &idx), "named import binds the name");
assert!(gd_resolves(src, "opt_here", &idx), "goto-def resolves named import");
}
#[test]
fn tag_selector_binds_members_no_fp_and_gd_resolves() {
let idx = modx_index();
let src = "use ModX qw(:all);\nopt_here();\n";
assert!(!flags_fn(src, "opt_here", &idx), ":all tag binds member opt_here");
assert!(gd_resolves(src, "opt_here", &idx), "goto-def resolves :tag member");
}
#[test]
fn default_tag_equals_export() {
let idx = modx_index();
let src = "use ModX qw(:DEFAULT);\nalways_here();\n";
assert!(!flags_fn(src, "always_here", &idx), ":DEFAULT binds @EXPORT member");
assert!(gd_resolves(src, "always_here", &idx), "goto-def resolves :DEFAULT member");
}
#[test]
fn as_rename_binds_local_to_origin() {
let idx = modx_index();
let src = "use ModX always_here => { -as => 'here' };\nhere();\n";
let analysis = parse_analysis(src);
let renamed = analysis
.imports
.iter()
.flat_map(|i| i.imported_symbols.iter())
.find(|s| s.local_name == "here");
assert!(
renamed.map_or(false, |s| s.remote() == "always_here"),
"the -as rename must bind local `here` to origin `always_here`; imports: {:?}",
analysis.imports.iter().map(|i| i.imported_symbols.clone()).collect::<Vec<_>>(),
);
assert!(!flags_fn(src, "here", &idx), "renamed local `here` is bound — no FP");
assert!(gd_resolves(src, "here", &idx), "goto-def on `here` reaches origin sub");
}
#[test]
fn as_rename_plain_comma_binds_local_to_origin() {
let idx = modx_index();
let src = "use ModX 'always_here', { '-as', 'here' };\nhere();\n";
let analysis = parse_analysis(src);
let renamed = analysis
.imports
.iter()
.flat_map(|i| i.imported_symbols.iter())
.find(|s| s.local_name == "here");
assert!(
renamed.map_or(false, |s| s.remote() == "always_here"),
"plain-comma -as rename must bind local `here` to origin `always_here`; imports: {:?}",
analysis.imports.iter().map(|i| i.imported_symbols.clone()).collect::<Vec<_>>(),
);
assert!(!flags_fn(src, "here", &idx), "renamed local `here` is bound — no FP");
assert!(gd_resolves(src, "here", &idx), "goto-def on `here` reaches origin sub");
}
#[test]
fn empty_import_binds_nothing_flags_export_name() {
let idx = modx_index();
let src = "use ModX ();\nalways_here();\n";
assert!(
flags_fn(src, "always_here", &idx),
"empty `()` import binds nothing — @EXPORT name must flag (honest)",
);
}
#[test]
fn export_ok_on_bare_use_is_suppressed_gate5() {
let idx = modx_index();
let src = "use ModX;\nopt_here();\n";
assert!(
!flags_fn(src, "opt_here", &idx),
"bare use must suppress unresolved-function for @EXPORT_OK names (684-FP guard)",
);
}
#[test]
fn diagnostic_and_gotodef_agree_on_bound_set() {
let idx = modx_index();
let cases: &[(&str, &str, bool)] = &[
("use ModX;\nalways_here();\n", "always_here", false),
("use ModX qw(opt_here);\nopt_here();\n", "opt_here", false),
("use ModX qw(:all);\nopt_here();\n", "opt_here", false),
("use ModX ();\nalways_here();\n", "always_here", true),
];
for (src, name, should_flag) in cases {
let flagged = flags_fn(src, name, &idx);
assert_eq!(
flagged, *should_flag,
"diagnostic verdict mismatch for `{}` in {:?}",
name, src,
);
if !should_flag {
assert!(
gd_resolves(src, name, &idx),
"a non-flagged (brought) name must goto-def resolve: `{}` in {:?}",
name, src,
);
}
}
}
fn register_module(idx: &crate::module_index::ModuleIndex, pkg: &str, src: &str) {
let file = format!("/tmp/perl_lsp_re_{}.pm", pkg.replace("::", "_"));
idx.register_workspace_module(
std::path::PathBuf::from(file),
std::sync::Arc::new(parse_analysis(src)),
);
}
fn gd_resolves_to(
source: &str,
name: &str,
idx: &crate::module_index::ModuleIndex,
target_file: &str,
) -> bool {
let analysis = parse_analysis(source);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_parser_perl::LANGUAGE.into()).unwrap();
let _tree = parser.parse(source, None).unwrap();
let byte = source.rfind(&format!("{}(", name)).expect("call site present");
let prefix = &source[..byte];
let pos = Position {
line: prefix.matches('\n').count() as u32,
character: (byte - prefix.rfind('\n').map(|i| i + 1).unwrap_or(0)) as u32,
};
let uri = Url::parse("file:///consumer.pl").unwrap();
let loc = match find_definition(&analysis, pos, &uri, idx) {
Some(GotoDefinitionResponse::Scalar(loc)) => Some(loc),
Some(GotoDefinitionResponse::Array(mut v)) if !v.is_empty() => Some(v.remove(0)),
_ => None,
};
loc.map_or(false, |l| l.uri.path().ends_with(target_file))
}
#[test]
fn reexport_static_splice_binds_transitively() {
let idx = crate::module_index::ModuleIndex::new_for_test();
register_module(&idx, "Base", "package Base;\nour @EXPORT = qw(base_fn);\nsub base_fn { 1 }\n1;\n");
register_module(
&idx,
"M",
"package M;\nour @EXPORT = ('own_fn', @Base::EXPORT);\nsub own_fn { 2 }\n1;\n",
);
let src = "use M;\nbase_fn();\nown_fn();\n";
assert!(!flags_fn(src, "base_fn", &idx), "re-exported base_fn binds, no FP");
assert!(!flags_fn(src, "own_fn", &idx), "own_fn binds, no FP");
assert!(
gd_resolves_to(src, "base_fn", &idx, "re_Base.pm"),
"goto-def base_fn reaches Base",
);
assert!(
gd_resolves_to(src, "own_fn", &idx, "re_M.pm"),
"goto-def own_fn reaches M",
);
}
#[test]
fn reexport_loop_push_literal_qw_binds() {
let idx = crate::module_index::ModuleIndex::new_for_test();
register_module(&idx, "A", "package A;\nour @EXPORT = qw(a_fn);\nsub a_fn { 1 }\n1;\n");
register_module(&idx, "B", "package B;\nour @EXPORT = qw(b_fn);\nsub b_fn { 1 }\n1;\n");
register_module(
&idx,
"M",
"package M;\nour @EXPORT = ();\nfor my $m (qw(A B)) {\n push @EXPORT, @{\"${m}::EXPORT\"};\n}\n1;\n",
);
let src = "use M;\na_fn();\nb_fn();\n";
assert!(!flags_fn(src, "a_fn", &idx), "loop-push re-exports A::a_fn");
assert!(!flags_fn(src, "b_fn", &idx), "loop-push re-exports B::b_fn");
assert!(gd_resolves_to(src, "a_fn", &idx, "re_A.pm"), "gd a_fn → A");
assert!(gd_resolves_to(src, "b_fn", &idx, "re_B.pm"), "gd b_fn → B");
}
#[test]
fn reexport_loop_push_samefile_array_binds() {
let idx = crate::module_index::ModuleIndex::new_for_test();
register_module(&idx, "A", "package A;\nour @EXPORT = qw(a_fn);\nsub a_fn { 1 }\n1;\n");
register_module(&idx, "B", "package B;\nour @EXPORT = qw(b_fn);\nsub b_fn { 1 }\n1;\n");
register_module(
&idx,
"M",
"package M;\nour @EXPORT = ();\nmy @mods = ('A', 'B');\nfor my $m (@mods) {\n push @EXPORT, @{\"${m}::EXPORT\"};\n}\n1;\n",
);
let src = "use M;\na_fn();\nb_fn();\n";
assert!(!flags_fn(src, "a_fn", &idx), "same-file @mods list re-exports A");
assert!(!flags_fn(src, "b_fn", &idx), "same-file @mods list re-exports B");
}
#[test]
fn reexport_loop_push_dynamic_list_mints_no_edge() {
let idx = crate::module_index::ModuleIndex::new_for_test();
register_module(&idx, "A", "package A;\nour @EXPORT = qw(a_fn);\nsub a_fn { 1 }\n1;\n");
register_module(
&idx,
"M",
"package M;\nour @EXPORT = ();\nfor my $m (@dynamic_runtime_list) {\n push @EXPORT, @{\"${m}::EXPORT\"};\n}\n1;\n",
);
let m = idx.get_cached("M").expect("M cached");
assert!(
m.analysis.reexport_modules.is_empty(),
"dynamic list must mint no re-export edge, got: {:?}",
m.analysis.reexport_modules,
);
let src = "use M;\na_fn();\n";
assert!(flags_fn(src, "a_fn", &idx), "dynamic list → a_fn stays unresolved (honest)");
}
#[test]
fn reexport_declarative_also_binds() {
let idx = crate::module_index::ModuleIndex::new_for_test();
register_module(&idx, "Base", "package Base;\nour @EXPORT = qw(base_fn);\nsub base_fn { 1 }\n1;\n");
register_module(
&idx,
"M",
"package M;\nuse Moose::Exporter;\nMoose::Exporter->setup_import_methods( also => [ 'Base' ] );\n1;\n",
);
let m = idx.get_cached("M").expect("M cached");
assert!(
m.analysis.reexport_modules.contains(&"Base".to_string()),
"also => ['Base'] mints a re-export edge, got: {:?}",
m.analysis.reexport_modules,
);
let src = "use M;\nbase_fn();\n";
assert!(!flags_fn(src, "base_fn", &idx), "also-re-exported base_fn binds");
assert!(gd_resolves_to(src, "base_fn", &idx, "re_Base.pm"), "gd base_fn → Base");
}
#[test]
fn reexport_cycle_resolves_finitely() {
let idx = crate::module_index::ModuleIndex::new_for_test();
register_module(
&idx,
"A",
"package A;\nour @EXPORT = ('a_fn', @B::EXPORT);\nsub a_fn { 1 }\n1;\n",
);
register_module(
&idx,
"B",
"package B;\nour @EXPORT = ('b_fn', @A::EXPORT);\nsub b_fn { 1 }\n1;\n",
);
let src = "use A;\na_fn();\nb_fn();\n";
assert!(!flags_fn(src, "a_fn", &idx), "cycle: a_fn binds");
assert!(!flags_fn(src, "b_fn", &idx), "cycle: b_fn binds via A→B edge");
}
#[test]
fn reexport_imported_names_evaluator_unchanged() {
let idx = crate::module_index::ModuleIndex::new_for_test();
register_module(&idx, "Base", "package Base;\nour @EXPORT = qw(base_fn);\nsub base_fn { 1 }\n1;\n");
register_module(
&idx,
"M",
"package M;\nour @EXPORT = ('own_fn', @Base::EXPORT);\nsub own_fn { 2 }\n1;\n",
);
let m = idx.get_cached("M").expect("M cached");
let consumer = parse_analysis("use M;\n");
let import = consumer.imports.iter().find(|i| i.module_name == "M").expect("use M import");
let own = m.analysis.export_surface();
let bound_own = crate::file_analysis::imported_names(import, &own);
assert!(bound_own.iter().any(|(l, _)| l == "own_fn"));
assert!(
!bound_own.iter().any(|(l, _)| l == "base_fn"),
"own-only surface must not include the re-exported name",
);
let walked = m.analysis.export_surface_with_index(&idx);
let bound_walked = crate::file_analysis::imported_names(import, &walked);
assert!(bound_walked.iter().any(|(l, _)| l == "own_fn"));
assert!(
bound_walked.iter().any(|(l, _)| l == "base_fn"),
"transitive surface includes the re-exported name — via the SAME evaluator",
);
}
#[test]
fn test_goto_def_deferred_ctor_key_cross_file() {
let point_src = "\
use v5.38;
class Point {
field $x :param :reader;
}
1;
";
let module_index = crate::module_index::ModuleIndex::new_for_test();
module_index.insert_cache(
"Point",
Some(std::sync::Arc::new(crate::module_index::CachedModule::new(
std::path::PathBuf::from("/tmp/sym_defer_point.pm"),
std::sync::Arc::new(parse_analysis(point_src)),
))),
);
let consumer_src = "use Point;\nmy $p = Point->new(x => 1);\n";
let analysis = parse_analysis(consumer_src);
let uri = Url::parse("file:///tmp/sym_defer_consumer.pl").unwrap();
let resp = find_definition(
&analysis,
Position { line: 1, character: 19 },
&uri,
&module_index,
);
let Some(GotoDefinitionResponse::Scalar(loc)) = resp else {
panic!("expected scalar goto-def, got {:?}", resp);
};
assert!(
loc.uri.path().ends_with("sym_defer_point.pm"),
"lands in the class file: {:?}",
loc.uri,
);
assert_eq!(loc.range.start.line, 2, "lands on the field decl line");
}
#[test]
fn test_closed_shape_unknown_key_diagnostic() {
let src = "\
my $config = { host => 'x', port => 1 };
my $bad = $config->{typo};
my $ok = $config->{host};
my $mutv = { host => 'x' };
$mutv->{added} = 1;
my $r0 = $mutv->{added};
my $r1 = $mutv->{other};
my $cond = { host => 'x' };
$cond->{maybe} = 1 if $ENV{X};
my $rc = $cond->{anything};
my $esc = { host => 'x' };
my $pre = $esc->{typo_pre};
process($esc);
my $r2 = $esc->{anything};
my $re = { host => 'x' };
$re = fetch_config() if $ENV{X};
my $r3 = $re->{whatever};
my $base = { a => 1 };
my $open = { %$base, extra => 1 };
my $maybe = $open->{whatever};
";
let analysis = parse_analysis(src);
let idx = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(
&analysis,
&idx,
DiagnosticOptions { unresolved_dispatch: false },
);
let keys: Vec<&str> = diags
.iter()
.filter(|d| matches!(&d.code, Some(NumberOrString::String(c)) if c == "unknown-hash-key"))
.map(|d| d.message.as_str())
.collect();
assert_eq!(
keys.len(),
3,
"the typo, the post-extension unknown, and the pre-escape typo: {:?}",
keys,
);
assert!(keys[0].contains("'typo'"), "{:?}", keys);
assert!(keys[0].contains("host"), "message names the known keys: {:?}", keys);
assert!(keys[1].contains("'other'"), "{:?}", keys);
assert!(
keys[1].contains("added"),
"extended shape names the written key: {:?}",
keys,
);
assert!(
keys[2].contains("'typo_pre'"),
"read BEFORE the escape still hints: {:?}",
keys,
);
}
#[test]
fn test_literal_hash_unknown_key_diagnostic() {
let src = "\
my %config = (host => 'x');
my $bad = $config{typo};
func(%config);
my %taken = (host => 'x');
my $r = \\%taken;
my $silent = $taken{anything};
";
let analysis = parse_analysis(src);
let idx = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(
&analysis,
&idx,
DiagnosticOptions { unresolved_dispatch: false },
);
let keys: Vec<&str> = diags
.iter()
.filter(|d| matches!(&d.code, Some(NumberOrString::String(c)) if c == "unknown-hash-key"))
.map(|d| d.message.as_str())
.collect();
assert_eq!(keys.len(), 1, "only the %config typo: {:?}", keys);
assert!(keys[0].contains("'typo'"), "{:?}", keys);
assert!(keys[0].contains("%config"), "names the hash variable: {:?}", keys);
}
#[test]
fn test_expression_base_unknown_key_diagnostic() {
let src = "\
sub cfg { return { host => 'x', port => 1 } }
my $ok = cfg()->{host};
my $bad = cfg()->{hsot};
cfg()->{hsot2};
";
let analysis = parse_analysis(src);
let idx = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(
&analysis,
&idx,
DiagnosticOptions { unresolved_dispatch: false },
);
let keys: Vec<&str> = diags
.iter()
.filter(|d| matches!(&d.code, Some(NumberOrString::String(c)) if c == "unknown-hash-key"))
.map(|d| d.message.as_str())
.collect();
assert_eq!(
keys.len(),
2,
"assignment-position and bare-statement call-base typos: {:?}",
keys,
);
assert!(keys[0].contains("'hsot'"), "{:?}", keys);
assert!(
keys[0].contains("this expression's"),
"expression-base message form: {:?}",
keys,
);
assert!(
keys[1].contains("'hsot2'"),
"bare-statement drill is witnessed too: {:?}",
keys,
);
}
#[test]
fn test_role_requires_suppresses_unresolved_method() {
let src = "\
package My::Role;
use Moo::Role;
requires qw/fetch source/;
requires 'extra';
sub run {
my ($self) = @_;
$self->fetch;
$self->source;
$self->extra;
$self->typo_method;
}
1;
";
let analysis = parse_analysis(src);
let idx = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(
&analysis,
&idx,
DiagnosticOptions { unresolved_dispatch: false },
);
let unresolved: Vec<&str> = diags
.iter()
.filter(|d| matches!(&d.code, Some(NumberOrString::String(c)) if c == "unresolved-method"))
.map(|d| d.message.as_str())
.collect();
assert_eq!(unresolved.len(), 1, "only the typo: {:?}", unresolved);
assert!(unresolved[0].contains("typo_method"), "{:?}", unresolved);
assert_eq!(
analysis.role_requires.get("My::Role").map(|v| v.len()),
Some(3),
"the contract record carries all three names",
);
}
#[test]
fn test_anon_subs_hidden_from_workspace_symbols() {
let src = "\
my $cb = sub { return 42 };
sub real_sub { 1 }
";
let analysis = parse_analysis(src);
let uri = tower_lsp::lsp_types::Url::parse("file:///t.pl").unwrap();
let names: Vec<String> = analysis
.symbols
.iter()
.filter_map(|s| symbol_to_workspace_info(s, uri.clone()))
.map(|i| i.name)
.collect();
assert!(
names.iter().any(|n| n == "real_sub"),
"real subs surface: {:?}",
names,
);
assert!(
!names.iter().any(|n| n.contains("anon")),
"anon subs stay out: {:?}",
names,
);
}
#[test]
fn test_lexical_subs_outline_only() {
let src = "\
my sub helper_fn { 42 }
sub public_fn { helper_fn() }
";
let analysis = parse_analysis(src);
let uri = tower_lsp::lsp_types::Url::parse("file:///t.pl").unwrap();
let ws: Vec<String> = analysis
.symbols
.iter()
.filter_map(|s| symbol_to_workspace_info(s, uri.clone()))
.map(|i| i.name)
.collect();
assert!(ws.iter().any(|n| n == "public_fn"), "{:?}", ws);
assert!(!ws.iter().any(|n| n == "helper_fn"), "lexical stays out of workspace: {:?}", ws);
let outline = analysis.document_symbols();
let names: Vec<&str> = outline.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"helper_fn"), "outline keeps the lexical sub: {:?}", names);
assert!(names.contains(&"public_fn"), "{:?}", names);
}
fn role_requires_diags(source: &str) -> Vec<String> {
let analysis = parse_analysis(source);
let module_index = crate::module_index::ModuleIndex::new_for_test();
collect_diagnostics(&analysis, &module_index, Default::default())
.into_iter()
.filter(|d| {
matches!(&d.code, Some(tower_lsp::lsp_types::NumberOrString::String(c))
if c == "role-requires-unfulfilled")
})
.map(|d| d.message)
.collect()
}
#[test]
fn test_role_requires_unfulfilled_fires_on_missing_def() {
let msgs = role_requires_diags(
"package My::Role;\nuse Moo::Role;\nrequires 'fetch';\n\
package My::Broken;\nuse Moo;\nwith 'My::Role';\nsub other { 1 }\n1;\n",
);
assert_eq!(
msgs,
vec!["role My::Role requires 'fetch'; My::Broken does not provide it"],
);
}
#[test]
fn test_role_requires_satisfied_stays_quiet() {
let msgs = role_requires_diags(
"package My::Role;\nuse Moo::Role;\nrequires 'fetch';\n\
package My::Provider;\nuse Moo::Role;\nsub fetch { 9 }\n\
package My::Ok;\nuse Moo;\nwith 'My::Role';\nsub fetch { 1 }\n\
package My::Attr;\nuse Moo;\nwith 'My::Role';\nhas fetch => (is => 'ro');\n\
package My::Sibling;\nuse Moo;\nwith 'My::Role', 'My::Provider';\n1;\n",
);
assert!(msgs.is_empty(), "expected no diagnostics, got: {:?}", msgs);
}
#[test]
fn test_role_requires_transitive_and_marker_not_a_def() {
let msgs = role_requires_diags(
"package My::Role;\nuse Moo::Role;\nrequires 'fetch';\n\
package My::SubRole;\nuse Moo::Role;\nwith 'My::Role';\nrequires 'fetch';\n\
package My::Deep;\nuse Moo;\nwith 'My::SubRole';\nsub fetch { 7 }\n\
package My::Broken2;\nuse Moo;\nwith 'My::SubRole';\n1;\n",
);
assert_eq!(msgs.len(), 1, "only Broken2 should fire, got: {:?}", msgs);
assert!(msgs[0].contains("My::Broken2 does not provide it"), "got: {:?}", msgs);
}
#[test]
fn test_role_requires_honest_silence() {
let msgs = role_requires_diags(
"package My::Role;\nuse Moo::Role;\nrequires 'fetch';\n\
package My::Auto;\nuse Moo;\nwith 'My::Role';\nsub AUTOLOAD { }\n\
package My::Mystery;\nuse Moo;\nextends 'Vendor::Unknown';\nwith 'My::Role';\n1;\n",
);
assert!(msgs.is_empty(), "expected honest silence, got: {:?}", msgs);
}
#[test]
fn test_role_requires_default_implementation_provides() {
let msgs = role_requires_diags(
"package My::Role;\nuse Moo::Role;\nrequires 'fetch';\nsub fetch { 'default' }\n\
package My::Composer;\nuse Moo;\nwith 'My::Role';\n1;\n",
);
assert!(msgs.is_empty(), "default impl in the role provides, got: {:?}", msgs);
}
#[test]
fn test_role_requires_dynamic_parent_honest_silence() {
let analysis = parse_analysis(
"package My::Role;\nuse Moo::Role;\nrequires 'fetch';\n\
package My::Dynamic;\nuse Moo;\nwith RoleGen(type => 'x');\nwith 'My::Role';\n\
sub run { my $self = shift; $self->fetch }\n1;\n",
);
assert!(
analysis.dynamic_parent_packages.contains("My::Dynamic"),
"unfoldable with-arg must mark the package dynamic",
);
let module_index = crate::module_index::ModuleIndex::new_for_test();
let diags = collect_diagnostics(&analysis, &module_index, Default::default());
let role_or_method: Vec<&String> = diags
.iter()
.filter(|d| {
matches!(&d.code, Some(tower_lsp::lsp_types::NumberOrString::String(c))
if c == "role-requires-unfulfilled" || c == "unresolved-method")
})
.map(|d| &d.message)
.collect();
assert!(
role_or_method.is_empty(),
"dynamic parents must suppress, got: {:?}",
role_or_method,
);
}
fn helper_lint_setup() -> (crate::module_index::ModuleIndex, String) {
let plugin_src = "package My::Plugin::WasLoaded;\nuse Mojo::Base 'Mojolicious::Plugin';\n\
sub register {\n my ($self, $app, $conf) = @_;\n $app->helper(was_loaded => sub { 1 });\n}\n1;\n";
let idx = crate::module_index::ModuleIndex::new_for_test();
let mut parser = crate::builder::create_parser();
let tree = parser.parse(plugin_src, None).unwrap();
let fa = crate::builder::build(&tree, plugin_src.as_bytes());
idx.register_workspace_module(
std::path::PathBuf::from("/fake/lint/My/Plugin/WasLoaded.pm"),
std::sync::Arc::new(fa),
);
let consumer_src = "package MyApp::C;\nuse Mojo::Base 'Mojolicious::Controller';\n\
sub act {\n my $self = shift;\n $self->was_loaded;\n}\n1;\n"
.to_string();
(idx, consumer_src)
}
fn lint_messages(idx: &crate::module_index::ModuleIndex, src: &str) -> Vec<String> {
let analysis = parse_analysis(src);
collect_diagnostics(&analysis, idx, Default::default())
.into_iter()
.filter(|d| {
matches!(&d.code, Some(tower_lsp::lsp_types::NumberOrString::String(c))
if c == "helper-not-loaded")
})
.map(|d| d.message)
.collect()
}
#[test]
fn test_helper_not_loaded_fires_for_unloaded_workspace_plugin() {
let (idx, consumer) = helper_lint_setup();
let msgs = lint_messages(&idx, &consumer);
assert_eq!(
msgs,
vec!["'was_loaded' is provided by My::Plugin::WasLoaded, which no workspace entrypoint loads"],
);
}
#[test]
fn test_helper_not_loaded_suppressed_when_an_entrypoint_loads_it() {
let (idx, consumer) = helper_lint_setup();
let entry_src = "use My::Plugin::WasLoaded;\nprint 1;\n";
let mut parser = crate::builder::create_parser();
let tree = parser.parse(entry_src, None).unwrap();
let fa = crate::builder::build(&tree, entry_src.as_bytes());
idx.register_workspace_module(
std::path::PathBuf::from("/fake/lint/app.pl"),
std::sync::Arc::new(fa),
);
assert!(lint_messages(&idx, &consumer).is_empty());
}
#[test]
fn test_helper_not_loaded_exempts_installed_plugins() {
let plugin_src = "package My::Plugin::WasLoaded;\nuse Mojo::Base 'Mojolicious::Plugin';\n\
sub register {\n my ($self, $app, $conf) = @_;\n $app->helper(was_loaded => sub { 1 });\n}\n1;\n";
let idx = crate::module_index::ModuleIndex::new_for_test();
let mut parser = crate::builder::create_parser();
let tree = parser.parse(plugin_src, None).unwrap();
let fa = crate::builder::build(&tree, plugin_src.as_bytes());
idx.insert_cache(
"My::Plugin::WasLoaded",
Some(std::sync::Arc::new(crate::file_analysis::CachedModule::new(
std::path::PathBuf::from("/inc/My/Plugin/WasLoaded.pm"),
std::sync::Arc::new(fa),
))),
);
let consumer = "package MyApp::C;\nuse Mojo::Base 'Mojolicious::Controller';\n\
sub act {\n my $self = shift;\n $self->was_loaded;\n}\n1;\n";
assert!(lint_messages(&idx, consumer).is_empty());
}