yard-rspec-coverage 0.1.3 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4579697cc4b66cd8e8f2542925a108797ed295a4ef8c1584a669f9d6a5e0a79
4
- data.tar.gz: 04766fba38fc646ad5682f4c7e5d2a797059074e5b83bda4812743d2fa1f186f
3
+ metadata.gz: 92e79bd5a908db3cedd4c50b47932d17429ee4ea97be3bf4fc9051925f07214e
4
+ data.tar.gz: 456788b68888517279dc5aa9362d4512cc147499fba916e0ba2a3dc45d2ee723
5
5
  SHA512:
6
- metadata.gz: 786fe926ae8c6260b343c25b918fd2f9b568fac0a9fe2e508a8fedb2c9dbae578a4a7303ddc34ed2bcd1942837c58f16d83704b1203a0b64b95b16c88eb4b623
7
- data.tar.gz: c99f4df51422c71ddec7c5931662c6ad375c7a9f53b5dd9359956a69bb4096a0ff499f4d083373c48591676992fdf43aae5950cf5119b0c925afcf24c2fb3fd5
6
+ metadata.gz: c70ce69ec8af7fcd6490acbd4f135be002a87241b6ef66da05caebd5ce8d4c7aa44ee52c8a75949dc35abc405b1419a44e3a46f6cc3f5e688f86a84b2aa6e09d
7
+ data.tar.gz: 78ad8e219a770c7c8aeecf1b7ef8771e4222bc893764f0e8c67bc2c669af4936095bcb52886ddf1345df83ebb16686de75a56afeb527bc2ac5573563b26cef3e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ Changed (these can change the coverage result of a project):
6
+
7
+ - `yard:rspec_coverage` parses only `YardRspec::Config.files`. The options in `.yardopts`, such
8
+ as `--exclude`, still apply, but the files listed there are no longer added.
9
+ - Classes, modules and methods that spec files define outside describe blocks, such as helpers
10
+ in `spec/support`, are left out of the documentation and of `yard stats`.
11
+ - Files ending in `_helper.rb` outside a spec directory, such as Rails view helpers, count for
12
+ RSpec coverage again. Spec files are the files in a `spec/` directory and files ending in
13
+ `_spec.rb`.
14
+ - An example without a block (`it 'spins twice'`) is pending, as in RSpec: it is labelled
15
+ "pending" and does not count as coverage.
16
+ - A description that is not a string literal, such as `it format('runs %s', 1)`, is shown as
17
+ `example`. A description with interpolation is shown as written.
18
+
19
+ Added:
20
+
21
+ - Descriptions split over several lines (`it 'spins ' \ 'twice'`) are joined, as in RSpec.
22
+ - `describe '.new'` and `describe '#new'` document `#initialize`, unless the class defines a
23
+ class method `new` itself.
24
+
25
+ Fixed:
26
+
27
+ - An exact coverage percentage failed an equal minimum, for example 57 of 100 methods against a
28
+ minimum of 57.
29
+ - A describe of a class defined as a constant (`Widget = Class.new`), a spec file that reopens a
30
+ class of another gem, and describes or contexts without a block or without arguments no
31
+ longer stop the processing of a describe.
32
+ - The plugin no longer keeps the syntax tree of every spec file in memory until the end of the
33
+ parse. In a project with about 600 spec files, peak memory dropped from 247 MB to 72 MB.
34
+ - Looking up the described method is faster, especially in large projects.
35
+
3
36
  ## 0.1.3
4
37
 
5
38
  First public release as `yard-rspec-coverage`.
data/README.md CHANGED
@@ -38,6 +38,10 @@ yard doc --plugin rspec-coverage 'lib/**/*.rb' 'spec/**/*.rb'
38
38
  List the spec files explicitly: without file arguments (or files in `.yardopts`) YARD only parses
39
39
  `lib/` and `app/`, so it finds no examples.
40
40
 
41
+ The spec files are parsed for their examples only. Classes, modules and methods they define
42
+ outside describe blocks, such as helpers in `spec/support`, are left out of the documentation and
43
+ of `yard stats`. Spec files are the files in a `spec/` directory and files ending in `_spec.rb`.
44
+
41
45
  ### Rake tasks
42
46
 
43
47
  Declare the gem with `require: false` in your Gemfile, so you choose which YARD runs use the
@@ -56,9 +60,10 @@ This defines two tasks:
56
60
  - `rake yard:doc` generates the documentation with the `rspec-coverage` plugin. It writes to `doc/`, like
57
61
  other YARD tasks such as `yard:coverage`, so run it last.
58
62
  - `rake yard:rspec_coverage` lists the public methods without RSpec examples and fails when the
59
- coverage is below `minimum_rspec_coverage`. Attribute readers/writers and aliases are not
60
- counted. It also lists describe blocks that name a class or method that cannot be found, such
61
- as a typo, without failing.
63
+ coverage is below `minimum_rspec_coverage`. Attribute readers/writers, aliases and code in
64
+ spec files are not counted. It also lists describe blocks that name a class or method that cannot be found, such
65
+ as a typo, without failing. It parses only `YardRspec::Config.files`: the options in
66
+ `.yardopts`, such as `--exclude`, apply, but the files listed there do not.
62
67
 
63
68
  The plugin never logs YARD warnings, so it is safe in YARD runs with `--fail-on-warning`.
64
69
 
@@ -113,11 +118,14 @@ The plugin supports the following RSpec syntax:
113
118
 
114
119
  - `describe ClassName, '#method_name'` for instance methods
115
120
  - `describe ClassName, '.method_name'` for class methods
116
- - `it "description"` blocks within describe blocks
121
+ - `describe ClassName, '.new'` (or `'#new'`) for the constructor, documented as `#initialize`,
122
+ unless the class defines a class method `new` itself
123
+ - `it "description"` blocks within describe blocks, also with a description split over several
124
+ lines as adjacent strings (`'spins ' \ 'twice'`)
117
125
  - `specify "description"` and `example "description"` blocks (aliases for `it`)
