instead of 5 slightly different calls to RESTHandler::usage_str this introduces a wrapper function that handles all required cases and is capable of resolving sub-commands and aliases. Adds a subroutine to print the short help for a command in case no subcommand was given. Modifies handle_cmd and print_bash_completion to allow for parsing of subcommands and aliases. --- History: v1: sub-commands for unified help-/documentation-generator v2: + aliases for unified help-/documentation-generator v3: + sub-commands and aliases for bash-completion. documentation v4: + more documentation, some patches moved from 2/3, restructuring
src/PVE/CLIHandler.pm | 344 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 247 insertions(+), 97 deletions(-) diff --git a/src/PVE/CLIHandler.pm b/src/PVE/CLIHandler.pm index e61fa6a..84cefa3 100644 --- a/src/PVE/CLIHandler.pm +++ b/src/PVE/CLIHandler.pm @@ -11,6 +11,50 @@ use PVE::INotify; use base qw(PVE::RESTHandler); +# $cmddef is usually a hash of hashes and arrays. The keys are the +# commands to enter, the values define the handling of those commands. +# +# Each array defines a command that can be executed in the format: +# +# [class, method name, parameters, URI parameters, output worker] +# +# Where 'class' is the class being called, 'method name' is the name +# of the method being called, 'parameters' is an array of arguments +# passed to that method, 'URI parameters' is a hash of arguments +# passed to the API and 'output worker' is a subroutine handling what +# the called method returns. +# +# Each hash defines a cmddef again, allowing for arbitrarily deeply +# nested sub-commands. +# +# In case of 'simple commands' the $cmddef can be an array. +# +# Examples: +# $cmddef = { +# command => [ 'PVE::API2::Class', 'command', [ 'arg1', 'arg2' ], { node => $nodename } ], +# do => { +# this => [ 'PVE::API2::OtherClass', 'method', [ 'arg1' ], undef, sub { +# my ($res) = @_; +# print "$res\n"; +# }], +# that => [ 'PVE::API2::OtherClass', 'subroutine', [], undef, sub { +# my ($res) = @_; +# print "$res\n"; +# }], +# }, +# } +# +# If given for PVE::CLI::cliexe this defines the following commands: +# cliexe command <arg1> <arg2> [OPTIONS] +# cliexe do this <arg1> [OPTIONS] +# cliexe do that [OPTIONS] +# +# $cmddef = [ 'PVE::API2::Example', 'method', [ 'arg1' ] ] +# +# For PVE::CLI::clicmd this defines +# clicmd <arg1> [OPTIONS] +# +# The available OPTIONS are defined by the method. my $cmddef; my $exename; my $cli_handler_class; @@ -48,6 +92,76 @@ my $complete_command_names = sub { return $res; }; +my $generate_usage_str; +$generate_usage_str = sub { + my ($args) = @_; + my ($format, $cmd, $indent, $separator, $sortfunc, $base, $prefix) = @_; + die 'not initialized' if !($cmddef && $exename && $cli_handler_class); + die 'format required' if !$format; + + # Set the defaults + $sortfunc //= sub { + my ($hash) = @_; + return sort keys %$hash; + }; + $base //= $cmddef; + $prefix //= $exename; + if (defined($cmd)) { + # Follow alias if necessary + $cmd = $cmddef->{$cmd}->{alias} // $cmd if (ref($cmddef->{$cmd}) eq 'HASH'); + # Set base accordingly + $prefix .= " $cmd"; + my @cmds = split(/ +/, $cmd); + while (@cmds) { + $base = $base->{shift @cmds}; + } + } + $separator //= ''; + $indent //= ''; + + my $str = ''; + if (ref($base) eq 'HASH') { + my $oldclass = undef; + foreach my $cmd (&$sortfunc($base)) { + if (ref($base->{$cmd}) eq 'ARRAY') { + # $cmd is an array, so it's an actual command + my ($class, $name, $arg_param, $fixed_param) = @{$base->{$cmd}}; + $str .= $separator if $oldclass && $oldclass ne $class; + $str .= $indent; + $str .= $class->usage_str($name, "$prefix $cmd", $arg_param, $fixed_param, $format, + $cli_handler_class->can('read_password'), + $cli_handler_class->can('string_param_file_mapping')); + $oldclass = $class; + } elsif (defined($base->{$cmd}->{alias}) && ($format eq 'asciidoc')) { + # Handle asciidoc separately + $str .= "*$prefix $cmd*\n\nAn alias for '$exename $base->{$cmd}->{alias}'.\n\n"; + } else { + # $cmd has sub-commands or is an alias + next if $base->{$cmd}->{alias}; + my $substr = $generate_usage_str->($format, $cmd, $indent, $separator, $sortfunc, $base, $prefix); + if ($substr) { + $substr .= $separator if $substr !~ /$separator{2}/; + $str .= $substr; + } + } + } + } else { + # Handle simple commands + my ($class, $name, $arg_param, $fixed_param) = @{$base || []}; + + if (!$class) { + print_usage_short (\*STDERR, "unknown command '" . join(' ', $cmd) . "'"); + exit (-1); + } + + $str .= $indent; + $str .= $class->usage_str($name, $prefix, $arg_param, $fixed_param, $format, + $cli_handler_class->can('read_password'), + $cli_handler_class->can('string_param_file_mapping')); + } + return $str; +}; + __PACKAGE__->register_method ({ name => 'help', path => 'help', @@ -56,9 +170,9 @@ __PACKAGE__->register_method ({ parameters => { additionalProperties => 0, properties => { - cmd => { - description => "Command name", - type => 'string', + 'extra-args' => { + type => 'array', + items => { type => 'string' }, optional => 1, completion => $complete_command_names, }, @@ -76,12 +190,12 @@ __PACKAGE__->register_method ({ die "not initialized" if !($cmddef && $exename && $cli_handler_class); - my $cmd = $param->{cmd}; + my @cmds = @{$param->{'extra-args'} // []}; - my $verbose = defined($cmd) && $cmd; + my $verbose = @cmds; $verbose = $param->{verbose} if defined($param->{verbose}); - if (!$cmd) { + if (!@cmds) { if ($verbose) { print_usage_verbose(); } else { @@ -90,18 +204,19 @@ __PACKAGE__->register_method ({ return undef; } - $cmd = &$expand_command_name($cmddef, $cmd); - - my ($class, $name, $arg_param, $uri_param) = @{$cmddef->{$cmd} || []}; - - raise_param_exc({ cmd => "no such command '$cmd'"}) if !$class; + my $base = $cmddef; + my @newcmd; + while (scalar(@cmds) > 0) { + # Auto-complete command + last if (ref($base) eq 'ARRAY'); + push @newcmd, &$expand_command_name($base, shift @cmds); + $base = $base->{$newcmd[-1]}; + } + my $cmd = join(' ', @newcmd); - my $pwcallback = $cli_handler_class->can('read_password'); - my $stringfilemap = $cli_handler_class->can('string_param_file_mapping'); + my $str = &$generate_usage_str($verbose ? 'full' : 'short', $cmd, $verbose ? '' : ' ' x 7); + $str =~ s/^\s+//; - my $str = $class->usage_str($name, "$exename $cmd", $arg_param, $uri_param, - $verbose ? 'full' : 'short', $pwcallback, - $stringfilemap); if ($verbose) { print "$str\n"; } else { @@ -113,17 +228,10 @@ __PACKAGE__->register_method ({ }}); sub print_simple_asciidoc_synopsis { - my ($class, $name, $arg_param, $uri_param) = @_; - die "not initialized" if !$cli_handler_class; - my $pwcallback = $cli_handler_class->can('read_password'); - my $stringfilemap = $cli_handler_class->can('string_param_file_mapping'); - - my $synopsis = "*${name}* `help`\n\n"; - - $synopsis .= $class->usage_str($name, $name, $arg_param, $uri_param, - 'asciidoc', $pwcallback, $stringfilemap); + my $synopsis = "*${exename}* `help`\n\n"; + $synopsis .= &$generate_usage_str('asciidoc'); return $synopsis; } @@ -132,24 +240,11 @@ sub print_asciidoc_synopsis { die "not initialized" if !($cmddef && $exename && $cli_handler_class); - my $pwcallback = $cli_handler_class->can('read_password'); - my $stringfilemap = $cli_handler_class->can('string_param_file_mapping'); - my $synopsis = ""; $synopsis .= "*${exename}* `<COMMAND> [ARGS] [OPTIONS]`\n\n"; - my $oldclass; - foreach my $cmd (sort keys %$cmddef) { - my ($class, $name, $arg_param, $uri_param) = @{$cmddef->{$cmd}}; - my $str = $class->usage_str($name, "$exename $cmd", $arg_param, - $uri_param, 'asciidoc', $pwcallback, - $stringfilemap); - $synopsis .= "\n" if $oldclass && $oldclass ne $class; - - $synopsis .= "$str\n\n"; - $oldclass = $class; - } + $synopsis .= &$generate_usage_str('asciidoc'); $synopsis .= "\n"; @@ -160,21 +255,11 @@ sub print_usage_verbose { die "not initialized" if !($cmddef && $exename && $cli_handler_class); - my $pwcallback = $cli_handler_class->can('read_password'); - my $stringfilemap = $cli_handler_class->can('string_param_file_mapping'); - print "USAGE: $exename <COMMAND> [ARGS] [OPTIONS]\n\n"; - foreach my $cmd (sort keys %$cmddef) { - my ($class, $name, $arg_param, $uri_param) = @{$cmddef->{$cmd}}; - my $str = $class->usage_str($name, "$exename $cmd", $arg_param, $uri_param, - 'full', $pwcallback, $stringfilemap); - print "$str\n\n"; - } -} + my $str = &$generate_usage_str('full'); -sub sorted_commands { - return sort { ($cmddef->{$a}->[0] cmp $cmddef->{$b}->[0]) || ($a cmp $b)} keys %$cmddef; + print "$str\n"; } sub print_usage_short { @@ -182,22 +267,49 @@ sub print_usage_short { die "not initialized" if !($cmddef && $exename && $cli_handler_class); - my $pwcallback = $cli_handler_class->can('read_password'); - my $stringfilemap = $cli_handler_class->can('string_param_file_mapping'); - print $fd "ERROR: $msg\n" if $msg; print $fd "USAGE: $exename <COMMAND> [ARGS] [OPTIONS]\n"; - my $oldclass; - foreach my $cmd (sorted_commands()) { - my ($class, $name, $arg_param, $uri_param) = @{$cmddef->{$cmd}}; - my $str = $class->usage_str($name, "$exename $cmd", $arg_param, $uri_param, 'short', $pwcallback, $stringfilemap); - print $fd "\n" if $oldclass && $oldclass ne $class; - print $fd " $str"; - $oldclass = $class; - } + print {$fd} &$generate_usage_str('short', undef, ' ' x 7, "\n", + sub { + my ($hash) = @_; + return sort { + if ((ref($hash->{$a}) eq 'ARRAY' && ref($hash->{$b}) eq 'ARRAY') && + ($hash->{$a}->[0] ne $hash->{$b}->[0])) { + # If $a and $b are both arrays (commands) and the commands are not in + # the same class, order their classes alphabetically + return $hash->{$a}->[0] cmp $hash->{$b}->[0]; + } elsif (ref($hash->{$a}) eq 'ARRAY' xor ref($hash->{$b}) eq 'ARRAY') { + # If one is an array (command) and one is a hash (has subcommands), + # sort commands behind sub.commands + return ref($hash->{$b}) eq 'ARRAY' ? -1 : 1; + } else { + # If $a and $b are both commands of the same class or both sub-commands, + # sort alphabetically + return $a cmp $b; + } + } keys %$hash; + }); } +my $print_help_short = sub { + my ($fd, $cmd, $msg) = @_; + + die "not initialized" if !($cmddef); + + print $fd "ERROR: $msg\n" if $msg; + + my $base = $cmddef; + while (scalar(@$cmd) > 1) { + $base = $base->{shift @$cmd}; + } + + my $str = &$generate_usage_str('short', $cmd->[0], ' ' x 7, undef, undef, $base); + $str =~ s/^\s+//; + + print {$fd} "USAGE: $str\n"; +}; + my $print_bash_completion = sub { my ($cmddef, $simple_cmd, $bash_command, $cur, $prev) = @_; @@ -225,17 +337,40 @@ my $print_bash_completion = sub { }; my $cmd; + my $def = $cmddef; + my $cmd_depth = 0; + if (scalar(@$args) > 1) { + for my $i (1 .. $#$args) { + last if (ref($def) eq 'ARRAY'); + if (@$args[$i] ne $cur && exists $def->{@$args[$i]}) { + # Move def to proper sub-command-def + # Don't try yet-to-complete commands + # exists… prevents auto-vivification + $def = $def->{@$args[$i]}; + $cmd_depth++; + } + } + } if ($simple_cmd) { $cmd = $simple_cmd; + $def = $def->{$simple_cmd}; } else { - if ($pos == 0) { - &$print_result(keys %$cmddef); - return; + if (ref($def) eq 'HASH') { + if (exists $def->{alias}) { + # Move def to aliased command + my $newdef = $cmddef; + foreach my $subcmd (split(/ /, $def->{alias})) { + $newdef = $newdef->{$subcmd}; + } + $def = $newdef; + } else { + &$print_result(keys %$def); + return; + } } - $cmd = $args->[1]; + $cmd = @$args[-1]; } - my $def = $cmddef->{$cmd}; return if !$def; print STDERR "CMDLINE1:$pos:$cmdline\n" if $debug; @@ -251,12 +386,11 @@ my $print_bash_completion = sub { map { $skip_param->{$_} = 1; } @$arg_param; map { $skip_param->{$_} = 1; } keys %$uri_param; - my $fpcount = scalar(@$arg_param); + my $fpcount = scalar(@$arg_param) + $cmd_depth - 1; my $info = $class->map_method_by_name($name); - my $schema = $info->{parameters}; - my $prop = $schema->{properties}; + my $prop = $info->{parameters}->{properties}; my $print_parameter_completion = sub { my ($pname) = @_; @@ -277,7 +411,7 @@ my $print_bash_completion = sub { # positional arguments $pos += 1 if $simple_cmd; if ($fpcount && $pos <= $fpcount) { - my $pname = $arg_param->[$pos -1]; + my $pname = $arg_param->[$pos - $cmd_depth]; &$print_parameter_completion($pname); return; } @@ -375,12 +509,11 @@ sub generate_asciidoc_synopsis { no strict 'refs'; my $def = ${"${class}::cmddef"}; + $cmddef = $def; if (ref($def) eq 'ARRAY') { print_simple_asciidoc_synopsis(@$def); } else { - $cmddef = $def; - $cmddef->{help} = [ __PACKAGE__, 'help', ['cmd'] ]; print_asciidoc_synopsis(); @@ -395,58 +528,77 @@ sub setup_environment { } my $handle_cmd = sub { - my ($def, $cmdname, $cmd, $args, $pwcallback, $preparefunc, $stringfilemap) = @_; - - $cmddef = $def; - $exename = $cmdname; - - $cmddef->{help} = [ __PACKAGE__, 'help', ['cmd'] ]; + my ($args, $pwcallback, $preparefunc, $stringfilemap) = @_; + + $cmddef->{help} = [ __PACKAGE__, 'help', ['extra-args'] ]; + + my @cmd; + my $base = $cmddef; + while (scalar(@$args) > 0) { + last if (ref($base) eq 'ARRAY'); + # Auto-complete commands + push @cmd, &$expand_command_name($base, shift @$args); + $base = $base->{$cmd[-1]}; + if (ref($base) eq 'HASH' && defined($base->{alias})) { + # If command is an alias, reset $base and move to aliased command + my @alias = split(/ +/, $base->{alias}); + $base = $cmddef; + undef(@cmd); + while (@alias) { + unshift @$args, @alias; + } + } + } # call verifyapi before setup_environment(), because we do not want to # execute any real code in this case - if (!$cmd) { + if (!defined($cmd[0])) { print_usage_short (\*STDERR, "no command specified"); exit (-1); - } elsif ($cmd eq 'verifyapi') { + } elsif ($cmd[0] eq 'verifyapi') { PVE::RESTHandler::validate_method_schemas(); return; } $cli_handler_class->setup_environment(); - if ($cmd eq 'bashcomplete') { - &$print_bash_completion($cmddef, 0, @$args); + if ($cmd[0] eq 'bashcomplete') { + shift @cmd; + &$print_bash_completion($cmddef, 0, @cmd); return; } &$preparefunc() if $preparefunc; - $cmd = &$expand_command_name($cmddef, $cmd); + if (ref($base) eq 'HASH') { + &$print_help_short (\*STDERR, \@cmd, "incomplete command '" . join(' ', @cmd) . "'"); + exit (-1); + } - my ($class, $name, $arg_param, $uri_param, $outsub) = @{$cmddef->{$cmd} || []}; + my ($class, $name, $arg_param, $uri_param, $outsub) = @{$base || []}; if (!$class) { - print_usage_short (\*STDERR, "unknown command '$cmd'"); + print_usage_short (\*STDERR, "unknown command '" . join(' ', @cmd) . "'"); exit (-1); } - my $prefix = "$exename $cmd"; + my $prefix = "$exename " . join(' ', @cmd); my $res = $class->cli_handler($prefix, $name, \@ARGV, $arg_param, $uri_param, $pwcallback, $stringfilemap); &$outsub($res) if $outsub; }; my $handle_simple_cmd = sub { - my ($def, $args, $pwcallback, $preparefunc, $stringfilemap) = @_; + my ($args, $pwcallback, $preparefunc, $stringfilemap) = @_; - my ($class, $name, $arg_param, $uri_param, $outsub) = @{$def}; + my ($class, $name, $arg_param, $uri_param, $outsub) = @{$cmddef}; die "no class specified" if !$class; if (scalar(@$args) >= 1) { if ($args->[0] eq 'help') { my $str = "USAGE: $name help\n"; - $str .= $class->usage_str($name, $name, $arg_param, $uri_param, 'long', $pwcallback, $stringfilemap); + $str .= &$generate_usage_str('long'); print STDERR "$str\n\n"; return; } elsif ($args->[0] eq 'verifyapi') { @@ -460,7 +612,7 @@ my $handle_simple_cmd = sub { if (scalar(@$args) >= 1) { if ($args->[0] eq 'bashcomplete') { shift @$args; - &$print_bash_completion({ $name => $def }, $name, @$args); + &$print_bash_completion({ $name => $cmddef }, $name, @$args); return; } } @@ -507,14 +659,12 @@ sub run_cli_handler { initlog($exename); no strict 'refs'; - my $def = ${"${class}::cmddef"}; + $cmddef = ${"${class}::cmddef"}; - if (ref($def) eq 'ARRAY') { - &$handle_simple_cmd($def, \@ARGV, $pwcallback, $preparefunc, $stringfilemap); + if (ref($cmddef) eq 'ARRAY') { + &$handle_simple_cmd(\@ARGV, $pwcallback, $preparefunc, $stringfilemap); } else { - $cmddef = $def; - my $cmd = shift @ARGV; - &$handle_cmd($cmddef, $exename, $cmd, \@ARGV, $pwcallback, $preparefunc, $stringfilemap); + &$handle_cmd(\@ARGV, $pwcallback, $preparefunc, $stringfilemap); } exit 0; -- 2.11.0 _______________________________________________ pve-devel mailing list pve-devel@pve.proxmox.com https://pve.proxmox.com/cgi-bin/mailman/listinfo/pve-devel