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,138 @@
1
+ require 'cgi/escape'
2
+ require 'prism'
3
+ require_relative 'path_gaps'
4
+
5
+ module PathCoverage
6
+ # SPIKE: the path grids of one covered file's page, hidden in
7
+ # the page until side.js moves them into the rail. Each grid
8
+ # has a row per decision, to sit level with that decision's
9
+ # line, and a column per possible path, never-taken first.
10
+ module HtmlPaths
11
+ # The hidden grids of every method in methods.
12
+ def self.grids(source, methods, paths)
13
+ ends = end_lines(source)
14
+ arms = arm_lines(source)
15
+ methods.map do |site, method|
16
+ grid(site, method, paths.fetch(site, {}), ends, arms)
17
+ end.join("\n")
18
+ end
19
+
20
+ # { an if's first line => its parts' lines } for every if:
21
+ # head, its if and predicate; then, its then arm; else, its
22
+ # else (or elsif) keyword through its arm; end, its end line.
23
+ # Each is [first, last], nil when absent; a modifier if or
24
+ # ternary is all head.
25
+ def self.arm_lines(source)
26
+ arms = {}
27
+ walk = lambda do |node|
28
+ arms[node.location.start_line] = if_parts(node) if node.type == :if_node
29
+ node.compact_child_nodes.each { |child| walk.call(child) }
30
+ end
31
+ walk.call(Prism.parse(source).value)
32
+ arms
33
+ end
34
+
35
+ # The lines of an if's parts, as arm_lines gives them.
36
+ def self.if_parts(node)
37
+ start = node.location.start_line
38
+ return { head: [start, start] } if node.end_keyword_loc.nil?
39
+
40
+ last = node.end_keyword_loc.start_line
41
+ then_arm = span(node.statements)
42
+ else_part = else_lines(node.subsequent, last)
43
+ body = [then_arm&.first, else_part&.first, last].compact.min
44
+ {
45
+ head: [start, [start, body - 1].max], then: then_arm,
46
+ else: else_part, end: [last, last]
47
+ }
48
+ end
49
+
50
+ # The lines from an else or elsif keyword to the end of its
51
+ # arm, the line before the if's end for an elsif.
52
+ def self.else_lines(otherwise, last)
53
+ return nil if otherwise.nil?
54
+ return [otherwise.location.start_line, last - 1] if otherwise.type == :if_node
55
+
56
+ first = otherwise.else_keyword_loc.start_line
57
+ [first, otherwise.statements ? otherwise.statements.location.end_line : first]
58
+ end
59
+
60
+ # The first and last lines of statements, nil for none.
61
+ def self.span(statements)
62
+ return nil if statements.nil?
63
+
64
+ loc = statements.location
65
+ [loc.start_line, loc.end_line]
66
+ end
67
+
68
+ # { def's first line => its last line } for every def.
69
+ def self.end_lines(source)
70
+ ends = {}
71
+ walk = lambda do |node|
72
+ if node.type == :def_node
73
+ loc = node.location
74
+ ends[loc.start_line] = loc.end_line
75
+ end
76
+ node.compact_child_nodes.each { |child| walk.call(child) }
77
+ end
78
+ walk.call(Prism.parse(source).value)
79
+ ends
80
+ end
81
+
82
+ # One method's grid: its calls row, level with its def, then
83
+ # a row per decision, level with that decision's line.
84
+ def self.grid(site, method, taken, ends, arms)
85
+ first = site.split(':')[-2].to_i
86
+ every = PathGaps.possible(method.decisions)
87
+ ordered = every.each_with_index.sort_by do |path, i|
88
+ [taken.key?(path) ? 1 : 0, i]
89
+ end.map(&:first)
90
+ missing = every.count { |path| !taken.key?(path) }
91
+ status = missing.zero? ? 'covered' : 'gaps'
92
+ rows = [calls_row(first, ordered, taken, method, every.size - missing)]
93
+ method.decisions.each_with_index do |decision, i|
94
+ rows << decision_row(decision, i, ordered, taken, arms)
95
+ end
96
+ attrs = [
97
+ %(id="paths-#{first}" data-first="#{first}"),
98
+ %(data-col="#{site.split(':').last}"),
99
+ %(data-last="#{ends.fetch(first, first)}" data-status="#{status}")
100
+ ].join(' ')
101
+ %(<div class="pathgrid" #{attrs} hidden="hidden">#{rows.join}</div>)
102
+ end
103
+
104
+ # The row level with the def: how many calls took each path,
105
+ # never-taken ones marked.
106
+ def self.calls_row(first, ordered, taken, method, seen)
107
+ cells = ordered.map do |path|
108
+ calls = taken.fetch(path, 0)
109
+ css = calls.zero? ? 'pcell never' : 'pcell taken'
110
+ %(<span class="#{css}">#{calls.zero? ? '0' : calls}</span>)
111
+ end
112
+ name = CGI.escapeHTML(method.name.to_s)
113
+ label = %(<span class="plabel">#{seen}/#{ordered.size}</span>)
114
+ title = %( title="#{name}: #{seen} of #{ordered.size} paths taken")
115
+ %(<div class="prow calls" data-line="#{first}"#{title}>#{label}#{cells.join}</div>)
116
+ end
117
+
118
+ # The row level with a decision: T or F for each path that
119
+ # makes it, those of never-taken paths marked, blank for a
120
+ # path that ended before it.
121
+ def self.decision_row(decision, index, ordered, taken, arms)
122
+ cells = ordered.map do |path|
123
+ outcome = path[index]
124
+ next '<span class="pcell ended"></span>' if outcome.nil?
125
+
126
+ never = taken.key?(path) ? '' : ' never'
127
+ %(<span class="pcell #{outcome}#{never}">#{outcome ? 'T' : 'F'}</span>)
128
+ end
129
+ label = %(<span class="plabel">#{decision.line}</span>)
130
+ arm = arms.fetch(decision.line, {})
131
+ parts = %i[head then else end].map do |part|
132
+ %(data-#{part}="#{arm[part]&.join(',')}")
133
+ end
134
+ data = [%(data-line="#{decision.line}"), *parts].join(' ')
135
+ %(<div class="prow" #{data}>#{label}#{cells.join}</div>)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,118 @@
1
+ require 'fileutils'
2
+ require_relative 'html_file'
3
+ require_relative 'html_index'
4
+ require_relative 'html_paths'
5
+ require_relative 'path_manifest'
6
+ require_relative 'site_tree'
7
+ require_relative 'source_lines'
8
+ require_relative 'summary'
9
+
10
+ module PathCoverage
11
+ # The :html reporter: index.html, and files/<path>.html for
12
+ # each covered file, written to the configuration's
13
+ # output_dir.
14
+ module HtmlReport
15
+ # Writes every page. operands and site_paths are as Result
16
+ # gives them, site_paths holding every site in files; files
17
+ # is { full path => {source:, sites:} } for each covered
18
+ # file, as the kernel parsed it.
19
+ def self.write(operands, files, config, paths = nil, site_paths:)
20
+ dir = config.output_dir
21
+ index = index(files, config, paths, site_paths)
22
+ File.write("#{dir}/index.html", index)
23
+ files.each do |full_path, parsed|
24
+ path = config.relative(full_path)
25
+ out = "#{dir}/files/#{path}.html"
26
+ # simplecov:disable
27
+ grids = paths ? path_grids(path, parsed, paths) : ''
28
+ # simplecov:enable
29
+ write_page(out, path, parsed, [operands, site_paths], grids)
30
+ end
31
+ end
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
+
47
+ # simplecov:disable
48
+ # SPIKE: the hidden path grids of one file's methods.
49
+ def self.path_grids(path, parsed, paths)
50
+ source = parsed[:source]
51
+ methods = PathManifest.methods(source, path)
52
+ HtmlPaths.grids(source, methods, paths)
53
+ end
54
+ # simplecov:enable
55
+
56
+ # Writes one file's page to out, from its sites' operand
57
+ # counts and paths.
58
+ def self.write_page(out, path, parsed,
59
+ (operands, site_paths), grids)
60
+ source, sites = parsed.values_at(:source, :sites)
61
+ lines = SourceLines.of(source, sites, operands)
62
+ trees = trees(source, sites, lines, site_paths)
63
+ FileUtils.mkdir_p(File.dirname(out))
64
+ File.write(out, HtmlFile.page(path, lines, trees, grids))
65
+ end
66
+
67
+ # { first line of a span => [SiteTree.of, ...] } for the
68
+ # sites of one file, each under the span its line is in.
69
+ def self.trees(source, sites, lines, site_paths)
70
+ trees = Hash.new { |all, first| all[first] = [] }
71
+ sites.each do |site, condition|
72
+ first = lines[SourceLines.line_of(site) - 1][:span].begin
73
+ tree = SiteTree.of(source, condition, site_paths.fetch(site))
74
+ trees[first] << located(source, condition, tree)
75
+ end
76
+ trees
77
+ end
78
+
79
+ # simplecov:disable
80
+ # SPIKE: tree with an at: of [line, column, line, column]
81
+ # on it and on each of its operands: a leaf's from its byte
82
+ # range, a condition's and an arm's from its extent, the
83
+ # whole of it as written. Columns count characters, not
84
+ # bytes.
85
+ def self.located(source, condition, tree)
86
+ pairs = condition.operands.zip(tree[:operands])
87
+ operands = pairs.map do |operand, node|
88
+ case operand
89
+ when Condition then located(source, operand, node)
90
+ when Arm then located_arm(source, operand, node)
91
+ else node.merge(at: at(source, operand))
92
+ end
93
+ end
94
+ tree.merge(at: at(source, condition.extent), operands:)
95
+ end
96
+
97
+ # An arm's node with its at:, nil for an arm with no
98
+ # statements, and its body's tree located.
99
+ def self.located_arm(source, arm, node)
100
+ body = arm.body && located(source, arm.body, node[:body])
101
+ node.merge(at: arm.extent && at(source, arm.extent), body:)
102
+ end
103
+
104
+ # [line, column, line, column] of the byte range in source.
105
+ def self.at(source, range)
106
+ [*position(source, range.begin), *position(source, range.end)]
107
+ end
108
+
109
+ # [line, column] of a byte offset in source.
110
+ def self.position(source, offset)
111
+ before = source.byteslice(0, offset)
112
+ newline = before.rindex("\n")
113
+ column = before.length - (newline ? newline + 1 : 0)
114
+ [before.count("\n") + 1, column]
115
+ end
116
+ # simplecov:enable
117
+ end
118
+ end
@@ -0,0 +1,20 @@
1
+ module PathCoverage
2
+ # The HTML report's page templates, styles and scripts, kept
3
+ # as files beside this one, and the filling of their slots.
4
+ module HtmlTemplate
5
+ DIR = File.join(__dir__, 'html')
6
+
7
+ # The text of one of the report's template, style or script
8
+ # files.
9
+ def self.asset(name)
10
+ File.read(File.join(DIR, name))
11
+ end
12
+
13
+ # template with each {{name}} replaced by values[name].
14
+ def self.fill(template, values)
15
+ template.gsub(/\{\{(\w+)\}\}/) do
16
+ values.fetch(Regexp.last_match(1))
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,146 @@
1
+ require 'cgi/escape'
2
+ require_relative 'condition'
3
+
4
+ module PathCoverage
5
+ # The tree of a span's sites on a file's page, from SiteTree.of
6
+ # for each site: a grid whose columns are a site's paths.
7
+ module HtmlTree
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
+
17
+ # A bullet marks a path taking an arm.
18
+ TAKEN = { true => [0x2022].pack('U'), false => '' }.freeze
19
+
20
+ # The tree of a span, open when its sites have gaps, else
21
+ # hidden: each site's rows in a group of their own.
22
+ def self.of(number, site_trees, open)
23
+ groups = site_trees.map do |site|
24
+ rows = site_rows(site).join
25
+ %(<div class="site" data-site="">#{rows}</div>)
26
+ end
27
+ hidden = open ? '' : ' hidden="hidden"'
28
+ body = groups.join
29
+ %(<div class="tree" id="tree-#{number}"#{hidden}>#{body}</div>)
30
+ end
31
+
32
+ # A site's row, naming its operator and each path's count,
33
+ # then the rows of its operands.
34
+ def self.site_rows(site)
35
+ counts = count_cells(site[:counts])
36
+ [row(at(site), operator(site), counts), *operand_rows(site, '')]
37
+ end
38
+
39
+ # One cell per path's count, a path never taken marked as a
40
+ # gap.
41
+ def self.count_cells(counts)
42
+ counts.map do |count|
43
+ css = count.zero? ? 'count never' : 'count'
44
+ %(<span class="#{css}" data-count="">#{count}</span>)
45
+ end.join
46
+ end
47
+
48
+ # One cell per path, naming each with names.
49
+ def self.cells(values, names = OUTCOMES)
50
+ values.map do |value|
51
+ %(<span class="cell" data-cell="">#{names[value]}</span>)
52
+ end.join
53
+ end
54
+
55
+ # SPIKE: the data-at attribute of a node located in its
56
+ # source, '' for one that is not.
57
+ def self.at(node)
58
+ node[:at] ? %( data-at="#{node[:at].join(',')}") : ''
59
+ end
60
+
61
+ # The rows of a condition's operands, each branching from
62
+ # its operator after indent; a nested condition's operands,
63
+ # and those of an arm's condition, follow its own row,
64
+ # indented under its branch.
65
+ def self.operand_rows(condition, indent)
66
+ last = condition[:operands].size - 1
67
+ condition[:operands].each_with_index.flat_map do |operand, i|
68
+ tip = i == last
69
+ branch = indent + (tip ? '└─ ' : '├─ ')
70
+ under = indent + (tip ? ' ' : '│ ')
71
+ node_rows(operand, branch, under, condition[:operator])
72
+ end
73
+ end
74
+
75
+ # The rows of one operand of a condition whose operator is
76
+ # choice, branching after branch, any operands of its own
77
+ # indented to under.
78
+ def self.node_rows(operand, branch, under, choice)
79
+ if operand.key?(:arm)
80
+ return arm_rows(operand, branch, under, choice)
81
+ end
82
+ return [operand_row(operand, branch)] unless operand[:operator]
83
+
84
+ [subtree_row(operand, branch), *operand_rows(operand, under)]
85
+ end
86
+
87
+ # An arm's row: a ternary's, ? for then and : for else, an
88
+ # arm holding a condition showing the condition's operator
89
+ # and value, its operands below, a plain arm its text; an
90
+ # if's named then or else, as its text is statements. A
91
+ # plain arm marks the paths taking it.
92
+ def self.arm_rows(arm, branch, under, choice)
93
+ body = arm[:body]
94
+ values = cells((body || arm)[:cells], body ? OUTCOMES : TAKEN)
95
+ text = CGI.escapeHTML(arm_label(arm, choice))
96
+ lead = branch_cell(branch) +
97
+ %(<span class="arm" data-label="">#{text}</span>)
98
+ row = row(%( data-arm=""#{at(arm)}), lead, values)
99
+ body ? [row, *operand_rows(body, under)] : [row]
100
+ end
101
+
102
+ # The name of an arm of a condition whose operator is choice.
103
+ def self.arm_label(arm, choice)
104
+ return arm[:arm].to_s if NAMED_ARMS.include?(choice)
105
+
106
+ mark = arm[:arm] == :then ? '?' : ':'
107
+ body = arm[:body]
108
+ "#{mark} #{body ? body[:operator] : arm[:text]}"
109
+ end
110
+
111
+ # A nested condition's row: its branch, its operator, and
112
+ # its value on each path.
113
+ def self.subtree_row(condition, branch)
114
+ lead = branch_cell(branch) + operator(condition)
115
+ attrs = %( data-subtree=""#{at(condition)})
116
+ row(attrs, lead, cells(condition[:cells]))
117
+ end
118
+
119
+ # A row of the grid, with attrs, lead's cells naming it,
120
+ # then values, its cells for each path.
121
+ def self.row(attrs, lead, values)
122
+ %(<div class="row"#{attrs}>#{lead}#{values}</div>)
123
+ end
124
+
125
+ # The cell naming a condition's operator.
126
+ def self.operator(condition)
127
+ name = CGI.escapeHTML(condition[:operator])
128
+ %(<span class="operator" data-operator="">#{name}</span>)
129
+ end
130
+
131
+ # One operand's row: its branch, text, and its outcome on
132
+ # each path.
133
+ def self.operand_row(operand, branch)
134
+ text = CGI.escapeHTML(operand[:text].to_s)
135
+ lead = branch_cell(branch) +
136
+ %(<span class="text" data-text="">#{text}</span>)
137
+ attrs = %( data-operand=""#{at(operand)})
138
+ row(attrs, lead, cells(operand[:cells]))
139
+ end
140
+
141
+ # The cell drawing a row's branch from its operator.
142
+ def self.branch_cell(branch)
143
+ %(<span class="branch" data-branch="">#{branch}</span>)
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,25 @@
1
+ require 'json'
2
+
3
+ module PathCoverage
4
+ # The base reporter: the result as raw JSON, which every
5
+ # other reporter can read back.
6
+ module JsonWriter
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:))
14
+ end
15
+
16
+ # operands, and any paths and totals, as the JSON report's
17
+ # text.
18
+ def self.json(operands, paths = {}, totals: {})
19
+ report = { 'sites' => operands }
20
+ report['paths'] = paths unless paths.empty?
21
+ report['totals'] = totals unless totals.empty?
22
+ JSON.pretty_generate(report)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,165 @@
1
+ require 'fileutils'
2
+ require_relative 'calls'
3
+ require_relative 'configuration'
4
+ require_relative 'json_writer'
5
+ require_relative 'manifest'
6
+ require_relative 'path_gaps'
7
+ require_relative 'path_manifest'
8
+ require_relative 'paths_report'
9
+ require_relative 'recorder'
10
+ require_relative 'render'
11
+ require_relative 'result'
12
+ require_relative 'rewriter'
13
+ require_relative 'totals'
14
+
15
+ # Path coverage for Ruby. This file is the kernel: it loads
16
+ # before any covered file and is never itself instrumented, since
17
+ # an instrumented recorder would record itself.
18
+ module PathCoverage
19
+ @recorder = Recorder.new
20
+ @config = nil
21
+ # { full path => {source:, sites:} } of each covered file
22
+ # parsed as it loaded, so the exit report need not read it
23
+ # again.
24
+ @parsed = {}
25
+
26
+ # The one recorder that every instrumented operand reports to.
27
+ def self.recorder
28
+ @recorder
29
+ end
30
+
31
+ # The configuration of the latest start, which the load
32
+ # hook consults; nil before the first start.
33
+ def self.config
34
+ @config
35
+ end
36
+
37
+ # Instruments every file later required that the settings
38
+ # block covers, and reports when the process exits. on_exit
39
+ # is Kernel#at_exit except in tests, which must not print
40
+ # a report as their own process exits.
41
+ def self.start(on_exit: method(:at_exit), &)
42
+ config = Configuration.new(&)
43
+ install_hook if @config.nil?
44
+ @config = config
45
+ on_exit.call { report(config) }
46
+ end
47
+
48
+ # Starts from settings in env alone, for a run whose code
49
+ # cannot be changed, eg one started by RUBYOPT=-r: covers
50
+ # the comma-separated PATH_COVERAGE_COVER globs under
51
+ # PATH_COVERAGE_ROOT, writing the JSON report into
52
+ # PATH_COVERAGE_DIR and POSTing it to
53
+ # PATH_COVERAGE_COLLECTOR. Without a collector it
54
+ # starts nothing, so RUBYOPT can be set for every run.
55
+ def self.start_from_env(env, on_exit: method(:at_exit))
56
+ return unless env.key?('PATH_COVERAGE_COLLECTOR')
57
+
58
+ start(on_exit:) do
59
+ root env.fetch('PATH_COVERAGE_ROOT')
60
+ env.fetch('PATH_COVERAGE_COVER').split(',').each do |glob|
61
+ cover glob
62
+ end
63
+ coverage_dir env.fetch('PATH_COVERAGE_DIR')
64
+ collector env.fetch('PATH_COVERAGE_COLLECTOR')
65
+ report :post
66
+ end
67
+ end
68
+
69
+ # Routes every later load through instrumented, under
70
+ # whichever configuration is current. Installed once: a
71
+ # second definition would warn that it was redefined.
72
+ def self.install_hook
73
+ iseq = RubyVM::InstructionSequence
74
+ iseq.singleton_class.define_method(:load_iseq) do |path|
75
+ PathCoverage.instrumented(path, PathCoverage.config)
76
+ end
77
+ end
78
+
79
+ # The compiled, rewritten code of path, or nil for a path
80
+ # config does not cover, which Ruby then loads as normal.
81
+ def self.instrumented(path, config)
82
+ return unless config.covers?(path)
83
+
84
+ parsed = @parsed[path] = Manifest.parse(path, config)
85
+ methods = path_methods(path, parsed[:source], config)
86
+ sites = parsed[:sites]
87
+ rewritten = Rewriter.rewrite(parsed[:source], sites, methods)
88
+ RubyVM::InstructionSequence.compile(rewritten, path, path)
89
+ end
90
+
91
+ # The methods of the covered file at path whose paths are
92
+ # counted, none unless config measures methods.
93
+ def self.path_methods(path, source, config)
94
+ return {} unless config.measures.include?(:methods)
95
+
96
+ PathManifest.methods(source, config.relative(path))
97
+ end
98
+
99
+ # Writes the JSON report, then runs the chosen reporters, for
100
+ # every covered file whether or not the run loaded it, into
101
+ # coverage_dir, created first if need be. The paths never
102
+ # taken follow the condition gaps.
103
+ def self.report(config)
104
+ files = parsed_files(config)
105
+ result = collect(files, config)
106
+ methods = config.measures.include?(:methods)
107
+ paths = methods ? @recorder.paths : nil
108
+ Render.run(
109
+ result.operands, files, config, paths, site_paths: result.paths
110
+ )
111
+ return unless config.reporters.include?(:gaps)
112
+
113
+ methods = path_methods_of(files, config)
114
+ PathGaps.lines(methods, @recorder.paths).each { |line| puts line }
115
+ end
116
+
117
+ # The methods whose paths are counted of every covered file,
118
+ # in one manifest.
119
+ def self.path_methods_of(files, config)
120
+ all = files.map do |path, parsed|
121
+ path_methods(path, parsed[:source], config)
122
+ end
123
+ all.reduce({}, :merge)
124
+ end
125
+
126
+ # The Result of every covered file, with the counts and site
127
+ # paths this run recorded, once its operands and paths are
128
+ # written as the JSON report.
129
+ def self.collect(files, config)
130
+ sites = merged_sites(files)
131
+ result = Result.new(sites, @recorder.counts, @recorder.site_paths)
132
+ FileUtils.mkdir_p(config.output_dir)
133
+ report = PathsReport.of(result.paths, files)
134
+ totals = totals(result, files, config)
135
+ path = config.json_path
136
+ JsonWriter.write(result.operands, path, report, totals:)
137
+ result
138
+ end
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
+
152
+ # { full path => {source:, sites:} } for every covered file:
153
+ # as parsed when it loaded, or parsed now for a file the run
154
+ # never loaded.
155
+ def self.parsed_files(config)
156
+ config.files.to_h do |path|
157
+ [path, @parsed.fetch(path) { Manifest.parse(path, config) }]
158
+ end
159
+ end
160
+
161
+ # The sites of every covered file, in one manifest.
162
+ def self.merged_sites(files)
163
+ files.values.map { |parsed| parsed[:sites] }.reduce({}, :merge)
164
+ end
165
+ end