1 ///
2 module imap.searchquery;
3 
4 import mir.algebraic : Variant, This, match;
5 import std.format : format;
6 import std.datetime : Date;
7 
8 import imap.defines;
9 import imap.socket;
10 import imap.session : Session;
11 import imap.sil : SILdoc;
12 
13 // -------------------------------------------------------------------------------------------------
14 
15 final class SearchQuery {
16     this(const SearchExpr * expr = null) {
17         query = expr;
18     }
19 
20     this(T)(T value) {
21         this(new SearchExpr(value));
22     }
23 
24 
25     SearchQuery not(T)(T value) {
26         return not(new SearchExpr(value));
27     }
28 
29     SearchQuery not(const(SearchExpr) * term) {
30         assert(query is null, "Cannot apply .not() to existing queries!  Use andNot() or orNot().");
31         query = new SearchExpr(SearchOp('!', term, null));
32         return this;
33     }
34 
35     alias and = applyBinOpImpl!('&');
36     alias or = applyBinOpImpl!('|');
37 
38     alias andNot = applyBinOpImpl!('&', true);
39     alias orNot = applyBinOpImpl!('|', true);
40 
41     private template applyBinOpImpl(char op, bool not = false) {
42         SearchQuery applyBinOpImpl(T)(T value) {
43             return applyBinOpImpl(new SearchExpr(value));
44         }
45         SearchQuery applyBinOpImpl(const(SearchExpr) * term) {
46             static if (not) {
47                 return applyBinOp(op, new SearchExpr(SearchOp('!', term, null)));
48             } else {
49                 return applyBinOp(op, term);
50             }
51         }
52         SearchQuery applyBinOpImpl(const SearchQuery other) {
53             return applyBinOpImpl(other.query);
54         }
55     }
56 
57     override string toString() const {
58         return searchExprToString(query);
59     }
60 
61     SearchQuery applyBinOp(char op, const(SearchExpr) * newTerm) {
62         if (query is null) {
63             query = newTerm;
64         } else {
65             query = new SearchExpr(SearchOp(op, query, newTerm));
66         }
67         return this;
68     }
69 
70     const(SearchExpr) *query;
71 }
72 
73 // -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
74 
75 string searchExprToString(const SearchExpr* expr) {
76     import std.algorithm : map;
77     import std.array : join;
78 
79     if (expr is null) {
80         return "ALL";
81     }
82     return (*expr).match!(
83         (FlagTerm term)    => cast(string)term.flag,
84         (KeywordTerm term) => format!"%sKEYWORD %s"(term.negated ? "UN" : "", term.keyword),
85         (FieldTerm term)   => format!`%s "%s"`(cast(string)term.field, term.term),
86         (HeaderTerm term)  => format!`HEADER %s "%s"`(term.header, term.term),
87         (DateTerm term)    => format!"%s %s"(cast(string)term.when, rfcDateStr(term.date)),
88         (SizeTerm term)    => format!"%s %d"(cast(string)term.relation, term.size),
89         (UidSeqTerm term)  => format!"UID %s"(term.sequences.map!(s => s.toString()).join(",")),
90         (SearchOp expr)    => searchOpToString(expr),
91     );
92 }
93 
94 string searchOpToString(SearchOp op) {
95     switch (op.op) {
96         case '!': return notOpToString(op.lhs);
97         case '&': return format!"%s %s"(searchExprToString(op.lhs), searchExprToString(op.rhs));
98         case '|': return orOpToString(op.lhs, op.rhs);
99         default:  assert(false, "Unknown search operation.");
100     }
101 }
102 
103 string notOpToString(const SearchExpr* expr) {
104     string exprStr = searchExprToString(expr);
105     if (isBinaryOp(expr)) {
106         return format!"NOT (%s)"(exprStr);
107     }
108     return format!"NOT %s"(exprStr);
109 }
110 
111 string orOpToString(const(SearchExpr) * lhs, const(SearchExpr) * rhs) {
112     string lhsStr = searchExprToString(lhs);
113     if (isBinaryOp(lhs)) {
114         lhsStr = format!"(%s)"(lhsStr);
115     }
116     string rhsStr = searchExprToString(rhs);
117     if (isBinaryOp(rhs)) {
118         rhsStr = format!"(%s)"(rhsStr);
119     }
120     return format!"OR %s %s"(lhsStr, rhsStr);
121 }
122 
123 bool isBinaryOp(const SearchExpr* expr) {
124     return (*expr).match!(
125         (SearchOp op) => op.op == '&' || op.op == '|',
126         (_)           => false,
127     );
128 }
129 
130 // -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
131 
132 import std.typecons : Tuple;
133 
134 alias SearchExpr = Variant!(
135     FlagTerm,
136     KeywordTerm,
137     FieldTerm,
138     HeaderTerm,
139     DateTerm,
140     SizeTerm,
141     UidSeqTerm,
142 
143     // '!' == NOT, '&' == AND, '|' == OR.
144     Tuple!(char, "op", const(This) *, "lhs", const(This) *, "rhs"),
145 );
146 
147 alias SearchOp = Tuple!(char, "op", const(SearchExpr) *, "lhs", const(SearchExpr) *, "rhs");
148 
149 struct FlagTerm {
150     enum Flag : string {
151         Answered   = "ANSWERED",
152         Deleted    = "DELETED",
153         Draft      = "DRAFT",
154         Flagged    = "FLAGGED",
155         New        = "NEW",
156         Old        = "OLD",
157         Recent     = "RECENT",
158         Seen       = "SEEN",
159         Unanswered = "UNANSWERED",
160         Undeleted  = "UNDELETED",
161         Undraft    = "UNDRAFT",
162         Unflagged  = "UNFLAGGED",
163         Unseen     = "UNSEEN",
164     }
165     Flag flag;
166 }
167 
168 struct KeywordTerm {
169     string keyword;
170     bool negated = false;
171 }
172 
173 struct FieldTerm {
174     enum Field : string {
175         Bcc     = "BCC",
176         Body    = "BODY",
177         Cc      = "CC",
178         From    = "FROM",
179         Subject = "SUBJECT",
180         Text    = "TEXT",
181         To      = "TO",
182     }
183     Field field;
184     string term;
185 }
186 
187 struct HeaderTerm {
188     string header;
189     string term;
190 }
191 
192 struct DateTerm {
193     enum When : string {
194         Before     = "BEFORE",
195         On         = "ON",
196         SentBefore = "SENTBEFORE",
197         SentOn     = "SENTON",
198         SentSince  = "SENTSINCE",
199         Since      = "SINCE",
200     }
201     When when;
202     Date date;
203 }
204 
205 string rfcDateStr(Date date) {
206     return format!"%d-%s-%d"(date.day,
207         ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
208          "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][date.month - 1],
209         date.year);
210 }
211 
212 struct SizeTerm {
213     enum Relation : string {
214         Larger  = "LARGER",
215         Smaller = "SMALLER",
216     }
217     Relation relation;
218     int size;
219 }
220 
221 struct UidSeqTerm {
222     struct Range {
223         // This is a little unintuitive; if length is 0 then there's a single value: start, else
224         // it's a range *including* start+length.  I.e., if length is 1, then it's a range of start
225         // and start+1 (2 values).
226         int start, length = 0;
227 
228         invariant(start >= 1 && length >= 0);
229 
230         string toString() const {
231             if (length == 0)
232                 return format!"%d"(start);
233             return format!"%d:%d"(start, start + length);
234         }
235     }
236     const(Range)[] sequences;
237 }
238 
239 // -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
240 
241 unittest {
242     assert(new SearchQuery().toString == "ALL");
243 
244     void termTest(T)(T term, string expected) {
245         import std.stdio : writeln;
246 
247         auto got = new SearchQuery(term).toString();
248         if (got != expected) {
249             writeln("FAILED MATCH:");
250             writeln("expecting: ", expected);
251             writeln("got:       ", got);
252         }
253         assert(new SearchQuery(new SearchExpr(term)).toString() == expected);
254     }
255 
256     termTest(FlagTerm(FlagTerm.Flag.New), "NEW");
257     termTest(KeywordTerm("foobar"), "KEYWORD foobar");
258     termTest(FieldTerm(FieldTerm.Field.Cc, "alice"), `CC "alice"`);
259     termTest(HeaderTerm("X-SPAM", "high"), `HEADER X-SPAM "high"`);
260     termTest(DateTerm(DateTerm.When.Since, Date(2007, 7, 1)), "SINCE 1-Jul-2007");
261     termTest(SizeTerm(SizeTerm.Relation.Larger, 12345), "LARGER 12345");
262     termTest(UidSeqTerm([UidSeqTerm.Range(1, 3), UidSeqTerm.Range(7), UidSeqTerm.Range(33, 100)]), "UID 1:4,7,33:133");
263 }
264 
265 // -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
266 
267 unittest {
268     import std.conv : to;
269     import std.exception;
270     import core.exception;
271 
272     void queryTest(Q)(Q query, string expected) {
273         import std.stdio : writeln;
274 
275         auto got = query.to!string;
276         if (got != expected) {
277             writeln("FAILED MATCH:");
278             writeln("expecting: ", expected);
279             writeln("got:       ", got);
280         }
281         assert(query.to!string == expected);
282     }
283 
284     // AND.
285     auto sq00 =
286         new SearchQuery(FlagTerm(FlagTerm.Flag.Flagged))
287         .and(FieldTerm(FieldTerm.Field.Subject, "welcome"));
288     queryTest(sq00, `FLAGGED SUBJECT "welcome"`);
289     auto sq01 =
290         new SearchQuery()
291         .and(FlagTerm(FlagTerm.Flag.Unflagged))
292         .and(FieldTerm(FieldTerm.Field.Subject, "welcome"));
293     queryTest(sq01, `UNFLAGGED SUBJECT "welcome"`);
294     auto sq02 =
295         new SearchQuery()
296         .and(FlagTerm(FlagTerm.Flag.Unflagged))
297         .and(FieldTerm(FieldTerm.Field.Subject, "welcome"))
298         .and(KeywordTerm("quux", true));
299     queryTest(sq02, `UNFLAGGED SUBJECT "welcome" UNKEYWORD quux`);
300 
301     // OR.
302     auto sq10 =
303         new SearchQuery(DateTerm(DateTerm.When.On, Date(2012, 12, 12)))
304         .or(SizeTerm(SizeTerm.Relation.Smaller, 1212));
305     queryTest(sq10, "OR ON 12-Dec-2012 SMALLER 1212");
306     auto sq11 =
307         new SearchQuery()
308         .or(DateTerm(DateTerm.When.On, Date(2012, 12, 12)))
309         .or(SizeTerm(SizeTerm.Relation.Smaller, 1212));
310     queryTest(sq11, "OR ON 12-Dec-2012 SMALLER 1212");
311     auto sq12 =
312         new SearchQuery()
313         .or(DateTerm(DateTerm.When.On, Date(2012, 12, 12)))
314         .or(SizeTerm(SizeTerm.Relation.Smaller, 1212))
315         .or(UidSeqTerm([UidSeqTerm.Range(40, 10)]));
316     queryTest(sq12, "OR (OR ON 12-Dec-2012 SMALLER 1212) UID 40:50");
317 
318     // NOT/AND-NOT/OR-NOT.
319     auto sq20 =
320         new SearchQuery()
321         .not(FieldTerm(FieldTerm.Field.To, "bob"));
322     queryTest(sq20, `NOT TO "bob"`);
323     auto sq21 =
324         new SearchQuery()
325         .and(FieldTerm(FieldTerm.Field.To, "bob"))
326         .andNot(FieldTerm(FieldTerm.Field.From, "carlos"));
327     queryTest(sq21, `TO "bob" NOT FROM "carlos"`);
328     auto sq22 =
329         new SearchQuery()
330         .and(FieldTerm(FieldTerm.Field.To, "bob"))
331         .orNot(FieldTerm(FieldTerm.Field.From, "carlos"));
332     queryTest(sq22, `OR TO "bob" NOT FROM "carlos"`);
333     auto sq23 =
334         new SearchQuery()
335         .or(FieldTerm(FieldTerm.Field.To, "bob"))
336         .orNot(FieldTerm(FieldTerm.Field.From, "carlos"));
337     queryTest(sq23, `OR TO "bob" NOT FROM "carlos"`);
338     auto sq24 =
339         new SearchQuery()
340         .not(FieldTerm(FieldTerm.Field.To, "bob"))
341         .orNot(FieldTerm(FieldTerm.Field.From, "carlos"));
342     queryTest(sq24, `OR NOT TO "bob" NOT FROM "carlos"`);
343     assertThrown!AssertError(new SearchQuery()
344             .and(FieldTerm(FieldTerm.Field.To, "bob"))
345             .not(FieldTerm(FieldTerm.Field.From, "carlos")));
346     version (none) {
347         // This would be nice, but special casing FlagTerm in this way would require too much guff.
348         auto sq25 =
349             new SearchQuery()
350             .not(FlagTerm(FlagTerm.Flag.Answered))
351             .andNot(FlagTerm(FlagTerm.Flag.Deleted))
352             .andNot(FlagTerm(FlagTerm.Flag.Draft))
353             .andNot(FlagTerm(FlagTerm.Flag.Flagged))
354             .andNot(FlagTerm(FlagTerm.Flag.New))
355             .andNot(FlagTerm(FlagTerm.Flag.Old))
356             .andNot(FlagTerm(FlagTerm.Flag.Recent))
357             .andNot(FlagTerm(FlagTerm.Flag.Seen))
358             .andNot(KeywordTerm("xyzzy"));
359         queryTest(sq25, `UNANSWERED UNDELETED UNDRAFT UNFLAGGED NOT NEW RECENT OLD UNSEEN UNKEYWORD xyzzy`);
360     }
361 
362     // DEEP NESTING.
363     // a && (b || c) && d
364     auto sq30_BorC =
365         new SearchQuery(FlagTerm(FlagTerm.Flag.Draft))
366         .or(FlagTerm(FlagTerm.Flag.Flagged));
367     auto sq30 =
368         new SearchQuery()
369         .and(FieldTerm(FieldTerm.Field.To, "alice"))
370         .and(sq30_BorC)
371         .and(FieldTerm(FieldTerm.Field.Subject, "wip"));
372     queryTest(sq30, `TO "alice" OR DRAFT FLAGGED SUBJECT "wip"`);
373     // a || (b && c) || d
374     auto sq31_BandC =
375         new SearchQuery(FlagTerm(FlagTerm.Flag.Draft))
376         .and(FlagTerm(FlagTerm.Flag.Flagged));
377     auto sq31 =
378         new SearchQuery()
379         .or(FieldTerm(FieldTerm.Field.To, "alice"))
380         .or(sq31_BandC)
381         .or(FieldTerm(FieldTerm.Field.Subject, "wip"));
382     queryTest(sq31, `OR (OR TO "alice" (DRAFT FLAGGED)) SUBJECT "wip"`);
383     // a || !(b || c) || d  (Using De Morgan we *could* rewrite to a || (b && c) || d).
384     auto sq32_BorC =
385         new SearchQuery(FlagTerm(FlagTerm.Flag.Draft))
386         .or(FlagTerm(FlagTerm.Flag.Flagged));
387     auto sq32 =
388         new SearchQuery()
389         .or(FieldTerm(FieldTerm.Field.To, "alice"))
390         .orNot(sq32_BorC)
391         .or(FieldTerm(FieldTerm.Field.Subject, "wip"));
392     queryTest(sq32, `OR (OR TO "alice" NOT (OR DRAFT FLAGGED)) SUBJECT "wip"`);
393     // a && !(b || c) && d
394     auto sq33_BorC =
395         new SearchQuery(FlagTerm(FlagTerm.Flag.Draft))
396         .or(FlagTerm(FlagTerm.Flag.Flagged));
397     auto sq33 =
398         new SearchQuery()
399         .and(FieldTerm(FieldTerm.Field.To, "alice"))
400         .andNot(sq33_BorC)
401         .and(FieldTerm(FieldTerm.Field.Subject, "wip"));
402     queryTest(sq33, `TO "alice" NOT (OR DRAFT FLAGGED) SUBJECT "wip"`);
403 }
404 
405 // -------------------------------------------------------------------------------------------------
406