pericmd 038: Getopt::Long::Complete

Getopt::Long::Complete is a module which I created as a drop-in replacement for Getopt::Long. It lets you use the tab completion features like in Perinci::CmdLine, without you having to get into all the other concepts of Perinci::CmdLine (like Rinci metadata and Riap URL, output formatting rules, or even subcommands, and so on). It’s perfect if you want to add tab completion feature for your CLI application but you use Getopt::Long.

I personally use this module to write tab completer for other applications (non-Perl, non-Perinci::CmdLine-based). Some examples: App::ShellCompleter::cpanm (for Miyagawa’s cpanm), App::ShellCompleter::emacs (for the Emacs editor), App::ShellCompleter::CpanUpload (for RJBS’ cpan-upload),

Also you might remember from a previous blog post (pericmd 024) about Getopt::Long::Subcommand. This module also lets you use all the Complete::* modules without getting into the whole Perinci::CmdLine.

An example on how you might use Getopt::Long::Complete can be seen here (reproduced below sans the POD). It’s a source code of _cpanm, which you install on bash using complete -C _cpanm cpanm.

#!perl
 
our $DATE = '2015-02-15'; # DATE
our $VERSION = '0.10'; # VERSION
 
# NO_PERINCI_CMDLINE_SCRIPT
# FRAGMENT id=shcompgen-hint completer=1 for=cpanm
 
use 5.010001;
use strict;
use warnings;
use Log::Any '$log';
 
use Complete::Util qw(complete_array_elem complete_file combine_answers);
use Getopt::Long::Complete qw(GetOptionsWithCompletion);
 
die "This script is for shell completion only\n"
    unless $ENV{COMP_LINE} || $ENV{COMMAND_LINE};
 
my $noop = sub {};
 
# complete with list of installed modules
my $comp_installed_mods = sub {
    require Complete::Module;
 
    my %args = @_;
 
    $log->tracef("Adding completion: installed modules");
    Complete::Module::complete_module(
        word => $args{word},
    );
};
 
# complete with installable stuff
my $comp_installable = sub {
    require Complete::Module;
 
    my %args = @_;
    my $word   = $args{word} // '';
    my $mirror = $args{mirror}; # XXX support multiple mirrors
 
    # if user already types something that looks like a path instead of module
    # name, like '../' or perhaps 'C:\' (windows) then don't bother to complete
    # with module name because it will just delay things without getting any
    # result.
    my $looks_like_completing_module =
        $word eq '' || $word =~ /\A(\w+)(::\w+)*/;
 
    my @answers;
 
    {
        $log->tracef("Adding completion: tarballs & dirs");
        my $answer = complete_file(
            filter => sub { /\.(zip|tar\.gz|tar\.bz2)$/i || (-d $_) },
            word   => $word,
        );
        $log->tracef("  answer: %s", {words=>$answer, path_sep=>'/'});
        push @answers, $answer;
    }
 
    if ($looks_like_completing_module) {
        $log->tracef("Adding completion: installed modules ".
                         "(e.g. when upgrading)");
        my $answer = Complete::Module::complete_module(
            word   => $word,
        );
        $log->tracef("  answer: %s", $answer);
        push @answers, $answer;
    }
 
    # currently we only complete from local CPAN (App::lcpan) if it's available.
    # for remote service, ideally we will need a remote service that quickly
    # returns list of matching PAUSE ids, package/module names, and dist names
    # (CPANDB, XPAN::Query, and MetaCPAN::Client are not ideal because the
    # response time is not conveniently quick enough). i probably will need to
    # setup such completion-oriented web service myself. stay tuned.
    {
        no warnings 'once';
 
        last unless $looks_like_completing_module;
        eval { require App::lcpan }; last if $@;
        $log->tracef("Adding completion: modules from local CPAN mirror");
 
        require Perinci::CmdLine::Util::Config;
 
        my %lcpanargs;
        my $res = Perinci::CmdLine::Util::Config::read_config(
            program_name => "lcpan",
        );
        unless ($res->[0] == 200) {
            $log->tracef("Can't get config for lcpan: %s", $res);
            last;
        }
        my $config = $res->[2];
 
        $res = Perinci::CmdLine::Util::Config::get_args_from_config(
            config => $config,
            args   => \%lcpanargs,
            subcommand_name => 'update-index',
            meta   => $App::lcpan::SPEC{update_local_cpan_index},
        );
        unless ($res->[0] == 200) {
            $log->tracef("Can't get args from config: %s", $res);
            last;
        }
        App::lcpan::_set_args_default(\%lcpanargs);
        my $mods = App::lcpan::list_local_cpan_modules(
            %lcpanargs,
            query => $word . '%',
        );
        #$log->tracef("all mods: %s", $mods);
        my $answer = [grep {
                if ($word =~ /::\z/) {
                    /\A\Q$word\E[^:]+(::)?\z/i
                } else {
                    /\A\Q$word\E[^:]*(::)?\z/i
                }
        } @$mods];
        $log->tracef("  answer: %s", $answer);
        push @answers, $answer;
    }
 
    # TODO module name can be suffixed with '@<version>'
 
    combine_answers(@answers);
};
 
