Repository: ruby/rdoc (Ruby)

Issue:
Markdown links with styled text are parsed incorrectly
Given 2 links:

```md
[Link to Foo](https://example.com)

[Link to `Foo`](https://example.com)
```

RDoc can't handle the 2nd case:

<img width="163" alt="Screenshot 2024-12-12 at 16 18 46" src="https://github.com/user-attachments/assets/a3c8cf4e-9c43-465e-849b-f24754a4514d" />

This happens to `**bold**` style too.



Discussion:
Once this is implemented, we can improve `doc/ruby/options.md` like so:

<details><summary><code>options_md_improvements.patch</code></summary>

````patch
From 0a7a8f7aa5ab9a20107b8808f2cada9aa4d9dfbe Mon Sep 17 00:00:00 2001
From: Alexander Momchilov <alexander.momchilov@shopify.com>
Date: Thu, 12 Dec 2024 12:18:14 -0500
Subject: [PATCH] Highlight param names as code

---
 doc/ruby/options.md | 74 ++++++++++++++++++++++-----------------------
 1 file changed, 37 insertions(+), 37 deletions(-)

diff --git a/doc/ruby/options.md b/doc/ruby/options.md
index 6e0278e143..2062fdafd5 100644
--- a/doc/ruby/options.md
+++ b/doc/ruby/options.md
@@ -61,15 +61,15 @@ nil
 
 See also:
 
-- {Option -a}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
+- {Option `-a`}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
   Split input lines into fields.
-- {Option -F}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
+- {Option `-F`}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
   \Set input field separator.
-- {Option -l}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
+- {Option `-l`}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
   \Set output record separator; chop lines.
-- {Option -n}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
+- {Option `-n`}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
   Run program in `gets` loop.
-- {Option -p}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
+- {Option `-p`}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
   `-n`, with printing.
 
 ### `-a`: Split Input Lines into Fields
@@ -91,15 +91,15 @@ and the default field separator  is `$;`.
 
 See also:
 
