pi-readseek 0.3.15 → 0.3.17

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 CHANGED
@@ -34,6 +34,9 @@ npm install --save-dev @jarkkojs/readseek
34
34
  follow-up `read`.
35
35
  - **search** — searches code by structural pattern (AST) and returns anchored
36
36
  matches. Use when syntax matters more than raw text.
37
+ - **refs** — finds binding-accurate references to an identifier and returns
38
+ anchored usages with their enclosing symbols. Use before renaming or deleting
39
+ a symbol.
37
40
  - **write** — creates or overwrites whole files and returns anchors for
38
41
  immediate follow-up edits.
39
42
 
package/index.ts CHANGED
@@ -3,6 +3,7 @@ import { registerReadTool } from "./src/read.js";
3
3
  import { registerEditTool } from "./src/edit.js";
4
4
  import { registerGrepTool } from "./src/grep.js";
5
5
  import { registerSgTool, isSgAvailable } from "./src/sg.js";
6
+ import { registerRefsTool } from "./src/refs.js";
6
7
  import { registerWriteTool } from "./src/write.js";
7
8
  export default function piReadseekExtension(pi: ExtensionAPI): void {
8
9
  const readPaths = new Set<string>();
@@ -20,5 +21,6 @@ export default function piReadseekExtension(pi: ExtensionAPI): void {
20
21
 
21
22
  registerGrepTool(pi, { searchGuideline, onFileAnchored: noteRead });
22
23
  registerSgTool(pi, { onFileAnchored: noteRead });
24
+ registerRefsTool(pi, { onFileAnchored: noteRead });
23
25
  registerWriteTool(pi, { onFileAnchored: noteRead });
24
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Pi extension for readseek-backed hash-anchored read/edit/grep, structural code maps, structural search, and file exploration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,12 +39,10 @@
39
39
  "node": ">=20.0.0"
40
40
  },
41
41
  "dependencies": {
42
- "@jarkkojs/readseek": "^0.3.14",
42
+ "@jarkkojs/readseek": "^0.3.17",
43
43
  "diff": "^8.0.3",
44
44
  "ignore": "^7.0.5",
45
45
  "picomatch": "^4.0.4",
46
- "tree-sitter-wasms": "0.1.13",
47
- "web-tree-sitter": "0.25.10",
48
46
  "xxhash-wasm": "^1.1.0"
49
47
  },
50
48
  "peerDependencies": {
package/prompts/edit.md CHANGED
@@ -80,7 +80,7 @@ Syntax validation runs before writing when supported:
80
80
  - Default `warn`: write succeeds, but warnings include `syntax-regression: lines X-Y`.
81
81
  - `block`: aborts without writing.
82
82
  - `off`: skips validation.
83
- - `PI_HASHLINE_SYNTAX_VALIDATE` can set the default mode.
83
+ - `READSEEK_SYNTAX_VALIDATE` can set the default mode.
84
84
 
85
85
  Existing syntax errors are tolerated; warnings are for newly introduced parser errors.
86
86
 
@@ -0,0 +1,23 @@
1
+ Find binding-accurate references to an identifier with readseek. Use it when you need every place a name is used — before renaming, deleting, or changing a symbol — and plain text search would be too broad. Results are grouped by file with edit-ready hashline anchors and the enclosing symbol for each reference.
2
+
3
+ ## Parameters
4
+
5
+ - `name` — identifier to find references for.
6
+ - `path` — file or directory, default cwd.
7
+ - `lang` — language hint; set it when syntax is ambiguous, extensionless, or generated.
8
+ - `scope` — restrict results to the binding under `line`/`column` in a single file. Requires `line`.
9
+ - `line` — one-based cursor line, used with `scope`.
10
+ - `column` — one-based cursor byte column, used with `scope`.
11
+ - `cached` — in a Git repository, search tracked/indexed files.
12
+ - `others` — in a Git repository, search untracked files.
13
+ - `ignored` — with `others`, include ignored untracked files.
14
+
15
+ ## Scope
16
+
17
+ Without `scope`, references match by identifier name across the target. With `scope` plus `line` (and optionally `column`), results are limited to the specific binding under that cursor in a single file, so shadowed or unrelated same-named identifiers are excluded.
18
+
19
+ ## Git selection
20
+
21
+ When searching a directory inside a Git repository, readseek defaults to tracked/indexed files plus untracked non-ignored files. Use `cached`, `others`, and `ignored` to narrow or expand that selection. `ignored` requires `others`.
22
+
23
+ Use `grep` for plain text, `search` for code shape, and `refs` for identifier usage.
@@ -1,6 +1,4 @@
1
- import type { Node as SyntaxNode, Parser as WasmParser, Tree } from "web-tree-sitter";
2
- import { getWasmParser, type WasmLanguageId } from "./readseek/parser-loader.js";
3
- import { reportParserError } from "./readseek/parser-errors.js";
1
+ import { readseekCheck, type ReadseekCheckOutput, type ReadseekDiagnostic } from "./readseek-client.js";
4
2
 
5
3
  export interface ValidateInput {
6
4
  filePath: string;
@@ -14,122 +12,53 @@ export interface ValidateResult {
14
12
  newMissingCount: number;
15
13
  }
16
14
 
17
- interface NodeStats {
18
- errors: Array<{ startLine: number; endLine: number }>;
19
- missing: Array<{ startLine: number; endLine: number }>;
20
- }
21
-
22
- function countNodes(parser: WasmParser, source: string): NodeStats {
23
- const tree: Tree | null = parser.parse(source);
24
- const errors: Array<{ startLine: number; endLine: number }> = [];
25
- const missing: Array<{ startLine: number; endLine: number }> = [];
26
- if (!tree) return { errors, missing };
27
- try {
28
- const stack: SyntaxNode[] = [tree.rootNode];
29
- while (stack.length > 0) {
30
- const node = stack.pop()!;
31
- if (node.type === "ERROR") {
32
- errors.push({
33
- startLine: node.startPosition.row + 1,
34
- endLine: node.endPosition.row + 1,
35
- });
36
- }
37
- if (node.isMissing) {
38
- missing.push({
39
- startLine: node.startPosition.row + 1,
40
- endLine: node.endPosition.row + 1,
41
- });
42
- }
43
- for (let i = 0; i < node.namedChildCount; i++) {
44
- const c = node.namedChild(i);
45
- if (c) stack.push(c);
46
- }
47
- // Also descend into anonymous children to find MISSING tokens.
48
- for (let i = 0; i < node.childCount; i++) {
49
- const c = node.child(i);
50
- if (c && !c.isNamed) stack.push(c);
51
- }
52
- }
53
- return { errors, missing };
54
- } finally {
55
- tree.delete();
56
- }
57
- }
58
-
59
- function dedupeSortLines(
60
- ranges: Array<{ startLine: number; endLine: number }>,
61
- ): string[] {
15
+ function dedupeSortLines(diagnostics: ReadseekDiagnostic[]): string[] {
62
16
  const seen = new Set<string>();
63
17
  const out: Array<{ key: string; start: number }> = [];
64
- for (const r of ranges) {
65
- const key = r.startLine === r.endLine
66
- ? String(r.startLine)
67
- : `${r.startLine}-${r.endLine}`;
18
+ for (const diagnostic of diagnostics) {
19
+ const key =
20
+ diagnostic.start_line === diagnostic.end_line
21
+ ? String(diagnostic.start_line)
22
+ : `${diagnostic.start_line}-${diagnostic.end_line}`;
68
23
  if (!seen.has(key)) {
69
24
  seen.add(key);
70
- out.push({ key, start: r.startLine });
25
+ out.push({ key, start: diagnostic.start_line });
71
26
  }
72
27
  }
73
28
  out.sort((a, b) => a.start - b.start);
74
29
  return out.map((o) => o.key);
75
30
  }
76
31
 
77
- const EXTENSION_TO_LANGUAGE: Record<string, WasmLanguageId> = {
78
- ".rs": "rust",
79
- ".c": "cpp",
80
- ".cc": "cpp",
81
- ".cpp": "cpp",
82
- ".cxx": "cpp",
83
- ".h": "c-header",
84
- ".hpp": "c-header",
85
- ".hxx": "c-header",
86
- ".java": "java",
87
- };
88
-
89
- function detectWasmLang(filePath: string): WasmLanguageId | null {
90
- for (const [ext, langId] of Object.entries(EXTENSION_TO_LANGUAGE)) {
91
- if (filePath.toLowerCase().endsWith(ext)) return langId;
92
- }
93
- return null;
94
- }
32
+ const EMPTY: ReadseekCheckOutput = { errorCount: 0, missingCount: 0, diagnostics: [] };
95
33
 
34
+ /**
35
+ * Compare parse diagnostics between `before` and `after` and report any newly
36
+ * introduced syntax errors. Uses readseek's native `check`, so every language
37
+ * with a tree-sitter parser is validated.
38
+ *
39
+ * Returns `null` when nothing new appears or when readseek cannot parse the
40
+ * file, so a validator failure never blocks an edit.
41
+ */
96
42
  export async function validateSyntaxRegression(
97
- input: ValidateInput,
43
+ input: ValidateInput,
98
44
  ): Promise<ValidateResult | null> {
99
- const langId = detectWasmLang(input.filePath);
100
- if (!langId) return null;
101
- const parser = await getWasmParser(langId);
102
- if (!parser) return null;
103
-
45
+ let before: ReadseekCheckOutput;
46
+ let after: ReadseekCheckOutput;
104
47
  try {
105
- const beforeStats = input.before === undefined
106
- ? { errors: [], missing: [] }
107
- : countNodes(parser, input.before);
108
- const afterStats = countNodes(parser, input.after);
48
+ before = input.before === undefined ? EMPTY : await readseekCheck(input.filePath, input.before);
49
+ after = await readseekCheck(input.filePath, input.after);
50
+ } catch {
51
+ return null;
52
+ }
109
53
 
110
- // ±1 tolerance on ERROR count, no tolerance on MISSING.
111
- const newErrorCount = Math.max(
112
- 0,
113
- afterStats.errors.length - beforeStats.errors.length - 1,
114
- );
115
- const newMissingCount = Math.max(
116
- 0,
117
- afterStats.missing.length - beforeStats.missing.length,
118
- );
54
+ const newErrorCount = Math.max(0, after.errorCount - before.errorCount - 1);
55
+ const newMissingCount = Math.max(0, after.missingCount - before.missingCount);
119
56
 
120
- if (newErrorCount === 0 && newMissingCount === 0) return null;
57
+ if (newErrorCount === 0 && newMissingCount === 0) return null;
121
58
 
122
- const errorLines = dedupeSortLines([
123
- ...afterStats.errors,
124
- ...afterStats.missing,
125
- ]);
126
- return { errorLines, newErrorCount, newMissingCount };
127
- } catch (err) {
128
- reportParserError(`wasm:syntax-validate:${langId}:${err instanceof Error ? err.message : String(err)}`, err, {
129
- context: `tree-sitter syntax validation failed for ${langId}`,
130
- });
131
- return null;
132
- } finally {
133
- parser.delete();
134
- }
59
+ return {
60
+ errorLines: dedupeSortLines(after.diagnostics),
61
+ newErrorCount,
62
+ newMissingCount,
63
+ };
135
64
  }
package/src/edit.ts CHANGED
@@ -269,7 +269,7 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
269
269
  // Error-precedence order: replace_symbol resolution > anchor-overlap > anchored-edit.
270
270
  //
271
271
  // AC 4: store successful probe results and reuse them in the apply loop so
272
- // generateMapFromContent is invoked at most once per replace_symbol edit.
272
+ // readseekMapContent is invoked at most once per replace_symbol edit.
273
273
  const replaceSymbolRanges: { start: number; end: number }[] = [];
274
274
  const rsProbeResults: { type: "ok"; content: string; replacement: string; warnings: string[]; range: { start: number; end: number } }[] = [];
275
275
  for (const rs of replaceSymbolEdits) {
@@ -342,7 +342,7 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
342
342
  // Apply pass: reuse all probe results (AC 4). The probe pass resolved every
343
343
  // replace_symbol against originalNormalized; apply those replacements in
344
344
  // reverse source order so original line ranges stay valid and no second
345
- // replaceSymbol/generateMapFromContent call is needed.
345
+ // replaceSymbol/readseekMapContent call is needed.
346
346
  const replaceSymbolWarnings: string[] = [];
347
347
  if (rsProbeResults.length > 0) {
348
348
  const lines = originalNormalized.split("\n");
@@ -4,7 +4,7 @@ import { resolveReadseekJsonSettings } from "./readseek-settings.js";
4
4
  const POSITIVE_BASE10_INT = /^[1-9][0-9]*$/;
5
5
 
6
6
  /**
7
- * Strict positive base-10 integer parser used by hashline env knobs.
7
+ * Strict positive base-10 integer parser used by readseek env knobs.
8
8
  *
9
9
  * Accepts: trimmed strings matching /^[1-9][0-9]*$/ that parse to a finite
10
10
  * positive integer.
@@ -66,12 +66,12 @@ export function resolveGrepOutputBudget(): GrepOutputBudget {
66
66
  const settings = resolveReadseekJsonSettings().settings.grep;
67
67
  return {
68
68
  maxLines: resolveDimension(
69
- process.env.PI_HASHLINE_GREP_MAX_LINES,
69
+ process.env.READSEEK_GREP_MAX_LINES,
70
70
  settings?.maxLines,
71
71
  GREP_OUTPUT_DEFAULT_MAX_LINES,
72
72
  ),
73
73
  maxBytes: resolveDimension(
74
- process.env.PI_HASHLINE_GREP_MAX_BYTES,
74
+ process.env.READSEEK_GREP_MAX_BYTES,
75
75
  settings?.maxBytes,
76
76
  GREP_OUTPUT_DEFAULT_MAX_BYTES,
77
77
  ),
package/src/map-cache.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import type { FileMap } from "./readseek/types.js";
3
- import { generateMap } from "./readseek/mapper.js";
3
+ import { readseekMap } from "./readseek-client.js";
4
4
 
5
5
  interface CacheEntry {
6
6
  mtimeMs: number;
@@ -60,7 +60,7 @@ export async function getOrGenerateMap(absPath: string): Promise<FileMap | null>
60
60
  if (inflight) return inflight;
61
61
 
62
62
  const generation = (async () => {
63
- const map = await generateMap(absPath);
63
+ const map = await readseekMap(absPath, fileStat.size);
64
64
  rememberInMemory(absPath, { mtimeMs, map });
65
65
  return map;
66
66
  })()
@@ -58,16 +58,6 @@ export interface FileMap {
58
58
  truncatedInfo?: TruncatedInfo;
59
59
  }
60
60
 
61
- /**
62
- * Map generation options.
63
- */
64
- export interface MapOptions {
65
- /** Maximum map size in bytes (default: 20KB) */
66
- maxBytes?: number;
67
- /** Abort signal */
68
- signal?: AbortSignal;
69
- }
70
-
71
61
  /**
72
62
  * Language information.
73
63
  */
@@ -70,6 +70,42 @@ interface ReadseekSearchOutput {
70
70
  results: ReadseekSearchFileOutput[];
71
71
  }
72
72
 
73
+ export interface ReadseekReference {
74
+ file: string;
75
+ line: number;
76
+ column: number;
77
+ line_hash: string;
78
+ text: string;
79
+ enclosingSymbol?: string;
80
+ }
81
+
82
+ interface ReadseekRefsOutput {
83
+ references: ReadseekReference[];
84
+ }
85
+
86
+ export interface ReadseekRefsOptions {
87
+ scope?: boolean;
88
+ line?: number;
89
+ column?: number;
90
+ language?: string;
91
+ cached?: boolean;
92
+ others?: boolean;
93
+ ignored?: boolean;
94
+ signal?: AbortSignal;
95
+ }
96
+
97
+ export interface ReadseekDiagnostic {
98
+ kind: "error" | "missing";
99
+ start_line: number;
100
+ end_line: number;
101
+ }
102
+
103
+ export interface ReadseekCheckOutput {
104
+ errorCount: number;
105
+ missingCount: number;
106
+ diagnostics: ReadseekDiagnostic[];
107
+ }
108
+
73
109
  export interface ReadseekSearchOptions {
74
110
  language?: string;
75
111
  cached?: boolean;
@@ -398,7 +434,87 @@ export async function readseekMapContent(
398
434
  options: { signal?: AbortSignal } = {},
399
435
  ): Promise<FileMap | null> {
400
436
  const output = parseMapOutput(
401
- await runReadseek(["map", "--stdin", "--path", filePath], { signal: options.signal, stdin: content }),
437
+ await runReadseek(["map", "--stdin", filePath], { signal: options.signal, stdin: content }),
402
438
  );
403
439
  return fileMapFromReadseekOutput(output, filePath, Buffer.byteLength(content, "utf8"));
404
440
  }
441
+
442
+ function optionalString(value: unknown, field: string): string | undefined {
443
+ if (value === undefined || value === null) return undefined;
444
+ return requireString(value, field);
445
+ }
446
+
447
+ function parseRefsOutput(value: unknown): ReadseekRefsOutput {
448
+ if (!value || typeof value !== "object") throw new Error("invalid readseek refs output");
449
+ const output = value as Record<string, unknown>;
450
+ if (!Array.isArray(output.references)) throw new Error("invalid readseek references");
451
+ return {
452
+ references: output.references.map((reference) => {
453
+ if (!reference || typeof reference !== "object") throw new Error("invalid readseek reference");
454
+ const item = reference as Record<string, unknown>;
455
+ const symbol = item.symbol;
456
+ const enclosing =
457
+ symbol && typeof symbol === "object"
458
+ ? optionalString((symbol as Record<string, unknown>).qualified_name, "reference.symbol.qualified_name")
459
+ : undefined;
460
+ return {
461
+ file: requireString(item.file, "reference.file"),
462
+ line: requireNumber(item.line, "reference.line"),
463
+ column: requireNumber(item.column, "reference.column"),
464
+ line_hash: requireString(item.line_hash, "reference.line_hash"),
465
+ text: requireString(item.text, "reference.text"),
466
+ enclosingSymbol: enclosing,
467
+ };
468
+ }),
469
+ };
470
+ }
471
+
472
+ export async function readseekRefs(
473
+ target: string,
474
+ name: string,
475
+ options: ReadseekRefsOptions = {},
476
+ ): Promise<ReadseekReference[]> {
477
+ const args = ["refs", target, name];
478
+ if (options.scope) args.push("--scope");
479
+ if (options.line !== undefined) args.push("--line", String(options.line));
480
+ if (options.column !== undefined) args.push("--column", String(options.column));
481
+ if (options.language) args.push("--language", options.language);
482
+ if (options.cached) args.push("--cached");
483
+ if (options.others) args.push("--others");
484
+ if (options.ignored) args.push("--ignored");
485
+ return parseRefsOutput(await runReadseek(args, { signal: options.signal })).references;
486
+ }
487
+
488
+ function parseDiagnosticKind(value: unknown): ReadseekDiagnostic["kind"] {
489
+ if (value === "error" || value === "missing") return value;
490
+ throw new Error("invalid readseek diagnostic.kind");
491
+ }
492
+
493
+ function parseCheckOutput(value: unknown): ReadseekCheckOutput {
494
+ if (!value || typeof value !== "object") throw new Error("invalid readseek check output");
495
+ const output = value as Record<string, unknown>;
496
+ if (!Array.isArray(output.diagnostics)) throw new Error("invalid readseek diagnostics");
497
+ return {
498
+ errorCount: requireNumber(output.error_count, "error_count"),
499
+ missingCount: requireNumber(output.missing_count, "missing_count"),
500
+ diagnostics: output.diagnostics.map((diagnostic) => {
501
+ if (!diagnostic || typeof diagnostic !== "object") throw new Error("invalid readseek diagnostic");
502
+ const item = diagnostic as Record<string, unknown>;
503
+ return {
504
+ kind: parseDiagnosticKind(item.kind),
505
+ start_line: requireNumber(item.start_line, "diagnostic.start_line"),
506
+ end_line: requireNumber(item.end_line, "diagnostic.end_line"),
507
+ };
508
+ }),
509
+ };
510
+ }
511
+
512
+ export async function readseekCheck(
513
+ filePath: string,
514
+ content: string,
515
+ options: { signal?: AbortSignal } = {},
516
+ ): Promise<ReadseekCheckOutput> {
517
+ return parseCheckOutput(
518
+ await runReadseek(["check", "--stdin", filePath], { signal: options.signal, stdin: content }),
519
+ );
520
+ }
@@ -121,7 +121,7 @@ export function resolveReadseekJsonSettings(): ReadseekSettingsResult {
121
121
  }
122
122
 
123
123
  export function resolveEditDiffDisplay(env: NodeJS.ProcessEnv = process.env): "collapsed" | "expanded" {
124
- const raw = env.PI_HASHLINE_EDIT_DIFF_DISPLAY;
124
+ const raw = env.READSEEK_EDIT_DIFF_DISPLAY;
125
125
  if (typeof raw === "string") {
126
126
  const normalized = raw.trim().toLowerCase();
127
127
  if (normalized === "expanded" || normalized === "collapsed") return normalized;
@@ -0,0 +1,56 @@
1
+ import type { ReadseekLine } from "./readseek-value.js";
2
+
3
+ export interface RefsOutputLine extends ReadseekLine {
4
+ enclosingSymbol?: string;
5
+ }
6
+
7
+ export interface RefsOutputFile {
8
+ displayPath: string;
9
+ path: string;
10
+ lines: RefsOutputLine[];
11
+ }
12
+
13
+ export interface BuildRefsOutputInput {
14
+ name: string;
15
+ files: RefsOutputFile[];
16
+ }
17
+
18
+ export interface RefsOutputResult {
19
+ text: string;
20
+ readseekValue: {
21
+ tool: "refs";
22
+ files: Array<{
23
+ path: string;
24
+ lines: RefsOutputLine[];
25
+ }>;
26
+ };
27
+ }
28
+
29
+ export function buildRefsOutput(input: BuildRefsOutputInput): RefsOutputResult {
30
+ if (input.files.length === 0) {
31
+ return {
32
+ text: `No references found for: ${input.name}`,
33
+ readseekValue: { tool: "refs", files: [] },
34
+ };
35
+ }
36
+
37
+ const blocks: string[] = [];
38
+ for (const file of input.files) {
39
+ blocks.push(`--- ${file.displayPath} ---`);
40
+ for (const line of file.lines) {
41
+ const suffix = line.enclosingSymbol ? ` (in ${line.enclosingSymbol})` : "";
42
+ blocks.push(`>>${line.anchor}|${line.display}${suffix}`);
43
+ }
44
+ }
45
+
46
+ return {
47
+ text: blocks.join("\n"),
48
+ readseekValue: {
49
+ tool: "refs",
50
+ files: input.files.map((file) => ({
51
+ path: file.path,
52
+ lines: file.lines.map((line) => ({ ...line })),
53
+ })),
54
+ },
55
+ };
56
+ }
package/src/refs.ts ADDED
@@ -0,0 +1,202 @@
1
+ import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "@sinclair/typebox";
4
+ import path from "node:path";
5
+ import { stat as fsStat } from "node:fs/promises";
6
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
+ import { escapeControlCharsForDisplay } from "./hashline.js";
8
+ import { buildToolErrorResult } from "./readseek-value.js";
9
+ import { resolveToCwd } from "./path-utils.js";
10
+ import { isReadseekAvailable, readseekRefs, type ReadseekReference } from "./readseek-client.js";
11
+ import { buildRefsOutput, type RefsOutputFile, type RefsOutputLine } from "./refs-output.js";
12
+
13
+ import { clampLineToWidth, clampLinesToWidth, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
14
+
15
+ type RefsParams = {
16
+ name: string;
17
+ path?: string;
18
+ lang?: string;
19
+ scope?: boolean;
20
+ line?: number;
21
+ column?: number;
22
+ cached?: boolean;
23
+ others?: boolean;
24
+ ignored?: boolean;
25
+ };
26
+
27
+ const REFS_PROMPT_METADATA = defineToolPromptMetadata({
28
+ promptUrl: new URL("../prompts/refs.md", import.meta.url),
29
+ promptSnippet: "Find references to an identifier with readseek and return edit-ready anchors",
30
+ });
31
+
32
+ export function isRefsAvailable(): boolean {
33
+ return isReadseekAvailable();
34
+ }
35
+
36
+ interface RefsToolOptions {
37
+ onFileAnchored?: (absolutePath: string) => void;
38
+ }
39
+
40
+ function refsLine(reference: ReadseekReference): RefsOutputLine {
41
+ return {
42
+ line: reference.line,
43
+ hash: reference.line_hash,
44
+ anchor: `${reference.line}:${reference.line_hash}`,
45
+ raw: reference.text,
46
+ display: escapeControlCharsForDisplay(reference.text),
47
+ enclosingSymbol: reference.enclosingSymbol,
48
+ };
49
+ }
50
+
51
+ function groupReferences(references: ReadseekReference[], cwd: string): RefsOutputFile[] {
52
+ const files = new Map<string, RefsOutputFile>();
53
+ const seen = new Set<string>();
54
+ for (const reference of references) {
55
+ const abs = path.isAbsolute(reference.file) ? reference.file : path.resolve(cwd, reference.file);
56
+ const dedupeKey = `${abs}:${reference.line}:${reference.column}`;
57
+ if (seen.has(dedupeKey)) continue;
58
+ seen.add(dedupeKey);
59
+ let file = files.get(abs);
60
+ if (!file) {
61
+ file = { displayPath: path.relative(cwd, abs) || abs, path: abs, lines: [] };
62
+ files.set(abs, file);
63
+ }
64
+ file.lines.push(refsLine(reference));
65
+ }
66
+ return [...files.values()];
67
+ }
68
+
69
+ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}) {
70
+ const toolConfig = {
71
+ callable: true,
72
+ enabled: true,
73
+ policy: "read-only" as const,
74
+ readOnly: true,
75
+ pythonName: "refs",
76
+ defaultExposure: "opt-in" as const,
77
+ };
78
+
79
+ const tool = {
80
+ name: "refs",
81
+ label: "References",
82
+ description: REFS_PROMPT_METADATA.description,
83
+ promptSnippet: REFS_PROMPT_METADATA.promptSnippet,
84
+ promptGuidelines: REFS_PROMPT_METADATA.promptGuidelines,
85
+ parameters: Type.Object({
86
+ name: Type.String({ description: "Identifier to find references for" }),
87
+ path: Type.Optional(Type.String({ description: "Search path" })),
88
+ lang: Type.Optional(Type.String({ description: "Language hint" })),
89
+ scope: Type.Optional(Type.Boolean({ description: "Restrict to the binding under line/column (single file)" })),
90
+ line: Type.Optional(Type.Number({ description: "One-based cursor line, used with scope" })),
91
+ column: Type.Optional(Type.Number({ description: "One-based cursor byte column, used with scope" })),
92
+ cached: Type.Optional(Type.Boolean({ description: "In a Git repository, search tracked/indexed files" })),
93
+ others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
94
+ ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
95
+ }),
96
+ ptc: toolConfig,
97
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
98
+ const p = params as RefsParams;
99
+ if (p.ignored && !p.others) {
100
+ return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'ignored' requires 'others'");
101
+ }
102
+ if (p.scope && p.line === undefined) {
103
+ return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'scope' requires 'line'");
104
+ }
105
+ const searchPath = resolveToCwd(p.path ?? ".", ctx.cwd);
106
+
107
+ try {
108
+ await fsStat(searchPath);
109
+ } catch (err: any) {
110
+ if (err?.code === "ENOENT") {
111
+ return buildToolErrorResult("refs", "path-not-found", `Error: path '${p.path ?? "."}' does not exist`, {
112
+ path: p.path ?? searchPath,
113
+ });
114
+ }
115
+ if (err?.code === "EACCES" || err?.code === "EPERM") {
116
+ return buildToolErrorResult("refs", "permission-denied", `Error: permission denied for path '${p.path ?? "."}'`, {
117
+ path: p.path ?? searchPath,
118
+ });
119
+ }
120
+ const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
121
+ return buildToolErrorResult("refs", "fs-error", message, {
122
+ path: p.path ?? searchPath,
123
+ details: { fsCode: err?.code, fsMessage: err?.message },
124
+ });
125
+ }
126
+
127
+ try {
128
+ const references = await readseekRefs(searchPath, p.name, {
129
+ scope: p.scope,
130
+ line: p.line,
131
+ column: p.column,
132
+ language: p.lang,
133
+ cached: p.cached,
134
+ others: p.others,
135
+ ignored: p.ignored,
136
+ signal,
137
+ });
138
+ const files = groupReferences(references, ctx.cwd);
139
+ const builtOutput = buildRefsOutput({ name: p.name, files });
140
+ for (const file of files) {
141
+ options.onFileAnchored?.(file.path);
142
+ }
143
+ return {
144
+ content: [{ type: "text", text: builtOutput.text }],
145
+ details: { readseekValue: builtOutput.readseekValue },
146
+ };
147
+ } catch (err: any) {
148
+ const message = String(err?.message || err);
149
+ const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
150
+ return buildToolErrorResult(
151
+ "refs",
152
+ missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
153
+ message,
154
+ missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
155
+ );
156
+ }
157
+ },
158
+ renderCall(args: any, theme: any, ...rest: any[]) {
159
+ const context = rest[0] ?? {};
160
+ let text = `${renderToolLabel(theme, "refs")} ${theme.fg("accent", args.name)}`;
161
+ text += theme.fg("dim", ` in ${args.path ?? "."}`);
162
+ if (args.lang) text += theme.fg("dim", ` (${args.lang})`);
163
+ const flags = [args.scope && "scope", args.cached && "cached", args.others && "others", args.ignored && "ignored"].filter(Boolean);
164
+ if (flags.length > 0) text += theme.fg("dim", ` [${flags.join(",")}]`);
165
+ return new Text(clampLineToWidth(text, context.width), 0, 0);
166
+ },
167
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
168
+ const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
169
+
170
+ if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending refs")], width).join("\n"), 0, 0);
171
+
172
+ const content = result.content?.[0];
173
+ const textContent = content?.type === "text" ? content.text : "";
174
+ if (isError || result.isError) {
175
+ const firstLine = textContent.split("\n")[0] || "Error";
176
+ const body = expanded && textContent ? textContent : firstLine;
177
+ return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
178
+ }
179
+ const readseekValue = (result.details as any)?.readseekValue as
180
+ | { tool: "refs"; files: Array<{ path: string; lines: any[] }> }
181
+ | undefined;
182
+ const files = readseekValue?.files ?? [];
183
+ if (files.length === 0) return new Text(summaryLine("no references"), 0, 0);
184
+ const fileCount = files.length;
185
+ const totalRefs = files.reduce((sum: number, f: any) => sum + f.lines.length, 0);
186
+ const refWord = totalRefs === 1 ? "reference" : "references";
187
+ const fileWord = fileCount === 1 ? "file" : "files";
188
+ let text = summaryLine(`${totalRefs} ${refWord} in ${fileCount} ${fileWord}`, { hidden: !expanded });
189
+ if (expanded) {
190
+ for (const file of files.slice(0, 20)) {
191
+ const display = path.relative(cwd, file.path) || file.path;
192
+ text += "\n" + theme.fg("dim", ` ${display} (${file.lines.length})`);
193
+ }
194
+ if (files.length > 20) text += "\n" + theme.fg("muted", ` … and ${files.length - 20} more files`);
195
+ }
196
+ return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
197
+ },
198
+ } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
199
+
200
+ pi.registerTool(tool);
201
+ return tool;
202
+ }
@@ -1,4 +1,4 @@
1
- import { generateMapFromContent } from "./readseek/mapper.js";
1
+ import { readseekMapContent } from "./readseek-client.js";
2
2
  import { findSymbol } from "./readseek/symbol-lookup.js";
3
3
  import { formatAmbiguous, formatNotFound } from "./readseek/symbol-error-format.js";
4
4
 
@@ -32,7 +32,7 @@ function reindent(text: string, indent: string): string {
32
32
  }
33
33
 
34
34
  export async function replaceSymbol(input: ReplaceSymbolInput): Promise<ReplaceSymbolResult> {
35
- const map = await generateMapFromContent(input.filePath, input.content);
35
+ const map = await readseekMapContent(input.filePath, input.content);
36
36
  if (!map) {
37
37
  return {
38
38
  type: "unsupported",
@@ -19,7 +19,7 @@ export function resolveSyntaxValidateMode(
19
19
  ): SyntaxValidateMode {
20
20
  const fromOpt = coerce(opts.syntaxValidate);
21
21
  if (fromOpt) return fromOpt;
22
- const fromEnv = coerce(process.env.PI_HASHLINE_SYNTAX_VALIDATE);
22
+ const fromEnv = coerce(process.env.READSEEK_SYNTAX_VALIDATE);
23
23
  if (fromEnv) return fromEnv;
24
24
  return DEFAULT;
25
25
  }
@@ -8,6 +8,7 @@ const COMPACT_DESCRIPTIONS: Record<string, string> = {
8
8
 
9
9
  "write.md": "Create or overwrite a complete file and return anchors.",
10
10
  "sg.md": "Search code by AST pattern and return anchored matches.",
11
+ "refs.md": "Find references to an identifier and return anchored usages with enclosing symbols.",
11
12
  };
12
13
 
13
14
  const COMPACT_GUIDELINES: Record<string, string[]> = {
@@ -33,6 +34,10 @@ const COMPACT_GUIDELINES: Record<string, string[]> = {
33
34
  "Use search for AST-shaped code patterns.",
34
35
  "Use grep instead of search for plain text.",
35
36
  ],
37
+ "refs.md": [
38
+ "Use refs to find every usage of an identifier before renaming or deleting it.",
39
+ "Use refs with scope plus line/column to follow a specific binding instead of every same-named identifier.",
40
+ ],
36
41
  };
37
42
 
38
43
  export interface ToolPromptMetadata {
@@ -1,28 +0,0 @@
1
- import { stat } from "node:fs/promises";
2
-
3
- import { readseekMap, readseekMapContent } from "../readseek-client.js";
4
- import { throwIfAborted } from "../runtime.js";
5
- import type { FileMap, MapOptions } from "./types.js";
6
-
7
- export async function generateMap(
8
- filePath: string,
9
- options: MapOptions = {},
10
- ): Promise<FileMap | null> {
11
- throwIfAborted(options.signal);
12
- const fileStat = await stat(filePath);
13
- throwIfAborted(options.signal);
14
- const map = await readseekMap(filePath, fileStat.size, { signal: options.signal });
15
- throwIfAborted(options.signal);
16
- return map;
17
- }
18
-
19
- export async function generateMapFromContent(
20
- filePath: string,
21
- content: string,
22
- options: MapOptions = {},
23
- ): Promise<FileMap | null> {
24
- throwIfAborted(options.signal);
25
- const map = await readseekMapContent(filePath, content, { signal: options.signal });
26
- throwIfAborted(options.signal);
27
- return map;
28
- }
@@ -1,19 +0,0 @@
1
- const emitted = new Set<string>();
2
-
3
- function causeMessage(err: unknown): string {
4
- if (err instanceof Error) return err.message;
5
- return String(err);
6
- }
7
-
8
- export function reportParserError(
9
- onceKey: string,
10
- err: unknown,
11
- options: { context?: string } = {},
12
- ): void {
13
- if (!process.env.PI_READSEEK_DEBUG) return;
14
- if (emitted.has(onceKey)) return;
15
- emitted.add(onceKey);
16
- const context = options.context ?? onceKey;
17
- console.error(`[readseek] ${context}: ${causeMessage(err)}`);
18
- }
19
-
@@ -1,78 +0,0 @@
1
- import { createRequire } from "node:module";
2
- import { dirname, join } from "node:path";
3
- import { Language, Parser } from "web-tree-sitter";
4
- import { reportParserError } from "./parser-errors.js";
5
-
6
- export type WasmLanguageId = "rust" | "cpp" | "c-header" | "java";
7
- export type WasmParser = Parser;
8
-
9
- const require_ = createRequire(import.meta.url);
10
- const wasmNames: Record<WasmLanguageId, string> = {
11
- rust: "rust",
12
- cpp: "cpp",
13
- "c-header": "cpp",
14
- java: "java",
15
- };
16
- let initPromise: Promise<void> | null = null;
17
- const languages = new Map<WasmLanguageId, Language>();
18
- const languageLoads = new Map<WasmLanguageId, Promise<Language | null>>();
19
-
20
- function isBun(): boolean {
21
- return typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
22
- }
23
-
24
- function wasmPath(langId: WasmLanguageId): string {
25
- const pkg = require_.resolve("tree-sitter-wasms/package.json");
26
- return join(dirname(pkg), "out", `tree-sitter-${wasmNames[langId]}.wasm`);
27
- }
28
-
29
- async function init(): Promise<void> {
30
- initPromise ??= Parser.init().catch((err: unknown) => {
31
- initPromise = null;
32
- reportParserError("wasm:init", err, { context: "web-tree-sitter initialization failed" });
33
- throw err;
34
- });
35
- return initPromise;
36
- }
37
-
38
- async function language(langId: WasmLanguageId): Promise<Language | null> {
39
- const loaded = languages.get(langId);
40
- if (loaded) return loaded;
41
-
42
- const inFlight = languageLoads.get(langId);
43
- if (inFlight) return inFlight;
44
-
45
- const loadPromise = (async () => {
46
- try {
47
- await init();
48
- const lang = await Language.load(wasmPath(langId));
49
- languages.set(langId, lang);
50
- return lang;
51
- } catch (err) {
52
- reportParserError(`wasm:load:${langId}`, err, {
53
- context: `tree-sitter WASM grammar load failed for ${langId}`,
54
- });
55
- return null;
56
- } finally {
57
- languageLoads.delete(langId);
58
- }
59
- })();
60
-
61
- languageLoads.set(langId, loadPromise);
62
- return loadPromise;
63
- }
64
-
65
- export async function getWasmParser(langId: WasmLanguageId): Promise<WasmParser | null> {
66
- if (isBun()) return null;
67
- const lang = await language(langId);
68
- if (!lang) return null;
69
- try {
70
- const parser = new Parser();
71
- parser.setLanguage(lang);
72
- return parser;
73
- } catch (err) {
74
- reportParserError(`wasm:parser:${langId}`, err, { context: `tree-sitter WASM parser creation failed for ${langId}` });
75
- return null;
76
- }
77
- }
78
-