Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:45:30.731Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

streaming_fuzzy_matcher.rs

1180 lines · 40.1 KB · rust
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// Two matches are enough to prove ambiguity, so stop scanning early; exact
8// matches are byte-identical and can't be ranked.
9const MAX_EXACT_MATCHES: usize = 2;
10
11/// A streaming fuzzy matcher that can process text chunks incrementally
12/// and return the best match found so far at each step.
13pub struct StreamingFuzzyMatcher {
14    snapshot: TextBufferSnapshot,
15    query_lines: Vec<String>,
16    line_hint: Option<u32>,
17    incomplete_line: String,
18    raw_query: String,
19    matches: Vec<SearchMatch>,
20    matrix: SearchMatrix,
21}
22
23/// A match candidate: the matched byte range plus the 0-based
24/// `(query_row, buffer_row)` line pairs the search aligned to produce it.
25#[derive(Clone, Debug)]
26pub(super) struct SearchMatch {
27    pub(super) range: Range<usize>,
28    pub(super) line_pairs: Vec<(u32, u32)>,
29}
30
31#[derive(Debug)]
32pub(super) enum SearchMatches {
33    Exact(Vec<SearchMatch>),
34    Fuzzy(Vec<SearchMatch>),
35}
36
37impl StreamingFuzzyMatcher {
38    pub fn new(snapshot: TextBufferSnapshot) -> Self {
39        let buffer_line_count = snapshot.max_point().row as usize + 1;
40        Self {
41            snapshot,
42            query_lines: Vec::new(),
43            line_hint: None,
44            incomplete_line: String::new(),
45            raw_query: String::new(),
46            matches: Vec::new(),
47            matrix: SearchMatrix::new(buffer_line_count + 1),
48        }
49    }
50
51    /// Returns the query lines.
52    pub fn query_lines(&self) -> &[String] {
53        &self.query_lines
54    }
55
56    /// Push a new chunk of text and get the best match found so far.
57    ///
58    /// This method accumulates text chunks and processes complete lines.
59    /// Partial lines are buffered internally until a newline is received.
60    ///
61    /// # Returns
62    ///
63    /// Returns `Some(range)` if a match has been found with the accumulated
64    /// query so far, or `None` if no suitable match exists yet.
65    pub fn push(&mut self, chunk: &str, line_hint: Option<u32>) -> Option<Range<usize>> {
66        // Add the chunk to our incomplete line buffer
67        self.raw_query.push_str(chunk);
68        self.incomplete_line.push_str(chunk);
69        self.line_hint = line_hint;
70
71        if let Some((last_pos, _)) = self.incomplete_line.match_indices('\n').next_back() {
72            let complete_part = &self.incomplete_line[..=last_pos];
73
74            // Split into lines and add to query_lines
75            for line in complete_part.lines() {
76                self.query_lines.push(line.to_string());
77            }
78
79            self.incomplete_line.replace_range(..last_pos + 1, "");
80
81            self.matches = self.resolve_location_fuzzy();
82        }
83
84        let best_match = self.select_best_match();
85        best_match.or_else(|| {
86            self.matches
87                .first()
88                .map(|search_match| search_match.range.clone())
89        })
90    }
91
92    /// Finish processing and return the final best match(es).
93    ///
94    /// This processes any remaining incomplete line before returning the final
95    /// match result.
96    pub fn finish(&mut self) -> SearchMatches {
97        let exact_ranges = find_exact_matches(&self.snapshot, &self.raw_query);
98        if !exact_ranges.is_empty() {
99            if !self.incomplete_line.is_empty() {
100                self.query_lines
101                    .push(std::mem::take(&mut self.incomplete_line));
102            }
103            let matches = exact_ranges
104                .into_iter()
105                .map(|range| {
106                    let start_row = self.snapshot.offset_to_point(range.start).row;
107                    let line_pairs = (0..self.query_lines.len())
108                        .map(|query_row| (query_row as u32, start_row + query_row as u32))
109                        .collect();
110                    SearchMatch { range, line_pairs }
111                })
112                .collect();
113            return SearchMatches::Exact(matches);
114        }
115
116        // Process any remaining incomplete line
117        if !self.incomplete_line.is_empty() {
118            if let [only_match] = self.matches.as_mut_slice() {
119                let range = &mut only_match.range;
120                if range.end < self.snapshot.len()
121                    && self
122                        .snapshot
123                        .contains_str_at(range.end + 1, &self.incomplete_line)
124                {
125                    range.end += 1 + self.incomplete_line.len();
126                    // Record the line and its alignment so that `query_lines`
127                    // and `line_pairs` stay in sync with the lines covered by
128                    // the returned range.
129                    let extended_row = self.snapshot.offset_to_point(range.end).row;
130                    self.query_lines
131                        .push(std::mem::take(&mut self.incomplete_line));
132                    only_match
133                        .line_pairs
134                        .push(((self.query_lines.len() - 1) as u32, extended_row));
135                    return SearchMatches::Fuzzy(self.matches.clone());
136                }
137            }
138
139            self.query_lines
140                .push(std::mem::take(&mut self.incomplete_line));
141            self.matches = self.resolve_location_fuzzy();
142        }
143        SearchMatches::Fuzzy(self.matches.clone())
144    }
145
146    fn resolve_location_fuzzy(&mut self) -> Vec<SearchMatch> {
147        let new_query_line_count = self.query_lines.len();
148        let old_query_line_count = self.matrix.rows.saturating_sub(1);
149        if new_query_line_count == old_query_line_count {
150            return Vec::new();
151        }
152
153        self.matrix.resize_rows(new_query_line_count + 1);
154
155        // Process only the new query lines
156        for row in old_query_line_count..new_query_line_count {
157            let query_line = self.query_lines[row].trim();
158            let leading_deletion_cost = (row + 1) as u32 * DELETION_COST;
159
160            self.matrix.set(
161                row + 1,
162                0,
163                SearchState::new(leading_deletion_cost, SearchDirection::Up),
164            );
165
166            let mut buffer_lines = self.snapshot.as_rope().chunks().lines();
167            let mut col = 0;
168            while let Some(buffer_line) = buffer_lines.next() {
169                let buffer_line = buffer_line.trim();
170                let up = SearchState::new(
171                    self.matrix
172                        .get(row, col + 1)
173                        .cost
174                        .saturating_add(DELETION_COST),
175                    SearchDirection::Up,
176                );
177                let left = SearchState::new(
178                    self.matrix
179                        .get(row + 1, col)
180                        .cost
181                        .saturating_add(INSERTION_COST),
182                    SearchDirection::Left,
183                );
184                let diagonal = SearchState::new(
185                    if query_line == buffer_line {
186                        self.matrix.get(row, col).cost
187                    } else if fuzzy_eq(query_line, buffer_line) {
188                        self.matrix.get(row, col).cost + REPLACEMENT_COST
189                    } else {
190                        self.matrix
191                            .get(row, col)
192                            .cost
193                            .saturating_add(DELETION_COST + INSERTION_COST)
194                    },
195                    SearchDirection::Diagonal,
196                );
197                self.matrix
198                    .set(row + 1, col + 1, up.min(left).min(diagonal));
199                col += 1;
200            }
201        }
202
203        // Find all matches with the best cost
204        let buffer_line_count = self.snapshot.max_point().row as usize + 1;
205        let mut best_cost = u32::MAX;
206        let mut matches_with_best_cost = Vec::new();
207
208        for col in 1..=buffer_line_count {
209            let cost = self.matrix.get(new_query_line_count, col).cost;
210            if cost < best_cost {
211                best_cost = cost;
212                matches_with_best_cost.clear();
213                matches_with_best_cost.push(col as u32);
214            } else if cost == best_cost {
215                matches_with_best_cost.push(col as u32);
216            }
217        }
218
219        // Find ranges for the matches
220        let mut valid_matches = Vec::new();
221        for &buffer_row_end in &matches_with_best_cost {
222            let mut line_pairs = Vec::new();
223            let mut query_row = new_query_line_count;
224            let mut buffer_row_start = buffer_row_end;
225            while query_row > 0 && buffer_row_start > 0 {
226                let current = self.matrix.get(query_row, buffer_row_start as usize);
227                match current.direction {
228                    SearchDirection::Diagonal => {
229                        query_row -= 1;
230                        buffer_row_start -= 1;
231                        line_pairs.push((query_row as u32, buffer_row_start));
232                    }
233                    SearchDirection::Up => {
234                        query_row -= 1;
235                    }
236                    SearchDirection::Left => {
237                        buffer_row_start -= 1;
238                    }
239                }
240            }
241            line_pairs.reverse();
242
243            let matched_buffer_row_count = buffer_row_end - buffer_row_start;
244            let matched_ratio = line_pairs.len() as f32
245                / (matched_buffer_row_count as f32).max(new_query_line_count as f32);
246            if matched_ratio >= 0.8 {
247                let buffer_start_ix = self
248                    .snapshot
249                    .point_to_offset(Point::new(buffer_row_start, 0));
250                let buffer_end_ix = self.snapshot.point_to_offset(Point::new(
251                    buffer_row_end - 1,
252                    self.snapshot.line_len(buffer_row_end - 1),
253                ));
254                valid_matches.push(SearchMatch {
255                    range: buffer_start_ix..buffer_end_ix,
256                    line_pairs,
257                });
258            }
259        }
260
261        valid_matches
262    }
263
264    /// Return the best match with starting position close enough to line_hint.
265    pub fn select_best_match(&self) -> Option<Range<usize>> {
266        // Allow line hint to be off by that many lines.
267        // Higher values increase probability of applying edits to a wrong place,
268        // Lower values increase edits failures and overall conversation length.
269        const LINE_HINT_TOLERANCE: u32 = 200;
270
271        if self.matches.is_empty() {
272            return None;
273        }
274
275        if let [only_match] = self.matches.as_slice() {
276            return Some(only_match.range.clone());
277        }
278
279        let Some(line_hint) = self.line_hint else {
280            // Multiple ambiguous matches
281            return None;
282        };
283
284        let mut best_match = None;
285        let mut best_distance = u32::MAX;
286
287        for search_match in &self.matches {
288            let start_point = self.snapshot.offset_to_point(search_match.range.start);
289            let start_line = start_point.row;
290            let distance = start_line.abs_diff(line_hint);
291
292            if distance <= LINE_HINT_TOLERANCE && distance < best_distance {
293                best_distance = distance;
294                best_match = Some(search_match.range.clone());
295            }
296        }
297
298        best_match
299    }
300}
301
302// Aho-Corasick's overlapping search requires a contiguous haystack, while its
303// streaming search skips overlapping matches. KMP detects overlapping ambiguity
304// while scanning rope chunks without copying the buffer.
305fn find_exact_matches(snapshot: &TextBufferSnapshot, query: &str) -> Vec<Range<usize>> {
306    if query.is_empty() {
307        return Vec::new();
308    }
309
310    let query = query.as_bytes();
311    let trailing_line_ending_len = if query.ends_with(b"\r\n") {
312        2
313    } else if query.ends_with(b"\n") {
314        1
315    } else {
316        0
317    };
318    let mut prefix_lengths = vec![0; query.len()];
319    let mut prefix_length = 0;
320    for query_index in 1..query.len() {
321        while prefix_length > 0 && query[query_index] != query[prefix_length] {
322            prefix_length = prefix_lengths[prefix_length - 1];
323        }
324        if query[query_index] == query[prefix_length] {
325            prefix_length += 1;
326            prefix_lengths[query_index] = prefix_length;
327        }
328    }
329
330    let mut matches = Vec::new();
331    let mut matched_length = 0;
332    for (offset, byte) in snapshot
333        .bytes_in_range(0..snapshot.len())
334        .flatten()
335        .copied()
336        .enumerate()
337    {
338        while matched_length > 0 && byte != query[matched_length] {
339            matched_length = prefix_lengths[matched_length - 1];
340        }
341        if byte == query[matched_length] {
342            matched_length += 1;
343        }
344        if matched_length == query.len() {
345            let raw_end = offset + 1;
346            let start = raw_end - query.len();
347            let end = raw_end - trailing_line_ending_len;
348            matches.push(start..end);
349            if matches.len() == MAX_EXACT_MATCHES {
350                break;
351            }
352            matched_length = prefix_lengths[matched_length - 1];
353        }
354    }
355    matches
356}
357
358fn fuzzy_eq(left: &str, right: &str) -> bool {
359    const THRESHOLD: f64 = 0.8;
360
361    let min_levenshtein = left.len().abs_diff(right.len());
362    let min_normalized_levenshtein =
363        1. - (min_levenshtein as f64 / cmp::max(left.len(), right.len()) as f64);
364    if min_normalized_levenshtein < THRESHOLD {
365        return false;
366    }
367
368    strsim::normalized_levenshtein(left, right) >= THRESHOLD
369}
370
371#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
372enum SearchDirection {
373    Up,
374    Left,
375    Diagonal,
376}
377
378#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
379struct SearchState {
380    cost: u32,
381    direction: SearchDirection,
382}
383
384impl SearchState {
385    fn new(cost: u32, direction: SearchDirection) -> Self {
386        Self { cost, direction }
387    }
388}
389
390struct SearchMatrix {
391    cols: usize,
392    rows: usize,
393    data: Vec<SearchState>,
394}
395
396impl SearchMatrix {
397    fn new(cols: usize) -> Self {
398        SearchMatrix {
399            cols,
400            rows: 0,
401            data: Vec::new(),
402        }
403    }
404
405    fn resize_rows(&mut self, needed_rows: usize) {
406        debug_assert!(needed_rows > self.rows);
407        self.rows = needed_rows;
408        self.data.resize(
409            self.rows * self.cols,
410            SearchState::new(0, SearchDirection::Diagonal),
411        );
412    }
413
414    fn get(&self, row: usize, col: usize) -> SearchState {
415        debug_assert!(row < self.rows && col < self.cols);
416        self.data[row * self.cols + col]
417    }
418
419    fn set(&mut self, row: usize, col: usize, state: SearchState) {
420        debug_assert!(row < self.rows && col < self.cols);
421        self.data[row * self.cols + col] = state;
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use indoc::indoc;
429    use language::{BufferId, TextBuffer};
430    use rand::prelude::*;
431    use text::ReplicaId;
432    use util::test::{generate_marked_text, marked_text_ranges};
433
434    #[test]
435    fn test_empty_query() {
436        let buffer = TextBuffer::new(
437            ReplicaId::LOCAL,
438            BufferId::new(1).unwrap(),
439            "Hello world\nThis is a test\nFoo bar baz",
440        );
441        let snapshot = buffer.snapshot();
442
443        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
444        assert_eq!(push(&mut finder, ""), None);
445        assert_eq!(finish(finder), None);
446    }
447
448    #[test]
449    fn test_streaming_exact_match() {
450        let buffer = TextBuffer::new(
451            ReplicaId::LOCAL,
452            BufferId::new(1).unwrap(),
453            "Hello world\nThis is a test\nFoo bar baz",
454        );
455        let snapshot = buffer.snapshot();
456
457        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
458
459        // Push partial query
460        assert_eq!(push(&mut finder, "This"), None);
461
462        // Complete the line
463        assert_eq!(
464            push(&mut finder, " is a test\n"),
465            Some("This is a test".to_string())
466        );
467
468        // Finish should return the same result
469        assert_eq!(finish(finder), Some("This is a test".to_string()));
470    }
471
472    #[test]
473    fn test_streaming_fuzzy_match() {
474        let buffer = TextBuffer::new(
475            ReplicaId::LOCAL,
476            BufferId::new(1).unwrap(),
477            indoc! {"
478                function foo(a, b) {
479                    return a + b;
480                }
481
482                function bar(x, y) {
483                    return x * y;
484                }
485            "},
486        );
487        let snapshot = buffer.snapshot();
488
489        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
490
491        // Push a fuzzy query that should match the first function
492        assert_eq!(
493            push(&mut finder, "function foo(a, c) {\n").as_deref(),
494            Some("function foo(a, b) {")
495        );
496        assert_eq!(
497            push(&mut finder, "    return a + c;\n}\n").as_deref(),
498            Some(concat!(
499                "function foo(a, b) {\n",
500                "    return a + b;\n",
501                "}"
502            ))
503        );
504    }
505
506    #[test]
507    fn test_incremental_improvement() {
508        let buffer = TextBuffer::new(
509            ReplicaId::LOCAL,
510            BufferId::new(1).unwrap(),
511            "Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
512        );
513        let snapshot = buffer.snapshot();
514
515        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
516
517        // No match initially
518        assert_eq!(push(&mut finder, "Lin"), None);
519
520        // Get a match when we complete a line
521        assert_eq!(push(&mut finder, "e 3\n"), Some("Line 3".to_string()));
522
523        // The match might change if we add more specific content
524        assert_eq!(
525            push(&mut finder, "Line 4\n"),
526            Some("Line 3\nLine 4".to_string())
527        );
528        assert_eq!(finish(finder), Some("Line 3\nLine 4".to_string()));
529    }
530
531    #[test]
532    fn test_incomplete_lines_buffering() {
533        let buffer = TextBuffer::new(
534            ReplicaId::LOCAL,
535            BufferId::new(1).unwrap(),
536            indoc! {"
537                The quick brown fox
538                jumps over the lazy dog
539                Pack my box with five dozen liquor jugs
540            "},
541        );
542        let snapshot = buffer.snapshot();
543
544        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
545
546        // Push text in small chunks across line boundaries
547        assert_eq!(push(&mut finder, "jumps "), None); // No newline yet
548        assert_eq!(push(&mut finder, "over the"), None); // Still no newline
549        assert_eq!(push(&mut finder, " lazy"), None); // Still incomplete
550
551        // Complete the line
552        assert_eq!(
553            push(&mut finder, " dog\n"),
554            Some("jumps over the lazy dog".to_string())
555        );
556    }
557
558    #[test]
559    fn test_multiline_fuzzy_match() {
560        let buffer = TextBuffer::new(
561            ReplicaId::LOCAL,
562            BufferId::new(1).unwrap(),
563            indoc! {r#"
564                impl Display for User {
565                    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
566                        write!(f, "User: {} ({})", self.name, self.email)
567                    }
568                }
569
570                impl Debug for User {
571                    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
572                        f.debug_struct("User")
573                            .field("name", &self.name)
574                            .field("email", &self.email)
575                            .finish()
576                    }
577                }
578            "#},
579        );
580        let snapshot = buffer.snapshot();
581
582        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
583
584        assert_eq!(
585            push(&mut finder, "impl Debug for User {\n"),
586            Some("impl Debug for User {".to_string())
587        );
588        assert_eq!(
589            push(
590                &mut finder,
591                "    fn fmt(&self, f: &mut Formatter) -> Result {\n"
592            )
593            .as_deref(),
594            Some(concat!(
595                "impl Debug for User {\n",
596                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {"
597            ))
598        );
599        assert_eq!(
600            push(&mut finder, "        f.debug_struct(\"User\")\n").as_deref(),
601            Some(concat!(
602                "impl Debug for User {\n",
603                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {\n",
604                "        f.debug_struct(\"User\")"
605            ))
606        );
607        assert_eq!(
608            push(
609                &mut finder,
610                "            .field(\"name\", &self.username)\n"
611            )
612            .as_deref(),
613            Some(concat!(
614                "impl Debug for User {\n",
615                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {\n",
616                "        f.debug_struct(\"User\")\n",
617                "            .field(\"name\", &self.name)"
618            ))
619        );
620        assert_eq!(
621            finish(finder).as_deref(),
622            Some(concat!(
623                "impl Debug for User {\n",
624                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {\n",
625                "        f.debug_struct(\"User\")\n",
626                "            .field(\"name\", &self.name)"
627            ))
628        );
629    }
630
631    #[gpui::test(iterations = 100)]
632    fn test_resolve_location_single_line(mut rng: StdRng) {
633        assert_location_resolution(
634            concat!(
635                "    Lorem\n",
636                "    «ipsum»\n",
637                "    dolor sit amet\n",
638                "    consecteur",
639            ),
640            "ipsum",
641            &mut rng,
642        );
643    }
644
645    #[gpui::test(iterations = 100)]
646    fn test_resolve_location_multiline(mut rng: StdRng) {
647        assert_location_resolution(
648            concat!(
649                "    Lorem\n",
650                "«    ipsum\n",
651                "    dolor sit amet»\n",
652                "    consecteur",
653            ),
654            "ipsum\ndolor sit amet",
655            &mut rng,
656        );
657    }
658
659    #[gpui::test(iterations = 100)]
660    fn test_resolve_location_function_with_typo(mut rng: StdRng) {
661        assert_location_resolution(
662            indoc! {"
663                «fn foo1(a: usize) -> usize {
664                    40
665
666
667                fn foo2(b: usize) -> usize {
668                    42
669                }
670            "},
671            "fn foo1(a: usize) -> u32 {\n40\n}",
672            &mut rng,
673        );
674    }
675
676    #[gpui::test(iterations = 100)]
677    fn test_resolve_location_class_methods(mut rng: StdRng) {
678        assert_location_resolution(
679            indoc! {"
680                class Something {
681                    one() { return 1; }
682                «    two() { return 2222; }
683                    three() { return 333; }
684                    four() { return 4444; }
685                    five() { return 5555; }
686                    six() { return 6666; }»
687                    seven() { return 7; }
688                    eight() { return 8; }
689                }
690            "},
691            indoc! {"
692                two() { return 2222; }
693                four() { return 4444; }
694                five() { return 5555; }
695                six() { return 6666; }
696            "},
697            &mut rng,
698        );
699    }
700
701    #[gpui::test(iterations = 100)]
702    fn test_resolve_location_imports_no_match(mut rng: StdRng) {
703        assert_location_resolution(
704            indoc! {"
705                use std::ops::Range;
706                use std::sync::Mutex;
707                use std::{
708                    collections::HashMap,
709                    env,
710                    ffi::{OsStr, OsString},
711                    fs,
712                    io::{BufRead, BufReader},
713                    mem,
714                    path::{Path, PathBuf},
715                    process::Command,
716                    sync::LazyLock,
717                    time::SystemTime,
718                };
719            "},
720            indoc! {"
721                use std::collections::{HashMap, HashSet};
722                use std::ffi::{OsStr, OsString};
723                use std::fmt::Write as _;
724                use std::fs;
725                use std::io::{BufReader, Read, Write};
726                use std::mem;
727                use std::path::{Path, PathBuf};
728                use std::process::Command;
729                use std::sync::Arc;
730            "},
731            &mut rng,
732        );
733    }
734
735    #[gpui::test(iterations = 100)]
736    fn test_resolve_location_nested_closure(mut rng: StdRng) {
737        assert_location_resolution(
738            indoc! {"
739                impl Foo {
740                    fn new() -> Self {
741                        Self {
742                            subscriptions: vec![
743                                cx.observe_window_activation(window, |editor, window, cx| {
744                                    let active = window.is_window_active();
745                                    editor.blink_manager.update(cx, |blink_manager, cx| {
746                                        if active {
747                                            blink_manager.enable(cx);
748                                        } else {
749                                            blink_manager.disable(cx);
750                                        }
751                                    });
752                                }),
753                            ];
754                        }
755                    }
756                }
757            "},
758            concat!(
759                "                    editor.blink_manager.update(cx, |blink_manager, cx| {\n",
760                "                        blink_manager.enable(cx);\n",
761                "                    });",
762            ),
763            &mut rng,
764        );
765    }
766
767    #[gpui::test(iterations = 100)]
768    fn test_resolve_location_tool_invocation(mut rng: StdRng) {
769        assert_location_resolution(
770            indoc! {r#"
771                let tool = cx
772                    .update(|cx| working_set.tool(&tool_name, cx))
773                    .map_err(|err| {
774                        anyhow!("Failed to look up tool '{}': {}", tool_name, err)
775                    })?;
776
777                let Some(tool) = tool else {
778                    return Err(anyhow!("Tool '{}' not found", tool_name));
779                };
780
781                let project = project.clone();
782                let action_log = action_log.clone();
783                let messages = messages.clone();
784                let tool_result = cx
785                    .update(|cx| tool.run(invocation.input, &messages, project, action_log, cx))
786                    .map_err(|err| anyhow!("Failed to start tool '{}': {}", tool_name, err))?;
787
788                tasks.push(tool_result.output);
789            "#},
790            concat!(
791                "let tool_result = cx\n",
792                "    .update(|cx| tool.run(invocation.input, &messages, project, action_log, cx))\n",
793                "    .output;",
794            ),
795            &mut rng,
796        );
797    }
798
799    #[gpui::test]
800    fn test_line_hint_selection() {
801        let text = indoc! {r#"
802            fn first_function() {
803                return 42;
804            }
805
806            fn second_function() {
807                return 42;
808            }
809
810            fn third_function() {
811                return 42;
812            }
813        "#};
814
815        let buffer = TextBuffer::new(
816            ReplicaId::LOCAL,
817            BufferId::new(1).unwrap(),
818            text.to_string(),
819        );
820        let snapshot = buffer.snapshot();
821        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
822
823        // Given a query that matches all three functions
824        let query = "return 42;\n";
825
826        // Test with line hint pointing to second function (around line 5)
827        let best_match = matcher.push(query, Some(5)).expect("Failed to match query");
828
829        let matched_text = snapshot
830            .text_for_range(best_match.clone())
831            .collect::<String>();
832        assert!(matched_text.contains("return 42;"));
833        assert_eq!(
834            best_match,
835            63..77,
836            "Expected to match `second_function` based on the line hint"
837        );
838
839        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
840        matcher.push(query, None);
841        matcher.finish();
842        let best_match = matcher.select_best_match();
843        assert!(
844            best_match.is_none(),
845            "Best match should be None when query cannot be uniquely resolved"
846        );
847    }
848
849    #[gpui::test]
850    fn test_exact_match_takes_precedence_over_fuzzy_match() {
851        let buffer = TextBuffer::new(
852            ReplicaId::LOCAL,
853            BufferId::new(1).unwrap(),
854            concat!(
855                "prefix keyboard WASD, voxel-based suffix\n",
856                "keyboard WASD, voxel-baseX\n",
857            ),
858        );
859        let snapshot = buffer.snapshot();
860        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
861
862        assert_eq!(matcher.push("keyboard WASD, voxel-based", None), None);
863        let SearchMatches::Exact(matches) = matcher.finish() else {
864            panic!("expected an exact match");
865        };
866        let [search_match] = matches.as_slice() else {
867            panic!("expected one match, got {}", matches.len());
868        };
869        assert_eq!(
870            snapshot
871                .text_for_range(search_match.range.clone())
872                .collect::<String>(),
873            "keyboard WASD, voxel-based"
874        );
875    }
876
877    #[gpui::test]
878    fn test_exact_match_uses_trailing_newline_to_disambiguate() {
879        let buffer = TextBuffer::new(
880            ReplicaId::LOCAL,
881            BufferId::new(1).unwrap(),
882            "foo suffix\nfoo\n",
883        );
884        let snapshot = buffer.snapshot();
885        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
886
887        matcher.push("foo\n", None);
888        let SearchMatches::Exact(matches) = matcher.finish() else {
889            panic!("expected an exact match");
890        };
891        let [search_match] = matches.as_slice() else {
892            panic!("expected one match, got {}", matches.len());
893        };
894        assert_eq!(
895            snapshot
896                .text_for_range(search_match.range.clone())
897                .collect::<String>(),
898            "foo"
899        );
900    }
901
902    #[gpui::test]
903    fn test_exact_newline_only_match_excludes_line_ending() {
904        let buffer = TextBuffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "a\nb");
905        let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone());
906
907        matcher.push("\n", None);
908        let SearchMatches::Exact(matches) = matcher.finish() else {
909            panic!("expected an exact match");
910        };
911        let [search_match] = matches.as_slice() else {
912            panic!("expected one match, got {}", matches.len());
913        };
914        assert_eq!(search_match.range, 1..1);
915    }
916
917    #[gpui::test]
918    fn test_exact_overlapping_matches_are_ambiguous() {
919        let buffer = TextBuffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "aaaaa");
920        let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone());
921
922        matcher.push("aaaa", None);
923        let matches = matcher.finish();
924
925        assert!(matches!(matches, SearchMatches::Exact(_)));
926        assert_eq!(match_ranges(&matches), vec![0..4, 1..5]);
927    }
928
929    #[gpui::test]
930    fn test_exact_multiline_match_does_not_extend_incomplete_line() {
931        let buffer = TextBuffer::new(
932            ReplicaId::LOCAL,
933            BufferId::new(1).unwrap(),
934            "prefix fragment\nnext\nnext\n",
935        );
936        let snapshot = buffer.snapshot();
937        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
938
939        assert_eq!(matcher.push("fragment\nnext", None), None);
940        let SearchMatches::Exact(matches) = matcher.finish() else {
941            panic!("expected an exact match");
942        };
943        let [search_match] = matches.as_slice() else {
944            panic!("expected one match, got {}", matches.len());
945        };
946        assert_eq!(
947            snapshot
948                .text_for_range(search_match.range.clone())
949                .collect::<String>(),
950            "fragment\nnext"
951        );
952    }
953
954    #[gpui::test]
955    fn test_prefix_of_last_line_resolves_to_correct_range() {
956        let text = indoc! {r#"
957            fn on_query_change(&mut self, cx: &mut Context<Self>) {
958                self.filter(cx);
959            }
960
961
962
963            fn render_search(&self, cx: &mut Context<Self>) -> Div {
964                div()
965            }
966        "#};
967
968        let buffer = TextBuffer::new(
969            ReplicaId::LOCAL,
970            BufferId::new(1).unwrap(),
971            text.to_string(),
972        );
973        let snapshot = buffer.snapshot();
974
975        // Query with a partial last line. This is a verbatim substring of the
976        // buffer, so it resolves through the exact-match path.
977        let query = "}\n\n\n\nfn render_search";
978
979        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
980        matcher.push(query, None);
981        let matches = matcher.finish();
982        let matched = search_matches(&matches);
983
984        // The match should include the line containing "fn render_search".
985        let matched_text = matched.first().map(|search_match| {
986            snapshot
987                .text_for_range(search_match.range.clone())
988                .collect::<String>()
989        });
990
991        assert!(
992            matched.len() == 1,
993            "Expected exactly one match, got {}: {:?}",
994            matched.len(),
995            matched_text,
996        );
997
998        let Some(matched_text) = matched_text else {
999            panic!("expected a match");
1000        };
1001        pretty_assertions::assert_eq!(
1002            matched_text,
1003            "}\n\n\n\nfn render_search",
1004            "Match should include the render_search line",
1005        );
1006    }
1007
1008    #[track_caller]
1009    fn assert_location_resolution(text_with_expected_range: &str, query: &str, rng: &mut StdRng) {
1010        let (text, expected_ranges) = marked_text_ranges(text_with_expected_range, false);
1011        let buffer = TextBuffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), text.clone());
1012        let snapshot = buffer.snapshot();
1013
1014        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
1015
1016        // Split query into random chunks
1017        let chunks = to_random_chunks(rng, query);
1018
1019        // Push chunks incrementally
1020        for chunk in &chunks {
1021            matcher.push(chunk, None);
1022        }
1023
1024        let actual_matches = matcher.finish();
1025        let actual_ranges = match_ranges(&actual_matches);
1026
1027        // If no expected ranges, we expect no match
1028        if expected_ranges.is_empty() {
1029            assert!(
1030                actual_ranges.is_empty(),
1031                "Expected no match for query: {:?}, but found: {:?}",
1032                query,
1033                actual_ranges
1034            );
1035        } else {
1036            let text_with_actual_range = generate_marked_text(&text, &actual_ranges, false);
1037            pretty_assertions::assert_eq!(
1038                text_with_actual_range,
1039                text_with_expected_range,
1040                indoc! {"
1041                    Query: {:?}
1042                    Chunks: {:?}
1043                    Expected marked text: {}
1044                    Actual marked text: {}
1045                    Expected ranges: {:?}
1046                    Actual ranges: {:?}"
1047                },
1048                query,
1049                chunks,
1050                text_with_expected_range,
1051                text_with_actual_range,
1052                expected_ranges,
1053                actual_ranges
1054            );
1055        }
1056    }
1057
1058    #[test]
1059    fn test_line_pairs_skip_unmatched_buffer_line() {
1060        let text = indoc! {r#"
1061            class Outer:
1062                def method(self):
1063                    self.kept = "unchanged"
1064                    self.target_a = "before"
1065                    self.extra = "row"
1066                    self.target_b = "before"
1067                    self.target_c = "before"
1068                    self.target_d = "before"
1069                    self.kept_2 = "unchanged"
1070        "#};
1071        let buffer = TextBuffer::new(
1072            ReplicaId::LOCAL,
1073            BufferId::new(1).unwrap(),
1074            text.to_string(),
1075        );
1076        let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone());
1077
1078        // The query omits the `self.extra` row that sits between the matched
1079        // buffer lines.
1080        matcher.push(
1081            concat!(
1082                "        self.target_a = \"before\"\n",
1083                "        self.target_b = \"before\"\n",
1084                "        self.target_c = \"before\"\n",
1085                "        self.target_d = \"before\"\n",
1086            ),
1087            None,
1088        );
1089        let SearchMatches::Fuzzy(matches) = matcher.finish() else {
1090            panic!("expected a fuzzy match");
1091        };
1092        let [search_match] = matches.as_slice() else {
1093            panic!("expected one match, got {}", matches.len());
1094        };
1095        assert_eq!(search_match.line_pairs, [(0, 3), (1, 5), (2, 6), (3, 7)]);
1096    }
1097
1098    #[test]
1099    fn test_line_pairs_include_extended_incomplete_line() {
1100        let text = indoc! {r#"
1101            fn on_query_change(&mut self, cx: &mut Context<Self>) {
1102                self.filter(cx);
1103            }
1104
1105
1106
1107            fn render_search(&self, cx: &mut Context<Self>) -> Div {
1108                div()
1109            }
1110        "#};
1111        let buffer = TextBuffer::new(
1112            ReplicaId::LOCAL,
1113            BufferId::new(1).unwrap(),
1114            text.to_string(),
1115        );
1116        let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone());
1117
1118        // The last query line is incomplete and gets appended to the match by
1119        // `finish` via verbatim comparison rather than the fuzzy search. The
1120        // trailing space after `}` keeps the query from being an exact
1121        // substring of the buffer, so the fuzzy extension path is exercised.
1122        matcher.push("} \n\n\n\nfn render_search", None);
1123        let SearchMatches::Fuzzy(matches) = matcher.finish() else {
1124            panic!("expected a fuzzy match");
1125        };
1126        let [search_match] = matches.as_slice() else {
1127            panic!("expected one match, got {}", matches.len());
1128        };
1129        assert_eq!(
1130            search_match.line_pairs,
1131            [(0, 2), (1, 3), (2, 4), (3, 5), (4, 6)]
1132        );
1133        assert_eq!(matcher.query_lines().len(), 5);
1134    }
1135
1136    fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec<String> {
1137        let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50));
1138        let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count);
1139        chunk_indices.sort();
1140        chunk_indices.push(input.len());
1141
1142        let mut chunks = Vec::new();
1143        let mut last_ix = 0;
1144        for chunk_ix in chunk_indices {
1145            chunks.push(input[last_ix..chunk_ix].to_string());
1146            last_ix = chunk_ix;
1147        }
1148        chunks
1149    }
1150
1151    fn push(finder: &mut StreamingFuzzyMatcher, chunk: &str) -> Option<String> {
1152        finder
1153            .push(chunk, None)
1154            .map(|range| finder.snapshot.text_for_range(range).collect::<String>())
1155    }
1156
1157    fn search_matches(matches: &SearchMatches) -> &[SearchMatch] {
1158        match matches {
1159            SearchMatches::Exact(matches) | SearchMatches::Fuzzy(matches) => matches,
1160        }
1161    }
1162
1163    fn match_ranges(matches: &SearchMatches) -> Vec<Range<usize>> {
1164        search_matches(matches)
1165            .iter()
1166            .map(|search_match| search_match.range.clone())
1167            .collect()
1168    }
1169
1170    fn finish(mut finder: StreamingFuzzyMatcher) -> Option<String> {
1171        let snapshot = finder.snapshot.clone();
1172        let matches = finder.finish();
1173        search_matches(&matches).first().map(|search_match| {
1174            snapshot
1175                .text_for_range(search_match.range.clone())
1176                .collect::<String>()
1177        })
1178    }
1179}
1180
Served at tenant.openagents/omega Member data and write actions are omitted.