diff --git a/lib/rdoc/markup/to_html.rb b/lib/rdoc/markup/to_html.rb
index 2924b89b94..6d9899000e 100644
--- a/lib/rdoc/markup/to_html.rb
+++ b/lib/rdoc/markup/to_html.rb
@@ -282,6 +282,7 @@ def start_accepting
@res = []
@in_list_entry = []
@list = []
+ @heading_ids = {}
end
##
@@ -412,8 +413,8 @@ def accept_blank_line(blank_line)
def accept_heading(heading)
level = [6, heading.level].min
- label = heading.label @code_object
- legacy_label = heading.legacy_label @code_object
+ label = deduplicate_heading_id(heading.label(@code_object))
+ legacy_label = deduplicate_heading_id(heading.legacy_label(@code_object))
# Add legacy anchor before the heading for backward compatibility.
# This allows old links with label- prefix to still work.
@@ -468,6 +469,20 @@ def accept_table(header, body, aligns)
# :section: Utilities
+ ##
+ # Returns a unique heading ID, appending -1, -2, etc. for duplicates.
+ # Matches GitHub's behavior for duplicate heading anchors.
+
+ def deduplicate_heading_id(id)
+ if @heading_ids.key?(id)
+ @heading_ids[id] += 1
+ "#{id}-#{@heading_ids[id]}"
+ else
+ @heading_ids[id] = 0
+ id
+ end
+ end
+
##
# CGI-escapes +text+
diff --git a/lib/rdoc/markup/to_html_crossref.rb b/lib/rdoc/markup/to_html_crossref.rb
index fad87a5802..089f7fea8f 100644
--- a/lib/rdoc/markup/to_html_crossref.rb
+++ b/lib/rdoc/markup/to_html_crossref.rb
@@ -61,8 +61,11 @@ def cross_reference(name, text = nil, code = true, rdoc_ref: false)
name = name[1..-1] unless @show_hash if name[0, 1] == '#'
- if !(name.end_with?('+@', '-@')) and name =~ /(.*[^#:])?@/
- text ||= [CGI.unescape($'), (" at #{$1}" if $~.begin(1))].join("")
+ if !name.end_with?('+@', '-@') && match = name.match(/(.*[^#:])?@(.*)/)
+ context_name = match[1]
+ label = RDoc::Text.decode_legacy_label(match[2])
+ text ||= "#{label} at #{context_name}" if context_name
+ text ||= label
code = false
else
text ||= name
@@ -168,9 +171,10 @@ def link(name, text, code = true, rdoc_ref: false)
end
if label
- # Convert label to GitHub-style anchor format
- # First convert + to space (URL encoding), then apply GitHub-style rules
- formatted_label = RDoc::Text.to_anchor(label.tr('+', ' '))
+ # Decode legacy labels (e.g., "What-27s+Here" -> "What's Here")
+ # then convert to GitHub-style anchor format
+ decoded_label = RDoc::Text.decode_legacy_label(label)
+ formatted_label = RDoc::Text.to_anchor(decoded_label)
# Case 1: Path already has an anchor (e.g., method link)
# Input: C1#method@label -> path="C1.html#method-i-m"
@@ -181,7 +185,7 @@ def link(name, text, code = true, rdoc_ref: false)
# Case 2: Label matches a section title
# Input: C1@Section -> path="C1.html", section "Section" exists
# Output: C1.html#section (uses section.aref for GitHub-style)
- elsif (section = ref&.sections&.find { |s| label.tr('+', ' ') == s.title })
+ elsif (section = ref&.sections&.find { |s| decoded_label == s.title })
path << "##{section.aref}"
# Case 3: Ref has an aref (class/module context)
diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb
index b0d446748a..1ae1fa199a 100644
--- a/lib/rdoc/parser/prism_ruby.rb
+++ b/lib/rdoc/parser/prism_ruby.rb
@@ -16,7 +16,7 @@ class RDoc::Parser::PrismRuby < RDoc::Parser
parse_files_matching(/\.rbw?$/) if ENV['RDOC_USE_PRISM_PARSER']
attr_accessor :visibility
- attr_reader :container, :singleton
+ attr_reader :container, :singleton, :in_proc_block
def initialize(top_level, content, options, stats)
super
@@ -43,9 +43,10 @@ def initialize(top_level, content, options, stats)
# example: `Module.new { include M }` `M.module_eval { include N }`
def with_in_proc_block
+ in_proc_block = @in_proc_block
@in_proc_block = true
yield
- @in_proc_block = false
+ @in_proc_block = in_proc_block
end
# Dive into another container
@@ -480,7 +481,6 @@ def add_attributes(names, rw, line_no)
# Adds includes/extends. Module name is resolved to full before adding.
def add_includes_extends(names, rdoc_class, line_no) # :nodoc:
- return if @in_proc_block
comment, directives = consecutive_comment(line_no)
handle_code_object_directives(@container, directives) if directives
names.each do |name|
@@ -508,8 +508,6 @@ def add_extends(names, line_no) # :nodoc:
# Adds a method defined by `def` syntax
def add_method(method_name, receiver_name:, receiver_fallback_type:, visibility:, singleton:, params:, calls_super:, block_params:, tokens:, start_line:, args_end_line:, end_line:)
- return if @in_proc_block
-
receiver = receiver_name ? find_or_create_module_path(receiver_name, receiver_fallback_type) : @container
comment, directives = consecutive_comment(start_line)
handle_code_object_directives(@container, directives) if directives
@@ -745,11 +743,14 @@ def visit_call_node(node)
when :extend
_visit_call_extend(node)
when :public
- _visit_call_public_private_protected(node, :public) { super }
+ super
+ _visit_call_public_private_protected(node, :public)
when :private
- _visit_call_public_private_protected(node, :private) { super }
+ super
+ _visit_call_public_private_protected(node, :private)
when :protected
- _visit_call_public_private_protected(node, :protected) { super }
+ super
+ _visit_call_public_private_protected(node, :protected)
when :private_constant
_visit_call_private_constant(node)
when :public_constant
@@ -759,11 +760,14 @@ def visit_call_node(node)
when :alias_method
_visit_call_alias_method(node)
when :module_function
- _visit_call_module_function(node) { super }
+ super
+ _visit_call_module_function(node)
when :public_class_method
- _visit_call_public_private_class_method(node, :public) { super }
+ super
+ _visit_call_public_private_class_method(node, :public)
when :private_class_method
- _visit_call_public_private_class_method(node, :private) { super }
+ super
+ _visit_call_public_private_class_method(node, :private)
else
super
end
@@ -774,12 +778,14 @@ def visit_call_node(node)
def visit_block_node(node)
@scanner.with_in_proc_block do
- # include, extend and method definition inside block are not documentable
+ # include, extend and method definition inside block are not documentable.
+ # visibility methods and attribute definition methods should be ignored inside block.
super
end
end
def visit_alias_method_node(node)
+ return if @scanner.in_proc_block
@scanner.process_comments_until(node.location.start_line - 1)
return unless node.old_name.is_a?(Prism::SymbolNode) && node.new_name.is_a?(Prism::SymbolNode)
@scanner.add_alias_method(node.old_name.value.to_s, node.new_name.value.to_s, node.location.start_line)
@@ -858,6 +864,8 @@ def visit_def_node(node)
end_line = node.location.end_line
@scanner.process_comments_until(start_line - 1)
+ return if @scanner.in_proc_block
+
case node.receiver
when Prism::NilNode, Prism::TrueNode, Prism::FalseNode
visibility = :public
@@ -995,37 +1003,39 @@ def _visit_call_require(call_node)
end
def _visit_call_module_function(call_node)
- yield
- return if @scanner.singleton
+ return if @scanner.in_proc_block || @scanner.singleton
names = visibility_method_arguments(call_node, singleton: false)&.map(&:to_s)
@scanner.change_method_to_module_function(names) if names
end
def _visit_call_public_private_class_method(call_node, visibility)
- yield
- return if @scanner.singleton
+ return if @scanner.in_proc_block || @scanner.singleton
names = visibility_method_arguments(call_node, singleton: true)
@scanner.change_method_visibility(names, visibility, singleton: true) if names
end
def _visit_call_public_private_protected(call_node, visibility)
+ return if @scanner.in_proc_block
arguments_node = call_node.arguments
if arguments_node.nil? # `public` `private`
@scanner.visibility = visibility
else # `public :foo, :bar`, `private def foo; end`
- yield
names = visibility_method_arguments(call_node, singleton: false)
@scanner.change_method_visibility(names, visibility) if names
end
end
def _visit_call_alias_method(call_node)
+ return if @scanner.in_proc_block
+
new_name, old_name, *rest = symbol_arguments(call_node)
return unless old_name && new_name && rest.empty?
@scanner.add_alias_method(old_name.to_s, new_name.to_s, call_node.location.start_line)
end
def _visit_call_include(call_node)
+ return if @scanner.in_proc_block
+
names = constant_arguments_names(call_node)
line_no = call_node.location.start_line
return unless names
@@ -1038,26 +1048,30 @@ def _visit_call_include(call_node)
end
def _visit_call_extend(call_node)
+ return if @scanner.in_proc_block
+
names = constant_arguments_names(call_node)
@scanner.add_extends(names, call_node.location.start_line) if names && !@scanner.singleton
end
def _visit_call_public_constant(call_node)
- return if @scanner.singleton
+ return if @scanner.in_proc_block || @scanner.singleton
names = symbol_arguments(call_node)
@scanner.container.set_constant_visibility_for(names.map(&:to_s), :public) if names
end
def _visit_call_private_constant(call_node)
- return if @scanner.singleton
+ return if @scanner.in_proc_block || @scanner.singleton
names = symbol_arguments(call_node)
@scanner.container.set_constant_visibility_for(names.map(&:to_s), :private) if names
end
def _visit_call_attr_reader_writer_accessor(call_node, rw)
+ return if @scanner.in_proc_block
names = symbol_arguments(call_node)
@scanner.add_attributes(names.map(&:to_s), rw, call_node.location.start_line) if names
end
+
class MethodSignatureVisitor < Prism::Visitor # :nodoc:
class << self
def scan_signature(def_node)
diff --git a/lib/rdoc/text.rb b/lib/rdoc/text.rb
index 94c84037c8..a7cca6e38d 100644
--- a/lib/rdoc/text.rb
+++ b/lib/rdoc/text.rb
@@ -335,4 +335,27 @@ def wrap(txt, line_len = 76)
text.downcase.gsub(/[^a-z0-9 \-]/, '').gsub(' ', '-')
end
+ ##
+ # Decodes a label that may be in legacy RDoc format where CGI.escape was
+ # applied and then '%' was replaced with '-'. Converts '+' to space,
+ # then reverses -XX hex encoding for non-alphanumeric characters.
+ #
+ # Labels in new format pass through unchanged because -XX patterns that
+ # decode to alphanumeric characters are left as-is (CGI.escape never
+ # encodes alphanumerics).
+ #
+ # Examples:
+ # "What-27s+Here" -> "What's Here" (legacy: -27 is apostrophe)
+ # "Foo-3A-3ABar" -> "Foo::Bar" (legacy: -3A is colon)
+ # "Whats-Here" -> "Whats-Here" (new format, unchanged)
+
+ module_function def decode_legacy_label(label)
+ label = label.tr('+', ' ')
+ label.gsub!(/-([0-7][0-9A-F])/) do
+ char = [$1.hex].pack('C')
+ char.match?(/[a-zA-Z0-9]/) ? $& : char
+ end
+ label
+ end
+
end
diff --git a/test/rdoc/markup/to_html_crossref_test.rb b/test/rdoc/markup/to_html_crossref_test.rb
index ce69aa50db..15a1d26913 100644
--- a/test/rdoc/markup/to_html_crossref_test.rb
+++ b/test/rdoc/markup/to_html_crossref_test.rb
@@ -111,6 +111,23 @@ def test_convert_CROSSREF_section_with_spaces
assert_equal para("Public Methods at C1"), result
end
+ def test_convert_CROSSREF_legacy_label
+ result = @to.convert 'C1@What-27s+Here'
+ assert_equal para("What\u2019s Here at C1"), result
+ end
+
+ def test_convert_CROSSREF_legacy_label_colon
+ result = @to.convert 'C1@Foo-3A-3ABar'
+ assert_equal para("Foo::Bar at C1"), result
+ end
+
+ def test_convert_CROSSREF_legacy_section
+ @c1.add_section "What's Here"
+
+ result = @to.convert "C1@What-27s+Here"
+ assert_equal para("What\u2019s Here at C1"), result
+ end
+
def test_convert_CROSSREF_constant
result = @to.convert 'C1::CONST'
diff --git a/test/rdoc/markup/to_html_test.rb b/test/rdoc/markup/to_html_test.rb
index bb57e78e86..4e9ac38176 100644
--- a/test/rdoc/markup/to_html_test.rb
+++ b/test/rdoc/markup/to_html_test.rb
@@ -360,6 +360,56 @@ def test_accept_heading_pipe
assert_equal "\n