// Synchronous LSP client harness for integration tests, replacing the old // TypeScript test driver. Tests that need a native TypeScript backend set // AVRIL_TEST_TYPESCRIPT to its absolute path and are skipped otherwise. use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde_json::{Value, json}; pub fn backend() -> Option { let path = std::env::var_os("AVRIL_TEST_TYPESCRIPT")?; if path.is_empty() { return None; } Some(PathBuf::from(path)) } pub fn avril() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_avril")) } pub fn write_frame(writer: &mut impl Write, message: &Value) -> std::io::Result<()> { let body = serde_json::to_vec(message).unwrap(); write!(writer, "Content-Length: {}\r\n\r\n", body.len())?; writer.write_all(&body)?; writer.flush() } pub fn read_frame(reader: &mut impl BufRead) -> std::io::Result> { let mut length = None; loop { let mut line = String::new(); if reader.read_line(&mut line)? == 0 { return Ok(None); } let line = line.trim_end_matches(['\r', '\n']); if line.is_empty() { break; } if let Some((name, value)) = line.split_once(':') && name.trim().eq_ignore_ascii_case("Content-Length") { length = Some(value.trim().parse::().map_err(|_| { std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid Content-Length") })?); } } let length = length.ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing Content-Length") })?; if length > 64 * 1024 * 1024 { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "RPC frame exceeds test transport limit", )); } let mut body = vec![0; length]; reader.read_exact(&mut body)?; Ok(Some(serde_json::from_slice(&body).unwrap())) } #[derive(Debug)] pub struct RpcError { pub code: i64, pub message: String, } impl std::fmt::Display for RpcError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "RPC {}: {}", self.code, self.message) } } pub struct Server { child: Child, stdin: Option, messages: Receiver, pub notifications: Vec, next_id: u64, root: PathBuf, stderr: Arc>>, } impl Server { pub fn start(root: &Path, backend: &Path, options: &[&str]) -> Server { let mut child = Command::new(avril()) .current_dir(root) .args(["lsp", &format!("--typescript={}", backend.display())]) .arg(format!("--project={}", root.display())) .args(options) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); let (tx, messages) = channel(); std::thread::spawn(move || { while let Ok(Some(message)) = read_frame(&mut stdout) { if tx.send(message).is_err() { return; } } }); let stderr = Arc::new(Mutex::new(Vec::new())); let mut pipe = child.stderr.take().unwrap(); std::thread::spawn({ let stderr = stderr.clone(); move || { let mut chunk = [0; 8192]; while let Ok(count) = pipe.read(&mut chunk) { if count == 0 { break; } stderr.lock().unwrap().extend_from_slice(&chunk[..count]); } } }); Server { child, stdin: Some(stdin), messages, notifications: Vec::new(), next_id: 0, root: root.to_owned(), stderr, } } pub fn pid(&self) -> u32 { self.child.id() } pub fn stderr(&self) -> String { String::from_utf8_lossy(&self.stderr.lock().unwrap()).into_owned() } fn send(&mut self, message: &Value) { write_frame(self.stdin.as_mut().unwrap(), message).unwrap(); } pub fn notify(&mut self, method: &str, params: Value) { self.send(&json!({"jsonrpc": "2.0", "method": method, "params": params})); } /// Sends a request without waiting, for tests that must act (cancel, /// signal, close) while it is in flight. pub fn request_start(&mut self, method: &str, params: Value) -> u64 { self.next_id += 1; let id = self.next_id; self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); id } pub fn request_wait(&mut self, id: u64) -> Value { let deadline = Instant::now() + Duration::from_secs(15); loop { let remaining = deadline.saturating_duration_since(Instant::now()); assert!( !remaining.is_zero(), "Timeout waiting for response {id}\n{}", self.stderr() ); match self.messages.recv_timeout(remaining) { Ok(message) => { if message.get("method").is_some() { self.notifications.push(message); } else if message["id"].as_u64() == Some(id) { return message; } } Err(RecvTimeoutError::Timeout) => { panic!("Timeout waiting for response {id}\n{}", self.stderr()) } Err(RecvTimeoutError::Disconnected) => { panic!( "Unexpected LSP EOF waiting for response {id}\n{}", self.stderr() ) } } } } /// Sends a request and returns the full response message, so tests can /// inspect either `result` or `error`. pub fn request(&mut self, method: &str, params: Value) -> Value { let id = self.request_start(method, params); self.request_wait(id) } pub fn call(&mut self, method: &str, params: Value) -> Result { let response = self.request(method, params); if let Some(error) = response.get("error") { Err(RpcError { code: error["code"].as_i64().unwrap_or_default(), message: error["message"].as_str().unwrap_or_default().to_owned(), }) } else { Ok(response["result"].clone()) } } pub fn write_raw(&mut self, bytes: &[u8]) { let stdin = self.stdin.as_mut().unwrap(); stdin.write_all(bytes).unwrap(); stdin.flush().unwrap(); } pub fn close_stdin(&mut self) { self.stdin.take(); } pub fn wait_status(&mut self, timeout: Duration) -> ExitStatus { let deadline = Instant::now() + timeout; loop { if let Some(status) = self.child.try_wait().unwrap() { return status; } assert!( Instant::now() < deadline, "Server did not exit\n{}", self.stderr() ); std::thread::sleep(Duration::from_millis(10)); } } pub fn kill(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } pub fn uri(&self, path: &str) -> String { url::Url::from_file_path(self.root.join(path)) .unwrap() .to_string() } pub fn initialize(&mut self) { self.initialize_with(json!({"textDocument": {"definition": {"linkSupport": true}}})); } pub fn initialize_with(&mut self, capabilities: Value) -> Value { self.initialize_params(json!({"capabilities": capabilities})) } pub fn initialize_params(&mut self, params: Value) -> Value { let result = self.call("initialize", params).unwrap(); self.notify("initialized", json!({})); result } pub fn open(&mut self, path: &str, text: &str) { self.open_as(path, text, 1, "typescript"); } pub fn open_version(&mut self, path: &str, text: &str, version: i64) { self.open_as(path, text, version, "typescript"); } pub fn open_as(&mut self, path: &str, text: &str, version: i64, language: &str) { self.notify( "textDocument/didOpen", json!({ "textDocument": { "uri": self.uri(path), "text": text, "version": version, "languageId": language } }), ); } pub fn change(&mut self, path: &str, text: &str, version: i64) { self.notify( "textDocument/didChange", json!({ "textDocument": {"uri": self.uri(path), "version": version}, "contentChanges": [{"text": text}] }), ); } fn query(&self, path: &str, text: &str, word: &str) -> Value { Self::query_uri(&self.uri(path), text, word) } fn query_uri(uri: &str, text: &str, word: &str) -> Value { let offset = text.rfind(word).unwrap() + 1; json!({ "textDocument": {"uri": uri}, "position": position_at(text, offset) }) } pub fn hover_uri(&mut self, uri: &str, text: &str, word: &str) -> String { let result = self .call("textDocument/hover", Self::query_uri(uri, text, word)) .unwrap(); hover_text(&result) } pub fn hover(&mut self, path: &str, text: &str, word: &str) -> String { let result = self .call("textDocument/hover", self.query(path, text, word)) .unwrap(); hover_text(&result) } pub fn definitions(&mut self, path: &str, text: &str, word: &str) -> Vec { self.call("textDocument/definition", self.query(path, text, word)) .unwrap() .as_array() .cloned() .unwrap_or_default() } pub fn complete( &mut self, path: &str, text: &str, offset: usize, trigger_character: Option<&str>, ) -> Value { let mut params = json!({ "textDocument": {"uri": self.uri(path)}, "position": position_at(text, offset) }); if let Some(trigger) = trigger_character { params["context"] = json!({"triggerKind": 2, "triggerCharacter": trigger}); } self.call("textDocument/completion", params).unwrap() } /// The most recently published diagnostics for a project-relative path. pub fn diagnostics(&self, path: &str) -> Vec { self.diagnostics_uri(&self.uri(path)) } pub fn diagnostics_uri(&self, uri: &str) -> Vec { self.notifications .iter() .rfind(|message| { message["method"] == "textDocument/publishDiagnostics" && message["params"]["uri"] == uri }) .map(|message| { message["params"]["diagnostics"] .as_array() .cloned() .unwrap_or_default() }) .unwrap_or_default() } pub fn eventually(&mut self, mut check: impl FnMut(&mut Server) -> bool) { for _ in 0..80 { if check(self) { return; } std::thread::sleep(Duration::from_millis(100)); } panic!("Live project did not update\n{}", self.stderr()); } pub fn close(&mut self) { self.call("shutdown", json!({})).unwrap(); self.notify("exit", json!({})); self.close_stdin(); let status = self.wait_status(Duration::from_secs(8)); assert!(status.success(), "Server exit {status}\n{}", self.stderr()); } } impl Drop for Server { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } pub fn files(root: &Path, entries: &[(&str, &str)]) { for (name, text) in entries { let path = root.join(name); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, text).unwrap(); } } /// LSP position (UTF-16 code units) for a byte offset into `text`. pub fn position_at(text: &str, byte_offset: usize) -> Value { assert!(text.is_char_boundary(byte_offset)); let prefix = &text[..byte_offset]; let line = prefix.matches(['\n', '\r']).count() - prefix.matches("\r\n").count(); let line_start = prefix .rfind(['\n', '\r']) .map(|index| index + prefix[index..].chars().next().unwrap().len_utf8()) .unwrap_or(0); let character = text[line_start..byte_offset].encode_utf16().count(); json!({"line": line, "character": character}) } /// Byte offset for an LSP position in `text`, clamping to the line end. pub fn offset_at(text: &str, position: &Value) -> usize { let line = position["line"].as_u64().unwrap() as usize; let character = position["character"].as_u64().unwrap() as usize; let mut offset = 0; for (current, part) in text.split_inclusive('\n').enumerate() { let content = part.trim_end_matches(['\r', '\n']); if current == line { let mut units = 0; for (index, ch) in content.char_indices() { if units >= character { return offset + index; } units += ch.len_utf16(); } return offset + content.len(); } offset += part.len(); } text.len() } /// The source text covered by an LSP range value. pub fn selected(text: &str, range: &Value) -> String { text[offset_at(text, &range["start"])..offset_at(text, &range["end"])].to_owned() } pub fn hover_text(hover: &Value) -> String { fn append(content: &Value, text: &mut String) { match content { Value::String(value) => text.push_str(value), Value::Array(contents) => { for (index, content) in contents.iter().enumerate() { if index > 0 { text.push('\n'); } append(content, text); } } Value::Object(object) => { if let Some(value) = object.get("value").and_then(Value::as_str) { text.push_str(value); } } _ => {} } } let mut text = String::new(); if let Some(contents) = hover.get("contents") { append(contents, &mut text); } text }