use std::collections::{HashMap, HashSet};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::thread;
use std::time::Duration;
use lsp_types::{GotoDefinitionResponse, Url};
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Clone, Debug)]
pub struct Loc {
pub path: PathBuf,
pub line: usize,
pub character: u32,
}
pub fn char_to_utf16(line: &str, char_col: usize) -> u32 {
line.chars().take(char_col).map(|c| c.len_utf16() as u32).sum()
}
pub fn utf16_to_char(line: &str, utf16_col: u32) -> usize {
let mut units = 0u32;
for (i, c) in line.chars().enumerate() {
if units >= utf16_col {
return i;
}
units += c.len_utf16() as u32;
}
line.chars().count()
}
fn uri_of(path: &Path) -> Option<String> {
Url::from_file_path(path).ok().map(|u| u.to_string())
}
#[derive(Clone)]
struct LangServerDef {
id: String,
extensions: Vec<String>,
command: Vec<String>,
}
pub struct LspConfig {
langs: Vec<LangServerDef>,
}
fn default_langs() -> Vec<LangServerDef> {
let def = |id: &str, exts: &[&str], cmd: &[&str]| LangServerDef {
id: id.to_string(),
extensions: exts.iter().map(|s| s.to_string()).collect(),
command: cmd.iter().map(|s| s.to_string()).collect(),
};
vec![
def("rust", &["rs"], &["rust-analyzer"]),
def("php", &["php", "phtml"], &["intelephense", "--stdio"]),
def("ruby", &["rb", "rake", "gemspec"], &["ruby-lsp"]),
]
}
#[derive(Deserialize, Default)]
struct FileConfig {
#[serde(default)]
lsp: HashMap<String, LangEntry>,
}
#[derive(Deserialize)]
struct LangEntry {
#[serde(default)]
extensions: Vec<String>,
#[serde(default)]
command: Vec<String>,
}
impl LspConfig {
pub fn load(root: &Path) -> Self {
let mut langs = default_langs();
if let Some(home) = std::env::var_os("HOME") {
merge_file(&mut langs, &Path::new(&home).join(".config/srev/config.toml"));
}
merge_file(&mut langs, &root.join(".srev.toml"));
Self { langs }
}
fn lang_for_ext(&self, ext: &str) -> Option<&LangServerDef> {
self.langs
.iter()
.find(|l| !l.command.is_empty() && l.extensions.iter().any(|e| e == ext))
}
fn command_for(&self, id: &str) -> Option<Vec<String>> {
self.langs
.iter()
.find(|l| l.id == id && !l.command.is_empty())
.map(|l| l.command.clone())
}
}
fn merge_file(langs: &mut Vec<LangServerDef>, path: &Path) {
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let Ok(cfg) = toml::from_str::<FileConfig>(&text) else {
return;
};
for (id, entry) in cfg.lsp {
match langs.iter_mut().find(|l| l.id == id) {
Some(existing) => {
existing.command = entry.command;
if !entry.extensions.is_empty() {
existing.extensions = entry.extensions;
}
}
None => langs.push(LangServerDef {
id,
extensions: entry.extensions,
command: entry.command,
}),
}
}
}
struct LspServer {
child: Child,
out_tx: Sender<Value>,
in_rx: Receiver<Value>,
init_id: i64,
initialized: bool,
queue: Vec<Value>,
opened: HashSet<String>,
dead: bool,
}
impl LspServer {
fn spawn(command: &[String], root: &Path, init_id: i64) -> Option<Self> {
let mut child = Command::new(&command[0])
.args(&command[1..])
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let mut stdin = child.stdin.take()?;
let stdout = child.stdout.take()?;
let (out_tx, out_rx) = channel::<Value>();
thread::spawn(move || {
while let Ok(msg) = out_rx.recv() {
if write_message(&mut stdin, &msg).is_err() {
break;
}
}
});
let (in_tx, in_rx) = channel::<Value>();
thread::spawn(move || {
let mut reader = BufReader::new(stdout);
while let Some(msg) = read_message(&mut reader) {
if in_tx.send(msg).is_err() {
break;
}
}
});
let server = Self {
child,
out_tx,
in_rx,
init_id,
initialized: false,
queue: Vec::new(),
opened: HashSet::new(),
dead: false,
};
let _ = server.out_tx.send(request(init_id, "initialize", initialize_params(root)));
Some(server)
}
fn send(&mut self, msg: Value) {
if self.initialized {
let _ = self.out_tx.send(msg);
} else {
self.queue.push(msg);
}
}
}
impl Drop for LspServer {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
pub struct LspManager {
root: PathBuf,
config: LspConfig,
servers: HashMap<String, LspServer>,
unavailable: HashSet<String>,
next_id: i64,
}
impl LspManager {
pub fn new(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
config: LspConfig::load(root),
servers: HashMap::new(),
unavailable: HashSet::new(),
next_id: 1,
}
}
fn alloc_id(&mut self) -> i64 {
let id = self.next_id;
self.next_id += 1;
id
}
pub fn lang_id_for(&self, path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
self.config.lang_for_ext(&ext).map(|l| l.id.clone())
}
pub fn is_ready_for(&self, path: &Path) -> bool {
match self.lang_id_for(path) {
Some(lang) => self
.servers
.get(&lang)
.is_some_and(|s| s.initialized && !s.dead),
None => false,
}
}
pub fn ensure_open(&mut self, path: &Path, lang_id: &str, text: &str) -> bool {
if self.unavailable.contains(lang_id) {
return false;
}
if !self.servers.contains_key(lang_id) {
let Some(cmd) = self.config.command_for(lang_id) else {
return false; };
let id = self.alloc_id();
match LspServer::spawn(&cmd, &self.root, id) {
Some(s) => {
self.servers.insert(lang_id.to_string(), s);
}
None => {
self.unavailable.insert(lang_id.to_string());
return true;
}
}
}
if let Some(uri) = uri_of(path)
&& let Some(server) = self.servers.get_mut(lang_id)
&& server.opened.insert(uri.clone())
{
let params = json!({
"textDocument": {
"uri": uri,
"languageId": lang_id,
"version": 0,
"text": text,
}
});
server.send(notification("textDocument/didOpen", params));
}
false
}
pub fn request_definition(&mut self, path: &Path, line: u32, character: u32) -> Option<i64> {
self.request("textDocument/definition", path, line, character, None)
}
pub fn request_references(&mut self, path: &Path, line: u32, character: u32) -> Option<i64> {
self.request(
"textDocument/references",
path,
line,
character,
Some(json!({ "includeDeclaration": false })),
)
}
fn request(
&mut self,
method: &str,
path: &Path,
line: u32,
character: u32,
context: Option<Value>,
) -> Option<i64> {
let lang = self.lang_id_for(path)?;
let uri = uri_of(path)?;
let id = self.alloc_id();
let server = self.servers.get_mut(&lang)?;
if !server.initialized || server.dead {
return None;
}
let mut params = json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character },
});
if let Some(ctx) = context {
params["context"] = ctx;
}
server.send(request(id, method, params));
Some(id)
}
pub fn poll(&mut self) -> Vec<(i64, Vec<Loc>)> {
let mut out = Vec::new();
for server in self.servers.values_mut() {
loop {
match server.in_rx.try_recv() {
Ok(msg) => handle_message(server, msg, &mut out),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
server.dead = true;
break;
}
}
}
}
out
}
}
fn handle_message(server: &mut LspServer, msg: Value, out: &mut Vec<(i64, Vec<Loc>)>) {
let has_method = msg.get("method").is_some();
let id = msg.get("id");
if let Some(id) = id
&& has_method
{
let _ = server.out_tx.send(json!({
"jsonrpc": "2.0",
"id": id,
"result": Value::Null,
}));
} else if let Some(id) = id.and_then(Value::as_i64) {
if id == server.init_id {
server.initialized = true;
let _ = server.out_tx.send(notification("initialized", json!({})));
for m in server.queue.drain(..) {
let _ = server.out_tx.send(m);
}
} else {
let locs = msg.get("result").map(parse_locations).unwrap_or_default();
out.push((id, locs));
}
}
}
fn parse_locations(result: &Value) -> Vec<Loc> {
if result.is_null() {
return Vec::new();
}
let Ok(resp) = serde_json::from_value::<GotoDefinitionResponse>(result.clone()) else {
return Vec::new();
};
let to_loc = |uri: &Url, line: u32, character: u32| {
uri.to_file_path().ok().map(|path| Loc {
path,
line: line as usize,
character,
})
};
match resp {
GotoDefinitionResponse::Scalar(l) => to_loc(&l.uri, l.range.start.line, l.range.start.character)
.into_iter()
.collect(),
GotoDefinitionResponse::Array(ls) => ls
.iter()
.filter_map(|l| to_loc(&l.uri, l.range.start.line, l.range.start.character))
.collect(),
GotoDefinitionResponse::Link(links) => links
.iter()
.filter_map(|l| {
let r = l.target_selection_range.start;
to_loc(&l.target_uri, r.line, r.character)
})
.collect(),
}
}
fn request(id: i64, method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
}
fn notification(method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "method": method, "params": params })
}
fn initialize_params(root: &Path) -> Value {
let root_uri = Url::from_directory_path(root).ok().map(|u| u.to_string());
json!({
"processId": std::process::id(),
"rootUri": root_uri,
"capabilities": {
"textDocument": {
"definition": { "dynamicRegistration": false, "linkSupport": true },
"references": { "dynamicRegistration": false },
}
},
})
}
fn write_message(stdin: &mut impl Write, msg: &Value) -> std::io::Result<()> {
let body = serde_json::to_vec(msg)?;
write!(stdin, "Content-Length: {}\r\n\r\n", body.len())?;
stdin.write_all(&body)?;
stdin.flush()
}
fn read_message(reader: &mut impl BufRead) -> Option<Value> {
let mut content_len: usize = 0;
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(v) = line.strip_prefix("Content-Length:") {
content_len = v.trim().parse().ok()?;
}
}
let mut buf = vec![0u8; content_len];
std::io::Read::read_exact(reader, &mut buf).ok()?;
serde_json::from_slice(&buf).ok()
}
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn utf16_roundtrip_ascii() {
let line = "fn main() {}";
assert_eq!(char_to_utf16(line, 3), 3);
assert_eq!(utf16_to_char(line, 3), 3);
}
#[test]
fn utf16_handles_multibyte_and_emoji() {
let line = "let x = \"あ👍\";";
let c = line.chars().position(|c| c == '👍').unwrap();
let u = char_to_utf16(line, c);
assert_eq!(utf16_to_char(line, u), c, "char↔utf16 must round-trip");
let after = char_to_utf16(line, c + 1);
assert_eq!(after, u + 2, "emoji counts as 2 utf-16 units");
}
#[test]
fn framing_roundtrip() {
let msg = json!({ "jsonrpc": "2.0", "id": 7, "method": "x", "params": { "a": 1 } });
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
let text = String::from_utf8(buf.clone()).unwrap();
assert!(text.starts_with("Content-Length: "));
let mut reader = BufReader::new(Cursor::new(buf));
let got = read_message(&mut reader).expect("parse frame");
assert_eq!(got, msg);
}
#[test]
fn parse_locations_handles_variants() {
let single = json!({
"uri": "file:///tmp/a.rs",
"range": { "start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 8} }
});
let locs = parse_locations(&single);
assert_eq!(locs.len(), 1);
assert_eq!(locs[0].line, 2);
assert_eq!(locs[0].character, 4);
let arr = json!([single]);
assert_eq!(parse_locations(&arr).len(), 1);
assert!(parse_locations(&Value::Null).is_empty());
}
#[test]
fn config_defaults_cover_rust_php_ruby() {
let cfg = LspConfig {
langs: default_langs(),
};
assert_eq!(cfg.lang_for_ext("rs").map(|l| l.id.as_str()), Some("rust"));
assert_eq!(cfg.lang_for_ext("php").map(|l| l.id.as_str()), Some("php"));
assert_eq!(cfg.lang_for_ext("phtml").map(|l| l.id.as_str()), Some("php"));
assert_eq!(cfg.lang_for_ext("rb").map(|l| l.id.as_str()), Some("ruby"));
assert!(cfg.lang_for_ext("xyz").is_none());
assert_eq!(cfg.command_for("rust"), Some(vec!["rust-analyzer".to_string()]));
}
#[test]
#[ignore = "spawns rust-analyzer; run with --ignored"]
fn rust_analyzer_resolves_definition() {
use std::time::Instant;
let dir = std::env::temp_dir().join(format!("srev_lsp_e2e_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"e2e\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
let line0 = "fn target() -> i32 { 1 }";
let line1 = "fn main() { let _ = target(); }";
let src = format!("{line0}\n{line1}\n");
let main_rs = dir.join("src").join("main.rs");
std::fs::write(&main_rs, &src).unwrap();
let mut mgr = LspManager::new(&dir);
if mgr.ensure_open(&main_rs, "rust", &src) {
eprintln!("rust-analyzer unavailable; skipping");
let _ = std::fs::remove_dir_all(&dir);
return;
}
let ch = line1.find("target()").unwrap() as u32;
let deadline = Instant::now() + Duration::from_secs(90);
let mut pending = HashSet::new();
let mut last_req = Instant::now() - Duration::from_secs(10);
loop {
if last_req.elapsed() > Duration::from_secs(2) {
if let Some(id) = mgr.request_definition(&main_rs, 1, ch) {
pending.insert(id);
}
last_req = Instant::now();
}
for (rid, locs) in mgr.poll() {
if pending.contains(&rid) && !locs.is_empty() {
assert_eq!(locs[0].line, 0, "target() is defined on line 0");
assert!(locs[0].path.ends_with("main.rs"), "got {:?}", locs[0].path);
let _ = std::fs::remove_dir_all(&dir);
return;
}
}
if Instant::now() > deadline {
let _ = std::fs::remove_dir_all(&dir);
panic!("rust-analyzer did not resolve definition within timeout");
}
thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn user_config_overrides_command_and_disables() {
let mut langs = default_langs();
let toml = r#"
[lsp.rust]
command = ["my-ra", "--stdio"]
[lsp.ruby]
command = []
[lsp.go]
extensions = ["go"]
command = ["gopls"]
"#;
let cfg: FileConfig = toml::from_str(toml).unwrap();
for (id, entry) in cfg.lsp {
match langs.iter_mut().find(|l| l.id == id) {
Some(e) => {
e.command = entry.command;
if !entry.extensions.is_empty() {
e.extensions = entry.extensions;
}
}
None => langs.push(LangServerDef {
id,
extensions: entry.extensions,
command: entry.command,
}),
}
}
let cfg = LspConfig { langs };
assert_eq!(
cfg.command_for("rust"),
Some(vec!["my-ra".to_string(), "--stdio".to_string()])
);
assert!(cfg.lang_for_ext("rb").is_none());
assert_eq!(cfg.lang_for_ext("go").map(|l| l.id.as_str()), Some("go"));
}
}