@loomcli/core 0.7.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.
package/dist/inspect.d.ts CHANGED
@@ -21,17 +21,22 @@ interface ArgumentNode {
21
21
  readonly extensions: Readonly<Record<string, unknown>>;
22
22
  }
23
23
  /**
24
- * One declared option, in the shape its type gives it. Spellings are the accepted CLI forms, and
25
- * `scope` tells an application's own option from a plugin option, which reaches no action. An
26
- * option a plugin's lifecycle hook declared on a Command is that Command's own in every respect, so
27
- * it reads `application` and names no plugin.
24
+ * One declared option, in the shape its type gives it. Spellings are the accepted CLI forms. A
25
+ * global option reads the same whether the application or a plugin declared it, and an option a
26
+ * plugin's lifecycle hook declared on a Command is that Command's own in every respect, so neither
27
+ * names a plugin.
28
28
  * `hidden` is `false` unless the declaration says `true`, and `deprecated` is the declared
29
29
  * migration message or `undefined`. A listing projection omits a hidden node and marks a
30
30
  * deprecated one; parsing binds without reading either.
31
- * `schema` is the input schema the validator publishes. A Boolean option validates nothing, so its
32
- * variant carries the field at `null`, and every projection built on the node holds if a later
33
- * contract lets it validate.
31
+ * `schema` is the input schema the validator publishes. A Boolean option and a counted option
32
+ * validate nothing, so their variants carry the field at `null`, and every projection built on the
33
+ * node holds if a later contract lets a Boolean option validate. A counted option has no negative
34
+ * spelling, polarity, default, or validator, so its variant carries none of them. A string option's
35
+ * `implied` is the value a bare spelling supplies, or `null` when it declares none.
34
36
  * `env` is the variable the option's environment binding names, or `null` when it binds none.
37
+ * `aliases` holds the declared aliases as bare names in declaration order. An alias is
38
+ * unadvertised, so the spellings above are the ones the declared name derives and no listing reads
39
+ * `aliases`; parsing and `locate` read the table, which holds every alias's spellings.
35
40
  */
