File size: 6,577 Bytes
eb3f11e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | // Reparse support for lazy commands after their placeholder has been replaced.
import type { Command, Option } from "commander";
import { buildParseArgv } from "../argv.js";
function getCommandPathFromRoot(command: Command | undefined): Command[] {
const path: Command[] = [];
let current = command;
while (current?.parent) {
if (current.name()) {
path.unshift(current);
}
current = current.parent;
}
return path;
}
function findRootCommand(cmd: Command): Command {
let current: Command = cmd;
while (current.parent) {
current = current.parent;
}
return current;
}
function findOption(command: Command, token: string): Option | undefined {
const equalsIndex = token.indexOf("=");
const flag = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
const exactOption = command.options.find(
(candidate) =>
(candidate.short === flag || candidate.long === flag) &&
(equalsIndex === -1 || candidate.required || candidate.optional),
);
if (exactOption || !token.startsWith("-") || token.startsWith("--") || token.length <= 2) {
return exactOption;
}
// Once a child claims a short group, preserve its unknown suffix for Commander.
let claimedOption: Option | undefined;
for (let index = 1; index < token.length; index += 1) {
const option = command.options.find((candidate) => candidate.short === `-${token[index]}`);
if (!option) {
return claimedOption;
}
claimedOption ??= option;
if (option.required || option.optional || index === token.length - 1) {
return option;
}
}
return undefined;
}
function findNearestOption(commands: readonly Command[], token: string): Option | undefined {
for (let index = commands.length - 1; index >= 0; index -= 1) {
const command = commands[index];
const option = command ? findOption(command, token) : undefined;
if (option) {
return option;
}
}
return undefined;
}
function matchesCommandName(command: Command, token: string): boolean {
return command.name() === token || command.aliases().includes(token);
}
// Returns 0 for a missing required value, otherwise the number of consumed tokens.
function optionTokenCount(option: Option, argv: readonly string[], index: number): number {
const token = argv[index] ?? "";
const shortFlagIndex =
option.short !== undefined && !token.startsWith("--")
? token.indexOf(option.short.slice(1), 1)
: -1;
const hasAttachedShortValue = shortFlagIndex !== -1 && shortFlagIndex < token.length - 1;
if (token.includes("=") || hasAttachedShortValue || (!option.required && !option.optional)) {
return 1;
}
const next = argv[index + 1];
if (option.required) {
return next === undefined ? 0 : 2;
}
const optionalValue = next && (!next.startsWith("-") || /^-\d/.test(next));
return optionalValue ? 2 : 1;
}
function findCommandPathEnd(argv: readonly string[], command: Command): number {
const path = getCommandPathFromRoot(command);
const root = path[0]?.parent;
if (!root) {
return -1;
}
const selectedCommands = [root];
let pathIndex = 0;
for (let index = 2; index < argv.length; index += 1) {
const token = argv[index] ?? "";
const option = findNearestOption(selectedCommands, token);
if (option) {
const count = optionTokenCount(option, argv, index);
if (count === 0) {
return -1;
}
index += count - 1;
continue;
}
const nextCommand = path[pathIndex];
if (!nextCommand || !matchesCommandName(nextCommand, token)) {
return -1;
}
selectedCommands.push(nextCommand);
pathIndex += 1;
if (pathIndex === path.length) {
return index + 1;
}
}
return -1;
}
/** Restore parent-option placement without stealing options owned by the loaded child command. */
function hoistLazyParentOptions(
argv: string[],
parentCommand: Command,
lazyCommandName: string,
): string[] {
let lazyCommandIndex = findCommandPathEnd(argv, parentCommand);
if (lazyCommandIndex === -1) {
return argv;
}
while (lazyCommandIndex < argv.length) {
const option = findOption(parentCommand, argv[lazyCommandIndex] ?? "");
if (!option) {
break;
}
const count = optionTokenCount(option, argv, lazyCommandIndex);
if (count === 0) {
return argv;
}
lazyCommandIndex += count;
}
if (argv[lazyCommandIndex] !== lazyCommandName) {
return argv;
}
const lazyCommand = parentCommand.commands.find((command) =>
matchesCommandName(command, lazyCommandName),
);
if (!lazyCommand) {
return argv;
}
let selectedCommand = lazyCommand;
const selectedCommands = [selectedCommand];
const hoisted: string[] = [];
const remaining: string[] = [];
let acceptsSubcommands = true;
for (let index = lazyCommandIndex + 1; index < argv.length; index += 1) {
const token = argv[index] ?? "";
if (token === "--") {
remaining.push(...argv.slice(index));
break;
}
const childOption = findNearestOption(selectedCommands, token);
const parentOption = findOption(parentCommand, token);
const option = childOption ?? parentOption;
if (option) {
const count = optionTokenCount(option, argv, index);
if (count === 0) {
return argv;
}
const tokens = argv.slice(index, index + count);
(childOption ? remaining : hoisted).push(...tokens);
index += count - 1;
continue;
}
if (acceptsSubcommands && !token.startsWith("-")) {
const nextCommand: Command | undefined = selectedCommand.commands.find((command) =>
matchesCommandName(command, token),
);
if (nextCommand) {
selectedCommand = nextCommand;
selectedCommands.push(nextCommand);
} else {
acceptsSubcommands = false;
}
}
remaining.push(token);
}
return hoisted.length === 0
? argv
: [...argv.slice(0, lazyCommandIndex), ...hoisted, lazyCommandName, ...remaining];
}
/** Re-run parsing after replacing a lazy command placeholder. */
export async function reparseProgramFromActionCommand(
program: Command,
actionCommand: Command,
): Promise<void> {
const rootProgram = findRootCommand(actionCommand) as Command & { rawArgs: string[] };
// Commander 15 snapshots the full parse input on the root before actions run.
const parseArgv = buildParseArgv(rootProgram.rawArgs, rootProgram.name());
const normalizedArgv = hoistLazyParentOptions(parseArgv, program, actionCommand.name());
await rootProgram.parseAsync(normalizedArgv);
}
|