diff --git a/lib/rbs/inline/ast/declarations.rb b/lib/rbs/inline/ast/declarations.rb index c6a5a56..c2b457f 100644 --- a/lib/rbs/inline/ast/declarations.rb +++ b/lib/rbs/inline/ast/declarations.rb @@ -28,7 +28,7 @@ def value_node(node) end # @rbs! - # type t = ClassDecl | ModuleDecl | ConstantDecl | SingletonClassDecl | BlockDecl | DataAssignDecl + # type t = ClassDecl | ModuleDecl | ConstantDecl | SingletonClassDecl | BlockDecl | DataAssignDecl | StructAssignDecl # # interface _WithComments # def comments: () -> AnnotationParser::ParsingResult? @@ -279,9 +279,53 @@ def module_class_annotation #: Annotations::ModuleDecl | Annotations::ClassDecl end end + # @rbs module-self _WithTypeDecls + module DataStructUtil + # @rbs! + # interface _WithTypeDecls + # def type_decls: () -> Hash[Integer, Annotations::TypeAssertion] + # + # def each_attribute_argument: () { (Prism::Node) -> void } -> void + # + # def comments: %a{pure} () -> AnnotationParser::ParsingResult? + # end + + # @rbs %a{pure} + # @rbs () { ([Symbol, Annotations::TypeAssertion?]) -> void } -> void + # | () -> Enumerator[[Symbol, Annotations::TypeAssertion?], void] + def each_attribute(&block) + if block + each_attribute_argument do |arg| + if arg.is_a?(Prism::SymbolNode) + if name = arg.value + type = type_decls.fetch(arg.location.start_line, nil) + yield [name.to_sym, type] + end + end + end + else + enum_for :each_attribute + end + end + + def class_annotations #: Array[RBS::AST::Annotation] + annotations = [] #: Array[RBS::AST::Annotation] + + comments&.each_annotation do |annotation| + if annotation.is_a?(Annotations::RBSAnnotation) + annotations.concat annotation.annotations + end + end + + annotations + end + end + class DataAssignDecl < Base extend ConstantUtil + include DataStructUtil + attr_reader :node #: Prism::ConstantWriteNode attr_reader :comments #: AnnotationParser::ParsingResult? @@ -323,36 +367,140 @@ def self.data_define?(node) end end + # @rbs () { (Prism::Node) -> void } -> void + def each_attribute_argument(&block) + if args = data_define_node.arguments + args.arguments.each(&block) + end + end + end + + class StructAssignDecl < Base + extend ConstantUtil + + include DataStructUtil + + attr_reader :node #: Prism::ConstantWriteNode + + attr_reader :comments #: AnnotationParser::ParsingResult? + + attr_reader :type_decls #: Hash[Integer, Annotations::TypeAssertion] + + attr_reader :struct_new_node #: Prism::CallNode + + # @rbs (Prism::ConstantWriteNode, Prism::CallNode, AnnotationParser::ParsingResult?, Hash[Integer, Annotations::TypeAssertion]) -> void + def initialize(node, struct_new_node, comments, type_decls) + @node = node + @comments = comments + @type_decls = type_decls + @struct_new_node = struct_new_node + end + + def start_line #: Integer + node.location.start_line + end + # @rbs %a{pure} - # @rbs () { ([Symbol, Annotations::TypeAssertion?]) -> void } -> void - # | () -> Enumerator[[Symbol, Annotations::TypeAssertion?], void] - def each_attribute(&block) - if block - if args = data_define_node.arguments - args.arguments.each do |arg| - if arg.is_a?(Prism::SymbolNode) - if name = arg.value - type = type_decls.fetch(arg.location.start_line, nil) - yield [name.to_sym, type] - end + # @rbs () -> TypeName? + def constant_name + TypeName.new(name: node.name, namespace: Namespace.empty) + end + + # @rbs () { (Prism::Node) -> void } -> void + def each_attribute_argument(&block) + if args = struct_new_node.arguments + args.arguments.each do |arg| + next if arg.is_a?(Prism::KeywordHashNode) + next if arg.is_a?(Prism::StringNode) + + yield arg + end + end + end + + # @rbs (Prism::ConstantWriteNode) -> Prism::CallNode? + def self.struct_new?(node) + value = value_node(node) + + if value.is_a?(Prism::CallNode) + if value.receiver.is_a?(Prism::ConstantReadNode) + if value.receiver.full_name.delete_prefix("::") == "Struct" + if value.name == :new + return value end end end - else - enum_for :each_attribute end end - def class_annotations #: Array[RBS::AST::Annotation] - annotations = [] #: Array[RBS::AST::Annotation] + # @rbs %a{pure} + def keyword_init? #: bool + if args = struct_new_node.arguments + args.arguments.each do |arg| + if arg.is_a?(Prism::KeywordHashNode) + arg.elements.each do |assoc| + if assoc.is_a?(Prism::AssocNode) + if (key = assoc.key).is_a?(Prism::SymbolNode) + if key.value == "keyword_init" + value = assoc.value + if value.is_a?(Prism::FalseNode) + return false + end + end + end + end + end + end + end + end - comments&.each_annotation do |annotation| - if annotation.is_a?(Annotations::RBSAnnotation) - annotations.concat annotation.annotations + true + end + + # @rbs %a{pure} + def positional_init? #: bool + if args = struct_new_node.arguments + args.arguments.each do |arg| + if arg.is_a?(Prism::KeywordHashNode) + arg.elements.each do |assoc| + if assoc.is_a?(Prism::AssocNode) + if (key = assoc.key).is_a?(Prism::SymbolNode) + if key.value == "keyword_init" + value = assoc.value + if value.is_a?(Prism::TrueNode) + return false + end + end + end + end + end + end end end - annotations + true + end + + # Returns `true` is annotation is given to make all attributes *readonly* + # + # Add `# @rbs %a{rbs-inline:readonly-attributes=true}` to the class to make all attributes `attr_reader`, instead of `attr_accessor`. + # + # @rbs %a{pure} + def readonly_attributes? #: bool + class_annotations.any? do |annotation| + annotation.string == "rbs-inline:readonly-attributes=true" + end + end + + # Returns `true` if annotation is given to make all `.new` arguments required + # + # Add `# @rbs %a{rbs-inline:new-args=required}` to the class to make all of the parameters required. + # + # @rbs %a{pure} + def required_new_args? #: bool + class_annotations.any? do |annotation| + annotation.string == "rbs-inline:new-args=required" + end end end end diff --git a/lib/rbs/inline/parser.rb b/lib/rbs/inline/parser.rb index 7eeba6a..c170af3 100644 --- a/lib/rbs/inline/parser.rb +++ b/lib/rbs/inline/parser.rb @@ -432,6 +432,19 @@ def visit_constant_write_node(node) end decl = AST::Declarations::DataAssignDecl.new(node, data_node, comment, type_decls) + when struct_node = AST::Declarations::StructAssignDecl.struct_new?(node) + type_decls = {} #: Hash[Integer, AST::Annotations::TypeAssertion] + + inner_annotations(node.location.start_line, node.location.end_line).flat_map do |comment| + comment.each_annotation do |annotation| + if annotation.is_a?(AST::Annotations::TypeAssertion) + start_line = annotation.source.comments[0].location.start_line + type_decls[start_line] = annotation + end + end + end + + decl = AST::Declarations::StructAssignDecl.new(node, struct_node, comment, type_decls) else assertion = assertion_annotation(node) decl = AST::Declarations::ConstantDecl.new(node, comment, assertion) diff --git a/lib/rbs/inline/writer.rb b/lib/rbs/inline/writer.rb index 49b6724..db3cf15 100644 --- a/lib/rbs/inline/writer.rb +++ b/lib/rbs/inline/writer.rb @@ -79,6 +79,8 @@ def translate_decl(decl, rbs) translate_constant_decl(decl, rbs) when AST::Declarations::DataAssignDecl translate_data_assign_decl(decl, rbs) + when AST::Declarations::StructAssignDecl + translate_struct_assign_decl(decl, rbs) when AST::Declarations::BlockDecl if decl.module_class_annotation case decl.module_class_annotation @@ -133,7 +135,7 @@ def translate_members(members, decl, rbs) else translate_members(member.members, decl, rbs) end - when AST::Declarations::ClassDecl, AST::Declarations::ModuleDecl, AST::Declarations::ConstantDecl, AST::Declarations::DataAssignDecl + when AST::Declarations::ClassDecl, AST::Declarations::ModuleDecl, AST::Declarations::ConstantDecl, AST::Declarations::DataAssignDecl, AST::Declarations::StructAssignDecl translate_decl(member, rbs) end end @@ -270,6 +272,116 @@ def translate_data_assign_decl(decl, rbs) #: void ) end + # @rbs decl: AST::Declarations::StructAssignDecl + # @rbs rbs: _Content + def translate_struct_assign_decl(decl, rbs) #: void + return unless decl.constant_name + + if decl.comments + comment = RBS::AST::Comment.new(string: decl.comments.content(trim: true), location: nil) + end + + attributes = decl.each_attribute.map do |name, type| + if decl.readonly_attributes? + RBS::AST::Members::AttrReader.new( + name: name, + type: type&.type || Types::Bases::Any.new(location: nil), + ivar_name: false, + comment: nil, + kind: :instance, + annotations: [], + visibility: nil, + location: nil + ) + else + RBS::AST::Members::AttrAccessor.new( + name: name, + type: type&.type || Types::Bases::Any.new(location: nil), + ivar_name: false, + comment: nil, + kind: :instance, + annotations: [], + visibility: nil, + location: nil + ) + end + end + + init = RBS::AST::Members::MethodDefinition.new( + name: :initialize, + kind: :instance, + overloads: [], + annotations: [], + location: nil, + comment: nil, + overloading: false, + visibility: nil + ) + + if decl.positional_init? + attr_params = decl.each_attribute.map do |name, attr| + RBS::Types::Function::Param.new( + type: attr&.type || Types::Bases::Any.new(location: nil), + name: name, + location: nil + ) + end + + method_type = Types::Function.empty(Types::Bases::Void.new(location: nil)) + if decl.required_new_args? + method_type = method_type.update(required_positionals: attr_params) + else + method_type = method_type.update(optional_positionals: attr_params) + end + + init.overloads << + RBS::AST::Members::MethodDefinition::Overload.new( + method_type: RBS::MethodType.new(type_params: [], type: method_type, block: nil, location: nil), + annotations: [] + ) + end + + if decl.keyword_init? + attr_keywords = decl.each_attribute.map do |name, attr| + [ + name, + RBS::Types::Function::Param.new( + type: attr&.type || Types::Bases::Any.new(location: nil), + name: nil, + location: nil + ) + ] + end.to_h #: Hash[Symbol, RBS::Types::Function::Param] + + method_type = Types::Function.empty(Types::Bases::Void.new(location: nil)) + if decl.required_new_args? + method_type = method_type.update(required_keywords: attr_keywords) + else + method_type = method_type.update(optional_keywords: attr_keywords) + end + + init.overloads << + RBS::AST::Members::MethodDefinition::Overload.new( + method_type: RBS::MethodType.new(type_params: [], type: method_type, block: nil, location: nil), + annotations: [] + ) + end + + rbs << RBS::AST::Declarations::Class.new( + name: decl.constant_name, + type_params: [], + members: [*attributes, init], + super_class: RBS::AST::Declarations::Class::Super.new( + name: RBS::TypeName.new(name: :Struct, namespace: RBS::Namespace.empty), + args: [RBS::Types::Bases::Any.new(location: nil)], + location: nil + ), + annotations: decl.class_annotations, + location: nil, + comment: comment + ) + end + # @rbs decl: AST::Declarations::SingletonClassDecl # @rbs rbs: _Content # @rbs return: void diff --git a/sig/generated/rbs/inline/ast/declarations.rbs b/sig/generated/rbs/inline/ast/declarations.rbs index f19bef3..4a9fa2b 100644 --- a/sig/generated/rbs/inline/ast/declarations.rbs +++ b/sig/generated/rbs/inline/ast/declarations.rbs @@ -13,7 +13,7 @@ module RBS def value_node: (Prism::Node) -> Prism::Node? end - type t = ClassDecl | ModuleDecl | ConstantDecl | SingletonClassDecl | BlockDecl | DataAssignDecl + type t = ClassDecl | ModuleDecl | ConstantDecl | SingletonClassDecl | BlockDecl | DataAssignDecl | StructAssignDecl interface _WithComments def comments: () -> AnnotationParser::ParsingResult? @@ -133,9 +133,31 @@ module RBS def module_class_annotation: () -> (Annotations::ModuleDecl | Annotations::ClassDecl | nil) end + # @rbs module-self _WithTypeDecls + module DataStructUtil : _WithTypeDecls + interface _WithTypeDecls + def type_decls: () -> Hash[Integer, Annotations::TypeAssertion] + + def each_attribute_argument: () { (Prism::Node) -> void } -> void + + def comments: %a{pure} () -> AnnotationParser::ParsingResult? + end + + # @rbs %a{pure} + # @rbs () { ([Symbol, Annotations::TypeAssertion?]) -> void } -> void + # | () -> Enumerator[[Symbol, Annotations::TypeAssertion?], void] + %a{pure} + def each_attribute: () { ([ Symbol, Annotations::TypeAssertion? ]) -> void } -> void + | () -> Enumerator[[ Symbol, Annotations::TypeAssertion? ], void] + + def class_annotations: () -> Array[RBS::AST::Annotation] + end + class DataAssignDecl < Base extend ConstantUtil + include DataStructUtil + attr_reader node: Prism::ConstantWriteNode attr_reader comments: AnnotationParser::ParsingResult? @@ -157,14 +179,62 @@ module RBS # @rbs (Prism::ConstantWriteNode) -> Prism::CallNode? def self.data_define?: (Prism::ConstantWriteNode) -> Prism::CallNode? + # @rbs () { (Prism::Node) -> void } -> void + def each_attribute_argument: () { (Prism::Node) -> void } -> void + end + + class StructAssignDecl < Base + extend ConstantUtil + + include DataStructUtil + + attr_reader node: Prism::ConstantWriteNode + + attr_reader comments: AnnotationParser::ParsingResult? + + attr_reader type_decls: Hash[Integer, Annotations::TypeAssertion] + + attr_reader struct_new_node: Prism::CallNode + + # @rbs (Prism::ConstantWriteNode, Prism::CallNode, AnnotationParser::ParsingResult?, Hash[Integer, Annotations::TypeAssertion]) -> void + def initialize: (Prism::ConstantWriteNode, Prism::CallNode, AnnotationParser::ParsingResult?, Hash[Integer, Annotations::TypeAssertion]) -> void + + def start_line: () -> Integer + # @rbs %a{pure} - # @rbs () { ([Symbol, Annotations::TypeAssertion?]) -> void } -> void - # | () -> Enumerator[[Symbol, Annotations::TypeAssertion?], void] + # @rbs () -> TypeName? %a{pure} - def each_attribute: () { ([ Symbol, Annotations::TypeAssertion? ]) -> void } -> void - | () -> Enumerator[[ Symbol, Annotations::TypeAssertion? ], void] + def constant_name: () -> TypeName? - def class_annotations: () -> Array[RBS::AST::Annotation] + # @rbs () { (Prism::Node) -> void } -> void + def each_attribute_argument: () { (Prism::Node) -> void } -> void + + # @rbs (Prism::ConstantWriteNode) -> Prism::CallNode? + def self.struct_new?: (Prism::ConstantWriteNode) -> Prism::CallNode? + + # @rbs %a{pure} + %a{pure} + def keyword_init?: () -> bool + + # @rbs %a{pure} + %a{pure} + def positional_init?: () -> bool + + # Returns `true` is annotation is given to make all attributes *readonly* + # + # Add `# @rbs %a{rbs-inline:readonly-attributes=true}` to the class to make all attributes `attr_reader`, instead of `attr_accessor`. + # + # @rbs %a{pure} + %a{pure} + def readonly_attributes?: () -> bool + + # Returns `true` if annotation is given to make all `.new` arguments required + # + # Add `# @rbs %a{rbs-inline:new-args=required}` to the class to make all of the parameters required. + # + # @rbs %a{pure} + %a{pure} + def required_new_args?: () -> bool end end end diff --git a/sig/generated/rbs/inline/writer.rbs b/sig/generated/rbs/inline/writer.rbs index 9c4dbde..0d3ea2b 100644 --- a/sig/generated/rbs/inline/writer.rbs +++ b/sig/generated/rbs/inline/writer.rbs @@ -62,6 +62,10 @@ module RBS # @rbs rbs: _Content def translate_data_assign_decl: (AST::Declarations::DataAssignDecl decl, _Content rbs) -> void + # @rbs decl: AST::Declarations::StructAssignDecl + # @rbs rbs: _Content + def translate_struct_assign_decl: (AST::Declarations::StructAssignDecl decl, _Content rbs) -> void + # @rbs decl: AST::Declarations::SingletonClassDecl # @rbs rbs: _Content # @rbs return: void diff --git a/test/rbs/inline/annotation_parser_test.rb b/test/rbs/inline/annotation_parser_test.rb index 59d2b16..e88c779 100644 --- a/test/rbs/inline/annotation_parser_test.rb +++ b/test/rbs/inline/annotation_parser_test.rb @@ -697,7 +697,7 @@ def test_module_decl_annotation end end - def test_module_decl_annotation + def test_class_decl_annotation annots = AnnotationParser.parse(parse_comments(<<~RUBY)) # @rbs class Foo # @rbs class Foo[A < Integer] < Array[A] diff --git a/test/rbs/inline/parser_test.rb b/test/rbs/inline/parser_test.rb index 17c7841..4c25d2b 100644 --- a/test/rbs/inline/parser_test.rb +++ b/test/rbs/inline/parser_test.rb @@ -113,4 +113,49 @@ class Account end end end + + def test_struct_assign_decl + _, decls = Parser.parse(parse_ruby(<<~RUBY), opt_in: false) + Account = Struct.new( + :id, #: String + :email + ) + + class Account + # @rbs %a{rbs-inline:new-args=required} + # @rbs %a{rbs-inline:readonly-attributes=true} + Group = _ = Struct.new( + :name, #: String + keyword_init: true + ) + end + RUBY + + assert_equal 2, decls.size + decls[0].tap do |decl| + assert_instance_of AST::Declarations::StructAssignDecl, decl + attrs = decl.each_attribute.to_h + attrs[:id].tap do |type| + assert_equal "String", type.type.to_s + end + attrs[:email].tap do |type| + assert_nil type + end + assert_predicate decl, :keyword_init? + assert_predicate decl, :positional_init? + end + decls[1].tap do |decl| + assert_instance_of AST::Declarations::ClassDecl, decl + + decl.members[0].tap do |decl| + assert_instance_of AST::Declarations::StructAssignDecl, decl + attrs = decl.each_attribute.to_h + attrs[:name].tap do |type| + assert_equal "String", type.type.to_s + end + assert_predicate decl, :keyword_init? + refute_predicate decl, :positional_init? + end + end + end end diff --git a/test/rbs/inline/writer_test.rb b/test/rbs/inline/writer_test.rb index 29b05f1..8228963 100644 --- a/test/rbs/inline/writer_test.rb +++ b/test/rbs/inline/writer_test.rb @@ -897,4 +897,74 @@ def initialize: (untyped name) -> void end RBS end + + def test_struct_assign_decl + output = translate(<<~RUBY) + # Account record + # + Account = Struct.new( + "Account", + :id, #: Integer + :email, #: String + ) + + class Account + Group = _ = Struct.new( + :name, + keyword_init: true + ) + end + + Item = _ = Struct.new( + :sku, #: String + :price, #: Integer + keyword_init: false + ) + + # @rbs %a{rbs-inline:new-args=required} + # @rbs %a{rbs-inline:readonly-attributes=true} + User = Struct.new( + :name #: String + ) + RUBY + + assert_equal <<~RBS, output + # Account record + class Account < Struct[untyped] + attr_accessor id(): Integer + + attr_accessor email(): String + + def initialize: (?Integer id, ?String email) -> void + | (?id: Integer, ?email: String) -> void + end + + class Account + class Group < Struct[untyped] + attr_accessor name(): untyped + + def initialize: (?name: untyped) -> void + end + end + + class Item < Struct[untyped] + attr_accessor sku(): String + + attr_accessor price(): Integer + + def initialize: (?String sku, ?Integer price) -> void + end + + # @rbs %a{rbs-inline:new-args=required} + # @rbs %a{rbs-inline:readonly-attributes=true} + %a{rbs-inline:new-args=required} + %a{rbs-inline:readonly-attributes=true} + class User < Struct[untyped] + attr_reader name(): String + + def initialize: (String name) -> void + | (name: String) -> void + end + RBS + end end