diff --git a/.gitignore b/.gitignore index 547aa64..a6d40a9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules build yarn.lock test-d/build.ts +.tsimp diff --git a/package.json b/package.json index 8c21a8d..947a671 100644 --- a/package.json +++ b/package.json @@ -63,10 +63,12 @@ "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", + "@sindresorhus/is": "^6.2.0", "@sindresorhus/tsconfig": "^5.0.0", "@types/common-tags": "^1.8.4", "@types/minimist": "^1.2.5", "@types/node": "18", + "@types/stack-utils": "^2.0.3", "@types/yargs-parser": "^21.0.3", "ava": "^6.1.2", "camelcase-keys": "^9.1.3", diff --git a/rollup.config.js b/rollup.config.js index 68f4201..1caf60d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -79,9 +79,10 @@ const dtsConfig = defineConfig({ name: 'copy-tsd', async generateBundle() { let tsdFile = await fs.readFile('./test-d/index.ts', 'utf8'); + tsdFile = tsdFile.replace( - `import meow from '../${sourceDirectory}/index.js'`, - `import meow from '../${outputDirectory}/index.js'`, + `../${sourceDirectory}/index.js`, + `../${outputDirectory}/index.js`, ); await fs.writeFile(`./test-d/${outputDirectory}.ts`, tsdFile); diff --git a/source/index.ts b/source/index.ts index cc1be39..9bcd825 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,14 +1,20 @@ import process from 'node:process'; -import parseArguments from 'yargs-parser'; +import parseArguments, {type Options as ParserOptions} from 'yargs-parser'; import camelCaseKeys from 'camelcase-keys'; import {trimNewlines} from 'trim-newlines'; import redent from 'redent'; import {buildOptions} from './options.js'; import {buildParserOptions} from './parser.js'; import {validate, checkUnknownFlags, checkMissingRequiredFlags} from './validate.js'; - -const buildResult = ({pkg: packageJson, ...options}, parserOptions) => { - const argv = parseArguments(options.argv, parserOptions); +import type { + Options, + ParsedOptions, + Result, + AnyFlags, +} from './types.js'; + +const buildResult = ({pkg: packageJson, ...options}: ParsedOptions, parserOptions: ParserOptions): Result => { + const {_: input, ...argv} = parseArguments(options.argv as string[], parserOptions); let help = ''; if (options.help) { @@ -32,7 +38,7 @@ const buildResult = ({pkg: packageJson, ...options}, parserOptions) => { help += '\n'; - const showHelp = code => { + const showHelp = (code?: number) => { console.log(help); process.exit(typeof code === 'number' ? code : 2); // Default to code 2 for incorrect usage (#47) }; @@ -42,17 +48,14 @@ const buildResult = ({pkg: packageJson, ...options}, parserOptions) => { process.exit(0); }; - if (argv._.length === 0 && options.argv.length === 1) { - if (argv.version === true && options.autoVersion) { + if (input.length === 0 && options.argv.length === 1) { + if (argv['version'] === true && options.autoVersion) { showVersion(); - } else if (argv.help === true && options.autoHelp) { + } else if (argv['help'] === true && options.autoHelp) { showHelp(0); } } - const input = argv._; - delete argv._; - if (!options.allowUnknownFlags) { checkUnknownFlags(input); } @@ -69,12 +72,14 @@ const buildResult = ({pkg: packageJson, ...options}, parserOptions) => { } } - delete flags[flagValue.shortFlag]; + if (flagValue.shortFlag) { + delete flags[flagValue.shortFlag]; + } } - checkMissingRequiredFlags(options.flags, flags, input); + checkMissingRequiredFlags(options.flags, flags, input as string[]); - return { + const result = { input, flags, unnormalizedFlags, @@ -83,16 +88,67 @@ const buildResult = ({pkg: packageJson, ...options}, parserOptions) => { showHelp, showVersion, }; + + return result as unknown as Result; }; -const meow = (helpText, options = {}) => { - const parsedOptions = buildOptions(helpText, options); +/** +@param helpMessage - Shortcut for the `help` option. + +@example +``` +#!/usr/bin/env node +import meow from 'meow'; +import foo from './index.js'; + +const cli = meow(` + Usage + $ foo + + Options + --rainbow, -r Include a rainbow + + Examples + $ foo unicorns --rainbow + 🌈 unicorns 🌈 +`, { + importMeta: import.meta, // This is required + flags: { + rainbow: { + type: 'boolean', + shortFlag: 'r' + } + } +}); + +//{ +// input: ['unicorns'], +// flags: {rainbow: true}, +// ... +//} + +foo(cli.input.at(0), cli.flags); +``` +*/ +export default function meow(helpMessage: string, options: Options): Result; +export default function meow(options: Options): Result; + +export default function meow(helpMessage: string | Options, options?: Options): Result { + if (typeof helpMessage !== 'string') { + options = helpMessage; + helpMessage = ''; + } + + const parsedOptions = buildOptions(helpMessage, options!); const parserOptions = buildParserOptions(parsedOptions); - const result = buildResult(parsedOptions, parserOptions); + const result = buildResult(parsedOptions, parserOptions); - process.title = result.pkg.bin ? Object.keys(result.pkg.bin).at(0) : result.pkg.name; + const packageTitle = result.pkg.bin ? Object.keys(result.pkg.bin).at(0) : result.pkg.name; - return result; -}; + // TODO: move to separate PR? + if (packageTitle) { + process.title = packageTitle; + } -export default meow; + return result; +} diff --git a/source/minimist-options.d.ts b/source/minimist-options.d.ts new file mode 100644 index 0000000..bcf4364 --- /dev/null +++ b/source/minimist-options.d.ts @@ -0,0 +1,67 @@ +/* eslint-disable import/no-extraneous-dependencies */ +declare module 'minimist-options' { + import type {Opts as MinimistOptions} from 'minimist'; + + export type {Opts as MinimistOptions} from 'minimist'; + + export type OptionType = 'string' | 'boolean' | 'number' | 'array' | 'string-array' | 'boolean-array' | 'number-array'; + + export type BaseOption< + TypeOptionType extends OptionType, + DefaultOptionType, + > = { + /** + * The data type the option should be parsed to. + */ + readonly type?: TypeOptionType; + + /** + * An alias/list of aliases for the option. + */ + readonly alias?: string | readonly string[]; + + /** + * The default value for the option. + */ + readonly default?: DefaultOptionType; + }; + + export type StringOption = BaseOption<'string', string>; + export type BooleanOption = BaseOption<'boolean', boolean>; + export type NumberOption = BaseOption<'number', number>; + export type DefaultArrayOption = BaseOption<'array', readonly string[]>; + export type StringArrayOption = BaseOption<'string-array', readonly string[]>; + export type BooleanArrayOption = BaseOption<'boolean-array', readonly boolean[]>; + export type NumberArrayOption = BaseOption<'number-array', readonly number[]>; + + export type AnyOption = ( + | StringOption + | BooleanOption + | NumberOption + | DefaultArrayOption + | StringArrayOption + | BooleanArrayOption + | NumberArrayOption + ); + + export type MinimistOption = Pick; + + export type Options = MinimistOption & { + [key: string]: ( + | OptionType + | StringOption + | BooleanOption + | NumberOption + | DefaultArrayOption + | StringArrayOption + | BooleanArrayOption + | NumberArrayOption + ); + arguments?: string; + }; + + /** + * Write options for [minimist](https://npmjs.org/package/minimist) in a comfortable way. Support string, boolean, number and array options. + */ + export default function buildOptions(options?: Options): MinimistOptions; +} diff --git a/source/options.ts b/source/options.ts index 6531f29..87ef76c 100644 --- a/source/options.ts +++ b/source/options.ts @@ -1,12 +1,27 @@ import process from 'node:process'; import {dirname} from 'node:path'; import {fileURLToPath} from 'node:url'; -import {readPackageUpSync} from 'read-package-up'; +import {readPackageUpSync, type PackageJson} from 'read-package-up'; import normalizePackageData from 'normalize-package-data'; import {decamelizeFlagKey, joinFlagKeys} from './utils.js'; +import type { + Options, + ParsedOptions, + AnyFlag, + AnyFlags, +} from './types.js'; -const validateOptions = options => { - const invalidOptionFilters = { +type InvalidOptionFilter = { + filter: (flag: [flagKey: string, flag: AnyFlag]) => boolean; + message: (flagKeys: string[]) => string; +}; + +type InvalidOptionFilters = { + flags: Record; +}; + +const validateOptions = (options: ParsedOptions): void => { + const invalidOptionFilters: InvalidOptionFilters = { flags: { keyContainsDashes: { filter: ([flagKey]) => flagKey.includes('-') && flagKey !== '--', @@ -21,22 +36,23 @@ const validateOptions = options => { message: flagKeys => `The option \`choices\` must be an array. Invalid flags: ${joinFlagKeys(flagKeys)}`, }, choicesNotMatchFlagType: { - filter: ([, flag]) => flag.type && Array.isArray(flag.choices) && flag.choices.some(choice => typeof choice !== flag.type), + filter: ([, flag]) => flag.type !== undefined && Array.isArray(flag.choices) && flag.choices.some(choice => typeof choice !== flag.type), message(flagKeys) { - const flagKeysAndTypes = flagKeys.map(flagKey => `(\`${decamelizeFlagKey(flagKey)}\`, type: '${options.flags[flagKey].type}')`); + const flagKeysAndTypes = flagKeys.map(flagKey => `(\`${decamelizeFlagKey(flagKey)}\`, type: '${options.flags[flagKey]!.type}')`); return `Each value of the option \`choices\` must be of the same type as its flag. Invalid flags: ${flagKeysAndTypes.join(', ')}`; }, }, defaultNotInChoices: { - filter: ([, flag]) => flag.default && Array.isArray(flag.choices) && ![flag.default].flat().every(value => flag.choices.includes(value)), + filter: ([, flag]) => flag.default !== undefined && Array.isArray(flag.choices) && ![flag.default].flat().every(value => flag.choices!.includes(value as never)), // TODO: never? message: flagKeys => `Each value of the option \`default\` must exist within the option \`choices\`. Invalid flags: ${joinFlagKeys(flagKeys)}`, }, }, }; const errorMessages = []; + type Entry = ['flags', Record]; - for (const [optionKey, filters] of Object.entries(invalidOptionFilters)) { + for (const [optionKey, filters] of Object.entries(invalidOptionFilters) as Entry[]) { const optionEntries = Object.entries(options[optionKey]); for (const {filter, message} of Object.values(filters)) { @@ -54,17 +70,12 @@ const validateOptions = options => { } }; -export const buildOptions = (helpText, options) => { - if (typeof helpText !== 'string') { - options = helpText; - helpText = ''; - } - - if (!options.importMeta?.url) { +export const buildOptions = (helpMessage: string, options: Options): ParsedOptions => { + if (!options?.importMeta?.url) { throw new TypeError('The `importMeta` option is required. Its value must be `import.meta`.'); } - const foundPackage = options.pkg ?? readPackageUpSync({ + const foundPackage = options.pkg as PackageJson ?? readPackageUpSync({ cwd: dirname(fileURLToPath(options.importMeta.url)), normalize: false, })?.packageJson; @@ -73,14 +84,14 @@ export const buildOptions = (helpText, options) => { const pkg = foundPackage ?? {}; normalizePackageData(pkg); - const parsedOptions = { + const parsedOptions: ParsedOptions = { argv: process.argv.slice(2), flags: {}, inferType: false, - input: 'string', + input: 'string', // TODO: undocumented option? description: pkg.description ?? false, - help: helpText, - version: pkg.version || 'No version found', + help: helpMessage, + version: pkg.version || 'No version found', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing autoHelp: true, autoVersion: true, booleanDefault: false, diff --git a/source/parser.ts b/source/parser.ts index 7dc7e68..accbb10 100644 --- a/source/parser.ts +++ b/source/parser.ts @@ -1,11 +1,21 @@ -import constructParserOptions from 'minimist-options'; +import constructParserOptions, { + type Options as ParserFlags, + type MinimistOptions, + type AnyOption as ParserFlag, +} from 'minimist-options'; +import type {Options as YargsOptions} from 'yargs-parser'; import decamelizeKeys from 'decamelize-keys'; +import type {Writable} from 'type-fest'; +import type {ParsedOptions, AnyFlag} from './types.js'; -const buildParserFlags = ({flags, booleanDefault}) => { - const parserFlags = {}; +type ParserOptions = YargsOptions & MinimistOptions; + +const buildParserFlags = ({flags, booleanDefault}: ParsedOptions): ParserFlags => { + const parserFlags: ParserFlags = {}; for (const [flagKey, flagValue] of Object.entries(flags)) { - const flag = {...flagValue}; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const flag = {...flagValue} as Writable; // `minimist-options` expects `flag.alias` if (flag.shortFlag) { @@ -14,10 +24,14 @@ const buildParserFlags = ({flags, booleanDefault}) => { } if (booleanDefault !== undefined && flag.type === 'boolean' && !Object.hasOwn(flag, 'default')) { + // TODO: + // @ts-expect-error: not sure flag.default = flag.isMultiple ? [booleanDefault] : booleanDefault; } if (flag.isMultiple) { + // TODO: + // @ts-expect-error: doesn't allow array types? flag.type = flag.type ? `${flag.type}-array` : 'array'; flag.default ??= []; delete flag.isMultiple; @@ -25,41 +39,40 @@ const buildParserFlags = ({flags, booleanDefault}) => { if (Array.isArray(flag.aliases)) { if (flag.alias) { - flag.aliases.push(flag.alias); + flag.aliases.push(flag.alias as string); } flag.alias = flag.aliases; delete flag.aliases; } - parserFlags[flagKey] = flag; + parserFlags[flagKey] = flag as ParserFlag; } return parserFlags; }; -export const buildParserOptions = options => { - let parserOptions = buildParserFlags(options); - parserOptions.arguments = options.input; - - parserOptions = decamelizeKeys(parserOptions, {separator: '-', exclude: ['stopEarly', '--']}); +export const buildParserOptions = (options: ParsedOptions): YargsOptions => { + let parserFlags = buildParserFlags(options); - if (options.inferType) { - delete parserOptions.arguments; + if (!options.inferType) { + parserFlags.arguments = options.input; } + parserFlags = decamelizeKeys(parserFlags, {separator: '-', exclude: ['stopEarly', '--']}); + // Add --help and --version to known flags if autoHelp or autoVersion are set if (!options.allowUnknownFlags) { - if (options.autoHelp && !parserOptions.help) { - parserOptions.help = {type: 'boolean'}; + if (options.autoHelp && !parserFlags['help']) { + parserFlags['help'] = {type: 'boolean'}; } - if (options.autoVersion && !parserOptions.version) { - parserOptions.version = {type: 'boolean'}; + if (options.autoVersion && !parserFlags['version']) { + parserFlags['version'] = {type: 'boolean'}; } } - parserOptions = constructParserOptions(parserOptions); + const parserOptions = constructParserOptions(parserFlags) as ParserOptions; parserOptions.configuration = { ...parserOptions.configuration, diff --git a/source/types.ts b/source/types.ts index 6299975..e74109f 100644 --- a/source/types.ts +++ b/source/types.ts @@ -5,6 +5,9 @@ import type { export type FlagType = 'string' | 'boolean' | 'number'; +export type ParsedFlag = string | boolean | number; +export type ParsedFlags = Readonly>; + /** Callback function to determine if a flag is required during runtime. @@ -108,8 +111,8 @@ export type Flag = { type StringFlag = Flag<'string', string> | Flag<'string', string[], true>; type BooleanFlag = Flag<'boolean', boolean> | Flag<'boolean', boolean[], true>; type NumberFlag = Flag<'number', number> | Flag<'number', number[], true>; -type AnyFlag = StringFlag | BooleanFlag | NumberFlag; -type AnyFlags = Record; +export type AnyFlag = StringFlag | BooleanFlag | NumberFlag; +export type AnyFlags = Record; export type Options = { /** @@ -315,21 +318,28 @@ export type Options = { readonly helpIndent?: number; }; +type BooleanDefault = Pick, 'booleanDefault'>; + +export type ParsedOptions = Required, 'booleanDefault' | 'hardRejection'>> & BooleanDefault & { + input: string; + allowParentFlags: boolean; +}; + type TypedFlag = - Flag extends {type: 'number'} - ? number - : Flag extends {type: 'string'} - ? string - : Flag extends {type: 'boolean'} - ? boolean - : unknown; + Flag extends {type: 'number'} + ? number + : Flag extends {type: 'string'} + ? string + : Flag extends {type: 'boolean'} + ? boolean + : unknown; type PossiblyOptionalFlag = - Flag extends {isRequired: true} + Flag extends {isRequired: true} + ? FlagType + : Flag extends {default: any} ? FlagType - : Flag extends {default: any} - ? FlagType - : FlagType | undefined; + : FlagType | undefined; export type TypedFlags = { [F in keyof Flags]: Flags[F] extends {isMultiple: true} @@ -375,43 +385,3 @@ export type Result = { */ showVersion: () => void; }; -/** -@param helpMessage - Shortcut for the `help` option. - -@example -``` -#!/usr/bin/env node -import meow from 'meow'; -import foo from './index.js'; - -const cli = meow(` - Usage - $ foo - - Options - --rainbow, -r Include a rainbow - - Examples - $ foo unicorns --rainbow - 🌈 unicorns 🌈 -`, { - importMeta: import.meta, // This is required - flags: { - rainbow: { - type: 'boolean', - shortFlag: 'r' - } - } -}); - -//{ -// input: ['unicorns'], -// flags: {rainbow: true}, -// ... -//} - -foo(cli.input.at(0), cli.flags); -``` -*/ -export default function meow(helpMessage: string, options: Options): Result; -export default function meow(options: Options): Result; diff --git a/source/utils.ts b/source/utils.ts index 6325b1b..caa73e3 100644 --- a/source/utils.ts +++ b/source/utils.ts @@ -1,5 +1,5 @@ import decamelize from 'decamelize'; -export const decamelizeFlagKey = flagKey => `--${decamelize(flagKey, {separator: '-'})}`; +export const decamelizeFlagKey = (flagKey: string) => `--${decamelize(flagKey, {separator: '-'})}`; -export const joinFlagKeys = (flagKeys, prefix = '--') => `\`${prefix}${flagKeys.join(`\`, \`${prefix}`)}\``; +export const joinFlagKeys = (flagKeys: string[], prefix = '--') => `\`${prefix}${flagKeys.join(`\`, \`${prefix}`)}\``; diff --git a/source/validate.ts b/source/validate.ts index effba37..58d1be6 100644 --- a/source/validate.ts +++ b/source/validate.ts @@ -1,7 +1,19 @@ import process from 'node:process'; import {decamelizeFlagKey} from './utils.js'; - -const validateFlags = (flags, options) => { +import type { + AnyFlag, + AnyFlags, + ParsedOptions, + ParsedFlags, +} from './types.js'; + +type FlagInput = string | number; +type Flags = Record; +type Options = Omit; +type DefinedFlags = Options['flags']; +type RequiredFlag = DefinedFlags[keyof DefinedFlags] & {key: string}; + +const validateFlags = (flags: Flags, options: Options): void => { for (const [flagKey, flagValue] of Object.entries(options.flags)) { if (flagKey !== '--' && !flagValue.isMultiple && Array.isArray(flags[flagKey])) { throw new Error(`The flag --${flagKey} can only be set once.`); @@ -9,7 +21,7 @@ const validateFlags = (flags, options) => { } }; -const validateChoicesByFlag = (flagKey, flagValue, receivedInput) => { +const validateChoicesByFlag = (flagKey: string, flagValue: AnyFlag, receivedInput: FlagInput | FlagInput[] | undefined): string | void => { const {choices, isRequired} = flagValue; if (!choices) { @@ -27,19 +39,19 @@ const validateChoicesByFlag = (flagKey, flagValue, receivedInput) => { } if (Array.isArray(receivedInput)) { - const unknownValues = receivedInput.filter(index => !choices.includes(index)); + const unknownValues = receivedInput.filter(index => !choices.includes(index as never)); // TODO: never? if (unknownValues.length > 0) { const valuesText = unknownValues.length > 1 ? 'values' : 'value'; return `Unknown ${valuesText} for flag \`${decamelizeFlagKey(flagKey)}\`: \`${unknownValues.join('`, `')}\`. ${valueMustBeOneOf}`; } - } else if (!choices.includes(receivedInput)) { + } else if (!choices.includes(receivedInput as never)) { return `Unknown value for flag \`${decamelizeFlagKey(flagKey)}\`: \`${receivedInput}\`. ${valueMustBeOneOf}`; } }; -const validateChoices = (flags, receivedFlags) => { +const validateChoices = (flags: DefinedFlags, receivedFlags: Flags): void => { const errors = []; for (const [flagKey, flagValue] of Object.entries(flags)) { @@ -56,60 +68,62 @@ const validateChoices = (flags, receivedFlags) => { } }; -export const validate = (flags, options) => { +export const validate = (flags: Flags, options: Options): void => { validateFlags(flags, options); validateChoices(options.flags, flags); }; -const reportUnknownFlags = unknownFlags => { +const reportUnknownFlags = (unknownFlags: string[]): void => { console.error([ `Unknown flag${unknownFlags.length > 1 ? 's' : ''}`, ...unknownFlags, ].join('\n')); }; -export const checkUnknownFlags = input => { - const unknownFlags = input.filter(item => typeof item === 'string' && item.startsWith('-')); +export const checkUnknownFlags = (input: Array): void => { + const unknownFlags = input.filter((item): item is string => typeof item === 'string' && item.startsWith('-')); if (unknownFlags.length > 0) { reportUnknownFlags(unknownFlags); process.exit(2); } }; -const isFlagMissing = (flagName, definedFlags, receivedFlags, input) => { - const flag = definedFlags[flagName]; +const isFlagMissing = (flagName: string, flag: AnyFlag, receivedFlags: ParsedFlags, input: string[]): boolean => { let isFlagRequired = true; if (typeof flag.isRequired === 'function') { - isFlagRequired = flag.isRequired(receivedFlags, input); + isFlagRequired = flag.isRequired(receivedFlags as AnyFlags, input); if (typeof isFlagRequired !== 'boolean') { throw new TypeError(`Return value for isRequired callback should be of type boolean, but ${typeof isFlagRequired} was returned.`); } } - if (receivedFlags[flagName] === undefined) { + const receivedFlag = receivedFlags[flagName]; + + if (receivedFlag === undefined) { return isFlagRequired; } - return flag.isMultiple && receivedFlags[flagName].length === 0 && isFlagRequired; + return Boolean(flag.isMultiple) && (receivedFlag as any[]).length === 0 && isFlagRequired; }; -const reportMissingRequiredFlags = missingRequiredFlags => { +const reportMissingRequiredFlags = (missingRequiredFlags: RequiredFlag[]): void => { console.error(`Missing required flag${missingRequiredFlags.length > 1 ? 's' : ''}`); for (const flag of missingRequiredFlags) { console.error(`\t${decamelizeFlagKey(flag.key)}${flag.shortFlag ? `, -${flag.shortFlag}` : ''}`); } }; -export const checkMissingRequiredFlags = (flags, receivedFlags, input) => { - const missingRequiredFlags = []; +export const checkMissingRequiredFlags = (flags: DefinedFlags, receivedFlags: ParsedFlags, input: string[]): void => { if (flags === undefined) { - return []; + return; } - for (const flagName of Object.keys(flags)) { - if (flags[flagName].isRequired && isFlagMissing(flagName, flags, receivedFlags, input)) { - missingRequiredFlags.push({key: flagName, ...flags[flagName]}); + const missingRequiredFlags: RequiredFlag[] = []; + + for (const [flagName, flag] of Object.entries(flags)) { + if (flag.isRequired && isFlagMissing(flagName, flag, receivedFlags, input)) { + missingRequiredFlags.push({key: flagName, ...flag}); } } diff --git a/test-d/index.ts b/test-d/index.ts index 842272d..3080517 100644 --- a/test-d/index.ts +++ b/test-d/index.ts @@ -1,8 +1,7 @@ import {expectAssignable, expectError, expectType} from 'tsd'; import type {PackageJson} from 'type-fest'; -import meow, {type Result} from '../source/index.js'; - -type AnyFlag = NonNullable[0]>['flags']>[string]; +import meow from '../source/index.js'; +import type {Result, AnyFlag} from './types.js'; const importMeta = import.meta; @@ -71,10 +70,10 @@ expectType(result.flags.bar); expectType(result.flags.abc); expectType(result.flags.baz); expectType(result.unnormalizedFlags.foo); -expectType(result.unnormalizedFlags.f); +expectType(result.unnormalizedFlags['f']); expectType(result.unnormalizedFlags['foo-bar']); -expectType(result.unnormalizedFlags.foobar); -expectType(result.unnormalizedFlags.fooBar); +expectType(result.unnormalizedFlags['foobar']); +expectType(result.unnormalizedFlags['fooBar']); expectType(result.unnormalizedFlags.bar); expectType(result.unnormalizedFlags.abc); expectType(result.unnormalizedFlags.baz); diff --git a/test-d/types.ts b/test-d/types.ts new file mode 100644 index 0000000..a068f77 --- /dev/null +++ b/test-d/types.ts @@ -0,0 +1 @@ +export type {Result, AnyFlag} from '../source/types.js'; diff --git a/test/_utils.ts b/test/_utils.ts index 7dde13c..270a433 100644 --- a/test/_utils.ts +++ b/test/_utils.ts @@ -1,16 +1,26 @@ /* eslint-disable ava/no-ignored-test-files */ import {fileURLToPath} from 'node:url'; import test from 'ava'; -import {execa} from 'execa'; +import { + execa, + type ExecaChildProcess, + type ExecaReturnValue, + type Options as ExecaOptions, +} from 'execa'; import {readPackage} from 'read-pkg'; import {createTag, stripIndentTransformer, trimResultTransformer} from 'common-tags'; import StackUtils from 'stack-utils'; +import type {RequireOneOrNone} from 'type-fest'; +import type {Options, AnyFlags} from '../source/types.js'; export const defaultFixture = 'fixture.ts'; -const getFixture = fixture => fileURLToPath(new URL(`fixtures/${fixture}`, import.meta.url)); +const getFixture = (fixture: string): string => fileURLToPath(new URL(`fixtures/${fixture}`, import.meta.url)); -export const spawnFixture = async (fixture = defaultFixture, arguments_ = [], options = {}) => { +export async function spawnFixture(arguments_: string[]): Promise; +export async function spawnFixture(fixture: string, arguments_?: string[], options?: ExecaOptions): Promise; + +export async function spawnFixture(fixture: string | string[] = defaultFixture, arguments_: string[] = [], options: ExecaOptions = {}): Promise { // Allow calling with arguments first if (Array.isArray(fixture)) { arguments_ = fixture; @@ -18,7 +28,7 @@ export const spawnFixture = async (fixture = defaultFixture, arguments_ = [], op } return execa(getFixture(fixture), arguments_, options); -}; +} export {stripIndent} from 'common-tags'; @@ -33,13 +43,28 @@ export const meowVersion = meowPackage.version; const stackUtils = new StackUtils(); -export const stackToErrorMessage = stack => stackUtils.clean(stack).split('\n').at(0); +export const stackToErrorMessage = (stack: string) => stackUtils.clean(stack).split('\n').at(0); + +export type MeowOptions = Omit, 'importMeta'>; + +type VerifyCliMacroArguments = [{ + fixture?: string; + args?: string; + execaOptions?: ExecaOptions; +} & RequireOneOrNone<{ + expected: string; + error: string | { + message: string; + code: number; + clean?: boolean; + }; +}, 'expected' | 'error'>]; -export const _verifyCli = (baseFixture = defaultFixture) => test.macro( +export const _verifyCli = (baseFixture = defaultFixture) => test.macro( async (t, {fixture = baseFixture, args, execaOptions, expected, error}) => { const assertions = await t.try(async tt => { const arguments_ = args ? args.split(' ') : []; - const {all: output, exitCode} = await spawnFixture(fixture, arguments_, {reject: false, all: true, ...execaOptions}); + const {all: output, exitCode} = await spawnFixture(fixture, arguments_, {reject: false, all: true, ...execaOptions}) as ExecaReturnValue & {all: string}; tt.log('args:', arguments_); if (error) { diff --git a/test/fixtures/required/fixture-required-function.ts b/test/fixtures/required/fixture-required-function.ts index f3287b7..ec89d24 100755 --- a/test/fixtures/required/fixture-required-function.ts +++ b/test/fixtures/required/fixture-required-function.ts @@ -15,6 +15,8 @@ const cli = meow({ }, withTrigger: { type: 'string', + // TODO: change type to allow truthy / falsy values? + // @ts-expect-error: falsy trigger is still boolean isRequired: (flags, _) => flags.trigger, }, allowError: { @@ -23,11 +25,12 @@ const cli = meow({ }, shouldError: { type: 'boolean', - isRequired: (flags, _) => - flags.allowError ? 'should error' : false - , + // @ts-expect-error: invalid string return + isRequired: (flags, _) => flags.allowError ? 'should error' : false, }, }, }); +// TODO: errors above make flags untyped +// eslint-disable-next-line @typescript-eslint/restrict-template-expressions console.log(`${cli.flags.trigger},${cli.flags.withTrigger}`); diff --git a/test/fixtures/tsconfig.json b/test/fixtures/tsconfig.json new file mode 100644 index 0000000..80e8fa8 --- /dev/null +++ b/test/fixtures/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/test/fixtures/version/fixture.ts b/test/fixtures/version/fixture.ts index ab3a244..3ec4e8b 100755 --- a/test/fixtures/version/fixture.ts +++ b/test/fixtures/version/fixture.ts @@ -2,14 +2,14 @@ import process from 'node:process'; import meow from '../../../source/index.js'; -const version = process.env.VERSION === 'false' ? false : process.env.VERSION; +const version = (process.env.VERSION === 'false' ? false : process.env.VERSION) as string | undefined; const options = { importMeta: import.meta, version, autoVersion: !process.argv.includes('--no-auto-version'), flags: { - showVersion: {type: 'boolean'}, + showVersion: {type: 'boolean'} as const, }, }; diff --git a/test/flags/_utils.ts b/test/flags/_utils.ts index 69f20c9..df7219c 100644 --- a/test/flags/_utils.ts +++ b/test/flags/_utils.ts @@ -1,13 +1,27 @@ /* eslint-disable ava/no-ignored-test-files */ import test from 'ava'; +import type {RequireOneOrNone} from 'type-fest'; +import type {MeowOptions as _MeowOptions} from '../_utils.js'; +import type {AnyFlags} from '../../source/types.js'; import meow from '../../source/index.js'; -export const _verifyFlags = importMeta => test.macro(async (t, {flags = {}, args, expected, error, ...meowOptions}) => { +type MeowOptions = Omit<_MeowOptions, 'argv' | 'flags'>; + +type VerifyFlagsMacroArguments = [{ + [option: string]: MeowOptions[keyof MeowOptions]; + flags?: AnyFlags; + args?: string; +} & RequireOneOrNone<{ + expected: Record; + error: string; +}, 'expected' | 'error'>]; + +export const _verifyFlags = (importMeta: ImportMeta) => test.macro(async (t, {flags = {}, args, expected, error, ..._meowOptions}) => { const assertions = await t.try(async tt => { const arguments_ = args?.split(' ') ?? []; - meowOptions = { - ...meowOptions, + const meowOptions = { + ..._meowOptions, importMeta, argv: arguments_, flags, diff --git a/test/flags/boolean-default.ts b/test/flags/boolean-default.ts index ac54256..70359d4 100644 --- a/test/flags/boolean-default.ts +++ b/test/flags/boolean-default.ts @@ -46,6 +46,7 @@ test('boolean args are false by default', verifyFlags, { }, }); +// @ts-expect-error: invalid booleanDefault test('throws if default is null', verifyFlags, { booleanDefault: null, flags: { diff --git a/test/flags/choices.ts b/test/flags/choices.ts index f210c2c..9f2a228 100644 --- a/test/flags/choices.ts +++ b/test/flags/choices.ts @@ -38,6 +38,7 @@ test('throws if input does not match choices', verifyChoices, { `, }); +// @ts-expect-error: invalid choices test('throws if choices is not array', verifyChoices, { flags: { animal: { @@ -123,6 +124,7 @@ test('throws with multiple flags', verifyChoices, { `, }); +// @ts-expect-error: invalid choices test('choices must be of the same type', verifyChoices, { flags: { number: { diff --git a/test/flags/short-flag.ts b/test/flags/short-flag.ts index 311de00..1c961e4 100644 --- a/test/flags/short-flag.ts +++ b/test/flags/short-flag.ts @@ -59,6 +59,7 @@ test('grouped flags work', t => { }); }); +// @ts-expect-error: unknown key alias test('suggests renaming alias to shortFlag', verifyFlags, { flags: { foo: { diff --git a/test/flags/test.ts b/test/flags/test.ts index ae57a1b..afcd82a 100644 --- a/test/flags/test.ts +++ b/test/flags/test.ts @@ -31,6 +31,7 @@ test('supports negation via --no', verifyFlags, { }, }); +// @ts-expect-error: invalid default test('throws if default value is not of the correct type', verifyFlags, { flags: { foo: { @@ -80,7 +81,7 @@ test('default - no flag', verifyFlags, { test('single character flag casing should be preserved', verifyFlags, { args: '-F', expected: { - F: true, + F: true, // eslint-disable-line @typescript-eslint/naming-convention }, }); @@ -103,6 +104,7 @@ test('single flag set more than once is an error', verifyFlags, { error: 'The flag --foo can only be set once.', }); +// @ts-expect-error: invalid choices, unknown key alias test('options - multiple validation errors', verifyFlags, { flags: { animal: { diff --git a/test/options/help.ts b/test/options/help.ts index ebcad63..7856006 100644 --- a/test/options/help.ts +++ b/test/options/help.ts @@ -1,16 +1,26 @@ import test from 'ava'; import indentString from 'indent-string'; import meow from '../../source/index.js'; -import {_verifyCli, stripIndent, stripIndentTrim} from '../_utils.js'; +import { + _verifyCli, + type MeowOptions, + stripIndent, + stripIndentTrim, +} from '../_utils.js'; const importMeta = import.meta; const verifyCli = _verifyCli(); -const verifyHelp = test.macro(async (t, {cli: cliArguments, expected}) => { +type VerifyHelpMacroArguments = [{ + cli: MeowOptions | [helpMessage: string, options?: MeowOptions]; + expected: string; +}]; + +const verifyHelp = test.macro(async (t, {cli: cliArguments, expected}) => { const assertions = await t.try(async tt => { const cli = Array.isArray(cliArguments) - ? meow(cliArguments.at(0), {importMeta, ...cliArguments.at(1)}) + ? meow(cliArguments[0], {importMeta, ...cliArguments[1]}) : meow({importMeta, ...cliArguments}); tt.log('help text:\n', cli.help); diff --git a/test/options/import-meta.ts b/test/options/import-meta.ts index e0f72b0..ea47f62 100644 --- a/test/options/import-meta.ts +++ b/test/options/import-meta.ts @@ -1,7 +1,12 @@ import test from 'ava'; import meow from '../../source/index.js'; -const verifyImportMeta = test.macro((t, {cli, error}) => { +type MacroArguments = [{ + cli: () => ReturnType; + error?: true; +}]; + +const verifyImportMeta = test.macro((t, {cli, error}) => { if (error) { t.throws(cli, {message: 'The `importMeta` option is required. Its value must be `import.meta`.'}); } else { @@ -25,21 +30,25 @@ test('with help shortcut', verifyImportMeta, { }); test('invalid package url', verifyImportMeta, { + // @ts-expect-error: invalid importMeta cli: () => meow({importMeta: '/path/to/package'}), error: true, }); test('throws if unset', verifyImportMeta, { + // @ts-expect-error: missing importMeta cli: () => meow('foo', {}), error: true, }); test('throws if unset - options only', verifyImportMeta, { + // @ts-expect-error: missing importMeta cli: () => meow({}), error: true, }); test('throws if unset - help shortcut only', verifyImportMeta, { + // @ts-expect-error: missing options cli: () => meow('foo'), error: true, }); diff --git a/test/options/infer-type.ts b/test/options/infer-type.ts index a96ffe5..c5e9545 100644 --- a/test/options/infer-type.ts +++ b/test/options/infer-type.ts @@ -1,7 +1,13 @@ import test from 'ava'; import meow from '../../source/index.js'; +import type {MeowOptions} from '../_utils.js'; -const verifyTypeInference = test.macro((t, {cli: cliArguments, expected}) => { +type MacroArguments = [{ + cli: MeowOptions; + expected: string | number; +}]; + +const verifyTypeInference = test.macro((t, {cli: cliArguments, expected}) => { const cli = meow({importMeta: import.meta, ...cliArguments}); t.like(cli.input, [expected]); }); @@ -22,10 +28,10 @@ test('type inference', verifyTypeInference, { }); test('with input type', verifyTypeInference, { - cli: { + cli: { // eslint-disable-line @typescript-eslint/consistent-type-assertions argv: ['5'], input: 'number', - }, + } as MeowOptions, expected: 5, }); @@ -33,7 +39,9 @@ test('works with flags', verifyTypeInference, { cli: { argv: ['5'], inferType: true, - flags: {foo: 'string'}, + flags: { + foo: {type: 'string'}, + }, }, expected: 5, }); diff --git a/test/options/version.ts b/test/options/version.ts index 0449dba..0a2a19f 100644 --- a/test/options/version.ts +++ b/test/options/version.ts @@ -1,7 +1,7 @@ import test from 'ava'; import {_verifyCli, defaultFixture, stripIndentTrim} from '../_utils.js'; -const verifyVersion = _verifyCli('version/fixture.js'); +const verifyVersion = _verifyCli('version/fixture.ts'); test('spawn cli and show version', verifyVersion, { args: '--version', @@ -31,13 +31,13 @@ test('spawn cli and not show version', verifyVersion, { test('custom version', verifyVersion, { args: '--version', - execaOptions: {env: {VERSION: 'beta'}}, + execaOptions: {env: {VERSION: 'beta'}}, // eslint-disable-line @typescript-eslint/naming-convention expected: 'beta', }); test('version = false has no effect', verifyVersion, { args: '--version', - execaOptions: {env: {VERSION: 'false'}}, + execaOptions: {env: {VERSION: 'false'}}, // eslint-disable-line @typescript-eslint/naming-convention expected: 'false', });