@valbuild/language-server 0.143.0 → 0.144.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.
@@ -7,7 +7,7 @@ import { TextDocuments, TextDocumentSyncKind, createConnection, ProposedFeatures
7
7
  import { TextDocument } from 'vscode-languageserver-textdocument';
8
8
  import crypto from 'crypto';
9
9
  import { createRequire } from 'node:module';
10
- import { fixHandlers, createService, analyzeValModule, createFixPatch, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, planJsonValuesEntryExtraction, extractVideoMetadataFromFile, extractImageMetadata, extractFileMetadata, findDefaultExport } from '@valbuild/server';
10
+ import { fixHandlers, createService, analyzeValModule, createFixPatch, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, patchSourceFile, getSettings, uploadRemoteFile, planJsonValuesEntryExtraction, extractVideoMetadataFromFile, extractImageMetadata, extractFileMetadata, findDefaultExport } from '@valbuild/server';
11
11
  import { resolveSchemaSourceFixes, SCHEMA_SOURCE_FIXES, getRoutesWithModulePaths } from '@valbuild/shared/internal';
12
12
  import { result } from '@valbuild/core/fp';
13
13
  import { JSONOps, applyPatch, deepClone } from '@valbuild/core/patch';
@@ -172,7 +172,7 @@ var packageJson = {
172
172
  "lsp",
173
173
  "language-server"
174
174
  ],
175
- version: "0.143.0",
175
+ version: "0.144.0",
176
176
  bin: {
177
177
  "val-language-server": "./bin.js"
178
178
  },
@@ -1717,8 +1717,8 @@ function longestResolvedPrefixRange(modulePath, modulePathMap) {
1717
1717
  /**
1718
1718
  * Gallery checks core emits unconditionally.
1719
1719
  *
1720
- * `RecordSchema.validate` attaches these to every `s.imageset()` / `s.fileset()`
1721
- * / `s.videoset()` module whether or not anything is actually wrong (see
1720
+ * `RecordSchema.validate` attaches these to every `s.imagedir()` / `s.filedir()`
1721
+ * / `s.videodir()` module whether or not anything is actually wrong (see
1722
1722
  * `packages/core/src/schema/record.ts`): they are placeholders asking someone to
1723
1723
  * go and look. `val validate` looks by running the matching fix handler, which
1724
1724
  * reports success when the directory is unique and every file is accounted for.
@@ -1828,7 +1828,7 @@ function dropDeferredPlaceholders(validation) {
1828
1828
  *
1829
1829
  * `ImageSchema.validate` reports this (`packages/core/src/schema/image.ts`:
1830
1830
  * "The gallery does not have an image at '…'"), and `VideoSchema.validate` the
1831
- * same for a field picked from an `s.videoset()` ("The set does not have a
1831
+ * same for a field picked from an `s.videodir()` ("The set does not have a
1832
1832
  * video at '…'"), but neither attaches a `ValidationFix`,
1833
1833
  * because the remedy is not a change to this module: either the gallery gains an
1834
1834
  * entry, or the file moves into the gallery's directory. Both are edits to
@@ -2016,6 +2016,144 @@ function toModuleFilePath(valRoot, uri) {
2016
2016
  return `/${relative.split(path.sep).join("/")}`;
2017
2017
  }
2018
2018
 
2019
+ const jsonOps = new JSONOps();
2020
+
2021
+ /**
2022
+ * Turning a fix patch into an edit when the value it fixes is NOT in the
2023
+ * `.val.ts` the diagnostic was reported on.
2024
+ *
2025
+ * A `.jsonValues()` record keeps each entry's value in its own `*.val.json`; the
2026
+ * `.val.ts` holds `c.json(() => import("./x.val.json"))` and nothing below it.
2027
+ * So a fix for, say, an `s.image()` inside an entry produces a patch whose path
2028
+ * starts at the module root (`["/jobb/student", "pageImage", "width"]`) but
2029
+ * whose target lives in another file entirely.
2030
+ *
2031
+ * Applying such a patch to the `.val.ts` cannot work: the walk reaches the
2032
+ * `c.json(...)` call expression and `TSOps` refuses it ("Expression must be a
2033
+ * literal"). That refusal is why an editor silently offered no quick fix at all
2034
+ * for anything inside an entry — `computeFixEdit` saw an error result and
2035
+ * dropped the action.
2036
+ *
2037
+ * The routing here is the same one the Studio's publish and `val validate --fix`
2038
+ * use — {@link classifyJsonValuesOp} to find the entry, {@link rebaseContentOp}
2039
+ * to drop the entry-key prefix — so all three write the same file the same way.
2040
+ * What is specific to the editor is only the last step: the result comes back as
2041
+ * a `TextEdit` against the entry's document rather than as a write to disk, so
2042
+ * the edit goes through the editor's undo stack and respects an unsaved buffer.
2043
+ */
2044
+
2045
+ function jsonEntryEditFor({
2046
+ patch,
2047
+ schema,
2048
+ moduleFilePath,
2049
+ valTsText,
2050
+ valRoot,
2051
+ read
2052
+ }) {
2053
+ if (!schema) {
2054
+ return undefined;
2055
+ }
2056
+ const entry = entryOpsOf(patch, schema);
2057
+ if (!entry) {
2058
+ return undefined;
2059
+ }
2060
+ const jsonFilePath = findJsonEntryFilePath(moduleFilePath, ts.createSourceFile(moduleFilePath, valTsText, ts.ScriptTarget.ES2020), entry.entryKey);
2061
+ if (!jsonFilePath) {
2062
+ return undefined;
2063
+ }
2064
+ const absolutePath = path.join(valRoot, jsonFilePath);
2065
+ const before = read?.(absolutePath) ?? readFile(absolutePath);
2066
+ if (before === undefined) {
2067
+ return undefined;
2068
+ }
2069
+ let content;
2070
+ try {
2071
+ content = JSON.parse(before);
2072
+ } catch {
2073
+ // A malformed entry file is already reported as a load error; offering an
2074
+ // edit computed from a guess at its contents would be worse than offering
2075
+ // nothing.
2076
+ return undefined;
2077
+ }
2078
+ for (const op of entry.ops) {
2079
+ // Root-only, like the rest of the `.jsonValues()` machinery, so the prefix
2080
+ // to drop is exactly the entry key.
2081
+ const rebased = rebaseContentOp(op, 1);
2082
+ if (result.isErr(rebased)) {
2083
+ return undefined;
2084
+ }
2085
+ const applied = applyPatch(deepClone(content), jsonOps, [rebased.value]);
2086
+ if (result.isErr(applied)) {
2087
+ return undefined;
2088
+ }
2089
+ content = applied.value;
2090
+ }
2091
+ // Re-serialising the whole value and diffing it is what keeps this honest:
2092
+ // the alternative is editing JSON text by hand, and the fix would then differ
2093
+ // from what the CLI writes. `minimalTextEdit` narrows the result to the region
2094
+ // that actually changed, so a two-space-indented file (what prettier and the
2095
+ // CLI both produce) yields an edit covering the changed properties and no
2096
+ // more. A file indented differently gets a correct but larger edit.
2097
+ const after = JSON.stringify(content, null, 2) + (before.endsWith("\n") ? "\n" : "");
2098
+ const uri = pathToUri(absolutePath);
2099
+ const edit = minimalTextEdit(before, after, TextDocument.create(uri, "json", 0, before));
2100
+ return edit && {
2101
+ uri,
2102
+ edit
2103
+ };
2104
+ }
2105
+
2106
+ /**
2107
+ * The ops of `patch`, if every one of them edits the content of ONE
2108
+ * `.jsonValues()` entry. `undefined` for anything else — an ordinary module, a
2109
+ * patch that also touches the `.val.ts`, or one that adds or removes a whole
2110
+ * entry — because those either belong on the `.val.ts` (the caller's existing
2111
+ * path) or need both files written at once, which a single `TextEdit` cannot do.
2112
+ */
2113
+ function entryOpsOf(patch, schema) {
2114
+ let entryKey;
2115
+ const ops = [];
2116
+ for (const op of patch) {
2117
+ const cls = classifyJsonValuesOp(schema, op.path);
2118
+ if (cls.kind !== "entry") {
2119
+ return undefined;
2120
+ }
2121
+ // Nested `.jsonValues()` is rejected as a module error long before a fix is
2122
+ // offered; treat it as out of scope rather than writing to a guessed file.
2123
+ if (cls.recordPath.length > 0 || cls.subPath.length === 0) {
2124
+ return undefined;
2125
+ }
2126
+ // `move` and `copy` carry a second path, and `rebaseContentOp` slices `from`
2127
+ // by the same prefix as `path` -- so an op reaching outside this entry would
2128
+ // have its source silently reinterpreted as a path inside this entry's file.
2129
+ if (op.op === "move" || op.op === "copy") {
2130
+ const fromCls = classifyJsonValuesOp(schema, op.from);
2131
+ if (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey) {
2132
+ return undefined;
2133
+ }
2134
+ }
2135
+ if (entryKey !== undefined && entryKey !== cls.entryKey) {
2136
+ return undefined;
2137
+ }
2138
+ entryKey = cls.entryKey;
2139
+ ops.push(op);
2140
+ }
2141
+ if (entryKey === undefined || ops.length === 0) {
2142
+ return undefined;
2143
+ }
2144
+ return {
2145
+ entryKey,
2146
+ ops
2147
+ };
2148
+ }
2149
+ function readFile(filePath) {
2150
+ try {
2151
+ return fs.readFileSync(filePath, "utf8");
2152
+ } catch {
2153
+ return undefined;
2154
+ }
2155
+ }
2156
+
2019
2157
  /**
2020
2158
  * `workspace/executeCommand` handlers.
2021
2159
  *
@@ -2290,9 +2428,11 @@ function createValCommands(deps) {
2290
2428
  // them in the same edit, so one undo takes back all of it.
2291
2429
  const unwritten = [];
2292
2430
  for (const other of outcome.otherModulePatches ?? []) {
2293
- const otherEdit = editForModulePatch(project.valRoot, other);
2294
- if (otherEdit) {
2295
- (changes[otherEdit.uri] ??= []).push(otherEdit.edit);
2431
+ const otherEdits = await editsForModulePatch(project, other);
2432
+ if (otherEdits) {
2433
+ for (const otherEdit of otherEdits) {
2434
+ (changes[otherEdit.uri] ??= []).push(otherEdit.edit);
2435
+ }
2296
2436
  } else {
2297
2437
  unwritten.push(other.moduleFilePath);
2298
2438
  }
@@ -2319,31 +2459,84 @@ function createValCommands(deps) {
2319
2459
  }
2320
2460
 
2321
2461
  /**
2322
- * The edit for a patch to another module than the one the command ran on,
2323
- * against the editor's buffer when the file is open and the disk otherwise.
2462
+ * The edits for a patch to another module than the one the command ran on,
2463
+ * against the editor's buffers when the files are open and the disk
2464
+ * otherwise. `undefined` when any part of it cannot be written: half a rename
2465
+ * is worse than a warning naming the module.
2466
+ *
2467
+ * A `.jsonValues()` entry's value is in its own `*.val.json`, not in the
2468
+ * `.val.ts` — so the ops are split by the entry they land in, and each
2469
+ * entry's go through `jsonEntryEditFor`, the same routing a quick fix inside
2470
+ * an entry uses. Only what is left is applied to the `.val.ts`.
2324
2471
  */
2325
- function editForModulePatch(valRoot, other) {
2326
- const uri = pathToUri(path.join(valRoot, other.moduleFilePath));
2327
- let otherDocument = deps.getDocument(uri);
2328
- if (!otherDocument) {
2329
- let text;
2330
- try {
2331
- text = fs.readFileSync(path.join(valRoot, other.moduleFilePath), "utf8");
2332
- } catch {
2472
+ async function editsForModulePatch(project, other) {
2473
+ const {
2474
+ valRoot
2475
+ } = project;
2476
+ const moduleFile = path.join(valRoot, other.moduleFilePath);
2477
+ const uri = pathToUri(moduleFile);
2478
+ const valTsText = readBuffer(moduleFile);
2479
+ if (valTsText === undefined) {
2480
+ return undefined;
2481
+ }
2482
+ const moduleResult = await project.getModule(other.moduleFilePath, {
2483
+ validate: false
2484
+ });
2485
+ const schema = moduleResult.status === "ok" ? moduleResult.content.schema : undefined;
2486
+ const valTsOps = [];
2487
+ const entryOps = new Map();
2488
+ for (const op of other.patch) {
2489
+ const cls = schema && classifyJsonValuesOp(schema, op.path);
2490
+ if (cls && cls.kind === "entry") {
2491
+ const ops = entryOps.get(cls.entryKey) ?? [];
2492
+ ops.push(op);
2493
+ entryOps.set(cls.entryKey, ops);
2494
+ } else {
2495
+ valTsOps.push(op);
2496
+ }
2497
+ }
2498
+ const edits = [];
2499
+ for (const ops of entryOps.values()) {
2500
+ const entryEdit = jsonEntryEditFor({
2501
+ patch: ops,
2502
+ schema,
2503
+ moduleFilePath: other.moduleFilePath,
2504
+ valTsText,
2505
+ valRoot,
2506
+ read: readBuffer
2507
+ });
2508
+ if (!entryEdit) {
2333
2509
  return undefined;
2334
2510
  }
2335
- otherDocument = TextDocument.create(uri, "typescript", 0, text);
2511
+ edits.push(entryEdit);
2512
+ }
2513
+ if (valTsOps.length > 0) {
2514
+ const patched = patchSourceFile(valTsText, valTsOps);
2515
+ if (result.isErr(patched)) {
2516
+ return undefined;
2517
+ }
2518
+ const edit = minimalTextEdit(valTsText, patched.value.text, TextDocument.create(uri, "typescript", 0, valTsText));
2519
+ if (edit) {
2520
+ edits.push({
2521
+ uri,
2522
+ edit
2523
+ });
2524
+ }
2525
+ }
2526
+ return edits;
2527
+ }
2528
+
2529
+ /** A file as the editor has it, falling back to disk. */
2530
+ function readBuffer(fsPath) {
2531
+ const open = deps.getDocument(pathToUri(fsPath));
2532
+ if (open) {
2533
+ return open.getText();
2336
2534
  }
2337
- const otherBefore = otherDocument.getText();
2338
- const otherPatched = patchSourceFile(otherBefore, other.patch);
2339
- if (result.isErr(otherPatched)) {
2535
+ try {
2536
+ return fs.readFileSync(fsPath, "utf8");
2537
+ } catch {
2340
2538
  return undefined;
2341
2539
  }
2342
- const otherEdit = minimalTextEdit(otherBefore, otherPatched.value.text, otherDocument);
2343
- return otherEdit && {
2344
- uri,
2345
- edit: otherEdit
2346
- };
2347
2540
  }
2348
2541
  return {
2349
2542
  async execute(command, args) {
@@ -2526,144 +2719,6 @@ function valModulesEntryText({
2526
2719
  return hasElements ? `,\n${indentation}${entry}` : `\n${indentation}${entry}\n${indentation.slice(2)}`;
2527
2720
  }
2528
2721
 
2529
- const jsonOps = new JSONOps();
2530
-
2531
- /**
2532
- * Turning a fix patch into an edit when the value it fixes is NOT in the
2533
- * `.val.ts` the diagnostic was reported on.
2534
- *
2535
- * A `.jsonValues()` record keeps each entry's value in its own `*.val.json`; the
2536
- * `.val.ts` holds `c.json(() => import("./x.val.json"))` and nothing below it.
2537
- * So a fix for, say, an `s.image()` inside an entry produces a patch whose path
2538
- * starts at the module root (`["/jobb/student", "pageImage", "width"]`) but
2539
- * whose target lives in another file entirely.
2540
- *
2541
- * Applying such a patch to the `.val.ts` cannot work: the walk reaches the
2542
- * `c.json(...)` call expression and `TSOps` refuses it ("Expression must be a
2543
- * literal"). That refusal is why an editor silently offered no quick fix at all
2544
- * for anything inside an entry — `computeFixEdit` saw an error result and
2545
- * dropped the action.
2546
- *
2547
- * The routing here is the same one the Studio's publish and `val validate --fix`
2548
- * use — {@link classifyJsonValuesOp} to find the entry, {@link rebaseContentOp}
2549
- * to drop the entry-key prefix — so all three write the same file the same way.
2550
- * What is specific to the editor is only the last step: the result comes back as
2551
- * a `TextEdit` against the entry's document rather than as a write to disk, so
2552
- * the edit goes through the editor's undo stack and respects an unsaved buffer.
2553
- */
2554
-
2555
- function jsonEntryEditFor({
2556
- patch,
2557
- schema,
2558
- moduleFilePath,
2559
- valTsText,
2560
- valRoot,
2561
- read
2562
- }) {
2563
- if (!schema) {
2564
- return undefined;
2565
- }
2566
- const entry = entryOpsOf(patch, schema);
2567
- if (!entry) {
2568
- return undefined;
2569
- }
2570
- const jsonFilePath = findJsonEntryFilePath(moduleFilePath, ts.createSourceFile(moduleFilePath, valTsText, ts.ScriptTarget.ES2020), entry.entryKey);
2571
- if (!jsonFilePath) {
2572
- return undefined;
2573
- }
2574
- const absolutePath = path.join(valRoot, jsonFilePath);
2575
- const before = read?.(absolutePath) ?? readFile(absolutePath);
2576
- if (before === undefined) {
2577
- return undefined;
2578
- }
2579
- let content;
2580
- try {
2581
- content = JSON.parse(before);
2582
- } catch {
2583
- // A malformed entry file is already reported as a load error; offering an
2584
- // edit computed from a guess at its contents would be worse than offering
2585
- // nothing.
2586
- return undefined;
2587
- }
2588
- for (const op of entry.ops) {
2589
- // Root-only, like the rest of the `.jsonValues()` machinery, so the prefix
2590
- // to drop is exactly the entry key.
2591
- const rebased = rebaseContentOp(op, 1);
2592
- if (result.isErr(rebased)) {
2593
- return undefined;
2594
- }
2595
- const applied = applyPatch(deepClone(content), jsonOps, [rebased.value]);
2596
- if (result.isErr(applied)) {
2597
- return undefined;
2598
- }
2599
- content = applied.value;
2600
- }
2601
- // Re-serialising the whole value and diffing it is what keeps this honest:
2602
- // the alternative is editing JSON text by hand, and the fix would then differ
2603
- // from what the CLI writes. `minimalTextEdit` narrows the result to the region
2604
- // that actually changed, so a two-space-indented file (what prettier and the
2605
- // CLI both produce) yields an edit covering the changed properties and no
2606
- // more. A file indented differently gets a correct but larger edit.
2607
- const after = JSON.stringify(content, null, 2) + (before.endsWith("\n") ? "\n" : "");
2608
- const uri = pathToUri(absolutePath);
2609
- const edit = minimalTextEdit(before, after, TextDocument.create(uri, "json", 0, before));
2610
- return edit && {
2611
- uri,
2612
- edit
2613
- };
2614
- }
2615
-
2616
- /**
2617
- * The ops of `patch`, if every one of them edits the content of ONE
2618
- * `.jsonValues()` entry. `undefined` for anything else — an ordinary module, a
2619
- * patch that also touches the `.val.ts`, or one that adds or removes a whole
2620
- * entry — because those either belong on the `.val.ts` (the caller's existing
2621
- * path) or need both files written at once, which a single `TextEdit` cannot do.
2622
- */
2623
- function entryOpsOf(patch, schema) {
2624
- let entryKey;
2625
- const ops = [];
2626
- for (const op of patch) {
2627
- const cls = classifyJsonValuesOp(schema, op.path);
2628
- if (cls.kind !== "entry") {
2629
- return undefined;
2630
- }
2631
- // Nested `.jsonValues()` is rejected as a module error long before a fix is
2632
- // offered; treat it as out of scope rather than writing to a guessed file.
2633
- if (cls.recordPath.length > 0 || cls.subPath.length === 0) {
2634
- return undefined;
2635
- }
2636
- // `move` and `copy` carry a second path, and `rebaseContentOp` slices `from`
2637
- // by the same prefix as `path` -- so an op reaching outside this entry would
2638
- // have its source silently reinterpreted as a path inside this entry's file.
2639
- if (op.op === "move" || op.op === "copy") {
2640
- const fromCls = classifyJsonValuesOp(schema, op.from);
2641
- if (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey) {
2642
- return undefined;
2643
- }
2644
- }
2645
- if (entryKey !== undefined && entryKey !== cls.entryKey) {
2646
- return undefined;
2647
- }
2648
- entryKey = cls.entryKey;
2649
- ops.push(op);
2650
- }
2651
- if (entryKey === undefined || ops.length === 0) {
2652
- return undefined;
2653
- }
2654
- return {
2655
- entryKey,
2656
- ops
2657
- };
2658
- }
2659
- function readFile(filePath) {
2660
- try {
2661
- return fs.readFileSync(filePath, "utf8");
2662
- } catch {
2663
- return undefined;
2664
- }
2665
- }
2666
-
2667
2722
  /**
2668
2723
  * What the client announced it will do with a `WorkspaceEdit`.
2669
2724
  *
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "lsp",
12
12
  "language-server"
13
13
  ],
14
- "version": "0.143.0",
14
+ "version": "0.144.0",
15
15
  "bin": {
16
16
  "val-language-server": "./bin.js"
17
17
  },
@@ -29,9 +29,9 @@
29
29
  "typescript": "^6.0.3",
30
30
  "vscode-languageserver": "^10.1.0",
31
31
  "vscode-languageserver-textdocument": "^1.0.14",
32
- "@valbuild/server": "0.143.0",
33
- "@valbuild/shared": "0.143.0",
34
- "@valbuild/core": "0.143.0"
32
+ "@valbuild/shared": "0.144.0",
33
+ "@valbuild/server": "0.144.0",
34
+ "@valbuild/core": "0.144.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/jest": "^30.0.0",