path_coverage 0.0.1 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.md +22 -0
  3. data/README.md +353 -0
  4. data/exe/path_coverage_collector +37 -0
  5. data/lib/path_coverage/auto.rb +5 -0
  6. data/lib/path_coverage/calls.rb +67 -0
  7. data/lib/path_coverage/collector.rb +112 -0
  8. data/lib/path_coverage/condition.rb +96 -0
  9. data/lib/path_coverage/configuration.rb +130 -0
  10. data/lib/path_coverage/gaps.rb +60 -0
  11. data/lib/path_coverage/html/file.html +33 -0
  12. data/lib/path_coverage/html/index.html +41 -0
  13. data/lib/path_coverage/html/side.js +542 -0
  14. data/lib/path_coverage/html/sort.js +31 -0
  15. data/lib/path_coverage/html/style.css +338 -0
  16. data/lib/path_coverage/html/toggle.js +12 -0
  17. data/lib/path_coverage/html_file.rb +109 -0
  18. data/lib/path_coverage/html_index.rb +74 -0
  19. data/lib/path_coverage/html_nav.rb +67 -0
  20. data/lib/path_coverage/html_paths.rb +138 -0
  21. data/lib/path_coverage/html_report.rb +118 -0
  22. data/lib/path_coverage/html_template.rb +20 -0
  23. data/lib/path_coverage/html_tree.rb +146 -0
  24. data/lib/path_coverage/json_writer.rb +25 -0
  25. data/lib/path_coverage/kernel.rb +165 -0
  26. data/lib/path_coverage/manifest.rb +205 -0
  27. data/lib/path_coverage/manifest_blocks.rb +57 -0
  28. data/lib/path_coverage/manifest_case.rb +54 -0
  29. data/lib/path_coverage/manifest_choices.rb +132 -0
  30. data/lib/path_coverage/merge.rb +35 -0
  31. data/lib/path_coverage/path_gaps.rb +41 -0
  32. data/lib/path_coverage/path_manifest.rb +110 -0
  33. data/lib/path_coverage/paths_report.rb +55 -0
  34. data/lib/path_coverage/post.rb +26 -0
  35. data/lib/path_coverage/recorder.rb +182 -0
  36. data/lib/path_coverage/render.rb +92 -0
  37. data/lib/path_coverage/result.rb +59 -0
  38. data/lib/path_coverage/rewriter.rb +166 -0
  39. data/lib/path_coverage/site.rb +16 -0
  40. data/lib/path_coverage/site_paths.rb +86 -0
  41. data/lib/path_coverage/site_tree.rb +134 -0
  42. data/lib/path_coverage/source_lines.rb +79 -0
  43. data/lib/path_coverage/summary.rb +52 -0
  44. data/lib/path_coverage/totals.rb +29 -0
  45. data/lib/path_coverage/version.rb +3 -0
  46. data/lib/path_coverage.rb +2 -5
  47. metadata +73 -14
