use crate::common::{
create_psr4_workspace, create_test_backend_with_function_stubs, create_test_backend_with_stubs,
};
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::*;
async fn complete_at(
backend: &Backend,
uri: &Url,
text: &str,
line: u32,
character: u32,
) -> Vec<CompletionItem> {
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: uri.clone() },
position: Position { line, character },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
match result {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
None => vec![],
}
}
fn class_items(items: &[CompletionItem]) -> Vec<&CompletionItem> {
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CLASS))
.collect()
}
fn labels(items: &[CompletionItem]) -> Vec<&str> {
items.iter().map(|i| i.label.as_str()).collect()
}
fn find_by_fqn<'a>(items: &[&'a CompletionItem], fqn: &str) -> Option<&'a CompletionItem> {
items
.iter()
.find(|i| i.detail.as_deref() == Some(fqn))
.copied()
}
fn fqns<'a>(items: &'a [&'a CompletionItem]) -> Vec<&'a str> {
items.iter().filter_map(|i| i.detail.as_deref()).collect()
}
#[test]
fn test_extract_partial_class_name_simple() {
let content = "<?php\nnew Dat\n";
let result = Backend::extract_partial_class_name(
content,
Position {
line: 1,
character: 7,
},
);
assert_eq!(result, Some("Dat".to_string()));
}
#[test]
fn test_extract_partial_class_name_with_namespace() {
let content = "<?php\nnew App\\Models\\Us\n";
let result = Backend::extract_partial_class_name(
content,
Position {
line: 1,
character: 19,
},
);
assert_eq!(result, Some("App\\Models\\Us".to_string()));
}
#[test]
fn test_extract_partial_class_name_variable_returns_none() {
let content = "<?php\n$var\n";
let result = Backend::extract_partial_class_name(
content,
Position {
line: 1,
character: 4,
},
);
assert!(
result.is_none(),
"Variables ($var) should not trigger class name completion"
);
}
#[test]
fn test_extract_partial_class_name_empty_returns_none() {
let content = "<?php\n\n";
let result = Backend::extract_partial_class_name(
content,
Position {
line: 1,
character: 0,
},
);
assert!(
result.is_none(),
"Empty position should not trigger class name completion"
);
}
#[test]
fn test_extract_partial_class_name_after_arrow_returns_none() {
let content = "<?php\n$this->meth\n";
let result = Backend::extract_partial_class_name(
content,
Position {
line: 1,
character: 11,
},
);
assert!(
result.is_none(),
"After -> should not trigger class name completion"
);
}
#[test]
fn test_extract_partial_class_name_after_double_colon_returns_none() {
let content = "<?php\nFoo::bar\n";
let result = Backend::extract_partial_class_name(
content,
Position {
line: 1,
character: 8,
},
);
assert!(
result.is_none(),
"After :: should not trigger class name completion"
);
}
#[test]
fn test_extract_partial_class_name_type_hint_context() {
let content = "<?php\nfunction foo(Str $x) {}\n";
let result = Backend::extract_partial_class_name(
content,
Position {
line: 1,
character: 16,
},
);
assert_eq!(result, Some("Str".to_string()));
}
#[test]
fn test_extract_partial_class_name_with_leading_backslash() {
let content = "<?php\nnew \\Run\n";
let result = Backend::extract_partial_class_name(
content,
Position {
line: 1,
character: 8,
},
);
assert_eq!(
result,
Some("\\Run".to_string()),
"Leading backslash should be included in the partial"
);
}
#[tokio::test]
async fn test_class_name_completion_with_leading_backslash() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///backslash.php").unwrap();
let text = concat!("<?php\n", "function foo(\\Unit $x) {}\n",);
let items = complete_at(&backend, &uri, text, 1, 17).await;
let classes = class_items(&items);
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
assert!(
class_labels.contains(&"UnitEnum"),
"Typing '\\Unit' should match 'UnitEnum', got: {:?}",
class_labels
);
}
#[tokio::test]
async fn test_class_name_completion_backslash_backed() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///backslash2.php").unwrap();
let text = concat!("<?php\n", "function foo(\\Backed $x) {}\n",);
let items = complete_at(&backend, &uri, text, 1, 19).await;
let classes = class_items(&items);
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
assert!(
class_labels.contains(&"BackedEnum"),
"Typing '\\Backed' should match 'BackedEnum', got: {:?}",
class_labels
);
}
#[tokio::test]
async fn test_class_name_completion_fqn_prefix() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!(
"<?php\n",
"namespace App\\Models;\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///fqn_test.php").unwrap();
let text = concat!("<?php\n", "new \\Us\n",);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = std::fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let items = complete_at(&backend, &uri, text, 1, 7).await;
let classes = class_items(&items);
let user_item = classes
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Models\\User"));
assert!(
user_item.is_some(),
"Typing '\\Us' should match App\\Models\\User, got details: {:?}",
classes
.iter()
.map(|i| i.detail.as_deref())
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_class_name_completion_includes_stubs() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test.php").unwrap();
let text_unit = concat!("<?php\n", "$x instanceof Unit\n",);
let items_unit = complete_at(&backend, &uri, text_unit, 1, 18).await;
let classes_unit = class_items(&items_unit);
let labels_unit: Vec<&str> = classes_unit.iter().map(|i| i.label.as_str()).collect();
assert!(
!classes_unit.is_empty(),
"Should return class name completions when typing a class name"
);
assert!(
labels_unit.contains(&"UnitEnum"),
"Should include stub interface 'UnitEnum', got: {:?}",
labels_unit
);
let text_backed = concat!("<?php\n", "$x instanceof Backed\n",);
let items_backed = complete_at(&backend, &uri, text_backed, 1, 20).await;
let classes_backed = class_items(&items_backed);
let labels_backed: Vec<&str> = classes_backed.iter().map(|i| i.label.as_str()).collect();
assert!(
labels_backed.contains(&"BackedEnum"),
"Should include stub interface 'BackedEnum', got: {:?}",
labels_backed
);
}
#[tokio::test]
async fn test_class_name_completion_not_triggered_for_variables() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "$unit\n",);
let items = complete_at(&backend, &uri, text, 1, 5).await;
let classes = class_items(&items);
assert!(
classes.is_empty(),
"Should not return class name completions after $, got: {:?}",
labels(&items)
);
}
#[tokio::test]
async fn test_class_name_completion_includes_use_imports() {
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 run(): void {}\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!("<?php\n", "use Acme\\Service;\n", "new Ser\n",);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let classes = class_items(&items);
let class_fqns = fqns(&classes);
assert!(
class_fqns.contains(&"Acme\\Service"),
"Should include use-imported class 'Acme\\Service', got: {:?}",
class_fqns
);
let service_item = find_by_fqn(&classes, "Acme\\Service").unwrap();
assert_eq!(
service_item.label, "Service",
"Label should be the short name"
);
assert_eq!(
service_item.detail.as_deref(),
Some("Acme\\Service"),
"Detail should show FQN"
);
}
#[tokio::test]
async fn test_class_name_completion_use_import_has_higher_sort_priority() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Acme\\": "src/"
}
}
}"#,
&[(
"src/Widget.php",
concat!("<?php\n", "namespace Acme;\n", "class Widget {}\n",),
)],
);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!("<?php\n", "use Acme\\Widget;\n", "new Wid\n",);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let classes = class_items(&items);
let widget_item = find_by_fqn(&classes, "Acme\\Widget").unwrap();
let sort = widget_item.sort_text.as_deref().unwrap_or("");
assert!(
sort.len() > 1 && &sort[1..2] == "0",
"Use-imported classes should have source tier '0' at position 1, got: {:?}",
sort
);
}
#[tokio::test]
async fn test_class_name_completion_same_namespace() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/UserService.php",
concat!(
"<?php\n",
"namespace App;\n",
"class UserService {\n",
" public function find(): void {}\n",
"}\n",
),
),
(
"src/Controller.php",
concat!(
"<?php\n",
"namespace App;\n",
"class Controller {\n",
" public function index() {\n",
" new User\n",
" }\n",
"}\n",
),
),
],
);
let service_uri = Url::parse("file:///service.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: service_uri.clone(),
language_id: "php".to_string(),
version: 1,
text: concat!(
"<?php\n",
"namespace App;\n",
"class UserService {\n",
" public function find(): void {}\n",
"}\n",
)
.to_string(),
},
})
.await;
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App;\n",
"class Controller {\n",
" public function index() {\n",
" new User\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 4, 16).await;
let classes = class_items(&items);
let class_fqns = fqns(&classes);
assert!(
class_fqns.contains(&"App\\UserService"),
"Should include same-namespace class 'App\\UserService', got: {:?}",
class_fqns
);
let service_item = find_by_fqn(&classes, "App\\UserService").unwrap();
let sort = service_item.sort_text.as_deref().unwrap_or("");
assert!(
sort.len() > 1 && &sort[1..2] == "1",
"Same-namespace classes should have source tier '1' at position 1, got: {:?}",
sort
);
}
#[tokio::test]
async fn test_class_name_completion_from_classmap() {
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 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",
" 'Illuminate\\\\Support\\\\Collection' => $vendorDir . '/laravel/framework/src/Illuminate/Support/Collection.php',\n",
" 'Illuminate\\\\Database\\\\Eloquent\\\\Model' => $vendorDir . '/laravel/framework/src/Illuminate/Database/Eloquent/Model.php',\n",
" 'Carbon\\\\Carbon' => $vendorDir . '/nesbot/carbon/src/Carbon/Carbon.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(), 3);
{
let mut cm = backend.classmap().write();
*cm = classmap;
}
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!("<?php\n", "new Coll\n",);
let items = complete_at(&backend, &uri, text, 1, 8).await;
let classes = class_items(&items);
let class_fqns = fqns(&classes);
assert!(
class_fqns.contains(&"Illuminate\\Support\\Collection"),
"Should include classmap class 'Illuminate\\Support\\Collection', got: {:?}",
class_fqns
);
let text_mo = concat!("<?php\n", "new Mo\n",);
let items_mo = complete_at(&backend, &uri, text_mo, 1, 6).await;
let classes_mo = class_items(&items_mo);
let fqns_mo = fqns(&classes_mo);
assert!(
fqns_mo.contains(&"Illuminate\\Database\\Eloquent\\Model"),
"Should include classmap class 'Illuminate\\Database\\Eloquent\\Model', got: {:?}",
fqns_mo
);
let text_car = concat!("<?php\n", "new Car\n",);
let items_car = complete_at(&backend, &uri, text_car, 1, 7).await;
let classes_car = class_items(&items_car);
let fqns_car = fqns(&classes_car);
assert!(
fqns_car.contains(&"Carbon\\Carbon"),
"Should include classmap class 'Carbon\\Carbon', got: {:?}",
fqns_car
);
let collection = find_by_fqn(&classes, "Illuminate\\Support\\Collection")
.expect("Should have a Collection item with FQN Illuminate\\Support\\Collection in detail");
assert_eq!(
collection.detail.as_deref(),
Some("Illuminate\\Support\\Collection"),
"Detail should show FQN for classmap entries"
);
}
#[tokio::test]
async fn test_class_name_completion_from_class_index() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"App\\Models\\User".to_string(),
"file:///app/Models/User.php".to_string(),
);
idx.insert(
"App\\Models\\Order".to_string(),
"file:///app/Models/Order.php".to_string(),
);
}
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "new Us\n",);
let items = complete_at(&backend, &uri, text, 1, 6).await;
let classes = class_items(&items);
let class_fqns = fqns(&classes);
assert!(
class_fqns.contains(&"App\\Models\\User"),
"Should include class_index class 'App\\Models\\User', got: {:?}",
class_fqns
);
let text_or = concat!("<?php\n", "new Or\n",);
let items_or = complete_at(&backend, &uri, text_or, 1, 6).await;
let classes_or = class_items(&items_or);
let fqns_or = fqns(&classes_or);
assert!(
fqns_or.contains(&"App\\Models\\Order"),
"Should include class_index class 'App\\Models\\Order', got: {:?}",
fqns_or
);
}
#[tokio::test]
async fn test_class_name_completion_deduplicates_by_fqn() {
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 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\\\\Duplicated' => $vendorDir . '/acme/src/Duplicated.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 mut idx = backend.class_index().write();
idx.insert(
"Acme\\Duplicated".to_string(),
"file:///acme/src/Duplicated.php".to_string(),
);
}
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "new Dup\n",);
let items = complete_at(&backend, &uri, text, 1, 7).await;
let classes = class_items(&items);
let dup_count = classes
.iter()
.filter(|i| i.detail.as_deref() == Some("Acme\\Duplicated"))
.count();
assert_eq!(
dup_count, 1,
"Should deduplicate classes with the same FQN, got {} occurrences",
dup_count
);
}
#[tokio::test]
async fn test_class_name_completion_after_new_keyword() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class BackupService {\n",
" function bar() {\n",
" $x = new Back\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 3, 21).await;
let classes = class_items(&items);
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
assert!(
class_labels.contains(&"BackupService"),
"Should offer class names after 'new' keyword, got: {:?}",
class_labels
);
assert!(
!class_labels.contains(&"BackedEnum"),
"Should not offer interface stubs after 'new', got: {:?}",
class_labels
);
}
#[tokio::test]
async fn test_class_name_completion_in_type_hint() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "function process(Unit $x) {}\n",);
let items = complete_at(&backend, &uri, text, 1, 21).await;
let classes = class_items(&items);
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
assert!(
class_labels.contains(&"UnitEnum"),
"Should offer class names in type hint position, got: {:?}",
class_labels
);
}
#[tokio::test]
async fn test_class_name_completion_in_extends_clause() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "interface MyEnum extends Back\n",);
let items = complete_at(&backend, &uri, text, 1, 32).await;
let classes = class_items(&items);
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
assert!(
class_labels.contains(&"BackedEnum"),
"Should offer interface names in interface extends clause, got: {:?}",
class_labels
);
}
#[tokio::test]
async fn test_class_name_completion_not_after_arrow() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" public function bar(): void {}\n",
"}\n",
"$f = new Foo();\n",
"$f->ba\n",
);
let items = complete_at(&backend, &uri, text, 5, 6).await;
let classes = class_items(&items);
assert!(
classes.is_empty(),
"Should NOT return class name completions after ->, got: {:?}",
labels(&items)
);
}
#[tokio::test]
async fn test_class_name_completion_items_have_class_kind() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "$x instanceof Uni\n",);
let items = complete_at(&backend, &uri, text, 1, 17).await;
let classes = class_items(&items);
assert!(
!classes.is_empty(),
"Should have at least one class completion"
);
for item in &classes {
assert_eq!(
item.kind,
Some(CompletionItemKind::CLASS),
"All class name completions should have kind=CLASS, item '{}' has kind={:?}",
item.label,
item.kind
);
}
}
#[tokio::test]
async fn test_class_name_completion_combines_all_sources() {
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 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",
"return array(\n",
" 'Vendor\\\\ClassmapClass' => $vendorDir . '/vendor/src/ClassmapClass.php',\n",
");\n",
),
)
.expect("failed to write autoload_classmap.php");
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"StubClass",
"<?php\nclass StubClass {\n public function stubMethod(): void {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
*backend.workspace_root().write() = Some(dir.path().to_path_buf());
let classmap = parse_autoload_classmap(dir.path(), "vendor");
{
let mut cm = backend.classmap().write();
*cm = classmap;
}
{
let mut idx = backend.class_index().write();
idx.insert(
"App\\IndexedClass".to_string(),
"file:///app/IndexedClass.php".to_string(),
);
}
let uri = Url::parse("file:///test.php").unwrap();
let text_stub = concat!("<?php\n", "use App\\IndexedClass;\n", "new Stub\n",);
let items_stub = complete_at(&backend, &uri, text_stub, 2, 8).await;
let classes_stub = class_items(&items_stub);
let labels_stub: Vec<&str> = classes_stub.iter().map(|i| i.label.as_str()).collect();
assert!(
labels_stub.contains(&"StubClass"),
"Should include stub class, got: {:?}",
labels_stub
);
let text_cm = concat!("<?php\n", "use App\\IndexedClass;\n", "new Classmap\n",);
let items_cm = complete_at(&backend, &uri, text_cm, 2, 12).await;
let classes_cm = class_items(&items_cm);
let fqns_cm = fqns(&classes_cm);
assert!(
fqns_cm.contains(&"Vendor\\ClassmapClass"),
"Should include classmap class, got: {:?}",
fqns_cm
);
let text_idx = concat!("<?php\n", "use App\\IndexedClass;\n", "new Indexed\n",);
let items_idx = complete_at(&backend, &uri, text_idx, 2, 11).await;
let classes_idx = class_items(&items_idx);
let fqns_idx = fqns(&classes_idx);
assert!(
fqns_idx.contains(&"App\\IndexedClass"),
"Should include use-imported / class_index class, got: {:?}",
fqns_idx
);
}
#[tokio::test]
async fn test_class_name_completion_insert_text_is_short_name() {
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 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",
"return array(\n",
" 'Deep\\\\Nested\\\\Namespace\\\\MyClass' => $vendorDir . '/pkg/src/MyClass.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:///test.php").unwrap();
let text = concat!("<?php\n", "new My\n",);
let items = complete_at(&backend, &uri, text, 1, 6).await;
let classes = class_items(&items);
let my_class = find_by_fqn(&classes, "Deep\\Nested\\Namespace\\MyClass")
.expect("Should find Deep\\Nested\\Namespace\\MyClass by FQN");
assert_eq!(
my_class.insert_text.as_deref(),
Some("MyClass()$0"),
"insert_text should be the short class name with parens in `new` context"
);
assert_eq!(
my_class.insert_text_format,
Some(InsertTextFormat::SNIPPET),
"insert_text_format should be Snippet in `new` context"
);
assert_eq!(
my_class.detail.as_deref(),
Some("Deep\\Nested\\Namespace\\MyClass"),
"detail should show the FQN"
);
}
#[tokio::test]
async fn test_auto_import_classmap_class_adds_use_statement() {
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 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",
"return array(\n",
" 'Illuminate\\\\Support\\\\Collection' => $vendorDir . '/laravel/framework/src/Collection.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",
"namespace App;\n",
"use App\\Helpers\\Foo;\n",
"\n",
"new Coll\n",
);
let items = complete_at(&backend, &uri, text, 4, 8).await;
let collection = items
.iter()
.find(|i| i.detail.as_deref() == Some("Illuminate\\Support\\Collection"))
.expect("Should have Collection completion");
let edits = collection
.additional_text_edits
.as_ref()
.expect("Classmap class should have additional_text_edits for auto-import");
assert_eq!(edits.len(), 1);
assert_eq!(
edits[0].new_text, "use Illuminate\\Support\\Collection;\n",
"Should insert a use statement for the FQN"
);
assert_eq!(
edits[0].range.start,
Position {
line: 3,
character: 0,
},
"Should insert after the last existing use statement"
);
}
#[tokio::test]
async fn test_auto_import_class_index_adds_use_statement() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"App\\Services\\PaymentService".to_string(),
"file:///app/Services/PaymentService.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Controllers;\n",
"\n",
"new Payment\n",
);
let items = complete_at(&backend, &uri, text, 3, 11).await;
let payment = items
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Services\\PaymentService"))
.expect("Should have PaymentService completion");
let edits = payment
.additional_text_edits
.as_ref()
.expect("class_index class should have additional_text_edits");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "use App\\Services\\PaymentService;\n",);
assert_eq!(
edits[0].range.start,
Position {
line: 2,
character: 0,
},
);
}
#[tokio::test]
async fn test_no_auto_import_for_non_namespaced_class() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"DateTime",
"<?php\nclass DateTime {\n public function format(string $f): string {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "new DateT\n",);
let items = complete_at(&backend, &uri, text, 1, 9).await;
let dt = items
.iter()
.find(|i| i.label == "DateTime")
.expect("Should have DateTime completion");
assert!(
dt.additional_text_edits.is_none(),
"Non-namespaced class should not get auto-import edits, got: {:?}",
dt.additional_text_edits
);
}
#[tokio::test]
async fn test_no_auto_import_for_already_imported_class() {
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 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",
"return array(\n",
" 'Illuminate\\\\Support\\\\Collection' => $vendorDir . '/laravel/framework/src/Collection.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",
"use Illuminate\\Support\\Collection;\n",
"\n",
"new Coll\n",
);
let items = complete_at(&backend, &uri, text, 3, 8).await;
let collection = items
.iter()
.find(|i| i.detail.as_deref() == Some("Illuminate\\Support\\Collection"))
.expect("Should have Collection completion");
assert!(
collection.additional_text_edits.is_none(),
"Already-imported class should not get auto-import edits"
);
}
#[tokio::test]
async fn test_auto_import_inserts_after_php_open_tag() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"Vendor\\Lib\\Widget".to_string(),
"file:///vendor/lib/Widget.php".to_string(),
);
}
let uri = Url::parse("file:///bare.php").unwrap();
let text = concat!("<?php\n", "\n", "new Wid\n",);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let widget = items
.iter()
.find(|i| i.detail.as_deref() == Some("Vendor\\Lib\\Widget"))
.expect("Should have Widget completion");
let edits = widget
.additional_text_edits
.as_ref()
.expect("Should have auto-import edit");
assert_eq!(edits[0].new_text, "use Vendor\\Lib\\Widget;\n");
assert_eq!(
edits[0].range.start,
Position {
line: 1,
character: 0,
},
);
}
#[tokio::test]
async fn test_auto_import_not_confused_by_trait_use_in_class_body() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"Cassandra\\DefaultCluster".to_string(),
"file:///vendor/cassandra/DefaultCluster.php".to_string(),
);
}
let uri = Url::parse("file:///showcase.php").unwrap();
let text = concat!(
"<?php\n", "\n", "namespace Demo;\n", "\n", "use Exception;\n", "use Stringable;\n", "\n", "class User extends Model implements Renderable\n", "{\n", " use HasTimestamps;\n", " use HasSlug;\n", "\n", " function test() {\n", " new Default\n", " }\n", "}\n", );
let items = complete_at(&backend, &uri, text, 13, 19).await;
let cluster = items
.iter()
.find(|i| i.detail.as_deref() == Some("Cassandra\\DefaultCluster"))
.expect("Should have DefaultCluster completion");
let edits = cluster
.additional_text_edits
.as_ref()
.expect("Should have auto-import edit");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "use Cassandra\\DefaultCluster;\n",);
assert_eq!(
edits[0].range.start,
Position {
line: 4,
character: 0,
},
"Auto-import should be inserted alphabetically among top-level use statements"
);
}
#[tokio::test]
async fn test_auto_import_global_class_when_file_has_namespace() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"PDO",
"<?php\nclass PDO {\n public function query(string $q): mixed {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n", "\n", "namespace App\\Db;\n", "\n", "new PD\n", );
let items = complete_at(&backend, &uri, text, 4, 6).await;
let pdo = items
.iter()
.find(|i| i.label == "PDO")
.expect("Should have PDO completion");
let edits = pdo
.additional_text_edits
.as_ref()
.expect("Global class should get auto-import when file has a namespace");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "use PDO;\n");
assert_eq!(
edits[0].range.start,
Position {
line: 3,
character: 0,
},
);
}
#[tokio::test]
async fn test_no_auto_import_global_class_when_file_has_no_namespace() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"PDO",
"<?php\nclass PDO {\n public function query(string $q): mixed {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "new PD\n",);
let items = complete_at(&backend, &uri, text, 1, 6).await;
let pdo = items
.iter()
.find(|i| i.label == "PDO")
.expect("Should have PDO completion");
assert!(
pdo.additional_text_edits.is_none(),
"Global class should NOT get auto-import when file has no namespace, got: {:?}",
pdo.additional_text_edits
);
}
#[tokio::test]
async fn test_auto_import_global_class_inserts_after_existing_use_statements() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert(
"PDO",
"<?php\nclass PDO {\n public function query(string $q): mixed {}\n}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n", "\n", "namespace App\\Service;\n", "\n", "use App\\Repository\\UserRepository;\n", "use App\\Entity\\User;\n", "\n", "new PD\n", );
let items = complete_at(&backend, &uri, text, 7, 6).await;
let pdo = items
.iter()
.find(|i| i.label == "PDO")
.expect("Should have PDO completion");
let edits = pdo
.additional_text_edits
.as_ref()
.expect("Global class should get auto-import when file has a namespace");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "use PDO;\n");
assert_eq!(
edits[0].range.start,
Position {
line: 6,
character: 0,
},
);
}
#[tokio::test]
async fn test_new_context_excludes_constants_and_functions() {
let backend = create_test_backend_with_function_stubs();
let uri = Url::parse("file:///test_new_no_const_func.php").unwrap();
let text = concat!("<?php\n", "new Date\n",);
let items = complete_at(&backend, &uri, text, 1, 8).await;
let has_class = items
.iter()
.any(|i| i.kind == Some(CompletionItemKind::CLASS));
assert!(has_class, "Should include class completions after `new`");
let constants: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.label.as_str())
.collect();
assert!(
constants.is_empty(),
"Should not include constants after `new`, got: {:?}",
constants
);
let functions: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FUNCTION))
.map(|i| i.label.as_str())
.collect();
assert!(
functions.is_empty(),
"Should not include functions after `new`, got: {:?}",
functions
);
}
#[tokio::test]
async fn test_non_new_context_includes_constants_and_functions() {
let backend = create_test_backend_with_function_stubs();
let uri = Url::parse("file:///test_no_new.php").unwrap();
let text = concat!("<?php\n", "PHP_\n",);
let items = complete_at(&backend, &uri, text, 1, 4).await;
let constants: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.label.as_str())
.collect();
assert!(
!constants.is_empty(),
"Should include constants without `new`, got: {:?}",
labels(&items)
);
}
#[tokio::test]
async fn test_new_context_excludes_loaded_abstract_class() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test_new_abstract.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App;\n",
"abstract class AbstractWidget {}\n",
"class ConcreteWidget extends AbstractWidget {}\n",
"new Wid\n",
);
let items = complete_at(&backend, &uri, text, 4, 7).await;
let classes = class_items(&items);
let class_fqns = fqns(&classes);
assert!(
class_fqns.contains(&"App\\ConcreteWidget"),
"Should include concrete class, got: {:?}",
class_fqns
);
assert!(
!class_fqns.contains(&"App\\AbstractWidget"),
"Should exclude loaded abstract class, got: {:?}",
class_fqns
);
}
#[tokio::test]
async fn test_new_context_excludes_loaded_interface() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test_new_iface.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App;\n",
"interface Renderable {}\n",
"class HtmlRenderer implements Renderable {}\n",
"new Render\n",
);
let items = complete_at(&backend, &uri, text, 4, 10).await;
let classes = class_items(&items);
let class_fqns = fqns(&classes);
assert!(
class_fqns.contains(&"App\\HtmlRenderer"),
"Should include concrete class, got: {:?}",
class_fqns
);
assert!(
!class_fqns.contains(&"App\\Renderable"),
"Should exclude loaded interface, got: {:?}",
class_fqns
);
}
#[tokio::test]
async fn test_new_context_excludes_loaded_trait() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test_new_trait.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App;\n",
"trait Loggable {}\n",
"class Logger { use Loggable; }\n",
"new Logg\n",
);
let items = complete_at(&backend, &uri, text, 4, 8).await;
let classes = class_items(&items);
let class_fqns = fqns(&classes);
assert!(
class_fqns.contains(&"App\\Logger"),
"Should include concrete class, got: {:?}",
class_fqns
);
assert!(
!class_fqns.contains(&"App\\Loggable"),
"Should exclude loaded trait, got: {:?}",
class_fqns
);
}
#[tokio::test]
async fn test_new_context_excludes_loaded_enum() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test_new_enum.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App;\n",
"enum ColorEnum { case Red; case Blue; }\n",
"class ColorPicker {}\n",
"new Color\n",
);
let items = complete_at(&backend, &uri, text, 4, 9).await;
let classes = class_items(&items);
let class_fqns = fqns(&classes);
assert!(
class_fqns.contains(&"App\\ColorPicker"),
"Should include concrete class, got: {:?}",
class_fqns
);
assert!(
!class_fqns.contains(&"App\\ColorEnum"),
"Should exclude loaded enum, got: {:?}",
class_fqns
);
}
#[tokio::test]
async fn test_new_context_demotes_likely_non_instantiable_classmap() {
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 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",
"return array(\n",
" 'Vendor\\\\ConcreteHandler' => $vendorDir . '/src/ConcreteHandler.php',\n",
" 'Vendor\\\\ImageHandler' => $vendorDir . '/src/ImageHandler.php',\n",
" 'Vendor\\\\DatabaseHandler' => $vendorDir . '/src/DatabaseHandler.php',\n",
" 'Vendor\\\\BaselineHandler' => $vendorDir . '/src/BaselineHandler.php',\n",
" 'Vendor\\\\AbstractHandler' => $vendorDir . '/src/AbstractHandler.php',\n",
" 'Vendor\\\\HandlerAbstract' => $vendorDir . '/src/HandlerAbstract.php',\n",
" 'Vendor\\\\HandlerInterface' => $vendorDir . '/src/HandlerInterface.php',\n",
" 'Vendor\\\\IHandler' => $vendorDir . '/src/IHandler.php',\n",
" 'Vendor\\\\BaseHandler' => $vendorDir . '/src/BaseHandler.php',\n",
");\n",
),
)
.expect("failed to write autoload_classmap.php");
let backend = Backend::new_test();
*backend.workspace_root().write() = Some(dir.path().to_path_buf());
let classmap = parse_autoload_classmap(dir.path(), "vendor");
{
let mut cm = backend.classmap().write();
*cm = classmap;
}
let uri = Url::parse("file:///test_new_demote.php").unwrap();
let text = concat!("<?php\n", "new Handler\n",);
let items = complete_at(&backend, &uri, text, 1, 11).await;
let classes = class_items(&items);
let demote_flag = |item: &CompletionItem| -> char {
item.sort_text
.as_deref()
.and_then(|s| s.chars().nth(6))
.unwrap_or('?')
};
let concrete = find_by_fqn(&classes, "Vendor\\ConcreteHandler")
.expect("Should find Vendor\\ConcreteHandler");
assert_eq!(
demote_flag(concrete),
'0',
"ConcreteHandler should not be demoted, sort_text: {:?}",
concrete.sort_text
);
let demoted_names = [
"Vendor\\AbstractHandler",
"Vendor\\HandlerAbstract",
"Vendor\\HandlerInterface",
"Vendor\\IHandler",
"Vendor\\BaseHandler",
];
for name in &demoted_names {
let item = find_by_fqn(&classes, name)
.unwrap_or_else(|| panic!("Should find {} (unloaded, included but demoted)", name));
assert_eq!(
demote_flag(item),
'1',
"{} should be demoted (flag '1'), sort_text: {:?}",
name,
item.sort_text
);
}
let abstract_h = find_by_fqn(&classes, "Vendor\\AbstractHandler")
.expect("Should find Vendor\\AbstractHandler");
assert!(
concrete.sort_text < abstract_h.sort_text,
"ConcreteHandler ({:?}) should sort before AbstractHandler ({:?}) \
(same match quality, demoted vs normal)",
concrete.sort_text,
abstract_h.sort_text
);
let image =
find_by_fqn(&classes, "Vendor\\ImageHandler").expect("Should find Vendor\\ImageHandler");
assert_eq!(
demote_flag(image),
'0',
"ImageHandler should not be demoted, sort_text: {:?}",
image.sort_text
);
let database = find_by_fqn(&classes, "Vendor\\DatabaseHandler")
.expect("Should find Vendor\\DatabaseHandler");
assert_eq!(
demote_flag(database),
'0',
"DatabaseHandler should not be demoted, sort_text: {:?}",
database.sort_text
);
let baseline = find_by_fqn(&classes, "Vendor\\BaselineHandler")
.expect("Should find Vendor\\BaselineHandler");
assert_eq!(
demote_flag(baseline),
'0',
"BaselineHandler should not be demoted, sort_text: {:?}",
baseline.sort_text
);
}
#[tokio::test]
async fn test_new_context_excludes_abstract_stubs() {
let mut stubs: HashMap<&str, &str> = HashMap::new();
stubs.insert("ConcreteService", "<?php\nclass ConcreteService {}\n");
stubs.insert(
"AbstractService",
"<?php\nabstract class AbstractService {}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///test_new_demote_stubs.php").unwrap();
let text = concat!("<?php\n", "new Service\n",);
let items = complete_at(&backend, &uri, text, 1, 11).await;
let classes = class_items(&items);
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
assert!(
class_labels.contains(&"ConcreteService"),
"Should find ConcreteService in new context, got: {:?}",
class_labels
);
assert!(
!class_labels.contains(&"AbstractService"),
"Abstract stub should be excluded from new context, got: {:?}",
class_labels
);
}
#[tokio::test]
async fn test_new_context_excludes_use_imported_interface() {
let (backend, _dir) = create_psr4_workspace(
r#"{"autoload": {"psr-4": {"App\\": "src/"}}}"#,
&[
(
"src/Contracts/Cacheable.php",
"<?php\nnamespace App\\Contracts;\ninterface Cacheable {\n public function cacheKey(): string;\n}\n",
),
(
"src/Models/CacheStore.php",
"<?php\nnamespace App\\Models;\nclass CacheStore {}\n",
),
],
);
let iface_uri = Url::parse("file:///iface.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: iface_uri.clone(),
language_id: "php".to_string(),
version: 1,
text: "<?php\nnamespace App\\Contracts;\ninterface Cacheable {\n public function cacheKey(): string;\n}\n".to_string(),
},
})
.await;
let class_uri = Url::parse("file:///cls.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: class_uri.clone(),
language_id: "php".to_string(),
version: 1,
text: "<?php\nnamespace App\\Models;\nclass CacheStore {}\n".to_string(),
},
})
.await;
let uri = Url::parse("file:///test_new_use_iface.php").unwrap();
let text = concat!(
"<?php\n",
"use App\\Contracts\\Cacheable;\n",
"use App\\Models\\CacheStore;\n",
"new Cache\n",
);
let items = complete_at(&backend, &uri, text, 3, 9).await;
let classes = class_items(&items);
let class_fqns = fqns(&classes);
assert!(
class_fqns.contains(&"App\\Models\\CacheStore"),
"Should include concrete use-imported class, got: {:?}",
class_fqns
);
assert!(
!class_fqns.contains(&"App\\Contracts\\Cacheable"),
"Should exclude use-imported interface in `new` context, got: {:?}",
class_fqns
);
}
#[tokio::test]
async fn test_new_context_excludes_class_index_abstract() {
let backend = create_test_backend_with_stubs();
let abs_uri = Url::parse("file:///app/AbstractRepo.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: abs_uri.clone(),
language_id: "php".to_string(),
version: 1,
text: "<?php\nnamespace App;\nabstract class AbstractRepo {}\n".to_string(),
},
})
.await;
{
let mut idx = backend.class_index().write();
idx.insert("App\\AbstractRepo".to_string(), abs_uri.to_string());
}
let uri = Url::parse("file:///test_new_idx_abs.php").unwrap();
let text = concat!("<?php\n", "new AbstractR\n",);
let items = complete_at(&backend, &uri, text, 1, 13).await;
let class_labels: Vec<&str> = class_items(&items)
.iter()
.map(|i| i.label.as_str())
.collect();
assert!(
!class_labels.contains(&"App\\AbstractRepo"),
"Should exclude class_index entry that is loaded as abstract, got: {:?}",
class_labels
);
}
#[tokio::test]
async fn test_fqn_prefix_matches_by_namespace() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!(
"<?php\n",
"namespace App\\Models;\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
"}\n",
),
)],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = std::fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let uri = Url::parse("file:///fqn_prefix.php").unwrap();
let text = concat!("<?php\n", "new App\\Models\\U\n",);
let items = complete_at(&backend, &uri, text, 1, 16).await;
let classes = class_items(&items);
let user_item = classes
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Models\\User"))
.expect("Should find User via FQN prefix App\\Models\\U");
assert_eq!(
user_item.label, "App\\Models\\User",
"Label should be the FQN in FQN-prefix mode"
);
assert!(
user_item.text_edit.is_some(),
"FQN-prefix completions should have a text_edit with explicit range"
);
}
#[tokio::test]
async fn test_fqn_prefix_with_leading_backslash() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
)],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = std::fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let uri = Url::parse("file:///fqn_leading.php").unwrap();
let text = concat!("<?php\n", "new \\App\\Models\\U\n",);
let items = complete_at(&backend, &uri, text, 1, 17).await;
let classes = class_items(&items);
let user_item = classes
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Models\\User"))
.expect("Should find User via FQN prefix \\App\\Models\\U");
let insert = user_item.insert_text.as_deref().unwrap_or("");
assert!(
insert.starts_with("\\App\\Models\\User"),
"insert_text should start with \\App\\Models\\User, got: {:?}",
insert
);
}
#[tokio::test]
async fn test_fqn_prefix_skips_auto_import() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
)],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = std::fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let uri = Url::parse("file:///fqn_noimport.php").unwrap();
let text = concat!("<?php\n", "namespace Other;\n", "new App\\Models\\U\n",);
let items = complete_at(&backend, &uri, text, 2, 16).await;
let classes = class_items(&items);
let user_item = classes
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Models\\User"));
if let Some(item) = user_item {
assert!(
item.additional_text_edits.is_none(),
"FQN-prefix completions should NOT have additional_text_edits (auto-import), got: {:?}",
item.additional_text_edits
);
}
}
#[tokio::test]
async fn test_filter_text_uses_short_name_in_non_fqn_mode() {
let backend = create_test_backend_with_stubs();
let scaffolding_uri = Url::parse("file:///filter_scaffold.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: scaffolding_uri,
language_id: "php".to_string(),
version: 1,
text: concat!(
"<?php\n",
"namespace Vendor\\Package;\n",
"class Widget {}\n",
)
.to_string(),
},
})
.await;
let uri = Url::parse("file:///filter_test.php").unwrap();
let text = concat!("<?php\n", "new Wid\n",);
let items = complete_at(&backend, &uri, text, 1, 7).await;
let classes = class_items(&items);
let widget = find_by_fqn(&classes, "Vendor\\Package\\Widget")
.expect("Should find Vendor\\Package\\Widget");
let filter = widget.filter_text.as_deref().unwrap_or("");
assert_eq!(
filter, "Widget",
"non-FQN filter_text should be the short name, got: {:?}",
filter
);
let uri_fqn = Url::parse("file:///filter_fqn_test.php").unwrap();
let text_fqn = concat!("<?php\n", "new Vendor\\Pack\n",);
let items_fqn = complete_at(&backend, &uri_fqn, text_fqn, 1, 15).await;
let classes_fqn = class_items(&items_fqn);
let widget_fqn = classes_fqn
.iter()
.find(|i| i.label == "Vendor\\Package\\Widget")
.expect("Should find Vendor\\Package\\Widget in FQN mode");
let filter_fqn = widget_fqn.filter_text.as_deref().unwrap_or("");
assert!(
filter_fqn.contains("Vendor\\Package"),
"FQN-mode filter_text should include the namespace path, got: {:?}",
filter_fqn
);
}
#[tokio::test]
async fn test_fqn_prefix_text_edit_replaces_full_prefix() {
let backend = create_test_backend_with_stubs();
let scaffolding_uri = Url::parse("file:///textedit_scaffold.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: scaffolding_uri,
language_id: "php".to_string(),
version: 1,
text: concat!(
"<?php\n",
"namespace http\\Exception;\n",
"class BadUrlException {}\n",
)
.to_string(),
},
})
.await;
let uri = Url::parse("file:///textedit_test.php").unwrap();
let text = concat!("<?php\n", "if ($user instanceof http\\Ex) {}\n",);
let items = complete_at(&backend, &uri, text, 1, 28).await;
let classes = class_items(&items);
let exc_item = classes
.iter()
.find(|i| i.detail.as_deref() == Some("http\\Exception\\BadUrlException"))
.expect("Should find BadUrlException via FQN prefix http\\Ex");
let te = exc_item
.text_edit
.as_ref()
.expect("FQN-prefix completions must have a text_edit");
match te {
CompletionTextEdit::Edit(edit) => {
assert_eq!(
edit.range.start,
Position {
line: 1,
character: 21,
},
"text_edit range should start where the FQN prefix begins"
);
assert_eq!(
edit.range.end,
Position {
line: 1,
character: 28,
},
"text_edit range should end at the cursor"
);
assert!(
edit.new_text.contains("http\\Exception\\BadUrlException"),
"text_edit new_text should be the full FQN, got: {:?}",
edit.new_text
);
}
_ => panic!("Expected CompletionTextEdit::Edit"),
}
}
#[tokio::test]
async fn test_fqn_prefix_same_namespace_simplifies_to_short_name() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Demo\\": "src/"
}
}
}"#,
&[(
"src/Box.php",
concat!("<?php\n", "namespace Demo;\n", "class Box {}\n",),
)],
);
let box_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Box.php").display()
))
.unwrap();
let box_content = std::fs::read_to_string(_dir.path().join("src/Box.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: box_uri,
language_id: "php".to_string(),
version: 1,
text: box_content,
},
})
.await;
let uri = Url::parse("file:///fqn_same_ns.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"if ($user instanceof \\Demo\\) {}\n",
);
let items = complete_at(&backend, &uri, text, 2, 27).await;
let classes = class_items(&items);
let box_item = classes
.iter()
.find(|i| i.detail.as_deref() == Some("Demo\\Box"))
.expect("Should find Box via FQN prefix \\Demo\\");
assert_eq!(box_item.label, "Demo\\Box", "Label should be the FQN");
let te = box_item
.text_edit
.as_ref()
.expect("FQN-prefix completions should have a text_edit");
match te {
CompletionTextEdit::Edit(edit) => {
assert_eq!(
edit.new_text, "Box",
"text_edit should insert 'Box', not the full FQN"
);
}
_ => panic!("Expected CompletionTextEdit::Edit"),
}
}
#[tokio::test]
async fn test_fqn_prefix_different_namespace_keeps_fqn() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Other\\": "src/"
}
}
}"#,
&[(
"src/Foo.php",
concat!("<?php\n", "namespace Other;\n", "class Foo {}\n",),
)],
);
let foo_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Foo.php").display()
))
.unwrap();
let foo_content = std::fs::read_to_string(_dir.path().join("src/Foo.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: foo_uri,
language_id: "php".to_string(),
version: 1,
text: foo_content,
},
})
.await;
let uri = Url::parse("file:///fqn_diff_ns.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"if ($user instanceof \\Other\\) {}\n",
);
let items = complete_at(&backend, &uri, text, 2, 28).await;
let classes = class_items(&items);
let foo_item = classes
.iter()
.find(|i| i.detail.as_deref() == Some("Other\\Foo"))
.expect("Should find Foo via FQN prefix \\Other\\");
assert_eq!(
foo_item.label, "Other\\Foo",
"Label should be the full FQN when class is in a different namespace"
);
let te = foo_item
.text_edit
.as_ref()
.expect("FQN-prefix completions should have a text_edit");
match te {
CompletionTextEdit::Edit(edit) => {
assert!(
edit.new_text.contains("\\Other\\Foo"),
"text_edit should insert the full FQN with leading backslash, got: {:?}",
edit.new_text
);
}
_ => panic!("Expected CompletionTextEdit::Edit"),
}
}
#[tokio::test]
async fn test_namespace_declaration_suggests_namespaces() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
),
(
"src/Services/AuthService.php",
concat!(
"<?php\n",
"namespace App\\Services;\n",
"class AuthService {}\n",
),
),
],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = std::fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let uri = Url::parse("file:///ns_decl.php").unwrap();
let text = concat!("<?php\n", "namespace App\n",);
let items = complete_at(&backend, &uri, text, 1, 13).await;
let all_labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
all_labels.iter().any(|l| l.contains("App\\Models")),
"Should suggest App\\Models namespace, got labels: {:?}",
all_labels
);
assert!(
!all_labels.contains(&"User"),
"Should NOT suggest class names in namespace context, got labels: {:?}",
all_labels
);
assert!(
!all_labels.contains(&"Decimal"),
"Should NOT suggest stub-only namespaces outside PSR-4 prefixes, got labels: {:?}",
all_labels
);
for item in &items {
assert_eq!(
item.kind,
Some(CompletionItemKind::MODULE),
"Namespace items should have MODULE kind, got {:?} for {:?}",
item.kind,
item.label
);
}
}
#[tokio::test]
async fn test_namespace_declaration_discovers_cached_sub_namespaces() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
),
(
"src/Models/Concerns/HasUuids.php",
concat!(
"<?php\n",
"namespace App\\Models\\Concerns;\n",
"trait HasUuids {}\n",
),
),
],
);
let concerns_uri = Url::parse(&format!(
"file://{}",
_dir.path()
.join("src/Models/Concerns/HasUuids.php")
.display()
))
.unwrap();
let concerns_content =
std::fs::read_to_string(_dir.path().join("src/Models/Concerns/HasUuids.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: concerns_uri,
language_id: "php".to_string(),
version: 1,
text: concerns_content,
},
})
.await;
let uri = Url::parse("file:///ns_subdir.php").unwrap();
let text = concat!("<?php\n", "namespace App\\Models\n",);
let items = complete_at(&backend, &uri, text, 1, 20).await;
let all_labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
all_labels.contains(&"App\\Models\\Concerns"),
"Should discover sub-namespace from cached files, got labels: {:?}",
all_labels
);
}
#[tokio::test]
async fn test_namespace_context_not_inside_class_body() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///ns_in_body.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" public function bar() {\n",
" namespace\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 3, 17).await;
let has_non_module = items
.iter()
.any(|i| i.kind != Some(CompletionItemKind::MODULE));
assert!(
items.is_empty() || has_non_module,
"`namespace` inside a class body should NOT produce only MODULE completions"
);
}
#[tokio::test]
async fn test_fqn_leading_backslash_single_segment_same_namespace() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Demo\\": "src/"
}
}
}"#,
&[(
"src/Box.php",
concat!("<?php\n", "namespace Demo;\n", "class Box {}\n",),
)],
);
let box_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Box.php").display()
))
.unwrap();
let box_content = std::fs::read_to_string(_dir.path().join("src/Box.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: box_uri,
language_id: "php".to_string(),
version: 1,
text: box_content,
},
})
.await;
let uri = Url::parse("file:///fqn_backslash_single.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"if ($user instanceof \\Demo) {}\n",
);
let items = complete_at(&backend, &uri, text, 2, 26).await;
let classes = class_items(&items);
let box_item = classes
.iter()
.find(|i| i.detail.as_deref() == Some("Demo\\Box"))
.expect("Should find Box via prefix \\Demo");
let te = box_item
.text_edit
.as_ref()
.expect("Leading-backslash completions should have a text_edit");
match te {
CompletionTextEdit::Edit(edit) => {
assert_eq!(
edit.new_text, "Box",
"text_edit should insert 'Box' (same namespace), not '\\Box' or '\\Demo\\Box'"
);
}
_ => panic!("Expected CompletionTextEdit::Edit"),
}
}
#[tokio::test]
async fn test_use_function_no_parentheses() {
let backend = create_test_backend_with_function_stubs();
let uri = Url::parse("file:///use_func_parens.php").unwrap();
let text = concat!("<?php\n", "use function array_ma\n",);
let items = complete_at(&backend, &uri, text, 1, 21).await;
let func_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FUNCTION))
.collect();
assert!(
!func_items.is_empty(),
"Should have function completions for 'array_ma'"
);
for item in &func_items {
let insert = item.insert_text.as_deref().unwrap_or(&item.label);
assert!(
!insert.contains('('),
"use function completions should NOT contain parentheses, got insert_text: {:?} for {:?}",
insert,
item.label
);
assert!(
insert.ends_with(';'),
"use function completions should end with ';', got insert_text: {:?} for {:?}",
insert,
item.label
);
assert!(
item.insert_text_format != Some(InsertTextFormat::SNIPPET),
"use function completions should be plain text, not snippets, for {:?}",
item.label
);
}
}
#[tokio::test]
async fn test_use_const_semicolon_termination() {
let backend = create_test_backend_with_stubs();
{
let mut dmap = backend.global_defines().write();
dmap.insert(
"MY_CONST".to_string(),
phpantom_lsp::DefineInfo {
file_uri: "file:///defs.php".to_string(),
name_offset: 0,
value: None,
},
);
}
let uri = Url::parse("file:///use_const_semi.php").unwrap();
let text = concat!("<?php\n", "use const MY_C\n",);
let items = complete_at(&backend, &uri, text, 1, 14).await;
let const_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.collect();
assert!(
!const_items.is_empty(),
"Should have constant completions for 'MY_C'"
);
for item in &const_items {
let insert = item.insert_text.as_deref().unwrap_or(&item.label);
assert!(
insert.ends_with(';'),
"use const completions should end with ';', got insert_text: {:?} for {:?}",
insert,
item.label
);
}
}
#[tokio::test]
async fn test_use_class_import_semicolon_termination() {
let backend = create_test_backend_with_stubs();
let scaffold_uri = Url::parse("file:///scaffold_datetime.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: scaffold_uri,
language_id: "php".to_string(),
version: 1,
text: "<?php\nnamespace App;\nclass DateTransformer {}\n".to_string(),
},
})
.await;
let uri = Url::parse("file:///use_class_semi.php").unwrap();
let text = concat!("<?php\n", "use DateT\n",);
let items = complete_at(&backend, &uri, text, 1, 9).await;
let class_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CLASS))
.collect();
assert!(
!class_items.is_empty(),
"Should have class completions for 'DateT', got: {:?}",
labels(&items)
);
for item in &class_items {
let insert = item.insert_text.as_deref().unwrap_or(&item.label);
assert!(
insert.ends_with(';'),
"use class completions should end with ';', got insert_text: {:?} for {:?}",
insert,
item.label
);
}
let keyword_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::KEYWORD))
.collect();
for item in &keyword_items {
let insert = item.insert_text.as_deref().unwrap_or(&item.label);
assert!(
!insert.ends_with(';'),
"keyword hints should NOT end with ';', got insert_text: {:?} for {:?}",
insert,
item.label
);
}
}
#[tokio::test]
async fn test_namespace_declaration_excludes_non_psr4_namespaces() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
)],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = std::fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
{
let mut idx = backend.class_index().write();
idx.insert(
"MySql\\Enums\\IntTypes".to_string(),
"file:///somewhere.php".to_string(),
);
}
let uri = Url::parse("file:///ns_filter.php").unwrap();
let text = concat!("<?php\n", "namespace My\n",);
let items = complete_at(&backend, &uri, text, 1, 12).await;
let all_labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
!all_labels.iter().any(|l| l.contains("MySql")),
"Should NOT suggest namespaces outside PSR-4 prefixes, got labels: {:?}",
all_labels
);
}
#[tokio::test]
async fn test_namespace_declaration_psr4_and_cached_only() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"JohnyDogood\\": "src/",
"JUtils\\": "utils/"
}
}
}"#,
&[
(
"src/Money/USD.php",
concat!(
"<?php\n",
"namespace JohnyDogood\\Money;\n",
"class USD {}\n",
),
),
("utils/.gitkeep", ""),
],
);
let usd_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Money/USD.php").display()
))
.unwrap();
let usd_content = std::fs::read_to_string(_dir.path().join("src/Money/USD.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: usd_uri,
language_id: "php".to_string(),
version: 1,
text: usd_content,
},
})
.await;
{
let mut idx = backend.class_index().write();
idx.insert(
"MySql\\Enums\\IntTypes".to_string(),
"file:///ext.php".to_string(),
);
}
let uri = Url::parse("file:///ns_psr4.php").unwrap();
let text = concat!("<?php\n", "namespace J\n",);
let items = complete_at(&backend, &uri, text, 1, 11).await;
let all_labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
all_labels.contains(&"JohnyDogood"),
"Should suggest PSR-4 prefix JohnyDogood, got: {:?}",
all_labels
);
assert!(
all_labels.contains(&"JUtils"),
"Should suggest PSR-4 prefix JUtils, got: {:?}",
all_labels
);
assert!(
all_labels.contains(&"JohnyDogood\\Money"),
"Should suggest cached sub-namespace JohnyDogood\\Money, got: {:?}",
all_labels
);
assert!(
!all_labels.contains(&"USD"),
"Should NOT suggest class names, got: {:?}",
all_labels
);
assert!(
!all_labels.iter().any(|l| l.contains("MySql")),
"Should NOT suggest non-PSR-4 namespaces, got: {:?}",
all_labels
);
}
#[tokio::test]
async fn test_namespace_declaration_replaces_full_prefix() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Tests\\": "tests/"
}
}
}"#,
&[(
"tests/Feature/Domain/SomeTest.php",
concat!(
"<?php\n",
"namespace Tests\\Feature\\Domain;\n",
"class SomeTest {}\n",
),
)],
);
let file_uri = Url::parse(&format!(
"file://{}",
_dir.path()
.join("tests/Feature/Domain/SomeTest.php")
.display()
))
.unwrap();
let file_content =
std::fs::read_to_string(_dir.path().join("tests/Feature/Domain/SomeTest.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: file_uri,
language_id: "php".to_string(),
version: 1,
text: file_content,
},
})
.await;
let uri = Url::parse("file:///ns_replace.php").unwrap();
let text = concat!("<?php\n", "namespace Tests\\Feature\\D\n",);
let items = complete_at(&backend, &uri, text, 1, 25).await;
let domain_item = items
.iter()
.find(|i| i.label == "Tests\\Feature\\Domain")
.expect("Should find Tests\\Feature\\Domain in namespace completions");
let te = domain_item
.text_edit
.as_ref()
.expect("Namespace completions with backslash should have a text_edit");
match te {
CompletionTextEdit::Edit(edit) => {
assert_eq!(
edit.new_text, "Tests\\Feature\\Domain",
"text_edit should insert the full namespace"
);
assert_eq!(
edit.range.start,
Position {
line: 1,
character: 10
},
"replacement range should start at the beginning of the typed prefix"
);
assert_eq!(
edit.range.end,
Position {
line: 1,
character: 25
},
"replacement range should end at the cursor"
);
}
_ => panic!("Expected CompletionTextEdit::Edit"),
}
}
#[tokio::test]
async fn test_fqn_shortened_via_use_map_prefix() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"Cassandra\\Exception\\AlreadyExistsException".to_string(),
"file:///vendor/cassandra.php".to_string(),
);
}
let uri = Url::parse("file:///shorten_prefix.php").unwrap();
let text = concat!(
"<?php\n",
"use Cassandra\\Exception;\n",
"if ($user instanceof Exception\\AlreadyEx) {}\n",
);
let items = complete_at(&backend, &uri, text, 2, 40).await;
let cls = class_items(&items);
let item = cls
.iter()
.find(|i| i.detail.as_deref() == Some("Cassandra\\Exception\\AlreadyExistsException"))
.expect("Should find AlreadyExistsException in completions");
assert_eq!(
item.label, "Cassandra\\Exception\\AlreadyExistsException",
"label should be the full FQN"
);
let te = item
.text_edit
.as_ref()
.expect("FQN completions should have a text_edit");
match te {
CompletionTextEdit::Edit(edit) => {
assert_eq!(
edit.new_text, "Exception\\AlreadyExistsException",
"text_edit should insert the shortened form"
);
}
_ => panic!("Expected CompletionTextEdit::Edit"),
}
assert!(
item.additional_text_edits.is_none()
|| item.additional_text_edits.as_ref().unwrap().is_empty(),
"should not generate a use import when already reachable via existing import"
);
}
#[tokio::test]
async fn test_fqn_shortened_via_use_map_exact_match_leading_backslash() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"Cassandra\\Exception\\AlreadyExistsException".to_string(),
"file:///vendor/cassandra.php".to_string(),
);
}
let uri = Url::parse("file:///shorten_exact.php").unwrap();
let text = concat!(
"<?php\n",
"use Cassandra\\Exception\\AlreadyExistsException;\n",
"if ($user instanceof \\Cassa) {}\n",
);
let items = complete_at(&backend, &uri, text, 2, 27).await;
let cls = class_items(&items);
let item = cls
.iter()
.find(|i| i.detail.as_deref() == Some("Cassandra\\Exception\\AlreadyExistsException"))
.expect("Should find AlreadyExistsException in completions");
assert_eq!(
item.label, "Cassandra\\Exception\\AlreadyExistsException",
"label should be the full FQN"
);
let te = item
.text_edit
.as_ref()
.expect("FQN completions should have a text_edit");
match te {
CompletionTextEdit::Edit(edit) => {
assert_eq!(
edit.new_text, "AlreadyExistsException",
"text_edit should insert the short imported name, not the FQN"
);
}
_ => panic!("Expected CompletionTextEdit::Edit"),
}
}
#[tokio::test]
async fn test_use_import_context_does_not_shorten() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"Cassandra\\Exception\\AlreadyExistsException".to_string(),
"file:///vendor/cassandra.php".to_string(),
);
}
let uri = Url::parse("file:///use_no_shorten.php").unwrap();
let text = concat!(
"<?php\n",
"use Cassandra\\Exception;\n",
"use Cassandra\\Exception\\Already\n",
);
let items = complete_at(&backend, &uri, text, 2, 31).await;
let cls = class_items(&items);
let item = cls
.iter()
.find(|i| i.detail.as_deref() == Some("Cassandra\\Exception\\AlreadyExistsException"))
.expect("Should find AlreadyExistsException in use-import completions");
assert_eq!(
item.label, "Cassandra\\Exception\\AlreadyExistsException",
"use-import context should NOT shorten via use-map"
);
}
#[tokio::test]
async fn test_namespace_alias_import_not_shown_as_class() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"Luxplus\\Core\\Enums\\Status".to_string(),
"file:///vendor/luxplus/enums/Status.php".to_string(),
);
idx.insert(
"Luxplus\\Core\\Enums\\Color".to_string(),
"file:///vendor/luxplus/enums/Color.php".to_string(),
);
}
let uri = Url::parse("file:///ns_alias.php").unwrap();
let text = concat!("<?php\n", "use Luxplus\\Core\\Enums as LCE;\n", "new LCE\n",);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let cls = class_items(&items);
let phantom = cls
.iter()
.find(|i| i.detail.as_deref() == Some("Luxplus\\Core\\Enums"));
assert!(
phantom.is_none(),
"Namespace alias should not appear as a class completion, got: {:?}",
phantom
);
}
#[tokio::test]
async fn test_classes_under_namespace_alias_still_available() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"Luxplus\\Core\\Enums\\Status".to_string(),
"file:///vendor/luxplus/enums/Status.php".to_string(),
);
}
let uri = Url::parse("file:///ns_alias_child.php").unwrap();
let text = concat!(
"<?php\n",
"use Luxplus\\Core\\Enums as LCE;\n",
"new Stat\n",
);
let items = complete_at(&backend, &uri, text, 2, 8).await;
let cls = class_items(&items);
let status = cls
.iter()
.find(|i| i.detail.as_deref() == Some("Luxplus\\Core\\Enums\\Status"));
assert!(
status.is_some(),
"Classes under the namespace should still appear in completions"
);
}
#[tokio::test]
async fn test_undiscovered_use_import_still_shown() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///undiscovered.php").unwrap();
let text = concat!("<?php\n", "use Vendor\\SomeLibrary\\Widget;\n", "new Wid\n",);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let cls = class_items(&items);
let class_fqns = fqns(&cls);
assert!(
class_fqns.contains(&"Vendor\\SomeLibrary\\Widget"),
"Undiscovered use-imported class should still appear, got: {:?}",
class_fqns
);
}
#[tokio::test]
async fn test_conflicting_use_import_class_index_falls_back_to_fqn() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"App\\Exception".to_string(),
"file:///app/Exception.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Controllers;\n",
"use Cassandra\\Exception;\n",
"\n",
"new Exc\n",
);
let items = complete_at(&backend, &uri, text, 4, 7).await;
let app_exc = items
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Exception"))
.expect("Should have App\\Exception completion");
assert!(
app_exc.additional_text_edits.is_none(),
"Conflicting import should not produce a use statement, got: {:?}",
app_exc.additional_text_edits
);
let insert = app_exc.insert_text.as_deref().unwrap_or("");
assert!(
insert.starts_with("\\App\\Exception"),
"Should insert FQN with leading backslash, got: {:?}",
insert
);
}
#[tokio::test]
async fn test_conflicting_use_import_classmap_falls_back_to_fqn() {
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 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",
"return array(\n",
" 'App\\\\Exception' => $vendorDir . '/app/Exception.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:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Controllers;\n",
"use Cassandra\\Exception;\n",
"\n",
"new Exc\n",
);
let items = complete_at(&backend, &uri, text, 4, 7).await;
let app_exc = items
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Exception"))
.expect("Should have App\\Exception completion from classmap");
assert!(
app_exc.additional_text_edits.is_none(),
"Conflicting classmap import should not produce a use statement, got: {:?}",
app_exc.additional_text_edits
);
let insert = app_exc.insert_text.as_deref().unwrap_or("");
assert!(
insert.starts_with("\\App\\Exception"),
"Classmap conflict should insert FQN with leading backslash, got: {:?}",
insert
);
}
#[tokio::test]
async fn test_conflicting_use_import_stub_falls_back_to_fqn() {
let mut stubs: HashMap<&'static str, &'static str> = HashMap::new();
stubs.insert("Exception", "<?php\nclass Exception {}\n");
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Controllers;\n",
"use Cassandra\\Exception;\n",
"\n",
"new Exc\n",
);
let items = complete_at(&backend, &uri, text, 4, 7).await;
let stub_exc = items
.iter()
.find(|i| i.detail.as_deref() == Some("Exception") && i.label != "Exception")
.or_else(|| {
items.iter().find(|i| {
i.detail.as_deref() == Some("Exception")
&& i.additional_text_edits.is_none()
&& i.insert_text
.as_deref()
.is_some_and(|t| t.starts_with("\\Exception"))
})
})
.expect("Should have stub Exception completion with FQN fallback");
assert!(
stub_exc.additional_text_edits.is_none(),
"Conflicting stub import should not produce a use statement"
);
let insert = stub_exc.insert_text.as_deref().unwrap_or("");
assert!(
insert.starts_with("\\Exception"),
"Stub conflict should insert FQN with leading backslash, got: {:?}",
insert
);
}
#[tokio::test]
async fn test_no_conflict_auto_import_still_works() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"App\\Services\\PaymentService".to_string(),
"file:///app/Services/PaymentService.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Controllers;\n",
"use Cassandra\\Exception;\n",
"\n",
"new Payment\n",
);
let items = complete_at(&backend, &uri, text, 4, 11).await;
let payment = items
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Services\\PaymentService"))
.expect("Should have PaymentService completion");
let edits = payment
.additional_text_edits
.as_ref()
.expect("Non-conflicting import should have additional_text_edits");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "use App\\Services\\PaymentService;\n",);
}
#[tokio::test]
async fn test_conflicting_use_import_with_new_keyword_inserts_fqn_snippet() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"App\\Exception".to_string(),
"file:///app/Exception.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Controllers;\n",
"use Cassandra\\Exception;\n",
"\n",
"new Exc\n",
);
let items = complete_at(&backend, &uri, text, 4, 7).await;
let app_exc = items
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Exception"))
.expect("Should have App\\Exception completion");
let insert = app_exc.insert_text.as_deref().unwrap_or("");
assert!(
insert.starts_with("\\App\\Exception("),
"new + conflict should insert \\FQN() snippet, got: {:?}",
insert
);
assert!(
app_exc.additional_text_edits.is_none(),
"Conflict with new keyword should not produce a use statement"
);
}
#[tokio::test]
async fn test_same_fqn_already_imported_is_not_a_conflict() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"Cassandra\\Exception".to_string(),
"file:///vendor/cassandra/Exception.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Controllers;\n",
"use Cassandra\\Exception;\n",
"\n",
"Exc\n",
);
let items = complete_at(&backend, &uri, text, 4, 3).await;
let exc = items
.iter()
.find(|i| i.detail.as_deref() == Some("Cassandra\\Exception"))
.expect("Should have Cassandra\\Exception completion");
let insert = exc.insert_text.as_deref().unwrap_or("");
assert!(
!insert.starts_with('\\'),
"Same-FQN import should use short name, got: {:?}",
insert
);
}
#[tokio::test]
async fn test_multiple_conflicting_classes_all_use_fqn() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"App\\Exception".to_string(),
"file:///app/Exception.php".to_string(),
);
idx.insert(
"Domain\\Exception".to_string(),
"file:///domain/Exception.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Controllers;\n",
"use Cassandra\\Exception;\n",
"\n",
"Exc\n",
);
let items = complete_at(&backend, &uri, text, 4, 3).await;
for fqn in &["App\\Exception", "Domain\\Exception"] {
let item = items
.iter()
.find(|i| i.detail.as_deref() == Some(*fqn))
.unwrap_or_else(|| panic!("Should have {} completion", fqn));
assert!(
item.additional_text_edits.is_none(),
"{} should not produce a use statement",
fqn
);
let insert = item.insert_text.as_deref().unwrap_or("");
assert!(
insert.starts_with(&format!("\\{}", fqn)),
"{} should insert FQN with leading backslash, got: {:?}",
fqn,
insert
);
}
}
#[tokio::test]
async fn test_fqn_mode_leading_segment_alias_collision() {
let mut stubs: HashMap<&'static str, &'static str> = HashMap::new();
stubs.insert(
"pq\\Exception",
"<?php\nnamespace pq;\nclass Exception {}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"use Exception as pq;\n",
"\n",
"throw new pq\\Exc\n",
);
let items = complete_at(&backend, &uri, text, 4, 17).await;
let pq_exc = items
.iter()
.find(|i| i.detail.as_deref() == Some("pq\\Exception"))
.expect("Should have pq\\Exception completion");
let insert = pq_exc.insert_text.as_deref().unwrap_or("");
assert!(
insert.starts_with("\\pq\\Exception"),
"FQN mode with alias collision should prepend \\, got: {:?}",
insert
);
let edit = pq_exc
.text_edit
.as_ref()
.expect("FQN mode should produce a text_edit");
match edit {
CompletionTextEdit::Edit(te) => {
let replaced_len = te.range.end.character - te.range.start.character;
assert_eq!(
replaced_len,
"pq\\Exc".len() as u32,
"text_edit range should cover the full pq\\Exc prefix, got range {:?}",
te.range
);
assert!(
te.new_text.starts_with("\\pq\\Exception"),
"text_edit new_text should start with \\pq\\Exception, got: {:?}",
te.new_text
);
}
_ => panic!("Expected CompletionTextEdit::Edit"),
}
}
#[tokio::test]
async fn test_fqn_mode_no_alias_collision_keeps_bare_fqn() {
let mut stubs: HashMap<&'static str, &'static str> = HashMap::new();
stubs.insert(
"pq\\Exception",
"<?php\nnamespace pq;\nclass Exception {}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"use Exception;\n",
"\n",
"throw new pq\\Exc\n",
);
let items = complete_at(&backend, &uri, text, 4, 17).await;
let pq_exc = items
.iter()
.find(|i| i.detail.as_deref() == Some("pq\\Exception"))
.expect("Should have pq\\Exception completion");
let insert = pq_exc.insert_text.as_deref().unwrap_or("");
assert!(
!insert.starts_with('\\'),
"No alias collision should keep bare FQN, got: {:?}",
insert
);
}
#[tokio::test]
async fn test_fqn_mode_user_typed_leading_backslash_unaffected() {
let mut stubs: HashMap<&'static str, &'static str> = HashMap::new();
stubs.insert(
"pq\\Exception",
"<?php\nnamespace pq;\nclass Exception {}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"use Exception as pq;\n",
"\n",
"throw new \\pq\\Exc\n",
);
let items = complete_at(&backend, &uri, text, 4, 18).await;
let pq_exc = items
.iter()
.find(|i| i.detail.as_deref() == Some("pq\\Exception"))
.expect("Should have pq\\Exception completion");
let insert = pq_exc.insert_text.as_deref().unwrap_or("");
assert!(
insert.starts_with("\\pq\\Exception"),
"User-typed leading \\ should be preserved, got: {:?}",
insert
);
assert!(
!insert.starts_with("\\\\"),
"Should not double the leading \\, got: {:?}",
insert
);
}
fn module_items(items: &[CompletionItem]) -> Vec<&CompletionItem> {
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::MODULE))
.collect()
}
#[tokio::test]
async fn test_namespace_segments_in_use_import() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
),
(
"src/Models/Post.php",
concat!("<?php\n", "namespace App\\Models;\n", "class Post {}\n",),
),
(
"src/Services/AuthService.php",
concat!(
"<?php\n",
"namespace App\\Services;\n",
"class AuthService {}\n",
),
),
],
);
for relpath in &[
"src/Models/User.php",
"src/Models/Post.php",
"src/Services/AuthService.php",
] {
let file_uri =
Url::parse(&format!("file://{}", _dir.path().join(relpath).display())).unwrap();
let content = fs::read_to_string(_dir.path().join(relpath)).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: file_uri,
language_id: "php".to_string(),
version: 1,
text: content,
},
})
.await;
}
let uri = Url::parse("file:///use_ns_segment.php").unwrap();
let text = concat!("<?php\n", "use App\\\n",);
let items = complete_at(&backend, &uri, text, 1, 8).await;
let modules = module_items(&items);
let module_labels: Vec<&str> = modules.iter().map(|i| i.label.as_str()).collect();
assert!(
module_labels.contains(&"App\\Models"),
"Should suggest App\\Models namespace segment, got: {:?}",
module_labels
);
assert!(
module_labels.contains(&"App\\Services"),
"Should suggest App\\Services namespace segment, got: {:?}",
module_labels
);
for item in &modules {
assert_eq!(item.kind, Some(CompletionItemKind::MODULE));
}
let models_item = modules.iter().find(|i| i.label == "App\\Models").unwrap();
assert_eq!(models_item.detail.as_deref(), Some("namespace App\\Models"),);
let classes = class_items(&items);
assert!(
classes
.iter()
.any(|i| i.detail.as_deref() == Some("App\\Models\\User")),
"Classes should still appear alongside namespace segments"
);
}
#[tokio::test]
async fn test_namespace_segments_sort_above_classes() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
),
(
"src/Helper.php",
concat!("<?php\n", "namespace App;\n", "class Helper {}\n",),
),
],
);
for relpath in &["src/Models/User.php", "src/Helper.php"] {
let file_uri =
Url::parse(&format!("file://{}", _dir.path().join(relpath).display())).unwrap();
let content = fs::read_to_string(_dir.path().join(relpath)).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: file_uri,
language_id: "php".to_string(),
version: 1,
text: content,
},
})
.await;
}
let uri = Url::parse("file:///sort_test.php").unwrap();
let text = concat!("<?php\n", "use App\\\n",);
let items = complete_at(&backend, &uri, text, 1, 8).await;
let models_segment = items
.iter()
.find(|i| i.label == "App\\Models" && i.kind == Some(CompletionItemKind::MODULE))
.expect("Should have App\\Models segment");
let helper_class = items
.iter()
.find(|i| i.detail.as_deref() == Some("App\\Helper"))
.expect("Should have App\\Helper class");
assert!(
models_segment.sort_text < helper_class.sort_text,
"Namespace segment sort_text ({:?}) should be before class sort_text ({:?})",
models_segment.sort_text,
helper_class.sort_text
);
}
#[tokio::test]
async fn test_namespace_segments_no_semicolon_in_use_context() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
)],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let uri = Url::parse("file:///use_ns_semi.php").unwrap();
let text = concat!("<?php\n", "use App\\\n",);
let items = complete_at(&backend, &uri, text, 1, 8).await;
let modules = module_items(&items);
assert!(
!modules.is_empty(),
"Should have at least one namespace segment"
);
for item in &modules {
let insert = item.insert_text.as_deref().unwrap_or(&item.label);
assert!(
!insert.ends_with(';'),
"Namespace segments in use context should NOT end with ';', got: {:?}",
insert
);
}
let classes = class_items(&items);
for item in &classes {
let insert = item.insert_text.as_deref().unwrap_or(&item.label);
assert!(
insert.ends_with(';'),
"Classes in use context should end with ';', got: {:?}",
insert
);
}
}
#[tokio::test]
async fn test_namespace_segments_with_leading_backslash() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
),
(
"src/Services/Auth.php",
concat!("<?php\n", "namespace App\\Services;\n", "class Auth {}\n",),
),
],
);
for relpath in &["src/Models/User.php", "src/Services/Auth.php"] {
let file_uri =
Url::parse(&format!("file://{}", _dir.path().join(relpath).display())).unwrap();
let content = fs::read_to_string(_dir.path().join(relpath)).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: file_uri,
language_id: "php".to_string(),
version: 1,
text: content,
},
})
.await;
}
let uri = Url::parse("file:///leading_bs.php").unwrap();
let text = concat!("<?php\n", "new \\App\\\n",);
let items = complete_at(&backend, &uri, text, 1, 9).await;
let modules = module_items(&items);
let module_labels: Vec<&str> = modules.iter().map(|i| i.label.as_str()).collect();
assert!(
module_labels.contains(&"App\\Models"),
"Should suggest App\\Models with leading backslash, got: {:?}",
module_labels
);
let models_item = modules.iter().find(|i| i.label == "App\\Models").unwrap();
let insert = models_item.insert_text.as_deref().unwrap();
assert_eq!(
insert, "\\App\\Models",
"Insert text should include leading backslash"
);
}
#[tokio::test]
async fn test_namespace_segments_in_type_hint() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
)],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let uri = Url::parse("file:///typehint_ns.php").unwrap();
let text = concat!("<?php\n", "function foo(\\App\\\n",);
let items = complete_at(&backend, &uri, text, 1, 18).await;
let modules = module_items(&items);
let module_labels: Vec<&str> = modules.iter().map(|i| i.label.as_str()).collect();
assert!(
module_labels.contains(&"App\\Models"),
"Should suggest namespace segments in type hint context, got: {:?}",
module_labels
);
}
#[tokio::test]
async fn test_namespace_segments_filtered_by_partial() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
),
(
"src/Services/Auth.php",
concat!("<?php\n", "namespace App\\Services;\n", "class Auth {}\n",),
),
],
);
for relpath in &["src/Models/User.php", "src/Services/Auth.php"] {
let file_uri =
Url::parse(&format!("file://{}", _dir.path().join(relpath).display())).unwrap();
let content = fs::read_to_string(_dir.path().join(relpath)).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: file_uri,
language_id: "php".to_string(),
version: 1,
text: content,
},
})
.await;
}
let uri = Url::parse("file:///filter_ns.php").unwrap();
let text = concat!("<?php\n", "use App\\M\n",);
let items = complete_at(&backend, &uri, text, 1, 9).await;
let modules = module_items(&items);
let module_labels: Vec<&str> = modules.iter().map(|i| i.label.as_str()).collect();
assert!(
module_labels.contains(&"App\\Models"),
"App\\Models should match partial 'M', got: {:?}",
module_labels
);
assert!(
!module_labels.contains(&"App\\Services"),
"App\\Services should NOT match partial 'M', got: {:?}",
module_labels
);
}
#[tokio::test]
async fn test_namespace_segments_deep_nesting() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/Http/Controllers/Admin/UserController.php",
concat!(
"<?php\n",
"namespace App\\Http\\Controllers\\Admin;\n",
"class UserController {}\n",
),
),
(
"src/Http/Controllers/Api/AuthController.php",
concat!(
"<?php\n",
"namespace App\\Http\\Controllers\\Api;\n",
"class AuthController {}\n",
),
),
(
"src/Http/Middleware/AuthMiddleware.php",
concat!(
"<?php\n",
"namespace App\\Http\\Middleware;\n",
"class AuthMiddleware {}\n",
),
),
],
);
for relpath in &[
"src/Http/Controllers/Admin/UserController.php",
"src/Http/Controllers/Api/AuthController.php",
"src/Http/Middleware/AuthMiddleware.php",
] {
let file_uri =
Url::parse(&format!("file://{}", _dir.path().join(relpath).display())).unwrap();
let content = fs::read_to_string(_dir.path().join(relpath)).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: file_uri,
language_id: "php".to_string(),
version: 1,
text: content,
},
})
.await;
}
let uri = Url::parse("file:///deep_ns1.php").unwrap();
let text = concat!("<?php\n", "use App\\\n",);
let items = complete_at(&backend, &uri, text, 1, 8).await;
let modules = module_items(&items);
let module_labels: Vec<&str> = modules.iter().map(|i| i.label.as_str()).collect();
assert!(
module_labels.contains(&"App\\Http"),
"First level should show App\\Http, got: {:?}",
module_labels
);
let uri2 = Url::parse("file:///deep_ns2.php").unwrap();
let text2 = concat!("<?php\n", "use App\\Http\\\n",);
let items2 = complete_at(&backend, &uri2, text2, 1, 13).await;
let modules2 = module_items(&items2);
let module_labels2: Vec<&str> = modules2.iter().map(|i| i.label.as_str()).collect();
assert!(
module_labels2.contains(&"App\\Http\\Controllers"),
"Second level should show App\\Http\\Controllers, got: {:?}",
module_labels2
);
assert!(
module_labels2.contains(&"App\\Http\\Middleware"),
"Second level should show App\\Http\\Middleware, got: {:?}",
module_labels2
);
let uri3 = Url::parse("file:///deep_ns3.php").unwrap();
let text3 = concat!("<?php\n", "use App\\Http\\Controllers\\\n",);
let items3 = complete_at(&backend, &uri3, text3, 1, 25).await;
let modules3 = module_items(&items3);
let module_labels3: Vec<&str> = modules3.iter().map(|i| i.label.as_str()).collect();
assert!(
module_labels3.contains(&"App\\Http\\Controllers\\Admin"),
"Third level should show Admin, got: {:?}",
module_labels3
);
assert!(
module_labels3.contains(&"App\\Http\\Controllers\\Api"),
"Third level should show Api, got: {:?}",
module_labels3
);
}
#[tokio::test]
async fn test_namespace_segments_same_namespace_simplifies() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
)],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let uri = Url::parse("file:///same_ns_segment.php").unwrap();
let text = concat!("<?php\n", "namespace App;\n", "new App\\M\n",);
let items = complete_at(&backend, &uri, text, 2, 9).await;
let modules = module_items(&items);
let models_segment = modules
.iter()
.find(|i| i.detail.as_deref() == Some("namespace App\\Models"));
assert!(
models_segment.is_some(),
"Should have App\\Models namespace segment, got modules: {:?}",
modules
.iter()
.map(|i| (&i.label, &i.detail))
.collect::<Vec<_>>()
);
let seg = models_segment.unwrap();
assert_eq!(
seg.label, "Models",
"Label should be simplified to relative name within same namespace"
);
assert_eq!(
seg.insert_text.as_deref(),
Some("Models"),
"Insert text should be the relative name"
);
}
#[tokio::test]
async fn test_namespace_segments_from_stubs() {
let mut stubs: HashMap<&'static str, &'static str> = HashMap::new();
stubs.insert("Ds\\Map", "<?php\nnamespace Ds;\nclass Map {}\n");
stubs.insert("Ds\\Set", "<?php\nnamespace Ds;\nclass Set {}\n");
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///stub_ns.php").unwrap();
let text = concat!("<?php\n", "use Ds\\\n",);
let items = complete_at(&backend, &uri, text, 1, 7).await;
let classes = class_items(&items);
assert!(
classes
.iter()
.any(|i| i.detail.as_deref() == Some("Ds\\Map")),
"Should have Ds\\Map class"
);
assert!(
classes
.iter()
.any(|i| i.detail.as_deref() == Some("Ds\\Set")),
"Should have Ds\\Set class"
);
let modules = module_items(&items);
assert!(
modules.is_empty(),
"Should have no namespace segments for flat namespace, got: {:?}",
modules.iter().map(|i| &i.label).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_namespace_segments_from_stubs_with_nesting() {
let mut stubs: HashMap<&'static str, &'static str> = HashMap::new();
stubs.insert(
"Cassandra\\Exception\\AlreadyExistsException",
"<?php\nnamespace Cassandra\\Exception;\nclass AlreadyExistsException {}\n",
);
stubs.insert(
"Cassandra\\Cluster",
"<?php\nnamespace Cassandra;\nclass Cluster {}\n",
);
let backend = Backend::new_test_with_stubs(stubs);
let uri = Url::parse("file:///stub_nested_ns.php").unwrap();
let text = concat!("<?php\n", "use Cassandra\\\n",);
let items = complete_at(&backend, &uri, text, 1, 14).await;
let modules = module_items(&items);
let module_labels: Vec<&str> = modules.iter().map(|i| i.label.as_str()).collect();
assert!(
module_labels.contains(&"Cassandra\\Exception"),
"Should suggest Cassandra\\Exception namespace segment, got: {:?}",
module_labels
);
}
#[tokio::test]
async fn test_namespace_segments_text_edit_replaces_full_prefix() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
)],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let uri = Url::parse("file:///textedit_ns.php").unwrap();
let text = concat!("<?php\n", "use App\\M\n",);
let items = complete_at(&backend, &uri, text, 1, 9).await;
let modules = module_items(&items);
let models_item = modules
.iter()
.find(|i| i.label == "App\\Models")
.expect("Should have App\\Models segment");
if let Some(CompletionTextEdit::Edit(ref edit)) = models_item.text_edit {
assert_eq!(edit.range.start.line, 1);
assert_eq!(
edit.range.start.character, 4,
"text_edit should start at the beginning of 'App\\M' (after 'use ')"
);
assert_eq!(edit.range.end.line, 1);
assert_eq!(edit.range.end.character, 9);
assert_eq!(edit.new_text, "App\\Models");
} else {
panic!(
"Namespace segment should have a text_edit, got: {:?}",
models_item.text_edit
);
}
}
#[tokio::test]
async fn test_namespace_segments_not_injected_for_bare_name() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///bare_name.php").unwrap();
let text = concat!("<?php\n", "use DateT\n",);
let items = complete_at(&backend, &uri, text, 1, 9).await;
let modules = module_items(&items);
assert!(
modules.is_empty(),
"Bare name without backslash should not produce namespace segments, got: {:?}",
modules.iter().map(|i| &i.label).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_namespace_segments_in_new_context() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
)],
);
let user_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/User.php").display()
))
.unwrap();
let user_content = fs::read_to_string(_dir.path().join("src/Models/User.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: user_uri,
language_id: "php".to_string(),
version: 1,
text: user_content,
},
})
.await;
let uri = Url::parse("file:///new_ns.php").unwrap();
let text = concat!("<?php\n", "new App\\\n",);
let items = complete_at(&backend, &uri, text, 1, 8).await;
let modules = module_items(&items);
let module_labels: Vec<&str> = modules.iter().map(|i| i.label.as_str()).collect();
assert!(
module_labels.contains(&"App\\Models"),
"Namespace segments should appear in `new` context, got: {:?}",
module_labels
);
let models_item = modules.iter().find(|i| i.label == "App\\Models").unwrap();
assert!(
models_item.insert_text_format.is_none()
|| models_item.insert_text_format == Some(InsertTextFormat::PLAIN_TEXT),
"Namespace segments should not have snippet format"
);
}
#[tokio::test]
async fn test_namespace_segments_deduplicated() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/Models/User.php",
concat!("<?php\n", "namespace App\\Models;\n", "class User {}\n",),
),
(
"src/Models/Post.php",
concat!("<?php\n", "namespace App\\Models;\n", "class Post {}\n",),
),
(
"src/Models/Comment.php",
concat!("<?php\n", "namespace App\\Models;\n", "class Comment {}\n",),
),
],
);
for relpath in &[
"src/Models/User.php",
"src/Models/Post.php",
"src/Models/Comment.php",
] {
let file_uri =
Url::parse(&format!("file://{}", _dir.path().join(relpath).display())).unwrap();
let content = fs::read_to_string(_dir.path().join(relpath)).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: file_uri,
language_id: "php".to_string(),
version: 1,
text: content,
},
})
.await;
}
let uri = Url::parse("file:///dedup_ns.php").unwrap();
let text = concat!("<?php\n", "use App\\\n",);
let items = complete_at(&backend, &uri, text, 1, 8).await;
let modules = module_items(&items);
let models_count = modules.iter().filter(|i| i.label == "App\\Models").count();
assert_eq!(
models_count, 1,
"App\\Models should appear exactly once, got {} occurrences",
models_count
);
}
#[tokio::test]
async fn test_class_name_completion_label_is_short_name_in_method_body() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/Order.php",
concat!(
"<?php\n",
"namespace App\\Models;\n",
"class Order {\n",
" public function id(): int { return 1; }\n",
"}\n",
),
)],
);
let order_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/Order.php").display()
))
.unwrap();
let order_content = std::fs::read_to_string(_dir.path().join("src/Models/Order.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: order_uri,
language_id: "php".to_string(),
version: 1,
text: order_content,
},
})
.await;
let uri = Url::parse("file:///test_label_details.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Http\\Controllers;\n",
"class TestController {\n",
" public function index() {\n",
" $order = new Orde\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 4, 25).await;
let classes = class_items(&items);
let order_item = find_by_fqn(&classes, "App\\Models\\Order");
assert!(
order_item.is_some(),
"Should find 'App\\Models\\Order' in completions (via detail), got: {:?}",
fqns(&classes)
);
assert_eq!(
order_item.unwrap().label,
"Order",
"Label should be the short name in non-FQN mode"
);
}
#[tokio::test]
async fn test_class_name_completion_label_is_fqn_in_use_import() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/Order.php",
concat!("<?php\n", "namespace App\\Models;\n", "class Order {}\n",),
)],
);
let order_uri = Url::parse(&format!(
"file://{}",
_dir.path().join("src/Models/Order.php").display()
))
.unwrap();
let order_content = std::fs::read_to_string(_dir.path().join("src/Models/Order.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: order_uri,
language_id: "php".to_string(),
version: 1,
text: order_content,
},
})
.await;
let uri = Url::parse("file:///test_use_ld.php").unwrap();
let text = concat!("<?php\n", "use App\\Models\\Orde\n",);
let items = complete_at(&backend, &uri, text, 1, 19).await;
let classes = class_items(&items);
let order_item = classes.iter().find(|i| i.label == "App\\Models\\Order");
assert!(
order_item.is_some(),
"Should find 'App\\Models\\Order' in use-import completions, got: {:?}",
classes.iter().map(|i| &i.label).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_class_name_completion_label_is_short_name_for_global() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///test_global_ld.php").unwrap();
let text = concat!("<?php\n", "class MyLocalClass {}\n", "new MyLocal\n",);
let items = complete_at(&backend, &uri, text, 2, 11).await;
let classes = class_items(&items);
let local = classes.iter().find(|i| i.label == "MyLocalClass");
assert!(
local.is_some(),
"Should find 'MyLocalClass' (FQN == short name for globals), got: {:?}",
classes.iter().map(|i| &i.label).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_class_name_completion_classmap_label_is_short_name() {
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 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",
"return array(\n",
" 'Vendor\\\\Payments\\\\Invoice' => $vendorDir . '/pkg/src/Invoice.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:///test_cm_ld.php").unwrap();
let text = concat!("<?php\n", "new Invoic\n",);
let items = complete_at(&backend, &uri, text, 1, 10).await;
let classes = class_items(&items);
let invoice = find_by_fqn(&classes, "Vendor\\Payments\\Invoice");
assert!(
invoice.is_some(),
"Should find 'Vendor\\Payments\\Invoice' from classmap (via detail), got: {:?}",
fqns(&classes)
);
assert_eq!(
invoice.unwrap().label,
"Invoice",
"Label should be the short name in non-FQN mode"
);
}
#[tokio::test]
async fn test_class_name_completion_class_index_label_is_short_name() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"Acme\\Billing\\Receipt".to_string(),
"file:///acme/Billing/Receipt.php".to_string(),
);
}
let uri = Url::parse("file:///test_idx_ld.php").unwrap();
let text = concat!("<?php\n", "new Receip\n",);
let items = complete_at(&backend, &uri, text, 1, 10).await;
let classes = class_items(&items);
let receipt = find_by_fqn(&classes, "Acme\\Billing\\Receipt");
assert!(
receipt.is_some(),
"Should find 'Acme\\Billing\\Receipt' from class_index (via detail), got: {:?}",
fqns(&classes)
);
assert_eq!(
receipt.unwrap().label,
"Receipt",
"Label should be the short name in non-FQN mode"
);
}
#[tokio::test]
async fn test_namespace_alias_prefix_matches_classes_underneath() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"OpenApi\\Attributes\\Response".to_string(),
"file:///vendor/openapi/Response.php".to_string(),
);
idx.insert(
"OpenApi\\Attributes\\RequestBody".to_string(),
"file:///vendor/openapi/RequestBody.php".to_string(),
);
idx.insert(
"OpenApi\\Attributes\\Property".to_string(),
"file:///vendor/openapi/Property.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"use OpenApi\\Attributes as OA;\n",
"new OA\\Re\n",
);
let items = complete_at(&backend, &uri, text, 2, 9).await;
let cls = class_items(&items);
let labels: Vec<&str> = cls.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("Response")),
"Expected OA\\Response in completions, got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.contains("RequestBody")),
"Expected OA\\RequestBody in completions, got: {:?}",
labels
);
assert!(
!labels.iter().any(|l| l.contains("Property")),
"Property should not match OA\\Re prefix, got: {:?}",
labels
);
}
#[tokio::test]
async fn test_namespace_alias_prefix_insert_text_uses_alias() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"OpenApi\\Attributes\\Response".to_string(),
"file:///vendor/openapi/Response.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!(
"<?php\n",
"use OpenApi\\Attributes as OA;\n",
"new OA\\Resp\n",
);
let items = complete_at(&backend, &uri, text, 2, 11).await;
let resp = items
.iter()
.find(|i| i.detail.as_deref() == Some("OpenApi\\Attributes\\Response"))
.expect("Should find OpenApi\\Attributes\\Response");
let insert = resp.insert_text.as_deref().unwrap_or("");
assert!(
insert.contains("OA\\Response"),
"Insert text should use alias form OA\\Response, got: {:?}",
insert
);
}
#[tokio::test]
async fn test_namespace_alias_prefix_shows_segments() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"OpenApi\\Attributes\\Callbacks\\Callback".to_string(),
"file:///vendor/openapi/Callbacks/Callback.php".to_string(),
);
idx.insert(
"OpenApi\\Attributes\\Response".to_string(),
"file:///vendor/openapi/Response.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!("<?php\n", "use OpenApi\\Attributes as OA;\n", "new OA\\C\n",);
let items = complete_at(&backend, &uri, text, 2, 8).await;
let callbacks = items.iter().find(|i| i.label.contains("Callbacks"));
assert!(
callbacks.is_some(),
"Expected a Callbacks namespace segment, got labels: {:?}",
items.iter().map(|i| &i.label).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_namespace_alias_prefix_bare_backslash_lists_all() {
let backend = create_test_backend_with_stubs();
{
let mut idx = backend.class_index().write();
idx.insert(
"OpenApi\\Attributes\\Response".to_string(),
"file:///vendor/openapi/Response.php".to_string(),
);
idx.insert(
"OpenApi\\Attributes\\Property".to_string(),
"file:///vendor/openapi/Property.php".to_string(),
);
}
let uri = Url::parse("file:///controller.php").unwrap();
let text = concat!("<?php\n", "use OpenApi\\Attributes as OA;\n", "new OA\\\n",);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let cls = class_items(&items);
let labels: Vec<&str> = cls.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("Response")),
"Expected Response in completions for OA\\, got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.contains("Property")),
"Expected Property in completions for OA\\, got: {:?}",
labels
);
}
#[tokio::test]
async fn test_namespace_inferred_from_file_path_basic() {
let (backend, dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[],
);
std::fs::create_dir_all(dir.path().join("src/Models")).unwrap();
let uri = Url::from_file_path(dir.path().join("src/Models/User.php")).unwrap();
let text = concat!("<?php\n", "namespace \n",);
let items = complete_at(&backend, &uri, text, 1, 10).await;
let inferred = items
.iter()
.find(|i| i.label == "App\\Models")
.expect("Should suggest App\\Models inferred from file path");
assert_eq!(
inferred.detail.as_deref(),
Some("(from file path)"),
"Inferred namespace should have '(from file path)' detail"
);
assert_eq!(
inferred.preselect,
Some(true),
"Inferred namespace should be preselected"
);
let app_only = items.iter().find(|i| i.label == "App").unwrap();
assert!(
inferred.sort_text < app_only.sort_text,
"Inferred namespace should sort before parent namespace: {:?} vs {:?}",
inferred.sort_text,
app_only.sort_text
);
}
#[tokio::test]
async fn test_namespace_inferred_multiple_matches_longest_first() {
let (backend, dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Tests\\": "tests/",
"Tests\\Support\\": "tests/Support/"
}
}
}"#,
&[],
);
std::fs::create_dir_all(dir.path().join("tests/Support/Helpers")).unwrap();
let uri = Url::from_file_path(dir.path().join("tests/Support/Helpers/TestHelper.php")).unwrap();
let text = concat!("<?php\n", "namespace \n",);
let items = complete_at(&backend, &uri, text, 1, 10).await;
let inferred_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| {
i.label == "Tests\\Support\\Helpers" && i.detail.as_deref() == Some("(from file path)")
})
.collect();
assert!(
!inferred_items.is_empty(),
"Should have at least one inferred namespace for Tests\\Support\\Helpers, got labels: {:?}",
items.iter().map(|i| &i.label).collect::<Vec<_>>()
);
assert_eq!(
inferred_items[0].preselect,
Some(true),
"Most specific inferred namespace should be preselected"
);
}
#[tokio::test]
async fn test_namespace_inferred_luxplus_real_world() {
let (backend, dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Luxplus\\Core\\": "src/core/",
"Luxplus\\Core\\Database\\": "src/database/",
"Luxplus\\Core\\Tasks\\": "src/tasks/",
"Luxplus\\Web\\": "src/web/"
}
}
}"#,
&[],
);
std::fs::create_dir_all(dir.path().join("src/core/Brands/Services")).unwrap();
let uri = Url::from_file_path(dir.path().join("src/core/Brands/Services/Fred.php")).unwrap();
let text = concat!("<?php\n", "namespace \n",);
let items = complete_at(&backend, &uri, text, 1, 10).await;
let inferred = items
.iter()
.find(|i| i.label == "Luxplus\\Core\\Brands\\Services")
.expect("Should suggest Luxplus\\Core\\Brands\\Services from file path");
assert_eq!(inferred.detail.as_deref(), Some("(from file path)"),);
assert_eq!(inferred.preselect, Some(true),);
let db_inferred = items.iter().find(|i| {
i.label == "Luxplus\\Core\\Database\\Brands\\Services"
&& i.detail.as_deref() == Some("(from file path)")
});
assert!(
db_inferred.is_none(),
"Should not infer namespace from non-matching PSR-4 base path"
);
}
#[tokio::test]
async fn test_namespace_inferred_at_source_root() {
let (backend, dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[],
);
std::fs::create_dir_all(dir.path().join("src")).unwrap();
let uri = Url::from_file_path(dir.path().join("src/Kernel.php")).unwrap();
let text = concat!("<?php\n", "namespace \n",);
let items = complete_at(&backend, &uri, text, 1, 10).await;
let inferred = items
.iter()
.find(|i| i.label == "App" && i.detail.as_deref() == Some("(from file path)"))
.expect("Should suggest App inferred from src/Kernel.php");
assert_eq!(inferred.preselect, Some(true));
}
#[tokio::test]
async fn test_namespace_no_inference_outside_psr4() {
let (backend, dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[],
);
std::fs::create_dir_all(dir.path().join("config")).unwrap();
let uri = Url::from_file_path(dir.path().join("config/app.php")).unwrap();
let text = concat!("<?php\n", "namespace \n",);
let items = complete_at(&backend, &uri, text, 1, 10).await;
let inferred: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.detail.as_deref() == Some("(from file path)"))
.collect();
assert!(
inferred.is_empty(),
"Should not have any inferred namespaces for files outside PSR-4 dirs, got: {:?}",
inferred.iter().map(|i| &i.label).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_namespace_inferred_with_partial_prefix() {
let (backend, dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[],
);
std::fs::create_dir_all(dir.path().join("src/Models")).unwrap();
let uri = Url::from_file_path(dir.path().join("src/Models/User.php")).unwrap();
let text = concat!("<?php\n", "namespace App\n",);
let items = complete_at(&backend, &uri, text, 1, 13).await;
let inferred = items
.iter()
.find(|i| i.label == "App\\Models" && i.detail.as_deref() == Some("(from file path)"));
assert!(
inferred.is_some(),
"Typing partial 'App' should still show inferred App\\Models, got: {:?}",
items.iter().map(|i| &i.label).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_namespace_non_inferred_items_not_preselected() {
let (backend, dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/",
"Tests\\": "tests/"
}
}
}"#,
&[],
);
std::fs::create_dir_all(dir.path().join("src/Models")).unwrap();
let uri = Url::from_file_path(dir.path().join("src/Models/User.php")).unwrap();
let text = concat!("<?php\n", "namespace \n",);
let items = complete_at(&backend, &uri, text, 1, 10).await;
let tests_item = items
.iter()
.find(|i| i.label == "Tests")
.expect("Should have Tests in namespace completions");
assert_ne!(
tests_item.preselect,
Some(true),
"Tests namespace should NOT be preselected when file is under src/"
);
assert_ne!(
tests_item.detail.as_deref(),
Some("(from file path)"),
"Tests namespace should NOT have '(from file path)' detail"
);
}