From bf0adb725a45f1980ab6da0c0ae998d7d3e2f14e Mon Sep 17 00:00:00 2001 From: Jason Zondor Date: Tue, 1 Sep 2026 20:01:53 -0500 Subject: [PATCH 1/3] Don't apply emphasis or links inside inline code spans inlineMarkup() ran the bold, italic, and link regexes over the whole line without regard for backtick-delimited code spans, so text like `The_brown_fox` had "brown" styled as italic (and the caret skipped the underscores as if they were hidden markers). Collect the inline code-span ranges first and skip any emphasis or link match that overlaps one. Fixes #47 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MfBmFxT8EkK9Aan5bSopm5 --- src/markdownhighlighter.cpp | 25 +++++++++++++++++++++++++ tests/tst_omawrite.cpp | 13 +++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/markdownhighlighter.cpp b/src/markdownhighlighter.cpp index 99bc5de..be7885c 100644 --- a/src/markdownhighlighter.cpp +++ b/src/markdownhighlighter.cpp @@ -210,10 +210,31 @@ QList MarkdownHighlighter::inlineMarkup(const return Span{int(match.capturedStart(group)), int(match.capturedLength(group))}; }; + // Inline code spans suppress every other kind of inline markup, so collect + // their ranges first and skip any emphasis/link match that overlaps one. + 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 insideCode = [&codeSpans](const QRegularExpressionMatch &match) { + const int start = match.capturedStart(0); + const int end = start + match.capturedLength(0); + for (const Span &code : codeSpans) { + if (start < code.start + code.length && code.start < end) + return true; + } + return false; + }; + 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 +244,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 +257,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..38e1e0e 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -54,6 +54,19 @@ 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); + } + void loadsCurrentOmarchyTheme() { QTemporaryDir homeDirectory; QVERIFY(homeDirectory.isValid()); From 5f608212d181134573293c3a41e204304be509b2 Mon Sep 17 00:00:00 2001 From: Omabot Date: Wed, 2 Sep 2026 05:34:49 -0700 Subject: [PATCH 2/3] Test the code-span delimiters, not the whole match Rejecting every emphasis or link whose range overlaps a code span also rejects markup that merely encloses one. `_a `b` c_` lost its italics and `[see `code`](url)` stopped being a link, both of which worked before this branch. A code span makes its own contents literal; it does not make the text around it literal. Testing the two delimiter positions instead keeps the reported case fixed -- in `` `The_brown_fox` `` both underscores sit inside the code span -- while leaving enclosing markup alone. It also still rejects markup that straddles a boundary, as in `_a `b_ c` d_`, where the closing underscore is inside the code span and so is not a marker. Co-Authored-By: Claude Opus 5 (1M context) --- src/markdownhighlighter.cpp | 17 +++++++++++------ tests/tst_omawrite.cpp | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/markdownhighlighter.cpp b/src/markdownhighlighter.cpp index be7885c..eb15f1a 100644 --- a/src/markdownhighlighter.cpp +++ b/src/markdownhighlighter.cpp @@ -210,8 +210,10 @@ QList MarkdownHighlighter::inlineMarkup(const return Span{int(match.capturedStart(group)), int(match.capturedLength(group))}; }; - // Inline code spans suppress every other kind of inline markup, so collect - // their ranges first and skip any emphasis/link match that overlaps one. + // 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("`([^`]+)`")); @@ -219,15 +221,18 @@ QList MarkdownHighlighter::inlineMarkup(const while (codeMatches.hasNext()) codeSpans.append(span(codeMatches.next(), 0)); } - const auto insideCode = [&codeSpans](const QRegularExpressionMatch &match) { - const int start = match.capturedStart(0); - const int end = start + match.capturedLength(0); + const auto codeCovers = [&codeSpans](int index) { for (const Span &code : codeSpans) { - if (start < code.start + code.length && code.start < end) + 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); diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 38e1e0e..cf56090 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -67,6 +67,33 @@ private slots: 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); + + // The closing underscore is inside the code span, so it is not a marker. + QCOMPARE(MarkdownHighlighter::inlineMarkup( + QStringLiteral("_a `b_ c` d_")).size(), 0); + } + void loadsCurrentOmarchyTheme() { QTemporaryDir homeDirectory; QVERIFY(homeDirectory.isValid()); From 0972a84baf3feefebf00fad9adcd15702e9c632b Mon Sep 17 00:00:00 2001 From: Omabot Date: Wed, 2 Sep 2026 05:45:34 -0700 Subject: [PATCH 3/3] Style an enclosed code span as code, not as emphasis Now that emphasis may enclose a code span, the two passes overlap on the code span's own range, and setFormat replaces rather than merges -- Qt assigns the whole QTextCharFormat to every covered character. Running the code pass first meant the enclosing emphasis overwrote it, so `_a `b` c_` italicised the code span and dropped its background, which is the thing the issue asked to stop. Running it last makes the code styling win on exactly the nested range: `a ` and ` c` stay italic, `` `b` `` keeps the code background and is not italicised. The markers are untouched either way, because markup with a delimiter inside a code span is already rejected. The test reads the block layout's format ranges rather than asserting on spans, since this is about which pass wins where the two overlap. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- src/markdownhighlighter.cpp | 20 ++++++++++--------- tests/tst_omawrite.cpp | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/markdownhighlighter.cpp b/src/markdownhighlighter.cpp index eb15f1a..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) { diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index cf56090..da0c4be 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -1,5 +1,8 @@ #include #include +#include +#include +#include #include #include #include @@ -89,11 +92,47 @@ private slots: 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());