@khanhicetea/pi-better-tool 0.2.1 → 0.2.3
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 +116 -69
- package/package.json +34 -5
- package/src/apply.ts +22 -12
- package/src/diagnostics.ts +159 -26
- package/src/index.ts +15 -5
- package/src/paths.ts +19 -0
- package/src/read-evidence.ts +74 -33
- package/src/read-symbol.ts +214 -0
- package/src/similarity.ts +140 -63
- package/src/source-file.ts +60 -0
- package/src/symbols.ts +228 -0
- package/src/text.ts +36 -7
- package/src/tool.ts +178 -106
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/text.ts
CHANGED
|
@@ -71,6 +71,17 @@ export function splitLinesWithEndings(content: string): string[] {
|
|
|
71
71
|
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Split every logical line while retaining endings. Unlike
|
|
76
|
+
* splitLinesWithEndings(), this includes the zero-length logical line after a
|
|
77
|
+
* terminal newline. That distinction is needed when fuzzy normalization turns
|
|
78
|
+
* a whitespace-only final line into an empty line.
|
|
79
|
+
*/
|
|
80
|
+
export function splitLogicalLinesWithEndings(content: string): string[] {
|
|
81
|
+
const parts = content.split("\n");
|
|
82
|
+
return parts.map((part, index) => (index < parts.length - 1 ? `${part}\n` : part));
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
export function getLineSpans(content: string): LineSpan[] {
|
|
75
86
|
let offset = 0;
|
|
76
87
|
return splitLinesWithEndings(content).map((line) => {
|
|
@@ -80,6 +91,16 @@ export function getLineSpans(content: string): LineSpan[] {
|
|
|
80
91
|
});
|
|
81
92
|
}
|
|
82
93
|
|
|
94
|
+
/** Logical spans, including a possible zero-length final line. */
|
|
95
|
+
export function getLogicalLineSpans(content: string): LineSpan[] {
|
|
96
|
+
let offset = 0;
|
|
97
|
+
return splitLogicalLinesWithEndings(content).map((line) => {
|
|
98
|
+
const span = { start: offset, end: offset + line.length };
|
|
99
|
+
offset = span.end;
|
|
100
|
+
return span;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
83
104
|
/** 0-based index of the line containing `offset` (clamped to the last line). */
|
|
84
105
|
export function lineAt(spans: LineSpan[], offset: number): number {
|
|
85
106
|
let lo = 0;
|
|
@@ -102,17 +123,25 @@ export function lineAt(spans: LineSpan[], offset: number): number {
|
|
|
102
123
|
* same normalization the matching engine uses for its uniqueness check.
|
|
103
124
|
* `fuzzyContent` must already be `normalizeForFuzzyMatch`-ed.
|
|
104
125
|
*/
|
|
105
|
-
export function countFuzzyOccurrences(fuzzyContent: string, needle: string): number {
|
|
106
|
-
|
|
107
|
-
|
|
126
|
+
export function countFuzzyOccurrences(fuzzyContent: string, needle: string, stopAfter = Number.POSITIVE_INFINITY): number {
|
|
127
|
+
const normalizedNeedle = normalizeForFuzzyMatch(needle);
|
|
128
|
+
if (!normalizedNeedle) return 0;
|
|
129
|
+
let count = 0;
|
|
130
|
+
let index = fuzzyContent.indexOf(normalizedNeedle);
|
|
131
|
+
while (index !== -1) {
|
|
132
|
+
count++;
|
|
133
|
+
if (count >= stopAfter) return count;
|
|
134
|
+
index = fuzzyContent.indexOf(normalizedNeedle, index + normalizedNeedle.length);
|
|
135
|
+
}
|
|
136
|
+
return count;
|
|
108
137
|
}
|
|
109
138
|
|
|
110
|
-
/** All start offsets of `needle
|
|
111
|
-
export function findAllOccurrences(haystack: string, needle: string): number[] {
|
|
139
|
+
/** All non-overlapping start offsets of `needle`, optionally bounded. */
|
|
140
|
+
export function findAllOccurrences(haystack: string, needle: string, limit = Number.POSITIVE_INFINITY): number[] {
|
|
112
141
|
const offsets: number[] = [];
|
|
113
|
-
if (!needle) return offsets;
|
|
142
|
+
if (!needle || limit <= 0) return offsets;
|
|
114
143
|
let idx = haystack.indexOf(needle);
|
|
115
|
-
while (idx !== -1) {
|
|
144
|
+
while (idx !== -1 && offsets.length < limit) {
|
|
116
145
|
offsets.push(idx);
|
|
117
146
|
idx = haystack.indexOf(needle, idx + needle.length);
|
|
118
147
|
}
|