Skip to content

Commit 28b884f

Browse files
committed
Refactor comment_location from Array to Hash
Convert ClassModule#comment_location from an Array of [comment, location] pairs to a Hash of { location => comment }. Ruby hashes preserve insertion order, and replacing an existing key preserves its position, which naturally fixes the comment reordering bug during server re-parse without needing empty placeholder workarounds. Key changes: - add_comment simplifies to a single hash assignment - clear_file_contributions gains keep_position: keyword for server re-parse - C parser's delete_if special case is no longer needed (hash replaces) - Same-file duplicate comments are naturally deduplicated - Marshal format is unchanged (serialized via parse() as before)
1 parent 6426b43 commit 28b884f

9 files changed

Lines changed: 156 additions & 105 deletions

File tree

lib/rdoc/code_object/class_module.rb

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,19 @@ 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 => comment }</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. Re-assigning a key preserves its position.
38+
#
3639
# Before marshalling:
37-
# - +comment+ is a String
3840
# - +location+ is an RDoc::TopLevel
41+
# - +comment+ is a String
3942
#
4043
# After unmarshalling:
41-
# - +comment+ is an RDoc::Markup::Document
4244
# - +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
45+
# - +comment+ is an RDoc::Markup::Document
4946

5047
attr_accessor :comment_location
5148

@@ -63,7 +60,7 @@ class RDoc::ClassModule < RDoc::Context
6360
def self.from_module(class_type, mod)
6461
klass = class_type.new mod.name
6562

66-
mod.comment_location.each do |comment, location|
63+
mod.comment_location.each do |location, comment|
6764
klass.add_comment comment, location
6865
end
6966

@@ -125,7 +122,7 @@ def initialize(name, superclass = nil)
125122
@is_alias_for = nil
126123
@name = name
127124
@superclass = superclass
128-
@comment_location = [] # Array of [comment, location] pairs
125+
@comment_location = {} # Hash of { location => comment }
129126

130127
super()
131128
end
@@ -147,11 +144,7 @@ def add_comment(comment, location)
147144
normalize_comment comment
148145
end
149146

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

156149
self.comment = original
157150
end
@@ -270,7 +263,7 @@ def document_self_or_methods
270263
def documented?
271264
return true if @received_nodoc
272265
return false if @comment_location.empty?
273-
@comment_location.any? { |comment, _| not comment.empty? }
266+
@comment_location.each_value.any? { |comment| not comment.empty? }
274267
end
275268

276269
##
@@ -408,13 +401,7 @@ def marshal_load(array) # :nodoc:
408401
@superclass = array[3]
409402
document = array[4]
410403

411-
@comment = RDoc::Comment.from_document document
412-
413-
@comment_location = if document.parts.first.is_a?(RDoc::Markup::Document)
414-
document.parts.map { |doc| [doc, doc.file] }
415-
else
416-
[[document, document.file]]
417-
end
404+
load_comment_from_document(document)
418405

419406
array[5].each do |name, rw, visibility, singleton, file|
420407
singleton ||= false
@@ -492,13 +479,7 @@ def merge(class_module)
492479

493480
document = document.merge other_document
494481

495-
@comment = RDoc::Comment.from_document(document)
496-
497-
@comment_location = if document.parts.first.is_a?(RDoc::Markup::Document)
498-
document.parts.map { |doc| [doc, doc.file] }
499-
else
500-
[[document, document.file]]
501-
end
482+
load_comment_from_document(document)
502483
end
503484

504485
cm = class_module
@@ -643,8 +624,8 @@ def parse(comment_location)
643624
case comment_location
644625
when String then
645626
super
646-
when Array then
647-
docs = comment_location.map do |comment, location|
627+
when Hash then
628+
docs = comment_location.map do |location, comment|
648629
doc = super comment
649630
doc.file = location
650631
doc
@@ -745,12 +726,22 @@ def search_record
745726
# Returns an HTML snippet of the first comment for search results.
746727

747728
def search_snippet
748-
first_comment = @comment_location.first&.first
729+
first_comment = @comment_location.each_value.first
749730
return '' unless first_comment && !first_comment.empty?
750731

751732
snippet(first_comment)
752733
end
753734

735+
##
736+
# Rebuilds +@comment+ from the current +@comment_location+ entries,
737+
# skipping any empty placeholders.
738+
739+
def rebuild_comment_from_location
740+
texts = @comment_location.each_value.filter_map { |c| c.to_s unless c.empty? }
741+
merged = texts.join("\n---\n")
742+
@comment = merged.empty? ? '' : RDoc::Comment.new(merged)
743+
end
744+
754745
##
755746
# Sets the store for this class or module and its contained code objects.
756747

