@mrclrchtr/supi-tree-sitter 10.0.0 → 11.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.
Files changed (41) hide show
  1. package/README.md +63 -17
  2. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/LICENSE +21 -0
  3. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/README.md +743 -0
  4. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/index.js +17 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/constants.js +184 -0
  6. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/parse.js +1425 -0
  7. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/picomatch.js +361 -0
  8. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/scan.js +403 -0
  9. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/utils.js +72 -0
  10. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/package.json +83 -0
  11. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/posix.js +3 -0
  12. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +4 -1
  13. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +11 -0
  14. package/node_modules/@mrclrchtr/supi-code-runtime/src/file-associations.ts +190 -0
  15. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +22 -0
  16. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  17. package/package.json +3 -3
  18. package/src/language.ts +16 -10
  19. package/src/operation-support.ts +13 -6
  20. package/src/provider/tree-sitter-provider.ts +19 -1
  21. package/src/session/runtime-controller.ts +18 -4
  22. package/src/session/session.ts +10 -3
  23. package/src/session/structural-worker-client.ts +17 -2
  24. package/src/session/structural-worker-process.ts +22 -5
  25. package/src/session/structural-worker-protocol.ts +36 -1
  26. package/src/syntax-errors.ts +73 -0
  27. package/src/syntax-node.ts +4 -0
  28. package/src/tool/call-sites.ts +15 -9
  29. package/src/tool/callees.ts +24 -15
  30. package/src/tool/exports.ts +8 -1
  31. package/src/tool/imports.ts +233 -7
  32. package/src/tool/node-at.ts +7 -3
  33. package/src/tool/outline-expansion.ts +78 -0
  34. package/src/tool/outline-jvm.ts +25 -4
  35. package/src/tool/outline-nested.ts +390 -0
  36. package/src/tool/outline.ts +49 -28
  37. package/src/types.ts +16 -0
  38. package/src/worker/process-host.mjs +61 -24
  39. package/src/worker/runtime.ts +44 -10
  40. package/src/worker/service.ts +13 -9
  41. package/src/worker/worker-main.ts +15 -2
