@valbuild/language-server 0.123.0 → 0.123.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @valbuild/language-server
2
2
 
3
+ ## 0.123.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [#623](https://github.com/valbuild/val/pull/623) [`4c9369b`](https://github.com/valbuild/val/commit/4c9369bad3f17e96868262e78a4f47361d85319e) Thanks [@freekh](https://github.com/freekh)! - Add the editor quick fix for a `.jsonValues()` entry written inline: **Val: move entry into its own .val.json**.
8
+
9
+ The language server already reported the problem — "Entry '…' is written inline … Run 'val validate --fix' to move it" — but offered no way to act on it, so the only remedy was to leave the editor and run the CLI. It now offers a quick fix that creates the `*.val.json` with the entry's content and rewrites the `.val.ts` to `c.json(() => import("./…"))`, in one undoable step.
10
+
11
+ The fix is computed from the same code `val validate --fix` runs, so the two write the same files, and it is computed against the buffer you are looking at rather than what is on disk — an unsaved module can be fixed without saving first. It refuses, rather than overwriting, when a file or an unsaved buffer already occupies the target path.
12
+
13
+ Requires an editor that honours file creation inside a workspace edit; VS Code does. In an editor that does not announce it, no action is offered rather than one that would rewrite the module to import a file that never got created.
14
+
15
+ - Updated dependencies [[`4c9369b`](https://github.com/valbuild/val/commit/4c9369bad3f17e96868262e78a4f47361d85319e), [`fb1baff`](https://github.com/valbuild/val/commit/fb1baffead5228d8a86a51af65178b88738d5a64), [`accf4f8`](https://github.com/valbuild/val/commit/accf4f852fe3400d762cc14e80316da741a69a9a), [`aef45ce`](https://github.com/valbuild/val/commit/aef45ce3ef269f1b6221de44a56df0f6a0d9dbbd)]:
16
+ - @valbuild/server@0.123.3
17
+ - @valbuild/shared@0.123.3
18
+
19
+ ## 0.123.2
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [[`04b6d4c`](https://github.com/valbuild/val/commit/04b6d4cbbecc131bbaf3c20633af9dad8c857310), [`8e58c34`](https://github.com/valbuild/val/commit/8e58c3495d1bf0f221a57082cb0a3045929722a1)]:
24
+ - @valbuild/shared@0.123.2
25
+ - @valbuild/server@0.123.2
26
+
3
27
  ## 0.123.0
4
28
 
5
29
  ### Patch Changes
@@ -7,7 +7,10 @@ import type { ValModuleContent } from "./ValProject.js";
7
7
  import { minimalTextEdit } from "./textEdit.js";
8
8
  export { minimalTextEdit };
9
9
  export declare function isLocalFix(fix: string): fix is ValidationFix;
10
- export declare function createValCodeActions({ document, diagnostics, content, valRoot, moduleFilePath, read, remoteHost, }: {
10
+ export declare function isWorkspaceEditFix(fix: string): fix is ValidationFix;
11
+ /** Whether the client will honour a file CREATE inside a `WorkspaceEdit`. */
12
+ export declare function canCreateFiles(capabilities: unknown): boolean;
13
+ export declare function createValCodeActions({ document, diagnostics, content, valRoot, moduleFilePath, read, remoteHost, allowCreateFiles, }: {
11
14
  document: TextDocument;
12
15
  diagnostics: Diagnostic[];
13
16
  content: ValModuleContent;
@@ -25,6 +28,11 @@ export declare function createValCodeActions({ document, diagnostics, content, v
25
28
  */
26
29
  read?: (fsPath: string) => string | undefined;
27
30
  remoteHost?: string;
31
+ /**
32
+ * Whether the client honours a file CREATE in a `WorkspaceEdit`. Without it
33
+ * the extract-entry fix is not offered at all — see {@link canCreateFiles}.
34
+ */
35
+ allowCreateFiles?: boolean;
28
36
  }): Promise<CodeAction[]>;
29
37
  /**
30
38
  * Quick fix for a module that is not registered in `val.modules`.
@@ -2385,6 +2385,26 @@ function readFile(filePath) {
2385
2385
  }
2386
2386
  }
2387
2387
 
2388
+ /**
2389
+ * What the client announced it will do with a `WorkspaceEdit`.
2390
+ *
2391
+ * A resource operation — a file created, renamed or deleted as part of an edit —
2392
+ * is silently DROPPED by a client that did not announce it. The rest of the edit
2393
+ * still applies, so a fix that needs one and is offered anyway leaves the
2394
+ * project in a state that is worse than the one it set out to fix: a `.val.ts`
2395
+ * rewritten to import a file that was never created, a path rewritten to a file
2396
+ * that never moved. Hence: probe first, and do not offer the action at all.
2397
+ *
2398
+ * Read defensively. These come from `InitializeParams.capabilities`, which is
2399
+ * whatever the client sent — this is the one place that has to cope with the
2400
+ * field being absent or the wrong shape.
2401
+ */
2402
+ function supportsResourceOperation(capabilities, operation) {
2403
+ const workspace = capabilities?.workspace;
2404
+ const operations = workspace?.workspaceEdit?.resourceOperations;
2405
+ return Array.isArray(operations) && operations.includes(operation);
2406
+ }
2407
+
2388
2408
  /**
2389
2409
  * Quick fixes, built by running Val's own fix machinery.
2390
2410
  *
@@ -2408,6 +2428,18 @@ const LOCAL_FIXES = ["image:add-metadata", "image:check-metadata", "file:add-met
2408
2428
  // stored metadata, dropping entries whose file has gone. Filesystem only.
2409
2429
  "images:check-all-files", "files:check-all-files"];
2410
2430
 
2431
+ /**
2432
+ * Fixes that are local, but are NOT a patch against the document they are
2433
+ * reported on, so they never reach `computeFixEdit`.
2434
+ *
2435
+ * `jsonValues:extract-entry` creates a file and rewrites another — see
2436
+ * {@link createExtractEntryAction}. `createFixPatch` has no branch for it at all
2437
+ * (the fix lives in the server's own `extractJsonValuesEntry`), so routing it
2438
+ * through the patch pipeline would silently produce an empty patch and no
2439
+ * action, which is exactly what it did before this existed.
2440
+ */
2441
+ const WORKSPACE_EDIT_FIXES = ["jsonValues:extract-entry"];
2442
+
2411
2443
  /** Human-readable titles; falls back to the fix name for anything unknown. */
2412
2444
  const FIX_TITLES = {
2413
2445
  "image:add-metadata": "Val: add image metadata",
@@ -2415,11 +2447,20 @@ const FIX_TITLES = {
2415
2447
  "file:add-metadata": "Val: add file metadata",
2416
2448
  "file:check-metadata": "Val: update file metadata",
2417
2449
  "images:check-all-files": "Val: update gallery image metadata",
2418
- "files:check-all-files": "Val: update gallery file metadata"
2450
+ "files:check-all-files": "Val: update gallery file metadata",
2451
+ "jsonValues:extract-entry": "Val: move entry into its own .val.json"
2419
2452
  };
2420
2453
  function isLocalFix(fix) {
2421
2454
  return LOCAL_FIXES.includes(fix);
2422
2455
  }
2456
+ function isWorkspaceEditFix(fix) {
2457
+ return WORKSPACE_EDIT_FIXES.includes(fix);
2458
+ }
2459
+
2460
+ /** Whether the client will honour a file CREATE inside a `WorkspaceEdit`. */
2461
+ function canCreateFiles(capabilities) {
2462
+ return supportsResourceOperation(capabilities, "create");
2463
+ }
2423
2464
 
2424
2465
  /**
2425
2466
  * Build quick fixes for the diagnostics the client sent back.
@@ -2443,7 +2484,8 @@ async function createValCodeActions({
2443
2484
  valRoot,
2444
2485
  moduleFilePath,
2445
2486
  read,
2446
- remoteHost = process.env.VAL_REMOTE_HOST || core.DEFAULT_VAL_REMOTE_HOST
2487
+ remoteHost = process.env.VAL_REMOTE_HOST || core.DEFAULT_VAL_REMOTE_HOST,
2488
+ allowCreateFiles = false
2447
2489
  }) {
2448
2490
  const actions = [];
2449
2491
  /**
@@ -2492,6 +2534,26 @@ async function createValCodeActions({
2492
2534
  });
2493
2535
  continue;
2494
2536
  }
2537
+ if (isWorkspaceEditFix(fix)) {
2538
+ if (offered.has(`${data.sourcePath}|${fix}`)) {
2539
+ continue;
2540
+ }
2541
+ const action = createExtractEntryAction({
2542
+ document,
2543
+ sourcePath: data.sourcePath,
2544
+ content,
2545
+ valRoot,
2546
+ moduleFilePath,
2547
+ read,
2548
+ allowCreateFiles
2549
+ });
2550
+ if (!action) {
2551
+ continue;
2552
+ }
2553
+ offered.add(`${data.sourcePath}|${fix}`);
2554
+ actions.push(action);
2555
+ continue;
2556
+ }
2495
2557
  if (!isLocalFix(fix)) {
2496
2558
  continue;
2497
2559
  }
@@ -2534,6 +2596,131 @@ async function createValCodeActions({
2534
2596
  }
2535
2597
  return actions;
2536
2598
  }
2599
+
2600
+ /**
2601
+ * The quick fix for `jsonValues:extract-entry`: move an entry that was written
2602
+ * inline in the `.val.ts` into its own `*.val.json`.
2603
+ *
2604
+ * Not a patch, and so not `computeFixEdit`. Two files change at once — a
2605
+ * `*.val.json` is created with the entry's content, and the `.val.ts` has the
2606
+ * inline value replaced by `c.json(() => import("./..."))` — and a `Patch` can
2607
+ * only edit one `.val.ts` and cannot create anything. That is why the fix is
2608
+ * `extractJsonValuesEntry` in the server rather than a `createFixPatch` branch,
2609
+ * and why the editor had no fix to offer for it at all until now: the patch
2610
+ * pipeline produced nothing and the action was dropped.
2611
+ *
2612
+ * The plan comes from the same `planJsonValuesEntryExtraction` that
2613
+ * `val validate --fix` runs, so the two cannot write different files; only the
2614
+ * last step differs. Here it is a `WorkspaceEdit`, so the change goes through
2615
+ * the editor's undo stack, and — crucially — is computed against the buffer the
2616
+ * user is looking at rather than what is on disk.
2617
+ */
2618
+ function createExtractEntryAction({
2619
+ document,
2620
+ sourcePath,
2621
+ content,
2622
+ valRoot,
2623
+ moduleFilePath,
2624
+ read,
2625
+ allowCreateFiles
2626
+ }) {
2627
+ if (!moduleFilePath || !allowCreateFiles) {
2628
+ return undefined;
2629
+ }
2630
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
2631
+ const parts = core.Internal.splitModulePath(modulePath);
2632
+ // Root-only by contract, exactly as the server's fix handler asserts: the
2633
+ // entry is a direct child of the module's root record/router.
2634
+ if (parts.length !== 1) {
2635
+ return undefined;
2636
+ }
2637
+ const entryKey = parts[0];
2638
+ const entryContent = entryContentOf(content.source, entryKey);
2639
+ if (entryContent === undefined) {
2640
+ return undefined;
2641
+ }
2642
+ const planned = server.planJsonValuesEntryExtraction({
2643
+ moduleFilePath,
2644
+ entryKey,
2645
+ content: entryContent,
2646
+ valTsPath: uriToPath(document.uri),
2647
+ // The editor's text, not the file's: an edit computed against stale text
2648
+ // has ranges that land in the wrong place.
2649
+ valTsText: document.getText()
2650
+ });
2651
+ if (fp.result.isErr(planned)) {
2652
+ return undefined;
2653
+ }
2654
+ const absoluteJsonPath = path__default["default"].join(valRoot, planned.value.jsonPath);
2655
+ // Same refusal as the CLI's, widened by one case: an unsaved buffer counts as
2656
+ // occupied too. Creating over either would destroy content that a `--fix` run
2657
+ // refuses to touch.
2658
+ if (read?.(absoluteJsonPath) !== undefined || fs__default["default"].existsSync(absoluteJsonPath)) {
2659
+ return undefined;
2660
+ }
2661
+ const valTsEdit = minimalTextEdit(document.getText(), planned.value.valTsContent, document);
2662
+ if (!valTsEdit) {
2663
+ return undefined;
2664
+ }
2665
+ const jsonUri = pathToUri(absoluteJsonPath);
2666
+ return {
2667
+ title: FIX_TITLES["jsonValues:extract-entry"] ,
2668
+ kind: vscodeLanguageserver.CodeActionKind.QuickFix,
2669
+ edit: {
2670
+ // `documentChanges`, not `changes`: a plain edit map cannot create a file,
2671
+ // and an edit addressed to a file that does not exist is an error rather
2672
+ // than a creation.
2673
+ documentChanges: [{
2674
+ kind: "create",
2675
+ uri: jsonUri
2676
+ }, {
2677
+ textDocument: {
2678
+ uri: jsonUri,
2679
+ version: null
2680
+ },
2681
+ edits: [{
2682
+ range: {
2683
+ start: {
2684
+ line: 0,
2685
+ character: 0
2686
+ },
2687
+ end: {
2688
+ line: 0,
2689
+ character: 0
2690
+ }
2691
+ },
2692
+ newText: planned.value.jsonContent
2693
+ }]
2694
+ }, {
2695
+ textDocument: {
2696
+ uri: document.uri,
2697
+ version: null
2698
+ },
2699
+ edits: [valTsEdit]
2700
+ }]
2701
+ }
2702
+ };
2703
+ }
2704
+
2705
+ /**
2706
+ * One entry's value out of a module's source.
2707
+ *
2708
+ * `Object.entries` rather than an index: the source is a `Source`, which has no
2709
+ * index signature, and reading a dynamic key off it is the one thing this needs
2710
+ * to do. Mirrors what the server's fix handler reads, so the editor extracts the
2711
+ * same bytes `val validate --fix` would.
2712
+ */
2713
+ function entryContentOf(source, entryKey) {
2714
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
2715
+ return undefined;
2716
+ }
2717
+ for (const [key, value] of Object.entries(source)) {
2718
+ if (key === entryKey) {
2719
+ return value;
2720
+ }
2721
+ }
2722
+ return undefined;
2723
+ }
2537
2724
  async function computeFixEdit({
2538
2725
  document,
2539
2726
  sourcePath,
@@ -3317,9 +3504,7 @@ function keysOfKeyOf(schema, snapshot) {
3317
3504
  * was — worse than not offering the fix.
3318
3505
  */
3319
3506
  function canRenameFiles(capabilities) {
3320
- const workspace = capabilities?.workspace;
3321
- const operations = workspace?.workspaceEdit?.resourceOperations;
3322
- return Array.isArray(operations) && operations.includes("rename");
3507
+ return supportsResourceOperation(capabilities, "rename");
3323
3508
  }
3324
3509
  async function createGalleryMembershipActions({
3325
3510
  document,
@@ -3792,6 +3977,7 @@ function createValLanguageServer(connection) {
3792
3977
  const documents = new node.TextDocuments(vscodeLanguageserverTextdocument.TextDocument);
3793
3978
  let canRegisterWatchers = false;
3794
3979
  let canRenameFiles$1 = false;
3980
+ let canCreateFiles$1 = false;
3795
3981
 
3796
3982
  /**
3797
3983
  * The editor's view of a file, by absolute path, or `undefined` when the file
@@ -3955,6 +4141,10 @@ function createValLanguageServer(connection) {
3955
4141
  // A RenameFile sent to a client that did not announce resourceOperations is
3956
4142
  // silently dropped, which would rewrite the path and leave the file behind.
3957
4143
  canRenameFiles$1 = canRenameFiles(params.capabilities);
4144
+ // Same reasoning for the extract-entry fix: it CREATES the `*.val.json` the
4145
+ // rewritten `.val.ts` imports, so a client that drops the create would be
4146
+ // left importing a file that is not there.
4147
+ canCreateFiles$1 = canCreateFiles(params.capabilities);
3958
4148
  const clientCapabilities = params.capabilities.experimental?.val ?? {};
3959
4149
 
3960
4150
  // Announce only what this version actually serves: a client hides UI for
@@ -4162,7 +4352,8 @@ function createValLanguageServer(connection) {
4162
4352
  content: result.content,
4163
4353
  valRoot: project.valRoot,
4164
4354
  moduleFilePath,
4165
- read: readOpenDocument
4355
+ read: readOpenDocument,
4356
+ allowCreateFiles: canCreateFiles$1
4166
4357
  })));
4167
4358
  return actions;
4168
4359
  } catch (e) {
@@ -2385,6 +2385,26 @@ function readFile(filePath) {
2385
2385
  }
2386
2386
  }
2387
2387
 
2388
+ /**
2389
+ * What the client announced it will do with a `WorkspaceEdit`.
2390
+ *
2391
+ * A resource operation — a file created, renamed or deleted as part of an edit —
2392
+ * is silently DROPPED by a client that did not announce it. The rest of the edit
2393
+ * still applies, so a fix that needs one and is offered anyway leaves the
2394
+ * project in a state that is worse than the one it set out to fix: a `.val.ts`
2395
+ * rewritten to import a file that was never created, a path rewritten to a file
2396
+ * that never moved. Hence: probe first, and do not offer the action at all.
2397
+ *
2398
+ * Read defensively. These come from `InitializeParams.capabilities`, which is
2399
+ * whatever the client sent — this is the one place that has to cope with the
2400
+ * field being absent or the wrong shape.
2401
+ */
2402
+ function supportsResourceOperation(capabilities, operation) {
2403
+ const workspace = capabilities?.workspace;
2404
+ const operations = workspace?.workspaceEdit?.resourceOperations;
2405
+ return Array.isArray(operations) && operations.includes(operation);
2406
+ }
2407
+
2388
2408
  /**
2389
2409
  * Quick fixes, built by running Val's own fix machinery.
2390
2410
  *
@@ -2408,6 +2428,18 @@ const LOCAL_FIXES = ["image:add-metadata", "image:check-metadata", "file:add-met
2408
2428
  // stored metadata, dropping entries whose file has gone. Filesystem only.
2409
2429
  "images:check-all-files", "files:check-all-files"];
2410
2430
 
2431
+ /**
2432
+ * Fixes that are local, but are NOT a patch against the document they are
2433
+ * reported on, so they never reach `computeFixEdit`.
2434
+ *
2435
+ * `jsonValues:extract-entry` creates a file and rewrites another — see
2436
+ * {@link createExtractEntryAction}. `createFixPatch` has no branch for it at all
2437
+ * (the fix lives in the server's own `extractJsonValuesEntry`), so routing it
2438
+ * through the patch pipeline would silently produce an empty patch and no
2439
+ * action, which is exactly what it did before this existed.
2440
+ */
2441
+ const WORKSPACE_EDIT_FIXES = ["jsonValues:extract-entry"];
2442
+
2411
2443
  /** Human-readable titles; falls back to the fix name for anything unknown. */
2412
2444
  const FIX_TITLES = {
2413
2445
  "image:add-metadata": "Val: add image metadata",
@@ -2415,11 +2447,20 @@ const FIX_TITLES = {
2415
2447
  "file:add-metadata": "Val: add file metadata",
2416
2448
  "file:check-metadata": "Val: update file metadata",
2417
2449
  "images:check-all-files": "Val: update gallery image metadata",
2418
- "files:check-all-files": "Val: update gallery file metadata"
2450
+ "files:check-all-files": "Val: update gallery file metadata",
2451
+ "jsonValues:extract-entry": "Val: move entry into its own .val.json"
2419
2452
  };
2420
2453
  function isLocalFix(fix) {
2421
2454
  return LOCAL_FIXES.includes(fix);
2422
2455
  }
2456
+ function isWorkspaceEditFix(fix) {
2457
+ return WORKSPACE_EDIT_FIXES.includes(fix);
2458
+ }
2459
+
2460
+ /** Whether the client will honour a file CREATE inside a `WorkspaceEdit`. */
2461
+ function canCreateFiles(capabilities) {
2462
+ return supportsResourceOperation(capabilities, "create");
2463
+ }
2423
2464
 
2424
2465
  /**
2425
2466
  * Build quick fixes for the diagnostics the client sent back.
@@ -2443,7 +2484,8 @@ async function createValCodeActions({
2443
2484
  valRoot,
2444
2485
  moduleFilePath,
2445
2486
  read,
2446
- remoteHost = process.env.VAL_REMOTE_HOST || core.DEFAULT_VAL_REMOTE_HOST
2487
+ remoteHost = process.env.VAL_REMOTE_HOST || core.DEFAULT_VAL_REMOTE_HOST,
2488
+ allowCreateFiles = false
2447
2489
  }) {
2448
2490
  const actions = [];
2449
2491
  /**
@@ -2492,6 +2534,26 @@ async function createValCodeActions({
2492
2534
  });
2493
2535
  continue;
2494
2536
  }
2537
+ if (isWorkspaceEditFix(fix)) {
2538
+ if (offered.has(`${data.sourcePath}|${fix}`)) {
2539
+ continue;
2540
+ }
2541
+ const action = createExtractEntryAction({
2542
+ document,
2543
+ sourcePath: data.sourcePath,
2544
+ content,
2545
+ valRoot,
2546
+ moduleFilePath,
2547
+ read,
2548
+ allowCreateFiles
2549
+ });
2550
+ if (!action) {
2551
+ continue;
2552
+ }
2553
+ offered.add(`${data.sourcePath}|${fix}`);
2554
+ actions.push(action);
2555
+ continue;
2556
+ }
2495
2557
  if (!isLocalFix(fix)) {
2496
2558
  continue;
2497
2559
  }
@@ -2534,6 +2596,131 @@ async function createValCodeActions({
2534
2596
  }
2535
2597
  return actions;
2536
2598
  }
2599
+
2600
+ /**
2601
+ * The quick fix for `jsonValues:extract-entry`: move an entry that was written
2602
+ * inline in the `.val.ts` into its own `*.val.json`.
2603
+ *
2604
+ * Not a patch, and so not `computeFixEdit`. Two files change at once — a
2605
+ * `*.val.json` is created with the entry's content, and the `.val.ts` has the
2606
+ * inline value replaced by `c.json(() => import("./..."))` — and a `Patch` can
2607
+ * only edit one `.val.ts` and cannot create anything. That is why the fix is
2608
+ * `extractJsonValuesEntry` in the server rather than a `createFixPatch` branch,
2609
+ * and why the editor had no fix to offer for it at all until now: the patch
2610
+ * pipeline produced nothing and the action was dropped.
2611
+ *
2612
+ * The plan comes from the same `planJsonValuesEntryExtraction` that
2613
+ * `val validate --fix` runs, so the two cannot write different files; only the
2614
+ * last step differs. Here it is a `WorkspaceEdit`, so the change goes through
2615
+ * the editor's undo stack, and — crucially — is computed against the buffer the
2616
+ * user is looking at rather than what is on disk.
2617
+ */
2618
+ function createExtractEntryAction({
2619
+ document,
2620
+ sourcePath,
2621
+ content,
2622
+ valRoot,
2623
+ moduleFilePath,
2624
+ read,
2625
+ allowCreateFiles
2626
+ }) {
2627
+ if (!moduleFilePath || !allowCreateFiles) {
2628
+ return undefined;
2629
+ }
2630
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
2631
+ const parts = core.Internal.splitModulePath(modulePath);
2632
+ // Root-only by contract, exactly as the server's fix handler asserts: the
2633
+ // entry is a direct child of the module's root record/router.
2634
+ if (parts.length !== 1) {
2635
+ return undefined;
2636
+ }
2637
+ const entryKey = parts[0];
2638
+ const entryContent = entryContentOf(content.source, entryKey);
2639
+ if (entryContent === undefined) {
2640
+ return undefined;
2641
+ }
2642
+ const planned = server.planJsonValuesEntryExtraction({
2643
+ moduleFilePath,
2644
+ entryKey,
2645
+ content: entryContent,
2646
+ valTsPath: uriToPath(document.uri),
2647
+ // The editor's text, not the file's: an edit computed against stale text
2648
+ // has ranges that land in the wrong place.
2649
+ valTsText: document.getText()
2650
+ });
2651
+ if (fp.result.isErr(planned)) {
2652
+ return undefined;
2653
+ }
2654
+ const absoluteJsonPath = path__default["default"].join(valRoot, planned.value.jsonPath);
2655
+ // Same refusal as the CLI's, widened by one case: an unsaved buffer counts as
2656
+ // occupied too. Creating over either would destroy content that a `--fix` run
2657
+ // refuses to touch.
2658
+ if (read?.(absoluteJsonPath) !== undefined || fs__default["default"].existsSync(absoluteJsonPath)) {
2659
+ return undefined;
2660
+ }
2661
+ const valTsEdit = minimalTextEdit(document.getText(), planned.value.valTsContent, document);
2662
+ if (!valTsEdit) {
2663
+ return undefined;
2664
+ }
2665
+ const jsonUri = pathToUri(absoluteJsonPath);
2666
+ return {
2667
+ title: FIX_TITLES["jsonValues:extract-entry"] ,
2668
+ kind: vscodeLanguageserver.CodeActionKind.QuickFix,
2669
+ edit: {
2670
+ // `documentChanges`, not `changes`: a plain edit map cannot create a file,
2671
+ // and an edit addressed to a file that does not exist is an error rather
2672
+ // than a creation.
2673
+ documentChanges: [{
2674
+ kind: "create",
2675
+ uri: jsonUri
2676
+ }, {
2677
+ textDocument: {
2678
+ uri: jsonUri,
2679
+ version: null
2680
+ },
2681
+ edits: [{
2682
+ range: {
2683
+ start: {
2684
+ line: 0,
2685
+ character: 0
2686
+ },
2687
+ end: {
2688
+ line: 0,
2689
+ character: 0
2690
+ }
2691
+ },
2692
+ newText: planned.value.jsonContent
2693
+ }]
2694
+ }, {
2695
+ textDocument: {
2696
+ uri: document.uri,
2697
+ version: null
2698
+ },
2699
+ edits: [valTsEdit]
2700
+ }]
2701
+ }
2702
+ };
2703
+ }
2704
+
2705
+ /**
2706
+ * One entry's value out of a module's source.
2707
+ *
2708
+ * `Object.entries` rather than an index: the source is a `Source`, which has no
2709
+ * index signature, and reading a dynamic key off it is the one thing this needs
2710
+ * to do. Mirrors what the server's fix handler reads, so the editor extracts the
2711
+ * same bytes `val validate --fix` would.
2712
+ */
2713
+ function entryContentOf(source, entryKey) {
2714
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
2715
+ return undefined;
2716
+ }
2717
+ for (const [key, value] of Object.entries(source)) {
2718
+ if (key === entryKey) {
2719
+ return value;
2720
+ }
2721
+ }
2722
+ return undefined;
2723
+ }
2537
2724
  async function computeFixEdit({
2538
2725
  document,
2539
2726
  sourcePath,
@@ -3317,9 +3504,7 @@ function keysOfKeyOf(schema, snapshot) {
3317
3504
  * was — worse than not offering the fix.
3318
3505
  */
3319
3506
  function canRenameFiles(capabilities) {
3320
- const workspace = capabilities?.workspace;
3321
- const operations = workspace?.workspaceEdit?.resourceOperations;
3322
- return Array.isArray(operations) && operations.includes("rename");
3507
+ return supportsResourceOperation(capabilities, "rename");
3323
3508
  }
3324
3509
  async function createGalleryMembershipActions({
3325
3510
  document,
@@ -3792,6 +3977,7 @@ function createValLanguageServer(connection) {
3792
3977
  const documents = new node.TextDocuments(vscodeLanguageserverTextdocument.TextDocument);
3793
3978
  let canRegisterWatchers = false;
3794
3979
  let canRenameFiles$1 = false;
3980
+ let canCreateFiles$1 = false;
3795
3981
 
3796
3982
  /**
3797
3983
  * The editor's view of a file, by absolute path, or `undefined` when the file
@@ -3955,6 +4141,10 @@ function createValLanguageServer(connection) {
3955
4141
  // A RenameFile sent to a client that did not announce resourceOperations is
3956
4142
  // silently dropped, which would rewrite the path and leave the file behind.
3957
4143
  canRenameFiles$1 = canRenameFiles(params.capabilities);
4144
+ // Same reasoning for the extract-entry fix: it CREATES the `*.val.json` the
4145
+ // rewritten `.val.ts` imports, so a client that drops the create would be
4146
+ // left importing a file that is not there.
4147
+ canCreateFiles$1 = canCreateFiles(params.capabilities);
3958
4148
  const clientCapabilities = params.capabilities.experimental?.val ?? {};
3959
4149
 
3960
4150
  // Announce only what this version actually serves: a client hides UI for
@@ -4162,7 +4352,8 @@ function createValLanguageServer(connection) {
4162
4352
  content: result.content,
4163
4353
  valRoot: project.valRoot,
4164
4354
  moduleFilePath,
4165
- read: readOpenDocument
4355
+ read: readOpenDocument,
4356
+ allowCreateFiles: canCreateFiles$1
4166
4357
  })));
4167
4358
  return actions;
4168
4359
  } catch (e) {
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import ts from 'typescript';
4
4
  import { Internal, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST } from '@valbuild/core';
5
- import { fixHandlers, createService, analyzeValModule, createFixPatch, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, extractImageMetadata, extractFileMetadata } from '@valbuild/server';
5
+ import { fixHandlers, createService, analyzeValModule, createFixPatch, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, planJsonValuesEntryExtraction, extractImageMetadata, extractFileMetadata } from '@valbuild/server';
6
6
  import { DiagnosticSeverity, ShowDocumentRequest, ApplyWorkspaceEditRequest, CodeActionKind, CodeAction, MarkupContent, CompletionItemKind, DidChangeWatchedFilesNotification } from 'vscode-languageserver';
7
7
  import { TextDocuments, TextDocumentSyncKind, createConnection, ProposedFeatures } from 'vscode-languageserver/node';
8
8
  import { TextDocument } from 'vscode-languageserver-textdocument';
@@ -2374,6 +2374,26 @@ function readFile(filePath) {
2374
2374
  }
2375
2375
  }
2376
2376
 
2377
+ /**
2378
+ * What the client announced it will do with a `WorkspaceEdit`.
2379
+ *
2380
+ * A resource operation — a file created, renamed or deleted as part of an edit —
2381
+ * is silently DROPPED by a client that did not announce it. The rest of the edit
2382
+ * still applies, so a fix that needs one and is offered anyway leaves the
2383
+ * project in a state that is worse than the one it set out to fix: a `.val.ts`
2384
+ * rewritten to import a file that was never created, a path rewritten to a file
2385
+ * that never moved. Hence: probe first, and do not offer the action at all.
2386
+ *
2387
+ * Read defensively. These come from `InitializeParams.capabilities`, which is
2388
+ * whatever the client sent — this is the one place that has to cope with the
2389
+ * field being absent or the wrong shape.
2390
+ */
2391
+ function supportsResourceOperation(capabilities, operation) {
2392
+ const workspace = capabilities?.workspace;
2393
+ const operations = workspace?.workspaceEdit?.resourceOperations;
2394
+ return Array.isArray(operations) && operations.includes(operation);
2395
+ }
2396
+
2377
2397
  /**
2378
2398
  * Quick fixes, built by running Val's own fix machinery.
2379
2399
  *
@@ -2397,6 +2417,18 @@ const LOCAL_FIXES = ["image:add-metadata", "image:check-metadata", "file:add-met
2397
2417
  // stored metadata, dropping entries whose file has gone. Filesystem only.
2398
2418
  "images:check-all-files", "files:check-all-files"];
2399
2419
 
2420
+ /**
2421
+ * Fixes that are local, but are NOT a patch against the document they are
2422
+ * reported on, so they never reach `computeFixEdit`.
2423
+ *
2424
+ * `jsonValues:extract-entry` creates a file and rewrites another — see
2425
+ * {@link createExtractEntryAction}. `createFixPatch` has no branch for it at all
2426
+ * (the fix lives in the server's own `extractJsonValuesEntry`), so routing it
2427
+ * through the patch pipeline would silently produce an empty patch and no
2428
+ * action, which is exactly what it did before this existed.
2429
+ */
2430
+ const WORKSPACE_EDIT_FIXES = ["jsonValues:extract-entry"];
2431
+
2400
2432
  /** Human-readable titles; falls back to the fix name for anything unknown. */
2401
2433
  const FIX_TITLES = {
2402
2434
  "image:add-metadata": "Val: add image metadata",
@@ -2404,11 +2436,20 @@ const FIX_TITLES = {
2404
2436
  "file:add-metadata": "Val: add file metadata",
2405
2437
  "file:check-metadata": "Val: update file metadata",
2406
2438
  "images:check-all-files": "Val: update gallery image metadata",
2407
- "files:check-all-files": "Val: update gallery file metadata"
2439
+ "files:check-all-files": "Val: update gallery file metadata",
2440
+ "jsonValues:extract-entry": "Val: move entry into its own .val.json"
2408
2441
  };
2409
2442
  function isLocalFix(fix) {
2410
2443
  return LOCAL_FIXES.includes(fix);
2411
2444
  }
2445
+ function isWorkspaceEditFix(fix) {
2446
+ return WORKSPACE_EDIT_FIXES.includes(fix);
2447
+ }
2448
+
2449
+ /** Whether the client will honour a file CREATE inside a `WorkspaceEdit`. */
2450
+ function canCreateFiles(capabilities) {
2451
+ return supportsResourceOperation(capabilities, "create");
2452
+ }
2412
2453
 
2413
2454
  /**
2414
2455
  * Build quick fixes for the diagnostics the client sent back.
@@ -2432,7 +2473,8 @@ async function createValCodeActions({
2432
2473
  valRoot,
2433
2474
  moduleFilePath,
2434
2475
  read,
2435
- remoteHost = process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST
2476
+ remoteHost = process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST,
2477
+ allowCreateFiles = false
2436
2478
  }) {
2437
2479
  const actions = [];
2438
2480
  /**
@@ -2481,6 +2523,26 @@ async function createValCodeActions({
2481
2523
  });
2482
2524
  continue;
2483
2525
  }
2526
+ if (isWorkspaceEditFix(fix)) {
2527
+ if (offered.has(`${data.sourcePath}|${fix}`)) {
2528
+ continue;
2529
+ }
2530
+ const action = createExtractEntryAction({
2531
+ document,
2532
+ sourcePath: data.sourcePath,
2533
+ content,
2534
+ valRoot,
2535
+ moduleFilePath,
2536
+ read,
2537
+ allowCreateFiles
2538
+ });
2539
+ if (!action) {
2540
+ continue;
2541
+ }
2542
+ offered.add(`${data.sourcePath}|${fix}`);
2543
+ actions.push(action);
2544
+ continue;
2545
+ }
2484
2546
  if (!isLocalFix(fix)) {
2485
2547
  continue;
2486
2548
  }
@@ -2523,6 +2585,131 @@ async function createValCodeActions({
2523
2585
  }
2524
2586
  return actions;
2525
2587
  }
2588
+
2589
+ /**
2590
+ * The quick fix for `jsonValues:extract-entry`: move an entry that was written
2591
+ * inline in the `.val.ts` into its own `*.val.json`.
2592
+ *
2593
+ * Not a patch, and so not `computeFixEdit`. Two files change at once — a
2594
+ * `*.val.json` is created with the entry's content, and the `.val.ts` has the
2595
+ * inline value replaced by `c.json(() => import("./..."))` — and a `Patch` can
2596
+ * only edit one `.val.ts` and cannot create anything. That is why the fix is
2597
+ * `extractJsonValuesEntry` in the server rather than a `createFixPatch` branch,
2598
+ * and why the editor had no fix to offer for it at all until now: the patch
2599
+ * pipeline produced nothing and the action was dropped.
2600
+ *
2601
+ * The plan comes from the same `planJsonValuesEntryExtraction` that
2602
+ * `val validate --fix` runs, so the two cannot write different files; only the
2603
+ * last step differs. Here it is a `WorkspaceEdit`, so the change goes through
2604
+ * the editor's undo stack, and — crucially — is computed against the buffer the
2605
+ * user is looking at rather than what is on disk.
2606
+ */
2607
+ function createExtractEntryAction({
2608
+ document,
2609
+ sourcePath,
2610
+ content,
2611
+ valRoot,
2612
+ moduleFilePath,
2613
+ read,
2614
+ allowCreateFiles
2615
+ }) {
2616
+ if (!moduleFilePath || !allowCreateFiles) {
2617
+ return undefined;
2618
+ }
2619
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(sourcePath);
2620
+ const parts = Internal.splitModulePath(modulePath);
2621
+ // Root-only by contract, exactly as the server's fix handler asserts: the
2622
+ // entry is a direct child of the module's root record/router.
2623
+ if (parts.length !== 1) {
2624
+ return undefined;
2625
+ }
2626
+ const entryKey = parts[0];
2627
+ const entryContent = entryContentOf(content.source, entryKey);
2628
+ if (entryContent === undefined) {
2629
+ return undefined;
2630
+ }
2631
+ const planned = planJsonValuesEntryExtraction({
2632
+ moduleFilePath,
2633
+ entryKey,
2634
+ content: entryContent,
2635
+ valTsPath: uriToPath(document.uri),
2636
+ // The editor's text, not the file's: an edit computed against stale text
2637
+ // has ranges that land in the wrong place.
2638
+ valTsText: document.getText()
2639
+ });
2640
+ if (result.isErr(planned)) {
2641
+ return undefined;
2642
+ }
2643
+ const absoluteJsonPath = path.join(valRoot, planned.value.jsonPath);
2644
+ // Same refusal as the CLI's, widened by one case: an unsaved buffer counts as
2645
+ // occupied too. Creating over either would destroy content that a `--fix` run
2646
+ // refuses to touch.
2647
+ if (read?.(absoluteJsonPath) !== undefined || fs.existsSync(absoluteJsonPath)) {
2648
+ return undefined;
2649
+ }
2650
+ const valTsEdit = minimalTextEdit(document.getText(), planned.value.valTsContent, document);
2651
+ if (!valTsEdit) {
2652
+ return undefined;
2653
+ }
2654
+ const jsonUri = pathToUri(absoluteJsonPath);
2655
+ return {
2656
+ title: FIX_TITLES["jsonValues:extract-entry"] ,
2657
+ kind: CodeActionKind.QuickFix,
2658
+ edit: {
2659
+ // `documentChanges`, not `changes`: a plain edit map cannot create a file,
2660
+ // and an edit addressed to a file that does not exist is an error rather
2661
+ // than a creation.
2662
+ documentChanges: [{
2663
+ kind: "create",
2664
+ uri: jsonUri
2665
+ }, {
2666
+ textDocument: {
2667
+ uri: jsonUri,
2668
+ version: null
2669
+ },
2670
+ edits: [{
2671
+ range: {
2672
+ start: {
2673
+ line: 0,
2674
+ character: 0
2675
+ },
2676
+ end: {
2677
+ line: 0,
2678
+ character: 0
2679
+ }
2680
+ },
2681
+ newText: planned.value.jsonContent
2682
+ }]
2683
+ }, {
2684
+ textDocument: {
2685
+ uri: document.uri,
2686
+ version: null
2687
+ },
2688
+ edits: [valTsEdit]
2689
+ }]
2690
+ }
2691
+ };
2692
+ }
2693
+
2694
+ /**
2695
+ * One entry's value out of a module's source.
2696
+ *
2697
+ * `Object.entries` rather than an index: the source is a `Source`, which has no
2698
+ * index signature, and reading a dynamic key off it is the one thing this needs
2699
+ * to do. Mirrors what the server's fix handler reads, so the editor extracts the
2700
+ * same bytes `val validate --fix` would.
2701
+ */
2702
+ function entryContentOf(source, entryKey) {
2703
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
2704
+ return undefined;
2705
+ }
2706
+ for (const [key, value] of Object.entries(source)) {
2707
+ if (key === entryKey) {
2708
+ return value;
2709
+ }
2710
+ }
2711
+ return undefined;
2712
+ }
2526
2713
  async function computeFixEdit({
2527
2714
  document,
2528
2715
  sourcePath,
@@ -3306,9 +3493,7 @@ function keysOfKeyOf(schema, snapshot) {
3306
3493
  * was — worse than not offering the fix.
3307
3494
  */
3308
3495
  function canRenameFiles(capabilities) {
3309
- const workspace = capabilities?.workspace;
3310
- const operations = workspace?.workspaceEdit?.resourceOperations;
3311
- return Array.isArray(operations) && operations.includes("rename");
3496
+ return supportsResourceOperation(capabilities, "rename");
3312
3497
  }
3313
3498
  async function createGalleryMembershipActions({
3314
3499
  document,
@@ -3781,6 +3966,7 @@ function createValLanguageServer(connection) {
3781
3966
  const documents = new TextDocuments(TextDocument);
3782
3967
  let canRegisterWatchers = false;
3783
3968
  let canRenameFiles$1 = false;
3969
+ let canCreateFiles$1 = false;
3784
3970
 
3785
3971
  /**
3786
3972
  * The editor's view of a file, by absolute path, or `undefined` when the file
@@ -3944,6 +4130,10 @@ function createValLanguageServer(connection) {
3944
4130
  // A RenameFile sent to a client that did not announce resourceOperations is
3945
4131
  // silently dropped, which would rewrite the path and leave the file behind.
3946
4132
  canRenameFiles$1 = canRenameFiles(params.capabilities);
4133
+ // Same reasoning for the extract-entry fix: it CREATES the `*.val.json` the
4134
+ // rewritten `.val.ts` imports, so a client that drops the create would be
4135
+ // left importing a file that is not there.
4136
+ canCreateFiles$1 = canCreateFiles(params.capabilities);
3947
4137
  const clientCapabilities = params.capabilities.experimental?.val ?? {};
3948
4138
 
3949
4139
  // Announce only what this version actually serves: a client hides UI for
@@ -4151,7 +4341,8 @@ function createValLanguageServer(connection) {
4151
4341
  content: result.content,
4152
4342
  valRoot: project.valRoot,
4153
4343
  moduleFilePath,
4154
- read: readOpenDocument
4344
+ read: readOpenDocument,
4345
+ allowCreateFiles: canCreateFiles$1
4155
4346
  })));
4156
4347
  return actions;
4157
4348
  } catch (e) {
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "lsp",
12
12
  "language-server"
13
13
  ],
14
- "version": "0.123.0",
14
+ "version": "0.123.3",
15
15
  "bin": {
16
16
  "val-language-server": "./bin.js"
17
17
  },
@@ -30,8 +30,8 @@
30
30
  "vscode-languageserver": "^10.1.0",
31
31
  "vscode-languageserver-textdocument": "^1.0.14",
32
32
  "@valbuild/core": "0.121.0",
33
- "@valbuild/server": "0.123.0",
34
- "@valbuild/shared": "0.123.0"
33
+ "@valbuild/server": "0.123.3",
34
+ "@valbuild/shared": "0.123.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/jest": "^30.0.0",