simplecov 0.22.0 → 1.0.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +1058 -508
  4. data/doc/alternate-formatters.md +0 -5
  5. data/doc/commercial-services.md +5 -5
  6. data/exe/simplecov +11 -0
  7. data/lib/minitest/simplecov_plugin.rb +13 -5
  8. data/lib/simplecov/autostart.rb +11 -0
  9. data/lib/simplecov/cli/clean.rb +47 -0
  10. data/lib/simplecov/cli/coverage.rb +93 -0
  11. data/lib/simplecov/cli/diff.rb +152 -0
  12. data/lib/simplecov/cli/dotfile.rb +105 -0
  13. data/lib/simplecov/cli/merge.rb +118 -0
  14. data/lib/simplecov/cli/open.rb +50 -0
  15. data/lib/simplecov/cli/report.rb +89 -0
  16. data/lib/simplecov/cli/run.rb +36 -0
  17. data/lib/simplecov/cli/serve.rb +149 -0
  18. data/lib/simplecov/cli/uncovered.rb +108 -0
  19. data/lib/simplecov/cli.rb +150 -0
  20. data/lib/simplecov/color.rb +79 -0
  21. data/lib/simplecov/combine/branches_combiner.rb +28 -5
  22. data/lib/simplecov/combine/files_combiner.rb +47 -1
  23. data/lib/simplecov/combine/lines_combiner.rb +19 -17
  24. data/lib/simplecov/combine/methods_combiner.rb +49 -0
  25. data/lib/simplecov/combine/results_combiner.rb +7 -5
  26. data/lib/simplecov/command_guesser.rb +46 -32
  27. data/lib/simplecov/configuration/coverage.rb +175 -0
  28. data/lib/simplecov/configuration/coverage_criteria.rb +158 -0
  29. data/lib/simplecov/configuration/filters.rb +197 -0
  30. data/lib/simplecov/configuration/formatting.rb +129 -0
  31. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  32. data/lib/simplecov/configuration/merging.rb +164 -0
  33. data/lib/simplecov/configuration/thresholds.rb +178 -0
  34. data/lib/simplecov/configuration.rb +105 -412
  35. data/lib/simplecov/coverage_statistics.rb +15 -9
  36. data/lib/simplecov/coverage_violations.rb +149 -0
  37. data/lib/simplecov/defaults.rb +29 -22
  38. data/lib/simplecov/deprecation.rb +45 -0
  39. data/lib/simplecov/directive.rb +167 -0
  40. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  41. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
  42. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
  43. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
  45. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
  46. data/lib/simplecov/exit_codes.rb +15 -0
  47. data/lib/simplecov/exit_handling.rb +158 -0
  48. data/lib/simplecov/file_list.rb +70 -19
  49. data/lib/simplecov/filter.rb +69 -24
  50. data/lib/simplecov/formatter/base.rb +102 -0
  51. data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
  52. data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
  53. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  54. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  55. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  56. data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +85 -0
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +90 -0
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +128 -0
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
  61. data/lib/simplecov/formatter/json_formatter.rb +89 -0
  62. data/lib/simplecov/formatter/multi_formatter.rb +11 -7
  63. data/lib/simplecov/formatter/simple_formatter.rb +11 -11
  64. data/lib/simplecov/formatter.rb +4 -0
  65. data/lib/simplecov/last_run.rb +10 -3
  66. data/lib/simplecov/lines_classifier.rb +25 -13
  67. data/lib/simplecov/load_global_config.rb +9 -4
  68. data/lib/simplecov/parallel_adapters/base.rb +61 -0
  69. data/lib/simplecov/parallel_adapters/generic.rb +44 -0
  70. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  71. data/lib/simplecov/parallel_adapters.rb +83 -0
  72. data/lib/simplecov/parallel_coordination.rb +129 -0
  73. data/lib/simplecov/process.rb +52 -12
  74. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  75. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  76. data/lib/simplecov/profiles/rails.rb +24 -10
  77. data/lib/simplecov/profiles/root_filter.rb +6 -5
  78. data/lib/simplecov/profiles/strict.rb +32 -0
  79. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  80. data/lib/simplecov/profiles.rb +32 -3
  81. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  82. data/lib/simplecov/result/source_file_builder.rb +51 -0
  83. data/lib/simplecov/result.rb +108 -19
  84. data/lib/simplecov/result_adapter.rb +105 -8
  85. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  86. data/lib/simplecov/result_merger/resultset_file.rb +38 -0
  87. data/lib/simplecov/result_merger/resultset_store.rb +50 -0
  88. data/lib/simplecov/result_merger.rb +59 -91
  89. data/lib/simplecov/result_processing.rb +179 -0
  90. data/lib/simplecov/simulate_coverage.rb +54 -8
  91. data/lib/simplecov/source_file/branch.rb +1 -3
  92. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  93. data/lib/simplecov/source_file/builder_context.rb +28 -0
  94. data/lib/simplecov/source_file/line.rb +9 -4
  95. data/lib/simplecov/source_file/line_builder.rb +43 -0
  96. data/lib/simplecov/source_file/method.rb +52 -0
  97. data/lib/simplecov/source_file/method_builder.rb +60 -0
  98. data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
  99. data/lib/simplecov/source_file/skip_chunks.rb +75 -0
  100. data/lib/simplecov/source_file/source_loader.rb +63 -0
  101. data/lib/simplecov/source_file/statistics.rb +57 -0
  102. data/lib/simplecov/source_file.rb +66 -232
  103. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +49 -0
  104. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +247 -0
  105. data/lib/simplecov/static_coverage_extractor/method_collector.rb +55 -0
  106. data/lib/simplecov/static_coverage_extractor/value_position.rb +82 -0
  107. data/lib/simplecov/static_coverage_extractor/visitor.rb +196 -0
  108. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  109. data/lib/simplecov/useless_results_remover.rb +16 -7
  110. data/lib/simplecov/version.rb +1 -1
  111. data/lib/simplecov-html.rb +4 -0
  112. data/lib/simplecov.rb +178 -380
  113. data/lib/simplecov_json_formatter.rb +4 -0
  114. data/schemas/coverage-v1.0.schema.json +306 -0
  115. data/schemas/coverage.schema.json +306 -0
  116. data/sig/simplecov.rbs +1638 -0
  117. metadata +94 -57
  118. data/CHANGELOG.md +0 -191
  119. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ # `simplecov report [--input PATH]` — pretty-print the overall
