php-lsp 0.4.0

A PHP Language Server Protocol implementation
Documentation
//! Workspace folders and watched-file notifications: add/remove folders,
//! did{Create,Delete,Rename}Files, and edge cases from workspace-scan path.

mod common;

use common::TestServer;
use common::{render_document_symbols, render_hover, render_workspace_symbols};
use expect_test::expect;
use std::time::{Duration, Instant};
use tower_lsp::lsp_types::Url;

const CREATED: u32 = 1;
const CHANGED: u32 = 2;
const DELETED: u32 = 3;

async fn poll_until_symbol_present(server: &mut TestServer, query: &str, timeout: Duration) {
    let deadline = Instant::now() + timeout;
    loop {
        let resp = server.workspace_symbols(query).await;
        if resp["result"]
            .as_array()
            .map(|a| !a.is_empty())
            .unwrap_or(false)
        {
            return;
        }
        assert!(
            Instant::now() < deadline,
            "timed out after {:?} waiting for '{}' in workspace symbols",
            timeout,
            query
        );
        tokio::time::sleep(Duration::from_millis(30)).await;
    }
}

async fn poll_until_symbol_uri_contains(
    server: &mut TestServer,
    query: &str,
    needle: &str,
    timeout: Duration,
) {
    let deadline = Instant::now() + timeout;
    loop {
        let found = server.workspace_symbols(query).await["result"]
            .as_array()
            .map(|a| {
                a.iter().any(|s| {
                    s["location"]["uri"]
                        .as_str()
                        .map(|u| u.contains(needle))
                        .unwrap_or(false)
                })
            })
            .unwrap_or(false);
        if found {
            return;
        }
        assert!(
            Instant::now() < deadline,
            "timed out after {:?} waiting for '{}' with URI containing '{}'",
            timeout,
            query,
            needle
        );
        tokio::time::sleep(Duration::from_millis(30)).await;
    }
}

async fn poll_until_symbol_absent(server: &mut TestServer, query: &str, timeout: Duration) {
    let deadline = Instant::now() + timeout;
    loop {
        let empty = server.workspace_symbols(query).await["result"]
            .as_array()
            .map(|a| a.is_empty())
            .unwrap_or(true);
        if empty {
            return;
        }
        assert!(
            Instant::now() < deadline,
            "timed out after {:?} waiting for '{}' to disappear from workspace symbols",
            timeout,
            query
        );
        tokio::time::sleep(Duration::from_millis(30)).await;
    }
}

// ── workspace/didChangeWorkspaceFolders ───────────────────────────────────────

