main
1diff --git a/book/src/editor.md b/book/src/editor.md
2index d2b5016b..b7700866 100644
3--- a/book/src/editor.md
4+++ b/book/src/editor.md
5@@ -173,6 +173,7 @@ ### `[editor.statusline]` Section
6 | `diagnostics` | The number of warnings and/or errors |
7 | `workspace-diagnostics` | The number of warnings and/or errors on workspace |
8 | `selections` | The primary selection index out of the number of active selections |
9+| `search-position` | Current search match and total matches in the view `[<current>/<total>]` |
10 | `primary-selection-length` | The number of characters currently in primary selection |
11 | `position` | The cursor position |
12 | `position-percentage` | The cursor position as a percentage of the total number of lines |
13@@ -349,6 +350,7 @@ ### `[editor.search]` Section
14 |--|--|---------|
15 | `smart-case` | Enable smart case regex searching (case-insensitive unless pattern contains upper case characters) | `true` |
16 | `wrap-around`| Whether the search should wrap after depleting the matches | `true` |
17+| `max-matches`| Maximum number of matches that will be counted for the denominator of `search-position`. Possible values are integers or `"none"` for no limit. | `100` |
18
19 ### `[editor.whitespace]` Section
20
21diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
22index cd11400c..507cb75f 100644
23--- a/helix-term/src/commands.rs
24+++ b/helix-term/src/commands.rs
25@@ -49,8 +49,8 @@
26 Selection, SmallVec, Syntax, Tendril, Transaction,
27 };
28 use helix_view::{
29- document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
30- editor::{Action, Motion},
31+ document::{FormatterError, Mode, SearchMatch, SearchMatchLimit, SCRATCH_BUFFER_NAME},
32+ editor::{Action, Motion, OptionToml, SearchConfig},
33 expansion,
34 info::Info,
35 input::KeyEvent,
36@@ -2276,12 +2276,13 @@ fn search_impl(
37 movement: Movement,
38 direction: Direction,
39 scrolloff: usize,
40- wrap_around: bool,
41+ search_config: &SearchConfig,
42 show_warnings: bool,
43 ) {
44 let (view, doc) = current!(editor);
45 let text = doc.text().slice(..);
46 let selection = doc.selection(view.id);
47+ let wrap_around = search_config.wrap_around;
48
49 // Get the right side of the primary block cursor for forward search, or the
50 // grapheme before the start of the selection for reverse search.
51@@ -2302,54 +2303,126 @@ fn search_impl(
52 // it out, we need to add it back to the position of the selection.
53 let doc = doc!(editor).text().slice(..);
54
55- // use find_at to find the next match after the cursor, loop around the end
56+ let mut all_matches = regex.find_iter(doc.regex_input()).enumerate().peekable();
57+
58+ if all_matches.peek().is_none() {
59+ if show_warnings {
60+ editor.set_error("No matches");
61+ }
62+ return;
63+ }
64+
65+ // We will get the number of the current match and total matches from
66+ // `all_matches`. So, here we look in the iterator until we find exactly
67+ // the match we were looking for (either after or behind `start`). In the
68+ // `Backward` case, in particular, we need to look the match ahead to know
69+ // if this is the one we need.
70+
71 // Careful, `Regex` uses `bytes` as offsets, not character indices!
72 let mut mat = match direction {
73- Direction::Forward => regex.find(doc.regex_input_at_bytes(start..)),
74- Direction::Backward => regex.find_iter(doc.regex_input_at_bytes(..start)).last(),
75+ Direction::Forward => all_matches.by_ref().find(|&(_, m)| m.start() >= start),
76+ Direction::Backward => {
77+ let one_behind = std::iter::once(None).chain(all_matches.by_ref().map(Some));
78+ one_behind
79+ .zip(regex.find_iter(doc.regex_input()))
80+ .find(|&(_, m1)| m1.start() >= start)
81+ .map(|(m0, _)| m0)
82+ .unwrap_or(None)
83+ }
84 };
85
86- if mat.is_none() {
87- if wrap_around {
88- mat = match direction {
89- Direction::Forward => regex.find(doc.regex_input()),
90- Direction::Backward => regex.find_iter(doc.regex_input_at_bytes(start..)).last(),
91- };
92+ if mat.is_none() && !wrap_around {
93+ if show_warnings {
94+ editor.set_error("No more matches");
95 }
96+ return;
97+ }
98+
99+ // If we didn't find a match before, lets wrap the search.
100+ if mat.is_none() {
101 if show_warnings {
102- if wrap_around && mat.is_some() {
103- editor.set_status("Wrapped around document");
104+ editor.set_status("Wrapped around document");
105+ }
106+
107+ let doc = doc!(editor).text().slice(..);
108+ all_matches = regex.find_iter(doc.regex_input()).enumerate().peekable();
109+ mat = match direction {
110+ Direction::Forward => all_matches.by_ref().next(),
111+ Direction::Backward => all_matches.by_ref().last(),
112+ };
113+ }
114+
115+ let (idx, mat) = mat.unwrap();
116+ let match_count = match search_config.max_matches {
117+ OptionToml::None => match all_matches.last() {
118+ None => SearchMatchLimit::Limitless(idx + 1),
119+ Some((last_idx, _)) => SearchMatchLimit::Limitless(last_idx + 1),
120+ },
121+ OptionToml::Some(max) => {
122+ if all_matches.peek().is_none() {
123+ // Case #1: If we consumed `all_matches`, it means that we have
124+ // the last match in `mat`. Hence, we know exactly how many
125+ // matches there are. To respect the `max` option, if it goes
126+ // beyong `max`, we limit the counter to `max`.
127+ if idx + 1 > max {
128+ SearchMatchLimit::Limited(max)
129+ } else {
130+ SearchMatchLimit::Limitless(idx + 1)
131+ }
132 } else {
133- editor.set_error("No more matches");
134+ // Case #2: If we are here, we have at least one match in
135+ // `all_matches`. We need to find the last match that's
136+ // less than `max`. If we find it, we simply return it as a
137+ // `Limitless` denominator because it doesn't go beyong the
138+ // user option. The two remaining cases are `last_idx == max`
139+ // and `None` (when the remaining matches are all greater than
140+ // `max`) for which we return a `Limited` denominator.
141+ match all_matches.take_while(|(idx, _)| idx <= &max).last() {
142+ Some((last_idx, _)) if last_idx < max => {
143+ SearchMatchLimit::Limitless(last_idx + 1)
144+ }
145+ _ => SearchMatchLimit::Limited(max),
146+ }
147 }
148 }
149- }
150+ };
151
152+ // Move the cursor to the match.
153 let (view, doc) = current!(editor);
154 let text = doc.text().slice(..);
155 let selection = doc.selection(view.id);
156
157- if let Some(mat) = mat {
158- let start = text.byte_to_char(mat.start());
159- let end = text.byte_to_char(mat.end());
160+ let start = text.byte_to_char(mat.start());
161+ let end = text.byte_to_char(mat.end());
162
163- if end == 0 {
164- // skip empty matches that don't make sense
165- return;
166- }
167-
168- // Determine range direction based on the primary range
169- let primary = selection.primary();
170- let range = Range::new(start, end).with_direction(primary.direction());
171+ if end == 0 {
172+ // skip empty matches that don't make sense
173+ return;
174+ }
175
176- let selection = match movement {
177- Movement::Extend => selection.clone().push(range),
178- Movement::Move => selection.clone().replace(selection.primary_index(), range),
179- };
180+ // Determine range direction based on the primary range
181+ let primary = selection.primary();
182+ let range = Range::new(start, end).with_direction(primary.direction());
183
184- doc.set_selection(view.id, selection);
185- view.ensure_cursor_in_view_center(doc, scrolloff);
186+ let selection = match movement {
187+ Movement::Extend => selection.clone().push(range),
188+ Movement::Move => selection.clone().replace(selection.primary_index(), range),
189 };
190+
191+ doc.set_selection(view.id, selection);
192+ view.ensure_cursor_in_view_center(doc, scrolloff);
193+
194+ // Set the index of this match and total number of matchs in the doc. It's
195+ // important to set it after `set_selection` since that method resets the
196+ // last match position.
197+ let (view, doc) = current!(editor);
198+ doc.set_last_search_match(
199+ view.id,
200+ SearchMatch {
201+ idx: idx + 1,
202+ count: match_count,
203+ },
204+ );
205 }
206
207 fn search_completions(cx: &mut Context, reg: Option<char>) -> Vec<String> {
208@@ -2373,7 +2446,6 @@ fn searcher(cx: &mut Context, direction: Direction) {
209 let reg = cx.register.unwrap_or('/');
210 let config = cx.editor.config();
211 let scrolloff = config.scrolloff;
212- let wrap_around = config.search.wrap_around;
213 let movement = if cx.editor.mode() == Mode::Select {
214 Movement::Extend
215 } else {
216@@ -2406,7 +2478,7 @@ fn searcher(cx: &mut Context, direction: Direction) {
217 movement,
218 direction,
219 scrolloff,
220- wrap_around,
221+ &config.search,
222 false,
223 );
224 },
225@@ -2427,7 +2499,6 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir
226 } else {
227 false
228 };
229- let wrap_around = search_config.wrap_around;
230 let is_crlf = doc!(cx.editor).line_ending == LineEnding::Crlf;
231 if let Ok(regex) = rope::RegexBuilder::new()
232 .syntax(
233@@ -2445,7 +2516,7 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir
234 movement,
235 direction,
236 scrolloff,
237- wrap_around,
238+ search_config,
239 true,
240 );
241 }
242diff --git a/helix-term/src/commands/engine/steel/mod.rs b/helix-term/src/commands/engine/steel/mod.rs
243index 8a166638..a8f64986 100644
244--- a/helix-term/src/commands/engine/steel/mod.rs
245+++ b/helix-term/src/commands/engine/steel/mod.rs
246@@ -3104,6 +3104,7 @@ fn search(&self, smart_case: bool, wrap_around: bool) {
247 app_config.editor.search = SearchConfig {
248 smart_case,
249 wrap_around,
250+ ..SearchConfig::default()
251 };
252 self.store_config(app_config);
253 }
254diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs
255index acca850d..e71b57d2 100644
256--- a/helix-term/src/ui/statusline.rs
257+++ b/helix-term/src/ui/statusline.rs
258@@ -3,7 +3,7 @@
259 use helix_lsp::lsp::DiagnosticSeverity;
260 use helix_view::document::DEFAULT_LANGUAGE_NAME;
261 use helix_view::{
262- document::{Mode, SCRATCH_BUFFER_NAME},
263+ document::{Mode, SearchMatch, SearchMatchLimit, SCRATCH_BUFFER_NAME},
264 graphics::Rect,
265 theme::Style,
266 Document, Editor,
267@@ -184,6 +184,7 @@ fn get_render_function<'a, F>(
268 helix_view::editor::StatusLineElement::Register => render_register,
269 helix_view::editor::StatusLineElement::CurrentWorkingDirectory => render_cwd,
270 helix_view::editor::StatusLineElement::CodeActionHint => render_code_action_hint,
271+ helix_view::editor::StatusLineElement::SearchPosition => render_search_position,
272 #[cfg(feature = "steel")]
273 _ => unreachable!(),
274 }
275@@ -633,3 +634,17 @@ fn render_code_action_hint<'a, F>(context: &mut RenderContext<'a>, write: F)
276 write(context, " ⋮ ".into())
277 }
278 }
279+
280+fn render_search_position<'a, F>(context: &mut RenderContext<'a>, write: F)
281+where
282+ F: Fn(&mut RenderContext<'a>, Span<'a>) + Copy,
283+{
284+ let doc = get_doc(context);
285+ if let Some(SearchMatch { idx, count }) = doc.get_last_search_match(context.view_id) {
286+ let count_str = match count {
287+ SearchMatchLimit::Limitless(count) => format!("{}", count),
288+ SearchMatchLimit::Limited(max) => format!(">{}", max),
289+ };
290+ write(context, format!(" [{}/{}] ", idx, count_str).into());
291+ }
292+}
293diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
294index b17fd230..6e5fbd98 100644
295--- a/helix-view/src/document.rs
296+++ b/helix-view/src/document.rs
297@@ -139,12 +139,28 @@ pub enum DocumentOpenError {
298 IoError(#[from] io::Error),
299 }
300
301+#[derive(Debug, Clone, Copy)]
302+pub enum SearchMatchLimit {
303+ Limitless(usize),
304+ Limited(usize),
305+}
306+
307+#[derive(Debug, Clone, Copy)]
308+pub struct SearchMatch {
309+ /// nth match from the beginning of the document.
310+ pub idx: usize,
311+ /// Total number of matches in the document.
312+ pub count: SearchMatchLimit,
313+}
314+
315 pub struct Document {
316 pub(crate) id: DocumentId,
317 text: Rope,
318 selections: HashMap<ViewId, Selection>,
319 view_data: HashMap<ViewId, ViewData>,
320 pub active_snippet: Option<ActiveSnippet>,
321+ /// Current search information.
322+ last_search_match: HashMap<ViewId, SearchMatch>,
323
324 /// Inlay hints annotations for the document, by view.
325 ///
326@@ -832,6 +848,7 @@ pub fn from(
327 text,
328 selections: HashMap::default(),
329 inlay_hints: HashMap::default(),
330+ last_search_match: HashMap::default(),
331 inlay_hints_oudated: false,
332 view_data: Default::default(),
333 indent_style: DEFAULT_INDENT,
334@@ -1494,6 +1511,8 @@ pub fn set_language_by_language_id(
335
336 /// Select text within the [`Document`].
337 pub fn set_selection(&mut self, view_id: ViewId, selection: Selection) {
338+ self.last_search_match.remove(&view_id);
339+
340 // TODO: use a transaction?
341 self.selections
342 .insert(view_id, selection.ensure_invariants(self.text().slice(..)));
343@@ -1503,6 +1522,14 @@ pub fn set_selection(&mut self, view_id: ViewId, selection: Selection) {
344 })
345 }
346
347+ pub fn set_last_search_match(&mut self, view_id: ViewId, search_match: SearchMatch) {
348+ self.last_search_match.insert(view_id, search_match);
349+ }
350+
351+ pub fn get_last_search_match(&self, view_id: ViewId) -> Option<SearchMatch> {
352+ self.last_search_match.get(&view_id).copied()
353+ }
354+
355 /// Find the origin selection of the text in a document, i.e. where
356 /// a single cursor would go if it were on the first grapheme. If
357 /// the text is empty, returns (0, 0).
358diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
359index 263a8542..bfa9acd7 100644
360--- a/helix-view/src/editor.rs
361+++ b/helix-view/src/editor.rs
362@@ -687,6 +687,14 @@ fn default() -> Self {
363 }
364 }
365
366+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367+#[serde(rename_all = "kebab-case", deny_unknown_fields)]
368+pub enum OptionToml<T> {
369+ None,
370+ #[serde(untagged)]
371+ Some(T),
372+}
373+
374 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375 #[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
376 pub struct SearchConfig {
377@@ -694,6 +702,8 @@ pub struct SearchConfig {
378 pub smart_case: bool,
379 /// Whether the search should wrap after depleting the matches. Default to true.
380 pub wrap_around: bool,
381+ /// Maximum number of counted matches when searching in a document. `None` means no limit. Default to 100.
382+ pub max_matches: OptionToml<usize>,
383 }
384
385 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386@@ -853,6 +863,9 @@ pub enum StatusLineElement {
387 /// Indicator for selected register
388 Register,
389
390+ /// Search index and count
391+ SearchPosition,
392+
393 /// The base of current working directory
394 CurrentWorkingDirectory,
395
396@@ -1327,6 +1340,7 @@ fn default() -> Self {
397 Self {
398 wrap_around: true,
399 smart_case: true,
400+ max_matches: OptionToml::Some(100),
401 }
402 }
403 }