@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
1,188 lines • 55.9 kB
JavaScript
import { resolvePath } from "../../utils/path.js";
import { compareCodePoints } from "../../utils/sort.js";
import { BUILTIN_SPECS, isBuiltinGrammar } from "./builtins.js";
import { argumentDest, compileSpec, expandGitLong, expandLong, expandTableLong, positionalName, positionalRequired, } from "./compile.js";
import { ARG_PLACEHOLDER, DASH_LETTER, DIGIT_OPTIONS, EQUALS_SHORT_VALUES, flagKwargName, FLOAT_VALUE, IN_ORDER_OPERANDS, INT_VALUE, LETTER_OPTIONS, LONG_OPTION_TABLES, LONG_SYNONYMS, NEGATIVE_NUMBER, NEGATIVE_NUMBER_OPERANDS, NO_LONG_OPTIONS, NUMERIC_SHORT, OPERAND, OWN_OPTION_LOOP, PARSE_KEPT_CHARS, PARSES_KEPT, REFUSED, SOLE_ARGUMENT_LONG_OPTIONS, SPELLED, SPELLED_WORDS, STDIN_SCRIPT_COMMANDS, STDOUT_DASH_OPTIONS, WHOLE_WORD_LONG_OPTIONS, WORD_CHOICE_OPTIONS, } from "./constants.js";
import { flagOccurrences } from "./flag_view.js";
import { expandOldStyle } from "./oldstyle.js";
import { WordKind, } from "./types.js";
import { matchWord, valueClasses } from "./word_choice.js";
/**
* The builtin `Argument` objects whose choices are word choices.
*
* WORD_CHOICE_OPTIONS names them as "<command> <spelling>" because that
* is how the measurement reads; this resolves each entry to the one object
* the builtin spec declares, so `wordChoiceDests` can test `===` rather than
* compare strings. An entry naming no option is a rotted table and throws
* here, at module load. `_word_choice_arguments` in parser.py is the twin.
*/
function wordChoiceArguments() {
const found = [];
for (const key of [...WORD_CHOICE_OPTIONS].sort(compareCodePoints)) {
const sep = key.indexOf(' ');
const name = key.slice(0, sep);
const spelling = key.slice(sep + 1);
const options = (BUILTIN_SPECS[name]?.arguments ?? []).filter((o) => (o.names.find((name) => name.startsWith('--')) ?? o.names[0]) === spelling);
if (options.length === 0) {
throw new Error(`WORD_CHOICE_OPTIONS names ${name} ${spelling}, which that spec does not declare`);
}
for (const item of options)
found.push(item);
}
return found;
}
const WORD_CHOICE_ARGUMENTS = wordChoiceArguments();
/**
* Which of this spec's choice sets GNU matches as word choices.
*
* Decided by `Argument` identity, not by the command's name: a mount may
* register its own `tee` (commands/registry.ts), and a name is not an
* identity. Identity is also the only signal that survives registration,
* which parses an enriched COPY of the spec (config.ts appends
* --help/--version, once per backend), while every declared Argument stays the
* same object.
*
* Read off the spec rather than kept on its CompiledSpec, as
* `_word_choice_dests` in parser.py reads it: the tables describe the
* grammar, and whether a choice set is tee's is a fact about which Argument
* objects the spec holds.
*/
function wordChoiceDests(spec) {
const dests = new Set();
for (const o of spec.arguments) {
if (o.choices.length === 0)
continue;
if (WORD_CHOICE_ARGUMENTS.some((table) => table === o))
dests.add(argumentDest(o));
}
return dests;
}
export class ParsedArgs {
flags;
args;
/** Selected PATH option values before cwd resolution, keyed like parseToKwargs. */
rawPathFlags;
pathFlagValues;
rawOperands;
textFlagValues;
warnings;
wordKinds;
// Per-position base directory, aligned with wordKinds: the absolute
// path a word resolves against when an operandBase option (tar's -C)
// moved it, and null when the session cwd still applies. Only a spec
// declaring operandBase ever fills this.
wordBases;
// Per-position role, aligned with wordKinds: OPTION for a word the scan
// read as an option, VALUE for an option's detached value, OPERAND for an
// argument, null for a word the scan never reads.
wordRoles;
// GNU-shaped option errors, reported (never thrown) by the parser:
// undeclared options ('--bogus' or the offending cluster char 'Y'),
// abbreviated longs matching several options (typed prefix, matched
// spellings in declaration order), declared value flags that ran out
// of line ('--max-depth', 'm'), values outside a declared choices set
// (canonical spelling, value, allowed values), non-integer values on
// int-typed options (canonical spelling, value), and absent required
// options (canonical spelling).
invalidOptions;
ambiguousOptions;
// One tag per refusal in scan encounter order ("invalid",
// "unexpected_value", "ambiguous", "needs_value", "int", "float",
// "value"), so the refusal names the FIRST offending token like GNU (grep
// --c --bogus reports --c; reversed reports --bogus; numfmt --from=bad
// --bogus reports the value). Each tag's detail is the next entry of that
// tag's own list. "unexpected_value" is a boolean
// long handed a value, which getopt_long refuses in its own words rather
// than as an unrecognized option; its entry in invalidOptions is the
// option's canonical spelling with the typed value ("--byte-offset=2"),
// so the two tags share one list and the renderer tells them apart by
// the tag.
optionErrorKinds;
needsValueOptions;
invalidValueOptions;
ambiguousValueOptions;
invalidIntOptions;
invalidFloatOptions;
missingRequiredOptions;
missingRequiredOperands;
typedDests;
// The old-style cluster letter whose argument ran off the end of the
// line (`tar xzf` with no archive). Its own report because GNU tar
// words it differently and exits differently from every getopt refusal
// above, and because it outranks all of them: tar counts the cluster's
// argument needs before argp ever validates a letter, so `tar Qf` and
// `tar fQ` both name f, not Q.
oldOptionNeedsValue;
builtin;
constructor(init) {
this.flags = init.flags;
this.args = init.args;
this.rawPathFlags = init.rawPathFlags ?? {};
this.pathFlagValues = init.pathFlagValues ?? [];
this.rawOperands = init.rawOperands ?? [];
this.textFlagValues = init.textFlagValues ?? [];
this.warnings = init.warnings ?? [];
this.wordKinds = init.wordKinds ?? [];
this.wordBases = init.wordBases ?? [];
this.wordRoles = init.wordRoles ?? [];
this.invalidOptions = init.invalidOptions ?? [];
this.ambiguousOptions = init.ambiguousOptions ?? [];
this.optionErrorKinds = init.optionErrorKinds ?? [];
this.needsValueOptions = init.needsValueOptions ?? [];
this.invalidValueOptions = init.invalidValueOptions ?? [];
this.ambiguousValueOptions = init.ambiguousValueOptions ?? [];
this.invalidIntOptions = init.invalidIntOptions ?? [];
this.invalidFloatOptions = init.invalidFloatOptions ?? [];
this.missingRequiredOptions = init.missingRequiredOptions ?? [];
this.missingRequiredOperands = init.missingRequiredOperands ?? [];
this.typedDests = init.typedDests ?? [];
this.oldOptionNeedsValue = init.oldOptionNeedsValue ?? null;
this.builtin = init.builtin ?? false;
Object.defineProperty(this, 'facts', { value: new Map() });
}
paths() {
return this.args.filter(([, k]) => k === 'path').map(([v]) => v);
}
texts() {
return this.args.filter(([, k]) => k !== 'path').map(([v]) => v);
}
}
// Run one value through its dest's int, float and choices checks. Int-typed
// values are refused before choices, argparse's order (type conversion runs
// before the choices test), and one value is refused once: a non-numeric
// value on an int option that also declares choices reports the conversion
// failure, not the choice list.
//
// A declared `choices` set compares the WHOLE word, argparse's rule, unless
// the option declaring it is one of the word-choice tables the
// parser owns, in which case an unambiguous prefix resolves to its
// candidate. The returned word is what the caller stores, so a command reads
// `none` where the line typed `non` and never learns the difference. Which
// sets those are was settled by `compileSpec`, by `Argument` identity rather
// than by the command's name, so a registered command that borrows the name
// `tee` still compares the whole word. `_check_value` in parser.py is the
// twin.
function checkValue(refusals, cs, wordChoiceDestSet, dest, value) {
if (cs.intDests.has(dest) && !INT_VALUE.test(value)) {
refusals.ints.push([dest, value]);
refusals.kinds.push('int');
return value;
}
if (cs.floatDests.has(dest) && !FLOAT_VALUE.test(value)) {
refusals.floats.push([dest, value]);
refusals.kinds.push('float');
return value;
}
const allowed = cs.choicesByDest.get(dest);
if (allowed === undefined)
return value;
if (wordChoiceDestSet.has(dest)) {
const match = matchWord(value, allowed);
if (match.matched)
return match.word;
if (match.kind === 'ambiguous') {
refusals.ambiguousValues.push([dest, value, allowed]);
refusals.kinds.push('ambiguous_value');
return value;
}
refusals.values.push([dest, value, allowed]);
refusals.kinds.push('value');
return value;
}
for (const group of valueClasses(allowed)) {
if (group.includes(value))
return group[0] ?? value;
}
refusals.values.push([dest, value, allowed]);
refusals.kinds.push('value');
return value;
}
// Record a value flag occurrence under its canonical dest. Both spellings
// of one option land on the same key, so the last occurrence wins
// regardless of spelling (GNU: `cp --update=all -u` is `--update=older`)
// and `multiple` options accumulate in true command-line order
// (`sort -k1 --key=2` is `[1, 2]`).
//
// Every value is checked the moment it is read, as GNU's getopt loop and
// argparse's `type=` do, so `numfmt --to=bogus --to=si` is refused for
// `bogus` although the bag keeps only `si`, and `--from=bad1 --to=bad2`
// names `bad1`. Only what the environment or a default fills in afterwards
// is checked after the scan.
function setValueFlag(flags, refusals, cs, wordChoiceDestSet, spelling, value) {
const name = cs.destOf(spelling);
const stored = (typeof value === 'string' ? [value] : value).map((part) => checkValue(refusals, cs, wordChoiceDestSet, name, part));
flagOccurrences(flags).push(...stored.map((part) => [name, part]));
if (cs.multipleDests.has(name)) {
const previous = flags[name];
if (Array.isArray(previous))
for (const item of stored)
previous.push(item);
else
flags[name] = stored;
}
else if (Array.isArray(value)) {
flags[name] = stored;
}
else {
Reflect.deleteProperty(flags, name);
flags[name] = stored[0] ?? '';
}
}
// The values the bag holds for one dest. The bare boolean form of an
// optional-value flag is exempt from the per-value checks, so it reads as
// no value at all.
function bagValues(flags, destName) {
const value = flags[destName];
if (Array.isArray(value))
return value;
return typeof value === 'string' ? [value] : [];
}
// Fold one option occurrence into the operand base directory. Called
// after every value-flag record. Only the spec's declared operandBase
// option moves the base, and it moves it the way a chdir does: relative
// to wherever the previous occurrence left it, so `-C d1 ... -C ../d2`
// lands in d1/../d2. The resolved absolute path replaces the raw value
// in the flag bag, so the later path-flag pass has nothing left to do.
function rebase(flags, cs, spelling, value, base) {
if (cs.baseDest === null || cs.destOf(spelling) !== cs.baseDest)
return base;
const moved = resolvePath(value, base);
const bag = flags[cs.baseDest];
if (Array.isArray(bag) && bag.length > 0) {
// An accumulating option already appended the raw value; the
// resolved one replaces it so nothing resolves it twice.
bag[bag.length - 1] = moved;
}
else {
flags[cs.baseDest] = moved;
}
return moved;
}
// The first operand a textWhen option turns textual, or null. Called after
// the scan, when the bag holds every option the line carried and its tape
// every option occurrence and operand in scan order. A program that reads its
// whole line first (tar's -x) turns every operand textual, wherever the option
// sits; one that files each operand as it reads it (IN_ORDER_OPERANDS) turns
// only the operands typed after the first such option. `_first_text_operand`
// in parser.py is the twin.
function firstTextOperand(flags, cs, textWhen, inOrder) {
const dests = new Set(textWhen.map((name) => cs.destOf(name)));
if (!inOrder)
return [...dests].some((dest) => dest in flags) ? 0 : null;
let operands = 0;
for (const [name] of flagOccurrences(flags)) {
if (dests.has(name))
return operands;
if (name === OPERAND)
operands += 1;
}
return null;
}
// Record a boolean flag occurrence under its canonical dest. A count flag
// accumulates occurrences into a number (`-vvv` and `-v -v -v` both land
// as 3); every other boolean flag is sticky true.
function setBoolFlag(flags, cs, spelling) {
const name = cs.destOf(spelling);
flagOccurrences(flags).push([name, true]);
if (cs.countDests.has(name)) {
const prev = flags[name];
flags[name] = typeof prev === 'number' ? prev + 1 : 1;
}
else {
Reflect.deleteProperty(flags, name);
flags[name] = true;
}
}
// getopt-style cluster of bool flags ending in a value flag, e.g. -ne / -nepat.
// An optional-value short (getopt's `x::`) takes whatever follows it in the
// cluster as its value, as getopt does, so `date -uIs` is `-u -Is`; with
// nothing after it, it is one more bool flag. Returns null when any character
// is unknown or no value flag terminates it.
// An attached short-option value, one leading `=` dropped for a program that
// reads `-x=VALUE` as `VALUE` (EQUALS_SHORT_VALUES).
function attached(value, equals) {
return equals && value.startsWith('=') ? value.slice(1) : value;
}
function matchMixedCluster(tok, cs) {
const bools = [];
const chars = tok.slice(1);
for (let idx = 0; idx < chars.length; idx++) {
const ch = chars[idx];
if (ch === undefined)
break;
const name = `-${ch}`;
const rest = chars.slice(idx + 1);
if (rest.length > 0 && cs.attachSpellings.includes(name)) {
return { bools, valueFlag: name, attached: rest };
}
if (cs.detachedOptionalSpellings.has(name)) {
return { bools, valueFlag: name, attached: rest.length > 0 ? rest : null };
}
if (cs.boolSpellings.has(name)) {
bools.push(name);
continue;
}
if (cs.valueSpellings.includes(name)) {
return { bools, valueFlag: name, attached: rest.length > 0 ? rest : null };
}
return null;
}
return null;
}
// A cluster of bool flags and digit options (`-d10`). For a DIGIT_OPTIONS
// program the digits are option letters too, and getopt hands them over one
// at a time into one number: every digit of the word joins it, wherever it
// sits (`-1d0` is ten). A value option ends the cluster and takes the rest of
// the word, as getopt reads it (`grep -1m2` is `-1 -m2`): `rest` is that
// option and what follows it as a token of its own. Null when a character is
// neither or no digit is present.
function matchDigitCluster(tok, cs) {
const bools = [];
let digits = '';
const chars = tok.slice(1);
for (let idx = 0; idx < chars.length; idx++) {
const ch = chars[idx] ?? '';
if (ch >= '0' && ch <= '9')
digits += ch;
else if (cs.boolSpellings.has(`-${ch}`))
bools.push(`-${ch}`);
else if (digits !== '' && cs.valueSpellings.includes(`-${ch}`))
return { bools, digits, rest: `-${chars.slice(idx)}` };
else
return null;
}
return digits === '' ? null : { bools, digits, rest: null };
}
/** Each spec's readings, keyed by spec as `compileSpec` keys its tables. */
const PARSES = new WeakMap();
/**
* Read one command line against a spec.
*
* `unknownIsOperand` says whether another parser reads this line after
* mirage. False is a GNU command, where mirage is the only parser the line
* will meet, so a dashed word the spec does not declare is `unrecognized
* option`. True is an installed CLI's node, where the spec is deliberately
* partial: mirage's `git log` declares the flags mirage enforces and git owns
* the rest, so an undeclared dashed word is handed back as an operand for git
* to refuse in git's own words and exit (`fatal: unrecognized argument: -p`).
* It comes last and defaults to the GNU answer, because it is a fact about
* the call rather than about the spec, and nothing on CommandSpec may say it:
* the shared grammar stays what POSIX and argparse can both express. It says
* nothing about `choices`, which compares the whole word for every spec
* unless the option declaring the set is one of the builtin word-choice
* declarations -- an identity the spec itself settles, so it is not a fact
* about the caller at all.
*
* `abbreviations` is the same kind of fact about the program reading the
* line: its own full table of long options (git's `--[no-]` notation), when it
* resolves an abbreviated long option against that table the way git's
* parse-options does. A partial spec cannot answer whether `--no-m` is
* ambiguous, since the option git would also match is one mirage never
* declared, so the program's table is what is asked; an empty table is a
* program that takes whole words only (git's revision walkers). Undefined
* leaves the getopt_long reading against the spec.
*
* The reading is kept per spec, keyed by everything it depends on (the
* environment only through the variables the spec declares), so every reader
* of one command shares one parse: read it, never change it. Words longer
* than PARSE_KEPT_CHARS in all are read afresh each time.
*
* `parse_command` in parser.py is the twin.
*/
export function parseCommand(spec, argv, cwd, cmdName = '', env, unknownIsOperand = false, abbreviations) {
const cs = compileSpec(spec);
let parses;
let key = '';
if (argv.reduce((chars, word) => chars + word.length, 0) <= PARSE_KEPT_CHARS) {
key = JSON.stringify([
argv,
cwd,
cmdName,
unknownIsOperand,
abbreviations ?? null,
[...cs.envByDest.values()].map((variable) => env?.[variable] ?? null),
]);
parses = PARSES.get(spec);
if (parses === undefined) {
parses = new Map();
PARSES.set(spec, parses);
}
const kept = parses.get(key);
if (kept !== undefined)
return kept;
}
const wordChoiceDestSet = wordChoiceDests(spec);
// tar's old option style is expanded before anything else reads the
// line, so classification, routing and dispatch all scan the same
// dashed words; scanOrigins maps each of them back to the caller's
// argv slot (every synthesized token to the cluster's own slot).
const old = spec.oldOptionStyle ? expandOldStyle(cs, argv) : null;
const scanArgv = old !== null ? old.argv : argv;
const scanOrigins = old !== null ? old.origins : argv.map((_, idx) => idx);
const flags = {};
// Every scalar value-flag occurrence, in scan order, beside the bag that
// keeps only the last of each. Appended to by setValueFlag and read by
// nobody here: it leaves on the parse result.
const rawArgs = [];
// rawIndices[k] = argv position of rawArgs[k]
const rawIndices = [];
// Per-position operand kinds aligned with the caller's argv (null =
// a word the scan never reads, such as tar's empty old-style cluster).
// Positions, not value sets, so the same word can be TEXT in one slot
// and PATH in another:
// grep *.txt *.txt -> [TEXT, PATH]
// find /data -name *.txt -> [PATH, TEXT, TEXT]
// tar "" f.txt -> [null, PATH]
// scanOrigins/rawIndices map the parser's views back to argv slots
// (scanArgv spells a tar cluster as one word per letter, rawArgs keeps
// only operands); kinds must be written at the original positions or
// one expanded cluster shifts every later kind onto the wrong word.
const wordKinds = new Array(argv.length).fill(null);
// The positions the scan stood on: an option word or an operand.
const scanned = new Array(argv.length).fill(false);
// The directory the next path operand resolves against, and where it
// was for each word already read. It only ever moves for a spec that
// declares operandBase, so every other command records null throughout
// and the classifier keeps using the session cwd.
let base = cwd;
const wordBases = new Array(argv.length).fill(null);
const rawBases = [];
const warnings = [];
const invalidOptions = [];
const ambiguousOptions = [];
const optionErrorKinds = [];
const refusals = {
kinds: optionErrorKinds,
ints: [],
floats: [],
values: [],
ambiguousValues: [],
};
const needsValueOptions = [];
const builtin = isBuiltinGrammar(cmdName, spec);
// Who owns a dashed word the spec does not declare. The caller already
// answered that with unknownIsOperand.
let noLongOptionParser;
let outsideSoleArgument;
let lenientDashOperands;
let digitOptions;
let equalsValues;
let inOrderOperands;
let spelledWords;
let letterOptions;
let negativeNumbers;
let wholeWords;
let ownLoop;
let longTable;
const synonyms = new Map();
if (unknownIsOperand) {
// Where the word goes is still the grammar's to say: it lands in a textual
// rest slot when the node has one (git's `log -p`, and a script root whose
// whole line is forwarded) and is refused here when the node declares no
// slot for it (`pager --frobnicate`). The rest kind can answer that here
// and could not answer it for a GNU command: a CLI node's textual rest IS
// the pass-through slot, while basename's is a list of names, and eleven
// GNU specs share basename's shape.
lenientDashOperands = cs.restKind !== null && cs.restKind !== 'path' && !cs.remainder;
noLongOptionParser = lenientDashOperands;
outsideSoleArgument = false;
digitOptions = false;
equalsValues = false;
inOrderOperands = false;
spelledWords = new Set();
letterOptions = false;
negativeNumbers = false;
wholeWords = false;
ownLoop = false;
}
else {
// getopt_long, with exactly two exceptions, both named rather than derived
// from the spec because nothing in a declaration tells them apart: see
// NO_LONG_OPTIONS and SOLE_ARGUMENT_LONG_OPTIONS for the measurements and
// for why #1107's "declares no long options" predicate cannot work. A
// program with no long-option parser prints a dash word it does not know
// instead of refusing it, and never expands an abbreviation.
// Both tables name one real program, so both are gated on this spec
// being that program's own grammar: a mount may register a command under
// a builtin's name (nothing refuses it), and the sole-argument rule turns
// such a spec's declared `--mode=x` into an operand its handler then
// never sees.
noLongOptionParser = builtin && NO_LONG_OPTIONS.has(cmdName);
// gnulib's parse_long_options reads argv[1] only when it is the whole
// line, so outside that one-argument window the program has no long
// options AT ALL and even an exact `--help` is an operand.
const soleArgument = builtin && SOLE_ARGUMENT_LONG_OPTIONS.has(cmdName);
outsideSoleArgument = soleArgument && argv.length !== 1;
// A dash-leading word this program answers by printing it as an operand
// rather than by refusing it.
lenientDashOperands = noLongOptionParser || soleArgument;
// Gated the same way: the digit letters and the synonym pairs are the real
// program's own tables, not facts any declaration states.
digitOptions = builtin && DIGIT_OPTIONS.has(cmdName);
equalsValues = builtin && EQUALS_SHORT_VALUES.has(cmdName);
inOrderOperands = builtin && IN_ORDER_OPERANDS.has(cmdName);
spelledWords = (builtin ? SPELLED_WORDS[cmdName] : undefined) ?? new Set();
letterOptions = builtin && LETTER_OPTIONS.has(cmdName);
negativeNumbers = builtin && NEGATIVE_NUMBER_OPERANDS.has(cmdName);
wholeWords = builtin && WHOLE_WORD_LONG_OPTIONS.has(cmdName);
ownLoop = builtin && OWN_OPTION_LOOP.has(cmdName);
if (builtin) {
for (const [key, same] of LONG_SYNONYMS) {
const [name, spelling] = key.split(' ');
if (name === cmdName && spelling !== undefined)
synonyms.set(spelling, same);
}
}
longTable = builtin ? LONG_OPTION_TABLES[cmdName] : undefined;
}
// Leave a refusal on the tape, where a program that runs its own option
// loop reports it, and say whether it went there. `refused_on_tape` in
// parser.py is the twin.
const refusedOnTape = (word) => {
if (ownLoop)
flagOccurrences(flags).push([REFUSED, word]);
return ownLoop;
};
let i = 0;
let endOfFlags = false;
const recordOperand = (word) => {
rawArgs.push(word);
rawIndices.push(scanOrigins[i] ?? -1);
rawBases.push(base);
if (inOrderOperands || ownLoop)
flagOccurrences(flags).push([OPERAND, word]);
};
const recordValues = (spelling, arity, attachedValue = null) => {
const detached = arity - (attachedValue === null ? 0 : 1);
const values = [
...(attachedValue === null ? [] : [attachedValue]),
...scanArgv.slice(i + 1, i + detached + 1),
];
setValueFlag(flags, refusals, cs, wordChoiceDestSet, spelling, values);
const kinds = cs.valueTypesByDest.get(cs.destOf(spelling)) ?? [];
for (let offset = 0; offset < detached; offset++) {
wordKinds[scanOrigins[i + offset + 1] ?? -1] =
kinds[offset + (attachedValue === null ? 0 : 1)] ?? cs.kindOf.get(spelling) ?? 'str';
}
return detached + 1;
};
while (i < scanArgv.length) {
let tok = scanArgv[i];
if (tok === undefined)
break;
// Keep option words literal: the shape heuristic would treat
// `-o/data/out` as a relative path. Synthesized tar flags mark the
// original cluster here; values and operands receive their own kinds.
wordKinds[scanOrigins[i] ?? -1] = 'str';
scanned[scanOrigins[i] ?? -1] = true;
if (!endOfFlags && spec.ignoreTokens.has(tok)) {
// Expression syntax, never an operand of the declared kind: `find
// /d \( -name x \) ! -empty` would otherwise classify "(", ")" and
// "!" as PATH operands, giving find three phantom start points on
// top of the real one.
i += 1;
continue;
}
if (endOfFlags) {
recordOperand(tok);
i += 1;
continue;
}
if (spelledWords.has(tok))
flagOccurrences(flags).push([SPELLED, tok]);
if (tok === '--') {
endOfFlags = true;
i += 1;
continue;
}
if (tok.startsWith('--')) {
if (outsideSoleArgument) {
// Outside gnulib's one-argument window the program has no long options
// to recognize, so the word is an operand whether or not it is
// declared: `expr --help x` is a syntax error on `x`, not a help
// request.
recordOperand(tok);
i += 1;
continue;
}
// getopt_long: an exact spelling always wins; otherwise an
// unambiguous prefix expands to its declared spelling (grep --rec)
// and an ambiguous one is refused with every possibility. A program
// with no long-option parser keeps exact-only matching: its unknown
// dash tokens are operands, not typos. expr inside its window is a
// real getopt_long call, so `expr --h` does resolve to --help.
const eqPos = wholeWords ? -1 : tok.indexOf('=');
const typed = eqPos === -1 ? tok : tok.slice(0, eqPos);
let spelling = typed;
if (!cs.dest.has(typed) && abbreviations !== undefined) {
const resolved = expandGitLong(abbreviations, typed);
if (resolved !== null && 'ambiguous' in resolved) {
ambiguousOptions.push([tok, resolved.ambiguous]);
optionErrorKinds.push('ambiguous');
i += 1;
continue;
}
if (resolved !== null && cs.dest.has(resolved.spelling))
spelling = resolved.spelling;
}
else if (!cs.dest.has(typed) && longTable !== undefined) {
// The program's own table decides, since a prefix of an option
// mirage never declared is still ambiguous.
const found = expandTableLong(longTable, typed);
if (found.length > 1) {
ambiguousOptions.push([tok, found]);
optionErrorKinds.push('ambiguous');
i += 1;
continue;
}
const only = found[0];
if (only !== undefined) {
const group = longTable.find((g) => g[0] === only);
spelling = group?.find((name) => cs.dest.has(name)) ?? typed;
}
}
else if (!cs.dest.has(typed) && !noLongOptionParser && spec.allowAbbrev) {
const candidates = expandLong(cs, typed, synonyms);
if (candidates.length === 1) {
spelling = candidates[0] ?? typed;
}
else if (candidates.length > 1) {
// glibc names the word as typed, `=value` and all (`ls: option
// '--re=x' is ambiguous`).
ambiguousOptions.push([tok, candidates]);
optionErrorKinds.push('ambiguous');
i += 1;
continue;
}
}
const etok = eqPos === -1 ? spelling : spelling + tok.slice(eqPos);
const dest = cs.destOf(spelling);
const width = cs.nargsByDest.get(dest);
if (cs.longBoolSpellings.has(etok)) {
const next = scanArgv[i + 1];
if (cs.detachedOptionalSpellings.has(etok) &&
next !== undefined &&
(!next.startsWith('-') || next === '-' || NEGATIVE_NUMBER.test(next))) {
setValueFlag(flags, refusals, cs, wordChoiceDestSet, etok, next);
wordKinds[scanOrigins[i + 1] ?? -1] = cs.kindOf.get(etok) ?? null;
i += 2;
}
else {
setBoolFlag(flags, cs, etok);
i += 1;
}
}
else if (width === 1 && eqPos !== -1) {
i += recordValues(spelling, width, tok.slice(eqPos + 1));
}
else if (width !== undefined && eqPos === -1 && i + width < scanArgv.length) {
i += recordValues(spelling, width);
}
else if (width === undefined &&
cs.longValueSpellings.has(etok) &&
i + 1 < scanArgv.length) {
setValueFlag(flags, refusals, cs, wordChoiceDestSet, etok, scanArgv[i + 1] ?? '');
wordKinds[scanOrigins[i + 1] ?? -1] = cs.kindOf.get(etok) ?? null;
if (cs.destOf(etok) === cs.baseDest)
wordBases[scanOrigins[i + 1] ?? -1] = base;
base = rebase(flags, cs, etok, scanArgv[i + 1] ?? '', base);
i += 2;
}
else if (width !== undefined) {
if (eqPos === -1) {
if (!refusedOnTape(spelling)) {
needsValueOptions.push(spelling);
optionErrorKinds.push('needs_value');
}
}
else if (!refusedOnTape(tok)) {
// Multi-value options have no `=` form (jq refuses `--arg=name`
// as an unknown option).
invalidOptions.push(tok);
optionErrorKinds.push('invalid');
}
i += 1;
}
else {
if (eqPos !== -1 &&
(cs.longValueSpellings.has(spelling) || cs.longOptionalSpellings.has(spelling))) {
setValueFlag(flags, refusals, cs, wordChoiceDestSet, spelling, tok.slice(eqPos + 1));
base = rebase(flags, cs, spelling, tok.slice(eqPos + 1), base);
}
else if (cs.longValueSpellings.has(etok)) {
// Declared value flag at end of line with no argument.
if (!refusedOnTape(etok)) {
needsValueOptions.push(etok);
optionErrorKinds.push('needs_value');
}
}
else if (lenientDashOperands) {
recordOperand(tok);
}
else if (eqPos !== -1 && cs.longBoolSpellings.has(spelling)) {
// A boolean long handed a value. getopt_long knows the option, so
// it refuses the VALUE and names the option without it, which is a
// different message from the unrecognized one below (`grep
// --byte-offset=2` is "option '--byte-offset' doesn't allow an
// argument", not "unrecognized option '--byte-offset=2'"). Reported
// as the CANONICAL spelling plus the typed value, because GNU names
// the canonical one even for an abbreviation -- `grep --byte=2`
// answers for --byte-offset -- and because the programs that word
// this as an unknown option quote the value along with it.
if (!refusedOnTape(tok)) {
invalidOptions.push(spelling + tok.slice(eqPos));
optionErrorKinds.push('unexpected_value');
}
}
else if (!refusedOnTape(tok)) {
invalidOptions.push(tok);
optionErrorKinds.push('invalid');
}
i += 1;
}
continue;
}
// A dash word with no letter after the dash is an operand to jq (`-1`,
// `-.`, `- x`), so it falls through to the operands below.
if (tok.startsWith('-') && tok.length > 1 && (!letterOptions || DASH_LETTER.test(tok))) {
if (negativeNumbers && NEGATIVE_NUMBER.test(tok)) {
recordOperand(tok);
endOfFlags = true;
i += 1;
continue;
}
if (cs.numericDest !== null && NUMERIC_SHORT.test(tok)) {
flagOccurrences(flags).push([cs.numericDest, tok.slice(1)]);
flags[cs.numericDest] = tok.slice(1);
i += 1;
continue;
}
let matchedOptional = false;
for (const vf of cs.attachSpellings) {
if (tok.startsWith(vf) && tok.length > vf.length) {
setValueFlag(flags, refusals, cs, wordChoiceDestSet, vf, tok.slice(vf.length));
base = rebase(flags, cs, vf, tok.slice(vf.length), base);
i += 1;
matchedOptional = true;
break;
}
}
if (matchedOptional)
continue;
let matchedValue = false;
for (const vf of cs.valueSpellings) {
const dest = cs.destOf(vf);
const width = cs.nargsByDest.get(dest);
if (width !== undefined && tok.startsWith(vf)) {
const attachedValue = tok.length > vf.length ? attached(tok.slice(vf.length), equalsValues) : null;
const detached = width - (attachedValue === null ? 0 : 1);
if (i + detached >= scanArgv.length) {
needsValueOptions.push(vf.slice(1));
optionErrorKinds.push('needs_value');
i += 1;
}
else {
i += recordValues(vf, width, attachedValue);
}
matchedValue = true;
break;
}
if (tok === vf && i + 1 < scanArgv.length) {
setValueFlag(flags, refusals, cs, wordChoiceDestSet, vf, scanArgv[i + 1] ?? '');
wordKinds[scanOrigins[i + 1] ?? -1] = cs.kindOf.get(vf) ?? null;
if (cs.destOf(vf) === cs.baseDest)
wordBases[scanOrigins[i + 1] ?? -1] = base;
base = rebase(flags, cs, vf, scanArgv[i + 1] ?? '', base);
i += 2;
matchedValue = true;
break;
}
if (tok.startsWith(vf) && tok.length > vf.length) {
const attachedValue = attached(tok.slice(vf.length), equalsValues);
setValueFlag(flags, refusals, cs, wordChoiceDestSet, vf, attachedValue);
base = rebase(flags, cs, vf, attachedValue, base);
i += 1;
matchedValue = true;
break;
}
}
if (matchedValue) {
continue;
}
if (cs.boolSpellings.has(tok)) {
const next = scanArgv[i + 1];
if (cs.detachedOptionalSpellings.has(tok) &&
next !== undefined &&
(!next.startsWith('-') || next === '-' || NEGATIVE_NUMBER.test(next))) {
setValueFlag(flags, refusals, cs, wordChoiceDestSet, tok, next);
wordKinds[scanOrigins[i + 1] ?? -1] = cs.kindOf.get(tok) ?? null;
i += 2;
}
else {
setBoolFlag(flags, cs, tok);
i += 1;
}
continue;
}
const digitCluster = digitOptions && cs.numericDest !== null ? matchDigitCluster(tok, cs) : null;
if (digitCluster !== null && cs.numericDest !== null) {
for (const name of digitCluster.bools)
setBoolFlag(flags, cs, name);
flagOccurrences(flags).push([cs.numericDest, digitCluster.digits]);
Reflect.deleteProperty(flags, cs.numericDest);
flags[cs.numericDest] = digitCluster.digits;
if (digitCluster.rest === null) {
i += 1;
continue;
}
tok = digitCluster.rest;
}
let allBool = true;
for (const ch of tok.slice(1)) {
if (!cs.boolSpellings.has(`-${ch}`) || cs.detachedOptionalSpellings.has(`-${ch}`)) {
allBool = false;
break;
}
}
if (allBool && tok.length > 1) {
for (const ch of tok.slice(1))
setBoolFlag(flags, cs, `-${ch}`);
i += 1;
continue;
}
const mixed = matchMixedCluster(tok, cs);
if (mixed !== null) {
const dest = cs.destOf(mixed.valueFlag);
const arity = cs.nargsByDest.get(dest);
if (arity !== undefined) {
const remaining = arity - (mixed.attached === null ? 0 : 1);
if (i + remaining >= scanArgv.length) {
needsValueOptions.push(mixed.valueFlag.slice(1));
optionErrorKinds.push('needs_value');
i += 1;
}
else {
for (const name of mixed.bools)
setBoolFlag(flags, cs, name);
i += recordValues(mixed.valueFlag, arity, mixed.attached === null ? null : attached(mixed.attached, equalsValues));
}
continue;
}
const next = scanArgv[i + 1];
if (mixed.attached === null &&
cs.detachedOptionalSpellings.has(mixed.valueFlag) &&
(next === undefined ||
(next.startsWith('-') && next !== '-' && !NEGATIVE_NUMBER.test(next)))) {
for (const name of [...mixed.bools, mixed.valueFlag])
setBoolFlag(flags, cs, name);
i += 1;
continue;
}
if (mixed.attached !== null) {
const attachedValue = attached(mixed.attached, equalsValues);
for (const name of mixed.bools)
setBoolFlag(flags, cs, name);
setValueFlag(flags, refusals, cs, wordChoiceDestSet, mixed.valueFlag, attachedValue);
base = rebase(flags, cs, mixed.valueFlag, attachedValue, base);
i += 1;
continue;
}
if (i + 1 < scanArgv.length) {
for (const name of mixed.bools)
setBoolFlag(flags, cs, name);
setValueFlag(flags, refusals, cs, wordChoiceDestSet, mixed.valueFlag, scanArgv[i + 1] ?? '');
wordKinds[scanOrigins[i + 1] ?? -1] = cs.kindOf.get(mixed.valueFlag) ?? null;
if (cs.destOf(mixed.valueFlag) === cs.baseDest) {
wordBases[scanOrigins[i + 1] ?? -1] = base;
}
base = rebase(flags, cs, mixed.valueFlag, scanArgv[i + 1] ?? '', base);
i += 2;
continue;
}
}
if (lenientDashOperands || (NUMERIC_SHORT.test(tok) && !isBuiltinGrammar(cmdName, spec))) {
recordOperand(tok);
}
else if (cs.valueSpellings.includes(tok)) {
// A declared value flag with no argument left on the line.
if (!refusedOnTape(tok)) {
needsValueOptions.push(tok.slice(1));
optionErrorKinds.push('needs_value');
}
}
else if (mixed !== null && mixed.attached === null) {
// A cluster ending in a value flag that ran out of line.
if (ownLoop) {
// The loop reads the cluster's letters in turn.
for (const name of mixed.bools)
setBoolFlag(flags, cs, name);
}
if (!refusedOnTape(mixed.valueFlag)) {
needsValueOptions.push(mixed.valueFlag.slice(1));
optionErrorKinds.push('needs_value');
}
}
else {
// GNU reports the first offending character, not the token.
let bad = tok.slice(1, 2);
let before = [];
const read = [];
for (const ch of tok.slice(1)) {
if (!cs.boolSpellings.has(`-${ch}`) && !cs.valueSpellings.includes(`-${ch}`)) {
bad = ch;
before = read;
break;
}
read.push(ch);
}
if (ownLoop) {
// The letters before it are read first, so jq's `-hx` is help.
for (const ch of before) {
if (cs.boolSpellings.has(`-${ch}`))
setBoolFlag(flags, cs, `-${ch}`);
}
}
if (!refusedOnTape(`-${bad}`)) {
invalidOptions.push(bad);
optionErrorKinds.push('invalid');
}
}
i += 1;
continue;
}
recordOperand(tok);
// The first operand ends option parsing outright under
// argparse's REMAINDER, so a script's own flags reach the script
// of being read as the interpreter's.
if (cs.remainder)
endOfFlags = true;
i += 1;
}
// Snapshot before defaults land, because "typed" and "present" stop being
// the same set one line below. A dialect that echoes the options a line
// carried (clap's missing-argument usage) needs the former, and key order is
// the order they were scanned in.
const typedDests = Object.keys(flags);
// An option's declared variable lands exactly where a default does, so it
// gets the same coercion, the same choices test, the same PATH resolution
// and the same required credit. Filling it after the parse instead would
// leave an int unchecked and a path a bare string. It goes in ahead of the
// defaults because it outranks one, it yields to anything the line typed,
// and it lands below the snapshot because clap's usage line distinguishes
// an option the line carried from one the environment supplied.
for (const [destName, variable] of cs.envByDest) {
if (destName in flags)
continue;
const supplied = env?.[variable];
if (supplied === undefined || supplied === '')
continue;
flags[destName] = cs.multipleDests.has(destName) ? [supplied] : supplied;
}
// Declared defaults land as if typed, before choices/required checks
// and before PATH/TEXT flag-value collection, so a PATH default
// resolves and routes and a default always satisfies required. A
// multiple dest holds lists, so its default is a one-element list.
for (const [destName, defaultValue] of cs.defaults) {
if (!(destName in flags)) {
flags[destName] = cs.multipleDests.has(destName) ? [defaultValue] : defaultValue;
}
}
// Every typed value was checked as it was read; what a default or the
// environment filled in afterwards is checked here. A word-choice dest
// canonicalizes here too, so a default spelled as a prefix reaches the
// command as the candidate it names.
const checked = new Set([...cs.intDests, ...cs.floatDests, ...cs.choicesByDest.keys()]);
for (const destName of checked) {
if (typedDests.includes(destName))
continue;
const values = bagValues(flags, destName);
const stored = values.map((part) => checkValue(refusals, cs, wordChoiceDestSet, destName, part));
if (stored.length > 0 && stored.some((word, at) => word !== values[at])) {
flags[destName] = Array.isArray(flags[destName]) ? stored : (stored[0] ?? '');
}
}
const missingRequiredOptions = cs.requiredDests.filter((destName) => !(destName in flags));
const slots = cs.positional.filter((op) => !op.providedBy.some((name) => cs.destOf(name) in flags));
let required = slots.filter(positionalRequired).length +
Number(cs.rest !== null && positionalRequired(cs.rest));
// An optional slot can consume only words not needed by required slots.
const supplying = [];
for (const op of slots) {
if (positionalRequired(op))
required -= 1;
else if (rawArgs.length - supplying.length <= required)
continue;
supplying.push(op);
}
const positional = supplying.map((op) => op.type);
// A required slot the line left empty. Counted against the surviving slots
// rather than the declared ones, so a flag standing in for a slot
// (providedBy) satisfies it the same way a word would.
const missingRequiredOperands = supplying
.filter((op, index) => positionalRequired(op) && rawArgs.length <= index)
.map((op) => (positionalName(op) === '' ? ARG_PLACEHOLDER : positionalName(op)));
if (cs.rest !== null && positionalRequired(cs.rest) && rawArgs.length <= supplying.length) {
missingRequiredOperands.push(positionalName(cs.rest) === '' ? ARG_PLACEHOLDER : positionalName(cs.rest));
}
// A flag can turn the rest slot textual for this line only: tar's -x makes
// every operand a member name rather than a file, and jq's --args makes the
// operands typed after it positional strings rather than input files. Only
// classification moves: unknown dash tokens stay as strict as the declared
// kind makes them.
const textFrom = cs.rest === null ? null : firstTextOperand(flags, cs, cs.rest.textWhen, inOrderOperands);
// Overflow operands past the declared positional slots pass through
// classified like the last slot (TEXT when there is none), so a
// fixed-arity command receives them and raises its own extra-operand
// UsageError (#452). The parser classifies, it never drops or raises.
const overflowKind = positional.at(-1) ?? 'str';
const stdinScript = STDIN_SCRIPT_COMMANDS.has(cmdName) && isBuiltinGrammar(cmdName, spec);
const classified = [];
const rawOperands = [];
for (let j = 0; j < rawArgs.length; j++) {
const arg = rawArgs[j];
if (arg === undefined)
continue;
const operand = supplying[j] ?? cs.rest;
if (operand !== null && !spec.ignoreTokens.has(arg)) {
checkValue(refusals, cs, new Set(), operand.names[0] ?? '', arg);
}
let kind;
if (j < positional.length) {
kind = positional[j] ?? 'str';
}
else if (textFrom !== null && j >= textFrom) {
kind = 'str';
}
else if (cs.restKind !== null) {
kind = cs.restKind;
}
else {
kind = overflowKind;
}
if (stdinScript && kind === 'path' && arg === '-')
kind = 'str';
if (kind === 'path') {
// Against the base an operandBase option left in effect at this
// position, which is the session cwd for every command that
// declares none.
const here = rawBases[j] ?? cwd;
classified.push([resolvePath(arg, here), 'path']);
rawOperands.push([arg, 'path']);
const baseIdx = rawIndices[j];
if (here !== cwd && baseIdx !== undefined && baseIdx >= 0)
wordBases[baseIdx] = here;
}
else {
classified.push([arg, kind]);
rawOperands.push([arg, kind]);
}
const origIdx = rawIndices[j];
if (origIdx !== undefined && origIdx >= 0)
wordKinds[origIdx] = kind;
}
const rawPathFlags = {};
const pathFlagValues = [];
for (const [flagName, kind] of cs.kindByDest) {
if (kind !== 'path' || !(flagName in flags))
continue;
const val = flags[flagName];
if (val !== undefined)
rawPathFlags[flagKwargName(flagName)] = val;
if (Array.isArray(val) && cs.valueTypesByDest.has(flagName)) {
// Only the odd slots are the paths: the even ones name them.
const kinds = cs.valueTypesByDest.get(flagName) ?? [];
const paired = val.map((part, index) => kinds[index % kinds.length] === 'path' ? resolvePath(part, cwd) : part);
flags[flagName] = paired;
for (const item of paired.filter((_, index) => kinds[index % kinds.length] === 'path'))
pathFlagValues.push(item);
}
else if (Array.isArray(val)) {
const resolvedList = val.map((part) => part === '-' &&
['grep', 'rg', 'sed', 'awk'].includes(cmdName) &&
['-f', '--file'].includes(flagName)
? '-'
: resolvePath(part, cwd));
flags[flagName] = resolvedList;
for (const item of resolvedList)
pathFlagValues.push(item);
}
else if (typeof val === 'string') {
if (val === '-' && STDOUT_DASH_OPTIONS.get(cmdName) === flagName)
continue;
const resolved = resolvePath(val, cwd);
flags[flagName] = resolved;
pathFlagValues.push(resolved);
}
}
const textFlagValues = [];
for (const [flagName, kind] of cs.kindByDest) {
if (kind === 'path' || !(flagName in flags))
continue;
const val = flags[flagName];
if (Array.isArray(val)) {
for (const item of val)
textFlagValues.push(item);
}
else if (typeof val === 'string') {
textFlagValues.push(val);
}
}
for (const occurrence of flagOccurrences(flags)) {
const [name, value] = occurrence;
if (cs.kindByDest.get(name) === 'path' &&
typeof value === 'string' &&
!(value === '-' && STDOUT_DASH_OPTIONS.get(cmdName) === name))
occurrence[1] = resolvePath(value, cwd);
}
const operandSlots = new Set(rawIndices);
const wordRoles = wordKinds.map((kind, slot) => operandSlots.has(slot)
? WordKind.OPERAND
: scanned[slot] === true
? WordKind.OPTION
: kind !== null
? WordKind.VALUE
: null);
const parsed = new ParsedArgs({
flags,
args: classified,
rawPathFlags,
pathFlagValues,
rawOperands,
textFlagValues,
warnings,
invalidOptions,
ambiguousOptions,
optionErrorKinds,
needsValueOptions,
invalidValueOptions: refusals.values,
ambiguousValueOptions: refusals.ambiguousValues,
invalidIntOptions: refusals.ints,
invalidFloatOptions: refusals.floats,
missingRequiredOptions,
missingRequiredOperands,
typedDests,
oldOptionNeedsValue: old !== null ? old.needsValue : null,
builtin,
wordKinds,
wordBases,
wordRoles,
});
if (parses !== undefined) {
if (parses.size >= PARSES_KEPT)
parses.clear();
parses.set(key, parsed);
}
return parsed;
}
export function parseToKwargs(parsed) {
const result = {};
for (const [key, value] of Object.entries(parsed.flags)) {
result[flagKwargName(key)] = value;
}
flagOccurrences(result).push(...flagOccurrences(parsed.flags).map(([name, value]) => [
flagKwargName(name),
value,
]));
return result;
}
//# sourceMappingURL=parser.js.map