157 / 165 lines covered (95.2%)
1unit module BDD::Behave::DocExtractor;23use BDD::Behave::Benchmark::Format;4use BDD::Behave::SpecTree;56constant Suite = BDD::Behave::SpecTree::Suite;7constant ExampleGroup = BDD::Behave::SpecTree::ExampleGroup;8constant Example = BDD::Behave::SpecTree::Example;910subset DocFormat of Str where { $_ eq any(<markdown html json>) };1112sub html-escape(Str $s --> Str) {13 my $body = $s // '';14 $body = $body.subst('&', '&', :g);15 $body = $body.subst('<', '<', :g);16 $body = $body.subst('>', '>', :g);17 $body = $body.subst('"', '"', :g);18 $body;19}2021sub describe-status($example) {22 $example.pending ?? 'pending'23 !! ($example.effective-skipped ?? 'skipped'24 !! ($example.effective-focused ?? 'focused'25 !! 'passing'));26}2728sub status-suffix(Str $status --> Str) {29 given $status {30 when 'pending' { ' (PENDING)' }31 when 'skipped' { ' (SKIPPED)' }32 when 'focused' { ' (FOCUSED)' }33 default { '' }34 }35}3637our class DocExtractor {38 has DocFormat $.format = 'markdown';39 has @.include-tags;40 has @.exclude-tags;41 has @.example-patterns;42 has %.metadata-filters;43 has %.metadata-exclude-filters;4445 method has-filters(--> Bool) {46 so @!include-tags.elems47 || @!exclude-tags.elems48 || @!example-patterns.elems49 || %!metadata-filters.elems50 || %!metadata-exclude-filters.elems;51 }5253 method example-matches(Example $example --> Bool) {54 my @tags = $example.effective-tags;5556 if @!exclude-tags.elems57 && @tags.first({ $_ ∈ @!exclude-tags }).defined {58 return False;59 }6061 if @!include-tags.elems62 && !@tags.first({ $_ ∈ @!include-tags }).defined {63 return False;64 }6566 if @!example-patterns.elems {67 my $full = self.full-description($example);68 my $any = False;69 for @!example-patterns -> $pattern {70 if self.description-matches-pattern($full, $pattern) {71 $any = True;72 last;73 }74 }75 return False unless $any;76 }7778 for %!metadata-filters.kv -> $key, $expected {79 return False unless self.metadata-matches($example, $key, $expected);80 }81 for %!metadata-exclude-filters.kv -> $key, $expected {82 return False if self.metadata-matches($example, $key, $expected);83 }8485 True;86 }8788 method metadata-matches(Example $example, Str $key, $expected --> Bool) {89 my $actual = $example.effective-metadata-value($key);90 return False unless $actual.defined;91 if $expected ~~ Bool {92 return $expected ?? ?$actual !! !$actual;93 }94 $actual eq $expected;95 }9697 method description-matches-pattern(Str $description, Str $pattern --> Bool) {98 if $pattern.chars > 299 && $pattern.starts-with('/')100 && $pattern.ends-with('/') {101 my $body = $pattern.substr(1, $pattern.chars - 2);102 my $rx = / <{ $body }> /;103 return so $description.match($rx);104 }105 $description.contains($pattern);106 }107108 method full-description($example --> Str) {109 my @parts;110 for $example.ancestry -> $node {111 next if $node ~~ Suite;112 @parts.push: $node.description if $node.description.defined;113 }114 @parts.join(' ');115 }116117 method group-has-matching-example(ExampleGroup $group --> Bool) {118 for $group.examples -> $ex {119 return True if self.example-matches($ex);120 }121 for $group.groups -> $g {122 return True if self.group-has-matching-example($g);123 }124 False;125 }126127 method suite-has-matching-example(Suite $suite --> Bool) {128 for $suite.examples -> $ex {129 return True if self.example-matches($ex);130 }131 for $suite.groups -> $g {132 return True if self.group-has-matching-example($g);133 }134 False;135 }136137 method extract(@suites --> Str) {138 given $!format {139 when 'markdown' { return self.render-markdown(@suites) }140 when 'html' { return self.render-html(@suites) }141 when 'json' { return self.render-json(@suites) }142 }143 }144145 method render-markdown(@suites --> Str) {146 my @lines;147 my $multi = @suites.elems > 1;148 for @suites -> $suite {149 next if self.has-filters && !self.suite-has-matching-example($suite);150 if $multi {151 @lines.push: "# {$suite.description}";152 @lines.push: '';153 }154 self.md-render-children($suite, $multi ?? 2 !! 1, @lines);155 }156 @lines.join("\n") ~ "\n";157 }158159 method md-render-children($container, Int $heading-level, @lines) {160 for $container.children -> $child {161 given $child {162 when ExampleGroup {163 next if self.has-filters && !self.group-has-matching-example($child);164 my $hashes = '#' x ($heading-level min 6);165 @lines.push: "$hashes {$child.description}";166 @lines.push: '';167 self.md-render-children($child, $heading-level + 1, @lines);168 }169 when Example {170 next if self.has-filters && !self.example-matches($child);171 my $status = describe-status($child);172 my $suffix = status-suffix($status);173 my $tags = self.md-tag-suffix($child);174 @lines.push: "- {$child.description}{$suffix}{$tags}";175 }176 }177 }178 # blank line trailing examples so subsequent group heading separates cleanly179 if @lines.elems && @lines[*-1] ne '' {180 @lines.push: '';181 }182 }183184 method md-tag-suffix($node --> Str) {185 my @tags = $node.tags;186 return '' unless @tags.elems;187 ' [' ~ @tags.join(', ') ~ ']';188 }189190 method render-html(@suites --> Str) {191 my @lines;192 @lines.push: '<!DOCTYPE html>';193 @lines.push: '<html>';194 @lines.push: '<head><meta charset="utf-8"><title>Spec documentation</title></head>';195 @lines.push: '<body>';196 for @suites -> $suite {197 next if self.has-filters && !self.suite-has-matching-example($suite);198 @lines.push: '<section class="suite">';199 @lines.push: '<h1>' ~ html-escape($suite.description) ~ '</h1>';200 self.html-render-children($suite, 2, @lines);201 @lines.push: '</section>';202 }203 @lines.push: '</body>';204 @lines.push: '</html>';205 @lines.join("\n") ~ "\n";206 }207208 method html-render-children($container, Int $heading-level, @lines) {209 my @child-examples;210 for $container.children -> $child {211 given $child {212 when ExampleGroup {213 next if self.has-filters && !self.group-has-matching-example($child);214 if @child-examples.elems {215 self.html-emit-example-list(@child-examples, @lines);216 @child-examples = [];217 }218 @lines.push: '<section class="group">';219 my $level = $heading-level min 6;220 @lines.push: "<h{$level}>" ~ html-escape($child.description) ~ "</h{$level}>";221 self.html-render-children($child, $heading-level + 1, @lines);222 @lines.push: '</section>';223 }224 when Example {225 next if self.has-filters && !self.example-matches($child);226 @child-examples.push: $child;227 }228 }229 }230 if @child-examples.elems {231 self.html-emit-example-list(@child-examples, @lines);232 }233 }234235 method html-emit-example-list(@examples, @lines) {236 @lines.push: '<ul class="examples">';237 for @examples -> $ex {238 my $status = describe-status($ex);239 my $desc = html-escape($ex.description);240 my $line = '<li class="example status-' ~ $status ~ '">' ~ $desc;241 if $status ne 'passing' {242 $line ~= ' <em class="status">(' ~ $status ~ ')</em>';243 }244 my @tags = $ex.tags;245 if @tags.elems {246 $line ~= ' <span class="tags">[' ~ html-escape(@tags.join(', ')) ~ ']</span>';247 }248 $line ~= '</li>';249 @lines.push: $line;250 }251 @lines.push: '</ul>';252 }253254 method render-json(@suites --> Str) {255 my @rendered-suites;256 for @suites -> $suite {257 next if self.has-filters && !self.suite-has-matching-example($suite);258 @rendered-suites.push: self.json-suite($suite);259 }260 BDD::Behave::Benchmark::Format::to-json(%(261 version => 1,262 suites => @rendered-suites,263 ));264 }265266 method json-suite(Suite $suite --> Hash) {267 %(268 description => $suite.description,269 file => $suite.file.Str,270 groups => self.json-groups($suite),271 examples => self.json-examples($suite),272 );273 }274275 method json-groups($container --> List) {276 my @groups;277 for $container.children -> $child {278 next unless $child ~~ ExampleGroup;279 next if self.has-filters && !self.group-has-matching-example($child);280 @groups.push: %(281 description => $child.description,282 file => $child.file.Str,283 line => $child.line,284 tags => $child.tags.List,285 groups => self.json-groups($child),286 examples => self.json-examples($child),287 );288 }289 @groups.List;290 }291292 method json-examples($container --> List) {293 my @examples;294 for $container.children -> $child {295 next unless $child ~~ Example;296 next if self.has-filters && !self.example-matches($child);297 @examples.push: %(298 description => $child.description,299 file => $child.file.Str,300 line => $child.line,301 tags => $child.tags.List,302 pending => $child.pending.so,303 skipped => $child.effective-skipped.so,304 focused => $child.effective-focused.so,305 );306 }307 @examples.List;308 }309}310311sub base-exports() {312 %(313 DocExtractor => DocExtractor,314 );315}316317sub EXPORT(:$ALL?) {318 my %exports = base-exports();319 %(320 DEFAULT => %exports,321 ALL => %exports,322 );323}