simplecov 1.1.1 → 1.3.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 (200) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -5
  3. data/exe/simplecov +0 -3
  4. data/lib/minitest/simplecov_plugin.rb +12 -9
  5. data/lib/simplecov/atomic_file.rb +16 -19
  6. data/lib/simplecov/autostart.rb +5 -7
  7. data/lib/simplecov/baseline/parser.rb +58 -0
  8. data/lib/simplecov/baseline.rb +170 -0
  9. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  10. data/lib/simplecov/cli/affected/selection.rb +117 -0
  11. data/lib/simplecov/cli/affected.rb +148 -0
  12. data/lib/simplecov/cli/annotations.rb +166 -0
  13. data/lib/simplecov/cli/badge/svg.rb +70 -0
  14. data/lib/simplecov/cli/badge.rb +62 -0
  15. data/lib/simplecov/cli/clean.rb +23 -24
  16. data/lib/simplecov/cli/command_helpers.rb +55 -19
  17. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  18. data/lib/simplecov/cli/completions.rb +83 -0
  19. data/lib/simplecov/cli/coverage.rb +25 -28
  20. data/lib/simplecov/cli/coverage_file.rb +58 -14
  21. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  22. data/lib/simplecov/cli/dead_code.rb +139 -0
  23. data/lib/simplecov/cli/diff/output.rb +45 -0
  24. data/lib/simplecov/cli/diff.rb +35 -67
  25. data/lib/simplecov/cli/dotfile.rb +83 -38
  26. data/lib/simplecov/cli/git.rb +41 -0
  27. data/lib/simplecov/cli/history/output.rb +135 -0
  28. data/lib/simplecov/cli/history.rb +67 -0
  29. data/lib/simplecov/cli/merge.rb +34 -39
  30. data/lib/simplecov/cli/open.rb +12 -15
  31. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  32. data/lib/simplecov/cli/patch/output.rb +151 -0
  33. data/lib/simplecov/cli/patch.rb +175 -0
  34. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  35. data/lib/simplecov/cli/ratchet.rb +83 -0
  36. data/lib/simplecov/cli/report.rb +18 -24
  37. data/lib/simplecov/cli/run.rb +8 -9
  38. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  39. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  40. data/lib/simplecov/cli/serve.rb +27 -24
  41. data/lib/simplecov/cli/show/annotator.rb +87 -0
  42. data/lib/simplecov/cli/show/sweep.rb +36 -0
  43. data/lib/simplecov/cli/show.rb +133 -0
  44. data/lib/simplecov/cli/status/facts.rb +87 -0
  45. data/lib/simplecov/cli/status.rb +93 -0
  46. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  47. data/lib/simplecov/cli/tests.rb +148 -0
  48. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  49. data/lib/simplecov/cli/uncovered.rb +58 -34
  50. data/lib/simplecov/cli/usage.rb +153 -0
  51. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  52. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  53. data/lib/simplecov/cli/watch/poller.rb +45 -0
  54. data/lib/simplecov/cli/watch/session.rb +134 -0
  55. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  56. data/lib/simplecov/cli/watch.rb +85 -0
  57. data/lib/simplecov/cli.rb +43 -92
  58. data/lib/simplecov/color.rb +19 -37
  59. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  60. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  61. data/lib/simplecov/combine/identity_interner.rb +10 -13
  62. data/lib/simplecov/combine/interned_counts.rb +7 -9
  63. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  64. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  65. data/lib/simplecov/combine/results_combiner.rb +4 -21
  66. data/lib/simplecov/combine.rb +0 -6
  67. data/lib/simplecov/command_guesser.rb +20 -34
  68. data/lib/simplecov/configuration/baseline.rb +31 -0
  69. data/lib/simplecov/configuration/coverage.rb +91 -79
  70. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  71. data/lib/simplecov/configuration/deprecations.rb +21 -0
  72. data/lib/simplecov/configuration/eval_coverage.rb +7 -11
  73. data/lib/simplecov/configuration/filters.rb +40 -73
  74. data/lib/simplecov/configuration/formatting.rb +74 -83
  75. data/lib/simplecov/configuration/groups.rb +19 -13
  76. data/lib/simplecov/configuration/history.rb +44 -0
  77. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  78. data/lib/simplecov/configuration/merging.rb +94 -62
  79. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  80. data/lib/simplecov/configuration/production.rb +23 -0
  81. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  82. data/lib/simplecov/configuration/thresholds.rb +33 -62
  83. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  84. data/lib/simplecov/configuration.rb +70 -76
  85. data/lib/simplecov/configuration_error.rb +5 -0
  86. data/lib/simplecov/context_map/union.rb +81 -0
  87. data/lib/simplecov/context_map.rb +187 -0
  88. data/lib/simplecov/coverage_json.rb +2 -5
  89. data/lib/simplecov/coverage_statistics.rb +14 -24
  90. data/lib/simplecov/coverage_violations.rb +160 -65
  91. data/lib/simplecov/current_run.rb +61 -0
  92. data/lib/simplecov/defaults.rb +4 -23
  93. data/lib/simplecov/deprecation.rb +45 -27
  94. data/lib/simplecov/directive/erb.rb +44 -0
  95. data/lib/simplecov/directive/haml.rb +33 -0
  96. data/lib/simplecov/directive/indented_template.rb +37 -0
  97. data/lib/simplecov/directive/slim.rb +35 -0
  98. data/lib/simplecov/directive/template.rb +29 -0
  99. data/lib/simplecov/directive.rb +47 -62
  100. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  101. data/lib/simplecov/exit_codes/check.rb +17 -11
  102. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  103. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  104. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  105. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  106. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  107. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  109. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  110. data/lib/simplecov/exit_codes.rb +16 -10
  111. data/lib/simplecov/exit_handling.rb +94 -59
  112. data/lib/simplecov/file_list.rb +20 -51
  113. data/lib/simplecov/filter.rb +43 -66
  114. data/lib/simplecov/formatter/base.rb +37 -55
  115. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  116. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  117. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  118. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  119. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  120. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  121. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  122. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  123. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  124. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  125. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  126. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  127. data/lib/simplecov/formatter.rb +14 -10
  128. data/lib/simplecov/group_names.rb +13 -12
  129. data/lib/simplecov/history.rb +115 -0
  130. data/lib/simplecov/last_run.rb +6 -9
  131. data/lib/simplecov/lines_classifier.rb +31 -16
  132. data/lib/simplecov/load_global_config.rb +7 -7
  133. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  134. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  135. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  136. data/lib/simplecov/parallel_adapters.rb +24 -55
  137. data/lib/simplecov/parallel_coordination.rb +39 -49
  138. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  139. data/lib/simplecov/parallel_result_merger.rb +67 -126
  140. data/lib/simplecov/process.rb +14 -32
  141. data/lib/simplecov/production/error.rb +11 -0
  142. data/lib/simplecov/production/file_sink.rb +130 -0
  143. data/lib/simplecov/production.rb +249 -0
  144. data/lib/simplecov/profiles/rails.rb +4 -15
  145. data/lib/simplecov/profiles/root_filter.rb +0 -4
  146. data/lib/simplecov/profiles/strict.rb +11 -21
  147. data/lib/simplecov/profiles.rb +11 -33
  148. data/lib/simplecov/report_deferral.rb +11 -15
  149. data/lib/simplecov/report_stamp.rb +6 -9
  150. data/lib/simplecov/result/filter_config.rb +19 -0
  151. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  152. data/lib/simplecov/result/serialization.rb +43 -0
  153. data/lib/simplecov/result/source_file_builder.rb +7 -12
  154. data/lib/simplecov/result.rb +74 -117
  155. data/lib/simplecov/result_adapter.rb +56 -68
  156. data/lib/simplecov/result_merger/contexts.rb +22 -0
  157. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  158. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  159. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  160. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  161. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  162. data/lib/simplecov/result_merger.rb +59 -58
  163. data/lib/simplecov/result_processing.rb +92 -122
  164. data/lib/simplecov/run_identity.rb +16 -18
  165. data/lib/simplecov/simulate_coverage.rb +34 -50
  166. data/lib/simplecov/source_file/branch.rb +8 -33
  167. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  168. data/lib/simplecov/source_file/builder_context.rb +13 -13
  169. data/lib/simplecov/source_file/line.rb +13 -33
  170. data/lib/simplecov/source_file/line_builder.rb +9 -15
  171. data/lib/simplecov/source_file/method.rb +3 -7
  172. data/lib/simplecov/source_file/method_builder.rb +6 -15
  173. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  174. data/lib/simplecov/source_file/skip_chunks.rb +29 -29
  175. data/lib/simplecov/source_file/source_loader.rb +45 -35
  176. data/lib/simplecov/source_file/statistics.rb +20 -9
  177. data/lib/simplecov/source_file.rb +21 -51
  178. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +55 -139
  179. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  180. data/lib/simplecov/static_coverage_extractor/method_collector.rb +9 -26
  181. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  182. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  183. data/lib/simplecov/static_coverage_extractor/visitor.rb +45 -65
  184. data/lib/simplecov/static_coverage_extractor.rb +43 -75
  185. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  186. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  187. data/lib/simplecov/test_tracker/delta.rb +69 -0
  188. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  189. data/lib/simplecov/test_tracker.rb +162 -0
  190. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  191. data/lib/simplecov/useless_results_remover.rb +7 -6
  192. data/lib/simplecov/version.rb +1 -1
  193. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  194. data/lib/simplecov/view_coverage.rb +55 -0
  195. data/lib/simplecov-html.rb +1 -0
  196. data/lib/simplecov.rb +57 -113
  197. data/lib/simplecov_json_formatter.rb +1 -0
  198. data/man/simplecov.1 +354 -0
  199. data/sig/simplecov.rbs +528 -595
  200. metadata +79 -6
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module History
6
+ # Renders the trend: Unicode sparklines scaled to each series' own range,
7
+ # with the numbers beside them carrying the absolute scale.
8
+ module Output
9
+ BARS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
10
+
11
+ CRITERIA_ORDER = %w[line branch method].freeze
12
+
13
+ extend self
14
+
15
+ def emit(stdout, opts, entries, color:)
16
+ return emit_json(stdout, opts, entries) if opts.fetch(:json)
17
+ return stdout.puts("simplecov history: no recorded runs in #{opts.fetch(:input)}") if entries.empty?
18
+
19
+ if opts.fetch(:file)
20
+ emit_file(stdout, opts, entries, color)
21
+ else
22
+ emit_totals(stdout, opts, entries, color)
23
+ end
24
+ end
25
+
26
+ def emit_totals(stdout, opts, entries, color)
27
+ stdout.puts("Coverage history: #{opts.fetch(:input)} (#{pluralize(entries.length, "run")})")
28
+ stdout.puts
29
+ emit_criteria_views(stdout, entries, color, "totals")
30
+ end
31
+
32
+ def emit_file(stdout, opts, entries, color)
33
+ file = opts.fetch(:file)
34
+ stdout.puts("Coverage history for #{file} (#{pluralize(entries.length, "run")})")
35
+ stdout.puts
36
+ emit_criteria_views(stdout, entries, color, "files", file)
37
+ end
38
+
39
+ def emit_criteria_views(stdout, entries, color, *path)
40
+ criteria = measured_criteria(entries, path)
41
+ emit_sparklines(stdout, entries, criteria, color, path)
42
+ stdout.puts
43
+ emit_rows(stdout, entries) { |entry| percents_cell(entry, criteria, path) }
44
+ end
45
+
46
+ def emit_sparklines(stdout, entries, criteria, color, path)
47
+ width = criteria.map(&:length).max
48
+ criteria.each do |criterion|
49
+ series = entries.map { |entry| numeric(entry.dig(*path, criterion)) }
50
+ stdout.puts(" #{criterion.ljust(width)} #{sparkline(series)} #{trend(series, color)}")
51
+ end
52
+ end
53
+
54
+ def emit_rows(stdout, entries)
55
+ width = entries.map { |entry| branch_of(entry).length }.max
56
+ entries.each do |entry|
57
+ commit = (entry["commit"] || "-")[0, 7].ljust(7)
58
+ stdout.puts(" #{entry["created_at"]} #{branch_of(entry).ljust(width)} #{commit} #{yield(entry)}")
59
+ end
60
+ end
61
+
62
+ def branch_of(entry)
63
+ entry["branch"] || "-"
64
+ end
65
+
66
+ def percents_cell(entry, criteria, path)
67
+ cell = criteria.filter_map do |criterion|
68
+ value = numeric(entry.dig(*path, criterion))
69
+ "#{criterion} #{value}%" if value
70
+ end.join(" ")
71
+ cell.empty? ? "-" : cell
72
+ end
73
+
74
+ # Min-max scaled block characters, one per run, a space where the series has
75
+ # no value for that run. A flat series renders at mid height: direction of
76
+ # travel is the message, and a flat line says it plainly. A series with no
77
+ # numbers at all is all gaps, and answering that outright keeps the scale off
78
+ # a range it doesn't have.
79
+ def sparkline(series)
80
+ numeric = series.compact
81
+ return " " * series.length if numeric.empty?
82
+
83
+ min = numeric.min
84
+ span = numeric.max - min.to_f
85
+ series.map { |value| value.nil? ? " " : bar(value, min, span) }.join
86
+ end
87
+
88
+ def bar(value, min, span)
89
+ span.zero? ? BARS.fetch(3) : BARS.fetch(((value - min) / span * (BARS.length - 1)).round)
90
+ end
91
+
92
+ def trend(series, color)
93
+ numeric = series.compact
94
+ first = numeric.first
95
+ last = numeric.last
96
+ delta = (last - first).round(2)
97
+ sign = "+" unless delta.negative?
98
+ delta_text = Color.colorize("(#{sign}#{delta})", delta.negative? ? :red : :green, enabled: color)
99
+ "#{first}% → #{last}% #{delta_text}"
100
+ end
101
+
102
+ def emit_json(stdout, opts, entries)
103
+ return stdout.puts(JSON.generate(entries)) unless opts.fetch(:file)
104
+
105
+ rows = entries.map do |entry|
106
+ percents = entry.dig("files", opts.fetch(:file))
107
+ {created_at: entry["created_at"], branch: entry["branch"], commit: entry["commit"],
108
+ percents: (percents if percents.instance_of?(Hash))}
109
+ end
110
+ stdout.puts(JSON.generate(rows))
111
+ end
112
+
113
+ # Criteria some run recorded a number for, in canonical order, so a criterion
114
+ # enabled midway still gets its sparkline. One no run recorded is left out:
115
+ # an all-gaps sparkline has no trend to read off it.
116
+ def measured_criteria(entries, path)
117
+ CRITERIA_ORDER.select do |criterion|
118
+ entries.any? do |entry|
119
+ percents = entry.dig(*path)
120
+ percents.instance_of?(Hash) && numeric(percents[criterion])
121
+ end
122
+ end
123
+ end
124
+
125
+ def numeric(value)
126
+ value if value.is_a?(Numeric)
127
+ end
128
+
129
+ def pluralize(number, noun)
130
+ "#{number} #{noun}#{"s" unless number.eql?(1)}"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_helpers"
4
+ require_relative "history/output"
5
+ require_relative "../history"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ # `simplecov history`: the recorded coverage trend in the terminal, a
10
+ # sparkline per measured criterion with the run rows beneath, and
11
+ # `--file PATH` for one file's trajectory instead of the totals.
12
+ module History
13
+ extend CommandHelpers
14
+
15
+ extend self
16
+
17
+ def run(args, stdout:, stderr:, **)
18
+ opts = parse(args, stderr) or return 1
19
+ entries = load_entries(opts.fetch(:input), stderr) or return 1
20
+ return 1 unless file_recorded?(entries, opts, stderr)
21
+
22
+ Output.emit(stdout, opts, entries, color: CLI.color_enabled?(opts, stdout))
23
+ 0
24
+ end
25
+
26
+ def parse(args, stderr)
27
+ opts, rest = parse_common(args, input: default_input, file: nil) do |parser, options|
28
+ parser.on("--file PATH") { |v| options[:file] = v }
29
+ end
30
+ return error_nil(stderr, "unexpected argument #{rest.first.inspect}") unless rest.empty?
31
+
32
+ opts
33
+ end
34
+
35
+ # The history file, not coverage.json: this command reads the sibling
36
+ # artifact the exit tasks append to.
37
+ def default_input
38
+ File.join(CLI.coverage_dir, ".history.json")
39
+ end
40
+
41
+ def load_entries(path, stderr)
42
+ document = JSON.parse(File.read(path))
43
+ entries = document.dig(SimpleCov::History::ENVELOPE, "entries") if document.instance_of?(Hash)
44
+ return entries if entries.instance_of?(Array)
45
+
46
+ error_nil(stderr, "#{path} is not a SimpleCov history file")
47
+ rescue Errno::ENOENT
48
+ error_nil(stderr, "#{path} not found (the history is recorded automatically each time a suite reports)")
49
+ rescue JSON::ParserError => e
50
+ error_nil(stderr, "#{path} is not valid JSON (#{e.message.lines.first.to_s.strip})")
51
+ rescue SystemCallError => e
52
+ error_nil(stderr, "#{path} could not be read (#{e})")
53
+ end
54
+
55
+ # A `--file` for a path no entry recorded deserves a loud answer, not an
56
+ # empty sparkline that reads as "0% forever".
57
+ def file_recorded?(entries, opts, stderr)
58
+ file = opts.fetch(:file)
59
+ return true unless file
60
+ return true if entries.any? { |entry| entry.instance_of?(Hash) && entry.dig("files", file).instance_of?(Hash) }
61
+
62
+ error(stderr, "no recorded coverage for #{file} in #{opts.fetch(:input)}")
63
+ false
64
+ end
65
+ end
66
+ end
67
+ end
@@ -7,59 +7,56 @@ require_relative "command_helpers"
7
7
 
