[clang-tools-extra] [clangd] Use plaintext newline handling for escaped markdown hover style (PR #185197)

via cfe-commits cfe-commits at lists.llvm.org
Sat Mar 7 07:15:23 PST 2026


https://github.com/tcottin created https://github.com/llvm/llvm-project/pull/185197

For clangd 22 there are [3 new options](https://clangd.llvm.org/config#documentation) to control the content of the hover information. Using the default Plaintext option, Markdown syntax is escaped.
But whitespaces (like newlines etc.) are not "escaped" anymore as it was before the introduction of Markdown and Doxygen rendering.

Some clients do not render Markdown even if they request Markdown content from the server.
At least the [neovim client](https://github.com/clangd/clangd/issues/95) does not render Markdown whitespace correctly.
Therefore, with clangd 22, the rendering for these clients looks different, especially for newlines.
Without the escaping, newlines are rendered as written in the documentation comment, which is not desired for these clients.

This patch fixes the regression by using the same whitespace escaping as clangd < 22.

>From 1a34041a41717d9efad90ccc4b8639c51c4728c8 Mon Sep 17 00:00:00 2001
From: Tim Cottin <timcottin at gmx.de>
Date: Sat, 7 Mar 2026 14:42:43 +0000
Subject: [PATCH] use plaintext newline handling for escaped markdown hover
 style

---
 clang-tools-extra/clangd/support/Markup.cpp   |  4 +-
 clang-tools-extra/clangd/support/Markup.h     |  3 +-
 .../clangd/unittests/HoverTests.cpp           | 40 ++++++++--------
 .../unittests/SymbolDocumentationTests.cpp    | 25 +++++-----
 .../clangd/unittests/support/MarkupTests.cpp  | 47 +++++++++----------
 5 files changed, 59 insertions(+), 60 deletions(-)

diff --git a/clang-tools-extra/clangd/support/Markup.cpp b/clang-tools-extra/clangd/support/Markup.cpp
index 9ba993a04709c..a00482614f701 100644
--- a/clang-tools-extra/clangd/support/Markup.cpp
+++ b/clang-tools-extra/clangd/support/Markup.cpp
@@ -528,7 +528,9 @@ void Paragraph::renderEscapedMarkdown(llvm::raw_ostream &OS) const {
     NeedsSpace = C.SpaceAfter;
   }
 
-  renderNewlinesMarkdown(OS, ParagraphText);
+  // We use the same newline handling as for plaintext to "escape" markdown
+  // whitespace rendering.
+  renderNewlinesPlaintext(OS, ParagraphText);
 
   // A paragraph in markdown is separated by a blank line.
   OS << "\n\n";
diff --git a/clang-tools-extra/clangd/support/Markup.h b/clang-tools-extra/clangd/support/Markup.h
index 219a7dad1e175..af26aae9c38b6 100644
--- a/clang-tools-extra/clangd/support/Markup.h
+++ b/clang-tools-extra/clangd/support/Markup.h
@@ -152,8 +152,9 @@ class Paragraph : public Block {
   /// Newlines are added if:
   /// - the line ends with 2 spaces and a newline follows
   /// - the line ends with punctuation that indicates a line break (.:,;!?)
-  /// - the next line starts with a hard line break indicator (-@>#`\\ or a
+  /// - the next line starts with a hard line break indicator (-@>#\\ or a
   ///   digit followed by '.' or ')'), ignoring leading whitespace.
+  /// - the next line starts with a Markdown code block (```).
   ///
   /// Otherwise, newlines in the input are replaced with a single space.
   ///
diff --git a/clang-tools-extra/clangd/unittests/HoverTests.cpp b/clang-tools-extra/clangd/unittests/HoverTests.cpp
index 7bff20e6f5635..5a7c1cd9e75dc 100644
--- a/clang-tools-extra/clangd/unittests/HoverTests.cpp
+++ b/clang-tools-extra/clangd/unittests/HoverTests.cpp
@@ -4362,25 +4362,25 @@ TEST(Hover, ParseDocumentation) {
     llvm::StringRef ExpectedRenderPlainText;
   } Cases[] = {{
                    " \n foo\nbar",
-                   "foo\nbar",
+                   "foo bar",
                    "foo\nbar",
                    "foo bar",
                },
                {
                    "foo\nbar \n  ",
-                   "foo\nbar",
+                   "foo bar",
                    "foo\nbar",
                    "foo bar",
                },
                {
                    "foo  \nbar",
-                   "foo  \nbar",
+                   "foo\nbar",
                    "foo  \nbar",
                    "foo\nbar",
                },
                {
                    "foo    \nbar",
-                   "foo    \nbar",
+                   "foo\nbar",
                    "foo    \nbar",
                    "foo\nbar",
                },
@@ -4392,25 +4392,25 @@ TEST(Hover, ParseDocumentation) {
                },
                {
                    "foo\n\n\n\tbar",
-                   "foo\n\n\tbar",
+                   "foo\n\nbar",
                    "foo\n\n\tbar",
                    "foo\n\nbar",
                },
                {
                    "foo\n\n\n    bar",
-                   "foo\n\n    bar",
+                   "foo\n\nbar",
                    "foo\n\n    bar",
                    "foo\n\nbar",
                },
                {
                    "foo\n\n\n   bar",
-                   "foo\n\n   bar",
+                   "foo\n\nbar",
                    "foo\n\n   bar",
                    "foo\n\nbar",
                },
                {
                    "foo\n\n\n bar",
-                   "foo\n\n bar",
+                   "foo\n\nbar",
                    "foo\n\n bar",
                    "foo\n\nbar",
                },
@@ -4422,49 +4422,49 @@ TEST(Hover, ParseDocumentation) {
                },
                {
                    "foo\n\n\n\n\tbar",
-                   "foo\n\n\tbar",
+                   "foo\n\nbar",
                    "foo\n\n\tbar",
                    "foo\n\nbar",
                },
                {
                    "foo\n\n\n\n    bar",
-                   "foo\n\n    bar",
+                   "foo\n\nbar",
                    "foo\n\n    bar",
                    "foo\n\nbar",
                },
                {
                    "foo\n\n\n\n   bar",
-                   "foo\n\n   bar",
+                   "foo\n\nbar",
                    "foo\n\n   bar",
                    "foo\n\nbar",
                },
                {
                    "foo\n\n\n\n bar",
-                   "foo\n\n bar",
+                   "foo\n\nbar",
                    "foo\n\n bar",
                    "foo\n\nbar",
                },
                {
                    "foo.\nbar",
-                   "foo.  \nbar",
+                   "foo.\nbar",
                    "foo.  \nbar",
                    "foo.\nbar",
                },
                {
                    "foo. \nbar",
-                   "foo.   \nbar",
+                   "foo.\nbar",
                    "foo.   \nbar",
                    "foo.\nbar",
                },
                {
                    "foo\n*bar",
-                   "foo  \n\\*bar",
+                   "foo\n\\*bar",
                    "foo\n*bar",
                    "foo\n*bar",
                },
                {
                    "foo\nbar",
-                   "foo\nbar",
+                   "foo bar",
                    "foo\nbar",
                    "foo bar",
                },
@@ -4482,7 +4482,7 @@ TEST(Hover, ParseDocumentation) {
                },
                {
                    "`not\nparsed`",
-                   "\\`not\nparsed\\`",
+                   "\\`not parsed\\`",
                    "`not\nparsed`",
                    "`not parsed`",
                },
@@ -4491,9 +4491,9 @@ TEST(Hover, ParseDocumentation) {
 @param x this is x
 \param y this is y
 @return something)",
-                   R"(@brief this is a typical use case  
- at param x this is x  
-\\param y this is y  
+                   R"(@brief this is a typical use case
+ at param x this is x
+\\param y this is y
 @return something)",
                    R"(@brief this is a typical use case  
 @param x this is x  
diff --git a/clang-tools-extra/clangd/unittests/SymbolDocumentationTests.cpp b/clang-tools-extra/clangd/unittests/SymbolDocumentationTests.cpp
index 676f7dfc74483..9a22bb60f4793 100644
--- a/clang-tools-extra/clangd/unittests/SymbolDocumentationTests.cpp
+++ b/clang-tools-extra/clangd/unittests/SymbolDocumentationTests.cpp
@@ -33,7 +33,7 @@ TEST(SymbolDocumentation, DetailedDocToMarkup) {
       },
       {
           "brief\n\nfoo\nbar\n",
-          "foo\nbar",
+          "foo bar",
           "foo\nbar",
           "foo bar",
       },
@@ -174,8 +174,7 @@ documentation)",
 
 these are details
 
-More description
-documentation)",
+More description documentation)",
           R"(**\brief** another brief?
 
 these are details
@@ -194,8 +193,7 @@ More description documentation)",
 <b>this is a bold text</b>
 normal text<i>this is an italic text</i>
 <code>this is a code block</code>)",
