From ecc9d1ad93d63b4df200f57b48753d57f9051d9d Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 6 Jan 2026 22:00:57 -0800 Subject: [PATCH 1/2] feat: support multi-character aliases - Register multi-char aliases as separate options with parseArgs() - Collapse alias values into canonical names after parsing - Detect conflicts when both alias and canonical provided for non-array options - Merge values for array options when both alias and canonical provided - Validate aliases don't conflict with canonical option names - Validate aliases don't conflict with auto-generated boolean negations - Display multi-character aliases in help output - Update README.md documentation with aliases section --- README.md | 59 ++++++++++++-- src/help.ts | 31 ++++++-- src/opt.ts | 29 +++++++ src/parser.ts | 90 +++++++++++++++++++++- src/types.ts | 13 +++- src/validate.ts | 47 +++++++++++- test/help.test.ts | 54 +++++++++++++ test/opt.test.ts | 37 +++++++++ test/parser.test.ts | 175 ++++++++++++++++++++++++++++++++++++++++++ test/validate.test.ts | 94 ++++++++++++++++++++++- 10 files changed, 604 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ea0d6c1..3cac601 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,7 @@ import { opt } from '@boneskull/bargs'; opt.string({ default: 'value' }); // --name value opt.number({ default: 42 }); // --count 42 opt.boolean({ aliases: ['v'] }); // --verbose, -v +opt.boolean({ aliases: ['v', 'verb'] }); // --verbose, --verb, -v opt.enum(['a', 'b', 'c']); // --level a opt.array('string'); // --file x --file y opt.array(['low', 'medium', 'high']); // --priority low --priority high @@ -346,14 +347,56 @@ opt.count(); // -vvv → 3 ### Option Properties -| Property | Type | Description | -| ------------- | ---------- | ------------------------------------------------ | -| `aliases` | `string[]` | Short flags (e.g., `['v']` for `-v`) | -| `default` | varies | Default value (makes the option non-nullable) | -| `description` | `string` | Help text description | -| `group` | `string` | Groups options under a custom section header | -| `hidden` | `boolean` | Hide from `--help` output | -| `required` | `boolean` | Mark as required (makes the option non-nullable) | +| Property | Type | Description | +| ------------- | ---------- | ------------------------------------------------------------------ | +| `aliases` | `string[]` | Short (`['v']` for `-v`) or long aliases (`['verb']` for `--verb`) | +| `default` | varies | Default value (makes the option non-nullable) | +| `description` | `string` | Help text description | +| `group` | `string` | Groups options under a custom section header | +| `hidden` | `boolean` | Hide from `--help` output | +| `required` | `boolean` | Mark as required (makes the option non-nullable) | + +### Aliases + +Options can have both short (single-character) and long (multi-character) aliases: + +```typescript +opt.options({ + verbose: opt.boolean({ aliases: ['v', 'verb'] }), + output: opt.string({ aliases: ['o', 'out'] }), +}); +``` + +All of these are equivalent: + +```shell +$ my-cli -v # verbose: true +$ my-cli --verb # verbose: true +$ my-cli --verbose # verbose: true +$ my-cli -o file.txt # output: "file.txt" +$ my-cli --out file.txt # output: "file.txt" +$ my-cli --output file.txt # output: "file.txt" +``` + +For non-array options, using both an alias and the canonical name throws an error: + +```shell +$ my-cli --verb --verbose +Error: Conflicting options: --verb and --verbose cannot both be specified +``` + +For array options, values from all aliases are merged: + +```typescript +opt.options({ + files: opt.array('string', { aliases: ['f', 'file'] }), +}); +``` + +```shell +$ my-cli --file a.txt -f b.txt --files c.txt +# files: ["a.txt", "b.txt", "c.txt"] +``` ### Boolean Negation (`--no-`) diff --git a/src/help.ts b/src/help.ts index e38179d..dfa709d 100644 --- a/src/help.ts +++ b/src/help.ts @@ -188,6 +188,9 @@ const getTypeLabel = (def: OptionDef): string => { * For boolean options with `default: true`, shows `--no-` instead of * `--` since that's how users would turn it off. * + * Displays aliases in order: short alias first (-v), then multi-char aliases + * sorted by length (--verb), then the canonical name (--verbose). + * * @function */ const formatOptionHelp = ( @@ -202,17 +205,33 @@ const formatOptionHelp = ( const displayName = def.type === 'boolean' && def.default === true ? `no-${name}` : name; - // Build flag string: -v, --verbose (or --no-verbose for default:true booleans) + // Separate short and long aliases const shortAlias = def.aliases?.find((a) => a.length === 1); + const longAliases = (def.aliases ?? []) + .filter((a) => a.length > 1) + .sort((a, b) => a.length - b.length); + + // Build flag string: -v, --verb, --verbose // Don't show short alias for negated booleans + const flagParts: string[] = []; + if (shortAlias && displayName === name) { + flagParts.push(`-${shortAlias}`); + } + for (const alias of longAliases) { + flagParts.push(`--${alias}`); + } + flagParts.push(`--${displayName}`); + + // If no short alias and no long aliases, add padding const flagText = - shortAlias && displayName === name - ? `-${shortAlias}, --${displayName}` - : ` --${displayName}`; + flagParts.length === 1 && !shortAlias + ? ` ${flagParts[0]}` + : flagParts.join(', '); parts.push(` ${styler.flag(flagText)}`); - // Pad to align descriptions - const padding = Math.max(0, 24 - flagText.length - 2); + // Pad to align descriptions (increase base padding for longer alias chains) + const basePadding = Math.max(24, flagText.length + 4); + const padding = Math.max(0, basePadding - flagText.length - 2); parts.push(' '.repeat(padding)); // Description diff --git a/src/opt.ts b/src/opt.ts index 5b76be8..1c48479 100644 --- a/src/opt.ts +++ b/src/opt.ts @@ -34,10 +34,26 @@ import { BargsError } from './errors.js'; /** * Validate that no alias conflicts exist in a merged options schema. * + * Checks for: + * + * - Duplicate aliases across options + * - Aliases that conflict with canonical option names + * - Aliases that conflict with auto-generated boolean negation names + * (--no-) + * * @function */ const validateAliasConflicts = (schema: OptionsSchema): void => { const aliasToOption = new Map(); + const canonicalNames = new Set(Object.keys(schema)); + + // Collect auto-generated boolean negation names (--no-) + const booleanNegations = new Set(); + for (const [name, def] of Object.entries(schema)) { + if (def.type === 'boolean') { + booleanNegations.add(`no-${name}`); + } + } for (const [optionName, def] of Object.entries(schema)) { if (!def.aliases) { @@ -45,12 +61,25 @@ const validateAliasConflicts = (schema: OptionsSchema): void => { } for (const alias of def.aliases) { + // Check for duplicate aliases const existing = aliasToOption.get(alias); if (existing && existing !== optionName) { throw new BargsError( `Alias conflict: "-${alias}" is used by both "--${existing}" and "--${optionName}"`, ); } + // Check for conflicts with canonical option names + if (canonicalNames.has(alias)) { + throw new BargsError( + `Alias conflict: "--${alias}" conflicts with an existing option name`, + ); + } + // Check for conflicts with auto-generated boolean negations + if (booleanNegations.has(alias)) { + throw new BargsError( + `Alias conflict: "--${alias}" conflicts with auto-generated boolean negation "--${alias}"`, + ); + } aliasToOption.set(alias, optionName); } } diff --git a/src/parser.ts b/src/parser.ts index be933a4..2227bdf 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -28,6 +28,10 @@ import { HelpError } from './errors.js'; * For boolean options, also adds `no-` variants to support explicit * negation (e.g., `--no-verbose` sets `verbose` to `false`). * + * Multi-character aliases are registered as separate options with the same type + * and multiple settings, then collapsed back to canonical names after parsing + * via `collapseAliases()`. + * * @function */ const buildParseArgsConfig = ( @@ -42,12 +46,16 @@ const buildParseArgsConfig = ( > = {}; for (const [name, def] of Object.entries(schema)) { + const parseArgsType: 'boolean' | 'string' = + def.type === 'boolean' ? 'boolean' : 'string'; + const isMultiple = def.type === 'array'; + const opt: { multiple?: boolean; short?: string; type: 'boolean' | 'string'; } = { - type: def.type === 'boolean' ? 'boolean' : 'string', + type: parseArgsType, }; // First single-char alias becomes short option @@ -57,13 +65,28 @@ const buildParseArgsConfig = ( } // Arrays need multiple: true - if (def.type === 'array') { + if (isMultiple) { opt.multiple = true; } config[name] = opt; + // Register multi-character aliases as separate options + for (const alias of def.aliases ?? []) { + if (alias.length > 1) { + const aliasOpt: { + multiple?: boolean; + type: 'boolean' | 'string'; + } = { type: parseArgsType }; + if (isMultiple) { + aliasOpt.multiple = true; + } + config[alias] = aliasOpt; + } + } + // For boolean options, add negated form (--no-) + // Note: We do NOT add --no- forms for aliases if (def.type === 'boolean') { config[`no-${name}`] = { type: 'boolean' }; } @@ -240,6 +263,64 @@ const processNegatedBooleans = ( return result; }; +/** + * Collapse multi-character aliases into their canonical option names. + * + * For array options, merges values from all aliases into the canonical name. + * For non-array options, throws HelpError if both alias and canonical were + * provided. Always removes alias keys from the result. + * + * @function + */ +const collapseAliases = ( + values: Record, + schema: OptionsSchema, +): Record => { + const result = { ...values }; + + // Build alias-to-canonical mapping (only multi-char aliases) + const aliasToCanonical = new Map(); + for (const [name, def] of Object.entries(schema)) { + for (const alias of def.aliases ?? []) { + if (alias.length > 1) { + aliasToCanonical.set(alias, name); + } + } + } + + // Process each alias found in the values + for (const [alias, canonical] of aliasToCanonical) { + const aliasValue = result[alias]; + if (aliasValue === undefined) { + continue; + } + + const def = schema[canonical]!; + const canonicalValue = result[canonical]; + const isArray = def.type === 'array'; + + if (isArray) { + // For arrays, merge values + const existingArray = Array.isArray(canonicalValue) ? canonicalValue : []; + const aliasArray = Array.isArray(aliasValue) ? aliasValue : [aliasValue]; + result[canonical] = [...existingArray, ...aliasArray]; + } else { + // For non-arrays, check for conflict + if (canonicalValue !== undefined) { + throw new HelpError( + `Conflicting options: --${alias} and --${canonical} cannot both be specified`, + ); + } + result[canonical] = aliasValue; + } + + // Remove the alias key + delete result[alias]; + } + + return result; +}; + /** * Options for parseSimple. */ @@ -286,8 +367,11 @@ export const parseSimple = < optionsSchema, ); + // Collapse multi-character aliases into canonical names + const collapsedValues = collapseAliases(processedValues, optionsSchema); + // Coerce and apply defaults - const coercedValues = coerceValues(processedValues, optionsSchema); + const coercedValues = coerceValues(collapsedValues, optionsSchema); const coercedPositionals = coercePositionals(positionals, positionalsSchema); return { diff --git a/src/types.ts b/src/types.ts index 24a9b84..8b9d266 100644 --- a/src/types.ts +++ b/src/types.ts @@ -570,7 +570,18 @@ export interface VariadicPositional extends PositionalBase { * Base properties shared by all option definitions. */ interface OptionBase { - /** Aliases for this option (e.g., ['v'] for --verbose) */ + /** + * Short or long aliases for this option. + * + * - Single-character aliases (e.g., `'v'`) become short flags (`-v`) + * - Multi-character aliases (e.g., `'verb'`) become long flags (`--verb`) + * + * @example + * + * ```typescript + * opt.boolean({ aliases: ['v', 'verb'] }); // -v, --verb, --verbose + * ``` + */ aliases?: string[]; /** Option description displayed in help text */ description?: string; diff --git a/src/validate.ts b/src/validate.ts index 684926f..4d7ebc8 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -64,6 +64,8 @@ const validateOption = ( opt: unknown, path: string, allAliases: Map, + allCanonicalNames: Set, + booleanNegationNames: Set, ): void => { if (!isObject(opt)) { throw new ValidationError(path, 'option must be an object'); @@ -116,13 +118,28 @@ const validateOption = ( } for (let i = 0; i < opt['aliases'].length; i++) { const alias = opt['aliases'][i]!; - if (alias.length !== 1) { + // Reject empty aliases + if (alias.length === 0) { throw new ValidationError( `${path}.aliases[${i}]`, - `alias must be a single character, got "${alias}"`, + 'alias cannot be an empty string', ); } - // Check for duplicates + // Check if alias conflicts with a canonical option name + if (allCanonicalNames.has(alias)) { + throw new ValidationError( + `${path}.aliases[${i}]`, + `alias "${alias}" conflicts with an existing option name`, + ); + } + // Check if alias conflicts with auto-generated boolean negation (--no-) + if (booleanNegationNames.has(alias)) { + throw new ValidationError( + `${path}.aliases[${i}]`, + `alias "${alias}" conflicts with an auto-generated boolean negation`, + ); + } + // Check for duplicate aliases across options const existingOption = allAliases.get(alias); if (existingOption !== undefined) { throw new ValidationError( @@ -263,6 +280,7 @@ export const validateOptionsSchema = ( schema: unknown, path: string = 'options', allAliases: Map = new Map(), + allCanonicalNames: Set = new Set(), ): void => { if (schema === undefined) { return; // optional @@ -272,8 +290,29 @@ export const validateOptionsSchema = ( throw new ValidationError(path, 'must be an object'); } + // Collect all canonical option names first (for alias conflict detection) + const canonicalNames = new Set([ + ...allCanonicalNames, + ...Object.keys(schema), + ]); + + // Collect auto-generated boolean negation names (--no-) + const booleanNegations = new Set(); for (const [name, opt] of Object.entries(schema)) { - validateOption(name, opt, `${path}.${name}`, allAliases); + if (isObject(opt) && opt['type'] === 'boolean') { + booleanNegations.add(`no-${name}`); + } + } + + for (const [name, opt] of Object.entries(schema)) { + validateOption( + name, + opt, + `${path}.${name}`, + allAliases, + canonicalNames, + booleanNegations, + ); } }; diff --git a/test/help.test.ts b/test/help.test.ts index 12db907..3287ab3 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -54,6 +54,60 @@ describe('generateHelp', () => { expect(help, 'to contain', '[boolean]'); }); + it('shows multi-character aliases in help', () => { + const help = stripAnsi( + generateHelp({ + name: 'my-cli', + options: { + verbose: opt.boolean({ + aliases: ['v', 'verb'], + description: 'Enable verbose output', + }), + }, + }), + ); + + // Should show short alias, multi-char alias, and canonical name + expect(help, 'to contain', '-v'); + expect(help, 'to contain', '--verb'); + expect(help, 'to contain', '--verbose'); + }); + + it('shows multiple multi-character aliases sorted by length', () => { + const help = stripAnsi( + generateHelp({ + name: 'my-cli', + options: { + output: opt.string({ + aliases: ['o', 'out', 'outfile'], + description: 'Output file', + }), + }, + }), + ); + + // Aliases sorted by length: -o, --out, --outfile, --output + expect(help, 'to match', /-o.*--out.*--outfile.*--output/); + }); + + it('shows multi-char alias without short alias', () => { + const help = stripAnsi( + generateHelp({ + name: 'my-cli', + options: { + verbose: opt.boolean({ + aliases: ['verb'], + description: 'Enable verbose output', + }), + }, + }), + ); + + expect(help, 'to contain', '--verb'); + expect(help, 'to contain', '--verbose'); + expect(help, 'not to match', /-v\b/); // No short -v alias + }); + it('shows enum choices', () => { const help = stripAnsi( generateHelp({ diff --git a/test/opt.test.ts b/test/opt.test.ts index 5e69fc0..c9b8b6b 100644 --- a/test/opt.test.ts +++ b/test/opt.test.ts @@ -31,6 +31,43 @@ describe('opt.options', () => { /Alias conflict.*-v.*--verbose.*--version/, ); }); + + it('throws when alias conflicts with canonical option name', () => { + expect( + () => + opt.options({ + debug: opt.boolean(), + verbose: opt.boolean({ aliases: ['debug'] }), + }), + 'to throw', + /Alias conflict.*--debug.*conflicts with an existing option name/, + ); + }); + + it('throws when alias conflicts with boolean negation', () => { + // The alias "no-debug" would conflict with auto-generated --no-debug + expect( + () => + opt.options({ + debug: opt.boolean(), + verbose: opt.boolean({ aliases: ['no-debug'] }), + }), + 'to throw', + /Alias conflict.*--no-debug.*conflicts with auto-generated boolean negation/, + ); + }); + + it('throws when alias conflicts with own boolean negation', () => { + // A boolean option's alias "no-op" would conflict with its own --no-op + expect( + () => + opt.options({ + op: opt.boolean({ aliases: ['no-op'] }), + }), + 'to throw', + /Alias conflict.*--no-op.*conflicts with auto-generated boolean negation/, + ); + }); }); describe('pos.positionals', () => { diff --git a/test/parser.test.ts b/test/parser.test.ts index f699ed5..98424b5 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -183,6 +183,181 @@ describe('parseSimple', () => { expect(result.values, 'to deeply equal', { verbose: true }); }); + describe('multi-character aliases', () => { + it('parses multi-char alias to canonical name', () => { + const result = parseSimple({ + args: ['--verb'], + options: { + verbose: opt.boolean({ aliases: ['verb'] }), + }, + }); + + expect(result.values, 'to deeply equal', { verbose: true }); + }); + + it('parses multi-char string alias', () => { + const result = parseSimple({ + args: ['--out', '/tmp/output'], + options: { + output: opt.string({ aliases: ['out'] }), + }, + }); + + expect(result.values, 'to deeply equal', { output: '/tmp/output' }); + }); + + it('short and multi-char aliases both work', () => { + // Test short alias + const result1 = parseSimple({ + args: ['-v'], + options: { + verbose: opt.boolean({ aliases: ['v', 'verb'] }), + }, + }); + expect(result1.values, 'to deeply equal', { verbose: true }); + + // Test multi-char alias + const result2 = parseSimple({ + args: ['--verb'], + options: { + verbose: opt.boolean({ aliases: ['v', 'verb'] }), + }, + }); + expect(result2.values, 'to deeply equal', { verbose: true }); + + // Test canonical name + const result3 = parseSimple({ + args: ['--verbose'], + options: { + verbose: opt.boolean({ aliases: ['v', 'verb'] }), + }, + }); + expect(result3.values, 'to deeply equal', { verbose: true }); + }); + + it('throws HelpError when both alias and canonical provided for non-array', () => { + expect( + () => + parseSimple({ + args: ['--verb', '--verbose'], + options: { + verbose: opt.boolean({ aliases: ['verb'] }), + }, + }), + 'to throw a', + HelpError, + ); + }); + + it('throws HelpError for string option with alias and canonical', () => { + expect( + () => + parseSimple({ + args: ['--out', 'a', '--output', 'b'], + options: { + output: opt.string({ aliases: ['out'] }), + }, + }), + 'to throw a', + HelpError, + ); + }); + + it('error message mentions conflicting options', () => { + expect( + () => + parseSimple({ + args: ['--verb', '--verbose'], + options: { + verbose: opt.boolean({ aliases: ['verb'] }), + }, + }), + 'to throw', + /Conflicting options.*--verb.*--verbose/, + ); + }); + + it('merges array values from alias and canonical', () => { + const result = parseSimple({ + args: ['--file', 'a.txt', '-f', 'b.txt', '--files', 'c.txt'], + options: { + files: opt.array('string', { aliases: ['f', 'file'] }), + }, + }); + + // Short alias (-f) and canonical (--files) go to canonical first, + // then multi-char alias (--file) is appended during collapse + expect(result.values.files, 'to deeply equal', [ + 'b.txt', + 'c.txt', + 'a.txt', + ]); + }); + + it('merges number array values from aliases', () => { + const result = parseSimple({ + args: ['--port', '80', '-p', '443', '--ports', '8080'], + options: { + ports: opt.array('number', { aliases: ['p', 'port'] }), + }, + }); + + // Short alias (-p) and canonical (--ports) go to canonical first, + // then multi-char alias (--port) is appended during collapse + expect(result.values.ports, 'to deeply equal', [443, 8080, 80]); + }); + + it('boolean multi-char alias works correctly', () => { + const result = parseSimple({ + args: ['--verb'], + options: { + verbose: opt.boolean({ aliases: ['verb'], default: false }), + }, + }); + + expect(result.values, 'to deeply equal', { verbose: true }); + }); + + it('--no-verbose does NOT create --no-verb negation for aliases', () => { + // Trying to use --no-verb (which is not registered) should throw + expect( + () => + parseSimple({ + args: ['--no-verb'], + options: { + verbose: opt.boolean({ aliases: ['verb'] }), + }, + }), + 'to throw', + /unknown.*verb|no-verb/i, + ); + }); + + it('alias keys never appear in result values', () => { + const result = parseSimple({ + args: ['--verb'], + options: { + verbose: opt.boolean({ aliases: ['v', 'verb'] }), + }, + }); + + expect(Object.keys(result.values), 'not to contain', 'verb'); + expect(Object.keys(result.values), 'not to contain', 'v'); + expect(result.values, 'to have keys', ['verbose']); + }); + + it('works with multiple multi-char aliases', () => { + const result = parseSimple({ + args: ['--verb'], + options: { + verbose: opt.boolean({ aliases: ['v', 'verb', 'vb'] }), + }, + }); + + expect(result.values, 'to deeply equal', { verbose: true }); + }); + }); + it('returns undefined for options without defaults', () => { const result = parseSimple({ args: [], diff --git a/test/validate.test.ts b/test/validate.test.ts index f30f146..95b5dcb 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -311,7 +311,7 @@ describe('validateOptionsSchema', () => { ); }); - it('validates aliases are single characters', () => { + it('allows single-character aliases', () => { expect( () => validateOptionsSchema({ @@ -319,22 +319,99 @@ describe('validateOptionsSchema', () => { }), 'not to throw', ); + }); + + it('allows multi-character aliases', () => { + expect( + () => + validateOptionsSchema({ + verbose: opt.boolean({ aliases: ['verb'] }), + }), + 'not to throw', + ); + + // Multiple aliases including both short and long + expect( + () => + validateOptionsSchema({ + verbose: opt.boolean({ aliases: ['v', 'verb', 'vb'] }), + }), + 'not to throw', + ); + }); + it('rejects empty string aliases', () => { expect( () => validateOptionsSchema({ - verbose: { aliases: ['verbose'], type: 'boolean' }, + verbose: { aliases: [''], type: 'boolean' }, }), 'to throw a', Error, 'satisfying', { - message: /must be a single character/, + message: /alias cannot be an empty string/, path: 'options.verbose.aliases[0]', }, ); }); + it('rejects alias that conflicts with canonical option name', () => { + expect( + () => + validateOptionsSchema({ + debug: opt.boolean(), + verbose: opt.boolean({ aliases: ['debug'] }), + }), + 'to throw a', + Error, + 'satisfying', + { + message: /alias "debug" conflicts with an existing option name/, + path: 'options.verbose.aliases[0]', + }, + ); + }); + + it('rejects alias that conflicts with auto-generated boolean negation', () => { + // An alias "no-debug" would conflict with the auto-generated --no-debug + // negation for boolean option "debug" + expect( + () => + validateOptionsSchema({ + debug: opt.boolean(), + verbose: opt.boolean({ aliases: ['no-debug'] }), + }), + 'to throw a', + Error, + 'satisfying', + { + message: + /alias "no-debug" conflicts with an auto-generated boolean negation/, + path: 'options.verbose.aliases[0]', + }, + ); + }); + + it('rejects alias that conflicts with own boolean negation', () => { + // A boolean option's alias "no-op" would conflict with its own + // auto-generated --no-op negation + expect( + () => + validateOptionsSchema({ + op: opt.boolean({ aliases: ['no-op'] }), + }), + 'to throw a', + Error, + 'satisfying', + { + message: + /alias "no-op" conflicts with an auto-generated boolean negation/, + path: 'options.op.aliases[0]', + }, + ); + }); + it('detects duplicate aliases across options', () => { expect( () => @@ -346,6 +423,17 @@ describe('validateOptionsSchema', () => { // Second option to be validated will fail { message: /alias "v" is already used/ }, ); + + // Also test with multi-char aliases + expect( + () => + validateOptionsSchema({ + debug: { aliases: ['verb'], type: 'boolean' }, + verbose: { aliases: ['verb'], type: 'boolean' }, + }), + 'to throw error satisfying', + { message: /alias "verb" is already used/ }, + ); }); it('validates option description is a string', () => { From d99110bc3a34521b524219681c55dff0f1b0ec22 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 7 Jan 2026 11:36:15 -0800 Subject: [PATCH 2/2] fix: address review comments on multi-char aliases PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix README example to show actual array merge order (canonical/short first, multi-char aliases appended) instead of command-line order - Improve error message for alias-negation conflicts to show which boolean option generates the conflicting negation - Update test comments to clarify merge order is documented behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 5 +++-- src/opt.ts | 4 +++- test/parser.test.ts | 10 ++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3cac601..e5390f2 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ $ my-cli --verb --verbose Error: Conflicting options: --verb and --verbose cannot both be specified ``` -For array options, values from all aliases are merged: +For array options, values from all aliases are merged. Single-character aliases and the canonical name are processed first (in command-line order), then multi-character aliases are appended: ```typescript opt.options({ @@ -395,7 +395,8 @@ opt.options({ ```shell $ my-cli --file a.txt -f b.txt --files c.txt -# files: ["a.txt", "b.txt", "c.txt"] +# files: ["b.txt", "c.txt", "a.txt"] +# (-f and --files first, then --file appended) ``` ### Boolean Negation (`--no-`) diff --git a/src/opt.ts b/src/opt.ts index 1c48479..b27bbbd 100644 --- a/src/opt.ts +++ b/src/opt.ts @@ -76,8 +76,10 @@ const validateAliasConflicts = (schema: OptionsSchema): void => { } // Check for conflicts with auto-generated boolean negations if (booleanNegations.has(alias)) { + // alias is "no-", so extract the original option name + const originalOption = alias.replace(/^no-/, ''); throw new BargsError( - `Alias conflict: "--${alias}" conflicts with auto-generated boolean negation "--${alias}"`, + `Alias conflict: "--${alias}" conflicts with auto-generated boolean negation for "--${originalOption}"`, ); } aliasToOption.set(alias, optionName); diff --git a/test/parser.test.ts b/test/parser.test.ts index 98424b5..2c4e24a 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -285,8 +285,9 @@ describe('parseSimple', () => { }, }); - // Short alias (-f) and canonical (--files) go to canonical first, - // then multi-char alias (--file) is appended during collapse + // Documented merge order: short aliases and canonical are processed first + // (in command-line order), then multi-char aliases are appended. + // Here: -f (b.txt) and --files (c.txt) first, then --file (a.txt) expect(result.values.files, 'to deeply equal', [ 'b.txt', 'c.txt', @@ -302,8 +303,9 @@ describe('parseSimple', () => { }, }); - // Short alias (-p) and canonical (--ports) go to canonical first, - // then multi-char alias (--port) is appended during collapse + // Documented merge order: short aliases and canonical are processed first + // (in command-line order), then multi-char aliases are appended. + // Here: -p (443) and --ports (8080) first, then --port (80) expect(result.values.ports, 'to deeply equal', [443, 8080, 80]); });