simplecov 1.1.1 → 1.3.2

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 (202) 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/runner.rb +38 -0
  11. data/lib/simplecov/cli/affected/selection.rb +117 -0
  12. data/lib/simplecov/cli/affected.rb +147 -0
  13. data/lib/simplecov/cli/annotations.rb +166 -0
  14. data/lib/simplecov/cli/badge/svg.rb +70 -0
  15. data/lib/simplecov/cli/badge.rb +62 -0
  16. data/lib/simplecov/cli/clean.rb +25 -25
  17. data/lib/simplecov/cli/command_helpers.rb +55 -19
  18. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  19. data/lib/simplecov/cli/completions.rb +83 -0
  20. data/lib/simplecov/cli/coverage.rb +25 -28
  21. data/lib/simplecov/cli/coverage_file.rb +59 -14
  22. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  23. data/lib/simplecov/cli/dead_code.rb +139 -0
  24. data/lib/simplecov/cli/diff/output.rb +45 -0
  25. data/lib/simplecov/cli/diff.rb +35 -67
  26. data/lib/simplecov/cli/dotfile.rb +83 -38
  27. data/lib/simplecov/cli/git.rb +41 -0
  28. data/lib/simplecov/cli/history/output.rb +135 -0
  29. data/lib/simplecov/cli/history.rb +67 -0
  30. data/lib/simplecov/cli/merge.rb +34 -39
  31. data/lib/simplecov/cli/open.rb +12 -15
  32. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  33. data/lib/simplecov/cli/patch/output.rb +151 -0
  34. data/lib/simplecov/cli/patch.rb +175 -0
  35. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  36. data/lib/simplecov/cli/ratchet.rb +83 -0
  37. data/lib/simplecov/cli/real_path.rb +41 -0
  38. data/lib/simplecov/cli/report.rb +18 -24
  39. data/lib/simplecov/cli/run.rb +8 -9
  40. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  41. data/lib/simplecov/cli/serve/static_file_handler.rb +66 -58
  42. data/lib/simplecov/cli/serve.rb +27 -24
  43. data/lib/simplecov/cli/show/annotator.rb +87 -0
  44. data/lib/simplecov/cli/show/sweep.rb +36 -0
  45. data/lib/simplecov/cli/show.rb +133 -0
  46. data/lib/simplecov/cli/status/facts.rb +87 -0
  47. data/lib/simplecov/cli/status.rb +93 -0
  48. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  49. data/lib/simplecov/cli/tests.rb +148 -0
  50. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  51. data/lib/simplecov/cli/uncovered.rb +58 -34
  52. data/lib/simplecov/cli/usage.rb +153 -0
  53. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  54. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  55. data/lib/simplecov/cli/watch/poller.rb +45 -0
  56. data/lib/simplecov/cli/watch/session.rb +134 -0
  57. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  58. data/lib/simplecov/cli/watch.rb +85 -0
  59. data/lib/simplecov/cli.rb +44 -92
  60. data/lib/simplecov/color.rb +19 -37
  61. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  62. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  63. data/lib/simplecov/combine/identity_interner.rb +10 -13
  64. data/lib/simplecov/combine/interned_counts.rb +7 -9
  65. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  66. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  67. data/lib/simplecov/combine/results_combiner.rb +4 -21
  68. data/lib/simplecov/combine.rb +0 -6
  69. data/lib/simplecov/command_guesser.rb +20 -34
  70. data/lib/simplecov/configuration/baseline.rb +31 -0
  71. data/lib/simplecov/configuration/coverage.rb +91 -79
  72. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  73. data/lib/simplecov/configuration/deprecations.rb +21 -0
  74. data/lib/simplecov/configuration/eval_coverage.rb +7 -11
  75. data/lib/simplecov/configuration/filters.rb +40 -73
  76. data/lib/simplecov/configuration/formatting.rb +74 -83
  77. data/lib/simplecov/configuration/groups.rb +19 -13
  78. data/lib/simplecov/configuration/history.rb +44 -0
  79. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  80. data/lib/simplecov/configuration/merging.rb +94 -62
  81. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  82. data/lib/simplecov/configuration/production.rb +23 -0
  83. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  84. data/lib/simplecov/configuration/thresholds.rb +33 -62
  85. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  86. data/lib/simplecov/configuration.rb +70 -76
  87. data/lib/simplecov/configuration_error.rb +5 -0
  88. data/lib/simplecov/context_map/union.rb +81 -0
  89. data/lib/simplecov/context_map.rb +187 -0
  90. data/lib/simplecov/coverage_json.rb +2 -5
  91. data/lib/simplecov/coverage_statistics.rb +14 -24
  92. data/lib/simplecov/coverage_violations.rb +160 -65
  93. data/lib/simplecov/current_run.rb +61 -0
  94. data/lib/simplecov/defaults.rb +4 -23
  95. data/lib/simplecov/deprecation.rb +45 -27
  96. data/lib/simplecov/directive/erb.rb +44 -0
  97. data/lib/simplecov/directive/haml.rb +33 -0
  98. data/lib/simplecov/directive/indented_template.rb +37 -0
  99. data/lib/simplecov/directive/slim.rb +35 -0
  100. data/lib/simplecov/directive/template.rb +29 -0
  101. data/lib/simplecov/directive.rb +47 -62
  102. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  103. data/lib/simplecov/exit_codes/check.rb +17 -11
  104. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  105. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  106. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  107. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  108. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  109. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  110. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  111. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  112. data/lib/simplecov/exit_codes.rb +16 -10
  113. data/lib/simplecov/exit_handling.rb +99 -62
  114. data/lib/simplecov/file_list.rb +20 -51
  115. data/lib/simplecov/filter.rb +43 -66
  116. data/lib/simplecov/formatter/base.rb +37 -55
  117. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  118. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  119. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  120. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  121. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  122. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  123. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  124. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  125. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  126. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  127. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  128. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  129. data/lib/simplecov/formatter.rb +14 -10
  130. data/lib/simplecov/group_names.rb +13 -12
  131. data/lib/simplecov/history.rb +115 -0
  132. data/lib/simplecov/last_run.rb +6 -9
  133. data/lib/simplecov/lines_classifier.rb +31 -16
  134. data/lib/simplecov/load_global_config.rb +7 -7
  135. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  136. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  137. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  138. data/lib/simplecov/parallel_adapters.rb +24 -55
  139. data/lib/simplecov/parallel_coordination.rb +39 -49
  140. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  141. data/lib/simplecov/parallel_result_merger.rb +67 -126
  142. data/lib/simplecov/process.rb +14 -32
  143. data/lib/simplecov/production/error.rb +11 -0
  144. data/lib/simplecov/production/file_sink.rb +130 -0
  145. data/lib/simplecov/production.rb +249 -0
  146. data/lib/simplecov/profiles/rails.rb +4 -15
  147. data/lib/simplecov/profiles/root_filter.rb +0 -4
  148. data/lib/simplecov/profiles/strict.rb +11 -21
  149. data/lib/simplecov/profiles.rb +11 -33
  150. data/lib/simplecov/report_deferral.rb +11 -15
  151. data/lib/simplecov/report_stamp.rb +6 -9
  152. data/lib/simplecov/result/filter_config.rb +19 -0
  153. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  154. data/lib/simplecov/result/serialization.rb +43 -0
  155. data/lib/simplecov/result/source_file_builder.rb +7 -12
  156. data/lib/simplecov/result.rb +74 -117
  157. data/lib/simplecov/result_adapter.rb +56 -68
  158. data/lib/simplecov/result_merger/contexts.rb +22 -0
  159. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  160. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  161. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  162. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  163. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  164. data/lib/simplecov/result_merger.rb +59 -58
  165. data/lib/simplecov/result_processing.rb +92 -122
  166. data/lib/simplecov/run_identity.rb +16 -18
  167. data/lib/simplecov/simulate_coverage.rb +34 -50
  168. data/lib/simplecov/source_file/branch.rb +8 -33
  169. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  170. data/lib/simplecov/source_file/builder_context.rb +13 -13
  171. data/lib/simplecov/source_file/line.rb +13 -33
  172. data/lib/simplecov/source_file/line_builder.rb +9 -15
  173. data/lib/simplecov/source_file/method.rb +3 -7
  174. data/lib/simplecov/source_file/method_builder.rb +6 -15
  175. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  176. data/lib/simplecov/source_file/skip_chunks.rb +29 -29
  177. data/lib/simplecov/source_file/source_loader.rb +45 -35
  178. data/lib/simplecov/source_file/statistics.rb +20 -9
  179. data/lib/simplecov/source_file.rb +21 -51
  180. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +55 -139
  181. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  182. data/lib/simplecov/static_coverage_extractor/method_collector.rb +9 -26
  183. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  184. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  185. data/lib/simplecov/static_coverage_extractor/visitor.rb +46 -65
  186. data/lib/simplecov/static_coverage_extractor.rb +63 -75
  187. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  188. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  189. data/lib/simplecov/test_tracker/delta.rb +69 -0
  190. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  191. data/lib/simplecov/test_tracker.rb +162 -0
  192. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  193. data/lib/simplecov/useless_results_remover.rb +7 -6
  194. data/lib/simplecov/version.rb +1 -1
  195. data/lib/simplecov/view_coverage/template_compiler.rb +92 -0
  196. data/lib/simplecov/view_coverage.rb +55 -0
  197. data/lib/simplecov-html.rb +1 -0
  198. data/lib/simplecov.rb +57 -113
  199. data/lib/simplecov_json_formatter.rb +1 -0
  200. data/man/simplecov.1 +354 -0
  201. data/sig/simplecov.rbs +528 -595
  202. metadata +81 -6
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../real_path"
4
+
3
5
  module SimpleCov
