yard-rspec-coverage 0.1.3

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f4579697cc4b66cd8e8f2542925a108797ed295a4ef8c1584a669f9d6a5e0a79
4
+ data.tar.gz: 04766fba38fc646ad5682f4c7e5d2a797059074e5b83bda4812743d2fa1f186f
5
+ SHA512:
6
+ metadata.gz: 786fe926ae8c6260b343c25b918fd2f9b568fac0a9fe2e508a8fedb2c9dbae578a4a7303ddc34ed2bcd1942837c58f16d83704b1203a0b64b95b16c88eb4b623
7
+ data.tar.gz: c99f4df51422c71ddec7c5931662c6ad375c7a9f53b5dd9359956a69bb4096a0ff499f4d083373c48591676992fdf43aae5950cf5119b0c925afcf24c2fb3fd5
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.3
4
+
5
+ First public release as `yard-rspec-coverage`.
6
+
7
+ - The RSpec examples in the documentation have a new look:
8
+ - Example code is syntax-highlighted, like the method source.
9
+ - The heading shows the number of examples, with an "expand all" link when there is more
10
+ than one.
11
+ - Pending examples (`xit`, `xspecify`, `xexample`) get a "pending" label.
12
+ - Contexts are grouped with a line along their examples.
13
+ - A public method without specs gets a red "no specs" label.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vdbijl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # yard-rspec-coverage
2
+
3
+ A YARD plugin that embeds RSpec examples alongside method documentation in the generated YARD
4
+ documentation.
5
+
6
+ ## Overview
7
+
8
+ This plugin allows you to see RSpec test examples directly in your YARD-generated documentation.
9
+ When you document a method and have corresponding RSpec tests, those tests will automatically appear
10
+ in a "RSpec Examples" section in the method's documentation.
11
+
12
+ ## Installation
13
+
14
+ Add this line to your application's Gemfile:
15
+
16
+ ```ruby
17
+ gem 'yard-rspec-coverage'
18
+ ```
19
+
20
+ Or install it yourself:
21
+
22
+ ```bash
23
+ gem install yard-rspec-coverage
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### Basic Usage
29
+
30
+ 1. Write your Ruby code with YARD documentation comments
31
+ 2. Write RSpec tests using `describe` and `it` blocks
32
+ 3. Generate documentation with the plugin loaded:
33
+
34
+ ```bash
35
+ yard doc --plugin rspec-coverage 'lib/**/*.rb' 'spec/**/*.rb'
36
+ ```
37
+
38
+ List the spec files explicitly: without file arguments (or files in `.yardopts`) YARD only parses
39
+ `lib/` and `app/`, so it finds no examples.
40
+
41
+ ### Rake tasks
42
+
43
+ Declare the gem with `require: false` in your Gemfile, so you choose which YARD runs use the
44
+ plugin; `Bundler.require` would load it into every YARD run in the process. Then require the tasks
45
+ from your project's `Rakefile`:
46
+
47
+ ```ruby
48
+ require 'yard_rspec/rake_tasks'
49
+
50
+ YardRspec::Config.minimum_rspec_coverage = 80.0 # default: 100.0
51
+ YardRspec::Config.files = ['{lib,spec}/**/*.rb'] # default
52
+ ```
53
+
54
+ This defines two tasks:
55
+
56
+ - `rake yard:doc` generates the documentation with the `rspec-coverage` plugin. It writes to `doc/`, like
57
+ other YARD tasks such as `yard:coverage`, so run it last.
58
+ - `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.
62
+
63
+ The plugin never logs YARD warnings, so it is safe in YARD runs with `--fail-on-warning`.
64
+
65
+ Or use the provided build script:
66
+
67
+ ```bash
68
+ ./build.sh
69
+ ```
70
+
71
+ ### Example
72
+
73
+ Given this code:
74
+
75
+ ```ruby
76
+ class String
77
+ # Converts a string to pig latin
78
+ #
79
+ # @return [String] the pig latin version of the string
80
+ def pig_latin
81
+ self[1..-1] + self[0] + "ay"
82
+ end
83
+ end
84
+
85
+ describe String, '#pig_latin' do
86
+ it "converts 'hello' to pig latin" do
87
+ "hello".pig_latin.should == "ellohay"
88
+ end
89
+
90
+ it "works with single character strings" do
91
+ "a".pig_latin.should == "aay"
92
+ end
93
+ end
94
+ ```
95
+
96
+ The generated documentation for `String#pig_latin` will include an "RSpec Examples" section showing both test cases.
97
+
98
+ ## How It Works
99
+
100
+ The plugin consists of four main components:
101
+
102
+ 1. **Handler** (`lib/yard_rspec/handler.rb`): Parses RSpec `describe` blocks and extracts `it` examples
103
+ 2. **Template** (`templates/default/method_details/html/rspec_specs.erb`): Displays the examples in the documentation
104
+ 3. **Setup** (`templates/default/method_details/html/setup.rb`): Integrates the template into YARD's rendering pipeline
105
+ 4. **Assets** (`templates/default/fulldoc/html/`): A stylesheet and a script for the "expand all" link
106
+
107
+ Each example shows its description and its syntax-highlighted code. Pending
108
+ examples are labelled "pending", and a public method without specs gets a red "no specs" label.
109
+
110
+ ## RSpec Format Support
111
+
112
+ The plugin supports the following RSpec syntax:
113
+
114
+ - `describe ClassName, '#method_name'` for instance methods
115
+ - `describe ClassName, '.method_name'` for class methods
116
+ - `it "description"` blocks within describe blocks
117
+ - `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.
121
+ - nested `context "..."` and `describe "..."` blocks, shown as groups of examples
122
+
123
+ ### Not supported
124
+
125
+ These RSpec constructs are not recognized. Examples written with them do not appear in the
126
+ documentation and do not count towards RSpec coverage.
127
+
128
+ - Examples that are not written as a command without parentheses followed by a block: `it('x') {}`
129
+ the one-liner `it {is_expected.to be_ok}` and `it do ... end` without a description.
130
+ - Focused examples: `fit`, `fspecify` and `fexample`. A focused example makes RSpec skip the rest
131
+ of the suite, so it should not be committed; the plugin does not document it.
132
+ - The `pending` and `skip` keywords.
133
+ - Capybara's `scenario` (and `feature` as a group), and the `its` method of rspec-its.
134
+ - Shared examples: `it_behaves_like`, `it_should_behave_like`, `include_examples` and
135
+ `include_context` are ignored, so the examples of a `shared_examples` group are not attributed to
136
+ the methods that use it. The examples inside a `shared_examples` block are attributed to the
137
+ enclosing method, as if they were written directly in the describe block.
138
+ - Focused and excluded groups: `fdescribe`, `xdescribe`, `fcontext`, `xcontext` and `example_group`
139
+ are not recognized as groups. Their examples are still collected, but without the group, so the
140
+ group's description is lost. A `describe '#method'` written with one of these keywords is not
141
+ linked to the method.
142
+
143
+ Limitations of the supported constructs:
144
+
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`.
148
+
149
+ ## Development
150
+
151
+ To test the plugin with the example code:
152
+
153
+ ```bash
154
+ bundle install
155
+ ./build.sh
156
+ ```
157
+
158
+ Then open `doc/index.html` in your browser to see the generated documentation.
159
+
160
+ Run all checks (RuboCop, a dependency vulnerability audit, the specs with SimpleCov, YARD
161
+ documentation coverage and RSpec coverage) with:
162
+
163
+ ```bash
164
+ bundle exec rake
165
+ ```
166
+
167
+ ### Releasing
168
+
169
+ Update the version in `yard-rspec-coverage.gemspec` and `CHANGELOG.md`, then push a matching tag:
170
+
171
+ ```bash
172
+ git tag v0.1.3
173
+ git push origin v0.1.3
174
+ ```
175
+
176
+ The release workflow runs all checks and publishes the gem to rubygems.org with
177
+ [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/), so no API key is stored.
178
+
179
+ ## License
180
+
181
+ MIT License
182
+
183
+ ## Credits
184
+
185
+ Inspired by the [yard-spec-plugin](https://github.com/lsegal/yard-spec-plugin) by Loren Segal.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The tasks load the plugin when they run; loading it here would affect every YARD run in Rake.
4
+ require 'yard'
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
49
+
50
+ namespace :yard do
51
+ desc 'Generate YARD documentation with the yard-rspec-coverage plugin'
52
+ task :doc do
53
+ # YARD ignores --plugin in-process.
54
+ require_relative '../yard-rspec-coverage'
55
+ YARD::CLI::Yardoc.run(*YardRspec::Config.files)
56
+ end
57
+
58
+ desc 'Verify RSpec coverage of public methods (without attributes and aliases)'
59
+ task :rspec_coverage do
60
+ require_relative '../yard-rspec-coverage'
61
+ YARD::Registry.clear
62
+ yardoc = yardoc_with_yardopts(YardRspec::Config.files)
63
+ YARD.parse(yardoc.files, yardoc.excluded, YARD::Logger::ERROR)
64
+
65
+ coverage = rspec_coverage_report(YardRspec::Coverage.countable_methods)
66
+ describe_problems_report(YardRspec::DescribeProblems.all)
67
+ minimum = YardRspec::Config.minimum_rspec_coverage
68
+
69
+ if coverage < minimum
70
+ warn format("\nRSpec coverage too low: %<coverage>.2f%% (minimum is %<minimum>.2f%%)",
71
+ coverage:, minimum:)
72
+ exit 1
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yard'
4
+ require_relative 'yard_rspec/config'
5
+ require_relative 'yard_rspec/coverage'
6
+ require_relative 'yard_rspec/describe_problems'
7
+ require_relative 'yard_rspec/example_extractor'
8
+ require_relative 'yard_rspec/method_description'
9
+ require_relative 'yard_rspec/handler'
10
+ require_relative 'yard_rspec/stats'
11
+
12
+ YARD::Templates::Engine.register_template_path File.expand_path('../templates', __dir__)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The yard-rspec-coverage plugin.
4
+ module YardRspec
5
+ # Set in a project's Rakefile, e.g. `YardRspec::Config.minimum_rspec_coverage = 80.0`.
6
+ module Config
7
+ class << self
8
+ attr_writer :minimum_rspec_coverage, :files
9
+
10
+ # @return [Float] A percentage.
11
+ def minimum_rspec_coverage
12
+ @minimum_rspec_coverage || DEFAULT_MINIMUM_RSPEC_COVERAGE
13
+ end
14
+
15
+ # @return [<String>] Glob patterns of the source and spec files.
16
+ def files
17
+ @files || DEFAULT_FILES
18
+ end
19
+ end
20
+
21
+ # @return [Float]
22
+ DEFAULT_MINIMUM_RSPEC_COVERAGE = 100.0
23
+
24
+ # @return [<String>]
25
+ DEFAULT_FILES = ['{lib,spec}/**/*.rb'].freeze
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # Which methods count for RSpec coverage.
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$}
8
+
9
+ # @return [<YARD::CodeObjects::MethodObject>]
10
+ def self.countable_methods
11
+ YARD::Registry.all(:method).select {|method| countable?(method)}
12
+ end
13
+
14
+ # Attributes and aliases have no behaviour of their own to spec.
15
+ # @param [YARD::CodeObjects::MethodObject] method
16
+ # @return [Boolean]
17
+ def self.countable?(method)
18
+ method.visibility == :public &&
19
+ !method.file.to_s.match?(SPEC_FILE_PATTERN) &&
20
+ !method.is_attribute? &&
21
+ !method.is_alias?
22
+ end
23
+
24
+ # Pending examples (`xit`) do not run, so they do not count.
25
+ # @param [YARD::CodeObjects::MethodObject] method
26
+ # @return [Boolean]
27
+ def self.covered?(method)
28
+ examples(method[:rspec_examples] || []).any? {|example| !example[:pending]}
29
+ end
30
+
31
+ # @param [<Hash>] nodes Example and context nodes, see `method[:rspec_examples]`.
32
+ # @return [<Hash>] The examples at any depth.
33
+ def self.examples(nodes)
34
+ nodes.flat_map {|node| node[:children] ? examples(node[:children]) : [node]}
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # Describe blocks naming a class or method that cannot be found. Not logged as YARD warnings,
5
+ # which would fail `yard --fail-on-warning`; `yard:rspec_coverage` lists them instead.
6
+ # Kept on the registry root, so YARD::Registry.clear also clears them.
7
+ module DescribeProblems
8
+ # @return [Symbol]
9
+ REGISTRY_KEY = :rspec_describe_problems
10
+
11
+ # @param [String] problem The location and description of the problem, for example
12
+ # "spec/widget_spec.rb:4: could not find method '#wobble' on 'Widget'".
13
+ def self.add(problem)
14
+ root = YARD::Registry.root
15
+ root[REGISTRY_KEY] ||= []
16
+ root[REGISTRY_KEY] << problem
17
+ end
18
+
19
+ # @return [<String>] Sorted by location.
20
+ def self.all
21
+ (YARD::Registry.root[REGISTRY_KEY] || []).sort
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # Extracts the RSpec examples of a describe block as a tree of example and context nodes.
5
+ # Excluded examples (xit/xspecify/xexample) get `pending: true`.
6
+ class ExampleExtractor
7
+ # @return [<String>] The keywords of examples that RSpec runs.
8
+ EXAMPLE_KEYWORDS = %w[it specify example].freeze
9
+
10
+ # @return [<String>] The keywords of examples that RSpec excludes from the run.
11
+ PENDING_KEYWORDS = %w[xit xspecify xexample].freeze
12
+
13
+ # @param [YARD::Parser::Ruby::AstNode, nil] block_node The body of a describe block.
14
+ def initialize(block_node)
15
+ @block_node = block_node
16
+ end
17
+
18
+ # @return [<Hash>] The example and context nodes.
19
+ def extract
20
+ return [] if @block_node.nil?
21
+
22
+ examples = []
23
+ traverse(@block_node, examples)
24
+ examples
25
+ end
26
+
27
+ private
28
+
29
+ def traverse(node, nodes)
30
+ return if !ast_node?(node)
31
+
32
+ if example_call?(node)
33
+ nodes << build_example(node)
34
+ elsif context_call?(node)
35
+ add_context(node, nodes)
36
+ else
37
+ node.children.each {|child| traverse(child, nodes)}
38
+ end
39
+ end
40
+
41
+ # A context without examples is left out.
42
+ def add_context(node, nodes)
43
+ children = []
44
+ traverse(node.last&.last, children)
45
+ return if children.empty?
46
+
47
+ nodes << { description: extract_context_description(node), children: children }
48
+ end
49
+
50
+ # Includes the `x` variants.
51
+ # @return [Boolean]
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)
57
+ end
58
+
59
+ # @return [Boolean]
60
+ 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)
65
+ end
66
+
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.
73
+ def build_example(node)
74
+ example = {
75
+ description: extract_description(node.children[1]) || 'example',
76
+ code: extract_code(node.children[2])
77
+ }
78
+ example[:pending] = true if PENDING_KEYWORDS.include?(node.children[0].source)
79
+ example
80
+ end
81
+
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)
87
+ end
88
+
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
99
+ end
100
+
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
110
+ end
111
+ nil
112
+ end
113
+
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)
121
+
122
+ # The source starts at the first token; restore its indentation before dedenting.
123
+ dedent((' ' * column_of(body)) + body.source.strip)
124
+ end
125
+
126
+ # @return [Integer] 0 if unknown.
127
+ 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)
133
+ newline_before.nil? ? start : start - newline_before - 1
134
+ end
135
+
136
+ # @return [Boolean]
137
+ def ast_node?(node)
138
+ node.is_a?(YARD::Parser::Ruby::AstNode)
139
+ end
140
+
141
+ # @return [String]
142
+ def dedent(source)
143
+ lines = source.lines.map(&:rstrip)
144
+ min_indent = lines.reject(&:empty?).map {|line| line[/^\s*/].length}.min || 0
145
+ lines.map {|line| line[min_indent..] || line}.join("\n").strip
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
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.
7
+ class DescribeHandler < YARD::Handlers::Ruby::Base
8
+ handles method_call(:describe)
9
+ handles method_call(:context)
10
+ namespace_only
11
+
12
+ # Deferred while parsing a file list, see the callbacks at the end of this file.
13
+ 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
19
+ end
20
+ end
21
+
22
+ private
23
+
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?
30
+
31
+ 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
38
+ end
39
+
40
+ # @return [MethodDescription, nil] From the second describe parameter.
41
+ def extract_method_description
42
+ second_param = statement.parameters[1]
43
+ return nil if !ast_node?(second_param)
44
+
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
62
+ end
63
+
64
+ # @return [Boolean]
65
+ def loaded_constant?(obj_name)
66
+ Object.const_defined?(obj_name)
67
+ rescue NameError
68
+ false
69
+ end
70
+
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)
78
+ end
79
+ end
80
+
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(/['"]/, '')
142
+ end
143
+
144
+ # @return [String] 'file:line', by default the line of the statement.
145
+ def location_of(line = statement.line || '?')
146
+ "#{statement.file || '(unknown)'}:#{line}"
147
+ end
148
+ end
149
+ end
150
+
151
+ YARD::Parser::SourceParser.before_parse_list do |_files, global_state|
152
+ global_state.yard_rspec_deferred_describes = []
153
+ nil # Returning false would cancel the parse.
154
+ end
155
+
156
+ YARD::Parser::SourceParser.after_parse_list do |_files, global_state|
157
+ global_state.yard_rspec_deferred_describes.each(&:call)
158
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardRspec
4
+ # The method named by a describe string such as '#run', '.build' or '#initialize via Factory'.
5
+ class MethodDescription
6
+ # @return [Regexp] '#' (instance) or '.' (class), followed by the method name.
7
+ PATTERN = /^([#.])(\S+)/
8
+
9
+ # @return [String]
10
+ attr_reader :name
11
+
12
+ # @return [:instance, :class]
13
+ attr_reader :scope
14
+
15
+ # @param [String] text The describe string, without quotes.
16
+ # @return [MethodDescription, nil] Nil if the text names no method.
17
+ def self.parse(text)
18
+ match = text.match(PATTERN)
19
+ return nil if match.nil?
20
+
21
+ new(name: match[2], scope: match[1] == '#' ? :instance : :class,
22
+ imprecise: text.length > match[0].length)
23
+ end
24
+
25
+ # @param [String] name
26
+ # @param [:instance, :class] scope
27
+ # @param [Boolean] imprecise
28
+ def initialize(name:, scope:, imprecise:)
29
+ @name = name
30
+ @scope = scope
31
+ @imprecise = imprecise
32
+ end
33
+
34
+ # Whether text follows the method name, as in '#initialize via SomeFactory'. A missing method
35
+ # is then not reported.
36
+ # @return [Boolean]
37
+ def imprecise?
38
+ @imprecise
39
+ end
40
+
41
+ # @return [String] '#name' or '.name'.
42
+ def to_s
43
+ "#{@scope == :instance ? '#' : '.'}#{name}"
44
+ end
45
+
46
+ # Falls back to the other scope, because specs sometimes write '#' for class methods.
47
+ # @param [YARD::CodeObjects::NamespaceObject] namespace
48
+ # @return [YARD::CodeObjects::MethodObject, nil] Also searched in superclasses and mixins.
49
+ def find_in(namespace)
50
+ find_with_scope(namespace, @scope) || find_with_scope(namespace, other_scope)
51
+ end
52
+
53
+ private
54
+
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}
60
+ end
61
+
62
+ def other_scope
63
+ @scope == :instance ? :class : :instance
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Defines the yard:doc and yard:rspec_coverage Rake tasks. Usage in a Rakefile:
4
+ #
5
+ # require 'yard_rspec/rake_tasks'
6
+ # YardRspec::Config.minimum_rspec_coverage = 80.0
7
+ require 'rake'
8
+
9
+ load File.expand_path('../tasks/yard_rspec.rake', __dir__)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ YARD::CLI::Stats.class_eval do
4
+ # Prints the RSpec specs row. Leaves @total/@undocumented alone, so "% documented" is unaffected.
5
+ def stats_for_rspec
6
+ public_lib_methods = YardRspec::Coverage.countable_methods
7
+
8
+ total = public_lib_methods.size
9
+ covered = public_lib_methods.count {|method| YardRspec::Coverage.covered?(method)}
10
+
11
+ log.puts "RSpec specs: #{rspec_specs_data(covered, total - covered)}"
12
+ end
13
+
14
+ private
15
+
16
+ # @return [String]
17
+ def rspec_specs_data(covered, uncovered)
18
+ if uncovered.positive?
19
+ format('%<covered>5d (%<uncovered>5d without specs)', covered:, uncovered:)
20
+ else
21
+ format('%<covered>5d', covered:)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ .rspec_examples ul.rspec {
2
+ list-style: none;
3
+ padding-left: 0;
4
+ }
5
+ .rspec_context > ul.rspec {
6
+ margin: 3px 0 6px 4px;
7
+ padding-left: 12px;
8
+ border-left: 2px solid #e1e1e8;
9
+ }
10
+ .rspec_examples summary {
11
+ cursor: pointer;
12
+ }
13
+ .rspec_context_name {
14
+ font-style: italic;
15
+ color: #555;
16
+ }
17
+ .rspec_toggle {
18
+ margin-left: 6px;
19
+ font-size: 11px;
20
+ font-weight: normal;
21
+ }
22
+ .rspec_label {
23
+ padding: 1px 4px;
24
+ font-size: 11px;
25
+ font-weight: normal;
26
+ }
27
+ .rspec_pending {
28
+ background: #d5d5d5;
29
+ }
30
+ .rspec_missing {
31
+ color: #fff;
32
+ background: #cc0000;
33
+ }
@@ -0,0 +1,11 @@
1
+ // Opens or closes all examples of a method at once.
2
+ document.addEventListener('click', function(event) {
3
+ const toggle = event.target.closest('.rspec_toggle');
4
+ if (!toggle) return;
5
+
6
+ event.preventDefault();
7
+ const all_details = toggle.closest('.rspec_examples').querySelectorAll('details');
8
+ const open = !Array.from(all_details).every(details => details.open);
9
+ all_details.forEach(details => { details.open = open; });
10
+ toggle.textContent = open ? 'collapse all' : 'expand all';
11
+ });
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @return [<String>]
4
+ def stylesheets
5
+ super + %w[css/rspec.css]
6
+ end
7
+
8
+ # @return [<String>]
9
+ def javascripts
10
+ super + %w[js/rspec.js]
11
+ end
@@ -0,0 +1,14 @@
1
+ <% examples = object[:rspec_examples].to_a %>
2
+ <% if examples.any? || YardRspec::Coverage.countable?(object) %>
3
+ <div class="tags rspec_examples">
4
+ <% if examples.any? %>
5
+ <% count = YardRspec::Coverage.examples(examples).size %>
6
+ <p class="tag_title">RSpec Examples (<%= count %>):<% if count > 1 %> <a href="#" class="rspec_toggle">expand all</a><% end %></p>
7
+ <ul class="rspec">
8
+ <%= rspec_nodes_html(examples) %>
9
+ </ul>
10
+ <% else %>
11
+ <p class="tag_title">RSpec Examples: <span class="rspec_label rspec_missing">no specs</span></p>
12
+ <% end %>
13
+ </div>
14
+ <% end %>
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shows the examples before the source.
4
+ def init
5
+ super
6
+ sections.place(:rspec_specs).before(:source, true)
7
+ end
8
+
9
+ # @return [String]
10
+ def rspec_specs
11
+ erb(:rspec_specs)
12
+ end
13
+
14
+ # @param [<Hash>] nodes Example and context nodes, see `method[:rspec_examples]`.
15
+ # @return [String] HTML list items.
16
+ def rspec_nodes_html(nodes)
17
+ nodes.map {|node| node[:children] ? rspec_group_html(node) : rspec_example_html(node)}.join
18
+ end
19
+
20
+ # @param [{description: String, code: String, pending: Boolean}] example
21
+ # @return [String]
22
+ def rspec_example_html(example)
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>" \
26
+ '</details></li>'
27
+ end
28
+
29
+ # @param [{description: String, children: [<Hash>]}] group
30
+ # @return [String]
31
+ def rspec_group_html(group)
32
+ '<li class="rspec_context">' \
33
+ "<span class=\"rspec_context_name\">#{h(group[:description])}</span>" \
34
+ "<ul class=\"rspec\">#{rspec_nodes_html(group[:children])}</ul></li>"
35
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yard-rspec-coverage
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ platform: ruby
6
+ authors:
7
+ - vdbijl
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: yard
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.9'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.9'
27
+ description: A YARD plugin that embeds RSpec examples alongside method documentation,
28
+ and reports public methods without specs
29
+ email:
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE
36
+ - README.md
37
+ - lib/tasks/yard_rspec.rake
38
+ - lib/yard-rspec-coverage.rb
39
+ - lib/yard_rspec/config.rb
40
+ - lib/yard_rspec/coverage.rb
41
+ - lib/yard_rspec/describe_problems.rb
42
+ - lib/yard_rspec/example_extractor.rb
43
+ - lib/yard_rspec/handler.rb
44
+ - lib/yard_rspec/method_description.rb
45
+ - lib/yard_rspec/rake_tasks.rb
46
+ - lib/yard_rspec/stats.rb
47
+ - templates/default/fulldoc/html/css/rspec.css
48
+ - templates/default/fulldoc/html/js/rspec.js
49
+ - templates/default/layout/html/setup.rb
50
+ - templates/default/method_details/html/rspec_specs.erb
51
+ - templates/default/method_details/html/setup.rb
52
+ homepage: https://github.com/vdbijl/yard-rspec-coverage
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ rubygems_mfa_required: 'true'
57
+ source_code_uri: https://github.com/vdbijl/yard-rspec-coverage
58
+ changelog_uri: https://github.com/vdbijl/yard-rspec-coverage/blob/main/CHANGELOG.md
59
+ bug_tracker_uri: https://github.com/vdbijl/yard-rspec-coverage/issues
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.3'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.4.22
76
+ signing_key:
77
+ specification_version: 4
78
+ summary: YARD plugin to display RSpec examples in documentation and report RSpec coverage
79
+ test_files: []