|
| 1 | +/** |
| 2 | + * Argv preprocessor that moves global flags to the end of the argument list. |
| 3 | + * |
| 4 | + * Stricli only parses flags at the leaf command level, so flags like |
| 5 | + * `--verbose` placed before the subcommand (`sentry --verbose issue list`) |
| 6 | + * fail route resolution. This module relocates known global flags from any |
| 7 | + * position to the tail of argv where Stricli's leaf-command parser can |
| 8 | + * find them. |
| 9 | + * |
| 10 | + * Flag metadata is derived from the shared {@link GLOBAL_FLAGS} definition |
| 11 | + * in `global-flags.ts` so both the hoisting preprocessor and the |
| 12 | + * `buildCommand` injection stay in sync automatically. |
| 13 | + */ |
| 14 | + |
| 15 | +import { GLOBAL_FLAGS } from "./global-flags.js"; |
| 16 | + |
| 17 | +/** Resolved flag metadata used by the hoisting algorithm. */ |
| 18 | +type HoistableFlag = { |
| 19 | + /** Long flag name without `--` prefix (e.g., `"verbose"`) */ |
| 20 | + readonly name: string; |
| 21 | + /** Single-char short alias without `-` prefix, or `null` if none */ |
| 22 | + readonly short: string | null; |
| 23 | + /** Whether the flag consumes the next token as its value */ |
| 24 | + readonly takesValue: boolean; |
| 25 | + /** Whether `--no-<name>` is recognized as the negation form */ |
| 26 | + readonly negatable: boolean; |
| 27 | +}; |
| 28 | + |
| 29 | +/** Derive hoisting metadata from the shared flag definitions. */ |
| 30 | +const HOISTABLE_FLAGS: readonly HoistableFlag[] = GLOBAL_FLAGS.map((f) => ({ |
| 31 | + name: f.name, |
| 32 | + short: f.short, |
| 33 | + takesValue: f.kind === "value", |
| 34 | + negatable: f.kind === "boolean", |
| 35 | +})); |
| 36 | + |
| 37 | +/** Pre-built lookup: long name → flag definition */ |
| 38 | +const FLAG_BY_NAME = new Map(HOISTABLE_FLAGS.map((f) => [f.name, f])); |
| 39 | + |
| 40 | +/** Pre-built lookup: short alias → flag definition */ |
| 41 | +const FLAG_BY_SHORT = new Map( |
| 42 | + HOISTABLE_FLAGS.filter( |
| 43 | + (f): f is HoistableFlag & { short: string } => f.short !== null |
| 44 | + ).map((f) => [f.short, f]) |
| 45 | +); |
| 46 | + |
| 47 | +/** Names that support `--no-<name>` negation */ |
| 48 | +const NEGATABLE_NAMES = new Set( |
| 49 | + HOISTABLE_FLAGS.filter((f) => f.negatable).map((f) => f.name) |
| 50 | +); |
| 51 | + |
| 52 | +/** |
| 53 | + * Match result from {@link matchHoistable}. |
| 54 | + * |
| 55 | + * - `"plain"`: `--flag` (boolean) or `--flag` (value-taking, value is next token) |
| 56 | + * - `"eq"`: `--flag=value` (value embedded in token) |
| 57 | + * - `"negated"`: `--no-flag` |
| 58 | + * - `"short"`: `-v` (single-char alias) |
| 59 | + */ |
| 60 | +type MatchForm = "plain" | "eq" | "negated" | "short"; |
| 61 | + |
| 62 | +/** Try matching a `--no-<name>` negation form. */ |
| 63 | +function matchNegated( |
| 64 | + name: string |
| 65 | +): { flag: HoistableFlag; form: MatchForm } | null { |
| 66 | + if (!name.startsWith("no-")) { |
| 67 | + return null; |
| 68 | + } |
| 69 | + const baseName = name.slice(3); |
| 70 | + if (!NEGATABLE_NAMES.has(baseName)) { |
| 71 | + return null; |
| 72 | + } |
| 73 | + const flag = FLAG_BY_NAME.get(baseName); |
| 74 | + return flag ? { flag, form: "negated" } : null; |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Match a token against the hoistable flag registry. |
| 79 | + * |
| 80 | + * @returns The matched flag and form, or `null` if not hoistable. |
| 81 | + */ |
| 82 | +function matchHoistable( |
| 83 | + token: string |
| 84 | +): { flag: HoistableFlag; form: MatchForm } | null { |
| 85 | + // Short alias: -v (exactly two chars, dash + letter) |
| 86 | + if (token.length === 2 && token[0] === "-" && token[1] !== "-") { |
| 87 | + const flag = FLAG_BY_SHORT.get(token[1] ?? ""); |
| 88 | + return flag ? { flag, form: "short" } : null; |
| 89 | + } |
| 90 | + |
| 91 | + if (!token.startsWith("--")) { |
| 92 | + return null; |
| 93 | + } |
| 94 | + |
| 95 | + // --flag=value form |
| 96 | + const eqIdx = token.indexOf("="); |
| 97 | + if (eqIdx !== -1) { |
| 98 | + const name = token.slice(2, eqIdx); |
| 99 | + const flag = FLAG_BY_NAME.get(name); |
| 100 | + return flag?.takesValue ? { flag, form: "eq" } : null; |
| 101 | + } |
| 102 | + |
| 103 | + const name = token.slice(2); |
| 104 | + const negated = matchNegated(name); |
| 105 | + if (negated) { |
| 106 | + return negated; |
| 107 | + } |
| 108 | + const flag = FLAG_BY_NAME.get(name); |
| 109 | + return flag ? { flag, form: "plain" } : null; |
| 110 | +} |
| 111 | + |
| 112 | +/** |
| 113 | + * Hoist a single matched flag token (and its value if applicable) into the |
| 114 | + * `hoisted` array, advancing the index past the consumed tokens. |
| 115 | + * |
| 116 | + * Extracted from the main loop to keep {@link hoistGlobalFlags} under |
| 117 | + * Biome's cognitive complexity limit. |
| 118 | + */ |
| 119 | +function consumeFlag( |
| 120 | + argv: readonly string[], |
| 121 | + index: number, |
| 122 | + match: { flag: HoistableFlag; form: MatchForm }, |
| 123 | + hoisted: string[] |
| 124 | +): number { |
| 125 | + const token = argv[index] ?? ""; |
| 126 | + |
| 127 | + if ( |
| 128 | + match.form === "eq" || |
| 129 | + match.form === "negated" || |
| 130 | + match.form === "short" |
| 131 | + ) { |
| 132 | + // Single token: --flag=value, --no-flag, or -v |
| 133 | + hoisted.push(token); |
| 134 | + return index + 1; |
| 135 | + } |
| 136 | + |
| 137 | + if (match.flag.takesValue) { |
| 138 | + // --flag value: consume two tokens |
| 139 | + hoisted.push(token); |
| 140 | + const next = index + 1; |
| 141 | + if (next < argv.length) { |
| 142 | + hoisted.push(argv[next] ?? ""); |
| 143 | + return next + 1; |
| 144 | + } |
| 145 | + // No value follows — the bare --flag is still hoisted; |
| 146 | + // Stricli will report the missing value at parse time. |
| 147 | + return next; |
| 148 | + } |
| 149 | + |
| 150 | + // Boolean flag: --flag |
| 151 | + hoisted.push(token); |
| 152 | + return index + 1; |
| 153 | +} |
| 154 | + |
| 155 | +/** |
| 156 | + * Move global flags from any position in argv to the end. |
| 157 | + * |
| 158 | + * Tokens after `--` are never touched. The relative order of both |
| 159 | + * hoisted and non-hoisted tokens is preserved. |
| 160 | + * |
| 161 | + * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) |
| 162 | + * @returns New array with global flags relocated to the tail |
| 163 | + */ |
| 164 | +export function hoistGlobalFlags(argv: readonly string[]): string[] { |
| 165 | + const remaining: string[] = []; |
| 166 | + const hoisted: string[] = []; |
| 167 | + |
| 168 | + let i = 0; |
| 169 | + while (i < argv.length) { |
| 170 | + const token = argv[i] ?? ""; |
| 171 | + |
| 172 | + // Stop scanning at -- separator; pass everything through verbatim |
| 173 | + if (token === "--") { |
| 174 | + for (let j = i; j < argv.length; j += 1) { |
| 175 | + remaining.push(argv[j] ?? ""); |
| 176 | + } |
| 177 | + break; |
| 178 | + } |
| 179 | + |
| 180 | + const match = matchHoistable(token); |
| 181 | + if (match) { |
| 182 | + i = consumeFlag(argv, i, match, hoisted); |
| 183 | + } else { |
| 184 | + remaining.push(token); |
| 185 | + i += 1; |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + return [...remaining, ...hoisted]; |
| 190 | +} |
0 commit comments