118
- - `xit`, `xspecify` and `xexample` blocks (excluded examples), shown with a "(pending)" suffix.
119
- They do not count as coverage: a method with only pending examples is reported as without
120
- specs.
126
+ - `xit`, `xspecify` and `xexample` blocks (excluded examples) and examples without a block
127
+ (`it 'spins twice'`, which RSpec reports as "Not yet implemented"), labelled "pending". They do
128
+ not count as coverage: a method with only pending examples is reported as without specs.
121
129
  - nested `context "..."` and `describe "..."` blocks, shown as groups of examples
122
130
 
123
131
  ### Not supported
@@ -142,20 +150,21 @@ documentation and do not count towards RSpec coverage.
142
150
 
143
151
  Limitations of the supported constructs:
144
152
 
145
- - Only the text before the first interpolation of a description is kept: `it "loop #{index}"`
146
- is shown as `loop `. Examples generated in a loop are listed once.
147
- - Examples whose description is not a string literal are shown as `example`.
153
+ - A description is shown as written in the spec. So interpolation stays visible
154
+ (`it "loop #{index}"` is shown as `loop #{index}`) and so do escape sequences (`it "a\tb"` is
155
+ shown as `a\tb`, where RSpec shows a tab). Examples generated in a loop are listed once.
156
+ - Examples whose description is not a string literal, or is empty, are shown as `example`. That
157
+ includes a heredoc (`it <<~DESC do`).
148
158
 
149
159
  ## Development
150
160
 
151
- To test the plugin with the example code:
161
+ To build the plugin's own documentation, with its specs embedded:
152
162
 
153
163
  ```bash
154
- bundle install
155
164
  ./build.sh
156
165
  ```
157
166
 
158
- Then open `doc/index.html` in your browser to see the generated documentation.
167
+ Then open `doc/index.html` in your browser. `build.sh` also runs all the checks below.
159
168
 
160
169
  Run all checks (RuboCop, a dependency vulnerability audit, the specs with SimpleCov, YARD
161
170
  documentation coverage and RSpec coverage) with:
@@ -166,11 +175,20 @@ bundle exec rake
166
175
 
167
176
  ### Releasing
168
177
 
169
- Update the version in `yard-rspec-coverage.gemspec` and `CHANGELOG.md`, then push a matching tag:
178
+ Update the version in `yard-rspec-coverage.gemspec` and `CHANGELOG.md` (a `## <version>` heading
179
+ instead of `## Unreleased`), commit, and run the checks for a release:
180
+
181
+ ```bash
182
+ bundle exec rake prerelease
183
+ ```
184
+
185
+ It checks the commit, runs `rake` and `yard:doc`, builds and tests the gem, and runs mutation
186
+ testing. Set `COMPARE_PROJECT` (and `COMPARE_RSPEC`) to also compare the plugin with
187
+ `rspec --dry-run` in a real project, see `AGENTS.md`. Then push a matching tag:
170
188
 
171
189
  ```bash
172
- git tag v0.1.3
173
- git push origin v0.1.3
190
+ git tag v0.2.0
191
+ git push origin v0.2.0
174
192
  ```
175
193
 
176
194
  The release workflow runs all checks and publishes the gem to rubygems.org with
@@ -3,49 +3,7 @@
3
3
  # The tasks load the plugin when they run; loading it here would affect every YARD run in Rake.
4
4
  require 'yard'
5
5
  require_relative '../yard_rspec/config'
6
-
7
- # @param [<YARD::CodeObjects::MethodObject>] uncovered
8
- def uncovered_methods_report(uncovered)
9
- return if uncovered.empty?
10
-
11
- puts "\nMethods without RSpec specs:"
12
- uncovered.sort_by(&:path).each do |method|
13
- puts " #{method.file}:#{method.line}: #{method.path}"
14
- end
15
- end
16
-
17
- # @param [<YARD::CodeObjects::MethodObject>] public_lib_methods
18
- # @return [Float] The coverage percentage.
19
- def rspec_coverage_report(public_lib_methods)
20
- total = public_lib_methods.size
21
- uncovered = public_lib_methods.reject {|method| YardRspec::Coverage.covered?(method)}
22
- covered = total - uncovered.size
23
- coverage = total.positive? ? (covered.to_f / total * 100).round(2) : 100.0
24
-
25
- puts format('RSpec coverage: %<covered>d/%<total>d (%<coverage>.2f%%)',
26
- covered:, total:, coverage:)
27
- uncovered_methods_report(uncovered)
28
-
29
- coverage
30
- end
31
-
32
- # Applies .yardopts like `yard doc` does, so --exclude works. --no-output skips the markup check.
33
- # @param [<String>] files Glob patterns; they replace the files in .yardopts.
34
- # @return [YARD::CLI::Yardoc]
35
- def yardoc_with_yardopts(files)
36
- yardoc = YARD::CLI::Yardoc.new
37
- yardoc.parse_arguments('--no-output', *files)
38
- yardoc
39
- end
40
-
41
- # These do not fail the coverage check.
42
- # @param [<String>] describe_problems
43
- def describe_problems_report(describe_problems)
44
- return if describe_problems.empty?
45
-
46
- puts "\nDescribe blocks without a matching class or method:"
47
- describe_problems.each {|describe_problem| puts " #{describe_problem}"}
48
- end
6
+ require_relative '../yard_rspec/task_reports'
49
7
 
50
8
  namespace :yard do
51
9
  desc 'Generate YARD documentation with the yard-rspec-coverage plugin'
@@ -59,16 +17,16 @@ namespace :yard do
59
17
  task :rspec_coverage do
60
18
  require_relative '../yard-rspec-coverage'
61
19
  YARD::Registry.clear
62
- yardoc = yardoc_with_yardopts(YardRspec::Config.files)
20
+ yardoc = YardRspec::TaskReports.yardoc_with_yardopts(YardRspec::Config.files)
63
21
  YARD.parse(yardoc.files, yardoc.excluded, YARD::Logger::ERROR)
