The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Path::Dispatcher::Rule::Tokens;
use Any::Moose;
extends 'Path::Dispatcher::Rule';

has tokens => (
    is         => 'ro',
    isa        => 'ArrayRef',
    auto_deref => 1,
    required   => 1,
);

has delimiter => (
    is      => 'ro',
    isa     => 'Str',
    default => ' ',
);

has case_sensitive => (
    is      => 'ro',
    isa     => 'Bool',
    default => 1,
);

sub _match_as_far_as_possible {
    my $self = shift;
    my $path = shift;

    my @got      = $self->tokenize($path->path);
    my @expected = $self->tokens;
    my @matched;

    while (@got && @expected) {
        my $expected = $expected[0];
        my $got      = $got[0];

        last unless $self->_match_token($got, $expected);

        push @matched, $got;
        shift @expected;
        shift @got;
    }

    return (\@matched, \@got, \@expected);
}

sub _match {
    my $self = shift;
    my $path = shift;

    my ($matched, $got, $expected) = $self->_match_as_far_as_possible($path);

    return if @$expected; # didn't provide everything necessary
    return if @$got && !$self->prefix; # had tokens left over

    my $leftover = $self->untokenize(@$got);

    return if !$matched;

    return {
        positional_captures => $matched,
        leftover            => $leftover,
    };
}

sub complete {
    my $self = shift;
    my $path = shift;

    my ($matched, $got, $expected) = $self->_match_as_far_as_possible($path);
    return if @$got > 1; # had tokens leftover
    return if !@$expected; # consumed all tokens

    my $next = shift @$expected;
    my $part = @$got ? shift @$got : '';
    my @completions;

    for my $completion (ref($next) eq 'ARRAY' ? @$next : $next) {
        next if ref($completion);

        next unless substr($completion, 0, length($part)) eq $part;
        push @completions, $self->untokenize(@$matched, $completion);
    }

    return @completions;
}

sub _each_token {
    my $self     = shift;
    my $got      = shift;
    my $expected = shift;
    my $callback = shift;

    if (ref($expected) eq 'ARRAY') {
        for my $alternative (@$expected) {
            $self->_each_token($got, $alternative, $callback);
        }
    }
    elsif (!ref($expected) || ref($expected) eq 'Regexp') {
        $callback->($got, $expected);
    }
    else {
        die "Unexpected token '$expected'"; # the irony is not lost on me :)
    }
}

sub _match_token {
    my $self     = shift;
    my $got      = shift;
    my $expected = shift;

    my $matched = 0;
    $self->_each_token($got, $expected, sub {
        my ($g, $e) = @_;
        if (!ref($e)) {
            ($g, $e) = (lc $g, lc $e) if !$self->case_sensitive;
            $matched ||= $g eq $e;
        }
        elsif (ref($e) eq 'Regexp') {
            $matched ||= $g =~ $e;
        }
    });

    return $matched;
}

sub tokenize {
    my $self = shift;
    my $path = shift;
    return grep { length } split $self->delimiter, $path;
}

sub untokenize {
    my $self   = shift;
    my @tokens = @_;
    return join $self->delimiter,
           grep { length }
           map { split $self->delimiter, $_ }
           @tokens;
}

__PACKAGE__->meta->make_immutable;
no Any::Moose;

1;

__END__

=head1 NAME

Path::Dispatcher::Rule::Tokens - predicate is a list of tokens

=head1 SYNOPSIS

    my $rule = Path::Dispatcher::Rule::Tokens->new(
        tokens    => [ "comment", "show", qr/^\d+$/ ],
        delimiter => '/',
        block     => sub { display_comment(shift->pos(3)) },
    );

    $rule->match("/comment/show/25");

=head1 DESCRIPTION

Rules of this class use a list of tokens to match the path.

=head1 ATTRIBUTES

=head2 tokens

Each token can be a literal string, a regular expression, or a list of either
(which are taken to mean alternations). For example, the tokens:

    [ 'ticket', [ 'show', 'display' ], [ qr/^\d+$/, qr/^#\w{3}/ ] ]

first matches "ticket". Then, the next token must be "show" or "display". The
final token must be a number or a pound sign followed by three word characters.

The results are the tokens in the original string, as they were matched. If you
have three tokens, then C<< match->pos(1) >> will be the string's first token
("ticket"), C<< match->pos(2) >> its second ("display"), and C<< match->pos(3)
>> its third ("#AAA").

Capture groups inside a regex token are completely ignored.

=head2 delimiter

A string that is used to tokenize the path. The delimiter must be a string
because prefix matches use C<join> on unmatched tokens to return the leftover
path. In the future this may be extended to support having a regex delimiter.

The default is a space, but if you're matching URLs you probably want to change
this to a slash.

=head2 case_sensitive

Decide whether the rule matching is case sensitive. Default is 1, case
sensitive matching.

=cut