-          R"(\<b>this is a bold text\</b>
-normal text\<i>this is an italic text\</i>  
+          R"(\<b>this is a bold text\</b> normal text\<i>this is an italic text\</i>
 \<code>this is a code block\</code>)",
           R"(\<b>this is a bold text\</b>
 normal text\<i>this is an italic text\</i>  
@@ -204,7 +202,7 @@ normal text\<i>this is an italic text\</i>
           "<code>this is a code block</code>",
       },
       {"brief\n\n at note This is a note",
-       R"(\*\*Note:\*\*  
+       R"(\*\*Note:\*\*
 This is a note)",
        R"(**Note:**  
 This is a note)",
@@ -218,7 +216,7 @@ Paragraph 1
 Paragraph 2)",
        R"(Paragraph 1
 
-\*\*Note:\*\*  
+\*\*Note:\*\*
 This is a note
 
 Paragraph 2)",
@@ -235,7 +233,7 @@ This is a note
 
 Paragraph 2)"},
       {"brief\n\n at warning This is a warning",
-       R"(\*\*Warning:\*\*  
+       R"(\*\*Warning:\*\*
 This is a warning)",
        R"(**Warning:**  
 This is a warning)",
@@ -249,7 +247,7 @@ Paragraph 1
 Paragraph 2)",
        R"(Paragraph 1
 
