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