Skip to content

Commit 4f38877

Browse files
authored
feat: add docopts (#188)
* feat: add docopts * chore: fix comment
1 parent 9ad792a commit 4f38877

9 files changed

Lines changed: 473 additions & 27 deletions

File tree

src/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,8 @@ export async function toCached(c: Command.Class, plugin?: IPlugin): Promise<Comm
496496
helpLabel: flag.helpLabel,
497497
helpGroup: flag.helpGroup,
498498
allowNo: flag.allowNo,
499+
dependsOn: flag.dependsOn,
500+
exclusive: flag.exclusive,
499501
}
500502
} else {
501503
flags[name] = {
@@ -511,6 +513,8 @@ export async function toCached(c: Command.Class, plugin?: IPlugin): Promise<Comm
511513
helpGroup: flag.helpGroup,
512514
multiple: flag.multiple,
513515
options: flag.options,
516+
dependsOn: flag.dependsOn,
517+
exclusive: flag.exclusive,
514518
// eslint-disable-next-line no-await-in-loop
515519
default: typeof flag.default === 'function' ? await flag.default({options: {}, flags: {}}) : flag.default,
516520
}

src/help/command.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {castArray, compact, sortBy} from '../util'
55
import * as Interfaces from '../interfaces'
66
import {Example} from '../interfaces/command'
77
import {HelpFormatter} from './formatter'
8+
import {DocOpts} from './docopts'
89

910
// Don't use os.EOL because we need to ensure that a string
1011
// written on any platform, that may use \r\n or \n, will be
@@ -75,7 +76,7 @@ export class CommandHelp extends HelpFormatter {
7576
return [
7677
{
7778
header: this.opts.usageHeader || 'USAGE',
78-
generate: ({flags}) => this.usage(flags),
79+
generate: () => this.usage(),
7980
},
8081
{
8182
header: 'ARGUMENTS',
@@ -121,15 +122,28 @@ export class CommandHelp extends HelpFormatter {
121122
]
122123
}
123124

124-
protected usage(flags: Interfaces.Command.Flag[]): string {
125+
protected usage(): string {
125126
const usage = this.command.usage
126-
const body = (usage ? castArray(usage) : [this.defaultUsage(flags)])
127-
.map(u => `$ ${this.config.bin} ${u}`.trim())
127+
const body = (usage ? castArray(usage) : [this.defaultUsage()])
128+
.map(u => {
129+
const allowedSpacing = this.opts.maxWidth - this.indentSpacing
130+
const line = `$ ${this.config.bin} ${u}`.trim()
131+
if (line.length > allowedSpacing) {
132+
const splitIndex = line.substring(0, allowedSpacing).lastIndexOf(' ')
133+
return line.substring(0, splitIndex) + '\n' +
134+
this.indent(this.wrap(line.substring(splitIndex), this.indentSpacing * 2))
135+
}
136+
return this.wrap(line)
137+
})
128138
.join('\n')
129-
return this.wrap(body)
139+
return body
130140
}
131141

132-
protected defaultUsage(_: Interfaces.Command.Flag[]): string {
142+
protected defaultUsage(): string {
143+
// Docopts by default
144+
if (this.opts.docopts === undefined || this.opts.docopts) {
145+
return DocOpts.generate(this.command)
146+
}
133147
return compact([
134148
this.command.id,
135149
this.command.args.filter(a => !a.hidden).map(a => this.arg(a)).join(' '),

src/help/docopts.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import {Interfaces} from '..'
2+
3+
type Flag = Interfaces.Command.Flag
4+
type Flags = Flag[]
5+
6+
/**
7+
* DocOpts - See http://docopt.org/.
8+
*
9+
* flag.exclusive: groups elements when one of the mutually exclusive cases is a required flag: (--apple | --orange)
10+
* flag.exclusive: groups elements when none of the mutually exclusive cases is required (optional flags): [--apple | --orange]
11+
* flag.dependsOn: specifies that if one element is present, then another one is required: (--apple --orange)
12+
*
13+
* @example
14+
* {
15+
* name: 'classnames',
16+
* required: true,
17+
* exclusive: ['suitenames']
18+
* ...
19+
* },{
20+
* name: 'suitenames',
21+
* type: 'array'
22+
* required: true
23+
* ...
24+
* }
25+
*
26+
* Results in:
27+
* Usage: <%= command.id %> (-n <string> | -s <array>)
28+
*
29+
* @example
30+
* {
31+
* name: 'classnames',
32+
* ...
33+
* excludes: ['suitenames']
34+
* },{
35+
* name: 'suitenames',
36+
* ...
37+
* }
38+
*
39+
* Results in:
40+
* Usage: <%= command.id %> [-n <string> | -s <string>]
41+
*
42+
* @example
43+
* {
44+
* name: 'classnames',
45+
* ...
46+
* dependsOn: ['suitenames']
47+
* },{
48+
* name: 'suitenames',
49+
* type: 'flag'
50+
* ...
51+
* }
52+
*
53+
* Results in:
54+
* Usage: <%= command.id %> (-n <string> -s)
55+
*
56+
* TODO:
57+
* - Support nesting, eg:
58+
* Usage: my_program (--either-this <and-that> | <or-this>)
59+
* Usage: my_program [(<one-argument> <another-argument>)]
60+
*
61+
*/
62+
export class DocOpts {
63+
private flagMap: {[index: string]: Flag}
64+
65+
private flagList: Flags
66+
67+
public constructor(private cmd: Interfaces.Command) {
68+
// Create a new map with references to the flags that we can manipulate.
69+
this.flagMap = {}
70+
this.flagList = Object.entries(cmd.flags || {})
71+
.filter(([_, flag]) => !flag.hidden)
72+
.map(([name, flag]) => {
73+
this.flagMap[name] = flag
74+
return flag
75+
})
76+
}
77+
78+
public static generate(cmd: Interfaces.Command): string {
79+
return new DocOpts(cmd).toString()
80+
}
81+
82+
public toString(): string {
83+
const opts = ['<%= command.id %>']
84+
if (this.cmd.args) {
85+
opts.push(...this.cmd.args?.map(arg => `[${arg.name.toUpperCase()}]`))
86+
}
87+
try {
88+
opts.push(...Object.values(this.groupFlagElements()))
89+
} catch (error) {
90+
// If there is an error, just return no usage so we don't fail command help.
91+
opts.push(...this.flagList.map(flag => {
92+
const name = flag.char ? `-${flag.char}` : `--${flag.name}`
93+
if (flag.type === 'boolean') return name
94+
return `${name}=<value>`
95+
}))
96+
}
97+
return opts.join(' ')
98+
}
99+
100+
private groupFlagElements(): {[index: string]: string} {
101+
const elementMap: {[index: string]: string} = {}
102+
103+
// Generate all doc opt elements for combining
104+
// Show required flags first
105+
this.generateElements(elementMap, this.flagList.filter(flag => flag.required))
106+
// Then show optional flags
107+
this.generateElements(elementMap, this.flagList.filter(flag => !flag.required))
108+
109+
for (const flag of this.flagList) {
110+
if (Array.isArray(flag.dependsOn)) {
111+
this.combineElementsToFlag(elementMap, flag.name, flag.dependsOn, ' ')
112+
}
113+
114+
if (Array.isArray(flag.exclusive)) {
115+
this.combineElementsToFlag(elementMap, flag.name, flag.exclusive, ' | ')
116+
}
117+
}
118+
119+
// Since combineElementsToFlag deletes the references in this.flags when it combines
120+
// them, this will go through the remaining list of uncombined elements.
121+
for (const remainingFlagName of Object.keys(this.flagMap)) {
122+
const remainingFlag = this.flagMap[remainingFlagName] || {}
123+
124+
if (!remainingFlag.required) {
125+
elementMap[remainingFlag.name] = `[${elementMap[remainingFlag.name] || ''}]`
126+
}
127+
}
128+
return elementMap
129+
}
130+
131+
private combineElementsToFlag(
132+
elementMap: {[index: string]: string},
133+
flagName: string,
134+
flagNames: string[],
135+
unionString: string,
136+
): void {
137+
if (!this.flagMap[flagName]) {
138+
return
139+
}
140+
let isRequired = this.flagMap[flagName]?.required
141+
if (typeof isRequired !== 'boolean' || !isRequired) {
142+
isRequired = flagNames.reduce(
143+
(required: boolean, toCombine) => required || this.flagMap[toCombine]?.required || false,
144+
false,
145+
)
146+
}
147+
148+
for (const toCombine of flagNames) {
149+
elementMap[flagName] = `${elementMap[flagName] || ''}${unionString}${elementMap[toCombine] || ''}`
150+
// We handled this flag, don't handle it again
151+
delete elementMap[toCombine]
152+
delete this.flagMap[toCombine]
153+
}
154+
if (isRequired) {
155+
elementMap[flagName] = `(${elementMap[flagName] || ''})`
156+
} else {
157+
elementMap[flagName] = `[${elementMap[flagName] || ''}]`
158+
}
159+
// We handled this flag, don't handle it again
160+
delete this.flagMap[flagName]
161+
}
162+
163+
private generateElements(elementMap: {[index: string]: string} = {}, flagGroups: Flags): string[] {
164+
const elementStrs = []
165+
for (const flag of flagGroups) {
166+
let type = ''
167+
// not all flags have short names
168+
const flagName = flag.char ? `-${flag.char}` : `--${flag.name}`
169+
if (flag.type === 'option') {
170+
type = flag.options ? ` ${flag.options.join('|')}` : ' <value>'
171+
}
172+
const element = `${flagName}${type}`
173+
elementMap[flag.name] = element
174+
elementStrs.push(element)
175+
}
176+
return elementStrs
177+
}
178+
}

src/interfaces/help.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,8 @@ export interface HelpOptions {
2828
* http://www.gnu.org/software/help2man/#--help-recommendations
2929
*/
3030
usageHeader?: string;
31+
/**
32+
* Use docopts as the usage. Defaults to true.
33+
*/
34+
docopts?: boolean;
3135
}

src/interfaces/parser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ export type FlagProps = {
109109
helpGroup?: string;
110110
hidden?: boolean;
111111
required?: boolean;
112+
dependsOn?: string[];
113+
exclusive?: string[];
112114
}
113115

114116
export type BooleanFlagProps = FlagProps & {
@@ -124,8 +126,6 @@ export type OptionFlagProps = FlagProps & {
124126
}
125127

126128
export type FlagBase<T, I> = FlagProps & {
127-
dependsOn?: string[];
128-
exclusive?: string[];
129129
exactlyOne?: string[];
130130
/**
131131
* also accept an environment variable as input

0 commit comments

Comments
 (0)