@loomcli/core 0.4.0 → 0.5.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.
@@ -73,33 +73,44 @@ function spellingOf(input) {
73
73
  return input.name;
74
74
  }
75
75
  const { config } = input;
76
- return config.shortOnly === true && config.short !== undefined
77
- ? `-${config.short}`
76
+ if (config.shortOnly === true && config.short !== undefined) {
77
+ return `-${config.short}`;
78
+ }
79
+ // A negative-only Boolean option accepts its negative form alone.
80
+ return config.type === 'boolean' && config.polarity === 'negative'
81
+ ? `--no-${input.name}`
78
82
  : `--${input.name}`;
79
83
  }
80
- /** An input diagnostic names the declaration by kind and by the spelling that reaches it. */
81
- function suppliedName(input, spelling) {
82
- return input.kind === 'argument' ? `Argument "${spelling}"` : `Option "${spelling}"`;
84
+ /**
85
+ * An input diagnostic names the declaration by kind and by the spelling that reaches it. A value an
86
+ * input source filled adds its source in parentheses, so the operator learns where it came from.
87
+ */
88
+ function suppliedName(input, spelling, origin) {
89
+ if (input.kind === 'argument') {
90
+ return `Argument "${spelling}"`;
91
+ }
92
+ return origin === undefined ? `Option "${spelling}"` : `Option "${spelling}" (from ${origin})`;
83
93
  }
84
94
  /**
85
95
  * The default sentence for an omitted required input. An argument and an option keep the wording
86
96
  * each phase used before omission became one problem, and a collected input asks for one value
87
97
  * more than a scalar does.
88
98
  */
89
- function missingMessage(input, spelling, collected) {
99
+ function missingMessage(input, spelling, facts) {
100
+ const { collected, origin } = facts;
90
101
  return input.kind === 'argument'
91
102
  ? `Argument "${spelling}" requires ${collected ? 'at least one value' : 'a value'}. Supply a value for "${spelling}".`
92
- : `Option "${spelling}" is required. Supply ${collected ? 'at least one value' : 'a value'}.`;
103
+ : `${suppliedName(input, spelling, origin)} is required. Supply ${collected ? 'at least one value' : 'a value'}.`;
93
104
  }
94
105
  /**
95
106
  * A multiple option collects its occurrences and a variadic argument collects the remaining
96
- * tokens, so either one carries the whole `string[]` as its raw value.
107
+ * tokens, so either one carries a `string[]` as its raw value and validates each value alone.
97
108
  */
98
109
  function collects(input) {
99
110
  return input.kind === 'option' ? input.config.multiple === true : input.config.variadic === true;
100
111
  }
101
112
  /**
102
- * The declaration flag that sends an omitted value to its own schema. Every declaration reads it
113
+ * The declaration flag that sends an omitted value to its own validator. Every declaration reads it
103
114
  * here, and the declaration rules below reject it wherever another rule already decides absence.
104
115
  */
105
116
  export function validatesOmission(input) {
@@ -114,16 +125,23 @@ function suppliedOption(options, name, collected) {
114
125
  function copied(value) {
115
126
  return Array.isArray(value) ? [...value] : value;
116
127
  }
117
- /** Without a schema the raw shape is the declared default's only contract. */
128
+ /**
129
+ * Without a validator the raw shape is the declared default's only contract. With one, a default
130
+ * of several values must still be an array, because each of its values passes the validator.
131
+ */
118
132
  function holdsRawDefault(input) {
119
133
  const value = input.config.default;
120
- return collects(input)
121
- ? Array.isArray(value) && value.every((entry) => typeof entry === 'string')
122
- : typeof value === 'string';
134
+ if (!collects(input)) {
135
+ return input.config.validate !== undefined || typeof value === 'string';
136
+ }
137
+ return (Array.isArray(value) &&
138
+ (input.config.validate !== undefined ||
139
+ value.every((entry) => typeof entry === 'string')));
123
140
  }
124
141
  /**
125
- * `validateOmitted: true` is the one way an omitted scalar reaches its schema, so every other rule
126
- * that already decides absence rejects it, and the flag needs a schema to receive the omission.
142
+ * `validateOmitted: true` is the one way an omitted scalar reaches its validator, so every other
143
+ * rule that already decides absence rejects it, and the flag needs a validator to receive the
144
+ * omission.
127
145
  */
128
146
  function checkOmissionValidation(input, subject) {
129
147
  const { config } = input;
@@ -134,10 +152,10 @@ function checkOmissionValidation(input, subject) {
134
152
  throw new DeclarationError(`${subject} declares a default and validateOmitted. Remove one; the default already fills an omitted value.`);
135
153
  }
136
154
  if (collects(input)) {
137
- throw new DeclarationError(`${subject} collects its values and declares validateOmitted. Remove validateOmitted; an omitted collection reaches the schema as an empty array.`);
155
+ throw new DeclarationError(`${subject} takes several values and declares validateOmitted. Remove validateOmitted; with no values the action receives an empty array and no validator runs.`);
138
156
  }
139
157
  if (config.validate === undefined) {
140
- throw new DeclarationError(`${subject} declares validateOmitted without a schema. Add validate or remove validateOmitted.`);
158
+ throw new DeclarationError(`${subject} declares validateOmitted without a validator. Add validate or remove validateOmitted.`);
141
159
  }
142
160
  }
143
161
  function checkDeclaration(input, subject) {
@@ -169,15 +187,15 @@ function checkDeclaration(input, subject) {
169
187
  if (validatesOmission(input)) {
170
188
  checkOmissionValidation(input, subject);
171
189
  }
172
- const schema = config.validate;
173
- if (schema !== undefined &&
174
- (schema === null ||
175
- (typeof schema !== 'object' && typeof schema !== 'function') ||
176
- !schema['~standard'] ||
177
- schema['~standard'].version !== 1 ||
178
- typeof schema['~standard'].vendor !== 'string' ||
179
- typeof schema['~standard'].validate !== 'function')) {
180
- throw new DeclarationError(`${subject} validate must be a Standard Schema v1 object. Supply a compatible schema.`);
190
+ const validator = config.validate;
191
+ if (validator !== undefined &&
192
+ (validator === null ||
193
+ (typeof validator !== 'object' && typeof validator !== 'function') ||
194
+ !validator['~standard'] ||
195
+ validator['~standard'].version !== 1 ||
196
+ typeof validator['~standard'].vendor !== 'string' ||
197
+ typeof validator['~standard'].validate !== 'function')) {
198
+ throw new DeclarationError(`${subject} validate must be a Standard Schema v1 object. Supply a compatible validator.`);
181
199
  }
182
200
  }
183
201
  function readIssue(issue) {
@@ -209,12 +227,12 @@ function readIssue(issue) {
209
227
  * names the declaration. Returned issues belong to the value, so the caller names those.
210
228
  */
211
229
  async function validate(input, raw, context) {
212
- const schema = input.config.validate;
213
- if (schema === undefined) {
230
+ const validator = input.config.validate;
231
+ if (validator === undefined) {
214
232
  return { value: raw };
215
233
  }
216
234
  try {
217
- const result = await schema['~standard'].validate(raw, schemaOptions(context));
235
+ const result = await validator['~standard'].validate(raw, schemaOptions(context));
218
236
  if (result === null || typeof result !== 'object') {
219
237
  throw new Error('The validator returned an invalid Standard Schema result.');
220
238
  }
@@ -232,6 +250,42 @@ async function validate(input, raw, context) {
232
250
  throw new DeclarationError(`${declaredName(input)} validator failed unexpectedly: ${reason} Fix the validator.`);
233
251
  }
234
252
  }
253
+ /**
254
+ * One validation of a declared value. A multiple option or a variadic argument passes each of its
255
+ * values through the validator in order, and each issue reads at its value's position before its
256
+ * own path, so the action receives the array of outputs. Every other input passes its value once.
257
+ * Each call reads a fresh context whose arrays are copies, so a write to them never reaches the
258
+ * next call. The host is the one captured object that every call and the action share.
259
+ */
260
+ async function validateDeclared(input, raw, call) {
261
+ const { context, signal } = call;
262
+ if (!collects(input) || input.config.validate === undefined) {
263
+ return validate(input, raw, context());
264
+ }
265
+ if (!Array.isArray(raw)) {
266
+ // The parser, the input sources, and the declaration rules only ever supply an array here.
267
+ throw new TypeError(`${declaredName(input)} reached validation without an array of values.`);
268
+ }
269
+ const outputs = [];
270
+ const issues = [];
271
+ for (const [position, value] of raw.entries()) {
272
+ if (signal?.aborted) {
273
+ // A cancelled run starts no further call; the run resolves its cancellation code instead.
274
+ break;
275
+ }
276
+ const result = await validate(input, value, context());
277
+ if (result.issues === undefined) {
278
+ outputs.push(result.value);
279
+ }
280
+ else {
281
+ issues.push(...reported(result.issues).map((issue) => ({
282
+ message: issue.message,
283
+ path: [position, ...(issue.path ?? [])],
284
+ })));
285
+ }
286
+ }
287
+ return issues.length === 0 ? { value: outputs } : { issues };
288
+ }
235
289
  /**
236
290
  * The dotted path an issue names inside a value, or `undefined` when the issue names the value
237
291
  * itself. Core's default text and an application's own view read a position through this one
@@ -244,13 +298,13 @@ export function issuePath(issue) {
244
298
  return path === undefined || path === '' ? undefined : path;
245
299
  }
246
300
  /**
247
- * The issues one rejection reports. A schema that returned none still rejected the value, so the
301
+ * The issues one rejection reports. A validator that returned none still rejected the value, so the
248
302
  * placeholder stands in for its silence. Reporting takes this list once: the reported problem
249
303
  * carries it and the default text is derived from it, so a view and core read the same issues.
250
304
  */
251
305
  function reported(issues) {
252
306
  return issues.length === 0
253
- ? [{ message: 'The schema rejected this value without an explanation.' }]
307
+ ? [{ message: 'The validator rejected this value without an explanation.' }]
254
308
  : issues;
255
309
  }
256
310
  function messages(subject, issues) {
@@ -259,58 +313,62 @@ function messages(subject, issues) {
259
313
  return `${subject}${path === undefined ? '' : ` at ${path}`}: ${issue.message}`;
260
314
  });
261
315
  }
316
+ /** The fault a default of the wrong raw shape reports, by the shape its declaration expects. */
317
+ function defaultShapeFault(input, subject) {
318
+ if (!collects(input)) {
319
+ return `${subject} default must be a string without a validator. Supply a string default.`;
320
+ }
321
+ return input.config.validate === undefined
322
+ ? `${subject} default must be an array of strings without a validator. Supply a string array default.`
323
+ : `${subject} default must be an array. Supply an array of values.`;
324
+ }
262
325
  /** A declared `default: undefined` is a default, so presence is the key, never the value. */
263
326
  function hasDefault(input) {
264
327
  return Object.hasOwn(input.config, 'default');
265
328
  }
266
329
  /**
267
- * Every declaration rule that reads the declaration alone. It is synchronous, so `inspect()` and
268
- * `run()` apply exactly the same rules, and only validating a default through its schema, which
269
- * can be asynchronous, is left to `run()`. A contributor that declares under its own name, such as
270
- * a plugin, supplies the subject its diagnostics read with; every other caller is named by the
271
- * declaration itself.
330
+ * Every declaration rule that reads the declaration alone. It is synchronous, so the call that
331
+ * declares an input applies it, and build applies it to an input a lifecycle hook declared; only
332
+ * validating a default through its validator, which can be asynchronous, is left to `run()`. A
333
+ * contributor that declares under its own name, such as a plugin, supplies the subject its
334
+ * diagnostics read with; every other caller is named by the declaration itself.
272
335
  */
273
336
  export function checkDeclarations(inputs, named) {
274
337
  for (const input of inputs) {
275
338
  checkDeclaration(input, named ?? declaredName(input));
276
339
  }
277
340
  for (const input of inputs.filter((entry) => hasDefault(entry))) {
278
- if (input.config.validate === undefined && !holdsRawDefault(input)) {
341
+ if (!holdsRawDefault(input)) {
279
342
  const subject = named ?? declaredName(input);
280
- throw new DeclarationError(collects(input)
281
- ? `${subject} default must be an array of strings without a schema. Supply a string array default.`
282
- : `${subject} default must be a string without a schema. Supply a string default.`);
343
+ throw new DeclarationError(defaultShapeFault(input, subject));
283
344
  }
284
345
  }
285
346
  }
286
347
  /**
287
348
  * Every declared default, validated before any token is read. The host is captured by then, so a
288
- * default's schema reads the same Host its action will, under the `default` phase.
349
+ * default's validator reads the same Host its action will, under the `default` phase.
289
350
  */
290
351
  export async function prepareInputs(inputs, host) {
291
352
  const declarations = scoped(inputs);
292
- checkDeclarations(declarations.map((entry) => entry.input));
293
353
  const defaults = new Map();
294
354
  for (const entry of declarations.filter(({ input }) => hasDefault(input))) {
295
355
  const { input } = entry;
296
356
  const subject = declaredName(input);
297
- const result = await validate(input, input.config.default, {
298
- host,
299
- input: identityOf(entry),
300
- phase: 'default',
357
+ const result = await validateDeclared(input, input.config.default, {
358
+ context: () => ({ host, input: identityOf(entry), phase: 'default' }),
301
359
  });
302
360
  if (result.issues !== undefined) {
303
- throw new DeclarationError(`${subject} has an invalid default. Fix the default or its schema.\n${messages(subject, reported(result.issues)).join('\n')}`);
361
+ throw new DeclarationError(`${subject} has an invalid default. Fix the default or its validator.\n${messages(subject, reported(result.issues)).join('\n')}`);
304
362
  }
305
363
  defaults.set(input, result.value);
306
364
  }
307
365
  return defaults;
308
366
  }
309
367
  /**
310
- * An array default reaches the action as its own copy, so an action that mutates its collection
311
- * rewrites neither the declaration nor the next invocation. A schema that returns a new array is
312
- * copied too, because a pass-through schema returns the declared array itself and cannot be told
313
- * apart from one that built its own. Every other output passes through unchanged.
368
+ * An array default reaches the action as its own copy, so an action that mutates its array
369
+ * rewrites neither the declaration nor the next invocation. One prepared default serves every
370
+ * invocation of a run, and an unvalidated default is the declared array itself, so each read
371
+ * copies it. Every other output passes through unchanged.
314
372
  */
315
373
  function freshDefault(value) {
316
374
  return Array.isArray(value) ? [...value] : value;
@@ -339,50 +397,94 @@ function suppliedInputs(declarations, supplied) {
339
397
  }
340
398
  return { args, options };
341
399
  }
400
+ /** The Boolean grammar's one issue, which a variable outside it reports. */
401
+ const grammarIssues = [{ message: 'Use true, false, 1, or 0.' }];
402
+ /**
403
+ * Every declaration in the order this phase reports its problems: the globals, then each plugin
404
+ * option whose variable is outside the grammar, then the routed Command's own declarations.
405
+ */
406
+ function reportingOrder(invocation) {
407
+ const declarations = scoped(invocation.inputs);
408
+ const globals = declarations.filter((entry) => entry.global);
409
+ const rejected = invocation.plugins
410
+ .filter((input) => invocation.sources.rejected.has(input.name))
411
+ .map((input) => ({ global: true, input }));
412
+ return [...globals, ...rejected, ...declarations.filter((entry) => !entry.global)];
413
+ }
342
414
  export async function validateValues(invocation) {
343
- const { defaults, supplied } = invocation;
415
+ const { defaults, sources, supplied } = invocation;
344
416
  const declarations = scoped(invocation.inputs);
417
+ /** Where a filled option's value came from, which its diagnostic names; argv names none. */
418
+ const originOf = (input) => input.kind === 'option' ? sources.labels.get(input.name) : undefined;
345
419
  /**
346
- * One reading of the tokens and the route, built anew for each schema call. The route, the
347
- * tail, and every collected value are copies, so a schema that writes to them reaches neither
348
- * the parser's collections, nor the tail the action receives, nor the next schema of this
349
- * invocation. The host is the captured object itself, the one the action receives.
420
+ * One reading of the tokens and the route, built anew for each validator call. The route, the
421
+ * tail, and every collected value are copies, so a validator that writes to them reaches neither
422
+ * the parser's collections, nor the tail the action receives, nor the next validator call of
423
+ * this invocation. Each copy is made on its first read and kept for the call, so a validator
424
+ * that never reads one never pays for it, however many values a list holds. The host is the
425
+ * captured object itself, the one the action receives.
350
426
  */
351
- const facts = () => ({
352
- command: [...invocation.command],
353
- host: invocation.host,
354
- passthrough: [...invocation.passthrough],
355
- supplied: suppliedInputs(declarations.map((entry) => entry.input), supplied),
356
- });
427
+ const contextOf = (entry) => {
428
+ const copies = {};
429
+ return {
430
+ get command() {
431
+ copies.command ??= [...invocation.command];
432
+ return copies.command;
433
+ },
434
+ host: invocation.host,
435
+ input: identityOf(entry),
436
+ get passthrough() {
437
+ copies.passthrough ??= [...invocation.passthrough];
438
+ return copies.passthrough;
439
+ },
440
+ phase: 'invocation',
441
+ get supplied() {
442
+ copies.supplied ??= suppliedInputs(declarations.map((declared) => declared.input), supplied);
443
+ return copies.supplied;
444
+ },
445
+ };
446
+ };
357
447
  const values = new Map();
358
448
  const lines = [];
359
449
  const problems = [];
360
- /** One path for every value the schema reads, so a raw shape and its issues meet it once. */
361
- const accept = async (entry, raw, spelling) => {
362
- const result = await validate(entry.input, raw, {
363
- ...facts(),
450
+ /** One rejected input, whatever rejected it, in the order this phase reaches it. */
451
+ const reject = (entry, issues, subject) => {
452
+ problems.push({
364
453
  input: identityOf(entry),
365
- phase: 'invocation',
454
+ issues,
455
+ reason: 'invalid',
456
+ spelling: spellingOf(entry.input),
457
+ });
458
+ lines.push(...messages(subject, issues));
459
+ };
460
+ /** One path for every value a validator reads, so a raw shape and its issues meet it once. */
461
+ const accept = async (entry, raw, spelling) => {
462
+ const result = await validateDeclared(entry.input, raw, {
463
+ context: () => contextOf(entry),
464
+ signal: invocation.signal,
366
465
  });
367
466
  if (result.issues === undefined) {
368
467
  values.set(entry.input, result.value);
369
468
  return;
370
469
  }
371
- const issues = reported(result.issues);
372
- problems.push({ input: identityOf(entry), issues, reason: 'invalid', spelling });
373
- lines.push(...messages(suppliedName(entry.input, spelling), issues));
470
+ reject(entry, reported(result.issues), suppliedName(entry.input, spelling, originOf(entry.input)));
374
471
  };
375
- for (const entry of declarations) {
472
+ for (const entry of reportingOrder(invocation)) {
376
473
  if (invocation.signal.aborted) {
377
474
  /**
378
- * A cancelled run starts no further schema call. The one already in flight was awaited
475
+ * A cancelled run starts no further validator call. The one already in flight was awaited
379
476
  * above, and whatever this phase collected is never raised, because the run resolves its
380
477
  * cancellation code instead.
381
478
  */
382
479
  break;
383
480
  }
384
481
  const { input } = entry;
385
- if (input.kind === 'option' && input.config.type === 'boolean') {
482
+ const variable = sources.rejected.get(input.name);
483
+ if (input.kind === 'option' && variable !== undefined) {
484
+ // A Boolean variable outside the grammar filled nothing, so it is the option's problem.
485
+ reject(entry, grammarIssues, suppliedName(input, spellingOf(input), variable));
486
+ }
487
+ else if (input.kind === 'option' && input.config.type === 'boolean') {
386
488
  values.set(input, booleanValue(supplied.options, input.name, input.config));
387
489
  }
388
490
  else {
@@ -396,14 +498,14 @@ export async function validateValues(invocation) {
396
498
  // An omitted required argument arrives here too, so omission has one class.
397
499
  // One aggregated diagnostic covers an omitted argument and an omitted option alike.
398
500
  problems.push({ input: identityOf(entry), reason: 'missing', spelling });
399
- lines.push(missingMessage(input, spelling, collected));
501
+ lines.push(missingMessage(input, spelling, { collected }));
400
502
  }
401
503
  else if (collected && !defaults.has(input)) {
402
- // No occurrence is an accurate empty collection, so it reads like a supplied value.
403
- await accept(entry, [], spelling);
504
+ // No occurrence has no value to validate, so the action receives an empty array.
505
+ values.set(input, []);
404
506
  }
405
507
  else if (validatesOmission(input)) {
406
- // The flag sends the omission itself to the schema.
508
+ // The flag sends the omission itself to the validator.
407
509
  // An absence rule reads the context a supplied value reads, and reports input issues.
408
510
  await accept(entry, undefined, spelling);
409
511
  }
@@ -411,6 +513,11 @@ export async function validateValues(invocation) {
411
513
  values.set(input, freshDefault(defaults.get(input)));
412
514
  }
413
515
  }
516
+ else if (input.config.required && Array.isArray(raw) && raw.length === 0) {
517
+ // A filled list satisfies the at-least-one rule by its length, so an empty one is missing.
518
+ problems.push({ input: identityOf(entry), reason: 'missing', spelling });
519
+ lines.push(missingMessage(input, spelling, { collected, origin: originOf(input) }));
520
+ }
414
521
  else {
415
522
  await accept(entry, raw, spelling);
416
523
  }
package/dist/view.d.ts CHANGED
@@ -78,7 +78,7 @@ declare function view<Data>(identity: string, definition: View<Data>): DeclaredV
78
78
  declare function view<Row>(identity: string, definition: RowView<Row>): DeclaredRowView<Row>;
79
79
  /**
80
80
  * What one override replaces: a declared view, a failure class read as its prototype, or a value
81
- * that is neither, which build reports as the entry fault of the list that holds it.
81
+ * that is neither, which the list that holds it reports as its entry fault.
82
82
  */
83
83
  type OverrideKey = {
84
84
  kind: 'view';
package/dist/view.js CHANGED
@@ -83,8 +83,8 @@ function override(key, replacement) {
83
83
  }
84
84
  /**
85
85
  * The key one failure-class override answers. A class is a function whose `prototype` is the
86
- * object a thrown failure's chain holds, so anything else is no key at all and build reports it as
87
- * the entry fault of the list that holds it, rather than colliding with every other such value.
86
+ * object a thrown failure's chain holds, so anything else is no key at all and the list that holds
87
+ * it reports it as its entry fault, rather than colliding with every other such value.
88
88
  */
89
89
  function failureKey(key) {
90
90
  if (typeof key !== 'function' || !('prototype' in key)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomcli/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The Loom CLI core package. Provides the scaffolding for creating new Loom CLI applications.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,7 +8,8 @@
8
8
  "url": "git+https://github.com/dbtlr/loomcli.git"
9
9
  },
10
10
  "files": [
11
- "dist"
11
+ "dist",
12
+ "LICENSE"
12
13
  ],
13
14
  "type": "module",
14
15
  "exports": {