diff --git a/src/core/actions.rs b/src/core/actions.rs index 94a2582a..558f3aef 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -135,6 +135,8 @@ actions!( EditorQuickFix, EditorRenameSymbol, EditorFormatDocument, + EditorFindReferences, + EditorWorkspaceSymbol, EditorNextChange, EditorPrevChange, EditorRevertChange, diff --git a/src/ui/app.rs b/src/ui/app.rs index f0017ebd..675d4a14 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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) })) diff --git a/src/ui/code_editor/nav.rs b/src/ui/code_editor/nav.rs index 1d0e1e56..7820080f 100644 --- a/src/ui/code_editor/nav.rs +++ b/src/ui/code_editor/nav.rs @@ -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 { + 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; diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 9f03e611..77d73353 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -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", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index dcf1568b..599cb47f 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -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 => "変更", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 146d7ca3..21b13148 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -979,6 +979,12 @@ l10n_keys! { LspServerStarting, LspServerDown, LspProblemsTooltip, + SearchTabLocations, + SearchPlaceholderLocations, + SearchLocationsNone, + LspFindReferences, + LspWorkspaceSymbol, + SearchPlaceholderWorkspaceSymbols, PanelInfoTitle, PanelChangesTitle, PanelScmTitle, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index e4016d4d..7e2fb9b4 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -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 => "更改", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 8d851d0c..45175353 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -101,6 +101,10 @@ fn fixed_bindings() -> Vec { 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 { "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), diff --git a/src/ui/lsp/editor.rs b/src/ui/lsp/editor.rs index d34c7049..cda872be 100644 --- a/src/ui/lsp/editor.rs +++ b/src/ui/lsp/editor.rs @@ -56,6 +56,7 @@ impl Tty7App { pub(crate) fn lsp_buffer_edited(&self, id: BufferId, cx: &mut Context) { if let Some(f) = self.editor_local_file(id) { LspStore::edited(f.input.entity_id(), &f.path, cx); + 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), diff --git a/src/ui/lsp/locations.rs b/src/ui/lsp/locations.rs new file mode 100644 index 00000000..b931cdd3 --- /dev/null +++ b/src/ui/lsp/locations.rs @@ -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, + enc: Encoding, +) -> Vec { + let mut found: Vec = 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, + enc: Encoding, +) -> Vec> { + let mut read: HashMap>> = 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 { + 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, + ) { + 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) { + 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::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) { + 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, + ) { + 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::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::>() + }) + .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, + ) { + 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, + 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 { + use lsp_types::{OneOf, WorkspaceSymbolResponse}; + let hits: Vec = 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")); + } +} diff --git a/src/ui/lsp/mod.rs b/src/ui/lsp/mod.rs index aa934111..f92bb194 100644 --- a/src/ui/lsp/mod.rs +++ b/src/ui/lsp/mod.rs @@ -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, /// Where the owning buffer is drawn, once known — edits the server asks /// for are applied through it. window: Option, @@ -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, problems: (usize, usize), } @@ -151,6 +160,8 @@ pub(crate) struct DocContext { pub(crate) caps: Rc, /// The server's raw diagnostics for this document, in its own columns. pub(crate) diagnostics: Vec, + /// 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::() + .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::() - .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::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::() + .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 = 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": { diff --git a/src/ui/lsp/providers.rs b/src/ui/lsp/providers.rs index 367d2b46..ed8aa4c0 100644 --- a/src/ui/lsp/providers.rs +++ b/src/ui/lsp/providers.rs @@ -135,6 +135,41 @@ impl CompletionProvider for LspProvider { }) } + fn resolve_completion( + &self, + item: lsp_types::CompletionItem, + text: &Rope, + cx: &mut App, + ) -> Task> { + // 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::(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, + text: &Rope, + encoding: Encoding, +) -> Vec { + 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( diff --git a/src/ui/lsp/servers.rs b/src/ui/lsp/servers.rs index 32e2a3bc..d06010b6 100644 --- a/src/ui/lsp/servers.rs +++ b/src/ui/lsp/servers.rs @@ -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(); diff --git a/src/ui/lsp/signature.rs b/src/ui/lsp/signature.rs new file mode 100644 index 00000000..4319f69f --- /dev/null +++ b/src/ui/lsp/signature.rs @@ -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 { + 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) { + 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::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, active: Option) -> 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() + ); + } +} diff --git a/src/ui/lsp/symbols.rs b/src/ui/lsp/symbols.rs new file mode 100644 index 00000000..f11523e6 --- /dev/null +++ b/src/ui/lsp/symbols.rs @@ -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, text: &Rope, enc: Encoding) -> Vec { + 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) { + 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); + } +} diff --git a/src/ui/lsp/tests.rs b/src/ui/lsp/tests.rs index 55a1138a..abad6237 100644 --- a/src/ui/lsp/tests.rs +++ b/src/ui/lsp/tests.rs @@ -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::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::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 = 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); diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs index 7dea7c65..7d30715d 100644 --- a/src/ui/search/command.rs +++ b/src/ui/search/command.rs @@ -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(_) diff --git a/src/ui/search/mod.rs b/src/ui/search/mod.rs index dab40d20..85495c41 100644 --- a/src/ui/search/mod.rs +++ b/src/ui/search/mod.rs @@ -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, }) } diff --git a/src/ui/search/sources.rs b/src/ui/search/sources.rs index 969e9ea5..c3ea74fe 100644 --- a/src/ui/search/sources.rs +++ b/src/ui/search/sources.rs @@ -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, + /// 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, } +/// Something that answers a query later — a language server. +pub(crate) type LiveQuery = std::rc::Rc; + impl Catalog { pub(crate) fn new(mut actions: Vec, terminals: Vec, hosts: Vec) -> 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
{ + 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 { + 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<'_> { diff --git a/src/ui/search/view.rs b/src/ui/search/view.rs index a238440f..df241308 100644 --- a/src/ui/search/view.rs +++ b/src/ui/search/view.rs @@ -213,6 +213,11 @@ impl ListDelegate for SearchDelegate { cx: &mut Context>, ) -> 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, + window: &mut Window, + cx: &mut Context, + ) { + 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.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