From 2b84f7552b8b1adedbf55fe05152244639e601ec Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 6 Oct 2026 02:18:58 +0200 Subject: [PATCH 1/6] Add a safe indexed method collection Keep name buckets private behind scalar lookups with optional predicates. Preserve scan behavior for mutable name strings and use validity tokens for explicitly renamed methods. Discard derived cache state when copying or marshaling the collection, with focused coverage for lookup boundaries, mutable names, and direct serialization. --- lib/rdoc/code_object/any_method.rb | 1 + lib/rdoc/code_object/context.rb | 2 + lib/rdoc/code_object/context/method_list.rb | 118 ++++++++++++++++++ lib/rdoc/code_object/method_attr.rb | 27 +++- .../code_object/context/method_list_test.rb | 70 +++++++++++ 5 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 lib/rdoc/code_object/context/method_list.rb create mode 100644 test/rdoc/code_object/context/method_list_test.rb diff --git a/lib/rdoc/code_object/any_method.rb b/lib/rdoc/code_object/any_method.rb index 2eb1d22c41..f2c2ba8a77 100644 --- a/lib/rdoc/code_object/any_method.rb +++ b/lib/rdoc/code_object/any_method.rb @@ -111,6 +111,7 @@ def call_seq def call_seq=(call_seq) return if call_seq.nil? || call_seq.empty? + invalidate_name_indexes unless @name @call_seq = call_seq end diff --git a/lib/rdoc/code_object/context.rb b/lib/rdoc/code_object/context.rb index 4f28d62600..a39b76a616 100644 --- a/lib/rdoc/code_object/context.rb +++ b/lib/rdoc/code_object/context.rb @@ -9,6 +9,8 @@ class Context < CodeObject include Comparable + require_relative 'context/method_list' + ## # Types of methods diff --git a/lib/rdoc/code_object/context/method_list.rb b/lib/rdoc/code_object/context/method_list.rb new file mode 100644 index 0000000000..418ffddb95 --- /dev/null +++ b/lib/rdoc/code_object/context/method_list.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true +module RDoc + class Context + ## + # An Array with a lazy, methods-only name index. Keeping the index on the + # list lets shallow-copied class/module aliases share its invalidation. + + class MethodList < Array # :nodoc: + EMPTY = [].freeze + + # Finds the first method named +name+ accepted by the optional block. + # Keep candidate buckets private so callers cannot mutate the name index. + + #: (String?) ?{ (AnyMethod) -> bool } -> AnyMethod? + def find_named(name, &predicate) + candidates = methods_named(name) + predicate ? candidates.find(&predicate) : candidates.first + end + + #: (String?) -> Array[AnyMethod] + def methods_named(name) + # Block-based mutations can expose a partially changed list to a lookup. + return select { |method| method.name == name } if @mutating + + indexed = @name_index && @name_index_token[0] + if !indexed || @indexed_length < length + # A frozen list can still be searched, including after a method rename. + return select { |method| method.name == name } if frozen? + + index = indexed ? @name_index : {} + token = indexed ? @name_index_token : [true] + mutable_names = indexed && @mutable_names + + # Parsing aliases interleaves lookups with additions. Index just the + # appended tail rather than repeatedly rebuilding the whole list. + (indexed ? @indexed_length : 0).upto(length - 1) do |i| + method = self[i] + method_name = method.name + if method_name.frozen? + (index[method_name] ||= []) << method + else + mutable_names = true + end + method.track_name_index token unless method.frozen? + end + + @name_index_token = token + @name_index = index + @indexed_length = length + @mutable_names = mutable_names + end + + # Names are retained by reference. Mutable strings can change without + # name=, so only use the index when every name is already frozen. + return select { |method| method.name == name } if @mutable_names + + @name_index[name] || EMPTY + end + private :methods_named + + #: () -> void + def invalidate_name_index + @name_index_token[0] = false if @name_index_token + @name_index = nil + end + + #: (MethodList) -> void + def initialize_copy(other) + super + @name_index = nil + @name_index_token = nil + @indexed_length = nil + @mutable_names = nil + @mutating = nil + end + + # Persist only collection contents, never derived index state or tokens. + + #: () -> Array[AnyMethod] + def marshal_dump + to_a + end + + #: (Array[AnyMethod]) -> void + def marshal_load(methods) + replace(methods) + end + + # Intercept public Array mutations as well as RDoc's own additions, + # filtering, store loading and live-preview removals. Appends leave the + # indexed prefix intact; other mutations invalidate on both sides because + # a block can perform lookups before the mutation finishes. + %i[ + << []= append clear collect! compact! concat delete delete_at delete_if + fill filter! flatten! insert keep_if map! pop prepend push reject! + replace reverse! rotate! select! shift shuffle! slice! sort! sort_by! + uniq! unshift + ].each do |mutation| + appending = %i[<< append concat push].include? mutation + + define_method(mutation) do |*args, **kwargs, &block| + # Preserve Array's no-op behavior, e.g. deleting a missing item. + return super(*args, **kwargs, &block) if frozen? + + invalidate_name_index unless appending + mutating = @mutating + @mutating = true + begin + super(*args, **kwargs, &block) + ensure + @mutating = mutating + invalidate_name_index unless appending + end + end + end + end + end +end diff --git a/lib/rdoc/code_object/method_attr.rb b/lib/rdoc/code_object/method_attr.rb index a760a89aae..bd7928617f 100644 --- a/lib/rdoc/code_object/method_attr.rb +++ b/lib/rdoc/code_object/method_attr.rb @@ -9,8 +9,32 @@ class MethodAttr < CodeObject ## # Name of this method/attribute. + # The supplied string is retained by reference and may be mutated in place. - attr_accessor :name + attr_reader :name + + #: (String?) -> String? + def name=(name) + invalidate_name_indexes + @name = name + end + + # Track only validity tokens, not the indexes or lists themselves. A method + # can appear in more than one list after module promotion or a public Array + # mutation, and must invalidate all of their name indexes when renamed. + + #: (Array[bool]) -> void + def track_name_index(token) # :nodoc: + tokens = (@name_index_tokens ||= []) + tokens.reject! { |existing| !existing[0] } + tokens << token unless tokens.any? { |existing| existing.equal? token } + end + + #: () -> void + def invalidate_name_indexes # :nodoc: + @name_index_tokens&.each { |token| token[0] = false } + @name_index_tokens = nil + end ## # public, protected, private @@ -95,6 +119,7 @@ def initialize(name, singleton: false) def initialize_copy(other) # :nodoc: @full_name = nil + @name_index_tokens = nil end def initialize_visibility # :nodoc: diff --git a/test/rdoc/code_object/context/method_list_test.rb b/test/rdoc/code_object/context/method_list_test.rb new file mode 100644 index 0000000000..0450e6528d --- /dev/null +++ b/test/rdoc/code_object/context/method_list_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +require_relative '../../helper' + +class RDocContextMethodListTest < RDoc::TestCase + + def setup + super + + @instance = RDoc::AnyMethod.new('m') + @singleton = RDoc::AnyMethod.new('m', singleton: true) + @other = RDoc::AnyMethod.new('other', singleton: true) + @list = RDoc::Context::MethodList.new([@other, @instance, @singleton]) + end + + def test_find_named + assert_same @instance, @list.find_named('m') + assert_nil @list.find_named('missing') + refute_respond_to @list, :methods_named + end + + def test_find_named_with_predicate + seen = [] + result = @list.find_named('m') do |method| + seen << method + method.singleton + end + + assert_same @singleton, result + assert_equal [@instance, @singleton], seen + assert_nil @list.find_named('m') { false } + assert_nil @list.find_named('missing') { flunk 'predicate called for a missing name' } + end + + def test_find_named_with_mutable_names + assert_same @instance, @list.find_named('m') + name = +'old' + method = RDoc::AnyMethod.new(name) + @list << method + assert_same method, @list.find_named('old') + + name.replace('new') + assert_nil @list.find_named('old') + assert_same method, @list.find_named('new') + + method.name = +'assigned' + assert_same method, @list.find_named('assigned') + method.name.replace('renamed') + assert_nil @list.find_named('assigned') + assert_same method, @list.find_named('renamed') + end + + def test_marshal_discards_name_index + file = @store.add_file('file.rb') + @list.each do |method| + method.record_location file + file.add_method method + end + assert_same @instance, @list.find_named('m') + + loaded = Marshal.load Marshal.dump(@list) + assert_nil loaded.instance_variable_get(:@name_index) + loaded.each { |method| method.name.freeze } + method = loaded.find_named('m') + method.name = 'renamed' + + assert_same loaded.last, loaded.find_named('m') + assert_same method, loaded.find_named('renamed') + end + +end From 30567630bd713b4fdf3a26f31db99afb7178705c Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 6 Oct 2026 02:19:49 +0200 Subject: [PATCH 2/6] Use indexed method lookup in RDoc contexts Store methods in MethodList and route all four name lookup paths through its scalar lookup API while preserving ordering and singleton matching. Cover additions, renames, filtering, shared collections, and mutations, together with class promotion, store loading, merging, and live-preview replacement and removal. --- lib/rdoc/code_object/context.rb | 16 +- test/rdoc/code_object/class_module_test.rb | 22 ++ test/rdoc/rdoc_context_test.rb | 334 +++++++++++++++++++++ test/rdoc/rdoc_server_test.rb | 44 +++ test/rdoc/rdoc_store_test.rb | 19 ++ 5 files changed, 424 insertions(+), 11 deletions(-) diff --git a/lib/rdoc/code_object/context.rb b/lib/rdoc/code_object/context.rb index a39b76a616..0b71e92d43 100644 --- a/lib/rdoc/code_object/context.rb +++ b/lib/rdoc/code_object/context.rb @@ -134,7 +134,7 @@ def initialize # Sets the defaults for methods and so-forth def initialize_methods_etc - @method_list = [] + @method_list = MethodList.new @attributes = [] @aliases = [] @requires = [] @@ -768,7 +768,7 @@ def find_attribute_named(name) # Finds a class method with +name+ in this context def find_class_method_named(name) - @method_list.find { |meth| meth.singleton && meth.name == name } + @method_list.find_named(name) { |meth| meth.singleton } end ## @@ -815,7 +815,7 @@ def find_external_alias_named(name) # Finds an instance method with +name+ in this context def find_instance_method_named(name) - @method_list.find { |meth| !meth.singleton && meth.name == name } + @method_list.find_named(name) { |meth| !meth.singleton } end ## @@ -835,13 +835,7 @@ def find_local_symbol(symbol) # Finds a method named +name+ with singleton value +singleton+. def find_method(name, singleton) - @method_list.find { |m| - if m.singleton - m.name == name && m.singleton == singleton - else - m.name == name && !m.singleton && !singleton - end - } + @method_list.find_named(name) { |m| m.singleton ? m.singleton == singleton : !singleton } end ## @@ -854,7 +848,7 @@ def find_method_named(name) when /\A::/ find_method name[2..-1], true else - @method_list.find { |meth| meth.name == name } + @method_list.find_named(name) end end diff --git a/test/rdoc/code_object/class_module_test.rb b/test/rdoc/code_object/class_module_test.rb index 43c49332c2..04eca866c7 100644 --- a/test/rdoc/code_object/class_module_test.rb +++ b/test/rdoc/code_object/class_module_test.rb @@ -159,6 +159,21 @@ def test_from_module_comment assert_equal({ tl => ['really a class'] }, klass.comment_location) end + def test_from_module_method_lookup + method = @m1.add_method RDoc::AnyMethod.new('indexed') + assert_same method, @m1.find_method_named('indexed') + + klass = RDoc::ClassModule.from_module RDoc::NormalClass, @m1 + assert_same method, klass.find_method_named('indexed') + + method.name = 'renamed' + + assert_nil @m1.find_method_named('indexed') + assert_nil klass.find_method_named('indexed') + assert_same method, @m1.find_method_named('renamed') + assert_same method, klass.find_method_named('renamed') + end + def test_marshal_dump @store.path = Dir.tmpdir tl = @store.add_file 'file.rb' @@ -1045,8 +1060,15 @@ def test_merge_methods meth = cm2.add_method RDoc::AnyMethod.new('m4') meth.record_location tl1 + old_method = cm1.find_method_named('m3') + assert_nil cm1.find_method_named('m2') + cm1.merge cm2 + assert_nil cm1.find_method_named('m1') + assert_same meth, cm1.find_method_named('m4') + assert_not_same old_method, cm1.find_method_named('m3') + expected = [ RDoc::AnyMethod.new('m2'), RDoc::AnyMethod.new('m3'), diff --git a/test/rdoc/rdoc_context_test.rb b/test/rdoc/rdoc_context_test.rb index d193b324cd..6eac3cd7c1 100644 --- a/test/rdoc/rdoc_context_test.rb +++ b/test/rdoc/rdoc_context_test.rb @@ -588,6 +588,340 @@ def test_find_method_named assert_equal true, @c1.find_method_named('m').singleton end + def test_find_method_named_order + instance = @context.add_method RDoc::AnyMethod.new('m') + singleton = @context.add_method RDoc::AnyMethod.new('m', singleton: true) + + assert_same instance, @context.find_method_named('m') + assert_same instance, @context.find_method_named('#m') + assert_same singleton, @context.find_method_named('::m') + + @context.method_list.reverse! + + assert_same singleton, @context.find_method_named('m') + assert_same instance, @context.find_method_named('#m') + assert_same singleton, @context.find_method_named('::m') + end + + def test_find_method_named_excludes_attributes + @context.add_attribute RDoc::Attr.new('attr', 'RW', '') + + %w[attr attr= #attr #attr= ::attr].each do |name| + assert_nil @context.find_method_named(name) + end + end + + def test_find_method_named_does_not_rescan + method = @context.add_method RDoc::AnyMethod.new('m') + assert_same method, @context.find_method_named('m') + + def method.name + raise 'method list was scanned again' + end + + assert_same method, @context.find_method_named('m') + assert_same method, @context.find_method_named('#m') + assert_same method, @context.find_instance_method_named('m') + assert_nil @context.find_method_named('missing') + assert_nil @context.find_method_named('::missing') + assert_nil @context.find_class_method_named('missing') + end + + def test_find_method_named_after_addition + assert_nil @context.find_method_named('m') + method = @context.add_method RDoc::AnyMethod.new('m') + assert_same method, @context.find_method_named('m') + end + + def test_find_method_named_appends_without_rescanning + method = @context.add_method RDoc::AnyMethod.new('m') + assert_same method, @context.find_method_named('m') + + def method.name + raise 'existing methods were rescanned after an append' + end + + added = @context.add_method RDoc::AnyMethod.new('added') + assert_same added, @context.find_method_named('added') + assert_same method, @context.find_method_named('m') + + @context.add_alias RDoc::Alias.new('m', 'aliased', '') + + assert_same method, @context.find_method_named('aliased').is_alias_for + assert_same method, @context.find_method_named('m') + end + + def test_find_method_named_after_alias + method = @context.add_method RDoc::AnyMethod.new('m') + assert_nil @context.find_method_named('alias') + + @context.add_alias RDoc::Alias.new('m', 'alias', '') + + aliased = @context.find_method_named('alias') + assert_same method, aliased.is_alias_for + end + + def test_find_method_named_after_unmatched_alias + @context.add_alias RDoc::Alias.new('m', 'alias', '') + assert_nil @context.find_method_named('alias') + + method = @context.add_method RDoc::AnyMethod.new('m') + + assert_same method, @context.find_method_named('alias').is_alias_for + end + + def test_find_method_named_after_visibility_filter + util_visibilities + assert_same @priv, @vis.find_method_named('priv') + assert_same @prot, @vis.find_method_named('#prot') + + @vis.remove_invisible :public + + assert_nil @vis.find_method_named('priv') + assert_nil @vis.find_method_named('#prot') + assert_same @pub, @vis.find_method_named('pub') + assert_same @priv, @vis.methods_hash['#priv'] + end + + def test_find_method_named_after_rename + method = @context.add_method RDoc::AnyMethod.new('old') + assert_same method, @context.find_method_named('old') + assert_nil @context.find_method_named('new') + + method.name = 'new' + + assert_nil @context.find_method_named('old') + assert_same method, @context.find_method_named('new') + assert_same method, @context.find_method_named('#new') + end + + def test_find_method_named_after_singleton_change + method = @context.add_method RDoc::AnyMethod.new('m') + assert_same method, @context.find_method_named('#m') + + method.singleton = true + + assert_nil @context.find_method_named('#m') + assert_same method, @context.find_method_named('::m') + assert_same method, @context.find_class_method_named('m') + assert_nil @context.find_instance_method_named('m') + end + + def test_find_method_nil_singleton + method = @context.add_method RDoc::AnyMethod.new('m', singleton: nil) + + assert_same method, @context.find_method('m', nil) + assert_same method, @context.find_method('m', false) + assert_same method, @context.find_method_named('#m') + assert_nil @context.find_method('m', true) + end + + def test_find_method_named_shared_list + method = @context.add_method RDoc::AnyMethod.new('m') + copy = @context.dup + assert_same method, @context.find_method_named('m') + assert_same method, copy.find_method_named('m') + + added = copy.add_method RDoc::AnyMethod.new('new') + assert_same added, @context.find_method_named('new') + + method.name = 'renamed' + assert_nil @context.find_method_named('m') + assert_nil copy.find_method_named('m') + assert_same method, copy.find_method_named('renamed') + + copy.method_list.clear + assert_nil @context.find_method_named('renamed') + end + + def test_find_method_named_after_list_mutation + mutations = [ + ->(list, method) { list << method }, + ->(list, method) { list.push method }, + ->(list, method) { list.append method }, + ->(list, method) { list.unshift method }, + ->(list, method) { list.prepend method }, + ->(list, method) { list.concat [method] }, + ->(list, method) { list.insert 0, method }, + ->(list, method) { list[0] = method }, + ->(list, method) { list.replace [method] }, + ->(list, method) { list.fill method }, + ->(list, method) { list.map! { method } }, + ->(list, method) { list.collect! { method } }, + ] + + mutations.each do |mutate| + context = RDoc::Context.new + old = context.add_method RDoc::AnyMethod.new('old') + list = context.method_list + assert_same old, context.find_method_named('old') + assert_nil context.find_method_named('new') + + method = RDoc::AnyMethod.new 'new' + mutate.call list, method + + assert_same method, context.find_method_named('new') + method.name = 'renamed' + assert_nil context.find_method_named('new') + assert_same method, context.find_method_named('renamed') + end + end + + def test_find_method_named_after_list_removal + mutations = [ + ->(list) { list.clear }, + ->(list) { list.delete list.first }, + ->(list) { list.delete_at 0 }, + ->(list) { list.delete_if { true } }, + ->(list) { list.keep_if { false } }, + ->(list) { list.reject! { true } }, + ->(list) { list.select! { false } }, + ->(list) { list.filter! { false } }, + ->(list) { list.shift }, + ->(list) { list.pop }, + ->(list) { list.slice! 0 }, + ] + + mutations.each do |mutate| + context = RDoc::Context.new + method = context.add_method RDoc::AnyMethod.new('m') + list = context.method_list + assert_same method, context.find_method_named('m') + + mutate.call list + + assert_nil context.find_method_named('m') + end + end + + def test_find_method_named_after_mutating_enumerator + method = @context.add_method RDoc::AnyMethod.new('old') + enumerator = @context.method_list.map! + assert_same method, @context.find_method_named('old') + + replacement = RDoc::AnyMethod.new 'new' + enumerator.each { replacement } + + assert_nil @context.find_method_named('old') + assert_same replacement, @context.find_method_named('new') + end + + def test_find_method_named_during_list_mutation + @context.add_method RDoc::AnyMethod.new('first') + @context.add_method RDoc::AnyMethod.new('second') + replacement = RDoc::AnyMethod.new 'new' + + @context.method_list.map! do |method| + expected = @context.method_list.find { |entry| entry.name == 'new' } + assert_same expected, @context.find_method_named('new') + method.name == 'first' ? replacement : method + end + + assert_same replacement, @context.find_method_named('new') + end + + def test_find_method_named_after_interrupted_mutation + @context.add_method RDoc::AnyMethod.new('first') + @context.add_method RDoc::AnyMethod.new('second') + replacement = RDoc::AnyMethod.new 'new' + + assert_raise RuntimeError do + @context.method_list.map! do |method| + @context.find_method_named('first') + raise 'interrupted' if method.name == 'second' + replacement + end + end + + assert_nil @context.find_method_named('first') + assert_same replacement, @context.find_method_named('new') + end + + def test_find_method_named_after_list_reordering + mutations = [ + ->(list) { list.reverse! }, + ->(list) { list.rotate! }, + ->(list) { list.sort! }, + ->(list) { list.sort_by! { |method| method.singleton ? 0 : 1 } }, + ->(list) { list.shuffle!(random: Random.new(1)) }, + ->(list) { list.uniq!(&:name) }, + ] + + mutations.each do |mutate| + context = RDoc::Context.new + method = context.add_method RDoc::AnyMethod.new('m') + context.add_method RDoc::AnyMethod.new('m', singleton: true) + list = context.method_list + assert_same method, context.find_method_named('m') + + mutate.call list + + %w[m #m ::m].each do |name| + expected = list.find { |entry| + entry.name == 'm' && (name == 'm' || entry.singleton == (name == '::m')) + } + assert_same expected, context.find_method_named(name) + end + end + end + + def test_find_method_named_after_list_copy + method = @context.add_method RDoc::AnyMethod.new('m') + assert_same method, @context.find_method_named('m') + + list = @context.method_list.dup + list.clear + + assert_same method, @context.find_method_named('m') + end + + def test_find_method_named_frozen_list + method = @context.add_method RDoc::AnyMethod.new('m') + @context.method_list.freeze + assert_same method, @context.find_method_named('m') + + method.name = 'renamed' + + assert_nil @context.find_method_named('m') + assert_same method, @context.find_method_named('renamed') + end + + def test_find_method_named_frozen_method + method = @context.add_method RDoc::AnyMethod.new('m') + method.freeze + + assert_same method, @context.find_method_named('m') + assert_nil @context.find_method_named('missing') + end + + def test_find_method_named_after_frozen_list_noop + method = @context.add_method RDoc::AnyMethod.new('m') + assert_same method, @context.find_method_named('m') + @context.method_list.freeze + + assert_nil @context.method_list.delete(RDoc::AnyMethod.new('missing')) + assert_same method, @context.find_method_named('m') + end + + def test_find_method_named_from_call_seq + method = RDoc::AnyMethod.new nil + @context.method_list << method + assert_nil @context.find_method_named('m') + + method.call_seq = 'object.m()' + + assert_same method, @context.find_method_named('m') + end + + def test_find_method_named_after_reset + method = @context.add_method RDoc::AnyMethod.new('m') + assert_same method, @context.find_method_named('m') + + @context.initialize_methods_etc + + assert_nil @context.find_method_named('m') + end + def test_find_module_named assert_equal @c2_c3, @c2.find_module_named('C3') assert_equal @c2, @c2.find_module_named('C2') diff --git a/test/rdoc/rdoc_server_test.rb b/test/rdoc/rdoc_server_test.rb index 9f9a715713..03dac2ce91 100644 --- a/test/rdoc/rdoc_server_test.rb +++ b/test/rdoc/rdoc_server_test.rb @@ -120,6 +120,50 @@ def greet: () -> String end end + def test_check_for_changes_invalidates_method_lookup + other_file = File.join @dir, 'other.rb' + File.write other_file, "class Example\n def retained; end\nend\n" + capture_output { @rdoc.parse_files [other_file] } + @rdoc.store.complete @options.visibility + + example = @rdoc.store.find_class_or_module 'Example' + greet = example.find_method_named 'greet' + retained = example.find_method_named 'retained' + assert_not_nil greet + assert_not_nil retained + assert_nil example.find_method_named('farewell') + + with_running_server do |port| + assert_equal '200', get(port, '/Example.html').code + + File.write File.join(@dir, 'example.rb'), <<~RUBY + # Updated class + class Example + def farewell + end + end + RUBY + + wait_for('updated method to appear in the class page') do + get(port, '/Example.html').body.include?('method-i-farewell') + end + + assert_same example, @rdoc.store.find_class_or_module('Example') + assert_nil example.find_method_named('greet') + assert_not_nil example.find_method_named('farewell') + assert_same retained, example.find_method_named('retained') + + File.delete other_file + + wait_for('deleted file contributions to disappear') do + !get(port, '/Example.html').body.include?('method-i-retained') + end + + assert_nil example.find_method_named('retained') + assert_not_nil example.find_method_named('farewell') + end + end + def test_current_watch_files_deduplicates_symlinked_source_tree source_dir = File.join @dir, 'gem' symlink_dir = File.join @dir, 'docs', 'gem' diff --git a/test/rdoc/rdoc_store_test.rb b/test/rdoc/rdoc_store_test.rb index 6caccdc7b5..1ca57d74a9 100644 --- a/test/rdoc/rdoc_store_test.rb +++ b/test/rdoc/rdoc_store_test.rb @@ -392,8 +392,16 @@ def test_load_all s = RDoc::Store.new(RDoc::Options.new, path: @tmpdir) + loaded_class = s.load_class @klass.full_name + placeholder = loaded_class.find_method_named(@meth.name) + assert_not_nil placeholder + s.load_all + loaded_method = loaded_class.find_method_named @meth.name + assert_not_same placeholder, loaded_method + assert_equal @meth_comment.parse, loaded_method.comment.parse + assert_equal [@klass, @nest_klass], s.all_classes.sort assert_equal [@mod], s.all_modules.sort assert_equal [@page, @top_level], s.all_files.sort @@ -1159,8 +1167,14 @@ def test_clear_file_contributions_multi_file_class klass.add_comment 'comment from a', file_a klass.add_comment 'comment from b', file_b + assert_same meth_a, klass.find_method_named('from_a') + assert_same meth_b, klass.find_method_named('from_b') + @s.clear_file_contributions 'a.rb' + assert_nil klass.find_method_named('from_a') + assert_same meth_b, klass.find_method_named('from_b') + # Class is preserved because file_b still contributes assert_includes @s.classes_hash, 'MultiFileClass' @@ -1172,6 +1186,11 @@ def test_clear_file_contributions_multi_file_class # Comment from a.rb is removed, comment from b.rb remains assert_not_include klass.comment_location.keys, file_a assert_includes klass.comment_location.keys, file_b + + replacement = RDoc::AnyMethod.new 'from_a' + replacement.record_location file_a + klass.add_method replacement + assert_same replacement, klass.find_method_named('from_a') end def test_clear_file_contributions_cleans_methods_and_constants From 7a712441197476a33edf160a2929b0689e29fe7e Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 6 Oct 2026 02:36:40 +0200 Subject: [PATCH 3/6] removing generic mutation wrappers, use defined api for writers --- lib/rdoc/code_object/class_module.rb | 5 +- lib/rdoc/code_object/context.rb | 31 ++- lib/rdoc/code_object/context/method_list.rb | 58 +---- lib/rdoc/code_object/method_attr.rb | 4 +- lib/rdoc/store.rb | 4 +- .../code_object/context/method_list_test.rb | 18 -- test/rdoc/rdoc_context_test.rb | 202 +++++------------- 7 files changed, 91 insertions(+), 231 deletions(-) diff --git a/lib/rdoc/code_object/class_module.rb b/lib/rdoc/code_object/class_module.rb index e1c3105850..c18d702cfc 100644 --- a/lib/rdoc/code_object/class_module.rb +++ b/lib/rdoc/code_object/class_module.rb @@ -70,7 +70,7 @@ def self.from_module(class_type, mod) klass.section = mod.section klass.attributes.concat mod.attributes - klass.method_list.concat mod.method_list + klass.replace_methods mod.method_list klass.aliases.concat mod.aliases klass.external_aliases.concat mod.external_aliases klass.constants.concat mod.constants @@ -541,8 +541,7 @@ def merge(class_module) if add add_method meth else - @method_list.delete meth - @methods_hash.delete meth.pretty_name + remove_method meth end end diff --git a/lib/rdoc/code_object/context.rb b/lib/rdoc/code_object/context.rb index 0b71e92d43..45124df22f 100644 --- a/lib/rdoc/code_object/context.rb +++ b/lib/rdoc/code_object/context.rb @@ -63,9 +63,12 @@ class Context < CodeObject attr_reader :extends ## - # Methods defined in this context - - attr_reader :method_list + # A frozen snapshot of the methods defined in this context. + # Use #add_method, #remove_method or #replace_methods to change the list. + #: () -> Array[AnyMethod] + def method_list + @method_list.to_a.freeze + end ## # Name of this class excluding namespace. See also full_name @@ -1043,7 +1046,9 @@ def remove_from_documentation? def remove_invisible(min_visibility) return if [:private, :nodoc].include? min_visibility - remove_invisible_in @method_list, min_visibility + methods = method_list.dup + remove_invisible_in methods, min_visibility + replace_methods methods remove_invisible_in @attributes, min_visibility remove_invisible_in @constants, min_visibility end @@ -1063,6 +1068,24 @@ def remove_invisible_in(array, min_visibility) # :nodoc: end end + ## + # Removes +method+ from the documentation list and method registrations. + #: (AnyMethod) -> void + def remove_method(method) + @method_list.delete method + @method_list.invalidate_name_index + @methods_hash.delete method.pretty_name + end + + ## + # Replaces the documentation method list with +methods+. + # Registrations in #methods_hash are unchanged, including filtered methods. + #: (Array[AnyMethod]) -> void + def replace_methods(methods) + @method_list.replace methods + @method_list.invalidate_name_index + end + ## # Tries to resolve unmatched aliases when a method or attribute has just # been added. diff --git a/lib/rdoc/code_object/context/method_list.rb b/lib/rdoc/code_object/context/method_list.rb index 418ffddb95..696dde2d2f 100644 --- a/lib/rdoc/code_object/context/method_list.rb +++ b/lib/rdoc/code_object/context/method_list.rb @@ -2,8 +2,9 @@ module RDoc class Context ## - # An Array with a lazy, methods-only name index. Keeping the index on the + # Internal method storage with a lazy name index. Keeping the index on the # list lets shallow-copied class/module aliases share its invalidation. + # Context exposes only frozen snapshots and invalidates destructive changes. class MethodList < Array # :nodoc: EMPTY = [].freeze @@ -19,14 +20,8 @@ def find_named(name, &predicate) #: (String?) -> Array[AnyMethod] def methods_named(name) - # Block-based mutations can expose a partially changed list to a lookup. - return select { |method| method.name == name } if @mutating - indexed = @name_index && @name_index_token[0] if !indexed || @indexed_length < length - # A frozen list can still be searched, including after a method rename. - return select { |method| method.name == name } if frozen? - index = indexed ? @name_index : {} token = indexed ? @name_index_token : [true] mutable_names = indexed && @mutable_names @@ -64,55 +59,6 @@ def invalidate_name_index @name_index = nil end - #: (MethodList) -> void - def initialize_copy(other) - super - @name_index = nil - @name_index_token = nil - @indexed_length = nil - @mutable_names = nil - @mutating = nil - end - - # Persist only collection contents, never derived index state or tokens. - - #: () -> Array[AnyMethod] - def marshal_dump - to_a - end - - #: (Array[AnyMethod]) -> void - def marshal_load(methods) - replace(methods) - end - - # Intercept public Array mutations as well as RDoc's own additions, - # filtering, store loading and live-preview removals. Appends leave the - # indexed prefix intact; other mutations invalidate on both sides because - # a block can perform lookups before the mutation finishes. - %i[ - << []= append clear collect! compact! concat delete delete_at delete_if - fill filter! flatten! insert keep_if map! pop prepend push reject! - replace reverse! rotate! select! shift shuffle! slice! sort! sort_by! - uniq! unshift - ].each do |mutation| - appending = %i[<< append concat push].include? mutation - - define_method(mutation) do |*args, **kwargs, &block| - # Preserve Array's no-op behavior, e.g. deleting a missing item. - return super(*args, **kwargs, &block) if frozen? - - invalidate_name_index unless appending - mutating = @mutating - @mutating = true - begin - super(*args, **kwargs, &block) - ensure - @mutating = mutating - invalidate_name_index unless appending - end - end - end end end end diff --git a/lib/rdoc/code_object/method_attr.rb b/lib/rdoc/code_object/method_attr.rb index bd7928617f..53f1e6e356 100644 --- a/lib/rdoc/code_object/method_attr.rb +++ b/lib/rdoc/code_object/method_attr.rb @@ -20,8 +20,8 @@ def name=(name) end # Track only validity tokens, not the indexes or lists themselves. A method - # can appear in more than one list after module promotion or a public Array - # mutation, and must invalidate all of their name indexes when renamed. + # can appear in more than one list after module promotion or bulk replacement, + # and must invalidate all of their name indexes when renamed. #: (Array[bool]) -> void def track_name_index(token) # :nodoc: diff --git a/lib/rdoc/store.rb b/lib/rdoc/store.rb index 3b934dbab0..2dcd84cfcd 100644 --- a/lib/rdoc/store.rb +++ b/lib/rdoc/store.rb @@ -219,7 +219,7 @@ def clear_file_contributions(relative_name, keep_position: false) top_level.classes_or_modules.each do |cm| # Remove methods and attributes contributed by this file - cm.method_list.reject! { |m| m.file == top_level } + cm.replace_methods cm.method_list.reject { |m| m.file == top_level } cm.attributes.reject! { |a| a.file == top_level } # Rebuild methods_hash from remaining methods and attributes @@ -706,7 +706,7 @@ def load_all load_method module_name, method.full_name end - mod.method_list.replace loaded_methods + mod.replace_methods loaded_methods loaded_attributes = mod.attributes.map do |attribute| load_method module_name, attribute.full_name diff --git a/test/rdoc/code_object/context/method_list_test.rb b/test/rdoc/code_object/context/method_list_test.rb index 0450e6528d..15b7b4ef53 100644 --- a/test/rdoc/code_object/context/method_list_test.rb +++ b/test/rdoc/code_object/context/method_list_test.rb @@ -49,22 +49,4 @@ def test_find_named_with_mutable_names assert_same method, @list.find_named('renamed') end - def test_marshal_discards_name_index - file = @store.add_file('file.rb') - @list.each do |method| - method.record_location file - file.add_method method - end - assert_same @instance, @list.find_named('m') - - loaded = Marshal.load Marshal.dump(@list) - assert_nil loaded.instance_variable_get(:@name_index) - loaded.each { |method| method.name.freeze } - method = loaded.find_named('m') - method.name = 'renamed' - - assert_same loaded.last, loaded.find_named('m') - assert_same method, loaded.find_named('renamed') - end - end diff --git a/test/rdoc/rdoc_context_test.rb b/test/rdoc/rdoc_context_test.rb index 6eac3cd7c1..e25cde3f4f 100644 --- a/test/rdoc/rdoc_context_test.rb +++ b/test/rdoc/rdoc_context_test.rb @@ -29,6 +29,56 @@ def test_initialize assert_empty @context.constants end + def test_method_list_read_only_snapshot + method = @context.add_method RDoc::AnyMethod.new('m') + snapshot = @context.method_list + + assert_instance_of Array, snapshot + assert_predicate snapshot, :frozen? + assert_raise(FrozenError) { snapshot.clear } + + added = @context.add_method RDoc::AnyMethod.new('added') + + assert_equal [method], snapshot + assert_equal [method, added], @context.method_list + assert_same added, @context.find_method_named('added') + end + + def test_replace_methods + old = @context.add_method RDoc::AnyMethod.new('old') + assert_same old, @context.find_method_named('old') + assert_nil @context.find_method_named('new') + method = RDoc::AnyMethod.new 'new' + methods = [method] + + @context.replace_methods methods + methods.clear + + assert_equal [method], @context.method_list + assert_nil @context.find_method_named('old') + assert_same method, @context.find_method_named('new') + assert_same old, @context.methods_hash['#old'] + + method.name = 'renamed' + assert_nil @context.find_method_named('new') + assert_same method, @context.find_method_named('renamed') + end + + def test_remove_method + method = @context.add_method RDoc::AnyMethod.new('m') + retained = @context.add_method RDoc::AnyMethod.new('retained') + assert_same method, @context.find_method_named('m') + + @context.remove_method method + + assert_nil @context.find_method_named('m') + assert_nil @context.methods_hash['#m'] + assert_same retained, @context.find_method_named('retained') + + replacement = @context.add_method RDoc::AnyMethod.new('m') + assert_same replacement, @context.find_method_named('m') + end + def test_add_alias as = RDoc::Alias.new 'old_name', 'new_name', 'comment' @@ -596,7 +646,7 @@ def test_find_method_named_order assert_same instance, @context.find_method_named('#m') assert_same singleton, @context.find_method_named('::m') - @context.method_list.reverse! + @context.replace_methods @context.method_list.reverse assert_same singleton, @context.find_method_named('m') assert_same instance, @context.find_method_named('#m') @@ -730,141 +780,10 @@ def test_find_method_named_shared_list assert_nil copy.find_method_named('m') assert_same method, copy.find_method_named('renamed') - copy.method_list.clear + copy.replace_methods [] assert_nil @context.find_method_named('renamed') end - def test_find_method_named_after_list_mutation - mutations = [ - ->(list, method) { list << method }, - ->(list, method) { list.push method }, - ->(list, method) { list.append method }, - ->(list, method) { list.unshift method }, - ->(list, method) { list.prepend method }, - ->(list, method) { list.concat [method] }, - ->(list, method) { list.insert 0, method }, - ->(list, method) { list[0] = method }, - ->(list, method) { list.replace [method] }, - ->(list, method) { list.fill method }, - ->(list, method) { list.map! { method } }, - ->(list, method) { list.collect! { method } }, - ] - - mutations.each do |mutate| - context = RDoc::Context.new - old = context.add_method RDoc::AnyMethod.new('old') - list = context.method_list - assert_same old, context.find_method_named('old') - assert_nil context.find_method_named('new') - - method = RDoc::AnyMethod.new 'new' - mutate.call list, method - - assert_same method, context.find_method_named('new') - method.name = 'renamed' - assert_nil context.find_method_named('new') - assert_same method, context.find_method_named('renamed') - end - end - - def test_find_method_named_after_list_removal - mutations = [ - ->(list) { list.clear }, - ->(list) { list.delete list.first }, - ->(list) { list.delete_at 0 }, - ->(list) { list.delete_if { true } }, - ->(list) { list.keep_if { false } }, - ->(list) { list.reject! { true } }, - ->(list) { list.select! { false } }, - ->(list) { list.filter! { false } }, - ->(list) { list.shift }, - ->(list) { list.pop }, - ->(list) { list.slice! 0 }, - ] - - mutations.each do |mutate| - context = RDoc::Context.new - method = context.add_method RDoc::AnyMethod.new('m') - list = context.method_list - assert_same method, context.find_method_named('m') - - mutate.call list - - assert_nil context.find_method_named('m') - end - end - - def test_find_method_named_after_mutating_enumerator - method = @context.add_method RDoc::AnyMethod.new('old') - enumerator = @context.method_list.map! - assert_same method, @context.find_method_named('old') - - replacement = RDoc::AnyMethod.new 'new' - enumerator.each { replacement } - - assert_nil @context.find_method_named('old') - assert_same replacement, @context.find_method_named('new') - end - - def test_find_method_named_during_list_mutation - @context.add_method RDoc::AnyMethod.new('first') - @context.add_method RDoc::AnyMethod.new('second') - replacement = RDoc::AnyMethod.new 'new' - - @context.method_list.map! do |method| - expected = @context.method_list.find { |entry| entry.name == 'new' } - assert_same expected, @context.find_method_named('new') - method.name == 'first' ? replacement : method - end - - assert_same replacement, @context.find_method_named('new') - end - - def test_find_method_named_after_interrupted_mutation - @context.add_method RDoc::AnyMethod.new('first') - @context.add_method RDoc::AnyMethod.new('second') - replacement = RDoc::AnyMethod.new 'new' - - assert_raise RuntimeError do - @context.method_list.map! do |method| - @context.find_method_named('first') - raise 'interrupted' if method.name == 'second' - replacement - end - end - - assert_nil @context.find_method_named('first') - assert_same replacement, @context.find_method_named('new') - end - - def test_find_method_named_after_list_reordering - mutations = [ - ->(list) { list.reverse! }, - ->(list) { list.rotate! }, - ->(list) { list.sort! }, - ->(list) { list.sort_by! { |method| method.singleton ? 0 : 1 } }, - ->(list) { list.shuffle!(random: Random.new(1)) }, - ->(list) { list.uniq!(&:name) }, - ] - - mutations.each do |mutate| - context = RDoc::Context.new - method = context.add_method RDoc::AnyMethod.new('m') - context.add_method RDoc::AnyMethod.new('m', singleton: true) - list = context.method_list - assert_same method, context.find_method_named('m') - - mutate.call list - - %w[m #m ::m].each do |name| - expected = list.find { |entry| - entry.name == 'm' && (name == 'm' || entry.singleton == (name == '::m')) - } - assert_same expected, context.find_method_named(name) - end - end - end - def test_find_method_named_after_list_copy method = @context.add_method RDoc::AnyMethod.new('m') assert_same method, @context.find_method_named('m') @@ -875,12 +794,12 @@ def test_find_method_named_after_list_copy assert_same method, @context.find_method_named('m') end - def test_find_method_named_frozen_list + def test_method_list_does_not_freeze_methods method = @context.add_method RDoc::AnyMethod.new('m') - @context.method_list.freeze + snapshot = @context.method_list assert_same method, @context.find_method_named('m') - method.name = 'renamed' + snapshot.first.name = 'renamed' assert_nil @context.find_method_named('m') assert_same method, @context.find_method_named('renamed') @@ -894,18 +813,9 @@ def test_find_method_named_frozen_method assert_nil @context.find_method_named('missing') end - def test_find_method_named_after_frozen_list_noop - method = @context.add_method RDoc::AnyMethod.new('m') - assert_same method, @context.find_method_named('m') - @context.method_list.freeze - - assert_nil @context.method_list.delete(RDoc::AnyMethod.new('missing')) - assert_same method, @context.find_method_named('m') - end - def test_find_method_named_from_call_seq method = RDoc::AnyMethod.new nil - @context.method_list << method + @context.replace_methods [method] assert_nil @context.find_method_named('m') method.call_seq = 'object.m()' From 469d464983774caf4ad1ead0bd4c68cfbd24b1cd Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 6 Oct 2026 02:50:49 +0200 Subject: [PATCH 4/6] Consolidate method lookup tests --- test/rdoc/rdoc_context_test.rb | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/test/rdoc/rdoc_context_test.rb b/test/rdoc/rdoc_context_test.rb index e25cde3f4f..42311aa239 100644 --- a/test/rdoc/rdoc_context_test.rb +++ b/test/rdoc/rdoc_context_test.rb @@ -695,22 +695,13 @@ def method.name assert_same added, @context.find_method_named('added') assert_same method, @context.find_method_named('m') + assert_nil @context.find_method_named('aliased') @context.add_alias RDoc::Alias.new('m', 'aliased', '') assert_same method, @context.find_method_named('aliased').is_alias_for assert_same method, @context.find_method_named('m') end - def test_find_method_named_after_alias - method = @context.add_method RDoc::AnyMethod.new('m') - assert_nil @context.find_method_named('alias') - - @context.add_alias RDoc::Alias.new('m', 'alias', '') - - aliased = @context.find_method_named('alias') - assert_same method, aliased.is_alias_for - end - def test_find_method_named_after_unmatched_alias @context.add_alias RDoc::Alias.new('m', 'alias', '') assert_nil @context.find_method_named('alias') @@ -738,7 +729,7 @@ def test_find_method_named_after_rename assert_same method, @context.find_method_named('old') assert_nil @context.find_method_named('new') - method.name = 'new' + @context.method_list.first.name = 'new' assert_nil @context.find_method_named('old') assert_same method, @context.find_method_named('new') @@ -794,17 +785,6 @@ def test_find_method_named_after_list_copy assert_same method, @context.find_method_named('m') end - def test_method_list_does_not_freeze_methods - method = @context.add_method RDoc::AnyMethod.new('m') - snapshot = @context.method_list - assert_same method, @context.find_method_named('m') - - snapshot.first.name = 'renamed' - - assert_nil @context.find_method_named('m') - assert_same method, @context.find_method_named('renamed') - end - def test_find_method_named_frozen_method method = @context.add_method RDoc::AnyMethod.new('m') method.freeze From 777553374a2832b0d10f1ddf028ebe5c9d1d8866 Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 6 Oct 2026 14:23:18 +0200 Subject: [PATCH 5/6] Ruby, C, and RBS parsers produce frozen, deduplicated method and attribute names --- lib/rdoc/code_object/context/method_list.rb | 30 ++--- lib/rdoc/parser/c.rb | 6 +- lib/rdoc/parser/rbs.rb | 8 +- lib/rdoc/parser/ruby.rb | 13 ++- .../code_object/context/method_list_test.rb | 27 ++++- test/rdoc/parser/c_test.rb | 23 ++++ test/rdoc/parser/rbs_test.rb | 21 ++++ test/rdoc/parser/ruby_test.rb | 105 ++++++++++++++++++ 8 files changed, 206 insertions(+), 27 deletions(-) diff --git a/lib/rdoc/code_object/context/method_list.rb b/lib/rdoc/code_object/context/method_list.rb index 696dde2d2f..25b7d888aa 100644 --- a/lib/rdoc/code_object/context/method_list.rb +++ b/lib/rdoc/code_object/context/method_list.rb @@ -7,19 +7,27 @@ class Context # Context exposes only frozen snapshots and invalidates destructive changes. class MethodList < Array # :nodoc: - EMPTY = [].freeze - # Finds the first method named +name+ accepted by the optional block. # Keep candidate buckets private so callers cannot mutate the name index. #: (String?) ?{ (AnyMethod) -> bool } -> AnyMethod? - def find_named(name, &predicate) - candidates = methods_named(name) - predicate ? candidates.find(&predicate) : candidates.first + def find_named(name) + index_names + + # Names are retained by reference. Mutable strings can change without + # name=, so only use the index when every name is already frozen. + # Stop at the first match instead of allocating all matching candidates. + if @mutable_names + return find { |method| method.name == name && (!block_given? || yield(method)) } + end + + candidates = @name_index[name] + return unless candidates + block_given? ? candidates.find { |method| yield method } : candidates.first end - #: (String?) -> Array[AnyMethod] - def methods_named(name) + #: () -> void + def index_names indexed = @name_index && @name_index_token[0] if !indexed || @indexed_length < length index = indexed ? @name_index : {} @@ -44,14 +52,8 @@ def methods_named(name) @indexed_length = length @mutable_names = mutable_names end - - # Names are retained by reference. Mutable strings can change without - # name=, so only use the index when every name is already frozen. - return select { |method| method.name == name } if @mutable_names - - @name_index[name] || EMPTY end - private :methods_named + private :index_names #: () -> void def invalidate_name_index diff --git a/lib/rdoc/parser/c.rb b/lib/rdoc/parser/c.rb index c8bc98e6ae..91ae1ceee0 100644 --- a/lib/rdoc/parser/c.rb +++ b/lib/rdoc/parser/c.rb @@ -250,7 +250,7 @@ def do_aliases # method that reference the same function. def add_alias(var_name, class_obj, old_name, new_name, comment, singleton:) - al = Alias.new old_name, new_name, comment, singleton: singleton + al = Alias.new(-old_name, -new_name, comment, singleton: singleton) al.record_location @top_level class_obj.add_alias al @stats.add_alias al @@ -850,7 +850,7 @@ def handle_attr(var_name, attr_name, read, write) name = attr_name.gsub(/rb_intern(?:_const)?\("([^"]+)"\)/, '\1') - attr = Attr.new name, rw, comment + attr = Attr.new(-name, rw, comment) attr.record_location @top_level class_obj.add_attribute attr @@ -1008,7 +1008,7 @@ def handle_method(type, var_name, meth_name, function, param_count, type = 'method' # force public end - meth_obj = AnyMethod.new meth_name, singleton: singleton + meth_obj = AnyMethod.new(-meth_name, singleton: singleton) meth_obj.c_function = function p_count = Integer(param_count) rescue -1 diff --git a/lib/rdoc/parser/rbs.rb b/lib/rdoc/parser/rbs.rb index 7ceda649d0..9e84194cdb 100644 --- a/lib/rdoc/parser/rbs.rb +++ b/lib/rdoc/parser/rbs.rb @@ -106,7 +106,7 @@ def merge_attribute_methods(context, name, rw, singleton, comment, type_signatur end def rdoc_method_name(decl) - rbs_constructor_decl?(decl) ? 'new' : decl.name.to_s + rbs_constructor_decl?(decl) ? 'new' : decl.name.name end def rdoc_method_singleton?(decl) @@ -135,7 +135,7 @@ def parse_attr_decl(decl, context) comment = rdoc_comment_for decl type_signature_lines = [decl.type.to_s] - name = decl.name.to_s + name = decl.name.name singleton = decl.kind == :singleton if attribute = context.find_attribute(name, singleton) merge_documentation attribute, comment, type_signature_lines if @@ -231,8 +231,8 @@ def parse_member_decl(decl, context) def parse_method_alias_decl(decl, context) alias_def = Alias.new( - decl.old_name.to_s, - decl.new_name.to_s, + decl.old_name.name, + decl.new_name.name, rdoc_comment_for(decl), singleton: decl.kind == :singleton ) diff --git a/lib/rdoc/parser/ruby.rb b/lib/rdoc/parser/ruby.rb index ab0544e0b4..3e09d9f828 100644 --- a/lib/rdoc/parser/ruby.rb +++ b/lib/rdoc/parser/ruby.rb @@ -361,7 +361,7 @@ def parse_comment_tomdoc(container, comment, line_no, start_line) name, = signature.split %r%[ \(]%, 2 - meth = AnyMethod.new name + meth = AnyMethod.new(-name) record_location(meth) meth.line = start_line meth.call_seq = signature @@ -437,7 +437,7 @@ def handle_meta_method_comment(comment, directives, node) if attributes attributes.each do |attr| - a = Attr.new(attr, rw, comment, singleton: @singleton) + a = Attr.new(-attr, rw, comment, singleton: @singleton) a.store = @store a.line = line_no a.visibility = visibility @@ -624,7 +624,7 @@ def add_alias_method(old_name, new_name, line_no) handle_code_object_directives(@container, directives) if directives return if document_suppressed? - a = Alias.new(old_name, new_name, comment, singleton: @singleton) + a = Alias.new(-old_name, -new_name, comment, singleton: @singleton) handle_modifier_directive(a, line_no) a.store = @store a.line = line_no @@ -645,7 +645,7 @@ def add_attributes(names, rw, line_no) return unless @container.document_children names.each do |symbol| - a = Attr.new(symbol.to_s, rw, comment, singleton: @singleton) + a = Attr.new(-symbol.to_s, rw, comment, singleton: @singleton) a.store = @store a.line = line_no a.type_signature_lines = type_signature_lines @@ -753,6 +753,9 @@ def add_method(method_name, receiver_name:, receiver_fallback_type:, visibility: end end + # Keep parser-owned names immutable and deduplicated without freezing + # strings supplied to the code-object API. + meth.name = -meth.name record_location(meth) container.add_method(meth) token_stream_loader = @colorizer_context.token_stream_loader(node_id) if node_id @@ -1164,7 +1167,7 @@ def visit_def_node(node) # `def (unknown expression).method_name` is not documentable return end - name = node.name.to_s + name = node.name.name params, block_params, calls_super = MethodSignatureVisitor.scan_signature(node) @scanner.add_method( name, diff --git a/test/rdoc/code_object/context/method_list_test.rb b/test/rdoc/code_object/context/method_list_test.rb index 15b7b4ef53..4422746ca0 100644 --- a/test/rdoc/code_object/context/method_list_test.rb +++ b/test/rdoc/code_object/context/method_list_test.rb @@ -15,7 +15,7 @@ def setup def test_find_named assert_same @instance, @list.find_named('m') assert_nil @list.find_named('missing') - refute_respond_to @list, :methods_named + refute_respond_to @list, :index_names end def test_find_named_with_predicate @@ -36,6 +36,8 @@ def test_find_named_with_mutable_names name = +'old' method = RDoc::AnyMethod.new(name) @list << method + assert_same name, method.name + refute_predicate name, :frozen? assert_same method, @list.find_named('old') name.replace('new') @@ -43,10 +45,33 @@ def test_find_named_with_mutable_names assert_same method, @list.find_named('new') method.name = +'assigned' + refute_predicate method.name, :frozen? assert_same method, @list.find_named('assigned') method.name.replace('renamed') assert_nil @list.find_named('assigned') assert_same method, @list.find_named('renamed') end + def test_find_named_with_mutable_names_stops_at_first_match + @instance.name = +'m' + assert_same @instance, @list.find_named('m') + + def @singleton.name + raise 'method list was scanned past the first match' + end + + assert_same @instance, @list.find_named('m') + assert_same @instance, @list.find_named('m') { true } + end + + def test_find_named_with_mutable_names_and_predicate + @instance.name = +'m' + seen = [] + + assert_same @singleton, @list.find_named('m') { |method| seen << method; method.singleton } + assert_equal [@instance, @singleton], seen + assert_nil @list.find_named('m') { false } + assert_nil @list.find_named('missing') { flunk 'predicate called for a missing name' } + end + end diff --git a/test/rdoc/parser/c_test.rb b/test/rdoc/parser/c_test.rb index c2b92ae053..fdcccbfacd 100644 --- a/test/rdoc/parser/c_test.rb +++ b/test/rdoc/parser/c_test.rb @@ -273,6 +273,29 @@ def test_do_instance_duplicate_define_method assert_equal @top_level, methods.last.file end + def test_scan_freezes_method_and_attribute_names + content = <<~C + VALUE greet(VALUE self) { + } + + void Init_Sample(void) { + cSample = rb_define_class("Sample", rb_cObject); + rb_define_method(cSample, "greet", greet, 0); + rb_define_method(cSample, "salutation", greet, 0); + rb_define_singleton_method(cSample, "greet", greet, 0); + rb_define_alias(cSample, "hello", "greet"); + rb_define_attr(cSample, "name", 1, 0); + } + C + + klass = util_get_class content, 'cSample' + assert_equal %w[greet salutation greet hello], klass.method_list.map(&:name) + (klass.method_list + klass.attributes).each do |method| + assert_predicate method.name, :frozen?, method.full_name + end + assert_same klass.find_method('greet', false).name, klass.find_method('greet', true).name + end + def test_do_singleton_define_alias content = <<~C /* diff --git a/test/rdoc/parser/rbs_test.rb b/test/rdoc/parser/rbs_test.rb index 0365462cc1..0d27313c99 100644 --- a/test/rdoc/parser/rbs_test.rb +++ b/test/rdoc/parser/rbs_test.rb @@ -79,6 +79,27 @@ class Foo::Baz assert_same baz, foo.classes_hash['Baz'] end + def test_scan_freezes_method_and_attribute_names + util_parser(<<~RBS).scan + class Sample + def initialize: () -> void + def greet: () -> String + def self.greet: () -> String + alias salutation greet + alias later_alias later + def later: () -> void + attr_reader name: String + end + RBS + + sample = @store.find_class_named 'Sample' + assert_equal %w[new greet greet salutation later later_alias], sample.method_list.map(&:name) + (sample.method_list + sample.attributes).each do |method| + assert_predicate method.name, :frozen?, method.full_name + end + assert_same sample.find_method('greet', false).name, sample.find_method('greet', true).name + end + def test_scan_qualifies_nested_mixins util_parser(<<~RBS).scan module Sample diff --git a/test/rdoc/parser/ruby_test.rb b/test/rdoc/parser/ruby_test.rb index 4ac663abb4..7479394d8b 100644 --- a/test/rdoc/parser/ruby_test.rb +++ b/test/rdoc/parser/ruby_test.rb @@ -967,6 +967,110 @@ def two assert_equal 'my method two', two.comment.text.strip end + def test_method_names_are_frozen + util_parser <<~RUBY + module Foo + def one; end + def self.two; end + class << self + def three; end + end + alias four one + alias_method :five, :one + alias six later + def later; end + module_function :one + attr_accessor :attribute + end + RUBY + + mod = @store.find_module_named 'Foo' + assert_equal %w[one two three four five later six one], mod.method_list.map(&:name) + (mod.method_list + mod.attributes).each do |method| + assert_predicate method.name, :frozen?, method.full_name + end + assert_same mod.method_list.first.name, mod.method_list.last.name + + # Parser-owned immutable names must not change the public mutable-name API. + assert_same mod.method_list.first, mod.find_method_named('one') + name = +'external' + method = mod.add_method RDoc::AnyMethod.new(name) + assert_same name, method.name + assert_same method, mod.find_method_named('external') + name.replace('renamed') + assert_nil mod.find_method_named('external') + assert_same method, mod.find_method_named('renamed') + end + + def test_method_name_lookups_use_index + util_parser <<~RUBY + class Foo + def one; end + def self.one; end + end + RUBY + + klass = @store.find_class_named 'Foo' + instance, singleton = klass.method_list + assert_same instance, klass.find_method_named('one') + + klass.method_list.each do |method| + def method.name + raise 'parsed method names were scanned again' + end + end + + assert_same instance, klass.find_method('one', false) + assert_same singleton, klass.find_method('one', true) + assert_same instance, klass.find_method_named('#one') + assert_same singleton, klass.find_method_named('::one') + assert_same instance, klass.find_instance_method_named('one') + assert_same singleton, klass.find_class_method_named('one') + assert_nil klass.find_method_named('missing') + + added = klass.add_method RDoc::AnyMethod.new('added') + assert_same added, klass.find_method_named('added') + assert_same instance, klass.find_method_named('one') + end + + def test_meta_method_names_are_frozen + util_parser <<~RUBY + class Foo + ## + # Symbol argument. + define_method :one do end + + ## + # String argument. + define_method "two" do end + + ## + # :method: three + + ## + # :singleton-method: four + + ## + # :method: + # :call-seq: + # five(name) + + ## + # Unknown name. + define_method(name) do end + + ## + # :attr_reader: attribute + end + RUBY + + klass = @store.find_class_named 'Foo' + assert_equal %w[one two three four five unknown], klass.method_list.map(&:name) + (klass.method_list + klass.attributes).each do |method| + assert_predicate method.name, :frozen?, method.full_name + end + end + def test_method_toplevel util_parser <<~RUBY # comment @@ -2849,6 +2953,7 @@ class C m = c.method_list.first assert_equal "find_by_[_and_...]", m.name + assert_predicate m.name, :frozen? assert_equal "find_by_[_and_...](args)\n", m.call_seq expected = From 864833980ee872d54a2ba518db7e1f8df4f0ca31 Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 6 Oct 2026 14:30:04 +0200 Subject: [PATCH 6/6] reduce tests --- lib/rdoc/code_object/context.rb | 5 ++--- test/rdoc/rdoc_context_test.rb | 10 ---------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/lib/rdoc/code_object/context.rb b/lib/rdoc/code_object/context.rb index 45124df22f..da354e18ca 100644 --- a/lib/rdoc/code_object/context.rb +++ b/lib/rdoc/code_object/context.rb @@ -1046,9 +1046,8 @@ def remove_from_documentation? def remove_invisible(min_visibility) return if [:private, :nodoc].include? min_visibility - methods = method_list.dup - remove_invisible_in methods, min_visibility - replace_methods methods + remove_invisible_in @method_list, min_visibility + @method_list.invalidate_name_index remove_invisible_in @attributes, min_visibility remove_invisible_in @constants, min_visibility end diff --git a/test/rdoc/rdoc_context_test.rb b/test/rdoc/rdoc_context_test.rb index 42311aa239..fb33c01956 100644 --- a/test/rdoc/rdoc_context_test.rb +++ b/test/rdoc/rdoc_context_test.rb @@ -775,16 +775,6 @@ def test_find_method_named_shared_list assert_nil @context.find_method_named('renamed') end - def test_find_method_named_after_list_copy - method = @context.add_method RDoc::AnyMethod.new('m') - assert_same method, @context.find_method_named('m') - - list = @context.method_list.dup - list.clear - - assert_same method, @context.find_method_named('m') - end - def test_find_method_named_frozen_method method = @context.add_method RDoc::AnyMethod.new('m') method.freeze