aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 11:12:47 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 11:12:47 +0200
commit26dd50700305d613b86fe8789f13c8253f75984a (patch)
treeb6730836a2d3769203926ace424f19034477b063
parentcff230c8e9dba7a95c8ee28932b7a299fc0d994a (diff)
downloadqtmaildir-26dd50700305d613b86fe8789f13c8253f75984a.tar.gz
qtmaildir-26dd50700305d613b86fe8789f13c8253f75984a.zip
feat(rulequery): parse an or-group with trailing exclusions
-rw-r--r--src/rulequery.cpp81
-rw-r--r--tests/test_rulequery.cpp24
2 files changed, 105 insertions, 0 deletions
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 <term>` 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"