simplecov 1.2.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/simplecov/cli/annotations.rb +166 -0
  4. data/lib/simplecov/cli/patch/output.rb +16 -0
  5. data/lib/simplecov/cli/patch.rb +8 -2
  6. data/lib/simplecov/cli/uncovered/misses.rb +5 -9
  7. data/lib/simplecov/cli/uncovered.rb +6 -20
  8. data/lib/simplecov/cli/usage.rb +2 -1
  9. data/lib/simplecov/configuration/eval_coverage.rb +1 -1
  10. data/lib/simplecov/configuration/view_coverage.rb +2 -3
  11. data/lib/simplecov/coverage_violations.rb +4 -2
  12. data/lib/simplecov/directive/erb.rb +44 -0
  13. data/lib/simplecov/directive/haml.rb +33 -0
  14. data/lib/simplecov/directive/indented_template.rb +37 -0
  15. data/lib/simplecov/directive/slim.rb +35 -0
  16. data/lib/simplecov/directive/template.rb +29 -0
  17. data/lib/simplecov/directive.rb +1 -0
  18. data/lib/simplecov/formatter/html_formatter/public/index.html +12 -12
  19. data/lib/simplecov/production.rb +1 -1
  20. data/lib/simplecov/profiles/rails.rb +4 -4
  21. data/lib/simplecov/profiles/strict.rb +3 -3
  22. data/lib/simplecov/result.rb +6 -0
  23. data/lib/simplecov/result_processing.rb +20 -5
  24. data/lib/simplecov/simulate_coverage.rb +2 -2
  25. data/lib/simplecov/source_file/builder_context.rb +7 -0
  26. data/lib/simplecov/source_file/skip_chunks.rb +11 -2
  27. data/lib/simplecov/source_file/statistics.rb +16 -6
  28. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +9 -42
  29. data/lib/simplecov/static_coverage_extractor/method_collector.rb +1 -5
  30. data/lib/simplecov/static_coverage_extractor/visitor.rb +4 -7
  31. data/lib/simplecov/static_coverage_extractor.rb +6 -22
  32. data/lib/simplecov/version.rb +1 -1
  33. data/man/simplecov.1 +8 -3
  34. data/sig/simplecov.rbs +5 -0
  35. metadata +10 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 774f2590be4fc8efd720e8c61b8e0678b50794dbc8c881c5872c51ac7b318c08
4
- data.tar.gz: a67efdab2414345fa195aea9015d5cfc5128c452be20888edc1814c05fff98e0
3
+ metadata.gz: d476eb77902a3475da8b97f4af5d61fc3ff9fd0829221447b78b82581ce7ddff
4
+ data.tar.gz: 5ad815e1bdc6c010579d550fbea11aaa860c2c92f2f4f77cca3824a611f1b38e
5
5
  SHA512:
