@valbuild/language-server 0.108.3 → 0.108.5

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,13 +7,23 @@ 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, remoteHost, }: {
10
+ export declare function createValCodeActions({ document, diagnostics, content, valRoot, moduleFilePath, read, remoteHost, }: {
11
11
  document: TextDocument;
12
12
  diagnostics: Diagnostic[];
13
13
  content: ValModuleContent;
14
14
  valRoot: string;
15
- /** Needed to offer the remote fixes, which run as commands. */
15
+ /**
16
+ * Needed to offer the remote fixes, which run as commands -- and to fix a
17
+ * `.jsonValues()` entry, whose content is in a file this document only
18
+ * references.
19
+ */
16
20
  moduleFilePath?: ModuleFilePath;
21
+ /**
22
+ * Reads an open buffer for a path. A fix that edits another file must see that
23
+ * file as the editor has it, or the edit's ranges are computed against text
24
+ * the editor is not showing.
25
+ */
26
+ read?: (fsPath: string) => string | undefined;
17
27
  remoteHost?: string;
18
28
  }): Promise<CodeAction[]>;
19
29
  /**
@@ -1,5 +1,6 @@
1
1
  import type { ModuleFilePath } from "@valbuild/core";
2
2
  export declare function isValModuleUri(uri: string): boolean;
3
+ export declare function isValJsonEntryUri(uri: string): boolean;
3
4
  /**
4
5
  * `file:///a/b.val.ts` -> `/a/b.val.ts`
5
6
  *
@@ -14,6 +14,7 @@ var crypto = require('crypto');
14
14
  var node_module = require('node:module');
15
15
  var internal = require('@valbuild/shared/internal');
16
16
  var fp = require('@valbuild/core/fp');
17
+ var patch = require('@valbuild/core/patch');
17
18
 
18
19
  function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
19
20
 
@@ -1148,10 +1149,46 @@ preferChild) {
1148
1149
  }
1149
1150
  }
1150
1151
  const range = getModulePathRange(modulePath, modulePathMap);
1151
- return range ? {
1152
- start: range.start,
1153
- end: range.end
1154
- } : FALLBACK_RANGE;
1152
+ if (range) {
1153
+ return {
1154
+ start: range.start,
1155
+ end: range.end
1156
+ };
1157
+ }
1158
+ return longestResolvedPrefixRange(modulePath, modulePathMap) ?? FALLBACK_RANGE;
1159
+ }
1160
+
1161
+ /**
1162
+ * The range of the longest prefix of `modulePath` this module's own text knows
1163
+ * about.
1164
+ *
1165
+ * `.jsonValues()` is why this exists. An entry's value lives in a separate
1166
+ * `*.val.json`, so the `.val.ts` contains the entry KEY and nothing below it,
1167
+ * and every error inside an entry resolved to nothing at all -- landing on
1168
+ * FALLBACK_RANGE, line 1. For a record with hundreds of entries that is hundreds
1169
+ * of diagnostics stacked on the first line, none of them naming an entry.
1170
+ *
1171
+ * The entry key is not where the offending value is -- that is in the other file
1172
+ * -- but it is the closest place in THIS file, and it is where the quick fix is
1173
+ * offered from.
1174
+ */
1175
+ function longestResolvedPrefixRange(modulePath, modulePathMap) {
1176
+ let segments;
1177
+ try {
1178
+ segments = core.Internal.splitModulePath(modulePath);
1179
+ } catch {
1180
+ return undefined;
1181
+ }
1182
+ for (let length = segments.length - 1; length > 0; length--) {
1183
+ const range = getModulePathRange(segments.slice(0, length).map(segment => JSON.stringify(segment)).join("."), modulePathMap);
1184
+ if (range) {
1185
+ return {
1186
+ start: range.start,
1187
+ end: range.end
1188
+ };
1189
+ }
1190
+ }
1191
+ return undefined;
1155
1192
  }
1156
1193
 
1157
1194
  /**
@@ -1794,9 +1831,19 @@ const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
1794
1831
 
1795
1832
  /** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
1796
1833
  const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
1834
+
1835
+ /**
1836
+ * Matches the `*.val.json` files that hold `.jsonValues()` entry content. Not a
1837
+ * Val module: nothing validates one on its own, but editing it changes what a
1838
+ * module validates to, so the server has to notice.
1839
+ */
1840
+ const VAL_JSON_ENTRY_RE = /\.val\.json$/;
1797
1841
  function isValModuleUri(uri) {
1798
1842
  return VAL_MODULE_RE.test(uri);
1799
1843
  }
1844
+ function isValJsonEntryUri(uri) {
1845
+ return VAL_JSON_ENTRY_RE.test(uri);
1846
+ }
1800
1847
 
1801
1848
  /**
1802
1849
  * `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
@@ -1862,6 +1909,144 @@ function toModuleFilePath(valRoot, uri) {
1862
1909
  return `/${relative.split(path__default["default"].sep).join("/")}`;
1863
1910
  }
1864
1911
 
1912
+ const jsonOps = new patch.JSONOps();
1913
+
1914
+ /**
1915
+ * Turning a fix patch into an edit when the value it fixes is NOT in the
1916
+ * `.val.ts` the diagnostic was reported on.
1917
+ *
1918
+ * A `.jsonValues()` record keeps each entry's value in its own `*.val.json`; the
1919
+ * `.val.ts` holds `c.json(() => import("./x.val.json"))` and nothing below it.
1920
+ * So a fix for, say, an `s.image()` inside an entry produces a patch whose path
1921
+ * starts at the module root (`["/jobb/student", "pageImage", "width"]`) but
1922
+ * whose target lives in another file entirely.
1923
+ *
1924
+ * Applying such a patch to the `.val.ts` cannot work: the walk reaches the
1925
+ * `c.json(...)` call expression and `TSOps` refuses it ("Expression must be a
1926
+ * literal"). That refusal is why an editor silently offered no quick fix at all
1927
+ * for anything inside an entry — `computeFixEdit` saw an error result and
1928
+ * dropped the action.
1929
+ *
1930
+ * The routing here is the same one the Studio's publish and `val validate --fix`
1931
+ * use — {@link classifyJsonValuesOp} to find the entry, {@link rebaseContentOp}
1932
+ * to drop the entry-key prefix — so all three write the same file the same way.
1933
+ * What is specific to the editor is only the last step: the result comes back as
1934
+ * a `TextEdit` against the entry's document rather than as a write to disk, so
1935
+ * the edit goes through the editor's undo stack and respects an unsaved buffer.
1936
+ */
1937
+
1938
+ function jsonEntryEditFor({
1939
+ patch: patch$1,
1940
+ schema,
1941
+ moduleFilePath,
1942
+ valTsText,
1943
+ valRoot,
1944
+ read
1945
+ }) {
1946
+ if (!schema) {
1947
+ return undefined;
1948
+ }
1949
+ const entry = entryOpsOf(patch$1, schema);
1950
+ if (!entry) {
1951
+ return undefined;
1952
+ }
1953
+ const jsonFilePath = server.findJsonEntryFilePath(moduleFilePath, ts__default["default"].createSourceFile(moduleFilePath, valTsText, ts__default["default"].ScriptTarget.ES2020), entry.entryKey);
1954
+ if (!jsonFilePath) {
1955
+ return undefined;
1956
+ }
1957
+ const absolutePath = path__default["default"].join(valRoot, jsonFilePath);
1958
+ const before = read?.(absolutePath) ?? readFile(absolutePath);
1959
+ if (before === undefined) {
1960
+ return undefined;
1961
+ }
1962
+ let content;
1963
+ try {
1964
+ content = JSON.parse(before);
1965
+ } catch {
1966
+ // A malformed entry file is already reported as a load error; offering an
1967
+ // edit computed from a guess at its contents would be worse than offering
1968
+ // nothing.
1969
+ return undefined;
1970
+ }
1971
+ for (const op of entry.ops) {
1972
+ // Root-only, like the rest of the `.jsonValues()` machinery, so the prefix
1973
+ // to drop is exactly the entry key.
1974
+ const rebased = server.rebaseContentOp(op, 1);
1975
+ if (fp.result.isErr(rebased)) {
1976
+ return undefined;
1977
+ }
1978
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps, [rebased.value]);
1979
+ if (fp.result.isErr(applied)) {
1980
+ return undefined;
1981
+ }
1982
+ content = applied.value;
1983
+ }
1984
+ // Re-serialising the whole value and diffing it is what keeps this honest:
1985
+ // the alternative is editing JSON text by hand, and the fix would then differ
1986
+ // from what the CLI writes. `minimalTextEdit` narrows the result to the region
1987
+ // that actually changed, so a two-space-indented file (what prettier and the
1988
+ // CLI both produce) yields an edit covering the changed properties and no
1989
+ // more. A file indented differently gets a correct but larger edit.
1990
+ const after = JSON.stringify(content, null, 2) + (before.endsWith("\n") ? "\n" : "");
1991
+ const uri = pathToUri(absolutePath);
1992
+ const edit = minimalTextEdit(before, after, vscodeLanguageserverTextdocument.TextDocument.create(uri, "json", 0, before));
1993
+ return edit && {
1994
+ uri,
1995
+ edit
1996
+ };
1997
+ }
1998
+
1999
+ /**
2000
+ * The ops of `patch`, if every one of them edits the content of ONE
2001
+ * `.jsonValues()` entry. `undefined` for anything else — an ordinary module, a
2002
+ * patch that also touches the `.val.ts`, or one that adds or removes a whole
2003
+ * entry — because those either belong on the `.val.ts` (the caller's existing
2004
+ * path) or need both files written at once, which a single `TextEdit` cannot do.
2005
+ */
2006
+ function entryOpsOf(patch, schema) {
2007
+ let entryKey;
2008
+ const ops = [];
2009
+ for (const op of patch) {
2010
+ const cls = server.classifyJsonValuesOp(schema, op.path);
2011
+ if (cls.kind !== "entry") {
2012
+ return undefined;
2013
+ }
2014
+ // Nested `.jsonValues()` is rejected as a module error long before a fix is
2015
+ // offered; treat it as out of scope rather than writing to a guessed file.
2016
+ if (cls.recordPath.length > 0 || cls.subPath.length === 0) {
2017
+ return undefined;
2018
+ }
2019
+ // `move` and `copy` carry a second path, and `rebaseContentOp` slices `from`
2020
+ // by the same prefix as `path` -- so an op reaching outside this entry would
2021
+ // have its source silently reinterpreted as a path inside this entry's file.
2022
+ if (op.op === "move" || op.op === "copy") {
2023
+ const fromCls = server.classifyJsonValuesOp(schema, op.from);
2024
+ if (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey) {
2025
+ return undefined;
2026
+ }
2027
+ }
2028
+ if (entryKey !== undefined && entryKey !== cls.entryKey) {
2029
+ return undefined;
2030
+ }
2031
+ entryKey = cls.entryKey;
2032
+ ops.push(op);
2033
+ }
2034
+ if (entryKey === undefined || ops.length === 0) {
2035
+ return undefined;
2036
+ }
2037
+ return {
2038
+ entryKey,
2039
+ ops
2040
+ };
2041
+ }
2042
+ function readFile(filePath) {
2043
+ try {
2044
+ return fs__default["default"].readFileSync(filePath, "utf8");
2045
+ } catch {
2046
+ return undefined;
2047
+ }
2048
+ }
2049
+
1865
2050
  /**
1866
2051
  * Quick fixes, built by running Val's own fix machinery.
1867
2052
  *
@@ -1919,6 +2104,7 @@ async function createValCodeActions({
1919
2104
  content,
1920
2105
  valRoot,
1921
2106
  moduleFilePath,
2107
+ read,
1922
2108
  remoteHost = process.env.VAL_REMOTE_HOST || core.DEFAULT_VAL_REMOTE_HOST
1923
2109
  }) {
1924
2110
  const actions = [];
@@ -1960,7 +2146,7 @@ async function createValCodeActions({
1960
2146
  if (!isLocalFix(fix)) {
1961
2147
  continue;
1962
2148
  }
1963
- const edit = await computeFixEdit({
2149
+ const fixEdit = await computeFixEdit({
1964
2150
  document,
1965
2151
  // A gallery check is reported on the entry but fixed against the record
1966
2152
  // that contains it; everything else fixes where it is reported.
@@ -1974,14 +2160,20 @@ async function createValCodeActions({
1974
2160
  },
1975
2161
  content,
1976
2162
  valRoot,
2163
+ moduleFilePath,
2164
+ read,
1977
2165
  remoteHost
1978
2166
  });
1979
- if (!edit) {
2167
+ if (!fixEdit) {
1980
2168
  continue;
1981
2169
  }
1982
- actions.push(vscodeLanguageserver.CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`, {
2170
+ actions.push(vscodeLanguageserver.CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`,
2171
+ // Not always `document.uri`: a fix inside a `.jsonValues()` entry
2172
+ // edits the entry's own `*.val.json`, which is a different file from
2173
+ // the one the diagnostic is reported on.
2174
+ {
1983
2175
  changes: {
1984
- [document.uri]: [edit]
2176
+ [fixEdit.uri]: [fixEdit.edit]
1985
2177
  }
1986
2178
  }, vscodeLanguageserver.CodeActionKind.QuickFix));
1987
2179
  }
@@ -1994,6 +2186,8 @@ async function computeFixEdit({
1994
2186
  validationError,
1995
2187
  content,
1996
2188
  valRoot,
2189
+ moduleFilePath,
2190
+ read,
1997
2191
  remoteHost
1998
2192
  }) {
1999
2193
  let fixed;
@@ -2012,11 +2206,32 @@ async function computeFixEdit({
2012
2206
  return undefined;
2013
2207
  }
2014
2208
  const before = document.getText();
2209
+ // The value may not be in this file at all. A `.jsonValues()` entry's content
2210
+ // lives in its own `*.val.json`, and applying the patch to the `.val.ts` would
2211
+ // walk into the `c.json(...)` thunk and fail -- which is exactly why no quick
2212
+ // fix was offered inside an entry.
2213
+ if (moduleFilePath) {
2214
+ const entryEdit = jsonEntryEditFor({
2215
+ patch: fixed.patch,
2216
+ schema: content.schema,
2217
+ moduleFilePath,
2218
+ valTsText: before,
2219
+ valRoot,
2220
+ read
2221
+ });
2222
+ if (entryEdit) {
2223
+ return entryEdit;
2224
+ }
2225
+ }
2015
2226
  const patched = server.patchSourceFile(before, fixed.patch);
2016
2227
  if (fp.result.isErr(patched)) {
2017
2228
  return undefined;
2018
2229
  }
2019
- return minimalTextEdit(before, patched.value.text, document);
2230
+ const edit = minimalTextEdit(before, patched.value.text, document);
2231
+ return edit && {
2232
+ uri: document.uri,
2233
+ edit
2234
+ };
2020
2235
  }
2021
2236
 
2022
2237
  /**
@@ -3468,6 +3683,12 @@ function createValLanguageServer(connection) {
3468
3683
  void connection.client.register(vscodeLanguageserver.DidChangeWatchedFilesNotification.type, {
3469
3684
  watchers: [{
3470
3685
  globPattern: "**/*.val.{ts,js}"
