@valbuild/language-server 0.98.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.
@@ -0,0 +1,2451 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import ts from 'typescript';
4
+ import { Internal, FILE_REF_PROP, DEFAULT_VAL_REMOTE_HOST } from '@valbuild/core';
5
+ import { createService, analyzeValModule, createFixPatch, patchSourceFile, extractImageMetadata, extractFileMetadata, findAndEvalValConfigFile } from '@valbuild/server';
6
+ import { DiagnosticSeverity, CodeAction, CodeActionKind, CompletionItemKind } from 'vscode-languageserver';
7
+ import { TextDocuments, TextDocumentSyncKind, createConnection, ProposedFeatures } from 'vscode-languageserver/node';
8
+ import { TextDocument } from 'vscode-languageserver-textdocument';
9
+ import crypto from 'crypto';
10
+ import { createRequire } from 'node:module';
11
+ import { resolveSchemaSourceFixes, getRoutesWithModulePaths } from '@valbuild/shared/internal';
12
+ import { result } from '@valbuild/core/fp';
13
+
14
+ /**
15
+ * The Val language server protocol.
16
+ *
17
+ * This module is the contract between an editor client (for example the Val
18
+ * VS Code extension) and the language server that ships with the Val version
19
+ * installed in the user's project.
20
+ *
21
+ * The whole point of this package is that ONE editor client works against MANY
22
+ * versions of Val. That means:
23
+ *
24
+ * - Clients MUST NOT assume they can import this module. They resolve the
25
+ * server from the user's `node_modules` at runtime, and the constants below
26
+ * are intentionally small and pure so a client can vendor a copy of them.
27
+ * A client may take a type-only devDependency on this package for the types.
28
+ * - Anything added here must degrade gracefully. New capabilities are
29
+ * announced through `features` / `commands` so that a client which has never
30
+ * heard of them simply does not offer them, instead of breaking.
31
+ *
32
+ * Deliberately dependency-free: no `vscode-languageserver` import, no Val
33
+ * imports, no I/O.
34
+ */
35
+
36
+ /**
37
+ * The current protocol version.
38
+ *
39
+ * Bump this ONLY for a breaking change to the client/server contract — a
40
+ * removed or renamed request, a changed payload shape, or changed semantics
41
+ * that an older client would misinterpret. Additive changes (a new feature
42
+ * flag, a new command, a new optional field) do NOT need a bump: they are
43
+ * negotiated through `features` and `commands`.
44
+ *
45
+ * This is a hand-maintained literal on purpose. Deriving it from package.json
46
+ * (as `Internal.VERSION.core` does) breaks under bundling, and build-time
47
+ * string substitution (as `@valbuild/ui`'s VERSION does) is easy to get wrong.
48
+ */
49
+ const PROTOCOL_VERSION = 1;
50
+
51
+ /**
52
+ * The range of protocol versions this server can speak. Kept separate from
53
+ * {@link PROTOCOL_VERSION} so that a future server can continue to serve older
54
+ * clients by lowering `min`.
55
+ */
56
+ const SUPPORTED_PROTOCOL_VERSIONS = {
57
+ min: 1,
58
+ max: PROTOCOL_VERSION
59
+ };
60
+ /**
61
+ * The only environment variables a client may override. `initializationOptions`
62
+ * is untyped JSON at runtime, so this list is what the server enforces: without
63
+ * it a client could set `PATH` or `NODE_OPTIONS` on the server process.
64
+ */
65
+ const VAL_ENV_OVERRIDE_KEYS = ["VAL_CONTENT_URL", "VAL_REMOTE_HOST", "VAL_BUILD_URL"];
66
+
67
+ /**
68
+ * Environment overrides forwarded from the client. These mirror the
69
+ * `VAL_*` environment variables so that an editor can point a session at a
70
+ * non-production Val backend without the user having to restart their editor
71
+ * with a modified environment.
72
+ */
73
+
74
+ /**
75
+ * Sent by the client as `InitializeParams.initializationOptions`.
76
+ *
77
+ * One server instance serves exactly one Val root. A workspace containing
78
+ * several Val roots (a monorepo) gets one server per root, because different
79
+ * roots may pin different versions of Val.
80
+ */
81
+
82
+ /**
83
+ * Optional capabilities a client may implement, announced by the client under
84
+ * `capabilities.experimental.val`.
85
+ *
86
+ * The server uses this to decide whether it can offer flows that need user
87
+ * interaction. A client that implements neither still gets diagnostics and
88
+ * completions.
89
+ */
90
+
91
+ /**
92
+ * Feature flags announced by the server under
93
+ * `capabilities.experimental.val.features`.
94
+ *
95
+ * A client should treat an unknown string as "something this Val version can do
96
+ * that I do not know about" and ignore it, and a missing string as "not
97
+ * available in this Val version" and hide the corresponding UI.
98
+ */
99
+ const VAL_FEATURES = ["diagnostics", "diagnostics/gallery", "completions/route", "completions/keyOf", "completions/mediaPath", "completions/galleryKey", "completions/richtextLink", "fix/metadata", "fix/upload-remote", "fix/download-remote", "fix/missing-module", "fix/gallery", "login"];
100
+
101
+ /**
102
+ * Announced by the server as
103
+ * `InitializeResult.capabilities.experimental.val`.
104
+ */
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Custom requests: server -> client
108
+ //
109
+ // Standard LSP already covers applying edits (`workspace/applyEdit`), opening a
110
+ // URL in a browser (`window/showDocument` with `external: true`), progress
111
+ // (`$/progress`) and confirmations (`window/showMessageRequest`). The two
112
+ // requests below are the only UI primitives LSP lacks that Val needs.
113
+ //
114
+ // Both are deliberately content-agnostic: they carry no Val types, so they do
115
+ // not change when Val changes.
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** Ask the user to choose one of a list of options (a "quick pick"). */
119
+ const VAL_PICK_REQUEST = "val/pick";
120
+
121
+ /** `null` when the user dismissed the picker. */
122
+
123
+ /** Ask the user to type a value (an "input box"). */
124
+ const VAL_INPUT_REQUEST = "val/input";
125
+
126
+ /** `null` when the user dismissed the input box. */
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Version negotiation
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Pick the highest protocol version both sides can speak.
134
+ *
135
+ * Returns a *directional* failure so the client can tell the user which side to
136
+ * update, rather than showing a generic "incompatible versions" error.
137
+ */
138
+ function negotiateProtocolVersion(client, server = SUPPORTED_PROTOCOL_VERSIONS) {
139
+ const min = Math.max(client.min, server.min);
140
+ const max = Math.min(client.max, server.max);
141
+ if (min <= max) {
142
+ return {
143
+ status: "ok",
144
+ protocolVersion: max
145
+ };
146
+ }
147
+ if (server.max < client.min) {
148
+ return {
149
+ status: "server-too-old",
150
+ server,
151
+ client
152
+ };
153
+ }
154
+ return {
155
+ status: "client-too-old",
156
+ server,
157
+ client
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Version of this package, read from its own package.json.
163
+ *
164
+ * Mirrors how `@valbuild/core` reports `Internal.VERSION.core`
165
+ * (see `packages/core/src/index.ts`): the built output lives one directory
166
+ * below the package root, so `../package.json` resolves correctly. Returns
167
+ * `null` rather than throwing if the file cannot be read — the version is only
168
+ * ever used for display, never for behaviour.
169
+ */
170
+ function getLanguageServerVersion() {
171
+ try {
172
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
173
+ return require("../package.json").version;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Access to the editor's in-memory view of a file.
181
+ *
182
+ * An editor holds unsaved ("dirty") buffers that differ from disk. Validation
183
+ * must see what the user is looking at, not what was last saved, so every read
184
+ * goes through this first.
185
+ */
186
+
187
+ /** An {@link OpenDocuments} backed by a plain map. Useful for tests. */
188
+ function mapOpenDocuments(entries = new Map()) {
189
+ const normalized = new Map();
190
+ for (const [k, v] of entries) {
191
+ normalized.set(path.normalize(k), v);
192
+ }
193
+ return {
194
+ read: fsPath => normalized.get(path.normalize(fsPath)),
195
+ set: (fsPath, content) => normalized.set(path.normalize(fsPath), content)
196
+ };
197
+ }
198
+
199
+ /**
200
+ * An `IValFSHost` that overlays the editor's unsaved buffers on top of the real
201
+ * filesystem.
202
+ *
203
+ * `IValFSHost` is the filesystem seam that `createService`, `loadValModules`
204
+ * and `ValSourceFileHandler` all read through, so overriding it here is what
205
+ * makes Val evaluate the user's *current* editor state. Everything else
206
+ * delegates to `ts.sys`, exactly like the default host in
207
+ * `@valbuild/server`'s `createService`.
208
+ */
209
+ function createEditorFsHost(open) {
210
+ return {
211
+ ...ts.sys,
212
+ fileExists(fileName) {
213
+ // A file open in the editor but not yet written to disk still exists as
214
+ // far as validation is concerned.
215
+ if (open.read(fileName) !== undefined) {
216
+ return true;
217
+ }
218
+ return ts.sys.fileExists(fileName);
219
+ },
220
+ readFile(fileName, encoding) {
221
+ const overlay = open.read(fileName);
222
+ if (overlay !== undefined) {
223
+ return overlay;
224
+ }
225
+ return ts.sys.readFile(fileName, encoding);
226
+ },
227
+ readBuffer(fileName) {
228
+ const overlay = open.read(fileName);
229
+ if (overlay !== undefined) {
230
+ return Buffer.from(overlay, "utf8");
231
+ }
232
+ try {
233
+ return fs.readFileSync(fileName);
234
+ } catch {
235
+ return undefined;
236
+ }
237
+ },
238
+ /**
239
+ * Deliberately unimplemented. A language server must never write to the
240
+ * user's files behind their back — every change goes through the editor as
241
+ * a `workspace/applyEdit` so it lands in the undo stack and respects dirty
242
+ * buffers. Throwing here turns an accidental write into a loud failure
243
+ * rather than silent data loss.
244
+ */
245
+ writeFile(fileName) {
246
+ throw Error(`The Val language server must not write files directly (attempted: '${fileName}'). ` + `Produce a workspace edit instead.`);
247
+ },
248
+ rmFile(fileName) {
249
+ throw Error(`The Val language server must not delete files directly (attempted: '${fileName}'). ` + `Produce a workspace edit instead.`);
250
+ }
251
+ };
252
+ }
253
+
254
+ /**
255
+ * A single Val root, and everything needed to evaluate its modules.
256
+ *
257
+ * One of these per Val root — never one shared across roots. Different roots in
258
+ * a monorepo can pin different versions of Val, and each gets its own
259
+ * `Service` (and therefore its own evaluated `val.modules` and module cache).
260
+ */
261
+
262
+ /** What `Service.get` returns; re-declared to avoid depending on an internal type. */
263
+
264
+ const DEFAULT_OPTIONS = {
265
+ validate: true
266
+ };
267
+
268
+ /** Cheap cache key. Not security-sensitive, so sha1 over the source is fine. */
269
+ function fingerprint(content) {
270
+ return content === undefined ? "<missing>" : crypto.createHash("sha1").update(content).digest("hex");
271
+ }
272
+
273
+ /**
274
+ * Whether `@valbuild/core` is resolvable from a Val root.
275
+ *
276
+ * Injectable because jest's module registry intercepts `createRequire` and
277
+ * resolves against the repo regardless of the base path given, so the real
278
+ * implementation always reports success under test.
279
+ */
280
+
281
+ const defaultCoreResolver = valRoot => {
282
+ try {
283
+ createRequire(path.join(valRoot, "package.json")).resolve("@valbuild/core/package.json");
284
+ return true;
285
+ } catch {
286
+ return false;
287
+ }
288
+ };
289
+
290
+ /**
291
+ * Check up front that `@valbuild/core` resolves from the Val root.
292
+ *
293
+ * Val modules import `@valbuild/core`, and so does the `val.modules` file that
294
+ * the service evaluates. When it is not resolvable, every module fails with a fatal
295
+ * "Could not resolve module: '@valbuild/core'" — which reads like a Val bug
296
+ * rather than a missing dependency.
297
+ *
298
+ * This is easy to hit: `@valbuild/core` must be a *direct* dependency (which is
299
+ * what `valbuild-init` enforces), but under pnpm's isolated node_modules a
300
+ * project that only declares `@valbuild/next` has no resolvable core, whereas
301
+ * under npm's hoisting the same project works by accident. Detecting it once
302
+ * here turns N cryptic per-module fatals into one actionable message.
303
+ */
304
+ function checkCoreIsResolvable(valRoot, isCoreResolvable) {
305
+ if (isCoreResolvable(valRoot)) {
306
+ return null;
307
+ }
308
+ return {
309
+ code: "missing-core",
310
+ message: `Could not resolve '@valbuild/core' from '${valRoot}'. ` + `Val requires @valbuild/core as a direct dependency of your project — ` + `add it with your package manager (for example: npm install @valbuild/core).`
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Find every Val module under the Val root.
316
+ *
317
+ * Globs rather than reading `val.modules`, matching what the CLI does: it is a
318
+ * superset, and it keeps working while `val.modules` is mid-edit or broken.
319
+ */
320
+ function findValModuleFilePaths(valRoot) {
321
+ const found = [];
322
+ function walk(dir) {
323
+ let entries;
324
+ try {
325
+ entries = fs.readdirSync(dir, {
326
+ withFileTypes: true
327
+ });
328
+ } catch {
329
+ return;
330
+ }
331
+ for (const entry of entries) {
332
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) {
333
+ continue;
334
+ }
335
+ const absolute = path.join(dir, entry.name);
336
+ if (entry.isDirectory()) {
337
+ walk(absolute);
338
+ } else if (/\.val\.(ts|js|tsx|jsx)$/.test(entry.name)) {
339
+ found.push(`/${path.relative(valRoot, absolute).split(path.sep).join("/")}`);
340
+ }
341
+ }
342
+ }
343
+ walk(valRoot);
344
+ return found.sort();
345
+ }
346
+ function createValProject({
347
+ valRoot,
348
+ open,
349
+ isCoreResolvable = defaultCoreResolver
350
+ }) {
351
+ const host = createEditorFsHost(open);
352
+
353
+ // `createService` calls getCompilerOptions, which THROWS when the Val root has
354
+ // neither tsconfig.json nor jsconfig.json. Initialise lazily and keep the
355
+ // failure as a value so a misconfigured project degrades to "no diagnostics"
356
+ // instead of taking the server down.
357
+ let servicePromise;
358
+
359
+ /**
360
+ * Forget the current `Service` so the next request builds a new one.
361
+ *
362
+ * A `Service` evaluates the whole `val.modules` graph when it is created and
363
+ * then answers `get` from that evaluation, so it is a snapshot of the project
364
+ * at one point in time and cannot re-read a single module. Dropping it is what
365
+ * makes the next request see an edit. `Service.dispose()` is a no-op, so the
366
+ * only cost is the re-evaluation itself.
367
+ */
368
+ function discardService() {
369
+ servicePromise = undefined;
370
+ }
371
+ function startService() {
372
+ const coreProblem = checkCoreIsResolvable(valRoot, isCoreResolvable);
373
+ if (coreProblem) {
374
+ return Promise.resolve({
375
+ status: "error",
376
+ error: coreProblem
377
+ });
378
+ }
379
+ return createService(valRoot, host).then(service => ({
380
+ status: "ok",
381
+ service
382
+ })).catch(e => {
383
+ const message = e instanceof Error ? e.message : String(e);
384
+ return {
385
+ status: "error",
386
+ error: {
387
+ code: /Could not read config from/.test(message) ? "no-config" : "service-failed",
388
+ message
389
+ }
390
+ };
391
+ });
392
+ }
393
+ function getService() {
394
+ if (!servicePromise) {
395
+ const attempt = startService();
396
+ servicePromise = attempt;
397
+ // Only a *successful* evaluation is worth keeping. Everything that makes
398
+ // one fail can be fixed without any Val module's content changing -- add a
399
+ // tsconfig, install @valbuild/core, fix the module that throws, fix
400
+ // val.modules -- so memoizing the failure would leave the session
401
+ // permanently broken. Retrying is cheap: a failing evaluation throws early.
402
+ void attempt.then(resolved => {
403
+ if (resolved.status === "error" && servicePromise === attempt) {
404
+ servicePromise = undefined;
405
+ }
406
+ });
407
+ }
408
+ return servicePromise;
409
+ }
410
+ const cache = new Map();
411
+
412
+ // Snapshot entries are kept across edits and refreshed individually: a
413
+ // keystroke should cost one module evaluation, not one per module.
414
+ const snapshot = {
415
+ schemas: {},
416
+ sources: {}
417
+ };
418
+ let snapshotStale;
419
+
420
+ // One in-flight snapshot build, shared by concurrent callers.
421
+ let snapshotPromise;
422
+ async function buildSnapshot() {
423
+ // First call: everything is stale. Later calls: only what changed.
424
+ if (snapshotStale === undefined) {
425
+ snapshotStale = new Set(findValModuleFilePaths(valRoot));
426
+ }
427
+ // Claim the work list *before* the first await, and leave a fresh set
428
+ // behind. An `invalidate()` that lands while we are evaluating then lands in
429
+ // that fresh set and is refreshed on the next call, instead of being cleared
430
+ // as though this call had handled it.
431
+ const refreshing = [...snapshotStale];
432
+ snapshotStale = new Set();
433
+ const resolved = await getService();
434
+ if (resolved.status === "error") {
435
+ // Nothing was refreshed, so put the work back — unless a full invalidation
436
+ // landed meanwhile, which re-lists everything anyway.
437
+ if (snapshotStale !== undefined) {
438
+ for (const moduleFilePath of refreshing) {
439
+ snapshotStale.add(moduleFilePath);
440
+ }
441
+ }
442
+ return {
443
+ status: "error",
444
+ error: resolved.error
445
+ };
446
+ }
447
+ for (const moduleFilePath of refreshing) {
448
+ const content = await resolved.service.get(moduleFilePath, "",
449
+ // Validation is not needed to answer "what keys/routes exist", and
450
+ // skipping it keeps the snapshot cheap.
451
+ {
452
+ validate: false
453
+ });
454
+ if (content.schema) {
455
+ snapshot.schemas[moduleFilePath] = content.schema;
456
+ } else {
457
+ delete snapshot.schemas[moduleFilePath];
458
+ }
459
+ if (content.source !== undefined) {
460
+ // Same conversion the CLI does when building its snapshot; Source is
461
+ // JSON-shaped once serialized.
462
+ snapshot.sources[moduleFilePath] = content.source;
463
+ } else {
464
+ delete snapshot.sources[moduleFilePath];
465
+ }
466
+ }
467
+ return {
468
+ status: "ok",
469
+ snapshot
470
+ };
471
+ }
472
+ return {
473
+ valRoot,
474
+ async getModule(moduleFilePath, options = DEFAULT_OPTIONS) {
475
+ // Cache on the module's own content as seen by the editor. This covers the
476
+ // common case -- repeated requests while the user edits one file -- but
477
+ // NOT edits to a module's imports; `invalidate()` is how the server
478
+ // handles that.
479
+ const absolute = path.join(valRoot, moduleFilePath);
480
+ const current = fingerprint(host.readFile(absolute));
481
+ const optionsKey = `${+options.validate}`;
482
+ const hit = cache.get(moduleFilePath);
483
+ if (hit) {
484
+ if (hit.fingerprint === current && hit.optionsKey === optionsKey) {
485
+ return {
486
+ status: "ok",
487
+ content: hit.content,
488
+ cached: true
489
+ };
490
+ }
491
+ if (hit.fingerprint !== current) {
492
+ // The content we last evaluated at is gone, so the Service's
493
+ // whole-project evaluation is stale as well. Callers do not have to
494
+ // call `invalidate()` first for this to be correct.
495
+ discardService();
496
+ }
497
+ }
498
+ const resolved = await getService();
499
+ if (resolved.status === "error") {
500
+ return {
501
+ status: "error",
502
+ error: resolved.error
503
+ };
504
+ }
505
+ const content = await resolved.service.get(moduleFilePath, "", options);
506
+ cache.set(moduleFilePath, {
507
+ fingerprint: current,
508
+ optionsKey,
509
+ content
510
+ });
511
+ return {
512
+ status: "ok",
513
+ content,
514
+ cached: false
515
+ };
516
+ },
517
+ listModuleFilePaths: () => findValModuleFilePaths(valRoot),
518
+ getSnapshot() {
519
+ // `snapshot` is one shared object handed to every caller, so a second
520
+ // concurrent build must not be allowed to return it half-filled: the
521
+ // in-flight build is shared instead. Without this, two callers see 1 and 6
522
+ // schemas respectively, and a partial snapshot makes keyOf/route resolution
523
+ // report references that are perfectly valid as broken.
524
+ if (!snapshotPromise) {
525
+ const attempt = buildSnapshot();
526
+ snapshotPromise = attempt;
527
+ void attempt.finally(() => {
528
+ if (snapshotPromise === attempt) {
529
+ snapshotPromise = undefined;
530
+ }
531
+ });
532
+ }
533
+ return snapshotPromise;
534
+ },
535
+ invalidate(moduleFilePath) {
536
+ discardService();
537
+ if (moduleFilePath === undefined) {
538
+ cache.clear();
539
+ snapshotStale = undefined;
540
+ for (const key of Object.keys(snapshot.schemas)) {
541
+ delete snapshot.schemas[key];
542
+ }
543
+ for (const key of Object.keys(snapshot.sources)) {
544
+ delete snapshot.sources[key];
545
+ }
546
+ } else {
547
+ cache.delete(moduleFilePath);
548
+ // Refresh just this module in the snapshot next time it is read.
549
+ if (snapshotStale !== undefined) {
550
+ snapshotStale.add(moduleFilePath);
551
+ }
552
+ }
553
+ },
554
+ cacheSize: () => cache.size,
555
+ async dispose() {
556
+ if (!servicePromise) {
557
+ return;
558
+ }
559
+ const resolved = await servicePromise;
560
+ if (resolved.status === "ok") {
561
+ resolved.service.dispose();
562
+ }
563
+ discardService();
564
+ cache.clear();
565
+ }
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Maps Val module paths onto positions in the module's TypeScript source.
571
+ *
572
+ * Validation errors come back keyed by `SourcePath` (for example
573
+ * `/content/page.val.ts?p="hero"."image"`), but an editor needs a line/column
574
+ * range. This walks the source expression passed to `c.define(...)` and records
575
+ * where each addressable path lands in the file.
576
+ *
577
+ * This is version-sensitive by nature: it encodes how Val's module paths
578
+ * correspond to source syntax, which is why it lives with Val rather than in an
579
+ * editor extension.
580
+ *
581
+ * NOTE: `@valbuild/server` has a near-identical `modulePathMap.ts`, used for the
582
+ * CLI's code frames and the Val UI. This copy differs in locating the source
583
+ * expression via `analyzeValModule` and in exposing
584
+ * {@link findModulePathAtPosition}. Fix traversal bugs in both, or fold them
585
+ * together.
586
+ */
587
+
588
+ /**
589
+ * Look up the source range for a module path.
590
+ *
591
+ * Returns `undefined` when the path cannot be resolved — an unparseable path, or
592
+ * one that does not correspond to any node. That happens legitimately when
593
+ * schema serialization failed upstream, so callers should treat a missing range
594
+ * as "report this diagnostic on the module instead" rather than as a bug.
595
+ */
596
+ function getModulePathRange(modulePath, modulePathMap) {
597
+ if (!modulePath || typeof modulePath !== "string") {
598
+ return undefined;
599
+ }
600
+ let segments;
601
+ try {
602
+ // Val's own splitter: handles quoted segments and escaped quotes, which a
603
+ // naive modulePath.split(".").map(JSON.parse) does not.
604
+ segments = Internal.splitModulePath(modulePath);
605
+ } catch {
606
+ return undefined;
607
+ }
608
+ if (segments.length === 0) {
609
+ return undefined;
610
+ }
611
+ let entry = modulePathMap[segments[0]];
612
+ for (const segment of segments.slice(1)) {
613
+ if (!entry) {
614
+ break;
615
+ }
616
+ entry = entry.children?.[segment];
617
+ }
618
+ if (!entry?.start || !entry?.end) {
619
+ return undefined;
620
+ }
621
+ return {
622
+ start: entry.start,
623
+ end: entry.end
624
+ };
625
+ }
626
+
627
+ /**
628
+ * Find the module path of the innermost entry whose range contains `position`.
629
+ *
630
+ * The inverse of the map's normal use: given where the cursor is, work out which
631
+ * part of the module it addresses, so the schema there can be looked up. Used by
632
+ * schema-driven completions.
633
+ *
634
+ * Segments are encoded with `Internal.patchPathToModulePath`, so the result is
635
+ * addressable by the same functions that consume validation error paths.
636
+ */
637
+ function findModulePathAtPosition(modulePathMap, position) {
638
+ const segments = [];
639
+ function descend(map) {
640
+ for (const [segment, entry] of Object.entries(map)) {
641
+ // `val` is synthetic (it marks where a property's value sits) and `""`
642
+ // marks a bare literal. Both have no children, and `val` spans the entire
643
+ // value — so descending into either would match first and stop the walk
644
+ // before it reached the real nested key. They are skipped as descent
645
+ // targets, and used only for containment below.
646
+ if (segment === "val" || segment === "") {
647
+ continue;
648
+ }
649
+ // An entry's own range covers only its key, so also accept a cursor inside
650
+ // its value; otherwise nothing nested would ever match its parents.
651
+ const valChild = entry.children?.val;
652
+ if (!contains(entry, position) && !(valChild && contains(valChild, position))) {
653
+ continue;
654
+ }
655
+ segments.push(segment);
656
+ descend(entry.children);
657
+ return true;
658
+ }
659
+ return false;
660
+ }
661
+ if (!descend(modulePathMap)) {
662
+ return undefined;
663
+ }
664
+ return Internal.patchPathToModulePath(segments);
665
+ }
666
+ function contains(range, position) {
667
+ const afterStart = position.line > range.start.line || position.line === range.start.line && position.character >= range.start.character;
668
+ const beforeEnd = position.line < range.end.line || position.line === range.end.line && position.character <= range.end.character;
669
+ return afterStart && beforeEnd;
670
+ }
671
+
672
+ /**
673
+ * Build a {@link ModulePathMap} for a Val module source file.
674
+ *
675
+ * Returns `undefined` when the file is not a recognisable Val module (no
676
+ * `export default c.define(...)`).
677
+ */
678
+ function createModulePathMap(sourceFile) {
679
+ const source = findSourceExpression(sourceFile);
680
+ if (!source) {
681
+ return undefined;
682
+ }
683
+ return traverse(source, sourceFile);
684
+ }
685
+
686
+ /**
687
+ * Locate the third argument of `c.define(...)` — the module's content.
688
+ *
689
+ * Uses Val's own `analyzeValModule`, which validates that the default export
690
+ * really is a `c.define` call with a string-literal path, rather than blindly
691
+ * taking `arguments[2]` of whatever the default export happens to call.
692
+ */
693
+ function findSourceExpression(sourceFile) {
694
+ let analysis;
695
+ try {
696
+ analysis = analyzeValModule(sourceFile);
697
+ } catch {
698
+ // analyzeValModule throws when there is no default export at all.
699
+ return undefined;
700
+ }
701
+ if (result.isErr(analysis)) {
702
+ return undefined;
703
+ }
704
+ return analysis.value.source;
705
+ }
706
+ function traverse(node, sourceFile) {
707
+ if (ts.isStringLiteral(node) || ts.isNumericLiteral(node)) {
708
+ return {
709
+ "": {
710
+ children: {},
711
+ ...rangeOfNode(node, sourceFile)
712
+ }
713
+ };
714
+ }
715
+ if (ts.isObjectLiteralExpression(node)) {
716
+ return traverseObjectLiteral(node, sourceFile);
717
+ }
718
+ if (ts.isArrayLiteralExpression(node)) {
719
+ return traverseArrayLiteral(node, sourceFile);
720
+ }
721
+ if (ts.isCallExpression(node)) {
722
+ return traverseCallExpression(node, sourceFile);
723
+ }
724
+ }
725
+
726
+ /**
727
+ * The line/character range of `node`'s own text (leading trivia excluded).
728
+ *
729
+ * NOTE: do not compute the start as `end.character - node.getWidth()`. That
730
+ * identity only holds while the node stays on a single line - for a multi-line
731
+ * node (an object inside an array, a `c.image` metadata argument, ...) it
732
+ * reports the *closing* line and a negative character. `getStart(sourceFile)`
733
+ * needs no parent pointers as long as the source file is passed explicitly,
734
+ * which is why it is safe here.
735
+ */
736
+ function rangeOfNode(node, sourceFile) {
737
+ return {
738
+ start: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)),
739
+ end: sourceFile.getLineAndCharacterOfPosition(node.getEnd())
740
+ };
741
+ }
742
+
743
+ /**
744
+ * `c.image(...)` / `c.file(...)` expose three addressable paths: the call itself
745
+ * (`val`), the reference argument (`_ref`) and the metadata argument
746
+ * (`metadata`). Validation errors about a missing file point at `_ref`, and
747
+ * errors about metadata point at `metadata`, so both need their own range.
748
+ */
749
+ function traverseCallExpression(node, sourceFile) {
750
+ if (!ts.isPropertyAccessExpression(node.expression)) {
751
+ return undefined;
752
+ }
753
+ const isValFileConstructor = node.expression.expression.getText(sourceFile) === "c" && (node.expression.name.getText(sourceFile) === "file" || node.expression.name.getText(sourceFile) === "image");
754
+ if (!isValFileConstructor || !node.arguments[0]) {
755
+ return undefined;
756
+ }
757
+ const val = {
758
+ children: {},
759
+ ...rangeOfNode(node, sourceFile)
760
+ };
761
+ const _ref = {
762
+ children: {},
763
+ ...rangeOfNode(node.arguments[0], sourceFile)
764
+ };
765
+ if (!node.arguments[1]) {
766
+ return {
767
+ val,
768
+ _ref
769
+ };
770
+ }
771
+ return {
772
+ val,
773
+ _ref,
774
+ metadata: {
775
+ children: {},
776
+ ...rangeOfNode(node.arguments[1], sourceFile)
777
+ }
778
+ };
779
+ }
780
+ function traverseArrayLiteral(node, sourceFile) {
781
+ const map = {};
782
+ node.elements.forEach((element, index) => {
783
+ if (!ts.isExpression(element)) {
784
+ return;
785
+ }
786
+ map[index] = {
787
+ children: traverse(element, sourceFile) ?? {},
788
+ ...rangeOfNode(element, sourceFile)
789
+ };
790
+ });
791
+ return map;
792
+ }
793
+ function traverseObjectLiteral(node, sourceFile) {
794
+ const map = {};
795
+ for (const property of node.properties) {
796
+ if (!ts.isPropertyAssignment(property)) {
797
+ continue;
798
+ }
799
+ const key = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && property.name.text;
800
+ // NOTE: this also skips `""`, which is what a gallery key looks like the
801
+ // moment the user opens the quote. That is deliberate, not an oversight: a
802
+ // module path cannot address an empty segment, because
803
+ // `Internal.splitModulePath('""')` returns `[]`, and `""` already means
804
+ // "bare literal" in this map (see findModulePathAtPosition). Supporting it
805
+ // needs a change in @valbuild/core first.
806
+ if (!key) {
807
+ continue;
808
+ }
809
+ map[key] = {
810
+ children: {
811
+ // `val` addresses the property's value, whereas the key's own range is
812
+ // what a diagnostic about the field itself should highlight.
813
+ val: {
814
+ children: {},
815
+ ...rangeOfNode(property.initializer, sourceFile)
816
+ },
817
+ ...traverse(property.initializer, sourceFile)
818
+ },
819
+ ...rangeOfNode(property.name, sourceFile)
820
+ };
821
+ }
822
+ return map;
823
+ }
824
+
825
+ /** Marks diagnostics as ours, so a client can filter on it. */
826
+ const VAL_DIAGNOSTIC_SOURCE = "val";
827
+
828
+ /**
829
+ * Every diagnostic this server can produce.
830
+ *
831
+ * One naming convention, deliberately: `val/` followed by kebab-case. The
832
+ * VS Code extension this replaces had accumulated three conventions at once
833
+ * (`file-not-found`, `val:missing-module`, `image:add-to-gallery`), which made
834
+ * codes impossible to match on reliably.
835
+ *
836
+ * These are *diagnostic* codes and are distinct from *fix* names, which come
837
+ * from `ValidationFix` in `@valbuild/core` and keep Val's own `image:`/`file:`
838
+ * vocabulary. A diagnostic says what is wrong; a fix says what can be done
839
+ * about it, and travels in {@link ValDiagnosticData.fixes}.
840
+ */
841
+ const VAL_DIAGNOSTIC_CODES = [/** Content does not satisfy the schema. */
842
+ "val/validation", /** The schema itself is invalid. */
843
+ "val/schema", /** The module could not be evaluated at all. */
844
+ "val/fatal", /** A referenced image or file is not on disk. */
845
+ "val/file-not-found", /** The module is not registered in `val.modules`, so Val will not serve it. */
846
+ "val/missing-module"];
847
+
848
+ /**
849
+ * Structured payload attached to every Val diagnostic.
850
+ *
851
+ * Carried in `Diagnostic.data` (LSP 3.16), which is round-tripped back to the
852
+ * server on `textDocument/codeAction`. This replaces encoding information into
853
+ * the diagnostic's `code` string and parsing it out again — that approach could
854
+ * not carry anything but strings and broke whenever the format shifted.
855
+ */
856
+
857
+ /**
858
+ * Severity policy, in one place.
859
+ *
860
+ * - **Warning** — Val can fix it automatically. Mirrors the CLI, which prints
861
+ * these with `⚠` (its `validation-fixable-error` event) rather than `✘`. In an
862
+ * editor a one-click-fixable metadata mismatch should not shout as loudly as a
863
+ * type error.
864
+ * - **Error** — everything else: the content is wrong, the schema is wrong, or
865
+ * the module does not work at all.
866
+ *
867
+ * Note that `val validate` still exits non-zero for fixable errors, so Warning
868
+ * here is about presentation, not about the problem being optional.
869
+ */
870
+ function severityFor({
871
+ code,
872
+ fixes
873
+ }) {
874
+ if (code === "val/validation" && fixes && fixes.length > 0) {
875
+ return DiagnosticSeverity.Warning;
876
+ }
877
+ return DiagnosticSeverity.Error;
878
+ }
879
+
880
+ /** Range covering the start of the file, used when a path cannot be located. */
881
+ const FALLBACK_RANGE = {
882
+ start: {
883
+ line: 0,
884
+ character: 0
885
+ },
886
+ end: {
887
+ line: 0,
888
+ character: 0
889
+ }
890
+ };
891
+
892
+ /** Fixes that operate on a file or image reference. */
893
+ const FILE_FIXES = ["image:add-metadata", "image:check-metadata", "image:upload-remote", "image:download-remote", "image:check-remote", "file:add-metadata", "file:check-metadata", "file:upload-remote", "file:download-remote", "file:check-remote"];
894
+ function build(range, message, data) {
895
+ return {
896
+ range,
897
+ severity: severityFor(data),
898
+ source: VAL_DIAGNOSTIC_SOURCE,
899
+ code: data.code,
900
+ message,
901
+ data
902
+ };
903
+ }
904
+ function createValDiagnostics({
905
+ moduleFilePath,
906
+ content,
907
+ text,
908
+ valRoot,
909
+ snapshot
910
+ }) {
911
+ if (content.errors === false) {
912
+ return [];
913
+ }
914
+ const diagnostics = [];
915
+
916
+ // A fatal error means the module could not be evaluated, so there is no
917
+ // reliable path information: report it on the whole file.
918
+ for (const fatal of content.errors.fatal ?? []) {
919
+ diagnostics.push(build(FALLBACK_RANGE, fatal.message, {
920
+ code: "val/fatal",
921
+ sourcePath: moduleFilePath
922
+ }));
923
+ }
924
+ const rawValidation = content.errors.validation;
925
+ if (!rawValidation) {
926
+ return diagnostics;
927
+ }
928
+
929
+ // Resolve the deferred keyOf/route placeholders against the rest of the
930
+ // project. This is the same call `val validate` and the Val UI make, so all
931
+ // three agree on which references are actually broken.
932
+ const validation = snapshot ? resolveSchemaSourceFixes(rawValidation, snapshot) : dropDeferredPlaceholders(rawValidation);
933
+
934
+ // Only parse the source file if there is something to place in it.
935
+ const modulePathMap = createModulePathMap(ts.createSourceFile(moduleFilePath, text, ts.ScriptTarget.ES2020));
936
+ for (const [sourcePath, errors] of Object.entries(validation)) {
937
+ for (const error of errors) {
938
+ const fixes = error.fixes;
939
+
940
+ // A file-related fix cannot succeed if the file is not there, and
941
+ // "metadata is incorrect" is a misleading way to say "the file is
942
+ // missing". Report the real problem instead, exactly as the CLI's fix
943
+ // handlers do when their precondition fails.
944
+ const missing = valRoot && fixes?.some(fix => FILE_FIXES.includes(fix)) ? missingFileRef({
945
+ sourcePath,
946
+ content,
947
+ valRoot
948
+ }) : undefined;
949
+ if (missing) {
950
+ diagnostics.push(build(rangeOf(sourcePath, modulePathMap, "_ref"), `File ${missing} does not exist`, {
951
+ code: "val/file-not-found",
952
+ sourcePath,
953
+ filePath: missing
954
+ }));
955
+ continue;
956
+ }
957
+ diagnostics.push(build(rangeOf(sourcePath, modulePathMap), error.message, {
958
+ code: error.schemaError ? "val/schema" : "val/validation",
959
+ sourcePath,
960
+ ...(fixes ? {
961
+ fixes
962
+ } : {}),
963
+ ...(error.value !== undefined ? {
964
+ value: error.value
965
+ } : {})
966
+ }));
967
+ }
968
+ }
969
+ return diagnostics;
970
+ }
971
+
972
+ /**
973
+ * Resolve a source path to its file reference and report the absolute path when
974
+ * it is not on disk.
975
+ *
976
+ * Mirrors the precondition check in `handleFileMetadata`
977
+ * (`packages/cli/src/runValidation.ts`): resolve the path, read `FILE_REF_PROP`,
978
+ * check the file exists.
979
+ */
980
+ function missingFileRef({
981
+ sourcePath,
982
+ content,
983
+ valRoot
984
+ }) {
985
+ if (!content.source || !content.schema) {
986
+ return undefined;
987
+ }
988
+ try {
989
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(sourcePath);
990
+ const resolved = Internal.resolvePath(modulePath, content.source, content.schema);
991
+ const ref = resolved.source?.[FILE_REF_PROP];
992
+ if (typeof ref !== "string") {
993
+ return undefined;
994
+ }
995
+ // Remote references are URLs and are not expected on disk.
996
+ if (Internal.remote.splitRemoteRef(ref).status === "success") {
997
+ return undefined;
998
+ }
999
+ const filePath = path.join(valRoot, ref);
1000
+ return fs.existsSync(filePath) ? undefined : filePath;
1001
+ } catch {
1002
+ // Resolution can fail when the schema failed to serialize; skip the check
1003
+ // rather than dropping the underlying validation error.
1004
+ return undefined;
1005
+ }
1006
+ }
1007
+
1008
+ /**
1009
+ * Diagnostic for a project that could not be evaluated at all.
1010
+ *
1011
+ * `createService` evaluates the whole `val.modules` graph, so one module that
1012
+ * throws stops every module from being evaluated — as do a missing tsconfig and
1013
+ * an unresolvable `@valbuild/core`. Reporting it on the file the user is looking
1014
+ * at is imprecise, but the alternative is that all Val diagnostics silently
1015
+ * disappear the moment a project stops evaluating.
1016
+ */
1017
+ function createProjectErrorDiagnostic({
1018
+ moduleFilePath,
1019
+ message
1020
+ }) {
1021
+ return build(FALLBACK_RANGE, `Val could not evaluate this project, so no module is validated: ${message}`, {
1022
+ code: "val/fatal",
1023
+ sourcePath: moduleFilePath
1024
+ });
1025
+ }
1026
+
1027
+ /**
1028
+ * Diagnostic for a Val module that is not registered in `val.modules`.
1029
+ *
1030
+ * Val only serves modules listed there, so an unregistered module silently does
1031
+ * nothing — worth surfacing even though it is not a validation error.
1032
+ */
1033
+ function createMissingModuleDiagnostic({
1034
+ moduleFilePath
1035
+ }) {
1036
+ return build(FALLBACK_RANGE, `${moduleFilePath} is not registered in val.modules, so Val will not serve it.`, {
1037
+ code: "val/missing-module",
1038
+ sourcePath: moduleFilePath
1039
+ });
1040
+ }
1041
+ function rangeOf(sourcePath, modulePathMap, /** Optional child segment to prefer, for example `_ref`. */
1042
+ preferChild) {
1043
+ if (!modulePathMap) {
1044
+ return FALLBACK_RANGE;
1045
+ }
1046
+ let modulePath;
1047
+ try {
1048
+ [, modulePath] = Internal.splitModuleFilePathAndModulePath(sourcePath);
1049
+ } catch {
1050
+ // A path we cannot split is still worth reporting, just on the whole file.
1051
+ return FALLBACK_RANGE;
1052
+ }
1053
+ // Module-level errors have an empty module path.
1054
+ if (!modulePath) {
1055
+ return FALLBACK_RANGE;
1056
+ }
1057
+ if (preferChild) {
1058
+ const child = getModulePathRange(`${modulePath}.${JSON.stringify(preferChild)}`, modulePathMap);
1059
+ if (child) {
1060
+ return {
1061
+ start: child.start,
1062
+ end: child.end
1063
+ };
1064
+ }
1065
+ }
1066
+ const range = getModulePathRange(modulePath, modulePathMap);
1067
+ return range ? {
1068
+ start: range.start,
1069
+ end: range.end
1070
+ } : FALLBACK_RANGE;
1071
+ }
1072
+
1073
+ /** Fixes core cannot resolve without a project-wide snapshot. */
1074
+ const DEFERRED_FIXES = ["keyof:check-keys", "router:check-route"];
1075
+
1076
+ /**
1077
+ * Fallback for when no snapshot is available: drop the placeholders rather than
1078
+ * show their developer-facing text.
1079
+ */
1080
+ function dropDeferredPlaceholders(validation) {
1081
+ const out = {};
1082
+ for (const [sourcePath, errors] of Object.entries(validation)) {
1083
+ const kept = errors.filter(error => !(error.fixes?.length && error.fixes.every(fix => DEFERRED_FIXES.includes(fix))));
1084
+ if (kept.length > 0) {
1085
+ out[sourcePath] = kept;
1086
+ }
1087
+ }
1088
+ return out;
1089
+ }
1090
+
1091
+ /**
1092
+ * Quick fixes, built by running Val's own fix machinery.
1093
+ *
1094
+ * The pipeline is deliberately the same one `val validate --fix` uses:
1095
+ *
1096
+ * ValidationError -> createFixPatch -> Patch -> patchSourceFile -> TextEdit
1097
+ *
1098
+ * so an editor fix and a CLI fix cannot diverge. The previous VS Code extension
1099
+ * hand-wrote AST edits per fix kind, which is how the two drifted apart.
1100
+ */
1101
+
1102
+ /**
1103
+ * Fixes that can be computed locally, without network access or credentials.
1104
+ *
1105
+ * Remote upload/download need a logged-in session and are handled by a separate
1106
+ * flow, so they are not offered as plain quick fixes: a code action that
1107
+ * silently required auth would just fail.
1108
+ */
1109
+ const LOCAL_FIXES = ["image:add-metadata", "image:check-metadata", "file:add-metadata", "file:check-metadata",
1110
+ // Gallery metadata: createFixPatch reads each entry's file and corrects the
1111
+ // stored metadata, dropping entries whose file has gone. Filesystem only.
1112
+ "images:check-all-files", "files:check-all-files"];
1113
+
1114
+ /** Human-readable titles; falls back to the fix name for anything unknown. */
1115
+ const FIX_TITLES = {
1116
+ "image:add-metadata": "Val: add image metadata",
1117
+ "image:check-metadata": "Val: update image metadata",
1118
+ "file:add-metadata": "Val: add file metadata",
1119
+ "file:check-metadata": "Val: update file metadata",
1120
+ "images:check-all-files": "Val: update gallery image metadata",
1121
+ "files:check-all-files": "Val: update gallery file metadata"
1122
+ };
1123
+ function isLocalFix(fix) {
1124
+ return LOCAL_FIXES.includes(fix);
1125
+ }
1126
+
1127
+ /**
1128
+ * Build quick fixes for the diagnostics the client sent back.
1129
+ *
1130
+ * The client returns our `Diagnostic.data` verbatim, which is where the source
1131
+ * path and available fixes come from — no re-deriving them from a code string.
1132
+ */
1133
+ async function createValCodeActions({
1134
+ document,
1135
+ diagnostics,
1136
+ content,
1137
+ valRoot,
1138
+ remoteHost = process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST
1139
+ }) {
1140
+ const actions = [];
1141
+ for (const diagnostic of diagnostics) {
1142
+ const data = diagnostic.data;
1143
+ if (!data?.fixes?.length) {
1144
+ continue;
1145
+ }
1146
+ for (const fix of data.fixes) {
1147
+ if (!isLocalFix(fix)) {
1148
+ continue;
1149
+ }
1150
+ const edit = await computeFixEdit({
1151
+ document,
1152
+ sourcePath: data.sourcePath,
1153
+ // createFixPatch works one fix at a time; give it exactly this one so a
1154
+ // failing sibling fix cannot suppress this action.
1155
+ validationError: {
1156
+ message: diagnostic.message,
1157
+ value: data.value,
1158
+ fixes: [fix]
1159
+ },
1160
+ content,
1161
+ valRoot,
1162
+ remoteHost
1163
+ });
1164
+ if (!edit) {
1165
+ continue;
1166
+ }
1167
+ actions.push(CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`, {
1168
+ changes: {
1169
+ [document.uri]: [edit]
1170
+ }
1171
+ }, CodeActionKind.QuickFix));
1172
+ }
1173
+ }
1174
+ return actions;
1175
+ }
1176
+ async function computeFixEdit({
1177
+ document,
1178
+ sourcePath,
1179
+ validationError,
1180
+ content,
1181
+ valRoot,
1182
+ remoteHost
1183
+ }) {
1184
+ let fixed;
1185
+ try {
1186
+ fixed = await createFixPatch({
1187
+ projectRoot: valRoot,
1188
+ remoteHost
1189
+ },
1190
+ // `true` means "produce the patch"; nothing is written to disk here, the
1191
+ // patch is applied to the editor's text and returned as an edit.
1192
+ true, sourcePath, validationError, {}, content.source, content.schema);
1193
+ } catch {
1194
+ return undefined;
1195
+ }
1196
+ if (!fixed || fixed.patch.length === 0) {
1197
+ return undefined;
1198
+ }
1199
+ const before = document.getText();
1200
+ const patched = patchSourceFile(before, fixed.patch);
1201
+ if (result.isErr(patched)) {
1202
+ return undefined;
1203
+ }
1204
+ return minimalTextEdit(before, patched.value.text, document);
1205
+ }
1206
+
1207
+ /**
1208
+ * Narrow an edit down to the region that actually changed.
1209
+ *
1210
+ * A whole-document replacement would work, but it moves the cursor and shows up
1211
+ * as a full-file change in review. Trimming the common prefix and suffix keeps
1212
+ * the edit tight without needing a real diff algorithm.
1213
+ */
1214
+ function minimalTextEdit(before, after, document) {
1215
+ if (before === after) {
1216
+ return undefined;
1217
+ }
1218
+ let prefix = 0;
1219
+ const maxPrefix = Math.min(before.length, after.length);
1220
+ while (prefix < maxPrefix && before[prefix] === after[prefix]) {
1221
+ prefix++;
1222
+ }
1223
+ let suffix = 0;
1224
+ const maxSuffix = Math.min(before.length, after.length) - prefix;
1225
+ while (suffix < maxSuffix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) {
1226
+ suffix++;
1227
+ }
1228
+ const range = {
1229
+ start: document.positionAt(prefix),
1230
+ end: document.positionAt(before.length - suffix)
1231
+ };
1232
+ return {
1233
+ range,
1234
+ newText: after.slice(prefix, after.length - suffix)
1235
+ };
1236
+ }
1237
+
1238
+ /**
1239
+ * Works out what the cursor is sitting in, so completions can be offered for it.
1240
+ *
1241
+ * AST-based rather than text/regex-based: `c.image(` can be nested, wrapped or
1242
+ * multi-line, and matching on text gets that wrong in exactly the cases where a
1243
+ * user most wants help.
1244
+ */
1245
+
1246
+ /** The cursor is inside a plain string in the module's content. */
1247
+
1248
+ /**
1249
+ * The offsets of a string literal's contents, excluding its quotes.
1250
+ *
1251
+ * A client that does not auto-close quotes leaves `c.image("` unterminated while
1252
+ * the user types. TypeScript still produces a string-literal node for it, but the
1253
+ * node ends *at* the cursor rather than one character past it, so the usual
1254
+ * `getEnd() - 1` bound excludes every position inside the literal and no
1255
+ * completions are offered at all. The closing quote is therefore counted rather
1256
+ * than assumed.
1257
+ */
1258
+ function contentRangeOf(node, sourceFile) {
1259
+ const raw = node.getText(sourceFile);
1260
+ const quote = raw[0];
1261
+ let closing = 0;
1262
+ if (raw.length >= 2 && raw[raw.length - 1] === quote) {
1263
+ // A quote preceded by an odd number of backslashes is escaped, so it is part
1264
+ // of the contents rather than the terminator.
1265
+ let backslashes = 0;
1266
+ for (let i = raw.length - 2; i >= 1 && raw[i] === "\\"; i--) {
1267
+ backslashes++;
1268
+ }
1269
+ closing = backslashes % 2 === 0 ? 1 : 0;
1270
+ }
1271
+ return {
1272
+ contentStart: node.getStart(sourceFile) + 1,
1273
+ contentEnd: node.getEnd() - closing
1274
+ };
1275
+ }
1276
+
1277
+ /**
1278
+ * Find the innermost `c.image(...)` / `c.file(...)` call whose first argument
1279
+ * contains `offset`.
1280
+ */
1281
+ function getValCompletionContext(sourceFile, offset) {
1282
+ let found;
1283
+ let innermostString;
1284
+ let innermostStringContent;
1285
+ // Tracked explicitly: `ts.createSourceFile` does not set parent pointers
1286
+ // unless asked, so `node.parent` cannot be relied on here.
1287
+ let innermostStringParent;
1288
+ function visit(node, parent) {
1289
+ if (offset < node.getStart(sourceFile) || offset > node.getEnd()) {
1290
+ return;
1291
+ }
1292
+ if (ts.isCallExpression(node)) {
1293
+ const subType = fileConstructorSubType(node, sourceFile);
1294
+ const [refArg] = node.arguments;
1295
+ const refContent = refArg && ts.isStringLiteralLike(refArg) ? contentRangeOf(refArg, sourceFile) : undefined;
1296
+ if (subType && refArg && ts.isStringLiteralLike(refArg) && refContent &&
1297
+ // Inside the quotes, inclusive of both ends so completion works on an
1298
+ // empty string and at either edge.
1299
+ offset >= refContent.contentStart && offset <= refContent.contentEnd) {
1300
+ const metadataArg = node.arguments[1];
1301
+ found = {
1302
+ kind: "file-ref",
1303
+ subType,
1304
+ currentText: refArg.text,
1305
+ ...refContent,
1306
+ refArgStart: refArg.getStart(sourceFile),
1307
+ refArgEnd: refArg.getEnd(),
1308
+ ...(metadataArg ? {
1309
+ metadataStart: metadataArg.getStart(sourceFile),
1310
+ metadataEnd: metadataArg.getEnd()
1311
+ } : {})
1312
+ };
1313
+ }
1314
+ }
1315
+ if (ts.isStringLiteralLike(node)) {
1316
+ const content = contentRangeOf(node, sourceFile);
1317
+ if (offset >= content.contentStart && offset <= content.contentEnd) {
1318
+ innermostString = node;
1319
+ innermostStringContent = content;
1320
+ innermostStringParent = parent;
1321
+ }
1322
+ }
1323
+ ts.forEachChild(node, child => visit(child, node));
1324
+ }
1325
+ visit(sourceFile, undefined);
1326
+ if (found) {
1327
+ return found;
1328
+ }
1329
+ // Not a file reference, but still inside a string: schema-driven completions
1330
+ // (keyOf keys, route paths) decide whether they apply.
1331
+ if (innermostString && innermostStringContent) {
1332
+ return {
1333
+ kind: "string-value",
1334
+ currentText: innermostString.text,
1335
+ ...innermostStringContent,
1336
+ // Uses the parent tracked during the walk, not `node.parent`, which
1337
+ // `ts.createSourceFile` leaves unset unless asked to populate it.
1338
+ isPropertyName: Boolean(innermostStringParent && ts.isPropertyAssignment(innermostStringParent) && innermostStringParent.name === innermostString),
1339
+ ...(innermostStringParent && ts.isPropertyAssignment(innermostStringParent) && innermostStringParent.name !== innermostString && (ts.isIdentifier(innermostStringParent.name) || ts.isStringLiteral(innermostStringParent.name)) ? {
1340
+ valueOfProperty: innermostStringParent.name.text
1341
+ } : {})
1342
+ };
1343
+ }
1344
+ return undefined;
1345
+ }
1346
+
1347
+ /**
1348
+ * Re-find the `c.image(...)` / `c.file(...)` call whose reference argument starts
1349
+ * at `refArgStart`, and report where its arguments are *now*.
1350
+ *
1351
+ * `completionItem/resolve` runs against a document the user may have typed into
1352
+ * since the list was computed, so the offsets captured back then have moved.
1353
+ * Applying them anyway inserts the metadata object into the middle of the string
1354
+ * literal and corrupts the file, so the offsets are re-derived here instead.
1355
+ *
1356
+ * Returns `undefined` when no such call is found — the document changed in some
1357
+ * way this anchor does not survive, and the caller must then offer no edit
1358
+ * rather than a wrong one.
1359
+ */
1360
+ function findFileRefArgument(sourceFile, refArgStart) {
1361
+ let found;
1362
+ function visit(node) {
1363
+ if (found) {
1364
+ return;
1365
+ }
1366
+ if (ts.isCallExpression(node) && fileConstructorSubType(node, sourceFile)) {
1367
+ const [refArg] = node.arguments;
1368
+ if (refArg && ts.isStringLiteralLike(refArg) && refArg.getStart(sourceFile) === refArgStart) {
1369
+ const metadataArg = node.arguments[1];
1370
+ found = {
1371
+ refArgEnd: refArg.getEnd(),
1372
+ ...(metadataArg ? {
1373
+ metadataStart: metadataArg.getStart(sourceFile),
1374
+ metadataEnd: metadataArg.getEnd()
1375
+ } : {})
1376
+ };
1377
+ return;
1378
+ }
1379
+ }
1380
+ ts.forEachChild(node, visit);
1381
+ }
1382
+ visit(sourceFile);
1383
+ return found;
1384
+ }
1385
+ function fileConstructorSubType(node, sourceFile) {
1386
+ if (!ts.isPropertyAccessExpression(node.expression)) {
1387
+ return undefined;
1388
+ }
1389
+ if (node.expression.expression.getText(sourceFile) !== "c") {
1390
+ return undefined;
1391
+ }
1392
+ const name = node.expression.name.getText(sourceFile);
1393
+ return name === "image" || name === "file" ? name : undefined;
1394
+ }
1395
+
1396
+ /**
1397
+ * Completions for file and image references.
1398
+ *
1399
+ * Offers the files that actually exist under the project's files directory, and
1400
+ * — when the item is accepted — fills in the metadata argument by reading the
1401
+ * chosen file. Getting width/height/mimeType right by hand is tedious and a
1402
+ * frequent source of the very validation errors this server reports.
1403
+ */
1404
+
1405
+ /** Stashed on the item so `resolve` can do the expensive work lazily. */
1406
+
1407
+ function createValCompletions({
1408
+ document,
1409
+ offset,
1410
+ files,
1411
+ moduleFilePath,
1412
+ snapshot
1413
+ }) {
1414
+ const sourceFile = ts.createSourceFile(document.uri, document.getText(), ts.ScriptTarget.ES2020);
1415
+ const context = getValCompletionContext(sourceFile, offset);
1416
+ if (!context) {
1417
+ return [];
1418
+ }
1419
+ if (context.kind === "string-value") {
1420
+ if (!moduleFilePath || !snapshot) {
1421
+ return [];
1422
+ }
1423
+ return createSchemaDrivenCompletions({
1424
+ document,
1425
+ sourceFile,
1426
+ offset,
1427
+ moduleFilePath,
1428
+ snapshot,
1429
+ files,
1430
+ isPropertyName: context.isPropertyName,
1431
+ valueOfProperty: context.valueOfProperty,
1432
+ contentStart: context.contentStart,
1433
+ contentEnd: context.contentEnd
1434
+ });
1435
+ }
1436
+
1437
+ // `c.image()` only accepts images; `c.file()` accepts anything.
1438
+ const candidates = context.subType === "image" ? files.images() : files.list();
1439
+
1440
+ // Replace the whole string contents rather than inserting at the cursor, so
1441
+ // completing over an existing path does not concatenate the two.
1442
+ const replaceRange = {
1443
+ start: document.positionAt(context.contentStart),
1444
+ end: document.positionAt(context.contentEnd)
1445
+ };
1446
+ return candidates.map((file, index) => {
1447
+ const data = {
1448
+ kind: "file-ref",
1449
+ uri: document.uri,
1450
+ ref: file.ref,
1451
+ filePath: file.filePath,
1452
+ subType: context.subType,
1453
+ refArgStart: context.refArgStart
1454
+ };
1455
+ return {
1456
+ label: file.ref,
1457
+ kind: CompletionItemKind.File,
1458
+ detail: file.mimeType,
1459
+ textEdit: {
1460
+ range: replaceRange,
1461
+ newText: file.ref
1462
+ },
1463
+ // Preserve directory ordering rather than letting the client sort
1464
+ // alphabetically on the full path.
1465
+ sortText: String(index).padStart(5, "0"),
1466
+ data
1467
+ };
1468
+ });
1469
+ }
1470
+
1471
+ /**
1472
+ * Fill in the metadata argument for an accepted file reference.
1473
+ *
1474
+ * Done at resolve time because it reads the file from disk, and an editor
1475
+ * requests completions far more often than it accepts one.
1476
+ */
1477
+ async function resolveValCompletion({
1478
+ item,
1479
+ documents
1480
+ }) {
1481
+ const data = item.data;
1482
+ if (data?.kind !== "file-ref") {
1483
+ return item;
1484
+ }
1485
+ const document = documents.get(data.uri);
1486
+ if (!document) {
1487
+ return item;
1488
+ }
1489
+
1490
+ // Re-derive the argument offsets against the document as it is *now*: the user
1491
+ // may have typed to filter the list since it was computed, which moves
1492
+ // everything after the reference string. `additionalTextEdits` are applied
1493
+ // verbatim by the client, so a stale offset here corrupts the file.
1494
+ const args = findFileRefArgument(ts.createSourceFile(data.uri, document.getText(), ts.ScriptTarget.ES2020, false, ts.ScriptKind.TS), data.refArgStart);
1495
+ if (!args) {
1496
+ // The anchor no longer resolves, so there is no safe place to put the
1497
+ // metadata. `val validate --fix` and the metadata quick fix still cover it.
1498
+ return item;
1499
+ }
1500
+ const metadata = await readMetadata(data);
1501
+ if (!metadata) {
1502
+ return item;
1503
+ }
1504
+ const edit = args.metadataStart !== undefined && args.metadataEnd !== undefined ? {
1505
+ // Replace an existing metadata argument.
1506
+ range: {
1507
+ start: document.positionAt(args.metadataStart),
1508
+ end: document.positionAt(args.metadataEnd)
1509
+ },
1510
+ newText: metadata
1511
+ } : {
1512
+ // Insert one after the reference argument.
1513
+ range: {
1514
+ start: document.positionAt(args.refArgEnd),
1515
+ end: document.positionAt(args.refArgEnd)
1516
+ },
1517
+ newText: `, ${metadata}`
1518
+ };
1519
+ return {
1520
+ ...item,
1521
+ additionalTextEdits: [edit]
1522
+ };
1523
+ }
1524
+
1525
+ /**
1526
+ * Read metadata for the chosen file and render it as source.
1527
+ *
1528
+ * Uses `@valbuild/server`'s extractors, the same ones `val validate --fix` uses,
1529
+ * so a completed reference and a fixed one agree.
1530
+ */
1531
+ async function readMetadata(data) {
1532
+ try {
1533
+ const buffer = fs.readFileSync(data.filePath);
1534
+ if (data.subType === "image") {
1535
+ const metadata = await extractImageMetadata(data.filePath, buffer);
1536
+ if (metadata.width === undefined || metadata.height === undefined || !metadata.mimeType) {
1537
+ return undefined;
1538
+ }
1539
+ return `{ width: ${metadata.width}, height: ${metadata.height}, mimeType: ${JSON.stringify(metadata.mimeType)} }`;
1540
+ }
1541
+ const metadata = await extractFileMetadata(data.filePath, buffer);
1542
+ if (!metadata.mimeType) {
1543
+ return undefined;
1544
+ }
1545
+ return `{ mimeType: ${JSON.stringify(metadata.mimeType)} }`;
1546
+ } catch {
1547
+ // An unreadable or unrecognised file just means no metadata to offer.
1548
+ return undefined;
1549
+ }
1550
+ }
1551
+
1552
+ /**
1553
+ * Completions whose candidates come from the schema at the cursor.
1554
+ *
1555
+ * Handles `keyOf` (the keys of the record or object it points at) and `route`
1556
+ * (the pages that exist in the project). Both need to look at other modules,
1557
+ * hence the snapshot.
1558
+ *
1559
+ * The schema at the cursor is found by mapping the cursor position back to a
1560
+ * module path and resolving the schema there with `Internal.resolvePath`, rather
1561
+ * than walking the serialized schema by hand.
1562
+ */
1563
+ function createSchemaDrivenCompletions({
1564
+ document,
1565
+ sourceFile,
1566
+ offset,
1567
+ moduleFilePath,
1568
+ snapshot,
1569
+ files,
1570
+ isPropertyName,
1571
+ valueOfProperty,
1572
+ contentStart,
1573
+ contentEnd
1574
+ }) {
1575
+ const schema = snapshot.schemas[moduleFilePath];
1576
+ const source = snapshot.sources[moduleFilePath];
1577
+ if (!schema || source === undefined) {
1578
+ return [];
1579
+ }
1580
+ const modulePathMap = createModulePathMap(sourceFile);
1581
+ if (!modulePathMap) {
1582
+ return [];
1583
+ }
1584
+ const modulePath = findModulePathAtPosition(modulePathMap, document.positionAt(offset));
1585
+ if (modulePath === undefined) {
1586
+ return [];
1587
+ }
1588
+ const range = {
1589
+ start: document.positionAt(contentStart),
1590
+ end: document.positionAt(contentEnd)
1591
+ };
1592
+
1593
+ // A key is described by its container, not by itself: a gallery record is
1594
+ // keyed by file reference, so the candidates come from the record's schema.
1595
+ if (isPropertyName) {
1596
+ const container = resolveSchemaAt(parentModulePath(modulePath), source, schema);
1597
+ if (!container || typeof container !== "object" || !("type" in container) || container.type !== "record" || !("mediaType" in container) || !container.mediaType) {
1598
+ return [];
1599
+ }
1600
+ const directory = "directory" in container && typeof container.directory === "string" ? container.directory : undefined;
1601
+ const galleryFiles = container.mediaType === "images" ? files.images(directory) : files.list(directory);
1602
+ return items(galleryFiles.map(file => file.ref), CompletionItemKind.File, range);
1603
+ }
1604
+ const fieldSchema = resolveSchemaAt(modulePath, source, schema);
1605
+
1606
+ // Checked before the schema is required, because Val describes richtext content
1607
+ // as a whole rather than node by node: a link is a plain
1608
+ // `{ tag: "a", href: ... }` object, so resolving the href's own path fails and
1609
+ // there is no field schema to branch on. Walk out to the enclosing richtext and
1610
+ // check that it permits inline links instead.
1611
+ if (valueOfProperty === "href") {
1612
+ const richtext = findEnclosingRichtext(modulePath, source, schema);
1613
+ if (richtext && permitsInlineLinks(richtext)) {
1614
+ return routeItems(snapshot, range);
1615
+ }
1616
+ }
1617
+ if (!fieldSchema || typeof fieldSchema !== "object" || !("type" in fieldSchema)) {
1618
+ return [];
1619
+ }
1620
+ if (fieldSchema.type === "keyOf") {
1621
+ return items(keysOfKeyOf(fieldSchema, snapshot), CompletionItemKind.EnumMember, range);
1622
+ }
1623
+ if (fieldSchema.type === "route") {
1624
+ return routeItems(snapshot, range);
1625
+ }
1626
+ return [];
1627
+ }
1628
+
1629
+ /** The routes the project defines, as completion items. */
1630
+ function routeItems(snapshot, range) {
1631
+ // The routes that exist are the keys of the project's router modules, which is
1632
+ // exactly what getRoutesWithModulePaths reads out of the snapshot.
1633
+ const routes = getRoutesWithModulePaths(snapshot.schemas, snapshot.sources);
1634
+ return routes.map((route, index) => ({
1635
+ label: route.route,
1636
+ kind: CompletionItemKind.Value,
1637
+ // Say which module defines the page, so an ambiguous route is
1638
+ // distinguishable.
1639
+ detail: route.moduleFilePath,
1640
+ textEdit: {
1641
+ range,
1642
+ newText: route.route
1643
+ },
1644
+ sortText: String(index).padStart(5, "0")
1645
+ }));
1646
+ }
1647
+
1648
+ /**
1649
+ * Walk out from a module path until a richtext schema is found.
1650
+ *
1651
+ * Resolving a path *inside* richtext content fails, since Val does not describe
1652
+ * individual nodes with schemas, so the walk drops segments until it lands on the
1653
+ * richtext field itself.
1654
+ */
1655
+ function findEnclosingRichtext(modulePath, source, schema) {
1656
+ let segments = Internal.splitModulePath(modulePath);
1657
+ // Tests the cursor's own path first, then walks outwards: the path may already
1658
+ // be the richtext field if the cursor is not inside a nested node.
1659
+ for (;;) {
1660
+ const candidate = resolveSchemaAt(Internal.patchPathToModulePath(segments), source, schema);
1661
+ if (candidate && typeof candidate === "object" && "type" in candidate && candidate.type === "richtext") {
1662
+ return candidate;
1663
+ }
1664
+ if (segments.length === 0) {
1665
+ return undefined;
1666
+ }
1667
+ segments = segments.slice(0, -1);
1668
+ }
1669
+ }
1670
+
1671
+ /** Whether a richtext schema allows inline `a` links. */
1672
+ function permitsInlineLinks(richtext) {
1673
+ if (!("options" in richtext)) {
1674
+ return false;
1675
+ }
1676
+ const options = richtext.options;
1677
+ // `inline.a` is either `true` or the schema the href must satisfy; both mean
1678
+ // links are allowed.
1679
+ return Boolean(options?.inline?.a);
1680
+ }
1681
+
1682
+ /** Schema at a module path, or undefined when it cannot be resolved. */
1683
+ function resolveSchemaAt(modulePath, source, schema) {
1684
+ try {
1685
+ // Val's own resolver, rather than a hand-rolled serialized-schema walker.
1686
+ return Internal.resolvePath(modulePath, source, schema).schema;
1687
+ } catch {
1688
+ return undefined;
1689
+ }
1690
+ }
1691
+
1692
+ /** Drop the last segment of a module path; `""` is the module root. */
1693
+ function parentModulePath(modulePath) {
1694
+ const segments = Internal.splitModulePath(modulePath);
1695
+ return Internal.patchPathToModulePath(segments.slice(0, -1));
1696
+ }
1697
+ function items(labels, kind, range) {
1698
+ return labels.map((label, index) => ({
1699
+ label,
1700
+ kind,
1701
+ textEdit: {
1702
+ range,
1703
+ newText: label
1704
+ },
1705
+ // Preserve the source ordering rather than letting the client re-sort.
1706
+ sortText: String(index).padStart(5, "0")
1707
+ }));
1708
+ }
1709
+ function keysOfKeyOf(schema, snapshot) {
1710
+ // Object targets serialize their keys directly.
1711
+ if (Array.isArray(schema.values)) {
1712
+ return schema.values;
1713
+ }
1714
+ // Record targets say "string"; the keys are whatever the target module holds.
1715
+ if (!schema.path) {
1716
+ return [];
1717
+ }
1718
+ try {
1719
+ const [targetModuleFilePath, targetModulePath] = Internal.splitModuleFilePathAndModulePath(schema.path);
1720
+ const targetSchema = snapshot.schemas[targetModuleFilePath];
1721
+ const targetSource = snapshot.sources[targetModuleFilePath];
1722
+ if (!targetSchema || targetSource === undefined) {
1723
+ return [];
1724
+ }
1725
+ const resolved = Internal.resolvePath(targetModulePath, targetSource, targetSchema);
1726
+ const value = resolved.source;
1727
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1728
+ return [];
1729
+ }
1730
+ return Object.keys(value);
1731
+ } catch {
1732
+ return [];
1733
+ }
1734
+ }
1735
+
1736
+ /**
1737
+ * Lists the files a Val module may reference.
1738
+ *
1739
+ * Val stores referenced files under `/public/val` by convention (configurable
1740
+ * via `files.directory` in val.config), and a reference is written as the path
1741
+ * *including* the `/public` prefix — so this returns Val-style refs directly.
1742
+ */
1743
+
1744
+ /** Default directory, matching `files.directory` in val.config. */
1745
+ const DEFAULT_FILES_DIRECTORY = "/public/val";
1746
+
1747
+ /**
1748
+ * How long a directory listing is reused.
1749
+ *
1750
+ * Completions are requested per keystroke, so re-walking the tree every time is
1751
+ * wasteful; a short window keeps newly added files appearing promptly without a
1752
+ * filesystem watcher to invalidate and tear down.
1753
+ */
1754
+ const CACHE_TTL_MS = 2000;
1755
+ function createPublicValFiles({
1756
+ valRoot,
1757
+ directory = DEFAULT_FILES_DIRECTORY,
1758
+ now = () => Date.now()
1759
+ }) {
1760
+ // Keyed by directory: a project has one files directory, but each gallery
1761
+ // declares its own, and completions need whichever one is in scope.
1762
+ const cache = new Map();
1763
+ function read(directory) {
1764
+ const root = path.join(valRoot, directory);
1765
+ const files = [];
1766
+ function walk(dir) {
1767
+ let entries;
1768
+ try {
1769
+ entries = fs.readdirSync(dir, {
1770
+ withFileTypes: true
1771
+ });
1772
+ } catch {
1773
+ // Directory does not exist yet, or is not readable: no candidates.
1774
+ return;
1775
+ }
1776
+ for (const entry of entries) {
1777
+ // Skip dotfiles: .DS_Store and friends are never valid references.
1778
+ if (entry.name.startsWith(".")) {
1779
+ continue;
1780
+ }
1781
+ const absolute = path.join(dir, entry.name);
1782
+ if (entry.isDirectory()) {
1783
+ walk(absolute);
1784
+ continue;
1785
+ }
1786
+ const relative = path.relative(path.join(valRoot, directory), absolute).split(path.sep).join("/");
1787
+ files.push({
1788
+ ref: `${directory}/${relative}`,
1789
+ filePath: absolute,
1790
+ // Val's own extension -> mime table, rather than a vendored copy.
1791
+ mimeType: Internal.filenameToMimeType(entry.name)
1792
+ });
1793
+ }
1794
+ }
1795
+ walk(root);
1796
+ files.sort((a, b) => a.ref.localeCompare(b.ref));
1797
+ return files;
1798
+ }
1799
+ function list(dir = directory) {
1800
+ const at = now();
1801
+ const hit = cache.get(dir);
1802
+ if (hit && at - hit.at < CACHE_TTL_MS) {
1803
+ return hit.files;
1804
+ }
1805
+ const files = read(dir);
1806
+ cache.set(dir, {
1807
+ at,
1808
+ files
1809
+ });
1810
+ return files;
1811
+ }
1812
+ return {
1813
+ list,
1814
+ images: dir => list(dir).filter(f => f.mimeType?.startsWith("image/")),
1815
+ invalidate: () => {
1816
+ cache.clear();
1817
+ }
1818
+ };
1819
+ }
1820
+
1821
+ /**
1822
+ * Works out which Val modules a `val.modules.{ts,js}` file registers.
1823
+ *
1824
+ * Val only serves modules listed there, so an unregistered `.val.ts` file
1825
+ * silently does nothing.
1826
+ *
1827
+ * Rather than pattern-matching the accepted authoring shapes — `config.modules([…])`
1828
+ * vs `modules(config, […])`, bare `import("./x.val")` vs
1829
+ * `{ def: () => import("./x.val") }` — this collects *every* dynamic import
1830
+ * specifier in the file. That is deliberate:
1831
+ *
1832
+ * - it covers all current shapes with one rule, and any shape added later;
1833
+ * - when in doubt it over-reports registration, so a new authoring form makes
1834
+ * the diagnostic go quiet rather than firing a false "missing module" on
1835
+ * every file, which is the failure mode that actually hurts.
1836
+ */
1837
+ function findRegisteredModuleSpecifiers(sourceFile) {
1838
+ const specifiers = [];
1839
+ function visit(node) {
1840
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
1841
+ const [arg] = node.arguments;
1842
+ if (arg && ts.isStringLiteralLike(arg)) {
1843
+ specifiers.push(arg.text);
1844
+ }
1845
+ }
1846
+ ts.forEachChild(node, visit);
1847
+ }
1848
+ visit(sourceFile);
1849
+ return specifiers;
1850
+ }
1851
+
1852
+ /**
1853
+ * Whether `moduleFilePath` is registered by the given `val.modules` file.
1854
+ *
1855
+ * @param valModulesDir directory containing the val.modules file, relative to
1856
+ * the Val root (`""` when it sits at the root).
1857
+ * @param moduleFilePath the module's path, Val-style: root-relative, leading
1858
+ * slash, with extension (for example `/content/page.val.ts`).
1859
+ */
1860
+ function isModuleRegistered({
1861
+ sourceFile,
1862
+ valModulesDir,
1863
+ moduleFilePath
1864
+ }) {
1865
+ const target = stripValModuleExtension(moduleFilePath);
1866
+ return findRegisteredModuleSpecifiers(sourceFile).some(specifier => {
1867
+ // Specifiers are written relative to the val.modules file and normally omit
1868
+ // the extension ("./content/page.val").
1869
+ const resolved = specifier.startsWith(".") ? path.posix.normalize(path.posix.join("/", valModulesDir, stripValModuleExtension(specifier))) : stripValModuleExtension(specifier);
1870
+ return resolved === target;
1871
+ });
1872
+ }
1873
+
1874
+ /** `/content/page.val.ts` -> `/content/page.val` (also handles `.val` already). */
1875
+ function stripValModuleExtension(specifier) {
1876
+ return specifier.replace(/\.val\.(ts|js|tsx|jsx)$/, ".val");
1877
+ }
1878
+
1879
+ /**
1880
+ * Conversions between LSP document URIs and the paths Val uses.
1881
+ *
1882
+ * Deliberately minimal rather than pulling in `vscode-uri`: the server only ever
1883
+ * deals with local `file:` URIs, and keeping this small makes the assumptions
1884
+ * visible.
1885
+ */
1886
+
1887
+ /** Matches the Val module files the server validates. */
1888
+ const VAL_MODULE_RE = /\.val\.(ts|js|tsx|jsx)$/;
1889
+
1890
+ /** `file:` with an optional authority, capturing authority and path apart. */
1891
+ const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
1892
+
1893
+ /** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
1894
+ const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
1895
+ function isValModuleUri(uri) {
1896
+ return VAL_MODULE_RE.test(uri);
1897
+ }
1898
+
1899
+ /**
1900
+ * `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
1901
+ * one is broken, but that should not take the server down: fall back to the
1902
+ * undecoded text so the path is at worst not found.
1903
+ */
1904
+ function decodeSafely(value) {
1905
+ try {
1906
+ return decodeURIComponent(value);
1907
+ } catch {
1908
+ return value;
1909
+ }
1910
+ }
1911
+
1912
+ /**
1913
+ * `file:///a/b.val.ts` -> `/a/b.val.ts`
1914
+ *
1915
+ * Percent escapes are decoded, Windows drive letters lose the leading slash
1916
+ * (`file:///c%3A/a` -> `c:/a`), and a URI with an authority is read as a UNC
1917
+ * path (`file://host/share/a` -> `//host/share/a`). Anything that is not a
1918
+ * `file:` URI is passed through unchanged, since callers also hand us plain
1919
+ * paths.
1920
+ */
1921
+ function uriToPath(uri) {
1922
+ const match = FILE_URI_RE.exec(uri);
1923
+ if (!match) {
1924
+ return uri;
1925
+ }
1926
+ const authority = decodeSafely(match[1]);
1927
+ const fsPath = decodeSafely(match[2] || "/");
1928
+ if (authority) {
1929
+ return `//${authority}${fsPath}`;
1930
+ }
1931
+ return fsPath.replace(URI_DRIVE_LETTER_RE, "$1$2");
1932
+ }
1933
+
1934
+ /**
1935
+ * `/a/b.val.ts` -> `file:///a/b.val.ts`
1936
+ *
1937
+ * The escaping matches what VS Code produces (drive-letter colons included), so
1938
+ * that a URI built here can be looked up in the open-document map keyed by the
1939
+ * URIs the client sent.
1940
+ */
1941
+ function pathToUri(fsPath) {
1942
+ const normalized = fsPath.split(path.sep).join("/");
1943
+ const rooted = normalized.startsWith("/") ? normalized : `/${normalized}`;
1944
+ return `file://${rooted.split("/").map(segment => encodeURIComponent(segment)).join("/")}`;
1945
+ }
1946
+
1947
+ /**
1948
+ * Convert a document URI into the `ModuleFilePath` Val addresses it by: a
1949
+ * POSIX-style path relative to the Val root, with a leading slash.
1950
+ *
1951
+ * Returns `undefined` when the file lies outside the Val root — one server
1952
+ * serves exactly one root, so another root's files are not its business.
1953
+ */
1954
+ function toModuleFilePath(valRoot, uri) {
1955
+ const fsPath = uriToPath(uri);
1956
+ const relative = path.relative(valRoot, fsPath);
1957
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
1958
+ return undefined;
1959
+ }
1960
+ return `/${relative.split(path.sep).join("/")}`;
1961
+ }
1962
+
1963
+ /**
1964
+ * How long to wait after an edit before re-evaluating.
1965
+ *
1966
+ * Evaluation is ~15ms per module (see scripts/evalLatency.bench.js), so this is
1967
+ * about avoiding pointless work mid-keystroke rather than about hiding latency.
1968
+ */
1969
+ const VALIDATION_DEBOUNCE_MS = 200;
1970
+
1971
+ /**
1972
+ * Everything resolved during `initialize` that the rest of the server needs.
1973
+ * Held in one object so that later phases (diagnostics, completions, commands)
1974
+ * have a single place to read session state from.
1975
+ */
1976
+
1977
+ /**
1978
+ * Read and sanity-check the client's `initializationOptions`.
1979
+ *
1980
+ * A client that predates these options, or a bare LSP client such as a
1981
+ * hand-written Neovim config, may send nothing useful. Rather than failing, we
1982
+ * fall back to the workspace root and to the narrowest protocol range, so the
1983
+ * server still starts.
1984
+ */
1985
+ function parseInitializationOptions(params) {
1986
+ const raw = params.initializationOptions;
1987
+ const workspaceFolderUri = params.workspaceFolders?.[0]?.uri;
1988
+ const fallbackRoot = (workspaceFolderUri !== undefined ? uriToPath(workspaceFolderUri) : null) ?? params.rootPath ?? process.cwd();
1989
+ return {
1990
+ client: {
1991
+ name: raw?.client?.name ?? params.clientInfo?.name ?? "unknown",
1992
+ version: raw?.client?.version ?? params.clientInfo?.version ?? null
1993
+ },
1994
+ supportedProtocolVersions: parseVersionRange(raw?.supportedProtocolVersions),
1995
+ valRoot: raw?.valRoot ?? fallbackRoot,
1996
+ env: pickEnvOverrides(raw?.env)
1997
+ };
1998
+ }
1999
+
2000
+ /**
2001
+ * Read the protocol range a client claims to speak.
2002
+ *
2003
+ * Falls back to `{min: 1, max: 1}` for anything that is not a pair of finite
2004
+ * numbers. A half-filled object would otherwise produce `NaN` bounds, and every
2005
+ * comparison against `NaN` is false — which `negotiateProtocolVersion` reads as
2006
+ * "client too old" and refuses to serve, rather than as the graceful default a
2007
+ * bare LSP client is meant to get.
2008
+ */
2009
+ function parseVersionRange(raw) {
2010
+ const fallback = {
2011
+ min: 1,
2012
+ max: 1
2013
+ };
2014
+ if (!raw || typeof raw !== "object") {
2015
+ return fallback;
2016
+ }
2017
+ const {
2018
+ min,
2019
+ max
2020
+ } = raw;
2021
+ if (typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || max < min) {
2022
+ return fallback;
2023
+ }
2024
+ return {
2025
+ min,
2026
+ max
2027
+ };
2028
+ }
2029
+
2030
+ /**
2031
+ * Keep only the environment variables Val documents.
2032
+ *
2033
+ * `initializationOptions` is untyped JSON at runtime, so a client can send any
2034
+ * key at all. Dropping the rest here means neither this server nor anything
2035
+ * reading {@link ValInitializationOptions.env} downstream can be talked into
2036
+ * setting `PATH` or `NODE_OPTIONS`.
2037
+ *
2038
+ * Returns `undefined` when the client sent nothing usable, so that "no
2039
+ * overrides" stays distinguishable from "an empty set of overrides".
2040
+ */
2041
+ function pickEnvOverrides(env) {
2042
+ if (!env || typeof env !== "object") {
2043
+ return undefined;
2044
+ }
2045
+ const record = {
2046
+ ...env
2047
+ };
2048
+ const picked = {};
2049
+ let any = false;
2050
+ for (const key of VAL_ENV_OVERRIDE_KEYS) {
2051
+ const value = record[key];
2052
+ if (typeof value === "string") {
2053
+ picked[key] = value;
2054
+ any = true;
2055
+ }
2056
+ }
2057
+ return any ? picked : undefined;
2058
+ }
2059
+
2060
+ /**
2061
+ * Apply `VAL_*` overrides the client forwarded, so an editor session can be
2062
+ * pointed at a non-production Val backend without restarting the editor with a
2063
+ * modified environment.
2064
+ */
2065
+ function applyEnvOverrides(options) {
2066
+ const env = options.env;
2067
+ if (!env) {
2068
+ return;
2069
+ }
2070
+ // Iterate the allowlist rather than what was sent: `options` may have been
2071
+ // built by a caller that did not go through `parseInitializationOptions`.
2072
+ for (const key of VAL_ENV_OVERRIDE_KEYS) {
2073
+ const value = env[key];
2074
+ if (typeof value === "string" && value) {
2075
+ process.env[key] = value;
2076
+ }
2077
+ }
2078
+ }
2079
+
2080
+ /**
2081
+ * Files that every Val module is evaluated through, so an edit to one makes every
2082
+ * module's result stale rather than just its own.
2083
+ *
2084
+ * `val.modules` decides which modules Val serves at all; `val.config` is what the
2085
+ * modules import `c` and `s` from.
2086
+ */
2087
+ const PROJECT_WIDE_FILE_RE = /[/\\]val\.(modules|config)\.(ts|js)$/;
2088
+
2089
+ /**
2090
+ * Report a Val module that `val.modules` does not register.
2091
+ *
2092
+ * Reads `val.modules.{ts,js}` on demand, through the editor's buffer when it is
2093
+ * open: adding a module to the registry must clear the diagnostic straight away,
2094
+ * not only once the user saves. Returns `undefined` when no such file exists —
2095
+ * that is a project-level problem, not something to blame on an individual
2096
+ * module.
2097
+ */
2098
+ function findMissingModuleDiagnostic(valRoot, moduleFilePath, read) {
2099
+ for (const candidate of ["val.modules.ts", "val.modules.js"]) {
2100
+ const file = path.join(valRoot, candidate);
2101
+ let text = read(file);
2102
+ if (text === undefined) {
2103
+ try {
2104
+ text = fs.readFileSync(file, "utf8");
2105
+ } catch {
2106
+ continue;
2107
+ }
2108
+ }
2109
+ const registered = isModuleRegistered({
2110
+ sourceFile: ts.createSourceFile(file, text, ts.ScriptTarget.ES2020),
2111
+ valModulesDir: "",
2112
+ moduleFilePath
2113
+ });
2114
+ return registered ? undefined : createMissingModuleDiagnostic({
2115
+ moduleFilePath
2116
+ });
2117
+ }
2118
+ return undefined;
2119
+ }
2120
+
2121
+ /**
2122
+ * Wire up a Val language server on an existing connection.
2123
+ *
2124
+ * Split out from {@link main} so `main` stays a thin transport choice.
2125
+ */
2126
+ function createValLanguageServer(connection) {
2127
+ const documents = new TextDocuments(TextDocument);
2128
+
2129
+ /**
2130
+ * The editor's view of a file, by absolute path, or `undefined` when the file
2131
+ * is not open.
2132
+ *
2133
+ * Found by comparing paths rather than by rebuilding the client's URI: URI
2134
+ * escaping is not something two serializers agree on byte for byte (`!`, `'`,
2135
+ * `(`, `)`, `*` and drive-letter colons all vary), and a near-miss would
2136
+ * silently fall back to disk — exactly the bug this server exists to avoid.
2137
+ * The number of open documents is small, so a scan is cheaper than keeping an
2138
+ * index in sync.
2139
+ */
2140
+ function readOpenDocument(fsPath) {
2141
+ const wanted = path.normalize(fsPath);
2142
+ for (const document of documents.all()) {
2143
+ if (path.normalize(uriToPath(document.uri)) === wanted) {
2144
+ return document.getText();
2145
+ }
2146
+ }
2147
+ return undefined;
2148
+ }
2149
+ let session;
2150
+ let project;
2151
+ let publicFiles;
2152
+ const pending = new Map();
2153
+
2154
+ /**
2155
+ * Re-evaluate a module and publish its diagnostics.
2156
+ *
2157
+ * Evaluation costs ~15ms, so this is debounced rather than run on every
2158
+ * keystroke, and a newer edit supersedes an in-flight timer for the same file.
2159
+ */
2160
+ function scheduleValidation(uri) {
2161
+ const existing = pending.get(uri);
2162
+ if (existing) {
2163
+ clearTimeout(existing);
2164
+ }
2165
+ pending.set(uri, setTimeout(() => {
2166
+ pending.delete(uri);
2167
+ void validate(uri);
2168
+ }, VALIDATION_DEBOUNCE_MS));
2169
+ }
2170
+ async function validate(uri) {
2171
+ const document = documents.get(uri);
2172
+ if (!project || !document || !isValModuleUri(uri)) {
2173
+ return;
2174
+ }
2175
+ const moduleFilePath = toModuleFilePath(project.valRoot, uri);
2176
+ if (!moduleFilePath) {
2177
+ return;
2178
+ }
2179
+ try {
2180
+ // The module's own content changed, so any cached result is stale.
2181
+ project.invalidate(moduleFilePath);
2182
+ const result = await project.getModule(moduleFilePath);
2183
+ if (result.status === "error") {
2184
+ // A project-level problem (no tsconfig, missing @valbuild/core, a module
2185
+ // that throws while `val.modules` is evaluated) is not a property of this
2186
+ // file. It still has to be visible: publishing nothing would silently
2187
+ // drop every Val diagnostic and look like "all good".
2188
+ connection.console.warn(`Val: ${result.error.code}: ${result.error.message}`);
2189
+ connection.sendDiagnostics({
2190
+ uri,
2191
+ diagnostics: [createProjectErrorDiagnostic({
2192
+ moduleFilePath,
2193
+ message: result.error.message
2194
+ })]
2195
+ });
2196
+ return;
2197
+ }
2198
+ // Needed to resolve keyOf/route validation, which has to look at other
2199
+ // modules. Built once and refreshed per changed module.
2200
+ const snapshotResult = await project.getSnapshot();
2201
+ const diagnostics = createValDiagnostics({
2202
+ moduleFilePath,
2203
+ content: result.content,
2204
+ text: document.getText(),
2205
+ valRoot: project.valRoot,
2206
+ ...(snapshotResult.status === "ok" ? {
2207
+ snapshot: snapshotResult.snapshot
2208
+ } : {})
2209
+ });
2210
+ const unregistered = findMissingModuleDiagnostic(project.valRoot, moduleFilePath, readOpenDocument);
2211
+ if (unregistered) {
2212
+ diagnostics.push(unregistered);
2213
+ }
2214
+ connection.sendDiagnostics({
2215
+ uri,
2216
+ diagnostics
2217
+ });
2218
+ } catch (e) {
2219
+ // Never let a single bad module take the server down.
2220
+ connection.console.error(`Val: failed to validate ${uri}: ${e instanceof Error ? e.message : String(e)}`);
2221
+ }
2222
+ }
2223
+ connection.onInitialize(params => {
2224
+ const options = parseInitializationOptions(params);
2225
+ applyEnvOverrides(options);
2226
+ const negotiation = negotiateProtocolVersion(options.supportedProtocolVersions, SUPPORTED_PROTOCOL_VERSIONS);
2227
+ const versions = {
2228
+ core: Internal.VERSION.core,
2229
+ languageServer: getLanguageServerVersion()
2230
+ };
2231
+ if (negotiation.status !== "ok") {
2232
+ // Still return a valid InitializeResult: the client needs the payload
2233
+ // below to tell the user *which* side to update. It is the client's job
2234
+ // to stop the server after reading `incompatible`.
2235
+ const val = {
2236
+ protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.max,
2237
+ incompatible: negotiation,
2238
+ versions,
2239
+ valRoot: options.valRoot,
2240
+ features: [],
2241
+ commands: []
2242
+ };
2243
+ return {
2244
+ capabilities: {
2245
+ experimental: {
2246
+ val
2247
+ }
2248
+ }
2249
+ };
2250
+ }
2251
+ const clientCapabilities = params.capabilities.experimental?.val ?? {};
2252
+
2253
+ // Announce only what this version actually serves: a client hides UI for
2254
+ // anything missing here, and ignores anything it does not recognise.
2255
+ // Completions and commands land in later phases.
2256
+ const features = ["diagnostics", "fix/metadata", "completions/mediaPath", "completions/keyOf", "completions/route", "fix/gallery", "completions/galleryKey", "completions/richtextLink"];
2257
+ const commands = [];
2258
+ publicFiles = createPublicValFiles({
2259
+ valRoot: options.valRoot
2260
+ });
2261
+ // A project can point `files.directory` somewhere other than /public/val, and
2262
+ // media-path completions would list nothing if we assumed the default.
2263
+ // Reading val.config is async and `initialize` is not, so the default stands
2264
+ // until the real value arrives — completions cannot be requested before
2265
+ // `initialize` returns anyway.
2266
+ void findAndEvalValConfigFile(options.valRoot).then(config => {
2267
+ const directory = config?.files?.directory;
2268
+ if (directory && directory !== DEFAULT_FILES_DIRECTORY) {
2269
+ publicFiles = createPublicValFiles({
2270
+ valRoot: options.valRoot,
2271
+ directory
2272
+ });
2273
+ }
2274
+ }).catch(e => {
2275
+ // A broken or unreadable val.config is reported per module by the
2276
+ // service; here it only means "keep the default directory".
2277
+ connection.console.warn(`Val: could not read val.config: ${e instanceof Error ? e.message : String(e)}`);
2278
+ });
2279
+ project = createValProject({
2280
+ valRoot: options.valRoot,
2281
+ open: {
2282
+ // Prefer the editor's buffer; fall back to disk for files the user has
2283
+ // not opened.
2284
+ read: readOpenDocument
2285
+ }
2286
+ });
2287
+ session = {
2288
+ valRoot: options.valRoot,
2289
+ clientCapabilities,
2290
+ protocolVersion: negotiation.protocolVersion,
2291
+ features
2292
+ };
2293
+ connection.console.log(`Val language server ${versions.languageServer ?? "?"} ` + `(@valbuild/core ${versions.core ?? "?"}, protocol v${negotiation.protocolVersion}) ` + `serving ${options.valRoot} for ${options.client.name} ${options.client.version ?? "?"}`);
2294
+ const val = {
2295
+ protocolVersion: negotiation.protocolVersion,
2296
+ versions,
2297
+ valRoot: options.valRoot,
2298
+ features,
2299
+ commands
2300
+ };
2301
+ return {
2302
+ capabilities: {
2303
+ textDocumentSync: TextDocumentSyncKind.Incremental,
2304
+ codeActionProvider: {
2305
+ codeActionKinds: [CodeActionKind.QuickFix]
2306
+ },
2307
+ completionProvider: {
2308
+ resolveProvider: true,
2309
+ // Completing a path involves "/" and "." characters.
2310
+ triggerCharacters: ["/", "."]
2311
+ },
2312
+ executeCommandProvider: commands.length > 0 ? {
2313
+ commands
2314
+ } : undefined,
2315
+ experimental: {
2316
+ val
2317
+ }
2318
+ }
2319
+ };
2320
+ });
2321
+ connection.onCompletion(async params => {
2322
+ const document = documents.get(params.textDocument.uri);
2323
+ if (!publicFiles || !project || !document || !isValModuleUri(params.textDocument.uri)) {
2324
+ return [];
2325
+ }
2326
+ try {
2327
+ const moduleFilePath = toModuleFilePath(project.valRoot, params.textDocument.uri);
2328
+ // Only needed for schema-driven completions; file references do not use it.
2329
+ const snapshotResult = await project.getSnapshot();
2330
+ return createValCompletions({
2331
+ document,
2332
+ offset: document.offsetAt(params.position),
2333
+ files: publicFiles,
2334
+ ...(moduleFilePath ? {
2335
+ moduleFilePath
2336
+ } : {}),
2337
+ ...(snapshotResult.status === "ok" ? {
2338
+ snapshot: snapshotResult.snapshot
2339
+ } : {})
2340
+ });
2341
+ } catch (e) {
2342
+ connection.console.error(`Val: failed to build completions: ${e instanceof Error ? e.message : String(e)}`);
2343
+ return [];
2344
+ }
2345
+ });
2346
+ connection.onCompletionResolve(async item => {
2347
+ if (!project) {
2348
+ return item;
2349
+ }
2350
+ try {
2351
+ return await resolveValCompletion({
2352
+ item,
2353
+ documents
2354
+ });
2355
+ } catch (e) {
2356
+ connection.console.error(`Val: failed to resolve completion: ${e instanceof Error ? e.message : String(e)}`);
2357
+ return item;
2358
+ }
2359
+ });
2360
+ connection.onCodeAction(async params => {
2361
+ const document = documents.get(params.textDocument.uri);
2362
+ if (!project || !document || !isValModuleUri(params.textDocument.uri)) {
2363
+ return [];
2364
+ }
2365
+ const moduleFilePath = toModuleFilePath(project.valRoot, params.textDocument.uri);
2366
+ if (!moduleFilePath) {
2367
+ return [];
2368
+ }
2369
+ try {
2370
+ const result = await project.getModule(moduleFilePath);
2371
+ if (result.status === "error") {
2372
+ return [];
2373
+ }
2374
+ return await createValCodeActions({
2375
+ document,
2376
+ diagnostics: params.context.diagnostics,
2377
+ content: result.content,
2378
+ valRoot: project.valRoot
2379
+ });
2380
+ } catch (e) {
2381
+ connection.console.error(`Val: failed to build code actions for ${params.textDocument.uri}: ${e instanceof Error ? e.message : String(e)}`);
2382
+ return [];
2383
+ }
2384
+ });
2385
+
2386
+ // Validate when a module is opened and whenever it changes. `didChange` fires
2387
+ // per keystroke, which scheduleValidation debounces.
2388
+ documents.onDidOpen(({
2389
+ document
2390
+ }) => scheduleValidation(document.uri));
2391
+ documents.onDidChangeContent(({
2392
+ document
2393
+ }) => {
2394
+ if (PROJECT_WIDE_FILE_RE.test(uriToPath(document.uri))) {
2395
+ // A project-wide fact changed, so every module's cached result is stale --
2396
+ // not just this file's. `invalidate()` with no argument clears the content
2397
+ // cache too, which the per-module fingerprint check would not.
2398
+ project?.invalidate();
2399
+ for (const open of documents.all()) {
2400
+ if (isValModuleUri(open.uri)) {
2401
+ scheduleValidation(open.uri);
2402
+ }
2403
+ }
2404
+ return;
2405
+ }
2406
+ scheduleValidation(document.uri);
2407
+ });
2408
+ documents.onDidClose(({
2409
+ document
2410
+ }) => {
2411
+ const timer = pending.get(document.uri);
2412
+ if (timer) {
2413
+ clearTimeout(timer);
2414
+ pending.delete(document.uri);
2415
+ }
2416
+ // Clear our diagnostics so they do not linger for a file the user closed.
2417
+ connection.sendDiagnostics({
2418
+ uri: document.uri,
2419
+ diagnostics: []
2420
+ });
2421
+ });
2422
+ connection.onShutdown(() => {
2423
+ for (const timer of pending.values()) {
2424
+ clearTimeout(timer);
2425
+ }
2426
+ pending.clear();
2427
+ void project?.dispose();
2428
+ project = undefined;
2429
+ publicFiles = undefined;
2430
+ session = undefined;
2431
+ });
2432
+ documents.listen(connection);
2433
+ connection.listen();
2434
+ return {
2435
+ documents,
2436
+ getSession: () => session
2437
+ };
2438
+ }
2439
+
2440
+ /**
2441
+ * Entry point used by `bin.js`.
2442
+ *
2443
+ * `createConnection` picks its transport from argv (`--stdio`, `--node-ipc`,
2444
+ * `--socket=`), so the same binary serves VS Code (IPC) and any other LSP
2445
+ * client (stdio).
2446
+ */
2447
+ function main() {
2448
+ createValLanguageServer(createConnection(ProposedFeatures.all));
2449
+ }
2450
+
2451
+ export { DEFAULT_FILES_DIRECTORY, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, VAL_DIAGNOSTIC_CODES, VAL_DIAGNOSTIC_SOURCE, VAL_FEATURES, VAL_INPUT_REQUEST, VAL_PICK_REQUEST, createEditorFsHost, createMissingModuleDiagnostic, createModulePathMap, createProjectErrorDiagnostic, createPublicValFiles, createValCodeActions, createValCompletions, createValDiagnostics, createValLanguageServer, createValProject, defaultCoreResolver, findModulePathAtPosition, findRegisteredModuleSpecifiers, getLanguageServerVersion, getModulePathRange, getValCompletionContext, isLocalFix, isModuleRegistered, isValModuleUri, main, mapOpenDocuments, minimalTextEdit, negotiateProtocolVersion, pathToUri, resolveValCompletion, severityFor, toModuleFilePath, uriToPath };