@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.
package/dist/locate.js ADDED
@@ -0,0 +1,121 @@
1
+ import { argumentSlot, readsAsChild, route } from './command.js';
2
+ import { InternalError, UsageError } from './errors.js';
3
+ import { linkOf } from './inspect.js';
4
+ import { isOptionToken, longStringOption, longToken, scanGlobals, scanInputs } from './options.js';
5
+ const none = Object.freeze({ kind: 'none' });
6
+ /**
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`.
9
+ */
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) {
44
+ try {
45
+ return readStructure(graph, earlier);
46
+ }
47
+ catch (error) {
48
+ if (error instanceof UsageError) {
49
+ return undefined;
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+ /** The value position of one option in scope: a global, or the routed Command's own. */
55
+ function valueOf(scope, name, word) {
56
+ const { command, graph } = scope;
57
+ const option = graph.globals.find((entry) => entry.name === name) ??
58
+ command.options.find((entry) => entry.name === name);
59
+ if (!option) {
60
+ throw new InternalError(`Option "${name}" is not in the inspected graph.`, undefined);
61
+ }
62
+ return { command, kind: 'value', lead: word.lead, option, prefix: word.prefix };
63
+ }
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
+ /** The word after a string option that ended the earlier words is that option's value. */
71
+ function awaitedValue(scope, awaiting, last) {
72
+ return isOptionToken(last) ? none : valueOf(scope, awaiting.name, { lead: '', prefix: last });
73
+ }
74
+ /** A bare word names a child until routing commits, and fills the next positional after. */
75
+ function bareWord(scope, last) {
76
+ const { command, earlier } = scope;
77
+ if (!earlier.committed && readsAsChild(earlier.command, last)) {
78
+ return { command, kind: 'command', prefix: last };
79
+ }
80
+ const slot = argumentSlot(earlier.command.arguments, earlier.positionals);
81
+ const argument = slot && command.arguments[earlier.command.arguments.indexOf(slot)];
82
+ return argument ? { argument, command, kind: 'argument', prefix: last } : none;
83
+ }
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);
89
+ }
90
+ return { command: scope.command, kind: 'option', prefix: last, supplied: scope.earlier.supplied };
91
+ }
92
+ /** The last word, read as the parser would read the next token. */
93
+ function lastWord(scope, last) {
94
+ const { command, earlier } = scope;
95
+ if (earlier.delimited) {
96
+ return { command, kind: 'passthrough', prefix: last };
97
+ }
98
+ if (earlier.awaiting) {
99
+ return awaitedValue(scope, earlier.awaiting, last);
100
+ }
101
+ return isOptionToken(last) ? optionWord(scope, last) : bareWord(scope, last);
102
+ }
103
+ /**
104
+ * Reads an unfinished invocation against a graph `inspect()` returned and reports where its last
105
+ * word sits. `words` holds the tokens after the application name; an empty list reads as one empty
106
+ * word. It runs no validator, input source, or middleware, and a structural fault among the earlier
107
+ * words reads as `none`.
108
+ */
109
+ function locate(graph, words) {
110
+ const link = linkOf(graph);
111
+ const earlier = readEarlier(link.graph, words.slice(0, -1));
112
+ if (!earlier) {
113
+ return none;
114
+ }
115
+ const command = link.nodes.get(earlier.command);
116
+ if (!command) {
117
+ throw new InternalError('The routed command is not in the inspected graph.', undefined);
118
+ }
119
+ return lastWord({ built: link.graph, command, earlier, graph }, words.at(-1) ?? '');
120
+ }
121
+ export { locate };
package/dist/options.d.ts CHANGED
@@ -7,7 +7,11 @@ export interface OptionValues {
7
7
  strings: Map<string, string>;
8
8
  lists: Map<string, string[]>;
9
9
  booleans: Map<string, boolean>;
10
+ /** The spelling of the token that supplied each parsed option, which no input source writes. */
11
+ spellings: Map<string, string>;
10
12
  }
13
+ /** Every parsed value lands in one of these maps; `lists` holds the repeated string options. */
14
+ export declare function emptyValues(): OptionValues;
11
15
  /** Which accepted form a table entry is. The table owns the convention, so readers never re-derive it. */
12
16
  type SpellingRole = 'long' | 'negative' | 'short';