4
6
  module CLI
5
7
  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.
8
+ # The HTTP mechanics behind `simplecov serve`: reads one request per
9
+ # connection and answers it from the report directory, with
10
+ # traversal-safe path resolution. Kept apart from the CLI wiring in
11
+ # serve.rb.
10
12
  module StaticFileHandler
11
13
  MIME = {
12
14
  ".html" => "text/html; charset=utf-8", ".htm" => "text/html; charset=utf-8",
@@ -24,96 +26,102 @@ module SimpleCov
24
26
  STATUS_TEXT = {200 => "OK", 400 => "Bad Request", 403 => "Forbidden",
25
27
  404 => "Not Found", 405 => "Method Not Allowed"}.freeze
26
28
 
27
- # Seconds a connection may sit idle mid-request before its reads
28
- # raise IO::TimeoutError and the connection is dropped.
29
+ # Seconds a connection may sit idle mid-request before its reads raise
30
+ # IO::TimeoutError and the connection is dropped.
29
31
  READ_TIMEOUT = 5
30
32
 
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.
33
+ extend self
34
+
35
+ # Reads one HTTP request line, drains headers, serves the file or writes a
36
+ # status response. Wide rescue so a misbehaving client can't crash the
37
+ # server.
38
+ #
39
+ # `routes` maps exact request paths (query string excluded) to callables
40
+ # that take over the connection, the seam `simplecov watch` mounts its
41
+ # /events stream on. A route may hold the socket for as long as it
42
+ # likes; the ensure below closes it when the route returns.
43
+ def handle_connection(client, root, routes = {})
44
+ # JRuby doesn't implement IO#timeout=. Without the guard the
45
+ # NoMethodError lands in the wide rescue below and every connection
46
+ # closes with an empty response. An idle connection then pins its
47
+ # thread instead of timing out, which only leaks a thread in an
48
+ # interactive dev server.
44
49
  client.timeout = READ_TIMEOUT if client.respond_to?(:timeout=)
45
- # simplecov:enable branch
46
50
  method, path = client.readline.split
47
51
  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.
52
+ # A request line without both tokens used to raise on `path.split` inside
53
+ # the wide rescue, closing the connection with an empty response
54
+ # instead of the 400 below.
51
55
  return respond(client, 400) if path.nil?
52
- return respond(client, 405) unless method == "GET"
53
56
 
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.
57
+ dispatch(client, method, path, root, routes)
58
+ rescue
59
+ # Misbehaving clients (truncated requests, connection resets, invalid
60
+ # encoding) shouldn't take the whole server down.
58
61
  nil
59
62
  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
63
+ client.close
64
+ end
65
+
66
+ def dispatch(client, method, path, root, routes)
67
+ return respond(client, 405) unless method.eql?("GET")
68
+
69
+ route = routes[path.split("?").first]
70
+ return route.call(client) if route
71
+
72
+ serve_file(client, path, root)
64
73
  end
65
74
 
66
75
  def serve_file(client, path, root)
67
76
  file = resolve(path, root)
68
- return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)
77
+ # `resolve` answers a path, nothing for a file that is not there, or the
78
+ # refusal itself.
79
+ return respond(client, file ? 403 : 404) unless file.instance_of?(String)
69
80
 
