simplecov 1.2.0 → 1.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/simplecov/cli/affected/runner.rb +38 -0
  4. data/lib/simplecov/cli/affected.rb +4 -5
  5. data/lib/simplecov/cli/annotations.rb +166 -0
  6. data/lib/simplecov/cli/clean.rb +2 -1
  7. data/lib/simplecov/cli/coverage_file.rb +2 -1
  8. data/lib/simplecov/cli/patch/output.rb +16 -0
  9. data/lib/simplecov/cli/patch.rb +8 -2
  10. data/lib/simplecov/cli/real_path.rb +41 -0
  11. data/lib/simplecov/cli/serve/static_file_handler.rb +5 -3
  12. data/lib/simplecov/cli/uncovered/misses.rb +5 -9
  13. data/lib/simplecov/cli/uncovered.rb +6 -20
  14. data/lib/simplecov/cli/usage.rb +2 -1
  15. data/lib/simplecov/cli.rb +1 -0
  16. data/lib/simplecov/configuration/eval_coverage.rb +1 -1
  17. data/lib/simplecov/configuration/view_coverage.rb +2 -3
  18. data/lib/simplecov/coverage_violations.rb +4 -2
  19. data/lib/simplecov/directive/erb.rb +44 -0
  20. data/lib/simplecov/directive/haml.rb +33 -0
  21. data/lib/simplecov/directive/indented_template.rb +37 -0
  22. data/lib/simplecov/directive/slim.rb +35 -0
  23. data/lib/simplecov/directive/template.rb +29 -0
  24. data/lib/simplecov/directive.rb +1 -0
  25. data/lib/simplecov/formatter/html_formatter/public/index.html +12 -12
  26. data/lib/simplecov/production.rb +1 -1
  27. data/lib/simplecov/profiles/rails.rb +4 -4
  28. data/lib/simplecov/profiles/strict.rb +3 -3
  29. data/lib/simplecov/result.rb +6 -0
  30. data/lib/simplecov/result_processing.rb +20 -5
  31. data/lib/simplecov/simulate_coverage.rb +2 -2
  32. data/lib/simplecov/source_file/builder_context.rb +7 -0
  33. data/lib/simplecov/source_file/skip_chunks.rb +11 -2
  34. data/lib/simplecov/source_file/statistics.rb +16 -6
  35. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +9 -42
  36. data/lib/simplecov/static_coverage_extractor/method_collector.rb +1 -5
  37. data/lib/simplecov/static_coverage_extractor/visitor.rb +5 -7
  38. data/lib/simplecov/static_coverage_extractor.rb +27 -23
  39. data/lib/simplecov/version.rb +1 -1
  40. data/lib/simplecov/view_coverage/template_compiler.rb +16 -7
  41. data/man/simplecov.1 +8 -3
  42. data/sig/simplecov.rbs +5 -0
  43. metadata +13 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 774f2590be4fc8efd720e8c61b8e0678b50794dbc8c881c5872c51ac7b318c08
4
- data.tar.gz: a67efdab2414345fa195aea9015d5cfc5128c452be20888edc1814c05fff98e0
3
+ metadata.gz: c96bd7c97b97593cd5f90f20315a755ddbb9e25bd2b43ca54cc19fdf6c0540ac
4
+ data.tar.gz: 23b1b6392bfcf9119b2a5c575adf54568c47ef2c5838e11cbb77bffd72d61666
5
5
  SHA512:
6
- metadata.gz: 32062ccc9c83227f868788d5f2a0fa43eda93e8f714ed4ffe068fea3ac05ee7dd21f26d4234903da7679a6c72325955282000055892deaabf4a0703f924f06ca
7
- data.tar.gz: c4a9a68bfbed7bc9c38de07afd89bedfefd3ea1ff1ee2b5294701c588c6cca7a10ae571313e0d836478045c07fcee81e4a1dc20c258d92e13a46f1e044c4d6d5
6
+ metadata.gz: 3fb49f89386b147aa212d3b8814fb4afef021f394a2231c1e0649b5c35ea4e4ed14ba96cb9b8deacf1d6825629f56c55f900e3decaea287bff81f8a05d12b83f
7
+ data.tar.gz: a57ae0348b43869e6084866da885132c118acf88d83d967992d78aec222f8b6fab1ff6ab6572d3cc1ecdb2e701650aac9837ba45e963e64312ca8d3cdaaad1bd
data/README.md CHANGED
@@ -212,7 +212,7 @@ Both commands are documented in [the CLI docs](docs/CLI.md).
212
212
 
