simplecov 1.0.0 → 1.1.1

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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -1451
  3. data/lib/simplecov/atomic_file.rb +70 -0
  4. data/lib/simplecov/cli/clean.rb +43 -3
  5. data/lib/simplecov/cli/command_helpers.rb +55 -0
  6. data/lib/simplecov/cli/coverage.rb +23 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -47
  9. data/lib/simplecov/cli/dotfile.rb +21 -10
  10. data/lib/simplecov/cli/merge.rb +17 -13
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +38 -25
  13. data/lib/simplecov/cli/run.rb +1 -1
  14. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  15. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  16. data/lib/simplecov/cli/serve.rb +45 -99
  17. data/lib/simplecov/cli/uncovered.rb +20 -24
  18. data/lib/simplecov/cli.rb +15 -2
  19. data/lib/simplecov/color.rb +10 -1
  20. data/lib/simplecov/combine/branches_combiner.rb +43 -18
  21. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  22. data/lib/simplecov/combine/identity_interner.rb +30 -0
  23. data/lib/simplecov/combine/interned_counts.rb +30 -0
  24. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  25. data/lib/simplecov/combine/methods_combiner.rb +53 -5
  26. data/lib/simplecov/combine/results_combiner.rb +12 -36
  27. data/lib/simplecov/combine.rb +5 -23
  28. data/lib/simplecov/command_guesser.rb +66 -9
  29. data/lib/simplecov/configuration/coverage.rb +17 -16
  30. data/lib/simplecov/configuration/coverage_criteria.rb +37 -39
  31. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  32. data/lib/simplecov/configuration/filters.rb +11 -44
  33. data/lib/simplecov/configuration/formatting.rb +33 -14
  34. data/lib/simplecov/configuration/groups.rb +42 -0
  35. data/lib/simplecov/configuration/merging.rb +8 -7
  36. data/lib/simplecov/configuration/thresholds.rb +27 -22
  37. data/lib/simplecov/configuration.rb +21 -42
  38. data/lib/simplecov/coverage_json.rb +24 -0
  39. data/lib/simplecov/coverage_statistics.rb +5 -2
  40. data/lib/simplecov/coverage_violations.rb +17 -6
  41. data/lib/simplecov/defaults.rb +10 -6
  42. data/lib/simplecov/directive.rb +9 -4
  43. data/lib/simplecov/exit_codes/check.rb +33 -0
  44. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  45. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +4 -17
  46. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  47. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  48. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +7 -20
  49. data/lib/simplecov/exit_codes.rb +17 -0
  50. data/lib/simplecov/exit_handling.rb +13 -43
  51. data/lib/simplecov/file_list.rb +14 -12
  52. data/lib/simplecov/filter.rb +43 -9
  53. data/lib/simplecov/formatter/base.rb +18 -1
  54. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  55. data/lib/simplecov/formatter/html_formatter/public/index.html +36 -5
  56. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +71 -45
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -50
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  61. data/lib/simplecov/formatter/json_formatter.rb +5 -46
  62. data/lib/simplecov/formatter/multi_formatter.rb +8 -3
  63. data/lib/simplecov/formatter/simple_formatter.rb +8 -3
  64. data/lib/simplecov/formatter.rb +12 -0
  65. data/lib/simplecov/group_names.rb +32 -0
  66. data/lib/simplecov/last_run.rb +16 -9
  67. data/lib/simplecov/lines_classifier.rb +29 -8
  68. data/lib/simplecov/load_global_config.rb +5 -2
  69. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  70. data/lib/simplecov/parallel_adapters/generic.rb +9 -8
  71. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  72. data/lib/simplecov/parallel_adapters.rb +4 -2
  73. data/lib/simplecov/parallel_coordination.rb +6 -1
  74. data/lib/simplecov/parallel_result_merger.rb +230 -0
  75. data/lib/simplecov/process.rb +22 -0
  76. data/lib/simplecov/report_deferral.rb +49 -0
  77. data/lib/simplecov/report_stamp.rb +28 -0
  78. data/lib/simplecov/result.rb +40 -10
  79. data/lib/simplecov/result_adapter.rb +79 -10
  80. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  81. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  82. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  83. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  84. data/lib/simplecov/result_merger.rb +69 -42
  85. data/lib/simplecov/result_processing.rb +85 -46
  86. data/lib/simplecov/run_identity.rb +77 -0
  87. data/lib/simplecov/simulate_coverage.rb +37 -12
  88. data/lib/simplecov/source_file/line.rb +2 -2
  89. data/lib/simplecov/source_file/method.rb +8 -2
  90. data/lib/simplecov/source_file/method_builder.rb +3 -1
  91. data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
  92. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  93. data/lib/simplecov/source_file/source_loader.rb +25 -9
  94. data/lib/simplecov/source_file/statistics.rb +24 -16
  95. data/lib/simplecov/source_file.rb +2 -2
  96. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  97. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  98. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  99. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  100. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  101. data/lib/simplecov/static_coverage_extractor/visitor.rb +74 -62
  102. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  103. data/lib/simplecov/version.rb +1 -1
  104. data/lib/simplecov.rb +37 -17
  105. data/sig/simplecov.rbs +1777 -0
  106. metadata +32 -16
  107. data/doc/alternate-formatters.md +0 -66
  108. data/doc/commercial-services.md +0 -25
  109. data/doc/editor-integration.md +0 -18
  110. data/lib/simplecov/combine/files_combiner.rb +0 -30
  111. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  112. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  113. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  114. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  115. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  116. data/schemas/coverage-v1.0.schema.json +0 -306
  117. data/schemas/coverage.schema.json +0 -306
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "command_helpers"
4
5
 
