pi-diet-ripgrep 0.1.3 → 0.1.5
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 +24 -2
- package/index.ts +146 -65
- package/package.json +13 -1
- package/spec.md +5 -9
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-diet-ripgrep
|
|
2
2
|
|
|
3
|
-
> One of my diet context engineering and workflow extensions.
|
|
3
|
+
> One of my diet context engineering and workflow extensions. Explore the complete collection: <https://www.npmjs.com/~probabilityengineer>
|
|
4
4
|
|
|
5
5
|
A minimal Pi extension that exposes ripgrep as a first-class exact-search tool.
|
|
6
6
|
|
|
@@ -37,6 +37,28 @@ mkdir -p ~/.pi/agent/extensions/pi-diet-ripgrep
|
|
|
37
37
|
ln -sf /path/to/pi-diet-ripgrep/index.ts ~/.pi/agent/extensions/pi-diet-ripgrep/index.ts
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
## Development and validation
|
|
41
|
+
|
|
42
|
+
Install a current Node.js release and `rg` (ripgrep), then run:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm ci
|
|
46
|
+
npm run lint
|
|
47
|
+
npm run build
|
|
48
|
+
npm test
|
|
49
|
+
npm pack --dry-run
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`lint` performs strict TypeScript checking of the extension and tests. `build`
|
|
53
|
+
optionally compiles the extension to ignored `dist/` output; Pi and the published
|
|
54
|
+
package continue to load `index.ts` directly.
|
|
55
|
+
|
|
56
|
+
Tests require a POSIX environment (macOS or Linux). They exercise the registered
|
|
57
|
+
tool with controlled subprocess fixtures plus a real `rg` integration test.
|
|
58
|
+
Coverage includes arguments and leading-dash patterns, formatting and context,
|
|
59
|
+
no matches, invalid regexes, missing executables, match/byte/line limits, complete
|
|
60
|
+
saved output, and self-contained tool guidance.
|
|
61
|
+
|
|
40
62
|
## Publishing
|
|
41
63
|
|
|
42
64
|
First publish manually once:
|
|
@@ -67,4 +89,4 @@ git push && git push origin v0.1.1
|
|
|
67
89
|
|
|
68
90
|
## Tool
|
|
69
91
|
|
|
70
|
-
`rg` is intended for exact text and regex search
|
|
92
|
+
`rg` is intended for exact text and regex search: literal verification, logs, config keys, TODOs, and message lookup. Text matches alone do not establish symbol identity, code structure, or behavior. Its guidance does not assume other extensions are installed.
|
package/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdtemp,
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
3
5
|
import { tmpdir } from "node:os";
|
|
4
6
|
import { join } from "node:path";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
7
|
import { Type } from "@earendil-works/pi-ai";
|
|
7
8
|
import {
|
|
8
9
|
DEFAULT_MAX_BYTES,
|
|
@@ -11,16 +12,16 @@ import {
|
|
|
11
12
|
formatSize,
|
|
12
13
|
truncateHead,
|
|
13
14
|
type TruncationResult,
|
|
14
|
-
withFileMutationQueue,
|
|
15
15
|
type ExtensionAPI,
|
|
16
16
|
} from "@earendil-works/pi-coding-agent";
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const MAX_METADATA_MATCH_BYTES = 20_000;
|
|
19
|
+
const MAX_LINE_BYTES = 1_024;
|
|
19
20
|
|
|
20
21
|
const rgTool = defineTool({
|
|
21
22
|
name: "rg",
|
|
22
23
|
label: "ripgrep",
|
|
23
|
-
description: "Exact text/regex search with ripgrep. Use for literal verification, logs, config keys, TODOs, and message lookup.
|
|
24
|
+
description: "Exact text/regex search with ripgrep. Use for literal verification, logs, config keys, TODOs, and message lookup. Text matches alone do not establish symbol identity, code structure, or behavior.",
|
|
24
25
|
parameters: Type.Object({
|
|
25
26
|
query: Type.String({ description: "Pattern" }),
|
|
26
27
|
paths: Type.Optional(Type.Array(Type.String({ description: "Path" }))),
|
|
@@ -33,72 +34,58 @@ const rgTool = defineTool({
|
|
|
33
34
|
fixedStrings: Type.Optional(Type.Boolean({ description: "Literal strings" })),
|
|
34
35
|
}),
|
|
35
36
|
|
|
36
|
-
async execute(_toolCallId, params,
|
|
37
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
37
38
|
const args = buildArgs(params);
|
|
38
39
|
const searchPaths = params.paths?.length ? params.paths : ["."];
|
|
39
40
|
const maxMatches = params.maxMatches ?? 50;
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
});
|
|
48
|
-
stdout = result.stdout;
|
|
49
|
-
} catch (error: any) {
|
|
50
|
-
stdout = error?.stdout ?? "";
|
|
51
|
-
if (error?.code !== 1) {
|
|
52
|
-
throw new Error(error?.stderr?.trim() || error?.message || "ripgrep failed");
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const parsed = parseRgJson(stdout, maxMatches);
|
|
42
|
+
const capture = await captureRg([...args, "--", params.query, ...searchPaths], ctx.cwd, signal);
|
|
43
|
+
const parsed = await parseRgJson(capture.rawPath, capture.fullPath, maxMatches);
|
|
44
|
+
const truncation = truncateHead(parsed.lines.join("\n"), {
|
|
45
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
46
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
47
|
+
});
|
|
57
48
|
const details: RgDetails = {
|
|
58
|
-
query: params.query,
|
|
59
|
-
searchedPaths: searchPaths,
|
|
49
|
+
query: shortText(params.query, 512),
|
|
50
|
+
searchedPaths: searchPaths.slice(0, 20).map(value => shortText(value, 256)),
|
|
60
51
|
matchCount: parsed.totalMatchCount,
|
|
61
|
-
truncated: parsed.
|
|
52
|
+
truncated: parsed.omittedMatches > 0 || parsed.metadataOmitted > 0 ||
|
|
53
|
+
parsed.shortenedLines > 0 || parsed.previewOverflow || truncation.truncated ||
|
|
54
|
+
parsed.invalidRecords > 0,
|
|
62
55
|
matches: parsed.matches,
|
|
56
|
+
metadataOmitted: parsed.metadataOmitted,
|
|
57
|
+
shortenedLines: parsed.shortenedLines,
|
|
58
|
+
incomplete: parsed.invalidRecords > 0,
|
|
63
59
|
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const truncation = truncateHead(fullText, {
|
|
74
|
-
maxLines: DEFAULT_MAX_LINES,
|
|
75
|
-
maxBytes: DEFAULT_MAX_BYTES,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
let text = truncation.content;
|
|
79
|
-
if (details.truncated || truncation.truncated) {
|
|
80
|
-
details.truncated = true;
|
|
81
|
-
details.truncation = truncation;
|
|
82
|
-
const tempDir = await mkdtemp(join(tmpdir(), "pi-diet-ripgrep-"));
|
|
83
|
-
const tempFile = join(tempDir, "output.txt");
|
|
84
|
-
await withFileMutationQueue(tempFile, async () => {
|
|
85
|
-
await writeFile(tempFile, fullText, "utf8");
|
|
86
|
-
});
|
|
87
|
-
details.fullOutputPath = tempFile;
|
|
88
|
-
const omittedMatches = Math.max(0, parsed.totalMatchCount - parsed.matches.length);
|
|
60
|
+
if (details.query !== params.query || searchPaths.length > 20 ||
|
|
61
|
+
searchPaths.some(value => Buffer.byteLength(value) > 256)) details.truncated = true;
|
|
62
|
+
let text = truncation.content || (parsed.invalidRecords ? "No complete matching records parsed." : "No matches found");
|
|
63
|
+
if (details.truncated) {
|
|
64
|
+
// Do not duplicate the display content inside transcript metadata.
|
|
65
|
+
const { content: _content, ...stats } = truncation;
|
|
66
|
+
details.truncation = stats;
|
|
67
|
+
details.fullOutputPath = capture.fullPath;
|
|
68
|
+
details.rawOutputPath = capture.rawPath;
|
|
89
69
|
const notices: string[] = [];
|
|
90
|
-
if (omittedMatches) notices.push(`${omittedMatches} matches omitted by maxMatches cap`);
|
|
91
|
-
if (
|
|
70
|
+
if (parsed.omittedMatches) notices.push(`${parsed.omittedMatches} matches omitted by maxMatches cap`);
|
|
71
|
+
if (parsed.metadataOmitted) notices.push(`${parsed.metadataOmitted} selected matches omitted from metadata by its byte budget`);
|
|
72
|
+
if (parsed.shortenedLines) notices.push(`${parsed.shortenedLines} long lines shortened; match windows may omit other occurrences`);
|
|
73
|
+
if (parsed.invalidRecords) notices.push(`${parsed.invalidRecords} invalid JSON records; search evidence is incomplete`);
|
|
74
|
+
if (truncation.truncated || parsed.previewOverflow) {
|
|
92
75
|
notices.push(
|
|
93
76
|
`output truncated to ${truncation.outputLines}/${truncation.totalLines} lines and ${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}`,
|
|
94
77
|
);
|
|
95
78
|
}
|
|
96
|
-
|
|
79
|
+
if (!notices.length) notices.push("query/path metadata shortened");
|
|
80
|
+
text += `\n\n[${notices.join("; ")}. Full output: ${capture.fullPath}; raw rg JSON: ${capture.rawPath}. Temporary files; not a durable archive.]`;
|
|
81
|
+
} else {
|
|
82
|
+
await rm(capture.directory, { recursive: true, force: true });
|
|
97
83
|
}
|
|
98
84
|
|
|
99
85
|
return {
|
|
100
86
|
content: [{ type: "text", text }],
|
|
101
87
|
details,
|
|
88
|
+
isError: parsed.invalidRecords > 0,
|
|
102
89
|
};
|
|
103
90
|
},
|
|
104
91
|
});
|
|
@@ -142,21 +129,94 @@ type RgDetails = {
|
|
|
142
129
|
matchCount: number;
|
|
143
130
|
truncated: boolean;
|
|
144
131
|
matches: RgMatch[];
|
|
145
|
-
truncation?: TruncationResult
|
|
132
|
+
truncation?: Omit<TruncationResult, "content">;
|
|
146
133
|
fullOutputPath?: string;
|
|
134
|
+
rawOutputPath?: string;
|
|
135
|
+
metadataOmitted?: number;
|
|
136
|
+
shortenedLines?: number;
|
|
137
|
+
incomplete?: boolean;
|
|
147
138
|
};
|
|
148
139
|
|
|
149
|
-
function
|
|
140
|
+
function shortText(value: string, maxBytes: number) {
|
|
141
|
+
const buffer = Buffer.from(value);
|
|
142
|
+
if (buffer.length <= maxBytes) return value;
|
|
143
|
+
return windowText(buffer, 0, maxBytes - 3) + "...";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function windowText(buffer: Buffer, start: number, end: number) {
|
|
147
|
+
while (start < end && (buffer[start] & 0xc0) === 0x80) start++;
|
|
148
|
+
while (end < buffer.length && end > start && (buffer[end] & 0xc0) === 0x80) end--;
|
|
149
|
+
return buffer.subarray(start, end).toString("utf8");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function linePreview(value: string, submatches: { start: number }[] = []) {
|
|
153
|
+
const buffer = Buffer.from(value);
|
|
154
|
+
if (buffer.length <= MAX_LINE_BYTES) return value;
|
|
155
|
+
if (!submatches.length) return shortText(value, MAX_LINE_BYTES);
|
|
156
|
+
return submatches.slice(0, 3).map(match => {
|
|
157
|
+
const start = Math.max(0, match.start - 120);
|
|
158
|
+
const end = Math.min(buffer.length, match.start + 200);
|
|
159
|
+
return `[match byte column ${match.start + 1}] ${start > 0 ? "..." : ""}${windowText(buffer, start, end)}${end < buffer.length ? "..." : ""}`;
|
|
160
|
+
}).join(" | ");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function captureRg(args: string[], cwd: string, signal?: AbortSignal) {
|
|
164
|
+
const directory = await mkdtemp(join(tmpdir(), "pi-diet-ripgrep-"));
|
|
165
|
+
const rawPath = join(directory, "rg.jsonl");
|
|
166
|
+
const stderrPath = join(directory, "stderr.txt");
|
|
167
|
+
const out = await open(rawPath, "w", 0o600);
|
|
168
|
+
const err = await open(stderrPath, "w", 0o600);
|
|
169
|
+
let failure: Error | undefined;
|
|
170
|
+
let code: number | null;
|
|
171
|
+
try {
|
|
172
|
+
code = await new Promise<number | null>(resolve => {
|
|
173
|
+
const child = spawn("rg", args, { cwd, signal, stdio: ["ignore", out.fd, err.fd] });
|
|
174
|
+
child.on("error", error => { failure = error; });
|
|
175
|
+
child.on("close", resolve);
|
|
176
|
+
});
|
|
177
|
+
} finally {
|
|
178
|
+
await Promise.all([out.close(), err.close()]);
|
|
179
|
+
}
|
|
180
|
+
if (failure || (code !== 0 && code !== 1)) {
|
|
181
|
+
const file = await open(stderrPath, "r");
|
|
182
|
+
let message: string;
|
|
183
|
+
try {
|
|
184
|
+
const bytes = Buffer.alloc(4_000);
|
|
185
|
+
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
|
|
186
|
+
message = bytes.subarray(0, bytesRead).toString("utf8").trim();
|
|
187
|
+
} finally { await file.close(); }
|
|
188
|
+
throw new Error(`${message || failure?.message || `ripgrep exited with code ${code}`}\nCaptured stdout: ${rawPath}; stderr: ${stderrPath}`);
|
|
189
|
+
}
|
|
190
|
+
return { directory, rawPath, fullPath: join(directory, "output.txt") };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function parseRgJson(rawPath: string, fullPath: string, maxMatches: number) {
|
|
150
194
|
const lines: string[] = [];
|
|
151
195
|
const matches: RgMatch[] = [];
|
|
152
196
|
let totalMatchCount = 0;
|
|
153
|
-
|
|
154
|
-
|
|
197
|
+
let metadataBytes = 0, metadataOmitted = 0, shortenedLines = 0, invalidRecords = 0;
|
|
198
|
+
let previewBytes = 0, previewOverflow = false, first = true;
|
|
199
|
+
const output = await open(fullPath, "w", 0o600);
|
|
200
|
+
const input = createReadStream(rawPath);
|
|
201
|
+
const records = createInterface({ input, crlfDelay: Infinity });
|
|
202
|
+
const addPreview = (line: string) => {
|
|
203
|
+
if (previewOverflow) return;
|
|
204
|
+
lines.push(line);
|
|
205
|
+
previewBytes += Buffer.byteLength(line) + 1;
|
|
206
|
+
if (lines.length > DEFAULT_MAX_LINES || previewBytes > DEFAULT_MAX_BYTES) previewOverflow = true;
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
for await (const rawLine of records) {
|
|
155
210
|
if (!rawLine.trim()) continue;
|
|
156
211
|
let record: any;
|
|
157
212
|
try {
|
|
158
213
|
record = JSON.parse(rawLine);
|
|
159
214
|
} catch {
|
|
215
|
+
invalidRecords++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!record || typeof record !== "object") {
|
|
219
|
+
invalidRecords++;
|
|
160
220
|
continue;
|
|
161
221
|
}
|
|
162
222
|
|
|
@@ -166,20 +226,41 @@ function parseRgJson(stdout: string, maxMatches: number) {
|
|
|
166
226
|
const line = record.data?.line_number ?? 0;
|
|
167
227
|
const text = String(record.data?.lines?.text ?? "").replace(/\n$/, "");
|
|
168
228
|
const column = (record.data?.submatches?.[0]?.start ?? 0) + 1;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
229
|
+
await output.writeFile(`${first ? "" : "\n"}${path}:${line}:${column}:${text}`);
|
|
230
|
+
first = false;
|
|
231
|
+
if (totalMatchCount <= maxMatches) {
|
|
232
|
+
const preview = linePreview(text, record.data?.submatches);
|
|
233
|
+
if (preview !== text || Buffer.byteLength(path) > 512) shortenedLines++;
|
|
234
|
+
const match = { path: shortText(path, 512), line, column, text: preview };
|
|
235
|
+
const bytes = Buffer.byteLength(JSON.stringify(match));
|
|
236
|
+
if (metadataBytes + bytes <= MAX_METADATA_MATCH_BYTES) {
|
|
237
|
+
matches.push(match);
|
|
238
|
+
metadataBytes += bytes;
|
|
239
|
+
} else metadataOmitted++;
|
|
240
|
+
addPreview(`${shortText(path, 512)}:${line}:${column}:${preview}`);
|
|
172
241
|
}
|
|
173
242
|
continue;
|
|
174
243
|
}
|
|
175
244
|
|
|
176
|
-
if (record.type === "context"
|
|
245
|
+
if (record.type === "context") {
|
|
177
246
|
const path = record.data?.path?.text ?? "<unknown>";
|
|
178
247
|
const line = record.data?.line_number ?? 0;
|
|
179
248
|
const text = String(record.data?.lines?.text ?? "").replace(/\n$/, "");
|
|
180
|
-
|
|
249
|
+
const formatted = `${path}-${line}- ${text}`;
|
|
250
|
+
await output.writeFile(`${first ? "" : "\n"}${formatted}`);
|
|
251
|
+
first = false;
|
|
252
|
+
if (totalMatchCount <= maxMatches) {
|
|
253
|
+
const preview = linePreview(text);
|
|
254
|
+
if (preview !== text || Buffer.byteLength(path) > 512) shortenedLines++;
|
|
255
|
+
addPreview(`${shortText(path, 512)}-${line}- ${preview}`);
|
|
256
|
+
}
|
|
181
257
|
}
|
|
182
258
|
}
|
|
183
|
-
|
|
184
|
-
|
|
259
|
+
} finally {
|
|
260
|
+
records.close();
|
|
261
|
+
input.destroy();
|
|
262
|
+
await output.close();
|
|
263
|
+
}
|
|
264
|
+
return { lines, matches, totalMatchCount, metadataOmitted, shortenedLines, invalidRecords,
|
|
265
|
+
previewOverflow, omittedMatches: Math.max(0, totalMatchCount - maxMatches) };
|
|
185
266
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-diet-ripgrep",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Minimal Pi extension exposing ripgrep as a first-class exact-search tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"lint": "tsc --noEmit",
|
|
9
|
+
"build": "tsc --project tsconfig.build.json",
|
|
10
|
+
"test": "tsx --test tests/*.test.ts"
|
|
11
|
+
},
|
|
7
12
|
"files": [
|
|
8
13
|
"index.ts",
|
|
9
14
|
"README.md",
|
|
@@ -38,5 +43,12 @@
|
|
|
38
43
|
"extensions": [
|
|
39
44
|
"./index.ts"
|
|
40
45
|
]
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-ai": "^1.4.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
50
|
+
"@types/node": "^26.6.5",
|
|
51
|
+
"tsx": "^4.23.15",
|
|
52
|
+
"typescript": "^7.0.2"
|
|
41
53
|
}
|
|
42
54
|
}
|
package/spec.md
CHANGED
|
@@ -8,11 +8,7 @@ The extension is intentionally "diet": it should add as little system-prompt ove
|
|
|
8
8
|
|
|
9
9
|
## Problem
|
|
10
10
|
|
|
11
|
-
Pi
|
|
12
|
-
- LSP for symbol-aware navigation
|
|
13
|
-
- AST tools for structural search
|
|
14
|
-
- Semble for semantic discovery
|
|
15
|
-
- `bash` for ad hoc `rg`
|
|
11
|
+
Pi supports `bash` for ad hoc `rg`, but additional code-intelligence extensions are optional and must not be assumed to be installed.
|
|
16
12
|
|
|
17
13
|
What is missing is a compact, structured, exact-text search tool that:
|
|
18
14
|
- is cheaper and safer than freeform shell use
|
|
@@ -26,11 +22,11 @@ What is missing is a compact, structured, exact-text search tool that:
|
|
|
26
22
|
2. Keep added prompt footprint to roughly 100-200 tokens, ideally under 200.
|
|
27
23
|
3. Return concise structured output suitable for agent use.
|
|
28
24
|
4. Bound output size and support truncation safely.
|
|
29
|
-
5.
|
|
25
|
+
5. Keep guidance self-contained: describe this tool's purpose and limits without recommending other extensions.
|
|
30
26
|
|
|
31
27
|
## Non-goals
|
|
32
28
|
|
|
33
|
-
-
|
|
29
|
+
- Providing symbol-aware navigation, structural matching, or semantic discovery.
|
|
34
30
|
- Becoming a full grep UI.
|
|
35
31
|
- Supporting every ripgrep flag.
|
|
36
32
|
- Adding large prompt instructions, examples, or verbose routing prose.
|
|
@@ -41,7 +37,7 @@ Use `rg` when the task needs:
|
|
|
41
37
|
- exact literals
|
|
42
38
|
- regex search
|
|
43
39
|
- finding strings, log messages, config keys, TODOs, identifiers by text
|
|
44
|
-
- quick scoped verification
|
|
40
|
+
- quick scoped verification of candidate source locations
|
|
45
41
|
|
|
46
42
|
Do not prefer `rg` for:
|
|
47
43
|
- definitions/references/types
|
|
@@ -166,7 +162,7 @@ That usage is common enough to justify a small first-class tool, but not a verbo
|
|
|
166
162
|
Build the richer-but-still-diet version:
|
|
167
163
|
- `rg` tool only
|
|
168
164
|
- compact schema with `hidden` and `fixedStrings`
|
|
169
|
-
-
|
|
165
|
+
- self-contained description covering exact-search use cases and the limits of text matches
|
|
170
166
|
- `rg --json`
|
|
171
167
|
- structured matches
|
|
172
168
|
- small default result cap
|