@khanhicetea/pi-better-tool 0.2.2 → 0.2.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 +121 -83
- package/package.json +35 -5
- package/src/diagnostics.ts +39 -9
- package/src/index.ts +14 -4
- package/src/paths.ts +19 -0
- package/src/read-code-imports.ts +157 -0
- package/src/read-evidence.ts +21 -4
- package/src/read-symbol.ts +214 -0
- package/src/source-file.ts +62 -0
- package/src/symbols.ts +228 -0
- package/src/tool.ts +3 -29
package/src/symbols.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { basename, extname } from "node:path";
|
|
2
|
+
import type { SgNode } from "@ast-grep/napi";
|
|
3
|
+
|
|
4
|
+
export const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
const MAX_NODES = 100_000;
|
|
6
|
+
const MAX_SYMBOLS = 10_000;
|
|
7
|
+
|
|
8
|
+
const LANGUAGES: Record<string, string> = {
|
|
9
|
+
".js": "JavaScript", ".jsx": "Tsx", ".mjs": "JavaScript", ".cjs": "JavaScript",
|
|
10
|
+
".ts": "TypeScript", ".tsx": "Tsx", ".mts": "TypeScript", ".cts": "TypeScript",
|
|
11
|
+
".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust",
|
|
12
|
+
".bash": "bash", ".bats": "bash", ".command": "bash", ".ksh": "bash", ".sh": "bash", ".zsh": "bash",
|
|
13
|
+
".c": "c", ".h": "c", ".cc": "cpp", ".cp": "cpp", ".cpp": "cpp", ".cxx": "cpp", ".c++": "cpp", ".cu": "cpp", ".hh": "cpp", ".hpp": "cpp", ".hxx": "cpp", ".ino": "cpp",
|
|
14
|
+
".cs": "csharp", ".java": "java", ".kt": "kotlin", ".ktm": "kotlin", ".kts": "kotlin",
|
|
15
|
+
".php": "php", ".phtml": "php", ".rb": "ruby", ".rbw": "ruby", ".rake": "ruby", ".gemspec": "ruby", ".swift": "swift",
|
|
16
|
+
};
|
|
17
|
+
const FILENAMES: Record<string, string> = { Gemfile: "ruby", Rakefile: "ruby" };
|
|
18
|
+
export const SUPPORTED_LANGUAGES = "Bash, C, C++, C#, Java, JavaScript/JSX, TypeScript/TSX, Kotlin, PHP, Python, Ruby, Rust, Swift, and Go";
|
|
19
|
+
export function languageForPath(path: string): string | undefined {
|
|
20
|
+
return LANGUAGES[extname(path).toLowerCase()] ?? FILENAMES[basename(path)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SourceSymbol {
|
|
24
|
+
name: string;
|
|
25
|
+
qualifiedName: string;
|
|
26
|
+
kind: string;
|
|
27
|
+
startLine: number;
|
|
28
|
+
endLine: number;
|
|
29
|
+
/** 1-based UTF-16 columns, end-exclusive. */
|
|
30
|
+
startColumn: number;
|
|
31
|
+
endColumn: number;
|
|
32
|
+
parent?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SymbolIndex {
|
|
36
|
+
symbols: SourceSymbol[];
|
|
37
|
+
/** Never claim complete symbol boundaries for a malformed tree. */
|
|
38
|
+
errorLine?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Parser = typeof import("@ast-grep/napi");
|
|
42
|
+
type DynamicLanguageModule = { default: import("@ast-grep/napi").DynamicLangRegistrations[string] };
|
|
43
|
+
|
|
44
|
+
// Native dynamic language registration is process-wide. Keep only parser setup
|
|
45
|
+
// across /reload, never project content or session evidence. ast-grep initializes
|
|
46
|
+
// its registry only once, so upgrading from a smaller grammar set requires a Pi
|
|
47
|
+
// restart before the newly shipped grammars can be registered.
|
|
48
|
+
const parserKey = Symbol.for("pi-better-tool.parser.0.45.3.v2");
|
|
49
|
+
const parserState = globalThis as typeof globalThis & { [parserKey]?: Promise<Parser> };
|
|
50
|
+
const DYNAMIC_LANGUAGE_MODULES: ReadonlyArray<readonly [string, () => Promise<DynamicLanguageModule>]> = [
|
|
51
|
+
["bash", () => import("@ast-grep/lang-bash")],
|
|
52
|
+
["c", () => import("@ast-grep/lang-c")],
|
|
53
|
+
["cpp", () => import("@ast-grep/lang-cpp")],
|
|
54
|
+
["csharp", () => import("@ast-grep/lang-csharp")],
|
|
55
|
+
["java", () => import("@ast-grep/lang-java")],
|
|
56
|
+
["kotlin", () => import("@ast-grep/lang-kotlin")],
|
|
57
|
+
["php", () => import("@ast-grep/lang-php")],
|
|
58
|
+
["python", () => import("@ast-grep/lang-python")],
|
|
59
|
+
["ruby", () => import("@ast-grep/lang-ruby")],
|
|
60
|
+
["rust", () => import("@ast-grep/lang-rust")],
|
|
61
|
+
["swift", () => import("@ast-grep/lang-swift")],
|
|
62
|
+
["go", () => import("@ast-grep/lang-go")],
|
|
63
|
+
];
|
|
64
|
+
async function loadParser(): Promise<Parser> {
|
|
65
|
+
return parserState[parserKey] ??= (async () => {
|
|
66
|
+
const api = await import("@ast-grep/napi");
|
|
67
|
+
const languages: import("@ast-grep/napi").DynamicLangRegistrations = {};
|
|
68
|
+
const modules = await Promise.allSettled(DYNAMIC_LANGUAGE_MODULES.map(([, load]) => load()));
|
|
69
|
+
for (const [index, [name]] of DYNAMIC_LANGUAGE_MODULES.entries()) {
|
|
70
|
+
const module = modules[index];
|
|
71
|
+
if (module.status !== "fulfilled") continue;
|
|
72
|
+
try {
|
|
73
|
+
// Resolve lazy prebuild getters here. One unavailable grammar must
|
|
74
|
+
// not prevent the built-in JS/TS parsers from working.
|
|
75
|
+
languages[name] = { ...module.value.default };
|
|
76
|
+
} catch { /* Report unavailable language at parse time. */ }
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
if (Object.keys(languages).length) api.registerDynamicLanguage(languages);
|
|
80
|
+
} catch { /* An incompatible native grammar must not disable built-in JS/TS. */ }
|
|
81
|
+
return api;
|
|
82
|
+
})();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const JS_DECLARATIONS = [
|
|
86
|
+
"function_declaration", "generator_function_declaration", "function_expression", "generator_function", "arrow_function",
|
|
87
|
+
"method_definition", "class_declaration", "abstract_class_declaration", "class",
|
|
88
|
+
"interface_declaration", "type_alias_declaration", "enum_declaration", "internal_module",
|
|
89
|
+
"function_signature", "method_signature", "abstract_method_signature",
|
|
90
|
+
];
|
|
91
|
+
const DECLARATIONS_BY_LANGUAGE: Record<string, ReadonlySet<string>> = {
|
|
92
|
+
JavaScript: new Set(JS_DECLARATIONS), TypeScript: new Set(JS_DECLARATIONS), Tsx: new Set(JS_DECLARATIONS),
|
|
93
|
+
python: new Set(["function_definition", "class_definition"]),
|
|
94
|
+
go: new Set(["function_declaration", "method_declaration", "type_spec", "func_literal"]),
|
|
95
|
+
rust: new Set(["function_item", "function_signature_item", "struct_item", "enum_item", "trait_item", "impl_item", "mod_item", "type_item", "closure_expression"]),
|
|
96
|
+
bash: new Set(["function_definition"]),
|
|
97
|
+
c: new Set(["function_definition", "struct_specifier", "union_specifier", "enum_specifier", "type_definition"]),
|
|
98
|
+
cpp: new Set(["function_definition", "class_specifier", "struct_specifier", "union_specifier", "enum_specifier", "namespace_definition", "alias_declaration"]),
|
|
99
|
+
csharp: new Set(["namespace_declaration", "file_scoped_namespace_declaration", "class_declaration", "struct_declaration", "interface_declaration", "enum_declaration", "record_declaration", "delegate_declaration", "method_declaration", "constructor_declaration", "destructor_declaration", "local_function_statement"]),
|
|
100
|
+
java: new Set(["class_declaration", "interface_declaration", "enum_declaration", "annotation_type_declaration", "record_declaration", "method_declaration", "constructor_declaration", "compact_constructor_declaration"]),
|
|
101
|
+
kotlin: new Set(["class_declaration", "object_declaration", "function_declaration", "secondary_constructor"]),
|
|
102
|
+
php: new Set(["class_declaration", "interface_declaration", "trait_declaration", "enum_declaration", "function_definition", "method_declaration"]),
|
|
103
|
+
ruby: new Set(["class", "module", "method", "singleton_method"]),
|
|
104
|
+
swift: new Set(["class_declaration", "protocol_declaration", "struct_declaration", "enum_declaration", "extension_declaration", "actor_declaration", "function_declaration", "initializer_declaration", "deinitializer_declaration"]),
|
|
105
|
+
};
|
|
106
|
+
const ANONYMOUS_FUNCTIONS = new Set(["arrow_function", "function_expression", "generator_function", "func_literal", "closure_expression"]);
|
|
107
|
+
const BINDINGS = new Set(["variable_declarator", "pair", "public_field_definition", "field_definition", "assignment", "let_declaration"]);
|
|
108
|
+
const LIFECYCLE_NAMES: Record<string, string> = {
|
|
109
|
+
constructor_declaration: "constructor", compact_constructor_declaration: "constructor", secondary_constructor: "constructor",
|
|
110
|
+
destructor_declaration: "destructor", initializer_declaration: "init", deinitializer_declaration: "deinit",
|
|
111
|
+
};
|
|
112
|
+
const NAME_KINDS_BY_LANGUAGE: Record<string, readonly string[]> = {
|
|
113
|
+
c: ["identifier", "field_identifier", "type_identifier"],
|
|
114
|
+
cpp: ["identifier", "field_identifier", "type_identifier", "namespace_identifier", "operator_name"],
|
|
115
|
+
kotlin: ["simple_identifier", "type_identifier"],
|
|
116
|
+
swift: ["simple_identifier", "type_identifier"],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function firstNamedText(node: SgNode, kinds: readonly string[]): string | undefined {
|
|
120
|
+
if (kinds.includes(String(node.kind()))) return node.text();
|
|
121
|
+
for (const kind of kinds) {
|
|
122
|
+
const found = node.find({ rule: { kind } });
|
|
123
|
+
if (found) return found.text();
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function nameForNode(node: SgNode, language: string): string | undefined {
|
|
129
|
+
const directName = node.field("name")?.text();
|
|
130
|
+
if (directName) return directName;
|
|
131
|
+
const lifecycleName = LIFECYCLE_NAMES[String(node.kind())];
|
|
132
|
+
if (lifecycleName) return lifecycleName;
|
|
133
|
+
// Kotlin classes/objects use type_identifier while functions use
|
|
134
|
+
// simple_identifier. Searching for the latter first would accidentally take
|
|
135
|
+
// a nested method name as the enclosing class name.
|
|
136
|
+
const kinds = language === "kotlin"
|
|
137
|
+
? (node.kind() === "function_declaration" ? ["simple_identifier"] : ["type_identifier"])
|
|
138
|
+
: NAME_KINDS_BY_LANGUAGE[language];
|
|
139
|
+
if (!kinds) return undefined;
|
|
140
|
+
const declarator = node.field("declarator");
|
|
141
|
+
return (declarator && firstNamedText(declarator, kinds)) ?? firstNamedText(node, kinds);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isDeclaration(node: SgNode, language: string): boolean {
|
|
145
|
+
if (!DECLARATIONS_BY_LANGUAGE[language]?.has(String(node.kind()))) return false;
|
|
146
|
+
// A typedef that wraps a named C struct/union/enum is one declaration. Keep
|
|
147
|
+
// the outer typedef's full range instead of emitting a duplicate child name.
|
|
148
|
+
return !(language === "c" && ["struct_specifier", "union_specifier", "enum_specifier"].includes(String(node.kind())) && node.parent()?.kind() === "type_definition");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function describeNode(node: SgNode, language: string): { name: string; start: SgNode; end: SgNode } {
|
|
152
|
+
let start = node;
|
|
153
|
+
let end = node;
|
|
154
|
+
let name = nameForNode(node, language);
|
|
155
|
+
const parent = node.parent();
|
|
156
|
+
if (ANONYMOUS_FUNCTIONS.has(String(node.kind())) && parent && BINDINGS.has(String(parent.kind()))) {
|
|
157
|
+
name = parent.field("name")?.text() ?? parent.field("key")?.text() ?? parent.field("left")?.text() ?? parent.field("pattern")?.text() ?? name;
|
|
158
|
+
start = end = parent;
|
|
159
|
+
const declaration = parent.parent();
|
|
160
|
+
if (declaration && ["lexical_declaration", "variable_declaration"].includes(String(declaration.kind())) &&
|
|
161
|
+
declaration.children().filter((child) => child.kind() === "variable_declarator").length === 1) {
|
|
162
|
+
start = end = declaration;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (node.kind() === "impl_item") name = node.field("type")?.text();
|
|
166
|
+
if (node.kind() === "method_declaration") {
|
|
167
|
+
const receiver = node.field("receiver")?.find({ rule: { kind: "type_identifier" } })?.text();
|
|
168
|
+
if (receiver && name) name = `${receiver}.${name}`;
|
|
169
|
+
}
|
|
170
|
+
const wrapper = start.parent();
|
|
171
|
+
if (wrapper && ["export_statement", "decorated_definition"].includes(String(wrapper.kind()))) start = end = wrapper;
|
|
172
|
+
// Rust attributes are siblings, not part of the declaration node.
|
|
173
|
+
let previous = start.prev();
|
|
174
|
+
while (previous?.kind() === "attribute_item") {
|
|
175
|
+
start = previous;
|
|
176
|
+
previous = previous.prev();
|
|
177
|
+
}
|
|
178
|
+
return { name: name ?? `<anonymous@${node.range().start.line + 1}:${node.range().start.column + 1}>`, start, end };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse syntax, not regex/braces. No repository code, config, or shell runs. */
|
|
182
|
+
export async function indexSymbols(path: string, content: string, signal?: AbortSignal): Promise<SymbolIndex> {
|
|
183
|
+
signal?.throwIfAborted();
|
|
184
|
+
if (Buffer.byteLength(content, "utf8") > MAX_SOURCE_BYTES) throw new Error("Source exceeds the 2 MiB symbol analysis limit.");
|
|
185
|
+
const language = languageForPath(path);
|
|
186
|
+
if (!language) throw new Error(`Unsupported source type. Symbol parsing supports ${SUPPORTED_LANGUAGES}.`);
|
|
187
|
+
const api = await loadParser();
|
|
188
|
+
signal?.throwIfAborted();
|
|
189
|
+
const root = (await api.parseAsync(language, content)).root();
|
|
190
|
+
signal?.throwIfAborted();
|
|
191
|
+
const symbols: SourceSymbol[] = [];
|
|
192
|
+
const stack: Array<{ node: SgNode; parent?: number }> = [{ node: root }];
|
|
193
|
+
let visited = 0;
|
|
194
|
+
let errorLine: number | undefined;
|
|
195
|
+
while (stack.length) {
|
|
196
|
+
if (++visited > MAX_NODES || symbols.length > MAX_SYMBOLS) throw new Error("Source exceeds the symbol traversal budget; use a bounded read instead.");
|
|
197
|
+
const { node, parent } = stack.pop()!;
|
|
198
|
+
const range = node.range();
|
|
199
|
+
if (node.kind() === "ERROR" || (node.isLeaf() && node.id() !== root.id() && range.start.index === range.end.index)) {
|
|
200
|
+
errorLine ??= range.start.line + 1;
|
|
201
|
+
}
|
|
202
|
+
let enclosing = parent;
|
|
203
|
+
if (isDeclaration(node, language)) {
|
|
204
|
+
const described = describeNode(node, language);
|
|
205
|
+
const start = described.start.range().start;
|
|
206
|
+
const end = described.end.range().end;
|
|
207
|
+
const owner = parent === undefined ? undefined : symbols[parent];
|
|
208
|
+
const qualifiedName = owner ? `${owner.qualifiedName}.${described.name}` : described.name;
|
|
209
|
+
if (qualifiedName.length > 1024) throw new Error("Source exceeds the 1024-character qualified symbol name budget; use a bounded read instead.");
|
|
210
|
+
enclosing = symbols.length;
|
|
211
|
+
symbols.push({
|
|
212
|
+
name: described.name,
|
|
213
|
+
qualifiedName,
|
|
214
|
+
kind: String(node.kind()), startLine: start.line + 1,
|
|
215
|
+
endLine: end.line + (end.column === 0 ? 0 : 1),
|
|
216
|
+
startColumn: start.column + 1, endColumn: end.column + 1, parent,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const children = node.children();
|
|
220
|
+
for (let index = children.length - 1; index >= 0; index--) stack.push({ node: children[index], parent: enclosing });
|
|
221
|
+
// Give cancellation a chance during large traversals.
|
|
222
|
+
if (visited % 2048 === 0) {
|
|
223
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
224
|
+
signal?.throwIfAborted();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { symbols, errorLine };
|
|
228
|
+
}
|
package/src/tool.ts
CHANGED
|
@@ -31,9 +31,7 @@ import {
|
|
|
31
31
|
stat as fsStat,
|
|
32
32
|
writeFile as fsWriteFile,
|
|
33
33
|
} from "node:fs/promises";
|
|
34
|
-
import {
|
|
35
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
36
|
-
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { resolveToolPath as resolveToCwd } from "./paths.ts";
|
|
37
35
|
import { type Static, Type } from "typebox";
|
|
38
36
|
import { analyzeEdits, applyAnalysis, fuzzyFindText, normalizeEdits, type EditOp } from "./apply.ts";
|
|
39
37
|
import {
|
|
@@ -72,31 +70,6 @@ export const betterEditSchema = Type.Object({
|
|
|
72
70
|
|
|
73
71
|
export type BetterEditInput = Static<typeof betterEditSchema>;
|
|
74
72
|
|
|
75
|
-
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
76
|
-
|
|
77
|
-
/** Match pi's built-in path normalization for tool arguments. */
|
|
78
|
-
function normalizeToolPath(input: string): string {
|
|
79
|
-
let path = input.replace(UNICODE_SPACES, " ");
|
|
80
|
-
if (path.startsWith("@")) path = path.slice(1);
|
|
81
|
-
|
|
82
|
-
if (process.platform === "win32" && path.startsWith("/") && !path.startsWith("//") && !path.includes("\\")) {
|
|
83
|
-
const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
84
|
-
if (match) path = `${match[1].toUpperCase()}:\\${match[2]?.replaceAll("/", "\\") ?? ""}`;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (path === "~") return homedir();
|
|
88
|
-
if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) {
|
|
89
|
-
return join(homedir(), path.slice(2));
|
|
90
|
-
}
|
|
91
|
-
if (/^file:\/\//.test(path)) return fileURLToPath(path);
|
|
92
|
-
return path;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function resolveToCwd(filePath: string, cwd: string): string {
|
|
96
|
-
const path = normalizeToolPath(filePath);
|
|
97
|
-
return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
73
|
function isSingleEditInput(value: unknown): value is { oldText: string; newText: string } {
|
|
101
74
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
102
75
|
return false;
|
|
@@ -391,7 +364,8 @@ export function registerBetterEditTool(pi: ExtensionAPI, options: BetterEditExec
|
|
|
391
364
|
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls.",
|
|
392
365
|
"In edit, each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits; merge nearby changes into one edit.",
|
|
393
366
|
"Keep edit edits[].oldText as small as possible while still being unique in the file; do not pad with large unchanged regions.",
|
|
394
|
-
"When edit safely auto-disambiguates repeated text from the latest verified stored-context read, its success message lists bounded remaining candidates for an optional follow-up edit.",
|
|
367
|
+
"When edit safely auto-disambiguates repeated text from the latest verified stored-context read or read_symbol result, its success message lists bounded remaining candidates for an optional follow-up edit.",
|
|
368
|
+
"An edit matching failure applies none of the batch. Fix the reported entries and resubmit the complete batch, not only the failed replacement. Use suggested read_symbol calls when you need the whole enclosing function.",
|
|
395
369
|
"When edit fails, reuse only a fenced snippet explicitly marked retryable. If edit reports low confidence, competing candidates, omitted output, stale evidence, or a write that may have modified the file, read the referenced file/range before retrying.",
|
|
396
370
|
],
|
|
397
371
|
parameters: betterEditSchema,
|