simplecov 1.0.2 → 1.1.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1425
  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 +21 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -48
  9. data/lib/simplecov/cli/dotfile.rb +12 -6
  10. data/lib/simplecov/cli/merge.rb +12 -10
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +31 -23
  13. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  14. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  15. data/lib/simplecov/cli/serve.rb +30 -94
  16. data/lib/simplecov/cli/uncovered.rb +20 -25
  17. data/lib/simplecov/cli.rb +15 -2
  18. data/lib/simplecov/combine/branches_combiner.rb +43 -19
  19. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  20. data/lib/simplecov/combine/identity_interner.rb +30 -0
  21. data/lib/simplecov/combine/interned_counts.rb +30 -0
  22. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  23. data/lib/simplecov/combine/methods_combiner.rb +46 -21
  24. data/lib/simplecov/combine/results_combiner.rb +12 -37
  25. data/lib/simplecov/combine.rb +5 -23
  26. data/lib/simplecov/command_guesser.rb +66 -9
  27. data/lib/simplecov/configuration/coverage.rb +12 -15
  28. data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
  29. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  30. data/lib/simplecov/configuration/filters.rb +11 -44
  31. data/lib/simplecov/configuration/formatting.rb +18 -9
  32. data/lib/simplecov/configuration/groups.rb +42 -0
  33. data/lib/simplecov/configuration/merging.rb +8 -7
  34. data/lib/simplecov/configuration/thresholds.rb +14 -13
  35. data/lib/simplecov/configuration.rb +13 -43
  36. data/lib/simplecov/coverage_json.rb +24 -0
  37. data/lib/simplecov/coverage_statistics.rb +1 -1
  38. data/lib/simplecov/coverage_violations.rb +15 -5
  39. data/lib/simplecov/defaults.rb +7 -3
  40. data/lib/simplecov/directive.rb +1 -1
  41. data/lib/simplecov/exit_codes/check.rb +33 -0
  42. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  43. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  45. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  46. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
  47. data/lib/simplecov/exit_codes.rb +1 -0
  48. data/lib/simplecov/exit_handling.rb +11 -41
  49. data/lib/simplecov/file_list.rb +5 -10
  50. data/lib/simplecov/filter.rb +43 -9
  51. data/lib/simplecov/formatter/base.rb +11 -0
  52. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  53. data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
  54. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  55. data/lib/simplecov/formatter/html_formatter.rb +67 -47
  56. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
  57. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  58. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  59. data/lib/simplecov/formatter/json_formatter.rb +5 -48
  60. data/lib/simplecov/formatter/multi_formatter.rb +1 -1
  61. data/lib/simplecov/formatter/simple_formatter.rb +8 -5
  62. data/lib/simplecov/formatter.rb +12 -0
  63. data/lib/simplecov/group_names.rb +32 -0
  64. data/lib/simplecov/last_run.rb +16 -9
  65. data/lib/simplecov/lines_classifier.rb +29 -8
  66. data/lib/simplecov/load_global_config.rb +5 -2
  67. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  68. data/lib/simplecov/parallel_adapters/generic.rb +2 -2
  69. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  70. data/lib/simplecov/parallel_coordination.rb +6 -1
  71. data/lib/simplecov/parallel_result_merger.rb +230 -0
  72. data/lib/simplecov/report_deferral.rb +49 -0
  73. data/lib/simplecov/report_stamp.rb +28 -0
  74. data/lib/simplecov/result.rb +40 -10
  75. data/lib/simplecov/result_adapter.rb +48 -16
  76. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  77. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  78. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  79. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  80. data/lib/simplecov/result_merger.rb +69 -42
  81. data/lib/simplecov/result_processing.rb +82 -43
  82. data/lib/simplecov/run_identity.rb +77 -0
  83. data/lib/simplecov/simulate_coverage.rb +35 -11
  84. data/lib/simplecov/source_file/method.rb +7 -1
  85. data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
  86. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  87. data/lib/simplecov/source_file/source_loader.rb +23 -7
  88. data/lib/simplecov/source_file/statistics.rb +24 -16
  89. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
  90. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
  91. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  92. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  93. data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
  94. data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
  95. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  96. data/lib/simplecov/version.rb +1 -1
  97. data/lib/simplecov.rb +11 -5
  98. data/schemas/coverage-v1.0.schema.json +2 -2
  99. data/schemas/coverage.schema.json +2 -2
  100. data/sig/simplecov.rbs +206 -67
  101. metadata +28 -14
  102. data/doc/alternate-formatters.md +0 -66
  103. data/doc/commercial-services.md +0 -25
  104. data/doc/editor-integration.md +0 -18
  105. data/lib/simplecov/combine/files_combiner.rb +0 -70
  106. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  107. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  108. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  109. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  110. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