13
17
  type OptionForm = {
@@ -30,13 +34,82 @@ type OptionSpelling = OptionForm & {
30
34
  */
31
35
  export declare function booleanValue(values: OptionValues, name: string, config: OptionConfig): boolean;
32
36
  export declare function compileOptions(declarations: readonly OptionDeclaration[], subject: string): Map<string, OptionSpelling>;
37
+ /**
38
+ * Whether a token reads as an option: it starts with a hyphen. Routing stops at one, and a
39
+ * separate value is never one. The parser and `locate` read each token through this rule.
40
+ */
41
+ export declare function isOptionToken(token: string): boolean;
42
+ /** A long option token and the inline value it carries after its first `=`, if any. */
43
+ export interface LongToken {
44
+ spelling: string;
45
+ inline: string | undefined;
46
+ }
47
+ /**
48
+ * A token that starts with `--` split at its first `=` into the spelling and the inline value, or
49
+ * `undefined` for any other token. The parser and `locate` split long tokens through this rule.
50
+ */
51
+ export declare function longToken(token: string): LongToken | undefined;
52
+ /**
53
+ * The name of the string option one long spelling names in a table, which takes its value after
54
+ * `=`, or `undefined` for a Boolean, negative, short, or unknown spelling.
55
+ */
56
+ export declare function longStringOption(spellings: ReadonlyMap<string, OptionSpelling>, spelling: string): string | undefined;
57
+ /**
58
+ * A string option whose value the next token supplies, where the tokens ended first. A complete
59
+ * invocation reports it as a missing value; a partial one reads the next word as that value.
60
+ */
61
+ export interface AwaitingValue {
62
+ name: string;
63
+ spelling: string;
64
+ }
65
+ /** One option name a token newly supplied, with the index of that token in the list read. */
66
+ export interface SuppliedOption {
67
+ name: string;
68
+ token: number;
69
+ }
70
+ /**
71
+ * The pre-scan's reading of a token list that may stop short. `positions` holds the index in
72
+ * `tokens` of each `rest` token, and `awaiting` is the global option the last token left without
73
+ * its value.
74
+ */
75
+ export interface GlobalScan {
76
+ awaiting: AwaitingValue | undefined;
77
+ positions: number[];
78
+ rest: string[];
79
+ supplied: SuppliedOption[];
80
+ values: OptionValues;
81
+ }
33
82
  /** Consumes global options anywhere before the passthrough delimiter and leaves the rest routable. */
83
+ export declare function scanGlobals(spellings: ReadonlyMap<string, OptionSpelling>, tokens: readonly string[]): GlobalScan;
84
+ /** The pre-scan of a complete invocation, where a global still waiting for its value is a fault. */
34
85
  export declare function extractGlobals(spellings: ReadonlyMap<string, OptionSpelling>, tokens: readonly string[]): {
35
86
  rest: string[];
36
87
  values: OptionValues;
37
88
  };
89
+ /**
90
+ * Whether one option holds a value a tier supplied: a token in any spelling it accepts, or a fill
91
+ * from an input source. A declared default is never in these maps, so it never counts.
92
+ */
93
+ export declare function isSupplied(values: OptionValues, name: string): boolean;
94
+ /** One run's own copy of parsed values, which the input-source stage fills without touching argv's. */
95
+ export declare function copyValues(values: OptionValues): OptionValues;
38
96
  /** Global and local keys never overlap, so one merged view feeds a single validation pass. */
39
97
  export declare function mergeValues(globals: OptionValues, locals: OptionValues): OptionValues;
98
+ /**
99
+ * One Command's reading of its own tokens, which may stop short. `delimited` says a bare `--` was
100
+ * read, and `awaiting` is the option the last token left without its value.
101
+ */
102
+ export interface InputScan {
103
+ awaiting: AwaitingValue | undefined;
104
+ delimited: boolean;
105
+ options: OptionValues;
106
+ passthrough: string[];
107
+ positionals: string[];
108
+ supplied: SuppliedOption[];
109
+ }
110
+ /** Reads one Command's tokens into options, positionals, and the passthrough tail. */
111
+ export declare function scanInputs(spellings: ReadonlyMap<string, OptionSpelling>, tokens: readonly string[]): InputScan;
112
+ /** Parses a complete invocation's local tokens, where a waiting option is a missing value. */
40
113
  export declare function parseInputs(spellings: ReadonlyMap<string, OptionSpelling>, tokens: readonly string[]): {
41
114
  options: OptionValues;
42
115
  passthrough: string[];
package/dist/options.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DeclarationError, MissingValueError, RepeatedOptionError, ShortGroupError, UnexpectedValueError, UnknownOptionError, } from './errors.js';
2
2
  /** Every parsed value lands in one of these maps; `lists` holds the repeated string options. */
3
- function emptyValues() {
4
- return { booleans: new Map(), lists: new Map(), strings: new Map() };
3
+ export function emptyValues() {
4
+ return { booleans: new Map(), lists: new Map(), spellings: new Map(), strings: new Map() };
5
5
  }
6
6
  function validateDeclaration({ name, config }) {
7
7
  if (typeof name !== 'string') {
@@ -89,6 +89,26 @@ export function compileOptions(declarations, subject) {
89
89
  }
90
90
  return spellings;
91
91
  }
92
+ /**
93
+ * Whether a token reads as an option: it starts with a hyphen. Routing stops at one, and a
94
+ * separate value is never one. The parser and `locate` read each token through this rule.
95
+ */
96
+ export function isOptionToken(token) {
97
+ return token.startsWith('-');
98
+ }
99
+ /**
100
+ * A token that starts with `--` split at its first `=` into the spelling and the inline value, or
101
+ * `undefined` for any other token. The parser and `locate` split long tokens through this rule.
102
+ */
103
+ export function longToken(token) {
104
+ if (!token.startsWith('--')) {
105
+ return undefined;
106
+ }
107
+ const equals = token.indexOf('=');
108
+ return equals === -1
109
+ ? { inline: undefined, spelling: token }
110
+ : { inline: token.slice(equals + 1), spelling: token.slice(0, equals) };
111
+ }
92
112
  function lookup(spellings, spelling) {
93
113
  const option = spellings.get(spelling);
94
114
  if (!option) {
@@ -96,20 +116,33 @@ function lookup(spellings, spelling) {
96
116
  }
97
117
  return option;
98
118
  }
119
+ /**
120
+ * The name of the string option one long spelling names in a table, which takes its value after
121
+ * `=`, or `undefined` for a Boolean, negative, short, or unknown spelling.
122
+ */
123
+ export function longStringOption(spellings, spelling) {
124
+ const option = spellings.get(spelling);
125
+ return option?.type === 'string' && option.role === 'long' ? option.name : undefined;
126
+ }
99
127
  function acceptValue({ option, spelling, values, next, inline, }) {
100
128
  const repeatable = option.type === 'string' && option.multiple;
101
129
  if (!repeatable && (values.strings.has(option.name) || values.booleans.has(option.name))) {
102
130
  throw new RepeatedOptionError(spelling);
103
131
  }
132
+ // A repeatable option records its last occurrence, because each one overwrites the entry.
133
+ values.spellings.set(option.name, spelling);
104
134
  if (option.type === 'boolean') {
105
135
  if (inline !== undefined) {
106
136
  throw new UnexpectedValueError(spelling, inline);
107
137
  }
108
138
  values.booleans.set(option.name, option.value);
109
- return false;
139
+ return 'alone';
110
140
  }
111
141
  const value = inline ?? next;
112
- if (value === undefined || (inline === undefined && value.startsWith('-'))) {
142
+ if (value === undefined) {
143
+ return { name: option.name, spelling };
144
+ }
145
+ if (inline === undefined && isOptionToken(value)) {
113
146
  throw new MissingValueError(spelling);
114
147
  }
115
148
  if (repeatable) {
@@ -120,14 +153,13 @@ function acceptValue({ option, spelling, values, next, inline, }) {
120
153
  else {
121
154
  values.strings.set(option.name, value);
122
155
  }
123
- return inline === undefined;
156
+ return inline === undefined ? 'next' : 'alone';
124
157
  }
125
158
  function parseOption(spellings, input, values) {
126
159
  const { token, next } = input;
127
- if (token.startsWith('--')) {
128
- const equals = token.indexOf('=');
129
- const spelling = equals === -1 ? token : token.slice(0, equals);
130
- const inline = equals === -1 ? undefined : token.slice(equals + 1);
160
+ const long = longToken(token);
161
+ if (long) {
162
+ const { inline, spelling } = long;
131
163
  return acceptValue({ inline, next, option: lookup(spellings, spelling), spelling, values });
132
164
  }
133
165
  if (token === '-') {
@@ -141,20 +173,37 @@ function parseOption(spellings, input, values) {
141
173
  throw new ShortGroupError({ reason: 'value-position', token: spelling });
142
174
  }
143
175
  const inline = suffix.startsWith('=') ? suffix.slice(1) : undefined;
144
- if (acceptValue({ inline, next, option, spelling, values })) {
145
- return true;
176
+ const reading = acceptValue({ inline, next, option, spelling, values });
177
+ if (reading !== 'alone') {
178
+ return reading;
146
179
  }
147
180
  }
148
- return false;
181
+ return 'alone';
182
+ }
183
+ /**
184
+ * Reads one option token into the scan and answers whether it took the next token too. The names
185
+ * it newly supplied join `supplied` in the order the values map first recorded them, so a repeated
186
+ * option keeps its first position.
187
+ */
188
+ function readOption(spellings, input, state) {
189
+ const known = state.values.spellings.size;
190
+ const reading = parseOption(spellings, input, state.values);
191
+ for (const name of [...state.values.spellings.keys()].slice(known)) {
192
+ state.supplied.push({ name, token: input.index });
193
+ }
194
+ if (typeof reading === 'object') {
195
+ state.awaiting = reading;
196
+ }
197
+ return reading === 'next';
149
198
  }
150
199
  /**
151
200
  * A hyphen token belongs to the globals when its long spelling or every short letter does. The
152
201
  * pre-scan reads the globals alone, so a letter it does not own is only "not a global option".
153
202
  */
154
203
  function isGlobalToken(spellings, token) {
155
- if (token.startsWith('--')) {
156
- const equals = token.indexOf('=');
157
- return spellings.has(equals === -1 ? token : token.slice(0, equals));
204
+ const long = longToken(token);
205
+ if (long) {
206
+ return spellings.has(long.spelling);
158
207
  }
159
208
  const group = token.slice(1).split('=')[0] ?? '';
160
209
  let global = '';
@@ -177,52 +226,100 @@ function isGlobalToken(spellings, token) {
177
226
  return true;
178
227
  }
179
228
  /** Consumes global options anywhere before the passthrough delimiter and leaves the rest routable. */
180
- export function extractGlobals(spellings, tokens) {
181
- const values = emptyValues();
229
+ export function scanGlobals(spellings, tokens) {
230
+ const state = { awaiting: undefined, supplied: [], values: emptyValues() };
182
231
  const rest = [];
232
+ const positions = [];
183
233
  for (let index = 0; index < tokens.length; index += 1) {
184
234
  const token = tokens[index];
185
235
  if (token === undefined) {
186
236
  break;
187
237
  }
188
238
  if (token === '--') {
189
- rest.push(...tokens.slice(index));
190
- return { rest, values };
239
+ // One push per token, because a spread call would overflow the stack on a long list.
240
+ for (const [offset, tail] of tokens.slice(index).entries()) {
241
+ rest.push(tail);
242
+ positions.push(index + offset);
243
+ }
244
+ break;
191
245
  }
192
- if (!token.startsWith('-') || !isGlobalToken(spellings, token)) {
246
+ if (!isOptionToken(token) || !isGlobalToken(spellings, token)) {
193
247
  rest.push(token);
248
+ positions.push(index);
194
249
  }
195
- else if (parseOption(spellings, { next: tokens[index + 1], token }, values)) {
250
+ else if (readOption(spellings, { index, next: tokens[index + 1], token }, state)) {
196
251
  index += 1;
197
252
  }
198
253
  }
254
+ return { ...state, positions, rest };
255
+ }
256
+ /** The pre-scan of a complete invocation, where a global still waiting for its value is a fault. */
257
+ export function extractGlobals(spellings, tokens) {
258
+ const { awaiting, rest, values } = scanGlobals(spellings, tokens);
259
+ if (awaiting) {
260
+ throw new MissingValueError(awaiting.spelling);
261
+ }
199
262
  return { rest, values };
200
263
  }
264
+ /**
265
+ * Whether one option holds a value a tier supplied: a token in any spelling it accepts, or a fill
266
+ * from an input source. A declared default is never in these maps, so it never counts.
267
+ */
268
+ export function isSupplied(values, name) {
269
+ return values.strings.has(name) || values.lists.has(name) || values.booleans.has(name);
270
+ }
271
+ /** One run's own copy of parsed values, which the input-source stage fills without touching argv's. */
272
+ export function copyValues(values) {
273
+ return {
274
+ booleans: new Map(values.booleans),
275
+ lists: new Map([...values.lists].map(([name, list]) => [name, [...list]])),
276
+ spellings: new Map(values.spellings),
277
+ strings: new Map(values.strings),
278
+ };
279
+ }
201
280
  /** Global and local keys never overlap, so one merged view feeds a single validation pass. */
202
281
  export function mergeValues(globals, locals) {
203
282
  return {
204
283
  booleans: new Map([...globals.booleans, ...locals.booleans]),
205
284
  lists: new Map([...globals.lists, ...locals.lists]),
285
+ spellings: new Map([...globals.spellings, ...locals.spellings]),
206
286
  strings: new Map([...globals.strings, ...locals.strings]),
207
287
  };
208
288
  }
209
- export function parseInputs(spellings, tokens) {
210
- const options = emptyValues();
289
+ /** Reads one Command's tokens into options, positionals, and the passthrough tail. */
290
+ export function scanInputs(spellings, tokens) {
291
+ const state = { awaiting: undefined, supplied: [], values: emptyValues() };
211
292
  const positionals = [];
293
+ const read = (passthrough, delimited) => ({
294
+ awaiting: state.awaiting,
295
+ delimited,
296
+ options: state.values,
297
+ passthrough,
298
+ positionals,
299
+ supplied: state.supplied,
300
+ });
212
301
  for (let index = 0; index < tokens.length; index += 1) {
213
302
  const token = tokens[index];
214
303
  if (token === undefined) {
215
304
  break;
216
305
  }
217
306
  if (token === '--') {
218
- return { options, passthrough: tokens.slice(index + 1), positionals };
307
+ return read(tokens.slice(index + 1), true);
219
308
  }
220
- if (!token.startsWith('-')) {
309
+ if (!isOptionToken(token)) {
221
310
  positionals.push(token);
222
311
  }
223
- else if (parseOption(spellings, { next: tokens[index + 1], token }, options)) {
312
+ else if (readOption(spellings, { index, next: tokens[index + 1], token }, state)) {
224
313
  index += 1;
225
314
  }
226
315
  }
227
- return { options, passthrough: [], positionals };
316
+ return read([], false);
317
+ }
318
+ /** Parses a complete invocation's local tokens, where a waiting option is a missing value. */
319
+ export function parseInputs(spellings, tokens) {
320
+ const { awaiting, options, passthrough, positionals } = scanInputs(spellings, tokens);
321
+ if (awaiting) {
322
+ throw new MissingValueError(awaiting.spelling);
323
+ }
324
+ return { options, passthrough, positionals };
228
325
  }
package/dist/output.d.ts CHANGED
@@ -26,6 +26,8 @@ export declare class Output {
26
26
  * declaration a call answers to is checked where the action was authored.
27
27
  */
28
28
  readonly out: Out<OpenResult>;
29
+ /** The channel a configuration source receives: `out` with the results call naming a source. */
30
+ readonly sourceOut: Out<OpenResult>;
29
31
  private palette;
30
32
  private policy;
31
33
  private registry;
package/dist/output.js CHANGED
@@ -155,6 +155,8 @@ export class Output {
155
155
  * declaration a call answers to is checked where the action was authored.
156
156
  */
157
157
  out;
158
+ /** The channel a configuration source receives: `out` with the results call naming a source. */
159
+ sourceOut;
158
160
  palette = new Map();
159
161
  policy = {};
160
162
  // The contributors this invocation resolves a declared view through, published once they build.
@@ -179,6 +181,8 @@ export class Output {
179
181
  success: (message) => this.emit('success', message, 'stderr'),
180
182
  warn: (message) => this.emit('warn', message, 'stderr'),
181
183
  };
184
+ // A configuration source writes through the same channel, and its results call names it.
185
+ this.sourceOut = { ...this.out, results: () => this.resultFault('source') };
182
186
  }
183
187
  /**
184
188
  * The channel one action receives. On a Command that declares a result nothing the action writes
@@ -238,7 +242,7 @@ export class Output {
238
242
  });
239
243
  }
240
244
  if (typeof view.row === 'function') {
241
- // Build rejects a row view on a value result, so reaching one here is core's own fault.
245
+ // The result() call rejects a row view on a value result, so reaching one here is core's own fault.
242
246
  return this.renderFailed(new Error(`The view "${selected}" renders rows, not a value.`));
243
247
  }
244
248
  return this.rendered(() => resolveView(bare, view)(erased(value), this.context('stdout')));