64
22
 
65
- coverage = rspec_coverage_report(YardRspec::Coverage.countable_methods)
66
- describe_problems_report(YardRspec::DescribeProblems.all)
23
+ coverage = YardRspec::TaskReports.rspec_coverage(YardRspec::Coverage.countable_methods)
24
+ YardRspec::TaskReports.describe_problems(YardRspec::DescribeProblems.all)
67
25
  minimum = YardRspec::Config.minimum_rspec_coverage
68
26
 
69
27
  if coverage < minimum
70
28
  warn format("\nRSpec coverage too low: %<coverage>.2f%% (minimum is %<minimum>.2f%%)",
71
- coverage:, minimum:)
29
+ coverage: coverage.floor(2), minimum:)
72
30
  exit 1
73
31
  end
74
32
  end
@@ -1,12 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yard'
4
+ require_relative 'yard_rspec/ast_helpers'
4
5
  require_relative 'yard_rspec/config'
5
6
  require_relative 'yard_rspec/coverage'
6
7
  require_relative 'yard_rspec/describe_problems'
7
8
  require_relative 'yard_rspec/example_extractor'
8
9
  require_relative 'yard_rspec/method_description'
10
+ require_relative 'yard_rspec/nested_method_describes'
11
+ require_relative 'yard_rspec/parsed_describe'
9
12
  require_relative 'yard_rspec/handler'
13
+ require_relative 'yard_rspec/spec_objects'
10
14
  require_relative 'yard_rspec/stats'
15
+ require_relative 'yard_rspec/unquote'
11
16
 
12
17
  YARD::Templates::Engine.register_template_path File.expand_path('../templates', __dir__)
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # Checks on YARD's Ruby AST that the handler, the example extractor and the nested describe
5
+ # scanner share, so that they agree on which calls are describes.
6
+ module AstHelpers
7
+ private
8
+
9
+ # @return [Boolean]
10
+ def ast_node?(node)
11
+ node.is_a?(YARD::Parser::Ruby::AstNode)
12
+ end
13
+
14
+ # @param [YARD::Parser::Ruby::MethodCallNode] call_node A command such as `describe Widget`.
15
+ # @return [Boolean]
16
+ def describe_or_context_call?(call_node)
17
+ %i[describe context].include?(call_node.method_name(true))
18
+ end
19
+
20
+ # @param [YARD::Parser::Ruby::MethodCallNode] call_node A command such as `it 'spins' do end`.
21
+ # @return [YARD::Parser::Ruby::AstNode, nil] The statements in the block, nil without a block.
22
+ def block_body_of(call_node)
23
+ call_node.block&.last
24
+ end
25
+ end
26
+ end
@@ -3,27 +3,33 @@
3
3
  module YardRspec
4
4
  # Which methods count for RSpec coverage.
5
5
  module Coverage
6
- # @return [Regexp] Spec and helper files, whose methods need no specs.
7
- SPEC_FILE_PATTERN = %r{(?:^|/)spec/|_spec\.rb$|_helper\.rb$}
6
+ # @return [Regexp] Files in a spec directory or ending in _spec.rb. Not any _helper.rb, which
7
+ # would include Rails view helpers.
8
+ SPEC_FILE_PATTERN = %r{(?:^|/)spec/|_spec\.rb$}
8
9
 
9
- # @return [<YARD::CodeObjects::MethodObject>]
10
+ # @param [String] file
11
+ # @return [Boolean] Whether the file is spec code, which needs no specs and no documentation.
12
+ def self.spec_file?(file)
13
+ file.match?(SPEC_FILE_PATTERN)
14
+ end
15
+
16
+ # @return [<YARD::CodeObjects::MethodObject>] The methods that need specs, see {countable?}.
10
17
  def self.countable_methods
11
18
  YARD::Registry.all(:method).select {|method| countable?(method)}
12
19
  end
13
20
 
14
- # Attributes and aliases have no behaviour of their own to spec.
15
21
  # @param [YARD::CodeObjects::MethodObject] method
16
- # @return [Boolean]
22
+ # @return [Boolean] True for a public method outside spec files. Attributes and aliases are
23
+ # left out: they have no behaviour of their own.
17
24
  def self.countable?(method)
18
25
  method.visibility == :public &&
19
- !method.file.to_s.match?(SPEC_FILE_PATTERN) &&
26
+ !spec_file?(method.file.to_s) &&
20
27
  !method.is_attribute? &&
21
28
  !method.is_alias?
22
29
  end
23
30
 
24
- # Pending examples (`xit`) do not run, so they do not count.
25
31
  # @param [YARD::CodeObjects::MethodObject] method
26
- # @return [Boolean]
32
+ # @return [Boolean] Whether it has an example that is not pending: `xit` does not run.
27
33
  def self.covered?(method)
28
34
  examples(method[:rspec_examples] || []).any? {|example| !example[:pending]}
29
35
  end
@@ -20,5 +20,13 @@ module YardRspec
20
20
  def self.all
21
21
  (YARD::Registry.root[REGISTRY_KEY] || []).sort
22
22
  end
23
+
24
+ # Records an error as a problem instead of letting YARD log it, so the parse goes on.
25
+ # @param [String] location 'file:line' of the describe.
26
+ def self.recording_failure(location)
27
+ yield
28
+ rescue StandardError => e
29
+ add("#{location}: could not process the describe: #{e.class}: #{e.message}")
30
+ end
23
31
  end
24
32
  end
@@ -4,11 +4,13 @@ module YardRspec
4
4
  # Extracts the RSpec examples of a describe block as a tree of example and context nodes.
5
5
  # Excluded examples (xit/xspecify/xexample) get `pending: true`.
6
6
  class ExampleExtractor
7
- # @return [<String>] The keywords of examples that RSpec runs.
8
- EXAMPLE_KEYWORDS = %w[it specify example].freeze
7
+ include AstHelpers
9
8
 