5
6
  module SimpleCov
6
7
  module CLI
@@ -8,6 +9,8 @@ module SimpleCov
8
9
  # platform's default browser. Tiny QoL wrapper around `xdg-open` /
9
10
  # `open` / `start` so users don't have to type a file:// URL.
10
11
  module Open
12
+ extend CommandHelpers
13
+
11
14
  module_function
12
15
 
13
16
  def run(args, stderr:, **)
@@ -20,11 +23,6 @@ module SimpleCov
20
23
  system(*opener, path) ? 0 : 1
21
24
  end
22
25
 
23
- def error(stderr, message)
24
- stderr.puts("simplecov open: #{message}")
25
- 1
26
- end
27
-
28
26
  def parse(args)
29
27
  path = SimpleCov::CLI.default_report
30
28
  OptionParser.new do |o|
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "optparse"
5
+ require_relative "command_helpers"
5
6
 
6
7
  module SimpleCov
7
8
  module CLI
@@ -11,12 +12,15 @@ module SimpleCov
11
12
  # contexts where opening a browser isn't an option (CI logs, ssh
12
13
  # sessions, terminal-only workflows).
13
14
  module Report
15
+ extend CommandHelpers
16
+
17
+ TOTAL_LABEL = "All Files"
14
18
  SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze
15
19
 
16
20
  module_function
17
21
 
18
22
  def run(args, stdout:, stderr:)
19
- opts = parse(args)
23
+ opts, = parse_common(args)
20
24
  return 1 unless (data = load_data(opts[:input], stderr))
21
25
 
22
26
  if opts[:json]
@@ -27,26 +31,32 @@ module SimpleCov
27
31
  0
28
32
  end
29
33
 
30
- def parse(args)
31
- opts = {input: SimpleCov::CLI.default_input, json: false, no_color: false}
32
- OptionParser.new do |o|
33
- o.on("--input PATH") { |v| opts[:input] = v }
34
- o.on("--json") { opts[:json] = true }
35
- o.on("--no-color") { opts[:no_color] = true }
36
- end.parse(args)
37
- opts
38
- end
39
-
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.
40
38
  def load_data(input, stderr)
41
- return JSON.parse(File.read(input)) if File.exist?(input)
39
+ data = CoverageFile.load_document(input, command: "report", stderr: stderr)
40
+ return unless data
42
41
 
