| File: | blib/lib/Params/Validate/Strict/BNF.pm |
| Coverage: | 99.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Params::Validate::Strict::BNF; | |||||
| 2 | ||||||
| 3 | 3 3 3 | 8008 3 37 | use strict; | |||
| 4 | 3 3 3 | 5 3 52 | use warnings; | |||
| 5 | 3 3 3 | 5 3 52 | use Carp qw(croak); | |||
| 6 | 3 3 3 | 4 3 35 | use Exporter qw(import); | |||
| 7 | 3 3 3 | 10 8 1178 | use Params::Validate::Strict qw(validate_strict); | |||
| 8 | ||||||
| 9 | our $VERSION = '0.41'; | |||||
| 10 | our @EXPORT_OK = qw(bnf_to_matcher); | |||||
| 11 | ||||||
| 12 | my %_cache; | |||||
| 13 | ||||||
| 14 - 177 | =head1 NAME
Params::Validate::Strict::BNF - compile a BNF grammar to a string matcher
=head1 SYNOPSIS
use Params::Validate::Strict::BNF qw(bnf_to_matcher);
my $matcher = bnf_to_matcher([
'<greeting> ::= "hello" | "hi"',
]);
$matcher->('hello'); # 1
$matcher->('bye'); # 0
=head1 DESCRIPTION
Converts an arrayref of BNF grammar lines into a closure that tests whether
a string is a member of the language defined by that grammar.
Used internally by L<Params::Validate::Strict> when a schema rule includes a
C<bnf> key. Results are cached by grammar content, so repeated calls with
the same grammar pay no compilation cost after the first call.
=head1 GRAMMAR FORMAT
Each element of the arrayref is either a rule definition or a continuation line.
=head2 Rule definition
'<rule-name> ::= <alternatives>'
The left-hand side is a non-terminal name enclosed in angle brackets.
The right-hand side is one or more alternatives separated by C<|>.
=head2 Continuation lines
A line that contains no C<::=> is appended (with a space) to the preceding
rule's right-hand side. This lets long rules span multiple array elements:
'<telephone-number> ::= <country-code-opt> <area-code> <separator-opt>',
'<central-office-code> <separator-opt> <station-code>',
=head2 Terminals
Literal text is enclosed in double quotes. An empty terminal C<""> matches
the empty string (makes a production optional):
'<sep> ::= "" | "-" | " "'
Backslash escapes inside terminals (e.g. C<"\"">) are passed through to the
compiled regex via C<quotemeta>, so no special treatment is needed for regex
metacharacters.
=head2 Non-terminals
References to other rules are enclosed in angle brackets: C<< <rule-name> >>.
Recursive rules are not supported and will raise an exception.
=head2 Start rule
The first rule defined in the grammar is the start rule. A value must match
the language generated by that rule (anchored C<^...$>) to be accepted.
=head1 FUNCTIONS
=head2 bnf_to_matcher( \@grammar_lines )
=head3 Purpose
Compiles a BNF grammar into a reusable string-membership predicate.
Parses the grammar lines into production rules, builds a compiled regular
expression, and returns a closure that tests whether a given string belongs
to the language. Results are cached by grammar content so repeated calls with
identical grammar lines pay the compilation cost only once.
=head3 Arguments
=head4 grammar_lines (required)
An arrayref of one or more BNF grammar lines. Each line is either a rule
definition (C<< '<name> ::= <rhs>' >>) or a continuation line (no C<::=>,
appended to the preceding rule with a space). Terminals are double-quoted;
non-terminals use angle brackets; alternatives are separated by C<|>.
=head3 Returns
A code reference C<sub ($str) -> 0|1> that returns C<1> if C<$str> is defined
and matches the start rule, C<0> otherwise (including when C<$str> is undef).
=head3 Side Effects
The compiled matcher is stored in a module-level cache keyed by grammar
content and is never freed. Grammars are expected to be defined once at
program start-up and reused across many calls.
=head3 Usage Example
use Params::Validate::Strict::BNF qw(bnf_to_matcher);
my $is_colour = bnf_to_matcher([
'<colour> ::= "red" | "green" | "blue"',
]);
$is_colour->('red'); # 1
$is_colour->('purple'); # 0
$is_colour->(undef); # 0
=head3 API SPECIFICATION
=head4 Input
grammar_lines => {
type => 'arrayref',
min => 1,
}
=head4 Output
{ type => 'coderef' }
The returned coderef has the signature C<sub ($str) -> 0|1>.
=head3 FORMAL SPECIFICATION
bnf_to_matcher : seq STRING -> (STRING U {undef}) -> B
Let G = <N, T, P, S> be the grammar derived from grammar_lines, where
N -- set of non-terminals (angle-bracket names)
T -- set of terminals (double-quoted literals)
P -- set of productions in N x (N U T)*
S -- start symbol (first defined non-terminal)
L(G) = { w in T* | S =>* w } (language generated by G)
bnf_to_matcher(lines) =def= lambda s.
if s is undef then 0
else if s in L(G) then 1
else 0
Pre-conditions:
lines /= empty
for all n in N referenced in P: n is defined in P (no dangling non-terminals)
no cycle in the non-terminal reference graph (no left recursion / loops)
Post-conditions:
result is a coderef
for all s: result(s) = 1 iff s in L(G)
result(undef) = 0
Raises an exception if:
=over 4
=item * the argument is not an arrayref
=item * the grammar contains no rule definitions
=item * a non-terminal reference is not defined elsewhere in the grammar
=item * a recursive rule is detected
=back
=cut | |||||
| 178 | ||||||
| 179 | sub bnf_to_matcher { | |||||
| 180 | 79 | 131668 | my ($grammar_lines) = @_; | |||
| 181 | ||||||
| 182 | 79 | 241 | validate_strict({ | |||
| 183 | input => { grammar_lines => $grammar_lines }, | |||||
| 184 | schema => { | |||||
| 185 | grammar_lines => { | |||||
| 186 | type => 'arrayref', | |||||
| 187 | min => 1, | |||||
| 188 | }, | |||||
| 189 | }, | |||||
| 190 | }); | |||||
| 191 | ||||||
| 192 | 71 | 136 | my $cache_key = join("\0", @$grammar_lines); | |||
| 193 | 71 | 87 | return $_cache{$cache_key} if exists $_cache{$cache_key}; | |||
| 194 | ||||||
| 195 | 47 | 50 | my (%rules, @rule_order); | |||
| 196 | 47 | 0 | my ($current_rule, $current_rhs); | |||
| 197 | ||||||
| 198 | my $flush = sub { | |||||
| 199 | 103 | 83 | return unless defined $current_rule; | |||
| 200 | 56 | 50 | $rules{$current_rule} = _parse_rhs($current_rhs); | |||
| 201 | 47 | 66 | }; | |||
| 202 | ||||||
| 203 | 47 | 62 | for my $line (@$grammar_lines) { | |||
| 204 | 67 | 176 | if($line =~ /^\s*(<[^>]+>)\s*::=\s*(.*)$/) { | |||
| 205 | 56 | 69 | $flush->(); | |||
| 206 | 56 | 56 | $current_rule = $1; | |||
| 207 | 56 | 60 | push @rule_order, $current_rule unless exists $rules{$current_rule}; | |||
| 208 | 56 | 57 | $current_rhs = $2; | |||
| 209 | } elsif(defined $current_rule && $line =~ /\S/) { | |||||
| 210 | 6 | 6 | $current_rhs .= " $line"; | |||
| 211 | } | |||||
| 212 | } | |||||
| 213 | 47 | 48 | $flush->(); | |||
| 214 | ||||||
| 215 | 47 | 54 | croak 'bnf_to_matcher: no rules found in grammar' unless @rule_order; | |||
| 216 | ||||||
| 217 | 43 | 33 | my $start = $rule_order[0]; | |||
| 218 | 43 | 53 | my $regex_str = _rule_to_regex($start, \%rules, {}); | |||
| 219 | 35 | 379 | my $re = qr/^(?:$regex_str)$/s; | |||
| 220 | ||||||
| 221 | 35 64 | 59 274 | my $matcher = sub { defined($_[0]) && $_[0] =~ $re ? 1 : 0 }; | |||
| 222 | 35 | 40 | $_cache{$cache_key} = $matcher; | |||
| 223 | 35 | 141 | return $matcher; | |||
| 224 | } | |||||
| 225 | ||||||
| 226 | sub _parse_rhs { | |||||
| 227 | 66 | 14543 | my $rhs = $_[0]; | |||
| 228 | 66 | 44 | my @alternatives; | |||
| 229 | ||||||
| 230 | 66 | 103 | for my $alt (split /\s*\|\s*/, $rhs) { | |||
| 231 | 109 | 55 | my @tokens; | |||
| 232 | 109 | 243 | while($alt =~ /("(?:[^"\\]|\\.)*"|<[^>]+>)/g) { | |||
| 233 | 138 | 105 | my $tok = $1; | |||
| 234 | 138 | 170 | if($tok =~ /^"(.*)"$/s) { | |||
| 235 | 104 | 164 | push @tokens, { type => 'terminal', value => $1 }; | |||
| 236 | } else { | |||||
| 237 | 34 | 60 | push @tokens, { type => 'nonterminal', name => $tok }; | |||
| 238 | } | |||||
| 239 | } | |||||
| 240 | 109 | 90 | push @alternatives, \@tokens; | |||
| 241 | } | |||||
| 242 | 66 | 87 | return \@alternatives; | |||
| 243 | } | |||||
| 244 | ||||||
| 245 | sub _rule_to_regex { | |||||
| 246 | 87 | 12549 | my ($rule_name, $rules, $seen) = @_; | |||
| 247 | croak "bnf_to_matcher: undefined rule '$rule_name' referenced in grammar" | |||||
| 248 | 87 | 96 | unless exists $rules->{$rule_name}; | |||
| 249 | croak "bnf_to_matcher: recursive rule '$rule_name' is not supported" | |||||
| 250 | 82 | 107 | if $seen->{$rule_name}; | |||
| 251 | ||||||
| 252 | 77 | 66 | local $seen->{$rule_name} = 1; | |||
| 253 | ||||||
| 254 | 77 | 50 | my @alt_regexes; | |||
| 255 | 77 77 | 52 68 | for my $alt (@{ $rules->{$rule_name} }) { | |||
| 256 | 207 | 99 | my $alt_re = ''; | |||
| 257 | 207 | 148 | for my $tok (@$alt) { | |||
| 258 | 232 | 163 | if($tok->{type} eq 'terminal') { | |||
| 259 | 197 | 130 | $alt_re .= quotemeta($tok->{value}); | |||
| 260 | } else { | |||||
| 261 | 35 | 36 | $alt_re .= _rule_to_regex($tok->{name}, $rules, $seen); | |||
| 262 | } | |||||
| 263 | } | |||||
| 264 | 198 | 160 | push @alt_regexes, $alt_re; | |||
| 265 | } | |||||
| 266 | ||||||
| 267 | 68 | 117 | return @alt_regexes == 1 | |||
| 268 | ? $alt_regexes[0] | |||||
| 269 | : '(?:' . join('|', @alt_regexes) . ')'; | |||||
| 270 | } | |||||
| 271 | ||||||
| 272 | 1; | |||||
| 273 | ||||||