@aaroncql/pim-agent 0.2.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.
- package/README.md +10 -0
- package/package.json +6 -6
- package/src/extensions/apply-patch/coordinator.ts +25 -7
- package/src/extensions/apply-patch/index.ts +2 -1
- package/src/extensions/file-picker/FilePickerSuggestionEngine.ts +14 -0
- package/src/extensions/file-picker/InProcessFilePickerSuggestionEngine.ts +52 -0
- package/src/extensions/file-picker/WorkerFilePickerSuggestionEngine.ts +268 -0
- package/src/extensions/file-picker/catalog.ts +38 -33
- package/src/extensions/file-picker/filePickerWorker.ts +72 -0
- package/src/extensions/file-picker/filePickerWorkerMessages.ts +39 -0
- package/src/extensions/file-picker/index.ts +138 -83
- package/src/extensions/file-picker/ranker.ts +180 -12
- package/src/extensions/grep/grep.ts +45 -2
- package/src/shared/FileEnumerator.ts +492 -0
- package/src/shared/FileScanner.ts +15 -17
- package/src/shared/GitignoreFilter.ts +0 -142
|
@@ -3,116 +3,171 @@ import type {
|
|
|
3
3
|
ExtensionAPI,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { AutocompleteProvider } from "@earendil-works/pi-tui";
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
6
|
+
import type { FilePickerSuggestionEngine } from "./FilePickerSuggestionEngine";
|
|
7
|
+
import { WorkerFilePickerSuggestionEngine } from "./WorkerFilePickerSuggestionEngine";
|
|
8
8
|
|
|
9
9
|
const MAX_VISIBLE_ROWS = 50;
|
|
10
10
|
const AT_PREFIX = /(?:^|\s)@(\S*)$/;
|
|
11
11
|
|
|
12
|
+
// Pi cancels autocomplete after Tab; for directories we want to keep
|
|
13
|
+
// drilling, so re-enter Tab on the next tick.
|
|
14
|
+
function keepDrilling(): void {
|
|
15
|
+
setTimeout(() => {
|
|
16
|
+
try {
|
|
17
|
+
process.stdin.emit("data", "\t");
|
|
18
|
+
} catch {}
|
|
19
|
+
}, 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function activeAtTokenFromMatch(
|
|
23
|
+
match: RegExpMatchArray,
|
|
24
|
+
cursorLine: number
|
|
25
|
+
): ActiveAtToken {
|
|
26
|
+
const matchedText = match[0] ?? "";
|
|
27
|
+
const matchCol = match.index ?? 0;
|
|
28
|
+
const atCol = matchCol + (matchedText.startsWith("@") ? 0 : 1);
|
|
29
|
+
return { cursorLine, atCol };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sameActiveAtToken(
|
|
33
|
+
a: ActiveAtToken | undefined,
|
|
34
|
+
b: ActiveAtToken
|
|
35
|
+
): boolean {
|
|
36
|
+
return a?.cursorLine === b.cursorLine && a.atCol === b.atCol;
|
|
37
|
+
}
|
|
38
|
+
|
|
12
39
|
export type FilePickerProviderFactoryOptions = {
|
|
13
|
-
readonly
|
|
40
|
+
readonly engine: FilePickerSuggestionEngine;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type ActiveAtToken = {
|
|
44
|
+
readonly cursorLine: number;
|
|
45
|
+
readonly atCol: number;
|
|
14
46
|
};
|
|
15
47
|
|
|
16
48
|
export function createFilePickerProviderFactory(
|
|
17
49
|
options: FilePickerProviderFactoryOptions
|
|
18
50
|
): AutocompleteProviderFactory {
|
|
19
|
-
let cachedRelative: readonly FileCandidate[] | undefined;
|
|
20
|
-
let relativeRefresh: Promise<void> | undefined;
|
|
21
|
-
|
|
22
51
|
const refreshRelative = (): void => {
|
|
23
|
-
|
|
24
|
-
.loadRelativeCatalog()
|
|
25
|
-
.then((catalog) => {
|
|
26
|
-
cachedRelative = catalog;
|
|
27
|
-
})
|
|
28
|
-
.catch(() => {
|
|
29
|
-
if (cachedRelative === undefined) {
|
|
30
|
-
cachedRelative = [];
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
.finally(() => {
|
|
34
|
-
relativeRefresh = undefined;
|
|
35
|
-
});
|
|
52
|
+
void options.engine.refreshRelative();
|
|
36
53
|
};
|
|
37
54
|
|
|
38
|
-
|
|
55
|
+
return (current: AutocompleteProvider): AutocompleteProvider => {
|
|
56
|
+
let activeAtToken: ActiveAtToken | undefined;
|
|
39
57
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
return {
|
|
59
|
+
async getSuggestions(lines, cursorLine, cursorCol, autocompleteOptions) {
|
|
60
|
+
const line = lines[cursorLine] ?? "";
|
|
61
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
44
62
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
const atMatch = beforeCursor.match(AT_PREFIX);
|
|
64
|
+
if (!atMatch) {
|
|
65
|
+
activeAtToken = undefined;
|
|
66
|
+
return current.getSuggestions(
|
|
67
|
+
lines,
|
|
68
|
+
cursorLine,
|
|
69
|
+
cursorCol,
|
|
70
|
+
autocompleteOptions
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const query = atMatch[1] ?? "";
|
|
75
|
+
const atToken = activeAtTokenFromMatch(atMatch, cursorLine);
|
|
76
|
+
if (!sameActiveAtToken(activeAtToken, atToken)) {
|
|
77
|
+
activeAtToken = atToken;
|
|
78
|
+
refreshRelative();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const items = await options.engine
|
|
82
|
+
.rank(query, {
|
|
83
|
+
limit: MAX_VISIBLE_ROWS,
|
|
84
|
+
signal: autocompleteOptions.signal,
|
|
85
|
+
})
|
|
86
|
+
.catch(() => undefined);
|
|
87
|
+
if (items === undefined) {
|
|
88
|
+
return current.getSuggestions(
|
|
89
|
+
lines,
|
|
90
|
+
cursorLine,
|
|
91
|
+
cursorCol,
|
|
92
|
+
autocompleteOptions
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (items.length === 0) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
items: items.map((item) => ({
|
|
100
|
+
...item,
|
|
101
|
+
value: `@${item.value}`,
|
|
102
|
+
})),
|
|
103
|
+
prefix: `@${query}`,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
108
|
+
// Pi appends a trailing space after file completions; apply @ items
|
|
109
|
+
// ourselves so Tab inserts the bare path.
|
|
110
|
+
if (prefix.startsWith("@")) {
|
|
111
|
+
const line = lines[cursorLine] ?? "";
|
|
112
|
+
const beforePrefix = line.slice(0, cursorCol - prefix.length);
|
|
113
|
+
const afterCursor = line.slice(cursorCol);
|
|
114
|
+
const hasTrailingQuote = item.value.endsWith('"');
|
|
115
|
+
const adjustedAfterCursor =
|
|
116
|
+
prefix.startsWith('@"') &&
|
|
117
|
+
hasTrailingQuote &&
|
|
118
|
+
afterCursor.startsWith('"')
|
|
119
|
+
? afterCursor.slice(1)
|
|
120
|
+
: afterCursor;
|
|
121
|
+
|
|
122
|
+
const newLines = [...lines];
|
|
123
|
+
newLines[cursorLine] =
|
|
124
|
+
`${beforePrefix}${item.value}${adjustedAfterCursor}`;
|
|
54
125
|
|
|
55
|
-
|
|
56
|
-
|
|
126
|
+
const isDirectory = item.label.endsWith("/");
|
|
127
|
+
const cursorOffset =
|
|
128
|
+
isDirectory && hasTrailingQuote
|
|
129
|
+
? item.value.length - 1
|
|
130
|
+
: item.value.length;
|
|
57
131
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
132
|
+
if (isDirectory) {
|
|
133
|
+
keepDrilling();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
lines: newLines,
|
|
138
|
+
cursorLine,
|
|
139
|
+
cursorCol: beforePrefix.length + cursorOffset,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const result = current.applyCompletion(
|
|
64
144
|
lines,
|
|
65
145
|
cursorLine,
|
|
66
146
|
cursorCol,
|
|
67
|
-
|
|
147
|
+
item,
|
|
148
|
+
prefix
|
|
68
149
|
);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const result = current.applyCompletion(
|
|
84
|
-
lines,
|
|
85
|
-
cursorLine,
|
|
86
|
-
cursorCol,
|
|
87
|
-
item,
|
|
88
|
-
prefix
|
|
89
|
-
);
|
|
90
|
-
// Pi cancels autocomplete after Tab; for directories we want to keep
|
|
91
|
-
// drilling, so re-enter Tab on the next tick.
|
|
92
|
-
if (typeof item.value === "string" && item.value.endsWith("/")) {
|
|
93
|
-
setTimeout(() => {
|
|
94
|
-
try {
|
|
95
|
-
process.stdin.emit("data", "\t");
|
|
96
|
-
} catch {}
|
|
97
|
-
}, 0);
|
|
98
|
-
}
|
|
99
|
-
return result;
|
|
100
|
-
},
|
|
101
|
-
|
|
102
|
-
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
103
|
-
return (
|
|
104
|
-
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
|
|
105
|
-
true
|
|
106
|
-
);
|
|
107
|
-
},
|
|
108
|
-
});
|
|
150
|
+
if (item.value.endsWith("/")) {
|
|
151
|
+
keepDrilling();
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
157
|
+
return (
|
|
158
|
+
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
|
|
159
|
+
true
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
};
|
|
109
164
|
}
|
|
110
165
|
|
|
111
166
|
export default function (pi: ExtensionAPI): void {
|
|
112
167
|
pi.on("session_start", (_event, ctx) => {
|
|
113
168
|
ctx.ui.addAutocompleteProvider(
|
|
114
169
|
createFilePickerProviderFactory({
|
|
115
|
-
|
|
170
|
+
engine: new WorkerFilePickerSuggestionEngine(ctx.cwd),
|
|
116
171
|
})
|
|
117
172
|
);
|
|
118
173
|
});
|
|
@@ -14,31 +14,175 @@ export type FileRankOptions = {
|
|
|
14
14
|
|
|
15
15
|
const isAbsoluteQuery = (query: string): boolean =>
|
|
16
16
|
query.startsWith("/") || query.startsWith("~");
|
|
17
|
+
const GLOBAL_FUZZY_QUERY_MIN_LENGTH = 3;
|
|
17
18
|
|
|
18
19
|
let cachedIndex:
|
|
19
20
|
| {
|
|
20
21
|
readonly source: readonly FileCandidate[];
|
|
21
|
-
readonly index:
|
|
22
|
+
readonly index: RelativeRankingIndex;
|
|
22
23
|
}
|
|
23
24
|
| undefined;
|
|
24
25
|
|
|
25
26
|
const indexFor = (
|
|
26
27
|
candidates: readonly FileCandidate[]
|
|
27
|
-
):
|
|
28
|
+
): RelativeRankingIndex => {
|
|
28
29
|
if (cachedIndex?.source === candidates) {
|
|
29
30
|
return cachedIndex.index;
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
item: candidate,
|
|
34
|
-
haystacks: [candidate.matchHaystack],
|
|
35
|
-
})
|
|
36
|
-
);
|
|
37
|
-
const index = FuzzyMatcher.prepare(fuzzy);
|
|
32
|
+
|
|
33
|
+
const index = new RelativeRankingIndex(candidates);
|
|
38
34
|
cachedIndex = { source: candidates, index };
|
|
39
35
|
return index;
|
|
40
36
|
};
|
|
41
37
|
|
|
38
|
+
type LoweredCandidate = {
|
|
39
|
+
readonly candidate: FileCandidate;
|
|
40
|
+
readonly nameLower: string;
|
|
41
|
+
readonly haystackLower: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
class RelativeRankingIndex {
|
|
45
|
+
private readonly childrenByDirectory = new Map<string, FileCandidate[]>();
|
|
46
|
+
private readonly loweredSource: readonly LoweredCandidate[];
|
|
47
|
+
|
|
48
|
+
private readonly scopedIndexes = new Map<string, FuzzyIndex<FileCandidate>>();
|
|
49
|
+
private globalIndex: FuzzyIndex<FileCandidate> | undefined;
|
|
50
|
+
|
|
51
|
+
public constructor(private readonly source: readonly FileCandidate[]) {
|
|
52
|
+
const lowered: LoweredCandidate[] = [];
|
|
53
|
+
|
|
54
|
+
for (const candidate of source) {
|
|
55
|
+
const slash = candidate.insertPath.lastIndexOf("/");
|
|
56
|
+
const directory =
|
|
57
|
+
slash === -1 ? "" : candidate.insertPath.slice(0, slash);
|
|
58
|
+
const children = this.childrenByDirectory.get(directory) ?? [];
|
|
59
|
+
children.push(candidate);
|
|
60
|
+
this.childrenByDirectory.set(directory, children);
|
|
61
|
+
|
|
62
|
+
lowered.push({
|
|
63
|
+
candidate,
|
|
64
|
+
nameLower: basename(candidate.insertPath).toLocaleLowerCase(),
|
|
65
|
+
haystackLower: candidate.matchHaystack.toLocaleLowerCase(),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.loweredSource = lowered;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public rank(query: string, limit: number | undefined): AutocompleteItem[] {
|
|
73
|
+
const scoped = this.scopedCandidates(query);
|
|
74
|
+
if (scoped !== undefined) {
|
|
75
|
+
return rankCandidates(scoped.candidates, scoped.residualQuery, limit, {
|
|
76
|
+
index: () => this.indexForScope(scoped.directory, scoped.candidates),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const literalHits = this.literalRank(query, limit);
|
|
81
|
+
if (literalHits !== undefined) {
|
|
82
|
+
return literalHits;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return rankCandidates(this.source, query, limit, {
|
|
86
|
+
index: () => this.indexForGlobal(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private scopedCandidates(query: string):
|
|
91
|
+
| {
|
|
92
|
+
readonly directory: string;
|
|
93
|
+
readonly residualQuery: string;
|
|
94
|
+
readonly candidates: readonly FileCandidate[];
|
|
95
|
+
}
|
|
96
|
+
| undefined {
|
|
97
|
+
const slash = query.lastIndexOf("/");
|
|
98
|
+
if (slash === -1) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const directory = query.slice(0, slash);
|
|
103
|
+
const candidates = this.childrenByDirectory.get(directory);
|
|
104
|
+
if (candidates === undefined) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
directory,
|
|
110
|
+
residualQuery: query.slice(slash + 1),
|
|
111
|
+
candidates,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private indexForScope(
|
|
116
|
+
directory: string,
|
|
117
|
+
candidates: readonly FileCandidate[]
|
|
118
|
+
): FuzzyIndex<FileCandidate> {
|
|
119
|
+
const cached = this.scopedIndexes.get(directory);
|
|
120
|
+
if (cached !== undefined) {
|
|
121
|
+
return cached;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const index = prepareIndex(candidates, (candidate) =>
|
|
125
|
+
basename(candidate.insertPath)
|
|
126
|
+
);
|
|
127
|
+
this.scopedIndexes.set(directory, index);
|
|
128
|
+
return index;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private indexForGlobal(): FuzzyIndex<FileCandidate> {
|
|
132
|
+
this.globalIndex ??= prepareIndex(
|
|
133
|
+
this.source,
|
|
134
|
+
(candidate) => candidate.matchHaystack
|
|
135
|
+
);
|
|
136
|
+
return this.globalIndex;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private literalRank(
|
|
140
|
+
query: string,
|
|
141
|
+
limit: number | undefined
|
|
142
|
+
): AutocompleteItem[] | undefined {
|
|
143
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
144
|
+
if (needle.length === 0 || query.includes("/")) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const prefixHits: FileCandidate[] = [];
|
|
149
|
+
const substringHits: FileCandidate[] = [];
|
|
150
|
+
const limitSize = limit ?? Infinity;
|
|
151
|
+
|
|
152
|
+
for (const { candidate, nameLower, haystackLower } of this.loweredSource) {
|
|
153
|
+
if (nameLower.startsWith(needle) || haystackLower.startsWith(needle)) {
|
|
154
|
+
prefixHits.push(candidate);
|
|
155
|
+
} else if (nameLower.includes(needle) || haystackLower.includes(needle)) {
|
|
156
|
+
substringHits.push(candidate);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Prefix hits always outrank substring hits, so we can stop only once we
|
|
160
|
+
// have enough prefixes to fill the limit; otherwise a late-sorting prefix
|
|
161
|
+
// could be dropped for an earlier substring match.
|
|
162
|
+
if (prefixHits.length >= limitSize) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const hitCount = prefixHits.length + substringHits.length;
|
|
168
|
+
if (hitCount === 0) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
prefixHits.length === 0 &&
|
|
174
|
+
needle.length >= GLOBAL_FUZZY_QUERY_MIN_LENGTH &&
|
|
175
|
+
hitCount < limitSize
|
|
176
|
+
) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return [...prefixHits, ...substringHits]
|
|
181
|
+
.slice(0, limit)
|
|
182
|
+
.map((candidate) => toItem(candidate));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
42
186
|
export async function rank(
|
|
43
187
|
query: string,
|
|
44
188
|
options: FileRankOptions
|
|
@@ -52,18 +196,42 @@ export async function rank(
|
|
|
52
196
|
return undefined;
|
|
53
197
|
}
|
|
54
198
|
|
|
55
|
-
return
|
|
199
|
+
return indexFor(options.cachedRelative).rank(query, options.limit);
|
|
56
200
|
}
|
|
57
201
|
|
|
202
|
+
type RankCandidatesOptions = {
|
|
203
|
+
readonly index?: () => FuzzyIndex<FileCandidate>;
|
|
204
|
+
};
|
|
205
|
+
|
|
58
206
|
const rankCandidates = (
|
|
59
207
|
candidates: readonly FileCandidate[],
|
|
60
208
|
query: string,
|
|
61
|
-
limit: number | undefined
|
|
209
|
+
limit: number | undefined,
|
|
210
|
+
options: RankCandidatesOptions = {}
|
|
62
211
|
): AutocompleteItem[] => {
|
|
63
|
-
|
|
212
|
+
if (query.trim().length === 0) {
|
|
213
|
+
return candidates.slice(0, limit).map((candidate) => toItem(candidate));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const index = options.index ?? (() => prepareIndex(candidates));
|
|
217
|
+
const hits = index().find(query, { limit });
|
|
64
218
|
return hits.map((hit) => toItem(hit.item));
|
|
65
219
|
};
|
|
66
220
|
|
|
221
|
+
const prepareIndex = (
|
|
222
|
+
candidates: readonly FileCandidate[],
|
|
223
|
+
haystack: (candidate: FileCandidate) => string = (candidate) =>
|
|
224
|
+
candidate.matchHaystack
|
|
225
|
+
): FuzzyIndex<FileCandidate> => {
|
|
226
|
+
const fuzzy: FuzzyCandidate<FileCandidate>[] = candidates.map(
|
|
227
|
+
(candidate) => ({
|
|
228
|
+
item: candidate,
|
|
229
|
+
haystacks: [haystack(candidate)],
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
return FuzzyMatcher.prepare(fuzzy);
|
|
233
|
+
};
|
|
234
|
+
|
|
67
235
|
const toItem = (candidate: FileCandidate): AutocompleteItem => {
|
|
68
236
|
const suffix = candidate.isDirectory ? "/" : "";
|
|
69
237
|
const value = `${candidate.insertPath}${suffix}`;
|
|
@@ -2,7 +2,7 @@ import { FileScanner, type FileScanOptions } from "../../shared/FileScanner";
|
|
|
2
2
|
import { FsErrors } from "../../shared/FsErrors";
|
|
3
3
|
import { Lines } from "../../shared/Lines";
|
|
4
4
|
|
|
5
|
-
const MATCH_CONCURRENCY =
|
|
5
|
+
const MATCH_CONCURRENCY = 16;
|
|
6
6
|
|
|
7
7
|
export type GrepLine = {
|
|
8
8
|
readonly lineNumber: number;
|
|
@@ -25,10 +25,33 @@ export type GrepMatch = {
|
|
|
25
25
|
export type GrepMatcher = {
|
|
26
26
|
readonly regex: RegExp;
|
|
27
27
|
readonly matchAcrossLines: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Raw-byte needle for the literal fast path: present only when the pattern is
|
|
30
|
+
* a pure literal, case-sensitive, and single-line, so `matchFile` can reject a
|
|
31
|
+
* non-matching file with `Buffer.indexOf` before decoding it. Undefined
|
|
32
|
+
* otherwise, in which case the regex path runs unchanged.
|
|
33
|
+
*/
|
|
34
|
+
readonly literal: Buffer | undefined;
|
|
28
35
|
};
|
|
29
36
|
|
|
30
37
|
export type GrepScanOptions = FileScanOptions;
|
|
31
38
|
|
|
39
|
+
// Characters that stand for themselves in both a default-flag regex and raw
|
|
40
|
+
// UTF-8 bytes (all ASCII, so they never alias a multibyte sequence). A pattern
|
|
41
|
+
// made only of these is a literal we can scan on bytes.
|
|
42
|
+
const PURE_LITERAL = /^[A-Za-z0-9_ \-/]+$/;
|
|
43
|
+
|
|
44
|
+
function literalNeedle(
|
|
45
|
+
pattern: string,
|
|
46
|
+
caseInsensitive: boolean,
|
|
47
|
+
matchAcrossLines: boolean
|
|
48
|
+
): Buffer | undefined {
|
|
49
|
+
if (caseInsensitive || matchAcrossLines || !PURE_LITERAL.test(pattern)) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return Buffer.from(pattern, "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
export function buildMatcher(options: {
|
|
33
56
|
readonly pattern: string;
|
|
34
57
|
readonly caseInsensitive: boolean;
|
|
@@ -42,6 +65,11 @@ export function buildMatcher(options: {
|
|
|
42
65
|
return {
|
|
43
66
|
regex: new RegExp(options.pattern, flags),
|
|
44
67
|
matchAcrossLines: options.matchAcrossLines,
|
|
68
|
+
literal: literalNeedle(
|
|
69
|
+
options.pattern,
|
|
70
|
+
options.caseInsensitive,
|
|
71
|
+
options.matchAcrossLines
|
|
72
|
+
),
|
|
45
73
|
};
|
|
46
74
|
} catch (error) {
|
|
47
75
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -87,11 +115,26 @@ async function matchFile(
|
|
|
87
115
|
): Promise<GrepMatch | undefined> {
|
|
88
116
|
const file = Bun.file(filePath);
|
|
89
117
|
|
|
118
|
+
// Binary skip reads only the first 8KB, so a binary file is never fully read.
|
|
90
119
|
if (await Lines.isBinary(file)) {
|
|
91
120
|
return undefined;
|
|
92
121
|
}
|
|
93
122
|
|
|
94
|
-
|
|
123
|
+
let text: string;
|
|
124
|
+
if (matcher.literal !== undefined) {
|
|
125
|
+
// Literal fast path: scan raw bytes and bail on a miss without decoding. An
|
|
126
|
+
// ASCII literal can't match across a normalized newline or alias a
|
|
127
|
+
// multibyte char, so a raw-byte hit/miss matches the decoded result.
|
|
128
|
+
const bytes = Buffer.from(await file.arrayBuffer());
|
|
129
|
+
if (bytes.indexOf(matcher.literal) < 0) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
text = bytes.toString("utf8");
|
|
133
|
+
} else {
|
|
134
|
+
text = await file.text();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const content = Lines.normalize(text);
|
|
95
138
|
const fileLines = Lines.split(content);
|
|
96
139
|
const ranges = matcher.matchAcrossLines
|
|
97
140
|
? regexRanges(content, matcher.regex)
|