Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split constant and method entries #1949

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/ruby_indexer/lib/ruby_indexer/collector.rb
Expand Up @@ -206,7 +206,7 @@ def handle_private_constant(node)

# The private_constant method does not resolve the constant name. It always points to a constant that needs to
# exist in the current namespace
entries = @index[fully_qualify_name(name)]
entries = @index.get_constant(fully_qualify_name(name))
entries&.each { |entry| entry.visibility = :private }
end

Expand Down
106 changes: 79 additions & 27 deletions lib/ruby_indexer/lib/ruby_indexer/index.rb
Expand Up @@ -12,15 +12,25 @@ class UnresolvableAliasError < StandardError; end

sig { void }
def initialize
# Holds all entries in the index using the following format:
# Holds all constant entries in the index using the following format:
# {
# "Foo" => [#<Entry::Class>, #<Entry::Class>],
# "Foo::Bar" => [#<Entry::Class>],
# }
@entries = T.let({}, T::Hash[String, T::Array[Entry]])
@constant_entries = T.let({}, T::Hash[String, T::Array[Entry]])

# Holds all entries in the index using a prefix tree for searching based on prefixes to provide autocompletion
@entries_tree = T.let(PrefixTree[T::Array[Entry]].new, PrefixTree[T::Array[Entry]])
@constant_entries_tree = T.let(PrefixTree[T::Array[Entry]].new, PrefixTree[T::Array[Entry]])

# Holds all method entries in the index using the following format:
# {
# "method_name" => [#<Entry::Method>, #<Entry::Method>],
# "foo" => [#<Entry::Accessor>],
# }
@method_entries = T.let({}, T::Hash[String, T::Array[Entry]])

# Holds all entries in the index using a prefix tree for searching based on prefixes to provide autocompletion
@method_entries_tree = T.let(PrefixTree[T::Array[Entry]].new, PrefixTree[T::Array[Entry]])

# Holds references to where entries where discovered so that we can easily delete them
# {
Expand All @@ -38,8 +48,16 @@ def delete(indexable)
# For each constant discovered in `path`, delete the associated entry from the index. If there are no entries
# left, delete the constant from the index.
@files_to_entries[indexable.full_path]&.each do |entry|
if entry.is_a?(Entry::Member)
entry_set = @method_entries
entry_tree = @method_entries_tree
else
entry_set = @constant_entries
entry_tree = @constant_entries_tree
end

name = entry.name
entries = @entries[name]
entries = entry_set[name]
next unless entries

# Delete the specific entry from the list for this name
Expand All @@ -48,10 +66,10 @@ def delete(indexable)
# If all entries were deleted, then remove the name from the hash and from the prefix tree. Otherwise, update
# the prefix tree with the current entries
if entries.empty?
@entries.delete(name)
@entries_tree.delete(name)
entry_set.delete(name)
entry_tree.delete(name)
else
@entries_tree.insert(name, entries)
entry_tree.insert(name, entries)
end
end

Expand All @@ -64,15 +82,28 @@ def delete(indexable)
sig { params(entry: Entry).void }
def <<(entry)
name = entry.name

(@entries[name] ||= []) << entry
(@files_to_entries[entry.file_path] ||= []) << entry
@entries_tree.insert(name, T.must(@entries[name]))

if entry.is_a?(Entry::Member)
entry_set = @method_entries
entry_tree = @method_entries_tree
else
entry_set = @constant_entries
entry_tree = @constant_entries_tree
end

(entry_set[name] ||= []) << entry
entry_tree.insert(name, T.must(entry_set[name]))
end

sig { params(fully_qualified_name: String).returns(T.nilable(T::Array[Entry])) }
def [](fully_qualified_name)
@entries[fully_qualified_name.delete_prefix("::")]
def get_constant(fully_qualified_name)
@constant_entries[fully_qualified_name.delete_prefix("::")]
end

sig { params(name: String).returns(T.nilable(T::Array[Entry])) }
def get_method(name)
@method_entries[name]
end

sig { params(query: String).returns(T::Array[IndexablePath]) }
Expand All @@ -94,17 +125,35 @@ def search_require_paths(query)
# ]
# ```
sig { params(query: String, nesting: T.nilable(T::Array[String])).returns(T::Array[T::Array[Entry]]) }
def prefix_search(query, nesting = nil)
def prefix_search_constants(query, nesting = nil)
unless nesting
results = @constant_entries_tree.search(query)
results.uniq!
return results
end

results = nesting.length.downto(0).flat_map do |i|
prefix = T.must(nesting[0...i]).join("::")
namespaced_query = prefix.empty? ? query : "#{prefix}::#{query}"
@constant_entries_tree.search(namespaced_query)
end

results.uniq!
results
end

