Skip to content

Commit 434d2ef

Browse files
committed
Stop lazy alias resolver from overwriting real classes
PR #1621 made RDoc::Constant#is_alias_for fall through to a lazy find_alias_for lookup that returns whatever class the constant's value names 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`. Refactor: * Split the lazy resolver out of the getter. RDoc::Constant#is_alias_for is now a pure accessor over @is_alias_for; the opportunistic value-shaped lookup is exposed as #resolved_alias_target, which is pure (no mutation) and honors :nodoc:. * RDoc::ClassModule#update_aliases is the single caller of the lazy path and gates writes behind a collision check that mirrors Context#add_module_alias when the alias was lazy-resolved. Explicit aliases (vetted by add_module_alias's own collision guard) keep their existing behavior so add_module_alias + update_aliases still compose. Tests: * Parser-level regressions cover :nodoc: assignment, real-class collision, the combined scenario, and the :stopdoc:/:startdoc: workaround path. * Unit tests on update_aliases cover both the collision skip and the :nodoc: skip. * The two 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 434d2ef

4 files changed

Lines changed: 159 additions & 14 deletions

File tree

lib/rdoc/code_object/class_module.rb

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

848848
def update_aliases
849849
constants.each do |const|
850-
next unless cm = const.is_alias_for
850+
if (cm = const.is_alias_for)
851+
via_lazy = false
852+
elsif const.respond_to?(:resolved_alias_target) && (cm = const.resolved_alias_target)
853+
via_lazy = true
854+
else
855+
next
856+
end
851857

852858
# Resolve chained aliases (A = B = C) to the real class/module.
853859
cm = @store.find_class_or_module(cm.full_name) || cm
@@ -866,6 +872,15 @@ def update_aliases
866872
end
867873
cm_alias.full_name = nil # force update for new parent
868874

875+
# For lazy-resolved aliases (Foo = Bar where Bar is parsed elsewhere
876+
# and was not vetted by add_module_alias), don't clobber a real class
877+
# already living at this name. add_module_alias performs its own
878+
# collision check at context.rb so the explicit path is already safe.
879+
if via_lazy
880+
existing = @store.find_class_or_module(cm_alias.full_name)
881+
next if existing && !existing.is_alias_for
882+
end
883+
869884
cm_alias.aliases.clear
870885
cm_alias.is_alias_for = cm
871886

lib/rdoc/code_object/constant.rb

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ def full_name
8383
end
8484

8585
##
86-
# The module or class this constant is an alias for
86+
# The module or class this constant is an alias for, if one was recorded
87+
# explicitly (by the parser via RDoc::Context#add_module_alias, by
88+
# RDoc::ClassModule#update_aliases, or by ri marshal load). Pure accessor:
89+
# never consults the store on its own. Callers that want to opportunistically
90+
# resolve a class-path-shaped value to a class/module should use
91+
# #resolved_alias_target.
8792

8893
def is_alias_for
8994
case @is_alias_for
@@ -92,16 +97,21 @@ def is_alias_for
9297
@is_alias_for = found if found
9398
@is_alias_for
9499
else
95-
@is_alias_for ||= find_alias_for
100+
@is_alias_for
96101
end
97102
end
98103

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/)
104+
##
105+
# Returns the class/module this constant *would* alias if its #value names
106+
# one in the current store, or nil. Used to support `Const = RHS` parsed
107+
# before `class RHS;end` is defined in another file. Pure lookup; does not
108+
# mutate state. Honors :nodoc: (returns nil if document_self is false). Note
109+
# that module nesting information is lost, so constant lookup is inaccurate.
110+
111+
def resolved_alias_target
112+
return nil unless document_self
113+
return nil unless value&.match?(/\A(::)?([A-Z][A-Za-z0-9_]*)(::[A-Z][A-Za-z0-9_]*)*\z/)
114+
parent.find_module_named(value)
105115
end
106116

107117
def inspect # :nodoc:

test/rdoc/code_object/class_module_test.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,47 @@ 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+
1472+
const = RDoc::Constant.new 'Foo', 'Other', ''
1473+
const.record_location top_level
1474+
object.add_constant const
1475+
1476+
object.update_aliases
1477+
1478+
assert_same real_foo, store.classes_hash['Foo']
1479+
assert_nil store.classes_hash['Foo'].is_alias_for
1480+
assert_equal ['real_method'], store.classes_hash['Foo'].method_list.map(&:name)
1481+
assert_empty other.aliases
1482+
end
1483+
1484+
def test_update_aliases_skips_nodoc_constant
1485+
store = RDoc::Store.new(RDoc::Options.new)
1486+
top_level = store.add_file 'file.rb'
1487+
1488+
object = top_level.add_class RDoc::NormalClass, 'Object'
1489+
target = top_level.add_class RDoc::NormalClass, 'Target'
1490+
1491+
const = RDoc::Constant.new 'NodocAlias', 'Target', ''
1492+
const.record_location top_level
1493+
const.document_self = nil
1494+
object.add_constant const
1495+
1496+
object.update_aliases
1497+
1498+
assert_nil store.classes_hash['NodocAlias']
1499+
assert_nil store.modules_hash['NodocAlias']
1500+
assert_empty target.aliases
1501+
end
1502+
14621503
def test_update_includes
14631504
a = RDoc::Include.new 'M1', nil
14641505
b = RDoc::Include.new 'M2', nil