36
41
  type OptionNode = {
37
42
  readonly type: 'string';
@@ -39,9 +44,9 @@ type OptionNode = {
39
44
  readonly description: string | undefined;
40
45
  readonly hidden: boolean;
41
46
  readonly deprecated: string | undefined;
42
- readonly scope: 'application' | 'plugin';
43
47
  readonly long: string | null;
44
48
  readonly short: string | null;
49
+ readonly aliases: readonly string[];
45
50
  readonly required: boolean;
46
51
  readonly multiple: boolean;
47
52
  readonly validated: boolean;
@@ -51,6 +56,7 @@ type OptionNode = {
51
56
  readonly default: {
52
57
  readonly value: unknown;
53
58
  } | undefined;
59
+ readonly implied: string | null;
54
60
  readonly extensions: Readonly<Record<string, unknown>>;
55
61
  } | {
56
62
  readonly type: 'boolean';
@@ -58,14 +64,26 @@ type OptionNode = {
58
64
  readonly description: string | undefined;
59
65
  readonly hidden: boolean;
60
66
  readonly deprecated: string | undefined;
61
- readonly scope: 'application' | 'plugin';
62
67
  readonly long: string | null;
63
68
  readonly short: string | null;
64
69
  readonly negative: string | null;
70
+ readonly aliases: readonly string[];
65
71
  readonly polarity: 'positive' | 'negative' | 'both';
66
72
  readonly schema: InputSchema;
67
73
  readonly env: string | null;
68
74
  readonly extensions: Readonly<Record<string, unknown>>;
75
+ } | {
76
+ readonly type: 'count';
77
+ readonly name: string;
78
+ readonly description: string | undefined;
79
+ readonly hidden: boolean;
80
+ readonly deprecated: string | undefined;
81
+ readonly long: string | null;
82
+ readonly short: string | null;
83
+ readonly aliases: readonly string[];
84
+ readonly schema: null;
85
+ readonly env: string | null;
86
+ readonly extensions: Readonly<Record<string, unknown>>;
69
87
  };
70
88
  /**
71
89
  * One Command in the graph. `name` is `null` for the root, and `path` is its route from it.
@@ -110,13 +128,19 @@ interface CommandGraph {
110
128
  readonly globals: readonly OptionNode[];
111
129
  readonly root: CommandNode;
112
130
  }
131
+ /**
132
+ * The spelling every reported problem names one option by, the one core's own validation reports:
133
+ * its long form, a negative-only Boolean option's negative form, and otherwise its short form. A
134
+ * plugin that reports a problem for an option, such as an input source, names it this way too.
135
+ */
136
+ export declare function reportedSpelling(option: OptionNode): string;
113
137
  /** The declared result as plain data, or `null` on a Command that declares none. */
114
138
  declare function resultNode(result: DeclaredResult | undefined): ResultNode | null;
115
139
  /**
116
140
  * Renders one built graph as frozen plain data. Nothing here reads a host fact; each validated
117
141
  * input's converter is asked for its input schema, and in a development build a converter that
118
- * fails is a declaration fault. The globals list holds the application's own options, then each
119
- * installed plugin's in installation order, which is the order the globals table holds them in.
142
+ * fails is a declaration fault. The globals list holds every global option, the application's own
143
+ * and then each installed plugin's in installation order, which is the order the table holds them.
120
144
  */
121
145
  declare function inspectGraph(name: string, graph: BuiltGraph, facts: {
122
146
  description: string | undefined;
package/dist/inspect.js CHANGED
@@ -1,24 +1,20 @@
1
1
  import { asSentence, DeclarationError, InternalError, reasonOf } from './errors.js';
2
2
  import { partOf, siteFinding } from './facts.js';
3
3
  import { schemaConverterFailed } from './input-rules.js';
4
+ import { reportedOf, spellingsOf } from './options.js';
4
5
  import { isPlainObject, snapshotRecord } from './plain.js';
5
6
  import { foreignGraph, foreignGraphCorrection } from './rules.js';
6
- import { declaringSite, inputPlace, validatesOmission } from './validation.js';
7
+ import { declarationSubject, declaringSite, inputPlace, validatesOmission } from './validation.js';
7
8
  /** A declaration that carries no extension value publishes one shared, empty frozen record. */
8
9
  const noExtensions = Object.freeze({});
9
10
  /**
10
- * Reads the spellings out of the compiled table the parser uses, so inspection cannot report a
11
- * form the parser does not accept. Each entry carries its own role, so the naming convention has
12
- * one owner: the table that writes it.
11
+ * The spelling every reported problem names one option by, the one core's own validation reports:
12
+ * its long form, a negative-only Boolean option's negative form, and otherwise its short form. A
13
+ * plugin that reports a problem for an option, such as an input source, names it this way too.
13
14
  */
14
- function spellingsOf(table, name) {
15
- const spellings = { long: null, negative: null, short: null };
16
- for (const [spelling, option] of table) {
17
- if (option.name === name) {
18
- spellings[option.role] = spelling;
19
- }
20
- }
21
- return spellings;
15
+ export function reportedSpelling(option) {
16
+ const negative = option.type === 'boolean' ? option.negative : null;
17
+ return reportedOf({ long: option.long, negative, short: option.short }, option.name);
22
18
  }
23
19
  /**
24
20
  * A declared default is wrapped, so `default: undefined` reads apart from no default at all. The
@@ -49,11 +45,11 @@ function publishesSchema(schema) {
49
45
  * the converter threw, the thrown value as its cause.
50
46
  */
51
47
  function converterFault(input, check, failed) {
52
- const site = declaringSite(input, inputPlace(input, check));
48
+ const site = check.siteOf(input);
53
49
  const parts = {
54
50
  correction: 'Fix the converter so it returns a JSON Schema object, or declare a validator that publishes none.',
55
- findings: [siteFinding(site, partOf(site, 'validate'))],
56
- sentence: `${site.subject} validator's JSON Schema converter ${failed.failure}`,
51
+ findings: site === undefined ? [] : [siteFinding(site, partOf(site, 'validate'))],
52
+ sentence: `${declarationSubject(input)} validator's JSON Schema converter ${failed.failure}`,
57
53
  };
58
54
  return 'cause' in failed
59
55
  ? new DeclarationError(schemaConverterFailed, parts, { cause: failed.cause })
@@ -104,47 +100,58 @@ function inputSchema(input, check) {
104
100
  function extensionsOf(records, declaration) {
105
101
  return records.get(declaration) ?? noExtensions;
106
102
  }
103
+ /**
104
+ * One option's node, in the shape its kind gives it. Every kind publishes the listing facts and the
105
+ * spellings the declared name derives; a string option adds its value facts, a Boolean option its
106
+ * negative spelling and polarity, and a counted option nothing more.
107
+ */
107
108
  function optionNode(input, read) {
108
- const { check, records, scope, table } = read;
109
+ const { check, records, table } = read;
109
110
  const { config, name } = input;
110
111
  const { long, negative, short } = spellingsOf(table, name);
111
- const extensions = extensionsOf(records, input);
112
- const node = config.type === 'boolean'
113
- ? {
114
- deprecated: config.deprecated,
115
- description: config.description,
116
- env: config.env ?? null,
117
- extensions,
118
- hidden: config.hidden === true,
119
- long,
120
- name,
121
- negative,
122
- polarity: config.polarity ?? 'positive',
123
- schema: null,
124
- scope,
125
- short,
126
- type: 'boolean',
112
+ const shared = {
113
+ aliases: Object.freeze([...(config.aliases ?? [])]),
114
+ deprecated: config.deprecated,
115
+ description: config.description,
116
+ env: config.env ?? null,
117
+ extensions: extensionsOf(records, input),
118
+ hidden: config.hidden === true,
119
+ long,
120
+ name,
121
+ short,
122
+ };
123
+ switch (config.type) {
124
+ case 'string': {
125
+ return Object.freeze({
126
+ ...shared,
127
+ default: declaredDefault(config),
128
+ implied: config.implied ?? null,
129
+ // The parser reads the same test, so a collection reports as one here and there.
130
+ multiple: config.multiple === true,
131
+ required: config.required === true,
132
+ schema: inputSchema(input, check),
133
+ type: 'string',
134
+ validateOmitted: validatesOmission(input),
135
+ validated: config.validate !== undefined,
136
+ });
127
137
  }
128
- : {
129
- default: declaredDefault(config),
130
- deprecated: config.deprecated,
131
- description: config.description,
132
- env: config.env ?? null,
133
- extensions,
134
- hidden: config.hidden === true,
135
- long,
136
- // The parser reads the same test, so a collection reports as one here and there.
137
- multiple: config.multiple === true,
138
- name,
139
- required: config.required === true,
140
- schema: inputSchema(input, check),
141
- scope,
142
- short,
143
- type: 'string',
144
- validateOmitted: validatesOmission(input),
145
- validated: config.validate !== undefined,
146
- };
147
- return Object.freeze(node);
138
+ case 'boolean': {
139
+ return Object.freeze({
140
+ ...shared,
141
+ negative,
142
+ polarity: config.polarity ?? 'positive',
143
+ schema: null,
144
+ type: 'boolean',
145
+ });
146
+ }
147
+ case 'count': {
148
+ return Object.freeze({ ...shared, schema: null, type: 'count' });
149
+ }
150
+ default: {
151
+ const exhaustive = config;
152
+ return exhaustive;
153
+ }
154
+ }
148
155
  }
149
156
  /** The built slots already answer presence and arity, so the node repeats no config reading. */
150
157
  function argumentNode(slot, read) {
@@ -172,7 +179,10 @@ function optionNodes(inputs, read) {
172
179
  */
173
180
  function commandNode(command, place) {
174
181
  const { development, nodes, path, records } = place;
175
- const check = { development, global: false, path };
182
+ const check = {
183
+ development,
184
+ siteOf: (input) => declaringSite(input, inputPlace(input, path)),
185
+ };
176
186
  const node = {
177
187
  aliases: Object.freeze([...command.aliases]),
178
188
  arguments: Object.freeze(command.arguments.map((slot) => argumentNode(slot, { check, records }))),
@@ -183,12 +193,7 @@ function commandNode(command, place) {
183
193
  hasAction: command.dispatch !== undefined,
184
194
  hidden: command.hidden,
185
195
  name: command.name,
186
- options: Object.freeze(optionNodes(command.inputs, {
187
- check,
188
- records,
189
- scope: 'application',
190
- table: command.options,
191
- })),
196
+ options: Object.freeze(optionNodes(command.inputs, { check, records, table: command.table })),
192
197
  path,
193
198
  result: resultNode(command.result),
194
199
  };
@@ -210,21 +215,19 @@ function resultNode(result) {
210
215
  /**
211
216
  * Renders one built graph as frozen plain data. Nothing here reads a host fact; each validated
212
217
  * input's converter is asked for its input schema, and in a development build a converter that
213
- * fails is a declaration fault. The globals list holds the application's own options, then each
214
- * installed plugin's in installation order, which is the order the globals table holds them in.
218
+ * fails is a declaration fault. The globals list holds every global option, the application's own
219
+ * and then each installed plugin's in installation order, which is the order the table holds them.
215
220
  */
216
221
  function inspectGraph(name, graph, facts) {
217
222
  const { development } = facts;
218
223
  const records = graph.extensions;
219
224
  const table = graph.globals.options;
220
225
  const nodes = new WeakMap();
221
- const check = { development, global: true, path: [] };
226
+ const { sites } = graph.globals;
227
+ const check = { development, siteOf: (input) => sites.get(input) };
222
228
  const inspected = {
223
229
  description: facts.description,
224
- globals: Object.freeze([
225
- ...optionNodes(graph.globals.inputs, { check, records, scope: 'application', table }),
226
- ...graph.globals.plugins.flatMap((installed) => optionNodes(installed.inputs, { check, records, scope: 'plugin', table })),
227
- ]),
230
+ globals: Object.freeze(optionNodes(graph.globals.inputs, { check, records, table })),
228
231
  name,
229
232
  root: commandNode(graph.root, {
230
233
  description: facts.description,
package/dist/locate.js CHANGED
@@ -1,48 +1,17 @@
1
- import { argumentSlot, readsAsChild, route } from './command.js';
2
1
  import { UsageError } from './errors.js';
3
2
  import { graphMismatch, linkOf } from './inspect.js';
4
- import { isOptionToken, longStringOption, longToken, scanGlobals, scanInputs } from './options.js';
3
+ import { argumentSlot, isOptionWord, readOptionWord, readWords, refusesValue } from './parse.js';
5
4
  const none = Object.freeze({ kind: 'none' });
6
5
  /**
7
- * The option names the earlier words supplied, globals and locals merged into token order.
8
- * Routing consumed the first `offset` rest tokens, so local token `i` is rest token `i + offset`.
6
+ * The words before the last, as the parser reads them, stopped before any validation. Every
7
+ * structural fault among them, an unknown Command included, reads as no position. When they run
8
+ * out at a Command with children, the last word may still continue routing, whatever it holds, so
9
+ * routing has not ended and a parent's own option among them stays unbound.
9
10
  */
10
- function suppliedOrder(count, scans) {
11
- const { globals, local, offset } = scans;
12
- const byToken = Array.from({ length: count }, () => []);
13
- for (const { name, token } of globals.supplied) {
14
- byToken[token]?.push(name);
15
- }
16
- for (const { name, token } of local.supplied) {
17
- byToken[globals.positions[token + offset] ?? count]?.push(name);
18
- }
19
- return byToken.flat();
20
- }
21
- /**
22
- * The parser's own pre-scan, routing, and local scan over the complete words. An earlier
23
- * positional that no slot accepts is the unexpected-argument fault, so it reads as no position.
24
- */
25
- function readStructure(graph, earlier) {
26
- const globals = scanGlobals(graph.globals.options, earlier);
27
- const routed = route(graph.root, globals.rest);
28
- const local = scanInputs(routed.command.options, routed.tokens);
29
- const positionals = local.positionals.length;
30
- if (positionals > 0 && !argumentSlot(routed.command.arguments, positionals - 1)) {
31
- return undefined;
32
- }
33
- return {
34
- awaiting: globals.awaiting ?? local.awaiting,
35
- command: routed.command,
36
- committed: routed.tokens.length > 0,
37
- delimited: local.delimited,
38
- positionals,
39
- supplied: suppliedOrder(earlier.length, { globals, local, offset: routed.path.length }),
40
- };
41
- }
42
- /** Every structural fault the grammar raises is a usage error, and it reads as no position. */
43
- function readEarlier(graph, earlier) {
11
+ function readEarlier(graph, words) {
44
12
  try {
45
- return readStructure(graph, earlier);
13
+ const read = readWords(graph, words.slice(0, -1), { partial: true });
14
+ return read.fault === undefined ? read : undefined;
46
15
  }
47
16
  catch (error) {
48
17
  if (error instanceof UsageError) {
@@ -61,35 +30,55 @@ function valueOf(scope, name, word) {
61
30
  }
62
31
  return { command, kind: 'value', lead: word.lead, option, prefix: word.prefix };
63
32
  }
64
- /** A long token with an inline value is that option's value when it names a string option. */
65
- function inlineValue(scope, spelling, inline) {
66
- const name = longStringOption(scope.built.globals.options, spelling) ??
67
- longStringOption(scope.earlier.command.options, spelling);
68
- return name === undefined ? none : valueOf(scope, name, { lead: `${spelling}=`, prefix: inline });
69
- }
70
33
  /** The word after a string option that ended the earlier words is that option's value. */
71
34
  function awaitedValue(scope, awaiting, last) {
72
- return isOptionToken(last) ? none : valueOf(scope, awaiting.name, { lead: '', prefix: last });
35
+ return refusesValue(last) ? none : valueOf(scope, awaiting.name, { lead: '', prefix: last });
73
36
  }
74
- /** A bare word names a child until routing commits, and fills the next positional after. */
75
- function bareWord(scope, last) {
37
+ /** A plain word names a child until routing ends, and fills the next positional after. */
38
+ function plainWord(scope, last) {
76
39
  const { command, earlier } = scope;
77
- if (!earlier.committed && readsAsChild(earlier.command, last)) {
40
+ if (!earlier.committed && earlier.command.children.size > 0) {
78
41
  return { command, kind: 'command', prefix: last };
79
42
  }
80
- const slot = argumentSlot(earlier.command.arguments, earlier.positionals);
43
+ const slot = argumentSlot(earlier.command.arguments, earlier.positionals.length);
81
44
  const argument = slot && command.arguments[earlier.command.arguments.indexOf(slot)];
82
45
  return argument ? { argument, command, kind: 'argument', prefix: last } : none;
83
46
  }
84
- /** An option token: a long token's inline value, or else an option spelling being completed. */
85
- function optionWord(scope, last) {
86
- const long = longToken(last);
87
- if (long?.inline !== undefined) {
88
- return inlineValue(scope, long.spelling, long.inline);
47
+ /**
48
+ * A word the walk reads against the routed Command's table and the earlier words' values, as the
49
+ * parser reads it: no position where the walk faults, a repeat of an earlier option included, the
50
+ * value a long spelling carries after `=` or a short value letter carries after it, and otherwise
51
+ * `undefined`, an option spelling.
52
+ */
53
+ function carriedValue(scope, last) {
54
+ const { earlier } = scope;
55
+ const context = { table: earlier.command.table, values: earlier.values };
56
+ const { occurrences } = readOptionWord(context, last, undefined);
57
+ for (const occurrence of occurrences) {
58
+ if (occurrence.kind !== 'value' && occurrence.kind !== 'awaiting') {
59
+ return none;
60
+ }
61
+ if (occurrence.kind === 'value' && occurrence.lead !== undefined) {
62
+ const { lead, option } = occurrence;
63
+ return valueOf(scope, option.name, { lead, prefix: last.slice(lead.length) });
64
+ }
89
65
  }
90
- return { command: scope.command, kind: 'option', prefix: last, supplied: scope.earlier.supplied };
66
+ return undefined;
91
67
  }
92
- /** The last word, read as the parser would read the next token. */
68
+ /**
69
+ * An option word. A long spelling without `=` is still being typed, so it is an option spelling
70
+ * whatever it names; any other word is read by the walk.
71
+ */
72
+ function optionWord(scope, last) {
73
+ const typing = last.startsWith('--') && !last.includes('=');
74
+ return ((typing ? undefined : carriedValue(scope, last)) ?? {
75
+ command: scope.command,
76
+ kind: 'option',
77
+ prefix: last,
78
+ supplied: scope.earlier.supplied,
79
+ });
80
+ }
81
+ /** The last word, read as the parser would read the next word. */
93
82
  function lastWord(scope, last) {
94
83
  const { command, earlier } = scope;
95
84
  if (earlier.delimited) {
@@ -98,7 +87,10 @@ function lastWord(scope, last) {
98
87
  if (earlier.awaiting) {
99
88
  return awaitedValue(scope, earlier.awaiting, last);
100
89
  }
101
- return isOptionToken(last) ? optionWord(scope, last) : bareWord(scope, last);
90
+ if (last === '-' || last === '--') {
91
+ return { command, kind: 'option', prefix: last, supplied: earlier.supplied };
92
+ }
93
+ return isOptionWord(last) ? optionWord(scope, last) : plainWord(scope, last);
102
94
  }
103
95
  /**
104
96
  * Reads an unfinished invocation against a graph `inspect()` returned and reports where its last
@@ -108,7 +100,7 @@ function lastWord(scope, last) {
108
100
  */
109
101
  function locate(graph, words) {
110
102
  const link = linkOf(graph);
111
- const earlier = readEarlier(link.graph, words.slice(0, -1));
103
+ const earlier = readEarlier(link.graph, words);
112
104
  if (!earlier) {
113
105
  return none;
114
106
  }
@@ -116,6 +108,6 @@ function locate(graph, words) {
116
108
  if (!command) {
117
109
  throw graphMismatch('The routed command is not in the inspected graph.');
118
110
  }
119
- return lastWord({ built: link.graph, command, earlier, graph }, words.at(-1) ?? '');
111
+ return lastWord({ command, earlier, graph }, words.at(-1) ?? '');
120
112
  }
121
113
  export { locate };