mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
feat(lsp): outline, references, workspace symbols, signature help
- documentSymbol feeds the editor's outline (breadcrumbs, Go to Symbol)
through editor_set_document_symbols, refreshed 500 ms after typing
pauses. Flat answers are nested by range; an empty answer from a server
still indexing leaves the tree's outline in place.
- Find All References (shift-F12) and a definition with several answers
open a Locations tab in the search. Arrowing through it previews a place
in the file in front, and Return goes there, into any file.
- Go to Symbol in Workspace (secondary-T inside the editor) asks the
server's workspace/symbol as the query is typed, into the same list.
- Completion items are resolved for their documentation and auto-import
edits, which are applied on accept (with the gpui-component fork).
- Signature help opens on the server's trigger characters (or on '(' and
','), stays current while typing in the call, and closes when the server
says the cursor has left it or on Escape.
- Diagnostics are replaced wholesale on publish; between publishes the
fork now carries them through edits instead of dropping them.
- workspace/configuration answers from per-server settings, with an empty
object for a section tty7 sets nothing for. rust-analyzer gets
checkOnSave with cargo check, also as initializationOptions, and every
server gets didChangeConfiguration after initialized.
This commit is contained in:
@@ -135,6 +135,8 @@ actions!(
|
||||
EditorQuickFix,
|
||||
EditorRenameSymbol,
|
||||
EditorFormatDocument,
|
||||
EditorFindReferences,
|
||||
EditorWorkspaceSymbol,
|
||||
EditorNextChange,
|
||||
EditorPrevChange,
|
||||
EditorRevertChange,
|
||||
|
||||
+24
-1
@@ -6132,7 +6132,10 @@ impl Tty7App {
|
||||
self.theme_preview_restore = None;
|
||||
}
|
||||
// Likewise the symbol Go to Symbol has the caret on.
|
||||
if matches!(kind, CommandKind::GoToSymbol { .. }) {
|
||||
if matches!(
|
||||
kind,
|
||||
CommandKind::GoToSymbol { .. } | CommandKind::GoToLocation { .. }
|
||||
) {
|
||||
self.editor_symbol_preview_commit();
|
||||
}
|
||||
self.close_search(window, cx);
|
||||
@@ -6150,6 +6153,9 @@ impl Tty7App {
|
||||
SearchEvent::PreviewSymbol { line, column } => {
|
||||
self.editor_symbol_preview(*line, *column, cx)
|
||||
}
|
||||
SearchEvent::PreviewLocation { path, line, column } => {
|
||||
self.lsp_preview_location(path, *line, *column, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6356,6 +6362,9 @@ impl Tty7App {
|
||||
self.open_indexed_file(&path, line, column, window, cx)
|
||||
}
|
||||
GoToSymbol { line, column } => self.editor_go_to_position(line, column, window, cx),
|
||||
GoToLocation { path, line, column } => {
|
||||
self.open_file_in_editor_at(&path, Some(line + 1), Some(column + 1), window, cx)
|
||||
}
|
||||
GoToTab { workspace, tab } => self.go_to_tab(workspace, tab, false, window, cx),
|
||||
ResumeSession {
|
||||
agent,
|
||||
@@ -9401,6 +9410,20 @@ impl Render for Tty7App {
|
||||
}
|
||||
this.lsp_format_document(window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &EditorFindReferences, window, cx| {
|
||||
if !this.editor_has_focus(window, cx) {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
this.lsp_find_references(window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &EditorWorkspaceSymbol, window, cx| {
|
||||
if !this.editor_has_focus(window, cx) {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
this.lsp_workspace_symbols(window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &EditorNewFile, window, cx| {
|
||||
this.editor_new_file(window, cx)
|
||||
}))
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, Context, Window, div, px};
|
||||
use gpui::{AnyElement, App, Context, Window, div, px};
|
||||
use gpui_component::input::Position;
|
||||
use gpui_component::{ActiveTheme as _, h_flex};
|
||||
use tty7_core::core::machine::TabId;
|
||||
@@ -360,7 +360,6 @@ impl Tty7App {
|
||||
/// language server's `textDocument/documentSymbol`, flattened with
|
||||
/// [`Outline::from_nodes`]. `None` goes back to the tree's. The caller
|
||||
/// keeps them current: they are shown as given until replaced.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn editor_set_document_symbols(
|
||||
&mut self,
|
||||
buffer: BufferId,
|
||||
@@ -496,6 +495,29 @@ impl Tty7App {
|
||||
});
|
||||
}
|
||||
|
||||
/// Begins a preview in the file in front for a picker someone else
|
||||
/// filled — Find References (`ui::lsp`) — so arrowing through it moves
|
||||
/// the caret and closing it puts the caret back, as Go to Symbol does.
|
||||
pub(crate) fn editor_begin_preview(&mut self, cx: &App) {
|
||||
let Some(f) = self.active_buffer() else {
|
||||
return;
|
||||
};
|
||||
let (origin, scroll) = {
|
||||
let state = f.input.read(cx);
|
||||
(state.cursor_position(), state.scroll_offset())
|
||||
};
|
||||
self.editor.nav.preview = Some(SymbolPreview {
|
||||
buffer: f.id(),
|
||||
origin,
|
||||
scroll,
|
||||
});
|
||||
}
|
||||
|
||||
/// The buffer a preview is running in, if one is.
|
||||
pub(crate) fn editor_preview_buffer(&self) -> Option<BufferId> {
|
||||
self.editor.nav.preview.as_ref().map(|p| p.buffer)
|
||||
}
|
||||
|
||||
/// The picker's choice is final: the caret stays where it took it.
|
||||
pub(crate) fn editor_symbol_preview_commit(&mut self) {
|
||||
self.editor.nav.preview = None;
|
||||
|
||||
@@ -1249,6 +1249,12 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::LspServerStarting => "{name} starting…",
|
||||
L10nKey::LspServerDown => "{name} stopped",
|
||||
L10nKey::LspProblemsTooltip => "{errors} errors, {warnings} warnings",
|
||||
L10nKey::SearchTabLocations => "Locations",
|
||||
L10nKey::SearchPlaceholderLocations => "Filter the places found…",
|
||||
L10nKey::SearchLocationsNone => "Nothing found",
|
||||
L10nKey::LspFindReferences => "Find All References",
|
||||
L10nKey::LspWorkspaceSymbol => "Go to Symbol in Workspace…",
|
||||
L10nKey::SearchPlaceholderWorkspaceSymbols => "Type a symbol name to search the project…",
|
||||
L10nKey::PanelInfoTitle => "Info",
|
||||
L10nKey::PanelChangesTitle => "Changes",
|
||||
L10nKey::PanelScmTitle => "Changes",
|
||||
|
||||
@@ -1297,6 +1297,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::LspServerStarting => "{name} 起動中…",
|
||||
L10nKey::LspServerDown => "{name} 停止",
|
||||
L10nKey::LspProblemsTooltip => "エラー {errors} 件、警告 {warnings} 件",
|
||||
L10nKey::SearchTabLocations => "場所",
|
||||
L10nKey::SearchPlaceholderLocations => "見つかった場所を絞り込む…",
|
||||
L10nKey::SearchLocationsNone => "見つかりませんでした",
|
||||
L10nKey::LspFindReferences => "すべての参照を検索",
|
||||
L10nKey::LspWorkspaceSymbol => "ワークスペース内のシンボルへ移動…",
|
||||
L10nKey::SearchPlaceholderWorkspaceSymbols => "シンボル名を入力してプロジェクトを検索…",
|
||||
L10nKey::PanelInfoTitle => "情報",
|
||||
L10nKey::PanelChangesTitle => "変更",
|
||||
L10nKey::PanelScmTitle => "変更",
|
||||
|
||||
@@ -979,6 +979,12 @@ l10n_keys! {
|
||||
LspServerStarting,
|
||||
LspServerDown,
|
||||
LspProblemsTooltip,
|
||||
SearchTabLocations,
|
||||
SearchPlaceholderLocations,
|
||||
SearchLocationsNone,
|
||||
LspFindReferences,
|
||||
LspWorkspaceSymbol,
|
||||
SearchPlaceholderWorkspaceSymbols,
|
||||
PanelInfoTitle,
|
||||
PanelChangesTitle,
|
||||
PanelScmTitle,
|
||||
|
||||
@@ -1167,6 +1167,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::LspServerStarting => "{name} 启动中…",
|
||||
L10nKey::LspServerDown => "{name} 已停止",
|
||||
L10nKey::LspProblemsTooltip => "{errors} 个错误,{warnings} 个警告",
|
||||
L10nKey::SearchTabLocations => "位置",
|
||||
L10nKey::SearchPlaceholderLocations => "筛选找到的位置…",
|
||||
L10nKey::SearchLocationsNone => "未找到任何位置",
|
||||
L10nKey::LspFindReferences => "查找所有引用",
|
||||
L10nKey::LspWorkspaceSymbol => "转到工作区中的符号…",
|
||||
L10nKey::SearchPlaceholderWorkspaceSymbols => "输入符号名称以搜索整个项目…",
|
||||
L10nKey::PanelInfoTitle => "信息",
|
||||
L10nKey::PanelChangesTitle => "更改",
|
||||
L10nKey::PanelScmTitle => "更改",
|
||||
|
||||
@@ -101,6 +101,10 @@ fn fixed_bindings() -> Vec<KeyBinding> {
|
||||
bindings.push(KeyBinding::new("f12", EditorGoToDefinition, input));
|
||||
bindings.push(KeyBinding::new("f2", EditorRenameSymbol, input));
|
||||
bindings.push(KeyBinding::new("shift-alt-f", EditorFormatDocument, input));
|
||||
bindings.push(KeyBinding::new("shift-f12", EditorFindReferences, input));
|
||||
// ⌘T / Ctrl+T is a new tab everywhere else in tty7; inside the editor it
|
||||
// is VS Code's Go to Symbol in Workspace.
|
||||
bindings.push(KeyBinding::new("secondary-t", EditorWorkspaceSymbol, input));
|
||||
// The gutter's change markers, on VS Code's chords. Only in an `Input`:
|
||||
// a function key bound for the whole window would never reach the shell,
|
||||
// and the handlers let the key through when the code editor is not the
|
||||
@@ -680,6 +684,8 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
("EditorQuickFix", ""),
|
||||
("EditorRenameSymbol", ""),
|
||||
("EditorFormatDocument", ""),
|
||||
("EditorFindReferences", ""),
|
||||
("EditorWorkspaceSymbol", ""),
|
||||
// Alt+F5 / Shift+Alt+F5 (VS Code's) are fixed `Input`-context
|
||||
// bindings: a default on a function key would hide it from the shell.
|
||||
("EditorNextChange", ""),
|
||||
@@ -985,6 +991,14 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
|
||||
CommandGroup::Terminal,
|
||||
t(L10nKey::LspFormatDocument).to_string(),
|
||||
),
|
||||
"EditorFindReferences" => (
|
||||
CommandGroup::Terminal,
|
||||
t(L10nKey::LspFindReferences).to_string(),
|
||||
),
|
||||
"EditorWorkspaceSymbol" => (
|
||||
CommandGroup::Terminal,
|
||||
t(L10nKey::LspWorkspaceSymbol).to_string(),
|
||||
),
|
||||
"EditorGoToSymbol" => (
|
||||
CommandGroup::Terminal,
|
||||
t(L10nKey::EditorGoToSymbolAction).to_string(),
|
||||
@@ -1730,6 +1744,8 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||||
"EditorQuickFix" => KeyBinding::new(keystroke, EditorQuickFix, None),
|
||||
"EditorRenameSymbol" => KeyBinding::new(keystroke, EditorRenameSymbol, None),
|
||||
"EditorFormatDocument" => KeyBinding::new(keystroke, EditorFormatDocument, None),
|
||||
"EditorFindReferences" => KeyBinding::new(keystroke, EditorFindReferences, None),
|
||||
"EditorWorkspaceSymbol" => KeyBinding::new(keystroke, EditorWorkspaceSymbol, None),
|
||||
"EditorNextChange" => KeyBinding::new(keystroke, EditorNextChange, None),
|
||||
"EditorPrevChange" => KeyBinding::new(keystroke, EditorPrevChange, None),
|
||||
"EditorRevertChange" => KeyBinding::new(keystroke, EditorRevertChange, None),
|
||||
|
||||
+25
-16
@@ -56,6 +56,7 @@ impl Tty7App {
|
||||
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);
|
||||
self.lsp_signature_help_after_edit(&f, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ impl Tty7App {
|
||||
};
|
||||
let state = f.input.read(cx);
|
||||
let request = super::providers::definition_request(&doc, state.text(), state.cursor());
|
||||
let encoding = doc.encoding;
|
||||
let (encoding, root) = (doc.encoding, doc.root.clone());
|
||||
cx.spawn_in(window, async move |app, cx| {
|
||||
let links = match request.await {
|
||||
Ok(links) => links,
|
||||
@@ -152,20 +153,23 @@ impl Tty7App {
|
||||
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,
|
||||
)
|
||||
let places: Vec<_> = links
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
Some((
|
||||
super::uri_to_path(&l.target_uri)?,
|
||||
l.target_selection_range.start,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let _ = app.update_in(cx, |app, window, cx| match places.as_slice() {
|
||||
[] => {}
|
||||
// One answer is followed straight away, through the same path
|
||||
// a secondary-click takes.
|
||||
[(path, position)] => {
|
||||
app.lsp_open_location(path.clone(), *position, encoding, window, cx)
|
||||
}
|
||||
_ => app.lsp_show_places(places, encoding, root, window, cx),
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
@@ -314,7 +318,8 @@ impl Tty7App {
|
||||
cx: &gpui::App,
|
||||
) -> PopupMenu {
|
||||
use crate::core::actions::{
|
||||
EditorFormatDocument, EditorGoToDefinition, EditorQuickFix, EditorRenameSymbol,
|
||||
EditorFindReferences, EditorFormatDocument, EditorGoToDefinition, EditorQuickFix,
|
||||
EditorRenameSymbol,
|
||||
};
|
||||
let Some(f) = self.editor_local_file(id) else {
|
||||
return menu;
|
||||
@@ -330,6 +335,10 @@ impl Tty7App {
|
||||
t(L10nKey::LspGoToDefinition),
|
||||
Box::new(EditorGoToDefinition),
|
||||
)
|
||||
.menu(
|
||||
t(L10nKey::LspFindReferences),
|
||||
Box::new(EditorFindReferences),
|
||||
)
|
||||
.menu(t(L10nKey::LspQuickFix), Box::new(EditorQuickFix))
|
||||
.menu(
|
||||
t(L10nKey::LspRenameSymbolAction),
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
//! Places a language server points at — every reference to a symbol, or a
|
||||
//! definition with more than one answer — listed in the search's Locations
|
||||
//! tab: arrowing through it previews a place in the file in front, Return
|
||||
//! goes there.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gpui::{App, AppContext as _, Context, Window};
|
||||
use gpui_component::{Rope, RopeExt as _};
|
||||
use lsp_types::Position;
|
||||
|
||||
use super::convert::{self, Encoding};
|
||||
use super::{Freshen, LspStore};
|
||||
use crate::ui::app::Tty7App;
|
||||
use crate::ui::search::{CommandKind, Item, SearchTab};
|
||||
|
||||
/// One place, in the editor's terms: 0-based, the column counted in chars,
|
||||
/// with the line it is on for the row to show.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Found {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) line: u32,
|
||||
pub(crate) column: u32,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
/// The longest line text a row shows.
|
||||
const LINE_PREVIEW: usize = 160;
|
||||
|
||||
/// Converts server positions to [`Found`]s. Files open in the editor are
|
||||
/// measured against their buffer (`open`), the rest against the disk — so
|
||||
/// this runs off the main thread. Sorted by file, then position, duplicates
|
||||
/// dropped.
|
||||
pub(crate) fn resolve(
|
||||
places: Vec<(PathBuf, Position)>,
|
||||
open: HashMap<PathBuf, Rope>,
|
||||
enc: Encoding,
|
||||
) -> Vec<Found> {
|
||||
let mut found: Vec<Found> = measure(places, &open, enc).into_iter().flatten().collect();
|
||||
found.sort_by(|a, b| (&a.path, a.line, a.column).cmp(&(&b.path, b.line, b.column)));
|
||||
found.dedup();
|
||||
found
|
||||
}
|
||||
|
||||
/// [`resolve`] without the sorting: one answer per place, in order, `None`
|
||||
/// for a place past the end of its file.
|
||||
pub(crate) fn measure(
|
||||
places: Vec<(PathBuf, Position)>,
|
||||
open: &HashMap<PathBuf, Rope>,
|
||||
enc: Encoding,
|
||||
) -> Vec<Option<Found>> {
|
||||
let mut read: HashMap<PathBuf, Option<Vec<String>>> = HashMap::new();
|
||||
places
|
||||
.into_iter()
|
||||
.map(|(path, pos)| {
|
||||
let line = match open.get(&path) {
|
||||
Some(rope) if (pos.line as usize) < rope.lines_len() => {
|
||||
rope.slice_line(pos.line as usize).to_string()
|
||||
}
|
||||
Some(_) => return None,
|
||||
None => read
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| {
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|t| t.lines().map(str::to_owned).collect())
|
||||
})
|
||||
.as_ref()
|
||||
.and_then(|lines| lines.get(pos.line as usize).cloned())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let byte = convert::byte_of_column(&line, pos.character, enc);
|
||||
let column = line[..byte].chars().count() as u32;
|
||||
let text: String = line.trim().chars().take(LINE_PREVIEW).collect();
|
||||
Some(Found {
|
||||
path,
|
||||
line: pos.line,
|
||||
column,
|
||||
text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A search row for `found`, its path shown relative to `root`.
|
||||
pub(crate) fn item(found: &Found, root: &Path) -> Item {
|
||||
let shown = found.path.strip_prefix(root).unwrap_or(&found.path);
|
||||
Item::new(
|
||||
format!("{}:{}", shown.display(), found.line + 1),
|
||||
CommandKind::GoToLocation {
|
||||
path: found.path.clone(),
|
||||
line: found.line,
|
||||
column: found.column,
|
||||
},
|
||||
)
|
||||
.with_subtitle(found.text.clone())
|
||||
.with_alias(found.path.display().to_string())
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
/// The open local buffers' texts, for measuring places in them.
|
||||
fn lsp_open_texts(&self, cx: &App) -> HashMap<PathBuf, Rope> {
|
||||
self.editor_local_files()
|
||||
.into_iter()
|
||||
.map(|f| (f.path, f.input.read(cx).text().clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Shows what a server found: one place is gone to at once, several
|
||||
/// are listed to pick from.
|
||||
pub(crate) fn lsp_show_places(
|
||||
&mut self,
|
||||
places: Vec<(PathBuf, Position)>,
|
||||
encoding: Encoding,
|
||||
root: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if places.is_empty() {
|
||||
return;
|
||||
}
|
||||
let open = self.lsp_open_texts(cx);
|
||||
cx.spawn_in(window, async move |app, cx| {
|
||||
let found = cx
|
||||
.background_spawn(async move { resolve(places, open, encoding) })
|
||||
.await;
|
||||
let _ = app.update_in(cx, |app, window, cx| match found.as_slice() {
|
||||
[] => {}
|
||||
[one] => app.open_file_in_editor_at(
|
||||
&one.path,
|
||||
Some(one.line + 1),
|
||||
Some(one.column + 1),
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
many => {
|
||||
let items = many.iter().map(|f| item(f, &root)).collect();
|
||||
app.open_search(SearchTab::Locations, "", window, cx);
|
||||
app.editor_begin_preview(cx);
|
||||
if let Some(view) = app.search.clone() {
|
||||
view.update(cx, |view, cx| view.set_locations(items, window, cx));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// ⇧F12: every reference to the symbol under the cursor.
|
||||
pub(crate) fn lsp_find_references(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(f) = self.editor_active_local_file() else {
|
||||
return;
|
||||
};
|
||||
let (text, offset) = {
|
||||
let state = f.input.read(cx);
|
||||
(state.text().clone(), state.cursor())
|
||||
};
|
||||
let Some(doc) = LspStore::context(
|
||||
&f.path,
|
||||
Freshen::Text(&text),
|
||||
Some(window.window_handle()),
|
||||
cx,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if doc.caps.references_provider.is_none() {
|
||||
return;
|
||||
}
|
||||
let request =
|
||||
doc.client
|
||||
.request::<lsp_types::request::References>(lsp_types::ReferenceParams {
|
||||
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: lsp_types::ReferenceContext {
|
||||
include_declaration: true,
|
||||
},
|
||||
});
|
||||
let (encoding, root) = (doc.encoding, doc.root.clone());
|
||||
cx.spawn_in(window, async move |app, cx| {
|
||||
let locations = match request.await {
|
||||
Ok(locations) => locations.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
log::debug!("lsp: references: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let places = locations
|
||||
.into_iter()
|
||||
.filter_map(|l| Some((super::uri_to_path(&l.uri)?, l.range.start)))
|
||||
.collect();
|
||||
let _ = app.update_in(cx, |app, window, cx| {
|
||||
app.lsp_show_places(places, encoding, root, window, cx)
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// ⌘T: symbols anywhere in the project, asked of the front file's
|
||||
/// server as the query is typed.
|
||||
pub(crate) fn lsp_workspace_symbols(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(f) = self.editor_active_local_file() else {
|
||||
return;
|
||||
};
|
||||
let Some(doc) = LspStore::context(&f.path, Freshen::Skip, None, cx) else {
|
||||
return;
|
||||
};
|
||||
if doc.caps.workspace_symbol_provider.is_none() {
|
||||
return;
|
||||
}
|
||||
let app = cx.entity().downgrade();
|
||||
let handle = window.window_handle();
|
||||
let path = f.path.clone();
|
||||
let live: crate::ui::search::LiveQuery =
|
||||
std::rc::Rc::new(move |query: &str, cx: &mut App| {
|
||||
let (app, path, query) = (app.clone(), path.clone(), query.to_owned());
|
||||
// Deferred: the query arrives from inside the search's own
|
||||
// update, and answering it updates the search.
|
||||
cx.defer(move |cx| {
|
||||
let _ = handle.update(cx, |_, window, cx| {
|
||||
let _ = app.update(cx, |app, cx| {
|
||||
app.lsp_workspace_query(&path, query, window, cx)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
self.open_search(SearchTab::WorkspaceSymbols, "", window, cx);
|
||||
self.editor_begin_preview(cx);
|
||||
if let Some(view) = self.search.clone() {
|
||||
view.update(cx, |view, cx| view.set_live_query(live, window, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn lsp_workspace_query(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
// The latest query wins: an answer to an older one is dropped.
|
||||
static LATEST: AtomicU64 = AtomicU64::new(0);
|
||||
let seq = LATEST.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if query.trim().is_empty() {
|
||||
if let Some(view) = self.search.clone() {
|
||||
view.update(cx, |view, cx| view.set_locations(Vec::new(), window, cx));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(doc) = LspStore::context(path, Freshen::Skip, None, cx) else {
|
||||
return;
|
||||
};
|
||||
let client = doc.client.clone();
|
||||
let (encoding, root) = (doc.encoding, doc.root.clone());
|
||||
let open = self.lsp_open_texts(cx);
|
||||
cx.spawn_in(window, async move |app, cx| {
|
||||
// Typing fast asks once, for what was typed last.
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_millis(150))
|
||||
.await;
|
||||
if LATEST.load(Ordering::Relaxed) != seq {
|
||||
return;
|
||||
}
|
||||
let answer = client
|
||||
.request::<lsp_types::request::WorkspaceSymbolRequest>(
|
||||
lsp_types::WorkspaceSymbolParams {
|
||||
query,
|
||||
work_done_progress_params: Default::default(),
|
||||
partial_result_params: Default::default(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let symbols = match answer {
|
||||
Ok(Some(symbols)) => workspace_symbols(symbols),
|
||||
Ok(None) => Vec::new(),
|
||||
Err(e) => {
|
||||
log::debug!("lsp: workspace/symbol: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let items = cx
|
||||
.background_spawn(async move {
|
||||
let places = symbols
|
||||
.iter()
|
||||
.map(|s| (s.path.clone(), s.position))
|
||||
.collect();
|
||||
measure(places, &open, encoding)
|
||||
.into_iter()
|
||||
.zip(symbols)
|
||||
.filter_map(|(found, symbol)| Some(symbol_item(&found?, &symbol, &root)))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await;
|
||||
if LATEST.load(Ordering::Relaxed) != seq {
|
||||
return;
|
||||
}
|
||||
let _ = app.update_in(cx, |app, window, cx| {
|
||||
if let Some(view) = app.search.clone()
|
||||
&& view.read(cx).tab() == SearchTab::WorkspaceSymbols
|
||||
{
|
||||
view.update(cx, |view, cx| view.set_locations(items, window, cx));
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The Locations list moved onto a place: shown at once when it is in
|
||||
/// the file in front, where the preview runs.
|
||||
pub(crate) fn lsp_preview_location(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
line: u32,
|
||||
column: u32,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let in_front = self
|
||||
.editor_preview_buffer()
|
||||
.and_then(|id| self.editor_local_file(id))
|
||||
.is_some_and(|f| f.path == path);
|
||||
if in_front {
|
||||
self.editor_symbol_preview(line, column, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most workspace symbols listed for one query.
|
||||
const MAX_WORKSPACE_SYMBOLS: usize = 200;
|
||||
|
||||
/// One workspace symbol, before its column is measured.
|
||||
pub(crate) struct WorkspaceHit {
|
||||
pub(crate) name: String,
|
||||
pub(crate) kind: lsp_types::SymbolKind,
|
||||
pub(crate) container: Option<String>,
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) position: Position,
|
||||
}
|
||||
|
||||
/// A `workspace/symbol` answer in either of its shapes. A symbol known only
|
||||
/// by its file (no range yet) lands on the file's first line.
|
||||
#[allow(deprecated)]
|
||||
pub(crate) fn workspace_symbols(answer: lsp_types::WorkspaceSymbolResponse) -> Vec<WorkspaceHit> {
|
||||
use lsp_types::{OneOf, WorkspaceSymbolResponse};
|
||||
let hits: Vec<WorkspaceHit> = match answer {
|
||||
WorkspaceSymbolResponse::Flat(symbols) => symbols
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
Some(WorkspaceHit {
|
||||
path: super::uri_to_path(&s.location.uri)?,
|
||||
position: s.location.range.start,
|
||||
name: s.name,
|
||||
kind: s.kind,
|
||||
container: s.container_name,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
WorkspaceSymbolResponse::Nested(symbols) => symbols
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
let (uri, position) = match s.location {
|
||||
OneOf::Left(l) => (l.uri, l.range.start),
|
||||
OneOf::Right(l) => (l.uri, Position::default()),
|
||||
};
|
||||
Some(WorkspaceHit {
|
||||
path: super::uri_to_path(&uri)?,
|
||||
position,
|
||||
name: s.name,
|
||||
kind: s.kind,
|
||||
container: s.container_name,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
hits.into_iter().take(MAX_WORKSPACE_SYMBOLS).collect()
|
||||
}
|
||||
|
||||
fn symbol_item(found: &Found, symbol: &WorkspaceHit, root: &Path) -> Item {
|
||||
let kind = serde_json::to_value(symbol.kind)
|
||||
.ok()
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let shown = found.path.strip_prefix(root).unwrap_or(&found.path);
|
||||
let place = format!("{}:{}", shown.display(), found.line + 1);
|
||||
let subtitle = match symbol.container.as_deref().filter(|c| !c.is_empty()) {
|
||||
Some(container) => format!("{container} · {place}"),
|
||||
None => place,
|
||||
};
|
||||
Item::new(
|
||||
symbol.name.clone(),
|
||||
CommandKind::GoToLocation {
|
||||
path: found.path.clone(),
|
||||
line: found.line,
|
||||
column: found.column,
|
||||
},
|
||||
)
|
||||
.with_subtitle(subtitle)
|
||||
.with_note(crate::ui::code_editor::outline::SymbolKind::from_lsp(kind as u32).tag())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn places_are_measured_against_open_buffers_and_the_disk() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let on_disk = dir.path().join("b.rs");
|
||||
std::fs::write(&on_disk, "fn b() {\n let 🎉 = a();\n}\n").unwrap();
|
||||
let open_path = dir.path().join("a.rs");
|
||||
let open = HashMap::from([(open_path.clone(), Rope::from("pub fn é_a() {}\n"))]);
|
||||
let found = resolve(
|
||||
vec![
|
||||
// `a` after the emoji: UTF-16 column 13, char column 12.
|
||||
(on_disk.clone(), Position::new(1, 13)),
|
||||
(open_path.clone(), Position::new(0, 7)),
|
||||
(open_path.clone(), Position::new(0, 7)),
|
||||
(open_path.clone(), Position::new(9, 0)),
|
||||
],
|
||||
open,
|
||||
Encoding::Utf16,
|
||||
);
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
Found {
|
||||
path: open_path,
|
||||
line: 0,
|
||||
column: 7,
|
||||
text: "pub fn é_a() {}".into(),
|
||||
},
|
||||
Found {
|
||||
path: on_disk,
|
||||
line: 1,
|
||||
column: 12,
|
||||
text: "let 🎉 = a();".into(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn workspace_symbols_become_rows_naming_where_they_live() {
|
||||
let uri = super::super::path_to_uri(Path::new("/p/src/net.rs")).unwrap();
|
||||
let hits = workspace_symbols(lsp_types::WorkspaceSymbolResponse::Flat(vec![
|
||||
lsp_types::SymbolInformation {
|
||||
name: "Server".into(),
|
||||
kind: lsp_types::SymbolKind::STRUCT,
|
||||
tags: None,
|
||||
deprecated: None,
|
||||
location: lsp_types::Location::new(
|
||||
uri,
|
||||
lsp_types::Range::new(Position::new(3, 11), Position::new(3, 17)),
|
||||
),
|
||||
container_name: Some("net".into()),
|
||||
},
|
||||
]));
|
||||
assert_eq!(hits.len(), 1);
|
||||
let found = Found {
|
||||
path: hits[0].path.clone(),
|
||||
line: 3,
|
||||
column: 11,
|
||||
text: String::new(),
|
||||
};
|
||||
let row = symbol_item(&found, &hits[0], Path::new("/p"));
|
||||
assert_eq!(row.title, "Server");
|
||||
assert_eq!(row.subtitle.as_deref(), Some("net · src/net.rs:4"));
|
||||
assert_eq!(row.note.as_deref(), Some("struct"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_names_its_file_relative_to_the_project() {
|
||||
let found = Found {
|
||||
path: PathBuf::from("/p/src/lib.rs"),
|
||||
line: 4,
|
||||
column: 2,
|
||||
text: "x".into(),
|
||||
};
|
||||
let row = item(&found, Path::new("/p"));
|
||||
assert_eq!(row.title, "src/lib.rs:5");
|
||||
assert_eq!(row.subtitle.as_deref(), Some("x"));
|
||||
}
|
||||
}
|
||||
+143
-15
@@ -23,9 +23,12 @@
|
||||
pub(crate) mod client;
|
||||
pub(crate) mod convert;
|
||||
mod editor;
|
||||
mod locations;
|
||||
mod providers;
|
||||
pub(crate) mod rpc;
|
||||
pub(crate) mod servers;
|
||||
mod signature;
|
||||
mod symbols;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -54,6 +57,10 @@ pub(crate) use self::editor::LspBuffer;
|
||||
/// decides how soon diagnostics catch up after a pause.
|
||||
const CHANGE_DEBOUNCE: Duration = Duration::from_millis(150);
|
||||
|
||||
/// How long typing must pause before the outline is asked for again. Longer
|
||||
/// than [`CHANGE_DEBOUNCE`]: nobody reads the breadcrumbs mid-word.
|
||||
const SYMBOLS_DEBOUNCE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long a server outlives its last open file. Closing a file and opening
|
||||
/// its neighbour is common; restarting rust-analyzer for it is not cheap.
|
||||
const IDLE_SHUTDOWN: Duration = Duration::from_secs(60);
|
||||
@@ -105,6 +112,7 @@ struct Doc {
|
||||
owner_id: EntityId,
|
||||
/// The window-level editor (`Tty7App`) that holds the owning buffer.
|
||||
app_id: EntityId,
|
||||
app: WeakEntity<crate::ui::app::Tty7App>,
|
||||
/// Where the owning buffer is drawn, once known — edits the server asks
|
||||
/// for are applied through it.
|
||||
window: Option<AnyWindowHandle>,
|
||||
@@ -113,6 +121,7 @@ struct Doc {
|
||||
/// An edit the server has not heard about yet.
|
||||
dirty: bool,
|
||||
change_seq: u64,
|
||||
symbols_seq: u64,
|
||||
diagnostics: Vec<lsp_types::Diagnostic>,
|
||||
problems: (usize, usize),
|
||||
}
|
||||
@@ -151,6 +160,8 @@ pub(crate) struct DocContext {
|
||||
pub(crate) caps: Rc<ServerCapabilities>,
|
||||
/// The server's raw diagnostics for this document, in its own columns.
|
||||
pub(crate) diagnostics: Vec<lsp_types::Diagnostic>,
|
||||
/// The project the server was started on.
|
||||
pub(crate) root: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -253,15 +264,23 @@ impl LspStore {
|
||||
doc.change_seq += 1;
|
||||
let seq = doc.change_seq;
|
||||
let path = path.to_path_buf();
|
||||
let current = move |cx: &App, path: &Path| {
|
||||
cx.try_global::<LspStore>()
|
||||
.and_then(|s| s.docs.get(path))
|
||||
.is_some_and(|d| d.change_seq == seq)
|
||||
};
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(CHANGE_DEBOUNCE).await;
|
||||
if !cx.update(|cx| current(cx, &path)) {
|
||||
return;
|
||||
}
|
||||
cx.update(|cx| Self::update(cx, |store, cx| store.flush_from_owner(&path, cx)));
|
||||
cx.background_executor()
|
||||
.timer(SYMBOLS_DEBOUNCE.saturating_sub(CHANGE_DEBOUNCE))
|
||||
.await;
|
||||
cx.update(|cx| {
|
||||
let current = cx
|
||||
.try_global::<LspStore>()
|
||||
.and_then(|s| s.docs.get(&path))
|
||||
.is_some_and(|d| d.change_seq == seq && d.dirty);
|
||||
if current {
|
||||
Self::update(cx, |store, cx| store.flush_from_owner(&path, cx));
|
||||
if current(cx, &path) {
|
||||
Self::update(cx, |store, cx| store.refresh_symbols(&path, cx));
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -356,6 +375,7 @@ impl LspStore {
|
||||
encoding: server.encoding,
|
||||
caps: server.caps.clone(),
|
||||
diagnostics: doc.diagnostics.clone(),
|
||||
root: doc.server.root.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -405,10 +425,12 @@ impl LspStore {
|
||||
owner: buffer.input.downgrade(),
|
||||
owner_id: buffer.input.entity_id(),
|
||||
app_id,
|
||||
app: buffer.app.clone(),
|
||||
window: None,
|
||||
sent: Some(text.clone()),
|
||||
dirty: false,
|
||||
change_seq: 0,
|
||||
symbols_seq: 0,
|
||||
diagnostics: Vec::new(),
|
||||
problems: (0, 0),
|
||||
};
|
||||
@@ -421,6 +443,7 @@ impl LspStore {
|
||||
}
|
||||
self.docs.insert(buffer.path.clone(), doc);
|
||||
providers::install(&buffer.input, &buffer.path, buffer.app.clone(), cx);
|
||||
self.refresh_symbols(&buffer.path, cx);
|
||||
}
|
||||
|
||||
fn close_doc(&mut self, path: &Path, cx: &mut App) {
|
||||
@@ -431,6 +454,14 @@ impl LspStore {
|
||||
providers::uninstall(&input, cx);
|
||||
clear_diagnostics(&input, cx);
|
||||
}
|
||||
// Deferred: this runs while the editor that holds the buffer is
|
||||
// itself being updated (it is what asked for the close).
|
||||
let (app, buffer) = (doc.app.clone(), doc.owner_id);
|
||||
cx.defer(move |cx| {
|
||||
let _ = app.update(cx, |app, cx| {
|
||||
app.editor_set_document_symbols(buffer, None, cx)
|
||||
});
|
||||
});
|
||||
let key = doc.server.clone();
|
||||
let Some(server) = self.servers.get_mut(&key) else {
|
||||
return;
|
||||
@@ -530,6 +561,67 @@ impl LspStore {
|
||||
);
|
||||
}
|
||||
|
||||
/// Asks the server for the document's outline, and hands it to the
|
||||
/// editor if the text has not moved on by the time it answers.
|
||||
fn refresh_symbols(&mut self, path: &Path, cx: &mut App) {
|
||||
let Some(doc) = self.docs.get_mut(path) else {
|
||||
return;
|
||||
};
|
||||
let Some(server) = self.servers.get(&doc.server) else {
|
||||
return;
|
||||
};
|
||||
let supported = matches!(
|
||||
&server.caps.document_symbol_provider,
|
||||
Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_))
|
||||
);
|
||||
let (Phase::Running, true, Some(client), Some(text)) = (
|
||||
server.phase,
|
||||
supported,
|
||||
server.client.clone(),
|
||||
doc.sent.clone(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
doc.symbols_seq += 1;
|
||||
let (seq, version) = (doc.symbols_seq, doc.version);
|
||||
let (app, buffer) = (doc.app.clone(), doc.owner_id);
|
||||
let encoding = server.encoding;
|
||||
let request = client.request::<lsp_types::request::DocumentSymbolRequest>(
|
||||
lsp_types::DocumentSymbolParams {
|
||||
text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
|
||||
work_done_progress_params: Default::default(),
|
||||
partial_result_params: Default::default(),
|
||||
},
|
||||
);
|
||||
let path = path.to_path_buf();
|
||||
cx.spawn(async move |cx| {
|
||||
let response = match request.await {
|
||||
Ok(Some(response)) => response,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
log::debug!("lsp: documentSymbol: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let outline = symbols::outline(response, &text, encoding);
|
||||
cx.update(|cx| {
|
||||
let current = cx
|
||||
.try_global::<LspStore>()
|
||||
.and_then(|s| s.docs.get(&path))
|
||||
.is_some_and(|d| d.symbols_seq == seq && d.version == version && !d.dirty);
|
||||
if current {
|
||||
// Nothing yet — a server still indexing answers with an
|
||||
// empty list — leaves the tree's outline in place.
|
||||
let outline = (!outline.is_empty()).then_some(outline);
|
||||
let _ = app.update(cx, |app, cx| {
|
||||
app.editor_set_document_symbols(buffer, outline, cx)
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
// ---- Servers ----
|
||||
|
||||
fn start_server(&mut self, key: ServerKey, spec: &'static ServerSpec, cx: &mut App) -> bool {
|
||||
@@ -567,7 +659,7 @@ impl LspStore {
|
||||
.map(|s| s.crashes.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let init = client.initialize(initialize_params(&key.root));
|
||||
let init = client.initialize(initialize_params(&key.root, spec));
|
||||
let init_task = {
|
||||
let key = key.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
@@ -641,8 +733,23 @@ impl LspStore {
|
||||
server.caps = Rc::new(init.capabilities);
|
||||
server.phase = Phase::Running;
|
||||
client.notify_raw("initialized", json!({}));
|
||||
// Servers that pull their settings (pyright) wait for this
|
||||
// before asking; the rest take them as sent.
|
||||
client.notify_raw(
|
||||
"workspace/didChangeConfiguration",
|
||||
json!({ "settings": server.spec.settings() }),
|
||||
);
|
||||
client.open_gate();
|
||||
log::info!("lsp: {} is ready", server.label);
|
||||
let paths: Vec<PathBuf> = self
|
||||
.docs
|
||||
.iter()
|
||||
.filter(|(_, d)| &d.server == key)
|
||||
.map(|(p, _)| p.clone())
|
||||
.collect();
|
||||
for path in paths {
|
||||
self.refresh_symbols(&path, cx);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("lsp: {} failed to initialize: {e:#}", server.label);
|
||||
@@ -685,11 +792,21 @@ impl LspStore {
|
||||
ServerEvent::Request { id, method, params } => {
|
||||
let reply = match method.as_str() {
|
||||
"workspace/configuration" => {
|
||||
let n = params
|
||||
let settings = server.spec.settings();
|
||||
let items = params
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map_or(0, Vec::len);
|
||||
Ok(Value::Array(vec![Value::Null; n]))
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let section = item.get("section").and_then(Value::as_str);
|
||||
servers::configuration_item(&settings, section)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(Value::Array(items))
|
||||
}
|
||||
"client/registerCapability"
|
||||
| "client/unregisterCapability"
|
||||
@@ -743,7 +860,8 @@ impl LspStore {
|
||||
doc.diagnostics = params.diagnostics;
|
||||
// Positions for a text the buffer no longer holds would underline the
|
||||
// wrong characters. The server publishes again once it has caught
|
||||
// up, so these are counted and kept for code actions, not drawn.
|
||||
// up, so these are counted and kept for code actions, not drawn; the
|
||||
// underlines already shown have been moving with the edits.
|
||||
let stale = doc.dirty || params.version.is_some_and(|v| v != doc.version);
|
||||
let Some(input) = doc.owner.upgrade() else {
|
||||
return;
|
||||
@@ -755,8 +873,7 @@ impl LspStore {
|
||||
if !stale && sent.as_ref() == Some(&text) {
|
||||
let mapped = convert::diagnostics_to_editor(&text, &diagnostics, encoding);
|
||||
if let Some(set) = state.diagnostics_mut() {
|
||||
set.reset(&text);
|
||||
set.extend(mapped);
|
||||
set.replace_all(&text, mapped);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
@@ -982,7 +1099,7 @@ fn folder_name(root: &Path) -> String {
|
||||
/// has no snippet engine — so servers send plain insert text where they can;
|
||||
/// the few that send snippets anyway are flattened by
|
||||
/// [`convert::snippet_to_plain`].
|
||||
fn initialize_params(root: &Path) -> Value {
|
||||
fn initialize_params(root: &Path, spec: &ServerSpec) -> Value {
|
||||
let uri = path_to_uri(root).map(|u| u.as_str().to_owned());
|
||||
json!({
|
||||
"processId": std::process::id(),
|
||||
@@ -990,7 +1107,7 @@ fn initialize_params(root: &Path) -> Value {
|
||||
"rootUri": uri,
|
||||
"rootPath": root.display().to_string(),
|
||||
"workspaceFolders": [{ "uri": uri, "name": folder_name(root) }],
|
||||
"initializationOptions": null,
|
||||
"initializationOptions": spec.initialization_options(),
|
||||
"capabilities": {
|
||||
"general": { "positionEncodings": ["utf-16"] },
|
||||
"workspace": {
|
||||
@@ -1006,10 +1123,21 @@ fn initialize_params(root: &Path) -> Value {
|
||||
"snippetSupport": false,
|
||||
"documentationFormat": ["markdown", "plaintext"],
|
||||
"insertReplaceSupport": false,
|
||||
"resolveSupport": {
|
||||
"properties": ["documentation", "detail", "additionalTextEdits"],
|
||||
},
|
||||
},
|
||||
"contextSupport": true,
|
||||
},
|
||||
"hover": { "contentFormat": ["markdown", "plaintext"] },
|
||||
"signatureHelp": {
|
||||
"signatureInformation": {
|
||||
"documentationFormat": ["markdown", "plaintext"],
|
||||
"parameterInformation": { "labelOffsetSupport": true },
|
||||
"activeParameterSupport": true,
|
||||
},
|
||||
},
|
||||
"documentSymbol": { "hierarchicalDocumentSymbolSupport": true },
|
||||
"definition": { "linkSupport": true },
|
||||
"codeAction": {
|
||||
"codeActionLiteralSupport": {
|
||||
|
||||
@@ -135,6 +135,41 @@ impl CompletionProvider for LspProvider {
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_completion(
|
||||
&self,
|
||||
item: lsp_types::CompletionItem,
|
||||
text: &Rope,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<lsp_types::CompletionItem>> {
|
||||
// Resolving asks nothing of the text, which the caller may be in the
|
||||
// middle of changing: nothing is sent first.
|
||||
let Some(doc) = LspStore::context(&self.path, Freshen::Skip, None, cx) else {
|
||||
return Task::ready(Ok(item));
|
||||
};
|
||||
let resolvable = doc
|
||||
.caps
|
||||
.completion_provider
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.resolve_provider == Some(true));
|
||||
if !resolvable {
|
||||
return Task::ready(Ok(item));
|
||||
}
|
||||
let request = doc
|
||||
.client
|
||||
.request::<lsp_types::request::ResolveCompletionItem>(item.clone());
|
||||
let text = text.clone();
|
||||
let encoding = doc.encoding;
|
||||
cx.background_spawn(async move {
|
||||
Ok(match request.await {
|
||||
Ok(resolved) => merge_resolved(item, resolved, &text, encoding),
|
||||
Err(e) => {
|
||||
log::debug!("lsp: completionItem/resolve: {e:#}");
|
||||
item
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn is_completion_trigger(
|
||||
&self,
|
||||
_offset: usize,
|
||||
@@ -182,6 +217,48 @@ pub(crate) fn completion_to_editor(
|
||||
},
|
||||
}),
|
||||
});
|
||||
item.additional_text_edits = item
|
||||
.additional_text_edits
|
||||
.map(|edits| additional_edits_to_editor(edits, text, encoding));
|
||||
item
|
||||
}
|
||||
|
||||
fn additional_edits_to_editor(
|
||||
edits: Vec<lsp_types::TextEdit>,
|
||||
text: &Rope,
|
||||
encoding: Encoding,
|
||||
) -> Vec<lsp_types::TextEdit> {
|
||||
edits
|
||||
.into_iter()
|
||||
.map(|e| lsp_types::TextEdit {
|
||||
range: convert::range_to_editor(text, e.range, encoding),
|
||||
new_text: e.new_text,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What `completionItem/resolve` adds to an item the menu already holds.
|
||||
///
|
||||
/// Only the fields resolving is for are taken: the item the server echoes
|
||||
/// back carries the editor-column ranges it was sent, which converting again
|
||||
/// would shift. An item resolved without imports says so with an empty list,
|
||||
/// so accepting it does not ask again.
|
||||
pub(crate) fn merge_resolved(
|
||||
mut item: lsp_types::CompletionItem,
|
||||
resolved: lsp_types::CompletionItem,
|
||||
text: &Rope,
|
||||
encoding: Encoding,
|
||||
) -> lsp_types::CompletionItem {
|
||||
item.documentation = resolved.documentation.or(item.documentation);
|
||||
item.detail = resolved.detail.or(item.detail);
|
||||
item.command = resolved.command.or(item.command);
|
||||
item.additional_text_edits = Some(
|
||||
resolved
|
||||
.additional_text_edits
|
||||
.map(|edits| additional_edits_to_editor(edits, text, encoding))
|
||||
.or(item.additional_text_edits)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
item
|
||||
}
|
||||
|
||||
@@ -485,6 +562,44 @@ mod tests {
|
||||
assert_eq!(item.insert_text_format, Some(InsertTextFormat::PLAIN_TEXT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolving_adds_imports_in_editor_columns_and_keeps_the_edit() {
|
||||
let text = Rope::from("use é;\nfoo\n");
|
||||
let item = CompletionItem {
|
||||
label: "foo".into(),
|
||||
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
|
||||
range: LspRange::new(Position::new(1, 0), Position::new(1, 3)),
|
||||
new_text: "foo".into(),
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let resolved = CompletionItem {
|
||||
label: "foo".into(),
|
||||
documentation: Some(lsp_types::Documentation::String("Does foo.".into())),
|
||||
// Echoed back as sent; must not be converted a second time.
|
||||
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
|
||||
range: LspRange::new(Position::new(9, 9), Position::new(9, 9)),
|
||||
new_text: "wrong".into(),
|
||||
})),
|
||||
additional_text_edits: Some(vec![TextEdit {
|
||||
// After `use é;` — UTF-16 column 6 is char column 6 here.
|
||||
range: LspRange::new(Position::new(0, 6), Position::new(0, 6)),
|
||||
new_text: "\nuse bar::foo;".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
let merged = merge_resolved(item.clone(), resolved, &text, Encoding::Utf16);
|
||||
assert_eq!(merged.text_edit, item.text_edit);
|
||||
assert!(merged.documentation.is_some());
|
||||
assert_eq!(
|
||||
merged.additional_text_edits.unwrap()[0].new_text,
|
||||
"\nuse bar::foo;"
|
||||
);
|
||||
// Nothing to add: said so, so accepting does not ask again.
|
||||
let bare = merge_resolved(item.clone(), item, &text, Encoding::Utf16);
|
||||
assert_eq!(bare.additional_text_edits, Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_strings_in_a_hover_become_fenced_code() {
|
||||
let contents = lsp_types::HoverContents::Scalar(lsp_types::MarkedString::LanguageString(
|
||||
|
||||
@@ -23,6 +23,12 @@ pub(crate) struct ServerSpec {
|
||||
/// 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],
|
||||
/// The settings tty7 gives the server, as JSON keyed by configuration
|
||||
/// section — what `workspace/configuration` answers from.
|
||||
pub(crate) settings: &'static str,
|
||||
/// The section, if any, that also goes as `initializationOptions`: some
|
||||
/// servers read their settings only from there.
|
||||
pub(crate) init_section: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub(crate) const SERVERS: &[ServerSpec] = &[
|
||||
@@ -32,6 +38,11 @@ pub(crate) const SERVERS: &[ServerSpec] = &[
|
||||
commands: &[("rust-analyzer", &[])],
|
||||
root_markers: &["Cargo.toml"],
|
||||
outer_markers: &["Cargo.toml#workspace"],
|
||||
// `cargo check` on save is where rust-analyzer's type errors and
|
||||
// lints come from; without it only its own syntax-level
|
||||
// diagnostics arrive.
|
||||
settings: r#"{ "rust-analyzer": { "checkOnSave": true, "check": { "command": "check" } } }"#,
|
||||
init_section: Some("rust-analyzer"),
|
||||
},
|
||||
ServerSpec {
|
||||
name: "typescript-language-server",
|
||||
@@ -39,6 +50,8 @@ pub(crate) const SERVERS: &[ServerSpec] = &[
|
||||
commands: &[("typescript-language-server", &["--stdio"])],
|
||||
root_markers: &["tsconfig.json", "jsconfig.json", "package.json"],
|
||||
outer_markers: &[],
|
||||
settings: "{}",
|
||||
init_section: None,
|
||||
},
|
||||
ServerSpec {
|
||||
name: "pyright",
|
||||
@@ -56,6 +69,8 @@ pub(crate) const SERVERS: &[ServerSpec] = &[
|
||||
"requirements.txt",
|
||||
],
|
||||
outer_markers: &[],
|
||||
settings: r#"{ "python": { "analysis": { "autoSearchPaths": true, "useLibraryCodeForTypes": true } } }"#,
|
||||
init_section: None,
|
||||
},
|
||||
ServerSpec {
|
||||
name: "gopls",
|
||||
@@ -63,6 +78,8 @@ pub(crate) const SERVERS: &[ServerSpec] = &[
|
||||
commands: &[("gopls", &[])],
|
||||
root_markers: &["go.mod"],
|
||||
outer_markers: &["go.work"],
|
||||
settings: r#"{ "gopls": {} }"#,
|
||||
init_section: Some("gopls"),
|
||||
},
|
||||
ServerSpec {
|
||||
name: "clangd",
|
||||
@@ -70,9 +87,42 @@ pub(crate) const SERVERS: &[ServerSpec] = &[
|
||||
commands: &[("clangd", &[])],
|
||||
root_markers: &["compile_commands.json", "compile_flags.txt", ".clangd"],
|
||||
outer_markers: &[],
|
||||
settings: "{}",
|
||||
init_section: None,
|
||||
},
|
||||
];
|
||||
|
||||
impl ServerSpec {
|
||||
pub(crate) fn settings(&self) -> serde_json::Value {
|
||||
serde_json::from_str(self.settings).unwrap_or_else(|_| serde_json::json!({}))
|
||||
}
|
||||
|
||||
pub(crate) fn initialization_options(&self) -> serde_json::Value {
|
||||
match self.init_section {
|
||||
Some(section) => configuration_item(&self.settings(), Some(section)),
|
||||
None => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One item of a `workspace/configuration` answer: the part of `settings`
|
||||
/// under the dotted `section`, or all of it when none is named. A section
|
||||
/// tty7 sets nothing for gets an empty object rather than `null` — several
|
||||
/// servers read fields off the answer and fail on a null.
|
||||
pub(crate) fn configuration_item(
|
||||
settings: &serde_json::Value,
|
||||
section: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let Some(section) = section.filter(|s| !s.is_empty()) else {
|
||||
return settings.clone();
|
||||
};
|
||||
section
|
||||
.split('.')
|
||||
.try_fold(settings, |value, key| value.get(key))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}))
|
||||
}
|
||||
|
||||
/// Markers every server falls back on when its own are nowhere to be found.
|
||||
const FALLBACK_MARKERS: &[&str] = &[".git", ".hg", ".jj"];
|
||||
|
||||
@@ -293,6 +343,31 @@ mod tests {
|
||||
assert_eq!(language_id(Path::new("a.rs"), "rust"), "rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_answers_by_section_and_never_with_null() {
|
||||
let ra = rust().settings();
|
||||
assert_eq!(
|
||||
configuration_item(&ra, Some("rust-analyzer"))["checkOnSave"],
|
||||
serde_json::json!(true)
|
||||
);
|
||||
assert_eq!(
|
||||
configuration_item(&ra, Some("rust-analyzer.check.command")),
|
||||
serde_json::json!("check")
|
||||
);
|
||||
assert_eq!(
|
||||
configuration_item(&ra, Some("editor")),
|
||||
serde_json::json!({})
|
||||
);
|
||||
assert_eq!(configuration_item(&ra, None), ra);
|
||||
assert_eq!(
|
||||
rust().initialization_options()["checkOnSave"],
|
||||
serde_json::json!(true)
|
||||
);
|
||||
for spec in SERVERS {
|
||||
assert!(spec.settings().is_object(), "{} settings parse", spec.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_language_has_at_most_one_server() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Signature help: the parameters of the call the cursor is in, shown in a
|
||||
//! popover once `(` or `,` is typed and kept current while typing goes on
|
||||
//! inside the call. The server decides when the cursor has left it (it
|
||||
//! answers with nothing), and Escape puts the popover away.
|
||||
|
||||
use gpui::Context;
|
||||
use gpui_component::RopeExt as _;
|
||||
use lsp_types::{Documentation, ParameterLabel, SignatureHelp};
|
||||
|
||||
use super::convert::{self, Encoding};
|
||||
use super::{Freshen, LspStore};
|
||||
use crate::ui::app::Tty7App;
|
||||
use crate::ui::code_editor::LocalFile;
|
||||
|
||||
/// Characters that open signature help when the server names none.
|
||||
const DEFAULT_TRIGGERS: [&str; 2] = ["(", ","];
|
||||
|
||||
fn documentation(doc: &Documentation) -> &str {
|
||||
match doc {
|
||||
Documentation::String(s) => s,
|
||||
Documentation::MarkupContent(m) => &m.value,
|
||||
}
|
||||
}
|
||||
|
||||
/// Markdown's own punctuation, escaped so a signature reads as written —
|
||||
/// `*const T` is not emphasis.
|
||||
fn escape(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
if matches!(
|
||||
c,
|
||||
'\\' | '*' | '_' | '`' | '[' | ']' | '<' | '>' | '#' | '|'
|
||||
) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The popover's text: the active signature with the active parameter in
|
||||
/// bold, then what the server says about that parameter and the function.
|
||||
pub(crate) fn markdown(help: &SignatureHelp) -> Option<String> {
|
||||
let index = help.active_signature.unwrap_or(0) as usize;
|
||||
let signature = help
|
||||
.signatures
|
||||
.get(index)
|
||||
.or_else(|| help.signatures.first())?;
|
||||
let label = signature.label.as_str();
|
||||
let active = signature.active_parameter.or(help.active_parameter);
|
||||
let span = active
|
||||
.and_then(|ix| signature.parameters.as_ref()?.get(ix as usize))
|
||||
.and_then(|param| match ¶m.label {
|
||||
ParameterLabel::Simple(name) => {
|
||||
let start = label.find(name.as_str())?;
|
||||
Some((start, start + name.len(), param))
|
||||
}
|
||||
ParameterLabel::LabelOffsets([start, end]) => {
|
||||
// Offsets into the label, in UTF-16 units whatever the
|
||||
// position encoding.
|
||||
let start = convert::byte_of_column(label, *start, Encoding::Utf16);
|
||||
let end = convert::byte_of_column(label, *end, Encoding::Utf16).max(start);
|
||||
Some((start, end, param))
|
||||
}
|
||||
});
|
||||
let mut out = String::new();
|
||||
if help.signatures.len() > 1 {
|
||||
out.push_str(&format!("{}/{} ", index + 1, help.signatures.len()));
|
||||
}
|
||||
match span {
|
||||
Some((start, end, _)) if start < end => {
|
||||
out.push_str(&escape(&label[..start]));
|
||||
out.push_str("**");
|
||||
out.push_str(&escape(&label[start..end]));
|
||||
out.push_str("**");
|
||||
out.push_str(&escape(&label[end..]));
|
||||
}
|
||||
_ => out.push_str(&escape(label)),
|
||||
}
|
||||
if let Some(doc) = span.and_then(|(_, _, p)| p.documentation.as_ref()) {
|
||||
let doc = documentation(doc).trim();
|
||||
if !doc.is_empty() {
|
||||
out.push_str("\n\n");
|
||||
out.push_str(doc);
|
||||
}
|
||||
}
|
||||
if let Some(doc) = signature.documentation.as_ref() {
|
||||
let doc = documentation(doc).trim();
|
||||
if !doc.is_empty() {
|
||||
out.push_str("\n\n");
|
||||
out.push_str(doc);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
/// After an edit: opens signature help on a trigger character, and
|
||||
/// keeps an open one current.
|
||||
pub(crate) fn lsp_signature_help_after_edit(&self, f: &LocalFile, cx: &mut Context<Self>) {
|
||||
let (text, cursor, visible) = {
|
||||
let state = f.input.read(cx);
|
||||
(
|
||||
state.text().clone(),
|
||||
state.cursor(),
|
||||
state.is_signature_help_visible(),
|
||||
)
|
||||
};
|
||||
let point = text.offset_to_point(cursor);
|
||||
let line = text.slice_line(point.row).to_string();
|
||||
let Some(before) = line.get(..point.column).and_then(|l| l.chars().next_back()) else {
|
||||
return;
|
||||
};
|
||||
// Looked at without sending the edit: most keystrokes stop here, and
|
||||
// the server hears about them on the usual debounce.
|
||||
let Some(doc) = LspStore::context(&f.path, Freshen::Skip, None, cx) else {
|
||||
return;
|
||||
};
|
||||
let Some(options) = doc.caps.signature_help_provider.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let typed = before.to_string();
|
||||
let is_trigger = match &options.trigger_characters {
|
||||
Some(chars) => chars.contains(&typed),
|
||||
None => DEFAULT_TRIGGERS.contains(&typed.as_str()),
|
||||
};
|
||||
if !is_trigger && !visible {
|
||||
return;
|
||||
}
|
||||
let Some(doc) = LspStore::context(&f.path, Freshen::Text(&text), None, cx) else {
|
||||
return;
|
||||
};
|
||||
let context = lsp_types::SignatureHelpContext {
|
||||
trigger_kind: if is_trigger {
|
||||
lsp_types::SignatureHelpTriggerKind::TRIGGER_CHARACTER
|
||||
} else {
|
||||
lsp_types::SignatureHelpTriggerKind::CONTENT_CHANGE
|
||||
},
|
||||
trigger_character: is_trigger.then_some(typed),
|
||||
is_retrigger: visible,
|
||||
active_signature_help: None,
|
||||
};
|
||||
let request = doc
|
||||
.client
|
||||
.request::<lsp_types::request::SignatureHelpRequest>(lsp_types::SignatureHelpParams {
|
||||
context: Some(context),
|
||||
text_document_position_params: lsp_types::TextDocumentPositionParams::new(
|
||||
lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
|
||||
convert::offset_to_lsp(&text, cursor, doc.encoding),
|
||||
),
|
||||
work_done_progress_params: Default::default(),
|
||||
});
|
||||
let input = f.input.clone();
|
||||
let anchor = cursor - before.len_utf8()..cursor;
|
||||
cx.spawn(async move |_, cx| {
|
||||
let shown = match request.await {
|
||||
Ok(Some(help)) => markdown(&help),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
log::debug!("lsp: signatureHelp: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
input.update(cx, |state, cx| {
|
||||
// Typed on since: the next edit asks again.
|
||||
if *state.text() != text {
|
||||
return;
|
||||
}
|
||||
match shown {
|
||||
Some(markdown) => state.show_signature_help(anchor, markdown, cx),
|
||||
None => state.hide_signature_help(cx),
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lsp_types::{ParameterInformation, SignatureInformation};
|
||||
|
||||
fn help(params: Vec<ParameterLabel>, active: Option<u32>) -> SignatureHelp {
|
||||
SignatureHelp {
|
||||
signatures: vec![SignatureInformation {
|
||||
label: "fn copy(src: *const u8, n: usize)".into(),
|
||||
documentation: Some(Documentation::String("Copies.".into())),
|
||||
parameters: Some(
|
||||
params
|
||||
.into_iter()
|
||||
.map(|label| ParameterInformation {
|
||||
label,
|
||||
documentation: None,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
active_parameter: None,
|
||||
}],
|
||||
active_signature: Some(0),
|
||||
active_parameter: active,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_active_parameter_is_bold_and_markdown_is_escaped() {
|
||||
let by_name = help(
|
||||
vec![
|
||||
ParameterLabel::Simple("src: *const u8".into()),
|
||||
ParameterLabel::Simple("n: usize".into()),
|
||||
],
|
||||
Some(1),
|
||||
);
|
||||
assert_eq!(
|
||||
markdown(&by_name).unwrap(),
|
||||
"fn copy(src: \\*const u8, **n: usize**)\n\nCopies."
|
||||
);
|
||||
let by_offset = help(
|
||||
vec![
|
||||
ParameterLabel::LabelOffsets([8, 22]),
|
||||
ParameterLabel::LabelOffsets([24, 32]),
|
||||
],
|
||||
Some(0),
|
||||
);
|
||||
assert_eq!(
|
||||
markdown(&by_offset).unwrap(),
|
||||
"fn copy(**src: \\*const u8**, n: usize)\n\nCopies."
|
||||
);
|
||||
assert!(
|
||||
markdown(&SignatureHelp {
|
||||
signatures: vec![],
|
||||
active_signature: None,
|
||||
active_parameter: None
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! A server's `textDocument/documentSymbol`, as the editor's outline.
|
||||
//!
|
||||
//! The editor already builds an outline from its own parse tree; a server's
|
||||
//! is better where there is one — it knows `impl` blocks from macros, and
|
||||
//! languages the tree queries do not cover. It replaces the tree's through
|
||||
//! `Tty7App::editor_set_document_symbols`, refreshed a moment after the
|
||||
//! server hears about each edit.
|
||||
|
||||
use gpui_component::Rope;
|
||||
use lsp_types::{DocumentSymbol, DocumentSymbolResponse, SymbolInformation};
|
||||
|
||||
use super::convert::{self, Encoding};
|
||||
use crate::ui::code_editor::outline::{Outline, SymbolKind, SymbolNode};
|
||||
|
||||
fn kind(kind: lsp_types::SymbolKind) -> SymbolKind {
|
||||
let n = serde_json::to_value(kind)
|
||||
.ok()
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
SymbolKind::from_lsp(n as u32)
|
||||
}
|
||||
|
||||
/// The answer as an outline, its positions converted to the editor's
|
||||
/// columns against `text` — the text the server answered about.
|
||||
pub(crate) fn outline(response: DocumentSymbolResponse, text: &Rope, enc: Encoding) -> Outline {
|
||||
let nodes = match response {
|
||||
DocumentSymbolResponse::Nested(symbols) => {
|
||||
symbols.into_iter().map(|s| node(s, text, enc)).collect()
|
||||
}
|
||||
DocumentSymbolResponse::Flat(symbols) => nest_flat(symbols, text, enc),
|
||||
};
|
||||
Outline::from_nodes(nodes)
|
||||
}
|
||||
|
||||
fn node(symbol: DocumentSymbol, text: &Rope, enc: Encoding) -> SymbolNode {
|
||||
let range = convert::range_to_editor(text, symbol.range, enc);
|
||||
SymbolNode {
|
||||
name: symbol.name,
|
||||
kind: kind(symbol.kind),
|
||||
range: range.start..range.end,
|
||||
selection: convert::lsp_to_editor(text, symbol.selection_range.start, enc),
|
||||
children: symbol
|
||||
.children
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|c| node(c, text, enc))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The older, flat answer: nested by which range holds which, since the
|
||||
/// `containerName` it carries is a name, not a reference.
|
||||
fn nest_flat(mut symbols: Vec<SymbolInformation>, text: &Rope, enc: Encoding) -> Vec<SymbolNode> {
|
||||
let key = |p: lsp_types::Position| (p.line, p.character);
|
||||
// Outer before inner: by start, then the longer range first.
|
||||
symbols.sort_by(|a, b| {
|
||||
key(a.location.range.start)
|
||||
.cmp(&key(b.location.range.start))
|
||||
.then(key(b.location.range.end).cmp(&key(a.location.range.end)))
|
||||
});
|
||||
let contains = |outer: &lsp_types::Range, inner: &lsp_types::Range| {
|
||||
key(outer.start) <= key(inner.start) && key(inner.end) <= key(outer.end)
|
||||
};
|
||||
// A stack of open containers: (their server range, the node).
|
||||
let mut stack: Vec<(lsp_types::Range, SymbolNode)> = Vec::new();
|
||||
let mut roots = Vec::new();
|
||||
fn pop_into(stack: &mut Vec<(lsp_types::Range, SymbolNode)>, roots: &mut Vec<SymbolNode>) {
|
||||
if let Some((_, done)) = stack.pop() {
|
||||
match stack.last_mut() {
|
||||
Some((_, parent)) => parent.children.push(done),
|
||||
None => roots.push(done),
|
||||
}
|
||||
}
|
||||
}
|
||||
for s in symbols {
|
||||
let range = s.location.range;
|
||||
while stack
|
||||
.last()
|
||||
.is_some_and(|(outer, _)| !contains(outer, &range))
|
||||
{
|
||||
pop_into(&mut stack, &mut roots);
|
||||
}
|
||||
let editor = convert::range_to_editor(text, range, enc);
|
||||
stack.push((
|
||||
range,
|
||||
SymbolNode {
|
||||
name: s.name,
|
||||
kind: kind(s.kind),
|
||||
range: editor.start..editor.end,
|
||||
selection: editor.start,
|
||||
children: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
while !stack.is_empty() {
|
||||
pop_into(&mut stack, &mut roots);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lsp_types::{Location, Position, Range};
|
||||
|
||||
fn range(a: u32, b: u32, c: u32, d: u32) -> Range {
|
||||
Range::new(Position::new(a, b), Position::new(c, d))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn a_flat_answer_is_nested_by_range() {
|
||||
let text = Rope::from("struct A {\n f: u8,\n}\nfn g() {}\n");
|
||||
let uri = super::super::path_to_uri(std::path::Path::new("/a.rs")).unwrap();
|
||||
let info = |name: &str, kind, r| SymbolInformation {
|
||||
name: name.into(),
|
||||
kind,
|
||||
tags: None,
|
||||
deprecated: None,
|
||||
location: Location::new(uri.clone(), r),
|
||||
container_name: None,
|
||||
};
|
||||
let outline = outline(
|
||||
DocumentSymbolResponse::Flat(vec![
|
||||
info("g", lsp_types::SymbolKind::FUNCTION, range(3, 0, 3, 9)),
|
||||
info("f", lsp_types::SymbolKind::FIELD, range(1, 2, 1, 7)),
|
||||
info("A", lsp_types::SymbolKind::STRUCT, range(0, 0, 2, 1)),
|
||||
]),
|
||||
&text,
|
||||
Encoding::Utf16,
|
||||
);
|
||||
let shape: Vec<(&str, usize, SymbolKind)> = outline
|
||||
.symbols
|
||||
.iter()
|
||||
.map(|s| (s.name.as_str(), s.depth, s.kind))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
[
|
||||
("A", 0, SymbolKind::Struct),
|
||||
("f", 1, SymbolKind::Field),
|
||||
("g", 0, SymbolKind::Function),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn a_nested_answer_keeps_its_shape_in_editor_columns() {
|
||||
let text = Rope::from("mod 🎉m {\n fn f() {}\n}\n");
|
||||
let sym = |name: &str, kind, r: Range, sel: Range, children| DocumentSymbol {
|
||||
name: name.into(),
|
||||
detail: None,
|
||||
kind,
|
||||
tags: None,
|
||||
deprecated: None,
|
||||
range: r,
|
||||
selection_range: sel,
|
||||
children,
|
||||
};
|
||||
let outline = outline(
|
||||
DocumentSymbolResponse::Nested(vec![sym(
|
||||
"m",
|
||||
lsp_types::SymbolKind::MODULE,
|
||||
range(0, 0, 2, 1),
|
||||
// `m` after the emoji: UTF-16 column 6, char column 5.
|
||||
range(0, 6, 0, 7),
|
||||
Some(vec![sym(
|
||||
"f",
|
||||
lsp_types::SymbolKind::FUNCTION,
|
||||
range(1, 4, 1, 14),
|
||||
range(1, 7, 1, 8),
|
||||
None,
|
||||
)]),
|
||||
)]),
|
||||
&text,
|
||||
Encoding::Utf16,
|
||||
);
|
||||
assert_eq!(outline.symbols.len(), 2);
|
||||
assert_eq!(outline.symbols[0].selection, Position::new(0, 5));
|
||||
assert_eq!(outline.symbols[1].parent, Some(0));
|
||||
assert_eq!(outline.symbols[1].kind, SymbolKind::Function);
|
||||
}
|
||||
}
|
||||
+62
-1
@@ -126,7 +126,7 @@ fn lsp_smoke_a_real_server_publishes_diagnostics_and_shuts_down() {
|
||||
}))
|
||||
};
|
||||
|
||||
let init = client.initialize(initialize_params(&root));
|
||||
let init = client.initialize(initialize_params(&root, spec));
|
||||
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());
|
||||
@@ -185,6 +185,67 @@ fn lsp_smoke_a_real_server_publishes_diagnostics_and_shuts_down() {
|
||||
"hover over `x` says something"
|
||||
);
|
||||
|
||||
// The outline, the references to `x`, and signature help inside a call
|
||||
// all come back in shapes the editor reads.
|
||||
let doc_id = || lsp_types::TextDocumentIdentifier::new(path_to_uri(&file).unwrap());
|
||||
let symbols = client.request::<lsp_types::request::DocumentSymbolRequest>(
|
||||
lsp_types::DocumentSymbolParams {
|
||||
text_document: doc_id(),
|
||||
work_done_progress_params: Default::default(),
|
||||
partial_result_params: Default::default(),
|
||||
},
|
||||
);
|
||||
let outline = within(
|
||||
30,
|
||||
Box::pin(async move {
|
||||
symbols
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| serde_json::to_value(s).unwrap())
|
||||
}),
|
||||
)
|
||||
.map(|v| {
|
||||
symbols::outline(
|
||||
serde_json::from_value(v).unwrap(),
|
||||
&gpui_component::Rope::from(text),
|
||||
Encoding::Utf16,
|
||||
)
|
||||
})
|
||||
.expect("an outline");
|
||||
assert!(
|
||||
outline.symbols.iter().any(|s| s.name == "main"),
|
||||
"{outline:?}"
|
||||
);
|
||||
|
||||
let references = client.request::<lsp_types::request::References>(lsp_types::ReferenceParams {
|
||||
text_document_position: lsp_types::TextDocumentPositionParams::new(
|
||||
doc_id(),
|
||||
Position::new(2, 11),
|
||||
),
|
||||
work_done_progress_params: Default::default(),
|
||||
partial_result_params: Default::default(),
|
||||
context: lsp_types::ReferenceContext {
|
||||
include_declaration: true,
|
||||
},
|
||||
});
|
||||
let found = within(
|
||||
30,
|
||||
Box::pin(async move {
|
||||
references
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|r| serde_json::to_value(r).unwrap())
|
||||
}),
|
||||
)
|
||||
.expect("references");
|
||||
let found: Vec<lsp_types::Location> = serde_json::from_value(found).unwrap();
|
||||
assert!(
|
||||
found.len() >= 2,
|
||||
"the declaration and the use of x: {found:?}"
|
||||
);
|
||||
|
||||
let shutdown = client.request_raw("shutdown", Value::Null);
|
||||
within(10, Box::pin(async move { shutdown.await.ok() }));
|
||||
client.notify_raw("exit", Value::Null);
|
||||
|
||||
@@ -121,6 +121,12 @@ pub enum CommandKind {
|
||||
line: u32,
|
||||
column: u32,
|
||||
},
|
||||
/// A place a language server found: 0-based, the column in chars.
|
||||
GoToLocation {
|
||||
path: std::path::PathBuf,
|
||||
line: u32,
|
||||
column: u32,
|
||||
},
|
||||
/// Connect with a typed `ssh` command line (`-p`, `-J`, an alias…).
|
||||
OpenSshConnect(String),
|
||||
SetTheme(usize),
|
||||
@@ -276,6 +282,7 @@ impl CommandKind {
|
||||
| GoToTab { .. }
|
||||
| OpenFile { .. }
|
||||
| GoToSymbol { .. }
|
||||
| GoToLocation { .. }
|
||||
| ResumeSession { .. }
|
||||
| ForkSession { .. }
|
||||
| CopySessionId(_)
|
||||
@@ -413,6 +420,7 @@ impl CommandKind {
|
||||
| GoToTab { .. }
|
||||
| OpenFile { .. }
|
||||
| GoToSymbol { .. }
|
||||
| GoToLocation { .. }
|
||||
| ResumeSession { .. }
|
||||
| ForkSession { .. }
|
||||
| CopySessionId(_)
|
||||
|
||||
+11
-1
@@ -17,7 +17,7 @@ mod view;
|
||||
pub(crate) use command::{Avatar, ChromeState, CommandGroup, CommandKind, Item};
|
||||
pub(crate) use files::{FileIndexStore, FileList};
|
||||
pub(crate) use score::fuzzy_score;
|
||||
pub(crate) use sources::{Catalog, host_items};
|
||||
pub(crate) use sources::{Catalog, LiveQuery, host_items};
|
||||
pub(crate) use view::{KEY_CONTEXT, SearchEvent, SearchView};
|
||||
|
||||
use crate::ui::i18n::{L10nKey, t};
|
||||
@@ -33,6 +33,12 @@ pub(crate) enum SearchTab {
|
||||
/// Go to Symbol: the symbols of the file in front of the editor. Like
|
||||
/// Files, reached by its chord (or the breadcrumbs), never by the row.
|
||||
Symbols,
|
||||
/// Places a language server found — the references to a symbol, or its
|
||||
/// several definitions (`ui::lsp`). Reached only by those commands.
|
||||
Locations,
|
||||
/// Go to Symbol in Workspace: what the language server finds for the
|
||||
/// query, asked afresh as it is typed (`ui::lsp`).
|
||||
WorkspaceSymbols,
|
||||
}
|
||||
|
||||
impl SearchTab {
|
||||
@@ -61,6 +67,8 @@ impl SearchTab {
|
||||
SearchTab::Sessions => L10nKey::SearchTabSessions,
|
||||
SearchTab::Hosts => L10nKey::SearchTabHosts,
|
||||
SearchTab::Symbols => L10nKey::SearchTabSymbols,
|
||||
SearchTab::Locations => L10nKey::SearchTabLocations,
|
||||
SearchTab::WorkspaceSymbols => L10nKey::SearchTabSymbols,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +81,8 @@ impl SearchTab {
|
||||
SearchTab::Sessions => L10nKey::SearchPlaceholderSessions,
|
||||
SearchTab::Hosts => L10nKey::SearchPlaceholderHosts,
|
||||
SearchTab::Symbols => L10nKey::SearchPlaceholderSymbols,
|
||||
SearchTab::Locations => L10nKey::SearchPlaceholderLocations,
|
||||
SearchTab::WorkspaceSymbols => L10nKey::SearchPlaceholderWorkspaceSymbols,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -94,8 +94,17 @@ pub(crate) struct Catalog {
|
||||
/// each with how deep it sits — filled only for Go to Symbol
|
||||
/// (`Tty7App::editor_go_to_symbol`).
|
||||
pub symbols: Vec<(usize, Item)>,
|
||||
/// What a language server found for Find References or a definition
|
||||
/// with more than one answer (`ui::lsp`), in the order to list them.
|
||||
pub locations: Vec<Item>,
|
||||
/// Asked with every query the Go to Symbol in Workspace tab is given;
|
||||
/// its answer comes back through `SearchView::set_locations`.
|
||||
pub live_query: Option<LiveQuery>,
|
||||
}
|
||||
|
||||
/// Something that answers a query later — a language server.
|
||||
pub(crate) type LiveQuery = std::rc::Rc<dyn Fn(&str, &mut App)>;
|
||||
|
||||
impl Catalog {
|
||||
pub(crate) fn new(mut actions: Vec<Item>, terminals: Vec<Item>, hosts: Vec<Item>) -> Self {
|
||||
// Stable, so a group keeps the order its rows were listed in. The
|
||||
@@ -114,6 +123,8 @@ impl Catalog {
|
||||
sessions_here: 0,
|
||||
files: FileList::default(),
|
||||
symbols: Vec::new(),
|
||||
locations: Vec::new(),
|
||||
live_query: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +140,9 @@ impl Catalog {
|
||||
SearchTab::Hosts => Some(Box::new(Hosts(&self.hosts))),
|
||||
SearchTab::Files => Some(Box::new(Files(&self.files))),
|
||||
SearchTab::Symbols => Some(Box::new(Symbols(&self.symbols))),
|
||||
SearchTab::Locations | SearchTab::WorkspaceSymbols => {
|
||||
Some(Box::new(Locations(&self.locations)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +487,37 @@ impl Source for Symbols<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find References' rows: listed as found, filtered by path and line text.
|
||||
struct Locations<'a>(&'a [Item]);
|
||||
|
||||
impl Source for Locations<'_> {
|
||||
fn tab(&self) -> SearchTab {
|
||||
SearchTab::Locations
|
||||
}
|
||||
|
||||
fn browse(&self, _cx: &App) -> Vec<Section> {
|
||||
if self.0.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Section {
|
||||
title: None,
|
||||
rows: self.0.iter().cloned().map(Row::Item).collect(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn highlights(&self, _cx: &App) -> Vec<Item> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn on_the_empty_all_tab(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> {
|
||||
rank(self.0, query, |_| 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct Hosts<'a>(&'a [Item]);
|
||||
|
||||
impl Source for Hosts<'_> {
|
||||
|
||||
@@ -213,6 +213,11 @@ impl ListDelegate for SearchDelegate {
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Task<()> {
|
||||
self.query = query.to_string();
|
||||
if self.scope == Scope::Tab(SearchTab::WorkspaceSymbols)
|
||||
&& let Some(live) = self.catalog.live_query.clone()
|
||||
{
|
||||
live(query, cx);
|
||||
}
|
||||
self.refresh(cx);
|
||||
// Through `set_selected_index`, not by hand: the row index may not have
|
||||
// moved, but the row under it has, and the theme picker previews the
|
||||
@@ -271,6 +276,13 @@ impl ListDelegate for SearchDelegate {
|
||||
Scope::Tab(SearchTab::Sessions) if self.query.trim().is_empty() => {
|
||||
t(L10nKey::SearchSessionsEmptyHint)
|
||||
}
|
||||
Scope::Tab(SearchTab::Locations) if self.catalog.locations.is_empty() => {
|
||||
t(L10nKey::SearchLocationsNone)
|
||||
}
|
||||
Scope::Tab(SearchTab::WorkspaceSymbols) if self.query.trim().is_empty() => {
|
||||
t(L10nKey::SearchPlaceholderWorkspaceSymbols)
|
||||
}
|
||||
Scope::Tab(SearchTab::WorkspaceSymbols) => t(L10nKey::SearchLocationsNone),
|
||||
Scope::Tab(SearchTab::Symbols) if self.catalog.symbols.is_empty() => {
|
||||
headline = t(L10nKey::SearchSymbolsNone);
|
||||
t(L10nKey::SearchSymbolsNoneHint)
|
||||
@@ -384,6 +396,13 @@ pub enum SearchEvent {
|
||||
line: u32,
|
||||
column: u32,
|
||||
},
|
||||
/// The same for a place a language server found (`ui::lsp`), which may
|
||||
/// be in another file.
|
||||
PreviewLocation {
|
||||
path: std::path::PathBuf,
|
||||
line: u32,
|
||||
column: u32,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct SearchView {
|
||||
@@ -566,6 +585,30 @@ impl SearchView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find References' rows (`ui::lsp`). The first is highlighted and not
|
||||
/// previewed — opening the list must not move the caret — until the
|
||||
/// highlight moves.
|
||||
pub(crate) fn set_locations(
|
||||
&mut self,
|
||||
locations: Vec<Item>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.symbol_moved = false;
|
||||
self.symbol_initial = locations.first().map(|item| item.kind.clone());
|
||||
self.update_catalog(|catalog| catalog.locations = locations, window, cx);
|
||||
}
|
||||
|
||||
/// Sets what Go to Symbol in Workspace asks as the query changes.
|
||||
pub(crate) fn set_live_query(
|
||||
&mut self,
|
||||
live: crate::ui::search::LiveQuery,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.update_catalog(|catalog| catalog.live_query = Some(live), window, cx);
|
||||
}
|
||||
|
||||
/// Changes part of the catalog under an open list. The highlight stays on
|
||||
/// the row it was on when that row is still there, so a list that fills in
|
||||
/// under the cursor does not move what Return runs.
|
||||
@@ -745,6 +788,21 @@ impl SearchView {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.in_sub_list()
|
||||
&& matches!(self.tab, SearchTab::Locations | SearchTab::WorkspaceSymbols)
|
||||
&& let Some(Row::Item(item)) = list.read(cx).delegate().row_at(*ix)
|
||||
&& let CommandKind::GoToLocation { path, line, column } = &item.kind
|
||||
{
|
||||
if self.symbol_moved || self.symbol_initial.as_ref() != Some(&item.kind) {
|
||||
self.symbol_moved = true;
|
||||
cx.emit(SearchEvent::PreviewLocation {
|
||||
path: path.clone(),
|
||||
line: *line,
|
||||
column: *column,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.in_sub_list()
|
||||
&& let Some(Row::Item(item)) = list.read(cx).delegate().row_at(*ix)
|
||||
&& let CommandKind::SetTheme(i) = item.kind
|
||||
|
||||
Reference in New Issue
Block a user