Skip to content

Commit c038be1

Browse files
authored
Merge pull request #170 from dduugg/merge-rbi-sigs
Merge sigs in RBI files with existing documentation
2 parents 3df3455 + 2375f3f commit c038be1

File tree

7 files changed

+213
-22
lines changed

7 files changed

+213
-22
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## main
44

5+
### New Features
6+
7+
* [#141](https://github.com/dduugg/yard-sorbet/issues/141) Merge RBI sigs into existing documentation
8+
59
### Bug Fixes
610

711
* Handle multiple invocations of `mixes_in_class_methods` within a class

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ A YARD [plugin](https://rubydoc.info/gems/yard/file/docs/GettingStarted.md#Plugi
1212
- Generates constant definitions from `T::Enum` enums
1313
- Modules marked `abstract!` or `interface!` are tagged `@abstract`
1414
- Modules using `mixes_in_class_methods` will attach class methods
15+
- Merges `sig`s in rbi files with source code documentation (rbi files must come after source code in yard configuration)
1516

1617
## Usage
1718

lib/yard-sorbet/handlers/sig_handler.rb

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,43 +10,93 @@ class SigHandler < YARD::Handlers::Ruby::Base
1010
handles method_call(:sig)
1111
namespace_only
1212

13-
# These node types attached to sigs represent attr_* declarations
14-
ATTR_NODE_TYPES = T.let(%i[command fcall].freeze, T::Array[Symbol])
15-
private_constant :ATTR_NODE_TYPES
13+
# YARD types that can have docstrings attached to them
14+
Documentable = T.type_alias do
15+
T.any(
16+
YARD::CodeObjects::MethodObject, YARD::Parser::Ruby::MethodCallNode, YARD::Parser::Ruby::MethodDefinitionNode
17+
)
18+
end
19+
private_constant :Documentable
1620

1721
# Swap the method definition docstring and the sig docstring.
1822
# Parse relevant parts of the `sig` and include them as well.
1923
sig { void }
2024
def process
2125
method_node = NodeUtils.get_method_node(NodeUtils.sibling_node(statement))
22-
docstring, directives = Directives.extract_directives(statement.docstring)
23-
parse_sig(method_node, docstring)
24-
method_node.docstring = docstring.to_raw
25-
Directives.add_directives(method_node.docstring, directives)
26+
case method_node
27+
when YARD::Parser::Ruby::MethodDefinitionNode then process_def(method_node)
28+
when YARD::Parser::Ruby::MethodCallNode then process_attr(method_node)
29+
end
2630
statement.docstring = nil
2731
end
2832

2933
private
3034

31-
sig { params(method_node: YARD::Parser::Ruby::AstNode, docstring: YARD::Docstring).void }
32-
def parse_sig(method_node, docstring)
35+
sig { params(def_node: YARD::Parser::Ruby::MethodDefinitionNode).void }
36+
def process_def(def_node)
37+
separator = scope == :instance && def_node.type == :def ? '#' : '.'
38+
registered = YARD::Registry.at("#{namespace}#{separator}#{def_node.method_name(true)}")
39+
if registered
40+
parse_node(registered, registered.docstring)
41+
# Since we're probably in an RBI file, delete the def node, which could otherwise erroneously override the
42+
# visibility setting
43+
NodeUtils.delete_node(def_node)
44+
else
45+
parse_node(def_node, statement.docstring)
46+
end
47+
end
48+
49+
sig { params(attr_node: YARD::Parser::Ruby::MethodCallNode).void }
50+
def process_attr(attr_node)
51+
return if merged_into_attr?(attr_node)
52+
53+
parse_node(attr_node, statement.docstring, include_params: false)
54+
end
55+
56+
# An attr* sig can be merged into a previous attr* docstring if it is the only parameter passed to the attr*
57+
# declaration. This is to avoid needing to rewrite the source code to separate merged and unmerged attr*
58+
# declarations.
59+
sig { params(attr_node: YARD::Parser::Ruby::MethodCallNode).returns(T::Boolean) }
60+
def merged_into_attr?(attr_node)
61+
names = NodeUtils.validated_attribute_names(attr_node)
62+
return false if names.size != 1
63+
64+
attrs = namespace.attributes[scope][names[0]]
65+
return false if attrs.nil? || attrs.empty?
66+
67+
document_attr_methods(attrs.values.compact)
68+
attr_node.docstring = nil
69+
true
70+
end
71+
72+
sig { params(method_objects: T::Array[YARD::CodeObjects::MethodObject]).void }
73+
def document_attr_methods(method_objects)
74+
method_objects.each { parse_node(_1, _1.docstring, include_params: false) }
75+
end
76+
77+
sig { params(attach_to: Documentable, docstring: T.nilable(String), include_params: T::Boolean).void }
78+
def parse_node(attach_to, docstring, include_params: true)
79+
existing_docstring = docstring.is_a?(YARD::Docstring)
80+
docstring, directives = Directives.extract_directives(docstring) unless existing_docstring
81+
parse_sig(docstring, include_params: include_params)
82+
attach_to.docstring = docstring.to_raw
83+
Directives.add_directives(attach_to.docstring, directives) unless existing_docstring
84+
end
85+
86+
sig { params(docstring: YARD::Docstring, include_params: T::Boolean).void }
87+
def parse_sig(docstring, include_params: true)
3388
NodeUtils.bfs_traverse(statement) do |node|
3489
case node.source
3590
when 'returns' then parse_return(node, docstring)
36-
when 'params' then parse_params(method_node, node, docstring)
91+
when 'params' then parse_params(node, docstring) if include_params
3792
when 'void' then TagUtils.upsert_tag(docstring, 'return', TagUtils::VOID_RETURN_TYPE)
3893
when 'abstract' then TagUtils.upsert_tag(docstring, 'abstract')
3994
end
4095
end
4196
end
4297

43-
sig do
44-
params(method_node: YARD::Parser::Ruby::AstNode, node: YARD::Parser::Ruby::AstNode, docstring: YARD::Docstring)
45-
.void
46-
end
47-
def parse_params(method_node, node, docstring)
48-
return if ATTR_NODE_TYPES.include?(method_node.type)
49-
98+
sig { params(node: YARD::Parser::Ruby::AstNode, docstring: YARD::Docstring).void }
99+
def parse_params(node, docstring)
50100
sibling = NodeUtils.sibling_node(node)
51101
sibling.dig(0, 0).each do |param|
52102
param_name = param.dig(0, 0)

lib/yard-sorbet/node_utils.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ def self.bfs_traverse(node, &_blk)
2828
end
2929
end
3030

31+
sig { params(node: YARD::Parser::Ruby::AstNode).void }
32+
def self.delete_node(node)
33+
node.parent.children.delete(node)
34+
end
35+
3136
# Gets the node that a sorbet `sig` can be attached do, bypassing visisbility modifiers and the like
3237
sig { params(node: YARD::Parser::Ruby::AstNode).returns(SigableNode) }
3338
def self.get_method_node(node)
@@ -51,5 +56,17 @@ def self.sigable_node?(node)
5156
else false
5257
end
5358
end
59+
60+
# @see https://github.com/lsegal/yard/blob/main/lib/yard/handlers/ruby/attribute_handler.rb
61+
# YARD::Handlers::Ruby::AttributeHandler.validated_attribute_names
62+
sig { params(attr_node: YARD::Parser::Ruby::MethodCallNode).returns(T::Array[String]) }
63+
def self.validated_attribute_names(attr_node)
64+
attr_node.parameters(false).map do |obj|
65+
case obj
66+
when YARD::Parser::Ruby::LiteralNode then obj[0][0].source
67+
else raise YARD::Parser::UndocumentableError, obj.source
68+
end
69+
end
70+
end
5471
end
5572
end

spec/data/sig_handler.rbi.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
module Merge
2+
class A
3+
sig { returns(Numeric) }
4+
attr_accessor :a_foo
5+
6+
sig { returns(T.nilable(String)) }
7+
attr_reader :a_bar
8+
9+
sig { params(writer: Integer).returns(Integer) }
10+
attr_writer :a_baz
11+
12+
sig { returns(String) }
13+
def foo; end
14+
15+
sig { params(a: Integer).returns(Float) }
16+
def self.bar(a); end
17+
18+
sig { returns(String) }
19+
def baz; end
20+
21+
sig { returns(Integer) }
22+
def bat; end
23+
end
24+
end

spec/data/sig_handler.txt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ end
294294

295295
class AttrSigs
296296
sig {returns(String)}
297-
attr_accessor :my_accessor
297+
attr_accessor 'my_accessor'
298298

299299
sig {returns(Integer)}
300300
attr_reader :my_reader
@@ -337,3 +337,25 @@ class Nodes
337337
sig { returns(INT) }
338338
def returns_const; 1; end
339339
end
340+
341+
module Merge
342+
class A
343+
# annotated attr_accessor
344+
attr_accessor :a_foo
345+
346+
attr_reader :a_bar
347+
348+
attr_writer :a_baz
349+
350+
# The foo instance method for A
351+
def foo; end
352+
353+
# The bar singleton method for A
354+
def self.bar(a); end
355+
356+
private def baz; end
357+
358+
# @return the result
359+
def bat; end
360+
end
361+
end

spec/yard_sorbet/handlers/sig_handler_spec.rb

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,67 @@
22
# frozen_string_literal: true
33

44
RSpec.describe YARDSorbet::Handlers::SigHandler do
5-
path = File.join(File.expand_path('../../data', __dir__), 'sig_handler.txt')
6-
7-
before do
5+
# The :all (and corresponding rubocop disable) isn't strictly necessary, but it speeds up tests considerably
6+
before(:all) do # rubocop:disable RSpec/BeforeAfterAll
87
YARD::Registry.clear
8+
path = File.join(File.expand_path('../../data', __dir__), 'sig_handler.txt')
99
YARD::Parser::SourceParser.parse(path)
10+
rbi_path = File.join(File.expand_path('../../data', __dir__), 'sig_handler.rbi.txt')
11+
YARD::Parser::SourceParser.parse(rbi_path)
12+
end
13+
14+
describe 'Merging an RBI file' do
15+
it 'includes docstring from original attr_accessor' do
16+
expect(YARD::Registry.at('Merge::A#a_foo').docstring).to eq('annotated attr_accessor')
17+
end
18+
19+
it 'merges attr_accessor sig' do
20+
expect(YARD::Registry.at('Merge::A#a_foo').tag(:return).types).to eq(['Numeric'])
21+
end
22+
23+
it 'includes docstring from original attr_reader' do
24+
expect(YARD::Registry.at('Merge::A#a_bar').docstring).to eq('Returns the value of attribute a_bar.')
25+
end
26+
27+
it 'merges attr_reader sig' do
28+
expect(YARD::Registry.at('Merge::A#a_bar').tag(:return).types).to eq(%w[String nil])
29+
end
30+
31+
it 'includes docstring from original attr_writer' do
32+
expect(YARD::Registry.at('Merge::A#a_baz=').docstring).to eq('Sets the attribute a_baz')
33+
end
34+
35+
it 'merges attr_writer sig' do
36+
expect(YARD::Registry.at('Merge::A#a_baz=').tag(:return).types).to eq(['Integer'])
37+
end
38+
39+
it 'includes docstring from original instance def' do
40+
expect(YARD::Registry.at('Merge::A#foo').docstring).to eq('The foo instance method for A')
41+
end
42+
43+
it 'merges instance def sig' do
44+
expect(YARD::Registry.at('Merge::A#foo').tag(:return).types).to eq(['String'])
45+
end
46+
47+
it 'includes docstring from original singleton def' do
48+
expect(YARD::Registry.at('Merge::A.bar').docstring).to eq('The bar singleton method for A')
49+
end
50+
51+
it 'merges singleton def sig' do
52+
expect(YARD::Registry.at('Merge::A.bar').tag(:return).types).to eq(['Float'])
53+
end
54+
55+
it 'preserves the visibility of the original method' do
56+
expect(YARD::Registry.at('Merge::A#baz').visibility).to be(:private)
57+
end
58+
59+
it 'merges sig return type with return tag' do
60+
expect(YARD::Registry.at('Merge::A#bat').tag(:return).types).to eq(['Integer'])
61+
end
62+
63+
it 'merges return tag comment with sig return type' do
64+
expect(YARD::Registry.at('Merge::A#bat').tag(:return).text).to eq('the result')
65+
end
1066
end
1167

1268
describe 'attaching to method' do
@@ -240,7 +296,7 @@
240296
end
241297

242298
it 'preserves visibility modifier' do
243-
expect(YARD::Registry.at('CollectionSigs#fixed_hash').visibility).to eq(:protected)
299+
expect(YARD::Registry.at('CollectionSigs#fixed_hash').visibility).to be(:protected)
244300
end
245301
end
246302

@@ -440,4 +496,21 @@
440496
end
441497
end
442498
end
499+
500+
describe 'Unparsable sigs' do
501+
before do
502+
allow(log).to receive(:warn)
503+
YARD::Parser::SourceParser.parse_string(<<~RUBY)
504+
class Test
505+
CONST = :foo
506+
sig { returns(Integer) }
507+
attr_reader CONST
508+
end
509+
RUBY
510+
end
511+
512+
it 'warn when parsing an attr* with a constant param' do
513+
expect(log).to have_received(:warn).with(/Undocumentable CONST/).twice
514+
end
515+
end
443516
end

0 commit comments

Comments
 (0)