use std::{ collections::HashSet, iter, path::{Path, PathBuf}, sync::Arc, }; use dashmap::DashMap; use futures_util::FutureExt; use grep_regex::RegexMatcherBuilder; use grep_searcher::{sinks, BinaryDetection, SearcherBuilder}; use helix_core::{ syntax::{Loader, QueryIterEvent}, Rope, RopeSlice, Selection, Syntax, Uri, }; use helix_stdx::{ path, rope::{self, RopeSliceExt}, }; use helix_view::{ align_view, document::{from_reader, SCRATCH_BUFFER_NAME}, Align, Document, DocumentId, Editor, }; use ignore::{DirEntry, WalkBuilder, WalkState}; use crate::{ filter_picker_entry, ui::{ overlay::overlaid, picker::{Injector, PathOrId}, Picker, PickerColumn, }, }; use super::Context; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TagKind { Class, Constant, Function, Interface, Macro, Module, Struct, Type, } impl TagKind { fn as_str(&self) -> &'static str { match self { Self::Class => "class", Self::Constant => "constant", Self::Function => "function", Self::Interface => "interface", Self::Macro => "macro", Self::Module => "module", Self::Struct => "struct", Self::Type => "type", } } fn from_name(name: &str) -> Option { match name { "class" => Some(TagKind::Class), "constant" => Some(TagKind::Constant), "function" => Some(TagKind::Function), "interface" => Some(TagKind::Interface), "macro" => Some(TagKind::Macro), "module" => Some(TagKind::Module), "struct" => Some(TagKind::Struct), "type" => Some(TagKind::Type), _ => None, } } } // NOTE: Uri is cheap to clone and DocumentId is Copy #[derive(Debug, Clone)] enum UriOrDocumentId { 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 Tag { kind: TagKind, name: String, start: usize, end: usize, start_line: usize, end_line: usize, doc: UriOrDocumentId, } fn tags_iter<'a>( syntax: &'a Syntax, loader: &'a Loader, text: RopeSlice<'a>, doc: UriOrDocumentId, pattern: Option<&'a rope::Regex>, ) -> impl Iterator + 'a { let mut tags_iter = syntax.tags(text, loader, ..); iter::from_fn(move || loop { let QueryIterEvent::Match(mat) = tags_iter.next()? else { continue; }; let query = &loader .tag_query(tags_iter.current_language()) .expect("must have a tags query to emit matches") .query; let Some(kind) = query .capture_name(mat.capture) .strip_prefix("definition.") .and_then(TagKind::from_name) else { continue; }; let range = mat.node.byte_range(); if pattern.is_some_and(|pattern| { !pattern.is_match(text.regex_input_at_bytes(range.start as usize..range.end as usize)) }) { continue; } let start = text.byte_to_char(range.start as usize); let end = text.byte_to_char(range.end as usize); return Some(Tag { 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: doc.clone(), }); }) } pub fn syntax_symbol_picker(cx: &mut Context) { let doc = doc!(cx.editor); let Some(syntax) = doc.syntax() else { cx.editor .set_error("Syntax tree is not available on this buffer"); return; }; let doc_id = doc.id(); let text = doc.text().slice(..); let loader = cx.editor.syn_loader.load(); let tags = tags_iter(syntax, &loader, text, UriOrDocumentId::Id(doc.id()), None); let columns = vec![ PickerColumn::new("kind", |tag: &Tag, _| tag.kind.as_str().into()), PickerColumn::new("name", |tag: &Tag, _| tag.name.as_str().into()), ]; let picker = Picker::new( columns, 1, // name tags, (), move |cx, tag, 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(tag.start, tag.end)); if action.align_view(view, doc.id()) { align_view(doc, view, Align::Center) } }, ) .with_preview(|_editor, tag| { Some((tag.doc.path_or_id()?, Some((tag.start_line, tag.end_line)))) }) .truncate_start(false); cx.push_layer(Box::new(overlaid(picker))); } pub fn syntax_workspace_symbol_picker(cx: &mut Context) { #[derive(Debug)] struct SearchState { searcher_builder: SearcherBuilder, walk_builder: WalkBuilder, regex_matcher_builder: RegexMatcherBuilder, rope_regex_builder: rope::RegexBuilder, search_root: PathBuf, /// A cache of files that have been parsed in prior searches. syntax_cache: DashMap>, } let mut searcher_builder = SearcherBuilder::new(); searcher_builder.binary_detection(BinaryDetection::quit(b'\x00')); // Search from the workspace that the currently focused document is within. This behaves like global // search most of the time but helps when you have two projects open in splits. let search_root = if let Some(path) = doc!(cx.editor).path() { helix_loader::find_workspace_in(path).0 } else { helix_loader::find_workspace().0 }; let absolute_root = search_root .canonicalize() .unwrap_or_else(|_| search_root.clone()); let config = cx.editor.config(); let dedup_symlinks = config.file_picker.deduplicate_links; let mut walk_builder = WalkBuilder::new(&search_root); walk_builder .hidden(config.file_picker.hidden) .parents(config.file_picker.parents) .ignore(config.file_picker.ignore) .follow_links(config.file_picker.follow_symlinks) .git_ignore(config.file_picker.git_ignore) .git_global(config.file_picker.git_global) .git_exclude(config.file_picker.git_exclude) .max_depth(config.file_picker.max_depth) .filter_entry(move |entry| filter_picker_entry(entry, &absolute_root, dedup_symlinks)) .add_custom_ignore_filename(helix_loader::config_dir().join("ignore")) .add_custom_ignore_filename(".helix/ignore"); let mut regex_matcher_builder = RegexMatcherBuilder::new(); regex_matcher_builder.case_smart(config.search.smart_case); let mut rope_regex_builder = rope::RegexBuilder::new(); rope_regex_builder.syntax(rope::Config::new().case_insensitive(config.search.smart_case)); let state = SearchState { searcher_builder, walk_builder, regex_matcher_builder, rope_regex_builder, search_root, syntax_cache: DashMap::default(), }; let reg = cx.register.unwrap_or('/'); cx.editor.registers.last_search_register = reg; let columns = vec![ PickerColumn::new("kind", |tag: &Tag, _| tag.kind.as_str().into()), PickerColumn::new("name", |tag: &Tag, _| tag.name.as_str().into()).without_filtering(), PickerColumn::new("path", |tag: &Tag, state: &SearchState| { match &tag.doc { UriOrDocumentId::Uri(uri) => { if let Some(path) = uri.as_path() { let path = if let Ok(stripped) = path.strip_prefix(&state.search_root) { stripped } else { path }; path.to_string_lossy().into() } else { uri.to_string().into() } } // This picker only uses `Id` for scratch buffers for better display. UriOrDocumentId::Id(_) => SCRATCH_BUFFER_NAME.into(), } }), ]; let get_tags = |query: &str, editor: &mut Editor, state: Arc, injector: &Injector<_, _>| { if query.len() < 3 { return async { Ok(()) }.boxed(); } // Attempt to find the tag in any open documents. let pattern = match state.rope_regex_builder.build(query) { Ok(pattern) => pattern, Err(err) => return async { Err(anyhow::anyhow!(err)) }.boxed(), }; let loader = editor.syn_loader.load(); for doc in editor.documents() { let Some(syntax) = doc.syntax() else { continue }; let text = doc.text().slice(..); let uri_or_id = doc .uri() .map(UriOrDocumentId::Uri) .unwrap_or_else(|| UriOrDocumentId::Id(doc.id())); for tag in tags_iter(syntax, &loader, text.slice(..), uri_or_id, Some(&pattern)) { if injector.push(tag).is_err() { return async { Ok(()) }.boxed(); } } } if !state.search_root.exists() { return async { Err(anyhow::anyhow!("Current working directory does not exist")) } .boxed(); } let matcher = match state.regex_matcher_builder.build(query) { Ok(matcher) => { // Clear any "Failed to compile regex" errors out of the statusline. editor.clear_status(); matcher } Err(err) => { log::info!( "Failed to compile search pattern in workspace symbol search: {}", err ); return async { Err(anyhow::anyhow!("Failed to compile regex")) }.boxed(); } }; let pattern = Arc::new(pattern); let injector = injector.clone(); let loader = editor.syn_loader.load(); let documents: HashSet<_> = editor .documents() .filter_map(Document::path) .cloned() .collect(); async move { let searcher = state.searcher_builder.build(); state.walk_builder.build_parallel().run(|| { let mut searcher = searcher.clone(); let matcher = matcher.clone(); let injector = injector.clone(); let loader = loader.clone(); let documents = &documents; let pattern = pattern.clone(); let syntax_cache = &state.syntax_cache; Box::new(move |entry: Result| -> WalkState { let entry = match entry { Ok(entry) => entry, Err(_) => return WalkState::Continue, }; match entry.file_type() { Some(entry) if entry.is_file() => {} // skip everything else _ => return WalkState::Continue, }; let path = entry.path(); // If this document is open, skip it because we've already processed it above. if documents.contains(path) { return WalkState::Continue; }; let mut quit = false; let sink = sinks::UTF8(|_line, _content| { if !syntax_cache.contains_key(path) { // Read the file into a Rope and attempt to recognize the language // and parse it with tree-sitter. Save the Rope and Syntax for future // queries. syntax_cache.insert(path.to_path_buf(), syntax_for_path(path, &loader)); }; let entry = syntax_cache.get(path).unwrap(); let Some((text, syntax)) = entry.value() else { // If the file couldn't be parsed, move on. return Ok(false); }; let uri = Uri::from(path::normalize(path)); for tag in tags_iter( syntax, &loader, text.slice(..), UriOrDocumentId::Uri(uri), Some(&pattern), ) { if injector.push(tag).is_err() { quit = true; break; } } // Quit after seeing the first regex match. We only care to find files // that contain the pattern and then we run the tags query within // those. The location and contents of a match are irrelevant - it's // only important _if_ a file matches. Ok(false) }); if let Err(err) = searcher.search_path(&matcher, path, sink) { log::info!("Workspace syntax search error: {}, {}", path.display(), err); } if quit { WalkState::Quit } else { WalkState::Continue } }) }); Ok(()) } .boxed() }; let picker = Picker::new( columns, 1, // name [], state, move |cx, tag, action| { let doc_id = match &tag.doc { UriOrDocumentId::Id(id) => *id, UriOrDocumentId::Uri(uri) => match cx.editor.open(uri.as_path().expect(""), action) { Ok(id) => id, Err(e) => { cx.editor .set_error(format!("Failed to open file '{uri:?}': {e}")); return; } } }; let doc = doc_mut!(cx.editor, &doc_id); let view = view_mut!(cx.editor); let len_chars = doc.text().len_chars(); if tag.start >= len_chars || tag.end > len_chars { cx.editor.set_error("The location you jumped to does not exist anymore because the file has changed."); return; } doc.set_selection(view.id, Selection::single(tag.start, tag.end)); if action.align_view(view, doc.id()) { align_view(doc, view, Align::Center) } }, ) .with_dynamic_query(get_tags, Some(275)) .with_preview(move |_editor, tag| { Some(( tag.doc.path_or_id()?, Some((tag.start_line, tag.end_line)), )) }) .truncate_start(false); cx.push_layer(Box::new(overlaid(picker))); } /// Create a Rope and language config for a given existing path without creating a full Document. fn syntax_for_path(path: &Path, loader: &Loader) -> Option<(Rope, Syntax)> { let mut file = std::fs::File::open(path).ok()?; let (rope, _encoding, _has_bom) = from_reader(&mut file, None).ok()?; let text = rope.slice(..); let language = loader .language_for_filename(path) .or_else(|| loader.language_for_shebang(text))?; Syntax::new(text, language, loader) .ok() .map(|syntax| (rope, syntax)) }