File Coverage

blib/lib/Commandable/Finder.pm
Criterion Covered Total %
statement 211 221 95.4
branch 80 104 76.9
condition 22 29 75.8
subroutine 19 20 95.0
pod 7 11 63.6
total 339 385 88.0


line stmt bran cond sub pod time code
1             # You may distribute under the terms of either the GNU General Public License
2             # or the Artistic License (the same terms as Perl itself)
3             #
4             # (C) Paul Evans, 2021-2024 -- leonerd@leonerd.org.uk
5              
6             package Commandable::Finder 0.14;
7              
8 11     11   213 use v5.26;
  11         68  
9 11     11   87 use warnings;
  11         28  
  11         617  
10 11     11   91 use experimental qw( signatures );
  11         40  
  11         81  
11              
12 11     11   1615 use Carp;
  11         51  
  11         938  
13 11     11   63 use List::Util 'max';
  11         35  
  11         47500  
14              
15             require Commandable::Output;
16              
17             =head1 NAME
18              
19             C - an interface for discovery of Ls
20              
21             =head1 SYNOPSIS
22              
23             use Commandable::Finder::...;
24              
25             my $finder = Commandable::Finder::...->new(
26             ...
27             );
28              
29             $finder->find_and_invoke( Commandable::Invocation->new( $text ) );
30              
31             =head1 DESCRIPTION
32              
33             This base class is common to the various finder subclasses:
34              
35             =over 4
36              
37             =item *
38              
39             L
40              
41             =item *
42              
43             L
44              
45             =item *
46              
47             L
48              
49             =back
50              
51             =head1 METHODS
52              
53             =cut
54              
55             =head2 configure
56              
57             $finder = $finder->configure( %conf );
58              
59             Sets configuration options on the finder instance. Returns the finder instance
60             itself, to permit easy chaining.
61              
62             The following configuration options are recognised:
63              
64             =head3 allow_multiple_commands
65              
66             If enabled, the L method will permit multiple command
67             invocations within a single call.
68              
69             =head3 require_order
70              
71             If enabled, stop processing options when the first non-option argument
72             is seen.
73              
74             =head3 bundling
75              
76             If enabled, short (single-letter) options of simple boolean type can be
77             combined into a single C<-abc...> argument. Incrementable options can be
78             specified multiple times (as common with things like C<-vvv> for
79             C<--verbose 3>).
80              
81             =cut
82              
83 4         10 sub configure ( $self, %conf )
84 4     4 1 9 {
  4         12  
  4         7  
85             exists $conf{$_} and $self->{config}{$_} = delete $conf{$_}
86 4   66     48 for qw( allow_multiple_commands require_order bundling );
87              
88 4 50       14 keys %conf and croak "Unrecognised ->configure params: " . join( ", ", sort keys %conf );
89              
90 4         12 return $self;
91             }
92              
93             =head2 add_global_options
94              
95             $finder->add_global_options( @optspecs );
96              
97             I
98              
99             Adds additional global options to the stored set.
100              
101             Each is specified as a HASH reference containing keys to specify one option,
102             in the same style as the per-command options used by
103             L.
104              
105             In addition, each should also provide a key named C, whose value should
106             be a SCALAR or CODE reference to be used for applying the value for the option
107             when it is parsed. SCALAR references will be assigned to directly; CODE
108             references will be invoked with the option's name and value as positional
109             arguments:
110              
111             $$into = $value;
112             $into->( $name, $value );
113              
114             This style permits a relatively easy upgrade from such modules as
115             L, to handle global options.
116              
117             GetOptions(
118             'verbose|v+' => \my $VERBOSE,
119             'silent|s' => \my $SILENT,
120             ) or exit 1;
121              
122             Can now become
123              
124             $finder->add_global_options(
125             { name => "verbose|v", mode => "inc", into => \my $VERBOSE,
126             description => "Increase verbosity of output" },
127             { name => "silent|s", into => \my $SILENT,
128             description => "Silence output entirely" },
129             );
130              
131             with the added benefit of automated integration with the global C
132             command, more consistent option parsing along with other command handling, and
133             so on.
134              
135             =cut
136              
137 2         4 sub add_global_options ( $self, @optspecs )
138 2     2 1 54 {
  2         7  
  2         4  
139 2         8 foreach my $optspec ( @optspecs ) {
140 5         13 my $into = $optspec->{into};
141 5         41 my $opt = Commandable::Command::_Option->new( %$optspec );
142              
143 5         21 my $name = $opt->name;
144 5 50       17 defined $into or
145             croak "Global option $name requires an 'into'";
146 5 50       36 ( ref $into ) =~ m/^(?:SCALAR|CODE)$/ or
147             croak "Global option $name 'into' must be a SCALAR or CODE reference; got ";
148              
149 5         20 $self->{global_options}{ $_ } = $opt for $opt->names;
150 5         22 $self->{global_options_into}{ $opt->keyname } = $into;
151             }
152              
153 2         9 return $self;
154             }
155              
156             =head2 handle_global_options
157              
158             $finder->handle_global_options( $cinv );
159              
160             I
161              
162             Extracts global options from the command invocation and process them into the
163             C references previously supplied.
164              
165             Normally it would not be necessary to invoke this directly, because the main
166             L method does this anyway. It is provided in case the
167             implementing program performs its own command handling or changes the logic in
168             some other way.
169              
170             =cut
171              
172 6         15 sub handle_global_options ( $self, $cinv )
173 6     6 1 11 {
  6         12  
  6         11  
174             my $global_optspecs = $self->{global_options}
175 6 50       23 or return;
176              
177 6         30 my $opts = $self->parse_invocation_options( $cinv, $global_optspecs, passthrough => 1 );
178              
179 6         21 foreach ( keys %$opts ) {
180 10         37 my $value = $opts->{$_};
181 10         23 my $into = $self->{global_options_into}{$_};
182 10 100       34 if( ref $into eq "SCALAR" ) {
183 7         24 $into->$* = $value;
184             }
185             else {
186 3         11 $into->( $_, $value );
187             }
188             }
189             }
190              
191             =head2 find_commands
192              
193             @commands = $finder->find_commands;
194              
195             Returns a list of command instances, in no particular order. Each will be an
196             instance of L.
197              
198             =head2 find_command
199              
200             $command = $finder->find_command( $cmdname );
201              
202             Returns a command instance of the given name as an instance of
203             L, or C if there is none.
204              
205             =cut
206              
207             =head2 parse_invocation
208              
209             @vals = $finder->parse_invocation( $command, $cinv );
210              
211             I
212              
213             Parses values out of a L instance according to the
214             specification for the command's arguments. Returns a list of perl values
215             suitable to pass into the function implementing the command.
216              
217             This method will throw an exception if mandatory arguments are missing.
218              
219             =cut
220              
221 38         77 sub parse_invocation ( $self, $command, $cinv )
  38         55  
