basic named path part matching
[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) = @_;
bc878dde 38 return match_true() 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
a96dd5cb 87 # ~
88
89 /\G~/gc and
90 return match_path('^$');
91
920d6222 92 # GET POST PUT HEAD ...
93
94 /\G([A-Z]+)/gc and
e954644c 95 return match_method($1);
920d6222 96
97 # /...
98
99 /\G(?=\/)/gc and
100 return $self->_url_path_match($_);
101
c6ea9542 102 # .* and .html
103
104 /\G\.(\*|\w+)/gc and
e954644c 105 return match_extension($1);
b0420ad6 106
2ee4ab06 107 # (...)
b0420ad6 108
109 /\G\(/gc and
110 return $self->_parse_spec($_, pos);
2ee4ab06 111
112 # !something
113
114 /\G!/gc and
ce573717 115 return match_not($self->_parse_spec_section($_));
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');
05aafc1a 124
125 # *<param spec>
126 /\G\*/gc and
127 return $self->_parse_param_handler($_, 'uploads');
c6ea9542 128 }
9b9866ae 129 return; # () will trigger the blam in our caller
c6ea9542 130}
131
920d6222 132sub _url_path_match {
133 my ($self) = @_;
920d6222 134 for ($_[1]) {
b83ac307 135 my (@path, @names, $seen_nameless);
e7dd1c4b 136 my $end = '';
15e679c1 137 my $keep_dot;
da8429c9 138 PATH: while (/\G\//gc) {
139 /\G\.\.\./gc
140 and do {
e7dd1c4b 141 $end = '(/.*)';
da8429c9 142 last PATH;
143 };
b83ac307 144
145 my ($segment) = $self->_url_path_segment_match($_)
920d6222 146 or $self->_blam("Couldn't parse path match segment");
b83ac307 147
148 if (ref($segment)) {
149 ($segment, $keep_dot, my $name) = @$segment;
150 if (defined($name)) {
151 $self->_blam("Can't mix positional and named captures in path match")
152 if $seen_nameless;
153 push @names, $name;
154 } else {
155 $self->_blam("Can't mix positional and named captures in path match")
156 if @names;
157 $seen_nameless = 1;
158 }
159 }
160 push @path, $segment;
161
8c51c01a 162 /\G\.\.\./gc
163 and do {
bb0dbe7c 164 $end = '(|/.*)';
8c51c01a 165 last PATH;
166 };
15e679c1 167 /\G\.\*/gc
b83ac307 168 and $keep_dot = 1;
169
170 last PATH if $keep_dot;
920d6222 171 }
15e679c1 172 if (@path && !$end && !$keep_dot) {
4ed4fb42 173 length and $_ .= '(?:\.\w+)?' for $path[-1];
174 }
e7dd1c4b 175 my $re = '^('.join('/','',@path).')'.$end.'$';
da8429c9 176 $re = qr/$re/;
e7dd1c4b 177 if ($end) {
b83ac307 178 return match_path_strip($re, @names ? \@names : ());
e7dd1c4b 179 } else {
b83ac307 180 return match_path($re, @names ? \@names : ());
da8429c9 181 }
920d6222 182 }
183 return;
184}
185
186sub _url_path_segment_match {
187 my ($self) = @_;
188 for ($_[1]) {
189 # trailing / -> require / on end of URL
6c0f599a 190 /\G(?:(?=[+|\)])|$)/gc and
e7dd1c4b 191 return '';
920d6222 192 # word chars only -> exact path part match
c2cf0534 193 /
194 \G(
195 (?: # start matching at a space followed by:
196 [\w\-] # word chars or dashes
197 | # OR
198 \. # a period
199 (?!\.) # not followed by another period
200 )
201 + # then grab as far as possible
202 )
203 /gcx and
920d6222 204 return "\Q$1";
28f3dfd5 205 # ** -> capture unlimited path parts
b83ac307 206 /\G\*\*(?:(\.\*)?\:(\w+))?/gc and
207 return [ '(.*?[^/])', $1, $2 ];
920d6222 208 # * -> capture path part
b83ac307 209 # *:name -> capture named path part
210 /\G\*(?:(\.\*)?\:(\w+))?/gc and
211 return [ '([^/]+?)', $1, $2 ];
212
213 # :name -> capture named path part
214 /\G\:(\w+)/gc and
215 return [ '([^/]+?)', 0, $1 ];
920d6222 216 }
217 return ();
218}
219
9b9866ae 220sub _parse_param_handler {
221 my ($self, $spec, $type) = @_;
222
9b9866ae 223 for ($_[1]) {
eb9e0e25 224 my (@required, @single, %multi, $star, $multistar, %positional, $have_kw);
052bdd54 225 my %spec;
eb9e0e25 226 my $pos_idx = 0;
9b9866ae 227 PARAM: { do {
228
eb9e0e25 229 # ?:foo or ?@:foo
9b9866ae 230
eb9e0e25 231 my $is_kw = /\G\:/gc;
9b9866ae 232
233 # ?@foo or ?@*
234
eb9e0e25 235 my $multi = /\G\@/gc;
9b9866ae 236
237 # @* or *
238
a5917caa 239 if (/\G\*/gc) {
9b9866ae 240
eb9e0e25 241 $self->_blam("* is always named; no need to supply :") if $is_kw;
242
052bdd54 243 if ($star) {
244 $self->_blam("Can only use one * or \@* in a parameter match");
a5917caa 245 }
052bdd54 246
247 $spec{star} = { multi => $multi };
9b9866ae 248 } else {
249
250 # @foo= or foo= or @foo~ or foo~
6c0f599a 251
9b9866ae 252 /\G(\w+)/gc or $self->_blam('Expected parameter name');
253
254 my $name = $1;
255
256 # check for = or ~ on the end
257
258 /\G\=/gc
052bdd54 259 ? push(@{$spec{required}||=[]}, $name)
9b9866ae 260 : (/\G\~/gc or $self->_blam('Expected = or ~ after parameter name'));
261
eb9e0e25 262 # record positional or keyword
263
052bdd54 264 push @{$spec{$is_kw ? 'named' : 'positional'}||=[]},
265 { name => $name, multi => $multi };
9b9866ae 266 }
267 } while (/\G\&/gc) }
268
b6bf9ed3 269 return Web::Dispatch::Predicates->can("match_${type}")->(\%spec);
9b9866ae 270 }
271}
272
920d6222 2731;