The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
package String::Range::Expand;

#######################
# LOAD MODULES
#######################
use strict;
use warnings FATAL => 'all';
use Carp qw(croak carp);

#######################
# VERSION
#######################
our $VERSION = '0.04';

#######################
# EXPORT
#######################
use base qw(Exporter);
our ( @EXPORT, @EXPORT_OK );

@EXPORT    = qw(expand_range);
@EXPORT_OK = qw(expand_range expand_expr);

#######################
# PUBLIC FUNCTIONS
#######################
sub expand_range {
    my ($range_expr) = @_;

    my @range;

    # Define a valid range
    my $valid_range = qr{[a-zA-Z0-9\,\-\^]+}x;

    # split expression into ranges
    my @bits;
    if ( $range_expr =~ m{\[$valid_range\]}x ) {

        # This is a Range
        # Loop thru' multiple instances (e.g. [a-c][f-i])
        while (1) {

            if ( $range_expr =~ m{(\[$valid_range\])}x ) {
                my $match = $+;
                my $pre = substr( $range_expr, 0, $-[0] );
                push @bits, ($pre) if defined $pre;
                push @bits, $match;
                substr( $range_expr, 0, $+[0], '' );
            } ## end if ( $range_expr =~ m{(\[$valid_range\])}x)
            else {
                push @bits, $range_expr;
                $range_expr = '';
            } ## end else [ if ( $range_expr =~ m{(\[$valid_range\])}x)]
          last unless $range_expr;

        } ## end while (1)
    } ## end if ( $range_expr =~ m{\[$valid_range\]}x)
    else {

        # Expression does not have any ranges to expand
        push @range, $range_expr;
    } ## end else [ if ( $range_expr =~ m{\[$valid_range\]}x)]

    # Expand
    foreach my $_bit (@bits) {
        if ( $_bit =~ m{^\[(.+)\]$}x ) {
            @range
              ? do { @range = _combine( \@range, [ _compute($1) ] ); }
              : do { @range = _compute($1); };
        } ## end if ( $_bit =~ m{^\[(.+)\]$}x)
        else {
            @range
              ? do { @range = _combine( \@range, [$_bit] ); }
              : do { push( @range, $_bit ); };
        } ## end else [ if ( $_bit =~ m{^\[(.+)\]$}x)]
    } ## end foreach my $_bit (@bits)

    @range = sort { lc($a) cmp lc($b) } @range if @range;
  return @range;
} ## end sub expand_range


sub expand_expr {
    my @range;
    foreach my $expr ( _split_expr(@_) ) {
        push @range, expand_range($expr);
    }
    @range = sort { lc($a) cmp lc($b) } @range if @range;
  return @range;
} ## end sub expand_expr

#######################
# INTERNAL FUNCTIONS
#######################

## _compute
##  This performs the actual expansion
sub _compute {
    my ($expr) = @_;

    my @list;  # Expanded values

    # Loop thru' ranges
    foreach my $_range ( split( /,/x, $expr ) ) {

        # Type: [aa-az]. Normal Range
        if ( $_range =~ m{^(\w+)\-(\w+)$}x ) { push @list, ( $1 .. $2 ); }

        # Type: [^ba-be]. Negate range
        elsif ( $_range =~ m{^\^(\w+)\-(\w+)$}x ) {
            foreach my $_exclude ( $1 .. $2 ) {
                @list = grep { !/^$_exclude$/x } @list;
            }
        } ## end elsif ( $_range =~ m{^\^(\w+)\-(\w+)$}x)

        # Type: [^zz]. Negate element
        elsif ( $_range =~ m{^\^(\w+)$}x ) {
            @list = grep { !/^$1$/x } @list;
        }

        # Type: [foo]. Individual element
        else { push @list, $_range; }
    } ## end foreach my $_range ( split(...))
  return @list;
} ## end sub _compute

