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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ # The `--annotate KIND` output shared by `uncovered` and `patch`: each command
9
+ # reduces its answer to diagnostics (a path, a contiguous missed line range,
10
+ # and the criterion that missed), and one emitter per CI host renders them in
11
+ # that host's native inline-annotation channel, so a gap surfaces on the diff
12
+ # with no upload step and no extra gem.
13
+ module Annotations
14
+ KINDS = %w[github gitlab rdjson azure teamcity buildkite].freeze
15
+
16
+ MESSAGES = {
17
+ line: "Not covered by tests",
18
+ branch: "Branch not covered by tests",
19
+ method: "Method not covered by tests"
20
+ }.freeze
21
+
22
+ DESCRIPTIONS = {
23
+ line: "Lines the tests never executed",
24
+ branch: "Branches the tests never took",
25
+ method: "Methods the tests never called"
26
+ }.freeze
27
+
28
+ SOURCE = {"name" => "simplecov", "url" => "https://github.com/simplecov-ruby/simplecov"}.freeze
29
+
30
+ # TeamCity's service message grammar and Azure's logging command grammar
31
+ # each reserve a few characters in attribute values.
32
+ TEAMCITY_ESCAPES = {"|" => "||", "'" => "|'", "[" => "|[", "]" => "|]", "\n" => "|n", "\r" => "|r"}.freeze
33
+ AZURE_ESCAPES = {"%" => "%AZP25", ";" => "%3B", "]" => "%5D", "\n" => "%0A", "\r" => "%0D"}.freeze
34
+
35
+ extend self
36
+
37
+ def issue(opts)
38
+ kind = opts.fetch(:annotate)
39
+ return nil unless kind
40
+ return "unknown --annotate #{kind.inspect} (expected #{expected_kinds})" unless KINDS.include?(kind)
41
+
42
+ "cannot combine --annotate with --json" if opts.fetch(:json)
43
+ end
44
+
45
+ def expected_kinds
46
+ *rest, last = KINDS
47
+ "#{rest.join(", ")}, or #{last}"
48
+ end
49
+
50
+ def diagnostics(path, missed, criterion)
51
+ missed.slice_when { |previous, current| current > previous + 1 }.map do |run|
52
+ {path: path, first: run.first, last: run.last, criterion: criterion, message: MESSAGES.fetch(criterion)}
53
+ end
54
+ end
55
+
56
+ def emit(stdout, kind, diagnostics)
57
+ case kind
58
+ when "github" then github(stdout, diagnostics)
59
+ when "gitlab" then stdout.puts(JSON.pretty_generate(gitlab(diagnostics)))
60
+ when "rdjson" then stdout.puts(JSON.pretty_generate(rdjson(diagnostics)))
61
+ when "azure" then azure(stdout, diagnostics)
62
+ when "teamcity" then teamcity(stdout, diagnostics)
63
+ else buildkite(stdout, diagnostics)
64
+ end
65
+ end
66
+
67
+ def github(stdout, diagnostics)
68
+ diagnostics.each do |diagnostic|
69
+ stdout.puts("::warning file=#{diagnostic.fetch(:path)},line=#{diagnostic.fetch(:first)}," \
70
+ "endLine=#{diagnostic.fetch(:last)}::#{diagnostic.fetch(:message)}")
71
+ end
72
+ end
73
+
74
+ # GitLab's Code Quality report, a subset of the Code Climate spec, read from
75
+ # a `codequality` artifact for merge request annotations.
76
+ def gitlab(diagnostics)
77
+ diagnostics.map do |diagnostic|
78
+ {
79
+ "description" => diagnostic.fetch(:message),
80
+ "check_name" => "coverage",
81
+ "fingerprint" => fingerprint(diagnostic),
82
+ "severity" => "minor",
83
+ "location" => {
84
+ "path" => diagnostic.fetch(:path),
85
+ "lines" => {"begin" => diagnostic.fetch(:first), "end" => diagnostic.fetch(:last)}
86
+ }
87
+ }
88
+ end
89
+ end
90
+
91
+ def fingerprint(diagnostic)
92
+ Digest::SHA256.hexdigest("#{diagnostic.fetch(:path)}:#{diagnostic.fetch(:first)}-#{diagnostic.fetch(:last)}:" \
93
+ "#{diagnostic.fetch(:criterion)}")
94
+ end
95
+
96
+ # reviewdog's Diagnostic Format, which reviewdog posts to whichever host it
97
+ # runs under.
98
+ def rdjson(diagnostics)
99
+ {"source" => SOURCE, "severity" => "WARNING", "diagnostics" => diagnostics.map { |d| rdjson_diagnostic(d) }}
100
+ end
101
+
102
+ def rdjson_diagnostic(diagnostic)
103
+ {
104
+ "message" => diagnostic.fetch(:message),
105
+ "location" => {
106
+ "path" => diagnostic.fetch(:path),
107
+ "range" => {"start" => {"line" => diagnostic.fetch(:first)}, "end" => {"line" => diagnostic.fetch(:last)}}
108
+ },
109
+ "severity" => "WARNING",
110
+ "code" => {"value" => diagnostic.fetch(:criterion).to_s}
111
+ }
112
+ end
113
+
114
+ # Azure Pipelines logging commands carry one line number, so a range is
115
+ # spelled out in the message.
116
+ def azure(stdout, diagnostics)
117
+ diagnostics.each do |diagnostic|
118
+ stdout.puts("##vso[task.logissue type=warning;sourcepath=#{escape(diagnostic.fetch(:path), AZURE_ESCAPES)};" \
119
+ "linenumber=#{diagnostic.fetch(:first)}]#{described(diagnostic)}")
120
+ end
121
+ end
122
+
123
+ # TeamCity's code inspection service messages: each criterion is one
124
+ # inspection type, declared once before its first inspection.
125
+ def teamcity(stdout, diagnostics)
126
+ diagnostics.group_by { |diagnostic| diagnostic.fetch(:criterion) }.each do |criterion, group|
127
+ id = "simplecov.#{criterion}"
128
+ stdout.puts(service_message("inspectionType", id: id, name: MESSAGES.fetch(criterion),
129
+ description: DESCRIPTIONS.fetch(criterion), category: "Code coverage"))
130
+ group.each do |diagnostic|
131
+ stdout.puts(service_message("inspection", typeId: id, message: described(diagnostic),
132
+ file: diagnostic.fetch(:path), line: diagnostic.fetch(:first), SEVERITY: "WARNING"))
133
+ end
134
+ end
135
+ end
136
+
137
+ def service_message(name, attributes)
138
+ body = attributes.map { |key, value| "#{key}='#{escape(value.to_s, TEAMCITY_ESCAPES)}'" }.join(" ")
139
+ "##teamcity[#{name} #{body}]"
140
+ end
141
+
142
+ # Buildkite has no per-line channel; its annotations are Markdown, so this
143
+ # is the body for `buildkite-agent annotate`.
144
+ def buildkite(stdout, diagnostics)
145
+ sections = diagnostics.group_by { |diagnostic| diagnostic.fetch(:message) }.map do |message, group|
146
+ (["#### #{message}"] + group.map { |diagnostic| "- `#{diagnostic.fetch(:path)}:#{range(diagnostic)}`" }).join("\n")
147
+ end
148
+ stdout.puts(sections.join("\n\n")) unless sections.empty?
149
+ end
150
+
151
+ def described(diagnostic)
152
+ return diagnostic.fetch(:message) if diagnostic.fetch(:first).equal?(diagnostic.fetch(:last))
153
+
154
+ "#{diagnostic.fetch(:message)} (lines #{range(diagnostic)})"
155
+ end
156
+
157
+ def range(diagnostic)
158
+ [diagnostic.fetch(:first), diagnostic.fetch(:last)].uniq.join("-")
159
+ end
160
+
161
+ def escape(text, escapes)
162
+ text.gsub(Regexp.union(escapes.keys), escapes)
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Badge
6
+ # Renders the badge SVG in shields.io's flat style: 20px tall with 3px
7
+ # rounded corners, the label on #555 and the value on its ladder color, each
8
+ # text drawn twice for the drop shadow at 10x scale. `textLength` pins the
9
+ # layout, so the estimated segment widths never let glyphs spill.
10
+ module Svg
11
+ extend self
12
+
13
+ COLORS = [[90, "#4c1"], [80, "#97ca00"], [70, "#a4a61d"], [60, "#dfb317"], [50, "#fe7d37"]].freeze
14
+
15
+ def color(percent)
16
+ rung = COLORS.detect { |floor, _color| percent >= floor }
17
+ rung ? rung.fetch(1) : "#e05d44"
18
+ end
19
+
20
+ def render(label:, percent:)
21
+ value = format("%.2f%%", percent)
22
+ document(label: escape(label), value: value, fill: color(percent),
23
+ geo: geometry(width(label), width(value)))
24
+ end
25
+
26
+ # Estimates 11px Verdana; `textLength` absorbs the error.
27
+ def width(text)
28
+ (7 * text.length) + 10
29
+ end
30
+
31
+ # The scale(.1) trick draws text at 10x and shrinks it, so the text
32
+ # coordinates live in a 10x space: centers at 10 * (offset + width / 2) and
33
+ # textLength spanning the width less padding.
34
+ def geometry(label_width, value_width)
35
+ {label_width: label_width, value_width: value_width, total: label_width + value_width,
36
+ label_x: 5 * label_width, value_x: (10 * label_width) + (5 * value_width),
37
+ label_span: 10 * (label_width - 10), value_span: 10 * (value_width - 10)}
38
+ end
39
+
40
+ def escape(text)
41
+ text.gsub(/[&<>"]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;")
42
+ end
43
+
44
+ def document(label:, value:, fill:, geo:)
45
+ <<~SVG
46
+ <svg xmlns="http://www.w3.org/2000/svg" width="#{geo.fetch(:total)}" height="20" role="img" aria-label="#{label}: #{value}">
47
+ <title>#{label}: #{value}</title>
48
+ <linearGradient id="s" x2="0" y2="100%">
49
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
50
+ <stop offset="1" stop-opacity=".1"/>
51
+ </linearGradient>
52
+ <clipPath id="r"><rect width="#{geo.fetch(:total)}" height="20" rx="3" fill="#fff"/></clipPath>
53
+ <g clip-path="url(#r)">
54
+ <rect width="#{geo.fetch(:label_width)}" height="20" fill="#555"/>
55
+ <rect x="#{geo.fetch(:label_width)}" width="#{geo.fetch(:value_width)}" height="20" fill="#{fill}"/>
56
+ <rect width="#{geo.fetch(:total)}" height="20" fill="url(#s)"/>
57
+ </g>
58
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
59
+ <text aria-hidden="true" x="#{geo.fetch(:label_x)}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="#{geo.fetch(:label_span)}">#{label}</text>
60
+ <text x="#{geo.fetch(:label_x)}" y="140" transform="scale(.1)" fill="#fff" textLength="#{geo.fetch(:label_span)}">#{label}</text>
61
+ <text aria-hidden="true" x="#{geo.fetch(:value_x)}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="#{geo.fetch(:value_span)}">#{value}</text>
62
+ <text x="#{geo.fetch(:value_x)}" y="140" transform="scale(.1)" fill="#fff" textLength="#{geo.fetch(:value_span)}">#{value}</text>
63
+ </g>
64
+ </svg>
65
+ SVG
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "command_helpers"
5
+ require_relative "coverage_file"
6
+ require_relative "badge/svg"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ module Badge
11
+ extend CommandHelpers
12
+
13
+ extend self
14
+
15
+ TOTAL_KEYS = {line: "lines", branch: "branches", method: "methods"}.freeze
16
+
17
+ def run(args, stdout:, stderr:)
18
+ opts = parse(args)
19
+ key = TOTAL_KEYS[opts.fetch(:criterion)]
20
+ unless key
21
+ return error(stderr,
22
+ "unknown --criterion #{opts.fetch(:criterion).inspect} (expected line, branch, or method)")
23
+ end
24
+
25
+ percent = percent_for(opts, key, stderr)
26
+ return 1 unless percent
27
+
28
+ label = opts.fetch(:label) || "#{opts.fetch(:criterion)} coverage"
29
+ emit(Svg.render(label: label, percent: percent), opts, stdout)
30
+ end
31
+
32
+ def parse(args)
33
+ opts = {input: CLI.default_input, criterion: :line,
34
+ label: nil, output: nil} #: Hash[Symbol, untyped]
35
+ build_parser do |parser|
36
+ parser.on("--input PATH") { |v| opts[:input] = v }
37
+ parser.on("--output PATH") { |v| opts[:output] = v }
38
+ parser.on("--criterion C") { |v| opts[:criterion] = v.to_sym }
39
+ parser.on("--label TEXT") { |v| opts[:label] = v }
40
+ end.parse(args)
41
+ opts
42
+ end
43
+
44
+ def percent_for(opts, key, stderr)
45
+ document = CoverageFile.load_document(opts.fetch(:input), command: "badge", stderr: stderr)
46
+ return nil unless document
47
+
48
+ totals = document["total"]
49
+ percent = totals.dig(key, "percent") if totals.is_a?(Hash)
50
+ return percent if percent.is_a?(Numeric)
51
+
52
+ error_nil(stderr, "no #{opts.fetch(:criterion)} totals in #{opts.fetch(:input)} " \
53
+ "(was the run measuring #{opts.fetch(:criterion)} coverage?)")
54
+ end
55
+
56
+ def emit(svg, opts, stdout)
57
+ opts.fetch(:output) ? File.write(opts.fetch(:output), svg) : stdout.puts(svg)
58
+ 0
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require_relative "command_helpers"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ # `simplecov clean [--dry-run]`: remove the coverage report directory.
10
+ # `--dry-run` prints what would be deleted without touching disk.
11
+ module Clean
12
+ extend CommandHelpers
13
+
14
+ extend self
15
+
16
+ def run(args, stdout:, stderr:, **)
17
+ opts = parse(args)
18
+ dir = CLI.coverage_dir
19
+ return error(stderr, "refusing to remove unsafe coverage directory #{dir.inspect}") unless safe_to_remove?(dir)
20
+ return announce(stdout, opts, "#{dir} doesn't exist; nothing to do") || 0 unless File.directory?(dir)
21
+
22
+ sweep(dir, opts, stdout)
23
+ 0
24
+ end
25
+
26
+ def sweep(dir, opts, stdout)
27
+ if opts.fetch(:dry_run)
28
+ announce(stdout, opts, "would remove #{dir} (#{entry_count(dir)} entries)")
29
+ else
30
+ FileUtils.rm_rf(dir)
31
+ announce(stdout, opts, "removed #{dir}")
32
+ end
33
+ end
34
+
35
+ # FNM_DOTMATCH so the dotfiles rm_rf will actually delete count too. The
36
+ # glob still yields the directory's own "." entry, which rm_rf does not
37
+ # remove separately.
38
+ def entry_count(dir)
39
+ Dir.glob("#{dir}/**/*", File::FNM_DOTMATCH).count { |path| !path.end_with?("/.") }
40
+ end
41
+
42
+ def announce(stdout, opts, message)
43
+ stdout.puts("simplecov #{command_name}: #{message}") unless opts.fetch(:quiet)
44
+ end
45
+
46
+ # A filesystem root is its own dirname, on every platform and every Windows
47
+ # drive. The descendant check only protects roots that happen to contain the
48
+ # cwd, which misses other drives, so roots are refused outright.
49
+ def safe_to_remove?(dir)
50
+ target = canonical_path(dir)
51
+ return false if File.dirname(target).eql?(target)
52
+
53
+ protected_paths.none? { |path| path.eql?(target) || descendant_of?(path, target) }
54
+ end
55
+
56
+ # The working directory, which a coverage directory must not be or contain.
57
+ # The `.simplecov` project root needs no entry of its own: `Dotfile.find`
58
+ # walks up from the working directory, so any target containing that root
59
+ # contains the working directory too.
60
+ def protected_paths
61
+ [canonical_path(Dir.pwd)]
62
+ end
63
+
64
+ # Canonical paths only carry a trailing separator on a filesystem root, and
65
+ # roots are refused before this check runs.
66
+ def descendant_of?(path, possible_ancestor)
67
+ path.start_with?("#{possible_ancestor}#{File::SEPARATOR}")
68
+ end
69
+
70
+ def canonical_path(path)
71
+ File.realpath(path)
72
+ rescue SystemCallError
73
+ File.expand_path(path)
74
+ end
75
+
76
+ def parse(args)
77
+ opts = {dry_run: false, quiet: false}
78
+ build_parser do |o|
79
+ o.on("--dry-run") { opts[:dry_run] = true }
80
+ quiet_option(o, opts)
81
+ end.parse(args)
82
+ opts
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ module CommandHelpers
8
+ STATS_ROW_FORMAT = " %<label>-7s %<pct>s (%<covered>d / %<total>d)"
9
+
10
+ # Raised by the shared --help switch; `CLI.dispatch` turns it into the
11
+ # command's own slice of the usage text. The explicit handler also overrides
12
+ # optparse's built-in --help, which would print a bare option summary and
13
+ # exit the process outright from inside the parser.
14
+ class HelpRequested < StandardError; end
15
+
16
+ def command_name
17
+ name.split("::").last.downcase
18
+ end
19
+
20
+ def one?(count)
21
+ (count - 1).zero?
22
+ end
23
+
24
+ def error(stderr, message)
25
+ stderr.puts("simplecov #{command_name}: #{message}")
26
+ 1
27
+ end
28
+
29
+ # `error` returns 1 for an exit status; query helpers need nil so their
30
+ # callers can tell "reported" from a real answer.
31
+ def error_nil(stderr, message)
32
+ error(stderr, message)
33
+ nil
34
+ end
35
+
36
+ def recorded_contexts(document, opts, stderr)
37
+ contexts = document["contexts"]
38
+ if contexts.nil?
39
+ return error_nil(stderr, "no test contexts recorded in #{opts.fetch(:input)}. Enable `track_tests` in " \
40
+ "your `SimpleCov.start` block and rerun the suite to record them")
41
+ end
42
+ return contexts if contexts.instance_of?(Array) && contexts.all?(String)
43
+
44
+ CoverageFile.report_invalid(stderr, command_name, opts.fetch(:input), '"contexts" must be an array of strings')
45
+ end
46
+
47
+ def common_options(parser, opts)
48
+ parser.on("--input PATH") { |v| opts[:input] = v }
49
+ parser.on("--json") { opts[:json] = true }
50
+ parser.on("--no-color") { opts[:no_color] = true }
51
+ end
52
+
53
+ def build_parser
54
+ OptionParser.new do |parser|
55
+ yield parser if block_given?
56
+ on_help(parser)
57
+ end
58
+ end
59
+
60
+ # mutant:disable
61
+ # OptionParser answers `-q` as an unambiguous abbreviation of `--quiet`
62
+ # whether or not the short form is declared.
63
+ def quiet_option(parser, opts)
64
+ parser.on("-q", "--quiet") { opts[:quiet] = true }
65
+ end
66
+
67
+ # mutant:disable — optparse matches an abbreviated `-h` against
68
+ # `--help` whether or not the short form is declared, so dropping
69
+ # it changes nothing an example could see. Declared anyway, so the
70
+ # flag appears in the usage text.
71
+ def on_help(parser)
72
+ parser.on("--help", "-h") { raise HelpRequested }
73
+ end
74
+
75
+ def parse_common(args, defaults = {})
76
+ opts = {input: CLI.default_input, json: false, no_color: false} #: Hash[Symbol, untyped]
77
+ opts.merge!(defaults)
78
+ rest =
79
+ build_parser do |parser|
80
+ common_options(parser, opts)
81
+ yield parser, opts if block_given?
82
+ end.parse(args)
83
+ [opts, rest]
84
+ end
85
+
86
+ def stats_row(label, percent_text, covered, total)
87
+ format(STATS_ROW_FORMAT, label: "#{label}:", pct: percent_text, covered: covered.to_i, total: total.to_i)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Completions
6
+ # The per-shell renderers. Each receives the same parsed usage data and
7
+ # returns the complete script text. The completions command's own shell-name
8
+ # argument is the one thing the usage tables don't carry, so each renderer
9
+ # wires it in by hand.
10
+ module Scripts
11
+ extend self
12
+
13
+ def fish(commands, options)
14
+ lines = ["# Completions for simplecov. Generated by `simplecov completions fish`."] #: Array[String]
15
+ commands.each do |name, desc|
16
+ lines << "complete -c simplecov -f -n __fish_use_subcommand -a #{name} -d #{fish_quote(desc)}"
17
+ end
18
+ options.each { |name, list| list.each { |option| lines << fish_option(name, option) } }
19
+ lines << "complete -c simplecov -f -n '__fish_seen_subcommand_from completions' -a 'fish bash zsh'"
20
+ lines.join("\n")
21
+ end
22
+
23
+ def fish_option(name, option)
24
+ parts = ["complete -c simplecov -n '__fish_seen_subcommand_from #{name}'"] #: Array[String]
25
+ parts << "-s #{(_ = option.fetch(:short)).delete_prefix("-")}" if option.fetch(:short)
26
+ parts << "-l #{option.fetch(:long).delete_prefix("--")}"
27
+ parts << "-r" if option.fetch(:arg)
28
+ parts << "-d #{fish_quote(option.fetch(:desc))}"
29
+ parts.join(" ")
30
+ end
31
+
32
+ def fish_quote(text)
33
+ "'#{text.gsub("\\") { "\\\\" }.gsub("'") { "\\'" }}'"
34
+ end
35
+
36
+ BASH_TAIL = [
37
+ " esac",
38
+ ' [[ "$cur" == -* ]] && COMPREPLY=( $(compgen -W "$opts" -- "$cur") )',
39
+ "}",
40
+ "complete -o default -F _simplecov simplecov"
41
+ ].freeze
42
+
43
+ def bash(commands, options)
44
+ (bash_head(commands) + bash_cases(options) + BASH_TAIL).join("\n")
45
+ end
46
+
47
+ def bash_head(commands)
48
+ [
49
+ "# Completions for simplecov. Generated by `simplecov completions bash`.",
50
+ "_simplecov() {",
51
+ ' local cur="${COMP_WORDS[COMP_CWORD]}" opts=""',
52
+ ' if [ "$COMP_CWORD" -eq 1 ]; then',
53
+ " COMPREPLY=( $(compgen -W \"#{commands.collect(&:first).join(" ")}\" -- \"$cur\") ); return",
54
+ " fi",
55
+ ' case "${COMP_WORDS[1]}" in'
56
+ ]
57
+ end
58
+
59
+ def bash_cases(options)
60
+ shells = ' completions) COMPREPLY=( $(compgen -W "fish bash zsh -h --help" -- "$cur") ); return;;'
61
+ cases = [shells] #: Array[String]
62
+ options.each do |name, list|
63
+ next if name.eql?("completions")
64
+
65
+ words = list.flat_map { |option| [option.fetch(:short), option.fetch(:long)] }.compact.join(" ")
66
+ cases << " #{name}) opts=\"#{words}\";;"
67
+ end
68
+ cases
69
+ end
70
+
71
+ ZSH_MIDDLE = [
72
+ " )",
73
+ " if (( CURRENT == 2 )); then",
74
+ " _describe -t commands 'simplecov command' commands",
75
+ " return",
76
+ " fi",
77
+ ' case "$words[2]" in'
78
+ ].freeze
79
+
80
+ ZSH_TAIL = [" esac", "}", '_simplecov "$@"'].freeze
81
+
82
+ def zsh(commands, options)
83
+ head = ["#compdef simplecov", "# Completions for simplecov. Generated by `simplecov completions zsh`.",
84
+ "_simplecov() {", " local -a commands", " commands=("]
85
+ rows = commands.collect { |name, desc| " '#{name}:#{zsh_quote(desc)}'" }
86
+ (head + rows + ZSH_MIDDLE + zsh_cases(options) + ZSH_TAIL).join("\n")
87
+ end
88
+
89
+ def zsh_cases(options)
90
+ options.collect do |name, list|
91
+ specs = +""
92
+ specs << "'1:shell:(fish bash zsh)' " if name.eql?("completions")
93
+ list.each { |option| zsh_specs(option).each { |spec| specs << spec << " " } }
94
+ " #{name}) _arguments #{specs.chop};;"
95
+ end
96
+ end
97
+
98
+ # `'--input=[desc]'`: the `=` marks an argument-taking switch, which optparse
99
+ # accepts in that spelling too. Brackets would end the description early, so
100
+ # they are dropped from it.
101
+ def zsh_specs(option)
102
+ desc = "[#{zsh_quote(option.fetch(:desc).delete("[]"))}]"
103
+ specs = [] #: Array[String]
104
+ specs << "'#{option.fetch(:short)}#{desc}'" if option.fetch(:short)
105
+ specs << "'#{option.fetch(:long)}#{"=" if option.fetch(:arg)}#{desc}'"
106
+ end
107
+
108
+ def zsh_quote(text)
109
+ text.gsub("'") { "'\\''" }
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "command_helpers"
5
+ require_relative "usage"
6
+ require_relative "completions/scripts"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov completions <shell>`: completion scripts for fish, bash, and
11
+ # zsh, generated from the usage document rather than a hand-kept table, so a
12
+ # new command or switch shows up in completions the moment it is documented.
13
+ module Completions
14
+ extend CommandHelpers
15
+
16
+ extend self
17
+
18
+ SHELLS = %w[fish bash zsh].freeze
19
+
20
+ # " -q, --quiet Suppress...": optional short, long, optional
21
+ # argument placeholder, then the description after the column gap. A single
22
+ # space never separates the switch from its description, so one non-space
23
+ # token before a two-space run can only be an argument.
24
+ OPTION_ROW = /\A\s*(?:(-\w), )?(--[\w-]+)(?: (\S+))?\s{2,}(\S.*)\z/
25
+ COMMAND_ROW = /\A(\S+)(?: \S+)*?\s{2,}(\S.*)\z/
26
+
27
+ def run(args, stdout:, stderr:)
28
+ shell, = build_parser.parse(args)
29
+ return error(stderr, "missing shell (expected fish, bash, or zsh)") unless shell
30
+ unless SHELLS.include?(shell)
31
+ return error(stderr, "unknown shell #{shell.inspect} (expected fish, bash, or zsh)")
32
+ end
33
+
34
+ stdout.puts(script_for(shell))
35
+ 0
36
+ end
37
+
38
+ def script_for(shell)
39
+ case shell
40
+ when "fish" then Scripts.fish(commands, options_by_command)
41
+ when "bash" then Scripts.bash(commands, options_by_command)
42
+ else Scripts.zsh(commands, options_by_command)
43
+ end
44
+ end
45
+
46
+ def commands
47
+ Usage.text(CLI).split("\n\n").fetch(1).lines.filter_map do |row|
48
+ match = row.strip.match(COMMAND_ROW)
49
+ match && [(_ = match[1]), (_ = match[2])]
50
+ end
51
+ end
52
+
53
+ def options_by_command
54
+ commands.filter_map do |name, _desc|
55
+ list = options_for(name)
56
+ [name, list] unless list.empty?
57
+ end.to_h
58
+ end
59
+
60
+ # `run` takes no options of its own, since everything after it belongs to the
61
+ # command being run, so it must not even offer --help.
62
+ def options_for(command)
63
+ return [] if command.eql?("run")
64
+
65
+ sections = Usage.text(CLI).split("\n\n").select { |section| Usage.section_for?(section, command) }
66
+ options = sections.flat_map { |section| section_options(section) }
67
+ options << {short: "-h", long: "--help", arg: nil, desc: "Show this command's usage"}
68
+ end
69
+
70
+ # Rows that don't parse as options are continuations of the previous row's
71
+ # description, wrapped for the terminal.
72
+ def section_options(section)
73
+ section.lines.each_with_object([]) do |line, options|
74
+ if (match = line.chomp.match(OPTION_ROW))
75
+ options << {short: match[1], long: match[2], arg: match[3], desc: (_ = match[4])}
76
+ elsif (previous = options.last)
77
+ previous[:desc] = "#{previous.fetch(:desc)} #{line.strip}"
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end