weaveback-lsp provides a client interface for communicating with language-specific LSP servers (like rust-analyzer). It is used to resolve semantic information in generated code and map it back to literate sources.

LspClient

LspClient manages a background LSP server process and handles the JSON-RPC protocol over stdin/stdout.

// <<lsp-client>>=
use std::process::{Child, ChildStdin, Command, Stdio};
use std::io::{BufRead, BufReader, Write, Read};
use std::path::Path;
use serde_json::{json, Value};
use lsp_types::*;
use url::Url;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum LspError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("LSP error: {0}")]
    Protocol(String),
    #[error("Server exited")]
    Exited,
}

use std::collections::HashMap;

pub struct LspClient {
    child: Child,
    stdin: ChildStdin,
    reader: BufReader<std::process::ChildStdout>,
    next_id: i64,
    language_id: String,
    diagnostics: HashMap<Url, Vec<Diagnostic>>,
}

impl LspClient {
    pub fn spawn(
        cmd: &str,
        args: &[&str],
        root_dir: &Path,
        language_id: String,
    ) -> Result<Self, LspError> {
        let mut child = Command::new(cmd)
            .args(args)
            .current_dir(root_dir)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::inherit())
            .spawn()?;

        let stdin = child.stdin.take().ok_or_else(|| LspError::Protocol("failed to open stdin".into()))?;
        let stdout = child.stdout.take().ok_or_else(|| LspError::Protocol("failed to open stdout".into()))?;
        let reader = BufReader::new(stdout);

        Ok(Self {
            child,
            stdin,
            reader,
            next_id: 1,
            language_id,
            diagnostics: HashMap::new(),
        })
    }

    pub fn initialize(&mut self, root_path: &Path) -> Result<(), LspError> {
        let root_uri = Url::from_directory_path(root_path)
            .map_err(|_| LspError::Protocol("invalid root path".into()))?;

        let params = InitializeParams {
            workspace_folders: Some(vec![WorkspaceFolder {
                uri: root_uri,
                name: "root".to_string(),
            }]),
            ..Default::default()
        };

        let res = self.call("initialize", params)?;

        // Basic capability check - ensure the server can actually do what we need.
        if let Some(caps) = res.get("capabilities")
            && caps.get("definitionProvider").is_none() {
            log::warn!("LSP server does not support gotoDefinition");
        }

        self.notify("initialized", json!({}))?;

        // Give the server some time to index.
        std::thread::sleep(std::time::Duration::from_secs(2));

        Ok(())
    }

    pub fn is_alive(&mut self) -> bool {
        matches!(self.child.try_wait(), Ok(None))
    }

    pub fn call<P: serde::Serialize>(&mut self, method: &str, params: P) -> Result<Value, LspError> {
        let id = self.next_id;
        self.next_id += 1;

        let req = json!({
            "jsonrpc": "2.0",
            "id": id,
            "method": method,
            "params": params,
        });

        self.write_request(&req)?;
        self.read_response(id)
    }

    pub fn notify<P: serde::Serialize>(&mut self, method: &str, params: P) -> Result<(), LspError> {
        let req = json!({
            "jsonrpc": "2.0",
            "method": method,
            "params": params,
        });
        self.write_request(&req)
    }

    pub fn did_open(&mut self, path: &Path) -> Result<(), LspError> {
        let uri = Url::from_file_path(path)
            .map_err(|_| LspError::Protocol("invalid file path".into()))?;
        let text = std::fs::read_to_string(path)?;

        let params = DidOpenTextDocumentParams {
            text_document: TextDocumentItem {
                uri,
                language_id: self.language_id.clone(),
                version: 1,
                text,
            },
        };
        self.notify("textDocument/didOpen", params)
    }

    pub fn get_diagnostics(&self, path: &Path) -> Vec<Diagnostic> {
        let Ok(uri) = Url::from_file_path(path) else { return vec![]; };
        self.diagnostics.get(&uri).cloned().unwrap_or_default()
    }

    fn write_request(&mut self, req: &Value) -> Result<(), LspError> {
        let body = serde_json::to_string(req)?;
        write!(self.stdin, "Content-Length: {}\r\n\r\n{}", body.len(), body)?;
        self.stdin.flush()?;
        Ok(())
    }

    fn read_response(&mut self, expected_id: i64) -> Result<Value, LspError> {
        loop {
            let mut line = String::new();
            self.reader.read_line(&mut line)?;
            if line.is_empty() { return Err(LspError::Exited); }

            if let Some(stripped) = line.strip_prefix("Content-Length: ") {
                let len: usize = stripped.trim().parse()
                    .map_err(|_| LspError::Protocol("invalid content-length".into()))?;

                // Skip the \r\n\r\n
                let mut junk = String::new();
                self.reader.read_line(&mut junk)?;

                let mut body = vec![0u8; len];
                self.reader.read_exact(&mut body)?;
                let resp: Value = serde_json::from_slice(&body)?;

                if let Some(id) = resp.get("id")
                    && id.as_i64() == Some(expected_id) {
                    if let Some(error) = resp.get("error") {
                        return Err(LspError::Protocol(error.to_string()));
                    }
                    return Ok(resp.get("result").cloned().unwrap_or(Value::Null));
                }

                // Handle notifications (no ID)
                if resp.get("id").is_none()
                    && let Some(method) = resp.get("method").and_then(|m| m.as_str())
                    && method == "textDocument/publishDiagnostics"
                    && let Ok(params) = serde_json::from_value::<PublishDiagnosticsParams>(resp["params"].clone())
                {
                    self.diagnostics.insert(params.uri, params.diagnostics);
                }
            }
        }
    }
}

