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