Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions lib/rdoc/markup/to_html.rb
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ def start_accepting
@res = []
@in_list_entry = []
@list = []
@heading_ids = {}
end

##
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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+

Expand Down
16 changes: 10 additions & 6 deletions lib/rdoc/markup/to_html_crossref.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>#{$1}</code>" 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 <code>#{context_name}</code>" if context_name
text ||= label
code = false
else
text ||= name
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down
52 changes: 33 additions & 19 deletions lib/rdoc/parser/prism_ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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|
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions lib/rdoc/text.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions test/rdoc/markup/to_html_crossref_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@ def test_convert_CROSSREF_section_with_spaces
assert_equal para("<a href=\"C1.html#public-methods\">Public Methods at <code>C1</code></a>"), result
end

def test_convert_CROSSREF_legacy_label
result = @to.convert 'C1@What-27s+Here'
assert_equal para("<a href=\"C1.html#class-c1-whats-here\">What\u2019s Here at <code>C1</code></a>"), result
end

def test_convert_CROSSREF_legacy_label_colon
result = @to.convert 'C1@Foo-3A-3ABar'
assert_equal para("<a href=\"C1.html#class-c1-foobar\">Foo::Bar at <code>C1</code></a>"), result
end

def test_convert_CROSSREF_legacy_section
@c1.add_section "What's Here"

result = @to.convert "C1@What-27s+Here"
assert_equal para("<a href=\"C1.html#whats-here\">What\u2019s Here at <code>C1</code></a>"), result
end

def test_convert_CROSSREF_constant
result = @to.convert 'C1::CONST'

Expand Down
50 changes: 50 additions & 0 deletions test/rdoc/markup/to_html_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,56 @@ def test_accept_heading_pipe
assert_equal "\n<h1 id=\"hello\">Hello</h1>\n", @to.res.join
end

def test_accept_heading_duplicate
@to.start_accepting

@to.accept_heading @RM::Heading.new(2, 'Hello')
@to.accept_heading @RM::Heading.new(2, 'Hello')

result = @to.res.join
assert_match(/<h2 id="hello">/, result)
assert_match(/<h2 id="hello-1">/, result)
assert_match(/id="label-Hello" class="legacy-anchor"/, result)
assert_match(/id="label-Hello-1" class="legacy-anchor"/, result)
end

def test_accept_heading_duplicate_punctuation_collision
@to.start_accepting

@to.accept_heading @RM::Heading.new(2, 'Method match')
@to.accept_heading @RM::Heading.new(2, 'Method match?')

result = @to.res.join
assert_match(/<h2 id="method-match">/, result)
assert_match(/<h2 id="method-match-1">/, result)
end

def test_accept_heading_three_duplicates
@to.start_accepting

@to.accept_heading @RM::Heading.new(2, 'Hello')
@to.accept_heading @RM::Heading.new(2, 'Hello')
@to.accept_heading @RM::Heading.new(2, 'Hello')

result = @to.res.join
assert_match(/<h2 id="hello">/, result)
assert_match(/<h2 id="hello-1">/, result)
assert_match(/<h2 id="hello-2">/, result)
end

def test_accept_heading_dedup_resets_on_start_accepting
@to.start_accepting
@to.accept_heading @RM::Heading.new(2, 'Hello')
@to.accept_heading @RM::Heading.new(2, 'Hello')

@to.start_accepting
@to.accept_heading @RM::Heading.new(2, 'Hello')

result = @to.res.join
assert_match(/<h2 id="hello">/, result)
refute_match(/id="hello-1"/, result)
end

def test_accept_paragraph_newline
hellos = ["hello", "\u{393 3b5 3b9 3ac} \u{3c3 3bf 3c5}"]
worlds = ["world", "\u{3ba 3cc 3c3 3bc 3bf 3c2}"]
Expand Down
Loading
Loading