@@ -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,27 +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
41
+
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
42
46
 
43
- stderr.puts("simplecov report: #{input} not found")
44
- nil
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
54
  none = {} #: Hash[String, untyped]
49
- emit_totals(stdout, "All Files", data.fetch("total", none), color)
50
- data.fetch("groups", none).each { |name, group| emit_totals(stdout, name, group, color) }
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
51
60
  end
52
61
 
53
62
  def emit_totals(stdout, label, totals, color)
@@ -59,17 +68,16 @@ module SimpleCov
59
68
  def emit_section(stdout, display, section, color)
60
69
  return unless section.is_a?(Hash) && section["total"].to_i.positive?
61
70
 
62
- stdout.puts(format(" %<label>-7s %<pct>s (%<covered>d / %<total>d)",
63
- label: "#{display}:",
64
- pct: SimpleCov::Color.colorize_percent(section["percent"].to_f, enabled: color),
65
- covered: section["covered"] || 0,
66
- 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"]))
67
75
  end
68
76
 
69
77
  def emit_json(stdout, data)
70
78
  none = {} #: Hash[String, untyped]
71
- payload = {"All Files" => collect_section(data.fetch("total", none))}
72
- data.fetch("groups", none).each { |name, group| payload[name] = collect_section(group) }
79
+ groups = data.fetch("groups", none).transform_values { |group| collect_section(group) }
80
+ payload = {"total" => collect_section(data.fetch("total", none)), "groups" => groups}
73
81
  stdout.puts(JSON.pretty_generate(payload))
74
82
  end
75
83
 
@@ -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,52 +1,46 @@
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
- with_server(opts) do |server|
28
+ with_server(opts, stderr) do |server|
40
29
  announce(stdout, server, dir)
41
30
  serve_loop(server, dir, stdout)
42
31
  0
43
32
  end
44
33
  end
45
34
 
46
- def with_server(opts)
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)
47
38
  # The receiver cast works around an rbs stdlib gap: TCPSocket's
48
39
  # explicit `self.new` shadows TCPServer#initialize's (host, port) form.
49
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
50
44
  begin
51
45
  yield server
52
46
  ensure
@@ -65,85 +59,27 @@ module SimpleCov
65
59
 
66
60
  def announce(stdout, server, dir)
67
61
  port = server.addr[1]
68
- host = server.addr[3]
62
+ host = url_host(server.addr[3])
69
63
  stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
70
64
  stdout.puts("Press Ctrl-C to stop.")
71
65
  end
72
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.
73
76
  def serve_loop(server, dir, stdout)
74
- loop { handle_connection(server.accept, dir) }
77
+ loop do
78
+ Thread.new(server.accept) { |client| StaticFileHandler.handle_connection(client, dir) }
79
+ end
75
80
  rescue Interrupt
76
81
  stdout.puts("\nsimplecov serve: stopping")
77
82
  end
78
-
79
- # Reads one HTTP request line, drains headers, serves the file or
80
- # writes a status response. Wide rescue so a misbehaving client
81
- # can't crash the server.
82
- def handle_connection(client, root)
83
- method, path = client.readline.split
84
- drain_headers(client)
85
- return respond(client, 405) unless method == "GET"
86
-
87
- file = resolve(path, root)
88
- return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)
89
-
90
- respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
91
- rescue StandardError
92
- # Misbehaving clients (truncated requests, connection resets,
93
- # invalid encoding) shouldn't take the whole server down.
94
- nil
95
- ensure
96
- # simplecov:disable — `client` is the parameter, never nil here;
97
- # the `&.` is purely defensive in case of future refactors
98
- client&.close
99
- # simplecov:enable
100
- end
101
-
102
- def drain_headers(client)
103
- loop { break if client.readline.strip.empty? }
104
- end
105
-
106
- # Returns the absolute path of the file to serve, :forbidden for
107
- # a traversal attempt (including symlinks that escape root), or
108
- # nil for "not found".
109
- def resolve(request_path, root)
110
- path = request_path.split("?", 2).first.to_s.sub(%r{^/}, "")
111
- absolute_root = File.realpath(root)
112
- candidate = File.expand_path(path.empty? ? "index.html" : path, absolute_root)
113
- # Reject `..` traversal and absolute-path attempts before
114
- # touching disk so they're 403, not 404.
115
- return :forbidden unless inside?(candidate, absolute_root)
116
-
117
- candidate = File.join(candidate, "index.html") if File.directory?(candidate)
118
- return nil unless File.file?(candidate)
119
-
120
- # Resolve symlinks last and re-check: a file inside root could
121
- # be a symlink pointing outside (e.g. /etc/passwd).
122
- real = File.realpath(candidate)
123
- inside?(real, absolute_root) ? real : :forbidden
124
- rescue Errno::ENOENT
125
- # simplecov:disable — TOCTOU: candidate vanished between
126
- # File.file? and File.realpath. Treat as "not found".
127
- nil
128
- # simplecov:enable
129
- end
130
-
131
- def inside?(path, root)
132
- path == root || path.start_with?(root + File::SEPARATOR)
133
- end
134
-
135
- def respond(client, status, body = "", content_type = "text/plain")
136
- client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || 'Error'}\r\n",
137
- "Content-Type: #{content_type || 'application/octet-stream'}\r\n",
138
- "Content-Length: #{body.bytesize}\r\n",
139
- "Connection: close\r\n\r\n")
140
- client.write(body)
141
- end
142
-
143
- def error(stderr, message)
144
- stderr.puts("simplecov serve: #{message}")
145
- 1
146
- end
147
83
  end
