@valbuild/language-server 0.143.1 → 0.144.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.
@@ -183,7 +183,7 @@ var packageJson = {
183
183
  "lsp",
184
184
  "language-server"
185
185
  ],
186
- version: "0.143.1",
186
+ version: "0.144.1",
187
187
  bin: {
188
188
  "val-language-server": "./bin.js"
189
189
  },
@@ -1728,8 +1728,8 @@ function longestResolvedPrefixRange(modulePath, modulePathMap) {
1728
1728
  /**
1729
1729
  * Gallery checks core emits unconditionally.
1730
1730
  *
1731
- * `RecordSchema.validate` attaches these to every `s.imageset()` / `s.fileset()`
1732
- * / `s.videoset()` module whether or not anything is actually wrong (see
1731
+ * `RecordSchema.validate` attaches these to every `s.imagedir()` / `s.filedir()`
1732
+ * / `s.videodir()` module whether or not anything is actually wrong (see
1733
1733
  * `packages/core/src/schema/record.ts`): they are placeholders asking someone to
1734
1734
  * go and look. `val validate` looks by running the matching fix handler, which
1735
1735
  * reports success when the directory is unique and every file is accounted for.
@@ -1839,7 +1839,7 @@ function dropDeferredPlaceholders(validation) {
1839
1839
  *
1840
1840
  * `ImageSchema.validate` reports this (`packages/core/src/schema/image.ts`:
1841
1841
  * "The gallery does not have an image at '…'"), and `VideoSchema.validate` the
1842
- * same for a field picked from an `s.videoset()` ("The set does not have a
1842
+ * same for a field picked from an `s.videodir()` ("The set does not have a
1843
1843
  * video at '…'"), but neither attaches a `ValidationFix`,
1844
1844
  * because the remedy is not a change to this module: either the gallery gains an
1845
1845
  * entry, or the file moves into the gallery's directory. Both are edits to
@@ -2027,6 +2027,144 @@ function toModuleFilePath(valRoot, uri) {
2027
2027
  return `/${relative.split(path__default["default"].sep).join("/")}`;
2028
2028
  }
2029
2029
 
2030
+ const jsonOps = new patch.JSONOps();
2031
+
2032
+ /**
2033
+ * Turning a fix patch into an edit when the value it fixes is NOT in the
2034
+ * `.val.ts` the diagnostic was reported on.
2035
+ *
2036
+ * A `.jsonValues()` record keeps each entry's value in its own `*.val.json`; the
2037
+ * `.val.ts` holds `c.json(() => import("./x.val.json"))` and nothing below it.
2038
+ * So a fix for, say, an `s.image()` inside an entry produces a patch whose path
2039
+ * starts at the module root (`["/jobb/student", "pageImage", "width"]`) but
2040
+ * whose target lives in another file entirely.
2041
+ *
2042
+ * Applying such a patch to the `.val.ts` cannot work: the walk reaches the
2043
+ * `c.json(...)` call expression and `TSOps` refuses it ("Expression must be a
2044
+ * literal"). That refusal is why an editor silently offered no quick fix at all
2045
+ * for anything inside an entry — `computeFixEdit` saw an error result and
2046
+ * dropped the action.
2047
+ *
2048
+ * The routing here is the same one the Studio's publish and `val validate --fix`
2049
+ * use — {@link classifyJsonValuesOp} to find the entry, {@link rebaseContentOp}
2050
+ * to drop the entry-key prefix — so all three write the same file the same way.
2051
+ * What is specific to the editor is only the last step: the result comes back as
2052
+ * a `TextEdit` against the entry's document rather than as a write to disk, so
2053
+ * the edit goes through the editor's undo stack and respects an unsaved buffer.
2054
+ */
2055
+
2056
+ function jsonEntryEditFor({
2057
+ patch: patch$1,
2058
+ schema,
2059
+ moduleFilePath,
2060
+ valTsText,
2061
+ valRoot,
2062
+ read
2063
+ }) {
2064
+ if (!schema) {
2065
+ return undefined;
2066
+ }
2067
+ const entry = entryOpsOf(patch$1, schema);
2068
+ if (!entry) {
2069
+ return undefined;
2070
+ }
2071
+ const jsonFilePath = server.findJsonEntryFilePath(moduleFilePath, ts__default["default"].createSourceFile(moduleFilePath, valTsText, ts__default["default"].ScriptTarget.ES2020), entry.entryKey);
2072
+ if (!jsonFilePath) {
2073
+ return undefined;
2074
+ }
2075
+ const absolutePath = path__default["default"].join(valRoot, jsonFilePath);
2076
+ const before = read?.(absolutePath) ?? readFile(absolutePath);
2077
+ if (before === undefined) {
2078
+ return undefined;
2079
+ }
2080
+ let content;
2081
+ try {
2082
+ content = JSON.parse(before);
2083
+ } catch {
2084
+ // A malformed entry file is already reported as a load error; offering an
2085
+ // edit computed from a guess at its contents would be worse than offering
2086
+ // nothing.
2087
+ return undefined;
2088
+ }
2089
+ for (const op of entry.ops) {
2090
+ // Root-only, like the rest of the `.jsonValues()` machinery, so the prefix
2091
+ // to drop is exactly the entry key.
2092
+ const rebased = server.rebaseContentOp(op, 1);
2093
+ if (fp.result.isErr(rebased)) {
2094
+ return undefined;
2095
+ }
2096
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps, [rebased.value]);
2097
+ if (fp.result.isErr(applied)) {
2098
+ return undefined;
2099
+ }
2100
+ content = applied.value;
2101
+ }
2102
+ // Re-serialising the whole value and diffing it is what keeps this honest:
2103
+ // the alternative is editing JSON text by hand, and the fix would then differ
2104
+ // from what the CLI writes. `minimalTextEdit` narrows the result to the region
2105
+ // that actually changed, so a two-space-indented file (what prettier and the
2106
+ // CLI both produce) yields an edit covering the changed properties and no
2107
+ // more. A file indented differently gets a correct but larger edit.
2108
+ const after = JSON.stringify(content, null, 2) + (before.endsWith("\n") ? "\n" : "");
2109
+ const uri = pathToUri(absolutePath);
2110
+ const edit = minimalTextEdit(before, after, vscodeLanguageserverTextdocument.TextDocument.create(uri, "json", 0, before));
2111
+ return edit && {
2112
+ uri,
2113
+ edit
2114
+ };
2115
+ }
2116
+
2117
+ /**
2118
+ * The ops of `patch`, if every one of them edits the content of ONE
2119
+ * `.jsonValues()` entry. `undefined` for anything else — an ordinary module, a
2120
+ * patch that also touches the `.val.ts`, or one that adds or removes a whole
2121
+ * entry — because those either belong on the `.val.ts` (the caller's existing
2122
+ * path) or need both files written at once, which a single `TextEdit` cannot do.
2123
+ */
2124
+ function entryOpsOf(patch, schema) {
2125
+ let entryKey;
2126
+ const ops = [];
2127
+ for (const op of patch) {
2128
+ const cls = server.classifyJsonValuesOp(schema, op.path);
2129
+ if (cls.kind !== "entry") {
2130
+ return undefined;
2131
+ }
2132
+ // Nested `.jsonValues()` is rejected as a module error long before a fix is
2133
+ // offered; treat it as out of scope rather than writing to a guessed file.
2134
+ if (cls.recordPath.length > 0 || cls.subPath.length === 0) {
2135
+ return undefined;
2136
+ }
2137
+ // `move` and `copy` carry a second path, and `rebaseContentOp` slices `from`
2138
+ // by the same prefix as `path` -- so an op reaching outside this entry would
2139
+ // have its source silently reinterpreted as a path inside this entry's file.
2140
+ if (op.op === "move" || op.op === "copy") {
2141
+ const fromCls = server.classifyJsonValuesOp(schema, op.from);
2142
+ if (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey) {
2143
+ return undefined;
2144
+ }
2145
+ }
2146
+ if (entryKey !== undefined && entryKey !== cls.entryKey) {
2147
+ return undefined;
2148
+ }
2149
+ entryKey = cls.entryKey;
2150
+ ops.push(op);
2151
+ }
2152
+ if (entryKey === undefined || ops.length === 0) {
2153
+ return undefined;
2154
+ }
2155
+ return {
2156
+ entryKey,
2157
+ ops
2158
+ };
2159
+ }
2160
+ function readFile(filePath) {
2161
+ try {
2162
+ return fs__default["default"].readFileSync(filePath, "utf8");
2163
+ } catch {
2164
+ return undefined;
2165
+ }
2166
+ }
2167
+
2030
2168
  /**
2031
2169
  * `workspace/executeCommand` handlers.
2032
2170
  *
@@ -2301,9 +2439,11 @@ function createValCommands(deps) {
2301
2439
  // them in the same edit, so one undo takes back all of it.
2302
2440
  const unwritten = [];
2303
2441
  for (const other of outcome.otherModulePatches ?? []) {
2304
- const otherEdit = editForModulePatch(project.valRoot, other);
2305
- if (otherEdit) {
2306
- (changes[otherEdit.uri] ??= []).push(otherEdit.edit);
2442
+ const otherEdits = await editsForModulePatch(project, other);
2443
+ if (otherEdits) {
2444
+ for (const otherEdit of otherEdits) {
2445
+ (changes[otherEdit.uri] ??= []).push(otherEdit.edit);
2446
+ }
2307
2447
  } else {
2308
2448
  unwritten.push(other.moduleFilePath);
2309
2449
  }
@@ -2330,31 +2470,84 @@ function createValCommands(deps) {
2330
2470
  }
2331
2471
 
2332
2472
  /**
2333
- * The edit for a patch to another module than the one the command ran on,
2334
- * against the editor's buffer when the file is open and the disk otherwise.
2473
+ * The edits for a patch to another module than the one the command ran on,
2474
+ * against the editor's buffers when the files are open and the disk
2475
+ * otherwise. `undefined` when any part of it cannot be written: half a rename
2476
+ * is worse than a warning naming the module.
2477
+ *
2478
+ * A `.jsonValues()` entry's value is in its own `*.val.json`, not in the
2479
+ * `.val.ts` — so the ops are split by the entry they land in, and each
2480
+ * entry's go through `jsonEntryEditFor`, the same routing a quick fix inside
2481
+ * an entry uses. Only what is left is applied to the `.val.ts`.
2335
2482
  */