8
8
  module SimpleCov
9
9
  module CLI
10
- # `simplecov merge <files...>` wrap SimpleCov::ResultMerger so a
11
- # CI matrix that produces one .resultset.json per worker can stitch
12
- # them together from the shell instead of dropping a Rake task into
13
- # every project. Requires the full simplecov library to be on the
14
- # load path; lazy-required so the read-only subcommands above don't
15
- # pay for ResultMerger (and its Coverage runtime guard).
10
+ # `simplecov merge <files...>`: wraps `SimpleCov::ResultMerger` so a CI
11
+ # matrix that produces one .resultset.json per worker can stitch them
12
+ # together from the shell instead of dropping a Rake task into every project.
16
13
  module Merge
17
14
  extend CommandHelpers
18
15
 
19
- module_function
16
+ extend self
20
17
 
21
18
  def run(args, stdout:, stderr:, **)
22
19
  opts = parse(args)
23
- return error(stderr, "missing input files") if opts[:files].empty?
24
- return 1 unless valid_inputs?(opts[:files], stderr)
20
+ return error(stderr, "missing input files") if opts.fetch(:files).empty?
25
21
 
26
- require "simplecov"
27
- result = SimpleCov::ResultMerger.merge_results(*opts[:files], ignore_timeout: !opts[:honor_timeout])
22
+ parsed = parse_inputs(opts.fetch(:files), stderr)
23
+ return 1 unless parsed
24
+
25
+ warn_about_duplicate_command_names(parsed, stderr)
26
+ result = result_merger.merge_results(*opts.fetch(:files), ignore_timeout: !opts.fetch(:honor_timeout))
28
27
  return error(stderr, "no mergeable results in input files") unless result
