From 5003c4d733f07c5fe8cd76cf4f880fe3d6c665ae Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:17:27 +0800 Subject: [PATCH] feat(editor): language servers for the code editor A new ui::lsp module runs language servers for local files the editor opens: rust-analyzer, typescript-language-server, pyright (or pylsp), gopls and clangd, from a registry table. Each server is keyed by (server, project root), found from markers such as a Cargo workspace, package.json, go.work/go.mod or pyproject.toml, and shared by every buffer under that root. A server missing from PATH (which tty7 already fills from the login shell) means no LSP for that language and a one-line hint in the status bar. The JSON-RPC client frames Content-Length messages over the server's stdio on dedicated threads, holds everything back until initialize is answered, cancels requests nobody waits for any more, and answers the server's own requests (configuration, capability registration, applyEdit). Documents use full-text sync, debounced, and flushed before every request. A server whose last file closed shuts down after a grace period, every server exits with the app, and one that crashes is restarted a few times before it is left down. Features: diagnostics as underlines with hover messages and an error and warning count in the status bar; completion (snippets flattened to plain text); hover; go to definition by secondary-click or F12, across files through open_file_in_editor_at; code actions on the editor's own menu (cmd-.), resolved and executed as the server needs; Format Document (shift-alt-f); and Rename Symbol (F2) in the editor's bar, applied to open buffers and to files on disk. The key bindings sit in the Input context so F2 and F12 stay with terminal programs. Positions are converted at the boundary: servers count UTF-16 units, gpui-component counts chars, and the rope counts bytes. The new editor_lsp setting (default on) turns it all off. --- crates/tty7-core/src/core/config.rs | 7 + src/core/actions.rs | 4 + src/ui/app.rs | 31 + src/ui/code_editor.rs | 79 +- src/ui/i18n/en.rs | 11 + src/ui/i18n/ja.rs | 11 + src/ui/i18n/mod.rs | 11 + src/ui/i18n/zh.rs | 11 + src/ui/keymap.rs | 33 + src/ui/lsp/client.rs | 491 ++++++++++++ src/ui/lsp/convert.rs | 445 +++++++++++ src/ui/lsp/editor.rs | 408 ++++++++++ src/ui/lsp/mod.rs | 1136 +++++++++++++++++++++++++++ src/ui/lsp/providers.rs | 504 ++++++++++++ src/ui/lsp/rpc.rs | 294 +++++++ src/ui/lsp/servers.rs | 322 ++++++++ src/ui/lsp/tests.rs | 201 +++++ src/ui/mod.rs | 1 + 18 files changed, 3998 insertions(+), 2 deletions(-) create mode 100644 src/ui/lsp/client.rs create mode 100644 src/ui/lsp/convert.rs create mode 100644 src/ui/lsp/editor.rs create mode 100644 src/ui/lsp/mod.rs create mode 100644 src/ui/lsp/providers.rs create mode 100644 src/ui/lsp/rpc.rs create mode 100644 src/ui/lsp/servers.rs create mode 100644 src/ui/lsp/tests.rs 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;