3686
+ },
3687
+ // A `.jsonValues()` entry's content is here, not in the `.val.ts`, so
3688
+ // this is the only thing that says an entry changed -- including when
3689
+ // the change is a quick fix the editor just applied.
3690
+ {
3691
+ globPattern: "**/*.val.json"
3471
3692
  }, {
3472
3693
  globPattern: "**/val.modules.{ts,js}"
3473
3694
  }, {
@@ -3575,7 +3796,8 @@ function createValLanguageServer(connection) {
3575
3796
  diagnostics: params.context.diagnostics,
3576
3797
  content: result.content,
3577
3798
  valRoot: project.valRoot,
3578
- moduleFilePath
3799
+ moduleFilePath,
3800
+ read: readOpenDocument
3579
3801
  })));
3580
3802
  return actions;
3581
3803
  } catch (e) {
@@ -3592,7 +3814,10 @@ function createValLanguageServer(connection) {
3592
3814
  documents.onDidChangeContent(({
3593
3815
  document
3594
3816
  }) => {
3595
- if (PROJECT_WIDE_FILE_RE.test(uriToPath(document.uri))) {
3817
+ if (PROJECT_WIDE_FILE_RE.test(uriToPath(document.uri)) ||
3818
+ // Same reason as in the watched-files handler: an open, unsaved entry file
3819
+ // is read through the editor overlay, but only a fresh Service sees it.
3820
+ isValJsonEntryUri(document.uri)) {
3596
3821
  // A project-wide fact changed, so every module's cached result is stale --
3597
3822
  // not just this file's. `invalidate()` with no argument clears the content
3598
3823
  // cache too, which the per-module fingerprint check would not.
@@ -3634,6 +3859,13 @@ function createValLanguageServer(connection) {
3634
3859
  projectWide = true;
3635
3860
  continue;
3636
3861
  }
3862
+ // An entry file has no module of its own to invalidate: which module reads
3863
+ // it is recorded in a `.val.ts` import specifier, and the parsed JSON is
3864
+ // cached inside the Service, which only a project-wide invalidate discards.
3865
+ if (isValJsonEntryUri(change.uri)) {
3866
+ projectWide = true;
3867
+ continue;
3868
+ }
3637
3869
  // A file appearing or vanishing under the files directory changes what a
3638
3870
  // media path may complete to.
3639
3871
  publicFiles?.invalidate();
@@ -3682,6 +3914,19 @@ function createValLanguageServer(connection) {
3682
3914
  uri: document.uri,
3683
3915
  diagnostics: []
3684
3916
  });
3917
+ // Closing an entry file REMOVES an overlay: whatever the buffer said stops
3918
+ // being what the module reads, and the modules that read it are still open,
3919
+ // still showing diagnostics computed from a buffer that no longer exists.
3920
+ // (Opening one needs no case of its own -- `TextDocuments` fires
3921
+ // onDidChangeContent for a didOpen too, which the handler above catches.)
3922
+ if (isValJsonEntryUri(document.uri)) {
3923
+ project?.invalidate();
3924
+ for (const open of documents.all()) {
3925
+ if (isValModuleUri(open.uri)) {
3926
+ scheduleValidation(open.uri);
3927
+ }
3928
+ }
3929
+ }
3685
3930
  });
3686
3931
  connection.onShutdown(() => {
3687
3932
  for (const timer of pending.values()) {
@@ -14,6 +14,7 @@ var crypto = require('crypto');
14
14
  var node_module = require('node:module');
15
15
  var internal = require('@valbuild/shared/internal');
16
16
  var fp = require('@valbuild/core/fp');
17
+ var patch = require('@valbuild/core/patch');
17
18
 
18
19
  function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
19
20
 
@@ -1148,10 +1149,46 @@ preferChild) {
1148
1149
  }
1149
1150
  }
1150
1151
  const range = getModulePathRange(modulePath, modulePathMap);
1151
- return range ? {
1152
- start: range.start,
1153
- end: range.end
1154
- } : FALLBACK_RANGE;
1152
+ if (range) {
1153
+ return {
1154
+ start: range.start,
1155
+ end: range.end
1156
+ };
1157
+ }
1158
+ return longestResolvedPrefixRange(modulePath, modulePathMap) ?? FALLBACK_RANGE;
1159
+ }
1160
+
1161
+ /**
1162
+ * The range of the longest prefix of `modulePath` this module's own text knows
1163
+ * about.
1164
+ *
1165
+ * `.jsonValues()` is why this exists. An entry's value lives in a separate
1166
+ * `*.val.json`, so the `.val.ts` contains the entry KEY and nothing below it,
1167
+ * and every error inside an entry resolved to nothing at all -- landing on
1168
+ * FALLBACK_RANGE, line 1. For a record with hundreds of entries that is hundreds
1169
+ * of diagnostics stacked on the first line, none of them naming an entry.
1170
+ *
1171
+ * The entry key is not where the offending value is -- that is in the other file
1172
+ * -- but it is the closest place in THIS file, and it is where the quick fix is
1173
+ * offered from.
1174
+ */
1175
+ function longestResolvedPrefixRange(modulePath, modulePathMap) {
1176
+ let segments;
1177
+ try {
1178
+ segments = core.Internal.splitModulePath(modulePath);
1179
+ } catch {
1180
+ return undefined;
1181
+ }
1182
+ for (let length = segments.length - 1; length > 0; length--) {
1183
+ const range = getModulePathRange(segments.slice(0, length).map(segment => JSON.stringify(segment)).join("."), modulePathMap);
1184
+ if (range) {
1185
+ return {
1186
+ start: range.start,
1187
+ end: range.end
1188
+ };
1189
+ }
1190
+ }
1191
+ return undefined;
1155
1192
  }
1156
1193
 
1157
1194
  /**
@@ -1794,9 +1831,19 @@ const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
1794
1831
 
1795
1832
  /** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
1796
1833
  const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
1834
+
1835
+ /**
1836
+ * Matches the `*.val.json` files that hold `.jsonValues()` entry content. Not a
1837
+ * Val module: nothing validates one on its own, but editing it changes what a
1838
+ * module validates to, so the server has to notice.
1839
+ */
1840
+ const VAL_JSON_ENTRY_RE = /\.val\.json$/;
1797
1841
  function isValModuleUri(uri) {
1798
1842
  return VAL_MODULE_RE.test(uri);
1799
1843
  }
1844
+ function isValJsonEntryUri(uri) {
1845
+ return VAL_JSON_ENTRY_RE.test(uri);
1846
+ }
1800
1847
 
1801
1848
  /**
1802
1849
  * `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
@@ -1862,6 +1909,144 @@ function toModuleFilePath(valRoot, uri) {
1862
1909
  return `/${relative.split(path__default["default"].sep).join("/")}`;
1863
1910
  }
1864
1911
 
1912
+ const jsonOps = new patch.JSONOps();
1913
+
1914
+ /**
1915
+ * Turning a fix patch into an edit when the value it fixes is NOT in the
1916
+ * `.val.ts` the diagnostic was reported on.
1917
+ *
1918
+ * A `.jsonValues()` record keeps each entry's value in its own `*.val.json`; the
1919
+ * `.val.ts` holds `c.json(() => import("./x.val.json"))` and nothing below it.
1920
+ * So a fix for, say, an `s.image()` inside an entry produces a patch whose path
1921
+ * starts at the module root (`["/jobb/student", "pageImage", "width"]`) but
1922
+ * whose target lives in another file entirely.
1923
+ *
1924
+ * Applying such a patch to the `.val.ts` cannot work: the walk reaches the
1925
+ * `c.json(...)` call expression and `TSOps` refuses it ("Expression must be a
1926
+ * literal"). That refusal is why an editor silently offered no quick fix at all
1927
+ * for anything inside an entry — `computeFixEdit` saw an error result and
1928
+ * dropped the action.
1929
+ *
1930
+ * The routing here is the same one the Studio's publish and `val validate --fix`
1931
+ * use — {@link classifyJsonValuesOp} to find the entry, {@link rebaseContentOp}
1932
+ * to drop the entry-key prefix — so all three write the same file the same way.
1933
+ * What is specific to the editor is only the last step: the result comes back as
1934
+ * a `TextEdit` against the entry's document rather than as a write to disk, so
1935
+ * the edit goes through the editor's undo stack and respects an unsaved buffer.
1936
+ */
1937
+
1938
+ function jsonEntryEditFor({
1939
+ patch: patch$1,
1940
+ schema,
1941
+ moduleFilePath,
1942
+ valTsText,
1943
+ valRoot,
1944
+ read
1945
+ }) {
1946
+ if (!schema) {
1947
+ return undefined;
1948
+ }
1949
+ const entry = entryOpsOf(patch$1, schema);
1950
+ if (!entry) {
1951
+ return undefined;
1952
+ }
1953
+ const jsonFilePath = server.findJsonEntryFilePath(moduleFilePath, ts__default["default"].createSourceFile(moduleFilePath, valTsText, ts__default["default"].ScriptTarget.ES2020), entry.entryKey);
1954
+ if (!jsonFilePath) {
1955
+ return undefined;
1956
+ }
1957
+ const absolutePath = path__default["default"].join(valRoot, jsonFilePath);
1958
+ const before = read?.(absolutePath) ?? readFile(absolutePath);
1959
+ if (before === undefined) {
1960
+ return undefined;
1961
+ }
1962
+ let content;
1963
+ try {
1964
+ content = JSON.parse(before);
1965
+ } catch {
1966
+ // A malformed entry file is already reported as a load error; offering an
1967
+ // edit computed from a guess at its contents would be worse than offering
1968
+ // nothing.
1969
+ return undefined;
1970
+ }
1971
+ for (const op of entry.ops) {
1972
+ // Root-only, like the rest of the `.jsonValues()` machinery, so the prefix
1973
+ // to drop is exactly the entry key.
1974
+ const rebased = server.rebaseContentOp(op, 1);
1975
+ if (fp.result.isErr(rebased)) {
1976
+ return undefined;
1977
+ }
1978
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps, [rebased.value]);
1979
+ if (fp.result.isErr(applied)) {
1980
+ return undefined;
1981
+ }
1982
+ content = applied.value;
1983
+ }
1984
+ // Re-serialising the whole value and diffing it is what keeps this honest:
1985
+ // the alternative is editing JSON text by hand, and the fix would then differ
1986
+ // from what the CLI writes. `minimalTextEdit` narrows the result to the region
1987
+ // that actually changed, so a two-space-indented file (what prettier and the
1988
+ // CLI both produce) yields an edit covering the changed properties and no
1989
+ // more. A file indented differently gets a correct but larger edit.
1990
+ const after = JSON.stringify(content, null, 2) + (before.endsWith("\n") ? "\n" : "");
1991
+ const uri = pathToUri(absolutePath);
1992
+ const edit = minimalTextEdit(before, after, vscodeLanguageserverTextdocument.TextDocument.create(uri, "json", 0, before));
1993
+ return edit && {
1994
+ uri,
1995
+ edit
1996
+ };
1997
+ }
1998
+
1999
+ /**
2000
+ * The ops of `patch`, if every one of them edits the content of ONE
2001
+ * `.jsonValues()` entry. `undefined` for anything else — an ordinary module, a
2002
+ * patch that also touches the `.val.ts`, or one that adds or removes a whole
2003
+ * entry — because those either belong on the `.val.ts` (the caller's existing
2004
+ * path) or need both files written at once, which a single `TextEdit` cannot do.
2005
+ */
2006
+ function entryOpsOf(patch, schema) {
2007
+ let entryKey;
2008
+ const ops = [];
2009
+ for (const op of patch) {
2010
+ const cls = server.classifyJsonValuesOp(schema, op.path);
2011
+ if (cls.kind !== "entry") {
2012
+ return undefined;
2013
+ }
2014
+ // Nested `.jsonValues()` is rejected as a module error long before a fix is
2015
+ // offered; treat it as out of scope rather than writing to a guessed file.
2016
+ if (cls.recordPath.length > 0 || cls.subPath.length === 0) {
2017
+ return undefined;
2018
+ }
2019
+ // `move` and `copy` carry a second path, and `rebaseContentOp` slices `from`
2020
+ // by the same prefix as `path` -- so an op reaching outside this entry would
2021
+ // have its source silently reinterpreted as a path inside this entry's file.
2022
+ if (op.op === "move" || op.op === "copy") {
2023
+ const fromCls = server.classifyJsonValuesOp(schema, op.from);
2024
+ if (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey) {
2025
+ return undefined;
2026
+ }
2027
+ }
2028
+ if (entryKey !== undefined && entryKey !== cls.entryKey) {
2029
+ return undefined;
2030
+ }
2031
+ entryKey = cls.entryKey;
2032
+ ops.push(op);
2033
+ }
2034
+ if (entryKey === undefined || ops.length === 0) {
2035
+ return undefined;
2036
+ }
2037
+ return {
2038
+ entryKey,
2039
+ ops
2040
+ };
2041
+ }
2042
+ function readFile(filePath) {
2043
+ try {
2044
+ return fs__default["default"].readFileSync(filePath, "utf8");
2045
+ } catch {
2046
+ return undefined;
2047
+ }
2048
+ }
2049
+
1865
2050
  /**
1866
2051
  * Quick fixes, built by running Val's own fix machinery.
1867
2052
  *
@@ -1919,6 +2104,7 @@ async function createValCodeActions({
1919
2104
  content,
1920
2105
  valRoot,
1921
2106
  moduleFilePath,
2107
+ read,
1922
2108
  remoteHost = process.env.VAL_REMOTE_HOST || core.DEFAULT_VAL_REMOTE_HOST
1923
2109
  }) {
1924
2110
  const actions = [];
@@ -1960,7 +2146,7 @@ async function createValCodeActions({
1960
2146
  if (!isLocalFix(fix)) {
1961
2147
  continue;
1962
2148
  }
1963
- const edit = await computeFixEdit({
2149
+ const fixEdit = await computeFixEdit({
1964
2150
  document,
1965
2151
  // A gallery check is reported on the entry but fixed against the record
1966
2152
  // that contains it; everything else fixes where it is reported.
@@ -1974,14 +2160,20 @@ async function createValCodeActions({
1974
2160
  },
1975
2161
  content,
1976
2162
  valRoot,
2163
+ moduleFilePath,
2164
+ read,
1977
2165
  remoteHost
1978
2166
  });
1979
- if (!edit) {
2167
+ if (!fixEdit) {
1980
2168
  continue;
1981
2169
  }
1982
- actions.push(vscodeLanguageserver.CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`, {
2170
+ actions.push(vscodeLanguageserver.CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`,
2171
+ // Not always `document.uri`: a fix inside a `.jsonValues()` entry
2172
+ // edits the entry's own `*.val.json`, which is a different file from
2173
+ // the one the diagnostic is reported on.
2174
+ {
1983
2175
  changes: {
1984
- [document.uri]: [edit]
2176
+ [fixEdit.uri]: [fixEdit.edit]
1985
2177
  }
1986
2178
  }, vscodeLanguageserver.CodeActionKind.QuickFix));
1987
2179
  }
@@ -1994,6 +2186,8 @@ async function computeFixEdit({
1994
2186
  validationError,
1995
2187
  content,
1996
2188
  valRoot,
2189
+ moduleFilePath,
2190
+ read,
1997
2191
  remoteHost
1998
2192
  }) {
1999
2193
  let fixed;
@@ -2012,11 +2206,32 @@ async function computeFixEdit({
2012
2206
  return undefined;
2013
2207
  }
2014
2208
  const before = document.getText();
2209
+ // The value may not be in this file at all. A `.jsonValues()` entry's content
2210
+ // lives in its own `*.val.json`, and applying the patch to the `.val.ts` would
2211
+ // walk into the `c.json(...)` thunk and fail -- which is exactly why no quick
2212
+ // fix was offered inside an entry.
2213
+ if (moduleFilePath) {
2214
+ const entryEdit = jsonEntryEditFor({
2215
+ patch: fixed.patch,
2216
+ schema: content.schema,
2217
+ moduleFilePath,
2218
+ valTsText: before,
2219
+ valRoot,
2220
+ read
2221
+ });
2222
+ if (entryEdit) {
2223
+ return entryEdit;
2224
+ }
2225
+ }
2015
2226
  const patched = server.patchSourceFile(before, fixed.patch);
2016
2227
  if (fp.result.isErr(patched)) {
2017
2228
  return undefined;
2018
2229
  }
2019
- return minimalTextEdit(before, patched.value.text, document);
2230
+ const edit = minimalTextEdit(before, patched.value.text, document);
2231
+ return edit && {
2232
+ uri: document.uri,
2233
+ edit
2234
+ };
2020
2235
  }
2021
2236
 
2022
2237
  /**
@@ -3468,6 +3683,12 @@ function createValLanguageServer(connection) {
3468
3683
  void connection.client.register(vscodeLanguageserver.DidChangeWatchedFilesNotification.type, {
3469
3684
  watchers: [{
3470
3685
  globPattern: "**/*.val.{ts,js}"
3686
+ },
3687
+ // A `.jsonValues()` entry's content is here, not in the `.val.ts`, so
3688
+ // this is the only thing that says an entry changed -- including when
3689
+ // the change is a quick fix the editor just applied.
3690
+ {
3691
+ globPattern: "**/*.val.json"
3471
3692
  }, {
3472
3693
  globPattern: "**/val.modules.{ts,js}"
3473
3694
  }, {
@@ -3575,7 +3796,8 @@ function createValLanguageServer(connection) {
3575
3796
  diagnostics: params.context.diagnostics,
3576
3797
  content: result.content,
3577
3798
  valRoot: project.valRoot,
3578
- moduleFilePath
3799
+ moduleFilePath,
3800
+ read: readOpenDocument
3579
3801
  })));