29
28
 
30
29
  commit(opts, result, stdout)
31
30
  0
32
31
  end
33
32
 
34
- def commit(opts, result, stdout)
35
- verb = opts[:dry_run] ? "would write" : "wrote"
36
- write(opts[:output], result) unless opts[:dry_run]
37
- stdout.puts("simplecov merge: #{verb} #{opts[:output]}") unless opts[:quiet]
33
+ # Loaded here rather than at the top of the file, so the read-only
34
+ # subcommands never pay for ResultMerger and its Coverage runtime guard.
35
+ def result_merger
36
+ require "simplecov"
37
+ ResultMerger
38
38
  end
39
39
 
40
- def valid_inputs?(files, stderr)
41
- parsed = parse_inputs(files, stderr) or return false
42
-
43
- warn_about_duplicate_command_names(parsed, stderr)
44
- true
40
+ def commit(opts, result, stdout)
41
+ verb = opts.fetch(:dry_run) ? "would write" : "wrote"
42
+ write(opts.fetch(:output), result) unless opts.fetch(:dry_run)
43
+ stdout.puts("simplecov merge: #{verb} #{opts.fetch(:output)}") unless opts.fetch(:quiet)
45
44
  end
46
45
 
47
46
  def parse(args)
48
- opts = {output: SimpleCov::CLI.default_resultset, honor_timeout: false, dry_run: false, quiet: false}
47
+ opts = {output: CLI.default_resultset, honor_timeout: false, dry_run: false, quiet: false}
49
48
  files =
