← Back to index

lib/BDD/Behave/DocExtractor.rakumod

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('&', '&amp;',  :g);15  $body = $body.subst('<', '&lt;',   :g);16  $body = $body.subst('>', '&gt;',   :g);17  $body = $body.subst('"', '&quot;', :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}