# typed: strict
# frozen_string_literal: true
module RubyIndexer
class Entry
#: String
attr_reader :name
#: URI::Generic
attr_reader :uri
#: RubyIndexer::Location
attr_reader :location
alias_method :name_location, :location
#: Symbol
attr_accessor :visibility
#: (String name, URI::Generic uri, Location location, String? comments) -> void
def initialize(name, uri, location, comments)
@name = name
@uri = uri
@comments = comments
@visibility = :public #: Symbol
@location = location
end
#: -> bool
def public?
@visibility == :public
end
#: -> bool
def protected?
@visibility == :protected
end
#: -> bool
def private?
@visibility == :private
end
#: -> String
def file_name
if @uri.scheme == "untitled"
@uri.opaque #: as !nil
else
File.basename(
file_path, #: as !nil
)
end
end
#: -> String?
def file_path
@uri.full_path
end
#: -> String
def comments
@comments ||= begin
# Parse only the comments based on the file path, which is much faster than parsing the entire file
path = file_path
parsed_comments = path ? Prism.parse_file_comments(path) : []
# Group comments based on whether they belong to a single block of comments
grouped = parsed_comments.slice_when do |left, right|
left.location.start_line + 1 != right.location.start_line
end
# Find the group that is either immediately or two lines above the current entry
correct_group = grouped.find do |group|
comment_end_line = group.last.location.start_line
(comment_end_line..comment_end_line + 1).cover?(@location.start_line - 1)
end
# If we found something, we join the comments together. Otherwise, the entry has no documentation and we don't
# want to accidentally re-parse it, so we set it to an empty string. If an entry is updated, the entire entry
# object is dropped, so this will not prevent updates
if correct_group
correct_group.filter_map do |comment|
content = comment.slice.chomp
if content.valid_encoding?
content.delete_prefix!("#")
content.delete_prefix!(" ")
content
end
end.join("\n")
else
""
end
rescue Errno::ENOENT
# If the file was deleted, but the entry hasn't been removed yet (could happen due to concurrency), then we do
# not want to fail. Just set the comments to an empty string
""
end
end
class ModuleOperation
extend T::Helpers
abstract!
#: String
attr_reader :module_name
#: (String module_name) -> void
def initialize(module_name)
@module_name = module_name
end
end
class Include < ModuleOperation; end
class Prepend < ModuleOperation; end
class Namespace < Entry
extend T::Helpers
abstract!
#: Array[String]
attr_reader :nesting
# Returns the location of the constant name, excluding the parent class or the body
#: Location
attr_reader :name_location
#: (Array[String] nesting, URI::Generic uri, Location location, Location name_location, String? comments) -> void
def initialize(nesting, uri, location, name_location, comments)
@name = nesting.join("::") #: String
# The original nesting where this namespace was discovered
@nesting = nesting
super(@name, uri, location, comments)
@name_location = name_location
end
#: -> Array[String]
def mixin_operation_module_names
mixin_operations.map(&:module_name)
end
# Stores all explicit prepend, include and extend operations in the exact order they were discovered in the source
# code. Maintaining the order is essential to linearize ancestors the right way when a module is both included
# and prepended
#: -> Array[ModuleOperation]
def mixin_operations
@mixin_operations ||= [] #: Array[ModuleOperation]?
end
#: -> Integer
def ancestor_hash
mixin_operation_module_names.hash
end
end
class Module < Namespace
end
class Class < Namespace
# The unresolved name of the parent class. This may return `nil`, which indicates the lack of an explicit parent
# and therefore ::Object is the correct parent class
#: String?
attr_reader :parent_class
#: (Array[String] nesting, URI::Generic uri, Location location, Location name_location, String? comments, String? parent_class) -> void
def initialize(nesting, uri, location, name_location, comments, parent_class) # rubocop:disable Metrics/ParameterLists
super(nesting, uri, location, name_location, comments)
@parent_class = parent_class
end
# @override
#: -> Integer
def ancestor_hash
[mixin_operation_module_names, @parent_class].hash
end
end
class SingletonClass < Class
#: (Location location, Location name_location, String? comments) -> void
def update_singleton_information(location, name_location, comments)
@location = location
@name_location = name_location
(@comments ||= +"") << comments if comments
end
end
class Constant < Entry
end
class Parameter
extend T::Helpers
abstract!
# Name includes just the name of the parameter, excluding symbols like splats
#: Symbol
attr_reader :name
# Decorated name is the parameter name including the splat or block prefix, e.g.: `*foo`, `**foo` or `&block`
alias_method :decorated_name, :name
#: (name: Symbol) -> void
def initialize(name:)
@name = name
end
end
# A required method parameter, e.g. `def foo(a)`
class RequiredParameter < Parameter
end
# An optional method parameter, e.g. `def foo(a = 123)`
class OptionalParameter < Parameter
# @override
#: -> Symbol
def decorated_name
:"#{@name} = <default>"
end
end
# An required keyword method parameter, e.g. `def foo(a:)`
class KeywordParameter < Parameter
# @override
#: -> Symbol
def decorated_name
:"#{@name}:"
end
end
# An optional keyword method parameter, e.g. `def foo(a: 123)`
class OptionalKeywordParameter < Parameter
# @override
#: -> Symbol
def decorated_name
:"#{@name}: <default>"
end
end
# A rest method parameter, e.g. `def foo(*a)`
class RestParameter < Parameter
DEFAULT_NAME = :"<anonymous splat>" #: Symbol
# @override
#: -> Symbol
def decorated_name
:"*#{@name}"
end
end
# A keyword rest method parameter, e.g. `def foo(**a)`
class KeywordRestParameter < Parameter
DEFAULT_NAME = :"<anonymous keyword splat>" #: Symbol
# @override
#: -> Symbol
def decorated_name
:"**#{@name}"
end
end
# A block method parameter, e.g. `def foo(&block)`
class BlockParameter < Parameter
DEFAULT_NAME = :"<anonymous block>" #: Symbol
class << self
#: -> BlockParameter
def anonymous
new(name: DEFAULT_NAME)
end
end
# @override
#: -> Symbol
def decorated_name
:"&#{@name}"
end
end
# A forwarding method parameter, e.g. `def foo(...)`
class ForwardingParameter < Parameter
#: -> void
def initialize
# You can't name a forwarding parameter, it's always called `...`
super(name: :"...")
end
end
class Member < Entry
extend T::Sig
extend T::Helpers
abstract!
#: Entry::Namespace?
attr_reader :owner
#: (String name, URI::Generic uri, Location location, String? comments, Symbol visibility, Entry::Namespace? owner) -> void
def initialize(name, uri, location, comments, visibility, owner) # rubocop:disable Metrics/ParameterLists
super(name, uri, location, comments)
@visibility = visibility
@owner = owner
end
sig { abstract.returns(T::Array[Entry::Signature]) }
def signatures; end
#: -> String
def decorated_parameters
first_signature = signatures.first
return "()" unless first_signature
"(#{first_signature.format})"
end
#: -> String
def formatted_signatures
overloads_count = signatures.size
case overloads_count
when 1
""
when 2
"\n(+1 overload)"
else
"\n(+#{overloads_count - 1} overloads)"
end
end
end
class Accessor < Member
# @override
#: -> Array[Signature]
def signatures
@signatures ||= begin
params = []
params << RequiredParameter.new(name: name.delete_suffix("=").to_sym) if name.end_with?("=")
[Entry::Signature.new(params)]
end #: Array[Signature]?
end
end
class Method < Member
#: Array[Signature]
attr_reader :signatures
# Returns the location of the method name, excluding parameters or the body
#: Location
attr_reader :name_location
#: (String name, URI::Generic uri, Location location, Location name_location, String? comments, Array[Signature] signatures, Symbol visibility, Entry::Namespace? owner) -> void
def initialize(name, uri, location, name_location, comments, signatures, visibility, owner) # rubocop:disable Metrics/ParameterLists
super(name, uri, location, comments, visibility, owner)
@signatures = signatures
@name_location = name_location
end
end
# An UnresolvedAlias points to a constant alias with a right hand side that has not yet been resolved. For
# example, if we find
#
# ```ruby
# CONST = Foo
# ```
# Before we have discovered `Foo`, there's no way to eagerly resolve this alias to the correct target constant.
# All aliases are inserted as UnresolvedAlias in the index first and then we lazily resolve them to the correct
# target in [rdoc-ref:Index#resolve]. If the right hand side contains a constant that doesn't exist, then it's not
# possible to resolve the alias and it will remain an UnresolvedAlias until the right hand side constant exists
class UnresolvedConstantAlias < Entry
#: String
attr_reader :target
#: Array[String]
attr_reader :nesting
#: (String target, Array[String] nesting, String name, URI::Generic uri, Location location, String? comments) -> void
def initialize(target, nesting, name, uri, location, comments) # rubocop:disable Metrics/ParameterLists
super(name, uri, location, comments)
@target = target
@nesting = nesting
end
end
# Alias represents a resolved alias, which points to an existing constant target
class ConstantAlias < Entry
#: String
attr_reader :target
#: (String target, UnresolvedConstantAlias unresolved_alias) -> void
def initialize(target, unresolved_alias)
super(
unresolved_alias.name,
unresolved_alias.uri,
unresolved_alias.location,
unresolved_alias.comments,
)
@visibility = unresolved_alias.visibility
@target = target
end
end
# Represents a global variable e.g.: $DEBUG
class GlobalVariable < Entry; end
# Represents a class variable e.g.: @@a = 1
class ClassVariable < Entry
#: Entry::Namespace?
attr_reader :owner
#: (String name, URI::Generic uri, Location location, String? comments, Entry::Namespace? owner) -> void
def initialize(name, uri, location, comments, owner)
super(name, uri, location, comments)
@owner = owner
end
end
# Represents an instance variable e.g.: @a = 1
class InstanceVariable < Entry
#: Entry::Namespace?
attr_reader :owner
#: (String name, URI::Generic uri, Location location, String? comments, Entry::Namespace? owner) -> void
def initialize(name, uri, location, comments, owner)
super(name, uri, location, comments)
@owner = owner
end
end
# An unresolved method alias is an alias entry for which we aren't sure what the right hand side points to yet. For
# example, if we have `alias a b`, we create an unresolved alias for `a` because we aren't sure immediate what `b`
# is referring to
class UnresolvedMethodAlias < Entry
#: String
attr_reader :new_name, :old_name
#: Entry::Namespace?
attr_reader :owner
#: (String new_name, String old_name, Entry::Namespace? owner, URI::Generic uri, Location location, String? comments) -> void
def initialize(new_name, old_name, owner, uri, location, comments) # rubocop:disable Metrics/ParameterLists
super(new_name, uri, location, comments)
@new_name = new_name
@old_name = old_name
@owner = owner
end
end
# A method alias is a resolved alias entry that points to the exact method target it refers to
class MethodAlias < Entry
#: (Member | MethodAlias)
attr_reader :target
#: Entry::Namespace?
attr_reader :owner
#: ((Member | MethodAlias) target, UnresolvedMethodAlias unresolved_alias) -> void
def initialize(target, unresolved_alias)
full_comments = +"Alias for #{target.name}\n"
full_comments << "#{unresolved_alias.comments}\n"
full_comments << target.comments
super(
unresolved_alias.new_name,
unresolved_alias.uri,
unresolved_alias.location,
full_comments,
)
@target = target
@owner = unresolved_alias.owner #: Entry::Namespace?
end
#: -> String
def decorated_parameters
@target.decorated_parameters
end
#: -> String
def formatted_signatures
@target.formatted_signatures
end
#: -> Array[Signature]
def signatures
@target.signatures
end
end
# Ruby doesn't support method overloading, so a method will have only one signature.
# However RBS can represent the concept of method overloading, with different return types based on the arguments
# passed, so we need to store all the signatures.
class Signature
#: Array[Parameter]
attr_reader :parameters
#: (Array[Parameter] parameters) -> void
def initialize(parameters)
@parameters = parameters
end
# Returns a string with the decorated names of the parameters of this member. E.g.: `(a, b = 1, c: 2)`
#: -> String
def format
@parameters.map(&:decorated_name).join(", ")
end
# Returns `true` if the given call node arguments array matches this method signature. This method will prefer
# returning `true` for situations that cannot be analyzed statically, like the presence of splats, keyword splats
# or forwarding arguments.
#
# Since this method is used to detect which overload should be displayed in signature help, it will also return
# `true` if there are missing arguments since the user may not be done typing yet. For example:
#
# ```ruby
# def foo(a, b); end
# # All of the following are considered matches because the user might be in the middle of typing and we have to
# # show them the signature
# foo
# foo(1)
# foo(1, 2)
# ```
#: (Array[Prism::Node] arguments) -> bool
def matches?(arguments)
min_pos = 0
max_pos = 0 #: (Integer | Float)
names = []
has_forward = false #: bool
has_keyword_rest = false #: bool
@parameters.each do |param|
case param
when RequiredParameter
min_pos += 1
max_pos += 1
when OptionalParameter
max_pos += 1
when RestParameter
max_pos = Float::INFINITY
when ForwardingParameter
max_pos = Float::INFINITY
has_forward = true
when KeywordParameter, OptionalKeywordParameter
names << param.name
when KeywordRestParameter
has_keyword_rest = true
end
end
keyword_hash_nodes, positional_args = arguments.partition { |arg| arg.is_a?(Prism::KeywordHashNode) }
keyword_args = keyword_hash_nodes.first #: as Prism::KeywordHashNode?
&.elements
forwarding_arguments, positionals = positional_args.partition do |arg|
arg.is_a?(Prism::ForwardingArgumentsNode)
end
return true if has_forward && min_pos == 0
# If the only argument passed is a forwarding argument, then anything will match
(positionals.empty? && forwarding_arguments.any?) ||
(
# Check if positional arguments match. This includes required, optional, rest arguments. We also need to
# verify if there's a trailing forwarding argument, like `def foo(a, ...); end`
positional_arguments_match?(positionals, forwarding_arguments, keyword_args, min_pos, max_pos) &&
# If the positional arguments match, we move on to checking keyword, optional keyword and keyword rest
# arguments. If there's a forward argument, then it will always match. If the method accepts a keyword rest
# (**kwargs), then we can't analyze statically because the user could be passing a hash and we don't know
# what the runtime values inside the hash are.
#
# If none of those match, then we verify if the user is passing the expect names for the keyword arguments
(has_forward || has_keyword_rest || keyword_arguments_match?(keyword_args, names))
)
end
#: (Array[Prism::Node] positional_args, Array[Prism::Node] forwarding_arguments, Array[Prism::Node]? keyword_args, Integer min_pos, (Integer | Float) max_pos) -> bool
def positional_arguments_match?(positional_args, forwarding_arguments, keyword_args, min_pos, max_pos)
# If the method accepts at least one positional argument and a splat has been passed
(min_pos > 0 && positional_args.any? { |arg| arg.is_a?(Prism::SplatNode) }) ||
# If there's at least one positional argument unaccounted for and a keyword splat has been passed
(min_pos - positional_args.length > 0 && keyword_args&.any? { |arg| arg.is_a?(Prism::AssocSplatNode) }) ||
# If there's at least one positional argument unaccounted for and a forwarding argument has been passed
(min_pos - positional_args.length > 0 && forwarding_arguments.any?) ||
# If the number of positional arguments is within the expected range
(min_pos > 0 && positional_args.length <= max_pos) ||
(min_pos == 0 && positional_args.empty?)
end
#: (Array[Prism::Node]? args, Array[Symbol] names) -> bool
def keyword_arguments_match?(args, names)
return true unless args
return true if args.any? { |arg| arg.is_a?(Prism::AssocSplatNode) }
arg_names = args.filter_map do |arg|
next unless arg.is_a?(Prism::AssocNode)
key = arg.key
key.value&.to_sym if key.is_a?(Prism::SymbolNode)
end
(arg_names - names).empty?
end
end
end
end