path_coverage 0.0.2 → 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.
@@ -1,3 +1,5 @@
1
+ require_relative 'condition'
2
+
1
3
  module PathCoverage
2
4
  # Instruments source text so that each leaf operand records its
3
5
  # outcome when the code runs. It inserts text at the byte
@@ -13,12 +15,54 @@ module PathCoverage
13
15
  # line numbers still match the file on disk.
14
16
  def self.rewrite(source, sites, methods = {})
15
17
  inserts = sites.flat_map do |site, condition|
16
- record_inserts(site, condition) +
17
- trail_inserts(site, condition)
18
+ site_inserts(site, condition)
18
19
  end
19
20
  apply(source.dup, inserts + path_inserts(methods))
20
21
  end
21
22
 
23
+ # The inserts for one site, by its kind: a case's patterns'
24
+ # stand-ins, a block method's call and block, or any other
25
+ # site's leaves' records and its leave.
26
+ def self.site_inserts(site, condition)
27
+ kind = condition.operator
28
+ return case_inserts(site, condition) if kind == 'case'
29
+
30
+ blocks = BLOCK_PATHS.key?(kind)
31
+ return block_inserts(site, condition) if blocks
32
+
33
+ record_inserts(site, condition) + trail_inserts(site, condition)
34
+ end
35
+
36
+ # The inserts that wrap a block method's call whole in a
37
+ # leave_items call that enters it first, inside any leave of
38
+ # a site it is the predicate of, and its block's expression,
39
+ # outside any site's leave there, in an item call, noting its
40
+ # last answer, or for a method that calls it with every item
41
+ # an answer call, noting every answer.
42
+ def self.block_inserts(site, condition)
43
+ _receiver, body = condition.leaves
44
+ enter = "PathCoverage.enter_items(#{site.inspect})"
45
+ call = "PathCoverage.leave_items(#{enter}, "
46
+ every = EVERY_ITEM.include?(condition.operator)
47
+ noter = every ? 'answer' : 'item'
48
+ item = "PathCoverage.#{noter}(#{site.inspect}, "
49
+ command = condition.commands.include?(body)
50
+ wrap(call, condition.extent, false, 0.5) +
51
+ wrap(item, body, command, 1.5)
52
+ end
53
+
54
+ # The inserts that wrap each pattern of a case site in a
55
+ # when_match call, its stand-in, which enters and leaves the
56
+ # site itself, as the case tries its patterns one by one.
57
+ def self.case_inserts(site, condition)
58
+ size = condition.leaves.size
59
+ condition.leaves.each_with_index.flat_map do |range, index|
60
+ call = "PathCoverage.when_match(#{site.inspect}, " \
61
+ "#{index}, #{size}, "
62
+ wrap(call, range, false, 0)
63
+ end
64
+ end
65
+
22
66
  # The inserts that wrap each of site's leaves in a record
23
67
  # call, innermost of all.
24
68
  def self.record_inserts(site, condition)
@@ -45,12 +89,14 @@ module PathCoverage
45
89
  wrap(call, nested ? wrapped.extent : wrapped, word, 1)
46
90
  end
47
91
 
48
- # What a site's leave call wraps: an if's predicate, a leaf's
49
- # byte range or a nested Condition, as its path is decided
50
- # there and its arms are statements that may return or
51
- # raise; any other site as a whole.
92
+ # What a site's leave call wraps: an if's or an unless's
93
+ # predicate, a leaf's byte range or a nested Condition, as
94
+ # its path is decided there and its arms are statements that
95
+ # may return or raise; any other site as a whole.
52
96
  def self.trailed(condition)
53
- return condition unless condition.operator == 'if'
97
+ unless STATEMENT_CHOICES.include?(condition.operator)
98
+ return condition
99
+ end
54
100
 
55
101
  condition.operands.first
56
102
  end
@@ -4,7 +4,12 @@ module PathCoverage
4
4
  module Site
5
5
  # "path:line:column" of node, eg "k.rb:2:2".
6
6
  def self.of(path, node)
7
- loc = node.location
7
+ at(path, node.location)
8
+ end
9
+
10
+ # "path:line:column" of where loc starts, eg an if's keyword,
11
+ # which a modifier if's statement comes before.
12
+ def self.at(path, loc)
8
13
  "#{path}:#{loc.start_line}:#{loc.start_column}"
