@valbuild/language-server 0.101.1 → 0.103.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +367 -0
- package/dist/declarations/src/ValProject.d.ts +39 -2
- package/dist/declarations/src/codeActions.d.ts +57 -8
- package/dist/declarations/src/commands.d.ts +63 -0
- package/dist/declarations/src/completionContext.d.ts +25 -40
- package/dist/declarations/src/completions.d.ts +12 -8
- package/dist/declarations/src/diagnostics.d.ts +107 -3
- package/dist/declarations/src/galleryFixes.d.ts +46 -0
- package/dist/declarations/src/index.d.ts +7 -3
- package/dist/declarations/src/textEdit.d.ts +10 -0
- package/dist/declarations/src/valModulesRegistry.d.ts +35 -0
- package/dist/valbuild-language-server.cjs.dev.js +1780 -513
- package/dist/valbuild-language-server.cjs.prod.js +1780 -513
- package/dist/valbuild-language-server.esm.js +1761 -517
- package/package.json +6 -5
|
@@ -272,6 +272,25 @@ function createEditorFsHost(open) {
|
|
|
272
272
|
|
|
273
273
|
/** What `Service.get` returns; re-declared to avoid depending on an internal type. */
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* A remote that refuses to do anything.
|
|
277
|
+
*
|
|
278
|
+
* The handlers that need a remote (upload) are not reachable through
|
|
279
|
+
* {@link ValProject.runFixHandler}, which reports rather than applies. Passing a
|
|
280
|
+
* refusing stub keeps the context type honest instead of asserting the field
|
|
281
|
+
* away, and turns any future miswiring into a message rather than a crash.
|
|
282
|
+
*/
|
|
283
|
+
const reportOnlyRemote = {
|
|
284
|
+
remoteHost: "",
|
|
285
|
+
getSettings: () => Promise.resolve({
|
|
286
|
+
success: false,
|
|
287
|
+
message: "The language server does not upload while reporting."
|
|
288
|
+
}),
|
|
289
|
+
uploadFile: () => Promise.resolve({
|
|
290
|
+
success: false,
|
|
291
|
+
error: "The language server does not upload while reporting."
|
|
292
|
+
})
|
|
293
|
+
};
|
|
275
294
|
const DEFAULT_OPTIONS = {
|
|
276
295
|
validate: true
|
|
277
296
|
};
|
|
@@ -480,7 +499,7 @@ function createValProject({
|
|
|
480
499
|
snapshot
|
|
481
500
|
};
|
|
482
501
|
}
|
|
483
|
-
|
|
502
|
+
const project = {
|
|
484
503
|
valRoot,
|
|
485
504
|
async getModule(moduleFilePath, options = DEFAULT_OPTIONS) {
|
|
486
505
|
// Cache on the module's own content as seen by the editor. This covers the
|
|
@@ -526,6 +545,50 @@ function createValProject({
|
|
|
526
545
|
};
|
|
527
546
|
},
|
|
528
547
|
listModuleFilePaths: () => findValModuleFilePaths(valRoot),
|
|
548
|
+
async runFixHandler({
|
|
549
|
+
moduleFilePath,
|
|
550
|
+
sourcePath,
|
|
551
|
+
validationError,
|
|
552
|
+
fix = false,
|
|
553
|
+
remote = reportOnlyRemote,
|
|
554
|
+
project: projectName,
|
|
555
|
+
remoteFiles = {}
|
|
556
|
+
}) {
|
|
557
|
+
const handlerName = validationError.fixes?.find(fix => server.fixHandlers[fix] !== undefined);
|
|
558
|
+
if (!handlerName) {
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
const resolved = await getService();
|
|
562
|
+
if (resolved.status === "error") {
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
const moduleResult = await project.getModule(moduleFilePath, {
|
|
566
|
+
validate: false
|
|
567
|
+
});
|
|
568
|
+
if (moduleResult.status === "error") {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
return server.fixHandlers[handlerName]({
|
|
572
|
+
sourcePath,
|
|
573
|
+
validationError,
|
|
574
|
+
valModule: moduleResult.content,
|
|
575
|
+
projectRoot: valRoot,
|
|
576
|
+
// Off by default: applying a fix unasked would write to disk behind the
|
|
577
|
+
// editor's back, and an accepted quick fix travels as a WorkspaceEdit.
|
|
578
|
+
// The remote fixes are the exception -- an upload is not expressible as
|
|
579
|
+
// an edit -- and pass `fix: true` deliberately.
|
|
580
|
+
fix,
|
|
581
|
+
service: resolved.service,
|
|
582
|
+
valFiles: findValModuleFilePaths(valRoot).map(p => p.slice(1)),
|
|
583
|
+
moduleFilePath,
|
|
584
|
+
file: moduleFilePath.slice(1),
|
|
585
|
+
fs: host,
|
|
586
|
+
remoteFiles,
|
|
587
|
+
remoteFilesCounter: 0,
|
|
588
|
+
remote,
|
|
589
|
+
project: projectName
|
|
590
|
+
});
|
|
591
|
+
},
|
|
529
592
|
getSnapshot() {
|
|
530
593
|
// `snapshot` is one shared object handed to every caller, so a second
|
|
531
594
|
// concurrent build must not be allowed to return it half-filled: the
|
|
@@ -575,6 +638,7 @@ function createValProject({
|
|
|
575
638
|
cache.clear();
|
|
576
639
|
}
|
|
577
640
|
};
|
|
641
|
+
return project;
|
|
578
642
|
}
|
|
579
643
|
|
|
580
644
|
/**
|
|
@@ -729,9 +793,6 @@ function traverse(node, sourceFile) {
|
|
|
729
793
|
if (ts__default["default"].isArrayLiteralExpression(node)) {
|
|
730
794
|
return traverseArrayLiteral(node, sourceFile);
|
|
731
795
|
}
|
|
732
|
-
if (ts__default["default"].isCallExpression(node)) {
|
|
733
|
-
return traverseCallExpression(node, sourceFile);
|
|
734
|
-
}
|
|
735
796
|
}
|
|
736
797
|
|
|
737
798
|
/**
|
|
@@ -739,7 +800,7 @@ function traverse(node, sourceFile) {
|
|
|
739
800
|
*
|
|
740
801
|
* NOTE: do not compute the start as `end.character - node.getWidth()`. That
|
|
741
802
|
* identity only holds while the node stays on a single line - for a multi-line
|
|
742
|
-
* node (an object inside an array, a
|
|
803
|
+
* node (an object inside an array, a multi-line media object, ...) it
|
|
743
804
|
* reports the *closing* line and a negative character. `getStart(sourceFile)`
|
|
744
805
|
* needs no parent pointers as long as the source file is passed explicitly,
|
|
745
806
|
* which is why it is safe here.
|
|
@@ -750,44 +811,6 @@ function rangeOfNode(node, sourceFile) {
|
|
|
750
811
|
end: sourceFile.getLineAndCharacterOfPosition(node.getEnd())
|
|
751
812
|
};
|
|
752
813
|
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* `c.image(...)` / `c.file(...)` expose three addressable paths: the call itself
|
|
756
|
-
* (`val`), the reference argument (`_ref`) and the metadata argument
|
|
757
|
-
* (`metadata`). Validation errors about a missing file point at `_ref`, and
|
|
758
|
-
* errors about metadata point at `metadata`, so both need their own range.
|
|
759
|
-
*/
|
|
760
|
-
function traverseCallExpression(node, sourceFile) {
|
|
761
|
-
if (!ts__default["default"].isPropertyAccessExpression(node.expression)) {
|
|
762
|
-
return undefined;
|
|
763
|
-
}
|
|
764
|
-
const isValFileConstructor = node.expression.expression.getText(sourceFile) === "c" && (node.expression.name.getText(sourceFile) === "file" || node.expression.name.getText(sourceFile) === "image");
|
|
765
|
-
if (!isValFileConstructor || !node.arguments[0]) {
|
|
766
|
-
return undefined;
|
|
767
|
-
}
|
|
768
|
-
const val = {
|
|
769
|
-
children: {},
|
|
770
|
-
...rangeOfNode(node, sourceFile)
|
|
771
|
-
};
|
|
772
|
-
const _ref = {
|
|
773
|
-
children: {},
|
|
774
|
-
...rangeOfNode(node.arguments[0], sourceFile)
|
|
775
|
-
};
|
|
776
|
-
if (!node.arguments[1]) {
|
|
777
|
-
return {
|
|
778
|
-
val,
|
|
779
|
-
_ref
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
return {
|
|
783
|
-
val,
|
|
784
|
-
_ref,
|
|
785
|
-
metadata: {
|
|
786
|
-
children: {},
|
|
787
|
-
...rangeOfNode(node.arguments[1], sourceFile)
|
|
788
|
-
}
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
814
|
function traverseArrayLiteral(node, sourceFile) {
|
|
792
815
|
const map = {};
|
|
793
816
|
node.elements.forEach((element, index) => {
|
|
@@ -854,7 +877,13 @@ const VAL_DIAGNOSTIC_CODES = [/** Content does not satisfy the schema. */
|
|
|
854
877
|
"val/schema", /** The module could not be evaluated at all. */
|
|
855
878
|
"val/fatal", /** A referenced image or file is not on disk. */
|
|
856
879
|
"val/file-not-found", /** The module is not registered in `val.modules`, so Val will not serve it. */
|
|
857
|
-
"val/missing-module"
|
|
880
|
+
"val/missing-module",
|
|
881
|
+
/**
|
|
882
|
+
* A gallery-backed media field points at something its gallery does not have.
|
|
883
|
+
* Reported by core with no `ValidationFix`, because the remedy is an edit to
|
|
884
|
+
* the gallery module or a file move -- see {@link GalleryMembership}.
|
|
885
|
+
*/
|
|
886
|
+
"val/gallery-membership"];
|
|
858
887
|
|
|
859
888
|
/**
|
|
860
889
|
* Structured payload attached to every Val diagnostic.
|
|
@@ -917,7 +946,8 @@ function createValDiagnostics({
|
|
|
917
946
|
content,
|
|
918
947
|
text,
|
|
919
948
|
valRoot,
|
|
920
|
-
snapshot
|
|
949
|
+
snapshot,
|
|
950
|
+
galleryChecks
|
|
921
951
|
}) {
|
|
922
952
|
if (content.errors === false) {
|
|
923
953
|
return [];
|
|
@@ -948,6 +978,29 @@ function createValDiagnostics({
|
|
|
948
978
|
for (const error of errors) {
|
|
949
979
|
const fixes = error.fixes;
|
|
950
980
|
|
|
981
|
+
// An unconditional gallery placeholder: show it only if the fix handler
|
|
982
|
+
// found something, and then with the handler's own message rather than
|
|
983
|
+
// the placeholder's "may have files not tracked by this gallery".
|
|
984
|
+
if (fixes?.some(isGalleryCheckFix)) {
|
|
985
|
+
const verdict = galleryChecks?.get(galleryCheckKey(sourcePath, error));
|
|
986
|
+
for (const finding of verdict ?? []) {
|
|
987
|
+
diagnostics.push(build(rangeOf(finding.sourcePath, modulePathMap), finding.message, {
|
|
988
|
+
code: "val/validation",
|
|
989
|
+
sourcePath: finding.sourcePath,
|
|
990
|
+
...(finding.fixes ? {
|
|
991
|
+
fixes: finding.fixes
|
|
992
|
+
} : {}),
|
|
993
|
+
...(finding.fixSourcePath ? {
|
|
994
|
+
fixSourcePath: finding.fixSourcePath
|
|
995
|
+
} : {}),
|
|
996
|
+
...(finding.value !== undefined ? {
|
|
997
|
+
value: finding.value
|
|
998
|
+
} : {})
|
|
999
|
+
}));
|
|
1000
|
+
}
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
951
1004
|
// A file-related fix cannot succeed if the file is not there, and
|
|
952
1005
|
// "metadata is incorrect" is a misleading way to say "the file is
|
|
953
1006
|
// missing". Report the real problem instead, exactly as the CLI's fix
|
|
@@ -958,13 +1011,33 @@ function createValDiagnostics({
|
|
|
958
1011
|
valRoot
|
|
959
1012
|
}) : undefined;
|
|
960
1013
|
if (missing) {
|
|
961
|
-
diagnostics.push(build(rangeOf(sourcePath, modulePathMap, "
|
|
1014
|
+
diagnostics.push(build(rangeOf(sourcePath, modulePathMap, "path"), `File ${missing} does not exist`, {
|
|
962
1015
|
code: "val/file-not-found",
|
|
963
1016
|
sourcePath,
|
|
964
1017
|
filePath: missing
|
|
965
1018
|
}));
|
|
966
1019
|
continue;
|
|
967
1020
|
}
|
|
1021
|
+
|
|
1022
|
+
// A gallery-backed field whose path the gallery does not track. Core
|
|
1023
|
+
// reports it with no fix because the remedy is elsewhere; giving it its
|
|
1024
|
+
// own code is what lets the editor offer the two remedies.
|
|
1025
|
+
const gallery = !fixes?.length && !error.schemaError ? galleryMembershipAt({
|
|
1026
|
+
sourcePath,
|
|
1027
|
+
content,
|
|
1028
|
+
snapshot
|
|
1029
|
+
}) : undefined;
|
|
1030
|
+
if (gallery) {
|
|
1031
|
+
diagnostics.push(build(rangeOf(sourcePath, modulePathMap, "path"), error.message, {
|
|
1032
|
+
code: "val/gallery-membership",
|
|
1033
|
+
sourcePath,
|
|
1034
|
+
gallery,
|
|
1035
|
+
...(error.value !== undefined ? {
|
|
1036
|
+
value: error.value
|
|
1037
|
+
} : {})
|
|
1038
|
+
}));
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
968
1041
|
diagnostics.push(build(rangeOf(sourcePath, modulePathMap), error.message, {
|
|
969
1042
|
code: error.schemaError ? "val/schema" : "val/validation",
|
|
970
1043
|
sourcePath,
|
|
@@ -985,7 +1058,7 @@ function createValDiagnostics({
|
|
|
985
1058
|
* it is not on disk.
|
|
986
1059
|
*
|
|
987
1060
|
* Mirrors the precondition check in `handleFileMetadata`
|
|
988
|
-
* (`packages/cli/src/runValidation.ts`): resolve the path, read `
|
|
1061
|
+
* (`packages/cli/src/runValidation.ts`): resolve the path, read its `path`,
|
|
989
1062
|
* check the file exists.
|
|
990
1063
|
*/
|
|
991
1064
|
function missingFileRef({
|
|
@@ -999,7 +1072,7 @@ function missingFileRef({
|
|
|
999
1072
|
try {
|
|
1000
1073
|
const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
|
|
1001
1074
|
const resolved = core.Internal.resolvePath(modulePath, content.source, content.schema);
|
|
1002
|
-
const ref = resolved.source?.
|
|
1075
|
+
const ref = resolved.source?.path;
|
|
1003
1076
|
if (typeof ref !== "string") {
|
|
1004
1077
|
return undefined;
|
|
1005
1078
|
}
|
|
@@ -1049,7 +1122,7 @@ function createMissingModuleDiagnostic({
|
|
|
1049
1122
|
sourcePath: moduleFilePath
|
|
1050
1123
|
});
|
|
1051
1124
|
}
|
|
1052
|
-
function rangeOf(sourcePath, modulePathMap, /** Optional child segment to prefer, for example `
|
|
1125
|
+
function rangeOf(sourcePath, modulePathMap, /** Optional child segment to prefer, for example `path`. */
|
|
1053
1126
|
preferChild) {
|
|
1054
1127
|
if (!modulePathMap) {
|
|
1055
1128
|
return FALLBACK_RANGE;
|
|
@@ -1081,6 +1154,88 @@ preferChild) {
|
|
|
1081
1154
|
} : FALLBACK_RANGE;
|
|
1082
1155
|
}
|
|
1083
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* Gallery checks core emits unconditionally.
|
|
1159
|
+
*
|
|
1160
|
+
* `RecordSchema.validate` attaches these to every `s.images()` / `s.files()`
|
|
1161
|
+
* module whether or not anything is actually wrong (see
|
|
1162
|
+
* `packages/core/src/schema/record.ts`): they are placeholders asking someone to
|
|
1163
|
+
* go and look. `val validate` looks by running the matching fix handler, which
|
|
1164
|
+
* reports success when the directory is unique and every file is accounted for.
|
|
1165
|
+
*
|
|
1166
|
+
* An editor has to do the same. Publishing them as-is put two permanent warnings
|
|
1167
|
+
* on every gallery module in the project — which is worse than useless, because
|
|
1168
|
+
* a warning that is always there is one nobody reads.
|
|
1169
|
+
*/
|
|
1170
|
+
const GALLERY_CHECK_FIXES = ["images:check-unique-folder", "files:check-unique-folder", "images:check-all-files", "files:check-all-files"];
|
|
1171
|
+
function isGalleryCheckFix(fix) {
|
|
1172
|
+
return GALLERY_CHECK_FIXES.includes(fix);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* One real problem found behind a gallery placeholder.
|
|
1177
|
+
*
|
|
1178
|
+
* A single placeholder can expand into several of these, each pointing at its
|
|
1179
|
+
* own entry: `handleCheckAllFiles` reporting `shouldApplyPatch` means "no
|
|
1180
|
+
* membership problem, now check the metadata", and `createFixPatch` then returns
|
|
1181
|
+
* one error per entry whose stored metadata disagrees with its file. This is why
|
|
1182
|
+
* the verdict is a list rather than a message, and why each carries its own
|
|
1183
|
+
* source path.
|
|
1184
|
+
*/
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The verdict on one gallery check. Empty means nothing is wrong and the
|
|
1188
|
+
* placeholder is dropped.
|
|
1189
|
+
*/
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Adjudicate every gallery placeholder in `validation`, by running the same fix
|
|
1193
|
+
* handler `val validate` runs.
|
|
1194
|
+
*
|
|
1195
|
+
* Async and therefore separate from {@link createValDiagnostics}, which stays
|
|
1196
|
+
* synchronous so it can be tested without a project. The caller runs this first
|
|
1197
|
+
* and passes the result in.
|
|
1198
|
+
*/
|
|
1199
|
+
async function resolveGalleryChecks({
|
|
1200
|
+
validation,
|
|
1201
|
+
runHandler
|
|
1202
|
+
}) {
|
|
1203
|
+
const verdicts = new Map();
|
|
1204
|
+
for (const [sourcePath, errors] of Object.entries(validation)) {
|
|
1205
|
+
for (const error of errors) {
|
|
1206
|
+
if (!error.fixes?.some(isGalleryCheckFix)) {
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
const key = galleryCheckKey(sourcePath, error);
|
|
1210
|
+
try {
|
|
1211
|
+
verdicts.set(key, await runHandler(sourcePath, error));
|
|
1212
|
+
} catch {
|
|
1213
|
+
// A handler that threw tells us nothing about the gallery. Keep the
|
|
1214
|
+
// placeholder rather than silently claiming the gallery is fine.
|
|
1215
|
+
verdicts.set(key, [{
|
|
1216
|
+
sourcePath,
|
|
1217
|
+
message: error.message,
|
|
1218
|
+
...(error.fixes ? {
|
|
1219
|
+
fixes: error.fixes
|
|
1220
|
+
} : {}),
|
|
1221
|
+
...(error.value !== undefined ? {
|
|
1222
|
+
value: error.value
|
|
1223
|
+
} : {})
|
|
1224
|
+
}]);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return verdicts;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Key for one placeholder. A gallery module carries several, all at the same
|
|
1233
|
+
* source path, so the fix names have to be part of the key.
|
|
1234
|
+
*/
|
|
1235
|
+
function galleryCheckKey(sourcePath, error) {
|
|
1236
|
+
return `${sourcePath}|${(error.fixes ?? []).join(",")}`;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1084
1239
|
/** Fixes core cannot resolve without a project-wide snapshot. */
|
|
1085
1240
|
const DEFERRED_FIXES = ["keyof:check-keys", "router:check-route"];
|
|
1086
1241
|
|
|
@@ -1100,119 +1255,69 @@ function dropDeferredPlaceholders(validation) {
|
|
|
1100
1255
|
}
|
|
1101
1256
|
|
|
1102
1257
|
/**
|
|
1103
|
-
*
|
|
1258
|
+
* A gallery-backed field pointing at something the gallery does not have.
|
|
1104
1259
|
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
* hand-wrote AST edits per fix kind, which is how the two drifted apart.
|
|
1111
|
-
*/
|
|
1112
|
-
|
|
1113
|
-
/**
|
|
1114
|
-
* Fixes that can be computed locally, without network access or credentials.
|
|
1260
|
+
* `ImageSchema.validate` reports this (`packages/core/src/schema/image.ts`:
|
|
1261
|
+
* "The gallery does not have an image at '…'") but attaches no `ValidationFix`,
|
|
1262
|
+
* because the remedy is not a change to this module: either the gallery gains an
|
|
1263
|
+
* entry, or the file moves into the gallery's directory. Both are edits to
|
|
1264
|
+
* somewhere else, which is not what a `ValidationFix` describes.
|
|
1115
1265
|
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1266
|
+
* So the fix is built in the editor instead, and this is what it needs. Derived
|
|
1267
|
+
* from the **schema**, not from the message: matching on message text would break
|
|
1268
|
+
* the moment the wording changed, silently.
|
|
1119
1269
|
*/
|
|
1120
|
-
const LOCAL_FIXES = ["image:add-metadata", "image:check-metadata", "file:add-metadata", "file:check-metadata",
|
|
1121
|
-
// Gallery metadata: createFixPatch reads each entry's file and corrects the
|
|
1122
|
-
// stored metadata, dropping entries whose file has gone. Filesystem only.
|
|
1123
|
-
"images:check-all-files", "files:check-all-files"];
|
|
1124
|
-
|
|
1125
|
-
/** Human-readable titles; falls back to the fix name for anything unknown. */
|
|
1126
|
-
const FIX_TITLES = {
|
|
1127
|
-
"image:add-metadata": "Val: add image metadata",
|
|
1128
|
-
"image:check-metadata": "Val: update image metadata",
|
|
1129
|
-
"file:add-metadata": "Val: add file metadata",
|
|
1130
|
-
"file:check-metadata": "Val: update file metadata",
|
|
1131
|
-
"images:check-all-files": "Val: update gallery image metadata",
|
|
1132
|
-
"files:check-all-files": "Val: update gallery file metadata"
|
|
1133
|
-
};
|
|
1134
|
-
function isLocalFix(fix) {
|
|
1135
|
-
return LOCAL_FIXES.includes(fix);
|
|
1136
|
-
}
|
|
1137
1270
|
|
|
1138
|
-
|
|
1139
|
-
* Build quick fixes for the diagnostics the client sent back.
|
|
1140
|
-
*
|
|
1141
|
-
* The client returns our `Diagnostic.data` verbatim, which is where the source
|
|
1142
|
-
* path and available fixes come from — no re-deriving them from a code string.
|
|
1143
|
-
*/
|
|
1144
|
-
async function createValCodeActions({
|
|
1145
|
-
document,
|
|
1146
|
-
diagnostics,
|
|
1147
|
-
content,
|
|
1148
|
-
valRoot,
|
|
1149
|
-
remoteHost = process.env.VAL_REMOTE_HOST || core.DEFAULT_VAL_REMOTE_HOST
|
|
1150
|
-
}) {
|
|
1151
|
-
const actions = [];
|
|
1152
|
-
for (const diagnostic of diagnostics) {
|
|
1153
|
-
const data = diagnostic.data;
|
|
1154
|
-
if (!data?.fixes?.length) {
|
|
1155
|
-
continue;
|
|
1156
|
-
}
|
|
1157
|
-
for (const fix of data.fixes) {
|
|
1158
|
-
if (!isLocalFix(fix)) {
|
|
1159
|
-
continue;
|
|
1160
|
-
}
|
|
1161
|
-
const edit = await computeFixEdit({
|
|
1162
|
-
document,
|
|
1163
|
-
sourcePath: data.sourcePath,
|
|
1164
|
-
// createFixPatch works one fix at a time; give it exactly this one so a
|
|
1165
|
-
// failing sibling fix cannot suppress this action.
|
|
1166
|
-
validationError: {
|
|
1167
|
-
message: diagnostic.message,
|
|
1168
|
-
value: data.value,
|
|
1169
|
-
fixes: [fix]
|
|
1170
|
-
},
|
|
1171
|
-
content,
|
|
1172
|
-
valRoot,
|
|
1173
|
-
remoteHost
|
|
1174
|
-
});
|
|
1175
|
-
if (!edit) {
|
|
1176
|
-
continue;
|
|
1177
|
-
}
|
|
1178
|
-
actions.push(vscodeLanguageserver.CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`, {
|
|
1179
|
-
changes: {
|
|
1180
|
-
[document.uri]: [edit]
|
|
1181
|
-
}
|
|
1182
|
-
}, vscodeLanguageserver.CodeActionKind.QuickFix));
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
return actions;
|
|
1186
|
-
}
|
|
1187
|
-
async function computeFixEdit({
|
|
1188
|
-
document,
|
|
1271
|
+
function galleryMembershipAt({
|
|
1189
1272
|
sourcePath,
|
|
1190
|
-
validationError,
|
|
1191
1273
|
content,
|
|
1192
|
-
|
|
1193
|
-
remoteHost
|
|
1274
|
+
snapshot
|
|
1194
1275
|
}) {
|
|
1195
|
-
|
|
1276
|
+
if (!content.source || !content.schema) {
|
|
1277
|
+
return undefined;
|
|
1278
|
+
}
|
|
1279
|
+
let resolved;
|
|
1196
1280
|
try {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
remoteHost
|
|
1200
|
-
},
|
|
1201
|
-
// `true` means "produce the patch"; nothing is written to disk here, the
|
|
1202
|
-
// patch is applied to the editor's text and returned as an edit.
|
|
1203
|
-
true, sourcePath, validationError, {}, content.source, content.schema);
|
|
1281
|
+
const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
|
|
1282
|
+
resolved = core.Internal.resolvePath(modulePath, content.source, content.schema);
|
|
1204
1283
|
} catch {
|
|
1205
1284
|
return undefined;
|
|
1206
1285
|
}
|
|
1207
|
-
|
|
1286
|
+
const schema = resolved.schema;
|
|
1287
|
+
if (!schema || typeof schema !== "object" || !("type" in schema) || schema.type !== "image" && schema.type !== "file") {
|
|
1208
1288
|
return undefined;
|
|
1209
1289
|
}
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1212
|
-
if (fp.result.isErr(patched)) {
|
|
1290
|
+
const referencedModule = "referencedModule" in schema && typeof schema.referencedModule === "string" ? schema.referencedModule : undefined;
|
|
1291
|
+
if (!referencedModule) {
|
|
1213
1292
|
return undefined;
|
|
1214
1293
|
}
|
|
1215
|
-
|
|
1294
|
+
const source = resolved.source;
|
|
1295
|
+
const currentPath = source && typeof source === "object" && "path" in source ? source.path : undefined;
|
|
1296
|
+
if (typeof currentPath !== "string") {
|
|
1297
|
+
return undefined;
|
|
1298
|
+
}
|
|
1299
|
+
const gallery = snapshot?.schemas[referencedModule];
|
|
1300
|
+
const directory = gallery?.type === "record" && typeof gallery.directory === "string" ? gallery.directory : undefined;
|
|
1301
|
+
// Core emits TWO fixless errors on a gallery-backed field: this one, and "an
|
|
1302
|
+
// image from a gallery must not carry its own width, height...". Both look
|
|
1303
|
+
// identical from here, and offering "add it to the gallery" for the second
|
|
1304
|
+
// would be nonsense. The path already being a key in the gallery is what tells
|
|
1305
|
+
// them apart, so without a snapshot to check against, claim nothing.
|
|
1306
|
+
const entries = snapshot?.sources[referencedModule];
|
|
1307
|
+
if (entries === undefined || entries === null || typeof entries !== "object" || Array.isArray(entries)) {
|
|
1308
|
+
return undefined;
|
|
1309
|
+
}
|
|
1310
|
+
if (currentPath in entries) {
|
|
1311
|
+
return undefined;
|
|
1312
|
+
}
|
|
1313
|
+
return {
|
|
1314
|
+
referencedModule,
|
|
1315
|
+
...(directory !== undefined ? {
|
|
1316
|
+
directory
|
|
1317
|
+
} : {}),
|
|
1318
|
+
path: currentPath,
|
|
1319
|
+
mediaType: schema.type
|
|
1320
|
+
};
|
|
1216
1321
|
}
|
|
1217
1322
|
|
|
1218
1323
|
/**
|
|
@@ -1247,81 +1352,874 @@ function minimalTextEdit(before, after, document) {
|
|
|
1247
1352
|
}
|
|
1248
1353
|
|
|
1249
1354
|
/**
|
|
1250
|
-
*
|
|
1355
|
+
* `workspace/executeCommand` handlers.
|
|
1356
|
+
*
|
|
1357
|
+
* Quick fixes that only rewrite text travel as a `WorkspaceEdit` and need no
|
|
1358
|
+
* command. These three cannot:
|
|
1359
|
+
*
|
|
1360
|
+
* - **login** has no edit at all; it opens a browser and waits.
|
|
1361
|
+
* - **upload-remote** sends bytes to a remote host, which needs credentials and
|
|
1362
|
+
* is not expressible as an edit. Only the resulting rewrite is.
|
|
1363
|
+
* - **download-remote** writes a file to disk before the rewrite makes sense.
|
|
1364
|
+
*
|
|
1365
|
+
* Routing them through `executeCommand` is what keeps editors free of Val
|
|
1366
|
+
* knowledge: a code action carries a `command` name the server advertised, and
|
|
1367
|
+
* the LSP client forwards it back without understanding it. `vscode-languageclient`
|
|
1368
|
+
* registers every advertised command automatically, and a Neovim client does the
|
|
1369
|
+
* same through `vim.lsp.buf.code_action`, so neither needs a line of Val-specific
|
|
1370
|
+
* code for any of this.
|
|
1371
|
+
*/
|
|
1372
|
+
/** Names advertised in `executeCommandProvider.commands`. */
|
|
1373
|
+
const VAL_LOGIN_COMMAND = "val.login";
|
|
1374
|
+
const VAL_UPLOAD_REMOTE_COMMAND = "val.uploadRemote";
|
|
1375
|
+
const VAL_DOWNLOAD_REMOTE_COMMAND = "val.downloadRemote";
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* The remote fixes, and which command each is offered through.
|
|
1251
1379
|
*
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1380
|
+
* Kept apart from `LOCAL_FIXES` in `codeActions.ts` deliberately: a quick fix
|
|
1381
|
+
* that silently needed credentials would just fail, so these are offered as
|
|
1382
|
+
* commands and can report "you are not logged in" like a normal outcome.
|
|
1255
1383
|
*/
|
|
1384
|
+
const REMOTE_FIX_COMMANDS = {
|
|
1385
|
+
"image:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
|
|
1386
|
+
"file:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
|
|
1387
|
+
"images:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
|
|
1388
|
+
"files:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
|
|
1389
|
+
"image:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND,
|
|
1390
|
+
"file:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND
|
|
1391
|
+
};
|
|
1392
|
+
const REMOTE_FIX_TITLES = {
|
|
1393
|
+
"image:upload-remote": "Val: upload this image to Val Remote",
|
|
1394
|
+
"file:upload-remote": "Val: upload this file to Val Remote",
|
|
1395
|
+
"images:upload-remote": "Val: upload this gallery's images to Val Remote",
|
|
1396
|
+
"files:upload-remote": "Val: upload this gallery's files to Val Remote",
|
|
1397
|
+
"image:download-remote": "Val: download this image into the project",
|
|
1398
|
+
"file:download-remote": "Val: download this file into the project"
|
|
1399
|
+
};
|
|
1256
1400
|
|
|
1257
|
-
/**
|
|
1401
|
+
/** Arguments a remote-fix command is invoked with. */
|
|
1402
|
+
|
|
1403
|
+
/** Whether a fix is offered as a command rather than as a plain edit. */
|
|
1404
|
+
function isRemoteFix(fix) {
|
|
1405
|
+
return Object.prototype.hasOwnProperty.call(REMOTE_FIX_COMMANDS, fix);
|
|
1406
|
+
}
|
|
1407
|
+
function valCommandNames() {
|
|
1408
|
+
return [VAL_LOGIN_COMMAND, VAL_UPLOAD_REMOTE_COMMAND, VAL_DOWNLOAD_REMOTE_COMMAND];
|
|
1409
|
+
}
|
|
1258
1410
|
|
|
1259
1411
|
/**
|
|
1260
|
-
*
|
|
1412
|
+
* Read the project's personal access token.
|
|
1261
1413
|
*
|
|
1262
|
-
*
|
|
1263
|
-
*
|
|
1264
|
-
* node ends *at* the cursor rather than one character past it, so the usual
|
|
1265
|
-
* `getEnd() - 1` bound excludes every position inside the literal and no
|
|
1266
|
-
* completions are offered at all. The closing quote is therefore counted rather
|
|
1267
|
-
* than assumed.
|
|
1414
|
+
* Same file the CLI writes and the dev server reads (`<root>/.val/pat.json`), so
|
|
1415
|
+
* logging in through either is logging in for both.
|
|
1268
1416
|
*/
|
|
1269
|
-
function
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
// of the contents rather than the terminator.
|
|
1276
|
-
let backslashes = 0;
|
|
1277
|
-
for (let i = raw.length - 2; i >= 1 && raw[i] === "\\"; i--) {
|
|
1278
|
-
backslashes++;
|
|
1279
|
-
}
|
|
1280
|
-
closing = backslashes % 2 === 0 ? 1 : 0;
|
|
1417
|
+
function readPersonalAccessToken(valRoot) {
|
|
1418
|
+
try {
|
|
1419
|
+
const parsed = server.parsePersonalAccessTokenFile(fs__default["default"].readFileSync(server.getPersonalAccessTokenPath(valRoot), "utf8"));
|
|
1420
|
+
return parsed.success ? parsed.data.pat : null;
|
|
1421
|
+
} catch {
|
|
1422
|
+
return null;
|
|
1281
1423
|
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* The remote the upload fixes talk to, built from `@valbuild/server`.
|
|
1428
|
+
*
|
|
1429
|
+
* Same wiring as `packages/cli/src/validate.ts`: the ref's host is
|
|
1430
|
+
* `VAL_REMOTE_HOST`, but the bytes and the project settings go to the content
|
|
1431
|
+
* host. Conflating the two uploads to the wrong place.
|
|
1432
|
+
*/
|
|
1433
|
+
function createRemote(remoteHost) {
|
|
1434
|
+
const contentHost = process.env.VAL_CONTENT_URL ?? core.DEFAULT_CONTENT_HOST;
|
|
1282
1435
|
return {
|
|
1283
|
-
|
|
1284
|
-
|
|
1436
|
+
remoteHost,
|
|
1437
|
+
getSettings: (projectName, options) => server.getSettings(projectName, options),
|
|
1438
|
+
uploadFile: (project, bucket, fileHash, fileExt, fileBuffer, options) => server.uploadRemoteFile(contentHost, project, bucket, fileHash,
|
|
1439
|
+
// The handler types this optional; an empty extension is what the CLI
|
|
1440
|
+
// ends up sending for a file with none.
|
|
1441
|
+
fileExt ?? "", fileBuffer, options)
|
|
1285
1442
|
};
|
|
1286
1443
|
}
|
|
1287
1444
|
|
|
1288
1445
|
/**
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1446
|
+
* A work-done progress the client can show, and cancel.
|
|
1447
|
+
*
|
|
1448
|
+
* `createWorkDoneProgress` needs the client to have announced
|
|
1449
|
+
* `window.workDoneProgress`; when it has not, this degrades to a plain message
|
|
1450
|
+
* and a signal nobody aborts, rather than failing the command. The
|
|
1451
|
+
* `AbortSignal` is what connects a user pressing cancel to
|
|
1452
|
+
* `awaitValLoginConfirmation`, which takes one precisely so an editor can drive
|
|
1453
|
+
* it.
|
|
1291
1454
|
*/
|
|
1292
|
-
function
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1455
|
+
async function withProgress(connection, title, message) {
|
|
1456
|
+
const controller = new AbortController();
|
|
1457
|
+
try {
|
|
1458
|
+
const reporter = await connection.window.createWorkDoneProgress();
|
|
1459
|
+
reporter.begin(title, undefined, message, true);
|
|
1460
|
+
reporter.token.onCancellationRequested(() => controller.abort());
|
|
1461
|
+
let finished = false;
|
|
1462
|
+
return {
|
|
1463
|
+
signal: controller.signal,
|
|
1464
|
+
done: () => {
|
|
1465
|
+
if (!finished) {
|
|
1466
|
+
finished = true;
|
|
1467
|
+
reporter.done();
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
} catch {
|
|
1472
|
+
connection.window.showInformationMessage(`${title}. ${message}`);
|
|
1473
|
+
return {
|
|
1474
|
+
signal: controller.signal,
|
|
1475
|
+
done: () => {}
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
function createValCommands(deps) {
|
|
1480
|
+
const remoteHost = deps.remoteHost ?? process.env.VAL_REMOTE_HOST ?? core.DEFAULT_VAL_REMOTE_HOST;
|
|
1481
|
+
async function login() {
|
|
1482
|
+
const project = deps.getProject();
|
|
1483
|
+
if (!project) {
|
|
1301
1484
|
return;
|
|
1302
1485
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1486
|
+
const {
|
|
1487
|
+
connection
|
|
1488
|
+
} = deps;
|
|
1489
|
+
try {
|
|
1490
|
+
const session = await server.startValLogin();
|
|
1491
|
+
// `showDocument` with `external` is the standard way to reach a browser;
|
|
1492
|
+
// there is no Val-specific request for it, which is what lets any LSP
|
|
1493
|
+
// client drive this flow.
|
|
1494
|
+
await connection.sendRequest(vscodeLanguageserver.ShowDocumentRequest.type, {
|
|
1495
|
+
uri: session.url,
|
|
1496
|
+
external: true
|
|
1497
|
+
});
|
|
1498
|
+
// The poll runs for up to five minutes. Without progress the editor looks
|
|
1499
|
+
// hung, and there is nothing to tell the user the browser is the next step.
|
|
1500
|
+
const progress = await withProgress(connection, "Val: waiting for login", "Complete the login in your browser.");
|
|
1501
|
+
let confirmed;
|
|
1502
|
+
try {
|
|
1503
|
+
confirmed = await server.awaitValLoginConfirmation(session.nonce, {
|
|
1504
|
+
signal: progress.signal
|
|
1505
|
+
});
|
|
1506
|
+
} finally {
|
|
1507
|
+
progress.done();
|
|
1508
|
+
}
|
|
1509
|
+
const filePath = server.persistPersonalAccessToken(project.valRoot, confirmed);
|
|
1510
|
+
connection.window.showInformationMessage(`Val: logged in as ${confirmed.profile.email} (token saved to ${filePath}).`);
|
|
1511
|
+
} catch (e) {
|
|
1512
|
+
const message = e instanceof server.ValLoginError ? e.message : e instanceof Error ? e.message : String(e);
|
|
1513
|
+
deps.connection.window.showErrorMessage(`Val: login failed. ${message}`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
async function remoteFix(args) {
|
|
1517
|
+
const project = deps.getProject();
|
|
1518
|
+
const document = deps.getDocument(args.uri);
|
|
1519
|
+
const {
|
|
1520
|
+
connection
|
|
1521
|
+
} = deps;
|
|
1522
|
+
if (!project || !document) {
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
const isUpload = args.fix.endsWith(":upload-remote");
|
|
1526
|
+
let projectName;
|
|
1527
|
+
if (isUpload) {
|
|
1528
|
+
// Uploading needs both a project name and a token; saying which one is
|
|
1529
|
+
// missing is the difference between an actionable message and a shrug.
|
|
1530
|
+
const pat = readPersonalAccessToken(project.valRoot);
|
|
1531
|
+
if (!pat) {
|
|
1532
|
+
connection.window.showErrorMessage(`Val: you are not logged in. Run "${VAL_LOGIN_COMMAND}" first.`);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
const config = await server.findAndEvalValConfigFile(project.valRoot).catch(() => null);
|
|
1536
|
+
projectName = config?.project;
|
|
1537
|
+
if (!projectName) {
|
|
1538
|
+
connection.window.showErrorMessage("Val: no `project` in val.config, so there is nowhere to upload to.");
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
const remoteFiles = {};
|
|
1543
|
+
const validationError = {
|
|
1544
|
+
message: args.message,
|
|
1545
|
+
value: args.value,
|
|
1546
|
+
fixes: [args.fix]
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
// Bytes over the network: how long depends on the file, so the editor needs
|
|
1550
|
+
// to say something is happening.
|
|
1551
|
+
const progress = await withProgress(connection, isUpload ? "Val: uploading to Val Remote" : "Val: downloading from Val Remote", args.sourcePath);
|
|
1552
|
+
let outcome;
|
|
1553
|
+
try {
|
|
1554
|
+
outcome = await project.runFixHandler({
|
|
1555
|
+
moduleFilePath: args.moduleFilePath,
|
|
1556
|
+
sourcePath: args.sourcePath,
|
|
1557
|
+
validationError,
|
|
1558
|
+
// The upload or download itself is the point; it cannot be an edit.
|
|
1559
|
+
fix: true,
|
|
1560
|
+
remote: createRemote(remoteHost),
|
|
1561
|
+
...(projectName ? {
|
|
1562
|
+
project: projectName
|
|
1563
|
+
} : {}),
|
|
1564
|
+
remoteFiles
|
|
1565
|
+
});
|
|
1566
|
+
} catch (e) {
|
|
1567
|
+
progress.done();
|
|
1568
|
+
connection.window.showErrorMessage(`Val: ${args.fix} failed. ${e instanceof Error ? e.message : String(e)}`);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
progress.done();
|
|
1572
|
+
if (!outcome) {
|
|
1573
|
+
connection.window.showErrorMessage(`Val: could not run ${args.fix} — the project would not evaluate.`);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
if (!outcome.success) {
|
|
1577
|
+
connection.window.showErrorMessage(`Val: ${outcome.errorMessage ?? `${args.fix} failed.`}`);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
if (!outcome.shouldApplyPatch) {
|
|
1581
|
+
// The handler did the work and there is nothing left to rewrite.
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
const moduleResult = await project.getModule(args.moduleFilePath, {
|
|
1585
|
+
validate: false
|
|
1586
|
+
});
|
|
1587
|
+
if (moduleResult.status === "error") {
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
let fixed;
|
|
1591
|
+
try {
|
|
1592
|
+
fixed = await server.createFixPatch({
|
|
1593
|
+
projectRoot: project.valRoot,
|
|
1594
|
+
remoteHost
|
|
1595
|
+
}, true, args.sourcePath, validationError, remoteFiles, moduleResult.content.source, moduleResult.content.schema);
|
|
1596
|
+
} catch (e) {
|
|
1597
|
+
connection.window.showErrorMessage(`Val: ${args.fix} could not be written. ${e instanceof Error ? e.message : String(e)}`);
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
if (!fixed || fixed.patch.length === 0) {
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const before = document.getText();
|
|
1604
|
+
const patched = server.patchSourceFile(before, fixed.patch);
|
|
1605
|
+
if (fp.result.isErr(patched)) {
|
|
1606
|
+
connection.window.showErrorMessage(`Val: ${args.fix} produced a patch that would not apply.`);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
const edit = minimalTextEdit(before, patched.value.text, document);
|
|
1610
|
+
if (!edit) {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
// Applied through the client so it lands in the editor's undo history,
|
|
1614
|
+
// rather than written to disk under the user's cursor.
|
|
1615
|
+
await connection.sendRequest(vscodeLanguageserver.ApplyWorkspaceEditRequest.type, {
|
|
1616
|
+
label: REMOTE_FIX_TITLES[args.fix] ?? `Val: ${args.fix}`,
|
|
1617
|
+
edit: {
|
|
1618
|
+
changes: {
|
|
1619
|
+
[args.uri]: [edit]
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
return {
|
|
1625
|
+
async execute(command, args) {
|
|
1626
|
+
if (command === VAL_LOGIN_COMMAND) {
|
|
1627
|
+
await login();
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (command === VAL_UPLOAD_REMOTE_COMMAND || command === VAL_DOWNLOAD_REMOTE_COMMAND) {
|
|
1631
|
+
const [raw] = args;
|
|
1632
|
+
if (!isRemoteFixCommandArgs(raw)) {
|
|
1633
|
+
deps.connection.console.error(`Val: ${command} called with unexpected arguments.`);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
await remoteFix(raw);
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
deps.connection.console.error(`Val: unknown command ${command}.`);
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
function isRemoteFixCommandArgs(raw) {
|
|
1644
|
+
if (raw === null || typeof raw !== "object") {
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
const candidate = raw;
|
|
1648
|
+
return typeof candidate.uri === "string" && typeof candidate.moduleFilePath === "string" && typeof candidate.sourcePath === "string" && typeof candidate.fix === "string" && typeof candidate.message === "string";
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Works out which Val modules a `val.modules.{ts,js}` file registers.
|
|
1653
|
+
*
|
|
1654
|
+
* Val only serves modules listed there, so an unregistered `.val.ts` file
|
|
1655
|
+
* silently does nothing.
|
|
1656
|
+
*
|
|
1657
|
+
* Rather than pattern-matching the accepted authoring shapes — `config.modules([…])`
|
|
1658
|
+
* vs `modules(config, […])`, bare `import("./x.val")` vs
|
|
1659
|
+
* `{ def: () => import("./x.val") }` — this collects *every* dynamic import
|
|
1660
|
+
* specifier in the file. That is deliberate:
|
|
1661
|
+
*
|
|
1662
|
+
* - it covers all current shapes with one rule, and any shape added later;
|
|
1663
|
+
* - when in doubt it over-reports registration, so a new authoring form makes
|
|
1664
|
+
* the diagnostic go quiet rather than firing a false "missing module" on
|
|
1665
|
+
* every file, which is the failure mode that actually hurts.
|
|
1666
|
+
*/
|
|
1667
|
+
function findRegisteredModuleSpecifiers(sourceFile) {
|
|
1668
|
+
const specifiers = [];
|
|
1669
|
+
function visit(node) {
|
|
1670
|
+
if (ts__default["default"].isCallExpression(node) && node.expression.kind === ts__default["default"].SyntaxKind.ImportKeyword) {
|
|
1671
|
+
const [arg] = node.arguments;
|
|
1672
|
+
if (arg && ts__default["default"].isStringLiteralLike(arg)) {
|
|
1673
|
+
specifiers.push(arg.text);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
ts__default["default"].forEachChild(node, visit);
|
|
1677
|
+
}
|
|
1678
|
+
visit(sourceFile);
|
|
1679
|
+
return specifiers;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Whether `moduleFilePath` is registered by the given `val.modules` file.
|
|
1684
|
+
*
|
|
1685
|
+
* @param valModulesDir directory containing the val.modules file, relative to
|
|
1686
|
+
* the Val root (`""` when it sits at the root).
|
|
1687
|
+
* @param moduleFilePath the module's path, Val-style: root-relative, leading
|
|
1688
|
+
* slash, with extension (for example `/content/page.val.ts`).
|
|
1689
|
+
*/
|
|
1690
|
+
function isModuleRegistered({
|
|
1691
|
+
sourceFile,
|
|
1692
|
+
valModulesDir,
|
|
1693
|
+
moduleFilePath
|
|
1694
|
+
}) {
|
|
1695
|
+
const target = stripValModuleExtension(moduleFilePath);
|
|
1696
|
+
return findRegisteredModuleSpecifiers(sourceFile).some(specifier => {
|
|
1697
|
+
// Specifiers are written relative to the val.modules file and normally omit
|
|
1698
|
+
// the extension ("./content/page.val").
|
|
1699
|
+
const resolved = specifier.startsWith(".") ? path__default["default"].posix.normalize(path__default["default"].posix.join("/", valModulesDir, stripValModuleExtension(specifier))) : stripValModuleExtension(specifier);
|
|
1700
|
+
return resolved === target;
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/** `/content/page.val.ts` -> `/content/page.val` (also handles `.val` already). */
|
|
1705
|
+
function stripValModuleExtension(specifier) {
|
|
1706
|
+
return specifier.replace(/\.val\.(ts|js|tsx|jsx)$/, ".val");
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* Where to insert a new entry in a `val.modules` file, and how to indent it.
|
|
1711
|
+
*
|
|
1712
|
+
* Only `modules(config, [ … ])` is matched, because that is the shape Val
|
|
1713
|
+
* actually accepts and the shape `examples/next/val.modules.ts` uses. The
|
|
1714
|
+
* over-reporting rule in {@link findRegisteredModuleSpecifiers} is right for
|
|
1715
|
+
* *reading* a file someone else wrote; writing into one has to commit to a
|
|
1716
|
+
* shape, and guessing wrong produces a file that no longer compiles.
|
|
1717
|
+
*
|
|
1718
|
+
* Returns `null` when the array cannot be found — the caller then offers no fix
|
|
1719
|
+
* rather than inserting somewhere arbitrary.
|
|
1720
|
+
*/
|
|
1721
|
+
function findValModulesInsertion(sourceFile) {
|
|
1722
|
+
let found = null;
|
|
1723
|
+
function visit(node) {
|
|
1724
|
+
if (found === null && ts__default["default"].isCallExpression(node) && ts__default["default"].isIdentifier(node.expression) && node.expression.text === "modules" && node.arguments.length >= 2) {
|
|
1725
|
+
const array = node.arguments[1];
|
|
1726
|
+
if (ts__default["default"].isArrayLiteralExpression(array)) {
|
|
1727
|
+
if (array.elements.length > 0) {
|
|
1728
|
+
const last = array.elements[array.elements.length - 1];
|
|
1729
|
+
const first = array.elements[0];
|
|
1730
|
+
const {
|
|
1731
|
+
character
|
|
1732
|
+
} = sourceFile.getLineAndCharacterOfPosition(first.getStart(sourceFile));
|
|
1733
|
+
found = {
|
|
1734
|
+
insertOffset: last.end,
|
|
1735
|
+
indentation: " ".repeat(character),
|
|
1736
|
+
hasElements: true
|
|
1737
|
+
};
|
|
1738
|
+
} else {
|
|
1739
|
+
found = {
|
|
1740
|
+
// Just after the `[`.
|
|
1741
|
+
insertOffset: array.getStart(sourceFile) + 1,
|
|
1742
|
+
indentation: " ",
|
|
1743
|
+
hasElements: false
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
ts__default["default"].forEachChild(node, visit);
|
|
1750
|
+
}
|
|
1751
|
+
visit(sourceFile);
|
|
1752
|
+
return found;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* The specifier to write for `moduleFilePath`, relative to the `val.modules`
|
|
1757
|
+
* file that will hold it.
|
|
1758
|
+
*
|
|
1759
|
+
* Both paths are Val-style (root-relative, leading slash). POSIX separators
|
|
1760
|
+
* always: the string ends up in an `import()` in source, where a backslash is an
|
|
1761
|
+
* escape rather than a separator.
|
|
1762
|
+
*/
|
|
1763
|
+
function valModuleSpecifier({
|
|
1764
|
+
valModulesFilePath,
|
|
1765
|
+
moduleFilePath
|
|
1766
|
+
}) {
|
|
1767
|
+
const relative = path__default["default"].posix.relative(path__default["default"].posix.dirname(valModulesFilePath), stripValModuleExtension(moduleFilePath));
|
|
1768
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** The text to insert for one new entry, including its separator. */
|
|
1772
|
+
function valModulesEntryText({
|
|
1773
|
+
specifier,
|
|
1774
|
+
indentation,
|
|
1775
|
+
hasElements
|
|
1776
|
+
}) {
|
|
1777
|
+
const entry = `{ def: () => import("${specifier}") }`;
|
|
1778
|
+
return hasElements ? `,\n${indentation}${entry}` : `\n${indentation}${entry}\n${indentation.slice(2)}`;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* Conversions between LSP document URIs and the paths Val uses.
|
|
1783
|
+
*
|
|
1784
|
+
* Deliberately minimal rather than pulling in `vscode-uri`: the server only ever
|
|
1785
|
+
* deals with local `file:` URIs, and keeping this small makes the assumptions
|
|
1786
|
+
* visible.
|
|
1787
|
+
*/
|
|
1788
|
+
|
|
1789
|
+
/** Matches the Val module files the server validates. */
|
|
1790
|
+
const VAL_MODULE_RE = /\.val\.(ts|js|tsx|jsx)$/;
|
|
1791
|
+
|
|
1792
|
+
/** `file:` with an optional authority, capturing authority and path apart. */
|
|
1793
|
+
const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
|
|
1794
|
+
|
|
1795
|
+
/** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
|
|
1796
|
+
const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
|
|
1797
|
+
function isValModuleUri(uri) {
|
|
1798
|
+
return VAL_MODULE_RE.test(uri);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
|
|
1803
|
+
* one is broken, but that should not take the server down: fall back to the
|
|
1804
|
+
* undecoded text so the path is at worst not found.
|
|
1805
|
+
*/
|
|
1806
|
+
function decodeSafely(value) {
|
|
1807
|
+
try {
|
|
1808
|
+
return decodeURIComponent(value);
|
|
1809
|
+
} catch {
|
|
1810
|
+
return value;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* `file:///a/b.val.ts` -> `/a/b.val.ts`
|
|
1816
|
+
*
|
|
1817
|
+
* Percent escapes are decoded, Windows drive letters lose the leading slash
|
|
1818
|
+
* (`file:///c%3A/a` -> `c:/a`), and a URI with an authority is read as a UNC
|
|
1819
|
+
* path (`file://host/share/a` -> `//host/share/a`). Anything that is not a
|
|
1820
|
+
* `file:` URI is passed through unchanged, since callers also hand us plain
|
|
1821
|
+
* paths.
|
|
1822
|
+
*/
|
|
1823
|
+
function uriToPath(uri) {
|
|
1824
|
+
const match = FILE_URI_RE.exec(uri);
|
|
1825
|
+
if (!match) {
|
|
1826
|
+
return uri;
|
|
1827
|
+
}
|
|
1828
|
+
const authority = decodeSafely(match[1]);
|
|
1829
|
+
const fsPath = decodeSafely(match[2] || "/");
|
|
1830
|
+
if (authority) {
|
|
1831
|
+
return `//${authority}${fsPath}`;
|
|
1832
|
+
}
|
|
1833
|
+
return fsPath.replace(URI_DRIVE_LETTER_RE, "$1$2");
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* `/a/b.val.ts` -> `file:///a/b.val.ts`
|
|
1838
|
+
*
|
|
1839
|
+
* The escaping matches what VS Code produces (drive-letter colons included), so
|
|
1840
|
+
* that a URI built here can be looked up in the open-document map keyed by the
|
|
1841
|
+
* URIs the client sent.
|
|
1842
|
+
*/
|
|
1843
|
+
function pathToUri(fsPath) {
|
|
1844
|
+
const normalized = fsPath.split(path__default["default"].sep).join("/");
|
|
1845
|
+
const rooted = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
1846
|
+
return `file://${rooted.split("/").map(segment => encodeURIComponent(segment)).join("/")}`;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* Convert a document URI into the `ModuleFilePath` Val addresses it by: a
|
|
1851
|
+
* POSIX-style path relative to the Val root, with a leading slash.
|
|
1852
|
+
*
|
|
1853
|
+
* Returns `undefined` when the file lies outside the Val root — one server
|
|
1854
|
+
* serves exactly one root, so another root's files are not its business.
|
|
1855
|
+
*/
|
|
1856
|
+
function toModuleFilePath(valRoot, uri) {
|
|
1857
|
+
const fsPath = uriToPath(uri);
|
|
1858
|
+
const relative = path__default["default"].relative(valRoot, fsPath);
|
|
1859
|
+
if (!relative || relative.startsWith("..") || path__default["default"].isAbsolute(relative)) {
|
|
1860
|
+
return undefined;
|
|
1861
|
+
}
|
|
1862
|
+
return `/${relative.split(path__default["default"].sep).join("/")}`;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Quick fixes, built by running Val's own fix machinery.
|
|
1867
|
+
*
|
|
1868
|
+
* The pipeline is deliberately the same one `val validate --fix` uses:
|
|
1869
|
+
*
|
|
1870
|
+
* ValidationError -> createFixPatch -> Patch -> patchSourceFile -> TextEdit
|
|
1871
|
+
*
|
|
1872
|
+
* so an editor fix and a CLI fix cannot diverge. The previous VS Code extension
|
|
1873
|
+
* hand-wrote AST edits per fix kind, which is how the two drifted apart.
|
|
1874
|
+
*/
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* Fixes that can be computed locally, without network access or credentials.
|
|
1878
|
+
*
|
|
1879
|
+
* Remote upload/download need a logged-in session and are handled by a separate
|
|
1880
|
+
* flow, so they are not offered as plain quick fixes: a code action that
|
|
1881
|
+
* silently required auth would just fail.
|
|
1882
|
+
*/
|
|
1883
|
+
const LOCAL_FIXES = ["image:add-metadata", "image:check-metadata", "file:add-metadata", "file:check-metadata",
|
|
1884
|
+
// Gallery metadata: createFixPatch reads each entry's file and corrects the
|
|
1885
|
+
// stored metadata, dropping entries whose file has gone. Filesystem only.
|
|
1886
|
+
"images:check-all-files", "files:check-all-files"];
|
|
1887
|
+
|
|
1888
|
+
/** Human-readable titles; falls back to the fix name for anything unknown. */
|
|
1889
|
+
const FIX_TITLES = {
|
|
1890
|
+
"image:add-metadata": "Val: add image metadata",
|
|
1891
|
+
"image:check-metadata": "Val: update image metadata",
|
|
1892
|
+
"file:add-metadata": "Val: add file metadata",
|
|
1893
|
+
"file:check-metadata": "Val: update file metadata",
|
|
1894
|
+
"images:check-all-files": "Val: update gallery image metadata",
|
|
1895
|
+
"files:check-all-files": "Val: update gallery file metadata"
|
|
1896
|
+
};
|
|
1897
|
+
function isLocalFix(fix) {
|
|
1898
|
+
return LOCAL_FIXES.includes(fix);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* Build quick fixes for the diagnostics the client sent back.
|
|
1903
|
+
*
|
|
1904
|
+
* The client returns our `Diagnostic.data` verbatim, which is where the source
|
|
1905
|
+
* path and available fixes come from — no re-deriving them from a code string.
|
|
1906
|
+
*/
|
|
1907
|
+
async function createValCodeActions({
|
|
1908
|
+
document,
|
|
1909
|
+
diagnostics,
|
|
1910
|
+
content,
|
|
1911
|
+
valRoot,
|
|
1912
|
+
moduleFilePath,
|
|
1913
|
+
remoteHost = process.env.VAL_REMOTE_HOST || core.DEFAULT_VAL_REMOTE_HOST
|
|
1914
|
+
}) {
|
|
1915
|
+
const actions = [];
|
|
1916
|
+
for (const diagnostic of diagnostics) {
|
|
1917
|
+
const data = diagnostic.data;
|
|
1918
|
+
if (!data?.fixes?.length) {
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
for (const fix of data.fixes) {
|
|
1922
|
+
// Remote fixes upload or download bytes, so they are commands rather than
|
|
1923
|
+
// edits: a quick fix that silently needed credentials would just fail,
|
|
1924
|
+
// whereas a command can say "you are not logged in".
|
|
1925
|
+
if (isRemoteFix(fix)) {
|
|
1926
|
+
const command = REMOTE_FIX_COMMANDS[fix];
|
|
1927
|
+
if (!command || !moduleFilePath) {
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
const args = {
|
|
1931
|
+
uri: document.uri,
|
|
1932
|
+
moduleFilePath,
|
|
1933
|
+
sourcePath: data.sourcePath,
|
|
1934
|
+
fix,
|
|
1935
|
+
message: diagnostic.message,
|
|
1936
|
+
...(data.value !== undefined ? {
|
|
1937
|
+
value: data.value
|
|
1322
1938
|
} : {})
|
|
1323
1939
|
};
|
|
1940
|
+
actions.push({
|
|
1941
|
+
title: REMOTE_FIX_TITLES[fix] ?? `Val: ${fix}`,
|
|
1942
|
+
kind: vscodeLanguageserver.CodeActionKind.QuickFix,
|
|
1943
|
+
command: {
|
|
1944
|
+
title: REMOTE_FIX_TITLES[fix] ?? fix,
|
|
1945
|
+
command,
|
|
1946
|
+
arguments: [args]
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
if (!isLocalFix(fix)) {
|
|
1952
|
+
continue;
|
|
1324
1953
|
}
|
|
1954
|
+
const edit = await computeFixEdit({
|
|
1955
|
+
document,
|
|
1956
|
+
// A gallery check is reported on the entry but fixed against the record
|
|
1957
|
+
// that contains it; everything else fixes where it is reported.
|
|
1958
|
+
sourcePath: data.fixSourcePath ?? data.sourcePath,
|
|
1959
|
+
// createFixPatch works one fix at a time; give it exactly this one so a
|
|
1960
|
+
// failing sibling fix cannot suppress this action.
|
|
1961
|
+
validationError: {
|
|
1962
|
+
message: diagnostic.message,
|
|
1963
|
+
value: data.value,
|
|
1964
|
+
fixes: [fix]
|
|
1965
|
+
},
|
|
1966
|
+
content,
|
|
1967
|
+
valRoot,
|
|
1968
|
+
remoteHost
|
|
1969
|
+
});
|
|
1970
|
+
if (!edit) {
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
actions.push(vscodeLanguageserver.CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`, {
|
|
1974
|
+
changes: {
|
|
1975
|
+
[document.uri]: [edit]
|
|
1976
|
+
}
|
|
1977
|
+
}, vscodeLanguageserver.CodeActionKind.QuickFix));
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
return actions;
|
|
1981
|
+
}
|
|
1982
|
+
async function computeFixEdit({
|
|
1983
|
+
document,
|
|
1984
|
+
sourcePath,
|
|
1985
|
+
validationError,
|
|
1986
|
+
content,
|
|
1987
|
+
valRoot,
|
|
1988
|
+
remoteHost
|
|
1989
|
+
}) {
|
|
1990
|
+
let fixed;
|
|
1991
|
+
try {
|
|
1992
|
+
fixed = await server.createFixPatch({
|
|
1993
|
+
projectRoot: valRoot,
|
|
1994
|
+
remoteHost
|
|
1995
|
+
},
|
|
1996
|
+
// `true` means "produce the patch"; nothing is written to disk here, the
|
|
1997
|
+
// patch is applied to the editor's text and returned as an edit.
|
|
1998
|
+
true, sourcePath, validationError, {}, content.source, content.schema);
|
|
1999
|
+
} catch {
|
|
2000
|
+
return undefined;
|
|
2001
|
+
}
|
|
2002
|
+
if (!fixed || fixed.patch.length === 0) {
|
|
2003
|
+
return undefined;
|
|
2004
|
+
}
|
|
2005
|
+
const before = document.getText();
|
|
2006
|
+
const patched = server.patchSourceFile(before, fixed.patch);
|
|
2007
|
+
if (fp.result.isErr(patched)) {
|
|
2008
|
+
return undefined;
|
|
2009
|
+
}
|
|
2010
|
+
return minimalTextEdit(before, patched.value.text, document);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* Quick fix for a module that is not registered in `val.modules`.
|
|
2015
|
+
*
|
|
2016
|
+
* Separate from {@link createValCodeActions} because it is not a
|
|
2017
|
+
* `ValidationError` at all — nothing is wrong with the module's content, it is
|
|
2018
|
+
* simply not listed, so there is no `createFixPatch` path to reuse. The edit
|
|
2019
|
+
* also lands in a *different* file than the diagnostic, which a `WorkspaceEdit`
|
|
2020
|
+
* handles natively and needs no extra client capability.
|
|
2021
|
+
*
|
|
2022
|
+
* Returns nothing when the `val.modules` file cannot be found or its `modules(
|
|
2023
|
+
* config, [ … ])` array cannot be located: an insertion at a guessed offset
|
|
2024
|
+
* produces a file that no longer compiles, which is worse than no fix.
|
|
2025
|
+
*/
|
|
2026
|
+
function createMissingModuleCodeAction({
|
|
2027
|
+
valRoot,
|
|
2028
|
+
moduleFilePath,
|
|
2029
|
+
read
|
|
2030
|
+
}) {
|
|
2031
|
+
for (const candidate of ["val.modules.ts", "val.modules.js"]) {
|
|
2032
|
+
const file = path__default["default"].join(valRoot, candidate);
|
|
2033
|
+
let text = read(file);
|
|
2034
|
+
if (text === undefined) {
|
|
2035
|
+
try {
|
|
2036
|
+
text = fs__default["default"].readFileSync(file, "utf8");
|
|
2037
|
+
} catch {
|
|
2038
|
+
continue;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
const sourceFile = ts__default["default"].createSourceFile(file, text, ts__default["default"].ScriptTarget.ES2020, true);
|
|
2042
|
+
const insertion = findValModulesInsertion(sourceFile);
|
|
2043
|
+
if (!insertion) {
|
|
2044
|
+
return undefined;
|
|
2045
|
+
}
|
|
2046
|
+
const specifier = valModuleSpecifier({
|
|
2047
|
+
// The val.modules file sits at the Val root, so its Val-style path is its
|
|
2048
|
+
// bare filename with a leading slash.
|
|
2049
|
+
valModulesFilePath: `/${candidate}`,
|
|
2050
|
+
moduleFilePath: moduleFilePath
|
|
2051
|
+
});
|
|
2052
|
+
const position = sourceFile.getLineAndCharacterOfPosition(insertion.insertOffset);
|
|
2053
|
+
const edit = {
|
|
2054
|
+
range: {
|
|
2055
|
+
start: position,
|
|
2056
|
+
end: position
|
|
2057
|
+
},
|
|
2058
|
+
newText: valModulesEntryText({
|
|
2059
|
+
specifier,
|
|
2060
|
+
indentation: insertion.indentation,
|
|
2061
|
+
hasElements: insertion.hasElements
|
|
2062
|
+
})
|
|
2063
|
+
};
|
|
2064
|
+
return vscodeLanguageserver.CodeAction.create(`Val: register ${path__default["default"].posix.basename(moduleFilePath)} in ${candidate}`, {
|
|
2065
|
+
changes: {
|
|
2066
|
+
[pathToUri(file)]: [edit]
|
|
2067
|
+
}
|
|
2068
|
+
}, vscodeLanguageserver.CodeActionKind.QuickFix);
|
|
2069
|
+
}
|
|
2070
|
+
return undefined;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* Work out whether a gallery placeholder is hiding a real problem.
|
|
2075
|
+
*
|
|
2076
|
+
* Core attaches `images:check-unique-folder` and `images:check-all-files` to
|
|
2077
|
+
* every gallery module unconditionally — they are requests to go and look, not
|
|
2078
|
+
* findings. `val validate` looks by running the fix handler and then, when the
|
|
2079
|
+
* handler says the membership is fine, by running `createFixPatch` to compare
|
|
2080
|
+
* each entry's stored metadata against its file. Both steps matter:
|
|
2081
|
+
*
|
|
2082
|
+
* - handler `success: false` — a membership problem, with its own message.
|
|
2083
|
+
* - handler `shouldApplyPatch` — membership is fine; the metadata still has to
|
|
2084
|
+
* be checked, and `createFixPatch` returns one `remainingError` per entry
|
|
2085
|
+
* that disagrees with its file.
|
|
2086
|
+
* - anything else — nothing to report, so the placeholder is dropped.
|
|
2087
|
+
*
|
|
2088
|
+
* Doing it exactly this way is the point: an editor that adjudicated these
|
|
2089
|
+
* itself would disagree with the CLI, and the first symptom would be a warning
|
|
2090
|
+
* that appears in one and not the other.
|
|
2091
|
+
*/
|
|
2092
|
+
async function adjudicateGalleryCheck({
|
|
2093
|
+
sourcePath,
|
|
2094
|
+
validationError,
|
|
2095
|
+
moduleFilePath,
|
|
2096
|
+
valRoot,
|
|
2097
|
+
content,
|
|
2098
|
+
runFixHandler,
|
|
2099
|
+
remoteHost = process.env.VAL_REMOTE_HOST || core.DEFAULT_VAL_REMOTE_HOST
|
|
2100
|
+
}) {
|
|
2101
|
+
const outcome = await runFixHandler({
|
|
2102
|
+
moduleFilePath,
|
|
2103
|
+
sourcePath,
|
|
2104
|
+
validationError
|
|
2105
|
+
});
|
|
2106
|
+
if (!outcome) {
|
|
2107
|
+
// No handler, or a project that would not evaluate. Keep the placeholder:
|
|
2108
|
+
// claiming the gallery is fine on no evidence is the worse error.
|
|
2109
|
+
return [{
|
|
2110
|
+
sourcePath,
|
|
2111
|
+
message: validationError.message,
|
|
2112
|
+
...(validationError.fixes ? {
|
|
2113
|
+
fixes: validationError.fixes
|
|
2114
|
+
} : {}),
|
|
2115
|
+
...(validationError.value !== undefined ? {
|
|
2116
|
+
value: validationError.value
|
|
2117
|
+
} : {})
|
|
2118
|
+
}];
|
|
2119
|
+
}
|
|
2120
|
+
if (!outcome.success) {
|
|
2121
|
+
return [{
|
|
2122
|
+
sourcePath,
|
|
2123
|
+
message: outcome.errorMessage ?? validationError.message,
|
|
2124
|
+
...(validationError.fixes ? {
|
|
2125
|
+
fixes: validationError.fixes
|
|
2126
|
+
} : {})
|
|
2127
|
+
}];
|
|
2128
|
+
}
|
|
2129
|
+
if (outcome.fixableErrorMessage) {
|
|
2130
|
+
return [{
|
|
2131
|
+
sourcePath,
|
|
2132
|
+
message: outcome.fixableErrorMessage,
|
|
2133
|
+
...(validationError.fixes ? {
|
|
2134
|
+
fixes: validationError.fixes
|
|
2135
|
+
} : {})
|
|
2136
|
+
}];
|
|
2137
|
+
}
|
|
2138
|
+
if (!outcome.shouldApplyPatch) {
|
|
2139
|
+
return [];
|
|
2140
|
+
}
|
|
2141
|
+
let fixed;
|
|
2142
|
+
try {
|
|
2143
|
+
fixed = await server.createFixPatch({
|
|
2144
|
+
projectRoot: valRoot,
|
|
2145
|
+
remoteHost
|
|
2146
|
+
},
|
|
2147
|
+
// `false`: this is a question, not a fix. Asking for the patch would have
|
|
2148
|
+
// createFixPatch read and rewrite files behind the editor's back.
|
|
2149
|
+
false, sourcePath, validationError, {}, content.source, content.schema);
|
|
2150
|
+
} catch {
|
|
2151
|
+
return [];
|
|
2152
|
+
}
|
|
2153
|
+
return (fixed?.remainingErrors ?? []).map(error => ({
|
|
2154
|
+
// A gallery check expands into per-entry errors carrying their own path;
|
|
2155
|
+
// fall back to the record's path when one does not.
|
|
2156
|
+
sourcePath: error.sourcePath ?? sourcePath,
|
|
2157
|
+
message: error.message,
|
|
2158
|
+
...(error.fixes ? {
|
|
2159
|
+
fixes: error.fixes
|
|
2160
|
+
} : {}),
|
|
2161
|
+
// The fix runs against the record, not the entry: `createFixPatch`'s gallery
|
|
2162
|
+
// branch walks every entry itself and builds patch paths from the record's
|
|
2163
|
+
// path, so handing it the entry's path would write to the wrong place.
|
|
2164
|
+
fixSourcePath: sourcePath,
|
|
2165
|
+
...(validationError.value !== undefined ? {
|
|
2166
|
+
value: validationError.value
|
|
2167
|
+
} : {})
|
|
2168
|
+
}));
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/**
|
|
2172
|
+
* Works out what the cursor is sitting in, so completions can be offered for it.
|
|
2173
|
+
*
|
|
2174
|
+
* AST-based rather than text/regex-based: an object literal can be nested,
|
|
2175
|
+
* wrapped or multi-line, and matching on text gets that wrong in exactly the
|
|
2176
|
+
* cases where a user most wants help.
|
|
2177
|
+
*/
|
|
2178
|
+
|
|
2179
|
+
/** The cursor is inside a plain string in the module's content. */
|
|
2180
|
+
|
|
2181
|
+
/**
|
|
2182
|
+
* The offsets of a string literal's contents, excluding its quotes.
|
|
2183
|
+
*
|
|
2184
|
+
* A client that does not auto-close quotes leaves `path: "` unterminated while
|
|
2185
|
+
* the user types. TypeScript still produces a string-literal node for it, but the
|
|
2186
|
+
* node ends *at* the cursor rather than one character past it, so the usual
|
|
2187
|
+
* `getEnd() - 1` bound excludes every position inside the literal and no
|
|
2188
|
+
* completions are offered at all. The closing quote is therefore counted rather
|
|
2189
|
+
* than assumed.
|
|
2190
|
+
*/
|
|
2191
|
+
function contentRangeOf(node, sourceFile) {
|
|
2192
|
+
const raw = node.getText(sourceFile);
|
|
2193
|
+
const quote = raw[0];
|
|
2194
|
+
let closing = 0;
|
|
2195
|
+
if (raw.length >= 2 && raw[raw.length - 1] === quote) {
|
|
2196
|
+
// A quote preceded by an odd number of backslashes is escaped, so it is part
|
|
2197
|
+
// of the contents rather than the terminator.
|
|
2198
|
+
let backslashes = 0;
|
|
2199
|
+
for (let i = raw.length - 2; i >= 1 && raw[i] === "\\"; i--) {
|
|
2200
|
+
backslashes++;
|
|
2201
|
+
}
|
|
2202
|
+
closing = backslashes % 2 === 0 ? 1 : 0;
|
|
2203
|
+
}
|
|
2204
|
+
return {
|
|
2205
|
+
contentStart: node.getStart(sourceFile) + 1,
|
|
2206
|
+
contentEnd: node.getEnd() - closing
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* The innermost string literal containing `offset`, described well enough for a
|
|
2212
|
+
* schema-driven completion to decide whether it applies.
|
|
2213
|
+
*/
|
|
2214
|
+
function getValCompletionContext(sourceFile, offset) {
|
|
2215
|
+
let innermostString;
|
|
2216
|
+
let innermostStringContent;
|
|
2217
|
+
// Tracked explicitly: `ts.createSourceFile` does not set parent pointers
|
|
2218
|
+
// unless asked, so `node.parent` cannot be relied on here.
|
|
2219
|
+
let innermostStringParent;
|
|
2220
|
+
function visit(node, parent) {
|
|
2221
|
+
if (offset < node.getStart(sourceFile) || offset > node.getEnd()) {
|
|
2222
|
+
return;
|
|
1325
2223
|
}
|
|
1326
2224
|
if (ts__default["default"].isStringLiteralLike(node)) {
|
|
1327
2225
|
const content = contentRangeOf(node, sourceFile);
|
|
@@ -1334,56 +2232,62 @@ function getValCompletionContext(sourceFile, offset) {
|
|
|
1334
2232
|
ts__default["default"].forEachChild(node, child => visit(child, node));
|
|
1335
2233
|
}
|
|
1336
2234
|
visit(sourceFile, undefined);
|
|
1337
|
-
if (
|
|
1338
|
-
return
|
|
1339
|
-
}
|
|
1340
|
-
// Not a file reference, but still inside a string: schema-driven completions
|
|
1341
|
-
// (keyOf keys, route paths) decide whether they apply.
|
|
1342
|
-
if (innermostString && innermostStringContent) {
|
|
1343
|
-
return {
|
|
1344
|
-
kind: "string-value",
|
|
1345
|
-
currentText: innermostString.text,
|
|
1346
|
-
...innermostStringContent,
|
|
1347
|
-
// Uses the parent tracked during the walk, not `node.parent`, which
|
|
1348
|
-
// `ts.createSourceFile` leaves unset unless asked to populate it.
|
|
1349
|
-
isPropertyName: Boolean(innermostStringParent && ts__default["default"].isPropertyAssignment(innermostStringParent) && innermostStringParent.name === innermostString),
|
|
1350
|
-
...(innermostStringParent && ts__default["default"].isPropertyAssignment(innermostStringParent) && innermostStringParent.name !== innermostString && (ts__default["default"].isIdentifier(innermostStringParent.name) || ts__default["default"].isStringLiteral(innermostStringParent.name)) ? {
|
|
1351
|
-
valueOfProperty: innermostStringParent.name.text
|
|
1352
|
-
} : {})
|
|
1353
|
-
};
|
|
2235
|
+
if (!innermostString || !innermostStringContent) {
|
|
2236
|
+
return undefined;
|
|
1354
2237
|
}
|
|
1355
|
-
return
|
|
2238
|
+
return {
|
|
2239
|
+
kind: "string-value",
|
|
2240
|
+
currentText: innermostString.text,
|
|
2241
|
+
...innermostStringContent,
|
|
2242
|
+
// Uses the parent tracked during the walk, not `node.parent`, which
|
|
2243
|
+
// `ts.createSourceFile` leaves unset unless asked to populate it.
|
|
2244
|
+
isPropertyName: Boolean(innermostStringParent && ts__default["default"].isPropertyAssignment(innermostStringParent) && innermostStringParent.name === innermostString),
|
|
2245
|
+
...(innermostStringParent && ts__default["default"].isPropertyAssignment(innermostStringParent) && innermostStringParent.name !== innermostString && (ts__default["default"].isIdentifier(innermostStringParent.name) || ts__default["default"].isStringLiteral(innermostStringParent.name)) ? {
|
|
2246
|
+
valueOfProperty: innermostStringParent.name.text
|
|
2247
|
+
} : {})
|
|
2248
|
+
};
|
|
1356
2249
|
}
|
|
1357
2250
|
|
|
2251
|
+
/** The properties Val computes from a file's bytes. */
|
|
2252
|
+
const MEDIA_METADATA_KEYS = ["width", "height", "mimeType"];
|
|
1358
2253
|
/**
|
|
1359
|
-
* Re-find the
|
|
1360
|
-
*
|
|
2254
|
+
* Re-find the media object literal whose `path` value starts at
|
|
2255
|
+
* `pathValueStart`, and report where its metadata siblings are *now*.
|
|
1361
2256
|
*
|
|
1362
2257
|
* `completionItem/resolve` runs against a document the user may have typed into
|
|
1363
2258
|
* since the list was computed, so the offsets captured back then have moved.
|
|
1364
|
-
* Applying them anyway inserts
|
|
1365
|
-
*
|
|
2259
|
+
* Applying them anyway inserts text into the middle of the string literal and
|
|
2260
|
+
* corrupts the file, so the offsets are re-derived here instead.
|
|
1366
2261
|
*
|
|
1367
|
-
* Returns `undefined` when no such
|
|
1368
|
-
* way this anchor does not survive, and the caller must then offer no edit
|
|
2262
|
+
* Returns `undefined` when no such object is found — the document changed in
|
|
2263
|
+
* some way this anchor does not survive, and the caller must then offer no edit
|
|
1369
2264
|
* rather than a wrong one.
|
|
1370
2265
|
*/
|
|
1371
|
-
function
|
|
2266
|
+
function findMediaPathObject(sourceFile, pathValueStart) {
|
|
1372
2267
|
let found;
|
|
1373
2268
|
function visit(node) {
|
|
1374
2269
|
if (found) {
|
|
1375
2270
|
return;
|
|
1376
2271
|
}
|
|
1377
|
-
if (ts__default["default"].
|
|
1378
|
-
const
|
|
1379
|
-
if (
|
|
1380
|
-
const
|
|
2272
|
+
if (ts__default["default"].isObjectLiteralExpression(node)) {
|
|
2273
|
+
const pathAssignment = node.properties.find(property => ts__default["default"].isPropertyAssignment(property) && nameOf(property) === "path" && property.initializer.getStart(sourceFile) === pathValueStart);
|
|
2274
|
+
if (pathAssignment) {
|
|
2275
|
+
const existing = {};
|
|
2276
|
+
for (const property of node.properties) {
|
|
2277
|
+
if (!ts__default["default"].isPropertyAssignment(property)) {
|
|
2278
|
+
continue;
|
|
2279
|
+
}
|
|
2280
|
+
const name = nameOf(property);
|
|
2281
|
+
if (name && MEDIA_METADATA_KEYS.includes(name)) {
|
|
2282
|
+
existing[name] = {
|
|
2283
|
+
start: property.initializer.getStart(sourceFile),
|
|
2284
|
+
end: property.initializer.getEnd()
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
1381
2288
|
found = {
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
metadataStart: metadataArg.getStart(sourceFile),
|
|
1385
|
-
metadataEnd: metadataArg.getEnd()
|
|
1386
|
-
} : {})
|
|
2289
|
+
insertAfter: pathAssignment.getEnd(),
|
|
2290
|
+
existing
|
|
1387
2291
|
};
|
|
1388
2292
|
return;
|
|
1389
2293
|
}
|
|
@@ -1393,22 +2297,18 @@ function findFileRefArgument(sourceFile, refArgStart) {
|
|
|
1393
2297
|
visit(sourceFile);
|
|
1394
2298
|
return found;
|
|
1395
2299
|
}
|
|
1396
|
-
function
|
|
1397
|
-
if (
|
|
1398
|
-
return
|
|
1399
|
-
}
|
|
1400
|
-
if (node.expression.expression.getText(sourceFile) !== "c") {
|
|
1401
|
-
return undefined;
|
|
2300
|
+
function nameOf(property) {
|
|
2301
|
+
if (ts__default["default"].isIdentifier(property.name) || ts__default["default"].isStringLiteral(property.name)) {
|
|
2302
|
+
return property.name.text;
|
|
1402
2303
|
}
|
|
1403
|
-
|
|
1404
|
-
return name === "image" || name === "file" ? name : undefined;
|
|
2304
|
+
return undefined;
|
|
1405
2305
|
}
|
|
1406
2306
|
|
|
1407
2307
|
/**
|
|
1408
2308
|
* Completions for file and image references.
|
|
1409
2309
|
*
|
|
1410
2310
|
* Offers the files that actually exist under the project's files directory, and
|
|
1411
|
-
* — when the item is accepted — fills in the metadata
|
|
2311
|
+
* — when the item is accepted — fills in the metadata siblings by reading the
|
|
1412
2312
|
* chosen file. Getting width/height/mimeType right by hand is tedious and a
|
|
1413
2313
|
* frequent source of the very validation errors this server reports.
|
|
1414
2314
|
*/
|
|
@@ -1427,60 +2327,25 @@ function createValCompletions({
|
|
|
1427
2327
|
if (!context) {
|
|
1428
2328
|
return [];
|
|
1429
2329
|
}
|
|
1430
|
-
if (
|
|
1431
|
-
|
|
1432
|
-
return [];
|
|
1433
|
-
}
|
|
1434
|
-
return createSchemaDrivenCompletions({
|
|
1435
|
-
document,
|
|
1436
|
-
sourceFile,
|
|
1437
|
-
offset,
|
|
1438
|
-
moduleFilePath,
|
|
1439
|
-
snapshot,
|
|
1440
|
-
files,
|
|
1441
|
-
isPropertyName: context.isPropertyName,
|
|
1442
|
-
valueOfProperty: context.valueOfProperty,
|
|
1443
|
-
contentStart: context.contentStart,
|
|
1444
|
-
contentEnd: context.contentEnd
|
|
1445
|
-
});
|
|
2330
|
+
if (!moduleFilePath || !snapshot) {
|
|
2331
|
+
return [];
|
|
1446
2332
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
const data = {
|
|
1459
|
-
kind: "file-ref",
|
|
1460
|
-
uri: document.uri,
|
|
1461
|
-
ref: file.ref,
|
|
1462
|
-
filePath: file.filePath,
|
|
1463
|
-
subType: context.subType,
|
|
1464
|
-
refArgStart: context.refArgStart
|
|
1465
|
-
};
|
|
1466
|
-
return {
|
|
1467
|
-
label: file.ref,
|
|
1468
|
-
kind: vscodeLanguageserver.CompletionItemKind.File,
|
|
1469
|
-
detail: file.mimeType,
|
|
1470
|
-
textEdit: {
|
|
1471
|
-
range: replaceRange,
|
|
1472
|
-
newText: file.ref
|
|
1473
|
-
},
|
|
1474
|
-
// Preserve directory ordering rather than letting the client sort
|
|
1475
|
-
// alphabetically on the full path.
|
|
1476
|
-
sortText: String(index).padStart(5, "0"),
|
|
1477
|
-
data
|
|
1478
|
-
};
|
|
2333
|
+
return createSchemaDrivenCompletions({
|
|
2334
|
+
document,
|
|
2335
|
+
sourceFile,
|
|
2336
|
+
offset,
|
|
2337
|
+
moduleFilePath,
|
|
2338
|
+
snapshot,
|
|
2339
|
+
files,
|
|
2340
|
+
isPropertyName: context.isPropertyName,
|
|
2341
|
+
valueOfProperty: context.valueOfProperty,
|
|
2342
|
+
contentStart: context.contentStart,
|
|
2343
|
+
contentEnd: context.contentEnd
|
|
1479
2344
|
});
|
|
1480
2345
|
}
|
|
1481
2346
|
|
|
1482
2347
|
/**
|
|
1483
|
-
* Fill in the metadata
|
|
2348
|
+
* Fill in the metadata siblings for an accepted media path.
|
|
1484
2349
|
*
|
|
1485
2350
|
* Done at resolve time because it reads the file from disk, and an editor
|
|
1486
2351
|
* requests completions far more often than it accepts one.
|
|
@@ -1490,7 +2355,13 @@ async function resolveValCompletion({
|
|
|
1490
2355
|
documents
|
|
1491
2356
|
}) {
|
|
1492
2357
|
const data = item.data;
|
|
1493
|
-
if (data?.kind !== "
|
|
2358
|
+
if (data?.kind !== "media-path") {
|
|
2359
|
+
return item;
|
|
2360
|
+
}
|
|
2361
|
+
// A gallery-backed field stores only the path: its dimensions and mime type
|
|
2362
|
+
// live in the gallery module, and writing them here too is how two copies of
|
|
2363
|
+
// one fact get to disagree.
|
|
2364
|
+
if (data.gallery) {
|
|
1494
2365
|
return item;
|
|
1495
2366
|
}
|
|
1496
2367
|
const document = documents.get(data.uri);
|
|
@@ -1498,12 +2369,12 @@ async function resolveValCompletion({
|
|
|
1498
2369
|
return item;
|
|
1499
2370
|
}
|
|
1500
2371
|
|
|
1501
|
-
// Re-derive the
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
const
|
|
1506
|
-
if (!
|
|
2372
|
+
// Re-derive the offsets against the document as it is *now*: the user may
|
|
2373
|
+
// have typed to filter the list since it was computed, which moves everything
|
|
2374
|
+
// after the path string. `additionalTextEdits` are applied verbatim by the
|
|
2375
|
+
// client, so a stale offset here corrupts the file.
|
|
2376
|
+
const object = findMediaPathObject(ts__default["default"].createSourceFile(data.uri, document.getText(), ts__default["default"].ScriptTarget.ES2020, false, ts__default["default"].ScriptKind.TS), data.pathValueStart);
|
|
2377
|
+
if (!object) {
|
|
1507
2378
|
// The anchor no longer resolves, so there is no safe place to put the
|
|
1508
2379
|
// metadata. `val validate --fix` and the metadata quick fix still cover it.
|
|
1509
2380
|
return item;
|
|
@@ -1512,24 +2383,43 @@ async function resolveValCompletion({
|
|
|
1512
2383
|
if (!metadata) {
|
|
1513
2384
|
return item;
|
|
1514
2385
|
}
|
|
1515
|
-
const
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
2386
|
+
const edits = [];
|
|
2387
|
+
const missing = [];
|
|
2388
|
+
for (const key of MEDIA_METADATA_KEYS) {
|
|
2389
|
+
const value = metadata[key];
|
|
2390
|
+
if (value === undefined) {
|
|
2391
|
+
continue;
|
|
2392
|
+
}
|
|
2393
|
+
const at = object.existing[key];
|
|
2394
|
+
if (at) {
|
|
2395
|
+
edits.push({
|
|
2396
|
+
range: {
|
|
2397
|
+
start: document.positionAt(at.start),
|
|
2398
|
+
end: document.positionAt(at.end)
|
|
2399
|
+
},
|
|
2400
|
+
newText: value
|
|
2401
|
+
});
|
|
2402
|
+
} else {
|
|
2403
|
+
missing.push(`${key}: ${value}`);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
if (missing.length > 0) {
|
|
2407
|
+
// One insertion after the `path` property, so the edits stay disjoint —
|
|
2408
|
+
// a client applies them verbatim and two overlapping ranges corrupt the file.
|
|
2409
|
+
edits.push({
|
|
2410
|
+
range: {
|
|
2411
|
+
start: document.positionAt(object.insertAfter),
|
|
2412
|
+
end: document.positionAt(object.insertAfter)
|
|
2413
|
+
},
|
|
2414
|
+
newText: `, ${missing.join(", ")}`
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
if (edits.length === 0) {
|
|
2418
|
+
return item;
|
|
2419
|
+
}
|
|
1530
2420
|
return {
|
|
1531
2421
|
...item,
|
|
1532
|
-
additionalTextEdits:
|
|
2422
|
+
additionalTextEdits: edits
|
|
1533
2423
|
};
|
|
1534
2424
|
}
|
|
1535
2425
|
|
|
@@ -1542,18 +2432,24 @@ async function resolveValCompletion({
|
|
|
1542
2432
|
async function readMetadata(data) {
|
|
1543
2433
|
try {
|
|
1544
2434
|
const buffer = fs__default["default"].readFileSync(data.filePath);
|
|
1545
|
-
if (data.
|
|
2435
|
+
if (data.mediaType === "image") {
|
|
1546
2436
|
const metadata = await server.extractImageMetadata(data.filePath, buffer);
|
|
1547
2437
|
if (metadata.width === undefined || metadata.height === undefined || !metadata.mimeType) {
|
|
1548
2438
|
return undefined;
|
|
1549
2439
|
}
|
|
1550
|
-
return
|
|
2440
|
+
return {
|
|
2441
|
+
width: String(metadata.width),
|
|
2442
|
+
height: String(metadata.height),
|
|
2443
|
+
mimeType: JSON.stringify(metadata.mimeType)
|
|
2444
|
+
};
|
|
1551
2445
|
}
|
|
1552
2446
|
const metadata = await server.extractFileMetadata(data.filePath, buffer);
|
|
1553
2447
|
if (!metadata.mimeType) {
|
|
1554
2448
|
return undefined;
|
|
1555
2449
|
}
|
|
1556
|
-
return
|
|
2450
|
+
return {
|
|
2451
|
+
mimeType: JSON.stringify(metadata.mimeType)
|
|
2452
|
+
};
|
|
1557
2453
|
} catch {
|
|
1558
2454
|
// An unreadable or unrecognised file just means no metadata to offer.
|
|
1559
2455
|
return undefined;
|
|
@@ -1612,6 +2508,27 @@ function createSchemaDrivenCompletions({
|
|
|
1612
2508
|
const galleryFiles = container.mediaType === "images" ? files.images(directory) : files.list(directory);
|
|
1613
2509
|
return items(galleryFiles.map(file => file.ref), vscodeLanguageserver.CompletionItemKind.File, range);
|
|
1614
2510
|
}
|
|
2511
|
+
|
|
2512
|
+
// Media is an object literal with a `path`, so the cursor's own module path is
|
|
2513
|
+
// `…."image"."path"` — resolving that would try to descend INTO the image
|
|
2514
|
+
// schema. The container is what says whether this is media at all, and which
|
|
2515
|
+
// kind, and which directory its files come from. Before this was an object,
|
|
2516
|
+
// the callee name (`c.image` vs `c.file`) said so, and per-field `directory`
|
|
2517
|
+
// was ignored.
|
|
2518
|
+
if (!isPropertyName && valueOfProperty === "path") {
|
|
2519
|
+
const container = resolveSchemaAt(parentModulePath(modulePath), source, schema);
|
|
2520
|
+
if (container && typeof container === "object" && "type" in container && (container.type === "image" || container.type === "file")) {
|
|
2521
|
+
return mediaPathItems({
|
|
2522
|
+
document,
|
|
2523
|
+
container,
|
|
2524
|
+
snapshot,
|
|
2525
|
+
files,
|
|
2526
|
+
range,
|
|
2527
|
+
contentStart
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
return [];
|
|
2531
|
+
}
|
|
1615
2532
|
const fieldSchema = resolveSchemaAt(modulePath, source, schema);
|
|
1616
2533
|
|
|
1617
2534
|
// Checked before the schema is required, because Val describes richtext content
|
|
@@ -1637,6 +2554,58 @@ function createSchemaDrivenCompletions({
|
|
|
1637
2554
|
return [];
|
|
1638
2555
|
}
|
|
1639
2556
|
|
|
2557
|
+
/**
|
|
2558
|
+
* The files a media field can point at, as completion items.
|
|
2559
|
+
*
|
|
2560
|
+
* Where they come from, in order: the field's own `directory`, then the
|
|
2561
|
+
* directory of the gallery it references, then the default.
|
|
2562
|
+
*/
|
|
2563
|
+
function mediaPathItems({
|
|
2564
|
+
document,
|
|
2565
|
+
container,
|
|
2566
|
+
snapshot,
|
|
2567
|
+
files,
|
|
2568
|
+
range,
|
|
2569
|
+
contentStart
|
|
2570
|
+
}) {
|
|
2571
|
+
const referencedModule = typeof container.referencedModule === "string" ? container.referencedModule : undefined;
|
|
2572
|
+
const options = container.options;
|
|
2573
|
+
let directory = typeof options?.directory === "string" ? options.directory : undefined;
|
|
2574
|
+
if (directory === undefined && referencedModule) {
|
|
2575
|
+
const gallery = snapshot.schemas[referencedModule];
|
|
2576
|
+
if (gallery?.type === "record" && typeof gallery.directory === "string") {
|
|
2577
|
+
directory = gallery.directory;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
const candidates = container.type === "image" ? files.images(directory) : files.list(directory);
|
|
2581
|
+
return candidates.map((file, index) => {
|
|
2582
|
+
const data = {
|
|
2583
|
+
kind: "media-path",
|
|
2584
|
+
uri: document.uri,
|
|
2585
|
+
ref: file.ref,
|
|
2586
|
+
filePath: file.filePath,
|
|
2587
|
+
mediaType: container.type,
|
|
2588
|
+
gallery: referencedModule !== undefined,
|
|
2589
|
+
pathValueStart: contentStart - 1
|
|
2590
|
+
};
|
|
2591
|
+
return {
|
|
2592
|
+
label: file.ref,
|
|
2593
|
+
kind: vscodeLanguageserver.CompletionItemKind.File,
|
|
2594
|
+
detail: file.mimeType,
|
|
2595
|
+
// Replace the whole string contents rather than inserting at the cursor,
|
|
2596
|
+
// so completing over an existing path does not concatenate the two.
|
|
2597
|
+
textEdit: {
|
|
2598
|
+
range,
|
|
2599
|
+
newText: file.ref
|
|
2600
|
+
},
|
|
2601
|
+
// Preserve directory ordering rather than letting the client sort
|
|
2602
|
+
// alphabetically on the full path.
|
|
2603
|
+
sortText: String(index).padStart(5, "0"),
|
|
2604
|
+
data
|
|
2605
|
+
};
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
|
|
1640
2609
|
/** The routes the project defines, as completion items. */
|
|
1641
2610
|
function routeItems(snapshot, range) {
|
|
1642
2611
|
// The routes that exist are the keys of the project's router modules, which is
|
|
@@ -1690,57 +2659,306 @@ function permitsInlineLinks(richtext) {
|
|
|
1690
2659
|
return Boolean(options?.inline?.a);
|
|
1691
2660
|
}
|
|
1692
2661
|
|
|
1693
|
-
/** Schema at a module path, or undefined when it cannot be resolved. */
|
|
1694
|
-
function resolveSchemaAt(modulePath, source, schema) {
|
|
2662
|
+
/** Schema at a module path, or undefined when it cannot be resolved. */
|
|
2663
|
+
function resolveSchemaAt(modulePath, source, schema) {
|
|
2664
|
+
try {
|
|
2665
|
+
// Val's own resolver, rather than a hand-rolled serialized-schema walker.
|
|
2666
|
+
return core.Internal.resolvePath(modulePath, source, schema).schema;
|
|
2667
|
+
} catch {
|
|
2668
|
+
return undefined;
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
/** Drop the last segment of a module path; `""` is the module root. */
|
|
2673
|
+
function parentModulePath(modulePath) {
|
|
2674
|
+
const segments = core.Internal.splitModulePath(modulePath);
|
|
2675
|
+
return core.Internal.patchPathToModulePath(segments.slice(0, -1));
|
|
2676
|
+
}
|
|
2677
|
+
function items(labels, kind, range) {
|
|
2678
|
+
return labels.map((label, index) => ({
|
|
2679
|
+
label,
|
|
2680
|
+
kind,
|
|
2681
|
+
textEdit: {
|
|
2682
|
+
range,
|
|
2683
|
+
newText: label
|
|
2684
|
+
},
|
|
2685
|
+
// Preserve the source ordering rather than letting the client re-sort.
|
|
2686
|
+
sortText: String(index).padStart(5, "0")
|
|
2687
|
+
}));
|
|
2688
|
+
}
|
|
2689
|
+
function keysOfKeyOf(schema, snapshot) {
|
|
2690
|
+
// Object targets serialize their keys directly.
|
|
2691
|
+
if (Array.isArray(schema.values)) {
|
|
2692
|
+
return schema.values;
|
|
2693
|
+
}
|
|
2694
|
+
// Record targets say "string"; the keys are whatever the target module holds.
|
|
2695
|
+
if (!schema.path) {
|
|
2696
|
+
return [];
|
|
2697
|
+
}
|
|
2698
|
+
try {
|
|
2699
|
+
const [targetModuleFilePath, targetModulePath] = core.Internal.splitModuleFilePathAndModulePath(schema.path);
|
|
2700
|
+
const targetSchema = snapshot.schemas[targetModuleFilePath];
|
|
2701
|
+
const targetSource = snapshot.sources[targetModuleFilePath];
|
|
2702
|
+
if (!targetSchema || targetSource === undefined) {
|
|
2703
|
+
return [];
|
|
2704
|
+
}
|
|
2705
|
+
const resolved = core.Internal.resolvePath(targetModulePath, targetSource, targetSchema);
|
|
2706
|
+
const value = resolved.source;
|
|
2707
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2708
|
+
return [];
|
|
2709
|
+
}
|
|
2710
|
+
return Object.keys(value);
|
|
2711
|
+
} catch {
|
|
2712
|
+
return [];
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
/**
|
|
2717
|
+
* The two remedies for a gallery-backed field pointing at something its gallery
|
|
2718
|
+
* does not have.
|
|
2719
|
+
*
|
|
2720
|
+
* Core reports the problem (`packages/core/src/schema/image.ts`) and offers no
|
|
2721
|
+
* `ValidationFix`, because neither remedy is a change to the module holding the
|
|
2722
|
+
* field:
|
|
2723
|
+
*
|
|
2724
|
+
* - **register it** — add an entry to the *gallery* module, keyed by the path,
|
|
2725
|
+
* carrying the metadata read from the file. An edit to another document.
|
|
2726
|
+
* - **move the file** — when the file is on disk but outside the gallery's
|
|
2727
|
+
* directory, move it there and update the path. A file rename plus an edit.
|
|
2728
|
+
*
|
|
2729
|
+
* Both used to be VS Code commands in the extension, driven by diagnostics the
|
|
2730
|
+
* extension computed itself. They are here so that every editor gets them, and
|
|
2731
|
+
* so that a change to how galleries work has one place to be reflected.
|
|
2732
|
+
*/
|
|
2733
|
+
|
|
2734
|
+
/**
|
|
2735
|
+
* Whether the client will honour a file rename inside a `WorkspaceEdit`.
|
|
2736
|
+
*
|
|
2737
|
+
* A `RenameFile` sent to a client that did not announce `resourceOperations`
|
|
2738
|
+
* is silently dropped, which would leave the path rewritten and the file where it
|
|
2739
|
+
* was — worse than not offering the fix.
|
|
2740
|
+
*/
|
|
2741
|
+
function canRenameFiles(capabilities) {
|
|
2742
|
+
const workspace = capabilities?.workspace;
|
|
2743
|
+
const operations = workspace?.workspaceEdit?.resourceOperations;
|
|
2744
|
+
return Array.isArray(operations) && operations.includes("rename");
|
|
2745
|
+
}
|
|
2746
|
+
async function createGalleryMembershipActions({
|
|
2747
|
+
document,
|
|
2748
|
+
gallery,
|
|
2749
|
+
valRoot,
|
|
2750
|
+
read,
|
|
2751
|
+
allowRename
|
|
2752
|
+
}) {
|
|
2753
|
+
const actions = [];
|
|
2754
|
+
const absolute = path__default["default"].join(valRoot, gallery.path);
|
|
2755
|
+
const onDisk = fs__default["default"].existsSync(absolute);
|
|
2756
|
+
const register = await createRegisterInGalleryAction({
|
|
2757
|
+
gallery,
|
|
2758
|
+
valRoot,
|
|
2759
|
+
read,
|
|
2760
|
+
onDisk
|
|
2761
|
+
});
|
|
2762
|
+
if (register) {
|
|
2763
|
+
actions.push(register);
|
|
2764
|
+
}
|
|
2765
|
+
if (allowRename && gallery.directory && onDisk) {
|
|
2766
|
+
const move = createMoveIntoGalleryDirectoryAction({
|
|
2767
|
+
document,
|
|
2768
|
+
gallery,
|
|
2769
|
+
valRoot
|
|
2770
|
+
});
|
|
2771
|
+
if (move) {
|
|
2772
|
+
actions.push(move);
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
return actions;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
/** Add `"<path>": { …metadata }` to the gallery module's record. */
|
|
2779
|
+
async function createRegisterInGalleryAction({
|
|
2780
|
+
gallery,
|
|
2781
|
+
valRoot,
|
|
2782
|
+
read,
|
|
2783
|
+
onDisk
|
|
2784
|
+
}) {
|
|
2785
|
+
if (!onDisk) {
|
|
2786
|
+
// Registering a path with no file behind it would trade this diagnostic for
|
|
2787
|
+
// a "file does not exist" one.
|
|
2788
|
+
return undefined;
|
|
2789
|
+
}
|
|
2790
|
+
if (gallery.directory && !gallery.path.startsWith(`${gallery.directory}/`)) {
|
|
2791
|
+
// Outside the gallery's directory: registering it would break the gallery's
|
|
2792
|
+
// own directory check. Moving is the remedy, not registering.
|
|
2793
|
+
return undefined;
|
|
2794
|
+
}
|
|
2795
|
+
const galleryFile = path__default["default"].join(valRoot, gallery.referencedModule);
|
|
2796
|
+
const text = read(galleryFile) ?? readFileOrUndefined(galleryFile);
|
|
2797
|
+
if (text === undefined) {
|
|
2798
|
+
return undefined;
|
|
2799
|
+
}
|
|
2800
|
+
const sourceFile = ts__default["default"].createSourceFile(galleryFile, text, ts__default["default"].ScriptTarget.ES2020, true);
|
|
2801
|
+
const insertion = findRecordInsertion(sourceFile);
|
|
2802
|
+
if (!insertion) {
|
|
2803
|
+
return undefined;
|
|
2804
|
+
}
|
|
2805
|
+
const metadata = await readMetadataSource(path__default["default"].join(valRoot, gallery.path), gallery.mediaType);
|
|
2806
|
+
if (!metadata) {
|
|
2807
|
+
return undefined;
|
|
2808
|
+
}
|
|
2809
|
+
const position = sourceFile.getLineAndCharacterOfPosition(insertion.insertOffset);
|
|
2810
|
+
const entry = `${JSON.stringify(gallery.path)}: { ${metadata} }`;
|
|
2811
|
+
const edit = {
|
|
2812
|
+
range: {
|
|
2813
|
+
start: position,
|
|
2814
|
+
end: position
|
|
2815
|
+
},
|
|
2816
|
+
newText: insertion.hasProperties ? `,\n${insertion.indentation}${entry}` : `\n${insertion.indentation}${entry}\n${insertion.indentation.slice(2)}`
|
|
2817
|
+
};
|
|
2818
|
+
return vscodeLanguageserver.CodeAction.create(`Val: add ${path__default["default"].posix.basename(gallery.path)} to the gallery`, {
|
|
2819
|
+
changes: {
|
|
2820
|
+
[pathToUri(galleryFile)]: [edit]
|
|
2821
|
+
}
|
|
2822
|
+
}, vscodeLanguageserver.CodeActionKind.QuickFix);
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
/** Move the file into the gallery's directory, and point the field at it. */
|
|
2826
|
+
function createMoveIntoGalleryDirectoryAction({
|
|
2827
|
+
document,
|
|
2828
|
+
gallery,
|
|
2829
|
+
valRoot
|
|
2830
|
+
}) {
|
|
2831
|
+
const directory = gallery.directory;
|
|
2832
|
+
if (!directory || gallery.path.startsWith(`${directory}/`)) {
|
|
2833
|
+
return undefined;
|
|
2834
|
+
}
|
|
2835
|
+
const target = `${directory}/${path__default["default"].posix.basename(gallery.path)}`;
|
|
2836
|
+
if (fs__default["default"].existsSync(path__default["default"].join(valRoot, target))) {
|
|
2837
|
+
// Something already lives there. Overwriting a different file is not a fix.
|
|
2838
|
+
return undefined;
|
|
2839
|
+
}
|
|
2840
|
+
const range = findPathStringRange(document, gallery.path);
|
|
2841
|
+
if (!range) {
|
|
2842
|
+
return undefined;
|
|
2843
|
+
}
|
|
2844
|
+
return {
|
|
2845
|
+
title: `Val: move ${path__default["default"].posix.basename(gallery.path)} into ${directory}`,
|
|
2846
|
+
kind: vscodeLanguageserver.CodeActionKind.QuickFix,
|
|
2847
|
+
edit: {
|
|
2848
|
+
documentChanges: [{
|
|
2849
|
+
kind: "rename",
|
|
2850
|
+
oldUri: pathToUri(path__default["default"].join(valRoot, gallery.path)),
|
|
2851
|
+
newUri: pathToUri(path__default["default"].join(valRoot, target))
|
|
2852
|
+
}, {
|
|
2853
|
+
textDocument: {
|
|
2854
|
+
uri: document.uri,
|
|
2855
|
+
version: null
|
|
2856
|
+
},
|
|
2857
|
+
edits: [{
|
|
2858
|
+
range,
|
|
2859
|
+
newText: target
|
|
2860
|
+
}]
|
|
2861
|
+
}]
|
|
2862
|
+
}
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/**
|
|
2867
|
+
* The range of the `path` string's *contents* in the field being fixed.
|
|
2868
|
+
*
|
|
2869
|
+
* Located by searching the document text for the path in quotes rather than
|
|
2870
|
+
* through the module path map: the map addresses the value, and what has to be
|
|
2871
|
+
* replaced is the text inside the quotes.
|
|
2872
|
+
*/
|
|
2873
|
+
function findPathStringRange(document, currentPath) {
|
|
2874
|
+
const text = document.getText();
|
|
2875
|
+
for (const quote of ['"', "'"]) {
|
|
2876
|
+
const needle = `${quote}${currentPath}${quote}`;
|
|
2877
|
+
const at = text.indexOf(needle);
|
|
2878
|
+
if (at === -1) {
|
|
2879
|
+
continue;
|
|
2880
|
+
}
|
|
2881
|
+
// A second occurrence means we cannot tell which one the diagnostic is
|
|
2882
|
+
// about, and rewriting the wrong one is worse than offering nothing.
|
|
2883
|
+
if (text.indexOf(needle, at + 1) !== -1) {
|
|
2884
|
+
return undefined;
|
|
2885
|
+
}
|
|
2886
|
+
return {
|
|
2887
|
+
start: document.positionAt(at + 1),
|
|
2888
|
+
end: document.positionAt(at + 1 + currentPath.length)
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
return undefined;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/**
|
|
2895
|
+
* Where to insert into the record that is a gallery module's content -- the third
|
|
2896
|
+
* argument of its `c.define(...)`.
|
|
2897
|
+
*/
|
|
2898
|
+
function findRecordInsertion(sourceFile) {
|
|
2899
|
+
let found = null;
|
|
2900
|
+
function visit(node) {
|
|
2901
|
+
if (found === null && ts__default["default"].isCallExpression(node) && ts__default["default"].isPropertyAccessExpression(node.expression) && node.expression.name.text === "define" && node.arguments.length >= 3) {
|
|
2902
|
+
const record = node.arguments[2];
|
|
2903
|
+
if (ts__default["default"].isObjectLiteralExpression(record)) {
|
|
2904
|
+
if (record.properties.length > 0) {
|
|
2905
|
+
const last = record.properties[record.properties.length - 1];
|
|
2906
|
+
const first = record.properties[0];
|
|
2907
|
+
const {
|
|
2908
|
+
character
|
|
2909
|
+
} = sourceFile.getLineAndCharacterOfPosition(first.getStart(sourceFile));
|
|
2910
|
+
found = {
|
|
2911
|
+
insertOffset: last.end,
|
|
2912
|
+
indentation: " ".repeat(character),
|
|
2913
|
+
hasProperties: true
|
|
2914
|
+
};
|
|
2915
|
+
} else {
|
|
2916
|
+
found = {
|
|
2917
|
+
insertOffset: record.getStart(sourceFile) + 1,
|
|
2918
|
+
indentation: " ",
|
|
2919
|
+
hasProperties: false
|
|
2920
|
+
};
|
|
2921
|
+
}
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
ts__default["default"].forEachChild(node, visit);
|
|
2926
|
+
}
|
|
2927
|
+
visit(sourceFile);
|
|
2928
|
+
return found;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
/**
|
|
2932
|
+
* The metadata for a gallery entry, rendered as source.
|
|
2933
|
+
*
|
|
2934
|
+
* Read with `@valbuild/server`'s extractors -- the same ones `val validate --fix`
|
|
2935
|
+
* and the media-path completion use -- so a registered entry and a fixed one
|
|
2936
|
+
* agree. `alt: null` matches what an upload writes.
|
|
2937
|
+
*/
|
|
2938
|
+
async function readMetadataSource(filePath, mediaType) {
|
|
1695
2939
|
try {
|
|
1696
|
-
|
|
1697
|
-
|
|
2940
|
+
const buffer = fs__default["default"].readFileSync(filePath);
|
|
2941
|
+
if (mediaType === "image") {
|
|
2942
|
+
const metadata = await server.extractImageMetadata(filePath, buffer);
|
|
2943
|
+
if (metadata.width === undefined || metadata.height === undefined || !metadata.mimeType) {
|
|
2944
|
+
return undefined;
|
|
2945
|
+
}
|
|
2946
|
+
return `width: ${metadata.width}, height: ${metadata.height}, mimeType: ${JSON.stringify(metadata.mimeType)}, alt: null`;
|
|
2947
|
+
}
|
|
2948
|
+
const metadata = await server.extractFileMetadata(filePath, buffer);
|
|
2949
|
+
if (!metadata.mimeType) {
|
|
2950
|
+
return undefined;
|
|
2951
|
+
}
|
|
2952
|
+
return `mimeType: ${JSON.stringify(metadata.mimeType)}`;
|
|
1698
2953
|
} catch {
|
|
1699
2954
|
return undefined;
|
|
1700
2955
|
}
|
|
1701
2956
|
}
|
|
1702
|
-
|
|
1703
|
-
/** Drop the last segment of a module path; `""` is the module root. */
|
|
1704
|
-
function parentModulePath(modulePath) {
|
|
1705
|
-
const segments = core.Internal.splitModulePath(modulePath);
|
|
1706
|
-
return core.Internal.patchPathToModulePath(segments.slice(0, -1));
|
|
1707
|
-
}
|
|
1708
|
-
function items(labels, kind, range) {
|
|
1709
|
-
return labels.map((label, index) => ({
|
|
1710
|
-
label,
|
|
1711
|
-
kind,
|
|
1712
|
-
textEdit: {
|
|
1713
|
-
range,
|
|
1714
|
-
newText: label
|
|
1715
|
-
},
|
|
1716
|
-
// Preserve the source ordering rather than letting the client re-sort.
|
|
1717
|
-
sortText: String(index).padStart(5, "0")
|
|
1718
|
-
}));
|
|
1719
|
-
}
|
|
1720
|
-
function keysOfKeyOf(schema, snapshot) {
|
|
1721
|
-
// Object targets serialize their keys directly.
|
|
1722
|
-
if (Array.isArray(schema.values)) {
|
|
1723
|
-
return schema.values;
|
|
1724
|
-
}
|
|
1725
|
-
// Record targets say "string"; the keys are whatever the target module holds.
|
|
1726
|
-
if (!schema.path) {
|
|
1727
|
-
return [];
|
|
1728
|
-
}
|
|
2957
|
+
function readFileOrUndefined(filePath) {
|
|
1729
2958
|
try {
|
|
1730
|
-
|
|
1731
|
-
const targetSchema = snapshot.schemas[targetModuleFilePath];
|
|
1732
|
-
const targetSource = snapshot.sources[targetModuleFilePath];
|
|
1733
|
-
if (!targetSchema || targetSource === undefined) {
|
|
1734
|
-
return [];
|
|
1735
|
-
}
|
|
1736
|
-
const resolved = core.Internal.resolvePath(targetModulePath, targetSource, targetSchema);
|
|
1737
|
-
const value = resolved.source;
|
|
1738
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1739
|
-
return [];
|
|
1740
|
-
}
|
|
1741
|
-
return Object.keys(value);
|
|
2959
|
+
return fs__default["default"].readFileSync(filePath, "utf8");
|
|
1742
2960
|
} catch {
|
|
1743
|
-
return
|
|
2961
|
+
return undefined;
|
|
1744
2962
|
}
|
|
1745
2963
|
}
|
|
1746
2964
|
|
|
@@ -1829,148 +3047,6 @@ function createPublicValFiles({
|
|
|
1829
3047
|
};
|
|
1830
3048
|
}
|
|
1831
3049
|
|
|
1832
|
-
/**
|
|
1833
|
-
* Works out which Val modules a `val.modules.{ts,js}` file registers.
|
|
1834
|
-
*
|
|
1835
|
-
* Val only serves modules listed there, so an unregistered `.val.ts` file
|
|
1836
|
-
* silently does nothing.
|
|
1837
|
-
*
|
|
1838
|
-
* Rather than pattern-matching the accepted authoring shapes — `config.modules([…])`
|
|
1839
|
-
* vs `modules(config, […])`, bare `import("./x.val")` vs
|
|
1840
|
-
* `{ def: () => import("./x.val") }` — this collects *every* dynamic import
|
|
1841
|
-
* specifier in the file. That is deliberate:
|
|
1842
|
-
*
|
|
1843
|
-
* - it covers all current shapes with one rule, and any shape added later;
|
|
1844
|
-
* - when in doubt it over-reports registration, so a new authoring form makes
|
|
1845
|
-
* the diagnostic go quiet rather than firing a false "missing module" on
|
|
1846
|
-
* every file, which is the failure mode that actually hurts.
|
|
1847
|
-
*/
|
|
1848
|
-
function findRegisteredModuleSpecifiers(sourceFile) {
|
|
1849
|
-
const specifiers = [];
|
|
1850
|
-
function visit(node) {
|
|
1851
|
-
if (ts__default["default"].isCallExpression(node) && node.expression.kind === ts__default["default"].SyntaxKind.ImportKeyword) {
|
|
1852
|
-
const [arg] = node.arguments;
|
|
1853
|
-
if (arg && ts__default["default"].isStringLiteralLike(arg)) {
|
|
1854
|
-
specifiers.push(arg.text);
|
|
1855
|
-
}
|
|
1856
|
-
}
|
|
1857
|
-
ts__default["default"].forEachChild(node, visit);
|
|
1858
|
-
}
|
|
1859
|
-
visit(sourceFile);
|
|
1860
|
-
return specifiers;
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
/**
|
|
1864
|
-
* Whether `moduleFilePath` is registered by the given `val.modules` file.
|
|
1865
|
-
*
|
|
1866
|
-
* @param valModulesDir directory containing the val.modules file, relative to
|
|
1867
|
-
* the Val root (`""` when it sits at the root).
|
|
1868
|
-
* @param moduleFilePath the module's path, Val-style: root-relative, leading
|
|
1869
|
-
* slash, with extension (for example `/content/page.val.ts`).
|
|
1870
|
-
*/
|
|
1871
|
-
function isModuleRegistered({
|
|
1872
|
-
sourceFile,
|
|
1873
|
-
valModulesDir,
|
|
1874
|
-
moduleFilePath
|
|
1875
|
-
}) {
|
|
1876
|
-
const target = stripValModuleExtension(moduleFilePath);
|
|
1877
|
-
return findRegisteredModuleSpecifiers(sourceFile).some(specifier => {
|
|
1878
|
-
// Specifiers are written relative to the val.modules file and normally omit
|
|
1879
|
-
// the extension ("./content/page.val").
|
|
1880
|
-
const resolved = specifier.startsWith(".") ? path__default["default"].posix.normalize(path__default["default"].posix.join("/", valModulesDir, stripValModuleExtension(specifier))) : stripValModuleExtension(specifier);
|
|
1881
|
-
return resolved === target;
|
|
1882
|
-
});
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
/** `/content/page.val.ts` -> `/content/page.val` (also handles `.val` already). */
|
|
1886
|
-
function stripValModuleExtension(specifier) {
|
|
1887
|
-
return specifier.replace(/\.val\.(ts|js|tsx|jsx)$/, ".val");
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
/**
|
|
1891
|
-
* Conversions between LSP document URIs and the paths Val uses.
|
|
1892
|
-
*
|
|
1893
|
-
* Deliberately minimal rather than pulling in `vscode-uri`: the server only ever
|
|
1894
|
-
* deals with local `file:` URIs, and keeping this small makes the assumptions
|
|
1895
|
-
* visible.
|
|
1896
|
-
*/
|
|
1897
|
-
|
|
1898
|
-
/** Matches the Val module files the server validates. */
|
|
1899
|
-
const VAL_MODULE_RE = /\.val\.(ts|js|tsx|jsx)$/;
|
|
1900
|
-
|
|
1901
|
-
/** `file:` with an optional authority, capturing authority and path apart. */
|
|
1902
|
-
const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
|
|
1903
|
-
|
|
1904
|
-
/** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
|
|
1905
|
-
const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
|
|
1906
|
-
function isValModuleUri(uri) {
|
|
1907
|
-
return VAL_MODULE_RE.test(uri);
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
/**
|
|
1911
|
-
* `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
|
|
1912
|
-
* one is broken, but that should not take the server down: fall back to the
|
|
1913
|
-
* undecoded text so the path is at worst not found.
|
|
1914
|
-
*/
|
|
1915
|
-
function decodeSafely(value) {
|
|
1916
|
-
try {
|
|
1917
|
-
return decodeURIComponent(value);
|
|
1918
|
-
} catch {
|
|
1919
|
-
return value;
|
|
1920
|
-
}
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
|
-
/**
|
|
1924
|
-
* `file:///a/b.val.ts` -> `/a/b.val.ts`
|
|
1925
|
-
*
|
|
1926
|
-
* Percent escapes are decoded, Windows drive letters lose the leading slash
|
|
1927
|
-
* (`file:///c%3A/a` -> `c:/a`), and a URI with an authority is read as a UNC
|
|
1928
|
-
* path (`file://host/share/a` -> `//host/share/a`). Anything that is not a
|
|
1929
|
-
* `file:` URI is passed through unchanged, since callers also hand us plain
|
|
1930
|
-
* paths.
|
|
1931
|
-
*/
|
|
1932
|
-
function uriToPath(uri) {
|
|
1933
|
-
const match = FILE_URI_RE.exec(uri);
|
|
1934
|
-
if (!match) {
|
|
1935
|
-
return uri;
|
|
1936
|
-
}
|
|
1937
|
-
const authority = decodeSafely(match[1]);
|
|
1938
|
-
const fsPath = decodeSafely(match[2] || "/");
|
|
1939
|
-
if (authority) {
|
|
1940
|
-
return `//${authority}${fsPath}`;
|
|
1941
|
-
}
|
|
1942
|
-
return fsPath.replace(URI_DRIVE_LETTER_RE, "$1$2");
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
/**
|
|
1946
|
-
* `/a/b.val.ts` -> `file:///a/b.val.ts`
|
|
1947
|
-
*
|
|
1948
|
-
* The escaping matches what VS Code produces (drive-letter colons included), so
|
|
1949
|
-
* that a URI built here can be looked up in the open-document map keyed by the
|
|
1950
|
-
* URIs the client sent.
|
|
1951
|
-
*/
|
|
1952
|
-
function pathToUri(fsPath) {
|
|
1953
|
-
const normalized = fsPath.split(path__default["default"].sep).join("/");
|
|
1954
|
-
const rooted = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
1955
|
-
return `file://${rooted.split("/").map(segment => encodeURIComponent(segment)).join("/")}`;
|
|
1956
|
-
}
|
|
1957
|
-
|
|
1958
|
-
/**
|
|
1959
|
-
* Convert a document URI into the `ModuleFilePath` Val addresses it by: a
|
|
1960
|
-
* POSIX-style path relative to the Val root, with a leading slash.
|
|
1961
|
-
*
|
|
1962
|
-
* Returns `undefined` when the file lies outside the Val root — one server
|
|
1963
|
-
* serves exactly one root, so another root's files are not its business.
|
|
1964
|
-
*/
|
|
1965
|
-
function toModuleFilePath(valRoot, uri) {
|
|
1966
|
-
const fsPath = uriToPath(uri);
|
|
1967
|
-
const relative = path__default["default"].relative(valRoot, fsPath);
|
|
1968
|
-
if (!relative || relative.startsWith("..") || path__default["default"].isAbsolute(relative)) {
|
|
1969
|
-
return undefined;
|
|
1970
|
-
}
|
|
1971
|
-
return `/${relative.split(path__default["default"].sep).join("/")}`;
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
3050
|
/**
|
|
1975
3051
|
* How long to wait after an edit before re-evaluating.
|
|
1976
3052
|
*
|
|
@@ -2136,6 +3212,8 @@ function findMissingModuleDiagnostic(valRoot, moduleFilePath, read) {
|
|
|
2136
3212
|
*/
|
|
2137
3213
|
function createValLanguageServer(connection) {
|
|
2138
3214
|
const documents = new node.TextDocuments(vscodeLanguageserverTextdocument.TextDocument);
|
|
3215
|
+
let canRegisterWatchers = false;
|
|
3216
|
+
let canRenameFiles$1 = false;
|
|
2139
3217
|
|
|
2140
3218
|
/**
|
|
2141
3219
|
* The editor's view of a file, by absolute path, or `undefined` when the file
|
|
@@ -2209,6 +3287,23 @@ function createValLanguageServer(connection) {
|
|
|
2209
3287
|
// Needed to resolve keyOf/route validation, which has to look at other
|
|
2210
3288
|
// modules. Built once and refreshed per changed module.
|
|
2211
3289
|
const snapshotResult = await project.getSnapshot();
|
|
3290
|
+
// Core attaches the gallery checks to every gallery module whether or not
|
|
3291
|
+
// anything is wrong, so they have to be adjudicated by the same fix
|
|
3292
|
+
// handlers `val validate` uses before any of them is shown.
|
|
3293
|
+
// Narrowed once: `project` is module-level and reassigned on shutdown, so
|
|
3294
|
+
// the closures below need a local binding.
|
|
3295
|
+
const activeProject = project;
|
|
3296
|
+
const galleryChecks = result.content.errors !== false && result.content.errors.validation ? await resolveGalleryChecks({
|
|
3297
|
+
validation: result.content.errors.validation,
|
|
3298
|
+
runHandler: (sourcePath, validationError) => adjudicateGalleryCheck({
|
|
3299
|
+
sourcePath,
|
|
3300
|
+
validationError,
|
|
3301
|
+
moduleFilePath,
|
|
3302
|
+
valRoot: activeProject.valRoot,
|
|
3303
|
+
content: result.content,
|
|
3304
|
+
runFixHandler: args => activeProject.runFixHandler(args)
|
|
3305
|
+
})
|
|
3306
|
+
}) : undefined;
|
|
2212
3307
|
const diagnostics = createValDiagnostics({
|
|
2213
3308
|
moduleFilePath,
|
|
2214
3309
|
content: result.content,
|
|
@@ -2216,6 +3311,9 @@ function createValLanguageServer(connection) {
|
|
|
2216
3311
|
valRoot: project.valRoot,
|
|
2217
3312
|
...(snapshotResult.status === "ok" ? {
|
|
2218
3313
|
snapshot: snapshotResult.snapshot
|
|
3314
|
+
} : {}),
|
|
3315
|
+
...(galleryChecks ? {
|
|
3316
|
+
galleryChecks
|
|
2219
3317
|
} : {})
|
|
2220
3318
|
});
|
|
2221
3319
|
const unregistered = findMissingModuleDiagnostic(project.valRoot, moduleFilePath, readOpenDocument);
|
|
@@ -2259,13 +3357,22 @@ function createValLanguageServer(connection) {
|
|
|
2259
3357
|
}
|
|
2260
3358
|
};
|
|
2261
3359
|
}
|
|
3360
|
+
|
|
3361
|
+
// Whether we may ask the client to watch files for us, rather than relying on
|
|
3362
|
+
// it having been configured to. A VS Code extension can set watchers up
|
|
3363
|
+
// itself; a hand-written Neovim config generally will not, and this is what
|
|
3364
|
+
// makes the server work the same in both.
|
|
3365
|
+
canRegisterWatchers = params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
|
|
3366
|
+
// A RenameFile sent to a client that did not announce resourceOperations is
|
|
3367
|
+
// silently dropped, which would rewrite the path and leave the file behind.
|
|
3368
|
+
canRenameFiles$1 = canRenameFiles(params.capabilities);
|
|
2262
3369
|
const clientCapabilities = params.capabilities.experimental?.val ?? {};
|
|
2263
3370
|
|
|
2264
3371
|
// Announce only what this version actually serves: a client hides UI for
|
|
2265
3372
|
// anything missing here, and ignores anything it does not recognise.
|
|
2266
3373
|
// Completions and commands land in later phases.
|
|
2267
|
-
const features = ["diagnostics", "fix/metadata", "completions/mediaPath", "completions/keyOf", "completions/route", "fix/gallery", "completions/galleryKey", "completions/richtextLink"];
|
|
2268
|
-
const commands =
|
|
3374
|
+
const features = ["diagnostics", "fix/metadata", "completions/mediaPath", "completions/keyOf", "completions/route", "fix/gallery", "completions/galleryKey", "completions/richtextLink", "fix/missing-module", "fix/upload-remote", "fix/download-remote", "login", "diagnostics/gallery"];
|
|
3375
|
+
const commands = valCommandNames();
|
|
2269
3376
|
publicFiles = createPublicValFiles({
|
|
2270
3377
|
valRoot: options.valRoot
|
|
2271
3378
|
});
|
|
@@ -2329,6 +3436,45 @@ function createValLanguageServer(connection) {
|
|
|
2329
3436
|
}
|
|
2330
3437
|
};
|
|
2331
3438
|
});
|
|
3439
|
+
const commandHandlers = createValCommands({
|
|
3440
|
+
connection,
|
|
3441
|
+
getProject: () => project,
|
|
3442
|
+
getDocument: uri => documents.get(uri)
|
|
3443
|
+
});
|
|
3444
|
+
connection.onExecuteCommand(async params => {
|
|
3445
|
+
try {
|
|
3446
|
+
await commandHandlers.execute(params.command, params.arguments ?? []);
|
|
3447
|
+
} catch (e) {
|
|
3448
|
+
// A command that throws must not take the server down with it.
|
|
3449
|
+
connection.console.error(`Val: ${params.command} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3450
|
+
}
|
|
3451
|
+
});
|
|
3452
|
+
connection.onInitialized(() => {
|
|
3453
|
+
if (!canRegisterWatchers) {
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
// Registered here rather than in `initialize`: dynamic registration is a
|
|
3457
|
+
// request to the client, and the client is not ready to answer one until it
|
|
3458
|
+
// has sent `initialized`.
|
|
3459
|
+
void connection.client.register(vscodeLanguageserver.DidChangeWatchedFilesNotification.type, {
|
|
3460
|
+
watchers: [{
|
|
3461
|
+
globPattern: "**/*.val.{ts,js}"
|
|
3462
|
+
}, {
|
|
3463
|
+
globPattern: "**/val.modules.{ts,js}"
|
|
3464
|
+
}, {
|
|
3465
|
+
globPattern: "**/val.config.{ts,js}"
|
|
3466
|
+
},
|
|
3467
|
+
// Media completions and file-not-found diagnostics both read this
|
|
3468
|
+
// tree, and nothing else tells us a file arrived in it.
|
|
3469
|
+
{
|
|
3470
|
+
globPattern: "**/public/**"
|
|
3471
|
+
}]
|
|
3472
|
+
}).catch(e => {
|
|
3473
|
+
// A client that declined leaves us on document events alone, which is
|
|
3474
|
+
// what every client did before this existed.
|
|
3475
|
+
connection.console.warn(`Val: could not register file watchers: ${e instanceof Error ? e.message : String(e)}`);
|
|
3476
|
+
});
|
|
3477
|
+
});
|
|
2332
3478
|
connection.onCompletion(async params => {
|
|
2333
3479
|
const document = documents.get(params.textDocument.uri);
|
|
2334
3480
|
if (!publicFiles || !project || !document || !isValModuleUri(params.textDocument.uri)) {
|
|
@@ -2378,16 +3524,51 @@ function createValLanguageServer(connection) {
|
|
|
2378
3524
|
return [];
|
|
2379
3525
|
}
|
|
2380
3526
|
try {
|
|
3527
|
+
const actions = [];
|
|
3528
|
+
|
|
3529
|
+
// Registering a module in val.modules is not a content fix, so it is not
|
|
3530
|
+
// part of the createFixPatch pipeline: it is offered whenever the
|
|
3531
|
+
// diagnostic is present, even if the module could not be evaluated -- a
|
|
3532
|
+
// module Val does not serve is exactly the kind that fails to evaluate.
|
|
3533
|
+
const unregistered = params.context.diagnostics.some(diagnostic => diagnostic.data?.code === "val/missing-module");
|
|
3534
|
+
if (unregistered) {
|
|
3535
|
+
const action = createMissingModuleCodeAction({
|
|
3536
|
+
valRoot: project.valRoot,
|
|
3537
|
+
moduleFilePath,
|
|
3538
|
+
read: readOpenDocument
|
|
3539
|
+
});
|
|
3540
|
+
if (action) {
|
|
3541
|
+
actions.push(action);
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
2381
3544
|
const result = await project.getModule(moduleFilePath);
|
|
2382
3545
|
if (result.status === "error") {
|
|
2383
|
-
return
|
|
3546
|
+
return actions;
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
// Gallery membership: core reports it but offers no fix, because both
|
|
3550
|
+
// remedies are edits elsewhere -- the gallery module, or the file itself.
|
|
3551
|
+
for (const diagnostic of params.context.diagnostics) {
|
|
3552
|
+
const data = diagnostic.data;
|
|
3553
|
+
if (data?.code !== "val/gallery-membership" || !data.gallery) {
|
|
3554
|
+
continue;
|
|
3555
|
+
}
|
|
3556
|
+
actions.push(...(await createGalleryMembershipActions({
|
|
3557
|
+
document,
|
|
3558
|
+
gallery: data.gallery,
|
|
3559
|
+
valRoot: project.valRoot,
|
|
3560
|
+
read: readOpenDocument,
|
|
3561
|
+
allowRename: canRenameFiles$1
|
|
3562
|
+
})));
|
|
2384
3563
|
}
|
|
2385
|
-
|
|
3564
|
+
actions.push(...(await createValCodeActions({
|
|
2386
3565
|
document,
|
|
2387
3566
|
diagnostics: params.context.diagnostics,
|
|
2388
3567
|
content: result.content,
|
|
2389
|
-
valRoot: project.valRoot
|
|
2390
|
-
|
|
3568
|
+
valRoot: project.valRoot,
|
|
3569
|
+
moduleFilePath
|
|
3570
|
+
})));
|
|
3571
|
+
return actions;
|
|
2391
3572
|
} catch (e) {
|
|
2392
3573
|
connection.console.error(`Val: failed to build code actions for ${params.textDocument.uri}: ${e instanceof Error ? e.message : String(e)}`);
|
|
2393
3574
|
return [];
|
|
@@ -2416,6 +3597,69 @@ function createValLanguageServer(connection) {
|
|
|
2416
3597
|
}
|
|
2417
3598
|
scheduleValidation(document.uri);
|
|
2418
3599
|
});
|
|
3600
|
+
|
|
3601
|
+
/**
|
|
3602
|
+
* React to changes made outside the editor's buffers.
|
|
3603
|
+
*
|
|
3604
|
+
* `didChange` covers what the user types; it says nothing about a `git
|
|
3605
|
+
* checkout`, a `val validate --fix` run in a terminal, or an image dropped
|
|
3606
|
+
* into `/public/val`. Without this the server kept serving a stale evaluation
|
|
3607
|
+
* and stale completion candidates until something happened to be retyped —
|
|
3608
|
+
* and the watchers an editor had already been told to send were feeding a
|
|
3609
|
+
* handler that did not exist.
|
|
3610
|
+
*/
|
|
3611
|
+
connection.onDidChangeWatchedFiles(({
|
|
3612
|
+
changes
|
|
3613
|
+
}) => {
|
|
3614
|
+
if (!project) {
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3617
|
+
let projectWide = false;
|
|
3618
|
+
const changed = [];
|
|
3619
|
+
for (const change of changes) {
|
|
3620
|
+
const fsPath = uriToPath(change.uri);
|
|
3621
|
+
if (fsPath === null) {
|
|
3622
|
+
continue;
|
|
3623
|
+
}
|
|
3624
|
+
if (PROJECT_WIDE_FILE_RE.test(fsPath)) {
|
|
3625
|
+
projectWide = true;
|
|
3626
|
+
continue;
|
|
3627
|
+
}
|
|
3628
|
+
// A file appearing or vanishing under the files directory changes what a
|
|
3629
|
+
// media path may complete to.
|
|
3630
|
+
publicFiles?.invalidate();
|
|
3631
|
+
if (!isValModuleUri(change.uri)) {
|
|
3632
|
+
continue;
|
|
3633
|
+
}
|
|
3634
|
+
const moduleFilePath = toModuleFilePath(project.valRoot, change.uri);
|
|
3635
|
+
if (moduleFilePath) {
|
|
3636
|
+
changed.push(moduleFilePath);
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
if (projectWide) {
|
|
3640
|
+
project.invalidate();
|
|
3641
|
+
publicFiles?.invalidate();
|
|
3642
|
+
for (const open of documents.all()) {
|
|
3643
|
+
if (isValModuleUri(open.uri)) {
|
|
3644
|
+
scheduleValidation(open.uri);
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
for (const moduleFilePath of changed) {
|
|
3650
|
+
project.invalidate(moduleFilePath);
|
|
3651
|
+
}
|
|
3652
|
+
// A module the user is not looking at can still be the one that makes an
|
|
3653
|
+
// open module invalid -- a gallery it references, a record a keyOf points
|
|
3654
|
+
// at -- so revalidate every open module rather than only the changed ones.
|
|
3655
|
+
if (changed.length > 0) {
|
|
3656
|
+
for (const open of documents.all()) {
|
|
3657
|
+
if (isValModuleUri(open.uri)) {
|
|
3658
|
+
scheduleValidation(open.uri);
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
});
|
|
2419
3663
|
documents.onDidClose(({
|
|
2420
3664
|
document
|
|
2421
3665
|
}) => {
|
|
@@ -2460,38 +3704,61 @@ function main() {
|
|
|
2460
3704
|
}
|
|
2461
3705
|
|
|
2462
3706
|
exports.DEFAULT_FILES_DIRECTORY = DEFAULT_FILES_DIRECTORY;
|
|
3707
|
+
exports.MEDIA_METADATA_KEYS = MEDIA_METADATA_KEYS;
|
|
2463
3708
|
exports.PROTOCOL_VERSION = PROTOCOL_VERSION;
|
|
3709
|
+
exports.REMOTE_FIX_COMMANDS = REMOTE_FIX_COMMANDS;
|
|
3710
|
+
exports.REMOTE_FIX_TITLES = REMOTE_FIX_TITLES;
|
|
2464
3711
|
exports.SUPPORTED_PROTOCOL_VERSIONS = SUPPORTED_PROTOCOL_VERSIONS;
|
|
2465
3712
|
exports.VAL_DIAGNOSTIC_CODES = VAL_DIAGNOSTIC_CODES;
|
|
2466
3713
|
exports.VAL_DIAGNOSTIC_SOURCE = VAL_DIAGNOSTIC_SOURCE;
|
|
3714
|
+
exports.VAL_DOWNLOAD_REMOTE_COMMAND = VAL_DOWNLOAD_REMOTE_COMMAND;
|
|
2467
3715
|
exports.VAL_FEATURES = VAL_FEATURES;
|
|
2468
3716
|
exports.VAL_INPUT_REQUEST = VAL_INPUT_REQUEST;
|
|
3717
|
+
exports.VAL_LOGIN_COMMAND = VAL_LOGIN_COMMAND;
|
|
2469
3718
|
exports.VAL_PICK_REQUEST = VAL_PICK_REQUEST;
|
|
3719
|
+
exports.VAL_UPLOAD_REMOTE_COMMAND = VAL_UPLOAD_REMOTE_COMMAND;
|
|
3720
|
+
exports.adjudicateGalleryCheck = adjudicateGalleryCheck;
|
|
3721
|
+
exports.canRenameFiles = canRenameFiles;
|
|
2470
3722
|
exports.createEditorFsHost = createEditorFsHost;
|
|
3723
|
+
exports.createGalleryMembershipActions = createGalleryMembershipActions;
|
|
3724
|
+
exports.createMissingModuleCodeAction = createMissingModuleCodeAction;
|
|
2471
3725
|
exports.createMissingModuleDiagnostic = createMissingModuleDiagnostic;
|
|
2472
3726
|
exports.createModulePathMap = createModulePathMap;
|
|
2473
3727
|
exports.createProjectErrorDiagnostic = createProjectErrorDiagnostic;
|
|
2474
3728
|
exports.createPublicValFiles = createPublicValFiles;
|
|
2475
3729
|
exports.createValCodeActions = createValCodeActions;
|
|
3730
|
+
exports.createValCommands = createValCommands;
|
|
2476
3731
|
exports.createValCompletions = createValCompletions;
|
|
2477
3732
|
exports.createValDiagnostics = createValDiagnostics;
|
|
2478
3733
|
exports.createValLanguageServer = createValLanguageServer;
|
|
2479
3734
|
exports.createValProject = createValProject;
|
|
2480
3735
|
exports.defaultCoreResolver = defaultCoreResolver;
|
|
3736
|
+
exports.findMediaPathObject = findMediaPathObject;
|
|
2481
3737
|
exports.findModulePathAtPosition = findModulePathAtPosition;
|
|
3738
|
+
exports.findRecordInsertion = findRecordInsertion;
|
|
2482
3739
|
exports.findRegisteredModuleSpecifiers = findRegisteredModuleSpecifiers;
|
|
3740
|
+
exports.findValModulesInsertion = findValModulesInsertion;
|
|
3741
|
+
exports.galleryCheckKey = galleryCheckKey;
|
|
3742
|
+
exports.galleryMembershipAt = galleryMembershipAt;
|
|
2483
3743
|
exports.getLanguageServerVersion = getLanguageServerVersion;
|
|
2484
3744
|
exports.getModulePathRange = getModulePathRange;
|
|
2485
3745
|
exports.getValCompletionContext = getValCompletionContext;
|
|
3746
|
+
exports.isGalleryCheckFix = isGalleryCheckFix;
|
|
2486
3747
|
exports.isLocalFix = isLocalFix;
|
|
2487
3748
|
exports.isModuleRegistered = isModuleRegistered;
|
|
3749
|
+
exports.isRemoteFix = isRemoteFix;
|
|
2488
3750
|
exports.isValModuleUri = isValModuleUri;
|
|
2489
3751
|
exports.main = main;
|
|
2490
3752
|
exports.mapOpenDocuments = mapOpenDocuments;
|
|
2491
3753
|
exports.minimalTextEdit = minimalTextEdit;
|
|
2492
3754
|
exports.negotiateProtocolVersion = negotiateProtocolVersion;
|
|
2493
3755
|
exports.pathToUri = pathToUri;
|
|
3756
|
+
exports.readPersonalAccessToken = readPersonalAccessToken;
|
|
3757
|
+
exports.resolveGalleryChecks = resolveGalleryChecks;
|
|
2494
3758
|
exports.resolveValCompletion = resolveValCompletion;
|
|
2495
3759
|
exports.severityFor = severityFor;
|
|
2496
3760
|
exports.toModuleFilePath = toModuleFilePath;
|
|
2497
3761
|
exports.uriToPath = uriToPath;
|
|
3762
|
+
exports.valCommandNames = valCommandNames;
|
|
3763
|
+
exports.valModuleSpecifier = valModuleSpecifier;
|
|
3764
|
+
exports.valModulesEntryText = valModulesEntryText;
|