#[tokio::test]
async fn add_workspace_folder_indexes_php_classes() {
    let mut server = TestServer::with_fixture("psr4-mini").await;
    server.wait_for_index_ready().await;

    let tmp = tempfile::tempdir().expect("create TempDir");
    std::fs::write(
        tmp.path().join("ExtraWidget.php"),
        "<?php\nclass ExtraWidget {}\n",
    )
    .expect("write ExtraWidget.php");

    let folder_uri = Url::from_file_path(tmp.path())
        .expect("valid file URI")
        .to_string();

    server.add_workspace_folder(&folder_uri).await;
    poll_until_symbol_present(&mut server, "ExtraWidget", Duration::from_secs(5)).await;

    let resp = server.workspace_symbols("ExtraWidget").await;
    let out = render_workspace_symbols(&resp, &folder_uri);
    expect![[r#"Class       ExtraWidget @ ExtraWidget.php:1"#]].assert_eq(&out);
}

#[tokio::test]
async fn add_empty_workspace_folder_does_not_crash() {
    let mut server = TestServer::with_fixture("psr4-mini").await;
    server.wait_for_index_ready().await;

    let tmp = tempfile::tempdir().expect("create TempDir");
    let folder_uri = Url::from_file_path(tmp.path())
        .expect("valid file URI")
        .to_string();

    server.add_workspace_folder(&folder_uri).await;
    poll_until_symbol_present(&mut server, "User", Duration::from_secs(3)).await;

    let out = server.snapshot_workspace_symbols("User").await;
    expect!["Class       User @ src/Model/User.php:4"].assert_eq(&out);

    let out = server.snapshot_workspace_symbols("NonExistent").await;
    expect![[r#"<no symbols>"#]].assert_eq(&out);
}

#[tokio::test]
async fn add_workspace_folder_idempotent_on_duplicate() {
    let mut server = TestServer::with_fixture("psr4-mini").await;
    server.wait_for_index_ready().await;

    let tmp = tempfile::tempdir().expect("create TempDir");
    std::fs::write(
        tmp.path().join("UniqueGadget.php"),
        "<?php\nclass UniqueGadget {}\n",
    )
    .expect("write UniqueGadget.php");

    let folder_uri = Url::from_file_path(tmp.path())
        .expect("valid file URI")
        .to_string();

    server.add_workspace_folder(&folder_uri).await;
    server.add_workspace_folder(&folder_uri).await;
    poll_until_symbol_present(&mut server, "UniqueGadget", Duration::from_secs(5)).await;

    let resp = server.workspace_symbols("UniqueGadget").await;
    let out = render_workspace_symbols(&resp, &folder_uri);
    expect![[r#"Class       UniqueGadget @ UniqueGadget.php:1"#]].assert_eq(&out);
}

#[tokio::test]
async fn remove_workspace_folder_does_not_crash_and_keeps_indexed_docs() {
    let mut server = TestServer::with_fixture("psr4-mini").await;
    server.wait_for_index_ready().await;

    let root_uri = server.uri("").trim_end_matches('/').to_string();
    server.remove_workspace_folder(&root_uri).await;

    // Removing a folder keeps already-indexed docs in memory (no negative
    // assertion possible here since the root folder was removed and its
    // symbols remain accessible by design).
    let out = server.snapshot_workspace_symbols("User").await;
    expect!["Class       User @ src/Model/User.php:4"].assert_eq(&out);
}

// ── workspace-scan edge cases ─────────────────────────────────────────────────

#[tokio::test]
async fn workspace_without_composer_json_still_works() {
    let mut server = TestServer::with_fixture("no-composer").await;
    server.wait_for_index_ready().await;

    let (text, line, ch) = server.locate("src/standalone.php", "standalone", 0);
    server.open("src/standalone.php", &text).await;
    let resp = server.hover("src/standalone.php", line, ch).await;
    let out = render_hover(&resp);
    expect![[r#"
        ```php
        function standalone(int $n): int
        ```"#]]
    .assert_eq(&out);
}

#[tokio::test]
async fn nonexistent_psr4_dir_does_not_crash_server() {
    let mut server = TestServer::with_fixture("missing-psr4-dir").await;
    server.wait_for_index_ready().await;

    let out = server.snapshot_workspace_symbols("Alive").await;
    expect!["Class       Alive @ src/Present/Alive.php:4"].assert_eq(&out);

    let (text, _, _) = server.locate("src/Present/Alive.php", "<?php", 0);
    server.open("src/Present/Alive.php", &text).await;
    let resp = server.document_symbols("src/Present/Alive.php").await;
    let out = render_document_symbols(&resp);
    expect![[r#"
        Class Alive @L4
          Method hello @L6"#]]
    .assert_eq(&out);
}

#[tokio::test]
async fn malformed_composer_json_does_not_crash_server() {
    let mut server = TestServer::with_fixture("broken-composer").await;
    server.wait_for_index_ready().await;

    let (text, _, _) = server.locate("src/Thing.php", "<?php", 0);
    server.open("src/Thing.php", &text).await;

    let resp = server.document_symbols("src/Thing.php").await;
    let out = render_document_symbols(&resp);
    expect![[r#"
        Class Thing @L4
          Method go @L6"#]]
    .assert_eq(&out);
}

// ── workspace/didCreateFiles / didDeleteFiles / didRenameFiles ────────────────

#[tokio::test]
async fn did_rename_files_updates_index_to_new_path() {
    let mut server = TestServer::with_fixture("psr4-mini").await;
    server.wait_for_index_ready().await;

    let old_uri = server.uri("src/Model/User.php");
    let new_uri = server.uri("src/Entity/User.php");

    let (content, _, _) = server.locate("src/Model/User.php", "<?php", 0);
    server.write_file("src/Entity/User.php", &content);
    server.remove_file("src/Model/User.php");

    server
        .did_rename_files(vec![(old_uri.clone(), new_uri.clone())])
        .await;

    poll_until_symbol_uri_contains(
        &mut server,
        "User",
        "Entity/User.php",
        Duration::from_secs(3),
    )
    .await;

    let out = server.snapshot_workspace_symbols("User").await;
    expect!["Class       User @ src/Entity/User.php:4"].assert_eq(&out);
}

#[tokio::test]
async fn did_create_files_adds_new_class_to_index() {
    let mut server = TestServer::with_fixture("psr4-mini").await;
    server.wait_for_index_ready().await;

    let pre = server.workspace_symbols("OrderRepo").await;
    assert!(
        pre["result"]
            .as_array()
            .map(|a| a.is_empty())
            .unwrap_or(true),
        "OrderRepo must not be indexed before creation"
    );

    server.write_file(
        "src/Repository/OrderRepo.php",
        "<?php\nnamespace App\\Repository;\nclass OrderRepo {}\n",
    );
    let new_uri = server.uri("src/Repository/OrderRepo.php");
    server.did_create_files(vec![new_uri]).await;

    poll_until_symbol_present(&mut server, "OrderRepo", Duration::from_secs(3)).await;

    let out = server.snapshot_workspace_symbols("OrderRepo").await;
    expect!["Class       OrderRepo @ src/Repository/OrderRepo.php:2"].assert_eq(&out);
}

#[tokio::test]
async fn did_delete_files_removes_class_and_clears_diagnostics() {
    let mut server = TestServer::with_fixture("psr4-mini").await;
    server.wait_for_index_ready().await;

    let (content, _, _) = server.locate("src/Model/User.php", "<?php", 0);
    server.open("src/Model/User.php", &content).await;

    let uri = server.uri("src/Model/User.php");
    server.remove_file("src/Model/User.php");

    let results = server.did_delete_files(vec![uri]).await;

    let diag_notif = &results[0];
    let notif_uri = diag_notif["params"]["uri"].as_str().unwrap_or("");
    assert!(
        notif_uri.contains("Model/User.php"),
        "publishDiagnostics must be for User.php, got URI: {notif_uri}"
    );
    let diagnostics = diag_notif["params"]["diagnostics"]
        .as_array()
        .cloned()
        .unwrap_or_default();
    assert!(
        diagnostics.is_empty(),
        "publishDiagnostics after deletion must be empty, got: {diagnostics:?}"
    );

    poll_until_symbol_absent(&mut server, "User", Duration::from_secs(3)).await;

    let post = server.workspace_symbols("User").await;
    assert!(
        post["result"]
            .as_array()
            .map(|a| a.is_empty())
            .unwrap_or(true),
        "User must be removed from workspace symbols after deletion: {:?}",
        post["result"]
    );
}

// ── didChangeWatchedFiles edge cases ──────────────────────────────────────────

#[tokio::test]
async fn changed_event_does_not_overwrite_open_editor_file() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::write(
        tmp.path().join("editor.php"),
        "<?php\nfunction diskVersion(): void {}\n",
    )
    .unwrap();

    let mut server = TestServer::with_root(tmp.path()).await;
    server
        .open("editor.php", "<?php\nfunction editorVersion(): void {}\n")
        .await;

    let uri = server.uri("editor.php");
    server.did_change_watched_files(vec![(uri, CHANGED)]).await;

    let resp = server.hover("editor.php", 1, 10).await;
    let out = render_hover(&resp);
    expect![[r#"
        ```php
        function editorVersion(): void
        ```"#]]
    .assert_eq(&out);
}

#[tokio::test]
async fn batch_changes_all_applied() {
    let mut server = TestServer::with_fixture("psr4-mini").await;
    server.wait_for_index_ready().await;

    server.write_file(
        "src/Service/Alpha.php",
        "<?php\nnamespace App\\Service;\n\nclass Alpha {}\n",
    );
    server.write_file(
        "src/Service/Beta.php",
        "<?php\nnamespace App\\Service;\n\nclass Beta {}\n",
    );
    server.remove_file("src/Service/Registry.php");

    let alpha_uri = server.uri("src/Service/Alpha.php");
    let beta_uri = server.uri("src/Service/Beta.php");
    let registry_uri = server.uri("src/Service/Registry.php");

    server
        .did_change_watched_files(vec![
            (alpha_uri, CREATED),
            (beta_uri, CREATED),
            (registry_uri, DELETED),
        ])
        .await;

    poll_until_symbol_present(&mut server, "Alpha", Duration::from_secs(3)).await;
    poll_until_symbol_present(&mut server, "Beta", Duration::from_secs(3)).await;
    poll_until_symbol_absent(&mut server, "Registry", Duration::from_secs(3)).await;

    let alpha_out = server.snapshot_workspace_symbols("Alpha").await;
    expect![[r#"Class       Alpha @ src/Service/Alpha.php:3"#]].assert_eq(&alpha_out);

    let beta_out = server.snapshot_workspace_symbols("Beta").await;
    expect![[r#"Class       Beta @ src/Service/Beta.php:3"#]].assert_eq(&beta_out);

    let registry_out = server.snapshot_workspace_symbols("Registry").await;
    expect![[r#"<no symbols>"#]].assert_eq(&registry_out);
}