9
14
  end
10
15
  end
@@ -6,27 +6,38 @@ module PathCoverage
6
6
  # order, nil for a leaf that evaluation never reaches.
7
7
  module SitePaths
8
8
  # Every possible path through site's condition, true
9
- # before false.
9
+ # before false; a stop-early block method's are fixed by its
10
+ # shape alone.
10
11
  def self.possible(site)
12
+ shape = BLOCK_PATHS[site.operator]
13
+ return shape if shape
14
+
11
15
  walk(site, site.fixed).map do |outcomes, _value|
12
16
  site.leaves.map { |range| outcomes[range] }
13
17
  end
14
18
  end
15
19
 
16
20
  # The outcome of an operand on which each operator goes on
17
- # to its next operand; any other ends the chain there.
21
+ # to its next operand; any other ends the chain there. A
22
+ # when's patterns, and a case's whens, stop at a match.
18
23
  CONTINUES_ON = {
19
- '&&' => true, 'and' => true, '||' => false, 'or' => false
24
+ '&&' => true, 'and' => true, '||' => false, 'or' => false,
25
+ 'when' => false, 'case' => false
20
26
  }.freeze
21
27
 
22
28
  # [[{ leaf range => outcome }, value], ...] for each way
23
29
  # node can be evaluated, value being the outcome of node as
24
30
  # a whole; fixed is the site's { leaf range => its one
25
- # outcome }.
31
+ # outcome }. A case is the chain of its whens' conditions.
26
32
  def self.walk(node, fixed)
27
33
  return leaf(node, fixed) unless node.is_a?(Condition)
28
34
  return ternary(node, fixed) if CHOICES.include?(node.operator)
29
35
 
36
+ if node.operator == 'case'
37
+ whens = node.operands.filter_map(&:body)
38
+ return chain(node.with(operands: whens), fixed)
39
+ end
40
+
30
41
  chain(node, fixed)
31
42
  end
32
43
 
@@ -47,14 +58,15 @@ module PathCoverage
47
58
  end
48
59
  end
49
60
 
50
- # The ways a ternary or an if can be evaluated: each way of
51
- # its predicate, then each way of the condition the arm it
52
- # picks is, if that arm is one; an if with no else arm
53
- # picks none. Its value is that of no choice, so is nil.
61
+ # The ways a ternary, an if or an unless can be evaluated:
62
+ # each way of its predicate, then each way of the condition
63
+ # the arm it picks is, if that arm is one; one with no else
64
+ # arm picks none. Its value is that of no choice, so is nil.
54
65
  def self.ternary(node, fixed)
55
66
  predicate, then_arm, else_arm = node.operands
67
+ then_on = THEN_ON[node.operator]
56
68
  walk(predicate, fixed).flat_map do |outcomes, value|
57
- body = (value ? then_arm : else_arm)&.body
69
+ body = (value == then_on ? then_arm : else_arm)&.body
58
70
  next [[outcomes, nil]] if body.nil?
59
71
 
60
72
  walk(body, fixed).map do |more, _|
@@ -24,14 +24,24 @@ module PathCoverage
24
24
  # The tree of condition, each leaf taking the next index of
25
25
  # leaves, so leaves pair with path entries left to right.
26
26
  def self.node(source, condition, outcomes, leaves)
27
- operands = condition.operands.map do |operand|
27
+ if condition.operator == 'case'
28
+ return case_arms(source, condition.operands, outcomes, leaves)
29
+ end
30
+
31
+ operands = operands_of(source, condition, outcomes, leaves)
32
+ arms(source, condition, operands, outcomes, leaves)
33
+ end
34
+
35
+ # condition's operands' nodes, leaves and nested conditions
36
+ # built in order, its arms left for arms to build after.
37
+ def self.operands_of(source, condition, outcomes, leaves)
38
+ condition.operands.map do |operand|
28
39
  case operand
29
40
  when Condition then node(source, operand, outcomes, leaves)
30
41
  when Arm then operand
31
42
  else leaf(source, operand, outcomes, leaves.next)
32
43
  end
33
44
  end
34
- arms(source, condition, operands, outcomes, leaves)
35
45
  end
