Skip to content

Commit f519593

Browse files
authored
Refactor comment_location from Array to Hash (#1634)
Convert `ClassModule#comment_location` from an Array of `[comment, location]` pairs to a Hash of `{ location => [comments] }`. ## Motivation When a class like `RDoc` is documented across multiple files (`lib/rdoc.rb`, `lib/rdoc/rubygems_hook.rb`, etc.), each file contributes a comment. With the old Array of `[comment, location]` pairs, there was no efficient way to look up or replace comments by file. This matters for server live-reload (#1620): when a user edits `lib/rdoc.rb`, we need to clear that file's comment and re-parse — but preserve comments from other files **in their original order**. The Array structure made this fragile because removing and re-appending an entry moved it to the end, changing the order comments appeared on the rendered page. The Hash structure solves this: `{ location => [comments] }` allows O(1) lookup by file, and Ruby hashes preserve insertion order — replacing a key's value keeps it in position. A secondary benefit: a class reopened in the same file now preserves all its comments: ```ruby # comment1 class A; end # comment2 class A; end ``` With the old Array, the C parser had a special-case `delete_if` to deduplicate same-location entries, while Ruby parsers accumulated duplicates inconsistently. With the new Hash, each location maps to an array of comments — both comments are preserved and rendered, matching the cross-file behavior. ## Changes - `add_comment`: appends to array per location — `(@comment_location[location] ||= []) << comment` - `parse`: uses `flat_map` to flatten per-location comment arrays into Document parts - `marshal_load` / `merge`: use `group_by(&:file)` to reconstruct arrays from Documents - `documented?`, `search_snippet`, `from_module`: updated for new value shape - `i18n/text.rb`: handles Hash with array values - C parser `delete_if` special case removed (hash key naturally deduplicates by location)
1 parent 9760805 commit f519593

File tree

7 files changed

+59
-65
lines changed

7 files changed

+59
-65
lines changed

lib/rdoc/code_object/class_module.rb

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,20 @@ class RDoc::ClassModule < RDoc::Context
3030
attr_accessor :constant_aliases
3131

3232
##
33-
# An array of `[comment, location]` pairs documenting this class/module.
33+
# A hash of <tt>{ location => [comments] }</tt> documenting this class/module.
3434
# Use #add_comment to add comments.
3535
#
36+
# Ruby hashes maintain insertion order, so comments render in the order
37+
# they were first added. Each location maps to an array of comments,
38+
# allowing a class reopened in the same file to accumulate multiple comments.
39+
#
3640
# Before marshalling:
37-
# - +comment+ is a String
3841
# - +location+ is an RDoc::TopLevel
42+
# - +comments+ are Strings
3943
#
4044
# After unmarshalling:
41-
# - +comment+ is an RDoc::Markup::Document
4245
# - +location+ is a filename String
43-
#
44-
# These type changes are acceptable (for now) because:
45-
# - +comment+: Both String and Document respond to #empty?, and #parse
46-
# returns Document as-is (see RDoc::Text#parse)
47-
# - +location+: Only used by #parse to set Document#file, which accepts
48-
# both TopLevel (extracts relative_name) and String
46+
# - +comments+ are RDoc::Markup::Documents
4947

5048
attr_accessor :comment_location
5149

@@ -63,8 +61,8 @@ class RDoc::ClassModule < RDoc::Context
6361
def self.from_module(class_type, mod)
6462
klass = class_type.new mod.name
6563

66-
mod.comment_location.each do |comment, location|
67-
klass.add_comment comment, location
64+
mod.comment_location.each do |location, comments|
65+
comments.each { |comment| klass.add_comment comment, location }
6866
end
6967

7068
klass.parent = mod.parent
@@ -125,7 +123,7 @@ def initialize(name, superclass = nil)
125123
@is_alias_for = nil
126124
@name = name
127125
@superclass = superclass
128-
@comment_location = [] # Array of [comment, location] pairs
126+
@comment_location = {} # Hash of { location => [comments] }
129127

130128
super()
131129
end
@@ -147,11 +145,7 @@ def add_comment(comment, location)
147145
normalize_comment comment
148146
end
149147

150-
if location.parser == RDoc::Parser::C
151-
@comment_location.delete_if { |(_, l)| l == location }
152-
end
153-
154-
@comment_location << [comment, location]
148+
(@comment_location[location] ||= []) << comment
155149

156150
self.comment = original
157151
end
@@ -270,7 +264,7 @@ def document_self_or_methods
270264
def documented?
271265
return true if @received_nodoc
272266
return false if @comment_location.empty?
273-
@comment_location.any? { |comment, _| not comment.empty? }
267+
@comment_location.each_value.any? { |comments| comments.any? { |c| not c.empty? } }
274268
end
275269

276270
##
@@ -411,9 +405,9 @@ def marshal_load(array) # :nodoc:
411405
@comment = RDoc::Comment.from_document document
412406

413407
@comment_location = if document.parts.first.is_a?(RDoc::Markup::Document)
414-
document.parts.map { |doc| [doc, doc.file] }
408+
document.parts.group_by(&:file)
415409
else
416-
[[document, document.file]]
410+
{ document.file => [document] }
417411
end
418412

419413
array[5].each do |name, rw, visibility, singleton, file|
@@ -495,9 +489,9 @@ def merge(class_module)
495489
@comment = RDoc::Comment.from_document(document)
496490

497491
@comment_location = if document.parts.first.is_a?(RDoc::Markup::Document)
498-
document.parts.map { |doc| [doc, doc.file] }
492+
document.parts.group_by(&:file)
499493
else
500-
[[document, document.file]]
494+
{ document.file => [document] }
501495
end
502496
end
503497

@@ -643,11 +637,13 @@ def parse(comment_location)
643637
case comment_location
644638
when String then
645639
super
646-
when Array then
647-
docs = comment_location.map do |comment, location|
648-
doc = super comment
649-
doc.file = location
650-
doc
640+
when Hash then
641+
docs = comment_location.flat_map do |location, comments|
642+
comments.map do |comment|
643+
doc = super comment
644+
doc.file = location
645+
doc
646+
end
651647
end
652648

653649
RDoc::Markup::Document.new(*docs)
@@ -745,7 +741,7 @@ def search_record
745741
# Returns an HTML snippet of the first comment for search results.
746742

747743
def search_snippet
748-
first_comment = @comment_location.first&.first
744+
first_comment = @comment_location.each_value.first&.first
749745
return '' unless first_comment && !first_comment.empty?
750746

751747
snippet(first_comment)

lib/rdoc/i18n/text.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ def each_line(raw, &block)
8989
case raw
9090
when RDoc::Comment
9191
raw.text.each_line(&block)
92-
when Array
93-
raw.each do |comment, location|
94-
each_line(comment, &block)
92+
when Hash
93+
raw.each_value do |comments|
94+
comments.each { |comment| each_line(comment, &block) }
9595
end
9696
else
9797
raw.each_line(&block)

test/rdoc/code_object/class_module_test.rb

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,21 @@ def test_add_comment
1212
comment_tl1 = RDoc::Comment.new('# comment 1', @top_level, :ruby)
1313
cm.add_comment comment_tl1, tl1
1414

15-
assert_equal [[comment_tl1, tl1]], cm.comment_location
15+
assert_equal({ tl1 => [comment_tl1] }, cm.comment_location)
1616
assert_equal 'comment 1', cm.comment.text
1717

1818
comment_tl2 = RDoc::Comment.new('# comment 2', @top_level, :ruby)
1919
cm.add_comment comment_tl2, tl2
2020

21-
assert_equal [[comment_tl1, tl1], [comment_tl2, tl2]], cm.comment_location
21+
assert_equal({ tl1 => [comment_tl1], tl2 => [comment_tl2] }, cm.comment_location)
2222
assert_equal "comment 1\n---\ncomment 2", cm.comment
2323

2424
comment_tl3 = RDoc::Comment.new('# * comment 3', @top_level, :ruby)
2525
cm.add_comment comment_tl3, tl3
2626

27-
assert_equal [[comment_tl1, tl1],
28-
[comment_tl2, tl2],
29-
[comment_tl3, tl3]], cm.comment_location
27+
assert_equal({ tl1 => [comment_tl1],
28+
tl2 => [comment_tl2],
29+
tl3 => [comment_tl3] }, cm.comment_location)
3030
assert_equal "comment 1\n---\ncomment 2\n---\n* comment 3", cm.comment
3131
end
3232

@@ -38,7 +38,7 @@ def test_add_comment_comment
3838
assert_equal 'comment', cm.comment.text
3939
end
4040

41-
def test_add_comment_duplicate
41+
def test_add_comment_same_file_reopened_class
4242
tl1 = @store.add_file 'one.rb'
4343

4444
cm = RDoc::ClassModule.new 'Klass'
@@ -47,8 +47,8 @@ def test_add_comment_duplicate
4747
cm.add_comment comment1, tl1
4848
cm.add_comment comment2, tl1
4949

50-
assert_equal [[comment1, tl1],
51-
[comment2, tl1]], cm.comment_location
50+
# Both comments should appear in the rendered description
51+
assert_equal({ tl1 => [comment1, comment2] }, cm.comment_location)
5252
end
5353

5454
def test_add_comment_stopdoc
@@ -156,7 +156,7 @@ def test_from_module_comment
156156

157157
klass = RDoc::ClassModule.from_module RDoc::NormalClass, klass
158158

159-
assert_equal [['really a class', tl]], klass.comment_location
159+
assert_equal({ tl => ['really a class'] }, klass.comment_location)
160160
end
161161

162162
def test_marshal_dump
@@ -631,7 +631,7 @@ def test_search_snippet_after_marshal
631631
assert_match(/class comment/, snippet)
632632
end
633633

634-
def test_comment_location_is_array_after_marshal
634+
def test_comment_location_is_hash_after_marshal
635635
@store.path = Dir.tmpdir
636636
tl = @store.add_file 'file.rb'
637637

@@ -642,10 +642,11 @@ def test_comment_location_is_array_after_marshal
642642
loaded = Marshal.load Marshal.dump cm
643643
loaded.store = @store
644644

645-
assert_kind_of Array, loaded.comment_location
646-
assert_equal 1, loaded.comment_location.length
645+
assert_kind_of Hash, loaded.comment_location
646+
assert_equal 1, loaded.comment_location.size
647647

648-
comment, location = loaded.comment_location.first
648+
location, comments = loaded.comment_location.first
649+
comment = comments.first
649650
assert_kind_of RDoc::Markup::Document, comment
650651
# After marshal, location is the filename string (from doc.file)
651652
assert_equal tl.relative_name, location
@@ -668,7 +669,8 @@ def test_merge
668669
assert c1.current_section, 'original current_section'
669670
assert c2.current_section, 'merged current_section'
670671

671-
comment, location = c2.comment_location.first
672+
location, comments = c2.comment_location.first
673+
comment = comments.first
672674
assert_kind_of RDoc::Markup::Document, comment
673675
assert_equal tl.relative_name, location
674676
end
@@ -793,7 +795,7 @@ def test_merge_comment
793795
inner2 = @RM::Document.new @RM::Paragraph.new 'klass 2'
794796
inner2.file = 'two.rb'
795797

796-
expected = @RM::Document.new inner2, inner1
798+
expected = @RM::Document.new inner1, inner2
797799

798800
assert_equal expected, cm1.comment.parse
799801
end
@@ -1217,17 +1219,15 @@ def test_parse_comment_location
12171219
cm.add_comment 'comment 1', tl1
12181220
cm.add_comment 'comment 2', tl2
12191221

1220-
assert_kind_of Array, cm.comment_location
1221-
assert_equal 2, cm.comment_location.length
1222-
assert_equal 'comment 1', cm.comment_location[0][0]
1223-
assert_equal tl1, cm.comment_location[0][1]
1224-
assert_equal 'comment 2', cm.comment_location[1][0]
1225-
assert_equal tl2, cm.comment_location[1][1]
1222+
assert_kind_of Hash, cm.comment_location
1223+
assert_equal 2, cm.comment_location.size
1224+
assert_equal ['comment 1'], cm.comment_location[tl1]
1225+
assert_equal ['comment 2'], cm.comment_location[tl2]
12261226

12271227
cm = Marshal.load Marshal.dump cm
12281228

1229-
# After marshal, comment_location should still be an array
1230-
assert_kind_of Array, cm.comment_location
1229+
# After marshal, comment_location should still be a hash
1230+
assert_kind_of Hash, cm.comment_location
12311231

12321232
# parse() produces a Document with parts for each comment
12331233
parsed = cm.parse(cm.comment_location)

test/rdoc/parser/c_test.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,8 +318,7 @@ def test_do_classes_duplicate_class
318318

319319
klass = util_get_class content, 'cFoo'
320320
assert_equal 1, klass.comment_location.size
321-
first = klass.comment_location.first
322-
first_comment = first[0]
321+
first_comment = klass.comment_location.each_value.first&.first
323322
assert_equal 'first', first_comment.text
324323
end
325324

test/rdoc/parser/prism_ruby_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1939,7 +1939,7 @@ module Foo
19391939
RDoc::Comment.new('comment b', @top_level)
19401940
]
19411941

