File Coverage

File:blib/lib/Params/Validate/Strict/BNF.pm
Coverage:99.0%

linestmtbrancondsubtimecode
1package 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
9our $VERSION = '0.41';
10our @EXPORT_OK = qw(bnf_to_matcher);
11
12my %_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
179sub 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
226sub _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
245sub _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
2721;
273