my $comp_file = sub {
    my %args = @_;
 
    complete_file(
        word => $args{word},
        ci   => 1,
    );
};
 
# this is taken from App::cpanminus::script and should be updated from time to
# time.
GetOptionsWithCompletion(
    sub {
        my %args  = @_;
        my $type      = $args{type};
        my $word      = $args{word};
        if ($type eq 'arg') {
            $log->tracef("Completing arg");
            my $seen_opts = $args{seen_opts};
            if ($seen_opts->{'--uninstall'} || $seen_opts->{'--reinstall'}) {
                return $comp_installed_mods->(word=>$word);
            } else {
                return $comp_installable->(
                    word=>$word, mirror=>$seen_opts->{'--mirror'});
            }
        } elsif ($type eq 'optval') {
            my $ospec = $args{ospec};
            my $opt   = $args{opt};
            $log->tracef("Completing optval (opt=$opt)");
            if ($ospec eq 'l|local-lib=s' ||
                    $ospec eq 'L|local-lib-contained=s') {
                return complete_file(filter=>'d', word=>$word);
            } elsif ($ospec eq 'format=s') {
                return complete_array_elem(
                    array=>[qw/tree json yaml dists/], word=>$word);
            } elsif ($ospec eq 'cpanfile=s') {
                return complete_file(word=>$word);
            }
        }
        return [];
    },
    'f|force'   => $noop,
    'n|notest!' => $noop,
    'test-only' => $noop,
    'S|sudo!'   => $noop,
    'v|verbose' => $noop,
    'verify!'   => $noop,
    'q|quiet!'  => $noop,
    'h|help'    => $noop,
    'V|version' => $noop,
    'perl=s'          => $noop,
    'l|local-lib=s'   => $noop,
    'L|local-lib-contained=s' => $noop,
    'self-contained!' => $noop,
    'mirror=s@'       => $noop,
    'mirror-only!'    => $noop,
    'mirror-index=s'  => $noop,
    'cpanmetadb=s'    => $noop,
    'cascade-search!' => $noop,
    'prompt!'         => $noop,
    'installdeps'     => $noop,
    'skip-installed!' => $noop,
    'skip-satisfied!' => $noop,
    'reinstall'       => $noop,
    'interactive!'    => $noop,
    'i|install'       => $noop,
    'info'            => $noop,
    'look'            => $noop,
    'U|uninstall'     => $noop,
    'self-upgrade'    => $noop,
    'uninst-shadows!' => $noop,
    'lwp!'    => $noop,
    'wget!'   => $noop,
    'curl!'   => $noop,
    'auto-cleanup=s' => $noop,
    'man-pages!' => $noop,
    'scandeps'   => $noop,
    'showdeps'   => $noop,
    'format=s'   => $noop,
    'save-dists=s' => $noop,
    'skip-configure!' => $noop,
    'dev!'       => $noop,
    'metacpan!'  => $noop,
    'report-perl-version!' => $noop,
    'configure-timeout=i' => $noop,
    'build-timeout=i' => $noop,
    'test-timeout=i' => $noop,
    'with-develop' => $noop,
    'without-develop' => $noop,
    'with-feature=s' => $noop,
    'without-feature=s' => $noop,
    'with-all-features' => $noop,
    'pp|pureperl!' => $noop,
    "cpanfile=s" => $noop,
    #$self->install_type_handlers,
    #$self->build_args_handlers,
);
 
# ABSTRACT: Shell completer for cpanm
# PODNAME: _cpanm
 
__END__

In the linked source code you’ll see the completion routine passed in as the first argument for the GetOptionsWithCompletion() function (line 140). This is very much like a completion routine you set in completion property of function argument specification in a Rinci metadata. The routine should accept a hash argument, with the usual keys like word and is expected to return an array or a hash.

One key that is present, passed by Getopt::Long::Complete to the completion routine is the type key, which can have possible values of “optval” (meaning we are completing the value for a command-line option) or “arg” (meaning we are completing the value of a command-line argument).

When type is “optval”, these keys are also passed: ospec (a Getopt::Long option specification to identify the option, e.g. “–foo”), opt the name of the option). There is also seen_opts which is a hash containing all the options that have been specified in the command-line.

When type is “arg”, these keys are also passed:pos (an integer starting from 0 to let us know which argument are we completing the value for).

Also you’ll see a new function being used from Complete::Util: combine_answers() (line 125). This function is used to combine two or more completion answers. Each answer can be an array or a hash. If all answers are arrays, the final result will still be an array, but if one of the input answers is a hash, the final result will be a hash.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s