50
- OptionParser.new do |o|
49
+ build_parser do |o|
51
50
  o.on("--output PATH") { |v| opts[:output] = v }
52
51
  o.on("--honor-timeout") { opts[:honor_timeout] = true }
53
52
  o.on("--dry-run") { opts[:dry_run] = true }
54
- o.on("-q", "--quiet") { opts[:quiet] = true }
53
+ quiet_option(o, opts)
55
54
  end.parse(args)
56
55
  opts.merge(files: files)
57
56
  end
58
57
 
59
- # Validate every input file up-front and return a {path => parsed}
60
- # hash. Surfacing per-file errors here turns ResultMerger's
61
- # generic "no mergeable results" into a message that points at
62
- # the specific input causing the failure.
58
+ # Validating every input up front turns ResultMerger's generic "no mergeable
59
+ # results" into a message that points at the specific input causing it.
63
60
  def parse_inputs(files, stderr)
64
61
  parsed = {} #: Hash[String, Hash[String, untyped]]
65
62
  files.each_with_object(parsed) do |path, memo|
@@ -69,31 +66,29 @@ module SimpleCov
69
66
  end
70
67
  end
71
68
 
72
- # Read first, classify by the exception: an exist?-then-read pair
73
- # is racy, and rescuing only ENOENT crashed with a backtrace on a
74
- # directory (EISDIR) or an unreadable file (EACCES).
69
+ # Read first and classify by the exception: an exist?-then-read pair is racy,
70
+ # and rescuing only ENOENT crashed with a backtrace on a directory or an
71
+ # unreadable file.
75
72
  def parse_input(path, stderr)
