@loomcli/core 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/NOTICE +34 -0
  2. package/dist/application.d.ts +11 -6
  3. package/dist/application.js +57 -19
  4. package/dist/capture.d.ts +65 -0
  5. package/dist/capture.js +99 -0
  6. package/dist/chain.d.ts +28 -17
  7. package/dist/chain.js +11 -10
  8. package/dist/command-rules.d.ts +1 -1
  9. package/dist/command-rules.js +2 -2
  10. package/dist/command.d.ts +31 -47
  11. package/dist/command.js +204 -209
  12. package/dist/errors.d.ts +22 -21
  13. package/dist/errors.js +38 -18
  14. package/dist/facts.d.ts +5 -0
  15. package/dist/facts.js +8 -1
  16. package/dist/globals.d.ts +48 -22
  17. package/dist/globals.js +72 -29
  18. package/dist/glyphs.generated.js +1 -1
  19. package/dist/index.d.ts +5 -3
  20. package/dist/index.js +3 -1
  21. package/dist/input-rules.d.ts +20 -2
  22. package/dist/input-rules.js +47 -5
  23. package/dist/inspect.d.ts +33 -17
  24. package/dist/inspect.js +74 -87
  25. package/dist/locate.js +52 -60
  26. package/dist/options.d.ts +101 -83
  27. package/dist/options.js +311 -267
  28. package/dist/parse.d.ts +162 -0
  29. package/dist/parse.js +601 -0
  30. package/dist/plain.d.ts +52 -2
  31. package/dist/plain.js +228 -2
  32. package/dist/plugin-rules.d.ts +8 -4
  33. package/dist/plugin-rules.js +13 -9
  34. package/dist/plugin-settings.d.ts +18 -0
  35. package/dist/plugin-settings.js +38 -0
  36. package/dist/plugin.d.ts +32 -27
  37. package/dist/plugin.js +107 -89
  38. package/dist/sources.d.ts +18 -9
  39. package/dist/sources.js +50 -20
  40. package/dist/style-layout.js +2 -2
  41. package/dist/style-width.d.ts +13 -0
  42. package/dist/style-width.js +170 -0
  43. package/dist/types.d.ts +70 -30
  44. package/dist/unicode.generated.d.ts +27 -0
  45. package/dist/unicode.generated.js +1036 -0
  46. package/dist/validation.d.ts +108 -34
  47. package/dist/validation.js +282 -125
  48. package/dist/view.d.ts +16 -11
  49. package/dist/view.js +13 -4
  50. package/licenses/unicode-LICENSE.txt +41 -0
  51. package/licenses/uucode-LICENSE.md +35 -0
  52. package/package.json +9 -5
package/dist/plugin.js CHANGED
@@ -1,22 +1,25 @@
1
1
  import { checkEnvBinding, claimVariables } from './bindings.js';
2
+ import { captureDeclaration, elidedRead, unreadableArgument, unreadableFault } from './capture.js';
2
3
  import { notACommand } from './command-rules.js';
3
4
  import { attach, commandCode, commandNode } from './command.js';
4
5
  import { elided, quoteString, spelled } from './diagnostic-text.js';
5
6
  import { DeclarationError, InternalError, quoted, reasonOf } from './errors.js';
6
7
  import { appliesTo, buildExtensions, isDescriptor, registerDescriptor } from './extension.js';
7
- import { checkDeprecated, checkDescription, checkHidden, factFault, partFinding, pluginOptionSite, slotSite, } from './facts.js';
8
- import { boundOptions, pluginSites } from './globals.js';
8
+ import { checkDeprecated, checkDescription, checkHidden, partFinding, pluginOptionSite, slotSite, } from './facts.js';
9
+ import { boundOptions, checkNoPresenceRule, pluginSites } from './globals.js';
9
10
  import { checkIdentity } from './identity.js';
10
11
  import { coreViews } from './lanes.js';
11
- import { booleanValue, compileOptions } from './options.js';
12
- import { isPlainObject } from './plain.js';
13
- import { foreignValue, middlewareActivation, notAFunction, notAList, notAnObject, pluginInstalledTwice, pluginOptionRule, signalClaimedTwice, slotTaken, sourceBinding, sourceBoundOwnOption, unknownSignal, } from './plugin-rules.js';
12
+ import { compileOptions } from './options.js';
13
+ import { copyOwnKeys, decidePlain, declaring, isPlainObject, shallowList, shallowRecord, } from './plain.js';
14
+ import { foreignValue, middlewareActivation, notAFunction, notAList, notAnObject, pluginInstalledTwice, signalClaimedTwice, slotTaken, sourceBinding, sourceBoundOwnOption, unknownSignal, } from './plugin-rules.js';
14
15
  import { pluginLoaderFailed } from './rules.js';
