diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md index 762403c8c..5d07174cc 100644 --- a/book/src/generated/static-cmd.md +++ b/book/src/generated/static-cmd.md @@ -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: `` / ``, select: `` / `` | -| `local_search` | Local search in buffer | normal: `` l ``, select: `` l `` | +| `local_search_grep` | Local search in buffer | normal: `` l ``, select: `` l `` | +| `local_search_fuzzy` | Fuzzy local search in buffer | normal: `` L ``, select: `` 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 | | diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 6abe5c97f..c74690fa9 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -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, + 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 = 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, diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 1352ecd0c..178ca8a23 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -282,7 +282,8 @@ pub fn default() -> HashMap { "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,