//! A search as it is typed, in Gmail's words, matched against the mail //! loaded so the list narrows at once; Enter asks Gmail itself. //! //! A word or "a phrase" matches who is on a thread, its subject, or its //! snippet. from:, to:, cc:, and subject: match one of those; label: and //! in: a label, by its name or Gmail's id, with /, -, and space alike; //! is:unread, is:read, and is:starred; before:, after:, older:, and //! newer: a date (2026/11/07, or seconds since the epoch), at midnight //! UTC; older_than: and newer_than: an age (3d, 2m, 1y). A - before a //! term leaves out what it matches. Every term must match. What Gmail //! knows and this does not, OR and other operators, is left to Gmail //! and ignored here. `found` tells where in a thread's text its words //! matched, for the list to mark them. const std = @import("std"); const Mailbox = @import("Mailbox.zig"); const ascii = std.ascii; const mem = std.mem; const testing = std.testing; const Allocator = std.mem.Allocator; const print = std.debug.print; const Query = @This(); /// The terms, all of which a thread must match: the first `len`. A /// query is a line typed, so a few suffice, and past them terms are /// ignored. (std has no bounded list in 0.17.) terms: [max_terms]Term, len: u8, const max_terms = 16; pub const Term = struct { /// Whether it leaves out what it matches. negated: bool, what: What, /// Which of the mailbox's people, by place, a term naming people /// matches, found once rather than once a thread; empty for the rest. people: std.bit_set.Dynamic, }; /// What a term matches. Its text points into the typed line. pub const What = union(enum) { /// A word or phrase anywhere: who, subject, snippet. text: []const u8, from: []const u8, to: []const u8, cc: []const u8, subject: []const u8, label: []const u8, unread: bool, starred, /// A message before a time, or at or after it, in seconds since the epoch. before: i64, after: i64, /// Every thread, spam and trash too. anywhere, }; const Operator = enum { from, to, cc, subject, label, in, is, before, after, older, newer, older_than, newer_than }; const operators: std.StaticStringMap(Operator) = .initComptime(.{ .{ "from", .from }, .{ "to", .to }, .{ "cc", .cc }, .{ "subject", .subject }, .{ "label", .label }, .{ "in", .in }, .{ "is", .is }, .{ "before", .before }, .{ "after", .after }, .{ "older", .older }, .{ "newer", .newer }, .{ "older_than", .older_than }, .{ "newer_than", .newer_than }, }); /// The query `text` asks of `mail`, with ages counted back from `now`, /// in seconds since the epoch. It points into `text`, and matches only /// the people `mail` has now. pub fn init(gpa: Allocator, text: []const u8, now: i64, mail: *const Mailbox) Allocator.Error!Query { var q = parse(text, now); errdefer q.deinit(gpa); for (q.terms[0..q.len]) |*t| { const s = switch (t.what) { .text, .from, .to, .cc => |s| s, else => continue, }; t.people = try .initEmpty(gpa, mail.people.count()); for (0..mail.people.count()) |i| { const p = mail.person(@intCast(i)); if (has(p.name, s) or has(p.address, s)) t.people.set(i); } } return q; } pub fn deinit(q: *Query, gpa: Allocator) void { for (q.terms[0..q.len]) |*t| t.people.deinit(gpa); q.* = undefined; } /// The query `text` asks, with ages counted back from `now`, matching no /// people of a mailbox: for one that is not, the search index's. It /// points into `text`, and owns nothing. pub fn parse(text: []const u8, now: i64) Query { var q: Query = .{ .terms = undefined, .len = 0 }; var rest = text; while (nextToken(&rest)) |token| { if (q.len == max_terms) break; const t = term(token, now) orelse continue; q.terms[q.len] = t; q.len += 1; } return q; } /// Whether thread `t` of `mail` matches every term. pub fn matches(q: *const Query, mail: *const Mailbox, t: *const Mailbox.Thread) bool { for (q.terms[0..q.len]) |*term_| { if (matchesTerm(mail, t, term_) == term_.negated) return false; } return true; } /// Where a thread's text shows: who is on it, its subject, its snippet. pub const Field = enum { who, subject, snippet }; /// Bytes of a field a term matched. pub const Range = struct { start: u32, end: u32 }; /// Where the terms that name text matched `text`, a thread's `field`, /// in order and apart, as many as `buf` holds: a word or phrase in any /// field, from:, to:, and cc: who is on it, subject: its subject. A term /// that leaves out what it matches marks nothing, nor does a name only /// a message's people hold. pub fn found(q: *const Query, field: Field, text: []const u8, buf: []Range) []Range { var n: usize = 0; for (q.terms[0..q.len]) |*t| { if (t.negated) continue; const needle = switch (t.what) { .text => |s| s, .from, .to, .cc => |s| if (field == .who) s else continue, .subject => |s| if (field == .subject) s else continue, else => continue, }; var at: usize = 0; while (n < buf.len) { const start = at + (ascii.findIgnoreCase(text[at..], needle) orelse break); var end = start + needle.len; // A word cut inside a character, as a full field cuts one, // marks the whole character. while (end < text.len and text[end] & 0xc0 == 0x80) end += 1; buf[n] = .{ .start = @intCast(start), .end = @intCast(end) }; n += 1; at = end; } } mem.sort(Range, buf[0..n], {}, startsFirst); // Overlapping or touching ranges are one. var merged: usize = 0; for (buf[0..n]) |r| { if (merged > 0 and r.start <= buf[merged - 1].end) { buf[merged - 1].end = @max(buf[merged - 1].end, r.end); } else { buf[merged] = r; merged += 1; } } return buf[0..merged]; } fn startsFirst(_: void, a: Range, b: Range) bool { return a.start < b.start; } /// Whether the query asks for spam or trash, which views otherwise leave /// out. pub fn showsHidden(q: *const Query) bool { for (q.terms[0..q.len]) |*t| { if (t.negated) continue; switch (t.what) { .anywhere => return true, .label => |name| if (sameLabel(name, "spam") or sameLabel(name, "trash")) return true, else => {}, } } return false; } /// The next term of `rest`, with its - and quotes, and `rest` past it. /// Brackets, Gmail's grouping, are taken as spaces. fn nextToken(rest: *[]const u8) ?[]const u8 { const s = rest.*; var start: usize = 0; while (start < s.len and isGap(s[start])) start += 1; if (start == s.len) return null; var end = start; var quoted = false; while (end < s.len and (quoted or !isGap(s[end]))) : (end += 1) { if (s[end] == '"') quoted = !quoted; } rest.* = s[end..]; return s[start..end]; } fn isGap(c: u8) bool { return ascii.isWhitespace(c) or mem.findScalar(u8, "(){}", c) != null; } /// The term a token asks, or null for one to ignore: OR, an operator /// this does not know, or one with nothing after it yet. fn term(token: []const u8, now: i64) ?Term { const negated = token.len > 1 and token[0] == '-'; const t = if (negated) token[1..] else token; if (mem.eql(u8, t, "OR") or mem.eql(u8, t, "AND")) return null; const colon = mem.findScalar(u8, t, ':'); const op_name = if (colon) |c| t[0..c] else ""; const is_operator = op_name.len > 0 and t[0] != '"' and for (op_name) |c| { if (!ascii.isAlphabetic(c) and c != '_') break false; } else true; if (!is_operator) { const text = unquote(t); return if (text.len == 0) null else .{ .negated = negated, .what = .{ .text = text }, .people = .{} }; } const value = unquote(t[colon.? + 1 ..]); if (value.len == 0) return null; const what: What = switch (operators.get(op_name) orelse return null) { .from => .{ .from = value }, .to => .{ .to = value }, .cc => .{ .cc = value }, .subject => .{ .subject = value }, .label => .{ .label = value }, .in => if (ascii.eqlIgnoreCase(value, "anywhere")) .anywhere else .{ .label = value }, .is => if (ascii.eqlIgnoreCase(value, "unread")) .{ .unread = true } else if (ascii.eqlIgnoreCase(value, "read")) .{ .unread = false } else if (ascii.eqlIgnoreCase(value, "starred")) .starred else return null, .before, .older => .{ .before = parseDate(value) orelse return null }, .after, .newer => .{ .after = parseDate(value) orelse return null }, .older_than => .{ .before = now - (parseAge(value) orelse return null) }, .newer_than => .{ .after = now - (parseAge(value) orelse return null) }, }; return .{ .negated = negated, .what = what, .people = .{} }; } fn unquote(s: []const u8) []const u8 { const start: usize = @intFromBool(s.len > 0 and s[0] == '"'); const end = if (s.len > start and s[s.len - 1] == '"') s.len - 1 else s.len; return s[start..end]; } /// A date as Gmail takes one: 2026/11/07 or 2026-11-07, at midnight UTC, /// or seconds since the epoch. fn parseDate(s: []const u8) ?i64 { if (std.fmt.parseInt(i64, s, 10)) |seconds| return seconds else |_| {} var parts = mem.tokenizeAny(u8, s, "/-"); var ymd: [3]i64 = undefined; for (&ymd) |*n| n.* = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null; if (parts.next() != null or ymd[1] < 1 or ymd[1] > 12 or ymd[2] < 1 or ymd[2] > 31) return null; return daysFromCivil(ymd[0], ymd[1], ymd[2]) * std.time.s_per_day; } /// The days from 1970-01-01 to a date of the proleptic Gregorian /// calendar, by Howard Hinnant's days_from_civil; std counts only the /// other way. fn daysFromCivil(year: i64, month: i64, day: i64) i64 { const y = if (month <= 2) year - 1 else year; const era = @divFloor(y, 400); const yoe = y - era * 400; const doy = @divFloor(153 * (if (month > 2) month - 3 else month + 9) + 2, 5) + day - 1; const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; return era * 146097 + doe - 719468; } /// An age as Gmail takes one, in seconds: 3d, 2m (of 30 days), or 1y /// (of 365). fn parseAge(s: []const u8) ?i64 { if (s.len < 2) return null; const n = std.fmt.parseInt(i64, s[0 .. s.len - 1], 10) catch return null; const days: i64 = switch (ascii.toLower(s[s.len - 1])) { 'd' => 1, 'm' => 30, 'y' => 365, else => return null, }; return n * days * std.time.s_per_day; } fn matchesTerm(mail: *const Mailbox, t: *const Mailbox.Thread, term_: *const Term) bool { const who = &term_.people; return switch (term_.what) { .text => |s| has(t.participants, s) or has(t.subject, s) or has(t.snippet, s) or for (t.notes) |n| { if (isIn(who, n.from) or anyIn(who, n.people)) break true; } else false, // A thread with no notes has only Gmail's list of senders, and // "me" names the user, who sent what has the Sent label. .from => |s| if (t.notes.len == 0) has(t.participants, s) else for (t.notes) |n| { if (isIn(who, n.from)) break true; } else ascii.eqlIgnoreCase(s, "me") and t.labels.isSet(@backingInt(Mailbox.Label.sent)), .to => for (t.notes) |n| { if (anyIn(who, n.people[0..n.to])) break true; } else false, .cc => for (t.notes) |n| { if (anyIn(who, n.people[n.to..])) break true; } else false, .subject => |s| has(t.subject, s), .label => |name| { var it = t.labels.iterator(.{}); while (it.next()) |i| { const info = mail.label(@fromBackingInt(@intCast(i))); if (sameLabel(info.name, name) or sameLabel(info.id, name)) return true; } return false; }, .unread => |unread| t.unread == unread, .starred => t.labels.isSet(@backingInt(Mailbox.Label.starred)), .before => |time| if (t.notes.len == 0) t.date < time else for (t.notes) |n| { if (n.date < time) break true; } else false, .after => |time| if (t.notes.len == 0) t.date >= time else for (t.notes) |n| { if (n.date >= time) break true; } else false, .anywhere => true, }; } fn has(haystack: []const u8, needle: []const u8) bool { return ascii.findIgnoreCase(haystack, needle) != null; } /// Whether person `p` is among `who`; one added since the query was /// made is not. fn isIn(who: *const std.bit_set.Dynamic, p: u32) bool { return p < who.capacity() and who.isSet(p); } fn anyIn(who: *const std.bit_set.Dynamic, people: []const u32) bool { for (people) |p| if (isIn(who, p)) return true; return false; } /// Whether two label names are one, ignoring case, with /, -, _, and /// space alike: work-projects is Work/Projects. fn sameLabel(a: []const u8, b: []const u8) bool { if (a.len != b.len) return false; for (a, b) |x, y| { const gaps = "/-_ "; const same = ascii.toLower(x) == ascii.toLower(y) or (mem.findScalar(u8, gaps, x) != null and mem.findScalar(u8, gaps, y) != null); if (!same) return false; } return true; } test "found marks each field's matches of the terms that name text there, merged, ignoring case" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); var q: Query = try .init(testing.allocator, "lis from:ravi subject:\"on b\" -maya to:me is:unread", 0, &mail); defer q.deinit(testing.allocator); var buf: [8]Range = undefined; // to:me marks "me" in who; "lis" marks every field; -maya marks nothing. try testing.expectEqualSlices(Range, &.{ .{ .start = 6, .end = 10 }, .{ .start = 12, .end = 14 } }, q.found( .who, "Maya, Ravi, me", &buf, )); // subject:"on b" and lis touch in "Lison brunch": one range. try testing.expectEqualSlices(Range, &.{.{ .start = 0, .end = 7 }}, q.found(.subject, "Lison brunch", &buf)); // from: marks only who. try testing.expectEqualSlices(Range, &.{.{ .start = 3, .end = 6 }}, q.found(.snippet, "In LIS, Ravi", &buf)); // A word cut inside a character marks all of it. var cut: Query = try .init(testing.allocator, "caf\xc3", 0, &mail); defer cut.deinit(testing.allocator); try testing.expectEqualSlices(Range, &.{.{ .start = 4, .end = 9 }}, cut.found(.subject, "The café", &buf)); } test "operators match who is on which message, labels by name, dates at their bounds, and - leaves out" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const gray: Mailbox.Rgb = .{ .r = 0, .g = 0, .b = 0 }; const work = try mail.addLabel(.{ .id = "Label_3", .name = "Work/Projects", .kind = .user, .color = gray }); const maya: Mailbox.Person = .{ .name = "Maya Okafor", .address = "Maya@Example.com" }; const ravi: Mailbox.Person = .{ .name = "", .address = "ravi@example.org" }; const me: Mailbox.Person = .{ .name = "Tim", .address = "tim@example.net" }; // 2024-01-02T00:00:00Z is 1704153600. const midnight = 1_704_153_600; const from_maya = try mail.addThread(.{ .id = "a", .participants = "Maya Okafor", .subject = "Lunch plans for Friday", .snippet = "", .date = midnight, .messages = 1, .labels = &.{ .inbox, work }, .unread = true, .notes = &.{.{ .date = midnight, .from = maya, .to = &.{me}, .cc = &.{ravi} }}, }); const maya_copied = try mail.addThread(.{ .id = "b", .participants = "me", .subject = "Plans", .snippet = "lunch at 12:30", .date = midnight - 1, .messages = 1, .labels = &.{ .sent, .starred }, .unread = false, .notes = &.{.{ .date = midnight - 1, .from = me, .to = &.{ravi}, .cc = &.{maya} }}, }); const trashed = try mail.addThread(.{ .id = "c", .participants = "Ravi", .subject = "Old", .snippet = "", .date = 100, .messages = 1, .labels = &.{.trash}, .unread = false, .notes = &.{}, }); const now = midnight + 3 * std.time.s_per_day; const cases = [_]struct { text: []const u8, want: []const Mailbox.Id }{ .{ .text = "from:maya", .want = &.{from_maya} }, .{ .text = "cc:MAYA", .want = &.{maya_copied} }, .{ .text = "to:ravi", .want = &.{maya_copied} }, .{ .text = "from:me", .want = &.{maya_copied} }, // A plain word matches the people of every message. .{ .text = "okafor", .want = &.{ from_maya, maya_copied } }, .{ .text = "maya -label:work-projects", .want = &.{maya_copied} }, .{ .text = "label:\"work projects\"", .want = &.{from_maya} }, .{ .text = "subject:\"lunch plans\"", .want = &.{from_maya} }, .{ .text = "\"lunch plans\"", .want = &.{from_maya} }, .{ .text = "lunch plans", .want = &.{ from_maya, maya_copied } }, .{ .text = "before:2024/01/02", .want = &.{maya_copied} }, .{ .text = "after:2024-01-02", .want = &.{from_maya} }, .{ .text = "older_than:3d", .want = &.{maya_copied} }, .{ .text = "newer_than:3d is:unread", .want = &.{from_maya} }, .{ .text = "is:read is:starred", .want = &.{maya_copied} }, // What it does not know it leaves to Gmail; a time is a word. .{ .text = "has:attachment 12:30", .want = &.{maya_copied} }, .{ .text = "from: OR", .want = &.{ from_maya, maya_copied } }, // Trash shows when asked for. .{ .text = "in:trash", .want = &.{trashed} }, .{ .text = "ravi in:anywhere", .want = &.{ from_maya, maya_copied, trashed } }, }; var ids: std.ArrayList(Mailbox.Id) = .empty; defer ids.deinit(testing.allocator); for (cases) |c| { var q: Query = try .init(testing.allocator, c.text, now, &mail); defer q.deinit(testing.allocator); try mail.query(.{ .query = &q }, &ids); testing.expectEqualSlices(Mailbox.Id, c.want, ids.items) catch |err| { print("query: {s}\n", .{c.text}); return err; }; } }