43
- stderr.puts("simplecov report: #{input} not found")
44
- nil
42
+ none = {} #: Hash[String, untyped]
43
+ unless data.fetch("total", none).is_a?(Hash)
44
+ return CoverageFile.report_invalid(stderr, "report", input, '"total" must be an object')
45
+ end
46
+
47
+ groups = data.fetch("groups", none)
48
+ return data if groups.is_a?(Hash) && groups.each_value.all?(Hash)
49
+
50
+ CoverageFile.report_invalid(stderr, "report", input, '"groups" must be an object of objects')
45
51
  end
46
52
 
47
53
  def emit_text(stdout, data, color)
48
- emit_totals(stdout, "All Files", data.fetch("total", {}), color)
49
- data.fetch("groups", {}).each { |name, group| emit_totals(stdout, name, group, color) }
54
+ none = {} #: Hash[String, untyped]
55
+ emit_totals(stdout, TOTAL_LABEL, data.fetch("total", none), color)
56
+ data.fetch("groups", none).each do |name, group|
57
+ label = name == TOTAL_LABEL ? "#{name} (group)" : name
58
+ emit_totals(stdout, label, group, color)
59
+ end
50
60
  end
51
61
 
52
62
  def emit_totals(stdout, label, totals, color)
@@ -58,25 +68,28 @@ module SimpleCov
58
68
  def emit_section(stdout, display, section, color)
59
69
  return unless section.is_a?(Hash) && section["total"].to_i.positive?
60
70
 
61
- stdout.puts(format(" %<label>-7s %<pct>s (%<covered>d / %<total>d)",
62
- label: "#{display}:",
63
- pct: SimpleCov::Color.colorize_percent(section["percent"].to_f, enabled: color),
64
- covered: section["covered"] || 0,
65
- total: section["total"] || 0))
71
+ stdout.puts(stats_row(display,
72
+ SimpleCov::Color.colorize_percent(section["percent"].to_f, enabled: color),
73
+ section["covered"],
74
+ section["total"]))
66
75
  end
67
76
 
68
77
  def emit_json(stdout, data)
69
- payload = {"All Files" => collect_section(data.fetch("total", {}))}
70
- data.fetch("groups", {}).each { |name, group| payload[name] = collect_section(group) }
78
+ none = {} #: Hash[String, untyped]
79
+ groups = data.fetch("groups", none).transform_values { |group| collect_section(group) }
80
+ payload = {"total" => collect_section(data.fetch("total", none)), "groups" => groups}
71
81
  stdout.puts(JSON.pretty_generate(payload))
72
82
  end
73
83
 
74
84
  def collect_section(totals)
75
- SECTIONS.each_with_object({}) do |(_, key), out|
85
+ sections = {} #: Hash[String, untyped]
86
+ SECTIONS.each_with_object(sections) do |(_, key), out|
76
87
  section = totals[key]
77
88
  next unless section.is_a?(Hash) && section["total"].to_i.positive?
78
89
 
79
- out[key] = {"percent" => section["percent"], "covered" => section["covered"], "total" => section["total"]}
90
+ out[key.to_s] = {
91
+ "percent" => section["percent"], "covered" => section["covered"], "total" => section["total"]
92
+ }
80
93
  end
81
94
  end
82
95
  end
@@ -8,7 +8,7 @@ module SimpleCov
8
8
  # without a test_helper that already calls SimpleCov.start (e.g.
9
9
  # plain `bundle exec rake test` on an unconfigured library).
10
10
  module Run
11
- AUTOSTART = File.expand_path("../autostart", __dir__)
11
+ AUTOSTART = File.expand_path("../autostart", __dir__.to_s)
12
12
 
13
13
  module_function
