From 1a5bfd157babb05a7fe7ad66ff8a5bcffdf13ebe Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 25 Feb 2026 10:47:39 -0500 Subject: [PATCH 01/20] Add rubydex dependency --- .mcp.json | 7 + Gemfile.lock | 7 + lib/ruby_lsp/internal.rb | 1 + ruby-lsp.gemspec | 1 + sorbet/rbi/gems/rubydex@0.1.0.beta8.rbi | 450 ++++++++++++++++++++++++ sorbet/tapioca/require.rb | 1 + 6 files changed, 467 insertions(+) create mode 100644 .mcp.json create mode 100644 sorbet/rbi/gems/rubydex@0.1.0.beta8.rbi diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..2d80d3c1b6 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "rubydex": { + "command": "${HOME}/.cargo/bin/rubydex_mcp" + } + } +} diff --git a/Gemfile.lock b/Gemfile.lock index b00752ad03..e3be8d2839 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,7 @@ PATH language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 5) + rubydex (~> 0.1.0.beta1) GEM remote: https://rubygems.org/ @@ -91,6 +92,9 @@ GEM rubocop (>= 1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) + rubydex (0.1.0.beta8) + rubydex (0.1.0.beta8-arm64-darwin) + rubydex (0.1.0.beta8-x86_64-linux) sorbet (0.6.13055) sorbet-static (= 0.6.13055) sorbet-runtime (0.6.13055) @@ -200,6 +204,9 @@ CHECKSUMS ruby-lsp (0.26.9) ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubydex (0.1.0.beta8) sha256=6bba99fed4a334c32c9ea18e803e72335c7ac99a57fbfae552b0c902561eb0de + rubydex (0.1.0.beta8-arm64-darwin) sha256=ea09f759af9ec0986f6196faba505ac4e6618025bb9cc79af975c49acbef3ed3 + rubydex (0.1.0.beta8-x86_64-linux) sha256=49598b58d4be412f54164f6323c8e1d32cda883c25a0881f06c4c0b49d4af3dc sorbet (0.6.13055) sha256=5f5e8f37c13c281fa2b2f95e261d2e531d8331ddcc8e2dd8c4f16457935872ec sorbet-runtime (0.6.13055) sha256=c8ae8c81310e0a28d290b11f44ddca59659b7d7f13752c0ef5d16964bbb84d18 sorbet-static (0.6.13055-universal-darwin) sha256=649c8e79a443be85318922f9ecbb46be72f6c585443f4440c4ec0fb1737c86e8 diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 1083c51450..7b3a04a000 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -20,6 +20,7 @@ end require "set" require "strscan" +require "rubydex" require "prism" require "prism/visitor" require "language_server-protocol" diff --git a/ruby-lsp.gemspec b/ruby-lsp.gemspec index e42cfafd47..328186e4a5 100644 --- a/ruby-lsp.gemspec +++ b/ruby-lsp.gemspec @@ -24,6 +24,7 @@ Gem::Specification.new do |s| s.add_dependency("language_server-protocol", "~> 3.17.0") s.add_dependency("prism", ">= 1.2", "< 2.0") s.add_dependency("rbs", ">= 3", "< 5") + s.add_dependency("rubydex", "~> 0.1.0.beta1") s.required_ruby_version = ">= 3.0" end diff --git a/sorbet/rbi/gems/rubydex@0.1.0.beta8.rbi b/sorbet/rbi/gems/rubydex@0.1.0.beta8.rbi new file mode 100644 index 0000000000..ffae66aa1c --- /dev/null +++ b/sorbet/rbi/gems/rubydex@0.1.0.beta8.rbi @@ -0,0 +1,450 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for types exported from the `rubydex` gem. +# Please instead update this file by running `bin/tapioca gem rubydex`. + +# frozen_string_literal: true + +# source://rubydex//lib/rubydex/version.rb#3 +module Rubydex; end + +class Rubydex::AttrAccessorDefinition < ::Rubydex::Definition; end +class Rubydex::AttrReaderDefinition < ::Rubydex::Definition; end +class Rubydex::AttrWriterDefinition < ::Rubydex::Definition; end +class Rubydex::Class < ::Rubydex::Namespace; end +class Rubydex::ClassDefinition < ::Rubydex::Definition; end +class Rubydex::ClassVariable < ::Rubydex::Declaration; end +class Rubydex::ClassVariableDefinition < ::Rubydex::Definition; end + +# source://rubydex//lib/rubydex/comment.rb#4 +class Rubydex::Comment + # @return [Comment] a new instance of Comment + # + # source://rubydex//lib/rubydex/comment.rb#12 + sig { params(string: String, location: Rubydex::Location).void } + def initialize(string:, location:); end + + # source://rubydex//lib/rubydex/comment.rb#9 + sig { returns(Rubydex::Location) } + def location; end + + # source://rubydex//lib/rubydex/comment.rb#6 + sig { returns(String) } + def string; end +end + +class Rubydex::Constant < ::Rubydex::Declaration; end +class Rubydex::ConstantAlias < ::Rubydex::Declaration; end +class Rubydex::ConstantAliasDefinition < ::Rubydex::Definition; end +class Rubydex::ConstantDefinition < ::Rubydex::Definition; end + +class Rubydex::ConstantReference < ::Rubydex::Reference + # source://rubydex//lib/rubydex.rb#10 + def initialize(_arg0, _arg1); end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(Rubydex::Location) } + def location; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(String) } + def name; end +end + +class Rubydex::Declaration + # source://rubydex//lib/rubydex.rb#10 + def initialize(_arg0, _arg1); end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::Definition]) } + def definitions; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(String) } + def name; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(Rubydex::Declaration) } + def owner; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::Reference]) } + def references; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(String) } + def unqualified_name; end + + class << self + private + + # source://rubydex//lib/rubydex.rb#10 + def new(*_arg0); end + end +end + +class Rubydex::Definition + # source://rubydex//lib/rubydex.rb#10 + def initialize(_arg0, _arg1); end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Array[Rubydex::Comment]) } + def comments; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Boolean) } + def deprecated?; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(Rubydex::Location) } + def location; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(String) } + def name; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T.nilable(Rubydex::Location)) } + def name_location; end + + class << self + private + + # source://rubydex//lib/rubydex.rb#10 + def new(*_arg0); end + end +end + +# source://rubydex//lib/rubydex/diagnostic.rb#4 +class Rubydex::Diagnostic + # @return [Diagnostic] a new instance of Diagnostic + # + # source://rubydex//lib/rubydex/diagnostic.rb#15 + sig { params(rule: Symbol, message: String, location: Rubydex::Location).void } + def initialize(rule:, message:, location:); end + + # source://rubydex//lib/rubydex/diagnostic.rb#12 + sig { returns(Rubydex::Location) } + def location; end + + # source://rubydex//lib/rubydex/diagnostic.rb#9 + sig { returns(String) } + def message; end + + # source://rubydex//lib/rubydex/diagnostic.rb#6 + sig { returns(Symbol) } + def rule; end +end + +# A one based location intended for display purposes. This is what should be used when displaying a location to users, +# like in CLIs +# +# source://rubydex//lib/rubydex/location.rb#70 +class Rubydex::DisplayLocation < ::Rubydex::Location + # Normalize to zero-based for comparison with Location + # + # + # source://rubydex//lib/rubydex/location.rb#81 + sig { returns([String, Integer, Integer, Integer, Integer]) } + def comparable_values; end + + # Returns itself + # + # + # source://rubydex//lib/rubydex/location.rb#74 + sig { returns(Rubydex::DisplayLocation) } + def to_display; end + + # source://rubydex//lib/rubydex/location.rb#86 + sig { returns(String) } + def to_s; end +end + +class Rubydex::Document + # source://rubydex//lib/rubydex.rb#10 + def initialize(_arg0, _arg1); end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::Definition]) } + def definitions; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(String) } + def uri; end + + class << self + private + + # source://rubydex//lib/rubydex.rb#10 + def new(*_arg0); end + end +end + +class Rubydex::Error < StandardError; end + +# source://rubydex//lib/rubydex/failures.rb#4 +class Rubydex::Failure + # @return [Failure] a new instance of Failure + # + # source://rubydex//lib/rubydex/failures.rb#9 + sig { params(message: String).void } + def initialize(message); end + + # source://rubydex//lib/rubydex/failures.rb#6 + sig { returns(String) } + def message; end +end + +class Rubydex::GlobalVariable < ::Rubydex::Declaration; end +class Rubydex::GlobalVariableAliasDefinition < ::Rubydex::Definition; end +class Rubydex::GlobalVariableDefinition < ::Rubydex::Definition; end + +# The global graph representing all declarations and their relationships for the workspace +# +# Note: this class is partially defined in C to integrate with the Rust backend +# +# source://rubydex//lib/rubydex/graph.rb#7 +class Rubydex::Graph + # @return [Graph] a new instance of Graph + # + # source://rubydex//lib/rubydex/graph.rb#20 + sig { params(workspace_path: T.nilable(String)).void } + def initialize(workspace_path: T.unsafe(nil)); end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(fully_qualified_name: String).returns(T.nilable(Rubydex::Declaration)) } + def [](fully_qualified_name); end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Array[Rubydex::Failure]) } + def check_integrity; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::ConstantReference]) } + def constant_references; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::Declaration]) } + def declarations; end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(uri: String).returns(T.nilable(Rubydex::Document)) } + def delete_document(uri); end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Array[Rubydex::Diagnostic]) } + def diagnostics; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::Document]) } + def documents; end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(encoding: String).void } + def encoding=(encoding); end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(paths: T::Array[String]).returns(T::Array[String]) } + def index_all(paths); end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(uri: String, source: String, language_id: String).void } + def index_source(uri, source, language_id); end + + # Index all files and dependencies of the workspace that exists in `@workspace_path` + # + # source://rubydex//lib/rubydex/graph.rb#26 + # Index all files and dependencies of the workspace that exists in `@workspace_path` + sig { returns(T::Array[String]) } + def index_workspace; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::MethodReference]) } + def method_references; end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(load_path: T::Array[String]).returns(T::Array[String]) } + def require_paths(load_path); end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T.self_type) } + def resolve; end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(name: String, nesting: T::Array[String]).returns(T.nilable(Rubydex::Declaration)) } + def resolve_constant(name, nesting); end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(require_path: String, load_path: T::Array[String]).returns(T.nilable(Rubydex::Document)) } + def resolve_require_path(require_path, load_path); end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(query: String).returns(T::Array[Rubydex::Declaration]) } + def search(query); end + + # source://rubydex//lib/rubydex/graph.rb#17 + sig { returns(String) } + def workspace_path; end + + # source://rubydex//lib/rubydex/graph.rb#17 + sig { params(workspace_path: String).void } + def workspace_path=(workspace_path); end + + # Returns all workspace paths that should be indexed, excluding directories that we don't need to descend into such + # as `.git`, `node_modules`. Also includes any top level Ruby files + # + # + # source://rubydex//lib/rubydex/graph.rb#34 + sig { returns(T::Array[String]) } + def workspace_paths; end + + private + + # Searches for the latest installation of the `rbs` gem and adds the paths for the core and stdlib RBS definitions + # to the list of paths. This method does not require `rbs` to be a part of the bundle. It searches for whatever + # latest installation of `rbs` exists in the system and fails silently if we can't find one + # + # + # source://rubydex//lib/rubydex/graph.rb#81 + sig { params(paths: T::Array[String]).void } + def add_core_rbs_definition_paths(paths); end + + # Gathers the paths we have to index for all workspace dependencies + # + # source://rubydex//lib/rubydex/graph.rb#57 + sig { params(paths: T::Array[String]).void } + def add_workspace_dependency_paths(paths); end +end + +# source://rubydex//lib/rubydex/graph.rb#8 +Rubydex::Graph::IGNORED_DIRECTORIES = T.let(T.unsafe(nil), Array) + +class Rubydex::InstanceVariable < ::Rubydex::Declaration; end +class Rubydex::InstanceVariableDefinition < ::Rubydex::Definition; end + +# source://rubydex//lib/rubydex/failures.rb#14 +class Rubydex::IntegrityFailure < ::Rubydex::Failure; end + +# A zero based internal location. Intended to be used for tool-to-tool communication, such as a language server +# communicating with an editor. +# +# source://rubydex//lib/rubydex/location.rb#6 +class Rubydex::Location + include ::Comparable + + # @return [Location] a new instance of Location + # + # source://rubydex//lib/rubydex/location.rb#18 + sig { params(uri: String, start_line: Integer, end_line: Integer, start_column: Integer, end_column: Integer).void } + def initialize(uri:, start_line:, end_line:, start_column:, end_column:); end + + # source://rubydex//lib/rubydex/location.rb#38 + sig { params(other: T.untyped).returns(T.nilable(Integer)) } + def <=>(other); end + + # source://rubydex//lib/rubydex/location.rb#45 + sig { returns([String, Integer, Integer, Integer, Integer]) } + def comparable_values; end + + # source://rubydex//lib/rubydex/location.rb#15 + sig { returns(Integer) } + def end_column; end + + # source://rubydex//lib/rubydex/location.rb#15 + sig { returns(Integer) } + def end_line; end + + # source://rubydex//lib/rubydex/location.rb#15 + sig { returns(Integer) } + def start_column; end + + # source://rubydex//lib/rubydex/location.rb#15 + sig { returns(Integer) } + def start_line; end + + # Turns this zero based location into a one based location for display purposes. + # + # + # source://rubydex//lib/rubydex/location.rb#52 + sig { returns(Rubydex::DisplayLocation) } + def to_display; end + + # @raise [NotFileUriError] + # + # source://rubydex//lib/rubydex/location.rb#27 + sig { returns(String) } + def to_file_path; end + + # source://rubydex//lib/rubydex/location.rb#63 + sig { returns(String) } + def to_s; end + + # source://rubydex//lib/rubydex/location.rb#12 + sig { returns(String) } + def uri; end +end + +# source://rubydex//lib/rubydex/location.rb#7 +class Rubydex::Location::NotFileUriError < ::StandardError; end + +class Rubydex::Method < ::Rubydex::Declaration; end +class Rubydex::MethodAliasDefinition < ::Rubydex::Definition; end +class Rubydex::MethodDefinition < ::Rubydex::Definition; end + +class Rubydex::MethodReference < ::Rubydex::Reference + # source://rubydex//lib/rubydex.rb#10 + def initialize(_arg0, _arg1); end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(Rubydex::Location) } + def location; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(String) } + def name; end +end + +class Rubydex::Module < ::Rubydex::Namespace; end +class Rubydex::ModuleDefinition < ::Rubydex::Definition; end + +class Rubydex::Namespace < ::Rubydex::Declaration + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::Namespace]) } + def ancestors; end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::Namespace]) } + def descendants; end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(name: String, only_inherited: T::Boolean).returns(T.nilable(Rubydex::Declaration)) } + def find_member(name, only_inherited: T.unsafe(nil)); end + + # source://rubydex//lib/rubydex.rb#10 + sig { params(name: String).returns(T.nilable(Rubydex::Declaration)) } + def member(name); end + + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T.nilable(Rubydex::SingletonClass)) } + def singleton_class; end +end + +class Rubydex::Reference + # source://rubydex//lib/rubydex.rb#10 + def initialize(_arg0, _arg1); end + + class << self + private + + # source://rubydex//lib/rubydex.rb#10 + def new(*_arg0); end + end +end + +class Rubydex::SingletonClass < ::Rubydex::Namespace; end +class Rubydex::SingletonClassDefinition < ::Rubydex::Definition; end +class Rubydex::Todo < ::Rubydex::Namespace; end + +# source://rubydex//lib/rubydex/version.rb#4 +Rubydex::VERSION = T.let(T.unsafe(nil), String) diff --git a/sorbet/tapioca/require.rb b/sorbet/tapioca/require.rb index 36dd0a2df3..61f328097f 100644 --- a/sorbet/tapioca/require.rb +++ b/sorbet/tapioca/require.rb @@ -8,6 +8,7 @@ yarp_require_paths = Gem.loaded_specs["yarp"]&.full_require_paths $LOAD_PATH.delete_if { |path| yarp_require_paths.include?(path) } if yarp_require_paths +require "rubydex" require "language_server-protocol" require "prism" require "prism/visitor" From c3c79e79953e9d65968031b7faf333125032858a Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 18 Mar 2026 10:19:50 -0400 Subject: [PATCH 02/20] Upgrade rubydex to v0.1.0.beta9 --- Gemfile.lock | 12 ++++++------ ...bydex@0.1.0.beta8.rbi => rubydex@0.1.0.beta9.rbi} | 0 2 files changed, 6 insertions(+), 6 deletions(-) rename sorbet/rbi/gems/{rubydex@0.1.0.beta8.rbi => rubydex@0.1.0.beta9.rbi} (100%) diff --git a/Gemfile.lock b/Gemfile.lock index e3be8d2839..b3d50cd0ac 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,9 +92,9 @@ GEM rubocop (>= 1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - rubydex (0.1.0.beta8) - rubydex (0.1.0.beta8-arm64-darwin) - rubydex (0.1.0.beta8-x86_64-linux) + rubydex (0.1.0.beta9) + rubydex (0.1.0.beta9-arm64-darwin) + rubydex (0.1.0.beta9-x86_64-linux) sorbet (0.6.13055) sorbet-static (= 0.6.13055) sorbet-runtime (0.6.13055) @@ -204,9 +204,9 @@ CHECKSUMS ruby-lsp (0.26.9) ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - rubydex (0.1.0.beta8) sha256=6bba99fed4a334c32c9ea18e803e72335c7ac99a57fbfae552b0c902561eb0de - rubydex (0.1.0.beta8-arm64-darwin) sha256=ea09f759af9ec0986f6196faba505ac4e6618025bb9cc79af975c49acbef3ed3 - rubydex (0.1.0.beta8-x86_64-linux) sha256=49598b58d4be412f54164f6323c8e1d32cda883c25a0881f06c4c0b49d4af3dc + rubydex (0.1.0.beta9) sha256=575dcb951f627f63cc36fc00b4698839ca18fd71fd13999237fe129c3478f468 + rubydex (0.1.0.beta9-arm64-darwin) sha256=4f93a4871da4961544113603a0e2cc68b480d95707247f746116b217978994df + rubydex (0.1.0.beta9-x86_64-linux) sha256=6a9e6959ad02ca13aa554ada2a35d6c95ba6432adc7e87eb70714bf777f15372 sorbet (0.6.13055) sha256=5f5e8f37c13c281fa2b2f95e261d2e531d8331ddcc8e2dd8c4f16457935872ec sorbet-runtime (0.6.13055) sha256=c8ae8c81310e0a28d290b11f44ddca59659b7d7f13752c0ef5d16964bbb84d18 sorbet-static (0.6.13055-universal-darwin) sha256=649c8e79a443be85318922f9ecbb46be72f6c585443f4440c4ec0fb1737c86e8 diff --git a/sorbet/rbi/gems/rubydex@0.1.0.beta8.rbi b/sorbet/rbi/gems/rubydex@0.1.0.beta9.rbi similarity index 100% rename from sorbet/rbi/gems/rubydex@0.1.0.beta8.rbi rename to sorbet/rbi/gems/rubydex@0.1.0.beta9.rbi From 8a404eb370378428eab8500296122e3523ded179 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Tue, 3 Mar 2026 16:59:34 -0500 Subject: [PATCH 03/20] Start indexing and resolving Rubydex graph --- lib/ruby_lsp/global_state.rb | 9 +++++++++ lib/ruby_lsp/server.rb | 16 ++++++++++++---- lib/ruby_lsp/test_helper.rb | 3 +++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index 82e6edbcf5..2673dec3a2 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -30,6 +30,9 @@ class GlobalState #: RubyIndexer::Index attr_reader :index + #: Rubydex::Graph + attr_reader :graph + #: Encoding attr_reader :encoding @@ -58,6 +61,7 @@ def initialize @test_library = "minitest" #: String @has_type_checker = true #: bool @index = RubyIndexer::Index.new #: RubyIndexer::Index + @graph = Rubydex::Graph.new #: Rubydex::Graph @supported_formatters = {} #: Hash[String, Requests::Support::Formatter] @type_inferrer = TypeInferrer.new(@index) #: TypeInferrer @addon_settings = {} #: Hash[String, untyped] @@ -117,6 +121,7 @@ def apply_options(options) all_dependencies = gather_direct_and_indirect_dependencies workspace_uri = options.dig(:workspaceFolders, 0, :uri) @workspace_uri = URI(workspace_uri) if workspace_uri + @graph.workspace_path = workspace_path specified_formatter = options.dig(:initializationOptions, :formatter) rubocop_has_addon = defined?(::RuboCop::Version::STRING) && @@ -189,12 +194,16 @@ def apply_options(options) encodings = options.dig(:capabilities, :general, :positionEncodings) @encoding = if !encodings || encodings.empty? + @graph.encoding = "utf16" Encoding::UTF_16LE elsif encodings.include?(Constant::PositionEncodingKind::UTF8) + @graph.encoding = "utf8" Encoding::UTF_8 elsif encodings.include?(Constant::PositionEncodingKind::UTF16) + @graph.encoding = "utf16" Encoding::UTF_16LE else + @graph.encoding = "utf32" Encoding::UTF_32 end @index.configuration.encoding = @encoding diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index c700fc6c55..4bf6edc73b 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -1242,12 +1242,18 @@ def shutdown #: -> void def perform_initial_indexing + progress("indexing-progress", message: "Indexing workspace...") + @global_state.graph.index_workspace + + progress("indexing-progress", message: "Resolving graph...") + @global_state.graph.resolve + # The begin progress invocation happens during `initialize`, so that the notification is sent before we are # stuck indexing files Thread.new do begin @global_state.index.index_all do |percentage| - progress("indexing-progress", percentage) + progress("indexing-progress", percentage: percentage) true rescue ClosedQueueError # Since we run indexing on a separate thread, it's possible to kill the server before indexing is complete. @@ -1301,11 +1307,13 @@ def begin_progress(id, title, percentage: 0) send_message(Notification.progress_begin(id, title, percentage: percentage, message: "#{percentage}% completed")) end - #: (String id, Integer percentage) -> void - def progress(id, percentage) + #: (String, ?message: String?, ?percentage: Integer?) -> void + def progress(id, message: nil, percentage: nil) return unless @global_state.client_capabilities.supports_progress - send_message(Notification.progress_report(id, percentage: percentage, message: "#{percentage}% completed")) + message ||= "#{percentage}% completed" if percentage + + send_message(Notification.progress_report(id, percentage: percentage, message: message)) end #: (String id) -> void diff --git a/lib/ruby_lsp/test_helper.rb b/lib/ruby_lsp/test_helper.rb index 02501276bc..31ee1fef1d 100644 --- a/lib/ruby_lsp/test_helper.rb +++ b/lib/ruby_lsp/test_helper.rb @@ -30,6 +30,9 @@ def with_server(source = nil, uri = Kernel.URI("file:///fake.rb"), stub_no_typec }) server.global_state.index.index_single(uri, source) + graph = server.global_state.graph + graph.index_source(uri.to_s, source, "ruby") + graph.resolve end server.load_addons(include_project_addons: false) if load_addons From ad2814d454f7608df54f567cf8410d391e164643 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Thu, 5 Mar 2026 09:16:00 -0500 Subject: [PATCH 04/20] Switch singleton class names to new style --- .../lib/ruby_indexer/declaration_listener.rb | 4 +- lib/ruby_indexer/lib/ruby_indexer/index.rb | 16 +-- .../lib/ruby_indexer/reference_finder.rb | 4 +- .../test/classes_and_modules_test.rb | 22 ++-- lib/ruby_indexer/test/enhancements_test.rb | 20 ++-- lib/ruby_indexer/test/index_test.rb | 102 +++++++++--------- .../test/instance_variables_test.rb | 10 +- lib/ruby_indexer/test/method_test.rb | 6 +- lib/ruby_indexer/test/rbs_indexer_test.rb | 10 +- .../test/reference_finder_test.rb | 2 +- lib/ruby_lsp/listeners/test_style.rb | 2 +- lib/ruby_lsp/node_context.rb | 4 +- lib/ruby_lsp/type_inferrer.rb | 10 +- test/requests/completion_resolve_test.rb | 2 +- test/ruby_document_test.rb | 6 +- test/server_test.rb | 4 +- test/type_inferrer_test.rb | 26 ++--- 17 files changed, 125 insertions(+), 125 deletions(-) diff --git a/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb b/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb index 174dc346ba..eb573cabfe 100644 --- a/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb +++ b/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb @@ -129,7 +129,7 @@ def on_singleton_class_node_enter(node) if current_owner expression = node.expression - name = (expression.is_a?(Prism::SelfNode) ? "" : "") + name = (expression.is_a?(Prism::SelfNode) ? "<#{last_name_in_stack}>" : "<#{expression.slice}>") real_nesting = Index.actual_nesting(@stack, name) existing_entries = @index[real_nesting.join("::")] #: as Array[Entry::SingletonClass]? @@ -577,7 +577,7 @@ def handle_class_variable(node, loc) # set the class variable's owner to the attached context when defined within a singleton scope. if owner.is_a?(Entry::SingletonClass) - owner = @owner_stack.reverse.find { |entry| !entry.name.include?("" + nesting << "<#{nesting.last}>" end end @@ -616,7 +616,7 @@ def instance_variable_completion_candidates(name, owner_name) if class_variables.any? name_parts = owner_name.split("::") - if name_parts.last&.start_with?(" Entry::SingletonClass def existing_or_new_singleton_class(name) *_namespace, unqualified_name = name.split("::") - full_singleton_name = "#{name}::" + full_singleton_name = "#{name}::<#{unqualified_name}>" singleton = self[full_singleton_name]&.first #: as Entry::SingletonClass? unless singleton @@ -744,7 +744,7 @@ def entries_for(uri, type = nil) def linearized_attached_ancestors(name) name_parts = name.split("::") - if name_parts.last&.start_with?("" + parent_name_parts << "<#{parent_name_parts.last}>" end ancestors.concat(linearized_ancestors_of(parent_name_parts.join("::"))) @@ -878,7 +878,7 @@ def linearize_superclass( # rubocop:disable Metrics/ParameterLists class_class_name_parts = ["Class"] (singleton_levels - 1).times do - class_class_name_parts << "" + class_class_name_parts << "<#{class_class_name_parts.last}>" end ancestors.concat(linearized_ancestors_of(class_class_name_parts.join("::"))) @@ -892,7 +892,7 @@ def linearize_superclass( # rubocop:disable Metrics/ParameterLists module_class_name_parts = ["Module"] (singleton_levels - 1).times do - module_class_name_parts << "" + module_class_name_parts << "<#{module_class_name_parts.last}>" end ancestors.concat(linearized_ancestors_of(module_class_name_parts.join("::"))) diff --git a/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb b/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb index e521a4d070..8c167c40a8 100644 --- a/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb +++ b/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb @@ -134,7 +134,7 @@ def on_singleton_class_node_enter(node) expression = node.expression return unless expression.is_a?(Prism::SelfNode) - @stack << "" + @stack << "<#{@stack.last}>" end #: (Prism::SingletonClassNode node) -> void @@ -239,7 +239,7 @@ def on_def_node_enter(node) end if node.receiver.is_a?(Prism::SelfNode) - @stack << "" + @stack << "<#{@stack.last}>" end end diff --git a/lib/ruby_indexer/test/classes_and_modules_test.rb b/lib/ruby_indexer/test/classes_and_modules_test.rb index 349465921f..6439aa5784 100644 --- a/lib/ruby_indexer/test/classes_and_modules_test.rb +++ b/lib/ruby_indexer/test/classes_and_modules_test.rb @@ -489,15 +489,15 @@ class ConstantPathReferences end RUBY - foo = @index["Foo::"] #: as !nil + foo = @index["Foo::"] #: as !nil .first #: as Entry::Class assert_equal(["A1", "A2", "A3", "A4", "A5", "A6"], foo.mixin_operation_module_names) - qux = @index["Foo::Qux::"] #: as !nil + qux = @index["Foo::Qux::"] #: as !nil .first #: as Entry::Class assert_equal(["Corge", "Corge", "Baz"], qux.mixin_operation_module_names) - constant_path_references = @index["ConstantPathReferences::"] #: as !nil + constant_path_references = @index["ConstantPathReferences::"] #: as !nil .first #: as Entry::Class assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.mixin_operation_module_names) end @@ -512,7 +512,7 @@ class << self end RUBY - foo = @index["Foo::"] #: as !nil + foo = @index["Foo::"] #: as !nil .first #: as Entry::SingletonClass assert_equal(4, foo.location.start_line) assert_equal("Some extra comments", foo.comments) @@ -527,7 +527,7 @@ class << bar end RUBY - singleton = @index["Foo::"] #: as !nil + singleton = @index["Foo::"] #: as !nil .first #: as Entry::SingletonClass # Even though this is not correct, we consider any dynamic singleton class block as a regular singleton class. @@ -547,7 +547,7 @@ class Bar end RUBY - assert_entry("Foo::::Bar", Entry::Class, "/fake/path/foo.rb:2-4:3-7") + assert_entry("Foo::::Bar", Entry::Class, "/fake/path/foo.rb:2-4:3-7") end def test_name_location_points_to_constant_path_location @@ -614,10 +614,10 @@ class << self entries = @index.instance_variable_get(:@entries) refute(entries.key?("::Foo")) refute(entries.key?("::Foo::Bar")) - refute(entries.key?("::Foo::Bar::")) + refute(entries.key?("::Foo::Bar::")) assert_entry("Foo", Entry::Module, "/fake/path/foo.rb:0-0:5-3") assert_entry("Foo::Bar", Entry::Class, "/fake/path/foo.rb:1-2:4-5") - assert_entry("Foo::Bar::", Entry::SingletonClass, "/fake/path/foo.rb:2-4:3-7") + assert_entry("Foo::Bar::", Entry::SingletonClass, "/fake/path/foo.rb:2-4:3-7") end def test_indexing_namespaces_inside_nested_top_level_references @@ -683,13 +683,13 @@ def baz; end RUBY # Verify we didn't index the incorrect name - assert_nil(@index["Foo::Bar::"]) + assert_nil(@index["Foo::Bar::"]) # Verify we indexed the correct name - assert_entry("Foo::Bar::", Entry::SingletonClass, "/fake/path/foo.rb:1-2:3-5") + assert_entry("Foo::Bar::", Entry::SingletonClass, "/fake/path/foo.rb:1-2:3-5") method = @index["baz"]&.first #: as Entry::Method - assert_equal("Foo::Bar::", method.owner&.name) + assert_equal("Foo::Bar::", method.owner&.name) end def test_lazy_comments_with_spaces_are_properly_attributed diff --git a/lib/ruby_indexer/test/enhancements_test.rb b/lib/ruby_indexer/test/enhancements_test.rb index aeadbabdc6..078606fa52 100644 --- a/lib/ruby_indexer/test/enhancements_test.rb +++ b/lib/ruby_indexer/test/enhancements_test.rb @@ -76,18 +76,18 @@ class User < ActiveRecord::Base assert_equal( [ - "User::", - "ActiveRecord::Base::", + "User::", + "ActiveRecord::Base::", "ActiveRecord::Associations::ClassMethods", - "Object::", - "BasicObject::", + "Object::", + "BasicObject::", "Class", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("User::"), + @index.linearized_ancestors_of("User::"), ) assert_entry("new_method", Entry::Method, "/fake/path/foo.rb:10-4:10-33") @@ -271,20 +271,20 @@ class User assert_equal( [ - "User::", + "User::", "MyConcern::ClassMethods", - "Object::", - "BasicObject::", + "Object::", + "BasicObject::", "Class", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("User::"), + @index.linearized_ancestors_of("User::"), ) - refute_nil(@index.resolve_method("foo", "User::")) + refute_nil(@index.resolve_method("foo", "User::")) end def test_creating_anonymous_classes_from_enhancement diff --git a/lib/ruby_indexer/test/index_test.rb b/lib/ruby_indexer/test/index_test.rb index f236bd00bf..a38be36291 100644 --- a/lib/ruby_indexer/test/index_test.rb +++ b/lib/ruby_indexer/test/index_test.rb @@ -1386,7 +1386,7 @@ def found_me!; end end RUBY - entry = @index.resolve_method("found_me!", "Foo::Bar::::Baz::")&.first #: as !nil + entry = @index.resolve_method("found_me!", "Foo::Bar::::Baz::")&.first #: as !nil refute_nil(entry) assert_equal("found_me!", entry.name) end @@ -1411,7 +1411,7 @@ class << self end RUBY - entry = @index.resolve("CONST", ["Foo", "Bar", "", "Baz", ""])&.first #: as !nil + entry = @index.resolve("CONST", ["Foo", "Bar", "", "Baz", ""])&.first #: as !nil refute_nil(entry) assert_equal(9, entry.location.start_line) end @@ -1433,15 +1433,15 @@ def hello end RUBY - entry = @index.resolve_instance_variable("@a", "Foo::Bar::")&.first #: as !nil + entry = @index.resolve_instance_variable("@a", "Foo::Bar::")&.first #: as !nil refute_nil(entry) assert_equal("@a", entry.name) - entry = @index.resolve_instance_variable("@b", "Foo::Bar::")&.first #: as !nil + entry = @index.resolve_instance_variable("@b", "Foo::Bar::")&.first #: as !nil refute_nil(entry) assert_equal("@b", entry.name) - entry = @index.resolve_instance_variable("@c", "Foo::Bar::::>")&.first #: as !nil + entry = @index.resolve_instance_variable("@c", "Foo::Bar::::<>")&.first #: as !nil refute_nil(entry) assert_equal("@c", entry.name) end @@ -1463,7 +1463,7 @@ def hello end RUBY - entries = @index.instance_variable_completion_candidates("@", "Foo::Bar::").map(&:name) + entries = @index.instance_variable_completion_candidates("@", "Foo::Bar::").map(&:name) assert_includes(entries, "@a") assert_includes(entries, "@b") end @@ -1724,37 +1724,37 @@ class << self assert_equal( [ - "Baz::::>", - "Bar::::>", - "Foo::::>", - "Object::::>", - "BasicObject::::>", - "Class::", - "Module::", - "Object::", - "BasicObject::", + "Baz::::<>", + "Bar::::<>", + "Foo::::<>", + "Object::::<>", + "BasicObject::::<>", + "Class::", + "Module::", + "Object::", + "BasicObject::", "Class", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("Baz::::>"), + @index.linearized_ancestors_of("Baz::::<>"), ) end def test_linearizing_singleton_object assert_equal( [ - "Object::", - "BasicObject::", + "Object::", + "BasicObject::", "Class", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("Object::"), + @index.linearized_ancestors_of("Object::"), ) end @@ -1771,7 +1771,7 @@ def baz end RUBY - ["bar", "baz"].product(["Foo", "Foo::"]).each do |method, receiver| + ["bar", "baz"].product(["Foo", "Foo::"]).each do |method, receiver| entry = @index.resolve_method(method, receiver)&.first #: as !nil refute_nil(entry) assert_equal(method, entry.name) @@ -1779,14 +1779,14 @@ def baz assert_equal( [ - "Foo::", + "Foo::", "Foo", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("Foo::"), + @index.linearized_ancestors_of("Foo::"), ) end @@ -1816,18 +1816,18 @@ class << self assert_equal( [ - "Foo::Bar::::Baz::", + "Foo::Bar::::Baz::", "Second", "First", - "Object::", - "BasicObject::", + "Object::", + "BasicObject::", "Class", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("Foo::Bar::::Baz::"), + @index.linearized_ancestors_of("Foo::Bar::::Baz::"), ) end @@ -1846,18 +1846,18 @@ class << self assert_equal( [ - "Baz::", - "Bar::", - "Foo::", - "Object::", - "BasicObject::", + "Baz::", + "Bar::", + "Foo::", + "Object::", + "BasicObject::", "Class", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("Baz::"), + @index.linearized_ancestors_of("Baz::"), ) end @@ -1868,19 +1868,19 @@ module A; end assert_equal( [ - "A::", + "A::", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("A::"), + @index.linearized_ancestors_of("A::"), ) end def test_linearizing_a_singleton_class_with_no_attached assert_raises(Index::NonExistingNamespaceError) do - @index.linearized_ancestors_of("A::") + @index.linearized_ancestors_of("A::") end end @@ -1894,17 +1894,17 @@ class User < ActiveRecord::Base assert_equal( [ - "User::", - "ActiveRecord::Base::", - "Object::", - "BasicObject::", + "User::", + "ActiveRecord::Base::", + "Object::", + "BasicObject::", "Class", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("User::"), + @index.linearized_ancestors_of("User::"), ) end @@ -1926,18 +1926,18 @@ class Child < Namespace::Parent assert_equal( [ - "Foo::Child::", - "Foo::Namespace::Parent::", + "Foo::Child::", + "Foo::Namespace::Parent::", "Bar", - "Object::", - "BasicObject::", + "Object::", + "BasicObject::", "Class", "Module", "Object", "Kernel", "BasicObject", ], - @index.linearized_ancestors_of("Foo::Child::"), + @index.linearized_ancestors_of("Foo::Child::"), ) end @@ -1972,13 +1972,13 @@ def self.my_singleton_def; end RUBY entries = @index.entries_for("file:///fake/path/foo.rb", Entry) #: as !nil - assert_equal(["Foo", "Bar", "my_def", "Bar::", "my_singleton_def"], entries.map(&:name)) + assert_equal(["Foo", "Bar", "my_def", "Bar::", "my_singleton_def"], entries.map(&:name)) entries = @index.entries_for("file:///fake/path/foo.rb", RubyIndexer::Entry::Namespace) #: as !nil - assert_equal(["Foo", "Bar", "Bar::"], entries.map(&:name)) + assert_equal(["Foo", "Bar", "Bar::"], entries.map(&:name)) entries = @index.entries_for("file:///fake/path/foo.rb") #: as !nil - assert_equal(["Foo", "Bar", "my_def", "Bar::", "my_singleton_def"], entries.map(&:name)) + assert_equal(["Foo", "Bar", "my_def", "Bar::", "my_singleton_def"], entries.map(&:name)) end def test_entries_for_returns_nil_if_no_matches @@ -2204,7 +2204,7 @@ def self.do end RUBY - adf, abc = @index.instance_variable_completion_candidates("@", "Child::") + adf, abc = @index.instance_variable_completion_candidates("@", "Child::") refute_nil(abc) refute_nil(adf) @@ -2223,7 +2223,7 @@ def self.do end RUBY - candidates = @index.class_variable_completion_candidates("@@", "Foo::") + candidates = @index.class_variable_completion_candidates("@@", "Foo::") refute_empty(candidates) assert_equal("@@hello", candidates.first&.name) @@ -2236,7 +2236,7 @@ class Foo end RUBY - candidates = @index.resolve_class_variable("@@hello", "Foo::") #: as !nil + candidates = @index.resolve_class_variable("@@hello", "Foo::") #: as !nil refute_empty(candidates) assert_equal("@@hello", candidates.first&.name) diff --git a/lib/ruby_indexer/test/instance_variables_test.rb b/lib/ruby_indexer/test/instance_variables_test.rb index b67adf685c..53dc73c6ba 100644 --- a/lib/ruby_indexer/test/instance_variables_test.rb +++ b/lib/ruby_indexer/test/instance_variables_test.rb @@ -159,21 +159,21 @@ def hello entry = @index["@a"]&.first #: as Entry::InstanceVariable owner = entry.owner assert_instance_of(Entry::SingletonClass, owner) - assert_equal("Foo::Bar::", owner&.name) + assert_equal("Foo::Bar::", owner&.name) assert_entry("@b", Entry::InstanceVariable, "/fake/path/foo.rb:6-8:6-10") entry = @index["@b"]&.first #: as Entry::InstanceVariable owner = entry.owner assert_instance_of(Entry::SingletonClass, owner) - assert_equal("Foo::Bar::", owner&.name) + assert_equal("Foo::Bar::", owner&.name) assert_entry("@c", Entry::InstanceVariable, "/fake/path/foo.rb:9-6:9-8") entry = @index["@c"]&.first #: as Entry::InstanceVariable owner = entry.owner assert_instance_of(Entry::SingletonClass, owner) - assert_equal("Foo::Bar::::>", owner&.name) + assert_equal("Foo::Bar::::<>", owner&.name) end def test_top_level_instance_variables @@ -197,7 +197,7 @@ def self.bar entry = @index["@a"]&.first #: as Entry::InstanceVariable owner = entry.owner assert_instance_of(Entry::SingletonClass, owner) - assert_equal("Foo::", owner&.name) + assert_equal("Foo::", owner&.name) end def test_instance_variable_inside_dynamic_method_declaration @@ -234,7 +234,7 @@ def something; end entry = @index["@a"]&.first #: as Entry::InstanceVariable owner = entry.owner assert_instance_of(Entry::SingletonClass, owner) - assert_equal("Foo::", owner&.name) + assert_equal("Foo::", owner&.name) end def test_class_instance_variable_comments diff --git a/lib/ruby_indexer/test/method_test.rb b/lib/ruby_indexer/test/method_test.rb index c188d75950..c81f8a4bd6 100644 --- a/lib/ruby_indexer/test/method_test.rb +++ b/lib/ruby_indexer/test/method_test.rb @@ -49,7 +49,7 @@ def self.bar entry = @index["bar"]&.first #: as Entry::Method owner = entry.owner - assert_equal("Foo::", owner&.name) + assert_equal("Foo::", owner&.name) assert_instance_of(Entry::SingletonClass, owner) end @@ -145,7 +145,7 @@ def bar; end assert_instance_of(Entry::Module, first_entry&.owner) assert_predicate(first_entry, :private?) # The second entry points to the public singleton method - assert_equal("Test::", second_entry&.owner&.name) + assert_equal("Test::", second_entry&.owner&.name) assert_instance_of(Entry::SingletonClass, second_entry&.owner) assert_equal(:public, second_entry&.visibility) end @@ -873,7 +873,7 @@ def qux; end assert_equal("Foo", instance_baz&.owner&.name) assert_predicate(singleton_baz, :public?) - assert_equal("Foo::", singleton_baz&.owner&.name) + assert_equal("Foo::", singleton_baz&.owner&.name) # After invoking `public`, the state of `module_function` is reset instance_qux, singleton_qux = @index["qux"] #: as Array[Entry::Method] diff --git a/lib/ruby_indexer/test/rbs_indexer_test.rb b/lib/ruby_indexer/test/rbs_indexer_test.rb index 4e026ca3c7..ea5a2c3b4a 100644 --- a/lib/ruby_indexer/test/rbs_indexer_test.rb +++ b/lib/ruby_indexer/test/rbs_indexer_test.rb @@ -89,7 +89,7 @@ def test_attaches_correct_owner_to_singleton_methods owner = entries.first&.owner #: as Entry::SingletonClass assert_instance_of(Entry::SingletonClass, owner) - assert_equal("File::", owner.name) + assert_equal("File::", owner.name) end def test_location_and_name_location_are_the_same @@ -122,7 +122,7 @@ def test_rbs_method_with_required_positionals def test_rbs_method_with_unnamed_required_positionals entries = @index["try_convert"] #: as Array[Entry::Method] - entry = entries.find { |entry| entry.owner&.name == "Array::" } #: as Entry::Method + entry = entries.find { |entry| entry.owner&.name == "Array::" } #: as Entry::Method parameters = entry.signatures[0]&.parameters #: as Array[Entry::Parameter] @@ -132,7 +132,7 @@ def test_rbs_method_with_unnamed_required_positionals def test_rbs_method_with_optional_positionals entries = @index["polar"] #: as Array[Entry::Method] - entry = entries.find { |entry| entry.owner&.name == "Complex::" } #: as Entry::Method + entry = entries.find { |entry| entry.owner&.name == "Complex::" } #: as Entry::Method # def self.polar: (Numeric, ?Numeric) -> Complex @@ -190,7 +190,7 @@ def test_rbs_method_with_required_and_optional_parameters def test_rbs_anonymous_block_parameter entries = @index["open"] #: as Array[Entry::Method] - entry = entries.find { |entry| entry.owner&.name == "File::" } #: as Entry::Method + entry = entries.find { |entry| entry.owner&.name == "File::" } #: as Entry::Method assert_equal(2, entry.signatures.length) @@ -227,7 +227,7 @@ def test_rbs_method_with_rest_positionals def test_rbs_method_with_trailing_positionals entries = @index["select"] #: as Array[Entry::Method] - entry = entries.find { |entry| entry.owner&.name == "IO::" } #: as !nil + entry = entries.find { |entry| entry.owner&.name == "IO::" } #: as !nil signatures = entry.signatures assert_equal(2, signatures.length) diff --git a/lib/ruby_indexer/test/reference_finder_test.rb b/lib/ruby_indexer/test/reference_finder_test.rb index ed5028d5af..45be86db4a 100644 --- a/lib/ruby_indexer/test/reference_finder_test.rb +++ b/lib/ruby_indexer/test/reference_finder_test.rb @@ -28,7 +28,7 @@ class Bar end def test_finds_constant_references_inside_singleton_contexts - refs = find_const_references("Foo::::Bar", <<~RUBY) + refs = find_const_references("Foo::::Bar", <<~RUBY) class Foo class << self class Bar diff --git a/lib/ruby_lsp/listeners/test_style.rb b/lib/ruby_lsp/listeners/test_style.rb index b18ed7d320..b29aeb204b 100644 --- a/lib/ruby_lsp/listeners/test_style.rb +++ b/lib/ruby_lsp/listeners/test_style.rb @@ -266,7 +266,7 @@ def non_declarative_minitest?(attached_ancestors, fully_qualified_name) # We only support regular Minitest tests. The declarative syntax provided by ActiveSupport is handled by the # Rails add-on name_parts = fully_qualified_name.split("::") - singleton_name = "#{name_parts.join("::")}::" + singleton_name = "#{name_parts.join("::")}::<#{name_parts.last}>" !@index.linearized_ancestors_of(singleton_name).include?("ActiveSupport::Testing::Declarative") rescue RubyIndexer::Index::NonExistingNamespaceError true diff --git a/lib/ruby_lsp/node_context.rb b/lib/ruby_lsp/node_context.rb index 1fe2c4631b..48d06f82c1 100644 --- a/lib/ruby_lsp/node_context.rb +++ b/lib/ruby_lsp/node_context.rb @@ -62,12 +62,12 @@ def handle_nesting_nodes(nodes) when Prism::ClassNode, Prism::ModuleNode nesting << node.constant_path.slice when Prism::SingletonClassNode - nesting << "" + nesting << "<#{nesting.flat_map { |n| n.split("::") }.last}>" when Prism::DefNode surrounding_method = node.name.to_s next unless node.receiver.is_a?(Prism::SelfNode) - nesting << "" + nesting << "<#{nesting.flat_map { |n| n.split("::") }.last}>" end end diff --git a/lib/ruby_lsp/type_inferrer.rb b/lib/ruby_lsp/type_inferrer.rb index e0b75d4f9c..35fbd6b3c3 100644 --- a/lib/ruby_lsp/type_inferrer.rb +++ b/lib/ruby_lsp/type_inferrer.rb @@ -86,9 +86,9 @@ def infer_receiver_for_call_node(node, node_context) return unless name *parts, last = name.split("::") - return Type.new("#{last}::") if parts.empty? + return Type.new("#{last}::<#{last}>") if parts.empty? - Type.new("#{parts.join("::")}::#{last}::") + Type.new("#{parts.join("::")}::#{last}::<#{last}>") when Prism::CallNode raw_receiver = receiver.message @@ -142,7 +142,7 @@ def self_receiver_handling(node_context) # If the class/module definition is using compact style (e.g.: `class Foo::Bar`), then we need to split the name # into its individual parts to build the correct singleton name parts = nesting.flat_map { |part| part.split("::") } - Type.new("#{parts.join("::")}::") + Type.new("#{parts.join("::")}::<#{parts.last}>") end #: (NodeContext node_context) -> Type? @@ -152,7 +152,7 @@ def infer_receiver_for_class_variables(node_context) return Type.new("Object") if nesting_parts.empty? nesting_parts.reverse_each do |part| - break unless part.include?("` part from its name + # Returns the attached version of this type by removing the `<...>` part from its name #: -> Type def attached Type.new( diff --git a/test/requests/completion_resolve_test.rb b/test/requests/completion_resolve_test.rb index e42785bdcb..c7664f66a9 100644 --- a/test/requests/completion_resolve_test.rb +++ b/test/requests/completion_resolve_test.rb @@ -122,7 +122,7 @@ def test_indicates_signature_count_in_label_details existing_item = { label: "try_convert", kind: RubyLsp::Constant::CompletionItemKind::METHOD, - data: { owner_name: "String::" }, + data: { owner_name: "String::" }, } server.process_message(id: 1, method: "completionItem/resolve", params: existing_item) diff --git a/test/ruby_document_test.rb b/test/ruby_document_test.rb index 0b677a43bd..fe96e6fcff 100644 --- a/test/ruby_document_test.rb +++ b/test/ruby_document_test.rb @@ -953,15 +953,15 @@ def qux assert_nil(node_context.surrounding_method) node_context = document.locate_node({ line: 4, character: 4 }) - assert_equal(["Foo", ""], node_context.nesting) + assert_equal(["Foo", ""], node_context.nesting) assert_equal("bar", node_context.surrounding_method) node_context = document.locate_node({ line: 8, character: 4 }) - assert_equal(["Foo", ""], node_context.nesting) + assert_equal(["Foo", ""], node_context.nesting) assert_nil(node_context.surrounding_method) node_context = document.locate_node({ line: 11, character: 6 }) - assert_equal(["Foo", ""], node_context.nesting) + assert_equal(["Foo", ""], node_context.nesting) assert_equal("baz", node_context.surrounding_method) node_context = document.locate_node({ line: 16, character: 6 }) diff --git a/test/server_test.rb b/test/server_test.rb index 783e7af28e..41cc58afca 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -1067,7 +1067,7 @@ class Foo params: { textDocument: { uri: uri } }, }) - assert_equal(["Foo::", "Bar"], index.linearized_ancestors_of("Foo::")) + assert_equal(["Foo::", "Bar"], index.linearized_ancestors_of("Foo::")) # Delete the extend @server.process_message({ @@ -1106,7 +1106,7 @@ class Foo result = find_message(RubyLsp::Result, id: 2) refute_nil(result) - assert_equal(["Foo::"], index.linearized_ancestors_of("Foo::")) + assert_equal(["Foo::"], index.linearized_ancestors_of("Foo::")) end def test_edits_outside_of_declarations_do_not_trigger_indexing diff --git a/test/type_inferrer_test.rb b/test/type_inferrer_test.rb index 1425be9b5f..0afc1ef0e4 100644 --- a/test/type_inferrer_test.rb +++ b/test/type_inferrer_test.rb @@ -29,7 +29,7 @@ class Foo end RUBY - assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_self_inside_singleton_method @@ -41,7 +41,7 @@ def self.bar end RUBY - assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_self_inside_singleton_block_body @@ -53,7 +53,7 @@ class << self end RUBY - assert_equal("Foo::::>", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::::<>", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_self_inside_singleton_block_method @@ -67,7 +67,7 @@ def bar end RUBY - assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_constant @@ -79,7 +79,7 @@ def bar; end Foo.bar RUBY - assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_constant_path @@ -93,7 +93,7 @@ def baz; end Foo::Bar.baz RUBY - assert_equal("Foo::Bar::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::Bar::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_top_level_receiver @@ -111,7 +111,7 @@ class Foo end RUBY - assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_instance_variables_in_singleton_method @@ -123,7 +123,7 @@ def self.bar end RUBY - assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_instance_variables_in_singleton_block_body @@ -135,7 +135,7 @@ class << self end RUBY - assert_equal("Foo::::>", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::::<>", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_in_namespaced_singleton_method @@ -148,7 +148,7 @@ def self.foo RUBY result = @type_inferrer.infer_receiver_type(node_context).name - assert_equal("Foo::Bar::", result) + assert_equal("Foo::Bar::", result) end def test_infer_receiver_type_instance_variables_in_singleton_block_method @@ -162,7 +162,7 @@ def bar end RUBY - assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_instance_variables_in_instance_method @@ -376,7 +376,7 @@ class Admin::User end RUBY - assert_equal("Admin::User::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Admin::User::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_self_type_for_compact_namespace_inside_method @@ -400,7 +400,7 @@ def self.foo end RUBY - assert_equal("Admin::User::", @type_inferrer.infer_receiver_type(node_context).name) + assert_equal("Admin::User::", @type_inferrer.infer_receiver_type(node_context).name) end def test_infer_receiver_type_class_variables_in_class_body From 524d991732de9e6a0b12a4c2d65a28609fb17c67 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 18 Mar 2026 11:19:14 -0400 Subject: [PATCH 05/20] Migrate type inferrer to use Rubydex --- lib/ruby_lsp/global_state.rb | 2 +- lib/ruby_lsp/type_inferrer.rb | 38 +++++++++++++----------- test/requests/hover_expectations_test.rb | 2 ++ test/type_inferrer_test.rb | 8 +++-- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index 2673dec3a2..0836ca880b 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -63,7 +63,7 @@ def initialize @index = RubyIndexer::Index.new #: RubyIndexer::Index @graph = Rubydex::Graph.new #: Rubydex::Graph @supported_formatters = {} #: Hash[String, Requests::Support::Formatter] - @type_inferrer = TypeInferrer.new(@index) #: TypeInferrer + @type_inferrer = TypeInferrer.new(@graph) #: TypeInferrer @addon_settings = {} #: Hash[String, untyped] @top_level_bundle = begin Bundler.with_original_env { Bundler.default_gemfile } diff --git a/lib/ruby_lsp/type_inferrer.rb b/lib/ruby_lsp/type_inferrer.rb index 35fbd6b3c3..f6c8b93b8a 100644 --- a/lib/ruby_lsp/type_inferrer.rb +++ b/lib/ruby_lsp/type_inferrer.rb @@ -5,9 +5,9 @@ module RubyLsp # A minimalistic type checker to try to resolve types that can be inferred without requiring a type system or # annotations class TypeInferrer - #: (RubyIndexer::Index index) -> void - def initialize(index) - @index = index + #: (Rubydex::Graph) -> void + def initialize(graph) + @graph = graph end #: (NodeContext node_context) -> Type? @@ -81,11 +81,10 @@ def infer_receiver_for_call_node(node, node_context) receiver_name = RubyIndexer::Index.constant_name(receiver) return unless receiver_name - resolved_receiver = @index.resolve(receiver_name, node_context.nesting) - name = resolved_receiver&.first&.name - return unless name + resolved_receiver = @graph.resolve_constant(receiver_name, node_context.nesting) + return unless resolved_receiver - *parts, last = name.split("::") + *parts, last = resolved_receiver.name.split("::") return Type.new("#{last}::<#{last}>") if parts.empty? Type.new("#{parts.join("::")}::#{last}::<#{last}>") @@ -96,12 +95,14 @@ def infer_receiver_for_call_node(node, node_context) # When invoking `new`, we recursively infer the type of the receiver to get the class type its being invoked # on and then return the attached version of that type, since it's being instantiated. type = infer_receiver_for_call_node(receiver, node_context) - return unless type # If the method `new` was overridden, then we cannot assume that it will return a new instance of the class - new_method = @index.resolve_method("new", type.name)&.first - return if new_method && new_method.owner&.name != "Class" + declaration = @graph[type.name] #: as Rubydex::Namespace? + return unless declaration + + new_method = declaration.find_member("new()") + return if new_method && new_method.owner.name != "Class" type.attached elsif raw_receiver @@ -121,11 +122,11 @@ def guess_type(raw_receiver, nesting) .map(&:capitalize) .join - entries = @index.resolve(guessed_name, nesting) || @index.first_unqualified_const(guessed_name) - name = entries&.first&.name - return unless name + declaration = @graph.resolve_constant(guessed_name, nesting) + declaration ||= @graph.search(guessed_name).first + return unless declaration - GuessedType.new(name) + GuessedType.new(declaration.name) end #: (NodeContext node_context) -> Type @@ -148,7 +149,6 @@ def self_receiver_handling(node_context) #: (NodeContext node_context) -> Type? def infer_receiver_for_class_variables(node_context) nesting_parts = node_context.nesting.dup - return Type.new("Object") if nesting_parts.empty? nesting_parts.reverse_each do |part| @@ -157,9 +157,11 @@ def infer_receiver_for_class_variables(node_context) nesting_parts.pop end - receiver_name = nesting_parts.join("::") - resolved_receiver = @index.resolve(receiver_name, node_context.nesting)&.first - return unless resolved_receiver&.name + resolved_receiver = @graph.resolve_constant( + nesting_parts.last, #: as !nil + nesting_parts[0...-1], #: as !nil + ) + return unless resolved_receiver Type.new(resolved_receiver.name) end diff --git a/test/requests/hover_expectations_test.rb b/test/requests/hover_expectations_test.rb index ad80a65418..aff6fd9d49 100644 --- a/test/requests/hover_expectations_test.rb +++ b/test/requests/hover_expectations_test.rb @@ -670,6 +670,8 @@ def baz end def test_hover_for_methods_shows_overload_count + skip("[RUBYDEX] Temporarily skipped because we don't yet index RBS methods") + source = <<~RUBY String.try_convert RUBY diff --git a/test/type_inferrer_test.rb b/test/type_inferrer_test.rb index 0afc1ef0e4..ee8b6c1d35 100644 --- a/test/type_inferrer_test.rb +++ b/test/type_inferrer_test.rb @@ -6,8 +6,8 @@ module RubyLsp class TypeInferrerTest < Minitest::Test def setup - @index = RubyIndexer::Index.new - @type_inferrer = TypeInferrer.new(@index) + @graph = Rubydex::Graph.new + @type_inferrer = TypeInferrer.new(@graph) end def test_infer_receiver_type_self_inside_method @@ -499,7 +499,9 @@ class Foo private def index_and_locate(source, position) - @index.index_single(URI::Generic.from_path(path: "/fake/path/foo.rb"), source) + @graph.index_source(URI::Generic.from_path(path: "/fake/path/foo.rb").to_s, source, "ruby") + @graph.resolve + document = RubyLsp::RubyDocument.new( source: source, version: 1, From 05ddc2eb23a49fb3da75d100776cdfc9825843ac Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 25 Mar 2026 11:37:49 -0400 Subject: [PATCH 06/20] Upgrade Rubydex to v0.1.0.beta10 --- Gemfile.lock | 12 ++++++------ ...ydex@0.1.0.beta9.rbi => rubydex@0.1.0.beta10.rbi} | 10 +++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) rename sorbet/rbi/gems/{rubydex@0.1.0.beta9.rbi => rubydex@0.1.0.beta10.rbi} (97%) diff --git a/Gemfile.lock b/Gemfile.lock index b3d50cd0ac..dedb240b39 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,9 +92,9 @@ GEM rubocop (>= 1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - rubydex (0.1.0.beta9) - rubydex (0.1.0.beta9-arm64-darwin) - rubydex (0.1.0.beta9-x86_64-linux) + rubydex (0.1.0.beta10) + rubydex (0.1.0.beta10-arm64-darwin) + rubydex (0.1.0.beta10-x86_64-linux) sorbet (0.6.13055) sorbet-static (= 0.6.13055) sorbet-runtime (0.6.13055) @@ -204,9 +204,9 @@ CHECKSUMS ruby-lsp (0.26.9) ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - rubydex (0.1.0.beta9) sha256=575dcb951f627f63cc36fc00b4698839ca18fd71fd13999237fe129c3478f468 - rubydex (0.1.0.beta9-arm64-darwin) sha256=4f93a4871da4961544113603a0e2cc68b480d95707247f746116b217978994df - rubydex (0.1.0.beta9-x86_64-linux) sha256=6a9e6959ad02ca13aa554ada2a35d6c95ba6432adc7e87eb70714bf777f15372 + rubydex (0.1.0.beta10) sha256=73b179ba1565bfabf8e5ec0781ec9eade02a8de779e7ee12cd23cfbc17e2c60a + rubydex (0.1.0.beta10-arm64-darwin) sha256=97d571bbf941205b04c5865fa00ee7aaaf26f56d653ba0e2db6592b1ad0c423c + rubydex (0.1.0.beta10-x86_64-linux) sha256=32eadcdcf4fd1396c6d37e315e97e9cc390403ed3c0cdb56ed92f8c84b0aa775 sorbet (0.6.13055) sha256=5f5e8f37c13c281fa2b2f95e261d2e531d8331ddcc8e2dd8c4f16457935872ec sorbet-runtime (0.6.13055) sha256=c8ae8c81310e0a28d290b11f44ddca59659b7d7f13752c0ef5d16964bbb84d18 sorbet-static (0.6.13055-universal-darwin) sha256=649c8e79a443be85318922f9ecbb46be72f6c585443f4440c4ec0fb1737c86e8 diff --git a/sorbet/rbi/gems/rubydex@0.1.0.beta9.rbi b/sorbet/rbi/gems/rubydex@0.1.0.beta10.rbi similarity index 97% rename from sorbet/rbi/gems/rubydex@0.1.0.beta9.rbi rename to sorbet/rbi/gems/rubydex@0.1.0.beta10.rbi index ffae66aa1c..1ac213b4c8 100644 --- a/sorbet/rbi/gems/rubydex@0.1.0.beta9.rbi +++ b/sorbet/rbi/gems/rubydex@0.1.0.beta10.rbi @@ -244,6 +244,10 @@ class Rubydex::Graph sig { params(encoding: String).void } def encoding=(encoding); end + # source://rubydex//lib/rubydex.rb#10 + sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) } + def fuzzy_search(query); end + # source://rubydex//lib/rubydex.rb#10 sig { params(paths: T::Array[String]).returns(T::Array[String]) } def index_all(paths); end @@ -280,7 +284,7 @@ class Rubydex::Graph def resolve_require_path(require_path, load_path); end # source://rubydex//lib/rubydex.rb#10 - sig { params(query: String).returns(T::Array[Rubydex::Declaration]) } + sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) } def search(query); end # source://rubydex//lib/rubydex/graph.rb#17 @@ -425,6 +429,10 @@ class Rubydex::Namespace < ::Rubydex::Declaration sig { params(name: String).returns(T.nilable(Rubydex::Declaration)) } def member(name); end + # source://rubydex//lib/rubydex.rb#10 + sig { returns(T::Enumerable[Rubydex::Declaration]) } + def members; end + # source://rubydex//lib/rubydex.rb#10 sig { returns(T.nilable(Rubydex::SingletonClass)) } def singleton_class; end From 2557df80ffdf1d33404314a70eea454afb410e4c Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Fri, 20 Mar 2026 16:01:55 -0400 Subject: [PATCH 07/20] Partially migrate go to definition to Rubydex --- lib/ruby_lsp/internal.rb | 3 + lib/ruby_lsp/listeners/definition.rb | 162 +++++++---------- lib/ruby_lsp/rubydex/definition.rb | 65 +++++++ .../definition/class_reference.exp.json | 30 ++-- .../definition/constant_reference.exp.json | 32 ++-- test/fixtures/class_reference.rb | 5 +- test/fixtures/class_reference_target.rb | 9 - test/fixtures/constant_reference.rb | 4 +- test/fixtures/constant_reference_target.rb | 5 - test/requests/definition_expectations_test.rb | 167 ++++++++---------- test/server_test.rb | 4 + test/test_helper.rb | 8 + 12 files changed, 257 insertions(+), 237 deletions(-) create mode 100644 lib/ruby_lsp/rubydex/definition.rb delete mode 100644 test/fixtures/class_reference_target.rb delete mode 100644 test/fixtures/constant_reference_target.rb diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 7b3a04a000..cbff9bb3ce 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -31,6 +31,9 @@ require "shellwords" require "set" +# Rubydex LSP additions +require "ruby_lsp/rubydex/definition" + require "ruby-lsp" require "ruby_lsp/base_server" require "ruby_indexer/ruby_indexer" diff --git a/lib/ruby_lsp/listeners/definition.rb b/lib/ruby_lsp/listeners/definition.rb index 38478712cf..f0991ee218 100644 --- a/lib/ruby_lsp/listeners/definition.rb +++ b/lib/ruby_lsp/listeners/definition.rb @@ -12,7 +12,7 @@ class Definition def initialize(response_builder, global_state, language_id, uri, node_context, dispatcher, sorbet_level) # rubocop:disable Metrics/ParameterLists @response_builder = response_builder @global_state = global_state - @index = global_state.index #: RubyIndexer::Index + @graph = global_state.graph #: Rubydex::Graph @type_inferrer = global_state.type_inferrer #: TypeInferrer @language_id = language_id @uri = uri @@ -109,7 +109,7 @@ def on_constant_path_node_enter(node) name = RubyIndexer::Index.constant_name(node) return if name.nil? - find_in_index(name) + handle_constant_definition(name) end #: (Prism::ConstantReadNode node) -> void @@ -117,7 +117,7 @@ def on_constant_read_node_enter(node) name = RubyIndexer::Index.constant_name(node) return if name.nil? - find_in_index(name) + handle_constant_definition(name) end #: (Prism::GlobalVariableAndWriteNode node) -> void @@ -152,32 +152,32 @@ def on_global_variable_write_node_enter(node) #: (Prism::InstanceVariableReadNode node) -> void def on_instance_variable_read_node_enter(node) - handle_instance_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::InstanceVariableWriteNode node) -> void def on_instance_variable_write_node_enter(node) - handle_instance_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::InstanceVariableAndWriteNode node) -> void def on_instance_variable_and_write_node_enter(node) - handle_instance_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::InstanceVariableOperatorWriteNode node) -> void def on_instance_variable_operator_write_node_enter(node) - handle_instance_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::InstanceVariableOrWriteNode node) -> void def on_instance_variable_or_write_node_enter(node) - handle_instance_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::InstanceVariableTargetNode node) -> void def on_instance_variable_target_node_enter(node) - handle_instance_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::SuperNode node) -> void @@ -192,32 +192,32 @@ def on_forwarding_super_node_enter(node) #: (Prism::ClassVariableAndWriteNode node) -> void def on_class_variable_and_write_node_enter(node) - handle_class_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::ClassVariableOperatorWriteNode node) -> void def on_class_variable_operator_write_node_enter(node) - handle_class_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::ClassVariableOrWriteNode node) -> void def on_class_variable_or_write_node_enter(node) - handle_class_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::ClassVariableTargetNode node) -> void def on_class_variable_target_node_enter(node) - handle_class_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::ClassVariableReadNode node) -> void def on_class_variable_read_node_enter(node) - handle_class_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end #: (Prism::ClassVariableWriteNode node) -> void def on_class_variable_write_node_enter(node) - handle_class_variable_definition(node.name.to_s) + handle_variable_definition(node.name.to_s) end private @@ -257,93 +257,63 @@ def handle_super_node_definition #: (String name) -> void def handle_global_variable_definition(name) - entries = @index[name] + declaration = @graph[name] + return unless declaration - return unless entries - - entries.each do |entry| - location = entry.location - - @response_builder << Interface::Location.new( - uri: entry.uri.to_s, - range: Interface::Range.new( - start: Interface::Position.new(line: location.start_line - 1, character: location.start_column), - end: Interface::Position.new(line: location.end_line - 1, character: location.end_column), - ), - ) - end - end - - #: (String name) -> void - def handle_class_variable_definition(name) - type = @type_inferrer.infer_receiver_type(@node_context) - return unless type - - entries = @index.resolve_class_variable(name, type.name) - return unless entries - - entries.each do |entry| - @response_builder << Interface::Location.new( - uri: entry.uri.to_s, - range: range_from_location(entry.location), - ) - end - rescue RubyIndexer::Index::NonExistingNamespaceError - # If by any chance we haven't indexed the owner, then there's no way to find the right declaration + declaration.definitions.each { |definition| @response_builder << definition.to_lsp_selection_location } end + # Handle class or instance variables. We collect all definitions across the ancestors of the type + # #: (String name) -> void - def handle_instance_variable_definition(name) - # Sorbet enforces that all instance variables be declared on typed strict or higher, which means it will be able - # to provide all features for them + def handle_variable_definition(name) + # Sorbet enforces that all variables be declared on typed strict or higher, which means it will be able to + # provide all features for them return if @sorbet_level.strict? type = @type_inferrer.infer_receiver_type(@node_context) return unless type - entries = @index.resolve_instance_variable(name, type.name) - return unless entries + owner = @graph[type.name] + return unless owner.is_a?(Rubydex::Namespace) - entries.each do |entry| - location = entry.location + owner.ancestors.each do |ancestor| + member = ancestor.member(name) + next unless member - @response_builder << Interface::Location.new( - uri: entry.uri.to_s, - range: Interface::Range.new( - start: Interface::Position.new(line: location.start_line - 1, character: location.start_column), - end: Interface::Position.new(line: location.end_line - 1, character: location.end_column), - ), - ) + member.definitions.each { |definition| @response_builder << definition.to_lsp_selection_location } end - rescue RubyIndexer::Index::NonExistingNamespaceError - # If by any chance we haven't indexed the owner, then there's no way to find the right declaration end #: (String message, TypeInferrer::Type? receiver_type, ?inherited_only: bool) -> void def handle_method_definition(message, receiver_type, inherited_only: false) - methods = if receiver_type - @index.resolve_method(message, receiver_type.name, inherited_only: inherited_only) + declaration = if receiver_type + owner = @graph[receiver_type.name] + owner.find_member("#{message}()", only_inherited: inherited_only) if owner.is_a?(Rubydex::Namespace) end - # If the method doesn't have a receiver, or the guessed receiver doesn't have any matched candidates, - # then we provide a few candidates to jump to - # But we don't want to provide too many candidates, as it can be overwhelming - if receiver_type.nil? || (receiver_type.is_a?(TypeInferrer::GuessedType) && methods.nil?) - methods = @index[message]&.take(MAX_NUMBER_OF_DEFINITION_CANDIDATES_WITHOUT_RECEIVER) + # If the method doesn't have a receiver, or the guessed receiver doesn't have any matched candidates, then we + # provide a few candidates to jump to. However, we don't want to provide too many candidates, as it can be + # overwhelming + if receiver_type.nil? || (receiver_type.is_a?(TypeInferrer::GuessedType) && declaration.nil?) + declaration = @graph.search("##{message}()").take(MAX_NUMBER_OF_DEFINITION_CANDIDATES_WITHOUT_RECEIVER) end - return unless methods + return unless declaration - methods.each do |target_method| - uri = target_method.uri - full_path = uri.full_path - next if @sorbet_level.true_or_higher? && (!full_path || not_in_dependencies?(full_path)) + Array(declaration).each do |decl| + decl.definitions.each do |definition| + location = definition.location + uri = URI(location.uri) + full_path = uri.full_path + next if @sorbet_level.true_or_higher? && (!full_path || not_in_dependencies?(full_path)) - @response_builder << Interface::LocationLink.new( - target_uri: uri.to_s, - target_range: range_from_location(target_method.location), - target_selection_range: range_from_location(target_method.name_location), - ) + @response_builder << Interface::LocationLink.new( + target_uri: uri.to_s, + target_range: definition.to_lsp_selection_range, + target_selection_range: definition.to_lsp_name_range || definition.to_lsp_selection_range, + ) + end end end @@ -351,12 +321,10 @@ def handle_method_definition(message, receiver_type, inherited_only: false) def handle_require_definition(node, message) case message when :require - entry = @index.search_require_paths(node.content).find do |uri| - uri.require_path == node.content - end + document = @graph.resolve_require_path(node.content, $LOAD_PATH) - if entry - candidate = entry.full_path + if document + candidate = URI(document.uri).full_path if candidate @response_builder << Interface::Location.new( @@ -392,35 +360,33 @@ def handle_autoload_definition(node) constant_name = argument.value return unless constant_name - find_in_index(constant_name) + handle_constant_definition(constant_name) end #: (String value) -> void - def find_in_index(value) - entries = @index.resolve(value, @node_context.nesting) - return unless entries + def handle_constant_definition(value) + declaration = @graph.resolve_constant(value, @node_context.nesting) + return unless declaration + # [RUBYDEX] TODO: temporarily commented out until we have the visibility API + # # We should only allow jumping to the definition of private constants if the constant is defined in the same # namespace as the reference - first_entry = entries.first #: as !nil - return if first_entry.private? && first_entry.name != "#{@node_context.fully_qualified_name}::#{value}" + # + # return if declaration.private? && declaration.name != "#{@node_context.fully_qualified_name}::#{value}" - entries.each do |entry| + declaration.definitions.each do |definition| # If the project has Sorbet, then we only want to handle go to definition for constants defined in gems, as an # additional behavior on top of jumping to RBIs. The only sigil where Sorbet cannot handle constants is typed # ignore - uri = entry.uri + uri = URI(definition.location.uri) full_path = uri.full_path if !@sorbet_level.ignore? && (!full_path || not_in_dependencies?(full_path)) next end - @response_builder << Interface::LocationLink.new( - target_uri: uri.to_s, - target_range: range_from_location(entry.location), - target_selection_range: range_from_location(entry.name_location), - ) + @response_builder << definition.to_lsp_location_link end end end diff --git a/lib/ruby_lsp/rubydex/definition.rb b/lib/ruby_lsp/rubydex/definition.rb new file mode 100644 index 0000000000..c82da168e3 --- /dev/null +++ b/lib/ruby_lsp/rubydex/definition.rb @@ -0,0 +1,65 @@ +# typed: strict +# frozen_string_literal: true + +module Rubydex + class Definition + #: () -> RubyLsp::Interface::LocationLink + def to_lsp_location_link + selection_range = to_lsp_selection_range + + RubyLsp::Interface::LocationLink.new( + target_uri: location.uri, + target_range: selection_range, + target_selection_range: to_lsp_name_range || selection_range, + ) + end + + #: () -> RubyLsp::Interface::Range + def to_lsp_selection_range + loc = location + + RubyLsp::Interface::Range.new( + start: RubyLsp::Interface::Position.new(line: loc.start_line, character: loc.start_column), + end: RubyLsp::Interface::Position.new(line: loc.end_line, character: loc.end_column), + ) + end + + #: () -> RubyLsp::Interface::Location + def to_lsp_selection_location + location = self.location + + RubyLsp::Interface::Location.new( + uri: location.uri, + range: RubyLsp::Interface::Range.new( + start: RubyLsp::Interface::Position.new(line: location.start_line, character: location.start_column), + end: RubyLsp::Interface::Position.new(line: location.end_line, character: location.end_column), + ), + ) + end + + #: () -> RubyLsp::Interface::Range? + def to_lsp_name_range + loc = name_location + return unless loc + + RubyLsp::Interface::Range.new( + start: RubyLsp::Interface::Position.new(line: loc.start_line, character: loc.start_column), + end: RubyLsp::Interface::Position.new(line: loc.end_line, character: loc.end_column), + ) + end + + #: () -> RubyLsp::Interface::Location? + def to_lsp_name_location + location = name_location + return unless location + + RubyLsp::Interface::Location.new( + uri: location.uri, + range: RubyLsp::Interface::Range.new( + start: RubyLsp::Interface::Position.new(line: location.start_line, character: location.start_column), + end: RubyLsp::Interface::Position.new(line: location.end_line, character: location.end_column), + ), + ) + end + end +end diff --git a/test/expectations/definition/class_reference.exp.json b/test/expectations/definition/class_reference.exp.json index 2acd501651..245cb01ad4 100644 --- a/test/expectations/definition/class_reference.exp.json +++ b/test/expectations/definition/class_reference.exp.json @@ -1,33 +1,33 @@ { + "params": [ + { + "line": 3, + "character": 10 + } + ], "result": [ { - "targetUri": "file:///fixtures/class_reference_target.rb", + "targetUri": "file:////fake.rb", "targetSelectionRange": { "start": { - "line": 4, - "character": 8 + "line": 0, + "character": 6 }, "end": { - "line": 4, - "character": 20 + "line": 0, + "character": 12 } }, "targetRange": { "start": { - "line": 4, - "character": 2 + "line": 0, + "character": 0 }, "end": { - "line": 7, - "character": 5 + "line": 1, + "character": 3 } } } - ], - "params": [ - { - "line": 0, - "character": 19 - } ] } diff --git a/test/expectations/definition/constant_reference.exp.json b/test/expectations/definition/constant_reference.exp.json index 4376a1af2a..18b5a95bb9 100644 --- a/test/expectations/definition/constant_reference.exp.json +++ b/test/expectations/definition/constant_reference.exp.json @@ -1,33 +1,33 @@ { + "params": [ + { + "line": 2, + "character": 10 + } + ], "result": [ { - "targetUri": "file:///fixtures/constant_reference_target.rb", - "targetSelectionRange": { + "targetUri": "file:////fake.rb", + "targetRange": { "start": { - "line": 3, - "character": 7 + "line": 0, + "character": 0 }, "end": { - "line": 3, - "character": 10 + "line": 0, + "character": 6 } }, - "targetRange": { + "targetSelectionRange": { "start": { - "line": 3, + "line": 0, "character": 0 }, "end": { - "line": 4, - "character": 3 + "line": 0, + "character": 6 } } } - ], - "params": [ - { - "line": 0, - "character": 12 - } ] } diff --git a/test/fixtures/class_reference.rb b/test/fixtures/class_reference.rb index b7db8e8b68..8fd48fae09 100644 --- a/test/fixtures/class_reference.rb +++ b/test/fixtures/class_reference.rb @@ -1 +1,4 @@ -example = RubyLsp::ExampleClass.new +class Target +end + +example = Target.new diff --git a/test/fixtures/class_reference_target.rb b/test/fixtures/class_reference_target.rb deleted file mode 100644 index e1aeb6caa1..0000000000 --- a/test/fixtures/class_reference_target.rb +++ /dev/null @@ -1,9 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -module RubyLsp - class ExampleClass - def foo - end - end -end diff --git a/test/fixtures/constant_reference.rb b/test/fixtures/constant_reference.rb index be785de36a..1473e12e1d 100644 --- a/test/fixtures/constant_reference.rb +++ b/test/fixtures/constant_reference.rb @@ -1 +1,3 @@ -example = Foo +TARGET = 1 + +example = TARGET diff --git a/test/fixtures/constant_reference_target.rb b/test/fixtures/constant_reference_target.rb deleted file mode 100644 index d6937fd100..0000000000 --- a/test/fixtures/constant_reference_target.rb +++ /dev/null @@ -1,5 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -module Foo -end diff --git a/test/requests/definition_expectations_test.rb b/test/requests/definition_expectations_test.rb index 5845300aac..3ad790a633 100644 --- a/test/requests/definition_expectations_test.rb +++ b/test/requests/definition_expectations_test.rb @@ -18,34 +18,9 @@ def run_expectations(source) with_server(source, stub_no_typechecker: true) do |server, uri| position = @__params&.first || { character: 0, line: 0 } - index = server.global_state.index - - index.index_file( - URI::Generic.from_path( - load_path_entry: "#{Dir.pwd}/lib", - path: File.expand_path( - "../../test/fixtures/class_reference_target.rb", - __dir__, - ), - ), - ) - index.index_file( - URI::Generic.from_path( - path: File.expand_path( - "../../test/fixtures/constant_reference_target.rb", - __dir__, - ), - ), - ) - index.index_file( - URI::Generic.from_path( - load_path_entry: "#{Dir.pwd}/lib", - path: File.expand_path( - "../../lib/ruby_lsp/server.rb", - __dir__, - ), - ), - ) + graph = server.global_state.graph + graph.index_all([File.expand_path("../../lib/ruby_lsp/server.rb", __dir__)]) + graph.resolve server.process_message( id: 1, @@ -78,10 +53,12 @@ def run_expectations(source) end end - def test_jumping_to_default_gems + def test_jumping_to_rbs with_server("Pathname") do |server, uri| - index = server.global_state.index - index.index_file(URI::Generic.from_path(path: "#{RbConfig::CONFIG["rubylibdir"]}/pathname.rb")) + graph = server.global_state.graph + graph.index_all([gem_path("rbs").join("core").join("pathname.rbs").to_s]) + graph.resolve + server.process_message( id: 1, method: "textDocument/definition", @@ -161,26 +138,21 @@ class Baz end end - def test_jumping_to_default_require_of_a_gem - with_server("require \"bundler\"") do |server, uri| - index = server.global_state.index - - bundler_uri = URI::Generic.from_path( - path: "#{RbConfig::CONFIG["rubylibdir"]}/bundler.rb", - load_path_entry: RbConfig::CONFIG["rubylibdir"], - ) - index.index_file(bundler_uri) - - Dir.glob("#{RbConfig::CONFIG["rubylibdir"]}/bundler/*.rb").each do |path| - index.index_file(URI::Generic.from_path(load_path_entry: RbConfig::CONFIG["rubylibdir"], path: path)) - end + def test_jumping_to_a_gem_default_require + with_server("require \"minitest\"") do |server, uri| + graph = server.global_state.graph + minitest_paths = Dir.glob("#{gem_path("minitest")}/**/*.rb") + minitest_path = gem_path("minitest").join("lib").join("minitest.rb").to_s + minitest_paths << minitest_path + graph.index_all(minitest_paths) + graph.resolve server.process_message( id: 1, method: "textDocument/definition", params: { textDocument: { uri: uri }, position: { character: 10, line: 0 } }, ) - assert_equal(bundler_uri.to_s, server.pop_response.response.first.attributes[:uri]) + assert_equal(URI::Generic.from_path(path: minitest_path).to_s, server.pop_response.response.first.attributes[:uri]) end end @@ -208,6 +180,8 @@ class A end def test_jumping_to_private_constant_from_different_namespace + skip("[RUBYDEX] Requires the visibility API") + source = <<~RUBY class A CONST = 123 @@ -229,31 +203,25 @@ class A def test_definition_addons source = <<~RUBY - RubyLsp + class Target + end + + Target RUBY begin create_definition_addon with_server(source, stub_no_typechecker: true, load_addons: true) do |server, uri| - server.global_state.index.index_file( - URI::Generic.from_path( - load_path_entry: "#{Dir.pwd}/lib", - path: File.expand_path( - "../../test/fixtures/class_reference_target.rb", - __dir__, - ), - ), - ) server.process_message( id: 1, method: "textDocument/definition", - params: { textDocument: { uri: uri }, position: { character: 0, line: 0 } }, + params: { textDocument: { uri: uri }, position: { character: 0, line: 3 } }, ) response = server.pop_response.response assert_equal(2, response.size) - assert_match("class_reference_target.rb", response[0].target_uri) + assert_match("fake.rb", response[0].target_uri) assert_match("generated_by_addon.rb", response[1].uri) end ensure @@ -317,9 +285,9 @@ def foo; end }, }, }) - index = server.global_state.index - path = second_uri.to_standardized_path #: as !nil - index.index_single(URI::Generic.from_path(path: path), second_source) + graph = server.global_state.graph + graph.index_source(second_uri.to_s, second_source, "ruby") + graph.resolve server.process_message( id: 1, @@ -387,16 +355,15 @@ class Foo RUBY with_server(source) do |server, uri| - server.global_state.index.index_single( - URI::Generic.from_path(path: "/fake/path/bar.rb"), <<~RUBY - class Foo::Bar; end - RUBY - ) - server.global_state.index.index_single( - URI::Generic.from_path(path: "/fake/path/baz.rb"), <<~RUBY - class Foo::Bar; end - RUBY - ) + graph = server.global_state.graph + graph.index_source(URI::Generic.from_path(path: "/fake/path/bar.rb").to_s, <<~RUBY, "ruby") + class Foo::Bar; end + RUBY + graph.index_source(URI::Generic.from_path(path: "/fake/path/baz.rb").to_s, <<~RUBY, "ruby") + class Foo::Bar; end + RUBY + graph.resolve + server.process_message( id: 1, method: "textDocument/definition", @@ -461,6 +428,7 @@ def foo; end ) response = server.pop_response.response assert_equal(2, response.size) + response.sort_by! { |location| location.target_range.start.line } assert_equal(1, response[0].target_range.start.line) assert_equal(1, response[0].target_range.end.line) @@ -539,6 +507,7 @@ def foo; end ) response = server.pop_response.response assert_equal(2, response.size) + response.sort_by! { |location| location.target_range.start.line } assert_equal(1, response[0].target_range.start.line) assert_equal(1, response[0].target_range.end.line) @@ -589,7 +558,7 @@ class Foo end end - def test_methods_with_dynamic_namespace_is_also_suggested + def test_members_of_dynamic_namespaces_are_not_found source = <<~RUBY # typed: false @@ -610,11 +579,7 @@ def bar ) response = server.pop_response.response - assert_equal(1, response.size) - - range = response[0].attributes[:targetRange].attributes - range_hash = { start: range[:start].to_hash, end: range[:end].to_hash } - assert_equal({ start: { line: 3, character: 2 }, end: { line: 3, character: 14 } }, range_hash) + assert_empty(response) end end @@ -642,6 +607,7 @@ def foo; end response = server.pop_response.response assert_equal(2, response.size) + response.sort_by! { |location| location.target_range.start.line } range = response[0].attributes[:targetRange].attributes range_hash = { start: range[:start].to_hash, end: range[:end].to_hash } @@ -748,8 +714,9 @@ def test_definitions_are_listed_in_erb_files_as_unknown_receiver ERB with_server(source, URI("/fake.erb")) do |server, uri| - server.global_state.index.index_single( - URI::Generic.from_path(path: "/fake/path/foo.rb"), <<~RUBY + graph = server.global_state.graph + graph.index_source( + URI::Generic.from_path(path: "/fake/path/foo.rb").to_s, <<~RUBY, "ruby" class Bar def foo; end @@ -757,6 +724,7 @@ def bar; end end RUBY ) + graph.resolve server.process_message( id: 1, @@ -827,6 +795,32 @@ def baz end end + def test_inherited_class_variables + source = <<~RUBY + class Foo + @@hello = 123 + end + + class Bar < Foo + def self.hello + @@hello + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { line: 6, character: 4 } }, + ) + + response = server.pop_response.response + assert_equal(1, response.size) + assert_equal(1, response[0].range.start.line) + end + end + def test_definition_for_global_variables source = <<~RUBY $bar &&= 1 @@ -838,9 +832,6 @@ def test_definition_for_global_variables RUBY with_server(source) do |server, uri| - index = server.instance_variable_get(:@global_state).index - RubyIndexer::RBSIndexer.new(index).index_ruby_core - server.process_message( id: 1, method: "textDocument/definition", @@ -861,20 +852,11 @@ def test_definition_for_global_variables response = server.pop_response.response assert_equal(3, response.size) + + response.sort_by! { |location| location.range.start.line } assert_equal(2, response[0].range.start.line) assert_equal(3, response[1].range.start.line) assert_equal(4, response[2].range.start.line) - - server.process_message( - id: 1, - method: "textDocument/definition", - params: { textDocument: { uri: uri }, position: { character: 1, line: 5 } }, - ) - - response = server.pop_response.response.first - assert_match(%r{/gems/rbs-.*/core/global_variables.rbs}, response.uri) - assert_equal(response.range.start.line, response.range.end.line) - assert_operator(response.range.start.character, :<, response.range.end.character) end end @@ -1003,6 +985,7 @@ def self.baz params: { textDocument: { uri: uri }, position: { character: 4, line: 1 } }, ) response = server.pop_response.response + response.sort_by! { |location| location.range.start.line } assert_equal(1, response[0].range.start.line) assert_equal(4, response[1].range.start.line) diff --git a/test/server_test.rb b/test/server_test.rb index 41cc58afca..65a65c7c30 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -934,6 +934,10 @@ def test_cancelling_requests_returns_expected_error_code def test_requests_cancelled_during_processing_are_deleted_from_cancelled_requests_list uri = URI("file:///foo.rb") + graph = @server.global_state.graph + graph.index_source(uri.to_s, "class Foo\nend", "ruby") + graph.resolve + @server.process_message({ method: "textDocument/didOpen", params: { diff --git a/test/test_helper.rb b/test/test_helper.rb index 11e27795cc..8d936a75de 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -24,5 +24,13 @@ class Test include RubyLsp::TestHelper Minitest::Test.make_my_diffs_pretty! + + # Returns full path to the requested gem + # + #: (String) -> Pathname + def gem_path(gem_name) + spec = Gem::Specification.find_by_name(gem_name) + Pathname.new(spec.full_gem_path) + end end end From 33a2960b975ed40fd0174f4b16071e9738f1b3a0 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Thu, 19 Mar 2026 12:43:39 -0400 Subject: [PATCH 08/20] Migrate workspace symbol to use Rubydex --- lib/ruby_lsp/requests/workspace_symbol.rb | 52 +++------ lib/ruby_lsp/rubydex/definition.rb | 135 ++++++++++++++++++++++ test/requests/workspace_symbol_test.rb | 83 ++++++------- 3 files changed, 194 insertions(+), 76 deletions(-) diff --git a/lib/ruby_lsp/requests/workspace_symbol.rb b/lib/ruby_lsp/requests/workspace_symbol.rb index f41be04957..ec84973a6b 100644 --- a/lib/ruby_lsp/requests/workspace_symbol.rb +++ b/lib/ruby_lsp/requests/workspace_symbol.rb @@ -12,54 +12,32 @@ class WorkspaceSymbol < Request #: (GlobalState global_state, String? query) -> void def initialize(global_state, query) super() - @global_state = global_state @query = query - @index = global_state.index #: RubyIndexer::Index + @graph = global_state.graph #: Rubydex::Graph end # @override #: -> Array[Interface::WorkspaceSymbol] def perform - fuzzy_search.filter_map do |entry| - kind = kind_for_entry(entry) - loc = entry.location + response = [] - # We use the namespace as the container name, but we also use the full name as the regular name. The reason we - # do this is to allow people to search for fully qualified names (e.g.: `Foo::Bar`). If we only included the - # short name `Bar`, then searching for `Foo::Bar` would not return any results - *container, _short_name = entry.name.split("::") + @graph.fuzzy_search(@query || "").each do |declaration| + name = declaration.name - Interface::WorkspaceSymbol.new( - name: entry.name, - container_name: container.join("::"), - kind: kind, - location: Interface::Location.new( - uri: entry.uri.to_s, - range: Interface::Range.new( - start: Interface::Position.new(line: loc.start_line - 1, character: loc.start_column), - end: Interface::Position.new(line: loc.end_line - 1, character: loc.end_column), - ), - ), - ) - end - end - - private + declaration.definitions.each do |definition| + location = definition.location + uri = URI(location.uri) + file_path = uri.full_path - #: -> Array[RubyIndexer::Entry] - def fuzzy_search - @index.fuzzy_search(@query) do |entry| - file_path = entry.uri.full_path + # We only show symbols declared in the workspace + in_dependencies = file_path && !not_in_dependencies?(file_path) + next if in_dependencies - # We only show symbols declared in the workspace - in_dependencies = file_path && !not_in_dependencies?(file_path) - next if in_dependencies - - # We should never show private symbols when searching the entire workspace - next if entry.private? - - true + response << definition.to_lsp_workspace_symbol(name) + end end + + response end end end diff --git a/lib/ruby_lsp/rubydex/definition.rb b/lib/ruby_lsp/rubydex/definition.rb index c82da168e3..ca04f2391e 100644 --- a/lib/ruby_lsp/rubydex/definition.rb +++ b/lib/ruby_lsp/rubydex/definition.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true module Rubydex + # @abstract class Definition #: () -> RubyLsp::Interface::LocationLink def to_lsp_location_link @@ -14,6 +15,28 @@ def to_lsp_location_link ) end + # @abstract + #: () -> Integer + def to_lsp_kind + raise RubyLsp::AbstractMethodInvokedError + end + + #: (String name) -> RubyLsp::Interface::WorkspaceSymbol + def to_lsp_workspace_symbol(name) + # We use the namespace as the container name, but we also use the full name as the regular name. The reason we do + # this is to allow people to search for fully qualified names (e.g.: `Foo::Bar`). If we only included the short + # name `Bar`, then searching for `Foo::Bar` would not return any results + *container, _short_name = name.split("::") + container_name = container.join("::") + + RubyLsp::Interface::WorkspaceSymbol.new( + name: name, + container_name: container_name, + kind: to_lsp_kind, + location: to_lsp_selection_location, + ) + end + #: () -> RubyLsp::Interface::Range def to_lsp_selection_range loc = location @@ -62,4 +85,116 @@ def to_lsp_name_location ) end end + + class ClassDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::CLASS + end + end + + class ModuleDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::NAMESPACE + end + end + + class SingletonClassDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::CLASS + end + end + + class ConstantDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::CONSTANT + end + end + + class ConstantAliasDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::CONSTANT + end + end + + class MethodDefinition + # @override + #: () -> Integer + def to_lsp_kind + name == "initialize()" ? RubyLsp::Constant::SymbolKind::CONSTRUCTOR : RubyLsp::Constant::SymbolKind::METHOD + end + end + + class MethodAliasDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::METHOD + end + end + + class AttrReaderDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::PROPERTY + end + end + + class AttrWriterDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::PROPERTY + end + end + + class AttrAccessorDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::PROPERTY + end + end + + class InstanceVariableDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::FIELD + end + end + + class ClassVariableDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::FIELD + end + end + + class GlobalVariableDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::VARIABLE + end + end + + class GlobalVariableAliasDefinition + # @override + #: () -> Integer + def to_lsp_kind + RubyLsp::Constant::SymbolKind::VARIABLE + end + end end diff --git a/test/requests/workspace_symbol_test.rb b/test/requests/workspace_symbol_test.rb index 0f683abb94..ff29f139ad 100644 --- a/test/requests/workspace_symbol_test.rb +++ b/test/requests/workspace_symbol_test.rb @@ -7,18 +7,18 @@ class WorkspaceSymbolTest < Minitest::Test def setup @global_state = RubyLsp::GlobalState.new @global_state.stubs(:has_type_checker).returns(false) - @index = @global_state.index + @graph = @global_state.graph end def test_returns_index_entries_based_on_query - @index.index_single(URI::Generic.from_path(path: "/fake.rb"), <<~RUBY) + index_source(<<~RUBY) class Foo; end module Bar; end CONSTANT = 1 RUBY - result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Foo").perform.first + result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Fo").perform.first assert_equal("Foo", result&.name) assert_equal(RubyLsp::Constant::SymbolKind::CLASS, result&.kind) @@ -31,29 +31,8 @@ module Bar; end assert_equal(RubyLsp::Constant::SymbolKind::CONSTANT, result&.kind) end - def test_fuzzy_matches_symbols - @index.index_single(URI::Generic.from_path(path: "/fake.rb"), <<~RUBY) - class Foo; end - module Bar; end - - CONSTANT = 1 - RUBY - - result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Floo").perform.first - assert_equal("Foo", result&.name) - assert_equal(RubyLsp::Constant::SymbolKind::CLASS, result&.kind) - - result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Bear").perform.first - assert_equal("Bar", result&.name) - assert_equal(RubyLsp::Constant::SymbolKind::NAMESPACE, result&.kind) - - result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "CONF").perform.first - assert_equal("CONSTANT", result&.name) - assert_equal(RubyLsp::Constant::SymbolKind::CONSTANT, result&.kind) - end - def test_symbols_include_container_name - @index.index_single(URI::Generic.from_path(path: "/fake.rb"), <<~RUBY) + index_source(<<~RUBY) module Foo class Bar; end end @@ -66,27 +45,44 @@ class Bar; end end def test_does_not_include_symbols_from_dependencies - @index.index_file(URI::Generic.from_path(path: "#{RbConfig::CONFIG["rubylibdir"]}/pathname.rb")) + @graph.index_all(["#{RbConfig::CONFIG["rubylibdir"]}/pathname.rb"]) + @graph.resolve result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Pathname").perform assert_empty(result) end - def test_does_not_include_private_constants - @index.index_single(URI::Generic.from_path(path: "/fake.rb"), <<~RUBY) + def test_includes_private_and_protected_symbols + index_source(<<~RUBY) class Foo CONSTANT = 1 private_constant(:CONSTANT) + + private + + def secret; end + + protected + + def internal; end end RUBY - result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Foo::CONSTANT").perform - assert_equal(1, result.length) - assert_equal("Foo", result.first&.name) + result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Foo::CONSTANT").perform.first + assert_equal("Foo::CONSTANT", result&.name) + assert_equal(RubyLsp::Constant::SymbolKind::CONSTANT, result&.kind) + + result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Foo#secret").perform.first + assert_equal("Foo#secret()", result&.name) + assert_equal(RubyLsp::Constant::SymbolKind::METHOD, result&.kind) + + result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Foo#internal").perform.first + assert_equal("Foo#internal()", result&.name) + assert_equal(RubyLsp::Constant::SymbolKind::METHOD, result&.kind) end def test_returns_method_symbols - @index.index_single(URI::Generic.from_path(path: "/fake.rb"), <<~RUBY) + index_source(<<~RUBY) class Foo attr_reader :baz @@ -95,26 +91,35 @@ def bar; end end RUBY - result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "bar").perform.first - assert_equal("bar", result&.name) + result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Foo#bar").perform.first + assert_equal("Foo#bar()", result&.name) assert_equal(RubyLsp::Constant::SymbolKind::METHOD, result&.kind) - result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "initialize").perform.first - assert_equal("initialize", result&.name) + result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Foo#initialize").perform.first + assert_equal("Foo#initialize()", result&.name) assert_equal(RubyLsp::Constant::SymbolKind::CONSTRUCTOR, result&.kind) - result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "baz").perform.first - assert_equal("baz", result&.name) + result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Foo#baz").perform.first + assert_equal("Foo#baz()", result&.name) assert_equal(RubyLsp::Constant::SymbolKind::PROPERTY, result&.kind) end def test_returns_symbols_from_unsaved_files - @index.index_single(URI("untitled:Untitled-1"), <<~RUBY) + @graph.index_source("untitled:Untitled-1", <<~RUBY, "ruby") class Foo; end RUBY + @graph.resolve result = RubyLsp::Requests::WorkspaceSymbol.new(@global_state, "Foo").perform.first assert_equal("Foo", result&.name) assert_equal(RubyLsp::Constant::SymbolKind::CLASS, result&.kind) end + + private + + #: (String, ?uri: String) -> void + def index_source(source, uri: URI::Generic.from_path(path: "/fake.rb").to_s) + @graph.index_source(uri, source, "ruby") + @graph.resolve + end end From 28174aa0ae1dad7fc356e230d06d4c8e86b9cd1a Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Thu, 26 Mar 2026 14:57:07 -0400 Subject: [PATCH 09/20] Update graph on edits --- lib/ruby_lsp/server.rb | 28 +++++++++++++++++- lib/ruby_lsp/store.rb | 6 ---- test/server_test.rb | 8 ++--- test/store_test.rb | 67 ------------------------------------------ 4 files changed, 31 insertions(+), 78 deletions(-) diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 4bf6edc73b..4d34255de7 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -429,7 +429,16 @@ def text_document_did_change(message) params = message[:params] text_document = params[:textDocument] - @store.push_edits(uri: text_document[:uri], edits: params[:contentChanges], version: text_document[:version]) + document = @store.get(text_document[:uri]) + document.push_edits(params[:contentChanges], version: text_document[:version]) + + language_id = document.language_id + + if [:ruby, :rbs].include?(language_id) + graph = @global_state.graph + graph.index_source(text_document[:uri].to_s, document.source, language_id.to_s) + graph.resolve + end end #: (Hash[Symbol, untyped] message) -> void @@ -1047,6 +1056,23 @@ def workspace_did_change_watched_files(message) # is fine, but we shouldn't process the same file changes more than once changes.uniq! + graph = @global_state.graph + + # Handle deletions and accumulate additions and changes for indexing + additions_and_changes = changes.each_with_object([]) do |change, acc| + if change[:type] == Constant::FileChangeType::DELETED + graph.delete_document(change[:uri]) + else + path = URI(change[:uri]).to_standardized_path + next if path.nil? + next unless File.directory?(path) || [".rb", ".rbs"].include?(File.extname(path)) + + acc << path + end + end + graph.index_all(additions_and_changes) + graph.resolve + index = @global_state.index changes.each do |change| # File change events include folders, but we're only interested in files diff --git a/lib/ruby_lsp/store.rb b/lib/ruby_lsp/store.rb index 18295ac77f..a030a743dc 100644 --- a/lib/ruby_lsp/store.rb +++ b/lib/ruby_lsp/store.rb @@ -53,12 +53,6 @@ def set(uri:, source:, version:, language_id:) end end - #: (uri: URI::Generic, edits: Array[Hash[Symbol, untyped]], version: Integer) -> void - def push_edits(uri:, edits:, version:) - @state[uri.to_s] #: as !nil - .push_edits(edits, version: version) - end - #: -> void def clear @state.clear diff --git a/test/server_test.rb b/test/server_test.rb index 65a65c7c30..8e6a0d5856 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -465,7 +465,7 @@ def test_changed_file_only_indexes_ruby type: RubyLsp::Constant::FileChangeType::CREATED, }, { - uri: URI("file:///.rubocop.yml"), + uri: URI("file:///.rubocop.yml").to_s, type: RubyLsp::Constant::FileChangeType::CREATED, }, ], @@ -505,7 +505,7 @@ def test_did_change_watched_files_handles_deletions uri.full_path == path end - uri = URI::Generic.from_path(path: path) + uri = URI::Generic.from_path(path: path).to_s @server.global_state.index.index_all(uris: []) @server.process_message({ @@ -1185,7 +1185,7 @@ def test_rubocop_config_changes_trigger_workspace_diagnostic_refresh @server.global_state.index.index_all(uris: []) [".rubocop.yml", ".rubocop", ".rubocop_todo.yml"].each do |config_file| - uri = URI::Generic.from_path(path: File.join(Dir.pwd, config_file)) + uri = URI::Generic.from_path(path: File.join(Dir.pwd, config_file)).to_s @server.process_message({ method: "workspace/didChangeWatchedFiles", @@ -1334,7 +1334,7 @@ class Foo end RUBY File.write(path, source) - uri = URI::Generic.from_path(path: path) + uri = URI::Generic.from_path(path: path).to_s begin @server.process_message({ diff --git a/test/store_test.rb b/test/store_test.rb index 358ced731e..bea192b46b 100644 --- a/test/store_test.rb +++ b/test/store_test.rb @@ -168,73 +168,6 @@ def test_cache assert_equal(2, counter) end - def test_push_edits - uri = URI("file:///foo/bar.rb") - @store.set(uri: uri, source: +"def bar; end", version: 1, language_id: :ruby) - - # Write puts 'a' in incremental edits - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 8 }, end: { line: 0, character: 8 } }, text: " " }], - version: 2, - ) - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } }, text: "p" }], - version: 3, - ) - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 10 }, end: { line: 0, character: 10 } }, text: "u" }], - version: 4, - ) - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 11 }, end: { line: 0, character: 11 } }, text: "t" }], - version: 5, - ) - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 12 }, end: { line: 0, character: 12 } }, text: "s" }], - version: 6, - ) - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, text: " " }], - version: 7, - ) - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 14 }, end: { line: 0, character: 14 } }, text: "'" }], - version: 8, - ) - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 15 }, end: { line: 0, character: 15 } }, text: "a" }], - version: 9, - ) - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 16 }, end: { line: 0, character: 16 } }, text: "'" }], - version: 10, - ) - @store.push_edits( - uri: uri, - edits: [{ range: { start: { line: 0, character: 17 }, end: { line: 0, character: 17 } }, text: ";" }], - version: 11, - ) - - assert_equal( - RubyLsp::RubyDocument.new( - source: "def bar; puts 'a'; end", - version: 1, - uri: uri, - global_state: @global_state, - ), - @store.get(uri), - ) - end - def test_raises_non_existing_document_error_on_unknown_unsaved_files assert_raises(RubyLsp::Store::NonExistingDocumentError) do @store.get(URI("untitled:Untitled-1")) From 2ddf3b6ff3944b8c55af1afc18f514ae6eabea5c Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Fri, 27 Mar 2026 15:26:09 -0400 Subject: [PATCH 10/20] Upgrade to Rubydex v0.1.0.beta11 --- Gemfile.lock | 12 ++++++------ ...dex@0.1.0.beta10.rbi => rubydex@0.1.0.beta11.rbi} | 2 ++ test/requests/definition_expectations_test.rb | 1 + 3 files changed, 9 insertions(+), 6 deletions(-) rename sorbet/rbi/gems/{rubydex@0.1.0.beta10.rbi => rubydex@0.1.0.beta11.rbi} (99%) diff --git a/Gemfile.lock b/Gemfile.lock index dedb240b39..937f7568e8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,9 +92,9 @@ GEM rubocop (>= 1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - rubydex (0.1.0.beta10) - rubydex (0.1.0.beta10-arm64-darwin) - rubydex (0.1.0.beta10-x86_64-linux) + rubydex (0.1.0.beta11) + rubydex (0.1.0.beta11-arm64-darwin) + rubydex (0.1.0.beta11-x86_64-linux) sorbet (0.6.13055) sorbet-static (= 0.6.13055) sorbet-runtime (0.6.13055) @@ -204,9 +204,9 @@ CHECKSUMS ruby-lsp (0.26.9) ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - rubydex (0.1.0.beta10) sha256=73b179ba1565bfabf8e5ec0781ec9eade02a8de779e7ee12cd23cfbc17e2c60a - rubydex (0.1.0.beta10-arm64-darwin) sha256=97d571bbf941205b04c5865fa00ee7aaaf26f56d653ba0e2db6592b1ad0c423c - rubydex (0.1.0.beta10-x86_64-linux) sha256=32eadcdcf4fd1396c6d37e315e97e9cc390403ed3c0cdb56ed92f8c84b0aa775 + rubydex (0.1.0.beta11) sha256=d7a1f6a7b5404cab73a59eda077d7678ab576086391be2d7e2c96e5dffe7643e + rubydex (0.1.0.beta11-arm64-darwin) sha256=55d105e624f0ca5fbbcc1f960a71a56670e6c266b3a1a49357aadfcd579b94da + rubydex (0.1.0.beta11-x86_64-linux) sha256=1530f1ba435fb0067d42029d3917c73e871a296fca00da3e7df6ea6475d860e4 sorbet (0.6.13055) sha256=5f5e8f37c13c281fa2b2f95e261d2e531d8331ddcc8e2dd8c4f16457935872ec sorbet-runtime (0.6.13055) sha256=c8ae8c81310e0a28d290b11f44ddca59659b7d7f13752c0ef5d16964bbb84d18 sorbet-static (0.6.13055-universal-darwin) sha256=649c8e79a443be85318922f9ecbb46be72f6c585443f4440c4ec0fb1737c86e8 diff --git a/sorbet/rbi/gems/rubydex@0.1.0.beta10.rbi b/sorbet/rbi/gems/rubydex@0.1.0.beta11.rbi similarity index 99% rename from sorbet/rbi/gems/rubydex@0.1.0.beta10.rbi rename to sorbet/rbi/gems/rubydex@0.1.0.beta11.rbi index 1ac213b4c8..b005559486 100644 --- a/sorbet/rbi/gems/rubydex@0.1.0.beta10.rbi +++ b/sorbet/rbi/gems/rubydex@0.1.0.beta11.rbi @@ -52,6 +52,8 @@ class Rubydex::ConstantReference < ::Rubydex::Reference def name; end end +class Rubydex::ConstantVisibilityDefinition < ::Rubydex::Definition; end + class Rubydex::Declaration # source://rubydex//lib/rubydex.rb#10 def initialize(_arg0, _arg1); end diff --git a/test/requests/definition_expectations_test.rb b/test/requests/definition_expectations_test.rb index 3ad790a633..f442c60b43 100644 --- a/test/requests/definition_expectations_test.rb +++ b/test/requests/definition_expectations_test.rb @@ -839,6 +839,7 @@ def test_definition_for_global_variables ) response = server.pop_response.response + response.sort_by! { |location| location.range.start.line } assert_equal(3, response.size) assert_equal(0, response[0].range.start.line) assert_equal(1, response[1].range.start.line) From b488e018d14222a67d4a58632b484be53c084f4c Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Mon, 23 Mar 2026 13:02:10 -0400 Subject: [PATCH 11/20] Partially migrate hover to use Rubydex --- lib/ruby_lsp/listeners/hover.rb | 85 ++++++++----------- lib/ruby_lsp/requests/support/common.rb | 40 +++++++++ .../hover/documented_constant.exp.json | 2 +- test/requests/hover_expectations_test.rb | 31 ++++--- 4 files changed, 93 insertions(+), 65 deletions(-) diff --git a/lib/ruby_lsp/listeners/hover.rb b/lib/ruby_lsp/listeners/hover.rb index b06e118052..8aadae7c44 100644 --- a/lib/ruby_lsp/listeners/hover.rb +++ b/lib/ruby_lsp/listeners/hover.rb @@ -48,6 +48,7 @@ def initialize(response_builder, global_state, uri, node_context, dispatcher, so @response_builder = response_builder @global_state = global_state @index = global_state.index #: RubyIndexer::Index + @graph = global_state.graph #: Rubydex::Graph @type_inferrer = global_state.type_inferrer #: TypeInferrer @path = uri.to_standardized_path #: String? @node_context = node_context @@ -178,32 +179,32 @@ def on_global_variable_write_node_enter(node) #: (Prism::InstanceVariableReadNode node) -> void def on_instance_variable_read_node_enter(node) - handle_instance_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::InstanceVariableWriteNode node) -> void def on_instance_variable_write_node_enter(node) - handle_instance_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::InstanceVariableAndWriteNode node) -> void def on_instance_variable_and_write_node_enter(node) - handle_instance_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::InstanceVariableOperatorWriteNode node) -> void def on_instance_variable_operator_write_node_enter(node) - handle_instance_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::InstanceVariableOrWriteNode node) -> void def on_instance_variable_or_write_node_enter(node) - handle_instance_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::InstanceVariableTargetNode node) -> void def on_instance_variable_target_node_enter(node) - handle_instance_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::SuperNode node) -> void @@ -223,32 +224,32 @@ def on_yield_node_enter(node) #: (Prism::ClassVariableAndWriteNode node) -> void def on_class_variable_and_write_node_enter(node) - handle_class_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::ClassVariableOperatorWriteNode node) -> void def on_class_variable_operator_write_node_enter(node) - handle_class_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::ClassVariableOrWriteNode node) -> void def on_class_variable_or_write_node_enter(node) - handle_class_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::ClassVariableTargetNode node) -> void def on_class_variable_target_node_enter(node) - handle_class_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::ClassVariableReadNode node) -> void def on_class_variable_read_node_enter(node) - handle_class_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end #: (Prism::ClassVariableWriteNode node) -> void def on_class_variable_write_node_enter(node) - handle_class_variable_hover(node.name.to_s) + handle_variable_hover(node.name.to_s) end private @@ -324,62 +325,46 @@ def handle_method_hover(message, inherited_only: false) end end - #: (String name) -> void - def handle_instance_variable_hover(name) - # Sorbet enforces that all instance variables be declared on typed strict or higher, which means it will be able - # to provide all features for them - return if @sorbet_level.strict? - - type = @type_inferrer.infer_receiver_type(@node_context) - return unless type - - entries = @index.resolve_instance_variable(name, type.name) - return unless entries - - categorized_markdown_from_index_entries(name, entries).each do |category, content| - @response_builder.push(content, category: category) - end - rescue RubyIndexer::Index::NonExistingNamespaceError - # If by any chance we haven't indexed the owner, then there's no way to find the right declaration - end - #: (String name) -> void def handle_global_variable_hover(name) - entries = @index[name] - return unless entries + declaration = @graph[name] + return unless declaration - categorized_markdown_from_index_entries(name, entries).each do |category, content| + categorized_markdown_from_definitions(name, declaration.definitions).each do |category, content| @response_builder.push(content, category: category) end end + # Handle class or instance variables. We collect all definitions across the ancestors of the type + # #: (String name) -> void - def handle_class_variable_hover(name) + def handle_variable_hover(name) + # Sorbet enforces that all variables be declared on typed strict or higher, which means it will be able to + # provide all features for them + return if @sorbet_level.strict? + type = @type_inferrer.infer_receiver_type(@node_context) return unless type - entries = @index.resolve_class_variable(name, type.name) - return unless entries + owner = @graph[type.name] + return unless owner.is_a?(Rubydex::Namespace) - categorized_markdown_from_index_entries(name, entries).each do |category, content| - @response_builder.push(content, category: category) + owner.ancestors.each do |ancestor| + member = ancestor.member(name) + next unless member + + categorized_markdown_from_definitions(member.name, member.definitions).each do |category, content| + @response_builder.push(content, category: category) + end end - rescue RubyIndexer::Index::NonExistingNamespaceError - # If by any chance we haven't indexed the owner, then there's no way to find the right declaration end #: (String name, Prism::Location location) -> void def generate_hover(name, location) - entries = @index.resolve(name, @node_context.nesting) - return unless entries - - # We should only show hover for private constants if the constant is defined in the same namespace as the - # reference - first_entry = entries.first #: as !nil - full_name = first_entry.name - return if first_entry.private? && full_name != "#{@node_context.fully_qualified_name}::#{name}" + declaration = @graph.resolve_constant(name, @node_context.nesting) + return unless declaration - categorized_markdown_from_index_entries(full_name, entries).each do |category, content| + categorized_markdown_from_definitions(declaration.name, declaration.definitions).each do |category, content| @response_builder.push(content, category: category) end end diff --git a/lib/ruby_lsp/requests/support/common.rb b/lib/ruby_lsp/requests/support/common.rb index 010dd38509..8c391711cc 100644 --- a/lib/ruby_lsp/requests/support/common.rb +++ b/lib/ruby_lsp/requests/support/common.rb @@ -64,6 +64,46 @@ def self_receiver?(node) receiver.nil? || receiver.is_a?(Prism::SelfNode) end + #: (String, Enumerable[Rubydex::Definition], ?Integer?) -> Hash[Symbol, String] + def categorized_markdown_from_definitions(title, definitions, max_entries = nil) + markdown_title = "```ruby\n#{title}\n```" + file_links = [] + content = +"" + defs = max_entries ? definitions.take(max_entries) : definitions + defs.each do |definition| + # For Markdown links, we need 1 based display locations + loc = definition.location.to_display + uri = URI(loc.uri) + file_name = if uri.scheme == "untitled" + uri.opaque #: as !nil + else + File.basename( + uri.full_path, #: as !nil + ) + end + + # The format for VS Code file URIs is `file:///path/to/file.rb#Lstart_line,start_column-end_line,end_column` + string_uri = "#{loc.uri}#L#{loc.start_line},#{loc.start_column}-#{loc.end_line},#{loc.end_column}" + file_links << "[#{file_name}](#{string_uri})" + content << "\n\n#{definition.comments.map { |comment| comment.string.delete_prefix("# ") }.join("\n")}" unless definition.comments.empty? + end + + total_definitions = definitions.count + + additional_entries_text = if max_entries && total_definitions > max_entries + additional = total_definitions - max_entries + " | #{additional} other#{additional > 1 ? "s" : ""}" + else + "" + end + + { + title: markdown_title, + links: "**Definitions**: #{file_links.join(" | ")}#{additional_entries_text}", + documentation: content, + } + end + #: (String title, (Array[RubyIndexer::Entry] | RubyIndexer::Entry) entries, ?Integer? max_entries) -> Hash[Symbol, String] def categorized_markdown_from_index_entries(title, entries, max_entries = nil) markdown_title = "```ruby\n#{title}\n```" diff --git a/test/expectations/hover/documented_constant.exp.json b/test/expectations/hover/documented_constant.exp.json index cbbe5b37fe..d9483c231c 100644 --- a/test/expectations/hover/documented_constant.exp.json +++ b/test/expectations/hover/documented_constant.exp.json @@ -8,7 +8,7 @@ "result": { "contents": { "kind": "markdown", - "value": "```ruby\nBAZ\n```\n\n**Definitions**: [fake.rb](file:///fake.rb#L2,1-2,10)\n\n\n\nThis is the documentation for Baz" + "value": "```ruby\nBAZ\n```\n\n**Definitions**: [fake.rb](file:///fake.rb#L2,1-2,4)\n\n\n\nThis is the documentation for Baz" } } } diff --git a/test/requests/hover_expectations_test.rb b/test/requests/hover_expectations_test.rb index aff6fd9d49..511bfa4d32 100644 --- a/test/requests/hover_expectations_test.rb +++ b/test/requests/hover_expectations_test.rb @@ -51,18 +51,25 @@ class A def test_hovering_on_erb source = <<~ERB - <% String %> + <% Person %> ERB with_server(source, Kernel.URI("file:///fake.erb"), stub_no_typechecker: true) do |server, uri| - RubyIndexer::RBSIndexer.new(server.global_state.index).index_ruby_core + graph = server.global_state.graph + graph.index_source(URI::Generic.from_path(path: "/person.rb").to_s, <<~RUBY, "ruby") + # Hello from person.rb + class Person + end + RUBY + graph.resolve + server.process_message( id: 1, method: "textDocument/hover", params: { textDocument: { uri: uri }, position: { line: 0, character: 4 } }, ) response = server.pop_response - assert_match(/String\b/, response.response.contents.value) + assert_match(/Hello from person\.rb/, response.response.contents.value) end end @@ -76,10 +83,9 @@ def test_hovering_for_global_variables $qux ||= 1 # target write node $quux, $corge = 1 - # write node + # foo docs $foo = 1 - # read node - $DEBUG + $foo RUBY expectations = [ @@ -87,14 +93,11 @@ def test_hovering_for_global_variables { line: 3, documentation: "operator write node" }, { line: 5, documentation: "or write node" }, { line: 7, documentation: "target write node" }, - { line: 9, documentation: "write node" }, - { line: 11, documentation: "The debug flag" }, + { line: 9, documentation: "foo docs" }, + { line: 10, documentation: "foo docs" }, ] with_server(source) do |server, uri| - index = server.instance_variable_get(:@global_state).index - RubyIndexer::RBSIndexer.new(index).index_ruby_core - expectations.each do |expectation| server.process_message( id: 1, @@ -264,10 +267,9 @@ class A private_constant(:CONST) end - A::CONST # invalid private reference + A::CONST RUBY - # We need to pretend that Sorbet is not a dependency or else we can't properly test with_server(source, stub_no_typechecker: true) do |server, uri| server.process_message( id: 1, @@ -275,7 +277,8 @@ class A params: { textDocument: { uri: uri }, position: { character: 3, line: 5 } }, ) - assert_nil(server.pop_response.response) + # TODO: once we have visibility exposed from Rubydex, let's show that the constant is private + assert_match("A::CONST", server.pop_response.response.contents.value) end end From 4513a6b87675deca5cdc35b223c266f92a96609b Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Thu, 26 Mar 2026 16:49:49 -0400 Subject: [PATCH 12/20] Migrate rename to use Rubydex --- lib/ruby_lsp/internal.rb | 1 + lib/ruby_lsp/requests/rename.rb | 136 ++++++++--------- lib/ruby_lsp/rubydex/reference.rb | 16 ++ test/requests/rename_test.rb | 238 +++++++++++++++++++----------- 4 files changed, 235 insertions(+), 156 deletions(-) create mode 100644 lib/ruby_lsp/rubydex/reference.rb diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index cbff9bb3ce..1a2cbdf953 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -33,6 +33,7 @@ # Rubydex LSP additions require "ruby_lsp/rubydex/definition" +require "ruby_lsp/rubydex/reference" require "ruby-lsp" require "ruby_lsp/base_server" diff --git a/lib/ruby_lsp/requests/rename.rb b/lib/ruby_lsp/requests/rename.rb index a8e8276410..db7b2d78d0 100644 --- a/lib/ruby_lsp/requests/rename.rb +++ b/lib/ruby_lsp/requests/rename.rb @@ -22,6 +22,7 @@ def provider def initialize(global_state, store, document, params) super() @global_state = global_state + @graph = global_state.graph #: Rubydex::Graph @store = store @document = document @position = params[:position] #: Hash[Symbol, Integer] @@ -56,17 +57,14 @@ def perform name = RubyIndexer::Index.constant_name(target) return unless name - entries = @global_state.index.resolve(name, node_context.nesting) - return unless entries + declaration = @graph.resolve_constant(name, node_context.nesting) + return unless declaration - if (conflict_entries = @global_state.index.resolve(@new_name, node_context.nesting)) - raise InvalidNameError, "The new name is already in use by #{conflict_entries.first&.name}" + if (conflict = @graph.resolve_constant(@new_name, node_context.nesting)) + raise InvalidNameError, "The new name is already in use by #{conflict.name}" end - fully_qualified_name = entries.first #: as !nil - .name - reference_target = RubyIndexer::ReferenceFinder::ConstTarget.new(fully_qualified_name) - changes = collect_text_edits(reference_target, name) + changes = collect_text_edits(declaration, name) # If the client doesn't support resource operations, such as renaming files, then we can only return the basic # text changes @@ -78,99 +76,93 @@ def perform # renamed and then the URI associated to the text edit no longer exists, causing it to be dropped document_changes = changes.map do |uri, edits| Interface::TextDocumentEdit.new( - text_document: Interface::VersionedTextDocumentIdentifier.new(uri: uri, version: nil), + text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(uri: uri, version: nil), edits: edits, ) end - collect_file_renames(fully_qualified_name, document_changes) + collect_file_renames(declaration, document_changes) Interface::WorkspaceEdit.new(document_changes: document_changes) end private - #: (String fully_qualified_name, Array[(Interface::RenameFile | Interface::TextDocumentEdit)] document_changes) -> void - def collect_file_renames(fully_qualified_name, document_changes) + #: (Rubydex::Declaration, Array[(Interface::RenameFile | Interface::TextDocumentEdit)]) -> void + def collect_file_renames(declaration, document_changes) # Check if the declarations of the symbol being renamed match the file name. In case they do, we automatically # rename the files for the user. # # We also look for an associated test file and rename it too - short_name = fully_qualified_name.split("::").last #: as !nil - @global_state.index[fully_qualified_name]&.each do |entry| + unless [ + Rubydex::Class, + Rubydex::Module, + Rubydex::Constant, + Rubydex::ConstantAlias, + ].any? { |type| declaration.is_a?(type) } + return + end + + short_name = declaration.unqualified_name + + declaration.definitions.each do |definition| # Do not rename files that are not part of the workspace - uri = entry.uri + uri = URI(definition.location.uri) file_path = uri.full_path next unless file_path&.start_with?(@global_state.workspace_path) - case entry - when RubyIndexer::Entry::Class, RubyIndexer::Entry::Module, RubyIndexer::Entry::Constant, - RubyIndexer::Entry::ConstantAlias, RubyIndexer::Entry::UnresolvedConstantAlias - - file_name = file_from_constant_name(short_name) + file_name = file_from_constant_name(short_name) + next unless "#{file_name}.rb" == File.basename(file_path) - if "#{file_name}.rb" == entry.file_name - new_file_name = file_from_constant_name( - @new_name.split("::").last, #: as !nil - ) + new_file_name = file_from_constant_name( + @new_name.split("::").last, #: as !nil + ) - new_uri = URI::Generic.from_path(path: File.join( - File.dirname(file_path), - "#{new_file_name}.rb", - )).to_s + new_uri = URI::Generic.from_path(path: File.join( + File.dirname(file_path), + "#{new_file_name}.rb", + )).to_s - document_changes << Interface::RenameFile.new(kind: "rename", old_uri: uri.to_s, new_uri: new_uri) - end - end + document_changes << Interface::RenameFile.new(kind: "rename", old_uri: uri.to_s, new_uri: new_uri) end end - #: (RubyIndexer::ReferenceFinder::Target target, String name) -> Hash[String, Array[Interface::TextEdit]] - def collect_text_edits(target, name) - changes = {} - - Dir.glob(File.join(@global_state.workspace_path, "**/*.rb")).each do |path| - uri = URI::Generic.from_path(path: path) - # If the document is being managed by the client, then we should use whatever is present in the store instead - # of reading from disk - next if @store.key?(uri) - - parse_result = Prism.parse_file(path) - edits = collect_changes(target, parse_result.value, name, uri) - changes[uri.to_s] = edits unless edits.empty? - rescue Errno::EISDIR, Errno::ENOENT - # If `path` is a directory, just ignore it and continue. If the file doesn't exist, then we also ignore it. - end - - @store.each do |uri, document| - next unless document.is_a?(RubyDocument) || document.is_a?(ERBDocument) + #: (Rubydex::Declaration declaration, String name) -> Hash[String, Array[Interface::TextEdit]] + def collect_text_edits(declaration, name) + changes = {} #: Hash[String, Array[Interface::TextEdit]] + short_name = name.split("::").last #: as !nil + new_short_name = @new_name.split("::").last #: as !nil + + # Collect edits for definition sites (where the constant is declared) + declaration.definitions.each do |definition| + name_loc = definition.name_location + next unless name_loc + + uri_string = name_loc.uri + edits = (changes[uri_string] ||= []) + + # The name_location spans the constant name as written in the definition. + # We only replace the unqualified name portion (the last segment). + range = Interface::Range.new( + start: Interface::Position.new( + line: name_loc.end_line, + character: name_loc.end_column - short_name.length, + ), + end: Interface::Position.new(line: name_loc.end_line, character: name_loc.end_column), + ) - edits = collect_changes(target, document.ast, name, document.uri) - changes[uri] = edits unless edits.empty? + edits << Interface::TextEdit.new(range: range, new_text: new_short_name) end - changes - end - - #: (RubyIndexer::ReferenceFinder::Target target, Prism::Node ast, String name, URI::Generic uri) -> Array[Interface::TextEdit] - def collect_changes(target, ast, name, uri) - dispatcher = Prism::Dispatcher.new - finder = RubyIndexer::ReferenceFinder.new(target, @global_state.index, dispatcher, uri) - dispatcher.visit(ast) - - finder.references.map do |reference| - adjust_reference_for_edit(name, reference) + # Collect edits for reference sites (where the constant is used) + declaration.references.each do |reference| + ref = reference #: as Rubydex::ConstantReference + uri_string = ref.location.uri + edits = (changes[uri_string] ||= []) + edits << Interface::TextEdit.new(range: ref.to_lsp_range, new_text: new_short_name) end - end - - #: (String name, RubyIndexer::ReferenceFinder::Reference reference) -> Interface::TextEdit - def adjust_reference_for_edit(name, reference) - # The reference may include a namespace in front. We need to check if the rename new name includes namespaces - # and then adjust both the text and the location to produce the correct edit - location = reference.location - new_text = reference.name.sub(name, @new_name) - Interface::TextEdit.new(range: range_from_location(location), new_text: new_text) + changes end #: (String constant_name) -> String diff --git a/lib/ruby_lsp/rubydex/reference.rb b/lib/ruby_lsp/rubydex/reference.rb new file mode 100644 index 0000000000..ae47aa0f79 --- /dev/null +++ b/lib/ruby_lsp/rubydex/reference.rb @@ -0,0 +1,16 @@ +# typed: strict +# frozen_string_literal: true + +module Rubydex + class ConstantReference + #: () -> RubyLsp::Interface::Range + def to_lsp_range + loc = location + + RubyLsp::Interface::Range.new( + start: RubyLsp::Interface::Position.new(line: loc.start_line, character: loc.start_column), + end: RubyLsp::Interface::Position.new(line: loc.end_line, character: loc.end_column), + ) + end + end +end diff --git a/test/requests/rename_test.rb b/test/requests/rename_test.rb index d9bf7c01ec..e83435044b 100644 --- a/test/requests/rename_test.rb +++ b/test/requests/rename_test.rb @@ -4,113 +4,167 @@ require "test_helper" class RenameTest < Minitest::Test - def test_empty_diagnostics_for_ignored_file - expected = <<~RUBY + def setup + @tmp_dir = Dir.mktmpdir + end + + def teardown + FileUtils.remove_entry(@tmp_dir) + end + + def test_renaming_a_constant + source = <<~RUBY + class RenameMe + end + + RenameMe + RUBY + + result, document = perform_rename( + source, + position: { line: 0, character: 7 }, + new_name: "Article", + file_name: "rename_me.rb", + ) + + apply_edits(result, document) + + assert_equal(<<~RUBY, document.source) class Article end Article RUBY - expect_renames( - "test/fixtures/rename_me.rb", - File.join("test", "fixtures", "article.rb"), - expected, - { line: 0, character: 7 }, - "Article", + assert_file_renamed(result, from: "rename_me.rb", to: "article.rb") + end + + def test_renaming_a_complex_compact_style_constant + source = <<~RUBY + module Foo + module Bar; end + end + + module Baz + include Foo + + class Bar::RenameMe + end + end + + Foo::Bar::RenameMe + RUBY + + result, document = perform_rename( + source, + position: { line: 6, character: 13 }, + new_name: "Article", ) + + apply_edits(result, document) + + assert_equal(<<~RUBY, document.source) + module Foo + module Bar; end + end + + module Baz + include Foo + + class Bar::Article + end + end + + Foo::Bar::Article + RUBY end - def test_renaming_conflict - fixture_path = "test/fixtures/rename_me.rb" - source = File.read(fixture_path) - global_state = RubyLsp::GlobalState.new - global_state.apply_options({ - capabilities: { - workspace: { - workspaceEdit: { - resourceOperations: ["rename"], - }, - }, - }, - }) - path = File.expand_path(fixture_path) - global_state.index.index_single(URI::Generic.from_path(path: path), source) - global_state.index.index_single(URI::Generic.from_path(path: "/fake.rb"), <<~RUBY) - class Conflicting + def test_renaming_a_method_receiver + source = <<~RUBY + class Foo + end + + class Bar + def Foo.qux + end end RUBY - store = RubyLsp::Store.new(global_state) - document = RubyLsp::RubyDocument.new( - source: source, - version: 1, - uri: URI::Generic.from_path(path: path), - global_state: global_state, + result, document = perform_rename( + source, + position: { line: 4, character: 6 }, + new_name: "Zip", ) + apply_edits(result, document) + + assert_equal(<<~RUBY, document.source) + class Zip + end + + class Bar + def Zip.qux + end + end + RUBY + end + + def test_renaming_conflict + source = <<~RUBY + class RenameMe + end + + RenameMe + RUBY + assert_raises(RubyLsp::Requests::Rename::InvalidNameError) do - RubyLsp::Requests::Rename.new( - global_state, - store, - document, - { position: { line: 3, character: 7 }, newName: "Conflicting" }, - ).perform + perform_rename(source, position: { line: 3, character: 0 }, new_name: "Conflicting") do |graph| + graph.index_source( + URI::Generic.from_path(path: File.join(@tmp_dir, "conflicting.rb")).to_s, + "class Conflicting\nend\n", + "ruby", + ) + end end end - def test_renaming_an_unsaved_symbol - fixture_path = "test/fixtures/rename_me.rb" - source = File.read(fixture_path) - global_state = RubyLsp::GlobalState.new - global_state.apply_options({ - capabilities: { - workspace: { - workspaceEdit: { - resourceOperations: ["rename"], - }, - }, - }, - }) - - store = RubyLsp::Store.new(global_state) + def test_renaming_across_unsaved_files + source = <<~RUBY + class RenameMe + end - path = File.expand_path(fixture_path) - global_state.index.index_single(URI::Generic.from_path(path: path), source) + RenameMe + RUBY untitled_uri = URI("untitled:Untitled-1") untitled_source = <<~RUBY class RenameMe end RUBY - global_state.index.index_single(untitled_uri, untitled_source) - store.set(uri: untitled_uri, source: untitled_source, version: 1, language_id: :ruby) - - document = RubyLsp::RubyDocument.new( - source: source, - version: 1, - uri: URI::Generic.from_path(path: path), - global_state: global_state, - ) - response = RubyLsp::Requests::Rename.new( - global_state, - store, - document, - { position: { line: 3, character: 7 }, newName: "NewMe" }, - ).perform #: as !nil + result, = perform_rename(source, position: { line: 3, character: 0 }, new_name: "NewMe") do |graph, store| + graph.index_source(untitled_uri.to_s, untitled_source, "ruby") + store.set(uri: untitled_uri, source: untitled_source, version: 1, language_id: :ruby) + end - untitled_change = response.document_changes[1] - assert_equal("untitled:Untitled-1", untitled_change.text_document.uri) + untitled_change = result.document_changes.find do |c| + c.is_a?(RubyLsp::Interface::TextDocumentEdit) && c.text_document.uri == untitled_uri.to_s + end + refute_nil(untitled_change) assert_equal("NewMe", untitled_change.edits[0].new_text) end private - def expect_renames(fixture_path, new_fixture_path, expected, position, new_name) - source = File.read(fixture_path) + #: (String, position: Hash[Symbol, Integer], new_name: String, ?file_name: String) ?{ (Rubydex::Graph, RubyLsp::Store) -> void } -> [RubyLsp::Interface::WorkspaceEdit, RubyLsp::RubyDocument] + def perform_rename(source, position:, new_name:, file_name: "test.rb", &block) + path = File.join(@tmp_dir, file_name) + File.write(path, source) + uri = URI::Generic.from_path(path: path) + global_state = RubyLsp::GlobalState.new global_state.apply_options({ + workspaceFolders: [{ uri: URI::Generic.from_path(path: @tmp_dir).to_s }], capabilities: { workspace: { workspaceEdit: { @@ -119,36 +173,52 @@ def expect_renames(fixture_path, new_fixture_path, expected, position, new_name) }, }, }) - path = File.expand_path(fixture_path) - global_state.index.index_single(URI::Generic.from_path(path: path), source) + graph = global_state.graph store = RubyLsp::Store.new(global_state) + graph.index_source(uri.to_s, source, "ruby") + + block&.call(graph, store) + + graph.resolve + document = RubyLsp::RubyDocument.new( - source: source, + source: source.dup, version: 1, - uri: URI::Generic.from_path(path: path), + uri: uri, global_state: global_state, ) - workspace_edit = RubyLsp::Requests::Rename.new( + + result = RubyLsp::Requests::Rename.new( global_state, store, document, { position: position, newName: new_name }, ).perform #: as !nil - file_renames = workspace_edit.document_changes.filter_map do |text_edit_or_rename| - next text_edit_or_rename unless text_edit_or_rename.is_a?(RubyLsp::Interface::TextDocumentEdit) + [result, document] + end + + #: (RubyLsp::Interface::WorkspaceEdit result, RubyLsp::RubyDocument document) -> void + def apply_edits(result, document) + result.document_changes.each do |change| + next unless change.is_a?(RubyLsp::Interface::TextDocumentEdit) + next unless change.text_document.uri == document.uri.to_s document.push_edits( - text_edit_or_rename.edits.map do |edit| + change.edits.map do |edit| { range: edit.range.to_hash.transform_values(&:to_hash), text: edit.new_text } end, version: 2, ) - nil end + end - assert_equal(expected, document.source) - assert_equal(File.expand_path(new_fixture_path), URI(file_renames.first.new_uri).to_standardized_path) + #: (RubyLsp::Interface::WorkspaceEdit result, from: String, to: String) -> void + def assert_file_renamed(result, from:, to:) + file_rename = result.document_changes.find { |c| c.is_a?(RubyLsp::Interface::RenameFile) } + refute_nil(file_rename, "Expected a file rename operation") + assert(file_rename.old_uri.end_with?(from), "Expected old_uri to end with '#{from}', got '#{file_rename.old_uri}'") + assert(file_rename.new_uri.end_with?(to), "Expected new_uri to end with '#{to}', got '#{file_rename.new_uri}'") end end From 2e5712d77141763a85331b9bc5a7a93416e940f9 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 1 Apr 2026 09:16:59 -0400 Subject: [PATCH 13/20] Bump version to v0.27.0.beta1 --- Gemfile.lock | 4 ++-- VERSION | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 937f7568e8..2b16a84955 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - ruby-lsp (0.26.9) + ruby-lsp (0.27.0.beta1) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 5) @@ -201,7 +201,7 @@ CHECKSUMS rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d rubocop-shopify (2.17.1) sha256=03850eb1a9c4d1f9f0ac1d8d5aa51bb47a149e532cfb5e8d02ac6a90c8800a5f rubocop-sorbet (0.8.7) sha256=670b7478425543e808558c5aa9acafeb5c9137af9ac8d3541b69d3d18ea4726b - ruby-lsp (0.26.9) + ruby-lsp (0.27.0.beta1) ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef rubydex (0.1.0.beta11) sha256=d7a1f6a7b5404cab73a59eda077d7678ab576086391be2d7e2c96e5dffe7643e diff --git a/VERSION b/VERSION index 5b60fe05a8..40ea2e5d5f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.26.9 +0.27.0.beta1 From 92bdb39820f46930c392acaba1c0ac097294c594 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 15 Apr 2026 15:54:39 -0400 Subject: [PATCH 14/20] Bump rubydex to v0.1.0.beta12 --- Gemfile.lock | 12 +- ....0.beta11.rbi => rubydex@0.1.0.beta12.rbi} | 302 +++++++++++++----- 2 files changed, 233 insertions(+), 81 deletions(-) rename sorbet/rbi/gems/{rubydex@0.1.0.beta11.rbi => rubydex@0.1.0.beta12.rbi} (59%) diff --git a/Gemfile.lock b/Gemfile.lock index 2b16a84955..e6d59453f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,9 +92,9 @@ GEM rubocop (>= 1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - rubydex (0.1.0.beta11) - rubydex (0.1.0.beta11-arm64-darwin) - rubydex (0.1.0.beta11-x86_64-linux) + rubydex (0.1.0.beta12) + rubydex (0.1.0.beta12-arm64-darwin) + rubydex (0.1.0.beta12-x86_64-linux) sorbet (0.6.13055) sorbet-static (= 0.6.13055) sorbet-runtime (0.6.13055) @@ -204,9 +204,9 @@ CHECKSUMS ruby-lsp (0.27.0.beta1) ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - rubydex (0.1.0.beta11) sha256=d7a1f6a7b5404cab73a59eda077d7678ab576086391be2d7e2c96e5dffe7643e - rubydex (0.1.0.beta11-arm64-darwin) sha256=55d105e624f0ca5fbbcc1f960a71a56670e6c266b3a1a49357aadfcd579b94da - rubydex (0.1.0.beta11-x86_64-linux) sha256=1530f1ba435fb0067d42029d3917c73e871a296fca00da3e7df6ea6475d860e4 + rubydex (0.1.0.beta12) sha256=3c08753ce69ab17999c3e65209180fc2801e5b6e85a2d0064ceda881a9a85d17 + rubydex (0.1.0.beta12-arm64-darwin) sha256=d565632fa825db57191cef3865bf361339a0f82fc465e46aec3793fd858beeb1 + rubydex (0.1.0.beta12-x86_64-linux) sha256=9034afc1ddb612a74c4242f566facc910e16bbde010e5855d6ca45b60d3ef757 sorbet (0.6.13055) sha256=5f5e8f37c13c281fa2b2f95e261d2e531d8331ddcc8e2dd8c4f16457935872ec sorbet-runtime (0.6.13055) sha256=c8ae8c81310e0a28d290b11f44ddca59659b7d7f13752c0ef5d16964bbb84d18 sorbet-static (0.6.13055-universal-darwin) sha256=649c8e79a443be85318922f9ecbb46be72f6c585443f4440c4ec0fb1737c86e8 diff --git a/sorbet/rbi/gems/rubydex@0.1.0.beta11.rbi b/sorbet/rbi/gems/rubydex@0.1.0.beta12.rbi similarity index 59% rename from sorbet/rbi/gems/rubydex@0.1.0.beta11.rbi rename to sorbet/rbi/gems/rubydex@0.1.0.beta12.rbi index b005559486..032a49f8d1 100644 --- a/sorbet/rbi/gems/rubydex@0.1.0.beta11.rbi +++ b/sorbet/rbi/gems/rubydex@0.1.0.beta12.rbi @@ -4,7 +4,9 @@ # This is an autogenerated file for types exported from the `rubydex` gem. # Please instead update this file by running `bin/tapioca gem rubydex`. + # frozen_string_literal: true +# typed: strict # source://rubydex//lib/rubydex/version.rb#3 module Rubydex; end @@ -13,8 +15,23 @@ class Rubydex::AttrAccessorDefinition < ::Rubydex::Definition; end class Rubydex::AttrReaderDefinition < ::Rubydex::Definition; end class Rubydex::AttrWriterDefinition < ::Rubydex::Definition; end class Rubydex::Class < ::Rubydex::Namespace; end -class Rubydex::ClassDefinition < ::Rubydex::Definition; end -class Rubydex::ClassVariable < ::Rubydex::Declaration; end + +class Rubydex::ClassDefinition < ::Rubydex::Definition + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Array[Rubydex::Mixin]) } + def mixins; end + + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T.nilable(Rubydex::ConstantReference)) } + def superclass; end +end + +class Rubydex::ClassVariable < ::Rubydex::Declaration + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Array[T.untyped]) } + def references; end +end + class Rubydex::ClassVariableDefinition < ::Rubydex::Definition; end # source://rubydex//lib/rubydex/comment.rb#4 @@ -34,86 +51,96 @@ class Rubydex::Comment def string; end end -class Rubydex::Constant < ::Rubydex::Declaration; end -class Rubydex::ConstantAlias < ::Rubydex::Declaration; end +class Rubydex::Constant < ::Rubydex::Declaration + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Enumerable[Rubydex::ConstantReference]) } + def references; end +end + +class Rubydex::ConstantAlias < ::Rubydex::Declaration + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Enumerable[Rubydex::ConstantReference]) } + def references; end +end + class Rubydex::ConstantAliasDefinition < ::Rubydex::Definition; end class Rubydex::ConstantDefinition < ::Rubydex::Definition; end class Rubydex::ConstantReference < ::Rubydex::Reference - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def initialize(_arg0, _arg1); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(Rubydex::Location) } def location; end - # source://rubydex//lib/rubydex.rb#10 - sig { returns(String) } - def name; end + class << self + def new(*args); end + end end class Rubydex::ConstantVisibilityDefinition < ::Rubydex::Definition; end class Rubydex::Declaration - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def initialize(_arg0, _arg1); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::Definition]) } def definitions; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(String) } def name; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(Rubydex::Declaration) } def owner; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::Reference]) } def references; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(String) } def unqualified_name; end class << self private - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def new(*_arg0); end end end class Rubydex::Definition - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def initialize(_arg0, _arg1); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Array[Rubydex::Comment]) } def comments; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Boolean) } def deprecated?; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(Rubydex::Location) } def location; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(String) } def name; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T.nilable(Rubydex::Location)) } def name_location; end class << self private - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def new(*_arg0); end end end @@ -164,27 +191,32 @@ class Rubydex::DisplayLocation < ::Rubydex::Location end class Rubydex::Document - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def initialize(_arg0, _arg1); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::Definition]) } def definitions; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(String) } def uri; end class << self private - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def new(*_arg0); end end end class Rubydex::Error < StandardError; end +# Represents `extend SomeModule` +# +# source://rubydex//lib/rubydex/mixin.rb#21 +class Rubydex::Extend < ::Rubydex::Mixin; end + # source://rubydex//lib/rubydex/failures.rb#4 class Rubydex::Failure # @return [Failure] a new instance of Failure @@ -198,7 +230,12 @@ class Rubydex::Failure def message; end end -class Rubydex::GlobalVariable < ::Rubydex::Declaration; end +class Rubydex::GlobalVariable < ::Rubydex::Declaration + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Array[T.untyped]) } + def references; end +end + class Rubydex::GlobalVariableAliasDefinition < ::Rubydex::Definition; end class Rubydex::GlobalVariableDefinition < ::Rubydex::Definition; end @@ -210,98 +247,114 @@ class Rubydex::GlobalVariableDefinition < ::Rubydex::Definition; end class Rubydex::Graph # @return [Graph] a new instance of Graph # - # source://rubydex//lib/rubydex/graph.rb#20 + # source://rubydex//lib/rubydex/graph.rb#24 sig { params(workspace_path: T.nilable(String)).void } def initialize(workspace_path: T.unsafe(nil)); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { params(fully_qualified_name: String).returns(T.nilable(Rubydex::Declaration)) } def [](fully_qualified_name); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Array[Rubydex::Failure]) } def check_integrity; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 + sig { params(nesting: T::Array[String]).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword)]) } + def complete_expression(nesting); end + + # source://rubydex//lib/rubydex.rb#11 + sig { params(name: String, nesting: T::Array[String]).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword, Rubydex::KeywordParameter)]) } + def complete_method_argument(name, nesting); end + + # source://rubydex//lib/rubydex.rb#11 + sig { params(name: String).returns(T::Array[Rubydex::Method]) } + def complete_method_call(name); end + + # source://rubydex//lib/rubydex.rb#11 + sig { params(name: String).returns(T::Array[Rubydex::Declaration]) } + def complete_namespace_access(name); end + + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def constant_references; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::Declaration]) } def declarations; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { params(uri: String).returns(T.nilable(Rubydex::Document)) } def delete_document(uri); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Array[Rubydex::Diagnostic]) } def diagnostics; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::Document]) } def documents; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { params(encoding: String).void } def encoding=(encoding); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) } def fuzzy_search(query); end - # source://rubydex//lib/rubydex.rb#10 - sig { params(paths: T::Array[String]).returns(T::Array[String]) } - def index_all(paths); end + # source://rubydex//lib/rubydex.rb#11 + sig { params(file_paths: T::Array[String]).returns(T::Array[String]) } + def index_all(file_paths); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { params(uri: String, source: String, language_id: String).void } def index_source(uri, source, language_id); end # Index all files and dependencies of the workspace that exists in `@workspace_path` # - # source://rubydex//lib/rubydex/graph.rb#26 + # source://rubydex//lib/rubydex/graph.rb#32 # Index all files and dependencies of the workspace that exists in `@workspace_path` sig { returns(T::Array[String]) } def index_workspace; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::MethodReference]) } def method_references; end - # source://rubydex//lib/rubydex.rb#10 - sig { params(load_path: T::Array[String]).returns(T::Array[String]) } - def require_paths(load_path); end + # source://rubydex//lib/rubydex.rb#11 + sig { params(load_paths: T::Array[String]).returns(T::Array[String]) } + def require_paths(load_paths); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T.self_type) } def resolve; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { params(name: String, nesting: T::Array[String]).returns(T.nilable(Rubydex::Declaration)) } def resolve_constant(name, nesting); end - # source://rubydex//lib/rubydex.rb#10 - sig { params(require_path: String, load_path: T::Array[String]).returns(T.nilable(Rubydex::Document)) } - def resolve_require_path(require_path, load_path); end + # source://rubydex//lib/rubydex.rb#11 + sig { params(require_path: String, load_paths: T::Array[String]).returns(T.nilable(Rubydex::Document)) } + def resolve_require_path(require_path, load_paths); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) } def search(query); end - # source://rubydex//lib/rubydex/graph.rb#17 + # source://rubydex//lib/rubydex/graph.rb#21 sig { returns(String) } def workspace_path; end - # source://rubydex//lib/rubydex/graph.rb#17 - sig { params(workspace_path: String).void } + # source://rubydex//lib/rubydex/graph.rb#21 + sig { params(workspace_path: String).returns(String) } def workspace_path=(workspace_path); end # Returns all workspace paths that should be indexed, excluding directories that we don't need to descend into such # as `.git`, `node_modules`. Also includes any top level Ruby files # # - # source://rubydex//lib/rubydex/graph.rb#34 + # source://rubydex//lib/rubydex/graph.rb#40 sig { returns(T::Array[String]) } def workspace_paths; end @@ -312,26 +365,74 @@ class Rubydex::Graph # latest installation of `rbs` exists in the system and fails silently if we can't find one # # - # source://rubydex//lib/rubydex/graph.rb#81 + # source://rubydex//lib/rubydex/graph.rb#87 sig { params(paths: T::Array[String]).void } def add_core_rbs_definition_paths(paths); end # Gathers the paths we have to index for all workspace dependencies # - # source://rubydex//lib/rubydex/graph.rb#57 + # source://rubydex//lib/rubydex/graph.rb#63 sig { params(paths: T::Array[String]).void } def add_workspace_dependency_paths(paths); end + + # source://rubydex//lib/rubydex.rb#11 + sig { params(paths: T::Array[String]).void } + def exclude_paths(paths); end + + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Array[String]) } + def excluded_paths; end end # source://rubydex//lib/rubydex/graph.rb#8 Rubydex::Graph::IGNORED_DIRECTORIES = T.let(T.unsafe(nil), Array) -class Rubydex::InstanceVariable < ::Rubydex::Declaration; end +# Represents `include SomeModule` +# +# source://rubydex//lib/rubydex/mixin.rb#15 +class Rubydex::Include < ::Rubydex::Mixin; end + +class Rubydex::InstanceVariable < ::Rubydex::Declaration + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Array[T.untyped]) } + def references; end +end + class Rubydex::InstanceVariableDefinition < ::Rubydex::Definition; end # source://rubydex//lib/rubydex/failures.rb#14 class Rubydex::IntegrityFailure < ::Rubydex::Failure; end +# source://rubydex//lib/rubydex/keyword.rb#4 +class Rubydex::Keyword + # @return [Keyword] a new instance of Keyword + # + # source://rubydex//lib/rubydex/keyword.rb#12 + sig { params(name: String, documentation: String).void } + def initialize(name, documentation); end + + # source://rubydex//lib/rubydex/keyword.rb#9 + sig { returns(String) } + def documentation; end + + # source://rubydex//lib/rubydex/keyword.rb#6 + sig { returns(String) } + def name; end +end + +# source://rubydex//lib/rubydex/keyword_parameter.rb#4 +class Rubydex::KeywordParameter + # @return [KeywordParameter] a new instance of KeywordParameter + # + # source://rubydex//lib/rubydex/keyword_parameter.rb#9 + sig { params(name: String).void } + def initialize(name); end + + # source://rubydex//lib/rubydex/keyword_parameter.rb#6 + sig { returns(String) } + def name; end +end + # A zero based internal location. Intended to be used for tool-to-tool communication, such as a language server # communicating with an editor. # @@ -394,67 +495,118 @@ end # source://rubydex//lib/rubydex/location.rb#7 class Rubydex::Location::NotFileUriError < ::StandardError; end -class Rubydex::Method < ::Rubydex::Declaration; end +class Rubydex::Method < ::Rubydex::Declaration + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Enumerable[Rubydex::MethodReference]) } + def references; end +end + class Rubydex::MethodAliasDefinition < ::Rubydex::Definition; end class Rubydex::MethodDefinition < ::Rubydex::Definition; end class Rubydex::MethodReference < ::Rubydex::Reference - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def initialize(_arg0, _arg1); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(Rubydex::Location) } def location; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(String) } def name; end end +class Rubydex::MethodVisibilityDefinition < ::Rubydex::Definition; end + +# source://rubydex//lib/rubydex/mixin.rb#4 +class Rubydex::Mixin + # @return [Mixin] a new instance of Mixin + # + # source://rubydex//lib/rubydex/mixin.rb#9 + sig { params(constant_reference: Rubydex::ConstantReference).void } + def initialize(constant_reference); end + + # source://rubydex//lib/rubydex/mixin.rb#6 + def constant_reference; end +end + class Rubydex::Module < ::Rubydex::Namespace; end -class Rubydex::ModuleDefinition < ::Rubydex::Definition; end + +class Rubydex::ModuleDefinition < ::Rubydex::Definition + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Array[Rubydex::Mixin]) } + def mixins; end +end class Rubydex::Namespace < ::Rubydex::Declaration - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::Namespace]) } def ancestors; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::Namespace]) } def descendants; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { params(name: String, only_inherited: T::Boolean).returns(T.nilable(Rubydex::Declaration)) } - def find_member(name, only_inherited: T.unsafe(nil)); end + def find_member(name, only_inherited: false); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { params(name: String).returns(T.nilable(Rubydex::Declaration)) } def member(name); end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::Declaration]) } def members; end - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Enumerable[Rubydex::ConstantReference]) } + def references; end + + # source://rubydex//lib/rubydex.rb#11 sig { returns(T.nilable(Rubydex::SingletonClass)) } def singleton_class; end end +# Represents `prepend SomeModule` +# +# source://rubydex//lib/rubydex/mixin.rb#18 +class Rubydex::Prepend < ::Rubydex::Mixin; end + class Rubydex::Reference - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def initialize(_arg0, _arg1); end class << self private - # source://rubydex//lib/rubydex.rb#10 + # source://rubydex//lib/rubydex.rb#11 def new(*_arg0); end end end +class Rubydex::ResolvedConstantReference < ::Rubydex::ConstantReference + # source://rubydex//lib/rubydex.rb#11 + sig { returns(Rubydex::Declaration) } + def declaration; end +end + class Rubydex::SingletonClass < ::Rubydex::Namespace; end -class Rubydex::SingletonClassDefinition < ::Rubydex::Definition; end + +class Rubydex::SingletonClassDefinition < ::Rubydex::Definition + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Array[Rubydex::Mixin]) } + def mixins; end +end + class Rubydex::Todo < ::Rubydex::Namespace; end +class Rubydex::UnresolvedConstantReference < ::Rubydex::ConstantReference + # source://rubydex//lib/rubydex.rb#11 + sig { returns(String) } + def name; end +end + # source://rubydex//lib/rubydex/version.rb#4 Rubydex::VERSION = T.let(T.unsafe(nil), String) From 09196cb4d5063cbac884cafda2863d324c0d414b Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Fri, 17 Apr 2026 09:16:12 -0400 Subject: [PATCH 15/20] Fix UTF-32 encoding in global state (#4063) Encoding::UTF_32 doesn't actually encodes strings correctly and we were calculating code points with a wrong encoding. It's UTF_32LE that encodes the string and provides us the right locations back --- lib/ruby_lsp/global_state.rb | 2 +- test/global_state_test.rb | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index 0836ca880b..17f674c392 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -204,7 +204,7 @@ def apply_options(options) Encoding::UTF_16LE else @graph.encoding = "utf32" - Encoding::UTF_32 + Encoding::UTF_32LE end @index.configuration.encoding = @encoding diff --git a/test/global_state_test.rb b/test/global_state_test.rb index 35f96e7540..91a114a09f 100644 --- a/test/global_state_test.rb +++ b/test/global_state_test.rb @@ -219,6 +219,22 @@ def test_delegates_supports_watching_files_to_client_capabilities global_state.supports_watching_files end + def test_utf32_negotiation_yields_encoding_compatible_with_prism_code_units_cache + state = GlobalState.new + state.apply_options(capabilities: { general: { positionEncodings: ["utf-32"] } }) + + source = "class Foo; end\n\"🙂\"; Foo\n" + result = Prism.parse(source) + cache = result.code_units_cache(state.encoding) + foo_ref = result.value.statements.body.last #: as !nil + + # `Foo` on line 1 starts at byte 23 (`class Foo; end\n` = 15 bytes + `"🙂"; ` = 8 bytes). + # In UTF-32 code units that same position is codepoint 20 (15 + 5). If `state.encoding` + # returns the dummy `Encoding::UTF_32`, the cache cannot translate past `🙂` and this + # assertion fails with a non-20 value. + assert_equal(20, foo_ref.location.cached_start_code_units_offset(cache)) + end + def test_feature_flags_are_processed_by_apply_options state = GlobalState.new From 9d24700d65f2ce09ed99be67792cc5d0ec17598c Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Tue, 21 Apr 2026 10:31:08 -0400 Subject: [PATCH 16/20] Upgrade rubydex to v0.1.0.beta13 --- Gemfile.lock | 14 ++++++----- ....0.beta12.rbi => rubydex@0.1.0.beta13.rbi} | 25 ++++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) rename sorbet/rbi/gems/{rubydex@0.1.0.beta12.rbi => rubydex@0.1.0.beta13.rbi} (99%) diff --git a/Gemfile.lock b/Gemfile.lock index e6d59453f8..b642536d5b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,9 +92,10 @@ GEM rubocop (>= 1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - rubydex (0.1.0.beta12) - rubydex (0.1.0.beta12-arm64-darwin) - rubydex (0.1.0.beta12-x86_64-linux) + rubydex (0.1.0.beta13-arm64-darwin) + rubydex (0.1.0.beta13-x64-mingw-ucrt) + rubydex (0.1.0.beta13-x86_64-darwin) + rubydex (0.1.0.beta13-x86_64-linux) sorbet (0.6.13055) sorbet-static (= 0.6.13055) sorbet-runtime (0.6.13055) @@ -204,9 +205,10 @@ CHECKSUMS ruby-lsp (0.27.0.beta1) ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - rubydex (0.1.0.beta12) sha256=3c08753ce69ab17999c3e65209180fc2801e5b6e85a2d0064ceda881a9a85d17 - rubydex (0.1.0.beta12-arm64-darwin) sha256=d565632fa825db57191cef3865bf361339a0f82fc465e46aec3793fd858beeb1 - rubydex (0.1.0.beta12-x86_64-linux) sha256=9034afc1ddb612a74c4242f566facc910e16bbde010e5855d6ca45b60d3ef757 + rubydex (0.1.0.beta13-arm64-darwin) sha256=f840d6ec14368cca6951c093ec19518baa91fa627998ac9ff06653943a6919f1 + rubydex (0.1.0.beta13-x64-mingw-ucrt) sha256=c834f3ced16fe5384b138ab8d7aa2335c43043ba4927de904a89b16bfaa05c2a + rubydex (0.1.0.beta13-x86_64-darwin) sha256=be1b16aaa2c933d437049c67f50e8e8c1c778c3a7b9661681d7ba8106b88428c + rubydex (0.1.0.beta13-x86_64-linux) sha256=0d33d3820b0e43402e019e5ae516f7de11f590fd2a1ec50063f1d820e36e1ed1 sorbet (0.6.13055) sha256=5f5e8f37c13c281fa2b2f95e261d2e531d8331ddcc8e2dd8c4f16457935872ec sorbet-runtime (0.6.13055) sha256=c8ae8c81310e0a28d290b11f44ddca59659b7d7f13752c0ef5d16964bbb84d18 sorbet-static (0.6.13055-universal-darwin) sha256=649c8e79a443be85318922f9ecbb46be72f6c585443f4440c4ec0fb1737c86e8 diff --git a/sorbet/rbi/gems/rubydex@0.1.0.beta12.rbi b/sorbet/rbi/gems/rubydex@0.1.0.beta13.rbi similarity index 99% rename from sorbet/rbi/gems/rubydex@0.1.0.beta12.rbi rename to sorbet/rbi/gems/rubydex@0.1.0.beta13.rbi index 032a49f8d1..2894daea19 100644 --- a/sorbet/rbi/gems/rubydex@0.1.0.beta12.rbi +++ b/sorbet/rbi/gems/rubydex@0.1.0.beta13.rbi @@ -81,6 +81,7 @@ end class Rubydex::ConstantVisibilityDefinition < ::Rubydex::Definition; end +# source://rubydex//lib/rubydex/declaration.rb#4 class Rubydex::Declaration # source://rubydex//lib/rubydex.rb#11 def initialize(_arg0, _arg1); end @@ -97,7 +98,10 @@ class Rubydex::Declaration sig { returns(Rubydex::Declaration) } def owner; end - # source://rubydex//lib/rubydex.rb#11 + # @abstract + # @raise [NotImplementedError] + # + # source://rubydex//lib/rubydex/declaration.rb#7 sig { returns(T::Enumerable[Rubydex::Reference]) } def references; end @@ -299,6 +303,14 @@ class Rubydex::Graph sig { params(encoding: String).void } def encoding=(encoding); end + # source://rubydex//lib/rubydex.rb#11 + sig { params(paths: T::Array[String]).void } + def exclude_paths(paths); end + + # source://rubydex//lib/rubydex.rb#11 + sig { returns(T::Array[String]) } + def excluded_paths; end + # source://rubydex//lib/rubydex.rb#11 sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) } def fuzzy_search(query); end @@ -318,6 +330,9 @@ class Rubydex::Graph sig { returns(T::Array[String]) } def index_workspace; end + # source://rubydex//lib/rubydex.rb#11 + def keyword(_arg0); end + # source://rubydex//lib/rubydex.rb#11 sig { returns(T::Enumerable[Rubydex::MethodReference]) } def method_references; end @@ -374,14 +389,6 @@ class Rubydex::Graph # source://rubydex//lib/rubydex/graph.rb#63 sig { params(paths: T::Array[String]).void } def add_workspace_dependency_paths(paths); end - - # source://rubydex//lib/rubydex.rb#11 - sig { params(paths: T::Array[String]).void } - def exclude_paths(paths); end - - # source://rubydex//lib/rubydex.rb#11 - sig { returns(T::Array[String]) } - def excluded_paths; end end # source://rubydex//lib/rubydex/graph.rb#8 From d1435acd75ca0d130b1a677f0b5750128df94230 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Thu, 23 Apr 2026 09:14:40 -0400 Subject: [PATCH 17/20] Benchmark and send telemetry events for indexing and resolution (#4074) --- lib/ruby_lsp/server.rb | 38 ++++++++++++++++++++++++++++++++------ test/server_test.rb | 5 ++--- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 4d34255de7..e2dc3ab1c2 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -436,8 +436,11 @@ def text_document_did_change(message) if [:ruby, :rbs].include?(language_id) graph = @global_state.graph - graph.index_source(text_document[:uri].to_s, document.source, language_id.to_s) - graph.resolve + + benchmark("index_source") do + graph.index_source(text_document[:uri].to_s, document.source, language_id.to_s) + end + benchmark("incremental_resolve") { graph.resolve } end end @@ -1070,8 +1073,8 @@ def workspace_did_change_watched_files(message) acc << path end end - graph.index_all(additions_and_changes) - graph.resolve + benchmark("index_all") { graph.index_all(additions_and_changes) } + benchmark("incremental_resolve") { graph.resolve } index = @global_state.index changes.each do |change| @@ -1269,10 +1272,10 @@ def shutdown #: -> void def perform_initial_indexing progress("indexing-progress", message: "Indexing workspace...") - @global_state.graph.index_workspace + benchmark("index_workspace") { @global_state.graph.index_workspace } progress("indexing-progress", message: "Resolving graph...") - @global_state.graph.resolve + benchmark("full_resolve") { @global_state.graph.resolve } # The begin progress invocation happens during `initialize`, so that the notification is sent before we are # stuck indexing files @@ -1572,5 +1575,28 @@ def code_lens_resolve(message) response: code_lens, )) end + + #: [T] (String) { () -> T } -> T + def benchmark(label, &block) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) + result = block.call + duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start + + send_message(Notification.telemetry({ + type: "data", + eventName: "ruby_lsp.response_time", + data: { + type: "histogram", + value: duration, + attributes: { + message: label, + lspVersion: RubyLsp::VERSION, + rubyVersion: RUBY_VERSION, + }, + }, + })) + + result + end end end diff --git a/test/server_test.rb b/test/server_test.rb index 8e6a0d5856..a8bdd4ddf0 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -194,8 +194,7 @@ def test_initialized_recovers_from_indexing_failures @server.process_message({ method: "initialized" }) end - notification = @server.pop_response - assert_equal("window/showMessage", notification.method) + notification = find_message(RubyLsp::Notification, "window/showMessage") expected_message = "Error while indexing (see [troubleshooting steps]" \ "(https://shopify.github.io/ruby-lsp/troubleshooting#indexing)): boom!" assert_equal( @@ -576,7 +575,7 @@ def version }, }) - message = @server.pop_response.params.message + message = find_message(RubyLsp::Notification, "window/logMessage").params.message assert_match("Error in Foo add-on while processing watched file notifications", message) assert_match("boom", message) ensure From 86dd20438780f2c010b06a1c6888b66dc78ab125 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Tue, 28 Apr 2026 15:23:18 -0400 Subject: [PATCH 18/20] Migrate test discovery to use Rubydex (#4064) --- lib/ruby_lsp/listeners/spec_style.rb | 7 +- lib/ruby_lsp/listeners/test_discovery.rb | 35 ++++--- lib/ruby_lsp/listeners/test_style.rb | 28 ++++-- lib/ruby_lsp/requests/discover_tests.rb | 46 +--------- test/requests/discover_tests_test.rb | 111 +++++++++-------------- 5 files changed, 93 insertions(+), 134 deletions(-) diff --git a/lib/ruby_lsp/listeners/spec_style.rb b/lib/ruby_lsp/listeners/spec_style.rb index 5f79a418ab..aad6658093 100644 --- a/lib/ruby_lsp/listeners/spec_style.rb +++ b/lib/ruby_lsp/listeners/spec_style.rb @@ -34,7 +34,7 @@ def initialize(response_builder, global_state, dispatcher, uri) #: (Prism::ClassNode) -> void def on_class_node_enter(node) # rubocop:disable RubyLsp/UseRegisterWithHandlerMethod with_test_ancestor_tracking(node) do |name, ancestors| - @spec_group_id_stack << (ancestors.include?("Minitest::Spec") ? ClassGroup.new(name) : nil) + @spec_group_id_stack << (spec_group?(ancestors, name) ? ClassGroup.new(name) : nil) end end @@ -81,6 +81,11 @@ def on_call_node_leave(node) # rubocop:disable RubyLsp/UseRegisterWithHandlerMet private + #: (Array[String], String) -> bool + def spec_group?(ancestors, fully_qualified_name) + fully_qualified_name != "Minitest::Spec" && ancestors.include?("Minitest::Spec") + end + #: (Prism::CallNode) -> void def handle_describe(node) # Describes will include the nesting of all classes and all outer describes as part of its ID, unlike classes diff --git a/lib/ruby_lsp/listeners/test_discovery.rb b/lib/ruby_lsp/listeners/test_discovery.rb index 18c4dc8ed4..e36d6b62a9 100644 --- a/lib/ruby_lsp/listeners/test_discovery.rb +++ b/lib/ruby_lsp/listeners/test_discovery.rb @@ -13,7 +13,7 @@ class TestDiscovery def initialize(response_builder, global_state, uri) @response_builder = response_builder @uri = uri - @index = global_state.index #: RubyIndexer::Index + @graph = global_state.graph #: Rubydex::Graph @visibility_stack = [:public] #: Array[Symbol] @nesting = [] #: Array[String] end @@ -64,22 +64,29 @@ def calc_attached_ancestors(node, fully_qualified_name) superclass = node.superclass begin - ancestors = @index.linearized_ancestors_of(fully_qualified_name) - # If the project has no bundle and a test class inherits from `Minitest::Test`, the linearized ancestors will - # not include the parent class because we never indexed it in the first place. Here we add the superclass - # directly, so that we can support running tests in projects without a bundle - return ancestors if ancestors.length > 1 - - # If all we found is the class itself, then manually include the parent class - if ancestors.first == fully_qualified_name && superclass - return [*ancestors, superclass.slice] + declaration = @graph[fully_qualified_name] + + unless declaration.is_a?(Rubydex::Namespace) + # When there are dynamic parts in the constant path, we will not have indexed the namespace. We can still + # provide test functionality if the class inherits directly from Test::Unit::TestCase or Minitest::Test + return [superclass&.slice].compact + end + + ancestors = declaration.ancestors.map(&:name) + superclass_ref = declaration.definitions + .filter_map { |d| d.superclass if d.is_a?(Rubydex::ClassDefinition) } + .find { |ref| !ref.is_a?(Rubydex::ResolvedConstantReference) || ref.declaration.name != "Object" } + + # If we couldn't resolve the parent class, then artificially inject it into the ancestors + if superclass_ref.is_a?(Rubydex::UnresolvedConstantReference) && superclass + insert_index = ancestors.index(fully_qualified_name) #: as !nil + insert_index += 1 + ancestors.insert(insert_index, superclass.slice) + return ancestors end + # If the parent class is properly resolved or if there isn't one, then just use the ancestors ancestors - rescue RubyIndexer::Index::NonExistingNamespaceError - # When there are dynamic parts in the constant path, we will not have indexed the namespace. We can still - # provide test functionality if the class inherits directly from Test::Unit::TestCase or Minitest::Test - [superclass&.slice].compact end end diff --git a/lib/ruby_lsp/listeners/test_style.rb b/lib/ruby_lsp/listeners/test_style.rb index b29aeb204b..7d2881bc48 100644 --- a/lib/ruby_lsp/listeners/test_style.rb +++ b/lib/ruby_lsp/listeners/test_style.rb @@ -174,9 +174,10 @@ def initialize(response_builder, global_state, dispatcher, uri) #: (Prism::ClassNode node) -> void def on_class_node_enter(node) # rubocop:disable RubyLsp/UseRegisterWithHandlerMethod with_test_ancestor_tracking(node) do |name, ancestors| - @framework = :test_unit if ancestors.include?("Test::Unit::TestCase") + is_test_unit = test_unit_group?(ancestors, name) + @framework = :test_unit if is_test_unit - if @framework == :test_unit || non_declarative_minitest?(ancestors, name) + if is_test_unit || non_declarative_minitest?(ancestors, name) test_item = Requests::Support::TestItem.new( name, name, @@ -259,17 +260,28 @@ def last_test_group @parent_stack[index] #: as Requests::Support::TestItem | ResponseBuilders::TestCollection end - #: (Array[String] attached_ancestors, String fully_qualified_name) -> bool + #: (Array[String], String) -> bool + def test_unit_group?(ancestors, fully_qualified_name) + fully_qualified_name != "Test::Unit::TestCase" && ancestors.include?("Test::Unit::TestCase") + end + + #: (Array[String], String) -> bool def non_declarative_minitest?(attached_ancestors, fully_qualified_name) + return false if ["Minitest::Spec", "Minitest::Test", "ActiveSupport::TestCase"].include?(fully_qualified_name) return false unless attached_ancestors.include?("Minitest::Test") # We only support regular Minitest tests. The declarative syntax provided by ActiveSupport is handled by the # Rails add-on - name_parts = fully_qualified_name.split("::") - singleton_name = "#{name_parts.join("::")}::<#{name_parts.last}>" - !@index.linearized_ancestors_of(singleton_name).include?("ActiveSupport::Testing::Declarative") - rescue RubyIndexer::Index::NonExistingNamespaceError - true + + declaration = @graph[fully_qualified_name] + # If we don't find the fully qualified name in the graph, it means there's a dynamic portion in the test class + # definition. In that case, if the ancestors did include `Minitest::Test`, we always assume it's a test + return true unless declaration.is_a?(Rubydex::Namespace) + + singleton = declaration.singleton_class + return !singleton.ancestors.map(&:name).include?("ActiveSupport::Testing::Declarative") if singleton + + !attached_ancestors.include?("ActiveSupport::TestCase") end end end diff --git a/lib/ruby_lsp/requests/discover_tests.rb b/lib/ruby_lsp/requests/discover_tests.rb index 05f4e833d4..527e34928d 100644 --- a/lib/ruby_lsp/requests/discover_tests.rb +++ b/lib/ruby_lsp/requests/discover_tests.rb @@ -19,55 +19,19 @@ def initialize(global_state, document, dispatcher) @document = document @dispatcher = dispatcher @response_builder = ResponseBuilders::TestCollection.new #: ResponseBuilders::TestCollection - @index = global_state.index #: RubyIndexer::Index end # @override #: -> Array[Support::TestItem] def perform - uri = @document.uri + Listeners::TestStyle.new(@response_builder, @global_state, @dispatcher, @document.uri) + Listeners::SpecStyle.new(@response_builder, @global_state, @dispatcher, @document.uri) - # We normally only index test files once they are opened in the editor to save memory and avoid doing - # unnecessary work. If the file is already opened and we already indexed it, then we can just discover the tests - # straight away. - # - # However, if the user navigates to a specific test file from the explorer with nothing opened in the UI, then - # we will not have indexed the test file yet and trying to linearize the ancestor of the class will fail. In - # this case, we have to instantiate the indexer listener first, so that we insert classes, modules and methods - # in the index first and then discover the tests, all in the same traversal. - if @index.entries_for(uri.to_s) - Listeners::TestStyle.new(@response_builder, @global_state, @dispatcher, @document.uri) - Listeners::SpecStyle.new(@response_builder, @global_state, @dispatcher, @document.uri) - - Addon.addons.each do |addon| - addon.create_discover_tests_listener(@response_builder, @dispatcher, @document.uri) - end - - @dispatcher.visit(@document.ast) - else - @global_state.synchronize do - RubyIndexer::DeclarationListener.new( - @index, - @dispatcher, - @document.parse_result, - uri, - collect_comments: true, - ) - - Listeners::TestStyle.new(@response_builder, @global_state, @dispatcher, @document.uri) - Listeners::SpecStyle.new(@response_builder, @global_state, @dispatcher, @document.uri) - - Addon.addons.each do |addon| - addon.create_discover_tests_listener(@response_builder, @dispatcher, @document.uri) - end - - # Dispatch the events both for indexing the test file and discovering the tests. The order here is - # important because we need the index to be aware of the existing classes/modules/methods before the test - # listeners can do their work - @dispatcher.visit(@document.ast) - end + Addon.addons.each do |addon| + addon.create_discover_tests_listener(@response_builder, @dispatcher, @document.uri) end + @dispatcher.visit(@document.ast) @response_builder.response end end diff --git a/test/requests/discover_tests_test.rb b/test/requests/discover_tests_test.rb index d727dc0d73..1f2a04fab5 100644 --- a/test/requests/discover_tests_test.rb +++ b/test/requests/discover_tests_test.rb @@ -359,43 +359,6 @@ def test_something; end end end - def test_files_are_indexed_lazily_if_needed - path = File.join(Dir.pwd, "lib", "foo.rb") - File.write(path, <<~RUBY) - class FooTest < Test::Unit::TestCase - def test_something; end - end - RUBY - - begin - with_server do |server, uri| - server.global_state.index.index_single(uri, <<~RUBY) - module Test - module Unit - class TestCase; end - end - end - RUBY - - server.process_message( - id: 1, - method: "rubyLsp/discoverTests", - params: { textDocument: { uri: URI::Generic.from_path(path: path) } }, - ) - - items = get_response(server) - assert_equal( - ["FooTest"], - items.map { |i| i[:label] }, - ) - assert_equal(["test_something"], items[0][:children].map { |i| i[:label] }) - assert_all_items_tagged_with(items, :test_unit) - end - ensure - FileUtils.rm(path) - end - end - def test_does_not_raise_on_duplicate_test_ids source = <<~RUBY module Foo @@ -898,13 +861,15 @@ def assert_all_items_tagged_with(items, tag) end def with_minitest_test(source, &block) - with_server(source) do |server, uri| - server.global_state.index.index_single(uri, <<~RUBY) - module Minitest - class Test; end - end - RUBY + source_with_minitest = <<~RUBY + #{source} + module Minitest + class Test; end + end + RUBY + + with_server(source_with_minitest) do |server, uri| server.process_message(id: 1, method: "rubyLsp/discoverTests", params: { textDocument: { uri: uri }, }) @@ -916,15 +881,17 @@ class Test; end end def with_test_unit(source, &block) - with_server(source) do |server, uri| - server.global_state.index.index_single(uri, <<~RUBY) - module Test - module Unit - class TestCase; end - end + source_with_test_unit = <<~RUBY + #{source} + + module Test + module Unit + class TestCase; end end - RUBY + end + RUBY + with_server(source_with_test_unit) do |server, uri| server.process_message(id: 1, method: "rubyLsp/discoverTests", params: { textDocument: { uri: uri }, }) @@ -936,24 +903,26 @@ class TestCase; end end def with_active_support_declarative_tests(source, &block) - with_server(source) do |server, uri| - server.global_state.index.index_single(uri, <<~RUBY) - module Minitest - class Test; end - end + source_with_test_case = <<~RUBY + #{source} - module ActiveSupport - module Testing - module Declarative - end - end + module Minitest + class Test; end + end - class TestCase < Minitest::Test - extend Testing::Declarative + module ActiveSupport + module Testing + module Declarative end end - RUBY + class TestCase < Minitest::Test + extend Testing::Declarative + end + end + RUBY + + with_server(source_with_test_case) do |server, uri| server.process_message(id: 1, method: "rubyLsp/discoverTests", params: { textDocument: { uri: uri }, }) @@ -965,14 +934,16 @@ class TestCase < Minitest::Test end def with_minitest_spec_configured(source, &block) - with_server(source) do |server, uri| - server.global_state.index.index_single(uri, <<~RUBY) - module Minitest - class Test; end - class Spec < Test; end - end - RUBY + source_with_spec = <<~RUBY + #{source} + + module Minitest + class Test; end + class Spec < Test; end + end + RUBY + with_server(source_with_spec) do |server, uri| server.process_message(id: 1, method: "rubyLsp/discoverTests", params: { textDocument: { uri: uri }, }) From 9f6b0e0d6310a946d1812c4b21d10a560d37cb03 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Thu, 30 Apr 2026 09:29:23 -0400 Subject: [PATCH 19/20] Complete keyword hover with Rubydex (#4073) --- lib/ruby_lsp/internal.rb | 1 - lib/ruby_lsp/listeners/hover.rb | 296 ++++++++--- lib/ruby_lsp/requests/completion_resolve.rb | 22 +- lib/ruby_lsp/requests/hover.rb | 7 +- lib/ruby_lsp/requests/request.rb | 36 +- lib/ruby_lsp/requests/support/common.rb | 13 + lib/ruby_lsp/static_docs.rb | 20 - ruby-lsp.gemspec | 4 +- static_docs/break.md | 103 ---- static_docs/yield.md | 81 --- test/requests/completion_resolve_test.rb | 8 +- test/requests/hover_expectations_test.rb | 518 +++++++++++++++++++- 12 files changed, 745 insertions(+), 364 deletions(-) delete mode 100644 lib/ruby_lsp/static_docs.rb delete mode 100644 static_docs/break.md delete mode 100644 static_docs/yield.md diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 1a2cbdf953..f0b36e0d85 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -39,7 +39,6 @@ require "ruby_lsp/base_server" require "ruby_indexer/ruby_indexer" require "ruby_lsp/utils" -require "ruby_lsp/static_docs" require "ruby_lsp/scope" require "ruby_lsp/client_capabilities" require "ruby_lsp/global_state" diff --git a/lib/ruby_lsp/listeners/hover.rb b/lib/ruby_lsp/listeners/hover.rb index 8aadae7c44..fb7f8689b5 100644 --- a/lib/ruby_lsp/listeners/hover.rb +++ b/lib/ruby_lsp/listeners/hover.rb @@ -6,45 +6,13 @@ module Listeners class Hover include Requests::Support::Common - ALLOWED_TARGETS = [ - Prism::BreakNode, - Prism::CallNode, - Prism::ConstantReadNode, - Prism::ConstantWriteNode, - Prism::ConstantPathNode, - Prism::GlobalVariableAndWriteNode, - Prism::GlobalVariableOperatorWriteNode, - Prism::GlobalVariableOrWriteNode, - Prism::GlobalVariableReadNode, - Prism::GlobalVariableTargetNode, - Prism::GlobalVariableWriteNode, - Prism::InstanceVariableReadNode, - Prism::InstanceVariableAndWriteNode, - Prism::InstanceVariableOperatorWriteNode, - Prism::InstanceVariableOrWriteNode, - Prism::InstanceVariableTargetNode, - Prism::InstanceVariableWriteNode, - Prism::SymbolNode, - Prism::StringNode, - Prism::InterpolatedStringNode, - Prism::SuperNode, - Prism::ForwardingSuperNode, - Prism::YieldNode, - Prism::ClassVariableAndWriteNode, - Prism::ClassVariableOperatorWriteNode, - Prism::ClassVariableOrWriteNode, - Prism::ClassVariableReadNode, - Prism::ClassVariableTargetNode, - Prism::ClassVariableWriteNode, - ] #: Array[singleton(Prism::Node)] - ALLOWED_REMOTE_PROVIDERS = [ "https://github.com", "https://gitlab.com", ].freeze #: Array[String] - #: (ResponseBuilders::Hover response_builder, GlobalState global_state, URI::Generic uri, NodeContext node_context, Prism::Dispatcher dispatcher, SorbetLevel sorbet_level) -> void - def initialize(response_builder, global_state, uri, node_context, dispatcher, sorbet_level) # rubocop:disable Metrics/ParameterLists + #: (ResponseBuilders::Hover response_builder, GlobalState global_state, URI::Generic uri, NodeContext node_context, Prism::Dispatcher dispatcher, SorbetLevel sorbet_level, Hash[Symbol, untyped] position) -> void + def initialize(response_builder, global_state, uri, node_context, dispatcher, sorbet_level, position) # rubocop:disable Metrics/ParameterLists @response_builder = response_builder @global_state = global_state @index = global_state.index #: RubyIndexer::Index @@ -53,45 +21,80 @@ def initialize(response_builder, global_state, uri, node_context, dispatcher, so @path = uri.to_standardized_path #: String? @node_context = node_context @sorbet_level = sorbet_level + @position = position dispatcher.register( self, + :on_alias_global_variable_node_enter, + :on_alias_method_node_enter, + :on_and_node_enter, + :on_begin_node_enter, + :on_block_node_enter, :on_break_node_enter, + :on_call_node_enter, + :on_case_match_node_enter, + :on_case_node_enter, + :on_class_node_enter, + :on_singleton_class_node_enter, + :on_lambda_node_enter, + :on_class_variable_and_write_node_enter, + :on_class_variable_operator_write_node_enter, + :on_class_variable_or_write_node_enter, + :on_class_variable_read_node_enter, + :on_class_variable_target_node_enter, + :on_class_variable_write_node_enter, + :on_constant_path_node_enter, :on_constant_read_node_enter, :on_constant_write_node_enter, - :on_constant_path_node_enter, - :on_call_node_enter, + :on_def_node_enter, + :on_defined_node_enter, + :on_else_node_enter, + :on_ensure_node_enter, + :on_false_node_enter, + :on_for_node_enter, + :on_forwarding_super_node_enter, :on_global_variable_and_write_node_enter, :on_global_variable_operator_write_node_enter, :on_global_variable_or_write_node_enter, :on_global_variable_read_node_enter, :on_global_variable_target_node_enter, :on_global_variable_write_node_enter, - :on_instance_variable_read_node_enter, - :on_instance_variable_write_node_enter, + :on_if_node_enter, + :on_in_node_enter, :on_instance_variable_and_write_node_enter, :on_instance_variable_operator_write_node_enter, :on_instance_variable_or_write_node_enter, + :on_instance_variable_read_node_enter, :on_instance_variable_target_node_enter, - :on_super_node_enter, - :on_forwarding_super_node_enter, - :on_string_node_enter, + :on_instance_variable_write_node_enter, :on_interpolated_string_node_enter, + :on_module_node_enter, + :on_next_node_enter, + :on_nil_node_enter, + :on_or_node_enter, + :on_post_execution_node_enter, + :on_pre_execution_node_enter, + :on_redo_node_enter, + :on_rescue_modifier_node_enter, + :on_rescue_node_enter, + :on_retry_node_enter, + :on_return_node_enter, + :on_self_node_enter, + :on_source_encoding_node_enter, + :on_source_file_node_enter, + :on_source_line_node_enter, + :on_string_node_enter, + :on_super_node_enter, + :on_true_node_enter, + :on_undef_node_enter, + :on_unless_node_enter, + :on_until_node_enter, + :on_when_node_enter, + :on_while_node_enter, :on_yield_node_enter, - :on_class_variable_and_write_node_enter, - :on_class_variable_operator_write_node_enter, - :on_class_variable_or_write_node_enter, - :on_class_variable_read_node_enter, - :on_class_variable_target_node_enter, - :on_class_variable_write_node_enter, ) end - #: (Prism::BreakNode node) -> void - def on_break_node_enter(node) - handle_keyword_documentation(node.keyword) - end - #: (Prism::StringNode node) -> void def on_string_node_enter(node) if @path && File.basename(@path) == GEMFILE_NAME @@ -144,6 +147,12 @@ def on_call_node_enter(node) message = node.message return unless message + # `not x` is parsed as a call to `!` whose message_loc slices to "not" + if node.name == :! && message == "not" + handle_keyword_documentation("not") + return + end + handle_method_hover(message) end @@ -209,19 +218,150 @@ def on_instance_variable_target_node_enter(node) #: (Prism::SuperNode node) -> void def on_super_node_enter(node) - handle_super_node_hover + handle_super_node_hover(node.keyword_loc) end #: (Prism::ForwardingSuperNode node) -> void def on_forwarding_super_node_enter(node) - handle_super_node_hover + handle_super_node_hover(node.location) + end + + #: (Prism::AliasGlobalVariableNode) -> void + def on_alias_global_variable_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::AliasMethodNode) -> void + def on_alias_method_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::AndNode) -> void + def on_and_node_enter(node) = handle_keyword_at_location(node.operator_loc) + + #: (Prism::BeginNode) -> void + def on_begin_node_enter(node) = handle_keyword_at_location(node.begin_keyword_loc, node.end_keyword_loc) + + #: (Prism::BlockNode) -> void + def on_block_node_enter(node) = handle_keyword_at_location(node.opening_loc, node.closing_loc) + + #: (Prism::BreakNode) -> void + def on_break_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::CaseMatchNode) -> void + def on_case_match_node_enter(node) = handle_keyword_at_location(node.case_keyword_loc, node.end_keyword_loc) + + #: (Prism::CaseNode) -> void + def on_case_node_enter(node) = handle_keyword_at_location(node.case_keyword_loc, node.end_keyword_loc) + + #: (Prism::ClassNode) -> void + def on_class_node_enter(node) = handle_keyword_at_location(node.class_keyword_loc, node.end_keyword_loc) + + #: (Prism::SingletonClassNode) -> void + def on_singleton_class_node_enter(node) + handle_keyword_at_location(node.class_keyword_loc, node.end_keyword_loc) + end + + #: (Prism::LambdaNode) -> void + def on_lambda_node_enter(node) = handle_keyword_at_location(node.opening_loc, node.closing_loc) + + #: (Prism::DefNode) -> void + def on_def_node_enter(node) = handle_keyword_at_location(node.def_keyword_loc, node.end_keyword_loc) + + #: (Prism::DefinedNode) -> void + def on_defined_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::ElseNode) -> void + def on_else_node_enter(node) = handle_keyword_at_location(node.else_keyword_loc, node.end_keyword_loc) + + #: (Prism::EnsureNode) -> void + def on_ensure_node_enter(node) = handle_keyword_at_location(node.ensure_keyword_loc, node.end_keyword_loc) + + #: (Prism::FalseNode) -> void + def on_false_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::ForNode) -> void + def on_for_node_enter(node) + handle_keyword_at_location( + node.for_keyword_loc, + node.in_keyword_loc, + node.do_keyword_loc, + node.end_keyword_loc, + ) + end + + #: (Prism::IfNode) -> void + def on_if_node_enter(node) + handle_keyword_at_location(node.if_keyword_loc, node.then_keyword_loc, node.end_keyword_loc) end - #: (Prism::YieldNode node) -> void - def on_yield_node_enter(node) - handle_keyword_documentation(node.keyword) + #: (Prism::InNode) -> void + def on_in_node_enter(node) = handle_keyword_at_location(node.in_loc, node.then_loc) + + #: (Prism::ModuleNode) -> void + def on_module_node_enter(node) = handle_keyword_at_location(node.module_keyword_loc, node.end_keyword_loc) + + #: (Prism::NextNode) -> void + def on_next_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::NilNode) -> void + def on_nil_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::OrNode) -> void + def on_or_node_enter(node) = handle_keyword_at_location(node.operator_loc) + + #: (Prism::PostExecutionNode) -> void + def on_post_execution_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::PreExecutionNode) -> void + def on_pre_execution_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::RedoNode) -> void + def on_redo_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::RescueModifierNode) -> void + def on_rescue_modifier_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::RescueNode) -> void + def on_rescue_node_enter(node) = handle_keyword_at_location(node.keyword_loc, node.then_keyword_loc) + + #: (Prism::RetryNode) -> void + def on_retry_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::ReturnNode) -> void + def on_return_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::SelfNode) -> void + def on_self_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::SourceEncodingNode) -> void + def on_source_encoding_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::SourceFileNode) -> void + def on_source_file_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::SourceLineNode) -> void + def on_source_line_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::TrueNode) -> void + def on_true_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::UndefNode) -> void + def on_undef_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::UnlessNode) -> void + def on_unless_node_enter(node) + handle_keyword_at_location(node.keyword_loc, node.then_keyword_loc, node.end_keyword_loc) end + #: (Prism::UntilNode) -> void + def on_until_node_enter(node) = handle_keyword_at_location(node.keyword_loc, node.do_keyword_loc, node.closing_loc) + + #: (Prism::WhenNode) -> void + def on_when_node_enter(node) = handle_keyword_at_location(node.keyword_loc, node.then_keyword_loc) + + #: (Prism::WhileNode) -> void + def on_while_node_enter(node) = handle_keyword_at_location(node.keyword_loc, node.do_keyword_loc, node.closing_loc) + + #: (Prism::YieldNode) -> void + def on_yield_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + #: (Prism::ClassVariableAndWriteNode node) -> void def on_class_variable_and_write_node_enter(node) handle_variable_hover(node.name.to_s) @@ -279,27 +419,37 @@ def generate_heredoc_hover(node) end end - #: (String keyword) -> void - def handle_keyword_documentation(keyword) - content = KEYWORD_DOCS[keyword] - return unless content + #: (String) -> void + def handle_keyword_documentation(name) + keyword = @graph.keyword(name) + return unless keyword - doc_uri = URI::Generic.from_path(path: File.join(STATIC_DOCS_PATH, "#{keyword}.md")) - - @response_builder.push("```ruby\n#{keyword}\n```", category: :title) - @response_builder.push("[Read more](#{doc_uri})", category: :links) - @response_builder.push(content, category: :documentation) + @response_builder.push("```ruby\n#{keyword.name}\n```", category: :title) + @response_builder.push(keyword.documentation, category: :documentation) end - #: -> void - def handle_super_node_hover - # Sorbet can handle super hover on typed true or higher - return if @sorbet_level.true_or_higher? + # Push keyword documentation when the cursor is on one of the provided locations. The keyword name is taken from + # the covering location's slice so that operator forms (`&&`, `||`, `{`, `}`, ternary `? :`) yield no hover — + # their slice is not a keyword in the Rubydex graph. + # + #: (*Prism::Location?) -> void + def handle_keyword_at_location(*locations) + loc = locations.find { |l| l && covers_position?(l, @position) } + return unless loc - surrounding_method = @node_context.surrounding_method - return unless surrounding_method + handle_keyword_documentation(loc.slice) + end + + #: (Prism::Location keyword_location) -> void + def handle_super_node_hover(keyword_location) + # Sorbet can handle the inherited-method hover on typed true or higher, but it does not surface keyword docs, so + # we still push those + unless @sorbet_level.true_or_higher? + surrounding_method = @node_context.surrounding_method + handle_method_hover(surrounding_method, inherited_only: true) if surrounding_method + end - handle_method_hover(surrounding_method, inherited_only: true) + handle_keyword_at_location(keyword_location) end #: (String message, ?inherited_only: bool) -> void diff --git a/lib/ruby_lsp/requests/completion_resolve.rb b/lib/ruby_lsp/requests/completion_resolve.rb index fe83d36d70..2e21ab0a59 100644 --- a/lib/ruby_lsp/requests/completion_resolve.rb +++ b/lib/ruby_lsp/requests/completion_resolve.rb @@ -24,6 +24,7 @@ class CompletionResolve < Request def initialize(global_state, item) super() @index = global_state.index #: RubyIndexer::Index + @graph = global_state.graph #: Rubydex::Graph @item = item end @@ -40,7 +41,7 @@ def perform # For example, forgetting to return the `insertText` included in the original item will make the editor use the # `label` for the text edit instead label = @item[:label].dup - return keyword_resolve(@item) if @item.dig(:data, :keyword) + return keyword_resolve if @item.dig(:data, :keyword) entries = @index[label] || [] @@ -80,29 +81,24 @@ def perform private - #: (Hash[Symbol, untyped] item) -> Hash[Symbol, untyped] - def keyword_resolve(item) - keyword = item[:label] - content = KEYWORD_DOCS[keyword] - - if content - doc_uri = URI::Generic.from_path(path: File.join(STATIC_DOCS_PATH, "#{keyword}.md")) + #: () -> Hash[Symbol, untyped] + def keyword_resolve + keyword = @graph.keyword(@item[:label]) + if keyword @item[:documentation] = Interface::MarkupContent.new( kind: "markdown", value: <<~MARKDOWN.chomp, ```ruby - #{keyword} + #{keyword.name} ``` - [Read more](#{doc_uri}) - - #{content} + #{keyword.documentation} MARKDOWN ) end - item + @item end end end diff --git a/lib/ruby_lsp/requests/hover.rb b/lib/ruby_lsp/requests/hover.rb index d5f1e9dd2f..5095972d2a 100644 --- a/lib/ruby_lsp/requests/hover.rb +++ b/lib/ruby_lsp/requests/hover.rb @@ -26,7 +26,6 @@ def initialize(document, global_state, position, dispatcher, sorbet_level) node_context = RubyDocument.locate( document.ast, char_position, - node_types: Listeners::Hover::ALLOWED_TARGETS, code_units_cache: document.code_units_cache, ) target = node_context.node @@ -48,7 +47,7 @@ def initialize(document, global_state, position, dispatcher, sorbet_level) @target = target #: Prism::Node? uri = document.uri @response_builder = ResponseBuilders::Hover.new #: ResponseBuilders::Hover - Listeners::Hover.new(@response_builder, global_state, uri, node_context, dispatcher, sorbet_level) + Listeners::Hover.new(@response_builder, global_state, uri, node_context, dispatcher, sorbet_level, position) Addon.addons.each do |addon| addon.create_hover_listener(@response_builder, node_context, dispatcher) end @@ -77,9 +76,7 @@ def perform #: (Prism::Node? parent, Prism::Node? target) -> bool def should_refine_target?(parent, target) - (Listeners::Hover::ALLOWED_TARGETS.include?(parent.class) && - !Listeners::Hover::ALLOWED_TARGETS.include?(target.class)) || - (parent.is_a?(Prism::ConstantPathNode) && target.is_a?(Prism::ConstantReadNode)) + parent.is_a?(Prism::ConstantPathNode) && target.is_a?(Prism::ConstantReadNode) end #: (Hash[Symbol, untyped] position, Prism::Node? target) -> bool diff --git a/lib/ruby_lsp/requests/request.rb b/lib/ruby_lsp/requests/request.rb index 930b81a079..1fb9580e2e 100644 --- a/lib/ruby_lsp/requests/request.rb +++ b/lib/ruby_lsp/requests/request.rb @@ -5,6 +5,8 @@ module RubyLsp module Requests # @abstract class Request + include Support::Common + class InvalidFormatter < StandardError; end # @abstract @@ -26,24 +28,6 @@ def delegate_request_if_needed!(global_state, document, char_position) end end - # Checks if a location covers a position - #: (Prism::Location location, untyped position) -> bool - def cover?(location, position) - start_covered = - location.start_line - 1 < position[:line] || - ( - location.start_line - 1 == position[:line] && - location.start_column <= position[:character] - ) - end_covered = - location.end_line - 1 > position[:line] || - ( - location.end_line - 1 == position[:line] && - location.end_column >= position[:character] - ) - start_covered && end_covered - end - # Based on a constant node target, a constant path node parent and a position, this method will find the exact # portion of the constant path that matches the requested position, for higher precision in hover and # definition. For example: @@ -62,27 +46,13 @@ def determine_target(target, parent, position) parent = target #: as Prism::ConstantPathNode .parent #: Prism::Node? - while parent && cover?(parent.location, position) + while parent && covers_position?(parent.location, position) target = parent parent = target.is_a?(Prism::ConstantPathNode) ? target.parent : nil end target end - - # Checks if a given location covers the position requested - #: (Prism::Location? location, Hash[Symbol, untyped] position) -> bool - def covers_position?(location, position) - return false unless location - - start_line = location.start_line - 1 - end_line = location.end_line - 1 - line = position[:line] - character = position[:character] - - (start_line < line || (start_line == line && location.start_column <= character)) && - (end_line > line || (end_line == line && location.end_column >= character)) - end end end end diff --git a/lib/ruby_lsp/requests/support/common.rb b/lib/ruby_lsp/requests/support/common.rb index 8c391711cc..74edde32f5 100644 --- a/lib/ruby_lsp/requests/support/common.rb +++ b/lib/ruby_lsp/requests/support/common.rb @@ -34,6 +34,19 @@ def range_from_location(location) ) end + #: (Prism::Location? location, Hash[Symbol, untyped] position) -> bool + def covers_position?(location, position) + return false unless location + + start_line = location.start_line - 1 + end_line = location.end_line - 1 + line = position[:line] + character = position[:character] + + (start_line < line || (start_line == line && location.start_column <= character)) && + (end_line > line || (end_line == line && location.end_column >= character)) + end + #: (Prism::Node node, title: String, command_name: String, arguments: Array[untyped]?, data: Hash[untyped, untyped]?) -> Interface::CodeLens def create_code_lens(node, title:, command_name:, arguments:, data:) range = range_from_node(node) diff --git a/lib/ruby_lsp/static_docs.rb b/lib/ruby_lsp/static_docs.rb deleted file mode 100644 index edf4589d87..0000000000 --- a/lib/ruby_lsp/static_docs.rb +++ /dev/null @@ -1,20 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -module RubyLsp - # The path to the `static_docs` directory, where we keep long-form static documentation - STATIC_DOCS_PATH = File.join( - File.dirname( - File.dirname( - __dir__, #: as !nil - ), - ), - "static_docs", - ) #: String - - # A map of keyword => short documentation to be displayed on hover or completion - KEYWORD_DOCS = { - "break" => "Terminates the execution of a block or loop", - "yield" => "Invokes the passed block with the given arguments", - }.freeze #: Hash[String, String] -end diff --git a/ruby-lsp.gemspec b/ruby-lsp.gemspec index 328186e4a5..ce2a0cf583 100644 --- a/ruby-lsp.gemspec +++ b/ruby-lsp.gemspec @@ -13,9 +13,7 @@ Gem::Specification.new do |s| s.homepage = "https://github.com/Shopify/ruby-lsp" s.license = "MIT" - s.files = Dir.glob("lib/**/*.rb").grep_v(%r{^lib/ruby_indexer/test/}) + - ["README.md", "VERSION", "LICENSE.txt"] + - Dir.glob("static_docs/**/*.md") + s.files = Dir.glob("lib/**/*.rb").grep_v(%r{^lib/ruby_indexer/test/}) + ["README.md", "VERSION", "LICENSE.txt"] s.bindir = "exe" s.executables = ["ruby-lsp", "ruby-lsp-check", "ruby-lsp-launcher", "ruby-lsp-test-exec"] s.require_paths = ["lib"] diff --git a/static_docs/break.md b/static_docs/break.md deleted file mode 100644 index 16800f7fbb..0000000000 --- a/static_docs/break.md +++ /dev/null @@ -1,103 +0,0 @@ -# Break - -In Ruby, the `break` keyword is used to exit a loop or block prematurely. Unlike `next` which skips to the next iteration, `break` terminates the loop entirely and continues with the code after the loop. - -```ruby -# Basic break usage in a loop -5.times do |i| - break if i == 3 - - puts i -end -# Output: -# 0 -# 1 -# 2 -``` - -The `break` statement can be used with any of Ruby's iteration methods or loops. - -```ruby -array = [1, 2, 3, 4, 5] - -# Break in each iteration -array.each do |num| - break if num > 3 - - puts "Number: #{num}" -end -# Output: -# Number: 1 -# Number: 2 -# Number: 3 - -# Break in an infinite loop -count = 0 -loop do - count += 1 - break if count >= 3 - - puts "Count: #{count}" -end -# Output: -# Count: 1 -# Count: 2 -``` - -## Break with a Value - -When used inside a block, `break` can return a value that becomes the result of the method call. - -```ruby -# Break with a return value in map -result = [1, 2, 3, 4, 5].map do |num| - break "Too large!" if num > 3 - - num * 2 -end -puts result # Output: "Too large!" - -# Break with a value in find -number = (1..10).find do |n| - break n if n > 5 && n.even? -end -puts number # Output: 6 -``` - -## Break in Nested Loops - -When using `break` in nested loops, it only exits the innermost loop. To break from nested loops, you typically need to use a flag or return. - -```ruby -# Break in nested iteration -(1..3).each do |i| - puts "Outer: #{i}" - - (1..3).each do |j| - break if j == 2 - - puts " Inner: #{j}" - end -end -# Output: -# Outer: 1 -# Inner: 1 -# Outer: 2 -# Inner: 1 -# Outer: 3 -# Inner: 1 - -# Breaking from nested loops with a flag -found = false -(1..3).each do |i| - (1..3).each do |j| - if i * j == 4 - found = true - break - end - end - break if found -end -``` - -The `break` keyword is essential for controlling loop execution and implementing early exit conditions. It's particularly useful when you've found what you're looking for and don't need to continue iterating. \ No newline at end of file diff --git a/static_docs/yield.md b/static_docs/yield.md deleted file mode 100644 index dfa51cf875..0000000000 --- a/static_docs/yield.md +++ /dev/null @@ -1,81 +0,0 @@ -# Yield - -In Ruby, every method implicitly accepts a block, even when not included in the parameters list. - -```ruby -def foo -end - -foo { 123 } # works! -``` - -The `yield` keyword is used to invoke the block that was passed with arguments. - -```ruby -# Consider this method call. The block being passed to the method `foo` accepts an argument called `a`. -# It then takes whatever argument was passed and multiplies it by 2 -foo do |a| - a * 2 -end - -# In the `foo` method declaration, we can use `yield` to invoke the block that was passed and provide the block -# with the value for the `a` argument -def foo - # Invoke the block passed to `foo` with the number 10 as the argument `a` - result = yield(10) - puts result # Will print 20 -end -``` - -If `yield` is used to invoke the block, but no block was passed, that will result in a local jump error. - -```ruby -# If we invoke `foo` without a block, trying to `yield` will fail -foo - -# `foo': no block given (yield) (LocalJumpError) -``` - -We can decide to use `yield` conditionally by using Ruby's `block_given?` method, which will return `true` if a block -was passed to the method. - -```ruby -def foo - # If a block is passed when invoking `foo`, call the block with argument 10 and print the result. - # Otherwise, just print that no block was passed - if block_given? - result = yield(10) - puts result - else - puts "No block passed!" - end -end - -foo do |a| - a * 2 -end -# => 20 - -foo -# => No block passed! -``` - -## Block parameter - -In addition to implicit blocks, Ruby also allows developers to use explicit block parameters as part of the method's -signature. In this scenario, we can use the reference to the block directly instead of relying on the `yield` keyword. - -```ruby -# Block parameters are prefixed with & and a name -def foo(&my_block_param) - # If a block was passed to `foo`, `my_block_param` will be a `Proc` object. Otherwise, it will be `nil`. We can use - # that to check for its presence - if my_block_param - # Explicit block parameters are invoked using the method `call`, which is present in all `Proc` objects - result = my_block_param.call(10) - puts result - else - puts "No block passed!" - end -end -``` diff --git a/test/requests/completion_resolve_test.rb b/test/requests/completion_resolve_test.rb index c7664f66a9..d61565d6cc 100644 --- a/test/requests/completion_resolve_test.rb +++ b/test/requests/completion_resolve_test.rb @@ -199,13 +199,9 @@ def foo result = server.pop_response.response contents = result[:documentation].value + keyword = server.global_state.graph.keyword("yield") #: as !nil assert_match("```ruby\nyield\n```", contents) - assert_match( - RubyLsp::KEYWORD_DOCS["yield"], #: as !nil - contents, - ) - expected_uri = URI::Generic.from_path(path: File.join(RubyLsp::STATIC_DOCS_PATH, "yield.md")) - assert_match("[Read more](#{expected_uri})", contents) + assert_match(keyword.documentation, contents) end end diff --git a/test/requests/hover_expectations_test.rb b/test/requests/hover_expectations_test.rb index 511bfa4d32..63f5aa1a43 100644 --- a/test/requests/hover_expectations_test.rb +++ b/test/requests/hover_expectations_test.rb @@ -893,7 +893,7 @@ def bar end end - def test_hover_is_disabled_on_super_for_typed_true + def test_hover_on_super_for_typed_true_shows_keyword_doc_only source = <<~RUBY # typed: true class Parent @@ -913,7 +913,12 @@ def foo params: { textDocument: { uri: uri }, position: { character: 4, line: 6 } }, ) - assert_nil(server.pop_response.response) + response = server.pop_response.response + refute_nil(response) + + contents = response.contents.value + refute_match("foo", contents) + assert_match("```ruby\nsuper\n```", contents) end end @@ -941,45 +946,506 @@ def name; end def test_hover_for_keywords test_cases = { - "yield" => { - source: <<~RUBY, - def foo - yield - end - RUBY - position: { line: 1, character: 2 }, - }, - "break" => { - source: <<~RUBY, - while true - break - end - RUBY - position: { line: 1, character: 2 }, - }, + "BEGIN" => { source: "BEGIN { }" }, + "END" => { source: "END { }" }, + "__ENCODING__" => { source: "__ENCODING__" }, + "__FILE__" => { source: "__FILE__" }, + "__LINE__" => { source: "__LINE__" }, + "alias" => { source: "alias foo bar" }, + "and" => { source: "true and false", position: { character: 5, line: 0 } }, + "begin" => { source: "begin\nend" }, + "break" => { source: "break" }, + "case" => { source: "case 1\nwhen 1\nend" }, + "class" => { source: "class A\nend" }, + "def" => { source: "def foo\nend" }, + "defined?" => { source: "defined?(x)" }, + "do" => { source: "proc do\nend", position: { character: 5, line: 0 } }, + "else" => { source: "if true\nelse\nend", position: { character: 0, line: 1 } }, + "ensure" => { source: "begin\nensure\nend", position: { character: 0, line: 1 } }, + "false" => { source: "false" }, + "for" => { source: "for x in [1]\nend" }, + "if" => { source: "if true\nend" }, + "in" => { source: "case x\nin 1\nend", position: { character: 0, line: 1 } }, + "module" => { source: "module A\nend" }, + "next" => { source: "next" }, + "nil" => { source: "nil" }, + "or" => { source: "true or false", position: { character: 5, line: 0 } }, + "redo" => { source: "redo" }, + "rescue" => { source: "begin\nrescue\nend", position: { character: 0, line: 1 } }, + "retry" => { source: "retry" }, + "return" => { source: "return" }, + "self" => { source: "self" }, + "true" => { source: "true" }, + "undef" => { source: "undef :foo" }, + "unless" => { source: "unless true\nend" }, + "until" => { source: "until true\nend" }, + "when" => { source: "case x\nwhen 1\nend", position: { character: 0, line: 1 } }, + "while" => { source: "while true\nend" }, + "yield" => { source: "yield" }, } test_cases.each do |keyword, config| + position = config[:position] || { character: 0, line: 0 } + with_server(config[:source]) do |server, uri| server.process_message( id: 1, method: "textDocument/hover", params: { textDocument: { uri: uri }, - position: config[:position], + position: position, }, ) - contents = server.pop_response.response.contents.value + graph = server.global_state.graph + response = server.pop_response.response + refute_nil(response, "expected hover response for keyword `#{keyword}`") + contents = response.contents.value assert_match("```ruby\n#{keyword}\n```", contents) - assert_match( - RubyLsp::KEYWORD_DOCS[keyword] || "No documentation found for #{keyword}", - contents, - ) + assert_match(graph.keyword(keyword).documentation, contents) + end + end + end + + def test_hover_does_not_show_keyword_doc_on_constant_path_of_class + source = "class Foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `Foo` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 7, line: 0 } }, + ) + + contents = server.pop_response.response.contents.value + refute_match("```ruby\nclass\n```", contents) + assert_match("Foo", contents) + end + end + + def test_hover_does_not_show_keyword_doc_on_constant_path_of_module + source = "module Foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `Foo` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 8, line: 0 } }, + ) + + contents = server.pop_response.response.contents.value + refute_match("```ruby\nmodule\n```", contents) + assert_match("Foo", contents) + end + end + + def test_hover_does_not_show_keyword_doc_on_nested_constant_path + source = "class Foo::Bar\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `Foo` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 7, line: 0 } }, + ) + contents = server.pop_response.response.contents.value + refute_match("```ruby\nclass\n```", contents) + + # cursor on `Bar` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 12, line: 0 } }, + ) + contents = server.pop_response.response.contents.value + refute_match("```ruby\nclass\n```", contents) + end + end - expected_uri = URI::Generic.from_path(path: File.join(RubyLsp::STATIC_DOCS_PATH, "#{keyword}.md")) - assert_match("[Read more](#{expected_uri})", contents) + def test_hover_does_not_show_keyword_doc_on_superclass + source = "class Bar\nend\nclass Foo < Bar\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `Bar` (the superclass) + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 13, line: 2 } }, + ) + contents = server.pop_response.response.contents.value + refute_match("```ruby\nclass\n```", contents) + assert_match("Bar", contents) + end + end + + def test_hover_does_not_show_and_keyword_doc_on_double_ampersand_operator + source = "true && false" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `&&` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 5, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_does_not_show_or_keyword_doc_on_double_pipe_operator + source = "true || false" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `||` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 5, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_does_not_show_do_keyword_doc_on_brace_block + source = "proc { 1 }" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `{` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 5, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_does_not_show_keyword_doc_on_ternary_punctuation + source = "x ? 1 : 2" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `?` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 2, line: 0 } }, + ) + assert_nil(server.pop_response.response) + + # cursor on `:` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 6, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_class + source = "class Foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + assert_match("```ruby\nend\n```", contents) + refute_match("```ruby\nclass\n```", contents) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_def + source = "def foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + assert_match("```ruby\nend\n```", contents) + refute_match("```ruby\ndef\n```", contents) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_if + source = "if true\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + assert_match("```ruby\nend\n```", contents) + refute_match("```ruby\nif\n```", contents) + end + end + + def test_hover_on_elsif_shows_elsif_keyword_doc + source = "if a\nelsif b\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `elsif` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + assert_match("```ruby\nelsif\n```", contents) + refute_match("```ruby\nif\n```", contents) + end + end + + def test_hover_shows_class_keyword_doc_for_singleton_class + source = "class << self\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `class` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 0 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nclass\n```", response.contents.value) + end + end + + def test_hover_shows_end_keyword_doc_for_singleton_class + source = "class << self\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_shows_do_keyword_doc_for_lambda + source = "-> do\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `do` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 3, line: 0 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\ndo\n```", response.contents.value) + end + end + + def test_hover_shows_end_keyword_doc_for_lambda + source = "-> do\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_does_not_show_keyword_doc_on_lambda_operator_or_braces + with_server("-> { }", stub_no_typechecker: true) do |server, uri| + # cursor on `->` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 0 } }, + ) + assert_nil(server.pop_response.response) + + # cursor on `{` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 3, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_shows_not_keyword_doc + source = "not true" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `not` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 0 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nnot\n```", response.contents.value) + end + end + + def test_hover_on_forwarding_super_shows_method_doc_and_keyword_doc + source = <<~RUBY + class Parent + # Parent greeting + def greet + end + end + + class Child < Parent + def greet + super + end end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `super` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 8 } }, + ) + + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + + assert_match("greet", contents) + assert_match("```ruby\nsuper\n```", contents) + end + end + + def test_hover_on_super_call_shows_method_doc_and_keyword_doc + source = <<~RUBY + class Parent + def greet(name) + end + end + + class Child < Parent + def greet(name) + super(name) + end + end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `super` of `super(name)` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 7 } }, + ) + + response = server.pop_response.response + refute_nil(response) + + contents = response.contents.value + assert_match("greet", contents) + assert_match("```ruby\nsuper\n```", contents) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_module + source = "module Foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_while + source = "while true\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_begin_ensure + source = "begin\nensure\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 2 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_if_else + source = "if true\nelse\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 2 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) end end From 4a64de7a083b9a8b4efeee8a3adaadb99586f2fa Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 22 Apr 2026 18:06:11 -0400 Subject: [PATCH 20/20] Encapsulate location encoding in response builders --- lib/ruby_lsp/requests/code_lens.rb | 4 +- lib/ruby_lsp/requests/completion.rb | 2 +- lib/ruby_lsp/requests/definition.rb | 2 +- lib/ruby_lsp/requests/discover_tests.rb | 2 +- lib/ruby_lsp/requests/document_highlight.rb | 2 +- lib/ruby_lsp/requests/document_link.rb | 8 +-- lib/ruby_lsp/requests/document_symbol.rb | 6 +-- lib/ruby_lsp/requests/folding_ranges.rb | 8 +-- lib/ruby_lsp/requests/hover.rb | 2 +- lib/ruby_lsp/requests/inlay_hints.rb | 2 +- .../requests/semantic_highlighting.rb | 2 +- lib/ruby_lsp/requests/signature_help.rb | 2 +- .../collection_response_builder.rb | 4 +- .../response_builders/document_symbol.rb | 4 +- lib/ruby_lsp/response_builders/hover.rb | 4 +- .../response_builders/response_builder.rb | 19 +++++++ .../semantic_highlighting.rb | 8 +-- .../response_builders/signature_help.rb | 4 +- .../response_builders/test_collection.rb | 4 +- lib/ruby_lsp/server.rb | 6 +-- .../document_link_expectations_test.rb | 3 +- .../document_symbol_expectations_test.rb | 8 +-- .../folding_ranges_expectations_test.rb | 3 +- .../collection_response_builder_test.rb | 50 +++++++++++++++++++ .../response_builders/test_collection_test.rb | 5 +- 25 files changed, 116 insertions(+), 48 deletions(-) create mode 100644 test/response_builders/collection_response_builder_test.rb diff --git a/lib/ruby_lsp/requests/code_lens.rb b/lib/ruby_lsp/requests/code_lens.rb index 5b3681ddbd..d91a29c044 100644 --- a/lib/ruby_lsp/requests/code_lens.rb +++ b/lib/ruby_lsp/requests/code_lens.rb @@ -21,11 +21,11 @@ def provider #: (GlobalState, RubyDocument | ERBDocument, Prism::Dispatcher) -> void def initialize(global_state, document, dispatcher) @response_builder = ResponseBuilders::CollectionResponseBuilder - .new #: ResponseBuilders::CollectionResponseBuilder[Interface::CodeLens] + .new(document.encoding, document.parse_result) #: ResponseBuilders::CollectionResponseBuilder[Interface::CodeLens] super() @document = document - @test_builder = ResponseBuilders::TestCollection.new #: ResponseBuilders::TestCollection + @test_builder = ResponseBuilders::TestCollection.new(document.encoding, document.parse_result) #: ResponseBuilders::TestCollection uri = document.uri file_path = uri.full_path code_lens_config = global_state.feature_configuration(:codeLens) diff --git a/lib/ruby_lsp/requests/completion.rb b/lib/ruby_lsp/requests/completion.rb index 176c8174a4..38b0376536 100644 --- a/lib/ruby_lsp/requests/completion.rb +++ b/lib/ruby_lsp/requests/completion.rb @@ -61,7 +61,7 @@ def initialize(document, global_state, params, sorbet_level, dispatcher) code_units_cache: document.code_units_cache, ) @response_builder = ResponseBuilders::CollectionResponseBuilder - .new #: ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem] + .new(document.encoding, document.parse_result) #: ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem] Listeners::Completion.new( @response_builder, diff --git a/lib/ruby_lsp/requests/definition.rb b/lib/ruby_lsp/requests/definition.rb index 53ad3c29ba..97d59e3c99 100644 --- a/lib/ruby_lsp/requests/definition.rb +++ b/lib/ruby_lsp/requests/definition.rb @@ -13,7 +13,7 @@ class Definition < Request def initialize(document, global_state, position, dispatcher, sorbet_level) super() @response_builder = ResponseBuilders::CollectionResponseBuilder - .new #: ResponseBuilders::CollectionResponseBuilder[(Interface::Location | Interface::LocationLink)] + .new(document.encoding, document.parse_result) #: ResponseBuilders::CollectionResponseBuilder[(Interface::Location | Interface::LocationLink)] @dispatcher = dispatcher char_position, _ = document.find_index_by_position(position) diff --git a/lib/ruby_lsp/requests/discover_tests.rb b/lib/ruby_lsp/requests/discover_tests.rb index 527e34928d..212aa6e21c 100644 --- a/lib/ruby_lsp/requests/discover_tests.rb +++ b/lib/ruby_lsp/requests/discover_tests.rb @@ -18,7 +18,7 @@ def initialize(global_state, document, dispatcher) @global_state = global_state @document = document @dispatcher = dispatcher - @response_builder = ResponseBuilders::TestCollection.new #: ResponseBuilders::TestCollection + @response_builder = ResponseBuilders::TestCollection.new(document.encoding, document.parse_result) #: ResponseBuilders::TestCollection end # @override diff --git a/lib/ruby_lsp/requests/document_highlight.rb b/lib/ruby_lsp/requests/document_highlight.rb index 78b1b700ab..1358cae35b 100644 --- a/lib/ruby_lsp/requests/document_highlight.rb +++ b/lib/ruby_lsp/requests/document_highlight.rb @@ -26,7 +26,7 @@ def initialize(global_state, document, position, dispatcher) ) @response_builder = ResponseBuilders::CollectionResponseBuilder - .new #: ResponseBuilders::CollectionResponseBuilder[Interface::DocumentHighlight] + .new(document.encoding, document.parse_result) #: ResponseBuilders::CollectionResponseBuilder[Interface::DocumentHighlight] Listeners::DocumentHighlight.new( @response_builder, node_context.node, diff --git a/lib/ruby_lsp/requests/document_link.rb b/lib/ruby_lsp/requests/document_link.rb index 0fc72fc17b..e0ed8ded9d 100644 --- a/lib/ruby_lsp/requests/document_link.rb +++ b/lib/ruby_lsp/requests/document_link.rb @@ -16,12 +16,12 @@ def provider end end - #: (URI::Generic uri, Array[Prism::Comment] comments, Prism::Dispatcher dispatcher) -> void - def initialize(uri, comments, dispatcher) + #: (URI::Generic uri, (RubyDocument | ERBDocument) document, Prism::Dispatcher dispatcher) -> void + def initialize(uri, document, dispatcher) super() @response_builder = ResponseBuilders::CollectionResponseBuilder - .new #: ResponseBuilders::CollectionResponseBuilder[Interface::DocumentLink] - Listeners::DocumentLink.new(@response_builder, uri, comments, dispatcher) + .new(document.encoding, document.parse_result) #: ResponseBuilders::CollectionResponseBuilder[Interface::DocumentLink] + Listeners::DocumentLink.new(@response_builder, uri, document.parse_result.comments, dispatcher) end # @override diff --git a/lib/ruby_lsp/requests/document_symbol.rb b/lib/ruby_lsp/requests/document_symbol.rb index ae87fd1aa3..a5672ea350 100644 --- a/lib/ruby_lsp/requests/document_symbol.rb +++ b/lib/ruby_lsp/requests/document_symbol.rb @@ -20,10 +20,10 @@ def provider end end - #: (URI::Generic uri, Prism::Dispatcher dispatcher) -> void - def initialize(uri, dispatcher) + #: (URI::Generic uri, (RubyDocument | ERBDocument) document, Prism::Dispatcher dispatcher) -> void + def initialize(uri, document, dispatcher) super() - @response_builder = ResponseBuilders::DocumentSymbol.new #: ResponseBuilders::DocumentSymbol + @response_builder = ResponseBuilders::DocumentSymbol.new(document.encoding, document.parse_result) #: ResponseBuilders::DocumentSymbol Listeners::DocumentSymbol.new(@response_builder, uri, dispatcher) Addon.addons.each do |addon| diff --git a/lib/ruby_lsp/requests/folding_ranges.rb b/lib/ruby_lsp/requests/folding_ranges.rb index 6880233255..77ba571a6d 100644 --- a/lib/ruby_lsp/requests/folding_ranges.rb +++ b/lib/ruby_lsp/requests/folding_ranges.rb @@ -15,12 +15,12 @@ def provider end end - #: (Array[Prism::Comment] comments, Prism::Dispatcher dispatcher) -> void - def initialize(comments, dispatcher) + #: ((RubyDocument | ERBDocument) document, Prism::Dispatcher dispatcher) -> void + def initialize(document, dispatcher) super() @response_builder = ResponseBuilders::CollectionResponseBuilder - .new #: ResponseBuilders::CollectionResponseBuilder[Interface::FoldingRange] - @listener = Listeners::FoldingRanges.new(@response_builder, comments, dispatcher) #: Listeners::FoldingRanges + .new(document.encoding, document.parse_result) #: ResponseBuilders::CollectionResponseBuilder[Interface::FoldingRange] + @listener = Listeners::FoldingRanges.new(@response_builder, document.parse_result.comments, dispatcher) #: Listeners::FoldingRanges end # @override diff --git a/lib/ruby_lsp/requests/hover.rb b/lib/ruby_lsp/requests/hover.rb index 5095972d2a..0135feca49 100644 --- a/lib/ruby_lsp/requests/hover.rb +++ b/lib/ruby_lsp/requests/hover.rb @@ -46,7 +46,7 @@ def initialize(document, global_state, position, dispatcher, sorbet_level) @target = target #: Prism::Node? uri = document.uri - @response_builder = ResponseBuilders::Hover.new #: ResponseBuilders::Hover + @response_builder = ResponseBuilders::Hover.new(document.encoding, document.parse_result) #: ResponseBuilders::Hover Listeners::Hover.new(@response_builder, global_state, uri, node_context, dispatcher, sorbet_level, position) Addon.addons.each do |addon| addon.create_hover_listener(@response_builder, node_context, dispatcher) diff --git a/lib/ruby_lsp/requests/inlay_hints.rb b/lib/ruby_lsp/requests/inlay_hints.rb index 94d0a03ebe..83fd6100eb 100644 --- a/lib/ruby_lsp/requests/inlay_hints.rb +++ b/lib/ruby_lsp/requests/inlay_hints.rb @@ -21,7 +21,7 @@ def initialize(global_state, document, dispatcher) super() @response_builder = ResponseBuilders::CollectionResponseBuilder - .new #: ResponseBuilders::CollectionResponseBuilder[Interface::InlayHint] + .new(document.encoding, document.parse_result) #: ResponseBuilders::CollectionResponseBuilder[Interface::InlayHint] Listeners::InlayHints.new(global_state, @response_builder, dispatcher) end diff --git a/lib/ruby_lsp/requests/semantic_highlighting.rb b/lib/ruby_lsp/requests/semantic_highlighting.rb index 31a3e05bd4..d558b71ff3 100644 --- a/lib/ruby_lsp/requests/semantic_highlighting.rb +++ b/lib/ruby_lsp/requests/semantic_highlighting.rb @@ -83,7 +83,7 @@ def initialize(global_state, dispatcher, document, previous_result_id, range: ni @range = range @result_id = SemanticHighlighting.next_result_id.to_s #: String @response_builder = ResponseBuilders::SemanticHighlighting - .new(document.code_units_cache) #: ResponseBuilders::SemanticHighlighting + .new(document.encoding, document.parse_result) #: ResponseBuilders::SemanticHighlighting Listeners::SemanticHighlighting.new(dispatcher, @response_builder) Addon.addons.each do |addon| diff --git a/lib/ruby_lsp/requests/signature_help.rb b/lib/ruby_lsp/requests/signature_help.rb index 1a3c11819c..9dfcdea20d 100644 --- a/lib/ruby_lsp/requests/signature_help.rb +++ b/lib/ruby_lsp/requests/signature_help.rb @@ -37,7 +37,7 @@ def initialize(document, global_state, position, context, dispatcher, sorbet_lev @target = target #: Prism::Node? @dispatcher = dispatcher - @response_builder = ResponseBuilders::SignatureHelp.new #: ResponseBuilders::SignatureHelp + @response_builder = ResponseBuilders::SignatureHelp.new(document.encoding, document.parse_result) #: ResponseBuilders::SignatureHelp Listeners::SignatureHelp.new(@response_builder, global_state, node_context, dispatcher, sorbet_level) end diff --git a/lib/ruby_lsp/response_builders/collection_response_builder.rb b/lib/ruby_lsp/response_builders/collection_response_builder.rb index a746dd8d13..fd759797d5 100644 --- a/lib/ruby_lsp/response_builders/collection_response_builder.rb +++ b/lib/ruby_lsp/response_builders/collection_response_builder.rb @@ -5,8 +5,8 @@ module RubyLsp module ResponseBuilders #: [ResponseType < Object] class CollectionResponseBuilder < ResponseBuilder - #: -> void - def initialize + #: (Encoding, Prism::ParseLexResult) -> void + def initialize(encoding, parse_result) super @items = [] #: Array[ResponseType] end diff --git a/lib/ruby_lsp/response_builders/document_symbol.rb b/lib/ruby_lsp/response_builders/document_symbol.rb index 6fea355284..141661888b 100644 --- a/lib/ruby_lsp/response_builders/document_symbol.rb +++ b/lib/ruby_lsp/response_builders/document_symbol.rb @@ -15,8 +15,8 @@ def initialize end end - #: -> void - def initialize + #: (Encoding, Prism::ParseLexResult) -> void + def initialize(encoding, parse_result) super @stack = [SymbolHierarchyRoot.new] #: Array[(SymbolHierarchyRoot | Interface::DocumentSymbol)] end diff --git a/lib/ruby_lsp/response_builders/hover.rb b/lib/ruby_lsp/response_builders/hover.rb index 84dad1ef1d..c632166dc4 100644 --- a/lib/ruby_lsp/response_builders/hover.rb +++ b/lib/ruby_lsp/response_builders/hover.rb @@ -5,8 +5,8 @@ module RubyLsp module ResponseBuilders #: [ResponseType = String] class Hover < ResponseBuilder - #: -> void - def initialize + #: (Encoding, Prism::ParseLexResult) -> void + def initialize(encoding, parse_result) super @response = { diff --git a/lib/ruby_lsp/response_builders/response_builder.rb b/lib/ruby_lsp/response_builders/response_builder.rb index aff52b8609..b8d50555a8 100644 --- a/lib/ruby_lsp/response_builders/response_builder.rb +++ b/lib/ruby_lsp/response_builders/response_builder.rb @@ -5,6 +5,25 @@ module RubyLsp module ResponseBuilders # @abstract class ResponseBuilder + #: (Encoding, Prism::ParseLexResult) -> void + def initialize(encoding, parse_result) + @encoding = encoding + @code_units_cache = parse_result.code_units_cache(encoding) #: (^(Integer arg0) -> Integer | Prism::CodeUnitsCache) + end + + #: (Prism::Location) -> Interface::Range + def range_from_location(location) + Interface::Range.new( + start: Interface::Position.new(line: location.start_line - 1, character: location.cached_start_code_units_column(@code_units_cache)), + end: Interface::Position.new(line: location.end_line - 1, character: location.cached_end_code_units_column(@code_units_cache)), + ) + end + + #: (Prism::Node) -> Interface::Range + def range_from_node(node) + range_from_location(node.location) + end + # @abstract #: -> top def response diff --git a/lib/ruby_lsp/response_builders/semantic_highlighting.rb b/lib/ruby_lsp/response_builders/semantic_highlighting.rb index f797e28460..51208ecccd 100644 --- a/lib/ruby_lsp/response_builders/semantic_highlighting.rb +++ b/lib/ruby_lsp/response_builders/semantic_highlighting.rb @@ -46,10 +46,10 @@ class UndefinedTokenType < StandardError; end defaultLibrary: 9, }.freeze #: Hash[Symbol, Integer] - #: ((^(Integer arg0) -> Integer | Prism::CodeUnitsCache) code_units_cache) -> void - def initialize(code_units_cache) - super() - @code_units_cache = code_units_cache + #: (Encoding, Prism::ParseLexResult) -> void + def initialize(encoding, parse_result) + super + @code_units_cache = parse_result.code_units_cache(encoding) #: (^(Integer arg0) -> Integer | Prism::CodeUnitsCache) @stack = [] #: Array[SemanticToken] end diff --git a/lib/ruby_lsp/response_builders/signature_help.rb b/lib/ruby_lsp/response_builders/signature_help.rb index 780f286725..bf59d5feae 100644 --- a/lib/ruby_lsp/response_builders/signature_help.rb +++ b/lib/ruby_lsp/response_builders/signature_help.rb @@ -5,8 +5,8 @@ module RubyLsp module ResponseBuilders #: [ResponseType = Interface::SignatureHelp?] class SignatureHelp < ResponseBuilder - #: -> void - def initialize + #: (Encoding, Prism::ParseLexResult) -> void + def initialize(encoding, parse_result) super @signature_help = nil #: ResponseType end diff --git a/lib/ruby_lsp/response_builders/test_collection.rb b/lib/ruby_lsp/response_builders/test_collection.rb index 70f9a3957a..9343399f2c 100644 --- a/lib/ruby_lsp/response_builders/test_collection.rb +++ b/lib/ruby_lsp/response_builders/test_collection.rb @@ -8,8 +8,8 @@ class TestCollection < ResponseBuilder #: Array[Interface::CodeLens] attr_reader :code_lens - #: -> void - def initialize + #: (Encoding, Prism::ParseLexResult) -> void + def initialize(encoding, parse_result) super @items = {} #: Hash[String, ResponseType] @code_lens = [] #: Array[Interface::CodeLens] diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index e2dc3ab1c2..64172b6f8e 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -491,9 +491,9 @@ def run_combined_requests(message) # Run requests for the document dispatcher = Prism::Dispatcher.new - folding_range = Requests::FoldingRanges.new(parse_result.comments, dispatcher) - document_symbol = Requests::DocumentSymbol.new(uri, dispatcher) - document_link = Requests::DocumentLink.new(uri, parse_result.comments, dispatcher) + folding_range = Requests::FoldingRanges.new(document, dispatcher) + document_symbol = Requests::DocumentSymbol.new(uri, document, dispatcher) + document_link = Requests::DocumentLink.new(uri, document, dispatcher) inlay_hint = Requests::InlayHints.new( @global_state, document, diff --git a/test/requests/document_link_expectations_test.rb b/test/requests/document_link_expectations_test.rb index 6893421735..df4823e061 100644 --- a/test/requests/document_link_expectations_test.rb +++ b/test/requests/document_link_expectations_test.rb @@ -25,8 +25,7 @@ def run_expectations(source) document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: uri, global_state: @global_state) dispatcher = Prism::Dispatcher.new - parse_result = document.parse_result - listener = RubyLsp::Requests::DocumentLink.new(uri, parse_result.comments, dispatcher) + listener = RubyLsp::Requests::DocumentLink.new(uri, document, dispatcher) dispatcher.dispatch(document.ast) listener.perform end diff --git a/test/requests/document_symbol_expectations_test.rb b/test/requests/document_symbol_expectations_test.rb index ab5bedd978..3d77986052 100644 --- a/test/requests/document_symbol_expectations_test.rb +++ b/test/requests/document_symbol_expectations_test.rb @@ -20,7 +20,7 @@ def test_instance_variable_with_shorthand_assignment document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: uri, global_state: @global_state) dispatcher = Prism::Dispatcher.new - listener = RubyLsp::Requests::DocumentSymbol.new(uri, dispatcher) + listener = RubyLsp::Requests::DocumentSymbol.new(uri, document, dispatcher) dispatcher.dispatch(document.ast) response = listener.perform @@ -43,7 +43,7 @@ def test_instance_variable_with_destructuring_assignment document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: uri, global_state: @global_state) dispatcher = Prism::Dispatcher.new - listener = RubyLsp::Requests::DocumentSymbol.new(uri, dispatcher) + listener = RubyLsp::Requests::DocumentSymbol.new(uri, document, dispatcher) dispatcher.dispatch(document.ast) response = listener.perform @@ -65,7 +65,7 @@ def test_labels_blank_names document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: uri, global_state: @global_state) dispatcher = Prism::Dispatcher.new - listener = RubyLsp::Requests::DocumentSymbol.new(uri, dispatcher) + listener = RubyLsp::Requests::DocumentSymbol.new(uri, document, dispatcher) dispatcher.dispatch(document.ast) response = listener.perform @@ -114,7 +114,7 @@ def run_expectations(source) document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: uri, global_state: @global_state) dispatcher = Prism::Dispatcher.new - listener = RubyLsp::Requests::DocumentSymbol.new(uri, dispatcher) + listener = RubyLsp::Requests::DocumentSymbol.new(uri, document, dispatcher) dispatcher.dispatch(document.ast) listener.perform end diff --git a/test/requests/folding_ranges_expectations_test.rb b/test/requests/folding_ranges_expectations_test.rb index 6f0cbba6c8..accde7ac1e 100644 --- a/test/requests/folding_ranges_expectations_test.rb +++ b/test/requests/folding_ranges_expectations_test.rb @@ -12,8 +12,7 @@ def run_expectations(source) document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: uri, global_state: @global_state) dispatcher = Prism::Dispatcher.new - parse_result = document.parse_result - listener = RubyLsp::Requests::FoldingRanges.new(parse_result.comments, dispatcher) + listener = RubyLsp::Requests::FoldingRanges.new(document, dispatcher) dispatcher.dispatch(document.ast) listener.perform end diff --git a/test/response_builders/collection_response_builder_test.rb b/test/response_builders/collection_response_builder_test.rb new file mode 100644 index 0000000000..cac08c87fb --- /dev/null +++ b/test/response_builders/collection_response_builder_test.rb @@ -0,0 +1,50 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module RubyLsp + class CollectionResponseBuilderTest < Minitest::Test + def test_range_from_location_respects_negotiated_position_encoding + # Offsets for `foo` on the single line "🌍café"; foo: + # " 🌍 c a f é " ; _ | foo + # bytes 1 4 1 1 1 2 1 1 1 | 13..16 + # utf16 1 2 1 1 1 1 1 1 1 | 10..13 + # utf32 1 1 1 1 1 1 1 1 1 | 9..12 + source = "\"🌍café\"; foo" + parse_result = Prism.parse_lex(source) + location = parse_result.value[0].statements.body.last.location + + # UTF-8: number of bytes + assert_range( + ResponseBuilders::CollectionResponseBuilder.new(Encoding::UTF_8, parse_result).range_from_location(location), + start_character: 13, + end_character: 16, + ) + + # UTF-16: number of UTF-16 code units (length 1 for 1/2 byte characters, length 2 for 3/4 byte characters) + assert_range( + ResponseBuilders::CollectionResponseBuilder.new(Encoding::UTF_16LE, parse_result).range_from_location(location), + start_character: 10, + end_character: 13, + ) + + # UTF-32: number of UTF-32 code points (length 1 for all characters) + assert_range( + ResponseBuilders::CollectionResponseBuilder.new(Encoding::UTF_32LE, parse_result).range_from_location(location), + start_character: 9, + end_character: 12, + ) + end + + private + + #: (Interface::Range, start_character: Integer, end_character: Integer) -> void + def assert_range(range, start_character:, end_character:) + assert_equal(0, range.start.line) + assert_equal(0, range.end.line) + assert_equal(start_character, range.start.character) + assert_equal(end_character, range.end.character) + end + end +end diff --git a/test/response_builders/test_collection_test.rb b/test/response_builders/test_collection_test.rb index 74f60f0625..c58eb46dd7 100644 --- a/test/response_builders/test_collection_test.rb +++ b/test/response_builders/test_collection_test.rb @@ -11,10 +11,11 @@ def setup start: Interface::Position.new(line: 0, character: 0), end: Interface::Position.new(line: 10, character: 3), ) + @parse_result = Prism.parse_lex("") end def test_allows_building_hierarchy_of_tests - builder = ResponseBuilders::TestCollection.new + builder = ResponseBuilders::TestCollection.new(Encoding::UTF_8, @parse_result) test_item = Requests::Support::TestItem.new("my-id", "Test label", @uri, @range, framework: :minitest) nested_item = Requests::Support::TestItem.new("nested-id", "Nested label", @uri, @range, framework: :minitest) @@ -29,7 +30,7 @@ def test_allows_building_hierarchy_of_tests end def test_overrides_if_trying_to_add_item_with_same_id - builder = ResponseBuilders::TestCollection.new + builder = ResponseBuilders::TestCollection.new(Encoding::UTF_8, @parse_result) test_item = Requests::Support::TestItem.new("my-id", "Test label", @uri, @range, framework: :minitest) nested_item = Requests::Support::TestItem.new("nested-id", "Nested label", @uri, @range, framework: :minitest)