10
- # @return [<String>] The keywords of examples that RSpec excludes from the run.
11
- PENDING_KEYWORDS = %w[xit xspecify xexample].freeze
9
+ # @return [<Symbol>] The keywords of examples that RSpec excludes from the run.
10
+ PENDING_KEYWORDS = %i[xit xspecify xexample].freeze
11
+
12
+ # @return [<Symbol>] The keywords of all examples, including the pending ones.
13
+ KEYWORDS = (%i[it specify example] + PENDING_KEYWORDS).freeze
12
14
 
13
15
  # @param [YARD::Parser::Ruby::AstNode, nil] block_node The body of a describe block.
14
16
  def initialize(block_node)
@@ -27,8 +29,6 @@ module YardRspec
27
29
  private
28
30
 
29
31
  def traverse(node, nodes)
30
- return if !ast_node?(node)
31
-
32
32
  if example_call?(node)
33
33
  nodes << build_example(node)
34
34
  elsif context_call?(node)
@@ -38,106 +38,77 @@ module YardRspec
38
38
  end
39
39
  end
40
40
 
41
- # A context without examples is left out.
41
+ # A context without a block or without examples is left out.
42
42
  def add_context(node, nodes)
43
+ block_body = block_body_of(node) or return
43
44
  children = []
44
- traverse(node.last&.last, children)
45
+ traverse(block_body, children)
45
46
  return if children.empty?
46
47
 
47
- nodes << { description: extract_context_description(node), children: children }
48
+ nodes << { description: string_literal_text(node.parameters.first), children: children }
48
49
  end
49
50
 
50
- # Includes the `x` variants.
51
- # @return [Boolean]
51
+ # @return [Boolean] Includes the `x` variants.
52
52
  def example_call?(node)
53
- return false if node.type != :command
54
-
55
- name_node = node.children[0]
56
- name_node&.type == :ident && (EXAMPLE_KEYWORDS + PENDING_KEYWORDS).include?(name_node.source)
53
+ node.type == :command && KEYWORDS.include?(node.method_name(true))
57
54
  end
58
55
 
59
56
  # @return [Boolean]
60
57
  def context_call?(node)
61
- return false if node.type != :command
62
-
63
- name_node = node.children[0]
64
- name_node&.type == :ident && %w[context describe].include?(name_node.source)
58
+ node.type == :command && describe_or_context_call?(node)
65
59
  end
66
60
 
67
- # @return [String, nil]
68
- def extract_context_description(node)
69
- find_string_literal(node.parameters.first)
70
- end
71
-
72
- # @return [Hash] With `pending: true` only for the `x` variants.
61
+ # @return [Hash] With `pending: true` only for the `x` variants and examples without a block,
62
+ # which RSpec reports as pending.
73
63
  def build_example(node)
74
64
  example = {
75
- description: extract_description(node.children[1]) || 'example',
76
- code: extract_code(node.children[2])
65
+ description: string_literal_text(node.parameters.first) || 'example',
66
+ code: extract_code(block_body_of(node))
77
67
  }
78
- example[:pending] = true if PENDING_KEYWORDS.include?(node.children[0].source)
68
+ example[:pending] = true if pending_example?(node)
79
69
  example
80
70
  end
81
71
 
82
- # @return [String, nil]
83
- def extract_description(args_node)
84
- return nil if !ast_node?(args_node)
85
-
86
- find_string_literal(args_node)
72
+ # @return [Boolean]
73
+ def pending_example?(node)
74
+ PENDING_KEYWORDS.include?(node.method_name(true)) || block_body_of(node).nil?
87
75
  end
88
76
 
89
- # @return [String, nil] The first one at any depth.
90
- def find_string_literal(node)
91
- return nil if !ast_node?(node)
92
- return extract_string_content(node) if node.type == :string_literal
93
-
94
- node.children.each do |child|
95
- result = find_string_literal(child)
96
- return result if result
97
- end
98
- nil
77
+ # @param [YARD::Parser::Ruby::AstNode] argument The first argument of a call.
78
+ # @return [String, nil] The text as written, so with interpolations such as `#{count}`; nil if
79
+ # empty or not a string literal, such as `format('runs %s', count)`.
80
+ def string_literal_text(argument)
81
+ text = string_text(argument)
82
+ text if text && !text.empty?
99
83
  end
100
84
 
101
- # @return [String, nil]
102
- def extract_string_content(string_literal_node)
103
- string_literal_node.children.each do |child|
104
- next if !ast_node?(child) || child.type != :string_content
105
-
106
- child.children.each do |grandchild|
107
- return grandchild.source if grandchild.is_a?(YARD::Parser::Ruby::AstNode) &&
108
- grandchild.type == :tstring_content
109
- end
85
+ # @return [String, nil] Nil when the node is not a string literal. Adjacent literals
86
+ # (`'runs ' \ 'twice'`) are joined, as RSpec sees them.
87
+ def string_text(node)
88
+ case node.type
89
+ when :string_literal
90
+ node.children.find {|child| child.type == :string_content}.children.map(&:source).join
91
+ when :string_concat
92
+ node.children.map {|child| string_text(child)}.join
110
93
  end
111
- nil
112
94
  end
113
95
 
114
- # @return [String]
115
- def extract_code(block_node)
116
- return '' if !ast_node?(block_node)
117
- return '' if !%i[do_block brace_block].include?(block_node.type)
118
-
119
- body = block_node.children.first
120
- return '' if !ast_node?(body)
96
+ # @param [YARD::Parser::Ruby::AstNode, nil] block_body
97
+ # @return [String] Empty without a block.
98
+ def extract_code(block_body)
99
+ return '' if block_body.nil?
121
100
 
122
101
  # The source starts at the first token; restore its indentation before dedenting.
123
- dedent((' ' * column_of(body)) + body.source.strip)
102
+ dedent((' ' * column_of(block_body)) + block_body.source)
124
103
  end
125
104
 
