Skip to content

Commit f4db99c

Browse files
committed
Stop lazy alias resolver from overwriting real classes
Fixes #1662. PR #1621 made RDoc::Constant#is_alias_for fall through to a lazy find_alias_for lookup that returned whatever class the constant's value named in the current store. The lazy result then flowed into RDoc::ClassModule#update_aliases, which unconditionally wrote a dup'd alias copy into @store.classes_hash, with two safeguards present elsewhere bypassed: * Context#add_module_alias refuses to clobber an existing class entry (the historic BasicObject = BlankSlate guard), but update_aliases did not. * The prism parser only registers an alias when the constant has document_self set (so :nodoc: is honored), but the lazy resolver ignored it. In combination these meant `Foo = Bar # :nodoc:`, where a real `Foo` class was parsed elsewhere, would silently replace the real class's documentation with an alias copy of `Bar` -- the literal failure mode in #1662 (the prism shim's `Ripper = Prism::Translation::Ripper` clobbering the real Ripper class docs). Architectural fix ----------------- Split RDoc::Constant#is_alias_for back into a pure accessor and a separate Constant#resolved_alias_target lookup. is_alias_for now returns only what was recorded explicitly (by Context#add_module_alias, by ClassModule#update_aliases, or by ri marshal load) and never mutates state. resolved_alias_target is the opportunistic forward-reference lookup, used only by update_aliases as a fallback when no explicit alias is recorded; it honors document_self so :nodoc: constants don't lazy-resolve. The lazy lookup itself uses a new Constant#is_alias_for_path attribute populated by the parsers, instead of regex-matching #value at lookup time. Both the prism parser (via ConstantReadNode/ConstantPathNode detection in constant_path_string) and the legacy ripper parser (via on_const-only token accumulation in parse_constant_body) already know whether the RHS is a constant reference at parse time; we now propagate that explicitly rather than rediscovering it from a stringly-typed value. Mechanical fix -------------- ClassModule#update_aliases falls back to const.resolved_alias_target when const.is_alias_for is unset, and gates the destination write behind a collision check that mirrors the one in Context#add_module_alias: skip if classes_hash/modules_hash already holds a real (non-alias) class at the destination name. The guard now applies uniformly to both the explicit and lazy paths. When the fallback resolves a forward-reference target, the resolved class is also written back to const.is_alias_for so downstream consumers (RDoc::Stats#report_constants, RDoc::Constant#marshal_dump) observe the alias relationship the lazy lookup found -- otherwise forward-ref aliases get reported as undocumented and the alias slot is serialized as nil into ri data. For that guard to compose with add_module_alias's pre-registration flow (which already places an alias copy at the destination expecting update_aliases to overwrite it with the properly-marked version), add_module_alias now sets is_alias_for on the alias copy at creation time. Existing alias copies are therefore distinguishable from real classes by the unconditional guard. Tests ----- Parser-level regressions cover :nodoc: assignment, real-class collision, the combined :nodoc:-plus-collision scenario from #1662, the :stopdoc:/:startdoc: workaround path, an explicit alias followed by a same-named class re-open (which under the new guard preserves both the alias target's methods and the methods added by the re-open), and the post-Store#complete is_alias_for persistence on a forward-reference alias. Each asserts both the negative (the would-be alias didn't clobber) and the positive (the alias target keeps its own methods and aliases list). Unit tests on update_aliases assert the same. The two existing PR #1621 tests (test_constant_alias_reverse_order, test_repeated_constant_alias) are updated to exercise the renamed resolved_alias_target API; the underlying forward-reference behavior is preserved.
1 parent c5247f7 commit f4db99c

7 files changed

Lines changed: 260 additions & 25 deletions

File tree

lib/rdoc/code_object/class_module.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -847,7 +847,15 @@ def type
847847

848848
def update_aliases
849849
constants.each do |const|
850-
next unless cm = const.is_alias_for
850+
cm = const.is_alias_for
851+
if !cm && const.is_a?(RDoc::Constant)
852+
cm = const.resolved_alias_target
853+
# Persist the forward-reference resolution on the source constant
854+
# so Stats#report_constants and Constant#marshal_dump observe the
855+
# alias relationship that the lazy lookup found.
856+
const.is_alias_for = cm if cm
857+
end
858+
next unless cm
851859

852860
# Resolve chained aliases (A = B = C) to the real class/module.
853861
cm = @store.find_class_or_module(cm.full_name) || cm
@@ -866,6 +874,14 @@ def update_aliases
866874
end
867875
cm_alias.full_name = nil # force update for new parent
868876

