Skip to content

Commit

Permalink
Strongly typed optsWithGlobals (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
shadowspawn authored Nov 1, 2024
1 parent bce4491 commit 8649fe9
Show file tree
Hide file tree
Showing 9 changed files with 373 additions and 59 deletions.
20 changes: 2 additions & 18 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,15 @@ const esLintjs = require('@eslint/js');
const jest = require('eslint-plugin-jest');
const tseslint = require('typescript-eslint');
const prettier = require('eslint-config-prettier');
//const jsdoc = require('eslint-plugin-jsdoc');

// Only run tseslint on the files that we have included for TypeScript.
// Simpler setup than in Commander as not running TypeScript over .js files.
const tsconfigTsFiles = ['**/*.{ts,mts}'];
const tsconfigJsFiles = ['**.{js,mjs}'];

// Using tseslint.config adds some type safety and `extends` to simplify customising config array.
module.exports = tseslint.config(
// Add recommended rules.
esLintjs.configs.recommended,
// jsdoc.configs['flat/recommended'],
jest.configs['flat/recommended'],
// tseslint with different setup for js/ts
{
files: tsconfigJsFiles,
extends: [...tseslint.configs.recommended],
rules: {
'@typescript-eslint/no-var-requires': 'off', // (tseslint does not autodetect commonjs context )
},
},
{
files: tsconfigTsFiles,
extends: [...tseslint.configs.recommended],
Expand All @@ -34,11 +23,6 @@ module.exports = tseslint.config(
files: ['**/*.{js,mjs,cjs}', '**/*.{ts,mts,cts}'],
rules: {
'no-else-return': ['error', { allowElseIf: false }],

// 'jsdoc/tag-lines': 'off',
// 'jsdoc/require-jsdoc': 'off',
// 'jsdoc/require-param-description': 'off',
// 'jsdoc/require-returns-description': 'off',
},
languageOptions: {
globals: {
Expand All @@ -59,7 +43,7 @@ module.exports = tseslint.config(
},
},
{
files: [...tsconfigTsFiles, ...tsconfigJsFiles],
files: [...tsconfigTsFiles],
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/ban-ts-comment': [
Expand Down
11 changes: 11 additions & 0 deletions examples/assemble-program.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Command } from '../index.js';

// Example of strongly typed globals in a subcommand which is added to program using .addCommand().
// Declare factory function for root Command in separate file from adding subcommands to avoid circular dependencies.

export function createProgram() {
const program = new Command().option('-g, --global');
return program;
}

export type ProgramOpts = ReturnType<ReturnType<typeof createProgram>['opts']>;
14 changes: 14 additions & 0 deletions examples/assemble-sub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */

// Example of strongly typed globals in a subcommand which is added to program using .addCommand().

import { Command } from '../index.js';
import { type ProgramOpts } from './assemble-program.js';

export function createSub() {
const program = new Command<[], {}, ProgramOpts>('sub').option('-l, --local');
const optsWithGlobals = program.optsWithGlobals();
return program;
}

export type SubOpts = ReturnType<typeof createSub>['opts'];
11 changes: 11 additions & 0 deletions examples/assemble.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createProgram, type ProgramOpts } from './assemble-program';
import { createSub, type SubOpts } from './assemble-sub';

// Example of strongly typed globals in a subcommand which is added to program using .addCommand().

export function AssembleProgram() {
const program = createProgram();
const subCommand = createSub();
program.addCommand(subCommand);
return program;
}
67 changes: 45 additions & 22 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// eslint complains about {} as a type, but hard to be more accurate.
/* eslint-disable @typescript-eslint/no-empty-object-type */

type TrimRight<S extends string> = S extends `${infer R} ` ? TrimRight<R> : S;
type TrimLeft<S extends string> = S extends ` ${infer R}` ? TrimLeft<R> : S;
type Trim<S extends string> = TrimLeft<TrimRight<S>>;
Expand Down Expand Up @@ -313,7 +316,7 @@ type InferOptions<
ChoicesT
>;

export type CommandUnknownOpts = Command<unknown[], OptionValues>;
export type CommandUnknownOpts = Command<unknown[], OptionValues, OptionValues>;

// ----- full copy of normal commander typings from here down, with extra type inference -----

Expand Down Expand Up @@ -631,9 +634,11 @@ export type OptionValueSource =

export type OptionValues = Record<string, unknown>;

// eslint unimpressed with `OptionValues = {}`, but not sure what to use instead.
// eslint-disable-next-line @typescript-eslint/ban-types
export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
export class Command<
Args extends any[] = [],
Opts extends OptionValues = {},
GlobalOpts extends OptionValues = {},
> {
args: string[];
processedArgs: Args;
readonly commands: readonly CommandUnknownOpts[];
Expand Down Expand Up @@ -679,7 +684,7 @@ export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
command<Usage extends string>(
nameAndArgs: Usage,
opts?: CommandOptions,
): Command<[...InferCommandArguments<Usage>]>;
): Command<[...InferCommandArguments<Usage>], {}, Opts & GlobalOpts>;
/**
* Define a command, implemented in a separate executable file.
*
Expand Down Expand Up @@ -750,22 +755,22 @@ export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
flags: S,
description: string,
fn: (value: string, previous: T) => T,
): Command<[...Args, InferArgument<S, undefined, T>], Opts>;
): Command<[...Args, InferArgument<S, undefined, T>], Opts, GlobalOpts>;
argument<S extends string, T>(
flags: S,
description: string,
fn: (value: string, previous: T) => T,
defaultValue: T,
): Command<[...Args, InferArgument<S, T, T>], Opts>;
): Command<[...Args, InferArgument<S, T, T>], Opts, GlobalOpts>;
argument<S extends string>(
usage: S,
description?: string,
): Command<[...Args, InferArgument<S, undefined>], Opts>;
): Command<[...Args, InferArgument<S, undefined>], Opts, GlobalOpts>;
argument<S extends string, DefaultT>(
usage: S,
description: string,
defaultValue: DefaultT,
): Command<[...Args, InferArgument<S, DefaultT>], Opts>;
): Command<[...Args, InferArgument<S, DefaultT>], Opts, GlobalOpts>;

/**
* Define argument syntax for command, adding a prepared argument.
Expand All @@ -782,7 +787,8 @@ export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
arg: Argument<Usage, DefaultT, CoerceT, ArgRequired, ChoicesT>,
): Command<
[...Args, InferArgument<Usage, DefaultT, CoerceT, ArgRequired, ChoicesT>],
Opts
Opts,
GlobalOpts
>;

/**
Expand All @@ -799,7 +805,7 @@ export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
*/
arguments<Names extends string>(
args: Names,
): Command<[...Args, ...InferArguments<Names>], Opts>;
): Command<[...Args, ...InferArguments<Names>], Opts, GlobalOpts>;

/**
* Customise or override default help command. By default a help command is automatically added if your command has subcommands.
Expand Down Expand Up @@ -938,23 +944,31 @@ export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
option<S extends string>(
usage: S,
description?: string,
): Command<Args, InferOptions<Opts, S, undefined, undefined, false>>;
): Command<
Args,
InferOptions<Opts, S, undefined, undefined, false>,
GlobalOpts
>;
option<S extends string, DefaultT extends string | boolean | string[] | []>(
usage: S,
description?: string,
defaultValue?: DefaultT,
): Command<Args, InferOptions<Opts, S, DefaultT, undefined, false>>;
): Command<
Args,
InferOptions<Opts, S, DefaultT, undefined, false>,
GlobalOpts
>;
option<S extends string, T>(
usage: S,
description: string,
parseArg: (value: string, previous: T) => T,
): Command<Args, InferOptions<Opts, S, undefined, T, false>>;
): Command<Args, InferOptions<Opts, S, undefined, T, false>, GlobalOpts>;
option<S extends string, T>(
usage: S,
description: string,
parseArg: (value: string, previous: T) => T,
defaultValue?: T,
): Command<Args, InferOptions<Opts, S, T, T, false>>;
): Command<Args, InferOptions<Opts, S, T, T, false>, GlobalOpts>;

/**
* Define a required option, which must have a value after parsing. This usually means
Expand All @@ -965,26 +979,34 @@ export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
requiredOption<S extends string>(
usage: S,
description?: string,
): Command<Args, InferOptions<Opts, S, undefined, undefined, true>>;
): Command<
Args,
InferOptions<Opts, S, undefined, undefined, true>,
GlobalOpts
>;
requiredOption<
S extends string,
DefaultT extends string | boolean | string[],
>(
usage: S,
description?: string,
defaultValue?: DefaultT,
): Command<Args, InferOptions<Opts, S, DefaultT, undefined, true>>;
): Command<
Args,
InferOptions<Opts, S, DefaultT, undefined, true>,
GlobalOpts
>;
requiredOption<S extends string, T>(
usage: S,
description: string,
parseArg: (value: string, previous: T) => T,
): Command<Args, InferOptions<Opts, S, undefined, T, true>>;
): Command<Args, InferOptions<Opts, S, undefined, T, true>, GlobalOpts>;
requiredOption<S extends string, T, D extends T>(
usage: S,
description: string,
parseArg: (value: string, previous: T) => T,
defaultValue?: D,
): Command<Args, InferOptions<Opts, S, D, T, true>>;
): Command<Args, InferOptions<Opts, S, D, T, true>, GlobalOpts>;

/**
* Factory routine to create a new unattached option.
Expand Down Expand Up @@ -1014,7 +1036,8 @@ export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
option: Option<Usage, PresetT, DefaultT, CoerceT, Mandatory, ChoicesT>,
): Command<
Args,
InferOptions<Opts, Usage, DefaultT, CoerceT, Mandatory, PresetT, ChoicesT>
InferOptions<Opts, Usage, DefaultT, CoerceT, Mandatory, PresetT, ChoicesT>,
GlobalOpts
>;

/**
Expand Down Expand Up @@ -1066,7 +1089,7 @@ export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
/**
* Get source of option value. See also .optsWithGlobals().
*/
getOptionValueSourceWithGlobals<K extends keyof Opts>(
getOptionValueSourceWithGlobals<K extends keyof (Opts & GlobalOpts)>(
key: K,
): OptionValueSource | undefined;
getOptionValueSourceWithGlobals(key: string): OptionValueSource | undefined;
Expand Down Expand Up @@ -1176,7 +1199,7 @@ export class Command<Args extends any[] = [], Opts extends OptionValues = {}> {
/**
* Return an object containing merged local and global option values as key-value pairs.
*/
optsWithGlobals<T extends OptionValues>(): T;
optsWithGlobals(): Resolve<Opts & GlobalOpts>;

/**
* Set the description.
Expand Down
3 changes: 0 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ exports.InvalidArgumentError = commander.InvalidArgumentError;
exports.InvalidOptionArgumentError = commander.InvalidArgumentError; // Deprecated
exports.Option = commander.Option;

// In Commander, the create routines end up being aliases for the matching
// methods on the global program due to the (deprecated) legacy default export.
// Here we roll our own, the way Commander might in future.
exports.createCommand = (name) => new commander.Command(name);
exports.createOption = (flags, description) =>
new commander.Option(flags, description);
Expand Down
Loading

0 comments on commit 8649fe9

Please sign in to comment.