use crate::common::{
create_psr4_workspace, create_psr4_workspace_with_enum_stubs, create_test_backend,
create_test_backend_with_stubs,
};
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
#[tokio::test]
async fn test_completion_nullsafe_arrow_on_tryfrom() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///nullsafe_enum.php").unwrap();
let text = concat!(
"<?php\n",
"enum Priority: int\n",
"{\n",
" case Low = 1;\n",
" case Medium = 2;\n",
" case High = 3;\n",
"}\n",
"\n",
"class Service {\n",
" public function test(int $val): void {\n",
" Priority::tryFrom($val)?->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 38,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results for ?->");
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("name")),
"?-> after tryFrom() should include 'name', got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.contains("value")),
"?-> after tryFrom() should include 'value', got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_regular_arrow_on_tryfrom() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///regular_arrow_enum.php").unwrap();
let text = concat!(
"<?php\n",
"enum Priority: int\n",
"{\n",
" case Low = 1;\n",
" case Medium = 2;\n",
" case High = 3;\n",
"}\n",
"\n",
"class Service {\n",
" public function test(int $val): void {\n",
" Priority::tryFrom($val)->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 37,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results for ->");
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("name")),
"-> after tryFrom() should include 'name', got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.contains("value")),
"-> after tryFrom() should include 'value', got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_nullsafe_arrow_on_variable() {
let backend = create_test_backend();
let uri = Url::parse("file:///nullsafe_var.php").unwrap();
let text = concat!(
"<?php\n",
"class Widget {\n",
" public string $label;\n",
" public function render(): void {}\n",
"}\n",
"\n",
"class Page {\n",
" public function test(?Widget $w): void {\n",
" $w?->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 13,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should return results for $w?->"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("render")),
"$w?-> should include 'render', got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.contains("label")),
"$w?-> should include 'label', got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_nullsafe_arrow_on_method_call() {
let backend = create_test_backend();
let uri = Url::parse("file:///nullsafe_method.php").unwrap();
let text = concat!(
"<?php\n",
"class Widget {\n",
" public string $label;\n",
" public function render(): void {}\n",
"}\n",
"\n",
"class Page {\n",
" public function getWidget(): ?Widget { return null; }\n",
" public function test(): void {\n",
" $this->getWidget()?->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 33,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should return results for getWidget()?->"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("render")),
"getWidget()?-> should include 'render', got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.contains("label")),
"getWidget()?-> should include 'label', got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_enum_cases_via_double_colon() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_basic.php").unwrap();
let text = concat!(
"<?php\n",
"enum CustomerAvailabilityStatus: int\n",
"{\n",
" case CUSTOMER_NOT_IN_AUDIENCE = -1;\n",
" case AVAILABLE_TO_CUSTOMER = 0;\n",
"}\n",
"\n",
"class Service {\n",
" public function test(): void {\n",
" CustomerAvailabilityStatus::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 36,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
constant_names.contains(&"CUSTOMER_NOT_IN_AUDIENCE"),
"Should include enum case 'CUSTOMER_NOT_IN_AUDIENCE', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"AVAILABLE_TO_CUSTOMER"),
"Should include enum case 'AVAILABLE_TO_CUSTOMER', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_unit_enum_cases() {
let backend = create_test_backend();
let uri = Url::parse("file:///unit_enum.php").unwrap();
let text = concat!(
"<?php\n",
"enum Color\n",
"{\n",
" case Red;\n",
" case Green;\n",
" case Blue;\n",
"}\n",
"\n",
"class Painter {\n",
" public function test(): void {\n",
" Color::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
constant_names.contains(&"Red"),
"Should include enum case 'Red', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Green"),
"Should include enum case 'Green', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Blue"),
"Should include enum case 'Blue', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_enum_cases_and_static_methods_via_double_colon() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_methods.php").unwrap();
let text = concat!(
"<?php\n",
"enum Suit: string\n",
"{\n",
" case Hearts = 'H';\n",
" case Diamonds = 'D';\n",
" case Clubs = 'C';\n",
" case Spades = 'S';\n",
"\n",
" public function color(): string\n",
" {\n",
" return 'red';\n",
" }\n",
"\n",
" public static function fromSymbol(string $s): self\n",
" {\n",
" return self::Hearts;\n",
" }\n",
"}\n",
"\n",
"class Game {\n",
" public function test(): void {\n",
" Suit::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 21,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
constant_names.contains(&"Hearts"),
"Should include enum case 'Hearts', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Spades"),
"Should include enum case 'Spades', got: {:?}",
constant_names
);
assert!(
method_names.contains(&"fromSymbol"),
"Should include static method 'fromSymbol', got: {:?}",
method_names
);
assert!(
!method_names.contains(&"color"),
"Should NOT include instance method 'color' via '::', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_enum_instance_methods_via_arrow() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_arrow.php").unwrap();
let text = concat!(
"<?php\n", "enum Suit: string\n", "{\n", " case Hearts = 'H';\n", " case Spades = 'S';\n", "\n", " public function color(): string\n", " {\n", " return 'red';\n", " }\n", "\n", " public function isRed(): bool\n", " {\n", " return true;\n", " }\n", "\n", " public function test(): void\n", " {\n", " $this->\n", " }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 18,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"color"),
"Should include instance method 'color' via '->', got: {:?}",
method_names
);
assert!(
method_names.contains(&"isRed"),
"Should include instance method 'isRed' via '->', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_enum_mixed_constants_and_cases() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_mixed.php").unwrap();
let text = concat!(
"<?php\n",
"enum Status: int\n",
"{\n",
" const DEFAULT_STATUS = 0;\n",
" case Active = 1;\n",
" case Inactive = 2;\n",
"}\n",
"\n",
"class Handler {\n",
" public function test(): void {\n",
" Status::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 16,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
constant_names.contains(&"DEFAULT_STATUS"),
"Should include real constant 'DEFAULT_STATUS', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Active"),
"Should include enum case 'Active', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Inactive"),
"Should include enum case 'Inactive', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_goto_definition_enum_case_same_file() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_goto.php").unwrap();
let text = concat!(
"<?php\n", "enum Status: int\n", "{\n", " case Active = 1;\n", " case Inactive = 2;\n", "}\n", "\n", "class Service {\n", " public function test(): void {\n", " $s = Status::Active;\n", " }\n", "}\n", );
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 9,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = backend.goto_definition(params).await.unwrap();
assert!(
result.is_some(),
"Should resolve goto-definition for enum case"
);
match result.unwrap() {
GotoDefinitionResponse::Scalar(location) => {
assert_eq!(location.uri, uri);
assert_eq!(
location.range.start.line, 3,
"case Active is declared on line 3"
);
}
other => panic!("Expected Scalar location, got: {:?}", other),
}
}
#[tokio::test]
async fn test_goto_definition_enum_const_same_file() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_const_goto.php").unwrap();
let text = concat!(
"<?php\n", "enum Status: int\n", "{\n", " const DEFAULT_STATUS = 0;\n", " case Active = 1;\n", " case Inactive = 2;\n", "}\n", "\n", "class Service {\n", " public function test(): void {\n", " $d = Status::DEFAULT_STATUS;\n", " }\n", "}\n", );
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 10,
character: 25,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = backend.goto_definition(params).await.unwrap();
assert!(
result.is_some(),
"Should resolve goto-definition for enum const"
);
match result.unwrap() {
GotoDefinitionResponse::Scalar(location) => {
assert_eq!(location.uri, uri);
assert_eq!(
location.range.start.line, 3,
"const DEFAULT_STATUS is declared on line 3"
);
}
other => panic!("Expected Scalar location, got: {:?}", other),
}
}
#[tokio::test]
async fn test_goto_definition_enum_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_method_goto.php").unwrap();
let text = concat!(
"<?php\n", "enum Suit: string\n", "{\n", " case Hearts = 'H';\n", " case Spades = 'S';\n", "\n", " public function color(): string\n", " {\n", " return 'red';\n", " }\n", "\n", " public function test(): void\n", " {\n", " $this->color();\n", " }\n", "}\n", );
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 13,
character: 17,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = backend.goto_definition(params).await.unwrap();
assert!(
result.is_some(),
"Should resolve goto-definition for enum method"
);
match result.unwrap() {
GotoDefinitionResponse::Scalar(location) => {
assert_eq!(location.uri, uri);
assert_eq!(
location.range.start.line, 6,
"method color() is declared on line 6"
);
}
other => panic!("Expected Scalar location, got: {:?}", other),
}
}
#[tokio::test]
async fn test_completion_enum_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\Enums\\": "src/Enums/"
}
}
}"#;
let enum_content = concat!(
"<?php\n",
"namespace App\\Enums;\n",
"\n",
"enum Priority: int\n",
"{\n",
" case Low = 0;\n",
" case Medium = 1;\n",
" case High = 2;\n",
" case Critical = 3;\n",
"\n",
" public static function fromValue(int $v): self\n",
" {\n",
" return self::Low;\n",
" }\n",
"}\n",
);
let (backend, dir) =
create_psr4_workspace(composer_json, &[("src/Enums/Priority.php", enum_content)]);
let main_uri = Url::from_file_path(dir.path().join("main.php")).unwrap();
let main_text = concat!(
"<?php\n",
"use App\\Enums\\Priority;\n",
"\n",
"class TaskService {\n",
" public function test(): void {\n",
" Priority::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: main_uri.clone(),
language_id: "php".to_string(),
version: 1,
text: main_text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: main_uri },
position: Position {
line: 5,
character: 18,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
constant_names.contains(&"Low"),
"Should include enum case 'Low', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"High"),
"Should include enum case 'High', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Critical"),
"Should include enum case 'Critical', got: {:?}",
constant_names
);
assert!(
method_names.contains(&"fromValue"),
"Should include static method 'fromValue', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_goto_definition_enum_case_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\Enums\\": "src/Enums/"
}
}
}"#;
let enum_content = concat!(
"<?php\n", "namespace App\\Enums;\n", "\n", "enum Direction\n", "{\n", " case North;\n", " case South;\n", " case East;\n", " case West;\n", "}\n", );
let (backend, dir) =
create_psr4_workspace(composer_json, &[("src/Enums/Direction.php", enum_content)]);
let main_uri = Url::from_file_path(dir.path().join("main.php")).unwrap();
let main_text = concat!(
"<?php\n",
"use App\\Enums\\Direction;\n",
"\n",
"class Navigator {\n",
" public function test(): void {\n",
" $d = Direction::North;\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: main_uri.clone(),
language_id: "php".to_string(),
version: 1,
text: main_text.to_string(),
},
})
.await;
let enum_uri = Url::from_file_path(dir.path().join("src/Enums/Direction.php")).unwrap();
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: main_uri.clone(),
},
position: Position {
line: 5,
character: 26,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = backend.goto_definition(params).await.unwrap();
assert!(
result.is_some(),
"Should resolve goto-definition for cross-file enum case"
);
match result.unwrap() {
GotoDefinitionResponse::Scalar(location) => {
assert_eq!(location.uri, enum_uri, "Should jump to the enum file");
assert_eq!(
location.range.start.line, 5,
"case North is declared on line 5"
);
}
other => panic!("Expected Scalar location, got: {:?}", other),
}
}
#[tokio::test]
async fn test_completion_enum_in_namespace_same_file() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_ns.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App\\Enums;\n",
"\n",
"enum Visibility\n",
"{\n",
" case Published;\n",
" case Draft;\n",
" case Archived;\n",
"}\n",
"\n",
"class ContentService {\n",
" public function test(): void {\n",
" Visibility::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 12,
character: 21,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
constant_names.contains(&"Published"),
"Should include 'Published', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Draft"),
"Should include 'Draft', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Archived"),
"Should include 'Archived', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_enum_with_trait_cases_via_double_colon() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_trait.php").unwrap();
let text = concat!(
"<?php\n",
"trait HasDescription {\n",
" public function describe(): string { return ''; }\n",
"}\n",
"\n",
"enum Size\n",
"{\n",
" use HasDescription;\n",
"\n",
" case Small;\n",
" case Medium;\n",
" case Large;\n",
"}\n",
"\n",
"class Shop {\n",
" public function test(): void {\n",
" Size::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 16,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
constant_names.contains(&"Small"),
"Should include enum case 'Small', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Medium"),
"Should include enum case 'Medium', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Large"),
"Should include enum case 'Large', got: {:?}",
constant_names
);
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
!method_names.contains(&"describe"),
"Instance method 'describe' should NOT appear via '::', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_enum_implements_interface() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_iface.php").unwrap();
let text = concat!(
"<?php\n",
"interface HasLabel {\n",
" public function label(): string;\n",
"}\n",
"\n",
"enum Fruit: string implements HasLabel\n",
"{\n",
" case Apple = 'apple';\n",
" case Banana = 'banana';\n",
" case Cherry = 'cherry';\n",
"\n",
" public function label(): string\n",
" {\n",
" return ucfirst($this->value);\n",
" }\n",
"}\n",
"\n",
"class FruitStand {\n",
" public function test(): void {\n",
" Fruit::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 19,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
constant_names.contains(&"Apple"),
"Should include enum case 'Apple', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Cherry"),
"Should include enum case 'Cherry', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_goto_definition_enum_name() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_name_goto.php").unwrap();
let text = concat!(
"<?php\n", "enum Status: int\n", "{\n", " case Active = 1;\n", " case Inactive = 2;\n", "}\n", "\n", "class Service {\n", " public function test(): void {\n", " $s = Status::Active;\n", " }\n", "}\n", );
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 9,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = backend.goto_definition(params).await.unwrap();
assert!(
result.is_some(),
"Should resolve goto-definition for enum name"
);
match result.unwrap() {
GotoDefinitionResponse::Scalar(location) => {
assert_eq!(location.uri, uri);
assert_eq!(
location.range.start.line, 1,
"enum Status is declared on line 1"
);
}
other => panic!("Expected Scalar location, got: {:?}", other),
}
}
#[tokio::test]
async fn test_completion_self_inside_enum() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_self.php").unwrap();
let text = concat!(
"<?php\n",
"enum Priority: int\n",
"{\n",
" case Low = 0;\n",
" case Medium = 1;\n",
" case High = 2;\n",
"\n",
" public function isUrgent(): bool\n",
" {\n",
" return $this === self::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 31,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
constant_names.contains(&"Low"),
"Should include enum case 'Low', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Medium"),
"Should include enum case 'Medium', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"High"),
"Should include enum case 'High', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_unit_enum_inherits_cases_from_stub() {
let backend = create_test_backend();
let stub_uri = Url::parse("file:///stubs/UnitEnum.php").unwrap();
let unit_enum_stub = concat!(
"<?php\n",
"interface UnitEnum\n",
"{\n",
" public static function cases(): array;\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: stub_uri,
language_id: "php".to_string(),
version: 1,
text: unit_enum_stub.to_string(),
},
})
.await;
let uri = Url::parse("file:///enum_stub_unit.php").unwrap();
let text = concat!(
"<?php\n",
"enum Color\n",
"{\n",
" case Red;\n",
" case Green;\n",
" case Blue;\n",
"}\n",
"\n",
"class Palette {\n",
" public function test(): void {\n",
" Color::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"cases"),
"Unit enum should inherit 'cases()' from UnitEnum stub, got methods: {:?}",
method_names
);
assert!(
constant_names.contains(&"Red"),
"Should still include enum case 'Red', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_backed_enum_inherits_from_and_tryfrom_from_stub() {
let backend = create_test_backend();
let stub_uri = Url::parse("file:///stubs/BackedEnum.php").unwrap();
let backed_enum_stub = concat!(
"<?php\n",
"interface BackedEnum\n",
"{\n",
" public static function from(int|string $value): static;\n",
" public static function tryFrom(int|string $value): ?static;\n",
" public static function cases(): array;\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: stub_uri,
language_id: "php".to_string(),
version: 1,
text: backed_enum_stub.to_string(),
},
})
.await;
let uri = Url::parse("file:///enum_stub_backed.php").unwrap();
let text = concat!(
"<?php\n",
"enum Priority: int\n",
"{\n",
" case Low = 0;\n",
" case Medium = 1;\n",
" case High = 2;\n",
"}\n",
"\n",
"class TaskService {\n",
" public function test(): void {\n",
" Priority::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 18,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"from"),
"Backed enum should inherit 'from()' from BackedEnum stub, got methods: {:?}",
method_names
);
assert!(
method_names.contains(&"tryFrom"),
"Backed enum should inherit 'tryFrom()' from BackedEnum stub, got methods: {:?}",
method_names
);
assert!(
method_names.contains(&"cases"),
"Backed enum should inherit 'cases()' from BackedEnum stub, got methods: {:?}",
method_names
);
assert!(
constant_names.contains(&"Low"),
"Should still include enum case 'Low', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"High"),
"Should still include enum case 'High', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_parser_enum_implicit_interface_in_used_traits() {
let backend = create_test_backend();
let unit_php = concat!(
"<?php\n",
"enum Direction\n",
"{\n",
" case Up;\n",
" case Down;\n",
"}\n",
);
let unit_classes = backend.parse_php(unit_php);
assert_eq!(unit_classes.len(), 1);
assert!(
unit_classes[0]
.used_traits
.iter()
.any(|t| t == "\\UnitEnum"),
"Unit enum should have \\UnitEnum in used_traits, got: {:?}",
unit_classes[0].used_traits
);
assert!(
!unit_classes[0]
.used_traits
.iter()
.any(|t| t == "\\BackedEnum"),
"Unit enum should NOT have \\BackedEnum, got: {:?}",
unit_classes[0].used_traits
);
let backed_php = concat!(
"<?php\n",
"enum Status: int\n",
"{\n",
" case Active = 1;\n",
" case Inactive = 0;\n",
"}\n",
);
let backed_classes = backend.parse_php(backed_php);
assert_eq!(backed_classes.len(), 1);
assert!(
backed_classes[0]
.used_traits
.iter()
.any(|t| t == "\\BackedEnum"),
"Backed enum should have \\BackedEnum in used_traits, got: {:?}",
backed_classes[0].used_traits
);
assert!(
!backed_classes[0]
.used_traits
.iter()
.any(|t| t == "\\UnitEnum"),
"Backed enum should NOT have \\UnitEnum, got: {:?}",
backed_classes[0].used_traits
);
let string_php = concat!(
"<?php\n",
"enum Suit: string\n",
"{\n",
" case Hearts = 'H';\n",
"}\n",
);
let string_classes = backend.parse_php(string_php);
assert_eq!(string_classes.len(), 1);
assert!(
string_classes[0]
.used_traits
.iter()
.any(|t| t == "\\BackedEnum"),
"String-backed enum should have \\BackedEnum, got: {:?}",
string_classes[0].used_traits
);
}
#[tokio::test]
async fn test_parser_enum_with_trait_also_has_implicit_interface() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"trait HasLabel {\n",
" public function label(): string { return 'label'; }\n",
"}\n",
"\n",
"enum Status: int\n",
"{\n",
" use HasLabel;\n",
"\n",
" case Active = 1;\n",
" case Inactive = 0;\n",
"}\n",
);
let classes = backend.parse_php(php);
let enum_info = classes.iter().find(|c| c.name == "Status").unwrap();
assert!(
enum_info.used_traits.iter().any(|t| t == "HasLabel"),
"Should include the explicit trait, got: {:?}",
enum_info.used_traits
);
assert!(
enum_info.used_traits.iter().any(|t| t == "\\BackedEnum"),
"Should include implicit \\BackedEnum, got: {:?}",
enum_info.used_traits
);
}
#[tokio::test]
async fn test_completion_unit_enum_gets_cases_from_embedded_stub() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///embedded_unit.php").unwrap();
let text = concat!(
"<?php\n",
"enum Direction\n",
"{\n",
" case Up;\n",
" case Down;\n",
" case Left;\n",
" case Right;\n",
"}\n",
"\n",
"class Nav {\n",
" public function test(): void {\n",
" Direction::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"cases"),
"Unit enum should auto-inherit 'cases()' from embedded UnitEnum stub, got: {:?}",
method_names
);
assert!(
constant_names.contains(&"Up"),
"Should include enum case 'Up', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"Right"),
"Should include enum case 'Right', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_backed_enum_gets_all_spl_members_from_embedded_stubs() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///embedded_backed.php").unwrap();
let text = concat!(
"<?php\n",
"enum HttpStatus: int\n",
"{\n",
" case Ok = 200;\n",
" case NotFound = 404;\n",
" case ServerError = 500;\n",
"}\n",
"\n",
"class Router {\n",
" public function test(): void {\n",
" HttpStatus::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"from"),
"Backed enum should auto-inherit 'from()' from embedded BackedEnum stub, got: {:?}",
method_names
);
assert!(
method_names.contains(&"tryFrom"),
"Backed enum should auto-inherit 'tryFrom()' from embedded BackedEnum stub, got: {:?}",
method_names
);
assert!(
method_names.contains(&"cases"),
"Backed enum should auto-inherit 'cases()' from UnitEnum via extends chain, got: {:?}",
method_names
);
assert!(
constant_names.contains(&"Ok"),
"Should include enum case 'Ok', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"NotFound"),
"Should include enum case 'NotFound', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_backed_enum_arrow_gets_properties_from_embedded_stubs() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///embedded_arrow.php").unwrap();
let text = concat!(
"<?php\n",
"enum Color: string\n",
"{\n",
" case Red = 'red';\n",
" case Blue = 'blue';\n",
"\n",
" public function label(): string\n",
" {\n",
" return $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let property_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
property_names.contains(&"name"),
"Should auto-inherit 'name' property from UnitEnum stub, got: {:?}",
property_names
);
assert!(
property_names.contains(&"value"),
"Should auto-inherit 'value' property from BackedEnum stub, got: {:?}",
property_names
);
assert!(
method_names.contains(&"label"),
"Should include own instance method 'label', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_embedded_stub_caching_across_files() {
let backend = create_test_backend_with_stubs();
let uri1 = Url::parse("file:///cache_test_1.php").unwrap();
let text1 = concat!(
"<?php\n",
"enum Suit\n",
"{\n",
" case Hearts;\n",
" case Spades;\n",
"}\n",
"\n",
"class Game1 {\n",
" public function test(): void {\n",
" Suit::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri1.clone(),
language_id: "php".to_string(),
version: 1,
text: text1.to_string(),
},
})
.await;
let result1 = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri1 },
position: Position {
line: 9,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
if let Some(CompletionResponse::Array(items)) = &result1 {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"cases"),
"First enum should get 'cases()' from stub, got: {:?}",
method_names
);
} else {
panic!("Expected CompletionResponse::Array for first file");
}
let uri2 = Url::parse("file:///cache_test_2.php").unwrap();
let text2 = concat!(
"<?php\n",
"enum Season\n",
"{\n",
" case Spring;\n",
" case Summer;\n",
"}\n",
"\n",
"class Game2 {\n",
" public function test(): void {\n",
" Season::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri2.clone(),
language_id: "php".to_string(),
version: 1,
text: text2.to_string(),
},
})
.await;
let result2 = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri2 },
position: Position {
line: 9,
character: 16,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
if let Some(CompletionResponse::Array(items)) = &result2 {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"cases"),
"Second enum should also get 'cases()' from cached stub, got: {:?}",
method_names
);
} else {
panic!("Expected CompletionResponse::Array for second file");
}
}
#[tokio::test]
async fn test_completion_backed_enum_inherits_unit_enum_members_through_extends() {
let backend = create_test_backend();
let unit_stub_uri = Url::parse("file:///stubs/UnitEnum.php").unwrap();
let unit_stub = concat!(
"<?php\n",
"interface UnitEnum\n",
"{\n",
" public readonly string $name;\n",
" public static function cases(): array;\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: unit_stub_uri,
language_id: "php".to_string(),
version: 1,
text: unit_stub.to_string(),
},
})
.await;
let backed_stub_uri = Url::parse("file:///stubs/BackedEnum.php").unwrap();
let backed_stub = concat!(
"<?php\n",
"interface BackedEnum extends UnitEnum\n",
"{\n",
" public readonly int|string $value;\n",
" public static function from(int|string $value): static;\n",
" public static function tryFrom(int|string $value): ?static;\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: backed_stub_uri,
language_id: "php".to_string(),
version: 1,
text: backed_stub.to_string(),
},
})
.await;
let uri = Url::parse("file:///enum_backed_extends.php").unwrap();
let text = concat!(
"<?php\n",
"enum Priority: int\n",
"{\n",
" case Low = 0;\n",
" case Medium = 1;\n",
" case High = 2;\n",
"}\n",
"\n",
"class TaskService {\n",
" public function test(): void {\n",
" Priority::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 18,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let constant_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"from"),
"Should inherit 'from()' from BackedEnum, got methods: {:?}",
method_names
);
assert!(
method_names.contains(&"tryFrom"),
"Should inherit 'tryFrom()' from BackedEnum, got methods: {:?}",
method_names
);
assert!(
method_names.contains(&"cases"),
"Should inherit 'cases()' from UnitEnum via BackedEnum extends, got methods: {:?}",
method_names
);
assert!(
constant_names.contains(&"Low"),
"Should include enum case 'Low', got: {:?}",
constant_names
);
assert!(
constant_names.contains(&"High"),
"Should include enum case 'High', got: {:?}",
constant_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_backed_enum_inherits_properties_through_extends() {
let backend = create_test_backend();
let unit_stub_uri = Url::parse("file:///stubs2/UnitEnum.php").unwrap();
let unit_stub = concat!(
"<?php\n",
"interface UnitEnum\n",
"{\n",
" public readonly string $name;\n",
" public static function cases(): array;\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: unit_stub_uri,
language_id: "php".to_string(),
version: 1,
text: unit_stub.to_string(),
},
})
.await;
let backed_stub_uri = Url::parse("file:///stubs2/BackedEnum.php").unwrap();
let backed_stub = concat!(
"<?php\n",
"interface BackedEnum extends UnitEnum\n",
"{\n",
" public readonly int|string $value;\n",
" public static function from(int|string $value): static;\n",
" public static function tryFrom(int|string $value): ?static;\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: backed_stub_uri,
language_id: "php".to_string(),
version: 1,
text: backed_stub.to_string(),
},
})
.await;
let uri = Url::parse("file:///enum_arrow_extends.php").unwrap();
let text = concat!(
"<?php\n",
"enum Status: int\n",
"{\n",
" case Active = 1;\n",
" case Inactive = 0;\n",
"\n",
" public function describe(): string\n",
" {\n",
" return $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let property_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
property_names.contains(&"value"),
"Should inherit 'value' property from BackedEnum, got properties: {:?}",
property_names
);
assert!(
property_names.contains(&"name"),
"Should inherit 'name' property from UnitEnum via BackedEnum extends, got properties: {:?}",
property_names
);
assert!(
method_names.contains(&"describe"),
"Should include own instance method 'describe', got methods: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_enum_case_arrow_top_level() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_case_arrow.php").unwrap();
let text = concat!(
"<?php\n", "enum Status: string\n", "{\n", " case Active = 'active';\n", " case Inactive = 'inactive';\n", "\n", " public function label(): string\n", " {\n", " return 'Label';\n", " }\n", "\n", " public function isActive(): bool\n", " {\n", " return true;\n", " }\n", "}\n", "\n", "Status::Active->\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 17,
character: 17,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should return results for Status::Active->"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"label"),
"Should include instance method 'label' via Status::Active->, got: {:?}",
method_names
);
assert!(
method_names.contains(&"isActive"),
"Should include instance method 'isActive' via Status::Active->, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_enum_case_arrow_inside_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_case_arrow_method.php").unwrap();
let text = concat!(
"<?php\n", "enum Priority: int\n", "{\n", " case Low = 1;\n", " case High = 2;\n", "\n", " public function describe(): string\n", " {\n", " return 'priority';\n", " }\n", "}\n", "\n", "class Ticket {\n", " public function test(): void\n", " {\n", " Priority::High->\n", " }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 15,
character: 24,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should return results for Priority::High-> inside a 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())
.collect();
assert!(
method_names.contains(&"describe"),
"Should include instance method 'describe' via Priority::High->, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_variable_assigned_enum_case_top_level() {
let backend = create_test_backend();
let uri = Url::parse("file:///enum_var_toplevel.php").unwrap();
let text = concat!(
"<?php\n", "enum Status: string\n", "{\n", " case Active = 'active';\n", " case Inactive = 'inactive';\n", "\n", " public function label(): string\n", " {\n", " return 'Label';\n", " }\n", "}\n", "\n", "class Svc {\n", " public function run(): string { return ''; }\n", "}\n", "\n", "$svc = new Svc();\n", "$svc->\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 17,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should return results for top-level $svc = new Svc()"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"run"),
"Should include 'run' for top-level $svc-> , got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_self_referential_assignment_backed_enum_value() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///self_ref_enum.php").unwrap();
let text = concat!(
"<?php\n", "class ConvertHelper {\n", " public static function toBool(mixed $value): bool {\n", " if ($value instanceof \\BackedEnum) {\n", " $value = $value->value;\n", " }\n", " $value->\n", " }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
backend.completion(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,
}),
)
.await;
assert!(
result.is_ok(),
"Completion request should not hang on self-referential assignment"
);
}
#[tokio::test]
async fn test_goto_definition_backed_enum_value_property_not_function() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///goto_enum_prop.php").unwrap();
let text = concat!(
"<?php\n", "function value($value, ...$args) { return $value; }\n", "\n", "class ConvertHelper {\n", " public static function toBool(mixed $value): bool {\n", " if ($value instanceof \\BackedEnum) {\n", " $value = $value->value;\n", " }\n", " return (bool) $value;\n", " }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 6,
character: 33,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = backend.goto_definition(params).await.unwrap();
assert!(
result.is_some(),
"Should resolve goto-definition for BackedEnum->value property"
);
match result.unwrap() {
GotoDefinitionResponse::Scalar(location) => {
assert!(
location.uri.as_str().contains("phpantom-stub://"),
"Should resolve to the BackedEnum stub, not the current file. Got uri: {}",
location.uri
);
assert_eq!(
location.range.start.line, 5,
"Should point to the `$value` property declaration (line 5 of stub), \
not a method parameter. Got line: {}",
location.range.start.line
);
}
other => panic!("Expected Scalar location, got: {:?}", other),
}
}
#[tokio::test]
async fn test_completion_foreach_enum_cases_resolves_to_enum_type() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///foreach_enum_cases.php").unwrap();
let text = concat!(
"<?php\n",
"enum Country: string\n",
"{\n",
" case BE = 'be';\n",
" case NL = 'nl';\n",
"}\n",
"\n",
"class Handler {\n",
" public function run(): void {\n",
" foreach (Country::cases() as $country) {\n",
" $country->\n",
" }\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should return results for $country-> inside foreach over Enum::cases()"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("value")),
"$country-> should include 'value' (from BackedEnum), got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.contains("name")),
"$country-> should include 'name' (from UnitEnum), got: {:?}",
labels
);
assert!(
!labels.iter().any(|l| l.contains("run")),
"$country-> should NOT include 'run' from enclosing Handler class, got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_foreach_unit_enum_cases_resolves_to_enum_type() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///foreach_unit_enum_cases.php").unwrap();
let text = concat!(
"<?php\n",
"enum Suit\n",
"{\n",
" case Hearts;\n",
" case Diamonds;\n",
"}\n",
"\n",
"class Dealer {\n",
" public function deal(): void {\n",
" foreach (Suit::cases() as $suit) {\n",
" $suit->\n",
" }\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 10,
character: 19,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should return results for $suit-> inside foreach over UnitEnum::cases()"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("name")),
"$suit-> should include 'name' (from UnitEnum), got: {:?}",
labels
);
assert!(
!labels.iter().any(|l| l.contains("deal")),
"$suit-> should NOT include 'deal' from enclosing Dealer class, got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_foreach_variable_assigned_from_enum_cases() {
let backend = create_test_backend_with_stubs();
let uri = Url::parse("file:///foreach_var_enum_cases.php").unwrap();
let text = concat!(
"<?php\n",
"enum Country: string\n",
"{\n",
" case BE = 'be';\n",
" case NL = 'nl';\n",
"}\n",
"\n",
"class Handler {\n",
" public function run(): void {\n",
" $countries = Country::cases();\n",
" foreach ($countries as $country) {\n",
" $country->\n",
" }\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should return results for $country-> inside foreach over variable assigned from Enum::cases()"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("value")),
"$country-> should include 'value' (from BackedEnum), got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.contains("name")),
"$country-> should include 'name' (from UnitEnum), got: {:?}",
labels
);
assert!(
!labels.iter().any(|l| l.contains("run")),
"$country-> should NOT include 'run' from enclosing Handler class, got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_foreach_enum_cases_cross_file() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\Enums\\": "src/Enums/"
}
}
}"#;
let enum_content = concat!(
"<?php\n",
"namespace App\\Enums;\n",
"\n",
"enum Country: string\n",
"{\n",
" case BE = 'be';\n",
" case NL = 'nl';\n",
"}\n",
);
let (backend, dir) = create_psr4_workspace_with_enum_stubs(
composer_json,
&[("src/Enums/Country.php", enum_content)],
);
let handler_uri = Url::from_file_path(dir.path().join("handler.php")).unwrap();
let handler_text = concat!(
"<?php\n",
"namespace App\\Handlers;\n",
"\n",
"use App\\Enums\\Country;\n",
"\n",
"class Handler {\n",
" public function handle(): void {\n",
" foreach (Country::cases() as $country) {\n",
" $country->\n",
" }\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: handler_uri.clone(),
language_id: "php".to_string(),
version: 1,
text: handler_text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: handler_uri.clone(),
},
position: Position {
line: 8,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Completion should return results for $country-> in cross-file foreach over Enum::cases()"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.contains("value")),
"$country-> should include 'value' (from BackedEnum) cross-file, got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.contains("name")),
"$country-> should include 'name' (from UnitEnum) cross-file, got: {:?}",
labels
);
assert!(
!labels.iter().any(|l| l.contains("handle")),
"$country-> should NOT include 'handle' from enclosing Handler class, got: {:?}",
labels
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}