76
73
  data = JSON.parse(File.read(path))
77
- return data if data.is_a?(Hash) && !data.empty?
74
+ return data if data.instance_of?(Hash) && !data.empty?
78
75
 
79
76
  parse_input_error(stderr, path, "has no resultset entries")
80
77
  rescue Errno::ENOENT
81
78
  parse_input_error(stderr, path, "not found")
82
79
  rescue JSON::ParserError => e
83
- parse_input_error(stderr, path, "isn't valid JSON (#{e.message})")
80
+ parse_input_error(stderr, path, "isn't valid JSON (#{e})")
84
81
  rescue SystemCallError => e
85
- parse_input_error(stderr, path, "cannot be read (#{e.message.lines.first.to_s.strip})")
82
+ parse_input_error(stderr, path, "cannot be read (#{e.message.lines.first.to_s.rstrip})")
86
83
  end
87
84
 
88
85
  def parse_input_error(stderr, path, reason)
89
86
  stderr.puts("simplecov merge: input file #{path.inspect} #{reason}")
90
- nil
91
87
  end
92
88
 
93
- # When two input files share a command_name, ResultMerger folds
94
- # them together with last-write-wins on the timestamp easy to
95
- # mistake for "no merge happened." Surface the overlap so the
96
- # operator can rename the workers or accept the merge knowingly.
89
+ # ResultMerger folds entries that share a command_name together with
90
+ # last-write-wins on the timestamp, which is easy to mistake for "no merge
91
+ # happened", so the overlap is surfaced.
97
92
  def warn_about_duplicate_command_names(parsed, stderr)
