@pify/search 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ //! Fuzzy path matching and ranking.
2
+ //!
3
+ //! `find` answers with everything matching a glob, in filesystem order. The
4
+ //! question people actually ask is "the auth route file — you know the one",
5
+ //! which needs two things a glob cannot give: a match that tolerates how
6
+ //! people type, and an order that puts the likely file first.
7
+ //!
8
+ //! The model is fff's — match quality, plus frecency, recency and git state —
9
+ //! and the constants are kept identical to the TypeScript fallback so the two
10
+ //! engines rank the same way. A user who loses the native binary should get a
11
+ //! slower search, not a differently-ordered one.
12
+
13
+ pub const BONUS_EXACT_FILENAME: i32 = 300;
14
+ pub const BONUS_EXACT_STEM: i32 = 200;
15
+ pub const BONUS_FILENAME_PREFIX: i32 = 120;
16
+ pub const BONUS_IN_FILENAME: i32 = 60;
17
+ pub const BONUS_CONSECUTIVE: i32 = 12;
18
+ pub const BONUS_BOUNDARY: i32 = 18;
19
+ pub const PENALTY_LEADING: i32 = 2;
20
+ pub const PENALTY_TYPO: i32 = 40;
21
+ pub const PENALTY_DEPTH: i32 = 3;
22
+
23
+ /// One wrong letter in four is a different mistake from one in twenty.
24
+ pub fn max_typos(query: &str) -> usize {
25
+ (query.chars().count() / 4).clamp(2, 6)
26
+ }
27
+
28
+ pub fn recency_boost(mtime_ms: i64, now_ms: i64) -> i32 {
29
+ if mtime_ms <= 0 {
30
+ return 0;
31
+ }
32
+ let age = (now_ms - mtime_ms) / 1000;
33
+ match age {
34
+ a if a < 120 => 16,
35
+ a if a < 900 => 8,
36
+ a if a < 3_600 => 4,
37
+ a if a < 86_400 => 2,
38
+ a if a < 604_800 => 1,
39
+ _ => 0,
40
+ }
41
+ }
42
+
43
+ pub fn git_boost(status: &str) -> i32 {
44
+ match status {
45
+ "modified" => 24,
46
+ "staged" => 20,
47
+ "untracked" => 12,
48
+ _ => 0,
49
+ }
50
+ }
51
+
52
+ struct Match {
53
+ positions: Vec<usize>,
54
+ typos: usize,
55
+ }
56
+
57
+ /// Subsequence match within a typo budget. A skipped query character is the
58
+ /// typo — a slip or a transposition — and spending more than the budget means
59
+ /// this is simply not the file.
60
+ fn match_positions(haystack: &str, needle: &str, budget: usize) -> Option<Match> {
61
+ let hay: Vec<char> = haystack.chars().flat_map(|c| c.to_lowercase()).collect();
62
+ let need: Vec<char> = needle.chars().flat_map(|c| c.to_lowercase()).collect();
63
+ let mut positions = Vec::with_capacity(need.len());
64
+ let mut typos = 0usize;
65
+ let mut at = 0usize;
66
+
67
+ for &want in &need {
68
+ match hay[at.min(hay.len())..].iter().position(|&c| c == want) {
69
+ Some(offset) => {
70
+ let found = at + offset;
71
+ positions.push(found);
72
+ at = found + 1;
73
+ }
74
+ None => {
75
+ typos += 1;
76
+ if typos > budget {
77
+ return None;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ if positions.is_empty() {
83
+ return None;
84
+ }
85
+ Some(Match { positions, typos })
86
+ }
87
+
88
+ fn is_boundary(chars: &[char], index: usize) -> bool {
89
+ if index == 0 {
90
+ return true;
91
+ }
92
+ let prev = chars[index - 1];
93
+ prev == '/' || prev == '_' || prev == '-' || prev == '.' || (prev.is_lowercase() && chars[index].is_uppercase())
94
+ }
95
+
96
+ pub struct Candidate<'a> {
97
+ pub path: &'a str,
98
+ pub frecency: i32,
99
+ pub git: &'a str,
100
+ pub mtime_ms: i64,
101
+ }
102
+
103
+ /// Score one candidate, or `None` when the query does not match it at all.
104
+ pub fn score(candidate: &Candidate, query: &str, now_ms: i64) -> Option<i32> {
105
+ let base = candidate.frecency + git_boost(candidate.git) + recency_boost(candidate.mtime_ms, now_ms);
106
+ if query.is_empty() {
107
+ return Some(base);
108
+ }
109
+
110
+ let m = match_positions(candidate.path, query, max_typos(query))?;
111
+ let chars: Vec<char> = candidate.path.chars().collect();
112
+
113
+ let filename = candidate.path.rsplit('/').next().unwrap_or(candidate.path);
114
+ let lower_name = filename.to_lowercase();
115
+ let lower_query = query.to_lowercase();
116
+ let stem = match lower_name.rfind('.') {
117
+ Some(dot) if dot > 0 => &lower_name[..dot],
118
+ _ => lower_name.as_str(),
119
+ };
120
+
121
+ let mut total = 0i32;
122
+ if lower_name == lower_query {
123
+ total += BONUS_EXACT_FILENAME;
124
+ } else if stem == lower_query {
125
+ total += BONUS_EXACT_STEM;
126
+ } else if lower_name.starts_with(&lower_query) {
127
+ total += BONUS_FILENAME_PREFIX;
128
+ } else if lower_name.contains(&lower_query) {
129
+ total += BONUS_IN_FILENAME;
130
+ }
131
+
132
+ for (i, &at) in m.positions.iter().enumerate() {
133
+ if i > 0 && at == m.positions[i - 1] + 1 {
134
+ total += BONUS_CONSECUTIVE;
135
+ }
136
+ if at < chars.len() && is_boundary(&chars, at) {
137
+ total += BONUS_BOUNDARY;
138
+ }
139
+ if i == 0 {
140
+ total -= (at.min(40) as i32) * PENALTY_LEADING;
141
+ }
142
+ }
143
+
144
+ total -= (m.typos as i32) * PENALTY_TYPO;
145
+ total -= (candidate.path.matches('/').count() as i32) * PENALTY_DEPTH;
146
+ Some(total + base)
147
+ }
@@ -0,0 +1,194 @@
1
+ //! Making the index outlive the process.
2
+ //!
3
+ //! An in-memory index is rebuilt every time pi starts. On this suite that
4
+ //! costs 33ms and nobody notices; on a hundred-thousand-file monorepo it is
5
+ //! seconds, paid again at every session, for a tree that has barely changed.
6
+ //! tgrep's answer is the whole reason it is fast in practice — build once,
7
+ //! persist, and on the next start reload and reconcile instead of rebuilding.
8
+ //!
9
+ //! The format is deliberately dull: a header, the file table, then the posting
10
+ //! lists, all little-endian. No mmap, because a memory-mapped file is a
11
+ //! different set of failure modes on every platform and the load is already
12
+ //! bounded by the disk read.
13
+ //!
14
+ //! Correctness rests on one rule. A stored entry is trusted only while its
15
+ //! size and mtime still match what is on disk; anything that differs, or is
16
+ //! new, or has vanished, is re-read before a query can see it. A stale index
17
+ //! that answers confidently is worse than no index at all.
18
+
19
+ use std::collections::HashMap;
20
+ use std::fs::File;
21
+ use std::io::{BufWriter, Write};
22
+ use std::path::Path;
23
+
24
+ /// Bumped whenever the layout changes. A mismatch is not an error — it means
25
+ /// the cache is from another version and the tree is simply re-indexed.
26
+ const MAGIC: &[u8; 8] = b"PIFYSRC2";
27
+
28
+ pub struct StoredFile {
29
+ pub rel: String,
30
+ pub size: u64,
31
+ pub mtime_ms: i64,
32
+ pub trigrams: Vec<u32>,
33
+ }
34
+
35
+ fn write_u32(out: &mut impl Write, value: u32) -> std::io::Result<()> {
36
+ out.write_all(&value.to_le_bytes())
37
+ }
38
+
39
+ /// Trigram lists are sorted, so the gaps between neighbours are far smaller
40
+ /// than the values themselves and a varint spends one or two bytes where a
41
+ /// fixed `u32` spends four. This is what decides whether persisting is worth
42
+ /// it at all: a cache that is larger than the source it summarises costs more
43
+ /// to read back than the rebuild it was meant to avoid.
44
+ fn write_varint(out: &mut impl Write, mut value: u32) -> std::io::Result<()> {
45
+ let mut buf = [0u8; 5];
46
+ let mut len = 0;
47
+ loop {
48
+ let byte = (value & 0x7f) as u8;
49
+ value >>= 7;
50
+ if value == 0 {
51
+ buf[len] = byte;
52
+ len += 1;
53
+ break;
54
+ }
55
+ buf[len] = byte | 0x80;
56
+ len += 1;
57
+ }
58
+ out.write_all(&buf[..len])
59
+ }
60
+
61
+ /// Decoding reads from a slice rather than a `Read`. The index is millions of
62
+ /// varints, and one `read_exact` per byte through a `BufReader` spends more
63
+ /// time in the reader than in the decode; the whole file is a handful of
64
+ /// megabytes, so it is simply read once and walked in memory.
65
+ struct Cursor<'a> {
66
+ bytes: &'a [u8],
67
+ at: usize,
68
+ }
69
+
70
+ impl<'a> Cursor<'a> {
71
+ fn take(&mut self, n: usize) -> Option<&'a [u8]> {
72
+ let end = self.at.checked_add(n)?;
73
+ let slice = self.bytes.get(self.at..end)?;
74
+ self.at = end;
75
+ Some(slice)
76
+ }
77
+
78
+ fn u32(&mut self) -> Option<u32> {
79
+ Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
80
+ }
81
+
82
+ fn u64(&mut self) -> Option<u64> {
83
+ Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
84
+ }
85
+
86
+ fn i64(&mut self) -> Option<i64> {
87
+ Some(i64::from_le_bytes(self.take(8)?.try_into().ok()?))
88
+ }
89
+
90
+ fn varint(&mut self) -> Option<u32> {
91
+ let mut value = 0u32;
92
+ let mut shift = 0;
93
+ loop {
94
+ let byte = *self.bytes.get(self.at)?;
95
+ self.at += 1;
96
+ // Five groups of seven bits is the most a u32 can occupy; anything
97
+ // longer is corruption, not a large number.
98
+ if shift > 28 {
99
+ return None;
100
+ }
101
+ value |= u32::from(byte & 0x7f) << shift;
102
+ if byte & 0x80 == 0 {
103
+ return Some(value);
104
+ }
105
+ shift += 7;
106
+ }
107
+ }
108
+ }
109
+
110
+ fn write_u64(out: &mut impl Write, value: u64) -> std::io::Result<()> {
111
+ out.write_all(&value.to_le_bytes())
112
+ }
113
+
114
+ /// Write atomically: a half-written index that still has a valid header would
115
+ /// be loaded and believed, so the rename is what makes it safe.
116
+ pub fn save(path: &Path, files: &[StoredFile]) -> std::io::Result<()> {
117
+ if let Some(parent) = path.parent() {
118
+ std::fs::create_dir_all(parent)?;
119
+ }
120
+ let temp = path.with_extension("tmp");
121
+ {
122
+ let mut out = BufWriter::new(File::create(&temp)?);
123
+ out.write_all(MAGIC)?;
124
+ write_u32(&mut out, files.len() as u32)?;
125
+ for file in files {
126
+ let bytes = file.rel.as_bytes();
127
+ write_u32(&mut out, bytes.len() as u32)?;
128
+ out.write_all(bytes)?;
129
+ write_u64(&mut out, file.size)?;
130
+ out.write_all(&file.mtime_ms.to_le_bytes())?;
131
+ write_u32(&mut out, file.trigrams.len() as u32)?;
132
+ let mut previous = 0u32;
133
+ for &t in &file.trigrams {
134
+ write_varint(&mut out, t - previous)?;
135
+ previous = t;
136
+ }
137
+ }
138
+ out.flush()?;
139
+ }
140
+ std::fs::rename(&temp, path)
141
+ }
142
+
143
+ /// Load, or `None` for anything unreadable, truncated or from another version.
144
+ /// A cache is an optimisation; a broken one costs a rebuild and never a result.
145
+ pub fn load(path: &Path) -> Option<Vec<StoredFile>> {
146
+ let bytes = std::fs::read(path).ok()?;
147
+ let mut input = Cursor { bytes: &bytes, at: 0 };
148
+ if input.take(MAGIC.len())? != MAGIC {
149
+ return None;
150
+ }
151
+
152
+ let count = input.u32()? as usize;
153
+ // A corrupt length field must not turn into a huge allocation.
154
+ if count > 5_000_000 {
155
+ return None;
156
+ }
157
+ let mut files = Vec::with_capacity(count.min(65_536));
158
+ for _ in 0..count {
159
+ let name_len = input.u32()? as usize;
160
+ if name_len > 4096 {
161
+ return None;
162
+ }
163
+ let rel = std::str::from_utf8(input.take(name_len)?).ok()?.to_string();
164
+ let size = input.u64()?;
165
+ let mtime_ms = input.i64()?;
166
+ let trigram_count = input.u32()? as usize;
167
+ if trigram_count > 4_000_000 {
168
+ return None;
169
+ }
170
+ let mut trigrams = Vec::with_capacity(trigram_count.min(8192));
171
+ let mut previous = 0u32;
172
+ for _ in 0..trigram_count {
173
+ previous = previous.checked_add(input.varint()?)?;
174
+ trigrams.push(previous);
175
+ }
176
+ files.push(StoredFile { rel, size, mtime_ms, trigrams });
177
+ }
178
+ Some(files)
179
+ }
180
+
181
+ /// What a stored index still knows, keyed by path, so a walk can ask whether
182
+ /// each file it finds needs re-reading.
183
+ pub fn index_by_path(files: Vec<StoredFile>) -> HashMap<String, StoredFile> {
184
+ let mut map = HashMap::with_capacity(files.len());
185
+ for file in files {
186
+ map.insert(file.rel.clone(), file);
187
+ }
188
+ map
189
+ }
190
+
191
+ /// A stored entry is only trusted while the file on disk still looks the same.
192
+ pub fn still_valid(stored: &StoredFile, size: u64, mtime_ms: i64) -> bool {
193
+ stored.size == size && stored.mtime_ms == mtime_ms
194
+ }
@@ -0,0 +1,292 @@
1
+ //! Trigram index over file contents.
2
+ //!
3
+ //! Every overlapping three-byte window of a file is packed into a `u32` —
4
+ //! `(a << 16) | (b << 8) | c` — which is injective, so a trigram is its own
5
+ //! hash and no two distinct windows collide. The index maps each trigram to
6
+ //! the files containing it, and a search intersects the posting lists of the
7
+ //! trigrams its pattern must contain. Only the files that survive are read.
8
+ //!
9
+ //! The index only ever *narrows*. Every candidate is matched for real
10
+ //! afterwards, so a wrong candidate costs time and never correctness — which
11
+ //! is what makes it safe to narrow aggressively.
12
+
13
+ use std::collections::{HashMap, HashSet};
14
+
15
+ pub type Trigram = u32;
16
+
17
+ /// A trigram key *is* its own hash, so the default SipHash is pure overhead on
18
+ /// a path that runs once per input byte. One multiply-xorshift replaces it;
19
+ /// the xorshift is not optional, because hashbrown takes the bucket index from
20
+ /// the low bits and the low bits of `value * K` depend only on the last byte.
21
+ #[derive(Default, Clone, Copy)]
22
+ pub struct TrigramHasher(u64);
23
+
24
+ impl std::hash::Hasher for TrigramHasher {
25
+ #[inline]
26
+ fn finish(&self) -> u64 {
27
+ self.0
28
+ }
29
+ #[inline]
30
+ fn write(&mut self, bytes: &[u8]) {
31
+ for &b in bytes {
32
+ self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3);
33
+ }
34
+ }
35
+ #[inline]
36
+ fn write_u32(&mut self, value: u32) {
37
+ let mixed = (u64::from(value)).wrapping_mul(0x9E37_79B9_7F4A_7C15);
38
+ self.0 = mixed ^ (mixed >> 29);
39
+ }
40
+ }
41
+
42
+ #[derive(Default, Clone, Copy)]
43
+ pub struct BuildTrigramHasher;
44
+
45
+ impl std::hash::BuildHasher for BuildTrigramHasher {
46
+ type Hasher = TrigramHasher;
47
+ #[inline]
48
+ fn build_hasher(&self) -> TrigramHasher {
49
+ TrigramHasher::default()
50
+ }
51
+ }
52
+
53
+ type TrigramMap<V> = HashMap<Trigram, V, BuildTrigramHasher>;
54
+
55
+ /// Every distinct trigram in `bytes`, ASCII-folded so the index and the query
56
+ /// agree on case without storing both.
57
+ pub fn extract(bytes: &[u8], out: &mut HashSet<Trigram, BuildTrigramHasher>) {
58
+ out.clear();
59
+ if bytes.len() < 3 {
60
+ return;
61
+ }
62
+ for window in bytes.windows(3) {
63
+ let a = window[0].to_ascii_lowercase() as u32;
64
+ let b = window[1].to_ascii_lowercase() as u32;
65
+ let c = window[2].to_ascii_lowercase() as u32;
66
+ out.insert((a << 16) | (b << 8) | c);
67
+ }
68
+ }
69
+
70
+ pub fn of(text: &str) -> Vec<Trigram> {
71
+ let mut set = HashSet::with_hasher(BuildTrigramHasher);
72
+ extract(text.as_bytes(), &mut set);
73
+ let mut list: Vec<Trigram> = set.into_iter().collect();
74
+ list.sort_unstable();
75
+ list
76
+ }
77
+
78
+ /// What the index can be asked. `All` means the pattern gave nothing
79
+ /// indexable, so no candidate set is safe and everything must be read —
80
+ /// a different answer from "nothing matched", and conflating the two is how
81
+ /// an index silently starts hiding results.
82
+ #[derive(Debug, Clone)]
83
+ pub enum Plan {
84
+ And(Vec<Trigram>),
85
+ Or(Vec<Plan>),
86
+ All,
87
+ }
88
+
89
+ #[derive(Default)]
90
+ pub struct Index {
91
+ postings: TrigramMap<Vec<u32>>,
92
+ indexed: HashSet<u32>,
93
+ }
94
+
95
+ impl Index {
96
+ pub fn new() -> Self {
97
+ Self {
98
+ postings: HashMap::with_hasher(BuildTrigramHasher),
99
+ indexed: HashSet::new(),
100
+ }
101
+ }
102
+
103
+ pub fn len(&self) -> usize {
104
+ self.indexed.len()
105
+ }
106
+
107
+ pub fn add(&mut self, file: u32, trigrams: &[Trigram]) {
108
+ if self.indexed.contains(&file) {
109
+ self.remove(file);
110
+ }
111
+ for &t in trigrams {
112
+ let list = self.postings.entry(t).or_default();
113
+ // Postings stay sorted so intersection is a merge, not a scan.
114
+ //
115
+ // Bulk builds add files in ascending id order, so the new id
116
+ // belongs at the end and one comparison settles it. Falling
117
+ // through to a binary search here would cost fifteen scattered
118
+ // probes on a list held by every file — which measured as most of
119
+ // the time spent building the index.
120
+ match list.last() {
121
+ Some(&last) if last >= file => match list.binary_search(&file) {
122
+ Ok(_) => {}
123
+ Err(at) => list.insert(at, file),
124
+ },
125
+ _ => list.push(file),
126
+ }
127
+ }
128
+ self.indexed.insert(file);
129
+ }
130
+
131
+ pub fn remove(&mut self, file: u32) {
132
+ if !self.indexed.remove(&file) {
133
+ return;
134
+ }
135
+ self.postings.retain(|_, list| {
136
+ if let Ok(at) = list.binary_search(&file) {
137
+ list.remove(at);
138
+ }
139
+ !list.is_empty()
140
+ });
141
+ }
142
+
143
+ /// The index turned back the other way up: every trigram, grouped by the
144
+ /// file it came from, for writing the index out.
145
+ ///
146
+ /// One pass over the postings rather than one pass *per file* — asking each
147
+ /// file separately would rescan the entire index for every file, which on a
148
+ /// large tree is quadratic and would cost more than rebuilding from source.
149
+ pub fn by_file(&self) -> HashMap<u32, Vec<Trigram>> {
150
+ let mut out: HashMap<u32, Vec<Trigram>> = HashMap::with_capacity(self.indexed.len());
151
+ for &file in &self.indexed {
152
+ out.insert(file, Vec::new());
153
+ }
154
+ for (&t, files) in &self.postings {
155
+ for file in files {
156
+ if let Some(list) = out.get_mut(file) {
157
+ list.push(t);
158
+ }
159
+ }
160
+ }
161
+ for list in out.values_mut() {
162
+ list.sort_unstable();
163
+ }
164
+ out
165
+ }
166
+
167
+ /// Candidate file ids, or `None` meaning every file must be read.
168
+ pub fn candidates(&self, plan: &Plan) -> Option<Vec<u32>> {
169
+ match plan {
170
+ Plan::All => None,
171
+ Plan::Or(branches) => {
172
+ let mut union: Vec<u32> = Vec::new();
173
+ for branch in branches {
174
+ let part = self.candidates(branch)?;
175
+ union.extend_from_slice(&part);
176
+ }
177
+ union.sort_unstable();
178
+ union.dedup();
179
+ Some(union)
180
+ }
181
+ Plan::And(trigrams) => {
182
+ if trigrams.is_empty() {
183
+ return None;
184
+ }
185
+ let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(trigrams.len());
186
+ for t in trigrams {
187
+ match self.postings.get(t) {
188
+ // A trigram nobody has means nothing can match — an
189
+ // empty answer, emphatically not "read everything".
190
+ None => return Some(Vec::new()),
191
+ Some(list) => lists.push(list),
192
+ }
193
+ }
194
+ // Smallest first, so the working set only ever shrinks.
195
+ lists.sort_by_key(|l| l.len());
196
+ let mut result = lists[0].clone();
197
+ for list in &lists[1..] {
198
+ if result.is_empty() {
199
+ break;
200
+ }
201
+ result = intersect(&result, list);
202
+ }
203
+ Some(result)
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ /// Both sides are sorted, so this is a linear merge rather than a lookup loop.
210
+ fn intersect(a: &[u32], b: &[u32]) -> Vec<u32> {
211
+ let mut out = Vec::with_capacity(a.len().min(b.len()));
212
+ let (mut i, mut j) = (0usize, 0usize);
213
+ while i < a.len() && j < b.len() {
214
+ match a[i].cmp(&b[j]) {
215
+ std::cmp::Ordering::Equal => {
216
+ out.push(a[i]);
217
+ i += 1;
218
+ j += 1;
219
+ }
220
+ std::cmp::Ordering::Less => i += 1,
221
+ std::cmp::Ordering::Greater => j += 1,
222
+ }
223
+ }
224
+ out
225
+ }
226
+
227
+ /// Literal runs of three or more characters — the only parts of a pattern that
228
+ /// imply required trigrams.
229
+ pub fn literal_runs(pattern: &str) -> Vec<String> {
230
+ let chars: Vec<char> = pattern.chars().collect();
231
+ let mut runs = Vec::new();
232
+ let mut current = String::new();
233
+ let mut i = 0usize;
234
+ while i < chars.len() {
235
+ let ch = chars[i];
236
+ if ch == '\\' {
237
+ if let Some(&next) = chars.get(i + 1) {
238
+ if !next.is_ascii_alphanumeric() {
239
+ current.push(next);
240
+ i += 2;
241
+ continue;
242
+ }
243
+ }
244
+ runs.push(std::mem::take(&mut current));
245
+ i += 2;
246
+ continue;
247
+ }
248
+ // A quantifier applies to the character before it, so that character
249
+ // is not required either.
250
+ if matches!(ch, '?' | '*' | '+' | '{') {
251
+ current.pop();
252
+ runs.push(std::mem::take(&mut current));
253
+ i += 1;
254
+ continue;
255
+ }
256
+ if matches!(ch, '[' | ']' | '(' | ')' | '}' | '|' | '.' | '^' | '$') {
257
+ runs.push(std::mem::take(&mut current));
258
+ i += 1;
259
+ continue;
260
+ }
261
+ current.push(ch);
262
+ i += 1;
263
+ }
264
+ runs.push(current);
265
+ runs.retain(|r| r.chars().count() >= 3);
266
+ runs
267
+ }
268
+
269
+ pub fn plan_for_literal(literal: &str) -> Plan {
270
+ let trigrams = of(literal);
271
+ if trigrams.is_empty() {
272
+ Plan::All
273
+ } else {
274
+ Plan::And(trigrams)
275
+ }
276
+ }
277
+
278
+ pub fn plan_for_regex(pattern: &str) -> Plan {
279
+ if pattern.contains('|') {
280
+ let branches: Vec<Plan> = pattern.split('|').map(plan_for_regex).collect();
281
+ // One branch that can match anywhere makes the whole union unbounded.
282
+ if branches.iter().any(|b| matches!(b, Plan::All)) {
283
+ return Plan::All;
284
+ }
285
+ return Plan::Or(branches);
286
+ }
287
+ let runs = literal_runs(pattern);
288
+ match runs.iter().max_by_key(|r| r.len()) {
289
+ None => Plan::All,
290
+ Some(longest) => plan_for_literal(longest),
291
+ }
292
+ }