Merge pull request #159 from l0ng-ai/refactor/drop-lsp

refactor(editor): drop the LSP client entirely
This commit is contained in:
l0ng-ai
2026-07-24 18:09:27 +08:00
committed by GitHub
8 changed files with 26 additions and 1001 deletions
Generated
-3
View File
@@ -9560,7 +9560,6 @@ dependencies = [
"libc",
"libgssapi",
"log",
"lsp-types",
"memchr",
"notify 8.2.0",
"notify-rust",
@@ -9572,7 +9571,6 @@ dependencies = [
"regex",
"reqwest_client",
"resvg",
"ropey",
"russh",
"russh-sftp",
"serde",
@@ -9583,7 +9581,6 @@ dependencies = [
"smol",
"tokio",
"tray-icon",
"url",
"uuid",
"windows-sys 0.59.0",
"winresource",
+4 -9
View File
@@ -80,16 +80,11 @@ notify-rust = "4"
# editor, `ui::code`) reuses it to refresh the tree and detect external edits.
notify = "8"
# Code panel (file tree + editor, `ui::code`). `ropey` and `lsp-types` are pinned
# to the exact versions gpui-component uses so its `InputState` rope / LSP
# provider types unify with ours; `ignore` supplies the gitignore matcher chain
# the file tree uses to dim ignored entries (same crate ripgrep uses).
ropey = "=2.0.0-beta.1"
lsp-types = { version = "0.97.0", features = ["proposed"] }
# Code panel (file tree + editor, `ui::code`): `ignore` supplies the gitignore
# matcher chain the file tree uses to dim ignored entries (same crate ripgrep
# uses). The editor itself needs nothing else — text storage and syntax
# highlighting both live inside gpui-component's `InputState`.
ignore = "0.4"
# `url` does the file-path ↔ `file://` URI conversion for the LSP client
# (percent-encoding, Windows drive letters); already in the tree via gpui.
url = "2"
# Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows, behind
# one blocking `Read`/`Write`/`resize` API. This is what lets `daemon::pane` share
-4
View File
@@ -80,10 +80,6 @@ actions!(
ToggleCodePanel,
// Save the editor panel's active file (⌘S).
EditorSave,
// LSP jump to the definition of the symbol at the editor cursor (F12).
EditorGotoDefinition,
// LSP list references to the symbol at the editor cursor (⇧F12).
EditorFindReferences,
// Open the SSH profile manager/editor full-window page (WS6, FR-P1).
OpenSshProfiles,
// Reconnect a dead native-SSH pane in place (WS6, FR-E4).
+2 -8
View File
@@ -144,8 +144,8 @@ pub struct Tab {
/// roots/expansion, and visibility. Same per-tab contract as
/// `diff_overlay` — switching away hides it, switching back restores it,
/// closing the tab drops it. Shared caches (directory listings, gitignore
/// matchers, language servers, watchers) live on [`Tty7App`]. `None` until
/// the panel is first opened in this tab.
/// matchers, watchers) live on [`Tty7App`]. `None` until the panel is
/// first opened in this tab.
pub(crate) code: Option<Box<crate::ui::code_editor::TabCode>>,
/// The sidebar group this tab last *definitively* belonged to: the
/// repository home of its first pane's cwd — the main checkout's root, so
@@ -4864,12 +4864,6 @@ impl Render for Tty7App {
.on_action(
cx.listener(|this, _: &EditorSave, window, cx| this.editor_save_active(window, cx)),
)
.on_action(cx.listener(|this, _: &EditorGotoDefinition, window, cx| {
this.editor_goto_definition(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorFindReferences, window, cx| {
this.editor_find_references(window, cx)
}))
// Quit lives on the same element-tree action path as every other Cmd
// shortcut above, so a focused terminal routes `cmd-q` here rather
// than relying solely on the global handler (which the keystroke
+20 -329
View File
@@ -11,6 +11,14 @@
//! unsaved-close confirmation, and the overlay chrome itself (the file-tree
//! column comes from `ui::file_tree`).
//!
//! Deliberately *not* an IDE: there is no language-server integration, and
//! adding one is not a wanted feature. Opening a `.rs` file silently spawning
//! rust-analyzer — a background process indexing the whole workspace for
//! hundreds of megabytes of RAM — is not something a terminal emulator should
//! do to its user. Highlighting comes from tree-sitter grammars compiled into
//! gpui-component (see [`language_for_path`]), which is static, in-process,
//! and costs nothing beyond parsing the open buffer.
//!
//! Layout: overlaying the body (like Settings and the diff overlay) rather
//! than docking a side column means toggling never resizes the terminal — no
//! PTY resize, no reflow — and the editor gets the full body width. The tab
@@ -23,8 +31,8 @@ use std::time::SystemTime;
use gpui::prelude::*;
use gpui::{
AnyElement, Context, Entity, Focusable as _, MouseButton, PromptLevel, SharedString,
Subscription, Window, div, px,
AnyElement, Context, Entity, Focusable as _, PromptLevel, SharedString, Subscription, Window,
div, px,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::{Input, InputEvent, InputState, TabSize};
@@ -60,13 +68,6 @@ pub(crate) struct OpenFile {
pub(crate) preview: bool,
/// Soft-wrap state (mirrored here — the input's own flag isn't readable).
pub(crate) wrap: bool,
/// The language server serving this file (spawned per workspace root),
/// with the LSP `languageId` used for document sync. `None` when no
/// server is configured/available for the language.
lsp: Option<(std::rc::Rc<crate::ui::lsp::LspClient>, &'static str)>,
/// Debounced full-document `didChange`; replaced (cancelling the old
/// timer) on every keystroke.
change_task: Option<gpui::Task<()>>,
_sub: Subscription,
/// Repaints the app when the input notifies (cursor moves, scrolls…) so
/// the status bar's Ln/Col stays live.
@@ -87,17 +88,15 @@ impl OpenFile {
/// Per-tab code-panel state, hung on [`Tab::code`](crate::ui::app::Tab) with
/// the same lifecycle contract as the diff overlay: only the active tab's
/// panel renders, switching away hides it, closing the tab drops it. The
/// shared caches (directory listings, gitignore matchers, language servers,
/// filesystem watchers) live on [`Tty7App`] — this holds only what is truly
/// this tab's: its open files and its tree view state.
/// shared caches (directory listings, gitignore matchers, filesystem
/// watchers) live on [`Tty7App`] — this holds only what is truly this tab's:
/// its open files and its tree view state.
pub(crate) struct TabCode {
/// Whether the overlay is currently shown for this tab. The open-file set
/// survives hiding (Esc) — only closing the tab drops it.
pub(crate) visible: bool,
pub(crate) files: Vec<OpenFile>,
pub(crate) active: usize,
/// Find-references results, shown as a drawer under the editor.
pub(crate) references: Option<Vec<ReferenceItem>>,
/// File-tree roots: this tab's pane cwds resolved to repo roots.
pub(crate) roots: Vec<PathBuf>,
pub(crate) expanded: std::collections::HashSet<PathBuf>,
@@ -116,7 +115,6 @@ impl TabCode {
visible: false,
files: Vec::new(),
active: 0,
references: None,
roots: Vec::new(),
expanded: std::collections::HashSet::new(),
selected: None,
@@ -137,18 +135,6 @@ pub(crate) struct EditorPanelState {
/// Feeds changed paths from the watcher thread into the UI-side reload
/// loop spawned in [`EditorPanelState::new`].
events_tx: smol::channel::Sender<PathBuf>,
/// Language-server registry (one client per server × workspace root).
pub(crate) lsp: crate::ui::lsp::LspRegistry,
}
/// One row in the find-references drawer.
pub(crate) struct ReferenceItem {
pub path: PathBuf,
/// 0-based target position.
pub line: u32,
pub character: u32,
/// The referenced line's text, for the row preview.
pub preview: SharedString,
}
impl EditorPanelState {
@@ -177,7 +163,6 @@ impl EditorPanelState {
Self {
watcher: None,
events_tx: tx,
lsp: crate::ui::lsp::LspRegistry::new(window, cx),
}
}
}
@@ -409,36 +394,15 @@ impl Tty7App {
.replaceable(true)
.folding(true)
.soft_wrap(false)
.default_value(text.clone())
.default_value(text)
});
// Language server: spawn (or reuse) the server for this language at
// the file's workspace root, open the document, and install the
// completion / hover / definition providers on the input.
let root = crate::ui::file_tree::repo_root_for(&path)
.or_else(|| path.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("/"));
let lsp = self.editor.lsp.client_for(language, &root);
if let Some((client, language_id)) = &lsp {
client.did_open(&path, language_id, &text);
let provider = std::rc::Rc::new(crate::ui::lsp::FileLsp {
client: client.clone(),
path: path.clone(),
});
input.update(cx, |st, _| {
st.lsp.completion_provider = Some(provider.clone());
st.lsp.hover_provider = Some(provider.clone());
st.lsp.definition_provider = Some(provider);
});
}
// Dirty tracking: `set_value` suppresses events, so every Change here
// is a real user edit. Each edit also (re)arms the debounced LSP
// didChange sync. Files may be open in any tab, not just the active
// one, so the lookup scans all tabs.
// is a real user edit. Files may be open in any tab, not just the
// active one, so the lookup scans all tabs.
let sub = cx.subscribe_in(&input, window, {
let path = path.clone();
move |this: &mut Tty7App, _input, ev, window, cx| {
move |this: &mut Tty7App, _input, ev, _window, cx| {
if matches!(ev, InputEvent::Change) {
let path = path.clone();
let Some(f) = this
.tabs
.iter_mut()
@@ -451,16 +415,6 @@ impl Tty7App {
if !f.dirty {
f.dirty = true;
}
if f.lsp.is_some() {
f.change_task = Some(cx.spawn_in(window, async move |app, cx| {
cx.background_executor()
.timer(std::time::Duration::from_millis(150))
.await;
let _ = app.update(cx, |app, cx| {
app.editor_sync_lsp_document(&path, cx);
});
}));
}
cx.notify();
}
}
@@ -482,8 +436,6 @@ impl Tty7App {
conflict: false,
preview: false,
wrap: false,
lsp,
change_task: None,
_sub: sub,
_observe: observe,
},
@@ -581,13 +533,6 @@ impl Tty7App {
f.dirty = false;
f.conflict = false;
f.disk_mtime = std::fs::metadata(&f.path).and_then(|m| m.modified()).ok();
if let Some((client, _)) = &f.lsp {
// Make sure the server saw the final text before the save
// notification (the debounced didChange may still be
// pending), so on-save diagnostics match the disk state.
client.did_change(&f.path, &text);
client.did_save(&f.path);
}
cx.notify();
}
Err(e) => {
@@ -677,10 +622,7 @@ impl Tty7App {
if ix >= code.files.len() {
return;
}
let f = code.files.remove(ix);
if let Some((client, _)) = &f.lsp {
client.did_close(&f.path);
}
code.files.remove(ix);
if code.active >= ix && code.active > 0 {
code.active -= 1;
}
@@ -688,160 +630,6 @@ impl Tty7App {
cx.notify();
}
/// Every open buffer for `path`, across all tabs (a file can be open in
/// more than one tab's panel; each has its own buffer).
fn editor_files_for_path<'a>(&'a self, path: &'a Path) -> impl Iterator<Item = &'a OpenFile> {
self.tabs
.iter()
.filter_map(|t| t.code.as_deref())
.flat_map(|c| c.files.iter())
.filter(move |f| f.path == *path)
}
/// Push the buffer's current text to the language server (the debounced
/// tail of a typing burst).
pub(crate) fn editor_sync_lsp_document(&mut self, path: &Path, cx: &mut Context<Self>) {
for f in self.editor_files_for_path(path) {
if let Some((client, _)) = &f.lsp {
client.did_change(&f.path, &f.input.read(cx).text().to_string());
break; // one didChange per path — the server sees one document
}
}
}
/// Apply `publishDiagnostics` for `path` to its open buffers (any tab).
pub(crate) fn editor_apply_diagnostics(
&mut self,
path: &Path,
diags: Vec<lsp_types::Diagnostic>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let inputs: Vec<Entity<InputState>> = self
.editor_files_for_path(path)
.map(|f| f.input.clone())
.collect();
for input in inputs {
input.update(cx, |st, cx| {
let text = st.text().clone();
if let Some(set) = st.diagnostics_mut() {
set.reset(&text);
set.extend(diags.iter().cloned());
cx.notify();
}
});
}
}
/// `EditorGotoDefinition` (F12): resolve the definition at the cursor and
/// jump — opening the target file first when it lives elsewhere (the
/// in-buffer ⌘-click path can't cross files; this one can).
pub(crate) fn editor_goto_definition(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(f) = self.tab_code().and_then(|c| c.active_file()) else {
return;
};
let Some((client, _)) = &f.lsp else { return };
let st = f.input.read(cx);
let (text, offset) = (st.text().clone(), st.cursor());
let Some(params) = crate::ui::lsp::LspClient::position_params(&f.path, &text, offset)
else {
return;
};
let rx = client.request("textDocument/definition", params);
cx.spawn_in(window, async move |app, cx| {
let Ok(v) = rx.recv().await else { return };
let links = crate::ui::lsp::normalize_definitions(v);
let Some(link) = links.first() else { return };
let Some(target) = crate::ui::lsp::path_for_uri(link.target_uri.as_str()) else {
return;
};
let pos = link.target_selection_range.start;
let _ = app.update_in(cx, |app, window, cx| {
app.editor_jump_to(&target, pos, window, cx);
});
})
.detach();
}
/// `EditorFindReferences` (⇧F12): list every reference to the symbol at
/// the cursor in a drawer under the editor.
pub(crate) fn editor_find_references(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(f) = self.tab_code().and_then(|c| c.active_file()) else {
return;
};
let Some((client, _)) = &f.lsp else { return };
let st = f.input.read(cx);
let (text, offset) = (st.text().clone(), st.cursor());
let Some(mut params) = crate::ui::lsp::LspClient::position_params(&f.path, &text, offset)
else {
return;
};
params["context"] = serde_json::json!({ "includeDeclaration": true });
let rx = client.request("textDocument/references", params);
cx.spawn_in(window, async move |app, cx| {
let Ok(v) = rx.recv().await else { return };
let locations: Vec<lsp_types::Location> = serde_json::from_value(v).unwrap_or_default();
// Read each referenced line once per file for the row previews. On
// the background executor: `spawn_in` runs on the *main* thread, and
// a couple of hundred `read_to_string`s there would stall a frame.
let items: Vec<ReferenceItem> = cx
.background_executor()
.spawn(async move {
let mut items: Vec<ReferenceItem> = Vec::new();
let mut file_lines: std::collections::HashMap<PathBuf, Vec<String>> =
std::collections::HashMap::new();
for loc in locations.into_iter().take(200) {
let Some(path) = crate::ui::lsp::path_for_uri(loc.uri.as_str()) else {
continue;
};
let lines = file_lines.entry(path.clone()).or_insert_with(|| {
std::fs::read_to_string(&path)
.map(|t| t.lines().map(|l| l.to_string()).collect())
.unwrap_or_default()
});
let line = loc.range.start.line;
let preview = lines
.get(line as usize)
.map(|l| l.trim().to_string())
.unwrap_or_default();
items.push(ReferenceItem {
path,
line,
character: loc.range.start.character,
preview: preview.into(),
});
}
items
})
.await;
let _ = app.update(cx, |app, cx| {
if let Some(code) = app.tab_code_mut() {
code.references = Some(items);
}
cx.notify();
});
})
.detach();
}
/// Open `path` (if needed) and place the cursor at an LSP position.
pub(crate) fn editor_jump_to(
&mut self,
path: &Path,
pos: lsp_types::Position,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_file_in_editor(path, window, cx);
if let Some(f) = self.tab_code().and_then(|c| c.active_file())
&& f.path == *path
{
f.input.clone().update(cx, |st, cx| {
st.set_cursor_position(pos, window, cx);
});
}
}
/// A watched file changed on disk. Clean buffers reload silently; dirty
/// ones raise the conflict banner and let the user pick a side. The file
/// may be open in several tabs — each buffer is handled on its own.
@@ -909,9 +697,6 @@ impl Tty7App {
f.disk_mtime = std::fs::metadata(&f.path).and_then(|m| m.modified()).ok();
f.dirty = false;
f.conflict = false;
if let Some((client, _)) = &f.lsp {
client.did_change(&f.path, &text);
}
let input = f.input.clone();
input.update(cx, |input, cx| input.set_value(text, window, cx));
cx.notify();
@@ -968,7 +753,6 @@ impl Tty7App {
.and_then(|c| c.active_file())
.filter(|f| f.conflict)
.map(|_| self.render_editor_conflict_banner(cx));
let references = self.render_editor_references(cx);
let editor_col = v_flex()
.flex_1()
@@ -976,8 +760,7 @@ impl Tty7App {
.h_full()
.child(self.render_editor_header(cx))
.when_some(conflict_banner, |this, b| this.child(b))
.child(div().flex_1().min_h_0().child(body))
.when_some(references, |this, drawer| this.child(drawer));
.child(div().flex_1().min_h_0().child(body));
Some(
v_flex()
@@ -1082,8 +865,7 @@ impl Tty7App {
}
/// The Zed-style status bar along the panel bottom: repo-relative path on
/// the left; preview/wrap toggles, cursor position, and the language
/// server's presence on the right.
/// the left; preview/wrap toggles and the cursor position on the right.
fn render_code_status_bar(&self, _window: &Window, cx: &mut Context<Self>) -> gpui::Div {
let code = self.tab_code();
let muted = cx.theme().muted_foreground;
@@ -1116,9 +898,6 @@ impl Tty7App {
let wrap: Option<bool> = active.map(|f| f.wrap);
let is_markdown = active.is_some_and(|f| language_for_path(&f.path) == "markdown");
let preview = active.is_some_and(|f| f.preview);
let lsp_name: Option<SharedString> = active
.and_then(|f| f.lsp.as_ref())
.map(|(client, _)| format!("{}", client.name()).into());
h_flex()
.flex_none()
@@ -1174,7 +953,6 @@ impl Tty7App {
)
})
.when_some(cursor, |this, t| this.child(div().child(t)))
.when_some(lsp_name, |this, t| this.child(div().child(t)))
}
/// Empty state: the panel is open with nothing loaded.
@@ -1197,93 +975,6 @@ impl Tty7App {
)
}
/// The find-references drawer (⇧F12 results) under the editor body.
fn render_editor_references(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let refs = self.tab_code()?.references.as_ref()?;
let muted = cx.theme().muted_foreground;
let rows = refs.iter().enumerate().map(|(ix, r)| {
let name = r
.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let (path, line, character) = (r.path.clone(), r.line, r.character);
h_flex()
.id(("editor-ref", ix))
.items_center()
.gap_2()
.px_2()
.py_0p5()
.text_sm()
.cursor_pointer()
.hover(|s| s.bg(cx.theme().accent.opacity(0.5)))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, window, cx| {
this.editor_jump_to(
&path,
lsp_types::Position::new(line, character),
window,
cx,
);
}),
)
.child(
div()
.flex_none()
.text_color(muted)
.child(format!("{name}:{}", r.line + 1)),
)
.child(
div()
.flex_1()
.min_w_0()
.text_ellipsis()
.child(r.preview.clone()),
)
});
Some(
v_flex()
.flex_none()
.max_h(gpui::relative(0.4))
.border_t_1()
.border_color(cx.theme().border)
.child(
h_flex()
.items_center()
.px_2()
.py_1()
.text_sm()
.child(div().flex_1().child(format!("{} references", refs.len())))
.child(
crate::ui::tab_strip::chrome_tile(
Button::new("editor-refs-close").icon(IconName::Close),
false,
cx,
)
.xsmall()
.on_click(cx.listener(
|this, _, _w, cx| {
if let Some(code) = this.tab_code_mut() {
code.references = None;
}
cx.notify();
},
)),
),
)
.child(
v_flex()
.id("editor-refs-list")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.children(rows),
)
.into_any_element(),
)
}
/// Banner shown when the file changed on disk while the buffer is dirty.
fn render_editor_conflict_banner(&self, cx: &mut Context<Self>) -> AnyElement {
let tab_ix = self.active;
-5
View File
@@ -211,9 +211,6 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
("ToggleCodePanel", "secondary-shift-e"),
// Save the editor's active file. ⌘S is free — the terminal has no save.
("EditorSave", "secondary-s"),
// LSP navigation in the editor panel, on the VS Code chords.
("EditorGotoDefinition", "f12"),
("EditorFindReferences", "shift-f12"),
// No default chord — reachable from the command palette ("SSH: Manage
// Profiles…") and bindable in Settings.
("OpenSshProfiles", ""),
@@ -515,8 +512,6 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None),
"ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None),
"EditorSave" => KeyBinding::new(keystroke, EditorSave, None),
"EditorGotoDefinition" => KeyBinding::new(keystroke, EditorGotoDefinition, None),
"EditorFindReferences" => KeyBinding::new(keystroke, EditorFindReferences, None),
"OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None),
"RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None),
"Quit" => KeyBinding::new(keystroke, Quit, None),
-642
View File
@@ -1,642 +0,0 @@
//! Minimal LSP client for the code-editor panel.
//!
//! One `LspClient` per (server, workspace root): a spawned server process
//! speaking JSON-RPC over stdio. A std reader thread parses `Content-Length`
//! frames and routes responses to per-request channels, `publishDiagnostics`
//! notifications to a UI-side loop, and answers the handful of server→client
//! requests (`workspace/configuration` &co.) with benign defaults so servers
//! like rust-analyzer don't stall waiting on us.
//!
//! The editor integrates through gpui-component's provider traits: completion
//! (popup menu), hover (popover) and definition (⌘-hover underline + ⌘-click)
//! are handled by [`FileLsp`], one per open file. The component's ⌘-click jump
//! only works within the current buffer, so `definitions` filters to same-file
//! links; the app-level Go to Definition action (F12, `code_editor.rs`) does
//! the full cross-file open + jump itself.
//!
//! Servers are discovered on PATH per language (rust-analyzer, gopls, pyright,
//! typescript-language-server, clangd); a missing binary just means no LSP for
//! that language — the editor works fine without it.
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{Context as _, Result, anyhow};
use gpui::{App, Context, Task, Window};
use gpui_component::input::InputState;
use gpui_component::input::{CompletionProvider, DefinitionProvider, HoverProvider, RopeExt as _};
use ropey::Rope;
use serde_json::{Value, json};
use crate::ui::app::Tty7App;
/// How long a single request may wait on the server before the provider gives
/// up (a hung server must not wedge hover/completion forever).
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
/// The command line and LSP `languageId` for a tree-sitter language name.
/// `None` → no server configured for that language.
pub(crate) fn server_for_language(lang: &str) -> Option<(&'static [&'static str], &'static str)> {
Some(match lang {
"rust" => (&["rust-analyzer"], "rust"),
"go" => (&["gopls"], "go"),
"python" => (&["pyright-langserver", "--stdio"], "python"),
"typescript" => (&["typescript-language-server", "--stdio"], "typescript"),
"tsx" => (
&["typescript-language-server", "--stdio"],
"typescriptreact",
),
"javascript" => (&["typescript-language-server", "--stdio"], "javascript"),
"c" => (&["clangd"], "c"),
"cpp" => (&["clangd"], "cpp"),
_ => return None,
})
}
/// `file://` URI for a local path (percent-encoded via the `url` crate).
pub(crate) fn uri_for_path(path: &Path) -> Option<String> {
url::Url::from_file_path(path).ok().map(|u| u.to_string())
}
/// Local path for a `file://` URI string; `None` for non-file schemes.
pub(crate) fn path_for_uri(uri: &str) -> Option<PathBuf> {
url::Url::parse(uri).ok()?.to_file_path().ok()
}
// ---------------------------------------------------------------------------
// Client.
// ---------------------------------------------------------------------------
/// Thread-shared client internals (UI thread + reader thread).
struct Inner {
name: String,
stdin: Mutex<ChildStdin>,
/// In-flight requests by id; the reader thread resolves them.
pending: Mutex<HashMap<i64, smol::channel::Sender<Value>>>,
/// Flips true when the `initialize` response lands; frames sent before
/// that wait in `queued`.
ready: AtomicBool,
queued: Mutex<Vec<String>>,
next_id: AtomicI64,
}
impl Inner {
fn write_frame(stdin: &mut ChildStdin, body: &str) {
let _ = write!(stdin, "Content-Length: {}\r\n\r\n{body}", body.len());
let _ = stdin.flush();
}
/// Send a frame now, or park it until the server finished initializing.
///
/// The `ready` check happens under the `queued` lock, and `flush_queued`
/// takes the same lock — otherwise a frame could read `ready == false`,
/// lose the race to the reader thread flipping it and draining the queue,
/// and then park itself behind a handshake that already finished, where
/// nothing would ever send it (a `didOpen` lost that way costs the file its
/// diagnostics for the whole session).
fn send(&self, body: String) {
let mut queued = self.queued.lock().unwrap();
if self.ready.load(Ordering::SeqCst) {
drop(queued);
let mut stdin = self.stdin.lock().unwrap();
Self::write_frame(&mut stdin, &body);
} else {
queued.push(body);
}
}
/// Initialize finished: mark ready and flush everything parked behind the
/// handshake. `ready` flips under the `queued` lock, so no sender can slip a
/// frame out ahead of the ones already parked (a `didChange` overtaking its
/// `didOpen` desynchronizes the server for the rest of the session).
fn mark_ready_and_flush(&self) {
let mut queued = self.queued.lock().unwrap();
self.ready.store(true, Ordering::SeqCst);
let parked: Vec<String> = std::mem::take(&mut *queued);
let mut stdin = self.stdin.lock().unwrap();
for body in parked {
Self::write_frame(&mut stdin, &body);
}
}
}
/// A running language server bound to one workspace root.
pub(crate) struct LspClient {
inner: Arc<Inner>,
child: std::cell::RefCell<Child>,
#[allow(dead_code)] // identifies the client in future workspace-level requests
pub(crate) root: PathBuf,
/// didOpen version counter per document.
versions: std::cell::RefCell<HashMap<PathBuf, i64>>,
}
impl Drop for LspClient {
fn drop(&mut self) {
// No graceful shutdown round-trip — the app is closing the panel or
// exiting; killing the child reaps it without waiting on a wedged one.
let _ = self.child.borrow_mut().kill();
}
}
impl LspClient {
/// Spawn `cmd` rooted at `root` and start the handshake + reader thread.
/// Diagnostics flow out through `diag_tx` as `(path, diagnostics)`.
pub(crate) fn spawn(
cmd: &[&str],
root: &Path,
diag_tx: smol::channel::Sender<(PathBuf, Vec<lsp_types::Diagnostic>)>,
) -> Result<Self> {
let mut child = Command::new(cmd[0])
.args(&cmd[1..])
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("spawning {}", cmd[0]))?;
let stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
let inner = Arc::new(Inner {
name: cmd[0].to_string(),
stdin: Mutex::new(stdin),
pending: Mutex::new(HashMap::new()),
ready: AtomicBool::new(false),
queued: Mutex::new(Vec::new()),
next_id: AtomicI64::new(2), // 1 is reserved for `initialize`
});
// The initialize request goes out immediately (bypassing the queue).
let root_uri = uri_for_path(root).unwrap_or_else(|| "file:///".into());
let init = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"processId": std::process::id(),
"rootUri": root_uri,
"workspaceFolders": [{
"uri": root_uri,
"name": root.file_name().map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "root".into()),
}],
"capabilities": {
"textDocument": {
"synchronization": { "didSave": true },
"publishDiagnostics": { "relatedInformation": false },
"hover": { "contentFormat": ["markdown", "plaintext"] },
"completion": {
"completionItem": {
"snippetSupport": false,
"documentationFormat": ["markdown", "plaintext"],
}
},
"definition": { "linkSupport": true },
"references": {},
},
"workspace": { "configuration": true, "workspaceFolders": true },
"window": { "workDoneProgress": true },
},
},
});
{
let mut stdin = inner.stdin.lock().unwrap();
Inner::write_frame(&mut stdin, &init.to_string());
}
// Reader thread: frame parser + dispatcher.
let reader_inner = inner.clone();
std::thread::Builder::new()
.name(format!("lsp-{}", cmd[0]))
.spawn(move || reader_loop(stdout, reader_inner, diag_tx))
.context("spawning lsp reader thread")?;
Ok(Self {
inner,
child: std::cell::RefCell::new(child),
root: root.to_path_buf(),
versions: std::cell::RefCell::new(HashMap::new()),
})
}
/// The server binary's name, for the status bar.
pub(crate) fn name(&self) -> &str {
&self.inner.name
}
fn notify(&self, method: &str, params: Value) {
let body = json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string();
self.inner.send(body);
}
/// Fire a request; the returned channel yields the `result` value (or
/// `Null` on a server-side error). Await it on a background executor.
pub(crate) fn request(&self, method: &str, params: Value) -> smol::channel::Receiver<Value> {
let id = self.inner.next_id.fetch_add(1, Ordering::SeqCst);
let (tx, rx) = smol::channel::bounded(1);
self.inner.pending.lock().unwrap().insert(id, tx);
let body =
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
self.inner.send(body);
rx
}
pub(crate) fn did_open(&self, path: &Path, language_id: &str, text: &str) {
let Some(uri) = uri_for_path(path) else {
return;
};
self.versions.borrow_mut().insert(path.to_path_buf(), 1);
self.notify(
"textDocument/didOpen",
json!({ "textDocument": {
"uri": uri, "languageId": language_id, "version": 1, "text": text,
}}),
);
}
/// Full-document sync (the simplest correct thing at this scale).
pub(crate) fn did_change(&self, path: &Path, text: &str) {
let Some(uri) = uri_for_path(path) else {
return;
};
let mut versions = self.versions.borrow_mut();
let v = versions.entry(path.to_path_buf()).or_insert(1);
*v += 1;
self.notify(
"textDocument/didChange",
json!({
"textDocument": { "uri": uri, "version": *v },
"contentChanges": [{ "text": text }],
}),
);
}
pub(crate) fn did_save(&self, path: &Path) {
let Some(uri) = uri_for_path(path) else {
return;
};
self.notify(
"textDocument/didSave",
json!({ "textDocument": { "uri": uri } }),
);
}
pub(crate) fn did_close(&self, path: &Path) {
let Some(uri) = uri_for_path(path) else {
return;
};
self.versions.borrow_mut().remove(path);
self.notify(
"textDocument/didClose",
json!({ "textDocument": { "uri": uri } }),
);
}
/// Standard text-document position params for a rope offset.
pub(crate) fn position_params(path: &Path, text: &Rope, offset: usize) -> Option<Value> {
let uri = uri_for_path(path)?;
let pos = text.offset_to_position(offset);
Some(json!({
"textDocument": { "uri": uri },
"position": { "line": pos.line, "character": pos.character },
}))
}
}
/// Parse `Content-Length`-framed JSON-RPC from the server and dispatch.
fn reader_loop(
stdout: std::process::ChildStdout,
inner: Arc<Inner>,
diag_tx: smol::channel::Sender<(PathBuf, Vec<lsp_types::Diagnostic>)>,
) {
let mut reader = BufReader::new(stdout);
loop {
// Headers.
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => return, // EOF: server exited
Ok(_) => {}
Err(_) => return,
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some(rest) = line.strip_prefix("Content-Length:") {
content_length = rest.trim().parse().ok();
}
}
let Some(len) = content_length else { continue };
let mut buf = vec![0u8; len];
if reader.read_exact(&mut buf).is_err() {
return;
}
let Ok(msg) = serde_json::from_slice::<Value>(&buf) else {
continue;
};
let id = msg.get("id").and_then(|v| v.as_i64());
let method = msg.get("method").and_then(|v| v.as_str());
match (id, method) {
// Server → client request: answer with a benign default so the
// server never blocks on us.
(Some(id), Some(method)) => {
let result = match method {
"workspace/configuration" => {
let n = msg
.pointer("/params/items")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
Value::Array(vec![Value::Null; n])
}
_ => Value::Null,
};
let body = json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string();
let mut stdin = inner.stdin.lock().unwrap();
Inner::write_frame(&mut stdin, &body);
}
// Response.
(Some(id), None) => {
if id == 1 {
// The initialize response: complete the handshake, then
// release everything parked behind it.
let initialized =
json!({ "jsonrpc": "2.0", "method": "initialized", "params": {} })
.to_string();
{
let mut stdin = inner.stdin.lock().unwrap();
Inner::write_frame(&mut stdin, &initialized);
}
inner.mark_ready_and_flush();
log::info!("lsp: {} initialized", inner.name);
continue;
}
if let Some(tx) = inner.pending.lock().unwrap().remove(&id) {
let result = msg.get("result").cloned().unwrap_or(Value::Null);
let _ = tx.try_send(result);
}
}
// Notification.
(None, Some("textDocument/publishDiagnostics")) => {
let uri = msg
.pointer("/params/uri")
.and_then(|v| v.as_str())
.unwrap_or_default();
let Some(path) = path_for_uri(uri) else {
continue;
};
let diags: Vec<lsp_types::Diagnostic> = msg
.pointer("/params/diagnostics")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let _ = diag_tx.try_send((path, diags));
}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Registry.
// ---------------------------------------------------------------------------
/// Lazily-spawned clients keyed by (server binary, workspace root). A spawn
/// failure is cached as `None` so a missing binary logs once, not per file.
pub(crate) struct LspRegistry {
clients: HashMap<(String, PathBuf), Option<Rc<LspClient>>>,
diag_tx: smol::channel::Sender<(PathBuf, Vec<lsp_types::Diagnostic>)>,
}
impl LspRegistry {
pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> Self {
// Diagnostics loop: reader threads push (path, diags); this applies
// them to the matching open editor on the UI thread.
let (tx, rx) = smol::channel::unbounded::<(PathBuf, Vec<lsp_types::Diagnostic>)>();
cx.spawn_in(window, async move |app, cx| {
while let Ok((path, diags)) = rx.recv().await {
let ok = app.update_in(cx, |app, window, cx| {
app.editor_apply_diagnostics(&path, diags, window, cx);
});
if ok.is_err() {
break;
}
}
})
.detach();
Self {
clients: HashMap::new(),
diag_tx: tx,
}
}
/// The client for a language at a workspace root, spawning on first use.
/// Returns the client plus the LSP `languageId` for didOpen.
pub(crate) fn client_for(
&mut self,
language: &str,
root: &Path,
) -> Option<(Rc<LspClient>, &'static str)> {
let (cmd, language_id) = server_for_language(language)?;
let key = (cmd[0].to_string(), root.to_path_buf());
let slot = self.clients.entry(key).or_insert_with(|| {
match LspClient::spawn(cmd, root, self.diag_tx.clone()) {
Ok(client) => Some(Rc::new(client)),
Err(e) => {
log::info!("lsp: {} unavailable: {e:#}", cmd[0]);
None
}
}
});
slot.clone().map(|c| (c, language_id))
}
}
// ---------------------------------------------------------------------------
// Per-file provider bridging to gpui-component's LSP traits.
// ---------------------------------------------------------------------------
/// The provider object installed on an open file's `InputState`.
pub(crate) struct FileLsp {
pub(crate) client: Rc<LspClient>,
pub(crate) path: PathBuf,
}
/// Await one LSP response with a timeout, off the UI thread.
async fn recv_with_timeout(rx: smol::channel::Receiver<Value>) -> Result<Value> {
let timeout = async {
smol::Timer::after(REQUEST_TIMEOUT).await;
Err(anyhow!("lsp request timed out"))
};
let recv = async { rx.recv().await.map_err(|_| anyhow!("lsp server gone")) };
smol::future::or(recv, timeout).await
}
impl CompletionProvider for FileLsp {
fn completions(
&self,
text: &Rope,
offset: usize,
trigger: lsp_types::CompletionContext,
_window: &mut Window,
cx: &mut Context<InputState>,
) -> Task<Result<lsp_types::CompletionResponse>> {
let Some(mut params) = LspClient::position_params(&self.path, text, offset) else {
return Task::ready(Err(anyhow!("bad path")));
};
params["context"] = serde_json::to_value(&trigger).unwrap_or(Value::Null);
let rx = self.client.request("textDocument/completion", params);
cx.background_executor().spawn(async move {
let v = recv_with_timeout(rx).await?;
if v.is_null() {
return Ok(lsp_types::CompletionResponse::Array(vec![]));
}
Ok(serde_json::from_value(v)?)
})
}
fn is_completion_trigger(
&self,
_offset: usize,
new_text: &str,
_cx: &mut Context<InputState>,
) -> bool {
new_text
.chars()
.last()
.is_some_and(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | ':'))
}
}
impl HoverProvider for FileLsp {
fn hover(
&self,
text: &Rope,
offset: usize,
_window: &mut Window,
cx: &mut App,
) -> Task<Result<Option<lsp_types::Hover>>> {
let Some(params) = LspClient::position_params(&self.path, text, offset) else {
return Task::ready(Ok(None));
};
let rx = self.client.request("textDocument/hover", params);
cx.background_executor().spawn(async move {
let v = recv_with_timeout(rx).await?;
if v.is_null() {
return Ok(None);
}
Ok(serde_json::from_value(v).ok())
})
}
}
impl DefinitionProvider for FileLsp {
fn definitions(
&self,
text: &Rope,
offset: usize,
_window: &mut Window,
cx: &mut App,
) -> Task<Result<Vec<lsp_types::LocationLink>>> {
let Some(params) = LspClient::position_params(&self.path, text, offset) else {
return Task::ready(Ok(vec![]));
};
let Some(this_uri) = uri_for_path(&self.path) else {
return Task::ready(Ok(vec![]));
};
let rx = self.client.request("textDocument/definition", params);
cx.background_executor().spawn(async move {
let v = recv_with_timeout(rx).await?;
let links = normalize_definitions(v);
// The component's ⌘-click jump applies target offsets to the
// *current* buffer, so only same-file links are safe to hand it;
// cross-file jumps go through the F12 action instead.
Ok(links
.into_iter()
.filter(|l| l.target_uri.as_str() == this_uri)
.collect())
})
}
}
/// `textDocument/definition` may answer `Location`, `Location[]` or
/// `LocationLink[]`; normalize all three to links.
pub(crate) fn normalize_definitions(v: Value) -> Vec<lsp_types::LocationLink> {
if v.is_null() {
return vec![];
}
if let Ok(links) = serde_json::from_value::<Vec<lsp_types::LocationLink>>(v.clone()) {
return links;
}
let to_link = |loc: lsp_types::Location| lsp_types::LocationLink {
origin_selection_range: None,
target_uri: loc.uri,
target_range: loc.range,
target_selection_range: loc.range,
};
if let Ok(locs) = serde_json::from_value::<Vec<lsp_types::Location>>(v.clone()) {
return locs.into_iter().map(to_link).collect();
}
if let Ok(loc) = serde_json::from_value::<lsp_types::Location>(v) {
return vec![to_link(loc)];
}
vec![]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uri_round_trips_paths_with_spaces() {
// `Url::from_file_path` requires an *absolute* path in the host's own
// shape, so a POSIX literal here would simply fail to convert on
// Windows rather than exercise the percent-encoding under test.
let p = if cfg!(windows) {
Path::new(r"C:\tmp\a dir\file.rs")
} else {
Path::new("/tmp/a dir/file.rs")
};
let uri = uri_for_path(p).unwrap();
assert!(uri.starts_with("file:///"));
assert!(uri.contains("a%20dir"));
assert_eq!(path_for_uri(&uri), Some(p.to_path_buf()));
}
#[test]
fn definition_responses_normalize_all_three_shapes() {
let loc = json!({ "uri": "file:///a.rs", "range": {
"start": { "line": 1, "character": 2 },
"end": { "line": 1, "character": 5 } } });
// Single Location.
assert_eq!(normalize_definitions(loc.clone()).len(), 1);
// Location[].
assert_eq!(
normalize_definitions(json!([loc.clone(), loc.clone()])).len(),
2
);
// LocationLink[].
let link = json!([{ "targetUri": "file:///b.rs",
"targetRange": loc["range"], "targetSelectionRange": loc["range"] }]);
let links = normalize_definitions(link);
assert_eq!(links.len(), 1);
assert_eq!(links[0].target_uri.as_str(), "file:///b.rs");
// Null.
assert!(normalize_definitions(Value::Null).is_empty());
}
#[test]
fn server_map_covers_the_big_five() {
for lang in ["rust", "go", "python", "typescript", "cpp"] {
assert!(server_for_language(lang).is_some(), "{lang}");
}
assert!(server_for_language("markdown").is_none());
}
}
-1
View File
@@ -14,7 +14,6 @@ pub mod forwards;
pub mod hints;
pub mod home;
pub mod keymap;
pub mod lsp;
pub mod palette;
pub mod pane;
pub mod perf;