@@ -926,6 +917,15 @@ def embed_mixins
926917

927918
private
928919

920+
def load_comment_from_document(document) # :nodoc:
921+
@comment = RDoc::Comment.from_document(document)
922+
@comment_location = if document.parts.first.is_a?(RDoc::Markup::Document)
923+
document.parts.to_h { |doc| [doc.file, doc] }
924+
else
925+
{ document.file => document }
926+
end
927+
end
928+
929929
def prepare_to_embed(code_object, singleton=false)
930930
code_object = code_object.dup
931931
code_object.mixin_from = code_object.parent

lib/rdoc/i18n/text.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# * Extracts translation messages from wrapped raw text.
88
# * Translates wrapped raw text in specified locale.
99
#
10-
# Wrapped raw text is one of String, RDoc::Comment or Array of them.
10+
# Wrapped raw text is one of String, RDoc::Comment, Hash, or Array of them.
1111

1212
class RDoc::I18n::Text
1313

@@ -89,8 +89,8 @@ 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|
92+
when Hash
93+
raw.each_value do |comment|
9494
each_line(comment, &block)
9595
end
9696
else

lib/rdoc/server.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ def reparse_and_refresh(changed_files, removed_files)
356356
changed_files.each do |f|
357357
begin
358358
relative = @rdoc.relative_path_for(f)
359-
@store.clear_file_contributions(relative)
359+
@store.clear_file_contributions(relative, keep_position: true)
360360
@rdoc.parse_file(f)
361361
@file_mtimes[f] = File.mtime(f) rescue nil
362362
rescue => e

lib/rdoc/store.rb

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def remove_file(relative_name)
220220
# prevents duplication when the file is re-parsed while preserving
221221
# shared namespaces like +RDoc+ that span many files.
222222

223-
def clear_file_contributions(relative_name)
223+
def clear_file_contributions(relative_name, keep_position: false)
224224
top_level = @files_hash[relative_name]
225225
return unless top_level
226226

@@ -245,30 +245,34 @@ def clear_file_contributions(relative_name)
245245
cm.aliases.reject! { |a| a.file == top_level }
246246
cm.external_aliases.reject! { |a| a.file == top_level }
247247

248-
# Remove comment entries from this file and rebuild the comment
248+
# Clear or remove comment entries from this file
249249
if cm.is_a?(RDoc::ClassModule)
250-
cm.comment_location.reject! { |(_, loc)| loc == top_level }
251-
texts = cm.comment_location.map { |(c, _)| c.to_s }
252-
merged = texts.join("\n---\n")
253-
cm.instance_variable_set(:@comment,
254-
merged.empty? ? '' : RDoc::Comment.new(merged))
250+
if keep_position
251+
# Set to empty comment; hash preserves key position for re-parse
252+
cm.comment_location[top_level] = RDoc::Comment.new('') if cm.comment_location.key?(top_level)
253+
else
254+
cm.comment_location.delete(top_level)
255+
end
256+
cm.rebuild_comment_from_location
255257
end
256258

257-
# Remove this file from the class/module's file list
258-
cm.in_files.delete(top_level)
259-
260-
# If no files contribute to this class/module anymore, remove it
261-
# from the store entirely. This handles file deletion correctly
262-
# for classes that are only defined in the deleted file, while
263-
# preserving classes that span multiple files.
264-
if cm.in_files.empty?
265-
if cm.is_a?(RDoc::NormalModule)
266-
@modules_hash.delete(cm.full_name)
267-
else
268-
@classes_hash.delete(cm.full_name)
259+
unless keep_position
260+
# Remove this file from the class/module's file list
261+
cm.in_files.delete(top_level)
262+
263+
# If no files contribute to this class/module anymore, remove it
264+
# from the store entirely. This handles file deletion correctly
265+
# for classes that are only defined in the deleted file, while
266+
# preserving classes that span multiple files.
267+
if cm.in_files.empty?
268+
if cm.is_a?(RDoc::NormalModule)
269+
@modules_hash.delete(cm.full_name)
270+
else
271+
@classes_hash.delete(cm.full_name)
272+
end
273+
cm.parent&.classes_hash&.delete(cm.name)
274+
cm.parent&.modules_hash&.delete(cm.name)
269275
end
270-
cm.parent&.classes_hash&.delete(cm.name)
271-
cm.parent&.modules_hash&.delete(cm.name)
272276
end
273277
end
274278

