@levnikolaevich/hex-line-mcp 1.3.2 → 1.3.4
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 +62 -7
- package/dist/hook.mjs +428 -0
- package/dist/server.mjs +6615 -0
- package/output-style.md +16 -2
- package/package.json +16 -12
- package/benchmark/atomic.mjs +0 -502
- package/benchmark/graph.mjs +0 -80
- package/benchmark/index.mjs +0 -144
- package/benchmark/workflows.mjs +0 -259
- package/hook.mjs +0 -466
- package/lib/benchmark-helpers.mjs +0 -541
- package/lib/bulk-replace.mjs +0 -65
- package/lib/changes.mjs +0 -176
- package/lib/coerce.mjs +0 -2
- package/lib/edit.mjs +0 -400
- package/lib/format.mjs +0 -138
- package/lib/graph-enrich.mjs +0 -226
- package/lib/hash.mjs +0 -109
- package/lib/info.mjs +0 -91
- package/lib/normalize.mjs +0 -106
- package/lib/outline.mjs +0 -201
- package/lib/read.mjs +0 -136
- package/lib/search.mjs +0 -269
- package/lib/security.mjs +0 -112
- package/lib/setup.mjs +0 -275
- package/lib/tree.mjs +0 -236
- package/lib/update-check.mjs +0 -56
- package/lib/verify.mjs +0 -55
- package/server.mjs +0 -381
package/lib/outline.mjs
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AST-based file outline via tree-sitter WASM.
|
|
3
|
-
*
|
|
4
|
-
* Returns structural overview: functions, classes, interfaces with line ranges.
|
|
5
|
-
* 10-20 lines instead of 500 → 95% token reduction.
|
|
6
|
-
* Output maps directly to read_file ranges.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { readFileSync } from "node:fs";
|
|
10
|
-
import { resolve, extname } from "node:path";
|
|
11
|
-
import { validatePath, normalizePath } from "./security.mjs";
|
|
12
|
-
import { getGraphDB, symbolAnnotation, getRelativePath } from "./graph-enrich.mjs";
|
|
13
|
-
|
|
14
|
-
// Language configs: extension → { grammar, outline, skip, recurse }
|
|
15
|
-
const LANG_CONFIGS = {
|
|
16
|
-
".js": { grammar: "javascript", outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
17
|
-
".mjs": { grammar: "javascript", outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
18
|
-
".jsx": { grammar: "javascript", outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
19
|
-
".ts": { grammar: "typescript", outline: ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
20
|
-
".tsx": { grammar: "tsx", outline: ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
21
|
-
".py": { grammar: "python", outline: ["function_definition", "class_definition", "decorated_definition"], skip: ["import_statement", "import_from_statement"], recurse: ["class_body", "block"] },
|
|
22
|
-
".go": { grammar: "go", outline: ["function_declaration", "method_declaration", "type_declaration"], skip: ["import_declaration"], recurse: [] },
|
|
23
|
-
".rs": { grammar: "rust", outline: ["function_item", "struct_item", "enum_item", "impl_item", "trait_item", "const_item", "static_item"], skip: ["use_declaration"], recurse: ["impl_item"] },
|
|
24
|
-
".java": { grammar: "java", outline: ["class_declaration", "interface_declaration", "method_declaration", "enum_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
|
|
25
|
-
".c": { grammar: "c", outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
|
|
26
|
-
".h": { grammar: "c", outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
|
|
27
|
-
".cpp": { grammar: "cpp", outline: ["function_definition", "class_specifier", "struct_specifier", "namespace_definition"], skip: ["preproc_include"], recurse: ["class_specifier"] },
|
|
28
|
-
".cs": { grammar: "c_sharp", outline: ["class_declaration", "interface_declaration", "method_declaration", "namespace_declaration"], skip: ["using_directive"], recurse: ["class_body"] },
|
|
29
|
-
".rb": { grammar: "ruby", outline: ["method", "class", "module"], skip: ["require", "require_relative"], recurse: ["class", "module"] },
|
|
30
|
-
".php": { grammar: "php", outline: ["function_definition", "class_declaration", "method_declaration"], skip: ["namespace_use_declaration"], recurse: ["class_body"] },
|
|
31
|
-
".kt": { grammar: "kotlin", outline: ["function_declaration", "class_declaration", "object_declaration"], skip: ["import_header"], recurse: ["class_body"] },
|
|
32
|
-
".swift": { grammar: "swift", outline: ["function_declaration", "class_declaration", "struct_declaration", "protocol_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
|
|
33
|
-
".sh": { grammar: "bash", outline: ["function_definition"], skip: [], recurse: [] },
|
|
34
|
-
".bash": { grammar: "bash", outline: ["function_definition"], skip: [], recurse: [] },
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
// Parser cache (init once)
|
|
38
|
-
let _parser = null;
|
|
39
|
-
const _langCache = new Map();
|
|
40
|
-
|
|
41
|
-
async function getParser() {
|
|
42
|
-
if (_parser) return _parser;
|
|
43
|
-
try {
|
|
44
|
-
const { Parser } = await import("web-tree-sitter");
|
|
45
|
-
await Parser.init();
|
|
46
|
-
_parser = new Parser();
|
|
47
|
-
return _parser;
|
|
48
|
-
} catch (e) {
|
|
49
|
-
throw new Error(`tree-sitter init failed: ${e.message}. Run: cd mcp/hex-line-mcp && npm install`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function getLanguage(grammar) {
|
|
54
|
-
if (_langCache.has(grammar)) return _langCache.get(grammar);
|
|
55
|
-
await getParser(); // ensure init
|
|
56
|
-
try {
|
|
57
|
-
const { Language } = await import("web-tree-sitter");
|
|
58
|
-
const { createRequire } = await import("node:module");
|
|
59
|
-
const require = createRequire(import.meta.url);
|
|
60
|
-
// Absolute path for Windows compatibility (Gemini finding #3)
|
|
61
|
-
const wasmPath = resolve(require.resolve("tree-sitter-wasms/package.json"), "..", "out", `tree-sitter-${grammar}.wasm`);
|
|
62
|
-
const lang = await Language.load(wasmPath);
|
|
63
|
-
_langCache.set(grammar, lang);
|
|
64
|
-
return lang;
|
|
65
|
-
} catch (e) {
|
|
66
|
-
throw new Error(`Language "${grammar}" not available: ${e.message}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Extract structural outline entries from AST.
|
|
72
|
-
*/
|
|
73
|
-
function extractOutline(rootNode, config, sourceLines) {
|
|
74
|
-
const entries = [];
|
|
75
|
-
const skipTypes = new Set(config.skip);
|
|
76
|
-
const outlineTypes = new Set(config.outline);
|
|
77
|
-
const recurseTypes = new Set(config.recurse);
|
|
78
|
-
|
|
79
|
-
function walk(node, depth) {
|
|
80
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
81
|
-
const child = node.child(i);
|
|
82
|
-
const type = child.type;
|
|
83
|
-
const startLine = child.startPosition.row + 1;
|
|
84
|
-
const endLine = child.endPosition.row + 1;
|
|
85
|
-
|
|
86
|
-
if (skipTypes.has(type)) continue;
|
|
87
|
-
|
|
88
|
-
if (outlineTypes.has(type)) {
|
|
89
|
-
const firstLine = sourceLines[startLine - 1] || "";
|
|
90
|
-
// Extract symbol name for graph annotation
|
|
91
|
-
const nameMatch = firstLine.match(/(?:function|class|interface|type|enum|struct|def|fn|pub\s+fn)\s+(\w+)|(?:const|let|var|export\s+(?:const|let|var|function|class))\s+(\w+)/);
|
|
92
|
-
const name = nameMatch ? (nameMatch[1] || nameMatch[2]) : null;
|
|
93
|
-
|
|
94
|
-
entries.push({
|
|
95
|
-
start: startLine,
|
|
96
|
-
end: endLine,
|
|
97
|
-
depth,
|
|
98
|
-
text: firstLine.trim().slice(0, 120),
|
|
99
|
-
name,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// Recurse into class/struct bodies
|
|
103
|
-
for (let j = 0; j < child.childCount; j++) {
|
|
104
|
-
const sub = child.child(j);
|
|
105
|
-
if (recurseTypes.has(sub.type)) {
|
|
106
|
-
walk(sub, depth + 1);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Collect skipped ranges for summary
|
|
114
|
-
const skippedRanges = [];
|
|
115
|
-
for (let i = 0; i < rootNode.childCount; i++) {
|
|
116
|
-
const child = rootNode.child(i);
|
|
117
|
-
if (skipTypes.has(child.type)) {
|
|
118
|
-
skippedRanges.push({
|
|
119
|
-
start: child.startPosition.row + 1,
|
|
120
|
-
end: child.endPosition.row + 1,
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
walk(rootNode, 0);
|
|
126
|
-
return { entries, skippedRanges };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Parse content string into outline entries.
|
|
131
|
-
* Reusable core — no file I/O, no validatePath.
|
|
132
|
-
*
|
|
133
|
-
* @param {string} content Source text (LF-normalized)
|
|
134
|
-
* @param {string} ext Lowercase extension including dot (e.g. ".mjs")
|
|
135
|
-
* @returns {Promise<{entries: Array, skippedRanges: Array} | null>} null if unsupported ext
|
|
136
|
-
*/
|
|
137
|
-
export async function outlineFromContent(content, ext) {
|
|
138
|
-
const config = LANG_CONFIGS[ext];
|
|
139
|
-
if (!config) return null;
|
|
140
|
-
|
|
141
|
-
const sourceLines = content.split("\n");
|
|
142
|
-
|
|
143
|
-
let lang;
|
|
144
|
-
try {
|
|
145
|
-
lang = await getLanguage(config.grammar);
|
|
146
|
-
} catch (e) {
|
|
147
|
-
throw new Error(`Outline error: ${e.message}`);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const parser = await getParser();
|
|
151
|
-
parser.setLanguage(lang);
|
|
152
|
-
const tree = parser.parse(content);
|
|
153
|
-
return extractOutline(tree.rootNode, config, sourceLines);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Format outline entries into display string.
|
|
158
|
-
*/
|
|
159
|
-
function formatOutline(entries, skippedRanges, sourceLineCount, db, relFile) {
|
|
160
|
-
const lines = [];
|
|
161
|
-
|
|
162
|
-
if (skippedRanges.length > 0) {
|
|
163
|
-
const first = skippedRanges[0].start;
|
|
164
|
-
const last = skippedRanges[skippedRanges.length - 1].end;
|
|
165
|
-
const count = skippedRanges.reduce((sum, r) => sum + (r.end - r.start + 1), 0);
|
|
166
|
-
lines.push(`${first}-${last}: (${count} imports/declarations)`);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
for (const e of entries) {
|
|
170
|
-
const indent = " ".repeat(e.depth);
|
|
171
|
-
const anno = db ? symbolAnnotation(db, relFile, e.name) : null;
|
|
172
|
-
const suffix = anno ? ` ${anno}` : "";
|
|
173
|
-
lines.push(`${indent}${e.start}-${e.end}: ${e.text}${suffix}`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
lines.push("");
|
|
177
|
-
lines.push(`(${entries.length} symbols, ${sourceLineCount} source lines)`);
|
|
178
|
-
return lines.join("\n");
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Generate file outline.
|
|
183
|
-
*
|
|
184
|
-
* @param {string} filePath
|
|
185
|
-
* @returns {Promise<string>} formatted outline
|
|
186
|
-
*/
|
|
187
|
-
export async function fileOutline(filePath) {
|
|
188
|
-
filePath = normalizePath(filePath);
|
|
189
|
-
const real = validatePath(filePath);
|
|
190
|
-
const ext = extname(real).toLowerCase();
|
|
191
|
-
|
|
192
|
-
if (!LANG_CONFIGS[ext]) {
|
|
193
|
-
return `Outline unavailable for ${ext} files. Use read_file directly for non-code files (markdown, config, text). Supported code extensions: ${Object.keys(LANG_CONFIGS).join(", ")}`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const content = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
|
|
197
|
-
const result = await outlineFromContent(content, ext);
|
|
198
|
-
const db = getGraphDB(real);
|
|
199
|
-
const relFile = db ? getRelativePath(real) : null;
|
|
200
|
-
return `File: ${filePath}\n\n${formatOutline(result.entries, result.skippedRanges, content.split("\n").length, db, relFile)}`;
|
|
201
|
-
}
|
package/lib/read.mjs
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File read with FNV-1a hash annotations and range checksums.
|
|
3
|
-
*
|
|
4
|
-
* Output format: {tag}.{lineNum}\t{content}
|
|
5
|
-
* Appends: checksum: {start}-{end}:{8hex}
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { statSync } from "node:fs";
|
|
9
|
-
import { fnv1a, lineTag, rangeChecksum } from "./hash.mjs";
|
|
10
|
-
import { validatePath, normalizePath } from "./security.mjs";
|
|
11
|
-
import { getGraphDB, fileAnnotations, getRelativePath } from "./graph-enrich.mjs";
|
|
12
|
-
import { relativeTime, listDirectory, readText, MAX_OUTPUT_CHARS } from "./format.mjs";
|
|
13
|
-
|
|
14
|
-
const DEFAULT_LIMIT = 2000;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Read a file with hash-annotated lines.
|
|
18
|
-
*
|
|
19
|
-
* @param {string} filePath
|
|
20
|
-
* @param {object} opts - { offset, limit, plain, ranges }
|
|
21
|
-
* @returns {string} formatted output
|
|
22
|
-
*/
|
|
23
|
-
export function readFile(filePath, opts = {}) {
|
|
24
|
-
filePath = normalizePath(filePath);
|
|
25
|
-
const real = validatePath(filePath);
|
|
26
|
-
const stat = statSync(real);
|
|
27
|
-
|
|
28
|
-
// Directory listing fallback
|
|
29
|
-
if (stat.isDirectory()) {
|
|
30
|
-
const { text } = listDirectory(real, { metadata: true });
|
|
31
|
-
return `Directory: ${filePath}\n\n\`\`\`\n${text}\n\`\`\``;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const content = readText(real);
|
|
35
|
-
const lines = content.split("\n");
|
|
36
|
-
const total = lines.length;
|
|
37
|
-
|
|
38
|
-
// Determine ranges to read
|
|
39
|
-
let ranges;
|
|
40
|
-
if (opts.ranges && opts.ranges.length > 0) {
|
|
41
|
-
ranges = opts.ranges.map((r) => ({
|
|
42
|
-
start: Math.max(1, r.start || 1),
|
|
43
|
-
end: Math.min(total, r.end || total),
|
|
44
|
-
}));
|
|
45
|
-
} else {
|
|
46
|
-
const startLine = Math.max(1, opts.offset || 1);
|
|
47
|
-
const maxLines = (opts.limit && opts.limit > 0) ? opts.limit : DEFAULT_LIMIT;
|
|
48
|
-
ranges = [{ start: startLine, end: Math.min(total, startLine - 1 + maxLines) }];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const parts = [];
|
|
52
|
-
|
|
53
|
-
let cappedAtLine = 0;
|
|
54
|
-
|
|
55
|
-
for (const range of ranges) {
|
|
56
|
-
const selected = lines.slice(range.start - 1, range.end);
|
|
57
|
-
const lineHashes = [];
|
|
58
|
-
const formatted = [];
|
|
59
|
-
let charCount = 0;
|
|
60
|
-
|
|
61
|
-
for (let i = 0; i < selected.length; i++) {
|
|
62
|
-
const line = selected[i];
|
|
63
|
-
const num = range.start + i;
|
|
64
|
-
const hash32 = fnv1a(line);
|
|
65
|
-
const entry = opts.plain
|
|
66
|
-
? `${num}|${line}`
|
|
67
|
-
: `${lineTag(hash32)}.${num}\t${line}`;
|
|
68
|
-
|
|
69
|
-
if (charCount + entry.length > MAX_OUTPUT_CHARS && formatted.length > 0) {
|
|
70
|
-
cappedAtLine = num;
|
|
71
|
-
break;
|
|
72
|
-
}
|
|
73
|
-
lineHashes.push(hash32);
|
|
74
|
-
formatted.push(entry);
|
|
75
|
-
charCount += entry.length + 1;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Update range end to actual lines shown
|
|
79
|
-
const actualEnd = formatted.length > 0
|
|
80
|
-
? range.start + formatted.length - 1
|
|
81
|
-
: range.start;
|
|
82
|
-
range.end = actualEnd;
|
|
83
|
-
|
|
84
|
-
parts.push(formatted.join("\n"));
|
|
85
|
-
|
|
86
|
-
// Range checksum (only for lines actually shown)
|
|
87
|
-
const cs = rangeChecksum(lineHashes, range.start, actualEnd);
|
|
88
|
-
parts.push(`\nchecksum: ${cs}`);
|
|
89
|
-
|
|
90
|
-
if (cappedAtLine) break;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Header
|
|
94
|
-
const sizeKB = (stat.size / 1024).toFixed(1);
|
|
95
|
-
const mtime = stat.mtime;
|
|
96
|
-
const ago = relativeTime(mtime);
|
|
97
|
-
let header = `File: ${filePath} (${total} lines, ${sizeKB}KB, ${ago})`;
|
|
98
|
-
if (ranges.length === 1) {
|
|
99
|
-
const r = ranges[0];
|
|
100
|
-
if (r.start > 1 || r.end < total) {
|
|
101
|
-
header += ` [showing ${r.start}-${r.end}]`;
|
|
102
|
-
}
|
|
103
|
-
if (r.end < total) {
|
|
104
|
-
header += ` (${total - r.end} more below)`;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Graph enrichment (optional — silent if no DB)
|
|
109
|
-
const db = getGraphDB(real);
|
|
110
|
-
const relFile = db ? getRelativePath(real) : null;
|
|
111
|
-
let graphLine = "";
|
|
112
|
-
if (db && relFile) {
|
|
113
|
-
const annos = fileAnnotations(db, relFile);
|
|
114
|
-
if (annos.length > 0) {
|
|
115
|
-
const items = annos.map(a => {
|
|
116
|
-
const counts = (a.callees || a.callers) ? ` ${a.callees}\u2193 ${a.callers}\u2191` : "";
|
|
117
|
-
return `${a.name} [${a.kind}${counts}]`;
|
|
118
|
-
});
|
|
119
|
-
graphLine = `\nGraph: ${items.join(" | ")}`;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
let result = `${header}${graphLine}\n\n\`\`\`\n${parts.join("\n")}\n\`\`\``;
|
|
124
|
-
|
|
125
|
-
// Auto-hint for large files read from start without offset
|
|
126
|
-
if (total > 200 && (!opts.offset || opts.offset <= 1) && !cappedAtLine) {
|
|
127
|
-
result += `\n\n\u26A1 Tip: This file has ${total} lines. Use outline first, then read_file with offset/limit for 75% fewer tokens.`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Character cap notice
|
|
131
|
-
if (cappedAtLine) {
|
|
132
|
-
result += `\n\nOUTPUT_CAPPED at line ${cappedAtLine} (${MAX_OUTPUT_CHARS} char limit). Use offset=${cappedAtLine} to continue reading.`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return result;
|
|
136
|
-
}
|
package/lib/search.mjs
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File search via ripgrep with hash-annotated results.
|
|
3
|
-
* Uses spawn with arg arrays (no shell string interpolation).
|
|
4
|
-
*
|
|
5
|
-
* Output modes:
|
|
6
|
-
* content (default) — hash-annotated lines with per-group checksums (uses rg --json)
|
|
7
|
-
* files — file paths only (rg -l)
|
|
8
|
-
* count — match counts per file (rg -c)
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
|
-
import { resolve } from "node:path";
|
|
13
|
-
import { fnv1a, lineTag, rangeChecksum } from "./hash.mjs";
|
|
14
|
-
import { getGraphDB, matchAnnotation, getRelativePath } from "./graph-enrich.mjs";
|
|
15
|
-
import { normalizePath } from "./security.mjs";
|
|
16
|
-
|
|
17
|
-
const DEFAULT_LIMIT = 100;
|
|
18
|
-
const MAX_OUTPUT = 10 * 1024 * 1024; // 10 MB
|
|
19
|
-
const TIMEOUT = 30000; // 30s
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Spawn ripgrep and collect stdout.
|
|
25
|
-
* Returns { stdout, code, stderr, killed }.
|
|
26
|
-
*/
|
|
27
|
-
function spawnRg(args) {
|
|
28
|
-
return new Promise((resolve_, reject) => {
|
|
29
|
-
let stdout = "";
|
|
30
|
-
let totalBytes = 0;
|
|
31
|
-
let killed = false;
|
|
32
|
-
let stderrBuf = "";
|
|
33
|
-
|
|
34
|
-
const child = spawn("rg", args, { timeout: TIMEOUT });
|
|
35
|
-
|
|
36
|
-
child.stdout.on("data", (chunk) => {
|
|
37
|
-
totalBytes += chunk.length;
|
|
38
|
-
if (totalBytes > MAX_OUTPUT) {
|
|
39
|
-
killed = true;
|
|
40
|
-
child.kill();
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
stdout += chunk.toString("utf-8");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
child.stderr.on("data", (chunk) => { stderrBuf += chunk.toString("utf-8"); });
|
|
47
|
-
|
|
48
|
-
child.on("error", (err) => {
|
|
49
|
-
if (err.code === "ENOENT") {
|
|
50
|
-
reject(new Error("ripgrep (rg) not found. Install: https://github.com/BurntSushi/ripgrep#installation"));
|
|
51
|
-
} else {
|
|
52
|
-
reject(new Error(`rg spawn error: ${err.message}`));
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
child.on("close", (code) => {
|
|
57
|
-
resolve_({ stdout, code, stderr: stderrBuf, killed });
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Search files using ripgrep.
|
|
64
|
-
*
|
|
65
|
-
* @param {string} pattern - regex or literal pattern
|
|
66
|
-
* @param {object} opts
|
|
67
|
-
* @returns {Promise<string>} formatted results
|
|
68
|
-
*/
|
|
69
|
-
export function grepSearch(pattern, opts = {}) {
|
|
70
|
-
const normPath = normalizePath(opts.path || "");
|
|
71
|
-
const target = normPath ? resolve(normPath) : process.cwd();
|
|
72
|
-
const output = opts.output || "content";
|
|
73
|
-
const plain = !!opts.plain;
|
|
74
|
-
const totalLimit = (opts.totalLimit && opts.totalLimit > 0) ? opts.totalLimit : 0;
|
|
75
|
-
|
|
76
|
-
// Branch by output mode
|
|
77
|
-
if (output === "files") return filesMode(pattern, target, opts);
|
|
78
|
-
if (output === "count") return countMode(pattern, target, opts);
|
|
79
|
-
return contentMode(pattern, target, opts, plain, totalLimit);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* files mode: rg -l — just file paths.
|
|
84
|
-
*/
|
|
85
|
-
async function filesMode(pattern, target, opts) {
|
|
86
|
-
// -l + shared flags (without -n/heading/-m since -l ignores them)
|
|
87
|
-
const realArgs = ["-l"];
|
|
88
|
-
if (opts.caseInsensitive) realArgs.push("-i");
|
|
89
|
-
else if (opts.smartCase) realArgs.push("-S");
|
|
90
|
-
if (opts.literal) realArgs.push("-F");
|
|
91
|
-
if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
|
|
92
|
-
if (opts.glob) realArgs.push("--glob", opts.glob);
|
|
93
|
-
if (opts.type) realArgs.push("--type", opts.type);
|
|
94
|
-
realArgs.push("--", pattern, target);
|
|
95
|
-
|
|
96
|
-
const { stdout, code, stderr, killed } = await spawnRg(realArgs);
|
|
97
|
-
if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
|
|
98
|
-
if (code === 1) return "No matches found.";
|
|
99
|
-
if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} — ${stderr.trim() || "unknown error"}`);
|
|
100
|
-
|
|
101
|
-
const lines = stdout.trimEnd().split("\n").filter(Boolean);
|
|
102
|
-
const normalized = lines.map(l => l.replace(/\\/g, "/"));
|
|
103
|
-
return `\`\`\`\n${normalized.join("\n")}\n\`\`\``;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* count mode: rg -c — match counts per file.
|
|
108
|
-
*/
|
|
109
|
-
async function countMode(pattern, target, opts) {
|
|
110
|
-
const realArgs = ["-c"];
|
|
111
|
-
if (opts.caseInsensitive) realArgs.push("-i");
|
|
112
|
-
else if (opts.smartCase) realArgs.push("-S");
|
|
113
|
-
if (opts.literal) realArgs.push("-F");
|
|
114
|
-
if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
|
|
115
|
-
if (opts.glob) realArgs.push("--glob", opts.glob);
|
|
116
|
-
if (opts.type) realArgs.push("--type", opts.type);
|
|
117
|
-
realArgs.push("--", pattern, target);
|
|
118
|
-
|
|
119
|
-
const { stdout, code, stderr, killed } = await spawnRg(realArgs);
|
|
120
|
-
if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
|
|
121
|
-
if (code === 1) return "No matches found.";
|
|
122
|
-
if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} — ${stderr.trim() || "unknown error"}`);
|
|
123
|
-
|
|
124
|
-
const lines = stdout.trimEnd().split("\n").filter(Boolean);
|
|
125
|
-
const normalized = lines.map(l => l.replace(/\\/g, "/"));
|
|
126
|
-
return `\`\`\`\n${normalized.join("\n")}\n\`\`\``;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* content mode: rg --json — hash-annotated lines with per-group checksums.
|
|
131
|
-
*/
|
|
132
|
-
async function contentMode(pattern, target, opts, plain, totalLimit) {
|
|
133
|
-
const realArgs = ["--json"];
|
|
134
|
-
if (opts.caseInsensitive) realArgs.push("-i");
|
|
135
|
-
else if (opts.smartCase) realArgs.push("-S");
|
|
136
|
-
if (opts.literal) realArgs.push("-F");
|
|
137
|
-
if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
|
|
138
|
-
if (opts.glob) realArgs.push("--glob", opts.glob);
|
|
139
|
-
if (opts.type) realArgs.push("--type", opts.type);
|
|
140
|
-
if (opts.context && opts.context > 0) realArgs.push("-C", String(opts.context));
|
|
141
|
-
if (opts.contextBefore && opts.contextBefore > 0) realArgs.push("-B", String(opts.contextBefore));
|
|
142
|
-
if (opts.contextAfter && opts.contextAfter > 0) realArgs.push("-A", String(opts.contextAfter));
|
|
143
|
-
|
|
144
|
-
const limit = (opts.limit && opts.limit > 0) ? opts.limit : DEFAULT_LIMIT;
|
|
145
|
-
realArgs.push("-m", String(limit));
|
|
146
|
-
realArgs.push("--", pattern, target);
|
|
147
|
-
|
|
148
|
-
const { stdout, code, stderr, killed } = await spawnRg(realArgs);
|
|
149
|
-
if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
|
|
150
|
-
if (code === 1) return "No matches found.";
|
|
151
|
-
if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} — ${stderr.trim() || "unknown error"}`);
|
|
152
|
-
|
|
153
|
-
// Parse NDJSON output
|
|
154
|
-
const jsonLines = stdout.trimEnd().split("\n").filter(Boolean);
|
|
155
|
-
const formatted = [];
|
|
156
|
-
const db = getGraphDB(target);
|
|
157
|
-
const relCache = new Map();
|
|
158
|
-
|
|
159
|
-
// Track current group for checksums
|
|
160
|
-
let groupFile = null;
|
|
161
|
-
let groupLines = []; // { lineNum, hash32 }
|
|
162
|
-
let matchCount = 0;
|
|
163
|
-
|
|
164
|
-
function flushGroup() {
|
|
165
|
-
if (groupLines.length === 0) return;
|
|
166
|
-
const sorted = [...groupLines].sort((a, b) => a.lineNum - b.lineNum);
|
|
167
|
-
const start = sorted[0].lineNum;
|
|
168
|
-
const end = sorted[sorted.length - 1].lineNum;
|
|
169
|
-
const hashes = sorted.map(l => l.hash32);
|
|
170
|
-
const cs = rangeChecksum(hashes, start, end);
|
|
171
|
-
formatted.push(`checksum: ${cs}`);
|
|
172
|
-
groupLines = [];
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
for (const jl of jsonLines) {
|
|
176
|
-
let msg;
|
|
177
|
-
try { msg = JSON.parse(jl); } catch { continue; }
|
|
178
|
-
|
|
179
|
-
if (msg.type === "begin" || msg.type === "end" || msg.type === "summary") {
|
|
180
|
-
if (msg.type === "end") {
|
|
181
|
-
flushGroup();
|
|
182
|
-
groupFile = null;
|
|
183
|
-
}
|
|
184
|
-
if (msg.type === "begin") {
|
|
185
|
-
// Separator between file groups
|
|
186
|
-
if (formatted.length > 0 && formatted[formatted.length - 1] !== "") {
|
|
187
|
-
formatted.push("");
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (msg.type !== "match" && msg.type !== "context") continue;
|
|
194
|
-
|
|
195
|
-
const data = msg.data;
|
|
196
|
-
const filePath = (data.path?.text || "").replace(/\\/g, "/");
|
|
197
|
-
const lineNum = data.line_number;
|
|
198
|
-
if (!lineNum) continue;
|
|
199
|
-
|
|
200
|
-
// Get line content (handle text vs bytes)
|
|
201
|
-
let content = data.lines?.text;
|
|
202
|
-
if (content === undefined && data.lines?.bytes) {
|
|
203
|
-
content = Buffer.from(data.lines.bytes, "base64").toString("utf-8");
|
|
204
|
-
}
|
|
205
|
-
if (content === undefined) continue;
|
|
206
|
-
|
|
207
|
-
// Trim trailing newline from rg JSON output
|
|
208
|
-
content = content.replace(/\n$/, "");
|
|
209
|
-
|
|
210
|
-
// Handle multiline: split into individual lines
|
|
211
|
-
const subLines = content.split("\n");
|
|
212
|
-
|
|
213
|
-
// Track group boundaries
|
|
214
|
-
if (filePath !== groupFile) {
|
|
215
|
-
flushGroup();
|
|
216
|
-
groupFile = filePath;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
for (let i = 0; i < subLines.length; i++) {
|
|
220
|
-
const ln = lineNum + i;
|
|
221
|
-
const lineContent = subLines[i];
|
|
222
|
-
const hash32 = fnv1a(lineContent);
|
|
223
|
-
const tag = lineTag(hash32);
|
|
224
|
-
|
|
225
|
-
// Flush on line gap (disjoint match clusters get separate checksums)
|
|
226
|
-
if (groupLines.length > 0) {
|
|
227
|
-
const lastLn = groupLines[groupLines.length - 1].lineNum;
|
|
228
|
-
if (ln > lastLn + 1) flushGroup();
|
|
229
|
-
}
|
|
230
|
-
groupLines.push({ lineNum: ln, hash32 });
|
|
231
|
-
|
|
232
|
-
const isMatch = msg.type === "match";
|
|
233
|
-
if (plain) {
|
|
234
|
-
formatted.push(`${filePath}:${ln}:${lineContent}`);
|
|
235
|
-
} else {
|
|
236
|
-
let anno = "";
|
|
237
|
-
if (db && isMatch) {
|
|
238
|
-
let rel = relCache.get(filePath);
|
|
239
|
-
if (rel === undefined) {
|
|
240
|
-
rel = getRelativePath(resolve(filePath)) || "";
|
|
241
|
-
relCache.set(filePath, rel);
|
|
242
|
-
}
|
|
243
|
-
if (rel) {
|
|
244
|
-
const a = matchAnnotation(db, rel, ln);
|
|
245
|
-
if (a) anno = ` ${a}`;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
const prefix = isMatch ? ">>" : " ";
|
|
249
|
-
formatted.push(`${filePath}:${prefix}${tag}.${ln}\t${lineContent}${anno}`);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Count matches per rg event, not per subLine
|
|
255
|
-
if (msg.type === "match") {
|
|
256
|
-
matchCount++;
|
|
257
|
-
if (totalLimit > 0 && matchCount >= totalLimit) {
|
|
258
|
-
flushGroup();
|
|
259
|
-
formatted.push(`--- total_limit reached (${totalLimit}) ---`);
|
|
260
|
-
return `\`\`\`\n${formatted.join("\n")}\n\`\`\``;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Flush last group
|
|
266
|
-
flushGroup();
|
|
267
|
-
|
|
268
|
-
return `\`\`\`\n${formatted.join("\n")}\n\`\`\``;
|
|
269
|
-
}
|