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