lspz 0.11.7

AI-friendly LSP compression proxy
Documentation
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::Duration;

use lspz::StdioTransport;
use lspz::Transport;
use lspz::codec::json_rpc::LspMessage;
use serde_json::Value;
use tempfile::TempDir;

const LSP_TIMEOUT: Duration = Duration::from_secs(30);

pub struct LspTestHarness {
    pub transport: StdioTransport,
    pub temp_dir: TempDir,
    pub file_name: String,
    pub language_id: String,
    next_id: AtomicI64,
}

impl LspTestHarness {
    pub async fn try_new(
        cmd: &str,
        file_content: &str,
        file_name: &str,
        language_id: &str,
    ) -> Option<Self> {
        Self::try_new_with_args(cmd, &[], file_content, file_name, language_id).await
    }

    pub async fn try_new_with_args(
        cmd: &str,
        args: &[&str],
        file_content: &str,
        file_name: &str,
        language_id: &str,
    ) -> Option<Self> {
        let found = std::process::Command::new("which")
            .arg(cmd)
            .output()
            .ok()
            .filter(|o| o.status.success())
            .is_some();
        if !found {
            eprintln!("  SKIP: '{}' not found on PATH", cmd);
            return None;
        }

        let temp_dir = TempDir::with_prefix("lspz_e2e_").ok()?;
        let root_path = temp_dir.path().to_str()?.to_string();

        Self::scaffold_project(temp_dir.path(), language_id, file_name, file_content);

        let extra_args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
        let transport = StdioTransport::spawn(cmd, &extra_args).ok()?;

        let mut harness = Self {
            transport,
            temp_dir,
            file_name: file_name.to_string(),
            language_id: language_id.to_string(),
            next_id: AtomicI64::new(1),
        };

        if harness
            .initialize_with_root(Some(&root_path))
            .await
            .is_err()
        {
            eprintln!("  SKIP: '{}' initialize handshake failed", cmd);
            return None;
        }

        Some(harness)
    }

    fn scaffold_project(dir: &std::path::Path, lang: &str, file_name: &str, content: &str) {
        match lang {
            "rust" => {
                let src_dir = dir.join("src");
                let _ = std::fs::create_dir_all(&src_dir);
                let cargo_toml = dir.join("Cargo.toml");
                if !cargo_toml.exists() {
                    let _ = std::fs::write(
                        cargo_toml,
                        "[package]\nname = \"lspz_e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
                    );
                }
                let _ = std::fs::write(src_dir.join(file_name), content);
            }
            "go" => {
                let go_mod = dir.join("go.mod");
                if !go_mod.exists() {
                    let _ = std::fs::write(go_mod, "module lspz_e2e\ngo 1.21\n");
                }
                let _ = std::fs::write(dir.join(file_name), content);
            }
            "typescript" => {
                let _ = std::fs::write(dir.join(file_name), content);
            }
            _ => {
                let _ = std::fs::write(dir.join(file_name), content);
            }
        }
    }

    async fn send_request(&mut self, method: &str, params: Value) -> Result<Value, String> {
        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
        let msg = LspMessage::Request {
            id,
            method: method.into(),
            params,
        };
        let frame = msg.to_bytes().map_err(|e| e.to_string())?;
        self.transport
            .send(&frame)
            .await
            .map_err(|e| e.to_string())?;

        loop {
            let raw = tokio::time::timeout(LSP_TIMEOUT, self.transport.receive())
                .await
                .map_err(|_| format!("timeout waiting for response to '{method}'"))?
                .map_err(|e| e.to_string())?;
            let parsed = LspMessage::from_frame_bytes(&raw).map_err(|e| e.to_string())?;
            match parsed {
                LspMessage::Response {
                    id: rid,
                    result,
                    error,
                    ..
                } if rid == id => {
                    if let Some(err) = error {
                        return Err(format!("LSP error {}: {}", err.code, err.message));
                    }
                    return Ok(result.unwrap_or(Value::Null));
                }
                LspMessage::Notification { method: m, .. } => {
                    tracing::trace!("Buffered notification: {}", m);
                }
                _ => {}
            }
        }
    }

    async fn send_notification(&mut self, method: &str, params: Value) -> Result<(), String> {
        let msg = LspMessage::Notification {
            method: method.into(),
            params,
        };
        let frame = msg.to_bytes().map_err(|e| e.to_string())?;
        self.transport.send(&frame).await.map_err(|e| e.to_string())
    }

    async fn initialize_with_root(&mut self, root_uri: Option<&str>) -> Result<(), String> {
        let root_params =
            root_uri.map(|r| serde_json::json!([{"uri": format!("file://{r}"), "name": "root"}]));
        let params = serde_json::json!({
            "processId": null,
            "capabilities": {},
            "rootUri": root_uri.map(|r| format!("file://{r}")),
            "workspaceFolders": root_params,
        });
        self.send_request("initialize", params).await?;
        self.send_notification("initialized", serde_json::json!({}))
            .await
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    pub fn write_source_file(&self, content: &str) -> String {
        let file_path = match self.language_id.as_str() {
            "rust" => self.temp_dir.path().join("src").join(&self.file_name),
            _ => self.temp_dir.path().join(&self.file_name),
        };
        std::fs::write(&file_path, content).expect("should write test file");
        format!("file://{}", file_path.display())
    }

    pub async fn get_diagnostics(&mut self, uri: &str) -> Result<Value, String> {
        let file_path = uri.strip_prefix("file://").unwrap();
        let content = std::fs::read_to_string(file_path).map_err(|e| e.to_string())?;

        let params = serde_json::json!({
            "textDocument": {
                "uri": uri,
                "languageId": self.language_id,
                "version": 1,
                "text": content,
            }
        });
        self.send_notification("textDocument/didOpen", params)
            .await?;

        loop {
            let raw = tokio::time::timeout(LSP_TIMEOUT, self.transport.receive())
                .await
                .map_err(|_| "timeout waiting for publishDiagnostics notification".to_string())?
                .map_err(|e| e.to_string())?;
            let parsed = LspMessage::from_frame_bytes(&raw).map_err(|e| e.to_string())?;
            match parsed {
                LspMessage::Notification { method, params }
                    if method == "textDocument/publishDiagnostics" =>
                {
                    return Ok(params);
                }
                LspMessage::Notification { method, .. } => {
                    tracing::trace!("Skipping notification: {}", method);
                }
                _ => {}
            }
        }
    }
}