use crate::common::create_test_backend;
use phpantom_lsp::php_type::PhpType;
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
#[tokio::test]
async fn test_array_shape_key_completion_var_annotation() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_var.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{name: string, age: int, email: string} $config */\n",
"$config = getConfig();\n",
"$config['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 9,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for array shape keys"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"name"),
"Should suggest 'name' key, got {:?}",
labels
);
assert!(
labels.contains(&"age"),
"Should suggest 'age' key, got {:?}",
labels
);
assert!(
labels.contains(&"email"),
"Should suggest 'email' key, got {:?}",
labels
);
assert_eq!(items.len(), 3, "Should have exactly 3 key suggestions");
let name_item = items.iter().find(|i| i.label == "name").unwrap();
assert_eq!(name_item.detail.as_deref(), Some("name: string"));
assert_eq!(name_item.kind, Some(CompletionItemKind::FIELD));
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_completion_param_annotation() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_param.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" /**\n",
" * @param array{host: string, port: int, ssl: bool} $options\n",
" */\n",
" public function connect(array $options): void {\n",
" $options['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 17,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for @param array shape"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"host"), "Should suggest 'host' key");
assert!(labels.contains(&"port"), "Should suggest 'port' key");
assert!(labels.contains(&"ssl"), "Should suggest 'ssl' key");
assert_eq!(items.len(), 3);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_completion_partial_filter() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_partial.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{name: string, namespace: string, age: int} $data */\n",
"$data = getData();\n",
"$data['na\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 9,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should return filtered completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"name"), "Should suggest 'name'");
assert!(labels.contains(&"namespace"), "Should suggest 'namespace'");
assert!(
!labels.contains(&"age"),
"Should NOT suggest 'age' (doesn't start with 'na')"
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_completion_double_quote() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_dquote.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{first_name: string, last_name: string} $user */\n",
"$user = getUser();\n",
"$user[\"\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 7,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions with double quotes"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"first_name"),
"Should suggest 'first_name'"
);
assert!(labels.contains(&"last_name"), "Should suggest 'last_name'");
let item = items.iter().find(|i| i.label == "first_name").unwrap();
let edit_text = match &item.text_edit {
Some(CompletionTextEdit::Edit(te)) => &te.new_text,
_ => panic!("Expected text_edit on completion item"),
};
assert!(
edit_text.contains('"'),
"Text edit should use double quotes: {:?}",
edit_text
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_completion_optional_keys() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_optional.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{name: string, age?: int, email?: string} $profile */\n",
"$profile = getProfile();\n",
"$profile['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 10,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions including optional keys"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
assert_eq!(
items.len(),
3,
"Should have 3 keys (including optional ones)"
);
let age_item = items.iter().find(|i| i.label == "age").unwrap();
assert_eq!(
age_item.detail.as_deref(),
Some("age?: int"),
"Optional key should show '?' in detail"
);
let name_item = items.iter().find(|i| i.label == "name").unwrap();
assert_eq!(
name_item.detail.as_deref(),
Some("name: string"),
"Required key should not show '?'"
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_completion_bracket_only() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_bracket.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{id: int, title: string} $item */\n",
"$item = getItem();\n",
"$item[\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions when just [ is typed"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"id"), "Should suggest 'id'");
assert!(labels.contains(&"title"), "Should suggest 'title'");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_completion_nested_generic_value() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_nested.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{users: list<User>, count: int, meta: array<string, mixed>} $result */\n",
"$result = query();\n",
"$result['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 9,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should handle nested generic types in shape values"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"users"), "Should suggest 'users'");
assert!(labels.contains(&"count"), "Should suggest 'count'");
assert!(labels.contains(&"meta"), "Should suggest 'meta'");
assert_eq!(items.len(), 3);
let users_item = items.iter().find(|i| i.label == "users").unwrap();
assert_eq!(users_item.detail.as_deref(), Some("users: list<User>"));
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_no_completion_for_non_shape_array() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_no_shape.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var list<string> $names */\n",
"$names = getNames();\n",
"$names['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
if let Some(CompletionResponse::Array(items)) = result {
let field_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.collect();
assert!(
field_items.is_empty(),
"Should NOT suggest array shape keys for list<string>"
);
}
}
#[tokio::test]
async fn test_array_shape_value_type_member_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_chain.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public function getEmail(): string {}\n",
"}\n",
"class Handler {\n",
" /**\n",
" * @param array{user: User, count: int} $data\n",
" */\n",
" public function process(array $data): void {\n",
" $data['user']->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for array shape value type"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getEmail"),
"Should suggest User::getEmail(), got {:?}",
method_names
);
let prop_labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
prop_labels.contains(&"name"),
"Should suggest User::$name property, got {:?}",
prop_labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_value_type_inline_var() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_inline.php").unwrap();
let text = concat!(
"<?php\n",
"class Address {\n",
" public string $city;\n",
" public string $zip;\n",
" public function format(): string {}\n",
"}\n",
"/** @var array{address: Address, phone: string} $contact */\n",
"$contact = getContact();\n",
"$contact['address']->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for inline @var array shape"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"format"),
"Should suggest Address::format(), got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_value_type_scalar_no_completion() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_scalar.php").unwrap();
let text = concat!(
"<?php\n",
"class Service {\n",
" /**\n",
" * @param array{name: string, count: int} $data\n",
" */\n",
" public function handle(array $data): void {\n",
" $data['name']->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
if let Some(CompletionResponse::Array(items)) = result {
let method_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.collect();
assert!(
method_items.is_empty(),
"Should NOT suggest class methods for scalar value type"
);
}
}
#[tokio::test]
async fn test_array_shape_value_type_double_quote_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_dquote_access.php").unwrap();
let text = concat!(
"<?php\n",
"class Logger {\n",
" public function info(): void {}\n",
" public function error(): void {}\n",
"}\n",
"class App {\n",
" /**\n",
" * @param array{logger: Logger, name: string} $deps\n",
" */\n",
" public function boot(array $deps): void {\n",
" $deps[\"logger\"]->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 25,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for double-quoted key access"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"info"),
"Should suggest Logger::info(), got {:?}",
method_names
);
assert!(
method_names.contains(&"error"),
"Should suggest Logger::error(), got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_from_function_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_return.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @return array{id: int, name: string, active: bool}\n",
" */\n",
"function getUser(): array { return []; }\n",
"$user = getUser();\n",
"$user['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 7,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from function return type"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"id"),
"Should suggest 'id' key, got {:?}",
labels
);
assert!(
labels.contains(&"name"),
"Should suggest 'name' key, got {:?}",
labels
);
assert!(
labels.contains(&"active"),
"Should suggest 'active' key, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_chain_from_method_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_method_return.php").unwrap();
let text = concat!(
"<?php\n",
"class Logger {\n",
" public function info(): void {}\n",
" public function warning(): void {}\n",
"}\n",
"class Service {\n",
" /**\n",
" * @return array{logger: Logger, debug: bool}\n",
" */\n",
" public function getDeps(): array { return []; }\n",
" public function run(): void {\n",
" $deps = $this->getDeps();\n",
" $deps['logger']->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 12,
character: 26,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions via method return shape"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"info"),
"Should suggest Logger::info(), got {:?}",
method_names
);
assert!(
method_names.contains(&"warning"),
"Should suggest Logger::warning(), got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nullable_annotation() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_nullable.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var ?array{key: string, value: int} $map */\n",
"$map = getMap();\n",
"$map['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should handle nullable array shape");
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"key"), "Should suggest 'key'");
assert!(labels.contains(&"value"), "Should suggest 'value'");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_empty_shape() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_empty.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{} $empty */\n",
"$empty = getEmpty();\n",
"$empty['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
if let Some(CompletionResponse::Array(items)) = result {
let field_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.collect();
assert!(
field_items.is_empty(),
"Empty shape should not suggest any keys"
);
}
}
#[tokio::test]
async fn test_array_shape_insert_text_with_quote() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_insert.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{name: string, age: int} $data */\n",
"$data = getData();\n",
"$data['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 7,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let name_item = items.iter().find(|i| i.label == "name").unwrap();
let edit_text = match &name_item.text_edit {
Some(CompletionTextEdit::Edit(te)) => &te.new_text,
_ => panic!("Expected text_edit on completion item"),
};
assert_eq!(
edit_text, "name']",
"Text edit should close the quote and bracket"
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_insert_text_no_quote() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_insert_noquote.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{name: string, age: int} $data */\n",
"$data = getData();\n",
"$data[\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let name_item = items.iter().find(|i| i.label == "name").unwrap();
let edit_text = match &name_item.text_edit {
Some(CompletionTextEdit::Edit(te)) => &te.new_text,
_ => panic!("Expected text_edit on completion item"),
};
assert_eq!(
edit_text, "'name']",
"Text edit should include opening quote, key, closing quote, and bracket"
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[test]
fn test_parse_array_shape_basic() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries =
parse_array_shape_typed(&PhpType::parse("array{name: string, age: int}")).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("name"));
assert_eq!(entries[0].value_type.to_string(), "string");
assert!(!entries[0].optional);
assert_eq!(entries[1].key.as_deref(), Some("age"));
assert_eq!(entries[1].value_type.to_string(), "int");
assert!(!entries[1].optional);
}
#[test]
fn test_parse_array_shape_optional_keys() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse(
"array{name: string, age?: int, email?: string}",
))
.unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].key.as_deref(), Some("name"));
assert!(!entries[0].optional);
assert_eq!(entries[1].key.as_deref(), Some("age"));
assert!(entries[1].optional);
assert_eq!(entries[2].key.as_deref(), Some("email"));
assert!(entries[2].optional);
}
#[test]
fn test_parse_array_shape_positional() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse("array{string, int, bool}")).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].key.as_deref(), Some("0"));
assert_eq!(entries[0].value_type.to_string(), "string");
assert_eq!(entries[1].key.as_deref(), Some("1"));
assert_eq!(entries[1].value_type.to_string(), "int");
assert_eq!(entries[2].key.as_deref(), Some("2"));
assert_eq!(entries[2].value_type.to_string(), "bool");
}
#[test]
fn test_parse_array_shape_numeric_keys() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse("array{0: User, 1: Address}")).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("0"));
assert_eq!(entries[0].value_type.to_string(), "User");
assert_eq!(entries[1].key.as_deref(), Some("1"));
assert_eq!(entries[1].value_type.to_string(), "Address");
}
#[test]
fn test_parse_array_shape_nested_generics() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse(
"array{users: list<User>, meta: array<string, mixed>}",
))
.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("users"));
assert_eq!(entries[0].value_type.to_string(), "list<User>");
assert_eq!(entries[1].key.as_deref(), Some("meta"));
assert_eq!(entries[1].value_type.to_string(), "array<string, mixed>");
}
#[test]
fn test_parse_array_shape_empty() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse("array{}")).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_parse_array_shape_not_a_shape() {
use phpantom_lsp::docblock::parse_array_shape_typed;
assert!(parse_array_shape_typed(&PhpType::parse("array<int, User>")).is_none());
assert!(parse_array_shape_typed(&PhpType::parse("list<User>")).is_none());
assert!(parse_array_shape_typed(&PhpType::parse("string")).is_none());
assert!(parse_array_shape_typed(&PhpType::parse("User")).is_none());
}
#[test]
fn test_parse_array_shape_nullable() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse("?array{name: string}")).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].key.as_deref(), Some("name"));
}
#[test]
fn test_extract_array_shape_value_type() {
use phpantom_lsp::docblock::extract_array_shape_value_type_typed;
assert_eq!(
extract_array_shape_value_type_typed(
&PhpType::parse("array{name: string, user: User}"),
"user"
)
.map(|t| t.to_string()),
Some("User".to_string())
);
assert_eq!(
extract_array_shape_value_type_typed(
&PhpType::parse("array{name: string, user: User}"),
"name"
)
.map(|t| t.to_string()),
Some("string".to_string())
);
assert_eq!(
extract_array_shape_value_type_typed(
&PhpType::parse("array{name: string, user: User}"),
"missing"
)
.map(|t| t.to_string()),
None
);
assert_eq!(
extract_array_shape_value_type_typed(&PhpType::parse("list<User>"), "anything")
.map(|t| t.to_string()),
None
);
}
#[test]
fn test_parse_array_shape_nested_shapes() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse(
"array{user: array{name: string, age: int}, active: bool}",
))
.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("user"));
assert_eq!(
entries[0].value_type.to_string(),
"array{name: string, age: int}"
);
assert_eq!(entries[1].key.as_deref(), Some("active"));
assert_eq!(entries[1].value_type.to_string(), "bool");
}
#[tokio::test]
async fn test_array_shape_key_completion_inside_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_method_ctx.php").unwrap();
let text = concat!(
"<?php\n",
"class UserService {\n",
" /**\n",
" * @param array{username: string, password: string, remember?: bool} $credentials\n",
" */\n",
" public function login(array $credentials): void {\n",
" $credentials['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 21,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should return completions inside method");
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"username"), "Should suggest 'username'");
assert!(labels.contains(&"password"), "Should suggest 'password'");
assert!(
labels.contains(&"remember"),
"Should suggest 'remember' (optional)"
);
assert_eq!(items.len(), 3);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_sort_order_preserved() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_sort.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{zebra: string, apple: string, mango: string} $fruits */\n",
"$fruits = getFruits();\n",
"$fruits['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 9,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let sort_texts: Vec<&str> = items
.iter()
.map(|i| i.sort_text.as_deref().unwrap())
.collect();
assert_eq!(sort_texts, vec!["0000", "0001", "0002"]);
assert_eq!(items[0].label, "zebra");
assert_eq!(items[1].label, "apple");
assert_eq!(items[2].label, "mango");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_text_edit_range_bracket_autoclose() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_autoclose_bracket.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{host: string, port: int} $config */\n",
"$config = getConfig();\n",
"$config[]\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let host_item = items.iter().find(|i| i.label == "host").unwrap();
let te = match &host_item.text_edit {
Some(CompletionTextEdit::Edit(te)) => te,
_ => panic!("Expected text_edit"),
};
assert_eq!(
te.range.start.character, 8,
"Range start should be at key_start_col"
);
assert_eq!(
te.range.end.character, 9,
"Range end should cover the trailing ]"
);
assert_eq!(
te.new_text, "'host']",
"new_text should include quote + key + quote + ]"
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_text_edit_range_quote_autoclose() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_autoclose_quote.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{host: string, port: int} $config */\n",
"$config = getConfig();\n",
"$config['']\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 9,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let host_item = items.iter().find(|i| i.label == "host").unwrap();
let te = match &host_item.text_edit {
Some(CompletionTextEdit::Edit(te)) => te,
_ => panic!("Expected text_edit"),
};
assert_eq!(
te.range.start.character, 9,
"Range start should be at key_start_col"
);
assert_eq!(
te.range.end.character, 11,
"Range end should cover trailing ']"
);
assert_eq!(te.new_text, "host']", "new_text should be key + quote + ]");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_from_method_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_method_key.php").unwrap();
let text = concat!(
"<?php\n",
"class UserService {\n",
" /** @return array{name: string, email: string, age: int} */\n",
" public function getUserData(): array { return []; }\n",
" public function test(): void {\n",
" $data = $this->getUserData();\n",
" $data['\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 completion_params = 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,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from method return type"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"name"),
"Should suggest 'name' key, got {:?}",
labels
);
assert!(
labels.contains(&"email"),
"Should suggest 'email' key, got {:?}",
labels
);
assert!(
labels.contains(&"age"),
"Should suggest 'age' key, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_key_completion() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_nested_keys.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{meta: array{page: int, total: int}, items: list<string>} $response */\n",
"$response = getResponse();\n",
"$response['meta']['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 19,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for nested array shape"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"page"),
"Should suggest 'page' from nested shape, got {:?}",
labels
);
assert!(
labels.contains(&"total"),
"Should suggest 'total' from nested shape, got {:?}",
labels
);
assert_eq!(labels.len(), 2, "Should only suggest nested keys");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_key_completion_literal_array() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_literal.php").unwrap();
let text = concat!(
"<?php\n",
"$config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
"$config['db']['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 2,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for nested literal array keys"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host' from nested literal, got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port' from nested literal, got {:?}",
labels
);
assert_eq!(labels.len(), 2, "Should only suggest nested keys");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_key_completion_three_levels() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_three.php").unwrap();
let text = concat!(
"<?php\n",
"$app = ['db' => ['primary' => ['host' => 'localhost', 'port' => 5432]]];\n",
"$app['db']['primary']['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 2,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for three-level nested array"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host', got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port', got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_first_level_keys() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_first_level.php").unwrap();
let text = concat!(
"<?php\n",
"$config = ['db' => ['host' => 'x'], 'cache' => ['driver' => 'redis']];\n",
"$config['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 2,
character: 9,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should return first-level keys");
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"db"),
"Should suggest 'db', got {:?}",
labels
);
assert!(
labels.contains(&"cache"),
"Should suggest 'cache', got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_inside_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_method.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" public function load() {\n",
" $settings = ['mail' => ['from' => 'noreply@example.com', 'driver' => 'smtp']];\n",
" $settings['mail']['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 27,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for nested literal inside method"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"from"),
"Should suggest 'from', got {:?}",
labels
);
assert!(
labels.contains(&"driver"),
"Should suggest 'driver', got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_multiple_config_assignments() {
let backend = create_test_backend();
let uri = Url::parse("file:///multi_config.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class FirstClass {\n",
" public function demo(): void {\n",
" $config = ['host' => 'localhost', 'port' => 3306];\n",
" $config[''];\n",
" }\n",
"}\n",
"\n",
"class SecondClass {\n",
" /** @param array{host: string, port: int} $config */\n",
" public function fromParam(array $config): void {\n",
" $config['host'];\n",
" }\n",
"\n",
" public function demo(): void {\n",
" $config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
" $config['db']['']\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 cursor_line = text
.lines()
.position(|l| l.contains("$config['db']['']"))
.expect("must find the nested access line");
let line_text = text.lines().nth(cursor_line).unwrap();
let col = line_text.find("['']").expect("must find ['']") + 2;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: cursor_line as u32,
character: col as u32,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return nested key completions (line={}, col={})",
cursor_line,
col
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host' from nested literal, got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port' from nested literal, got {:?}",
labels
);
assert!(
!labels.contains(&"db"),
"Should NOT leak first-level key 'db', got {:?}",
labels
);
assert!(
!labels.contains(&"debug"),
"Should NOT leak first-level key 'debug', got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_example_php_scenario() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_example_php.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo;\n",
"class ShapeDemo {\n",
" public function nestedLiteral(): void {\n",
" $config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
" $config['db']['']\n",
" $config['debug'];\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 5,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return nested key completions for $config['db'][''] in example.php scenario"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host' from nested literal, got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port' from nested literal, got {:?}",
labels
);
assert!(
!labels.contains(&"db"),
"Should NOT leak first-level key 'db' into nested results, got {:?}",
labels
);
assert!(
!labels.contains(&"debug"),
"Should NOT leak first-level key 'debug' into nested results, got {:?}",
labels
);
assert_eq!(
labels.len(),
2,
"Should have exactly 2 nested keys, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_autoclosed_quotes() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_autoclosed.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" public function demo(): void {\n",
" $config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
" $config['db']['']\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for nested literal with autoclosed quotes"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host' from nested literal, got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port' from nested literal, got {:?}",
labels
);
assert_eq!(
labels.len(),
2,
"Should only suggest nested keys, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_autoclosed_with_semicolon() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_autoclosed_semi.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" public function demo(): void {\n",
" $config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
" $config['db'][''];\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for nested literal with autoclosed quotes + semicolon"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host', got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port', got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_bracket_then_quote() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_bracket_quote.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" public function demo(): void {\n",
" $config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
" $config['db'][']\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for nested literal with bracket+quote pattern"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host', got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port', got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_bare_bracket() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_bare_bracket.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" public function demo(): void {\n",
" $config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
" $config['db'][\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for nested literal with bare bracket"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host', got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port', got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_autoclosed_bracket() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_autoclosed_bracket.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" public function demo(): void {\n",
" $config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
" $config['db'][]\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for nested literal with autoclosed bracket"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host', got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port', got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_first_level_no_leak() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_no_leak.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" public function demo(): void {\n",
" $config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
" $config['']\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 17,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should return first-level completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"db"),
"Should suggest 'db', got {:?}",
labels
);
assert!(
labels.contains(&"debug"),
"Should suggest 'debug', got {:?}",
labels
);
assert!(
!labels.contains(&"host"),
"Should NOT suggest nested key 'host' at first level, got {:?}",
labels
);
assert_eq!(
labels.len(),
2,
"Should only have two first-level keys, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_nested_literal_open_quote() {
let backend = create_test_backend();
let uri = Url::parse("file:///nested_open.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" public function demo(): void {\n",
" $config = ['db' => ['host' => 'localhost', 'port' => 3306], 'debug' => true];\n",
" $config['db']['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for nested literal with open quote"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host', got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port', got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_server_superglobal_key_completion() {
let backend = create_test_backend();
let uri = Url::parse("file:///server_keys.php").unwrap();
let text = concat!("<?php\n", "$_SERVER['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 1,
character: 10,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should return $_SERVER key completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"REQUEST_METHOD"),
"Should suggest REQUEST_METHOD, got {:?}",
labels
);
assert!(labels.contains(&"HTTP_HOST"), "Should suggest HTTP_HOST");
assert!(
labels.contains(&"SERVER_NAME"),
"Should suggest SERVER_NAME"
);
assert!(
labels.contains(&"REMOTE_ADDR"),
"Should suggest REMOTE_ADDR"
);
assert!(
labels.contains(&"REQUEST_URI"),
"Should suggest REQUEST_URI"
);
assert_eq!(labels.len(), 40, "Should suggest all known $_SERVER keys");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_server_superglobal_key_partial_filter() {
let backend = create_test_backend();
let uri = Url::parse("file:///server_keys_filter.php").unwrap();
let text = concat!("<?php\n", "$_SERVER['REQ\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 1,
character: 13,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Should return filtered $_SERVER keys");
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(labels.contains(&"REQUEST_METHOD"));
assert!(labels.contains(&"REQUEST_TIME"));
assert!(labels.contains(&"REQUEST_TIME_FLOAT"));
assert!(labels.contains(&"REQUEST_URI"));
assert!(
!labels.contains(&"SERVER_NAME"),
"Should not suggest SERVER_NAME when filtering by REQ"
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_server_superglobal_bracket_only() {
let backend = create_test_backend();
let uri = Url::parse("file:///server_bracket.php").unwrap();
let text = concat!("<?php\n", "$_SERVER[\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 1,
character: 9,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return $_SERVER key completions with just ["
);
match result.unwrap() {
CompletionResponse::Array(items) => {
assert!(!items.is_empty(), "Should have $_SERVER key suggestions");
assert!(
items
.iter()
.all(|i| i.kind == Some(CompletionItemKind::FIELD)),
"All items should be FIELD kind"
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_server_superglobal_text_edit_autoclose() {
let backend = create_test_backend();
let uri = Url::parse("file:///server_autoclose.php").unwrap();
let text = concat!("<?php\n", "$_SERVER['']\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 1,
character: 10,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let item = items.iter().find(|i| i.label == "HTTP_HOST").unwrap();
let te = match &item.text_edit {
Some(CompletionTextEdit::Edit(te)) => te,
_ => panic!("Expected text_edit"),
};
assert_eq!(te.range.start.character, 10);
assert_eq!(te.range.end.character, 12);
assert_eq!(te.new_text, "HTTP_HOST']");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_from_method_return_type_bracket_autoclose() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_method_key_autoclose.php").unwrap();
let text = concat!(
"<?php\n",
"class UserService {\n",
" /** @return array{name: string, email: string, age: int} */\n",
" public function getUserData(): array { return []; }\n",
" public function test(): void {\n",
" $data = $this->getUserData();\n",
" $data[]\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from method return type with auto-closed bracket"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"name"),
"Should suggest 'name' key, got {:?}",
labels
);
assert!(
labels.contains(&"email"),
"Should suggest 'email' key, got {:?}",
labels
);
assert!(
labels.contains(&"age"),
"Should suggest 'age' key, got {:?}",
labels
);
let name_item = items.iter().find(|i| i.label == "name").unwrap();
let te = match &name_item.text_edit {
Some(CompletionTextEdit::Edit(te)) => te,
_ => panic!("Expected text_edit"),
};
assert_eq!(te.range.start.character, 14, "Start at key_start_col");
assert_eq!(te.range.end.character, 15, "End past the auto-inserted ]");
assert_eq!(te.new_text, "'name']");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_list_element_member_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_list_elem.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public function getEmail(): string {}\n",
"}\n",
"/** @var array{meta: array{page: int, total: int}, items: list<User>} $response */\n",
"$response = getResponse();\n",
"$response['items'][0]->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return User member completions from list element access"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items
.iter()
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
names.contains(&"name"),
"Should suggest User::$name, got {:?}",
names
);
assert!(
names.contains(&"getEmail"),
"Should suggest User::getEmail(), got {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[test]
fn test_parse_array_shape_single_quoted_keys() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries =
parse_array_shape_typed(&PhpType::parse("array{'foo': int, 'bar': string}")).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("foo"));
assert_eq!(entries[0].value_type.to_string(), "int");
assert!(!entries[0].optional);
assert_eq!(entries[1].key.as_deref(), Some("bar"));
assert_eq!(entries[1].value_type.to_string(), "string");
assert!(!entries[1].optional);
}
#[test]
fn test_parse_array_shape_double_quoted_keys() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries =
parse_array_shape_typed(&PhpType::parse(r#"array{"foo": int, "bar": string}"#)).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("foo"));
assert_eq!(entries[0].value_type.to_string(), "int");
assert_eq!(entries[1].key.as_deref(), Some("bar"));
assert_eq!(entries[1].value_type.to_string(), "string");
}
#[test]
fn test_parse_array_shape_mixed_quoted_and_unquoted_keys() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse(
r#"array{'foo': int, "bar"?: string, baz: bool}"#,
))
.unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].key.as_deref(), Some("foo"));
assert_eq!(entries[0].value_type.to_string(), "int");
assert!(!entries[0].optional);
assert_eq!(entries[1].key.as_deref(), Some("bar"));
assert_eq!(entries[1].value_type.to_string(), "string");
assert!(entries[1].optional);
assert_eq!(entries[2].key.as_deref(), Some("baz"));
assert_eq!(entries[2].value_type.to_string(), "bool");
assert!(!entries[2].optional);
}
#[test]
fn test_parse_array_shape_quoted_key_with_spaces() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries =
parse_array_shape_typed(&PhpType::parse("array{'po rt': int, 'my key': string}")).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("po rt"));
assert_eq!(entries[0].value_type.to_string(), "int");
assert_eq!(entries[1].key.as_deref(), Some("my key"));
assert_eq!(entries[1].value_type.to_string(), "string");
}
#[test]
fn test_parse_array_shape_quoted_key_with_special_chars() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse(
r#"array{",host?:}"?: string, 'po rt': int, credentials: User|AdminUser}"#,
))
.unwrap();
assert_eq!(entries.len(), 3, "entries: {:?}", entries);
assert_eq!(entries[0].key.as_deref(), Some(",host?:}"));
assert_eq!(entries[0].value_type.to_string(), "string");
assert!(entries[0].optional);
assert_eq!(entries[1].key.as_deref(), Some("po rt"));
assert_eq!(entries[1].value_type.to_string(), "int");
assert!(!entries[1].optional);
assert_eq!(entries[2].key.as_deref(), Some("credentials"));
assert_eq!(entries[2].value_type.to_string(), "User|AdminUser");
assert!(!entries[2].optional);
}
#[test]
fn test_parse_array_shape_optional_quoted_key() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries =
parse_array_shape_typed(&PhpType::parse(r#"array{"bar"?: string, 'baz'?: int}"#)).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("bar"));
assert!(entries[0].optional);
assert_eq!(entries[0].value_type.to_string(), "string");
assert_eq!(entries[1].key.as_deref(), Some("baz"));
assert!(entries[1].optional);
assert_eq!(entries[1].value_type.to_string(), "int");
}
#[test]
fn test_parse_array_shape_quoted_key_with_colon() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries = parse_array_shape_typed(&PhpType::parse(
r#"array{"host:port": string, name: string}"#,
))
.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("host:port"));
assert_eq!(entries[0].value_type.to_string(), "string");
assert_eq!(entries[1].key.as_deref(), Some("name"));
assert_eq!(entries[1].value_type.to_string(), "string");
}
#[test]
fn test_parse_array_shape_quoted_key_with_comma() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries =
parse_array_shape_typed(&PhpType::parse(r#"array{"first,last": string, age: int}"#))
.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("first,last"));
assert_eq!(entries[0].value_type.to_string(), "string");
assert_eq!(entries[1].key.as_deref(), Some("age"));
assert_eq!(entries[1].value_type.to_string(), "int");
}
#[test]
fn test_parse_array_shape_quoted_key_with_braces() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries =
parse_array_shape_typed(&PhpType::parse(r#"array{"{key}": string, normal: int}"#)).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("{key}"));
assert_eq!(entries[0].value_type.to_string(), "string");
assert_eq!(entries[1].key.as_deref(), Some("normal"));
assert_eq!(entries[1].value_type.to_string(), "int");
}
#[test]
fn test_extract_array_shape_value_type_quoted_key() {
use phpantom_lsp::docblock::extract_array_shape_value_type_typed;
let t = PhpType::parse(r#"array{"host": string, 'port': int, ssl: bool}"#);
assert_eq!(
extract_array_shape_value_type_typed(&t, "host").map(|t| t.to_string()),
Some("string".to_string())
);
assert_eq!(
extract_array_shape_value_type_typed(&t, "port").map(|t| t.to_string()),
Some("int".to_string())
);
assert_eq!(
extract_array_shape_value_type_typed(&t, "ssl").map(|t| t.to_string()),
Some("bool".to_string())
);
}
#[test]
fn test_parse_array_shape_phpstan_spec_examples() {
use phpantom_lsp::docblock::parse_array_shape_typed;
let entries =
parse_array_shape_typed(&PhpType::parse(r#"array{'foo': int, "bar": string}"#)).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("foo"));
assert_eq!(entries[0].value_type.to_string(), "int");
assert_eq!(entries[1].key.as_deref(), Some("bar"));
assert_eq!(entries[1].value_type.to_string(), "string");
let entries =
parse_array_shape_typed(&PhpType::parse(r#"array{'foo': int, "bar"?: string}"#)).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("foo"));
assert!(!entries[0].optional);
assert_eq!(entries[1].key.as_deref(), Some("bar"));
assert!(entries[1].optional);
let entries = parse_array_shape_typed(&PhpType::parse("array{int, int}")).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("0"));
assert_eq!(entries[0].value_type.to_string(), "int");
assert_eq!(entries[1].key.as_deref(), Some("1"));
assert_eq!(entries[1].value_type.to_string(), "int");
let entries = parse_array_shape_typed(&PhpType::parse("array{0: int, 1?: int}")).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("0"));
assert!(!entries[0].optional);
assert_eq!(entries[1].key.as_deref(), Some("1"));
assert!(entries[1].optional);
let entries = parse_array_shape_typed(&PhpType::parse("array{foo: int, bar: string}")).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key.as_deref(), Some("foo"));
assert_eq!(entries[1].key.as_deref(), Some("bar"));
}
#[tokio::test]
async fn test_array_shape_key_completion_quoted_keys_in_annotation() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_quoted_keys.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{'first name': string, \"last-name\": string, age: int} $person */\n",
"$person = getData();\n",
"$person['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 9,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for quoted-key array shape"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"first name"),
"Should suggest 'first name' key, got {:?}",
labels
);
assert!(
labels.contains(&"last-name"),
"Should suggest 'last-name' key, got {:?}",
labels
);
assert!(
labels.contains(&"age"),
"Should suggest 'age' key, got {:?}",
labels
);
assert_eq!(items.len(), 3, "Should have exactly 3 key suggestions");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_completion_method_return_bracket_only_no_semicolon() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_method_nosemi.php").unwrap();
let text = concat!(
"<?php\n",
"class ArrayShapeDemo {\n",
" /**\n",
" * @return array{user: string, profile: string, active: bool}\n",
" */\n",
" public function getUserData(): array {\n",
" return [];\n",
" }\n",
" public function test(): void {\n",
" $data = $this->getUserData();\n",
" $data[]\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from method return type (bracket-only, no semicolon)"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"user"),
"Should suggest 'user' key, got {:?}",
labels
);
assert!(
labels.contains(&"profile"),
"Should suggest 'profile' key, got {:?}",
labels
);
assert!(
labels.contains(&"active"),
"Should suggest 'active' key, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
fn method_return_array_key_php(cursor_line_content: &str) -> String {
format!(
"<?php\n\
class ArrayShapeDemo {{\n\
\x20\x20\x20\x20/**\n\
\x20\x20\x20\x20 * @return array{{user: string, profile: string, active: bool}}\n\
\x20\x20\x20\x20 */\n\
\x20\x20\x20\x20public function getUserData(): array {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20return [];\n\
\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20public function test(): void {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20$data = $this->getUserData();\n\
\x20\x20\x20\x20\x20\x20\x20\x20{}\n\
\x20\x20\x20\x20}}\n\
}}\n",
cursor_line_content
)
}
async fn run_method_return_key_completion(cursor_line: &str, cursor_col: u32) -> Vec<String> {
let backend = create_test_backend();
let uri = Url::parse("file:///method_return_key_test.php").unwrap();
let text = method_return_array_key_php(cursor_line);
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text,
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: cursor_col,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
match result {
Some(CompletionResponse::Array(items)) => items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.clone())
.collect(),
_ => vec![],
}
}
fn assert_has_shape_keys(labels: &[String], scenario: &str) {
assert!(
labels.contains(&"user".to_string()),
"{}: should suggest 'user', got {:?}",
scenario,
labels
);
assert!(
labels.contains(&"profile".to_string()),
"{}: should suggest 'profile', got {:?}",
scenario,
labels
);
assert!(
labels.contains(&"active".to_string()),
"{}: should suggest 'active', got {:?}",
scenario,
labels
);
}
#[tokio::test]
async fn test_method_return_key_bracket_open() {
let labels = run_method_return_key_completion("$data[", 14).await;
assert_has_shape_keys(&labels, "$data[");
}
#[tokio::test]
async fn test_method_return_key_single_quote_open() {
let labels = run_method_return_key_completion("$data['", 15).await;
assert_has_shape_keys(&labels, "$data['");
}
#[tokio::test]
async fn test_method_return_key_double_quote_open() {
let labels = run_method_return_key_completion("$data[\"", 15).await;
assert_has_shape_keys(&labels, "$data[\"");
}
#[tokio::test]
async fn test_method_return_key_bracket_autoclosed() {
let labels = run_method_return_key_completion("$data[]", 14).await;
assert_has_shape_keys(&labels, "$data[]");
}
#[tokio::test]
async fn test_method_return_key_single_quote_autoclosed() {
let labels = run_method_return_key_completion("$data['']", 15).await;
assert_has_shape_keys(&labels, "$data['']");
}
#[tokio::test]
async fn test_method_return_key_double_quote_autoclosed() {
let labels = run_method_return_key_completion("$data[\"\"]", 15).await;
assert_has_shape_keys(&labels, "$data[\"\"]");
}
#[tokio::test]
async fn test_method_return_key_single_quote_bracket_autoclosed() {
let labels = run_method_return_key_completion("$data[']", 15).await;
assert_has_shape_keys(&labels, "$data[']");
}
#[tokio::test]
async fn test_method_return_key_double_quote_bracket_autoclosed() {
let labels = run_method_return_key_completion("$data[\"]", 15).await;
assert_has_shape_keys(&labels, "$data[\"]");
}
#[tokio::test]
async fn test_method_return_key_single_quote_pair_no_bracket() {
let labels = run_method_return_key_completion("$data[''", 15).await;
assert_has_shape_keys(&labels, "$data[''");
}
#[tokio::test]
async fn test_method_return_key_double_quote_pair_no_bracket() {
let labels = run_method_return_key_completion("$data[\"\"", 15).await;
assert_has_shape_keys(&labels, "$data[\"\"");
}
#[tokio::test]
async fn test_method_return_key_with_preceding_interface() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_preceding_iface.php").unwrap();
let text = concat!(
"<?php\n",
"interface Renderable {}\n",
"\n",
"class ArrayShapeDemo {\n",
" /**\n",
" * @return array{user: string, profile: string, active: bool}\n",
" */\n",
" public function getUserData(): array {\n",
" return [];\n",
" }\n",
" public function methodReturnShapeKeys(): void {\n",
" $data = $this->getUserData();\n",
" $data[]\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 12,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions even with a preceding interface"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"user"),
"Should suggest 'user' key, got {:?}",
labels
);
assert!(
labels.contains(&"profile"),
"Should suggest 'profile' key, got {:?}",
labels
);
assert!(
labels.contains(&"active"),
"Should suggest 'active' key, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_return_key_with_preceding_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_preceding_class.php").unwrap();
let text = concat!(
"<?php\n",
"class SomeHelper {\n",
" public function help(): void {}\n",
"}\n",
"\n",
"class ArrayShapeDemo {\n",
" /**\n",
" * @return array{user: string, profile: string, active: bool}\n",
" */\n",
" public function getUserData(): array {\n",
" return [];\n",
" }\n",
" public function test(): void {\n",
" $data = $this->getUserData();\n",
" $data['\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 completion_params = 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,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions even with a preceding class"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"user"),
"Should suggest 'user' key, got {:?}",
labels
);
assert!(
labels.contains(&"profile"),
"Should suggest 'profile' key, got {:?}",
labels
);
assert!(
labels.contains(&"active"),
"Should suggest 'active' key, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_inferred_from_literal_array() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_literal.php").unwrap();
let text = concat!(
"<?php\n",
"$var = ['key1' => 1, 'key2' => 'hello'];\n",
"$var['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 2,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from literal array"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"key1"),
"Should suggest 'key1', got {:?}",
labels
);
assert!(
labels.contains(&"key2"),
"Should suggest 'key2', got {:?}",
labels
);
assert_eq!(labels.len(), 2, "Should have exactly 2 key suggestions");
let k1 = items.iter().find(|i| i.label == "key1").unwrap();
assert_eq!(k1.detail.as_deref(), Some("key1: int"));
let k2 = items.iter().find(|i| i.label == "key2").unwrap();
assert_eq!(k2.detail.as_deref(), Some("key2: string"));
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_inferred_from_literal_with_various_types() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_literal_types.php").unwrap();
let text = concat!(
"<?php\n",
"class User {}\n",
"$var = [\n",
" 'name' => 'Alice',\n",
" 'age' => 42,\n",
" 'score' => 3.14,\n",
" 'active' => true,\n",
" 'deleted' => null,\n",
" 'user' => new User(),\n",
" 'tags' => ['a', 'b'],\n",
"];\n",
"$var['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from multi-type literal array"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert_eq!(labels.len(), 7, "Should have 7 keys, got {:?}", labels);
let find = |name: &str| -> String {
items
.iter()
.find(|i| i.label == name)
.and_then(|i| i.detail.clone())
.unwrap_or_default()
};
assert_eq!(find("name"), "name: string");
assert_eq!(find("age"), "age: int");
assert_eq!(find("score"), "score: float");
assert_eq!(find("active"), "active: bool");
assert_eq!(find("deleted"), "deleted: null");
assert_eq!(find("user"), "user: User");
assert_eq!(find("tags"), "tags: list<string>");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_incremental_key_assignments() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_incremental.php").unwrap();
let text = concat!(
"<?php\n",
"$var = ['key1' => 1];\n",
"$var['key2'] = 'hello';\n",
"$var['key3'] = true;\n",
"$var['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from incremental assignments"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"key1"),
"Should suggest 'key1' from initial literal, got {:?}",
labels
);
assert!(
labels.contains(&"key2"),
"Should suggest 'key2' from incremental assignment, got {:?}",
labels
);
assert!(
labels.contains(&"key3"),
"Should suggest 'key3' from incremental assignment, got {:?}",
labels
);
assert_eq!(labels.len(), 3, "Should have exactly 3 keys");
let find = |name: &str| -> String {
items
.iter()
.find(|i| i.label == name)
.and_then(|i| i.detail.clone())
.unwrap_or_default()
};
assert_eq!(find("key1"), "key1: int");
assert_eq!(find("key2"), "key2: string");
assert_eq!(find("key3"), "key3: bool");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_empty_array_with_incremental_assignments() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_empty_incr.php").unwrap();
let text = concat!(
"<?php\n",
"$bar = [];\n",
"$bar['name'] = 'Alice';\n",
"$bar['age'] = 30;\n",
"$bar['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from empty array + incremental assignments"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"name"),
"Should suggest 'name', got {:?}",
labels
);
assert!(
labels.contains(&"age"),
"Should suggest 'age', got {:?}",
labels
);
assert_eq!(labels.len(), 2, "Should have exactly 2 keys");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_incremental_override_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_override.php").unwrap();
let text = concat!(
"<?php\n",
"$var = ['status' => 'pending'];\n",
"$var['status'] = 42;\n",
"$var['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert_eq!(labels.len(), 1);
assert_eq!(labels[0], "status");
let detail = items[0].detail.as_deref().unwrap();
assert_eq!(detail, "status: int");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_literal_array_syntax() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_old_syntax.php").unwrap();
let text = concat!(
"<?php\n",
"$var = array('host' => 'localhost', 'port' => 3306);\n",
"$var['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 2,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from array() syntax"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(labels.contains(&"host"), "got {:?}", labels);
assert!(labels.contains(&"port"), "got {:?}", labels);
assert_eq!(labels.len(), 2);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_literal_inside_class_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_literal_method.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" public function build(): void {\n",
" $opts = ['driver' => 'mysql', 'port' => 3306];\n",
" $opts['charset'] = 'utf8';\n",
" $opts['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from literal + incremental inside a method"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(labels.contains(&"driver"), "got {:?}", labels);
assert!(labels.contains(&"port"), "got {:?}", labels);
assert!(labels.contains(&"charset"), "got {:?}", labels);
assert_eq!(labels.len(), 3);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_literal_double_quoted_keys() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_literal_dq.php").unwrap();
let text = concat!(
"<?php\n",
"$cfg = [\"host\" => 'localhost', \"port\" => 8080];\n",
"$cfg['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 2,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(labels.contains(&"host"), "got {:?}", labels);
assert!(labels.contains(&"port"), "got {:?}", labels);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_annotation_takes_priority_over_literal() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_annotation_priority.php").unwrap();
let text = concat!(
"<?php\n",
"/** @var array{x: int, y: int} $point */\n",
"$point = ['x' => 1, 'y' => 2, 'z' => 3];\n",
"$point['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert_eq!(
labels.len(),
2,
"Annotation should take priority, got {:?}",
labels
);
assert!(labels.contains(&"x"));
assert!(labels.contains(&"y"));
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_incremental_with_new_object() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_incr_object.php").unwrap();
let text = concat!(
"<?php\n",
"class User {}\n",
"class Address {}\n",
"$data = [];\n",
"$data['user'] = new User();\n",
"$data['address'] = new Address();\n",
"$data['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 7,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from incremental new-object assignments"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(labels.contains(&"user"), "got {:?}", labels);
assert!(labels.contains(&"address"), "got {:?}", labels);
assert_eq!(labels.len(), 2);
let find = |name: &str| -> String {
items
.iter()
.find(|i| i.label == name)
.and_then(|i| i.detail.clone())
.unwrap_or_default()
};
assert_eq!(find("user"), "user: User");
assert_eq!(find("address"), "address: Address");
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_completion_does_not_leak_from_class_scope() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_scope.php").unwrap();
let text = concat!(
"<?php\n",
"class ArrayShapeDemo {\n",
" /**\n",
" * @param array{host: string, port: int, credentials: string} $config\n",
" */\n",
" public function connect(array $config): void {\n",
" $config['host'];\n",
" }\n",
"}\n",
"\n",
"$config = ['host' => 'localhost', 'port' => 3306, 'ssl' => true, 'author' => 'me'];\n",
"$config['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 9,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for file-scope $config"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"ssl"),
"Should suggest 'ssl' from literal array, got {:?}",
labels
);
assert!(
labels.contains(&"author"),
"Should suggest 'author' from literal array, got {:?}",
labels
);
assert!(
labels.contains(&"host"),
"Should suggest 'host', got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port', got {:?}",
labels
);
assert!(
!labels.contains(&"credentials"),
"Must NOT suggest 'credentials' from inner-scope @param, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_key_completion_inside_class_still_works() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_shape_scope_inner.php").unwrap();
let text = concat!(
"<?php\n",
"class Demo {\n",
" /**\n",
" * @param array{host: string, port: int, ssl: bool} $options\n",
" */\n",
" public function connect(array $options): void {\n",
" $options['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 18,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for @param inside method"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"host"),
"Should suggest 'host', got {:?}",
labels
);
assert!(
labels.contains(&"port"),
"Should suggest 'port', got {:?}",
labels
);
assert!(
labels.contains(&"ssl"),
"Should suggest 'ssl', got {:?}",
labels
);
assert_eq!(
labels.len(),
3,
"Should have exactly 3 keys, got {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_literal_value_type_member_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_literal_member.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public function getEmail(): string {}\n",
"}\n",
"\n",
"$result = ['status' => 'ok'];\n",
"$result['user'] = new User();\n",
"$result['user']->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 18,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for $result['user']->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getEmail"),
"Should suggest User::getEmail(), got {:?}",
method_names
);
let prop_labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
prop_labels.contains(&"name"),
"Should suggest User::$name property, got {:?}",
prop_labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_inline_literal_value_type_member_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_inline_member.php").unwrap();
let text = concat!(
"<?php\n",
"class Product {\n",
" public string $title;\n",
" public float $price;\n",
" public function getDescription(): string {}\n",
"}\n",
"\n",
"$data = ['item' => new Product(), 'qty' => 5];\n",
"$data['item']->\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 completion_params = 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,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for $data['item']->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getDescription"),
"Should suggest Product::getDescription(), got {:?}",
method_names
);
let prop_labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
prop_labels.contains(&"title"),
"Should suggest Product::$title, got {:?}",
prop_labels
);
assert!(
prop_labels.contains(&"price"),
"Should suggest Product::$price, got {:?}",
prop_labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_array_shape_literal_scalar_value_no_member_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_literal_scalar.php").unwrap();
let text = concat!(
"<?php\n",
"$data = ['count' => 42, 'name' => 'hello'];\n",
"$data['count']->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 2,
character: 17,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
if let Some(CompletionResponse::Array(items)) = result {
let class_members: Vec<&str> = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::METHOD)
|| i.kind == Some(CompletionItemKind::PROPERTY)
})
.map(|i| i.label.as_str())
.collect();
assert!(
class_members.is_empty(),
"Scalar types should not produce member completions, got {:?}",
class_members
);
}
}
#[tokio::test]
async fn test_array_shape_literal_value_type_inside_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///array_literal_class.php").unwrap();
let text = concat!(
"<?php\n",
"class Logger {\n",
" public function info(): void {}\n",
" public function error(): void {}\n",
"}\n",
"class App {\n",
" public function run(): void {\n",
" $services = ['logger' => new Logger()];\n",
" $services['logger']->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 29,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for $services['logger']->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"info"),
"Should suggest Logger::info(), got {:?}",
method_names
);
assert!(
method_names.contains(&"error"),
"Should suggest Logger::error(), got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_push_style_single_type_member_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///push_single.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public function getEmail(): string {}\n",
"}\n",
"$arr = [];\n",
"$arr[] = new User();\n",
"$arr[0]->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 10,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for $arr[0]-> with push-style inference"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getEmail"),
"Should suggest User::getEmail(), got {:?}",
method_names
);
let prop_labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
prop_labels.contains(&"name"),
"Should suggest User::$name, got {:?}",
prop_labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_push_style_union_type_member_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///push_union.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public function getEmail(): string {}\n",
"}\n",
"class AdminUser {\n",
" public string $name;\n",
" public function getEmail(): string {}\n",
" public function grantPermission(string $perm): void {}\n",
"}\n",
"$arr = [];\n",
"$arr[] = new User();\n",
"$arr[] = new AdminUser();\n",
"$arr[0]->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 13,
character: 10,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for union push-style list"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getEmail"),
"Should suggest getEmail() from both classes, got {:?}",
method_names
);
assert!(
method_names.contains(&"grantPermission"),
"Should suggest AdminUser::grantPermission(), got {:?}",
method_names
);
let prop_labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
prop_labels.contains(&"name"),
"Should suggest $name property, got {:?}",
prop_labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_push_style_deduplicates_types() {
let backend = create_test_backend();
let uri = Url::parse("file:///push_dedup.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public function getEmail(): string {}\n",
"}\n",
"$arr = [];\n",
"$arr[] = new User();\n",
"$arr[] = new User();\n",
"$arr[0]->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 10,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for deduplicated push-style list"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getEmail"),
"Should suggest User::getEmail(), got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_push_style_scalar_no_member_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///push_scalar.php").unwrap();
let text = concat!(
"<?php\n",
"$arr = [];\n",
"$arr[] = 'hello';\n",
"$arr[] = 'world';\n",
"$arr[0]->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 10,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
if let Some(CompletionResponse::Array(items)) = result {
let class_members: Vec<&str> = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::METHOD)
|| i.kind == Some(CompletionItemKind::PROPERTY)
})
.map(|i| i.label.as_str())
.collect();
assert!(
class_members.is_empty(),
"Scalar push types should not produce member completions, got {:?}",
class_members
);
}
}
#[tokio::test]
async fn test_push_style_mixed_with_keyed_prefers_shape() {
let backend = create_test_backend();
let uri = Url::parse("file:///push_mixed.php").unwrap();
let text = concat!(
"<?php\n",
"class User {}\n",
"$data = [];\n",
"$data['name'] = 'Alice';\n",
"$data[] = new User();\n",
"$data['\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 7,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return key completions from string-keyed assignments"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::FIELD))
.map(|i| i.label.as_str())
.collect();
assert!(
labels.contains(&"name"),
"Should suggest 'name' key, got {:?}",
labels
);
assert_eq!(labels.len(), 1, "Should have only 1 key, got {:?}", labels);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_push_style_inside_class_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///push_class.php").unwrap();
let text = concat!(
"<?php\n",
"class Logger {\n",
" public function info(): void {}\n",
" public function error(): void {}\n",
"}\n",
"class App {\n",
" public function run(): void {\n",
" $loggers = [];\n",
" $loggers[] = new Logger();\n",
" $loggers[0]->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for push-style list inside class method"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"info"),
"Should suggest Logger::info(), got {:?}",
method_names
);
assert!(
method_names.contains(&"error"),
"Should suggest Logger::error(), got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_push_style_with_initial_positional_array() {
let backend = create_test_backend();
let uri = Url::parse("file:///push_initial.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public function getEmail(): string {}\n",
"}\n",
"class AdminUser {\n",
" public function grantPermission(string $perm): void {}\n",
"}\n",
"$arr = [new User()];\n",
"$arr[] = new AdminUser();\n",
"$arr[0]->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 10,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions from push assignments on initially positional array"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"grantPermission"),
"Should suggest AdminUser::grantPermission() from push, got {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_variable_key_assignment_tracks_value_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///varkey_single.php").unwrap();
let text = concat!(
"<?php\n",
"class OrderLine {\n",
" public string $sku;\n",
" public function getQuantity(): int { return 0; }\n",
"}\n",
"class Svc {\n",
" /** @param list<OrderLine> $lines */\n",
" public function run(array $lines): void {\n",
" $grouped = [];\n",
" foreach ($lines as $line) {\n",
" $key = $line->sku;\n",
" $grouped[$key] = $line;\n",
" }\n",
" foreach ($grouped as $item) {\n",
" $item->\n",
" }\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 14,
character: 19,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for variable-key array assignment"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getQuantity"),
"Should include OrderLine::getQuantity() from variable-key assignment, got: {:?}",
method_names
);
let prop_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
prop_names.contains(&"sku"),
"Should include OrderLine::$sku from variable-key assignment, got: {:?}",
prop_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_variable_key_assignment_union_types() {
let backend = create_test_backend();
let uri = Url::parse("file:///varkey_union.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public function getEmail(): string {}\n",
"}\n",
"class AdminUser {\n",
" public function grantPermission(string $perm): void {}\n",
"}\n",
"$arr = [];\n",
"$arr[$idx1] = new User();\n",
"$arr[$idx2] = new AdminUser();\n",
"$arr[0]->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for union of variable-key assignments"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getEmail"),
"Should include User::getEmail() from first variable-key assignment, got: {:?}",
method_names
);
assert!(
method_names.contains(&"grantPermission"),
"Should include AdminUser::grantPermission() from second variable-key assignment, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_variable_key_assignment_bracket_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///varkey_bracket.php").unwrap();
let text = concat!(
"<?php\n",
"class Product {\n",
" public string $title;\n",
" public function getPrice(): float { return 0.0; }\n",
"}\n",
"class Svc {\n",
" /** @param list<Product> $products */\n",
" public function run(array $products): void {\n",
" $indexed = [];\n",
" foreach ($products as $p) {\n",
" $indexed[$p->title] = $p;\n",
" }\n",
" $indexed['some_key']->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 12,
character: 31,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for bracket access after variable-key assignment"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"Should include Product::getPrice() from variable-key assignment, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_variable_key_assignment_null_coalesce_guard() {
let backend = create_test_backend();
let uri = Url::parse("file:///varkey_coalesce.php").unwrap();
let text = concat!(
"<?php\n",
"class OrderLine {\n",
" public string $sku;\n",
" public function getTotal(): float { return 0.0; }\n",
"}\n",
"class Svc {\n",
" /** @param list<OrderLine> $lines */\n",
" public function run(array $lines): void {\n",
" $byKey = [];\n",
" foreach ($lines as $ol) {\n",
" $byKey[$ol->sku] = $ol;\n",
" }\n",
" $found = $byKey['abc'] ?? null;\n",
" if ($found === null) { return; }\n",
" $found->\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 completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 14,
character: 16,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions after null-coalesce + guard on variable-key array"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getTotal"),
"Should include OrderLine::getTotal() after null guard, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_variable_key_assignment_no_loop() {
let backend = create_test_backend();
let uri = Url::parse("file:///varkey_no_loop.php").unwrap();
let text = concat!(
"<?php\n",
"class Widget {\n",
" public function render(): string { return ''; }\n",
"}\n",
"$widgets = [];\n",
"$widgets[$someId] = new Widget();\n",
"$widgets[$otherId] = new Widget();\n",
"$widgets[0]->\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 completion_params = 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,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Should return completions for variable-key assignment without loop"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"render"),
"Should include Widget::render() from variable-key assignment, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}