@valbuild/language-server 0.135.0 → 0.136.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # @valbuild/language-server
2
2
 
3
+ ## 0.136.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`e673b43`](https://github.com/valbuild/val/commit/e673b43feaf48b7f30881d6786c858a0cb6a44a2), [`5c82928`](https://github.com/valbuild/val/commit/5c82928992d29f133f81cf0704a273c0449b18f1), [`4450be5`](https://github.com/valbuild/val/commit/4450be570ec3a66d7d98ba273c333ca2d2f163d9), [`d2f385a`](https://github.com/valbuild/val/commit/d2f385a04978992a5036379235e447ba7775faef)]:
8
+ - @valbuild/shared@0.136.1
9
+ - @valbuild/server@0.136.1
10
+ - @valbuild/core@0.136.1
11
+
12
+ ## 0.136.0
13
+
14
+ ### Patch Changes
15
+
16
+ - [#699](https://github.com/valbuild/val/pull/699) [`1d72d00`](https://github.com/valbuild/val/commit/1d72d00a1b036959ae0f1540121701cbb2694dcb) Thanks [@freekh](https://github.com/freekh)! - A `*.val.ts` with no default export is no longer reported as a missing module
17
+
18
+ `*.val.ts` is a naming convention, not a promise: plenty of files under it hold
19
+ only the schemas and helpers the modules beside them import. Every one of those
20
+ was getting two errors in the editor — `Module '…' was not found in
21
+ val.modules` and `… is not registered in val.modules, so Val will not serve it`
22
+ — both on line 1, telling their author to register a file that has nothing to
23
+ register. `val validate` has never reported them; only the editor did.
24
+
25
+ The default export is what makes a `*.val.ts` a module, so that is what the
26
+ diagnostic now asks about:
27
+
28
+ - **No default export → nothing is reported.** The file is not a module, so it
29
+ is not a module Val is failing to serve.
30
+ - **A default export → one diagnostic, on the `export default` itself** rather
31
+ than on line 1, so it is next to the thing that has to change, and the
32
+ duplicate fatal beside it is gone. The message now gives both remedies: add
33
+ the file to `val.modules`, or export what it holds by name instead. The "Val:
34
+ register … in val.modules" quick fix is offered there.
35
+
36
+ The rule is `findDefaultExport` in `@valbuild/server`, which `val validate`
37
+ already used to decide the same question — so the editor and the CLI now agree
38
+ about which files are modules, including the cases that are easy to get wrong
39
+ (`export * from …` carries no default; `export type { T as default }` and
40
+ `export default interface T {}` are both gone after transpilation).
41
+
42
+ Because the diagnostic now replaces a module's own findings rather than adding
43
+ to them, the editor also stops guessing about registration it cannot read: a
44
+ `val.modules` that registers modules through a tsconfig path alias
45
+ (`import("_/content/page.val")`), or that builds its list in another file, is no
46
+ longer taken to register nothing.
47
+
48
+ - Updated dependencies [[`f3a4bb7`](https://github.com/valbuild/val/commit/f3a4bb7aea604943b92545c122243798c759715c), [`1d72d00`](https://github.com/valbuild/val/commit/1d72d00a1b036959ae0f1540121701cbb2694dcb)]:
49
+ - @valbuild/core@0.136.0
50
+ - @valbuild/server@0.136.0
51
+ - @valbuild/shared@0.136.0
52
+
3
53
  ## 0.135.0
4
54
 
5
55
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  import { type ModuleFilePath, type SourcePath, type ValidationError, type ValidationFix } from "@valbuild/core";
2
2
  import { type SchemaSourceSnapshot } from "@valbuild/shared/internal";
3
- import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver";
3
+ import { Diagnostic, DiagnosticSeverity, type Range } from "vscode-languageserver";
4
4
  import { type MediaCheckVerdict } from "./mediaChecks.js";
5
5
  import type { ValModuleContent } from "./ValProject.js";
6
6
  /** Marks diagnostics as ours, so a client can filter on it. */
@@ -130,9 +130,23 @@ export declare function createProjectErrorDiagnostic({ moduleFilePath, message,
130
130
  *
131
131
  * Val only serves modules listed there, so an unregistered module silently does
132
132
  * nothing — worth surfacing even though it is not a validation error.
133
+ *
134
+ * Reported on the `export default`, which is also why a file that has none gets
135
+ * no diagnostic at all (see `findValModuleDefinition`): the default export is
136
+ * the thing that makes a `*.val.ts` a module, so it is the thing being asked
137
+ * about, and pointing at line 1 of a file full of shared schemas asked its
138
+ * author to register something that does not exist.
139
+ *
140
+ * The message carries both remedies for the same reason. Only one of them is
141
+ * "add it to val.modules"; the other is to stop default-exporting, which is how
142
+ * a helper that landed in the module slot is fixed. Never "wrap it in
143
+ * `c.define`" — that turns it into an unregistered module, i.e. straight back
144
+ * to here.
133
145
  */
134
- export declare function createMissingModuleDiagnostic({ moduleFilePath, }: {
146
+ export declare function createMissingModuleDiagnostic({ moduleFilePath, range, }: {
135
147
  moduleFilePath: ModuleFilePath;
148
+ /** The module's `export default`, from `findValModuleDefinition`. */
149
+ range: Range;
136
150
  }): Diagnostic;
137
151
  export declare function isGalleryCheckFix(fix: string): boolean;
138
152
  /**
@@ -20,6 +20,18 @@ export declare function findRegisteredModuleSpecifiers(sourceFile: ts.SourceFile
20
20
  /**
21
21
  * Whether `moduleFilePath` is registered by the given `val.modules` file.
22
22
  *
23
+ * Answers `true` whenever it cannot tell, which is the same safe direction
24
+ * {@link findRegisteredModuleSpecifiers} takes and matters more here than it
25
+ * looks: the caller publishes the missing-module diagnostic *instead of* the
26
+ * module's own diagnostics, so a false "unregistered" does not just add noise,
27
+ * it hides every real finding in the file. Two things it cannot tell:
28
+ *
29
+ * - a `val.modules` with no dynamic imports at all, which is what a registry
30
+ * that builds its list somewhere else looks like from here;
31
+ * - a non-relative specifier naming a `.val` module (`_/content/page.val`),
32
+ * which is a tsconfig path alias — a shape `loadValModules` resolves and
33
+ * this file, having no compiler options, cannot.
34
+ *
23
35
  * @param valModulesDir directory containing the val.modules file, relative to
24
36
  * the Val root (`""` when it sits at the root).
25
37
  * @param moduleFilePath the module's path, Val-style: root-relative, leading
@@ -183,7 +183,7 @@ var packageJson = {
183
183
  "lsp",
184
184
  "language-server"
185
185
  ],
186
- version: "0.135.0",
186
+ version: "0.136.1",
187
187
  bin: {
188
188
  "val-language-server": "./bin.js"
189
189
  },
@@ -1627,11 +1627,24 @@ function createProjectErrorDiagnostic({
1627
1627
  *
1628
1628
  * Val only serves modules listed there, so an unregistered module silently does
1629
1629
  * nothing — worth surfacing even though it is not a validation error.
1630
+ *
1631
+ * Reported on the `export default`, which is also why a file that has none gets
1632
+ * no diagnostic at all (see `findValModuleDefinition`): the default export is
1633
+ * the thing that makes a `*.val.ts` a module, so it is the thing being asked
1634
+ * about, and pointing at line 1 of a file full of shared schemas asked its
1635
+ * author to register something that does not exist.
1636
+ *
1637
+ * The message carries both remedies for the same reason. Only one of them is
1638
+ * "add it to val.modules"; the other is to stop default-exporting, which is how
1639
+ * a helper that landed in the module slot is fixed. Never "wrap it in
1640
+ * `c.define`" — that turns it into an unregistered module, i.e. straight back
1641
+ * to here.
1630
1642
  */
1631
1643
  function createMissingModuleDiagnostic({
1632
- moduleFilePath
1644
+ moduleFilePath,
1645
+ range
1633
1646
  }) {
1634
- return build(FALLBACK_RANGE, `${moduleFilePath} is not registered in val.modules, so Val will not serve it.`, {
1647
+ return build(range, `${moduleFilePath} is not registered in val.modules, so Val will not serve it. Add it there — or, if this file is not meant to be a Val module, export what it holds by name instead: a default export is what makes a *.val.ts a module.`, {
1635
1648
  code: "val/missing-module",
1636
1649
  sourcePath: moduleFilePath
1637
1650
  });
@@ -2243,6 +2256,18 @@ function findRegisteredModuleSpecifiers(sourceFile) {
2243
2256
  /**
2244
2257
  * Whether `moduleFilePath` is registered by the given `val.modules` file.
2245
2258
  *
2259
+ * Answers `true` whenever it cannot tell, which is the same safe direction
2260
+ * {@link findRegisteredModuleSpecifiers} takes and matters more here than it
2261
+ * looks: the caller publishes the missing-module diagnostic *instead of* the
2262
+ * module's own diagnostics, so a false "unregistered" does not just add noise,
2263
+ * it hides every real finding in the file. Two things it cannot tell:
2264
+ *
2265
+ * - a `val.modules` with no dynamic imports at all, which is what a registry
2266
+ * that builds its list somewhere else looks like from here;
2267
+ * - a non-relative specifier naming a `.val` module (`_/content/page.val`),
2268
+ * which is a tsconfig path alias — a shape `loadValModules` resolves and
2269
+ * this file, having no compiler options, cannot.
2270
+ *
2246
2271
  * @param valModulesDir directory containing the val.modules file, relative to
2247
2272
  * the Val root (`""` when it sits at the root).
2248
2273
  * @param moduleFilePath the module's path, Val-style: root-relative, leading
@@ -2254,11 +2279,23 @@ function isModuleRegistered({
2254
2279
  moduleFilePath
2255
2280
  }) {
2256
2281
  const target = stripValModuleExtension(moduleFilePath);
2257
- return findRegisteredModuleSpecifiers(sourceFile).some(specifier => {
2282
+ const specifiers = findRegisteredModuleSpecifiers(sourceFile);
2283
+ if (specifiers.length === 0) {
2284
+ return true;
2285
+ }
2286
+ return specifiers.some(specifier => {
2287
+ const stripped = stripValModuleExtension(specifier);
2258
2288
  // Specifiers are written relative to the val.modules file and normally omit
2259
2289
  // the extension ("./content/page.val").
2260
- const resolved = specifier.startsWith(".") ? path__default["default"].posix.normalize(path__default["default"].posix.join("/", valModulesDir, stripValModuleExtension(specifier))) : stripValModuleExtension(specifier);
2261
- return resolved === target;
2290
+ if (specifier.startsWith(".")) {
2291
+ return path__default["default"].posix.normalize(path__default["default"].posix.join("/", valModulesDir, stripped)) === target;
2292
+ }
2293
+ if (specifier.startsWith("/")) {
2294
+ return stripped === target;
2295
+ }
2296
+ // A path alias, which needs the project's tsconfig to resolve. A bare
2297
+ // package import is not one of these, so it stays a plain non-match.
2298
+ return stripped.endsWith(".val");
2262
2299
  });
2263
2300
  }
2264
2301
 
@@ -3668,6 +3705,60 @@ function keysOfKeyOf(schema, snapshot) {
3668
3705
  }
3669
3706
  }
3670
3707
 
3708
+ /**
3709
+ * Where a file declares its Val module, or `undefined` when it declares none.
3710
+ *
3711
+ * `*.val.ts` is a naming convention, not a guarantee: a file under it may hold
3712
+ * nothing but the schemas and helpers the modules beside it import. The default
3713
+ * export is what separates the two, and `findDefaultExport` in
3714
+ * `@valbuild/server` is the one implementation of that rule — the same one
3715
+ * `val validate` uses to decide whether an unregistered file is worth reporting
3716
+ * at all. It is imported rather than reimplemented because the subtleties are
3717
+ * not obvious (a star re-export carries no default, a type-only export is gone
3718
+ * after transpilation), and two copies would answer differently the first time
3719
+ * one of them was fixed.
3720
+ *
3721
+ * Only the `export default` keywords are returned, never the whole definition:
3722
+ * a module's source is most of its file, and a diagnostic spanning that paints
3723
+ * the file red.
3724
+ */
3725
+ function findValModuleDefinition(sourceFile) {
3726
+ const statement = server.findDefaultExport(sourceFile);
3727
+ if (!statement) {
3728
+ return undefined;
3729
+ }
3730
+ const start = statement.getStart(sourceFile);
3731
+ return {
3732
+ start: sourceFile.getLineAndCharacterOfPosition(start),
3733
+ end: sourceFile.getLineAndCharacterOfPosition(keywordsEnd(sourceFile, statement, start))
3734
+ };
3735
+ }
3736
+
3737
+ /**
3738
+ * The end of the `export default` a statement opens with.
3739
+ *
3740
+ * Three shapes reach this, and only the first two can be narrowed: `export
3741
+ * default <expr>`, where everything before the expression is the keywords;
3742
+ * `export default function f() {}` and `export default class C {}`, where they
3743
+ * are modifiers; and `export { x as default }`, which has no `default` keyword
3744
+ * to stop at and is short enough to underline whole.
3745
+ */
3746
+ function keywordsEnd(sourceFile, statement, start) {
3747
+ if (ts__default["default"].isExportAssignment(statement)) {
3748
+ // Up to the exported expression is `export default` plus whitespace (and,
3749
+ // rarely, a comment); trimming the tail keeps the underline on the keywords.
3750
+ return start + sourceFile.text.slice(start, statement.expression.getStart(sourceFile)).trimEnd().length;
3751
+ }
3752
+ const defaultModifier = ts__default["default"].canHaveModifiers(statement) ? (ts__default["default"].getModifiers(statement) ?? []).find(modifier => modifier.kind === ts__default["default"].SyntaxKind.DefaultKeyword) : undefined;
3753
+ return defaultModifier?.end ?? statement.end;
3754
+ }
3755
+
3756
+ /** Parse a Val module's text the way {@link findValModuleDefinition} needs it. */
3757
+ function parseValModule(moduleFilePath, text) {
3758
+ // Parent pointers: `getStart` walks them to skip leading trivia.
3759
+ return ts__default["default"].createSourceFile(moduleFilePath, text, ts__default["default"].ScriptTarget.ES2020, true);
3760
+ }
3761
+
3671
3762
  /**
3672
3763
  * The two remedies for a gallery-backed field pointing at something its gallery
3673
3764
  * does not have.
@@ -4127,15 +4218,14 @@ function applyEnvOverrides(options) {
4127
4218
  const PROJECT_WIDE_FILE_RE = /[/\\]val\.(modules|config)\.(ts|js)$/;
4128
4219
 
4129
4220
  /**
4130
- * Report a Val module that `val.modules` does not register.
4221
+ * Whether `val.modules` leaves this module out.
4131
4222
  *
4132
4223
  * Reads `val.modules.{ts,js}` on demand, through the editor's buffer when it is
4133
4224
  * open: adding a module to the registry must clear the diagnostic straight away,
4134
- * not only once the user saves. Returns `undefined` when no such file exists —
4135
- * that is a project-level problem, not something to blame on an individual
4136
- * module.
4225
+ * not only once the user saves. Answers `false` when no such file exists — that
4226
+ * is a project-level problem, not something to blame on an individual module.
4137
4227
  */
4138
- function findMissingModuleDiagnostic(valRoot, moduleFilePath, read) {
4228
+ function isModuleUnregistered(valRoot, moduleFilePath, read) {
4139
4229
  for (const candidate of ["val.modules.ts", "val.modules.js"]) {
4140
4230
  const file = path__default["default"].join(valRoot, candidate);
4141
4231
  let text = read(file);
@@ -4146,16 +4236,13 @@ function findMissingModuleDiagnostic(valRoot, moduleFilePath, read) {
4146
4236
  continue;
4147
4237
  }
4148
4238
  }
4149
- const registered = isModuleRegistered({
4239
+ return !isModuleRegistered({
4150
4240
  sourceFile: ts__default["default"].createSourceFile(file, text, ts__default["default"].ScriptTarget.ES2020),
4151
4241
  valModulesDir: "",
4152
4242
  moduleFilePath
4153
4243
  });
4154
- return registered ? undefined : createMissingModuleDiagnostic({
4155
- moduleFilePath
4156
- });
4157
4244
  }
4158
- return undefined;
4245
+ return false;
4159
4246
  }
4160
4247
 
4161
4248
  /**
@@ -4220,6 +4307,27 @@ function createValLanguageServer(connection) {
4220
4307
  return;
4221
4308
  }
4222
4309
  try {
4310
+ // An unregistered file is answered from its own text alone, before the
4311
+ // project is consulted at all. Val cannot have evaluated it -- it is not
4312
+ // in the graph `val.modules` describes -- so everything downstream has
4313
+ // only the one thing to say about it, and said it twice: `Service.get`
4314
+ // reports `Module '...' was not found in val.modules` as a fatal on line
4315
+ // 1, beside this diagnostic saying the same. And when the file has no
4316
+ // `export default` neither of them is a finding at all: it is a
4317
+ // `*.val.ts` holding the schemas the modules beside it import, which Val
4318
+ // never serves and there is nothing to register.
4319
+ const unregistered = isModuleUnregistered(project.valRoot, moduleFilePath, readOpenDocument);
4320
+ if (unregistered) {
4321
+ const definition = findValModuleDefinition(parseValModule(moduleFilePath, document.getText()));
4322
+ connection.sendDiagnostics({
4323
+ uri,
4324
+ diagnostics: definition ? [createMissingModuleDiagnostic({
4325
+ moduleFilePath,
4326
+ range: definition
4327
+ })] : []
4328
+ });
4329
+ return;
4330
+ }
4223
4331
  // The module's own content changed, so any cached result is stale.
4224
4332
  project.invalidate(moduleFilePath);
4225
4333
  const result = await project.getModule(moduleFilePath);
@@ -4283,10 +4391,6 @@ function createValLanguageServer(connection) {
4283
4391
  mediaChecks
4284
4392
  } : {})
4285
4393
  });
4286
- const unregistered = findMissingModuleDiagnostic(project.valRoot, moduleFilePath, readOpenDocument);
4287
- if (unregistered) {
4288
- diagnostics.push(unregistered);
4289
- }
4290
4394
  connection.sendDiagnostics({
4291
4395
  uri,
4292
4396
  diagnostics
@@ -183,7 +183,7 @@ var packageJson = {
183
183
  "lsp",
184
184
  "language-server"
185
185
  ],
186
- version: "0.135.0",
186
+ version: "0.136.1",
187
187
  bin: {
188
188
  "val-language-server": "./bin.js"
189
189
  },
@@ -1627,11 +1627,24 @@ function createProjectErrorDiagnostic({
1627
1627
  *
1628
1628
  * Val only serves modules listed there, so an unregistered module silently does
1629
1629
  * nothing — worth surfacing even though it is not a validation error.
1630
+ *
1631
+ * Reported on the `export default`, which is also why a file that has none gets
1632
+ * no diagnostic at all (see `findValModuleDefinition`): the default export is
1633
+ * the thing that makes a `*.val.ts` a module, so it is the thing being asked
1634
+ * about, and pointing at line 1 of a file full of shared schemas asked its
1635
+ * author to register something that does not exist.
1636
+ *
1637
+ * The message carries both remedies for the same reason. Only one of them is
1638
+ * "add it to val.modules"; the other is to stop default-exporting, which is how
1639
+ * a helper that landed in the module slot is fixed. Never "wrap it in
1640
+ * `c.define`" — that turns it into an unregistered module, i.e. straight back
1641
+ * to here.
1630
1642
  */
1631
1643
  function createMissingModuleDiagnostic({
1632
- moduleFilePath
1644
+ moduleFilePath,
1645
+ range
1633
1646
  }) {
1634
- return build(FALLBACK_RANGE, `${moduleFilePath} is not registered in val.modules, so Val will not serve it.`, {
1647
+ return build(range, `${moduleFilePath} is not registered in val.modules, so Val will not serve it. Add it there — or, if this file is not meant to be a Val module, export what it holds by name instead: a default export is what makes a *.val.ts a module.`, {
1635
1648
  code: "val/missing-module",
1636
1649
  sourcePath: moduleFilePath
1637
1650
  });
@@ -2243,6 +2256,18 @@ function findRegisteredModuleSpecifiers(sourceFile) {
2243
2256
  /**
2244
2257
  * Whether `moduleFilePath` is registered by the given `val.modules` file.
2245
2258
  *
2259
+ * Answers `true` whenever it cannot tell, which is the same safe direction
2260
+ * {@link findRegisteredModuleSpecifiers} takes and matters more here than it
2261
+ * looks: the caller publishes the missing-module diagnostic *instead of* the
2262
+ * module's own diagnostics, so a false "unregistered" does not just add noise,
2263
+ * it hides every real finding in the file. Two things it cannot tell:
2264
+ *
2265
+ * - a `val.modules` with no dynamic imports at all, which is what a registry
2266
+ * that builds its list somewhere else looks like from here;
2267
+ * - a non-relative specifier naming a `.val` module (`_/content/page.val`),
2268
+ * which is a tsconfig path alias — a shape `loadValModules` resolves and
2269
+ * this file, having no compiler options, cannot.
2270
+ *
2246
2271
  * @param valModulesDir directory containing the val.modules file, relative to
2247
2272
  * the Val root (`""` when it sits at the root).
2248
2273
  * @param moduleFilePath the module's path, Val-style: root-relative, leading
@@ -2254,11 +2279,23 @@ function isModuleRegistered({
2254
2279
  moduleFilePath
2255
2280
  }) {
2256
2281
  const target = stripValModuleExtension(moduleFilePath);
2257
- return findRegisteredModuleSpecifiers(sourceFile).some(specifier => {
2282
+ const specifiers = findRegisteredModuleSpecifiers(sourceFile);
2283
+ if (specifiers.length === 0) {
2284
+ return true;
2285
+ }
2286
+ return specifiers.some(specifier => {
2287
+ const stripped = stripValModuleExtension(specifier);
2258
2288
  // Specifiers are written relative to the val.modules file and normally omit
2259
2289
  // the extension ("./content/page.val").
2260
- const resolved = specifier.startsWith(".") ? path__default["default"].posix.normalize(path__default["default"].posix.join("/", valModulesDir, stripValModuleExtension(specifier))) : stripValModuleExtension(specifier);
2261
- return resolved === target;
2290
+ if (specifier.startsWith(".")) {
2291
+ return path__default["default"].posix.normalize(path__default["default"].posix.join("/", valModulesDir, stripped)) === target;
2292
+ }
2293
+ if (specifier.startsWith("/")) {
2294
+ return stripped === target;
2295
+ }
2296
+ // A path alias, which needs the project's tsconfig to resolve. A bare
2297
+ // package import is not one of these, so it stays a plain non-match.
2298
+ return stripped.endsWith(".val");
2262
2299
  });
2263
2300
  }
2264
2301
 
@@ -3668,6 +3705,60 @@ function keysOfKeyOf(schema, snapshot) {
3668
3705
  }
3669
3706
  }
3670
3707
 
3708
+ /**
3709
+ * Where a file declares its Val module, or `undefined` when it declares none.
3710
+ *
3711
+ * `*.val.ts` is a naming convention, not a guarantee: a file under it may hold
3712
+ * nothing but the schemas and helpers the modules beside it import. The default
3713
+ * export is what separates the two, and `findDefaultExport` in
3714
+ * `@valbuild/server` is the one implementation of that rule — the same one
3715
+ * `val validate` uses to decide whether an unregistered file is worth reporting
3716
+ * at all. It is imported rather than reimplemented because the subtleties are
3717
+ * not obvious (a star re-export carries no default, a type-only export is gone
3718
+ * after transpilation), and two copies would answer differently the first time
3719
+ * one of them was fixed.
3720
+ *
3721
+ * Only the `export default` keywords are returned, never the whole definition:
3722
+ * a module's source is most of its file, and a diagnostic spanning that paints
3723
+ * the file red.
3724
+ */
3725
+ function findValModuleDefinition(sourceFile) {
3726
+ const statement = server.findDefaultExport(sourceFile);
3727
+ if (!statement) {
3728
+ return undefined;
3729
+ }
3730
+ const start = statement.getStart(sourceFile);
3731
+ return {
3732
+ start: sourceFile.getLineAndCharacterOfPosition(start),
3733
+ end: sourceFile.getLineAndCharacterOfPosition(keywordsEnd(sourceFile, statement, start))
3734
+ };
3735
+ }
3736
+
3737
+ /**
3738
+ * The end of the `export default` a statement opens with.
3739
+ *
3740
+ * Three shapes reach this, and only the first two can be narrowed: `export
3741
+ * default <expr>`, where everything before the expression is the keywords;
3742
+ * `export default function f() {}` and `export default class C {}`, where they
3743
+ * are modifiers; and `export { x as default }`, which has no `default` keyword
3744
+ * to stop at and is short enough to underline whole.
3745
+ */
3746
+ function keywordsEnd(sourceFile, statement, start) {
3747
+ if (ts__default["default"].isExportAssignment(statement)) {
3748
+ // Up to the exported expression is `export default` plus whitespace (and,
3749
+ // rarely, a comment); trimming the tail keeps the underline on the keywords.
3750
+ return start + sourceFile.text.slice(start, statement.expression.getStart(sourceFile)).trimEnd().length;
3751
+ }
3752
+ const defaultModifier = ts__default["default"].canHaveModifiers(statement) ? (ts__default["default"].getModifiers(statement) ?? []).find(modifier => modifier.kind === ts__default["default"].SyntaxKind.DefaultKeyword) : undefined;
3753
+ return defaultModifier?.end ?? statement.end;
3754
+ }
3755
+
3756
+ /** Parse a Val module's text the way {@link findValModuleDefinition} needs it. */
3757
+ function parseValModule(moduleFilePath, text) {
3758
+ // Parent pointers: `getStart` walks them to skip leading trivia.
3759
+ return ts__default["default"].createSourceFile(moduleFilePath, text, ts__default["default"].ScriptTarget.ES2020, true);
3760
+ }
3761
+
3671
3762
  /**
3672
3763
  * The two remedies for a gallery-backed field pointing at something its gallery
3673
3764
  * does not have.
@@ -4127,15 +4218,14 @@ function applyEnvOverrides(options) {
4127
4218
  const PROJECT_WIDE_FILE_RE = /[/\\]val\.(modules|config)\.(ts|js)$/;
4128
4219
 
4129
4220
  /**
4130
- * Report a Val module that `val.modules` does not register.
4221
+ * Whether `val.modules` leaves this module out.
4131
4222
  *
4132
4223
  * Reads `val.modules.{ts,js}` on demand, through the editor's buffer when it is
4133
4224
  * open: adding a module to the registry must clear the diagnostic straight away,
4134
- * not only once the user saves. Returns `undefined` when no such file exists —
4135
- * that is a project-level problem, not something to blame on an individual
4136
- * module.
4225
+ * not only once the user saves. Answers `false` when no such file exists — that
4226
+ * is a project-level problem, not something to blame on an individual module.
4137
4227
  */
4138
- function findMissingModuleDiagnostic(valRoot, moduleFilePath, read) {
4228
+ function isModuleUnregistered(valRoot, moduleFilePath, read) {
4139
4229
  for (const candidate of ["val.modules.ts", "val.modules.js"]) {
4140
4230
  const file = path__default["default"].join(valRoot, candidate);
4141
4231
  let text = read(file);
@@ -4146,16 +4236,13 @@ function findMissingModuleDiagnostic(valRoot, moduleFilePath, read) {
4146
4236
  continue;
4147
4237
  }
4148
4238
  }
4149
- const registered = isModuleRegistered({
4239
+ return !isModuleRegistered({
4150
4240
  sourceFile: ts__default["default"].createSourceFile(file, text, ts__default["default"].ScriptTarget.ES2020),
4151
4241
  valModulesDir: "",
4152
4242
  moduleFilePath
4153
4243
  });
4154
- return registered ? undefined : createMissingModuleDiagnostic({
4155
- moduleFilePath
4156
- });
4157
4244
  }
4158
- return undefined;
4245
+ return false;
4159
4246
  }
4160
4247
 
4161
4248
  /**
@@ -4220,6 +4307,27 @@ function createValLanguageServer(connection) {
4220
4307
  return;
4221
4308
  }
4222
4309
  try {
4310
+ // An unregistered file is answered from its own text alone, before the
4311
+ // project is consulted at all. Val cannot have evaluated it -- it is not
4312
+ // in the graph `val.modules` describes -- so everything downstream has
4313
+ // only the one thing to say about it, and said it twice: `Service.get`
4314
+ // reports `Module '...' was not found in val.modules` as a fatal on line
4315
+ // 1, beside this diagnostic saying the same. And when the file has no
4316
+ // `export default` neither of them is a finding at all: it is a
4317
+ // `*.val.ts` holding the schemas the modules beside it import, which Val
4318
+ // never serves and there is nothing to register.
4319
+ const unregistered = isModuleUnregistered(project.valRoot, moduleFilePath, readOpenDocument);
4320
+ if (unregistered) {
4321
+ const definition = findValModuleDefinition(parseValModule(moduleFilePath, document.getText()));
4322
+ connection.sendDiagnostics({
4323
+ uri,
4324
+ diagnostics: definition ? [createMissingModuleDiagnostic({
4325
+ moduleFilePath,
4326
+ range: definition
4327
+ })] : []
4328
+ });
4329
+ return;
4330
+ }
4223
4331
  // The module's own content changed, so any cached result is stale.
4224
4332
  project.invalidate(moduleFilePath);
4225
4333
  const result = await project.getModule(moduleFilePath);
@@ -4283,10 +4391,6 @@ function createValLanguageServer(connection) {
4283
4391
  mediaChecks
4284
4392
  } : {})
4285
4393
  });
4286
- const unregistered = findMissingModuleDiagnostic(project.valRoot, moduleFilePath, readOpenDocument);
4287
- if (unregistered) {
4288
- diagnostics.push(unregistered);
4289
- }
4290
4394
  connection.sendDiagnostics({
4291
4395
  uri,
4292
4396
  diagnostics
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import ts from 'typescript';
4
4
  import { Internal, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST } from '@valbuild/core';
5
- import { fixHandlers, createService, analyzeValModule, createFixPatch, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, planJsonValuesEntryExtraction, extractImageMetadata, extractFileMetadata } from '@valbuild/server';
5
+ import { fixHandlers, createService, analyzeValModule, createFixPatch, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, planJsonValuesEntryExtraction, extractImageMetadata, extractFileMetadata, findDefaultExport } from '@valbuild/server';
6
6
  import { DiagnosticSeverity, ShowDocumentRequest, ApplyWorkspaceEditRequest, CodeActionKind, CodeAction, MarkupContent, CompletionItemKind, DidChangeWatchedFilesNotification } from 'vscode-languageserver';
7
7
  import { TextDocuments, TextDocumentSyncKind, createConnection, ProposedFeatures } from 'vscode-languageserver/node';
8
8
  import { TextDocument } from 'vscode-languageserver-textdocument';
@@ -172,7 +172,7 @@ var packageJson = {
172
172
  "lsp",
173
173
  "language-server"
174
174
  ],
175
- version: "0.135.0",
175
+ version: "0.136.1",
176
176
  bin: {
177
177
  "val-language-server": "./bin.js"
178
178
  },
@@ -1616,11 +1616,24 @@ function createProjectErrorDiagnostic({
1616
1616
  *
1617
1617
  * Val only serves modules listed there, so an unregistered module silently does
1618
1618
  * nothing — worth surfacing even though it is not a validation error.
1619
+ *
1620
+ * Reported on the `export default`, which is also why a file that has none gets
1621
+ * no diagnostic at all (see `findValModuleDefinition`): the default export is
1622
+ * the thing that makes a `*.val.ts` a module, so it is the thing being asked
1623
+ * about, and pointing at line 1 of a file full of shared schemas asked its
1624
+ * author to register something that does not exist.
1625
+ *
1626
+ * The message carries both remedies for the same reason. Only one of them is
1627
+ * "add it to val.modules"; the other is to stop default-exporting, which is how
1628
+ * a helper that landed in the module slot is fixed. Never "wrap it in
1629
+ * `c.define`" — that turns it into an unregistered module, i.e. straight back
1630
+ * to here.
1619
1631
  */
1620
1632
  function createMissingModuleDiagnostic({
1621
- moduleFilePath
1633
+ moduleFilePath,
1634
+ range
1622
1635
  }) {
1623
- return build(FALLBACK_RANGE, `${moduleFilePath} is not registered in val.modules, so Val will not serve it.`, {
1636
+ return build(range, `${moduleFilePath} is not registered in val.modules, so Val will not serve it. Add it there — or, if this file is not meant to be a Val module, export what it holds by name instead: a default export is what makes a *.val.ts a module.`, {
1624
1637
  code: "val/missing-module",
1625
1638
  sourcePath: moduleFilePath
1626
1639
  });
@@ -2232,6 +2245,18 @@ function findRegisteredModuleSpecifiers(sourceFile) {
2232
2245
  /**
2233
2246
  * Whether `moduleFilePath` is registered by the given `val.modules` file.
2234
2247
  *
2248
+ * Answers `true` whenever it cannot tell, which is the same safe direction
2249
+ * {@link findRegisteredModuleSpecifiers} takes and matters more here than it
2250
+ * looks: the caller publishes the missing-module diagnostic *instead of* the
2251
+ * module's own diagnostics, so a false "unregistered" does not just add noise,
2252
+ * it hides every real finding in the file. Two things it cannot tell:
2253
+ *
2254
+ * - a `val.modules` with no dynamic imports at all, which is what a registry
2255
+ * that builds its list somewhere else looks like from here;
2256
+ * - a non-relative specifier naming a `.val` module (`_/content/page.val`),
2257
+ * which is a tsconfig path alias — a shape `loadValModules` resolves and
2258
+ * this file, having no compiler options, cannot.
2259
+ *
2235
2260
  * @param valModulesDir directory containing the val.modules file, relative to
2236
2261
  * the Val root (`""` when it sits at the root).
2237
2262
  * @param moduleFilePath the module's path, Val-style: root-relative, leading
@@ -2243,11 +2268,23 @@ function isModuleRegistered({
2243
2268
  moduleFilePath
2244
2269
  }) {
2245
2270
  const target = stripValModuleExtension(moduleFilePath);
2246
- return findRegisteredModuleSpecifiers(sourceFile).some(specifier => {
2271
+ const specifiers = findRegisteredModuleSpecifiers(sourceFile);
2272
+ if (specifiers.length === 0) {
2273
+ return true;
2274
+ }
2275
+ return specifiers.some(specifier => {
2276
+ const stripped = stripValModuleExtension(specifier);
2247
2277
  // Specifiers are written relative to the val.modules file and normally omit
2248
2278
  // the extension ("./content/page.val").
2249
- const resolved = specifier.startsWith(".") ? path.posix.normalize(path.posix.join("/", valModulesDir, stripValModuleExtension(specifier))) : stripValModuleExtension(specifier);
2250
- return resolved === target;
2279
+ if (specifier.startsWith(".")) {
2280
+ return path.posix.normalize(path.posix.join("/", valModulesDir, stripped)) === target;
2281
+ }
2282
+ if (specifier.startsWith("/")) {
2283
+ return stripped === target;
2284
+ }
2285
+ // A path alias, which needs the project's tsconfig to resolve. A bare
2286
+ // package import is not one of these, so it stays a plain non-match.
2287
+ return stripped.endsWith(".val");
2251
2288
  });
2252
2289
  }
2253
2290
 
@@ -3657,6 +3694,60 @@ function keysOfKeyOf(schema, snapshot) {
3657
3694
  }
3658
3695
  }
3659
3696
 
3697
+ /**
3698
+ * Where a file declares its Val module, or `undefined` when it declares none.
3699
+ *
3700
+ * `*.val.ts` is a naming convention, not a guarantee: a file under it may hold
3701
+ * nothing but the schemas and helpers the modules beside it import. The default
3702
+ * export is what separates the two, and `findDefaultExport` in
3703
+ * `@valbuild/server` is the one implementation of that rule — the same one
3704
+ * `val validate` uses to decide whether an unregistered file is worth reporting
3705
+ * at all. It is imported rather than reimplemented because the subtleties are
3706
+ * not obvious (a star re-export carries no default, a type-only export is gone
3707
+ * after transpilation), and two copies would answer differently the first time
3708
+ * one of them was fixed.
3709
+ *
3710
+ * Only the `export default` keywords are returned, never the whole definition:
3711
+ * a module's source is most of its file, and a diagnostic spanning that paints
3712
+ * the file red.
3713
+ */
3714
+ function findValModuleDefinition(sourceFile) {
3715
+ const statement = findDefaultExport(sourceFile);
3716
+ if (!statement) {
3717
+ return undefined;
3718
+ }
3719
+ const start = statement.getStart(sourceFile);
3720
+ return {
3721
+ start: sourceFile.getLineAndCharacterOfPosition(start),
3722
+ end: sourceFile.getLineAndCharacterOfPosition(keywordsEnd(sourceFile, statement, start))
3723
+ };
3724
+ }
3725
+
3726
+ /**
3727
+ * The end of the `export default` a statement opens with.
3728
+ *
3729
+ * Three shapes reach this, and only the first two can be narrowed: `export
3730
+ * default <expr>`, where everything before the expression is the keywords;
3731
+ * `export default function f() {}` and `export default class C {}`, where they
3732
+ * are modifiers; and `export { x as default }`, which has no `default` keyword
3733
+ * to stop at and is short enough to underline whole.
3734
+ */
3735
+ function keywordsEnd(sourceFile, statement, start) {
3736
+ if (ts.isExportAssignment(statement)) {
3737
+ // Up to the exported expression is `export default` plus whitespace (and,
3738
+ // rarely, a comment); trimming the tail keeps the underline on the keywords.
3739
+ return start + sourceFile.text.slice(start, statement.expression.getStart(sourceFile)).trimEnd().length;
3740
+ }
3741
+ const defaultModifier = ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []).find(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword) : undefined;
3742
+ return defaultModifier?.end ?? statement.end;
3743
+ }
3744
+
3745
+ /** Parse a Val module's text the way {@link findValModuleDefinition} needs it. */
3746
+ function parseValModule(moduleFilePath, text) {
3747
+ // Parent pointers: `getStart` walks them to skip leading trivia.
3748
+ return ts.createSourceFile(moduleFilePath, text, ts.ScriptTarget.ES2020, true);
3749
+ }
3750
+
3660
3751
  /**
3661
3752
  * The two remedies for a gallery-backed field pointing at something its gallery
3662
3753
  * does not have.
@@ -4116,15 +4207,14 @@ function applyEnvOverrides(options) {
4116
4207
  const PROJECT_WIDE_FILE_RE = /[/\\]val\.(modules|config)\.(ts|js)$/;
4117
4208
 
4118
4209
  /**
4119
- * Report a Val module that `val.modules` does not register.
4210
+ * Whether `val.modules` leaves this module out.
4120
4211
  *
4121
4212
  * Reads `val.modules.{ts,js}` on demand, through the editor's buffer when it is
4122
4213
  * open: adding a module to the registry must clear the diagnostic straight away,
4123
- * not only once the user saves. Returns `undefined` when no such file exists —
4124
- * that is a project-level problem, not something to blame on an individual
4125
- * module.
4214
+ * not only once the user saves. Answers `false` when no such file exists — that
4215
+ * is a project-level problem, not something to blame on an individual module.
4126
4216
  */
4127
- function findMissingModuleDiagnostic(valRoot, moduleFilePath, read) {
4217
+ function isModuleUnregistered(valRoot, moduleFilePath, read) {
4128
4218
  for (const candidate of ["val.modules.ts", "val.modules.js"]) {
4129
4219
  const file = path.join(valRoot, candidate);
4130
4220
  let text = read(file);
@@ -4135,16 +4225,13 @@ function findMissingModuleDiagnostic(valRoot, moduleFilePath, read) {
4135
4225
  continue;
4136
4226
  }
4137
4227
  }
4138
- const registered = isModuleRegistered({
4228
+ return !isModuleRegistered({
4139
4229
  sourceFile: ts.createSourceFile(file, text, ts.ScriptTarget.ES2020),
4140
4230
  valModulesDir: "",
4141
4231
  moduleFilePath
4142
4232
  });
4143
- return registered ? undefined : createMissingModuleDiagnostic({
4144
- moduleFilePath
4145
- });
4146
4233
  }
4147
- return undefined;
4234
+ return false;
4148
4235
  }
4149
4236
 
4150
4237
  /**
@@ -4209,6 +4296,27 @@ function createValLanguageServer(connection) {
4209
4296
  return;
4210
4297
  }
4211
4298
  try {
4299
+ // An unregistered file is answered from its own text alone, before the
4300
+ // project is consulted at all. Val cannot have evaluated it -- it is not
4301
+ // in the graph `val.modules` describes -- so everything downstream has
4302
+ // only the one thing to say about it, and said it twice: `Service.get`
4303
+ // reports `Module '...' was not found in val.modules` as a fatal on line
4304
+ // 1, beside this diagnostic saying the same. And when the file has no
4305
+ // `export default` neither of them is a finding at all: it is a
4306
+ // `*.val.ts` holding the schemas the modules beside it import, which Val
4307
+ // never serves and there is nothing to register.
4308
+ const unregistered = isModuleUnregistered(project.valRoot, moduleFilePath, readOpenDocument);
4309
+ if (unregistered) {
4310
+ const definition = findValModuleDefinition(parseValModule(moduleFilePath, document.getText()));
4311
+ connection.sendDiagnostics({
4312
+ uri,
4313
+ diagnostics: definition ? [createMissingModuleDiagnostic({
4314
+ moduleFilePath,
4315
+ range: definition
4316
+ })] : []
4317
+ });
4318
+ return;
4319
+ }
4212
4320
  // The module's own content changed, so any cached result is stale.
4213
4321
  project.invalidate(moduleFilePath);
4214
4322
  const result = await project.getModule(moduleFilePath);
@@ -4272,10 +4380,6 @@ function createValLanguageServer(connection) {
4272
4380
  mediaChecks
4273
4381
  } : {})
4274
4382
  });
4275
- const unregistered = findMissingModuleDiagnostic(project.valRoot, moduleFilePath, readOpenDocument);
4276
- if (unregistered) {
4277
- diagnostics.push(unregistered);
4278
- }
4279
4383
  connection.sendDiagnostics({
4280
4384
  uri,
4281
4385
  diagnostics
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "lsp",
12
12
  "language-server"
13
13
  ],
14
- "version": "0.135.0",
14
+ "version": "0.136.1",
15
15
  "bin": {
16
16
  "val-language-server": "./bin.js"
17
17
  },
@@ -29,9 +29,9 @@
29
29
  "typescript": "^6.0.3",
30
30
  "vscode-languageserver": "^10.1.0",
31
31
  "vscode-languageserver-textdocument": "^1.0.14",
32
- "@valbuild/server": "0.135.0",
33
- "@valbuild/shared": "0.134.1",
34
- "@valbuild/core": "0.134.0"
32
+ "@valbuild/core": "0.136.1",
33
+ "@valbuild/server": "0.136.1",
34
+ "@valbuild/shared": "0.136.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/jest": "^30.0.0",