path_coverage 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,41 +3,72 @@ require_relative 'html_template'
3
3
 
4
4
  module PathCoverage
5
5
  # The HTML index page: one sortable row per covered file,
6
- # each linking to that file's own page.
6
+ # each linking to that file's own page, then a Total row.
7
7
  module HtmlIndex
8
8
  # The whole index page: the files table, sortable by any
9
- # column, with one row per Summary.files row.
10
- def self.page(rows)
9
+ # column, with one row per Summary.files row, and total, as
10
+ # Summary.total gives it, as its footer.
11
+ def self.page(rows, total)
11
12
  HtmlTemplate.fill(
12
13
  HtmlTemplate.asset('index.html'),
13
14
  'style' => HtmlTemplate.asset('style.css'),
14
15
  'script' => HtmlTemplate.asset('sort.js'),
15
- 'rows' => rows.map { |row| row(row) }.join("\n")
16
+ 'rows' => rows.map { |row| row(row) }.join("\n"),
17
+ 'total' => total_row(total),
18
+ 'across' => total[:methods] ? ACROSS : ''
16
19
  )
17
20
  end
18
21
 
22
+ # The heading of the across column, there only when methods
23
+ # are measured.
24
+ ACROSS = '<th class="num" aria-sort="none"><button ' \
25
+ 'type="button" data-column="3" data-type="number">' \
26
+ "Across a method's decisions</button></th>".freeze
27
+
19
28
  # The table row of one Summary.files row; each cell has a
20
29
  # data-sort value for the page's column sorting.
21
30
  def self.row(row)
22
- "<tr>#{cells(row).join}</tr>"
23
- end
24
-
25
- # The four cells: file, sites, outcomes seen, missing.
26
- def self.cells(row)
27
31
  path = CGI.escapeHTML(row[:path])
