lib/sord/rbi_generator.rb in sord-0.7.1 vs lib/sord/rbi_generator.rb in sord-0.8.0

- old
+ new

@@ -19,23 +19,34 @@ # @return [Array<Array(String, YARD::CodeObjects::Base, Integer)>] The # errors encountered by by the generator. Each element is of the form # [message, item, line]. attr_reader :warnings + # @return [Boolean] A boolean indicating whether the next item is the first + # in its namespace. This is used to determine whether to insert a blank + # line before it or not. + attr_accessor :next_item_is_first_in_namespace + # Create a new RBI generator. # @param [Hash] options - # @return [RbiGenerator] + # @option options [Integer] break_params + # @option options [Boolean] replace_errors_with_untyped + # @option options [Boolean] comments + # @return [void] def initialize(options) @rbi_contents = ['# typed: strong'] @namespace_count = 0 @method_count = 0 + @break_params = options[:break_params] + @replace_errors_with_untyped = options[:replace_errors_with_untyped] @warnings = [] + @next_item_is_first_in_namespace = true # Hook the logger so that messages are added as comments to the RBI file Logging.add_hook do |type, msg, item, indent_level = 0| rbi_contents << "#{' ' * (indent_level + 1)}# sord #{type} - #{msg}" - end if options.comments + end if options[:comments] # Hook the logger so that warnings are collected Logging.add_hook do |type, msg, item, indent_level = 0| warnings << [msg, item, rbi_contents.length] \ if type == :warn @@ -52,44 +63,80 @@ # @return [void] def count_method @method_count += 1 end + # Adds a single blank line to the RBI file, unless this item is the first + # in its namespace. + # @return [void] + def add_blank + rbi_contents << '' unless next_item_is_first_in_namespace + self.next_item_is_first_in_namespace = false + end + # Given a YARD CodeObject, add lines defining its mixins (that is, extends - # and includes) to the current RBI file. + # and includes) to the current RBI file. Returns the number of mixins. # @param [YARD::CodeObjects::Base] item # @param [Integer] indent_level - # @return [void] + # @return [Integer] def add_mixins(item, indent_level) - extends = item.instance_mixins - includes = item.class_mixins + includes = item.instance_mixins + extends = item.class_mixins - extends.each do |this_extend| + extends.reverse_each do |this_extend| rbi_contents << "#{' ' * (indent_level + 1)}extend #{this_extend.path}" end - includes.each do |this_include| + includes.reverse_each do |this_include| rbi_contents << "#{' ' * (indent_level + 1)}include #{this_include.path}" end + + extends.length + includes.length end + # Given an array of parameters and a return type, inserts the signature for + # a method with those properties into the current RBI file. + # @param [Array<String>] params + # @param [String] returns + # @param [Integer] indent_level + # @return [void] + def add_signature(params, returns, indent_level) + if params.empty? + rbi_contents << "#{' ' * (indent_level + 1)}sig { #{returns} }" + return + end + + if params.length >= @break_params + rbi_contents << "#{' ' * (indent_level + 1)}sig do" + rbi_contents << "#{' ' * (indent_level + 2)}params(" + params.each.with_index do |param, i| + terminator = params.length - 1 == i ? '' : ',' + rbi_contents << "#{' ' * (indent_level + 3)}#{param}#{terminator}" + end + rbi_contents << "#{' ' * (indent_level + 2)}).#{returns}" + rbi_contents << "#{' ' * (indent_level + 1)}end" + else + rbi_contents << "#{' ' * (indent_level + 1)}sig { params(#{params.join(', ')}).#{returns} }" + end + end + # Given a YARD NamespaceObject, add lines defining its methods and their # signatures to the current RBI file. # @param [YARD::CodeObjects::NamespaceObject] item # @param [Integer] indent_level # @return [void] def add_methods(item, indent_level) - # TODO: block documentation - item.meths.each do |meth| count_method # If the method is an alias, skip it so we don't define it as a # separate method. Sorbet will handle it automatically. if meth.is_alias? next end + add_blank + parameter_list = meth.parameters.map do |name, default| # Handle these three main cases: # - def method(param) or def method(param:) # - def method(param: 'default') # - def method(param = 'default') @@ -105,103 +152,156 @@ # This is better than iterating over YARD's "@param" tags directly # because it includes parameters without documentation # (The gsubs allow for better splat-argument compatibility) parameter_names_to_tags = meth.parameters.map do |name, _| [name, meth.tags('param') - .find { |p| p.name.gsub('*', '') == name.gsub('*', '') }] + .find { |p| p.name&.gsub('*', '') == name.gsub('*', '') }] end.to_h sig_params_list = parameter_names_to_tags.map do |name, tag| name = name.gsub('*', '') if tag - "#{name}: #{TypeConverter.yard_to_sorbet(tag.types, meth)}" + "#{name}: #{TypeConverter.yard_to_sorbet(tag.types, meth, indent_level, @replace_errors_with_untyped)}" elsif name.start_with? '&' - # Cut the ampersand from the block parameter name. - "#{name[1..-1]}: T.untyped" + # Cut the ampersand from the block parameter name + name = name.gsub('&', '') + + # Find yieldparams and yieldreturn + yieldparams = meth.tags('yieldparam') + yieldreturn = meth.tag('yieldreturn')&.types + yieldreturn = nil if yieldreturn&.length == 1 && + yieldreturn&.first&.downcase == 'void' + + # Create strings + params_string = yieldparams.map do |param| + "#{param.name.gsub('*', '')}: #{TypeConverter.yard_to_sorbet(param.types, meth, indent_level, @replace_errors_with_untyped)}" unless param.name.nil? + end.join(', ') + return_string = TypeConverter.yard_to_sorbet(yieldreturn, meth, indent_level, @replace_errors_with_untyped) + + # Create proc types, if possible + if yieldparams.empty? && yieldreturn.nil? + "#{name}: T.untyped" + elsif yieldreturn.nil? + "#{name}: T.proc#{params_string.empty? ? '' : ".params(#{params_string})"}.void" + else + "#{name}: T.proc#{params_string.empty? ? '' : ".params(#{params_string})"}.returns(#{return_string})" + end elsif meth.path.end_with? '=' # Look for the matching getter method getter_path = meth.path[0...-1] getter = item.meths.find { |m| m.path == getter_path } unless getter - Logging.omit("no YARD type given for #{name.inspect}, using T.untyped", meth, indent_level) - next "#{name}: T.untyped" + if parameter_names_to_tags.length == 1 \ + && meth.tags('param').length == 1 \ + && meth.tag('param').types + + Logging.infer("argument name in single @param inferred as #{parameter_names_to_tags.first.first.inspect}", meth, indent_level) + next "#{name}: #{TypeConverter.yard_to_sorbet(meth.tag('param').types, meth, indent_level, @replace_errors_with_untyped)}" + else + Logging.omit("no YARD type given for #{name.inspect}, using T.untyped", meth, indent_level) + next "#{name}: T.untyped" + end end inferred_type = TypeConverter.yard_to_sorbet( - getter.tags('return').flat_map(&:types), meth) + getter.tags('return').flat_map(&:types), meth, indent_level, @replace_errors_with_untyped) Logging.infer("inferred type of parameter #{name.inspect} as #{inferred_type} using getter's return type", meth, indent_level) # Get rid of : on keyword arguments. name = name.chop if name.end_with?(':') "#{name}: #{inferred_type}" else - Logging.omit("no YARD type given for #{name.inspect}, using T.untyped", meth, indent_level) - # Get rid of : on keyword arguments. - name = name.chop if name.end_with?(':') - "#{name}: T.untyped" + # Is this the only argument, and was a @param specified without an + # argument name? If so, infer it + if parameter_names_to_tags.length == 1 \ + && meth.tags('param').length == 1 \ + && meth.tag('param').types + + Logging.infer("argument name in single @param inferred as #{parameter_names_to_tags.first.first.inspect}", meth, indent_level) + "#{name}: #{TypeConverter.yard_to_sorbet(meth.tag('param').types, meth, indent_level, @replace_errors_with_untyped)}" + else + Logging.omit("no YARD type given for #{name.inspect}, using T.untyped", meth, indent_level) + # Get rid of : on keyword arguments. + name = name.chop if name.end_with?(':') + "#{name}: T.untyped" + end end - end.join(", ") + end return_tags = meth.tags('return') returns = if return_tags.length == 0 - "void" + Logging.omit("no YARD return type given, using T.untyped", meth, indent_level) + "returns(T.untyped)" elsif return_tags.length == 1 && return_tags&.first&.types&.first&.downcase == "void" "void" else - "returns(#{TypeConverter.yard_to_sorbet(meth.tag('return').types, meth)})" + "returns(#{TypeConverter.yard_to_sorbet(meth.tag('return').types, meth, indent_level, @replace_errors_with_untyped)})" end prefix = meth.scope == :class ? 'self.' : '' - sig = sig_params_list.empty? ? "#{' ' * (indent_level + 1)}sig { #{returns} }" : "#{' ' * (indent_level + 1)}sig { params(#{sig_params_list}).#{returns} }" - rbi_contents << sig + add_signature(sig_params_list, returns, indent_level) rbi_contents << "#{' ' * (indent_level + 1)}def #{prefix}#{meth.name}(#{parameter_list}); end" end end # Given a YARD NamespaceObject, add lines defining its mixins, methods # and children to the RBI file. # @param [YARD::CodeObjects::NamespaceObject] item # @param [Integer] indent_level + # @return [void] def add_namespace(item, indent_level = 0) count_namespace + add_blank if item.type == :class && item.superclass.to_s != "Object" rbi_contents << "#{' ' * indent_level}class #{item.name} < #{item.superclass.path}" else rbi_contents << "#{' ' * indent_level}#{item.type} #{item.name}" end - add_mixins(item, indent_level) + + self.next_item_is_first_in_namespace = true + if add_mixins(item, indent_level) > 0 + self.next_item_is_first_in_namespace = false + end add_methods(item, indent_level) item.children.select { |x| [:class, :module].include?(x.type) } .each { |child| add_namespace(child, indent_level + 1) } + self.next_item_is_first_in_namespace = false + rbi_contents << "#{' ' * indent_level}end" end - # Generates the RBI file and writes it to the given file path. - # @param [String] filename + # Generates the RBI file from the loading registry and returns its contents. + # You must load a registry first! + # @return [String] + def generate + # Generate top-level modules, which recurses to all modules + YARD::Registry.root.children + .select { |x| [:class, :module].include?(x.type) } + .each { |child| add_namespace(child) } + + rbi_contents.join("\n") + end + + # Generates the RBI file and writes it to the given file path, printing a + # summary and any warnings at the end. The registry is also loaded. + # @param [String, nil] filename # @return [void] def run(filename) - raise "No filename specified" unless filename + raise 'No filename specified' unless filename # Get YARD ready YARD::Registry.load! - # TODO: constants? - - # Generate top-level modules, which recurses to all modules - YARD::Registry.root.children - .select { |x| [:class, :module].include?(x.type) } - .each { |child| add_namespace(child) } - # Write the file - File.write(filename, rbi_contents.join(?\n)) + File.write(filename, generate) if object_count.zero? Logging.warn("No objects processed.") Logging.warn("Have you definitely generated the YARD documentation for this project?") Logging.warn("Run `yard` to generate docs.") @@ -211,14 +311,18 @@ Logging.hooks.clear unless warnings.empty? Logging.warn("There were #{warnings.length} important warnings in the RBI file, listed below.") - Logging.warn("The types which caused them have been replaced with SORD_ERROR_ constants.") + if @replace_errors_with_untyped + Logging.warn("The types which caused them have been replaced with T.untyped.") + else + Logging.warn("The types which caused them have been replaced with SORD_ERROR_ constants.") + end Logging.warn("Please edit the file near the line numbers given to fix these errors.") Logging.warn("Alternatively, edit your YARD documentation so that your types are valid and re-run Sord.") warnings.each do |(msg, item, line)| - puts " #{"Line #{line} |".light_black} (#{item.path.bold}) #{msg}" + puts " #{"Line #{line} |".light_black} (#{item&.path&.bold}) #{msg}" end end rescue Logging.error($!) $@.each do |line|