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

Hello

\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(/

/, result) + assert_match(/

/, 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(/

/, result) + assert_match(/

/, 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(/

/, result) + assert_match(/

/, result) + assert_match(/

/, 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(/

/, 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}"] diff --git a/test/rdoc/markup/to_label_test.rb b/test/rdoc/markup/to_label_test.rb index 53a27103f6..f63af59c8a 100644 --- a/test/rdoc/markup/to_label_test.rb +++ b/test/rdoc/markup/to_label_test.rb @@ -111,4 +111,37 @@ def test_convert_tt assert_equal 'tt', @to.convert('tt') end + def test_decode_legacy_label + # [input, expected] pairs grouped by behavior: + # + # Legacy encoded characters are decoded + [ + ["What-27s+Here", "What's Here"], # -27 = apostrophe + ["Foo-3A-3ABar", "Foo::Bar"], # -3A = colon + ["a-2Bb", "a+b"], # -2B = plus sign + ["foo+-25W+bar", "foo %W bar"], # -25 = percent, + = space + ["foo+bar", "foo bar"], # + = space + ["Whats-Here", "Whats-Here"], # New-format labels pass through unchanged + # -4F matches the regex (first digit 0-7) but decodes to 'O' (alphanumeric), + # so the alphanumeric guard leaves it as literal + ["class-4Fther", "class-4Fther"], + # Lowercase hex patterns are not decoded (CGI.escape only produces uppercase) + ["a-3a-test", "a-3a-test"], + # -FE is outside ASCII range (0x00-0x7F), first digit must be 0-7 + ["x-FEy", "x-FEy"], + ].each do |input, expected| + assert_equal expected, RDoc::Text.decode_legacy_label(input), + "decode_legacy_label(#{input.inspect})" + end + end + + def test_decode_legacy_label_round_trip + # Verify that legacy-encoded labels produce the same anchor as direct conversion + ["What's Here", "Foo::Bar", "a + b", "Hello World"].each do |heading| + legacy = CGI.escape(heading).gsub('%', '-').sub(/^-/, '') + decoded = RDoc::Text.decode_legacy_label(legacy) + assert_equal RDoc::Text.to_anchor(heading), RDoc::Text.to_anchor(decoded), + "Round-trip failed for heading: #{heading.inspect}" + end + end end diff --git a/test/rdoc/parser/prism_ruby_test.rb b/test/rdoc/parser/prism_ruby_test.rb index fe21533191..d8e6cb0314 100644 --- a/test/rdoc/parser/prism_ruby_test.rb +++ b/test/rdoc/parser/prism_ruby_test.rb @@ -2020,9 +2020,10 @@ def test_include_extend_suppressed_within_block util_parser <<~RUBY module M; end module N; end - module O: end + module O; end class A metaprogramming do + tap do end include M extend N class B @@ -2045,6 +2046,98 @@ class B assert_equal ['N'], b.extends.map(&:name) end + def test_visibility_methods_suppressed_within_block + util_parser <<~RUBY + class A + def pub1; end + X = 1 + Y = 1 + def self.s_pub; end + private_class_method def self.s_pri; end + private_constant :Y + Module.new do + tap do end + private_method :pub1 + private_constant :X + public_constant :Y + private + private_class_method :s_pub + public_class_method :s_pri + end + def pub2; end + private + Module.new do + public + end + def pri; end + end + RUBY + klass = @store.find_class_named 'A' + + assert_equal :public, klass.find_constant_named('X').visibility + assert_equal :private, klass.find_constant_named('Y').visibility + assert_equal :public, klass.find_method_named('pub1').visibility + assert_equal :private, klass.find_method_named('pri').visibility + assert_equal :public, klass.find_method_named('pub2').visibility + assert_equal :public, klass.find_class_method_named('s_pub').visibility + assert_equal :private, klass.find_class_method_named('s_pri').visibility unless accept_legacy_bug? + end + + def test_alias_method_suppressed_within_block + omit if accept_legacy_bug? + + util_parser <<~RUBY + class A + def foo; end + Module.new do + tap do end + def foo; end + alias_method :bar2, :foo + alias bar3 foo + end + alias_method :foo2, :foo + alias foo3 foo + end + RUBY + klass = @store.find_class_named 'A' + assert_equal ['foo', 'foo2', 'foo3'], klass.method_list.map(&:name) + end + + def test_attr_method_suppressed_within_block + util_parser <<~RUBY + class A + attr_reader :r + attr_writer :w + attr_accessor :rw + Module.new do + tap do end + attr_reader :r2 + attr_writer :w2 + attr_accessor :rw2 + end + end + RUBY + klass = @store.find_class_named 'A' + assert_equal ['r', 'w', 'rw'], klass.attributes.map(&:name) + end + + def test_module_function_suppressed_within_block + util_parser <<~RUBY + module M + def foo; end + Module.new do + tap do end + def foo; end + module_function :foo + end + def bar; end + module_function :bar + end + RUBY + mod = @store.find_module_named 'M' + assert_equal ['bar'], mod.class_method_list.map(&:name) + end + def test_multibyte_method_name content = <<~RUBY class Foo