simplecov 0.22.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +71 -916
  4. data/exe/simplecov +11 -0
  5. data/lib/minitest/simplecov_plugin.rb +13 -5
  6. data/lib/simplecov/atomic_file.rb +70 -0
  7. data/lib/simplecov/autostart.rb +11 -0
  8. data/lib/simplecov/cli/clean.rb +87 -0
  9. data/lib/simplecov/cli/command_helpers.rb +55 -0
  10. data/lib/simplecov/cli/coverage.rb +86 -0
  11. data/lib/simplecov/cli/coverage_file.rb +65 -0
  12. data/lib/simplecov/cli/diff.rb +147 -0
  13. data/lib/simplecov/cli/dotfile.rb +111 -0
  14. data/lib/simplecov/cli/merge.rb +120 -0
  15. data/lib/simplecov/cli/open.rb +48 -0
  16. data/lib/simplecov/cli/report.rb +97 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  19. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  20. data/lib/simplecov/cli/serve.rb +85 -0
  21. data/lib/simplecov/cli/uncovered.rb +103 -0
  22. data/lib/simplecov/cli.rb +163 -0
  23. data/lib/simplecov/color.rb +83 -0
  24. data/lib/simplecov/combine/branches_combiner.rb +54 -7
  25. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  26. data/lib/simplecov/combine/identity_interner.rb +30 -0
  27. data/lib/simplecov/combine/interned_counts.rb +30 -0
  28. data/lib/simplecov/combine/lines_combiner.rb +53 -23
  29. data/lib/simplecov/combine/methods_combiner.rb +74 -0
  30. data/lib/simplecov/combine/results_combiner.rb +13 -36
  31. data/lib/simplecov/combine.rb +5 -23
  32. data/lib/simplecov/command_guesser.rb +104 -33
  33. data/lib/simplecov/configuration/coverage.rb +172 -0
  34. data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
  35. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  36. data/lib/simplecov/configuration/filters.rb +164 -0
  37. data/lib/simplecov/configuration/formatting.rb +138 -0
  38. data/lib/simplecov/configuration/groups.rb +42 -0
  39. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  40. data/lib/simplecov/configuration/merging.rb +165 -0
  41. data/lib/simplecov/configuration/thresholds.rb +179 -0
  42. data/lib/simplecov/configuration.rb +83 -420
  43. data/lib/simplecov/coverage_json.rb +24 -0
  44. data/lib/simplecov/coverage_statistics.rb +16 -10
  45. data/lib/simplecov/coverage_violations.rb +159 -0
  46. data/lib/simplecov/defaults.rb +34 -23
  47. data/lib/simplecov/deprecation.rb +45 -0
  48. data/lib/simplecov/directive.rb +167 -0
  49. data/lib/simplecov/exit_codes/check.rb +33 -0
  50. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  51. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
  52. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
  53. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
  54. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
  55. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
  56. data/lib/simplecov/exit_codes.rb +20 -0
  57. data/lib/simplecov/exit_handling.rb +128 -0
  58. data/lib/simplecov/file_list.rb +70 -24
  59. data/lib/simplecov/filter.rb +105 -26
  60. data/lib/simplecov/formatter/base.rb +118 -0
  61. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  62. data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
  63. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  64. data/lib/simplecov/formatter/html_formatter.rb +105 -0
  65. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
  66. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
  67. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
  68. data/lib/simplecov/formatter/json_formatter.rb +46 -0
  69. data/lib/simplecov/formatter/multi_formatter.rb +12 -8
  70. data/lib/simplecov/formatter/simple_formatter.rb +14 -11
  71. data/lib/simplecov/formatter.rb +16 -0
  72. data/lib/simplecov/group_names.rb +32 -0
  73. data/lib/simplecov/last_run.rb +18 -4
  74. data/lib/simplecov/lines_classifier.rb +48 -15
  75. data/lib/simplecov/load_global_config.rb +12 -4
  76. data/lib/simplecov/parallel_adapters/base.rb +78 -0
  77. data/lib/simplecov/parallel_adapters/generic.rb +45 -0
  78. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  79. data/lib/simplecov/parallel_adapters.rb +85 -0
  80. data/lib/simplecov/parallel_coordination.rb +134 -0
  81. data/lib/simplecov/parallel_result_merger.rb +230 -0
  82. data/lib/simplecov/process.rb +52 -12
  83. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  84. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  85. data/lib/simplecov/profiles/rails.rb +24 -10
  86. data/lib/simplecov/profiles/root_filter.rb +6 -5
  87. data/lib/simplecov/profiles/strict.rb +32 -0
  88. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  89. data/lib/simplecov/profiles.rb +32 -3
  90. data/lib/simplecov/report_deferral.rb +49 -0
  91. data/lib/simplecov/report_stamp.rb +28 -0
  92. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  93. data/lib/simplecov/result/source_file_builder.rb +51 -0
  94. data/lib/simplecov/result.rb +145 -26
  95. data/lib/simplecov/result_adapter.rb +139 -8
  96. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  97. data/lib/simplecov/result_merger/resultset_file.rb +74 -0
  98. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  99. data/lib/simplecov/result_merger/resultset_store.rb +53 -0
  100. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  101. data/lib/simplecov/result_merger.rb +109 -114
  102. data/lib/simplecov/result_processing.rb +218 -0
  103. data/lib/simplecov/run_identity.rb +77 -0
  104. data/lib/simplecov/simulate_coverage.rb +83 -12
  105. data/lib/simplecov/source_file/branch.rb +1 -3
  106. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  107. data/lib/simplecov/source_file/builder_context.rb +28 -0
  108. data/lib/simplecov/source_file/line.rb +9 -4
  109. data/lib/simplecov/source_file/line_builder.rb +43 -0
  110. data/lib/simplecov/source_file/method.rb +58 -0
  111. data/lib/simplecov/source_file/method_builder.rb +60 -0
  112. data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
  113. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  114. data/lib/simplecov/source_file/source_loader.rb +79 -0
  115. data/lib/simplecov/source_file/statistics.rb +65 -0
  116. data/lib/simplecov/source_file.rb +66 -232
  117. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  118. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  119. data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
  120. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  121. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  122. data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
  123. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  124. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  125. data/lib/simplecov/useless_results_remover.rb +16 -7
  126. data/lib/simplecov/version.rb +1 -1
  127. data/lib/simplecov-html.rb +4 -0
  128. data/lib/simplecov.rb +185 -380
  129. data/lib/simplecov_json_formatter.rb +4 -0
  130. data/sig/simplecov.rbs +1777 -0
  131. metadata +111 -62
  132. data/CHANGELOG.md +0 -191
  133. data/doc/alternate-formatters.md +0 -71
  134. data/doc/commercial-services.md +0 -25
  135. data/doc/editor-integration.md +0 -18
  136. data/lib/simplecov/combine/files_combiner.rb +0 -24
  137. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../atomic_file"
