@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/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
- if (!needle) return 0;
107
- return fuzzyContent.split(normalizeForFuzzyMatch(needle)).length - 1;
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` in `haystack` (literal string search). */
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
  }