test/rdoc/parser/prism_ruby_test.rb

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,9 +1689,9 @@ class Bar; end
16891689
class Baz; end
16901690
RUBY
16911691
klass = @top_level.classes.first
1692-
assert_equal 'Foo::Bar', klass.find_constant_named('A').is_alias_for.full_name
1693-
assert_equal 'Foo::Bar', klass.find_constant_named('B').is_alias_for.full_name
1694-
assert_equal 'Baz', klass.find_constant_named('C').is_alias_for.full_name
1692+
assert_equal 'Foo::Bar', klass.find_constant_named('A').resolved_alias_target.full_name
1693+
assert_equal 'Foo::Bar', klass.find_constant_named('B').resolved_alias_target.full_name
1694+
assert_equal 'Baz', klass.find_constant_named('C').resolved_alias_target.full_name
16951695
end
16961696

16971697
def test_repeated_constant_alias
@@ -1713,8 +1713,87 @@ class Baz
17131713
RUBY
17141714
klass = @top_level.classes.first
17151715
assert_equal 'Bar', klass.find_constant_named('A').value
1716-
assert_nil klass.find_constant_named('A').is_alias_for
1717-
assert_equal 'Baz', klass.find_constant_named('B').is_alias_for.full_name
1716+
assert_nil klass.find_constant_named('A').resolved_alias_target
1717+
assert_equal 'Baz', klass.find_constant_named('B').resolved_alias_target.full_name
1718+
end
1719+
1720+
def test_nodoc_constant_assignment_does_not_become_alias
1721+
util_parser <<~RUBY
1722+
class Outer
1723+
class Bar
1724+
end
1725+
Foo = Bar # :nodoc:
1726+
end
1727+
RUBY
1728+
@store.complete(:public)
1729+
outer = @store.classes_hash['Outer']
1730+
refute_nil outer
1731+
assert_nil outer.classes_hash['Foo']
1732+
assert_nil outer.modules_hash['Foo']
1733+
foo_const = outer.constants.find { |c| c.name == 'Foo' }
1734+
refute_nil foo_const
1735+
assert_nil foo_const.is_alias_for
1736+
assert_nil foo_const.resolved_alias_target
1737+
end
1738+
1739+
def test_constant_alias_does_not_overwrite_real_class_with_same_name
1740+
util_parser <<~RUBY
1741+
class Foo
1742+
def real_method; end
1743+
end
1744+
class Bar
1745+
def other_method; end
1746+
end
1747+
Foo = Bar
1748+
RUBY
1749+
@store.complete(:public)
1750+
foo = @store.classes_hash['Foo']
1751+
refute_nil foo
1752+
assert_nil foo.is_alias_for
1753+
assert_includes foo.method_list.map(&:name), 'real_method'
1754+
refute_includes foo.method_list.map(&:name), 'other_method'
1755+
end
1756+
1757+
def test_nodoc_constant_assignment_preserves_real_class_with_same_name
1758+
util_parser <<~RUBY
1759+
class Foo
1760+
def real_method; end
1761+
end
1762+
module Bar
1763+
class Foo
1764+
def shim_method; end
1765+
end
1766+
end
1767+
Foo = Bar::Foo # :nodoc:
1768+
RUBY
1769+
@store.complete(:public)
1770+
foo = @store.classes_hash['Foo']
1771+
refute_nil foo
1772+
assert_nil foo.is_alias_for
1773+
assert_includes foo.method_list.map(&:name), 'real_method'
1774+
refute_includes foo.method_list.map(&:name), 'shim_method'
1775+
end
1776+
1777+
def test_stopdoc_constant_assignment_preserves_real_class_with_same_name
1778+
util_parser <<~RUBY
1779+
class Foo
1780+
def real_method; end
1781+
end
1782+
module Bar
1783+
class Foo
1784+
def shim_method; end
1785+
end
1786+
end
1787+
# :stopdoc:
1788+
Foo = Bar::Foo
1789+
# :startdoc:
1790+
RUBY
1791+
@store.complete(:public)
1792+
foo = @store.classes_hash['Foo']
1793+
refute_nil foo
1794+
assert_nil foo.is_alias_for
1795+
assert_includes foo.method_list.map(&:name), 'real_method'
1796+
refute_includes foo.method_list.map(&:name), 'shim_method'
17181797
end
17191798

17201799
def test_constant_with_singleton_class

0 commit comments

Comments
 (0)