pub mod transport;
use std::path::Path;
use std::sync::atomic::{AtomicU32, Ordering};
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use tokio::io::{AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout};
use tracing::{debug, info};
use transport::{encode_message, read_message};
pub struct LspClient {
#[allow(dead_code)]
process: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next_id: AtomicU32,
}
impl LspClient {
pub async fn start(server_cmd: &str, args: &[&str], project_root: &Path) -> Result<Self> {
info!(
"Starting LSP server: {} {:?} in {:?}",
server_cmd, args, project_root
);
let mut command = tokio::process::Command::new(server_cmd);
command
.args(args)
.current_dir(project_root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit());
let mut child = command.spawn().with_context(|| {
format!(
"Failed to spawn LSP server '{}'. Is it installed?",
server_cmd
)
})?;
let stdin = child
.stdin
.take()
.context("LSP server stdin was not piped")?;
let stdout_raw = child
.stdout
.take()
.context("LSP server stdout was not piped")?;
let stdout = BufReader::new(stdout_raw);
Ok(LspClient {
process: child,
stdin,
stdout,
next_id: AtomicU32::new(1),
})
}
pub async fn send_request(&mut self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let message = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
debug!("LSP → {method} (id={id})");
self.write_message(&message).await?;
loop {
let response = read_message(&mut self.stdout)
.await
.with_context(|| format!("Failed to read LSP response for method '{}'", method))?;
match response.get("id") {
Some(resp_id) if *resp_id == json!(id) => {
debug!("LSP ← {method} (id={id})");
if let Some(err) = response.get("error") {
bail!("LSP server returned error for '{}': {}", method, err);
}
return Ok(response.get("result").cloned().unwrap_or(Value::Null));
}
_ => {
debug!("LSP: discarding unmatched message: {}", response);
}
}
}
}
pub async fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
let message = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
debug!("LSP → {method} (notification)");
self.write_message(&message).await
}
pub async fn initialize(&mut self) -> Result<()> {
info!("LSP: initializing");
let _result = self
.send_request(
"initialize",
json!({
"processId": std::process::id(),
"clientInfo": {
"name": "xcodeai",
"version": env!("CARGO_PKG_VERSION"),
},
"rootUri": null,
"capabilities": {
"textDocument": {
"synchronization": {
"didOpen": true,
"didClose": true,
},
"publishDiagnostics": {
"relatedInformation": false,
},
},
},
}),
)
.await?;
self.send_notification("initialized", json!({})).await?;
info!("LSP: initialized successfully");
Ok(())
}
#[allow(dead_code)]
pub async fn shutdown(&mut self) -> Result<()> {
info!("LSP: shutting down");
let _ = self.send_request("shutdown", json!(null)).await;
let _ = self.send_notification("exit", json!(null)).await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), self.process.wait()).await;
let _ = self.process.kill().await;
info!("LSP: shutdown complete");
Ok(())
}
pub fn detect_server(project_root: &Path) -> Option<(String, Vec<String>)> {
if project_root.join("Cargo.toml").exists() {
return Some(("rust-analyzer".to_string(), vec![]));
}
if project_root.join("package.json").exists() {
return Some((
"typescript-language-server".to_string(),
vec!["--stdio".to_string()],
));
}
if project_root.join("pyproject.toml").exists() || project_root.join("setup.py").exists() {
return Some(("pylsp".to_string(), vec![]));
}
if project_root.join("pyrightconfig.json").exists() {
return Some(("pyright".to_string(), vec!["--stdio".to_string()]));
}
None
}
async fn write_message(&mut self, value: &Value) -> Result<()> {
let bytes = encode_message(value);
self.stdin
.write_all(&bytes)
.await
.context("Failed to write to LSP server stdin")?;
self.stdin
.flush()
.await
.context("Failed to flush LSP server stdin")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_rust_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let result = LspClient::detect_server(dir.path());
assert!(result.is_some());
let (cmd, args) = result.unwrap();
assert_eq!(cmd, "rust-analyzer");
assert!(args.is_empty());
}
#[test]
fn test_detect_typescript_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let result = LspClient::detect_server(dir.path());
assert!(result.is_some());
let (cmd, _args) = result.unwrap();
assert_eq!(cmd, "typescript-language-server");
}
#[test]
fn test_detect_python_project_pyproject() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").unwrap();
let result = LspClient::detect_server(dir.path());
assert!(result.is_some());
let (cmd, _args) = result.unwrap();
assert_eq!(cmd, "pylsp");
}
#[test]
fn test_detect_python_project_setup_py() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("setup.py"), "").unwrap();
let result = LspClient::detect_server(dir.path());
assert!(result.is_some());
let (cmd, _args) = result.unwrap();
assert_eq!(cmd, "pylsp");
}
#[test]
fn test_detect_no_project() {
let dir = tempfile::tempdir().unwrap();
let result = LspClient::detect_server(dir.path());
assert!(result.is_none());
}
#[test]
fn test_detect_prefers_rust_over_python_if_both() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").unwrap();
let result = LspClient::detect_server(dir.path());
let (cmd, _) = result.unwrap();
assert_eq!(cmd, "rust-analyzer", "Rust should be preferred");
}
}