2026-08-02 19:04:30 -04:00
|
|
|
|
//! The ranked search cascade.
|
|
|
|
|
|
//!
|
2026-08-03 03:06:19 -04:00
|
|
|
|
//! One term, four table scans, eleven ranks. Rank base = stage number:
|
2026-08-02 19:04:30 -04:00
|
|
|
|
//!
|
|
|
|
|
|
//! | rank | meaning | scan |
|
|
|
|
|
|
//! |-----:|----------------------------------|------|
|
|
|
|
|
|
//! | 1.x | exact filename, exact case | A |
|
|
|
|
|
|
//! | 2.x | exact filename, any case | A |
|
|
|
|
|
|
//! | 3.x | filename substring, exact case | A |
|
|
|
|
|
|
//! | 4.x | filename substring, any case | A |
|
|
|
|
|
|
//! | 5.x | full text occurrence, exact case | B |
|
|
|
|
|
|
//! | 6.x | full text occurrence, any case | B |
|
|
|
|
|
|
//! | 7.x | fuzzy filename | C |
|
|
|
|
|
|
//! | 8.x | fuzzy full text | D |
|
|
|
|
|
|
//! | 9.x | full path substring, exact case | A |
|
|
|
|
|
|
//! | 10.x | full path substring, any case | A |
|
|
|
|
|
|
//! | 11.x | fuzzy full path | C |
|
|
|
|
|
|
//!
|
2026-08-23 00:33:53 -04:00
|
|
|
|
//! Pass A is one `files` scan classified per-row in Rust; pass B one FTS
|
|
|
|
|
|
//! MATCH verified against the text; passes C/D (opt-in) bitap-scan the table.
|
|
|
|
|
|
//! Path tiers are buffered and flushed last.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
//!
|
2026-08-23 00:33:53 -04:00
|
|
|
|
//! Batches are ordered within themselves, not against each other — do not
|
|
|
|
|
|
//! assume arrival order is rank order.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
|
|
use std::collections::HashSet;
|
2026-08-20 18:58:25 -04:00
|
|
|
|
use std::hash::{BuildHasherDefault, Hasher};
|
2026-08-02 19:04:30 -04:00
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
2026-08-03 03:06:19 -04:00
|
|
|
|
use std::time::{Duration, Instant};
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
|
|
use rusqlite::Connection;
|
|
|
|
|
|
|
|
|
|
|
|
use rusqlite::OptionalExtension;
|
|
|
|
|
|
|
|
|
|
|
|
use crate::config::IgnoreSet;
|
|
|
|
|
|
use crate::query::pattern::clamp_match_range;
|
|
|
|
|
|
use crate::query::split::CascadeQuery;
|
|
|
|
|
|
use crate::query::translator::{escape_like, quote_phrase};
|
|
|
|
|
|
use crate::snippet;
|
|
|
|
|
|
|
2026-08-20 18:58:25 -04:00
|
|
|
|
use super::fuzzy::{edit_budget, pigeonhole_chunks, Bitap};
|
2026-09-06 19:43:57 -04:00
|
|
|
|
use super::{prefilter, SearchHit, SearchOptions};
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
mod passes;
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Cancellation check stride for row-cheap passes; decompression-heavy passes check every row.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
const CANCEL_CHECK_ROWS: usize = 256;
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Snippet window budget: the GUI trims to column width; mouseover shows the rest.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
const SNIPPET_WINDOW_CHARS: usize = 600;
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// The Content Match snippet for one document body, cut exactly as the
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// full-text passes cut it; shared with [`crate::live`] so a re-cut matches.
|
|
|
|
|
|
/// `folded` must be `text` ASCII-lowercased — byte-length preserving, which
|
|
|
|
|
|
/// is the whole reason offsets found in it can slice `text`.
|
2026-08-17 19:26:18 -04:00
|
|
|
|
pub fn text_snippet(
|
|
|
|
|
|
pattern: &crate::query::pattern::TermPattern,
|
|
|
|
|
|
text: &str,
|
|
|
|
|
|
folded: &str,
|
|
|
|
|
|
) -> Option<snippet::Snippet> {
|
|
|
|
|
|
let opts = snippet::Options {
|
|
|
|
|
|
approx_chars: SNIPPET_WINDOW_CHARS,
|
|
|
|
|
|
};
|
2026-08-20 18:58:25 -04:00
|
|
|
|
match text_snippet_counted(pattern, text, folded) {
|
|
|
|
|
|
Some((snip, _)) => Some(snip),
|
2026-08-17 19:26:18 -04:00
|
|
|
|
None => pattern.find_first_folded(folded).map(|r| {
|
|
|
|
|
|
// A greedy pattern can match megabytes; clamp before the window.
|
|
|
|
|
|
let r = clamp_match_range(text, r, SNIPPET_WINDOW_CHARS);
|
|
|
|
|
|
snippet::window_around(text, (r.start, r.end), &opts)
|
|
|
|
|
|
}),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// [`text_snippet`] for a literal pattern, plus the case-insensitive
|
|
|
|
|
|
/// occurrence count found on the way. `None` when the pattern is not literal.
|
2026-08-20 18:58:25 -04:00
|
|
|
|
pub fn text_snippet_counted(
|
|
|
|
|
|
pattern: &crate::query::pattern::TermPattern,
|
|
|
|
|
|
text: &str,
|
|
|
|
|
|
folded: &str,
|
|
|
|
|
|
) -> Option<(snippet::Snippet, usize)> {
|
|
|
|
|
|
let opts = snippet::Options {
|
|
|
|
|
|
approx_chars: SNIPPET_WINDOW_CHARS,
|
|
|
|
|
|
};
|
|
|
|
|
|
let term = pattern.literal_folded()?;
|
|
|
|
|
|
Some(snippet::extract_folded(text, folded, &[term], &opts))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// The fuzzy full-text match in one document body: occurrence count within
|
|
|
|
|
|
/// the edit budget and the window around the first occurrence; `None` when
|
|
|
|
|
|
/// absent. Shared with [`crate::live`]; no folded copy — the matcher folds
|
|
|
|
|
|
/// in its mask table.
|
2026-08-17 19:26:18 -04:00
|
|
|
|
pub fn fuzzy_snippet(
|
|
|
|
|
|
bitap: &crate::search::fuzzy::Bitap,
|
|
|
|
|
|
text: &str,
|
|
|
|
|
|
) -> Option<(usize, snippet::Snippet)> {
|
|
|
|
|
|
let opts = snippet::Options {
|
|
|
|
|
|
approx_chars: SNIPPET_WINDOW_CHARS,
|
|
|
|
|
|
};
|
2026-08-20 18:58:25 -04:00
|
|
|
|
let (count, first) = bitap.count_and_first(text.as_bytes());
|
2026-08-17 19:26:18 -04:00
|
|
|
|
first.map(|range| (count, snippet::window_around(text, range, &opts)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub struct Outcome {
|
|
|
|
|
|
pub total: usize,
|
|
|
|
|
|
pub limited: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Run the cascade, streaming rank-ordered batches into `sink`. `Ok(None)`
|
|
|
|
|
|
/// means cancelled (generation moved on) — the caller sends no completion.
|
|
|
|
|
|
/// SQL errors are returned as strings *unless* already cancelled (an
|
|
|
|
|
|
/// interrupted statement is normal cancellation, not an error).
|
2026-08-02 19:04:30 -04:00
|
|
|
|
pub fn run(
|
|
|
|
|
|
conn: &Connection,
|
|
|
|
|
|
query: &CascadeQuery,
|
|
|
|
|
|
options: &SearchOptions,
|
|
|
|
|
|
generation: u64,
|
|
|
|
|
|
latest_gen: &AtomicU64,
|
|
|
|
|
|
sink: &mut dyn FnMut(Vec<SearchHit>),
|
|
|
|
|
|
) -> Result<Option<Outcome>, String> {
|
|
|
|
|
|
if query.is_empty() {
|
|
|
|
|
|
return Ok(Some(Outcome {
|
|
|
|
|
|
total: 0,
|
|
|
|
|
|
limited: false,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
let ignore = IgnoreSet::compile(&options.session_ignores)
|
|
|
|
|
|
.map_err(|e| format!("session ignore filter: {}", e))?;
|
|
|
|
|
|
|
|
|
|
|
|
let mut cx = Cx {
|
|
|
|
|
|
conn,
|
|
|
|
|
|
query,
|
|
|
|
|
|
options,
|
|
|
|
|
|
generation,
|
|
|
|
|
|
latest_gen,
|
|
|
|
|
|
ignore,
|
2026-08-20 18:58:25 -04:00
|
|
|
|
emitted: IdSet::default(),
|
2026-08-02 19:04:30 -04:00
|
|
|
|
deferred_path: Deferred::default(),
|
|
|
|
|
|
deferred_fuzzy_path: Deferred::default(),
|
2026-09-06 19:43:57 -04:00
|
|
|
|
regex_doc: query
|
|
|
|
|
|
.regex
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.map(|_| crate::db::repo::DocDecoder::new())
|
|
|
|
|
|
.transpose()?,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
total: 0,
|
|
|
|
|
|
limited: false,
|
|
|
|
|
|
sink,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// With no term at all the regex drives its own scans; `Path` still
|
|
|
|
|
|
// flushes the deferred rank-10 buffer the name pass sets aside.
|
|
|
|
|
|
let passes: &[Pass] = if query.pattern.is_empty() {
|
|
|
|
|
|
&[Pass::RegexName, Pass::RegexContent, Pass::Path]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
&[
|
|
|
|
|
|
Pass::Filename,
|
|
|
|
|
|
Pass::FullText,
|
|
|
|
|
|
Pass::FuzzyFilename,
|
|
|
|
|
|
Pass::FuzzyFullText,
|
|
|
|
|
|
Pass::Path,
|
|
|
|
|
|
Pass::FuzzyPath,
|
|
|
|
|
|
]
|
|
|
|
|
|
};
|
|
|
|
|
|
for pass in passes {
|
|
|
|
|
|
if cx.cancelled() {
|
|
|
|
|
|
return Ok(None);
|
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
|
// Stop, but do not call it truncation: `remaining() == 0` is also
|
|
|
|
|
|
// what an exactly-full result set looks like. `flush_pass` sets
|
|
|
|
|
|
// `limited` only when it actually drops rows.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
if cx.remaining() == 0 {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
let run_pass = match pass {
|
|
|
|
|
|
Pass::Filename => cx.pass_filename(),
|
|
|
|
|
|
Pass::FullText => cx.pass_fulltext(),
|
|
|
|
|
|
Pass::FuzzyFilename => cx.pass_fuzzy_filename(),
|
|
|
|
|
|
Pass::FuzzyFullText => cx.pass_fuzzy_fulltext(),
|
|
|
|
|
|
Pass::RegexName => cx.pass_regex_name(),
|
|
|
|
|
|
Pass::RegexContent => cx.pass_regex_content(),
|
|
|
|
|
|
Pass::Path => {
|
|
|
|
|
|
let d = std::mem::take(&mut cx.deferred_path);
|
2026-08-23 00:33:53 -04:00
|
|
|
|
cx.flush_deferred(d);
|
|
|
|
|
|
Ok(true)
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
Pass::FuzzyPath => {
|
|
|
|
|
|
let d = std::mem::take(&mut cx.deferred_fuzzy_path);
|
2026-08-23 00:33:53 -04:00
|
|
|
|
cx.flush_deferred(d);
|
|
|
|
|
|
Ok(true)
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
match run_pass {
|
|
|
|
|
|
Ok(true) => {}
|
|
|
|
|
|
Ok(false) => return Ok(None), // cancelled mid-pass
|
|
|
|
|
|
Err(e) => {
|
2026-08-04 23:33:28 -04:00
|
|
|
|
// A kill from `interrupt()` arrives as an ordinary SQL error,
|
|
|
|
|
|
// and SQLite's interrupt flag carries no ordering edge to the
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// generation counter bumped just before it: without this
|
|
|
|
|
|
// fence a weakly-ordered CPU could report routine
|
|
|
|
|
|
// cancellation as `Search failed: interrupted`.
|
2026-08-04 23:33:28 -04:00
|
|
|
|
std::sync::atomic::fence(Ordering::Acquire);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
if cx.cancelled() {
|
|
|
|
|
|
return Ok(None); // interrupt() killed the statement
|
|
|
|
|
|
}
|
|
|
|
|
|
return Err(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(Some(Outcome {
|
|
|
|
|
|
total: cx.total,
|
|
|
|
|
|
limited: cx.limited,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
enum Pass {
|
|
|
|
|
|
Filename,
|
|
|
|
|
|
FullText,
|
|
|
|
|
|
FuzzyFilename,
|
|
|
|
|
|
FuzzyFullText,
|
|
|
|
|
|
/// Regex-only: name hits at rank 4 now, path hits deferred to rank 10.
|
|
|
|
|
|
RegexName,
|
|
|
|
|
|
/// Regex-only: content hits at rank 6.
|
|
|
|
|
|
RegexContent,
|
|
|
|
|
|
/// Flush of the rank 9–10 hits pass A set aside.
|
|
|
|
|
|
Path,
|
|
|
|
|
|
/// Flush of the rank 11 hits pass C set aside.
|
|
|
|
|
|
FuzzyPath,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// More occurrences → smaller fraction → sorts earlier within a rank base; 1000+ adds zero.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
fn count_frac(count: usize) -> f64 {
|
|
|
|
|
|
(1000usize.saturating_sub(count.min(1000))) as f64 / 1000.0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
fn col<T: rusqlite::types::FromSql>(row: &rusqlite::Row<'_>, idx: usize) -> Result<T, String> {
|
|
|
|
|
|
row.get(idx).map_err(|e| e.to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Rank, then name, then path — the path tiebreak makes the order total.
|
2026-08-09 16:25:43 -04:00
|
|
|
|
fn rank_order(a: &SearchHit, b: &SearchHit) -> std::cmp::Ordering {
|
|
|
|
|
|
a.rank
|
|
|
|
|
|
.total_cmp(&b.rank)
|
|
|
|
|
|
.then_with(|| a.name.cmp(&b.name))
|
|
|
|
|
|
.then_with(|| a.path.cmp(&b.path))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// The columns every pass selects, in the order the passes index them:
|
|
|
|
|
|
/// `0` id, `1` name, `2` parent, `3` size, `4` mtime, optionally `5` text.
|
|
|
|
|
|
/// A pass spelling its own order would quietly serve parents as names.
|
2026-08-20 02:34:08 -04:00
|
|
|
|
const HIT_COLUMNS: &str = "f.id, f.name, f.parent, f.size, f.mtime";
|
2026-08-05 18:05:04 -04:00
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Columns 3 and 4. `size` is signed in SQLite; without the clamp a corrupt
|
|
|
|
|
|
/// `-1` would become 18 exabytes on the way to `u64`.
|
2026-08-05 18:05:04 -04:00
|
|
|
|
fn size_and_mtime(row: &rusqlite::Row<'_>) -> Result<(u64, i64), String> {
|
2026-08-09 16:25:43 -04:00
|
|
|
|
let size = col::<i64>(row, 3)?.max(0) as u64;
|
|
|
|
|
|
let mtime = col(row, 4)?;
|
2026-08-05 18:05:04 -04:00
|
|
|
|
Ok((size, mtime))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Same specificity floor as the trigram pass; wildcards count only literals.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
fn path_tiers_enabled(pattern: &crate::query::pattern::TermPattern) -> bool {
|
|
|
|
|
|
pattern.literal_char_count() >= 3
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Hits ranked below later scans, held back until every better stage emitted.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
struct Deferred {
|
|
|
|
|
|
hits: Vec<SearchHit>,
|
|
|
|
|
|
overflowed: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Longest a pass may sit on hits: draining on a clock keeps a sparse query
|
|
|
|
|
|
/// painting; short enough to land 2–3 batches inside the GUI's 250 ms fade.
|
2026-08-03 03:06:19 -04:00
|
|
|
|
const FLUSH_INTERVAL: Duration = Duration::from_millis(80);
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Multiplicative hasher (odd constant — a bijection) for SQLite rowids.
|
|
|
|
|
|
/// Measured ~5% of a fuzzy search over SipHash; don't put SipHash back.
|
2026-08-20 18:58:25 -04:00
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
struct IdHasher(u64);
|
|
|
|
|
|
|
|
|
|
|
|
impl Hasher for IdHasher {
|
|
|
|
|
|
fn finish(&self) -> u64 {
|
|
|
|
|
|
self.0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
// Only `write_i64` is ever used; `write` exists because the trait requires it.
|
2026-08-20 18:58:25 -04:00
|
|
|
|
fn write(&mut self, bytes: &[u8]) {
|
|
|
|
|
|
for &b in bytes {
|
|
|
|
|
|
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn write_i64(&mut self, n: i64) {
|
|
|
|
|
|
self.write_u64(n as u64);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn write_u64(&mut self, n: u64) {
|
|
|
|
|
|
let mixed = n.wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
|
|
|
|
|
self.0 = mixed.rotate_left(31) ^ mixed;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type IdSet = HashSet<i64, BuildHasherDefault<IdHasher>>;
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
struct Cx<'a> {
|
|
|
|
|
|
conn: &'a Connection,
|
|
|
|
|
|
query: &'a CascadeQuery,
|
|
|
|
|
|
options: &'a SearchOptions,
|
|
|
|
|
|
generation: u64,
|
|
|
|
|
|
latest_gen: &'a AtomicU64,
|
|
|
|
|
|
ignore: IgnoreSet,
|
2026-08-20 18:58:25 -04:00
|
|
|
|
emitted: IdSet,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
/// Ranks 9–10, filled by pass A.
|
|
|
|
|
|
deferred_path: Deferred,
|
|
|
|
|
|
/// Rank 11, filled by pass C.
|
|
|
|
|
|
deferred_fuzzy_path: Deferred,
|
2026-09-06 19:43:57 -04:00
|
|
|
|
/// Decoder for [`Cx::regex_accepts`]'s content fetches, built once per
|
|
|
|
|
|
/// search and only when the query carries a regex — the predicate runs
|
|
|
|
|
|
/// per candidate row, exactly the shape [`crate::db::repo::DocDecoder`]
|
|
|
|
|
|
/// exists for.
|
|
|
|
|
|
regex_doc: Option<crate::db::repo::DocDecoder>,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
total: usize,
|
|
|
|
|
|
limited: bool,
|
|
|
|
|
|
sink: &'a mut dyn FnMut(Vec<SearchHit>),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 03:06:19 -04:00
|
|
|
|
struct FlushClock {
|
|
|
|
|
|
last: Instant,
|
|
|
|
|
|
sent_anything: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl FlushClock {
|
|
|
|
|
|
fn new() -> FlushClock {
|
|
|
|
|
|
FlushClock {
|
|
|
|
|
|
last: Instant::now(),
|
|
|
|
|
|
sent_anything: false,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn due(&self, len: usize, batch: usize) -> bool {
|
|
|
|
|
|
if len == 0 {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
!self.sent_anything || len >= batch || self.last.elapsed() >= FLUSH_INTERVAL
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn mark_sent(&mut self) {
|
|
|
|
|
|
self.last = Instant::now();
|
|
|
|
|
|
self.sent_anything = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
impl<'a> Cx<'a> {
|
|
|
|
|
|
fn cancelled(&self) -> bool {
|
|
|
|
|
|
self.generation != self.latest_gen.load(Ordering::Relaxed)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn remaining(&self) -> usize {
|
|
|
|
|
|
self.options.limit.saturating_sub(self.total)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Headroom so sorting keeps the best candidates, without unbounded growth.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
fn buffer_cap(&self) -> usize {
|
|
|
|
|
|
4096.max(2 * self.remaining())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn params_with_filters(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
leading: Vec<rusqlite::types::Value>,
|
|
|
|
|
|
) -> Vec<rusqlite::types::Value> {
|
|
|
|
|
|
let mut p = leading;
|
|
|
|
|
|
p.extend(self.query.filter_params.iter().cloned());
|
|
|
|
|
|
p
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn skip(&self, file_id: i64, path: &str) -> bool {
|
|
|
|
|
|
self.emitted.contains(&file_id) || self.ignore.matches_path(std::path::Path::new(path))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// The `regex:` accept-predicate when a regex accompanies a term. The
|
|
|
|
|
|
/// path contains the name, so one path check covers both; content is
|
2026-09-06 19:43:57 -04:00
|
|
|
|
/// fetched only for rows whose path missed — per candidate row, which is
|
|
|
|
|
|
/// why the statement is cached and the decoder ([`Cx::regex_doc`]) is
|
|
|
|
|
|
/// reused, and the blob is decoded borrowed rather than copied out.
|
|
|
|
|
|
/// Measured (`benches/search_alloc.rs`, case "term + regex"): 42.6 MiB
|
|
|
|
|
|
/// churned per keystroke → 4.3, and ~1.3x on the case's time, over the
|
|
|
|
|
|
/// `query_row` + `decode_all` shape this replaced.
|
|
|
|
|
|
fn regex_accepts(
|
|
|
|
|
|
&mut self,
|
|
|
|
|
|
file_id: i64,
|
|
|
|
|
|
path: &str,
|
|
|
|
|
|
text: Option<&str>,
|
|
|
|
|
|
) -> Result<bool, String> {
|
|
|
|
|
|
let Cx {
|
|
|
|
|
|
conn,
|
|
|
|
|
|
query,
|
|
|
|
|
|
regex_doc,
|
|
|
|
|
|
..
|
|
|
|
|
|
} = self;
|
|
|
|
|
|
let Some(re) = &query.regex else {
|
2026-08-02 19:04:30 -04:00
|
|
|
|
return Ok(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
if re.is_match(path) {
|
|
|
|
|
|
return Ok(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(text) = text {
|
|
|
|
|
|
return Ok(re.is_match(text));
|
|
|
|
|
|
}
|
2026-09-06 19:43:57 -04:00
|
|
|
|
let doc = regex_doc
|
|
|
|
|
|
.as_mut()
|
|
|
|
|
|
.expect("built in run() whenever the query carries a regex");
|
|
|
|
|
|
let accepted = conn
|
|
|
|
|
|
.prepare_cached("SELECT text_zstd FROM documents_text WHERE file_id = ?1")
|
|
|
|
|
|
.and_then(|mut stmt| {
|
|
|
|
|
|
stmt.query_row([file_id], |r| {
|
|
|
|
|
|
let blob = r.get_ref(0)?.as_blob()?;
|
|
|
|
|
|
// Strict UTF-8 via `decode` is not a behaviour change:
|
|
|
|
|
|
// bodies are written from `&str` (`repo::set_content_done`),
|
|
|
|
|
|
// and `stored_text` already treats non-UTF-8 as absent.
|
|
|
|
|
|
Ok(doc.decode(blob).is_some_and(|t| re.is_match(t)))
|
|
|
|
|
|
})
|
|
|
|
|
|
.optional()
|
|
|
|
|
|
})
|
2026-08-02 19:04:30 -04:00
|
|
|
|
.map_err(|e| e.to_string())?;
|
2026-09-06 19:43:57 -04:00
|
|
|
|
Ok(accepted.unwrap_or(false))
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 03:06:19 -04:00
|
|
|
|
/// Hand `buf` over mid-scan if it is due, leaving it empty when it goes.
|
|
|
|
|
|
fn flush_if_due(&mut self, buf: &mut Vec<SearchHit>, clock: &mut FlushClock) {
|
|
|
|
|
|
if !clock.due(buf.len(), self.options.batch.max(1)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let batch = std::mem::take(buf);
|
2026-08-23 00:33:53 -04:00
|
|
|
|
// `overflowed` belongs to the pass as a whole; the final flush reports it.
|
2026-08-03 03:06:19 -04:00
|
|
|
|
self.flush_pass(batch, false);
|
|
|
|
|
|
clock.mark_sent();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
fn flush_pass(&mut self, mut buf: Vec<SearchHit>, overflowed: bool) {
|
2026-08-09 16:25:43 -04:00
|
|
|
|
buf.sort_by(rank_order);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
let room = self.remaining();
|
|
|
|
|
|
if buf.len() > room {
|
|
|
|
|
|
buf.truncate(room);
|
|
|
|
|
|
self.limited = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if overflowed {
|
|
|
|
|
|
self.limited = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.total += buf.len();
|
|
|
|
|
|
for hit in &buf {
|
|
|
|
|
|
self.emitted.insert(hit.file_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
let batch = self.options.batch.max(1);
|
|
|
|
|
|
let mut buf = buf.into_iter().peekable();
|
|
|
|
|
|
while buf.peek().is_some() {
|
2026-08-23 00:33:53 -04:00
|
|
|
|
// A cancelled search stops emitting — the newer generation owns the UI.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
if self.cancelled() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let chunk: Vec<SearchHit> = buf.by_ref().take(batch).collect();
|
|
|
|
|
|
(self.sink)(chunk);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Emit a held-back buffer; anything a better stage emitted since drops out here.
|
|
|
|
|
|
fn flush_deferred(&mut self, mut deferred: Deferred) {
|
2026-08-02 19:04:30 -04:00
|
|
|
|
deferred.hits.retain(|h| !self.emitted.contains(&h.file_id));
|
|
|
|
|
|
self.flush_pass(deferred.hits, deferred.overflowed);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
/// Sort + cut back to display-limit room once past the cap; true if anything dropped.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
fn enforce_cap(&self, buf: &mut Vec<SearchHit>) -> bool {
|
|
|
|
|
|
if buf.len() <= self.buffer_cap() {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
|
buf.sort_by(rank_order);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
buf.truncate(self.remaining());
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|