1942-
assert_equal expected, mod.comment_location.map { |c, _l| c }
1942+
assert_equal expected, mod.comment_location[@top_level]
19431943
end
19441944

19451945
def test_enddoc

test/rdoc/parser/ruby_test.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -801,9 +801,7 @@ def test_parse_class_in_a_file_repeatedly
801801

802802
foo = @top_level.classes.first
803803
assert_equal 'Foo', foo.full_name
804-
assert_equal [[comment_a, @top_level],
805-
[comment_b, @top_level],
806-
[comment_c, @top_level]], foo.comment_location
804+
assert_equal({ @top_level => [comment_a, comment_b, comment_c] }, foo.comment_location)
807805
assert_equal [@top_level], foo.in_files
808806
assert_equal 1, foo.line
809807
end
@@ -3722,7 +3720,7 @@ module Foo
37223720
RDoc::Comment.new('comment b', @top_level)
37233721
]
37243722

3725-
assert_equal expected, foo.comment_location.map { |c, l| c }
3723+
assert_equal expected, foo.comment_location[@top_level]
37263724
end
37273725

37283726
def test_scan_meta_method_block

test/rdoc/rdoc_store_test.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -924,12 +924,13 @@ def test_save_class_merge
924924

925925
loaded = s.load_class('Object')
926926

927-
# After loading, comment_location is an array (not a Document)
928-
assert_kind_of Array, loaded.comment_location
929-
assert_equal 1, loaded.comment_location.length
927+
# After loading, comment_location is a hash (not a Document)
928+
assert_kind_of Hash, loaded.comment_location
929+
assert_equal 1, loaded.comment_location.size
930930

931931
# Verify content is preserved
932-
comment, location = loaded.comment_location.first
932+
location, comments = loaded.comment_location.first
933+
comment = comments.first
933934
assert_kind_of @RM::Document, comment
934935
assert_equal 'new comment', comment.parts[0].text
935936
assert_equal @top_level.relative_name, location

0 commit comments

Comments
 (0)