Merge pull request #1 from ivanrg99/local-search-buffer-fuzzy

Add fuzzy searching.

All items (in this case, lines) are shown at once since that's the norm in fuzzy searchers.

The unsafe is used to avoid extra memory allocations. If we use the text() method on the Document and get the Rope, we still need to iterate over the lines, but we will have to allocate a String every time so we can trim it, and also add it to the results to be injected later into the Injector. Using this feature in a file with 10000 lines would allocate 10000 times!
pull/13053/head
oxcrow 2025-04-04 01:36:01 +05:30 committed by GitHub
commit 74a4c613e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 157 additions and 7 deletions

View File

@ -80,7 +80,8 @@
| `search_selection_detect_word_boundaries` | Use current selection as the search pattern, automatically wrapping with `\b` on word boundaries | normal: `` * ``, select: `` * `` |
| `make_search_word_bounded` | Modify current search to make it word bounded | |
| `global_search` | Global search in workspace folder | normal: `` <space>/ ``, select: `` <space>/ `` |
| `local_search` | Local search in buffer | normal: `` <space>l ``, select: `` <space>l `` |
| `local_search_grep` | Local search in buffer | normal: `` <space>l ``, select: `` <space>l `` |
| `local_search_fuzzy` | Fuzzy local search in buffer | normal: `` <space>L ``, select: `` <space>L `` |
| `extend_line` | Select current line, if already selected, extend to another line based on the anchor | |
| `extend_line_below` | Select current line, if already selected, extend to next line | normal: `` x ``, select: `` x `` |
| `extend_line_above` | Select current line, if already selected, extend to previous line | |

View File