14
14
 
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../coverage_json"
4
+
5
+ module SimpleCov
6
+ module CLI
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
+ module ReportPreparer
12
+ def self.call(dir)
13
+ index_path = File.join(dir, "index.html")
14
+ json_path = File.join(dir, "coverage.json")
15
+ return "#{dir} doesn't exist; run your test suite first" unless File.directory?(dir)
16
+ return if File.file?(index_path)
17
+
18
+ return "#{dir} has no index.html or coverage.json; run your test suite first" unless File.file?(json_path)
19
+
20
+ require_relative "../../formatter/html_formatter"
21
+ SimpleCov::Formatter::HTMLFormatter.new.format_from_json(json_path, dir)
22
+ nil
23
+ rescue SimpleCov::CoverageJSON::Error, SystemCallError => e
24
+ "cannot build index.html from #{json_path}: #{e.message}"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
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.
10
+ module StaticFileHandler
11
+ MIME = {
12
+ ".html" => "text/html; charset=utf-8", ".htm" => "text/html; charset=utf-8",
13
+ ".css" => "text/css",
14
+ ".js" => "application/javascript",
15
+ ".json" => "application/json",
16
+ ".svg" => "image/svg+xml",
17
+ ".png" => "image/png",
18
+ ".gif" => "image/gif",
19
+ ".jpg" => "image/jpeg",
20
+ ".jpeg" => "image/jpeg",
21
+ ".ico" => "image/x-icon",
22
+ ".txt" => "text/plain; charset=utf-8"
23
+ }.freeze
24
+ STATUS_TEXT = {200 => "OK", 400 => "Bad Request", 403 => "Forbidden",
25
+ 404 => "Not Found", 405 => "Method Not Allowed"}.freeze
26
+
27
+ # Seconds a connection may sit idle mid-request before its reads
28
+ # raise IO::TimeoutError and the connection is dropped.
29
+ READ_TIMEOUT = 5
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.
44
+ client.timeout = READ_TIMEOUT if client.respond_to?(:timeout=)
45
+ # simplecov:enable branch
46
+ method, path = client.readline.split
47
+ 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.
51
+ return respond(client, 400) if path.nil?
52
+ return respond(client, 405) unless method == "GET"
53
+
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.
58
+ nil
59
+ 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
64
+ end
65
+
66
+ def serve_file(client, path, root)
67
+ file = resolve(path, root)
68
+ return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)
69
+
70
+ respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
71
+ end
72
+
73
+ def drain_headers(client)
74
+ loop { break if client.readline.strip.empty? }
75
+ end
76
+
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".
80
+ #
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.
86
+ 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.
92
+ return :forbidden unless inside?(candidate, absolute_root)
93
+
94
+ candidate = File.join(candidate, "index.html") if File.directory?(candidate)
95
+ return nil unless File.file?(candidate)
96
+
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)
100
+ inside?(real, absolute_root) ? real : :forbidden
101
+ rescue Errno::ENOENT
102
+ # simplecov:disable — TOCTOU: candidate vanished between
103
+ # File.file? and File.realpath. Treat as "not found".
104
+ nil
105
+ # simplecov:enable
106
+ end
107
+
108
+ def inside?(path, root)
109
+ path == root || path.start_with?(root + File::SEPARATOR)
110
+ end
111
+
112
+ 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")
117
+ client.write(body)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -1,51 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "command_helpers"
5
+ require_relative "serve/report_preparer"
6
+ require_relative "serve/static_file_handler"
4
7
 
5
8
  module SimpleCov
6
9
  module CLI
7
10
  # `simplecov serve [--port N] [--host HOST]` — serve the coverage
8
- # report over HTTP. A 30-line static file server backed by stdlib
11
+ # report over HTTP. A small static file server backed by stdlib
9
12
  # `socket`, so there's no extra dependency just for "view a local
10
- # report on a CI box where `file://` doesn't work."
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.
11
16
  module Serve
12
- MIME = {
13
- ".html" => "text/html; charset=utf-8",
14
- ".htm" => "text/html; charset=utf-8",
15
- ".css" => "text/css",
16
- ".js" => "application/javascript",
17
- ".json" => "application/json",
18
- ".svg" => "image/svg+xml",
19
- ".png" => "image/png",
20
- ".gif" => "image/gif",
21
- ".jpg" => "image/jpeg",
22
- ".jpeg" => "image/jpeg",
23
- ".ico" => "image/x-icon",
24
- ".txt" => "text/plain; charset=utf-8"
25
- }.freeze
26
- STATUS_TEXT = {
27
- 200 => "OK", 400 => "Bad Request", 403 => "Forbidden",
28
- 404 => "Not Found", 405 => "Method Not Allowed"
29
- }.freeze
17
+ extend CommandHelpers
30
18
 
31
19
  module_function
32
20
 
33
21
  def run(args, stdout:, stderr:, **)