98
93
  files_per_command = {} #: Hash[String, Array[String]]
99
94
  parsed.each do |path, data|
@@ -108,7 +103,7 @@ module SimpleCov
108
103
 
109
104
  def duplicate_warning(command_name, paths)
110
105
  "simplecov merge: warning — command_name #{command_name.inspect} " \
111
- "appears in #{paths.size} input files (#{paths.join(', ')}); " \
106
+ "appears in #{paths.size} input files (#{paths.join(", ")}); " \
112
107
  "entries will be merged"
113
108
  end
114
109
 
@@ -5,42 +5,39 @@ require_relative "command_helpers"
5
5
 
6
6
  module SimpleCov
7
7
  module CLI
8
- # `simplecov open [--report PATH]` — open the HTML report in the
9
- # platform's default browser. Tiny QoL wrapper around `xdg-open` /
10
- # `open` / `start` so users don't have to type a file:// URL.
11
8
  module Open
12
9
  extend CommandHelpers
13
10
 
14
- module_function
11
+ extend self
15
12
 
16
13
  def run(args, stderr:, **)
17
14
  path = parse(args)
18
15
  return error(stderr, "#{path} not found") unless File.exist?(path)
19
16
 
20
17
  opener = browser_opener
21
- return error(stderr, "no known opener for #{RbConfig::CONFIG['host_os']}") unless opener
18
+ return error(stderr, "no known opener for #{RbConfig::CONFIG.fetch("host_os")}") unless opener
22
19
 
23
20
  system(*opener, path) ? 0 : 1
24
21
  end
25
22
 
26
23
  def parse(args)
