use super::servers::ServerSpec;
use crate::{
extensions::util::content_hash,
utils::{http::path_to_uri, paths::find_in_path},
};
use serde_json::{Value, json};
use std::{
collections::HashMap,
path::Path,
process::Stdio,
sync::{
Arc, Mutex,
atomic::{AtomicI64, Ordering},
},
time::{Duration, Instant},
};
use tokio::{
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
process::{Child, ChildStdin, Command},
sync::{Mutex as AsyncMutex, oneshot},
};
const MAX_HEADER_LINE: usize = 8 * 1024;
#[derive(Debug, Clone)]
pub struct DiagEntry {
pub items: Vec<Value>,
pub version: Option<i64>,
pub received: Instant,
}
pub struct LspClient {
child: Mutex<Child>,
stdin: Arc<AsyncMutex<ChildStdin>>,
next_id: AtomicI64,
pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
diagnostics: Arc<Mutex<HashMap<String, DiagEntry>>>,
opened: AsyncMutex<HashMap<String, (i64, u64)>>,
label: String,
}
impl LspClient {
pub async fn spawn(
spec: &ServerSpec,
root: &Path,
log_path: &Path,
) -> Result<Arc<Self>, String> {
let exe = find_in_path(spec.command).ok_or_else(|| {
format!(
"language server `{}` is not installed or not on PATH. Install it with: {}",
spec.command, spec.install_hint
)
})?;
if let Some(parent) = log_path.parent() {
_ = std::fs::create_dir_all(parent);
}
let stderr = match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
{
Ok(file) => Stdio::from(file),
Err(_) => Stdio::null(),
};
let mut cmd = Command::new(&exe);
cmd.args(spec.args)
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(stderr)
.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to start `{}`: {e}", spec.command))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| format!("`{}` has no stdin", spec.command))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| format!("`{}` has no stdout", spec.command))?;
let client = Arc::new(Self {
child: Mutex::new(child),
stdin: Arc::new(AsyncMutex::new(stdin)),
next_id: AtomicI64::new(1),
pending: Arc::new(AsyncMutex::new(HashMap::new())),
diagnostics: Arc::new(Mutex::new(HashMap::new())),
opened: AsyncMutex::new(HashMap::new()),
label: format!("{} @ {}", spec.command, root.display()),
});
spawn_reader(&client, stdout);
client
.request(
"initialize",
initialize_params(root),
Duration::from_secs(30),
)
.await?;
client.notify("initialized", json!({})).await?;
Ok(client)
}
pub async fn request(
&self,
method: &str,
params: Value,
timeout: Duration,
) -> Result<Value, String> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(id, tx);
let message = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
if let Err(error) = self.write(message).await {
self.pending.lock().await.remove(&id);
return Err(error);
}
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(response)) => match response.get("error") {
Some(error) => Err(format!(
"{method} failed ({}): {}",
self.label,
error
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown error")
)),
None => Ok(response.get("result").cloned().unwrap_or(Value::Null)),
},
Ok(Err(_)) => Err(format!(
"{method} failed: the language server exited ({})",
self.label
)),
Err(_) => {
self.pending.lock().await.remove(&id);
Err(format!(
"{method} timed out after {}s ({}). The server may still be indexing; \
retry in a moment or check the server log.",
timeout.as_secs(),
self.label
))
}
}
}
pub async fn notify(&self, method: &str, params: Value) -> Result<(), String> {
self.write(json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
}))
.await
}
pub async fn sync_document(
&self,
uri: &str,
language_id: &str,
text: &str,
) -> Result<i64, String> {
let hash = content_hash(text);
let mut opened = self.opened.lock().await;
let next = match opened.get(uri) {
Some((version, old_hash)) => {
if *old_hash == hash {
return Ok(*version);
}
version + 1
}
None => 1,
};
opened.insert(uri.to_string(), (next, hash));
if next == 1 {
self.write(json!({
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": uri,
"languageId": language_id,
"version": 1,
"text": text,
}
}
}))
.await?;
} else {
self.write(json!({
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": { "uri": uri, "version": next },
"contentChanges": [{ "text": text }]
}
}))
.await?;
}
Ok(next)
}
pub async fn wait_diagnostics(
&self,
uri: &str,
since: Instant,
version: i64,
wait: Duration,
) -> Option<DiagEntry> {
let deadline = Instant::now() + wait;
loop {
{
let cache = self.diagnostics.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = cache.get(uri)
&& entry.received >= since
&& entry.version.is_none_or(|pushed| pushed >= version)
{
return Some(entry.clone());
}
}
if Instant::now() >= deadline {
return None;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
async fn write(&self, message: Value) -> Result<(), String> {
let body = serde_json::to_vec(&message).map_err(|e| e.to_string())?;
let mut stdin = self.stdin.lock().await;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
stdin
.write_all(header.as_bytes())
.await
.map_err(|e| format!("write to {} failed: {e}", self.label))?;
stdin
.write_all(&body)
.await
.map_err(|e| format!("write to {} failed: {e}", self.label))?;
stdin
.flush()
.await
.map_err(|e| format!("flush to {} failed: {e}", self.label))
}
}
impl Drop for LspClient {
fn drop(&mut self) {
if let Ok(mut child) = self.child.lock() {
let _ = child.start_kill();
}
}
}
fn spawn_reader(client: &Arc<LspClient>, stdout: tokio::process::ChildStdout) {
let pending = Arc::clone(&client.pending);
let diagnostics = Arc::clone(&client.diagnostics);
let stdin = Arc::clone(&client.stdin);
let label = client.label.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout);
loop {
match read_frame(&mut reader).await {
Ok(Some(message)) => {
handle_message(message, &pending, &diagnostics, &stdin, &label).await;
}
Ok(None) => break,
Err(error) => {
let _ = error;
break;
}
}
}
pending.lock().await.clear();
});
}
async fn handle_message(
message: Value,
pending: &Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
diagnostics: &Arc<Mutex<HashMap<String, DiagEntry>>>,
stdin: &Arc<AsyncMutex<ChildStdin>>,
label: &str,
) {
if let Some(method) = message.get("method").and_then(Value::as_str) {
if let Some(id) = message.get("id").cloned() {
let result = match method {
"workspace/configuration" => {
let count = message
.get("params")
.and_then(|p| p.get("items"))
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
Value::Array(vec![Value::Null; count])
}
_ => Value::Null,
};
let reply = json!({ "jsonrpc": "2.0", "id": id, "result": result });
let _ = write_shared(stdin, reply, label).await;
return;
}
if method == "textDocument/publishDiagnostics"
&& let Some(params) = message.get("params")
&& let Some(uri) = params.get("uri").and_then(Value::as_str)
{
let entry = DiagEntry {
items: params
.get("diagnostics")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default(),
version: params.get("version").and_then(Value::as_i64),
received: Instant::now(),
};
diagnostics
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(uri.to_string(), entry);
}
return;
}
if let Some(id) = message.get("id").and_then(Value::as_i64)
&& let Some(tx) = pending.lock().await.remove(&id)
{
let _ = tx.send(message);
}
}
async fn write_shared(
stdin: &Arc<AsyncMutex<ChildStdin>>,
message: Value,
label: &str,
) -> Result<(), String> {
let body = serde_json::to_vec(&message).map_err(|e| e.to_string())?;
let mut stdin = stdin.lock().await;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
stdin
.write_all(header.as_bytes())
.await
.map_err(|e| format!("write to {label} failed: {e}"))?;
stdin
.write_all(&body)
.await
.map_err(|e| format!("write to {label} failed: {e}"))?;
stdin
.flush()
.await
.map_err(|e| format!("flush to {label} failed: {e}"))
}
async fn read_frame<R: tokio::io::AsyncBufRead + Unpin>(
reader: &mut R,
) -> Result<Option<Value>, String> {
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
let read = reader
.read_line(&mut line)
.await
.map_err(|e| format!("read from language server failed: {e}"))?;
if read == 0 {
return Ok(None);
}
if line.len() > MAX_HEADER_LINE {
return Err("language server sent an oversized header line".to_string());
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
break;
}
if let Some(value) = trimmed.strip_prefix("Content-Length:") {
content_length = value.trim().parse().ok();
}
}
let length = content_length
.ok_or_else(|| "language server sent a frame without Content-Length".to_string())?;
let mut body = vec![0u8; length];
reader
.read_exact(&mut body)
.await
.map_err(|e| format!("read body from language server failed: {e}"))?;
serde_json::from_slice(&body)
.map(Some)
.map_err(|e| format!("language server sent invalid JSON: {e}"))
}
fn initialize_params(root: &Path) -> Value {
let root_uri = path_to_uri(root);
let name = root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace");
json!({
"processId": std::process::id(),
"clientInfo": { "name": "prux", "version": env!("CARGO_PKG_VERSION") },
"rootUri": root_uri,
"workspaceFolders": [{ "uri": root_uri, "name": name }],
"capabilities": {
"textDocument": {
"synchronization": { "dynamicRegistration": false, "didSave": false, "willSave": false },
"definition": { "linkSupport": true },
"references": { "dynamicRegistration": false },
"documentSymbol": { "hierarchicalDocumentSymbolSupport": true },
"publishDiagnostics": { "versionSupport": true, "relatedInformation": false }
},
"workspace": { "symbol": { "dynamicRegistration": false } }
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncWriteExt;
#[tokio::test]
async fn reads_content_length_frame() {
let (mut tx, rx) = tokio::io::duplex(4096);
let body = br#"{"jsonrpc":"2.0","id":1,"result":null}"#;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
let mut frame = header.into_bytes();
frame.extend_from_slice(body);
frame.extend_from_slice(b"Content-Length: 2\r\n\r\n{}");
tx.write_all(&frame).await.unwrap();
drop(tx);
let mut reader = BufReader::new(rx);
let first = read_frame(&mut reader).await.unwrap().unwrap();
assert_eq!(first["id"], 1);
let second = read_frame(&mut reader).await.unwrap().unwrap();
assert_eq!(second, json!({}));
}
#[tokio::test]
async fn eof_is_not_an_error() {
let (tx, rx) = tokio::io::duplex(64);
drop(tx);
let mut reader = BufReader::new(rx);
assert!(read_frame(&mut reader).await.unwrap().is_none());
}
}