File Coverage

blib/lib/Test/BDD/Cucumber/Parser.pm
Criterion Covered Total %
statement 229 237 96.6
branch 74 90 82.2
condition 35 51 68.6
subroutine 33 33 100.0
pod 2 2 100.0
total 373 413 90.3


line stmt bran cond sub pod time code
1             package Test::BDD::Cucumber::Parser;
2             $Test::BDD::Cucumber::Parser::VERSION = '0.84';
3             =head1 NAME
4              
5             Test::BDD::Cucumber::Parser - Parse Feature files
6              
7             =head1 VERSION
8              
9             version 0.84
10              
11             =head1 DESCRIPTION
12              
13             Parse Feature files in to a set of data classes
14              
15             =head1 SYNOPSIS
16              
17             # Returns a Test::BDD::Cucumber::Model::Feature object
18             my $feature = Test::BDD::Cucumber::Parser->parse_file(
19             't/data/features/basic_parse.feature' );
20              
21             =head1 METHODS
22              
23             =head2 parse_string
24              
25             =head2 parse_file
26              
27             Both methods accept a single string as their argument, and return a
28             L object on success.
29              
30             =cut
31              
32 18     18   1352190 use strict;
  18         156  
  18         584  
33 18     18   119 use warnings;
  18         51  
  18         471  
34              
35 18     18   8212 use Test::BDD::Cucumber::Model::Dataset;
  18         76  
  18         707  
36 18     18   8627 use Test::BDD::Cucumber::Model::Document;
  18         66  
  18         641  
37 18     18   8551 use Test::BDD::Cucumber::Model::Feature;
  18         61  
  18         641  
38 18     18   8914 use Test::BDD::Cucumber::Model::Scenario;
  18         76  
  18         745  
39 18     18   9070 use Test::BDD::Cucumber::Model::Step;
  18         72  
  18         645  
40 18     18   8983 use Test::BDD::Cucumber::Model::TagSpec;
  18         59  
  18         677  
41 18     18   5129 use Test::BDD::Cucumber::I18n qw(langdef);
  18         69  
  18         1327  
42 18     18   4831 use Test::BDD::Cucumber::Errors qw/parse_error_from_line/;
  18         57  
  18         54259  
