helix/helix-term/src/commands/syntax.rs

447 lines
16 KiB
Rust

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<Self> {
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<PathOrId<'_>> {
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<Item = Tag> + '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<PathBuf, Option<(Rope, Syntax)>>,
}
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<SearchState>,
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<DirEntry, ignore::Error>| -> 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))
}