27
- path = SimpleCov::CLI.default_report
28
- OptionParser.new do |o|
24
+ path = CLI.default_report
25
+ build_parser do |o|
29
26
  o.on("--report PATH") { |v| path = v }
30
27
  end.parse(args)
31
28
  path
32
29
  end
33
30
 
34
- # Returns the argv for the platform's "open this file" command, or
35
- # nil if the host OS isn't recognized. On Windows, `start` is a
36
- # cmd.exe builtin (not an executable), so route through `cmd /c`;
37
- # the empty string is the window-title positional `start` takes
38
- # before the path so a quoted path isn't mis-parsed as the title.
31
+ # The argv for the platform's "open this file" command, or nil if the host OS
32
+ # isn't recognized. On Windows, `start` is a cmd.exe builtin rather than an
33
+ # executable, so it routes through `cmd /c`; the empty string is the
34
+ # window-title positional `start` takes before the path, so a quoted path
35
+ # isn't mis-parsed as the title.
39
36
  def browser_opener
40
- case RbConfig::CONFIG["host_os"]
41
- when /darwin/ then ["open"]
37
+ case RbConfig::CONFIG.fetch("host_os")
38
+ when /darwin/ then ["open"]
42
39
  when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", ""]
43
- when /linux|bsd|solaris/ then ["xdg-open"]
40
+ when /linux|bsd|solaris/ then ["xdg-open"]
44
41
  end
45
42
  end