877+
# Don't clobber a real (non-alias) class/module already living at this
878+
# name. Mirrors the BasicObject = BlankSlate guard in
879+
# Context#add_module_alias. Existing alias copies (set by
880+
# add_module_alias or a previous update_aliases pass) carry is_alias_for,
881+
# so they're still overwritable here.
882+
existing = @store.find_class_or_module(cm_alias.full_name)
883+
next if existing && !existing.is_alias_for
884+
869885
cm_alias.aliases.clear
870886
cm_alias.is_alias_for = cm
871887

lib/rdoc/code_object/constant.rb

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ class RDoc::Constant < RDoc::CodeObject
2626

2727
attr_accessor :visibility
2828

29+
##
30+
# The constant path on the RHS when the RHS is a bare constant reference
31+
# (+Foo = Bar+ or +Foo = Bar::Baz+). Captured at parse time so
32+
# #resolved_alias_target doesn't have to re-derive it from the textual
33+
# #value. nil for other RHS shapes.
34+
35+
attr_accessor :is_alias_for_path
36+
2937
##
3038
# Creates a new constant with +name+, +value+ and +comment+
3139

@@ -35,8 +43,9 @@ def initialize(name, value, comment)
3543
@name = name
3644
@value = value
3745

38-
@is_alias_for = nil
39-
@visibility = :public
46+
@is_alias_for = nil
47+
@is_alias_for_path = nil
48+
@visibility = :public
4049

4150
self.comment = comment
4251
end
@@ -83,7 +92,10 @@ def full_name
8392
end
8493

8594
##
86-
# The module or class this constant is an alias for
95+
# The module or class this constant is an alias for, when one was recorded
96+
# explicitly (by RDoc::Context#add_module_alias, RDoc::ClassModule#update_aliases,
97+
# or ri marshal load). Pure accessor; see #resolved_alias_target for the
98+
# opportunistic lookup path.
8799

88100
def is_alias_for
89101
case @is_alias_for
@@ -92,16 +104,22 @@ def is_alias_for
92104
@is_alias_for = found if found
93105
@is_alias_for
94106
else
95-
@is_alias_for ||= find_alias_for
107+
@is_alias_for
96108
end
97109
end
98110

99-
# Find alias constant for a value.
100-
# This is used when the constant is parsed before the class or module defined in another file.
101-
# Note that module nesting information is lost, so constant lookup is inaccurate.
102-
103-
def find_alias_for
104-
parent.find_module_named(value) if value&.match?(/\A(::)?([A-Z][A-Za-z0-9_]*)(::[A-Z][A-Za-z0-9_]*)*\z/)
111+
##
112+
# Returns the class/module this constant *would* alias if #is_alias_for_path
113+
# was set by the parser and that path resolves to a known class/module, or
114+
# nil. Used to support `Const = RHS` parsed before `class RHS;end` is defined
115+
# in another file. Pure lookup; does not mutate state. Honors :nodoc:
116+
# (returns nil if document_self is false). Note that module nesting
117+
# information is lost, so constant lookup is inaccurate.
118+
119+
def resolved_alias_target
120+
return nil unless document_self
121+
return nil unless @is_alias_for_path
122+
parent.find_module_named(@is_alias_for_path)
105123
end
106124

107125
def inspect # :nodoc:

lib/rdoc/code_object/context.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ def add_module_alias(from, from_name, to, file)
544544
new_to = from.dup
545545
new_to.name = to.name
546546
new_to.full_name = nil
547+
new_to.is_alias_for = from
547548

548549
if new_to.module? then
549550
@store.modules_hash[to_full_name] = new_to

lib/rdoc/parser/prism_ruby.rb

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,7 @@ def find_or_create_constant_owner_name(constant_path)
767767

768768
# Adds a constant
769769

770-
def add_constant(constant_name, rhs_name, start_line, end_line)
770+
def add_constant(constant_name, rhs_name, start_line, end_line, alias_path: nil)
771771
comment, directives = consecutive_comment(start_line)
772772
handle_code_object_directives(@container, directives) if directives
773773
owner, name = find_or_create_constant_owner_name(constant_name)
@@ -776,19 +776,21 @@ def add_constant(constant_name, rhs_name, start_line, end_line)
776776
constant = RDoc::Constant.new(name, rhs_name, comment)
777777
constant.store = @store
778778
constant.line = start_line
779+
constant.is_alias_for_path = alias_path
779780
record_location(constant)
780781
handle_modifier_directive(constant, start_line)
781782
handle_modifier_directive(constant, end_line)
782783
owner.add_constant(constant)
784+
return unless alias_path
783785
mod =
784-
if rhs_name =~ /^::/
785-
@store.find_class_or_module(rhs_name)
786+
if alias_path.start_with?('::')
787+
@store.find_class_or_module(alias_path)
786788
else
787-
full_name = resolve_constant_path(rhs_name)
789+
full_name = resolve_constant_path(alias_path)
788790
@store.find_class_or_module(full_name)
789791
end
790792
if mod && constant.document_self
791-
a = @container.add_module_alias(mod, rhs_name, constant, @top_level)
793+
a = @container.add_module_alias(mod, alias_path, constant, @top_level)
792794
a.store = @store
793795
a.line = start_line
794796
record_location(a)
@@ -1056,23 +1058,27 @@ def visit_constant_path_write_node(node)
10561058
path = constant_path_string(node.target)
10571059
return unless path
10581060

