diff options
Diffstat (limited to 'src/rulequery.cpp')
| -rw-r--r-- | src/rulequery.cpp | 414 |
1 files changed, 414 insertions, 0 deletions
diff --git a/src/rulequery.cpp b/src/rulequery.cpp new file mode 100644 index 0000000..31996fa --- /dev/null +++ b/src/rulequery.cpp @@ -0,0 +1,414 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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" + +#include <QPair> +#include <QStringList> +#include <QVector> + +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(); +} + +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 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) +{ + 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; +} + +/// 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; +} + +/// 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) +{ + 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(); + + 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) +{ + 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; + } + + 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(); + + // 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; +} |