## _combine
sub _combine {
    my ( $a1, $a2 ) = @_;

    my @list;

    foreach my $_a1 (@$a1) {
        foreach my $_a2 (@$a2) {
            push @list, join( '', $_a1, $_a2 );
        }
    } ## end foreach my $_a1 (@$a1)

  return @list;
} ## end sub _combine

## split string into range expressions
sub _split_expr {
    my @args = @_;
    my @found;
    foreach my $arg (@args) {
        my @parts = split( /\s*(?<!\\)[\s,]\s*/, $arg );
        while ( my $bit = shift @parts ) {
          next unless $bit =~ m{^\S+$};
            if ( $bit =~ m{\[} and $bit !~ m{\]} ) {
                my @current = ($bit);
                while ( my $next = shift @parts ) {
                    push @current, $next;
                  last if $next =~ m{\]};
                } ## end while ( my $next = shift ...)
                push @found, join( ',', @current );
            } ## end if ( $bit =~ m{\[} and...{]})
            elsif ( $bit =~ m{\]} and $bit !~ m{\[} ) {
                my $previous = pop @found;
                push @found, join( ',', $previous, $bit );
            } ## end elsif ( $bit =~ m{\]} and...{[})
            else {
                push @found, $bit;
            }
        } ## end while ( my $bit = shift @parts)
    } ## end foreach my $arg (@args)
  return @found;
} ## end sub _split_expr

#######################
1;

__END__

#######################
# POD SECTION
#######################
=pod

=head1 NAME

String::Range::Expand - Expand range-like strings

=head1 SYNOPSIS

    use String::Range::Expand;

    print "$_\n" for expand_range('host[aa-ac,^ab,ae][01-04,^02-03]');

    # Prints ...
        # hostaa01
        # hostaa04
        # hostac01
        # hostac04
        # hostae01
        # hostae04

=head1 DESCRIPTION

This module provides functions to expand a string that contains
range-like expressions. This is something that is usually useful when
working with hostnames, but can be used elsewhere too.

=head1 FUNCTIONS

=head2 expand_range($string)

    my @list = expand_range('...');

This function accept a single string, evaluates expressions in those
strings and returns a list with all available permutations. Ranges with
limits are expanded using the L<Range
Operator|http://perldoc.perl.org/perlop.html#Range-Operators>.

    my @list = expand_range('[aa-ad]'); # This is identical to ('aa' .. 'ad')

The following formats are recognized and evaluated

    my @list = expand_range('foo[bar,baz]');        # Comma separated list
    my @list = expand_range('foo[aa-ad,^ab]');      # Negated element
    my @list = expand_range('foo[aa-ag,^ab-ad]');   # Negated range

=head2 expand_expr(@array)

	my @list = expand_expr('foo-bar[01-03] host[aa-ad,^ab]Z[01-04,^02-03].name');

This runs C<expand_range> against every range-like expression detected
in the argument list

=head1 SEE ALSO

=over

=item L<SSH::Batch>

This is an extremely useful distribution if you are working with
hostnames. C<String::Range::Expand> was inspired by this distribution,
and provides only a subset of features of C<SSH::Batch>

=item L<String::Glob::Permute>

Pretty similar, but does not evaluate alphabetical ranges

=item L<Text::Glob::Expand>

Like C<String::Glob::Permute>, it does not evaluate alphabetical
ranges. But it does provide some additional functionality like setting
upper limits and formatting.

=back

=head1 BUGS AND LIMITATIONS

This module does not attempt to limit the number of permutations for an
expression.

Please report any bugs or feature requests to
C<bug-string-range-expand@rt.cpan.org>, or through the web interface at
L<http://rt.cpan.org/Public/Dist/Display.html?Name=String-Range-Expand>

=head1 AUTHOR

Mithun Ayachit C<mithun@cpan.org>

=head1 LICENSE AND COPYRIGHT

Copyright (c) 2013, Mithun Ayachit. All rights reserved.

This module is free software; you can redistribute it and/or modify it
under the same terms as Perl itself. See L<perlartistic>.

=cut