From d9383bac8a6f0a0b6cea117cb51e586989ed7224 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 9 Apr 2026 16:43:20 +0100 Subject: [PATCH] Preserve `#` prefix for unresolved cross-references When `#name` doesn't resolve to a method, the cross-reference handler was stripping the `#` and returning just the name. Now the original text including `#` is restored when the lookup fails. This fixes rendering of text like `#no-space-heading` in Markdown paragraphs, where the `#` was silently dropped in the final HTML. Also refactors `cross_reference` and `link`: - `cross_reference` no longer mutates its `name` parameter; uses a separate `display` variable for `#`-stripped text - `link` returns `nil` for unresolved references instead of returning the bare text, letting the caller decide what to display - Label handling is hoisted out of the `case` branches so it's shared between resolved refs and bare label references (`@foo`) - Unresolved refs with caller-provided text (e.g. tidy links) now preserve the explicit text instead of falling back to the raw name - Move new test methods before `private` helper section to follow file convention --- lib/rdoc/markup/to_html_crossref.rb | 90 ++++++++++++----------- test/rdoc/markup/to_html_crossref_test.rb | 16 +++- 2 files changed, 64 insertions(+), 42 deletions(-) diff --git a/lib/rdoc/markup/to_html_crossref.rb b/lib/rdoc/markup/to_html_crossref.rb index 106810072b..a0e218b657 100644 --- a/lib/rdoc/markup/to_html_crossref.rb +++ b/lib/rdoc/markup/to_html_crossref.rb @@ -59,21 +59,24 @@ def init_link_notation_regexp_handlings # given it is used as the link text, otherwise +name+ is used. def cross_reference(name, text = nil, code = true, rdoc_ref: false) - lookup = name + # What to show when the reference doesn't resolve to a link: + # caller-provided text if any, otherwise the original name (preserving '#'). + fallback = text || name - name = name[1..-1] unless @show_hash if name[0, 1] == '#' + # Strip '#' for link display text (e.g. #method shows as "method" in links) + display = !@show_hash && name.start_with?('#') ? name[1..] : name - if !name.end_with?('+@', '-@') && match = name.match(/(.*[^#:])?@(.*)/) + if !display.end_with?('+@', '-@') && match = display.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 + text ||= display end - link lookup, text, code, rdoc_ref: rdoc_ref + link(name, text, code, rdoc_ref: rdoc_ref) || fallback end ## @@ -150,6 +153,7 @@ def gen_url(url, text) ## # Creates an HTML link to +name+ with the given +text+. + # Returns the link HTML string, or +nil+ if the reference could not be resolved. def link(name, text, code = true, rdoc_ref: false) if !(name.end_with?('+@', '-@')) and name =~ /(.*[^#:])?@/ @@ -162,56 +166,60 @@ def link(name, text, code = true, rdoc_ref: false) # Non-text source files (C, Ruby, etc.) don't get HTML pages generated, # so don't auto-link to them. Explicit rdoc-ref: links are still allowed. if !rdoc_ref && RDoc::TopLevel === ref && !ref.text? - return text + return end case ref - when String then + when String if rdoc_ref && @warn_missing_rdoc_ref puts "#{@from_path}: `rdoc-ref:#{name}` can't be resolved for `#{text}`" end - ref + return + when nil + # A bare label reference like @foo still produces a valid anchor link + return unless label + path = +"" else - path = ref ? ref.as_href(@from_path) : +"" + path = ref.as_href(@from_path) if code and RDoc::CodeObject === ref and !(RDoc::TopLevel === ref) text = "#{CGI.escapeHTML text}" end + end - if label - # 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" - # Output: C1.html#method-i-m-label - if path =~ /#/ - path << "-#{formatted_label}" - - # 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| decoded_label == s.title }) - path << "##{section.aref}" - - # Case 3: Ref has an aref (class/module context) - # Input: C1@heading -> path="C1.html", ref=C1 class - # Output: C1.html#class-c1-heading - elsif ref.respond_to?(:aref) - path << "##{ref.aref}-#{formatted_label}" - - # Case 4: No context, just the label (e.g., TopLevel/file) - # Input: README@section -> path="README_md.html" - # Output: README_md.html#section - else - path << "##{formatted_label}" - end + if label + # 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" + # Output: C1.html#method-i-m-label + if path =~ /#/ + path << "-#{formatted_label}" + + # 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| decoded_label == s.title }) + path << "##{section.aref}" + + # Case 3: Ref has an aref (class/module context) + # Input: C1@heading -> path="C1.html", ref=C1 class + # Output: C1.html#class-c1-heading + elsif ref.respond_to?(:aref) + path << "##{ref.aref}-#{formatted_label}" + + # Case 4: No context, just the label (e.g., TopLevel/file) + # Input: README@section -> path="README_md.html" + # Output: README_md.html#section + else + path << "##{formatted_label}" end - - "#{text}" end + + "#{text}" end def handle_TT(code) diff --git a/test/rdoc/markup/to_html_crossref_test.rb b/test/rdoc/markup/to_html_crossref_test.rb index ff243fc9e0..24e0c97e86 100644 --- a/test/rdoc/markup/to_html_crossref_test.rb +++ b/test/rdoc/markup/to_html_crossref_test.rb @@ -407,7 +407,7 @@ def test_convert_RDOCLINK_rdoc_ref_c_file_linked end def test_link - assert_equal 'n', @to.link('n', 'n') + assert_nil @to.link('n', 'n') assert_equal 'm', @to.link('m', 'm') end @@ -423,6 +423,20 @@ def test_link_class_method_full @to.link('Parent::m', 'Parent::m') end + def test_handle_regexp_CROSSREF_hash_preserved_for_unresolved + @to.show_hash = false + + # #no should not lose its '#' when it doesn't resolve to a method + assert_equal "#no", REGEXP_HANDLING('#no') + end + + def test_cross_reference_preserves_explicit_text_for_unresolved + # When explicit text is provided, it should be preserved on unresolved refs + assert_equal "Foo", @to.cross_reference("Missing", "Foo") + end + + private + def para(text) "\n

#{text}

\n" end