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