From 967ff717b5c2ad64d7d30b81ef32a7c5448a1164 Mon Sep 17 00:00:00 2001 From: ahogappa Date: Sat, 2 May 2026 05:54:58 +0900 Subject: [PATCH 1/7] Ignore rbs_collection.{yaml,lock.yaml} and .gem_rbs_collection These are generated by `rbs collection install` for individual workspaces; they are not part of typeprof's own development artifacts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3b8e16d99..2c5f59140 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ /pkg/ dog_bench.stackprof.dump dog_bench.pf2profile -dog_bench.json \ No newline at end of file +dog_bench.json + +rbs_collection*.yaml +.gem_rbs_collection From 25734528f51abc64bad528f9d1bc659a495805ee Mon Sep 17 00:00:00 2001 From: ahogappa Date: Sat, 2 May 2026 05:55:17 +0900 Subject: [PATCH 2/7] Wire rbs_collection from typeprof.conf to LSP Service Read the workspace's rbs_collection.yaml (or the path from typeprof.conf's `rbs_collection` key) and load its lockfile, passing the result through to Service so subsequent RBS loads can resolve gems from the collection. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/typeprof/lsp/server.rb | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/typeprof/lsp/server.rb b/lib/typeprof/lsp/server.rb index a26bf8023..dcb8d91c9 100644 --- a/lib/typeprof/lsp/server.rb +++ b/lib/typeprof/lsp/server.rb @@ -134,7 +134,11 @@ def add_workspaces(folders) end end @core_options[:exclude_patterns] = conf[:exclude] if conf[:exclude] - service_options = @core_options.merge(position_encoding: @position_encoding) + rbs_collection = setup_rbs_collection_for_workspace(path, conf[:rbs_collection]) + service_options = @core_options.merge( + position_encoding: @position_encoding, + rbs_collection: rbs_collection, + ) conf[:analysis_unit_dirs].each do |dir| dir = File.expand_path(dir, path) core = @cores[dir] = TypeProf::Core::Service.new(service_options) @@ -147,6 +151,32 @@ def add_workspaces(folders) end end + def setup_rbs_collection_for_workspace(workspace_path, conf_value) + if conf_value == false + return nil + elsif conf_value.is_a?(String) + config_path = File.expand_path(conf_value, workspace_path) + return nil unless File.readable?(config_path) + else + config_path = File.expand_path("rbs_collection.yaml", workspace_path) + return nil unless File.readable?(config_path) + end + + lock_path = RBS::Collection::Config.to_lockfile_path(Pathname(config_path)) + unless File.readable?(lock_path) + $stderr.puts "rbs_collection lockfile not found: #{ lock_path }; please run 'rbs collection install'" + return nil + end + + RBS::Collection::Config::Lockfile.from_lockfile( + lockfile_path: lock_path, + data: YAML.load_file(lock_path), + ) + rescue => e + $stderr.puts "Failed to load rbs_collection: #{ e.class }: #{ e.message }" + nil + end + #: (String) -> bool def target_path?(path) return true if @rbs_dir && path.start_with?(@rbs_dir) From a05f4c33d5d3ca89ac4aa5046c2e1423a036515c Mon Sep 17 00:00:00 2001 From: ahogappa Date: Sat, 2 May 2026 05:56:02 +0900 Subject: [PATCH 3/7] Dynamically load RBS for libraries seen in `require` literals When a Ruby file containing `require 'name'` is processed, parse the literal name and call `RBS::EnvironmentLoader#add(library: name)` on a Service-local loader, then feed the resolved RBS files through `update_rbs_file` so the genv learns about the library's types. Library names are deduped via @requested_rbs_libraries so repeat edits don't re-load. Unknown libraries roll back the loader's libs set so subsequent valid requires aren't blocked. The dynamic loader uses `core_root: nil` because the env's core RBS is already loaded at Service initialization. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/typeprof/core/service.rb | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/lib/typeprof/core/service.rb b/lib/typeprof/core/service.rb index f0fa098e8..3c76aa609 100644 --- a/lib/typeprof/core/service.rb +++ b/lib/typeprof/core/service.rb @@ -10,6 +10,18 @@ def initialize(options) @genv.load_core_rbs(load_rbs_declarations(@options[:rbs_collection]).declarations, @options[:position_encoding]) Builtin.new(genv).deploy + + @dynamic_rbs_loader = nil + @requested_rbs_libraries = ::Set.new + @loaded_rbs_paths = ::Set.new + end + + def dynamic_rbs_loader + @dynamic_rbs_loader ||= begin + loader = RBS::EnvironmentLoader.new(core_root: nil) + loader.add_collection(@options[:rbs_collection]) if @options[:rbs_collection] + loader + end end def load_rbs_declarations(rbs_collection) @@ -58,6 +70,7 @@ def update_rb_file(path, code) prev_node = @rb_text_nodes[path] code = File.read(path) unless code + load_libraries_for_requires(code) node = AST.parse_rb(path, code, @options[:position_encoding]) return false unless node @@ -114,6 +127,32 @@ def update_rb_file(path, code) return true end + def load_libraries_for_requires(code) + code.each_line do |line| + if md = line.match(/\A\s*require\s*\(?\s*['"]([^'"]+)['"]/) + load_library_for_require(md[1]) + end + end + end + + def load_library_for_require(require_name) + lib_name = require_name.tr("/", "-") + return unless @requested_rbs_libraries.add?(lib_name) + + loader = dynamic_rbs_loader + prev_libs = loader.libs.dup + begin + loader.add(library: lib_name, version: nil) + loader.each_signature do |_source, path, _buffer, _decls, _dirs| + path_str = path.to_s + next unless @loaded_rbs_paths.add?(path_str) + update_rbs_file(path_str, path.read) + end + rescue RBS::EnvironmentLoader::UnknownLibraryError, RuntimeError, Gem::LoadError, LoadError + loader.libs.replace(prev_libs) + end + end + def update_rbs_file(path, code) prev_decls = @rbs_text_nodes[path] From a753836af0c6bb61ff76995701e6c093164b29d8 Mon Sep 17 00:00:00 2001 From: ahogappa Date: Sat, 2 May 2026 05:56:44 +0900 Subject: [PATCH 4/7] Suggest unrequired constants and auto-insert require on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a lightweight ConstantCatalog that scans stdlib + rbs_collection RBS files (without loading them into the type env) and maps each cpath to a require name. Resolution priority for the require name uses `Gem.find_files` validation: dirname as-is (e.g. `open-uri`) → `-` → `/` (e.g. `net-http` → `net/http`) → fall back to the slash form. `Service#each_const_completion` yields candidates from the catalog first (so the require_name annotation wins over identically-named env entries when rbs_collection eagerly preloads gems), then from the env. The LSP completion handler emits matching candidates with `additionalTextEdits` that insert `require ''` right after any shebang/magic comments. The insert position walks past any existing require lines in that block and cancels the auto-insert when the same require is already present. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/typeprof/core/service.rb | 79 +++++++++++++++++++++++ lib/typeprof/lsp.rb | 1 + lib/typeprof/lsp/constant_catalog.rb | 95 ++++++++++++++++++++++++++++ lib/typeprof/lsp/messages.rb | 56 ++++++++++++++++ lib/typeprof/lsp/server.rb | 6 ++ test/lsp/constant_catalog_test.rb | 57 +++++++++++++++++ test/lsp/lsp_test.rb | 63 ++++++++++++++++++ 7 files changed, 357 insertions(+) create mode 100644 lib/typeprof/lsp/constant_catalog.rb create mode 100644 test/lsp/constant_catalog_test.rb diff --git a/lib/typeprof/core/service.rb b/lib/typeprof/core/service.rb index 3c76aa609..b902076ef 100644 --- a/lib/typeprof/core/service.rb +++ b/lib/typeprof/core/service.rb @@ -11,11 +11,16 @@ def initialize(options) Builtin.new(genv).deploy + @constant_catalog = nil @dynamic_rbs_loader = nil @requested_rbs_libraries = ::Set.new @loaded_rbs_paths = ::Set.new end + def constant_catalog + @constant_catalog ||= TypeProf::LSP::ConstantCatalog.new(rbs_collection: @options[:rbs_collection]) + end + def dynamic_rbs_loader @dynamic_rbs_loader ||= begin loader = RBS::EnvironmentLoader.new(core_root: nil) @@ -425,6 +430,80 @@ def code_lens(path) end end + def each_const_completion(path, pos, &blk) + const_node = find_constant_at(path, pos) || (pos.column > 0 ? find_constant_at(path, pos.left) : nil) + return unless const_node + + prefix = const_node.cname.to_s + seen = ::Set.new + + # Yield from the catalog first so candidates needing `require` win the + # dedup over identically-named env entries. With rbs_collection, gems are + # eagerly added to the env at startup; without this ordering the catalog + # entry's require_name would get masked by the (require-less) env one. + if const_node.cbase + parent_cpath = static_cpath_of(const_node.cbase) + return unless parent_cpath + constant_catalog.each_match(parent_cpath, prefix) do |cname, require_name| + yield cname.to_s, require_name if seen.add?(cname) + end + mod = @genv.resolve_cpath(parent_cpath) + if mod && mod.exist? + mod.consts.each do |cname, cdef| + next unless cdef.exist? + next unless cname.to_s.start_with?(prefix) + yield cname.to_s, nil if seen.add?(cname) + end + end + else + constant_catalog.each_match([], prefix) do |cname, require_name| + yield cname.to_s, require_name if seen.add?(cname) + end + each_visible_const_with_prefix(const_node, prefix) do |cname| + yield cname.to_s, nil if seen.add?(cname) + end + end + end + + def find_constant_at(path, pos) + result = nil + @rb_text_nodes[path]&.retrieve_at(pos) do |node| + next if result + next unless node.is_a?(AST::ConstantReadNode) + cr = node.cname_code_range + result = node if cr.first <= pos && pos <= cr.last + end + result + end + + def static_cpath_of(node) + parts = [] + current = node + while current.is_a?(AST::ConstantReadNode) + parts.unshift(current.cname) + current = current.cbase + end + current.nil? ? parts : nil + end + + def each_visible_const_with_prefix(const_node, prefix) + cref = const_node.lenv.cref + search_ancestors = !const_node.strict_const_scope + while cref + mod = @genv.resolve_cpath(cref.cpath) + @genv.each_superclass(mod, false) do |m, _singleton| + break if m == @genv.mod_object && cref.outer + m.consts.each do |cname, cdef| + next unless cdef.exist? + yield cname if cname.to_s.start_with?(prefix) + end + break unless search_ancestors + end + search_ancestors = false + cref = cref.outer + end + end + def completion(path, trigger, pos) @rb_text_nodes[path]&.retrieve_at(pos) do |node| if node.code_range.last == pos.right diff --git a/lib/typeprof/lsp.rb b/lib/typeprof/lsp.rb index 9977b4e9b..fb4e2803f 100644 --- a/lib/typeprof/lsp.rb +++ b/lib/typeprof/lsp.rb @@ -2,6 +2,7 @@ require "json" require_relative "lsp/text" +require_relative "lsp/constant_catalog" require_relative "lsp/messages" require_relative "lsp/server" require_relative "lsp/util" diff --git a/lib/typeprof/lsp/constant_catalog.rb b/lib/typeprof/lsp/constant_catalog.rb new file mode 100644 index 000000000..48f528278 --- /dev/null +++ b/lib/typeprof/lsp/constant_catalog.rb @@ -0,0 +1,95 @@ +module TypeProf::LSP + # Lightweight catalog of constants discovered in stdlib + rbs_collection RBS, + # without loading them into the type environment. Used by completion to suggest + # constants that need a `require` to be available, paired with the require name + # so the editor can auto-insert the require line via additionalTextEdits. + class ConstantCatalog + def initialize(rbs_collection: nil) + @entries = {} + build_from_stdlib + build_from_collection(rbs_collection) if rbs_collection + end + + def each_match(parent_cpath, prefix) + @entries.each do |cpath, require_name| + next unless cpath.size == parent_cpath.size + 1 + next unless cpath[0...-1] == parent_cpath + next unless cpath.last.to_s.start_with?(prefix) + yield cpath.last, require_name + end + end + + def require_name_for(cpath) + @entries[cpath] + end + + private + + def build_from_stdlib + stdlib_root = Pathname(RBS::Repository::DEFAULT_STDLIB_ROOT) + return unless stdlib_root.directory? + stdlib_root.each_child do |lib_dir| + next unless lib_dir.directory? + register_dir(lib_dir, resolve_require_name(lib_dir.basename.to_s)) + end + end + + def build_from_collection(lockfile) + loader = RBS::EnvironmentLoader.new(core_root: nil) + loader.add_collection(lockfile) + loader.each_signature do |source, path, _buffer, _decls, _dirs| + next unless source.is_a?(RBS::EnvironmentLoader::Library) + register_file(path, resolve_require_name(source.name)) + end + end + + def resolve_require_name(gem_name) + # 1. dirname as-is — e.g. `open-uri` + return gem_name if Gem.find_files(gem_name).any? + # 2. dash → slash — e.g. `net-http` → `net/http` + slash_form = gem_name.tr("-", "/") + return slash_form if Gem.find_files(slash_form).any? + # 3. fallback (gem not installed in the LSP process); slash form is the + # more common require convention so use it as a best guess. + slash_form + end + + def register_dir(dir, require_name) + Pathname.glob(dir + "**/*.rbs").each do |path| + register_file(path, require_name) + end + end + + def register_file(path, require_name) + content = path.read + buf = RBS::Buffer.new(name: path.to_s, content: content) + _, _, decls = RBS::Parser.parse_signature(buf) + walk_decls(decls, [], require_name) + rescue StandardError, RBS::ParsingError + # Skip files that fail to parse; the catalog is best-effort. + end + + def walk_decls(decls, prefix, require_name) + decls.each do |d| + case d + when RBS::AST::Declarations::Class, RBS::AST::Declarations::Module, RBS::AST::Declarations::Interface + cpath = compute_cpath(prefix, d.name) + @entries[cpath] ||= require_name + walk_decls(d.members.grep(RBS::AST::Declarations::Base), cpath, require_name) + when RBS::AST::Declarations::Constant + cpath = compute_cpath(prefix, d.name) + @entries[cpath] ||= require_name + end + end + end + + def compute_cpath(prefix, type_name) + ns_path = type_name.namespace.path + if type_name.namespace.absolute? + ns_path + [type_name.name] + else + prefix + ns_path + [type_name.name] + end + end + end +end diff --git a/lib/typeprof/lsp/messages.rb b/lib/typeprof/lsp/messages.rb index d3656d087..70a1afef9 100644 --- a/lib/typeprof/lsp/messages.rb +++ b/lib/typeprof/lsp/messages.rb @@ -327,6 +327,7 @@ def run end items = [] sort = "aaaa" + original_pos = pos text.modify_for_completion(text, pos) do |string, trigger, pos| @server.update_file(text.path, string) pos = TypeProf::CodePosition.from_lsp(pos) @@ -339,6 +340,27 @@ def run } sort = sort.succ end + + next unless trigger.nil? + + const_sort = "z000" + @server.each_const_completion(text.path, TypeProf::CodePosition.from_lsp(original_pos)) do |label, require_name| + item = { + label: label, + kind: 21, # Constant + sortText: const_sort, + } + if require_name + insert = compute_require_insert(text.string, require_name) + edits = insert && build_require_text_edits(require_name, insert, original_pos) + if edits + item[:detail] = "from '#{ require_name }'" + item[:additionalTextEdits] = edits + end + end + items << item + const_sort = const_sort.succ + end end respond( isIncomplete: false, @@ -346,6 +368,40 @@ def run ) @server.update_file(text.path, text.string) end + + def compute_require_insert(source, require_name) + lines = source.lines + insert_line = 0 + lines.each_with_index do |line, i| + s = line.strip + if (i == 0 && s.start_with?("#!")) || s.match?(/\A\s*#\s*(?:encoding|frozen_string_literal|coding|warn_indent):/) + insert_line = i + 1 + else + break + end + end + while (line = lines[insert_line]) + md = line.match(/\A\s*require(?:_relative)?\s*\(?\s*['"]([^'"]+)['"]/) + break unless md + return nil if md[1] == require_name + insert_line += 1 + end + eol = source.include?("\r\n") ? "\r\n" : "\n" + next_line = lines[insert_line] + suffix = next_line.nil? || next_line.strip.empty? ? "" : eol + { line: insert_line, eol: eol, suffix: suffix } + end + + # nil when the insert position would overlap with the cursor (LSP forbids + # overlap between additionalTextEdits and the primary edit at the cursor). + def build_require_text_edits(require_name, require_insert, completion_pos) + return nil if require_insert[:line] == completion_pos[:line] && completion_pos[:character] == 0 + pos = { line: require_insert[:line], character: 0 } + [{ + range: { start: pos, end: pos }, + newText: "require '#{ require_name }'#{ require_insert[:eol] }#{ require_insert[:suffix] }", + }] + end end # textDocument/signatureHelp request diff --git a/lib/typeprof/lsp/server.rb b/lib/typeprof/lsp/server.rb index dcb8d91c9..fb2d16d8f 100644 --- a/lib/typeprof/lsp/server.rb +++ b/lib/typeprof/lsp/server.rb @@ -247,6 +247,12 @@ def completion(path, trigger, pos, &blk) end end + def each_const_completion(path, pos, &blk) + each_core(path) do |core| + core.each_const_completion(path, pos, &blk) + end + end + def rename(path, pos) aggregate_each_core(path) do |core| core.rename(path, pos) diff --git a/test/lsp/constant_catalog_test.rb b/test/lsp/constant_catalog_test.rb new file mode 100644 index 000000000..10faf04b4 --- /dev/null +++ b/test/lsp/constant_catalog_test.rb @@ -0,0 +1,57 @@ +require_relative "../helper" + +module TypeProf::LSP + class ConstantCatalogTest < Test::Unit::TestCase + def setup + @catalog = ConstantCatalog.new + end + + def test_top_level_match_finds_csv + hits = [] + @catalog.each_match([], "CS") { |name, req| hits << [name, req] } + csv_hit = hits.find { |name, _| name == :CSV } + assert_not_nil(csv_hit, "CSV should be discoverable from stdlib catalog") + assert_equal("csv", csv_hit[1]) + end + + def test_top_level_match_excludes_non_matches + hits = [] + @catalog.each_match([], "CS") { |name, _req| hits << name } + assert(hits.all? { |n| n.to_s.start_with?("CS") }, "all results should match prefix") + end + + def test_scoped_match_finds_net_http + hits = [] + @catalog.each_match([:Net], "HTTP") { |name, req| hits << [name, req] } + http_hit = hits.find { |name, _| name == :HTTP } + assert_not_nil(http_hit, "Net::HTTP should be discoverable") + assert_equal("net/http", http_hit[1]) + end + + def test_require_name_for_top_level + assert_equal("csv", @catalog.require_name_for([:CSV])) + end + + def test_require_name_for_unknown + assert_nil(@catalog.require_name_for([:NonexistentXyz])) + end + + def test_dirname_dash_becomes_slash + assert_equal("bigdecimal/math", @catalog.require_name_for([:BigMath])) + end + + def test_json_uses_top_level_require_not_subpath + # Regression: the rdoc-file header in stdlib/json/0/json.rbs points to + # ext/json/lib/json/common.rb, but the actual require name is just 'json'. + assert_equal("json", @catalog.require_name_for([:JSON])) + end + + def test_open_uri_keeps_dash + # `resolve_require_name` priority: dirname as-is wins when it resolves + # via Gem.find_files. open-uri's lib file is `lib/open-uri.rb`, so the + # dash form should win over `open/uri` (which doesn't resolve). + omit "open-uri not installed in test environment" unless Gem.find_files("open-uri").any? + assert_equal("open-uri", @catalog.require_name_for([:OpenURI])) + end + end +end diff --git a/test/lsp/lsp_test.rb b/test/lsp/lsp_test.rb index ba9e769ed..c6c3d80e6 100644 --- a/test/lsp/lsp_test.rb +++ b/test/lsp/lsp_test.rb @@ -330,6 +330,69 @@ def check(nnn) end end + def test_completion_const_csv_inserts_require + init("basic") + + notify( + "textDocument/didOpen", + textDocument: { uri: @folder + "basic.rb", version: 0, text: <<~END }, + # frozen_string_literal: true + + CS + END + ) + + expect_notification("typeprof.enableToggleButton") {|json| } + expect_request("workspace/codeLens/refresh") {|json| } + + id = request( + "textDocument/completion", + textDocument: { uri: @folder + "basic.rb" }, + position: { line: 2, character: 2 }, + ) + expect_response(id) do |json| + items = json[:items] + csv_item = items.find { |i| i[:label] == "CSV" } + assert_not_nil(csv_item, "CSV candidate expected") + assert_equal(21, csv_item[:kind]) + assert_equal("from 'csv'", csv_item[:detail]) + edits = csv_item[:additionalTextEdits] + assert_not_nil(edits) + assert_equal(1, edits.size) + assert_match(/require 'csv'/, edits[0][:newText]) + assert_equal(1, edits[0][:range][:start][:line]) + assert_equal(0, edits[0][:range][:start][:character]) + end + end + + def test_completion_const_skips_require_when_already_present + init("basic") + + notify( + "textDocument/didOpen", + textDocument: { uri: @folder + "basic.rb", version: 0, text: <<~END }, + require 'csv' + + CS + END + ) + + expect_notification("typeprof.enableToggleButton") {|json| } + expect_request("workspace/codeLens/refresh") {|json| } + + id = request( + "textDocument/completion", + textDocument: { uri: @folder + "basic.rb" }, + position: { line: 2, character: 2 }, + ) + expect_response(id) do |json| + items = json[:items] + csv_item = items.find { |i| i[:label] == "CSV" } + assert_not_nil(csv_item) + assert_nil(csv_item[:additionalTextEdits], "additionalTextEdits should not be set when require is already in the file") + end + end + def test_completion init("basic") From e03d0f747ec082f9a715280db94279ea1ddca51a Mon Sep 17 00:00:00 2001 From: ahogappa Date: Mon, 4 May 2026 05:57:47 +0900 Subject: [PATCH 5/7] Inject ConstantCatalog into Service via options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service used to construct `TypeProf::LSP::ConstantCatalog` itself, introducing a Core → LSP dependency. Build the catalog on the LSP Server side (per workspace) and pass it to Service through `options[:constant_catalog]`; Service treats it as a duck-typed object with `each_match`. `each_const_completion` becomes a no-op for the catalog branch when the option is omitted (e.g. CLI use). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/typeprof/core/service.rb | 10 +++------- lib/typeprof/lsp/server.rb | 2 ++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/typeprof/core/service.rb b/lib/typeprof/core/service.rb index b902076ef..d8b06a078 100644 --- a/lib/typeprof/core/service.rb +++ b/lib/typeprof/core/service.rb @@ -11,16 +11,12 @@ def initialize(options) Builtin.new(genv).deploy - @constant_catalog = nil + @constant_catalog = options[:constant_catalog] @dynamic_rbs_loader = nil @requested_rbs_libraries = ::Set.new @loaded_rbs_paths = ::Set.new end - def constant_catalog - @constant_catalog ||= TypeProf::LSP::ConstantCatalog.new(rbs_collection: @options[:rbs_collection]) - end - def dynamic_rbs_loader @dynamic_rbs_loader ||= begin loader = RBS::EnvironmentLoader.new(core_root: nil) @@ -444,7 +440,7 @@ def each_const_completion(path, pos, &blk) if const_node.cbase parent_cpath = static_cpath_of(const_node.cbase) return unless parent_cpath - constant_catalog.each_match(parent_cpath, prefix) do |cname, require_name| + @constant_catalog&.each_match(parent_cpath, prefix) do |cname, require_name| yield cname.to_s, require_name if seen.add?(cname) end mod = @genv.resolve_cpath(parent_cpath) @@ -456,7 +452,7 @@ def each_const_completion(path, pos, &blk) end end else - constant_catalog.each_match([], prefix) do |cname, require_name| + @constant_catalog&.each_match([], prefix) do |cname, require_name| yield cname.to_s, require_name if seen.add?(cname) end each_visible_const_with_prefix(const_node, prefix) do |cname| diff --git a/lib/typeprof/lsp/server.rb b/lib/typeprof/lsp/server.rb index fb2d16d8f..decda88c4 100644 --- a/lib/typeprof/lsp/server.rb +++ b/lib/typeprof/lsp/server.rb @@ -135,9 +135,11 @@ def add_workspaces(folders) end @core_options[:exclude_patterns] = conf[:exclude] if conf[:exclude] rbs_collection = setup_rbs_collection_for_workspace(path, conf[:rbs_collection]) + constant_catalog = ConstantCatalog.new(rbs_collection: rbs_collection) service_options = @core_options.merge( position_encoding: @position_encoding, rbs_collection: rbs_collection, + constant_catalog: constant_catalog, ) conf[:analysis_unit_dirs].each do |dir| dir = File.expand_path(dir, path) From d4654c00f3c98b8e8350f364c9127eceea8500fb Mon Sep 17 00:00:00 2001 From: ahogappa Date: Mon, 4 May 2026 05:58:26 +0900 Subject: [PATCH 6/7] Unify dynamic RBS loader state in Service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace three ad hoc ivars (@dynamic_rbs_loader, @requested_rbs_libraries, @loaded_rbs_paths) with a single @rbs_loader (eagerly built, core_root: nil + collection). Library-name dedup now leverages @rbs_loader.libs (rbs gem's own Set), and path-level dedup uses the existing @rbs_text_nodes. The new load_library_for_require also computes new_libs = libs - prev_libs and filters each_signature to only files belonging to the just-added libraries — previously the path dedup alone allowed every collection lib's RBS files to be re-fed to update_rbs_file on the first dynamic require. Behavior change: failed `require 'foo'` literals are now retried on the next keystroke (loader.libs is rolled back). Previously @requested_rbs_libraries kept the failed name and never retried within a session. The retry path is harmless and lets typeprof recognize a gem installed mid-session. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/typeprof/core/service.rb | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/lib/typeprof/core/service.rb b/lib/typeprof/core/service.rb index d8b06a078..16672a246 100644 --- a/lib/typeprof/core/service.rb +++ b/lib/typeprof/core/service.rb @@ -12,17 +12,9 @@ def initialize(options) Builtin.new(genv).deploy @constant_catalog = options[:constant_catalog] - @dynamic_rbs_loader = nil - @requested_rbs_libraries = ::Set.new - @loaded_rbs_paths = ::Set.new - end - def dynamic_rbs_loader - @dynamic_rbs_loader ||= begin - loader = RBS::EnvironmentLoader.new(core_root: nil) - loader.add_collection(@options[:rbs_collection]) if @options[:rbs_collection] - loader - end + @rbs_loader = RBS::EnvironmentLoader.new(core_root: nil) + @rbs_loader.add_collection(@options[:rbs_collection]) if @options[:rbs_collection] end def load_rbs_declarations(rbs_collection) @@ -138,19 +130,20 @@ def load_libraries_for_requires(code) def load_library_for_require(require_name) lib_name = require_name.tr("/", "-") - return unless @requested_rbs_libraries.add?(lib_name) + return if @rbs_loader.libs.any? { |l| l.name == lib_name } - loader = dynamic_rbs_loader - prev_libs = loader.libs.dup + prev_libs = @rbs_loader.libs.dup begin - loader.add(library: lib_name, version: nil) - loader.each_signature do |_source, path, _buffer, _decls, _dirs| - path_str = path.to_s - next unless @loaded_rbs_paths.add?(path_str) - update_rbs_file(path_str, path.read) + @rbs_loader.add(library: lib_name, version: nil) + new_libs = @rbs_loader.libs - prev_libs + @rbs_loader.each_signature do |source, path, _buffer, _decls, _dirs| + next unless source.is_a?(RBS::EnvironmentLoader::Library) + next unless new_libs.include?(source) + next if @rbs_text_nodes.key?(path.to_s) + update_rbs_file(path.to_s, path.read) end rescue RBS::EnvironmentLoader::UnknownLibraryError, RuntimeError, Gem::LoadError, LoadError - loader.libs.replace(prev_libs) + @rbs_loader.libs.replace(prev_libs) end end From 1d456f3b3225509fae123d3bf47f06c522b02975 Mon Sep 17 00:00:00 2001 From: ahogappa Date: Mon, 4 May 2026 06:30:41 +0900 Subject: [PATCH 7/7] Lazy-load rbs_collection gems on require detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop eagerly walking the rbs_collection libraries into the type env at Service init: the env now contains only core RBS at startup, and any collection (or stdlib) gem is loaded into the env when the user's Ruby source contains `require 'name'`. This is the same lazy strategy that already applied to stdlib-only setups; collection just gets the same treatment now. `load_rbs_declarations` no longer takes the rbs_collection argument and no longer calls `add_collection`, so $raw_rbs_env caching is now used for every Service (with or without collection). The persistent `@rbs_loader` registers the collection's fullpath via `repository.add` so dynamic `loader.add(library: name)` can still find collection gems when the user requires them — without pre-adding them to `loader.libs`. With collection no longer eager, swap the yield order in `each_const_completion` to env → catalog. Catalog entries can have a wrong require_name when a stdlib gem reopens a core class (`abbrev` reopens `Array`, etc.); env-first dedup ensures `Array` etc. resolve to their core entry without an `additionalTextEdits` suggestion. Measured init time on a workspace with rbs_collection.lock.yaml containing 12 gems: 1st Service.new 923ms → 327ms; 2nd Service.new 807ms → 196ms (cache now applies regardless of collection). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/typeprof/core/service.rb | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/lib/typeprof/core/service.rb b/lib/typeprof/core/service.rb index 16672a246..3024fb21e 100644 --- a/lib/typeprof/core/service.rb +++ b/lib/typeprof/core/service.rb @@ -7,26 +7,20 @@ def initialize(options) @rbs_text_nodes = {} @genv = GlobalEnv.new - @genv.load_core_rbs(load_rbs_declarations(@options[:rbs_collection]).declarations, @options[:position_encoding]) + @genv.load_core_rbs(load_rbs_declarations.declarations, @options[:position_encoding]) Builtin.new(genv).deploy @constant_catalog = options[:constant_catalog] @rbs_loader = RBS::EnvironmentLoader.new(core_root: nil) - @rbs_loader.add_collection(@options[:rbs_collection]) if @options[:rbs_collection] + @rbs_loader.repository.add(@options[:rbs_collection].fullpath) if @options[:rbs_collection] end - def load_rbs_declarations(rbs_collection) - if rbs_collection - loader = RBS::EnvironmentLoader.new - loader.add_collection(rbs_collection) - RBS::Environment.from_loader(loader) - else - return $raw_rbs_env if defined?($raw_rbs_env) - loader = RBS::EnvironmentLoader.new - $raw_rbs_env = RBS::Environment.from_loader(loader) - end + def load_rbs_declarations + return $raw_rbs_env if defined?($raw_rbs_env) + loader = RBS::EnvironmentLoader.new + $raw_rbs_env = RBS::Environment.from_loader(loader) end attr_reader :genv @@ -426,16 +420,9 @@ def each_const_completion(path, pos, &blk) prefix = const_node.cname.to_s seen = ::Set.new - # Yield from the catalog first so candidates needing `require` win the - # dedup over identically-named env entries. With rbs_collection, gems are - # eagerly added to the env at startup; without this ordering the catalog - # entry's require_name would get masked by the (require-less) env one. if const_node.cbase parent_cpath = static_cpath_of(const_node.cbase) return unless parent_cpath - @constant_catalog&.each_match(parent_cpath, prefix) do |cname, require_name| - yield cname.to_s, require_name if seen.add?(cname) - end mod = @genv.resolve_cpath(parent_cpath) if mod && mod.exist? mod.consts.each do |cname, cdef| @@ -444,13 +431,16 @@ def each_const_completion(path, pos, &blk) yield cname.to_s, nil if seen.add?(cname) end end - else - @constant_catalog&.each_match([], prefix) do |cname, require_name| + @constant_catalog&.each_match(parent_cpath, prefix) do |cname, require_name| yield cname.to_s, require_name if seen.add?(cname) end + else each_visible_const_with_prefix(const_node, prefix) do |cname| yield cname.to_s, nil if seen.add?(cname) end + @constant_catalog&.each_match([], prefix) do |cname, require_name| + yield cname.to_s, require_name if seen.add?(cname) + end end end