@mrclrchtr/supi-tree-sitter 8.0.1 → 9.1.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 (32) hide show
  1. package/README.md +12 -3
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +10 -1
  4. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  5. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +9 -4
  6. package/node_modules/@mrclrchtr/supi-core/src/session-capabilities.ts +55 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/session-capability-profiles.ts +240 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/session.ts +22 -0
  9. package/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm +0 -0
  10. package/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm.map +7 -7
  11. package/node_modules/web-tree-sitter/package.json +1 -1
  12. package/node_modules/web-tree-sitter/web-tree-sitter.wasm +0 -0
  13. package/node_modules/web-tree-sitter/web-tree-sitter.wasm.map +7 -7
  14. package/package.json +6 -4
  15. package/resources/grammars/kotlin/tree-sitter-kotlin.wasm.json +1 -1
  16. package/resources/grammars/latex/LICENSE +21 -0
  17. package/resources/grammars/latex/tree-sitter-latex.wasm +0 -0
  18. package/resources/grammars/latex/tree-sitter-latex.wasm.json +17 -0
  19. package/resources/grammars/sql/tree-sitter-sql.wasm.json +1 -1
  20. package/src/language.ts +5 -1
  21. package/src/operation-support.ts +6 -0
  22. package/src/provider/tree-sitter-provider.ts +2 -0
  23. package/src/session/structural-worker-client.ts +45 -34
  24. package/src/session/structural-worker-process.ts +122 -0
  25. package/src/session/structural-worker-protocol.ts +1 -1
  26. package/src/tool/callees.ts +1 -0
  27. package/src/tool/imports.ts +55 -1
  28. package/src/tool/outline-latex.ts +288 -0
  29. package/src/tool/outline-polyglot.ts +8 -1
  30. package/src/tool/outline.ts +18 -4
  31. package/src/types.ts +11 -3
  32. package/src/worker/process-host.mjs +92 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-tree-sitter",
3
- "version": "8.0.1",
3
+ "version": "9.1.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": "8.0.1",
40
- "@mrclrchtr/supi-core": "8.0.1"
39
+ "@mrclrchtr/supi-code-runtime": "9.1.0",
40
+ "@mrclrchtr/supi-core": "9.1.0"
41
41
  },
42
42
  "bundledDependencies": [
43
43
  "@mrclrchtr/supi-code-runtime",
@@ -59,6 +59,8 @@
59
59
  "generate:kotlin-wasm": "node scripts/generate-kotlin-wasm.mjs",
60
60
  "check:kotlin-wasm": "node scripts/generate-kotlin-wasm.mjs --check",
61
61
  "generate:sql-wasm": "node scripts/generate-sql-wasm.mjs",
62
- "check:sql-wasm": "node scripts/generate-sql-wasm.mjs --check"
62
+ "check:sql-wasm": "node scripts/generate-sql-wasm.mjs --check",
63
+ "generate:latex-wasm": "node scripts/generate-latex-wasm.mjs",
64
+ "check:latex-wasm": "node scripts/generate-latex-wasm.mjs --check"
63
65
  }
64
66
  }
@@ -6,7 +6,7 @@
6
6
  "releaseAsset": "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm"
7
7
  },
8
8
  "generatedWith": {
9
- "treeSitterCli": "0.27.0"
9
+ "treeSitterCli": "0.27.1"
10
10
  },
11
11
  "sha256": "7f522f2e3ef633735b3e2bdea165071b5ad0a99d6201e84acab49017590f812a"
12
12
  }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Patrick Förster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ {
2
+ "source": {
3
+ "npmPackage": "@pfoerster/tree-sitter-latex",
4
+ "version": "0.6.0",
5
+ "repository": "https://github.com/latex-lsp/tree-sitter-latex"
6
+ },
7
+ "generatedWith": {
8
+ "treeSitterCli": "0.27.1"
9
+ },
10
+ "patches": [
11
+ {
12
+ "id": "label-underscore-v1",
13
+ "file": "grammar.js"
14
+ }
15
+ ],
16
+ "sha256": "122e3587a1faaed6316c2fadb2336e531252787ea541f897a966b0dc0c812d52"
17
+ }
@@ -5,7 +5,7 @@
5
5
  "repository": "https://github.com/derekstride/tree-sitter-sql"
