Skip to content

Commit eca10e4

Browse files
committed
feat: array & typed options
1 parent 5476327 commit eca10e4

5 files changed

Lines changed: 239 additions & 52 deletions

File tree

src/command.ts

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from "zod"
22
import { ValidationError } from "./error"
3-
import MassargOption, { MassargFlag, MassargNumber, OptionConfig, OptionType } from "./option"
4-
import { isZodError } from "./utils"
3+
import MassargOption, { MassargFlag, MassargNumber, OptionConfig, TypedOptionConfig } from "./option"
4+
import { generateCommandsHelpTable, generateOptionsHelpTable, isZodError } from "./utils"
55

66
export const CommandConfig = <RunArgs extends z.ZodType>(args: RunArgs) =>
77
z.object({
@@ -18,14 +18,16 @@ export type CommandConfig<T = unknown> = z.infer<ReturnType<typeof CommandConfig
1818

1919
export type ArgsObject = Record<string, unknown>
2020

21+
// export type RunFn<Args extends ArgsObject> = (options: Args) => Promise<void> | void
22+
2123
export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
2224
name: string
2325
description: string
24-
private aliases: string[]
26+
aliases: string[]
2527
private _run?: (options: Args) => Promise<void> | void
26-
private options: MassargOption[] = []
27-
private commands: MassargCommand<any>[] = []
28-
private args: Partial<Args> = {}
28+
options: MassargOption[] = []
29+
commands: MassargCommand<any>[] = []
30+
args: Partial<Args> = {}
2931

3032
constructor(options: CommandConfig<Args>) {
3133
CommandConfig(z.any()).parse(options)
@@ -35,9 +37,9 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
3537
this._run = options.run
3638
}
3739

38-
command(config: CommandConfig<Args>): MassargCommand<Args>
39-
command(config: MassargCommand<Args>): MassargCommand<Args>
40-
command(config: CommandConfig<Args> | MassargCommand<Args>): MassargCommand<Args> {
40+
command<A extends ArgsObject = Args>(config: CommandConfig<A>): MassargCommand<Args>
41+
command<A extends ArgsObject = Args>(config: MassargCommand<A>): MassargCommand<Args>
42+
command<A extends ArgsObject = Args>(config: CommandConfig<A> | MassargCommand<A>): MassargCommand<Args> {
4143
try {
4244
const command = config instanceof MassargCommand ? config : new MassargCommand(config)
4345
this.commands.push(command)
@@ -55,23 +57,10 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
5557
}
5658

5759
option<T = string>(config: MassargOption<T>): MassargCommand<Args>
58-
option<T = string>(config: OptionConfig<T> & { type?: OptionType }): MassargCommand<Args>
59-
option<T = string>(config: (OptionConfig<T> & { type?: OptionType }) | MassargOption<T>): MassargCommand<Args> {
60-
const factory = () => {
61-
if (!("type" in config)) {
62-
return new MassargOption(config as OptionConfig<T>)
63-
}
64-
switch (config.type) {
65-
case "string":
66-
return new MassargOption<string>(config as OptionConfig<string>)
67-
case "number":
68-
return new MassargNumber(config as OptionConfig<number>)
69-
case "boolean":
70-
return new MassargFlag(config)
71-
}
72-
}
60+
option<T = string>(config: TypedOptionConfig<T>): MassargCommand<Args>
61+
option<T = string>(config: TypedOptionConfig<T> | MassargOption<T>): MassargCommand<Args> {
7362
try {
74-
const option = config instanceof MassargOption ? config : factory()
63+
const option = config instanceof MassargOption ? config : MassargOption.fromTypedConfig(config)
7564
this.options.push(option as MassargOption)
7665
return this
7766
} catch (e) {
@@ -100,7 +89,8 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
10089
while (_argv.length) {
10190
const arg = _argv.shift()!
10291
console.log("parsing:", arg, _argv)
103-
if (arg.startsWith("-")) {
92+
const found = this.options.some((o) => o._isOption(arg))
93+
if (found) {
10494
console.log("option:", arg, _argv)
10595
_argv = this.parseOption(arg, _argv)
10696
continue
@@ -121,15 +111,21 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
121111
}
122112

123113
private parseOption(arg: string, argv: string[]): string[] {
124-
const option = this.options.find(
125-
(o) => (arg.startsWith("--") && o.name === arg.slice(2)) || o.aliases.includes(arg.slice(1)),
126-
)
114+
const option = this.options.find((o) => o._match(arg))
115+
127116
if (!option) {
128117
// TODO create custom error object
129118
throw new Error(`Unknown option ${arg}`)
130119
}
131120
const res = option.valueFromArgv([arg, ...argv])
132-
this.args[res.key as keyof Args] = res.value as Args[keyof Args]
121+
console.log("option class name", option.constructor.name)
122+
if (option.isArray) {
123+
this.args[res.key as keyof Args] ??= [] as Args[keyof Args]
124+
const _a = this.args[res.key as keyof Args] as unknown[]
125+
_a.push(res.value) as Args[keyof Args]
126+
} else {
127+
this.args[res.key as keyof Args] = res.value as Args[keyof Args]
128+
}
133129
console.log("option response:", { value: res.value, argv: res.argv })
134130
return res.argv
135131
}
@@ -139,6 +135,22 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
139135
console.log(argv)
140136
return {} as Args
141137
}
138+
139+
helpString(): string {
140+
const options = generateOptionsHelpTable(this.options)
141+
const commands = generateCommandsHelpTable(this.commands)
142+
return [
143+
`${this.name} - ${this.description}`,
144+
commands.length && "",
145+
commands.length && `Commands for ${this.name}:`,
146+
commands.length && commands,
147+
options.length && "",
148+
options.length && `Options for ${this.name}:`,
149+
options.length && options,
150+
]
151+
.filter((s) => typeof s === "string")
152+
.join("\n")
153+
}
142154
}
143155

144156
export { MassargCommand }

src/example.ts

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,84 @@
11
import { massarg } from "."
22
import MassargCommand from "./command"
3+
import { ParseError } from "./error"
34

45
type A = { test: boolean }
56
const args = massarg<A>({
67
name: "my-cli",
78
description: "This is an example CLI",
89
})
9-
.main((opts) => {
10-
console.log("Main command - printing all opts")
11-
console.log(opts)
12-
})
10+
// .main((opts) => {
11+
// console.log("Main command - printing all opts")
12+
// console.log(opts)
13+
// })
1314
.command(
14-
new MassargCommand<A>({
15-
name: "command",
16-
description: "Example command",
17-
aliases: ["c"],
15+
massarg<{ component: string }>({
16+
name: "add",
17+
description: "Add a component",
18+
aliases: ["a"],
19+
run: (opts) => {
20+
console.log("Adding component", opts.component)
21+
},
22+
})
23+
.option({
24+
name: "component",
25+
description: "Component to add",
26+
aliases: ["c"],
27+
// aliases: "" as never,
28+
})
29+
.option({
30+
name: "classes",
31+
description: "Classes to add",
32+
aliases: ["l"],
33+
array: true,
34+
})
35+
.option({
36+
name: "custom",
37+
description: "Custom option",
38+
aliases: ["x"],
39+
parse: (value) => {
40+
const asNumber = Number(value)
41+
if (isNaN(asNumber)) {
42+
throw new ParseError({
43+
path: ["custom"],
44+
message: "Custom option must be a number",
45+
code: "invalid_number",
46+
})
47+
}
48+
return {
49+
value: asNumber,
50+
half: asNumber / 2,
51+
double: asNumber * 2,
52+
}
53+
},
54+
}),
55+
)
56+
.command(
57+
new MassargCommand<{ component: string }>({
58+
name: "remove",
59+
description: "Remove a component",
60+
aliases: ["r"],
1861
run: (opts) => {
19-
console.log("`command` Command - printing all opts")
20-
console.log(opts)
62+
console.log("Removing component", opts.component)
2163
},
2264
}).option({
23-
name: "command-option",
24-
description: "Example command option",
25-
aliases: ["o"],
65+
name: "component",
66+
description: "Component to remove",
67+
aliases: ["c"],
2668
// aliases: "" as never,
2769
}),
2870
)
71+
.option({
72+
name: "bool",
73+
description: "Example number option",
74+
aliases: ["b"],
75+
type: "boolean",
76+
})
2977
.option({
3078
name: "number",
3179
description: "Example number option",
3280
aliases: ["n"],
3381
type: "number",
34-
parse: (s) => parseFloat(s),
3582
})
3683

3784
const opts = args.getArgs(process.argv.slice(2))

src/massarg.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import MassargCommand, { ArgsObject, CommandConfig } from "./command"
22

3-
type MinimalCommandConfig<Args extends ArgsObject> = Omit<CommandConfig<Args>, "aliases" | "run">
3+
type MinimalCommandConfig<Args extends ArgsObject> = Omit<CommandConfig<Args>, "aliases" | "run"> &
4+
Partial<Pick<CommandConfig<Args>, "aliases" | "run">>
45

56
export default class Massarg<Args extends ArgsObject = ArgsObject> extends MassargCommand<Args> {
67
constructor(options: MinimalCommandConfig<Args>) {
78
// TODO consider re-using name and description for general help, and pass them to super
89
super({
9-
...options,
1010
aliases: [],
1111
run: () => {
12-
throw new Error("Massarg is not a command")
12+
console.log(this.helpString())
13+
// throw new Error("No main command provided")
1314
},
15+
...options,
1416
})
1517
}
1618
}

src/option.ts

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,40 @@ export const OptionConfig = <T extends z.ZodType>(type: T) =>
99
defaultValue: z.any().optional(),
1010
aliases: z.string().array(),
1111
parse: z.function().args(z.string()).returns(type).optional(),
12+
array: z.boolean().optional(),
1213
})
1314
export type OptionConfig<T = unknown> = z.infer<ReturnType<typeof OptionConfig<z.ZodType<T>>>>
1415

15-
export type OptionType = "string" | "number" | "boolean"
16+
export const TypedOptionConfig = <T extends z.ZodType>(type: T) =>
17+
OptionConfig(type).merge(
18+
z.object({
19+
type: z.enum(["string", "number", "boolean"]).optional(),
20+
}),
21+
)
22+
export type TypedOptionConfig<T = unknown> = z.infer<ReturnType<typeof TypedOptionConfig<z.ZodType<T>>>>
23+
24+
export const ArrayOptionConfig = <T extends z.ZodType>(type: T) =>
25+
TypedOptionConfig(z.array(type)).merge(
26+
z.object({
27+
defaultValue: z.array(type).optional(),
28+
}),
29+
)
30+
export type ArrayOptionConfig<T = unknown> = z.infer<ReturnType<typeof ArrayOptionConfig<z.ZodType<T>>>>
31+
32+
const OPT_FULL_PREFIX = "--"
33+
const OPT_SHORT_PREFIX = "-"
34+
const NEGATE_FULL_PREFIX = "no-"
35+
const NEGATE_SHORT_PREFIX = "^"
36+
37+
export type ArgvValue<T> = { argv: string[]; value: T; key: string }
1638

1739
export default class MassargOption<T = unknown> {
1840
name: string
1941
description: string
2042
defaultValue?: T
2143
aliases: string[]
2244
parse: (value: string) => T
45+
isArray: boolean
2346

2447
constructor(options: OptionConfig<T>) {
2548
OptionConfig(z.any()).parse(options)
@@ -28,9 +51,20 @@ export default class MassargOption<T = unknown> {
2851
this.defaultValue = options.defaultValue
2952
this.aliases = options.aliases
3053
this.parse = options.parse ?? ((x) => x as unknown as T)
54+
this.isArray = options.array ?? false
55+
}
56+
57+
static fromTypedConfig<T = unknown>(config: TypedOptionConfig<T>): MassargOption<T> {
58+
switch (config.type) {
59+
case "number":
60+
return new MassargNumber(config as OptionConfig<number>) as MassargOption<T>
61+
case "boolean":
62+
return new MassargFlag(config) as MassargOption<T>
63+
}
64+
return new MassargOption(config as OptionConfig<T>)
3165
}
3266

33-
valueFromArgv(argv: string[]): { argv: string[]; value: T; key: string } {
67+
valueFromArgv(argv: string[]): ArgvValue<T> {
3468
// TODO: support --option=value
3569
argv.shift()
3670
try {
@@ -47,6 +81,36 @@ export default class MassargOption<T = unknown> {
4781
throw e
4882
}
4983
}
84+
85+
helpString(): string {
86+
const aliases = this.aliases.length ? `|${this.aliases.join("|-")}` : ""
87+
return `--${this.name}${aliases} ${this.description}`
88+
}
89+
90+
_match(arg: string): boolean {
91+
// full prefix
92+
if (arg.startsWith(OPT_FULL_PREFIX)) {
93+
// negate full prefix
94+
if (arg.startsWith(`--${NEGATE_FULL_PREFIX}`)) {
95+
return this.name === arg.slice(`--${NEGATE_FULL_PREFIX}`.length)
96+
}
97+
return this.name === arg.slice(OPT_FULL_PREFIX.length)
98+
}
99+
// short prefix
100+
if (arg.startsWith(OPT_SHORT_PREFIX) || arg.startsWith(NEGATE_SHORT_PREFIX)) {
101+
return this.aliases.includes(arg.slice(OPT_SHORT_PREFIX.length))
102+
}
103+
// negate short prefix
104+
if (arg.startsWith(NEGATE_SHORT_PREFIX)) {
105+
return this.aliases.includes(arg.slice(NEGATE_SHORT_PREFIX.length))
106+
}
107+
// no prefix
108+
return false
109+
}
110+
111+
_isOption(arg: string): boolean {
112+
return arg.startsWith(OPT_FULL_PREFIX) || arg.startsWith(OPT_SHORT_PREFIX) || arg.startsWith(NEGATE_SHORT_PREFIX)
113+
}
50114
}
51115

52116
export class MassargNumber extends MassargOption<number> {
@@ -57,7 +121,7 @@ export class MassargNumber extends MassargOption<number> {
57121
})
58122
}
59123

60-
valueFromArgv(argv: string[]): { argv: string[]; value: number; key: string } {
124+
valueFromArgv(argv: string[]): ArgvValue<number> {
61125
try {
62126
const { argv: _argv, value } = super.valueFromArgv(argv)
63127
if (isNaN(value)) {
@@ -89,9 +153,9 @@ export class MassargFlag extends MassargOption<boolean> {
89153
})
90154
}
91155

92-
valueFromArgv(argv: string[]): { argv: string[]; value: boolean; key: string } {
156+
valueFromArgv(argv: string[]): ArgvValue<boolean> {
93157
try {
94-
const isNegation = argv[0]?.startsWith("-!")
158+
const isNegation = argv[0]?.startsWith("^")
95159
argv.shift()
96160
if (isNegation) {
97161
return { key: this.name, value: false, argv }

0 commit comments

Comments
 (0)