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,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