diff --git a/src/markdownhighlighter.cpp b/src/markdownhighlighter.cpp index 99bc5de..2739d33 100644 --- a/src/markdownhighlighter.cpp +++ b/src/markdownhighlighter.cpp @@ -178,15 +178,6 @@ void MarkdownHighlighter::highlightMarkers(const QString &text) { } void MarkdownHighlighter::highlightInline(const QString &text) { - if (text.contains(QLatin1Char('`'))) { - static const QRegularExpression codeRe(QStringLiteral("`([^`]+)`")); - QRegularExpressionMatchIterator codeMatches = codeRe.globalMatch(text); - while (codeMatches.hasNext()) { - const QRegularExpressionMatch match = codeMatches.next(); - setFormat(match.capturedStart(0), match.capturedLength(0), m_codeFormat); - } - } - const QList markup = inlineMarkup(text); for (const InlineMarkup &item : markup) { const QTextCharFormat &contentFormat = @@ -197,6 +188,17 @@ void MarkdownHighlighter::highlightInline(const QString &text) { for (const Span &marker : item.markers) setFormat(marker.start, marker.length, m_hiddenMarkerFormat); } + + // Last, so a code span keeps its own styling where emphasis encloses it. + // setFormat replaces rather than merges, so whichever pass runs last wins. + if (text.contains(QLatin1Char('`'))) { + static const QRegularExpression codeRe(QStringLiteral("`([^`]+)`")); + QRegularExpressionMatchIterator codeMatches = codeRe.globalMatch(text); + while (codeMatches.hasNext()) { + const QRegularExpressionMatch match = codeMatches.next(); + setFormat(match.capturedStart(0), match.capturedLength(0), m_codeFormat); + } + } } QList MarkdownHighlighter::inlineMarkup(const QString &text) { @@ -210,10 +212,36 @@ QList MarkdownHighlighter::inlineMarkup(const return Span{int(match.capturedStart(group)), int(match.capturedLength(group))}; }; + // A code span makes its own contents literal, so collect the code ranges + // first and reject any emphasis/link whose delimiters land inside one. It is + // the delimiters that are tested, not the whole match: markup may enclose a + // code span (`_a `b` c_`, `[see `code`](url)`) and still be real markup. + QList codeSpans; + if (text.contains(QLatin1Char('`'))) { + static const QRegularExpression codeRe(QStringLiteral("`([^`]+)`")); + QRegularExpressionMatchIterator codeMatches = codeRe.globalMatch(text); + while (codeMatches.hasNext()) + codeSpans.append(span(codeMatches.next(), 0)); + } + const auto codeCovers = [&codeSpans](int index) { + for (const Span &code : codeSpans) { + if (index >= code.start && index < code.start + code.length) + return true; + } + return false; + }; + const auto insideCode = [&codeCovers](const QRegularExpressionMatch &match) { + const int start = match.capturedStart(0); + const int end = start + match.capturedLength(0); + return codeCovers(start) || codeCovers(end - 1); + }; + static const QRegularExpression boldRe(QStringLiteral("(\\*\\*|__)(.+?)(\\1)")); QRegularExpressionMatchIterator boldMatches = boldRe.globalMatch(text); while (boldMatches.hasNext()) { const QRegularExpressionMatch match = boldMatches.next(); + if (insideCode(match)) + continue; markup.append({InlineKind::Bold, span(match, 2), {span(match, 1), span(match, 3)}}); } @@ -223,6 +251,8 @@ QList MarkdownHighlighter::inlineMarkup(const QRegularExpressionMatchIterator italicMatches = italicRe.globalMatch(text); while (italicMatches.hasNext()) { const QRegularExpressionMatch match = italicMatches.next(); + if (insideCode(match)) + continue; const Span whole = span(match, 0); const int contentIndex = match.capturedStart(1) >= 0 ? 1 : 2; markup.append({InlineKind::Italic, span(match, contentIndex), @@ -234,6 +264,8 @@ QList MarkdownHighlighter::inlineMarkup(const QRegularExpressionMatchIterator linkMatches = linkRe.globalMatch(text); while (linkMatches.hasNext()) { const QRegularExpressionMatch match = linkMatches.next(); + if (insideCode(match)) + continue; const Span whole = span(match, 0); const Span content = span(match, 1); const int contentEnd = content.start + content.length; diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 5c3306a..da0c4be 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -1,5 +1,8 @@ #include #include +#include +#include +#include #include #include #include @@ -54,6 +57,82 @@ private slots: QCOMPARE(markup.at(2).markers[0].length, 1); } + void ignoresInlineMarkdownInsideCodeSpans() { + QCOMPARE(MarkdownHighlighter::inlineMarkup( + QStringLiteral("`The_brown_fox` jumps")).size(), 0); + QCOMPARE(MarkdownHighlighter::inlineMarkup( + QStringLiteral("`a **b** [c](d)` and `e`")).size(), 0); + + const auto mixed = MarkdownHighlighter::inlineMarkup( + QStringLiteral("`code_span` then *real* emphasis")); + QCOMPARE(mixed.size(), 1); + QCOMPARE(mixed.at(0).kind, MarkdownHighlighter::InlineKind::Italic); + QCOMPARE(mixed.at(0).content.start, 18); + } + + // A code span makes its own contents literal; it does not stop markup that + // merely encloses one. Only a delimiter landing inside the code span does. + void keepsMarkupThatEnclosesACodeSpan() { + const auto spanning = MarkdownHighlighter::inlineMarkup( + QStringLiteral("_a `b` c_")); + QCOMPARE(spanning.size(), 1); + QCOMPARE(spanning.at(0).kind, MarkdownHighlighter::InlineKind::Italic); + QCOMPARE(spanning.at(0).content.start, 1); + QCOMPARE(spanning.at(0).content.length, 7); + + const auto linked = MarkdownHighlighter::inlineMarkup( + QStringLiteral("[see `code`](url)")); + QCOMPARE(linked.size(), 1); + QCOMPARE(linked.at(0).kind, MarkdownHighlighter::InlineKind::Link); + QCOMPARE(linked.at(0).content.start, 1); + QCOMPARE(linked.at(0).content.length, 10); + + const auto wrapped = MarkdownHighlighter::inlineMarkup( + QStringLiteral("*`code`*")); + QCOMPARE(wrapped.size(), 1); + QCOMPARE(wrapped.at(0).kind, MarkdownHighlighter::InlineKind::Italic); + + const auto bold = MarkdownHighlighter::inlineMarkup( + QStringLiteral("**a `b` c**")); + QCOMPARE(bold.size(), 1); + QCOMPARE(bold.at(0).kind, MarkdownHighlighter::InlineKind::Bold); + QCOMPARE(bold.at(0).content.start, 2); + QCOMPARE(bold.at(0).content.length, 7); + + // The closing underscore is inside the code span, so it is not a marker. + QCOMPARE(MarkdownHighlighter::inlineMarkup( + QStringLiteral("_a `b_ c` d_")).size(), 0); + } + + // Emphasis may enclose a code span, so the code pass has to run last for the + // code span to keep its own styling. + void stylesAnEnclosedCodeSpanAsCode() { + QTextDocument document; + MarkdownHighlighter highlighter(&document); + document.setPlainText(QStringLiteral("_a `b` c_")); + // The constructor's own rehighlight is queued, so ask for one directly. + highlighter.rehighlight(); + + const QTextBlock block = document.firstBlock(); + QVERIFY(block.isValid()); + const auto formatAt = [&block](int index) { + QTextCharFormat found; + for (const QTextLayout::FormatRange &range : block.layout()->formats()) { + if (index >= range.start && index < range.start + range.length) + found = range.format; + } + return found; + }; + + // "a" is italic and carries no code background. + QVERIFY(formatAt(1).fontItalic()); + QCOMPARE(formatAt(1).background().style(), Qt::NoBrush); + + // The code span keeps the code background and is not italicised. + QVERIFY(formatAt(4).background().style() != Qt::NoBrush); + QVERIFY(!formatAt(4).fontItalic()); + } + void loadsCurrentOmarchyTheme() { QTemporaryDir homeDirectory; QVERIFY(homeDirectory.isValid());