use crate::common::create_psr4_workspace;
use phpantom_lsp::Backend;
use phpantom_lsp::composer::parse_autoload_classmap;
use std::collections::HashMap;
use std::fs;
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
#[tokio::test]
async fn test_cross_file_double_colon_psr4() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Service.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Service {\n",
" public static function create(): self { return new self(); }\n",
" public function run(): void {}\n",
" const VERSION = '1.0';\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"class App {\n",
" function boot() {\n",
" Acme\\Service::\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve Acme\\Service::"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.label.as_str())
.collect();
assert!(
method_names.contains(&"create"),
"Should include static 'create', got {:?}",
method_names
);
assert!(
!method_names.contains(&"run"),
"Should exclude non-static 'run'"
);
assert!(
constant_names.contains(&"VERSION"),
"Should include constant 'VERSION', got {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_new_variable_psr4() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Widget.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Widget {\n",
" public function render(): string { return ''; }\n",
" public string $title;\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///page.php").unwrap();
let text = concat!(
"<?php\n",
"class Page {\n",
" function show() {\n",
" $w = new Acme\\Widget();\n",
" $w->\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve $w-> to Acme\\Widget members"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let prop_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.label.as_str())
.collect();
assert!(
method_names.contains(&"render"),
"Should include 'render', got {:?}",
method_names
);
assert!(
prop_names.contains(&"title"),
"Should include property 'title', got {:?}",
prop_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_param_type_hint_psr4() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Logger.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Logger {\n",
" public function info(string $msg): void {}\n",
" public function error(string $msg): void {}\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///handler.php").unwrap();
let text = concat!(
"<?php\n",
"class Handler {\n",
" function handle(Acme\\Logger $log) {\n",
" $log->\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve $log-> via param type hint"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"info"),
"Should include 'info', got {:?}",
method_names
);
assert!(
method_names.contains(&"error"),
"Should include 'error', got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_caches_parsed_class() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Cache.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Cache {\n",
" public static function get(string $key): mixed { return null; }\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"class Controller {\n",
" function index() {\n",
" Acme\\Cache::\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 3,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result1 = backend.completion(completion_params.clone()).await.unwrap();
assert!(result1.is_some(), "First call should resolve");
let result2 = backend.completion(completion_params).await.unwrap();
assert!(result2.is_some(), "Second call should also resolve");
match result2.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"get"),
"Cached result should still include 'get', got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_no_psr4_mapping_falls_back() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[], );
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"class App {\n",
" function boot() {\n",
" Unknown\\Thing::\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 24,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_none(),
"Should return None when no PSR-4 mapping resolves the class"
);
}
#[tokio::test]
async fn test_cross_file_nested_namespace_psr4() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Vendor\\Package\\": "src/"
}
}
}"#,
&[(
"src/Sub/Helper.php",
concat!(
"<?php\n",
"namespace Vendor\\Package\\Sub;\n",
"class Helper {\n",
" public static function format(): string { return ''; }\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Test {\n",
" function run() {\n",
" Vendor\\Package\\Sub\\Helper::\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 36,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should resolve deeply nested namespace");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"format"),
"Should include 'format', got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_use_statement_new_variable() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Klarna\\": "src/Klarna/"
}
}
}"#,
&[(
"src/Klarna/Rest/Resource.php",
concat!(
"<?php\n",
"namespace Klarna\\Rest;\n",
"class Resource {\n",
" public function request(string $method): self { return $this; }\n",
" public string $url;\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///order.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Klarna\\Rest\\Checkout;\n",
"\n",
"use Klarna\\Rest\\Resource;\n",
"\n",
"class Order {\n",
" public function create() {\n",
" $e = new Resource();\n",
" $e->\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve $e-> via use statement + PSR-4"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let prop_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.label.as_str())
.collect();
assert!(
method_names.contains(&"request"),
"Should include 'request', got {:?}",
method_names
);
assert!(
prop_names.contains(&"url"),
"Should include property 'url', got {:?}",
prop_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_use_statement_double_colon() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Factory.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Factory {\n",
" public static function build(): self { return new self(); }\n",
" const VERSION = 1;\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"use Acme\\Factory;\n",
"\n",
"class App {\n",
" function boot() {\n",
" Factory::\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 17,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve Factory:: via use statement"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.label.as_str())
.collect();
assert!(
method_names.contains(&"build"),
"Should include static 'build', got {:?}",
method_names
);
assert!(
constant_names.contains(&"VERSION"),
"Should include constant 'VERSION', got {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_use_statement_aliased() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Service.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Service {\n",
" public function execute(): void {}\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///runner.php").unwrap();
let text = concat!(
"<?php\n",
"use Acme\\Service as Svc;\n",
"\n",
"class Runner {\n",
" function run() {\n",
" $s = new Svc();\n",
" $s->\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve aliased $s-> via use statement"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"execute"),
"Should include 'execute', got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_use_statement_param_type_hint() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Mailer.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Mailer {\n",
" public function send(string $to): bool { return true; }\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///notify.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"use Acme\\Mailer;\n",
"\n",
"class Notifier {\n",
" function notify(Mailer $m) {\n",
" $m->\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve $m-> via use-statement + param type hint"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"send"),
"Should include 'send', got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_classmap_resolution() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
fs::write(
dir.path().join("composer.json"),
r#"{"name": "test/project"}"#,
)
.expect("failed to write composer.json");
let class_dir = dir.path().join("lib").join("Legacy");
fs::create_dir_all(&class_dir).expect("failed to create dirs");
fs::write(
class_dir.join("Widget.php"),
concat!(
"<?php\n",
"namespace Legacy;\n",
"class Widget {\n",
" public function render(): string { return ''; }\n",
" public static function create(): self { return new self(); }\n",
" const TYPE = 'widget';\n",
"}\n",
),
)
.expect("failed to write class file");
let composer_dir = dir.path().join("vendor").join("composer");
fs::create_dir_all(&composer_dir).expect("failed to create vendor/composer");
fs::write(
composer_dir.join("autoload_classmap.php"),
concat!(
"<?php\n",
"\n",
"$vendorDir = dirname(__DIR__);\n",
"$baseDir = dirname($vendorDir);\n",
"\n",
"return array(\n",
" 'Legacy\\\\Widget' => $baseDir . '/lib/Legacy/Widget.php',\n",
");\n",
),
)
.expect("failed to write autoload_classmap.php");
let backend = Backend::new_test_with_workspace(dir.path().to_path_buf(), vec![]);
let classmap = parse_autoload_classmap(dir.path(), "vendor");
assert_eq!(classmap.len(), 1, "Should parse 1 classmap entry");
{
let mut cm = backend.classmap().write();
*cm = classmap;
}
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"class App {\n",
" function boot() {\n",
" $w = new Legacy\\Widget();\n",
" $w->\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 13,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve Legacy\\Widget via classmap"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"render"),
"Should include instance method 'render' resolved via classmap, got {:?}",
method_names
);
assert!(
!method_names.contains(&"create"),
"Should exclude static 'create' from -> access"
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_classmap_double_colon() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
fs::write(
dir.path().join("composer.json"),
r#"{"name": "test/project"}"#,
)
.expect("failed to write composer.json");
let class_dir = dir.path().join("vendor").join("acme").join("src");
fs::create_dir_all(&class_dir).expect("failed to create dirs");
fs::write(
class_dir.join("Factory.php"),
concat!(
"<?php\n",
"namespace Acme;\n",
"class Factory {\n",
" public static function build(): self { return new self(); }\n",
" public function configure(): void {}\n",
" const DEFAULT_CONFIG = 'default';\n",
"}\n",
),
)
.expect("failed to write class file");
let composer_dir = dir.path().join("vendor").join("composer");
fs::create_dir_all(&composer_dir).expect("failed to create vendor/composer");
fs::write(
composer_dir.join("autoload_classmap.php"),
concat!(
"<?php\n",
"$vendorDir = dirname(__DIR__);\n",
"$baseDir = dirname($vendorDir);\n",
"\n",
"return array(\n",
" 'Acme\\\\Factory' => $vendorDir . '/acme/src/Factory.php',\n",
");\n",
),
)
.expect("failed to write autoload_classmap.php");
let backend = Backend::new_test_with_workspace(dir.path().to_path_buf(), vec![]);
let classmap = parse_autoload_classmap(dir.path(), "vendor");
{
let mut cm = backend.classmap().write();
*cm = classmap;
}
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"class App {\n",
" function boot() {\n",
" Acme\\Factory::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should resolve Acme\\Factory via classmap"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.label.as_str())
.collect();
assert!(
method_names.contains(&"build"),
"Should include static 'build' via classmap, got {:?}",
method_names
);
assert!(
!method_names.contains(&"configure"),
"Should exclude non-static 'configure' from :: access"
);
assert!(
constant_names.contains(&"DEFAULT_CONFIG"),
"Should include constant 'DEFAULT_CONFIG' via classmap, got {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_namespace_relative_resolution() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Sibling.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Sibling {\n",
" public function greet(): string { return 'hi'; }\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///main.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Acme;\n",
"\n",
"class Main {\n",
" function run() {\n",
" $s = new Sibling();\n",
" $s->\n",
" }\n",
"}\n",
);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve $s-> via namespace-relative lookup"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"greet"),
"Should include 'greet', got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_unqualified_class_in_namespace_falls_back_to_global() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"PDO",
"<?php\nclass PDO {\n public static function getAvailableDrivers(): array {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"\n",
"class Foo {\n",
" function test() {\n",
" PDO::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 13,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
let has_pdo_methods = match &result {
Some(CompletionResponse::Array(items)) => items
.iter()
.any(|i| i.kind == Some(CompletionItemKind::METHOD)),
_ => false,
};
assert!(
has_pdo_methods,
"Bare `PDO::` in namespace Demo should fall back to global \\PDO"
);
}
#[tokio::test]
async fn test_fqn_class_in_namespace_resolves_globally() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"PDO",
"<?php\nclass PDO {\n public static function getAvailableDrivers(): array {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"\n",
"class Foo {\n",
" function test() {\n",
" \\PDO::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "\\PDO:: should resolve to global PDO");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getAvailableDrivers"),
"\\PDO:: should show global PDO methods, got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_imported_class_in_namespace_resolves_via_use() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"PDO",
"<?php\nclass PDO {\n public static function getAvailableDrivers(): array {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"\n",
"use PDO;\n",
"\n",
"class Foo {\n",
" function test() {\n",
" PDO::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 13,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"PDO:: with `use PDO;` should resolve to global PDO"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getAvailableDrivers"),
"Imported PDO:: should show global PDO methods, got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_unqualified_class_in_global_scope_resolves() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"PDO",
"<?php\nclass PDO {\n public static function getAvailableDrivers(): array {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "PDO::\n",);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 1,
character: 5,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"PDO:: in global scope should resolve to PDO"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getAvailableDrivers"),
"PDO:: in global scope should show methods, got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_fqn_namespaced_class_resolves_globally() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Service.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Service {\n",
" public static function create(): self { return new self(); }\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Other;\n",
"\n",
"class Foo {\n",
" function test() {\n",
" \\Acme\\Service::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 24,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "\\Acme\\Service:: should resolve via FQN");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"create"),
"\\Acme\\Service:: should show 'create', got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_aliased_import_resolves_in_namespace() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"PDO",
"<?php\nclass PDO {\n public static function getAvailableDrivers(): array {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"\n",
"use PDO as DB;\n",
"\n",
"class Foo {\n",
" function test() {\n",
" DB::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"DB:: with `use PDO as DB;` should resolve to global PDO"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getAvailableDrivers"),
"Aliased DB:: should show global PDO methods, got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_cross_file_variable_in_standalone_function() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Widget.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Widget {\n",
" public function render(): string { return ''; }\n",
" public string $title;\n",
"}\n",
),
)],
);
let uri_class = Url::parse("file:///test_class.php").unwrap();
let text_class = concat!(
"<?php\n",
"use Acme\\Widget;\n",
"class Page {\n",
" function show() {\n",
" $w = new Widget();\n",
" $w->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri_class.clone(),
language_id: "php".to_string(),
version: 1,
text: text_class.to_string(),
},
})
.await;
let result_class = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: uri_class.clone(),
},
position: Position {
line: 5,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
let class_methods: Vec<&str> = match result_class.as_ref() {
Some(CompletionResponse::Array(items)) => items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect(),
_ => vec![],
};
assert!(
class_methods.contains(&"render"),
"Baseline: $w-> inside class method should resolve Widget, got {:?}",
class_methods
);
let uri_func = Url::parse("file:///test_func.php").unwrap();
let text_func = concat!(
"<?php\n",
"use Acme\\Widget;\n",
"function demo() {\n",
" $w = new Widget();\n",
" $w->\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri_func.clone(),
language_id: "php".to_string(),
version: 1,
text: text_func.to_string(),
},
})
.await;
let result_func = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: uri_func.clone(),
},
position: Position {
line: 4,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
let func_methods: Vec<&str> = match result_func.as_ref() {
Some(CompletionResponse::Array(items)) => items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect(),
_ => vec![],
};
assert!(
func_methods.contains(&"render"),
"$w-> inside standalone function should resolve Widget, got {:?}",
func_methods
);
}
#[tokio::test]
async fn test_cross_file_static_chain_variable_in_standalone_function() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[
(
"src/Product.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Product {\n",
" public function getName(): string { return ''; }\n",
"}\n",
),
),
(
"src/Builder.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Builder {\n",
" public function build(): Product { return new Product(); }\n",
"}\n",
),
),
(
"src/Factory.php",
concat!(
"<?php\n",
"namespace Acme;\n",
"class Factory {\n",
" public static function create(): Builder { return new Builder(); }\n",
"}\n",
),
),
],
);
let uri_inline = Url::parse("file:///test_inline.php").unwrap();
let text_inline = concat!(
"<?php\n",
"use Acme\\Factory;\n",
"function test_inline() {\n",
" Factory::create()->build()->\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri_inline.clone(),
language_id: "php".to_string(),
version: 1,
text: text_inline.to_string(),
},
})
.await;
let result_inline = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: uri_inline.clone(),
},
position: Position {
line: 3,
character: 33,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
let inline_methods: Vec<&str> = match result_inline.as_ref() {
Some(CompletionResponse::Array(items)) => items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect(),
_ => vec![],
};
let uri_func = Url::parse("file:///test_chain_func.php").unwrap();
let text_func = concat!(
"<?php\n",
"use Acme\\Factory;\n",
"function test_chain() {\n",
" $p = Factory::create()->build();\n",
" $p->\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri_func.clone(),
language_id: "php".to_string(),
version: 1,
text: text_func.to_string(),
},
})
.await;
let result_func = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: uri_func.clone(),
},
position: Position {
line: 4,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
let func_methods: Vec<&str> = match result_func.as_ref() {
Some(CompletionResponse::Array(items)) => items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect(),
_ => vec![],
};
let uri_top = Url::parse("file:///test_chain_top.php").unwrap();
let text_top = concat!(
"<?php\n",
"use Acme\\Factory;\n",
"$p = Factory::create()->build();\n",
"$p->\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri_top.clone(),
language_id: "php".to_string(),
version: 1,
text: text_top.to_string(),
},
})
.await;
let result_top = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: uri_top.clone(),
},
position: Position {
line: 3,
character: 4,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
let top_methods: Vec<&str> = match result_top.as_ref() {
Some(CompletionResponse::Array(items)) => items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect(),
_ => vec![],
};
assert!(
inline_methods.contains(&"getName"),
"Inline chain Factory::create()->build()-> should resolve to Product, got {:?}",
inline_methods
);
assert!(
func_methods.contains(&"getName"),
"$p = Factory::create()->build() in function should resolve to Product, got {:?}\n\
inline chain: {:?}\n\
top-level: {:?}",
func_methods,
inline_methods,
top_methods
);
}