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,205 @@
|
|
|
1
|
+
require 'prism'
|
|
2
|
+
require_relative 'condition'
|
|
3
|
+
require_relative 'manifest_blocks'
|
|
4
|
+
require_relative 'manifest_case'
|
|
5
|
+
require_relative 'manifest_choices'
|
|
6
|
+
require_relative 'site'
|
|
7
|
+
|
|
8
|
+
module PathCoverage
|
|
9
|
+
# Finds every condition site in source without running it: each
|
|
10
|
+
# outermost &&, ||, and or or, each ternary, if, unless and case
|
|
11
|
+
# with a subject, with the conditions nested in it.
|
|
12
|
+
module Manifest
|
|
13
|
+
# { site => Condition } for each site in source.
|
|
14
|
+
def self.sites(source, path)
|
|
15
|
+
collector = ConditionSites.new(path)
|
|
16
|
+
Prism.parse(source).value.accept(collector)
|
|
17
|
+
collector.sites
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The source and sites of the covered file at full_path.
|
|
21
|
+
def self.parse(full_path, config)
|
|
22
|
+
source = File.read(full_path)
|
|
23
|
+
{ source:, sites: sites(source, config.relative(full_path)) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# True for a call to a named method with arguments and no
|
|
27
|
+
# parentheses, eg `id.include? 'N'`, but not `id.size == 6`;
|
|
28
|
+
# such a call is no bare argument, so a rewrite wrapping it
|
|
29
|
+
# in another call must parenthesise it.
|
|
30
|
+
def self.command?(node)
|
|
31
|
+
node.type == :call_node && node.arguments &&
|
|
32
|
+
node.opening_loc.nil? && node.name.match?(/\A[a-z_]/i)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Literals and lambdas, whose value is truthy by their form;
|
|
36
|
+
# a constant is not among them, as it can hold nil or false.
|
|
37
|
+
TRUTHY_TYPES = %i[
|
|
38
|
+
string_node integer_node symbol_node array_node hash_node
|
|
39
|
+
true_node lambda_node
|
|
40
|
+
].freeze
|
|
41
|
+
FALSY_TYPES = %i[nil_node false_node].freeze
|
|
42
|
+
|
|
43
|
+
# Ruby raises TypeError on assigning nil or false to these.
|
|
44
|
+
TRUTHY_GLOBALS = %i[$stdout $stderr].freeze
|
|
45
|
+
|
|
46
|
+
# The one outcome a leaf can have, eg :truthy for `Clock.new`
|
|
47
|
+
# or a literal, :falsy for `nil`; nil for a leaf that can
|
|
48
|
+
# have either.
|
|
49
|
+
def self.fixed_outcome(node)
|
|
50
|
+
return :falsy if FALSY_TYPES.include?(node.type)
|
|
51
|
+
|
|
52
|
+
:truthy if truthy_only?(node)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# True for a leaf that can never be falsy.
|
|
56
|
+
def self.truthy_only?(node)
|
|
57
|
+
TRUTHY_TYPES.include?(node.type) || new_call?(node) ||
|
|
58
|
+
truthy_global?(node)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# True for a call to new, eg `Clock.new`.
|
|
62
|
+
def self.new_call?(node)
|
|
63
|
+
node.type == :call_node && node.name == :new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# True for $stdout or $stderr, which can never be falsy.
|
|
67
|
+
def self.truthy_global?(node)
|
|
68
|
+
node.type == :global_variable_read_node &&
|
|
69
|
+
TRUTHY_GLOBALS.include?(node.name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Collects each site and its Condition.
|
|
73
|
+
class ConditionSites < Prism::Visitor
|
|
74
|
+
include ManifestBlocks
|
|
75
|
+
include ManifestCase
|
|
76
|
+
include ManifestChoices
|
|
77
|
+
|
|
78
|
+
# Starts with no sites found.
|
|
79
|
+
def initialize(path)
|
|
80
|
+
@path = path
|
|
81
|
+
@sites = {}
|
|
82
|
+
@notes = nil
|
|
83
|
+
super()
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The sites found so far.
|
|
87
|
+
attr_reader :sites
|
|
88
|
+
|
|
89
|
+
OPERATOR_TYPES = %i[and_node or_node].freeze
|
|
90
|
+
|
|
91
|
+
# Notes an && or and site.
|
|
92
|
+
def visit_and_node(node)
|
|
93
|
+
add_site(node)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Notes an || or or site.
|
|
97
|
+
def visit_or_node(node)
|
|
98
|
+
add_site(node)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Notes a ternary or an if site, a modifier if included,
|
|
102
|
+
# its arms included.
|
|
103
|
+
def visit_if_node(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)
|
|
126
|
+
|
|
127
|
+
add_site(node)
|
|
128
|
+
end
|
|
129
|
+
|
|
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.
|
|
133
|
+
def add_site(node)
|
|
134
|
+
site = Site.at(@path, site_loc(node))
|
|
135
|
+
@sites[site] = nil
|
|
136
|
+
outer = @notes
|
|
137
|
+
@notes = { commands: [], fixed: {} }
|
|
138
|
+
condition = condition_of(node)
|
|
139
|
+
@sites[site] = condition.with(**@notes)
|
|
140
|
+
@notes = outer
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# node as a Condition, operands and all, named by its
|
|
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.
|
|
147
|
+
def condition_of(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
|
|
151
|
+
|
|
152
|
+
Condition.new(
|
|
153
|
+
operator: node.operator, operands: operands_of(node),
|
|
154
|
+
extent: extent_of(node)
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# node's operands: a chain of the same operator flattened
|
|
159
|
+
# into one list, the other operator nested as a Condition,
|
|
160
|
+
# anything else a leaf's byte range.
|
|
161
|
+
def operands_of(node)
|
|
162
|
+
[node.left, node.right].flat_map do |written|
|
|
163
|
+
operand = grouped(written)
|
|
164
|
+
next operands_of(operand) if operand.type == node.type
|
|
165
|
+
next [condition_of(operand)] if operator?(operand)
|
|
166
|
+
|
|
167
|
+
[leaf(operand)]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The byte range of node as written.
|
|
172
|
+
def extent_of(node)
|
|
173
|
+
loc = node.location
|
|
174
|
+
loc.start_offset...loc.end_offset
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# True for an &&, ||, and or or node.
|
|
178
|
+
def operator?(node)
|
|
179
|
+
OPERATOR_TYPES.include?(node.type)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The byte range of a leaf operand, once any site inside
|
|
183
|
+
# it is noted too, and the range noted among its site's
|
|
184
|
+
# commands and fixed leaves where it is one.
|
|
185
|
+
def leaf(operand)
|
|
186
|
+
operand.accept(self)
|
|
187
|
+
loc = operand.location
|
|
188
|
+
range = loc.start_offset...loc.end_offset
|
|
189
|
+
@notes[:commands] << range if Manifest.command?(operand)
|
|
190
|
+
outcome = Manifest.fixed_outcome(operand)
|
|
191
|
+
@notes[:fixed][range] = outcome if outcome
|
|
192
|
+
range
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The one statement inside a parenthesised operand, or the
|
|
196
|
+
# operand itself.
|
|
197
|
+
def grouped(operand)
|
|
198
|
+
return operand unless operand.type == :parentheses_node
|
|
199
|
+
|
|
200
|
+
statements = operand.body&.body || []
|
|
201
|
+
statements.size == 1 ? statements.first : operand
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require_relative 'condition'
|
|
2
|
+
|
|
3
|
+
module PathCoverage
|
|
4
|
+
# The part of the manifest's visitor that builds the sites
|
|
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?.
|
|
9
|
+
module ManifestChoices
|
|
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
|
+
|
|
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
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A ternary, an if or an unless as a Condition.
|
|
36
|
+
def choice_of(node)
|
|
37
|
+
ternary?(node) ? ternary_condition(node) : if_condition(node)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A ternary as a Condition, its extent the whole of it.
|
|
41
|
+
def ternary_condition(node)
|
|
42
|
+
Condition.new(
|
|
43
|
+
operator: '?:', operands: ternary_of(node),
|
|
44
|
+
extent: extent_of(node)
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
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.
|
|
51
|
+
def if_condition(node)
|
|
52
|
+
start = keyword_loc(node).start_offset
|
|
53
|
+
Condition.new(
|
|
54
|
+
operator: unless?(node) ? 'unless' : 'if',
|
|
55
|
+
operands: if_of(node),
|
|
56
|
+
extent: start...node.predicate.location.end_offset
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An if's or an unless's predicate, then its then arm and,
|
|
61
|
+
# when it has one, its else arm.
|
|
62
|
+
def if_of(node)
|
|
63
|
+
rest = unless?(node) ? node.else_clause : node.subsequent
|
|
64
|
+
[
|
|
65
|
+
predicate_of(node),
|
|
66
|
+
statement_arm(:then, node.statements),
|
|
67
|
+
*else_arm(rest)
|
|
68
|
+
]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# An if's arm, statements its code, which never folds into
|
|
72
|
+
# the if's site; any site inside it is a site of its own.
|
|
73
|
+
# An arm with no statements has no extent.
|
|
74
|
+
def statement_arm(kind, statements)
|
|
75
|
+
statements&.accept(self)
|
|
76
|
+
extent = statements && extent_of(statements)
|
|
77
|
+
Arm.new(kind:, extent:, body: nil)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# An if's else arm, from subsequent: an else's statements,
|
|
81
|
+
# or an elsif, which is a site of its own; none for none.
|
|
82
|
+
def else_arm(subsequent)
|
|
83
|
+
return [] if subsequent.nil?
|
|
84
|
+
if subsequent.type == :else_node
|
|
85
|
+
return [statement_arm(:else, subsequent.statements)]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
subsequent.accept(self)
|
|
89
|
+
extent = extent_of(subsequent)
|
|
90
|
+
[Arm.new(kind: :else, extent:, body: nil)]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A ternary's predicate, then its then and else arms.
|
|
94
|
+
def ternary_of(node)
|
|
95
|
+
[
|
|
96
|
+
predicate_of(node),
|
|
97
|
+
arm_of(:then, node.statements),
|
|
98
|
+
arm_of(:else, node.subsequent.statements)
|
|
99
|
+
]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# One arm of a ternary, statements its code: a condition
|
|
103
|
+
# folds in as its body, being part of the one expression;
|
|
104
|
+
# any site inside a plain arm is a site of its own.
|
|
105
|
+
def arm_of(kind, statements)
|
|
106
|
+
written = grouped(statements.body.first)
|
|
107
|
+
body = condition_of(written) if foldable?(written)
|
|
108
|
+
statements.accept(self) if body.nil?
|
|
109
|
+
Arm.new(kind:, extent: extent_of(statements), body:)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# True for a condition that folds into a ternary's site:
|
|
113
|
+
# an &&, ||, and, or, or a ternary.
|
|
114
|
+
def foldable?(node)
|
|
115
|
+
operator?(node) || ternary?(node)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# True for a ternary, an if with no if keyword.
|
|
119
|
+
def ternary?(node)
|
|
120
|
+
node.type == :if_node && node.if_keyword_loc.nil?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A ternary's or an if's predicate as its operand: an && or
|
|
124
|
+
# || nested as a Condition, anything else a leaf.
|
|
125
|
+
def predicate_of(node)
|
|
126
|
+
predicate = node.predicate
|
|
127
|
+
return condition_of(predicate) if operator?(predicate)
|
|
128
|
+
|
|
129
|
+
leaf(predicate)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# Combines the operands of several runs of the same source.
|
|
3
|
+
module Merge
|
|
4
|
+
# One operands hash, as Result#operands gives them, whose
|
|
5
|
+
# counts are the sums of every run's.
|
|
6
|
+
def self.operands(runs)
|
|
7
|
+
runs.reduce do |total, run|
|
|
8
|
+
total.merge(run) do |_site, mine, theirs|
|
|
9
|
+
mine.zip(theirs).map { |a, b| sum(a, b) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# One site paths hash, as Result#paths gives them, each
|
|
15
|
+
# path's count the sum of every run's; runs of the same
|
|
16
|
+
# source list the same paths in the same order.
|
|
17
|
+
def self.paths(runs)
|
|
18
|
+
runs.reduce do |total, run|
|
|
19
|
+
total.merge(run) do |_site, mine, theirs|
|
|
20
|
+
mine.zip(theirs).map do |a, b|
|
|
21
|
+
{ path: a[:path], count: a[:count] + b[:count] }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# One operand's counts from two runs, added; a fixed leaf's
|
|
28
|
+
# only comes from the source, so either run's will do.
|
|
29
|
+
def self.sum(mine, theirs)
|
|
30
|
+
mine.merge(theirs) do |key, a, b|
|
|
31
|
+
key == :only ? a : a + b
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# The terminal worklist for paths: one line per path a method
|
|
3
|
+
# never took, and nothing for a method that took them all.
|
|
4
|
+
module PathGaps
|
|
5
|
+
OUTCOMES = [true, false].freeze
|
|
6
|
+
|
|
7
|
+
# The gap lines of methods, as PathManifest.methods gives
|
|
8
|
+
# them, for the paths the recorder counted, eg
|
|
9
|
+
# "k.rb:1:0 fee path never taken: line 3 true, line 6 true".
|
|
10
|
+
def self.lines(methods, paths)
|
|
11
|
+
methods.flat_map do |site, method|
|
|
12
|
+
taken = paths.fetch(site, {})
|
|
13
|
+
every = possible(method.decisions)
|
|
14
|
+
every.reject { |path| taken.key?(path) }.map do |path|
|
|
15
|
+
gap = "#{site} #{method.name} path never taken"
|
|
16
|
+
"#{gap}: #{steps(method, path)}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Every path through decisions, true before false at each:
|
|
22
|
+
# one taking a decision's leaving outcome ends there.
|
|
23
|
+
def self.possible(decisions)
|
|
24
|
+
return [[]] if decisions.empty?
|
|
25
|
+
|
|
26
|
+
first, *rest = decisions
|
|
27
|
+
OUTCOMES.flat_map do |outcome|
|
|
28
|
+
next [[outcome]] if outcome == first.exits
|
|
29
|
+
|
|
30
|
+
possible(rest).map { |tail| [outcome] + tail }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# path's outcomes, each named by its decision's line.
|
|
35
|
+
def self.steps(method, path)
|
|
36
|
+
path.each_with_index.map do |outcome, index|
|
|
37
|
+
"line #{method.decisions[index].line} #{outcome}"
|
|
38
|
+
end.join(', ')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require 'prism'
|
|
2
|
+
require_relative 'manifest'
|
|
3
|
+
require_relative 'site'
|
|
4
|
+
|
|
5
|
+
module PathCoverage
|
|
6
|
+
# One if a method's path passes: its line, the byte range of
|
|
7
|
+
# its predicate, and whether that predicate is a command call.
|
|
8
|
+
# exits is the outcome whose arm always leaves the method,
|
|
9
|
+
# ending the path there, or nil when neither arm does.
|
|
10
|
+
Decision = Data.define(:line, :range, :command, :exits) do
|
|
11
|
+
# A decision with no arm that leaves by default.
|
|
12
|
+
def initialize(line:, range:, command:, exits: nil)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A method whose calls each take one path through its
|
|
18
|
+
# decisions, in source order. entry is the byte offset of its
|
|
19
|
+
# first decision's statement, where each call's path starts.
|
|
20
|
+
PathMethod = Data.define(:name, :entry, :decisions)
|
|
21
|
+
|
|
22
|
+
# Finds, without running it, the decisions of each method in
|
|
23
|
+
# source whose paths are to be counted.
|
|
24
|
+
module PathManifest
|
|
25
|
+
# { def site => PathMethod } for each def in source.
|
|
26
|
+
def self.methods(source, path)
|
|
27
|
+
collector = PathMethods.new(path)
|
|
28
|
+
Prism.parse(source).value.accept(collector)
|
|
29
|
+
collector.methods
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Collects each def and the ifs at the top level of its body.
|
|
33
|
+
class PathMethods < Prism::Visitor
|
|
34
|
+
# Starts with no methods found.
|
|
35
|
+
def initialize(path)
|
|
36
|
+
@path = path
|
|
37
|
+
@methods = {}
|
|
38
|
+
super()
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The methods found so far.
|
|
42
|
+
attr_reader :methods
|
|
43
|
+
|
|
44
|
+
# Notes node's top-level ifs as its decisions, when it has
|
|
45
|
+
# at least two (with fewer its paths are its branches),
|
|
46
|
+
# then any def inside it, each a method of its own.
|
|
47
|
+
def visit_def_node(node)
|
|
48
|
+
ifs = statements_of(node.body).select do |statement|
|
|
49
|
+
statement.type == :if_node
|
|
50
|
+
end
|
|
51
|
+
note(node, ifs) if ifs.size >= 2
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Notes node as a method whose decisions are ifs.
|
|
56
|
+
def note(node, ifs)
|
|
57
|
+
@methods[Site.of(@path, node)] = PathMethod.new(
|
|
58
|
+
name: node.name,
|
|
59
|
+
entry: ifs.first.location.start_offset,
|
|
60
|
+
decisions: ifs.map { |statement| decision(statement) }
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The top-level statements of a def's body: those before
|
|
65
|
+
# any rescue, and none for an empty or endless def.
|
|
66
|
+
def statements_of(body)
|
|
67
|
+
body = body.statements if body&.type == :begin_node
|
|
68
|
+
body&.type == :statements_node ? body.body : []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The Decision of an if statement.
|
|
72
|
+
def decision(statement)
|
|
73
|
+
predicate = statement.predicate
|
|
74
|
+
loc = predicate.location
|
|
75
|
+
Decision.new(
|
|
76
|
+
line: loc.start_line,
|
|
77
|
+
range: loc.start_offset...loc.end_offset,
|
|
78
|
+
command: Manifest.command?(predicate) || false,
|
|
79
|
+
exits: exits(statement)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
LEAVING_CALLS = %i[fail raise].freeze
|
|
84
|
+
|
|
85
|
+
# The outcome of an if statement whose arm always leaves
|
|
86
|
+
# the method: true for its then arm, false for its else
|
|
87
|
+
# arm, nil for neither.
|
|
88
|
+
def exits(statement)
|
|
89
|
+
return true if leaves?(statement.statements)
|
|
90
|
+
|
|
91
|
+
otherwise = statement.subsequent
|
|
92
|
+
false if otherwise&.type == :else_node &&
|
|
93
|
+
leaves?(otherwise.statements)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# True when the last of statements is a return, or a
|
|
97
|
+
# fail or raise of the method's own; false for an empty
|
|
98
|
+
# arm, which has no statements.
|
|
99
|
+
def leaves?(statements)
|
|
100
|
+
return false if statements.nil?
|
|
101
|
+
|
|
102
|
+
last = statements.body.last
|
|
103
|
+
return true if last.type == :return_node
|
|
104
|
+
|
|
105
|
+
last.type == :call_node && last.receiver.nil? &&
|
|
106
|
+
LEAVING_CALLS.include?(last.name)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# What the reports say about each site's paths beyond their
|
|
3
|
+
# counts, worked out from each covered file's source: where
|
|
4
|
+
# each leaf is, and what it says.
|
|
5
|
+
module PathsReport
|
|
6
|
+
# { site => { leaves:, taken: } }: taken is each path and
|
|
7
|
+
# its count, as Result#paths gives them, and leaves each
|
|
8
|
+
# leaf's ["line:column", "line:column"] span, so a tool can
|
|
9
|
+
# recover each leaf exactly. files is { full path =>
|
|
10
|
+
# {source:, sites:} }.
|
|
11
|
+
def self.of(site_paths, files)
|
|
12
|
+
spans = leaf_spans(files)
|
|
13
|
+
site_paths.to_h do |site, taken|
|
|
14
|
+
[site, { leaves: spans.fetch(site), taken: }]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# { site => [each leaf's source text] } for every site in
|
|
19
|
+
# files.
|
|
20
|
+
def self.texts(files)
|
|
21
|
+
each_site(files) do |source, condition|
|
|
22
|
+
condition.leaves.map { |range| source.byteslice(range) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# { site => [each leaf's span] } for every site in files.
|
|
27
|
+
def self.leaf_spans(files)
|
|
28
|
+
each_site(files) do |source, condition|
|
|
29
|
+
condition.leaves.map do |range|
|
|
30
|
+
[at(source, range.begin), at(source, range.end)]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# { site => what the block makes of its file's source and
|
|
36
|
+
# its condition } for every site in files.
|
|
37
|
+
def self.each_site(files)
|
|
38
|
+
files.values.each_with_object({}) do |parsed, all|
|
|
39
|
+
parsed[:sites].each do |site, condition|
|
|
40
|
+
all[site] = yield(parsed[:source], condition)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# "line:column" of a byte offset in source, the line from
|
|
46
|
+
# 1 and the column, in characters, from 0, as site names
|
|
47
|
+
# count them.
|
|
48
|
+
def self.at(source, offset)
|
|
49
|
+
before = source.byteslice(0, offset)
|
|
50
|
+
line = before.count("\n") + 1
|
|
51
|
+
start = before.rindex("\n")
|
|
52
|
+
"#{line}:#{before.length - (start ? start + 1 : 0)}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|