Skip to content

Commit 389df23

Browse files
committed
Add simplecov:disable / simplecov:enable directive comments
Mirror RuboCop's directive style for selectively skipping coverage of specific lines, branches, or methods: # simplecov:disable line (block; closes at matching :enable) # simplecov:disable method, branch # simplecov:disable (no args = all three categories) # simplecov:disable line some reason (any trailing text becomes a reason) # simplecov:enable raise "absurd" # simplecov:disable (inline; just this line) Block directives sit on their own line (possibly indented) and open a region until the matching `# simplecov:enable` for the same category; unclosed disables run to end of file. Inline directives trail real code and only affect that one line. Categories are independently tracked: `# simplecov:disable line` skips those lines from line coverage but leaves any branches and methods on those lines reported normally. The bare form is a shorthand for all three. Trailing text after the directive is treated as a free-form reason and discarded — no `--` separator or other marker is required. As a consequence, an unrecognised category name silently falls into the reason bucket and the bare form takes effect (a deliberate over-disable so typos surface in the report rather than silently disabling nothing). Comment extraction goes through `Ripper.lex`, so directive markers that appear inside string literals or heredocs are correctly ignored. SourceFile collects the per-category disabled ranges from Directive, then applies them in process_skipped_lines, process_skipped_branches, and a new process_skipped_methods. Method gains a direct skipped! flag and overlaps_with? helper paralleling Branch's API. Deprecate `# :nocov:` (and the configurable `SimpleCov.nocov_token`) in favor of the new directives. Each file that still uses `# :nocov:` emits a one-time deprecation warning to stderr at load time pointing at the recommended `# simplecov:disable` / `# simplecov:enable` replacement. The toggle continues to work; it will be removed in a future release.
1 parent 33aee55 commit 389df23

17 files changed

Lines changed: 1117 additions & 31 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ Unreleased
99
* Removed `docile` gem dependency. The `SimpleCov.configure` DSL block is now evaluated via `instance_exec` with instance variable proxying.
1010
* Removed automatic activation of `JSONFormatter` when the `CC_TEST_REPORTER_ID` environment variable is set. The default `HTMLFormatter` now emits `coverage.json` alongside the HTML report (using `JSONFormatter.build_hash` to serialize the same payload `JSONFormatter` writes), so the env-var special case is no longer needed.
1111

12+
## Deprecations
13+
* `# :nocov:` toggle comments (and the configurable `SimpleCov.nocov_token` / `SimpleCov.skip_token`) are deprecated in favor of the new `# simplecov:disable` / `# simplecov:enable` directives. Each file that still uses `# :nocov:` emits a one-time deprecation warning to stderr at load time pointing at the recommended replacement, and any call to `SimpleCov.nocov_token` or `SimpleCov.skip_token` (getter or setter) likewise warns. The directive will be removed in a future release.
14+
1215
## Enhancements
16+
* Added `# simplecov:disable` / `# simplecov:enable` directive comments for selectively skipping `line`, `branch`, and `method` coverage. Block form (own line) opens a region until the matching `# simplecov:enable`; inline form (trailing a code line) skips just that line. Categories may be combined (`# simplecov:disable line, branch`); omitting categories targets all three. Any trailing text is treated as a free-form reason and discarded (e.g. `# simplecov:disable line legacy adapter`). Directive markers inside string literals or heredocs are ignored.
1317
* JSON formatter: `meta.timestamp` is now emitted with millisecond precision (`iso8601(3)`) so the concurrent-overwrite warning can distinguish writes within the same wall-clock second
1418
* JSON formatter: added `total` section with aggregate coverage statistics (covered, missed, total, percent, strength) for line, branch, and method coverage. Line stats additionally include `omitted` (count of blank/comment lines, i.e. lines that cannot be covered)
1519
* JSON formatter: per-file output now includes `total_lines`, `lines_covered_percent`, and when enabled: `branches_covered_percent`, `methods` array, and `methods_covered_percent`

