php-lsp 0.2.0

A PHP Language Server Protocol implementation
Documentation
mod common;

use common::TestServer;
use std::time::{Duration, Instant};

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 '{}' to appear 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 resp = server.workspace_symbols(query).await;
        let found = resp["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 '{}' in workspace symbols",
            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 resp = server.workspace_symbols(query).await;
        let empty = resp["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;
    }
}

#[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 pre = server.workspace_symbols("User").await;
    let pre_symbols = pre["result"].as_array().cloned().unwrap_or_default();
    assert!(
        pre_symbols
            .iter()
            .any(|s| s["name"].as_str() == Some("User")),
        "User must be indexed initially: {pre_symbols:?}"
    );

    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 post = server.workspace_symbols("User").await;
    let post_symbols = post["result"].as_array().cloned().unwrap_or_default();
    assert!(
        !post_symbols.iter().any(|s| {
            s["location"]["uri"]
                .as_str()
                .map(|u| u.contains("Model/User.php"))
                .unwrap_or(false)
        }),
        "old URI must no longer appear in workspace symbols after rename: {post_symbols:?}"
    );
    assert!(
        post_symbols.iter().any(|s| {
            s["location"]["uri"]
                .as_str()
                .map(|u| u.contains("Entity/User.php"))
                .unwrap_or(false)
        }),
        "new URI must appear in workspace symbols after rename: {post_symbols:?}"
    );
}

#[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 post = server.workspace_symbols("OrderRepo").await;
    let post_symbols = post["result"].as_array().cloned().unwrap_or_default();
    assert!(
        !post_symbols.is_empty(),
        "OrderRepo must be discoverable after did_create_files: {post_symbols:?}"
    );
}

#[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 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;
    let post_symbols = post["result"].as_array().cloned().unwrap_or_default();
    assert!(
        post_symbols.is_empty(),
        "User must be removed from workspace symbols after deletion: {post_symbols:?}"
    );
}