-\*\*Warning:\*\*  
+\*\*Warning:\*\*
 This is a warning
 
 Paragraph 2)",
@@ -270,7 +268,7 @@ Paragraph 2)"},
 @brief this is the brief
 
 Another paragraph)",
-       R"(\*\*Note:\*\*  
+       R"(\*\*Note:\*\*
 this is not treated as brief
 
 Another paragraph)",
@@ -612,7 +610,7 @@ TEST(SymbolDocumentation, MarkdownCodeBlocksSeparation) {
 /// Also note that without preprocessing, all doxygen commands inside code blocks, like @p would be incorrectly interpreted.
 int function() { return 0; }
 ```)",
-       R"(\*\*Note:\*\*  
+       R"(\*\*Note:\*\*
 Show that code blocks are correctly separated
 
 ```
@@ -648,7 +646,7 @@ int function() { return 0; })"},
 /// Also note that without preprocessing, all doxygen commands inside code blocks, like @p would be incorrectly interpreted.
 int function() { return 0; }
 ~~~~~~~~~)",
-       R"(\*\*Note:\*\*  
+       R"(\*\*Note:\*\*
 Show that code blocks are correctly separated
 
 ```
@@ -711,8 +709,7 @@ TEST(SymbolDocumentation, MarkdownCodeSpans) {
       {R"(`multi
 line
 \c span`)",
-       R"(\`multi
-line  
+       R"(\`multi line
 \\c span\`)",
        R"(`multi
 line  
diff --git a/clang-tools-extra/clangd/unittests/support/MarkupTests.cpp b/clang-tools-extra/clangd/unittests/support/MarkupTests.cpp
index af4782c07ae52..e7aafec42affa 100644
--- a/clang-tools-extra/clangd/unittests/support/MarkupTests.cpp
+++ b/clang-tools-extra/clangd/unittests/support/MarkupTests.cpp
@@ -304,7 +304,7 @@ TEST(Paragraph, SeparationOfChunks) {
 
   P.appendSpace().appendCode("code").appendText(".\n  newline");
   EXPECT_EQ(P.asEscapedMarkdown(),
-            "after `foobar` bat`no` `space` text `code`.  \n  newline");
+            "after `foobar` bat`no` `space` text `code`.\nnewline");
   EXPECT_EQ(P.asMarkdown(),
             "after `foobar` bat`no` `space` text `code`.  \n  newline");
   EXPECT_EQ(P.asPlainText(), "after foobar batno space text code.\nnewline");
@@ -343,12 +343,12 @@ TEST(Paragraph, SeparationOfChunks2) {
   EXPECT_EQ(P.asPlainText(), "after foobar batbaz faz");
 
   P.appendText("  bar  ");
-  EXPECT_EQ(P.asEscapedMarkdown(), "after foobar batbaz faz   bar");
+  EXPECT_EQ(P.asEscapedMarkdown(), "after foobar batbaz faz bar");
   EXPECT_EQ(P.asMarkdown(), "after foobar batbaz faz   bar");
   EXPECT_EQ(P.asPlainText(), "after foobar batbaz faz bar");
 
   P.appendText("qux");
-  EXPECT_EQ(P.asEscapedMarkdown(), "after foobar batbaz faz   bar  qux");
+  EXPECT_EQ(P.asEscapedMarkdown(), "after foobar batbaz faz bar qux");
   EXPECT_EQ(P.asMarkdown(), "after foobar batbaz faz   bar  qux");
   EXPECT_EQ(P.asPlainText(), "after foobar batbaz faz bar qux");
 }