sig { params(query: String, nesting: T.nilable(T::Array[String])).returns(T::Array[T::Array[Entry]]) }
def prefix_search_methods(query, nesting = nil)
unless nesting
results = @entries_tree.search(query)
results = @method_entries_tree.search(query)
results.uniq!
return results
end

results = nesting.length.downto(0).flat_map do |i|
prefix = T.must(nesting[0...i]).join("::")
namespaced_query = prefix.empty? ? query : "#{prefix}::#{query}"
@entries_tree.search(namespaced_query)
@method_entries_tree.search(namespaced_query)
end

results.uniq!
Expand All @@ -114,11 +163,14 @@ def prefix_search(query, nesting = nil)
# Fuzzy searches index entries based on Jaro-Winkler similarity. If no query is provided, all entries are returned
sig { params(query: T.nilable(String)).returns(T::Array[Entry]) }
def fuzzy_search(query)
return @entries.flat_map { |_name, entries| entries } unless query
unless query
constants = @constant_entries.flat_map { |_name, entries| entries }
return constants + @method_entries.flat_map { |_name, entries| entries }
end

normalized_query = query.gsub("::", "").downcase

results = @entries.filter_map do |name, entries|
results = @constant_entries.merge(@method_entries).filter_map do |name, entries|
similarity = DidYouMean::JaroWinkler.distance(name.gsub("::", "").downcase, normalized_query)
[entries, -similarity] if similarity > ENTRY_SIMILARITY_THRESHOLD
end
Expand All @@ -132,10 +184,10 @@ def fuzzy_search(query)
# 2. Foo::Baz
# 3. Baz
sig { params(name: String, nesting: T::Array[String]).returns(T.nilable(T::Array[Entry])) }
def resolve(name, nesting)
def resolve_constant(name, nesting)
if name.start_with?("::")
name = name.delete_prefix("::")
results = @entries[name] || @entries[follow_aliased_namespace(name)]
results = @constant_entries[name] || @constant_entries[follow_aliased_namespace(name)]
return results&.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e) : e }
end

Expand All @@ -150,7 +202,7 @@ def resolve(name, nesting)
# the LSP itself we alias `RubyLsp::Interface` to `LanguageServer::Protocol::Interface`, which means doing
# `RubyLsp::Interface::Location` is allowed. For these cases, we need some way to realize that the
# `RubyLsp::Interface` part is an alias, that has to be resolved
entries = @entries[full_name] || @entries[follow_aliased_namespace(full_name)]
entries = @constant_entries[full_name] || @constant_entries[follow_aliased_namespace(full_name)]
return entries.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e) : e } if entries
end

Expand Down Expand Up @@ -208,14 +260,14 @@ def index_single(indexable_path, source = nil)
# aliases, so we have to invoke `follow_aliased_namespace` again to check until we only return a real name
sig { params(name: String).returns(String) }
def follow_aliased_namespace(name)
return name if @entries[name]
return name if @constant_entries[name]

parts = name.split("::")
real_parts = []

(parts.length - 1).downto(0).each do |i|
current_name = T.must(parts[0..i]).join("::")
entry = @entries[current_name]&.first
entry = @constant_entries[current_name]&.first

case entry
when Entry::Alias
Expand All @@ -242,8 +294,8 @@ def follow_aliased_namespace(name)
# Returns `nil` if the method does not exist on that receiver
sig { params(method_name: String, receiver_name: String).returns(T.nilable(T::Array[Entry::Member])) }
def resolve_method(method_name, receiver_name)
method_entries = self[method_name]
owner_entries = self[receiver_name]
method_entries = get_method(method_name)
owner_entries = get_constant(receiver_name)
return unless owner_entries && method_entries

owner_name = T.must(owner_entries.first).name
Expand All @@ -261,18 +313,18 @@ def resolve_method(method_name, receiver_name)
# that doesn't exist, then we return the same UnresolvedAlias
sig { params(entry: Entry::UnresolvedAlias).returns(T.any(Entry::Alias, Entry::UnresolvedAlias)) }
def resolve_alias(entry)
target = resolve(entry.target, entry.nesting)
target = resolve_constant(entry.target, entry.nesting)
return entry unless target

target_name = T.must(target.first).name
resolved_alias = Entry::Alias.new(target_name, entry)

# Replace the UnresolvedAlias by a resolved one so that we don't have to do this again later
original_entries = T.must(@entries[entry.name])
original_entries = T.must(@constant_entries[entry.name])
original_entries.delete(entry)
original_entries << resolved_alias

@entries_tree.insert(entry.name, original_entries)
@constant_entries_tree.insert(entry.name, original_entries)

resolved_alias
end
Expand Down
44 changes: 22 additions & 22 deletions lib/ruby_indexer/test/classes_and_modules_test.rb
Expand Up @@ -161,10 +161,10 @@ class Foo
class Bar; end
RUBY

