@d3ara1n/pi-hashline-edit 0.5.7 → 0.6.1
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 +1 -0
- package/package.json +1 -1
- package/src/integration/grep-rg.test.ts +68 -1
- package/src/pi/grep-tool.ts +93 -12
- package/src/pi/grep.test.ts +76 -4
package/README.md
CHANGED
|
@@ -165,6 +165,7 @@ The `grep` override also covers the compound queries that otherwise push models
|
|
|
165
165
|
- `wordMatch` — whole words only (`rg -w`)
|
|
166
166
|
- `outputMode: "files"` / `"count"` — just the file paths (`rg -l`) or per-file counts + total (`grep -c`); `"files"` output pastes straight back as a `path` array
|
|
167
167
|
- `pattern` and `path` accept arrays — several patterns combined per `matchMode`, several search roots in one call
|
|
168
|
+
- `glob` accepts one pattern or an ordered array of ripgrep globs, e.g. `["*.ts", "*.md", "!**/*.test.ts"]` to include TypeScript and Markdown files but exclude tests. It also filters explicitly named files; this requires a locally available ripgrep binary.
|
|
168
169
|
|
|
169
170
|
Filters run before the match limit counts, and context windows are rebuilt from surviving matches, so `limit` and `context` compose cleanly with `matchMode`/`excludePattern`.
|
|
170
171
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-hashline-edit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
|
|
6
6
|
"homepage": "https://github.com/d3ara1n/pi-extensions/tree/main/packages/pi-hashline-edit#readme",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { test } from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
|
-
import { access, constants, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { access, constants, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { delimiter, join } from "node:path";
|
|
10
10
|
import { makeGrepOverrideWithBackend } from "../pi/grep-tool.ts";
|
|
@@ -46,6 +46,73 @@ test("real rg emits anchored matches from a temporary directory", {
|
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
test("real rg combines include and exclude globs in order", {
|
|
50
|
+
skip: rgPath === null,
|
|
51
|
+
}, async () => {
|
|
52
|
+
const directory = await mkdtemp(join(tmpdir(), "hl-grep-globs-"));
|
|
53
|
+
try {
|
|
54
|
+
for (const name of ["first.ts", "second.ts", "first.test.ts", "notes.md", "notes.txt"]) {
|
|
55
|
+
await writeFile(join(directory, name), "needle\n");
|
|
56
|
+
}
|
|
57
|
+
const tool = makeGrepOverrideWithBackend(directory, {
|
|
58
|
+
findRg: async () => rgPath,
|
|
59
|
+
delegate: async () => {
|
|
60
|
+
throw new Error("integration test must not invoke the built-in grep delegate");
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const result: any = await tool.execute("0", {
|
|
64
|
+
pattern: "needle",
|
|
65
|
+
glob: ["*.ts", "*.md", "!**/*.test.ts"],
|
|
66
|
+
outputMode: "files",
|
|
67
|
+
}, undefined, undefined);
|
|
68
|
+
assert.deepEqual(result.content[0].text.split("\n").sort(), ["first.ts", "notes.md", "second.ts"]);
|
|
69
|
+
} finally {
|
|
70
|
+
await rm(directory, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("real rg applies globs to explicit files and mixed search paths", {
|
|
75
|
+
skip: rgPath === null,
|
|
76
|
+
}, async () => {
|
|
77
|
+
const directory = await mkdtemp(join(tmpdir(), "hl-grep-explicit-globs-"));
|
|
78
|
+
try {
|
|
79
|
+
await mkdir(join(directory, "sub"));
|
|
80
|
+
await writeFile(join(directory, ".gitignore"), "ignored.ts\n");
|
|
81
|
+
for (const name of ["keep.ts", "drop.test.ts", "ignored.ts", "sub/child.ts"]) {
|
|
82
|
+
await writeFile(join(directory, name), "needle\n");
|
|
83
|
+
}
|
|
84
|
+
const tool = makeGrepOverrideWithBackend(directory, {
|
|
85
|
+
findRg: async () => rgPath,
|
|
86
|
+
delegate: async () => {
|
|
87
|
+
throw new Error("integration test must not invoke the built-in grep delegate");
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const search = async (path: string | string[], glob: string | string[]) => {
|
|
91
|
+
const result: any = await tool.execute("0", { pattern: "needle", path, glob, outputMode: "files" }, undefined, undefined);
|
|
92
|
+
return result.content[0].text;
|
|
93
|
+
};
|
|
94
|
+
const globs = ["*.ts", "!**/*.test.ts"];
|
|
95
|
+
assert.deepEqual(
|
|
96
|
+
(await search(["drop.test.ts", "keep.ts", "ignored.ts"], globs)).split("\n").sort(),
|
|
97
|
+
["ignored.ts", "keep.ts"],
|
|
98
|
+
);
|
|
99
|
+
assert.equal(await search("drop.test.ts", "*.ts"), "drop.test.ts");
|
|
100
|
+
assert.equal(await search("drop.test.ts", globs), "No matches found");
|
|
101
|
+
assert.equal(await search(["drop.test.ts", "keep.ts"], "!**/*.test.ts"), "keep.ts");
|
|
102
|
+
assert.equal(await search("sub/child.ts", "**/sub/*.ts"), "sub/child.ts");
|
|
103
|
+
assert.deepEqual(
|
|
104
|
+
(await search(["keep.ts", "sub/child.ts"], globs)).split("\n").sort(),
|
|
105
|
+
["keep.ts", "sub/child.ts"],
|
|
106
|
+
);
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
(await search(["drop.test.ts", "sub", "keep.ts"], globs)).split("\n").sort(),
|
|
109
|
+
["keep.ts", "sub/child.ts"],
|
|
110
|
+
);
|
|
111
|
+
} finally {
|
|
112
|
+
await rm(directory, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
49
116
|
test("real rg and line filters honor explicit case settings and Unicode folding", {
|
|
50
117
|
skip: rgPath === null,
|
|
51
118
|
}, async () => {
|
package/src/pi/grep-tool.ts
CHANGED
|
@@ -42,7 +42,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
42
42
|
import { spawn } from "node:child_process";
|
|
43
43
|
import { createInterface } from "node:readline";
|
|
44
44
|
import { access, constants, readFile, stat } from "node:fs/promises";
|
|
45
|
-
import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
45
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
46
46
|
import { hashFileLines } from "../core/hash.ts";
|
|
47
47
|
import { splitLines } from "../core/lines.ts";
|
|
48
48
|
import { getState } from "./state.ts";
|
|
@@ -140,7 +140,9 @@ const grepOverrideSchema = Type.Object({
|
|
|
140
140
|
"Directory or file to search (string or array of paths; default: current directory)",
|
|
141
141
|
})),
|
|
142
142
|
glob: Type.Optional(
|
|
143
|
-
Type.String(
|
|
143
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
144
|
+
description: "Filter files (including explicit file paths) by glob; pass an ordered array for multiple filters and prefix exclusions with `!`, e.g. ['*.ts', '!**/*.test.ts']",
|
|
145
|
+
}),
|
|
144
146
|
),
|
|
145
147
|
ignoreCase: Type.Optional(
|
|
146
148
|
Type.Boolean({ description: "Case-insensitive search (default: false); applies to pattern and excludePattern." }),
|
|
@@ -176,6 +178,12 @@ interface RgRunResult {
|
|
|
176
178
|
/** @internal — injectable process and fallback boundary for deterministic tests. */
|
|
177
179
|
export interface GrepBackend {
|
|
178
180
|
findRg(): Promise<string | null>;
|
|
181
|
+
listFiles(
|
|
182
|
+
rgPath: string,
|
|
183
|
+
directories: string[],
|
|
184
|
+
globs: string[],
|
|
185
|
+
signal: AbortSignal | undefined,
|
|
186
|
+
): Promise<Set<string>>;
|
|
179
187
|
runRg(
|
|
180
188
|
rgPath: string,
|
|
181
189
|
args: string[],
|
|
@@ -190,6 +198,43 @@ export interface GrepBackend {
|
|
|
190
198
|
): Promise<any>;
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
/** List direct children using rg's own ordered glob rules (including ignored files). */
|
|
202
|
+
function listRgFiles(
|
|
203
|
+
rgPath: string,
|
|
204
|
+
directories: string[],
|
|
205
|
+
globs: string[],
|
|
206
|
+
signal: AbortSignal | undefined,
|
|
207
|
+
): Promise<Set<string>> {
|
|
208
|
+
return new Promise((resolveFiles, reject) => {
|
|
209
|
+
if (signal?.aborted) return reject(new Error("Operation aborted"));
|
|
210
|
+
const args = ["--files", "--null", "--hidden", "--no-ignore", "--follow", "--max-depth=1"];
|
|
211
|
+
for (const glob of globs) args.push("--glob", glob);
|
|
212
|
+
args.push("--", ...directories);
|
|
213
|
+
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
214
|
+
const chunks: Buffer[] = [];
|
|
215
|
+
let stderr = "";
|
|
216
|
+
const onAbort = () => child.kill();
|
|
217
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
218
|
+
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
219
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
220
|
+
stderr += chunk.toString();
|
|
221
|
+
});
|
|
222
|
+
child.on("error", (error) => {
|
|
223
|
+
signal?.removeEventListener("abort", onAbort);
|
|
224
|
+
reject(new Error(`Failed to run ripgrep: ${error.message}`));
|
|
225
|
+
});
|
|
226
|
+
child.on("close", (code) => {
|
|
227
|
+
signal?.removeEventListener("abort", onAbort);
|
|
228
|
+
if (signal?.aborted) return reject(new Error("Operation aborted"));
|
|
229
|
+
if (code !== 0 && code !== 1) {
|
|
230
|
+
return reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
|
|
231
|
+
}
|
|
232
|
+
const paths = Buffer.concat(chunks).toString("utf-8").split("\0").filter(Boolean);
|
|
233
|
+
resolveFiles(new Set(paths));
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
193
238
|
/** Run ripgrep and stream its JSON lines to the caller until it asks to stop. */
|
|
194
239
|
function runRg(
|
|
195
240
|
rgPath: string,
|
|
@@ -256,6 +301,10 @@ function countLeading(s: string): number {
|
|
|
256
301
|
function toDisplayLines(raw: string, theme: any): string[] {
|
|
257
302
|
const out: string[] = [];
|
|
258
303
|
const lines = raw.split("\n");
|
|
304
|
+
const lineNoWidth = lines.reduce(
|
|
305
|
+
(width, line) => Math.max(width, parseHashline(line)?.lineNo.length ?? 0),
|
|
306
|
+
0,
|
|
307
|
+
);
|
|
259
308
|
let i = 0;
|
|
260
309
|
while (i < lines.length) {
|
|
261
310
|
const line = lines[i];
|
|
@@ -276,7 +325,7 @@ function toDisplayLines(raw: string, theme: any): string[] {
|
|
|
276
325
|
const marker = base > 0 ? theme.fg("dim", "›") + " " : "";
|
|
277
326
|
for (const g of group) {
|
|
278
327
|
const body = g.content.slice(base);
|
|
279
|
-
out.push(theme.fg("dim", ` ${g.lineNo}: `) + marker + theme.fg("toolOutput", body));
|
|
328
|
+
out.push(theme.fg("dim", ` ${g.lineNo.padStart(lineNoWidth)}: `) + marker + theme.fg("toolOutput", body));
|
|
280
329
|
}
|
|
281
330
|
i = j;
|
|
282
331
|
continue;
|
|
@@ -298,6 +347,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
298
347
|
let builtin: ReturnType<typeof createGrepTool> | undefined;
|
|
299
348
|
const backend: GrepBackend = {
|
|
300
349
|
findRg,
|
|
350
|
+
listFiles: listRgFiles,
|
|
301
351
|
runRg,
|
|
302
352
|
delegate(toolCallId, params, signal, onUpdate) {
|
|
303
353
|
builtin ??= createGrepTool(cwd);
|
|
@@ -340,7 +390,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
340
390
|
text += theme.fg("toolOutput", ` -v:${ex}`);
|
|
341
391
|
}
|
|
342
392
|
if (args?.wordMatch) text += theme.fg("toolOutput", " -w");
|
|
343
|
-
if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
|
|
393
|
+
if (args?.glob) text += theme.fg("toolOutput", ` (${toArray(args.glob).join(", ")})`);
|
|
344
394
|
if (args?.outputMode && args.outputMode !== "content")
|
|
345
395
|
text += theme.fg("success", ` → ${args.outputMode}`);
|
|
346
396
|
if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
|
|
@@ -388,12 +438,22 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
388
438
|
params.excludePattern === undefined &&
|
|
389
439
|
params.outputMode === undefined &&
|
|
390
440
|
params.wordMatch === undefined &&
|
|
441
|
+
!Array.isArray(params.glob) &&
|
|
391
442
|
!Array.isArray(params.path);
|
|
392
443
|
|
|
444
|
+
const globs = toArray(params.glob);
|
|
393
445
|
const rgPath = await backend.findRg();
|
|
394
|
-
//
|
|
446
|
+
// The built-in grep also lets explicit files bypass globs.
|
|
395
447
|
if (!rgPath) {
|
|
396
|
-
if (legacyShaped)
|
|
448
|
+
if (legacyShaped) {
|
|
449
|
+
if (params.glob !== undefined && params.path !== undefined) {
|
|
450
|
+
const path = canonicalPath(cwd, params.path);
|
|
451
|
+
let explicitFile = false;
|
|
452
|
+
try { explicitFile = (await stat(path)).isFile(); } catch {}
|
|
453
|
+
if (explicitFile) throw new Error("ripgrep (rg) not found; cannot apply glob to an explicit file");
|
|
454
|
+
}
|
|
455
|
+
return backend.delegate(toolCallId, delegatedParams, signal, onUpdate);
|
|
456
|
+
}
|
|
397
457
|
throw new Error(
|
|
398
458
|
"ripgrep (rg) not found; extended grep params cannot fall back to the built-in grep. Retry with a simple pattern first, or use bash",
|
|
399
459
|
);
|
|
@@ -402,22 +462,43 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
402
462
|
const excludes = toArray(params.excludePattern);
|
|
403
463
|
const matchMode: "any" | "all" = params.matchMode ?? "any";
|
|
404
464
|
const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
|
|
405
|
-
const {
|
|
465
|
+
const { ignoreCase, literal, wordMatch, limit } = params;
|
|
406
466
|
const searchPaths = (() => {
|
|
407
467
|
const raw = toArray(params.path);
|
|
408
468
|
return (raw.length ? raw : ["."]).map((p) => canonicalPath(cwd, p));
|
|
409
469
|
})();
|
|
410
470
|
const hashLen = state.config.hashLen;
|
|
411
471
|
|
|
412
|
-
//
|
|
413
|
-
|
|
472
|
+
// rg ignores globs for explicit files, so select those with its file walker first.
|
|
473
|
+
const pathInfo: { path: string; isFile: boolean }[] = [];
|
|
474
|
+
const parents = new Set<string>();
|
|
414
475
|
for (const sp of searchPaths) {
|
|
476
|
+
let info;
|
|
415
477
|
try {
|
|
416
|
-
await stat(sp);
|
|
478
|
+
info = await stat(sp);
|
|
417
479
|
} catch {
|
|
418
480
|
throw new Error(`Path not found: ${sp}`);
|
|
419
481
|
}
|
|
482
|
+
const isFile = info.isFile();
|
|
483
|
+
pathInfo.push({ path: sp, isFile });
|
|
484
|
+
if (globs.length && isFile) parents.add(dirname(sp));
|
|
420
485
|
}
|
|
486
|
+
const fileKey = (path: string) => {
|
|
487
|
+
const absolute = resolve(cwd, path);
|
|
488
|
+
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
489
|
+
};
|
|
490
|
+
const listed = parents.size
|
|
491
|
+
? await backend.listFiles(rgPath, [...parents], globs, signal)
|
|
492
|
+
: new Set<string>();
|
|
493
|
+
const allowed = new Set([...listed].map(fileKey));
|
|
494
|
+
const selectedPaths = pathInfo
|
|
495
|
+
.filter(({ path, isFile }) => !globs.length || !isFile || allowed.has(fileKey(path)))
|
|
496
|
+
.map(({ path }) => path);
|
|
497
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
498
|
+
if (selectedPaths.length === 0) return {
|
|
499
|
+
content: [{ type: "text", text: "No matches found" }],
|
|
500
|
+
details: undefined,
|
|
501
|
+
};
|
|
421
502
|
|
|
422
503
|
// Client-side line filters — only AND / exclude need them; "any" is native rg (-e OR).
|
|
423
504
|
const excludeMatchers = excludes.map((p) =>
|
|
@@ -446,9 +527,9 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
446
527
|
if (ignoreCase) args.push("--ignore-case");
|
|
447
528
|
if (literal) args.push("--fixed-strings");
|
|
448
529
|
if (wordMatch) args.push("--word-regexp");
|
|
449
|
-
|
|
530
|
+
for (const glob of globs) args.push("--glob", glob);
|
|
450
531
|
for (const p of patterns) args.push("-e", p);
|
|
451
|
-
args.push("--", ...
|
|
532
|
+
args.push("--", ...selectedPaths);
|
|
452
533
|
|
|
453
534
|
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
|
|
454
535
|
let matchCount = 0;
|
package/src/pi/grep.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { getState } from "./state.ts";
|
|
|
14
14
|
|
|
15
15
|
type FakeOptions = {
|
|
16
16
|
lines?: string[];
|
|
17
|
+
files?: string[];
|
|
17
18
|
code?: number | null;
|
|
18
19
|
stderr?: string;
|
|
19
20
|
error?: Error;
|
|
@@ -38,11 +39,16 @@ function rgMatch(filePath: string, lineNumber: number, text: string): string {
|
|
|
38
39
|
|
|
39
40
|
function fakeBackend(options: FakeOptions = {}) {
|
|
40
41
|
const calls: { path: string; args: string[] }[] = [];
|
|
42
|
+
const fileCalls: { directories: string[]; globs: string[] }[] = [];
|
|
41
43
|
const delegates: any[][] = [];
|
|
42
44
|
const backend: GrepBackend = {
|
|
43
45
|
async findRg() {
|
|
44
46
|
return "/fake/rg";
|
|
45
47
|
},
|
|
48
|
+
async listFiles(_path, directories, globs) {
|
|
49
|
+
fileCalls.push({ directories, globs });
|
|
50
|
+
return new Set(options.files ?? []);
|
|
51
|
+
},
|
|
46
52
|
async runRg(path, args, _signal, onLine) {
|
|
47
53
|
calls.push({ path, args });
|
|
48
54
|
options.onRun?.();
|
|
@@ -61,7 +67,7 @@ function fakeBackend(options: FakeOptions = {}) {
|
|
|
61
67
|
return { content: [{ type: "text", text: "delegated" }], details: undefined };
|
|
62
68
|
},
|
|
63
69
|
};
|
|
64
|
-
return { backend, calls, delegates };
|
|
70
|
+
return { backend, calls, fileCalls, delegates };
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
const text = (result: any): string => result.content[0].text;
|
|
@@ -79,6 +85,27 @@ async function withEnabled<T>(enabled: boolean, fn: () => Promise<T>): Promise<T
|
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
test("grep TUI aligns line-number colons across file groups", () => {
|
|
89
|
+
const raw = [
|
|
90
|
+
"a.ts · 2 matches",
|
|
91
|
+
"99#ABCD│ alpha",
|
|
92
|
+
"100#ABCD│ beta",
|
|
93
|
+
"b.ts · 1 match",
|
|
94
|
+
"7#ABCD│gamma",
|
|
95
|
+
].join("\n");
|
|
96
|
+
const tool = makeGrepOverrideWithBackend(".", {});
|
|
97
|
+
const theme = { fg: (_color: string, value: string) => value };
|
|
98
|
+
const result = { content: [{ type: "text", text: raw }] };
|
|
99
|
+
const rendered = tool.renderResult(result, { isPartial: false, expanded: true }, theme, {}).render(80);
|
|
100
|
+
assert.deepEqual(rendered.map((line) => line.trimEnd()), [
|
|
101
|
+
"a.ts · 2 matches",
|
|
102
|
+
" 99: › alpha",
|
|
103
|
+
" 100: › beta",
|
|
104
|
+
"b.ts · 1 match",
|
|
105
|
+
" 7: gamma",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
82
109
|
test("formats parsed rg matches with full-line hash anchors", async () => {
|
|
83
110
|
await withDir(async (dir) =>
|
|
84
111
|
withEnabled(true, async () => {
|
|
@@ -217,12 +244,15 @@ test("passes output flags and formats files and counts", async () => {
|
|
|
217
244
|
const b = join(dir, "b.ts");
|
|
218
245
|
await writeFile(a, "Foo a.b\n");
|
|
219
246
|
await writeFile(b, "foo a.b\n");
|
|
220
|
-
const fake = fakeBackend({ lines: [rgMatch(a, 1, "Foo a.b\n"), rgMatch(b, 1, "foo a.b\n")] });
|
|
247
|
+
const fake = fakeBackend({ files: [a, b], lines: [rgMatch(a, 1, "Foo a.b\n"), rgMatch(b, 1, "foo a.b\n")] });
|
|
221
248
|
|
|
222
|
-
const
|
|
249
|
+
const tool = makeGrepOverrideWithBackend(dir, fake.backend);
|
|
250
|
+
const globSchema: any = tool.parameters.properties.glob;
|
|
251
|
+
assert.deepEqual(globSchema.anyOf.map((option: any) => option.type), ["string", "array"]);
|
|
252
|
+
const files = await call(tool, {
|
|
223
253
|
pattern: ["Foo", "a.b"],
|
|
224
254
|
path: ["a.ts", "b.ts"],
|
|
225
|
-
glob: "*.ts",
|
|
255
|
+
glob: ["*.ts", "!**/*.test.ts"],
|
|
226
256
|
ignoreCase: true,
|
|
227
257
|
literal: true,
|
|
228
258
|
wordMatch: true,
|
|
@@ -239,6 +269,8 @@ test("passes output flags and formats files and counts", async () => {
|
|
|
239
269
|
"--word-regexp",
|
|
240
270
|
"--glob",
|
|
241
271
|
"*.ts",
|
|
272
|
+
"--glob",
|
|
273
|
+
"!**/*.test.ts",
|
|
242
274
|
"-e",
|
|
243
275
|
"Foo",
|
|
244
276
|
"-e",
|
|
@@ -257,6 +289,30 @@ test("passes output flags and formats files and counts", async () => {
|
|
|
257
289
|
);
|
|
258
290
|
});
|
|
259
291
|
|
|
292
|
+
test("glob filters explicit files and batches their parent directories", async () => {
|
|
293
|
+
await withDir(async (dir) => {
|
|
294
|
+
const kept = join(dir, "kept.ts");
|
|
295
|
+
const excluded = join(dir, "excluded.test.ts");
|
|
296
|
+
const nested = join(dir, "sub", "other.ts");
|
|
297
|
+
await mkdir(join(dir, "sub"));
|
|
298
|
+
await writeFile(nested, "needle\n");
|
|
299
|
+
await writeFile(kept, "needle\n");
|
|
300
|
+
await writeFile(excluded, "needle\n");
|
|
301
|
+
const fake = fakeBackend({
|
|
302
|
+
files: [kept, nested],
|
|
303
|
+
lines: [rgMatch(kept, 1, "needle\n"), rgMatch(nested, 1, "needle\n")],
|
|
304
|
+
});
|
|
305
|
+
const tool = makeGrepOverrideWithBackend(dir, fake.backend);
|
|
306
|
+
const query = { pattern: "needle", path: ["kept.ts", "excluded.test.ts", "sub/other.ts"], glob: ["*.ts", "!**/*.test.ts"] };
|
|
307
|
+
assert.match(text(await call(tool, query)), /│needle/);
|
|
308
|
+
assert.deepEqual(fake.fileCalls, [{ directories: [dir, join(dir, "sub")], globs: query.glob }]);
|
|
309
|
+
assert.deepEqual(fake.calls[0].args.slice(-3), ["--", kept, nested]);
|
|
310
|
+
|
|
311
|
+
assert.equal(text(await call(tool, { ...query, path: "excluded.test.ts" })), "No matches found");
|
|
312
|
+
assert.equal(fake.calls.length, 1);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
260
316
|
test("counts only surviving matches toward the limit and stops the fake runner", async () => {
|
|
261
317
|
await withDir(async (dir) =>
|
|
262
318
|
withEnabled(true, async () => {
|
|
@@ -337,6 +393,22 @@ test("delegates only safe fallbacks and rejects extended missing-rg requests", a
|
|
|
337
393
|
}),
|
|
338
394
|
/ripgrep \(rg\) not found/,
|
|
339
395
|
);
|
|
396
|
+
await call(tool, { pattern: "x", glob: "*.ts" });
|
|
397
|
+
assert.deepEqual(absent.delegates.at(-1)?.[1], { pattern: "x", glob: "*.ts" });
|
|
398
|
+
await assert.rejects(
|
|
399
|
+
call(tool, { pattern: "x", glob: ["*.ts", "!**/*.test.ts"] }),
|
|
400
|
+
/ripgrep \(rg\) not found/,
|
|
401
|
+
);
|
|
402
|
+
const file = join(dir, "fixture.ts");
|
|
403
|
+
await writeFile(file, "x\n");
|
|
404
|
+
const delegatedBefore = absent.delegates.length;
|
|
405
|
+
await assert.rejects(
|
|
406
|
+
call(tool, { pattern: "x", path: file, glob: "*.ts" }),
|
|
407
|
+
/cannot apply glob to an explicit file/,
|
|
408
|
+
);
|
|
409
|
+
assert.equal(absent.delegates.length, delegatedBefore);
|
|
410
|
+
assert.equal(text(await call(tool, { pattern: "x", path: file })), "delegated");
|
|
411
|
+
assert.equal(text(await call(tool, { pattern: "x", path: dir, glob: "*.ts" })), "delegated");
|
|
340
412
|
});
|
|
341
413
|
});
|
|
342
414
|
});
|