From 5d6071f1826d051345d3a37727612f612a7e4f26 Mon Sep 17 00:00:00 2001 From: Michael Davis Date: Fri, 8 Nov 2024 09:57:20 -0500 Subject: [PATCH] Add a document level syntax symbol picker --- book/src/generated/static-cmd.md | 4 +- helix-core/src/syntax.rs | 53 +++++++++++ helix-term/src/commands.rs | 25 ++++++ helix-term/src/commands/syntax.rs | 144 ++++++++++++++++++++++++++++++ helix-term/src/keymap/default.rs | 2 +- 5 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 helix-term/src/commands/syntax.rs diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md index 08c8e7023..e13d0506f 100644 --- a/book/src/generated/static-cmd.md +++ b/book/src/generated/static-cmd.md @@ -103,7 +103,7 @@ | `code_action` | Perform code action | normal: `` a ``, select: `` a `` | | `buffer_picker` | Open buffer picker | normal: `` b ``, select: `` b `` | | `jumplist_picker` | Open jumplist picker | normal: `` j ``, select: `` j `` | -| `symbol_picker` | Open symbol picker | normal: `` s ``, select: `` s `` | +| `symbol_picker` | Open symbol picker | | | `changed_file_picker` | Open changed file picker | normal: `` g ``, select: `` g `` | | `select_references_to_symbol_under_cursor` | Select symbol references | normal: `` h ``, select: `` h `` | | `workspace_symbol_picker` | Open workspace symbol picker | normal: `` S ``, select: `` S `` | @@ -294,3 +294,5 @@ | `extend_to_word` | Extend to a two-character label | select: `` gw `` | | `goto_next_tabstop` | goto next snippet placeholder | | | `goto_prev_tabstop` | goto next snippet placeholder | | +| `syntax_symbol_picker` | Open a picker of symbols from the syntax tree | | +| `lsp_or_syntax_symbol_picker` | Open an LSP symbol picker if available, or syntax otherwise | normal: `` s ``, select: `` s `` | diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 6ddf433cb..b7e7acdb5 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -154,6 +154,8 @@ pub struct LanguageConfiguration { #[serde(skip)] pub(crate) indent_query: OnceCell>, #[serde(skip)] + symbols_query: OnceCell>, + #[serde(skip)] pub(crate) textobject_query: OnceCell>, #[serde(skip_serializing_if = "Option::is_none")] pub debugger: Option, @@ -798,6 +800,12 @@ impl LanguageConfiguration { .as_ref() } + pub fn symbols_query(&self) -> Option<&Query> { + self.symbols_query + .get_or_init(|| self.load_query("symbols.scm")) + .as_ref() + } + pub fn textobject_query(&self) -> Option<&TextObjectQuery> { self.textobject_query .get_or_init(|| { @@ -1412,6 +1420,51 @@ impl Syntax { self.layers[self.root].tree() } + pub fn captures<'a>( + &'a self, + query: &'a Query, + source: RopeSlice<'a>, + range: Option>, + ) -> impl Iterator, usize)> + 'a { + struct Captures<'a> { + // The query cursor must live as long as the captures iterator so + // we need to bind them together in this struct. + _cursor: QueryCursor, + captures: QueryCaptures<'a, 'a, RopeProvider<'a>, &'a [u8]>, + } + + impl<'a> Iterator for Captures<'a> { + type Item = (QueryMatch<'a, 'a>, usize); + + fn next(&mut self) -> Option { + self.captures.next() + } + } + + let mut cursor = PARSER.with(|ts_parser| { + let highlighter = &mut ts_parser.borrow_mut(); + highlighter.cursors.pop().unwrap_or_default() + }); + + // The `captures` iterator borrows the `Tree` and the `QueryCursor`, which + // prevents them from being moved. But both of these values are really just + // pointers, so it's actually ok to move them. + let cursor_ref = unsafe { + mem::transmute::<&mut tree_sitter::QueryCursor, &mut tree_sitter::QueryCursor>( + &mut cursor, + ) + }; + + cursor_ref.set_byte_range(range.clone().unwrap_or(0..usize::MAX)); + cursor_ref.set_match_limit(TREE_SITTER_MATCH_LIMIT); + + let captures = cursor_ref.captures(query, self.tree().root_node(), RopeProvider(source)); + Captures { + _cursor: cursor, + captures, + } + } + /// Iterate over the highlighted regions for a given slice of source code. pub fn highlight_iter<'a>( &'a self, diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 76cad67b1..820636525 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,5 +1,6 @@ pub(crate) mod dap; pub(crate) mod lsp; +pub(crate) mod syntax; pub(crate) mod typed; pub use dap::*; @@ -11,6 +12,7 @@ use helix_stdx::{ }; use helix_vcs::{FileChange, Hunk}; pub use lsp::*; +pub use syntax::*; use tui::text::Span; pub use typed::*; @@ -587,6 +589,8 @@ impl MappableCommand { extend_to_word, "Extend to a two-character label", goto_next_tabstop, "goto next snippet placeholder", goto_prev_tabstop, "goto next snippet placeholder", + syntax_symbol_picker, "Open a picker of symbols from the syntax tree", + lsp_or_syntax_symbol_picker, "Open an LSP symbol picker if available, or syntax otherwise", ); } @@ -6495,3 +6499,24 @@ fn jump_to_word(cx: &mut Context, behaviour: Movement) { } jump_to_label(cx, words, behaviour) } + +fn lsp_or_syntax_symbol_picker(cx: &mut Context) { + let doc = doc!(cx.editor); + + if doc + .language_servers_with_feature(LanguageServerFeature::DocumentSymbols) + .next() + .is_some() + { + lsp::symbol_picker(cx); + } else if doc.syntax().is_some() + && doc + .language_config() + .is_some_and(|config| config.symbols_query().is_some()) + { + syntax_symbol_picker(cx); + } else { + cx.editor + .set_error("No language server supporting document symbols or syntax info available"); + } +} diff --git a/helix-term/src/commands/syntax.rs b/helix-term/src/commands/syntax.rs new file mode 100644 index 000000000..a86dbc4b9 --- /dev/null +++ b/helix-term/src/commands/syntax.rs @@ -0,0 +1,144 @@ +use helix_core::{tree_sitter::Query, Selection, Uri}; +use helix_view::{align_view, Align, DocumentId}; + +use crate::ui::{overlay::overlaid, picker::PathOrId, Picker, PickerColumn}; + +use super::Context; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum SymbolKind { + Function, + Macro, + Module, + Constant, + Struct, + Interface, + Type, + Class, +} + +impl SymbolKind { + fn as_str(&self) -> &'static str { + match self { + Self::Function => "function", + Self::Macro => "macro", + Self::Module => "module", + Self::Constant => "constant", + Self::Struct => "struct", + Self::Interface => "interface", + Self::Type => "type", + Self::Class => "class", + } + } +} + +fn definition_symbol_kind_for_capture(symbols: &Query, capture_index: usize) -> Option { + match symbols.capture_names()[capture_index] { + "definition.function" => Some(SymbolKind::Function), + "definition.macro" => Some(SymbolKind::Macro), + "definition.module" => Some(SymbolKind::Module), + "definition.constant" => Some(SymbolKind::Constant), + "definition.struct" => Some(SymbolKind::Struct), + "definition.interface" => Some(SymbolKind::Interface), + "definition.type" => Some(SymbolKind::Type), + "definition.class" => Some(SymbolKind::Class), + _ => None, + } +} + +// NOTE: Uri is cheap to clone and DocumentId is Copy +#[derive(Debug, Clone)] +enum UriOrDocumentId { + // TODO: the workspace symbol picker will take advantage of this. + #[allow(dead_code)] + Uri(Uri), + Id(DocumentId), +} + +impl UriOrDocumentId { + fn path_or_id(&self) -> Option> { + match self { + Self::Id(id) => Some(PathOrId::Id(*id)), + Self::Uri(uri) => uri.as_path().map(PathOrId::Path), + } + } +} + +#[derive(Debug)] +struct Symbol { + kind: SymbolKind, + name: String, + start: usize, + end: usize, + start_line: usize, + end_line: usize, + doc: UriOrDocumentId, +} + +pub fn syntax_symbol_picker(cx: &mut Context) { + let doc = doc!(cx.editor); + let Some((syntax, lang_config)) = doc.syntax().zip(doc.language_config()) else { + cx.editor + .set_error("Syntax tree is not available on this buffer"); + return; + }; + let Some(symbols_query) = lang_config.symbols_query() else { + cx.editor + .set_error("Syntax-based symbols information not available for this language"); + return; + }; + + let doc_id = doc.id(); + let text = doc.text(); + + let columns = vec![ + PickerColumn::new("kind", |symbol: &Symbol, _| symbol.kind.as_str().into()), + PickerColumn::new("name", |symbol: &Symbol, _| symbol.name.as_str().into()), + ]; + + let symbols = syntax + .captures(symbols_query, text.slice(..), None) + .filter_map(move |(match_, capture_index)| { + let capture = match_.captures[capture_index]; + let kind = definition_symbol_kind_for_capture(symbols_query, capture.index as usize)?; + let node = capture.node; + let start = text.byte_to_char(node.start_byte()); + let end = text.byte_to_char(node.end_byte()); + + Some(Symbol { + kind, + name: text.slice(start..end).to_string(), + start, + end, + + start_line: text.char_to_line(start), + end_line: text.char_to_line(end), + doc: UriOrDocumentId::Id(doc_id), + }) + }); + + let picker = Picker::new( + columns, + 1, // name + symbols, + (), + move |cx, symbol, action| { + cx.editor.switch(doc_id, action); + let view = view_mut!(cx.editor); + let doc = doc_mut!(cx.editor, &doc_id); + doc.set_selection(view.id, Selection::single(symbol.start, symbol.end)); + if action.align_view(view, doc.id()) { + align_view(doc, view, Align::Center) + } + }, + ) + .with_preview(|_editor, symbol| { + Some(( + symbol.doc.path_or_id()?, + Some((symbol.start_line, symbol.end_line)), + )) + }) + .truncate_start(false); + + cx.push_layer(Box::new(overlaid(picker))); +} diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index c6cefd927..20643e760 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -224,7 +224,7 @@ pub fn default() -> HashMap { "F" => file_picker_in_current_directory, "b" => buffer_picker, "j" => jumplist_picker, - "s" => symbol_picker, + "s" => lsp_or_syntax_symbol_picker, "S" => workspace_symbol_picker, "d" => diagnostics_picker, "D" => workspace_diagnostics_picker,