lsp-cli 0.1.6

Command-line tool for talking to Language Server Protocol (LSP) servers from the terminal.
use std::path::Path;
use std::time::Duration;

use crate::harness::{E2eContext, SocketSnapshot};
use crate::lsp_exchange;
use crate::manifest::{Manifest, RealServerLifecycleCase};
use crate::real_server_support::{CaseDeadline, run_isolated_case, run_reported_case};
use crate::repository_root;

struct LifecycleTest<'a> {
    case: RealServerLifecycleCase<'a>,
    repository: &'a Path,
}

impl<'a> LifecycleTest<'a> {
    fn new(case: RealServerLifecycleCase<'a>, repository: &'a Path) -> Self {
        Self { case, repository }
    }

    fn run(self) -> Result<(), String> {
        let label = self.case.label();
        run_reported_case("lifecycle", &label, || self.run_inner())
    }

    fn run_inner(&self) -> Result<(), String> {
        let deadline = CaseDeadline::new(self.case.deadline_seconds(), "lifecycle case");
        let source = self.repository.join(self.case.project());
        run_isolated_case(&source, self.case.host_programs(), &deadline, |context| {
            let server = self.case.server_name(self.repository)?;
            if self.case.direct_run_enabled() {
                self.direct_run(context, &server, deadline.remaining()?)?;
            }
            self.detached(context, &source, &server, &deadline)
                .map_err(|error| format!("{error}\n{}", context.lifecycle_state()))
        })
    }

    fn direct_run(
        &self,
        context: &E2eContext,
        server: &str,
        remaining: Duration,
    ) -> Result<(), String> {
        let args = self.server_args("run", ".", server);
        lsp_exchange::run(context, &args, context.workspace(), remaining)
            .map_err(|error| format!("direct run protocol exchange failed:\n{error}"))
    }

    fn detached(
        &self,
        context: &E2eContext,
        source: &Path,
        server: &str,
        deadline: &CaseDeadline,
    ) -> Result<(), String> {
        let daemon = context
            .try_run_with_deadline(&refs(&self.daemon_args(".", server)), deadline.remaining()?)?;
        daemon.ensure_success()?;
        let initial = only_socket(context)?;
        if daemon.stdout_text().trim() != initial.path.display().to_string() {
            return Err(format!(
                "daemon reported {}, but created {}",
                daemon.stdout_text().trim(),
                initial.path.display()
            ));
        }

        self.detached_query(context, ".", server, deadline.remaining()?)?;
        let after_first = only_socket(context)?;
        let starts = context.server_start_count();
        self.detached_query(context, ".", server, deadline.remaining()?)?;
        let after_second = only_socket(context)?;
        if initial != after_first
            || after_first != after_second
            || context.server_start_count() != starts
        {
            return Err("detached queries did not reuse the original daemon/server".to_string());
        }

        let stopped_pids = context.server_pids();
        let stop = context.try_run_with_deadline(
            &["stop", ".", "--lang", self.case.language(), "--lsp", server],
            deadline.remaining()?,
        )?;
        stop.ensure_success()?;
        expect_socket_count(context, 0)?;
        context.wait_for_processes_to_exit(
            &stopped_pids,
            deadline.remaining()?.min(Duration::from_secs(10)),
        )?;

        self.detached_query(context, ".", server, deadline.remaining()?)?;
        let restarted = only_socket(context)?;
        if restarted == initial || context.server_start_count() <= starts {
            return Err(
                "a detached query after stop did not start a fresh daemon/server".to_string(),
            );
        }

        let alternate = context.copy_project_as(source, "alternate-workspace")?;
        let alternate_text = alternate
            .to_str()
            .ok_or_else(|| "alternate workspace path is not UTF-8".to_string())?;
        self.detached_query(context, alternate_text, server, deadline.remaining()?)?;
        expect_socket_count(context, 2)?;
        let stopped_pids = context.server_pids();
        let stop_all = context.try_run_with_deadline(&["stop-all"], deadline.remaining()?)?;
        stop_all.ensure_success()?;
        expect_socket_count(context, 0)?;
        context.wait_for_processes_to_exit(
            &stopped_pids,
            deadline.remaining()?.min(Duration::from_secs(10)),
        )
    }

    fn detached_query(
        &self,
        context: &E2eContext,
        workspace: &str,
        server: &str,
        deadline: Duration,
    ) -> Result<(), String> {
        let mut args = vec![
            "server-capabilities".to_string(),
            workspace.to_string(),
            "--lang".to_string(),
            self.case.language().to_string(),
            "--lsp".to_string(),
            server.to_string(),
            "--detach".to_string(),
            "--timeout".to_string(),
            self.case.lsp_timeout_seconds().to_string(),
            "--json".to_string(),
            "--debug".to_string(),
        ];
        args.push("--download".to_string());
        let output = context.try_run_with_deadline(&refs(&args), deadline)?;
        output.ensure_success()?;
        let response: serde_json::Value = output.try_json()?;
        let capabilities = response
            .get("capabilities")
            .ok_or_else(|| "server-capabilities response omitted capabilities".to_string())?;
        context.record_server_capabilities(capabilities)
    }

    fn server_args(&self, command: &str, workspace: &str, server: &str) -> Vec<String> {
        let mut args = vec![
            command.to_string(),
            workspace.to_string(),
            "--lang".to_string(),
            self.case.language().to_string(),
            "--lsp".to_string(),
            server.to_string(),
            "--debug".to_string(),
        ];
        args.push("--download".to_string());
        args
    }

    fn daemon_args(&self, workspace: &str, server: &str) -> Vec<String> {
        let mut args = self.server_args("daemon", workspace, server);
        args.extend(["--idle-timeout".to_string(), "120".to_string()]);
        args
    }
}

fn refs(args: &[String]) -> Vec<&str> {
    args.iter().map(String::as_str).collect()
}

fn only_socket(context: &E2eContext) -> Result<SocketSnapshot, String> {
    let sockets = context.daemon_sockets()?;
    let [socket] = sockets.as_slice() else {
        return Err(format!("expected one daemon socket, found {sockets:#?}"));
    };
    Ok(socket.clone())
}

fn expect_socket_count(context: &E2eContext, expected: usize) -> Result<(), String> {
    let sockets = context.daemon_sockets()?;
    if sockets.len() == expected {
        Ok(())
    } else {
        Err(format!(
            "expected {expected} daemon sockets, found {sockets:#?}"
        ))
    }
}

#[test]
#[ignore = "downloads and runs real LSP servers; executed explicitly in CI"]
fn manifest_real_server_lifecycle_cases() {
    let repository = repository_root();
    let manifest = Manifest::load_validated(repository).expect("E2E manifest should be valid");
    let selected = std::env::var("E2E_CASE").ok();
    assert!(
        selected
            .as_deref()
            .is_none_or(|label| manifest.declares_pair(label)),
        "E2E_CASE {:?} does not select a declared manifest pair",
        selected.as_deref().unwrap_or_default()
    );
    let cases = manifest
        .real_server_lifecycle_cases()
        .filter(|case| {
            selected
                .as_ref()
                .is_none_or(|expected| case.label() == *expected)
        })
        .collect::<Vec<_>>();
    let failures = cases
        .into_iter()
        .filter_map(|case| LifecycleTest::new(case, repository).run().err())
        .collect::<Vec<_>>();
    assert!(
        failures.is_empty(),
        "real-server lifecycle failures:\n{}",
        failures.join("\n\n")
    );
}