allow subdispatch to apply even when the url has no trailing slash
[catagits/Web-Simple.git] / lib / Web / Dispatch / Parser.pm
CommitLineData
d63bcdae 1package Web::Dispatch::Parser;
920d6222 2
a5917caa 3sub DEBUG () { 0 }
4
5BEGIN {
d63bcdae 6 if ($ENV{WEB_DISPATCH_PARSER_DEBUG}) {
a5917caa 7 no warnings 'redefine';
8 *DEBUG = sub () { 1 }
9 }
10}
11
d63bcdae 12use Sub::Quote;
13use Web::Dispatch::Predicates;
14use Moo;
15
16has _cache => (
17 is => 'lazy', default => quote_sub q{ {} }
18);
a5917caa 19
d63bcdae 20sub diag { if (DEBUG) { warn $_[0] } }
920d6222 21
22sub _blam {
23 my ($self, $error) = @_;
a4ec359d 24 my $hat = (' ' x (pos||0)).'^';
920d6222 25 die "Error parsing dispatch specification: ${error}\n
26${_}
27${hat} here\n";
28}
29
d63bcdae 30sub parse {
920d6222 31 my ($self, $spec) = @_;
69aaa28a 32 $spec =~ s/\s+//g; # whitespace is not valid
d63bcdae 33 return $self->_cache->{$spec} ||= $self->_parse_spec($spec);
c6ea9542 34}
35
36sub _parse_spec {
b0420ad6 37 my ($self, $spec, $nested) = @_;
59ccc1e8 38 return sub { {} } unless length($spec);
c6ea9542 39 for ($_[1]) {
920d6222 40 my @match;
920d6222 41 PARSE: { do {
c6ea9542 42 push @match, $self->_parse_spec_section($_)
920d6222 43 or $self->_blam("Unable to work out what the next section is");
b0420ad6 44 if (/\G\)/gc) {
45 $self->_blam("Found closing ) with no opening (") unless $nested;
46 last PARSE;
47 }
920d6222 48 last PARSE if (pos == length);
c6ea9542 49 $match[-1] = $self->_parse_spec_combinator($_, $match[-1])
50 or $self->_blam('No valid combinator - expected + or |');
920d6222 51 } until (pos == length) }; # accept trailing whitespace
b0420ad6 52 if ($nested and pos == length) {
a4ec359d 53 pos = $nested - 1;
b0420ad6 54 $self->_blam("No closing ) found for opening (");
55 }
920d6222 56 return $match[0] if (@match == 1);
d63bcdae 57 return match_and(@match);
920d6222 58 }
59}
60
9b9866ae 61sub _parse_spec_combinator {
62 my ($self, $spec, $match) = @_;
63 for ($_[1]) {
64
65 /\G\+/gc and
66 return $match;
67
68 /\G\|/gc and
69 return do {
70 my @match = $match;
71 PARSE: { do {
72 push @match, $self->_parse_spec_section($_)
73 or $self->_blam("Unable to work out what the next section is");
74 last PARSE if (pos == length);
75 last PARSE unless /\G\|/gc; # give up when next thing isn't |
76 } until (pos == length) }; # accept trailing whitespace
d63bcdae 77 return match_or(@match);
9b9866ae 78 };
79 }
80 return;
81}
82
920d6222 83sub _parse_spec_section {
84 my ($self) = @_;
85 for ($_[1]) {
86
87 # GET POST PUT HEAD ...
88
89 /\G([A-Z]+)/gc and
e954644c 90 return match_method($1);
920d6222 91
92 # /...
93
94 /\G(?=\/)/gc and
95 return $self->_url_path_match($_);
96
c6ea9542 97 # .* and .html
98
99 /\G\.(\*|\w+)/gc and
e954644c 100 return match_extension($1);
b0420ad6 101
2ee4ab06 102 # (...)
b0420ad6 103
104 /\G\(/gc and
105 return $self->_parse_spec($_, pos);
2ee4ab06 106
107 # !something
108
109 /\G!/gc and
ce573717 110 return match_not($self->_parse_spec_section($_));
920d6222 111
9b9866ae 112 # ?<param spec>
113 /\G\?/gc and
114 return $self->_parse_param_handler($_, 'query');
53d47b78 115
116 # %<param spec>
117 /\G\%/gc and
118 return $self->_parse_param_handler($_, 'body');
05aafc1a 119
120 # *<param spec>
121 /\G\*/gc and
122 return $self->_parse_param_handler($_, 'uploads');
c6ea9542 123 }
9b9866ae 124 return; # () will trigger the blam in our caller
c6ea9542 125}
126
920d6222 127sub _url_path_match {
128 my ($self) = @_;
920d6222 129 for ($_[1]) {
130 my @path;
e7dd1c4b 131 my $end = '';
15e679c1 132 my $keep_dot;
da8429c9 133 PATH: while (/\G\//gc) {
134 /\G\.\.\./gc
135 and do {
e7dd1c4b 136 $end = '(/.*)';
da8429c9 137 last PATH;
138 };
920d6222 139 push @path, $self->_url_path_segment_match($_)
140 or $self->_blam("Couldn't parse path match segment");
8c51c01a 141 /\G\.\.\./gc
142 and do {
143 $end = '(.*)';
144 last PATH;
145 };
15e679c1 146 /\G\.\*/gc
147 and do {
148 $keep_dot = 1;
149 last PATH;
150 };
920d6222 151 }
15e679c1 152 if (@path && !$end && !$keep_dot) {
4ed4fb42 153 length and $_ .= '(?:\.\w+)?' for $path[-1];
154 }
e7dd1c4b 155 my $re = '^('.join('/','',@path).')'.$end.'$';
da8429c9 156 $re = qr/$re/;
e7dd1c4b 157 if ($end) {
158 return match_path_strip($re);
159 } else {
d63bcdae 160 return match_path($re);
da8429c9 161 }
920d6222 162 }
163 return;
164}
165
166sub _url_path_segment_match {
167 my ($self) = @_;
168 for ($_[1]) {
169 # trailing / -> require / on end of URL
6c0f599a 170 /\G(?:(?=[+|\)])|$)/gc and
e7dd1c4b 171 return '';
920d6222 172 # word chars only -> exact path part match
c2cf0534 173 /
174 \G(
175 (?: # start matching at a space followed by:
176 [\w\-] # word chars or dashes
177 | # OR
178 \. # a period
179 (?!\.) # not followed by another period
180 )
181 + # then grab as far as possible
182 )
183 /gcx and
920d6222 184 return "\Q$1";
28f3dfd5 185 # ** -> capture unlimited path parts
186 /\G\*\*/gc and
187 return '(.*?[^/])';
920d6222 188 # * -> capture path part
189 /\G\*/gc and
4ed4fb42 190 return '([^/]+?)';
920d6222 191 }
192 return ();
193}
194
9b9866ae 195sub _parse_param_handler {
196 my ($self, $spec, $type) = @_;
197
9b9866ae 198 for ($_[1]) {
eb9e0e25 199 my (@required, @single, %multi, $star, $multistar, %positional, $have_kw);
052bdd54 200 my %spec;
eb9e0e25 201 my $pos_idx = 0;
9b9866ae 202 PARAM: { do {
203
eb9e0e25 204 # ?:foo or ?@:foo
9b9866ae 205
eb9e0e25 206 my $is_kw = /\G\:/gc;
9b9866ae 207
208 # ?@foo or ?@*
209
eb9e0e25 210 my $multi = /\G\@/gc;
9b9866ae 211
212 # @* or *
213
a5917caa 214 if (/\G\*/gc) {
9b9866ae 215
eb9e0e25 216 $self->_blam("* is always named; no need to supply :") if $is_kw;
217
052bdd54 218 if ($star) {
219 $self->_blam("Can only use one * or \@* in a parameter match");
a5917caa 220 }
052bdd54 221
222 $spec{star} = { multi => $multi };
9b9866ae 223 } else {
224
225 # @foo= or foo= or @foo~ or foo~
6c0f599a 226
9b9866ae 227 /\G(\w+)/gc or $self->_blam('Expected parameter name');
228
229 my $name = $1;
230
231 # check for = or ~ on the end
232
233 /\G\=/gc
052bdd54 234 ? push(@{$spec{required}||=[]}, $name)
9b9866ae 235 : (/\G\~/gc or $self->_blam('Expected = or ~ after parameter name'));
236
eb9e0e25 237 # record positional or keyword
238
052bdd54 239 push @{$spec{$is_kw ? 'named' : 'positional'}||=[]},
240 { name => $name, multi => $multi };
9b9866ae 241 }
242 } while (/\G\&/gc) }
243
b6bf9ed3 244 return Web::Dispatch::Predicates->can("match_${type}")->(\%spec);
9b9866ae 245 }
246}
247
920d6222 2481;