222 38     38 1 162 {
  38         50  
  38         116  
223 38         55 my @args;
224              
225 38 100       152 if( my %optspec = $command->options ) {
226 30         95 push @args, $self->parse_invocation_options( $cinv, \%optspec );
227             }
228              
229 34         121 foreach my $argspec ( $command->arguments ) {
230 16         50 my $val = $cinv->pull_token;
231 16 100       92 if( defined $val ) {
    100          
232 10 100       57 if( $argspec->slurpy ) {
233 4         15 my @vals = ( $val );
234 4         15 while( defined( $val = $cinv->pull_token ) ) {
235 6         18 push @vals, $val;
236             }
237 4         12 $val = \@vals;
238             }
239 10         31 push @args, $val;
240             }
241             elsif( !$argspec->optional ) {
242 1         4 die "Expected a value for '".$argspec->name."' argument\n";
243             }
244             else {
245             # optional argument was missing; this is the end of the args
246 5         13 last;
247             }
248             }
249              
250 33         224 return @args;
251             }
252              
253 36         49 sub parse_invocation_options ( $self, $cinv, $optspec, %params )
  36         50  
  36         50  
254 36     36 0 56 {
  36         71  
  36         48  
255 36         63 my $passthrough = $params{passthrough};
256              
257 36         60 my $opts = {};
258 36         53 my @remaining;
259              
260 36         105 while( defined( my $token = $cinv->pull_token ) ) {
261 49 50       123 if( $token eq "--" ) {
262 0 0       0 push @remaining, $token if $passthrough;
263 0         0 last;
264             }
265              
266 49         127 my $spec;
267             my $value_in_token;
268 49         0 my $token_again;
269              
270 49         137 my $value = 1;
271 49         84 my $orig = $token;
272              
273 49 100       235 if( $token =~ s/^--([^=]+)(=|$)// ) {
    100          
274 21         117 my ( $opt, $equal ) = ($1, $2);
275 21 100 100     104 if( !$optspec->{$opt} and $opt =~ /no-(.+)/ ) {
    100          
276 1 50 33     8 $spec = $optspec->{$1} and $spec->negatable
277             or die "Unrecognised option name --$opt\n";
278 1         3 $value = undef;
279             }
280             elsif( $spec = $optspec->{$opt} ) {
281 19         37 $value_in_token = length $equal;
282             }
283             else {
284 1 50       5 die "Unrecognised option name --$opt\n" unless $passthrough;
285 1         3 push @remaining, $orig;
286 1         4 next;
287             }
288             }
289             elsif( $token =~ s/^-(.)// ) {
290 13 50       41 unless( $spec = $optspec->{$1} ) {
291 0 0       0 die "Unrecognised option name -$1\n" unless $passthrough;
292 0         0 push @remaining, $orig;
293 0         0 next;
294             }
295 13 100 100     57 if( $spec->mode_expects_value ) {
    100 66        
296 4         7 $value_in_token = length $token;
297             }
298             elsif( $self->{config}{bundling} and length $token and length($1) == 1 ) {
299 2         5 $token_again = "-$token";
300 2         5 undef $token;
301             }
302             }
303             else {
304 15         56 push @remaining, $token;
305 15 100       51 if( $self->{config}{require_order} ) {
306 1         4 last;
307             }
308             else {
309 14         59 next;
310             }
311             }
312              
313 33         90 my $name = $spec->name;
314              
315 33 100       89 if( $spec->mode_expects_value ) {
316 19 100 50     54 $value = $value_in_token ? $token
317             : ( $cinv->pull_token // die "Expected value for option --$name\n" );
318             }
319             else {
320 14 100 66     93 die "Unexpected value for parameter $name\n" if $value_in_token or length $token;
321             }
322              
323 32 100       69 if( defined( my $matches = $spec->matches ) ) {
324 6 100       50 $value =~ $matches or
325             die "Value for --$name option must " . $spec->match_msg . "\n";
326             }
327              
328 29         82 my $keyname = $spec->keyname;
329              
330 29 100       63 if( $spec->mode eq "multi_value" ) {
    100          
    100          
331 5         18 push $opts->{$keyname}->@*, $value;
332             }
333             elsif( $spec->mode eq "inc" ) {
334 8         23 $opts->{$keyname}++;
335             }
336             elsif( $spec->mode eq "bool" ) {
337 1         4 $opts->{$keyname} = !!$value;
338             }
339             else {
340 15         61 $opts->{$keyname} = $value;
341             }
342              
343 29 100       130 $token = $token_again, redo if defined $token_again;
344             }
345              
346 32         104 $cinv->putback_tokens( @remaining );
347              
348 32         104 foreach my $spec ( values %$optspec ) {
349 196         351 my $keyname = $spec->keyname;
350             $opts->{$keyname} = $spec->default if
351 196 100 100     331 defined $spec->default and !exists $opts->{$keyname};
352             }
353              
354 32         94 return $opts;
355             }
356              
357             =head2 find_and_invoke
358              
359             $result = $finder->find_and_invoke( $cinv );
360              
361             A convenient wrapper around the common steps of finding a command named after
362             the initial token in a L, parsing arguments from it,
363             and invoking the underlying implementation function.
364              
365             If the C configuration option is set, it will
366             repeatedly attempt to parse a command name followed by arguments and options
367             while the invocation string is non-empty.
368              
369             =cut
370              
371 14         25 sub find_and_invoke ( $self, $cinv )
372 14     14 1 29 {
  14         51  
  14         23  
373 14         43 my $multiple = $self->{config}{allow_multiple_commands};
374              
375             # global options come first
376             $self->handle_global_options( $cinv )
377 14 100       89 if $self->{global_options};
378              
379 14         41 my $result;
380             {
381 14 50       28 defined( my $cmdname = $cinv->pull_token ) or
  15         48  
382             die "Expected a command name\n";
383              
384 15 50       74 my $cmd = $self->find_command( $cmdname ) or
385             die "Unrecognised command '$cmdname'";
386              
387 15         61 my @args = $self->parse_invocation( $cmd, $cinv );
388              
389 15 50 66     64 !$multiple and length $cinv->peek_remaining and
390             die "Unrecognised extra input: " . $cinv->peek_remaining . "\n";
391              
392 15         70 $result = $cmd->code->( @args );
393              
394             # TODO configurable separator - ';' or '|' or whatever
395             # currently blank
396              
397 15 100 100     150 redo if $multiple and length $cinv->peek_remaining;
398             }
399              
400 14         51 return $result;
401             }
402              
403             =head2 find_and_invoke_list
404              
405             $result = $finder->find_and_invoke_list( @tokens );
406              
407             A further convenience around creating a L from the
408             given list of values and using that to invoke a command.
409              
410             =cut
411              
412 12         26 sub find_and_invoke_list ( $self, @args )
413 12     12 1 24622 {
  12         37  
  12         19  
414 12         2213 require Commandable::Invocation;
415 12         82 return $self->find_and_invoke( Commandable::Invocation->new_from_tokens( @args ) );
416             }
417              
418             =head2 find_and_invoke_ARGV
419              
420             $result = $finder->find_and_invoke_ARGV();
421              
422             A further convenience around creating a L from the
423             C<@ARGV> array and using that to invoke a command. Often this allows an entire
424             wrapper script to be created in a single line of code:
425              
426             exit Commandable::Finder::SOMESUBCLASS->new( ... )
427             ->find_and_invoke_ARGV();
428              
429             =cut
430              
431             sub find_and_invoke_ARGV ( $self )
432 0     0 1 0 {
  0         0  
  0         0  
433 0         0 $self->find_and_invoke_list( @ARGV );
434             }
435              
436             =head1 BUILTIN COMMANDS
437              
438             The following built-in commands are automatically provided.
439              
440             =cut
441              
442 10         20 sub add_builtin_commands ( $self, $commands )
443 10     10 0 24 {
  10         20  
  10         19  
444             $commands->{help} =
445             Commandable::Command->new(
446             name => "help",
447             description => "Display a list of available commands",
448             arguments => [
449             Commandable::Command::_Argument->new(
450             name => "cmd",
451             description => "command name",
452             optional => 1,
453             )
454             ],
455             code => sub {
456 2 100   2   35 @_ ? return $self->builtin_command_helpcmd( @_ )
457             : return $self->builtin_command_helpsummary;
458             },
459 10         59 );
460             }
461              
462             # TODO: some pretty output formatting maybe using S:T:Terminal?
463 2         51 sub _print_table2 ( $sep, @rows )
464 2     2   7 {
  2         6  
  2         6  
465 2         7 my $max_len = max map { length $_->[0] } @rows;
  4         27  
466              
467             Commandable::Output->printf( "%-*s%s%s\n",
468             $max_len, $_->[0], $sep, $_->[1]
469 2         27 ) for @rows;
470             }
471              
472             # A join() that respects stringify overloading
473             sub _join
474             {
475 5     5   320 my $sep = shift;
476 5         10 my $ret = shift;
477 5         16 $ret .= "$sep$_" for @_;
478 5         21 return $ret;
479             }
480              
481             =head2 help
482              
483             help
484              
485             help $commandname
486              
487             With no arguments, prints a summary table of known command names and their
488             descriptive text. If any global options have been registered, these are
489             described as well.
490              
491             With a command name argument, prints more descriptive text about that command,
492             additionally detailing the arguments and options.
493              
494             The package that implements a particular command can provide more output by
495             implementing a method called C, which will take as a
496             single argument the name of the command being printed. It should make use of
497             the various printing methods in L to generate whatever
498             extra output it wishes.
499              
500             =cut
501              
502             sub _print_optspecs ( $optspecs )
503 2     2   6 {
  2         7  
  2         4  
504             # @optspecs may contain duplicates; filter them
505 2         8 my %primary_names = map { $_->name => 1 } values %$optspecs;
  7         23  
506 2         40 my @optspecs = @$optspecs{ sort keys %primary_names };
507              
508 2         6 my $first = 1;
509 2         9 foreach my $optspec ( @optspecs ) {
510 5 100       45 Commandable::Output->printf( "\n" ) unless $first; undef $first;
  5         24  
511              
512 5         19 my $default = $optspec->default;
513 5 100       20 my $value = $optspec->mode eq "value" ? " " : "";
514 5 100       18 my $no = $optspec->negatable ? "[no-]" : "";
515              
516             Commandable::Output->printf( " %s\n",
517             _join( ", ", map {
518 5 100       19 Commandable::Output->format_note( length $_ > 1 ? "--$no$_$value" : "-$_$value", 1 )
  7         51  
519             } $optspec->names )
520             );
521 5 100       100 Commandable::Output->printf( " %s%s\n",
522             $optspec->description,
523             ( defined $default ? " (default: $default)" : "" ),
524             );
525             }
526             }
527              
528             sub builtin_command_helpsummary ( $self )
529 1     1 0 2 {
  1         30  
  1         3  
530 1         5 my @commands = sort { $a->name cmp $b->name } $self->find_commands;
  3         10  
531              
532 1         11 Commandable::Output->print_heading( "COMMANDS:" );
533             _print_table2 ": ", map {
534 1         239 [ " " . Commandable::Output->format_note( $_->name ), $_->description ]
  3         14  
535             } @commands;
536              
537 1 50       53 if( my $opts = $self->{global_options} ) {
538 1         56 Commandable::Output->printf( "\n" );
539 1         11 Commandable::Output->print_heading( "GLOBAL OPTIONS:" );
540 1         137 _print_optspecs( $opts );
541             }
542             }
543              
544 1         4 sub builtin_command_helpcmd ( $self, $cmdname )
545 1     1 0 3 {
  1         2  
  1         2  
546 1 50       4 my $cmd = $self->find_command( $cmdname ) or
547             die "Unrecognised command '$cmdname' - see 'help' for a list of commands\n";
548              
549 1         5 my @argspecs = $cmd->arguments;
550 1         4 my %optspecs = $cmd->options;
551              
552 1         6 Commandable::Output->printf( "%s - %s\n",
553             Commandable::Output->format_note( $cmd->name ),
554             $cmd->description
555             );
556 1         17 Commandable::Output->printf( "\n" );
557              
558 1         14 Commandable::Output->print_heading( "SYNOPSIS:" );
559             Commandable::Output->printf( " %s\n",
560             join " ",
561             $cmd->name,
562             %optspecs ? "[OPTIONS...]" : (),
563             @argspecs ? (
564             map {
565 1 50       22 my $argspec = $_;
  1 50       4  
566 1         19 my $str = "\$" . uc $argspec->name;
567 1 50       4 $str .= "..." if $argspec->slurpy;
568 1 50       5 $str = "($str)" if $argspec->optional;
569 1         9 $str;
570             } @argspecs
571             ) : ()
572             );
573              
574 1 50       10 if( %optspecs ) {
575 1         5 Commandable::Output->printf( "\n" );
576 1         8 Commandable::Output->print_heading( "OPTIONS:" );
577              
578 1         17 _print_optspecs( \%optspecs );
579             }
580              
581 1 50       15 if( @argspecs ) {
582 1         4 Commandable::Output->printf( "\n" );
583 1         10 Commandable::Output->print_heading( "ARGUMENTS:" );
584              
585             _print_table2 " ", map {
586 1         15 [ " " . Commandable::Output->format_note( '$' . uc $_->name, 1 ),
  1         5  
587             $_->description ]
588             } @argspecs;
589             }
590              
591 1         15 my $cmdpkg = $cmd->package;
592 1 50       29 if( $cmdpkg->can( "commandable_more_help" ) ) {
593 0         0 $cmdpkg->commandable_more_help( $cmdname );
594             }
595              
596 1         6 return 0;
597             }
598              
599             =head1 AUTHOR
600              
601             Paul Evans
602              
603             =cut
604              
605             0x55AA;