path_coverage 0.1.0 → 0.2.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +287 -78
  3. data/lib/path_coverage/calls.rb +54 -15
  4. data/lib/path_coverage/condition.rb +25 -2
  5. data/lib/path_coverage/configuration.rb +44 -7
  6. data/lib/path_coverage/configuration_paths.rb +56 -0
  7. data/lib/path_coverage/console_summary.rb +77 -0
  8. data/lib/path_coverage/gaps.rb +25 -9
  9. data/lib/path_coverage/html/index.html +3 -17
  10. data/lib/path_coverage/html/side.js +21 -2
  11. data/lib/path_coverage/html/sort.js +24 -21
  12. data/lib/path_coverage/html/style.css +28 -1
  13. data/lib/path_coverage/html/tabs.js +15 -0
  14. data/lib/path_coverage/html_index.rb +86 -31
  15. data/lib/path_coverage/html_index_head.rb +49 -0
  16. data/lib/path_coverage/html_report.rb +30 -13
  17. data/lib/path_coverage/html_tabs.rb +26 -0
  18. data/lib/path_coverage/html_tree.rb +41 -20
  19. data/lib/path_coverage/json_reader.rb +94 -0
  20. data/lib/path_coverage/json_writer.rb +41 -9
  21. data/lib/path_coverage/kernel.rb +42 -20
  22. data/lib/path_coverage/kinds/base.rb +71 -0
  23. data/lib/path_coverage/kinds/block.rb +136 -0
  24. data/lib/path_coverage/kinds/case.rb +89 -0
  25. data/lib/path_coverage/kinds/chain.rb +45 -0
  26. data/lib/path_coverage/kinds/choice.rb +68 -0
  27. data/lib/path_coverage/kinds/contains.rb +111 -0
  28. data/lib/path_coverage/kinds/safe.rb +151 -0
  29. data/lib/path_coverage/kinds/write.rb +54 -0
  30. data/lib/path_coverage/kinds.rb +37 -0
  31. data/lib/path_coverage/manifest.rb +58 -19
  32. data/lib/path_coverage/manifest_blocks.rb +49 -10
  33. data/lib/path_coverage/manifest_chains.rb +76 -0
  34. data/lib/path_coverage/manifest_choices.rb +30 -17
  35. data/lib/path_coverage/manifest_folding.rb +91 -0
  36. data/lib/path_coverage/manifest_ignoring.rb +35 -0
  37. data/lib/path_coverage/manifest_writes.rb +67 -0
  38. data/lib/path_coverage/merge.rb +19 -3
  39. data/lib/path_coverage/post.rb +8 -8
  40. data/lib/path_coverage/recorder.rb +9 -49
  41. data/lib/path_coverage/recorder_blocks.rb +68 -0
  42. data/lib/path_coverage/recorder_cases.rb +23 -0
  43. data/lib/path_coverage/recorder_chains.rb +40 -0
  44. data/lib/path_coverage/recorder_writes.rb +26 -0
  45. data/lib/path_coverage/render.rb +58 -46
  46. data/lib/path_coverage/result.rb +19 -0
  47. data/lib/path_coverage/rewriter.rb +58 -77
  48. data/lib/path_coverage/rewriter_nested.rb +62 -0
  49. data/lib/path_coverage/site_paths.rb +9 -58
  50. data/lib/path_coverage/site_tree.rb +12 -57
  51. data/lib/path_coverage/summary.rb +32 -4
  52. data/lib/path_coverage/totals.rb +1 -1
  53. data/lib/path_coverage/version.rb +1 -1
  54. metadata +30 -6
