@valbuild/language-server 0.108.2 → 0.108.4
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
|
-
/**
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
[
|
|
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
|
-
|
|
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.
|
|
14
|
+
"version": "0.108.4",
|
|
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.106.0",
|
|
33
|
-
"@valbuild/
|
|
34
|
-
"@valbuild/
|
|
33
|
+
"@valbuild/server": "0.108.4",
|
|
34
|
+
"@valbuild/shared": "0.108.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/jest": "^30.0.0",
|