@ -379,7 +379,8 @@ impl MappableCommand {
search_selection_detect_word_boundaries, "Use current selection as the search pattern, automatically wrapping with `\\b` on word boundaries",
make_search_word_bounded, "Modify current search to make it word bounded",
global_search, "Global search in workspace folder",
local_search, "Local search in buffer",
local_search_grep, "Local search in buffer",
local_search_fuzzy, "Fuzzy local search in buffer",
extend_line, "Select current line, if already selected, extend to another line based on the anchor",
extend_line_below, "Select current line, if already selected, extend to next line",
extend_line_above, "Select current line, if already selected, extend to previous line",
@ -2647,8 +2648,8 @@ fn global_search(cx: &mut Context) {
cx.push_layer(Box::new(overlaid(picker)));
}
/// Local search in buffer
fn local_search(cx: &mut Context) {
/// Local grep search in buffer
fn local_search_grep(cx: &mut Context) {
#[derive(Debug)]
struct FileResult {
path: PathBuf,
@ -2696,9 +2697,8 @@ fn local_search(cx: &mut Context) {
}),
PickerColumn::new("", |item: &FileResult, _config: &LocalSearchConfig| {
// extract line content to be displayed in the picker
let line_content = item.line_content.clone();
// create column value to be displayed in the picker
Cell::from(Spans::from(vec![Span::raw(line_content)]))
Cell::from(Spans::from(vec![Span::raw(&item.line_content)]))
}),
];
@ -2889,6 +2889,154 @@ fn local_search(cx: &mut Context) {
cx.push_layer(Box::new(overlaid(picker)));
}
fn local_search_fuzzy(cx: &mut Context) {
#[derive(Debug)]
struct FileResult {
path: std::sync::Arc<PathBuf>,
line_num: usize,
file_contents_byte_start: usize,
file_contents_byte_end: usize,
}
struct LocalSearchData {
file_contents: String,
number_style: Style,
}
let current_document = doc!(cx.editor);
let Some(current_document_path) = current_document.path() else {
cx.editor.set_error("Failed to get current document path");
return;
};
let file_contents = std::fs::read_to_string(current_document_path).unwrap();
let current_document_path = std::sync::Arc::new(current_document_path.clone());
let file_results: Vec<FileResult> = file_contents
.lines()
.enumerate()
.filter_map(|(line_num, line)| {
if !line.trim().is_empty() {
// SAFETY: The offsets will be used to index back into the original `file_contents` String
// as a byte slice. Since the `file_contents` will be moved into the `Picker` as part of
// `editor_data`, we know that the `Picker` will take ownership of the underlying String,
// so it will be valid for displaying the `Span` as long as the user uses the `Picker`
// (the `Picker` gets dropped only when a new `Picker` is created). Furthermore, the
// process of reconstructing a `&str` back requires that we have access to the original
// `String` anyways so we can index into it, as is the case when we construct the `Span`
// when creating the `PickerColumn`s, so we know that we are returning the correct
// substring from the original `file_contents`.
// In fact, since we only store offsets, and accessing them from safe rust, there is
// no risk of memory safety (like our &str not living long enough). The only real
// bug would be moving out the original underlying `String` (which we obviously
// don't do). This would lead to an out of bounds crash in the `PickerColumn` function
// call, or a crash when we recreate back the &str if the new underlying `String`
// makes it so that our byte offsets index into the middle of a Unicode grapheme cluster.
// Last but not least, it could make it so that we do display the lines correctly,
// but these are from a different underlying `String` than the original, which would be
// different from the lines in the current buffer.
let beg =
unsafe { line.as_ptr().byte_offset_from(file_contents.as_ptr()) } as usize;
let end = beg + line.len();
let result = FileResult {
path: current_document_path.clone(),
line_num,
file_contents_byte_start: beg,
file_contents_byte_end: end,
};
Some(result)
} else {
None
}
})
.collect();
let config = LocalSearchData {
number_style: cx.editor.theme.get("constant.numeric.integer"),
file_contents,
};
let columns = [
PickerColumn::new("line", |item: &FileResult, config: &LocalSearchData| {
let line_num = (item.line_num + 1).to_string();
// files can never contain more than 99_999_999 lines
// thus using maximum line length to be 8 for this formatter is valid
let max_line_num_length = 8;
// whitespace padding to align results after the line number
let padding_length = max_line_num_length - line_num.len();
let padding = " ".repeat(padding_length);
// create column value to be displayed in the picker
Cell::from(Spans::from(vec![
Span::styled(line_num, config.number_style),
Span::raw(padding),
]))
}),
PickerColumn::new("", |item: &FileResult, config: &LocalSearchData| {
// extract line content to be displayed in the picker
let slice = &config.file_contents.as_bytes()
[item.file_contents_byte_start..item.file_contents_byte_end];
let content = std::str::from_utf8(slice).unwrap();
// create column value to be displayed in the picker
Cell::from(Spans::from(vec![Span::raw(content)]))
}),
];
let reg = cx.register.unwrap_or('/');
cx.editor.registers.last_search_register = reg;
let picker = Picker::new(
columns,
1, // contents
[],
config,
move |cx, FileResult { path, line_num, .. }, action| {
let doc = match cx.editor.open(path, action) {
Ok(id) => doc_mut!(cx.editor, &id),
Err(e) => {
cx.editor
.set_error(format!("Failed to open file '{}': {}", path.display(), e));
return;
}
};
let line_num = *line_num;
let view = view_mut!(cx.editor);
let text = doc.text();
if line_num >= text.len_lines() {
cx.editor.set_error(
"The line you jumped to does not exist anymore because the file has changed.",
);
return;
}
let start = text.line_to_char(line_num);
let end = text.line_to_char((line_num + 1).min(text.len_lines()));
doc.set_selection(view.id, Selection::single(start, end));
if action.align_view(view, doc.id()) {
align_view(doc, view, Align::Center);
}
},
)
.with_preview(|_editor, FileResult { path, line_num, .. }| {
Some((path.as_path().into(), Some((*line_num, *line_num))))
})
.with_history_register(Some(reg));
let injector = picker.injector();
let timeout = std::time::Instant::now() + std::time::Duration::from_millis(30);
for file_result in file_results {
if injector.push(file_result).is_err() {
break;
}
if std::time::Instant::now() >= timeout {
break;
}
}
cx.push_layer(Box::new(overlaid(picker)));
}
enum Extend {
Above,
Below,

View File

@ -282,7 +282,8 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"P" => paste_clipboard_before,
"R" => replace_selections_with_clipboard,
"/" => global_search,
"l" => local_search,
"l" => local_search_grep,
"L" => local_search_fuzzy,
"k" => hover,
"r" => rename_symbol,
"h" => select_references_to_symbol_under_cursor,