46
43
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../git"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ module Patch
8
+ # Run `git diff --unified=0` against a base and parse the result into
9
+ # `{root:, changes: {path => [added line numbers] | :all}}`, where
10
+ # `root` is the repository toplevel the paths are relative to and
11
+ # `:all` marks an untracked file, whose every line is new.
12
+ module ChangedLines
13
+ # A `git diff --unified=0` hunk header. Only the new-file side is
14
+ # captured: removed lines cannot be covered.
15
+ HUNK_HEADER = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
16
+
17
+ UNQUOTE = {
18
+ "\\" => "\\", '"' => '"', "n" => "\n", "t" => "\t", "r" => "\r",
19
+ "a" => "\a", "b" => "\b", "f" => "\f", "v" => "\v"
20
+ }.freeze
21
+
22
+ extend self
23
+
24
+ # nil signals "could not diff" (already reported); empty changes are a
25
+ # valid result meaning the change touched no lines at all. Everything
26
+ # runs against the repository toplevel rather than the cwd, because
27
+ # `--relative` from a subdirectory would exclude every change outside
28
+ # that subdirectory from a `--minimum` gate.
29
+ def call(base, find_renames:, stderr:)
30
+ root = git_toplevel
31
+ return report_git_error(stderr, base, nil) unless root
32
+
33
+ output, detail = git_diff(root, base, find_renames: find_renames)
34
+ return report_git_error(stderr, base, detail) unless output
35
+
36
+ # `scrub` keeps a non-UTF-8 content line from blowing up the regexp
37
+ # parse; hunk headers are pure ASCII.
38
+ changes = parse_diff(output.scrub) #: Hash[String, untyped]
39
+ untracked_files(root).each { |path| changes[path] = :all }
40
+ {root: root, changes: changes}
41
+ end
42
+
43
+ def git_toplevel
44
+ Git.toplevel
45
+ end
46
+
47
+ # `--merge-base <base>` diffs the merge base of <base> and the working
48
+ # tree against the working tree: the change reads as its own work even
49
+ # when <base> has moved on independently, and uncommitted edits count
50
+ # too. The rest pins the output against a user's git config so it can't
51
+ # skew the numbers, run code, or throw off the parse: `--no-ext-diff` /
52
+ # `--no-textconv` (either runs a configured command, and the latter also
53
+ # renumbers lines), `--no-color`, `core.quotePath=false` (emit non-ASCII
54
+ # paths literally, so they still match report keys),
55
+ # `--inter-hunk-context=0` (never merge hunks over unchanged lines,
56
+ # which would score those lines as touched), fixed `a/`/`b/` prefixes
57
+ # (so the `diff_path` strip can't be fooled by `diff.noprefix`), and
58
+ # `--no-renames` unless asked (so a moved file reads as all-new).
59
+ # stderr is kept out of the parsed stream and its first line returned as
60
+ # the failure detail, so "unknown revision" reaches the user.
61
+ def git_diff(root, base, find_renames:)
62
+ # Refusing an option-like ref keeps a `--base` value from being read by
63
+ # git as an option instead of a revision (`--output=FILE` writes to
64
+ # disk, `--line-prefix=` empties the diff so a `--minimum` gate passes).
65
+ return [nil, "a ref cannot begin with \"-\""] if Git.option_like_ref?(base)
66
+
67
+ argv = ["-C", root, "-c", "core.quotePath=false", "diff", "--unified=0",
68
+ "--no-color", "--no-ext-diff", "--no-textconv", "--inter-hunk-context=0",
69
+ "--dst-prefix=b/",
70
+ find_renames ? "--find-renames" : "--no-renames", "--merge-base", base, "--"]
71
+ stdout, detail, success = Git.capture(*argv)
72
+ [(stdout if success), detail]
73
+ end
74
+
75
+ # A brand-new file that was never `git add`ed appears in no diff, but its
76
+ # lines are this change's lines all the same, and skipping it would pass
77
+ # a `--minimum` gate over entirely unscored new code. A failure here
78
+ # contributes nothing rather than failing the run.
79
+ def untracked_files(root)
80
+ stdout, _detail, success = Git.capture("-C", root, "ls-files", "--others", "--exclude-standard", "-z")
81
+ success ? (_ = stdout).scrub.split("\0") : []
82
+ end
83
+
84
+ def report_git_error(stderr, base, detail)
85
+ reason = detail.to_s.empty? ? "is this a git working tree, and does the ref exist?" : detail
86
+ stderr.puts("simplecov patch: could not run `git diff` against #{base.inspect} (#{reason})")
87
+ end
88
+
89
+ # Split into per-file sections first so a file's `+++` header, its first,
90
+ # before any hunk, is what names the path. Inside a hunk an added line is
91
+ # itself `+`-prefixed, so a touched line whose own text begins with
92
+ # `++ ` renders as `+++ ...`; reading only the section's first `+++`
93
+ # keeps that content line from standing in as the header.
94
+ def parse_diff(output)
95
+ changes = {} #: Hash[String, Array[Integer]]
96
+ sections = output.split(/^(?=diff --git )/)
97
+ sections.each do |section|
98
+ path = section_path(section) or next
99
+ section.each_line do |line|
100
+ match = HUNK_HEADER.match(line)
101
+ record_hunk(changes, path, match) if match
102
+ end
103
+ end
104
+ changes
105
+ end
106
+
107
+ # `+++ /dev/null` (a deletion) yields nil.
108
+ def section_path(section)
109
+ header = section[/^\+\+\+ .+/]
110
+ header && diff_path(header)
111
+ end
112
+
113
+ def record_hunk(changes, path, match)
114
+ start = Integer(match[1])
115
+ count = match[2] ? Integer(match[2]) : 1
116
+ return if count.zero? # a pure-deletion hunk adds nothing
117
+
118
+ (changes[path] ||= []).concat((start...(start + count)).to_a)
119
+ end
120
+
121
+ def diff_path(line)
122
+ raw = unquote(line[4..].to_s.chomp)
123
+ # git's own literal token for an absent side, not this host's null
124
+ # device, so File::NULL (which is "NUL" on Windows) would be wrong.
125
+ return nil if raw.eql?("/dev/null") # rubocop:disable Style/FileNull
126
+
127
+ raw.sub(%r{\A[ab]/}, "")
128
+ end
129
+
130
+ # A path carrying a quote, backslash, or control character is emitted as
131
+ # `"b/lib/we\"ird.rb"` even under `core.quotePath=false` (which only
132
+ # covers non-ASCII), and left quoted it matches no coverage key.
133
+ def unquote(raw)
134
+ return raw unless raw.length >= 2 && raw.start_with?('"') && raw.end_with?('"')
135
+
136
+ # Unescaped as raw bytes: an octal escape is a bare byte, and mixing one
137
+ # into a UTF-8 string would raise mid-gsub.
138
+ unquoted = (_ = raw.b[1..-2]).gsub(/\\(?:[0-7]{1,3}|.)/) { |escape| unescape(escape) }
139
+ unquoted.force_encoding(raw.encoding).scrub
140
+ end
141
+
142
+ def unescape(escape)
143
+ body = _ = escape[1..]
144
+ body.match?(/\A[0-7]/) ? Integer(body, 8).chr : UNQUOTE.fetch(body, body)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end