@mrclrchtr/supi-tree-sitter 6.3.0 → 7.0.0
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/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
- package/node_modules/@mrclrchtr/supi-core/README.md +8 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
- package/package.json +3 -3
- package/src/provider/tree-sitter-provider.ts +2 -0
- package/src/tool/callee-display-name.ts +165 -0
- package/src/tool/callees.ts +67 -9
- package/src/types.ts +2 -0
|
@@ -234,7 +234,14 @@ export type CalleeDepth = "direct" | "deep";
|
|
|
234
234
|
*/
|
|
235
235
|
export interface CalleesData {
|
|
236
236
|
enclosingScope: { name: string; startLine: number; endLine: number };
|
|
237
|
-
|
|
237
|
+
/** Distinct call sites with 1-based UTF-16 start coordinates. */
|
|
238
|
+
callees: Array<{
|
|
239
|
+
name: string;
|
|
240
|
+
/** Optional syntax-shortened label for presentation; does not replace `name`. */
|
|
241
|
+
displayName?: string;
|
|
242
|
+
startLine: number;
|
|
243
|
+
startCharacter: number;
|
|
244
|
+
}>;
|
|
238
245
|
depth: CalleeDepth;
|
|
239
246
|
}
|
|
240
247
|
|
|
@@ -21,6 +21,7 @@ pnpm add @mrclrchtr/supi-core
|
|
|
21
21
|
## Package surfaces
|
|
22
22
|
|
|
23
23
|
- `@mrclrchtr/supi-core/api` — reusable helpers for other packages and extensions
|
|
24
|
+
- `@mrclrchtr/supi-core/llm` — PI-owned direct model requests and JSON helpers
|
|
24
25
|
- `@mrclrchtr/supi-core/report` — shared text/report rendering helpers for TUI and plain-text summaries
|
|
25
26
|
|
|
26
27
|
## What you get from the API
|
|
@@ -48,6 +49,13 @@ Config file locations:
|
|
|
48
49
|
|
|
49
50
|
- `wrapExtensionContext()` — wrap injected text in SuPi's `<extension-context>` tag
|
|
50
51
|
|
|
52
|
+
### Model requests
|
|
53
|
+
|
|
54
|
+
- `completeModelRequest(ctx, model, context, options)` — complete through PI's model registry with stable feature affinity. PI owns auth and endpoint resolution.
|
|
55
|
+
- `callWithJsonResponse()` — retry a registry request, extract JSON, and validate it with TypeBox.
|
|
56
|
+
|
|
57
|
+
`completeModelRequest()` requires a stable `affinityScope`. It keeps cache retention defaults, does not include prompt content in the affinity ID, and adds OpenCode headers only when the provider or exact model endpoint matches OpenCode. Pass `maxTokens: model.maxTokens` when a caller needs the model's declared output cap without using PI private modules.
|
|
58
|
+
|
|
51
59
|
### Shared registries
|
|
52
60
|
|
|
53
61
|
- context-provider registry for `/supi-context`
|
|
@@ -2,17 +2,24 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Extensions register pre-styled text chunks with a placement hint
|
|
4
4
|
// ("stats" for the metrics line, "status" for the extension status line).
|
|
5
|
-
// The custom footer in supi-extras
|
|
6
|
-
//
|
|
5
|
+
// The custom footer in supi-extras reads these contributions and renders them
|
|
6
|
+
// alongside the built-in metrics. Extensions can use PI's status API as a
|
|
7
|
+
// fallback when the custom footer is not installed.
|
|
7
8
|
|
|
8
9
|
import { createRegistry } from "./registry-utils.ts";
|
|
9
10
|
|
|
11
|
+
/** Event emitted when a dynamic footer contribution needs a new render. */
|
|
12
|
+
export const FOOTER_INVALIDATE_EVENT = "supi:footer:invalidate";
|
|
13
|
+
|
|
10
14
|
/** Where the contribution should appear in the footer. */
|
|
11
15
|
export type FooterPlacement = "stats" | "stats-end" | "status";
|
|
12
16
|
|
|
13
17
|
/** A single footer contribution registered by an extension. */
|
|
14
18
|
export interface FooterContribution {
|
|
15
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Unique key for this contribution. Re-registering with the same key replaces it.
|
|
21
|
+
* A same-key Pi status is treated as this contribution's built-in-footer fallback.
|
|
22
|
+
*/
|
|
16
23
|
key: string;
|
|
17
24
|
/** Which footer line this belongs on. */
|
|
18
25
|
placement: FooterPlacement;
|
|
@@ -15,6 +15,8 @@ export * from "./debug.ts";
|
|
|
15
15
|
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
16
16
|
export * from "./footer-registry.ts";
|
|
17
17
|
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
18
|
+
export * from "./llm.ts";
|
|
19
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
18
20
|
export * from "./model-selection.ts";
|
|
19
21
|
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
20
22
|
export * from "./path.ts";
|
|
@@ -1,12 +1,131 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
Api,
|
|
4
|
+
AssistantMessage,
|
|
5
|
+
Context,
|
|
6
|
+
Model,
|
|
7
|
+
ModelsApiStreamOptions,
|
|
8
|
+
ProviderHeaders,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
2
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
11
|
import type { TSchema } from "typebox";
|
|
4
12
|
import { Value } from "typebox/value";
|
|
5
13
|
|
|
6
14
|
// Shared LLM utilities for SuPi extensions.
|
|
7
15
|
//
|
|
8
|
-
// Provides retry logic, structured LLM call helpers,
|
|
9
|
-
// common patterns for extensions that interact with AI models.
|
|
16
|
+
// Provides PI-owned model requests, retry logic, structured LLM call helpers,
|
|
17
|
+
// and other common patterns for extensions that interact with AI models.
|
|
18
|
+
|
|
19
|
+
const MODEL_REQUEST_NAMESPACE = "supi-direct-model-request-v1";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Options for {@link completeModelRequest}.
|
|
23
|
+
*
|
|
24
|
+
* Authentication, provider environment, and session identity stay under PI
|
|
25
|
+
* control. The feature supplies a stable scope for its prompt stream.
|
|
26
|
+
*/
|
|
27
|
+
export type CompleteModelRequestOptions<TApi extends Api = Api> = Omit<
|
|
28
|
+
ModelsApiStreamOptions<TApi>,
|
|
29
|
+
"apiKey" | "env" | "sessionId"
|
|
30
|
+
> & {
|
|
31
|
+
/** Stable feature scope. Do not include prompt, turn, or retry data. */
|
|
32
|
+
affinityScope: string;
|
|
33
|
+
/** PI owns these fields, including for APIs with open-ended option types. */
|
|
34
|
+
apiKey?: never;
|
|
35
|
+
env?: never;
|
|
36
|
+
sessionId?: never;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function createModelRequestAffinityId(
|
|
40
|
+
sessionId: string,
|
|
41
|
+
affinityScope: string,
|
|
42
|
+
model: Model<Api>,
|
|
43
|
+
): string {
|
|
44
|
+
const material = JSON.stringify([
|
|
45
|
+
MODEL_REQUEST_NAMESPACE,
|
|
46
|
+
sessionId,
|
|
47
|
+
affinityScope,
|
|
48
|
+
model.provider,
|
|
49
|
+
model.id,
|
|
50
|
+
]);
|
|
51
|
+
const digest = createHash("sha256").update(material, "utf8").digest("hex");
|
|
52
|
+
return `supi-${digest.slice(0, 56)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isOpenCodeModel(model: Model<Api>): boolean {
|
|
56
|
+
if (model.provider === "opencode" || model.provider === "opencode-go") return true;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return new URL(model.baseUrl).hostname === "opencode.ai";
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasHeader(headers: ProviderHeaders, name: string): boolean {
|
|
66
|
+
const lowerName = name.toLowerCase();
|
|
67
|
+
return Object.keys(headers).some((headerName) => headerName.toLowerCase() === lowerName);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function addOpenCodeDefaultHeaders(
|
|
71
|
+
model: Model<Api>,
|
|
72
|
+
affinityId: string,
|
|
73
|
+
headers: ProviderHeaders,
|
|
74
|
+
): ProviderHeaders {
|
|
75
|
+
if (!isOpenCodeModel(model)) return headers;
|
|
76
|
+
|
|
77
|
+
const result = { ...headers };
|
|
78
|
+
if (!hasHeader(result, "x-opencode-session")) {
|
|
79
|
+
result["x-opencode-session"] = affinityId;
|
|
80
|
+
}
|
|
81
|
+
if (!hasHeader(result, "x-opencode-client")) {
|
|
82
|
+
result["x-opencode-client"] = "pi";
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Complete a direct request through PI's model registry.
|
|
89
|
+
*
|
|
90
|
+
* PI resolves authentication, provider headers, environment, and the
|
|
91
|
+
* effective endpoint. This helper adds one stable opaque session identity for
|
|
92
|
+
* the feature prompt stream and applies the OpenCode compatibility defaults.
|
|
93
|
+
* It does not retry, validate output, or present errors.
|
|
94
|
+
*
|
|
95
|
+
* When `maxTokens` is omitted, the underlying registry receives no explicit
|
|
96
|
+
* output cap. A caller that needs the selected model's declared cap can pass
|
|
97
|
+
* `maxTokens: model.maxTokens` without importing PI internals.
|
|
98
|
+
*/
|
|
99
|
+
export async function completeModelRequest<TApi extends Api>(
|
|
100
|
+
ctx: ExtensionContext,
|
|
101
|
+
model: Model<TApi>,
|
|
102
|
+
context: Context,
|
|
103
|
+
options: CompleteModelRequestOptions<TApi>,
|
|
104
|
+
): Promise<AssistantMessage> {
|
|
105
|
+
const { affinityScope, transformHeaders: callerTransformHeaders, ...requestOptions } = options;
|
|
106
|
+
const safeRequestOptions = { ...requestOptions };
|
|
107
|
+
delete safeRequestOptions.apiKey;
|
|
108
|
+
delete safeRequestOptions.env;
|
|
109
|
+
delete safeRequestOptions.sessionId;
|
|
110
|
+
|
|
111
|
+
const affinityId = createModelRequestAffinityId(
|
|
112
|
+
ctx.sessionManager.getSessionId(),
|
|
113
|
+
affinityScope,
|
|
114
|
+
model,
|
|
115
|
+
);
|
|
116
|
+
const transformHeaders = async (headers: ProviderHeaders): Promise<ProviderHeaders> => {
|
|
117
|
+
const transformed = callerTransformHeaders ? await callerTransformHeaders(headers) : headers;
|
|
118
|
+
return addOpenCodeDefaultHeaders(model, affinityId, transformed);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Restore PI's conditional provider-option type after removing owned fields.
|
|
122
|
+
return ctx.modelRegistry.complete(model, context, {
|
|
123
|
+
...safeRequestOptions,
|
|
124
|
+
signal: safeRequestOptions.signal ?? ctx.signal,
|
|
125
|
+
sessionId: affinityId,
|
|
126
|
+
transformHeaders,
|
|
127
|
+
} as unknown as ModelsApiStreamOptions<TApi>);
|
|
128
|
+
}
|
|
10
129
|
|
|
11
130
|
/**
|
|
12
131
|
* Options for {@link withRetry}.
|
|
@@ -122,6 +241,8 @@ export function extractJsonFromResponse<T extends TSchema>(
|
|
|
122
241
|
export interface CallWithJsonResponseOptions {
|
|
123
242
|
/** The prompt to send to the LLM. */
|
|
124
243
|
prompt: string;
|
|
244
|
+
/** Stable feature scope for request affinity. Do not include prompt or retry data. */
|
|
245
|
+
affinityScope: string;
|
|
125
246
|
/** Optional data context appended to the prompt. */
|
|
126
247
|
dataContext?: string;
|
|
127
248
|
/** Maximum tokens for the response. Default: 4096 */
|
|
@@ -135,8 +256,9 @@ export interface CallWithJsonResponseOptions {
|
|
|
135
256
|
/**
|
|
136
257
|
* Call the LLM with a prompt and validate the JSON response against a TypeBox schema.
|
|
137
258
|
*
|
|
138
|
-
* Handles model resolution,
|
|
139
|
-
*
|
|
259
|
+
* Handles model resolution, retry via `withRetry`, text extraction, JSON
|
|
260
|
+
* matching, and TypeBox validation. The request itself stays under PI
|
|
261
|
+
* registry authority through {@link completeModelRequest}.
|
|
140
262
|
*
|
|
141
263
|
* Returns `null` when:
|
|
142
264
|
* - No model is available
|
|
@@ -145,7 +267,7 @@ export interface CallWithJsonResponseOptions {
|
|
|
145
267
|
* - JSON doesn't match the schema
|
|
146
268
|
* - The request is aborted
|
|
147
269
|
*
|
|
148
|
-
* @param ctx - The extension context for model
|
|
270
|
+
* @param ctx - The extension context for model selection and PI registry access.
|
|
149
271
|
* @param options - Call options including prompt, schema, and retry config.
|
|
150
272
|
* @param schema - TypeBox schema to validate the JSON response against.
|
|
151
273
|
* @returns The parsed and validated result, or `null`.
|
|
@@ -155,14 +277,18 @@ export async function callWithJsonResponse<T extends TSchema>(
|
|
|
155
277
|
options: CallWithJsonResponseOptions,
|
|
156
278
|
schema: T,
|
|
157
279
|
): Promise<{ parsed: import("typebox").Static<T> } | null> {
|
|
158
|
-
const {
|
|
280
|
+
const {
|
|
281
|
+
prompt,
|
|
282
|
+
affinityScope,
|
|
283
|
+
dataContext,
|
|
284
|
+
maxTokens = 4096,
|
|
285
|
+
systemPrompt = "",
|
|
286
|
+
retries = 2,
|
|
287
|
+
} = options;
|
|
159
288
|
|
|
160
289
|
const model = ctx.model ?? ctx.modelRegistry.getAvailable()[0] ?? null;
|
|
161
290
|
if (!model) return null;
|
|
162
291
|
|
|
163
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
164
|
-
if (!auth.ok || !auth.apiKey) return null;
|
|
165
|
-
|
|
166
292
|
const fullPrompt = dataContext
|
|
167
293
|
? `${prompt}
|
|
168
294
|
|
|
@@ -171,8 +297,9 @@ ${dataContext}`
|
|
|
171
297
|
: prompt;
|
|
172
298
|
|
|
173
299
|
const response = await withRetry(
|
|
174
|
-
async () =>
|
|
175
|
-
|
|
300
|
+
async () =>
|
|
301
|
+
completeModelRequest(
|
|
302
|
+
ctx,
|
|
176
303
|
model,
|
|
177
304
|
{
|
|
178
305
|
systemPrompt,
|
|
@@ -185,13 +312,11 @@ ${dataContext}`
|
|
|
185
312
|
],
|
|
186
313
|
},
|
|
187
314
|
{
|
|
188
|
-
|
|
189
|
-
headers: auth.headers,
|
|
315
|
+
affinityScope,
|
|
190
316
|
signal: ctx.signal,
|
|
191
317
|
maxTokens,
|
|
192
318
|
},
|
|
193
|
-
)
|
|
194
|
-
},
|
|
319
|
+
),
|
|
195
320
|
{ retries, baseDelayMs: 1000, signal: ctx.signal },
|
|
196
321
|
);
|
|
197
322
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-tree-sitter",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Structural AST analysis for SuPi code intelligence",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"jiti": "^2.7.0",
|
|
38
38
|
"web-tree-sitter": "^0.27.0",
|
|
39
|
-
"@mrclrchtr/supi-code-runtime": "
|
|
40
|
-
"@mrclrchtr/supi-core": "
|
|
39
|
+
"@mrclrchtr/supi-code-runtime": "7.0.0",
|
|
40
|
+
"@mrclrchtr/supi-core": "7.0.0"
|
|
41
41
|
},
|
|
42
42
|
"bundledDependencies": [
|
|
43
43
|
"@mrclrchtr/supi-code-runtime",
|
|
@@ -145,7 +145,9 @@ function mapCalleesAtResult(result: CalleesAtResult): CalleesData {
|
|
|
145
145
|
},
|
|
146
146
|
callees: result.callees.map((c) => ({
|
|
147
147
|
name: c.name,
|
|
148
|
+
...(c.displayName === undefined ? {} : { displayName: c.displayName }),
|
|
148
149
|
startLine: c.range.startLine,
|
|
150
|
+
startCharacter: c.range.startCharacter,
|
|
149
151
|
})),
|
|
150
152
|
depth: result.depth,
|
|
151
153
|
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { SyntaxNodeLike } from "../syntax-node.ts";
|
|
2
|
+
import type { GrammarId } from "../types.ts";
|
|
3
|
+
import { normalizeCallName } from "./call-name.ts";
|
|
4
|
+
|
|
5
|
+
type CalleeSyntaxChild = SyntaxNodeLike & {
|
|
6
|
+
readonly isExtra: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type CalleeSyntaxNode = SyntaxNodeLike & {
|
|
10
|
+
readonly namedChildren: CalleeSyntaxChild[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type SourcePoint = { row: number; column: number };
|
|
14
|
+
|
|
15
|
+
type TextReplacement = { start: number; end: number };
|
|
16
|
+
|
|
17
|
+
type CalleeDisplayNameOptions = {
|
|
18
|
+
node: SyntaxNodeLike;
|
|
19
|
+
grammarId: GrammarId;
|
|
20
|
+
nodeType: string;
|
|
21
|
+
source: string;
|
|
22
|
+
sourceLineStarts: readonly number[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const CALL_NODE_TYPES = new Set([
|
|
26
|
+
"call_expression",
|
|
27
|
+
"call",
|
|
28
|
+
"method_invocation",
|
|
29
|
+
"new_expression",
|
|
30
|
+
"object_creation_expression",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const ARGUMENT_LIST_TYPES = new Set(["arguments", "argument_list", "value_arguments"]);
|
|
34
|
+
|
|
35
|
+
const RECEIVER_FIELDS = [
|
|
36
|
+
"function",
|
|
37
|
+
"constructor",
|
|
38
|
+
"object",
|
|
39
|
+
"receiver",
|
|
40
|
+
"operand",
|
|
41
|
+
"value",
|
|
42
|
+
"lhs",
|
|
43
|
+
"argument",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
const RECEIVER_WRAPPER_TYPES = new Set(["navigation_expression", "parenthesized_expression"]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a syntax-shortened label for one structural callee capture.
|
|
50
|
+
*
|
|
51
|
+
* The full normalized name remains the identity-bearing value. This helper
|
|
52
|
+
* only replaces argument-list nodes on the receiver chain with `(…)`; it does
|
|
53
|
+
* not inspect arbitrary expression text to find parentheses.
|
|
54
|
+
*/
|
|
55
|
+
export function createCalleeDisplayName({
|
|
56
|
+
node,
|
|
57
|
+
grammarId,
|
|
58
|
+
nodeType,
|
|
59
|
+
source,
|
|
60
|
+
sourceLineStarts,
|
|
61
|
+
}: CalleeDisplayNameOptions): string | undefined {
|
|
62
|
+
const replacements: TextReplacement[] = [];
|
|
63
|
+
collectReceiverArgumentLists(node, replacements, sourceLineStarts);
|
|
64
|
+
const selected = selectNonOverlappingReplacements(replacements);
|
|
65
|
+
const start = sourceOffset(node.startPosition, sourceLineStarts);
|
|
66
|
+
const end = sourceOffset(node.endPosition, sourceLineStarts);
|
|
67
|
+
if (start < 0 || end < start || end > source.length) return undefined;
|
|
68
|
+
|
|
69
|
+
let rendered = source.slice(start, end);
|
|
70
|
+
for (let index = selected.length - 1; index >= 0; index -= 1) {
|
|
71
|
+
const replacement = selected[index];
|
|
72
|
+
if (!replacement || replacement.start < start || replacement.end > end) continue;
|
|
73
|
+
const relativeStart = replacement.start - start;
|
|
74
|
+
const relativeEnd = replacement.end - start;
|
|
75
|
+
rendered = `${rendered.slice(0, relativeStart)}(…)${rendered.slice(relativeEnd)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const displayName = normalizeCallName(rendered, grammarId, nodeType);
|
|
79
|
+
return displayName.length > 0 ? displayName : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function collectReceiverArgumentLists(
|
|
83
|
+
node: SyntaxNodeLike,
|
|
84
|
+
replacements: TextReplacement[],
|
|
85
|
+
sourceLineStarts: readonly number[],
|
|
86
|
+
): void {
|
|
87
|
+
const argumentList = findArgumentList(node);
|
|
88
|
+
if (argumentList && hasNamedChildren(argumentList)) {
|
|
89
|
+
const start = sourceOffset(argumentList.startPosition, sourceLineStarts);
|
|
90
|
+
const end = sourceOffset(argumentList.endPosition, sourceLineStarts);
|
|
91
|
+
if (start >= 0 && end >= start) replacements.push({ start, end });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const receiver of receiverNodes(node)) {
|
|
95
|
+
collectReceiverArgumentLists(receiver, replacements, sourceLineStarts);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function findArgumentList(node: SyntaxNodeLike): SyntaxNodeLike | null {
|
|
100
|
+
if (!CALL_NODE_TYPES.has(node.type)) return null;
|
|
101
|
+
|
|
102
|
+
const field = node.childForFieldName("arguments");
|
|
103
|
+
if (field) return field;
|
|
104
|
+
|
|
105
|
+
const directArgumentList = node.children.find((child) => ARGUMENT_LIST_TYPES.has(child.type));
|
|
106
|
+
if (directArgumentList) return directArgumentList;
|
|
107
|
+
|
|
108
|
+
if (node.type !== "call_expression") return null;
|
|
109
|
+
const callSuffix = node.children.find((child) => child.type === "call_suffix");
|
|
110
|
+
return callSuffix?.children.find((child) => child.type === "value_arguments") ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function receiverNodes(node: SyntaxNodeLike): SyntaxNodeLike[] {
|
|
114
|
+
for (const fieldName of RECEIVER_FIELDS) {
|
|
115
|
+
const field = node.childForFieldName(fieldName);
|
|
116
|
+
if (field) return [field];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (node.type === "call_expression") {
|
|
120
|
+
return namedChildren(node).filter(
|
|
121
|
+
(child) => child.type !== "call_suffix" && !ARGUMENT_LIST_TYPES.has(child.type),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!RECEIVER_WRAPPER_TYPES.has(node.type)) return [];
|
|
126
|
+
return namedChildren(node).filter((child) => !ARGUMENT_LIST_TYPES.has(child.type));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function namedChildren(node: SyntaxNodeLike): CalleeSyntaxChild[] {
|
|
130
|
+
return (node as CalleeSyntaxNode).namedChildren ?? [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hasNamedChildren(node: SyntaxNodeLike): boolean {
|
|
134
|
+
return namedChildren(node).some((child) => !child.isExtra);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function selectNonOverlappingReplacements(
|
|
138
|
+
replacements: readonly TextReplacement[],
|
|
139
|
+
): TextReplacement[] {
|
|
140
|
+
const selected: TextReplacement[] = [];
|
|
141
|
+
const ordered = [...replacements].sort(
|
|
142
|
+
(left, right) => left.start - right.start || right.end - left.end,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
for (const replacement of ordered) {
|
|
146
|
+
const previous = selected[selected.length - 1];
|
|
147
|
+
if (previous && replacement.start < previous.end) continue;
|
|
148
|
+
selected.push(replacement);
|
|
149
|
+
}
|
|
150
|
+
return selected;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Build source offsets for Tree-sitter rows without decoding expression text. */
|
|
154
|
+
export function createSourceLineStarts(source: string): number[] {
|
|
155
|
+
const lineStarts = [0];
|
|
156
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
157
|
+
if (source[index] === "\n") lineStarts.push(index + 1);
|
|
158
|
+
}
|
|
159
|
+
return lineStarts;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function sourceOffset(point: SourcePoint, lineStarts: readonly number[]): number {
|
|
163
|
+
const lineStart = lineStarts[point.row];
|
|
164
|
+
return lineStart === undefined ? -1 : lineStart + point.column;
|
|
165
|
+
}
|
package/src/tool/callees.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
// biome-ignore-all lint/style/noExcessiveLinesPerFile: callee scope filtering and syntax display mapping stay together.
|
|
1
2
|
// Structural callee extraction — enclosing-scope lookup with per-language queries.
|
|
2
3
|
|
|
3
4
|
import type { CodeRequestControl } from "@mrclrchtr/supi-code-runtime/api";
|
|
4
5
|
import { validatePublicPositionBounds } from "../coordinates.ts";
|
|
5
6
|
import { detectGrammar } from "../language.ts";
|
|
7
|
+
import type { SyntaxNodeLike } from "../syntax-node.ts";
|
|
6
8
|
import type { GrammarId, SourceRange, TreeSitterResult } from "../types.ts";
|
|
7
9
|
import { queryParsedFile, type TreeSitterRuntime } from "../worker/runtime.ts";
|
|
8
10
|
import { normalizeCallName } from "./call-name.ts";
|
|
11
|
+
import { createCalleeDisplayName, createSourceLineStarts } from "./callee-display-name.ts";
|
|
9
12
|
import { extractScopeName } from "./scope.ts";
|
|
10
13
|
|
|
11
14
|
/** Result shape returned by lookupCalleesAt. */
|
|
@@ -16,6 +19,7 @@ export interface CalleesAtResult {
|
|
|
16
19
|
};
|
|
17
20
|
callees: Array<{
|
|
18
21
|
name: string;
|
|
22
|
+
displayName?: string;
|
|
19
23
|
range: SourceRange;
|
|
20
24
|
}>;
|
|
21
25
|
depth: "direct" | "deep";
|
|
@@ -138,7 +142,7 @@ function findEnclosingScope(
|
|
|
138
142
|
* 3. Runs a grammar-specific callee query.
|
|
139
143
|
* 4. Filters to captures within that enclosing scope.
|
|
140
144
|
* 5. Excludes nested function/method/callback scopes that do not contain the anchor.
|
|
141
|
-
* 6. Deduplicates
|
|
145
|
+
* 6. Deduplicates identical capture ranges and returns captures in source order.
|
|
142
146
|
*/
|
|
143
147
|
// biome-ignore lint/complexity/useMaxParams: provider function needs runtime + coordinates + depth
|
|
144
148
|
export async function lookupCalleesAt(
|
|
@@ -212,6 +216,7 @@ export async function lookupCalleesAt(
|
|
|
212
216
|
scopes,
|
|
213
217
|
tsPoint,
|
|
214
218
|
depth,
|
|
219
|
+
source,
|
|
215
220
|
);
|
|
216
221
|
|
|
217
222
|
const enclosingRange = nodeToSourceRange(enclosingNode);
|
|
@@ -318,7 +323,8 @@ function filterCalleeCaptures(
|
|
|
318
323
|
scopeTypes: ReadonlySet<string>,
|
|
319
324
|
anchor: { row: number; column: number },
|
|
320
325
|
depth: "direct" | "deep" = "direct",
|
|
321
|
-
|
|
326
|
+
source: string,
|
|
327
|
+
): Array<{ name: string; displayName?: string; range: SourceRange }> {
|
|
322
328
|
const excludeRanges: Array<{
|
|
323
329
|
startRow: number;
|
|
324
330
|
startColumn: number;
|
|
@@ -329,8 +335,11 @@ function filterCalleeCaptures(
|
|
|
329
335
|
collectInnerScopes(enclosingNode, scopeTypes, anchor, excludeRanges);
|
|
330
336
|
}
|
|
331
337
|
|
|
332
|
-
const
|
|
333
|
-
const callees: Array<{ name: string; range: SourceRange }> = [];
|
|
338
|
+
const seenRanges = new Set<string>();
|
|
339
|
+
const callees: Array<{ name: string; displayName?: string; range: SourceRange }> = [];
|
|
340
|
+
const sourceLineStarts = createSourceLineStarts(source);
|
|
341
|
+
const syntaxNodes = new Map<string, SyntaxNodeLike>();
|
|
342
|
+
indexSyntaxNodes(enclosingNode as NamedSyntaxNode, syntaxNodes);
|
|
334
343
|
|
|
335
344
|
const enclosingStartRow = enclosingNode.startPosition.row;
|
|
336
345
|
const enclosingEndRow = enclosingNode.endPosition.row;
|
|
@@ -366,17 +375,66 @@ function filterCalleeCaptures(
|
|
|
366
375
|
}
|
|
367
376
|
|
|
368
377
|
const name = normalizeCallName(capture.text, grammarId, capture.nodeType);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
378
|
+
const rangeKey = sourceRangeKey(capture.range);
|
|
379
|
+
if (name.length === 0 || seenRanges.has(rangeKey)) continue;
|
|
380
|
+
seenRanges.add(rangeKey);
|
|
381
|
+
|
|
382
|
+
const syntaxNode = syntaxNodes.get(captureNodeKey(capture));
|
|
383
|
+
const displayName = syntaxNode
|
|
384
|
+
? createCalleeDisplayName({
|
|
385
|
+
node: syntaxNode,
|
|
386
|
+
grammarId,
|
|
387
|
+
nodeType: capture.nodeType,
|
|
388
|
+
source,
|
|
389
|
+
sourceLineStarts,
|
|
390
|
+
})
|
|
391
|
+
: undefined;
|
|
392
|
+
callees.push({
|
|
393
|
+
name,
|
|
394
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
395
|
+
range: capture.range,
|
|
396
|
+
});
|
|
373
397
|
}
|
|
374
398
|
|
|
375
|
-
return callees;
|
|
399
|
+
return callees.sort((left, right) => compareSourceRanges(left.range, right.range));
|
|
376
400
|
}
|
|
377
401
|
|
|
378
402
|
// ── Internal helpers ──────────────────────────────────────────────────
|
|
379
403
|
|
|
404
|
+
function sourceRangeKey(range: SourceRange): string {
|
|
405
|
+
return `${range.startLine}:${range.startCharacter}:${range.endLine}:${range.endCharacter}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
type NamedSyntaxNode = SyntaxNodeLike & { namedChildren: NamedSyntaxNode[] };
|
|
409
|
+
|
|
410
|
+
/** Query captures use named nodes. Skip anonymous tokens and avoid recursive indexing. */
|
|
411
|
+
function indexSyntaxNodes(node: NamedSyntaxNode, index: Map<string, SyntaxNodeLike>): void {
|
|
412
|
+
const pending = [node];
|
|
413
|
+
while (pending.length > 0) {
|
|
414
|
+
const current = pending.pop();
|
|
415
|
+
if (!current) break;
|
|
416
|
+
index.set(syntaxNodeKey(current), current);
|
|
417
|
+
for (const child of current.namedChildren) pending.push(child);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function syntaxNodeKey(node: SyntaxNodeLike): string {
|
|
422
|
+
return `${node.type}:${node.startPosition.row}:${node.startPosition.column}:${node.endPosition.row}:${node.endPosition.column}`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function captureNodeKey(capture: { nodeType: string; range: SourceRange }): string {
|
|
426
|
+
return `${capture.nodeType}:${capture.range.startLine - 1}:${capture.range.startCharacter - 1}:${capture.range.endLine - 1}:${capture.range.endCharacter - 1}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function compareSourceRanges(left: SourceRange, right: SourceRange): number {
|
|
430
|
+
return (
|
|
431
|
+
left.startLine - right.startLine ||
|
|
432
|
+
left.startCharacter - right.startCharacter ||
|
|
433
|
+
left.endLine - right.endLine ||
|
|
434
|
+
left.endCharacter - right.endCharacter
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
380
438
|
/** Convert a tree-sitter node to a SourceRange using the source text for
|
|
381
439
|
* UTF-16 column conversion. */
|
|
382
440
|
function nodeToSourceRange(node: {
|
package/src/types.ts
CHANGED