@@ -366,23 +366,22 @@ TEST(Paragraph, SeparationOfChunks3) {
   EXPECT_EQ(P.asPlainText(), "after");
 
   P.appendText("  foobar\n");
-  EXPECT_EQ(P.asEscapedMarkdown(), "after  \n  foobar");
+  EXPECT_EQ(P.asEscapedMarkdown(), "after\nfoobar");
   EXPECT_EQ(P.asMarkdown(), "after  \n  foobar");
   EXPECT_EQ(P.asPlainText(), "after\nfoobar");
 
   P.appendText("- bat\n");
-  EXPECT_EQ(P.asEscapedMarkdown(), "after  \n  foobar  \n\\- bat");
+  EXPECT_EQ(P.asEscapedMarkdown(), "after\nfoobar\n\\- bat");
   EXPECT_EQ(P.asMarkdown(), "after  \n  foobar\n- bat");
   EXPECT_EQ(P.asPlainText(), "after\nfoobar\n- bat");
 
   P.appendText("- baz");
-  EXPECT_EQ(P.asEscapedMarkdown(), "after  \n  foobar  \n\\- bat  \n\\- baz");
+  EXPECT_EQ(P.asEscapedMarkdown(), "after\nfoobar\n\\- bat\n\\- baz");
   EXPECT_EQ(P.asMarkdown(), "after  \n  foobar\n- bat\n- baz");
   EXPECT_EQ(P.asPlainText(), "after\nfoobar\n- bat\n- baz");
 
   P.appendText(" faz ");
-  EXPECT_EQ(P.asEscapedMarkdown(),
-            "after  \n  foobar  \n\\- bat  \n\\- baz faz");
+  EXPECT_EQ(P.asEscapedMarkdown(), "after\nfoobar\n\\- bat\n\\- baz faz");
   EXPECT_EQ(P.asMarkdown(), "after  \n  foobar\n- bat\n- baz faz");
   EXPECT_EQ(P.asPlainText(), "after\nfoobar\n- bat\n- baz faz");
 }
@@ -396,27 +395,27 @@ TEST(Paragraph, PunctuationLineBreaks) {
     std::string PlainText;
   } Cases[] = {
       {"Line ending with dot.\nForces a visual linebreak.",
-       "Line ending with dot.  \nForces a visual linebreak.",
+       "Line ending with dot.\nForces a visual linebreak.",
        "Line ending with dot.  \nForces a visual linebreak.",
        "Line ending with dot.\nForces a visual linebreak."},
       {"Line ending with colon:\nForces a visual linebreak.",
-       "Line ending with colon:  \nForces a visual linebreak.",
+       "Line ending with colon:\nForces a visual linebreak.",
        "Line ending with colon:  \nForces a visual linebreak.",
        "Line ending with colon:\nForces a visual linebreak."},
       {"Line ending with semicolon:\nForces a visual linebreak.",
-       "Line ending with semicolon:  \nForces a visual linebreak.",
+       "Line ending with semicolon:\nForces a visual linebreak.",
        "Line ending with semicolon:  \nForces a visual linebreak.",
        "Line ending with semicolon:\nForces a visual linebreak."},
       {"Line ending with comma,\nForces a visual linebreak.",
-       "Line ending with comma,  \nForces a visual linebreak.",
+       "Line ending with comma,\nForces a visual linebreak.",
        "Line ending with comma,  \nForces a visual linebreak.",
        "Line ending with comma,\nForces a visual linebreak."},
       {"Line ending with exclamation mark!\nForces a visual linebreak.",
-       "Line ending with exclamation mark!  \nForces a visual linebreak.",
+       "Line ending with exclamation mark!\nForces a visual linebreak.",
        "Line ending with exclamation mark!  \nForces a visual linebreak.",
        "Line ending with exclamation mark!\nForces a visual linebreak."},
       {"Line ending with question mark?\nForces a visual linebreak.",
-       "Line ending with question mark?  \nForces a visual linebreak.",
+       "Line ending with question mark?\nForces a visual linebreak.",
        "Line ending with question mark?  \nForces a visual linebreak.",
        "Line ending with question mark?\nForces a visual linebreak."},
   };