3580
3802
  return actions;
3581
3803
  } catch (e) {
@@ -3592,7 +3814,10 @@ function createValLanguageServer(connection) {
3592
3814
  documents.onDidChangeContent(({
3593
3815
  document
3594
3816
  }) => {
3595
- if (PROJECT_WIDE_FILE_RE.test(uriToPath(document.uri))) {
3817
+ if (PROJECT_WIDE_FILE_RE.test(uriToPath(document.uri)) ||
3818
+ // Same reason as in the watched-files handler: an open, unsaved entry file
3819
+ // is read through the editor overlay, but only a fresh Service sees it.
3820
+ isValJsonEntryUri(document.uri)) {
3596
3821
  // A project-wide fact changed, so every module's cached result is stale --
3597
3822
  // not just this file's. `invalidate()` with no argument clears the content
3598
3823
  // cache too, which the per-module fingerprint check would not.
@@ -3634,6 +3859,13 @@ function createValLanguageServer(connection) {
3634
3859
  projectWide = true;
3635
3860
  continue;
3636
3861
  }
3862
+ // An entry file has no module of its own to invalidate: which module reads
3863
+ // it is recorded in a `.val.ts` import specifier, and the parsed JSON is
3864
+ // cached inside the Service, which only a project-wide invalidate discards.
3865
+ if (isValJsonEntryUri(change.uri)) {
3866
+ projectWide = true;
3867
+ continue;
3868
+ }
3637
3869
  // A file appearing or vanishing under the files directory changes what a
3638
3870
  // media path may complete to.
3639
3871
  publicFiles?.invalidate();
@@ -3682,6 +3914,19 @@ function createValLanguageServer(connection) {
3682
3914
  uri: document.uri,
3683
3915
  diagnostics: []
3684
3916
  });
3917
+ // Closing an entry file REMOVES an overlay: whatever the buffer said stops
3918
+ // being what the module reads, and the modules that read it are still open,
3919
+ // still showing diagnostics computed from a buffer that no longer exists.
3920
+ // (Opening one needs no case of its own -- `TextDocuments` fires
3921
+ // onDidChangeContent for a didOpen too, which the handler above catches.)
3922
+ if (isValJsonEntryUri(document.uri)) {
3923
+ project?.invalidate();
3924
+ for (const open of documents.all()) {
3925
+ if (isValModuleUri(open.uri)) {
3926
+ scheduleValidation(open.uri);
3927
+ }
3928
+ }
3929
+ }
3685
3930
  });
3686
3931
  connection.onShutdown(() => {
3687
3932
  for (const timer of pending.values()) {
@@ -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, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, createFixPatch, patchSourceFile, getSettings, uploadRemoteFile, extractImageMetadata, extractFileMetadata } from '@valbuild/server';
5
+ import { fixHandlers, createService, analyzeValModule, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, createFixPatch, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, 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';
@@ -10,6 +10,7 @@ import crypto from 'crypto';
10
10
  import { createRequire } from 'node:module';
11
11
  import { resolveSchemaSourceFixes, getRoutesWithModulePaths } from '@valbuild/shared/internal';
12
12
  import { result } from '@valbuild/core/fp';
13
+ import { JSONOps, applyPatch, deepClone } from '@valbuild/core/patch';
13
14
 
14
15
  /**
15
16
  * The Val language server protocol.
@@ -1137,10 +1138,46 @@ preferChild) {
1137
1138
  }
1138
1139
  }
1139
1140
  const range = getModulePathRange(modulePath, modulePathMap);
1140
- return range ? {
1141
- start: range.start,
1142
- end: range.end
1143
- } : FALLBACK_RANGE;
1141
+ if (range) {
1142
+ return {
1143
+ start: range.start,
1144
+ end: range.end
1145
+ };
1146
+ }
1147
+ return longestResolvedPrefixRange(modulePath, modulePathMap) ?? FALLBACK_RANGE;
1148
+ }
1149
+
1150
+ /**
1151
+ * The range of the longest prefix of `modulePath` this module's own text knows
1152
+ * about.
1153
+ *
1154
+ * `.jsonValues()` is why this exists. An entry's value lives in a separate
1155
+ * `*.val.json`, so the `.val.ts` contains the entry KEY and nothing below it,
1156
+ * and every error inside an entry resolved to nothing at all -- landing on
1157
+ * FALLBACK_RANGE, line 1. For a record with hundreds of entries that is hundreds
1158
+ * of diagnostics stacked on the first line, none of them naming an entry.
1159
+ *
1160
+ * The entry key is not where the offending value is -- that is in the other file
1161
+ * -- but it is the closest place in THIS file, and it is where the quick fix is
1162
+ * offered from.
1163
+ */
1164
+ function longestResolvedPrefixRange(modulePath, modulePathMap) {
1165
+ let segments;
1166
+ try {
1167
+ segments = Internal.splitModulePath(modulePath);
1168
+ } catch {
1169
+ return undefined;
1170
+ }
1171
+ for (let length = segments.length - 1; length > 0; length--) {
1172
+ const range = getModulePathRange(segments.slice(0, length).map(segment => JSON.stringify(segment)).join("."), modulePathMap);
1173
+ if (range) {
1174
+ return {
1175
+ start: range.start,
1176
+ end: range.end
1177
+ };
1178
+ }
1179
+ }
1180
+ return undefined;
1144
1181
  }
1145
1182
 
1146
1183
  /**
@@ -1783,9 +1820,19 @@ const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
1783
1820
 
1784
1821
  /** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
1785
1822
  const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
1823
+
1824
+ /**
1825
+ * Matches the `*.val.json` files that hold `.jsonValues()` entry content. Not a
1826
+ * Val module: nothing validates one on its own, but editing it changes what a
1827
+ * module validates to, so the server has to notice.
1828
+ */
1829
+ const VAL_JSON_ENTRY_RE = /\.val\.json$/;
1786
1830
  function isValModuleUri(uri) {
1787
1831
  return VAL_MODULE_RE.test(uri);
1788
1832
  }
1833
+ function isValJsonEntryUri(uri) {
1834
+ return VAL_JSON_ENTRY_RE.test(uri);
1835
+ }
1789
1836
 
1790
1837
  /**
1791
1838
  * `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
@@ -1851,6 +1898,144 @@ function toModuleFilePath(valRoot, uri) {
1851
1898
  return `/${relative.split(path.sep).join("/")}`;
1852
1899
  }
1853
1900
 
1901
+ const jsonOps = new JSONOps();
1902
+
1903
+ /**
1904
+ * Turning a fix patch into an edit when the value it fixes is NOT in the
1905
+ * `.val.ts` the diagnostic was reported on.
1906
+ *
1907
+ * A `.jsonValues()` record keeps each entry's value in its own `*.val.json`; the
1908
+ * `.val.ts` holds `c.json(() => import("./x.val.json"))` and nothing below it.
1909
+ * So a fix for, say, an `s.image()` inside an entry produces a patch whose path
1910
+ * starts at the module root (`["/jobb/student", "pageImage", "width"]`) but
1911
+ * whose target lives in another file entirely.
1912
+ *
1913
+ * Applying such a patch to the `.val.ts` cannot work: the walk reaches the
1914
+ * `c.json(...)` call expression and `TSOps` refuses it ("Expression must be a
1915
+ * literal"). That refusal is why an editor silently offered no quick fix at all
1916
+ * for anything inside an entry — `computeFixEdit` saw an error result and
1917
+ * dropped the action.
1918
+ *
1919
+ * The routing here is the same one the Studio's publish and `val validate --fix`
1920
+ * use — {@link classifyJsonValuesOp} to find the entry, {@link rebaseContentOp}
1921
+ * to drop the entry-key prefix — so all three write the same file the same way.
1922
+ * What is specific to the editor is only the last step: the result comes back as
1923
+ * a `TextEdit` against the entry's document rather than as a write to disk, so
1924
+ * the edit goes through the editor's undo stack and respects an unsaved buffer.
1925
+ */
1926
+
1927
+ function jsonEntryEditFor({
1928
+ patch,
1929
+ schema,
1930
+ moduleFilePath,
1931
+ valTsText,
1932
+ valRoot,
1933
+ read
1934
+ }) {
1935
+ if (!schema) {
1936
+ return undefined;
1937
+ }
1938
+ const entry = entryOpsOf(patch, schema);
1939
+ if (!entry) {
1940
+ return undefined;
1941
+ }
1942
+ const jsonFilePath = findJsonEntryFilePath(moduleFilePath, ts.createSourceFile(moduleFilePath, valTsText, ts.ScriptTarget.ES2020), entry.entryKey);
1943
+ if (!jsonFilePath) {
1944
+ return undefined;
1945
+ }
1946
+ const absolutePath = path.join(valRoot, jsonFilePath);
1947
+ const before = read?.(absolutePath) ?? readFile(absolutePath);
1948
+ if (before === undefined) {
1949
+ return undefined;
1950
+ }
1951
+ let content;
1952
+ try {
1953
+ content = JSON.parse(before);
1954
+ } catch {
1955
+ // A malformed entry file is already reported as a load error; offering an
1956
+ // edit computed from a guess at its contents would be worse than offering
1957
+ // nothing.
1958
+ return undefined;
1959
+ }
1960
+ for (const op of entry.ops) {
1961
+ // Root-only, like the rest of the `.jsonValues()` machinery, so the prefix
1962
+ // to drop is exactly the entry key.
1963
+ const rebased = rebaseContentOp(op, 1);
1964
+ if (result.isErr(rebased)) {
1965
+ return undefined;
1966
+ }
1967
+ const applied = applyPatch(deepClone(content), jsonOps, [rebased.value]);
1968
+ if (result.isErr(applied)) {
1969
+ return undefined;
1970
+ }
1971
+ content = applied.value;
1972
+ }
1973
+ // Re-serialising the whole value and diffing it is what keeps this honest:
1974
+ // the alternative is editing JSON text by hand, and the fix would then differ
1975
+ // from what the CLI writes. `minimalTextEdit` narrows the result to the region
1976
+ // that actually changed, so a two-space-indented file (what prettier and the
1977
+ // CLI both produce) yields an edit covering the changed properties and no
1978
+ // more. A file indented differently gets a correct but larger edit.
1979
+ const after = JSON.stringify(content, null, 2) + (before.endsWith("\n") ? "\n" : "");
1980
+ const uri = pathToUri(absolutePath);
1981
+ const edit = minimalTextEdit(before, after, TextDocument.create(uri, "json", 0, before));
1982
+ return edit && {
1983
+ uri,
1984
+ edit
1985
+ };
1986
+ }
1987
+
1988
+ /**
1989
+ * The ops of `patch`, if every one of them edits the content of ONE
1990
+ * `.jsonValues()` entry. `undefined` for anything else — an ordinary module, a
1991
+ * patch that also touches the `.val.ts`, or one that adds or removes a whole
1992
+ * entry — because those either belong on the `.val.ts` (the caller's existing
1993
+ * path) or need both files written at once, which a single `TextEdit` cannot do.
1994
+ */
1995
+ function entryOpsOf(patch, schema) {
1996
+ let entryKey;
1997
+ const ops = [];
1998
+ for (const op of patch) {
1999
+ const cls = classifyJsonValuesOp(schema, op.path);
2000
+ if (cls.kind !== "entry") {
2001
+ return undefined;
2002
+ }
2003
+ // Nested `.jsonValues()` is rejected as a module error long before a fix is
2004
+ // offered; treat it as out of scope rather than writing to a guessed file.
2005
+ if (cls.recordPath.length > 0 || cls.subPath.length === 0) {
2006
+ return undefined;
2007
+ }
2008
+ // `move` and `copy` carry a second path, and `rebaseContentOp` slices `from`
2009
+ // by the same prefix as `path` -- so an op reaching outside this entry would
2010
+ // have its source silently reinterpreted as a path inside this entry's file.
2011
+ if (op.op === "move" || op.op === "copy") {
2012
+ const fromCls = classifyJsonValuesOp(schema, op.from);
2013
+ if (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey) {
2014
+ return undefined;
2015
+ }
2016
+ }
2017
+ if (entryKey !== undefined && entryKey !== cls.entryKey) {
2018
+ return undefined;
2019
+ }
2020
+ entryKey = cls.entryKey;
2021
+ ops.push(op);
2022
+ }
2023
+ if (entryKey === undefined || ops.length === 0) {
2024
+ return undefined;
2025
+ }
2026
+ return {
2027
+ entryKey,
2028
+ ops
2029
+ };
2030
+ }
2031
+ function readFile(filePath) {
2032
+ try {
2033
+ return fs.readFileSync(filePath, "utf8");
2034
+ } catch {
2035
+ return undefined;
2036
+ }
2037
+ }
2038
+
1854
2039
  /**
1855
2040
  * Quick fixes, built by running Val's own fix machinery.
1856
2041
  *
@@ -1908,6 +2093,7 @@ async function createValCodeActions({
1908
2093
  content,
1909
2094
  valRoot,
1910
2095
  moduleFilePath,
2096
+ read,
1911
2097
  remoteHost = process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST
1912
2098
  }) {
1913
2099
  const actions = [];
@@ -1949,7 +2135,7 @@ async function createValCodeActions({
1949
2135
  if (!isLocalFix(fix)) {
1950
2136
  continue;
1951
2137
  }
1952
- const edit = await computeFixEdit({
2138
+ const fixEdit = await computeFixEdit({
1953
2139
  document,
1954
2140
  // A gallery check is reported on the entry but fixed against the record
1955
2141
  // that contains it; everything else fixes where it is reported.
@@ -1963,14 +2149,20 @@ async function createValCodeActions({
1963
2149
  },
1964
2150
  content,
1965
2151
  valRoot,
2152
+ moduleFilePath,
2153
+ read,
1966
2154
  remoteHost
1967
2155
  });
1968
- if (!edit) {
2156
+ if (!fixEdit) {
1969
2157
  continue;
1970
2158
  }
1971
- actions.push(CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`, {
2159
+ actions.push(CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`,
2160
+ // Not always `document.uri`: a fix inside a `.jsonValues()` entry
2161
+ // edits the entry's own `*.val.json`, which is a different file from
2162
+ // the one the diagnostic is reported on.
2163
+ {
1972
2164
  changes: {
1973
- [document.uri]: [edit]
2165
+ [fixEdit.uri]: [fixEdit.edit]
1974
2166
  }
1975
2167
  }, CodeActionKind.QuickFix));
1976
2168
  }
@@ -1983,6 +2175,8 @@ async function computeFixEdit({
1983
2175
  validationError,
1984
2176
  content,
1985
2177
  valRoot,
2178
+ moduleFilePath,
2179
+ read,
1986
2180
  remoteHost
1987
2181
  }) {
1988
2182
  let fixed;
@@ -2001,11 +2195,32 @@ async function computeFixEdit({
2001
2195
  return undefined;
2002
2196
  }
2003
2197
  const before = document.getText();
2198
+ // The value may not be in this file at all. A `.jsonValues()` entry's content
2199
+ // lives in its own `*.val.json`, and applying the patch to the `.val.ts` would
2200
+ // walk into the `c.json(...)` thunk and fail -- which is exactly why no quick
2201
+ // fix was offered inside an entry.
2202
+ if (moduleFilePath) {
2203
+ const entryEdit = jsonEntryEditFor({
2204
+ patch: fixed.patch,
2205
+ schema: content.schema,
2206
+ moduleFilePath,
2207
+ valTsText: before,
2208
+ valRoot,
2209
+ read
2210
+ });
2211
+ if (entryEdit) {
2212
+ return entryEdit;
2213
+ }
2214
+ }
2004
2215
  const patched = patchSourceFile(before, fixed.patch);
2005
2216
  if (result.isErr(patched)) {
2006
2217
  return undefined;
2007
2218
  }
2008
- return minimalTextEdit(before, patched.value.text, document);
2219
+ const edit = minimalTextEdit(before, patched.value.text, document);
2220
+ return edit && {
2221
+ uri: document.uri,
2222
+ edit
2223
+ };
2009
2224
  }
2010
2225
 
2011
2226
  /**
@@ -3457,6 +3672,12 @@ function createValLanguageServer(connection) {
3457
3672
  void connection.client.register(DidChangeWatchedFilesNotification.type, {
3458
3673
  watchers: [{
3459
3674
  globPattern: "**/*.val.{ts,js}"
3675
+ },
3676
+ // A `.jsonValues()` entry's content is here, not in the `.val.ts`, so
3677
+ // this is the only thing that says an entry changed -- including when
3678
+ // the change is a quick fix the editor just applied.
3679
+ {
3680
+ globPattern: "**/*.val.json"
3460
3681
  }, {
3461
3682
  globPattern: "**/val.modules.{ts,js}"
3462
3683
  }, {
@@ -3564,7 +3785,8 @@ function createValLanguageServer(connection) {
3564
3785
  diagnostics: params.context.diagnostics,
3565
3786
  content: result.content,
3566
3787
  valRoot: project.valRoot,
3567
- moduleFilePath
3788
+ moduleFilePath,
3789
+ read: readOpenDocument
3568
3790
  })));
3569
3791
  return actions;
3570
3792
  } catch (e) {
@@ -3581,7 +3803,10 @@ function createValLanguageServer(connection) {
3581
3803
  documents.onDidChangeContent(({
3582
3804
  document
3583
3805
  }) => {
3584
- if (PROJECT_WIDE_FILE_RE.test(uriToPath(document.uri))) {
3806
+ if (PROJECT_WIDE_FILE_RE.test(uriToPath(document.uri)) ||
3807
+ // Same reason as in the watched-files handler: an open, unsaved entry file
3808
+ // is read through the editor overlay, but only a fresh Service sees it.
3809
+ isValJsonEntryUri(document.uri)) {
3585
3810
  // A project-wide fact changed, so every module's cached result is stale --
3586
3811
  // not just this file's. `invalidate()` with no argument clears the content
3587
3812
  // cache too, which the per-module fingerprint check would not.
@@ -3623,6 +3848,13 @@ function createValLanguageServer(connection) {
3623
3848
  projectWide = true;
3624
3849
  continue;
3625
3850
  }
3851
+ // An entry file has no module of its own to invalidate: which module reads
3852
+ // it is recorded in a `.val.ts` import specifier, and the parsed JSON is
3853
+ // cached inside the Service, which only a project-wide invalidate discards.
3854
+ if (isValJsonEntryUri(change.uri)) {
3855
+ projectWide = true;
3856
+ continue;
3857
+ }
3626
3858
  // A file appearing or vanishing under the files directory changes what a
3627
3859
  // media path may complete to.
3628
3860
  publicFiles?.invalidate();
@@ -3671,6 +3903,19 @@ function createValLanguageServer(connection) {
3671
3903
  uri: document.uri,
3672
3904
  diagnostics: []
3673
3905
  });
3906
+ // Closing an entry file REMOVES an overlay: whatever the buffer said stops
3907
+ // being what the module reads, and the modules that read it are still open,
3908
+ // still showing diagnostics computed from a buffer that no longer exists.
3909
+ // (Opening one needs no case of its own -- `TextDocuments` fires
3910
+ // onDidChangeContent for a didOpen too, which the handler above catches.)
3911
+ if (isValJsonEntryUri(document.uri)) {
3912
+ project?.invalidate();
3913
+ for (const open of documents.all()) {
3914
+ if (isValModuleUri(open.uri)) {
3915
+ scheduleValidation(open.uri);
3916
+ }
3917
+ }
3918
+ }
3674
3919
  });
3675
3920
  connection.onShutdown(() => {
3676
3921
  for (const timer of pending.values()) {
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "lsp",
12
12
  "language-server"
13
13
  ],
14
- "version": "0.108.3",
14
+ "version": "0.108.5",
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.108.3",
33
32
  "@valbuild/core": "0.106.0",
34
- "@valbuild/shared": "0.108.0"
33
+ "@valbuild/shared": "0.108.0",
34
+ "@valbuild/server": "0.108.5"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/jest": "^30.0.0",