34
22
  opts = parse(args)
35
23
  dir = SimpleCov::CLI.coverage_dir
36
- return error(stderr, "#{dir} doesn't exist; run your test suite first") unless File.directory?(dir)
24
+ message = ReportPreparer.call(dir)
25
+ return error(stderr, message) if message
37
26
 
38
27
  require "socket"
39
- server = TCPServer.new(opts[:host], opts[:port])
40
- announce(stdout, server, dir)
41
- serve_loop(server, dir, stdout)
42
- 0
43
- ensure
44
- server&.close
28
+ with_server(opts, stderr) do |server|
29
+ announce(stdout, server, dir)
30
+ serve_loop(server, dir, stdout)
31
+ 0
32
+ end
33
+ end
34
+
35
+ # Returns the block's exit status, or 1 when the socket can't be
36
+ # bound (port already taken, privileged port, unresolvable host).
37
+ 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
41
+ rescue SystemCallError, SocketError => e
42
+ error(stderr, "cannot bind to #{opts[:host]}:#{opts[:port]} (#{e.message})")
43
+ else
44
+ begin
45
+ yield server
46
+ ensure
47
+ server.close
48
+ end
45
49
  end
46
50
 
47
51
  def parse(args)
48
- opts = {port: 0, host: "127.0.0.1"}
52
+ opts = {port: 0, host: "127.0.0.1"} #: Hash[Symbol, untyped]
49
53
  OptionParser.new do |o|
50
54
  o.on("--port N", Integer) { |v| opts[:port] = v }
51
55
  o.on("--host HOST") { |v| opts[:host] = v }
@@ -55,85 +59,27 @@ module SimpleCov
55
59
 
56
60
  def announce(stdout, server, dir)
57
61
  port = server.addr[1]
58
- host = server.addr[3]
62
+ host = url_host(server.addr[3])
59
63
  stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
60
64
  stdout.puts("Press Ctrl-C to stop.")
61
65
  end
62
66
 
67
+ # IPv6 literals need brackets in a URL (--host ::1 must announce
68
+ # http://[::1]:PORT/, not the invalid http://::1:PORT/).
69
+ def url_host(host)
70
+ host.include?(":") ? "[#{host}]" : host
71
+ end
72
+
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.
63
76
  def serve_loop(server, dir, stdout)
64
- loop { handle_connection(server.accept, dir) }
77
+ loop do
78
+ Thread.new(server.accept) { |client| StaticFileHandler.handle_connection(client, dir) }
79
+ end
65
80
  rescue Interrupt
66
81
  stdout.puts("\nsimplecov serve: stopping")
67
82
  end
68
-
69
- # Reads one HTTP request line, drains headers, serves the file or
70
- # writes a status response. Wide rescue so a misbehaving client
71
- # can't crash the server.
72
- def handle_connection(client, root)
73
- method, path = client.readline.split
74
- drain_headers(client)
75
- return respond(client, 405) unless method == "GET"
76
-
77
- file = resolve(path, root)
78
- return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)
79
-
80
- respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
81
- rescue StandardError
82
- # Misbehaving clients (truncated requests, connection resets,
83
- # invalid encoding) shouldn't take the whole server down.
84
- nil
85
- ensure
86
- # simplecov:disable — `client` is the parameter, never nil here;
87
- # the `&.` is purely defensive in case of future refactors
88
- client&.close
89
- # simplecov:enable
90
- end
91
-
92
- def drain_headers(client)
93
- loop { break if client.readline.strip.empty? }
94
- end
95
-
96
- # Returns the absolute path of the file to serve, :forbidden for
97
- # a traversal attempt (including symlinks that escape root), or
98
- # nil for "not found".
99
- def resolve(request_path, root)
100
- path = request_path.split("?", 2).first.to_s.sub(%r{^/}, "")
101
- absolute_root = File.realpath(root)
102
- candidate = File.expand_path(path.empty? ? "index.html" : path, absolute_root)
103
- # Reject `..` traversal and absolute-path attempts before
104
- # touching disk so they're 403, not 404.
105
- return :forbidden unless inside?(candidate, absolute_root)
106
-
107
- candidate = File.join(candidate, "index.html") if File.directory?(candidate)
108
- return nil unless File.file?(candidate)
109
-
110
- # Resolve symlinks last and re-check: a file inside root could
111
- # be a symlink pointing outside (e.g. /etc/passwd).
112
- real = File.realpath(candidate)
113
- inside?(real, absolute_root) ? real : :forbidden
114
- rescue Errno::ENOENT
115
- # simplecov:disable — TOCTOU: candidate vanished between
116
- # File.file? and File.realpath. Treat as "not found".
117
- nil
118
- # simplecov:enable
119
- end
120
-
121
- def inside?(path, root)
122
- path == root || path.start_with?(root + File::SEPARATOR)
123
- end
124
-
125
- def respond(client, status, body = "", content_type = "text/plain")
126
- client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || 'Error'}\r\n",
127
- "Content-Type: #{content_type || 'application/octet-stream'}\r\n",
128
- "Content-Length: #{body.bytesize}\r\n",
129
- "Connection: close\r\n\r\n")
130
- client.write(body)
131
- end
132
-
133
- def error(stderr, message)
134
- stderr.puts("simplecov serve: #{message}")
135
- 1
136
- end
137
83
  end
