Files
gitea-axi/src/flags.ts
alexion 0bf914cbdd
Some checks failed
CI / test (pull_request) Failing after 38s
feat: add label commands (task 0015)
2026-07-14 07:24:16 -04:00

234 lines
7.3 KiB
TypeScript

import { axiError } from "./errors.js";
export interface FlagSpec {
/** Flag names (e.g. "--state") mapped to how they are parsed. */
[name: string]: {
takesValue: boolean;
/** Accumulate every occurrence into `lists` instead of `flags` (e.g. `--label`). */
repeatable?: boolean;
};
}
export interface ParsedFlags {
/** Single-valued flags: the value, or `true` for a bare switch. */
flags: Record<string, string | true>;
/**
* Values of each repeatable flag in argv order. Every repeatable flag in the
* spec is present, holding an empty array when it was not passed.
*/
lists: Record<string, string[]>;
positionals: string[];
}
export interface SplitFlag {
name: string;
inlineValue?: string;
}
/**
* Read a value-taking flag. `parseFlags` rejects such a flag without a value,
* so anything present here is a string; the `true` case only arises for bare
* switches, which callers never read through this helper.
*/
export function flagValue(
flags: Record<string, string | true>,
name: string,
): string | undefined {
const value = flags[name];
return typeof value === "string" ? value : undefined;
}
/** ["open", "closed", "all"] → "open, closed, or all". */
function orList(values: readonly string[]): string {
if (values.length < 2) {
return values[0] ?? "";
}
return `${values.slice(0, -1).join(", ")}, or ${values[values.length - 1]}`;
}
/**
* Read a flag whose value must be one of a fixed set. Returns undefined when the
* flag was absent, leaving the default to the caller — a flag with no default
* (`--sort`) and one with a default (`--state`) then differ only in what they do
* with that undefined.
*/
export function parseEnumFlag<T extends string>(
value: string | true | undefined,
name: string,
allowed: readonly T[],
suggestions: string[],
): T | undefined {
if (value === undefined) {
return undefined;
}
if (value === true || !allowed.includes(value as T)) {
throw axiError(
`Invalid ${name} value: ${String(value)} (expected ${orList(allowed)})`,
"VALIDATION_ERROR",
suggestions,
);
}
return value as T;
}
/**
* Parse a flag's value as a positive integer, rejecting a bare switch or a
* non-integer with a uniform `VALIDATION_ERROR`. `label` names the flag in the
* message (e.g. "--limit", "--label-id"). Shared by every flag that takes one.
*/
export function parsePositiveInt(
value: string | true,
label: string,
suggestions: string[] = [],
): number {
const parsed = Number(value);
if (value === true || !Number.isInteger(parsed) || parsed < 1) {
throw axiError(
`Invalid ${label} value: ${String(value)} (expected a positive integer)`,
"VALIDATION_ERROR",
suggestions,
);
}
return parsed;
}
/** Split "--flag=value" into name and inline value; "--flag" has none. */
export function splitFlag(arg: string): SplitFlag {
const equals = arg.indexOf("=");
if (equals === -1) {
return { name: arg };
}
return { name: arg.slice(0, equals), inlineValue: arg.slice(equals + 1) };
}
/**
* Resolve a value-taking flag's value from its inline form or the next
* argument, returning the index of the last argument consumed.
*/
export function consumeFlagValue(
args: string[],
index: number,
flag: SplitFlag,
suggestions: string[] = [],
): { value: string; lastIndex: number } {
if (flag.inlineValue !== undefined) {
if (!flag.inlineValue) {
throw axiError(`Flag ${flag.name} requires a value`, "VALIDATION_ERROR", suggestions);
}
return { value: flag.inlineValue, lastIndex: index };
}
const next = args[index + 1];
if (next === undefined || next.startsWith("-")) {
throw axiError(`Flag ${flag.name} requires a value`, "VALIDATION_ERROR", suggestions);
}
return { value: next, lastIndex: index + 1 };
}
/** "issue" → "an issue"; "pull request" → "a pull request". */
function withArticle(noun: string): string {
return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`;
}
/**
* Validate a single raw argument as a positive issue number. `noun` names what
* the number identifies ("issue", "target") in the error; `suggestions` carries
* the caller's help line. Shared by the single-positional parser and the
* two-positional dependency parser so the rule and its message live in one place.
*/
export function parseIssueNumber(raw: string, noun: string, suggestions: string[]): number {
const number = Number(raw);
if (!Number.isInteger(number) || number < 1) {
throw axiError(
`Invalid ${noun} number: ${raw} (expected a positive integer)`,
"VALIDATION_ERROR",
suggestions,
);
}
return number;
}
/**
* Take the single positional of a `<command> <arg>` invocation, rejecting a
* missing or extra one. `what` is the noun phrase the arg is ("an issue number",
* "a label name") and `placeholder` is how it reads in the usage line
* (`<number>`, `<name>`); the count checks and their messages are identical for
* every single-positional command, number or name.
*/
export function parseSinglePositional(
positionals: string[],
command: string,
what: string,
placeholder: string,
): string {
if (positionals.length === 0) {
throw axiError(`${command} requires ${what}`, "VALIDATION_ERROR", [
`Run \`gitea-axi ${command} ${placeholder}\``,
]);
}
if (positionals.length > 1) {
throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", [
`Run \`gitea-axi ${command} --help\` to see available flags`,
]);
}
return positionals[0]!;
}
/**
* Parse the single positional number of a `<command> <number>` invocation.
* `noun` names what the number identifies ("issue", "pull request") and appears
* in the errors; the parsing itself is identical for both.
*/
export function parsePositionalNumber(
positionals: string[],
command: string,
noun: string,
): number {
const raw = parseSinglePositional(positionals, command, `${withArticle(noun)} number`, "<number>");
return parseIssueNumber(raw, noun, [
`Run \`gitea-axi ${command} --help\` to see available flags`,
]);
}
export function parseFlags(
args: string[],
spec: FlagSpec,
helpCommand: string,
): ParsedFlags {
const flags: Record<string, string | true> = {};
const lists: Record<string, string[]> = {};
for (const [name, entry] of Object.entries(spec)) {
if (entry.repeatable) {
lists[name] = [];
}
}
const positionals: string[] = [];
const helpSuggestion = [`Run \`gitea-axi ${helpCommand} --help\` to see available flags`];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (!arg.startsWith("-")) {
positionals.push(arg);
continue;
}
const flag = splitFlag(arg);
const entry = spec[flag.name];
if (!entry) {
throw axiError(`Unknown flag: ${flag.name}`, "VALIDATION_ERROR", helpSuggestion);
}
if (!entry.takesValue) {
if (flag.inlineValue !== undefined) {
throw axiError(`Flag ${flag.name} does not take a value`, "VALIDATION_ERROR", helpSuggestion);
}
flags[flag.name] = true;
continue;
}
const consumed = consumeFlagValue(args, i, flag, helpSuggestion);
if (entry.repeatable) {
lists[flag.name]!.push(consumed.value);
} else {
flags[flag.name] = consumed.value;
}
i = consumed.lastIndex;
}
return { flags, lists, positionals };
}