php-lsp 0.5.0

A PHP Language Server Protocol implementation
Documentation
//! Incremental `didChange` correctness: cache invalidation, cross-file republish,
//! burst debouncing, and reopen stability.

use super::*;

use expect_test::expect;

fn has_code(notif: &serde_json::Value, code: &str) -> bool {
    notif["params"]["diagnostics"]
        .as_array()
        .map(|arr| arr.iter().any(|d| d["code"].as_str() == Some(code)))
        .unwrap_or(false)
}

fn render_def_location(resp: &serde_json::Value, root_uri: &str) -> String {
    common::render_locations(resp, root_uri)
}

#[tokio::test]
async fn hover_reflects_didchange_new_symbol() {
    let mut server = TestServer::new().await;
    server.open("edit.php", "<?php\n").await;

    server
        .change(
            "edit.php",
            2,
            "<?php\nfunction greeter(string $name): string { return $name; }\n",
        )
        .await;

    let out = server.hover("edit.php", 1, 10).await;
    let rendered = common::render_hover(&out);
    expect![[r#"
        ```php
        function greeter(string $name): string
        ```"#]]
    .assert_eq(&rendered);
}

#[tokio::test]
async fn definition_cache_is_invalidated_after_didchange() {
    let mut server = TestServer::new().await;
    let root_uri = server.uri("");
    server
        .open(
            "ren.php",
            "<?php\nfunction oldName(): void {}\noldName();\n",
        )
        .await;

    let resp_v1 = server.definition("ren.php", 2, 1).await;
    let out_v1 = render_def_location(&resp_v1, &root_uri);
    expect!["ren.php:1:9-1:16"].assert_eq(&out_v1);

    server
        .change(
            "ren.php",
            2,
            "<?php\n\nfunction newName(): void {}\nnewName();\n",
        )
        .await;

    let resp_v2 = server.definition("ren.php", 3, 1).await;
    let out_v2 = render_def_location(&resp_v2, &root_uri);
    // Cache must be invalidated: location moves to line 2 (added blank line)
    expect!["ren.php:2:9-2:16"].assert_eq(&out_v2);
}

#[tokio::test]
async fn references_reflect_didchange_additions_and_removals() {
    let mut server = TestServer::new().await;
    server
        .open("refs.php", "<?php\nfunction target(): void {}\ntarget();\n")
        .await;

    server
        .change(
            "refs.php",
            2,
            "<?php\nfunction target(): void {}\ntarget();\ntarget();\n",
        )
        .await;

    let resp = server.references("refs.php", 1, 9, false).await;
    let refs = resp["result"].as_array().expect("references array");
    let ref_count = refs.len();
    expect!["2"].assert_eq(&ref_count.to_string());

    server
        .change(
            "refs.php",
            3,
            "<?php\nfunction target(): void {}\ntarget();\n",
        )
        .await;

    let resp = server.references("refs.php", 1, 9, false).await;
    let refs = resp["result"].as_array().expect("references array");
    let ref_count = refs.len();
    expect!["1"].assert_eq(&ref_count.to_string());
}

#[tokio::test]
async fn diagnostics_replaced_not_appended_on_didchange() {
    let mut server = TestServer::new().await;
    let notif = server.open("d.php", "<?php\nbroken(;\n").await;
    let first_count = notif["params"]["diagnostics"]
        .as_array()
        .map(|a| a.len())
        .unwrap_or(0);
    assert!(first_count > 0, "expected parse error on open");

    let notif = server.change("d.php", 2, "<?php\n").await;
    let diags = notif["params"]["diagnostics"]
        .as_array()
        .cloned()
        .unwrap_or_default();
    assert!(
        diags.is_empty(),
        "diagnostics from prior version must be cleared, got: {diags:?}"
    );
}

#[tokio::test]
async fn cross_file_diagnostics_refresh_on_next_didchange() {
    let mut server = TestServer::new().await;
    server.open("dep.php", "<?php\nclass Widget {}\n").await;
    let notif = server.open("user.php", "<?php\n$w = new Widget();\n").await;
    assert!(
        !has_code(&notif, "UndefinedClass"),
        "Widget is defined — expected no UndefinedClass initially: {:?}",
        notif["params"]["diagnostics"]
    );

    server
        .change("dep.php", 2, "<?php\nclass Gadget {}\n")
        .await;

    let notif = server
        .change("user.php", 2, "<?php\n$w = new Widget();\n")
        .await;
    assert!(
        has_code(&notif, "UndefinedClass"),
        "after renaming Widget→Gadget in dep.php, user.php must report UndefinedClass: {:?}",
        notif["params"]["diagnostics"]
    );
}

#[tokio::test]
async fn cross_file_diagnostics_republish_on_dependency_change() {
    let mut server = TestServer::new().await;
    server.open("dep2.php", "<?php\nclass Widget2 {}\n").await;
    server
        .open("user2.php", "<?php\n$w = new Widget2();\n")
        .await;

    server
        .change("dep2.php", 2, "<?php\nclass Gadget2 {}\n")
        .await;

    let uri = server.uri("user2.php");
    let notif = server.client().wait_for_diagnostics(&uri).await;
    assert!(
        has_code(&notif, "UndefinedClass"),
        "expected proactive UndefinedClass on user2.php after dependency edit"
    );
}

#[tokio::test]
async fn true_burst_didchange_converges_to_final_text() {
    let mut server = TestServer::new().await;
    server.open("burst.php", "<?php\n").await;

    let uri = server.uri("burst.php");
    for v in 2..=6 {
        let text = format!("<?php\nfunction f{v}(): void {{}}\n");
        server
            .client()
            .notify(
                "textDocument/didChange",
                serde_json::json!({
                    "textDocument": { "uri": uri, "version": v },
                    "contentChanges": [{ "text": text }],
                }),
            )
            .await;
    }

    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
    loop {
        if std::time::Instant::now() >= deadline {
            panic!("timed out waiting for burst to settle");
        }
        let resp = server.hover("burst.php", 1, 10).await;
        let contents = resp["result"]["contents"].to_string();
        if contents.contains("f6") {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    }
}

#[tokio::test]
async fn reopen_does_not_duplicate_symbols() {
    let mut server = TestServer::new().await;
    let src = "<?php\nfunction once(): void {}\nonce();\n";
    server.open("reopen.php", src).await;

    let uri = server.uri("reopen.php");
    server
        .client()
        .notify(
            "textDocument/didClose",
            serde_json::json!({ "textDocument": { "uri": uri } }),
        )
        .await;

    server.open("reopen.php", src).await;

    let resp = server.references("reopen.php", 1, 9, true).await;
    let refs = resp["result"].as_array().expect("references array");
    let ref_count = refs.len();
    expect!["2"].assert_eq(&ref_count.to_string());
}

#[tokio::test]
async fn cross_file_diagnostic_clears_when_dependency_opened() {
    let mut server = TestServer::new().await;
    let notif = server
        .open("user_open.php", "<?php\n$w = new ProvidedClass();\n")
        .await;
    assert!(
        has_code(&notif, "UndefinedClass"),
        "expected UndefinedClass before dep is opened: {:?}",
        notif["params"]["diagnostics"]
    );

    server
        .open("provider.php", "<?php\nclass ProvidedClass {}\n")
        .await;

    let user_uri = server.uri("user_open.php");
    let notif = server.client().wait_for_diagnostics(&user_uri).await;
    assert!(
        !has_code(&notif, "UndefinedClass"),
        "expected UndefinedClass cleared after dep opened: {:?}",
        notif["params"]["diagnostics"]
    );
}

#[tokio::test]
async fn cross_file_republish_fans_out_to_multiple_dependents() {
    let mut server = TestServer::new().await;
    server
        .open("dep_fan.php", "<?php\nclass FanWidget {}\n")
        .await;
    server
        .open("u1_fan.php", "<?php\n$w = new FanWidget();\n")
        .await;
    server
        .open("u2_fan.php", "<?php\n$w = new FanWidget();\n")
        .await;

    let _ = server
        .client()
        .drain_publish_diagnostics_uris(tokio::time::Duration::from_millis(200))
        .await;

    server
        .change("dep_fan.php", 2, "<?php\nclass FanGadget {}\n")
        .await;

    let u1 = server.uri("u1_fan.php");
    let u2 = server.uri("u2_fan.php");
    let notifs = server
        .client()
        .wait_for_diagnostics_multi(&[&u1, &u2])
        .await;

    // Both files must receive diagnostics
    expect!["true"].assert_eq(&notifs.contains_key(&u1).to_string());
    expect!["true"].assert_eq(&notifs.contains_key(&u2).to_string());

    for (label, uri) in [("u1", &u1), ("u2", &u2)] {
        let notif = notifs
            .get(uri)
            .unwrap_or_else(|| panic!("missing publish for {label} ({uri})"));
        assert!(
            has_code(notif, "UndefinedClass"),
            "{label}: expected UndefinedClass after FanWidget rename"
        );
    }
}

#[tokio::test]
async fn cross_file_republish_skips_closed_files() {
    let mut server = TestServer::new().await;
    server
        .open("dep_closed.php", "<?php\nclass ClosedDep {}\n")
        .await;
    server
        .open("user_closed.php", "<?php\n$w = new ClosedDep();\n")
        .await;

    let user_uri = server.uri("user_closed.php");
    server.close("user_closed.php").await;
    let _ = server
        .client()
        .drain_publish_diagnostics_uris(tokio::time::Duration::from_millis(200))
        .await;

    server
        .change("dep_closed.php", 2, "<?php\nclass ClosedDepRenamed {}\n")
        .await;

    let seen = server
        .client()
        .drain_publish_diagnostics_uris(tokio::time::Duration::from_millis(300))
        .await;
    assert!(
        !seen.iter().any(|u| u == &user_uri),
        "closed file received an unexpected publishDiagnostics: {seen:?}"
    );
}

#[tokio::test]
async fn cross_file_republish_uses_empty_array_for_clean_dependent() {
    // `clean_b` is a genuine dependent of `clean_a` (references `aa()`).
    // Renaming an unrelated symbol in `clean_a` must still send a publish
    // for `clean_b`, and the diagnostics field must be an empty array
    // (LSP requires the field even when there are no issues).
    let mut server = TestServer::new().await;
    server
        .open(
            "clean_a.php",
            "<?php\nfunction aa(): void {}\nfunction extra(): void {}\n",
        )
        .await;
    server.open("clean_b.php", "<?php\naa();\n").await;

    server
        .change(
            "clean_a.php",
            2,
            "<?php\nfunction aa(): void {}\nfunction renamed(): void {}\n",
        )
        .await;

    let b_uri = server.uri("clean_b.php");
    let notif = server.client().wait_for_diagnostics(&b_uri).await;
    let diags = &notif["params"]["diagnostics"];
    assert!(
        diags.is_array(),
        "diagnostics must be an array (LSP requires the field), got: {diags:?}"
    );
    assert!(
        diags.as_array().unwrap().is_empty(),
        "clean_b still resolves aa() — expected empty diagnostics, got: {diags:?}"
    );
}

#[tokio::test]
async fn cross_file_republish_preserves_dependent_parse_errors() {
    // `broken.php` has a parse error AND references `Triggered`, which is
    // about to be defined by `trigger.php`. When the dependent is
    // republished, the parse error must survive (we merge LSP-side parse
    // diagnostics with mir's semantic issues).
    let mut server = TestServer::new().await;
    // Count parse-error diagnostics (no `code` field) vs semantic ones.
    fn parse_error_count(notif: &serde_json::Value) -> usize {
        notif["params"]["diagnostics"]
            .as_array()
            .map(|arr| arr.iter().filter(|d| d.get("code").is_none()).count())
            .unwrap_or(0)
    }

    let notif = server
        .open("broken.php", "<?php\nnew Triggered();\nbroken(;\n")
        .await;
    let original_parse = parse_error_count(&notif);
    expect!["true"].assert_eq(&(original_parse > 0).to_string());

    server
        .open("trigger.php", "<?php\nclass Triggered {}\n")
        .await;

    let broken_uri = server.uri("broken.php");
    let notif = server.client().wait_for_diagnostics(&broken_uri).await;
    let final_parse = parse_error_count(&notif);
    // Parse errors must be preserved during cross-file republish, even
    // though the UndefinedClass diagnostic clears once `Triggered` exists.
    expect!["true"].assert_eq(&(final_parse >= original_parse).to_string());
}