This repository has been archived by the owner on Nov 30, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
277 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
# frozen_string_literal: true | ||
# typed: strong | ||
require 'sorbet-runtime' | ||
require 'yard' | ||
|
||
# top-level namespace | ||
module YARDSorbet; end | ||
|
||
require_relative 'yard_sorbet/sig_handler' | ||
require_relative 'yard_sorbet/sig_to_yard' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
# frozen_string_literal: true | ||
module YARDSorbet | ||
class SigHandler < YARD::Handlers::Ruby::Base | ||
extend T::Sig | ||
handles :class, :module, :singleton_class? | ||
|
||
sig { returns(String).checked(:never) } | ||
def process | ||
# Find the list of declarations inside the class | ||
class_def = statement.children.find { |c| c.type == :list } | ||
class_contents = class_def.children | ||
|
||
class_contents.each_with_index do |child, i| | ||
next unless type_signature?(child) | ||
|
||
next_statement = class_contents[i + 1] | ||
if %i[def defs command].include?(next_statement&.type) && !next_statement.docstring | ||
# Swap the method definition docstring and the sig docstring. | ||
# Parse relevant parts of the `sig` and include them as well. | ||
parser = YARD::DocstringParser.new.parse(child.docstring) | ||
# Directives are already parsed at this point, and there doesn't | ||
# seem to be an API to tweeze them from one node to another without | ||
# managing YARD internal state. Instead, we just extract them from | ||
# the raw text and re-attach them. | ||
directives = parser.raw_text&.split("\n")&.select do |line| | ||
line.start_with?('@!') | ||
end || [] | ||
docstring = parser.to_docstring | ||
parsed_sig = parse_sig(child) | ||
enhance_tag(docstring, :abstract, parsed_sig) | ||
enhance_tag(docstring, :return, parsed_sig) | ||
if next_statement.type != :command | ||
parsed_sig[:params]&.each do |name, types| | ||
enhance_param(docstring, name, types) | ||
end | ||
end | ||
next_statement.docstring = docstring.to_raw | ||
directives.each do |directive| | ||
next_statement.docstring.concat("\n#{directive}") | ||
end | ||
child.docstring = nil | ||
end | ||
end | ||
end | ||
|
||
# sig do | ||
# params( | ||
# docstring: YARD::Docstring, | ||
# name: String, | ||
# types: T::Array[String] | ||
# ) | ||
# .void | ||
# .checked(:tests) | ||
# end | ||
private def enhance_param(docstring, name, types) | ||
tag = docstring.tags.find { |t| t.tag_name == 'param' && t.name == name } | ||
if tag | ||
docstring.delete_tag_if { |t| t == tag } | ||
tag.types = types | ||
else | ||
tag = YARD::Tags::Tag.new(:param, '', types, name) | ||
end | ||
docstring.add_tag(tag) | ||
end | ||
|
||
# sig do | ||
# params( | ||
# docstring: YARD::Docstring, | ||
# type: Symbol, | ||
# parsed_sig: T::Hash[T.untyped, T.untyped] | ||
# ) | ||
# .void | ||
# .checked(:tests) | ||
# end | ||
private def enhance_tag(docstring, type, parsed_sig) | ||
return if !parsed_sig[type] | ||
|
||
tag = docstring.tags.find { |t| t.tag_name == type.to_s } | ||
if tag | ||
docstring.delete_tags(type) | ||
else | ||
tag = YARD::Tags::Tag.new(type, '') | ||
end | ||
if parsed_sig[type].is_a?(Array) | ||
tag.types = parsed_sig[type] | ||
end | ||
docstring.add_tag(tag) | ||
end | ||
|
||
# sig do | ||
# params(sig_node: YARD::Parser::Ruby::MethodCallNode) | ||
# .returns(T::Hash[T.untyped, T.untyped]) | ||
# .checked(:tests) | ||
# end | ||
private def parse_sig(sig_node) | ||
parsed = {} | ||
parsed[:abstract] = false | ||
parsed[:params] = {} | ||
found_params = T.let(false, T::Boolean) | ||
found_return = T.let(false, T::Boolean) | ||
bfs_traverse(sig_node, exclude: %i[array hash]) do |n| | ||
if n.source == 'abstract' | ||
parsed[:abstract] = true | ||
elsif n.source == 'params' && !found_params | ||
found_params = true | ||
sibling = T.must(sibling_node(n)) | ||
bfs_traverse(sibling, exclude: %i[array call hash]) do |p| | ||
if p.type == :assoc | ||
param_name = p.children.first.source[0...-1] | ||
types = YARDSorbet::SigToYARD.convert(p.children.last) | ||
parsed[:params][param_name] = types | ||
end | ||
end | ||
elsif n.source == 'returns' && !found_return | ||
found_return = true | ||
parsed[:return] = YARDSorbet::SigToYARD.convert(T.must(sibling_node(n))) | ||
elsif n.source == 'void' | ||
parsed[:return] ||= ['void'] | ||
end | ||
end | ||
parsed | ||
end | ||
|
||
# Returns true if the given node is part of a type signature. | ||
# sig do | ||
# node is T.nilable(YARD::Parser::Ruby::AstNode) but that has issues | ||
# with loop code | ||
# params(node: T.untyped).returns(T.nilable(Boolean)).checked(:tests) | ||
# end | ||
private def type_signature?(node) | ||
loop do | ||
return false if node.nil? | ||
return false unless %i[call vcall fcall].include?(node.type) | ||
return true if T.unsafe(node).method_name(true) == :sig | ||
|
||
node = node.children.first | ||
end | ||
end | ||
|
||
# sig do | ||
# params(node: YARD::Parser::Ruby::AstNode) | ||
# .returns(T.nilable(YARD::Parser::Ruby::AstNode)) | ||
# .checked(:tests) | ||
# end | ||
private def sibling_node(node) | ||
found_sibling = T.let(false, T::Boolean) | ||
node.parent.children.each do |n| | ||
if found_sibling | ||
return n | ||
end | ||
|
||
if n == node | ||
found_sibling = true | ||
end | ||
end | ||
nil | ||
end | ||
|
||
# @yield [YARD::Parser::Ruby::AstNode] | ||
# sig do | ||
# params( | ||
# node: YARD::Parser::Ruby::AstNode, | ||
# exclude: T::Array[Symbol], | ||
# blk: T.proc.params(arg0: T.untyped).returns(T.untyped) | ||
# ) | ||
# .void | ||
# .checked(:tests) | ||
# end | ||
private def bfs_traverse(node, exclude: []) | ||
queue = [node] | ||
while !queue.empty? | ||
n = T.must(queue.shift) | ||
yield n | ||
n.children.each do |c| | ||
if !exclude.include?(c.type) | ||
queue.push(c) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
# frozen_string_literal: true | ||
# typed: strict | ||
|
||
module YARDSorbet::SigToYARD | ||
# Translate sig type syntax to YARD type syntax. | ||
# | ||
# @see https://yardoc.org/types.html | ||
# sig {params(node: YARD::Parser::Ruby::AstNode).returns(T::Array[String]).checked(:tests)} | ||
def self.convert(node) | ||
types = convert_type(node) | ||
# scrub newlines, as they break the YARD parser | ||
types.map { |type| type.gsub(/\n\s*/, ' ') } | ||
end | ||
|
||
def self.convert_type(node) | ||
children = node.children | ||
case node.type | ||
when :aref | ||
# https://www.rubydoc.info/gems/yard/file/docs/Tags.md#Parametrized_Types | ||
case children.first.source | ||
when 'T::Array', 'T::Enumerable', 'T::Range', 'T::Set' | ||
collection_type = children.first.source.split('::').last | ||
member_type = convert(children.last.children.first).join(', ') | ||
["#{collection_type}<#{member_type}>"] | ||
when 'T::Hash' | ||
key_type = convert(children.last.children.first).join(', ') | ||
value_type = convert(children.last.children.last).join(', ') | ||
["Hash{#{key_type} => #{value_type}}"] | ||
else | ||
log.warn("Unsupported sig aref node #{node.source}") | ||
[node.source] | ||
end | ||
when :arg_paren | ||
convert(children.first.children.first) | ||
when :array | ||
# https://www.rubydoc.info/gems/yard/file/docs/Tags.md#Order-Dependent_Lists | ||
member_types = children.first.children.map { |n| convert(n) }.join(', ') | ||
["Array(#{member_types})"] | ||
when :call | ||
if children[0].source == 'T' | ||
case children[2].source | ||
when 'all', 'class_of', 'enum', 'noreturn', 'self_type', 'type_parameter', 'untyped' | ||
# YARD doesn't have equivalent notions, so we just use the raw source | ||
[node.source] | ||
when 'any' | ||
children.last.children.first.children.map { |n| convert(n) }.flatten | ||
when 'nilable' | ||
# Order matters here, putting `nil` last results in a more concise | ||
# return syntax in the UI (superscripted `?`) | ||
convert(children.last) + ['nil'] | ||
else | ||
log.warn("Unsupported T method #{node.source}") | ||
[node.source] | ||
end | ||
else | ||
[node.source] | ||
end | ||
when :const_path_ref | ||
[node.source] | ||
when :hash, :list | ||
# Fixed hashes as return values are unsupported: | ||
# https://github.com/lsegal/yard/issues/425 | ||
# | ||
# Hash key params can be individually documented with `@option`, but | ||
# sig translation is unsupported. | ||
['Hash'] | ||
when :var_ref | ||
# YARD convention is use singleton objects when applicable: | ||
# https://www.rubydoc.info/gems/yard/file/docs/Tags.md#Literals | ||
case node.source | ||
when 'FalseClass' | ||
['false'] | ||
when 'NilClass' | ||
['nil'] | ||
when 'TrueClass' | ||
['true'] | ||
else | ||
[node.source] | ||
end | ||
else | ||
log.warn("Unsupported sig #{node.type} node #{node.source}") | ||
[node.source] | ||
end | ||
end | ||
end |