43              
44             # https://github.com/cucumber/cucumber/wiki/Multiline-Step-Arguments
45             # https://github.com/cucumber/cucumber/wiki/Scenario-outlines
46              
47             sub parse_string {
48 32     32 1 195945 my ( $class, $string ) = @_;
49              
50 32         728 return $class->_construct(
51             Test::BDD::Cucumber::Model::Document->new(
52             {
53             content => $string
54             }
55             )
56             );
57             }
58              
59             sub parse_file {
60 16     16 1 1846 my ( $class, $string ) = @_;
61 16         34 my $content;
62             {
63 16         33 local $/;
  16         67  
64 16 50       462 open(my $in, '<', $string) or die $?;
65 16         1504 binmode $in, 'utf8';
66 16         728 $content = <$in>;
67 16 50       315 close $in or warn $?;
68             }
69 16         200 return $class->_construct(
70             Test::BDD::Cucumber::Model::Document->new(
71             {
72             content => $content,
73             filename => '' . $string
74             })
75             );
76             }
77              
78             sub _construct {
79 48     48   897 my ( $class, $document ) = @_;
80              
81 48         913 my $feature =
82             Test::BDD::Cucumber::Model::Feature->new( { document => $document } );
83 48         1485 my @lines = $class->_remove_next_blanks( @{ $document->lines } );
  48         934  
84              
85 48         236 my $language = $class->_extract_language( \@lines );
86 48         1477 $feature->language( $language );
87              
88 48 50       2502 my $langdef = langdef( $feature->language )
89             or die "Declared language '$language' not available";
90              
91 48         190 my $self = bless {
92             langdef => $langdef,
93             _construct_matchers( $langdef )
94             }, $class;
95              
96 48         323 $self->_extract_scenarios(
97             $self->_extract_conditions_of_satisfaction(
98             $self->_extract_feature_name( $feature, @lines )
99             )
100             );
101              
102 47         866 return $feature;
103             }
104              
105             sub _construct_matchers {
106 48     48   124 my ($l) = @_;
107             my $step_line_kw_cont =
108 48         139 join('|', map { $l->{$_} } qw/given and when then but/);
  240         734  
109             my $step_line_kw_first =
110 48         138 join('|', map { $l->{$_} } qw/given when then/);
  144         384  
111             my $scenario_line_kw =
112 48         145 join('|', map { $l->{$_} } qw/background scenario scenarioOutline/);
  144         396  
113              
114             return (
115 48         4734 _step_line_first => qr/^($step_line_kw_first)(.+)/,
116             _step_line_cont => qr/^($step_line_kw_cont)(.+)/,
117             _feature_line => qr/^($l->{feature}): (.+)/,
118             _scenario_line => qr/^($scenario_line_kw): ?(.*)?/,
119             _examples_line => qr/^($l->{examples}): ?(.+)?$/,
120             _table_line => qr/^\s*\|/,
121             _tags_line => qr/\@([^\s]+)/,
122             );
123             }
124              
125             sub _is_step_line {
126 735     735   1556 my ($self, $continuation, $line) = @_;
127              
128 735 100       1485 if ($continuation) {
129 583         4614 return $line =~ $self->{_step_line_cont};
130             }
131             else {
132 152         1508 return $line =~ $self->{_step_line_first};
133             }
134             }
135              
136             sub _is_feature_line {
137 54     54   170 my ($self, $line) = @_;
138              
139 54         661 return $line =~ $self->{_feature_line};
140             }
141              
142             sub _is_scenario_line {
143 304     304   711 my ($self, $line) = @_;
144              
145 304         2675 return $line =~ $self->{_scenario_line};
146             }
147              
148             sub _is_table_line {
149 26     26   83 my ($self, $line) = @_;
150              
151 26         330 return $line =~ $self->{_table_line};
152             }
153              
154             sub _is_tags_line {
155 94     94   231 my ($self, $line) = @_;
156              
157 94         616 return $line =~ $self->{_tags_line};
158             }
159              
160             sub _is_examples_line {
161 191     191   441 my ($self, $line) = @_;
162              
163 191         1669 return $line =~ $self->{_examples_line};
164             }
165              
166             sub _extract_language {
167 48     48   611 my ( $self, $lines ) = @_;
168              
169             # return default language if we don't see the language directive on the first line
170 48 100 33     1111 return 'en'
      66        
171             unless ($lines and @$lines
172             and $lines->[0]->raw_content =~ m{^\s*#\s*language:\s+([^\s]+)});
173              
174             # remove the language directive if we saw it ...
175 4         59 shift @$lines;
176              
177             # ... and return the language it declared
178 4         16 return $1;
179             }
180              
181             sub _remove_next_blanks {
182 395     395   1692 my ( $self, @lines ) = @_;
183 395   100     1709 while ( $lines[0] && $lines[0]->is_blank ) {
184 94         384 shift(@lines);
185             }
186 395         2869 return @lines;
187             }
188              
189             sub _extract_feature_name {
190 48     48   227 my ( $self, $feature, @lines ) = @_;
191 48         124 my @feature_tags = ();
192              
193 48         204 while ( my $line = shift(@lines) ) {
194 60 100       227 next if $line->is_comment;
195 54 50       204 last if $line->is_blank;
196              
197 54 100       271 if ( my ($keyword, $name) =
    50          
198             $self->_is_feature_line( $line->content ) ) {
199 48         1020 $feature->name($name);
200 48         2682 $feature->keyword_original($keyword);
201 48         2446 $feature->name_line($line);
202 48         2641 $feature->tags( \@feature_tags );
203              
204 48         1938 last;
205              
206             # Feature-level tags
207             } elsif ( $line->content =~ m/^\s*\@\w/ ) {
208 6         37 my @tags = $line->content =~ m/\@([^\s]+)/g;
209 6         33 push( @feature_tags, @tags );
210              
211             } else {
212             die parse_error_from_line(
213             'Malformed feature line (expecting: /^(?:'
214             . $self->{langdef}->{feature}
215 0         0 . '): (.+)/',
216             $line
217             );
218             }
219             }
220              
221 48         187 return $feature, $self->_remove_next_blanks(@lines);
222             }
223              
224             sub _extract_conditions_of_satisfaction {
225 48     48   240 my ( $self, $feature, @lines ) = @_;
226              
227 48         193 while ( my $line = shift(@lines) ) {
228 189 100 66     1187 next if $line->is_comment || $line->is_blank;
229              
230 139 100 100     447 if ( $self->_is_scenario_line( $line->content )
231             or $self->_is_tags_line( $line->content ) ) {
232 47         144 unshift( @lines, $line );
233 47         106 last;
234             } else {
235 92         174 push( @{ $feature->satisfaction }, $line );
  92         1607  
236             }
237             }
238              
239 48         184 return $feature, $self->_remove_next_blanks(@lines);
240             }
241              
242             sub _finish_scenario {
243 199     199   443 my ($self, $feature, $line) = @_;
244             # Catch Scenario outlines without examples
245 199 100       306 if ( @{ $feature->scenarios } ) {
  199         3526  
246 138         2929 my $last_scenario = $feature->scenarios->[-1];
247 138 100 100     2839 if ( $last_scenario->keyword_original =~ m/^($self->{langdef}->{scenarioOutline})/
248 2         93 && !@{ $last_scenario->datasets } )
249             {
250 1   33     36 die parse_error_from_line(
251             "Outline scenario expects 'Examples:' section",
252             $line || $last_scenario->line );
253             }
254             }
255             }
256              
257             sub _extract_scenarios {
258 48     48   231 my ( $self, $feature, @lines ) = @_;
259 48         119 my $scenarios = 0;
260 48         102 my $langdef = $self->{langdef};
261 48         91 my @tags;
262              
263 48         197 while ( my $line = shift(@lines) ) {
264 208 100 66     2411 next if $line->is_comment || $line->is_blank;
265              
266 190 100       636 if ( my ( $type, $name ) =
    100          
    50          
267             $self->_is_examples_line( $line->content ) ) {
268              
269             die q{'Examples:' line before scenario definition}
270 26 50       74 unless @{$feature->scenarios};
  26         442  
271              
272             my $dataset = Test::BDD::Cucumber::Model::Dataset->new(
273             ( $name ? ( name => $name ) : () ),
274             tags => ( @tags ?
275 26 50       700 [ @{ $feature->scenarios->[-1]->tags }, @tags ]
  0 50       0  
276             # Reuse the ref to the scenario tags to allow
277             # detecting 'no dataset tags' in ::Scenario
278             : $feature->scenarios->[-1]->tags ),
279             line => $line,
280             );
281 26         1980 @tags = ();
282 26         136 @lines = $self->_extract_examples_description( $dataset, @lines );
283 26         103 @lines = $self->_extract_table( 6, $dataset,
284             $self->_remove_next_blanks(@lines) );
285              
286 26 100       99 if (@{$feature->scenarios->[-1]->datasets}) {
  26         526  
287 1         50 my $prev_ds = $feature->scenarios->[-1]->datasets->[0];
288 1         24 my $prev_ds_cols = join '|', sort keys %{$prev_ds->data->[0]};
  1         17  
289 1         16 my $cur_ds_cols = join '|', sort keys %{$dataset->data->[0]};
  1         18  
290 1 50       20 die parse_error_from_line(
291             q{Columns of 'Examples:' not in line with }
292             . q{previous 'Examples:' }
293             . qq{('$prev_ds_cols' vs '$cur_ds_cols')}, $line )
294             if $prev_ds_cols ne $cur_ds_cols;
295             }
296 26         828 push @{$feature->scenarios->[-1]->datasets}, $dataset;
  26         421  
297              
298             }
299             elsif ( ( $type, $name ) =
300             $self->_is_scenario_line( $line->content ) ) {
301              
302 151         577 $self->_finish_scenario( $feature, $line );
303              
304             # Only one background section, and it must be the first
305 151 50 66     2836 if ( $scenarios++ && $type =~ m/^($langdef->{background})/ ) {
306 0         0 die parse_error_from_line(
307             "Background not allowed after scenarios", $line );
308             }
309              
310             # Create the scenario
311             my $scenario = Test::BDD::Cucumber::Model::Scenario->new(
312             {
313             ( $name ? ( name => $name ) : () ),
314             background => $type =~ m/^($langdef->{background})/ ? 1 : 0,
315             keyword =>
316             ($type =~ m/^($langdef->{background})/ ? 'Background'
317             : ($type =~ m/^($langdef->{scenarioOutline})/
318             ? 'Scenario Outline' : 'Scenario')),
319             keyword_original => $type,
320             line => $line,
321 151 100       2423 tags => [ @{ $feature->tags }, @tags ]
  151 100       2804  
    100          
    100          
322             }
323             );
324 151         8208 @tags = ();
325              
326             # Attempt to populate it
327 151         522 @lines = $self->_extract_scenario_description($scenario, @lines);
328 151         548 @lines = $self->_extract_steps( $feature, $scenario, @lines );
329              
330 151 100       1063 if ( $type =~ m/^($langdef->{background})/ ) {
331 13         336 $feature->background($scenario);
332             } else {
333 138         275 push( @{ $feature->scenarios }, $scenario );
  138         2570  
334             }
335              
336             # Scenario-level tags
337             } elsif ( $line->content =~ m/^\s*\@\w/ ) {
338 13         42 push @tags, ( $line->content =~ m/\@([^\s]+)/g );
339              
340             } else {
341 0         0 die parse_error_from_line( "Malformed scenario line", $line );
342             }
343             }
344              
345 48         812 $self->_finish_scenario( $feature );
346 47         947 return $feature, $self->_remove_next_blanks(@lines);
347             }
348              
349             sub _extract_steps {
350 151     151   496 my ( $self, $feature, $scenario, @lines ) = @_;
351              
352 151         320 my $langdef = $self->{langdef};
353 151         572 my @givens = split( /\|/, $langdef->{given} );
354 151         309 my $last_verb = $givens[-1];
355              
356              
357 151         262 my ( $verb, $text );
358 151   100     595 while ( @lines and
      100        
359             ($lines[0]->is_comment
360             or ($verb, $text) = $self->_is_step_line( 1, $lines[0]->content ) ) ) {
361 473         1187 my $line = shift @lines;
362 473 100       1167 next if $line->is_comment;
363              
364 471         1001 my $original_verb = $verb;
365 471 100       2249 $verb = 'Given' if $verb =~ m/^($langdef->{given})$/;
366 471 100       1783 $verb = 'When' if $verb =~ m/^($langdef->{when})$/;
367 471 100       1688 $verb = 'Then' if $verb =~ m/^($langdef->{then})$/;
368 471 100 100     2759 $verb = $last_verb
369             if $verb =~ m/^($langdef->{and})$/
370             or $verb =~ m/^($langdef->{but}$)/;
371 471         847 $last_verb = $verb;
372              
373             # Remove the ending space for languages that
374             # have it, for backward compatibility
375 471         1725 $original_verb =~ s/ $//;
376 471         9621 my $step = Test::BDD::Cucumber::Model::Step->new(
377             {
378             text => $text,
379             verb => $verb,
380             line => $line,
381             verb_original => $original_verb,
382             }
383             );
384              
385 471         49185 @lines =
386             $self->_extract_step_data( $feature, $scenario, $step, @lines );
387              
388 471         836 push( @{ $scenario->steps }, $step );
  471         8190  
389             }
390              
391 151         864 return $self->_remove_next_blanks(@lines);
392             }
393              
394              
395             sub _extract_examples_description {
396 26     26   102 my ( $self, $examples, @lines ) = @_;
397              
398 26         97 while ( my $line = shift @lines ) {
399 26 50       85 next if $line->is_comment;
400              
401 26         117 my $content = $line->content;
402 26 0 33     134 return ( $line, @lines )
      33        
      0        
403             if $self->_is_table_line( $content )
404             or $self->_is_examples_line( $content )
405             or $self->_is_tags_line( $content )
406             or $self->_is_scenario_line( $content );
407              
408 0         0 push @{$examples->description}, $line;
  0         0  
409             }
410              
411 0         0 return @lines;
412             }
413              
414             sub _extract_scenario_description {
415 151     151   536 my ( $self, $scenario, @lines ) = @_;
416              
417 151   100     780 while ( @lines
      66        
418             and ($lines[0]->is_comment
419             or (not $self->_is_step_line(0, $lines[0]->content)
420             and not $self->_is_examples_line($lines[0]->content)
421             and not $self->_is_tags_line($lines[0]->content)
422             and not $self->_is_scenario_line($lines[0]->content) ) )
423             ) {
424 9         22 push @{$scenario->description}, shift(@lines);
  9         171  
425             }
426              
427 151         846 return @lines;
428             }
429              
430             sub _extract_step_data {
431 471     471   1517 my ( $self, $feature, $scenario, $step, @lines ) = @_;
432 471 100       1188 return unless @lines;
433              
434 433 100       1185 if ( $lines[0]->content eq '"""' ) {
    100          
435 27         143 return $self->_extract_multiline_string( $feature, $scenario, $step,
436             @lines );
437             } elsif ( $lines[0]->content =~ m/^\s*\|/ ) {
438 11         53 return $self->_extract_table( 6, $step, @lines );
439             } else {
440 395         2464 return @lines;
441             }
442              
443             }
444              
445             sub _extract_multiline_string {
446 27     27   111 my ( $self, $feature, $scenario, $step, @lines ) = @_;
447              
448 27         64 my $data = '';
449 27         56 my $start = shift(@lines);
450 27         94 my $indent = $start->indent;
451              
452             # Check we still have the minimum indentation
453 27         111 while ( my $line = shift(@lines) ) {
454              
455 99 100       247 if ( $line->content eq '"""' ) {
456 27         155 $step->data($data);
457 27         94 return $self->_remove_next_blanks(@lines);
458             }
459              
460 72         212 my $content = $line->content_remove_indentation($indent);
461              
462             # Unescape it
463 72         253 $content =~ s/\\(.)/$1/g;
464 72         119 push( @{ $step->data_as_strings }, $content );
  72         1290  
465 72         565 $content .= "\n";
466 72         227 $data .= $content;
467             }
468              
469 0         0 return;
470             }
471              
472             sub _extract_table {
473 37     37   158 my ( $self, $indent, $target, @lines ) = @_;
474 37         72 my @columns;
475              
476 37         74 my $data = [];
477 37         563 $target->data($data);
478              
479 37         1100 while ( my $line = shift(@lines) ) {
480 163 50       417 next if $line->is_comment;
481 163 100       420 return ( $line, @lines ) if index( $line->content, '|' );
482              
483 135         369 my @rows = $self->_pipe_array( $line->content );
484 135 100       656 if ( $target->can('data_as_strings') ) {
485 40         113 my $t_content = $line->content;
486 40         140 $t_content =~ s/^\s+//;
487 40         66 push( @{ $target->data_as_strings }, $t_content );
  40         650  
488             }
489              
490 135 100       532 if (@columns) {
491 98 50       252 die parse_error_from_line( "Inconsistent number of rows in table",
492             $line )
493             unless @rows == @columns;
494 98 100       873 $target->columns( [@columns] ) if $target->can('columns');
495 98         1449 my $i = 0;
496 98         196 my %data_hash = map { $columns[ $i++ ] => $_ } @rows;
  246         758  
497 98         439 push( @$data, \%data_hash );
498             } else {
499 37         168 @columns = @rows;
500             }
501             }
502              
503 9         30 return;
504             }
505              
506             sub _pipe_array {
507 135     135   283 my ( $self, $string ) = @_;
508 135         805 my @atoms = split( /(?
509 135         251 shift(@atoms);
510             return map {
511 135         278 my $atom = $_;
  332         516  
512 332         970 $atom =~ s/^\s+//;
513 332         1100 $atom =~ s/\s+$//;
514 332         661 $atom =~ s/\\(.)/$1/g;
515 332         918 $atom
516             } @atoms;
517             }
518              
519             1;
520              
521             =head1 AUTHOR
522              
523             Peter Sergeant C
524              
525             =head1 LICENSE
526              
527             Copyright 2019-2023, Erik Huelsmann
528             Copyright 2011-2019, Peter Sergeant; Licensed under the same terms as Perl
529              
530             =cut