// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Finding things by what is typed, for shell search and the profile //! selector alike, with zf's fuzzy matching. //! //! What is typed is split into words on whitespace, and every word must be //! found: its letters in order, not necessarily together, ignoring case — //! `rtr` finds `router`. A thing is known by two fields, the one it is //! named by and another — a pane's title and its location, a profile's //! name and what it runs — and each word is looked for in either. A word //! found in the first field counts for more than one found only in the //! second, whatever zf ranks them; among those equal in that, zf's rank //! orders them, letters together and at the start of a word counting for //! more. A field that is a path is ranked as one, a match in its last //! component counting for more. const std = @import("std"); const zf = @import("zf"); /// One of the two things a thing is known by. pub const Field = struct { text: []const u8, /// Ranked as a path: a match in its last component counts for more. path: bool = false, }; /// How well what was typed matches a thing. pub const Score = struct { /// How many words were found only in the second field. fallbacks: u32, /// zf's rank, summed over the words: lower is better. rank: f64, /// Whether `a` matches better than `b`. pub fn better(a: Score, b: Score) bool { if (a.fallbacks != b.fallbacks) return a.fallbacks < b.fallbacks; return a.rank < b.rank; } }; /// The most words a query is split into; more are left out. pub const max_words = 16; /// `query`'s words, in `buf`. pub fn words(query: []const u8, buf: *[max_words][]const u8) []const []const u8 { var n: usize = 0; var it = std.mem.tokenizeAny(u8, query, " \t\r\n"); while (it.next()) |w| { if (n == buf.len) break; buf[n] = w; n += 1; } return buf[0..n]; } /// How well `ws` match a thing known by `first` and `second`; null when /// one of them is in neither, or there are none. pub fn score(ws: []const []const u8, first: Field, second: Field) ?Score { if (ws.len == 0) return null; var s: Score = .{ .fallbacks = 0, .rank = 0 }; for (ws) |w| { if (rank(w, first)) |r| { s.rank += r; } else if (rank(w, second)) |r| { s.rank += r; s.fallbacks += 1; } else return null; } return s; } fn rank(word: []const u8, f: Field) ?f64 { if (f.text.len == 0) return null; return zf.rankNeedle(f.text, word, options(word, f)); } fn options(word: []const u8, f: Field) zf.RankNeedleOptions { return .{ .case_sensitive = false, .filename = if (f.path) std.fs.path.basename(f.text) else null, .strict_path = f.path and std.mem.findScalar(u8, word, '/') != null, }; } /// Where in each field the words were found: the byte offsets of the /// letters matched, ascending, in `first_buf` and `second_buf`. A word is /// shown where `score` counted it, in the first field if it is there. pub fn highlight( ws: []const []const u8, first: Field, second: Field, first_buf: []usize, second_buf: []usize, ) struct { first: []const usize, second: []const usize } { var nf: usize = 0; var ns: usize = 0; for (ws) |w| { if (rank(w, first) != null) { nf += zf.highlightNeedle(first.text, w, first_buf[nf..], options(w, first)).len; } else if (rank(w, second) != null) { ns += zf.highlightNeedle(second.text, w, second_buf[ns..], options(w, second)).len; } } return .{ .first = sortedUnique(first_buf[0..nf]), .second = sortedUnique(second_buf[0..ns]) }; } fn sortedUnique(xs: []usize) []const usize { std.mem.sortUnstable(usize, xs, {}, std.sort.asc(usize)); var n: usize = 0; for (xs) |x| { if (n > 0 and xs[n - 1] == x) continue; xs[n] = x; n += 1; } return xs[0..n]; } const testing = std.testing; fn scoreOf(query: []const u8, first: []const u8, second: Field) ?Score { var buf: [max_words][]const u8 = undefined; return score(words(query, &buf), .{ .text = first }, second); } test "every word must be found, its letters in order, ignoring case" { const loc: Field = .{ .text = "~/dev/harrier", .path = true }; try testing.expect(scoreOf("vim", "vim README.md", loc) != null); try testing.expect(scoreOf("VM rdme", "vim README.md", loc) != null); try testing.expect(scoreOf("hrr", "top", loc) != null); try testing.expect(scoreOf("vim emacs", "vim README.md", loc) == null); try testing.expect(scoreOf("mv", "vim", .{ .text = "" }) == null); try testing.expect(scoreOf("", "vim", loc) == null); try testing.expect(scoreOf(" \t", "vim", loc) == null); } test "a word found in the first field beats one found only in the second" { // "nas" closely in the second, loosely in the first: the first wins. const loose = scoreOf("nas", "n-a-s", .{ .text = "nas" }).?; const close = scoreOf("nas", "router", .{ .text = "ssh nas" }).?; try testing.expectEqual(@as(u32, 0), loose.fallbacks); try testing.expectEqual(@as(u32, 1), close.fallbacks); try testing.expect(loose.better(close)); try testing.expect(!close.better(loose)); } test "letters together and at the start rank better" { const together = scoreOf("rout", "router", .{ .text = "" }).?; const apart = scoreOf("rout", "rebuild-output", .{ .text = "" }).?; try testing.expect(together.better(apart)); } test "a path's last component counts for more" { const last = scoreOf("har", "sh", .{ .text = "/home/a/dev/harrier", .path = true }).?; const earlier = scoreOf("har", "sh", .{ .text = "/home/a/harbour/notes", .path = true }).?; try testing.expect(last.better(earlier)); } test "matched letters are found where the words were counted" { var buf: [max_words][]const u8 = undefined; const ws = words("rtr ssh", &buf); var fb: [64]usize = undefined; var sb: [64]usize = undefined; const h = highlight(ws, .{ .text = "router" }, .{ .text = "ssh admin@router" }, &fb, &sb); try testing.expectEqualSlices(usize, &.{ 0, 3, 5 }, h.first); try testing.expectEqualSlices(usize, &.{ 0, 1, 2 }, h.second); } test "words beyond the most are left out" { var buf: [max_words][]const u8 = undefined; try testing.expectEqual(@as(usize, max_words), words("a b c d e f g h i j k l m n o p q r s t", &buf).len); }