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);
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);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::INFORMATION));
assert!(diags[0].message.contains("frobnicate"));
}
#[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);
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);
assert!(
diags.is_empty(),
"Package-qualified calls should not produce diagnostic",
);
}
#[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,
&tree,
)
.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 = analysis.resolve_expression_type(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 =
analysis.resolve_expression_type(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
);
}
}