70
81
  respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
71
82
  end
72
83
 
73
84
  def drain_headers(client)
74
- loop { break if client.readline.strip.empty? }
85
+ loop { break if client.readline.rstrip.empty? }
75
86
  end
76
87
 
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".
88
+ # Answers the absolute path of the file to serve, :forbidden for a
89
+ # traversal attempt (symlinks that escape root included), or nil for
90
+ # "not found".
80
91
  #
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.
92
+ # The request path is deliberately NOT percent-decoded: filenames needing
93
+ # escapes don't occur in generated reports, and keeping `%2e%2e%2f` as
94
+ # literal bytes is part of the traversal defense. If decoding is ever
95
+ # added, it must happen BEFORE the `inside?` check.
86
96
  def resolve(request_path, root)
87
- path = request_path.split("?", 2).first.to_s.sub(%r{^/}, "")
88
- 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
+ path = request_path.split("?").first.to_s.delete_prefix("/")
98
+ absolute_root = REAL_PATHS.realpath(root)
99
+ candidate = File.expand_path(path, absolute_root)
100
+ # Rejected before touching disk, so traversal and absolute-path attempts
101
+ # are 403, not 404.
92
102
  return :forbidden unless inside?(candidate, absolute_root)
93
103
 
94
104
  candidate = File.join(candidate, "index.html") if File.directory?(candidate)