6
6
  },
7
7
  "generatedWith": {
8
- "treeSitterCli": "0.27.0"
8
+ "treeSitterCli": "0.27.1"
9
9
  },
10
10
  "sha256": "e03fb5079e88cfe0807056c284fd2b0589cee35df9bbb3d07e72f2f4178de8de",
11
11
  "trust": {
package/src/language.ts CHANGED
@@ -48,6 +48,9 @@ const EXTENSION_GRAMMAR = {
48
48
  ".xhtml": "html",
49
49
  ".r": "r",
50
50
  ".sql": "sql",
51
+ ".tex": "latex",
52
+ ".sty": "latex",
53
+ ".cls": "latex",
51
54
  } satisfies Record<SupportedExtension, GrammarId>;
52
55
 
53
56
  const SUPPORTED_EXTENSIONS = Object.keys(EXTENSION_GRAMMAR) as SupportedExtension[];
@@ -87,6 +90,7 @@ const GRAMMAR_WASM: Record<GrammarId, string> = {
87
90
  html: "tree-sitter-html.wasm",
88
91
  r: "tree-sitter-r.wasm",
89
92
  sql: "tree-sitter-sql.wasm",
93
+ latex: "tree-sitter-latex.wasm",
90
94
  };
91
95
 
92
96
  const resourcesDir = path.resolve(sourceDir, "../resources/grammars");
@@ -103,7 +107,7 @@ export function resolveGrammarWasmPath(grammarId: GrammarId): string {
103
107
  if (!fs.existsSync(vendoredPath)) {
104
108
  throw new Error(
105
109
  `Vendored WASM grammar not found for "${grammarId}": expected at ${vendoredPath}. ` +
106
- "Run `node scripts/vendor-wasm.mjs` or reinstall the package.",
110
+ "Run `pnpm --filter @mrclrchtr/supi-tree-sitter generate:all-wasm` or reinstall the package.",
107
111
  );
108
112
  }
109
113
 
@@ -33,6 +33,12 @@ export function supportsGrammarOperation(
33
33
  case "outline":
34
34
  return true;
35
35
  case "imports":
36
+ return (
37
+ grammar === "javascript" ||
38
+ grammar === "typescript" ||
39
+ grammar === "tsx" ||
40
+ grammar === "latex"
41
+ );
36
42
  case "exports":
37
43
  return grammar === "javascript" || grammar === "typescript" || grammar === "tsx";
38
44
  default:
@@ -103,6 +103,8 @@ function mapOutlineItems(items: OutlineItem[]): OutlineData[] {
103
103
  return items.map((item) => ({
104
104
  name: item.name,
105
105
  kind: item.kind,
106
+ ...(item.nameAnchor ? { nameAnchor: { ...item.nameAnchor } } : {}),
107
+ ...(item.nameEndAnchor ? { nameEndAnchor: { ...item.nameEndAnchor } } : {}),
106
108
  ...takeRange(item.range),
107
109
  children: item.children ? mapOutlineItems(item.children) : undefined,
108
110
  }));
@@ -1,6 +1,5 @@
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
- import { Worker } from "node:worker_threads";
4
3
  import {
5
4
  type CodeRequestControl,
6
5
  CodeRequestDeadlineError,
@@ -11,6 +10,10 @@ import type { TreeSitterResult } from "../types.ts";
11
10
  import { publishStructuralTimingEvent } from "./structural-timing.ts";
12
11
  import { StructuralWorkerLifecycle } from "./structural-worker-client-lifecycle.ts";
13
12
  import { assertStructuralProtocolMessageSize } from "./structural-worker-message-size.ts";
13
+ import {
14
+ createStructuralWorkerProcess,
15
+ type StructuralWorkerLike,
16
+ } from "./structural-worker-process.ts";
14
17
  import {
15
18
  decodeStructuralResult,
16
19
  type ParentToStructuralWorkerMessage,
@@ -22,14 +25,6 @@ import {
22
25
  validateWorkerToParentMessage,
23
26
  } from "./structural-worker-protocol.ts";
24
27
 
25
- interface StructuralWorkerLike {
26
- postMessage(message: ParentToStructuralWorkerMessage): void;
27
- on(event: "message", listener: (message: unknown) => void): this;
28
- on(event: "error", listener: (error: Error) => void): this;
29
- on(event: "exit", listener: (code: number) => void): this;
30
- terminate(): Promise<number>;
31
- }
32
-
33
28
  export type StructuralWorkerFactory = (options: {
34
29
  readonly cwd: string;
35
30
  readonly generation: number;
@@ -71,6 +66,7 @@ interface ActiveWorker {
71
66
  readonly worker: StructuralWorkerLike;
72
67
  ready: boolean;
73
68
  startupSettled: boolean;
69
+ failureStarted: boolean;
74
70
  readonly settleStartupFailure: (message: string) => void;
75
71
  }
76
72
 
@@ -198,6 +194,7 @@ export class StructuralWorkerClient {
198
194
  worker,
199
195
  ready: false,
200
196
  startupSettled: false,
197
+ failureStarted: false,
201
198
  settleStartupFailure: (message) => rejectStartup(message),
202
199
  };
203
200
  this.#worker = active;
@@ -241,23 +238,18 @@ export class StructuralWorkerClient {
241
238
  });
242
239
  worker.on("error", (error) => {
243
240
  settleFailure(error.message);
244
- if (this.#worker === active && active.ready) {
245
- void this.#lifecycle.run(() => this.#workerFailure(error.message));
246
- }
241
+ if (active.ready) this.#failWorker(active, error.message);
247
242
  });
248
243
  worker.on("exit", (code) => {
249
244
  settleFailure(`Structural Worker exited with code ${code}`);
250
- if (!this.#closed && this.#worker === active && active.ready) {
251
- void this.#lifecycle.run(() =>
252
- this.#workerFailure(`Structural Worker exited with code ${code}`),
253
- );
254
- }
245
+ if (active.ready) this.#failWorker(active, `Structural Worker exited with code ${code}`);
255
246
  });
256
247
  });
257
248
  }
258
249
 
259
250
  #dispatch(): void {
260
- if (this.#closed || this.#active || !this.#worker?.ready) return;
251
+ const worker = this.#worker;
252
+ if (this.#closed || this.#active || !worker?.ready) return;
261
253
  while (this.#queue.length > 0) {
262
254
  const request = this.#queue.shift();
263
255
  if (!request) return;
@@ -271,7 +263,7 @@ export class StructuralWorkerClient {
271
263
  const message = {
272
264
  kind: "request",
273
265
  version: STRUCTURAL_WORKER_PROTOCOL_VERSION,
274
- generation: this.#worker.generation,
266
+ generation: worker.generation,
275
267
  requestId: request.id,
276
268
  input: request.input,
277
269
  ...(request.control?.operationId ? { operationId: request.control.operationId } : {}),
@@ -280,7 +272,7 @@ export class StructuralWorkerClient {
280
272
  } as const;
281
273
  try {
282
274
  assertStructuralProtocolMessageSize(message);
283
- this.#worker.worker.postMessage(message);
275
+ if (!this.#send(worker, message)) return;
284
276
  } catch (error) {
285
277
  this.#active = null;
286
278
  this.#settle(request, runtimeError(errorMessage(error)));
@@ -334,12 +326,13 @@ export class StructuralWorkerClient {
334
326
  active.awaitingAck = message.sequence;
335
327
  if (message.final) active.finalSequence = message.sequence;
336
328
  await yieldImmediate();
337
- if (this.#active !== active || !this.#worker) return;
329
+ const worker = this.#worker;
330
+ if (this.#active !== active || !worker) return;
338
331
  active.awaitingAck = null;
339
- this.#worker.worker.postMessage({
332
+ this.#send(worker, {
340
333
  kind: "chunk-ack",
341
334
  version: STRUCTURAL_WORKER_PROTOCOL_VERSION,
342
- generation: this.#worker.generation,
335
+ generation: worker.generation,
343
336
  requestId: active.id,
344
337
  sequence: message.sequence,
345
338
  });
@@ -401,6 +394,22 @@ export class StructuralWorkerClient {
401
394
  };
402
395
  }
403
396
 
397
+ #send(worker: ActiveWorker, message: ParentToStructuralWorkerMessage): boolean {
398
+ try {
399
+ worker.worker.postMessage(message);
400
+ return true;
401
+ } catch (error) {
402
+ this.#failWorker(worker, errorMessage(error));
403
+ return false;
404
+ }
405
+ }
406
+
407
+ #failWorker(worker: ActiveWorker, reason: string): void {
408
+ if (this.#closed || this.#worker !== worker || worker.failureStarted) return;
409
+ worker.failureStarted = true;
410
+ void this.#lifecycle.run(() => this.#workerFailure(reason)).catch(() => undefined);
411
+ }
412
+
404
413
  #interrupt(request: PendingRequest<unknown>, error: unknown): void {
405
414
  if (request.settled) return;
406
415
  const queuedIndex = this.#queue.indexOf(request);
@@ -409,14 +418,19 @@ export class StructuralWorkerClient {
409
418
  this.#reject(request, error);
410
419
  return;
411
420
  }
412
- if (this.#active !== request || !this.#worker) return;
421
+ const worker = this.#worker;
422
+ if (this.#active !== request || !worker) return;
413
423
  Atomics.store(request.cancellationFlag, 0, 1);
414
- this.#worker.worker.postMessage({
415
- kind: "cancel",
416
- version: STRUCTURAL_WORKER_PROTOCOL_VERSION,
417
- generation: this.#worker.generation,
418
- requestId: request.id,
419
- });
424
+ if (
425
+ !this.#send(worker, {
426
+ kind: "cancel",
427
+ version: STRUCTURAL_WORKER_PROTOCOL_VERSION,
428
+ generation: worker.generation,
429
+ requestId: request.id,
430
+ })
431
+ ) {
432
+ return;
433
+ }
420
434
  request.hardStopTimer = setTimeout(
421
435
  () => void this.#hardStop(request, error),
422
436
  STRUCTURAL_WORKER_LIMITS.hardStopGraceMs,
@@ -550,10 +564,7 @@ function createProductionWorker(options: {
550
564
  cwd: string;
551
565
  generation: number;
552
566
  }): StructuralWorkerLike {
553
- return new Worker(new URL("../worker/bootstrap.mjs", import.meta.url), {
554
- execArgv: [],
555
- workerData: options,
556
- });
567
+ return createStructuralWorkerProcess(options);
557
568
  }
558
569
 
559
570
  function runtimeError<T = never>(message: string): TreeSitterResult<T> {
@@ -0,0 +1,122 @@
1
+ import { type ChildProcess, fork } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { fileURLToPath } from "node:url";
4
+ import type {
5
+ ParentToStructuralWorkerMessage,
6
+ StructuralWorkerToParentMessage,
7
+ } from "./structural-worker-protocol.ts";
8
+
9
+ const PARENT_CHANNEL = "supi-tree-sitter-parent";
10
+ const WORKER_CHANNEL = "supi-tree-sitter-worker";
11
+
12
+ export interface StructuralWorkerLike {
13
+ postMessage(message: ParentToStructuralWorkerMessage): void;
14
+ on(event: "message", listener: (message: unknown) => void): this;
15
+ on(event: "error", listener: (error: Error) => void): this;
16
+ on(event: "exit", listener: (code: number) => void): this;
17
+ terminate(): Promise<number>;
18
+ }
19
+
20
+ /** Start the Structural Worker in a Node process with safe WebAssembly compilation. */
21
+ export function createStructuralWorkerProcess(options: {
22
+ readonly cwd: string;
23
+ readonly generation: number;
24
+ }): StructuralWorkerLike {
25
+ const hostPath = fileURLToPath(new URL("../worker/process-host.mjs", import.meta.url));
26
+ // A child process accepts the V8 flag that a Worker thread rejects.
27
+ const child = fork(hostPath, [options.cwd, String(options.generation)], {
28
+ execArgv: ["--liftoff-only"],
29
+ env: { ...process.env, NODE_OPTIONS: "" },
30
+ serialization: "advanced",
31
+ stdio: ["ignore", "ignore", "inherit", "ipc"],
32
+ windowsHide: true,
33
+ });
34
+ return new StructuralWorkerProcess(child);
35
+ }
36
+
37
+ class StructuralWorkerProcess implements StructuralWorkerLike {
38
+ readonly #events = new EventEmitter();
39
+ readonly #child: ChildProcess;
40
+
41
+ constructor(child: ChildProcess) {
42
+ this.#child = child;
43
+ child.on("message", (value: unknown) => this.#receive(value));
44
+ child.on("error", (error) => this.#events.emit("error", error));
45
+ child.on("exit", (code) => this.#events.emit("exit", code ?? 1));
46
+ }
47
+
48
+ postMessage(message: ParentToStructuralWorkerMessage): void {
49
+ if (!this.#child.connected) throw new Error("Structural Worker process is disconnected");
50
+ // The process host creates a shared cancellation flag for its Worker thread.
51
+ const forwarded =
52
+ message.kind === "request" ? { ...message, cancellationFlag: undefined } : message;
53
+ this.#child.send({ channel: PARENT_CHANNEL, message: forwarded }, (error) => {
54
+ if (error) this.#events.emit("error", error);
55
+ });
56
+ }
57
+
58
+ on(event: "message", listener: (message: unknown) => void): this;
59
+ on(event: "error", listener: (error: Error) => void): this;
60
+ on(event: "exit", listener: (code: number) => void): this;
61
+ on(
62
+ event: "message" | "error" | "exit",
63
+ listener: ((message: unknown) => void) | ((error: Error) => void) | ((code: number) => void),
64
+ ): this {
65
+ this.#events.on(event, listener);
66
+ return this;
67
+ }
68
+
69
+ terminate(): Promise<number> {
70
+ if (this.#child.exitCode !== null) return Promise.resolve(this.#child.exitCode ?? 0);
71
+ if (this.#child.signalCode !== null) return Promise.resolve(0);
72
+
73
+ return new Promise((resolve) => {
74
+ let settled = false;
75
+ const finish = (code: number | null) => {
76
+ if (settled) return;
77
+ settled = true;
78
+ clearTimeout(forceStopTimer);
79
+ resolve(code ?? 0);
80
+ };
81
+ const forceStopTimer = setTimeout(() => {
82
+ if (!this.#child.kill("SIGKILL")) finish(this.#child.exitCode);
83
+ }, 250);
84
+ forceStopTimer.unref?.();
85
+ this.#child.once("exit", (code) => finish(code));
86
+ if (!this.#child.kill("SIGTERM")) finish(this.#child.exitCode);
87
+ });
88
+ }
89
+
90
+ #receive(value: unknown): void {
91
+ if (!isWorkerChannel(value)) {
92
+ this.#events.emit("message", value);
93
+ return;
94
+ }
95
+ if (value.message.kind === "worker-error") {
96
+ this.#events.emit("error", new Error(value.message.message));
97
+ return;
98
+ }
99
+ this.#events.emit("message", value.message);
100
+ }
101
+ }
102
+
103
+ interface WorkerChannelMessage {
104
+ readonly channel: typeof WORKER_CHANNEL;
105
+ readonly message:
106
+ | StructuralWorkerToParentMessage
107
+ | { readonly kind: "worker-error"; readonly message: string };
108
+ }
109
+
110
+ function isWorkerChannel(value: unknown): value is WorkerChannelMessage {
111
+ if (!value || typeof value !== "object") return false;
112
+ const candidate = value as Partial<WorkerChannelMessage>;
113
+ if (
114
+ candidate.channel !== WORKER_CHANNEL ||
115
+ !candidate.message ||
116
+ typeof candidate.message !== "object"
117
+ ) {
118
+ return false;
119
+ }
120
+ const message = candidate.message as { kind?: unknown; message?: unknown };
121
+ return message.kind !== "worker-error" || typeof message.message === "string";
122
+ }
@@ -515,7 +515,7 @@ function isFiniteDuration(value: unknown): boolean {
515
515
  }
516
516
 
517
517
  function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
518
- const actual = Object.keys(value).sort();
518
+ const actual = Object.keys(value);
519
519
  return actual.length === expected.length && expected.every((key) => actual.includes(key));
520
520
  }
521
521
 
@@ -76,6 +76,7 @@ const ENCLOSING_SCOPE_TYPES: Record<GrammarId, ReadonlySet<string>> = {
76
76
  r: new Set(["function_definition"]),
77
77
  html: new Set(),
78
78
  sql: new Set(),
79
+ latex: new Set(),
79
80
  };
80
81
 
81
82
  // ── Main entrypoint ──────────────────────────────────────────────────
@@ -19,7 +19,11 @@ export async function extractImports(
19
19
  const imports: ImportRecord[] = [];
20
20
 
21
21
  try {
22
- walkForImports(tree.rootNode, source, imports);
22
+ if (parseResult.data.grammarId === "latex") {
23
+ walkForLatexImports(tree.rootNode, source, imports);
24
+ } else {
25
+ walkForImports(tree.rootNode, source, imports);
26
+ }
23
27
  return { kind: "success", data: imports };
24
28
  } finally {
25
29
  tree.delete();
@@ -42,3 +46,53 @@ function walkForImports(node: SyntaxNodeLike, source: string, imports: ImportRec
42
46
  walkForImports(child, source, imports);
43
47
  }
44
48
  }
49
+
50
+ const LATEX_IMPORT_FIELDS: Readonly<Record<string, string>> = {
51
+ package_include: "paths",
52
+ class_include: "path",
53
+ latex_include: "path",
54
+ biblatex_include: "glob",
55
+ bibstyle_include: "path",
56
+ bibtex_include: "paths",
57
+ };
58
+
59
+ function walkForLatexImports(node: SyntaxNodeLike, source: string, imports: ImportRecord[]): void {
60
+ if (node.type === "import_include") {
61
+ const directory = groupContent(node.childForFieldName("directory"));
62
+ const file = groupContent(node.childForFieldName("file"));
63
+ if (directory || file) {
64
+ const separator = directory && !directory.endsWith("/") ? "/" : "";
65
+ imports.push({
66
+ moduleSpecifier: `${directory}${separator}${file}`,
67
+ range: nodeToRange(node, source),
68
+ });
69
+ }
70
+ return;
71
+ }
72
+
73
+ const field = LATEX_IMPORT_FIELDS[node.type];
74
+ if (field) {
75
+ for (const moduleSpecifier of fieldValues(node, field)) {
76
+ if (moduleSpecifier) imports.push({ moduleSpecifier, range: nodeToRange(node, source) });
77
+ }
78
+ return;
79
+ }
80
+
81
+ for (const child of node.children) {
82
+ walkForLatexImports(child, source, imports);
83
+ }
84
+ }
85
+
86
+ function fieldValues(node: SyntaxNodeLike, field: string): string[] {
87
+ const value = node.childForFieldName(field);
88
+ if (!value) return [];
89
+ const paths = value.childrenForFieldName("path");
90
+ if (paths.length > 0) return paths.map((path) => path.text.trim()).filter(Boolean);
91
+ return [groupContent(value)];
92
+ }
93
+
94
+ function groupContent(node: SyntaxNodeLike | null): string {
95
+ if (!node) return "";
96
+ const trimmed = node.text.trim();
97
+ return trimmed.startsWith("{") && trimmed.endsWith("}") ? trimmed.slice(1, -1).trim() : trimmed;
98
+ }