15
16
  import { isProcessSignal } from './signals.js';
16
17
  import { buildTheme } from './theme.js';
17
18
  import { readTranslations } from './translators.js';
18
- import { captureConfig, checkDeclarations } from './validation.js';
19
+ import { captureConfig, checkDeclarations, defaultDepthFault } from './validation.js';
19
20
  import { buildViews, viewIdentities } from './view.js';
21
+ /** The slots of a definition that hold a list, each copied with its entries as they were built. */
22
+ const definitionLists = ['extensions', 'signals', 'commands', 'views', 'translators'];
20
23
  /** Authored values register here, so the public type publishes no state to reach or replace. */
21
24
  const nodes = new WeakMap();
22
25
  /**
@@ -34,17 +37,47 @@ class PluginDeclaration {
34
37
  * One plugin: an identity and the contributions it carries. Creating and installing the value runs
35
38
  * none of its code: `onCommandAttach` runs at graph build, `onFailure` runs when `run()` renders a
36
39
  * failure, and the middleware runs inside an invocation, so an installed plugin an invocation never
37
- * reaches costs that invocation its hooks alone. Every rule
38
- * that one definition carries on its own throws here, before the value exists.
40
+ * reaches costs that invocation its hooks alone. The definition is read once, after the identity,
41
+ * and every rule that one definition carries on its own throws here, before the value exists.
39
42
  */
40
43
  function plugin(identity, definition) {
41
- const captured = {
42
- ...definition,
43
- ...(definition?.theme === undefined
44
- ? {}
45
- : { theme: isPlainObject(definition.theme) ? { ...definition.theme } : definition.theme }),
46
- };
47
- return new PluginDeclaration(readPlugin(identity, isPlainObject(definition) ? captured : definition));
44
+ return new PluginDeclaration(declaring(() => readPlugin(identity, definition)));
45
+ }
46
+ /**
47
+ * The one copy of a definition that `plugin()` reads: the definition, its theme mapping, options
48
+ * record, middleware with its activation list, and source, and each list it holds. An entry of a
49
+ * list is what its factory built and is not copied, and each option's config is captured where its
50
+ * own rules read it, so a fault about it names the option. A read that throws is the unreadable
51
+ * fault of the definition, and a definition that is not a plain object is the not-an-object fault.
52
+ */
53
+ function captureDefinition(identity, definition) {
54
+ return captureDeclaration(definition, (copy, read) => {
55
+ read.nested(copy, 'theme', shallowRecord);
56
+ read.nested(copy, 'options', shallowRecord);
57
+ read.nested(copy, 'middleware', copyMiddleware);
58
+ read.nested(copy, 'source', shallowRecord);
59
+ for (const list of definitionLists) {
60
+ read.nested(copy, list, shallowList);
61
+ }
62
+ }, {
63
+ notAnObject: () => new DeclarationError(notAnObject, {
64
+ correction: 'Supply { options, middleware, extensions, views }.',
65
+ findings: [{ arguments: [identity, definition], call: 'plugin', mark: '1' }],
66
+ sentence: `${pluginSentence(identity)} declares a definition that is not an object.`,
67
+ }),
68
+ unreadable: unreadableArgument({ call: 'plugin', named: identity, subject: pluginSentence(identity) }, 'definition'),
69
+ });
70
+ }
71
+ /** A middleware object's copy, its activation list copied too, or any other value as declared. */
72
+ function copyMiddleware(middleware) {
73
+ if (!decidePlain(middleware)) {
74
+ return middleware;
75
+ }
76
+ const copy = copyOwnKeys(middleware);
77
+ if (Object.hasOwn(copy, 'activate')) {
78
+ copy.activate = shallowList(copy.activate);
79
+ }
80
+ return copy;
48
81
  }
49
82
  /** How every plugin diagnostic names one plugin at the start of a sentence. */
