@valbuild/language-server 0.102.0 → 0.103.1

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