use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver};
use strop_lsp::languages::Languages;
use strop_lsp::registry::{self, ServerSpec};
use strop_lsp::{Client, LspEvent};
use super::Editor;
pub(crate) struct LspServer {
pub key: (PathBuf, String),
pub client: strop_lsp::Client,
pub rx: Receiver<strop_lsp::LspEvent>,
}
impl Editor {
pub(crate) fn lsp_maybe_attach(&mut self) {
if cfg!(test) {
return; }
let Some(path) = self.buf().path.clone() else {
return;
};
let ext = Path::new(&path)
.extension()
.map(|e| format!(".{}", e.to_string_lossy()));
let Some(ext) = ext else { return };
let abs = if Path::new(&path).is_absolute() {
PathBuf::from(&path)
} else {
self.cwd.join(&path)
};
let languages: &Languages = self.languages_for(&abs);
let warn = languages.warnings();
let Some(spec) = registry::for_extension(&ext, languages) else {
if !warn.is_empty() {
self.message = format!("languages.toml: {}", warn.join("; "));
}
return;
};
if spec.project_executable {
let root = registry::workspace_root(&abs, &self.cwd);
if !crate::session::is_trusted(self.state_dir.as_deref(), &root) {
self.message = format!(
"project config wants to run `{}` — :trust to allow (once)",
spec.command
);
return;
}
}
let root_known = self.lsp_server_root(&abs, languages);
let key = (root_known, spec.name.to_string());
if self.lsp_servers.iter().any(|s| s.key == key) {
self.lsp_did_open_current();
return;
}
if !spec.absolute_command()
&& std::process::Command::new(spec.command)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_err()
{
self.lsp_hint_once(spec);
return;
}
let (tx, rx) = channel();
match Client::spawn(&spec, &key.0.clone(), tx) {
Some(client) => {
if let Some(app_tx) = &self.app_tx {
let enc = client.encoding();
let tx = app_tx.clone();
std::thread::spawn(move || {
while let Ok(ev) = rx.recv() {
if tx.send(super::events::AppEvent::Lsp(enc, ev)).is_err() {
break;
}
}
});
let (_, rx) = channel();
self.lsp_servers.push(LspServer { key, client, rx });
} else {
self.lsp_servers.push(LspServer { key, client, rx });
}
self.message = format!("lsp: {} starting", spec.name);
if !warn.is_empty() {
self.message
.push_str(&format!(" — languages.toml: {}", warn.join("; ")));
}
self.lsp_did_open_current();
}
None => self.lsp_hint_once(spec),
}
}
fn lsp_hint_once(&mut self, spec: ServerSpec<'static>) {
if self.lsp_hints_shown.insert(spec.name) {
let hint = spec
.install_hint
.unwrap_or("install it or fix the command in languages.toml");
self.message = format!("lsp: {} not available — {}", spec.name, hint);
}
}
fn lsp_server_root(&self, abs: &Path, languages: &Languages) -> PathBuf {
languages
.project_root
.as_deref()
.map(Path::to_path_buf)
.unwrap_or_else(|| {
let fallback = self
.git
.as_ref()
.map(|g| g.workdir().to_path_buf())
.unwrap_or_else(|| self.cwd.clone());
registry::workspace_root(abs, &fallback)
})
}
fn lsp_current(&mut self) -> Option<&LspServer> {
let path = self.buf().path.as_deref()?;
let abs = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
self.cwd.join(path)
};
let ext = format!(".{}", abs.extension()?.to_string_lossy());
let languages: &Languages = self.languages_for(&abs);
let spec = registry::for_extension(&ext, languages)?;
let root = self.lsp_server_root(&abs, languages);
let key = (root, spec.name.to_string());
self.lsp_servers.iter().find(|s| s.key == key)
}
fn lsp_did_open_current(&mut self) {
let Some(path) = self.buf().path.clone() else {
return;
};
let abs = if std::path::Path::new(&path).is_absolute() {
PathBuf::from(&path)
} else {
self.cwd.join(&path)
};
if !self.lsp_opened.insert(abs.clone()) {
return;
}
let Some(client) = self.lsp_current().map(|s| s.client.clone()) else {
return;
};
let lang = lang_id(&abs);
client.did_open(&abs, lang, &self.buf().rope.to_string());
}
pub fn lsp_sync_changed(&mut self) {
let Some(buf) = self.docs.get(self.current()).map(|d| &d.buf) else {
return;
};
let Some(path) = buf.path.clone() else {
return;
};
let abs = if std::path::Path::new(&path).is_absolute() {
PathBuf::from(&path)
} else {
self.cwd.join(&path)
};
let epoch = buf.epoch;
if self.lsp_sent_epochs.get(&abs) == Some(&epoch) {
return;
}
self.lsp_sent_epochs.insert(abs.clone(), epoch);
let text = buf.rope.to_string();
let Some(client) = self.lsp_current().map(|s| s.client.clone()) else {
return;
};
client.did_change(&abs, &text);
}
pub fn drain_lsp(&mut self) {
let mut events: Vec<(strop_lsp::PositionEncoding, LspEvent)> = Vec::new();
for srv in &self.lsp_servers {
while let Ok(ev) = srv.rx.try_recv() {
events.push((srv.client.encoding(), ev));
}
}
for (enc, event) in events {
self.handle_lsp_event(enc, event);
}
}
pub(crate) fn handle_lsp_event(&mut self, enc: strop_lsp::PositionEncoding, event: LspEvent) {
{
match event {
LspEvent::Diagnostics {
path,
mut diags,
version,
} => {
let stale = self
.lsp_servers
.iter()
.find(|srv| path.starts_with(&srv.key.0))
.and_then(|srv| srv.client.sent_version(&path))
.is_some_and(|sent| version.is_some_and(|v| v < sent));
if stale {
return;
}
{
if let Some(buf) = self.buffer_for_path(&path) {
for d in &mut diags {
let line = buf.line_text(d.line);
d.col = strop_lsp::to_byte_col(&line, d.col, enc);
let end_line = buf.line_text(d.end_line);
d.end_col = strop_lsp::to_byte_col(&end_line, d.end_col, enc);
}
}
}
self.diags.insert(path, diags);
}
LspEvent::Ready { server } => {
self.message = format!("lsp: {server} ready");
}
LspEvent::Failed { server, hint } => {
self.message = format!("lsp: {server} failed — {hint}");
}
LspEvent::Note { text } => self.message = text,
LspEvent::HoverText { text } => {
let stale = self.hover_request.is_some_and(|(doc, depth)| {
doc != self.current() || self.buf().history.depth() != depth
});
if !stale {
self.hover_card = Some(text);
}
}
LspEvent::Locations { req_revision, .. }
if req_revision != 0 && !self.lsp_nav_fresh(req_revision) =>
{
}
LspEvent::Locations { kind, items, .. } => match items.len() {
0 => self.message = format!("lsp: no {}", kind.label()),
1 => {
let (path, line, col) = items.into_iter().next().unwrap();
self.jump_to_location(path, line, col, enc);
}
n => {
use strop_picker::{Item, Kind, Payload};
let label = kind.label();
let items: Vec<Item> = items
.into_iter()
.map(|(path, line, col)| Item {
text: format!("{}:{}:{}", path.display(), line + 1, col + 1),
payload: Payload::Grep {
path,
line: line + 1,
col: col + 1,
match_len: 1,
line_text: String::new(),
},
})
.collect();
let picker = strop_picker::Picker::new(Kind::Locations, items, false);
self.set_picker(crate::editor::PickerGlue::diagnostics(picker));
self.message = format!("{n} {label}");
}
},
LspEvent::GotoLocation {
path,
line,
col,
req_revision,
} => {
if req_revision != 0 && !self.lsp_nav_fresh(req_revision) {
return;
}
if std::env::var_os("STROP_LSP_LOG").is_some() {
eprintln!("strop: goto {}:{}:{}", path.display(), line, col);
}
self.jump_to_location(path, line, col, enc);
}
}
}
}
fn jump_to_location(
&mut self,
path: PathBuf,
line: usize,
col: usize,
enc: strop_lsp::PositionEncoding,
) {
let path_s = path.display().to_string();
if let Err(e) = self.open_buffer(&path_s) {
self.message = format!("open {path_s}: {e}");
return;
}
let probe = self.cwd.join("x");
let root = registry::workspace_root(&probe, &self.cwd);
if !path.starts_with(&root) && !self.buf().readonly {
self.buf_mut().readonly = true;
self.message = format!("{path_s} [readonly — outside workspace; :set noro to edit]");
}
let col = {
let line_idx = line.min(self.buf().len_lines().saturating_sub(1));
let text = self.buf().line_text(line_idx);
strop_lsp::to_byte_col(&text, col, enc)
};
let start = self.buf().line_start(line.min(self.buf().len_lines() - 1));
self.set_head(self.buf().clamp_boundary(start + col));
self.clamp_cursor();
self.scroll_to_cursor(self.view_rows());
}
pub(crate) fn lsp_locations(&mut self, kind: strop_lsp::LocKind) {
let Some(client) = self.lsp_current().map(|s| s.client.clone()) else {
self.message = "no language server — install it or fix languages.toml".into();
return;
};
let Some(path) = self.buf().path.clone() else {
return;
};
let abs = if std::path::Path::new(&path).is_absolute() {
PathBuf::from(&path)
} else {
self.cwd.join(&path)
};
let line = self.buf().line_of(self.head());
let col = self.server_col(&client, self.buf().col_of(self.head()));
let label = kind.label();
self.lsp_nav_request = Some((self.current(), self.buf().history.depth() as u64));
client.locations(kind, &abs, line, col, self.buf().history.depth() as u64);
self.message = format!("lsp: {label} …");
}
pub(crate) fn jump_diagnostic(&mut self, forward: bool) {
let Some(path) = self.buf().path.clone() else {
return;
};
let abs = if std::path::Path::new(&path).is_absolute() {
PathBuf::from(&path)
} else {
self.cwd.join(&path)
};
let Some(diags) = self.diags.get(&abs).filter(|d| !d.is_empty()) else {
self.message = "no diagnostics".into();
return;
};
let cur = self.buf().line_of(self.head());
let target = if forward {
diags
.iter()
.find(|d| d.line > cur || (d.line == cur && d.col > self.buf().col_of(self.head())))
.or(diags.first())
} else {
diags
.iter()
.rev()
.find(|d| d.line < cur || (d.line == cur && d.col < self.buf().col_of(self.head())))
.or(diags.last())
};
let Some(d) = target else { return };
let (line, col, msg) = (d.line, d.col, d.message.clone());
let start = self.buf().line_start(line.min(self.buf().len_lines() - 1));
self.set_head(self.buf().clamp_boundary(start + col));
self.clamp_cursor();
self.scroll_to_cursor(self.view_rows());
self.message = msg;
}
fn lsp_nav_fresh(&self, req_revision: u64) -> bool {
self.lsp_nav_request.is_some_and(|(doc, rev)| {
doc == self.current() && rev == self.buf().history.depth() as u64 && rev == req_revision
})
}
pub(crate) fn open_diagnostics_picker(&mut self) {
use strop_picker::{Item, Kind, Payload};
let items: Vec<Item> = self
.diags
.iter()
.flat_map(|(path, diags)| {
diags.iter().map(move |d| Item {
text: format!(
"{}:{} {} {}",
path.display(),
d.line + 1,
d.severity_char(),
d.message
),
payload: Payload::Grep {
path: path.clone(),
line: d.line + 1,
col: d.col + 1,
match_len: 1,
line_text: d.message.clone(),
},
})
})
.collect();
if items.is_empty() {
self.message = "no diagnostics".into();
return;
}
let picker = strop_picker::Picker::new(Kind::Diagnostics, items, false);
self.set_picker(crate::editor::PickerGlue::diagnostics(picker));
}
pub(crate) fn lsp_hover(&mut self) {
let Some(client) = self.lsp_current().map(|s| s.client.clone()) else {
self.message = "no language server — install it or fix languages.toml".into();
return;
};
let Some(path) = self.buf().path.clone() else {
return;
};
let abs = if std::path::Path::new(&path).is_absolute() {
PathBuf::from(&path)
} else {
self.cwd.join(&path)
};
let line = self.buf().line_of(self.head());
let col = self.buf().col_of(self.head());
let col = self.server_col(&client, col);
self.hover_request = Some((self.current(), self.buf().history.depth()));
client.hover(&abs, line, col);
}
fn buffer_for_path(&self, abs: &std::path::Path) -> Option<&strop_core::Buffer> {
self.docs.iter().map(|(_, d)| &d.buf).find(|b| {
b.path.as_deref().is_some_and(|p| {
let p = std::path::Path::new(p);
let buf_abs = if p.is_absolute() {
p.to_path_buf()
} else {
self.cwd.join(p)
};
buf_abs == abs || buf_abs.canonicalize().ok().as_deref() == Some(abs)
})
})
}
fn server_col(&self, client: &strop_lsp::Client, byte_col: usize) -> usize {
let line = self.buf().line_of(self.head());
let text = self.buf().line_text(line);
strop_lsp::to_server_col(&text, byte_col, client.encoding())
}
pub(crate) fn lsp_goto_definition(&mut self) {
let Some(client) = self.lsp_current().map(|s| s.client.clone()) else {
self.message = "no language server — install it or fix languages.toml".into();
return;
};
let Some(path) = self.buf().path.clone() else {
return;
};
let abs = if std::path::Path::new(&path).is_absolute() {
PathBuf::from(&path)
} else {
self.cwd.join(&path)
};
let line = self.buf().line_of(self.head());
let col = self.buf().col_of(self.head());
let col = self.server_col(&client, col);
self.lsp_nav_request = Some((self.current(), self.buf().history.depth() as u64));
client.goto_definition(&abs, line, col, self.buf().history.depth() as u64);
}
pub(crate) fn lsp_switch_source_header(&mut self) {
let Some(client) = self.lsp_current().map(|s| s.client.clone()) else {
self.message = "no language server — install it or fix languages.toml".into();
return;
};
let Some(path) = self.buf().path.clone() else {
return;
};
let abs = if std::path::Path::new(&path).is_absolute() {
PathBuf::from(&path)
} else {
self.cwd.join(&path)
};
client.switch_source_header(&abs);
}
}
impl Editor {
fn languages_for(&mut self, buffer: &Path) -> &'static Languages {
let root = registry::workspace_root(buffer, &self.cwd);
if !self.langs_by_root.contains_key(&root) {
let xdg = strop_lsp::languages::xdg_path();
let project = strop_lsp::languages::project_path(buffer);
let loaded: &'static Languages = Box::leak(Box::new(Languages::load(
xdg.as_deref(),
project.as_deref(),
)));
self.langs_by_root.insert(root.clone(), loaded);
}
self.langs_by_root[&root]
}
}
fn lang_id(path: &std::path::Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("rs") => "rust",
Some("py") | Some("pyi") => "python",
Some("go") => "go",
Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "javascript",
Some("ts") => "typescript",
Some("tsx") => "typescriptreact",
Some("json") => "json",
Some("sh") | Some("bash") => "shellscript",
Some("c") | Some("h") => "c",
Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") => "cpp",
_ => "plaintext",
}
}
impl Editor {
pub(crate) fn lsp_goto_definition_pub(&mut self) {
self.lsp_goto_definition();
}
pub(crate) fn lsp_switch_source_header_pub(&mut self) {
self.lsp_switch_source_header();
}
pub(crate) fn lsp_hover_pub(&mut self) {
self.lsp_hover();
}
pub fn lsp_locations_pub(&mut self, kind: strop_lsp::LocKind) {
self.lsp_locations(kind);
}
pub fn jump_diagnostic_pub(&mut self, forward: bool) {
self.jump_diagnostic(forward);
}
}