36
46
 
37
47
  # condition's node from its operands' nodes, its ternary
@@ -42,21 +52,51 @@ module PathCoverage
42
52
  operands = operands.map do |operand|
43
53
  next operand unless operand.is_a?(Arm)
44
54
 
45
- arm(source, operand, predicate[:cells], outcomes, leaves)
55
+ decided = [predicate[:cells], THEN_ON[condition.operator]]
56
+ arm(source, operand, decided, outcomes, leaves)
46
57
  end
47
58
  cells = value(condition.operator, operands)
48
59
  { operator: condition.operator, cells:, operands: }
49
60
  end
50
61
 
51
- # One arm of a ternary or an if: its text, nil for an arm
52
- # with no statements, whether each column took it, from the
53
- # predicate's cells, and the tree of its condition.
54
- def self.arm(source, arm, decided, outcomes, leaves)
55
- picked = arm.kind == :then
62
+ # A case's node from its arms, built in order so its whens'
63
+ # patterns pair with path entries left to right: a when is
64
+ # taken on each path one of its patterns matched, and the
65
+ # else on each none did. A case's value is that of no
66
+ # choice, so is nil.
67
+ def self.case_arms(source, arms, outcomes, leaves)
68
+ matched = []
69
+ operands = arms.map do |arm|
70
+ body = arm.body && node(source, arm.body, outcomes, leaves)
71
+ matched << body[:cells].map { |cell| cell == true } if body
72
+ cells = body ? matched.last : unmatched(matched, outcomes)
73
+ { arm: arm.kind, text: text_of(source, arm), cells:, body: }
74
+ end
75
+ { operator: 'case', cells: Array.new(outcomes.size), operands: }
76
+ end
77
+
78
+ # An arm's text, nil for one with no statements.
79
+ def self.text_of(source, arm)
80
+ arm.extent && source.byteslice(arm.extent)
81
+ end
82
+
83
+ # Whether each column matched none of its whens' patterns.
84
+ def self.unmatched(matched, outcomes)
85
+ outcomes.each_index.map do |column|
86
+ matched.none? { |taken| taken[column] }
87
+ end
88
+ end
89
+
90
+ # One arm of a ternary, an if or an unless: its text, nil
91
+ # for an arm with no statements, whether each column took
92
+ # it, from the predicate's cells and the outcome on which
93
+ # its choice takes its then arm, and the tree of its
94
+ # condition.
95
+ def self.arm(source, arm, (decided, then_on), outcomes, leaves)
96
+ picked = arm.kind == :then ? then_on : !then_on
56
97
  body = arm.body && node(source, arm.body, outcomes, leaves)
57
- text = arm.extent && source.byteslice(arm.extent)
58
98
  {
59
- arm: arm.kind, text:,
99
+ arm: arm.kind, text: text_of(source, arm),
60
100
  cells: decided.map { |outcome| outcome == picked }, body:
61
101
  }
62
102
  end
@@ -1,28 +1,47 @@
1
+ require_relative 'totals'
2
+
1
3
  module PathCoverage
2
- # The per-file numbers the HTML index page shows.
4
+ # The per-file numbers the HTML index page shows, and their
5
+ # total over every file.
3
6
  module Summary
4
- # One row per file that has sites in operands, as
5
- # Result#operands gives them, in path order.
6
- def self.files(operands)
7
- by_path = operands.group_by { |site, _| path_of(site) }
8
- by_path.sort.map { |path, sites| row(path, sites) }
7
+ # One row per file that has sites in site_paths, as
8
+ # Result#paths gives them, in path order: its sites, and
9
+ # its decisions total. Its methods total is nil unless
10
+ # methods, as PathManifest.methods gives them, and paths, as
11
+ # the recorder counts them, are given, as they are when
12
+ # methods are measured.
13
+ def self.files(site_paths, methods = {}, paths = nil)
14
+ by_path = site_paths.group_by { |site, _| path_of(site) }
15
+ by_path.sort.map do |path, sites|
16
+ {
17
+ path:, sites: sites.size,
18
+ decisions: Totals.decisions(sites.to_h),
19
+ methods: paths && methods_of(path, methods, paths)
20
+ }
21
+ end
22
+ end
23
+
24
+ # The methods total of the file at path; [0, 0] for a file
25
+ # with no method of two or more decisions.
26
+ def self.methods_of(path, methods, paths)
27
+ own = methods.select { |site, _| path_of(site) == path }
28
+ Totals.methods(own, paths)
9
29
  end
