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 }