use crate::common::create_test_backend;
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 find_docblock_item(items: &[CompletionItem]) -> Option<&CompletionItem> {
items.iter().find(|i| {
i.filter_text.as_deref() == Some("/**")
&& i.insert_text_format == Some(InsertTextFormat::SNIPPET)
})
}
fn snippet_text(item: &CompletionItem) -> &str {
match &item.text_edit {
Some(CompletionTextEdit::Edit(edit)) => &edit.new_text,
_ => "",
}
}
#[tokio::test]
async fn generates_summary_only_for_fully_typed_function() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_func.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function greet(string $name, int $age): string {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock generation item, got: {:?}",
items.iter().map(|i| &i.label).collect::<Vec<_>>(),
);
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Fully-typed scalar params should NOT get @param, got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"Scalar return type should NOT get @return, got:\n{}",
snippet
);
assert!(snippet.starts_with("/**"), "Should start with /**");
assert!(
snippet.contains("${1}"),
"Should have summary tab stop, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_summary_only_for_void_function_with_scalar_params() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_void.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function doStuff(int $count): void {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock generation item");
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Scalar int param should NOT get @param, got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"Void function should not have @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_return_for_missing_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_no_params.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" function bar() {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock generation item");
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"No params means no @param tags"
);
assert!(
snippet.contains("@return"),
"Missing return type should get @return, got:\n{}",
snippet
);
assert!(
snippet.contains("mixed"),
"Missing return type placeholder should be mixed, got:\n{}",
snippet
);
assert!(snippet.starts_with("/**"), "Should start with /**");
assert!(
snippet.ends_with(" */"),
"Should end with ' */', got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_param_for_untyped_params() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_untyped.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function process($data, string $name): void {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock generation item");
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"Untyped param should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$data"),
"Should reference \\$data (escaped), got:\n{}",
snippet
);
assert!(
snippet.contains("mixed"),
"Untyped param should have mixed placeholder, got:\n{}",
snippet
);
assert!(
!snippet.contains("\\$name"),
"Fully-typed $name should NOT appear in @param, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_tags_for_array_params_and_return() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_array.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function process(array $items): array {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock generation item");
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"array param should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$items"),
"Should reference \\$items (escaped), got:\n{}",
snippet
);
assert!(
snippet.contains("@return"),
"array return should get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_docblock_for_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_class.php").unwrap();
let text = concat!("<?php\n", "/**\n", "class MyService {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock generation item for class"
);
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Class docblock should not have @param"
);
assert!(
!snippet.contains("@return"),
"Class docblock should not have @return"
);
assert!(snippet.starts_with("/**"), "Should start with /**");
}
#[tokio::test]
async fn generates_docblock_for_interface() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_interface.php").unwrap();
let text = concat!("<?php\n", "/**\n", "interface Renderable {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock generation item for interface"
);
}
#[tokio::test]
async fn generates_docblock_for_enum() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_enum.php").unwrap();
let text = concat!("<?php\n", "/**\n", "enum Status: string {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock generation item for enum"
);
}
#[tokio::test]
async fn generates_docblock_for_trait() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_trait.php").unwrap();
let text = concat!("<?php\n", "/**\n", "trait Cacheable {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock generation item for trait"
);
}
#[tokio::test]
async fn generates_var_for_typed_property() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_prop_typed.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" public string $name;\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock generation item for typed property"
);
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@var string"),
"Typed property should have @var string, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_docblock_for_untyped_property() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_prop_untyped.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" public $name;\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock generation item for untyped property"
);
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@var"),
"Untyped property should include @var, got:\n{}",
snippet
);
assert!(
snippet.contains("mixed"),
"Untyped property should have mixed placeholder, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_docblock_for_constant() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_const.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" const MAX = 100;\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock generation item for constant"
);
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@var"),
"Constant should include @var, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_docblock_for_typed_constant() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_const_typed.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" public const int MAX = 100;\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock generation item for typed constant"
);
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@var int"),
"Typed constant should have @var int, got:\n{}",
snippet
);
}
#[tokio::test]
async fn skips_nullable_param_and_return() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_nullable.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function test(?string $name): ?int {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some());
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Nullable type is fully expressed, should NOT get @param, got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"Nullable return is fully expressed, should NOT get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn skips_union_param_and_return() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_union.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function test(string|int $value): string|false {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some());
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Union type is fully expressed, should NOT get @param, got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"Union return is fully expressed, should NOT get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_param_for_union_with_array() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_union_array.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function call(string $endpoint, array|string $params, array $headers): void {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some());
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"Union containing array should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("array<${1:mixed}>|string"),
"Should enrich array part in union type, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$params"),
"Should reference \\$params (escaped), got:\n{}",
snippet
);
assert!(
snippet.contains("\\$headers"),
"Bare array param should get @param too, got:\n{}",
snippet
);
assert!(
!snippet.contains("\\$endpoint"),
"Scalar string param should NOT appear, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_param_for_closure() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_closure.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function remember(Closure $callback): void {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some());
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"Closure param should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("Closure()"),
"Should contain callable signature placeholder, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$callback"),
"Should reference \\$callback (escaped), got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_param_for_callable() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_callable.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function register(callable $handler): void {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some());
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"callable param should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("callable()"),
"Should contain callable signature placeholder, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$handler"),
"Should reference \\$handler (escaped), got:\n{}",
snippet
);
}
#[tokio::test]
async fn blank_separator_between_param_and_throws() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_sep.php").unwrap();
let text = concat!(
"<?php\n",
"class NotFoundException extends \\Exception {}\n",
"/**\n",
"function find(array $exclude): string {\n",
" throw new NotFoundException('not found');\n",
" return 'found';\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock generation item");
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"Should have @param, got:\n{}",
snippet
);
assert!(
snippet.contains("@throws"),
"Should have @throws, got:\n{}",
snippet
);
let lines: Vec<&str> = snippet.lines().collect();
let param_idx = lines.iter().position(|l| l.contains("@param")).unwrap();
let throws_idx = lines.iter().position(|l| l.contains("@throws")).unwrap();
assert_eq!(
throws_idx,
param_idx + 2,
"@throws should be separated from @param by one blank * line, got:\n{}",
snippet
);
assert_eq!(
lines[param_idx + 1].trim(),
"*",
"Separator should be a bare *, got:\n{}",
snippet
);
}
#[tokio::test]
async fn skips_variadic_scalar_params() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_variadic.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function test(string $first, int ...$rest): void {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some());
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Scalar variadic params should NOT get @param, got:\n{}",
snippet
);
}
#[tokio::test]
async fn snippet_has_tab_stops() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_tabstops.php").unwrap();
let text = concat!("<?php\n", "/**\n", "function test(string $name): int {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items).unwrap();
assert_eq!(item.insert_text_format, Some(InsertTextFormat::SNIPPET));
let snippet = snippet_text(item);
assert!(
snippet.contains("${1}"),
"Should have tab stop for summary, got:\n{}",
snippet
);
}
#[tokio::test]
async fn text_edit_covers_trigger() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_range.php").unwrap();
let text = concat!("<?php\n", "/**\n", "function test(): void {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items).unwrap();
match &item.text_edit {
Some(CompletionTextEdit::Edit(edit)) => {
assert_eq!(edit.range.start.line, 1);
assert_eq!(edit.range.start.character, 0);
assert_eq!(edit.range.end.line, 1);
assert_eq!(edit.range.end.character, 3);
}
other => panic!("Expected a TextEdit, got: {:?}", other),
}
}
#[tokio::test]
async fn generates_docblock_with_autoclosed_block() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_vscode.php").unwrap();
let text = concat!(
"<?php\n",
"/** */\n",
"function process($data, array $items): void {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock even with auto-closed /** */, got: {:?}",
items.iter().map(|i| &i.label).collect::<Vec<_>>(),
);
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"Should have @param tags, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$data"),
"Should reference \\$data (escaped), got:\n{}",
snippet
);
}
#[tokio::test]
async fn text_edit_covers_autoclosed_block() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_range_ac.php").unwrap();
let text = concat!("<?php\n", "/** */\n", "function test(): void {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items).unwrap();
match &item.text_edit {
Some(CompletionTextEdit::Edit(edit)) => {
assert_eq!(edit.range.start.line, 1);
assert_eq!(edit.range.start.character, 0);
assert_eq!(edit.range.end.line, 1);
assert_eq!(
edit.range.end.character, 6,
"Range should cover the auto-closed */ too"
);
}
other => panic!("Expected a TextEdit, got: {:?}", other),
}
}
#[tokio::test]
async fn generates_docblock_with_indented_autoclosed_block() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_vscode_indent.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /** */\n",
" public function bar($x): void {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock for indented auto-closed block"
);
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"Should have @param for untyped $x, got:\n{}",
snippet
);
match &item.unwrap().text_edit {
Some(CompletionTextEdit::Edit(edit)) => {
assert_eq!(edit.range.start.line, 2);
assert_eq!(edit.range.start.character, 4);
assert_eq!(edit.range.end.line, 2);
assert_eq!(
edit.range.end.character, 10,
"Range should cover ` /** */` from the `/**` start to end of line"
);
}
other => panic!("Expected a TextEdit, got: {:?}", other),
}
}
#[tokio::test]
async fn no_generation_for_existing_single_line_docblock() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_existing_sl.php").unwrap();
let text = concat!("<?php\n", "/** @var int */\n", "public $count = 0;\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_none(),
"Should NOT generate for `/** @var int */` (has content between /** and */)"
);
}
#[tokio::test]
async fn preserves_indentation_inside_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_indent.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" public function bar(string $x): int {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items).unwrap();
let snippet = snippet_text(item);
let lines: Vec<&str> = snippet.lines().collect();
assert!(
lines.len() >= 3,
"Snippet should have at least 3 lines, got {} lines:\n{}",
lines.len(),
snippet
);
let last = lines.last().unwrap();
assert_eq!(
*last, " */",
"Last line should be ' */' (no base indent), got: {:?}",
last,
);
}
#[tokio::test]
async fn no_generation_for_single_line_docblock() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_single_line.php").unwrap();
let text = concat!("<?php\n", "/** @var int */\n", "public $count = 0;\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_none(),
"Should not generate a docblock when */ is on the same line"
);
}
#[tokio::test]
async fn no_generation_inside_existing_docblock() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_existing.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * Existing documentation.\n",
" */\n",
"function test(): void {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_none(),
"Should not generate a docblock inside an existing one"
);
}
#[tokio::test]
async fn no_generation_with_code_before() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_code_before.php").unwrap();
let text = concat!("<?php\n", "$x = /**\n", "function test(): void {}\n",);
let items = complete_at(&backend, &uri, text, 1, 8).await;
let item = find_docblock_item(&items);
assert!(item.is_none(), "Should not generate when code precedes /**");
}
#[tokio::test]
async fn generates_docblock_for_override_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_override.php").unwrap();
let text = concat!(
"<?php\n",
"class Base {\n",
" public function getName(): string { return ''; }\n",
"}\n",
"class Child extends Base {\n",
" /**\n",
" public function getName(): string { return 'child'; }\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 5, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Override method should now produce a docblock (no special treatment)"
);
}
#[tokio::test]
async fn generates_throws_tags() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_throws.php").unwrap();
let text = concat!(
"<?php\n",
"class NotFoundException extends \\Exception {}\n",
"/**\n",
"function find(int $id): string {\n",
" if ($id < 0) {\n",
" throw new NotFoundException('not found');\n",
" }\n",
" return 'found';\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock generation item");
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@throws NotFoundException"),
"Should include @throws for uncaught exception, got:\n{}",
snippet,
);
}
#[tokio::test]
async fn generates_docblock_for_abstract_method_summary_only() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_abstract.php").unwrap();
let text = concat!(
"<?php\n",
"abstract class Shape {\n",
" /**\n",
" abstract public function area(float $radius): float;\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock for abstract method"
);
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Scalar float param should NOT get @param, got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"Scalar float return should NOT get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_docblock_for_abstract_method_with_untyped_params() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_abstract2.php").unwrap();
let text = concat!(
"<?php\n",
"abstract class Shape {\n",
" /**\n",
" abstract public function compute($radius): float;\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(item.is_some());
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"Untyped param should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$radius"),
"Should reference \\$radius (escaped), got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_docblock_for_static_method_summary_only() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_static.php").unwrap();
let text = concat!(
"<?php\n",
"class Factory {\n",
" /**\n",
" public static function create(string $type): self {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock for static method"
);
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Scalar string param should NOT get @param, got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"self is a built-in type — should NOT get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_docblock_for_constructor_with_promotion() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_ctor.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" /**\n",
" public function __construct(\n",
" private string $name,\n",
" private int $age,\n",
" ) {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock for constructor");
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Scalar promoted params should NOT get @param, got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"Constructor should never get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_var_for_promoted_property_inside_constructor() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_promoted_inline.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" public function __construct(\n",
" /**\n",
" public array $name,\n",
" ) {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 3, 11).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock for promoted property"
);
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@var"),
"Promoted property should have @var, got:\n{}",
snippet
);
let edit = match item.unwrap().text_edit.as_ref().unwrap() {
CompletionTextEdit::Edit(e) => e,
_ => panic!("Expected a simple TextEdit"),
};
assert_eq!(
edit.range.start.character, 8,
"Edit should start at column 8 (the `/**` position), got: {}",
edit.range.start.character
);
}
#[tokio::test]
async fn constructor_gets_param_but_never_return() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_ctor2.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" /**\n",
" public function __construct($name) {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock for constructor");
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"Untyped param should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$name"),
"Should reference \\$name (escaped), got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"Constructor should NEVER get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_var_for_readonly_property() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_readonly.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" public readonly string $name;\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock for readonly property"
);
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@var string"),
"Typed readonly property should have @var string, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_docblock_for_standalone_function() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_standalone.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function calculateTotal(array $items, float $taxRate = 0.0): float {\n",
" return 0.0;\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock for standalone function"
);
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"array param should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$items"),
"Should reference \\$items (escaped), got:\n{}",
snippet
);
assert!(
!snippet.contains("\\$taxRate"),
"Scalar float param should NOT appear in @param, got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"Scalar float return should NOT get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn docblock_item_is_preselected() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_preselect.php").unwrap();
let text = concat!("<?php\n", "/**\n", "function test(): void {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items).unwrap();
assert_eq!(
item.preselect,
Some(true),
"Docblock item should be pre-selected"
);
}
#[tokio::test]
async fn generates_docblock_for_abstract_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_abstract_class.php").unwrap();
let text = concat!("<?php\n", "/**\n", "abstract class Shape {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Should produce a docblock for abstract class"
);
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Class docblock should not have @param"
);
assert!(
!snippet.contains("@return"),
"Class docblock should not have @return"
);
}
#[tokio::test]
async fn generates_docblock_for_final_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_final_class.php").unwrap();
let text = concat!("<?php\n", "/**\n", "final class Singleton {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock for final class");
}
#[tokio::test]
async fn skips_reference_params_with_scalar_types() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_ref.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
"function swap(int &$a, int &$b): void {}\n",
);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some());
let snippet = snippet_text(item.unwrap());
assert!(
!snippet.contains("@param"),
"Scalar by-reference params should NOT get @param, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_param_for_untyped_reference_params() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_ref2.php").unwrap();
let text = concat!("<?php\n", "/**\n", "function swap(&$a, &$b): void {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(item.is_some());
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"Untyped reference params should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$a"),
"Should reference \\$a (escaped), got:\n{}",
snippet
);
assert!(
snippet.contains("\\$b"),
"Should reference \\$b (escaped), got:\n{}",
snippet
);
}
#[tokio::test]
async fn paramless_void_produces_summary_skeleton() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_pv.php").unwrap();
let text = concat!("<?php\n", "/**\n", "function noop(): void {}\n",);
let items = complete_at(&backend, &uri, text, 1, 3).await;
let item = find_docblock_item(&items);
assert!(
item.is_some(),
"Paramless void function should still produce a docblock"
);
let snippet = snippet_text(item.unwrap());
assert!(snippet.starts_with("/**"));
assert!(snippet.contains("${1}"), "Should have summary tab stop");
assert!(!snippet.contains("@param"));
assert!(!snippet.contains("@return"));
}
async fn format_on_enter(
backend: &phpantom_lsp::Backend,
uri: &Url,
text: &str,
line: u32,
character: u32,
) -> Vec<TextEdit> {
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 params = DocumentOnTypeFormattingParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position { line, character },
},
ch: "\n".to_string(),
options: FormattingOptions {
tab_size: 4,
insert_spaces: true,
..FormattingOptions::default()
},
};
backend
.on_type_formatting(params)
.await
.unwrap()
.unwrap_or_default()
}
#[tokio::test]
async fn on_enter_generates_param_for_untyped() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_func.php").unwrap();
let text = "<?php\n/** */\nfunction greet($name, $age): string {}\n";
let edits = format_on_enter(&backend, &uri, text, 1, 4).await;
assert!(
!edits.is_empty(),
"Should produce edits for empty docblock above function"
);
let new_text = &edits[0].new_text;
assert!(
new_text.contains("@param"),
"Untyped params should get @param, got:\n{}",
new_text
);
assert!(
new_text.contains("$name"),
"Should contain $name, got:\n{}",
new_text
);
assert!(
new_text.contains("$age"),
"Should contain $age, got:\n{}",
new_text
);
}
#[tokio::test]
async fn on_enter_generates_summary_for_fully_typed() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_func_typed.php").unwrap();
let text = "<?php\n/** */\nfunction greet(string $name, int $age): string {}\n";
let edits = format_on_enter(&backend, &uri, text, 1, 4).await;
assert!(
!edits.is_empty(),
"Should produce edits for empty docblock above function"
);
let new_text = &edits[0].new_text;
assert!(
!new_text.contains("@param"),
"Scalar params should NOT get @param, got:\n{}",
new_text
);
assert!(
!new_text.contains("@return"),
"Scalar return should NOT get @return, got:\n{}",
new_text
);
}
#[tokio::test]
async fn on_enter_generates_docblock_for_multiline_empty() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_multi.php").unwrap();
let text = "<?php\n/**\n * \n */\nfunction test($x): bool {}\n";
let edits = format_on_enter(&backend, &uri, text, 2, 3).await;
assert!(
!edits.is_empty(),
"Should produce edits for multi-line empty docblock"
);
let new_text = &edits[0].new_text;
assert!(
new_text.contains("@param"),
"Untyped param should get @param, got:\n{}",
new_text
);
assert!(
new_text.contains("$x"),
"Should reference $x, got:\n{}",
new_text
);
}
#[tokio::test]
async fn on_enter_summary_for_void_with_scalar_params() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_void.php").unwrap();
let text = "<?php\n/** */\nfunction doStuff(int $n): void {}\n";
let edits = format_on_enter(&backend, &uri, text, 1, 4).await;
assert!(!edits.is_empty());
let new_text = &edits[0].new_text;
assert!(
!new_text.contains("@param"),
"Scalar int param should NOT get @param, got:\n{}",
new_text
);
assert!(
!new_text.contains("@return"),
"Void function should not have @return, got:\n{}",
new_text
);
}
#[tokio::test]
async fn on_enter_generates_docblock_for_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_class.php").unwrap();
let text = "<?php\n/** */\nclass MyService {}\n";
let edits = format_on_enter(&backend, &uri, text, 1, 4).await;
assert!(!edits.is_empty(), "Should produce edits for class docblock");
let new_text = &edits[0].new_text;
assert!(!new_text.contains("@param"), "Class should not have @param");
assert!(
!new_text.contains("@return"),
"Class should not have @return"
);
assert!(new_text.contains("/**"), "Should contain opening /**");
assert!(new_text.contains("*/"), "Should contain closing */");
}
#[tokio::test]
async fn on_enter_generates_var_for_untyped_property() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_prop.php").unwrap();
let text = "<?php\nclass Foo {\n /** */\n public $name;\n}\n";
let edits = format_on_enter(&backend, &uri, text, 2, 7).await;
assert!(!edits.is_empty());
let new_text = &edits[0].new_text;
assert!(
new_text.contains("@var"),
"Untyped property should have @var, got:\n{}",
new_text
);
assert!(
new_text.contains("mixed"),
"Untyped property should have mixed, got:\n{}",
new_text
);
}
#[tokio::test]
async fn on_enter_generates_var_for_typed_property() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_prop_typed.php").unwrap();
let text = "<?php\nclass Foo {\n /** */\n public string $name;\n}\n";
let edits = format_on_enter(&backend, &uri, text, 2, 7).await;
assert!(!edits.is_empty());
let new_text = &edits[0].new_text;
assert!(
new_text.contains("@var string"),
"Typed property should have @var string, got:\n{}",
new_text
);
}
#[tokio::test]
async fn on_enter_generates_var_for_promoted_property() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_promoted.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" public function __construct(\n",
" /** */\n",
" public array $name,\n",
" ) {}\n",
"}\n",
);
let edits = format_on_enter(&backend, &uri, text, 3, 11).await;
assert!(
!edits.is_empty(),
"Should produce edits for promoted property"
);
let new_text = &edits[0].new_text;
assert!(
new_text.contains("@var"),
"Promoted property should have @var, got:\n{}",
new_text
);
assert!(
new_text.starts_with(" /** @var"),
"Should have 8-space indent, got:\n{:?}",
new_text
);
}
#[tokio::test]
async fn on_enter_uses_declaration_indent_not_block_indent() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_promoted_misindent.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" public function __construct(\n",
" /** */\n",
" public array $name,\n",
" ) {}\n",
"}\n",
);
let edits = format_on_enter(&backend, &uri, text, 3, 7).await;
assert!(!edits.is_empty(), "Should produce edits");
let new_text = &edits[0].new_text;
assert!(
new_text.contains("@var"),
"Should have @var, got:\n{}",
new_text
);
assert!(
new_text.starts_with(" /** @var"),
"Should use declaration indent (8 spaces), got:\n{:?}",
new_text
);
}
#[tokio::test]
async fn on_enter_preserves_indentation() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_indent.php").unwrap();
let text = "<?php\nclass Foo {\n /** */\n public function bar($x): int {}\n}\n";
let edits = format_on_enter(&backend, &uri, text, 2, 7).await;
assert!(!edits.is_empty());
let new_text = &edits[0].new_text;
assert!(
new_text.contains(" /**"),
"Opening should be indented, got:\n{}",
new_text
);
assert!(
new_text.contains(" * @param") || new_text.contains(" * @param"),
"Tags should be indented, got:\n{}",
new_text
);
assert!(
new_text.contains(" */"),
"Closing should be indented, got:\n{}",
new_text
);
}
#[tokio::test]
async fn on_enter_does_not_replace_existing_docblock() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_existing.php").unwrap();
let text = "<?php\n/**\n * This function does stuff.\n */\nfunction test(): void {}\n";
let edits = format_on_enter(&backend, &uri, text, 2, 5).await;
assert!(
edits.is_empty(),
"Should not replace an existing docblock with content"
);
}
#[tokio::test]
async fn on_enter_generates_for_override() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_override.php").unwrap();
let text = concat!(
"<?php\n",
"class Base {\n",
" public function getName(): string { return ''; }\n",
"}\n",
"class Child extends Base {\n",
" /** */\n",
" public function getName(): string { return 'child'; }\n",
"}\n",
);
let edits = format_on_enter(&backend, &uri, text, 5, 7).await;
assert!(
!edits.is_empty(),
"Override method should now produce edits (no special treatment)"
);
}
#[tokio::test]
async fn on_enter_generates_var_for_typed_constant() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_const.php").unwrap();
let text = "<?php\nclass Foo {\n /** */\n public const int MAX = 100;\n}\n";
let edits = format_on_enter(&backend, &uri, text, 2, 7).await;
assert!(!edits.is_empty());
let new_text = &edits[0].new_text;
assert!(
new_text.contains("@var int"),
"Typed constant should have @var int, got:\n{}",
new_text
);
}
#[tokio::test]
async fn on_enter_edit_covers_original_block() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_range.php").unwrap();
let text = "<?php\n/** */\nfunction test(): void {}\n";
let edits = format_on_enter(&backend, &uri, text, 1, 4).await;
assert!(!edits.is_empty());
let range = &edits[0].range;
assert_eq!(range.start.line, 1, "Edit should start at the /** line");
assert_eq!(
range.end.line, 2,
"Edit should end after the closing */ line"
);
}
#[tokio::test]
async fn generates_return_when_attribute_before_declaration() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_attr_override.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" #[Override]\n",
" public function toArray(): array {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock generation item");
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@return"),
"array return type should get @return, got:\n{}",
snippet
);
assert!(
!snippet.contains("@param"),
"No params means no @param tags, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_param_when_multiline_attribute_before_declaration() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_attr_multi.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" #[Route(\n",
" path: '/api/users',\n",
" methods: ['GET']\n",
" )]\n",
" public function getUsers(array $filters): array {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock generation item");
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("@param"),
"array param should get @param, got:\n{}",
snippet
);
assert!(
snippet.contains("\\$filters"),
"Should reference \\$filters, got:\n{}",
snippet
);
assert!(
snippet.contains("@return"),
"array return type should get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn generates_docblock_when_multiple_attributes() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_attr_multiple.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" /**\n",
" #[Override]\n",
" #[Deprecated]\n",
" public function process(): void {}\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(item.is_some(), "Should produce a docblock generation item");
let snippet = snippet_text(item.unwrap());
assert!(
snippet.contains("${1}"),
"Should have summary tab stop, got:\n{}",
snippet
);
assert!(
!snippet.contains("@param"),
"No params means no @param, got:\n{}",
snippet
);
assert!(
!snippet.contains("@return"),
"void should not get @return, got:\n{}",
snippet
);
}
#[tokio::test]
async fn no_generation_inside_function_body_variable() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_var_assign.php").unwrap();
let text = concat!(
"<?php\n",
"function demo() {\n",
" /**\n",
" $items = [''];\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 2, 7).await;
let item = find_docblock_item(&items);
assert!(
item.is_none(),
"Should NOT produce a docblock for variable assignment inside function body"
);
}
#[tokio::test]
async fn on_enter_no_generation_inside_function_body_variable() {
let backend = create_test_backend();
let uri = Url::parse("file:///ot_var_assign.php").unwrap();
let text = concat!(
"<?php\n",
"function demo() {\n",
" /** */\n",
" $items = [''];\n",
"}\n",
);
let edits = format_on_enter(&backend, &uri, text, 2, 7).await;
assert!(
edits.is_empty(),
"Should NOT produce edits for variable assignment inside function body"
);
}
#[tokio::test]
async fn no_generation_inside_nested_scope_variable() {
let backend = create_test_backend();
let uri = Url::parse("file:///gen_var_nested.php").unwrap();
let text = concat!(
"<?php\n",
"function demo() {\n",
" if (true) {\n",
" /**\n",
" $data = [];\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, text, 3, 11).await;
let item = find_docblock_item(&items);
assert!(
item.is_none(),
"Should NOT produce a docblock for nested variable inside function body"
);
}