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.
- checksums.yaml +4 -4
- data/README.md +57 -20
- data/exe/path_coverage_collector +12 -1
- data/lib/path_coverage/calls.rb +67 -0
- data/lib/path_coverage/collector.rb +88 -22
- data/lib/path_coverage/condition.rb +54 -3
- data/lib/path_coverage/configuration.rb +9 -6
- data/lib/path_coverage/html/index.html +5 -2
- data/lib/path_coverage/html/side.js +15 -4
- data/lib/path_coverage/html/sort.js +4 -1
- data/lib/path_coverage/html/style.css +4 -0
- data/lib/path_coverage/html_index.rb +48 -17
- data/lib/path_coverage/html_report.rb +18 -3
- data/lib/path_coverage/html_tree.rb +11 -2
- data/lib/path_coverage/json_writer.rb +11 -7
- data/lib/path_coverage/kernel.rb +21 -35
- data/lib/path_coverage/manifest.rb +40 -12
- 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 +37 -18
- data/lib/path_coverage/recorder.rb +83 -0
- data/lib/path_coverage/rewriter.rb +53 -7
- data/lib/path_coverage/site.rb +6 -1
- data/lib/path_coverage/site_paths.rb +21 -9
- data/lib/path_coverage/site_tree.rb +50 -10
- data/lib/path_coverage/summary.rb +36 -17
- data/lib/path_coverage/totals.rb +29 -0
- data/lib/path_coverage/version.rb +1 -1
- metadata +6 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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 =
|
|
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:,
|
|
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 = {
|
|
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
|
|
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,
|
|
8
|
-
# PathsReport gives them,
|
|
9
|
-
# [counts, ...]}, "paths":
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
15
|
-
|
|
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
|
data/lib/path_coverage/kernel.rb
CHANGED
|
@@ -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
|
|
92
|
+
# counted, none unless config measures methods.
|
|
122
93
|
def self.path_methods(path, source, config)
|
|
123
|
-
return {} unless config.measures.include?(:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
98
|
-
#
|
|
101
|
+
# Notes a ternary or an if site, a modifier if included,
|
|
102
|
+
# its arms included.
|
|
99
103
|
def visit_if_node(node)
|
|
100
|
-
|
|
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
|
|
106
|
-
#
|
|
107
|
-
#
|
|
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.
|
|
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
|
|
120
|
-
#
|
|
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
|
|
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 ?:,
|
|
6
|
-
# if, named
|
|
7
|
-
# extent_of, grouped, leaf and
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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,
|
|
32
|
-
#
|
|
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.
|
|
52
|
+
start = keyword_loc(node).start_offset
|
|
36
53
|
Condition.new(
|
|
37
|
-
operator: '
|
|
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,
|
|
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(
|
|
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
|