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.
- checksums.yaml +4 -4
- data/LICENSE.md +22 -0
- data/README.md +353 -0
- data/exe/path_coverage_collector +37 -0
- data/lib/path_coverage/auto.rb +5 -0
- data/lib/path_coverage/calls.rb +67 -0
- data/lib/path_coverage/collector.rb +112 -0
- data/lib/path_coverage/condition.rb +96 -0
- data/lib/path_coverage/configuration.rb +130 -0
- data/lib/path_coverage/gaps.rb +60 -0
- data/lib/path_coverage/html/file.html +33 -0
- data/lib/path_coverage/html/index.html +41 -0
- data/lib/path_coverage/html/side.js +542 -0
- data/lib/path_coverage/html/sort.js +31 -0
- data/lib/path_coverage/html/style.css +338 -0
- data/lib/path_coverage/html/toggle.js +12 -0
- data/lib/path_coverage/html_file.rb +109 -0
- data/lib/path_coverage/html_index.rb +74 -0
- data/lib/path_coverage/html_nav.rb +67 -0
- data/lib/path_coverage/html_paths.rb +138 -0
- data/lib/path_coverage/html_report.rb +118 -0
- data/lib/path_coverage/html_template.rb +20 -0
- data/lib/path_coverage/html_tree.rb +146 -0
- data/lib/path_coverage/json_writer.rb +25 -0
- data/lib/path_coverage/kernel.rb +165 -0
- data/lib/path_coverage/manifest.rb +205 -0
- data/lib/path_coverage/manifest_blocks.rb +57 -0
- data/lib/path_coverage/manifest_case.rb +54 -0
- data/lib/path_coverage/manifest_choices.rb +132 -0
- data/lib/path_coverage/merge.rb +35 -0
- data/lib/path_coverage/path_gaps.rb +41 -0
- data/lib/path_coverage/path_manifest.rb +110 -0
- data/lib/path_coverage/paths_report.rb +55 -0
- data/lib/path_coverage/post.rb +26 -0
- data/lib/path_coverage/recorder.rb +182 -0
- data/lib/path_coverage/render.rb +92 -0
- data/lib/path_coverage/result.rb +59 -0
- data/lib/path_coverage/rewriter.rb +166 -0
- data/lib/path_coverage/site.rb +16 -0
- data/lib/path_coverage/site_paths.rb +86 -0
- data/lib/path_coverage/site_tree.rb +134 -0
- data/lib/path_coverage/source_lines.rb +79 -0
- data/lib/path_coverage/summary.rb +52 -0
- data/lib/path_coverage/totals.rb +29 -0
- data/lib/path_coverage/version.rb +3 -0
- data/lib/path_coverage.rb +2 -5
- 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
|