make non-/-terminated path matches allow an extension
[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) = @_;
d63bcdae 32 return $self->_cache->{$spec} ||= $self->_parse_spec($spec);
c6ea9542 33}
34
35sub _parse_spec {
b0420ad6 36 my ($self, $spec, $nested) = @_;
c6ea9542 37 for ($_[1]) {
920d6222 38 my @match;
920d6222 39 /^\G\s*/; # eat leading whitespace
40 PARSE: { do {
c6ea9542 41 push @match, $self->_parse_spec_section($_)
920d6222 42 or $self->_blam("Unable to work out what the next section is");
b0420ad6 43 if (/\G\)/gc) {
44 $self->_blam("Found closing ) with no opening (") unless $nested;
45 last PARSE;
46 }
920d6222 47 last PARSE if (pos == length);
c6ea9542 48 $match[-1] = $self->_parse_spec_combinator($_, $match[-1])
49 or $self->_blam('No valid combinator - expected + or |');
920d6222 50 } until (pos == length) }; # accept trailing whitespace
b0420ad6 51 if ($nested and pos == length) {
a4ec359d 52 pos = $nested - 1;
b0420ad6 53 $self->_blam("No closing ) found for opening (");
54 }
920d6222 55 return $match[0] if (@match == 1);
d63bcdae 56 return match_and(@match);
920d6222 57 }
58}
59
9b9866ae 60sub _parse_spec_combinator {
61 my ($self, $spec, $match) = @_;
62 for ($_[1]) {
63
64 /\G\+/gc and
65 return $match;
66
67 /\G\|/gc and
68 return do {
69 my @match = $match;
70 PARSE: { do {
71 push @match, $self->_parse_spec_section($_)
72 or $self->_blam("Unable to work out what the next section is");
73 last PARSE if (pos == length);
74 last PARSE unless /\G\|/gc; # give up when next thing isn't |
75 } until (pos == length) }; # accept trailing whitespace
d63bcdae 76 return match_or(@match);
9b9866ae 77 };
78 }
79 return;
80}
81
920d6222 82sub _parse_spec_section {
83 my ($self) = @_;
84 for ($_[1]) {
85
86 # GET POST PUT HEAD ...
87
88 /\G([A-Z]+)/gc and
89 return $self->_http_method_match($_, $1);
90
91 # /...
92
93 /\G(?=\/)/gc and
94 return $self->_url_path_match($_);
95
c6ea9542 96 # .* and .html
97
98 /\G\.(\*|\w+)/gc and
920d6222 99 return $self->_url_extension_match($_, $1);
b0420ad6 100
2ee4ab06 101 # (...)
b0420ad6 102
103 /\G\(/gc and
104 return $self->_parse_spec($_, pos);
2ee4ab06 105
106 # !something
107
108 /\G!/gc and
109 return do {
110 my $match = $self->_parse_spec_section($_);
111 return sub {
112 return {} unless $match->(@_);
113 return;
114 };
115 };
920d6222 116
9b9866ae 117 # ?<param spec>
118 /\G\?/gc and
119 return $self->_parse_param_handler($_, 'query');
53d47b78 120
121 # %<param spec>
122 /\G\%/gc and
123 return $self->_parse_param_handler($_, 'body');
c6ea9542 124 }
9b9866ae 125 return; # () will trigger the blam in our caller
c6ea9542 126}
127
920d6222 128sub _http_method_match {
129 my ($self, $str, $method) = @_;
d63bcdae 130 match_method($method);
920d6222 131}
132
133sub _url_path_match {
134 my ($self) = @_;
920d6222 135 for ($_[1]) {
136 my @path;
e7dd1c4b 137 my $end = '';
da8429c9 138 PATH: while (/\G\//gc) {
139 /\G\.\.\./gc
140 and do {
e7dd1c4b 141 $end = '(/.*)';
da8429c9 142 last PATH;
143 };
920d6222 144 push @path, $self->_url_path_segment_match($_)
145 or $self->_blam("Couldn't parse path match segment");
146 }
e7dd1c4b 147 !$end and length and $_ .= '(?:\.\w+)?' for $path[-1];
148 my $re = '^('.join('/','',@path).')'.$end.'$';
da8429c9 149 $re = qr/$re/;
e7dd1c4b 150 if ($end) {
151 return match_path_strip($re);
152 } else {
d63bcdae 153 return match_path($re);
da8429c9 154 }
920d6222 155 }
156 return;
157}
158
159sub _url_path_segment_match {
160 my ($self) = @_;
161 for ($_[1]) {
162 # trailing / -> require / on end of URL
6c0f599a 163 /\G(?:(?=[+|\)])|$)/gc and
e7dd1c4b 164 return '';
920d6222 165 # word chars only -> exact path part match
166 /\G(\w+)/gc and
167 return "\Q$1";
28f3dfd5 168 # ** -> capture unlimited path parts
169 /\G\*\*/gc and
170 return '(.*?[^/])';
920d6222 171 # * -> capture path part
172 /\G\*/gc and
173 return '([^/]+)';
174 }
175 return ();
176}
177
178sub _url_extension_match {
179 my ($self, $str, $extension) = @_;
c6ea9542 180 if ($extension eq '*') {
181 sub {
182 if ((my $tmp = shift->{PATH_INFO}) =~ s/\.(\w+)$//) {
183 ({ PATH_INFO => $tmp }, $1);
184 } else {
185 ();
186 }
187 };
188 } else {
189 sub {
190 if ((my $tmp = shift->{PATH_INFO}) =~ s/\.\Q${extension}\E$//) {
191 ({ PATH_INFO => $tmp });
192 } else {
193 ();
194 }
195 };
196 }
920d6222 197}
198
9b9866ae 199sub _parse_param_handler {
200 my ($self, $spec, $type) = @_;
201
202 require Web::Simple::ParamParser;
203 my $unpacker = Web::Simple::ParamParser->can("get_unpacked_${type}_from");
204
205 for ($_[1]) {
eb9e0e25 206 my (@required, @single, %multi, $star, $multistar, %positional, $have_kw);
207 my $pos_idx = 0;
9b9866ae 208 PARAM: { do {
209
eb9e0e25 210 # ?:foo or ?@:foo
9b9866ae 211
eb9e0e25 212 my $is_kw = /\G\:/gc;
9b9866ae 213
214 # ?@foo or ?@*
215
eb9e0e25 216 my $multi = /\G\@/gc;
9b9866ae 217
218 # @* or *
219
a5917caa 220 if (/\G\*/gc) {
9b9866ae 221
eb9e0e25 222 $self->_blam("* is always named; no need to supply :") if $is_kw;
223
9b9866ae 224 $multi ? ($multistar = 1) : ($star = 1);
a5917caa 225
eb9e0e25 226 $have_kw = 1;
227
a5917caa 228 if ($star && $multistar) {
229 $self->_blam("Can't use * and \@* in the same parameter match");
230 }
9b9866ae 231 } else {
232
233 # @foo= or foo= or @foo~ or foo~
6c0f599a 234
9b9866ae 235 /\G(\w+)/gc or $self->_blam('Expected parameter name');
236
237 my $name = $1;
238
239 # check for = or ~ on the end
240
241 /\G\=/gc
242 ? push(@required, $name)
243 : (/\G\~/gc or $self->_blam('Expected = or ~ after parameter name'));
244
245 # record the key in the right category depending on the multi (@) flag
246
a5917caa 247 $multi ? ($multi{$name} = 1) : (push @single, $name);
eb9e0e25 248
249 # record positional or keyword
250
251 $is_kw ? ($have_kw = 1) : ($positional{$name} = $pos_idx++);
9b9866ae 252 }
253 } while (/\G\&/gc) }
254
255 return sub {
256 my $raw = $unpacker->($_[0]);
257 foreach my $name (@required) {
258 return unless exists $raw->{$name};
259 }
eb9e0e25 260 my (%p, %done);
261 my @p = (undef) x $pos_idx;
9b9866ae 262 foreach my $name (
263 @single,
264 ($star
265 ? (grep { !exists $multi{$_} } keys %$raw)
266 : ()
267 )
268 ) {
eb9e0e25 269 if (exists $raw->{$name}) {
270 if (exists $positional{$name}) {
271 $p[$positional{$name}] = $raw->{$name}->[-1];
272 } else {
273 $p{$name} = $raw->{$name}->[-1];
274 }
275 }
276 $done{$name} = 1;
9b9866ae 277 }
278 foreach my $name (
279 keys %multi,
280 ($multistar
eb9e0e25 281 ? (grep { !exists $done{$_} && !exists $multi{$_} } keys %$raw)
9b9866ae 282 : ()
283 )
284 ) {
eb9e0e25 285 if (exists $positional{$name}) {
286 $p[$positional{$name}] = $raw->{$name}||[];
287 } else {
288 $p{$name} = $raw->{$name}||[];
289 }
9b9866ae 290 }
eb9e0e25 291 $p[$pos_idx] = \%p if $have_kw;
292 return ({}, @p);
9b9866ae 293 };
294 }
295}
296
920d6222 2971;