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
@@ -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
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Show
6
+ module Sweep
7
+ extend self
8
+
9
+ def misses(coverage)
10
+ rows = coverage.filter_map do |filename, entry|
11
+ next unless entry.instance_of?(Hash) && entry["lines"].instance_of?(Array)
12
+
13
+ missed = Annotator.missed_lines(entry)
14
+ [display_path(filename), missed] unless missed.empty?
15
+ end
16
+ rows.sort_by(&:first)
17
+ end
18
+
19
+ def emit(rows, opts, stdout, stderr)
20
+ if opts.fetch(:json)
21
+ stdout.puts(JSON.pretty_generate(rows.map { |path, missed| {path: path, missed: missed} }))
22
+ elsif rows.empty?
23
+ stderr.puts("simplecov show: nothing uncovered")
24
+ else
25
+ rows.each { |path, missed| stdout.puts("#{path}:#{Patch::Output.ranges(missed, ",")}") }
26
+ end
27
+ 0
28
+ end
29
+
30
+ def display_path(filename)
31
+ filename.delete_prefix("#{File.expand_path(SimpleCov.root)}/")
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "command_helpers"
6
+ require_relative "patch/output"
7
+ require_relative "show/annotator"
8
+ require_relative "show/sweep"
9
+
10
+ module SimpleCov
11
+ module CLI
12
+ # `simplecov show <path>`: annotated source in the terminal, the way
13
+ # `go tool cover` and `llvm-cov show` print it. `--uncovered-only` collapses
14
+ # the answer to `path:ranges`, a form that greps, fits in a commit message,
15
+ # and hands a coding agent exactly the lines whose tests are missing.
16
+ module Show
17
+ extend CommandHelpers
18
+
19
+ extend self
20
+
21
+ def run(args, stdout:, stderr:)
22
+ opts = parse(args)
23
+ return run_project(opts, stdout, stderr) unless opts.fetch(:path)
24
+
25
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "show", stderr: stderr)
26
+ return 1 unless coverage
27
+
28
+ located = locate_or_report(coverage, opts, stderr)
29
+ return 1 unless located
30
+
31
+ render(located, opts, stdout, stderr)
32
+ end
33
+
34
+ # No path sweeps the whole project, in the compact forms only. The annotated
35
+ # text form still wants one file.
36
+ def run_project(opts, stdout, stderr)
37
+ unless opts.fetch(:uncovered_only) || opts.fetch(:json)
38
+ return error(stderr, "missing path (annotating needs one file; --uncovered-only sweeps the whole project)")
39
+ end
40
+
41
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "show", stderr: stderr)
42
+ return 1 unless coverage
43
+
44
+ Sweep.emit(Sweep.misses(coverage), opts, stdout, stderr)
45
+ end
46
+
47
+ def parse(args)
48
+ opts = {input: CLI.default_input, no_color: false,
49
+ uncovered_only: false, json: false} #: Hash[Symbol, untyped]
50
+ rest = build_parser { |parser| options(parser, opts) }.parse(args)
51
+ opts[:path] = rest.first
52
+ opts
53
+ end
54
+
55
+ def options(parser, opts)
56
+ parser.on("--input PATH") { |v| opts[:input] = v }
57
+ parser.on("--no-color") { opts[:no_color] = true }
58
+ parser.on("--uncovered-only") { opts[:uncovered_only] = true }
59
+ parser.on("--json") { opts[:json] = true }
60
+ end
61
+
62
+ # A line-less report has no per-line hits to put in a gutter, so it is
63
+ # reported like an unresolvable path is.
64
+ def locate_or_report(coverage, opts, stderr)
65
+ path, input = opts.fetch_values(:path, :input)
66
+ match = CoverageFile.lookup(coverage, path)
67
+ return error_nil(stderr, CoverageFile.not_found_message(coverage, path, input)) unless match
68
+
69
+ entry = match.last
70
+ unless entry.instance_of?(Hash)
71
+ return CoverageFile.report_invalid(stderr, "show", input, "entry for #{path} must be an object")
72
+ end
73
+ return match if entry["lines"].instance_of?(Array)
74
+
75
+ error_nil(stderr, "no line coverage for #{path} in #{input}")
76
+ end
77
+
78
+ def render(located, opts, stdout, stderr)
79
+ filename, entry = located
80
+ return emit_json(entry, opts, stdout) if opts.fetch(:json)
81
+ return show_uncovered(entry, opts, stdout, stderr) if opts.fetch(:uncovered_only)
82
+
83
+ source = source_for(filename, entry, opts, stderr)
84
+ return 1 unless source
85
+
86
+ Annotator.call(source, entry, stdout, color: CLI.color_enabled?(opts, stdout))
87
+ 0
88
+ end
89
+
90
+ def emit_json(entry, opts, stdout)
91
+ stdout.puts(JSON.pretty_generate(
92
+ path: opts.fetch(:path), missed: Annotator.missed_lines(entry),
93
+ lines: relevant_lines(entry), markers: Annotator.markers_for(entry)
94
+ ))
95
+ 0
96
+ end
97
+
98
+ def relevant_lines(entry)
99
+ entry.fetch("lines").each_with_index.filter_map do |hit, index|
100
+ {number: index + 1, hits: hit} if hit.instance_of?(Integer)
101
+ end
102
+ end
103
+
104
+ def show_uncovered(entry, opts, stdout, stderr)
105
+ missed = Annotator.missed_lines(entry)
106
+ if missed.empty?
107
+ stderr.puts("simplecov show: nothing uncovered in #{opts.fetch(:path)}")
108
+ else
109
+ stdout.puts("#{opts.fetch(:path)}:#{Patch::Output.ranges(missed, ",")}")
110
+ end
111
+ 0
112
+ end
113
+
114
+ # The report's own source when it carries one; otherwise the file on disk,
115
+ # accepted only while its line count still matches the report's, since
116
+ # annotating drifted source would put hit counts on the wrong lines.
117
+ def source_for(filename, entry, opts, stderr)
118
+ embedded = entry["source"]
119
+ return embedded if embedded.instance_of?(Array) && embedded.all?(String)
120
+ unless File.file?(filename)
121
+ return error_nil(stderr, "no source for #{opts.fetch(:path)} in #{opts.fetch(:input)} and no file " \
122
+ "at #{filename}, regenerate the report with `source_in_json true`")
123
+ end
124
+
125
+ lines = File.readlines(filename, chomp: true)
126
+ return lines if lines.size.eql?(entry.fetch("lines").size)
127
+
128
+ error_nil(stderr, "#{filename} has changed since the report (#{lines.size} lines now, " \
129
+ "#{entry.fetch("lines").size} recorded), regenerate the report")
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Status
6
+ module Facts
7
+ extend self
8
+
9
+ def gather(document, resultset_path)
10
+ contexts = document["contexts"]
11
+ meta_facts(document).merge(
12
+ totals: totals(document["total"]),
13
+ contexts: (contexts.size if contexts.instance_of?(Array)),
14
+ resultset_path: resultset_path, resultset: readable_resultset(resultset_path)
15
+ )
16
+ end
17
+
18
+ def meta_facts(document)
19
+ none = {} #: Hash[String, untyped]
20
+ recorded = document["meta"]
21
+ meta = recorded.instance_of?(Hash) ? recorded : none
22
+ generated = parse_time(meta["timestamp"])
23
+ {
24
+ generated_at: meta["timestamp"], age: generated && whole_seconds(Time.now - generated),
25
+ version: meta["simplecov_version"], command_name: meta["command_name"],
26
+ commit: meta["commit"], behind: behind(meta["commit"])
27
+ }
28
+ end
29
+
30
+ def age_of(timestamp)
31
+ whole_seconds(Process.clock_gettime(Process::CLOCK_REALTIME) - timestamp)
32
+ end
33
+
34
+ def whole_seconds(elapsed)
35
+ elapsed.truncate
36
+ end
37
+
38
+ def parse_time(value)
39
+ Time.iso8601(value) if value.instance_of?(String)
40
+ rescue ArgumentError
41
+ nil
42
+ end
43
+
44
+ # Commits between the report's commit and HEAD: 0 at HEAD, nil when
45
+ # unanswerable, meaning no commit recorded, no git, or a commit this
46
+ # repository has never seen.
47
+ def behind(commit)
48
+ return nil unless commit.instance_of?(String)
49
+
50
+ stdout, _detail, success = Git.capture("rev-list", "--count", "#{commit}..HEAD")
51
+ commit_count(_ = stdout) if success
52
+ end
53
+
54
+ # `Integer` ignores surrounding whitespace on its own, so git's trailing
55
+ # newline needs no trimming. The base is spelled out because bare `Integer`
56
+ # reads a leading zero as octal.
57
+ def commit_count(output)
58
+ Integer(output, 10)
59
+ end
60
+
61
+ def totals(total)
62
+ return {} unless total.instance_of?(Hash)
63
+
64
+ rows = {"line" => "lines", "branch" => "branches", "method" => "methods"}.filter_map do |name, key|
65
+ percent = total.dig(key, "percent")
66
+ [name, percent] if percent.is_a?(Numeric)
67
+ end
68
+ rows.to_h
69
+ end
70
+
71
+ # An unreadable resultset answers nothing rather than raising: its
72
+ # absence is a fact, not an error.
73
+ def readable_resultset(path)
74
+ parsed = JSON.parse(File.read(path))
75
+ return nil unless parsed.instance_of?(Hash)
76
+
77
+ parsed.map do |command, data|
78
+ timestamp = data["timestamp"] if data.instance_of?(Hash)
79
+ {command: command, age: (age_of(timestamp) if timestamp.is_a?(Numeric))}
80
+ end
81
+ rescue SystemCallError, JSON::ParserError
82
+ nil
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end