10
30
 
11
- # The summary row of one file's sites.
12
- def self.row(path, sites)
13
- operands = sites.flat_map { |_, counts| counts }
14
- outcomes = operands.sum { |counts| counts[:only] ? 1 : 2 }
15
- seen = operands.sum { |counts| seen_in(counts) }
31
+ # The sum of each column over rows, a methods total being
32
+ # nil when the rows' are.
33
+ def self.total(rows)
34
+ methods = rows.map { |row| row[:methods] }
16
35
  {
17
- path:, sites: sites.size, seen:, outcomes:,
18
- percent: (100.0 * seen / outcomes).round,
19
- missing: outcomes - seen
36
+ sites: rows.sum { |row| row[:sites] },
37
+ decisions: add(rows.map { |row| row[:decisions] }),
38
+ methods: methods.first && add(methods)
20
39
  }
21
40
  end
22
41
 
23
- # How many of an operand's two outcomes were seen.
24
- def self.seen_in(counts)
25
- [counts[:truthy], counts[:falsy]].count(&:positive?)
42
+ # [taken, possible] pairs added together.
43
+ def self.add(pairs)
44
+ pairs.transpose.map(&:sum)
26
45
  end
27
46
 
28
47
  # The file a site is in: its name minus :line:column.
@@ -0,0 +1,29 @@
1
+ require_relative 'path_gaps'
2
+
3
+ module PathCoverage
4
+ # The two coverage totals, each [taken, possible] paths over
5
+ # every covered file. They count the very paths the gap
6
+ # lines list as never taken.
7
+ module Totals
8
+ # Paths within each decision: site_paths, as Result#paths
9
+ # gives them, lists every possible path of each site with
10
+ # its count; one with a count above zero was taken.
11
+ def self.decisions(site_paths)
12
+ every = site_paths.values.flatten
13
+ [every.count { |each| each[:count].positive? }, every.size]
14
+ end
15
+
16
+ # Paths across each method's decisions: methods, as
17
+ # PathManifest.methods gives them, each with every path
18
+ # through its decisions; paths, as the recorder counts
19
+ # them, holds the ones taken.
20
+ def self.methods(methods, paths)
21
+ methods.reduce([0, 0]) do |(taken, possible), (site, method)|
22
+ every = PathGaps.possible(method.decisions)
23
+ seen = paths.fetch(site, {})
24
+ [taken + every.count { |path| seen.key?(path) },
25
+ possible + every.size]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,3 +1,3 @@
1
1
  module PathCoverage
2
- VERSION = '0.0.2'.freeze
2
+ VERSION = '0.1.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: path_coverage
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Jagger
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-11-02 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -37,6 +37,7 @@ files:
37
37
  - exe/path_coverage_collector
38
38
  - lib/path_coverage.rb
39
39
  - lib/path_coverage/auto.rb
40
+ - lib/path_coverage/calls.rb
40
41
  - lib/path_coverage/collector.rb
41
42
  - lib/path_coverage/condition.rb
42
43
  - lib/path_coverage/configuration.rb
@@ -57,6 +58,8 @@ files:
57
58
  - lib/path_coverage/json_writer.rb
58
59
  - lib/path_coverage/kernel.rb
59
60
  - lib/path_coverage/manifest.rb
61
+ - lib/path_coverage/manifest_blocks.rb
62
+ - lib/path_coverage/manifest_case.rb
60
63
  - lib/path_coverage/manifest_choices.rb
61
64
  - lib/path_coverage/merge.rb
62
65
  - lib/path_coverage/path_gaps.rb
@@ -72,6 +75,7 @@ files:
72
75
  - lib/path_coverage/site_tree.rb
73
76
  - lib/path_coverage/source_lines.rb
74
77
  - lib/path_coverage/summary.rb
78
+ - lib/path_coverage/totals.rb
75
79
  - lib/path_coverage/version.rb
76
80
  homepage: https://github.com/cyber-dojo/path_coverage
77
81
  licenses: