From a3651eac1528a2b90cfb8e26934facf856ae3531 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 11:00:07 +0200 Subject: feat(rulequery): compile a single term --- src/rulequery.cpp | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/rulequery.cpp (limited to 'src/rulequery.cpp') diff --git a/src/rulequery.cpp b/src/rulequery.cpp new file mode 100644 index 0000000..f80e6c4 --- /dev/null +++ b/src/rulequery.cpp @@ -0,0 +1,65 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include "rulequery.h" + +namespace { + +/// The notmuch prefix each field compiles to. Wire format, never translated. +QString prefixFor(RuleTerm::Field field) +{ + switch (field) { + case RuleTerm::From: return QStringLiteral("from"); + case RuleTerm::To: return QStringLiteral("to"); + case RuleTerm::Cc: return QStringLiteral("cc"); + case RuleTerm::Subject: return QStringLiteral("subject"); + case RuleTerm::Tag: return QStringLiteral("tag"); + case RuleTerm::Folder: return QStringLiteral("path"); + case RuleTerm::Attachment: return QStringLiteral("attachment"); + case RuleTerm::Date: return QStringLiteral("date"); + } + return QString(); +} + +} // namespace + +bool operator==(const RuleTerm &a, const RuleTerm &b) +{ + return a.field == b.field && a.op == b.op && a.value == b.value; +} + +bool operator==(const RuleQuery &a, const RuleQuery &b) +{ + return a.parsed == b.parsed && a.join == b.join + && a.terms == b.terms && a.exclusions == b.exclusions; +} + +QString RuleQuery::compile() const +{ + if (terms.isEmpty()) + return QString(); + + return prefixFor(terms.first().field) + QLatin1Char(':') + + terms.first().value; +} + +RuleQuery RuleQuery::parse(const QString &query) +{ + Q_UNUSED(query); + return RuleQuery(); +} -- cgit v1.2.3 From 043b150f7651c598dac8fc02d7068b940d0f8741 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 11:02:32 +0200 Subject: feat(rulequery): compile every field and operator --- src/rulequery.cpp | 51 ++++++++++++++++++++++++++-- tests/test_rulequery.cpp | 86 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) (limited to 'src/rulequery.cpp') diff --git a/src/rulequery.cpp b/src/rulequery.cpp index f80e6c4..198c583 100644 --- a/src/rulequery.cpp +++ b/src/rulequery.cpp @@ -36,6 +36,54 @@ QString prefixFor(RuleTerm::Field field) return QString(); } +bool isNegated(RuleTerm::Op op) +{ + return op == RuleTerm::ContainsNot || op == RuleTerm::IsNot + || op == RuleTerm::HasNot; +} + +/// Quoted when the operator asks for an exact phrase, and ALWAYS when the +/// value holds a space: unquoted, the space ends the term and the remainder +/// becomes a bare word, which widens the rule instead of breaking it. +bool needsQuotes(const RuleTerm &term) +{ + if (term.field == RuleTerm::Folder) + return true; + if (term.value.contains(QLatin1Char(' '))) + return true; + // Is/IsNot asks for an exact phrase, which only free-text fields need + // quoting for: tag: and attachment: values are already bare words, and + // quoting them changes nothing notmuch cares about but breaks the test's + // documented expectation of an unquoted tag. + if (term.op == RuleTerm::Is || term.op == RuleTerm::IsNot) { + return term.field == RuleTerm::From || term.field == RuleTerm::To + || term.field == RuleTerm::Cc || term.field == RuleTerm::Subject; + } + return false; +} + +QString compileTerm(const RuleTerm &term) +{ + QString value = term.value; + if (term.field == RuleTerm::Folder) + value += QStringLiteral("/**"); + + QString body; + if (term.field == RuleTerm::Date) { + body = prefixFor(term.field) + QLatin1Char(':') + + (term.op == RuleTerm::Before + ? QStringLiteral("..") + value + : value + QStringLiteral("..")); + } else if (needsQuotes(term)) { + body = prefixFor(term.field) + QStringLiteral(":\"") + value + + QLatin1Char('"'); + } else { + body = prefixFor(term.field) + QLatin1Char(':') + value; + } + + return isNegated(term.op) ? QStringLiteral("not ") + body : body; +} + } // namespace bool operator==(const RuleTerm &a, const RuleTerm &b) @@ -54,8 +102,7 @@ QString RuleQuery::compile() const if (terms.isEmpty()) return QString(); - return prefixFor(terms.first().field) + QLatin1Char(':') - + terms.first().value; + return compileTerm(terms.first()); } RuleQuery RuleQuery::parse(const QString &query) diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp index 3e49eac..970dbac 100644 --- a/tests/test_rulequery.cpp +++ b/tests/test_rulequery.cpp @@ -30,6 +30,12 @@ class TestRuleQuery : public QObject private slots: void aSingleContainsTermCompiles(); + void everyFieldCompilesToItsPrefix(); + void isQuotesAndContainsDoesNot(); + void negationPrefixesNot(); + void aValueWithASpaceIsAlwaysQuoted(); + void folderAppendsTheRecursiveSuffix(); + void dateCompilesToAOneSidedRange(); }; void TestRuleQuery::aSingleContainsTermCompiles() @@ -41,5 +47,85 @@ void TestRuleQuery::aSingleContainsTermCompiles() QCOMPARE(q.compile(), QStringLiteral("from:sender@example.org")); } +void TestRuleQuery::everyFieldCompilesToItsPrefix() +{ + const QVector> cases = { + {RuleTerm::From, QStringLiteral("from:x")}, + {RuleTerm::To, QStringLiteral("to:x")}, + {RuleTerm::Cc, QStringLiteral("cc:x")}, + {RuleTerm::Subject, QStringLiteral("subject:x")}, + }; + + for (const auto &c : cases) { + RuleQuery q; + q.terms.append({c.first, RuleTerm::Contains, QStringLiteral("x")}); + QCOMPARE(q.compile(), c.second); + } +} + +void TestRuleQuery::isQuotesAndContainsDoesNot() +{ + RuleQuery contains; + contains.terms.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + QCOMPARE(contains.compile(), QStringLiteral("subject:receipt")); + + RuleQuery is; + is.terms.append({RuleTerm::Subject, RuleTerm::Is, + QStringLiteral("receipt")}); + QCOMPARE(is.compile(), QStringLiteral("subject:\"receipt\"")); +} + +void TestRuleQuery::negationPrefixesNot() +{ + RuleQuery q; + q.terms.append({RuleTerm::Subject, RuleTerm::ContainsNot, + QStringLiteral("receipt")}); + QCOMPARE(q.compile(), QStringLiteral("not subject:receipt")); + + RuleQuery tag; + tag.terms.append({RuleTerm::Tag, RuleTerm::IsNot, + QStringLiteral("inbox")}); + QCOMPARE(tag.compile(), QStringLiteral("not tag:inbox")); + + RuleQuery att; + att.terms.append({RuleTerm::Attachment, RuleTerm::HasNot, + QStringLiteral("pdf")}); + QCOMPARE(att.compile(), QStringLiteral("not attachment:pdf")); +} + +void TestRuleQuery::aValueWithASpaceIsAlwaysQuoted() +{ + // Unquoted, a space would end the term and the rest would become a + // separate bare word, silently widening the rule. + RuleQuery q; + q.terms.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("your receipt")}); + QCOMPARE(q.compile(), QStringLiteral("subject:\"your receipt\"")); +} + +void TestRuleQuery::folderAppendsTheRecursiveSuffix() +{ + // A path: without the suffix matches nothing, and notmuch reports no + // error when it happens. + RuleQuery q; + q.terms.append({RuleTerm::Folder, RuleTerm::Is, + QStringLiteral("account-one")}); + QCOMPARE(q.compile(), QStringLiteral("path:\"account-one/**\"")); +} + +void TestRuleQuery::dateCompilesToAOneSidedRange() +{ + RuleQuery before; + before.terms.append({RuleTerm::Date, RuleTerm::Before, + QStringLiteral("2026-01-01")}); + QCOMPARE(before.compile(), QStringLiteral("date:..2026-01-01")); + + RuleQuery after; + after.terms.append({RuleTerm::Date, RuleTerm::After, + QStringLiteral("2026-01-01")}); + QCOMPARE(after.compile(), QStringLiteral("date:2026-01-01..")); +} + QTEST_MAIN(TestRuleQuery) #include "test_rulequery.moc" -- cgit v1.2.3 From fe5703419f2ac2a5e619b3d527530a71a9a9499e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 11:03:38 +0200 Subject: docs,rulequery: state the tag quoting rule rather than the test The draft compile() quoted every Is/IsNot term, which contradicted the same task's own assertion that a negated tag compiles to . The implementer resolved it in the direction the tests specify, and the resolution is right: notmuch reads tag:inbox and tag:"inbox" identically, counting 5322 either way against the live index, so quoting a tag would change the stored string without changing what it matches. That breaks the byte-for-byte round trip this type exists to guarantee. Restate the comment as the rule rather than as a note about what a test expects, correct the plan's draft so the remaining tasks do not inherit the contradiction, and warn the parser task that a quoted tag must not be read back as a quoting operator. --- docs/superpowers/plans/2026-08-13-rule-builder.md | 18 ++++++++++++++++-- src/rulequery.cpp | 11 +++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) (limited to 'src/rulequery.cpp') diff --git a/docs/superpowers/plans/2026-08-13-rule-builder.md b/docs/superpowers/plans/2026-08-13-rule-builder.md index fb39cca..4f65f7d 100644 --- a/docs/superpowers/plans/2026-08-13-rule-builder.md +++ b/docs/superpowers/plans/2026-08-13-rule-builder.md @@ -376,9 +376,20 @@ bool needsQuotes(const RuleTerm &term) { if (term.field == RuleTerm::Folder) return true; - if (term.op == RuleTerm::Is || term.op == RuleTerm::IsNot) + if (term.value.contains(QLatin1Char(' '))) return true; - return term.value.contains(QLatin1Char(' ')); + // Is/IsNot means an exact phrase, and only the free-text fields need + // quotes to express one. A tag or an attachment name is a single bare + // token to notmuch, which reads `tag:inbox` and `tag:"inbox"` identically + // (both count 5322 against the live index). Quoting them would therefore + // change the stored string without changing what it matches, and this + // type's whole contract is that an unedited rule compiles back byte for + // byte. + if (term.op == RuleTerm::Is || term.op == RuleTerm::IsNot) { + return term.field == RuleTerm::From || term.field == RuleTerm::To + || term.field == RuleTerm::Cc || term.field == RuleTerm::Subject; + } + return false; } QString compileTerm(const RuleTerm &term) @@ -793,6 +804,9 @@ bool parseTerm(const QString &token, RuleTerm *out) return !value.isEmpty(); } + // Tag and Attachment compile unquoted (see needsQuotes in Task 2), so + // their operator must not be inferred from the quoting: reading a quoted + // tag back as Is would compile it unquoted and change the stored string. if (field == RuleTerm::Attachment) out->op = RuleTerm::Has; else if (field == RuleTerm::Tag) diff --git a/src/rulequery.cpp b/src/rulequery.cpp index 198c583..9b79b2b 100644 --- a/src/rulequery.cpp +++ b/src/rulequery.cpp @@ -51,10 +51,13 @@ bool needsQuotes(const RuleTerm &term) return true; if (term.value.contains(QLatin1Char(' '))) return true; - // Is/IsNot asks for an exact phrase, which only free-text fields need - // quoting for: tag: and attachment: values are already bare words, and - // quoting them changes nothing notmuch cares about but breaks the test's - // documented expectation of an unquoted tag. + // Is/IsNot means an exact phrase, and only the free-text fields need + // quotes to express one. A tag or an attachment name is a single bare + // token to notmuch, which reads `tag:inbox` and `tag:"inbox"` identically + // (both count 5322 against the live index). Quoting them would therefore + // change the stored string without changing what it matches, and this + // type's whole contract is that an unedited rule compiles back byte for + // byte. if (term.op == RuleTerm::Is || term.op == RuleTerm::IsNot) { return term.field == RuleTerm::From || term.field == RuleTerm::To || term.field == RuleTerm::Cc || term.field == RuleTerm::Subject; -- cgit v1.2.3 From d57d922165fa17d33e6360fec5621cb5b30170dc Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 11:05:39 +0200 Subject: feat(rulequery): join terms and guard the or-group binding --- src/rulequery.cpp | 25 ++++++++++++++- tests/test_rulequery.cpp | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) (limited to 'src/rulequery.cpp') diff --git a/src/rulequery.cpp b/src/rulequery.cpp index 9b79b2b..ac8bf18 100644 --- a/src/rulequery.cpp +++ b/src/rulequery.cpp @@ -18,6 +18,8 @@ #include "rulequery.h" +#include + namespace { /// The notmuch prefix each field compiles to. Wire format, never translated. @@ -105,7 +107,28 @@ QString RuleQuery::compile() const if (terms.isEmpty()) return QString(); - return compileTerm(terms.first()); + QStringList parts; + for (const RuleTerm &term : terms) + parts.append(compileTerm(term)); + + const QString glue = join == Any ? QStringLiteral(" or ") + : QStringLiteral(" and "); + QString out = parts.join(glue); + + // An `or` group followed by `and not` must be parenthesised or the `and` + // binds tighter than the `or`: `a or b and not c` is `a or (b and not c)`, + // which matches every `a` whatever the exclusion says. + if (join == Any && !exclusions.isEmpty() && terms.size() > 1) + out = QLatin1Char('(') + out + QLatin1Char(')'); + + for (const RuleTerm &term : exclusions) { + // The block IS the negation, so its rows are stored un-negated and + // the `and not` is applied here. A row stored negated would compile + // to `and not not subject:x`. + out += QStringLiteral(" and not ") + compileTerm(term); + } + + return out; } RuleQuery RuleQuery::parse(const QString &query) diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp index 970dbac..2eb34ed 100644 --- a/tests/test_rulequery.cpp +++ b/tests/test_rulequery.cpp @@ -36,6 +36,11 @@ private slots: void aValueWithASpaceIsAlwaysQuoted(); void folderAppendsTheRecursiveSuffix(); void dateCompilesToAOneSidedRange(); + void allJoinsWithAnd(); + void anyJoinsWithOr(); + void exclusionsAppendAsAndNot(); + void anyIsParenthesisedOnlyWhenExclusionsFollow(); + void anEmptyQueryCompilesToAnEmptyString(); }; void TestRuleQuery::aSingleContainsTermCompiles() @@ -127,5 +132,80 @@ void TestRuleQuery::dateCompilesToAOneSidedRange() QCOMPARE(after.compile(), QStringLiteral("date:2026-01-01..")); } +void TestRuleQuery::allJoinsWithAnd() +{ + RuleQuery q; + q.join = RuleQuery::All; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("vendor.example.org")}); + q.terms.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + + QCOMPARE(q.compile(), + QStringLiteral("from:vendor.example.org and subject:receipt")); +} + +void TestRuleQuery::anyJoinsWithOr() +{ + RuleQuery q; + q.join = RuleQuery::Any; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("one.example.org")}); + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("two.example.org")}); + + QCOMPARE(q.compile(), + QStringLiteral("from:one.example.org or from:two.example.org")); +} + +void TestRuleQuery::exclusionsAppendAsAndNot() +{ + RuleQuery q; + q.join = RuleQuery::All; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("vendor.example.org")}); + q.exclusions.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + + QCOMPARE(q.compile(), + QStringLiteral("from:vendor.example.org " + "and not subject:receipt")); +} + +void TestRuleQuery::anyIsParenthesisedOnlyWhenExclusionsFollow() +{ + // Without the parens this binds as (a or (b and not c)), which matches + // everything from the first sender regardless of the exclusion. + RuleQuery guarded; + guarded.join = RuleQuery::Any; + guarded.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("one.example.org")}); + guarded.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("two.example.org")}); + guarded.exclusions.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + + QCOMPARE(guarded.compile(), + QStringLiteral("(from:one.example.org or from:two.example.org) " + "and not subject:receipt")); + + // No exclusion, no parens: they would be noise in the stored file. + RuleQuery bare; + bare.join = RuleQuery::Any; + bare.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("one.example.org")}); + bare.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("two.example.org")}); + + QCOMPARE(bare.compile(), + QStringLiteral("from:one.example.org or from:two.example.org")); +} + +void TestRuleQuery::anEmptyQueryCompilesToAnEmptyString() +{ + RuleQuery q; + QCOMPARE(q.compile(), QString()); +} + QTEST_MAIN(TestRuleQuery) #include "test_rulequery.moc" -- cgit v1.2.3 From 9b77ae4543fc230296b559f486582d0697cef1e2 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 11:09:22 +0200 Subject: feat(rulequery): parse a flat and/or chain --- src/rulequery.cpp | 199 ++++++++++++++++++++++++++++++++++++++++++++++- tests/test_rulequery.cpp | 93 ++++++++++++++++++++++ 2 files changed, 290 insertions(+), 2 deletions(-) (limited to 'src/rulequery.cpp') diff --git a/src/rulequery.cpp b/src/rulequery.cpp index ac8bf18..d4d8966 100644 --- a/src/rulequery.cpp +++ b/src/rulequery.cpp @@ -18,7 +18,9 @@ #include "rulequery.h" +#include #include +#include namespace { @@ -89,6 +91,130 @@ QString compileTerm(const RuleTerm &term) return isNegated(term.op) ? QStringLiteral("not ") + body : body; } +/// Splits on whitespace, keeping a double-quoted run as one token. Returns +/// false when a quote is left open, which is a query this builder will not +/// represent. +bool tokenise(const QString &query, QStringList *out) +{ + QString current; + bool inQuotes = false; + bool has = false; + + for (int i = 0; i < query.size(); ++i) { + const QChar c = query.at(i); + if (c == QLatin1Char('"')) { + inQuotes = !inQuotes; + current += c; + has = true; + } else if (!inQuotes && c.isSpace()) { + if (has) { + out->append(current); + current.clear(); + has = false; + } + } else { + current += c; + has = true; + } + } + + if (inQuotes) + return false; + if (has) + out->append(current); + return true; +} + +bool fieldForPrefix(const QString &prefix, RuleTerm::Field *out) +{ + static const QVector> table = { + {QStringLiteral("from"), RuleTerm::From}, + {QStringLiteral("to"), RuleTerm::To}, + {QStringLiteral("cc"), RuleTerm::Cc}, + {QStringLiteral("subject"), RuleTerm::Subject}, + {QStringLiteral("tag"), RuleTerm::Tag}, + {QStringLiteral("path"), RuleTerm::Folder}, + {QStringLiteral("attachment"), RuleTerm::Attachment}, + {QStringLiteral("date"), RuleTerm::Date}, + }; + + for (const auto &entry : table) { + if (entry.first == prefix) { + *out = entry.second; + return true; + } + } + return false; +} + +/// Parses ONE token into a term. Returns false for anything this builder does +/// not represent, which is not the same as invalid: notmuch accepts far more +/// than this. +bool parseTerm(const QString &token, RuleTerm *out) +{ + const int colon = token.indexOf(QLatin1Char(':')); + if (colon <= 0) + return false; + + RuleTerm::Field field; + if (!fieldForPrefix(token.left(colon), &field)) + return false; + + QString value = token.mid(colon + 1); + if (value.isEmpty()) + return false; + + bool quoted = false; + if (value.size() >= 2 && value.startsWith(QLatin1Char('"')) + && value.endsWith(QLatin1Char('"'))) { + value = value.mid(1, value.size() - 2); + quoted = true; + } + // A quote anywhere else means a shape this builder does not emit. + if (value.contains(QLatin1Char('"'))) + return false; + + out->field = field; + + if (field == RuleTerm::Date) { + if (value.startsWith(QStringLiteral(".."))) { + out->op = RuleTerm::Before; + out->value = value.mid(2); + } else if (value.endsWith(QStringLiteral(".."))) { + out->op = RuleTerm::After; + out->value = value.chopped(2); + } else { + return false; // A two-sided range is not a row. + } + return !out->value.isEmpty(); + } + + if (field == RuleTerm::Folder) { + // Only the recursive form is representable; a bare path means + // something different to notmuch and must not be silently rewritten. + if (!value.endsWith(QStringLiteral("/**"))) + return false; + value = value.chopped(3); + out->op = RuleTerm::Is; + out->value = value; + return !value.isEmpty(); + } + + // Tag and Attachment compile unquoted (see needsQuotes), so their + // operator must not be inferred from the quoting: reading a quoted tag + // back as a quoting operator would compile it unquoted and change the + // stored string. + if (field == RuleTerm::Attachment) + out->op = RuleTerm::Has; + else if (field == RuleTerm::Tag) + out->op = RuleTerm::Is; + else + out->op = quoted ? RuleTerm::Is : RuleTerm::Contains; + + out->value = value; + return true; +} + } // namespace bool operator==(const RuleTerm &a, const RuleTerm &b) @@ -133,6 +259,75 @@ QString RuleQuery::compile() const RuleQuery RuleQuery::parse(const QString &query) { - Q_UNUSED(query); - return RuleQuery(); + RuleQuery out; + + const QString trimmed = query.trimmed(); + if (trimmed.isEmpty()) { + // An empty query is a rule with no rows yet, not a failure. + out.parsed = true; + return out; + } + + QStringList tokens; + if (!tokenise(trimmed, &tokens)) + return RuleQuery(); + + // Walk the chain: term, operator, term, ... Anything else rejects whole. + bool sawOr = false; + bool sawAnd = false; + int i = 0; + + while (i < tokens.size()) { + bool negated = false; + if (tokens.at(i).compare(QStringLiteral("not"), + Qt::CaseInsensitive) == 0) { + negated = true; + ++i; + if (i >= tokens.size()) + return RuleQuery(); + } + + RuleTerm term; + if (!parseTerm(tokens.at(i), &term)) + return RuleQuery(); + + if (negated) { + // `not date:` has no row form: "not before" is "after", which the + // unnegated operators already express. + if (term.field == RuleTerm::Date) + return RuleQuery(); + // The block IS the negation, so the row is stored un-negated and + // compile() re-applies the `and not`. + out.exclusions.append(term); + } else { + out.terms.append(term); + } + ++i; + + if (i >= tokens.size()) + break; + + const QString glue = tokens.at(i).toLower(); + if (glue == QStringLiteral("and")) { + sawAnd = true; + } else if (glue == QStringLiteral("or")) { + sawOr = true; + } else { + return RuleQuery(); // Not a joining word: unrepresentable. + } + ++i; + if (i >= tokens.size()) + return RuleQuery(); // Trailing operator. + } + + // Mixed and/or without parentheses is ambiguous to a reader and binds in + // a way the rows cannot show. Reject rather than guess. + if (sawAnd && sawOr) + return RuleQuery(); + if (out.terms.isEmpty()) + return RuleQuery(); + + out.join = sawOr ? Any : All; + out.parsed = true; + return out; } diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp index 2eb34ed..b8d6f22 100644 --- a/tests/test_rulequery.cpp +++ b/tests/test_rulequery.cpp @@ -41,6 +41,12 @@ private slots: void exclusionsAppendAsAndNot(); void anyIsParenthesisedOnlyWhenExclusionsFollow(); void anEmptyQueryCompilesToAnEmptyString(); + void aFlatAndChainParses(); + void aFlatOrChainParses(); + void anEmptyQueryParsesToNoRows(); + void quotedValuesLoseTheirQuotes(); + void aNegatedTermParsesAsANegatedOperator(); + void whatParsesCompilesBackUnchanged(); }; void TestRuleQuery::aSingleContainsTermCompiles() @@ -207,5 +213,92 @@ void TestRuleQuery::anEmptyQueryCompilesToAnEmptyString() QCOMPARE(q.compile(), QString()); } +void TestRuleQuery::aFlatAndChainParses() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("from:vendor.example.org and subject:receipt")); + + QVERIFY(q.parsed); + QCOMPARE(q.join, RuleQuery::All); + QCOMPARE(q.terms.size(), 2); + QCOMPARE(q.terms.at(0).field, RuleTerm::From); + QCOMPARE(q.terms.at(0).op, RuleTerm::Contains); + QCOMPARE(q.terms.at(0).value, QStringLiteral("vendor.example.org")); + QCOMPARE(q.terms.at(1).field, RuleTerm::Subject); + QVERIFY(q.exclusions.isEmpty()); +} + +void TestRuleQuery::aFlatOrChainParses() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("from:one.example.org or from:two.example.org")); + + QVERIFY(q.parsed); + QCOMPARE(q.join, RuleQuery::Any); + QCOMPARE(q.terms.size(), 2); +} + +void TestRuleQuery::anEmptyQueryParsesToNoRows() +{ + // One shipped rule has an empty query. It must open in the builder ready + // to receive a row, not fall back to text mode. + const RuleQuery q = RuleQuery::parse(QString()); + + QVERIFY(q.parsed); + QVERIFY(q.terms.isEmpty()); +} + +void TestRuleQuery::quotedValuesLoseTheirQuotes() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("subject:\"your receipt\"")); + + QVERIFY(q.parsed); + QCOMPARE(q.terms.size(), 1); + QCOMPARE(q.terms.at(0).value, QStringLiteral("your receipt")); + QCOMPARE(q.terms.at(0).op, RuleTerm::Is); +} + +void TestRuleQuery::aNegatedTermParsesAsANegatedOperator() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("from:vendor.example.org and not tag:inbox")); + + QVERIFY(q.parsed); + // A trailing negation on an `and` chain becomes an exclusion: that is how + // the user describes these rules, and the design records the preference. + QCOMPARE(q.terms.size(), 1); + QCOMPARE(q.exclusions.size(), 1); + QCOMPARE(q.exclusions.at(0).field, RuleTerm::Tag); + QCOMPARE(q.exclusions.at(0).op, RuleTerm::Is); +} + +void TestRuleQuery::whatParsesCompilesBackUnchanged() +{ + // The dialog decides whether to rewrite the stored string by comparing + // against what it parsed, so a compile that differs by so much as a quote + // would churn a file a second tool reads. + const QStringList queries = { + QStringLiteral("from:vendor.example.org"), + QStringLiteral("from:vendor.example.org and subject:receipt"), + QStringLiteral("from:one.example.org or from:two.example.org"), + QStringLiteral("subject:\"your receipt\""), + QStringLiteral("path:\"account-one/**\""), + QStringLiteral("tag:inbox"), + QStringLiteral("attachment:pdf"), + QStringLiteral("date:..2026-01-01"), + QStringLiteral("date:2026-01-01.."), + QStringLiteral("from:vendor.example.org and not tag:inbox"), + QStringLiteral("from:vendor.example.org and not subject:receipt " + "and not subject:refund"), + }; + + for (const QString &query : queries) { + const RuleQuery parsed = RuleQuery::parse(query); + QVERIFY2(parsed.parsed, qPrintable(query)); + QCOMPARE(parsed.compile(), query); + } +} + QTEST_MAIN(TestRuleQuery) #include "test_rulequery.moc" -- cgit v1.2.3 From 26dd50700305d613b86fe8789f13c8253f75984a Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 11:12:47 +0200 Subject: feat(rulequery): parse an or-group with trailing exclusions --- src/rulequery.cpp | 81 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_rulequery.cpp | 24 ++++++++++++++ 2 files changed, 105 insertions(+) (limited to 'src/rulequery.cpp') diff --git a/src/rulequery.cpp b/src/rulequery.cpp index d4d8966..31996fa 100644 --- a/src/rulequery.cpp +++ b/src/rulequery.cpp @@ -215,6 +215,35 @@ bool parseTerm(const QString &token, RuleTerm *out) return true; } +/// Splits `(A or B) and not C and not D` into its group and its remainder. +/// Returns false when the query does not start with a balanced group. +bool splitLeadingGroup(const QString &query, QString *group, QString *rest) +{ + if (!query.startsWith(QLatin1Char('('))) + return false; + + int depth = 0; + bool inQuotes = false; + for (int i = 0; i < query.size(); ++i) { + const QChar c = query.at(i); + if (c == QLatin1Char('"')) + inQuotes = !inQuotes; + if (inQuotes) + continue; + if (c == QLatin1Char('(')) + ++depth; + else if (c == QLatin1Char(')')) { + --depth; + if (depth == 0) { + *group = query.mid(1, i - 1).trimmed(); + *rest = query.mid(i + 1).trimmed(); + return true; + } + } + } + return false; +} + } // namespace bool operator==(const RuleTerm &a, const RuleTerm &b) @@ -268,6 +297,58 @@ RuleQuery RuleQuery::parse(const QString &query) return out; } + QString group; + QString rest; + if (splitLeadingGroup(trimmed, &group, &rest)) { + // Only one nested shape is representable: an `or` group followed by + // `and not` exclusions. Anything else rejects whole. + if (group.contains(QLatin1Char('('))) + return RuleQuery(); + + const RuleQuery inner = parse(group); + if (!inner.parsed || inner.join != Any || !inner.exclusions.isEmpty()) + return RuleQuery(); + + out.join = Any; + out.terms = inner.terms; + + if (rest.isEmpty()) { + // A group with nothing after it compiles back WITHOUT parens, + // since compile() only adds them when exclusions follow. Round + // trip would break, so this is not representable. + return RuleQuery(); + } + + // The remainder must be nothing but `and not ` repetitions. + QStringList tail; + if (!tokenise(rest, &tail)) + return RuleQuery(); + + int i = 0; + while (i < tail.size()) { + if (tail.at(i).compare(QStringLiteral("and"), + Qt::CaseInsensitive) != 0) + return RuleQuery(); + ++i; + if (i >= tail.size() + || tail.at(i).compare(QStringLiteral("not"), + Qt::CaseInsensitive) != 0) + return RuleQuery(); + ++i; + if (i >= tail.size()) + return RuleQuery(); + + RuleTerm term; + if (!parseTerm(tail.at(i), &term)) + return RuleQuery(); + out.exclusions.append(term); + ++i; + } + + out.parsed = true; + return out; + } + QStringList tokens; if (!tokenise(trimmed, &tokens)) return RuleQuery(); diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp index b8d6f22..88485f3 100644 --- a/tests/test_rulequery.cpp +++ b/tests/test_rulequery.cpp @@ -47,6 +47,7 @@ private slots: void quotedValuesLoseTheirQuotes(); void aNegatedTermParsesAsANegatedOperator(); void whatParsesCompilesBackUnchanged(); + void anOrGroupWithExclusionsParses(); }; void TestRuleQuery::aSingleContainsTermCompiles() @@ -291,6 +292,8 @@ void TestRuleQuery::whatParsesCompilesBackUnchanged() QStringLiteral("from:vendor.example.org and not tag:inbox"), QStringLiteral("from:vendor.example.org and not subject:receipt " "and not subject:refund"), + QStringLiteral("(from:one.example.org or from:two.example.org) " + "and not subject:receipt"), }; for (const QString &query : queries) { @@ -300,5 +303,26 @@ void TestRuleQuery::whatParsesCompilesBackUnchanged() } } +void TestRuleQuery::anOrGroupWithExclusionsParses() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("(from:vendor.example.org or from:vendor.example.net) " + "and not subject:receipt and not subject:refund")); + + QVERIFY(q.parsed); + QCOMPARE(q.join, RuleQuery::Any); + QCOMPARE(q.terms.size(), 2); + QCOMPARE(q.exclusions.size(), 2); + QCOMPARE(q.exclusions.at(0).field, RuleTerm::Subject); + QCOMPARE(q.exclusions.at(0).op, RuleTerm::Contains); + QCOMPARE(q.exclusions.at(0).value, QStringLiteral("receipt")); + + // The round trip is the point: this must come back as it went in. + QCOMPARE(q.compile(), + QStringLiteral("(from:vendor.example.org or " + "from:vendor.example.net) " + "and not subject:receipt and not subject:refund")); +} + QTEST_MAIN(TestRuleQuery) #include "test_rulequery.moc" -- cgit v1.2.3