use super::*;
use expect_test::expect;
use serde_json::json;
#[tokio::test]
async fn undefined_function_top_level() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
function _wrap(): void {
nonexistent_fn();
// ^^^^^^^^^^^^^^^^ error: nonexistent_fn
}
"#,
)
.await;
}
#[tokio::test]
async fn undefined_function_inside_function() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
function wrapper(): void {
nonexistent_fn();
// ^^^^^^^^^^^^^^^^ error: nonexistent_fn
}
"#,
)
.await;
}
#[tokio::test]
async fn undefined_function_inside_method() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
class C {
public function run(): void {
nonexistent_fn();
// ^^^^^^^^^^^^^^^^ error: nonexistent_fn
}
}
"#,
)
.await;
}
#[tokio::test]
async fn undefined_function_inside_namespaced_method() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
namespace LspTest;
class Broken {
public function f(): void {
nonexistent_fn();
// ^^^^^^^^^^^^^^^^ error: nonexistent_fn
}
}
"#,
)
.await;
}
#[tokio::test]
async fn issue_170_errors_inside_namespaced_method_detected() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
namespace LspTest;
class Broken
{
public int $count = 0;
public function bump(): int
{
$this->count++;
return $this->count;
}
public function obviouslyBroken(): int
{
nonexistent_function();
// ^^^^^^^^^^^^^^^^^^^^^^ error: nonexistent_function
$x = new UnknownClass();
// ^^^^^^^^^^^^ error: UnknownClass
return 0;
}
}
"#,
)
.await;
}
#[tokio::test]
async fn undefined_class_in_new() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
function _wrap(): void {
$x = new UnknownClass();
// ^^^^^^^^^^^^ error: UnknownClass
}
"#,
)
.await;
}
#[tokio::test]
async fn clean_file_has_no_diagnostics() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
function f(string $x): string { return $x; }
f('ok');
"#,
)
.await;
}
#[tokio::test]
async fn diagnostics_clear_after_fix() {
let mut s = TestServer::new().await;
let notif = s.open("fix.php", "<?php\nundefined_fn();\n").await;
assert!(
!notif["params"]["diagnostics"]
.as_array()
.unwrap_or(&vec![])
.is_empty()
);
let after = s.change("fix.php", 2, "<?php\n").await;
assert!(
after["params"]["diagnostics"]
.as_array()
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn parse_error_emits_diagnostic() {
let mut s = TestServer::new().await;
let notif = s.open("bad.php", "<?php\nfunction f( {\n").await;
assert!(
!notif["params"]["diagnostics"]
.as_array()
.unwrap_or(&vec![])
.is_empty(),
"expected parse diagnostic for malformed PHP"
);
}
#[tokio::test]
async fn multiple_diagnostics_same_file() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
function _wrap(): void {
one_undefined();
// ^^^^^^^^^^^^^^^ error: one_undefined
two_undefined();
// ^^^^^^^^^^^^^^^ error: two_undefined
}
"#,
)
.await;
}
#[tokio::test]
async fn pull_diagnostics_returns_report() {
let mut server = TestServer::new().await;
server.open("pull_diag.php", "<?php\n$x = 1;\n").await;
let resp = server.pull_diagnostics("pull_diag.php").await;
assert!(
resp["error"].is_null(),
"textDocument/diagnostic error: {:?}",
resp
);
let result = &resp["result"];
assert!(!result.is_null(), "expected non-null diagnostic report");
assert_eq!(
result["kind"].as_str(),
Some("full"),
"first pull must return kind='full', got: {:?}",
result["kind"]
);
let items = result["items"]
.as_array()
.expect("'items' array in full report");
assert!(
items.is_empty(),
"clean file should have zero diagnostics, got: {items:?}"
);
}
#[tokio::test]
async fn workspace_diagnostic_clean_file() {
let mut server = TestServer::new().await;
server.open("ws_clean.php", "<?php\n$x = 1;\n").await;
let resp = server.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &server.uri(""));
expect![[r#"
ws_clean.php
<clean>"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn workspace_diagnostic_single_file_with_errors() {
let mut server = TestServer::new().await;
server
.open(
"ws_error.php",
"<?php\nnonexistent_function();\n$x = new UnknownClass();\n",
)
.await;
let resp = server.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &server.uri(""));
expect![[r#"
ws_error.php
1:0 Function nonexistent_function() is not defined [UndefinedFunction] (error)
2:9 Class UnknownClass does not exist [UndefinedClass] (error)"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn workspace_diagnostic_multiple_files_mixed() {
let mut server = TestServer::new().await;
server
.open("ws_clean.php", "<?php\nfunction foo(): void {}\n")
.await;
server.open("ws_error.php", "<?php\nbar();\n").await;
server
.open("ws_another.php", "<?php\n$x = new Missing();\n")
.await;
let resp = server.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &server.uri(""));
expect![[r#"
ws_another.php
1:9 Class Missing does not exist [UndefinedClass] (error)
ws_clean.php
<clean>
ws_error.php
1:0 Function bar() is not defined [UndefinedFunction] (error)"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn workspace_diagnostic_multiple_errors_same_file() {
let mut server = TestServer::new().await;
server
.open(
"multi_err.php",
"<?php\none_undefined();\ntwo_undefined();\nthree_undefined();\n",
)
.await;
let resp = server.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &server.uri(""));
expect![[r#"
multi_err.php
1:0 Function one_undefined() is not defined [UndefinedFunction] (error)
2:0 Function two_undefined() is not defined [UndefinedFunction] (error)
3:0 Function three_undefined() is not defined [UndefinedFunction] (error)"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn workspace_diagnostic_after_edit() {
let mut server = TestServer::new().await;
server.open("ws_fix.php", "<?php\nundefined_fn();\n").await;
let resp1 = server.workspace_diagnostic().await;
let out1 = render_workspace_diagnostic(&resp1, &server.uri(""));
assert!(out1.contains("undefined_fn"));
server.change("ws_fix.php", 2, "<?php\n").await;
let resp2 = server.workspace_diagnostic().await;
let out2 = render_workspace_diagnostic(&resp2, &server.uri(""));
expect![[r#"
ws_fix.php
<clean>"#]]
.assert_eq(&out2);
}
#[tokio::test]
async fn workspace_diagnostic_empty_workspace() {
let mut server = TestServer::new().await;
let resp = server.workspace_diagnostic().await;
assert!(
resp["error"].is_null(),
"workspace/diagnostic error: {:?}",
resp
);
let items = resp["result"]["items"]
.as_array()
.expect("expected 'items' array in workspace diagnostic report");
assert!(
items.is_empty(),
"empty workspace should have no diagnostic items, got: {items:?}"
);
}
#[tokio::test]
async fn workspace_diagnostic_named_arguments() {
let mut server = TestServer::new().await;
server
.open(
"ws_named_args.php",
"<?php\nfunction foo(int $a, int $b): void {}\nfoo(a: 1, b: 2, a: 3);\n",
)
.await;
let resp = server.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &server.uri(""));
expect![[r#"
ws_named_args.php
2:16 foo() has no parameter named $a [InvalidNamedArgument] (error)"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn workspace_diagnostic_with_parse_error() {
let mut server = TestServer::new().await;
server
.open("ws_parse_error.php", "<?php\nfunction f( {\n")
.await;
let resp = server.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &server.uri(""));
assert!(out.contains("ws_parse_error.php"));
let items = resp["result"]["items"].as_array().unwrap();
assert!(!items.is_empty(), "parse error should produce diagnostic");
}
#[tokio::test]
async fn workspace_diagnostic_circular_inheritance() {
let mut server = TestServer::new().await;
server
.open(
"ws_circular.php",
"<?php\nclass A extends B {}\nclass B extends A {}\n",
)
.await;
let resp = server.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &server.uri(""));
expect![[r#"
ws_circular.php
2:0 Class B has a circular inheritance chain [CircularInheritance] (error)"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn regression_result_id_is_present() {
let mut server = TestServer::new().await;
server.open("test1.php", "<?php\n$x = 1;\n").await;
let resp = server.workspace_diagnostic().await;
let items = resp["result"]["items"].as_array().unwrap();
assert_eq!(items.len(), 1);
let result_id = &items[0]["resultId"];
assert!(
!result_id.is_null(),
"REGRESSION: resultId must be non-null. \
Clients need this to implement caching via previousResultIds."
);
assert!(
result_id.is_string(),
"resultId should be a string (format: v1:hash)"
);
}
#[tokio::test]
async fn regression_parse_error_files_included() {
let mut server = TestServer::new().await;
server
.open("parse_only.php", "<?php\nfunction broken( {\n")
.await;
let resp = server.workspace_diagnostic().await;
let items = resp["result"]["items"].as_array().unwrap();
assert!(
!items.is_empty(),
"Parse error files must appear in workspace/diagnostic"
);
assert!(
items[0]["items"]
.as_array()
.map(|a| !a.is_empty())
.unwrap_or(false),
"File should have diagnostics (parse error)"
);
}
#[tokio::test]
async fn regression_result_id_unique_per_file() {
let mut server = TestServer::new().await;
server.open("file1.php", "<?php\necho 'a';\n").await;
server.open("file2.php", "<?php\necho 'b';\n").await;
let resp = server.workspace_diagnostic().await;
let items = resp["result"]["items"].as_array().unwrap();
assert_eq!(items.len(), 2);
let id1 = items[0]["resultId"].as_str().unwrap();
let id2 = items[1]["resultId"].as_str().unwrap();
assert!(!id1.is_empty(), "file1 must have result_id");
assert!(!id2.is_empty(), "file2 must have result_id");
assert_ne!(
id1, id2,
"Different files with different content should have different result_ids"
);
}
#[tokio::test]
async fn regression_result_id_changes_with_diagnostics() {
let mut server = TestServer::new().await;
server.open("changetest.php", "<?php\n$x = 1;\n").await;
let resp1 = server.workspace_diagnostic().await;
let items1 = resp1["result"]["items"].as_array().unwrap();
let id_clean = items1[0]["resultId"].as_str().unwrap().to_string();
server
.change("changetest.php", 2, "<?php\nundefined_function();\n")
.await;
let resp2 = server.workspace_diagnostic().await;
let items2 = resp2["result"]["items"].as_array().unwrap();
let id_with_error = items2[0]["resultId"].as_str().unwrap().to_string();
assert_ne!(
id_clean, id_with_error,
"result_id must change when diagnostics change"
);
assert!(
!items2[0]["items"].as_array().unwrap().is_empty(),
"File should have diagnostics after adding error"
);
server.change("changetest.php", 2, "<?php\n$x = 1;\n").await;
let resp3 = server.workspace_diagnostic().await;
let items3 = resp3["result"]["items"].as_array().unwrap();
let id_fixed = items3[0]["resultId"].as_str().unwrap().to_string();
assert_eq!(
id_clean, id_fixed,
"result_id should revert when diagnostics return to original state"
);
}
#[tokio::test]
async fn regression_document_and_workspace_diagnostic_consistency() {
let mut server = TestServer::new().await;
server
.open("consistency.php", "<?php\necho 'test';\n")
.await;
let doc_resp = server.pull_diagnostics("consistency.php").await;
let ws_resp = server.workspace_diagnostic().await;
let doc_result_id = &doc_resp["result"]["resultId"];
let ws_result = &ws_resp["result"];
let ws_item = &ws_result["items"][0];
let ws_result_id = &ws_item["resultId"];
assert!(
!doc_result_id.is_null(),
"document/diagnostic must have resultId"
);
assert!(
!ws_result_id.is_null(),
"workspace/diagnostic must have resultId"
);
let doc_id = doc_result_id.as_str();
let ws_id = ws_result_id.as_str();
assert!(
doc_id.is_some() && ws_id.is_some(),
"resultIds must be strings"
);
assert_eq!(
doc_id, ws_id,
"Both endpoints should return same resultId for same file"
);
}
#[tokio::test]
async fn regression_error_handling() {
let mut server = TestServer::new().await;
server.open("test.php", "<?php\n").await;
let resp = server.workspace_diagnostic().await;
assert!(
resp["error"].is_null(),
"workspace_diagnostic request should not error for valid files"
);
assert!(
resp["result"]["items"].is_array(),
"Response should contain items array"
);
}
#[tokio::test]
async fn regression_result_id_is_stable() {
let mut server = TestServer::new().await;
server.open("stable.php", "<?php\necho 'hello';\n").await;
let resp1 = server.workspace_diagnostic().await;
let items1 = resp1["result"]["items"].as_array().unwrap();
let id1 = items1[0]["resultId"].as_str().unwrap().to_string();
let resp2 = server.workspace_diagnostic().await;
let items2 = resp2["result"]["items"].as_array().unwrap();
let id2 = items2[0]["resultId"].as_str().unwrap().to_string();
assert_eq!(
id1, id2,
"result_id must be stable for unchanged file (deterministic hashing)"
);
}
#[tokio::test]
async fn regression_result_id_with_mixed_diagnostics() {
let mut server = TestServer::new().await;
server
.open(
"semantic.php",
"<?php\nfunction foo() {}\nundefined_func();\n",
)
.await;
let resp1 = server.workspace_diagnostic().await;
let items1 = resp1["result"]["items"].as_array().unwrap();
let id_semantic = items1[0]["resultId"].as_str().unwrap();
server
.open("parse.php", "<?php\nfunction broken( {\n")
.await;
let resp2 = server.workspace_diagnostic().await;
let items2 = resp2["result"]["items"]
.as_array()
.unwrap()
.iter()
.find(|item| {
item["uri"]
.as_str()
.map(|uri| uri.contains("parse.php"))
.unwrap_or(false)
})
.unwrap();
let id_parse = items2["resultId"].as_str().unwrap();
assert_ne!(
id_semantic, id_parse,
"result_id should differ for different diagnostic types"
);
}
#[tokio::test]
async fn regression_params_structure_accepted() {
let mut server = TestServer::new().await;
server.open("param_test.php", "<?php\necho 'test';\n").await;
let resp = server.workspace_diagnostic().await;
assert!(
resp["error"].is_null(),
"workspace_diagnostic must accept params without error"
);
assert!(
resp["result"]["items"].is_array(),
"Should return items array"
);
}
#[tokio::test]
async fn regression_result_id_reflects_all_diagnostic_properties() {
let mut server = TestServer::new().await;
server
.open(
"props1.php",
"<?php\nfunction test() {}\nundefined_func();\n",
)
.await;
let resp1 = server.workspace_diagnostic().await;
let items1 = resp1["result"]["items"].as_array().unwrap();
assert_eq!(items1.len(), 1);
let diags1 = items1[0]["items"].as_array().unwrap();
assert!(
!diags1.is_empty(),
"Should have undefined function diagnostic"
);
let result_id_1 = items1[0]["resultId"].as_str().unwrap().to_string();
server
.open("props2.php", "<?php\necho $undefined_var;\n")
.await;
let resp2 = server.workspace_diagnostic().await;
let items2 = resp2["result"]["items"]
.as_array()
.unwrap()
.iter()
.find(|item| {
item["uri"]
.as_str()
.map(|uri| uri.contains("props2.php"))
.unwrap_or(false)
})
.unwrap();
let result_id_2 = items2["resultId"].as_str().unwrap();
assert_ne!(
result_id_1, result_id_2,
"Different diagnostic codes should produce different result_ids \
(even if both are 1 error). Hash must include code field."
);
}
#[tokio::test]
#[ignore = "O(N²) cross-file re-analysis per did_open — fix performance before re-enabling"]
async fn edge_case_workspace_diagnostic_many_files() {
let mut server = TestServer::new().await;
for i in 0..10 {
let code = format!("<?php\nfunction test{i}() {{ return 42; }}\n");
server.open(&format!("file{i}.php"), &code).await;
}
let resp = server.workspace_diagnostic().await;
let items = resp["result"]["items"].as_array().unwrap();
assert_eq!(
items.len(),
10,
"workspace_diagnostic should return diagnostics for all open files"
);
for item in items {
assert!(
item["items"].as_array().unwrap().is_empty(),
"Clean files should have empty diagnostics array"
);
}
}
#[tokio::test]
async fn edge_case_file_closed_during_workspace_diagnostic() {
let mut server = TestServer::new().await;
server.open("temp.php", "<?php\nundefined();\n").await;
let resp = server.workspace_diagnostic().await;
assert!(
resp["error"].is_null(),
"workspace_diagnostic should handle file closure gracefully"
);
}
#[tokio::test]
async fn requests_on_parse_error_file_do_not_error() {
let mut server = TestServer::new().await;
let notif = server
.open("broken.php", "<?php\nfunction f( $x { // missing ): body\n")
.await;
let diags = notif["params"]["diagnostics"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
!diags.is_empty(),
"expected parse diagnostics for broken source"
);
let resp = server.hover("broken.php", 1, 10).await;
assert!(resp["error"].is_null(), "hover errored: {resp:?}");
let resp = server.document_symbols("broken.php").await;
assert!(resp["error"].is_null(), "documentSymbol errored: {resp:?}");
let resp = server.folding_range("broken.php").await;
assert!(resp["error"].is_null(), "foldingRange errored: {resp:?}");
}
#[tokio::test]
async fn diagnostics_published_on_did_change_for_undefined_function() {
let mut server = TestServer::new().await;
server.open("change_test.php", "<?php\n").await;
let notif = server
.change("change_test.php", 2, "<?php\nnonexistent_function();\n")
.await;
let has = notif["params"]["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"].as_str() == Some("UndefinedFunction"));
assert!(has, "expected UndefinedFunction after didChange: {notif:?}");
}
#[tokio::test]
async fn did_open_reports_deprecated_call_warning() {
let mut server = TestServer::new().await;
let notif = server
.open(
"deprecated_test.php",
"<?php\n/** @deprecated Use newFunc() instead */\nfunction oldFunc(): void {}\n\noldFunc();\n",
)
.await;
let diags = notif["params"]["diagnostics"].as_array().unwrap();
let hit = diags.iter().find(|d| {
d["code"].as_str() == Some("DeprecatedCall")
&& d["message"]
.as_str()
.map(|m| m.contains("oldFunc"))
.unwrap_or(false)
});
assert!(
hit.is_some(),
"expected DeprecatedCall diagnostic for oldFunc on did_open, got: {diags:?}"
);
}
#[tokio::test]
async fn undefined_function_detected_in_static_method() {
let mut server = TestServer::new().await;
server
.check_diagnostics(
r#"<?php
class Factory {
public static function build(): void {
nonexistent_function();
// ^^^^^^^^^^^^^^^^^^^^^^ error: nonexistent_function
}
}
"#,
)
.await;
}
#[tokio::test]
async fn undefined_function_detected_in_arrow_function() {
let mut server = TestServer::new().await;
server
.check_diagnostics(
r#"<?php
$fn = fn() => nonexistent_function();
// ^^^^^^^^^^^^^^^^^^^^^^ error: nonexistent_function
"#,
)
.await;
}
#[tokio::test]
async fn undefined_function_detected_in_trait_method() {
let mut server = TestServer::new().await;
server
.check_diagnostics(
r#"<?php
trait Auditable {
public function audit(): void {
nonexistent_function();
// ^^^^^^^^^^^^^^^^^^^^^^ error: nonexistent_function
}
}
"#,
)
.await;
}
#[tokio::test]
async fn undefined_function_detected_in_closure() {
let mut server = TestServer::new().await;
server
.check_diagnostics(
r#"<?php
$fn = function() {
nonexistent_function();
// ^^^^^^^^^^^^^^^^^^^^^^ error: nonexistent_function
};
"#,
)
.await;
}
#[tokio::test]
async fn argument_count_too_few_detected() {
let mut server = TestServer::new().await;
server
.check_diagnostics(
r#"<?php
function needs_two(string $a, string $b): void {}
function wrap(): void {
needs_two('x');
// ^^^^^^^^^^^^^^ error: needs_two
}
"#,
)
.await;
}
#[tokio::test]
async fn argument_type_mismatch_detected() {
let mut server = TestServer::new().await;
server
.check_diagnostics(
r#"<?php
function takes_string(string $s): void {}
function wrap(): void {
takes_string(42);
// ^^ error: takes_string
}
"#,
)
.await;
}
#[tokio::test]
async fn psr4_imported_class_not_flagged_before_workspace_scan() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
std::fs::write(
tmp.path().join("src/Model/Entity.php"),
"<?php\nnamespace App\\Model;\nclass Entity {}\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Service")).unwrap();
let handler_src = "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n";
std::fs::write(tmp.path().join("src/Service/Handler.php"), handler_src).unwrap();
let mut s = TestServer::with_root(tmp.path()).await;
s.open("src/Service/Handler.php", handler_src).await;
let resp = s.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &s.uri(""));
expect![[r#"
src/Service/Handler.php
<clean>"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn argument_count_too_many_detected() {
let mut server = TestServer::new().await;
server
.check_diagnostics(
r#"<?php
function takes_one(string $s): void {}
function wrap(): void {
takes_one('a', 'b', 'c');
// ^^^ error: takes_one
}
"#,
)
.await;
}
#[tokio::test]
async fn new_expr_with_use_import_not_flagged_as_undefined_class() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
std::fs::write(
tmp.path().join("src/Model/Entity.php"),
"<?php\nnamespace App\\Model;\nclass Entity {}\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Service")).unwrap();
let src = "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(): void { $e = new Entity(); }\n";
std::fs::write(tmp.path().join("src/Service/Handler.php"), src).unwrap();
let mut s = TestServer::with_root(tmp.path()).await;
s.open("src/Service/Handler.php", src).await;
let resp = s.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &s.uri(""));
expect![[r#"
src/Service/Handler.php
<clean>"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn new_expr_with_explicit_use_alias_not_flagged_as_undefined_class() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
std::fs::write(
tmp.path().join("src/Model/Entity.php"),
"<?php\nnamespace App\\Model;\nclass Entity {}\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Service")).unwrap();
let src = "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity as EntityAlias;\nfunction handle(): void { $e = new EntityAlias(); }\n";
std::fs::write(tmp.path().join("src/Service/Handler.php"), src).unwrap();
let mut s = TestServer::with_root(tmp.path()).await;
s.open("src/Service/Handler.php", src).await;
let resp = s.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &s.uri(""));
expect![[r#"
src/Service/Handler.php
<clean>"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn new_expr_fully_qualified_not_flagged_as_undefined_class() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
std::fs::write(
tmp.path().join("src/Model/Entity.php"),
"<?php\nnamespace App\\Model;\nclass Entity {}\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Service")).unwrap();
let src = "<?php\nnamespace App\\Service;\nfunction handle(): void { $e = new \\App\\Model\\Entity(); }\n";
std::fs::write(tmp.path().join("src/Service/Handler.php"), src).unwrap();
let mut s = TestServer::with_root(tmp.path()).await;
s.open("src/Service/Handler.php", src).await;
let resp = s.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &s.uri(""));
expect![[r#"
src/Service/Handler.php
<clean>"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn new_expr_truly_unknown_class_is_flagged() {
let mut server = TestServer::new().await;
server
.check_diagnostics(
r#"<?php
function _wrap(): void {
$x = new TrulyNonExistentClass9z();
// ^^^^^^^^^^^^^^^^^^^^^^^ error: TrulyNonExistentClass9z
}
"#,
)
.await;
}
#[tokio::test]
async fn duplicate_named_arg_in_function_call() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
function foo(int $a, int $b): void {}
foo(a: 1, b: 2, a: 3);
// ^^^^ error: foo() has no parameter named $a
"#,
)
.await;
}
#[tokio::test]
async fn duplicate_named_arg_in_method_call() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
class C {
public function run(int $x, int $y): void {}
}
(new C())->run(x: 1, y: 2, x: 99);
// ^^^^^ error: run() has no parameter named $x
"#,
)
.await;
}
#[tokio::test]
async fn duplicate_named_arg_in_constructor() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
class Point {
public function __construct(public int $x, public int $y) {}
}
new Point(x: 0, y: 1, x: 2);
// ^^^^ error: Point::__construct() has no parameter named $x
"#,
)
.await;
}
#[tokio::test]
async fn positional_after_named_arg() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
function bar(int $a, int $b): void {}
bar(a: 1, 2);
// ^ error: cannot use positional argument after named argument
// ^ error: bar() has no parameter named $#2
"#,
)
.await;
}
#[tokio::test]
async fn valid_named_args_produce_no_diagnostic() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
function greet(string $name, int $times): void {}
greet(name: 'Alice', times: 3);
"#,
)
.await;
}
#[tokio::test]
async fn circular_inheritance_self_extends() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
class A extends A {}
//^^^^^^^^^^^^^^^^^^^^ error: Class A has a circular inheritance chain
"#,
)
.await;
}
#[tokio::test]
async fn circular_inheritance_two_class_cycle() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
class A extends B {}
class B extends A {}
//^^^^^^^^^^^^^^^^^^^^ error: Class B has a circular inheritance chain
"#,
)
.await;
}
#[tokio::test]
async fn circular_inheritance_three_class_cycle() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
class A extends B {}
class B extends C {}
class C extends A {}
//^^^^^^^^^^^^^^^^^^^^ error: Class C has a circular inheritance chain
"#,
)
.await;
}
#[tokio::test]
async fn builtin_restore_error_handler_is_known() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"<?php
function _wrap(): void {
restore_error_handler();
}
"#,
)
.await;
}
#[tokio::test]
async fn user_polyfill_does_not_break_builtin_restore_error_handler() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"//- /src/polyfill.php
<?php
if (!function_exists('restore_error_handler')) {
function restore_error_handler(): bool { return true; }
}
//- /src/main.php
<?php
function _wrap(): void {
restore_error_handler();
}
"#,
)
.await;
}
#[tokio::test]
async fn user_unconditional_redefinition_does_not_break_call() {
let mut s = TestServer::new().await;
s.check_diagnostics(
r#"//- /src/redef.php
<?php
function restore_error_handler(): bool { return true; }
//- /src/main.php
<?php
function _wrap(): void {
restore_error_handler();
}
"#,
)
.await;
}
#[tokio::test]
async fn circular_inheritance_suppressed_when_type_errors_disabled() {
let (mut s, _resp) = TestServer::new_with_options(json!({
"diagnostics": { "typeErrors": false }
}))
.await;
s.check_diagnostics(
r#"<?php
class A extends A {}
"#,
)
.await;
}
#[tokio::test]
async fn workspace_diagnostic_unchanged_on_repeated_request() {
let mut server = TestServer::new().await;
server.open("stable.php", "<?php\n$x = 1;\n").await;
let resp1 = server.workspace_diagnostic().await;
let items1 = resp1["result"]["items"].as_array().unwrap();
assert_eq!(items1[0]["kind"].as_str().unwrap(), "full");
let result_id = items1[0]["resultId"].as_str().unwrap().to_string();
let uri = items1[0]["uri"].as_str().unwrap().to_string();
let resp2 = server
.workspace_diagnostic_with_prev(vec![(uri, result_id)])
.await;
let items2 = resp2["result"]["items"].as_array().unwrap();
assert_eq!(
items2[0]["kind"].as_str().unwrap(),
"unchanged",
"second request with matching result_id must return Unchanged"
);
let out2 = render_workspace_diagnostic(&resp2, &server.uri(""));
expect![[r#"
stable.php
<unchanged>"#]]
.assert_eq(&out2);
}
#[tokio::test]
async fn workspace_diagnostic_full_after_file_change() {
let mut server = TestServer::new().await;
server.open("changing.php", "<?php\n$x = 1;\n").await;
let resp1 = server.workspace_diagnostic().await;
let items1 = resp1["result"]["items"].as_array().unwrap();
let old_id = items1[0]["resultId"].as_str().unwrap().to_string();
let uri = items1[0]["uri"].as_str().unwrap().to_string();
server
.change("changing.php", 2, "<?php\nundefined_fn();\n")
.await;
let resp2 = server
.workspace_diagnostic_with_prev(vec![(uri, old_id.clone())])
.await;
let items2 = resp2["result"]["items"].as_array().unwrap();
assert_eq!(
items2[0]["kind"].as_str().unwrap(),
"full",
"stale previousResultId must yield Full"
);
let new_id = items2[0]["resultId"].as_str().unwrap();
assert_ne!(
new_id, old_id,
"result_id must change when diagnostics change"
);
let out2 = render_workspace_diagnostic(&resp2, &server.uri(""));
expect![[r#"
changing.php
1:0 Function undefined_fn() is not defined [UndefinedFunction] (error)"#]]
.assert_eq(&out2);
}
#[tokio::test]
async fn workspace_diagnostic_mixed_unchanged_and_full() {
let mut server = TestServer::new().await;
server.open("stable.php", "<?php\n$x = 1;\n").await;
server.open("breaking.php", "<?php\n$y = 2;\n").await;
let resp1 = server.workspace_diagnostic().await;
let items1 = resp1["result"]["items"].as_array().unwrap();
let stable = items1
.iter()
.find(|i| i["uri"].as_str().unwrap_or("").contains("stable.php"))
.unwrap();
let prev = vec![(
stable["uri"].as_str().unwrap().to_string(),
stable["resultId"].as_str().unwrap().to_string(),
)];
server
.change("breaking.php", 2, "<?php\nundefined_fn();\n")
.await;
let resp2 = server.workspace_diagnostic_with_prev(prev).await;
let out2 = render_workspace_diagnostic(&resp2, &server.uri(""));
expect![[r#"
breaking.php
1:0 Function undefined_fn() is not defined [UndefinedFunction] (error)
stable.php
<unchanged>"#]]
.assert_eq(&out2);
}
#[tokio::test]
async fn workspace_diagnostic_new_file_always_full() {
let mut server = TestServer::new().await;
server.open("existing.php", "<?php\n$x = 1;\n").await;
let resp1 = server.workspace_diagnostic().await;
let items1 = resp1["result"]["items"].as_array().unwrap();
let prev = vec![(
items1[0]["uri"].as_str().unwrap().to_string(),
items1[0]["resultId"].as_str().unwrap().to_string(),
)];
server.open("newfile.php", "<?php\n$y = 2;\n").await;
let resp2 = server.workspace_diagnostic_with_prev(prev).await;
let items2 = resp2["result"]["items"].as_array().unwrap();
let new_item = items2
.iter()
.find(|i| i["uri"].as_str().unwrap_or("").contains("newfile.php"))
.expect("newfile.php must appear in response");
assert_eq!(
new_item["kind"].as_str().unwrap(),
"full",
"file absent from previousResultIds must return Full"
);
}
#[tokio::test]
async fn workspace_diagnostic_wrong_result_id_returns_full() {
let mut server = TestServer::new().await;
server.open("test.php", "<?php\n$x = 1;\n").await;
let uri = server.uri("test.php");
let resp = server
.workspace_diagnostic_with_prev(vec![(uri, "wrong-result-id".to_string())])
.await;
let items = resp["result"]["items"].as_array().unwrap();
assert_eq!(
items[0]["kind"].as_str().unwrap(),
"full",
"wrong previousResultId must produce Full, not Unchanged"
);
}
#[tokio::test]
async fn workspace_diagnostic_empty_prev_ids_all_full() {
let mut server = TestServer::new().await;
server.open("a.php", "<?php\n$x = 1;\n").await;
server.open("b.php", "<?php\n$y = 2;\n").await;
let resp = server.workspace_diagnostic().await;
let items = resp["result"]["items"].as_array().unwrap();
for item in items {
assert_eq!(
item["kind"].as_str().unwrap(),
"full",
"empty previousResultIds must yield Full for all files"
);
}
}
#[tokio::test]
async fn new_expr_with_grouped_use_import_not_flagged_as_undefined_class() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
std::fs::write(
tmp.path().join("src/Model/Foo.php"),
"<?php\nnamespace App\\Model;\nclass Foo {}\n",
)
.unwrap();
std::fs::write(
tmp.path().join("src/Model/Bar.php"),
"<?php\nnamespace App\\Model;\nclass Bar {}\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Service")).unwrap();
let src = "<?php\nnamespace App\\Service;\nuse App\\Model\\{Foo, Bar};\nfunction handle(): void { $a = new Foo(); $b = new Bar(); }\n";
std::fs::write(tmp.path().join("src/Service/Handler.php"), src).unwrap();
let mut s = TestServer::with_root(tmp.path()).await;
s.open("src/Service/Handler.php", src).await;
let resp = s.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &s.uri(""));
expect![[r#"
src/Service/Handler.php
<clean>"#]]
.assert_eq(&out);
}
#[tokio::test]
async fn use_imported_interface_in_implements_not_flagged_as_undefined_class() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Contract")).unwrap();
std::fs::write(
tmp.path().join("src/Contract/Runnable.php"),
"<?php\nnamespace App\\Contract;\ninterface Runnable { public function run(): void; }\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src/Service")).unwrap();
let src = "<?php\nnamespace App\\Service;\nuse App\\Contract\\Runnable;\nclass Worker implements Runnable { public function run(): void {} }\n";
std::fs::write(tmp.path().join("src/Service/Worker.php"), src).unwrap();
let mut s = TestServer::with_root(tmp.path()).await;
s.open("src/Service/Worker.php", src).await;
let resp = s.workspace_diagnostic().await;
let out = render_workspace_diagnostic(&resp, &s.uri(""));
expect![[r#"
src/Service/Worker.php
<clean>"#]]
.assert_eq(&out);
}