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::{plain_text, 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(plain_text(&(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    pub fn injector(&self) -> WorkerInjector<T> {
142        WorkerInjector {
143            inner: self.nucleo.injector(),
144            columns: self.columns.clone(),
145            version: self.version.load(atomic::Ordering::Relaxed),
146            picker_version: self.version.clone(),
147        }
148    }
149
150    pub fn find(&mut self, line: &str) {
151        let old_query = self.query.parse(line);
152        if self.query == old_query {
153            return;
154        }
155        for (i, column) in self
156            .columns
157            .iter()
158            .filter(|column| column.filter)
159            .enumerate()
160        {
161            let pattern = self
162                .query
163                .get(&column.name)
164                .map(|s| &**s)
165                .unwrap_or_else(|| {
166                    self.column_options[i]
167                        .contains(ColumnOptions::OrUseDefault)
168                        .then(|| self.query.primary_column_query())
169                        .flatten()
170                        .unwrap_or_default()
171                });
172
173            let old_pattern = old_query
174                .get(&column.name)
175                .map(|s| &**s)
176                .unwrap_or_else(|| {
177                    self.column_options[i]
178                        .contains(ColumnOptions::OrUseDefault)
179                        .then(|| {
180                            let name = self.query.primary_column_name()?;
181                            old_query.get(name).map(|s| &**s)
182                        })
183                        .flatten()
184                        .unwrap_or_default()
185                });
186
187            // Fastlane: most columns will remain unchanged after each edit.
188            if pattern == old_pattern {
189                continue;
190            }
191            let is_append = pattern.starts_with(old_pattern);
192
193            self.nucleo.pattern.reparse(
194                i,
195                pattern,
196                nucleo::pattern::CaseMatching::Smart,
197                nucleo::pattern::Normalization::Smart,
198                is_append,
199            );
200        }
201    }
202
203    // --------- UTILS
204    pub fn get_nth(&self, n: u32) -> Option<&T> {
205        self.nucleo
206            .snapshot()
207            .get_matched_item(n)
208            .map(|item| item.data)
209    }
210
211    pub fn new_snapshot(nucleo: &mut nucleo::Nucleo<T>) -> (&nucleo::Snapshot<T>, Status) {
212        let nucleo::Status { changed, running } = nucleo.tick(10);
213        let snapshot = nucleo.snapshot();
214        (
215            snapshot,
216            Status {
217                item_count: snapshot.item_count(),
218                matched_count: snapshot.matched_item_count(),
219                running,
220                changed,
221            },
222        )
223    }
224
225    pub fn raw_results(&self) -> impl ExactSizeIterator<Item = &T> + DoubleEndedIterator + '_ {
226        let snapshot = self.nucleo.snapshot();
227        snapshot.matched_items(..).map(|item| item.data)
228    }
229
230    /// matched item count, total item count
231    pub fn counts(&self) -> (u32, u32) {
232        let snapshot = self.nucleo.snapshot();
233        (snapshot.matched_item_count(), snapshot.item_count())
234    }
235
236    #[cfg(feature = "experimental")]
237    pub fn set_stability(&mut self, threshold: u32) {
238        self.nucleo.set_stability(threshold);
239    }
240
241    #[cfg(feature = "experimental")]
242    pub fn get_stability(&self) -> u32 {
243        self.nucleo.get_stability()
244    }
245
246    pub fn restart(&mut self, clear_snapshot: bool) {
247        self.nucleo.restart(clear_snapshot);
248    }
249}
250
251#[derive(Debug, Default, Clone)]
252pub struct Status {
253    pub item_count: u32,
254    pub matched_count: u32,
255    pub running: bool,
256    pub changed: bool,
257}
258
259#[derive(Debug, thiserror::Error)]
260pub enum WorkerError {
261    #[error("the matcher injector has been shut down")]
262    InjectorShutdown,
263    #[error("{0}")]
264    Custom(&'static str),
265}
266
267/// A vec of ItemResult, each ItemResult being the Column Texts of the Item, and Item
268pub type WorkerResults<'a, T> = Vec<(Vec<Text<'a>>, &'a T)>;
269
270impl<T: SSS> Worker<T> {
271    /// Returns:
272    /// 1. Table of (Row, item, height)
273    /// 2. Final column widths
274    /// 3. Status
275    ///
276    /// # Notes
277    /// - width is at least header width
278    pub fn results(
279        &mut self,
280        start: u32,
281        end: u32,
282        width_limits: &[u16],
283        highlight_style: Style,
284        matcher: &mut nucleo::Matcher,
285    ) -> (WorkerResults<'_, T>, Vec<u16>, Status) {
286        let (snapshot, status) = Self::new_snapshot(&mut self.nucleo);
287
288        let mut widths = vec![0u16; self.columns.len()];
289
290        let iter =
291            snapshot.matched_items(start.min(status.matched_count)..end.min(status.matched_count));
292
293        let table = iter
294            .map(|item| {
295                let mut widths = widths.iter_mut();
296
297                let row = self
298                    .columns
299                    .iter()
300                    .enumerate()
301                    .zip(width_limits.iter().chain(std::iter::repeat(&u16::MAX)))
302                    .map(|((col_idx, column), &width_limit)| {
303                        let max_width = widths.next().unwrap();
304                        let cell = column.format(item.data);
305
306                        // 0 represents hide
307                        if width_limit == 0 {
308                            return Text::default();
309                        }
310
311                        let (cell, width) = if column.filter {
312                            render_cell(
313                                cell,
314                                col_idx,
315                                snapshot,
316                                &item,
317                                matcher,
318                                highlight_style,
319                                width_limit,
320                                &mut self.col_indices_buffer,
321                            )
322                        } else if width_limit != u16::MAX {
323                            let (cell, wrapped) = wrap_text(cell, width_limit - 1);
324
325                            let width = if wrapped {
326                                width_limit as usize
327                            } else {
328                                cell.width()
329                            };
330                            (cell, width)
331                        } else {
332                            let width = cell.width();
333                            (cell, width)
334                        };
335
336                        // update col width, row height
337                        if width as u16 > *max_width {
338                            *max_width = width as u16;
339                        }
340
341                        cell
342                    });
343
344                (row.collect(), item.data)
345            })
346            .collect();
347
348        // Nonempty columns should have width at least their header
349        for (w, c) in widths.iter_mut().zip(self.columns.iter()) {
350            let name_width = c.name.width() as u16;
351            if *w != 0 {
352                *w = (*w).max(name_width);
353            }
354        }
355
356        (table, widths, status)
357    }
358
359    pub fn exact_column_match(&mut self, column: &str) -> Option<&T> {
360        let (i, col) = self
361            .columns
362            .iter()
363            .enumerate()
364            .find(|(_, c)| column == &*c.name)?;
365
366        let query = self.query.get(column).map(|s| &**s).or_else(|| {
367            self.column_options[i]
368                .contains(ColumnOptions::OrUseDefault)
369                .then(|| self.query.primary_column_query())
370                .flatten()
371        })?;
372
373        let snapshot = self.nucleo.snapshot();
374        snapshot.matched_items(..).find_map(|item| {
375            let content = col.format_text(item.data);
376            if content.as_str() == query {
377                Some(item.data)
378            } else {
379                None
380            }
381        })
382    }
383
384    pub fn format_with<'a>(&'a self, item: &'a T, col: &str) -> Option<Cow<'a, str>> {
385        self.columns
386            .iter()
387            .find(|c| &*c.name == col)
388            .map(|c| c.format_text(item))
389    }
390}
391
392fn render_cell<T: SSS>(
393    cell: Text<'_>,
394    col_idx: usize,
395    snapshot: &nucleo::Snapshot<T>,
396    item: &nucleo::Item<T>,
397    matcher: &mut nucleo::Matcher,
398    highlight_style: Style,
399    width_limit: u16,
400    col_indices_buffer: &mut Vec<u32>,
401) -> (Text<'static>, usize) {
402    let mut cell_width = 0;
403    let mut wrapped = false;
404
405    // get indices
406    let indices_buffer = col_indices_buffer;
407    indices_buffer.clear();
408    snapshot.pattern().column_pattern(col_idx).indices(
409        item.matcher_columns[col_idx].slice(..),
410        matcher,
411        indices_buffer,
412    );
413    indices_buffer.sort_unstable();
414    indices_buffer.dedup();
415    let mut indices = indices_buffer.drain(..);
416
417    let mut lines = vec![];
418    let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX);
419    let mut grapheme_idx = 0u32;
420
421    for line in cell {
422        let mut current_spans = Vec::new();
423        let mut current_span = String::new();
424        let mut current_style = Style::default();
425        let mut current_width = 0;
426
427        for span in line {
428            // this looks like a bug on first glance, we are iterating
429            // graphemes but treating them as char indices. The reason that
430            // this is correct is that nucleo will only ever consider the first char
431            // of a grapheme (and discard the rest of the grapheme) so the indices
432            // returned by nucleo are essentially grapheme indecies
433
434            if width_limit != u16::MAX {}
435            let mut graphemes = span.content.graphemes(true).peekable();
436            while let Some(grapheme) = graphemes.next() {
437                let grapheme_width = UnicodeWidthStr::width(grapheme);
438
439                if width_limit != u16::MAX {
440                    if current_width + grapheme_width > (width_limit - 1) as usize && {
441                        grapheme_width > 1 || graphemes.peek().is_some()
442                    } {
443                        current_spans.push(Span::styled(current_span, current_style));
444                        current_spans.push(Span::styled(
445                            "↵",
446                            Style::default().add_modifier(Modifier::DIM),
447                        ));
448                        lines.push(Line::from(current_spans));
449
450                        current_spans = Vec::new();
451                        current_span = String::new();
452                        current_width = 0;
453                        wrapped = true;
454                    }
455                }
456
457                let style = if grapheme_idx == next_highlight_idx {
458                    next_highlight_idx = indices.next().unwrap_or(u32::MAX);
459                    span.style.patch(highlight_style)
460                } else {
461                    span.style
462                };
463
464                if style != current_style {
465                    if !current_span.is_empty() {
466                        current_spans.push(Span::styled(current_span, current_style))
467                    }
468                    current_span = String::new();
469                    current_style = style;
470                }
471                current_span.push_str(grapheme);
472                grapheme_idx += 1;
473                current_width += grapheme_width;
474            }
475        }
476
477        current_spans.push(Span::styled(current_span, current_style));
478        lines.push(Line::from(current_spans));
479        cell_width = cell_width.max(current_width);
480        grapheme_idx += 1; // newline?
481    }
482
483    (
484        Text::from(lines),
485        if wrapped {
486            width_limit as usize
487        } else {
488            cell_width
489        },
490    )
491}