@@ -0,0 +1,190 @@
1
+ import * as path from "node:path";
2
+ import picomatch from "picomatch";
3
+
4
+ /** Language selected for a C-family source file. */
5
+ export type CFamilyLanguage = "c" | "cpp";
6
+
7
+ /** One ordered, project-relative language rule for an existing C-family file. */
8
+ export interface CFamilyFileAssociationRule {
9
+ readonly pattern: string;
10
+ readonly language: CFamilyLanguage;
11
+ }
12
+
13
+ /** C-family suffixes that already have Tree-sitter and LSP support. */
14
+ export const C_FAMILY_EXTENSIONS = Object.freeze([
15
+ ".c",
16
+ ".h",
17
+ ".cpp",
18
+ ".hpp",
19
+ ".cc",
20
+ ".cxx",
21
+ ".hxx",
22
+ ".c++",
23
+ ".h++",
24
+ ] as const);
25
+
26
+ export type CFamilyExtension = (typeof C_FAMILY_EXTENSIONS)[number];
27
+
28
+ const DEFAULT_C_FAMILY_LANGUAGES: Readonly<Record<CFamilyExtension, CFamilyLanguage>> =
29
+ Object.freeze({
30
+ ".c": "c",
31
+ ".h": "c",
32
+ ".cpp": "cpp",
33
+ ".hpp": "cpp",
34
+ ".cc": "cpp",
35
+ ".cxx": "cpp",
36
+ ".hxx": "cpp",
37
+ ".c++": "cpp",
38
+ ".h++": "cpp",
39
+ });
40
+
41
+ /** Immutable C-family language selection for one workspace. */
42
+ export interface CFamilyFileAssociationResolver {
43
+ /** Absolute root used to make project-relative paths. */
44
+ readonly workspaceRoot: string;
45
+ /** Validated ordered rules captured when the workspace host starts. */
46
+ readonly rules: readonly CFamilyFileAssociationRule[];
47
+ /** Select the configured language or the existing suffix default. */
48
+ languageForFile(filePath: string): CFamilyLanguage | undefined;
49
+ }
50
+
51
+ /** Return the current default language for one supported C-family suffix. */
52
+ export function defaultCFamilyLanguage(extension: string): CFamilyLanguage | undefined {
53
+ return DEFAULT_C_FAMILY_LANGUAGES[extension.toLowerCase() as CFamilyExtension];
54
+ }
55
+
56
+ /**
57
+ * Validate and compile ordered C-family file associations for one workspace.
58
+ * Patterns are case-sensitive globs over POSIX-style, workspace-relative paths.
59
+ * Supported patterns use `*`, `?`, `**`, character classes, and brace alternatives.
60
+ * The first matching rule wins. Rules never expand the eligible suffix set.
61
+ *
62
+ * @throws If the rule list, a rule, a language, or a pattern is invalid.
63
+ */
64
+ export function createCFamilyFileAssociationResolver(
65
+ workspaceRoot: string,
66
+ input: unknown = [],
67
+ ): CFamilyFileAssociationResolver {
68
+ const rules = validateRules(input);
69
+ const matchers = rules.map((rule, index) => ({
70
+ language: rule.language,
71
+ matches: compilePattern(rule.pattern, `code-intelligence.fileAssociations[${index}]`),
72
+ }));
73
+ const root = path.resolve(workspaceRoot);
74
+ return Object.freeze({
75
+ workspaceRoot: root,
76
+ rules,
77
+ languageForFile(filePath: string): CFamilyLanguage | undefined {
78
+ const extension = normalizeExtension(filePath);
79
+ const fallback = defaultCFamilyLanguage(extension);
80
+ if (!fallback) return undefined;
81
+
82
+ const absolutePath = path.resolve(root, filePath);
83
+ const relativePath = toPosixPath(path.relative(root, absolutePath));
84
+ if (
85
+ relativePath === ".." ||
86
+ relativePath.startsWith("../") ||
87
+ path.posix.isAbsolute(relativePath)
88
+ ) {
89
+ return fallback;
90
+ }
91
+ for (const rule of matchers) {
92
+ if (rule.matches(relativePath)) return rule.language;
93
+ }
94
+ return fallback;
95
+ },
96
+ });
97
+ }
98
+
99
+ function validateRules(input: unknown): readonly CFamilyFileAssociationRule[] {
100
+ if (!Array.isArray(input)) {
101
+ throw new Error('Invalid "code-intelligence.fileAssociations": expected an ordered array.');
102
+ }
103
+ if (input.length > 64) {
104
+ throw new Error('Invalid "code-intelligence.fileAssociations": use no more than 64 rules.');
105
+ }
106
+ const rules: CFamilyFileAssociationRule[] = [];
107
+ const seenPatterns = new Set<string>();
108
+ for (const [index, value] of input.entries()) {
109
+ const key = `code-intelligence.fileAssociations[${index}]`;
110
+ if (!isRecord(value) || !hasExactKeys(value, ["pattern", "language"])) {
111
+ throw new Error(`${key} must contain only "pattern" and "language" fields.`);
112
+ }
113
+ if (typeof value.pattern !== "string") {
114
+ throw new Error(`${key}.pattern must be a string.`);
115
+ }
116
+ if (value.language !== "c" && value.language !== "cpp") {
117
+ throw new Error(`${key}.language must be "c" or "cpp".`);
118
+ }
119
+ if (seenPatterns.has(value.pattern)) {
120
+ throw new Error(`${key}.pattern duplicates an earlier rule.`);
121
+ }
122
+ seenPatterns.add(value.pattern);
123
+ rules.push(Object.freeze({ pattern: value.pattern, language: value.language }));
124
+ }
125
+ return Object.freeze(rules);
126
+ }
127
+
128
+ function compilePattern(pattern: string, key: string): (file: string) => boolean {
129
+ if (
130
+ pattern.length === 0 ||
131
+ pattern.length > 512 ||
132
+ pattern !== pattern.trim() ||
133
+ pattern.includes("\\") ||
134
+ hasControlCharacter(pattern) ||
135
+ pattern.startsWith("/") ||
136
+ /^[A-Za-z]:/.test(pattern) ||
137
+ pattern.startsWith("!")
138
+ ) {
139
+ throw new Error(`${key}.pattern must be a safe workspace-relative glob using "/" separators.`);
140
+ }
141
+ const segments = pattern.split("/");
142
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
143
+ throw new Error(`${key}.pattern contains an unsafe or unsupported path pattern.`);
144
+ }
145
+ try {
146
+ return picomatch(pattern, {
147
+ nocase: false,
148
+ windows: false,
149
+ dot: true,
150
+ strictBrackets: true,
151
+ nonegate: true,
152
+ noextglob: true,
153
+ maxLength: 512,
154
+ });
155
+ } catch (error) {
156
+ throw new Error(`${key}.pattern is not a valid glob: ${errorMessage(error)}`, { cause: error });
157
+ }
158
+ }
159
+
160
+ function hasControlCharacter(value: string): boolean {
161
+ for (const character of value) {
162
+ const code = character.charCodeAt(0);
163
+ if (code < 32 || code === 127) return true;
164
+ }
165
+ return false;
166
+ }
167
+
168
+ function normalizeExtension(filePath: string): string {
169
+ return path.posix.extname(toPosixPath(filePath)).toLowerCase();
170
+ }
171
+
172
+ function toPosixPath(value: string): string {
173
+ return value.replaceAll("\\", "/");
174
+ }
175
+
176
+ function isRecord(value: unknown): value is Record<string, unknown> {
177
+ return typeof value === "object" && value !== null && !Array.isArray(value);
178
+ }
179
+
180
+ function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
181
+ const allowed = new Set(keys);
182
+ return (
183
+ keys.every((key) => Object.hasOwn(value, key)) &&
184
+ Object.keys(value).every((key) => allowed.has(key))
185
+ );
186
+ }
187
+
188
+ function errorMessage(error: unknown): string {
189
+ return error instanceof Error ? error.message : String(error);
190
+ }
@@ -80,6 +80,14 @@ export interface DocumentCodeSymbol extends CodeSymbol {
80
80
  */
81
81
  export type CodeResult<T> =
82
82
  | { kind: "success"; data: T }
83
+ | {
84
+ kind: "partial";
85
+ data: T;
86
+ file: string;
87
+ reason: string;
88
+ /** Syntax error and missing-node ranges in 0-based UTF-16 coordinates. */
89
+ errorRanges: SourceRange[];
90
+ }
83
91
  | { kind: "unsupported-language"; file: string; message: string }
84
92
  | { kind: "file-access-error"; file: string; message: string }
85
93
  | { kind: "validation-error"; message: string }
@@ -93,7 +101,10 @@ export type ConfidenceMode = "semantic" | "structural" | "heuristic" | "unavaila
93
101
 
94
102
  export interface OutlineData {
95
103
  name: string;
104
+ /** Common structural search category. */
96
105
  kind: string;
106
+ /** Source-language declaration kind, separate from common search category. */
107
+ nativeKind?: string;
97
108
  /** Exact 1-based start of the declaration name token, when known. */
98
109
  nameAnchor?: SymbolAnchor;
99
110
  /** Exclusive 1-based end of a verified literal name span, when known. */
@@ -156,6 +167,12 @@ export interface FileEdit {
156
167
  /** A document-state precondition established when semantic edits are normalized. */
157
168
  export type DocumentEditPrecondition =
158
169
  | { file: string; kind: "open-document-version"; version: number }
170
+ | {
171
+ file: string;
172
+ kind: "open-document-content";
173
+ version: number;
174
+ fingerprint: string;
175
+ }
159
176
  | { file: string; kind: "disk-content" };
160
177
 
161
178
  /**
@@ -257,5 +274,10 @@ export interface CalleesData {
257
274
  /** A single call-site match with name and start line. */
258
275
  export interface CallSite {
259
276
  name: string;
277
+ /** 1-based UTF-16 start position of the written callee expression. */
260
278
  startLine: number;
279
+ startCharacter: number;
280
+ /** Exclusive 1-based UTF-16 end position of the captured source expression. */
281
+ endLine: number;
282
+ endCharacter: number;
261
283
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "10.0.0",
3
+ "version": "11.0.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-tree-sitter",
3
- "version": "10.0.0",
3
+ "version": "11.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": "10.0.0",
40
- "@mrclrchtr/supi-core": "10.0.0"
39
+ "@mrclrchtr/supi-code-runtime": "11.0.0",
40
+ "@mrclrchtr/supi-core": "11.0.0"
41
41
  },
42
42
  "bundledDependencies": [
43
43
  "@mrclrchtr/supi-code-runtime",
package/src/language.ts CHANGED
@@ -7,9 +7,18 @@
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
+ import {
11
+ C_FAMILY_EXTENSIONS,
12
+ type CFamilyExtension,
13
+ type CFamilyFileAssociationResolver,
14
+ defaultCFamilyLanguage,
15
+ } from "@mrclrchtr/supi-code-runtime/api";
10
16
  import type { GrammarId, SupportedExtension } from "./types.ts";
11
17
 
12
18
  const sourceDir = path.dirname(fileURLToPath(import.meta.url));
19
+ const C_FAMILY_GRAMMAR = Object.fromEntries(
20
+ C_FAMILY_EXTENSIONS.map((extension) => [extension, defaultCFamilyLanguage(extension)]),
21
+ ) as Record<CFamilyExtension, GrammarId>;
13
22
 
14
23
  /** Mapping from file extension to grammar identifier. */
15
24
  const EXTENSION_GRAMMAR = {
@@ -25,15 +34,7 @@ const EXTENSION_GRAMMAR = {
25
34
  ".pyi": "python",
26
35
  ".rs": "rust",
27
36
  ".go": "go",
28
- ".c": "c",
29
- ".h": "c",
30
- ".cpp": "cpp",
31
- ".hpp": "cpp",
32
- ".cc": "cpp",
33
- ".cxx": "cpp",
34
- ".hxx": "cpp",
35
- ".c++": "cpp",
36
- ".h++": "cpp",
37
+ ...C_FAMILY_GRAMMAR,
37
38
  ".java": "java",
38
39
  ".kt": "kotlin",
39
40
  ".kts": "kotlin",
@@ -68,7 +69,12 @@ export function getSupportedExtensionGrammarEntries(): Array<
68
69
  }
69
70
 
70
71
  /** Detect the grammar for a file. Returns undefined if unsupported. */
71
- export function detectGrammar(filePath: string): GrammarId | undefined {
72
+ export function detectGrammar(
73
+ filePath: string,
74
+ associations?: CFamilyFileAssociationResolver,
75
+ ): GrammarId | undefined {
76
+ const familyLanguage = associations?.languageForFile(filePath);
77
+ if (familyLanguage) return familyLanguage;
72
78
  const ext = path.extname(filePath).toLowerCase();
73
79
  return EXTENSION_GRAMMAR[ext as SupportedExtension];
74
80
  }
@@ -4,6 +4,18 @@ import type { GrammarId, SupportedExtension } from "./types.ts";
4
4
 
5
5
  /** Structural collection operations whose language support defines an AST Scan universe. */
6
6
  const STRUCTURAL_SEARCH_OPERATIONS = ["outline", "imports", "exports", "call-sites"] as const;
7
+ const IMPORT_GRAMMARS: ReadonlySet<GrammarId> = new Set([
8
+ "javascript",
9
+ "typescript",
10
+ "tsx",
11
+ "python",
12
+ "java",
13
+ "go",
14
+ "rust",
15
+ "c",
16
+ "cpp",
17
+ "latex",
18
+ ]);
7
19
 
8
20
  /** One Tree-sitter structural collection operation used by code-aware search. */
9
21
  export type StructuralSearchOperation = (typeof STRUCTURAL_SEARCH_OPERATIONS)[number];
@@ -33,12 +45,7 @@ export function supportsGrammarOperation(
33
45
  case "outline":
34
46
  return true;
35
47
  case "imports":
36
- return (
37
- grammar === "javascript" ||
38
- grammar === "typescript" ||
39
- grammar === "tsx" ||
40
- grammar === "latex"
41
- );
48
+ return IMPORT_GRAMMARS.has(grammar);
42
49
  case "exports":
43
50
  return grammar === "javascript" || grammar === "typescript" || grammar === "tsx";
44
51
  default:
@@ -70,6 +70,17 @@ function mapTreeSitterResult<T, U>(
70
70
  switch (result.kind) {
71
71
  case "success":
72
72
  return { kind: "success", data: mapData(result.data) };
73
+ case "partial":
74
+ return {
75
+ kind: "partial",
76
+ data: mapData(result.data),
77
+ file: result.file,
78
+ reason: result.reason,
79
+ errorRanges: result.errorRanges.map((range) => ({
80
+ start: { line: range.startLine - 1, character: range.startCharacter - 1 },
81
+ end: { line: range.endLine - 1, character: range.endCharacter - 1 },
82
+ })),
83
+ };
73
84
  case "unsupported-language":
74
85
  return { kind: "unsupported-language", file: result.file, message: result.message };
75
86
  case "file-access-error":
@@ -103,6 +114,7 @@ function mapOutlineItems(items: OutlineItem[]): OutlineData[] {
103
114
  return items.map((item) => ({
104
115
  name: item.name,
105
116
  kind: item.kind,
117
+ ...(item.nativeKind === undefined ? {} : { nativeKind: item.nativeKind }),
106
118
  ...(item.nameAnchor ? { nameAnchor: { ...item.nameAnchor } } : {}),
107
119
  ...(item.nameEndAnchor ? { nameEndAnchor: { ...item.nameEndAnchor } } : {}),
108
120
  ...takeRange(item.range),
@@ -156,5 +168,11 @@ function mapCalleesAtResult(result: CalleesAtResult): CalleesData {
156
168
  }
157
169
 
158
170
  function mapCallSites(matches: CallSiteMatch[]): CallSite[] {
159
- return matches.map((m) => ({ name: m.name, startLine: m.startLine }));
171
+ return matches.map((match) => ({
172
+ name: match.name,
173
+ startLine: match.startLine,
174
+ startCharacter: match.startCharacter,
175
+ endLine: match.endLine,
176
+ endCharacter: match.endCharacter,
177
+ }));
160
178
  }
@@ -1,7 +1,10 @@
1
1
  // Tree-sitter lifecycle controller — owns one Structural Worker for one cwd.
2
2
 
3
3
  import { fileURLToPath } from "node:url";
4
- import type { WorkspaceRuntime } from "@mrclrchtr/supi-code-runtime/api";
4
+ import type {
5
+ CFamilyFileAssociationResolver,
6
+ WorkspaceRuntime,
7
+ } from "@mrclrchtr/supi-code-runtime/api";
5
8
  import { createTreeSitterProvider } from "../provider/tree-sitter-provider.ts";
6
9
  import type { TreeSitterService, TreeSitterSession } from "../types.ts";
7
10
  import { createTreeSitterSession } from "./session.ts";
@@ -32,13 +35,20 @@ export class TreeSitterRuntimeController {
32
35
  readonly #cwd: string;
33
36
  #state: TsControllerState = { kind: "initial" };
34
37
  readonly #runtime: WorkspaceRuntime;
38
+ readonly #fileAssociations?: CFamilyFileAssociationResolver;
35
39
  #pendingSession: TreeSitterSession | null = null;
36
40
  #generation = 0;
37
41
  #transition = Promise.resolve();
38
42
 
39
- constructor(cwd: string, runtime: WorkspaceRuntime) {
43
+ /** Create a controller with an optional workspace association snapshot. */
44
+ constructor(
45
+ cwd: string,
46
+ runtime: WorkspaceRuntime,
47
+ fileAssociations?: CFamilyFileAssociationResolver,
48
+ ) {
40
49
  this.#cwd = cwd;
41
50
  this.#runtime = runtime;
51
+ this.#fileAssociations = fileAssociations;
42
52
  }
43
53
 
44
54
  get cwd(): string {
@@ -79,11 +89,15 @@ export class TreeSitterRuntimeController {
79
89
  if (generation !== this.#generation) return supersededStart();
80
90
  this.#state = { kind: "unavailable", reason: "Initializing Tree-sitter" };
81
91
 
82
- const session = createTreeSitterSession(this.#cwd);
92
+ const session = createTreeSitterSession(this.#cwd, {
93
+ fileAssociations: this.#fileAssociations?.rules,
94
+ });
83
95
  this.#pendingSession = session;
84
96
  try {
85
97
  const probe = await session.canParse(fileURLToPath(import.meta.url));
86
- if (probe.kind !== "success") throw new Error(probe.message);
98
+ if (probe.kind !== "success") {
99
+ throw new Error(probe.kind === "partial" ? probe.reason : probe.message);
100
+ }
87
101
  if (generation !== this.#generation || this.#pendingSession !== session) {
88
102
  return supersededStart();
89
103
  }
@@ -1,6 +1,9 @@
1
1
  // Session factory — creates asynchronous Structural Worker proxies and owned sessions.
2
2
 
3
- import type { CodeRequestControl } from "@mrclrchtr/supi-code-runtime/api";
3
+ import type {
4
+ CFamilyFileAssociationRule,
5
+ CodeRequestControl,
6
+ } from "@mrclrchtr/supi-code-runtime/api";
4
7
  import type {
5
8
  CalleesAtResult,
6
9
  CallSiteMatch,
@@ -62,13 +65,17 @@ export function createTreeSitterService(client: StructuralWorkerClient): TreeSit
62
65
 
63
66
  /**
64
67
  * Create one Tree-sitter session bound to a working directory.
68
+ * Pass trusted workspace associations when the caller owns config loading.
65
69
  * Await disposal to prove that its Structural Worker has terminated.
66
70
  */
67
71
  export function createTreeSitterSession(
68
72
  cwd: string,
69
- options: { readonly workerFactory?: StructuralWorkerFactory } = {},
73
+ options: {
74
+ readonly workerFactory?: StructuralWorkerFactory;
75
+ readonly fileAssociations?: readonly CFamilyFileAssociationRule[];
76
+ } = {},
70
77
  ): TreeSitterSession {
71
- const client = new StructuralWorkerClient(cwd, options.workerFactory);
78
+ const client = new StructuralWorkerClient(cwd, options.workerFactory, options.fileAssociations);
72
79
  const service = createTreeSitterService(client);
73
80
  return {
74
81
  ...service,
@@ -1,6 +1,7 @@
1
1
  // biome-ignore-all lint/style/noExcessiveLinesPerFile: one mailbox state machine owns all Worker lifecycle transitions
2
2
  import { setImmediate as yieldImmediate } from "node:timers/promises";
3
3
  import {
4
+ type CFamilyFileAssociationRule,
4
5
  type CodeRequestControl,
5
6
  CodeRequestDeadlineError,
6
7
  throwIfCodeRequestInterrupted,
@@ -28,6 +29,7 @@ import {
28
29
  export type StructuralWorkerFactory = (options: {
29
30
  readonly cwd: string;
30
31
  readonly generation: number;
32
+ readonly fileAssociations: readonly CFamilyFileAssociationRule[];
31
33
  }) => StructuralWorkerLike;
32
34
 
33
35
  const TEST_WORKER_FACTORY = Symbol.for("supi-tree-sitter/test-worker-factory");
@@ -74,6 +76,7 @@ interface ActiveWorker {
74
76
  export class StructuralWorkerClient {
75
77
  readonly #cwd: string;
76
78
  readonly #factory: StructuralWorkerFactory;
79
+ readonly #fileAssociations: readonly CFamilyFileAssociationRule[];
77
80
  #worker: ActiveWorker | null = null;
78
81
  #queue: PendingRequest<unknown>[] = [];
79
82
  #active: PendingRequest<unknown> | null = null;
@@ -86,9 +89,16 @@ export class StructuralWorkerClient {
86
89
  readonly #lifecycle = new StructuralWorkerLifecycle();
87
90
  #disposePromise: Promise<void> | null = null;
88
91
 
89
- constructor(cwd: string, factory?: StructuralWorkerFactory) {
92
+ constructor(
93
+ cwd: string,
94
+ factory?: StructuralWorkerFactory,
95
+ fileAssociations: readonly CFamilyFileAssociationRule[] = [],
96
+ ) {
90
97
  this.#cwd = cwd;
91
98
  this.#factory = factory ?? testWorkerFactory() ?? createProductionWorker;
99
+ this.#fileAssociations = Object.freeze(
100
+ fileAssociations.map((rule) => Object.freeze({ ...rule })),
101
+ );
92
102
  }
93
103
 
94
104
  /** Start and validate the owned Worker without running parser work on the parent. */
@@ -187,7 +197,11 @@ export class StructuralWorkerClient {
187
197
 
188
198
  async #startGeneration(): Promise<void> {
189
199
  const generation = ++this.#generation;
190
- const worker = this.#factory({ cwd: this.#cwd, generation });
200
+ const worker = this.#factory({
201
+ cwd: this.#cwd,
202
+ generation,
203
+ fileAssociations: this.#fileAssociations,
204
+ });
191
205
  let rejectStartup = (_message: string) => {};
192
206
  const active: ActiveWorker = {
193
207
  generation,
@@ -563,6 +577,7 @@ function testWorkerFactory(): StructuralWorkerFactory | undefined {
563
577
  function createProductionWorker(options: {
564
578
  cwd: string;
565
579
  generation: number;
580
+ fileAssociations: readonly CFamilyFileAssociationRule[];
566
581
  }): StructuralWorkerLike {
567
582
  return createStructuralWorkerProcess(options);
568
583
  }
@@ -1,11 +1,14 @@
1
1
  import { type ChildProcess, fork } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { fileURLToPath } from "node:url";
4
+ import type { CFamilyFileAssociationRule } from "@mrclrchtr/supi-code-runtime/api";
4
5
  import { decodeStructuralWorkerJsonMessage } from "./structural-worker-json.ts";
5
6
  import { resolveStructuralWorkerNode } from "./structural-worker-node.ts";
6
- import type {
7
- ParentToStructuralWorkerMessage,
8
- StructuralWorkerToParentMessage,
7
+ import {
8
+ type ParentToStructuralWorkerMessage,
9
+ STRUCTURAL_WORKER_PROTOCOL_VERSION,
10
+ type StructuralWorkerInitializationMessage,
11
+ type StructuralWorkerToParentMessage,
9
12
  } from "./structural-worker-protocol.ts";
10
13
 
11
14
  const PARENT_CHANNEL = "supi-tree-sitter-parent";
@@ -23,6 +26,7 @@ export interface StructuralWorkerLike {
23
26
  export function createStructuralWorkerProcess(options: {
24
27
  readonly cwd: string;
25
28
  readonly generation: number;
29
+ readonly fileAssociations: readonly CFamilyFileAssociationRule[];
26
30
  }): StructuralWorkerLike {
27
31
  const hostPath = fileURLToPath(new URL("../worker/process-host.mjs", import.meta.url));
28
32
  const jsonIpc = Boolean(process.versions.bun);
@@ -36,7 +40,13 @@ export function createStructuralWorkerProcess(options: {
36
40
  stdio: ["ignore", "ignore", "inherit", "ipc"],
37
41
  windowsHide: true,
38
42
  });
39
- return new StructuralWorkerProcess(child, jsonIpc);
43
+ return new StructuralWorkerProcess(child, jsonIpc, {
44
+ kind: "initialize",
45
+ version: STRUCTURAL_WORKER_PROTOCOL_VERSION,
46
+ generation: options.generation,
47
+ cwd: options.cwd,
48
+ fileAssociations: options.fileAssociations,
49
+ });
40
50
  }
41
51
 
42
52
  class StructuralWorkerProcess implements StructuralWorkerLike {
@@ -44,12 +54,19 @@ class StructuralWorkerProcess implements StructuralWorkerLike {
44
54
  readonly #child: ChildProcess;
45
55
  readonly #jsonIpc: boolean;
46
56
 
47
- constructor(child: ChildProcess, jsonIpc: boolean) {
57
+ constructor(
58
+ child: ChildProcess,
59
+ jsonIpc: boolean,
60
+ initialization: StructuralWorkerInitializationMessage,
61
+ ) {
48
62
  this.#child = child;
49
63
  this.#jsonIpc = jsonIpc;
50
64
  child.on("message", (value: unknown) => this.#receive(value));
51
65
  child.on("error", (error) => this.#events.emit("error", error));
52
66
  child.on("exit", (code) => this.#events.emit("exit", code ?? 1));
67
+ child.send({ channel: PARENT_CHANNEL, message: initialization }, (error) => {
68
+ if (error) this.#events.emit("error", error);
69
+ });
53
70
  }
54
71
 
55
72
  postMessage(message: ParentToStructuralWorkerMessage): void {
@@ -1,4 +1,6 @@
1
1
  // biome-ignore-all lint/style/noExcessiveLinesPerFile: one private protocol module defines and validates the full closed contract
2
+
3
+ import type { CFamilyFileAssociationRule } from "@mrclrchtr/supi-code-runtime/api";
2
4
  import { isDebugOperationId } from "@mrclrchtr/supi-core/debug";
3
5
  import type { TreeSitterResult } from "../types.ts";
4
6
  import type { StructuralTimingEvent } from "./structural-timing.ts";
@@ -37,6 +39,14 @@ export type StructuralWorkerOperation =
37
39
  readonly depth?: "direct" | "deep";
38
40
  };
39
41
 
42
+ export interface StructuralWorkerInitializationMessage {
43
+ readonly kind: "initialize";
44
+ readonly version: typeof STRUCTURAL_WORKER_PROTOCOL_VERSION;
45
+ readonly generation: number;
46
+ readonly cwd: string;
47
+ readonly fileAssociations: readonly CFamilyFileAssociationRule[];
48
+ }
49
+
40
50
  export interface StructuralWorkerRequestMessage {
41
51
  readonly kind: "request";
42
52
  readonly version: typeof STRUCTURAL_WORKER_PROTOCOL_VERSION;
@@ -427,7 +437,18 @@ function isStructuralTimingEvent(value: unknown): value is StructuralTimingEvent
427
437
  function assertTreeSitterResult(value: unknown): asserts value is TreeSitterResult<unknown> {
428
438
  if (!isRecord(value) || typeof value.kind !== "string")
429
439
  throw new Error("Invalid structural result");
430
- if (value.kind === "success" && "data" in value) return;
440
+ if (value.kind === "success" && hasExactKeys(value, ["kind", "data"])) return;
441
+ if (
442
+ value.kind === "partial" &&
443
+ hasExactKeys(value, ["kind", "data", "file", "reason", "errorRanges"]) &&
444
+ isFileValue(value.file) &&
445
+ isBoundedString(value.reason, 2_048) &&
446
+ Array.isArray(value.errorRanges) &&
447
+ value.errorRanges.length <= 10_000 &&
448
+ value.errorRanges.every(isTreeSitterSourceRange)
449
+ ) {
450
+ return;
451
+ }
431
452
  if (
432
453
  value.kind === "unsupported-language" ||
433
454
  value.kind === "file-access-error" ||
@@ -527,6 +548,20 @@ function estimateChunkMessageBytes(payloadBytes: number): number {
527
548
  return payloadBytes + 1024;
528
549
  }
529
550
 
551
+ function isTreeSitterSourceRange(value: unknown): boolean {
552
+ return (
553
+ isRecord(value) &&
554
+ hasExactKeys(value, ["startLine", "startCharacter", "endLine", "endCharacter"]) &&
555
+ isPositiveInteger(value.startLine) &&
556
+ isPositiveInteger(value.startCharacter) &&
557
+ isPositiveInteger(value.endLine) &&
558
+ isPositiveInteger(value.endCharacter) &&
559
+ (Number(value.startLine) < Number(value.endLine) ||
560
+ (value.startLine === value.endLine &&
561
+ Number(value.startCharacter) <= Number(value.endCharacter)))
562
+ );
563
+ }
564
+
530
565
  function isRecord(value: unknown): value is Record<string, unknown> {
531
566
  return typeof value === "object" && value !== null && !Array.isArray(value);
532
567
  }