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

Index documents on modification #2941

Merged
merged 1 commit into from
Jan 7, 2025
Merged
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
4 changes: 0 additions & 4 deletions jekyll/index.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,6 @@ Sorry, your browser doesn't support embedded videos. This video demonstrates the

### Completion

{: .note }
There is currently a technical limitation that [code is only indexed when saved](https://github.com/Shopify/ruby-lsp/issues/1908).
This means if you add, for example, a new method to its called, its completion will not be available until you save that file.

The completion feature provides users with completion candidates when the text they type matches certain indexed components. This helps speed up coding by reducing the need to type out full method names or constants.
It also allows developers to discover constants or methods that are available to them.

Expand Down
31 changes: 21 additions & 10 deletions lib/ruby_indexer/lib/ruby_indexer/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def initialize
)

@configuration = T.let(RubyIndexer::Configuration.new, Configuration)

@initial_indexing_completed = T.let(false, T::Boolean)
end

# Register an included `hook` that will be executed when `module_name` is included into any namespace
Expand All @@ -56,8 +58,8 @@ def register_included_hook(module_name, &hook)
(@included_hooks[module_name] ||= []) << hook
end

sig { params(uri: URI::Generic).void }
def delete(uri)
sig { params(uri: URI::Generic, skip_require_paths_tree: T::Boolean).void }
def delete(uri, skip_require_paths_tree: false)
key = uri.to_s
# 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.
Expand All @@ -80,6 +82,7 @@ def delete(uri)
end

@uris_to_entries.delete(key)
return if skip_require_paths_tree

require_path = uri.require_path
@require_paths_tree.delete(require_path) if require_path
Expand Down Expand Up @@ -357,12 +360,13 @@ def index_all(uris: @configuration.indexable_uris, &block)
# When troubleshooting an indexing issue, e.g. through irb, it's not obvious that `index_all` will augment the
# existing index values, meaning it may contain 'stale' entries. This check ensures that the user is aware of this
# behavior and can take appropriate action.
# binding.break
if @entries.any?
if @initial_indexing_completed
raise IndexNotEmptyError,
"The index is not empty. To prevent invalid entries, `index_all` can only be called once."
end

@initial_indexing_completed = true

RBSIndexer.new(self).index_ruby_core
# Calculate how many paths are worth 1% of progress
progress_step = (uris.length / 100.0).ceil
Expand Down Expand Up @@ -618,17 +622,24 @@ def class_variable_completion_candidates(name, owner_name)
end

# Synchronizes a change made to the given URI. This method will ensure that new declarations are indexed, removed
# declarations removed and that the ancestor linearization cache is cleared if necessary
sig { params(uri: URI::Generic, source: String).void }
def handle_change(uri, source)
# declarations removed and that the ancestor linearization cache is cleared if necessary. If a block is passed, the
# consumer of this API has to handle deleting and inserting/updating entries in the index instead of passing the
# document's source (used to handle unsaved changes to files)
sig do
params(uri: URI::Generic, source: T.nilable(String), block: T.nilable(T.proc.params(index: Index).void)).void
end
def handle_change(uri, source = nil, &block)
key = uri.to_s
original_entries = @uris_to_entries[key]

delete(uri)
index_single(uri, source)
if block
block.call(self)
else
delete(uri)
index_single(uri, T.must(source))
end

updated_entries = @uris_to_entries[key]

return unless original_entries && updated_entries

# A change in one ancestor may impact several different others, which could be including that ancestor through
Expand Down
3 changes: 2 additions & 1 deletion lib/ruby_indexer/test/index_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2061,7 +2061,8 @@ def test_build_non_redundant_name
end

def test_prevents_multiple_calls_to_index_all
# For this test class, `index_all` is already called once in `setup`.
@index.index_all
vinistock marked this conversation as resolved.
Show resolved Hide resolved

assert_raises(Index::IndexNotEmptyError) do
@index.index_all
end
Expand Down
9 changes: 8 additions & 1 deletion lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,14 @@ def run_combined_requests(message)
document_link = Requests::DocumentLink.new(uri, parse_result.comments, dispatcher)
code_lens = Requests::CodeLens.new(@global_state, uri, dispatcher)
inlay_hint = Requests::InlayHints.new(document, T.must(@store.features_configuration.dig(:inlayHint)), dispatcher)
dispatcher.dispatch(parse_result.value)

