diff --git a/clang-tools-extra/clangd/Hover.h b/clang-tools-extra/clangd/Hover.h --- a/clang-tools-extra/clangd/Hover.h +++ b/clang-tools-extra/clangd/Hover.h @@ -74,6 +74,9 @@ /// Produce a user-readable information. markup::Document present() const; }; + +void parseLineBreaks(llvm::StringRef Input, markup::Document &Output); + llvm::raw_ostream &operator<<(llvm::raw_ostream &, const HoverInfo::Param &); inline bool operator==(const HoverInfo::Param &LHS, const HoverInfo::Param &RHS) { diff --git a/clang-tools-extra/clangd/Hover.cpp b/clang-tools-extra/clangd/Hover.cpp --- a/clang-tools-extra/clangd/Hover.cpp +++ b/clang-tools-extra/clangd/Hover.cpp @@ -520,6 +520,61 @@ } return llvm::None; } + +bool isParagraphLineBreak(llvm::StringRef Str, size_t LineBreakIndex) { + if (LineBreakIndex + 1 >= Str.size()) { + return false; + } + auto NextNonSpaceCharIndex = Str.find_first_not_of(' ', LineBreakIndex + 1); + + return NextNonSpaceCharIndex != llvm::StringRef::npos && + Str[NextNonSpaceCharIndex] == '\n'; +}; + +bool isMardkownLineBreak(llvm::StringRef Str, size_t LineBreakIndex) { + return LineBreakIndex > 1 && + (Str[LineBreakIndex - 1] == '\\' || + (Str[LineBreakIndex - 1] == ' ' && Str[LineBreakIndex - 2] == ' ')); +}; + +bool isPunctuationLineBreak(llvm::StringRef Str, size_t LineBreakIndex) { + constexpr llvm::StringLiteral Punctuation = R"txt(.:,;!?)txt"; + + return LineBreakIndex > 0 && + Punctuation.find(Str[LineBreakIndex - 1]) != llvm::StringRef::npos; +}; + +bool isFollowedByHardLineBreakIndicator(llvm::StringRef Str, + size_t LineBreakIndex) { + // '-'/'*' md list, '@'/'\' documentation command, '>' md blockquote, + // '#' headings, '`' code blocks + constexpr llvm::StringLiteral LinbreakIdenticators = R"txt(-*@\>#`)txt"; + + auto NextNonSpaceCharIndex = Str.find_first_not_of(' ', LineBreakIndex + 1); + + if (NextNonSpaceCharIndex == llvm::StringRef::npos) { + return false; + } + + auto FollowedBySingleCharIndicator = + LinbreakIdenticators.find(Str[NextNonSpaceCharIndex]) != + llvm::StringRef::npos; + + auto FollowedByNumberedListIndicator = + llvm::isDigit(Str[NextNonSpaceCharIndex]) && + NextNonSpaceCharIndex + 1 < Str.size() && + (Str[NextNonSpaceCharIndex + 1] == '.' || + Str[NextNonSpaceCharIndex + 1] == ')'); + + return FollowedBySingleCharIndicator || FollowedByNumberedListIndicator; +}; + +bool isHardLineBreak(llvm::StringRef Str, size_t LineBreakIndex) { + return isMardkownLineBreak(Str, LineBreakIndex) || + isPunctuationLineBreak(Str, LineBreakIndex) || + isFollowedByHardLineBreakIndicator(Str, LineBreakIndex); +} + } // namespace llvm::Optional getHover(ParsedAST &AST, Position Pos, @@ -652,7 +707,7 @@ } if (!Documentation.empty()) - Output.addParagraph().appendText(Documentation); + parseLineBreaks(Documentation, Output); if (!Definition.empty()) { Output.addRuler(); @@ -675,6 +730,49 @@ return Output; } +// Tries to retain hard line breaks and drops soft line breaks. +void parseLineBreaks(llvm::StringRef Input, markup::Document &Output) { + + constexpr auto WhiteSpaceChars = "\t\n\v\f\r "; + + auto TrimmedInput = Input.trim(); + + std::string CurrentLine; + + for (size_t CharIndex = 0; CharIndex < TrimmedInput.size();) { + if (TrimmedInput[CharIndex] == '\n') { + // Trim whitespace infront of linebreak, also get rif of markdown + // linebreak character `\` + const auto LastNonSpaceCharIndex = + CurrentLine.find_last_not_of(std::string(WhiteSpaceChars) + "\\") + 1; + CurrentLine.erase(LastNonSpaceCharIndex); + + if (isParagraphLineBreak(TrimmedInput, CharIndex)) { + // TODO this should add an actual paragraph (double linebreak) + Output.addParagraph().appendText(CurrentLine); + CurrentLine = ""; + } else if (isHardLineBreak(TrimmedInput, CharIndex)) { + Output.addParagraph().appendText(CurrentLine); + CurrentLine = ""; + } else { + // Ommit linebreak + CurrentLine += ' '; + } + + CharIndex++; + // After a linebreak always remove spaces to avoid 4 space markdown code + // blocks, also skip all additional linebreaks since they have no effect + CharIndex = TrimmedInput.find_first_not_of(WhiteSpaceChars, CharIndex); + } else { + CurrentLine += TrimmedInput[CharIndex]; + CharIndex++; + } + } + if (!CurrentLine.empty()) { + Output.addParagraph().appendText(CurrentLine); + } +} + llvm::raw_ostream &operator<<(llvm::raw_ostream &OS, const HoverInfo::Param &P) { std::vector Output; diff --git a/clang-tools-extra/clangd/unittests/HoverTests.cpp b/clang-tools-extra/clangd/unittests/HoverTests.cpp --- a/clang-tools-extra/clangd/unittests/HoverTests.cpp +++ b/clang-tools-extra/clangd/unittests/HoverTests.cpp @@ -1883,6 +1883,119 @@ } } +TEST(Hover, DocCommentLineBreakConversion) { + struct Case { + llvm::StringRef Documentation; + llvm::StringRef ExpectedRenderMarkdown; + llvm::StringRef ExpectedRenderPlainText; + } Cases[] = {{ + "foo\n\n\nbar", + "foo \nbar", + "foo\nbar", + }, + { + "foo\n\n\n\tbar", + "foo \nbar", + "foo\nbar", + }, + { + "foo\n\n\n bar", + "foo \nbar", + "foo\nbar", + }, + { + "foo.\nbar", + "foo. \nbar", + "foo.\nbar", + }, + { + "foo:\nbar", + "foo: \nbar", + "foo:\nbar", + }, + { + "foo,\nbar", + "foo, \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", + // TODO `*` should probably not be escaped after line break + "foo \n\\*bar", + "foo\n*bar", + }, + { + "foo\n@bar", + "foo \n@bar", + "foo\n@bar", + }, + { + "foo\n\\bar", + // TODO `\` should probably not be escaped after line break + "foo \n\\\\bar", + "foo\n\\bar", + }, + { + "foo\n>bar", + // TODO `>` should probably not be escaped after line break + "foo \n\\>bar", + "foo\n>bar", + }, + { + "foo\n#bar", + "foo \n#bar", + "foo\n#bar", + }, + { + "foo \nbar", + "foo \nbar", + "foo\nbar", + }, + { + "foo \nbar", + "foo \nbar", + "foo\nbar", + }, + { + "foo\\\nbar", + "foo \nbar", + "foo\nbar", + }, + { + "foo\nbar", + "foo bar", + "foo bar", + }}; + + for (const auto &C : Cases) { + markup::Document Output; + parseLineBreaks(C.Documentation, Output); + + EXPECT_EQ(Output.asMarkdown(), C.ExpectedRenderMarkdown); + EXPECT_EQ(Output.asPlainText(), C.ExpectedRenderPlainText); + } +} + // This is a separate test as headings don't create any differences in plaintext // mode. TEST(Hover, PresentHeadings) {