UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

407 lines • 17.5 kB
// ========= 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