1061+
alias_path = constant_path_string(node.value)
10591062
@scanner.add_constant(
10601063
path,
1061-
constant_path_string(node.value) || node.value.slice,
1064+
alias_path || node.value.slice,
10621065
node.location.start_line,
1063-
node.location.end_line
1066+
node.location.end_line,
1067+
alias_path: alias_path
10641068
)
10651069
@scanner.skip_comments_until(node.location.end_line)
10661070
# Do not traverse rhs not to document `A::B = Struct.new{def undocumentable_method; end}`
10671071
end
10681072

10691073
def visit_constant_write_node(node)
10701074
@scanner.process_comments_until(node.location.start_line - 1)
1075+
alias_path = constant_path_string(node.value)
10711076
@scanner.add_constant(
10721077
node.name.to_s,
1073-
constant_path_string(node.value) || node.value.slice,
1078+
alias_path || node.value.slice,
10741079
node.location.start_line,
1075-
node.location.end_line
1080+
node.location.end_line,
1081+
alias_path: alias_path
10761082
)
10771083
@scanner.skip_comments_until(node.location.end_line)
10781084
# Do not traverse rhs not to document `A = Struct.new{def undocumentable_method; end}`

lib/rdoc/parser/ripper_ruby.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ def create_attr(container, single, name, rw, comment) # :nodoc:
170170
# for "::") with the name from +constant+.
171171

172172
def create_module_alias(container, constant, rhs_name) # :nodoc:
173+
constant.is_alias_for_path = rhs_name
173174
mod = if rhs_name =~ /^::/ then
174175
@store.find_class_or_module rhs_name
175176
else

test/rdoc/code_object/class_module_test.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,56 @@ def test_update_aliases_reparent_root
14591459
assert_equal 'Klass', store.classes_hash['Klass'].full_name
14601460
end
14611461

1462+
def test_update_aliases_does_not_overwrite_existing_class_with_same_name
1463+
store = RDoc::Store.new(RDoc::Options.new)
1464+
top_level = store.add_file 'file.rb'
1465+
1466+
object = top_level.add_class RDoc::NormalClass, 'Object'
1467+
real_foo = top_level.add_class RDoc::NormalClass, 'Foo'
1468+
real_foo.add_method RDoc::AnyMethod.new(nil, 'real_method')
1469+
1470+
other = top_level.add_class RDoc::NormalClass, 'Other'
1471+
other.add_method RDoc::AnyMethod.new(nil, 'other_method')
1472+
1473+
const = RDoc::Constant.new 'Foo', 'Other', ''
1474+
const.is_alias_for_path = 'Other'
1475+
const.record_location top_level
1476+
object.add_constant const
1477+
1478+
object.update_aliases
1479+
1480+
assert_same real_foo, store.classes_hash['Foo']
1481+
assert_nil store.classes_hash['Foo'].is_alias_for
1482+
assert_equal ['real_method'], store.classes_hash['Foo'].method_list.map(&:name)
1483+
assert_same other, store.classes_hash['Other']
1484+
assert_nil other.is_alias_for
1485+
assert_equal ['other_method'], other.method_list.map(&:name)
1486+
assert_empty other.aliases
1487+
end
1488+
1489+
def test_update_aliases_skips_nodoc_constant
1490+
store = RDoc::Store.new(RDoc::Options.new)
1491+
top_level = store.add_file 'file.rb'
1492+
1493+
object = top_level.add_class RDoc::NormalClass, 'Object'
1494+
target = top_level.add_class RDoc::NormalClass, 'Target'
1495+
target.add_method RDoc::AnyMethod.new(nil, 'target_method')
1496+
1497+
const = RDoc::Constant.new 'NodocAlias', 'Target', ''
1498+
const.is_alias_for_path = 'Target'
1499+
const.record_location top_level
1500+
const.document_self = nil
1501+
object.add_constant const
1502+
1503+
object.update_aliases
1504+
1505+
assert_nil store.classes_hash['NodocAlias']
1506+
assert_nil store.modules_hash['NodocAlias']
1507+
assert_same target, store.classes_hash['Target']
1508+
assert_equal ['target_method'], target.method_list.map(&:name)
1509+
assert_empty target.aliases
1510+
end
1511+
14621512
def test_update_includes
14631513
a = RDoc::Include.new 'M1', nil
14641514
b = RDoc::Include.new 'M2', nil

0 commit comments

Comments
 (0)