@valbuild/language-server 0.102.0 → 0.103.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/README.md +367 -0
- package/dist/declarations/src/ValProject.d.ts +39 -2
- package/dist/declarations/src/codeActions.d.ts +57 -8
- package/dist/declarations/src/commands.d.ts +63 -0
- package/dist/declarations/src/completionContext.d.ts +25 -40
- package/dist/declarations/src/completions.d.ts +12 -8
- package/dist/declarations/src/diagnostics.d.ts +107 -3
- package/dist/declarations/src/galleryFixes.d.ts +46 -0
- package/dist/declarations/src/index.d.ts +7 -3
- package/dist/declarations/src/textEdit.d.ts +10 -0
- package/dist/declarations/src/valModulesRegistry.d.ts +35 -0
- package/dist/valbuild-language-server.cjs.dev.js +1780 -513
- package/dist/valbuild-language-server.cjs.prod.js +1780 -513
- package/dist/valbuild-language-server.esm.js +1761 -517
- package/package.json +6 -5
package/README.md
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# `@valbuild/language-server`
|
|
2
|
+
|
|
3
|
+
The Val language server: validation, quick fixes and completions for `*.val.ts`
|
|
4
|
+
files, over the Language Server Protocol.
|
|
5
|
+
|
|
6
|
+
It ships **inside Val**, as a dependency of `@valbuild/next` and
|
|
7
|
+
`@valbuild/cli`. You do not install it — a project on a recent enough Val
|
|
8
|
+
already has it. That is the point: an editor client resolves the server out of
|
|
9
|
+
the user's own `node_modules`, so one published client works against every
|
|
10
|
+
version of Val, and a feature Val gains works without an editor release.
|
|
11
|
+
|
|
12
|
+
The VS Code extension ([`valbuild/vscode-val-build`](https://github.com/valbuild/vscode-val-build))
|
|
13
|
+
is a client of this package and nothing more. Any LSP client can be one; this
|
|
14
|
+
document is what you need to write another.
|
|
15
|
+
|
|
16
|
+
## Running it
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Through the CLI, which imports this package.
|
|
20
|
+
npx val lsp --stdio
|
|
21
|
+
|
|
22
|
+
# Or the binary directly, once you have resolved it (see below). Note that
|
|
23
|
+
# `node_modules/.bin/val-language-server` exists only when the project depends on
|
|
24
|
+
# this package directly -- pnpm does not link a transitive dependency's bins.
|
|
25
|
+
node <resolved>/bin.js --stdio
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The transport is chosen from argv — `--stdio`, `--node-ipc`, or
|
|
29
|
+
`--socket=<port>` — so the same binary serves an editor that prefers IPC and one
|
|
30
|
+
that prefers stdio.
|
|
31
|
+
|
|
32
|
+
### Finding the binary
|
|
33
|
+
|
|
34
|
+
A client must resolve it from the **user's project**, not bundle its own. There
|
|
35
|
+
is one trap, and it is the whole reason this section exists: the package is a
|
|
36
|
+
_transitive_ dependency, and under pnpm's isolated `node_modules` a transitive
|
|
37
|
+
dependency **is not resolvable from the project root**. A plain
|
|
38
|
+
`require.resolve("@valbuild/language-server", { paths: [projectRoot] })` passes
|
|
39
|
+
under npm and fails under pnpm.
|
|
40
|
+
|
|
41
|
+
So resolve _through_ a package the project depends on directly:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import { createRequire } from "node:module";
|
|
45
|
+
|
|
46
|
+
const rootPkg = path.join(projectRoot, "package.json");
|
|
47
|
+
// A direct dependency wins; otherwise go through whichever package carries it.
|
|
48
|
+
// @valbuild/core and @valbuild/server are NOT valid anchors -- they do not
|
|
49
|
+
// depend on the language server, and could not without a cycle.
|
|
50
|
+
for (const anchor of [null, "@valbuild/next", "@valbuild/cli"]) {
|
|
51
|
+
const from =
|
|
52
|
+
anchor === null
|
|
53
|
+
? rootPkg
|
|
54
|
+
: createRequire(rootPkg).resolve(`${anchor}/package.json`);
|
|
55
|
+
const pkgPath = createRequire(from).resolve(
|
|
56
|
+
"@valbuild/language-server/package.json",
|
|
57
|
+
);
|
|
58
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
59
|
+
return path.resolve(path.dirname(pkgPath), pkg.bin["val-language-server"]);
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This works because `./package.json` is in the package's `exports` map; Node's
|
|
64
|
+
exports enforcement would otherwise block the subpath.
|
|
65
|
+
|
|
66
|
+
Yarn PnP has no `node_modules` at all, so path resolution cannot work there.
|
|
67
|
+
Offer an explicit override — the VS Code extension has
|
|
68
|
+
`valBuild.languageServerPath` and `VAL_LANGUAGE_SERVER_PATH` — and **report when
|
|
69
|
+
one is in use**, or it becomes the invisible reason a session misbehaves. The
|
|
70
|
+
same override is how you develop against an unreleased Val: point it at a
|
|
71
|
+
monorepo checkout's `packages/language-server/bin.js`, which runs directly
|
|
72
|
+
because `preconstruct dev` maps the entry to the TypeScript source.
|
|
73
|
+
|
|
74
|
+
**One server per Val root.** Roots in a monorepo may pin different versions of
|
|
75
|
+
Val, so they need different servers. A Val root is the directory of a
|
|
76
|
+
`package.json` that has a `val.config.{ts,js}` somewhere beneath it, ignoring
|
|
77
|
+
anything under `node_modules`. Confine each client's document selector to its own
|
|
78
|
+
root so two servers never both claim a file.
|
|
79
|
+
|
|
80
|
+
## The handshake
|
|
81
|
+
|
|
82
|
+
Send this as `InitializeParams.initializationOptions`:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
type ValInitializationOptions = {
|
|
86
|
+
client: { name: string; version: string | null };
|
|
87
|
+
supportedProtocolVersions: { min: number; max: number };
|
|
88
|
+
/** Absolute path to the directory containing this project's package.json. */
|
|
89
|
+
valRoot: string;
|
|
90
|
+
env?: {
|
|
91
|
+
VAL_CONTENT_URL?: string;
|
|
92
|
+
VAL_REMOTE_HOST?: string;
|
|
93
|
+
VAL_BUILD_URL?: string;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
All of it is optional in practice: a client that sends nothing gets `valRoot`
|
|
99
|
+
from `workspaceFolders[0]`, then `rootPath`, then `process.cwd()`, and the
|
|
100
|
+
narrowest protocol range. A hand-written Neovim config therefore works without
|
|
101
|
+
any of this — sending it just makes the behaviour explicit.
|
|
102
|
+
|
|
103
|
+
Announce what you can do under `capabilities.experimental.val`:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
type ValClientCapabilities = { pick?: boolean; input?: boolean };
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The server replies under `InitializeResult.capabilities.experimental.val`:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
type ValServerCapabilities = {
|
|
113
|
+
protocolVersion: number;
|
|
114
|
+
/** Set ONLY when negotiation failed. Check this FIRST. */
|
|
115
|
+
incompatible?: {
|
|
116
|
+
status: "client-too-old" | "server-too-old";
|
|
117
|
+
server: { min: number; max: number };
|
|
118
|
+
client: { min: number; max: number };
|
|
119
|
+
};
|
|
120
|
+
versions: { core: string | null; languageServer: string | null };
|
|
121
|
+
valRoot: string;
|
|
122
|
+
features: string[];
|
|
123
|
+
commands: string[];
|
|
124
|
+
};
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Three rules that are easy to get wrong:
|
|
128
|
+
|
|
129
|
+
1. **Check `incompatible` first**, and never infer compatibility from which
|
|
130
|
+
capabilities are present: `vscode-languageserver` injects `textDocumentSync`
|
|
131
|
+
into the `InitializeResult` on its own, so its presence proves nothing.
|
|
132
|
+
2. **`incompatible.status` is directional on purpose.** `client-too-old` means
|
|
133
|
+
tell the user to update the editor client; `server-too-old` means tell them to
|
|
134
|
+
update Val in their project. A generic "incompatible versions" message is a
|
|
135
|
+
dead end.
|
|
136
|
+
3. **Read `features`, never a copy of the list below.** An **unknown** string is
|
|
137
|
+
a capability this Val has that you do not know about — ignore it. A
|
|
138
|
+
**missing** string means not available — hide that UI. This is the mechanism
|
|
139
|
+
that lets a newer Val degrade gracefully against an older client.
|
|
140
|
+
|
|
141
|
+
Additive changes — a new feature flag, a new command, a new optional field — do
|
|
142
|
+
not bump `protocolVersion`. Only a removed or renamed request, a changed payload
|
|
143
|
+
shape, or changed semantics an older client would misread.
|
|
144
|
+
|
|
145
|
+
### Features
|
|
146
|
+
|
|
147
|
+
| Flag | What it covers |
|
|
148
|
+
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
149
|
+
| `diagnostics` | Validation and schema errors, fatal module errors, missing file references, modules absent from `val.modules`, and `keyOf`/`route` resolution with did-you-mean suggestions. |
|
|
150
|
+
| `fix/metadata` | Quick fixes for image and file metadata, computed by the same pipeline as `val validate --fix`. |
|
|
151
|
+
| `fix/gallery` | Quick fix correcting a gallery's stored metadata against the files on disk. |
|
|
152
|
+
| `fix/missing-module` | Quick fix registering a `*.val.ts` in `val.modules`. |
|
|
153
|
+
| `fix/upload-remote` | Upload a local file to Val Remote. A command, not an edit — it needs credentials. |
|
|
154
|
+
| `fix/download-remote` | Download a remote file back into the project. |
|
|
155
|
+
| `login` | The `val.login` command (device flow; writes `.val/pat.json`, the same file the CLI uses). |
|
|
156
|
+
| `completions/mediaPath` | The `path` of an image or file, with `width`/`height`/`mimeType` filled in on accept. |
|
|
157
|
+
| `completions/galleryKey` | Keys of an `s.images()` / `s.files()` collection. |
|
|
158
|
+
| `completions/keyOf` | Keys of the record or object an `s.keyOf()` field points at. |
|
|
159
|
+
| `completions/route` | Routes the project defines. |
|
|
160
|
+
| `completions/richtextLink` | Route completion inside a richtext inline link's `href`. |
|
|
161
|
+
|
|
162
|
+
Diagnostics are **push-based** (`textDocument/publishDiagnostics`), debounced
|
|
163
|
+
200ms after an edit. Every one carries structured `Diagnostic.data`:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
type ValDiagnosticData = {
|
|
167
|
+
code:
|
|
168
|
+
| "val/validation"
|
|
169
|
+
| "val/schema"
|
|
170
|
+
| "val/fatal"
|
|
171
|
+
| "val/file-not-found"
|
|
172
|
+
| "val/missing-module";
|
|
173
|
+
sourcePath: string;
|
|
174
|
+
fixes?: string[]; // ValidationFix names from @valbuild/core
|
|
175
|
+
value?: unknown;
|
|
176
|
+
filePath?: string;
|
|
177
|
+
fixSourcePath?: string;
|
|
178
|
+
};
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The client round-trips `data` back on `textDocument/codeAction`, which is where
|
|
182
|
+
the fixes come from. Do not parse the `code` string for anything.
|
|
183
|
+
|
|
184
|
+
### Quick fixes and commands
|
|
185
|
+
|
|
186
|
+
Most quick fixes are ordinary `CodeAction`s carrying a `WorkspaceEdit`. Three
|
|
187
|
+
things cannot be: logging in, uploading bytes, and downloading them. Those are
|
|
188
|
+
`workspace/executeCommand` names, advertised in `commands`, and the remote fixes
|
|
189
|
+
appear as code actions carrying a `command` rather than an `edit`.
|
|
190
|
+
|
|
191
|
+
**A client needs no Val-specific code for any of this.** Forward a code action's
|
|
192
|
+
`command` back to the server — `vscode-languageclient` and Neovim's
|
|
193
|
+
`vim.lsp.buf.code_action` both do it automatically — and the server does the
|
|
194
|
+
work, reporting progress with `$/progress`, opening the browser with
|
|
195
|
+
`window/showDocument`, and applying the result with `workspace/applyEdit`.
|
|
196
|
+
|
|
197
|
+
`val.login` is the one command worth exposing directly, since a user invokes it
|
|
198
|
+
rather than reaching it through a diagnostic.
|
|
199
|
+
|
|
200
|
+
### The two custom requests
|
|
201
|
+
|
|
202
|
+
Standard LSP covers applying edits, opening a URL, progress and confirmations.
|
|
203
|
+
Only two UI primitives are missing, and both are deliberately content-agnostic —
|
|
204
|
+
they carry no Val types, so they never change when Val changes:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
"val/pick" : { title, placeholder?, items: { label, description?, detail?, value }[] }
|
|
208
|
+
-> { value: string } | null // null = dismissed
|
|
209
|
+
|
|
210
|
+
"val/input" : { title, prompt?, value?, placeholder?, password? }
|
|
211
|
+
-> { value: string } | null // null = dismissed
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Implement them and say so in `ValClientCapabilities`; the server only offers
|
|
215
|
+
flows needing them when you do.
|
|
216
|
+
|
|
217
|
+
### Watched files
|
|
218
|
+
|
|
219
|
+
The server handles `workspace/didChangeWatchedFiles`, and **registers the
|
|
220
|
+
watchers itself** when the client supports dynamic registration. So a client that
|
|
221
|
+
does nothing still notices a `.val.ts` changed by `git checkout`, a
|
|
222
|
+
`val validate --fix` run in a terminal, or an image dropped into `/public`. If
|
|
223
|
+
your client cannot do dynamic registration, watch `**/*.val.{ts,js}`,
|
|
224
|
+
`**/val.modules.{ts,js}`, `**/val.config.{ts,js}` and `**/public/**` yourself.
|
|
225
|
+
|
|
226
|
+
## Neovim
|
|
227
|
+
|
|
228
|
+
With `nvim-lspconfig`, resolving the server from the project as described above:
|
|
229
|
+
|
|
230
|
+
```lua
|
|
231
|
+
local util = require("lspconfig.util")
|
|
232
|
+
|
|
233
|
+
-- Resolve the server entry the way the section above describes, by asking node.
|
|
234
|
+
-- Deliberately NOT `node_modules/.bin/val-language-server`: pnpm only links the
|
|
235
|
+
-- bins of a project's DIRECT dependencies, and this package is a transitive one,
|
|
236
|
+
-- so that path does not exist in exactly the layout most likely to be used.
|
|
237
|
+
local RESOLVE = [[
|
|
238
|
+
const { createRequire } = require("node:module");
|
|
239
|
+
const fs = require("fs"), path = require("path");
|
|
240
|
+
const rootPkg = path.join(process.argv[2], "package.json");
|
|
241
|
+
for (const anchor of [null, "@valbuild/next", "@valbuild/cli"]) {
|
|
242
|
+
try {
|
|
243
|
+
const from = anchor === null
|
|
244
|
+
? rootPkg
|
|
245
|
+
: createRequire(rootPkg).resolve(anchor + "/package.json");
|
|
246
|
+
const pkgPath = createRequire(from).resolve(
|
|
247
|
+
"@valbuild/language-server/package.json");
|
|
248
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
249
|
+
const bin = pkg.bin && pkg.bin["val-language-server"];
|
|
250
|
+
if (bin) {
|
|
251
|
+
process.stdout.write(path.resolve(path.dirname(pkgPath), bin));
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
} catch (e) {}
|
|
255
|
+
}
|
|
256
|
+
]]
|
|
257
|
+
|
|
258
|
+
local function val_server_cmd(root)
|
|
259
|
+
local override = vim.env.VAL_LANGUAGE_SERVER_PATH
|
|
260
|
+
if override and override ~= "" then
|
|
261
|
+
return { "node", override, "--stdio" }
|
|
262
|
+
end
|
|
263
|
+
local entry = vim.fn.system({ "node", "-e", RESOLVE, root })
|
|
264
|
+
entry = vim.trim(entry)
|
|
265
|
+
if vim.v.shell_error == 0 and entry ~= "" then
|
|
266
|
+
return { "node", entry, "--stdio" }
|
|
267
|
+
end
|
|
268
|
+
-- Nothing resolved: this project's Val is older than the language server, or
|
|
269
|
+
-- its dependencies are not installed. Say so rather than starting nothing.
|
|
270
|
+
vim.notify(
|
|
271
|
+
"Val: no @valbuild/language-server in " .. root ..
|
|
272
|
+
" -- upgrade @valbuild/next or @valbuild/cli.",
|
|
273
|
+
vim.log.levels.WARN
|
|
274
|
+
)
|
|
275
|
+
return nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
vim.api.nvim_create_autocmd("FileType", {
|
|
279
|
+
pattern = { "typescript", "javascript" },
|
|
280
|
+
callback = function(args)
|
|
281
|
+
local root = util.root_pattern("val.config.ts", "val.config.js")(
|
|
282
|
+
vim.api.nvim_buf_get_name(args.buf)
|
|
283
|
+
)
|
|
284
|
+
if not root then
|
|
285
|
+
return
|
|
286
|
+
end
|
|
287
|
+
local cmd = val_server_cmd(root)
|
|
288
|
+
if not cmd then
|
|
289
|
+
return
|
|
290
|
+
end
|
|
291
|
+
vim.lsp.start({
|
|
292
|
+
name = "valbuild",
|
|
293
|
+
cmd = cmd,
|
|
294
|
+
root_dir = root,
|
|
295
|
+
init_options = {
|
|
296
|
+
client = { name = "neovim", version = tostring(vim.version()) },
|
|
297
|
+
supportedProtocolVersions = { min = 1, max = 1 },
|
|
298
|
+
valRoot = root,
|
|
299
|
+
},
|
|
300
|
+
capabilities = vim.tbl_deep_extend(
|
|
301
|
+
"force",
|
|
302
|
+
vim.lsp.protocol.make_client_capabilities(),
|
|
303
|
+
-- Only needed for flows that ask the user something; diagnostics,
|
|
304
|
+
-- completions and quick fixes work without it.
|
|
305
|
+
{ experimental = { val = { pick = true, input = true } } }
|
|
306
|
+
),
|
|
307
|
+
handlers = {
|
|
308
|
+
["val/pick"] = function(_, params)
|
|
309
|
+
local labels = {}
|
|
310
|
+
for _, item in ipairs(params.items) do
|
|
311
|
+
table.insert(labels, item.label)
|
|
312
|
+
end
|
|
313
|
+
local choice = vim.fn.inputlist(labels)
|
|
314
|
+
if choice < 1 or choice > #params.items then
|
|
315
|
+
return vim.NIL
|
|
316
|
+
end
|
|
317
|
+
return { value = params.items[choice].value }
|
|
318
|
+
end,
|
|
319
|
+
["val/input"] = function(_, params)
|
|
320
|
+
local value = vim.fn.input(params.prompt or params.title or "", params.value or "")
|
|
321
|
+
if value == "" then
|
|
322
|
+
return vim.NIL
|
|
323
|
+
end
|
|
324
|
+
return { value = value }
|
|
325
|
+
end,
|
|
326
|
+
},
|
|
327
|
+
})
|
|
328
|
+
end,
|
|
329
|
+
})
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Diagnostics, `vim.lsp.buf.code_action()` and omnicompletion then work as usual.
|
|
333
|
+
`:lua vim.lsp.buf.execute_command({ command = "val.login", arguments = {} })`
|
|
334
|
+
logs in.
|
|
335
|
+
|
|
336
|
+
Read the server's `features` from
|
|
337
|
+
`vim.lsp.get_clients()[1].server_capabilities.experimental.val.features` if you
|
|
338
|
+
want to gate anything on what this Val version actually serves.
|
|
339
|
+
|
|
340
|
+
## Testing a client against it
|
|
341
|
+
|
|
342
|
+
**Do not run the server in-process under a test runner.**
|
|
343
|
+
`vscode-languageserver/node` registers `end` and `close` handlers on its input
|
|
344
|
+
stream that call `process.exit()`, so ending a stream in teardown kills the test
|
|
345
|
+
worker and the run hangs rather than failing. Under `--stdio` it also replaces
|
|
346
|
+
the global `console`. Spawn it as a child process over stdio instead — which also
|
|
347
|
+
tests the real launch path:
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
const child = spawn(process.execPath, [entry, "--stdio"], { cwd: valRoot });
|
|
351
|
+
const client = createMessageConnection(
|
|
352
|
+
new StreamMessageReader(child.stdout),
|
|
353
|
+
new StreamMessageWriter(child.stdin),
|
|
354
|
+
);
|
|
355
|
+
client.onUnhandledNotification(() => {}); // it logs via window/logMessage
|
|
356
|
+
client.listen();
|
|
357
|
+
// teardown: client.dispose(); child.kill();
|
|
358
|
+
// Never end/destroy the streams, and never send `exit`.
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Assert the child writes **nothing** to stderr on a successful start: editors read
|
|
362
|
+
stderr noise from a language server as a startup failure, and it is the cheapest
|
|
363
|
+
possible regression test for the launch path.
|
|
364
|
+
|
|
365
|
+
`src/__testHelpers__/lspClient.ts` in this package is a working harness, and
|
|
366
|
+
`src/server.test.ts`, `diagnostics.test.ts`, `codeActions.test.ts` and
|
|
367
|
+
`completions.test.ts` drive it against `examples/next`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ModuleFilePath } from "@valbuild/core";
|
|
1
|
+
import type { ModuleFilePath, SourcePath } from "@valbuild/core";
|
|
2
2
|
import type { SchemaSourceSnapshot } from "@valbuild/shared/internal";
|
|
3
|
-
import { type Service } from "@valbuild/server";
|
|
3
|
+
import { type FixHandlerResult, type IValRemote, type Service, type ValidationError } from "@valbuild/server";
|
|
4
4
|
import { type OpenDocuments } from "./EditorFsHost.js";
|
|
5
5
|
/**
|
|
6
6
|
* A single Val root, and everything needed to evaluate its modules.
|
|
@@ -53,6 +53,43 @@ export type ValProject = {
|
|
|
53
53
|
}>;
|
|
54
54
|
/** Val module file paths found under the Val root. */
|
|
55
55
|
listModuleFilePaths(): ModuleFilePath[];
|
|
56
|
+
/**
|
|
57
|
+
* Run the `val validate --fix` handler for one validation error.
|
|
58
|
+
*
|
|
59
|
+
* The handlers are the precondition layer in `@valbuild/server`: they read the
|
|
60
|
+
* file, check the directory, look across modules, and for the remote fixes
|
|
61
|
+
* they do the upload or download. Running them here rather than
|
|
62
|
+
* reimplementing their checks is what keeps an editor's verdict and the CLI's
|
|
63
|
+
* identical. Exposed as a method because the handlers need the `Service` and
|
|
64
|
+
* the fs host, and both stay private to this module.
|
|
65
|
+
*
|
|
66
|
+
* Defaults to reporting only (`fix: false`, a remote that refuses). A caller
|
|
67
|
+
* acting on an accepted quick fix passes `fix: true` and a real `remote`, and
|
|
68
|
+
* reads `remoteFiles` afterwards to hand to `createFixPatch` — the same
|
|
69
|
+
* two-step the CLI does.
|
|
70
|
+
*
|
|
71
|
+
* Resolves to `undefined` when the project could not be evaluated, or when no
|
|
72
|
+
* handler is registered for the error's fixes.
|
|
73
|
+
*/
|
|
74
|
+
runFixHandler(args: {
|
|
75
|
+
moduleFilePath: ModuleFilePath;
|
|
76
|
+
sourcePath: SourcePath;
|
|
77
|
+
validationError: ValidationError;
|
|
78
|
+
/** Whether the handler may act. Defaults to `false`. */
|
|
79
|
+
fix?: boolean;
|
|
80
|
+
/** A real remote, for the upload fixes. */
|
|
81
|
+
remote?: IValRemote;
|
|
82
|
+
/** Val project name from val.config, needed by the upload fixes. */
|
|
83
|
+
project?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Filled in by the upload handlers with the refs they uploaded. Pass an
|
|
86
|
+
* object and read it back — `createFixPatch` consumes it to build the patch.
|
|
87
|
+
*/
|
|
88
|
+
remoteFiles?: Record<SourcePath, {
|
|
89
|
+
ref: string;
|
|
90
|
+
metadata?: Record<string, unknown>;
|
|
91
|
+
}>;
|
|
92
|
+
}): Promise<FixHandlerResult | undefined>;
|
|
56
93
|
/** Drop cached results. Pass a path to invalidate one module. */
|
|
57
94
|
invalidate(moduleFilePath?: ModuleFilePath): void;
|
|
58
95
|
/** Number of cached module results — for tests and diagnostics. */
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { type ValidationFix } from "@valbuild/core";
|
|
2
|
-
import {
|
|
1
|
+
import { type ModuleFilePath, type SourcePath, type ValidationError, type ValidationFix } from "@valbuild/core";
|
|
2
|
+
import { type FixHandlerResult } from "@valbuild/server";
|
|
3
|
+
import { CodeAction, type Diagnostic } from "vscode-languageserver";
|
|
3
4
|
import type { TextDocument } from "vscode-languageserver-textdocument";
|
|
5
|
+
import type { GalleryCheckFinding } from "./diagnostics.js";
|
|
4
6
|
import type { ValModuleContent } from "./ValProject.js";
|
|
7
|
+
import { minimalTextEdit } from "./textEdit.js";
|
|
8
|
+
export { minimalTextEdit };
|
|
5
9
|
export declare function isLocalFix(fix: string): fix is ValidationFix;
|
|
6
10
|
/**
|
|
7
11
|
* Build quick fixes for the diagnostics the client sent back.
|
|
@@ -9,18 +13,63 @@ export declare function isLocalFix(fix: string): fix is ValidationFix;
|
|
|
9
13
|
* The client returns our `Diagnostic.data` verbatim, which is where the source
|
|
10
14
|
* path and available fixes come from — no re-deriving them from a code string.
|
|
11
15
|
*/
|
|
12
|
-
export declare function createValCodeActions({ document, diagnostics, content, valRoot, remoteHost, }: {
|
|
16
|
+
export declare function createValCodeActions({ document, diagnostics, content, valRoot, moduleFilePath, remoteHost, }: {
|
|
13
17
|
document: TextDocument;
|
|
14
18
|
diagnostics: Diagnostic[];
|
|
15
19
|
content: ValModuleContent;
|
|
16
20
|
valRoot: string;
|
|
21
|
+
/** Needed to offer the remote fixes, which run as commands. */
|
|
22
|
+
moduleFilePath?: ModuleFilePath;
|
|
17
23
|
remoteHost?: string;
|
|
18
24
|
}): Promise<CodeAction[]>;
|
|
19
25
|
/**
|
|
20
|
-
*
|
|
26
|
+
* Quick fix for a module that is not registered in `val.modules`.
|
|
21
27
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
28
|
+
* Separate from {@link createValCodeActions} because it is not a
|
|
29
|
+
* `ValidationError` at all — nothing is wrong with the module's content, it is
|
|
30
|
+
* simply not listed, so there is no `createFixPatch` path to reuse. The edit
|
|
31
|
+
* also lands in a *different* file than the diagnostic, which a `WorkspaceEdit`
|
|
32
|
+
* handles natively and needs no extra client capability.
|
|
33
|
+
*
|
|
34
|
+
* Returns nothing when the `val.modules` file cannot be found or its `modules(
|
|
35
|
+
* config, [ … ])` array cannot be located: an insertion at a guessed offset
|
|
36
|
+
* produces a file that no longer compiles, which is worse than no fix.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createMissingModuleCodeAction({ valRoot, moduleFilePath, read, }: {
|
|
39
|
+
valRoot: string;
|
|
40
|
+
moduleFilePath: string;
|
|
41
|
+
/** The editor's view of a file, falling back to disk. */
|
|
42
|
+
read: (fsPath: string) => string | undefined;
|
|
43
|
+
}): CodeAction | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Work out whether a gallery placeholder is hiding a real problem.
|
|
46
|
+
*
|
|
47
|
+
* Core attaches `images:check-unique-folder` and `images:check-all-files` to
|
|
48
|
+
* every gallery module unconditionally — they are requests to go and look, not
|
|
49
|
+
* findings. `val validate` looks by running the fix handler and then, when the
|
|
50
|
+
* handler says the membership is fine, by running `createFixPatch` to compare
|
|
51
|
+
* each entry's stored metadata against its file. Both steps matter:
|
|
52
|
+
*
|
|
53
|
+
* - handler `success: false` — a membership problem, with its own message.
|
|
54
|
+
* - handler `shouldApplyPatch` — membership is fine; the metadata still has to
|
|
55
|
+
* be checked, and `createFixPatch` returns one `remainingError` per entry
|
|
56
|
+
* that disagrees with its file.
|
|
57
|
+
* - anything else — nothing to report, so the placeholder is dropped.
|
|
58
|
+
*
|
|
59
|
+
* Doing it exactly this way is the point: an editor that adjudicated these
|
|
60
|
+
* itself would disagree with the CLI, and the first symptom would be a warning
|
|
61
|
+
* that appears in one and not the other.
|
|
25
62
|
*/
|
|
26
|
-
export declare function
|
|
63
|
+
export declare function adjudicateGalleryCheck({ sourcePath, validationError, moduleFilePath, valRoot, content, runFixHandler, remoteHost, }: {
|
|
64
|
+
sourcePath: SourcePath;
|
|
65
|
+
validationError: ValidationError;
|
|
66
|
+
moduleFilePath: ModuleFilePath;
|
|
67
|
+
valRoot: string;
|
|
68
|
+
content: ValModuleContent;
|
|
69
|
+
runFixHandler: (args: {
|
|
70
|
+
moduleFilePath: ModuleFilePath;
|
|
71
|
+
sourcePath: SourcePath;
|
|
72
|
+
validationError: ValidationError;
|
|
73
|
+
}) => Promise<FixHandlerResult | undefined>;
|
|
74
|
+
remoteHost?: string;
|
|
75
|
+
}): Promise<GalleryCheckFinding[]>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `workspace/executeCommand` handlers.
|
|
3
|
+
*
|
|
4
|
+
* Quick fixes that only rewrite text travel as a `WorkspaceEdit` and need no
|
|
5
|
+
* command. These three cannot:
|
|
6
|
+
*
|
|
7
|
+
* - **login** has no edit at all; it opens a browser and waits.
|
|
8
|
+
* - **upload-remote** sends bytes to a remote host, which needs credentials and
|
|
9
|
+
* is not expressible as an edit. Only the resulting rewrite is.
|
|
10
|
+
* - **download-remote** writes a file to disk before the rewrite makes sense.
|
|
11
|
+
*
|
|
12
|
+
* Routing them through `executeCommand` is what keeps editors free of Val
|
|
13
|
+
* knowledge: a code action carries a `command` name the server advertised, and
|
|
14
|
+
* the LSP client forwards it back without understanding it. `vscode-languageclient`
|
|
15
|
+
* registers every advertised command automatically, and a Neovim client does the
|
|
16
|
+
* same through `vim.lsp.buf.code_action`, so neither needs a line of Val-specific
|
|
17
|
+
* code for any of this.
|
|
18
|
+
*/
|
|
19
|
+
import { type ModuleFilePath, type SourcePath, type ValidationFix } from "@valbuild/core";
|
|
20
|
+
import { type Connection } from "vscode-languageserver";
|
|
21
|
+
import type { TextDocument } from "vscode-languageserver-textdocument";
|
|
22
|
+
import type { ValProject } from "./ValProject.js";
|
|
23
|
+
/** Names advertised in `executeCommandProvider.commands`. */
|
|
24
|
+
export declare const VAL_LOGIN_COMMAND = "val.login";
|
|
25
|
+
export declare const VAL_UPLOAD_REMOTE_COMMAND = "val.uploadRemote";
|
|
26
|
+
export declare const VAL_DOWNLOAD_REMOTE_COMMAND = "val.downloadRemote";
|
|
27
|
+
/**
|
|
28
|
+
* The remote fixes, and which command each is offered through.
|
|
29
|
+
*
|
|
30
|
+
* Kept apart from `LOCAL_FIXES` in `codeActions.ts` deliberately: a quick fix
|
|
31
|
+
* that silently needed credentials would just fail, so these are offered as
|
|
32
|
+
* commands and can report "you are not logged in" like a normal outcome.
|
|
33
|
+
*/
|
|
34
|
+
export declare const REMOTE_FIX_COMMANDS: Partial<Record<ValidationFix, string>>;
|
|
35
|
+
export declare const REMOTE_FIX_TITLES: Partial<Record<ValidationFix, string>>;
|
|
36
|
+
/** Arguments a remote-fix command is invoked with. */
|
|
37
|
+
export type RemoteFixCommandArgs = {
|
|
38
|
+
uri: string;
|
|
39
|
+
moduleFilePath: ModuleFilePath;
|
|
40
|
+
sourcePath: SourcePath;
|
|
41
|
+
fix: ValidationFix;
|
|
42
|
+
message: string;
|
|
43
|
+
value?: unknown;
|
|
44
|
+
};
|
|
45
|
+
export type ValCommandDeps = {
|
|
46
|
+
connection: Connection;
|
|
47
|
+
getProject: () => ValProject | undefined;
|
|
48
|
+
getDocument: (uri: string) => TextDocument | undefined;
|
|
49
|
+
remoteHost?: string;
|
|
50
|
+
};
|
|
51
|
+
/** Whether a fix is offered as a command rather than as a plain edit. */
|
|
52
|
+
export declare function isRemoteFix(fix: string): fix is ValidationFix;
|
|
53
|
+
export declare function valCommandNames(): string[];
|
|
54
|
+
/**
|
|
55
|
+
* Read the project's personal access token.
|
|
56
|
+
*
|
|
57
|
+
* Same file the CLI writes and the dev server reads (`<root>/.val/pat.json`), so
|
|
58
|
+
* logging in through either is logging in for both.
|
|
59
|
+
*/
|
|
60
|
+
export declare function readPersonalAccessToken(valRoot: string): string | null;
|
|
61
|
+
export declare function createValCommands(deps: ValCommandDeps): {
|
|
62
|
+
execute: (command: string, args: unknown[]) => Promise<void>;
|
|
63
|
+
};
|
|
@@ -2,11 +2,11 @@ import ts from "typescript";
|
|
|
2
2
|
/**
|
|
3
3
|
* Works out what the cursor is sitting in, so completions can be offered for it.
|
|
4
4
|
*
|
|
5
|
-
* AST-based rather than text/regex-based:
|
|
6
|
-
* multi-line, and matching on text gets that wrong in exactly the
|
|
7
|
-
* user most wants help.
|
|
5
|
+
* AST-based rather than text/regex-based: an object literal can be nested,
|
|
6
|
+
* wrapped or multi-line, and matching on text gets that wrong in exactly the
|
|
7
|
+
* cases where a user most wants help.
|
|
8
8
|
*/
|
|
9
|
-
export type ValCompletionContext =
|
|
9
|
+
export type ValCompletionContext = ValStringValueContext;
|
|
10
10
|
/** The cursor is inside a plain string in the module's content. */
|
|
11
11
|
export type ValStringValueContext = {
|
|
12
12
|
kind: "string-value";
|
|
@@ -29,49 +29,34 @@ export type ValStringValueContext = {
|
|
|
29
29
|
*/
|
|
30
30
|
valueOfProperty?: string;
|
|
31
31
|
};
|
|
32
|
-
export type ValFileRefContext = {
|
|
33
|
-
kind: "file-ref";
|
|
34
|
-
/** Which constructor: `c.image(...)` or `c.file(...)`. */
|
|
35
|
-
subType: "image" | "file";
|
|
36
|
-
/** The string literal being edited, without quotes. */
|
|
37
|
-
currentText: string;
|
|
38
|
-
/** Offsets of the string literal's contents, excluding the quotes. */
|
|
39
|
-
contentStart: number;
|
|
40
|
-
contentEnd: number;
|
|
41
|
-
/**
|
|
42
|
-
* Start offset of the reference argument, including its opening quote.
|
|
43
|
-
*
|
|
44
|
-
* Stable while the user types to filter the completion list, because every
|
|
45
|
-
* such keystroke lands *inside* the literal. That makes it the anchor
|
|
46
|
-
* {@link findFileRefArgument} re-locates the call by at resolve time.
|
|
47
|
-
*/
|
|
48
|
-
refArgStart: number;
|
|
49
|
-
/** End offset of the reference argument, where a metadata argument follows. */
|
|
50
|
-
refArgEnd: number;
|
|
51
|
-
/** Offsets of an existing metadata argument, when there is one. */
|
|
52
|
-
metadataStart?: number;
|
|
53
|
-
metadataEnd?: number;
|
|
54
|
-
};
|
|
55
32
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
33
|
+
* The innermost string literal containing `offset`, described well enough for a
|
|
34
|
+
* schema-driven completion to decide whether it applies.
|
|
58
35
|
*/
|
|
59
36
|
export declare function getValCompletionContext(sourceFile: ts.SourceFile, offset: number): ValCompletionContext | undefined;
|
|
37
|
+
/** The properties Val computes from a file's bytes. */
|
|
38
|
+
export declare const MEDIA_METADATA_KEYS: readonly ["width", "height", "mimeType"];
|
|
39
|
+
export type MediaMetadataKey = (typeof MEDIA_METADATA_KEYS)[number];
|
|
40
|
+
export type MediaPathObject = {
|
|
41
|
+
/** Offset to insert missing metadata properties after. */
|
|
42
|
+
insertAfter: number;
|
|
43
|
+
/** Where each metadata property's value is now, when it is there. */
|
|
44
|
+
existing: Partial<Record<MediaMetadataKey, {
|
|
45
|
+
start: number;
|
|
46
|
+
end: number;
|
|
47
|
+
}>>;
|
|
48
|
+
};
|
|
60
49
|
/**
|
|
61
|
-
* Re-find the
|
|
62
|
-
*
|
|
50
|
+
* Re-find the media object literal whose `path` value starts at
|
|
51
|
+
* `pathValueStart`, and report where its metadata siblings are *now*.
|
|
63
52
|
*
|
|
64
53
|
* `completionItem/resolve` runs against a document the user may have typed into
|
|
65
54
|
* since the list was computed, so the offsets captured back then have moved.
|
|
66
|
-
* Applying them anyway inserts
|
|
67
|
-
*
|
|
55
|
+
* Applying them anyway inserts text into the middle of the string literal and
|
|
56
|
+
* corrupts the file, so the offsets are re-derived here instead.
|
|
68
57
|
*
|
|
69
|
-
* Returns `undefined` when no such
|
|
70
|
-
* way this anchor does not survive, and the caller must then offer no edit
|
|
58
|
+
* Returns `undefined` when no such object is found — the document changed in
|
|
59
|
+
* some way this anchor does not survive, and the caller must then offer no edit
|
|
71
60
|
* rather than a wrong one.
|
|
72
61
|
*/
|
|
73
|
-
export declare function
|
|
74
|
-
refArgEnd: number;
|
|
75
|
-
metadataStart?: number;
|
|
76
|
-
metadataEnd?: number;
|
|
77
|
-
} | undefined;
|
|
62
|
+
export declare function findMediaPathObject(sourceFile: ts.SourceFile, pathValueStart: number): MediaPathObject | undefined;
|