From bd0e544ff4f003b3c51da254307deb7c1532c806 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 9 Apr 2026 16:43:09 +0100 Subject: [PATCH 1/2] Fix Markdown blockquote lazy continuation parsing - Headings (`# foo`), bullet lists, ordered lists, and code fences now break out of a blockquote instead of being lazily continued. - Unquoted blank lines end the blockquote; only `>`-prefixed blank lines continue it. - Heading lookahead uses `!(AtxStart @Spacechar)` so `#no-space` text (not a valid heading) is correctly kept inside the blockquote. - Code fence lookahead is gated behind `&{ github? }` so it only applies when the GitHub extension is enabled, matching `CodeFence`. Ref: https://github.com/ruby/rdoc/pull/1627 --- lib/rdoc/markdown.kpeg | 4 +- lib/rdoc/markdown.rb | 186 +++++++++++++++++++++++++++----- test/rdoc/rdoc_markdown_test.rb | 85 ++++++++++++++- 3 files changed, 246 insertions(+), 29 deletions(-) diff --git a/lib/rdoc/markdown.kpeg b/lib/rdoc/markdown.kpeg index d95a88a823..4b7d03863b 100644 --- a/lib/rdoc/markdown.kpeg +++ b/lib/rdoc/markdown.kpeg @@ -617,8 +617,8 @@ BlockQuote = BlockQuoteRaw:a BlockQuoteRaw = @StartList:a (( ">" " "? Line:l { a << l } ) - ( !">" !@BlankLine Line:c { a << c } )* - ( @BlankLine:n { a << n } )* + ( !">" !@BlankLine !(AtxStart @Spacechar) !Bullet !Enumerator !( &{ github? } Ticks3 ) Line:c { a << c } )* + ( ">" @BlankLine:n { a << n } )* )+ { inner_parse a.join } diff --git a/lib/rdoc/markdown.rb b/lib/rdoc/markdown.rb index e4d0ae9ff6..0bddda6a12 100644 --- a/lib/rdoc/markdown.rb +++ b/lib/rdoc/markdown.rb @@ -1656,7 +1656,7 @@ def _BlockQuote return _tmp end - # BlockQuoteRaw = @StartList:a (">" " "? Line:l { a << l } (!">" !@BlankLine Line:c { a << c })* (@BlankLine:n { a << n })*)+ { inner_parse a.join } + # BlockQuoteRaw = @StartList:a (">" " "? Line:l { a << l } (!">" !@BlankLine !(AtxStart @Spacechar) !Bullet !Enumerator !(&{ github? } Ticks3) Line:c { a << c })* (">" @BlankLine:n { a << n })*)+ { inner_parse a.join } def _BlockQuoteRaw _save = self.pos @@ -1718,6 +1718,68 @@ def _BlockQuoteRaw self.pos = _save5 break end + _save8 = self.pos + + _save9 = self.pos + while true # sequence + _tmp = apply(:_AtxStart) + unless _tmp + self.pos = _save9 + break + end + _tmp = _Spacechar() + unless _tmp + self.pos = _save9 + end + break + end # end sequence + + _tmp = _tmp ? nil : true + self.pos = _save8 + unless _tmp + self.pos = _save5 + break + end + _save10 = self.pos + _tmp = apply(:_Bullet) + _tmp = _tmp ? nil : true + self.pos = _save10 + unless _tmp + self.pos = _save5 + break + end + _save11 = self.pos + _tmp = apply(:_Enumerator) + _tmp = _tmp ? nil : true + self.pos = _save11 + unless _tmp + self.pos = _save5 + break + end + _save12 = self.pos + + _save13 = self.pos + while true # sequence + _save14 = self.pos + _tmp = begin; github? ; end + self.pos = _save14 + unless _tmp + self.pos = _save13 + break + end + _tmp = apply(:_Ticks3) + unless _tmp + self.pos = _save13 + end + break + end # end sequence + + _tmp = _tmp ? nil : true + self.pos = _save12 + unless _tmp + self.pos = _save5 + break + end _tmp = apply(:_Line) c = @result unless _tmp @@ -1741,18 +1803,23 @@ def _BlockQuoteRaw end while true - _save9 = self.pos + _save16 = self.pos while true # sequence + _tmp = match_string(">") + unless _tmp + self.pos = _save16 + break + end _tmp = _BlankLine() n = @result unless _tmp - self.pos = _save9 + self.pos = _save16 break end @result = begin; a << n ; end _tmp = true unless _tmp - self.pos = _save9 + self.pos = _save16 end break end # end sequence @@ -1769,65 +1836,127 @@ def _BlockQuoteRaw if _tmp while true - _save10 = self.pos + _save17 = self.pos while true # sequence _tmp = match_string(">") unless _tmp - self.pos = _save10 + self.pos = _save17 break end - _save11 = self.pos + _save18 = self.pos _tmp = match_string(" ") unless _tmp _tmp = true - self.pos = _save11 + self.pos = _save18 end unless _tmp - self.pos = _save10 + self.pos = _save17 break end _tmp = apply(:_Line) l = @result unless _tmp - self.pos = _save10 + self.pos = _save17 break end @result = begin; a << l ; end _tmp = true unless _tmp - self.pos = _save10 + self.pos = _save17 break end while true - _save13 = self.pos + _save20 = self.pos while true # sequence - _save14 = self.pos + _save21 = self.pos _tmp = match_string(">") _tmp = _tmp ? nil : true - self.pos = _save14 + self.pos = _save21 unless _tmp - self.pos = _save13 + self.pos = _save20 break end - _save15 = self.pos + _save22 = self.pos _tmp = _BlankLine() _tmp = _tmp ? nil : true - self.pos = _save15 + self.pos = _save22 unless _tmp - self.pos = _save13 + self.pos = _save20 + break + end + _save23 = self.pos + + _save24 = self.pos + while true # sequence + _tmp = apply(:_AtxStart) + unless _tmp + self.pos = _save24 + break + end + _tmp = _Spacechar() + unless _tmp + self.pos = _save24 + end + break + end # end sequence + + _tmp = _tmp ? nil : true + self.pos = _save23 + unless _tmp + self.pos = _save20 + break + end + _save25 = self.pos + _tmp = apply(:_Bullet) + _tmp = _tmp ? nil : true + self.pos = _save25 + unless _tmp + self.pos = _save20 + break + end + _save26 = self.pos + _tmp = apply(:_Enumerator) + _tmp = _tmp ? nil : true + self.pos = _save26 + unless _tmp + self.pos = _save20 + break + end + _save27 = self.pos + + _save28 = self.pos + while true # sequence + _save29 = self.pos + _tmp = begin; github? ; end + self.pos = _save29 + unless _tmp + self.pos = _save28 + break + end + _tmp = apply(:_Ticks3) + unless _tmp + self.pos = _save28 + end + break + end # end sequence + + _tmp = _tmp ? nil : true + self.pos = _save27 + unless _tmp + self.pos = _save20 break end _tmp = apply(:_Line) c = @result unless _tmp - self.pos = _save13 + self.pos = _save20 break end @result = begin; a << c ; end _tmp = true unless _tmp - self.pos = _save13 + self.pos = _save20 end break end # end sequence @@ -1836,23 +1965,28 @@ def _BlockQuoteRaw end _tmp = true unless _tmp - self.pos = _save10 + self.pos = _save17 break end while true - _save17 = self.pos + _save31 = self.pos while true # sequence + _tmp = match_string(">") + unless _tmp + self.pos = _save31 + break + end _tmp = _BlankLine() n = @result unless _tmp - self.pos = _save17 + self.pos = _save31 break end @result = begin; a << n ; end _tmp = true unless _tmp - self.pos = _save17 + self.pos = _save31 end break end # end sequence @@ -1861,7 +1995,7 @@ def _BlockQuoteRaw end _tmp = true unless _tmp - self.pos = _save10 + self.pos = _save17 end break end # end sequence @@ -16457,7 +16591,7 @@ def _DefinitionListDefinition Rules[:_SetextHeading2] = rule_info("SetextHeading2", "&(@RawLine SetextBottom2) @StartList:a (!@Endline Inline:b { a << b })+ @Sp @Newline SetextBottom2 { RDoc::Markup::Heading.new(2, a.join) }") Rules[:_Heading] = rule_info("Heading", "(SetextHeading | AtxHeading)") Rules[:_BlockQuote] = rule_info("BlockQuote", "BlockQuoteRaw:a { RDoc::Markup::BlockQuote.new(*a) }") - Rules[:_BlockQuoteRaw] = rule_info("BlockQuoteRaw", "@StartList:a (\">\" \" \"? Line:l { a << l } (!\">\" !@BlankLine Line:c { a << c })* (@BlankLine:n { a << n })*)+ { inner_parse a.join }") + Rules[:_BlockQuoteRaw] = rule_info("BlockQuoteRaw", "@StartList:a (\">\" \" \"? Line:l { a << l } (!\">\" !@BlankLine !(AtxStart @Spacechar) !Bullet !Enumerator !(&{ github? } Ticks3) Line:c { a << c })* (\">\" @BlankLine:n { a << n })*)+ { inner_parse a.join }") Rules[:_NonblankIndentedLine] = rule_info("NonblankIndentedLine", "!@BlankLine IndentedLine") Rules[:_VerbatimChunk] = rule_info("VerbatimChunk", "@BlankLine*:a NonblankIndentedLine+:b { a.concat b }") Rules[:_Verbatim] = rule_info("Verbatim", "VerbatimChunk+:a { RDoc::Markup::Verbatim.new(*a.flatten) }") diff --git a/test/rdoc/rdoc_markdown_test.rb b/test/rdoc/rdoc_markdown_test.rb index fc96b2985c..7767ac6784 100644 --- a/test/rdoc/rdoc_markdown_test.rb +++ b/test/rdoc/rdoc_markdown_test.rb @@ -120,12 +120,95 @@ def test_parse_block_quote_separate expected = doc( block( - para("this is\na block quote"), + para("this is\na block quote")), + block( para("that continues"))) assert_equal expected, doc end + def test_parse_block_quote_no_lazy_continuation_for_heading + doc = parse <<~BLOCK_QUOTE + > foo + # bar + BLOCK_QUOTE + + expected = + doc( + block( + para("foo")), + head(1, "bar")) + + assert_equal expected, doc + end + + def test_parse_block_quote_no_lazy_continuation_for_list + doc = parse <<~BLOCK_QUOTE + > foo + - bar + BLOCK_QUOTE + + expected = + doc( + block( + para("foo")), + list(:BULLET, + item(nil, para("bar")))) + + assert_equal expected, doc + end + + def test_parse_block_quote_no_lazy_continuation_for_ordered_list + doc = parse <<~BLOCK_QUOTE + > foo + 1. bar + BLOCK_QUOTE + + expected = + doc( + block( + para("foo")), + list(:NUMBER, + item(nil, para("bar")))) + + assert_equal expected, doc + end + + def test_parse_block_quote_no_lazy_continuation_for_code_fence + doc = parse <<~BLOCK_QUOTE + > foo + ``` + code + ``` + BLOCK_QUOTE + + expected = + doc( + block( + para("foo")), + verb("code\n")) + + assert_equal expected, doc + end + + def test_parse_block_quote_lazy_continuation_for_code_fence_non_github + @parser.github = false + + doc = parse <<~BLOCK_QUOTE + > foo + ``` + code + ``` + BLOCK_QUOTE + + expected = + doc( + block( + para("foo\n\ncode\n"))) + + assert_equal expected, doc + end + def test_parse_char_entity doc = parse 'π &nn;' From 2d74d023c2c72a1ebcd33176a3cf96c1d41e5f74 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 9 Apr 2026 16:43:20 +0100 Subject: [PATCH 2/2] 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. --- lib/rdoc/markup/to_html_crossref.rb | 11 ++++++++++- test/rdoc/markup/to_html_crossref_test.rb | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/rdoc/markup/to_html_crossref.rb b/lib/rdoc/markup/to_html_crossref.rb index 106810072b..16b23f49c3 100644 --- a/lib/rdoc/markup/to_html_crossref.rb +++ b/lib/rdoc/markup/to_html_crossref.rb @@ -61,6 +61,7 @@ def init_link_notation_regexp_handlings def cross_reference(name, text = nil, code = true, rdoc_ref: false) lookup = name + display_name = name name = name[1..-1] unless @show_hash if name[0, 1] == '#' if !name.end_with?('+@', '-@') && match = name.match(/(.*[^#:])?@(.*)/) @@ -73,7 +74,15 @@ def cross_reference(name, text = nil, code = true, rdoc_ref: false) text ||= name end - link lookup, text, code, rdoc_ref: rdoc_ref + result = link lookup, text, code, rdoc_ref: rdoc_ref + + # If the cross-reference didn't resolve to a link, restore the original + # text including the '#' prefix that was stripped above. + if result == text && display_name != name && !text.start_with?('#') + display_name + else + result + end end ## diff --git a/test/rdoc/markup/to_html_crossref_test.rb b/test/rdoc/markup/to_html_crossref_test.rb index ff243fc9e0..4a0ff315c8 100644 --- a/test/rdoc/markup/to_html_crossref_test.rb +++ b/test/rdoc/markup/to_html_crossref_test.rb @@ -435,6 +435,13 @@ def hyper(reference) "rdoc-ref:#{reference}" 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 tidy(reference) "{tidy}[rdoc-ref:#{reference}]" end