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 27     27   1388973 use v5.14;
  27         109  
2 27     27   154 use warnings;
  27         59  
  27         2529  
3              
4             package Test::BDD::Cucumber::Parser 0.87;
5              
6             =head1 NAME
7              
8             Test::BDD::Cucumber::Parser - Parse Feature files
9              
10             =head1 VERSION
11              
12             version 0.87
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 27     27   15416 use Test::BDD::Cucumber::Model::Dataset;
  27         121  
  27         1435  
37 27     27   16489 use Test::BDD::Cucumber::Model::Document;
  27         157  
  27         1397  
38 27     27   16030 use Test::BDD::Cucumber::Model::Feature;
  27         151  
  27         1424  
39 27     27   16340 use Test::BDD::Cucumber::Model::Scenario;
  27         173  
  27         1370  
40 27     27   16849 use Test::BDD::Cucumber::Model::Step;
  27         130  
  27         1271  
41 27     27   17615 use Test::BDD::Cucumber::Model::TagSpec;
  27         110  
  27         1285  
42 27     27   6631 use Test::BDD::Cucumber::I18n qw(langdef);
  27         88  
  27         2396  
43 27     27   5791 use Test::BDD::Cucumber::Errors qw/parse_error_from_line/;
  27         71  
  27         119736  
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 2598327 my ( $class, $string ) = @_;
50              
51 32         937 return $class->_construct(
52             Test::BDD::Cucumber::Model::Document->new(
53             {
54             content => $string
55             }
56             )
57             );
58             }
59              
60             sub parse_file {
61 29     29 1 669640 my ( $class, $string ) = @_;
62 29         146 my $content;
63             {
64 29         156 local $/;
  29         152  
65 29 50       725 open(my $in, '<', $string) or die $?;
66 29         2982 binmode $in, 'utf8';
67 29         1251 $content = <$in>;
68 29 50       567 close $in or warn $?;
69             }
70 29         257 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 61     61   1354 my ( $class, $document ) = @_;
81              
82 61         1328 my $feature =
83             Test::BDD::Cucumber::Model::Feature->new( { document => $document } );
84 61         1579 my @lines = $class->_remove_next_blanks( @{ $document->lines } );
  61         1166  
85              
86 61         313 my $language = $class->_extract_language( \@lines );
87 61         1772 $feature->language( $language );
88              
89 61 50       3010 my $langdef = langdef( $feature->language )
90             or die "Declared language '$language' not available";
91              
92 61         290 my $self = bless {
93             langdef => $langdef,
94             _construct_matchers( $langdef )
95             }, $class;
96              
97 61         449 $self->_extract_scenarios(
98             $self->_extract_conditions_of_satisfaction(
99             $self->_extract_feature_name( $feature, @lines )
100             )
101             );
102              
103 60         1511 return $feature;
104             }
105              
106             sub _construct_matchers {
107 61     61   161 my ($l) = @_;
108             my $step_line_kw_cont =
109 61         178 join('|', map { $l->{$_} } qw/given and when then but/);
  305         998  
110             my $step_line_kw_first =
111 61         308 join('|', map { $l->{$_} } qw/given when then/);
  183         2431  
112             my $scenario_line_kw =
113 61         220 join('|', map { $l->{$_} } qw/background scenario scenarioOutline/);
  183         483  
114              
115             return (
116 61         7811 _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 1032     1032   2090 my ($self, $continuation, $line) = @_;
128              
129 1032 100       2069 if ($continuation) {
130 816         6440 return $line =~ $self->{_step_line_cont};
131             }
132             else {
133 216         2206 return $line =~ $self->{_step_line_first};
134             }
135             }
136              
137             sub _is_feature_line {
138 68     68   200 my ($self, $line) = @_;
139              
140 68         878 return $line =~ $self->{_feature_line};
141             }
142              
143             sub _is_scenario_line {
144 419     419   834 my ($self, $line) = @_;
145              
146 419         5988 return $line =~ $self->{_scenario_line};
147             }
148              
149             sub _is_table_line {
150 36     36   101 my ($self, $line) = @_;
151              
152 36         519 return $line =~ $self->{_table_line};
153             }
154              
155             sub _is_tags_line {
156 125     125   267 my ($self, $line) = @_;
157              
158 125         892 return $line =~ $self->{_tags_line};
159             }
160              
161             sub _is_examples_line {
162 276     276   602 my ($self, $line) = @_;
163              
164 276         2441 return $line =~ $self->{_examples_line};
165             }
166              
167             sub _extract_language {
168 61     61   203 my ( $self, $lines ) = @_;
169              
170             # return default language if we don't see the language directive on the first line
171 61 100 33     1506 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 6         104 shift @$lines;
177              
178             # ... and return the language it declared
179 6         23 return $1;
180             }
181              
182             sub _remove_next_blanks {
183 530     530   2455 my ( $self, @lines ) = @_;
184 530   100     2207 while ( $lines[0] && $lines[0]->is_blank ) {
185 139         467 shift(@lines);
186             }
187 530         3634 return @lines;
188             }
189              
190             sub _extract_feature_name {
191 61     61   330 my ( $self, $feature, @lines ) = @_;
192 61         151 my @feature_tags = ();
193              
194 61         284 while ( my $line = shift(@lines) ) {
195 80 100       390 next if $line->is_comment;
196 68 50       338 last if $line->is_blank;
197              
198 68 100       269 if ( my ($keyword, $name) =
    50          
199             $self->_is_feature_line( $line->content ) ) {
200 61         1429 $feature->name($name);
201 61         3445 $feature->keyword_original($keyword);
202 61         2915 $feature->name_line($line);
203 61         3110 $feature->tags( \@feature_tags );
204              
205 61         1881 last;
206              
207             # Feature-level tags
208             } elsif ( $line->content =~ m/^\s*\@\w/ ) {
209 7         19 my @tags = $line->content =~ m/(\@[^\s\@]+)/g;
210 7         35 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 61         266 return $feature, $self->_remove_next_blanks(@lines);
223             }
224              
225             sub _extract_conditions_of_satisfaction {
226 61     61   254 my ( $self, $feature, @lines ) = @_;
227              
228 61         233 while ( my $line = shift(@lines) ) {
229 238 100 66     1320 next if $line->is_comment || $line->is_blank;
230              
231 179 100 100     466 if ( $self->_is_scenario_line( $line->content )
232             or $self->_is_tags_line( $line->content ) ) {
233 60         183 unshift( @lines, $line );
234 60         158 last;
235             } else {
236 119         236 push( @{ $feature->satisfaction }, $line );
  119         2246  
237             }
238             }
239              
240 61         232 return $feature, $self->_remove_next_blanks(@lines);
241             }
242              
243             sub _finish_scenario {
244 276     276   599 my ($self, $feature, $line) = @_;
245             # Catch Scenario outlines without examples
246 276 100       426 if ( @{ $feature->scenarios } ) {
  276         4999  
247 195         3872 my $last_scenario = $feature->scenarios->[-1];
248 195 100 100     3869 if ( $last_scenario->keyword_original =~ m/^($self->{langdef}->{scenarioOutline})/
249 2         132 && !@{ $last_scenario->datasets } )
250             {
251 1   33     36 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 61     61   323 my ( $self, $feature, @lines ) = @_;
260 61         125 my $scenarios = 0;
261 61         197 my $langdef = $self->{langdef};
262 61         199 my @tags;
263              
264 61         290 while ( my $line = shift(@lines) ) {
265 303 100 66     3254 next if $line->is_comment || $line->is_blank;
266              
267 275 100       715 if ( my ( $type, $name ) =
    100          
    50          
268             $self->_is_examples_line( $line->content ) ) {
269              
270             die q{'Examples:' line before scenario definition}
271 36 50       87 unless @{$feature->scenarios};
  36         762  
272              
273             my $dataset = Test::BDD::Cucumber::Model::Dataset->new(
274             ( $name ? ( name => $name ) : () ),
275             tags => ( @tags ?
276 36 50       1076 [ @{ $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 36         2463 @tags = ();
283 36         192 @lines = $self->_extract_examples_description( $dataset, @lines );
284 36         152 @lines = $self->_extract_table( 6, $dataset,
285             $self->_remove_next_blanks(@lines) );
286              
287 36 100       107 if (@{$feature->scenarios->[-1]->datasets}) {
  36         818  
288 1         54 my $prev_ds = $feature->scenarios->[-1]->datasets->[0];
289 1         27 my $prev_ds_cols = join '|', sort keys %{$prev_ds->data->[0]};
  1         23  
290 1         16 my $cur_ds_cols = join '|', sort keys %{$dataset->data->[0]};
  1         24  
291 1 50       14 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 36         1117 push @{$feature->scenarios->[-1]->datasets}, $dataset;
  36         656  
298              
299             }
300             elsif ( ( $type, $name ) =
301             $self->_is_scenario_line( $line->content ) ) {
302              
303 215         923 $self->_finish_scenario( $feature, $line );
304              
305             # Only one background section, and it must be the first
306 215 50 66     4342 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 215 100       4012 tags => [ @{ $feature->tags }, @tags ]
  215 100       4106  
    100          
    100          
323             }
324             );
325 215         8781 @tags = ();
326              
327             # Attempt to populate it
328 215         945 @lines = $self->_extract_scenario_description($scenario, @lines);
329 215         746 @lines = $self->_extract_steps( $feature, $scenario, @lines );
330              
331 215 100       1666 if ( $type =~ m/^($langdef->{background})/ ) {
332 20         524 $feature->background($scenario);
333             } else {
334 195         343 push( @{ $feature->scenarios }, $scenario );
  195         3692  
335             }
336              
337             # Scenario-level tags
338             } elsif ( $line->content =~ m/^\s*\@\w/ ) {
339 24         68 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 61         972 $self->_finish_scenario( $feature );
347 60         1094 return $feature, $self->_remove_next_blanks(@lines);
348             }
349              
350             sub _extract_steps {
351 215     215   753 my ( $self, $feature, $scenario, @lines ) = @_;
352              
353 215         434 my $langdef = $self->{langdef};
354 215         775 my @givens = split( /\|/, $langdef->{given} );
355 215         524 my $last_verb = $givens[-1];
356              
357              
358 215         369 my ( $verb, $text );
359 215   100     802 while ( @lines and
      100        
360             ($lines[0]->is_comment
361             or ($verb, $text) = $self->_is_step_line( 1, $lines[0]->content ) ) ) {
362 657         1352 my $line = shift @lines;
363 657 100       1451 next if $line->is_comment;
364              
365 653         1098 my $original_verb = $verb;
366 653 100       3798 $verb = 'Given' if $verb =~ m/^($langdef->{given})$/;
367 653 100       2644 $verb = 'When' if $verb =~ m/^($langdef->{when})$/;
368 653 100       2472 $verb = 'Then' if $verb =~ m/^($langdef->{then})$/;
369 653 100 100     4272 $verb = $last_verb
370             if $verb =~ m/^($langdef->{and})$/
371             or $verb =~ m/^($langdef->{but}$)/;
372 653         1040 $last_verb = $verb;
373              
374             # Remove the ending space for languages that
375             # have it, for backward compatibility
376 653         2336 $original_verb =~ s/ $//;
377 653         15065 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 653         56807 @lines =
387             $self->_extract_step_data( $feature, $scenario, $step, @lines );
388              
389 653         1030 push( @{ $scenario->steps }, $step );
  653         11170  
390             }
391              
392 215         1182 return $self->_remove_next_blanks(@lines);
393             }
394              
395              
396             sub _extract_examples_description {
397 36     36   150 my ( $self, $examples, @lines ) = @_;
398              
399 36         150 while ( my $line = shift @lines ) {
400 36 50       2063 next if $line->is_comment;
401              
402 36         124 my $content = $line->content;
403 36 0 33     149 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 215     215   793 my ( $self, $scenario, @lines ) = @_;
417              
418 215   100     1057 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         15 push @{$scenario->description}, shift(@lines);
  9         131  
426             }
427              
428 215         1261 return @lines;
429             }
430              
431             sub _extract_step_data {
432 653     653   2330 my ( $self, $feature, $scenario, $step, @lines ) = @_;
433 653 100       1520 return unless @lines;
434              
435 602 100       1735 if ( $lines[0]->content eq '"""' ) {
    100          
436 36         144 return $self->_extract_multiline_string( $feature, $scenario, $step,
437             @lines );
438             } elsif ( $lines[0]->content =~ m/^\s*\|/ ) {
439 15         113 return $self->_extract_table( 6, $step, @lines );
440             } else {
441 551         3794 return @lines;
442             }
443              
444             }
445              
446             sub _extract_multiline_string {
447 36     36   139 my ( $self, $feature, $scenario, $step, @lines ) = @_;
448              
449 36         71 my $data = '';
450 36         68 my $start = shift(@lines);
451 36         152 my $indent = $start->indent;
452              
453             # Check we still have the minimum indentation
454 36         125 while ( my $line = shift(@lines) ) {
455              
456 133 100       345 if ( $line->content eq '"""' ) {
457 36         233 $step->data($data);
458 36         148 return $self->_remove_next_blanks(@lines);
459             }
460              
461 97         282 my $content = $line->content_remove_indentation($indent);
462              
463             # Unescape it
464 97         350 $content =~ s/\\(.)/$1/g;
465 97         142 push( @{ $step->data_as_strings }, $content );
  97         1495  
466 97         549 $content .= "\n";
467 97         283 $data .= $content;
468             }
469              
470 0         0 return;
471             }
472              
473             sub _extract_table {
474 51     51   204 my ( $self, $indent, $target, @lines ) = @_;
475 51         91 my @columns;
476              
477 51         101 my $data = [];
478 51         940 $target->data($data);
479              
480 51         1302 while ( my $line = shift(@lines) ) {
481 236 50       594 next if $line->is_comment;
482 236 100       591 return ( $line, @lines ) if index( $line->content, '|' );
483              
484 194         483 my @rows = $self->_pipe_array( $line->content );
485 194 100       936 if ( $target->can('data_as_strings') ) {
486 56         157 my $t_content = $line->content;
487 56         118 $t_content =~ s/^\s+//;
488 56         92 push( @{ $target->data_as_strings }, $t_content );
  56         1010  
489             }
490              
491 194 100       670 if (@columns) {
492 143 50       342 die parse_error_from_line( "Inconsistent number of rows in table",
493             $line )
494             unless @rows == @columns;
495 143 100       1149 $target->columns( [@columns] ) if $target->can('columns');
496 143         1305 my $i = 0;
497 143         282 my %data_hash = map { $columns[ $i++ ] => $_ } @rows;
  358         1043  
498 143         696 push( @$data, \%data_hash );
499             } else {
500 51         263 @columns = @rows;
501             }
502             }
503              
504 9         84 return;
505             }
506              
507             sub _pipe_array {
508 194     194   394 my ( $self, $string ) = @_;
509 194         1177 my @atoms = split( /(?
510 194         324 shift(@atoms);
511             return map {
512 194         390 my $atom = $_;
  478         688  
513 478         1243 $atom =~ s/^\s+//;
514 478         1500 $atom =~ s/\s+$//;
515 478         801 $atom =~ s/\\(.)/$1/g;
516 478         1152 $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