diff options
| -rw-r--r-- | AGENTS.md | 8 | ||||
| -rw-r--r-- | CHANGELOG.md | 6 | ||||
| -rw-r--r-- | src/searchterm.cpp | 11 | ||||
| -rw-r--r-- | src/searchterm.h | 5 | ||||
| -rw-r--r-- | tests/test_searchterm.cpp | 8 |
5 files changed, 27 insertions, 11 deletions
@@ -472,9 +472,11 @@ labelled Sent that shows the whole Maildir. (`src/searchterm.h`), and that is what stops five surfaces growing five quoting rules.** It holds no widget, so the grammar is tested without a painter or a web engine. Two of its rules are load-bearing rather than cosmetic. `quote()` -escapes backslashes BEFORE quotes, since the other order escapes the -backslashes it just added; it truncates before escaping, so a cut cannot land -mid-escape. And `extend()` parenthesises BOTH sides, because the query bar can +DOUBLES an embedded quote, because notmuch has no backslash escape: a +backslash-escaped quote ends the term, and a selection holding +`x \" or tag:inbox or id:"` walked out of a `tag:nomatch and` scope and matched +every message, measured, where the doubled form matched none. It truncates +before doubling, so a cut cannot land mid-pair. And `extend()` parenthesises BOTH sides, because the query bar can hold a hand-written disjunction and `a or b AND c` binds as `a or (b AND c)`, which widens a search the user asked to narrow, reporting nothing. This is the same trap the `post-new` hook handles when it scopes a rule with `tag:new`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6701417..a92ff4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ point at which they are stable. - The status bar renders its messages as plain text. +### Fixed + +- Searching on selected text containing a double quote no longer lets the rest + of the selection escape the query. Quotes are now doubled, which is notmuch's + own escape; the backslash form ended the quoted term early. + ## [0.30.0] - 2026-09-29 A calendar window over the vdirsyncer vdir, with a month grid, an agenda and diff --git a/src/searchterm.cpp b/src/searchterm.cpp index 6bfb521..16936df 100644 --- a/src/searchterm.cpp +++ b/src/searchterm.cpp @@ -34,10 +34,13 @@ QString quote(const QString &value) if (cleaned.size() > kMaxValueLength) cleaned.truncate(kMaxValueLength); - // Backslashes first: escaping the quotes first would then escape the - // backslashes this step adds, doubling them. - cleaned.replace(QLatin1Char('\\'), QStringLiteral("\\\\")); - cleaned.replace(QLatin1Char('"'), QStringLiteral("\\\"")); + // notmuch escapes a quote inside a quoted term by DOUBLING it and has no + // backslash escape at all. A backslash-escaped quote ENDS the term, so + // `subject:"x \" or tag:inbox or id:"` let a selection walk out of the + // query it was meant to narrow: measured matching every message under a + // `tag:nomatch and` scope, where the doubled form matched none. A + // backslash is literal to notmuch and is left as it is. + cleaned.replace(QLatin1Char('"'), QStringLiteral("\"\"")); return QLatin1Char('"') + cleaned + QLatin1Char('"'); } diff --git a/src/searchterm.h b/src/searchterm.h index 3a0cac2..8ca67e4 100644 --- a/src/searchterm.h +++ b/src/searchterm.h @@ -59,8 +59,9 @@ enum class SearchMode { /// Quotes an arbitrary value for use as a notmuch term. /// /// Whitespace and newlines collapse to single spaces, embedded quotes are -/// escaped, the value is capped at kMaxValueLength, and an empty or -/// whitespace-only value yields an EMPTY STRING rather than `""`. Callers +/// doubled (notmuch has no backslash escape), the value is capped at +/// kMaxValueLength, and an empty or whitespace-only value yields an EMPTY +/// STRING rather than `""`. Callers /// test for empty to decide whether to offer a menu entry at all. QString quote(const QString &value); diff --git a/tests/test_searchterm.cpp b/tests/test_searchterm.cpp index 53185f3..7a0dfd7 100644 --- a/tests/test_searchterm.cpp +++ b/tests/test_searchterm.cpp @@ -56,9 +56,13 @@ void TestSearchTerm::escapesEmbeddedQuotes() { // A selection is arbitrary prose and can carry a quote. Unescaped, it ends // the quoted string early and the rest becomes stray query syntax, which - // notmuch accepts and matches nothing on. + // notmuch accepts and matches nothing on. notmuch DOUBLES a quote; a + // backslash-escaped one ends the term and lets the rest escape the query. QCOMPARE(SearchTerm::quote(QStringLiteral("say \"hello\" now")), - QStringLiteral("\"say \\\"hello\\\" now\"")); + QStringLiteral("\"say \"\"hello\"\" now\"")); + // A backslash is literal to notmuch and must reach it unchanged. + QCOMPARE(SearchTerm::quote(QStringLiteral("x \\\" or tag:inbox")), + QStringLiteral("\"x \\\"\" or tag:inbox\"")); } void TestSearchTerm::collapsesWhitespaceAndNewlines() |
