From b2e3b44a6c74151fc572846cdc155332c53badb0 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Fri, 12 Jun 2026 12:18:58 -0700 Subject: [PATCH 1/3] perf: Reduce branching during the index build phase Separate out the long/small files using one common branch and make an optimized path for >1kb files --- crates/fff-core/src/bigram_filter.rs | 204 +++++++++++++++++++-------- 1 file changed, 148 insertions(+), 56 deletions(-) diff --git a/crates/fff-core/src/bigram_filter.rs b/crates/fff-core/src/bigram_filter.rs index f9b99452..bd41a5f6 100644 --- a/crates/fff-core/src/bigram_filter.rs +++ b/crates/fff-core/src/bigram_filter.rs @@ -20,6 +20,11 @@ const NO_COLUMN: u16 = u16::MAX; /// 1024 × u64 = 8 KB covers all 65536 possible bigram keys. const SEEN_WORDS: usize = 1024; +/// Content size where the branchless two-pass `add_long_content` overtakes +/// the single-pass `add_short_content`: ~-35% on 4 KB files, but its fixed +/// flush scan dominates files under ~1 KB. See bigram_bench `bigram_build`. +const LONG_CONTENT_MIN_LEN: usize = 1024; + thread_local! { static NORM_BUF: std::cell::RefCell> = std::cell::RefCell::new(Vec::with_capacity(4096)); @@ -117,16 +122,6 @@ impl BigramIndexBuilder { let word_idx = file_idx / 64; let bit_mask = 1u64 << (file_idx % 64); - // Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536 - // bigram keys with margin. Has to fit in L1 cache. - let mut seen_consec = [0u64; SEEN_WORDS]; - let mut seen_skip = [0u64; SEEN_WORDS]; - - let consec_base = self.col_data_ptr(); - let consec_words = self.words; - let skip_base = skip_builder.col_data_ptr(); - let skip_words = skip_builder.words; - NORM_BUF.with_borrow_mut(|buf| { let len = content.len(); if buf.len() < len { @@ -136,48 +131,12 @@ impl BigramIndexBuilder { normalize_bytes(content, &mut buf[..len]); let n = &buf[..len]; - let mut n0 = n[0]; - let mut n1 = n[1]; - - if n0 != 0 && n1 != 0 { - let key = (n0 as u16) << 8 | n1 as u16; - self.record_bigram( - &mut seen_consec, - key, - word_idx, - bit_mask, - consec_base, - consec_words, - ); - } - - for &cur in &n[2..] { - if cur != 0 { - if n1 != 0 { - let key = (n1 as u16) << 8 | cur as u16; - self.record_bigram( - &mut seen_consec, - key, - word_idx, - bit_mask, - consec_base, - consec_words, - ); - } - if n0 != 0 { - let key = (n0 as u16) << 8 | cur as u16; - skip_builder.record_bigram( - &mut seen_skip, - key, - word_idx, - bit_mask, - skip_base, - skip_words, - ); - } - } - n0 = n1; - n1 = cur; + // Both paths record the identical bigram set; the split exists + // purely for speed (see LONG_CONTENT_MIN_LEN). + if len >= LONG_CONTENT_MIN_LEN { + self.add_long_content(skip_builder, n, word_idx, bit_mask); + } else { + self.add_short_content(skip_builder, n, word_idx, bit_mask); } }); @@ -185,10 +144,94 @@ impl BigramIndexBuilder { skip_builder.populated.fetch_add(1, Ordering::Relaxed); } - /// Mark `key` as present for the file whose column-word is `word_idx` - /// and bit position is `bit_mask`, de-duplicating via the caller-owned - /// `seen` bitmap so we only touch the shared column slab at most once - /// per unique bigram per file. + // Branchless two-pass: set every pair in stack-local bitmaps, including + // pairs touching the 0 sentinel — flush_seen masks those out. ~-35% vs + // the single pass on 4 KB files. + #[inline(always)] + fn add_long_content(&self, skip_builder: &Self, n: &[u8], word_idx: usize, bit_mask: u64) { + // Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536 + // bigram keys. Has to fit in L1 cache. + let mut seen_consec = [0u64; SEEN_WORDS]; + let mut seen_skip = [0u64; SEEN_WORDS]; + + let mut n0 = n[0]; + let mut n1 = n[1]; + + let key = (n0 as usize) << 8 | n1 as usize; + // SAFETY: key < 65536, so key >> 6 < 1024 = SEEN_WORDS. + unsafe { *seen_consec.get_unchecked_mut(key >> 6) |= 1u64 << (key & 63) }; + + for &cur in &n[2..] { + let ck = (n1 as usize) << 8 | cur as usize; + let sk = (n0 as usize) << 8 | cur as usize; + unsafe { + *seen_consec.get_unchecked_mut(ck >> 6) |= 1u64 << (ck & 63); + *seen_skip.get_unchecked_mut(sk >> 6) |= 1u64 << (sk & 63); + } + + n0 = n1; + n1 = cur; + } + + self.flush_seen(&seen_consec, word_idx, bit_mask); + skip_builder.flush_seen(&seen_skip, word_idx, bit_mask); + } + + #[inline(always)] + fn add_short_content(&self, skip_builder: &Self, n: &[u8], word_idx: usize, bit_mask: u64) { + let mut seen_consec = [0u64; SEEN_WORDS]; + let mut seen_skip = [0u64; SEEN_WORDS]; + + let consec_base = self.col_data_ptr(); + let consec_words = self.words; + let skip_base = skip_builder.col_data_ptr(); + let skip_words = skip_builder.words; + + let mut n0 = n[0]; + let mut n1 = n[1]; + + if n0 != 0 && n1 != 0 { + let key = (n0 as u16) << 8 | n1 as u16; + self.record_bigram( + &mut seen_consec, + key, + word_idx, + bit_mask, + consec_base, + consec_words, + ); + } + + for &cur in &n[2..] { + if cur != 0 { + if n1 != 0 { + let key = (n1 as u16) << 8 | cur as u16; + self.record_bigram( + &mut seen_consec, + key, + word_idx, + bit_mask, + consec_base, + consec_words, + ); + } + if n0 != 0 { + let key = (n0 as u16) << 8 | cur as u16; + skip_builder.record_bigram( + &mut seen_skip, + key, + word_idx, + bit_mask, + skip_base, + skip_words, + ); + } + } + n0 = n1; + n1 = cur; + } + } + #[inline(always)] fn record_bigram( &self, @@ -218,6 +261,36 @@ impl BigramIndexBuilder { } } + fn flush_seen(&self, seen: &[u64; SEEN_WORDS], word_idx: usize, bit_mask: u64) { + let col_base = self.col_data_ptr(); + let words = self.words; + for (blk, block) in seen.chunks_exact(8).enumerate() { + // OR-test whole blocks so the mostly-empty bitmap scans fast. + if block.iter().fold(0u64, |a, &w| a | w) == 0 { + continue; + } + for (j, &word_bits) in block.iter().enumerate() { + let w = blk * 8 + j; + let mut bits = match w & 3 { + _ if w < 4 => 0, + 0 => word_bits & !1, + _ => word_bits, + }; + while bits != 0 { + let key = (w << 6 | bits.trailing_zeros() as usize) as u16; + bits &= bits - 1; + let col = self.get_or_alloc_column(key); + if col != NO_COLUMN { + unsafe { + let p = col_base.add(col as usize * words + word_idx); + *p |= bit_mask; + } + } + } + } + } + } + pub fn is_ready(&self) -> bool { self.populated.load(Ordering::Relaxed) > 0 } @@ -1102,6 +1175,25 @@ mod tests { run_and_compare(&mixed[..192]); // SIMD path with scalar tail } + #[test] + fn add_file_long_short_paths_agree() { + // Same mixed content checked just below, at, and above + // LONG_CONTENT_MIN_LEN so both add_short_content and add_long_content + // are validated against the reference implementation. + let mut mixed = Vec::with_capacity(LONG_CONTENT_MIN_LEN * 2); + for i in 0..LONG_CONTENT_MIN_LEN * 2 { + mixed.push(match i % 11 { + 0 => 0, + 1 => 0x7F, + 2 => b'\n', + _ => 32 + ((i * 31) % 95) as u8, + }); + } + run_and_compare(&mixed[..LONG_CONTENT_MIN_LEN - 1]); + run_and_compare(&mixed[..LONG_CONTENT_MIN_LEN]); + run_and_compare(&mixed); + } + #[test] fn add_file_respects_file_count_boundary() { // file_count=100, file_idx=63 (last bit in word 0) and file_idx=64 From 45541276a5c03966d4d4192cff01c5ae6c527a61 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sat, 13 Jun 2026 16:16:38 -0700 Subject: [PATCH 2/3] perf: Improve fuzzy scoring pipeline --- Cargo.lock | 1 + crates/fff-core/src/grep.rs | 396 ++++++++++++++++++++---------------- crates/fff-nvim/Cargo.toml | 1 + 3 files changed, 219 insertions(+), 179 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7eb9d48f..dcae5824 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,6 +697,7 @@ dependencies = [ "once_cell", "rand 0.8.5", "tracing", + "tracing-subscriber", ] [[package]] diff --git a/crates/fff-core/src/grep.rs b/crates/fff-core/src/grep.rs index 9c69711b..5c73fa5b 100644 --- a/crates/fff-core/src/grep.rs +++ b/crates/fff-core/src/grep.rs @@ -1680,216 +1680,254 @@ fn fuzzy_grep_search<'a>( let search_start = std::time::Instant::now(); let budget_exceeded = AtomicBool::new(false); let max_matches_per_file = options.max_matches_per_file; - // Parallel phase with `map_init`: each rayon worker thread clones the - // matcher once and gets a reusable read buffer + mmap slot. Buffer holds - // small files, slot holds fresh mmap for cache-miss files - // ≥ FRESH_MMAP_THRESHOLD. - let per_file_results: Vec<(usize, &'a FileItem, Vec)> = files_to_search - .par_iter() - .enumerate() - .map_init( - || { - ( - matcher.clone(), - Vec::with_capacity(64 * 1024), - MmapSlot::default(), - ) - }, - |(matcher, buf, mmap_slot), (idx, file)| { - if abort_signal.load(Ordering::Relaxed) { - budget_exceeded.store(true, Ordering::Relaxed); - return None; - } - if let Some(budget) = time_budget - && search_start.elapsed() > budget - { - budget_exceeded.store(true, Ordering::Relaxed); - return None; - } + // for fuzzy match we need a bit smarter chunking as the amount of work we have to perform is + // exponentially larger than the original grep (and the nature of work is) - in short we have to + // understand if the approximate index prefilter got us a lot of candidates or not + // + // if we have a few candidates -> likely we have a lot of matches, so verify the check faster + // if we have a lot of candidates -> rely on a larger chunk pipelining more parallel lines at once + let page_limit = options.page_limit; + let base_chunk = rayon::current_num_threads() * 4; + let prefilter_strong = total_files > 0 && files_to_search.len() * 2 < total_files; + let max_chunk = if prefilter_strong { + base_chunk + } else { + (base_chunk * 256).max(8 * 1024) + }; - let file_arena = if file.is_overflow() { - overflow_arena - } else { - arena - }; + let growth = if prefilter_strong { 1 } else { 2 }; + let mut chunk_size = base_chunk; + let mut chunk_start = 0; + let mut running_matches = 0usize; + let mut per_file_results: Vec<(usize, &'a FileItem, Vec)> = Vec::new(); - let file_bytes = - file.get_content_for_search(buf, mmap_slot, file_arena, base_path, budget)?; - - // File-level prefilter: check if enough distinct needle chars - // exist anywhere in the file bytes. Uses memchr for speed. - if min_chars_required > 0 { - let mut chars_found = 0usize; - for &ch in &unique_needle_chars { - if memchr::memchr(ch, file_bytes).is_some() { - chars_found += 1; - if chars_found >= min_chars_required { - break; - } - } - } - if chars_found < min_chars_required { - return None; - } - } + while chunk_start < files_to_search.len() { + let chunk_end = (chunk_start + chunk_size).min(files_to_search.len()); + let chunk = &files_to_search[chunk_start..chunk_end]; + let chunk_offset = chunk_start; + chunk_start = chunk_end; + chunk_size = (chunk_size * growth).min(max_chunk); - // Validate the whole file as UTF-8 once upfront. Source code - // files are virtually always valid UTF-8; this single check - // replaces per-line from_utf8 calls (~8% of fuzzy grep time). - let file_is_utf8 = std::str::from_utf8(file_bytes).is_ok(); - - // Reuse grep-searcher's LineStep for SIMD-accelerated line iteration. - let mut stepper = LineStep::new(b'\n', 0, file_bytes.len()); - let estimated_lines = (file_bytes.len() / 40).max(64); - let mut file_lines: Vec<&str> = Vec::with_capacity(estimated_lines); - let mut line_meta: Vec<(u64, u64)> = Vec::with_capacity(estimated_lines); - let line_term_lf = fff_grep::LineTerminator::byte(b'\n'); - let line_term_cr = fff_grep::LineTerminator::byte(b'\r'); - - let mut line_number: u64 = 1; - while let Some(line_match) = stepper.next_match(file_bytes) { - let byte_offset = line_match.start() as u64; - - // Strip line terminators (\n, \r). - let trimmed = lines::without_terminator( - lines::without_terminator(&file_bytes[line_match], line_term_lf), - line_term_cr, - ); - - if !trimmed.is_empty() { - // SAFETY: when the whole file is valid UTF-8, every - // sub-slice split on ASCII byte boundaries (\n, \r) - // is also valid UTF-8. - let line_str = if file_is_utf8 { - unsafe { std::str::from_utf8_unchecked(trimmed) } - } else if let Ok(s) = std::str::from_utf8(trimmed) { - s - } else { - line_number += 1; - continue; - }; - file_lines.push(line_str); - line_meta.push((line_number, byte_offset)); + // Parallel phase with `map_init`: each rayon worker thread clones the + // matcher once and gets a reusable read buffer + mmap slot. Buffer holds + // small files, slot holds fresh mmap for cache-miss files + // ≥ FRESH_MMAP_THRESHOLD. + let chunk_results: Vec<(usize, &'a FileItem, Vec)> = chunk + .par_iter() + .enumerate() + .map_init( + || { + ( + matcher.clone(), + Vec::with_capacity(64 * 1024), + MmapSlot::default(), + ) + }, + |(matcher, buf, mmap_slot), (local_idx, file)| { + if abort_signal.load(Ordering::Relaxed) { + budget_exceeded.store(true, Ordering::Relaxed); + return None; } - line_number += 1; - } - - if file_lines.is_empty() { - return None; - } - - // Single-pass: score + indices in one Smith-Waterman run per line. - let matches_with_indices = matcher.match_list_indices(&file_lines); - let mut file_matches: Vec = Vec::new(); - - for mut match_indices in matches_with_indices { - if match_indices.score < min_score { - continue; + if let Some(budget) = time_budget + && search_start.elapsed() > budget + { + budget_exceeded.store(true, Ordering::Relaxed); + return None; } - let idx = match_indices.index as usize; - let raw_line = file_lines[idx]; - - let truncated = truncate_display_bytes(raw_line.as_bytes()); - let display_line = if truncated.len() < raw_line.len() { - // SAFETY: truncate_display_bytes preserves UTF-8 char boundaries - &raw_line[..truncated.len()] + let file_arena = if file.is_overflow() { + overflow_arena } else { - raw_line + arena }; - // If the line was truncated, re-compute indices on the shorter string. - if display_line.len() < raw_line.len() { - let Some(re_indices) = matcher - .match_list_indices(&[display_line]) - .into_iter() - .next() - else { - continue; - }; - match_indices = re_indices; + let file_bytes = + file.get_content_for_search(buf, mmap_slot, file_arena, base_path, budget)?; + + // File-level prefilter: check if enough distinct needle chars + // exist anywhere in the file bytes. Uses memchr for speed. + if min_chars_required > 0 { + let mut chars_found = 0usize; + for &ch in &unique_needle_chars { + if memchr::memchr(ch, file_bytes).is_some() { + chars_found += 1; + if chars_found >= min_chars_required { + break; + } + } + } + if chars_found < min_chars_required { + return None; + } } - // upstream returns indices in reverse order, sort ascending - match_indices.indices.sort_unstable(); + // Validate the whole file as UTF-8 once upfront. Source code + // files are virtually always valid UTF-8; this single check + // replaces per-line from_utf8 calls (~8% of fuzzy grep time). + let file_is_utf8 = std::str::from_utf8(file_bytes).is_ok(); + + // Reuse grep-searcher's LineStep for SIMD-accelerated line iteration. + let mut stepper = LineStep::new(b'\n', 0, file_bytes.len()); + let estimated_lines = (file_bytes.len() / 40).max(64); + let mut file_lines: Vec<&str> = Vec::with_capacity(estimated_lines); + let mut line_meta: Vec<(u64, u64)> = Vec::with_capacity(estimated_lines); + let line_term_lf = fff_grep::LineTerminator::byte(b'\n'); + let line_term_cr = fff_grep::LineTerminator::byte(b'\r'); + + let mut line_number: u64 = 1; + while let Some(line_match) = stepper.next_match(file_bytes) { + let byte_offset = line_match.start() as u64; + + // Strip line terminators (\n, \r). + let trimmed = lines::without_terminator( + lines::without_terminator(&file_bytes[line_match], line_term_lf), + line_term_cr, + ); + + if !trimmed.is_empty() { + // SAFETY: when the whole file is valid UTF-8, every + // sub-slice split on ASCII byte boundaries (\n, \r) + // is also valid UTF-8. + let line_str = if file_is_utf8 { + unsafe { std::str::from_utf8_unchecked(trimmed) } + } else if let Ok(s) = std::str::from_utf8(trimmed) { + s + } else { + line_number += 1; + continue; + }; + file_lines.push(line_str); + line_meta.push((line_number, byte_offset)); + } - // Minimum matched chars: at least (needle_len - max_typos) - // characters must appear. This is consistent with the typo - // budget: each typo can drop one needle char from the alignment. - let min_matched = needle_len.saturating_sub(max_typos).max(1); - if match_indices.indices.len() < min_matched { - continue; + line_number += 1; + } + + if file_lines.is_empty() { + return None; } - let indices = &match_indices.indices; + // Single-pass: score + indices in one Smith-Waterman run per line. + let matches_with_indices = matcher.match_list_indices(&file_lines); + let mut file_matches: Vec = Vec::new(); - if let (Some(&first), Some(&last)) = (indices.first(), indices.last()) { - // Span check: reject widely scattered matches. - let span = last - first + 1; - if span > max_match_span { + for mut match_indices in matches_with_indices { + if match_indices.score < min_score { continue; } - // Density check: matched chars / span must be dense enough. - // Relaxed for perfect subsequence matches (all needle chars - // present), slightly relaxed for typo matches to handle - // delimiter-heavy targets (e.g. "ff_flv_encode_picture_header" - // has span inflated by underscores → density ~68%). - let density = (indices.len() * 100) / span; - let min_density = if indices.len() >= needle_len { - 45 // Perfect subsequence — relaxed (delimiters inflate span) + let idx = match_indices.index as usize; + let raw_line = file_lines[idx]; + + let truncated = truncate_display_bytes(raw_line.as_bytes()); + let display_line = if truncated.len() < raw_line.len() { + // SAFETY: truncate_display_bytes preserves UTF-8 char boundaries + &raw_line[..truncated.len()] } else { - 65 // Has typos — moderately strict + raw_line }; - if density < min_density { - continue; + + // If the line was truncated, re-compute indices on the shorter string. + if display_line.len() < raw_line.len() { + let Some(re_indices) = matcher + .match_list_indices(&[display_line]) + .into_iter() + .next() + else { + continue; + }; + match_indices = re_indices; } - // Gap count check: count discontinuities in the indices. - let gap_count = indices.windows(2).filter(|w| w[1] != w[0] + 1).count(); - if gap_count > max_gaps { + match_indices.indices.sort_unstable(); + + // Minimum matched chars: at least (needle_len - max_typos) + // characters must appear. This is consistent with the typo + // budget: each typo can drop one needle char from the alignment. + let min_matched = needle_len.saturating_sub(max_typos).max(1); + if match_indices.indices.len() < min_matched { continue; } + + let indices = &match_indices.indices; + + if let (Some(&first), Some(&last)) = (indices.first(), indices.last()) { + // Span check: reject widely scattered matches. + let span = last - first + 1; + if span > max_match_span { + continue; + } + + // Density check: matched chars / span must be dense enough. + // Relaxed for perfect subsequence matches (all needle chars + // present), slightly relaxed for typo matches to handle + // delimiter-heavy targets (e.g. "ff_flv_encode_picture_header" + // has span inflated by underscores → density ~68%). + let density = (indices.len() * 100) / span; + let min_density = if indices.len() >= needle_len { + 45 // Perfect subsequence — relaxed (delimiters inflate span) + } else { + 65 // Has typos — moderately strict + }; + if density < min_density { + continue; + } + + // Gap count check: count discontinuities in the indices. + let gap_count = indices.windows(2).filter(|w| w[1] != w[0] + 1).count(); + if gap_count > max_gaps { + continue; + } + } + + let (ln, bo) = line_meta[idx]; + let match_byte_offsets = + char_indices_to_byte_offsets(display_line, &match_indices.indices); + let col = match_byte_offsets + .first() + .map(|r| r.0 as usize) + .unwrap_or(0); + + file_matches.push(GrepMatch { + file_index: 0, + line_number: ln, + col, + byte_offset: bo, + is_definition: options.classify_definitions + && is_definition_line(display_line), + line_content: display_line.to_string(), + match_byte_offsets, + fuzzy_score: Some(match_indices.score), + context_before: Vec::new(), + context_after: Vec::new(), + }); + + if max_matches_per_file != 0 && file_matches.len() >= max_matches_per_file { + break; + } } - let (ln, bo) = line_meta[idx]; - let match_byte_offsets = - char_indices_to_byte_offsets(display_line, &match_indices.indices); - let col = match_byte_offsets - .first() - .map(|r| r.0 as usize) - .unwrap_or(0); - - file_matches.push(GrepMatch { - file_index: 0, - line_number: ln, - col, - byte_offset: bo, - is_definition: options.classify_definitions - && is_definition_line(display_line), - line_content: display_line.to_string(), - match_byte_offsets, - fuzzy_score: Some(match_indices.score), - context_before: Vec::new(), - context_after: Vec::new(), - }); - - if max_matches_per_file != 0 && file_matches.len() >= max_matches_per_file { - break; + if file_matches.is_empty() { + return None; } - } - if file_matches.is_empty() { - return None; - } + Some((chunk_offset + local_idx, *file, file_matches)) + }, + ) + .flatten() + .collect(); - Some((idx, *file, file_matches)) - }, - ) - .flatten() - .collect(); + for result in chunk_results { + running_matches += result.2.len(); + per_file_results.push(result); + } + + if running_matches >= page_limit || budget_exceeded.load(Ordering::Relaxed) { + break; + } + } collect_grep_results( per_file_results, diff --git a/crates/fff-nvim/Cargo.toml b/crates/fff-nvim/Cargo.toml index d689b7ee..1b2e9077 100644 --- a/crates/fff-nvim/Cargo.toml +++ b/crates/fff-nvim/Cargo.toml @@ -32,6 +32,7 @@ once_cell = "1.20.2" [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } rand = { version = "0.8", features = ["small_rng"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } [[bench]] name = "fuzzy_search" From ade57eacf723151333ba88e40a8c008fd2a7cdbb Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sat, 13 Jun 2026 19:53:12 -0700 Subject: [PATCH 3/3] chore: Upgrade frizbee --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dcae5824..7913d654 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,9 +1505,9 @@ dependencies = [ [[package]] name = "neo_frizbee" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728e0731ad3a0083b9f72a82df72c298641ba970d4e308e0897ec4c89212c31d" +checksum = "0dd76fab81213d184cc28a7757791775bdcfd7f2a15e3558d7a4f7e4ee7de864" dependencies = [ "itertools 0.14.0", "raw-cpuid", diff --git a/Cargo.toml b/Cargo.toml index 64454117..1195dbd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ signal-hook-registry = "1.4" zlob = "1.4.1" mlua = { version = "0.11.1", features = ["module", "luajit"] } -neo_frizbee = { version = "0.10.2", features = ["match_end_col"] } +neo_frizbee = { version = "0.10.3", features = ["match_end_col"] } notify = { version = "9.0.0-rc.3" } notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.4" } once_cell = "1.20.2"