Skip to content

Commit 0b17487

Browse files
committed
fix(help): align option descriptions across varying flag widths
Calculate max flag width across all options before formatting, ensuring descriptions start at the same column regardless of individual flag lengths.
1 parent 23c097b commit 0b17487

3 files changed

Lines changed: 73 additions & 28 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ my-cli --completion-script fish > ~/.config/fish/completions/my-cli.fish
685685

686686
### What Gets Completed
687687

688-
Once installed, pressing <kbd>Tab</kbd> will complete:
688+
Once installed, pressing `Tab` will complete:
689689

690690
- **Commands and subcommands** (including nested commands and aliases)
691691
- **Options** (`--verbose`, `-v`, `--no-verbose` for booleans)

examples/completion.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ const lintParser = opt.options({
107107
// CLI
108108
// ═══════════════════════════════════════════════════════════════════════════════
109109

110-
await bargs('completion-demo', {
110+
await bargs('completion', {
111111
// Enable shell completion support!
112112
completion: true,
113113
description: 'Example CLI with shell completion support',

src/help.ts

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -186,25 +186,12 @@ const getTypeLabel = (def: OptionDef): string => {
186186
};
187187

188188
/**
189-
* Format a single option for help output.
190-
*
191-
* For boolean options with `default: true`, shows `--no-<name>` instead of
192-
* `--<name>` since that's how users would turn it off.
193-
*
194-
* Displays aliases in order: short alias first (-v), then multi-char aliases
195-
* sorted by length (--verb), then the canonical name (--verbose).
189+
* Get the flag text for an option (used for width calculation and display).
196190
*
197191
* @function
198192
*/
199-
const formatOptionHelp = (
200-
name: string,
201-
def: OptionDef,
202-
styler: Styler,
203-
): string => {
204-
const parts: string[] = [];
205-
193+
const getOptionFlagText = (name: string, def: OptionDef): string => {
206194
// For boolean options with default: true, show --no-<name>
207-
// since that's how users would turn it off
208195
const displayName =
209196
def.type === 'boolean' && def.default === true ? `no-${name}` : name;
210197

@@ -215,7 +202,6 @@ const formatOptionHelp = (
215202
.sort((a, b) => a.length - b.length);
216203

217204
// Build flag string: -v, --verb, --verbose
218-
// Don't show short alias for negated booleans
219205
const flagParts: string[] = [];
220206
if (shortAlias && displayName === name) {
221207
flagParts.push(`-${shortAlias}`);
@@ -226,14 +212,51 @@ const formatOptionHelp = (
226212
flagParts.push(`--${displayName}`);
227213

228214
// If no short alias and no long aliases, add padding
229-
const flagText =
230-
flagParts.length === 1 && !shortAlias
231-
? ` ${flagParts[0]}`
232-
: flagParts.join(', ');
215+
return flagParts.length === 1 && !shortAlias
216+
? ` ${flagParts[0]}`
217+
: flagParts.join(', ');
218+
};
219+
220+
/**
221+
* Calculate the max flag width for a set of options.
222+
*
223+
* @function
224+
*/
225+
const calculateMaxFlagWidth = (
226+
options: Array<{ def: OptionDef; name: string }>,
227+
): number => {
228+
let maxWidth = 0;
229+
for (const { def, name } of options) {
230+
const flagText = getOptionFlagText(name, def);
231+
maxWidth = Math.max(maxWidth, flagText.length);
232+
}
233+
return maxWidth;
234+
};
235+
236+
/**
237+
* Format a single option for help output.
238+
*
239+
* For boolean options with `default: true`, shows `--no-<name>` instead of
240+
* `--<name>` since that's how users would turn it off.
241+
*
242+
* Displays aliases in order: short alias first (-v), then multi-char aliases
243+
* sorted by length (--verb), then the canonical name (--verbose).
244+
*
245+
* @function
246+
*/
247+
const formatOptionHelp = (
248+
name: string,
249+
def: OptionDef,
250+
styler: Styler,
251+
maxFlagWidth?: number,
252+
): string => {
253+
const parts: string[] = [];
254+
255+
const flagText = getOptionFlagText(name, def);
233256
parts.push(` ${styler.flag(flagText)}`);
234257

235-
// Pad to align descriptions (increase base padding for longer alias chains)
236-
const basePadding = Math.max(24, flagText.length + 4);
258+
// Pad to align descriptions using provided maxFlagWidth or calculate dynamically
259+
const basePadding = Math.max(24, (maxFlagWidth ?? flagText.length) + 4);
237260
const padding = Math.max(0, basePadding - flagText.length - 2);
238261
parts.push(' '.repeat(padding));
239262

@@ -354,11 +377,15 @@ export const generateHelp = (
354377
}
355378
}
356379

380+
// Calculate max flag width across all visible options for alignment
381+
const allOptions = [...ungrouped, ...Array.from(groups.values()).flat()];
382+
const maxFlagWidth = calculateMaxFlagWidth(allOptions);
383+
357384
// Print grouped options
358385
for (const [groupName, options] of Array.from(groups.entries())) {
359386
lines.push(styler.sectionHeader(groupName.toUpperCase()));
360387
for (const opt of options) {
361-
lines.push(formatOptionHelp(opt.name, opt.def, styler));
388+
lines.push(formatOptionHelp(opt.name, opt.def, styler, maxFlagWidth));
362389
}
363390
lines.push('');
364391
}
@@ -368,7 +395,7 @@ export const generateHelp = (
368395
const label = hasCommands(config) ? 'GLOBAL OPTIONS' : 'OPTIONS';
369396
lines.push(styler.sectionHeader(label));
370397
for (const opt of ungrouped) {
371-
lines.push(formatOptionHelp(opt.name, opt.def, styler));
398+
lines.push(formatOptionHelp(opt.name, opt.def, styler, maxFlagWidth));
372399
}
373400
lines.push('');
374401
}
@@ -451,14 +478,32 @@ export const generateCommandHelp = (
451478
lines.push(styler.usage(` ${usageParts}`));
452479
lines.push('');
453480

481+
// Collect all visible options for alignment calculation
482+
const allOptions: Array<{ def: OptionDef; name: string }> = [];
483+
if (command.options) {
484+
for (const [name, def] of Object.entries(command.options)) {
485+
if (!def.hidden) {
486+
allOptions.push({ def, name });
487+
}
488+
}
489+
}
490+
if (config.options) {
491+
for (const [name, def] of Object.entries(config.options)) {
492+
if (!def.hidden) {
493+
allOptions.push({ def, name });
494+
}
495+
}
496+
}
497+
const maxFlagWidth = calculateMaxFlagWidth(allOptions);
498+
454499
// Command options
455500
if (command.options && Object.keys(command.options).length > 0) {
456501
lines.push(styler.sectionHeader('OPTIONS'));
457502
for (const [name, def] of Object.entries(command.options)) {
458503
if (def.hidden) {
459504
continue;
460505
}
461-
lines.push(formatOptionHelp(name, def, styler));
506+
lines.push(formatOptionHelp(name, def, styler, maxFlagWidth));
462507
}
463508
lines.push('');
464509
}
@@ -470,7 +515,7 @@ export const generateCommandHelp = (
470515
if (def.hidden) {
471516
continue;
472517
}
473-
lines.push(formatOptionHelp(name, def, styler));
518+
lines.push(formatOptionHelp(name, def, styler, maxFlagWidth));
474519
}
475520
lines.push('');
476521
}

0 commit comments

Comments
 (0)