diff --git a/lib/ruby_indexer/lib/ruby_indexer/index.rb b/lib/ruby_indexer/lib/ruby_indexer/index.rb index 3c60163e3..aaa46f12b 100644 --- a/lib/ruby_indexer/lib/ruby_indexer/index.rb +++ b/lib/ruby_indexer/lib/ruby_indexer/index.rb @@ -48,6 +48,8 @@ def initialize ) @configuration = T.let(RubyIndexer::Configuration.new, Configuration) + + @initial_indexing_complete = T.let(false, T::Boolean) end # Register an included `hook` that will be executed when `module_name` is included into any namespace @@ -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_tree: T::Boolean).void } + def delete(uri, skip_require_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. @@ -80,6 +82,7 @@ def delete(uri) end @uris_to_entries.delete(key) + return if skip_require_tree require_path = uri.require_path @require_paths_tree.delete(require_path) if require_path @@ -358,11 +361,13 @@ def index_all(uris: @configuration.indexable_uris, &block) # 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_complete raise IndexNotEmptyError, "The index is not empty. To prevent invalid entries, `index_all` can only be called once." end + @initial_indexing_complete = true + RBSIndexer.new(self).index_ruby_core # Calculate how many paths are worth 1% of progress progress_step = (uris.length / 100.0).ceil diff --git a/lib/ruby_indexer/test/index_test.rb b/lib/ruby_indexer/test/index_test.rb index 80a5a8dc4..c79c6d2ec 100644 --- a/lib/ruby_indexer/test/index_test.rb +++ b/lib/ruby_indexer/test/index_test.rb @@ -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 + assert_raises(Index::IndexNotEmptyError) do @index.index_all end diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index b7b1d5770..3e90c76b1 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -493,6 +493,18 @@ 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) + + # 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.delete(uri, skip_require_tree: true) + RubyIndexer::DeclarationListener.new( + @global_state.index, + dispatcher, + parse_result, + uri, + collect_comments: true, + ) + dispatcher.dispatch(parse_result.value) # Store all responses retrieve in this round of visits in the cache and then return the response for the request diff --git a/test/server_test.rb b/test/server_test.rb index ba042c199..8af7d0ffe 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -789,6 +789,64 @@ 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 + private def with_uninstalled_rubocop(&block)