use crate::common::{create_psr4_workspace, create_test_backend};
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
#[tokio::test]
async fn test_completion_mixin_methods_available_on_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_basic.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public function getItems(): array { return []; }\n",
" public function getTotal(): float { return 0.0; }\n",
" protected function recalculate(): void {}\n",
" private function internalCheck(): void {}\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\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(), "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(&"getId"),
"Should include own method 'getId', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getItems"),
"Should include mixin method 'getItems', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getTotal"),
"Should include mixin method 'getTotal', got: {:?}",
method_names
);
assert!(
!method_names.contains(&"recalculate"),
"Should NOT include protected mixin method 'recalculate', got: {:?}",
method_names
);
assert!(
!method_names.contains(&"internalCheck"),
"Should NOT include private mixin method 'internalCheck', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_own_method_overrides_mixin_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_override.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public function getId(): string { return 'cart-1'; }\n",
" public function getItems(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\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();
let get_id_count = method_names.iter().filter(|n| **n == "getId").count();
assert_eq!(
get_id_count, 1,
"getId should appear exactly once (own overrides mixin), got: {:?}",
method_names
);
assert!(
method_names.contains(&"getItems"),
"Should include mixin method 'getItems', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_properties_available() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_props.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public string $cartName;\n",
" public int $itemCount;\n",
" protected float $discount;\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public int $id;\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 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(&"id"),
"Should include own property 'id', got: {:?}",
prop_names
);
assert!(
prop_names.contains(&"cartName"),
"Should include mixin property 'cartName', got: {:?}",
prop_names
);
assert!(
prop_names.contains(&"itemCount"),
"Should include mixin property 'itemCount', got: {:?}",
prop_names
);
assert!(
!prop_names.contains(&"discount"),
"Should NOT include protected mixin property 'discount', got: {:?}",
prop_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_constants_available_via_double_colon() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_const.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public const MAX_ITEMS = 100;\n",
" public const MIN_ITEMS = 1;\n",
" private const INTERNAL = 'x';\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public const VERSION = '1.0';\n",
"}\n",
"CurrentCart::\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: 13,
},
},
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(&"VERSION"),
"Should include own constant 'VERSION', got: {:?}",
const_names
);
assert!(
const_names.contains(&"MAX_ITEMS"),
"Should include mixin constant 'MAX_ITEMS', got: {:?}",
const_names
);
assert!(
const_names.contains(&"MIN_ITEMS"),
"Should include mixin constant 'MIN_ITEMS', got: {:?}",
const_names
);
assert!(
!const_names.contains(&"INTERNAL"),
"Should NOT include private mixin constant 'INTERNAL', got: {:?}",
const_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_multiple_mixins() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_multi.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public function getItems(): array { return []; }\n",
"}\n",
"class Wishlist {\n",
" public function getWishes(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" * @mixin Wishlist\n",
" */\n",
"class UserDashboard {\n",
" public function getNotifications(): array { 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: 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(&"getNotifications"),
"Should include own method 'getNotifications', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getItems"),
"Should include mixin method 'getItems' from ShoppingCart, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getWishes"),
"Should include mixin method 'getWishes' from Wishlist, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_inherits_from_parent() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_inherit.php").unwrap();
let text = concat!(
"<?php\n",
"class BaseCart {\n",
" public function clear(): void {}\n",
"}\n",
"class ShoppingCart extends BaseCart {\n",
" public function getItems(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\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(&"getItems"),
"Should include mixin method 'getItems', got: {:?}",
method_names
);
assert!(
method_names.contains(&"clear"),
"Should include inherited method 'clear' from mixin's parent, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_lowest_precedence() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_prec.php").unwrap();
let text = concat!(
"<?php\n",
"class MixedIn {\n",
" public function shared(): string { return 'from-mixin'; }\n",
" public function mixinOnly(): string { return 'mixin'; }\n",
"}\n",
"class ParentClass {\n",
" public function shared(): string { return 'from-parent'; }\n",
" public function parentOnly(): string { return 'parent'; }\n",
"}\n",
"/**\n",
" * @mixin MixedIn\n",
" */\n",
"class Child extends ParentClass {\n",
" public function childOnly(): string { return 'child'; }\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(&"childOnly"),
"Should include own method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"parentOnly"),
"Should include parent method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"mixinOnly"),
"Should include mixin-only method, got: {:?}",
method_names
);
let shared_count = method_names.iter().filter(|n| **n == "shared").count();
assert_eq!(
shared_count, 1,
"'shared' should appear exactly once (parent wins over mixin), got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let cart_php = concat!(
"<?php\n",
"namespace App\\Models;\n",
"class ShoppingCart {\n",
" public function getItems(): array { return []; }\n",
" public function getTotal(): float { return 0.0; }\n",
"}\n",
);
let current_cart_php = concat!(
"<?php\n",
"namespace App\\Models;\n",
"use App\\Models\\ShoppingCart;\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
("src/Models/ShoppingCart.php", cart_php),
("src/Models/CurrentCart.php", current_cart_php),
],
);
let uri = Url::parse("file:///test_mixin_cross.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: current_cart_php.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(&"getId"),
"Should include own method 'getId', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getItems"),
"Should include mixin method 'getItems' from cross-file, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getTotal"),
"Should include mixin method 'getTotal' from cross-file, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_variable_of_class_with_mixin() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_var.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public function getItems(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\n",
" function test() {\n",
" /** @var CurrentCart $cart */\n",
" $cart = new CurrentCart();\n",
" $cart->\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(&"getId"),
"Should include own method 'getId', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getItems"),
"Should include mixin method 'getItems', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_no_duplicate_members_from_mixin() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_nodup.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public function getId(): string { return 'c1'; }\n",
" public function getItems(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\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();
let get_id_count = method_names.iter().filter(|n| **n == "getId").count();
assert_eq!(
get_id_count, 1,
"getId should appear exactly once (own overrides mixin), got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_class_with_trait() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_trait.php").unwrap();
let text = concat!(
"<?php\n",
"trait Discountable {\n",
" public function applyDiscount(float $pct): void {}\n",
"}\n",
"class ShoppingCart {\n",
" use Discountable;\n",
" public function getItems(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\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(&"getId"),
"Should include own method 'getId', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getItems"),
"Should include mixin method 'getItems', got: {:?}",
method_names
);
assert!(
method_names.contains(&"applyDiscount"),
"Should include trait method 'applyDiscount' from mixin class, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_goto_definition_mixin_method_same_file() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_goto.php").unwrap();
let text = concat!(
"<?php\n", "class ShoppingCart {\n", " public function getItems(): array { return []; }\n", "}\n", "/**\n", " * @mixin ShoppingCart\n", " */\n", "class CurrentCart {\n", " public function getId(): int { return 1; }\n", " function test() {\n", " $this->getItems();\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 },
position: Position {
line: 10,
character: 22, },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.await
.unwrap();
assert!(
result.is_some(),
"Should resolve definition for mixin method 'getItems'"
);
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(
location.range.start.line, 2,
"Should point to line 2 where getItems is defined in ShoppingCart"
);
} else {
panic!("Expected GotoDefinitionResponse::Scalar");
}
}
#[tokio::test]
async fn test_goto_definition_mixin_method_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let cart_php = concat!(
"<?php\n",
"namespace App\\Models;\n",
"class ShoppingCart {\n",
" public function getItems(): array { return []; }\n",
"}\n",
);
let current_cart_php = concat!(
"<?php\n",
"namespace App\\Services;\n",
"use App\\Models\\ShoppingCart;\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\n",
" function test() {\n",
" $this->getItems();\n",
" }\n",
"}\n",
);
let (backend, dir) = create_psr4_workspace(
composer_json,
&[
("src/Models/ShoppingCart.php", cart_php),
("src/Services/CurrentCart.php", current_cart_php),
],
);
let uri = Url::parse("file:///test_mixin_goto_cross.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: current_cart_php.to_string(),
},
})
.await;
let result = backend
.goto_definition(GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 22, },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.await
.unwrap();
assert!(
result.is_some(),
"Should resolve definition for cross-file mixin method"
);
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
let cart_path = dir.path().join("src/Models/ShoppingCart.php");
let expected_uri = Url::from_file_path(&cart_path).unwrap();
assert_eq!(
location.uri, expected_uri,
"Should point to the mixin source file"
);
assert_eq!(
location.range.start.line, 3,
"Should jump to the method definition line in the mixin class"
);
} else {
panic!("Expected GotoDefinitionResponse::Scalar");
}
}
#[tokio::test]
async fn test_completion_mixin_method_return_type_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_chain.php").unwrap();
let text = concat!(
"<?php\n", "class CartItem {\n", " public function getPrice(): float { return 0.0; }\n", "}\n", "class ShoppingCart {\n", " public function getFirstItem(): CartItem { return new CartItem(); }\n", "}\n", "/**\n", " * @mixin ShoppingCart\n", " */\n", "class CurrentCart {\n", " function test() {\n", " $item = $this->getFirstItem();\n", " $item->\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(&"getPrice"),
"Should follow return type chain through mixin method, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_parser_extracts_mixin_info() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_parser.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public function getItems(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\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: 0,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some() || result.is_none(),
"Parsing should succeed"
);
}
#[tokio::test]
async fn test_parser_resolves_mixin_names_with_use_statements() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let cart_php = concat!(
"<?php\n",
"namespace App\\Models;\n",
"class ShoppingCart {\n",
" public function getItems(): array { return []; }\n",
" public function getTotal(): float { return 0.0; }\n",
"}\n",
);
let current_cart_php = concat!(
"<?php\n",
"namespace App\\Services;\n",
"use App\\Models\\ShoppingCart;\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
("src/Models/ShoppingCart.php", cart_php),
("src/Services/CurrentCart.php", current_cart_php),
],
);
let uri = Url::parse("file:///test_resolve.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: current_cart_php.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(&"getItems"),
"Mixin name should be resolved via use statement, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getTotal"),
"Mixin name should be resolved via use statement, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_trait_overrides_mixin() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_trait_prec.php").unwrap();
let text = concat!(
"<?php\n",
"class MixedIn {\n",
" public function shared(): string { return 'from-mixin'; }\n",
" public function mixinOnly(): string { return 'mixin'; }\n",
"}\n",
"trait MyTrait {\n",
" public function shared(): int { return 42; }\n",
" public function traitOnly(): bool { return true; }\n",
"}\n",
"/**\n",
" * @mixin MixedIn\n",
" */\n",
"class MyClass {\n",
" use MyTrait;\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(&"traitOnly"),
"Should include trait method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"mixinOnly"),
"Should include mixin-only method, got: {:?}",
method_names
);
let shared_count = method_names.iter().filter(|n| **n == "shared").count();
assert_eq!(
shared_count, 1,
"'shared' should appear exactly once (trait wins over mixin), got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_combined_with_docblock_tags() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_docblock.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public function getItems(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" * @property string $sessionId\n",
" * @method void refresh()\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\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();
let prop_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getId"),
"Should include own method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getItems"),
"Should include mixin method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"refresh"),
"Should include @method tag method, got: {:?}",
method_names
);
assert!(
prop_names.contains(&"sessionId"),
"Should include @property tag property, got: {:?}",
prop_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_chained_mixin() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_chained.php").unwrap();
let text = concat!(
"<?php\n",
"class DeepModel {\n",
" public function deepMethod(): string { return 'deep'; }\n",
"}\n",
"/**\n",
" * @mixin DeepModel\n",
" */\n",
"class ShoppingCart {\n",
" public function getItems(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\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: 16,
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(&"getId"),
"Should include own method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getItems"),
"Should include first-level mixin method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"deepMethod"),
"Should include chained mixin method from DeepModel, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_on_class_that_extends() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_extends.php").unwrap();
let text = concat!(
"<?php\n",
"class MixedIn {\n",
" public function mixinMethod(): string { return 'hi'; }\n",
"}\n",
"class BaseCart {\n",
" public function baseMethod(): void {}\n",
"}\n",
"/**\n",
" * @mixin MixedIn\n",
" */\n",
"class CurrentCart extends BaseCart {\n",
" public function ownMethod(): int { return 1; }\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(&"ownMethod"),
"Should include own method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"baseMethod"),
"Should include parent method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"mixinMethod"),
"Should include mixin method, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_static_methods_via_double_colon() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_static.php").unwrap();
let text = concat!(
"<?php\n",
"class QueryBuilder {\n",
" public static function where(string $col): static { return new static(); }\n",
" public static function find(int $id): static { return new static(); }\n",
" public function first(): static { return $this; }\n",
"}\n",
"/**\n",
" * @mixin QueryBuilder\n",
" */\n",
"class Model {\n",
" public static function all(): array { return []; }\n",
"}\n",
"Model::\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: 7,
},
},
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 names: Vec<&str> = items
.iter()
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
names.contains(&"all"),
"Should include own static method 'all', got: {:?}",
names
);
assert!(
names.contains(&"where"),
"Should include mixin static method 'where', got: {:?}",
names
);
assert!(
names.contains(&"find"),
"Should include mixin static method 'find', got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_child_inherits_parent_mixin() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_child.php").unwrap();
let text = concat!(
"<?php\n",
"class MixedIn {\n",
" public function mixinMethod(): string { return 'hi'; }\n",
"}\n",
"/**\n",
" * @mixin MixedIn\n",
" */\n",
"class ParentClass {\n",
" public function parentMethod(): void {}\n",
"}\n",
"class ChildClass extends ParentClass {\n",
" public function childMethod(): int { return 1; }\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(&"childMethod"),
"Should include own method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"parentMethod"),
"Should include parent method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"mixinMethod"),
"Should include mixin method from parent's @mixin, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_variable_from_chained_method_call() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_chain_var.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCart {\n",
" public string $accessed_at;\n",
" public function getItems(): array { return []; }\n",
"}\n",
"/**\n",
" * @mixin ShoppingCart\n",
" */\n",
"class CurrentCart {\n",
" public function getId(): int { return 1; }\n",
"}\n",
"class CartFactory {\n",
" public function create(): CurrentCart { return new CurrentCart(); }\n",
"}\n",
"class Service {\n",
" public function getFactory(): CartFactory { return new CartFactory(); }\n",
" function test() {\n",
" $cart = $this->getFactory()->create();\n",
" $cart->\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: 18,
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();
let prop_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"getId"),
"Should include own method 'getId', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getItems"),
"Should include mixin method 'getItems', got: {:?}",
method_names
);
assert!(
prop_names.contains(&"accessed_at"),
"Should include mixin property 'accessed_at', got: {:?}",
prop_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_return_this_resolves_to_consumer_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_return_this.php").unwrap();
let text = concat!(
"<?php\n", "class QueryBuilder {\n", " /** @return $this */\n", " public function where(string $col): static { return $this; }\n", " public function get(): array { return []; }\n", " public function toSql(): string { return ''; }\n", "}\n", "/**\n", " * @mixin QueryBuilder\n", " */\n", "class Model {\n", " public function save(): bool { return true; }\n", " public function test(): void {\n", " $this->where('active')->\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: 33,
},
},
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(&"get"),
"Chaining after mixin @return $this should show mixin class methods (get), got: {:?}",
method_names
);
assert!(
method_names.contains(&"toSql"),
"Chaining after mixin @return $this should show mixin class methods (toSql), got: {:?}",
method_names
);
assert!(
method_names.contains(&"where"),
"Chaining after mixin @return $this should show mixin class methods (where), got: {:?}",
method_names
);
assert!(
method_names.contains(&"save"),
"Chaining after mixin @return $this should also show consumer class methods (save), got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_goto_definition_mixin_return_this_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_return_this_goto.php").unwrap();
let text = concat!(
"<?php\n", "class QueryBuilder {\n", " /** @return $this */\n", " public function where(string $col): static { return $this; }\n", " public function get(): array { return []; }\n", "}\n", "/**\n", " * @mixin QueryBuilder\n", " */\n", "class Model {\n", " public function test(): void {\n", " $this->where('active')->get();\n", " }\n", "}\n", );
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 11,
character: 35,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = backend.goto_definition(params).await.unwrap();
assert!(
result.is_some(),
"Should resolve chained call after mixin @return $this"
);
match result.unwrap() {
GotoDefinitionResponse::Scalar(location) => {
assert_eq!(location.uri, uri);
assert_eq!(
location.range.start.line, 4,
"get() is declared on line 4 in QueryBuilder, not in Model"
);
}
other => panic!("Expected Scalar location, got: {:?}", other),
}
}
#[tokio::test]
async fn test_completion_inherited_return_this_resolves_to_child() {
let backend = create_test_backend();
let uri = Url::parse("file:///inherit_return_this.php").unwrap();
let text = concat!(
"<?php\n", "class BaseBuilder {\n", " /** @return $this */\n", " public function where(string $col): static { return $this; }\n", "}\n", "class ModelBuilder extends BaseBuilder {\n", " public function paginate(): array { return []; }\n", " public function test(): void {\n", " $this->where('x')->\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: 27,
},
},
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 names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
names.contains(&"paginate"),
"Inherited @return $this should resolve to child class — 'paginate' expected, got: {:?}",
names
);
assert!(
names.contains(&"where"),
"Inherited where() should still be visible, got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_inherited_mixin_static_method_via_double_colon() {
let backend = create_test_backend();
let uri = Url::parse("file:///inherited_mixin_static.php").unwrap();
let text = concat!(
"<?php\n", "class Builder {\n", " /**\n", " * @return static\n", " */\n", " public static function query(): self {\n", " return new static();\n", " }\n", "}\n", "\n", "/**\n", " * @mixin Builder\n", " */\n", "abstract class Model {\n", "}\n", "\n", "class User extends Model {\n", "}\n", "\n", "$query = User::\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: 19,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"User:: should return completions including Builder's static methods"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.starts_with("query")),
"Should include query() from Builder via parent Model's @mixin, got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_inherited_mixin_instance_method_via_arrow() {
let backend = create_test_backend();
let uri = Url::parse("file:///inherited_mixin_instance.php").unwrap();
let text = concat!(
"<?php\n", "class Builder {\n", " public function where(): self {\n", " return $this;\n", " }\n", " public function get(): array {\n", " return [];\n", " }\n", "}\n", "\n", "/**\n", " * @mixin Builder\n", " */\n", "abstract class Model {\n", " public function save(): void {}\n", "}\n", "\n", "class User extends Model {\n", " public function getName(): string {\n", " return '';\n", " }\n", "}\n", "\n", "$user = new User();\n", "$user->\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: 24,
character: 7,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"$user-> should return completions including Builder's methods"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.starts_with("getName")),
"Should include getName() from User itself, got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.starts_with("save")),
"Should include save() from parent Model, got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.starts_with("where")),
"Should include where() from Builder via Model's @mixin, got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.starts_with("get")),
"Should include get() from Builder via Model's @mixin, got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_goto_definition_inherited_mixin_static_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///inherited_mixin_goto.php").unwrap();
let text = concat!(
"<?php\n", "class Builder {\n", " /**\n", " * @return static\n", " */\n", " public static function query(): self {\n", " return new static();\n", " }\n", "}\n", "\n", "/**\n", " * @mixin Builder\n", " */\n", "abstract class Model {\n", "}\n", "\n", "class User extends Model {\n", "}\n", "\n", "User::query();\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 19,
character: 7,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = backend.goto_definition(params).await.unwrap();
assert!(
result.is_some(),
"Should resolve User::query() to Builder::query() via inherited @mixin"
);
match result.unwrap() {
GotoDefinitionResponse::Scalar(location) => {
assert_eq!(location.uri, uri);
assert_eq!(
location.range.start.line, 5,
"query() is declared on line 5 in Builder"
);
}
other => panic!("Expected Scalar location, got: {:?}", other),
}
}
#[tokio::test]
async fn test_completion_mixin_return_static_resolves_to_consumer_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_return_static.php").unwrap();
let text = concat!(
"<?php\n", "class Builder {\n", " /** @return static */\n", " public static function query(): self { return new static(); }\n", " public function where(string $col, mixed $val): self {\n", " return $this;\n", " }\n", " public function get(): array { return []; }\n", "}\n", "/**\n", " * @mixin Builder\n", " */\n", "class Model {\n", " public function save(): bool { return true; }\n", "}\n", "class User extends Model {\n", " public function getEmail(): string { return ''; }\n", " public function test(): void {\n", " User::query()->\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: 18,
character: 24,
},
},
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(&"where"),
"Chaining after mixin @return static should show Builder methods (where), got: {:?}",
method_names
);
assert!(
method_names.contains(&"get"),
"Chaining after mixin @return static should show Builder methods (get), got: {:?}",
method_names
);
assert!(
method_names.contains(&"save"),
"Should show Model methods (save) after mixin static return, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"Should show User methods (getEmail) after mixin static return, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_mixin_return_self_resolves_to_consumer_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_return_self.php").unwrap();
let text = concat!(
"<?php\n", "class QueryBuilder {\n", " public function where(string $col): self { return $this; }\n", " public function toSql(): string { return ''; }\n", "}\n", "/**\n", " * @mixin QueryBuilder\n", " */\n", "class Model {\n", " public function save(): bool { return true; }\n", " public function test(): void {\n", " $this->where('active')->\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(), "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(&"where"),
"Chaining after mixin self return should show QueryBuilder methods (where), got: {:?}",
method_names
);
assert!(
method_names.contains(&"toSql"),
"Chaining after mixin self return should show QueryBuilder methods (toSql), got: {:?}",
method_names
);
assert!(
method_names.contains(&"save"),
"Should show Model methods (save) after mixin self return, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}