@pify/search 0.1.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.
package/src/engine.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The two engines, behind one shape.
3
+ *
4
+ * The fast path is the real thing: `@ff-labs/fff-node`, a Rust index with a
5
+ * live watcher, typo-resistant matching, git status and frecency built in. It
6
+ * scans this whole suite in about 80ms and is not something worth
7
+ * reimplementing.
8
+ *
9
+ * The slow path exists because a native binary is a promise you cannot always
10
+ * keep. An unsupported platform, a locked-down install, a postinstall that
11
+ * never ran — any of those and a binary-only search extension is an extension
12
+ * that does nothing. The fallback is pure TypeScript with no dependencies: a
13
+ * trigram index for content, a fuzzy scorer for paths. It is slower, and it
14
+ * works everywhere pi does.
15
+ *
16
+ * Both answer the same questions, so the tools never learn which one they got.
17
+ * `/search` says which is live, because a user comparing timings deserves to
18
+ * know why.
19
+ */
20
+
21
+ export interface FileHit {
22
+ path: string;
23
+ score?: number;
24
+ git?: string;
25
+ size?: number;
26
+ modifiedMs?: number;
27
+ }
28
+
29
+ export interface ContentHit {
30
+ path: string;
31
+ line: number;
32
+ text: string;
33
+ git?: string;
34
+ }
35
+
36
+ export interface Page<T> {
37
+ items: T[];
38
+ total: number;
39
+ cursor: string | null;
40
+ }
41
+
42
+ export type GrepMode = "literal" | "regex" | "fuzzy";
43
+
44
+ export interface FindOptions {
45
+ limit?: number;
46
+ cursor?: string;
47
+ }
48
+
49
+ export interface GrepOptions extends FindOptions {
50
+ mode?: GrepMode;
51
+ caseInsensitive?: boolean;
52
+ /** Only search paths matching this glob. */
53
+ glob?: string;
54
+ }
55
+
56
+ export interface SearchEngine {
57
+ readonly name: "fff" | "builtin";
58
+ /** Resolve once the first index build has landed. */
59
+ ready(timeoutMs: number): Promise<boolean>;
60
+ find(query: string, options?: FindOptions): Promise<Page<FileHit>>;
61
+ grep(pattern: string, options?: GrepOptions): Promise<Page<ContentHit>>;
62
+ /** Note that a path was used, so frecency can favour it later. */
63
+ touch?(path: string): void;
64
+ dispose(): void;
65
+ /** How many files the index holds, when the engine can say. */
66
+ indexed?(): number;
67
+ }
68
+
69
+ /** fff calls it "plain"; this package calls it what a user would call it. */
70
+ const FFF_MODE: Record<GrepMode, string> = { literal: "plain", regex: "regex", fuzzy: "fuzzy" };
71
+
72
+ interface FffResult<T> {
73
+ ok: boolean;
74
+ value?: T;
75
+ error?: string;
76
+ }
77
+
78
+ interface FffPage {
79
+ items: Array<Record<string, unknown>>;
80
+ /**
81
+ * For grep this is the size of THIS page, not a grand total — fff searches
82
+ * files lazily and cannot know the total without finishing the job. Reading
83
+ * it as a total is how a result set of twenty looks like the whole answer.
84
+ */
85
+ totalMatched?: number;
86
+ nextCursor?: unknown;
87
+ }
88
+
89
+ interface FffFinder {
90
+ waitForScan(ms: number): Promise<unknown>;
91
+ fileSearch(query: string, options?: Record<string, unknown>): FffResult<FffPage>;
92
+ grep(pattern: string, options?: Record<string, unknown>): FffResult<FffPage>;
93
+ destroy(): void;
94
+ }
95
+
96
+ /**
97
+ * Adapter over fff. Its two searches paginate differently — files by page
98
+ * index, content by an opaque cursor it hands back — so this translates both
99
+ * into the one string cursor the tools see.
100
+ */
101
+ export function fffEngine(finder: FffFinder): SearchEngine {
102
+ // fff's grep cursor is an opaque object, and this interface hands back a
103
+ // string. Keeping them here rather than serialising the object's innards
104
+ // avoids depending on a shape its author marked internal.
105
+ const cursors = new Map<string, unknown>();
106
+ let cursorSeq = 0;
107
+
108
+ function tokenFor(cursor: unknown): string | null {
109
+ if (cursor === null || cursor === undefined) return null;
110
+ const token = `c${++cursorSeq}`;
111
+ cursors.set(token, cursor);
112
+ // A session asks a lot; only the recent handful can still be in play.
113
+ if (cursors.size > 32) cursors.delete(cursors.keys().next().value as string);
114
+ return token;
115
+ }
116
+
117
+ return {
118
+ name: "fff",
119
+ async ready(timeoutMs) {
120
+ try {
121
+ await finder.waitForScan(timeoutMs);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ },
127
+ async find(query, options = {}) {
128
+ const limit = options.limit ?? 20;
129
+ // fff pages files by index, not by offset.
130
+ const page = Math.max(0, Number.parseInt(options.cursor ?? "0", 10) || 0);
131
+ const result = finder.fileSearch(query, { pageSize: limit, pageIndex: page });
132
+ if (!result.ok || !result.value) return { items: [], total: 0, cursor: null };
133
+ const items = result.value.items.map((raw) => ({
134
+ path: String(raw.relativePath ?? ""),
135
+ git: typeof raw.gitStatus === "string" && raw.gitStatus !== "clean" ? raw.gitStatus : undefined,
136
+ size: typeof raw.size === "number" ? raw.size : undefined,
137
+ modifiedMs: typeof raw.modified === "number" ? raw.modified * 1000 : undefined,
138
+ score: typeof raw.totalFrecencyScore === "number" ? raw.totalFrecencyScore : undefined,
139
+ }));
140
+ const total = result.value.totalMatched ?? items.length;
141
+ // A full page means there may be another; fff reports the match count
142
+ // for the query, so this is a real total rather than a guess.
143
+ const hasMore = items.length === limit && (page + 1) * limit < total;
144
+ return { items, total, cursor: hasMore ? String(page + 1) : null };
145
+ },
146
+ async grep(pattern, options = {}) {
147
+ const limit = options.limit ?? 20;
148
+ const previous = options.cursor ? cursors.get(options.cursor) : undefined;
149
+ const result = finder.grep(pattern, {
150
+ mode: FFF_MODE[options.mode ?? "literal"],
151
+ pageSize: limit,
152
+ smartCase: options.caseInsensitive !== false,
153
+ ...(previous ? { cursor: previous } : {}),
154
+ });
155
+ if (!result.ok || !result.value) return { items: [], total: 0, cursor: null };
156
+ const items = result.value.items.map((raw) => ({
157
+ path: String(raw.relativePath ?? ""),
158
+ line: typeof raw.lineNumber === "number" ? raw.lineNumber : 0,
159
+ text: String(raw.lineContent ?? ""),
160
+ git: typeof raw.gitStatus === "string" && raw.gitStatus !== "clean" ? raw.gitStatus : undefined,
161
+ }));
162
+ // Deliberately items.length: fff searches lazily, so the only honest
163
+ // total is what has actually been found so far.
164
+ return { items, total: items.length, cursor: tokenFor(result.value.nextCursor) };
165
+ },
166
+ dispose() {
167
+ cursors.clear();
168
+ try {
169
+ finder.destroy();
170
+ } catch {
171
+ // already gone
172
+ }
173
+ },
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Load fff if it is installed and its binary is present for this platform.
179
+ * Never throws: a missing engine is the ordinary case this package is built to
180
+ * survive, not an error to report.
181
+ */
182
+ export async function loadFff(basePath: string): Promise<SearchEngine | null> {
183
+ if (process.env.PIFY_SEARCH_ENGINE === "builtin") return null;
184
+ for (const specifier of ["@ff-labs/fff-node", "@ff-labs/fff-bun"]) {
185
+ try {
186
+ const mod = (await import(specifier)) as {
187
+ FileFinder?: { create(options: { basePath: string }): FffResult<FffFinder> };
188
+ };
189
+ const created = mod.FileFinder?.create({ basePath });
190
+ if (created?.ok && created.value) return fffEngine(created.value);
191
+ } catch {
192
+ // Not installed, or no binary for this platform — try the next.
193
+ }
194
+ }
195
+ return null;
196
+ }
package/src/format.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * How results read.
3
+ *
4
+ * A search result is consumed by a model that will decide what to open next,
5
+ * so the shape matters: one line per hit, path first, and the cursor stated in
6
+ * words rather than left in a details field the model may not look at. A
7
+ * result set that silently stops at twenty looks like a complete answer, which
8
+ * is how an agent concludes something does not exist.
9
+ */
10
+
11
+ import type { ContentHit, FileHit, GrepMode, Page, SearchEngine } from "./engine.ts";
12
+
13
+ function more(page: Page<unknown>, tool: string): string {
14
+ if (!page.cursor) return "";
15
+ const shown = page.items.length;
16
+ return `\n\n${page.total - shown} more. Continue with ${tool} cursor="${page.cursor}".`;
17
+ }
18
+
19
+ export function formatFiles(page: Page<FileHit>, query: string): string {
20
+ if (page.items.length === 0) {
21
+ return `No file matches "${query}". Try fewer characters — matching is fuzzy, so a fragment of the name works better than a guess at the full path.`;
22
+ }
23
+ const lines = page.items.map((hit) => {
24
+ const marks: string[] = [];
25
+ if (hit.git) marks.push(hit.git);
26
+ return `${hit.path}${marks.length > 0 ? ` (${marks.join(", ")})` : ""}`;
27
+ });
28
+ return `${page.total} file${page.total === 1 ? "" : "s"} match "${query}", best first:\n${lines.join("\n")}${more(page, "fffind")}`;
29
+ }
30
+
31
+ export function formatMatches(page: Page<ContentHit>, pattern: string, mode: GrepMode): string {
32
+ if (page.items.length === 0) {
33
+ const hint =
34
+ mode === "literal"
35
+ ? ' Try mode="fuzzy" if you are unsure of the exact wording, or mode="regex" for a pattern.'
36
+ : "";
37
+ return `No match for "${pattern}" (${mode}).${hint}`;
38
+ }
39
+ const lines = page.items.map((hit) => `${hit.path}:${hit.line}: ${hit.text.trim().slice(0, 200)}`);
40
+ return `${page.total} match${page.total === 1 ? "" : "es"} for "${pattern}" (${mode}):\n${lines.join("\n")}${more(page, "ffgrep")}`;
41
+ }
42
+
43
+ export function formatStatus(engine: SearchEngine | null, root: string): string {
44
+ if (!engine) {
45
+ return "No search engine is running. fffind and ffgrep will start one on first use.";
46
+ }
47
+ const lines = [
48
+ `Engine: ${engine.name === "fff" ? "fff (native index, live watcher)" : "builtin (pure TypeScript, no native binary)"}`,
49
+ `Root: ${root}`,
50
+ ];
51
+ const count = engine.indexed?.();
52
+ if (typeof count === "number") lines.push(`Files: ${count} indexed`);
53
+ if (engine.name === "builtin") {
54
+ lines.push(
55
+ "",
56
+ "The native engine was not available — either @ff-labs/fff-node is not installed or it has no",
57
+ "binary for this platform. The builtin engine is slower but needs nothing installed.",
58
+ );
59
+ }
60
+ return lines.join("\n");
61
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * How much a file's history is worth now.
3
+ *
4
+ * A file you opened twelve times last week should outrank one you have never
5
+ * touched, but not forever — otherwise last month's task keeps winning today's
6
+ * search. Frecency is the usual answer: every access decays, and the score is
7
+ * what is left of all of them.
8
+ *
9
+ * The half-life is fff's, and so is its reasoning: an agent session is shorter
10
+ * and more concentrated than a human's week, so the default here is the fast
11
+ * decay. Ten-day half-life is for a person browsing; three days is for a tool
12
+ * that touched forty files this afternoon.
13
+ *
14
+ * Pure: the caller supplies now, so the same input always scores the same.
15
+ */
16
+
17
+ /** ln(2)/3 — a three-day half-life. */
18
+ export const DECAY_FAST = 0.231;
19
+ /** ln(2)/10 — a ten-day half-life. */
20
+ export const DECAY_SLOW = 0.0693;
21
+
22
+ const DAY_MS = 86_400_000;
23
+
24
+ /** Older than this contributes nothing, so the store cannot grow forever. */
25
+ export const MAX_HISTORY_DAYS = 7;
26
+ /** Beyond this, the oldest access is dropped rather than kept and decayed. */
27
+ export const MAX_TIMESTAMPS = 128;
28
+
29
+ export type History = Record<string, number[]>;
30
+
31
+ export function parseHistory(raw: string | null): History {
32
+ if (!raw) return {};
33
+ try {
34
+ const data = JSON.parse(raw) as unknown;
35
+ if (!data || typeof data !== "object" || Array.isArray(data)) return {};
36
+ const out: History = {};
37
+ for (const [path, value] of Object.entries(data as Record<string, unknown>)) {
38
+ if (!Array.isArray(value)) continue;
39
+ const stamps = value.filter((v): v is number => typeof v === "number" && Number.isFinite(v) && v > 0);
40
+ if (stamps.length > 0) out[path] = stamps.slice(-MAX_TIMESTAMPS);
41
+ }
42
+ return out;
43
+ } catch {
44
+ return {};
45
+ }
46
+ }
47
+
48
+ /** Record an access, dropping what has aged out rather than keeping it. */
49
+ export function noteAccess(history: History, path: string, now: number): History {
50
+ const cutoff = now - MAX_HISTORY_DAYS * DAY_MS;
51
+ const kept = (history[path] ?? []).filter((t) => t >= cutoff);
52
+ kept.push(now);
53
+ return { ...history, [path]: kept.slice(-MAX_TIMESTAMPS) };
54
+ }
55
+
56
+ /**
57
+ * The decayed weight of every remaining access. Summed rather than averaged:
58
+ * ten recent touches should beat one, which an average would hide.
59
+ */
60
+ export function frecencyOf(history: History, path: string, now: number, decay = DECAY_FAST): number {
61
+ const stamps = history[path];
62
+ if (!stamps || stamps.length === 0) return 0;
63
+ let total = 0;
64
+ for (const at of stamps) {
65
+ const ageDays = (now - at) / DAY_MS;
66
+ if (ageDays < 0 || ageDays > MAX_HISTORY_DAYS) continue;
67
+ total += Math.exp(-decay * ageDays);
68
+ }
69
+ // Scaled into the same range as the other bonuses in fuzzy.ts, and capped so
70
+ // a much-used file cannot outrank an exact filename match on its own.
71
+ return Math.min(200, Math.round(total * 25));
72
+ }
73
+
74
+ /** Drop everything that has aged out, so the file on disk stays bounded. */
75
+ export function pruneHistory(history: History, now: number): History {
76
+ const cutoff = now - MAX_HISTORY_DAYS * DAY_MS;
77
+ const out: History = {};
78
+ for (const [path, stamps] of Object.entries(history)) {
79
+ const kept = stamps.filter((t) => t >= cutoff);
80
+ if (kept.length > 0) out[path] = kept.slice(-MAX_TIMESTAMPS);
81
+ }
82
+ return out;
83
+ }
package/src/fuzzy.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Fuzzy path matching, and what makes one result better than another.
3
+ *
4
+ * `find` answers with everything that matches a glob, in whatever order the
5
+ * filesystem handed them over. That is the wrong shape for the question people
6
+ * actually ask, which is "the auth route file — you know the one". Two things
7
+ * fix it: a match that tolerates the way people type, and an order that puts
8
+ * the file you probably meant first.
9
+ *
10
+ * The scoring model is fff's: a base score for match quality, plus a frecency
11
+ * boost, a proximity term, and a bonus for matching the filename rather than
12
+ * some directory halfway up the path. Typo tolerance scales with the query,
13
+ * because one wrong letter in four characters is a different mistake from one
14
+ * wrong letter in twenty.
15
+ *
16
+ * Pure. No index, no clock, no disk — the caller supplies all three.
17
+ */
18
+
19
+ export interface Candidate {
20
+ /** Path relative to the search root, forward-slashed. */
21
+ path: string;
22
+ /** Frecency, already decayed by the caller: 0 when never touched. */
23
+ frecency?: number;
24
+ /** Git working-tree state, if known. */
25
+ git?: "modified" | "staged" | "untracked" | undefined;
26
+ /** Modified-at, for the recency thresholds. */
27
+ mtimeMs?: number;
28
+ }
29
+
30
+ export interface Scored {
31
+ path: string;
32
+ score: number;
33
+ /** Where the query characters landed, for highlighting. */
34
+ positions: number[];
35
+ }
36
+
37
+ /** How many typos a query of this length may carry: fff's clamp(len/4, 2, 6). */
38
+ export function maxTypos(query: string): number {
39
+ return Math.min(6, Math.max(2, Math.floor(query.length / 4)));
40
+ }
41
+
42
+ const BONUS_EXACT_FILENAME = 300;
43
+ /** `auth` should find `auth.ts` before `authentication.md`: the stem is the name. */
44
+ const BONUS_EXACT_STEM = 200;
45
+ const BONUS_FILENAME_PREFIX = 120;
46
+ const BONUS_IN_FILENAME = 60;
47
+ const BONUS_CONSECUTIVE = 12;
48
+ const BONUS_BOUNDARY = 18;
49
+ const PENALTY_LEADING = 2;
50
+ const PENALTY_TYPO = 40;
51
+ const PENALTY_DEPTH = 3;
52
+
53
+ /** A discrete boost for how recently the file changed — fff's thresholds. */
54
+ export function recencyBoost(mtimeMs: number | undefined, now: number): number {
55
+ if (!mtimeMs) return 0;
56
+ const age = (now - mtimeMs) / 1000;
57
+ if (age < 120) return 16;
58
+ if (age < 900) return 8;
59
+ if (age < 3600) return 4;
60
+ if (age < 86_400) return 2;
61
+ if (age < 604_800) return 1;
62
+ return 0;
63
+ }
64
+
65
+ /** Work in progress is what you are most likely to be looking for. */
66
+ export function gitBoost(status: Candidate["git"]): number {
67
+ if (status === "modified") return 24;
68
+ if (status === "staged") return 20;
69
+ if (status === "untracked") return 12;
70
+ return 0;
71
+ }
72
+
73
+ /**
74
+ * Subsequence match, preferring later starts so `auth` in `src/auth/x.ts`
75
+ * anchors on the filename rather than the first `a` in the path. Returns null
76
+ * when the query cannot be found even with the typo budget spent.
77
+ */
78
+ function matchPositions(haystack: string, needle: string, budget: number): { positions: number[]; typos: number } | null {
79
+ const hay = haystack.toLowerCase();
80
+ const need = needle.toLowerCase();
81
+ const positions: number[] = [];
82
+ let typos = 0;
83
+ let at = 0;
84
+
85
+ for (let n = 0; n < need.length; n++) {
86
+ const ch = need[n]!;
87
+ const found = hay.indexOf(ch, at);
88
+ if (found === -1) {
89
+ // Skipping a query character is the typo: a transposition or a slip.
90
+ typos++;
91
+ if (typos > budget) return null;
92
+ continue;
93
+ }
94
+ positions.push(found);
95
+ at = found + 1;
96
+ }
97
+ if (positions.length === 0) return null;
98
+ return { positions, typos };
99
+ }
100
+
101
+ function isBoundary(text: string, index: number): boolean {
102
+ if (index === 0) return true;
103
+ const prev = text[index - 1]!;
104
+ return prev === "/" || prev === "_" || prev === "-" || prev === "." || (prev === prev.toLowerCase() && text[index] !== text[index]!.toLowerCase());
105
+ }
106
+
107
+ /** Score one candidate, or null when the query does not match it at all. */
108
+ export function scoreCandidate(candidate: Candidate, query: string, now: number): Scored | null {
109
+ const path = candidate.path;
110
+ if (query === "") {
111
+ return { path, score: (candidate.frecency ?? 0) + gitBoost(candidate.git) + recencyBoost(candidate.mtimeMs, now), positions: [] };
112
+ }
113
+
114
+ const match = matchPositions(path, query, maxTypos(query));
115
+ if (!match) return null;
116
+
117
+ const slash = path.lastIndexOf("/");
118
+ const filename = slash === -1 ? path : path.slice(slash + 1);
119
+ const lowerName = filename.toLowerCase();
120
+ const lowerQuery = query.toLowerCase();
121
+
122
+ const dot = lowerName.lastIndexOf(".");
123
+ const stem = dot <= 0 ? lowerName : lowerName.slice(0, dot);
124
+
125
+ let score = 0;
126
+ if (lowerName === lowerQuery) score += BONUS_EXACT_FILENAME;
127
+ else if (stem === lowerQuery) score += BONUS_EXACT_STEM;
128
+ else if (lowerName.startsWith(lowerQuery)) score += BONUS_FILENAME_PREFIX;
129
+ else if (lowerName.includes(lowerQuery)) score += BONUS_IN_FILENAME;
130
+
131
+ for (let i = 0; i < match.positions.length; i++) {
132
+ const at = match.positions[i]!;
133
+ if (i > 0 && at === match.positions[i - 1]! + 1) score += BONUS_CONSECUTIVE;
134
+ if (isBoundary(path, at)) score += BONUS_BOUNDARY;
135
+ // A match that starts deep in the string is a weaker match.
136
+ if (i === 0) score -= Math.min(at, 40) * PENALTY_LEADING;
137
+ }
138
+
139
+ score -= match.typos * PENALTY_TYPO;
140
+ // A shallow path is more likely to be the one meant than a deep one.
141
+ score -= (path.split("/").length - 1) * PENALTY_DEPTH;
142
+ score += candidate.frecency ?? 0;
143
+ score += gitBoost(candidate.git);
144
+ score += recencyBoost(candidate.mtimeMs, now);
145
+
146
+ return { path, score, positions: match.positions };
147
+ }
148
+
149
+ export interface Page<T> {
150
+ items: T[];
151
+ /** Opaque cursor for the next page, or null when this was the last. */
152
+ cursor: string | null;
153
+ total: number;
154
+ }
155
+
156
+ /**
157
+ * Rank and cut. The cursor is an offset rather than a token into stored state:
158
+ * an index that moves under a long-lived cursor would silently skip or repeat
159
+ * results, and an offset at least fails the same way a human would expect.
160
+ */
161
+ export function rankAndPage(
162
+ candidates: readonly Candidate[],
163
+ query: string,
164
+ now: number,
165
+ limit: number,
166
+ cursor?: string,
167
+ ): Page<Scored> {
168
+ const scored: Scored[] = [];
169
+ for (const candidate of candidates) {
170
+ const hit = scoreCandidate(candidate, query, now);
171
+ if (hit) scored.push(hit);
172
+ }
173
+ scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
174
+
175
+ const offset = Number.parseInt(cursor ?? "0", 10);
176
+ const start = Number.isFinite(offset) && offset > 0 ? offset : 0;
177
+ const items = scored.slice(start, start + limit);
178
+ const next = start + items.length;
179
+ return { items, cursor: next < scored.length ? String(next) : null, total: scored.length };
180
+ }
package/src/match.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Deciding whether a line really matches, once the index has narrowed the
3
+ * field.
4
+ *
5
+ * The trigram index answers "which files could contain this", never "which
6
+ * lines do". Every candidate is still read and matched here, so a wrong
7
+ * candidate costs time and never correctness — which is what makes it safe to
8
+ * narrow aggressively.
9
+ *
10
+ * Three modes, because three different questions get asked: the exact string,
11
+ * a regex, and "something like this" for when the caller does not know how the
12
+ * thing is spelled.
13
+ */
14
+
15
+ import { maxTypos } from "./fuzzy.ts";
16
+
17
+ export type Mode = "literal" | "regex" | "fuzzy";
18
+
19
+ export interface LineMatch {
20
+ /** 1-based, as every editor and every error message counts them. */
21
+ line: number;
22
+ text: string;
23
+ /** Byte-free column range of the hit, for highlighting. */
24
+ start: number;
25
+ end: number;
26
+ }
27
+
28
+ export interface Matcher {
29
+ test(line: string): { start: number; end: number } | null;
30
+ }
31
+
32
+ /** A matcher for one pattern, or null when the pattern itself is broken. */
33
+ export function buildMatcher(pattern: string, mode: Mode, caseInsensitive: boolean): Matcher | null {
34
+ if (pattern === "") return null;
35
+
36
+ if (mode === "literal") {
37
+ const needle = caseInsensitive ? pattern.toLowerCase() : pattern;
38
+ return {
39
+ test(line) {
40
+ const hay = caseInsensitive ? line.toLowerCase() : line;
41
+ const at = hay.indexOf(needle);
42
+ return at === -1 ? null : { start: at, end: at + needle.length };
43
+ },
44
+ };
45
+ }
46
+
47
+ if (mode === "regex") {
48
+ let re: RegExp;
49
+ try {
50
+ re = new RegExp(pattern, caseInsensitive ? "i" : "");
51
+ } catch {
52
+ return null;
53
+ }
54
+ return {
55
+ test(line) {
56
+ const m = re.exec(line);
57
+ return m ? { start: m.index, end: m.index + m[0].length } : null;
58
+ },
59
+ };
60
+ }
61
+
62
+ // Fuzzy: the query's characters in order, within a typo budget, and close
63
+ // enough together to be one word rather than three scattered letters.
64
+ const budget = maxTypos(pattern);
65
+ const needle = pattern.toLowerCase();
66
+ const span = Math.max(needle.length * 3, needle.length + 8);
67
+ return {
68
+ test(line) {
69
+ const hay = line.toLowerCase();
70
+ for (let start = 0; start < hay.length; start++) {
71
+ if (hay[start] !== needle[0] && budget === 0) continue;
72
+ let typos = 0;
73
+ let at = start;
74
+ let matched = 0;
75
+ for (let n = 0; n < needle.length; n++) {
76
+ const found = hay.indexOf(needle[n]!, at);
77
+ if (found === -1 || found - start > span) {
78
+ typos++;
79
+ if (typos > budget) break;
80
+ continue;
81
+ }
82
+ at = found + 1;
83
+ matched++;
84
+ }
85
+ if (matched > 0 && typos <= budget && matched >= needle.length - budget) {
86
+ return { start, end: Math.min(at, hay.length) };
87
+ }
88
+ }
89
+ return null;
90
+ },
91
+ };
92
+ }
93
+
94
+ /** Every matching line in one file's content. */
95
+ export function matchLines(content: string, matcher: Matcher, limit: number): LineMatch[] {
96
+ const out: LineMatch[] = [];
97
+ const lines = content.split(/\r?\n/);
98
+ for (let i = 0; i < lines.length && out.length < limit; i++) {
99
+ const text = lines[i]!;
100
+ const hit = matcher.test(text);
101
+ if (hit) out.push({ line: i + 1, text, start: hit.start, end: hit.end });
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Whether a file is worth reading as text at all.
108
+ *
109
+ * A NUL byte in the first few kilobytes is the classic signal, and it is
110
+ * cheaper and more reliable than trusting an extension: a `.dat` may be text
111
+ * and a `.txt` may not.
112
+ */
113
+ export function looksBinary(sample: string): boolean {
114
+ const window = sample.slice(0, 8192);
115
+ for (let i = 0; i < window.length; i++) {
116
+ if (window.charCodeAt(i) === 0) return true;
117
+ }
118
+ return false;
119
+ }