148
84
  end
149
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,30 +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
- none = {} #: Hash[String, untyped]
37
- files = rank(data.fetch("coverage", none), opts[:threshold], keys).first(opts[:top])
32
+ files = rank(coverage, opts[:threshold], keys).first(opts[:top])
38
33
  return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?
39
34
 
40
35
  emit(stdout, files, opts)
@@ -51,22 +46,22 @@ module SimpleCov
51
46
  end
52
47
 
53
48
  def parse(args)
54
- opts = {input: SimpleCov::CLI.default_input, threshold: 100.0, top: DEFAULT_TOP, criterion: :line, no_color: false}
55
- 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
56
54
  opts
57
55
  end
58
56
 
59
- # Option parsing with per-flag coercions is inherently ABC-heavy; the
60
- # metric is noise here.
61
- def build_parser(opts) # rubocop:disable Metrics/AbcSize
62
- OptionParser.new do |o|
63
- o.on("--input PATH") { |v| opts[:input] = v }
64
- o.on("--threshold N", Float) { |v| opts[:threshold] = v }
65
- o.on("--top N", Integer) { |v| opts[:top] = v }
66
- o.on("--criterion C") { |v| opts[:criterion] = v.to_sym }
67
- o.on("--json") { opts[:json] = true }
68
- o.on("--no-color") { opts[:no_color] = true }
69
- 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
70
65
  end
