Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/inline.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ end

It detects method definitions and allows you to add annotation comments to describe their types.

Methods defined inside `class << self` are singleton methods of the enclosing class or module.

```ruby
class Calculator
class << self
# @rbs () -> Integer
def zero = 0
end
end
```

### Unannotated method definition

Methods defined with `def` syntax are detected, but their inferred type depends on whether a super method exists.
Expand Down Expand Up @@ -352,7 +363,10 @@ end
### Current Limitations

- Method visibility declaration is not supported yet
- The `class << self` syntax is not supported
- Inside `class << self`, only method definitions are supported
- Mixins and attributes are not supported yet
- Classes, modules, constants, nested `class << self`, and `def self.foo` are reported because RBS cannot describe them
- Top-level `class << self` and `class << obj` are not supported
- Top-level method definitions (outside any class/module) are not supported
- Method definitions with a non-self receiver (e.g. `def obj.foo`) are not supported

Expand Down
23 changes: 23 additions & 0 deletions lib/rbs/ast/ruby/declarations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,29 @@ def type_fingerprint
end
end

class SingletonClassDecl < Base
attr_reader :members

attr_reader :node

def initialize(buffer, node)
super(buffer)
@node = node
@members = []
end

def location
rbs_location(node.location)
end

def type_fingerprint
[
"decls/singleton_class",
members.map { _1.type_fingerprint }
]
end
end

class ConstantDecl < Base
attr_reader :leading_comment
attr_reader :constant_name
Expand Down
7 changes: 7 additions & 0 deletions lib/rbs/cli/validate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ def validate_class_module_definition
@validator.validate_method_definition(member, type_name: name)
when AST::Ruby::Members::MixinMember
InvalidTypeApplicationError.check!(type_name: member.module_name, params: mixin_type_params(member.module_name), args: member.type_args, location: member.location)
when AST::Ruby::Declarations::SingletonClassDecl
member.members.each do |singleton_member|
case singleton_member
when AST::Ruby::Members::DefMember
@validator.validate_method_definition(singleton_member, type_name: name)
end
end
end
end
else
Expand Down
8 changes: 8 additions & 0 deletions lib/rbs/definition_builder/method_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ def build_singleton(type_name)
if member.singleton?
build_method(methods, type, member: member, accessibility: :public)
end
when AST::Ruby::Declarations::SingletonClassDecl
member.members.each do |singleton_member|
case singleton_member
when AST::Ruby::Members::DefMember
# Instance methods of the singleton class are singleton methods of the class
build_method(methods, type, member: singleton_member, accessibility: :public)
end
end
end
end
end
Expand Down
10 changes: 10 additions & 0 deletions lib/rbs/environment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,9 @@ def insert_ruby_decl(decl, context:, namespace:)
insert_ruby_decl(member, context: inner_context, namespace: name.to_namespace)
end

when AST::Ruby::Declarations::SingletonClassDecl
# `class << self` defines no constant; its members are read through the enclosing class entry

when AST::Ruby::Declarations::ConstantDecl
name = decl.constant_name.with_prefix(namespace)

Expand Down Expand Up @@ -757,6 +760,13 @@ def resolve_ruby_decl(resolver, decl, context:, prefix:)
end
end

when AST::Ruby::Declarations::SingletonClassDecl
AST::Ruby::Declarations::SingletonClassDecl.new(decl.buffer, decl.node).tap do |resolved|
decl.members.each do |member|
resolved.members << resolve_ruby_member(resolver, member, context: context)
end
end

when AST::Ruby::Declarations::ConstantDecl
full_name = decl.constant_name.with_prefix(prefix)

Expand Down
101 changes: 84 additions & 17 deletions lib/rbs/inline_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ def initialize(location, message)
MixinNonConstantModule = _ = Class.new(Base)
AttributeNonSymbolName = _ = Class.new(Base)
ClassModuleAliasDeclarationMissingTypeName = _ = Class.new(Base)
NestedSingletonScope = _ = Class.new(Base)
TopLevelSingletonScope = _ = Class.new(Base)
NonSelfSingletonScope = _ = Class.new(Base)
ConstantInSingletonScope = _ = Class.new(Base)
end

def self.parse(buffer, prism)
Expand Down Expand Up @@ -102,6 +106,7 @@ def skip_node?(node)

def visit_class_node(node)
return if skip_node?(node)
return if reject_constant_in_singleton_class(node.constant_path.location)