126
- # @return [Integer] 0 if unknown.
105
+ # @return [Integer]
127
106
  def column_of(node)
128
- file_source = node.full_source
129
- start = node.source_range&.first
130
- return 0 if file_source.nil? || start.nil? || start.zero?
131
-
132
- newline_before = file_source.rindex("\n", start - 1)
107
+ start = node.source_range.first
108
+ newline_before = node.full_source.rindex("\n", start - 1)
133
109
  newline_before.nil? ? start : start - newline_before - 1
134
110
  end
135
111
 
136
- # @return [Boolean]
137
- def ast_node?(node)
138
- node.is_a?(YARD::Parser::Ruby::AstNode)
139
- end
140
-
141
112
  # @return [String]
142
113
  def dedent(source)
143
114
  lines = source.lines.map(&:rstrip)
@@ -2,39 +2,54 @@
2
2
 
3
3
  module YardRspec
4
4
  # Attaches the examples of RSpec describe blocks to the described methods, as :rspec_examples.
5
- # When parsing a file list, describes are processed after all files: YARD postpones files with
6
- # unknown namespaces, so a spec may be parsed before its class.
5
+ # When parsing a file list, the examples are attached after all files: YARD postpones files with
6
+ # unknown namespaces, so a spec may be parsed before its class. The syntax tree is read right
7
+ # away, so that a waiting describe (a ParsedDescribe) does not keep it alive.
7
8
  class DescribeHandler < YARD::Handlers::Ruby::Base
9
+ include AstHelpers
10
+
8
11
  handles method_call(:describe)
9
12
  handles method_call(:context)
10
13
  namespace_only
11
14
 
12
- # Deferred while parsing a file list, see the callbacks at the end of this file.
15
+ # @return [Regexp] A class name such as 'Widget' or 'Shop::Widget', not a topic such as
16
+ # 'Spinning semantics'.
17
+ CLASS_NAME = /\A[A-Z][A-Za-z0-9_:]*\z/
18
+
19
+ # Deferred while parsing a file list, see the callbacks at the end of this file. There a
20
+ # failure is recorded as a describe problem, so it does not abort the parse.
13
21
  def process
14
- @describe_namespace = namespace
15
- if globals.yard_rspec_deferred_describes
16
- globals.yard_rspec_deferred_describes << -> {process_describe}
17
- else
18
- process_describe
22
+ deferred = globals.yard_rspec_deferred_describes
23
+ return parse_describe&.attach if deferred.nil?
24
+
25
+ DescribeProblems.recording_failure(location_of) do
26
+ parsed = parse_describe
27
+ deferred << parsed if parsed
19
28
  end
20
29
  end
21
30
 
22
31
  private
23
32
 
24
- def process_describe
25
- obj_name = unquote(statement.parameters.first.source)
26
-
27
- # Method-only describes (describe '#eql?') are handled by their outer describe.
28
- target_namespace = resolve_namespace(obj_name)
29
- return if target_namespace.nil?
33
+ # Method-only describes (describe '#eql?') are handled by their outer describe.
34
+ # @return [ParsedDescribe, nil] Nil for a describe without a class name.
35
+ def parse_describe
36
+ class_name = described_name
37
+ return nil if class_name.nil? || !class_name.match?(CLASS_NAME)
30
38
 
31
39
  description = extract_method_description
32
- if description
33
- document_method(target_namespace, obj_name, description, statement.last&.last,
34
- location_of)
35
- else
36
- process_nested_describes(target_namespace, obj_name)
37
- end
40
+ entries = if description
41
+ [entry(description, block_body_of(statement), statement.line)]
42
+ else
43
+ nested_entries
44
+ end
45
+ ParsedDescribe.new(namespace: namespace, class_name: class_name, location: location_of,
46
+ entries: entries)
47
+ end
48
+
49
+ # @return [String, nil] The first describe parameter; nil for `describe do`.
50
+ def described_name
51
+ first_parameter = statement.parameters.first
52
+ Unquote.call(first_parameter.source) if ast_node?(first_parameter)
38
53
  end
39
54
 
40
55
  # @return [MethodDescription, nil] From the second describe parameter.
@@ -42,108 +57,30 @@ module YardRspec
42
57
  second_param = statement.parameters[1]
43
58
  return nil if !ast_node?(second_param)
44
59
 
45
- MethodDescription.parse(unquote(second_param.source))
46
- end
47
-
48
- # Records a describe problem for an unknown class, unless it is loaded at runtime: a class
49
- # from another gem (e.g. YARD::CLI::Stats) is not a typo.
50
- # @return [YARD::CodeObjects::Base, nil]
51
- def resolve_namespace(obj_name)
52
- # Not a constant, e.g. describe 'Partial Order Reduction semantics'.
53
- return nil if !obj_name.match?(/\A[A-Z][A-Za-z0-9_:]*\z/)
54
-
55
- return @describe_namespace if obj_name == @describe_namespace.name.to_s
56
-
57
- resolved = YARD::Registry.resolve(@describe_namespace, obj_name, true)
58
- if resolved.nil? && !loaded_constant?(obj_name)
59
- DescribeProblems.add("#{location_of}: could not resolve class '#{obj_name}'")
60
- end
61
- resolved
60
+ MethodDescription.parse(Unquote.call(second_param.source))
62
61
  end
63
62
 
64
- # @return [Boolean]
65
- def loaded_constant?(obj_name)
66
- Object.const_defined?(obj_name)
67
- rescue NameError
68
- false
69
- end
63
+ # @return [<ParsedDescribe::Entry>] The nested '#method' describes.
64
+ def nested_entries
65
+ block_body = block_body_of(statement)
66
+ return [] if block_body.nil?
70
67
 
71
- # A missing method is a describe problem, unless the description is imprecise.
72
- def document_method(target_namespace, obj_name, description, block_body, location)
73
- method_obj = description.find_in(target_namespace)
74
- if method_obj
75
- attach_examples_to(method_obj, block_body)
76
- elsif !description.imprecise?
77
- add_missing_method_problem(description, obj_name, location)
68
+ NestedMethodDescribes.new(block_body).map do |description, nested_block_body, line|
69
+ entry(description, nested_block_body, line)
78
70
  end
