From c414365fbe602419a19f5522055e5b3c2fed761a Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Fri, 24 Nov 2023 14:52:07 +0200 Subject: [PATCH] feat: transform output name for options --- README.md | 31 ++++++--- src/help.ts | 18 +++--- src/option.ts | 9 ++- src/utils.ts | 27 ++++++++ test/command.test.ts | 145 +------------------------------------------ test/example.test.ts | 14 +++++ test/help.test.ts | 56 +++++++++++++++++ test/option.test.ts | 97 +++++++++++++++++++++++++++++ 8 files changed, 238 insertions(+), 159 deletions(-) create mode 100644 test/example.test.ts create mode 100644 test/help.test.ts create mode 100644 test/option.test.ts diff --git a/README.md b/README.md index 03446fd..2506525 100644 --- a/README.md +++ b/README.md @@ -66,15 +66,20 @@ documentation of every option. const parser = massarg({ name: 'my-cli', description: "Does really amazing stuff, you wouldn't believe!", - bindHelpCommand: true, }) // or: new Massarg() .main((options) => console.log('main command', options)) + .command({ + name: 'foo', + description: 'a sub command', + aliases: ['f'], + run: (options) => console.log('foo command'), + }) .command( massarg({ - name: 'sub', - description: 'a sub command', + name: 'bar', + description: 'another sub command', aliases: ['s'], - run: (options) => console.log('sub command', options), + run: (options) => console.log('bar command', options), }).option({ name: 'file', description: 'Filename to use', @@ -82,6 +87,11 @@ const parser = massarg({ parse: (filename) => path.resolve(process.cwd(), filename), }), ) + .option({ + name: 'my-string', + description: 'A string argument', + aliases: ['s'], + }) .flag({ name: 'flag', description: 'a flag that will be related to any command (main or sub)', @@ -93,8 +103,12 @@ const parser = massarg({ output: 'Sub command: flag is true', }) .help({ - binName: 'my-cli-app', - footer: 'Copyright © 2021 Me, Myself and I', + bindCommand: true, + footerText: `Copyright © ${new Date().getFullYear()} Me, Myself and I`, + titleStyle: { + bold: true, + color: 'brightWhite', + }, }) ``` @@ -122,7 +136,10 @@ $ ./mybin # Main command runs without options $ ./mybin --my-string "Some string" -# Main command runs with option { myString: "Some string" } +# Main command runs with options { myString: "Some string" } + +$ ./mybin foo +# Foo sub command run with options {} ``` ## Commands diff --git a/src/help.ts b/src/help.ts index dfa9af1..8b5c014 100644 --- a/src/help.ts +++ b/src/help.ts @@ -136,6 +136,7 @@ export type HelpItem = { name: string aliases: string[] description: string + hidden?: boolean } export class HelpGenerator { @@ -271,13 +272,16 @@ function generateHelpTable>( ...config }: Partial = {}, ): string { - const rows = items.map((o) => { - const name = `${namePrefix}${o.name}${ - o.aliases.length ? ` | ${aliasPrefix}${o.aliases.join(`|${aliasPrefix}`)}` : '' - }` - const description = o.description - return { name, description } - }) + const rows = items + .map((o) => { + const name = `${namePrefix}${o.name}${ + o.aliases.length ? ` | ${aliasPrefix}${o.aliases.join(`|${aliasPrefix}`)}` : '' + }` + const description = o.description + const hidden = o.hidden || false + return { name, description, hidden } + }) + .filter((r) => !r.hidden) const maxNameLength = Math.max(...rows.map((o) => o.name.length)) const nameStyle = (name: string) => format(name, config.nameStyle) const descStyle = (desc: string) => format(desc, config.descriptionStyle) diff --git a/src/option.ts b/src/option.ts index 328478a..f42eb4a 100644 --- a/src/option.ts +++ b/src/option.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { isZodError, ParseError } from './error' +import { toCamelCase } from './utils' export const OptionConfig = (type: T) => z.object({ @@ -35,6 +36,10 @@ export const OptionConfig = (type: T) => * option. */ isDefault: z.boolean().optional(), + /** Whether the option is hidden. Hidden options are not displayed in the help output. */ + hidden: z.boolean().optional(), + /** Specify a custom name for the output, which will be used when parsing the args. */ + outputName: z.string().optional(), }) export type OptionConfig = z.infer>>> @@ -108,6 +113,7 @@ export class MassargOption { parse: (value: string) => T isArray: boolean isDefault: boolean + outputName?: string constructor(options: OptionConfig) { OptionConfig(z.any()).parse(options) @@ -118,6 +124,7 @@ export class MassargOption { this.parse = options.parse ?? ((x) => x as unknown as T) this.isArray = options.array ?? false this.isDefault = options.isDefault ?? false + this.outputName = options.outputName } static fromTypedConfig(config: TypedOptionConfig): MassargOption { @@ -143,7 +150,7 @@ export class MassargOption { argv.shift() input = argv.shift()! const value = this.parse(input) - return { key: this.name, value, argv } + return { key: this.outputName || toCamelCase(this.name), value, argv } } catch (e) { if (isZodError(e)) { throw new ParseError({ diff --git a/src/utils.ts b/src/utils.ts index f3f7d68..0d39830 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -86,3 +86,30 @@ export function deepMerge(obj1: T1, obj2: T2): NonNullable & NonNull } return res } +/** + * Splits a name into words, using camelCase, PascalCase, snake_case, and kebab-case or + * regular spaced strings. + */ +export function splitWords(str: string): string[] { + return str + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([a-zA-Z])([0-9])/g, '$1 $2') + .replace(/([0-9])([a-zA-Z])/g, '$1 $2') + .replace(/([a-z])([_-])/g, '$1 $2') + .replace(/([_-])([a-zA-Z])/g, '$1 $2') + .split(/[_-]/) + .map((s) => s.trim()) + .filter(Boolean) +} + +export function toCamelCase(str: string): string { + return splitWords(str) + .map((s, i) => (i === 0 ? s : s[0].toUpperCase() + s.slice(1))) + .join('') +} + +export function toPascalCase(str: string): string { + return splitWords(str) + .map((s) => s[0].toUpperCase() + s.slice(1)) + .join('') +} diff --git a/test/command.test.ts b/test/command.test.ts index 368ac54..d9b6b2f 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -10,7 +10,7 @@ test('constructor', () => { expect(massarg(opts)).toBeInstanceOf(MassargCommand) }) -describe('command', () => { +describe('sub command', () => { test('add', () => { const command = massarg(opts) expect(command.command).toBeInstanceOf(Function) @@ -36,149 +36,6 @@ describe('command', () => { }) }) -describe('option', () => { - test('add', () => { - const command = massarg(opts) - expect(command.option).toBeInstanceOf(Function) - expect( - command.option({ name: 'test2', description: 'test2', aliases: [], defaultValue: '' }), - ).toBeInstanceOf(MassargCommand) - }) - test('validate', () => { - expect(() => - massarg(opts).option({ - name: 'test2', - description: 123 as any, - aliases: [], - defaultValue: '', - }), - ).toThrow('Expected string, received number') - }) - test('add duplicate', () => { - expect(() => - massarg(opts) - .option({ - name: 'test2', - description: 'test2', - aliases: [], - defaultValue: '', - }) - .option({ - name: 'test2', - description: 'test2', - aliases: [], - defaultValue: '', - }), - ).toThrow('Option "test2" already exists') - }) - test('add 2 defaults', () => { - expect(() => - massarg(opts) - .option({ - name: 'test', - description: 'test2', - aliases: [], - isDefault: true, - }) - .option({ - name: 'test2', - description: 'test2', - aliases: [], - isDefault: true, - }), - ).toThrow( - 'Option "test2" cannot be set as default because option "test" is already set as default', - ) - }) -}) - -describe('flag', () => { - test('add', () => { - const command = massarg(opts) - expect(command.flag).toBeInstanceOf(Function) - expect(command.flag({ name: 'test2', description: 'test2', aliases: [] })).toBeInstanceOf( - MassargCommand, - ) - }) - test('add duplicate', () => { - expect(() => - massarg(opts) - .flag({ name: 'test2', description: 'test2', aliases: [] }) - .flag({ name: 'test2', description: 'test2', aliases: [] }), - ).toThrow('Flag "test2" already exists') - }) - test('validate', () => { - expect(() => - massarg(opts).flag({ - name: 'test2', - description: 123 as any, - aliases: [], - }), - ).toThrow('Expected string, received number') - }) -}) - -describe('example', () => { - test('example', () => { - const command = massarg(opts) - expect(command.example).toBeInstanceOf(Function) - expect(command.example({ description: 'test', input: '', output: '' })).toBeInstanceOf( - MassargCommand, - ) - }) -}) - -describe('help', () => { - test('default value', () => { - const command = massarg(opts) - expect(command.helpConfig).toEqual(defaultHelpConfig) - }) - - test('init', () => { - const command = massarg(opts).help({ - bindOption: true, - optionOptions: { - namePrefix: '__', - }, - }) - expect(command.help).toBeInstanceOf(Function) - expect(command.helpConfig).toHaveProperty('bindOption', true) - expect(command.helpConfig).toHaveProperty('optionOptions.namePrefix', '__') - expect(command.helpConfig).toHaveProperty('optionOptions.aliasPrefix', '-') - expect(command.helpConfig).toHaveProperty('optionOptions.nameStyle.color', 'yellow') - }) - - test('binds command', () => { - const command = massarg(opts).help({ - bindCommand: true, - }) - expect(command.help).toBeInstanceOf(Function) - expect(command.helpConfig).toHaveProperty('bindCommand', true) - expect(command.commands.find((o) => o.name === 'help')).toBeTruthy() - }) - - test('binds option', () => { - const command = massarg(opts).help({ - bindOption: true, - }) - expect(command.help).toBeInstanceOf(Function) - expect(command.helpConfig).toHaveProperty('bindOption', true) - expect(command.options.find((o) => o.name === 'help')).toBeTruthy() - }) - - test('help string', () => { - const command = massarg(opts) - expect(command.helpString()).toContain(`Usage:`) - }) - - test('print help', () => { - const log = jest.spyOn(console, 'log').mockImplementation(() => {}) - const command = massarg(opts) - command.printHelp() - expect(log).toHaveBeenCalled() - }) -}) - describe('getArgs', () => { test('basic', () => { expect(massarg(opts).getArgs([])).toEqual({}) diff --git a/test/example.test.ts b/test/example.test.ts new file mode 100644 index 0000000..ce5870a --- /dev/null +++ b/test/example.test.ts @@ -0,0 +1,14 @@ +import { MassargCommand } from '../src/command' +import { massarg } from '../src/index' + +const opts = { + name: 'test', + description: 'test', +} +test('example', () => { + const command = massarg(opts) + expect(command.example).toBeInstanceOf(Function) + expect(command.example({ description: 'test', input: '', output: '' })).toBeInstanceOf( + MassargCommand, + ) +}) diff --git a/test/help.test.ts b/test/help.test.ts new file mode 100644 index 0000000..f8f5aab --- /dev/null +++ b/test/help.test.ts @@ -0,0 +1,56 @@ +import { defaultHelpConfig } from '../src/help' +import { massarg } from '../src/index' + +const opts = { + name: 'test', + description: 'test', +} + +test('default value', () => { + const command = massarg(opts) + expect(command.helpConfig).toEqual(defaultHelpConfig) +}) + +test('init', () => { + const command = massarg(opts).help({ + bindOption: true, + optionOptions: { + namePrefix: '__', + }, + }) + expect(command.help).toBeInstanceOf(Function) + expect(command.helpConfig).toHaveProperty('bindOption', true) + expect(command.helpConfig).toHaveProperty('optionOptions.namePrefix', '__') + expect(command.helpConfig).toHaveProperty('optionOptions.aliasPrefix', '-') + expect(command.helpConfig).toHaveProperty('optionOptions.nameStyle.color', 'yellow') +}) + +test('binds command', () => { + const command = massarg(opts).help({ + bindCommand: true, + }) + expect(command.help).toBeInstanceOf(Function) + expect(command.helpConfig).toHaveProperty('bindCommand', true) + expect(command.commands.find((o) => o.name === 'help')).toBeTruthy() +}) + +test('binds option', () => { + const command = massarg(opts).help({ + bindOption: true, + }) + expect(command.help).toBeInstanceOf(Function) + expect(command.helpConfig).toHaveProperty('bindOption', true) + expect(command.options.find((o) => o.name === 'help')).toBeTruthy() +}) + +test('help string', () => { + const command = massarg(opts) + expect(command.helpString()).toContain(`Usage:`) +}) + +test('print help', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => {}) + const command = massarg(opts) + command.printHelp() + expect(log).toHaveBeenCalled() +}) diff --git a/test/option.test.ts b/test/option.test.ts new file mode 100644 index 0000000..7ccacb2 --- /dev/null +++ b/test/option.test.ts @@ -0,0 +1,97 @@ +import { MassargCommand } from '../src/command' +import { massarg } from '../src/index' + +const opts = { + name: 'test', + description: 'test', +} +describe('option', () => { + test('add', () => { + const command = massarg(opts) + expect(command.option).toBeInstanceOf(Function) + expect( + command.option({ name: 'test2', description: 'test2', aliases: [], defaultValue: '' }), + ).toBeInstanceOf(MassargCommand) + }) + test('validate', () => { + expect(() => + massarg(opts).option({ + name: 'test2', + description: 123 as any, + aliases: [], + defaultValue: '', + }), + ).toThrow('Expected string, received number') + }) + test('add duplicate', () => { + expect(() => + massarg(opts) + .option({ + name: 'test2', + description: 'test2', + aliases: [], + defaultValue: '', + }) + .option({ + name: 'test2', + description: 'test2', + aliases: [], + defaultValue: '', + }), + ).toThrow('Option "test2" already exists') + }) + test('add 2 defaults', () => { + expect(() => + massarg(opts) + .option({ + name: 'test', + description: 'test2', + aliases: [], + isDefault: true, + }) + .option({ + name: 'test2', + description: 'test2', + aliases: [], + isDefault: true, + }), + ).toThrow( + 'Option "test2" cannot be set as default because option "test" is already set as default', + ) + }) + test('uses output name', () => { + const command = massarg(opts).option({ + name: 'test2', + description: 'test2', + aliases: [], + outputName: 'test', + }) + expect(command.getArgs(['--test2', 'test'])).toHaveProperty('test', 'test') + }) +}) + +describe('flag', () => { + test('add', () => { + const command = massarg(opts) + expect(command.flag).toBeInstanceOf(Function) + expect(command.flag({ name: 'test2', description: 'test2', aliases: [] })).toBeInstanceOf( + MassargCommand, + ) + }) + test('add duplicate', () => { + expect(() => + massarg(opts) + .flag({ name: 'test2', description: 'test2', aliases: [] }) + .flag({ name: 'test2', description: 'test2', aliases: [] }), + ).toThrow('Flag "test2" already exists') + }) + test('validate', () => { + expect(() => + massarg(opts).flag({ + name: 'test2', + description: 123 as any, + aliases: [], + }), + ).toThrow('Expected string, received number') + }) +})