use crate::common::{
create_psr4_workspace, create_test_backend, create_test_backend_with_function_stubs,
};
use phpantom_lsp::php_type::PhpType;
use phpantom_lsp::types::FunctionInfo;
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
async fn complete_at(
backend: &phpantom_lsp::Backend,
uri: &Url,
text: &str,
line: u32,
character: u32,
) -> Vec<CompletionItem> {
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position { line, character },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
match backend.completion(completion_params).await.unwrap() {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
_ => vec![],
}
}
fn labels(items: &[CompletionItem]) -> Vec<&str> {
items.iter().map(|i| i.label.as_str()).collect()
}
fn register_namespaced_function(
backend: &phpantom_lsp::Backend,
fqn: &str,
name: &str,
namespace: &str,
uri: &str,
) {
{
let mut fmap = backend.global_functions().write();
fmap.insert(
fqn.to_string(),
(
uri.to_string(),
FunctionInfo {
name: name.to_string(),
name_offset: 0,
parameters: vec![],
return_type: Some(PhpType::parse("mixed")),
native_return_type: None,
description: None,
return_description: None,
links: vec![],
see_refs: vec![],
namespace: Some(namespace.to_string()),
conditional_return: None,
type_assertions: vec![],
deprecation_message: None,
deprecated_replacement: None,
template_params: vec![],
template_bindings: vec![],
throws: vec![],
is_polyfill: false,
},
),
);
}
}
fn register_global_function(backend: &phpantom_lsp::Backend, name: &str, uri: &str) {
{
let mut fmap = backend.global_functions().write();
fmap.insert(
name.to_string(),
(
uri.to_string(),
FunctionInfo {
name: name.to_string(),
name_offset: 0,
parameters: vec![],
return_type: Some(PhpType::parse("string")),
native_return_type: None,
description: None,
return_description: None,
links: vec![],
see_refs: vec![],
namespace: None,
conditional_return: None,
type_assertions: vec![],
deprecation_message: None,
deprecated_replacement: None,
template_params: vec![],
template_bindings: vec![],
throws: vec![],
is_polyfill: false,
},
),
);
}
}
#[tokio::test]
async fn test_use_function_namespaced_insert_text_is_fqn() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nuse function enum_val\n";
let items = complete_at(&backend, &uri, text, 1, 21).await;
let item = items.iter().find(|i| {
i.kind == Some(CompletionItemKind::FUNCTION)
&& i.insert_text.as_deref() == Some("Illuminate\\Support\\enum_value;")
});
assert!(
item.is_some(),
"use function should insert the FQN. Got insert_texts: {:?}",
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FUNCTION))
.map(|i| i.insert_text.as_deref())
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_use_function_namespaced_label_is_fqn() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nuse function enum_val\n";
let items = complete_at(&backend, &uri, text, 1, 21).await;
let item = items.iter().find(|i| {
i.kind == Some(CompletionItemKind::FUNCTION)
&& i.label.contains("Illuminate\\Support\\enum_value")
});
assert!(
item.is_some(),
"use function label should contain the FQN. Labels: {:?}",
labels(&items)
);
}
#[tokio::test]
async fn test_use_function_global_insert_text_is_short_name() {
let backend = create_test_backend();
register_global_function(&backend, "my_global_func", "file:///helpers.php");
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nuse function my_global\n";
let items = complete_at(&backend, &uri, text, 1, 22).await;
let item = items.iter().find(|i| {
i.kind == Some(CompletionItemKind::FUNCTION)
&& i.insert_text
.as_deref()
.is_some_and(|t| t.contains("my_global_func"))
});
assert!(
item.is_some(),
"use function for global func should insert the short name. Got: {:?}",
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FUNCTION))
.map(|i| (&i.label, &i.insert_text))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_use_function_filter_text_is_fqn() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nuse function Illuminate\\Support\\enum\n";
let items = complete_at(&backend, &uri, text, 1, 40).await;
let item = items.iter().find(|i| {
i.kind == Some(CompletionItemKind::FUNCTION)
&& i.filter_text
.as_deref()
.is_some_and(|ft| ft.contains("Illuminate\\Support\\enum_value"))
});
assert!(
item.is_some(),
"filter_text should contain the FQN. Got: {:?}",
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FUNCTION))
.map(|i| (&i.label, &i.filter_text))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_inline_namespaced_function_gets_auto_import() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nenum_val\n";
let items = complete_at(&backend, &uri, text, 1, 8).await;
let item = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("enum_value"));
assert!(
item.is_some(),
"Should find enum_value in inline completions. Labels: {:?}",
labels(&items)
);
let item = item.unwrap();
let edits = item
.additional_text_edits
.as_ref()
.expect("Namespaced function should have additional_text_edits for auto-import");
assert!(
!edits.is_empty(),
"Should have at least one auto-import text edit"
);
let edit_text = &edits[0].new_text;
assert!(
edit_text.contains("use function Illuminate\\Support\\enum_value;"),
"Auto-import should insert `use function FQN;`, got: {}",
edit_text
);
}
#[tokio::test]
async fn test_inline_namespaced_function_insert_text_is_short_name() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nenum_val\n";
let items = complete_at(&backend, &uri, text, 1, 8).await;
let item = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("enum_value"));
assert!(item.is_some(), "Should find enum_value");
let insert = item.unwrap().insert_text.as_deref().unwrap();
assert!(
insert.starts_with("enum_value("),
"Insert text should start with the short name, not the FQN. Got: {}",
insert
);
assert!(
!insert.contains("Illuminate"),
"Insert text should NOT contain the namespace. Got: {}",
insert
);
}
#[tokio::test]
async fn test_inline_global_function_no_auto_import() {
let backend = create_test_backend();
register_global_function(&backend, "my_global_func", "file:///helpers.php");
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nmy_global\n";
let items = complete_at(&backend, &uri, text, 1, 9).await;
let item = items.iter().find(|i| {
i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("my_global_func")
});
assert!(item.is_some(), "Should find my_global_func");
assert!(
item.unwrap().additional_text_edits.is_none(),
"Global function should NOT have auto-import edits"
);
}
#[tokio::test]
async fn test_inline_same_namespace_function_no_auto_import() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"App\\Helpers\\my_helper",
"my_helper",
"App\\Helpers",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nnamespace App\\Helpers;\n\nmy_help\n";
let items = complete_at(&backend, &uri, text, 3, 7).await;
let item = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("my_helper"));
assert!(
item.is_some(),
"Should find my_helper. Labels: {:?}",
labels(&items)
);
assert!(
item.unwrap().additional_text_edits.is_none(),
"Same-namespace function should NOT have auto-import edits"
);
}
#[tokio::test]
async fn test_inline_namespaced_function_detail_shows_namespace() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nenum_val\n";
let items = complete_at(&backend, &uri, text, 1, 8).await;
let item = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("enum_value"));
assert!(item.is_some(), "Should find enum_value");
let item = item.unwrap();
assert_eq!(
item.detail.as_deref(),
Some("mixed"),
"Detail should show return type. Got: {:?}",
item.detail
);
}
#[tokio::test]
async fn test_inline_global_function_detail_is_plain() {
let backend = create_test_backend();
register_global_function(&backend, "my_global_func", "file:///helpers.php");
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nmy_global\n";
let items = complete_at(&backend, &uri, text, 1, 9).await;
let item = items.iter().find(|i| {
i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("my_global_func")
});
assert!(item.is_some(), "Should find my_global_func");
let item = item.unwrap();
assert_eq!(
item.detail.as_deref(),
Some("string"),
"Global function detail should show return type. Got: {:?}",
item.detail
);
}
#[tokio::test]
async fn test_different_namespaces_same_short_name_both_appear() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///illuminate.php",
);
register_namespaced_function(
&backend,
"Symfony\\Component\\enum_value",
"enum_value",
"Symfony\\Component",
"file:///symfony.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nenum_val\n";
let items = complete_at(&backend, &uri, text, 1, 8).await;
let matching: Vec<_> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("enum_value"))
.collect();
assert!(
matching.len() >= 2,
"Both namespaced functions should appear. Got {} matches: {:?}",
matching.len(),
matching
.iter()
.map(|i| (&i.label, &i.detail))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_use_function_different_namespaces_both_appear_with_fqn() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///illuminate.php",
);
register_namespaced_function(
&backend,
"Symfony\\Component\\enum_value",
"enum_value",
"Symfony\\Component",
"file:///symfony.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nuse function enum_val\n";
let items = complete_at(&backend, &uri, text, 1, 21).await;
let matching: Vec<_> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("enum_value"))
.collect();
assert!(
matching.len() >= 2,
"Both should appear in use function context. Got: {:?}",
labels(&items)
);
let has_illuminate = matching
.iter()
.any(|i| i.label.contains("Illuminate\\Support"));
let has_symfony = matching
.iter()
.any(|i| i.label.contains("Symfony\\Component"));
assert!(
has_illuminate && has_symfony,
"Both FQNs should appear as labels. Labels: {:?}",
matching.iter().map(|i| &i.label).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_no_short_name_fallback_in_global_functions() {
let backend = create_test_backend();
let uri = Url::parse("file:///helpers.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Illuminate\\Support;\n",
"\n",
"function enum_value(mixed $value): mixed {\n",
" return $value;\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let fmap = backend.global_functions().read();
assert!(
fmap.contains_key("Illuminate\\Support\\enum_value"),
"Should have FQN key. Keys: {:?}",
fmap.keys().collect::<Vec<_>>()
);
assert!(
!fmap.contains_key("enum_value"),
"Should NOT have short-name fallback key. Keys: {:?}",
fmap.keys().collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_use_function_filters_current_file_namespaced() {
let backend = create_test_backend();
let uri = Url::parse("file:///current.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Helpers;\n",
"\n",
"function my_current_helper(): void {}\n",
"\n",
"use function my_current\n",
);
let items = complete_at(&backend, &uri, text, 5, 24).await;
let has_current = items.iter().any(|i| {
i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("my_current_helper")
});
assert!(
!has_current,
"Functions from the current file should be filtered out in use function context. Labels: {:?}",
labels(&items)
);
}
#[tokio::test]
async fn test_psr4_namespaced_function_completion() {
let (backend, _dir) = create_psr4_workspace(
r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#,
&[(
"src/Helpers/helpers.php",
concat!(
"<?php\n",
"namespace App\\Helpers;\n",
"\n",
"function format_price(float $amount): string {\n",
" return '$' . number_format($amount, 2);\n",
"}\n",
),
)],
);
let helpers_path = _dir.path().join("src/Helpers/helpers.php");
let helpers_uri = Url::from_file_path(&helpers_path).unwrap();
let helpers_text = std::fs::read_to_string(&helpers_path).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: helpers_uri.clone(),
language_id: "php".to_string(),
version: 1,
text: helpers_text,
},
})
.await;
let test_uri = Url::parse("file:///test.php").unwrap();
let test_text = "<?php\nformat_pri\n";
let items = complete_at(&backend, &test_uri, test_text, 1, 10).await;
let item = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("format_price"));
assert!(
item.is_some(),
"Should find format_price from PSR-4 helper file. Labels: {:?}",
labels(&items)
);
let item = item.unwrap();
assert_eq!(
item.detail.as_deref(),
Some("string"),
"Detail should show return type. Got: {:?}",
item.detail
);
let edits = item
.additional_text_edits
.as_ref()
.expect("Cross-file namespaced function should have auto-import");
assert!(
edits[0]
.new_text
.contains("use function App\\Helpers\\format_price;"),
"Auto-import should use FQN. Got: {}",
edits[0].new_text
);
}
#[tokio::test]
async fn test_use_function_matches_by_namespace_prefix() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nuse function Illuminate\\\n";
let items = complete_at(&backend, &uri, text, 1, 28).await;
let item = items.iter().find(|i| {
i.kind == Some(CompletionItemKind::FUNCTION)
&& i.insert_text
.as_deref()
.is_some_and(|t| t.contains("Illuminate\\Support\\enum_value"))
});
assert!(
item.is_some(),
"Typing namespace prefix should match namespaced functions. Got: {:?}",
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FUNCTION))
.map(|i| (&i.label, &i.insert_text))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_stub_global_function_no_auto_import() {
let backend = create_test_backend_with_function_stubs();
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\narray_ma\n";
let items = complete_at(&backend, &uri, text, 1, 8).await;
let item = items.iter().find(|i| {
i.kind == Some(CompletionItemKind::FUNCTION)
&& i.filter_text.as_deref() == Some("array_map")
});
assert!(item.is_some(), "Should find array_map from stubs");
assert!(
item.unwrap().additional_text_edits.is_none(),
"Global stub function should NOT have auto-import edits"
);
}
#[tokio::test]
async fn test_auto_import_alphabetical_placement() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"use App\\Models\\User;\n",
"use Symfony\\Component\\HttpKernel;\n",
"\n",
"enum_val\n",
);
let items = complete_at(&backend, &uri, text, 4, 8).await;
let item = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("enum_value"));
assert!(item.is_some(), "Should find enum_value");
let edits = item
.unwrap()
.additional_text_edits
.as_ref()
.expect("Should have auto-import");
let edit = &edits[0];
assert_eq!(
edit.new_text, "\nuse function Illuminate\\Support\\enum_value;\n",
"Should have blank-line separator before the first function import"
);
assert_eq!(
edit.range.start.line, 3,
"Should insert after all class imports (line 3). Got line: {}",
edit.range.start.line
);
}
#[tokio::test]
async fn test_use_function_namespaced_detail_shows_signature() {
let backend = create_test_backend();
{
let mut fmap = backend.global_functions().write();
fmap.insert(
"Illuminate\\Support\\enum_value".to_string(),
(
"file:///helpers.php".to_string(),
FunctionInfo {
name: "enum_value".to_string(),
name_offset: 0,
parameters: vec![phpantom_lsp::types::ParameterInfo {
name: "$value".to_string(),
is_required: true,
type_hint: Some(PhpType::parse("mixed")),
native_type_hint: Some(PhpType::parse("mixed")),
description: None,
default_value: None,
is_variadic: false,
is_reference: false,
closure_this_type: None,
}],
return_type: Some(PhpType::parse("mixed")),
native_return_type: None,
description: None,
return_description: None,
links: vec![],
see_refs: vec![],
namespace: Some("Illuminate\\Support".to_string()),
conditional_return: None,
type_assertions: vec![],
deprecation_message: None,
deprecated_replacement: None,
template_params: vec![],
template_bindings: vec![],
throws: vec![],
is_polyfill: false,
},
),
);
}
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nuse function enum_val\n";
let items = complete_at(&backend, &uri, text, 1, 21).await;
let item = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("Illuminate"));
assert!(item.is_some(), "Should find the function");
let item = item.unwrap();
assert_eq!(
item.detail, None,
"Use-import items should have no detail (no return type shown). Got: {:?}",
item.detail
);
}
#[tokio::test]
async fn test_user_function_shadows_stub_same_fqn() {
let backend = create_test_backend_with_function_stubs();
register_global_function(&backend, "array_map", "file:///custom.php");
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\narray_ma\n";
let items = complete_at(&backend, &uri, text, 1, 8).await;
let matching: Vec<_> = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::FUNCTION)
&& i.filter_text.as_deref() == Some("array_map")
})
.collect();
assert_eq!(
matching.len(),
1,
"Should have exactly one array_map (user version shadows stub). Got: {:?}",
matching
.iter()
.map(|i| (&i.label, &i.detail))
.collect::<Vec<_>>()
);
assert_eq!(
matching[0].detail.as_deref(),
Some("string"),
"Should show return type in detail. Got: {:?}",
matching[0].detail
);
}
#[tokio::test]
async fn test_deprecated_namespaced_function() {
let backend = create_test_backend();
{
let mut fmap = backend.global_functions().write();
fmap.insert(
"Legacy\\old_helper".to_string(),
(
"file:///legacy.php".to_string(),
FunctionInfo {
name: "old_helper".to_string(),
name_offset: 0,
parameters: vec![],
return_type: None,
native_return_type: None,
description: None,
return_description: None,
links: vec![],
see_refs: vec![],
namespace: Some("Legacy".to_string()),
conditional_return: None,
type_assertions: vec![],
deprecation_message: Some("Use newFunc() instead".into()),
deprecated_replacement: None,
template_params: vec![],
template_bindings: vec![],
throws: vec![],
is_polyfill: false,
},
),
);
}
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nold_hel\n";
let items = complete_at(&backend, &uri, text, 1, 7).await;
let item = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("old_helper"));
assert!(item.is_some(), "Should find old_helper");
let tags = item.unwrap().tags.as_deref().unwrap_or(&[]);
assert!(
tags.contains(&CompletionItemTag::DEPRECATED),
"Tags should contain DEPRECATED"
);
}
#[tokio::test]
async fn test_inline_different_namespace_gets_auto_import() {
let backend = create_test_backend();
register_namespaced_function(
&backend,
"Illuminate\\Support\\enum_value",
"enum_value",
"Illuminate\\Support",
"file:///helpers.php",
);
let uri = Url::parse("file:///test.php").unwrap();
let text = "<?php\nnamespace App\\Services;\n\nenum_val\n";
let items = complete_at(&backend, &uri, text, 3, 8).await;
let item = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::FUNCTION) && i.label.contains("enum_value"));
assert!(item.is_some(), "Should find enum_value");
let edits = item
.unwrap()
.additional_text_edits
.as_ref()
.expect("Different-namespace function should get auto-import");
assert!(
edits[0]
.new_text
.contains("use function Illuminate\\Support\\enum_value;"),
"Auto-import text should be correct. Got: {}",
edits[0].new_text
);
}