foo_entry = @index["Foo"].first
foo_entry = @index.get_constant("Foo").first
assert_equal("This is a Foo comment\nThis is another Foo comment", foo_entry.comments.join("\n"))

bar_entry = @index["Bar"].first
bar_entry = @index.get_constant("Bar").first
assert_equal("This Bar comment has 1 line padding", bar_entry.comments.join("\n"))
end

Expand All @@ -174,7 +174,7 @@ def test_skips_comments_containing_invalid_encodings
class Foo
end
RUBY
assert(@index["Foo"].first)
assert(@index.get_constant("Foo").first)
end

def test_comments_can_be_attached_to_a_namespaced_class
Expand All @@ -187,10 +187,10 @@ class Bar; end
end
RUBY

foo_entry = @index["Foo"].first
foo_entry = @index.get_constant("Foo").first
assert_equal("This is a Foo comment\nThis is another Foo comment", foo_entry.comments.join("\n"))

bar_entry = @index["Foo::Bar"].first
bar_entry = @index.get_constant("Foo::Bar").first
assert_equal("This is a Bar comment", bar_entry.comments.join("\n"))
end

Expand All @@ -203,10 +203,10 @@ class Foo; end
class Foo; end
RUBY

first_foo_entry = @index["Foo"][0]
first_foo_entry = @index.get_constant("Foo")[0]
assert_equal("This is a Foo comment", first_foo_entry.comments.join("\n"))

second_foo_entry = @index["Foo"][1]
second_foo_entry = @index.get_constant("Foo")[1]
assert_equal("This is another Foo comment", second_foo_entry.comments.join("\n"))
end

Expand All @@ -219,10 +219,10 @@ class Foo; end
class Bar; end
RUBY

first_foo_entry = @index["Foo"][0]
first_foo_entry = @index.get_constant("Foo")[0]
assert_equal("This is a Foo comment", first_foo_entry.comments.join("\n"))

second_foo_entry = @index["Bar"][0]
second_foo_entry = @index.get_constant("Bar")[0]
assert_equal("This is a Bar comment", second_foo_entry.comments.join("\n"))
end

Expand All @@ -239,13 +239,13 @@ class D; end
end
RUBY

b_const = @index["A::B"].first
b_const = @index.get_constant("A::B").first
assert_equal(:private, b_const.visibility)

c_const = @index["A::C"].first
c_const = @index.get_constant("A::C").first
assert_equal(:private, c_const.visibility)

d_const = @index["A::D"].first
d_const = @index.get_constant("A::D").first
assert_equal(:public, d_const.visibility)
end

Expand All @@ -269,16 +269,16 @@ class FinalThing < Something::Baz
end
RUBY

foo = T.must(@index["Foo"].first)
foo = T.must(@index.get_constant("Foo").first)
assert_equal("Bar", foo.parent_class)

baz = T.must(@index["Baz"].first)
baz = T.must(@index.get_constant("Baz").first)
assert_nil(baz.parent_class)

qux = T.must(@index["Something::Qux"].first)
qux = T.must(@index.get_constant("Something::Qux").first)
assert_equal("::Baz", qux.parent_class)

final_thing = T.must(@index["FinalThing"].first)
final_thing = T.must(@index.get_constant("FinalThing").first)
assert_equal("Something::Baz", final_thing.parent_class)
end

Expand Down Expand Up @@ -318,13 +318,13 @@ class ConstantPathReferences
end
RUBY

foo = T.must(@index["Foo"][0])
foo = T.must(@index.get_constant("Foo")[0])
assert_equal(["A1", "A2", "A3", "A4", "A5", "A6"], foo.included_modules)

qux = T.must(@index["Foo::Qux"][0])
qux = T.must(@index.get_constant("Foo::Qux")[0])
assert_equal(["Corge", "Corge", "Baz"], qux.included_modules)

constant_path_references = T.must(@index["ConstantPathReferences"][0])
constant_path_references = T.must(@index.get_constant("ConstantPathReferences")[0])
assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.included_modules)
end

Expand Down Expand Up @@ -364,13 +364,13 @@ class ConstantPathReferences
end
RUBY

foo = T.must(@index["Foo"][0])
foo = T.must(@index.get_constant("Foo")[0])
assert_equal(["A1", "A2", "A3", "A4", "A5", "A6"], foo.prepended_modules)

qux = T.must(@index["Foo::Qux"][0])
qux = T.must(@index.get_constant("Foo::Qux")[0])
assert_equal(["Corge", "Corge", "Baz"], qux.prepended_modules)

constant_path_references = T.must(@index["ConstantPathReferences"][0])
constant_path_references = T.must(@index.get_constant("ConstantPathReferences")[0])
assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.prepended_modules)
end
end
Expand Down