@@ -0,0 +1,49 @@
1
+ module PathCoverage
2
+ # The two heading rows of the HTML index's files tables: File
3
+ # and Sites, then each total's name over its taken, / of and %
4
+ # columns, every column sortable.
5
+ module HtmlIndexHead
6
+ # The class and label of each of a total's three columns.
7
+ PARTS = [%w[taken taken], ['of', '/ of'], %w[percent %]].freeze
8
+
9
+ # The two heading rows; methods' total only when methods are
10
+ # measured.
11
+ def self.of(methods)
12
+ names = ['Within each decision']
13
+ names << 'Within each method' if methods
14
+ rows = [top_row(names), part_row(names)]
15
+ rows.map { |row| "<tr>\n#{row}\n</tr>" }.join("\n")
16
+ end
17
+
18
+ # The first heading row: File and Sites, each over both rows,
19
+ # then each total's name over its three columns.
20
+ def self.top_row(names)
21
+ fixed = [
22
+ heading(' rowspan="2"', 0, 'text', 'File'),
23
+ heading(' class="num" rowspan="2"', 1, 'number', 'Sites')
24
+ ]
25
+ groups = names.map do |name|
26
+ %(<th class="group" colspan="3">#{name}</th>)
27
+ end
28
+ (fixed + groups).join("\n")
29
+ end
30
+
31
+ # The second heading row: each total's taken, / of and %.
32
+ def self.part_row(names)
33
+ names.each_index.flat_map do |group|
34
+ PARTS.each_with_index.map do |(css, label), i|
35
+ column = 2 + (3 * group) + i
36
+ heading(%( class="#{css}"), column, 'number', label)
37
+ end
38
+ end.join("\n")
39
+ end
40
+
41
+ # A sortable heading, attrs its th's, sorting the column-th
42
+ # column as type, its button showing label.
43
+ def self.heading(attrs, column, type, label)
44
+ data = %(data-column="#{column}" data-type="#{type}")
45
+ button = %(<button type="button" #{data}>#{label}</button>)
46
+ %(<th#{attrs} aria-sort="none">#{button}</th>)
47
+ end
48
+ end
49
+ end
@@ -15,9 +15,11 @@ module PathCoverage
15
15
  # Writes every page. operands and site_paths are as Result
16
16
  # gives them, site_paths holding every site in files; files
17
17
  # is { full path => {source:, sites:} } for each covered
18
- # file, as the kernel parsed it.
18
+ # file, as the kernel parsed it. The files dir is emptied
19
+ # first, so it holds no page for a file no longer covered.
19
20
  def self.write(operands, files, config, paths = nil, site_paths:)
20
21
  dir = config.output_dir
22
+ FileUtils.rm_rf("#{dir}/files")
21
23
  index = index(files, config, paths, site_paths)
22
24
  File.write("#{dir}/index.html", index)
23
25
  files.each do |full_path, parsed|
@@ -32,8 +34,10 @@ module PathCoverage
32
34
 
33
35
  # The index page: a row per file of its totals within each
34
36
  # 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
+ # methods are measured, within each method too;
38
+ # then their Total row. With groups configured, each group,
39
+ # and the files in none, get a tab of their own too. Any
40
+ # operators config ignored are named after them.
37
41
  def self.index(files, config, paths, site_paths)
38
42
  methods = files.map do |full_path, parsed|
39
43
  path = config.relative(full_path)
@@ -41,7 +45,10 @@ module PathCoverage
41
45
  end
42
46
  all = methods.reduce({}, :merge)
43
47
  rows = Summary.files(site_paths, all, paths)
44
- HtmlIndex.page(rows, Summary.total(rows))
48
+ groups = config.groups
49
+ tabs = groups.empty? ? [] : Summary.groups(rows, groups)
50
+ ignored = config.ignored
51
+ HtmlIndex.page(rows, Summary.total(rows), tabs, ignored:)
45
52
  end
46
53
 
47
54
  # simplecov:disable
@@ -81,24 +88,34 @@ module PathCoverage
81
88
  # on it and on each of its operands: a leaf's from its byte
82
89
  # range, a condition's and an arm's from its extent, the
83
90
  # whole of it as written. Columns count characters, not
84
- # bytes.
91
+ # bytes. A node past the condition's operands, eg owner at
92
+ # the end of user&.account&.owner, is kept as it is.
85
93
  def self.located(source, condition, tree)
86
94
  pairs = condition.operands.zip(tree[:operands])
87
95
  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
96
+ located_operand(source, operand, node)
97
+ end
98
+ rest = tree[:operands].drop(pairs.size)
99
+ tree.merge(at: at(source, condition.extent),
100
+ operands: operands + rest)
101
+ end
102
+
103
+ # SPIKE: the node of operand with its at:, as located gives
104
+ # a condition's, an arm's or a leaf's.
105
+ def self.located_operand(source, operand, node)
106
+ case operand
107
+ when Condition then located(source, operand, node)
108
+ when Arm then located_arm(source, operand, node)
109
+ else node.merge(at: at(source, operand))
93
110
  end
94
- tree.merge(at: at(source, condition.extent), operands:)
95
111
  end
96
112
 
97
113
  # An arm's node with its at:, nil for an arm with no
98
114
  # statements, and its body's tree located.
99
115
  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:)
116
+ body = arm.body ? located(source, arm.body, node[:body]) : nil
117
+ at = arm.extent ? at(source, arm.extent) : nil
118
+ node.merge(at:, body:)
102
119
  end
103
120
 
104
121
  # [line, column, line, column] of the byte range in source.
@@ -0,0 +1,26 @@
1
+ require 'cgi/escape'
2
+
3
+ module PathCoverage
4
+ # The index page's tab bar, one tab per table: All files,
5
+ # then each group.
6
+ module HtmlTabs
7
+ # The tab bar of names, the first tab selected, each tab
8
+ # controlling the table of the same index.
9
+ def self.bar(names)
10
+ tabs = names.each_with_index.map { |name, i| tab(name, i) }
11
+ nav = %(<nav class="tabs" role="tablist" aria-label="Groups">)
12
+ "#{nav}#{tabs.join}</nav>"
13
+ end
14
+
15
+ # The index-th tab, named name.
16
+ def self.tab(name, index)
17
+ selected = index.zero?
18
+ [
19
+ %(<button type="button" role="tab"),
20
+ %( aria-selected="#{selected}"),
21
+ %( aria-controls="files-#{index}">),
22
+ CGI.escapeHTML(name), '</button>'
23
+ ].join
24
+ end
25
+ end
26
+ end
@@ -1,5 +1,6 @@
1
1
  require 'cgi/escape'
2
2
  require_relative 'condition'
3
+ require_relative 'kinds'
3
4
 
4
5
  module PathCoverage
5
6
  # The tree of a span's sites on a file's page, from SiteTree.of
@@ -8,14 +9,13 @@ module PathCoverage
8
9
  OUTCOMES = {
9
10
  true => 'T', false => 'F', nil => '',
10
11
  ITEMS[0] => '0', ITEMS[1] => '1', ITEMS[2] => '*',
11
- ANSWERS[0] => 'T', ANSWERS[1] => 'F', ANSWERS[2] => 'TF'
12
+ ANSWERS[0] => 'T', ANSWERS[1] => 'F', ANSWERS[2] => 'TF',
13
+ PRESENCE[0] => [0x25CF].pack('U'),
14
+ PRESENCE[1] => [0x2205].pack('U')
12
15
  }.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
16
 
17
- # A bullet marks a path taking an arm.
18
- TAKEN = { true => [0x2022].pack('U'), false => '' }.freeze
17
+ # A tick marks a path taking an arm.
18
+ TAKEN = { true => [0x2713].pack('U'), false => '' }.freeze
19
19
 
20
20
  # The tree of a span, open when its sites have gaps, else
21
21
  # hidden: each site's rows in a group of their own.
@@ -45,10 +45,13 @@ module PathCoverage
45
45
  end.join
46
46
  end
47
47
 
48
- # One cell per path, naming each with names.
48
+ # One cell per path, naming each with names; a &. receiver's,
49
+ # shown as a glyph, titled not nil or nil for its hover text.
49
50
  def self.cells(values, names = OUTCOMES)
50
51
  values.map do |value|
51
- %(<span class="cell" data-cell="">#{names[value]}</span>)
52
+ title = PRESENCE.include?(value) ? %( title="#{value}") : ''
53
+ attrs = %(class="cell" data-cell=""#{title})
54
+ %(<span #{attrs}>#{names[value]}</span>)
52
55
  end.join
53
56
  end
54
57
 
@@ -61,10 +64,16 @@ module PathCoverage
61
64
  # The rows of a condition's operands, each branching from
62
65
  # its operator after indent; a nested condition's operands,
63
66
  # and those of an arm's condition, follow its own row,
64
- # indented under its branch.
67
+ # indented under its branch. A leaf with sites inside it
68
+ # branches as those sites, then the leaf, in the order they
69
+ # run.
65
70
  def self.operand_rows(condition, indent)
66
- last = condition[:operands].size - 1
67
- condition[:operands].each_with_index.flat_map do |operand, i|
71
+ own = Kinds.of(condition[:operator]).rows(condition, indent)
72
+ return own if own
73
+
74
+ operands = unfolded(condition[:operands])
75
+ last = operands.size - 1
76
+ operands.each_with_index.flat_map do |operand, i|
68
77
  tip = i == last
69
78
  branch = indent + (tip ? '└─ ' : '├─ ')
70
79
  under = indent + (tip ? ' ' : '│ ')
@@ -91,7 +100,7 @@ module PathCoverage
91
100
  # plain arm marks the paths taking it.
92
101
  def self.arm_rows(arm, branch, under, choice)
93
102
  body = arm[:body]
94
- values = cells((body || arm)[:cells], body ? OUTCOMES : TAKEN)
103
+ values = body ? cells(body[:cells]) : cells(arm[:cells], TAKEN)
95
104
  text = CGI.escapeHTML(arm_label(arm, choice))
96
105
  lead = branch_cell(branch) +
97
106
  %(<span class="arm" data-label="">#{text}</span>)
@@ -99,13 +108,20 @@ module PathCoverage
99
108
  body ? [row, *operand_rows(body, under)] : [row]
100
109
  end
101
110
 
102
- # The name of an arm of a condition whose operator is choice.
111
+ # The name of an arm of a condition whose operator is choice,
112
+ # as its kind names it, eg then for an if's.
103
113
  def self.arm_label(arm, choice)
104
- return arm[:arm].to_s if NAMED_ARMS.include?(choice)
114
+ Kinds.of(choice).arm_label(arm, choice)
115
+ end
105
116
 
106
- mark = arm[:arm] == :then ? '?' : ':'
107
- body = arm[:body]
108
- "#{mark} #{body ? body[:operator] : arm[:text]}"
117
+ # operands with each node standing for the nodes its kind
118
+ # unfolds it into, eg a leaf with sites inside it for those
119
+ # sites, then the leaf; a leaf or an arm for itself.
120
+ def self.unfolded(operands)
121
+ operands.flat_map do |operand|
122
+ operator = operand[:operator]
123
+ operator ? Kinds.of(operator).unfold(operand) : [operand]
124
+ end
109
125
  end
110
126
 
111
127
  # A nested condition's row: its branch, its operator, and
@@ -122,20 +138,25 @@ module PathCoverage
122
138
  %(<div class="row"#{attrs}>#{lead}#{values}</div>)
123
139
  end
124
140
 
125
- # The cell naming a condition's operator.
141
+ # The cell naming a condition's operator, or its label when
142
+ # it has one, eg select { for a site inside a leaf.
126
143
  def self.operator(condition)
127
- name = CGI.escapeHTML(condition[:operator])
144
+ name = CGI.escapeHTML(condition[:label] || condition[:operator])
128
145
  %(<span class="operator" data-operator="">#{name}</span>)
129
146
  end
130
147
 
131
148
  # One operand's row: its branch, text, and its outcome on
132
149
  # each path.
150
+ # An operand that is only called, eg owner at the end of
151
+ # user&.account&.owner, has a tick on each path that called
152
+ # it instead.
133
153
  def self.operand_row(operand, branch)
134
154
  text = CGI.escapeHTML(operand[:text].to_s)
135
155
  lead = branch_cell(branch) +
136
156
  %(<span class="text" data-text="">#{text}</span>)
137
157
  attrs = %( data-operand=""#{at(operand)})
138
- row(attrs, lead, cells(operand[:cells]))
158
+ names = operand[:called] ? TAKEN : OUTCOMES
159
+ row(attrs, lead, cells(operand[:cells], names))
139
160
  end
140
161
 
141
162
  # The cell drawing a row's branch from its operator.
@@ -0,0 +1,94 @@
1
+ require 'json'
2
+ require_relative 'kinds'
3
+ require_relative 'merge'
4
+
5
+ module PathCoverage
6
+ # Reads back the JSON reports JsonWriter writes, merging the
7
+ # counts of several runs, and refusing any report whose
8
+ # sites' paths are listed differently from a render's.
9
+ module JsonReader
10
+ # The keys of a JSON report's fingerprint.
11
+ FINGERPRINT = %w[ignored treats].freeze
12
+
13
+ # The operands of the JSON reports at paths, merged; raises
14
+ # unless each measured the kinds a render does, with
15
+ # fingerprint, as Configuration#fingerprint gives it.
16
+ def self.operands(paths, fingerprint)
17
+ Merge.operands(paths.map { |path| read(path, fingerprint) })
18
+ end
19
+
20
+ # The method paths of the JSON reports at paths, merged.
21
+ def self.method_paths(paths)
22
+ merged(paths, 'method_paths')
23
+ end
24
+
25
+ # The paths taken of the JSON reports at paths, as
26
+ # Render.run takes them: site_paths:, each site's possible
27
+ # paths, and outside:, those a treatment rules out, each
28
+ # merged.
29
+ def self.taken(paths)
30
+ site_paths = Merge.paths(paths.map { |path| paths_of(path) })
31
+ { site_paths:, outside: merged(paths, 'outside') }
32
+ end
33
+
34
+ # Raises unless json, the JSON report at path, measured the
35
+ # kinds a render does, with fingerprint; one that measured
36
+ # others, or names none, lists its sites' paths differently,
37
+ # so its counts cannot be trusted to the paths a render
38
+ # lists.
39
+ def self.same_kinds(path, json, fingerprint)
40
+ kinds = json['kinds'] == Kinds.names
41
+ return if kinds && json.slice(*FINGERPRINT) == fingerprint
42
+
43
+ raise ArgumentError, "#{path} measured other kinds"
44
+ end
45
+
46
+ # The key lists of the JSON reports at paths, as listed_of
47
+ # gives them, each path's count the sum of every report's.
48
+ def self.merged(paths, key)
49
+ Merge.method_paths(paths.map { |path| listed_of(path, key) })
50
+ end
51
+
52
+ # The JSON report at path's list under key, eg its method
53
+ # paths as the recorder counts them, { site => { path =>
54
+ # count } }; none for a report that has none, eg from a run
55
+ # that did not measure methods, so the render claims no
56
+ # path taken.
57
+ def self.listed_of(path, key)
58
+ json = JSON.parse(File.read(path))
59
+ json.fetch(key, {}).transform_values do |taken|
60
+ taken.to_h { |each| [each['path'], each['count']] }
61
+ end
62
+ end
63
+
64
+ # The site paths of the JSON report at path, as
65
+ # Result#paths gives them; none for a report that has none.
66
+ def self.paths_of(path)
67
+ json = JSON.parse(File.read(path))
68
+ json.fetch('paths', {}).transform_values do |site|
69
+ site['taken'].map do |each|
70
+ { path: each['path'], count: each['count'] }
71
+ end
72
+ end
73
+ end
74
+
75
+ # The operands of the JSON report at path, which raises
76
+ # unless it measured the kinds a render does, with
77
+ # fingerprint.
78
+ def self.read(path, fingerprint)
79
+ json = JSON.parse(File.read(path))
80
+ same_kinds(path, json, fingerprint)
81
+ json['sites'].transform_values do |leaves|
82
+ leaves.map { |leaf| counts(leaf) }
83
+ end
84
+ end
85
+
86
+ # One operand's counts as JSON holds them, with the keys,
87
+ # and any only: value, as the symbols Result#operands uses.
88
+ def self.counts(leaf)
89
+ counts = { truthy: leaf['truthy'], falsy: leaf['falsy'] }
90
+ only = leaf['only']
91
+ only ? counts.merge(only: only.to_sym) : counts
92
+ end
93
+ end
94
+ end
@@ -1,25 +1,57 @@
1
1
  require 'json'
2
+ require_relative 'kinds'
2
3
 
3
4
  module PathCoverage
4
5
  # The base reporter: the result as raw JSON, which every
5
6
  # other reporter can read back.
6
7
  module JsonWriter
7
8
  # 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,
9
+ # PathsReport gives them, totals, as Kernel.totals gives
10
+ # them, and method_paths, as the recorder counts them, to
11
+ # path as {"sites": {site: [counts, ...]}, "paths":
12
+ # {site: {leaves:, taken:}}, "method_paths": {site:
13
+ # [{path:, count:}, ...]}, "totals": {"decisions": [taken,
11
14
  # possible]}}.
12
- def self.write(operands, path, paths = {}, totals: {})
13
- File.write(path, json(operands, paths, totals:))
15
+ # parts are json's.
16
+ def self.write(operands, path, paths = {}, **parts)
17
+ File.write(path, json(operands, paths, **parts))
14
18
  end
15
19
 
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
+ # The keys of parts listed as { site => [{path:, count:}] }:
21
+ # each method's paths taken, and each path taken that a
22
+ # treatment rules out, as Result#outside gives them.
23
+ LISTED = %i[method_paths outside].freeze
24
+
25
+ # operands, and any paths, and parts' method_paths:,
26
+ # outside: and totals:, as the JSON report's text, with
27
+ # parts' fingerprint:, as Configuration#fingerprint gives
28
+ # it, and the names of the kinds measured.
29
+ def self.json(operands, paths = {}, **parts)
30
+ fingerprint = parts.fetch(:fingerprint, {})
31
+ report = { 'sites' => operands, **fingerprint }
20
32
  report['paths'] = paths unless paths.empty?
33
+ report.merge!(lists(parts))
34
+ totals = parts.fetch(:totals, {})
21
35
  report['totals'] = totals unless totals.empty?
36
+ report['kinds'] = Kinds.names
22
37
  JSON.pretty_generate(report)
23
38
  end
39
+
40
+ # { key => its list } of each of parts' LISTED keys that
41
+ # has one, as listed gives them.
42
+ def self.lists(parts)
43
+ LISTED.filter_map do |key|
44
+ taken = parts.fetch(key, {})
45
+ [key.to_s, listed(taken)] unless taken.empty?
46
+ end.to_h
47
+ end
48
+
49
+ # taken, { site => { path => count } }, as JSON lists it,
50
+ # eg { 'k.rb:2:2' => [{ path: [true, false], count: 1 }] }.
51
+ def self.listed(taken)
52
+ taken.transform_values do |counts|
53
+ counts.map { |path, count| { path:, count: } }
54
+ end
55
+ end
24
56
  end
25
57
  end
@@ -1,6 +1,7 @@
1
1
  require 'fileutils'
2
2
  require_relative 'calls'
3
3
  require_relative 'configuration'
4
+ require_relative 'console_summary'
4
5
  require_relative 'json_writer'
5
6
  require_relative 'manifest'
6
7
  require_relative 'path_gaps'
@@ -10,7 +11,6 @@ require_relative 'recorder'
10
11
  require_relative 'render'
11
12
  require_relative 'result'
12
13
  require_relative 'rewriter'
13
- require_relative 'totals'
14
14
 
15
15
  # Path coverage for Ruby. This file is the kernel: it loads
16
16
  # before any covered file and is never itself instrumented, since
@@ -48,18 +48,26 @@ module PathCoverage
48
48
  # Starts from settings in env alone, for a run whose code
49
49
  # cannot be changed, eg one started by RUBYOPT=-r: covers
50
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
51
+ # PATH_COVERAGE_ROOT, skipping any matching the
52
+ # comma-separated PATH_COVERAGE_SKIP globs, writing the JSON
53
+ # report into PATH_COVERAGE_DIR and POSTing it to
53
54
  # PATH_COVERAGE_COLLECTOR. Without a collector it
54
55
  # starts nothing, so RUBYOPT can be set for every run.
55
56
  def self.start_from_env(env, on_exit: method(:at_exit))
56
57
  return unless env.key?('PATH_COVERAGE_COLLECTOR')
57
58
 
58
- start(on_exit:) do
59
+ start(on_exit:, &env_settings(env))
60
+ end
61
+
62
+ # The start block of the settings in env, as start_from_env
63
+ # reads them.
64
+ def self.env_settings(env)
65
+ covers = env.fetch('PATH_COVERAGE_COVER').split(',')
66
+ skips = env.fetch('PATH_COVERAGE_SKIP', '').split(',')
67
+ proc do
59
68
  root env.fetch('PATH_COVERAGE_ROOT')
60
- env.fetch('PATH_COVERAGE_COVER').split(',').each do |glob|
61
- cover glob
62
- end
69
+ covers.each { |glob| cover glob }
70
+ skips.each { |glob| skip glob }
63
71
  coverage_dir env.fetch('PATH_COVERAGE_DIR')
64
72
  collector env.fetch('PATH_COVERAGE_COLLECTOR')
65
73
  report :post
@@ -105,9 +113,7 @@ module PathCoverage
105
113
  result = collect(files, config)
106
114
  methods = config.measures.include?(:methods)
107
115
  paths = methods ? @recorder.paths : nil
108
- Render.run(
109
- result.operands, files, config, paths, site_paths: result.paths
110
- )
116
+ Render.run(result.operands, files, config, paths, **result.taken)
111
117
  return unless config.reporters.include?(:gaps)
112
118
 
113
119
  methods = path_methods_of(files, config)
@@ -130,23 +136,39 @@ module PathCoverage
130
136
  sites = merged_sites(files)
131
137
  result = Result.new(sites, @recorder.counts, @recorder.site_paths)
132
138
  FileUtils.mkdir_p(config.output_dir)
139
+ write_json(result, files, config)
140
+ result
141
+ end
142
+
143
+ # Writes result as the JSON report at config's json_path: its
144
+ # operands, site paths, paths a treatment rules out, and
145
+ # totals, and the method paths the recorder counted, so a
146
+ # render from the JSON alone has them, and config's
147
+ # fingerprint.
148
+ def self.write_json(result, files, config)
133
149
  report = PathsReport.of(result.paths, files)
134
150
  totals = totals(result, files, config)
135
- path = config.json_path
136
- JsonWriter.write(result.operands, path, report, totals:)
137
- result
151
+ JsonWriter.write(result.operands, config.json_path, report,
152
+ totals:, method_paths: @recorder.paths,
153
+ outside: result.outside,
154
+ fingerprint: config.fingerprint)
138
155
  end
139
156
 
140
157
  # The JSON report's totals, each [taken, possible] paths:
141
158
  # within each decision, and, when config measures methods,
142
- # across each method's decisions too.
159
+ # within each method too; with groups configured, each group's
160
+ # own too, under groups.
143
161
  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)
162
+ measured = config.measures.include?(:methods)
163
+ methods = measured ? path_methods_of(files, config) : {}
164
+ paths = measured ? @recorder.paths : nil
165
+ totals = ConsoleSummary.totals(result.paths, methods, paths)
166
+ groups = config.groups
167
+ return totals if groups.empty?
168
+
169
+ summary = ConsoleSummary
170
+ own = summary.group_totals(result.paths, methods, paths, groups)
171
+ totals.merge('groups' => own)
150
172
  end
151
173
 
152
174
  # { full path => {source:, sites:} } for every covered file:
@@ -0,0 +1,71 @@
1
+ module PathCoverage
2
+ module Kinds
3
+ # The hooks every kind has, each as most kinds need it; a
4
+ # kind extends Base and defines its own in place of any.
5
+ module Base
6
+ # The ways a site of this kind as a whole can be evaluated:
7
+ # as it can be nested in another.
8
+ def root_ways(node, fixed)
9
+ ways(node, fixed)
10
+ end
11
+
12
+ # Whether its leaves, nested in a site, need calls of their
13
+ # own in place of record calls; false for most.
14
+ def own_calls?
15
+ false
16
+ end
17
+
18
+ # The inserts for a site of this kind: its leaves' records,
19
+ # and its leave around them.
20
+ def site_inserts(site, condition)
21
+ Rewriter.record_inserts(site, condition) +
22
+ Rewriter.trail_inserts(site, condition)
23
+ end
24
+
25
+ # What a site of this kind's leave call wraps: the whole of
26
+ # it, for most.
27
+ def trailed(condition)
28
+ condition
29
+ end
30
+
31
+ # Its node in a site's tree, each leaf taking the next index
32
+ # of leaves: its operands' nodes, then its arms', if any.
33
+ def node(source, condition, outcomes, leaves)
34
+ built = [outcomes, leaves]
35
+ operands = SiteTree.operands_of(source, condition, *built)
36
+ SiteTree.arms(source, condition, operands, *built)
37
+ end
38
+
39
+ # The nodes its node stands for among its siblings' in a
40
+ # site's tree on a file's page: itself, for most.
41
+ def unfold(node)
42
+ [node]
43
+ end
44
+
45
+ # The inserts, besides its leaves' calls, for one nested in
46
+ # a site at its at-th leaf: none for most.
47
+ def inserts(_site, _inner, _at, _commands)
48
+ []
49
+ end
50
+
51
+ # nodes, the tree nodes of condition's operands, with any
52
+ # texts that repeat others cut: none for most.
53
+ def texted(_source, _condition, nodes)
54
+ nodes
55
+ end
56
+
57
+ # The text a site of this kind opens and closes with, as
58
+ # [opening, closing], eg ['{', '}'] for a block's braces.
59
+ # Most kinds have none, so ['', ''].
60
+ def brackets(_source, _site)
61
+ ['', '']
62
+ end
63
+
64
+ # Its operands' rows in a site's tree on a file's page, nil
65
+ # for most, whose operands branch from its operator.
66
+ def rows(_condition, _indent)
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end