simplecov 1.1.1 → 1.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 (194) 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/badge/svg.rb +70 -0
  13. data/lib/simplecov/cli/badge.rb +62 -0
  14. data/lib/simplecov/cli/clean.rb +23 -24
  15. data/lib/simplecov/cli/command_helpers.rb +55 -19
  16. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  17. data/lib/simplecov/cli/completions.rb +83 -0
  18. data/lib/simplecov/cli/coverage.rb +25 -28
  19. data/lib/simplecov/cli/coverage_file.rb +58 -14
  20. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  21. data/lib/simplecov/cli/dead_code.rb +139 -0
  22. data/lib/simplecov/cli/diff/output.rb +45 -0
  23. data/lib/simplecov/cli/diff.rb +35 -67
  24. data/lib/simplecov/cli/dotfile.rb +83 -38
  25. data/lib/simplecov/cli/git.rb +41 -0
  26. data/lib/simplecov/cli/history/output.rb +135 -0
  27. data/lib/simplecov/cli/history.rb +67 -0
  28. data/lib/simplecov/cli/merge.rb +34 -39
  29. data/lib/simplecov/cli/open.rb +12 -15
  30. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  31. data/lib/simplecov/cli/patch/output.rb +135 -0
  32. data/lib/simplecov/cli/patch.rb +169 -0
  33. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  34. data/lib/simplecov/cli/ratchet.rb +83 -0
  35. data/lib/simplecov/cli/report.rb +18 -24
  36. data/lib/simplecov/cli/run.rb +8 -9
  37. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  38. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  39. data/lib/simplecov/cli/serve.rb +27 -24
  40. data/lib/simplecov/cli/show/annotator.rb +87 -0
  41. data/lib/simplecov/cli/show/sweep.rb +36 -0
  42. data/lib/simplecov/cli/show.rb +133 -0
  43. data/lib/simplecov/cli/status/facts.rb +87 -0
  44. data/lib/simplecov/cli/status.rb +93 -0
  45. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  46. data/lib/simplecov/cli/tests.rb +148 -0
  47. data/lib/simplecov/cli/uncovered/misses.rb +37 -0
  48. data/lib/simplecov/cli/uncovered.rb +70 -32
  49. data/lib/simplecov/cli/usage.rb +152 -0
  50. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  51. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  52. data/lib/simplecov/cli/watch/poller.rb +45 -0
  53. data/lib/simplecov/cli/watch/session.rb +134 -0
  54. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  55. data/lib/simplecov/cli/watch.rb +85 -0
  56. data/lib/simplecov/cli.rb +43 -92
  57. data/lib/simplecov/color.rb +19 -37
  58. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  59. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  60. data/lib/simplecov/combine/identity_interner.rb +10 -13
  61. data/lib/simplecov/combine/interned_counts.rb +7 -9
  62. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  63. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  64. data/lib/simplecov/combine/results_combiner.rb +4 -21
  65. data/lib/simplecov/combine.rb +0 -6
  66. data/lib/simplecov/command_guesser.rb +20 -34
  67. data/lib/simplecov/configuration/baseline.rb +31 -0
  68. data/lib/simplecov/configuration/coverage.rb +91 -79
  69. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  70. data/lib/simplecov/configuration/deprecations.rb +21 -0
  71. data/lib/simplecov/configuration/eval_coverage.rb +6 -10
  72. data/lib/simplecov/configuration/filters.rb +40 -73
  73. data/lib/simplecov/configuration/formatting.rb +74 -83
  74. data/lib/simplecov/configuration/groups.rb +19 -13
  75. data/lib/simplecov/configuration/history.rb +44 -0
  76. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  77. data/lib/simplecov/configuration/merging.rb +94 -62
  78. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  79. data/lib/simplecov/configuration/production.rb +23 -0
  80. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  81. data/lib/simplecov/configuration/thresholds.rb +33 -62
  82. data/lib/simplecov/configuration/view_coverage.rb +38 -0
  83. data/lib/simplecov/configuration.rb +70 -76
  84. data/lib/simplecov/configuration_error.rb +5 -0
  85. data/lib/simplecov/context_map/union.rb +81 -0
  86. data/lib/simplecov/context_map.rb +187 -0
  87. data/lib/simplecov/coverage_json.rb +2 -5
  88. data/lib/simplecov/coverage_statistics.rb +14 -24
  89. data/lib/simplecov/coverage_violations.rb +157 -64
  90. data/lib/simplecov/current_run.rb +61 -0
  91. data/lib/simplecov/defaults.rb +4 -23
  92. data/lib/simplecov/deprecation.rb +45 -27
  93. data/lib/simplecov/directive.rb +46 -62
  94. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  95. data/lib/simplecov/exit_codes/check.rb +17 -11
  96. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  97. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  98. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  99. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  100. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  101. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  102. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  103. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  104. data/lib/simplecov/exit_codes.rb +16 -10
  105. data/lib/simplecov/exit_handling.rb +94 -59
  106. data/lib/simplecov/file_list.rb +20 -51
  107. data/lib/simplecov/filter.rb +43 -66
  108. data/lib/simplecov/formatter/base.rb +37 -55
  109. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  110. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  111. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  112. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  113. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  114. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  115. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  116. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  117. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  118. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  119. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  120. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  121. data/lib/simplecov/formatter.rb +14 -10
  122. data/lib/simplecov/group_names.rb +13 -12
  123. data/lib/simplecov/history.rb +115 -0
  124. data/lib/simplecov/last_run.rb +6 -9
  125. data/lib/simplecov/lines_classifier.rb +31 -16
  126. data/lib/simplecov/load_global_config.rb +7 -7
  127. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  128. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  129. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  130. data/lib/simplecov/parallel_adapters.rb +24 -55
  131. data/lib/simplecov/parallel_coordination.rb +39 -49
  132. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  133. data/lib/simplecov/parallel_result_merger.rb +67 -126
  134. data/lib/simplecov/process.rb +14 -32
  135. data/lib/simplecov/production/error.rb +11 -0
  136. data/lib/simplecov/production/file_sink.rb +130 -0
  137. data/lib/simplecov/production.rb +249 -0
  138. data/lib/simplecov/profiles/rails.rb +1 -12
  139. data/lib/simplecov/profiles/root_filter.rb +0 -4
  140. data/lib/simplecov/profiles/strict.rb +9 -19
  141. data/lib/simplecov/profiles.rb +11 -33
  142. data/lib/simplecov/report_deferral.rb +11 -15
  143. data/lib/simplecov/report_stamp.rb +6 -9
  144. data/lib/simplecov/result/filter_config.rb +19 -0
  145. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  146. data/lib/simplecov/result/serialization.rb +43 -0
  147. data/lib/simplecov/result/source_file_builder.rb +7 -12
  148. data/lib/simplecov/result.rb +68 -117
  149. data/lib/simplecov/result_adapter.rb +56 -68
  150. data/lib/simplecov/result_merger/contexts.rb +22 -0
  151. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  152. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  153. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  154. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  155. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  156. data/lib/simplecov/result_merger.rb +59 -58
  157. data/lib/simplecov/result_processing.rb +74 -119
  158. data/lib/simplecov/run_identity.rb +16 -18
  159. data/lib/simplecov/simulate_coverage.rb +34 -50
  160. data/lib/simplecov/source_file/branch.rb +8 -33
  161. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  162. data/lib/simplecov/source_file/builder_context.rb +6 -13
  163. data/lib/simplecov/source_file/line.rb +13 -33
  164. data/lib/simplecov/source_file/line_builder.rb +9 -15
  165. data/lib/simplecov/source_file/method.rb +3 -7
  166. data/lib/simplecov/source_file/method_builder.rb +6 -15
  167. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  168. data/lib/simplecov/source_file/skip_chunks.rb +19 -28
  169. data/lib/simplecov/source_file/source_loader.rb +45 -35
  170. data/lib/simplecov/source_file/statistics.rb +10 -9
  171. data/lib/simplecov/source_file.rb +21 -51
  172. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
  173. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  174. data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
  175. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  176. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  177. data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
  178. data/lib/simplecov/static_coverage_extractor.rb +46 -62
  179. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  180. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  181. data/lib/simplecov/test_tracker/delta.rb +69 -0
  182. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  183. data/lib/simplecov/test_tracker.rb +162 -0
  184. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  185. data/lib/simplecov/useless_results_remover.rb +7 -6
  186. data/lib/simplecov/version.rb +1 -1
  187. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  188. data/lib/simplecov/view_coverage.rb +55 -0
  189. data/lib/simplecov-html.rb +1 -0
  190. data/lib/simplecov.rb +57 -113
  191. data/lib/simplecov_json_formatter.rb +1 -0
  192. data/man/simplecov.1 +349 -0
  193. data/sig/simplecov.rbs +523 -595
  194. metadata +72 -5
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Ratchet
6
+ module Output
7
+ extend CommandHelpers
8
+
9
+ extend self
10
+
11
+ def emit(stdout, opts, outcome, generated)
12
+ return stdout.puts(JSON.generate(json_summary(opts, outcome, generated))) if opts.fetch(:json)
13
+
14
+ verb = opts.fetch(:dry_run) ? "would write" : "wrote"
15
+ stdout.puts("simplecov ratchet: #{verb} #{opts.fetch(:baseline)} (#{change_summary(outcome, generated)})")
16
+ report_regressed(stdout, outcome.regressed)
17
+ end
18
+
19
+ def change_summary(outcome, generated)
20
+ files = outcome.baseline.entries.size
21
+ return "#{files} #{one?(files) ? "file" : "files"}" if generated
22
+
23
+ "#{outcome.tightened.size} tightened, #{outcome.pruned.size} pruned, #{outcome.unchanged.size} unchanged"
24
+ end
25
+
26
+ def report_regressed(stdout, regressed)
27
+ return if regressed.empty?
28
+
29
+ noun = one?(regressed.size) ? "1 file below its floor" : "#{regressed.size} files below their floors"
30
+ stdout.puts("simplecov ratchet: #{noun}, entries kept unchanged")
31
+ end
32
+
33
+ def json_summary(opts, outcome, generated)
34
+ {
35
+ written: !opts.fetch(:dry_run), path: opts.fetch(:baseline), generated: generated,
36
+ files: outcome.baseline.entries.size,
37
+ tightened: outcome.tightened, pruned: outcome.pruned,
38
+ regressed: outcome.regressed, unchanged: outcome.unchanged
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atomic_file"
4
+ require_relative "command_helpers"
5
+ require_relative "coverage_file"
6
+ require_relative "ratchet/output"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov ratchet`: rewrite the checked-in per-file coverage baseline
11
+ # (#1268) from the current report. Floors only ever tighten, entries for
12
+ # files the report no longer carries are pruned, and files without an entry
13
+ # are never given one, so new code stays answerable to the global thresholds
14
+ # rather than to a floor cut at whatever it launched with. `--init`
15
+ # regenerates the whole file from the current state.
16
+ #
17
+ # The baseline path follows the project's `.simplecov` `baseline_file`
18
+ # setting when one is present, the same way the read-only commands follow
19
+ # `coverage_dir`.
20
+ module Ratchet
21
+ extend CommandHelpers
22
+
23
+ extend self
24
+
25
+ def run(args, stdout:, stderr:, **)
26
+ require "simplecov"
27
+ opts = parse(args, stderr) or return 1
28
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "ratchet", stderr: stderr) or return 1
29
+
30
+ outcome, generated = compute(opts, current_floors(coverage))
31
+ AtomicFile.write(opts.fetch(:baseline), outcome.baseline.to_yaml) unless opts.fetch(:dry_run)
32
+ Output.emit(stdout, opts, outcome, generated)
33
+ 0
34
+ rescue ConfigurationError => e
35
+ error(stderr, e)
36
+ end
37
+
38
+ def parse(args, stderr)
39
+ opts, rest = parse_common(args, init: false, dry_run: false) do |parser, options|
40
+ parser.on("--baseline PATH") { |v| options[:baseline] = v }
41
+ parser.on("--init") { options[:init] = true }
42
+ parser.on("--dry-run") { options[:dry_run] = true }
43
+ end
44
+ return error_nil(stderr, "unexpected argument #{rest.first.inspect}") unless rest.empty?
45
+
46
+ opts[:baseline] ||= Dotfile.baseline_file
47
+ opts
48
+ end
49
+
50
+ # Only criteria the report carries for a file appear, so a line-only report
51
+ # leaves existing branch floors alone.
52
+ def current_floors(coverage)
53
+ initial = {} #: Baseline::current_floors
54
+ coverage.each_with_object(initial) do |(path, entry), floors|
55
+ criteria = CoverageFile::CRITERIA.filter_map do |criterion, keys|
56
+ percent = entry[keys.fetch(:percent)]
57
+ missed = entry[keys.fetch(:missed)]
58
+ [criterion, {percent: SimpleCov.round_coverage(percent), missed: missed}] if usable?(percent, missed)
59
+ end.to_h
60
+ floors[path] = criteria unless criteria.empty?
61
+ end
62
+ end
63
+
64
+ # A report row from another tool may be missing counts; only a complete pair
65
+ # can become a floor.
66
+ def usable?(percent, missed)
67
+ percent.is_a?(Numeric) && missed.instance_of?(Integer)
68
+ end
69
+
70
+ def compute(opts, current)
71
+ existing = Baseline.read_if_exists(opts.fetch(:baseline)) unless opts.fetch(:init)
72
+ return [existing.ratchet(current), false] if existing
73
+
74
+ none = [] #: Array[String]
75
+ outcome = Baseline::Outcome.new(
76
+ baseline: Baseline.generate(current),
77
+ tightened: none, pruned: none, regressed: none, unchanged: none
78
+ )
79
+ [outcome, true]
80
+ end
81
+ end
82
+ end
83
+ end
@@ -6,46 +6,40 @@ require_relative "command_helpers"
6
6
 
7
7
  module SimpleCov
8
8
  module CLI
9
- # `simplecov report [--input PATH]` — pretty-print the overall
10
- # totals row plus per-group totals from a JSONFormatter
11
- # coverage.json. Same numbers as the HTML report's totals row, for
12
- # contexts where opening a browser isn't an option (CI logs, ssh
13
- # sessions, terminal-only workflows).
14
9
  module Report
15
10
  extend CommandHelpers
16
11
 
17
12
  TOTAL_LABEL = "All Files"
18
- SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze
13
+ SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze #: Array[[String, String]]
19
14
 
20
- module_function
15
+ extend self
21
16
 
22
17
  def run(args, stdout:, stderr:)
23
18
  opts, = parse_common(args)
24
- return 1 unless (data = load_data(opts[:input], stderr))
19
+ return 1 unless (data = load_data(opts.fetch(:input), stderr))
25
20
 
26
- if opts[:json]
21
+ if opts.fetch(:json)
27
22
  emit_json(stdout, data)
28
23
  else
29
- emit_text(stdout, data, SimpleCov::CLI.color_enabled?(opts, stdout))
24
+ emit_text(stdout, data, CLI.color_enabled?(opts, stdout))
30
25
  end
31
26
  0
32
27
  end
33
28
 
34
- # Valid JSON isn't enough: a wrong-typed "total" or "groups" (or a
35
- # wrong-typed group entry) used to escape here and crash the
36
- # emitters with a backtrace instead of the one-line error the
37
- # sibling commands print.
29
+ # Valid JSON isn't enough: a wrong-typed "total" or "groups" used to escape
30
+ # here and crash the emitters with a backtrace instead of the one-line error
31
+ # the sibling commands print.
38
32
  def load_data(input, stderr)
39
33
  data = CoverageFile.load_document(input, command: "report", stderr: stderr)
40
34
  return unless data
41
35
 
42
36
  none = {} #: Hash[String, untyped]
43
- unless data.fetch("total", none).is_a?(Hash)
37
+ unless data.fetch("total", none).instance_of?(Hash)
44
38
  return CoverageFile.report_invalid(stderr, "report", input, '"total" must be an object')
45
39
  end
46
40
 
47
41
  groups = data.fetch("groups", none)
48
- return data if groups.is_a?(Hash) && groups.each_value.all?(Hash)
42
+ return data if groups.instance_of?(Hash) && groups.each_value.all?(Hash)
49
43
 
50
44
  CoverageFile.report_invalid(stderr, "report", input, '"groups" must be an object of objects')
51
45
  end
@@ -54,7 +48,7 @@ module SimpleCov
54
48
  none = {} #: Hash[String, untyped]
55
49
  emit_totals(stdout, TOTAL_LABEL, data.fetch("total", none), color)
56
50
  data.fetch("groups", none).each do |name, group|
57
- label = name == TOTAL_LABEL ? "#{name} (group)" : name
51
+ label = name.eql?(TOTAL_LABEL) ? "#{name} (group)" : name
58
52
  emit_totals(stdout, label, group, color)
59
53
  end
60
54
  end
@@ -66,12 +60,12 @@ module SimpleCov
66
60
  end
67
61
 
68
62
  def emit_section(stdout, display, section, color)
69
- return unless section.is_a?(Hash) && section["total"].to_i.positive?
63
+ return unless section.instance_of?(Hash) && section["total"].to_i.positive?
70
64
 
71
65
  stdout.puts(stats_row(display,
72
- SimpleCov::Color.colorize_percent(section["percent"].to_f, enabled: color),
73
- section["covered"],
74
- section["total"]))
66
+ Color.colorize_percent(section["percent"].to_f, enabled: color),
67
+ section["covered"],
68
+ section.fetch("total")))
75
69
  end
76
70
 
77
71
  def emit_json(stdout, data)
@@ -85,10 +79,10 @@ module SimpleCov
85
79
  sections = {} #: Hash[String, untyped]
86
80
  SECTIONS.each_with_object(sections) do |(_, key), out|
87
81
  section = totals[key]
88
- next unless section.is_a?(Hash) && section["total"].to_i.positive?
82
+ next unless section.instance_of?(Hash) && section["total"].to_i.positive?
89
83
 
90
- out[key.to_s] = {
91
- "percent" => section["percent"], "covered" => section["covered"], "total" => section["total"]
84
+ out[key] = {
85
+ "percent" => section["percent"], "covered" => section["covered"], "total" => section.fetch("total")
92
86
  }
93
87
  end
94
88
  end
@@ -2,29 +2,28 @@
2
2
 
3
3
  module SimpleCov
4
4
  module CLI
5
- # `simplecov run <command...>` — exec the given command with
6
- # simplecov auto-loaded so a coverage report drops into the
7
- # project's coverage/ directory at the end. Useful for projects
8
- # without a test_helper that already calls SimpleCov.start (e.g.
9
- # plain `bundle exec rake test` on an unconfigured library).
10
5
  module Run
11
6
  AUTOSTART = File.expand_path("../autostart", __dir__.to_s)
12
7
 
13
- module_function
8
+ extend self
14
9
 
15
10
  def run(args, stderr:, **)
16
- cmd = args.first == "--" ? args.drop(1) : args
11
+ cmd = args.first.eql?("--") ? args.drop(1) : args
17
12
  if cmd.empty?
18
13
  stderr.puts("simplecov run: missing command")
19
14
  return 1
20
15
  end
21
16
 
22
- Kernel.exec(rubyopt_env, *cmd)
17
+ exec_command(rubyopt_env, cmd)
23
18
  rescue Errno::ENOENT => e
24
- stderr.puts("simplecov run: #{e.message}")
19
+ stderr.puts("simplecov run: #{e}")
25
20
  127
26
21
  end
27
22
 
23
+ def exec_command(env, cmd)
24
+ Kernel.exec(env, *cmd)
25
+ end
26
+
28
27
  def rubyopt_env
29
28
  existing = ENV["RUBYOPT"].to_s.strip
30
29
  injection = "-r#{AUTOSTART}"
@@ -5,9 +5,6 @@ require_relative "../../coverage_json"
5
5
  module SimpleCov
6
6
  module CLI
7
7
  module Serve
8
- # Ensures the coverage directory has a renderable entry point before
9
- # the HTTP server binds. Existing self-contained reports take priority
10
- # over their optional JSON sidecar.
11
8
  module ReportPreparer
12
9
  def self.call(dir)
13
10
  index_path = File.join(dir, "index.html")
@@ -17,11 +14,15 @@ module SimpleCov
17
14
 
18
15
  return "#{dir} has no index.html or coverage.json; run your test suite first" unless File.file?(json_path)
19
16
 
17
+ build_index(json_path, dir)
18
+ rescue CoverageJSON::Error, SystemCallError => e
19
+ "cannot build index.html from #{json_path}: #{e}"
20
+ end
21
+
22
+ def self.build_index(json_path, dir)
20
23
  require_relative "../../formatter/html_formatter"
21
- SimpleCov::Formatter::HTMLFormatter.new.format_from_json(json_path, dir)
24
+ Formatter::HTMLFormatter.new.format_from_json(json_path, dir)
22
25
  nil
23
- rescue SimpleCov::CoverageJSON::Error, SystemCallError => e
24
- "cannot build index.html from #{json_path}: #{e.message}"
25
26
  end
26
27
  end
27
28
  end
@@ -3,10 +3,10 @@
3
3
  module SimpleCov
4
4
  module CLI
5
5
  module Serve
6
- # The HTTP mechanics behind `simplecov serve`: reads one request
7
- # per connection and answers it from the report directory, with
8
- # traversal-safe path resolution. Kept apart from the CLI wiring
9
- # (option parsing, binding, the accept loop) in serve.rb.
6
+ # The HTTP mechanics behind `simplecov serve`: reads one request per
7
+ # connection and answers it from the report directory, with
8
+ # traversal-safe path resolution. Kept apart from the CLI wiring in
9
+ # serve.rb.
10
10
  module StaticFileHandler
11
11
  MIME = {
12
12
  ".html" => "text/html; charset=utf-8", ".htm" => "text/html; charset=utf-8",
@@ -24,96 +24,102 @@ module SimpleCov
24
24
  STATUS_TEXT = {200 => "OK", 400 => "Bad Request", 403 => "Forbidden",
25
25
  404 => "Not Found", 405 => "Method Not Allowed"}.freeze
26
26
 
27
- # Seconds a connection may sit idle mid-request before its reads
28
- # raise IO::TimeoutError and the connection is dropped.
27
+ # Seconds a connection may sit idle mid-request before its reads raise
28
+ # IO::TimeoutError and the connection is dropped.
29
29
  READ_TIMEOUT = 5
30
30
 
31
- module_function
32
-
33
- # Reads one HTTP request line, drains headers, serves the file or
34
- # writes a status response. Wide rescue so a misbehaving client
35
- # can't crash the server. The read timeout keeps idle connections
36
- # from pinning their threads forever.
37
- def handle_connection(client, root)
38
- # simplecov:disable branch support for IO#timeout= is fixed by the engine
39
- # JRuby and TruffleRuby don't implement IO#timeout=. Without
40
- # the guard the NoMethodError lands in the wide rescue below
41
- # and every connection closes with an empty response. On those
42
- # engines an idle connection pins its thread instead of timing
43
- # out, which only leaks a thread in an interactive dev server.
31
+ extend self
32
+
33
+ # Reads one HTTP request line, drains headers, serves the file or writes a
34
+ # status response. Wide rescue so a misbehaving client can't crash the
35
+ # server.
36
+ #
37
+ # `routes` maps exact request paths (query string excluded) to callables
38
+ # that take over the connection, the seam `simplecov watch` mounts its
39
+ # /events stream on. A route may hold the socket for as long as it
40
+ # likes; the ensure below closes it when the route returns.
41
+ def handle_connection(client, root, routes = {})
42
+ # JRuby doesn't implement IO#timeout=. Without the guard the
43
+ # NoMethodError lands in the wide rescue below and every connection
44
+ # closes with an empty response. An idle connection then pins its
45
+ # thread instead of timing out, which only leaks a thread in an
46
+ # interactive dev server.
44
47
  client.timeout = READ_TIMEOUT if client.respond_to?(:timeout=)
45
- # simplecov:enable branch
46
48
  method, path = client.readline.split
47
49
  drain_headers(client)
48
- # A request line without both tokens used to raise on
49
- # `path.split` inside the wide rescue, closing the connection
50
- # with an empty response instead of the 400 defined below.
50
+ # A request line without both tokens used to raise on `path.split` inside
51
+ # the wide rescue, closing the connection with an empty response
52
+ # instead of the 400 below.
51
53
  return respond(client, 400) if path.nil?
52
- return respond(client, 405) unless method == "GET"
53
54
 
54
- serve_file(client, path, root)
55
- rescue StandardError
56
- # Misbehaving clients (truncated requests, connection resets,
57
- # invalid encoding) shouldn't take the whole server down.
55
+ dispatch(client, method, path, root, routes)
56
+ rescue
57
+ # Misbehaving clients (truncated requests, connection resets, invalid
58
+ # encoding) shouldn't take the whole server down.
58
59
  nil
59
60
  ensure
60
- # simplecov:disable — `client` is the parameter, never nil here;
61
- # the `&.` is purely defensive in case of future refactors
62
- client&.close
63
- # simplecov:enable
61
+ client.close
62
+ end
63
+
64
+ def dispatch(client, method, path, root, routes)
65
+ return respond(client, 405) unless method.eql?("GET")
66
+
67
+ route = routes[path.split("?").first]
68
+ return route.call(client) if route
69
+
70
+ serve_file(client, path, root)
64
71
  end
65
72
 
66
73
  def serve_file(client, path, root)
67
74
  file = resolve(path, root)
68
- return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)
75
+ # `resolve` answers a path, nothing for a file that is not there, or the
76
+ # refusal itself.
77
+ return respond(client, file ? 403 : 404) unless file.instance_of?(String)
69
78
 
70
79
  respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
71
80
  end
72
81
 
73
82
  def drain_headers(client)
74
- loop { break if client.readline.strip.empty? }
83
+ loop { break if client.readline.rstrip.empty? }
75
84
  end
76
85
 
77
- # Returns the absolute path of the file to serve, :forbidden for
78
- # a traversal attempt (including symlinks that escape root), or
79
- # nil for "not found".
86
+ # Answers the absolute path of the file to serve, :forbidden for a
87
+ # traversal attempt (symlinks that escape root included), or nil for
88
+ # "not found".
80
89
  #
81
- # The request path is deliberately NOT percent-decoded: filenames
82
- # needing escapes don't occur in generated reports, and keeping
83
- # `%2e%2e%2f` as literal bytes is part of the traversal defense.
84
- # If decoding is ever added, it must happen BEFORE the `inside?`
85
- # check below.
90
+ # The request path is deliberately NOT percent-decoded: filenames needing
91
+ # escapes don't occur in generated reports, and keeping `%2e%2e%2f` as
92
+ # literal bytes is part of the traversal defense. If decoding is ever
93
+ # added, it must happen BEFORE the `inside?` check.
86
94
  def resolve(request_path, root)
87
- path = request_path.split("?", 2).first.to_s.sub(%r{^/}, "")
95
+ path = request_path.split("?").first.to_s.delete_prefix("/")
88
96
  absolute_root = File.realpath(root)
89
- candidate = File.expand_path(path.empty? ? "index.html" : path, absolute_root)
90
- # Reject `..` traversal and absolute-path attempts before
91
- # touching disk so they're 403, not 404.
97
+ candidate = File.expand_path(path, absolute_root)
98
+ # Rejected before touching disk, so traversal and absolute-path attempts
99
+ # are 403, not 404.
92
100
  return :forbidden unless inside?(candidate, absolute_root)
93
101
 
94
102
  candidate = File.join(candidate, "index.html") if File.directory?(candidate)
95
103
  return nil unless File.file?(candidate)
96
104
 
97
- # Resolve symlinks last and re-check: a file inside root could
98
- # be a symlink pointing outside (e.g. /etc/passwd).
105
+ # Symlinks are resolved last and re-checked: a file inside root could be
106
+ # a symlink pointing outside.
99
107
  real = File.realpath(candidate)
100
108
  inside?(real, absolute_root) ? real : :forbidden
101
109
  rescue Errno::ENOENT
102
- # simplecov:disable — TOCTOU: candidate vanished between
103
- # File.file? and File.realpath. Treat as "not found".
110
+ # TOCTOU: candidate vanished between File.file? and File.realpath.
104
111
  nil
105
- # simplecov:enable
106
112
  end
107
113
 
108
114
  def inside?(path, root)
109
- path == root || path.start_with?(root + File::SEPARATOR)
115
+ path.eql?(root) || path.start_with?(root + File::SEPARATOR)
110
116
  end
111
117
 
112
118
  def respond(client, status, body = "", content_type = "text/plain")
113
- client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || 'Error'}\r\n",
114
- "Content-Type: #{content_type || 'application/octet-stream'}\r\n",
115
- "Content-Length: #{body.bytesize}\r\n",
116
- "Connection: close\r\n\r\n")
119
+ client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || "Error"}\r\n",
120
+ "Content-Type: #{content_type || "application/octet-stream"}\r\n",
121
+ "Content-Length: #{body.bytesize}\r\n",
122
+ "Connection: close\r\n\r\n")
117
123
  client.write(body)
118
124
  end
119
125
  end
@@ -7,24 +7,22 @@ require_relative "serve/static_file_handler"
7
7
 
8
8
  module SimpleCov
9
9
  module CLI
10
- # `simplecov serve [--port N] [--host HOST]` serve the coverage
11
- # report over HTTP. A small static file server backed by stdlib
12
- # `socket`, so there's no extra dependency just for "view a local
13
- # report on a CI box where `file://` doesn't work." This module is
14
- # the CLI wiring (options, binding, the accept loop); the HTTP
15
- # mechanics live in StaticFileHandler.
10
+ # `simplecov serve`: a small static file server backed by stdlib `socket`, so
11
+ # there is no extra dependency just for viewing a local report on a box where
12
+ # `file://` doesn't work. This module is the CLI wiring; the HTTP mechanics
13
+ # live in StaticFileHandler.
16
14
  module Serve
17
15
  extend CommandHelpers
18
16
 
19
- module_function
17
+ extend self
20
18
 
21
19
  def run(args, stdout:, stderr:, **)
22
20
  opts = parse(args)
23
- dir = SimpleCov::CLI.coverage_dir
21
+ dir = CLI.coverage_dir
24
22
  message = ReportPreparer.call(dir)
25
23
  return error(stderr, message) if message
26
24
 
27
- require "socket"
25
+ require_socket
28
26
  with_server(opts, stderr) do |server|
29
27
  announce(stdout, server, dir)
30
28
  serve_loop(server, dir, stdout)
@@ -32,14 +30,20 @@ module SimpleCov
32
30
  end
33
31
  end
34
32
 
35
- # Returns the block's exit status, or 1 when the socket can't be
36
- # bound (port already taken, privileged port, unresolvable host).
33
+ # Required here rather than at load time so the other subcommands do not pay
34
+ # for sockets.
35
+ def require_socket
36
+ require "socket"
37
+ end
38
+
39
+ # Answers 1 when the socket can't be bound: port already taken, privileged
40
+ # port, unresolvable host. The receiver cast works around an rbs stdlib gap,
41
+ # where TCPSocket's explicit `self.new` shadows TCPServer#initialize's
42
+ # (host, port) form.
37
43
  def with_server(opts, stderr)
38
- # The receiver cast works around an rbs stdlib gap: TCPSocket's
39
- # explicit `self.new` shadows TCPServer#initialize's (host, port) form.
40
- server = (_ = TCPServer).new(opts[:host], opts[:port]) #: TCPServer
44
+ server = (_ = TCPServer).new(opts.fetch(:host), opts.fetch(:port)) #: TCPServer
41
45
  rescue SystemCallError, SocketError => e
42
- error(stderr, "cannot bind to #{opts[:host]}:#{opts[:port]} (#{e.message})")
46
+ error(stderr, "cannot bind to #{opts.fetch(:host)}:#{opts.fetch(:port)} (#{e})")
43
47
  else
44
48
  begin
45
49
  yield server
@@ -50,29 +54,28 @@ module SimpleCov
50
54
 
51
55
  def parse(args)
52
56
  opts = {port: 0, host: "127.0.0.1"} #: Hash[Symbol, untyped]
53
- OptionParser.new do |o|
57
+ build_parser do |o|
54
58
  o.on("--port N", Integer) { |v| opts[:port] = v }
55
- o.on("--host HOST") { |v| opts[:host] = v }
59
+ o.on("--host HOST") { |v| opts[:host] = v }
56
60
  end.parse(args)
57
61
  opts
58
62
  end
59
63
 
60
64
  def announce(stdout, server, dir)
61
- port = server.addr[1]
62
- host = url_host(server.addr[3])
65
+ port = server.addr.fetch(1)
66
+ host = url_host(server.addr.fetch(3))
63
67
  stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
64
68
  stdout.puts("Press Ctrl-C to stop.")
65
69
  end
66
70
 
67
- # IPv6 literals need brackets in a URL (--host ::1 must announce
68
- # http://[::1]:PORT/, not the invalid http://::1:PORT/).
71
+ # IPv6 literals need brackets in a URL: `--host ::1` must announce
72
+ # http://[::1]:PORT/, not the invalid http://::1:PORT/.
69
73
  def url_host(host)
70
74
  host.include?(":") ? "[#{host}]" : host
71
75
  end
72
76
 
73
- # One thread per connection: browsers open speculative sockets
74
- # that send no bytes, and a serial loop would stall every real
75
- # request (the report's assets included) behind them.
77
+ # One thread per connection: browsers open speculative sockets that send no
78
+ # bytes, and a serial loop would stall every real request behind them.
76
79
  def serve_loop(server, dir, stdout)
77
80
  loop do
78
81
  Thread.new(server.accept) { |client| StaticFileHandler.handle_connection(client, dir) }
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Show
6
+ # Renders one file's annotated source: a right-aligned line-number gutter,
7
+ # the hit count beside it, and a caret line naming each miss under the line
8
+ # it happened on.
9
+ module Annotator
10
+ extend self
11
+
12
+ def call(source, entry, stdout, color:)
13
+ markers = markers_for(entry)
14
+ widths = {number: source.size.to_s.length, count: count_width(entry)}
15
+ source.each_with_index do |line, index|
16
+ rendered = row(index + 1, entry.fetch("lines").at(index), line, widths, color)
17
+ emit(stdout, rendered, markers[index + 1], widths, color)
18
+ end
19
+ end
20
+
21
+ def emit(stdout, rendered, labels, widths, color)
22
+ stdout.puts(rendered)
23
+ return if labels.empty?
24
+
25
+ gutter = " " * (widths.fetch(:number) + widths.fetch(:count) + 4)
26
+ stdout.puts(gutter + paint("^ #{labels.join(", ")}", :red, color))
27
+ end
28
+
29
+ def missed_lines(entry)
30
+ entry.fetch("lines").each_with_index.filter_map do |hit, index|
31
+ index + 1 if hit.instance_of?(Integer) && hit.zero?
32
+ end
33
+ end
34
+
35
+ def markers_for(entry)
36
+ markers = Hash.new { |hash, line| hash[line] = [] } #: Hash[Integer, Array[String]]
37
+ missed_lines(entry).each { |line| markers[line] << "missed" }
38
+ each_missed(entry["branches"]) { |line| markers[line] << "branch missed" }
39
+ each_missed(entry["methods"]) { |line| markers[line] << "method missed" }
40
+ markers
41
+ end
42
+
43
+ def each_missed(items)
44
+ return unless items.instance_of?(Array)
45
+
46
+ items.each do |item|
47
+ line = missed_line_of(item)
48
+ yield line if line
49
+ end
50
+ end
51
+
52
+ # The reported line of a zero-hit item, nil for a covered or malformed one,
53
+ # the same tolerance the patch subcommand applies to branch entries.
54
+ def missed_line_of(item)
55
+ return nil unless item.instance_of?(Hash)
56
+
57
+ hits = item["coverage"]
58
+ return nil unless hits.instance_of?(Integer) && hits.zero?
59
+
60
+ line = item["report_line"] || item["start_line"]
61
+ line if line.instance_of?(Integer)
62
+ end
63
+
64
+ def count_width(entry)
65
+ entry.fetch("lines").grep(Integer) { |hit| hit.to_s.length }.max || 1
66
+ end
67
+
68
+ def row(number, hit, line, widths, color)
69
+ format("%#{widths.fetch(:number)}d %s %s", number, count(hit, widths, color), line).rstrip
70
+ end
71
+
72
+ # Padded to width before it is painted, so escape codes can't skew the
73
+ # column, and blank for a line carrying no count.
74
+ def count(hit, widths, color)
75
+ padded = (hit.instance_of?(Integer) ? hit.to_s : "").rjust(widths.fetch(:count))
76
+ return padded unless hit.instance_of?(Integer)
77
+
78
+ paint(padded, hit.zero? ? :red : :green, color)
79
+ end
80
+
81
+ def paint(text, tint, color)
82
+ Color.colorize(text, tint, enabled: color)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end