fix(cli): harden generated completions

This commit is contained in:
Peter Steinberger
2026-05-24 01:04:01 +01:00
parent 459cee5315
commit 308af85991
2 changed files with 94 additions and 13 deletions

View File

@@ -34,6 +34,75 @@ export function getCompletionScript(shell: CompletionShell, program: Command): s
return generateFishCompletion(program);
}
function splitOptionFlags(flags: string): string[] {
return flags.split(/[ ,|]+/u).filter(Boolean);
}
function preferredCompletionFlag(flags: string): string {
const parts = splitOptionFlags(flags);
return parts.find((flag) => flag.startsWith("--")) ?? parts[0] ?? flags;
}
function fishWords(values: readonly string[]): string {
return values.join(" ");
}
function fishOptionFlags(options: Command["options"], wantsValue: boolean): string[] {
return options.flatMap((option) => {
if (Boolean(option.required || option.optional) !== wantsValue) {
return [];
}
return splitOptionFlags(option.flags).filter((flag) => flag.startsWith("-"));
});
}
function generateFishPathHelper(program: Command, rootCmd: string): string {
const rootValueOptions = fishOptionFlags(program.options, true);
const rootBooleanOptions = fishOptionFlags(program.options, false);
return `
function __${rootCmd}_command_path_matches
set -l tokens (commandline -opc)
set -e tokens[1]
set -l root_value_options ${fishWords(rootValueOptions)}
set -l root_boolean_options ${fishWords(rootBooleanOptions)}
set -l command_tokens
set -l skip_next 0
for token in $tokens
if test $skip_next -eq 1
set skip_next 0
continue
end
if test (count $command_tokens) -eq 0
set -l flag (string split -m1 "=" -- $token)[1]
if contains -- $flag $root_boolean_options
continue
end
if contains -- $flag $root_value_options
if not string match -q -- "*=*" $token
set skip_next 1
end
continue
end
end
if string match -q -- "-*" $token
continue
end
set -a command_tokens $token
end
for i in (seq (count $argv))
if test "$command_tokens[$i]" != "$argv[$i]"
return 1
end
end
return 0
end
`;
}
function fishCommandPathCondition(rootCmd: string, parents: readonly string[]): string {
return `__${rootCmd}_command_path_matches ${parents.join(" ")}`;
}
async function writeCompletionCache(params: {
program: Command;
shells: CompletionShell[];
@@ -285,7 +354,7 @@ _${rootCmd}_completion() {
prev="\${COMP_WORDS[COMP_CWORD-1]}"
# Simple top-level completion for now
opts="${program.commands.map((c) => c.name()).join(" ")} ${program.options.map((o) => o.flags.split(" ")[0]).join(" ")}"
opts="${program.commands.map((c) => c.name()).join(" ")} ${program.options.map((o) => preferredCompletionFlag(o.flags)).join(" ")}"
case "\${prev}" in
${program.commands.map((cmd) => generateBashSubcommand(cmd)).join("\n ")}
@@ -307,7 +376,7 @@ function generateBashSubcommand(cmd: Command): string {
// This is a naive implementation; fully recursive bash completion is complex to generate as a single string without improved state tracking.
// For now, let's provide top-level command recognition.
return `${cmd.name()})
opts="${cmd.commands.map((c) => c.name()).join(" ")} ${cmd.options.map((o) => o.flags.split(" ")[0]).join(" ")}"
opts="${cmd.commands.map((c) => c.name()).join(" ")} ${cmd.options.map((o) => preferredCompletionFlag(o.flags)).join(" ")}"
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
return 0
;;`;
@@ -322,7 +391,7 @@ function generatePowerShellCompletion(program: Command): string {
// Command completion for this level
const subCommands = cmd.commands.map((c) => c.name());
const options = cmd.options.map((o) => o.flags.split(/[ ,|]+/)[0]); // Take first flag
const options = cmd.options.map((o) => preferredCompletionFlag(o.flags));
const allCompletions = [...subCommands, ...options].map((s) => `'${s}'`).join(",");
if (fullPath.length > 0 && allCompletions.length > 0) {
@@ -363,7 +432,7 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
# Root command
if ($commandPath -eq "") {
$completions = @(${program.commands.map((c) => `'${c.name()}'`).join(",")}, ${program.options.map((o) => `'${o.flags.split(" ")[0]}'`).join(",")})
$completions = @(${program.commands.map((c) => `'${c.name()}'`).join(",")}, ${program.options.map((o) => `'${preferredCompletionFlag(o.flags)}'`).join(",")})
$completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_)
}
@@ -376,11 +445,9 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
function generateFishCompletion(program: Command): string {
const rootCmd = program.name();
const segments: string[] = [];
const segments: string[] = [generateFishPathHelper(program, rootCmd)];
const visit = (cmd: Command, parents: string[]) => {
const cmdName = cmd.name();
// Root logic
if (parents.length === 0) {
// Subcommands of root
@@ -406,12 +473,13 @@ function generateFishCompletion(program: Command): string {
);
}
} else {
const condition = fishCommandPathCondition(rootCmd, parents);
// Subcommands
for (const sub of cmd.commands) {
segments.push(
buildFishSubcommandCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
condition,
name: sub.name(),
description: sub.description(),
}),
@@ -422,7 +490,7 @@ function generateFishCompletion(program: Command): string {
segments.push(
buildFishOptionCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
condition,
flags: opt.flags,
description: opt.description,
}),
@@ -431,7 +499,7 @@ function generateFishCompletion(program: Command): string {
}
for (const sub of cmd.commands) {
visit(sub, [...parents, cmdName]);
visit(sub, parents.length === 0 ? [sub.name()] : [...parents, sub.name()]);
}
};