71
66
 
72
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%
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../source_file/ruby_data_parser"
4
+ require_relative "identity_interner"
5
+ require_relative "interned_counts"
4
6
 
5
7
  module SimpleCov
6
8
  module Combine
7
9
  #
8
10
  # Combine different branch coverage results on single file.
9
11
  #
10
- # Should be called through `SimpleCov.combine`.
12
+ # Should be called through `CoverageAccumulator`.
11
13
  module BranchesCombiner
12
14
  module_function
13
15
 
@@ -24,31 +26,53 @@ module SimpleCov
24
26
  # @return [Hash]
25
27
  #
26
28
  def combine(coverage_a, coverage_b)
27
- merged = {} #: Hash[untyped, [untyped, Hash[untyped, untyped]]]
28
- combined = [coverage_a, coverage_b].each_with_object(merged) do |coverage, memo|
29
- coverage.each do |condition, branches_inside|
30
- condition_key = tuple_identity(condition)
31
- condition_tuple, merged_branches = memo[condition_key] ||= [condition, {}]
32
- merge_branches(merged_branches, branches_inside)
33
- memo[condition_key] = [condition_tuple, merged_branches]
34
- end
29
+ merged = absorb({}, coverage_a) #: Hash[untyped, [untyped, Hash[untyped, untyped]]]
30
+ materialize(absorb(merged, coverage_b))
31
+ end
32
+
33
+ # Folds `coverage` into `target`, an interned table keyed by condition
34
+ # identity. Kept interned (rather than turned back into tuple keys per
35
+ # merge) so a fold over N resultsets interns each condition once
36
+ # instead of re-interning the whole accumulated table N times.
37
+ #
38
+ # @return [Hash] `target`
39
+ def absorb(target, coverage)
40
+ return target unless coverage
41
+
42
+ coverage.each do |condition, branches_inside|
43
+ entry = target[identities[condition]] ||= new_condition(condition)
44
+ InternedCounts.absorb_counts(entry[1], branches_inside, identities)
35
45
  end
36
46
 
37
- combined.values.to_h { |condition, branches| [condition, branches.values.to_h] }
47
+ target
38
48
  end
39
49
 
40
- def merge_branches(target, source)
41
- source.each do |branch, count|
42
- branch_key = tuple_identity(branch)
43
- branch_tuple, existing_count = target[branch_key]
44
- target[branch_key] = [branch_tuple || branch, existing_count ? existing_count + count : count]
45
- end
50
+ # Split out so the empty arm table has somewhere to carry its type
51
+ # annotation. Only allocated the first time a condition is seen.
52
+ def new_condition(condition)
53
+ arms = {} #: Hash[untyped, untyped]
54
+ [condition, arms]
55
+ end
56
+
57
+ # Turns an interned table back into the tuple-keyed hash the rest of
58
+ # SimpleCov reads. Done once, at the end of a fold.
59
+ #
60
+ # @return [Hash]
61
+ def materialize(target)
62
+ target.values.to_h { |condition, branches| [condition, branches.values.to_h] }
63
+ end
64
+
65
+ # Bounded by the project's branch count, like `RubyDataParser`'s parse
66
+ # cache.
67
+ def identities
68
+ @identities ||= IdentityInterner.build { |tuple| tuple_identity(tuple) }
46
69
  end
47
70
 
71
+ # Branches match on source span, whatever ids the recording processes
72
+ # handed them (issue #1233).
48
73
  def tuple_identity(tuple)
49
- tuple = SourceFile::RubyDataParser.call(tuple)
50
- type, _id, start_line, start_column, end_line, end_column = tuple
51
- [type, start_line, start_column, end_line, end_column]
74
+ type, _id, start_line, start_column, end_line, end_column = SourceFile::RubyDataParser.call(tuple)
75
+ [type, start_line, start_column, end_line, end_column].freeze
52
76
  end
53
77
  end
54
78
  end