9
+ # totals row plus per-group totals from a JSONFormatter
10
+ # coverage.json. Same numbers as the HTML report's totals row, for
11
+ # contexts where opening a browser isn't an option (CI logs, ssh
12
+ # sessions, terminal-only workflows).
13
+ module Report
14
+ SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze
15
+
16
+ module_function
17
+
18
+ def run(args, stdout:, stderr:)
19
+ opts = parse(args)
20
+ return 1 unless (data = load_data(opts[:input], stderr))
21
+
22
+ if opts[:json]
23
+ emit_json(stdout, data)
24
+ else
25
+ emit_text(stdout, data, SimpleCov::CLI.color_enabled?(opts, stdout))
26
+ end
27
+ 0
28
+ end
29
+
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
+
40
+ def load_data(input, stderr)
41
+ return JSON.parse(File.read(input)) if File.exist?(input)
42
+
43
+ stderr.puts("simplecov report: #{input} not found")
44
+ nil
45
+ end
46
+
47
+ def emit_text(stdout, data, color)
48
+ 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) }
51
+ end
52
+
53
+ def emit_totals(stdout, label, totals, color)
54
+ stdout.puts(label)
55
+ SECTIONS.each { |display, key| emit_section(stdout, display, totals[key], color) }
56
+ stdout.puts
57
+ end
58
+
59
+ def emit_section(stdout, display, section, color)
60
+ return unless section.is_a?(Hash) && section["total"].to_i.positive?
61
+
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))
67
+ end
68
+
69
+ def emit_json(stdout, data)
70
+ 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) }
73
+ stdout.puts(JSON.pretty_generate(payload))
74
+ end
75
+
76
+ def collect_section(totals)
77
+ sections = {} #: Hash[String, untyped]
78
+ SECTIONS.each_with_object(sections) do |(_, key), out|
79
+ section = totals[key]
80
+ next unless section.is_a?(Hash) && section["total"].to_i.positive?
81
+
82
+ out[key.to_s] = {
83
+ "percent" => section["percent"], "covered" => section["covered"], "total" => section["total"]
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ # `simplecov run <command...>` — exec the given command with
6
+ # simplecov auto-loaded so a coverage report drops into the
7
+ # project's coverage/ directory at the end. Useful for projects
8
+ # without a test_helper that already calls SimpleCov.start (e.g.
9
+ # plain `bundle exec rake test` on an unconfigured library).
10
+ module Run
11
+ AUTOSTART = File.expand_path("../autostart", __dir__.to_s)
12
+
13
+ module_function
14
+
15
+ def run(args, stderr:, **)
16
+ cmd = args.first == "--" ? args.drop(1) : args
17
+ if cmd.empty?
18
+ stderr.puts("simplecov run: missing command")
19
+ return 1
20
+ end
21
+
22
+ Kernel.exec(rubyopt_env, *cmd)
23
+ rescue Errno::ENOENT => e
24
+ stderr.puts("simplecov run: #{e.message}")
25
+ 127
26
+ end
27
+
28
+ def rubyopt_env
29
+ existing = ENV["RUBYOPT"].to_s.strip
30
+ injection = "-r#{AUTOSTART}"
31
+ merged = existing.empty? ? injection : "#{existing} #{injection}"
32
+ ENV.to_hash.merge("RUBYOPT" => merged)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ # `simplecov serve [--port N] [--host HOST]` — serve the coverage
8
+ # report over HTTP. A 30-line static file server backed by stdlib
9
+ # `socket`, so there's no extra dependency just for "view a local
10
+ # report on a CI box where `file://` doesn't work."
11
+ 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
30
+
31
+ module_function
32
+
33
+ def run(args, stdout:, stderr:, **)
34
+ opts = parse(args)
35
+ dir = SimpleCov::CLI.coverage_dir
36
+ return error(stderr, "#{dir} doesn't exist; run your test suite first") unless File.directory?(dir)
37
+
38
+ require "socket"
39
+ with_server(opts) do |server|
40
+ announce(stdout, server, dir)
41
+ serve_loop(server, dir, stdout)
42
+ 0
43
+ end
44
+ end
45
+
46
+ def with_server(opts)
47
+ # The receiver cast works around an rbs stdlib gap: TCPSocket's
48
+ # explicit `self.new` shadows TCPServer#initialize's (host, port) form.
49
+ server = (_ = TCPServer).new(opts[:host], opts[:port]) #: TCPServer
50
+ begin
51
+ yield server
52
+ ensure
53
+ server.close
54
+ end
55
+ end
56
+
57
+ def parse(args)
58
+ opts = {port: 0, host: "127.0.0.1"} #: Hash[Symbol, untyped]
59
+ OptionParser.new do |o|
60
+ o.on("--port N", Integer) { |v| opts[:port] = v }
61
+ o.on("--host HOST") { |v| opts[:host] = v }
62
+ end.parse(args)
63
+ opts
64
+ end
65
+
66
+ def announce(stdout, server, dir)
67
+ port = server.addr[1]
68
+ host = server.addr[3]
69
+ stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
70
+ stdout.puts("Press Ctrl-C to stop.")
71
+ end
72
+
73
+ def serve_loop(server, dir, stdout)
74
+ loop { handle_connection(server.accept, dir) }
75
+ rescue Interrupt
76
+ stdout.puts("\nsimplecov serve: stopping")
77
+ 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
+ end
148
+ end
149
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "report"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ # `simplecov uncovered [--threshold N] [--top N] [--criterion C]` — list
10
+ # the lowest-coverage files (by the chosen criterion, ascending), so a
11
+ # developer can answer "where should I add tests next?" without
12
+ # opening a browser. Reads coverage.json directly.
13
+ module Uncovered
14
+ DEFAULT_TOP = 10
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
22
+
23
+ module_function
24
+
25
+ def run(args, stdout:, stderr:, **)
26
+ opts = parse(args)
27
+ keys = CRITERION_KEYS[opts[:criterion]]
28
+ return unknown_criterion(opts[:criterion], stderr) unless keys
29
+
30
+ report(opts, keys, stdout, stderr)
31
+ end
32
+
33
+ def report(opts, keys, stdout, stderr)
34
+ return 1 unless (data = Report.load_data(opts[:input], stderr))
35
+
36
+ none = {} #: Hash[String, untyped]
37
+ files = rank(data.fetch("coverage", none), opts[:threshold], keys).first(opts[:top])
38
+ return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?
39
+
40
+ emit(stdout, files, opts)
41
+ 0
42
+ end
43
+
44
+ def unknown_criterion(criterion, stderr)
45
+ stderr.puts("simplecov uncovered: unknown --criterion #{criterion.inspect} (expected line, branch, or method)")
46
+ 1
47
+ end
48
+
49
+ def emit(stdout, files, opts)
50
+ opts[:json] ? emit_json(stdout, files) : emit_text(stdout, files, SimpleCov::CLI.color_enabled?(opts, stdout))
51
+ end
52
+
53
+ 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)
56
+ opts
57
+ end
58
+
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
70
+ end
71
+
72
+ def emit_text(stdout, files, color)
73
+ files.each { |fname, pct, covered, total| stdout.puts(format_row(fname, pct, covered, total, color)) }
74
+ end
75
+
76
+ def emit_json(stdout, files)
77
+ rows = files.map do |fname, pct, covered, total|
78
+ {"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
79
+ end
80
+ stdout.puts(JSON.pretty_generate(rows))
81
+ end
82
+
83
+ def empty_message(json)
84
+ json ? "[]" : "simplecov uncovered: nothing to report"
85
+ end
86
+
87
+ def rank(coverage_hash, threshold, keys)
88
+ rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, threshold, keys) }
89
+ rows.sort_by { |_fname, pct, _c, _t| pct }
90
+ end
91
+
92
+ def row_for(fname, payload, threshold, keys)
93
+ return unless payload.is_a?(Hash) && payload[keys[:total]].to_i.positive?
94
+
95
+ pct = payload[keys[:percent]].to_f
96
+ return if pct >= threshold
97
+
98
+ [fname, pct, payload[keys[:covered]].to_i, payload[keys[:total]].to_i]
99
+ end
100
+
101
+ def format_row(fname, pct, covered, total, color)
102
+ format("%<pct>s %<covered>d/%<total>d %<fname>s",
103
+ pct: SimpleCov::Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
104
+ covered: covered, total: total, fname: fname)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "color"
4
+ require_relative "cli/dotfile"
5
+ require_relative "cli/clean"
6
+ require_relative "cli/coverage"
7
+ require_relative "cli/diff"
8
+ require_relative "cli/merge"
9
+ require_relative "cli/open"
10
+ require_relative "cli/report"
11
+ require_relative "cli/run"
12
+ require_relative "cli/serve"
13
+ require_relative "cli/uncovered"
14
+
15
+ module SimpleCov
16
+ # Lightweight command-line front-end. `run` dispatches a subcommand
17
+ # (`coverage`, `report`, `uncovered`, `merge`, `diff`, `open`, etc.) —
18
+ # see the `usage` text below for the full list, or run `simplecov help`.
19
+ #
20
+ # Read-only subcommands consume JSONFormatter output (`coverage.json`),
21
+ # which the bundled HTMLFormatter already drops alongside the HTML, so
22
+ # no runtime hooking is needed for those. Default paths follow the
23
+ # project's `.simplecov` `SimpleCov.coverage_dir` setting when one is
24
+ # present, so a project that writes its report somewhere other than
25
+ # `coverage/` doesn't have to pass `--input` / `--report` every
26
+ # invocation.
27
+ module CLI
28
+ COMMANDS = {
29
+ "coverage" => Coverage,
30
+ "run" => Run,
31
+ "open" => Open,
32
+ "report" => Report,
33
+ "uncovered" => Uncovered,
34
+ "merge" => Merge,
35
+ "diff" => Diff,
36
+ "serve" => Serve,
37
+ "clean" => Clean
38
+ }.freeze
39
+
40
+ module_function
41
+
42
+ # Resolved once per process. Walks up from cwd looking for a
43
+ # `.simplecov`; if present, the file is loaded with
44
+ # `SimpleCov.start` neutered so it can't trigger coverage tracking
45
+ # or an at_exit hook just because we asked it for a config value.
46
+ def coverage_dir
47
+ @coverage_dir ||= Dotfile.coverage_dir
48
+ end
49
+
50
+ def default_input
51
+ File.join(coverage_dir, "coverage.json")
52
+ end
53
+
54
+ # Resolve "should this subcommand colorize?" once per invocation.
55
+ # `--no-color` (opts[:no_color]) is the per-invocation kill-switch;
56
+ # otherwise we defer to `SimpleCov::Color.enabled?`, which honors
57
+ # `NO_COLOR` / `FORCE_COLOR` and falls back to `stream.tty?`.
58
+ def color_enabled?(opts, stream)
59
+ return false if opts[:no_color]
60
+
61
+ SimpleCov::Color.enabled?(stream)
62
+ end
63
+
64
+ def default_report
65
+ File.join(coverage_dir, "index.html")
66
+ end
67
+
68
+ def default_resultset
69
+ File.join(coverage_dir, ".resultset.json")
70
+ end
71
+
72
+ # Returns a process exit status (0 on success, non-zero on error).
73
+ def run(argv, stdout: $stdout, stderr: $stderr)
74
+ command, *rest = argv
75
+ handler = COMMANDS[command]
76
+ return handler.run(rest, stdout: stdout, stderr: stderr) if handler
77
+ return stdout.puts(usage) || 0 if [nil, "help", "--help", "-h"].include?(command)
78
+
79
+ stderr.puts("simplecov: unknown command #{command.inspect}", usage)
80
+ 1
81
+ end
82
+
83
+ def usage
84
+ <<~USAGE
85
+ Usage: simplecov <command> [options]
86
+
87
+ Commands:
88
+ run <command...> Execute <command> with simplecov pre-loaded
89
+ (so a coverage report is generated even
90
+ when the project has no test_helper hook)
91
+ coverage <path> Print coverage stats for the given file
92
+ report Print the overall summary and group totals
93
+ uncovered List the lowest-coverage files
94
+ merge <files...> Merge multiple .resultset.json files
95
+ diff <baseline> Show per-file coverage delta vs baseline
96
+ open Open the HTML report in the default browser
97
+ serve Serve the coverage report over HTTP
98
+ clean Remove the coverage report directory
99
+ help Show this message
100
+
101
+ Default paths follow SimpleCov.coverage_dir from a project's
102
+ `.simplecov` when one is present (#{coverage_dir} for this run).
103
+
104
+ coverage / report / uncovered / diff options:
105
+ --input PATH Read from PATH instead of #{default_input}
106
+ --no-color Disable colorized percentages
107
+ (also honors NO_COLOR / FORCE_COLOR env)
108
+
109
+ coverage options:
110
+ --json Print the file's JSON entry verbatim
111
+
112
+ report options:
113
+ --json Emit totals and group sections as JSON
114
+
115
+ uncovered options:
116
+ --threshold N Only show files below N% coverage
117
+ --top N Show at most N files (default: 10)
118
+ --criterion C line, branch, or method (default: line)
119
+ --json Emit results as a JSON array (for CI)
120
+
121
+ merge options:
122
+ --output PATH Write merged resultset to PATH
123
+ (default: #{default_resultset})
124
+ --honor-timeout Drop entries older than merge_timeout
125
+ --dry-run Print what would be written without
126
+ actually writing
127
+ -q, --quiet Suppress the success status line
128
+
129
+ diff options:
130
+ --fail-on-drop Exit non-zero when any file's coverage
131
+ dropped vs the baseline
132
+ --json Emit results as a JSON array (for CI)
133
+ --threshold N Only show files whose absolute delta
134
+ in any criterion is at least N%
135
+
136
+ open options:
137
+ --report PATH Open PATH instead of #{default_report}
138
+
139
+ serve options:
140
+ --port N Bind to port N (default: random open port)
141
+ --host HOST Bind to HOST (default: 127.0.0.1)
142
+
143
+ clean options:
144
+ --dry-run Print what would be removed without
145
+ deleting anything
146
+ -q, --quiet Suppress status lines
147
+ USAGE
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ # ANSI colorization for stderr diagnostics. Thresholds mirror the
5
+ # HTML formatter (>= 90 green, >= 75 yellow, otherwise red) so a
6
+ # team's mental model of "what's the cutoff" is the same whether
7
+ # they're reading the terminal output or the HTML report.
8
+ #
9
+ # Color precedence, highest first:
10
+ #
11
+ # - `SimpleCov.color = true` / `false` (programmatic override, wins
12
+ # over everything; default is `:auto` which falls through)
13
+ # - `NO_COLOR` env var (any non-empty value) → off (see no-color.org)
14
+ # - `FORCE_COLOR` env var (any non-empty value) → on
15
+ # - `stream.tty?` fallback
16
+ #
17
+ # `NO_COLOR` wins over `FORCE_COLOR` if both env vars are set.
18
+ module Color
19
+ GREEN_THRESHOLD = 90
20
+ YELLOW_THRESHOLD = 75
21
+
22
+ ANSI = {
23
+ red: "\e[31m",
24
+ yellow: "\e[33m",
25
+ green: "\e[32m",
26
+ reset: "\e[0m"
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ # `stream` is the IO that the colorized text is destined for. The
32
+ # formatter writes to stderr, so that's the default. CLI subcommands
33
+ # that print to stdout should pass `$stdout` so a redirected pipe
34
+ # doesn't get ANSI sequences. See the module-level comment for
35
+ # precedence.
36
+ def enabled?(stream = $stderr)
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)
43
+ return config if [true, false].include?(config)
44
+ return false if env_set?("NO_COLOR")
45
+ return true if env_set?("FORCE_COLOR")
46
+
47
+ stream.tty?
48
+ end
49
+
50
+ def for_percent(percent)
51
+ return :green if percent >= GREEN_THRESHOLD
52
+ return :yellow if percent >= YELLOW_THRESHOLD
53
+
54
+ :red
55
+ end
56
+
57
+ # Wrap `text` in the ANSI sequence for `color` (a key of ANSI).
58
+ # Returns the bare text if color is disabled. The `enabled:`
59
+ # keyword lets callers (e.g., CLI subcommands honoring `--no-color`)
60
+ # override the auto-detection without touching env vars.
61
+ def colorize(text, color, enabled: enabled?)
62
+ return text unless enabled
63
+
64
+ "#{ANSI.fetch(color)}#{text}#{ANSI.fetch(:reset)}"
65
+ end
66
+
67
+ # Render `percent` as a fixed "NN.NN%" string colored by which
68
+ # threshold band it falls into. Callers that want a different
69
+ # rendering of the number can pass the pre-rendered `text`.
70
+ def colorize_percent(percent, text = nil, enabled: enabled?)
71
+ colorize(text || format("%.2f%%", percent), for_percent(percent), enabled: enabled)
72
+ end
73
+
74
+ def env_set?(name)
75
+ value = ENV.fetch(name, nil)
76
+ value && !value.empty?
77
+ end
78
+ end
79
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../source_file/ruby_data_parser"
4
+
3
5
  module SimpleCov
4
6
  module Combine
5
7
  #
@@ -10,22 +12,43 @@ module SimpleCov
10
12
  module_function
11
13
 
12
14
  #
13
- # Return merged branches or the existed brach if other is missing.
15
+ # Return merged branches or the existed branch if other is missing.
14
16
  #
15
17
  # Branches inside files are always same if they exist, the difference only in coverage count.
16
18
  # Branch coverage report for any conditional case is built from hash, it's key is a condition and
17
19
  # it's body is a hash << keys from condition and value is coverage rate >>.
18
- # ex: branches =>{ [:if, 3, 8, 6, 8, 36] => {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36]=>2}, other conditions...}
20
+ # ex: branches => { [:if, 3, 8, 6, 8, 36] =>
21
+ # {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36] => 2}, ... }
19
22
  # We create copy of result and update it values depending on the combined branches coverage values.
20
23
  #
21
24
  # @return [Hash]
22
25
  #
23
26
  def combine(coverage_a, coverage_b)
24
- coverage_a.merge(coverage_b) do |_condition, branches_inside_a, branches_inside_b|
25
- branches_inside_a.merge(branches_inside_b) do |_branch, a_count, b_count|
26
- a_count + b_count
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]
27
34
  end
28
35
  end
36
+
37
+ combined.values.to_h { |condition, branches| [condition, branches.values.to_h] }
38
+ end
39
+
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
46
+ end
47
+
48
+ 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]
29
52
  end
30
53
  end
31
54
  end