#![allow(dead_code)]
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
pub const WAIT: Duration = Duration::from_secs(30);
pub struct Client {
child: Child,
stdin: Option<ChildStdin>,
messages: Receiver<Value>,
next_id: i64,
unread: Vec<Value>,
pub initialized: Value,
}
impl Client {
pub fn start(root: &Path, watch: bool) -> Client {
Client::start_with_env(root, watch, &[])
}
pub fn start_with_env(root: &Path, watch: bool, env: &[(&str, &Path)]) -> Client {
let mut command = Command::new(assert_cmd::cargo::cargo_bin("nova"));
command
.arg("lsp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
for (name, value) in env {
command.env(name, value);
}
let mut child = command.spawn().expect("start nova lsp");
let stdin = child.stdin.take();
let stdout = child.stdout.take().expect("piped stdout");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
while let Some(message) = read_message(&mut reader) {
if tx.send(message).is_err() {
break;
}
}
});
let mut client = Client {
child,
stdin,
messages: rx,
next_id: 1,
unread: Vec::new(),
initialized: Value::Null,
};
let capabilities = if watch {
json!({ "workspace": { "didChangeWatchedFiles": { "dynamicRegistration": true } } })
} else {
json!({})
};
client.initialized = client.request(
"initialize",
json!({ "processId": null, "rootUri": file_uri(root), "capabilities": capabilities }),
)["result"]
.clone();
client.notify("initialized", json!({}));
client
}
pub fn notify(&mut self, method: &str, params: Value) {
self.send(&json!({ "jsonrpc": "2.0", "method": method, "params": params }));
}
pub fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
self.send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }));
self.wait_for(|m| m.get("method").is_none() && m.get("id") == Some(&json!(id)))
}
pub fn wait_for(&mut self, want: impl Fn(&Value) -> bool) -> Value {
if let Some(i) = self.unread.iter().position(&want) {
return self.unread.remove(i);
}
let deadline = Instant::now() + WAIT;
loop {
let left = deadline.saturating_duration_since(Instant::now());
let message = self.messages.recv_timeout(left).unwrap_or_else(|_| {
panic!(
"no matching message within {WAIT:?}; read but unmatched: {:#?}",
self.unread
)
});
if message.get("method").is_some() && message.get("id").is_some() {
let id = message["id"].clone();
self.send(&json!({ "jsonrpc": "2.0", "id": id, "result": null }));
}
if want(&message) {
return message;
}
self.unread.push(message);
}
}
pub fn diagnostics(&mut self, uri: &str, want: impl Fn(&Value) -> bool) -> Value {
let message = self.wait_for(|m| {
m["method"] == "textDocument/publishDiagnostics"
&& m["params"]["uri"]
.as_str()
.is_some_and(|u| same_uri(u, uri))
&& want(&m["params"])
});
message["params"].clone()
}
pub fn clear_unread(&mut self) {
self.unread.clear();
}
pub fn last_diagnostics_before(&mut self, uri: &str, sentinel: &str) -> Option<Value> {
let is_publish_for = |m: &Value, u: &str| {
m["method"] == "textDocument/publishDiagnostics"
&& m["params"]["uri"].as_str().is_some_and(|p| same_uri(p, u))
};
self.wait_for(|m| is_publish_for(m, sentinel));
self.unread
.iter()
.rev()
.find(|m| is_publish_for(m, uri))
.map(|m| m["params"].clone())
}
pub fn shutdown_and_exit(mut self) -> ExitStatus {
self.request("shutdown", Value::Null);
self.notify("exit", Value::Null);
self.wait()
}
pub fn exit_without_shutdown(mut self) -> ExitStatus {
self.notify("exit", Value::Null);
self.wait()
}
fn wait(&mut self) -> ExitStatus {
self.stdin.take();
let deadline = Instant::now() + WAIT;
loop {
if let Some(status) = self.child.try_wait().expect("poll nova lsp") {
return status;
}
assert!(Instant::now() < deadline, "nova lsp did not exit");
std::thread::sleep(Duration::from_millis(20));
}
}
fn send(&mut self, message: &Value) {
let body = serde_json::to_vec(message).expect("serialize");
let stdin = self.stdin.as_mut().expect("stdin is open");
write!(stdin, "Content-Length: {}\r\n\r\n", body.len()).expect("write header");
stdin.write_all(&body).expect("write body");
stdin.flush().expect("flush");
}
}
impl Drop for Client {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn read_message(reader: &mut impl BufRead) -> Option<Value> {
let mut length = None;
loop {
let mut line = String::new();
if reader.read_line(&mut line).ok()? == 0 {
return None;
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some(n) = line.strip_prefix("Content-Length: ") {
length = n.parse().ok();
}
}
let mut body = vec![0; length?];
reader.read_exact(&mut body).ok()?;
serde_json::from_slice(&body).ok()
}
pub fn file_uri(path: &Path) -> String {
let mut s = path.to_string_lossy().replace('\\', "/");
if s.as_bytes().get(1) == Some(&b':') {
s = format!("{}{}", s[..1].to_lowercase(), &s[1..]);
}
if !s.starts_with('/') {
s.insert(0, '/');
}
let mut out = String::from("file://");
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
pub fn same_uri(a: &str, b: &str) -> bool {
let norm = |s: &str| {
let decoded = decode(s);
if cfg!(windows) {
decoded.to_lowercase()
} else {
decoded
}
};
norm(a) == norm(b)
}
pub fn decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(b) = u8::from_str_radix(&String::from_utf8_lossy(&bytes[i + 1..i + 3]), 16) {
out.push(b);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn fresh_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join("nova lsp tests")
.join(format!("เนเธเธฃเนเธเธเธเน-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create the test directory");
let real = std::fs::canonicalize(&dir).expect("canonicalize the test directory");
match real.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
Some(rest) => PathBuf::from(rest),
None => real,
}
}
pub const MANIFEST: &str = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2026\"\n";
pub const APP_MAIN: &str = "import geom\n\nfn main() {\n let a: Int = area()\n}\n";
pub const INDEX: &str = "https://example.test/index/";
pub fn project(name: &str, files: &[(&str, &str)]) -> PathBuf {
let dir = fresh_dir(name);
std::fs::write(dir.join("nova.toml"), MANIFEST).unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
for (file, text) in files {
std::fs::write(dir.join("src").join(file), text).unwrap();
}
dir
}
pub fn app_and_library(name: &str, lib: &str) -> (PathBuf, PathBuf) {
app_with_main(name, lib, APP_MAIN)
}
pub fn app_with_main(name: &str, lib: &str, main: &str) -> (PathBuf, PathBuf) {
let dir = fresh_dir(name);
let geom = dir.join("geom");
std::fs::create_dir_all(geom.join("src")).unwrap();
std::fs::write(geom.join("nova.toml"), MANIFEST.replace("demo", "geom")).unwrap();
std::fs::write(geom.join("src").join("lib.nova"), lib).unwrap();
let app = dir.join("app");
std::fs::create_dir_all(app.join("src")).unwrap();
let manifest = format!(
"{}\n[dependencies]\ngeom = {{ path = \"../geom\" }}\n",
MANIFEST.replace("demo", "app")
);
std::fs::write(app.join("nova.toml"), manifest).unwrap();
std::fs::write(app.join("src").join("main.nova"), main).unwrap();
(app, geom)
}
pub fn registry_app(name: &str, req: &str) -> (PathBuf, PathBuf) {
let dir = fresh_dir(name);
let app = dir.join("app");
std::fs::create_dir_all(app.join("src")).unwrap();
std::fs::write(
app.join("nova.toml"),
format!(
"{}\n[dependencies]\ngeom = \"{req}\"\n",
MANIFEST.replace("demo", "app")
),
)
.unwrap();
std::fs::write(app.join("src").join("main.nova"), APP_MAIN).unwrap();
(app, dir.join("home"))
}
pub fn lock_and_cache(app: &Path, home: &Path, lib: &str) -> PathBuf {
let geom = home
.join("registry")
.join("src")
.join(nova_pm::index_dir_name(INDEX))
.join("geom-0.1.0");
std::fs::create_dir_all(geom.join("src")).unwrap();
std::fs::write(geom.join("nova.toml"), MANIFEST.replace("demo", "geom")).unwrap();
std::fs::write(geom.join("src").join("lib.nova"), lib).unwrap();
std::fs::write(
app.join("nova.lock"),
format!(
"version = 1\nindex = \"{INDEX}\"\n\n[[package]]\nname = \"geom\"\n\
version = \"0.1.0\"\nchecksum = \"00\"\ndependencies = []\n"
),
)
.unwrap();
geom
}