6
+ require_relative "command_helpers"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov merge <files...>` — wrap SimpleCov::ResultMerger so a
11
+ # CI matrix that produces one .resultset.json per worker can stitch
12
+ # them together from the shell instead of dropping a Rake task into
13
+ # every project. Requires the full simplecov library to be on the
14
+ # load path; lazy-required so the read-only subcommands above don't
15
+ # pay for ResultMerger (and its Coverage runtime guard).
16
+ module Merge
17
+ extend CommandHelpers
18
+
19
+ module_function
20
+
21
+ def run(args, stdout:, stderr:, **)
22
+ opts = parse(args)
23
+ return error(stderr, "missing input files") if opts[:files].empty?
24
+ return 1 unless valid_inputs?(opts[:files], stderr)
25
+
26
+ require "simplecov"
27
+ result = SimpleCov::ResultMerger.merge_results(*opts[:files], ignore_timeout: !opts[:honor_timeout])
28
+ return error(stderr, "no mergeable results in input files") unless result
29
+
30
+ commit(opts, result, stdout)
31
+ 0
32
+ end
33
+
34
+ def commit(opts, result, stdout)
35
+ verb = opts[:dry_run] ? "would write" : "wrote"
36
+ write(opts[:output], result) unless opts[:dry_run]
37
+ stdout.puts("simplecov merge: #{verb} #{opts[:output]}") unless opts[:quiet]
38
+ end
39
+
40
+ def valid_inputs?(files, stderr)
41
+ parsed = parse_inputs(files, stderr) or return false
42
+
43
+ warn_about_duplicate_command_names(parsed, stderr)
44
+ true
45
+ end
46
+
47
+ def parse(args)
48
+ opts = {output: SimpleCov::CLI.default_resultset, honor_timeout: false, dry_run: false, quiet: false}
49
+ files =
50
+ OptionParser.new do |o|
51
+ o.on("--output PATH") { |v| opts[:output] = v }
52
+ o.on("--honor-timeout") { opts[:honor_timeout] = true }
53
+ o.on("--dry-run") { opts[:dry_run] = true }
54
+ o.on("-q", "--quiet") { opts[:quiet] = true }
55
+ end.parse(args)
56
+ opts.merge(files: files)
57
+ end
58
+
59
+ # Validate every input file up-front and return a {path => parsed}
60
+ # hash. Surfacing per-file errors here turns ResultMerger's
61
+ # generic "no mergeable results" into a message that points at
62
+ # the specific input causing the failure.
63
+ def parse_inputs(files, stderr)
64
+ parsed = {} #: Hash[String, Hash[String, untyped]]
65
+ files.each_with_object(parsed) do |path, memo|
66
+ data = parse_input(path, stderr) or return nil
67
+
68
+ memo[path] = data
69
+ end
70
+ end
71
+
72
+ # Read first, classify by the exception: an exist?-then-read pair
73
+ # is racy, and rescuing only ENOENT crashed with a backtrace on a
74
+ # directory (EISDIR) or an unreadable file (EACCES).
75
+ def parse_input(path, stderr)
76
+ data = JSON.parse(File.read(path))
77
+ return data if data.is_a?(Hash) && !data.empty?
78
+
79
+ parse_input_error(stderr, path, "has no resultset entries")
80
+ rescue Errno::ENOENT
81
+ parse_input_error(stderr, path, "not found")
82
+ rescue JSON::ParserError => e
83
+ parse_input_error(stderr, path, "isn't valid JSON (#{e.message})")
84
+ rescue SystemCallError => e
85
+ parse_input_error(stderr, path, "cannot be read (#{e.message.lines.first.to_s.strip})")
86
+ end
87
+
88
+ def parse_input_error(stderr, path, reason)
89
+ stderr.puts("simplecov merge: input file #{path.inspect} #{reason}")
90
+ nil
91
+ end
92
+
93
+ # When two input files share a command_name, ResultMerger folds
94
+ # them together with last-write-wins on the timestamp — easy to
95
+ # mistake for "no merge happened." Surface the overlap so the
96
+ # operator can rename the workers or accept the merge knowingly.
97
+ def warn_about_duplicate_command_names(parsed, stderr)
98
+ files_per_command = {} #: Hash[String, Array[String]]
99
+ parsed.each do |path, data|
100
+ data.each_key { |command_name| (files_per_command[command_name] ||= []) << path }
101
+ end
102
+ files_per_command.each do |command_name, paths|
103
+ next if paths.size < 2
104
+
105
+ stderr.puts(duplicate_warning(command_name, paths))
106
+ end
107
+ end
108
+
109
+ def duplicate_warning(command_name, paths)
110
+ "simplecov merge: warning — command_name #{command_name.inspect} " \
111
+ "appears in #{paths.size} input files (#{paths.join(', ')}); " \
112
+ "entries will be merged"
113
+ end
114
+
115
+ def write(path, result)
116
+ AtomicFile.write(path, JSON.pretty_generate(result.to_hash))
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "command_helpers"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ # `simplecov open [--report PATH]` — open the HTML report in the
9
+ # platform's default browser. Tiny QoL wrapper around `xdg-open` /
10
+ # `open` / `start` so users don't have to type a file:// URL.
11
+ module Open
12
+ extend CommandHelpers
13
+
14
+ module_function
15
+
16
+ def run(args, stderr:, **)
17
+ path = parse(args)
18
+ return error(stderr, "#{path} not found") unless File.exist?(path)
19
+
20
+ opener = browser_opener
21
+ return error(stderr, "no known opener for #{RbConfig::CONFIG['host_os']}") unless opener
22
+
23
+ system(*opener, path) ? 0 : 1
24
+ end
25
+
26
+ def parse(args)
27
+ path = SimpleCov::CLI.default_report
28
+ OptionParser.new do |o|
29
+ o.on("--report PATH") { |v| path = v }
30
+ end.parse(args)
31
+ path
32
+ end
33
+
34
+ # Returns the argv for the platform's "open this file" command, or
35
+ # nil if the host OS isn't recognized. On Windows, `start` is a
36
+ # cmd.exe builtin (not an executable), so route through `cmd /c`;
37
+ # the empty string is the window-title positional `start` takes
38
+ # before the path so a quoted path isn't mis-parsed as the title.
39
+ def browser_opener
40
+ case RbConfig::CONFIG["host_os"]
41
+ when /darwin/ then ["open"]
42
+ when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", ""]
43
+ when /linux|bsd|solaris/ then ["xdg-open"]
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "command_helpers"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ # `simplecov report [--input PATH]` — pretty-print the overall
10
+ # totals row plus per-group totals from a JSONFormatter
11
+ # coverage.json. Same numbers as the HTML report's totals row, for
12
+ # contexts where opening a browser isn't an option (CI logs, ssh
13
+ # sessions, terminal-only workflows).
14
+ module Report
15
+ extend CommandHelpers
16
+
17
+ TOTAL_LABEL = "All Files"
18
+ SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze
19
+
20
+ module_function
21
+
22
+ def run(args, stdout:, stderr:)
23
+ opts, = parse_common(args)
24
+ return 1 unless (data = load_data(opts[:input], stderr))
25
+
26
+ if opts[:json]
27
+ emit_json(stdout, data)
28
+ else
29
+ emit_text(stdout, data, SimpleCov::CLI.color_enabled?(opts, stdout))
30
+ end
31
+ 0
32
+ end
33
+
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.
38
+ def load_data(input, stderr)
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
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')
51
+ end
52
+
53
+ def emit_text(stdout, data, 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
60
+ end
61
+
62
+ def emit_totals(stdout, label, totals, color)
63
+ stdout.puts(label)
64
+ SECTIONS.each { |display, key| emit_section(stdout, display, totals[key], color) }
65
+ stdout.puts
66
+ end
67
+
68
+ def emit_section(stdout, display, section, color)
69
+ return unless section.is_a?(Hash) && section["total"].to_i.positive?
70
+
71
+ stdout.puts(stats_row(display,
72
+ SimpleCov::Color.colorize_percent(section["percent"].to_f, enabled: color),
73
+ section["covered"],
74
+ section["total"]))
75
+ end
76
+
77
+ def emit_json(stdout, data)
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}
81
+ stdout.puts(JSON.pretty_generate(payload))
82
+ end
83
+
84
+ def collect_section(totals)
85
+ sections = {} #: Hash[String, untyped]
86
+ SECTIONS.each_with_object(sections) do |(_, key), out|
87
+ section = totals[key]
88
+ next unless section.is_a?(Hash) && section["total"].to_i.positive?
89
+
90
+ out[key.to_s] = {
91
+ "percent" => section["percent"], "covered" => section["covered"], "total" => section["total"]
92
+ }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ 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,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
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "command_helpers"
5
+ require_relative "serve/report_preparer"
6
+ require_relative "serve/static_file_handler"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov serve [--port N] [--host HOST]` — serve the coverage
11
+ # report over HTTP. A small static file server backed by stdlib
12
+ # `socket`, so there's no extra dependency just for "view a local
13
+ # report on a CI box where `file://` doesn't work." This module is
14
+ # the CLI wiring (options, binding, the accept loop); the HTTP
15
+ # mechanics live in StaticFileHandler.
16
+ module Serve
17
+ extend CommandHelpers
18
+
19
+ module_function
20
+
21
+ def run(args, stdout:, stderr:, **)
22
+ opts = parse(args)
23
+ dir = SimpleCov::CLI.coverage_dir
24
+ message = ReportPreparer.call(dir)
25
+ return error(stderr, message) if message
26
+
27
+ require "socket"
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
49
+ end
50
+
51
+ def parse(args)
52
+ opts = {port: 0, host: "127.0.0.1"} #: Hash[Symbol, untyped]
53
+ OptionParser.new do |o|
54
+ o.on("--port N", Integer) { |v| opts[:port] = v }
55
+ o.on("--host HOST") { |v| opts[:host] = v }
56
+ end.parse(args)
57
+ opts
58
+ end
59
+
60
+ def announce(stdout, server, dir)
61
+ port = server.addr[1]
62
+ host = url_host(server.addr[3])
63
+ stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
64
+ stdout.puts("Press Ctrl-C to stop.")
65
+ end
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.
76
+ def serve_loop(server, dir, stdout)
77
+ loop do
78
+ Thread.new(server.accept) { |client| StaticFileHandler.handle_connection(client, dir) }
79
+ end
80
+ rescue Interrupt
81
+ stdout.puts("\nsimplecov serve: stopping")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "command_helpers"
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
+ extend CommandHelpers
15
+
16
+ DEFAULT_TOP = 10
17
+
18
+ module_function
19
+
20
+ def run(args, stdout:, stderr:, **)
21
+ opts = parse(args)
22
+ keys = CoverageFile::CRITERIA[opts[:criterion]]
23
+ return unknown_criterion(opts[:criterion], stderr) unless keys
24
+
25
+ report(opts, keys, stdout, stderr)
26
+ end
27
+
28
+ def report(opts, keys, stdout, stderr)
29
+ coverage = CoverageFile.load_coverage(opts[:input], command: "uncovered", stderr: stderr)
30
+ return 1 unless coverage
31
+
32
+ files = rank(coverage, opts[:threshold], keys).first(opts[:top])
33
+ return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?
34
+
35
+ emit(stdout, files, opts)
36
+ 0
37
+ end
38
+
39
+ def unknown_criterion(criterion, stderr)
40
+ stderr.puts("simplecov uncovered: unknown --criterion #{criterion.inspect} (expected line, branch, or method)")
41
+ 1
42
+ end
43
+
44
+ def emit(stdout, files, opts)
45
+ opts[:json] ? emit_json(stdout, files) : emit_text(stdout, files, SimpleCov::CLI.color_enabled?(opts, stdout))
46
+ end
47
+
48
+ def 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
54
+ opts
55
+ end
56
+
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
65
+ end
66
+
67
+ def emit_text(stdout, files, color)
68
+ files.each { |fname, pct, covered, total| stdout.puts(format_row(fname, pct, covered, total, color)) }
69
+ end
70
+
71
+ def emit_json(stdout, files)
72
+ rows = files.map do |fname, pct, covered, total|
73
+ {"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
74
+ end
75
+ stdout.puts(JSON.pretty_generate(rows))
76
+ end
77
+
78
+ def empty_message(json)
79
+ json ? "[]" : "simplecov uncovered: nothing to report"
80
+ end
81
+
82
+ def rank(coverage_hash, threshold, keys)
83
+ rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, threshold, keys) }
84
+ rows.sort_by { |_fname, pct, _c, _t| pct }
85
+ end
86
+
87
+ def row_for(fname, payload, threshold, keys)
88
+ return unless payload.is_a?(Hash) && payload[keys[:total]].to_i.positive?
89
+
90
+ pct = payload[keys[:percent]].to_f
91
+ return if pct >= threshold
92
+
93
+ [fname, pct, payload[keys[:covered]].to_i, payload[keys[:total]].to_i]
94
+ end
95
+
96
+ def format_row(fname, pct, covered, total, color)
97
+ format("%<pct>s %<covered>d/%<total>d %<fname>s",
98
+ pct: SimpleCov::Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
99
+ covered: covered, total: total, fname: fname)
100
+ end
101
+ end
102
+ end
103
+ end