aether-lspd 0.1.21

LSP daemon for sharing language servers across multiple agents
Documentation
mod common;

use aether_lspd::{LanguageId, LspClient, lockfile_path, socket_path};
use common::{CargoProject, DaemonHarness, TestProject, hover_text, use_fake_rust_server};
use lsp_types::PublishDiagnosticsParams;
use std::time::{Duration, Instant};

async fn poll_workspace_diagnostics(
    client: &LspClient,
    predicate: impl Fn(&[PublishDiagnosticsParams]) -> bool,
    timeout: Duration,
) -> Vec<PublishDiagnosticsParams> {
    let start = Instant::now();
    let mut last = Vec::new();

    while start.elapsed() < timeout {
        let diagnostics = client.get_diagnostics(None).await.expect("Failed to get workspace diagnostics");
        if predicate(&diagnostics) {
            return diagnostics;
        }
        last = diagnostics;
        tokio::time::sleep(Duration::from_millis(50)).await;
    }

    panic!("workspace diagnostics timed out after {timeout:?}. Last result: {last:?}");
}

fn workspace_error_count(diagnostics: &[PublishDiagnosticsParams]) -> usize {
    diagnostics.iter().map(|params| params.diagnostics.len()).sum()
}

#[tokio::test]
async fn daemon_persists_after_client_disconnect() {
    use_fake_rust_server();

    let project = CargoProject::new("disconnect_persists").expect("Failed to create project");
    let socket_path = socket_path(project.root(), LanguageId::Rust);
    let lockfile_path = lockfile_path(&socket_path);

    let client = LspClient::connect(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon");
    client.disconnect().await.expect("Failed to disconnect client");

    assert!(lockfile_path.exists(), "Daemon should continue running after a client disconnects");
}

#[tokio::test]
async fn multiple_clients_share_fake_server_session() {
    use_fake_rust_server();

    let project = CargoProject::new("shared_session").expect("Failed to create project");

    let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon");
    let client1 = harness.connect().await.expect("Failed to connect client 1");
    let client2 = harness.connect().await.expect("Failed to connect client 2");

    let uri = project.file_uri("src/main.rs");

    let hover1 = hover_text(client1.hover(uri.clone(), 0, 0).await.expect("Hover failed"));
    let hover2 = hover_text(client2.hover(uri.clone(), 0, 0).await.expect("Hover failed"));

    assert!(!hover1.is_empty());
    assert_eq!(hover1, hover2);

    harness.kill().await.expect("Failed to kill daemon");
}

#[tokio::test]
async fn diagnostics_are_available_across_clients_without_explicit_open() {
    use_fake_rust_server();

    let project = CargoProject::new("shared_diagnostics").expect("Failed to create project");
    project.add_file("src/main.rs", "fn main() { let error = 1; }\n").expect("Failed to add source file");

    let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon");
    let client1 = harness.connect().await.expect("Failed to connect client 1");
    let client2 = harness.connect().await.expect("Failed to connect client 2");
    let uri = project.file_uri("src/main.rs");

    let diagnostics1 = client1.get_diagnostics(Some(uri.clone())).await.expect("Failed to get diagnostics");
    let diagnostics2 = client2.get_diagnostics(Some(uri.clone())).await.expect("Failed to get diagnostics");

    assert_eq!(diagnostics1.len(), 1);
    assert_eq!(diagnostics2.len(), 1);
    assert_eq!(diagnostics1[0].diagnostics.len(), 1);
    assert_eq!(diagnostics2[0].diagnostics.len(), 1);

    harness.kill().await.expect("Failed to kill daemon");
}

#[tokio::test]
async fn workspace_bootstrap_diagnostics_are_available_without_explicit_open() {
    use_fake_rust_server();

    let project = CargoProject::new("workspace_bootstrap").expect("Failed to create project");
    project.add_file("src/main.rs", "fn main() { let error = 1; }\n").expect("Failed to add source file");

    let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon");
    let client = harness.connect().await.expect("Failed to connect client");

    let diagnostics = poll_workspace_diagnostics(
        &client,
        |diagnostics| workspace_error_count(diagnostics) > 0,
        Duration::from_secs(10),
    )
    .await;

    assert_eq!(diagnostics.len(), 1);
    assert_eq!(workspace_error_count(&diagnostics), 1);

    harness.kill().await.expect("Failed to kill daemon");
}

#[tokio::test]
async fn workspace_diagnostics_refresh_after_external_edit_without_explicit_open() {
    use_fake_rust_server();

    let project = CargoProject::new("workspace_external_refresh").expect("Failed to create project");
    project.add_file("src/main.rs", "fn main() { let ok = 1; }\n").expect("Failed to add source file");

    let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon");
    let client = harness.connect().await.expect("Failed to connect client");
    let main_rs = project.root().join("src/main.rs");

    let initial =
        poll_workspace_diagnostics(&client, |diagnostics| diagnostics.len() == 1, Duration::from_secs(10)).await;
    assert_eq!(workspace_error_count(&initial), 0);

    std::fs::write(&main_rs, "fn main() { let error = 1; }\n").expect("Failed to write file");

    let refreshed = poll_workspace_diagnostics(
        &client,
        |diagnostics| workspace_error_count(diagnostics) > 0,
        Duration::from_secs(10),
    )
    .await;
    assert_eq!(refreshed.len(), 1);
    assert_eq!(workspace_error_count(&refreshed), 1);

    harness.kill().await.expect("Failed to kill daemon");
}