README.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -462,21 +462,39 @@ You can pass in an array containing any of the other filter types.
462462
463463
#### Ignoring/skipping code
464464
465-
You can exclude code from the coverage report by wrapping it in `# :nocov:`.
465+
You can disable coverage by category with `# simplecov:disable` and `# simplecov:enable` comments. The available
466+
categories are `line`, `branch`, and `method`; they may be combined with commas, and omitting them targets all three.
467+
Anything trailing the directive is treated as a free-form reason and ignored — no separator is required, though `--`
468+
or any other visual marker is fine if you prefer one.
466469
467470
```ruby
468-
# :nocov:
469-
def skip_this_method
471+
# simplecov:disable line
472+
def skipped_lines
470473
never_reached
471474
end
472-
# :nocov:
475+
# simplecov:enable line
476+
477+
# simplecov:disable branch, method legacy adapter, scheduled for removal
478+
class LegacyAdapter
479+
def call(value)
480+
value ? :yes : :no
481+
end
482+
end
483+
# simplecov:enable
484+
485+
raise "absurd" # simplecov:disable
473486
```
474487
475-
The name of the token can be changed to your liking. [Learn more about the nocov feature.]( https://github.com/simplecov-ruby/simplecov/blob/main/features/config_nocov_token.feature)
488+
Inline directives (trailing real code) only affect the line they sit on. Block directives sit on their own line and
489+
remain in effect until the matching `# simplecov:enable` for the same category — or end of file if never closed.
490+
Directive markers inside string literals or heredocs are ignored.
491+
492+
The older `# :nocov:` toggle still works but is **deprecated** and will be removed in a future release. Each file that
493+
uses it emits a one-time deprecation warning pointing at the recommended `# simplecov:disable` / `# simplecov:enable`
494+
replacement. The configurable token name (`SimpleCov.nocov_token`) is similarly deprecated.
476495
477-
**Note:** You shouldn't have to use the nocov token to skip private methods that are being included in your coverage. If
478-
you appropriately test the public interface of your classes and objects you should automatically get full coverage of
479-
your private methods.
496+
**Note:** You shouldn't have to skip private methods that are being included in your coverage. If you appropriately test
497+
the public interface of your classes and objects you should automatically get full coverage of your private methods.
480498

481499
## Default root filter and coverage for things outside of it
482500

features/config_nocov_token.feature

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
Feature:
33

44
Code wrapped in # :nocov: will be ignored by coverage reports.
5-
The name of the token can be configured with SimpleCov.nocov_token or SimpleCov.skip_token
5+
The name of the token can be configured with SimpleCov.nocov_token or SimpleCov.skip_token.
6+
7+
NOTE: `# :nocov:` and the configurable token are deprecated. Each file that
8+
uses the token emits a one-time deprecation warning to stderr at load time
9+
pointing at the recommended `# simplecov:disable` / `# simplecov:enable`
10+
replacement.
611

712
Background:
813
Given I'm working on the project "faked_project"

features/skipping_code_blocks_manually.feature

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ Feature:
44
When code is wrapped in :nocov: comment blocks, it does not count
55
against the coverage numbers.
66

7+
NOTE: `# :nocov:` is deprecated. Each file that uses it emits a one-time
8+
deprecation warning to stderr at load time. New code should prefer
9+
`# simplecov:disable` / `# simplecov:enable` (see
10+
features/skipping_with_directives.feature).
11+
712
Background:
813
Given I'm working on the project "faked_project"
914
Given SimpleCov for Test/Unit is configured with:
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
@test_unit @nocov
2+
Feature:
3+
4+
When code is wrapped in `# simplecov:disable` / `# simplecov:enable`
5+
comment blocks (or trailed by an inline `# simplecov:disable`), it does
6+
not count against the coverage numbers.
7+
8+
Background:
9+
Given I'm working on the project "faked_project"
10+
Given SimpleCov for Test/Unit is configured with:
11+
"""
12+
require 'simplecov'
13+
SimpleCov.start 'test_frameworks'
14+
"""
15+
16+
Scenario: Block disable of line coverage
17+
Given a file named "lib/faked_project/directive.rb" with:
18+
"""
19+
class SourceCodeWithDirective
20+
# simplecov:disable line
21+
def some_weird_code
22+
never_reached
23+
rescue => err
24+
but no one cares about invalid ruby here
25+
end
26+
# simplecov:enable line
27+
end
28+
"""
29+
30+
When I open the coverage report generated with `bundle exec rake test`
31+
32+
Then I should see the source files:
33+
| name | coverage |
34+
| lib/faked_project.rb | 100.00% |
35+
| lib/faked_project/some_class.rb | 80.00% |
36+
| lib/faked_project/framework_specific.rb | 75.00% |
37+
| lib/faked_project/meta_magic.rb | 100.00% |
38+
| lib/faked_project/directive.rb | 100.00% |
39+
40+
And there should be 7 skipped lines in the source files
41+
42+
Scenario: Inline disable of a single line
43+
Given a file named "lib/faked_project/directive.rb" with:
44+
"""
45+
class SourceCodeWithDirective
46+
def boom(value)
47+
value || raise("absurd") # simplecov:disable
48+
end
49+
end
50+
"""
51+
52+
When I open the coverage report generated with `bundle exec rake test`
53+
54+
Then I should see the source files:
55+
| name | coverage |
56+
| lib/faked_project.rb | 100.00% |
57+
| lib/faked_project/some_class.rb | 80.00% |
58+
| lib/faked_project/framework_specific.rb | 75.00% |
59+
| lib/faked_project/meta_magic.rb | 100.00% |
60+
| lib/faked_project/directive.rb | 100.00% |
61+
62+
And there should be 1 skipped lines in the source files
63+
64+
Scenario: Block disable with a free-form trailing reason
65+
Given a file named "lib/faked_project/directive.rb" with:
66+
"""
67+
class SourceCodeWithDirective
68+
# simplecov:disable line legacy adapter, scheduled for removal
69+
def some_weird_code
70+
never_reached
71+
rescue => err
72+
but no one cares about invalid ruby here
73+
end
74+
# simplecov:enable line
75+
end
76+
"""
77+
78+
When I open the coverage report generated with `bundle exec rake test`
79+
80+
Then I should see the source files:
81+
| name | coverage |
82+
| lib/faked_project.rb | 100.00% |
83+
| lib/faked_project/some_class.rb | 80.00% |
84+
| lib/faked_project/framework_specific.rb | 75.00% |
85+
| lib/faked_project/meta_magic.rb | 100.00% |
86+
| lib/faked_project/directive.rb | 100.00% |
87+
88+
And there should be 7 skipped lines in the source files
89+
90+
Scenario: Directive markers inside string literals are ignored
91+
Given a file named "lib/faked_project/directive.rb" with:
92+
"""
93+
class SourceCodeWithDirective
94+
BANNER = "# simplecov:disable"
95+
def message
96+
BANNER
97+
end
98+
end
99+
"""
100+
101+
When I open the coverage report generated with `bundle exec rake test`
102+
103+
Then there should be 0 skipped lines in the source files

lib/simplecov/configuration.rb

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,26 @@ def print_error_status
136136
#
137137
# Configure with SimpleCov.nocov_token('skip') or it's alias SimpleCov.skip_token('skip')
138138
#
139+
# DEPRECATED: prefer `# simplecov:disable` / `# simplecov:enable` block comments
140+
# (see SimpleCov::Directive). The `# :nocov:` toggle and this configuration hook
141+
# will be removed in a future release.
142+
#
139143
def nocov_token(nocov_token = nil)
140-
return @nocov_token if defined?(@nocov_token) && nocov_token.nil?
141-
142-
@nocov_token = nocov_token || "nocov"
144+
warn "#{Kernel.caller.first}: [DEPRECATION] `SimpleCov.nocov_token` and `SimpleCov.skip_token` are deprecated. " \
145+
"Replace with `# simplecov:disable` / `# simplecov:enable` block comments."
146+
current_nocov_token(nocov_token)
143147
end
144148
alias skip_token nocov_token
145149

150+
# Internal accessor used by SimpleCov to recognise `# :nocov:` markers
151+
# without emitting the public-API deprecation warning. Will be removed
152+
# alongside the deprecated `nocov_token` setter.
153+
def current_nocov_token(value = nil)
154+
return @nocov_token if defined?(@nocov_token) && value.nil?
155+
156+
@nocov_token = value || "nocov"
157+
end
158+
146159
#
147160
# Returns the configured groups. Add groups using SimpleCov.add_group
148161
#

lib/simplecov/directive.rb

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# frozen_string_literal: true
2+
3+
require "ripper"
4+
5+
module SimpleCov
6+
# Parses `# simplecov:disable` / `# simplecov:enable` directive comments.
7+
#
8+
# Two forms are supported:
9+
#
10+
# Block form (the directive is the entire comment on its own line) opens a
11+
# region that runs until the matching `# simplecov:enable`:
12+
#
13+
# # simplecov:disable line
14+
# ...
15+
# # simplecov:enable line
16+
#
17+
# Inline form (the directive trails real code on the same line) only affects
18+
# that single line and does not need to be re-enabled:
19+
#
20+
# raise "absurd" # simplecov:disable
21+
#
22+
# Categories are `:line`, `:branch`, and `:method`. They may be combined
23+
# with commas. Omitting categories targets all three.
24+
#
25+
# Any text after the directive (and the optional category list) is treated
26+
# as a free-form reason and discarded:
27+
#
28+
# # simplecov:disable line not worth testing this glue
29+
#
30+
# As a consequence, an unrecognised category name silently falls into the
31+
# reason bucket. `# simplecov:disable cyclomatic` is parsed as the bare
32+
# form (disable everything) with reason "cyclomatic" — a deliberate
33+
# over-disable so the typo is visible in the report rather than silently
34+
# disabling nothing.
35+
#
36+
# Comment extraction goes through `Ripper.lex` so directive markers inside
37+
# string literals or heredocs are correctly ignored.
38+
class Directive
39+
CATEGORIES = %i[line branch method].freeze
40+
41+
CATEGORY_PATTERN = "(?:#{CATEGORIES.join('|')})"
42+
CATEGORIES_PATTERN = "(?:#{CATEGORY_PATTERN}(?:\\s*,\\s*#{CATEGORY_PATTERN})*)"
43+
PATTERN = /
44+
\#\s*simplecov\s*:\s*
45+
(?<mode>disable|enable)\b
46+
(?:\s+(?<categories>#{CATEGORIES_PATTERN}))?
47+
.*?
48+
\s*\z
49+
/x.freeze
50+
51+
attr_reader :line_number, :mode, :categories
52+
53+
# Walk an array of source lines and return the disabled line ranges per
54+
# category as `{ line: [Range, ...], branch: [...], method: [...] }`.
55+
# An unclosed `disable` block extends to the end of the file.
56+
def self.disabled_ranges(src_lines)
57+
lines = src_lines.to_a
58+
ranges = CATEGORIES.to_h { |category| [category, []] }
59+
open_starts = {}
60+
61+
directives_in(lines).each { |directive| directive.apply(ranges, open_starts) }
62+
open_starts.each { |category, start| ranges[category] << (start..lines.size) }
63+
64+
ranges
65+
end
66+
67+
# Extract every directive in the file, in source order. Comments inside
68+
# string literals or heredocs are skipped because Ripper.lex doesn't tag
69+
# them as :on_comment tokens.
70+
def self.directives_in(lines)
71+
return [] unless source_might_contain_directive?(lines)
72+
73+
comments_in(lines).filter_map do |line_number, column, text|
74+
parse_comment(lines, line_number, column, text)
75+
end
76+
end
77+
78+
# Cheap pre-check so we don't tokenize files that obviously can't contain
79+
# a directive.
80+
def self.source_might_contain_directive?(lines)
81+
lines.any? do |line|
82+
line.include?("simplecov")
83+
rescue ArgumentError, EncodingError
84+
false
85+
end
86+
end
87+
88+
def self.parse_comment(lines, line_number, column, text)
89+
match = PATTERN.match(text)
90+
return nil unless match
91+
92+
new(
93+
line_number: line_number,
94+
mode: match[:mode].to_sym,
95+
categories: parse_categories(match[:categories]),
96+
inline: inline?(lines, line_number, column + match.begin(0))
97+
)
98+
rescue ArgumentError, EncodingError
99+
# E.g., comment text contains an invalid byte sequence in UTF-8.
100+
nil
101+
end
102+
103+
def self.parse_categories(text)
104+
return CATEGORIES.dup if text.nil?
105+
106+
text.split(/\s*,\s*/).map(&:to_sym)
107+
end
108+
109+
# Whether the directive sits after non-whitespace content on its line.
110+
# `column` is the byte column of the directive's `#` in the source line,
111+
# adjusted for any prefix that may precede it within the comment token
112+
# (e.g., `# prefix # simplecov:disable line`).
113+
def self.inline?(lines, line_number, column)
114+
line = lines[line_number - 1].to_s
115+
!line.byteslice(0, column).to_s.strip.empty?
116+
rescue ArgumentError, EncodingError
117+
false
118+
end
119+
120+
def self.comments_in(lines)
121+
source = lines.map { |line| line.end_with?("\n") ? line : "#{line}\n" }.join
122+
Ripper.lex(source).filter_map do |(line_number, column), type, text|
123+
[line_number, column, text] if type == :on_comment
124+
end
125+
rescue ArgumentError, EncodingError
126+
[]
127+
end
128+
129+
private_class_method :directives_in, :source_might_contain_directive?,
130+
:parse_comment, :parse_categories, :inline?, :comments_in
131+
132+
def initialize(line_number:, mode:, categories:, inline:)
133+
@line_number = line_number
134+
@mode = mode
135+
@categories = categories
136+
@inline = inline
137+
end
138+
139+
def disabled?
140+
mode == :disable
141+
end
142+
143+
def enabled?
144+
mode == :enable
145+
end
146+
147+
def inline?
148+
@inline
149+
end
150+
151+
# Apply this directive's effect to the in-flight per-category state.
152+
# Inline directives mark just their line; block disables open a region;
153+
# block enables close one. Re-opening an already-open block is a no-op.
154+
def apply(ranges, open_starts)
155+
categories.each do |category|
156+
if inline?
157+
ranges[category] << (line_number..line_number) if disabled?
158+
elsif disabled?
159+
open_starts[category] ||= line_number
160+
elsif (start = open_starts.delete(category))
161+
ranges[category] << (start..line_number)
162+
end
163+
end
164+
end
165+
end
166+
end

0 commit comments

Comments
 (0)