50
83
  function pluginSentence(identity) {
@@ -71,17 +104,6 @@ function entryCode(value) {
71
104
  const node = nodeOf(value);
72
105
  return node ? spelled(`plugin(${quoteString(node.identity)}, ${elided})`) : value;
73
106
  }
74
- /** The declarations one plugin value carries, which a JavaScript author reaches as any value. */
75
- function definitionOf(identity, definition) {
76
- if (!isPlainObject(definition)) {
77
- throw new DeclarationError(notAnObject, {
78
- correction: 'Supply { options, middleware, extensions, views }.',
79
- findings: [{ arguments: [identity, definition], call: 'plugin', mark: '1' }],
80
- sentence: `${pluginSentence(identity)} declares a definition that is not an object.`,
81
- });
82
- }
83
- return definition;
84
- }
85
107
  /** How a second claim on one slot reads: its clause, the note on the owner's entry, and the fix. */
86
108
  const slotWords = {
87
109
  signals: {
@@ -116,9 +138,9 @@ function slotFault(site, slot, claim) {
116
138
  /**
117
139
  * The installed list in composition order, with every rule that reads two plugins together: an
118
140
  * identity installed twice, a second claim on the theme slot, the signals slot, or the
119
- * configuration source, and two distinct descriptors under one identity. The slot is read
120
- * defensively, because a JavaScript author reaches it with any value. Each plugin's own rules
121
- * already ran at its `plugin()` call.
141
+ * configuration source, and two distinct descriptors under one identity. The slot is the copy the
142
+ * Application's constructor took, which a JavaScript author fills with any value. Each plugin's own
143
+ * rules already ran at its `plugin()` call.
122
144
  */
123
145
  function installPlugins(application, plugins) {
124
146
  const printed = Array.isArray(plugins) ? Array.from(plugins, entryCode) : plugins;
@@ -186,29 +208,38 @@ function installPlugins(application, plugins) {
186
208
  }
187
209
  return { descriptors, plugins: installed };
188
210
  }
189
- /** The keys a plugin option may not declare, in the order its diagnostic names them. */
190
- const forbidden = ['validate', 'validateOmitted', 'required'];
191
- /** The rules a plugin option answers before every rule an ordinary declaration carries. */
192
- function checkPluginOption(site, config) {
193
- const sentence = site.subject;
194
- if (!isPlainObject(config)) {
195
- throw new DeclarationError(notAnObject, {
211
+ /**
212
+ * The rules a plugin's option answers before every rule an ordinary declaration carries, read from
213
+ * the one copy of its config that `captureConfig` takes, which it answers with for every later read.
214
+ * `siteOf` places the option with the value its entry prints as: the config as declared for a value
215
+ * that is not one, elided for a config whose read threw, and the copy for every later rule.
216
+ */
217
+ function checkPluginOption(siteOf, declared) {
218
+ const sentence = siteOf(declared).subject;
219
+ const config = captureConfig(declared, {
220
+ notAnObject: () => new DeclarationError(notAnObject, {
196
221
  correction: 'Supply { type, ... }.',
197
- findings: [partFinding(site, [])],
222
+ findings: [partFinding(siteOf(declared), [])],
198
223
  sentence: `${sentence} is not an option declaration.`,
199
- });
200
- }
201
- const rejected = forbidden.find((key) => key in config);
202
- if (rejected !== undefined) {
203
- throw factFault(pluginOptionRule, site, {
204
- correction: 'Remove it; a plugin option carries no validator or presence rule, and the middleware interprets the value.',
205
- fact: rejected,
206
- sentence: `${sentence} declares ${rejected}.`,
207
- });
208
- }
224
+ }),
225
+ // The default's walk stopped at the limit, so the finding prints it elided.
226
+ tooDeep: () => {
227
+ const { keys, shown } = elidedRead('default');
228
+ return defaultDepthFault(sentence, [partFinding(siteOf(shown), keys)]);
229
+ },
230
+ // A part whose read threw is never read again, so the finding prints it elided.
231
+ unreadable: (thrown, slot) => {
232
+ const { keys, shown } = elidedRead(slot);
233
+ return unreadableFault({ declared: 'config', findings: [partFinding(siteOf(shown), keys)], subject: sentence }, thrown);
234
+ },
235
+ });
236
+ const site = siteOf(config);
237
+ // A plugin's options are global options, so they answer the global option's presence rule.
238
+ checkNoPresenceRule(site, config);
209
239
  checkDescription(site, config.description);
210
240
  checkHidden(site, config.hidden);
211
241
  checkDeprecated(site, config.deprecated);
242
+ return config;
212
243
  }
213
244
  /**
214
245
  * One plugin's option declarations, in declaration order. They join the globals table, so the rules
@@ -223,12 +254,23 @@ function readOptions(identity, declared, build) {
223
254
  });
224
255
  }
225
256
  const inputs = [];
226
- for (const [name, config] of Object.entries(declared ?? {})) {
257
+ // A finding prints each option's captured config once it is taken, and each later one elided.
258
+ const shown = Object.fromEntries(Object.keys(declared ?? {}).map((name) => [name, spelled(elided)]));
259
+ for (const [name, entry] of Object.entries(declared ?? {})) {
227
260
  const sentence = `${pluginSentence(identity)} option ${quoted(name)}`;
228
- const site = pluginOptionSite({ identity, options: declared }, name, sentence);
229
- checkPluginOption(site, config);
261
+ // A computed key defines its own property even for `__proto__`, where assignment would not.
262
+ const siteOf = (value) => pluginOptionSite({ identity, options: { ...shown, [name]: value } }, name, sentence);
263
+ const config = checkPluginOption(siteOf, entry);
264
+ // Defining the key keeps its place, and holds even for a key of `__proto__`.
265
+ Object.defineProperty(shown, name, {
266
+ configurable: true,
267
+ enumerable: true,
268
+ value: config,
269
+ writable: true,
270
+ });
271
+ const site = siteOf(config);
230
272
  checkEnvBinding(site, config);
231
- const input = { config: captureConfig(config), kind: 'option', name };
273
+ const input = { config, kind: 'option', name };
232
274
  // The shared rules name the plugin and the option, so a fault reads with its contributor.
233
275
  checkDeclarations([{ input, site }], sentence);
234
276
  build.records.set(input, buildExtensions({
@@ -243,11 +285,12 @@ function readOptions(identity, declared, build) {
243
285
  }));
244
286
  inputs.push(input);
245
287
  }
246
- // Two options of one plugin meet in the one table the pre-scan reads, so they share its rules.
288
+ // Two options of one plugin meet in the globals table every Command's table holds.
289
+ // They therefore share its rules.
247
290
  const siteOf = pluginSites(identity, inputs);
248
291
  compileOptions(inputs, { siteOf, subject: `plugin ${quoted(identity)}` });
249
292
  claimVariables(boundOptions(inputs, (input) => ({
250
- phrase: `plugin ${quoted(identity)} option "${input.name}"`,
293
+ phrase: `plugin ${quoted(identity)} option ${quoted(input.name)}`,
251
294
  site: siteOf(input),
252
295
  })));
253
296
  return inputs;
@@ -532,7 +575,8 @@ function readSource(identity, declaration, own) {
532
575
  }
533
576
  const carrier = own.inputs.find((input) => Object.hasOwn(own.build.records.get(input) ?? {}, bindingIdentity));
534
577
  if (carrier) {
535
- const option = pluginOptionSite({ identity, options: declaration.options }, carrier.name, `${sentence} option ${quoted(carrier.name)}`);
578
+ // The record prints each option's captured config, which is what its rules read.
579
+ const option = pluginSites(identity, own.inputs)(carrier);
536
580
  throw new DeclarationError(sourceBoundOwnOption, {
537
581
  correction: "Remove the value; the source's own options resolve before it loads.",
538
582
  findings: [partFinding(option, ['extensions'])],
@@ -552,7 +596,8 @@ function defineExtensions(identity, declaration, build) {
552
596
  sentence: `${pluginSentence(identity)} declares extensions that are not an array.`,
553
597
  });
554
598
  }
555
- for (const [index, descriptor] of (extensions ?? []).entries()) {
599
+ const list = Array.isArray(extensions) ? extensions : [];
600
+ for (const [index, descriptor] of list.entries()) {
556
601
  if (!isDescriptor(descriptor)) {
557
602
  throw new DeclarationError(foreignValue, {
558
603
  correction: 'Supply the value returned by extension(identity, config).',
@@ -567,14 +612,15 @@ function defineExtensions(identity, declaration, build) {
567
612
  }
568
613
  }
569
614
  /**
570
- * Every rule one definition carries on its own, in the order the definition's slots are read. The
571
- * plugin's own extensions register before any declaration carries a value, so a duplicated package
572
- * copy is reported from the list that defines it. The views list is read against core's view
573
- * identities, and the Application reads it again against every other contributor's.
615
+ * Every rule one definition carries on its own, in the order the definition's slots are read, each
616
+ * from the one copy `captureDefinition` takes once the identity is judged. The plugin's own
617
+ * extensions register before any declaration carries a value, so a duplicated package copy is
618
+ * reported from the list that defines it. The views list is read against core's view identities,
619
+ * and the Application reads the same copy again against every other contributor's.
574
620
  */
575
621
  function readPlugin(named, definition) {
576
622
  checkIdentity('plugin', named);
577
- const declaration = definitionOf(named, definition);
623
+ const declaration = captureDefinition(named, definition);
578
624
  const theme = declaration.theme === undefined ? undefined : buildTheme(declaration.theme, named);
579
625
  const build = { descriptors: new Map(), records: new Map() };
580
626
  defineExtensions(named, declaration, build);
@@ -608,34 +654,6 @@ function readPlugin(named, definition) {
608
654
  function ownedSignals(plugins) {
609
655
  return plugins.find((entry) => entry.signals.length > 0)?.signals ?? [];
610
656
  }
611
- /** A collected value, or the declared array default, as this run's own copy. */
612
- function collectedValue(collected, declared) {
613
- if (collected) {
614
- return [...collected];
615
- }
616
- return Array.isArray(declared) ? [...declared] : [];
617
- }
618
- /** One plugin option's value for one run: what a tier supplied, or the declared default. */
619
- function pluginValue({ config, name }, values) {
620
- const declared = config.default;
621
- if (config.type === 'boolean') {
622
- return booleanValue(values, name, config);
623
- }
624
- if (config.multiple === true) {
625
- return collectedValue(values.lists.get(name), declared);
626
- }
627
- // Build already proved that a string option without a validator declares a string default.
628
- return values.strings.get(name) ?? (typeof declared === 'string' ? declared : undefined);
629
- }
630
- /**
631
- * One plugin's own option values for one run: what argv or an input source supplied, or the
632
- * declared default, filled without validation. A collected value and an array default are copied,
633
- * so a plugin that writes to what it received changes neither the declaration nor the next run.
634
- * Entries become own keys even for a name such as `__proto__`, which assignment would not.
635
- */
636
- function pluginValues(inputs, values) {
637
- return Object.fromEntries(inputs.map((input) => [input.name, pluginValue(input, values)]));
638
- }
639
657
  /** One plugin's own spellings for one run, frozen, so no plugin writes what another reads. */
640
658
  function pluginSpellings(inputs, values) {
641
659
  // Entries become own keys even for a name such as `__proto__`, which assignment would not.
@@ -645,4 +663,4 @@ function pluginSpellings(inputs, values) {
645
663
  });
646
664
  return Object.freeze(Object.fromEntries(supplied));
647
665
  }
648
- export { installPlugins, loadDefault, ownedSignals, plugin, pluginSentence, pluginSpellings, pluginViews, pluginValues, };
666
+ export { installPlugins, loadDefault, ownedSignals, plugin, pluginSentence, pluginSpellings, pluginViews, };
package/dist/sources.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { OptionValues } from './options.js';
5
5
  import type { BuiltPlugin } from './plugin.js';
6
6
  import type { ContextualStyle } from './style.js';
7
7
  import type { Host, Out } from './types.js';
8
- import type { OptionInput } from './validation.js';
8
+ import type { OptionInput, Provenance, Validation } from './validation.js';
9
9
  /**
10
10
  * One scope the stage fills: its options in declaration order, and the values argv supplied them.
11
11
  * The stage writes each fill into `values`, so the caller hands it the run's own copy.
@@ -35,23 +35,32 @@ interface SourceStage {
35
35
  signal: AbortSignal;
36
36
  /** The contextual style an action receives, so a source escapes raw data before it warns. */
37
37
  style: ContextualStyle;
38
+ /**
39
+ * Validates the listed options alone, from the values the stage has filled so far, with what the
40
+ * environment tier found about them, under the context every validator of the run reads. A
41
+ * configuration source's own options pass through it before the source loads.
42
+ */
43
+ validate: (inputs: readonly OptionInput[], provenance: Provenance) => Promise<Validation>;
38
44
  }
39
45
  /**
40
46
  * What the stage found beside the values it filled. `labels` names where each filled option's value
41
- * came from, by option name, and `rejected` names the variable of each Boolean option whose value
42
- * is outside the grammar. Both are internal to core's failure messages. `fault` is a configuration
47
+ * came from, by option name, and `rejected` names the variable of each Boolean or counted option
48
+ * whose value is outside its grammar. Both are internal to core's failure messages. `fault` is a configuration
43
49
  * source's own fault, or the failure its resolver threw, which stops the stage and takes the place
44
- * of every validation problem.
50
+ * of every validation problem. `validated` is the pass over the source's own options ahead of its
51
+ * call, whose values and problems the run's one validation pass reads.
45
52
  */
46
- interface SourceOutcome {
53
+ interface SourceOutcome extends Provenance {
47
54
  fault: LoomError | undefined;
48
- labels: ReadonlyMap<string, string>;
49
- rejected: ReadonlyMap<string, string>;
55
+ validated: Validation | undefined;
50
56
  }
51
57
  /**
52
58
  * The input-source stage: the environment, then the configuration source, for every option in
53
- * scope that argv left unfilled. When no option is left to ask, the source never loads. A source
54
- * fault stops the stage and fills nothing more.
59
+ * scope that argv left unfilled. When no option is left to ask, the source never loads. The
60
+ * source's own options pass their validators before it loads, and when one is rejected the source
61
+ * is never called and the problem reports with every other validation problem, while each option
62
+ * the source would have been asked to fill reports no missing value. A source fault stops the
63
+ * stage and fills nothing more.
55
64
  */
56
65
  declare function fillInputs(stage: SourceStage): Promise<SourceOutcome>;
57
66
  export type { SourceOutcome, SourceStage, StageScope };
package/dist/sources.js CHANGED
@@ -1,8 +1,9 @@
1
- import { asSentence, foreignFailure, InternalError, LoomError, reasonOf } from './errors.js';
1
+ import { asSentence, foreignFailure, InternalError, LoomError, quoted, reasonOf, } from './errors.js';
2
2
  import { isProseLine } from './facts.js';
3
+ import { frozenValues } from './globals.js';
3
4
  import { isSupplied } from './options.js';
4
5
  import { isPlainObject } from './plain.js';
5
- import { loadDefault, pluginSentence, pluginValues } from './plugin.js';
6
+ import { loadDefault, pluginSentence } from './plugin.js';
6
7
  import { foreignThrow, foreignThrowCorrection, sourceAnswers, sourceAnswersCorrection, } from './rules.js';
7
8
  /** The Boolean grammar a bound variable is read through: the whole value, case-insensitive. */
8
9
  const truths = new Map([
@@ -11,10 +12,13 @@ const truths = new Map([
11
12
  ['false', false],
12
13
  ['true', true],
13
14
  ]);
15
+ /** The count grammar a bound variable is read through: the whole value, one or more ASCII digits. */
16
+ const countText = /^[0-9]+$/u;
14
17
  /**
15
- * Reads one option's bound variable. An empty variable is unset and falls through, a string option
16
- * receives the raw string, and a Boolean option reads the whole value through the grammar, so the
17
- * value states the option's value and not a spelling.
18
+ * Reads one option's bound variable. An empty variable is unset and falls through, and a string
19
+ * option receives the raw string, never its implied value. A Boolean option reads the whole value
20
+ * through the grammar, so the value states the option's value and not a spelling, and a counted
21
+ * option reads the whole value as decimal digits.
18
22
  */
19
23
  function readVariable(input, env) {
20
24
  const variable = input.config.env;
@@ -25,6 +29,9 @@ function readVariable(input, env) {
25
29
  if (input.config.type === 'string') {
26
30
  return { kind: 'fill', value: raw };
27
31
  }
32
+ if (input.config.type === 'count') {
33
+ return countText.test(raw) ? { kind: 'fill', value: Number(raw) } : { kind: 'rejected' };
34
+ }
28
35
  const value = truths.get(raw.toLowerCase());
29
36
  return value === undefined ? { kind: 'rejected' } : { kind: 'fill', value };
30
37
  }
@@ -33,6 +40,9 @@ function fill(values, name, value) {
33
40
  if (typeof value === 'boolean') {
34
41
  values.booleans.set(name, value);
35
42
  }
43
+ else if (typeof value === 'number') {
44
+ values.counts.set(name, value);
45
+ }
36
46
  else if (typeof value === 'string') {
37
47
  values.strings.set(name, value);
38
48
  }
@@ -43,12 +53,13 @@ function fill(values, name, value) {
43
53
  /** How a fault names the value an answer owed, by the shape the option takes. */
44
54
  const owed = {
45
55
  boolean: 'a Boolean',
56
+ count: 'a whole number of 0 or more',
46
57
  list: 'an array of strings',
47
58
  string: 'a string',
48
59
  };
49
60
  function rawKind(input) {
50
- if (input.config.type === 'boolean') {
51
- return 'boolean';
61
+ if (input.config.type !== 'string') {
62
+ return input.config.type;
52
63
  }
53
64
  return input.config.multiple === true ? 'list' : 'string';
54
65
  }
@@ -78,6 +89,9 @@ function rawValue(kind, value) {
78
89
  if (kind === 'boolean') {
79
90
  return typeof value === 'boolean' ? value : undefined;
80
91
  }
92
+ if (kind === 'count') {
93
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
94
+ }
81
95
  return typeof value === 'string' ? value : undefined;
82
96
  }
83
97
  /**
@@ -100,12 +114,12 @@ function readAnswer(sentence, input, answer) {
100
114
  const shape = isPlainObject(answer) ? answer : undefined;
101
115
  const label = shape?.label;
102
116
  if (!shape || !isProseLine(label)) {
103
- throw ruleFault(`${sentence} answered option "${input.name}" with an answer that is not { value, label }.`);
117
+ throw ruleFault(`${sentence} answered option ${quoted(input.name)} with an answer that is not { value, label }.`);
104
118
  }
105
119
  const kind = rawKind(input);
106
120
  const value = rawValue(kind, shape.value);
107
121
  if (value === undefined) {
108
- throw ruleFault(`${sentence} answered option "${input.name}" with a value that is not ${owed[kind]}.`);
122
+ throw ruleFault(`${sentence} answered option ${quoted(input.name)} with a value that is not ${owed[kind]}.`);
109
123
  }
110
124
  return { label, value };
111
125
  }
@@ -121,7 +135,7 @@ function readAnswers(sentence, answers, requested) {
121
135
  return Object.entries(answers).map(([name, answer]) => {
122
136
  const target = byName.get(name);
123
137
  if (!target) {
124
- throw ruleFault(`${sentence} answered option "${name}", which core did not request.`);
138
+ throw ruleFault(`${sentence} answered option ${quoted(name)}, which core did not request.`);
125
139
  }
126
140
  const { label, value } = readAnswer(sentence, target.input, answer);
127
141
  return { label, target, value };
@@ -150,7 +164,7 @@ function sourceFailure(sentence, error) {
150
164
  * Core builds the context before the call, so a fault of its own is never the plugin's.
151
165
  */
152
166
  async function askSource(stage, call) {
153
- const { owner, requested } = call;
167
+ const { options, owner, requested } = call;
154
168
  const resolver = await loadDefault(owner.identity, owner.source.load, {
155
169
  guard: isResolverExport,
156
170
  noun: 'source',
@@ -162,7 +176,8 @@ async function askSource(stage, call) {
162
176
  const context = {
163
177
  graph: stage.inspected(),
164
178
  host: stage.host,
165
- options: pluginValues(owner.inputs, stage.globals.values),
179
+ // Validation produced the record the plugin's option types describe.
180
+ options,
166
181
  out: stage.out,
167
182
  requests: requested.map(({ input, scope }) => stage.request(input, scope.global)),
168
183
  style: stage.style,
@@ -193,8 +208,8 @@ async function askSource(stage, call) {
193
208
  }
194
209
  /**
195
210
  * The environment tier: each option in scope that argv left unfilled reads its bound variable. A
196
- * filled option is labeled with its variable, and a Boolean one outside the grammar is recorded
197
- * as rejected, which fills nothing.
211
+ * filled option is labeled with its variable, and a Boolean or counted one outside its grammar is
212
+ * recorded as rejected, which fills nothing.
198
213
  */
199
214
  function fillFromEnvironment(scopes, env) {
200
215
  const labels = new Map();
@@ -228,8 +243,11 @@ function requestedOf(scopes, excluded, bound) {
228
243
  }
229
244
  /**
230
245
  * The input-source stage: the environment, then the configuration source, for every option in
231
- * scope that argv left unfilled. When no option is left to ask, the source never loads. A source
232
- * fault stops the stage and fills nothing more.
246
+ * scope that argv left unfilled. When no option is left to ask, the source never loads. The
247
+ * source's own options pass their validators before it loads, and when one is rejected the source
248
+ * is never called and the problem reports with every other validation problem, while each option
249
+ * the source would have been asked to fill reports no missing value. A source fault stops the
250
+ * stage and fills nothing more.
233
251
  */
234
252
  async function fillInputs(stage) {
235
253
  const scopes = stage.locals ? [stage.globals, stage.locals] : [stage.globals];
@@ -239,20 +257,32 @@ async function fillInputs(stage) {
239
257
  ? requestedOf(scopes, rejected, { binding: owner.source.binding, extensions: stage.extensions })
240
258
  : [];
241
259
  if (!owner || requested.length === 0) {
242
- return { fault: undefined, labels, rejected };
260
+ return { fault: undefined, labels, rejected, validated: undefined };
243
261
  }
262
+ let validated = undefined;
244
263
  try {
245
- for (const { label, target, value } of await askSource(stage, { owner, requested })) {
264
+ validated = await stage.validate(owner.inputs, { labels, rejected });
265
+ // A rejected own option skips the source, and the options it would have filled report no omission.
266
+ if (validated.failure) {
267
+ const unanswered = new Set(requested.map(({ input }) => input));
268
+ return { fault: undefined, labels, rejected, unanswered, validated };
269
+ }
270
+ // A run cancelled while the source's own options were validated loads nothing.
271
+ if (stage.signal.aborted) {
272
+ return { fault: undefined, labels, rejected, validated };
273
+ }
274
+ const options = frozenValues(owner.inputs, validated.values);
275
+ for (const { label, target, value } of await askSource(stage, { options, owner, requested })) {
246
276
  fill(target.scope.values, target.input.name, value);
247
277
  labels.set(target.input.name, label);
248
278
  }
249
- return { fault: undefined, labels, rejected };
279
+ return { fault: undefined, labels, rejected, validated };
250
280
  }
251
281
  catch (error) {
252
282
  // The resolver's own failure keeps its class, and so its code.
253
283
  // Every other fault above is raised as an internal error, and anything else is wrapped the same way.
254
284
  const fault = error instanceof LoomError ? error : foreignFailure(error);
255
- return { fault, labels, rejected };
285
+ return { fault, labels, rejected, validated };
256
286
  }
257
287
  }
258
288
  export { fillInputs };
@@ -1,4 +1,4 @@
1
- import { graphemeWidths } from '@rockorager/uucode/width';
1
+ import { graphemeWidths } from './style-width.js';
2
2
  const offsets = [0, 1, 2, 3, 4, 5, 6, 7];
3
3
  const empty = {
4
4
  advance: offsets.map(() => 0),
@@ -117,6 +117,7 @@ function padBlock(block, padding, attributes) {
117
117
  if (!block.tabs && block.minimum >= padding.width) {
118
118
  return block;
119
119
  }
120
+ const space = (count) => leaf({ attributes, kind: 'text', text: ' '.repeat(count) }, count);
120
121
  const lines = block.lines.map((line, index) => {
121
122
  // A terminal newline has no extra line to align; interior blank lines do.
122
123
  if (index > 0 && index === block.lines.length - 1 && !line.content.text) {
@@ -134,7 +135,6 @@ function padBlock(block, padding, attributes) {
134
135
  else if (padding.align === 'center') {
135
136
  before = Math.floor(missing / 2);
136
137
  }
137
- const space = (count) => leaf({ attributes, kind: 'text', text: ' '.repeat(count) }, count);
138
138
  // Re-segment inserted spaces too: a trailing Unicode Prepend character can absorb one.
139
139
  const spaced = join([space(before), content, space(missing - before)]);
140
140
  const row = measured([{ content: spaced, ending: [] }]);
@@ -0,0 +1,13 @@
1
+ /** One grapheme cluster of a string: where it starts and ends, and the columns it occupies. */
2
+ interface GraphemeWidth {
3
+ start: number;
4
+ end: number;
5
+ width: number;
6
+ }
7
+ /**
8
+ * Each grapheme cluster of a string with the terminal columns it occupies. Printable ASCII that no
9
+ * non-ASCII character follows is its own one-column cluster. Any other run of clusters is read by
10
+ * one cursor, which starts at the run and carries the break state from cluster to cluster.
11
+ */
12
+ export declare function graphemeWidths(text: string): Generator<GraphemeWidth>;
13
+ export {};