streaming_fuzzy_matcher.rs

  1use language::{Point, TextBufferSnapshot};
  2use std::{cmp, ops::Range};
  3
  4const REPLACEMENT_COST: u32 = 1;
  5const INSERTION_COST: u32 = 3;
  6const DELETION_COST: u32 = 10;
  7
  8/// A streaming fuzzy matcher that can process text chunks incrementally
  9/// and return the best match found so far at each step.
 10pub struct StreamingFuzzyMatcher {
 11    snapshot: TextBufferSnapshot,
 12    query_lines: Vec<String>,
 13    line_hint: Option<u32>,
 14    incomplete_line: String,
 15    matches: Vec<Range<usize>>,
 16    matrix: SearchMatrix,
 17}
 18
 19impl StreamingFuzzyMatcher {
 20    pub fn new(snapshot: TextBufferSnapshot) -> Self {
 21        let buffer_line_count = snapshot.max_point().row as usize + 1;
 22        Self {
 23            snapshot,
 24            query_lines: Vec::new(),
 25            line_hint: None,
 26            incomplete_line: String::new(),
 27            matches: Vec::new(),
 28            matrix: SearchMatrix::new(buffer_line_count + 1),
 29        }
 30    }
 31
 32    /// Returns the query lines.
 33    pub fn query_lines(&self) -> &[String] {
 34        &self.query_lines
 35    }
 36
 37    /// Push a new chunk of text and get the best match found so far.
 38    ///
 39    /// This method accumulates text chunks and processes complete lines.
 40    /// Partial lines are buffered internally until a newline is received.
 41    ///
 42    /// # Returns
 43    ///
 44    /// Returns `Some(range)` if a match has been found with the accumulated
 45    /// query so far, or `None` if no suitable match exists yet.
 46    pub fn push(&mut self, chunk: &str, line_hint: Option<u32>) -> Option<Range<usize>> {
 47        // Add the chunk to our incomplete line buffer
 48        self.incomplete_line.push_str(chunk);
 49        self.line_hint = line_hint;
 50
 51        if let Some((last_pos, _)) = self.incomplete_line.match_indices('\n').next_back() {
 52            let complete_part = &self.incomplete_line[..=last_pos];
 53
 54            // Split into lines and add to query_lines
 55            for line in complete_part.lines() {
 56                self.query_lines.push(line.to_string());
 57            }
 58
 59            self.incomplete_line.replace_range(..last_pos + 1, "");
 60
 61            self.matches = self.resolve_location_fuzzy();
 62        }
 63
 64        let best_match = self.select_best_match();
 65        best_match.or_else(|| self.matches.first().cloned())
 66    }
 67
 68    /// Finish processing and return the final best match(es).
 69    ///
 70    /// This processes any remaining incomplete line before returning the final
 71    /// match result.
 72    pub fn finish(&mut self) -> Vec<Range<usize>> {
 73        // Process any remaining incomplete line
 74        if !self.incomplete_line.is_empty() {
 75            self.query_lines.push(self.incomplete_line.clone());
 76            self.incomplete_line.clear();
 77            self.matches = self.resolve_location_fuzzy();
 78        }
 79        self.matches.clone()
 80    }
 81
 82    fn resolve_location_fuzzy(&mut self) -> Vec<Range<usize>> {
 83        let new_query_line_count = self.query_lines.len();
 84        let old_query_line_count = self.matrix.rows.saturating_sub(1);
 85        if new_query_line_count == old_query_line_count {
 86            return Vec::new();
 87        }
 88
 89        self.matrix.resize_rows(new_query_line_count + 1);
 90
 91        // Process only the new query lines
 92        for row in old_query_line_count..new_query_line_count {
 93            let query_line = self.query_lines[row].trim();
 94            let leading_deletion_cost = (row + 1) as u32 * DELETION_COST;
 95
 96            self.matrix.set(
 97                row + 1,
 98                0,
 99                SearchState::new(leading_deletion_cost, SearchDirection::Up),
100            );
101
102            let mut buffer_lines = self.snapshot.as_rope().chunks().lines();
103            let mut col = 0;
104            while let Some(buffer_line) = buffer_lines.next() {
105                let buffer_line = buffer_line.trim();
106                let up = SearchState::new(
107                    self.matrix
108                        .get(row, col + 1)
109                        .cost
110                        .saturating_add(DELETION_COST),
111                    SearchDirection::Up,
112                );
113                let left = SearchState::new(
114                    self.matrix
115                        .get(row + 1, col)
116                        .cost
117                        .saturating_add(INSERTION_COST),
118                    SearchDirection::Left,
119                );
120                let diagonal = SearchState::new(
121                    if query_line == buffer_line {
122                        self.matrix.get(row, col).cost
123                    } else if fuzzy_eq(query_line, buffer_line) {
124                        self.matrix.get(row, col).cost + REPLACEMENT_COST
125                    } else {
126                        self.matrix
127                            .get(row, col)
128                            .cost
129                            .saturating_add(DELETION_COST + INSERTION_COST)
130                    },
131                    SearchDirection::Diagonal,
132                );
133                self.matrix
134                    .set(row + 1, col + 1, up.min(left).min(diagonal));
135                col += 1;
136            }
137        }
138
139        // Find all matches with the best cost
140        let buffer_line_count = self.snapshot.max_point().row as usize + 1;
141        let mut best_cost = u32::MAX;
142        let mut matches_with_best_cost = Vec::new();
143
144        for col in 1..=buffer_line_count {
145            let cost = self.matrix.get(new_query_line_count, col).cost;
146            if cost < best_cost {
147                best_cost = cost;
148                matches_with_best_cost.clear();
149                matches_with_best_cost.push(col as u32);
150            } else if cost == best_cost {
151                matches_with_best_cost.push(col as u32);
152            }
153        }
154
155        // Find ranges for the matches
156        let mut valid_matches = Vec::new();
157        for &buffer_row_end in &matches_with_best_cost {
158            let mut matched_lines = 0;
159            let mut query_row = new_query_line_count;
160            let mut buffer_row_start = buffer_row_end;
161            while query_row > 0 && buffer_row_start > 0 {
162                let current = self.matrix.get(query_row, buffer_row_start as usize);
163                match current.direction {
164                    SearchDirection::Diagonal => {
165                        query_row -= 1;
166                        buffer_row_start -= 1;
167                        matched_lines += 1;
168                    }
169                    SearchDirection::Up => {
170                        query_row -= 1;
171                    }
172                    SearchDirection::Left => {
173                        buffer_row_start -= 1;
174                    }
175                }
176            }
177
178            let matched_buffer_row_count = buffer_row_end - buffer_row_start;
179            let matched_ratio = matched_lines as f32
180                / (matched_buffer_row_count as f32).max(new_query_line_count as f32);
181            if matched_ratio >= 0.8 {
182                let buffer_start_ix = self
183                    .snapshot
184                    .point_to_offset(Point::new(buffer_row_start, 0));
185                let buffer_end_ix = self.snapshot.point_to_offset(Point::new(
186                    buffer_row_end - 1,
187                    self.snapshot.line_len(buffer_row_end - 1),
188                ));
189                valid_matches.push((buffer_row_start, buffer_start_ix..buffer_end_ix));
190            }
191        }
192
193        valid_matches.into_iter().map(|(_, range)| range).collect()
194    }
195
196    /// Return the best match with starting position close enough to line_hint.
197    pub fn select_best_match(&self) -> Option<Range<usize>> {
198        // Allow line hint to be off by that many lines.
199        // Higher values increase probability of applying edits to a wrong place,
200        // Lower values increase edits failures and overall conversation length.
201        const LINE_HINT_TOLERANCE: u32 = 200;
202
203        if self.matches.is_empty() {
204            return None;
205        }
206
207        if self.matches.len() == 1 {
208            return self.matches.first().cloned();
209        }
210
211        let Some(line_hint) = self.line_hint else {
212            // Multiple ambiguous matches
213            return None;
214        };
215
216        let mut best_match = None;
217        let mut best_distance = u32::MAX;
218
219        for range in &self.matches {
220            let start_point = self.snapshot.offset_to_point(range.start);
221            let start_line = start_point.row;
222            let distance = start_line.abs_diff(line_hint);
223
224            if distance <= LINE_HINT_TOLERANCE && distance < best_distance {
225                best_distance = distance;
226                best_match = Some(range.clone());
227            }
228        }
229
230        best_match
231    }
232}
233
234fn fuzzy_eq(left: &str, right: &str) -> bool {
235    const THRESHOLD: f64 = 0.8;
236
237    let min_levenshtein = left.len().abs_diff(right.len());
238    let min_normalized_levenshtein =
239        1. - (min_levenshtein as f64 / cmp::max(left.len(), right.len()) as f64);
240    if min_normalized_levenshtein < THRESHOLD {
241        return false;
242    }
243
244    strsim::normalized_levenshtein(left, right) >= THRESHOLD
245}
246
247#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
248enum SearchDirection {
249    Up,
250    Left,
251    Diagonal,
252}
253
254#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
255struct SearchState {
256    cost: u32,
257    direction: SearchDirection,
258}
259
260impl SearchState {
261    fn new(cost: u32, direction: SearchDirection) -> Self {
262        Self { cost, direction }
263    }
264}
265
266struct SearchMatrix {
267    cols: usize,
268    rows: usize,
269    data: Vec<SearchState>,
270}
271
272impl SearchMatrix {
273    fn new(cols: usize) -> Self {
274        SearchMatrix {
275            cols,
276            rows: 0,
277            data: Vec::new(),
278        }
279    }
280
281    fn resize_rows(&mut self, needed_rows: usize) {
282        debug_assert!(needed_rows > self.rows);
283        self.rows = needed_rows;
284        self.data.resize(
285            self.rows * self.cols,
286            SearchState::new(0, SearchDirection::Diagonal),
287        );
288    }
289
290    fn get(&self, row: usize, col: usize) -> SearchState {
291        debug_assert!(row < self.rows && col < self.cols);
292        self.data[row * self.cols + col]
293    }
294
295    fn set(&mut self, row: usize, col: usize, state: SearchState) {
296        debug_assert!(row < self.rows && col < self.cols);
297        self.data[row * self.cols + col] = state;
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use indoc::indoc;
305    use language::{BufferId, TextBuffer};
306    use rand::prelude::*;
307    use text::ReplicaId;
308    use util::test::{generate_marked_text, marked_text_ranges};
309
310    #[test]
311    fn test_empty_query() {
312        let buffer = TextBuffer::new(
313            ReplicaId::LOCAL,
314            BufferId::new(1).unwrap(),
315            "Hello world\nThis is a test\nFoo bar baz",
316        );
317        let snapshot = buffer.snapshot();
318
319        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
320        assert_eq!(push(&mut finder, ""), None);
321        assert_eq!(finish(finder), None);
322    }
323
324    #[test]
325    fn test_streaming_exact_match() {
326        let buffer = TextBuffer::new(
327            ReplicaId::LOCAL,
328            BufferId::new(1).unwrap(),
329            "Hello world\nThis is a test\nFoo bar baz",
330        );
331        let snapshot = buffer.snapshot();
332
333        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
334
335        // Push partial query
336        assert_eq!(push(&mut finder, "This"), None);
337
338        // Complete the line
339        assert_eq!(
340            push(&mut finder, " is a test\n"),
341            Some("This is a test".to_string())
342        );
343
344        // Finish should return the same result
345        assert_eq!(finish(finder), Some("This is a test".to_string()));
346    }
347
348    #[test]
349    fn test_streaming_fuzzy_match() {
350        let buffer = TextBuffer::new(
351            ReplicaId::LOCAL,
352            BufferId::new(1).unwrap(),
353            indoc! {"
354                function foo(a, b) {
355                    return a + b;
356                }
357
358                function bar(x, y) {
359                    return x * y;
360                }
361            "},
362        );
363        let snapshot = buffer.snapshot();
364
365        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
366
367        // Push a fuzzy query that should match the first function
368        assert_eq!(
369            push(&mut finder, "function foo(a, c) {\n").as_deref(),
370            Some("function foo(a, b) {")
371        );
372        assert_eq!(
373            push(&mut finder, "    return a + c;\n}\n").as_deref(),
374            Some(concat!(
375                "function foo(a, b) {\n",
376                "    return a + b;\n",
377                "}"
378            ))
379        );
380    }
381
382    #[test]
383    fn test_incremental_improvement() {
384        let buffer = TextBuffer::new(
385            ReplicaId::LOCAL,
386            BufferId::new(1).unwrap(),
387            "Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
388        );
389        let snapshot = buffer.snapshot();
390
391        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
392
393        // No match initially
394        assert_eq!(push(&mut finder, "Lin"), None);
395
396        // Get a match when we complete a line
397        assert_eq!(push(&mut finder, "e 3\n"), Some("Line 3".to_string()));
398
399        // The match might change if we add more specific content
400        assert_eq!(
401            push(&mut finder, "Line 4\n"),
402            Some("Line 3\nLine 4".to_string())
403        );
404        assert_eq!(finish(finder), Some("Line 3\nLine 4".to_string()));
405    }
406
407    #[test]
408    fn test_incomplete_lines_buffering() {
409        let buffer = TextBuffer::new(
410            ReplicaId::LOCAL,
411            BufferId::new(1).unwrap(),
412            indoc! {"
413                The quick brown fox
414                jumps over the lazy dog
415                Pack my box with five dozen liquor jugs
416            "},
417        );
418        let snapshot = buffer.snapshot();
419
420        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
421
422        // Push text in small chunks across line boundaries
423        assert_eq!(push(&mut finder, "jumps "), None); // No newline yet
424        assert_eq!(push(&mut finder, "over the"), None); // Still no newline
425        assert_eq!(push(&mut finder, " lazy"), None); // Still incomplete
426
427        // Complete the line
428        assert_eq!(
429            push(&mut finder, " dog\n"),
430            Some("jumps over the lazy dog".to_string())
431        );
432    }
433
434    #[test]
435    fn test_multiline_fuzzy_match() {
436        let buffer = TextBuffer::new(
437            ReplicaId::LOCAL,
438            BufferId::new(1).unwrap(),
439            indoc! {r#"
440                impl Display for User {
441                    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
442                        write!(f, "User: {} ({})", self.name, self.email)
443                    }
444                }
445
446                impl Debug for User {
447                    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
448                        f.debug_struct("User")
449                            .field("name", &self.name)
450                            .field("email", &self.email)
451                            .finish()
452                    }
453                }
454            "#},
455        );
456        let snapshot = buffer.snapshot();
457
458        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
459
460        assert_eq!(
461            push(&mut finder, "impl Debug for User {\n"),
462            Some("impl Debug for User {".to_string())
463        );
464        assert_eq!(
465            push(
466                &mut finder,
467                "    fn fmt(&self, f: &mut Formatter) -> Result {\n"
468            )
469            .as_deref(),
470            Some(concat!(
471                "impl Debug for User {\n",
472                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {"
473            ))
474        );
475        assert_eq!(
476            push(&mut finder, "        f.debug_struct(\"User\")\n").as_deref(),
477            Some(concat!(
478                "impl Debug for User {\n",
479                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {\n",
480                "        f.debug_struct(\"User\")"
481            ))
482        );
483        assert_eq!(
484            push(
485                &mut finder,
486                "            .field(\"name\", &self.username)\n"
487            )
488            .as_deref(),
489            Some(concat!(
490                "impl Debug for User {\n",
491                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {\n",
492                "        f.debug_struct(\"User\")\n",
493                "            .field(\"name\", &self.name)"
494            ))
495        );
496        assert_eq!(
497            finish(finder).as_deref(),
498            Some(concat!(
499                "impl Debug for User {\n",
500                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {\n",
501                "        f.debug_struct(\"User\")\n",
502                "            .field(\"name\", &self.name)"
503            ))
504        );
505    }
506
507    #[gpui::test(iterations = 100)]
508    fn test_resolve_location_single_line(mut rng: StdRng) {
509        assert_location_resolution(
510            concat!(
511                "    Lorem\n",
512                "«    ipsum»\n",
513                "    dolor sit amet\n",
514                "    consecteur",
515            ),
516            "ipsum",
517            &mut rng,
518        );
519    }
520
521    #[gpui::test(iterations = 100)]
522    fn test_resolve_location_multiline(mut rng: StdRng) {
523        assert_location_resolution(
524            concat!(
525                "    Lorem\n",
526                "«    ipsum\n",
527                "    dolor sit amet»\n",
528                "    consecteur",
529            ),
530            "ipsum\ndolor sit amet",
531            &mut rng,
532        );
533    }
534
535    #[gpui::test(iterations = 100)]
536    fn test_resolve_location_function_with_typo(mut rng: StdRng) {
537        assert_location_resolution(
538            indoc! {"
539                «fn foo1(a: usize) -> usize {
540                    40
541542
543                fn foo2(b: usize) -> usize {
544                    42
545                }
546            "},
547            "fn foo1(a: usize) -> u32 {\n40\n}",
548            &mut rng,
549        );
550    }
551
552    #[gpui::test(iterations = 100)]
553    fn test_resolve_location_class_methods(mut rng: StdRng) {
554        assert_location_resolution(
555            indoc! {"
556                class Something {
557                    one() { return 1; }
558                «    two() { return 2222; }
559                    three() { return 333; }
560                    four() { return 4444; }
561                    five() { return 5555; }
562                    six() { return 6666; }»
563                    seven() { return 7; }
564                    eight() { return 8; }
565                }
566            "},
567            indoc! {"
568                two() { return 2222; }
569                four() { return 4444; }
570                five() { return 5555; }
571                six() { return 6666; }
572            "},
573            &mut rng,
574        );
575    }
576
577    #[gpui::test(iterations = 100)]
578    fn test_resolve_location_imports_no_match(mut rng: StdRng) {
579        assert_location_resolution(
580            indoc! {"
581                use std::ops::Range;
582                use std::sync::Mutex;
583                use std::{
584                    collections::HashMap,
585                    env,
586                    ffi::{OsStr, OsString},
587                    fs,
588                    io::{BufRead, BufReader},
589                    mem,
590                    path::{Path, PathBuf},
591                    process::Command,
592                    sync::LazyLock,
593                    time::SystemTime,
594                };
595            "},
596            indoc! {"
597                use std::collections::{HashMap, HashSet};
598                use std::ffi::{OsStr, OsString};
599                use std::fmt::Write as _;
600                use std::fs;
601                use std::io::{BufReader, Read, Write};
602                use std::mem;
603                use std::path::{Path, PathBuf};
604                use std::process::Command;
605                use std::sync::Arc;
606            "},
607            &mut rng,
608        );
609    }
610
611    #[gpui::test(iterations = 100)]
612    fn test_resolve_location_nested_closure(mut rng: StdRng) {
613        assert_location_resolution(
614            indoc! {"
615                impl Foo {
616                    fn new() -> Self {
617                        Self {
618                            subscriptions: vec![
619                                cx.observe_window_activation(window, |editor, window, cx| {
620                                    let active = window.is_window_active();
621                                    editor.blink_manager.update(cx, |blink_manager, cx| {
622                                        if active {
623                                            blink_manager.enable(cx);
624                                        } else {
625                                            blink_manager.disable(cx);
626                                        }
627                                    });
628                                }),
629                            ];
630                        }
631                    }
632                }
633            "},
634            concat!(
635                "                    editor.blink_manager.update(cx, |blink_manager, cx| {\n",
636                "                        blink_manager.enable(cx);\n",
637                "                    });",
638            ),
639            &mut rng,
640        );
641    }
642
643    #[gpui::test(iterations = 100)]
644    fn test_resolve_location_tool_invocation(mut rng: StdRng) {
645        assert_location_resolution(
646            indoc! {r#"
647                let tool = cx
648                    .update(|cx| working_set.tool(&tool_name, cx))
649                    .map_err(|err| {
650                        anyhow!("Failed to look up tool '{}': {}", tool_name, err)
651                    })?;
652
653                let Some(tool) = tool else {
654                    return Err(anyhow!("Tool '{}' not found", tool_name));
655                };
656
657                let project = project.clone();
658                let action_log = action_log.clone();
659                let messages = messages.clone();
660                let tool_result = cx
661                    .update(|cx| tool.run(invocation.input, &messages, project, action_log, cx))
662                    .map_err(|err| anyhow!("Failed to start tool '{}': {}", tool_name, err))?;
663
664                tasks.push(tool_result.output);
665            "#},
666            concat!(
667                "let tool_result = cx\n",
668                "    .update(|cx| tool.run(invocation.input, &messages, project, action_log, cx))\n",
669                "    .output;",
670            ),
671            &mut rng,
672        );
673    }
674
675    #[gpui::test]
676    fn test_line_hint_selection() {
677        let text = indoc! {r#"
678            fn first_function() {
679                return 42;
680            }
681
682            fn second_function() {
683                return 42;
684            }
685
686            fn third_function() {
687                return 42;
688            }
689        "#};
690
691        let buffer = TextBuffer::new(
692            ReplicaId::LOCAL,
693            BufferId::new(1).unwrap(),
694            text.to_string(),
695        );
696        let snapshot = buffer.snapshot();
697        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
698
699        // Given a query that matches all three functions
700        let query = "return 42;\n";
701
702        // Test with line hint pointing to second function (around line 5)
703        let best_match = matcher.push(query, Some(5)).expect("Failed to match query");
704
705        let matched_text = snapshot
706            .text_for_range(best_match.clone())
707            .collect::<String>();
708        assert!(matched_text.contains("return 42;"));
709        assert_eq!(
710            best_match,
711            63..77,
712            "Expected to match `second_function` based on the line hint"
713        );
714
715        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
716        matcher.push(query, None);
717        matcher.finish();
718        let best_match = matcher.select_best_match();
719        assert!(
720            best_match.is_none(),
721            "Best match should be None when query cannot be uniquely resolved"
722        );
723    }
724
725    #[track_caller]
726    fn assert_location_resolution(text_with_expected_range: &str, query: &str, rng: &mut StdRng) {
727        let (text, expected_ranges) = marked_text_ranges(text_with_expected_range, false);
728        let buffer = TextBuffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), text.clone());
729        let snapshot = buffer.snapshot();
730
731        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
732
733        // Split query into random chunks
734        let chunks = to_random_chunks(rng, query);
735
736        // Push chunks incrementally
737        for chunk in &chunks {
738            matcher.push(chunk, None);
739        }
740
741        let actual_ranges = matcher.finish();
742
743        // If no expected ranges, we expect no match
744        if expected_ranges.is_empty() {
745            assert!(
746                actual_ranges.is_empty(),
747                "Expected no match for query: {:?}, but found: {:?}",
748                query,
749                actual_ranges
750            );
751        } else {
752            let text_with_actual_range = generate_marked_text(&text, &actual_ranges, false);
753            pretty_assertions::assert_eq!(
754                text_with_actual_range,
755                text_with_expected_range,
756                indoc! {"
757                    Query: {:?}
758                    Chunks: {:?}
759                    Expected marked text: {}
760                    Actual marked text: {}
761                    Expected ranges: {:?}
762                    Actual ranges: {:?}"
763                },
764                query,
765                chunks,
766                text_with_expected_range,
767                text_with_actual_range,
768                expected_ranges,
769                actual_ranges
770            );
771        }
772    }
773
774    fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec<String> {
775        let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50));
776        let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count);
777        chunk_indices.sort();
778        chunk_indices.push(input.len());
779
780        let mut chunks = Vec::new();
781        let mut last_ix = 0;
782        for chunk_ix in chunk_indices {
783            chunks.push(input[last_ix..chunk_ix].to_string());
784            last_ix = chunk_ix;
785        }
786        chunks
787    }
788
789    fn push(finder: &mut StreamingFuzzyMatcher, chunk: &str) -> Option<String> {
790        finder
791            .push(chunk, None)
792            .map(|range| finder.snapshot.text_for_range(range).collect::<String>())
793    }
794
795    fn finish(mut finder: StreamingFuzzyMatcher) -> Option<String> {
796        let snapshot = finder.snapshot.clone();
797        let matches = finder.finish();
798        matches
799            .first()
800            .map(|range| snapshot.text_for_range(range.clone()).collect::<String>())
801    }
802}