Skip to content
This repository has been archived by the owner on Nov 30, 2022. It is now read-only.

Commit

Permalink
Initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
dduugg committed Jan 5, 2020
1 parent b59c558 commit 4e40197
Show file tree
Hide file tree
Showing 3 changed files with 277 additions and 0 deletions.
10 changes: 10 additions & 0 deletions lib/yard_sorbet.rb
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'
182 changes: 182 additions & 0 deletions lib/yard_sorbet/sig_handler.rb
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
85 changes: 85 additions & 0 deletions lib/yard_sorbet/sig_to_yard.rb
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

0 comments on commit 4e40197

Please sign in to comment.