The Perl and Raku Conference 2025: Greenville, South Carolina - June 27-29 Learn more

package Command::Tree;
use strict;
use UR;
use File::Basename qw/basename/;
our $VERSION = "0.47"; # UR $VERSION;
class Command::Tree {
is => 'Command::V2',
is_abstract => 1,
doc => 'base class for commands which delegate to sub-commands',
};
sub resolve_class_and_params_for_argv {
# This is used by execute_with_shell_params_and_exit, but might be used within an application.
my $self = shift;
my @argv = @_;
if ( $argv[0] and $argv[0] !~ /^\-/
and my $class_for_sub_command = $self->class_for_sub_command($argv[0]) ) {
# delegate
shift @argv;
return $class_for_sub_command->resolve_class_and_params_for_argv(@argv);
}
elsif ( @argv == 1 and $argv[0] =~ /^(\-)?\-h(elp)?$/ ) { # HELP ME!
return ($self, { help => 1 });
}
else {
# error
return ($self,undef);
}
}
sub resolve_option_completion_spec {
my $class = shift;
my @completion_spec;
my @sub = eval { $class->sub_command_names };
if ($@) {
$class->warning_message("Couldn't load class $class: $@\nSkipping $class...");
return;
}
for my $sub (@sub) {
my $sub_class = $class->class_for_sub_command($sub);
my $sub_tree = $sub_class->resolve_option_completion_spec() if defined($sub_class);
# Hack to fix several broken commands, this should be removed once commands are fixed.
# If the commands were not broken then $sub_tree will always exist.
# Basically if $sub_tree is undef then we need to remove '>' to not break the OPTS_SPEC
if ($sub_tree) {
push @completion_spec, '>' . $sub => $sub_tree;
}
else {
if (defined $sub_class) {
print "WARNING: $sub has sub_class $sub_class of ($class) but could not resolve option completion spec for it.\n".
"Setting $sub to non-delegating command, investigate to correct tab completion.\n";
} else {
print "WARNING: $sub has no sub_class so could not resolve option completion spec for it.\n".
"Setting $sub to non-delegating command, investigate to correct tab completion.\n";
}
push @completion_spec, $sub => undef;
}
}
push @completion_spec, "help!" => undef;
return \@completion_spec
}
sub help_brief {
my $self = shift;
if (my $doc = $self->__meta__->doc) {
return $doc;
}
else {
my @parents = $self->__meta__->ancestry_class_metas;
for my $parent (@parents) {
if (my $doc = $parent->doc) {
return $doc;
}
}
return "";
}
}
sub doc_help {
my $self = shift;
my $command_name = $self->command_name;
my $text;
# show the list of sub-commands
$text = sprintf(
"Sub-commands for %s:\n%s",
Term::ANSIColor::colored($command_name, 'bold'),
$self->help_sub_commands,
);
return $text;
}
sub doc_manual {
my $self = shift;
my $pod = $self->_doc_name_version;
my $manual = $self->_doc_manual_body;
my $help = $self->help_detail;
if ($manual or $help) {
$pod .= "=head1 DESCRIPTION:\n\n";
my $txt = $manual || $help;
if ($txt =~ /^\=/) {
# pure POD
$pod .= $manual;
}
else {
$txt =~ s/\n/\n\n/g;
$pod .= $txt;
#$pod .= join('', map { " $_\n" } split ("\n",$txt)) . "\n";
}
}
my $sub_commands = $self->help_sub_commands(brief => 1);
$pod .= "=head1 SUB-COMMANDS\n\n" . $sub_commands . "\n\n";
$pod .= $self->_doc_footer();
$pod .= "\n\n=cut\n\n";
return "\n$pod";
}
sub sorted_sub_command_classes {
no warnings;
my @c = map { [ $_->sub_command_sort_position, $_ ] } shift->sub_command_classes;
return map { $_->[1] }
sort {
($a->[0] <=> $b->[0])
||
($a->[0] cmp $b->[0])
}
@c;
}
sub sorted_sub_command_names {
my $class = shift;
my @sub_command_classes = $class->sorted_sub_command_classes;
my @sub_command_names = map { $_->command_name_brief } @sub_command_classes;
return @sub_command_names;
}
sub sub_commands_table {
my $class = shift;
my @sub_command_names = $class->sorted_sub_command_names;
my $max_length = 0;
for (@sub_command_names) {
$max_length = length($_) if ($max_length < length($_));
}
$max_length ||= 79;
my $col_spacer = '_'x$max_length;
my $n_cols = floor(80/$max_length);
my $n_rows = ceil(@sub_command_names/$n_cols);
my @tb_rows;
for (my $i = 0; $i < @sub_command_names; $i += $n_cols) {
my $end = $i + $n_cols - 1;
$end = $#sub_command_names if ($end > $#sub_command_names);
push @tb_rows, [@sub_command_names[$i..$end]];
}
my @col_alignment;
for (my $i = 0; $i < $n_cols; $i++) {
push @col_alignment, { sample => "&$col_spacer" };
}
my $tb = Text::Table->new(@col_alignment);
$tb->load(@tb_rows);
return $tb;
}
sub _categorize_sub_commands {
my $class = shift;
my @sub_command_classes = $class->sorted_sub_command_classes;
my %categories;
my @order;
for my $sub_command_class (@sub_command_classes) {
next if $sub_command_class->_is_hidden_in_docs();
my $category = $sub_command_class->sub_command_category || '';
unless (exists $categories{$category}) {
if ($category) {
push(@order, $category)
} else {
unshift(@order, '');
}
$categories{$category} = [];
}
push(@{$categories{$category}}, $sub_command_class);
}
return (\@order, \%categories);
}
sub help_sub_commands {
my ($self, %params) = @_;
my ($order, $categories) = $self->_categorize_sub_commands(@_);
my $command_name_method = 'command_name_brief';
no warnings;
local $Text::Wrap::columns = 60;
my @full_data;
for my $category (@$order) {
my $sub_commands_within_this_category = $categories->{$category};
my @data = map {
my @rows = split("\n",Text::Wrap::wrap('', ' ', $_->help_brief));
chomp @rows;
(
[
$_->$command_name_method,
($_->isa('Command::Tree') ? '...' : ''), #$_->_shell_args_usage_string_abbreviated,
$rows[0],
],
map {
[
'',
' ',
$rows[$_],
]
} (1..$#rows)
);
}
@$sub_commands_within_this_category;
if ($category) {
# add a space between categories
push @full_data, ['','',''] if @full_data;
if ($category =~ /\D/) {
# non-numeric categories show their category as a header
$category .= ':' if $category =~ /\S/;
push @full_data,
[
Term::ANSIColor::colored(uc($category), 'blue'),
'',
''
];
}
else {
# numeric categories just sort
}
}
push @full_data, @data;
}
my @max_width_found = (0,0,0);
for (@full_data) {
for my $c (0..2) {
$max_width_found[$c] = length($_->[$c]) if $max_width_found[$c] < length($_->[$c]);
}
}
my @colors = (qw/ red bold /);
my $text = '';
for my $row (@full_data) {
for my $c (0..2) {
$text .= ' ';
$text .= $colors[$c] ? Term::ANSIColor::colored($row->[$c], $colors[$c]) : $row->[$c];
$text .= ' ';
$text .= ' ' x ($max_width_found[$c]-length($row->[$c]));
}
$text .= "\n";
}
return $text;
}
sub doc_sub_commands {
my $self = shift;
my ($order, $categories) = $self->_categorize_sub_commands(@_);
my $text = "";
my $indent_lvl = 4;
for my $category (@$order) {
my $category_name = ($category ? uc $category : "GENERAL");
$text .= "=head2 $category_name\n\n";
for my $cmd (@{$categories->{$category}}) {
$text .= "=over $indent_lvl\n\n";
my $name = $cmd->command_name_brief;
my $link = $cmd->command_name;
$link =~ s/ /-/g;
my $description = $cmd->help_brief;
$text .= "=item B<L<$name|$link>>\n\n=over 2\n\n=item $description\n\n=back\n\n";
$text .= "=back\n\nE<10>\n\n";
}
}
return $text;
}
#
# The following methods build allow a command to determine its
# sub-commands, if there are any.
#
# This is for cases in which the Foo::Bar command delegates to
# Foo::Bar::Baz, Foo::Bar::Buz or Foo::Bar::Doh, depending on its paramters.
sub sub_command_dirs {
my $class = shift;
my $subdir = ref($class) || $class;
$subdir =~ s|::|\/|g;
my @dirs = grep { -d $_ } map { $_ . '/' . $subdir } @INC;
return @dirs;
}
sub sub_command_classes {
my $class = shift;
my $mapping = $class->_build_sub_command_mapping;
return values %$mapping;
}
# For compatability with Command::V1-based callers
sub is_sub_command_delegator {
return scalar(shift->sub_command_classes);
}
sub command_tree_source_classes {
# override in subclass if you want different sources
my $class = shift;
return $class;
}
sub _build_sub_command_mapping {
my $class = shift;
$class = ref($class) || $class;
my @source_classes = $class->command_tree_source_classes;
my $mapping;
do {
no strict 'refs';
$mapping = ${ $class . '::SUB_COMMAND_MAPPING'};
if (ref($mapping) eq 'HASH') {
return $mapping;
}
};
for my $source_class (@source_classes) {
# check if this class is valid
eval{ $source_class->class; };
if ( $@ ) {
warn $@;
}
# for My::Foo::Command::* commands and sub-trees
my $subdir = $source_class;
$subdir =~ s|::|\/|g;
# for My::Foo::*::Command sub-trees
my $source_class_above = $source_class;
$source_class_above =~ s/::Command//;
my $subdir2 = $source_class_above;
$subdir2 =~ s|::|/|g;
# check everywhere
for my $lib (@INC) {
my $subdir_full_path = $lib . '/' . $subdir;
# find My::Foo::Command::*
if (-d $subdir_full_path) {
my @files = glob("\Q${subdir_full_path}/*");
for my $file (@files) {
my $basename = basename($file);
$basename =~ s/.pm$// or next;
my $sub_command_class_name = $source_class . '::' . $basename;
my $sub_command_class_meta = UR::Object::Type->get($sub_command_class_name);
unless ($sub_command_class_meta) {
local $SIG{__DIE__};
local $SIG{__WARN__};
# until _use_safe is refactored to be permissive, use directly...
print ">> $sub_command_class_name\n";
eval "use $sub_command_class_name";
}
$sub_command_class_meta = UR::Object::Type->get($sub_command_class_name);
next unless $sub_command_class_name->isa("Command");
next if $sub_command_class_meta->is_abstract;
next if $sub_command_class_name eq $class;
my $name = $source_class->_command_name_for_class_word($basename);
$mapping->{$name} = $sub_command_class_name;
}
}
# find My::Foo::*::Command
$subdir_full_path = $lib . '/' . $subdir2;
my $pattern = $subdir_full_path . '/*/Command.pm';
my @paths = glob("\Q$pattern\E");
for my $file (@paths) {
next unless defined $file;
next unless length $file;
next unless -f $file;
my $last_word = File::Basename::basename($file);
$last_word =~ s/.pm$// or next;
my $dir = File::Basename::dirname($file);
my $second_to_last_word = File::Basename::basename($dir);
my $sub_command_class_name = $source_class_above . '::' . $second_to_last_word . '::' . $last_word;
next unless $sub_command_class_name->isa('Command');
next if $sub_command_class_name->__meta__->is_abstract;
next if $sub_command_class_name eq $class;
my $basename = $second_to_last_word;
$basename =~ s/.pm$//;
my $name = $source_class->_command_name_for_class_word($basename);
$mapping->{$name} = $sub_command_class_name;
}
}
}
return $mapping;
}
sub sub_command_names {
my $class = shift;
my $mapping = $class->_build_sub_command_mapping;
return keys %$mapping;
}
sub _try_command_class_named {
my $self = shift;
my $sub_class = join('::', @_);
my $meta = UR::Object::Type->get($sub_class); # allow in memory classes
unless ( $meta ) {
eval "use $sub_class;";
if ($@) {
if ($@ =~ /^Can't locate .*\.pm in \@INC/) {
#die "Failed to find $sub_class! $class_for_sub_command.pm!\n$@";
return;
}
else {
my @msg = split("\n",$@);
pop @msg;
pop @msg;
$self->error_message("$sub_class failed to compile!:\n@msg\n\n");
return;
}
}
}
elsif (my $isa = $sub_class->isa("Command")) {
if (ref($isa)) {
# dumb modules (Test::Class) mess with the standard isa() API
if ($sub_class->SUPER::isa("Command")) {
return $sub_class;
}
else {
return;
}
}
return $sub_class;
}
else {
return;
}
}
sub class_for_sub_command {
my $self = shift;
my $class = ref($self) || $self;
my $sub_command = shift;
return if $sub_command =~ /^\-/; # If it starts with a "-", then it's a command-line option
# First attempt is to convert $sub_command into a camel-case module name
# and just try loading it
my $name_for_sub_command = join("", map { ucfirst($_) } split(/-/, $sub_command));
my @class_name_parts = (split(/::/,$class), $name_for_sub_command);
my $sub_command_class = $self->_try_command_class_named(@class_name_parts);
return $sub_command_class if $sub_command_class;
# Remove "Command" if it's embedded in the middle and try inserting it in other places, starting at the end
@class_name_parts = ( ( map { $_ eq 'Command' ? () : $_ } @class_name_parts) , 'Command');
for(my $i = $#class_name_parts; $i > 0; $i--) {
$sub_command_class = $self->_try_command_class_named(@class_name_parts);
return $sub_command_class if $sub_command_class;
$class_name_parts[$i] = $class_name_parts[$i-1];
$class_name_parts[$i-1] = 'Command';
}
# Didn't find it yet. Try exhaustively loading all the command modules under $class
my $mapping = $class->_build_sub_command_mapping;
if (my $sub_command_class = $mapping->{$sub_command}) {
return $sub_command_class;
} else {
return;
}
}
my $depth = 0;
sub __extend_namespace__ {
my ($self,$ext) = @_;
my $meta = $self->SUPER::__extend_namespace__($ext);
return $meta if $meta;
$depth++;
if ($depth>1) {
$depth--;
return;
}
my $class = Command::Tree::class_for_sub_command((ref $self || $self), $self->_command_name_for_class_word($ext));
return $class->__meta__ if $class;
return;
}
1;
__END__
=pod
=head1 NAME
Command::Tree -base class for commands which delegate to a list of sub-commands
=head1 DESCRIPTION
# in Foo.pm
class Foo { is => 'Command::Tree' };
# in Foo/Cmd1.pm
class Foo::Cmd1 { is => 'Command' };
# in Foo/Cmd2.pm
class Foo::Cmd2 { is => 'Command' };
# in the shell
$ foo
cmd1
cmd2
$ foo cmd1
$ foo cmd2
=cut