213
213
  View templates execute real logic, and now they can be part of the report.
214
214
  `cover_views` brings ERB, Haml, and Slim templates in, measured through eval
215
- coverage (CRuby 3.2+):
215
+ coverage:
216
216
 
217
217
  ```ruby
218
218
  SimpleCov.start 'rails' do
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ module Affected
8
+ # Starts the runner at the repository root, since the selection's paths
9
+ # are relative to it, and answers the runner's exit status.
10
+ module SpawnedRunner
11
+ extend self
12
+
13
+ def call(command, root)
14
+ _, status = Process.wait2(spawn(*command, chdir: root))
15
+ status.exitstatus || 1
16
+ end
17
+ end
18
+
19
+ # JRuby on Windows answers Process.wait2 with a nil status and ignores
20
+ # the chdir option Kernel#system takes, so there the runner starts
21
+ # inside Dir.chdir, and Kernel#system reports its status.
22
+ module SystemRunner
23
+ extend self
24
+
25
+ def call(command, root)
26
+ ran = Dir.chdir(root) { system(*command) }
27
+ raise Errno::ENOENT, command.first if ran.nil?
28
+
29
+ $CHILD_STATUS.exitstatus || 1
30
+ end
31
+ end
32
+
33
+ # simplecov:disable branch — fixed by the running engine and OS
34
+ RUNNER = (RUBY_ENGINE.eql?("jruby") && Gem.win_platform?) ? SystemRunner : SpawnedRunner
35
+ # simplecov:enable branch
36
+ end
37
+ end
38
+ end
@@ -5,6 +5,7 @@ require "optparse"
5
5
  require_relative "command_helpers"
6
6
  require_relative "tests"
7
7
  require_relative "affected/changed_files"
8
+ require_relative "affected/runner"
8
9
  require_relative "affected/selection"
9
10
 
10
11
  module SimpleCov
@@ -133,12 +134,10 @@ module SimpleCov
133
134
  run_command(opts.fetch(:run) + selection.fetch(:tests), opts.fetch(:root), stderr)
134
135
  end
135
136
 
136
- # The selection's paths are relative to the repository root, so the runner
137
- # starts there. The command is named explicitly in the failure because not
138
- # every engine's exception message carries it.
137
+ # The command is named explicitly in the failure because not every
138
+ # engine's exception message carries it.
139
139
  def run_command(command, root, stderr)
140
- _, status = Process.wait2(spawn(*command, chdir: root))
141
- status.exitstatus || 1
140
+ RUNNER.call(command, root)
142
141
  rescue SystemCallError => e
143
142
  stderr.puts("simplecov affected: cannot run #{command.first.inspect} (#{e})")
144
143
  127
@@ -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
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "optparse"
5
5
  require_relative "command_helpers"
6
+ require_relative "real_path"
6
7
 
7
8
  module SimpleCov
8
9
  module CLI
@@ -68,7 +69,7 @@ module SimpleCov
68
69
  end
69
70
 
70
71
  def canonical_path(path)
71
- File.realpath(path)
72
+ REAL_PATHS.realpath(path)
72
73
  rescue SystemCallError
73
74
  File.expand_path(path)
74
75
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../coverage_json"
4
+ require_relative "real_path"
4
5
 
5
6
  module SimpleCov
6
7
  module CLI
@@ -55,7 +56,7 @@ module SimpleCov
55
56
 
56
57
  # A key whose file no longer exists keeps its literal spelling.
57
58
  def normalize(key)
58
- File.realdirpath(key)
59
+ REAL_PATHS.realdirpath(key)
59
60
  rescue SystemCallError
60
61
  key
61
62
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../annotations"
4
5
 
5
6
  module SimpleCov
6
7
  module CLI
@@ -8,8 +9,23 @@ module SimpleCov
8
9
  module Output
9
10
  OPTIONAL_CRITERIA = %i[branch method].freeze
10
11
 
12
+ CRITERIA = %i[line branch method].freeze
13
+
11
14
  extend self
12
15
 
16
+ # One diagnostic per contiguous missed range per measured criterion, in the
17
+ # CI host's own annotation form. Totals and the empty-change notice stay
18
+ # off stdout: the exit status under --minimum is the verdict.
19
+ def annotate(stdout, rows, kind)
20
+ diagnostics = rows.flat_map do |row|
21
+ CRITERIA.flat_map do |criterion|
22
+ stats = row.fetch(criterion)
23
+ stats ? Annotations.diagnostics(row.fetch(:file), stats.fetch(:missing), criterion) : []
24
+ end
25
+ end
26
+ Annotations.emit(stdout, kind, diagnostics)
27
+ end
28
+
13
29
  def emit(stdout, rows, opts)
14
30
  if opts.fetch(:json)
15
31
  stdout.puts(JSON.pretty_generate(json_rows(rows)))
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "annotations"
3
4
  require_relative "command_helpers"
4
5
  require_relative "patch/changed_lines"
5
6
  require_relative "patch/output"
@@ -35,20 +36,25 @@ module SimpleCov
35
36
  return 1 unless diffed
36
37
 
37
38
  rows = compute_rows(opts.fetch(:coverage), diffed, stderr)
38
- Output.emit(stdout, rows, opts)
39
+ kind = opts.fetch(:annotate)
40
+ kind ? Output.annotate(stdout, rows, kind) : Output.emit(stdout, rows, opts)
39
41
  Output.gate(rows, opts.fetch(:minimum))
40
42
  end
41
43
 
42
44
  def parse(args, stderr)
43
45
  # No `base:` default: the run fills it in from the repository when the
44
46
  # option is left out.
45
- opts, rest = parse_common(args, find_renames: false, minimum: nil) do |parser, options|
47
+ opts, rest = parse_common(args, find_renames: false, minimum: nil, annotate: nil) do |parser, options|
46
48
  parser.on("--base REF") { |v| options[:base] = v }
47
49
  parser.on("--minimum N", Float) { |v| options[:minimum] = v }
48
50
  parser.on("--find-renames") { options[:find_renames] = true }
51
+ parser.on("--annotate KIND") { |v| options[:annotate] = v }
49
52
  end
50
53
  return unless positional_ok?(rest, stderr)
51
54
 
55
+ issue = Annotations.issue(opts)
56
+ return error_nil(stderr, issue) if issue
57
+
52
58
  opts[:coverage] = CoverageFile.load_coverage(opts.fetch(:input), command: "patch", stderr: stderr) or return nil
53
59
  opts
54
60
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ # The JDK's answer to File.realpath and File.realdirpath, for JRuby. On
6
+ # Windows JRuby's own versions follow no symlinks, and realdirpath accepts
7
+ # a directory that does not exist, so the CLI's containment and identity
8
+ # checks would compare paths that were never resolved. Path#toRealPath has
9
+ # neither problem on any OS. The path is expanded first because the JDK
10
+ # resolves a relative path against the JVM's working directory, which
11
+ # Dir.chdir does not move.
12
+ #
13
+ # simplecov:disable — JRuby-only; this suite's coverage is measured on CRuby
14
+ module JDKRealPath
15
+ extend self
16
+
17
+ # mutant:disable — JRuby-only, unreachable from the engine mutant runs on
18
+ def realpath(path)
19
+ expanded = File.expand_path(path)
20
+ java.nio.file.Paths.get(expanded).toRealPath.toString.tr("\\", "/") # steep:ignore NoMethod
21
+ rescue java.nio.file.NoSuchFileException, java.nio.file.NotDirectoryException # steep:ignore NoMethod
22
+ raise Errno::ENOENT, expanded
23
+ rescue java.nio.file.AccessDeniedException # steep:ignore NoMethod
24
+ raise Errno::EACCES, expanded
25
+ rescue java.nio.file.InvalidPathException # steep:ignore NoMethod
26
+ raise Errno::EINVAL, expanded
27
+ end
28
+
29
+ # mutant:disable — JRuby-only, unreachable from the engine mutant runs on
30
+ def realdirpath(path)
31
+ expanded = File.expand_path(path)
32
+ return realpath(expanded) if File.exist?(expanded)
33
+
34
+ File.join(realpath(File.dirname(expanded)), File.basename(expanded))
35
+ end
36
+ end
37
+ # simplecov:enable
38
+
39
+ REAL_PATHS = RUBY_ENGINE.eql?("jruby") ? JDKRealPath : File # simplecov:disable branch — fixed by the running engine
40
+ end
41
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../real_path"
4
+
3
5
  module SimpleCov
4
6
  module CLI
5
7
  module Serve
@@ -93,7 +95,7 @@ module SimpleCov
93
95
  # added, it must happen BEFORE the `inside?` check.
94
96
  def resolve(request_path, root)
95
97
  path = request_path.split("?").first.to_s.delete_prefix("/")
96
- absolute_root = File.realpath(root)
98
+ absolute_root = REAL_PATHS.realpath(root)
97
99
  candidate = File.expand_path(path, absolute_root)
98
100
  # Rejected before touching disk, so traversal and absolute-path attempts
99
101
  # are 403, not 404.
@@ -104,10 +106,10 @@ module SimpleCov
104
106
 
105
107
  # Symlinks are resolved last and re-checked: a file inside root could be
106
108
  # a symlink pointing outside.
107
- real = File.realpath(candidate)
109
+ real = REAL_PATHS.realpath(candidate)
108
110
  inside?(real, absolute_root) ? real : :forbidden
109
111
  rescue Errno::ENOENT
110
- # TOCTOU: candidate vanished between File.file? and File.realpath.
112
+ # TOCTOU: candidate vanished between File.file? and resolving it.
111
113
  nil
112
114
  end
113
115
 
@@ -20,16 +20,12 @@ module SimpleCov
20
20
  missed.uniq.sort
21
21
  end
22
22
 
23
- # GitHub workflow commands, one ::warning per contiguous missed range, so a
24
- # plain workflow gets inline diff annotations with no upload step and no
25
- # code-scanning permissions.
26
- def annotate(stdout, files)
27
- files.each do |fname, _pct, _covered, _total, missed|
28
- path = fname.delete_prefix("#{File.expand_path(SimpleCov.root)}/")
29
- missed.slice_when { |previous, current| current > previous + 1 }.each do |run|
30
- stdout.puts("::warning file=#{path},line=#{run.first},endLine=#{run.last}::Not covered by tests")
31
- end
23
+ def annotate(stdout, files, criterion, kind)
24
+ root = "#{File.expand_path(SimpleCov.root)}/"
25
+ diagnostics = files.flat_map do |fname, _pct, _covered, _total, missed|
26
+ Annotations.diagnostics(fname.delete_prefix(root), missed, criterion)
32
27
  end
28
+ Annotations.emit(stdout, kind, diagnostics)
33
29
  end
34
30
  end
35
31
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "optparse"
5
+ require_relative "annotations"
5
6
  require_relative "command_helpers"
6
7
  require_relative "patch/output"
7
8
  require_relative "show/annotator"
@@ -21,7 +22,7 @@ module SimpleCov
21
22
 
22
23
  def run(args, stdout:, stderr:, **)
23
24
  opts = parse(args)
24
- issue = precheck(opts)
25
+ issue = Annotations.issue(opts)
25
26
  return error(stderr, issue) if issue
26
27
 
27
28
  keys = CoverageFile::CRITERIA[opts.fetch(:criterion)]
@@ -31,28 +32,11 @@ module SimpleCov
31
32
  report(opts, keys, stdout, stderr)
32
33
  end
33
34
 
34
- def precheck(opts)
35
- return nil unless opts.fetch(:annotate)
36
- unless opts.fetch(:annotate).eql?("github")
37
- return "unknown --annotate #{opts.fetch(:annotate).inspect} (only github is supported)"
38
- end
39
-
40
- "cannot combine --annotate with --json" if opts.fetch(:json)
41
- end
42
-
43
35
  def report(opts, keys, stdout, stderr)
44
36
  coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "uncovered", stderr: stderr)
45
37
  return 1 unless coverage
46
38
 
47
- files = rank(coverage, opts, keys).first(opts.fetch(:top))
48
- return empty(opts, stdout) if files.empty?
49
-
50
- emit(stdout, files, opts)
51
- 0
52
- end
53
-
54
- def empty(opts, stdout)
55
- stdout.puts(empty_message(opts.fetch(:json))) unless opts.fetch(:annotate)
39
+ emit(stdout, rank(coverage, opts, keys).first(opts.fetch(:top)), opts)
56
40
  0
57
41
  end
58
42
 
@@ -62,7 +46,9 @@ module SimpleCov
62
46
  end
63
47
 
64
48
  def emit(stdout, files, opts)
65
- return Misses.annotate(stdout, files) if opts.fetch(:annotate)
49
+ kind = opts.fetch(:annotate)
50
+ return Misses.annotate(stdout, files, opts.fetch(:criterion), kind) if kind
51
+ return stdout.puts(empty_message(opts.fetch(:json))) if files.empty?
66
52
 
67
53
  opts.fetch(:json) ? emit_json(stdout, files) : emit_text(stdout, files, CLI.color_enabled?(opts, stdout))
68
54
  end
@@ -89,7 +89,7 @@ module SimpleCov
89
89
  --top N Show at most N files (default: 10)
90
90
  --criterion C line, branch, or method (default: line)
91
91
  --missing Append the missed line ranges to each row
92
- --annotate github Emit ::warning workflow commands instead of rows
92
+ --annotate KIND Emit CI annotations instead of rows: github, gitlab, rdjson, azure, teamcity, or buildkite
93
93
  --json Emit results as a JSON array (for CI)
94
94
 
95
95
  tests options:
@@ -116,6 +116,7 @@ module SimpleCov
116
116
  --base REF Diff against the merge-base of REF for the touched lines (default: origin's HEAD, else main, or in CI the PR's target branch)
117
117
  --minimum N Exit non-zero when patch coverage on any measured criterion is below N%
118
118
  --find-renames Follow a renamed file instead of counting the moved file as all-new
119
+ --annotate KIND Emit CI annotations instead of rows: github, gitlab, rdjson, azure, teamcity, or buildkite
119
120
  --json Emit results as a JSON array (for CI)
120
121
 
121
122
  dead-code options:
data/lib/simplecov/cli.rb CHANGED
@@ -4,6 +4,7 @@ require "optparse"
4
4
  require_relative "version"
5
5
  require_relative "color"
6
6
  require_relative "cli/dotfile"
7
+ require_relative "cli/real_path"
7
8
  require_relative "cli/badge"
8
9
  require_relative "cli/clean"
9
10
  require_relative "cli/completions"
@@ -25,7 +25,7 @@ module SimpleCov
25
25
  if coverage_for_eval_supported?
26
26
  @coverage_for_eval_enabled = true
27
27
  else
28
- warn "Coverage for eval is not available; Use Ruby 3.2.0 or later"
28
+ warn "Coverage for eval is not available on this Ruby"
29
29
  end
30
30
  end
31
31
 
@@ -11,9 +11,8 @@ module SimpleCov
11
11
  # them as empty.
12
12
  #
13
13
  # Templates the suite renders are measured through eval coverage, which this
14
- # enables, so it needs Ruby 3.2 or later. Templates it never renders are
15
- # compiled at the end of the run so they appear at 0% instead of going
16
- # missing.
14
+ # enables. Templates it never renders are compiled at the end of the run so
15
+ # they appear at 0% instead of going missing.
17
16
  def cover_views(*globs)
18
17
  globs = globs.flatten.compact
19
18
  globs = DEFAULT_VIEW_GLOBS.dup if globs.empty?
@@ -159,10 +159,12 @@ module SimpleCov
159
159
  end
160
160
 
161
161
  # The misconfiguration notice is enforcement output, not a Ruby warning: it
162
- # must survive `-W0` and `Warning.warn` hooks, and honor `print_errors`.
162
+ # must survive `-W0` and `Warning.warn` hooks, and honor `print_errors`. A
163
+ # configured group that matched no files is absent from `result.groups`
164
+ # too, but has nothing to gate rather than nothing to look up.
163
165
  def lookup_group(result, group_name)
164
166
  group = result.groups[group_name]
165
- if group.nil? && SimpleCov.print_errors
167
+ if group.nil? && SimpleCov.print_errors && !result.configured_group?(group_name)
166
168
  ExitCodes.print_error "minimum_coverage_by_group: no group named '#{group_name}' exists. " \
167
169
  "Available groups: #{result.groups.keys.join(", ")}"
168
170
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class Directive
5
+ # The Ruby an ERB template holds, at the template's own line numbers, so
6
+ # directive comments in a template are found the way they are in a `.rb`
7
+ # file. Lexing the template itself as Ruby is not an option: `%>` opens a
8
+ # percent literal that swallows everything up to the next `>`, so comments
9
+ # after the first tag are never tokenized.
10
+ #
11
+ # Text outside the tags is blanked and the tag delimiters become spaces, so
12
+ # every token keeps its line and column. A comment tag becomes a Ruby
13
+ # comment, which makes `<%# simplecov:disable %>` the template's own form of
14
+ # the directive.
15
+ module Erb
16
+ SEGMENT = /
17
+ (?<escaped><%%)
18
+ | <%(?<kind>\#|==?|-)?(?<body>.*?)(?<close>-?%>)
19
+ | (?<text>[^<]+|<)
20
+ /mx
21
+
22
+ def self.ruby_lines(lines)
23
+ source = lines.map { |line| line.end_with?("\n") ? line : "#{line}\n" }.join
24
+ source.gsub(SEGMENT) { convert(Regexp.last_match) }.lines
25
+ rescue ArgumentError, EncodingError
26
+ lines
27
+ end
28
+
29
+ def self.convert(match)
30
+ body = match[:body]
31
+ return blank(match[0]) if body.nil?
32
+ return " #" + body.gsub("\n", "\n#") + blank(match[:close]) if match[:kind].eql?("#")
33
+
34
+ blank("<%" + match[:kind].to_s) + body + blank(match[:close]).sub(" ", ";")
35
+ end
36
+
37
+ def self.blank(text)
38
+ Template.blank(text)
39
+ end
40
+
41
+ private_class_method :convert, :blank
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "indented_template"
4
+
5
+ module SimpleCov
6
+ class Directive
7
+ # The Ruby a Haml template holds. Script lines (`-`, `=`, and their `!`,
8
+ # `&`, and `~` variants) and the script a tag outputs keep their Ruby with
9
+ # the markers blanked, a `-#` comment becomes a Ruby comment, a `:ruby`
10
+ # filter's lines are Ruby as written, and everything else is blanked.
11
+ module Haml
12
+ include IndentedTemplate
13
+ extend self
14
+
15
+ SCRIPT = /\A[!&]?[-=~]/
16
+ TAG_SCRIPT = /\A[%.#][\w:.#-]*(?:\{[^{}]*\}|\([^()]*\)|\[[^\[\]]*\])*[<>]*[!&]?[=~]/
17
+
18
+ def convert(indent, rest)
19
+ if rest.start_with?("-#")
20
+ ["#{indent} ##{rest[2..]}", COMMENT]
21
+ elsif rest.match?(/\A:ruby[ \t]*\n\z/)
22
+ [Template.blank(indent + rest), RUBY]
23
+ elsif rest.start_with?(":")
24
+ [Template.blank(indent + rest), TEXT]
25
+ elsif (marker = rest[SCRIPT] || rest[TAG_SCRIPT])
26
+ [indent + Template.blank(marker) + rest[marker.length..]]
27
+ else
28
+ [Template.blank(indent + rest)]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end