condition_coverage 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +117 -40
- data/lib/condition_coverage/condition.rb +20 -0
- data/lib/condition_coverage/configuration.rb +98 -0
- data/lib/condition_coverage/gaps.rb +42 -0
- data/lib/condition_coverage/html/file.html +27 -0
- data/lib/condition_coverage/html/index.html +38 -0
- data/lib/condition_coverage/html/sort.js +28 -0
- data/lib/condition_coverage/html/style.css +106 -0
- data/lib/condition_coverage/html/toggle.js +12 -0
- data/lib/condition_coverage/html_file.rb +159 -0
- data/lib/condition_coverage/html_index.rb +43 -0
- data/lib/condition_coverage/html_report.rb +46 -0
- data/lib/condition_coverage/html_template.rb +20 -0
- data/lib/condition_coverage/json_writer.rb +13 -0
- data/lib/condition_coverage/kernel.rb +111 -0
- data/lib/condition_coverage/manifest.rb +107 -0
- data/lib/condition_coverage/recorder.rb +24 -0
- data/lib/condition_coverage/result.rb +31 -0
- data/lib/condition_coverage/rewriter.rb +39 -0
- data/lib/condition_coverage/site.rb +11 -0
- data/lib/condition_coverage/site_tree.rb +39 -0
- data/lib/condition_coverage/source_lines.rb +81 -0
- data/lib/condition_coverage/summary.rb +32 -0
- data/lib/condition_coverage/version.rb +3 -0
- data/lib/condition_coverage.rb +2 -3
- metadata +31 -10
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
require 'cgi/escape'
|
|
2
|
+
require_relative 'html_template'
|
|
3
|
+
|
|
4
|
+
module ConditionCoverage
|
|
5
|
+
# One covered file's HTML page: its source, with the numbers
|
|
6
|
+
# of the lines holding a condition as the one button that
|
|
7
|
+
# opens the condition's tree.
|
|
8
|
+
module HtmlFile
|
|
9
|
+
# The whole page for the file at path (relative to root),
|
|
10
|
+
# written at files/<path>.html, so it links back to the
|
|
11
|
+
# index one folder up for each folder in that.
|
|
12
|
+
def self.page(path, lines, trees)
|
|
13
|
+
HtmlTemplate.fill(
|
|
14
|
+
HtmlTemplate.asset('file.html'),
|
|
15
|
+
'path' => CGI.escapeHTML(path),
|
|
16
|
+
'index' => "#{'../' * (path.count('/') + 1)}index.html",
|
|
17
|
+
'style' => HtmlTemplate.asset('style.css'),
|
|
18
|
+
'script' => HtmlTemplate.asset('toggle.js'),
|
|
19
|
+
'listing' => listing(lines, trees)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The source listing of lines, as SourceLines.of gives them:
|
|
24
|
+
# each run of lines sharing a span is one block, followed
|
|
25
|
+
# by its hidden tree. trees is { span's first line =>
|
|
26
|
+
# [SiteTree.of, ...] }.
|
|
27
|
+
def self.listing(lines, trees = {})
|
|
28
|
+
blocks = lines.chunk_while do |line, following|
|
|
29
|
+
line[:span] && line[:span] == following[:span]
|
|
30
|
+
end
|
|
31
|
+
body = blocks.map { |block| block(block, trees) }.join("\n")
|
|
32
|
+
%(<div class="code">\n#{body}\n</div>)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A plain line, or the lines of one span behind one button
|
|
36
|
+
# with the span's tree straight after them.
|
|
37
|
+
def self.block(lines, trees)
|
|
38
|
+
first = lines[0]
|
|
39
|
+
return plain_line(first) if first[:span].nil?
|
|
40
|
+
|
|
41
|
+
number = first[:number]
|
|
42
|
+
site_trees = trees.fetch(number, [])
|
|
43
|
+
span_block(lines, site_trees.size) + tree(number, site_trees)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The lines of one span: one button over all their numbers,
|
|
47
|
+
# and how many sites it opens when that is two or more,
|
|
48
|
+
# beside their code.
|
|
49
|
+
def self.span_block(lines, sites)
|
|
50
|
+
numbers = lines.map { |line| number(line[:number]) }.join
|
|
51
|
+
codes = lines.map { |line| code(line) }.join
|
|
52
|
+
srcs = %(<div class="srcs">#{codes}</div>)
|
|
53
|
+
content = site_count(sites) + numbers
|
|
54
|
+
%(<div class="block">#{button(lines[0], content)}#{srcs}</div>)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The count of a button's sites, shown only for two or more.
|
|
58
|
+
def self.site_count(sites)
|
|
59
|
+
return '' if sites < 2
|
|
60
|
+
|
|
61
|
+
%(<span class="site-count" data-site-count="">#{sites}</span>)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
HEADINGS = %w[condition true false gap].freeze
|
|
65
|
+
|
|
66
|
+
# The hidden tree of a span: a heading row, then each site's
|
|
67
|
+
# rows in a group of their own.
|
|
68
|
+
def self.tree(number, site_trees)
|
|
69
|
+
groups = site_trees.map do |site|
|
|
70
|
+
rows = site_rows(site).join
|
|
71
|
+
%(<div class="site" data-site="">#{rows}</div>)
|
|
72
|
+
end
|
|
73
|
+
id = "tree-#{number}"
|
|
74
|
+
body = [heading_row, *groups].join
|
|
75
|
+
%(<div class="tree" id="#{id}" hidden="hidden">#{body}</div>)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The row naming each column of a tree.
|
|
79
|
+
def self.heading_row
|
|
80
|
+
cells = HEADINGS.map { |name| %(<span>#{name}</span>) }.join
|
|
81
|
+
%(<div class="row headings" data-heading="">#{cells}</div>)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A site's operator row, then the rows of its operands.
|
|
85
|
+
def self.site_rows(site)
|
|
86
|
+
row = %(<div class="row">#{operator(site)}</div>)
|
|
87
|
+
[row, *operand_rows(site, '')]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The rows of a condition's operands, each branching from
|
|
91
|
+
# its operator after indent; a nested condition's operands
|
|
92
|
+
# follow its own row, indented under its branch.
|
|
93
|
+
def self.operand_rows(condition, indent)
|
|
94
|
+
last = condition[:operands].size - 1
|
|
95
|
+
condition[:operands].each_with_index.flat_map do |operand, i|
|
|
96
|
+
tip = i == last
|
|
97
|
+
branch = indent + (tip ? '└─ ' : '├─ ')
|
|
98
|
+
next [operand_row(operand, branch)] unless operand[:operator]
|
|
99
|
+
|
|
100
|
+
nested = operand_rows(operand, indent + (tip ? ' ' : '│ '))
|
|
101
|
+
[subtree_row(operand, branch), *nested]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A nested condition's row: its branch and its operator.
|
|
106
|
+
def self.subtree_row(condition, branch)
|
|
107
|
+
cells = branch_cell(branch) + operator(condition)
|
|
108
|
+
%(<div class="row" data-subtree="">#{cells}</div>)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The cell naming a condition's operator.
|
|
112
|
+
def self.operator(condition)
|
|
113
|
+
name = CGI.escapeHTML(condition[:operator])
|
|
114
|
+
%(<span class="operator" data-operator="">#{name}</span>)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# One operand's row: its branch, text, true and false
|
|
118
|
+
# counts, and gap.
|
|
119
|
+
def self.operand_row(operand, branch)
|
|
120
|
+
cells = %i[text truthy falsy gap].map do |name|
|
|
121
|
+
value = CGI.escapeHTML(operand[name].to_s)
|
|
122
|
+
%(<span class="#{name}" data-#{name}="">#{value}</span>)
|
|
123
|
+
end
|
|
124
|
+
lead = branch_cell(branch)
|
|
125
|
+
%(<div class="row" data-operand="">#{lead}#{cells.join}</div>)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The cell drawing a row's branch from its operator.
|
|
129
|
+
def self.branch_cell(branch)
|
|
130
|
+
%(<span class="branch" data-branch="">#{branch}</span>)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A line with no condition: its number, then its code.
|
|
134
|
+
def self.plain_line(line)
|
|
135
|
+
%(<div class="line">#{number(line[:number])}#{code(line)}</div>)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The closed button over a span's numbers, opening the tree
|
|
139
|
+
# named after its first line.
|
|
140
|
+
def self.button(first, numbers)
|
|
141
|
+
[
|
|
142
|
+
%(<button type="button" class="lnum"),
|
|
143
|
+
%( data-status="#{first[:status]}" aria-expanded="false"),
|
|
144
|
+
%( aria-controls="tree-#{first[:number]}">#{numbers}</button>)
|
|
145
|
+
].join
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# One line's number.
|
|
149
|
+
def self.number(number)
|
|
150
|
+
%(<span class="lineno" data-line="#{number}">#{number}</span>)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# One line's code, escaped.
|
|
154
|
+
def self.code(line)
|
|
155
|
+
text = CGI.escapeHTML(line[:text])
|
|
156
|
+
%(<span class="src" data-src="">#{text}</span>)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require 'cgi/escape'
|
|
2
|
+
require_relative 'html_template'
|
|
3
|
+
|
|
4
|
+
module ConditionCoverage
|
|
5
|
+
# The HTML index page: one sortable row per covered file,
|
|
6
|
+
# each linking to that file's own page.
|
|
7
|
+
module HtmlIndex
|
|
8
|
+
# The whole index page: the files table, sortable by any
|
|
9
|
+
# column, with one row per Summary.files row.
|
|
10
|
+
def self.page(rows)
|
|
11
|
+
HtmlTemplate.fill(
|
|
12
|
+
HtmlTemplate.asset('index.html'),
|
|
13
|
+
'style' => HtmlTemplate.asset('style.css'),
|
|
14
|
+
'script' => HtmlTemplate.asset('sort.js'),
|
|
15
|
+
'rows' => rows.map { |row| row(row) }.join("\n")
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The table row of one Summary.files row; each cell has a
|
|
20
|
+
# data-sort value for the page's column sorting.
|
|
21
|
+
def self.row(row)
|
|
22
|
+
"<tr>#{cells(row).join}</tr>"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The four cells: file, sites, outcomes seen, missing.
|
|
26
|
+
def self.cells(row)
|
|
27
|
+
path = CGI.escapeHTML(row[:path])
|
|
28
|
+
link = %(<a href="files/#{path}.html">#{path}</a>)
|
|
29
|
+
seen = "#{row[:seen]} / #{row[:outcomes]} · #{row[:percent]}%"
|
|
30
|
+
[
|
|
31
|
+
cell(path, link),
|
|
32
|
+
cell(row[:sites], row[:sites]),
|
|
33
|
+
cell(row[:percent], seen),
|
|
34
|
+
cell(row[:missing], row[:missing])
|
|
35
|
+
]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# One table cell, sorted by sort_value, showing content.
|
|
39
|
+
def self.cell(sort_value, content)
|
|
40
|
+
%(<td data-sort="#{sort_value}">#{content}</td>)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require_relative 'html_file'
|
|
3
|
+
require_relative 'html_index'
|
|
4
|
+
require_relative 'site_tree'
|
|
5
|
+
require_relative 'source_lines'
|
|
6
|
+
require_relative 'summary'
|
|
7
|
+
|
|
8
|
+
module ConditionCoverage
|
|
9
|
+
# The :html reporter: index.html, and files/<path>.html for
|
|
10
|
+
# each covered file, written to the configuration's
|
|
11
|
+
# output_dir.
|
|
12
|
+
module HtmlReport
|
|
13
|
+
# Writes every page. files is { full path => {source:, sites:} }
|
|
14
|
+
# for each covered file, as the kernel parsed it.
|
|
15
|
+
def self.write(result, files, config)
|
|
16
|
+
dir = config.output_dir
|
|
17
|
+
index = HtmlIndex.page(Summary.files(result))
|
|
18
|
+
File.write("#{dir}/index.html", index)
|
|
19
|
+
files.each do |full_path, parsed|
|
|
20
|
+
path = config.relative(full_path)
|
|
21
|
+
write_page("#{dir}/files/#{path}.html", path, parsed, result)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Writes one file's page to out.
|
|
26
|
+
def self.write_page(out, path, parsed, result)
|
|
27
|
+
source = parsed[:source]
|
|
28
|
+
lines = SourceLines.of(source, parsed[:sites], result.operands)
|
|
29
|
+
trees = trees(source, parsed[:sites], lines, result.operands)
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
31
|
+
File.write(out, HtmlFile.page(path, lines, trees))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# { first line of a span => [SiteTree.of, ...] } for the
|
|
35
|
+
# sites of one file, each under the span its line is in.
|
|
36
|
+
def self.trees(source, sites, lines, operands)
|
|
37
|
+
trees = Hash.new { |all, first| all[first] = [] }
|
|
38
|
+
sites.each do |site, condition|
|
|
39
|
+
first = lines[SourceLines.line_of(site) - 1][:span].begin
|
|
40
|
+
tree = SiteTree.of(source, condition, operands.fetch(site))
|
|
41
|
+
trees[first] << tree
|
|
42
|
+
end
|
|
43
|
+
trees
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module ConditionCoverage
|
|
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,13 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module ConditionCoverage
|
|
4
|
+
# The base reporter: the result as raw JSON, which every
|
|
5
|
+
# other reporter can read back.
|
|
6
|
+
module JsonWriter
|
|
7
|
+
# Writes result to path as {"sites": {site: [counts, ...]}}.
|
|
8
|
+
def self.write(result, path)
|
|
9
|
+
json = JSON.pretty_generate('sites' => result.operands)
|
|
10
|
+
File.write(path, json)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require_relative 'configuration'
|
|
3
|
+
require_relative 'gaps'
|
|
4
|
+
require_relative 'html_report'
|
|
5
|
+
require_relative 'json_writer'
|
|
6
|
+
require_relative 'manifest'
|
|
7
|
+
require_relative 'recorder'
|
|
8
|
+
require_relative 'result'
|
|
9
|
+
require_relative 'rewriter'
|
|
10
|
+
|
|
11
|
+
# Condition coverage for Ruby. This file is the kernel: it loads
|
|
12
|
+
# before any covered file and is never itself instrumented, since
|
|
13
|
+
# an instrumented recorder would record itself.
|
|
14
|
+
module ConditionCoverage
|
|
15
|
+
@recorder = Recorder.new
|
|
16
|
+
@config = nil
|
|
17
|
+
# { full path => {source:, sites:} } of each covered file
|
|
18
|
+
# parsed as it loaded, so the exit report need not read it
|
|
19
|
+
# again.
|
|
20
|
+
@parsed = {}
|
|
21
|
+
|
|
22
|
+
# The one recorder that every instrumented operand reports to.
|
|
23
|
+
def self.recorder
|
|
24
|
+
@recorder
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The configuration of the latest start, which the load
|
|
28
|
+
# hook consults; nil before the first start.
|
|
29
|
+
def self.config
|
|
30
|
+
@config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The call rewritten code makes around each operand; returns
|
|
34
|
+
# value unchanged.
|
|
35
|
+
def self.record(site, index, value)
|
|
36
|
+
@recorder.record(site, index, value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Instruments every file later required that the settings
|
|
40
|
+
# block covers, and reports when the process exits. on_exit
|
|
41
|
+
# is Kernel#at_exit except in tests, which must not print
|
|
42
|
+
# a report as their own process exits.
|
|
43
|
+
def self.start(on_exit: method(:at_exit), &)
|
|
44
|
+
config = Configuration.new(&)
|
|
45
|
+
install_hook if @config.nil?
|
|
46
|
+
@config = config
|
|
47
|
+
on_exit.call { report(config) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Routes every later load through instrumented, under
|
|
51
|
+
# whichever configuration is current. Installed once: a
|
|
52
|
+
# second definition would warn that it was redefined.
|
|
53
|
+
def self.install_hook
|
|
54
|
+
iseq = RubyVM::InstructionSequence
|
|
55
|
+
iseq.singleton_class.define_method(:load_iseq) do |path|
|
|
56
|
+
ConditionCoverage.instrumented(path, ConditionCoverage.config)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The compiled, rewritten code of path, or nil for a path
|
|
61
|
+
# config does not cover, which Ruby then loads as normal.
|
|
62
|
+
def self.instrumented(path, config)
|
|
63
|
+
return unless config.covers?(path)
|
|
64
|
+
|
|
65
|
+
parsed = @parsed[path] = parse(path, config)
|
|
66
|
+
rewritten = Rewriter.rewrite(parsed[:source], parsed[:sites])
|
|
67
|
+
RubyVM::InstructionSequence.compile(rewritten, path, path)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The source and sites of the covered file at path.
|
|
71
|
+
def self.parse(path, config)
|
|
72
|
+
source = File.read(path)
|
|
73
|
+
{ source:, sites: Manifest.sites(source, config.relative(path)) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Writes the JSON report, then runs the chosen reporters, for
|
|
77
|
+
# every covered file whether or not the run loaded it, into
|
|
78
|
+
# coverage_dir, created first if need be.
|
|
79
|
+
def self.report(config)
|
|
80
|
+
files = parsed_files(config)
|
|
81
|
+
result = Result.new(merged_sites(files), @recorder.counts)
|
|
82
|
+
FileUtils.mkdir_p(config.output_dir)
|
|
83
|
+
JsonWriter.write(result, config.json_path)
|
|
84
|
+
run_reporters(result, files, config)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Runs each reporter chosen in the start block.
|
|
88
|
+
def self.run_reporters(result, files, config)
|
|
89
|
+
reporters = config.reporters
|
|
90
|
+
if reporters.include?(:gaps)
|
|
91
|
+
Gaps.lines(result).each { |line| puts line }
|
|
92
|
+
end
|
|
93
|
+
return unless reporters.include?(:html)
|
|
94
|
+
|
|
95
|
+
HtmlReport.write(result, files, config)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# { full path => {source:, sites:} } for every covered file:
|
|
99
|
+
# as parsed when it loaded, or parsed now for a file the run
|
|
100
|
+
# never loaded.
|
|
101
|
+
def self.parsed_files(config)
|
|
102
|
+
config.files.to_h do |path|
|
|
103
|
+
[path, @parsed.fetch(path) { parse(path, config) }]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The sites of every covered file, in one manifest.
|
|
108
|
+
def self.merged_sites(files)
|
|
109
|
+
files.values.map { |parsed| parsed[:sites] }.reduce({}, :merge)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require 'prism'
|
|
2
|
+
require_relative 'condition'
|
|
3
|
+
require_relative 'site'
|
|
4
|
+
|
|
5
|
+
module ConditionCoverage
|
|
6
|
+
# Finds every condition site in source without running it: each
|
|
7
|
+
# outermost &&, ||, and or or, with the conditions nested in it.
|
|
8
|
+
module Manifest
|
|
9
|
+
# { site => Condition } for each site in source.
|
|
10
|
+
def self.sites(source, path)
|
|
11
|
+
collector = ConditionSites.new(path)
|
|
12
|
+
Prism.parse(source).value.accept(collector)
|
|
13
|
+
collector.sites
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Collects each site and its Condition.
|
|
17
|
+
class ConditionSites < Prism::Visitor
|
|
18
|
+
# Starts with no sites found.
|
|
19
|
+
def initialize(path)
|
|
20
|
+
@path = path
|
|
21
|
+
@sites = {}
|
|
22
|
+
@commands = []
|
|
23
|
+
super()
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The sites found so far.
|
|
27
|
+
attr_reader :sites
|
|
28
|
+
|
|
29
|
+
OPERATOR_TYPES = %i[and_node or_node].freeze
|
|
30
|
+
|
|
31
|
+
# Notes an && or and site.
|
|
32
|
+
def visit_and_node(node)
|
|
33
|
+
add_site(node)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Notes an || or or site.
|
|
37
|
+
def visit_or_node(node)
|
|
38
|
+
add_site(node)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Notes node as a site. It claims its place first, so a
|
|
42
|
+
# site found inside one of its leaves follows it, keeping
|
|
43
|
+
# the sites in source order.
|
|
44
|
+
def add_site(node)
|
|
45
|
+
site = Site.of(@path, node)
|
|
46
|
+
@sites[site] = nil
|
|
47
|
+
outer = @commands
|
|
48
|
+
@commands = []
|
|
49
|
+
condition = condition_of(node)
|
|
50
|
+
@sites[site] = condition.with(commands: @commands)
|
|
51
|
+
@commands = outer
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# node as a Condition, operands and all, named by its
|
|
55
|
+
# operator as written.
|
|
56
|
+
def condition_of(node)
|
|
57
|
+
operator = node.operator
|
|
58
|
+
Condition.new(operator:, operands: operands_of(node))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# node's operands: a chain of the same operator flattened
|
|
62
|
+
# into one list, the other operator nested as a Condition,
|
|
63
|
+
# anything else a leaf's byte range.
|
|
64
|
+
def operands_of(node)
|
|
65
|
+
[node.left, node.right].flat_map do |written|
|
|
66
|
+
operand = grouped(written)
|
|
67
|
+
next operands_of(operand) if operand.type == node.type
|
|
68
|
+
next [condition_of(operand)] if operator?(operand)
|
|
69
|
+
|
|
70
|
+
[leaf(operand)]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# True for an &&, ||, and or or node.
|
|
75
|
+
def operator?(node)
|
|
76
|
+
OPERATOR_TYPES.include?(node.type)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The byte range of a leaf operand, once any site inside
|
|
80
|
+
# it is noted too, and the range among its site's commands
|
|
81
|
+
# if it is a command call.
|
|
82
|
+
def leaf(operand)
|
|
83
|
+
operand.accept(self)
|
|
84
|
+
loc = operand.location
|
|
85
|
+
range = loc.start_offset...loc.end_offset
|
|
86
|
+
@commands << range if command?(operand)
|
|
87
|
+
range
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# True for a call to a named method with arguments and no
|
|
91
|
+
# parentheses, eg `id.include? 'N'`, but not `id.size == 6`.
|
|
92
|
+
def command?(node)
|
|
93
|
+
node.type == :call_node && node.arguments &&
|
|
94
|
+
node.opening_loc.nil? && node.name.match?(/\A[a-z_]/i)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The one statement inside a parenthesised operand, or the
|
|
98
|
+
# operand itself.
|
|
99
|
+
def grouped(operand)
|
|
100
|
+
return operand unless operand.type == :parentheses_node
|
|
101
|
+
|
|
102
|
+
statements = operand.body&.body || []
|
|
103
|
+
statements.size == 1 ? statements.first : operand
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module ConditionCoverage
|
|
2
|
+
# Records the outcome of each instrumented operand.
|
|
3
|
+
class Recorder
|
|
4
|
+
# Starts with no outcomes counted.
|
|
5
|
+
def initialize
|
|
6
|
+
@counts = Hash.new do |counts, key|
|
|
7
|
+
counts[key] = { truthy: 0, falsy: 0 }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The truthy and falsy counts of every operand recorded so
|
|
12
|
+
# far, keyed by [site, index]; an operand that never ran
|
|
13
|
+
# has no entry.
|
|
14
|
+
attr_reader :counts
|
|
15
|
+
|
|
16
|
+
# Counts value's outcome and returns it unchanged, so the
|
|
17
|
+
# condition around it behaves exactly as uninstrumented.
|
|
18
|
+
def record(site, index, value)
|
|
19
|
+
outcome = value ? :truthy : :falsy
|
|
20
|
+
@counts[[site, index]][outcome] += 1
|
|
21
|
+
value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module ConditionCoverage
|
|
2
|
+
# What a run found: every site the manifest knows of, with
|
|
3
|
+
# the counts its operands were recorded with. Every
|
|
4
|
+
# reporter builds on this.
|
|
5
|
+
class Result
|
|
6
|
+
NEVER_RAN = { truthy: 0, falsy: 0 }.freeze
|
|
7
|
+
|
|
8
|
+
# sites is { site => Condition }, from the manifest;
|
|
9
|
+
# counts is { [site, index] => counts }, from the recorder.
|
|
10
|
+
def initialize(sites, counts)
|
|
11
|
+
@sites = sites
|
|
12
|
+
@counts = counts
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# { site => [counts of operand 0, counts of operand 1, ...] },
|
|
16
|
+
# with zero counts for an operand that never ran.
|
|
17
|
+
def operands
|
|
18
|
+
@sites.to_h do |site, condition|
|
|
19
|
+
size = condition.leaves.size
|
|
20
|
+
[site, Array.new(size) { |i| counts_of(site, i) }]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# The recorded counts of one operand, or zeros.
|
|
27
|
+
def counts_of(site, index)
|
|
28
|
+
@counts.fetch([site, index], NEVER_RAN)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module ConditionCoverage
|
|
2
|
+
# Instruments source text so that each leaf operand records its
|
|
3
|
+
# outcome when the code runs. It inserts text at the byte
|
|
4
|
+
# ranges the manifest found, and never parses.
|
|
5
|
+
module Rewriter
|
|
6
|
+
# Returns source with each operand in sites wrapped in a
|
|
7
|
+
# record call. Only text is inserted, never a newline, so
|
|
8
|
+
# backtrace line numbers still match the file on disk.
|
|
9
|
+
def self.rewrite(source, sites)
|
|
10
|
+
inserts = sites.flat_map do |site, condition|
|
|
11
|
+
condition.leaves.each_with_index.flat_map do |range, index|
|
|
12
|
+
command = condition.commands.include?(range)
|
|
13
|
+
wrap(site, index, range, command)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
apply(source.dup, inserts)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The two inserts that wrap one operand in a record call. A
|
|
20
|
+
# command call is parenthesised, as it is no bare argument;
|
|
21
|
+
# nothing else is, since parentheses make a statement, which
|
|
22
|
+
# gives its line a line-coverage count of its own.
|
|
23
|
+
def self.wrap(site, index, range, command)
|
|
24
|
+
open, close = command ? ['(', '))'] : ['', ')']
|
|
25
|
+
call = "ConditionCoverage.record(#{site.inspect}, #{index}, "
|
|
26
|
+
[[range.begin, call + open], [range.end, close]]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Splices each [offset, text] insert into source,
|
|
30
|
+
# last offset first so earlier offsets stay valid.
|
|
31
|
+
def self.apply(source, inserts)
|
|
32
|
+
sorted = inserts.sort_by { |offset, _| -offset }
|
|
33
|
+
sorted.each do |offset, text|
|
|
34
|
+
source.bytesplice(offset, 0, text)
|
|
35
|
+
end
|
|
36
|
+
source
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module ConditionCoverage
|
|
2
|
+
# Names a condition by where it starts in its file, so the
|
|
3
|
+
# rewriter and the manifest agree on which site is which.
|
|
4
|
+
module Site
|
|
5
|
+
# "path:line:column" of node, eg "k.rb:2:2".
|
|
6
|
+
def self.of(path, node)
|
|
7
|
+
loc = node.location
|
|
8
|
+
"#{path}:#{loc.start_line}:#{loc.start_column}"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require_relative 'condition'
|
|
2
|
+
require_relative 'gaps'
|
|
3
|
+
|
|
4
|
+
module ConditionCoverage
|
|
5
|
+
# The tree shown under a site's line on its file's page.
|
|
6
|
+
module SiteTree
|
|
7
|
+
# { operator:, operands: [{text:, truthy:, falsy:, gap:}] }
|
|
8
|
+
# for one site, a nested condition being an operand of the
|
|
9
|
+
# same { operator:, operands: } shape. Each leaf has its
|
|
10
|
+
# text cut from source at its manifest byte range, its
|
|
11
|
+
# counts, and its gap label in the same words as the
|
|
12
|
+
# terminal gaps list (nil for none).
|
|
13
|
+
def self.of(source, condition, counts)
|
|
14
|
+
node(source, condition, counts.each)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The tree of condition, each leaf taking the next of
|
|
18
|
+
# counts, so leaves pair with counts left to right.
|
|
19
|
+
def self.node(source, condition, counts)
|
|
20
|
+
operands = condition.operands.map do |operand|
|
|
21
|
+
if operand.is_a?(Condition)
|
|
22
|
+
node(source, operand, counts)
|
|
23
|
+
else
|
|
24
|
+
leaf(source, operand, counts.next)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
{ operator: condition.operator, operands: }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# One leaf's text, counts and gap.
|
|
31
|
+
def self.leaf(source, range, counts)
|
|
32
|
+
{
|
|
33
|
+
text: source.byteslice(range),
|
|
34
|
+
truthy: counts[:truthy], falsy: counts[:falsy],
|
|
35
|
+
gap: Gaps.operand_gap(counts).first
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|