-- {Option -0}[rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
+- {Option `-0`}[rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
   \Set `$/` (input record separator).
-- {Option -F}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
+- {Option `-F`}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
   \Set input field separator.
-- {Option -l}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
+- {Option `-l`}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
   \Set output record separator; chop lines.
-- {Option -n}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
+- {Option `-n`}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
   Run program in `gets` loop.
-- {Option -p}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
+- {Option `-p`}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
   `-n`, with printing.
 
 ### `-c`: Check Syntax
@@ -186,9 +186,9 @@ Whitespace between the option and its argument may be omitted.
 
 See also:
 
-- {Option --external-encoding}[options_md.html#label--external-encoding-3A+Set+Default+External+Encoding]:
+- {Option `--external-encoding`}[options_md.html#label--external-encoding-3A+Set+Default+External+Encoding]:
   \Set default external encoding.
-- {Option --internal-encoding}[options_md.html#label--internal-encoding-3A+Set+Default+Internal+Encoding]:
+- {Option `--internal-encoding`}[options_md.html#label--internal-encoding-3A+Set+Default+Internal+Encoding]:
   \Set default internal encoding.
 
 Option `--encoding` is an alias for option `-E`.
@@ -221,15 +221,15 @@ The argument must immediately follow the option
 
 See also:
 
-- {Option -0}[rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
+- {Option `-0`}[rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
   \Set `$/` (input record separator).
-- {Option -a}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
+- {Option `-a`}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
   Split input lines into fields.
-- {Option -l}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
+- {Option `-l`}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
   \Set output record separator; chop lines.
-- {Option -n}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
+- {Option `-n`}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
   Run program in `gets` loop.
-- {Option -p}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
+- {Option `-p`}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
   `-n`, with printing.
 
 ### `-h`: Print Short Help Message
@@ -307,15 +307,15 @@ $ ruby -ln -e 'p $_' desiderata.txt
 
 See also:
 
-- {Option -0}[rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
-  \Set `$/` (input record separator).
-- {Option -a}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
+- [Option `-0`](rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
+  Sets the input record separator (`$/`).
+- {Option `-a`}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
   Split input lines into fields.
-- {Option -F}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
+- {Option `-F`}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
   \Set input field separator.
-- {Option -n}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
+- {Option `-n`}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
   Run program in `gets` loop.
-- {Option -p}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
+- {Option `-p`}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
   `-n`, with printing.
 
 ### `-n`: Run Program in `gets` Loop
@@ -341,15 +341,15 @@ be on good terms with all persons.
 
 See also:
 
-- {Option -0}[rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
+- {Option `-0`}[rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
   \Set `$/` (input record separator).
-- {Option -a}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
+- {Option `-a`}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
   Split input lines into fields.
-- {Option -F}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
+- {Option `-F`}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
   \Set input field separator.
-- {Option -l}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
+- {Option `-l`}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
   \Set output record separator; chop lines.
-- {Option -p}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
+- {Option `-p`}[rdoc-ref:ruby/options.md@p-3A+-n-2C+with+Printing]:
   `-n`, with printing.
 
 ### `-p`: `-n`, with Printing
@@ -370,15 +370,15 @@ be on good terms with all persons.
 
 See also:
 
-- {Option -0}[rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
+- {Option `-0`}[rdoc-ref:ruby/options.md@0-3A+Set+-24-2F+-28Input+Record+Separator-29]:
   \Set `$/` (input record separator).
-- {Option -a}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
+- {Option `-a`}[rdoc-ref:ruby/options.md@a-3A+Split+Input+Lines+into+Fields]:
   Split input lines into fields.
-- {Option -F}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
+- {Option `-F`}[rdoc-ref:ruby/options.md@F-3A+Set+Input+Field+Separator]:
   \Set input field separator.
-- {Option -l}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
+- {Option `-l`}[rdoc-ref:ruby/options.md@l-3A+Set+Output+Record+Separator-3B+Chop+Lines]:
   \Set output record separator; chop lines.
-- {Option -n}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
+- {Option `-n`}[rdoc-ref:ruby/options.md@n-3A+Run+Program+in+gets+Loop]:
   Run program in `gets` loop.
 
 ### `-r`: Require Library
@@ -427,7 +427,7 @@ $ ruby -s t.rb -foo=baz -bar=bat
 ```
 
 The option may not be used with
-{option -e}[rdoc-ref:ruby/options.md@e-3A+Execute+Given+Ruby+Code]
+{option `-e`}[rdoc-ref:ruby/options.md@e-3A+Execute+Given+Ruby+Code]
 
 ### `-S`: Search Directories in `ENV['PATH']`
 
@@ -576,7 +576,7 @@ ruby - Copyright (C) 1993-2024 Yukihiro Matsumoto
 ### `--debug`: Alias for `-d`
 
 Option `--debug` is an alias for
-{option -d}[rdoc-ref:ruby/options.md@d-3A+Set+-24DEBUG+to+true].
+{option `-d`}[rdoc-ref:ruby/options.md@d-3A+Set+-24DEBUG+to+true].
 
 ### `--disable`: Disable Features
 
@@ -595,7 +595,7 @@ The supported features:
 - `frozen-string-literal`: Freeze all string literals (default: disabled).
 - `jit`: JIT compiler (default: disabled).
 
-See also {option --enable}[options_md.html#label--enable-3A+Enable+Features].
+See also {option `--enable`}[options_md.html#label--enable-3A+Enable+Features].
 
 ### `--dump`: Dump Items
 
@@ -617,7 +617,7 @@ option was given:
   Same as {option \-\-version}[options_md.html#label--version-3A+Print+Ruby+Version].
 
 For other argument values and examples,
-see {Option --dump}[option_dump_md.html].
+see {Option `--dump`}[option_dump_md.html].
 
 ### `--enable`: Enable Features
 
-- 
2.45.2
````

</details> 



Patch:
diff --git a/lib/rdoc/markup/to_html.rb b/lib/rdoc/markup/to_html.rb
index b036dbe53f..a949c53e41 100644
--- a/lib/rdoc/markup/to_html.rb
+++ b/lib/rdoc/markup/to_html.rb
@@ -158,19 +158,16 @@ def handle_regexp_RDOCLINK(target)
   def handle_regexp_TIDYLINK(target)
     text = target.text
 
-    return text unless
-      text =~ /^\{(.*)\}\[(.*?)\]$/ or text =~ /^(\S+)\[(.*?)\]$/
-
-    label = $1
-    url   = CGI.escapeHTML($2)
+    if tidy_link_capturing?
+      return finish_tidy_link(text)
+    end
 
-    if /^rdoc-image:/ =~ label
-      label = handle_RDOCLINK(label)
-    else
-      label = CGI.escapeHTML(label)
+    if text.start_with?('{') && !text.include?('}')
+      start_tidy_link text
+      return ''
     end
 
-    gen_url url, label
+    convert_complete_tidy_link(text)
   end
 
   # :section: Visitor
@@ -458,4 +455,153 @@ def to_html(item)
     super convert_flow @am.flow item
   end
 
+  private
+
+  def convert_flow(flow_items)
+    res = []
+
+    flow_items.each do |item|
+      case item
+      when String
+        append_flow_fragment res, convert_string(item)
+      when RDoc::Markup::AttrChanger
+        off_tags res, item
+        on_tags  res, item
+      when RDoc::Markup::RegexpHandling
+        append_flow_fragment res, convert_regexp_handling(item)
+      else
+        raise "Unknown flow element: #{item.inspect}"
+      end
+    end
+
+    res.join
+  end
+
+  def append_flow_fragment(res, fragment)
+    return if fragment.nil? || fragment.empty?
+
+    emit_tidy_link_fragment(res, fragment)
+  end
+
+  def append_to_tidy_label(fragment)
+    @tidy_link_buffer << fragment
+  end
+
+  ##
+  # Matches an entire tidy link with a braced label "{label}[url]".
+  #
+  # Capture 1: label contents.
+  # Capture 2: URL text.
+  # Capture 3: trailing content.
+  TIDY_LINK_WITH_BRACES = /\A\{(.*?)\}\[(.*?)\](.*)\z/
+
+  ##
+  # Matches the tail of a braced tidy link when the opening brace was
+  # consumed earlier while accumulating the label text.
+  #
+  # Capture 1: remaining label content.
+  # Capture 2: URL text.
+  # Capture 3: trailing content.
+  TIDY_LINK_WITH_BRACES_TAIL = /\A(.*?)\}\[(.*?)\](.*)\z/
+
+  ##
+  # Matches a tidy link with a single-word label "label[url]".
+  #
+  # Capture 1: the single-word label (no whitespace).
+  # Capture 2: URL text between the brackets.
+  TIDY_LINK_SINGLE_WORD = /\A(\S+)\[(.*?)\](.*)\z/
+
+  def convert_complete_tidy_link(text)
+    return text unless
+      text =~ TIDY_LINK_WITH_BRACES or text =~ TIDY_LINK_SINGLE_WORD
+
+    label = $1
+    url   = CGI.escapeHTML($2)
+
+    label_html = if /^rdoc-image:/ =~ label
+                   handle_RDOCLINK(label)
+                 else
+                   render_tidy_link_label(label)
+                 end
+
+    gen_url url, label_html
+  end
+
+  def emit_tidy_link_fragment(res, fragment)
+    if tidy_link_capturing?
+      append_to_tidy_label fragment
+    else
+      res << fragment
+    end
+  end
+
+  def finish_tidy_link(text)
+    label_tail, url, trailing = extract_tidy_link_parts(text)
+    append_to_tidy_label CGI.escapeHTML(label_tail) unless label_tail.empty?
+
+    return '' unless url
+
+    label_html = @tidy_link_buffer
+    @tidy_link_buffer = nil
+    link = gen_url(url, label_html)
+
+    return link if trailing.empty?
+
+    link + CGI.escapeHTML(trailing)
+  end
+
+  def extract_tidy_link_parts(text)
+    if text =~ TIDY_LINK_WITH_BRACES
+      [$1, CGI.escapeHTML($2), $3]
+    elsif text =~ TIDY_LINK_WITH_BRACES_TAIL
+      [$1, CGI.escapeHTML($2), $3]
+    elsif text =~ TIDY_LINK_SINGLE_WORD
+      [$1, CGI.escapeHTML($2), $3]
+    else
+      [text, nil, '']
+    end
+  end
+
+  def on_tags(res, item)
+    each_attr_tag(item.turn_on) do |tag|
+      emit_tidy_link_fragment(res, annotate(tag.on))
+      @in_tt += 1 if tt? tag
+    end
+  end
+
+  def off_tags(res, item)
+    each_attr_tag(item.turn_off, true) do |tag|
+      emit_tidy_link_fragment(res, annotate(tag.off))
+      @in_tt -= 1 if tt? tag
+    end
+  end
+
+  def start_tidy_link(text)
+    @tidy_link_buffer = String.new
+    append_to_tidy_label CGI.escapeHTML(text.delete_prefix('{'))
+  end
+
+  def tidy_link_capturing?
+    !!@tidy_link_buffer
+  end
+
+  def render_tidy_link_label(label)
+    RDoc::Markup::LinkLabelToHtml.render(label, @options, @from_path)
+  end
+end
+
+##
+# Formatter dedicated to rendering tidy link labels without mutating the
+# calling formatter's state.
+
+class RDoc::Markup::LinkLabelToHtml < RDoc::Markup::ToHtml
+  def self.render(label, options, from_path)
+    new(options, from_path).to_html(label)
+  end
+
+  def initialize(options, from_path = nil)
+    super(options)
+
+    self.from_path = from_path if from_path
+  end
 end
diff --git a/lib/rdoc/markup/to_html_crossref.rb b/lib/rdoc/markup/to_html_crossref.rb
index dab486aaae..c05a3b5b17 100644
--- a/lib/rdoc/markup/to_html_crossref.rb
+++ b/lib/rdoc/markup/to_html_crossref.rb
@@ -193,9 +193,9 @@ def convert_flow(flow_items, &block)
 
       case item
       when RDoc::Markup::AttrChanger
-        if (text = convert_tt_crossref(flow_items, i))
+        if !tidy_link_capturing? && (text = convert_tt_crossref(flow_items, i))
           text = block.call(text, res) if block
-          res << text
+          append_flow_fragment res, text
           i += 3
           next
         end
@@ -206,12 +206,12 @@ def convert_flow(flow_items, &block)
       when String
         text = convert_string(item)
         text = block.call(text, res) if block
-        res << text
+        append_flow_fragment res, text
         i += 1
       when RDoc::Markup::RegexpHandling
         text = convert_regexp_handling(item)
         text = block.call(text, res) if block
-        res << text
+        append_flow_fragment res, text
         i += 1
       else
         raise "Unknown flow element: #{item.inspect}"


Test patch:
diff --git a/test/rdoc/rdoc_markdown_test.rb b/test/rdoc/rdoc_markdown_test.rb
index 19eeb1c9ef..5ca4ddfc0e 100644
--- a/test/rdoc/rdoc_markdown_test.rb
+++ b/test/rdoc/rdoc_markdown_test.rb
@@ -1263,6 +1263,26 @@ def test_gfm_table_with_backslashes_in_code_spans
     assert_equal expected, doc
   end
 
+  def test_markdown_link_with_styled_label
+    markdown = <<~MD
+      [Link to Foo](https://example.com)
+
+      [Link to `Foo`](https://example.com)
+
+      [Link to **Foo**](https://example.com)
+
+      [Link to `Foo` and `\Bar` and `Baz`](https://example.com)
+    MD
+
+    doc = parse markdown
+    html = @to_html.convert doc
+
+    assert_includes html, '<a href="https://example.com">Link to Foo</a>'
+    assert_includes html, '<a href="https://example.com">Link to <code>Foo</code></a>'
+    assert_includes html, '<a href="https://example.com">Link to <strong>Foo</strong></a>'
+    assert_includes html, '<a href="https://example.com">Link to <code>Foo</code> and <code>Bar</code> and <code>Baz</code></a>'
+  end
+
   def parse(text)
     @parser.parse text
   end
diff --git a/test/rdoc/rdoc_markup_to_html_crossref_test.rb b/test/rdoc/rdoc_markup_to_html_crossref_test.rb
index e6836ea4c2..820aade4d0 100644
--- a/test/rdoc/rdoc_markup_to_html_crossref_test.rb
+++ b/test/rdoc/rdoc_markup_to_html_crossref_test.rb
@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 require_relative 'xref_test_case'
+require 'rdoc/markdown'
 
 class RDocMarkupToHtmlCrossrefTest < XrefTestCase
 
@@ -288,6 +289,14 @@ def test_handle_regexp_TIDYLINK_label
                  link, 'C1#m@foo'
   end
 
+  def test_convert_TIDYLINK_markdown_with_crossrefs
+    markdown = RDoc::Markdown.parse('[Link to `C1` and `Foo` and `\\C1` and `Bar`](https://example.com)')
+
+    result = markdown.accept(@to)
+
+    assert_equal para('<a href="https://example.com">Link to <code>C1</code> and <code>Foo</code> and <code>\\C1</code> and <code>Bar</code></a>'), result
+  end
+
   def test_to_html_CROSSREF_email
     @options.hyperlink_all = false
 
diff --git a/test/rdoc/rdoc_markup_to_html_test.rb b/test/rdoc/rdoc_markup_to_html_test.rb
index e586e03cb9..cb6774679b 100644
--- a/test/rdoc/rdoc_markup_to_html_test.rb
+++ b/test/rdoc/rdoc_markup_to_html_test.rb
@@ -734,6 +734,28 @@ def test_convert_TIDYLINK_multiple
     assert_equal expected, result
   end
 
+  def test_convert_TIDYLINK_with_code_label
+    result = @to.convert '{Link to +Foo+}[https://example.com]'
+
+    expected = "\n<p><a href=\"https://example.com\">Link to <code>Foo</code></a></p>\n"
+
+    assert_equal expected, result
+
+    result = @to.convert '{Link to +Foo+ and +Bar+ and +Baz+}[https://example.com]'
+
+    expected = "\n<p><a href=\"https://example.com\">Link to <code>Foo</code> and <code>Bar</code> and <code>Baz</code></a></p>\n"
+
+    assert_equal expected, result
+  end
+
+  def test_convert_TIDYLINK_with_bold_label
+    result = @to.convert '{Link to *Foo*}[https://example.com]'
+
+    expected = "\n<p><a href=\"https://example.com\">Link to <strong>Foo</strong></a></p>\n"
+
+    assert_equal expected, result
+  end
+
   def test_convert_TIDYLINK_image
     result =
       @to.convert '{rdoc-image:path/to/image.jpg}[http://example.com]'