Skip to main content

matchmaker/nucleo/
worker.rs

1// Original code from https://github.com/helix-editor/helix (MPL 2.0)
2// Modified by Squirreljetpack, 2025
3
4use super::{Line, Span, Style, Text};
5use bitflags::bitflags;
6
7use ratatui::style::Modifier;
8use std::{
9    borrow::Cow,
10    sync::{
11        Arc,
12        atomic::{self, AtomicU32},
13    },
14};
15use unicode_segmentation::UnicodeSegmentation;
16use unicode_width::UnicodeWidthStr;
17
18use super::{injector::WorkerInjector, query::PickerQuery};
19use crate::{
20    SSS,
21    nucleo::Render,
22    utils::text::{text_to_string, wrap_text},
23};
24
25type ColumnFormatFn<T> = Box<dyn for<'a> Fn(&'a T) -> Text<'a> + Send + Sync>;
26pub struct Column<T> {
27    pub name: Arc<str>,
28    pub(super) format: ColumnFormatFn<T>,
29    /// Whether the column should be passed to nucleo for matching and filtering.
30    pub(super) filter: bool,
31}
32
33impl<T> Column<T> {
34    pub fn new_boxed(name: impl Into<Arc<str>>, format: ColumnFormatFn<T>) -> Self {
35        Self {
36            name: name.into(),
37            format,
38            filter: true,
39        }
40    }
41
42    pub fn new<F>(name: impl Into<Arc<str>>, f: F) -> Self
43    where
44        F: for<'a> Fn(&'a T) -> Text<'a> + SSS,
45    {
46        Self {
47            name: name.into(),
48            format: Box::new(f),
49            filter: true,
50        }
51    }
52
53    /// Disable filtering.
54    pub fn without_filtering(mut self) -> Self {
55        self.filter = false;
56        self
57    }
58
59    pub(super) fn format<'a>(&self, item: &'a T) -> Text<'a> {
60        (self.format)(item)
61    }
62
63    // Note: the characters should match the output of [`Self::format`]
64    pub(super) fn format_text<'a>(&self, item: &'a T) -> Cow<'a, str> {
65        Cow::Owned(text_to_string(&(self.format)(item)))
66    }
67}
68
69/// Worker: can instantiate, push, and get results. A view into computation.
70///
71/// Additionally, the worker can affect the computation via find and restart.
72pub struct Worker<T>
73where
74    T: SSS,
75{
76    /// The inner `Nucleo` fuzzy matcher.
77    pub(crate) nucleo: nucleo::Nucleo<T>,
78    /// The last pattern that was matched against.
79    pub(super) query: PickerQuery,
80    /// A pre-allocated buffer used to collect match indices when fetching the results
81    /// from the matcher. This avoids having to re-allocate on each pass.
82    pub(super) col_indices_buffer: Vec<u32>,
83    pub(crate) columns: Arc<[Column<T>]>,
84
85    // Background tasks which push to the injector check their version matches this or exit
86    pub(super) version: Arc<AtomicU32>,
87    // pub settings: WorkerSettings,
88    column_options: Vec<ColumnOptions>,
89}
90
91// #[derive(Debug, Default)]
92// pub struct WorkerSettings {
93//     pub stable: bool,
94// }
95
96bitflags! {
97    #[derive(Default, Clone, Debug)]
98    pub struct ColumnOptions: u8 {
99        const Optional = 1 << 0;
100        const OrUseDefault = 1 << 2;
101    }
102}
103
104impl<T> Worker<T>
105where
106    T: SSS,
107{
108    /// Column names must be distinct!
109    pub fn new(columns: impl IntoIterator<Item = Column<T>>, default_column: usize) -> Self {
110        let columns: Arc<[_]> = columns.into_iter().collect();
111        let matcher_columns = columns.iter().filter(|col| col.filter).count() as u32;
112
113        let inner = nucleo::Nucleo::new(
114            nucleo::Config::DEFAULT,
115            Arc::new(|| {}),
116            None,
117            matcher_columns,
118        );
119
120        Self {
121            nucleo: inner,
122            col_indices_buffer: Vec::with_capacity(128),
123            query: PickerQuery::new(columns.iter().map(|col| &col.name).cloned(), default_column),
124            column_options: vec![ColumnOptions::default(); columns.len()],
125            columns,
126            version: Arc::new(AtomicU32::new(0)),
127        }
128    }
129
130    #[cfg(feature = "experimental")]
131    pub fn set_column_options(&mut self, index: usize, options: ColumnOptions) {
132        if options.contains(ColumnOptions::Optional) {
133            self.nucleo
134                .pattern
135                .configure_column(index, nucleo::pattern::Variant::Optional)
136        }
137
138        self.column_options[index] = options
139    }
140
141    #[cfg(feature = "experimental")]
142    pub fn reverse_items(&mut self, reverse_items: bool) {
143        self.nucleo.reverse_items(reverse_items);
144    }
145
146    pub fn injector(&self) -> WorkerInjector<T> {
147        WorkerInjector {
148            inner: self.nucleo.injector(),
149            columns: self.columns.clone(),
150            version: self.version.load(atomic::Ordering::Relaxed),
151            picker_version: self.version.clone(),
152        }
153    }
154
155    pub fn find(&mut self, line: &str) {
156        let old_query = self.query.parse(line);
157        if self.query == old_query {
158            return;
159        }
160        for (i, column) in self
161            .columns
162            .iter()
163            .filter(|column| column.filter)
164            .enumerate()
165        {
166            let pattern = self
167                .query
168                .get(&column.name)
169                .map(|s| &**s)
170                .unwrap_or_else(|| {
171                    self.column_options[i]
172                        .contains(ColumnOptions::OrUseDefault)
173                        .then(|| self.query.primary_column_query())
174                        .flatten()
175                        .unwrap_or_default()
176                });
177
178            let old_pattern = old_query
179                .get(&column.name)
180                .map(|s| &**s)
181                .unwrap_or_else(|| {
182                    self.column_options[i]
183                        .contains(ColumnOptions::OrUseDefault)
184                        .then(|| {
185                            let name = self.query.primary_column_name()?;
186                            old_query.get(name).map(|s| &**s)
187                        })
188                        .flatten()
189                        .unwrap_or_default()
190                });
191
192            // Fastlane: most columns will remain unchanged after each edit.
193            if pattern == old_pattern {
194                continue;
195            }
196            let is_append = pattern.starts_with(old_pattern);
197
198            self.nucleo.pattern.reparse(
199                i,
200                pattern,
201                nucleo::pattern::CaseMatching::Smart,
202                nucleo::pattern::Normalization::Smart,
203                is_append,
204            );
205        }
206    }
207
208    // --------- UTILS
209    pub fn get_nth(&self, n: u32) -> Option<&T> {
210        self.nucleo
211            .snapshot()
212            .get_matched_item(n)
213            .map(|item| item.data)
214    }
215
216    pub fn new_snapshot(nucleo: &mut nucleo::Nucleo<T>) -> (&nucleo::Snapshot<T>, Status) {
217        let nucleo::Status { changed, running } = nucleo.tick(10);
218        let snapshot = nucleo.snapshot();
219        (
220            snapshot,
221            Status {
222                item_count: snapshot.item_count(),
223                matched_count: snapshot.matched_item_count(),
224                running,
225                changed,
226            },
227        )
228    }
229
230    pub fn raw_results(&self) -> impl ExactSizeIterator<Item = &T> + DoubleEndedIterator + '_ {
231        let snapshot = self.nucleo.snapshot();
232        snapshot.matched_items(..).map(|item| item.data)
233    }
234
235    /// matched item count, total item count
236    pub fn counts(&self) -> (u32, u32) {
237        let snapshot = self.nucleo.snapshot();
238        (snapshot.matched_item_count(), snapshot.item_count())
239    }
240
241    #[cfg(feature = "experimental")]
242    pub fn set_stability(&mut self, threshold: u32) {
243        self.nucleo.set_stability(threshold);
244    }
245
246    #[cfg(feature = "experimental")]
247    pub fn get_stability(&self) -> u32 {
248        self.nucleo.get_stability()
249    }
250
251    pub fn restart(&mut self, clear_snapshot: bool) {
252        self.nucleo.restart(clear_snapshot);
253    }
254}
255
256#[derive(Debug, Default, Clone)]
257pub struct Status {
258    pub item_count: u32,
259    pub matched_count: u32,
260    pub running: bool,
261    pub changed: bool,
262}
263
264#[derive(Debug, thiserror::Error)]
265pub enum WorkerError {
266    #[error("the matcher injector has been shut down")]
267    InjectorShutdown,
268    #[error("{0}")]
269    Custom(&'static str),
270}
271
272/// A vec of ItemResult, each ItemResult being the Column Texts of the Item, and Item
273pub type WorkerResults<'a, T> = Vec<(Vec<Text<'a>>, &'a T)>;
274
275impl<T: SSS> Worker<T> {
276    /// Returns:
277    /// 1. Table of (Row, item, height)
278    /// 2. Final column widths
279    /// 3. Status
280    ///
281    /// # Notes
282    /// - width is at least header width
283    pub fn results(
284        &mut self,
285        start: u32,
286        end: u32,
287        width_limits: &[u16],
288        highlight_style: Style,
289        matcher: &mut nucleo::Matcher,
290        match_start_context: Option<usize>,
291    ) -> (WorkerResults<'_, T>, Vec<u16>, Status) {
292        let (snapshot, status) = Self::new_snapshot(&mut self.nucleo);
293
294        let mut widths = vec![0u16; self.columns.len()];
295
296        let iter =
297            snapshot.matched_items(start.min(status.matched_count)..end.min(status.matched_count));
298
299        let table = iter
300            .map(|item| {
301                let mut widths = widths.iter_mut();
302
303                let row = self
304                    .columns
305                    .iter()
306                    .enumerate()
307                    .zip(width_limits.iter().chain(std::iter::repeat(&u16::MAX)))
308                    .map(|((col_idx, column), &width_limit)| {
309                        let max_width = widths.next().unwrap();
310                        let cell = column.format(item.data);
311
312                        // 0 represents hide
313                        if width_limit == 0 {
314                            return Text::default();
315                        }
316
317                        let (cell, width) = if column.filter {
318                            render_cell(
319                                cell,
320                                col_idx,
321                                snapshot,
322                                &item,
323                                matcher,
324                                highlight_style,
325                                width_limit,
326                                &mut self.col_indices_buffer,
327                                match_start_context,
328                            )
329                        } else if width_limit != u16::MAX {
330                            let (cell, wrapped) = wrap_text(cell, width_limit - 1);
331
332                            let width = if wrapped {
333                                width_limit as usize
334                            } else {
335                                cell.width()
336                            };
337                            (cell, width)
338                        } else {
339                            let width = cell.width();
340                            (cell, width)
341                        };
342
343                        // update col width, row height
344                        if width as u16 > *max_width {
345                            *max_width = width as u16;
346                        }
347
348                        cell
349                    });
350
351                (row.collect(), item.data)
352            })
353            .collect();
354
355        // Nonempty columns should have width at least their header
356        for (w, c) in widths.iter_mut().zip(self.columns.iter()) {
357            let name_width = c.name.width() as u16;
358            if *w != 0 {
359                *w = (*w).max(name_width);
360            }
361        }
362
363        (table, widths, status)
364    }
365
366    pub fn exact_column_match(&mut self, column: &str) -> Option<&T> {
367        let (i, col) = self
368            .columns
369            .iter()
370            .enumerate()
371            .find(|(_, c)| column == &*c.name)?;
372
373        let query = self.query.get(column).map(|s| &**s).or_else(|| {
374            self.column_options[i]
375                .contains(ColumnOptions::OrUseDefault)
376                .then(|| self.query.primary_column_query())
377                .flatten()
378        })?;
379
380        let snapshot = self.nucleo.snapshot();
381        snapshot.matched_items(..).find_map(|item| {
382            let content = col.format_text(item.data);
383            if content.as_str() == query {
384                Some(item.data)
385            } else {
386                None
387            }
388        })
389    }
390
391    pub fn format_with<'a>(&'a self, item: &'a T, col: &str) -> Option<Cow<'a, str>> {
392        self.columns
393            .iter()
394            .find(|c| &*c.name == col)
395            .map(|c| c.format_text(item))
396    }
397}
398
399fn render_cell<T: SSS>(
400    // Assuming T implements the required SSS/Config trait
401    cell: Text<'_>,
402    col_idx: usize,
403    snapshot: &nucleo::Snapshot<T>,
404    item: &nucleo::Item<T>,
405    matcher: &mut nucleo::Matcher,
406    highlight_style: Style,
407    width_limit: u16,
408    col_indices_buffer: &mut Vec<u32>,
409    match_start_context: Option<usize>,
410) -> (Text<'static>, usize) {
411    let mut cell_width = 0;
412    let mut wrapped = false;
413
414    // get indices
415    let indices_buffer = col_indices_buffer;
416    indices_buffer.clear();
417    snapshot.pattern().column_pattern(col_idx).indices(
418        item.matcher_columns[col_idx].slice(..),
419        matcher,
420        indices_buffer,
421    );
422    indices_buffer.sort_unstable();
423    indices_buffer.dedup();
424    let mut indices = indices_buffer.drain(..);
425
426    let mut lines = vec![];
427    let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX);
428    let mut grapheme_idx = 0u32;
429
430    for line in &cell {
431        // 1: Collect graphemes, compute styles, and find the first match on this line.
432        let mut line_graphemes = Vec::new();
433        let mut first_match_idx = None;
434
435        for span in line {
436            // this looks like a bug on first glance, we are iterating
437            // graphemes but treating them as char indices. The reason that
438            // this is correct is that nucleo will only ever consider the first char
439            // of a grapheme (and discard the rest of the grapheme) so the indices
440            // returned by nucleo are essentially grapheme indecies
441            let mut graphemes = span.content.graphemes(true).peekable();
442
443            while let Some(grapheme) = graphemes.next() {
444                let is_match = grapheme_idx == next_highlight_idx;
445
446                let style = if is_match {
447                    next_highlight_idx = indices.next().unwrap_or(u32::MAX);
448                    span.style.patch(highlight_style)
449                } else {
450                    span.style
451                };
452
453                if is_match && first_match_idx.is_none() {
454                    first_match_idx = Some(line_graphemes.len());
455                }
456
457                line_graphemes.push((grapheme, style));
458                grapheme_idx += 1;
459            }
460        }
461
462        // 2: Calculate where to start rendering this line
463        let mut start_idx = 0;
464
465        if let (Some(n), Some(first_idx)) = (match_start_context, first_match_idx) {
466            start_idx = first_idx.saturating_sub(n);
467
468            // If width_limit is given, Try to start earlier provided we don't exceed width_limit.
469            if width_limit != u16::MAX {
470                let mut tail_width: usize = line_graphemes[start_idx..]
471                    .iter()
472                    .map(|(g, _)| g.width())
473                    .sum();
474
475                // Expand leftwards as long as the total rendered width <= width_limit
476                while start_idx > 0 {
477                    let prev_width = line_graphemes[start_idx - 1].0.width();
478                    if tail_width + prev_width <= width_limit as usize {
479                        start_idx -= 1;
480                        tail_width += prev_width;
481                    } else {
482                        break;
483                    }
484                }
485            }
486        }
487
488        // 3: Apply the standard wrapping and Span generation logic to the visible slice
489        let mut current_spans = Vec::new();
490        let mut current_span = String::new();
491        let mut current_style = Style::default();
492        let mut current_width = 0;
493
494        let mut graphemes = line_graphemes[start_idx..].iter().peekable();
495
496        while let Some(&(grapheme, style)) = graphemes.next() {
497            let grapheme_width = grapheme.width();
498
499            if width_limit != u16::MAX {
500                if current_width + grapheme_width > (width_limit - 1) as usize && {
501                    grapheme_width > 1 || graphemes.peek().is_some()
502                } {
503                    if !current_span.is_empty() {
504                        current_spans.push(Span::styled(current_span, current_style));
505                    }
506                    current_spans.push(Span::styled(
507                        "↵",
508                        Style::default().add_modifier(Modifier::DIM),
509                    ));
510                    lines.push(Line::from(current_spans));
511
512                    current_spans = Vec::new();
513                    current_span = String::new();
514                    current_width = 0;
515                    wrapped = true;
516                }
517            }
518
519            if style != current_style {
520                if !current_span.is_empty() {
521                    current_spans.push(Span::styled(current_span, current_style))
522                }
523                current_span = String::new();
524                current_style = style;
525            }
526            current_span.push_str(grapheme);
527            current_width += grapheme_width;
528        }
529
530        current_spans.push(Span::styled(current_span, current_style));
531        lines.push(Line::from(current_spans));
532        cell_width = cell_width.max(current_width);
533
534        grapheme_idx += 1; // newline
535    }
536
537    (
538        Text::from(lines),
539        if wrapped {
540            width_limit as usize
541        } else {
542            cell_width
543        },
544    )
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use nucleo::{Matcher, Nucleo};
551    use ratatui::style::{Color, Style};
552    use ratatui::text::Text;
553    use std::sync::Arc;
554
555    /// Sets up the necessary Nucleo state to trigger a match
556    fn setup_nucleo_mocks(
557        search_query: &str,
558        item_text: &str,
559    ) -> (Nucleo<String>, Matcher, Vec<u32>) {
560        let mut nucleo = Nucleo::<String>::new(nucleo::Config::DEFAULT, Arc::new(|| {}), None, 1);
561
562        let injector = nucleo.injector();
563        injector.push(item_text.to_string(), |item, columns| {
564            columns[0] = item.clone().into();
565        });
566
567        nucleo.pattern.reparse(
568            0,
569            search_query,
570            nucleo::pattern::CaseMatching::Ignore,
571            nucleo::pattern::Normalization::Smart,
572            false,
573        );
574
575        nucleo.tick(10); // Process the item
576
577        let matcher = Matcher::default();
578        let buffer = Vec::new();
579
580        (nucleo, matcher, buffer)
581    }
582
583    #[test]
584    fn test_no_scroll_context_renders_normally() {
585        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("match", "hello match world");
586        let snapshot = nucleo.snapshot();
587        let item = snapshot.get_item(0).unwrap();
588
589        let cell = Text::from("hello match world");
590        let highlight = Style::default().fg(Color::Red);
591
592        let (result_text, width) = render_cell(
593            cell,
594            0,
595            &snapshot,
596            &item,
597            &mut matcher,
598            highlight,
599            u16::MAX,
600            &mut buffer,
601            None,
602        );
603
604        let output_str = text_to_string(&result_text);
605        assert_eq!(output_str, "hello match world");
606        assert_eq!(width, 17);
607    }
608
609    #[test]
610    fn test_scroll_context_cuts_prefix_correctly() {
611        // Match starts at index 6 ("match"). Context is 2.
612        // We expect it to drop "hell", keep "o ", and render "o match world"
613        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("match", "hello match world");
614        let snapshot = nucleo.snapshot();
615        let item = snapshot.get_item(0).unwrap();
616
617        let cell = Text::from("hello match world");
618        let highlight = Style::default().fg(Color::Red);
619
620        // Width limit MAX so no backfill constraint triggers
621        let (result_text, _) = render_cell(
622            cell,
623            0,
624            &snapshot,
625            &item,
626            &mut matcher,
627            highlight,
628            u16::MAX,
629            &mut buffer,
630            Some(2),
631        );
632
633        let output_str = text_to_string(&result_text);
634        assert_eq!(output_str, "o match world");
635    }
636
637    #[test]
638    fn test_scroll_context_backfills_to_fill_width_limit() {
639        // Query "match". Starts at index 10.
640        // "abcdefghijmatch"
641        // If context = 1, normally we'd start at index 9 ("jmatch"). Width = 6.
642        // If width_limit = 10, the backfill logic should expand backwards
643        // to consume 4 more characters to fill the 10-char limit without wrapping.
644        // Expected start: "fghijmatch"
645
646        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("match", "abcdefghijmatch");
647        let snapshot = nucleo.snapshot();
648        let item = snapshot.get_item(0).unwrap();
649
650        let cell = Text::from("abcdefghijmatch");
651        let highlight = Style::default().fg(Color::Red);
652
653        let (result_text, width) = render_cell(
654            cell,
655            0,
656            &snapshot,
657            &item,
658            &mut matcher,
659            highlight,
660            10,
661            &mut buffer,
662            Some(1),
663        );
664
665        let output_str = text_to_string(&result_text);
666        assert_eq!(output_str, "fghijmatch");
667        assert_eq!(width, 10);
668    }
669}