@valbuild/language-server 0.102.0 → 0.103.1

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 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 { CodeAction, type Diagnostic, type TextEdit } from "vscode-languageserver";
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
- * Narrow an edit down to the region that actually changed.
26
+ * Quick fix for a module that is not registered in `val.modules`.
21
27
  *
22
- * A whole-document replacement would work, but it moves the cursor and shows up
23
- * as a full-file change in review. Trimming the common prefix and suffix keeps
24
- * the edit tight without needing a real diff algorithm.
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 minimalTextEdit(before: string, after: string, document: TextDocument): TextEdit | undefined;
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: `c.image(` can be nested, wrapped or
6
- * multi-line, and matching on text gets that wrong in exactly the cases where a
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 = ValFileRefContext | ValStringValueContext;
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
- * Find the innermost `c.image(...)` / `c.file(...)` call whose first argument
57
- * contains `offset`.
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 `c.image(...)` / `c.file(...)` call whose reference argument starts
62
- * at `refArgStart`, and report where its arguments are *now*.
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 the metadata object into the middle of the string
67
- * literal and corrupts the file, so the offsets are re-derived here instead.
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 call is found — the document changed in some
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 findFileRefArgument(sourceFile: ts.SourceFile, refArgStart: number): {
74
- refArgEnd: number;
75
- metadataStart?: number;
76
- metadataEnd?: number;
77
- } | undefined;
62
+ export declare function findMediaPathObject(sourceFile: ts.SourceFile, pathValueStart: number): MediaPathObject | undefined;