@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
407 lines • 17.5 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { FLOAT_VALUE, INT_VALUE } from "./constants.js";
import {} from "./types.js";
/** First short and long spelling, for dialects that display each separately. */
export function optionSpellings(argument) {
return [
argument.names.find((name) => !name.startsWith('--')) ?? null,
argument.names.find((name) => name.startsWith('--')) ?? null,
];
}
/** Canonical option spelling, or the positional argument's name. */
export function argumentDest(argument) {
return argument.names.find((name) => name.startsWith('--')) ?? argument.names[0] ?? '';
}
/** Declared positional placeholder, including an explicit empty placeholder. */
export function positionalName(argument) {
return argument.metavar ?? argument.names[0] ?? '';
}
export function positionalRequired(argument) {
return argument.nargs !== '?' && argument.nargs !== '*' && argument.nargs !== 'REMAINDER';
}
function argumentShapes(spec) {
const options = [];
const positional = [];
let rest = null;
const seenNames = new Set();
for (const arg of spec.arguments) {
if (arg.names.length === 0 ||
arg.names.some((name) => name === '' || name === '-' || name === '--')) {
throw new Error('argument requires a name or option spelling');
}
const optional = arg.names[0]?.startsWith('-') ?? false;
if (arg.names.some((name) => name.startsWith('-') !== optional)) {
throw new Error('argument cannot mix a positional name and option spellings');
}
if (arg.type === 'bool')
throw new Error("argument type 'bool' is expressed with action='store_true'");
if (typeof arg.nargs === 'number' && (!Number.isInteger(arg.nargs) || arg.nargs < 1)) {
throw new Error('nargs must be a positive integer');
}
if ((arg.action === 'store_true' || arg.action === 'count') &&
(arg.nargs !== null || arg.type !== 'str')) {
throw new Error('store_true and count do not take a type or nargs');
}
if (arg.valueTypes.length > 0 &&
(arg.nargs !== 2 || !['str,str', 'str,path'].includes(arg.valueTypes.join(',')))) {
throw new Error('valueTypes supports named text/path pairs with nargs=2');
}
if (optional) {
if (arg.nargs === '*' || arg.nargs === '+' || arg.nargs === 'REMAINDER') {
throw new Error('option nargs must be a fixed count or ?');
}
if (arg.attachedOnly && arg.nargs !== '?')
throw new Error('attachedOnly requires nargs ?');
if (arg.providedBy.length > 0 || arg.textWhen.length > 0) {
throw new Error('providedBy and textWhen require a positional argument');
}
options.push(arg);
}
else {
if (arg.names.length !== 1)
throw new Error('a positional argument takes exactly one name');
const name = arg.names[0] ?? '';
if (seenNames.has(name))
throw new Error(`duplicate positional argument '${name}'`);
seenNames.add(name);
if (arg.default !== null)
throw new Error('positional defaults are not supported');
if (arg.valueTypes.length > 0)
throw new Error('valueTypes requires option spellings');
if (arg.required)
throw new Error('positional arity uses nargs, not required');
if (arg.action !== 'store')
throw new Error('a positional argument requires action store');
if (arg.attachedOnly || arg.numericShorthand || arg.env !== null || !arg.shortValue) {
throw new Error('option settings require option spellings');
}
if (rest !== null)
throw new Error('a variadic positional argument must be last');
if (arg.nargs === '*' || arg.nargs === '+' || arg.nargs === 'REMAINDER')
rest = arg;
else
for (let i = 0; i < (typeof arg.nargs === 'number' ? arg.nargs : 1); i++)
positional.push(arg);
}
}
return { options, positional, rest };
}
const CACHE = new WeakMap();
// git's notation for an option parse-options also answers as `--no-<name>`,
// and the prefix itself.
const NEGATABLE = '[no-]';
const NO = 'no-';
/** Lower a CommandSpec into parser lookup tables, cached per spec. */
export function compileSpec(spec) {
const cached = CACHE.get(spec);
if (cached !== undefined)
return cached;
const grammar = argumentShapes(spec);
const nargsByDest = new Map();
const valueTypesByDest = new Map();
const detachedOptionalSpellings = new Set();
const seenSpellings = new Set();
const boolSpellings = new Set();
const valueSpellings = [];
const attachSpellings = [];
const longBoolSpellings = new Set();
const longValueSpellings = new Set();
const longOptionalSpellings = new Set();
const longSpellings = [];
const intDests = new Set();
const floatDests = new Set();
const kindOf = new Map();
const kindByDest = new Map();
const dest = new Map();
const multipleDests = new Set();
const countDests = new Set();
const choicesByDest = new Map();
const requiredDests = [];
const defaults = new Map();
const envByDest = new Map();
let numericDest = null;
for (const opt of grammar.options) {
const canonical = argumentDest(opt);
for (const spelling of opt.names) {
if (seenSpellings.has(spelling)) {
throw new Error(`duplicate option spelling '${spelling}'`);
}
seenSpellings.add(spelling);
}
const arg = opt;
const width = typeof arg.nargs === 'number' ? arg.nargs : 1;
if (typeof arg.nargs === 'number') {
if (arg.action === 'append')
throw new Error('multi-value arguments use store or extend');
nargsByDest.set(canonical, width);
valueTypesByDest.set(canonical, arg.valueTypes.length > 0 ? arg.valueTypes : Array(width).fill(arg.type));
}
if (arg.nargs === '?' && !arg.attachedOnly) {
for (const spelling of arg.names)
detachedOptionalSpellings.add(spelling);
}
if ((opt.action === 'store_true' || opt.action === 'count') &&
(opt.choices.length > 0 || opt.default !== null)) {
throw new Error(`option '${canonical}': choices and default require a value flag`);
}
if (opt.choices.length > 0 && opt.default !== null && !opt.choices.includes(opt.default)) {
throw new Error(`option '${canonical}': default '${opt.default}' is not one of its choices`);
}
if (opt.type === 'int') {
if (opt.default !== null && !INT_VALUE.test(opt.default)) {
throw new Error(`option '${canonical}': default '${opt.default}' is not an integer`);
}
intDests.add(canonical);
}
if (opt.type === 'float') {
if (opt.default !== null && !FLOAT_VALUE.test(opt.default)) {
throw new Error(`option '${canonical}': default '${opt.default}' is not a number`);
}
floatDests.add(canonical);
}
for (const spelling of arg.names)
dest.set(spelling, canonical);
if (opt.action !== 'store_true' && opt.action !== 'count')
kindByDest.set(canonical, opt.type);
if (opt.action === 'append' || opt.action === 'extend')
multipleDests.add(canonical);
if (opt.action === 'count')
countDests.add(canonical);
if (opt.choices.length > 0)
choicesByDest.set(canonical, opt.choices);
if (opt.required)
requiredDests.push(canonical);
if (opt.default !== null)
defaults.set(canonical, opt.default);
if (opt.env !== null)
envByDest.set(canonical, opt.env);
for (const short of arg.names.filter((name) => !name.startsWith('--'))) {
if (opt.action === 'store_true' || opt.action === 'count') {
boolSpellings.add(short);
}
else if (opt.nargs === '?') {
// GNU optional argument: the bare short is boolean and a value
// only rides attached to the same token.
boolSpellings.add(short);
if (opt.shortValue)
attachSpellings.push(short);
kindOf.set(short, opt.type);
}
else {
valueSpellings.push(short);
kindOf.set(short, opt.type);
if (opt.numericShorthand)
numericDest = canonical;
}
}
for (const long of arg.names.filter((name) => name.startsWith('--'))) {
longSpellings.push(long);
if (opt.action === 'store_true' || opt.action === 'count') {
longBoolSpellings.add(long);
}
else if (opt.nargs === '?') {
// GNU optional argument: bare form is boolean, value only
// attaches via `=`; a detached next token is an operand.
longBoolSpellings.add(long);
longOptionalSpellings.add(long);
kindOf.set(long, opt.type);
}
else {
longValueSpellings.add(long);
kindOf.set(long, opt.type);
}
}
}
for (const operand of [...grammar.positional, ...(grammar.rest === null ? [] : [grammar.rest])]) {
const argument = operand;
const name = argument.names[0] ?? '';
if (argument.type === 'int')
intDests.add(name);
if (argument.type === 'float')
floatDests.add(name);
if (argument.choices.length > 0)
choicesByDest.set(name, argument.choices);
}
let baseDest = null;
if (spec.operandBase !== null) {
baseDest = dest.get(spec.operandBase) ?? null;
if (baseDest === null) {
throw new Error(`operandBase '${spec.operandBase}' is not a declared option`);
}
if (kindByDest.get(baseDest) !== 'path' || nargsByDest.has(baseDest)) {
throw new Error(`operandBase '${spec.operandBase}' must be a single-token path option`);
}
}
// Longest first so an attached match can never be stolen by a shorter
// spelling that happens to prefix it (-name vs -n).
valueSpellings.sort((a, b) => b.length - a.length);
attachSpellings.sort((a, b) => b.length - a.length);
const compiled = {
...grammar,
nargsByDest,
valueTypesByDest,
detachedOptionalSpellings,
boolSpellings,
valueSpellings,
attachSpellings,
longBoolSpellings,
longValueSpellings,
longOptionalSpellings,
longSpellings,
intDests,
floatDests,
kindOf,
kindByDest,
dest,
multipleDests,
countDests,
choicesByDest,
requiredDests,
defaults,
envByDest,
numericDest,
restKind: grammar.rest !== null ? grammar.rest.type : null,
baseDest,
remainder: grammar.rest?.nargs === 'REMAINDER',
destOf(spelling) {
return dest.get(spelling) ?? spelling;
},
};
CACHE.set(spec, compiled);
return compiled;
}
/**
* getopt_long prefix matching for a long spelling.
*
* An exact declared spelling always wins (GNU: `--binary` never trips
* over `--binary-files`); otherwise the candidates are every declared
* long the typed spelling prefixes. Two declared options are two options,
* so a prefix of both is ambiguous (`ls --re` is `--reverse` or
* `--recursive`), unless `synonyms` (from LONG_SYNONYMS) names them one
* option under two names, the way glibc treats several table entries
* sharing one `val` (`grep --colo` resolves despite `--color`/`--colour`
* being separate entries); then the prefix resolves to the first. The
* result length tells the caller everything: 0 unknown, 1 match, 2+
* ambiguous (every matching spelling in declaration order, the order GNU
* lists possibilities, synonyms included like GNU's own listing).
*/
/**
* git's parse-options resolution of one long option against the program's own
* table, which lists each option in git's `--[no-]` notation.
*
* An exact name wins at once, a negatable option answering to its `--no-`
* form too. Otherwise the word may abbreviate one option, `--no-` abbreviating
* a negation, and a word that abbreviates two is ambiguous: git names the last
* two it found, each with the `no-` it was matched under. A word matching
* nothing is null, and the caller decides what that is. The result is the
* spelling the table resolves to, which the spec may or may not declare.
*
* @param table the program's long options, e.g. `['[no-]verbose', 'contains']`
* @param typed the word as typed, `--` included and any `=value` removed
*/
export function expandGitLong(table, typed) {
const arg = typed.slice(2);
let found = null;
let earlier = null;
for (const entry of table) {
const negatable = entry.startsWith(NEGATABLE);
const long = negatable ? entry.slice(NEGATABLE.length) : entry;
const inverted = !arg.startsWith(NO) && negatable && long.startsWith(NO);
const name = inverted ? long.slice(NO.length) : long;
let unset = false;
let exact = arg === name;
let abbreviated = !exact && name.startsWith(arg);
if (!exact && !abbreviated && negatable) {
if (NO.startsWith(arg)) {
unset = true;
abbreviated = true;
}
else if (arg.startsWith(NO)) {
unset = true;
exact = arg.slice(NO.length) === name;
abbreviated = !exact && name.startsWith(arg.slice(NO.length));
}
}
if (exact)
return { spelling: gitSpelling(long, unset !== inverted) };
if (abbreviated) {
earlier = found;
found = [long, unset !== inverted];
}
}
if (found === null)
return null;
if (earlier !== null)
return { ambiguous: [gitShown(...earlier), gitShown(...found)] };
return { spelling: gitSpelling(...found) };
}
/** How git names a candidate in its ambiguity refusal. */
function gitShown(long, unset) {
return `--${unset ? NO : ''}${long}`;
}
/** The long spelling one of git's options answers to, negated or not. */
function gitSpelling(long, unset) {
if (!unset)
return `--${long}`;
return long.startsWith(NO) ? `--${long.slice(NO.length)}` : `--${NO}${long}`;
}
/**
* getopt_long prefix matching against a program's whole table.
*
* An entry spelled exactly names its option; otherwise every entry the typed
* spelling prefixes is a candidate. glibc sets aside a later candidate that
* names the same option as the first one, so one option's aliases resolve
* where two options are ambiguous. The result length tells the caller
* everything: 0 unknown, 1 the option's primary spelling, 2+ the
* possibilities glibc lists (the first candidate and every later one naming
* another option, in table order). `table` is each option's primary spelling
* then its aliases, in the program's table order (LONG_OPTION_TABLES).
* Mirrors Python's expand_table_long.
*/
export function expandTableLong(table, spelling) {
const entries = table.flatMap((group) => group.map((name) => [name, group[0] ?? name]));
for (const [name, primary] of entries)
if (name === spelling)
return [primary];
if (spelling.length <= 2)
return [];
const matches = entries.filter(([name]) => name.startsWith(spelling));
const first = matches[0];
if (first === undefined)
return [];
const listed = [
first[0],
...matches
.slice(1)
.filter(([, p]) => p !== first[1])
.map(([n]) => n),
];
return listed.length === 1 ? [first[1]] : listed;
}
export function expandLong(cs, spelling, synonyms = new Map()) {
if (cs.dest.has(spelling))
return [spelling];
if (spelling.length <= 2)
return [];
const matches = cs.longSpellings.filter((declared) => declared.startsWith(spelling));
const first = matches[0];
if (first === undefined)
return [];
if (new Set(matches.map((declared) => synonyms.get(declared) ?? declared)).size === 1) {
return [first];
}
return matches;
}
//# sourceMappingURL=compile.js.map