diff --git a/packages/rstack/src/cli/args.ts b/packages/rstack/src/cli/args.ts index a7b3eb0..27e3af9 100644 --- a/packages/rstack/src/cli/args.ts +++ b/packages/rstack/src/cli/args.ts @@ -1,4 +1,73 @@ -import { parseArgs } from 'node:util'; +import { + parseArgs as nodeParseArgs, + type ParseArgsConfig, + type ParseArgsOptionsConfig, +} from 'node:util'; + +type CamelCase = Value extends `${infer Head}-${infer Tail}` + ? `${Head}${Capitalize>}` + : Value; + +type NodeParseArgsResult = ReturnType>; + +type ParseArgsResult = Omit< + NodeParseArgsResult, + 'values' +> & { + values: { + [ + Name in keyof NodeParseArgsResult['values'] as CamelCase + ]: NodeParseArgsResult['values'][Name]; + }; +}; + +const KEBAB_CASE_REGEXP = /-([a-z])/g; + +const toCamelCase = (value: string): string => + value.includes('-') + ? value.replace(KEBAB_CASE_REGEXP, (_, character: string) => character.toUpperCase()) + : value; + +export function parseArgs( + config?: Config, +): ParseArgsResult { + const options: ParseArgsOptionsConfig = {}; + const optionNames: [originalName: string, camelName: string][] = []; + + for (const [originalName, descriptor] of Object.entries(config?.options ?? {})) { + const camelName = toCamelCase(originalName); + optionNames.push([originalName, camelName]); + options[originalName] = descriptor; + + if (camelName !== originalName) { + options[camelName] = descriptor; + } + } + + const parsed = nodeParseArgs({ + ...config, + options, + }); + const values: Record = {}; + + for (const [originalName, camelName] of optionNames) { + const originalValue = parsed.values[originalName]; + const camelValue = camelName === originalName ? undefined : parsed.values[camelName]; + const value = + Array.isArray(originalValue) && Array.isArray(camelValue) + ? [...originalValue, ...camelValue] + : (originalValue ?? camelValue); + + if (value !== undefined) { + values[camelName] = value; + } + } + + return { + ...parsed, + values, + } as unknown as ParseArgsResult; +} type ParsedRstackArgs = { args: string[]; diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 47e3735..ae951ba 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { parseArgs } from 'node:util'; import { color, logger } from 'rslog'; +import { parseArgs } from '../cli/args.ts'; import { loadRstackConfig } from '../config.ts'; import { resolveFmtConfig } from './config.ts'; import { discoverFmtFiles } from './discovery.ts'; @@ -36,11 +36,7 @@ ${color.cyan('Options')}: --stdin-filepath Format stdin as if it were saved at -h, --help Display this help message`; -const parseMaxWorkers = ( - kebabValue: string | undefined, - camelValue: string | undefined, -): number | undefined => { - const value = kebabValue ?? camelValue; +const parseMaxWorkers = (value: string | undefined): number | undefined => { if (value === undefined) { return undefined; } @@ -60,33 +56,31 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { write: { type: 'boolean' }, check: { type: 'boolean' }, 'list-different': { type: 'boolean' }, - listDifferent: { type: 'boolean' }, 'ignore-path': { type: 'string', multiple: true }, - ignorePath: { type: 'string', multiple: true }, 'no-error-on-unmatched-pattern': { type: 'boolean' }, - noErrorOnUnmatchedPattern: { type: 'boolean' }, 'parallel-workers': { type: 'string' }, - parallelWorkers: { type: 'string' }, 'stdin-filepath': { type: 'string' }, - stdinFilepath: { type: 'string' }, help: { type: 'boolean', short: 'h' }, }, allowPositionals: true, strict: true, }); - const listDifferent = values['list-different'] || values.listDifferent; - const modes = [values.write, values.check, listDifferent].filter(Boolean); + const write = values.write; + const check = values.check; + const listDifferent = values.listDifferent; + const modes = [write, check, listDifferent].filter(Boolean); if (modes.length > 1) { throw new Error('The --write, --check, and --list-different options cannot be used together.'); } - const mode = values.check ? 'check' : listDifferent ? 'list-different' : 'write'; - const ignorePaths = [...(values['ignore-path'] ?? []), ...(values.ignorePath ?? [])]; - const noErrorOnUnmatchedPattern = - values['no-error-on-unmatched-pattern'] ?? values.noErrorOnUnmatchedPattern ?? false; - const maxWorkers = parseMaxWorkers(values['parallel-workers'], values.parallelWorkers); - const stdinFilepath = values['stdin-filepath'] ?? values.stdinFilepath; + const mode = check ? 'check' : listDifferent ? 'list-different' : 'write'; + const ignorePaths = values.ignorePath ?? []; + const noErrorOnUnmatchedPattern = values.noErrorOnUnmatchedPattern ?? false; + const parallelWorkers = values.parallelWorkers; + const maxWorkers = parseMaxWorkers(parallelWorkers); + const help = values.help ?? false; + const stdinFilepath = values.stdinFilepath; if (stdinFilepath !== undefined) { if (modes.length > 0) { @@ -106,7 +100,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { ignorePaths, noErrorOnUnmatchedPattern, maxWorkers, - help: values.help ?? false, + help, stdinFilepath, }; }; diff --git a/packages/rstack/src/setup/index.ts b/packages/rstack/src/setup/index.ts index 0b35d06..a8a039a 100644 --- a/packages/rstack/src/setup/index.ts +++ b/packages/rstack/src/setup/index.ts @@ -1,5 +1,5 @@ -import { parseArgs } from 'node:util'; import { color, logger } from 'rslog'; +import { parseArgs } from '../cli/args.ts'; import { installHooks } from './install.ts'; const helpMessage = `Rstack v${RSTACK_VERSION} @@ -24,7 +24,7 @@ export const runSetupCLI = (args: string[]): void => { strict: true, }); - const hooksDirs = values['hooks-dir']; + const hooksDirs = values.hooksDir; if (hooksDirs && hooksDirs.length > 1) { throw new Error('The --hooks-dir option cannot be specified more than once.'); } diff --git a/packages/rstack/src/staged.ts b/packages/rstack/src/staged.ts index f08ade3..da46b72 100644 --- a/packages/rstack/src/staged.ts +++ b/packages/rstack/src/staged.ts @@ -1,6 +1,6 @@ -import { parseArgs } from 'node:util'; import lintStaged from 'lint-staged'; import { color } from 'rslog'; +import { parseArgs } from './cli/args.ts'; import { loadRstackConfig } from './config.ts'; export type StagedSyncTaskGenerator = (stagedFileNames: readonly string[]) => string | string[]; @@ -44,7 +44,6 @@ export async function runStagedCLI(args: string[]): Promise { args, options: { 'allow-empty': { type: 'boolean' }, - allowEmpty: { type: 'boolean' }, concurrent: { type: 'string', short: 'p' }, cwd: { type: 'string' }, debug: { type: 'boolean', short: 'd' }, @@ -72,14 +71,14 @@ export async function runStagedCLI(args: string[]): Promise { } const success = await lintStaged({ - allowEmpty: values['allow-empty'] ?? values.allowEmpty, + allowEmpty: values.allowEmpty, concurrent: values.concurrent === undefined ? undefined : JSON.parse(values.concurrent), config: stagedConfig, cwd: values.cwd, debug: values.debug, quiet: values.quiet, relative: values.relative, - stash: values['no-stash'] ? false : undefined, + stash: values.noStash ? false : undefined, verbose: values.verbose, }); if (!success) { diff --git a/packages/rstack/tests/cli/args.test.ts b/packages/rstack/tests/cli/args.test.ts new file mode 100644 index 0000000..2063dce --- /dev/null +++ b/packages/rstack/tests/cli/args.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'rstack/test'; +import { parseArgs } from '../../src/cli/args.ts'; + +test.each([ + ['--long-option', 'kebab'], + ['--longOption', 'camel'], +] as const)('accepts %s and returns only a camel-case value', (option, value) => { + const { values } = parseArgs({ + args: [option, value], + options: { + 'long-option': { type: 'string' }, + }, + }); + + expect(values).toEqual({ longOption: value }); + expect('long-option' in values).toBe(false); +}); + +test('combines repeated kebab-case and camel-case values', () => { + const { values } = parseArgs({ + args: ['--include-path', 'first', '--includePath', 'second'], + options: { + 'include-path': { type: 'string', multiple: true }, + }, + }); + + expect(values).toEqual({ includePath: ['first', 'second'] }); +}); + +test('omits undefined values', () => { + const { values } = parseArgs({ + args: [], + options: { + 'optional-value': { type: 'string' }, + }, + }); + + expect(values).toEqual({}); +});