diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index a2e5444b..c97d1253 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -263,6 +263,12 @@ pub struct Config { /// [`Self::editor_soft_wrap`]. Files that are not Markdown ignore it. #[serde(default)] pub editor_markdown_preview: bool, + /// Whether the code editor starts language servers (rust-analyzer, + /// typescript-language-server, …) for the files it opens, for + /// diagnostics, completion, hover and go to definition. On by default; + /// a language whose server is not installed simply goes without. + #[serde(default = "default_true")] + pub editor_lsp: bool, /// Whether the sidebar files tabs nobody pinned into groups of its own — /// by repository, and by host for an SSH pane. Off, those tabs sit in one /// flat list below the pinned groups, which are the user's and show @@ -759,6 +765,7 @@ impl Default for Config { scm_changes_tree: false, editor_soft_wrap: false, editor_markdown_preview: false, + editor_lsp: true, sidebar_auto_grouping: true, notify_on_command_finish: NotifyMode::Unfocused, check_for_updates: true, diff --git a/src/core/actions.rs b/src/core/actions.rs index 1fb5be95..0ff8969a 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -128,6 +128,10 @@ actions!( EditorSaveAs, EditorGoToLine, EditorNewFile, + EditorGoToDefinition, + EditorQuickFix, + EditorRenameSymbol, + EditorFormatDocument, OpenSshProfiles, RestartSshSession, SendTab, diff --git a/src/ui/app.rs b/src/ui/app.rs index 49db39e6..fdcb35ed 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -9359,6 +9359,37 @@ impl Render for Tty7App { } this.editor_go_to_line(window, cx) })) + // The language-server commands (`ui::lsp`) belong to the + // editor only while it has the focus; F2 and F12 mean + // something to programs in the terminal too. + .on_action(cx.listener(|this, _: &EditorGoToDefinition, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.lsp_go_to_definition(window, cx) + })) + .on_action(cx.listener(|this, _: &EditorQuickFix, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.lsp_code_actions(window, cx) + })) + .on_action(cx.listener(|this, _: &EditorRenameSymbol, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.lsp_rename_start(window, cx) + })) + .on_action(cx.listener(|this, _: &EditorFormatDocument, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.lsp_format_document(window, cx) + })) .on_action(cx.listener(|this, _: &EditorNewFile, window, cx| { this.editor_new_file(window, cx) })) diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index b03f169e..496b2456 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -147,6 +147,25 @@ impl OpenFile { } language_for_path(&self.path) } + + fn local(&self) -> Option { + (self.untitled.is_none() && self.host.id().is_local()).then(|| LocalFile { + id: self.id(), + input: self.input.clone(), + path: self.path.clone(), + language: self.language(), + indent: self.indent, + }) + } +} + +/// A saved file on this machine, as the language servers see it. +pub(crate) struct LocalFile { + pub(crate) id: BufferId, + pub(crate) input: Entity, + pub(crate) path: PathBuf, + pub(crate) language: &'static str, + pub(crate) indent: Indent, } /// One tab's view of the editor: which buffers it shows, in the order its @@ -245,7 +264,15 @@ struct SaveWaiter { enum BarKind { GoToLine, - SaveAs { id: BufferId, then_close: bool }, + SaveAs { + id: BufferId, + then_close: bool, + }, + /// A new name for the symbol at `offset` (`ui::lsp`). + Rename { + id: BufferId, + offset: usize, + }, } /// The one-line prompt that sits above the text: go to line, or name a file @@ -855,6 +882,9 @@ impl Tty7App { .filter_map(|p| p.parent().map(Path::to_path_buf)) .collect(); self.editor.watched_files = files; + // Every change to which files are open, or where they live, passes + // through here — which is exactly what the language servers follow. + self.lsp_sync_buffers(cx); if dirs == self.editor.watched_dirs { return; } @@ -1297,9 +1327,41 @@ impl Tty7App { if let Some(f) = self.buffer_mut(id) { f.dirty = dirty; } + self.lsp_buffer_edited(id, cx); cx.notify(); } + /// Saved files on this machine — what a language server can read + /// (`ui::lsp`). Untitled buffers and files on other hosts are not. + pub(crate) fn editor_local_files(&self) -> Vec { + self.editor + .buffers + .iter() + .filter_map(OpenFile::local) + .collect() + } + + pub(crate) fn editor_local_file(&self, id: BufferId) -> Option { + self.buffer(id).and_then(OpenFile::local) + } + + pub(crate) fn editor_active_local_file(&self) -> Option { + self.active_buffer().and_then(OpenFile::local) + } + + /// Asks for a new name for the symbol at `offset`, in the bar above the + /// text; `ui::lsp` does the renaming once it is answered. + pub(crate) fn editor_open_rename_bar( + &mut self, + id: BufferId, + offset: usize, + current: String, + window: &mut Window, + cx: &mut Context, + ) { + self.editor_open_bar(BarKind::Rename { id, offset }, current, window, cx); + } + /// A new, empty, never-saved buffer, on the window's own machine. pub(crate) fn editor_new_file(&mut self, window: &mut Window, cx: &mut Context) { let Some(host) = self.active_host(cx) else { @@ -1587,6 +1649,7 @@ impl Tty7App { f.saved_format = format; f.conflict = None; app.editor_note_edit(id, cx); + app.lsp_buffer_saved(id, cx); // A save is a working-tree edit the `.git` watch cannot // see, and the file tree only sees it while it happens // to be showing that directory. @@ -1816,6 +1879,7 @@ impl Tty7App { ) } BarKind::SaveAs { .. } => t(L10nKey::EditorSaveAsPlaceholder).to_string(), + BarKind::Rename { .. } => t_fmt(L10nKey::LspRenamePlaceholder, &[("name", &initial)]), }; let input = cx.new(|cx| { InputState::new(window, cx) @@ -1833,7 +1897,7 @@ impl Tty7App { InputEvent::Blur => { if matches!( this.editor.bar.as_ref().map(|b| &b.kind), - Some(BarKind::GoToLine) + Some(BarKind::GoToLine | BarKind::Rename { .. }) ) { this.editor.bar = None; cx.notify(); @@ -1897,6 +1961,14 @@ impl Tty7App { }; place_cursor(input, position, CURSOR_SCROLL_ATTEMPTS, window, cx); } + BarKind::Rename { id, offset } => { + let name = text.trim().to_string(); + if name.is_empty() { + return; + } + self.editor_close_bar(window, cx); + self.lsp_rename(id, offset, name, window, cx); + } BarKind::SaveAs { id, then_close } => { let path = PathBuf::from(text.trim()); if path.as_os_str().is_empty() { @@ -2105,6 +2177,7 @@ impl Tty7App { t(L10nKey::EditorGoToLineAction), Box::new(crate::core::actions::EditorGoToLine), ); + let menu = this.lsp_menu_items(menu, id, cx); this.editor_file_menu_items(menu, id, app, cx) } @@ -3179,6 +3252,7 @@ impl Tty7App { let label = match bar.kind { BarKind::GoToLine => t(L10nKey::EditorGoToLine), BarKind::SaveAs { .. } => t(L10nKey::EditorSaveAs), + BarKind::Rename { .. } => t(L10nKey::LspRenameSymbol), }; Some( h_flex() @@ -3279,6 +3353,7 @@ impl Tty7App { this.child(div().min_w_0().text_ellipsis().child(t)) }) .child(div().flex_1()) + .children(self.render_lsp_status(cx)) .when(is_markdown, |this| { this.child( Button::new("status-md-preview") diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 22f913e7..c962d6f6 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1213,6 +1213,17 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorFileDeletedOnDisk => "This file was deleted on disk", L10nKey::EditorIndentSpaces => "Spaces: {n}", L10nKey::EditorIndentTabs => "Tab Size: {n}", + L10nKey::LspGoToDefinition => "Go to Definition", + L10nKey::LspQuickFix => "Quick Fix…", + L10nKey::LspFormatDocument => "Format Document", + L10nKey::LspRenameSymbol => "Rename symbol", + L10nKey::LspRenameSymbolAction => "Rename Symbol…", + L10nKey::LspRenamePlaceholder => "New name for {name}", + L10nKey::LspRenameFailed => "Could not rename {name}", + L10nKey::LspServerMissing => "{name} not installed", + L10nKey::LspServerStarting => "{name} starting…", + L10nKey::LspServerDown => "{name} stopped", + L10nKey::LspProblemsTooltip => "{errors} errors, {warnings} warnings", L10nKey::PanelInfoTitle => "Info", L10nKey::PanelChangesTitle => "Changes", L10nKey::PanelScmTitle => "Changes", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index dfebc354..a401c7a5 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1261,6 +1261,17 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorFileDeletedOnDisk => "このファイルはディスク上で削除されました", L10nKey::EditorIndentSpaces => "スペース: {n}", L10nKey::EditorIndentTabs => "タブ幅: {n}", + L10nKey::LspGoToDefinition => "定義へ移動", + L10nKey::LspQuickFix => "クイックフィックス…", + L10nKey::LspFormatDocument => "ドキュメントのフォーマット", + L10nKey::LspRenameSymbol => "シンボル名を変更", + L10nKey::LspRenameSymbolAction => "シンボル名を変更…", + L10nKey::LspRenamePlaceholder => "{name} の新しい名前", + L10nKey::LspRenameFailed => "{name} の名前を変更できませんでした", + L10nKey::LspServerMissing => "{name} 未インストール", + L10nKey::LspServerStarting => "{name} 起動中…", + L10nKey::LspServerDown => "{name} 停止", + L10nKey::LspProblemsTooltip => "エラー {errors} 件、警告 {warnings} 件", L10nKey::PanelInfoTitle => "情報", L10nKey::PanelChangesTitle => "変更", L10nKey::PanelScmTitle => "変更", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 746dbdb1..d8fcc187 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -947,6 +947,17 @@ l10n_keys! { EditorFileDeletedOnDisk, EditorIndentSpaces, EditorIndentTabs, + LspGoToDefinition, + LspQuickFix, + LspFormatDocument, + LspRenameSymbol, + LspRenameSymbolAction, + LspRenamePlaceholder, + LspRenameFailed, + LspServerMissing, + LspServerStarting, + LspServerDown, + LspProblemsTooltip, PanelInfoTitle, PanelChangesTitle, PanelScmTitle, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 7c1fc618..29c1c3d4 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1133,6 +1133,17 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorFileDeletedOnDisk => "此文件已在磁盘上被删除", L10nKey::EditorIndentSpaces => "空格:{n}", L10nKey::EditorIndentTabs => "Tab 宽度:{n}", + L10nKey::LspGoToDefinition => "转到定义", + L10nKey::LspQuickFix => "快速修复…", + L10nKey::LspFormatDocument => "格式化文档", + L10nKey::LspRenameSymbol => "重命名符号", + L10nKey::LspRenameSymbolAction => "重命名符号…", + L10nKey::LspRenamePlaceholder => "{name} 的新名称", + L10nKey::LspRenameFailed => "无法重命名 {name}", + L10nKey::LspServerMissing => "未安装 {name}", + L10nKey::LspServerStarting => "{name} 启动中…", + L10nKey::LspServerDown => "{name} 已停止", + L10nKey::LspProblemsTooltip => "{errors} 个错误,{warnings} 个警告", L10nKey::PanelInfoTitle => "信息", L10nKey::PanelChangesTitle => "更改", L10nKey::PanelScmTitle => "更改", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index e28d7b91..092814e2 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -70,6 +70,15 @@ fn fixed_bindings() -> Vec { let search = Some(crate::ui::search::KEY_CONTEXT); bindings.push(KeyBinding::new("tab", SearchNextTab, search)); bindings.push(KeyBinding::new("shift-tab", SearchPrevTab, search)); + // The language-server commands, on the keys VS Code taught everyone, and + // only inside a text field: F2 and F12 in a terminal belong to the + // program running there. The handlers act only for the code editor and + // let the key go on anywhere else. ⌘. / Ctrl+. needs nothing here: the + // editor binds it to the same code-action menu itself. + let input = Some("Input"); + bindings.push(KeyBinding::new("f12", EditorGoToDefinition, input)); + bindings.push(KeyBinding::new("f2", EditorRenameSymbol, input)); + bindings.push(KeyBinding::new("shift-alt-f", EditorFormatDocument, input)); bindings } @@ -592,6 +601,13 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { ("EditorGoToLine", "ctrl-g"), // Unbound: ⌘N is New Window, and the editor's header has a + for it. ("EditorNewFile", ""), + // The language-server commands. Unbound here — a default on a bare + // function key would take it from the shell — and bound instead in + // the editor's own context by `fixed_bindings`. + ("EditorGoToDefinition", ""), + ("EditorQuickFix", ""), + ("EditorRenameSymbol", ""), + ("EditorFormatDocument", ""), ("OpenSshProfiles", ""), ("RestartSshSession", "secondary-shift-r"), ("Quit", per_platform("secondary-q", "secondary-shift-q")), @@ -878,6 +894,19 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Terminal, t(L10nKey::EditorGoToLineAction).to_string(), ), + "EditorGoToDefinition" => ( + CommandGroup::Terminal, + t(L10nKey::LspGoToDefinition).to_string(), + ), + "EditorQuickFix" => (CommandGroup::Terminal, t(L10nKey::LspQuickFix).to_string()), + "EditorRenameSymbol" => ( + CommandGroup::Terminal, + t(L10nKey::LspRenameSymbolAction).to_string(), + ), + "EditorFormatDocument" => ( + CommandGroup::Terminal, + t(L10nKey::LspFormatDocument).to_string(), + ), "EditorNewFile" => ( CommandGroup::Terminal, t(L10nKey::EditorNewFile).to_string(), @@ -1573,6 +1602,10 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "EditorSaveAs" => KeyBinding::new(keystroke, EditorSaveAs, None), "EditorGoToLine" => KeyBinding::new(keystroke, EditorGoToLine, None), "EditorNewFile" => KeyBinding::new(keystroke, EditorNewFile, None), + "EditorGoToDefinition" => KeyBinding::new(keystroke, EditorGoToDefinition, None), + "EditorQuickFix" => KeyBinding::new(keystroke, EditorQuickFix, None), + "EditorRenameSymbol" => KeyBinding::new(keystroke, EditorRenameSymbol, None), + "EditorFormatDocument" => KeyBinding::new(keystroke, EditorFormatDocument, None), "OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None), "RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None), "Quit" => KeyBinding::new(keystroke, Quit, None), diff --git a/src/ui/lsp/client.rs b/src/ui/lsp/client.rs new file mode 100644 index 00000000..922cf189 --- /dev/null +++ b/src/ui/lsp/client.rs @@ -0,0 +1,491 @@ +//! One running language server: its process, the threads that pump its +//! pipes, and the request/response bookkeeping on top of [`super::rpc`]. +//! +//! Each pipe gets a dedicated thread rather than a task on gpui's background +//! executor: a read that blocks until the server says something would park +//! one of the executor's few workers for the life of the server. The threads +//! end on their own when the pipes close, which killing the process does. +//! +//! What the server sends unprompted — diagnostics, its own requests, the pipe +//! closing — arrives on the [`ServerEvent`] channel, for the store to handle +//! on the main thread. + +use std::collections::HashMap; +use std::io::{BufRead as _, BufReader, Read, Write}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; + +use anyhow::{Context as _, anyhow}; +use serde_json::Value; + +use super::rpc::{self, FrameReader, Incoming, RequestId, ResponseError}; + +type Reply = smol::channel::Sender>; +type Pending = Arc>>; + +/// Something the server did that nobody asked for. +#[derive(Debug)] +pub(crate) enum ServerEvent { + Notification { + method: String, + params: Value, + }, + Request { + id: Value, + method: String, + params: Value, + }, + /// The server's output closed: it exited, crashed or was killed. + Exited, +} + +pub(crate) struct LspClient { + next_id: AtomicI64, + pending: Pending, + out: std::sync::mpsc::Sender>, + /// Messages held back until the server has answered `initialize`, which + /// the protocol requires to be the first thing it sees. `None` once the + /// gate is open. + gate: Mutex>>>, + child: Mutex>, +} + +impl LspClient { + /// Starts `program` in `root` and wires up its pipes. + pub(crate) fn spawn( + name: &str, + program: &Path, + args: &[&str], + root: &Path, + ) -> anyhow::Result<(Arc, smol::channel::Receiver)> { + let mut command = Command::new(program); + command + .args(args) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + // CREATE_NO_WINDOW: a server is a pipe, not a console. + command.creation_flags(0x0800_0000); + } + let mut child = command + .spawn() + .with_context(|| format!("starting {}", program.display()))?; + let stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; + let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?; + if let Some(stderr) = child.stderr.take() { + let name = name.to_owned(); + // A server that fills its stderr pipe with nobody reading blocks + // on the next write — and stops answering. Drained into the log. + std::thread::Builder::new() + .name("tty7-lsp-stderr".into()) + .spawn(move || { + for line in BufReader::new(stderr).lines() { + let Ok(line) = line else { break }; + log::debug!("lsp {name} stderr: {line}"); + } + }) + .ok(); + } + let (client, events) = Self::from_streams(name, stdout, stdin)?; + *client.child.lock().unwrap() = Some(child); + Ok((client, events)) + } + + /// The client over any pair of pipes — a real process's, or a test's. + pub(crate) fn from_streams( + name: &str, + reader: impl Read + Send + 'static, + mut writer: impl Write + Send + 'static, + ) -> anyhow::Result<(Arc, smol::channel::Receiver)> { + let (out, out_rx) = std::sync::mpsc::channel::>(); + std::thread::Builder::new() + .name("tty7-lsp-write".into()) + .spawn(move || { + // Ends when every sender is gone — the client dropped — or + // the pipe breaks. Either way stdin closes behind it, which + // is the last word a server needs to exit. + while let Ok(bytes) = out_rx.recv() { + if writer + .write_all(&bytes) + .and_then(|_| writer.flush()) + .is_err() + { + break; + } + } + })?; + + let pending: Pending = Arc::default(); + let (events_tx, events) = smol::channel::unbounded(); + { + let pending = pending.clone(); + let name = name.to_owned(); + let mut reader = reader; + std::thread::Builder::new() + .name("tty7-lsp-read".into()) + .spawn(move || { + let mut frames = FrameReader::default(); + let mut chunk = vec![0u8; 64 * 1024]; + loop { + let n = match reader.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + frames.push(&chunk[..n]); + while let Some(body) = frames.next_frame() { + let Some(message) = rpc::classify(&body) else { + log::debug!("lsp {name}: ignoring an unreadable message"); + continue; + }; + let event = match message { + Incoming::Response { id, result } => { + let reply = pending.lock().unwrap().remove(&id); + if let Some(reply) = reply { + let _ = reply.try_send(result); + } + continue; + } + Incoming::Request { id, method, params } => { + ServerEvent::Request { id, method, params } + } + Incoming::Notification { method, params } => { + ServerEvent::Notification { method, params } + } + }; + if events_tx.send_blocking(event).is_err() { + // Nobody is listening any more; keep reading + // so the server never blocks on a full pipe. + } + } + } + // Every request still waiting will never be answered: + // dropping the senders fails them rather than leaving + // them hanging. + pending.lock().unwrap().clear(); + let _ = events_tx.send_blocking(ServerEvent::Exited); + })?; + } + + Ok(( + Arc::new(Self { + next_id: AtomicI64::new(1), + pending, + out, + gate: Mutex::new(Some(Vec::new())), + child: Mutex::new(None), + }), + events, + )) + } + + fn send(&self, bytes: Vec, gated: bool) { + let mut gate = self.gate.lock().unwrap(); + match gate.as_mut() { + Some(held) if gated => held.push(bytes), + _ => { + let _ = self.out.send(bytes); + } + } + } + + /// Lets through everything held back for `initialize`, in the order it + /// was sent. Held under the gate's lock, so nothing sent meanwhile can + /// overtake it. + pub(crate) fn open_gate(&self) { + let mut gate = self.gate.lock().unwrap(); + for bytes in gate.take().unwrap_or_default() { + let _ = self.out.send(bytes); + } + } + + /// Sends a request, returning the server's answer. Dropping the future + /// before it resolves tells the server to stop working on it. + pub(crate) fn request_raw( + self: &Arc, + method: &str, + params: Value, + ) -> impl std::future::Future> + use<> { + self.request_inner(method, params, true) + } + + /// `initialize`, which is the one request that goes past the gate. + pub(crate) fn initialize( + self: &Arc, + params: Value, + ) -> impl std::future::Future> + use<> { + self.request_inner("initialize", params, false) + } + + fn request_inner( + self: &Arc, + method: &str, + params: Value, + gated: bool, + ) -> impl std::future::Future> + use<> { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = smol::channel::bounded(1); + self.pending.lock().unwrap().insert(id, tx); + self.send(rpc::encode(&rpc::request(id, method, params)), gated); + let guard = CancelOnDrop { + client: Arc::downgrade(self), + id, + done: false, + }; + let method = method.to_owned(); + async move { + // Bound whole, not just the field written below: a closure + // capturing only `guard.done` would drop the guard — and cancel + // the request — before it was ever sent an answer. + let mut guard = guard; + let answer = rx.recv().await; + guard.done = true; + match answer { + Ok(Ok(value)) => Ok(value), + Ok(Err(e)) => Err(anyhow::Error::new(e).context(method)), + Err(_) => Err(anyhow!("{method}: the language server went away")), + } + } + } + + /// A typed request. + pub(crate) fn request( + self: &Arc, + params: R::Params, + ) -> impl std::future::Future> + use { + let params = serde_json::to_value(params).unwrap_or(Value::Null); + let answer = self.request_raw(R::METHOD, params); + async move { Ok(serde_json::from_value(answer.await?)?) } + } + + pub(crate) fn notify_raw(&self, method: &str, params: Value) { + self.send(rpc::encode(&rpc::notification(method, params)), true); + } + + pub(crate) fn notify(&self, params: N::Params) { + let params = serde_json::to_value(params).unwrap_or(Value::Null); + self.notify_raw(N::METHOD, params); + } + + /// Answers a request the server made. + pub(crate) fn respond(&self, id: Value, result: Result) { + self.send(rpc::encode(&rpc::response(id, result)), false); + } + + /// Kills the process, if there is one and it is still running, and reaps + /// it so it does not linger as a zombie. + pub(crate) fn kill(&self) { + if let Some(mut child) = self.child.lock().unwrap().take() { + if matches!(child.try_wait(), Ok(None)) { + let _ = child.kill(); + } + let _ = child.wait(); + } + } + + /// Whether the process has already exited on its own. + pub(crate) fn has_exited(&self) -> bool { + match self.child.lock().unwrap().as_mut() { + Some(child) => !matches!(child.try_wait(), Ok(None)), + None => true, + } + } + + /// Reaps the process if it has exited, without killing it. + pub(crate) fn reap_if_exited(&self) -> bool { + let mut slot = self.child.lock().unwrap(); + let exited = match slot.as_mut() { + Some(child) => !matches!(child.try_wait(), Ok(None)), + None => return true, + }; + if exited { + slot.take(); + } + exited + } +} + +impl Drop for LspClient { + fn drop(&mut self) { + // The last line of defence against a server outliving tty7's + // interest in it. A clean shutdown has already reaped it. + self.kill(); + } +} + +struct CancelOnDrop { + client: Weak, + id: RequestId, + done: bool, +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if self.done { + return; + } + let Some(client) = self.client.upgrade() else { + return; + }; + if client.pending.lock().unwrap().remove(&self.id).is_some() { + client.notify_raw("$/cancelRequest", serde_json::json!({ "id": self.id })); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// A server on the other end of two pipes, driven by the test. + struct FakeServer { + frames: FrameReader, + from_client: std::io::PipeReader, + to_client: std::io::PipeWriter, + } + + impl FakeServer { + fn next(&mut self) -> Value { + let mut chunk = [0u8; 256]; + loop { + if let Some(body) = self.frames.next_frame() { + return serde_json::from_slice(&body).unwrap(); + } + let n = self.from_client.read(&mut chunk).unwrap(); + assert!(n > 0, "the client closed its end"); + self.frames.push(&chunk[..n]); + } + } + + fn send(&mut self, value: Value) { + // Written in two pieces, so the client's reader has to put a + // frame back together. + let bytes = rpc::encode(&value); + let (a, b) = bytes.split_at(bytes.len() / 2); + self.to_client.write_all(a).unwrap(); + self.to_client.flush().unwrap(); + self.to_client.write_all(b).unwrap(); + self.to_client.flush().unwrap(); + } + } + + fn connect() -> ( + Arc, + smol::channel::Receiver, + FakeServer, + ) { + let (client_reads, server_writes) = std::io::pipe().unwrap(); + let (server_reads, client_writes) = std::io::pipe().unwrap(); + let (client, events) = + LspClient::from_streams("fake", client_reads, client_writes).unwrap(); + ( + client, + events, + FakeServer { + frames: FrameReader::default(), + from_client: server_reads, + to_client: server_writes, + }, + ) + } + + #[test] + fn nothing_but_initialize_reaches_the_server_until_the_gate_opens() { + let (client, _events, mut server) = connect(); + client.notify_raw("textDocument/didOpen", json!({ "n": 1 })); + let init = client.initialize(json!({})); + let first = server.next(); + assert_eq!(first["method"], "initialize"); + server + .send(json!({ "jsonrpc": "2.0", "id": first["id"], "result": { "capabilities": {} } })); + let answer = smol::block_on(init).unwrap(); + assert_eq!(answer, json!({ "capabilities": {} })); + client.notify_raw("initialized", json!({})); + client.open_gate(); + // Held messages come out first, in the order they were sent. + assert_eq!(server.next()["method"], "textDocument/didOpen"); + assert_eq!(server.next()["method"], "initialized"); + } + + #[test] + fn answers_find_their_way_back_to_the_request_that_asked() { + let (client, _events, mut server) = connect(); + client.open_gate(); + let a = client.request_raw("a", json!(1)); + let b = client.request_raw("b", json!(2)); + let ra = server.next(); + let rb = server.next(); + // Answered out of order. + server.send(json!({ "jsonrpc": "2.0", "id": rb["id"], "result": "B" })); + server.send(json!({ + "jsonrpc": "2.0", + "id": ra["id"], + "error": { "code": -32800, "message": "cancelled" }, + })); + assert_eq!(smol::block_on(b).unwrap(), json!("B")); + let err = smol::block_on(a).unwrap_err(); + assert!(format!("{err:#}").contains("cancelled"), "{err:#}"); + } + + #[test] + fn server_requests_and_notifications_arrive_as_events_and_can_be_answered() { + let (client, events, mut server) = connect(); + client.open_gate(); + server.send(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { "uri": "file:///a.rs", "diagnostics": [] }, + })); + server.send(json!({ + "jsonrpc": "2.0", + "id": "cfg-1", + "method": "workspace/configuration", + "params": { "items": [{}] }, + })); + match smol::block_on(events.recv()).unwrap() { + ServerEvent::Notification { method, .. } => { + assert_eq!(method, "textDocument/publishDiagnostics") + } + other => panic!("{other:?}"), + } + let ServerEvent::Request { id, method, .. } = smol::block_on(events.recv()).unwrap() else { + panic!("expected a request"); + }; + assert_eq!(method, "workspace/configuration"); + client.respond(id, Ok(json!([null]))); + let reply = server.next(); + assert_eq!(reply["id"], "cfg-1"); + assert_eq!(reply["result"], json!([null])); + } + + #[test] + fn a_closed_pipe_fails_waiting_requests_and_says_the_server_exited() { + let (client, events, server) = connect(); + client.open_gate(); + let waiting = client.request_raw("slow", Value::Null); + drop(server); + assert!(smol::block_on(waiting).is_err()); + assert!(matches!( + smol::block_on(events.recv()).unwrap(), + ServerEvent::Exited + )); + } + + #[test] + fn dropping_an_unanswered_request_cancels_it() { + let (client, _events, mut server) = connect(); + client.open_gate(); + let asked = client.request_raw("textDocument/completion", Value::Null); + let sent = server.next(); + drop(asked); + let cancel = server.next(); + assert_eq!(cancel["method"], "$/cancelRequest"); + assert_eq!(cancel["params"]["id"], sent["id"]); + } +} diff --git a/src/ui/lsp/convert.rs b/src/ui/lsp/convert.rs new file mode 100644 index 00000000..e32ccdc6 --- /dev/null +++ b/src/ui/lsp/convert.rs @@ -0,0 +1,445 @@ +//! Translating between the positions a language server speaks and the ones +//! the editor does. +//! +//! A server counts columns in UTF-16 code units unless it negotiated +//! otherwise. The editor keeps byte offsets into its rope, and +//! gpui-component's own LSP plumbing (`RopeExt::position_to_offset`, the +//! diagnostic set, the completion menu) reads a [`Position`]'s `character` as +//! a count of `char`s. So everything crossing the boundary is converted here, +//! once: server → editor positions before they reach gpui-component, editor +//! byte offsets → server positions before a request goes out. +//! +//! The three agree on ASCII and part ways on everything else: `é` is one +//! char and one UTF-16 unit but two bytes; `🎉` is one char, two UTF-16 +//! units and four bytes. + +use std::ops::Range; + +use gpui_component::{Rope, RopeExt as _}; +use lsp_types::{Position, TextEdit}; + +/// What a server's `character` counts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Encoding { + Utf8, + #[default] + Utf16, + Utf32, +} + +impl Encoding { + pub(crate) fn from_kind(kind: Option<&lsp_types::PositionEncodingKind>) -> Self { + match kind.map(|k| k.as_str()) { + Some("utf-8") => Self::Utf8, + Some("utf-32") => Self::Utf32, + _ => Self::Utf16, + } + } + + fn units(self, c: char) -> usize { + match self { + Self::Utf8 => c.len_utf8(), + Self::Utf16 => c.len_utf16(), + Self::Utf32 => 1, + } + } +} + +/// The server's column for the byte offset `byte` into `line`. +pub(crate) fn column_of_byte(line: &str, byte: usize, enc: Encoding) -> u32 { + line.char_indices() + .take_while(|(i, _)| *i < byte) + .map(|(_, c)| enc.units(c)) + .sum::() as u32 +} + +/// The byte offset into `line` of the server's column `units`. A column past +/// the end of the line lands at its end; one inside a character (half a +/// surrogate pair) lands before that character. +pub(crate) fn byte_of_column(line: &str, units: u32, enc: Encoding) -> usize { + let units = units as usize; + let mut seen = 0; + for (i, c) in line.char_indices() { + let next = seen + enc.units(c); + if next > units { + return i; + } + seen = next; + } + line.len() +} + +fn line_text(rope: &Rope, row: usize) -> String { + rope.slice_line(row).to_string() +} + +/// A byte offset in the editor → a server position. +pub(crate) fn offset_to_lsp(rope: &Rope, offset: usize, enc: Encoding) -> Position { + let point = rope.offset_to_point(offset); + let line = line_text(rope, point.row); + Position::new(point.row as u32, column_of_byte(&line, point.column, enc)) +} + +/// A server position → a byte offset in the editor, clamped into the text. +#[cfg(test)] +pub(crate) fn lsp_to_offset(rope: &Rope, pos: Position, enc: Encoding) -> usize { + let row = pos.line as usize; + if row >= rope.lines_len() { + return rope.len(); + } + let line = line_text(rope, row); + rope.line_start_offset(row) + byte_of_column(&line, pos.character, enc) +} + +/// A server position → the char-counted position gpui-component expects. +pub(crate) fn lsp_to_editor(rope: &Rope, pos: Position, enc: Encoding) -> Position { + if enc == Encoding::Utf32 { + return pos; + } + let row = pos.line as usize; + if row >= rope.lines_len() { + return pos; + } + let line = line_text(rope, row); + let byte = byte_of_column(&line, pos.character, enc); + Position::new(pos.line, line[..byte].chars().count() as u32) +} + +pub(crate) fn range_to_editor( + rope: &Rope, + range: lsp_types::Range, + enc: Encoding, +) -> lsp_types::Range { + lsp_types::Range::new( + lsp_to_editor(rope, range.start, enc), + lsp_to_editor(rope, range.end, enc), + ) +} + +/// A server's diagnostics with their ranges in editor columns, ready for +/// `DiagnosticSet::extend`. +pub(crate) fn diagnostics_to_editor( + rope: &Rope, + diagnostics: &[lsp_types::Diagnostic], + enc: Encoding, +) -> Vec { + diagnostics + .iter() + .map(|d| lsp_types::Diagnostic { + range: range_to_editor(rope, d.range, enc), + ..d.clone() + }) + .collect() +} + +/// How many errors and warnings a set of diagnostics holds. A diagnostic +/// without a severity counts as neither — the client decides, and the +/// status bar only claims what the server did. +pub(crate) fn count_problems(diagnostics: &[lsp_types::Diagnostic]) -> (usize, usize) { + diagnostics + .iter() + .fold((0, 0), |(e, w), d| match d.severity { + Some(lsp_types::DiagnosticSeverity::ERROR) => (e + 1, w), + Some(lsp_types::DiagnosticSeverity::WARNING) => (e, w + 1), + _ => (e, w), + }) +} + +/// `edits` in the order they can be applied one at a time. +/// +/// Every edit in a `TextEdit[]` is written against the original text, so +/// applying the one furthest into the document first keeps the positions of +/// the rest valid. Edits that start at the same place are inserted in the +/// order the server listed them, which going backwards means reversing them. +pub(crate) fn in_application_order(edits: &[TextEdit]) -> Vec { + let mut indexed: Vec<(usize, &TextEdit)> = edits.iter().enumerate().collect(); + indexed.sort_by(|(ia, a), (ib, b)| { + let ka = (a.range.start.line, a.range.start.character, *ia); + let kb = (b.range.start.line, b.range.start.character, *ib); + kb.cmp(&ka) + }); + indexed.into_iter().map(|(_, e)| e.clone()).collect() +} + +/// `edits` with their ranges in editor columns and in application order — +/// what `InputState::apply_lsp_edits`, which applies them as given, needs. +pub(crate) fn edits_to_editor(rope: &Rope, edits: &[TextEdit], enc: Encoding) -> Vec { + in_application_order(edits) + .into_iter() + .map(|e| TextEdit { + range: range_to_editor(rope, e.range, enc), + new_text: e.new_text, + }) + .collect() +} + +/// `text` with `edits` applied — for a file a rename touches that has no +/// buffer open. +pub(crate) fn apply_edits(text: &str, edits: &[TextEdit], enc: Encoding) -> String { + let starts = line_starts(text); + let offset = |pos: Position| -> usize { + let Some(&start) = starts.get(pos.line as usize) else { + return text.len(); + }; + let end = starts + .get(pos.line as usize + 1) + .map(|&next| next - 1) + .unwrap_or(text.len()); + let line = &text[start..end]; + let line = line.strip_suffix('\r').unwrap_or(line); + start + byte_of_column(line, pos.character, enc) + }; + let mut out = text.to_owned(); + for edit in in_application_order(edits) { + let start = offset(edit.range.start); + let end = offset(edit.range.end).max(start); + out.replace_range(start..end, &edit.new_text); + } + out +} + +fn line_starts(text: &str) -> Vec { + std::iter::once(0) + .chain(text.match_indices('\n').map(|(i, _)| i + 1)) + .collect() +} + +/// The byte range an editor range covers, for a request about a selection. +pub(crate) fn offsets_to_lsp_range( + rope: &Rope, + range: Range, + enc: Encoding, +) -> lsp_types::Range { + lsp_types::Range::new( + offset_to_lsp(rope, range.start, enc), + offset_to_lsp(rope, range.end, enc), + ) +} + +/// A completion's snippet as the plain text it would expand to with every +/// placeholder left at its default. The editor has no snippet engine; this +/// is what it inserts instead of `${1:x}` litter. +pub(crate) fn snippet_to_plain(snippet: &str) -> String { + let mut out = String::with_capacity(snippet.len()); + let mut chars = snippet.chars().peekable(); + expand(&mut chars, &mut out, false); + out +} + +/// Expands until the end, or until the `}` closing the placeholder being +/// expanded when `nested`. +fn expand(chars: &mut std::iter::Peekable>, out: &mut String, nested: bool) { + while let Some(c) = chars.next() { + match c { + '\\' => { + if let Some(next) = chars.next() { + out.push(next); + } + } + '}' if nested => return, + '$' => match chars.peek().copied() { + Some(d) if d.is_ascii_digit() => { + while chars.peek().is_some_and(|d| d.is_ascii_digit()) { + chars.next(); + } + } + Some('{') => { + chars.next(); + // `${1:default}`, `${1|one,two|}`, `${1}` or `${name}`. + while chars + .peek() + .is_some_and(|d| d.is_ascii_alphanumeric() || *d == '_') + { + chars.next(); + } + match chars.next() { + Some(':') => expand(chars, out, true), + Some('|') => { + let mut first = true; + while let Some(c) = chars.next() { + match c { + '|' => { + // The closing `}`. + chars.next(); + break; + } + ',' => first = false, + '\\' => { + if let Some(next) = chars.next() + && first + { + out.push(next); + } + } + c if first => out.push(c), + _ => {} + } + } + } + // `${1}`, or something this does not understand: + // skip to its end. + Some('}') | None => {} + Some(_) => { + let mut depth = 1; + for c in chars.by_ref() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + } + } + } + } + // `$name` (a variable) expands to nothing we know. + Some(d) if d.is_ascii_alphabetic() || d == '_' => { + while chars + .peek() + .is_some_and(|d| d.is_ascii_alphanumeric() || *d == '_') + { + chars.next(); + } + } + _ => out.push('$'), + }, + c => out.push(c), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::Range as LspRange; + + fn pos(line: u32, character: u32) -> Position { + Position::new(line, character) + } + + fn edit(sl: u32, sc: u32, el: u32, ec: u32, text: &str) -> TextEdit { + TextEdit { + range: LspRange::new(pos(sl, sc), pos(el, ec)), + new_text: text.into(), + } + } + + #[test] + fn columns_count_utf16_units_by_default() { + // a é 🎉 b: bytes 0,1,3,7 — UTF-16 units 0,1,2,4 — chars 0,1,2,3. + let line = "aé🎉b"; + let enc = Encoding::Utf16; + assert_eq!(column_of_byte(line, 0, enc), 0); + assert_eq!(column_of_byte(line, 1, enc), 1); + assert_eq!(column_of_byte(line, 3, enc), 2); + assert_eq!(column_of_byte(line, 7, enc), 4); + assert_eq!(column_of_byte(line, 8, enc), 5); + assert_eq!(byte_of_column(line, 2, enc), 3); + assert_eq!(byte_of_column(line, 4, enc), 7); + assert_eq!(byte_of_column(line, 5, enc), 8); + // Half a surrogate pair lands before the character, never inside it. + assert_eq!(byte_of_column(line, 3, enc), 3); + // Past the end of the line: the end of the line. + assert_eq!(byte_of_column(line, 99, enc), 8); + } + + #[test] + fn utf8_and_utf32_count_bytes_and_chars() { + let line = "aé🎉b"; + assert_eq!(column_of_byte(line, 7, Encoding::Utf8), 7); + assert_eq!(byte_of_column(line, 3, Encoding::Utf8), 3); + assert_eq!(column_of_byte(line, 7, Encoding::Utf32), 3); + assert_eq!(byte_of_column(line, 3, Encoding::Utf32), 7); + } + + #[test] + fn rope_offsets_and_server_positions_round_trip() { + let rope = Rope::from("fn a() {}\nlet 🎉 = \"é\";\n"); + let enc = Encoding::Utf16; + let second = "fn a() {}\n".len(); + let quote = second + "let 🎉 = ".len(); + assert_eq!(offset_to_lsp(&rope, quote, enc), pos(1, 9)); + assert_eq!(lsp_to_offset(&rope, pos(1, 9), enc), quote); + // A line past the end of the text clamps to the end. + assert_eq!(lsp_to_offset(&rope, pos(9, 0), enc), rope.len()); + // And the editor's own positions count chars. + assert_eq!(lsp_to_editor(&rope, pos(1, 9), enc), pos(1, 8)); + assert_eq!(lsp_to_editor(&rope, pos(0, 3), enc), pos(0, 3)); + } + + #[test] + fn diagnostics_move_to_editor_columns_and_are_counted() { + let rope = Rope::from("🎉🎉x\n"); + let d = |sev, c| lsp_types::Diagnostic { + range: LspRange::new(pos(0, c), pos(0, c + 1)), + severity: sev, + message: "m".into(), + ..Default::default() + }; + let diags = vec![ + d(Some(lsp_types::DiagnosticSeverity::ERROR), 4), + d(Some(lsp_types::DiagnosticSeverity::WARNING), 0), + d(None, 0), + ]; + let mapped = diagnostics_to_editor(&rope, &diags, Encoding::Utf16); + assert_eq!(mapped[0].range, LspRange::new(pos(0, 2), pos(0, 3))); + assert_eq!(mapped[0].message, "m"); + assert_eq!(count_problems(&diags), (1, 1)); + } + + #[test] + fn edits_apply_back_to_front_and_same_place_inserts_keep_their_order() { + let edits = vec![ + edit(0, 0, 0, 0, "A"), + edit(1, 0, 1, 3, "LET"), + edit(0, 0, 0, 0, "B"), + edit(0, 3, 0, 5, "__"), + ]; + let ordered = in_application_order(&edits); + let texts: Vec<&str> = ordered.iter().map(|e| e.new_text.as_str()).collect(); + assert_eq!(texts, ["LET", "__", "B", "A"]); + assert_eq!( + apply_edits("abcdef\nlet x\n", &edits, Encoding::Utf16), + "ABabc__f\nLET x\n" + ); + } + + #[test] + fn edits_on_a_file_count_utf16_columns_and_ignore_crlf() { + let text = "let 🎉x = 1;\r\nx;\r\n"; + // `x` after the emoji is UTF-16 column 6. + let edits = vec![edit(0, 6, 0, 7, "y"), edit(1, 0, 1, 1, "y")]; + assert_eq!( + apply_edits(text, &edits, Encoding::Utf16), + "let 🎉y = 1;\r\ny;\r\n" + ); + } + + #[test] + fn editor_edits_convert_each_range_against_the_original_text() { + let rope = Rope::from("🎉a🎉b\n"); + let edits = vec![edit(0, 2, 0, 3, "A"), edit(0, 5, 0, 6, "B")]; + let mapped = edits_to_editor(&rope, &edits, Encoding::Utf16); + assert_eq!(mapped[0].range, LspRange::new(pos(0, 3), pos(0, 4))); + assert_eq!(mapped[0].new_text, "B"); + assert_eq!(mapped[1].range, LspRange::new(pos(0, 1), pos(0, 2))); + } + + #[test] + fn snippets_become_their_default_text() { + assert_eq!(snippet_to_plain("println!(\"$1\")$0"), "println!(\"\")"); + assert_eq!( + snippet_to_plain("fn ${1:name}(${2:args}) {\n\t$0\n}"), + "fn name(args) {\n\t\n}" + ); + assert_eq!(snippet_to_plain("${1:outer ${2:inner}}"), "outer inner"); + assert_eq!(snippet_to_plain("${1|one,two|}"), "one"); + assert_eq!(snippet_to_plain("cost: \\$5 ${TM_FILENAME}"), "cost: $5 "); + assert_eq!(snippet_to_plain("a $ b"), "a $ b"); + assert_eq!(snippet_to_plain("x${1}y"), "xy"); + } +} diff --git a/src/ui/lsp/editor.rs b/src/ui/lsp/editor.rs new file mode 100644 index 00000000..d34c7049 --- /dev/null +++ b/src/ui/lsp/editor.rs @@ -0,0 +1,408 @@ +//! Where the code editor meets the language servers: the few calls +//! `code_editor.rs` makes when its buffers change, the commands (go to +//! definition, format, rename), and the status bar's word on the server. + +use std::path::PathBuf; + +use gpui::prelude::*; +use gpui::{AnyElement, Context, Entity, WeakEntity, Window, div, px}; +use gpui_component::input::InputState; +use gpui_component::menu::PopupMenu; +use gpui_component::{ + ActiveTheme as _, Icon, IconName, RopeExt as _, Sizable as _, WindowExt as _, h_flex, +}; +use lsp_types::Position; + +use super::convert::{self, Encoding}; +use super::{Freshen, LspStatus, LspStore}; +use crate::ui::app::Tty7App; +use crate::ui::code_editor::{BufferId, LocalFile}; +use crate::ui::i18n::{L10nKey, t, t_fmt}; + +/// One buffer offered to the language servers. +pub(crate) struct LspBuffer { + pub(crate) input: Entity, + pub(crate) path: PathBuf, + pub(crate) language: &'static str, + pub(crate) app: WeakEntity, +} + +impl Tty7App { + fn lsp_enabled(cx: &gpui::App) -> bool { + cx.global::().editor_lsp + } + + /// The set of open buffers changed — one opened, closed, or was saved + /// under a new name. Servers start and documents open or close to match. + pub(crate) fn lsp_sync_buffers(&self, cx: &mut Context) { + let app = cx.entity().downgrade(); + let buffers = if Self::lsp_enabled(cx) { + self.editor_local_files() + .into_iter() + .map(|f| LspBuffer { + input: f.input, + path: f.path, + language: f.language, + app: app.clone(), + }) + .collect() + } else { + Vec::new() + }; + LspStore::sync_window(cx.entity_id(), buffers, cx); + } + + /// A buffer's text changed. + pub(crate) fn lsp_buffer_edited(&self, id: BufferId, cx: &mut Context) { + if let Some(f) = self.editor_local_file(id) { + LspStore::edited(f.input.entity_id(), &f.path, cx); + } + } + + /// A buffer was written to disk. + pub(crate) fn lsp_buffer_saved(&self, id: BufferId, cx: &mut Context) { + if let Some(f) = self.editor_local_file(id) { + LspStore::saved(f.input.entity_id(), &f.path, cx); + } + } + + /// The front file, if a server has it and the editor has the focus. + fn lsp_active( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option<(LocalFile, super::DocContext)> { + let f = self.editor_active_local_file()?; + let text = f.input.read(cx).text().clone(); + let doc = LspStore::context( + &f.path, + Freshen::Text(&text), + Some(window.window_handle()), + cx, + )?; + Some((f, doc)) + } + + /// Opens `path` with the cursor on a server's `position` — through the + /// same open-at-line path a `file.rs:12:5` link takes, so navigation + /// history sees a definition jump like any other. + pub(crate) fn lsp_open_location( + &mut self, + path: PathBuf, + position: Position, + encoding: Encoding, + window: &mut Window, + cx: &mut Context, + ) { + // The open path counts columns in chars, from one. + let target = path.clone(); + let open = move |app: &mut Tty7App, + character: u32, + window: &mut Window, + cx: &mut Context| { + app.open_file_in_editor_at( + &target, + Some(position.line + 1), + Some(character + 1), + window, + cx, + ); + }; + let open_buffer = self + .editor_local_files() + .into_iter() + .find(|f| f.path == path.as_path()); + if let Some(f) = open_buffer { + let text = f.input.read(cx).text().clone(); + let character = convert::lsp_to_editor(&text, position, encoding).character; + open(self, character, window, cx); + return; + } + // Not open: the column is converted against the file on disk. + let read_from = path.clone(); + let line = position.line as usize; + cx.spawn_in(window, async move |app, cx| { + let character = cx + .background_spawn(async move { + let text = std::fs::read_to_string(&read_from).ok()?; + let line = text.lines().nth(line)?; + let byte = convert::byte_of_column(line, position.character, encoding); + Some(line[..byte].chars().count() as u32) + }) + .await + .unwrap_or(position.character); + let _ = app.update_in(cx, |app, window, cx| open(app, character, window, cx)); + }) + .detach(); + } + + /// F12: the definition of the symbol under the cursor. + pub(crate) fn lsp_go_to_definition(&mut self, window: &mut Window, cx: &mut Context) { + let Some((f, doc)) = self.lsp_active(window, cx) else { + return; + }; + let state = f.input.read(cx); + let request = super::providers::definition_request(&doc, state.text(), state.cursor()); + let encoding = doc.encoding; + cx.spawn_in(window, async move |app, cx| { + let links = match request.await { + Ok(links) => links, + Err(e) => { + log::debug!("lsp: definition: {e:#}"); + return; + } + }; + let Some(link) = links.into_iter().next() else { + return; + }; + let Some(path) = super::uri_to_path(&link.target_uri) else { + return; + }; + let _ = app.update_in(cx, |app, window, cx| { + app.lsp_open_location( + path, + link.target_selection_range.start, + encoding, + window, + cx, + ) + }); + }) + .detach(); + } + + /// ⇧⌥F: the server's formatting of the whole file, applied as edits so + /// it undoes like one. + pub(crate) fn lsp_format_document(&mut self, window: &mut Window, cx: &mut Context) { + let Some((f, doc)) = self.lsp_active(window, cx) else { + return; + }; + if doc.caps.document_formatting_provider.is_none() { + return; + } + let before = f.input.read(cx).text().clone(); + let request = doc.client.request::( + lsp_types::DocumentFormattingParams { + text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + options: lsp_types::FormattingOptions { + tab_size: f.indent.size as u32, + insert_spaces: !f.indent.hard_tabs, + trim_trailing_whitespace: Some(true), + insert_final_newline: Some(true), + ..Default::default() + }, + work_done_progress_params: Default::default(), + }, + ); + let input = f.input.clone(); + let encoding = doc.encoding; + cx.spawn_in(window, async move |_, cx| { + let edits = match request.await { + Ok(Some(edits)) if !edits.is_empty() => edits, + Ok(_) => return, + Err(e) => { + log::info!("lsp: formatting: {e:#}"); + return; + } + }; + let _ = cx.update(|window, cx| { + input.update(cx, |state, cx| { + // Typed over while the server worked: its edits describe + // a text that is gone. + if *state.text() != before { + return; + } + let edits = convert::edits_to_editor(&before, &edits, encoding); + state.apply_lsp_edits(&edits, window, cx); + }); + }); + }) + .detach(); + } + + /// F2: asks for a new name for the symbol under the cursor. + pub(crate) fn lsp_rename_start(&mut self, window: &mut Window, cx: &mut Context) { + let Some((f, doc)) = self.lsp_active(window, cx) else { + return; + }; + if doc.caps.rename_provider.is_none() { + return; + } + let state = f.input.read(cx); + let offset = state.cursor(); + let Some(word) = state + .text() + .word_range(offset) + .map(|r| state.text().slice(r).to_string()) + else { + return; + }; + if word.trim().is_empty() { + return; + } + self.editor_open_rename_bar(f.id, offset, word, window, cx); + } + + /// The rename prompt was answered. + pub(crate) fn lsp_rename( + &mut self, + id: BufferId, + offset: usize, + new_name: String, + window: &mut Window, + cx: &mut Context, + ) { + let Some(f) = self.editor_local_file(id) else { + return; + }; + let text = f.input.read(cx).text().clone(); + let Some(doc) = LspStore::context( + &f.path, + Freshen::Text(&text), + Some(window.window_handle()), + cx, + ) else { + return; + }; + let old_name = text + .word_range(offset) + .map(|r| text.slice(r).to_string()) + .unwrap_or_default(); + let request = doc + .client + .request::(lsp_types::RenameParams { + text_document_position: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(&text, offset, doc.encoding), + ), + new_name, + work_done_progress_params: Default::default(), + }); + let encoding = doc.encoding; + cx.spawn_in(window, async move |_, cx| { + let result = request.await; + let _ = cx.update(|window, cx| match result { + Ok(Some(edit)) => super::apply_workspace_edit(edit, encoding, cx), + Ok(None) => {} + Err(e) => { + let context = t_fmt(L10nKey::LspRenameFailed, &[("name", &old_name)]); + window.push_notification( + crate::ui::host_ops::failure(context, &format!("{e:#}")), + cx, + ); + } + }); + }) + .detach(); + } + + /// ⌘.: the server's fixes and refactors at the cursor, in the editor's + /// own code-action menu. + pub(crate) fn lsp_code_actions(&mut self, window: &mut Window, cx: &mut Context) { + if self.editor_active_local_file().is_none() { + return; + } + window.dispatch_action(Box::new(gpui_component::input::ToggleCodeActions), cx); + } + + /// The language-server commands for the editor's right-click menu, once + /// the buffer's server is up. + pub(crate) fn lsp_menu_items( + &self, + menu: PopupMenu, + id: BufferId, + cx: &gpui::App, + ) -> PopupMenu { + use crate::core::actions::{ + EditorFormatDocument, EditorGoToDefinition, EditorQuickFix, EditorRenameSymbol, + }; + let Some(f) = self.editor_local_file(id) else { + return menu; + }; + if !matches!( + LspStore::status(&f.path, f.language, cx), + Some(LspStatus::Running { .. }) + ) { + return menu; + } + menu.separator() + .menu( + t(L10nKey::LspGoToDefinition), + Box::new(EditorGoToDefinition), + ) + .menu(t(L10nKey::LspQuickFix), Box::new(EditorQuickFix)) + .menu( + t(L10nKey::LspRenameSymbolAction), + Box::new(EditorRenameSymbol), + ) + .menu( + t(L10nKey::LspFormatDocument), + Box::new(EditorFormatDocument), + ) + } + + /// The status bar's word on the front file's server: its problem counts + /// once it runs, or why there are none. + pub(crate) fn render_lsp_status(&self, cx: &mut Context) -> Option { + if !Self::lsp_enabled(cx) { + return None; + } + let f = self.editor_active_local_file()?; + let status = LspStore::status(&f.path, f.language, cx)?; + let muted = cx.theme().muted_foreground; + let element = match status { + LspStatus::Missing(name) => div() + .flex_none() + .child(t_fmt(L10nKey::LspServerMissing, &[("name", name)])) + .into_any_element(), + LspStatus::Starting(name) => div() + .flex_none() + .child(t_fmt(L10nKey::LspServerStarting, &[("name", &name)])) + .into_any_element(), + LspStatus::Down(name) => div() + .flex_none() + .child(t_fmt(L10nKey::LspServerDown, &[("name", &name)])) + .into_any_element(), + LspStatus::Running { + name, + errors, + warnings, + } => { + let tip = format!( + "{name} — {}", + t_fmt( + L10nKey::LspProblemsTooltip, + &[ + ("errors", &errors.to_string()), + ("warnings", &warnings.to_string()) + ], + ) + ); + let count = |icon: IconName, n: usize, color: gpui::Hsla| { + h_flex() + .gap(px(3.)) + .items_center() + .child(Icon::new(icon).xsmall().text_color(if n > 0 { + color + } else { + muted + })) + .child(n.to_string()) + }; + h_flex() + .id("lsp-status") + .flex_none() + .gap_2() + .items_center() + .font_features(crate::ui::theme::tabular_figures()) + .child(count(IconName::CircleX, errors, cx.theme().danger)) + .child(count(IconName::TriangleAlert, warnings, cx.theme().warning)) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(tip.clone()).build(window, cx) + }) + .into_any_element() + } + }; + Some(element) + } +} diff --git a/src/ui/lsp/mod.rs b/src/ui/lsp/mod.rs new file mode 100644 index 00000000..aa934111 --- /dev/null +++ b/src/ui/lsp/mod.rs @@ -0,0 +1,1136 @@ +//! Language servers for the code editor. +//! +//! One [`LspStore`] per process holds every running server and every open +//! document. A server is keyed by (server, project root), so every buffer +//! under one Cargo workspace shares one rust-analyzer, whichever window it is +//! open in. A document is keyed by its path and owned by the first buffer that +//! opened it; the same file open in a second window shows the first one's +//! diagnostics only if it is the owner, since two buffers can hold two +//! different texts and a server only ever knows one. +//! +//! The editor talks to this module in a handful of places (see `editor.rs`): +//! the set of open buffers changed, a buffer was edited, a buffer was saved. +//! Everything else — starting a server when a file of its language opens, +//! shutting it down once its last file has been closed for a while, restarting +//! it when it crashes — happens here. +//! +//! Only files on this machine are served: a server runs next to the files it +//! reads. A buffer from an SFTP or remote host is skipped in +//! [`LspStore::sync_window`]; serving those means running the server on that +//! host, behind the same `Host` boundary the editor already reads files +//! through. + +pub(crate) mod client; +pub(crate) mod convert; +mod editor; +mod providers; +pub(crate) mod rpc; +pub(crate) mod servers; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui::{ + AnyWindowHandle, App, AppContext as _, BorrowAppContext as _, Entity, EntityId, Global, + Subscription, WeakEntity, +}; +use gpui_component::Rope; +use gpui_component::input::InputState; +use lsp_types::{ServerCapabilities, TextDocumentSyncKind, Uri}; +use serde_json::{Value, json}; + +use self::client::{LspClient, ServerEvent}; +use self::convert::Encoding; +use self::rpc::ResponseError; +use self::servers::ServerSpec; + +pub(crate) use self::editor::LspBuffer; + +/// How long typing may go on before the server hears about it. Requests +/// (completion, hover…) send any change still waiting first, so this only +/// decides how soon diagnostics catch up after a pause. +const CHANGE_DEBOUNCE: Duration = Duration::from_millis(150); + +/// How long a server outlives its last open file. Closing a file and opening +/// its neighbour is common; restarting rust-analyzer for it is not cheap. +const IDLE_SHUTDOWN: Duration = Duration::from_secs(60); + +/// Crashes allowed within [`CRASH_WINDOW`] before a server is left down. +const MAX_CRASHES: usize = 3; +const CRASH_WINDOW: Duration = Duration::from_secs(180); + +/// How long a program that was not on `PATH` is believed to still be +/// missing — long enough that opening ten files does not scan `PATH` ten +/// times, short enough that installing it mid-session is noticed. +const MISSING_RECHECK: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ServerKey { + name: &'static str, + root: PathBuf, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + Starting, + Running, + Stopping, + /// Crashed more than [`MAX_CRASHES`] times, or would not start. + Dead, +} + +struct Server { + spec: &'static ServerSpec, + /// The program that actually runs — `pylsp` when pyright was not found. + label: String, + generation: u64, + phase: Phase, + client: Option>, + caps: Rc, + encoding: Encoding, + crashes: Vec, + idle_seq: u64, + _tasks: Vec>, +} + +struct Doc { + uri: Uri, + language_id: &'static str, + server: ServerKey, + version: i32, + owner: WeakEntity, + owner_id: EntityId, + /// The window-level editor (`Tty7App`) that holds the owning buffer. + app_id: EntityId, + /// Where the owning buffer is drawn, once known — edits the server asks + /// for are applied through it. + window: Option, + /// The text the server last heard. + sent: Option, + /// An edit the server has not heard about yet. + dirty: bool, + change_seq: u64, + diagnostics: Vec, + problems: (usize, usize), +} + +/// What the status bar says about a file's language server. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum LspStatus { + /// The language has a server, and it is not installed. + Missing(&'static str), + Starting(String), + Running { + name: String, + errors: usize, + warnings: usize, + }, + /// Gave up after repeated crashes. + Down(String), +} + +/// How [`LspStore::context`] brings the server up to date before a request. +pub(crate) enum Freshen<'a> { + /// With this text — the caller's view of the buffer, from inside the + /// buffer's own update, where the buffer cannot be read. + Text(&'a Rope), + /// Not at all: the caller is inside the buffer's update and has no text + /// to offer, and the request does not depend on it. + Skip, +} + +/// Everything a request about one document needs, taken in one borrow. +#[derive(Clone)] +pub(crate) struct DocContext { + pub(crate) client: Arc, + pub(crate) uri: Uri, + pub(crate) encoding: Encoding, + pub(crate) caps: Rc, + /// The server's raw diagnostics for this document, in its own columns. + pub(crate) diagnostics: Vec, +} + +#[derive(Default)] +pub(crate) struct LspStore { + servers: HashMap, + docs: HashMap, + missing: HashMap<&'static str, Instant>, + next_generation: u64, + _subscriptions: Vec, +} + +impl Global for LspStore {} + +fn try_store_mut(cx: &mut App) -> Option<&mut LspStore> { + cx.has_global::() + .then(|| cx.global_mut::()) +} + +pub(crate) fn path_to_uri(path: &Path) -> Option { + url::Url::from_file_path(path).ok()?.as_str().parse().ok() +} + +pub(crate) fn uri_to_path(uri: &Uri) -> Option { + let url = url::Url::parse(uri.as_str()).ok()?; + if url.scheme() != "file" { + return None; + } + url.to_file_path().ok() +} + +impl LspStore { + fn ensure(cx: &mut App) { + if cx.try_global::().is_some() { + return; + } + let quit = cx.on_app_quit(|cx| LspStore::on_quit(cx)); + let config = cx.observe_global::(|cx| { + if !cx.global::().editor_lsp + && cx + .try_global::() + .is_some_and(|s| !s.docs.is_empty()) + { + cx.update_global::(|store, cx| store.close_all(cx)); + } + }); + cx.set_global(LspStore { + _subscriptions: vec![quit, config], + ..Default::default() + }); + } + + fn update(cx: &mut App, f: impl FnOnce(&mut LspStore, &mut App) -> R) -> R { + Self::ensure(cx); + cx.update_global::(f) + } + + // ---- The editor's side ---- + + /// Brings the documents one window's editor owns in line with the + /// buffers it has: opens the new ones, closes the ones that went away or + /// moved, and leaves the rest alone. Cheap when nothing changed. + pub(crate) fn sync_window(app_id: EntityId, buffers: Vec, cx: &mut App) { + if buffers.is_empty() && cx.try_global::().is_none() { + return; + } + Self::update(cx, |store, cx| { + let stale: Vec = store + .docs + .iter() + .filter(|(path, doc)| { + doc.owner.upgrade().is_none() + || (doc.app_id == app_id + && !buffers + .iter() + .any(|b| b.input.entity_id() == doc.owner_id && &b.path == *path)) + }) + .map(|(path, _)| path.clone()) + .collect(); + for path in stale { + store.close_doc(&path, cx); + } + for buffer in buffers { + if !store.docs.contains_key(&buffer.path) { + store.open_doc(app_id, buffer, cx); + } + } + }); + } + + /// A buffer's text changed. The server hears about it after a pause, or + /// before the next request, whichever comes first. + pub(crate) fn edited(input_id: EntityId, path: &Path, cx: &mut App) { + let Some(store) = try_store_mut(cx) else { + return; + }; + let Some(doc) = store.docs.get_mut(path).filter(|d| d.owner_id == input_id) else { + return; + }; + doc.dirty = true; + doc.change_seq += 1; + let seq = doc.change_seq; + let path = path.to_path_buf(); + cx.spawn(async move |cx| { + cx.background_executor().timer(CHANGE_DEBOUNCE).await; + cx.update(|cx| { + let current = cx + .try_global::() + .and_then(|s| s.docs.get(&path)) + .is_some_and(|d| d.change_seq == seq && d.dirty); + if current { + Self::update(cx, |store, cx| store.flush_from_owner(&path, cx)); + } + }); + }) + .detach(); + } + + /// A buffer was written to disk. + pub(crate) fn saved(input_id: EntityId, path: &Path, cx: &mut App) { + if cx.try_global::().is_none() { + return; + } + Self::update(cx, |store, cx| { + if !store.docs.get(path).is_some_and(|d| d.owner_id == input_id) { + return; + } + store.flush_from_owner(path, cx); + let Some(doc) = store.docs.get(path) else { + return; + }; + let Some(server) = store.servers.get(&doc.server) else { + return; + }; + let Some(client) = server.client.as_ref() else { + return; + }; + let include_text = match &server.caps.text_document_sync { + Some(lsp_types::TextDocumentSyncCapability::Options(o)) => match &o.save { + Some(lsp_types::TextDocumentSyncSaveOptions::SaveOptions(s)) => { + s.include_text.unwrap_or(false) + } + _ => false, + }, + _ => false, + }; + client.notify::( + lsp_types::DidSaveTextDocumentParams { + text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + text: include_text + .then(|| doc.sent.as_ref().map(|t| t.to_string())) + .flatten(), + }, + ); + }); + } + + pub(crate) fn status(path: &Path, language: &str, cx: &App) -> Option { + let store = cx.try_global::()?; + if let Some(doc) = store.docs.get(path) { + let server = store.servers.get(&doc.server)?; + return Some(match server.phase { + Phase::Starting | Phase::Stopping => LspStatus::Starting(server.label.clone()), + Phase::Running => LspStatus::Running { + name: server.label.clone(), + errors: doc.problems.0, + warnings: doc.problems.1, + }, + Phase::Dead => LspStatus::Down(server.label.clone()), + }); + } + let spec = servers::spec_for_language(language)?; + store + .missing + .contains_key(spec.name) + .then_some(LspStatus::Missing(spec.name)) + } + + /// The server behind `path`, with any edit still waiting sent first (see + /// [`Freshen`]) so the request that follows sees the text the caller sees. + pub(crate) fn context( + path: &Path, + sync: Freshen<'_>, + window: Option, + cx: &mut App, + ) -> Option { + cx.try_global::()?; + Self::update(cx, |store, _| { + match sync { + Freshen::Text(text) => store.flush_text(path, text), + Freshen::Skip => {} + } + let doc = store.docs.get_mut(path)?; + if window.is_some() { + doc.window = window; + } + let server = store.servers.get(&doc.server)?; + if server.phase != Phase::Running { + return None; + } + Some(DocContext { + client: server.client.clone()?, + uri: doc.uri.clone(), + encoding: server.encoding, + caps: server.caps.clone(), + diagnostics: doc.diagnostics.clone(), + }) + }) + } + + /// The server's trigger characters for completion in `path`. + pub(crate) fn completion_triggers(path: &Path, cx: &App) -> Vec { + let Some(store) = cx.try_global::() else { + return Vec::new(); + }; + store + .docs + .get(path) + .and_then(|d| store.servers.get(&d.server)) + .and_then(|s| s.caps.completion_provider.as_ref()) + .and_then(|c| c.trigger_characters.clone()) + .unwrap_or_default() + } + + // ---- Documents ---- + + fn open_doc(&mut self, app_id: EntityId, buffer: LspBuffer, cx: &mut App) { + let Some(spec) = servers::spec_for_language(buffer.language) else { + return; + }; + let Some(uri) = path_to_uri(&buffer.path) else { + return; + }; + let home = std::env::var_os("HOME").map(PathBuf::from); + let root = servers::find_root(&buffer.path, spec, home.as_deref()); + let key = ServerKey { + name: spec.name, + root, + }; + let needs_start = self + .servers + .get(&key) + .is_none_or(|s| s.phase == Phase::Stopping); + if needs_start && !self.start_server(key.clone(), spec, cx) { + return; + } + let text = buffer.input.read(cx).text().clone(); + let doc = Doc { + uri, + language_id: servers::language_id(&buffer.path, buffer.language), + server: key.clone(), + version: 0, + owner: buffer.input.downgrade(), + owner_id: buffer.input.entity_id(), + app_id, + window: None, + sent: Some(text.clone()), + dirty: false, + change_seq: 0, + diagnostics: Vec::new(), + problems: (0, 0), + }; + if let Some(server) = self.servers.get_mut(&key) { + // A file opening cancels a pending idle shutdown. + server.idle_seq += 1; + if let Some(client) = &server.client { + send_did_open(client, &doc, &text); + } + } + self.docs.insert(buffer.path.clone(), doc); + providers::install(&buffer.input, &buffer.path, buffer.app.clone(), cx); + } + + fn close_doc(&mut self, path: &Path, cx: &mut App) { + let Some(doc) = self.docs.remove(path) else { + return; + }; + if let Some(input) = doc.owner.upgrade() { + providers::uninstall(&input, cx); + clear_diagnostics(&input, cx); + } + let key = doc.server.clone(); + let Some(server) = self.servers.get_mut(&key) else { + return; + }; + if let Some(client) = &server.client { + client.notify::( + lsp_types::DidCloseTextDocumentParams { + text_document: lsp_types::TextDocumentIdentifier::new(doc.uri), + }, + ); + } + if self.docs.values().any(|d| d.server == key) { + return; + } + server.idle_seq += 1; + let seq = server.idle_seq; + let generation = server.generation; + cx.spawn(async move |cx| { + cx.background_executor().timer(IDLE_SHUTDOWN).await; + cx.update(|cx| { + let Some(store) = try_store_mut(cx) else { + return; + }; + let still_idle = store + .servers + .get(&key) + .is_some_and(|s| s.idle_seq == seq && s.generation == generation) + && !store.docs.values().any(|d| d.server == key); + if still_idle { + LspStore::update(cx, |store, cx| store.shutdown_server(&key, cx)); + } + }); + }) + .detach(); + } + + fn close_all(&mut self, cx: &mut App) { + let paths: Vec = self.docs.keys().cloned().collect(); + for path in paths { + self.close_doc(&path, cx); + } + let keys: Vec = self.servers.keys().cloned().collect(); + for key in keys { + self.shutdown_server(&key, cx); + } + } + + fn flush_from_owner(&mut self, path: &Path, cx: &mut App) { + let Some(text) = self + .docs + .get(path) + .and_then(|d| d.owner.upgrade()) + .map(|input| input.read(cx).text().clone()) + else { + return; + }; + self.flush_text(path, &text); + } + + /// Sends `text` as the document's new content, if it is new. Full-text + /// sync: one change event without a range replaces the whole document, + /// which every server accepts whatever sync kind it asked for. + fn flush_text(&mut self, path: &Path, text: &Rope) { + let Some(doc) = self.docs.get_mut(path) else { + return; + }; + doc.dirty = false; + if doc.sent.as_ref() == Some(text) { + return; + } + let Some(server) = self.servers.get(&doc.server) else { + return; + }; + let wants_changes = !matches!( + &server.caps.text_document_sync, + Some(lsp_types::TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::NONE + )) + ); + doc.version += 1; + doc.sent = Some(text.clone()); + let (Some(client), true) = (&server.client, wants_changes) else { + return; + }; + client.notify::( + lsp_types::DidChangeTextDocumentParams { + text_document: lsp_types::VersionedTextDocumentIdentifier::new( + doc.uri.clone(), + doc.version, + ), + content_changes: vec![lsp_types::TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: text.to_string(), + }], + }, + ); + } + + // ---- Servers ---- + + fn start_server(&mut self, key: ServerKey, spec: &'static ServerSpec, cx: &mut App) -> bool { + if self + .missing + .get(spec.name) + .is_some_and(|at| at.elapsed() < MISSING_RECHECK) + { + return false; + } + let path = std::env::var_os("PATH").unwrap_or_default(); + let Some((program, args)) = servers::resolve(spec, &path) else { + self.missing.insert(spec.name, Instant::now()); + return false; + }; + self.missing.remove(spec.name); + let label = program + .file_name() + .map(|n| n.to_string_lossy().trim_end_matches(".exe").to_owned()) + .unwrap_or_else(|| spec.name.to_owned()); + let (client, events) = match LspClient::spawn(&label, &program, args, &key.root) { + Ok(started) => started, + Err(e) => { + log::warn!("lsp: could not start {label}: {e:#}"); + self.missing.insert(spec.name, Instant::now()); + return false; + } + }; + log::info!("lsp: started {label} for {}", key.root.display()); + self.next_generation += 1; + let generation = self.next_generation; + let crashes = self + .servers + .get(&key) + .map(|s| s.crashes.clone()) + .unwrap_or_default(); + + let init = client.initialize(initialize_params(&key.root)); + let init_task = { + let key = key.clone(); + cx.spawn(async move |cx| { + let result = init.await; + cx.update(|cx| { + LspStore::update(cx, |store, cx| { + store.initialized(&key, generation, result, cx) + }) + }); + }) + }; + let events_task = { + let key = key.clone(); + cx.spawn(async move |cx| { + while let Ok(event) = events.recv().await { + let exited = matches!(event, ServerEvent::Exited); + cx.update(|cx| { + LspStore::update(cx, |store, cx| { + store.handle_event(&key, generation, event, cx) + }) + }); + if exited { + break; + } + } + }) + }; + // A server being replaced — one still shutting down — goes with its + // own tasks; its client is killed when the last handle drops. + self.servers.insert( + key, + Server { + spec, + label, + generation, + phase: Phase::Starting, + client: Some(client), + caps: Rc::new(ServerCapabilities::default()), + encoding: Encoding::Utf16, + crashes, + idle_seq: 0, + _tasks: vec![init_task, events_task], + }, + ); + true + } + + fn initialized( + &mut self, + key: &ServerKey, + generation: u64, + result: anyhow::Result, + cx: &mut App, + ) { + let Some(server) = self + .servers + .get_mut(key) + .filter(|s| s.generation == generation && s.phase == Phase::Starting) + else { + return; + }; + let Some(client) = server.client.clone() else { + return; + }; + let init = result.and_then(|v| { + serde_json::from_value::(v).map_err(Into::into) + }); + match init { + Ok(init) => { + server.encoding = Encoding::from_kind(init.capabilities.position_encoding.as_ref()); + server.caps = Rc::new(init.capabilities); + server.phase = Phase::Running; + client.notify_raw("initialized", json!({})); + client.open_gate(); + log::info!("lsp: {} is ready", server.label); + } + Err(e) => { + log::warn!("lsp: {} failed to initialize: {e:#}", server.label); + // Treated as a crash: the exit that follows (or the kill) + // decides whether it is worth another try. + client.kill(); + } + } + self.notify_docs_of(key, cx); + } + + fn handle_event(&mut self, key: &ServerKey, generation: u64, event: ServerEvent, cx: &mut App) { + let Some(server) = self.servers.get(key).filter(|s| s.generation == generation) else { + return; + }; + let Some(client) = server.client.clone() else { + return; + }; + match event { + ServerEvent::Notification { method, params } => match method.as_str() { + "textDocument/publishDiagnostics" => { + if let Ok(params) = + serde_json::from_value::(params) + { + let encoding = server.encoding; + self.publish_diagnostics(params, encoding, cx); + } + } + "window/showMessage" | "window/logMessage" => { + let message = params.get("message").and_then(Value::as_str).unwrap_or(""); + let error = params.get("type").and_then(Value::as_i64) == Some(1); + if error { + log::warn!("lsp {}: {message}", server.label); + } else { + log::debug!("lsp {}: {message}", server.label); + } + } + _ => {} + }, + ServerEvent::Request { id, method, params } => { + let reply = match method.as_str() { + "workspace/configuration" => { + let n = params + .get("items") + .and_then(Value::as_array) + .map_or(0, Vec::len); + Ok(Value::Array(vec![Value::Null; n])) + } + "client/registerCapability" + | "client/unregisterCapability" + | "window/workDoneProgress/create" + | "window/showMessageRequest" + | "workspace/diagnostic/refresh" + | "workspace/semanticTokens/refresh" + | "workspace/inlayHint/refresh" + | "workspace/codeLens/refresh" => Ok(Value::Null), + "workspace/workspaceFolders" => Ok(json!([{ + "uri": path_to_uri(&key.root).map(|u| u.as_str().to_owned()), + "name": folder_name(&key.root), + }])), + "workspace/applyEdit" => { + let encoding = server.encoding; + match serde_json::from_value::(params) + { + Ok(params) => { + apply_workspace_edit(params.edit, encoding, cx); + Ok(json!({ "applied": true })) + } + Err(e) => { + Ok(json!({ "applied": false, "failureReason": e.to_string() })) + } + } + } + other => Err(ResponseError::method_not_found(other)), + }; + client.respond(id, reply); + } + ServerEvent::Exited => self.server_exited(key, cx), + } + } + + fn publish_diagnostics( + &mut self, + params: lsp_types::PublishDiagnosticsParams, + encoding: Encoding, + cx: &mut App, + ) { + let path = self + .docs + .iter() + .find(|(_, d)| d.uri == params.uri) + .map(|(p, _)| p.clone()) + .or_else(|| uri_to_path(¶ms.uri).filter(|p| self.docs.contains_key(p))); + let Some(doc) = path.and_then(|p| self.docs.get_mut(&p)) else { + return; + }; + doc.problems = convert::count_problems(¶ms.diagnostics); + doc.diagnostics = params.diagnostics; + // Positions for a text the buffer no longer holds would underline the + // wrong characters. The server publishes again once it has caught + // up, so these are counted and kept for code actions, not drawn. + let stale = doc.dirty || params.version.is_some_and(|v| v != doc.version); + let Some(input) = doc.owner.upgrade() else { + return; + }; + let diagnostics = doc.diagnostics.clone(); + let sent = doc.sent.clone(); + input.update(cx, |state, cx| { + let text = state.text().clone(); + if !stale && sent.as_ref() == Some(&text) { + let mapped = convert::diagnostics_to_editor(&text, &diagnostics, encoding); + if let Some(set) = state.diagnostics_mut() { + set.reset(&text); + set.extend(mapped); + } + } + cx.notify(); + }); + } + + fn server_exited(&mut self, key: &ServerKey, cx: &mut App) { + let Some(server) = self.servers.get_mut(key) else { + return; + }; + if let Some(client) = server.client.take() { + client.kill(); + } + if server.phase == Phase::Stopping { + self.servers.remove(key); + return; + } + if server.phase == Phase::Starting && server.crashes.is_empty() { + // Gone before it ever answered `initialize`, on its first try: + // not a crash a restart would fix, but a program that cannot run + // here — rustup's `rust-analyzer` proxy without the component + // installed exits exactly like this. Treated as missing, so the + // status bar says so and the next file tries again later. + log::info!("lsp: {} exited before initializing", server.label); + self.missing.insert(server.spec.name, Instant::now()); + self.servers.remove(key); + let orphans: Vec = self + .docs + .iter() + .filter(|(_, d)| &d.server == key) + .map(|(p, _)| p.clone()) + .collect(); + for path in orphans { + if let Some(input) = self.docs.remove(&path).and_then(|d| d.owner.upgrade()) { + providers::uninstall(&input, cx); + } + } + return; + } + let now = Instant::now(); + server + .crashes + .retain(|at| now.duration_since(*at) < CRASH_WINDOW); + server.crashes.push(now); + let has_docs = self.docs.values().any(|d| &d.server == key); + let server = self.servers.get_mut(key).expect("looked up above"); + log::warn!( + "lsp: {} exited unexpectedly ({} time(s) recently)", + server.label, + server.crashes.len() + ); + if !has_docs { + self.servers.remove(key); + return; + } + if server.crashes.len() >= MAX_CRASHES { + server.phase = Phase::Dead; + self.notify_docs_of(key, cx); + return; + } + server.phase = Phase::Starting; + let spec = server.spec; + let delay = Duration::from_secs(server.crashes.len() as u64); + let key = key.clone(); + self.notify_docs_of(&key, cx); + cx.spawn(async move |cx| { + cx.background_executor().timer(delay).await; + cx.update(|cx| LspStore::update(cx, |store, cx| store.restart(&key, spec, cx))); + }) + .detach(); + } + + fn restart(&mut self, key: &ServerKey, spec: &'static ServerSpec, cx: &mut App) { + if !self.docs.values().any(|d| &d.server == key) { + self.servers.remove(key); + return; + } + // Forget the grace period for a program that vanished: the restart + // is the check. + self.missing.remove(spec.name); + if !self.start_server(key.clone(), spec, cx) { + if let Some(server) = self.servers.get_mut(key) { + server.phase = Phase::Dead; + } + self.notify_docs_of(key, cx); + return; + } + let Some(client) = self.servers.get(key).and_then(|s| s.client.clone()) else { + return; + }; + for doc in self.docs.values_mut().filter(|d| &d.server == key) { + let Some(input) = doc.owner.upgrade() else { + continue; + }; + let text = input.read(cx).text().clone(); + doc.version = 0; + doc.dirty = false; + doc.sent = Some(text.clone()); + send_did_open(&client, doc, &text); + } + } + + fn shutdown_server(&mut self, key: &ServerKey, cx: &mut App) { + let Some(server) = self.servers.get_mut(key) else { + return; + }; + let Some(client) = server.client.clone() else { + self.servers.remove(key); + return; + }; + server.phase = Phase::Stopping; + log::info!( + "lsp: shutting down {} for {}", + server.label, + key.root.display() + ); + let generation = server.generation; + let key = key.clone(); + cx.spawn(async move |cx| { + let executor = cx.background_executor().clone(); + let answered = client.request_raw("shutdown", Value::Null); + let timeout = executor.timer(Duration::from_secs(2)); + smol::future::or(async { answered.await.map(|_| ()).ok() }, async { + timeout.await; + None + }) + .await; + client.notify_raw("exit", Value::Null); + for _ in 0..20 { + if client.reap_if_exited() { + break; + } + executor.timer(Duration::from_millis(50)).await; + } + client.kill(); + cx.update(|cx| { + if let Some(store) = try_store_mut(cx) + && store + .servers + .get(&key) + .is_some_and(|s| s.generation == generation && s.phase == Phase::Stopping) + { + store.servers.remove(&key); + } + }); + }) + .detach(); + } + + /// The app is quitting: every server is told to exit and, whatever it + /// does with that, is gone before tty7 is. + fn on_quit(cx: &mut App) -> impl std::future::Future + use<> { + let clients: Vec> = try_store_mut(cx) + .map(|store| { + store + .servers + .drain() + .filter_map(|(_, s)| s.client) + .collect() + }) + .unwrap_or_default(); + for client in &clients { + client.notify_raw("exit", Value::Null); + } + let executor = cx.background_executor().clone(); + async move { + // gpui gives quit handlers a fraction of a second; most of it goes + // to letting servers leave on their own. + for _ in 0..3 { + if clients.iter().all(|c| c.has_exited()) { + break; + } + executor.timer(Duration::from_millis(40)).await; + } + for client in &clients { + client.kill(); + } + } + } + + fn notify_docs_of(&self, key: &ServerKey, cx: &mut App) { + let inputs: Vec> = self + .docs + .values() + .filter(|d| &d.server == key) + .filter_map(|d| d.owner.upgrade()) + .collect(); + for input in inputs { + input.update(cx, |_, cx| cx.notify()); + } + } +} + +fn send_did_open(client: &LspClient, doc: &Doc, text: &Rope) { + client.notify::( + lsp_types::DidOpenTextDocumentParams { + text_document: lsp_types::TextDocumentItem::new( + doc.uri.clone(), + doc.language_id.to_owned(), + doc.version, + text.to_string(), + ), + }, + ); +} + +fn clear_diagnostics(input: &Entity, cx: &mut App) { + input.update(cx, |state, cx| { + if let Some(set) = state.diagnostics_mut() { + set.clear(); + } + cx.notify(); + }); +} + +fn folder_name(root: &Path) -> String { + root.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| root.display().to_string()) +} + +/// What tty7 tells a server it can do. Snippets are declined — the editor +/// has no snippet engine — so servers send plain insert text where they can; +/// the few that send snippets anyway are flattened by +/// [`convert::snippet_to_plain`]. +fn initialize_params(root: &Path) -> Value { + let uri = path_to_uri(root).map(|u| u.as_str().to_owned()); + json!({ + "processId": std::process::id(), + "clientInfo": { "name": "tty7", "version": env!("CARGO_PKG_VERSION") }, + "rootUri": uri, + "rootPath": root.display().to_string(), + "workspaceFolders": [{ "uri": uri, "name": folder_name(root) }], + "initializationOptions": null, + "capabilities": { + "general": { "positionEncodings": ["utf-16"] }, + "workspace": { + "applyEdit": true, + "workspaceEdit": { "documentChanges": true }, + "configuration": true, + "workspaceFolders": true, + }, + "textDocument": { + "synchronization": { "didSave": true, "willSave": false, "dynamicRegistration": false }, + "completion": { + "completionItem": { + "snippetSupport": false, + "documentationFormat": ["markdown", "plaintext"], + "insertReplaceSupport": false, + }, + "contextSupport": true, + }, + "hover": { "contentFormat": ["markdown", "plaintext"] }, + "definition": { "linkSupport": true }, + "codeAction": { + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", "quickfix", "refactor", "refactor.extract", + "refactor.inline", "refactor.rewrite", "source", + "source.organizeImports", "source.fixAll", + ], + }, + }, + "dataSupport": true, + "resolveSupport": { "properties": ["edit"] }, + }, + "formatting": { "dynamicRegistration": false }, + "rename": { "prepareSupport": false }, + "publishDiagnostics": { "versionSupport": true, "relatedInformation": false }, + }, + "window": { "workDoneProgress": false }, + }, + }) +} + +/// Applies a server's workspace edit: to the buffer holding a file when one +/// does, straight to disk otherwise. Run on a later turn of the event loop, +/// never inside the update that asked for it — a buffer's own window may be +/// the one being updated. +pub(crate) fn apply_workspace_edit( + edit: lsp_types::WorkspaceEdit, + encoding: Encoding, + cx: &mut App, +) { + let per_file = workspace_edit_files(edit); + cx.spawn(async move |cx| { + for (uri, edits) in per_file { + let Some(path) = uri_to_path(&uri) else { + continue; + }; + let target = cx.update(|cx| { + let store = cx.try_global::()?; + let doc = store.docs.get(&path)?; + let input = doc.owner.upgrade()?; + let window = doc + .window + .or_else(|| cx.active_window()) + .or_else(|| cx.windows().first().copied())?; + Some((input, window)) + }); + match target { + Some((input, window)) => { + let _ = window.update(cx, |_, window, cx| { + input.update(cx, |state, cx| { + let text = state.text().clone(); + let edits = convert::edits_to_editor(&text, &edits, encoding); + state.apply_lsp_edits(&edits, window, cx); + }); + }); + } + None => { + let written = cx + .background_spawn(async move { + let text = std::fs::read_to_string(&path)?; + let new = convert::apply_edits(&text, &edits, encoding); + crate::core::config::write_atomic(&path, new.as_bytes())?; + anyhow::Ok(()) + }) + .await; + if let Err(e) = written { + log::warn!("lsp: could not apply an edit to {}: {e:#}", uri.as_str()); + } + } + } + } + }) + .detach(); +} + +/// A workspace edit's text edits, file by file. Creating, renaming and +/// deleting files are not supported (tty7 does not advertise them). +pub(crate) fn workspace_edit_files( + edit: lsp_types::WorkspaceEdit, +) -> Vec<(Uri, Vec)> { + use lsp_types::{DocumentChangeOperation, DocumentChanges, OneOf}; + let mut out: Vec<(Uri, Vec)> = Vec::new(); + let mut push = + |uri: Uri, edits: Vec| match out.iter_mut().find(|(u, _)| *u == uri) { + Some((_, existing)) => existing.extend(edits), + None => out.push((uri, edits)), + }; + let text_edits = |edits: Vec>| { + edits + .into_iter() + .map(|e| match e { + OneOf::Left(e) => e, + OneOf::Right(a) => a.text_edit, + }) + .collect::>() + }; + match edit.document_changes { + Some(DocumentChanges::Edits(edits)) => { + for e in edits { + push(e.text_document.uri, text_edits(e.edits)); + } + } + Some(DocumentChanges::Operations(ops)) => { + for op in ops { + if let DocumentChangeOperation::Edit(e) = op { + push(e.text_document.uri, text_edits(e.edits)); + } + } + } + None => { + let mut changes: Vec<_> = edit.changes.unwrap_or_default().into_iter().collect(); + changes.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str())); + for (uri, edits) in changes { + push(uri, edits); + } + } + } + out +} + +#[cfg(test)] +mod tests; diff --git a/src/ui/lsp/providers.rs b/src/ui/lsp/providers.rs new file mode 100644 index 00000000..367d2b46 --- /dev/null +++ b/src/ui/lsp/providers.rs @@ -0,0 +1,504 @@ +//! gpui-component's editor hooks, answered by a language server. +//! +//! One [`LspProvider`] per buffer, installed into the buffer's +//! `InputState::lsp` while the buffer has a server and taken out when it +//! stops having one. Every request carries positions through +//! [`super::convert`]: gpui-component counts columns in chars, a server in +//! UTF-16 units. +//! +//! The completion, hover and definition hooks run inside the buffer's own +//! update, so they must not read the buffer: the text they need is handed in. + +use std::ops::Range; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use anyhow::Result; +use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity, Window}; +use gpui_component::input::{ + CodeActionProvider, CompletionProvider, DefinitionProvider, HoverProvider, InputState, +}; +use gpui_component::{Rope, RopeExt as _}; +use lsp_types::{ + CodeAction, CodeActionOrCommand, CompletionContext, CompletionResponse, CompletionTextEdit, + CompletionTriggerKind, InsertTextFormat, LocationLink, +}; + +use super::convert::{self, Encoding}; +use super::{DocContext, Freshen, LspStore}; +use crate::ui::app::Tty7App; + +/// The most completion items handed to the menu. A server asked at the top +/// of a file can offer thousands; the menu filters as you type, and the +/// server is asked again on the next keystroke anyway. +const MAX_COMPLETIONS: usize = 300; + +pub(crate) struct LspProvider { + path: PathBuf, + app: WeakEntity, +} + +pub(super) fn install( + input: &Entity, + path: &Path, + app: WeakEntity, + cx: &mut App, +) { + let provider = Rc::new(LspProvider { + path: path.to_path_buf(), + app, + }); + input.update(cx, |state, cx| { + state.lsp.completion_provider = Some(provider.clone()); + state.lsp.hover_provider = Some(provider.clone()); + state.lsp.definition_provider = Some(provider.clone()); + state.lsp.code_action_providers = vec![provider]; + cx.notify(); + }); +} + +pub(super) fn uninstall(input: &Entity, cx: &mut App) { + input.update(cx, |state, cx| { + state.lsp.completion_provider = None; + state.lsp.hover_provider = None; + state.lsp.definition_provider = None; + state.lsp.code_action_providers.clear(); + cx.notify(); + }); +} + +impl LspProvider { + fn context(&self, sync: Freshen<'_>, window: &Window, cx: &mut App) -> Option { + LspStore::context(&self.path, sync, Some(window.window_handle()), cx) + } +} + +fn not_available() -> Task> { + Task::ready(Ok(T::default())) +} + +impl CompletionProvider for LspProvider { + fn completions( + &self, + text: &Rope, + offset: usize, + _trigger: CompletionContext, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let Some(doc) = self.context(Freshen::Text(text), window, cx) else { + return Task::ready(Ok(CompletionResponse::Array(Vec::new()))); + }; + if doc.caps.completion_provider.is_none() { + return Task::ready(Ok(CompletionResponse::Array(Vec::new()))); + } + // gpui-component reports the word typed so far as the "trigger + // character"; a server wants the character that actually triggered. + let point = text.offset_to_point(offset); + let line = text.slice_line(point.row).to_string(); + let before = line.get(..point.column).and_then(|l| l.chars().last()); + let triggers = LspStore::completion_triggers(&self.path, cx); + let context = match before.map(String::from) { + Some(c) if triggers.contains(&c) => CompletionContext { + trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some(c), + }, + _ => CompletionContext { + trigger_kind: CompletionTriggerKind::INVOKED, + trigger_character: None, + }, + }; + let params = lsp_types::CompletionParams { + text_document_position: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(text, offset, doc.encoding), + ), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: Some(context), + }; + let request = doc.client.request::(params); + let text = text.clone(); + let encoding = doc.encoding; + cx.background_spawn(async move { + let items = match request.await? { + Some(CompletionResponse::Array(items)) => items, + Some(CompletionResponse::List(list)) => list.items, + None => Vec::new(), + }; + let items = items + .into_iter() + .take(MAX_COMPLETIONS) + .map(|item| completion_to_editor(item, &text, encoding)) + .collect(); + Ok(CompletionResponse::Array(items)) + }) + } + + fn is_completion_trigger( + &self, + _offset: usize, + new_text: &str, + cx: &mut Context, + ) -> bool { + let Some(last) = new_text.chars().last() else { + return false; + }; + if last.is_alphanumeric() || last == '_' { + return true; + } + LspStore::completion_triggers(&self.path, cx).contains(&last.to_string()) + } +} + +/// A completion item as the editor's menu can insert it: ranges in editor +/// columns, snippets flattened to plain text. +pub(crate) fn completion_to_editor( + mut item: lsp_types::CompletionItem, + text: &Rope, + encoding: Encoding, +) -> lsp_types::CompletionItem { + let snippet = item.insert_text_format == Some(InsertTextFormat::SNIPPET); + if snippet { + item.insert_text = item.insert_text.map(|t| convert::snippet_to_plain(&t)); + item.insert_text_format = Some(InsertTextFormat::PLAIN_TEXT); + } + item.text_edit = item.text_edit.map(|edit| match edit { + CompletionTextEdit::Edit(mut e) => { + e.range = convert::range_to_editor(text, e.range, encoding); + if snippet { + e.new_text = convert::snippet_to_plain(&e.new_text); + } + CompletionTextEdit::Edit(e) + } + // Declined in the client capabilities, but a server may send one + // anyway: replacing is what accepting a completion does here. + CompletionTextEdit::InsertAndReplace(e) => CompletionTextEdit::Edit(lsp_types::TextEdit { + range: convert::range_to_editor(text, e.replace, encoding), + new_text: if snippet { + convert::snippet_to_plain(&e.new_text) + } else { + e.new_text + }, + }), + }); + item +} + +impl HoverProvider for LspProvider { + fn hover( + &self, + text: &Rope, + offset: usize, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let Some(doc) = self.context(Freshen::Text(text), window, cx) else { + return not_available(); + }; + if doc.caps.hover_provider.is_none() { + return not_available(); + } + let params = lsp_types::HoverParams { + text_document_position_params: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(text, offset, doc.encoding), + ), + work_done_progress_params: Default::default(), + }; + let request = doc + .client + .request::(params); + let text = text.clone(); + let encoding = doc.encoding; + cx.background_spawn(async move { + let Some(mut hover) = request.await? else { + return Ok(None); + }; + if hover_is_empty(&hover.contents) { + return Ok(None); + } + hover.range = hover + .range + .map(|r| convert::range_to_editor(&text, r, encoding)); + hover.contents = fence_language_strings(hover.contents); + Ok(Some(hover)) + }) + } +} + +fn hover_is_empty(contents: &lsp_types::HoverContents) -> bool { + use lsp_types::{HoverContents, MarkedString}; + let blank = |m: &MarkedString| match m { + MarkedString::String(s) => s.trim().is_empty(), + MarkedString::LanguageString(l) => l.value.trim().is_empty(), + }; + match contents { + HoverContents::Scalar(m) => blank(m), + HoverContents::Array(a) => a.iter().all(blank), + HoverContents::Markup(m) => m.value.trim().is_empty(), + } +} + +/// The popover renders Markdown and shows a bare `{ language, value }` pair as +/// plain text; as a fenced block it gets its code styling. +fn fence_language_strings(contents: lsp_types::HoverContents) -> lsp_types::HoverContents { + use lsp_types::{HoverContents, MarkedString}; + let fence = |m: MarkedString| match m { + MarkedString::LanguageString(l) => { + MarkedString::String(format!("```{}\n{}\n```", l.language, l.value)) + } + other => other, + }; + match contents { + HoverContents::Scalar(m) => HoverContents::Scalar(fence(m)), + HoverContents::Array(a) => HoverContents::Array(a.into_iter().map(fence).collect()), + markup => markup, + } +} + +impl DefinitionProvider for LspProvider { + fn definitions( + &self, + text: &Rope, + offset: usize, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let Some(doc) = self.context(Freshen::Text(text), window, cx) else { + return not_available(); + }; + let request = definition_request(&doc, text, offset); + let text = text.clone(); + let encoding = doc.encoding; + cx.background_spawn(async move { + let mut links = request.await?; + // Only the origin is in this buffer; the target is converted when + // it is followed, against the file it is in. + for link in &mut links { + link.origin_selection_range = link + .origin_selection_range + .map(|r| convert::range_to_editor(&text, r, encoding)); + } + Ok(links) + }) + } + + fn open_definition(&self, location: &LocationLink, window: &mut Window, cx: &mut App) -> bool { + let Some(path) = super::uri_to_path(&location.target_uri) else { + // An `http:` target and the like: gpui-component opens it. + return false; + }; + let position = location.target_selection_range.start; + let encoding = LspStore::context(&self.path, Freshen::Skip, None, cx) + .map(|d| d.encoding) + .unwrap_or_default(); + let app = self.app.clone(); + // Deferred: this runs inside the buffer's update, and opening a file + // — even this same one — updates buffers. + window.defer(cx, move |window, cx| { + let _ = app.update(cx, |app, cx| { + app.lsp_open_location(path, position, encoding, window, cx) + }); + }); + true + } +} + +/// `textDocument/definition` at `offset`, answered as location links +/// whatever shape the server replied in. +pub(crate) fn definition_request( + doc: &DocContext, + text: &Rope, + offset: usize, +) -> impl std::future::Future>> + use<> { + let params = lsp_types::GotoDefinitionParams { + text_document_position_params: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(text, offset, doc.encoding), + ), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + let supported = doc.caps.definition_provider.is_some(); + let request = doc + .client + .request::(params); + async move { + if !supported { + return Ok(Vec::new()); + } + use lsp_types::GotoDefinitionResponse as R; + let to_link = |l: lsp_types::Location| LocationLink { + origin_selection_range: None, + target_uri: l.uri, + target_range: l.range, + target_selection_range: l.range, + }; + Ok(match request.await? { + Some(R::Scalar(l)) => vec![to_link(l)], + Some(R::Array(ls)) => ls.into_iter().map(to_link).collect(), + Some(R::Link(links)) => links, + None => Vec::new(), + }) + } +} + +impl CodeActionProvider for LspProvider { + fn id(&self) -> SharedString { + "lsp".into() + } + + fn code_actions( + &self, + state: Entity, + range: Range, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let text = state.read(cx).text().clone(); + let Some(doc) = self.context(Freshen::Text(&text), window, cx) else { + return not_available(); + }; + if doc.caps.code_action_provider.is_none() { + return not_available(); + } + let lsp_range = convert::offsets_to_lsp_range(&text, range, doc.encoding); + // The diagnostics the range touches, which is what turns a server's + // quick fixes on. + let diagnostics = doc + .diagnostics + .iter() + .filter(|d| { + d.range.start.line <= lsp_range.end.line && d.range.end.line >= lsp_range.start.line + }) + .cloned() + .collect(); + let params = lsp_types::CodeActionParams { + text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + range: lsp_range, + context: lsp_types::CodeActionContext { + diagnostics, + only: None, + trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + let request = doc + .client + .request::(params); + cx.background_spawn(async move { + Ok(request + .await? + .unwrap_or_default() + .into_iter() + .map(|a| match a { + CodeActionOrCommand::CodeAction(a) => a, + CodeActionOrCommand::Command(command) => CodeAction { + title: command.title.clone(), + command: Some(command), + ..Default::default() + }, + }) + .filter(|a| a.disabled.is_none()) + .collect()) + }) + } + + fn perform_code_action( + &self, + _state: Entity, + action: CodeAction, + _push_to_history: bool, + window: &mut Window, + cx: &mut App, + ) -> Task> { + // Inside the buffer's update: it cannot be read, and the actions on + // offer were computed against text that was already sent. + let Some(doc) = self.context(Freshen::Skip, window, cx) else { + return Task::ready(Ok(())); + }; + let resolvable = matches!( + &doc.caps.code_action_provider, + Some(lsp_types::CodeActionProviderCapability::Options(o)) + if o.resolve_provider == Some(true) + ); + let client = doc.client.clone(); + let encoding = doc.encoding; + window.spawn(cx, async move |cx| { + let action = if action.edit.is_none() && action.data.is_some() && resolvable { + client + .request::(action.clone()) + .await + .unwrap_or(action) + } else { + action + }; + if let Some(edit) = action.edit { + cx.update(|_, cx| super::apply_workspace_edit(edit, encoding, cx))?; + } + if let Some(command) = action.command { + // Whatever this changes comes back as `workspace/applyEdit`. + client + .request::( + lsp_types::ExecuteCommandParams { + command: command.command, + arguments: command.arguments.unwrap_or_default(), + work_done_progress_params: Default::default(), + }, + ) + .await?; + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{CompletionItem, Position, Range as LspRange, TextEdit}; + + #[test] + fn a_snippet_completion_arrives_as_plain_text_in_editor_columns() { + let text = Rope::from("let 🎉 = fo\n"); + let item = CompletionItem { + label: "foo".into(), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + // UTF-16: `let ` 4, emoji 2, ` = ` 3 → `fo` at 9..11. + range: LspRange::new(Position::new(0, 9), Position::new(0, 11)), + new_text: "foo(${1:x})$0".into(), + })), + ..Default::default() + }; + let item = completion_to_editor(item, &text, Encoding::Utf16); + let Some(CompletionTextEdit::Edit(edit)) = item.text_edit else { + panic!("expected a plain edit"); + }; + assert_eq!(edit.new_text, "foo(x)"); + assert_eq!( + edit.range, + LspRange::new(Position::new(0, 8), Position::new(0, 10)) + ); + assert_eq!(item.insert_text_format, Some(InsertTextFormat::PLAIN_TEXT)); + } + + #[test] + fn language_strings_in_a_hover_become_fenced_code() { + let contents = lsp_types::HoverContents::Scalar(lsp_types::MarkedString::LanguageString( + lsp_types::LanguageString { + language: "rust".into(), + value: "fn main()".into(), + }, + )); + let lsp_types::HoverContents::Scalar(lsp_types::MarkedString::String(s)) = + fence_language_strings(contents) + else { + panic!("expected a markdown string"); + }; + assert_eq!(s, "```rust\nfn main()\n```"); + assert!(hover_is_empty(&lsp_types::HoverContents::Array(vec![]))); + } +} diff --git a/src/ui/lsp/rpc.rs b/src/ui/lsp/rpc.rs new file mode 100644 index 00000000..2d641a1b --- /dev/null +++ b/src/ui/lsp/rpc.rs @@ -0,0 +1,294 @@ +//! JSON-RPC 2.0 as a language server speaks it over stdio: every message is a +//! `Content-Length` header block followed by exactly that many bytes of JSON. +//! +//! Nothing here touches a process or a thread, so the framing — the part a +//! pipe hands over in arbitrary pieces — can be tested byte by byte. + +use serde_json::{Value, json}; + +/// The id tty7 gives its own requests. A server's requests to us may carry a +/// string id instead; those are echoed back as the raw [`Value`]. +pub(crate) type RequestId = i64; + +/// Frames one message for the wire. +pub(crate) fn encode(message: &Value) -> Vec { + let body = serde_json::to_vec(message).unwrap_or_default(); + let mut out = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + out.extend_from_slice(&body); + out +} + +pub(crate) fn request(id: RequestId, method: &str, params: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) +} + +pub(crate) fn notification(method: &str, params: Value) -> Value { + json!({ "jsonrpc": "2.0", "method": method, "params": params }) +} + +pub(crate) fn response(id: Value, result: Result) -> Value { + match result { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err(e) => json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": e.code, "message": e.message }, + }), + } +} + +/// The `error` member of a response. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ResponseError { + pub(crate) code: i64, + pub(crate) message: String, +} + +impl ResponseError { + pub(crate) const METHOD_NOT_FOUND: i64 = -32601; + + pub(crate) fn method_not_found(method: &str) -> Self { + Self { + code: Self::METHOD_NOT_FOUND, + message: format!("tty7 does not handle {method}"), + } + } +} + +impl std::fmt::Display for ResponseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.message, self.code) + } +} + +impl std::error::Error for ResponseError {} + +/// One message from the server, sorted by what it asks of us. +#[derive(Debug, PartialEq)] +pub(crate) enum Incoming { + /// The answer to one of our requests. + Response { + id: RequestId, + result: Result, + }, + /// The server asking us something; it waits for a [`response`]. + Request { + id: Value, + method: String, + params: Value, + }, + Notification { + method: String, + params: Value, + }, +} + +/// Sorts a decoded body. `None` for anything that is not a message this +/// client can act on — a response to an id it never sent, a batch, garbage. +pub(crate) fn classify(body: &[u8]) -> Option { + let mut value: Value = serde_json::from_slice(body).ok()?; + let obj = value.as_object_mut()?; + let method = obj.get("method").and_then(Value::as_str).map(str::to_owned); + let params = obj.remove("params").unwrap_or(Value::Null); + match (method, obj.remove("id")) { + (Some(method), Some(id)) if !id.is_null() => Some(Incoming::Request { id, method, params }), + (Some(method), _) => Some(Incoming::Notification { method, params }), + (None, Some(id)) => { + let id = id.as_i64()?; + let result = match obj.remove("error") { + Some(Value::Object(err)) => Err(ResponseError { + code: err.get("code").and_then(Value::as_i64).unwrap_or(0), + message: err + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + }), + _ => Ok(obj.remove("result").unwrap_or(Value::Null)), + }; + Some(Incoming::Response { id, result }) + } + (None, None) => None, + } +} + +/// The longest header block believed. A server that sends more than this +/// without a blank line is not speaking LSP, and the buffer must not grow +/// without bound waiting for one. +const MAX_HEADER: usize = 8 * 1024; + +/// Reassembles messages from whatever pieces a pipe delivers. +#[derive(Default)] +pub(crate) struct FrameReader { + buf: Vec, +} + +impl FrameReader { + pub(crate) fn push(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// The next complete body, if one has arrived. A header block without a + /// usable `Content-Length` is dropped and reading carries on after it — + /// one garbled message costs that message, not the session. + pub(crate) fn next_frame(&mut self) -> Option> { + loop { + let Some(header_end) = find(&self.buf, b"\r\n\r\n") else { + if self.buf.len() > MAX_HEADER { + // No header terminator in sight: keep only a tail that + // could still be the start of one. + let keep = self.buf.len() - 3; + self.buf.drain(..keep); + } + return None; + }; + let header = String::from_utf8_lossy(&self.buf[..header_end]); + let length = header.split("\r\n").find_map(|line| { + let (name, value) = line.split_once(':')?; + name.trim() + .eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }); + let body_start = header_end + 4; + let Some(length) = length else { + log::warn!("lsp: dropping a message without Content-Length: {header:?}"); + self.buf.drain(..body_start); + continue; + }; + if self.buf.len() < body_start + length { + return None; + } + let body = self.buf[body_start..body_start + length].to_vec(); + self.buf.drain(..body_start + length); + return Some(body); + } + } +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_message_round_trips_through_the_framing() { + let msg = request(7, "textDocument/hover", json!({ "x": "é🎉" })); + let bytes = encode(&msg); + let mut reader = FrameReader::default(); + reader.push(&bytes); + let body = reader.next_frame().expect("a whole frame"); + assert_eq!(serde_json::from_slice::(&body).unwrap(), msg); + assert!(reader.next_frame().is_none()); + } + + #[test] + fn the_length_counts_bytes_not_characters() { + let bytes = encode(&json!("é🎉")); + let text = String::from_utf8(bytes).unwrap(); + // `"é🎉"` is 2 quotes + 2 + 4 bytes. + assert!(text.starts_with("Content-Length: 8\r\n\r\n"), "{text:?}"); + } + + #[test] + fn frames_split_at_every_byte_still_come_out_whole() { + let a = notification("a", json!({ "n": 1 })); + let b = notification("b", json!({ "text": "π".repeat(50) })); + let mut stream = encode(&a); + stream.extend(encode(&b)); + let mut reader = FrameReader::default(); + let mut got = Vec::new(); + for byte in &stream { + reader.push(std::slice::from_ref(byte)); + while let Some(body) = reader.next_frame() { + got.push(serde_json::from_slice::(&body).unwrap()); + } + } + assert_eq!(got, vec![a, b]); + } + + #[test] + fn two_frames_in_one_read_both_come_out() { + let mut stream = encode(&json!(1)); + stream.extend(encode(&json!(2))); + let mut reader = FrameReader::default(); + reader.push(&stream); + assert_eq!(reader.next_frame().as_deref(), Some(&b"1"[..])); + assert_eq!(reader.next_frame().as_deref(), Some(&b"2"[..])); + assert_eq!(reader.next_frame(), None); + } + + #[test] + fn extra_headers_and_odd_case_are_accepted() { + let body = br#"{"jsonrpc":"2.0","method":"x"}"#; + let mut stream = format!( + "content-type: application/vscode-jsonrpc; charset=utf-8\r\nCONTENT-LENGTH:{}\r\n\r\n", + body.len() + ) + .into_bytes(); + stream.extend_from_slice(body); + let mut reader = FrameReader::default(); + reader.push(&stream); + assert_eq!(reader.next_frame().as_deref(), Some(&body[..])); + } + + #[test] + fn a_header_without_a_length_is_skipped() { + let mut stream = b"X-Nothing: 1\r\n\r\n".to_vec(); + stream.extend(encode(&json!(3))); + let mut reader = FrameReader::default(); + reader.push(&stream); + assert_eq!(reader.next_frame().as_deref(), Some(&b"3"[..])); + } + + #[test] + fn messages_are_sorted_by_what_they_ask() { + assert_eq!( + classify(br#"{"jsonrpc":"2.0","id":3,"result":{"a":1}}"#), + Some(Incoming::Response { + id: 3, + result: Ok(json!({ "a": 1 })) + }) + ); + assert_eq!( + classify(br#"{"jsonrpc":"2.0","id":4,"error":{"code":-32601,"message":"nope"}}"#), + Some(Incoming::Response { + id: 4, + result: Err(ResponseError { + code: -32601, + message: "nope".into() + }) + }) + ); + assert_eq!( + classify( + br#"{"jsonrpc":"2.0","id":"abc","method":"workspace/configuration","params":{}}"# + ), + Some(Incoming::Request { + id: json!("abc"), + method: "workspace/configuration".into(), + params: json!({}) + }) + ); + assert_eq!( + classify(br#"{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics"}"#), + Some(Incoming::Notification { + method: "textDocument/publishDiagnostics".into(), + params: Value::Null + }) + ); + // A `null` result is still a result. + assert_eq!( + classify(br#"{"jsonrpc":"2.0","id":5,"result":null}"#), + Some(Incoming::Response { + id: 5, + result: Ok(Value::Null) + }) + ); + assert_eq!(classify(b"not json"), None); + assert_eq!(classify(br#"{"id":"x","result":1}"#), None); + } +} diff --git a/src/ui/lsp/servers.rs b/src/ui/lsp/servers.rs new file mode 100644 index 00000000..32e2a3bc --- /dev/null +++ b/src/ui/lsp/servers.rs @@ -0,0 +1,322 @@ +//! Which language server serves which files, where to find it, and which +//! directory it should treat as the project. +//! +//! [`SERVERS`] is the whole registry: one row per server. Adding a language +//! is adding a row — its editor language names, the commands to try in order +//! (the first one found on `PATH` wins), and the files that mark a project's +//! root. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +pub(crate) struct ServerSpec { + /// Shown to people, and the key a server is known by. + pub(crate) name: &'static str, + /// The editor's language names (`code_editor::language_for_path`) this + /// server handles. + pub(crate) languages: &'static [&'static str], + /// Programs to try, in order, with their arguments. + pub(crate) commands: &'static [(&'static str, &'static [&'static str])], + /// Files or directories whose presence marks a project root, nearest + /// first. See [`find_root`] for how they are weighed. + pub(crate) root_markers: &'static [&'static str], + /// Markers that, when found further up, win over a nearer one — a Cargo + /// or Go workspace above the crate or module the file is in. + pub(crate) outer_markers: &'static [&'static str], +} + +pub(crate) const SERVERS: &[ServerSpec] = &[ + ServerSpec { + name: "rust-analyzer", + languages: &["rust"], + commands: &[("rust-analyzer", &[])], + root_markers: &["Cargo.toml"], + outer_markers: &["Cargo.toml#workspace"], + }, + ServerSpec { + name: "typescript-language-server", + languages: &["typescript", "tsx", "javascript"], + commands: &[("typescript-language-server", &["--stdio"])], + root_markers: &["tsconfig.json", "jsconfig.json", "package.json"], + outer_markers: &[], + }, + ServerSpec { + name: "pyright", + languages: &["python"], + commands: &[ + ("pyright-langserver", &["--stdio"]), + ("basedpyright-langserver", &["--stdio"]), + ("pylsp", &[]), + ], + root_markers: &[ + "pyproject.toml", + "pyrightconfig.json", + "setup.py", + "setup.cfg", + "requirements.txt", + ], + outer_markers: &[], + }, + ServerSpec { + name: "gopls", + languages: &["go"], + commands: &[("gopls", &[])], + root_markers: &["go.mod"], + outer_markers: &["go.work"], + }, + ServerSpec { + name: "clangd", + languages: &["c", "cpp"], + commands: &[("clangd", &[])], + root_markers: &["compile_commands.json", "compile_flags.txt", ".clangd"], + outer_markers: &[], + }, +]; + +/// Markers every server falls back on when its own are nowhere to be found. +const FALLBACK_MARKERS: &[&str] = &[".git", ".hg", ".jj"]; + +pub(crate) fn spec_for_language(language: &str) -> Option<&'static ServerSpec> { + SERVERS.iter().find(|s| s.languages.contains(&language)) +} + +/// The `languageId` a server expects in `didOpen`, which is finer-grained +/// than the editor's highlighting language: JSX and TSX are their own ids. +pub(crate) fn language_id(path: &Path, language: &str) -> &'static str { + let ext = path + .extension() + .and_then(OsStr::to_str) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + match (language, ext.as_str()) { + ("javascript", "jsx") => "javascriptreact", + ("tsx", _) => "typescriptreact", + ("typescript", _) => "typescript", + ("javascript", _) => "javascript", + ("rust", _) => "rust", + ("python", _) => "python", + ("go", _) => "go", + ("c", _) => "c", + ("cpp", _) => "cpp", + _ => "plaintext", + } +} + +/// The first of `spec`'s commands that exists on `path` (a `PATH`-style +/// list), as a full path to run and its arguments. +/// +/// tty7 fills its own `PATH` in from the login shell at startup +/// (`enrich_path_from_login_shell` in `main.rs`), so a GUI launched from the +/// Dock still finds what `~/.cargo/bin` or `nvm` put on the terminal's. +pub(crate) fn resolve( + spec: &ServerSpec, + path: &OsStr, +) -> Option<(PathBuf, &'static [&'static str])> { + spec.commands + .iter() + .find_map(|(program, args)| Some((find_program(program, path)?, *args))) +} + +fn find_program(program: &str, path: &OsStr) -> Option { + let exts: &[&str] = if cfg!(windows) { + &[".exe", ".cmd", ".bat", ""] + } else { + &[""] + }; + std::env::split_paths(path).find_map(|dir| { + exts.iter() + .map(|ext| dir.join(format!("{program}{ext}"))) + .find(|candidate| is_executable(candidate)) + }) +} + +fn is_executable(path: &Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// The directory a server should open as the project that contains `file`. +/// +/// The nearest ancestor holding one of the server's own markers wins; above +/// it, the furthest ancestor holding an outer marker wins over that (a +/// `Cargo.toml` with a `[workspace]` above a member crate's). Without any +/// marker a version-control root will do, and without that the file's own +/// directory. The home directory and the filesystem root are never chosen: +/// pointing a server at either makes it index everything a person owns. +pub(crate) fn find_root(file: &Path, spec: &ServerSpec, home: Option<&Path>) -> PathBuf { + let dir = file.parent().unwrap_or(file).to_path_buf(); + let too_broad = |p: &Path| p.parent().is_none() || home.is_some_and(|h| h == p); + let ancestors: Vec<&Path> = dir.ancestors().filter(|p| !too_broad(p)).collect(); + + let nearest = ancestors + .iter() + .find(|p| spec.root_markers.iter().any(|m| has_marker(p, m))); + let outer = ancestors + .iter() + .rev() + .find(|p| spec.outer_markers.iter().any(|m| has_marker(p, m))); + let chosen = match (nearest, outer) { + // Only an outer marker that actually encloses the nearest root counts. + (Some(near), Some(out)) if near.starts_with(out) => Some(out), + (Some(near), _) => Some(near), + (None, Some(out)) => Some(out), + (None, None) => ancestors + .iter() + .find(|p| FALLBACK_MARKERS.iter().any(|m| p.join(m).exists())), + }; + chosen.map(|p| p.to_path_buf()).unwrap_or(dir) +} + +/// `name` exists in `dir`. `Cargo.toml#workspace` asks for a `Cargo.toml` +/// with a `[workspace]` table in it. +fn has_marker(dir: &Path, marker: &str) -> bool { + match marker.split_once('#') { + Some((file, table)) => std::fs::read_to_string(dir.join(file)) + .is_ok_and(|text| text.lines().any(|line| line.trim() == format!("[{table}]"))), + None => dir.join(marker).exists(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tree(files: &[&str]) -> tempfile::TempDir { + let dir = tempfile::TempDir::new().unwrap(); + for f in files { + let p = dir.path().join(f); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + if f.ends_with('/') { + std::fs::create_dir_all(&p).unwrap(); + } else { + let body = if f.ends_with("ws/Cargo.toml") { + "[workspace]\nmembers = [\"crates/*\"]\n" + } else { + "[package]\nname = \"x\"\n" + }; + std::fs::write(&p, body).unwrap(); + } + } + dir + } + + fn rust() -> &'static ServerSpec { + spec_for_language("rust").unwrap() + } + + #[test] + fn a_member_crate_opens_as_its_cargo_workspace() { + let t = tree(&[ + "ws/Cargo.toml", + "ws/crates/a/Cargo.toml", + "ws/crates/a/src/lib.rs", + ]); + let root = find_root(&t.path().join("ws/crates/a/src/lib.rs"), rust(), None); + assert_eq!(root, t.path().join("ws")); + } + + #[test] + fn a_crate_outside_any_workspace_is_its_own_root() { + let t = tree(&["solo/Cargo.toml", "solo/src/main.rs"]); + let root = find_root(&t.path().join("solo/src/main.rs"), rust(), None); + assert_eq!(root, t.path().join("solo")); + } + + #[test] + fn the_nearest_package_json_wins_for_typescript() { + let t = tree(&[ + "repo/package.json", + "repo/web/package.json", + "repo/web/src/a.ts", + ]); + let ts = spec_for_language("typescript").unwrap(); + let root = find_root(&t.path().join("repo/web/src/a.ts"), ts, None); + assert_eq!(root, t.path().join("repo/web")); + } + + #[test] + fn a_go_work_above_the_module_wins() { + let t = tree(&["w/go.work", "w/svc/go.mod", "w/svc/main.go"]); + let go = spec_for_language("go").unwrap(); + assert_eq!( + find_root(&t.path().join("w/svc/main.go"), go, None), + t.path().join("w") + ); + } + + #[test] + fn without_markers_the_repository_and_then_the_folder_are_used() { + let t = tree(&["repo/.git/", "repo/tools/x.py", "loose/y.py"]); + let py = spec_for_language("python").unwrap(); + assert_eq!( + find_root(&t.path().join("repo/tools/x.py"), py, None), + t.path().join("repo") + ); + assert_eq!( + find_root(&t.path().join("loose/y.py"), py, None), + t.path().join("loose") + ); + } + + #[test] + fn home_is_never_a_root_even_with_a_dotfiles_repository() { + let t = tree(&["home/.git/", "home/notes/n.py"]); + let home = t.path().join("home"); + let py = spec_for_language("python").unwrap(); + assert_eq!( + find_root(&home.join("notes/n.py"), py, Some(&home)), + home.join("notes") + ); + } + + #[test] + fn language_ids_distinguish_jsx_and_tsx() { + assert_eq!( + language_id(Path::new("a.jsx"), "javascript"), + "javascriptreact" + ); + assert_eq!(language_id(Path::new("a.js"), "javascript"), "javascript"); + assert_eq!(language_id(Path::new("a.tsx"), "tsx"), "typescriptreact"); + assert_eq!(language_id(Path::new("a.rs"), "rust"), "rust"); + } + + #[test] + fn every_language_has_at_most_one_server() { + let mut seen = std::collections::HashSet::new(); + for spec in SERVERS { + for lang in spec.languages { + assert!(seen.insert(*lang), "{lang} is served twice"); + } + } + } + + #[cfg(unix)] + #[test] + fn the_first_command_found_on_path_is_used() { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempfile::TempDir::new().unwrap(); + let pylsp = dir.path().join("pylsp"); + std::fs::write(&pylsp, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&pylsp, std::fs::Permissions::from_mode(0o755)).unwrap(); + // Not executable: not a candidate. + std::fs::write(dir.path().join("pyright-langserver"), "").unwrap(); + let py = spec_for_language("python").unwrap(); + let (program, args) = resolve(py, dir.path().as_os_str()).unwrap(); + assert_eq!(program, pylsp); + assert!(args.is_empty()); + assert!(resolve(rust(), dir.path().as_os_str()).is_none()); + } +} diff --git a/src/ui/lsp/tests.rs b/src/ui/lsp/tests.rs new file mode 100644 index 00000000..55a1138a --- /dev/null +++ b/src/ui/lsp/tests.rs @@ -0,0 +1,201 @@ +use std::collections::HashMap; + +use lsp_types::{ + AnnotatedTextEdit, DocumentChangeOperation, DocumentChanges, OneOf, + OptionalVersionedTextDocumentIdentifier, Position, Range, ResourceOp, TextDocumentEdit, + TextEdit, WorkspaceEdit, +}; + +use super::*; + +fn uri(path: &str) -> Uri { + path_to_uri(Path::new(path)).unwrap() +} + +fn edit(line: u32, text: &str) -> TextEdit { + TextEdit { + range: Range::new(Position::new(line, 0), Position::new(line, 1)), + new_text: text.into(), + } +} + +#[cfg(unix)] +#[test] +fn file_uris_round_trip_including_spaces_and_non_ascii() { + let path = Path::new("/tmp/a dir/ü.rs"); + let u = path_to_uri(path).unwrap(); + assert_eq!(u.as_str(), "file:///tmp/a%20dir/%C3%BC.rs"); + assert_eq!(uri_to_path(&u).as_deref(), Some(path)); + let web: Uri = "https://docs.rs/x".parse().unwrap(); + assert_eq!(uri_to_path(&web), None); +} + +#[test] +fn a_workspace_edit_is_grouped_by_file_from_either_shape() { + let mut changes = HashMap::new(); + changes.insert(uri("/b.rs"), vec![edit(1, "b")]); + changes.insert(uri("/a.rs"), vec![edit(0, "a")]); + let files = workspace_edit_files(WorkspaceEdit { + changes: Some(changes), + ..Default::default() + }); + let names: Vec<&str> = files.iter().map(|(u, _)| u.as_str()).collect(); + assert_eq!(names, ["file:///a.rs", "file:///b.rs"]); + + let doc_edit = |path: &str, edits: Vec>| TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + uri: uri(path), + version: None, + }, + edits, + }; + let files = workspace_edit_files(WorkspaceEdit { + document_changes: Some(DocumentChanges::Operations(vec![ + DocumentChangeOperation::Edit(doc_edit("/a.rs", vec![OneOf::Left(edit(0, "x"))])), + DocumentChangeOperation::Op(ResourceOp::Create(lsp_types::CreateFile { + uri: uri("/new.rs"), + options: None, + annotation_id: None, + })), + DocumentChangeOperation::Edit(doc_edit( + "/a.rs", + vec![OneOf::Right(AnnotatedTextEdit { + text_edit: edit(2, "y"), + annotation_id: "rename".into(), + })], + )), + ])), + ..Default::default() + }); + assert_eq!( + files.len(), + 1, + "edits to one file are merged; resource ops skipped" + ); + let texts: Vec<&str> = files[0].1.iter().map(|e| e.new_text.as_str()).collect(); + assert_eq!(texts, ["x", "y"]); +} + +#[test] +fn a_rename_across_a_file_applies_every_occurrence() { + // What a server sends for renaming `foo` to `renamed`, applied to a file + // with no buffer open. + let text = "fn foo() {}\nfn main() { foo(); /* 🎉 */ foo(); }\n"; + let at = |line, start, end| TextEdit { + range: Range::new(Position::new(line, start), Position::new(line, end)), + new_text: "renamed".into(), + }; + let edits = vec![at(1, 12, 15), at(0, 3, 6), at(1, 28, 31)]; + assert_eq!( + convert::apply_edits(text, &edits, Encoding::Utf16), + "fn renamed() {}\nfn main() { renamed(); /* 🎉 */ renamed(); }\n" + ); +} + +/// Drives a real language server through the whole client: spawn, +/// `initialize` behind the gate, `didOpen`, diagnostics arriving as an +/// event, a hover request, and a clean `shutdown`/`exit` with the process +/// reaped. clangd, because it ships with Xcode's command line tools and needs +/// no project to index; the file's type error is what it has to find. +/// +/// `cargo test -p tty7 --bin tty7-app lsp_smoke -- --ignored` +#[test] +#[ignore = "needs clangd on PATH"] +fn lsp_smoke_a_real_server_publishes_diagnostics_and_shuts_down() { + use std::time::Duration; + + let spec = servers::spec_for_language("c").unwrap(); + let Some((program, args)) = + servers::resolve(spec, &std::env::var_os("PATH").unwrap_or_default()) + else { + eprintln!("clangd not on PATH; skipping"); + return; + }; + let dir = tempfile::TempDir::new().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let file = root.join("main.c"); + let text = "int main(void) {\n int x = \"not a number\";\n return x;\n}\n"; + std::fs::write(&file, text).unwrap(); + + let (client, events) = LspClient::spawn("clangd", &program, args, &root).unwrap(); + let within = + |secs: u64, fut: std::pin::Pin>>>| { + smol::block_on(smol::future::or(fut, async move { + smol::Timer::after(Duration::from_secs(secs)).await; + None + })) + }; + + let init = client.initialize(initialize_params(&root)); + let result = within(30, Box::pin(async move { init.await.ok() })).expect("initialize answered"); + let init: lsp_types::InitializeResult = serde_json::from_value(result).unwrap(); + assert!(init.capabilities.hover_provider.is_some()); + client.notify_raw("initialized", json!({})); + client.open_gate(); + + let uri = path_to_uri(&file).unwrap(); + client.notify::( + lsp_types::DidOpenTextDocumentParams { + text_document: lsp_types::TextDocumentItem::new( + uri.clone(), + "c".into(), + 0, + text.into(), + ), + }, + ); + + let events2 = events.clone(); + let published = within( + 60, + Box::pin(async move { + while let Ok(event) = events2.recv().await { + match event { + ServerEvent::Notification { method, params } + if method == "textDocument/publishDiagnostics" => + { + return Some(params); + } + _ => {} + } + } + None + }), + ) + .expect("diagnostics published"); + let params: lsp_types::PublishDiagnosticsParams = serde_json::from_value(published).unwrap(); + assert_eq!(params.uri, uri); + let (errors, _) = convert::count_problems(¶ms.diagnostics); + assert!(errors >= 1, "{:?}", params.diagnostics); + assert!(params.diagnostics.iter().any(|d| d.range.start.line == 1)); + + let hover = client.request::(lsp_types::HoverParams { + text_document_position_params: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(uri), + Position::new(2, 11), + ), + work_done_progress_params: Default::default(), + }); + let hovered = within( + 30, + Box::pin(async move { hover.await.ok().map(|h| serde_json::to_value(h).unwrap()) }), + ); + assert!( + hovered.is_some_and(|h| !h.is_null()), + "hover over `x` says something" + ); + + let shutdown = client.request_raw("shutdown", Value::Null); + within(10, Box::pin(async move { shutdown.await.ok() })); + client.notify_raw("exit", Value::Null); + let mut exited = false; + for _ in 0..50 { + if client.reap_if_exited() { + exited = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + client.kill(); + assert!(exited, "clangd exits on `exit`"); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8719f01c..04eb9af6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -22,6 +22,7 @@ pub mod host_registry; pub mod i18n; pub mod keymap; pub mod local_link; +pub mod lsp; pub mod machine_mirror; pub mod notice; pub mod pane;