28
32
  link = %(<a href="files/#{path}.html">#{path}</a>)
29
- seen = "#{row[:seen]} / #{row[:outcomes]} · #{row[:percent]}%"
30
- [
31
- cell(path, link),
33
+ "<tr>#{cell(path, link)}#{counts(row).join}</tr>"
34
+ end
35
+
36
+ # The footer row: the sums over every file.
37
+ def self.total_row(total)
38
+ "<tr>#{cell('Total', 'Total')}#{counts(total).join}</tr>"
39
+ end
40
+
41
+ # The cells after the file's: its sites, then its total
42
+ # within each decision, then, when methods are measured, its
43
+ # total across a method's decisions.
44
+ def self.counts(row)
45
+ cells = [
32
46
  cell(row[:sites], row[:sites]),
33
- cell(row[:percent], seen),
34
- cell(row[:missing], row[:missing])
47
+ paths_cell(row[:decisions])
35
48
  ]
49
+ row[:methods] ? cells + [paths_cell(row[:methods])] : cells
50
+ end
51
+
52
+ # A cell showing [taken, possible] paths, eg 3 / 6 (50%),
53
+ # sorted by its percent. With no paths to take, as in a file
54
+ # with no method of two or more decisions, it shows -,
55
+ # titled with why, with an empty sort value, which sort.js
56
+ # keeps last whichever way the column is sorted.
57
+ def self.paths_cell(pair)
58
+ taken, possible = pair
59
+ if possible.zero?
60
+ return cell('', '-', 'no method with two or more decisions')
61
+ end
62
+
63
+ percent = (100.0 * taken / possible).round
64
+ cell(percent, "#{taken} / #{possible} (#{percent}%)")
36
65
  end
37
66
 
38
- # One table cell, sorted by sort_value, showing content.
39
- def self.cell(sort_value, content)
40
- %(<td data-sort="#{sort_value}">#{content}</td>)
67
+ # One table cell, sorted by sort_value, showing content,
68
+ # with title as its hover text when there is one.
69
+ def self.cell(sort_value, content, title = nil)
70
+ hover = title ? %( title="#{title}") : ''
71
+ %(<td data-sort="#{sort_value}"#{hover}>#{content}</td>)
41
72
  end
42
73
  end
43
74
  end
@@ -18,7 +18,7 @@ module PathCoverage
18
18
  # file, as the kernel parsed it.
19
19
  def self.write(operands, files, config, paths = nil, site_paths:)
20
20
  dir = config.output_dir
21
- index = HtmlIndex.page(Summary.files(operands))
21
+ index = index(files, config, paths, site_paths)
22
22
  File.write("#{dir}/index.html", index)
23
23
  files.each do |full_path, parsed|
24
24
  path = config.relative(full_path)
@@ -30,6 +30,20 @@ module PathCoverage
30
30
  end
31
31
  end
32
32
 
33
+ # The index page: a row per file of its totals within each
34
+ # decision, and, when paths are given, as they are when
35
+ # methods are measured, across each method's decisions too;
36
+ # then their Total row.
37
+ def self.index(files, config, paths, site_paths)
38
+ methods = files.map do |full_path, parsed|
39
+ path = config.relative(full_path)
40
+ PathManifest.methods(parsed[:source], path)
41
+ end
42
+ all = methods.reduce({}, :merge)
43
+ rows = Summary.files(site_paths, all, paths)
44
+ HtmlIndex.page(rows, Summary.total(rows))
45
+ end
46
+
33
47
  # simplecov:disable
34
48
  # SPIKE: the hidden path grids of one file's methods.
35
49
  def self.path_grids(path, parsed, paths)
@@ -80,10 +94,11 @@ module PathCoverage
80
94
  tree.merge(at: at(source, condition.extent), operands:)
81
95
  end
82
96
 
83
- # An arm's node with its at:, and its body's tree located.
97
+ # An arm's node with its at:, nil for an arm with no
98
+ # statements, and its body's tree located.
84
99
  def self.located_arm(source, arm, node)
85
100
  body = arm.body && located(source, arm.body, node[:body])
86
- node.merge(at: at(source, arm.extent), body:)
101
+ node.merge(at: arm.extent && at(source, arm.extent), body:)
87
102
  end
88
103
 
89
104
  # [line, column, line, column] of the byte range in source.
@@ -1,10 +1,19 @@
1
1
  require 'cgi/escape'
2
+ require_relative 'condition'
2
3
 
3
4
  module PathCoverage
4
5
  # The tree of a span's sites on a file's page, from SiteTree.of
5
6
  # for each site: a grid whose columns are a site's paths.
6
7
  module HtmlTree
7
- OUTCOMES = { true => 'T', false => 'F', nil => '' }.freeze
8
+ OUTCOMES = {
9
+ true => 'T', false => 'F', nil => '',
10
+ ITEMS[0] => '0', ITEMS[1] => '1', ITEMS[2] => '*',
11
+ ANSWERS[0] => 'T', ANSWERS[1] => 'F', ANSWERS[2] => 'TF'
12
+ }.freeze
13
+ # The choices whose arms are named by their kind, eg then,
14
+ # else or when, as their text is statements.
15
+ NAMED_ARMS = [*STATEMENT_CHOICES, 'case'].freeze
16
+
8
17
  # A bullet marks a path taking an arm.
9
18
  TAKEN = { true => [0x2022].pack('U'), false => '' }.freeze
10
19
 
@@ -92,7 +101,7 @@ module PathCoverage
92
101
 
93
102
  # The name of an arm of a condition whose operator is choice.
94
103
  def self.arm_label(arm, choice)
95
- return arm[:arm].to_s if choice == 'if'
104
+ return arm[:arm].to_s if NAMED_ARMS.include?(choice)
96
105
 
97
106
  mark = arm[:arm] == :then ? '?' : ':'
98
107
  body = arm[:body]
@@ -4,17 +4,21 @@ module PathCoverage
4
4
  # The base reporter: the result as raw JSON, which every
5
5
  # other reporter can read back.
6
6
  module JsonWriter
7
- # Writes operands, as Result gives them, and paths, as
8
- # PathsReport gives them, to path as {"sites": {site:
9
- # [counts, ...]}, "paths": {site: {leaves:, taken:}}}.
10
- def self.write(operands, path, paths = {})
11
- File.write(path, json(operands, paths))
7
+ # Writes operands, as Result gives them, paths, as
8
+ # PathsReport gives them, and totals, as Kernel.totals gives
9
+ # them, to path as {"sites": {site: [counts, ...]}, "paths":
10
+ # {site: {leaves:, taken:}}, "totals": {"decisions": [taken,
11
+ # possible]}}.
12
+ def self.write(operands, path, paths = {}, totals: {})
13
+ File.write(path, json(operands, paths, totals:))
12
14
  end
13
15
 
14
- # operands, and any paths, as the JSON report's text.
15
- def self.json(operands, paths = {})
16
+ # operands, and any paths and totals, as the JSON report's
17
+ # text.
18
+ def self.json(operands, paths = {}, totals: {})
16
19
  report = { 'sites' => operands }
17
20
  report['paths'] = paths unless paths.empty?
21
+ report['totals'] = totals unless totals.empty?
18
22
  JSON.pretty_generate(report)
19
23
  end
20
24
  end
@@ -1,4 +1,5 @@
1
1
  require 'fileutils'
2
+ require_relative 'calls'
2
3
  require_relative 'configuration'
3
4
  require_relative 'json_writer'
4
5
  require_relative 'manifest'
@@ -9,6 +10,7 @@ require_relative 'recorder'
9
10
  require_relative 'render'
10
11
  require_relative 'result'
11
12
  require_relative 'rewriter'
13
+ require_relative 'totals'
12
14
 
13
15
  # Path coverage for Ruby. This file is the kernel: it loads
14
16
  # before any covered file and is never itself instrumented, since
@@ -32,37 +34,6 @@ module PathCoverage
32
34
  @config
33
35
  end
34
36
 
35
- # The call rewritten code makes around each operand; returns
36
- # value unchanged.
37
- def self.record(site, index, value)
38
- @recorder.record(site, index, value)
39
- end
40
-
41
- # The call rewritten code makes before a site's first leaf,
42
- # site having size leaves; returns the evaluation's trail.
43
- def self.enter(site, size)
44
- @recorder.enter(site, size)
45
- end
46
-
47
- # The call rewritten code makes around a site as a whole,
48
- # with the trail enter gave it; returns value unchanged.
49
- def self.leave(trail, value)
50
- @recorder.leave(trail, value)
51
- end
52
-
53
- # The call rewritten code makes as a call reaches its
54
- # method's first decision; returns the call's new path.
55
- def self.path(site, size)
56
- @recorder.path(site, size)
57
- end
58
-
59
- # The call rewritten code makes around each decision's
60
- # predicate, with exits for one whose arm leaves the method;
61
- # returns value unchanged.
62
- def self.decide(path, index, value, exits = nil)
63
- @recorder.decide(path, index, value, exits)
64
- end
65
-
66
37
  # Instruments every file later required that the settings
67
38
  # block covers, and reports when the process exits. on_exit
68
39
  # is Kernel#at_exit except in tests, which must not print
@@ -118,9 +89,9 @@ module PathCoverage
118
89
  end
119
90
 
120
91
  # The methods of the covered file at path whose paths are
121
- # counted, none unless config measures paths.
92
+ # counted, none unless config measures methods.
122
93
  def self.path_methods(path, source, config)
123
- return {} unless config.measures.include?(:paths)
94
+ return {} unless config.measures.include?(:methods)
124
95
 
125
96
  PathManifest.methods(source, config.relative(path))
126
97
  end
@@ -132,7 +103,8 @@ module PathCoverage
132
103
  def self.report(config)
133
104
  files = parsed_files(config)
134
105
  result = collect(files, config)
135
- paths = config.measures.include?(:paths) ? @recorder.paths : nil
106
+ methods = config.measures.include?(:methods)
107
+ paths = methods ? @recorder.paths : nil
136
108
  Render.run(
137
109
  result.operands, files, config, paths, site_paths: result.paths
138
110
  )
@@ -159,10 +131,24 @@ module PathCoverage
159
131
  result = Result.new(sites, @recorder.counts, @recorder.site_paths)
160
132
  FileUtils.mkdir_p(config.output_dir)
161
133
  report = PathsReport.of(result.paths, files)
162
- JsonWriter.write(result.operands, config.json_path, report)
134
+ totals = totals(result, files, config)
135
+ path = config.json_path
136
+ JsonWriter.write(result.operands, path, report, totals:)
163
137
  result
164
138
  end
165
139
 
140
+ # The JSON report's totals, each [taken, possible] paths:
141
+ # within each decision, and, when config measures methods,
142
+ # across each method's decisions too.
143
+ def self.totals(result, files, config)
144
+ totals = { 'decisions' => Totals.decisions(result.paths) }
145
+ return totals unless config.measures.include?(:methods)
146
+
147
+ methods = path_methods_of(files, config)
148
+ taken = Totals.methods(methods, @recorder.paths)
149
+ totals.merge('methods' => taken)
150
+ end
151
+
166
152
  # { full path => {source:, sites:} } for every covered file:
167
153
  # as parsed when it loaded, or parsed now for a file the run
168
154
  # never loaded.
@@ -1,12 +1,14 @@
1
1
  require 'prism'
2
2
  require_relative 'condition'
3
+ require_relative 'manifest_blocks'
4
+ require_relative 'manifest_case'
3
5
  require_relative 'manifest_choices'
4
6
  require_relative 'site'
5
7
 
6
8
  module PathCoverage
7
9
  # Finds every condition site in source without running it: each
8
- # outermost &&, ||, and or or, each ternary and each if, with the
9
- # conditions nested in it.
10
+ # outermost &&, ||, and or or, each ternary, if, unless and case
11
+ # with a subject, with the conditions nested in it.
10
12
  module Manifest
11
13
  # { site => Condition } for each site in source.
12
14
  def self.sites(source, path)
@@ -69,6 +71,8 @@ module PathCoverage
69
71
 
70
72
  # Collects each site and its Condition.
71
73
  class ConditionSites < Prism::Visitor
74
+ include ManifestBlocks
75
+ include ManifestCase
72
76
  include ManifestChoices
73
77
 
74
78
  # Starts with no sites found.
@@ -94,19 +98,40 @@ module PathCoverage
94
98
  add_site(node)
95
99
  end
96
100
 
97
- # Notes a ternary or an if site, its arms included; a
98
- # modifier if is no site itself.
101
+ # Notes a ternary or an if site, a modifier if included,
102
+ # its arms included.
99
103
  def visit_if_node(node)
100
- return super if modifier?(node)
104
+ add_site(node)
105
+ end
106
+
107
+ # Notes an unless site, a modifier unless included, its
108
+ # arms included.
109
+ def visit_unless_node(node)
110
+ add_site(node)
111
+ end
112
+
113
+ # Notes a case site, when its paths can be seen; any other
114
+ # case is no site, though a condition inside it may be.
115
+ def visit_case_node(node)
116
+ return super unless case_site?(node)
117
+
118
+ add_site(node)
119
+ end
120
+
121
+ # Notes a block method's call as a site, when its block's
122
+ # every call can be seen; any other call is no site, though
123
+ # a condition inside it may be.
124
+ def visit_call_node(node)
125
+ return super unless block_site?(node)
101
126
 
102
127
  add_site(node)
103
128
  end
104
129
 
105
- # Notes node as a site. It claims its place first, so a
106
- # site found inside one of its leaves follows it, keeping
107
- # the sites in source order.
130
+ # Notes node as a site, named where it starts: an if or an
131
+ # unless at its keyword. It claims its place first, so a
132
+ # site found inside one of its leaves or arms follows it.
108
133
  def add_site(node)
109
- site = Site.of(@path, node)
134
+ site = Site.at(@path, site_loc(node))
110
135
  @sites[site] = nil
111
136
  outer = @notes
112
137
  @notes = { commands: [], fixed: {} }
@@ -116,10 +141,13 @@ module PathCoverage
116
141
  end
117
142
 
118
143
  # node as a Condition, operands and all, named by its
119
- # operator as written; a ternary is named ?: and an if is
120
- # named if, their operands being a predicate and arms.
144
+ # operator as written; a ternary is named ?:, an if if and
145
+ # an unless unless, their operands a predicate and arms; a
146
+ # case case, its operands its arms.
121
147
  def condition_of(node)
122
- return choice_of(node) if node.type == :if_node
148
+ return choice_of(node) if CHOICE_TYPES.include?(node.type)
149
+ return case_condition(node) if node.type == :case_node
150
+ return block_condition(node) if node.type == :call_node
123
151
 
124
152
  Condition.new(
125
153
  operator: node.operator, operands: operands_of(node),
@@ -0,0 +1,57 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # The part of the manifest's visitor that builds the site of a
5
+ # call to a block method, one that calls its block an item at
6
+ # a time, until it decides, eg any?, or for every item, eg
7
+ # select: its leaves are its receiver, whose items the block
8
+ # is called with, and its block's one expression, whose
9
+ # answers decide. It relies on its includer's extent_of and
10
+ # @notes.
11
+ module ManifestBlocks
12
+ # True for a call whose block's every call can be seen: to a
13
+ # block method, on a receiver, with no arguments, which can
14
+ # make it ignore its block, not by &., which skips it for
15
+ # nil, and with a block of one expression.
16
+ def block_site?(node)
17
+ BLOCK_PATHS.key?(node.name.to_s) && !node.receiver.nil? &&
18
+ node.arguments.nil? && !node.safe_navigation? &&
19
+ !block_expression(node.block).nil?
20
+ end
21
+
22
+ # The one expression of block, nil for no block, a block
23
+ # passed as an argument, or one of other than one expression.
24
+ def block_expression(block)
25
+ return nil unless block&.type == :block_node
26
+
27
+ statements = block.body
28
+ return nil unless statements&.type == :statements_node
29
+
30
+ statements.body.size == 1 ? statements.body.first : nil
31
+ end
32
+
33
+ # A block method's call as a Condition, named by its method,
34
+ # its extent the whole call. Its block's expression is noted
35
+ # among the commands when it is no bare argument.
36
+ def block_condition(node)
37
+ node.receiver.accept(self)
38
+ body = block_expression(node.block)
39
+ body.accept(self)
40
+ @notes[:commands] << extent_of(body) if bare_less?(body)
41
+ Condition.new(
42
+ operator: node.name.to_s,
43
+ operands: [extent_of(node.receiver), extent_of(body)],
44
+ extent: extent_of(node)
45
+ )
46
+ end
47
+
48
+ # True for an expression that is no bare argument: a command
49
+ # call, or an and or an or.
50
+ def bare_less?(node)
51
+ return true if Manifest.command?(node)
52
+
53
+ %i[and_node or_node].include?(node.type) &&
54
+ %w[and or].include?(node.operator)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # The part of the manifest's visitor that builds a case site,
5
+ # named case: its arms are its whens, each holding a when
6
+ # condition of its patterns, and its else. It relies on its
7
+ # includer's extent_of and statement_arm.
8
+ module ManifestCase
9
+ # True for a case whose paths can be seen: one with a
10
+ # subject, as each pattern is then matched against it, and
11
+ # no splat among its patterns, whose items cannot each be
12
+ # stood in for.
13
+ def case_site?(node)
14
+ return false if node.predicate.nil?
15
+
16
+ node.conditions.none? do |clause|
17
+ clause.conditions.any? { |each| each.type == :splat_node }
18
+ end
19
+ end
20
+
21
+ # A case as a Condition, its extent its case keyword to the
22
+ # end of its subject, as its arms are statements.
23
+ def case_condition(node)
24
+ node.predicate.accept(self)
25
+ whens = node.conditions.map { |clause| when_arm(clause) }
26
+ start = node.case_keyword_loc.start_offset
27
+ Condition.new(
28
+ operator: 'case',
29
+ operands: [*whens, *case_else(node.else_clause)],
30
+ extent: start...node.predicate.location.end_offset
31
+ )
32
+ end
33
+
34
+ # One when as an arm, its body a when condition whose leaves
35
+ # are its patterns' byte ranges. A pattern is matched, not
36
+ # tested for truth, so none is ever fixed.
37
+ def when_arm(clause)
38
+ patterns = clause.conditions.map do |pattern|
39
+ pattern.accept(self)
40
+ extent_of(pattern)
41
+ end
42
+ body = Condition.new(
43
+ operator: 'when', operands: patterns,
44
+ extent: patterns.first.begin...patterns.last.end
45
+ )
46
+ statement_arm(:when, clause.statements).with(body:)
47
+ end
48
+
49
+ # A case's else arm, none when it has no else.
50
+ def case_else(clause)
51
+ clause ? [statement_arm(:else, clause.statements)] : []
52
+ end
53
+ end
54
+ end
@@ -2,20 +2,37 @@ require_relative 'condition'
2
2
 
3
3
  module PathCoverage
4
4
  # The part of the manifest's visitor that builds the sites
5
- # whose predicate picks an arm: a ternary, named ?:, and an
6
- # if, named if. It relies on its includer's condition_of,
7
- # extent_of, grouped, leaf and operator?.
5
+ # whose predicate picks an arm: a ternary, named ?:, an if,
6
+ # named if, and an unless, named unless. It relies on its
7
+ # includer's condition_of, extent_of, grouped, leaf and
8
+ # operator?.
8
9
  module ManifestChoices
9
- # True for a modifier if, eg `return if done?`, whose
10
- # statement comes before its if keyword.
11
- def modifier?(node)
12
- return false if ternary?(node) || node.statements.nil?
10
+ # The node types whose predicate picks an arm, a ternary
11
+ # being an if_node with no if keyword.
12
+ CHOICE_TYPES = %i[if_node unless_node].freeze
13
13
 
14
- node.statements.location.start_offset <
15
- node.if_keyword_loc.start_offset
14
+ # Where node's site starts: an if's or an unless's keyword,
15
+ # which a modifier's statement comes before; anything
16
+ # else's own start.
17
+ def site_loc(node)
18
+ return node.message_loc if node.type == :call_node
19
+ return node.location unless CHOICE_TYPES.include?(node.type)
20
+ return node.location if ternary?(node)
21
+
22
+ keyword_loc(node)
23
+ end
24
+
25
+ # The location of an if's or an unless's keyword.
26
+ def keyword_loc(node)
27
+ unless?(node) ? node.keyword_loc : node.if_keyword_loc
28
+ end
29
+
30
+ # True for an unless.
31
+ def unless?(node)
32
+ node.type == :unless_node
16
33
  end
17
34
 
18
- # A ternary or an if as a Condition.
35
+ # A ternary, an if or an unless as a Condition.
19
36
  def choice_of(node)
20
37
  ternary?(node) ? ternary_condition(node) : if_condition(node)
21
38
  end
@@ -28,24 +45,26 @@ module PathCoverage
28
45
  )
29
46
  end
30
47
 
31
- # An if as a Condition, its extent its if keyword to the end
32
- # of its predicate, as its arms are statements, not part of
33
- # the expression.
48
+ # An if or an unless as a Condition, named by its keyword,
49
+ # its extent its keyword to the end of its predicate, as its
50
+ # arms are statements, not part of the expression.
34
51
  def if_condition(node)
35
- start = node.if_keyword_loc.start_offset
52
+ start = keyword_loc(node).start_offset
36
53
  Condition.new(
37
- operator: 'if', operands: if_of(node),
54
+ operator: unless?(node) ? 'unless' : 'if',
55
+ operands: if_of(node),
38
56
  extent: start...node.predicate.location.end_offset
39
57
  )
40
58
  end
41
59
 
42
- # An if's predicate, then its then arm and, when it has
43
- # one, its else arm.
60
+ # An if's or an unless's predicate, then its then arm and,
61
+ # when it has one, its else arm.
44
62
  def if_of(node)
63
+ rest = unless?(node) ? node.else_clause : node.subsequent
45
64
  [
46
65
  predicate_of(node),
47
66
  statement_arm(:then, node.statements),
48
- *else_arm(node.subsequent)
67
+ *else_arm(rest)
49
68
  ]
50
69
  end
51
70
 
@@ -1,3 +1,5 @@
1
+ require_relative 'condition'
2
+
1
3
  module PathCoverage
2
4
  # Records the outcome of each instrumented operand.
3
5
  class Recorder
@@ -95,5 +97,86 @@ module PathCoverage
95
97
  trail.outcomes[index] = outcome == :truthy if trail
96
98
  value
97
99
  end
100
+
101
+ # A stand-in for a case's index-th pattern of total: the
102
+ # case matches its subject against the stand-in, which
103
+ # matches it against the pattern, once, as the case would
104
+ # have.
105
+ WhenMatch = Struct.new(
106
+ :recorder, :site, :index, :total, :pattern
107
+ ) do
108
+ # Whether pattern matches subject, exactly as pattern says,
109
+ # noted on the site's path.
110
+ def ===(subject)
111
+ recorder.matched(site, index, total, pattern === subject)
112
+ end
113
+ end
114
+
115
+ # The stand-in for pattern, the index-th of size in the case
116
+ # at site.
117
+ def when_match(site, index, size, pattern)
118
+ WhenMatch.new(self, site, index, size, pattern)
119
+ end
120
+
121
+ # Notes whether the index-th of size patterns matched, the
122
+ # first entering the case's site, and a match or the last
123
+ # leaving it, as no more patterns are then tried; returns
124
+ # matched unchanged.
125
+ def matched(site, index, size, matched)
126
+ enter(site, size) if index.zero?
127
+ record(site, index, matched)
128
+ last = matched || index == size - 1
129
+ leave(trails(site).last, matched) if last
130
+ matched
131
+ end
132
+
133
+ # Starts an evaluation of a stop-early block method's call
134
+ # at site, its block not yet called with any item.
135
+ def enter_items(site)
136
+ trail = enter(site, 2)
137
+ trail.outcomes[0] = ITEMS[0]
138
+ trail
139
+ end
140
+
141
+ # Notes value, the block's answer, as its last, and one more
142
+ # item it was called with; returns value unchanged. A block
143
+ # called where its call was not entered, eg in another
144
+ # fiber, is counted but noted on no path.
145
+ def item(site, value)
146
+ trail = trails(site).last
147
+ one_more(trail) if trail
148
+ record(site, 1, value)
149
+ end
150
+
151
+ # Notes value, the block's answer for one more item, among
152
+ # all its answers so far: all true, all false, or both;
153
+ # returns value unchanged. A block called where its call
154
+ # was not entered is counted but noted on no path.
155
+ def answer(site, value)
156
+ trail = trails(site).last
157
+ if trail
158
+ one_more(trail)
159
+ now = value ? ANSWERS[0] : ANSWERS[1]
160
+ same = [nil, now].include?(trail.outcomes[1])
161
+ trail.outcomes[1] = same ? now : ANSWERS[2]
162
+ end
163
+ @counts[[site, 1]][value ? :truthy : :falsy] += 1
164
+ value
165
+ end
166
+
167
+ # Notes on trail one more item its block was called with.
168
+ def one_more(trail)
169
+ called = ITEMS.index(trail.outcomes[0]) + 1
170
+ trail.outcomes[0] = ITEMS[[called, 2].min]
171
+ end
172
+
173
+ # Ends a block method's call, counting its receiver truthy
174
+ # when its block was called, falsy when not, and its path;
175
+ # returns value, the call's, unchanged.
176
+ def leave_items(trail, value)
177
+ called = trail.outcomes[0] != ITEMS[0]
178
+ @counts[[trail.site, 0]][called ? :truthy : :falsy] += 1
179
+ leave(trail, value)
180
+ end
98
181
  end
99
182
  end