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