impl Drop for LspClient {
    fn drop(&mut self) {
        let _ = self.child.kill();
    }
}
// @

Semantic Navigation

// <<lsp-nav>>=
impl LspClient {
    pub fn goto_definition(
        &mut self,
        path: &Path,
        line: u32,
        col: u32,
    ) -> Result<Option<Location>, LspError> {
        let uri = Url::from_file_path(path)
            .map_err(|_| LspError::Protocol("invalid file path".into()))?;

        let params = TextDocumentPositionParams {
            text_document: TextDocumentIdentifier::new(uri),
            position: Position::new(line, col),
        };

        let res = self.call("textDocument/definition", params)?;
        if res.is_null() { return Ok(None); }

        // Definition can return Location, Vec<Location>, or Vec<LocationLink>
        if let Ok(loc) = serde_json::from_value::<Location>(res.clone()) {
            Ok(Some(loc))
        } else if let Ok(locs) = serde_json::from_value::<Vec<Location>>(res.clone()) {
            Ok(locs.into_iter().next())
        } else {
            // For now, ignore LocationLink and other complex types
            Ok(None)
        }
    }

    pub fn find_references(
        &mut self,
        path: &Path,
        line: u32,
        col: u32,
    ) -> Result<Vec<Location>, LspError> {
        let uri = Url::from_file_path(path)
            .map_err(|_| LspError::Protocol("invalid file path".into()))?;

        let params = ReferenceParams {
            text_document_position: TextDocumentPositionParams {
                text_document: TextDocumentIdentifier::new(uri),
                position: Position::new(line, col),
            },
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
            context: ReferenceContext {
                include_declaration: true,
            },
        };

        let res = self.call("textDocument/references", params)?;
        if res.is_null() { return Ok(vec![]); }

        let locs: Vec<Location> = serde_json::from_value(res)?;
        Ok(locs)
    }

    pub fn hover(
        &mut self,
        path: &Path,
        line: u32,
        col: u32,
    ) -> Result<Option<Hover>, LspError> {
        let uri = Url::from_file_path(path)
            .map_err(|_| LspError::Protocol("invalid file path".into()))?;

        let params = TextDocumentPositionParams {
            text_document: TextDocumentIdentifier::new(uri),
            position: Position::new(line, col),
        };

        let res = self.call("textDocument/hover", params)?;
        if res.is_null() { return Ok(None); }

        let hover: Hover = serde_json::from_value(res)?;
        Ok(Some(hover))
    }

    pub fn document_symbols(
        &mut self,
        path: &Path,
    ) -> Result<Vec<DocumentSymbolResponse>, LspError> {
        let uri = Url::from_file_path(path)
            .map_err(|_| LspError::Protocol("invalid file path".into()))?;

        let params = DocumentSymbolParams {
            text_document: TextDocumentIdentifier::new(uri),
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };

        let res = self.call("textDocument/documentSymbol", params)?;
        if res.is_null() { return Ok(vec![]); }

        let symbols: Vec<DocumentSymbolResponse> = serde_json::from_value(res)?;
        Ok(symbols)
    }
}
// @

LSP Registry

// <<lsp-registry>>=
/// Returns (command, language_id) for a given file extension.
pub fn get_lsp_config(ext: &str) -> Option<(String, String)> {
    match ext {
        "rs"  => Some(("rust-analyzer".to_string(), "rust".to_string())),
        "nim" => Some(("nimlsp".to_string(), "nim".to_string())),
        "py"  => Some(("pyright-langserver --stdio".to_string(), "python".to_string())),
        _     => None,
    }
}
// @

Assembly

// <<@file weaveback-lsp/src/lib.rs>>=
// <<lsp-client>>
// <<lsp-nav>>
// <<lsp-registry>>
// @