138
84
  end
139
85
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "optparse"
5
- require_relative "report"
5
+ require_relative "command_helpers"
6
6
 
7
7
  module SimpleCov
8
8
  module CLI
@@ -11,29 +11,25 @@ module SimpleCov
11
11
  # developer can answer "where should I add tests next?" without
12
12
  # opening a browser. Reads coverage.json directly.
13
13
  module Uncovered
14
- DEFAULT_TOP = 10
14
+ extend CommandHelpers
15
15
 
16
- # The coverage.json fields backing each criterion.
17
- CRITERION_KEYS = {
18
- line: {percent: "lines_covered_percent", covered: "covered_lines", total: "total_lines"},
19
- branch: {percent: "branches_covered_percent", covered: "covered_branches", total: "total_branches"},
20
- method: {percent: "methods_covered_percent", covered: "covered_methods", total: "total_methods"}
21
- }.freeze
16
+ DEFAULT_TOP = 10
22
17
 
23
18
  module_function
24
19
 
25
20
  def run(args, stdout:, stderr:, **)
26
21
  opts = parse(args)
27
- keys = CRITERION_KEYS[opts[:criterion]]
22
+ keys = CoverageFile::CRITERIA[opts[:criterion]]
28
23
  return unknown_criterion(opts[:criterion], stderr) unless keys
29
24
 
30
25
  report(opts, keys, stdout, stderr)
31
26
  end
32
27
 
33
28
  def report(opts, keys, stdout, stderr)
34
- return 1 unless (data = Report.load_data(opts[:input], stderr))
29
+ coverage = CoverageFile.load_coverage(opts[:input], command: "uncovered", stderr: stderr)
30
+ return 1 unless coverage
35
31
 
36
- files = rank(data.fetch("coverage", {}), opts[:threshold], keys).first(opts[:top])
32
+ files = rank(coverage, opts[:threshold], keys).first(opts[:top])
37
33
  return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?
38
34
 
39
35
  emit(stdout, files, opts)
@@ -50,22 +46,22 @@ module SimpleCov
50
46
  end
51
47
 
52
48
  def parse(args)
53
- opts = {input: SimpleCov::CLI.default_input, threshold: 100.0, top: DEFAULT_TOP, criterion: :line, no_color: false}
54
- build_parser(opts).parse(args)
49
+ opts, = parse_common(args, threshold: 100.0, top: DEFAULT_TOP, criterion: :line) do |o, options|
50
+ o.on("--threshold N", Float) { |v| options[:threshold] = v }
51
+ o.on("--top N", Integer) { |v| options[:top] = validate_top(v) }
52
+ o.on("--criterion C") { |v| options[:criterion] = v.to_sym }
53
+ end
55
54
  opts
56
55
  end
57
56
 
