use super::*;
use expect_test::expect;
const FEATURE_CAP_PAIRS: &[(&str, &str)] = &[
("completion", "completionProvider"),
("hover", "hoverProvider"),
("definition", "definitionProvider"),
("declaration", "declarationProvider"),
("references", "referencesProvider"),
("documentSymbols", "documentSymbolProvider"),
("workspaceSymbols", "workspaceSymbolProvider"),
("rename", "renameProvider"),
("signatureHelp", "signatureHelpProvider"),
("inlayHints", "inlayHintProvider"),
("semanticTokens", "semanticTokensProvider"),
("selectionRange", "selectionRangeProvider"),
("callHierarchy", "callHierarchyProvider"),
("documentHighlight", "documentHighlightProvider"),
("implementation", "implementationProvider"),
("codeAction", "codeActionProvider"),
("typeDefinition", "typeDefinitionProvider"),
("codeLens", "codeLensProvider"),
("formatting", "documentFormattingProvider"),
("rangeFormatting", "documentRangeFormattingProvider"),
("onTypeFormatting", "documentOnTypeFormattingProvider"),
("documentLink", "documentLinkProvider"),
("linkedEditingRange", "linkedEditingRangeProvider"),
("inlineValues", "inlineValueProvider"),
];
const UNCONDITIONAL_CAPS: &[&str] = &[
"textDocumentSync",
"foldingRangeProvider",
"executeCommandProvider",
"diagnosticProvider",
"workspace",
"monikerProvider",
];
#[tokio::test]
async fn all_features_disabled_removes_all_toggleable_capabilities() {
let mut all_off = serde_json::json!({ "diagnostics": { "enabled": true }, "features": {} });
for (flag, _) in FEATURE_CAP_PAIRS {
all_off["features"][flag] = serde_json::json!(false);
}
let (_, resp) = TestServer::new_with_options(all_off).await;
let caps = &resp["result"]["capabilities"];
for (flag, cap_field) in FEATURE_CAP_PAIRS {
assert!(
caps[cap_field].is_null(),
"expected {cap_field} to be absent when feature '{flag}' is disabled, got: {}",
caps[cap_field]
);
}
for cap_field in UNCONDITIONAL_CAPS {
assert!(
!caps[cap_field].is_null(),
"expected {cap_field} to remain present (unconditional), got null"
);
}
}
#[tokio::test]
async fn all_features_enabled_by_default() {
let (_, resp) = TestServer::new_with_options(serde_json::json!({
"diagnostics": { "enabled": true }
}))
.await;
let caps = &resp["result"]["capabilities"];
for (flag, cap_field) in FEATURE_CAP_PAIRS {
assert!(
!caps[cap_field].is_null(),
"expected {cap_field} to be present by default (feature '{flag}' not mentioned), got null"
);
}
}
#[tokio::test]
async fn initialize_returns_server_capabilities() {
let (mut server, init_resp) = TestServer::new_with_options(serde_json::json!({})).await;
assert!(
!init_resp["result"]["capabilities"]["hoverProvider"].is_null(),
"expected hoverProvider to be advertised: {init_resp:?}"
);
server
.open("cap.php", "<?php\nfunction f(): void {}\n")
.await;
let resp = server.hover("cap.php", 1, 10).await;
expect![[r#"
```php
function f(): void
```"#]]
.assert_eq(&render_hover(&resp));
}
#[tokio::test]
async fn backslash_is_a_completion_trigger_character() {
let (_, init_resp) = TestServer::new_with_options(serde_json::json!({})).await;
let triggers = init_resp["result"]["capabilities"]["completionProvider"]["triggerCharacters"]
.as_array()
.expect("completionProvider should advertise triggerCharacters")
.iter()
.map(|v| v.as_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(
triggers.contains(&"\\"),
"expected '\\\\' among trigger characters, got {triggers:?}"
);
}
#[tokio::test]
async fn shutdown_responds_correctly() {
let mut server = TestServer::new().await;
let resp = server.shutdown().await;
assert!(
resp["error"].is_null(),
"shutdown should not error: {:?}",
resp
);
assert!(resp["result"].is_null(), "shutdown result should be null");
}
#[tokio::test]
async fn many_files_hover_each_returns_own_signature() {
let mut server = TestServer::new().await;
for i in 0..10 {
let src = format!("<?php\nfunction fn_{i}(int $x): int {{ return $x; }}\n");
server.open(&format!("c{i}.php"), &src).await;
}
for i in 0..10 {
let resp = server.hover(&format!("c{i}.php"), 1, 10).await;
let contents = resp["result"]["contents"].to_string();
assert!(
contents.contains(&format!("fn_{i}")),
"file c{i}.php hover must mention fn_{i}, got: {contents}"
);
}
}
#[tokio::test]
async fn sustained_hover_volley_all_succeed() {
let mut server = TestServer::new().await;
server
.open(
"pipe.php",
"<?php\nfunction pipeHover(int $x): int { return $x; }\n",
)
.await;
for _ in 0..20 {
let resp = server.hover("pipe.php", 1, 10).await;
assert!(resp["error"].is_null(), "hover errored in volley: {resp:?}");
let out = render_hover(&resp);
assert!(
out.contains("pipeHover"),
"hover content must stay correct across volley, got: {out}"
);
}
}
#[tokio::test]
async fn didchange_followed_by_request_sees_new_state_every_iteration() {
let mut server = TestServer::new().await;
server.open("iter.php", "<?php\n").await;
for v in 2..=8 {
let src = format!("<?php\nfunction iter_{v}(): int {{ return {v}; }}\niter_{v}();\n");
server.change("iter.php", v, &src).await;
let resp = server.hover("iter.php", 1, 10).await;
let contents = resp["result"]["contents"].to_string();
assert!(
contents.contains(&format!("iter_{v}")),
"iteration {v}: hover must see latest name, got: {contents}"
);
let resp = server.references("iter.php", 1, 10, false).await;
let refs = resp["result"].as_array().cloned().unwrap_or_default();
assert_eq!(
refs.len(),
1,
"iteration {v}: expected 1 ref, got {}: {refs:?}",
refs.len()
);
}
}
#[tokio::test]
async fn request_after_close_and_reopen_returns_fresh_data() {
let mut server = TestServer::new().await;
server
.open("ro.php", "<?php\nfunction first(): void {}\n")
.await;
let uri = server.uri("ro.php");
server
.client()
.notify(
"textDocument/didClose",
serde_json::json!({ "textDocument": { "uri": uri } }),
)
.await;
server
.open("ro.php", "<?php\nfunction second(): void {}\n")
.await;
let resp = server.hover("ro.php", 1, 10).await;
expect![[r#"
```php
function second(): void
```"#]]
.assert_eq(&render_hover(&resp));
}
#[tokio::test]
async fn cancel_request_returns_request_cancelled_and_server_stays_alive() {
let mut server = TestServer::new().await;
for i in 0..30 {
let src =
format!("<?php\nclass Worker{i} {{\n public function doWork(): void {{}}\n}}\n");
server.open(&format!("w{i}.php"), &src).await;
}
server
.open(
"cancel_main.php",
"<?php\n$w = new Worker0();\n$w->doWork();\n",
)
.await;
let uri = server.uri("cancel_main.php");
let mut cancelled = false;
for _ in 0..10 {
let resp = server
.request_then_cancel(
"textDocument/references",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 2, "character": 5 },
"context": { "includeDeclaration": true },
}),
)
.await;
match resp.get("error") {
None => continue, Some(err) => {
assert_eq!(
err["code"],
serde_json::json!(-32800),
"expected RequestCancelled (-32800), got: {resp}"
);
cancelled = true;
break;
}
}
}
assert!(
cancelled,
"no attempt out of 10 produced a RequestCancelled response"
);
let hover = server.hover("cancel_main.php", 2, 5).await;
assert!(
hover.get("error").is_none(),
"hover after cancellation must succeed, got: {hover}"
);
}
#[tokio::test]
async fn references_and_hover_concurrent_complete_without_deadlock() {
let (mut srv_a, mut srv_b) = tokio::join!(TestServer::new(), TestServer::new());
srv_a
.open(
"conc_a.php",
"<?php\nfunction greet(string $name): string { return $name; }\ngreet('world');\n",
)
.await;
srv_b
.open(
"conc_b.php",
"<?php\nfunction greet(string $name): string { return $name; }\ngreet('world');\n",
)
.await;
let (refs_resp, hover_resp) = tokio::join!(
srv_a.references("conc_a.php", 1, 10, false),
srv_b.hover("conc_b.php", 1, 10)
);
expect!["conc_a.php:2:0-2:5"].assert_eq(&render_locations(&refs_resp, &srv_a.uri("")));
expect![[r#"
```php
function greet(string $name): string
```"#]]
.assert_eq(&render_hover(&hover_resp));
}
#[tokio::test]
async fn laravel_string_key_references_and_hover_concurrent_complete_without_deadlock() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
std::fs::write(workspace.path().join("artisan"), "#!/usr/bin/env php\n").unwrap();
std::fs::write(workspace.path().join(".env"), "APP_NAME=Test\n").unwrap();
std::fs::write(
workspace.path().join("a.php"),
"<?php\n$x = env('APP_NAME');\n",
)
.unwrap();
let (mut srv_a, mut srv_b) =
tokio::join!(TestServer::with_root(workspace.path()), TestServer::new());
srv_a.wait_for_index_ready().await;
srv_a.open(".env", "APP_NAME=Test\n").await;
srv_b
.open(
"conc_b.php",
"<?php\nfunction greet(string $name): string { return $name; }\ngreet('world');\n",
)
.await;
let (refs_resp, hover_resp) = tokio::join!(
srv_a.references(".env", 0, 2, false),
srv_b.hover("conc_b.php", 1, 10)
);
expect!["a.php:1:10-1:18"].assert_eq(&render_locations(&refs_resp, &srv_a.uri("")));
expect![[r#"
```php
function greet(string $name): string
```"#]]
.assert_eq(&render_hover(&hover_resp));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn did_open_stays_responsive_while_parse_is_in_flight() {
let (mut server, _init) = TestServer::new_with_options(serde_json::json!({
"diagnostics": { "enabled": false }
}))
.await;
let uri = server.uri("gated_did_open.php");
server
.assert_notification_stays_responsive(
"textDocument/didOpen",
serde_json::json!({
"textDocument": {
"uri": uri,
"languageId": "php",
"version": 1,
"text": "<?php\nfunction gated(): int { return 1; }\n",
}
}),
php_lsp::backend::debug_gate::GATE_DID_OPEN_PARSE,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn goto_type_definition_stays_responsive_with_many_open_documents() {
let mut server = TestServer::new().await;
server
.open(
"gated_goto_target.php",
"<?php\nfunction useIt(): void {\n undefinedGoal();\n}\n",
)
.await;
let uri = server.uri("gated_goto_target.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/typeDefinition",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 2, "character": 6 },
}),
php_lsp::backend::debug_gate::GATE_GOTO_TYPE_DEFINITION,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn goto_definition_stays_responsive_while_fallback_lookup_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_goto_definition.php",
"<?php\nfunction gated(): void {\n $gatedVar = 1;\n echo $gatedVar;\n}\n",
)
.await;
let uri = server.uri("gated_goto_definition.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/definition",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 3, "character": 11 },
}),
php_lsp::backend::debug_gate::GATE_GOTO_DEFINITION,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn prepare_rename_stays_responsive_for_keyword_shaped_identifier_on_large_file() {
let source = "<?php\nfunction useList() {\n list($a, $b) = someFunc();\n return $a;\n}\n";
let list_line = 2;
let list_char = source.lines().nth(list_line).unwrap().find("list").unwrap() as u32;
let mut server = TestServer::new().await;
server.open("gated_prepare_rename.php", source).await;
let uri = server.uri("gated_prepare_rename.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/prepareRename",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": list_line as u32, "character": list_char },
}),
php_lsp::backend::debug_gate::GATE_PREPARE_RENAME,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn type_hierarchy_prepare_stays_responsive_while_lookup_is_in_flight() {
let mut server = TestServer::new().await;
server
.open("gated_type_hierarchy.php", "<?php\nclass GatedType {}\n")
.await;
let uri = server.uri("gated_type_hierarchy.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/prepareTypeHierarchy",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 1, "character": 8 },
}),
php_lsp::backend::debug_gate::GATE_TYPE_HIERARCHY,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn moniker_stays_responsive_while_member_lookup_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_moniker.php",
"<?php\nclass GatedMoniker {\n public function work(): void {}\n}\n",
)
.await;
let uri = server.uri("gated_moniker.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/moniker",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 2, "character": 22 },
}),
php_lsp::backend::debug_gate::GATE_MONIKER,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inline_value_stays_responsive_while_range_scan_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_inline_value.php",
"<?php\n$first = 1;\n$second = $first + 1;\n",
)
.await;
let uri = server.uri("gated_inline_value.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/inlineValue",
serde_json::json!({
"textDocument": { "uri": uri },
"range": {
"start": { "line": 1, "character": 0 },
"end": { "line": 2, "character": 20 },
},
"context": {
"frameId": 0,
"stoppedLocation": {
"start": { "line": 1, "character": 0 },
"end": { "line": 2, "character": 20 },
},
},
}),
php_lsp::backend::debug_gate::GATE_INLINE_VALUE,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn linked_editing_range_stays_responsive_on_large_file() {
let mut server = TestServer::new().await;
server
.open(
"gated_linked_editing.php",
"<?php\nclass GatedClass {\n public function work(): void {}\n}\n",
)
.await;
let uri = server.uri("gated_linked_editing.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/linkedEditingRange",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 1, "character": 8 },
}),
php_lsp::backend::debug_gate::GATE_LINKED_EDITING_RANGE,
)
.await;
}
#[cfg(unix)]
#[serial_test::serial(fake_external_formatter)]
#[tokio::test]
async fn formatting_stays_responsive_with_slow_external_formatter() {
struct PathGuard(String);
impl Drop for PathGuard {
fn drop(&mut self) {
unsafe { std::env::set_var("PATH", &self.0) };
}
}
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("php-cs-fixer");
std::fs::write(&script, "#!/bin/sh\nsleep 0.2\ncat\necho '// formatted'\n")
.expect("write fake formatter");
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))
.expect("chmod fake formatter");
}
let original_path = std::env::var("PATH").unwrap_or_default();
let _restore_path = PathGuard(original_path.clone());
unsafe {
std::env::set_var("PATH", format!("{}:{original_path}", dir.path().display()));
}
let mut server = TestServer::new().await;
server
.open("fmt.php", "<?php\nfunction f(): void {}\n")
.await;
let uri = server.uri("fmt.php");
server
.assert_stays_responsive(
"textDocument/formatting",
serde_json::json!({
"textDocument": { "uri": uri },
"options": { "tabSize": 4, "insertSpaces": true },
}),
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn document_symbol_stays_responsive_on_large_file() {
let mut server = TestServer::new().await;
server
.open("gated_doc_symbol.php", "<?php\nfunction gated(): void {}\n")
.await;
let uri = server.uri("gated_doc_symbol.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/documentSymbol",
serde_json::json!({ "textDocument": { "uri": uri } }),
php_lsp::backend::debug_gate::GATE_DOCUMENT_SYMBOL,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn folding_range_stays_responsive_on_large_file() {
let mut server = TestServer::new().await;
server
.open("gated_folding.php", "<?php\nfunction gated(): void {}\n")
.await;
let uri = server.uri("gated_folding.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/foldingRange",
serde_json::json!({ "textDocument": { "uri": uri } }),
php_lsp::backend::debug_gate::GATE_FOLDING_RANGE,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn document_link_stays_responsive_on_large_file() {
let mut server = TestServer::new().await;
server
.open("gated_doc_link.php", "<?php\nfunction gated(): void {}\n")
.await;
let uri = server.uri("gated_doc_link.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/documentLink",
serde_json::json!({ "textDocument": { "uri": uri } }),
php_lsp::backend::debug_gate::GATE_DOCUMENT_LINK,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn semantic_tokens_full_stays_responsive_on_large_file() {
let mut server = TestServer::new().await;
server
.open(
"gated_semtok_full.php",
"<?php\nfunction gated(): void {}\n",
)
.await;
let uri = server.uri("gated_semtok_full.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/semanticTokens/full",
serde_json::json!({ "textDocument": { "uri": uri } }),
php_lsp::backend::debug_gate::GATE_SEMANTIC_TOKENS_FULL,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn semantic_tokens_range_stays_responsive_on_large_file() {
let mut server = TestServer::new().await;
server
.open(
"gated_semtok_range.php",
"<?php\nfunction gated(): void {}\n",
)
.await;
let uri = server.uri("gated_semtok_range.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/semanticTokens/range",
serde_json::json!({
"textDocument": { "uri": uri },
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 1, "character": 0 },
},
}),
php_lsp::backend::debug_gate::GATE_SEMANTIC_TOKENS_RANGE,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn did_save_stays_responsive_while_diagnostics_recompute_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_save.php",
"<?php\nfunction saved(): int { return 1; }\n",
)
.await;
let uri = server.uri("gated_save.php");
server
.assert_notification_stays_responsive(
"textDocument/didSave",
serde_json::json!({ "textDocument": { "uri": uri } }),
php_lsp::backend::debug_gate::GATE_DID_SAVE_DIAGNOSTICS,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn did_change_watched_files_stays_responsive_while_batch_parse_is_in_flight() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let mut server = TestServer::with_root(workspace.path()).await;
server.wait_for_index_ready().await;
server.write_file("gated_watched.php", "<?php\nclass GatedWatched {}\n");
let uri = server.uri("gated_watched.php");
server
.assert_notification_stays_responsive(
"workspace/didChangeWatchedFiles",
serde_json::json!({ "changes": [{ "uri": uri, "type": 1 }] }),
php_lsp::backend::debug_gate::GATE_DID_CHANGE_WATCHED_FILES,
)
.await;
}
#[tokio::test]
async fn did_change_watched_files_bulk_batch_indexes_every_file() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let mut server = TestServer::with_root(workspace.path()).await;
server.wait_for_index_ready().await;
server.write_file("ToDelete.php", "<?php\nclass ToDelete {}\n");
server
.did_change_watched_files(vec![(server.uri("ToDelete.php"), 1)])
.await;
server
.wait_until_symbol_present("ToDelete", std::time::Duration::from_secs(3))
.await;
std::fs::remove_file(workspace.path().join("ToDelete.php")).unwrap();
let mut changes = vec![(server.uri("ToDelete.php"), 3)]; for i in 0..50 {
let path = format!("Bulk{i}.php");
server.write_file(&path, &format!("<?php\nclass Bulk{i} {{}}\n"));
changes.push((server.uri(&path), 1)); }
server.did_change_watched_files(changes).await;
server
.wait_until_symbol_present("Bulk49", std::time::Duration::from_secs(3))
.await;
for i in 0..50 {
let found = server.snapshot_workspace_symbols(&format!("Bulk{i}")).await;
assert!(
found.contains(&format!("Bulk{i}")),
"Bulk{i} missing from workspace symbols after bulk batch: {found}"
);
}
let deleted = server.snapshot_workspace_symbols("ToDelete").await;
assert!(
deleted.contains("no symbols"),
"ToDelete must be gone after DELETED in the same batch, got: {deleted}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn will_rename_files_stays_responsive_while_use_edit_batch_is_in_flight() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
std::fs::write(
workspace.path().join("composer.json"),
r#"{"autoload": {"psr-4": {"": "src/"}}}"#,
)
.unwrap();
std::fs::create_dir_all(workspace.path().join("src")).unwrap();
std::fs::write(
workspace.path().join("src/Target.php"),
"<?php\nclass Target {}\n",
)
.unwrap();
std::fs::write(
workspace.path().join("src/Importer.php"),
"<?php\nuse Target;\n\nclass Importer\n{\n public function f(): Target\n {\n return new Target();\n }\n}\n",
)
.unwrap();
let mut server = TestServer::with_root(workspace.path()).await;
server.wait_for_index_ready().await;
let old_uri = server.uri("src/Target.php");
let new_uri = server.uri("src/Renamed.php");
server
.assert_request_stays_responsive_via_gate(
"workspace/willRenameFiles",
serde_json::json!({
"files": [{ "oldUri": old_uri, "newUri": new_uri }],
}),
php_lsp::backend::debug_gate::GATE_WILL_RENAME_FILES,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn will_delete_files_stays_responsive_while_use_edit_batch_is_in_flight() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
std::fs::write(
workspace.path().join("composer.json"),
r#"{"autoload": {"psr-4": {"": "src/"}}}"#,
)
.unwrap();
std::fs::create_dir_all(workspace.path().join("src")).unwrap();
std::fs::write(
workspace.path().join("src/Target.php"),
"<?php\nclass Target {}\n",
)
.unwrap();
std::fs::write(
workspace.path().join("src/Importer.php"),
"<?php\nuse Target;\n\nclass Importer\n{\n public function f(): Target\n {\n return new Target();\n }\n}\n",
)
.unwrap();
let mut server = TestServer::with_root(workspace.path()).await;
server.wait_for_index_ready().await;
let uri = server.uri("src/Target.php");
server
.assert_request_stays_responsive_via_gate(
"workspace/willDeleteFiles",
serde_json::json!({ "files": [{ "uri": uri }] }),
php_lsp::backend::debug_gate::GATE_WILL_DELETE_FILES,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn prepare_call_hierarchy_stays_responsive_while_indexed_lookup_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_call_hierarchy.php",
"<?php\nfunction gatedCallable(): void {}\ngatedCallable();\n",
)
.await;
let uri = server.uri("gated_call_hierarchy.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/prepareCallHierarchy",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 1, "character": 10 },
}),
php_lsp::backend::debug_gate::GATE_PREPARE_CALL_HIERARCHY,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn selection_range_stays_responsive_while_walk_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_selection_range.php",
"<?php\nfunction gated(): int { return 1; }\n",
)
.await;
let uri = server.uri("gated_selection_range.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/selectionRange",
serde_json::json!({
"textDocument": { "uri": uri },
"positions": [{ "line": 1, "character": 25 }],
}),
php_lsp::backend::debug_gate::GATE_SELECTION_RANGE,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn goto_implementation_stays_responsive_while_method_decl_check_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_goto_impl.php",
"<?php\ninterface Gated {\n public function work(): void;\n}\n",
)
.await;
let uri = server.uri("gated_goto_impl.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/implementation",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 1, "character": 11 },
}),
php_lsp::backend::debug_gate::GATE_GOTO_IMPLEMENTATION,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rename_stays_responsive_while_property_decl_check_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_rename.php",
"<?php\nfunction gated(): void {\n $gatedVar = 1;\n}\n",
)
.await;
let uri = server.uri("gated_rename.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/rename",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 2, "character": 8 },
"newName": "$renamedVar",
}),
php_lsp::backend::debug_gate::GATE_RENAME_VARIABLE,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn references_variable_stays_responsive_while_scope_walk_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_references_variable.php",
"<?php\nfunction gated(): void {\n $gatedVar = 1;\n echo $gatedVar;\n}\n",
)
.await;
let uri = server.uri("gated_references_variable.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/references",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 2, "character": 8 },
"context": { "includeDeclaration": true },
}),
php_lsp::backend::debug_gate::GATE_REFERENCES_VARIABLE,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn references_finds_declaration_opened_while_usage_symbol_lookup_is_gated() {
let mut server = TestServer::new().await;
server.validate_syntax(false);
server
.open(
"main.php",
"<?php\nnamespace App;\nuse App\\Services\\Logger;\n",
)
.await;
let uri = server.uri("main.php");
let slow_id = server
.send_slow_request_and_wait_for_gate(
"textDocument/references",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 2, "character": 20 },
"context": { "includeDeclaration": true },
}),
php_lsp::backend::debug_gate::GATE_USAGE_SYMBOL_RETRY,
)
.await;
server
.open(
"App/Services/Logger.php",
"<?php\nnamespace App\\Services;\nclass Logger {}\n",
)
.await;
let resp = server.release_gate_and_recv_response(slow_id).await;
assert!(resp["error"].is_null(), "references errored: {resp}");
let locations = render_locations(&resp, &server.uri(""));
assert!(
locations.contains("App/Services/Logger.php"),
"expected the just-opened companion file's declaration among the \
references, got:\n{locations}\nfull response: {resp}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn completion_resolve_stays_responsive_while_all_indexes_lookup_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_completion_resolve.php",
"<?php\nfunction gatedResolveFn(): void {}\ngatedResolveFn(",
)
.await;
let resp = server
.completion("gated_completion_resolve.php", 2, 15)
.await;
let items: Vec<_> = resp["result"]
.as_array()
.or_else(|| resp["result"]["items"].as_array())
.map(|a| a.to_vec())
.unwrap_or_default();
let item = items
.into_iter()
.find(|i| i["label"].as_str() == Some("gatedResolveFn"))
.expect("gatedResolveFn completion item not found");
server
.assert_request_stays_responsive_via_gate(
"completionItem/resolve",
item,
php_lsp::backend::debug_gate::GATE_COMPLETION_RESOLVE,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inlay_hint_resolve_stays_responsive_while_all_indexes_lookup_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_inlay_hint_resolve.php",
"<?php\nfunction gatedInlayFn(string $name): void {}\ngatedInlayFn(\"x\");\n",
)
.await;
let resp = server
.inlay_hints("gated_inlay_hint_resolve.php", 0, 0, 3, 0)
.await;
let hints: Vec<_> = resp["result"]
.as_array()
.map(|a| a.to_vec())
.unwrap_or_default();
let hint = hints
.into_iter()
.find(|h| {
h["data"]["php_lsp_fn"].as_str() == Some("gatedInlayFn") && h["tooltip"].is_null()
})
.expect("gatedInlayFn inlay hint not found");
server
.assert_request_stays_responsive_via_gate(
"inlayHint/resolve",
hint,
php_lsp::backend::debug_gate::GATE_INLAY_HINT_RESOLVE,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn on_type_formatting_stays_responsive_while_scan_is_in_flight() {
let mut server = TestServer::new().await;
server
.open(
"gated_on_type_format.php",
"<?php\nfunction gated(): void {\n echo 1;\n}\n",
)
.await;
let uri = server.uri("gated_on_type_format.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/onTypeFormatting",
serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": 3, "character": 1 },
"ch": "}",
"options": { "tabSize": 4, "insertSpaces": true },
}),
php_lsp::backend::debug_gate::GATE_ON_TYPE_FORMATTING,
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn semantic_tokens_full_delta_stays_responsive_on_large_file() {
let mut server = TestServer::new().await;
server
.open(
"gated_semtok_delta.php",
"<?php\nfunction gated(): void {}\n",
)
.await;
let uri = server.uri("gated_semtok_delta.php");
server
.assert_request_stays_responsive_via_gate(
"textDocument/semanticTokens/full/delta",
serde_json::json!({
"textDocument": { "uri": uri },
"previousResultId": "nonexistent",
}),
php_lsp::backend::debug_gate::GATE_SEMANTIC_TOKENS_FULL_DELTA,
)
.await;
}