unless class_name = constant_as_type_name(node.constant_path)
diagnostics << Diagnostic::NonConstantClassName.new(
Expand All @@ -124,6 +129,7 @@ def visit_class_node(node)

def visit_module_node(node)
return if skip_node?(node)
return if reject_constant_in_singleton_class(node.constant_path.location)

unless module_name = constant_as_type_name(node.constant_path)
diagnostics << Diagnostic::NonConstantModuleName.new(
Expand Down Expand Up @@ -164,6 +170,38 @@ def visit_module_node(node)
visit_class_or_module_body(module_decl, node)
end

def visit_singleton_class_node(node)
return if skip_node?(node)

case current = current_module
when AST::Ruby::Declarations::ClassDecl, AST::Ruby::Declarations::ModuleDecl
unless node.expression.is_a?(Prism::SelfNode)
diagnostics << Diagnostic::NonSelfSingletonScope.new(
rbs_location(node.expression.location),
"Non-self expression in `class << ...` opens an anonymous singleton class, which has no representation in RBS"
)
return
end

singleton_decl = AST::Ruby::Declarations::SingletonClassDecl.new(buffer, node)
current.members << singleton_decl

push_module_nesting(singleton_decl) do
visit_child_nodes(node)
end
when AST::Ruby::Declarations::SingletonClassDecl
diagnostics << Diagnostic::NestedSingletonScope.new(
rbs_location(node.class_keyword_loc),
"Nested `class << self` opens the singleton class of a singleton class, which has no representation in RBS"
)
when nil
diagnostics << Diagnostic::TopLevelSingletonScope.new(
rbs_location(node.class_keyword_loc),
"Top-level `class << self` opens the singleton class of `main`, which has no representation in RBS"
)
end
end

def visit_class_or_module_body(decl, node)
push_module_nesting(decl) do
visit_child_nodes(node)
Expand Down Expand Up @@ -210,10 +248,21 @@ def visit_def_node(node)
return
end

current = current_module

if current.is_a?(AST::Ruby::Declarations::SingletonClassDecl) && node.receiver
diagnostics << Diagnostic::NestedSingletonScope.new(
rbs_location(node.receiver.location),
"Method definition with self receiver inside `class << self` targets the singleton class of a singleton class, which has no representation in RBS"
)
return
end

# `def foo` inside `class << self` is kept as an instance method of the SingletonClassDecl
kind = node.receiver ? :singleton : :instance #: :singleton | :instance

case current = current_module
when AST::Ruby::Declarations::ClassDecl, AST::Ruby::Declarations::ModuleDecl
case current
when AST::Ruby::Declarations::ClassDecl, AST::Ruby::Declarations::ModuleDecl, AST::Ruby::Declarations::SingletonClassDecl
leading_block = comments.leading_block!(node)

if node.end_keyword_loc
Expand Down Expand Up @@ -250,13 +299,23 @@ def visit_call_node(node)
case current = current_module
when AST::Ruby::Declarations::ClassDecl, AST::Ruby::Declarations::ModuleDecl
parse_mixin_call(node)
when AST::Ruby::Declarations::SingletonClassDecl
diagnostics << Diagnostic::NotImplementedYet.new(
rbs_location(node.message_loc || node.location),
"Mixin call inside `class << self` is not supported"
)
end
when :attr_reader, :attr_writer, :attr_accessor
return if skip_node?(node)

case current = current_module
when AST::Ruby::Declarations::ClassDecl, AST::Ruby::Declarations::ModuleDecl
parse_attribute_call(node)
when AST::Ruby::Declarations::SingletonClassDecl
diagnostics << Diagnostic::NotImplementedYet.new(
rbs_location(node.message_loc || node.location),
"Attribute definition inside `class << self` is not supported"
)
when nil
# Top-level attribute definition
diagnostics << Diagnostic::TopLevelAttributeDefinition.new(
Expand All @@ -271,17 +330,33 @@ def visit_call_node(node)

def visit_constant_write_node(node)
return if skip_node?(node)
return if reject_constant_in_singleton_class(node.name_loc)

# Parse constant declaration (both top-level and in classes/modules)
parse_constant_declaration(node)
end

def visit_constant_path_write_node(node)
return if skip_node?(node)
return if reject_constant_in_singleton_class(node.target.location)

parse_constant_declaration(node)
end

# Constants defined inside `class << self` belong to the singleton class, not to the enclosing class.
# Returns `true` and reports a diagnostic if the current scope is a singleton class.
def reject_constant_in_singleton_class(location)
if current_module.is_a?(AST::Ruby::Declarations::SingletonClassDecl)
diagnostics << Diagnostic::ConstantInSingletonScope.new(
rbs_location(location),
"Constant definition inside `class << self` belongs to the singleton class, which has no representation in RBS"
)
true
else
false
end
end

def parse_mixin_call(node)
# Check for multiple arguments
if node.arguments && node.arguments.arguments.length > 1
Expand Down Expand Up @@ -469,13 +544,7 @@ def parse_constant_declaration(node)
alias_annotation
)

# Insert the alias declaration appropriately

if current_module
current_module.members << alias_decl
else
result.declarations << alias_decl
end
insert_declaration(alias_decl)
else
# Create regular constant declaration
constant_decl = AST::Ruby::Declarations::ConstantDecl.new(
Expand All @@ -486,18 +555,16 @@ def parse_constant_declaration(node)
type_annotation
)

# Insert the constant declaration appropriately
if current_module
current_module.members << constant_decl
else
result.declarations << constant_decl
end
insert_declaration(constant_decl)
end
end

def insert_declaration(decl)
if current_module
current_module.members << decl
case current = current_module
when AST::Ruby::Declarations::ClassDecl, AST::Ruby::Declarations::ModuleDecl
current.members << decl
when AST::Ruby::Declarations::SingletonClassDecl
raise "Declarations inside `class << self` must be rejected before insertion"
else
result.declarations << decl
end
Expand Down
21 changes: 20 additions & 1 deletion sig/ast/ruby/declarations.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module RBS
module AST
module Ruby
module Declarations
type t = ClassDecl | ModuleDecl | ConstantDecl | ClassModuleAliasDecl
type t = ClassDecl | ModuleDecl | SingletonClassDecl | ConstantDecl | ClassModuleAliasDecl

class Base
attr_reader buffer: Buffer
Expand Down Expand Up @@ -87,6 +87,25 @@ module RBS
def type_fingerprint: () -> untyped
end

# `class << self` in a class or module body
#
# Members are kept as written: `def foo` inside the body is an instance method of the singleton class,
# and consumers interpret it as a singleton method of the enclosing class.
#
class SingletonClassDecl < Base
type member = Members::t

attr_reader node: Prism::SingletonClassNode

attr_reader members: Array[member]

def initialize: (Buffer, Prism::SingletonClassNode) -> void

def location: () -> Location

def type_fingerprint: () -> untyped
end

class ConstantDecl < Base
type node = Prism::ConstantWriteNode | Prism::ConstantPathWriteNode

Expand Down
28 changes: 25 additions & 3 deletions sig/inline_parser.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ module RBS
class ClassModuleAliasDeclarationMissingTypeName < Base
end

class NestedSingletonScope < Base
end

class TopLevelSingletonScope < Base
end

class NonSelfSingletonScope < Base
end

class ConstantInSingletonScope < Base
end

type t = NotImplementedYet
| NonConstantClassName | NonConstantModuleName | NonConstantSuperClassName
| TopLevelMethodDefinition | TopLevelAttributeDefinition
Expand All @@ -69,12 +81,16 @@ module RBS
| AttributeNonSymbolName
| NonConstantConstantDeclaration
| ClassModuleAliasDeclarationMissingTypeName
| NestedSingletonScope
| TopLevelSingletonScope
| NonSelfSingletonScope
| ConstantInSingletonScope
end

def self.parse: (Buffer, Prism::ParseResult) -> Result

class Parser < Prism::Visitor
type module_context = Declarations::ClassDecl | Declarations::ModuleDecl
type module_context = Declarations::ClassDecl | Declarations::ModuleDecl | Declarations::SingletonClassDecl

include AST::Ruby::Helpers::ConstantHelper

Expand Down Expand Up @@ -104,13 +120,19 @@ module RBS
#
def skip_node?: (Prism::Node) -> bool

def insert_declaration: (module_context) -> void
def insert_declaration: (Declarations::t) -> void

def report_unused_annotation: (*AST::Ruby::Annotations::t | nil | AST::Ruby::CommentBlock::AnnotationSyntaxError) -> void

def report_unused_block: (AST::Ruby::CommentBlock) -> void

def visit_class_or_module_body: (module_context, Prism::ClassNode | Prism::ModuleNode) -> void
def visit_class_or_module_body: (Declarations::ClassDecl | Declarations::ModuleDecl, Prism::ClassNode | Prism::ModuleNode) -> void

def visit_singleton_class_node: (Prism::SingletonClassNode) -> void

# Reports `ConstantInSingletonScope` and returns `true` if the current scope is `class << self`
#
def reject_constant_in_singleton_class: (Prism::Location) -> bool

private

Expand Down
Loading
Loading