@@ -439,36 +438,36 @@ TEST(Paragraph, LineBreakIndicators) {
     std::string PlainText;
   } Cases[] = {
       {"Visual linebreak for\n- list items\n- and so on",
-       "Visual linebreak for  \n\\- list items  \n\\- and so on",
+       "Visual linebreak for\n\\- list items\n\\- and so on",
        "Visual linebreak for\n- list items\n- and so on",
        "Visual linebreak for\n- list items\n- and so on"},
       {"Visual linebreak for\n* list items\n* and so on",
-       "Visual linebreak for  \n\\* list items  \n\\* and so on",
+       "Visual linebreak for\n\\* list items\n\\* and so on",
        "Visual linebreak for\n* list items\n* and so on",
        "Visual linebreak for\n* list items\n* and so on"},
       {"Visual linebreak for\n at command any doxygen command\n\\other other "
        "doxygen command",
-       "Visual linebreak for  \n at command any doxygen command  \n\\\\other "
+       "Visual linebreak for\n at command any doxygen command\n\\\\other "
        "other doxygen command",
        "Visual linebreak for  \n at command any doxygen command  \n\\other other "
        "doxygen command",
        "Visual linebreak for\n at command any doxygen command\n\\other other "
        "doxygen command"},
       {"Visual linebreak for\n>blockquoute line 1\n> blockquoute line 2",
-       "Visual linebreak for  \n\\>blockquoute line 1  \n\\> blockquoute line "
+       "Visual linebreak for\n\\>blockquoute line 1\n\\> blockquoute line "
        "2",
        "Visual linebreak for\n>blockquoute line 1\n> blockquoute line 2",
        "Visual linebreak for\n>blockquoute line 1\n> blockquoute line 2"},
       {"Visual linebreak for\n# Heading 1\ntext under heading\n## Heading "
        "2\ntext under heading 2",
-       "Visual linebreak for  \n\\# Heading 1\ntext under heading  \n\\## "
-       "Heading 2\ntext under heading 2",
+       "Visual linebreak for\n\\# Heading 1 text under heading\n\\## "
+       "Heading 2 text under heading 2",
        "Visual linebreak for\n# Heading 1\ntext under heading\n## Heading "
        "2\ntext under heading 2",
        "Visual linebreak for\n# Heading 1 text under heading\n## Heading 2 "
        "text under heading 2"},
       {"Visual linebreak for\n`inline code`",
-       "Visual linebreak for  \n\\`inline code\\`",
+       "Visual linebreak for\n\\`inline code\\`",
        "Visual linebreak for\n`inline code`",
        "Visual linebreak for\n`inline code`"},
   };
@@ -488,7 +487,7 @@ TEST(Paragraph, ExtraSpaces) {
   Paragraph P;
   P.appendText("foo\n   \t   baz");
   P.appendCode(" bar\n");
-  EXPECT_EQ(P.asEscapedMarkdown(), "foo\n   \t   baz`bar`");
+  EXPECT_EQ(P.asEscapedMarkdown(), "foo baz`bar`");
   EXPECT_EQ(P.asMarkdown(), "foo\n   \t   baz`bar`");
   EXPECT_EQ(P.asPlainText(), "foo bazbar");
 }
@@ -497,7 +496,7 @@ TEST(Paragraph, SpacesCollapsed) {
   Paragraph P;
   P.appendText(" foo bar ");
   P.appendText(" baz ");
-  EXPECT_EQ(P.asEscapedMarkdown(), "foo bar  baz");
+  EXPECT_EQ(P.asEscapedMarkdown(), "foo bar baz");
   EXPECT_EQ(P.asMarkdown(), "foo bar  baz");
   EXPECT_EQ(P.asPlainText(), "foo bar baz");
 }
@@ -507,7 +506,7 @@ TEST(Paragraph, NewLines) {
   Paragraph P;
   P.appendText(" \n foo\nbar\n ");
   P.appendCode(" \n foo\nbar \n ");
-  EXPECT_EQ(P.asEscapedMarkdown(), "foo\nbar\n `foo bar`");
+  EXPECT_EQ(P.asEscapedMarkdown(), "foo bar\n`foo bar`");
   EXPECT_EQ(P.asMarkdown(), "foo\nbar\n `foo bar`");
   EXPECT_EQ(P.asPlainText(), "foo bar foo bar");
 }



More information about the cfe-commits mailing list