@@ -0,0 +1,26 @@
1
+ require 'net/http'
2
+ require_relative 'json_writer'
3
+
4
+ module PathCoverage
5
+ # The :post reporter: sends the JSON report to a collector,
6
+ # for runs whose own files are thrown away, eg a failing
7
+ # docker build step.
8
+ module Post
9
+ # POSTs operands and paths, as the JSON report, to url,
10
+ # never through an http_proxy, which could not reach a
11
+ # collector local to the run. A collector that cannot be
12
+ # reached, or does not answer within a second, is passed
13
+ # over in silence: the run's own output and exit status
14
+ # must stay as they are.
15
+ def self.deliver(operands, url, paths = {})
16
+ uri = URI(url)
17
+ http = Net::HTTP.new(uri.host, uri.port, nil)
18
+ http.open_timeout = http.read_timeout = 1
19
+ headers = { 'Content-Type' => 'application/json' }
20
+ body = JsonWriter.json(operands, paths)
21
+ http.post(uri.path, body, headers)
22
+ rescue StandardError
23
+ nil
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,182 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # Records the outcome of each instrumented operand.
5
+ class Recorder
6
+ # One call's way through its method: the outcome of each
7
+ # decision made so far, of the number of decisions its
8
+ # method has.
9
+ Path = Struct.new(:site, :decisions, :outcomes)
10
+
11
+ # One evaluation's way through its site: the outcome of
12
+ # each leaf, nil for one not yet, or never, reached.
13
+ Trail = Struct.new(:site, :outcomes)
14
+
15
+ # Starts with no outcomes, paths or site paths counted.
16
+ def initialize
17
+ @counts = Hash.new do |counts, key|
18
+ counts[key] = { truthy: 0, falsy: 0 }
19
+ end
20
+ @paths = Hash.new do |paths, site|
21
+ paths[site] = Hash.new(0)
22
+ end
23
+ @site_paths = Hash.new do |site_paths, site|
24
+ site_paths[site] = Hash.new(0)
25
+ end
26
+ @trails_key = :"path_coverage_trails_#{object_id}"
27
+ end
28
+
29
+ # The stack of site's evaluations under way in the current
30
+ # thread (fiber), innermost last, so another thread's never
31
+ # mix with its.
32
+ def trails(site)
33
+ stacks = Thread.current[@trails_key] ||=
34
+ Hash.new { |all, key| all[key] = [] }
35
+ stacks[site]
36
+ end
37
+
38
+ # { site => { [outcome, ...] => evaluations } } for every
39
+ # evaluation of a site entered and left so far.
40
+ attr_reader :site_paths
41
+
42
+ # Starts an evaluation of site, which has size leaves, as
43
+ # the trail its leaves' records fill in.
44
+ def enter(site, size)
45
+ trail = Trail.new(site, Array.new(size))
46
+ trails(site).push(trail)
47
+ trail
48
+ end
49
+
50
+ # Ends trail's evaluation, counting its path, and returns
51
+ # value, the site's, unchanged. An evaluation of the same
52
+ # site still entered above trail, as one that raised
53
+ # leaves, took trail's later outcomes, so trail's path is
54
+ # not counted; the stack is cleared down to trail either
55
+ # way.
56
+ def leave(trail, value)
57
+ stack = trails(trail.site)
58
+ clean = stack.last.equal?(trail)
59
+ nil until stack.empty? || stack.pop.equal?(trail)
60
+ @site_paths[trail.site][trail.outcomes] += 1 if clean
61
+ value
62
+ end
63
+
64
+ # { method site => { [outcome, ...] => calls } } for every
65
+ # path taken all the way through so far.
66
+ attr_reader :paths
67
+
68
+ # A new call's path through the method at site, which has
69
+ # size decisions.
70
+ def path(site, size)
71
+ Path.new(site, size, [])
72
+ end
73
+
74
+ # Notes value's outcome as path's index-th decision, counts
75
+ # path once that is its last, or once it is exits, the
76
+ # outcome whose arm leaves the method, and returns value
77
+ # unchanged.
78
+ def decide(path, index, value, exits = nil)
79
+ outcome = value ? true : false
80
+ path.outcomes[index] = outcome
81
+ last = index == path.decisions - 1 || outcome == exits
82
+ @paths[path.site][path.outcomes] += 1 if last
83
+ value
84
+ end
85
+
86
+ # The truthy and falsy counts of every operand recorded so
87
+ # far, keyed by [site, index]; an operand that never ran
88
+ # has no entry.
89
+ attr_reader :counts
90
+
91
+ # Counts value's outcome and returns it unchanged, so the
92
+ # condition around it behaves exactly as uninstrumented.
93
+ def record(site, index, value)
94
+ outcome = value ? :truthy : :falsy
95
+ @counts[[site, index]][outcome] += 1
96
+ trail = trails(site).last
97
+ trail.outcomes[index] = outcome == :truthy if trail
98
+ value
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
181
+ end
182
+ end
@@ -0,0 +1,92 @@
1
+ require 'json'
2
+ require_relative 'gaps'
3
+ require_relative 'html_report'
4
+ require_relative 'manifest'
5
+ require_relative 'merge'
6
+ require_relative 'paths_report'
7
+ require_relative 'post'
8
+ require_relative 'site_paths'
9
+
10
+ module PathCoverage
11
+ # The reporters, run from operands alone: needing nothing the
12
+ # run recorded beyond them, they can run in another process.
13
+ module Render
14
+ # Runs each reporter chosen in config. operands and
15
+ # site_paths are as Result gives them; files is { full path
16
+ # => {source:, sites:} } for each covered file.
17
+ def self.run(operands, files, config, paths = nil, site_paths: {})
18
+ site_paths = every_site(site_paths, files)
19
+ reporters = config.reporters
20
+ gaps(operands, files, site_paths) if reporters.include?(:gaps)
21
+ if reporters.include?(:html)
22
+ HtmlReport.write(operands, files, config, paths, site_paths:)
23
+ end
24
+ return unless reporters.include?(:post)
25
+
26
+ report = PathsReport.of(site_paths, files)
27
+ Post.deliver(operands, config.collector_url, report)
28
+ end
29
+
30
+ # site_paths with every site in files that it lacks, eg
31
+ # from a JSON report with no paths, given each possible
32
+ # path at a count of zero: unable to say which paths such
33
+ # a site took, the report claims none.
34
+ def self.every_site(site_paths, files)
35
+ none = files.values.each_with_object({}) do |parsed, all|
36
+ parsed[:sites].each do |site, condition|
37
+ all[site] = SitePaths.possible(condition).map do |path|
38
+ { path:, count: 0 }
39
+ end
40
+ end
41
+ end
42
+ none.merge(site_paths)
43
+ end
44
+
45
+ # Prints the gap lines: each path a site never took, each
46
+ # leaf named by its text in files.
47
+ def self.gaps(operands, files, site_paths)
48
+ texts = PathsReport.texts(files)
49
+ lines = Gaps.lines(operands, paths: site_paths, texts:)
50
+ lines.each { |line| puts line }
51
+ end
52
+
53
+ # Runs the reporters chosen in config from the JSON reports
54
+ # at paths, their counts merged, parsing each covered file
55
+ # afresh. paths is config.json_path alone by default.
56
+ def self.from_json(config, paths = [config.json_path])
57
+ operands = Merge.operands(paths.map { |path| read(path) })
58
+ site_paths = Merge.paths(paths.map { |path| paths_of(path) })
59
+ files = config.files.to_h do |path|
60
+ [path, Manifest.parse(path, config)]
61
+ end
62
+ run(operands, files, config, site_paths:)
63
+ end
64
+
65
+ # The site paths of the JSON report at path, as
66
+ # Result#paths gives them; none for a report that has none.
67
+ def self.paths_of(path)
68
+ json = JSON.parse(File.read(path))
69
+ json.fetch('paths', {}).transform_values do |site|
70
+ site['taken'].map do |each|
71
+ { path: each['path'], count: each['count'] }
72
+ end
73
+ end
74
+ end
75
+
76
+ # The operands of the JSON report at path.
77
+ def self.read(path)
78
+ json = JSON.parse(File.read(path))
79
+ json['sites'].transform_values do |leaves|
80
+ leaves.map { |leaf| counts(leaf) }
81
+ end
82
+ end
83
+
84
+ # One operand's counts as JSON holds them, with the keys,
85
+ # and any only: value, as the symbols Result#operands uses.
86
+ def self.counts(leaf)
87
+ counts = { truthy: leaf['truthy'], falsy: leaf['falsy'] }
88
+ only = leaf['only']
89
+ only ? counts.merge(only: only.to_sym) : counts
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,59 @@
1
+ require_relative 'site_paths'
2
+
3
+ module PathCoverage
4
+ # What a run found: every site the manifest knows of, with
5
+ # the counts its operands were recorded with. Every
6
+ # reporter builds on this.
7
+ class Result
8
+ NEVER_RAN = { truthy: 0, falsy: 0 }.freeze
9
+
10
+ # sites is { site => Condition }, from the manifest;
11
+ # counts is { [site, index] => counts } and site_paths is
12
+ # { site => { path => evaluations } }, both from the
13
+ # recorder.
14
+ def initialize(sites, counts, site_paths = {})
15
+ @sites = sites
16
+ @counts = counts
17
+ @site_paths = site_paths
18
+ end
19
+
20
+ # { site => [{ path:, count: }, ...] }: every possible path
21
+ # of each site, as SitePaths gives them, with the number of
22
+ # evaluations that took it, zero for one never taken.
23
+ def paths
24
+ @sites.to_h do |site, condition|
25
+ taken = @site_paths.fetch(site, {})
26
+ all = SitePaths.possible(condition).map do |path|
27
+ { path:, count: taken.fetch(path, 0) }
28
+ end
29
+ [site, all]
30
+ end
31
+ end
32
+
33
+ # { site => [counts of operand 0, counts of operand 1, ...] },
34
+ # with zero counts for an operand that never ran, and
35
+ # only: naming the one outcome a fixed leaf can have.
36
+ def operands
37
+ @sites.to_h do |site, condition|
38
+ leaves = condition.leaves.each_with_index.map do |range, i|
39
+ leaf(site, condition, range, i)
40
+ end
41
+ [site, leaves]
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # The counts of one leaf, with only: when it is fixed.
48
+ def leaf(site, condition, range, index)
49
+ counts = counts_of(site, index)
50
+ only = condition.fixed[range]
51
+ only ? counts.merge(only:) : counts
52
+ end
53
+
54
+ # The recorded counts of one operand, or zeros.
55
+ def counts_of(site, index)
56
+ @counts.fetch([site, index], NEVER_RAN)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,166 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # Instruments source text so that each leaf operand records its
5
+ # outcome when the code runs. It inserts text at the byte
6
+ # ranges the manifest found, and never parses.
7
+ module Rewriter
8
+ PATH_NAME = '__path_coverage_path'.freeze
9
+
10
+ # Returns source with each operand in sites wrapped in a
11
+ # record call, each site as a whole in a leave call that
12
+ # enters it first, and each method in methods starting a
13
+ # path that its decisions are wrapped in decide calls to add
14
+ # to. Only text is inserted, never a newline, so backtrace
15
+ # line numbers still match the file on disk.
16
+ def self.rewrite(source, sites, methods = {})
17
+ inserts = sites.flat_map do |site, condition|
18
+ site_inserts(site, condition)
19
+ end
20
+ apply(source.dup, inserts + path_inserts(methods))
21
+ end
22
+
23
+ # The inserts for one site, by its kind: a case's patterns'
24
+ # stand-ins, a block method's call and block, or any other
25
+ # site's leaves' records and its leave.
26
+ def self.site_inserts(site, condition)
27
+ kind = condition.operator
28
+ return case_inserts(site, condition) if kind == 'case'
29
+
30
+ blocks = BLOCK_PATHS.key?(kind)
31
+ return block_inserts(site, condition) if blocks
32
+
33
+ record_inserts(site, condition) + trail_inserts(site, condition)
34
+ end
35
+
36
+ # The inserts that wrap a block method's call whole in a
37
+ # leave_items call that enters it first, inside any leave of
38
+ # a site it is the predicate of, and its block's expression,
39
+ # outside any site's leave there, in an item call, noting its
40
+ # last answer, or for a method that calls it with every item
41
+ # an answer call, noting every answer.
42
+ def self.block_inserts(site, condition)
43
+ _receiver, body = condition.leaves
44
+ enter = "PathCoverage.enter_items(#{site.inspect})"
45
+ call = "PathCoverage.leave_items(#{enter}, "
46
+ every = EVERY_ITEM.include?(condition.operator)
47
+ noter = every ? 'answer' : 'item'
48
+ item = "PathCoverage.#{noter}(#{site.inspect}, "
49
+ command = condition.commands.include?(body)
50
+ wrap(call, condition.extent, false, 0.5) +
51
+ wrap(item, body, command, 1.5)
52
+ end
53
+
54
+ # The inserts that wrap each pattern of a case site in a
55
+ # when_match call, its stand-in, which enters and leaves the
56
+ # site itself, as the case tries its patterns one by one.
57
+ def self.case_inserts(site, condition)
58
+ size = condition.leaves.size
59
+ condition.leaves.each_with_index.flat_map do |range, index|
60
+ call = "PathCoverage.when_match(#{site.inspect}, " \
61
+ "#{index}, #{size}, "
62
+ wrap(call, range, false, 0)
63
+ end
64
+ end
65
+
66
+ # The inserts that wrap each of site's leaves in a record
67
+ # call, innermost of all.
68
+ def self.record_inserts(site, condition)
69
+ condition.leaves.each_with_index.flat_map do |range, index|
70
+ command = condition.commands.include?(range)
71
+ wrap(record_call(site, index), range, command, 0)
72
+ end
73
+ end
74
+
75
+ WORD_OPERATORS = %w[and or].freeze
76
+
77
+ # The inserts that wrap site as a whole in a leave call,
78
+ # outside its leaves' record calls, whose first argument
79
+ # enters the site before any leaf runs, so each evaluation
80
+ # fills in a trail of its own. An and or or is held in
81
+ # parentheses, being, like a command call, no bare argument.
82
+ def self.trail_inserts(site, condition)
83
+ enter = "PathCoverage.enter(#{site.inspect}, " \
84
+ "#{condition.leaves.size})"
85
+ call = "PathCoverage.leave(#{enter}, "
86
+ wrapped = trailed(condition)
87
+ nested = wrapped.is_a?(Condition)
88
+ word = nested && WORD_OPERATORS.include?(wrapped.operator)
89
+ wrap(call, nested ? wrapped.extent : wrapped, word, 1)
90
+ end
91
+
92
+ # What a site's leave call wraps: an if's or an unless's
93
+ # predicate, a leaf's byte range or a nested Condition, as
94
+ # its path is decided there and its arms are statements that
95
+ # may return or raise; any other site as a whole.
96
+ def self.trailed(condition)
97
+ unless STATEMENT_CHOICES.include?(condition.operator)
98
+ return condition
99
+ end
100
+
101
+ condition.operands.first
102
+ end
103
+
104
+ # The opening of the record call around operand index of
105
+ # the site.
106
+ def self.record_call(site, index)
107
+ "PathCoverage.record(#{site.inspect}, #{index}, "
108
+ end
109
+
110
+ # The inserts that start each method's path at its entry,
111
+ # outermost of all there, and wrap each of its decisions'
112
+ # predicates in a decide call, outside any record or
113
+ # outcome call.
114
+ def self.path_inserts(methods)
115
+ methods.flat_map do |site, method|
116
+ decisions = method.decisions
117
+ start = path_start(site, decisions.size)
118
+ decides = decisions.each_with_index.flat_map do |decision, i|
119
+ call = "PathCoverage.decide(#{PATH_NAME}, #{i}, "
120
+ tail = decision.exits.nil? ? '' : ", #{decision.exits}"
121
+ wrap(call, decision.range, decision.command, 2, tail)
122
+ end
123
+ [[method.entry, start, 3]] + decides
124
+ end
125
+ end
126
+
127
+ # The statement that starts a call's path through the
128
+ # method at site, which has size decisions.
129
+ def self.path_start(site, size)
130
+ call = "PathCoverage.path(#{site.inspect}, #{size})"
131
+ "#{PATH_NAME} = #{call}; "
132
+ end
133
+
134
+ # The two inserts that wrap the code at range in call, of
135
+ # rank among inserts at the same offset, with tail as the
136
+ # call's further arguments. A command call is parenthesised,
137
+ # as it is no bare argument; nothing else is, since
138
+ # parentheses make a statement, which gives its line a
139
+ # line-coverage count of its own.
140
+ def self.wrap(call, range, command, rank, tail = '')
141
+ open, close = command ? ['(', ")#{tail})"] : ['', "#{tail})"]
142
+ [
143
+ [range.begin, call + open, rank],
144
+ [range.end, close, rank, :close]
145
+ ]
146
+ end
147
+
148
+ # Splices each [offset, text, rank] insert into source, last
149
+ # offset first so earlier offsets stay valid; a closing
150
+ # insert is marked :close. Text spliced in later at an
151
+ # offset lands before text already there, so at one offset
152
+ # opening inserts go in lowest rank first, leaving the
153
+ # highest outermost, then closing ones highest rank first,
154
+ # so the innermost call closes first and each tail stays
155
+ # with its own call.
156
+ def self.apply(source, inserts)
157
+ sorted = inserts.sort_by do |offset, _, rank, close|
158
+ close ? [-offset, 1, -rank] : [-offset, 0, rank]
159
+ end
160
+ sorted.each do |offset, text, _|
161
+ source.bytesplice(offset, 0, text)
162
+ end
163
+ source
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,16 @@
1
+ module PathCoverage
2
+ # Names a condition by where it starts in its file, so the
3
+ # rewriter and the manifest agree on which site is which.
4
+ module Site
5
+ # "path:line:column" of node, eg "k.rb:2:2".
6
+ def self.of(path, node)
7
+ at(path, node.location)
8
+ end
9
+
10
+ # "path:line:column" of where loc starts, eg an if's keyword,
11
+ # which a modifier if's statement comes before.
12
+ def self.at(path, loc)
13
+ "#{path}:#{loc.start_line}:#{loc.start_column}"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,86 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # The paths an evaluation of a site can take, worked out
5
+ # from its condition alone: the outcome of each leaf, in leaf
6
+ # order, nil for a leaf that evaluation never reaches.
7
+ module SitePaths
8
+ # Every possible path through site's condition, true
9
+ # before false; a stop-early block method's are fixed by its
10
+ # shape alone.
11
+ def self.possible(site)
12
+ shape = BLOCK_PATHS[site.operator]
13
+ return shape if shape
14
+
15
+ walk(site, site.fixed).map do |outcomes, _value|
16
+ site.leaves.map { |range| outcomes[range] }
17
+ end
18
+ end
19
+
20
+ # The outcome of an operand on which each operator goes on
21
+ # to its next operand; any other ends the chain there. A
22
+ # when's patterns, and a case's whens, stop at a match.
23
+ CONTINUES_ON = {
24
+ '&&' => true, 'and' => true, '||' => false, 'or' => false,
25
+ 'when' => false, 'case' => false
26
+ }.freeze
27
+
28
+ # [[{ leaf range => outcome }, value], ...] for each way
29
+ # node can be evaluated, value being the outcome of node as
30
+ # a whole; fixed is the site's { leaf range => its one
31
+ # outcome }. A case is the chain of its whens' conditions.
32
+ def self.walk(node, fixed)
33
+ return leaf(node, fixed) unless node.is_a?(Condition)
34
+ return ternary(node, fixed) if CHOICES.include?(node.operator)
35
+
36
+ if node.operator == 'case'
37
+ whens = node.operands.filter_map(&:body)
38
+ return chain(node.with(operands: whens), fixed)
39
+ end
40
+
41
+ chain(node, fixed)
42
+ end
43
+
44
+ # The ways a chain of one operator can be evaluated: each
45
+ # way of its first operand, going on to the next while each
46
+ # outcome is the one its operator goes on with.
47
+ def self.chain(node, fixed)
48
+ first, *rest = node.operands
49
+ goes_on = CONTINUES_ON.fetch(node.operator)
50
+ rest.reduce(walk(first, fixed)) do |ways, operand|
51
+ ways.flat_map do |outcomes, value|
52
+ next [[outcomes, value]] unless value == goes_on
53
+
54
+ walk(operand, fixed).map do |more, after|
55
+ [outcomes.merge(more), after]
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # The ways a ternary, an if or an unless can be evaluated:
62
+ # each way of its predicate, then each way of the condition
63
+ # the arm it picks is, if that arm is one; one with no else
64
+ # arm picks none. Its value is that of no choice, so is nil.
65
+ def self.ternary(node, fixed)
66
+ predicate, then_arm, else_arm = node.operands
67
+ then_on = THEN_ON[node.operator]
68
+ walk(predicate, fixed).flat_map do |outcomes, value|
69
+ body = (value == then_on ? then_arm : else_arm)&.body
70
+ next [[outcomes, nil]] if body.nil?
71
+
72
+ walk(body, fixed).map do |more, _|
73
+ [outcomes.merge(more), nil]
74
+ end
75
+ end
76
+ end
77
+
78
+ # The ways a leaf can be evaluated: true, then false, or
79
+ # its one outcome alone when it is fixed.
80
+ def self.leaf(range, fixed)
81
+ only = fixed[range]
82
+ outcomes = only ? [only == :truthy] : [true, false]
83
+ outcomes.map { |outcome| [{ range => outcome }, outcome] }
84
+ end
85
+ end
86
+ end