Skip to content

Commit 2aa8b5e

Browse files
authored
Stop lazy alias resolver from overwriting real classes (#1662) (#1689)
1 parent c5247f7 commit 2aa8b5e

8 files changed

Lines changed: 326 additions & 27 deletions

File tree

AGENTS.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,11 +234,25 @@ exe/
234234

235235
## Architecture Notes
236236

237-
### Pluggable System
237+
### Parsers and Generators
238238

239-
- **Parsers:** Ruby, C, Markdown, RD, Prism-based Ruby (experimental)
239+
- **Parsers:** Prism-based Ruby (default, `RDoc::Parser::PrismRuby`), legacy ripper-based Ruby (`RDoc::Parser::RipperRuby`, opt-in via `RDOC_USE_RIPPER_PARSER=1`), C, Markdown, RD
240240
- **Generators:** HTML/Aliki (default), HTML/Darkfish (deprecated), RI, POT (gettext), JSON, Markup
241241

242+
Both Ruby parsers must produce equivalent code-object trees, so parser tests live in the `RDocParserPrismTestCases` module (`test/rdoc/parser/prism_ruby_test.rb`) and are included by both `RDocParserPrismRubyTest` and `RDocParserRipperRubyWithPrismRubyTestCasesTest`. The ripper variant is gated on `RDOC_USE_RIPPER_PARSER`, so `bundle exec rake` locally only runs prism; CI exercises ripper in a separate job. Add new parser tests to the mixin, and run `RDOC_USE_RIPPER_PARSER=1 bundle exec rake` locally before declaring a parser change done.
243+
244+
### Code Object Model and Constant Aliases
245+
246+
The code-object tree (`lib/rdoc/code_object/`) is built in two phases. Parse-time work happens in the parsers and `RDoc::Context` (`add_constant`, `add_module_alias`). Finalization happens in `Store#complete`, which calls `ClassModule#update_aliases` on each container — this is where forward-reference aliases (`Foo = Bar` parsed before `class Bar` in another file) get resolved via `Constant#resolved_alias_target`.
247+
248+
If you add an invariant to one of these paths — for example the `Context#add_module_alias` collision guard that refuses to clobber an existing class — mirror it on the other. The two paths are not interchangeable: `add_module_alias` runs against partial store state and does extra bookkeeping (`unmatched_constant_alias`); `update_aliases` runs against the finalized store and writes the alias copies into `classes_hash` / `modules_hash`.
249+
250+
**Known limitation: lexical scope.** `Context#find_enclosing_module_named` walks the syntactic parent chain as a stand-in for Ruby's lexical constant lookup. The prism parser doesn't represent module nesting via the parent chain at all (see the docstring on `Context#find_enclosing_module_named`), so alias resolution in deeply nested or re-opened classes can pick the wrong target. Fixing this properly requires capturing lexical scope at parse time — a feature change rather than an incremental fix.
251+
252+
### Marshal / ri Data Compatibility
253+
254+
`RDoc::Constant`, `RDoc::ClassModule`, and other code objects implement `marshal_dump` / `marshal_load` to persist ri data on disk, gated by a per-class `MARSHAL_VERSION` constant. The `ri` CLI (`lib/rdoc/ri/driver.rb`) and the `ri --server` servlet (`lib/rdoc/ri/servlet.rb`) read this format. Any change that alters the dumped array — adding/removing slots, reinterpreting an existing slot's meaning — needs `MARSHAL_VERSION` bumped and the loader taught to handle older payloads, otherwise locally-cached `.ri` data from an earlier rdoc version stops loading after an upgrade.
255+
242256
### Live Preview Server (`RDoc::Server`)
243257

244258
The server (`lib/rdoc/server.rb`) provides `rdoc --server` for live documentation preview.

lib/rdoc/code_object/class_module.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -847,7 +847,9 @@ 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+
cm ||= const.resolved_alias_target if const.is_a?(RDoc::Constant)
852+
next unless cm
851853

852854
# Resolve chained aliases (A = B = C) to the real class/module.
853855
cm = @store.find_class_or_module(cm.full_name) || cm
@@ -866,6 +868,19 @@ def update_aliases
866868
end
867869
cm_alias.full_name = nil # force update for new parent
868870

871+
# Don't clobber a real (non-alias) class/module already living at this
872+
# name. Mirrors the BasicObject = BlankSlate guard in
873+
# Context#add_module_alias. Existing alias copies (set by
874+
# add_module_alias or a previous update_aliases pass) carry is_alias_for,
875+
# so they're still overwritable here.
876+
existing = @store.find_class_or_module(cm_alias.full_name)
877+
next if existing && !existing.is_alias_for
878+
879+
# Persist a lazy-resolved target so Stats#report_constants and
880+
# Constant#marshal_dump observe the alias relationship. Skipped
881+
# aliases (above) intentionally leave the constant unmarked.
882+
const.is_alias_for ||= cm
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/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 = owner.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)