on master help sign in
Initial implementation of global search (#651)
9456d5cLeoi Hung Kin
Cargo.lock+74
@@ -41,9 +41,17 @@ version = "0.2.16"
41source = "registry+https://github.com/rust-lang/crates.io-index"
41source = "registry+https://github.com/rust-lang/crates.io-index"
42checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279"
42checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279"
43dependencies = [
43dependencies = [
44+ "lazy_static",
44 "memchr",
45 "memchr",
46+ "regex-automata",
45]
47]
46
48
49+[[package]]
50+name = "bytecount"
51+version = "0.6.2"
52+source = "registry+https://github.com/rust-lang/crates.io-index"
53+checksum = "72feb31ffc86498dacdbd0fcebb56138e7177a8cc5cea4516031d15ae85a742e"
54+
47[[package]]
55[[package]]
48name = "bytes"
56name = "bytes"
49version = "1.0.1"
57version = "1.0.1"
@@ -174,6 +182,15 @@ dependencies = [
174 "cfg-if",
182 "cfg-if",
175]
183]
176
184
185+[[package]]
186+name = "encoding_rs_io"
187+version = "0.1.7"
188+source = "registry+https://github.com/rust-lang/crates.io-index"
189+checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83"
190+dependencies = [
191+ "encoding_rs",
192+]
193+
177[[package]]
194[[package]]
178name = "error-code"
195name = "error-code"
179version = "2.3.0"
196version = "2.3.0"
@@ -300,6 +317,45 @@ dependencies = [
300 "regex",
317 "regex",
301]
318]
302
319
320+[[package]]
321+name = "grep-matcher"
322+version = "0.1.5"
323+source = "registry+https://github.com/rust-lang/crates.io-index"
324+checksum = "6d27563c33062cd33003b166ade2bb4fd82db1fd6a86db764dfdad132d46c1cc"
325+dependencies = [
326+ "memchr",
327+]
328+
329+[[package]]
330+name = "grep-regex"
331+version = "0.1.9"
332+source = "registry+https://github.com/rust-lang/crates.io-index"
333+checksum = "121553c9768c363839b92fc2d7cdbbad44a3b70e8d6e7b1b72b05c977527bd06"
334+dependencies = [
335+ "aho-corasick",
336+ "bstr",
337+ "grep-matcher",
338+ "log",
339+ "regex",
340+ "regex-syntax",
341+ "thread_local",
342+]
343+
344+[[package]]
345+name = "grep-searcher"
346+version = "0.1.8"
347+source = "registry+https://github.com/rust-lang/crates.io-index"
348+checksum = "7fbdbde90ba52adc240d2deef7b6ad1f99f53142d074b771fe9b7bede6c4c23d"
349+dependencies = [
350+ "bstr",
351+ "bytecount",
352+ "encoding_rs",
353+ "encoding_rs_io",
354+ "grep-matcher",
355+ "log",
356+ "memmap2",
357+]
358+
303[[package]]
359[[package]]
304name = "helix-core"
360name = "helix-core"
305version = "0.4.1"
361version = "0.4.1"
@@ -361,6 +417,8 @@ dependencies = [
361 "fern",
417 "fern",
362 "futures-util",
418 "futures-util",
363 "fuzzy-matcher",
419 "fuzzy-matcher",
420+ "grep-regex",
421+ "grep-searcher",
364 "helix-core",
422 "helix-core",
365 "helix-lsp",
423 "helix-lsp",
366 "helix-tui",
424 "helix-tui",
@@ -375,6 +433,7 @@ dependencies = [
375 "signal-hook",
433 "signal-hook",
376 "signal-hook-tokio",
434 "signal-hook-tokio",
377 "tokio",
435 "tokio",
436+ "tokio-stream",
378 "toml",
437 "toml",
379]
438]
380
439
@@ -552,6 +611,15 @@ version = "2.4.1"
552source = "registry+https://github.com/rust-lang/crates.io-index"
611source = "registry+https://github.com/rust-lang/crates.io-index"
553checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
612checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
554
613
614+[[package]]
615+name = "memmap2"
616+version = "0.3.1"
617+source = "registry+https://github.com/rust-lang/crates.io-index"
618+checksum = "00b6c2ebff6180198788f5db08d7ce3bc1d0b617176678831a7510825973e357"
619+dependencies = [
620+ "libc",
621+]
622+
555[[package]]
623[[package]]
556name = "mio"
624name = "mio"
557version = "0.7.13"
625version = "0.7.13"
@@ -753,6 +821,12 @@ dependencies = [
753 "regex-syntax",
821 "regex-syntax",
754]
822]
755
823
824+[[package]]
825+name = "regex-automata"
826+version = "0.1.10"
827+source = "registry+https://github.com/rust-lang/crates.io-index"
828+checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
829+
756[[package]]
830[[package]]
757name = "regex-syntax"
831name = "regex-syntax"
758version = "0.6.25"
832version = "0.6.25"
book/src/keymap.md+3−1
@@ -206,8 +206,10 @@ This layer is a kludge of mappings, mostly pickers.
206| `y` | Join and yank selections to clipboard | `yank_joined_to_clipboard` |
206| `y` | Join and yank selections to clipboard | `yank_joined_to_clipboard` |
207| `Y` | Yank main selection to clipboard | `yank_main_selection_to_clipboard` |
207| `Y` | Yank main selection to clipboard | `yank_main_selection_to_clipboard` |
208| `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` |
208| `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` |
209+| `/` | Global search in workspace folder | `global_search` |
209
210
210−
211+> NOTE: Global search display results in a fuzzy picker, use `space + '` to bring it back up after opening a file.
212+
211#### Unimpaired
213#### Unimpaired
212
214
213Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired).
215Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired).
helix-term/Cargo.toml+5
@@ -55,5 +55,10 @@ toml = "0.5"
55serde_json = "1.0"
55serde_json = "1.0"
56serde = { version = "1.0", features = ["derive"] }
56serde = { version = "1.0", features = ["derive"] }
57
57
58+# ripgrep for global search
59+grep-regex = "0.1.9"
60+grep-searcher = "0.1.8"
61+tokio-stream = "0.1.7"
62+
58[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100
63[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100
59signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
64signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
helix-term/src/commands.rs+166−22
@@ -31,7 +31,7 @@ use crate::{
31};
31};
32
32
33use crate::job::{self, Job, Jobs};
33use crate::job::{self, Job, Jobs};
34−use futures_util::FutureExt;
34+use futures_util::{FutureExt, StreamExt};
35use std::num::NonZeroUsize;
35use std::num::NonZeroUsize;
36use std::{fmt, future::Future};
36use std::{fmt, future::Future};
37
37
@@ -43,6 +43,11 @@ use std::{
43use once_cell::sync::Lazy;
43use once_cell::sync::Lazy;
44use serde::de::{self, Deserialize, Deserializer};
44use serde::de::{self, Deserialize, Deserializer};
45
45
46+use grep_regex::RegexMatcher;
47+use grep_searcher::{sinks, BinaryDetection, SearcherBuilder};
48+use ignore::{DirEntry, WalkBuilder, WalkState};
49+use tokio_stream::wrappers::UnboundedReceiverStream;
50+
46pub struct Context<'a> {
51pub struct Context<'a> {
47 pub register: Option<char>,
52 pub register: Option<char>,
48 pub count: Option<NonZeroUsize>,
53 pub count: Option<NonZeroUsize>,
@@ -209,6 +214,7 @@ impl Command {
209 search_next, "Select next search match",
214 search_next, "Select next search match",
210 extend_search_next, "Add next search match to selection",
215 extend_search_next, "Add next search match to selection",
211 search_selection, "Use current selection as search pattern",
216 search_selection, "Use current selection as search pattern",
217+ global_search, "Global Search in workspace folder",
212 extend_line, "Select current line, if already selected, extend to next line",
218 extend_line, "Select current line, if already selected, extend to next line",
213 extend_to_line_bounds, "Extend selection to line bounds (line-wise selection)",
219 extend_to_line_bounds, "Extend selection to line bounds (line-wise selection)",
214 delete_selection, "Delete selection",
220 delete_selection, "Delete selection",
@@ -1061,24 +1067,41 @@ fn select_all(cx: &mut Context) {
1061
1067
1062fn select_regex(cx: &mut Context) {
1068fn select_regex(cx: &mut Context) {
1063 let reg = cx.register.unwrap_or('/');
1069 let reg = cx.register.unwrap_or('/');
1064− let prompt = ui::regex_prompt(cx, "select:".into(), Some(reg), move |view, doc, regex| {
1070+ let prompt = ui::regex_prompt(
1071+ cx,
1072+ "select:".into(),
1073+ Some(reg),
1074+ move |view, doc, regex, event| {
1075+ if event != PromptEvent::Update {
1076+ return;
1077+ }
1065− let text = doc.text().slice(..);
1078+ let text = doc.text().slice(..);
1079+ if let Some(selection) =
1066− if let Some(selection) = selection::select_on_matches(text, doc.selection(view.id), &regex)
1080+ selection::select_on_matches(text, doc.selection(view.id), &regex)
1067− {
1081+ {
1068− doc.set_selection(view.id, selection);
1082+ doc.set_selection(view.id, selection);
1083+ }
1069− }
1084+ },
1070− });
1085+ );
1071
1086
1072 cx.push_layer(Box::new(prompt));
1087 cx.push_layer(Box::new(prompt));
1073}
1088}
1074
1089
1075fn split_selection(cx: &mut Context) {
1090fn split_selection(cx: &mut Context) {
1076 let reg = cx.register.unwrap_or('/');
1091 let reg = cx.register.unwrap_or('/');
1077− let prompt = ui::regex_prompt(cx, "split:".into(), Some(reg), move |view, doc, regex| {
1092+ let prompt = ui::regex_prompt(
1093+ cx,
1094+ "split:".into(),
1095+ Some(reg),
1096+ move |view, doc, regex, event| {
1097+ if event != PromptEvent::Update {
1098+ return;
1099+ }
1078− let text = doc.text().slice(..);
1100+ let text = doc.text().slice(..);
1079− let selection = selection::split_on_matches(text, doc.selection(view.id), &regex);
1101+ let selection = selection::split_on_matches(text, doc.selection(view.id), &regex);
1080− doc.set_selection(view.id, selection);
1102+ doc.set_selection(view.id, selection);
1103+ },
1081− });
1104+ );
1082
1105
1083 cx.push_layer(Box::new(prompt));
1106 cx.push_layer(Box::new(prompt));
1084}
1107}
@@ -1141,9 +1164,17 @@ fn search(cx: &mut Context) {
1141 // feed chunks into the regex yet
1164 // feed chunks into the regex yet
1142 let contents = doc.text().slice(..).to_string();
1165 let contents = doc.text().slice(..).to_string();
1143
1166
1144− let prompt = ui::regex_prompt(cx, "search:".into(), Some(reg), move |view, doc, regex| {
1167+ let prompt = ui::regex_prompt(
1168+ cx,
1169+ "search:".into(),
1170+ Some(reg),
1171+ move |view, doc, regex, event| {
1172+ if event != PromptEvent::Update {
1173+ return;
1174+ }
1145− search_impl(doc, view, &contents, &regex, false);
1175+ search_impl(doc, view, &contents, &regex, false);
1176+ },
1146− });
1177+ );
1147
1178
1148 cx.push_layer(Box::new(prompt));
1179 cx.push_layer(Box::new(prompt));
1149}
1180}
@@ -1192,6 +1223,111 @@ fn search_selection(cx: &mut Context) {
1192 cx.editor.set_status(msg);
1223 cx.editor.set_status(msg);
1193}
1224}
1194
1225
1226+fn global_search(cx: &mut Context) {
1227+ let (all_matches_sx, all_matches_rx) =
1228+ tokio::sync::mpsc::unbounded_channel::<(usize, PathBuf)>();
1229+ let prompt = ui::regex_prompt(
1230+ cx,
1231+ "global search:".into(),
1232+ None,
1233+ move |_view, _doc, regex, event| {
1234+ if event != PromptEvent::Validate {
1235+ return;
1236+ }
1237+ if let Ok(matcher) = RegexMatcher::new_line_matcher(regex.as_str()) {
1238+ let searcher = SearcherBuilder::new()
1239+ .binary_detection(BinaryDetection::quit(b'\x00'))
1240+ .build();
1241+
1242+ let search_root = std::env::current_dir()
1243+ .expect("Global search error: Failed to get current dir");
1244+ WalkBuilder::new(search_root).build_parallel().run(|| {
1245+ let mut searcher_cl = searcher.clone();
1246+ let matcher_cl = matcher.clone();
1247+ let all_matches_sx_cl = all_matches_sx.clone();
1248+ Box::new(move |dent: Result<DirEntry, ignore::Error>| -> WalkState {
1249+ let dent = match dent {
1250+ Ok(dent) => dent,
1251+ Err(_) => return WalkState::Continue,
1252+ };
1253+
1254+ match dent.file_type() {
1255+ Some(fi) => {
1256+ if !fi.is_file() {
1257+ return WalkState::Continue;
1258+ }
1259+ }
1260+ None => return WalkState::Continue,
1261+ }
1262+
1263+ let result_sink = sinks::UTF8(|line_num, _| {
1264+ match all_matches_sx_cl
1265+ .send((line_num as usize - 1, dent.path().to_path_buf()))
1266+ {
1267+ Ok(_) => Ok(true),
1268+ Err(_) => Ok(false),
1269+ }
1270+ });
1271+ let result = searcher_cl.search_path(&matcher_cl, dent.path(), result_sink);
1272+
1273+ if let Err(err) = result {
1274+ log::error!("Global search error: {}, {}", dent.path().display(), err);
1275+ }
1276+ WalkState::Continue
1277+ })
1278+ });
1279+ } else {
1280+ // Otherwise do nothing
1281+ // log::warn!("Global Search Invalid Pattern")
1282+ }
1283+ },
1284+ );
1285+
1286+ cx.push_layer(Box::new(prompt));
1287+
1288+ let show_picker = async move {
1289+ let all_matches: Vec<(usize, PathBuf)> =
1290+ UnboundedReceiverStream::new(all_matches_rx).collect().await;
1291+ let call: job::Callback =
1292+ Box::new(move |editor: &mut Editor, compositor: &mut Compositor| {
1293+ if all_matches.is_empty() {
1294+ editor.set_status("No matches found".to_string());
1295+ return;
1296+ }
1297+ let picker = FilePicker::new(
1298+ all_matches,
1299+ move |(_line_num, path)| path.to_str().unwrap().into(),
1300+ move |editor: &mut Editor, (line_num, path), action| {
1301+ match editor.open(path.into(), action) {
1302+ Ok(_) => {}
1303+ Err(e) => {
1304+ editor.set_error(format!(
1305+ "Failed to open file '{}': {}",
1306+ path.display(),
1307+ e
1308+ ));
1309+ return;
1310+ }
1311+ }
1312+
1313+ let line_num = *line_num;
1314+ let (view, doc) = current!(editor);
1315+ let text = doc.text();
1316+ let start = text.line_to_char(line_num);
1317+ let end = text.line_to_char((line_num + 1).min(text.len_lines()));
1318+
1319+ doc.set_selection(view.id, Selection::single(start, end));
1320+ align_view(doc, view, Align::Center);
1321+ },
1322+ |_editor, (line_num, path)| Some((path.clone(), Some((*line_num, *line_num)))),
1323+ );
1324+ compositor.push(Box::new(picker));
1325+ });
1326+ Ok(call)
1327+ };
1328+ cx.jobs.callback(show_picker);
1329+}
1330+
1195fn extend_line(cx: &mut Context) {
1331fn extend_line(cx: &mut Context) {
1196 let count = cx.count();
1332 let count = cx.count();
1197 let (view, doc) = current!(cx.editor);
1333 let (view, doc) = current!(cx.editor);
@@ -3847,13 +3983,21 @@ fn join_selections(cx: &mut Context) {
3847fn keep_selections(cx: &mut Context) {
3983fn keep_selections(cx: &mut Context) {
3848 // keep selections matching regex
3984 // keep selections matching regex
3849 let reg = cx.register.unwrap_or('/');
3985 let reg = cx.register.unwrap_or('/');
3850− let prompt = ui::regex_prompt(cx, "keep:".into(), Some(reg), move |view, doc, regex| {
3986+ let prompt = ui::regex_prompt(
3987+ cx,
3988+ "keep:".into(),
3989+ Some(reg),
3990+ move |view, doc, regex, event| {
3991+ if event != PromptEvent::Update {
3992+ return;
3993+ }
3851− let text = doc.text().slice(..);
3994+ let text = doc.text().slice(..);
3852
3995
3853− if let Some(selection) = selection::keep_matches(text, doc.selection(view.id), &regex) {
3996+ if let Some(selection) = selection::keep_matches(text, doc.selection(view.id), &regex) {
3854− doc.set_selection(view.id, selection);
3997+ doc.set_selection(view.id, selection);
3998+ }
3855− }
3999+ },
3856− });
4000+ );
3857
4001
3858 cx.push_layer(Box::new(prompt));
4002 cx.push_layer(Box::new(prompt));
3859}
4003}
helix-term/src/keymap.rs+1
@@ -555,6 +555,7 @@ impl Default for Keymaps {
555 "P" => paste_clipboard_before,
555 "P" => paste_clipboard_before,
556 "R" => replace_selections_with_clipboard,
556 "R" => replace_selections_with_clipboard,
557 "space" => keep_primary_selection,
557 "space" => keep_primary_selection,
558+ "/" => global_search,
558 },
559 },
559 "z" => { "View"
560 "z" => { "View"
560 "z" | "c" => align_view_center,
561 "z" | "c" => align_view_center,
helix-term/src/ui/mod.rs+10−2
@@ -29,7 +29,7 @@ pub fn regex_prompt(
29 cx: &mut crate::commands::Context,
29 cx: &mut crate::commands::Context,
30 prompt: std::borrow::Cow<'static, str>,
30 prompt: std::borrow::Cow<'static, str>,
31 history_register: Option<char>,
31 history_register: Option<char>,
32− fun: impl Fn(&mut View, &mut Document, Regex) + 'static,
32+ fun: impl Fn(&mut View, &mut Document, Regex, PromptEvent) + 'static,
33) -> Prompt {
33) -> Prompt {
34 let (view, doc) = current!(cx.editor);
34 let (view, doc) = current!(cx.editor);
35 let view_id = view.id;
35 let view_id = view.id;
@@ -47,6 +47,14 @@ pub fn regex_prompt(
47 }
47 }
48 PromptEvent::Validate => {
48 PromptEvent::Validate => {
49 // TODO: push_jump to store selection just before jump
49 // TODO: push_jump to store selection just before jump
50+
51+ match Regex::new(input) {
52+ Ok(regex) => {
53+ let (view, doc) = current!(cx.editor);
54+ fun(view, doc, regex, event);
55+ }
56+ Err(_err) => (), // TODO: mark command line as error
57+ }
50 }
58 }
51 PromptEvent::Update => {
59 PromptEvent::Update => {
52 // skip empty input, TODO: trigger default
60 // skip empty input, TODO: trigger default
@@ -70,7 +78,7 @@ pub fn regex_prompt(
70 // revert state to what it was before the last update
78 // revert state to what it was before the last update
71 doc.set_selection(view.id, snapshot.clone());
79 doc.set_selection(view.id, snapshot.clone());
72
80
73− fun(view, doc, regex);
81+ fun(view, doc, regex, event);
74
82
75 view.ensure_cursor_in_view(doc, cx.editor.config.scrolloff);
83 view.ensure_cursor_in_view(doc, cx.editor.config.scrolloff);
76 }
84 }