summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/rulequery.cpp199
-rw-r--r--tests/test_rulequery.cpp93
2 files changed, 290 insertions, 2 deletions
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 <QPair>
#include <QStringList>
+#include <QVector>
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<QPair<QString, RuleTerm::Field>> 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"