2336
- function editForModulePatch(valRoot, other) {
2337
- const uri = pathToUri(path__default["default"].join(valRoot, other.moduleFilePath));
2338
- let otherDocument = deps.getDocument(uri);
2339
- if (!otherDocument) {
2340
- let text;
2341
- try {
2342
- text = fs__default["default"].readFileSync(path__default["default"].join(valRoot, other.moduleFilePath), "utf8");
2343
- } catch {
2483
+ async function editsForModulePatch(project, other) {
2484
+ const {
2485
+ valRoot
2486
+ } = project;
2487
+ const moduleFile = path__default["default"].join(valRoot, other.moduleFilePath);
2488
+ const uri = pathToUri(moduleFile);
2489
+ const valTsText = readBuffer(moduleFile);
2490
+ if (valTsText === undefined) {
2491
+ return undefined;
2492
+ }
2493
+ const moduleResult = await project.getModule(other.moduleFilePath, {
2494
+ validate: false
2495
+ });
2496
+ const schema = moduleResult.status === "ok" ? moduleResult.content.schema : undefined;
2497
+ const valTsOps = [];
2498
+ const entryOps = new Map();
2499
+ for (const op of other.patch) {
2500
+ const cls = schema && server.classifyJsonValuesOp(schema, op.path);
2501
+ if (cls && cls.kind === "entry") {
2502
+ const ops = entryOps.get(cls.entryKey) ?? [];
2503
+ ops.push(op);
2504
+ entryOps.set(cls.entryKey, ops);
2505
+ } else {
2506
+ valTsOps.push(op);
2507
+ }
2508
+ }
2509
+ const edits = [];
2510
+ for (const ops of entryOps.values()) {
2511
+ const entryEdit = jsonEntryEditFor({
2512
+ patch: ops,
2513
+ schema,
2514
+ moduleFilePath: other.moduleFilePath,
2515
+ valTsText,
2516
+ valRoot,
2517
+ read: readBuffer
2518
+ });
2519
+ if (!entryEdit) {
2344
2520
  return undefined;
2345
2521
  }
2346
- otherDocument = vscodeLanguageserverTextdocument.TextDocument.create(uri, "typescript", 0, text);
2522
+ edits.push(entryEdit);
2523
+ }
2524
+ if (valTsOps.length > 0) {
2525
+ const patched = server.patchSourceFile(valTsText, valTsOps);
2526
+ if (fp.result.isErr(patched)) {
2527
+ return undefined;
2528
+ }
2529
+ const edit = minimalTextEdit(valTsText, patched.value.text, vscodeLanguageserverTextdocument.TextDocument.create(uri, "typescript", 0, valTsText));
2530
+ if (edit) {
2531
+ edits.push({
2532
+ uri,
2533
+ edit
2534
+ });
2535
+ }
2536
+ }
2537
+ return edits;
2538
+ }
2539
+
2540
+ /** A file as the editor has it, falling back to disk. */
2541
+ function readBuffer(fsPath) {
2542
+ const open = deps.getDocument(pathToUri(fsPath));
2543
+ if (open) {
2544
+ return open.getText();
2347
2545
  }
2348
- const otherBefore = otherDocument.getText();
2349
- const otherPatched = server.patchSourceFile(otherBefore, other.patch);
2350
- if (fp.result.isErr(otherPatched)) {
2546
+ try {
2547
+ return fs__default["default"].readFileSync(fsPath, "utf8");
2548
+ } catch {
2351
2549
  return undefined;
2352
2550
  }
2353
- const otherEdit = minimalTextEdit(otherBefore, otherPatched.value.text, otherDocument);
2354
- return otherEdit && {
2355
- uri,
2356
- edit: otherEdit
2357
- };
2358
2551
  }
2359
2552
  return {
2360
2553
  async execute(command, args) {
@@ -2537,144 +2730,6 @@ function valModulesEntryText({
2537
2730
  return hasElements ? `,\n${indentation}${entry}` : `\n${indentation}${entry}\n${indentation.slice(2)}`;
2538
2731
  }
2539
2732
 
2540
- const jsonOps = new patch.JSONOps();
2541
-
2542
- /**
2543
- * Turning a fix patch into an edit when the value it fixes is NOT in the
2544
- * `.val.ts` the diagnostic was reported on.
2545
- *
2546
- * A `.jsonValues()` record keeps each entry's value in its own `*.val.json`; the
2547
- * `.val.ts` holds `c.json(() => import("./x.val.json"))` and nothing below it.
2548
- * So a fix for, say, an `s.image()` inside an entry produces a patch whose path
2549
- * starts at the module root (`["/jobb/student", "pageImage", "width"]`) but
2550
- * whose target lives in another file entirely.
2551
- *
2552
- * Applying such a patch to the `.val.ts` cannot work: the walk reaches the
2553
- * `c.json(...)` call expression and `TSOps` refuses it ("Expression must be a
2554
- * literal"). That refusal is why an editor silently offered no quick fix at all
2555
- * for anything inside an entry — `computeFixEdit` saw an error result and
2556
- * dropped the action.
2557
- *
2558
- * The routing here is the same one the Studio's publish and `val validate --fix`
2559
- * use — {@link classifyJsonValuesOp} to find the entry, {@link rebaseContentOp}
2560
- * to drop the entry-key prefix — so all three write the same file the same way.
2561
- * What is specific to the editor is only the last step: the result comes back as
2562
- * a `TextEdit` against the entry's document rather than as a write to disk, so
2563
- * the edit goes through the editor's undo stack and respects an unsaved buffer.
2564
- */
2565
-
2566
- function jsonEntryEditFor({
2567
- patch: patch$1,
2568
- schema,
2569
- moduleFilePath,
2570
- valTsText,
2571
- valRoot,
2572
- read
2573
- }) {
2574
- if (!schema) {
2575
- return undefined;
2576
- }
2577
- const entry = entryOpsOf(patch$1, schema);
2578
- if (!entry) {
2579
- return undefined;
2580
- }
2581
- const jsonFilePath = server.findJsonEntryFilePath(moduleFilePath, ts__default["default"].createSourceFile(moduleFilePath, valTsText, ts__default["default"].ScriptTarget.ES2020), entry.entryKey);
2582
- if (!jsonFilePath) {
2583
- return undefined;
2584
- }
2585
- const absolutePath = path__default["default"].join(valRoot, jsonFilePath);
2586
- const before = read?.(absolutePath) ?? readFile(absolutePath);
2587
- if (before === undefined) {
2588
- return undefined;
2589
- }
2590
- let content;
2591
- try {
2592
- content = JSON.parse(before);
2593
- } catch {
2594
- // A malformed entry file is already reported as a load error; offering an
2595
- // edit computed from a guess at its contents would be worse than offering
2596
- // nothing.
2597
- return undefined;
2598
- }
2599
- for (const op of entry.ops) {
2600
- // Root-only, like the rest of the `.jsonValues()` machinery, so the prefix
2601
- // to drop is exactly the entry key.
2602
- const rebased = server.rebaseContentOp(op, 1);
2603
- if (fp.result.isErr(rebased)) {
2604
- return undefined;
2605
- }
2606
- const applied = patch.applyPatch(patch.deepClone(content), jsonOps, [rebased.value]);
2607
- if (fp.result.isErr(applied)) {
2608
- return undefined;
2609
- }
2610
- content = applied.value;
2611
- }
2612
- // Re-serialising the whole value and diffing it is what keeps this honest:
2613
- // the alternative is editing JSON text by hand, and the fix would then differ
2614
- // from what the CLI writes. `minimalTextEdit` narrows the result to the region
2615
- // that actually changed, so a two-space-indented file (what prettier and the
2616
- // CLI both produce) yields an edit covering the changed properties and no
2617
- // more. A file indented differently gets a correct but larger edit.
2618
- const after = JSON.stringify(content, null, 2) + (before.endsWith("\n") ? "\n" : "");
2619
- const uri = pathToUri(absolutePath);
2620
- const edit = minimalTextEdit(before, after, vscodeLanguageserverTextdocument.TextDocument.create(uri, "json", 0, before));
2621
- return edit && {
2622
- uri,
2623
- edit
2624
- };
2625
- }
2626
-
2627
- /**
2628
- * The ops of `patch`, if every one of them edits the content of ONE
2629
- * `.jsonValues()` entry. `undefined` for anything else — an ordinary module, a
2630
- * patch that also touches the `.val.ts`, or one that adds or removes a whole
2631
- * entry — because those either belong on the `.val.ts` (the caller's existing
2632
- * path) or need both files written at once, which a single `TextEdit` cannot do.
2633
- */
2634
- function entryOpsOf(patch, schema) {
2635
- let entryKey;
2636
- const ops = [];
2637
- for (const op of patch) {
2638
- const cls = server.classifyJsonValuesOp(schema, op.path);
2639
- if (cls.kind !== "entry") {
2640
- return undefined;
2641
- }
2642
- // Nested `.jsonValues()` is rejected as a module error long before a fix is
2643
- // offered; treat it as out of scope rather than writing to a guessed file.
2644
- if (cls.recordPath.length > 0 || cls.subPath.length === 0) {
2645
- return undefined;
2646
- }
2647
- // `move` and `copy` carry a second path, and `rebaseContentOp` slices `from`
2648
- // by the same prefix as `path` -- so an op reaching outside this entry would
2649
- // have its source silently reinterpreted as a path inside this entry's file.
2650
- if (op.op === "move" || op.op === "copy") {
2651
- const fromCls = server.classifyJsonValuesOp(schema, op.from);
2652
- if (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey) {
2653
- return undefined;
2654
- }
2655
- }
2656
- if (entryKey !== undefined && entryKey !== cls.entryKey) {
2657
- return undefined;
2658
- }
2659
- entryKey = cls.entryKey;
2660
- ops.push(op);
2661
- }
2662
- if (entryKey === undefined || ops.length === 0) {
2663
- return undefined;
2664
- }
2665
- return {
2666
- entryKey,
2667
- ops
2668
- };
2669
- }
2670
- function readFile(filePath) {
2671
- try {
2672
- return fs__default["default"].readFileSync(filePath, "utf8");
2673
- } catch {
2674
- return undefined;
2675
- }
2676
- }
2677
-
2678
2733
  /**
2679
2734
  * What the client announced it will do with a `WorkspaceEdit`.
2680
2735
  *