@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/trigram.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * A trigram index, so a content search touches the files that could match
3
+ * instead of every file there is.
4
+ *
5
+ * `grep` and its faster cousins are O(total bytes) per query: they read the
6
+ * whole tree every time you ask. That is fine on a small repo and miserable on
7
+ * a large one, and an agent asks a lot. The alternative is older than any of
8
+ * them — index every overlapping three-byte window once, and at query time
9
+ * intersect the posting lists of the trigrams the pattern must contain. The
10
+ * files that survive are the only ones worth reading. (Design from
11
+ * microsoft/tgrep, which reports up to 52x over ripgrep on very large trees.)
12
+ *
13
+ * The index only ever *narrows*. Every surviving candidate is still matched
14
+ * for real, so a wrong candidate costs time and never correctness.
15
+ *
16
+ * Pure, and no dependencies: a trigram is three bytes packed into a number.
17
+ */
18
+
19
+ /** `(a << 16) | (b << 8) | c` — injective for three bytes, so no collisions. */
20
+ export type Trigram = number;
21
+
22
+ export function trigramsOf(text: string): Set<Trigram> {
23
+ const out = new Set<Trigram>();
24
+ if (text.length < 3) return out;
25
+ // Latin-1 folding keeps the packing injective for ASCII, which is what
26
+ // source code is; anything above stays distinct enough to narrow with.
27
+ for (let i = 0; i + 2 < text.length; i++) {
28
+ const a = text.charCodeAt(i) & 0xff;
29
+ const b = text.charCodeAt(i + 1) & 0xff;
30
+ const c = text.charCodeAt(i + 2) & 0xff;
31
+ out.add((a << 16) | (b << 8) | c);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * What the index can be asked.
38
+ *
39
+ * `all` is the important one: it means the pattern gave nothing indexable, so
40
+ * no candidate set is safe and everything must be read. Getting this wrong is
41
+ * how an index silently starts hiding results.
42
+ */
43
+ export type Plan =
44
+ | { kind: "and"; trigrams: Trigram[] }
45
+ | { kind: "or"; branches: Plan[] }
46
+ | { kind: "all" };
47
+
48
+ /** Literal runs in a regex — the only parts that imply required trigrams. */
49
+ export function literalRuns(pattern: string): string[] {
50
+ const runs: string[] = [];
51
+ let current = "";
52
+ for (let i = 0; i < pattern.length; i++) {
53
+ const ch = pattern[i]!;
54
+ if (ch === "\\") {
55
+ const next = pattern[i + 1];
56
+ // An escaped literal character contributes; a character class like \d
57
+ // does not, and ends the run.
58
+ if (next && /[^A-Za-z0-9]/.test(next)) {
59
+ current += next;
60
+ i++;
61
+ continue;
62
+ }
63
+ runs.push(current);
64
+ current = "";
65
+ i++;
66
+ continue;
67
+ }
68
+ // Anything that can match a variable amount, or nothing, ends the run —
69
+ // and a quantifier applies to the character before it, which therefore
70
+ // cannot be required either.
71
+ if ("?*+{".includes(ch)) {
72
+ runs.push(current.slice(0, -1));
73
+ current = "";
74
+ continue;
75
+ }
76
+ if ("[](){}|.^$".includes(ch)) {
77
+ runs.push(current);
78
+ current = "";
79
+ continue;
80
+ }
81
+ current += ch;
82
+ }
83
+ runs.push(current);
84
+ return runs.filter((run) => run.length >= 3);
85
+ }
86
+
87
+ /** A literal pattern: every one of its trigrams must be present. */
88
+ export function planForLiteral(literal: string, caseInsensitive: boolean): Plan {
89
+ const text = caseInsensitive ? literal.toLowerCase() : literal;
90
+ const trigrams = [...trigramsOf(text)];
91
+ return trigrams.length === 0 ? { kind: "all" } : { kind: "and", trigrams };
92
+ }
93
+
94
+ /**
95
+ * A regex: take its longest literal run. Using only one run is deliberate —
96
+ * alternation means a run in one branch is not required by the pattern as a
97
+ * whole, and requiring it would hide matches from the other branch.
98
+ */
99
+ export function planForRegex(pattern: string, caseInsensitive: boolean): Plan {
100
+ if (pattern.includes("|")) {
101
+ // Each alternative narrows its own branch; the union is safe.
102
+ const branches = pattern.split("|").map((part) => planForRegex(part, caseInsensitive));
103
+ // One unindexable branch can match anywhere, so the union is unbounded.
104
+ if (branches.some((b) => b.kind === "all")) return { kind: "all" };
105
+ return { kind: "or", branches };
106
+ }
107
+ const runs = literalRuns(pattern);
108
+ if (runs.length === 0) return { kind: "all" };
109
+ const longest = runs.reduce((a, b) => (b.length > a.length ? b : a));
110
+ return planForLiteral(longest, caseInsensitive);
111
+ }
112
+
113
+ /** Several patterns: a file matches if any does, so the plans are unioned. */
114
+ export function planForPatterns(plans: readonly Plan[]): Plan {
115
+ if (plans.length === 0) return { kind: "all" };
116
+ if (plans.some((p) => p.kind === "all")) return { kind: "all" };
117
+ return plans.length === 1 ? plans[0]! : { kind: "or", branches: [...plans] };
118
+ }
119
+
120
+ /** Trigram → the files containing it. */
121
+ export class TrigramIndex {
122
+ private postings = new Map<Trigram, Set<number>>();
123
+ private indexed = new Set<number>();
124
+
125
+ add(fileId: number, content: string, caseInsensitive = true): void {
126
+ this.remove(fileId);
127
+ for (const trigram of trigramsOf(caseInsensitive ? content.toLowerCase() : content)) {
128
+ let list = this.postings.get(trigram);
129
+ if (!list) {
130
+ list = new Set();
131
+ this.postings.set(trigram, list);
132
+ }
133
+ list.add(fileId);
134
+ }
135
+ this.indexed.add(fileId);
136
+ }
137
+
138
+ remove(fileId: number): void {
139
+ if (!this.indexed.delete(fileId)) return;
140
+ for (const [trigram, list] of this.postings) {
141
+ if (list.delete(fileId) && list.size === 0) this.postings.delete(trigram);
142
+ }
143
+ }
144
+
145
+ get size(): number {
146
+ return this.indexed.size;
147
+ }
148
+
149
+ has(fileId: number): boolean {
150
+ return this.indexed.has(fileId);
151
+ }
152
+
153
+ /**
154
+ * Candidate file ids, or null meaning "no set is safe — read everything".
155
+ * Null and the empty set are different answers and must not be confused:
156
+ * one means everything, the other means nothing.
157
+ */
158
+ candidates(plan: Plan): Set<number> | null {
159
+ if (plan.kind === "all") return null;
160
+
161
+ if (plan.kind === "or") {
162
+ const union = new Set<number>();
163
+ for (const branch of plan.branches) {
164
+ const part = this.candidates(branch);
165
+ if (part === null) return null;
166
+ for (const id of part) union.add(id);
167
+ }
168
+ return union;
169
+ }
170
+
171
+ // Intersect, smallest posting list first so the working set only shrinks.
172
+ const lists = plan.trigrams.map((t) => this.postings.get(t) ?? new Set<number>());
173
+ if (lists.length === 0) return null;
174
+ lists.sort((a, b) => a.size - b.size);
175
+ let result = new Set(lists[0]!);
176
+ for (const list of lists.slice(1)) {
177
+ if (result.size === 0) break;
178
+ const next = new Set<number>();
179
+ for (const id of result) if (list.has(id)) next.add(id);
180
+ result = next;
181
+ }
182
+ return result;
183
+ }
184
+ }
package/src/walk.ts ADDED
Binary file