use crate::common::{create_psr4_workspace, create_test_backend};
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
#[tokio::test]
async fn test_completion_trait_methods_available_on_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_basic.php").unwrap();
let text = concat!(
"<?php\n",
"trait Greetable {\n",
" public function greet(): string { return 'hi'; }\n",
" protected function farewell(): string { return 'bye'; }\n",
"}\n",
"class Person {\n",
" use Greetable;\n",
" public function name(): string { return 'Alice'; }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"greet"),
"Should include trait method 'greet', got: {:?}",
method_names
);
assert!(
method_names.contains(&"farewell"),
"Should include trait protected method 'farewell', got: {:?}",
method_names
);
assert!(
method_names.contains(&"name"),
"Should include own method 'name', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_properties_available_on_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_props.php").unwrap();
let text = concat!(
"<?php\n",
"trait HasTimestamps {\n",
" public string $created_at;\n",
" protected string $updated_at;\n",
" private string $internal_ts;\n",
"}\n",
"class Post {\n",
" use HasTimestamps;\n",
" public string $title;\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let prop_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
prop_names.contains(&"title"),
"Should include own property 'title', got: {:?}",
prop_names
);
assert!(
prop_names.contains(&"created_at"),
"Should include trait property 'created_at', got: {:?}",
prop_names
);
assert!(
prop_names.contains(&"updated_at"),
"Should include trait protected property 'updated_at', got: {:?}",
prop_names
);
assert!(
prop_names.contains(&"internal_ts"),
"Should include trait private property 'internal_ts', got: {:?}",
prop_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_constants_available_via_double_colon() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_const.php").unwrap();
let text = concat!(
"<?php\n",
"trait HasVersion {\n",
" public const VERSION = '1.0';\n",
"}\n",
"class App {\n",
" use HasVersion;\n",
" public const NAME = 'MyApp';\n",
" function test() {\n",
" self::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let const_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
const_names.contains(&"NAME"),
"Should include own constant 'NAME', got: {:?}",
const_names
);
assert!(
const_names.contains(&"VERSION"),
"Should include trait constant 'VERSION', got: {:?}",
const_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_multiple_traits_same_file() {
let backend = create_test_backend();
let uri = Url::parse("file:///multi_traits.php").unwrap();
let text = concat!(
"<?php\n",
"trait Loggable {\n",
" public function log(): void {}\n",
"}\n",
"trait Cacheable {\n",
" public function cache(): void {}\n",
"}\n",
"class Service {\n",
" use Loggable, Cacheable;\n",
" public function run(): void {}\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"log"),
"Should include Loggable::log, got: {:?}",
method_names
);
assert!(
method_names.contains(&"cache"),
"Should include Cacheable::cache, got: {:?}",
method_names
);
assert!(
method_names.contains(&"run"),
"Should include own method 'run', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_nested_trait_composition() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_traits.php").unwrap();
let text = concat!(
"<?php\n",
"trait Hello {\n",
" public function sayHello(): string { return 'Hello'; }\n",
"}\n",
"trait World {\n",
" public function sayWorld(): string { return 'World'; }\n",
"}\n",
"trait HelloWorld {\n",
" use Hello, World;\n",
"}\n",
"class Greeter {\n",
" use HelloWorld;\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 13,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"sayHello"),
"Should include Hello::sayHello via nested trait, got: {:?}",
method_names
);
assert!(
method_names.contains(&"sayWorld"),
"Should include World::sayWorld via nested trait, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_class_method_overrides_trait_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_override.php").unwrap();
let text = concat!(
"<?php\n",
"trait Renderable {\n",
" public function render(): string { return 'trait'; }\n",
" public function format(): string { return 'format'; }\n",
"}\n",
"class View {\n",
" use Renderable;\n",
" public function render(): string { return 'class'; }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.collect();
let render_items: Vec<&&CompletionItem> = method_items
.iter()
.filter(|i| i.filter_text.as_deref() == Some("render"))
.collect();
assert_eq!(
render_items.len(),
1,
"Should have exactly one 'render' method (class override), got: {}",
render_items.len()
);
let format_items: Vec<&&CompletionItem> = method_items
.iter()
.filter(|i| i.filter_text.as_deref() == Some("format"))
.collect();
assert_eq!(
format_items.len(),
1,
"Should include trait-only method 'format'"
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_overrides_parent_class_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_vs_parent.php").unwrap();
let text = concat!(
"<?php\n",
"class Base {\n",
" public function hello(): string { return 'base'; }\n",
" public function baseOnly(): void {}\n",
"}\n",
"trait SayWorld {\n",
" public function hello(): string { return 'trait'; }\n",
" public function traitOnly(): void {}\n",
"}\n",
"class Child extends Base {\n",
" use SayWorld;\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 12,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"hello"),
"Should include 'hello' (from trait), got: {:?}",
method_names
);
assert!(
method_names.contains(&"traitOnly"),
"Should include trait-only method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"baseOnly"),
"Should include parent-only method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"test"),
"Should include own method 'test', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let trait_php = concat!(
"<?php\n",
"namespace App\\Traits;\n",
"trait Auditable {\n",
" public function getAuditLog(): array { return []; }\n",
" public function setAuditor(string $name): void {}\n",
"}\n",
);
let class_php = concat!(
"<?php\n",
"namespace App\\Models;\n",
"use App\\Traits\\Auditable;\n",
"class User {\n",
" use Auditable;\n",
" public string $name;\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
("src/Traits/Auditable.php", trait_php),
("src/Models/User.php", class_php),
],
);
let uri = Url::parse("file:///test_cross_trait.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: class_php.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getAuditLog"),
"Should include cross-file trait method 'getAuditLog', got: {:?}",
method_names
);
assert!(
method_names.contains(&"setAuditor"),
"Should include cross-file trait method 'setAuditor', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_nested_trait_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let trait_a = concat!(
"<?php\n",
"namespace App\\Traits;\n",
"trait Timestamps {\n",
" public function getCreatedAt(): string { return ''; }\n",
"}\n",
);
let trait_b = concat!(
"<?php\n",
"namespace App\\Traits;\n",
"trait SoftDeletes {\n",
" public function trashed(): bool { return false; }\n",
"}\n",
);
let composed_trait = concat!(
"<?php\n",
"namespace App\\Traits;\n",
"use App\\Traits\\Timestamps;\n",
"use App\\Traits\\SoftDeletes;\n",
"trait ModelBehavior {\n",
" use Timestamps, SoftDeletes;\n",
" public function save(): bool { return true; }\n",
"}\n",
);
let model_php = concat!(
"<?php\n",
"namespace App\\Models;\n",
"use App\\Traits\\ModelBehavior;\n",
"class Post {\n",
" use ModelBehavior;\n",
" public string $title;\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
("src/Traits/Timestamps.php", trait_a),
("src/Traits/SoftDeletes.php", trait_b),
("src/Traits/ModelBehavior.php", composed_trait),
("src/Models/Post.php", model_php),
],
);
let uri = Url::parse("file:///test_nested_trait.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: model_php.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"save"),
"Should include ModelBehavior::save, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getCreatedAt"),
"Should include nested Timestamps::getCreatedAt, got: {:?}",
method_names
);
assert!(
method_names.contains(&"trashed"),
"Should include nested SoftDeletes::trashed, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_static_methods_via_double_colon() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_static.php").unwrap();
let text = concat!(
"<?php\n",
"trait HasFactory {\n",
" public static function factory(): self { return new static(); }\n",
"}\n",
"class User {\n",
" use HasFactory;\n",
" public static function query(): string { return ''; }\n",
" function test() {\n",
" self::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"factory"),
"Should include trait static method 'factory', got: {:?}",
method_names
);
assert!(
method_names.contains(&"query"),
"Should include own static method 'query', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_variable_of_class_with_trait() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_var.php").unwrap();
let text = concat!(
"<?php\n",
"trait Printable {\n",
" public function print(): void {}\n",
"}\n",
"class Document {\n",
" use Printable;\n",
" public function getTitle(): string { return ''; }\n",
"}\n",
"class Consumer {\n",
" public function test() {\n",
" $doc = new Document();\n",
" $doc->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"print"),
"Should include trait method 'print' on variable, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getTitle"),
"Should include own method 'getTitle' on variable, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_child_inherits_parent_trait_members() {
let backend = create_test_backend();
let uri = Url::parse("file:///parent_trait.php").unwrap();
let text = concat!(
"<?php\n",
"trait Serializable {\n",
" public function serialize(): string { return ''; }\n",
"}\n",
"class BaseModel {\n",
" use Serializable;\n",
" public function getId(): int { return 0; }\n",
"}\n",
"class User extends BaseModel {\n",
" public function getEmail(): string { return ''; }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getEmail"),
"Should include own method 'getEmail', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getId"),
"Should include parent method 'getId', got: {:?}",
method_names
);
assert!(
method_names.contains(&"serialize"),
"Should include trait method 'serialize' from parent, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_no_duplicate_members_from_trait() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_no_dup.php").unwrap();
let text = concat!(
"<?php\n",
"trait Loggable {\n",
" public function log(): void {}\n",
"}\n",
"class Base {\n",
" use Loggable;\n",
"}\n",
"class Child extends Base {\n",
" use Loggable;\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let log_count = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::METHOD)
&& i.filter_text.as_deref() == Some("log")
})
.count();
assert_eq!(
log_count, 1,
"Should have exactly one 'log' method (no duplicates), got: {}",
log_count
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_method_return_type_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_chain.php").unwrap();
let text = concat!(
"<?php\n",
"class Builder {\n",
" public function build(): string { return ''; }\n",
"}\n",
"trait HasBuilder {\n",
" public function getBuilder(): Builder { return new Builder(); }\n",
"}\n",
"class Service {\n",
" use HasBuilder;\n",
" function test() {\n",
" $this->getBuilder()->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 30,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"build"),
"Should resolve trait method return type and chain to Builder::build, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_parser_extracts_trait_info() {
let backend = create_test_backend();
let text = concat!(
"<?php\n",
"trait MyTrait {\n",
" public function traitMethod(): void {}\n",
" public string $traitProp;\n",
"}\n",
"class MyClass {\n",
" use MyTrait;\n",
" public function classMethod(): void {}\n",
"}\n",
);
let classes = backend.parse_php(text);
assert_eq!(classes.len(), 2, "Should have trait + class");
let trait_info = classes.iter().find(|c| c.name == "MyTrait").unwrap();
assert_eq!(trait_info.methods.len(), 1);
assert_eq!(trait_info.methods[0].name, "traitMethod");
assert_eq!(trait_info.properties.len(), 1);
assert_eq!(trait_info.properties[0].name, "traitProp");
assert!(trait_info.parent_class.is_none());
let class_info = classes.iter().find(|c| c.name == "MyClass").unwrap();
assert_eq!(class_info.methods.len(), 1);
assert_eq!(class_info.methods[0].name, "classMethod");
assert_eq!(class_info.used_traits.len(), 1);
assert_eq!(class_info.used_traits[0], "MyTrait");
}
#[tokio::test]
async fn test_parser_resolves_trait_names_with_use_statements() {
let backend = create_test_backend();
let uri = "file:///parse_ns_trait.php";
let text = concat!(
"<?php\n",
"namespace App\\Models;\n",
"use App\\Traits\\Auditable;\n",
"class User {\n",
" use Auditable;\n",
"}\n",
);
backend.update_ast(uri, text);
let classes = backend
.get_classes_for_uri(uri)
.expect("Should have AST entries");
let user = classes.iter().find(|c| c.name == "User").unwrap();
assert_eq!(user.used_traits.len(), 1);
assert_eq!(
user.used_traits[0], "App\\Traits\\Auditable",
"Trait name should be resolved to FQN, got: {}",
user.used_traits[0]
);
}
#[tokio::test]
async fn test_goto_definition_trait_method_same_file() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_goto.php").unwrap();
let text = concat!(
"<?php\n", "trait Greetable {\n", " public function greet(): string {\n", " return 'hello';\n", " }\n", "}\n", "class Person {\n", " use Greetable;\n", " function test() {\n", " $this->greet();\n", " }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.goto_definition(GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 9,
character: 18,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.await
.unwrap();
assert!(
result.is_some(),
"Should resolve definition for trait method"
);
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(location.uri, uri);
assert_eq!(
location.range.start.line, 2,
"Should jump to trait method definition line"
);
} else {
panic!("Expected GotoDefinitionResponse::Scalar");
}
}
#[tokio::test]
async fn test_goto_definition_trait_method_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let trait_php = concat!(
"<?php\n",
"namespace App\\Traits;\n",
"trait Loggable {\n",
" public function logMessage(): void {}\n",
"}\n",
);
let class_php = concat!(
"<?php\n",
"namespace App\\Services;\n",
"use App\\Traits\\Loggable;\n",
"class Worker {\n",
" use Loggable;\n",
" function run() {\n",
" $this->logMessage();\n",
" }\n",
"}\n",
);
let (backend, dir) = create_psr4_workspace(
composer_json,
&[
("src/Traits/Loggable.php", trait_php),
("src/Services/Worker.php", class_php),
],
);
let uri = Url::parse("file:///test_trait_goto.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: class_php.to_string(),
},
})
.await;
let result = backend
.goto_definition(GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.await
.unwrap();
assert!(
result.is_some(),
"Should resolve definition for cross-file trait method"
);
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
let trait_path = dir.path().join("src/Traits/Loggable.php");
let expected_uri = Url::from_file_path(&trait_path).unwrap();
assert_eq!(location.uri, expected_uri, "Should point to the trait file");
assert_eq!(
location.range.start.line, 3,
"Should jump to trait method definition line"
);
} else {
panic!("Expected GotoDefinitionResponse::Scalar");
}
}
#[tokio::test]
async fn test_completion_separate_use_statements_for_traits() {
let backend = create_test_backend();
let uri = Url::parse("file:///separate_use.php").unwrap();
let text = concat!(
"<?php\n",
"trait A {\n",
" public function fromA(): void {}\n",
"}\n",
"trait B {\n",
" public function fromB(): void {}\n",
"}\n",
"class MyClass {\n",
" use A;\n",
" use B;\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"fromA"),
"Should include A::fromA, got: {:?}",
method_names
);
assert!(
method_names.contains(&"fromB"),
"Should include B::fromB, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_with_interface_and_parent() {
let backend = create_test_backend();
let uri = Url::parse("file:///combined.php").unwrap();
let text = concat!(
"<?php\n",
"interface Printable {\n",
" public function print(): void;\n",
"}\n",
"trait Loggable {\n",
" public function log(): void {}\n",
"}\n",
"class Base {\n",
" public function baseMethod(): void {}\n",
"}\n",
"class Document extends Base implements Printable {\n",
" use Loggable;\n",
" public function print(): void {}\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 14,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"print"),
"Should include own method 'print', got: {:?}",
method_names
);
assert!(
method_names.contains(&"log"),
"Should include trait method 'log', got: {:?}",
method_names
);
assert!(
method_names.contains(&"baseMethod"),
"Should include parent method 'baseMethod', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_param_type_hint_class_with_trait() {
let backend = create_test_backend();
let uri = Url::parse("file:///param_trait.php").unwrap();
let text = concat!(
"<?php\n",
"trait Taggable {\n",
" public function addTag(string $tag): void {}\n",
" public function getTags(): array { return []; }\n",
"}\n",
"class Article {\n",
" use Taggable;\n",
" public function getTitle(): string { return ''; }\n",
"}\n",
"class Processor {\n",
" public function process(Article $article) {\n",
" $article->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 18,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"addTag"),
"Should include trait method 'addTag' on param variable, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getTags"),
"Should include trait method 'getTags' on param variable, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getTitle"),
"Should include class method 'getTitle' on param variable, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_grandchild_inherits_trait_from_grandparent() {
let backend = create_test_backend();
let uri = Url::parse("file:///grandchild_trait.php").unwrap();
let text = concat!(
"<?php\n",
"trait Identifiable {\n",
" public function getId(): int { return 0; }\n",
"}\n",
"class BaseModel {\n",
" use Identifiable;\n",
"}\n",
"class User extends BaseModel {\n",
" public function getName(): string { return ''; }\n",
"}\n",
"class Admin extends User {\n",
" public function getRole(): string { return ''; }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 13,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getRole"),
"Should include own method 'getRole', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getName"),
"Should include parent method 'getName', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getId"),
"Should include grandparent trait method 'getId', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_goto_definition_trait_name() {
let backend = create_test_backend();
let uri = Url::parse("file:///goto_trait_name.php").unwrap();
let text = concat!(
"<?php\n", "trait Fooable {\n", " public function foo(): void {}\n", "}\n", "class Bar {\n", " use Fooable;\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.goto_definition(GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 5,
character: 10,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.await
.unwrap();
assert!(
result.is_some(),
"Should resolve goto-definition for trait name"
);
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(location.uri, uri);
assert_eq!(
location.range.start.line, 1,
"Should jump to trait declaration line"
);
} else {
panic!("Expected GotoDefinitionResponse::Scalar");
}
}
#[tokio::test]
async fn test_completion_trait_method_with_docblock_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_docblock.php").unwrap();
let text = concat!(
"<?php\n",
"class Result {\n",
" public function isOk(): bool { return true; }\n",
"}\n",
"trait HasResult {\n",
" /** @return Result */\n",
" public function getResult() { return new Result(); }\n",
"}\n",
"class Handler {\n",
" use HasResult;\n",
" function test() {\n",
" $this->getResult()->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 28,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"isOk"),
"Should chain through trait method docblock return type to Result::isOk, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_insteadof_basic() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_insteadof.php").unwrap();
let text = concat!(
"<?php\n",
"trait TraitA {\n",
" public function hello(): string { return 'A'; }\n",
" public function shared(): string { return 'A'; }\n",
"}\n",
"trait TraitB {\n",
" public function world(): string { return 'B'; }\n",
" public function shared(): string { return 'B'; }\n",
"}\n",
"class MyClass {\n",
" use TraitA, TraitB {\n",
" TraitA::shared insteadof TraitB;\n",
" }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 14,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"hello"),
"Should include TraitA::hello, got: {:?}",
method_names
);
assert!(
method_names.contains(&"world"),
"Should include TraitB::world, got: {:?}",
method_names
);
assert!(
method_names.contains(&"shared"),
"Should include 'shared' (from TraitA via insteadof), got: {:?}",
method_names
);
let shared_count = method_names.iter().filter(|&&n| n == "shared").count();
assert_eq!(
shared_count, 1,
"Should have exactly one 'shared' method, got: {}",
shared_count
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_insteadof_multiple_excluded() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_insteadof_multi.php").unwrap();
let text = concat!(
"<?php\n",
"trait TraitA {\n",
" public function doWork(): string { return 'A'; }\n",
"}\n",
"trait TraitB {\n",
" public function doWork(): string { return 'B'; }\n",
"}\n",
"trait TraitC {\n",
" public function doWork(): string { return 'C'; }\n",
"}\n",
"class Worker {\n",
" use TraitA, TraitB, TraitC {\n",
" TraitA::doWork insteadof TraitB, TraitC;\n",
" }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 15,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"doWork"),
"Should include 'doWork' from TraitA, got: {:?}",
method_names
);
let count = method_names.iter().filter(|&&n| n == "doWork").count();
assert_eq!(
count, 1,
"Should have exactly one 'doWork' (B and C excluded), got: {}",
count
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_as_alias_basic() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_as_alias.php").unwrap();
let text = concat!(
"<?php\n",
"trait TraitA {\n",
" public function hello(): string { return 'A'; }\n",
" public function shared(): string { return 'A'; }\n",
"}\n",
"trait TraitB {\n",
" public function world(): string { return 'B'; }\n",
" public function shared(): string { return 'B'; }\n",
"}\n",
"class MyClass {\n",
" use TraitA, TraitB {\n",
" TraitA::shared insteadof TraitB;\n",
" TraitB::shared as sharedFromB;\n",
" }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 15,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"hello"),
"Should include TraitA::hello, got: {:?}",
method_names
);
assert!(
method_names.contains(&"world"),
"Should include TraitB::world, got: {:?}",
method_names
);
assert!(
method_names.contains(&"shared"),
"Should include 'shared' (from TraitA), got: {:?}",
method_names
);
assert!(
method_names.contains(&"sharedFromB"),
"Should include alias 'sharedFromB' (from TraitB), got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_as_visibility_only() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_as_visibility.php").unwrap();
let text = concat!(
"<?php\n",
"trait Greeter {\n",
" public function greet(): string { return 'hi'; }\n",
"}\n",
"class MyClass {\n",
" use Greeter {\n",
" greet as protected;\n",
" }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"greet"),
"Should include 'greet' with changed visibility, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_as_alias_with_visibility() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_as_vis_alias.php").unwrap();
let text = concat!(
"<?php\n",
"trait Logger {\n",
" public function log(): void {}\n",
"}\n",
"class Service {\n",
" use Logger {\n",
" Logger::log as private privateLog;\n",
" }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"log"),
"Should include original 'log' method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"privateLog"),
"Should include alias 'privateLog', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_insteadof_with_as_alias() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_combined.php").unwrap();
let text = concat!(
"<?php\n",
"trait Talker {\n",
" public function smallTalk(): string { return 'talker'; }\n",
" public function talk(): string { return 'talker'; }\n",
"}\n",
"trait Greeter {\n",
" public function greet(): string { return 'greeter'; }\n",
" public function talk(): string { return 'greeter'; }\n",
"}\n",
"class Person {\n",
" use Talker, Greeter {\n",
" Talker::talk insteadof Greeter;\n",
" Greeter::talk as greeterTalk;\n",
" }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 15,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"smallTalk"),
"Should include Talker::smallTalk, got: {:?}",
method_names
);
assert!(
method_names.contains(&"greet"),
"Should include Greeter::greet, got: {:?}",
method_names
);
assert!(
method_names.contains(&"talk"),
"Should include 'talk' (from Talker via insteadof), got: {:?}",
method_names
);
assert!(
method_names.contains(&"greeterTalk"),
"Should include alias 'greeterTalk' (from Greeter), got: {:?}",
method_names
);
let talk_count = method_names.iter().filter(|&&n| n == "talk").count();
assert_eq!(
talk_count, 1,
"Should have exactly one 'talk', got: {}",
talk_count
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_insteadof_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let trait_a_php = concat!(
"<?php\n",
"namespace App\\Traits;\n",
"trait TraitA {\n",
" public function onlyA(): string { return 'A'; }\n",
" public function conflict(): string { return 'A'; }\n",
"}\n",
);
let trait_b_php = concat!(
"<?php\n",
"namespace App\\Traits;\n",
"trait TraitB {\n",
" public function onlyB(): string { return 'B'; }\n",
" public function conflict(): string { return 'B'; }\n",
"}\n",
);
let class_php = concat!(
"<?php\n",
"namespace App\\Models;\n",
"use App\\Traits\\TraitA;\n",
"use App\\Traits\\TraitB;\n",
"class Widget {\n",
" use TraitA, TraitB {\n",
" TraitA::conflict insteadof TraitB;\n",
" TraitB::conflict as conflictFromB;\n",
" }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
("src/Traits/TraitA.php", trait_a_php),
("src/Traits/TraitB.php", trait_b_php),
("src/Models/Widget.php", class_php),
],
);
let uri = Url::parse("file:///test_cross_insteadof.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: class_php.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"onlyA"),
"Should include TraitA::onlyA, got: {:?}",
method_names
);
assert!(
method_names.contains(&"onlyB"),
"Should include TraitB::onlyB, got: {:?}",
method_names
);
assert!(
method_names.contains(&"conflict"),
"Should include 'conflict' (from TraitA), got: {:?}",
method_names
);
assert!(
method_names.contains(&"conflictFromB"),
"Should include alias 'conflictFromB' (from TraitB), got: {:?}",
method_names
);
let conflict_count = method_names.iter().filter(|&&n| n == "conflict").count();
assert_eq!(
conflict_count, 1,
"Should have exactly one 'conflict', got: {}",
conflict_count
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_class_own_method_wins_over_insteadof() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_own_wins.php").unwrap();
let text = concat!(
"<?php\n",
"trait TraitA {\n",
" public function doIt(): string { return 'A'; }\n",
"}\n",
"trait TraitB {\n",
" public function doIt(): string { return 'B'; }\n",
"}\n",
"class MyClass {\n",
" use TraitA, TraitB {\n",
" TraitA::doIt insteadof TraitB;\n",
" }\n",
" public function doIt(): int { return 42; }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 13,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"doIt"),
"Should include 'doIt', got: {:?}",
method_names
);
let count = method_names.iter().filter(|&&n| n == "doIt").count();
assert_eq!(
count, 1,
"Class own method should win, exactly one 'doIt', got: {}",
count
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_alias_preserves_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_alias_return.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
"}\n",
"trait Finder {\n",
" public function find(): User { return new User(); }\n",
"}\n",
"trait Loader {\n",
" public function find(): User { return new User(); }\n",
"}\n",
"class Repository {\n",
" use Finder, Loader {\n",
" Finder::find insteadof Loader;\n",
" Loader::find as loadFind;\n",
" }\n",
" function test() {\n",
" $this->loadFind()->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 16,
character: 28,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getName"),
"Alias return type should chain — 'loadFind()' returns User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_variable_of_class_with_trait_insteadof() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_var_insteadof.php").unwrap();
let text = concat!(
"<?php\n",
"trait Encoder {\n",
" public function encode(): string { return ''; }\n",
" public function process(): string { return 'encoder'; }\n",
"}\n",
"trait Decoder {\n",
" public function decode(): string { return ''; }\n",
" public function process(): string { return 'decoder'; }\n",
"}\n",
"class Codec {\n",
" use Encoder, Decoder {\n",
" Encoder::process insteadof Decoder;\n",
" Decoder::process as decoderProcess;\n",
" }\n",
"}\n",
"function test() {\n",
" $codec = new Codec();\n",
" $codec->\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 17,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"encode"),
"Should include 'encode', got: {:?}",
method_names
);
assert!(
method_names.contains(&"decode"),
"Should include 'decode', got: {:?}",
method_names
);
assert!(
method_names.contains(&"process"),
"Should include 'process' (from Encoder), got: {:?}",
method_names
);
assert!(
method_names.contains(&"decoderProcess"),
"Should include alias 'decoderProcess', got: {:?}",
method_names
);
let process_count = method_names.iter().filter(|&&n| n == "process").count();
assert_eq!(
process_count, 1,
"Should have exactly one 'process', got: {}",
process_count
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_parser_extracts_trait_adaptations() {
let backend = create_test_backend();
let text = concat!(
"<?php\n",
"trait A {\n",
" public function foo(): void {}\n",
"}\n",
"trait B {\n",
" public function foo(): void {}\n",
"}\n",
"class C {\n",
" use A, B {\n",
" A::foo insteadof B;\n",
" B::foo as bFoo;\n",
" A::foo as protected;\n",
" }\n",
"}\n",
);
let classes = backend.parse_php(text);
let class_c = classes
.iter()
.find(|c| c.name == "C")
.expect("Should find class C");
assert_eq!(
class_c.trait_precedences.len(),
1,
"Should have 1 precedence"
);
assert_eq!(class_c.trait_precedences[0].trait_name, "A");
assert_eq!(class_c.trait_precedences[0].method_name, "foo");
assert_eq!(class_c.trait_precedences[0].insteadof, vec!["B"]);
assert_eq!(class_c.trait_aliases.len(), 2, "Should have 2 aliases");
let alias_b = class_c
.trait_aliases
.iter()
.find(|a| a.alias.as_deref() == Some("bFoo"))
.expect("Should have bFoo alias");
assert_eq!(alias_b.trait_name.as_deref(), Some("B"));
assert_eq!(alias_b.method_name, "foo");
assert!(alias_b.visibility.is_none());
let alias_a = class_c
.trait_aliases
.iter()
.find(|a| a.alias.is_none())
.expect("Should have visibility-only alias");
assert_eq!(alias_a.trait_name.as_deref(), Some("A"));
assert_eq!(alias_a.method_name, "foo");
assert_eq!(
alias_a.visibility,
Some(phpantom_lsp::types::Visibility::Protected)
);
}
#[tokio::test]
async fn test_completion_trait_as_unqualified_reference() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_unqualified_as.php").unwrap();
let text = concat!(
"<?php\n",
"trait Greeter {\n",
" public function greet(): string { return 'hi'; }\n",
"}\n",
"class MyClass {\n",
" use Greeter {\n",
" greet as helloWorld;\n",
" }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"greet"),
"Should still include original 'greet', got: {:?}",
method_names
);
assert!(
method_names.contains(&"helloWorld"),
"Should include alias 'helloWorld', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_property_docblock_type_chain_same_file() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_prop_docblock.php").unwrap();
let text = concat!(
"<?php\n",
"class OutputStyle {\n",
" public function createProgressBar(): ProgressBar { return new ProgressBar(); }\n",
"}\n",
"class ProgressBar {\n",
" public function advance(): void {}\n",
"}\n",
"trait InteractsWithIO {\n",
" /** @var OutputStyle */\n",
" protected $output;\n",
"}\n",
"class Command {\n",
" use InteractsWithIO;\n",
"}\n",
"final class ReindexSelectedCommand extends Command {\n",
" public function handle(): int {\n",
" $this->output->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 16,
character: 24,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completion results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"createProgressBar"),
"Should resolve trait property @var type and show OutputStyle::createProgressBar, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_property_docblock_type_chain_cross_file() {
let composer = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#;
let trait_file = concat!(
"<?php\n",
"namespace App\\Concerns;\n",
"use App\\OutputStyle;\n",
"trait InteractsWithIO {\n",
" /** @var OutputStyle */\n",
" protected $output;\n",
"}\n",
);
let output_style_file = concat!(
"<?php\n",
"namespace App;\n",
"class OutputStyle {\n",
" public function createProgressBar(): ProgressBar { return new ProgressBar(); }\n",
"}\n",
);
let progress_bar_file = concat!(
"<?php\n",
"namespace App;\n",
"class ProgressBar {\n",
" public function advance(): void {}\n",
"}\n",
);
let command_file = concat!(
"<?php\n",
"namespace App;\n",
"use App\\Concerns\\InteractsWithIO;\n",
"class Command {\n",
" use InteractsWithIO;\n",
"}\n",
);
let child_file = concat!(
"<?php\n",
"namespace App;\n",
"final class ReindexSelectedCommand extends Command {\n",
" public function handle(): int {\n",
" $this->output->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer,
&[
("src/Concerns/InteractsWithIO.php", trait_file),
("src/OutputStyle.php", output_style_file),
("src/ProgressBar.php", progress_bar_file),
("src/Command.php", command_file),
("src/ReindexSelectedCommand.php", child_file),
],
);
let uri = Url::from_file_path(_dir.path().join("src/ReindexSelectedCommand.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: child_file.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 24,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completion results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"createProgressBar"),
"Should resolve cross-file trait property @var type, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_property_docblock_type_different_namespace() {
let composer = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#;
let trait_file = concat!(
"<?php\n",
"namespace App\\Concerns;\n",
"use App\\Support\\OutputStyle;\n",
"trait InteractsWithIO {\n",
" /** @var OutputStyle */\n",
" protected $output;\n",
"}\n",
);
let output_style_file = concat!(
"<?php\n",
"namespace App\\Support;\n",
"class OutputStyle {\n",
" public function createProgressBar(): ProgressBar { return new ProgressBar(); }\n",
"}\n",
);
let progress_bar_file = concat!(
"<?php\n",
"namespace App\\Support;\n",
"class ProgressBar {\n",
" public function advance(): void {}\n",
"}\n",
);
let command_file = concat!(
"<?php\n",
"namespace App\\Console;\n",
"use App\\Concerns\\InteractsWithIO;\n",
"class Command {\n",
" use InteractsWithIO;\n",
"}\n",
);
let child_file = concat!(
"<?php\n",
"namespace App\\Console\\Commands;\n",
"use App\\Console\\Command;\n",
"final class ReindexSelectedCommand extends Command {\n",
" public function handle(): int {\n",
" $this->output->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer,
&[
("src/Concerns/InteractsWithIO.php", trait_file),
("src/Support/OutputStyle.php", output_style_file),
("src/Support/ProgressBar.php", progress_bar_file),
("src/Console/Command.php", command_file),
(
"src/Console/Commands/ReindexSelectedCommand.php",
child_file,
),
],
);
let uri = Url::from_file_path(
_dir.path()
.join("src/Console/Commands/ReindexSelectedCommand.php"),
)
.unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: child_file.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 24,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completion results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"createProgressBar"),
"Should resolve trait property @var type across different namespaces, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_property_fqn_leading_backslash() {
let composer = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#;
let trait_file = concat!(
"<?php\n",
"namespace App\\Concerns;\n",
"trait InteractsWithIO {\n",
" /** @var \\App\\Support\\OutputStyle */\n",
" protected $output;\n",
"}\n",
);
let output_style_file = concat!(
"<?php\n",
"namespace App\\Support;\n",
"class OutputStyle {\n",
" public function createProgressBar(): void {}\n",
"}\n",
);
let command_file = concat!(
"<?php\n",
"namespace App\\Console;\n",
"use App\\Concerns\\InteractsWithIO;\n",
"class Command {\n",
" use InteractsWithIO;\n",
"}\n",
);
let child_file = concat!(
"<?php\n",
"namespace App\\Console\\Commands;\n",
"use App\\Console\\Command;\n",
"final class ReindexSelectedCommand extends Command {\n",
" public function handle(): int {\n",
" $this->output->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer,
&[
("src/Concerns/InteractsWithIO.php", trait_file),
("src/Support/OutputStyle.php", output_style_file),
("src/Console/Command.php", command_file),
(
"src/Console/Commands/ReindexSelectedCommand.php",
child_file,
),
],
);
let uri = Url::from_file_path(
_dir.path()
.join("src/Console/Commands/ReindexSelectedCommand.php"),
)
.unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: child_file.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 24,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completion results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"createProgressBar"),
"Should resolve FQN @var type with leading backslash across namespaces, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_goto_definition_method_on_trait_property_docblock_type() {
let composer = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#;
let trait_file = concat!(
"<?php\n",
"namespace App\\Concerns;\n",
"use App\\OutputStyle;\n",
"trait InteractsWithIO {\n",
" /** @var OutputStyle */\n",
" protected $output;\n",
"}\n",
);
let output_style_file = concat!(
"<?php\n",
"namespace App;\n",
"class OutputStyle {\n",
" public function createProgressBar(): ProgressBar { return new ProgressBar(); }\n",
"}\n",
);
let progress_bar_file = concat!(
"<?php\n",
"namespace App;\n",
"class ProgressBar {\n",
" public function advance(): void {}\n",
"}\n",
);
let command_file = concat!(
"<?php\n",
"namespace App;\n",
"use App\\Concerns\\InteractsWithIO;\n",
"class Command {\n",
" use InteractsWithIO;\n",
"}\n",
);
let child_file = concat!(
"<?php\n",
"namespace App;\n",
"final class ReindexSelectedCommand extends Command {\n",
" public function handle(): int {\n",
" $bar = $this->output->createProgressBar();\n",
" }\n",
"}\n",
);
let (backend, dir) = create_psr4_workspace(
composer,
&[
("src/Concerns/InteractsWithIO.php", trait_file),
("src/OutputStyle.php", output_style_file),
("src/ProgressBar.php", progress_bar_file),
("src/Command.php", command_file),
("src/ReindexSelectedCommand.php", child_file),
],
);
let uri = Url::from_file_path(dir.path().join("src/ReindexSelectedCommand.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: child_file.to_string(),
},
})
.await;
let result = backend
.goto_definition(GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 35,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.await
.unwrap();
assert!(
result.is_some(),
"Should resolve go-to-definition for method on trait property's @var type"
);
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
let expected_uri = Url::from_file_path(dir.path().join("src/OutputStyle.php")).unwrap();
assert_eq!(
location.uri, expected_uri,
"Should jump to OutputStyle file"
);
assert_eq!(
location.range.start.line, 3,
"Should jump to createProgressBar definition line"
);
} else {
panic!("Expected GotoDefinitionResponse::Scalar");
}
}
#[tokio::test]
async fn test_completion_trait_property_fqn_var_type() {
let composer = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#;
let trait_file = concat!(
"<?php\n",
"namespace App\\Concerns;\n",
"trait InteractsWithIO {\n",
" /** @var \\App\\OutputStyle */\n",
" protected $output;\n",
"}\n",
);
let output_style_file = concat!(
"<?php\n",
"namespace App;\n",
"class OutputStyle {\n",
" public function createProgressBar(): void {}\n",
"}\n",
);
let command_file = concat!(
"<?php\n",
"namespace App;\n",
"use App\\Concerns\\InteractsWithIO;\n",
"class Command {\n",
" use InteractsWithIO;\n",
"}\n",
);
let child_file = concat!(
"<?php\n",
"namespace App;\n",
"final class ReindexSelectedCommand extends Command {\n",
" public function handle(): int {\n",
" $this->output->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer,
&[
("src/Concerns/InteractsWithIO.php", trait_file),
("src/OutputStyle.php", output_style_file),
("src/Command.php", command_file),
("src/ReindexSelectedCommand.php", child_file),
],
);
let uri = Url::from_file_path(_dir.path().join("src/ReindexSelectedCommand.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: child_file.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 24,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completion results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"createProgressBar"),
"Should resolve FQN @var type on trait property, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_this_return_from_trait_method_resolves_to_using_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_this_return.php").unwrap();
let text = concat!(
"<?php\n",
"trait MakesHttpRequests {\n",
" /** @return $this */\n",
" public function withHeaders(array $headers): static { return $this; }\n",
"}\n",
"class TestCase {\n",
" public function post(string $uri): string { return ''; }\n",
"}\n",
"class FeatureTest extends TestCase {\n",
" use MakesHttpRequests;\n",
" public function testSomething() {\n",
" $this->withHeaders([])->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 33,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completion results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"post"),
"withHeaders() returns $this which should resolve to FeatureTest, offering post() from parent TestCase, got: {:?}",
method_names
);
assert!(
method_names.contains(&"withHeaders"),
"Should also offer withHeaders() from the trait for further chaining, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_this_return_from_trait_method_cross_file() {
let composer = r#"{
"autoload": {
"psr-4": {
"App\\": "src/",
"App\\Traits\\": "src/Traits/",
"App\\Http\\": "src/Http/"
}
}
}"#;
let trait_php = "\
<?php
namespace App\\Traits;
trait MakesHttpRequests {
/** @return $this */
public function withHeaders(array $headers): static { return $this; }
/** @return $this */
public function withCookies(array $cookies): static { return $this; }
}
";
let base_php = "\
<?php
namespace App\\Http;
class TestCase {
public function post(string $uri): string { return ''; }
public function get(string $uri): string { return ''; }
}
";
let feature_php = "\
<?php
namespace App\\Http;
use App\\Traits\\MakesHttpRequests;
class FeatureTest extends TestCase {
use MakesHttpRequests;
public function testSomething() {
$this->withHeaders([])->
}
}
";
let (backend, dir) = create_psr4_workspace(
composer,
&[
("src/Traits/MakesHttpRequests.php", trait_php),
("src/Http/TestCase.php", base_php),
("src/Http/FeatureTest.php", feature_php),
],
);
let uri = Url::from_file_path(dir.path().join("src/Http/FeatureTest.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: feature_php.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 33,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completion results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"post"),
"withHeaders() returns $this, should resolve to FeatureTest offering post() from parent TestCase, got: {:?}",
method_names
);
assert!(
method_names.contains(&"get"),
"Should also offer get() from parent TestCase, got: {:?}",
method_names
);
assert!(
method_names.contains(&"withCookies"),
"Should offer withCookies() from the trait for further chaining, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_variable_resolution_inside_trait_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_var.php").unwrap();
let text = concat!(
"<?php\n",
"class Customer {\n",
" public function getName(): string { return ''; }\n",
" public function getEmail(): string { return ''; }\n",
"}\n",
"trait IsAuditableTrait {\n",
" public function transformAudit(): array {\n",
" $user = new Customer();\n",
" $user->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
match result {
Some(CompletionResponse::Array(items))
| Some(CompletionResponse::List(CompletionList { items, .. })) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Variable inside trait method should resolve to Customer with getName(), got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"Variable inside trait method should resolve to Customer with getEmail(), got: {:?}",
method_names
);
}
_ => panic!("Expected completion results for $user-> inside trait method"),
}
}
#[tokio::test]
async fn test_variable_from_method_call_inside_trait() {
let (backend, dir) = create_psr4_workspace(
r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#,
&[
(
"src/Order.php",
"<?php\nnamespace App;\nclass Order {\n public function getTotal(): float { return 0.0; }\n}\n",
),
(
"src/OrderService.php",
"<?php\nnamespace App;\nclass OrderService {\n public function findOrder(): Order { return new Order(); }\n}\n",
),
(
"src/AuditTrait.php",
concat!(
"<?php\n",
"namespace App;\n",
"trait AuditTrait {\n",
" public function audit(): void {\n",
" $svc = new OrderService();\n",
" $order = $svc->findOrder();\n",
" $order->\n",
" }\n",
"}\n",
),
),
],
);
let trait_content = concat!(
"<?php\n",
"namespace App;\n",
"trait AuditTrait {\n",
" public function audit(): void {\n",
" $svc = new OrderService();\n",
" $order = $svc->findOrder();\n",
" $order->\n",
" }\n",
"}\n",
);
let uri = Url::from_file_path(dir.path().join("src/AuditTrait.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: trait_content.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 16,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
match result {
Some(CompletionResponse::Array(items))
| Some(CompletionResponse::List(CompletionList { items, .. })) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getTotal"),
"Chained variable inside trait should resolve to Order with getTotal(), got: {:?}",
method_names
);
}
_ => panic!("Expected completion results for $order-> inside trait method"),
}
}
#[tokio::test]
async fn test_parameter_type_hint_inside_trait_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_param.php").unwrap();
let text = concat!(
"<?php\n",
"class Request {\n",
" public function getMethod(): string { return ''; }\n",
" public function getPath(): string { return ''; }\n",
"}\n",
"trait HandlesRequests {\n",
" public function handle(Request $req): void {\n",
" $req->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
match result {
Some(CompletionResponse::Array(items))
| Some(CompletionResponse::List(CompletionList { items, .. })) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getMethod"),
"Parameter type hint inside trait method should resolve, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getPath"),
"Parameter type hint inside trait method should resolve, got: {:?}",
method_names
);
}
_ => panic!("Expected completion results for $req-> inside trait method"),
}
}
#[tokio::test]
async fn test_cross_file_variable_resolution_inside_trait_method() {
let customer_php = "\
<?php
namespace App\\Models;
class Customer {
public function getName(): string { return ''; }
public function getEmail(): string { return ''; }
}
";
let trait_php = "\
<?php
namespace App\\Traits;
use App\\Models\\Customer;
trait IsAuditableTrait {
public function transformAudit(): array {
$user = new Customer();
$user->
}
}
";
let (backend, dir) = create_psr4_workspace(
r#"{ "autoload": { "psr-4": { "App\\Models\\": "src/Models/", "App\\Traits\\": "src/Traits/" } } }"#,
&[
("src/Models/Customer.php", customer_php),
("src/Traits/IsAuditableTrait.php", trait_php),
],
);
let uri = Url::from_file_path(dir.path().join("src/Traits/IsAuditableTrait.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: trait_php.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
match result {
Some(CompletionResponse::Array(items))
| Some(CompletionResponse::List(CompletionList { items, .. })) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Cross-file variable inside trait should resolve Customer with getName(), got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"Cross-file variable inside trait should resolve Customer with getEmail(), got: {:?}",
method_names
);
}
_ => panic!("Expected completion results for $user-> inside cross-file trait method"),
}
}