Skip to content

Commit ca76ee5

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 that lazy-resolved path behind a collision check that mirrors the one in Context#add_module_alias. Explicit aliases (already vetted by add_module_alias) keep their existing path so the two compose. Tests ----- Parser-level regressions cover :nodoc: assignment, real-class collision, the combined :nodoc:-plus-collision scenario from #1662, and the :stopdoc:/:startdoc: workaround path. 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 ca76ee5

6 files changed

Lines changed: 214 additions & 25 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.is_a?(RDoc::Constant) && (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: 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/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

test/rdoc/parser/prism_ruby_test.rb

Lines changed: 104 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,107 @@ 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+
def bar_method; end
1725+
end
1726+
Foo = Bar # :nodoc:
1727+
end
1728+
RUBY
1729+
@store.complete(:public)
1730+
outer = @store.classes_hash['Outer']
1731+
refute_nil outer
1732+
bar = outer.classes_hash['Bar']
1733+
refute_nil bar
1734+
assert_includes bar.method_list.map(&:name), 'bar_method'
1735+
assert_empty bar.aliases
1736+
assert_nil outer.classes_hash['Foo']
1737+
assert_nil outer.modules_hash['Foo']
1738+
foo_const = outer.constants.find { |c| c.name == 'Foo' }
1739+
refute_nil foo_const
1740+
assert_nil foo_const.is_alias_for
1741+
assert_nil foo_const.resolved_alias_target
1742+
end
1743+
1744+
def test_constant_alias_does_not_overwrite_real_class_with_same_name
1745+
util_parser <<~RUBY
1746+
class Foo
1747+
def real_method; end
1748+
end
1749+
class Bar
1750+
def other_method; end
1751+
end
1752+
Foo = Bar
1753+
RUBY
1754+
@store.complete(:public)
1755+
foo = @store.classes_hash['Foo']
1756+
refute_nil foo
1757+
assert_nil foo.is_alias_for
1758+
assert_includes foo.method_list.map(&:name), 'real_method'
1759+
refute_includes foo.method_list.map(&:name), 'other_method'
1760+
bar = @store.classes_hash['Bar']
1761+
refute_nil bar
1762+
assert_nil bar.is_alias_for
1763+
assert_includes bar.method_list.map(&:name), 'other_method'
1764+
assert_empty bar.aliases
1765+
end
1766+
1767+
def test_nodoc_constant_assignment_preserves_real_class_with_same_name
1768+
util_parser <<~RUBY
1769+
class Foo
1770+
def real_method; end
1771+
end
1772+
module Bar
1773+
class Foo
1774+
def shim_method; end
1775+
end
1776+
end
1777+
Foo = Bar::Foo # :nodoc:
1778+
RUBY
1779+
@store.complete(:public)
1780+
foo = @store.classes_hash['Foo']
1781+
refute_nil foo
1782+
assert_nil foo.is_alias_for
1783+
assert_includes foo.method_list.map(&:name), 'real_method'
1784+
refute_includes foo.method_list.map(&:name), 'shim_method'
1785+
bar_foo = @store.classes_hash['Bar::Foo']
1786+
refute_nil bar_foo
1787+
assert_nil bar_foo.is_alias_for
1788+
assert_includes bar_foo.method_list.map(&:name), 'shim_method'
1789+
assert_empty bar_foo.aliases
1790+
end
1791+
1792+
def test_stopdoc_constant_assignment_preserves_real_class_with_same_name
1793+
util_parser <<~RUBY
1794+
class Foo
1795+
def real_method; end
1796+
end
1797+
module Bar
1798+
class Foo
1799+
def shim_method; end
1800+
end
1801+
end
1802+
# :stopdoc:
1803+
Foo = Bar::Foo
1804+
# :startdoc:
1805+
RUBY
1806+
@store.complete(:public)
1807+
foo = @store.classes_hash['Foo']
1808+
refute_nil foo
1809+
assert_nil foo.is_alias_for
1810+
assert_includes foo.method_list.map(&:name), 'real_method'
1811+
refute_includes foo.method_list.map(&:name), 'shim_method'
1812+
bar_foo = @store.classes_hash['Bar::Foo']
1813+
refute_nil bar_foo
1814+
assert_nil bar_foo.is_alias_for
1815+
assert_includes bar_foo.method_list.map(&:name), 'shim_method'
1816+
assert_empty bar_foo.aliases
17181817
end
17191818

17201819
def test_constant_with_singleton_class

0 commit comments

Comments
 (0)