# Re-index the file as it is modified. This mode of indexing updates entries only. Require path trees are only
# updated on save
@global_state.index.handle_change(uri) do |index|
index.delete(uri, skip_require_paths_tree: true)
RubyIndexer::DeclarationListener.new(index, dispatcher, parse_result, uri, collect_comments: true)
dispatcher.dispatch(parse_result.value)
end

# Store all responses retrieve in this round of visits in the cache and then return the response for the request
# we actually received
Expand Down
130 changes: 130 additions & 0 deletions test/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,136 @@ def test_cancelling_requests_returns_expected_error_code
assert_equal("Request 1 was cancelled", error.message)
end

def test_unsaved_changes_are_indexed_when_computing_automatic_features
uri = URI("file:///foo.rb")
index = @server.global_state.index

# Simulate opening a file. First, send the notification to open the file with a class inside
@server.process_message({
method: "textDocument/didOpen",
params: {
textDocument: {
uri: uri,
text: +"class Foo\nend",
version: 1,
languageId: "ruby",
},
},
})
# Fire the automatic features requests to trigger indexing
@server.process_message({
id: 1,
method: "textDocument/documentSymbol",
params: { textDocument: { uri: uri } },
})

entries = index["Foo"]
assert_equal(1, entries.length)

# Modify the file without saving
@server.process_message({
method: "textDocument/didChange",
params: {
textDocument: { uri: uri, version: 2 },
contentChanges: [
{ text: " def bar\n end\n", range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } } },
],
},
})

# Parse the document after it was modified. This occurs automatically when we receive a text document request, to
# avoid parsing the document multiple times, but that depends on request coming in through the STDIN pipe, which
# isn't reproduced here. Parsing manually matches what happens normally
store = @server.instance_variable_get(:@store)
store.get(uri).parse!

# Trigger the automatic features again
@server.process_message({
id: 2,
method: "textDocument/documentSymbol",
params: { textDocument: { uri: uri } },
})

# There should still only be one entry for each declaration, but we should have picked up the new ones
entries = index["Foo"]
assert_equal(1, entries.length)

entries = index["bar"]
assert_equal(1, entries.length)
end

def test_ancestors_are_recomputed_even_on_unsaved_changes
uri = URI("file:///foo.rb")
index = @server.global_state.index
source = +<<~RUBY
module Bar; end

class Foo
extend Bar
end
RUBY

# Simulate opening a file. First, send the notification to open the file with a class inside
@server.process_message({
method: "textDocument/didOpen",
params: {
textDocument: {
uri: uri,
text: source,
version: 1,
languageId: "ruby",
},
},
})
# Fire the automatic features requests to trigger indexing
@server.process_message({
id: 1,
method: "textDocument/documentSymbol",
params: { textDocument: { uri: uri } },
})

assert_equal(["Foo::<Class:Foo>", "Bar"], index.linearized_ancestors_of("Foo::<Class:Foo>"))

# Delete the extend
@server.process_message({
method: "textDocument/didChange",
params: {
textDocument: { uri: uri, version: 2 },
contentChanges: [
{ text: "", range: { start: { line: 3, character: 0 }, end: { line: 3, character: 12 } } },
],
},
})

# Parse the document after it was modified. This occurs automatically when we receive a text document request, to
# avoid parsing the document multiple times, but that depends on request coming in through the STDIN pipe, which
# isn't reproduced here. Parsing manually matches what happens normally
store = @server.instance_variable_get(:@store)
document = store.get(uri)

assert_equal(<<~RUBY, document.source)
module Bar; end

class Foo

end
RUBY

document.parse!

# Trigger the automatic features again
@server.process_message({
id: 2,
method: "textDocument/documentSymbol",
params: { textDocument: { uri: uri } },
})

result = find_message(RubyLsp::Result, id: 2)
refute_nil(result)

assert_equal(["Foo::<Class:Foo>"], index.linearized_ancestors_of("Foo::<Class:Foo>"))
end

private

def with_uninstalled_rubocop(&block)
Expand Down
Loading