simplecov 0.22.0 → 1.3.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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +173 -875
  4. data/exe/simplecov +8 -0
  5. data/lib/minitest/simplecov_plugin.rb +18 -7
  6. data/lib/simplecov/atomic_file.rb +67 -0
  7. data/lib/simplecov/autostart.rb +9 -0
  8. data/lib/simplecov/baseline/parser.rb +58 -0
  9. data/lib/simplecov/baseline.rb +170 -0
  10. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  11. data/lib/simplecov/cli/affected/selection.rb +117 -0
  12. data/lib/simplecov/cli/affected.rb +148 -0
  13. data/lib/simplecov/cli/annotations.rb +166 -0
  14. data/lib/simplecov/cli/badge/svg.rb +70 -0
  15. data/lib/simplecov/cli/badge.rb +62 -0
  16. data/lib/simplecov/cli/clean.rb +86 -0
  17. data/lib/simplecov/cli/command_helpers.rb +91 -0
  18. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  19. data/lib/simplecov/cli/completions.rb +83 -0
  20. data/lib/simplecov/cli/coverage.rb +83 -0
  21. data/lib/simplecov/cli/coverage_file.rb +109 -0
  22. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  23. data/lib/simplecov/cli/dead_code.rb +139 -0
  24. data/lib/simplecov/cli/diff/output.rb +45 -0
  25. data/lib/simplecov/cli/diff.rb +115 -0
  26. data/lib/simplecov/cli/dotfile.rb +156 -0
  27. data/lib/simplecov/cli/git.rb +41 -0
  28. data/lib/simplecov/cli/history/output.rb +135 -0
  29. data/lib/simplecov/cli/history.rb +67 -0
  30. data/lib/simplecov/cli/merge.rb +115 -0
  31. data/lib/simplecov/cli/open.rb +45 -0
  32. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  33. data/lib/simplecov/cli/patch/output.rb +151 -0
  34. data/lib/simplecov/cli/patch.rb +175 -0
  35. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  36. data/lib/simplecov/cli/ratchet.rb +83 -0
  37. data/lib/simplecov/cli/report.rb +91 -0
  38. data/lib/simplecov/cli/run.rb +35 -0
  39. data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
  40. data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
  41. data/lib/simplecov/cli/serve.rb +88 -0
  42. data/lib/simplecov/cli/show/annotator.rb +87 -0
  43. data/lib/simplecov/cli/show/sweep.rb +36 -0
  44. data/lib/simplecov/cli/show.rb +133 -0
  45. data/lib/simplecov/cli/status/facts.rb +87 -0
  46. data/lib/simplecov/cli/status.rb +93 -0
  47. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  48. data/lib/simplecov/cli/tests.rb +148 -0
  49. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  50. data/lib/simplecov/cli/uncovered.rb +127 -0
  51. data/lib/simplecov/cli/usage.rb +153 -0
  52. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  53. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  54. data/lib/simplecov/cli/watch/poller.rb +45 -0
  55. data/lib/simplecov/cli/watch/session.rb +134 -0
  56. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  57. data/lib/simplecov/cli/watch.rb +85 -0
  58. data/lib/simplecov/cli.rb +114 -0
  59. data/lib/simplecov/color.rb +65 -0
  60. data/lib/simplecov/combine/branches_combiner.rb +44 -21
  61. data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
  62. data/lib/simplecov/combine/identity_interner.rb +27 -0
  63. data/lib/simplecov/combine/interned_counts.rb +28 -0
  64. data/lib/simplecov/combine/lines_combiner.rb +35 -29
  65. data/lib/simplecov/combine/methods_combiner.rb +48 -0
  66. data/lib/simplecov/combine/results_combiner.rb +9 -49
  67. data/lib/simplecov/combine.rb +0 -24
  68. data/lib/simplecov/command_guesser.rb +99 -42
  69. data/lib/simplecov/configuration/baseline.rb +31 -0
  70. data/lib/simplecov/configuration/coverage.rb +184 -0
  71. data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
  72. data/lib/simplecov/configuration/deprecations.rb +21 -0
  73. data/lib/simplecov/configuration/eval_coverage.rb +37 -0
  74. data/lib/simplecov/configuration/filters.rb +131 -0
  75. data/lib/simplecov/configuration/formatting.rb +129 -0
  76. data/lib/simplecov/configuration/groups.rb +48 -0
  77. data/lib/simplecov/configuration/history.rb +44 -0
  78. data/lib/simplecov/configuration/ignored_entries.rb +72 -0
  79. data/lib/simplecov/configuration/merging.rb +197 -0
  80. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  81. data/lib/simplecov/configuration/production.rb +23 -0
  82. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  83. data/lib/simplecov/configuration/thresholds.rb +150 -0
  84. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  85. data/lib/simplecov/configuration.rb +100 -443
  86. data/lib/simplecov/configuration_error.rb +5 -0
  87. data/lib/simplecov/context_map/union.rb +81 -0
  88. data/lib/simplecov/context_map.rb +187 -0
  89. data/lib/simplecov/coverage_json.rb +21 -0
  90. data/lib/simplecov/coverage_statistics.rb +24 -28
  91. data/lib/simplecov/coverage_violations.rb +254 -0
  92. data/lib/simplecov/current_run.rb +61 -0
  93. data/lib/simplecov/defaults.rb +20 -28
  94. data/lib/simplecov/deprecation.rb +63 -0
  95. data/lib/simplecov/directive/erb.rb +44 -0
  96. data/lib/simplecov/directive/haml.rb +33 -0
  97. data/lib/simplecov/directive/indented_template.rb +37 -0
  98. data/lib/simplecov/directive/slim.rb +35 -0
  99. data/lib/simplecov/directive/template.rb +29 -0
  100. data/lib/simplecov/directive.rb +152 -0
  101. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  102. data/lib/simplecov/exit_codes/check.rb +39 -0
  103. data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
  104. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
  105. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  106. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  107. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
  109. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
  110. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
  111. data/lib/simplecov/exit_codes.rb +26 -0
  112. data/lib/simplecov/exit_handling.rb +163 -0
  113. data/lib/simplecov/file_list.rb +63 -48
  114. data/lib/simplecov/filter.rb +98 -42
  115. data/lib/simplecov/formatter/base.rb +100 -0
  116. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  117. data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
  118. data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
  119. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
  120. data/lib/simplecov/formatter/html_formatter.rb +88 -0
  121. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
  122. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  123. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
  124. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
  125. data/lib/simplecov/formatter/json_formatter.rb +40 -0
  126. data/lib/simplecov/formatter/multi_formatter.rb +8 -10
  127. data/lib/simplecov/formatter/simple_formatter.rb +12 -15
  128. data/lib/simplecov/formatter.rb +21 -1
  129. data/lib/simplecov/group_names.rb +33 -0
  130. data/lib/simplecov/history.rb +115 -0
  131. data/lib/simplecov/last_run.rb +16 -5
  132. data/lib/simplecov/lines_classifier.rb +67 -19
  133. data/lib/simplecov/load_global_config.rb +12 -4
  134. data/lib/simplecov/parallel_adapters/base.rb +50 -0
  135. data/lib/simplecov/parallel_adapters/generic.rb +33 -0
  136. data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
  137. data/lib/simplecov/parallel_adapters.rb +54 -0
  138. data/lib/simplecov/parallel_coordination.rb +124 -0
  139. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  140. data/lib/simplecov/parallel_result_merger.rb +171 -0
  141. data/lib/simplecov/process.rb +34 -12
  142. data/lib/simplecov/production/error.rb +11 -0
  143. data/lib/simplecov/production/file_sink.rb +130 -0
  144. data/lib/simplecov/production.rb +249 -0
  145. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  146. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  147. data/lib/simplecov/profiles/rails.rb +13 -10
  148. data/lib/simplecov/profiles/root_filter.rb +2 -5
  149. data/lib/simplecov/profiles/strict.rb +22 -0
  150. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  151. data/lib/simplecov/profiles.rb +26 -19
  152. data/lib/simplecov/report_deferral.rb +45 -0
  153. data/lib/simplecov/report_stamp.rb +25 -0
  154. data/lib/simplecov/result/filter_config.rb +19 -0
  155. data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
  156. data/lib/simplecov/result/serialization.rb +43 -0
  157. data/lib/simplecov/result/source_file_builder.rb +46 -0
  158. data/lib/simplecov/result.rb +123 -47
  159. data/lib/simplecov/result_adapter.rb +130 -11
  160. data/lib/simplecov/result_merger/contexts.rb +22 -0
  161. data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
  162. data/lib/simplecov/result_merger/resultset_file.rb +67 -0
  163. data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
  164. data/lib/simplecov/result_merger/resultset_store.rb +62 -0
  165. data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
  166. data/lib/simplecov/result_merger.rb +120 -124
  167. data/lib/simplecov/result_processing.rb +188 -0
  168. data/lib/simplecov/run_identity.rb +75 -0
  169. data/lib/simplecov/simulate_coverage.rb +72 -17
  170. data/lib/simplecov/source_file/branch.rb +8 -35
  171. data/lib/simplecov/source_file/branch_builder.rb +95 -0
  172. data/lib/simplecov/source_file/builder_context.rb +28 -0
  173. data/lib/simplecov/source_file/line.rb +17 -32
  174. data/lib/simplecov/source_file/line_builder.rb +37 -0
  175. data/lib/simplecov/source_file/method.rb +54 -0
  176. data/lib/simplecov/source_file/method_builder.rb +51 -0
  177. data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
  178. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  179. data/lib/simplecov/source_file/source_loader.rb +89 -0
  180. data/lib/simplecov/source_file/statistics.rb +76 -0
  181. data/lib/simplecov/source_file.rb +64 -260
  182. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
  183. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
  184. data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
  185. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
  186. data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
  187. data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
  188. data/lib/simplecov/static_coverage_extractor.rb +78 -0
  189. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  190. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  191. data/lib/simplecov/test_tracker/delta.rb +69 -0
  192. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  193. data/lib/simplecov/test_tracker.rb +162 -0
  194. data/lib/simplecov/unloaded_file_injector.rb +62 -0
  195. data/lib/simplecov/useless_results_remover.rb +17 -7
  196. data/lib/simplecov/version.rb +1 -1
  197. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  198. data/lib/simplecov/view_coverage.rb +55 -0
  199. data/lib/simplecov-html.rb +5 -0
  200. data/lib/simplecov.rb +143 -394
  201. data/lib/simplecov_json_formatter.rb +5 -0
  202. data/man/simplecov.1 +354 -0
  203. data/sig/simplecov.rbs +1710 -0
  204. metadata +183 -61
  205. data/CHANGELOG.md +0 -191
  206. data/doc/alternate-formatters.md +0 -71
  207. data/doc/commercial-services.md +0 -25
  208. data/doc/editor-integration.md +0 -18
  209. data/lib/simplecov/combine/files_combiner.rb +0 -24
  210. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,91 @@
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
+ module Report
10
+ extend CommandHelpers
11
+
12
+ TOTAL_LABEL = "All Files"
13
+ SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze #: Array[[String, String]]
14
+
15
+ extend self
16
+
17
+ def run(args, stdout:, stderr:)
18
+ opts, = parse_common(args)
19
+ return 1 unless (data = load_data(opts.fetch(:input), stderr))
20
+
21
+ if opts.fetch(:json)
22
+ emit_json(stdout, data)
23
+ else
24
+ emit_text(stdout, data, CLI.color_enabled?(opts, stdout))
25
+ end
26
+ 0
27
+ end
28
+
29
+ # Valid JSON isn't enough: a wrong-typed "total" or "groups" used to escape
30
+ # here and crash the emitters with a backtrace instead of the one-line error
31
+ # the sibling commands print.
32
+ def load_data(input, stderr)
33
+ data = CoverageFile.load_document(input, command: "report", stderr: stderr)
34
+ return unless data
35
+
36
+ none = {} #: Hash[String, untyped]
37
+ unless data.fetch("total", none).instance_of?(Hash)
38
+ return CoverageFile.report_invalid(stderr, "report", input, '"total" must be an object')
39
+ end
40
+
41
+ groups = data.fetch("groups", none)
42
+ return data if groups.instance_of?(Hash) && groups.each_value.all?(Hash)
43
+
44
+ CoverageFile.report_invalid(stderr, "report", input, '"groups" must be an object of objects')
45
+ end
46
+
47
+ def emit_text(stdout, data, color)
48
+ none = {} #: Hash[String, untyped]
49
+ emit_totals(stdout, TOTAL_LABEL, data.fetch("total", none), color)
50
+ data.fetch("groups", none).each do |name, group|
51
+ label = name.eql?(TOTAL_LABEL) ? "#{name} (group)" : name
52
+ emit_totals(stdout, label, group, color)
53
+ end
54
+ end
55
+
56
+ def emit_totals(stdout, label, totals, color)
57
+ stdout.puts(label)
58
+ SECTIONS.each { |display, key| emit_section(stdout, display, totals[key], color) }
59
+ stdout.puts
60
+ end
61
+
62
+ def emit_section(stdout, display, section, color)
63
+ return unless section.instance_of?(Hash) && section["total"].to_i.positive?
64
+
65
+ stdout.puts(stats_row(display,
66
+ Color.colorize_percent(section["percent"].to_f, enabled: color),
67
+ section["covered"],
68
+ section.fetch("total")))
69
+ end
70
+
71
+ def emit_json(stdout, data)
72
+ none = {} #: Hash[String, untyped]
73
+ groups = data.fetch("groups", none).transform_values { |group| collect_section(group) }
74
+ payload = {"total" => collect_section(data.fetch("total", none)), "groups" => groups}
75
+ stdout.puts(JSON.pretty_generate(payload))
76
+ end
77
+
78
+ def collect_section(totals)
79
+ sections = {} #: Hash[String, untyped]
80
+ SECTIONS.each_with_object(sections) do |(_, key), out|
81
+ section = totals[key]
82
+ next unless section.instance_of?(Hash) && section["total"].to_i.positive?
83
+
84
+ out[key] = {
85
+ "percent" => section["percent"], "covered" => section["covered"], "total" => section.fetch("total")
86
+ }
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Run
6
+ AUTOSTART = File.expand_path("../autostart", __dir__.to_s)
7
+
8
+ extend self
9
+
10
+ def run(args, stderr:, **)
11
+ cmd = args.first.eql?("--") ? args.drop(1) : args
12
+ if cmd.empty?
13
+ stderr.puts("simplecov run: missing command")
14
+ return 1
15
+ end
16
+
17
+ exec_command(rubyopt_env, cmd)
18
+ rescue Errno::ENOENT => e
19
+ stderr.puts("simplecov run: #{e}")
20
+ 127
21
+ end
22
+
23
+ def exec_command(env, cmd)
24
+ Kernel.exec(env, *cmd)
25
+ end
26
+
27
+ def rubyopt_env
28
+ existing = ENV["RUBYOPT"].to_s.strip
29
+ injection = "-r#{AUTOSTART}"
30
+ merged = existing.empty? ? injection : "#{existing} #{injection}"
31
+ ENV.to_hash.merge("RUBYOPT" => merged)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../coverage_json"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ module Serve
8
+ module ReportPreparer
9
+ def self.call(dir)
10
+ index_path = File.join(dir, "index.html")
11
+ json_path = File.join(dir, "coverage.json")
12
+ return "#{dir} doesn't exist; run your test suite first" unless File.directory?(dir)
13
+ return if File.file?(index_path)
14
+
15
+ return "#{dir} has no index.html or coverage.json; run your test suite first" unless File.file?(json_path)
16
+
17
+ build_index(json_path, dir)
18
+ rescue CoverageJSON::Error, SystemCallError => e
19
+ "cannot build index.html from #{json_path}: #{e}"
20
+ end
21
+
22
+ def self.build_index(json_path, dir)
23
+ require_relative "../../formatter/html_formatter"
24
+ Formatter::HTMLFormatter.new.format_from_json(json_path, dir)
25
+ nil
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,128 @@
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 per
7
+ # connection and answers it from the report directory, with
8
+ # traversal-safe path resolution. Kept apart from the CLI wiring in
9
+ # 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 raise
28
+ # IO::TimeoutError and the connection is dropped.
29
+ READ_TIMEOUT = 5
30
+
31
+ extend self
32
+
33
+ # Reads one HTTP request line, drains headers, serves the file or writes a
34
+ # status response. Wide rescue so a misbehaving client can't crash the
35
+ # server.
36
+ #
37
+ # `routes` maps exact request paths (query string excluded) to callables
38
+ # that take over the connection, the seam `simplecov watch` mounts its
39
+ # /events stream on. A route may hold the socket for as long as it
40
+ # likes; the ensure below closes it when the route returns.
41
+ def handle_connection(client, root, routes = {})
42
+ # JRuby doesn't implement IO#timeout=. Without the guard the
43
+ # NoMethodError lands in the wide rescue below and every connection
44
+ # closes with an empty response. An idle connection then pins its
45
+ # thread instead of timing out, which only leaks a thread in an
46
+ # interactive dev server.
47
+ client.timeout = READ_TIMEOUT if client.respond_to?(:timeout=)
48
+ method, path = client.readline.split
49
+ drain_headers(client)
50
+ # A request line without both tokens used to raise on `path.split` inside
51
+ # the wide rescue, closing the connection with an empty response
52
+ # instead of the 400 below.
53
+ return respond(client, 400) if path.nil?
54
+
55
+ dispatch(client, method, path, root, routes)
56
+ rescue
57
+ # Misbehaving clients (truncated requests, connection resets, invalid
58
+ # encoding) shouldn't take the whole server down.
59
+ nil
60
+ ensure
61
+ client.close
62
+ end
63
+
64
+ def dispatch(client, method, path, root, routes)
65
+ return respond(client, 405) unless method.eql?("GET")
66
+
67
+ route = routes[path.split("?").first]
68
+ return route.call(client) if route
69
+
70
+ serve_file(client, path, root)
71
+ end
72
+
73
+ def serve_file(client, path, root)
74
+ file = resolve(path, root)
75
+ # `resolve` answers a path, nothing for a file that is not there, or the
76
+ # refusal itself.
77
+ return respond(client, file ? 403 : 404) unless file.instance_of?(String)
78
+
79
+ respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
80
+ end
81
+
82
+ def drain_headers(client)
83
+ loop { break if client.readline.rstrip.empty? }
84
+ end
85
+
86
+ # Answers the absolute path of the file to serve, :forbidden for a
87
+ # traversal attempt (symlinks that escape root included), or nil for
88
+ # "not found".
89
+ #
90
+ # The request path is deliberately NOT percent-decoded: filenames needing
91
+ # escapes don't occur in generated reports, and keeping `%2e%2e%2f` as
92
+ # literal bytes is part of the traversal defense. If decoding is ever
93
+ # added, it must happen BEFORE the `inside?` check.
94
+ def resolve(request_path, root)
95
+ path = request_path.split("?").first.to_s.delete_prefix("/")
96
+ absolute_root = File.realpath(root)
97
+ candidate = File.expand_path(path, absolute_root)
98
+ # Rejected before touching disk, so traversal and absolute-path attempts
99
+ # are 403, not 404.
100
+ return :forbidden unless inside?(candidate, absolute_root)
101
+
102
+ candidate = File.join(candidate, "index.html") if File.directory?(candidate)
103
+ return nil unless File.file?(candidate)
104
+
105
+ # Symlinks are resolved last and re-checked: a file inside root could be
106
+ # a symlink pointing outside.
107
+ real = File.realpath(candidate)
108
+ inside?(real, absolute_root) ? real : :forbidden
109
+ rescue Errno::ENOENT
110
+ # TOCTOU: candidate vanished between File.file? and File.realpath.
111
+ nil
112
+ end
113
+
114
+ def inside?(path, root)
115
+ path.eql?(root) || path.start_with?(root + File::SEPARATOR)
116
+ end
117
+
118
+ def respond(client, status, body = "", content_type = "text/plain")
119
+ client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || "Error"}\r\n",
120
+ "Content-Type: #{content_type || "application/octet-stream"}\r\n",
121
+ "Content-Length: #{body.bytesize}\r\n",
122
+ "Connection: close\r\n\r\n")
123
+ client.write(body)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,88 @@
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`: a small static file server backed by stdlib `socket`, so
11
+ # there is no extra dependency just for viewing a local report on a box where
12
+ # `file://` doesn't work. This module is the CLI wiring; the HTTP mechanics
13
+ # live in StaticFileHandler.
14
+ module Serve
15
+ extend CommandHelpers
16
+
17
+ extend self
18
+
19
+ def run(args, stdout:, stderr:, **)
20
+ opts = parse(args)
21
+ dir = CLI.coverage_dir
22
+ message = ReportPreparer.call(dir)
23
+ return error(stderr, message) if message
24
+
25
+ require_socket
26
+ with_server(opts, stderr) do |server|
27
+ announce(stdout, server, dir)
28
+ serve_loop(server, dir, stdout)
29
+ 0
30
+ end
31
+ end
32
+
33
+ # Required here rather than at load time so the other subcommands do not pay
34
+ # for sockets.
35
+ def require_socket
36
+ require "socket"
37
+ end
38
+
39
+ # Answers 1 when the socket can't be bound: port already taken, privileged
40
+ # port, unresolvable host. The receiver cast works around an rbs stdlib gap,
41
+ # where TCPSocket's explicit `self.new` shadows TCPServer#initialize's
42
+ # (host, port) form.
43
+ def with_server(opts, stderr)
44
+ server = (_ = TCPServer).new(opts.fetch(:host), opts.fetch(:port)) #: TCPServer
45
+ rescue SystemCallError, SocketError => e
46
+ error(stderr, "cannot bind to #{opts.fetch(:host)}:#{opts.fetch(:port)} (#{e})")
47
+ else
48
+ begin
49
+ yield server
50
+ ensure
51
+ server.close
52
+ end
53
+ end
54
+
55
+ def parse(args)
56
+ opts = {port: 0, host: "127.0.0.1"} #: Hash[Symbol, untyped]
57
+ build_parser do |o|
58
+ o.on("--port N", Integer) { |v| opts[:port] = v }
59
+ o.on("--host HOST") { |v| opts[:host] = v }
60
+ end.parse(args)
61
+ opts
62
+ end
63
+
64
+ def announce(stdout, server, dir)
65
+ port = server.addr.fetch(1)
66
+ host = url_host(server.addr.fetch(3))
67
+ stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
68
+ stdout.puts("Press Ctrl-C to stop.")
69
+ end
70
+
71
+ # IPv6 literals need brackets in a URL: `--host ::1` must announce
72
+ # http://[::1]:PORT/, not the invalid http://::1:PORT/.
73
+ def url_host(host)
74
+ host.include?(":") ? "[#{host}]" : host
75
+ end
76
+
77
+ # One thread per connection: browsers open speculative sockets that send no
78
+ # bytes, and a serial loop would stall every real request behind them.
79
+ def serve_loop(server, dir, stdout)
80
+ loop do
81
+ Thread.new(server.accept) { |client| StaticFileHandler.handle_connection(client, dir) }
82
+ end
83
+ rescue Interrupt
84
+ stdout.puts("\nsimplecov serve: stopping")
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Show
6
+ # Renders one file's annotated source: a right-aligned line-number gutter,
7
+ # the hit count beside it, and a caret line naming each miss under the line
8
+ # it happened on.
9
+ module Annotator
10
+ extend self
11
+
12
+ def call(source, entry, stdout, color:)
13
+ markers = markers_for(entry)
14
+ widths = {number: source.size.to_s.length, count: count_width(entry)}
15
+ source.each_with_index do |line, index|
16
+ rendered = row(index + 1, entry.fetch("lines").at(index), line, widths, color)
17
+ emit(stdout, rendered, markers[index + 1], widths, color)
18
+ end
19
+ end
20
+
21
+ def emit(stdout, rendered, labels, widths, color)
22
+ stdout.puts(rendered)
23
+ return if labels.empty?
24
+
25
+ gutter = " " * (widths.fetch(:number) + widths.fetch(:count) + 4)
26
+ stdout.puts(gutter + paint("^ #{labels.join(", ")}", :red, color))
27
+ end
28
+
29
+ def missed_lines(entry)
30
+ entry.fetch("lines").each_with_index.filter_map do |hit, index|
31
+ index + 1 if hit.instance_of?(Integer) && hit.zero?
32
+ end
33
+ end
34
+
35
+ def markers_for(entry)
36
+ markers = Hash.new { |hash, line| hash[line] = [] } #: Hash[Integer, Array[String]]
37
+ missed_lines(entry).each { |line| markers[line] << "missed" }
38
+ each_missed(entry["branches"]) { |line| markers[line] << "branch missed" }
39
+ each_missed(entry["methods"]) { |line| markers[line] << "method missed" }
40
+ markers
41
+ end
42
+
43
+ def each_missed(items)
44
+ return unless items.instance_of?(Array)
45
+
46
+ items.each do |item|
47
+ line = missed_line_of(item)
48
+ yield line if line
49
+ end
50
+ end
51
+
52
+ # The reported line of a zero-hit item, nil for a covered or malformed one,
53
+ # the same tolerance the patch subcommand applies to branch entries.
54
+ def missed_line_of(item)
55
+ return nil unless item.instance_of?(Hash)
56
+
57
+ hits = item["coverage"]
58
+ return nil unless hits.instance_of?(Integer) && hits.zero?
59
+
60
+ line = item["report_line"] || item["start_line"]
61
+ line if line.instance_of?(Integer)
62
+ end
63
+
64
+ def count_width(entry)
65
+ entry.fetch("lines").grep(Integer) { |hit| hit.to_s.length }.max || 1
66
+ end
67
+
68
+ def row(number, hit, line, widths, color)
69
+ format("%#{widths.fetch(:number)}d %s %s", number, count(hit, widths, color), line).rstrip
70
+ end
71
+
72
+ # Padded to width before it is painted, so escape codes can't skew the
73
+ # column, and blank for a line carrying no count.
74
+ def count(hit, widths, color)
75
+ padded = (hit.instance_of?(Integer) ? hit.to_s : "").rjust(widths.fetch(:count))
76
+ return padded unless hit.instance_of?(Integer)
77
+
78
+ paint(padded, hit.zero? ? :red : :green, color)
79
+ end
80
+
81
+ def paint(text, tint, color)
82
+ Color.colorize(text, tint, enabled: color)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Show
6
+ module Sweep
7
+ extend self
8
+
9
+ def misses(coverage)
10
+ rows = coverage.filter_map do |filename, entry|
11
+ next unless entry.instance_of?(Hash) && entry["lines"].instance_of?(Array)
12
+
13
+ missed = Annotator.missed_lines(entry)
14
+ [display_path(filename), missed] unless missed.empty?
15
+ end
16
+ rows.sort_by(&:first)
17
+ end
18
+
19
+ def emit(rows, opts, stdout, stderr)
20
+ if opts.fetch(:json)
21
+ stdout.puts(JSON.pretty_generate(rows.map { |path, missed| {path: path, missed: missed} }))
22
+ elsif rows.empty?
23
+ stderr.puts("simplecov show: nothing uncovered")
24
+ else
25
+ rows.each { |path, missed| stdout.puts("#{path}:#{Patch::Output.ranges(missed, ",")}") }
26
+ end
27
+ 0
28
+ end
29
+
30
+ def display_path(filename)
31
+ filename.delete_prefix("#{File.expand_path(SimpleCov.root)}/")
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "command_helpers"
6
+ require_relative "patch/output"
7
+ require_relative "show/annotator"
8
+ require_relative "show/sweep"
9
+
10
+ module SimpleCov
11
+ module CLI
12
+ # `simplecov show <path>`: annotated source in the terminal, the way
13
+ # `go tool cover` and `llvm-cov show` print it. `--uncovered-only` collapses
14
+ # the answer to `path:ranges`, a form that greps, fits in a commit message,
15
+ # and hands a coding agent exactly the lines whose tests are missing.
16
+ module Show
17
+ extend CommandHelpers
18
+
19
+ extend self
20
+
21
+ def run(args, stdout:, stderr:)
22
+ opts = parse(args)
23
+ return run_project(opts, stdout, stderr) unless opts.fetch(:path)
24
+
25
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "show", stderr: stderr)
26
+ return 1 unless coverage
27
+
28
+ located = locate_or_report(coverage, opts, stderr)
29
+ return 1 unless located
30
+
31
+ render(located, opts, stdout, stderr)
32
+ end
33
+
34
+ # No path sweeps the whole project, in the compact forms only. The annotated
35
+ # text form still wants one file.
36
+ def run_project(opts, stdout, stderr)
37
+ unless opts.fetch(:uncovered_only) || opts.fetch(:json)
38
+ return error(stderr, "missing path (annotating needs one file; --uncovered-only sweeps the whole project)")
39
+ end
40
+
41
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "show", stderr: stderr)
42
+ return 1 unless coverage
43
+
44
+ Sweep.emit(Sweep.misses(coverage), opts, stdout, stderr)
45
+ end
46
+
47
+ def parse(args)
48
+ opts = {input: CLI.default_input, no_color: false,
49
+ uncovered_only: false, json: false} #: Hash[Symbol, untyped]
50
+ rest = build_parser { |parser| options(parser, opts) }.parse(args)
51
+ opts[:path] = rest.first
52
+ opts
53
+ end
54
+
55
+ def options(parser, opts)
56
+ parser.on("--input PATH") { |v| opts[:input] = v }
57
+ parser.on("--no-color") { opts[:no_color] = true }
58
+ parser.on("--uncovered-only") { opts[:uncovered_only] = true }
59
+ parser.on("--json") { opts[:json] = true }
60
+ end
61
+
62
+ # A line-less report has no per-line hits to put in a gutter, so it is
63
+ # reported like an unresolvable path is.
64
+ def locate_or_report(coverage, opts, stderr)
65
+ path, input = opts.fetch_values(:path, :input)
66
+ match = CoverageFile.lookup(coverage, path)
67
+ return error_nil(stderr, CoverageFile.not_found_message(coverage, path, input)) unless match
68
+
69
+ entry = match.last
70
+ unless entry.instance_of?(Hash)
71
+ return CoverageFile.report_invalid(stderr, "show", input, "entry for #{path} must be an object")
72
+ end
73
+ return match if entry["lines"].instance_of?(Array)
74
+
75
+ error_nil(stderr, "no line coverage for #{path} in #{input}")
76
+ end
77
+
78
+ def render(located, opts, stdout, stderr)
79
+ filename, entry = located
80
+ return emit_json(entry, opts, stdout) if opts.fetch(:json)
81
+ return show_uncovered(entry, opts, stdout, stderr) if opts.fetch(:uncovered_only)
82
+
83
+ source = source_for(filename, entry, opts, stderr)
84
+ return 1 unless source
85
+
86
+ Annotator.call(source, entry, stdout, color: CLI.color_enabled?(opts, stdout))
87
+ 0
88
+ end
89
+
90
+ def emit_json(entry, opts, stdout)
91
+ stdout.puts(JSON.pretty_generate(
92
+ path: opts.fetch(:path), missed: Annotator.missed_lines(entry),
93
+ lines: relevant_lines(entry), markers: Annotator.markers_for(entry)
94
+ ))
95
+ 0
96
+ end
97
+
98
+ def relevant_lines(entry)
99
+ entry.fetch("lines").each_with_index.filter_map do |hit, index|
100
+ {number: index + 1, hits: hit} if hit.instance_of?(Integer)
101
+ end
102
+ end
103
+
104
+ def show_uncovered(entry, opts, stdout, stderr)
105
+ missed = Annotator.missed_lines(entry)
106
+ if missed.empty?
107
+ stderr.puts("simplecov show: nothing uncovered in #{opts.fetch(:path)}")
108
+ else
109
+ stdout.puts("#{opts.fetch(:path)}:#{Patch::Output.ranges(missed, ",")}")
110
+ end
111
+ 0
112
+ end
113
+
114
+ # The report's own source when it carries one; otherwise the file on disk,
115
+ # accepted only while its line count still matches the report's, since
116
+ # annotating drifted source would put hit counts on the wrong lines.
117
+ def source_for(filename, entry, opts, stderr)
118
+ embedded = entry["source"]
119
+ return embedded if embedded.instance_of?(Array) && embedded.all?(String)
120
+ unless File.file?(filename)
121
+ return error_nil(stderr, "no source for #{opts.fetch(:path)} in #{opts.fetch(:input)} and no file " \
122
+ "at #{filename}, regenerate the report with `source_in_json true`")
123
+ end
124
+
125
+ lines = File.readlines(filename, chomp: true)
126
+ return lines if lines.size.eql?(entry.fetch("lines").size)
127
+
128
+ error_nil(stderr, "#{filename} has changed since the report (#{lines.size} lines now, " \
129
+ "#{entry.fetch("lines").size} recorded), regenerate the report")
130
+ end
131
+ end
132
+ end
133
+ end