58
- # Option parsing with per-flag coercions is inherently ABC-heavy; the
59
- # metric is noise here.
60
- def build_parser(opts) # rubocop:disable Metrics/AbcSize
61
- OptionParser.new do |o|
62
- o.on("--input PATH") { |v| opts[:input] = v }
63
- o.on("--threshold N", Float) { |v| opts[:threshold] = v }
64
- o.on("--top N", Integer) { |v| opts[:top] = v }
65
- o.on("--criterion C") { |v| opts[:criterion] = v.to_sym }
66
- o.on("--json") { opts[:json] = true }
67
- o.on("--no-color") { opts[:no_color] = true }
68
- end
57
+ # A negative count would raise from `Array#first`; report it as
58
+ # the parse error it is (`--top 0` is allowed and shows nothing).
59
+ # OptionParser prepends the offending option's name, so the raise
60
+ # carries only the reason.
61
+ def validate_top(value)
62
+ raise OptionParser::InvalidArgument, "must not be negative" if value.negative?
63
+
64
+ value
69
65
  end
70
66
 
71
67
  def emit_text(stdout, files, color)
data/lib/simplecov/cli.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "optparse"
3
4
  require_relative "color"
4
5
  require_relative "cli/dotfile"
5
6
  require_relative "cli/clean"
7
+ require_relative "cli/coverage_file"
6
8
  require_relative "cli/coverage"
7
9
  require_relative "cli/diff"
8
10
  require_relative "cli/merge"
@@ -73,13 +75,23 @@ module SimpleCov
73
75
  def run(argv, stdout: $stdout, stderr: $stderr)
74
76
  command, *rest = argv
75
77
  handler = COMMANDS[command]
76
- return handler.run(rest, stdout: stdout, stderr: stderr) if handler
78
+ return dispatch(handler, command, rest, stdout: stdout, stderr: stderr) if handler
77
79
  return stdout.puts(usage) || 0 if [nil, "help", "--help", "-h"].include?(command)
78
80
 
79
81
  stderr.puts("simplecov: unknown command #{command.inspect}", usage)
80
82
  1
81
83
  end
82
84
 
85
+ # One rescue covers every subcommand's OptionParser, so a typo'd
86
+ # flag or a malformed typed argument becomes a one-line error and
87
+ # exit status 1 instead of an unhandled-exception backtrace.
88
+ def dispatch(handler, command, rest, stdout:, stderr:)
89
+ handler.run(rest, stdout: stdout, stderr: stderr)
90
+ rescue OptionParser::ParseError => e
91
+ stderr.puts("simplecov #{command}: #{e.message} (run `simplecov help` for usage)")
92
+ 1
93
+ end
94
+
83
95
  def usage
84
96
  <<~USAGE
85
97
  Usage: simplecov <command> [options]
@@ -128,7 +140,8 @@ module SimpleCov
128
140
 
129
141
  diff options:
130
142
  --fail-on-drop Exit non-zero when any file's coverage
131
- dropped vs the baseline
143
+ dropped vs the baseline (deleted
144
+ files don't count as drops)
132
145
  --json Emit results as a JSON array (for CI)
133
146
  --threshold N Only show files whose absolute delta
134
147
  in any criterion is at least N%
@@ -34,12 +34,21 @@ module SimpleCov
34
34
  # doesn't get ANSI sequences. See the module-level comment for
35
35
  # precedence.
36
36
  def enabled?(stream = $stderr)
37
- config = SimpleCov.color
37
+ # `SimpleCov.color` only exists once the full library is loaded.
38
+ # The standalone CLI (`exe/simplecov`) loads `simplecov/color`
39
+ # without `simplecov` itself to stay lightweight, so treat a
40
+ # missing config the same as its `:auto` default: fall through to
41
+ # the env vars and tty check below.
42
+ config = SimpleCov.color if SimpleCov.respond_to?(:color)
38
43
  return config if [true, false].include?(config)
39
44
  return false if env_set?("NO_COLOR")
40
45
  return true if env_set?("FORCE_COLOR")
41
46
 
42
47
  stream.tty?
48
+ rescue IOError
49
+ # A parallel runner can close a worker's stdio before its at_exit
50
+ # hooks run (rspec-conductor does). A closed stream is not a tty.
51
+ false
43
52
  end
44
53
 
45
54
  def for_percent(percent)