File Coverage

blib/lib/Test/BDD/Cucumber/Parser.pm
Criterion Covered Total %
statement 228 236 96.6
branch 74 90 82.2
condition 35 51 68.6
subroutine 33 33 100.0
pod 2 2 100.0
total 372 412 90.2


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