mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -128,6 +128,10 @@ actions!(
|
||||
EditorSaveAs,
|
||||
EditorGoToLine,
|
||||
EditorNewFile,
|
||||
EditorGoToDefinition,
|
||||
EditorQuickFix,
|
||||
EditorRenameSymbol,
|
||||
EditorFormatDocument,
|
||||
OpenSshProfiles,
|
||||
RestartSshSession,
|
||||
SendTab,
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
+77
-2
@@ -147,6 +147,25 @@ impl OpenFile {
|
||||
}
|
||||
language_for_path(&self.path)
|
||||
}
|
||||
|
||||
fn local(&self) -> Option<LocalFile> {
|
||||
(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<InputState>,
|
||||
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<LocalFile> {
|
||||
self.editor
|
||||
.buffers
|
||||
.iter()
|
||||
.filter_map(OpenFile::local)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn editor_local_file(&self, id: BufferId) -> Option<LocalFile> {
|
||||
self.buffer(id).and_then(OpenFile::local)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_active_local_file(&self) -> Option<LocalFile> {
|
||||
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>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => "変更",
|
||||
|
||||
@@ -947,6 +947,17 @@ l10n_keys! {
|
||||
EditorFileDeletedOnDisk,
|
||||
EditorIndentSpaces,
|
||||
EditorIndentTabs,
|
||||
LspGoToDefinition,
|
||||
LspQuickFix,
|
||||
LspFormatDocument,
|
||||
LspRenameSymbol,
|
||||
LspRenameSymbolAction,
|
||||
LspRenamePlaceholder,
|
||||
LspRenameFailed,
|
||||
LspServerMissing,
|
||||
LspServerStarting,
|
||||
LspServerDown,
|
||||
LspProblemsTooltip,
|
||||
PanelInfoTitle,
|
||||
PanelChangesTitle,
|
||||
PanelScmTitle,
|
||||
|
||||
@@ -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 => "更改",
|
||||
|
||||
@@ -70,6 +70,15 @@ fn fixed_bindings() -> Vec<KeyBinding> {
|
||||
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<KeyBinding> {
|
||||
"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),
|
||||
|
||||
@@ -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<Result<Value, ResponseError>>;
|
||||
type Pending = Arc<Mutex<HashMap<RequestId, Reply>>>;
|
||||
|
||||
/// 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<Vec<u8>>,
|
||||
/// 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<Option<Vec<Vec<u8>>>>,
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
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<Self>, smol::channel::Receiver<ServerEvent>)> {
|
||||
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<Self>, smol::channel::Receiver<ServerEvent>)> {
|
||||
let (out, out_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
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<u8>, 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<Self>,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> impl std::future::Future<Output = anyhow::Result<Value>> + use<> {
|
||||
self.request_inner(method, params, true)
|
||||
}
|
||||
|
||||
/// `initialize`, which is the one request that goes past the gate.
|
||||
pub(crate) fn initialize(
|
||||
self: &Arc<Self>,
|
||||
params: Value,
|
||||
) -> impl std::future::Future<Output = anyhow::Result<Value>> + use<> {
|
||||
self.request_inner("initialize", params, false)
|
||||
}
|
||||
|
||||
fn request_inner(
|
||||
self: &Arc<Self>,
|
||||
method: &str,
|
||||
params: Value,
|
||||
gated: bool,
|
||||
) -> impl std::future::Future<Output = anyhow::Result<Value>> + 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<R: lsp_types::request::Request>(
|
||||
self: &Arc<Self>,
|
||||
params: R::Params,
|
||||
) -> impl std::future::Future<Output = anyhow::Result<R::Result>> + use<R> {
|
||||
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<N: lsp_types::notification::Notification>(&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<Value, ResponseError>) {
|
||||
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<LspClient>,
|
||||
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<LspClient>,
|
||||
smol::channel::Receiver<ServerEvent>,
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
@@ -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::<usize>() 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<lsp_types::Diagnostic> {
|
||||
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<TextEdit> {
|
||||
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<TextEdit> {
|
||||
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<usize> {
|
||||
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<usize>,
|
||||
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<std::str::Chars<'_>>, 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");
|
||||
}
|
||||
}
|
||||
@@ -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<InputState>,
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) language: &'static str,
|
||||
pub(crate) app: WeakEntity<Tty7App>,
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
fn lsp_enabled(cx: &gpui::App) -> bool {
|
||||
cx.global::<crate::core::config::Config>().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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>,
|
||||
) -> 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<Self>,
|
||||
) {
|
||||
// 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<Tty7App>| {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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::request::Formatting>(
|
||||
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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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::request::Rename>(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<Self>) {
|
||||
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<Self>) -> Option<AnyElement> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1136
File diff suppressed because it is too large
Load Diff
@@ -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<Tty7App>,
|
||||
}
|
||||
|
||||
pub(super) fn install(
|
||||
input: &Entity<InputState>,
|
||||
path: &Path,
|
||||
app: WeakEntity<Tty7App>,
|
||||
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<InputState>, 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<DocContext> {
|
||||
LspStore::context(&self.path, sync, Some(window.window_handle()), cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn not_available<T: Default + Send + 'static>() -> Task<Result<T>> {
|
||||
Task::ready(Ok(T::default()))
|
||||
}
|
||||
|
||||
impl CompletionProvider for LspProvider {
|
||||
fn completions(
|
||||
&self,
|
||||
text: &Rope,
|
||||
offset: usize,
|
||||
_trigger: CompletionContext,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<InputState>,
|
||||
) -> Task<Result<CompletionResponse>> {
|
||||
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::<lsp_types::request::Completion>(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<InputState>,
|
||||
) -> 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<Result<Option<lsp_types::Hover>>> {
|
||||
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::<lsp_types::request::HoverRequest>(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<Result<Vec<LocationLink>>> {
|
||||
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<Output = Result<Vec<LocationLink>>> + 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::<lsp_types::request::GotoDefinition>(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<InputState>,
|
||||
range: Range<usize>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Vec<CodeAction>>> {
|
||||
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::<lsp_types::request::CodeActionRequest>(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<InputState>,
|
||||
action: CodeAction,
|
||||
_push_to_history: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<()>> {
|
||||
// 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::<lsp_types::request::CodeActionResolveRequest>(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::request::ExecuteCommand>(
|
||||
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![])));
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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, ResponseError>) -> 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<Value, ResponseError>,
|
||||
},
|
||||
/// 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<Incoming> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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<Vec<u8>> {
|
||||
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::<usize>().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<usize> {
|
||||
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::<Value>(&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::<Value>(&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);
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<OneOf<TextEdit, AnnotatedTextEdit>>| 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<Box<dyn std::future::Future<Output = Option<Value>>>>| {
|
||||
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::notification::DidOpenTextDocument>(
|
||||
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::request::HoverRequest>(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`");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user