79
71
  end
80
72
 
81
- def add_missing_method_problem(description, obj_name, location)
82
- DescribeProblems.add("#{location}: could not find method '#{description}' " \
83
- "on '#{obj_name}'")
84
- end
85
-
86
- def attach_examples_to(method_obj, block_body)
87
- examples = ExampleExtractor.new(block_body).extract
88
- method_obj[:rspec_examples] ||= []
89
- method_obj[:rspec_examples].concat(examples)
90
- end
91
-
92
- def process_nested_describes(target_namespace, obj_name)
93
- block_body = statement.last&.last
94
- return if block_body.nil?
95
-
96
- each_method_describe(block_body) do |description, nested_block_body, node_line|
97
- document_method(target_namespace, obj_name, description, nested_block_body,
98
- location_of(node_line))
99
- end
100
- end
101
-
102
- # Yields the description, block body and line of each nested method describe.
103
- def each_method_describe(node, &)
104
- return if !ast_node?(node)
105
-
106
- if method_describe_node?(node)
107
- description = MethodDescription.parse(unquote(node.parameters.first.source))
108
- yield(description, node.last&.last, node.line)
109
- else
110
- node.children.each {|child| each_method_describe(child, &)}
111
- end
112
- end
113
-
114
- # @return [Boolean]
115
- def method_describe_node?(node)
116
- return false if node.type != :command
117
-
118
- name_node = node.children[0]
119
- return false if !describe_or_context_call?(name_node)
120
-
121
- first_param = node.parameters.first
122
- return false if !ast_node?(first_param)
123
-
124
- unquote(first_param.source).start_with?('#', '.')
125
- rescue StandardError
126
- false
127
- end
128
-
129
- # @return [Boolean]
130
- def describe_or_context_call?(name_node)
131
- name_node&.type == :ident && %w[describe context].include?(name_node.source)
132
- end
133
-
134
- # @return [Boolean]
135
- def ast_node?(node)
136
- node.is_a?(YARD::Parser::Ruby::AstNode)
137
- end
138
-
139
- # @return [String]
140
- def unquote(source)
141
- source.gsub(/['"]/, '')
73
+ # @param [MethodDescription, nil] description
74
+ # @param [YARD::Parser::Ruby::AstNode, nil] block_body
75
+ # @return [ParsedDescribe::Entry]
76
+ def entry(description, block_body, line)
77
+ ParsedDescribe::Entry.new(description, ExampleExtractor.new(block_body).extract,
78
+ location_of(line))
142
79
  end
143
80
 
144
81
  # @return [String] 'file:line', by default the line of the statement.
145
- def location_of(line = statement.line || '?')
146
- "#{statement.file || '(unknown)'}:#{line}"
82
+ def location_of(line = statement.line)
83
+ "#{statement.file}:#{line}"
147
84
  end
148
85
  end
149
86
  end
@@ -153,6 +90,10 @@ YARD::Parser::SourceParser.before_parse_list do |_files, global_state|
153
90
  nil # Returning false would cancel the parse.
154
91
  end
155
92
 
93
+ # Spec objects are removed only after the examples have been attached.
156
94
  YARD::Parser::SourceParser.after_parse_list do |_files, global_state|
157
- global_state.yard_rspec_deferred_describes.each(&:call)
95
+ global_state.yard_rspec_deferred_describes.each do |parsed_describe|
96
+ YardRspec::DescribeProblems.recording_failure(parsed_describe.location) {parsed_describe.attach}
97
+ end
98
+ YardRspec::SpecObjects.remove
158
99
  end
@@ -4,7 +4,7 @@ module YardRspec
4
4
  # The method named by a describe string such as '#run', '.build' or '#initialize via Factory'.
5
5
  class MethodDescription
6
6
  # @return [Regexp] '#' (instance) or '.' (class), followed by the method name.
7
- PATTERN = /^([#.])(\S+)/
7
+ PATTERN = /\A([#.])(\S+)/
8
8
 
9
9
  # @return [String]
10
10
  attr_reader :name
@@ -31,9 +31,8 @@ module YardRspec
31
31
  @imprecise = imprecise
32
32
  end
33
33
 
34
- # Whether text follows the method name, as in '#initialize via SomeFactory'. A missing method
35
- # is then not reported.
36
- # @return [Boolean]
34
+ # @return [Boolean] Whether text follows the method name, as in '#initialize via SomeFactory'.
35
+ # A missing method is then not reported.
37
36
  def imprecise?
38
37
  @imprecise
39
38
  end
@@ -43,20 +42,23 @@ module YardRspec
43
42
  "#{@scope == :instance ? '#' : '.'}#{name}"
44
43
  end
45
44
 
46
- # Falls back to the other scope, because specs sometimes write '#' for class methods.
47
45
  # @param [YARD::CodeObjects::NamespaceObject] namespace
48
- # @return [YARD::CodeObjects::MethodObject, nil] Also searched in superclasses and mixins.
46
+ # @return [YARD::CodeObjects::MethodObject, nil] Searched in superclasses and mixins too. Falls
47
+ # back to the other scope, since specs sometimes write '#' for class methods, and from 'new'
48
+ # to #initialize, which YARD documents for the constructor.
49
49
  def find_in(namespace)
50
- find_with_scope(namespace, @scope) || find_with_scope(namespace, other_scope)
50
+ find_with_scope(namespace, @scope, name) || find_with_scope(namespace, other_scope, name) ||
51
+ (find_with_scope(namespace, :instance, 'initialize') if name == 'new')
51
52
  end
52
53
 
53
54
  private
54
55
 
55
- # Includes mixins, so examples end up on the module's method. Own methods come first.
56
- def find_with_scope(namespace, scope)
57
- namespace
58
- .meths(scope: scope, visibility: %i[public protected private], included: true)
59
- .find {|method| method.name.to_s == name}
56
+ # Includes superclasses and mixins, so examples end up on the module's method. Own methods
57
+ # come first, found directly: the full list takes time quadratic in the number of methods.
58
+ def find_with_scope(namespace, scope, method_name)
59
+ namespace.child(name: method_name.to_sym, scope: scope, type: :method) ||
60
+ namespace.meths(scope: scope, visibility: %i[public protected private], included: true)
61
+ .find {|method| method.name.to_s == method_name}
60
62
  end
61
63
 
62
64
  def other_scope
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # The `describe '#method'` and `describe '.method'` blocks nested in a describe block.
5
+ class NestedMethodDescribes
6
+ include Enumerable
7
+ include AstHelpers
8
+
9
+ # @param [YARD::Parser::Ruby::AstNode] block_body The body of the outer describe block.
10
+ def initialize(block_body)
11
+ @block_body = block_body
12
+ end
13
+
14
+ # @yieldparam [MethodDescription, nil] description Nil if the string names no method, as in
15
+ # `describe '#'`.
16
+ # @yieldparam [YARD::Parser::Ruby::AstNode, nil] block_body The body of the nested block, nil
17
+ # without a block.
18
+ # @yieldparam [Integer] line
19
+ def each(&)
20
+ return enum_for(:each) if !block_given?
21
+
22
+ walk(@block_body, &)
23
+ end
24
+
25
+ private
26
+
27
+ # Does not look inside a method describe.
28
+ def walk(node, &)
29
+ if method_describe_node?(node)
30
+ description = MethodDescription.parse(source_of(node.parameters.first))
31
+ yield(description, block_body_of(node), node.line)
32
+ else
33
+ node.children.each {|child| walk(child, &)}
34
+ end
35
+ end
36
+
37
+ # @return [Boolean]
38
+ def method_describe_node?(node)
39
+ node.type == :command && describe_or_context_call?(node) &&
40
+ source_of(node.parameters.first).start_with?('#', '.')
41
+ end
42
+
43
+ # @return [String] Without quotes.
44
+ def source_of(node)
45
+ Unquote.call(node.source)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # What DescribeHandler takes from a describe block's syntax tree: the class name and, per
5
+ # described method, its examples. It keeps no syntax tree, so a describe that waits for the end
6
+ # of a file list parse does not keep its spec file's tree alive (about 0.25 MB per spec file).
7
+ class ParsedDescribe
8
+ # One described method, with its examples.
9
+ # @attr [MethodDescription, nil] description Nil if the describe string names no method, as in
10
+ # `describe '#'`.
11
+ # @attr [<Hash>] examples See ExampleExtractor#extract.
12
+ # @attr [String] location 'file:line' of the describe of the method.
13
+ Entry = Struct.new(:description, :examples, :location)
14
+
15
+ # @return [String] 'file:line' of the describe.
16
+ attr_reader :location
17
+
18
+ # @param [YARD::CodeObjects::NamespaceObject] namespace Where the describe is; the class name
19
+ # is resolved from there.
20
+ # @param [String] class_name As written, such as 'Widget' or 'Shop::Widget'.
21
+ # @param [String] location 'file:line' of the describe.
22
+ # @param [<Entry>] entries
23
+ def initialize(namespace:, class_name:, location:, entries:)
24
+ @namespace = namespace
25
+ @class_name = class_name
26
+ @location = location
27
+ @entries = entries
28
+ end
29
+
30
+ # Attaches the examples to the described methods, or records describe problems. Call it once
31
+ # the described classes are in the registry.
32
+ def attach
33
+ target = resolve_namespace or return
34
+ @entries.each {|entry| attach_entry(target, entry)}
35
+ end
36
+
37
+ private
38
+
39
+ # @return [YARD::CodeObjects::NamespaceObject, nil] Nil for an unknown class, recorded as a
40
+ # problem unless it is loaded at runtime (a class of another gem is no typo), and for a
41
+ # constant such as `Widget = Class.new`, whose methods YARD does not know.
42
+ def resolve_namespace
43
+ resolved = YARD::Registry.resolve(@namespace, @class_name, true)
44
+ if resolved.nil? && !loaded_constant?
45
+ DescribeProblems.add("#{@location}: could not resolve class '#{@class_name}'")
46
+ end
47
+ resolved if resolved.is_a?(YARD::CodeObjects::NamespaceObject)
48
+ end
49
+
50
+ # @return [Boolean]
51
+ def loaded_constant?
52
+ Object.const_defined?(@class_name)
53
+ rescue NameError
54
+ false
55
+ end
56
+
57
+ # A missing method is a describe problem, unless the description is imprecise.
58
+ def attach_entry(target, entry)
59
+ description = entry.description
60
+ if description.nil?
61
+ DescribeProblems.add("#{entry.location}: could not parse the method name of the describe")
62
+ elsif (method_obj = description.find_in(target))
63
+ (method_obj[:rspec_examples] ||= []).concat(entry.examples)
64
+ elsif !description.imprecise?
65
+ DescribeProblems.add("#{entry.location}: could not find method '#{description}' "\
66
+ "on '#{@class_name}'")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # The classes, modules and methods that spec files define outside describe blocks, such as
5
+ # helpers in spec/support. YARD parses the spec files for their examples, but their own code is
6
+ # no part of the documentation, nor of `yard stats`.
7
+ module SpecObjects
8
+ # Removes objects defined only in spec files, and the spec files from the files of the others,
9
+ # such as a class that a spec reopens.
10
+ def self.remove
11
+ YARD::Registry.all.each do |object|
12
+ own_files = object.files.reject {|file, _line| Coverage.spec_file?(file)}
13
+ next if own_files == object.files
14
+
15
+ own_files.empty? ? delete(object) : object.files.replace(own_files)
16
+ end
17
+ end
18
+
19
+ # A namespace from another gem (`class Rack::Static::Middleware`) is a proxy without children.
20
+ # @param [YARD::CodeObjects::Base] object
21
+ def self.delete(object)
22
+ namespace = object.namespace
23
+ namespace.children.delete(object) if !namespace.is_a?(YARD::CodeObjects::Proxy)
24
+ YARD::Registry.delete(object)
25
+ end
26
+ private_class_method :delete
27
+ end
28
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # The output and options of the `yard:rspec_coverage` task. Not loaded into the global namespace
5
+ # by the rake file, since that would add the methods to every Rakefile that uses the plugin.
6
+ module TaskReports
7
+ # @param [<YARD::CodeObjects::MethodObject>] uncovered
8
+ def self.uncovered_methods(uncovered)
9
+ return if uncovered.empty?
10
+
11
+ puts "\nMethods without RSpec specs:"
12
+ uncovered.sort_by(&:path).each do |method|
13
+ puts " #{method.file}:#{method.line}: #{method.path}"
14
+ end
15
+ end
16
+ private_class_method :uncovered_methods
17
+
18
+ # @param [<YARD::CodeObjects::MethodObject>] public_lib_methods
19
+ # @return [Float] The unrounded coverage percentage: rounding could pass a minimum of 100. It
20
+ # is printed rounded down, so below 100 never shows as 100.00%.
21
+ def self.rspec_coverage(public_lib_methods)
22
+ total = public_lib_methods.size
23
+ uncovered = public_lib_methods.reject {|method| YardRspec::Coverage.covered?(method)}
24
+ covered = total - uncovered.size
25
+ coverage = total.positive? ? covered * 100.0 / total : 100.0
26
+
27
+ puts format('RSpec coverage: %<covered>d/%<total>d (%<coverage>.2f%%)',
28
+ covered:, total:, coverage: coverage.floor(2))
29
+ uncovered_methods(uncovered)
30
+
31
+ coverage
32
+ end
33
+
34
+ # @param [<String>] files Glob patterns; they replace the files in .yardopts, which YARD would
35
+ # otherwise add them to.
36
+ # @return [YARD::CLI::Yardoc] With the options of .yardopts (so --exclude works) and no output.
37
+ def self.yardoc_with_yardopts(files)
38
+ yardoc = YARD::CLI::Yardoc.new
39
+ yardoc.parse_arguments('--no-output')
40
+ yardoc.files = files
41
+ yardoc
42
+ end
43
+
44
+ # @param [<String>] describe_problems Reported only; they do not fail the check.
45
+ def self.describe_problems(describe_problems)
46
+ return if describe_problems.empty?
47
+
48
+ puts "\nDescribe blocks without a matching class or method:"
49
+ describe_problems.each {|describe_problem| puts " #{describe_problem}"}
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # Removes the quotes around the source of a string literal.
5
+ module Unquote
6
+ # @param [String] source For example `"'#run'"`.
7
+ # @return [String] Without the surrounding quotes only: `"it's"` stays `it's`.
8
+ def self.call(source)
9
+ source.sub(/\A(['"])(.*)\1\z/m, '\\2')
10
+ end
11
+ end
12
+ end
@@ -21,15 +21,15 @@ end
21
21
  # @return [String]
22
22
  def rspec_example_html(example)
23
23
  pending = example[:pending] ? ' <span class="rspec_label rspec_pending">pending</span>' : ''
24
- "<li><details><summary>#{h(example[:description])}#{pending}</summary>" \
25
- "<pre class=\"example code\">#{html_syntax_highlight(example[:code], :ruby)}</pre>" \
24
+ "<li><details><summary>#{h(example[:description])}#{pending}</summary>"\
25
+ "<pre class=\"example code\">#{html_syntax_highlight(example[:code], :ruby)}</pre>"\
26
26
  '</details></li>'
27
27
  end
28
28
 
29
29
  # @param [{description: String, children: [<Hash>]}] group
30
30
  # @return [String]
31
31
  def rspec_group_html(group)
32
- '<li class="rspec_context">' \
33
- "<span class=\"rspec_context_name\">#{h(group[:description])}</span>" \
32
+ '<li class="rspec_context">'\
33
+ "<span class=\"rspec_context_name\">#{h(group[:description])}</span>"\
34
34
  "<ul class=\"rspec\">#{rspec_nodes_html(group[:children])}</ul></li>"
35
35
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yard-rspec-coverage
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdbijl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-31 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: yard
@@ -36,14 +36,20 @@ files:
36
36
  - README.md
37
37
  - lib/tasks/yard_rspec.rake
38
38
  - lib/yard-rspec-coverage.rb
39
+ - lib/yard_rspec/ast_helpers.rb
39
40
  - lib/yard_rspec/config.rb
40
41
  - lib/yard_rspec/coverage.rb
41
42
  - lib/yard_rspec/describe_problems.rb
42
43
  - lib/yard_rspec/example_extractor.rb
43
44
  - lib/yard_rspec/handler.rb
44
45
  - lib/yard_rspec/method_description.rb
46
+ - lib/yard_rspec/nested_method_describes.rb
47
+ - lib/yard_rspec/parsed_describe.rb
45
48
  - lib/yard_rspec/rake_tasks.rb
49
+ - lib/yard_rspec/spec_objects.rb
46
50
  - lib/yard_rspec/stats.rb
51
+ - lib/yard_rspec/task_reports.rb
52
+ - lib/yard_rspec/unquote.rb
47
53
  - templates/default/fulldoc/html/css/rspec.css
48
54
  - templates/default/fulldoc/html/js/rspec.js
49
55
  - templates/default/layout/html/setup.rb
@@ -72,7 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
78
  - !ruby/object:Gem::Version
73
79
  version: '0'
74
80
  requirements: []
75
- rubygems_version: 3.4.22
81
+ rubygems_version: 3.5.22
76
82
  signing_key:
77
83
  specification_version: 4
78
84
  summary: YARD plugin to display RSpec examples in documentation and report RSpec coverage