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