95
105
  return nil unless File.file?(candidate)
96
106
 
97
- # Resolve symlinks last and re-check: a file inside root could
98
- # be a symlink pointing outside (e.g. /etc/passwd).
99
- real = File.realpath(candidate)
107
+ # Symlinks are resolved last and re-checked: a file inside root could be
108
+ # a symlink pointing outside.
109
+ real = REAL_PATHS.realpath(candidate)
100
110
  inside?(real, absolute_root) ? real : :forbidden
101
111
  rescue Errno::ENOENT
102
- # simplecov:disable — TOCTOU: candidate vanished between
103
- # File.file? and File.realpath. Treat as "not found".
112
+ # TOCTOU: candidate vanished between File.file? and resolving it.
104
113
  nil
105
- # simplecov:enable
106
114
  end
107
115
 
108
116
  def inside?(path, root)
109
- path == root || path.start_with?(root + File::SEPARATOR)
117
+ path.eql?(root) || path.start_with?(root + File::SEPARATOR)
110
118
  end
111
119
 
112
120
  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")
121
+ client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || "Error"}\r\n",
122
+ "Content-Type: #{content_type || "application/octet-stream"}\r\n",
123
+ "Content-Length: #{body.bytesize}\r\n",
124
+ "Connection: close\r\n\r\n")
117
125
  client.write(body)
118
126
  end
119
127
  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