| | | 1 | | /** |
| | | 2 | | * An option as registered with the CLI parser. |
| | | 3 | | */ |
| | | 4 | | export type KnownOption = { |
| | | 5 | | /** Flags string as declared, for example `--map <path>`. */ |
| | | 6 | | flags: string; |
| | | 7 | | /** Long name, for example `--map`. */ |
| | | 8 | | long?: string; |
| | | 9 | | /** Whether the option is listed in `--help`; only those are suggested. */ |
| | | 10 | | visible: boolean; |
| | | 11 | | }; |
| | | 12 | | |
| | 5 | 13 | | const GENERIC_ERROR = 'error: invalid command line. Run envilder --help.\n'; |
| | 5 | 14 | | const GENERIC_UNKNOWN_OPTION = 'error: unknown option. Run envilder --help.\n'; |
| | 5 | 15 | | const UNKNOWN_OPTION_PREFIX = "error: unknown option '"; |
| | 5 | 16 | | const SAFE_LONG_NAME = /^--[A-Za-z0-9][A-Za-z0-9-]*/; |
| | 5 | 17 | | const SAFE_SHORT_NAME = /^-[A-Za-z0-9]/; |
| | | 18 | | const TOO_MANY_ARGUMENTS = |
| | 5 | 19 | | /^error: too many arguments(?: for '[A-Za-z0-9-]+')?\. Expected (\d+) (arguments?) but got (\d+)\b/; |
| | 5 | 20 | | const MISSING_ARGUMENT = /^error: option '([^\n]*)' argument missing\s*$/; |
| | | 21 | | |
| | | 22 | | /** |
| | | 23 | | * Rebuilds a parse error from known-safe parts only (#554). |
| | | 24 | | * |
| | | 25 | | * The parser's messages quote what the user typed, and an option value, an |
| | | 26 | | * extra argument or even a mistyped option name can be a secret. Nothing |
| | | 27 | | * typed by the user is copied through unless it is provably harmless: |
| | | 28 | | * |
| | | 29 | | * - an unknown long option is named only when it is a near-typo of a visible |
| | | 30 | | * option of the command being parsed, with that option as the suggestion; |
| | | 31 | | * - an unknown short option is named by its single character; |
| | | 32 | | * - "too many arguments" keeps only the counts; |
| | | 33 | | * - "argument missing" keeps only a registered option's own flags. |
| | | 34 | | * |
| | | 35 | | * Anything else is replaced with a generic message. |
| | | 36 | | */ |
| | | 37 | | export function redactParseError( |
| | | 38 | | message: string, |
| | | 39 | | knownOptions: readonly KnownOption[], |
| | | 40 | | ): string { |
| | 21 | 41 | | if (message.startsWith(UNKNOWN_OPTION_PREFIX)) { |
| | 18 | 42 | | return rebuildUnknownOption( |
| | | 43 | | message.slice(UNKNOWN_OPTION_PREFIX.length), |
| | | 44 | | knownOptions, |
| | | 45 | | ); |
| | | 46 | | } |
| | | 47 | | |
| | 3 | 48 | | const tooMany = TOO_MANY_ARGUMENTS.exec(message); |
| | 3 | 49 | | if (tooMany) { |
| | 1 | 50 | | const [, expected, noun, received] = tooMany; |
| | 1 | 51 | | return `error: too many arguments. Expected ${expected} ${noun} but got ${received}.\n`; |
| | | 52 | | } |
| | | 53 | | |
| | 2 | 54 | | const missing = MISSING_ARGUMENT.exec(message); |
| | 9 | 55 | | if (missing && knownOptions.some((option) => option.flags === missing[1])) { |
| | 2 | 56 | | return missingArgumentMessage(missing[1]); |
| | | 57 | | } |
| | | 58 | | |
| | 0 | 59 | | return GENERIC_ERROR; |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | /** |
| | | 63 | | * The message for an option whose value is missing. Only ever built from a |
| | | 64 | | * registered option's flags. |
| | | 65 | | */ |
| | | 66 | | export function missingArgumentMessage(flags: string): string { |
| | 14 | 67 | | return `error: option '${flags}' argument missing\n`; |
| | | 68 | | } |
| | | 69 | | |
| | | 70 | | function rebuildUnknownOption( |
| | | 71 | | token: string, |
| | | 72 | | knownOptions: readonly KnownOption[], |
| | | 73 | | ): string { |
| | 18 | 74 | | if (!token.startsWith('--')) { |
| | 2 | 75 | | const short = SAFE_SHORT_NAME.exec(token)?.[0]; |
| | 2 | 76 | | return short === undefined |
| | | 77 | | ? GENERIC_UNKNOWN_OPTION |
| | | 78 | | : `error: unknown option '${short}'\n`; |
| | | 79 | | } |
| | | 80 | | |
| | 16 | 81 | | const name = SAFE_LONG_NAME.exec(token)?.[0]; |
| | 18 | 82 | | if (name === undefined) { |
| | 1 | 83 | | return GENERIC_UNKNOWN_OPTION; |
| | | 84 | | } |
| | 15 | 85 | | const candidates = knownOptions |
| | 183 | 86 | | .filter((option) => option.visible && option.long !== undefined) |
| | 147 | 87 | | .map((option) => option.long as string); |
| | 15 | 88 | | const similar = suggestSimilar(name, candidates); |
| | 15 | 89 | | if (similar.length === 0) { |
| | 10 | 90 | | return GENERIC_UNKNOWN_OPTION; |
| | | 91 | | } |
| | | 92 | | const suggestion = |
| | 5 | 93 | | similar.length === 1 ? similar[0] : `one of ${similar.join(', ')}`; |
| | 18 | 94 | | return `error: unknown option '${name}' (Did you mean ${suggestion}?)\n`; |
| | | 95 | | } |
| | | 96 | | |
| | 5 | 97 | | const MAX_DISTANCE = 3; |
| | 5 | 98 | | const MIN_SIMILARITY = 0.4; |
| | | 99 | | |
| | | 100 | | /** |
| | | 101 | | * Same matching as Commander's own suggestions (optimal string alignment |
| | | 102 | | * distance, at most 3 edits, more than 40% similar, closest matches only), |
| | | 103 | | * applied to the safe option name because Commander offers none when the |
| | | 104 | | * typed option carries a value. |
| | | 105 | | */ |
| | | 106 | | function suggestSimilar(flag: string, candidates: readonly string[]): string[] { |
| | 15 | 107 | | const word = flag.slice(2); |
| | 15 | 108 | | let best = MAX_DISTANCE; |
| | 15 | 109 | | let similar: string[] = []; |
| | 15 | 110 | | for (const candidate of new Set(candidates)) { |
| | 147 | 111 | | const name = candidate.slice(2); |
| | 147 | 112 | | if (name.length <= 1) { |
| | 0 | 113 | | continue; |
| | | 114 | | } |
| | 147 | 115 | | const distance = editDistance(word, name); |
| | 147 | 116 | | const length = Math.max(word.length, name.length); |
| | 147 | 117 | | if ((length - distance) / length <= MIN_SIMILARITY) { |
| | 142 | 118 | | continue; |
| | | 119 | | } |
| | 5 | 120 | | if (distance < best) { |
| | 5 | 121 | | best = distance; |
| | 5 | 122 | | similar = [candidate]; |
| | 0 | 123 | | } else if (distance === best) { |
| | 0 | 124 | | similar.push(candidate); |
| | | 125 | | } |
| | | 126 | | } |
| | 15 | 127 | | return similar.sort((a, b) => a.localeCompare(b)); |
| | | 128 | | } |
| | | 129 | | |
| | | 130 | | function editDistance(a: string, b: string): number { |
| | 147 | 131 | | if (Math.abs(a.length - b.length) > MAX_DISTANCE) { |
| | 72 | 132 | | return Math.max(a.length, b.length); |
| | | 133 | | } |
| | 75 | 134 | | const d: number[][] = []; |
| | 75 | 135 | | for (let i = 0; i <= a.length; i++) { |
| | 405 | 136 | | d[i] = [i]; |
| | | 137 | | } |
| | 75 | 138 | | for (let j = 0; j <= b.length; j++) { |
| | 463 | 139 | | d[0][j] = j; |
| | | 140 | | } |
| | 75 | 141 | | for (let j = 1; j <= b.length; j++) { |
| | 388 | 142 | | for (let i = 1; i <= a.length; i++) { |
| | 1961 | 143 | | const cost = a[i - 1] === b[j - 1] ? 0 : 1; |
| | 1961 | 144 | | d[i][j] = Math.min( |
| | | 145 | | d[i - 1][j] + 1, |
| | | 146 | | d[i][j - 1] + 1, |
| | | 147 | | d[i - 1][j - 1] + cost, |
| | | 148 | | ); |
| | 1961 | 149 | | if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { |
| | 9 | 150 | | d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1); |
| | | 151 | | } |
| | | 152 | | } |
| | | 153 | | } |
| | 75 | 154 | | return d[a.length][b.length]; |
| | | 155 | | } |