6
- metadata.gz: 32062ccc9c83227f868788d5f2a0fa43eda93e8f714ed4ffe068fea3ac05ee7dd21f26d4234903da7679a6c72325955282000055892deaabf4a0703f924f06ca
7
- data.tar.gz: c4a9a68bfbed7bc9c38de07afd89bedfefd3ea1ff1ee2b5294701c588c6cca7a10ae571313e0d836478045c07fcee81e4a1dc20c258d92e13a46f1e044c4d6d5
6
+ metadata.gz: 7c3de21fa0caf77d6bb4154094be10b9bca3c30155b27b8a4679791bea6221dd09c9cd88eeb0ca3dc0e4959646459cf10d21ec8db234a1ad0f06197bdaa1a0e3
7
+ data.tar.gz: 6264dd029949453bdaf3a94ad893b647f40424eccbae4d0e9485be26e28d6c5ff92bdcff0a4e0f7acbb95b9afa5710df4ef6bca1230c888cbe4f99122ce63cb0
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,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
@@ -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
@@ -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:
@@ -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
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class Directive
5
+ # The driver the line-oriented template languages share. Each line is
6
+ # converted on its own unless the line above it opened a block, which the
7
+ # language decides by answering a continuation alongside the converted
8
+ # text: `COMMENT` for a comment marker, whose deeper-indented lines
9
+ # continue the comment, `RUBY` for an embedded Ruby block, whose lines are
10
+ # Ruby as written, and `TEXT` for any other embedded block, whose lines are
11
+ # blanked. A blank line neither ends a block nor belongs to it.
12
+ module IndentedTemplate
13
+ COMMENT = ->(line) { line.start_with?("\n") ? line : "##{line[1..]}" }
14
+ RUBY = ->(line) { line }
15
+ TEXT = ->(line) { Template.blank(line) }
16
+
17
+ def ruby_lines(lines)
18
+ open = nil #: untyped
19
+ lines.map do |line|
20
+ line = "#{line}\n" unless line.end_with?("\n")
21
+ indent = line[/\A[ \t]*/].length
22
+ rest = line[indent..]
23
+ if open && (rest.eql?("\n") || indent > open.last)
24
+ open.first.call(line)
25
+ else
26
+ converted = convert(line[0, indent], rest)
27
+ continuation = converted.at(1)
28
+ open = continuation && [continuation, indent]
29
+ converted.fetch(0)
30
+ end
31
+ end
32
+ rescue ArgumentError, EncodingError
33
+ lines
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "indented_template"
4
+
5
+ module SimpleCov
6
+ class Directive
7
+ # The Ruby a Slim template holds. Control and output lines (`-`, `=`, `==`,
8
+ # and their whitespace variants) and the output a bare tag carries keep
9
+ # their Ruby with the markers blanked, a `/` comment becomes a Ruby
10
+ # comment, a `ruby:` block's lines are Ruby as written, and everything else
11
+ # is blanked. A tag with attributes is blanked whole, since `=` inside them
12
+ # is not the output marker.
13
+ module Slim
14
+ include IndentedTemplate
15
+ extend self
16
+
17
+ CODE = /\A(?:-|={1,2}[<>']*)(?=\s)/
18
+ TAG_CODE = /\A[\w.#-]+\s*={1,2}[<>']*(?=\s)/
19
+
20
+ def convert(indent, rest)
21
+ if rest.start_with?("/")
22
+ ["#{indent}##{rest[1..]}", COMMENT]
23
+ elsif rest.match?(/\Aruby:[ \t]*\n\z/)
24
+ [Template.blank(indent + rest), RUBY]
25
+ elsif rest.match?(/\A\w+:[ \t]*\n\z/)
26
+ [Template.blank(indent + rest), TEXT]
27
+ elsif (marker = rest[CODE] || rest[TAG_CODE])
28
+ [indent + Template.blank(marker) + rest[marker.length..]]
29
+ else
30
+ [Template.blank(indent + rest)]
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "erb"
4
+ require_relative "haml"
5
+ require_relative "slim"
6
+
7
+ module SimpleCov
8
+ class Directive
9
+ # Hands a template's lines to the extractor for its language, so the
10
+ # directive scan sees only the Ruby a template holds, at the template's own
11
+ # line numbers. A file in no template language is Ruby already.
12
+ module Template
13
+ EXTRACTORS = {".erb" => Erb, ".haml" => Haml, ".slim" => Slim}.freeze
14
+
15
+ def self.template?(filename)
16
+ EXTRACTORS.key?(File.extname(filename))
17
+ end
18
+
19
+ def self.ruby_lines(filename, lines)
20
+ extractor = EXTRACTORS[File.extname(filename)]
21
+ extractor ? extractor.ruby_lines(lines) : lines
22
+ end
23
+
24
+ def self.blank(text)
25
+ text.gsub(/[^\n]/, " ")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ripper"
4
+ require_relative "directive/template"
4
5
 
5
6
  module SimpleCov
6
7
  # Parses `# simplecov:disable` / `# simplecov:enable` directive comments, in