test/rdoc/code_object/class_module_test.rb

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,20 @@ 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, tl2 => comment_tl2,
28+
tl3 => comment_tl3 }, cm.comment_location)
3029
assert_equal "comment 1\n---\ncomment 2\n---\n* comment 3", cm.comment
3130
end
3231

@@ -47,8 +46,27 @@ def test_add_comment_duplicate
4746
cm.add_comment comment1, tl1
4847
cm.add_comment comment2, tl1
4948

50-
assert_equal [[comment1, tl1],
51-
[comment2, tl1]], cm.comment_location
49+
# Hash replaces in-place for the same location
50+
assert_equal({ tl1 => comment2 }, cm.comment_location)
51+
end
52+
53+
def test_add_comment_preserves_order_on_replace
54+
tl1 = @store.add_file 'one.rb'
55+
tl2 = @store.add_file 'two.rb'
56+
57+
cm = RDoc::ClassModule.new 'Klass'
58+
cm.add_comment 'comment from one', tl1
59+
cm.add_comment 'comment from two', tl2
60+
61+
# Simulate keep_position clearing: set tl1's comment to empty
62+
cm.comment_location[tl1] = RDoc::Comment.new('')
63+
64+
# Re-adding a comment for tl1 replaces in-place (hash preserves key order)
65+
cm.add_comment 'updated comment from one', tl1
66+
67+
assert_equal 2, cm.comment_location.size
68+
assert_equal [tl1, tl2], cm.comment_location.keys
69+
assert_equal 'updated comment from one', cm.comment_location[tl1].to_s
5270
end
5371

5472
def test_add_comment_stopdoc
@@ -156,7 +174,7 @@ def test_from_module_comment
156174

157175
klass = RDoc::ClassModule.from_module RDoc::NormalClass, klass
158176

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

162180
def test_marshal_dump
@@ -631,7 +649,7 @@ def test_search_snippet_after_marshal
631649
assert_match(/class comment/, snippet)
632650
end
633651

634-
def test_comment_location_is_array_after_marshal
652+
def test_comment_location_is_hash_after_marshal
635653
@store.path = Dir.tmpdir
636654
tl = @store.add_file 'file.rb'
637655

@@ -642,10 +660,10 @@ def test_comment_location_is_array_after_marshal
642660
loaded = Marshal.load Marshal.dump cm
643661
loaded.store = @store
644662

645-
assert_kind_of Array, loaded.comment_location
646-
assert_equal 1, loaded.comment_location.length
663+
assert_kind_of Hash, loaded.comment_location
664+
assert_equal 1, loaded.comment_location.size
647665

648-
comment, location = loaded.comment_location.first
666+
location, comment = loaded.comment_location.first
649667
assert_kind_of RDoc::Markup::Document, comment
650668
# After marshal, location is the filename string (from doc.file)
651669
assert_equal tl.relative_name, location
@@ -668,7 +686,7 @@ def test_merge
668686
assert c1.current_section, 'original current_section'
669687
assert c2.current_section, 'merged current_section'
670688

671-
comment, location = c2.comment_location.first
689+
location, comment = c2.comment_location.first
672690
assert_kind_of RDoc::Markup::Document, comment
673691
assert_equal tl.relative_name, location
674692
end
@@ -793,7 +811,10 @@ def test_merge_comment
793811
inner2 = @RM::Document.new @RM::Paragraph.new 'klass 2'
794812
inner2.file = 'two.rb'
795813

796-
expected = @RM::Document.new inner2, inner1
814+
# With hash-based comment_location, one.rb comes first (its key was
815+
# inserted first), then two.rb. Document.merge preserves this order
816+
# since both self and other have the same files.
817+
expected = @RM::Document.new inner1, inner2
797818

798819
assert_equal expected, cm1.comment.parse
799820
end
@@ -1217,17 +1238,15 @@ def test_parse_comment_location
12171238
cm.add_comment 'comment 1', tl1
12181239
cm.add_comment 'comment 2', tl2
12191240

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]
1241+
assert_kind_of Hash, cm.comment_location
1242+
assert_equal 2, cm.comment_location.size
1243+
assert_equal 'comment 1', cm.comment_location[tl1]
1244+
assert_equal 'comment 2', cm.comment_location[tl2]
12261245

12271246
cm = Marshal.load Marshal.dump cm
12281247

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

12321251
# parse() produces a Document with parts for each comment
12331252
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
323322
assert_equal 'first', first_comment.text
324323
end
325324

0 commit comments

Comments
 (0)