Prem Poddar
BrewlightSpotlight, but for Homebrew

Searching 16,000 packages in 3 ms per keystroke

How Brewlight's search works, from byte buffers shaped so every kind of match is one memmem call, to a ranking packed into a single integer, to opening instantly by not asking brew.

6 min read

  • macOS
  • Swift
  • Search

Brewlight is a Mac app for Homebrew. Press ⌘K, type, and it searches every formula and app in the catalogue as you type, then installs the one you pick in one click. The search box is the whole idea, so most of this post is about making it fast and making the first result the right one.

What it solves

Homebrew is excellent once you know the exact name of what you want. The parts around that are harder. brew search matches names, so you need to already know roughly what a package is called. Uninstalling an app leaves preferences, caches and support files behind. brew doctor assumes you know what its warnings mean.

Brewlight's command palette searching for rip, with ripgrep as the top hit
Typing rip. The top hit is ripgrep, with install actions for related packages underneath.

Brewlight puts all of that in one native window: a palette that searches packages and actions together, install, upgrade, pin and full removal with a list of every leftover file, services, taps, dependency trees, Brewfile import and export, plain-language explanations for brew doctor, and six languages. Every confirmation shows the exact brew command it’s about to run.

Built with

  • Swift 6, with SwiftUI and AppKit, for macOS 26 and later.
  • XcodeGen generates the Xcode project from a small config file.
  • Performance tests that run against an optimised build and fail if an interaction goes over its time budget.
  • A -demo launch flag that loads sample data, so the UI can be worked on without touching a real Homebrew install.

Not asking brew

The first speed problem has nothing to do with search. Running brew takes a noticeable moment to start, so a front end that shells out for every list feels slow no matter how good the rest is.

Brewlight mostly doesn’t ask. It reads Homebrew’s own API cache and the Cellar and Caskroom folders directly, keeps all of that off the main thread, and saves a snapshot of what it knew last time. On launch it shows the snapshot immediately and refreshes in the background. You never look at a spinner, and if anything changed, the list just updates.

Measured in a release build against a real install with 16,462 packages:

InteractionTime
Search, per keystrokeabout 3 ms
Click a package17 to 25 ms
Switch to Discoverabout 64 ms
Launchwindow at once, packages in about 0.3 s

One memmem per kind of match

A search has to answer several different questions for each package. Is the query the exact name? A prefix of the name? A prefix of one of the name’s parts, like node in node@20? A whole word in the description? Answering those with string APIs means lots of small allocations on every keystroke, 16,000 times over.

So when the catalogue loads, every field is lowercased once into a byte buffer shaped so that each question becomes a single memcmp or memmem call:

name parts -python-3.12 find "-3" description words ␣fast␣grep␣alternative␣ find "␣grep" aliases \0python3\0python@3\0 exact: "\0q\0"prefix: "\0q" A separator in front of every part turns "starts with" into "contains". Search for the separator plus the query, and one call answers the question for every part at once.
Underscores, dots, @ and spaces in names become dashes, and anything that isn't a letter or digit in a description becomes a space.

Building those buffers is a single pass over each package:

let name = Self.lowerBytes(p.token)
var segs: [UInt8] = [UInt8(ascii: "-")]
for b in name { segs.append(b == 95 || b == 46 || b == 64 || b == 32 ? 45 : b) } // _ . @ space become -

var aliases: [UInt8] = [0]
for a in p.aliases + p.oldNames { aliases += Self.lowerBytes(a); aliases.append(0) }

var words: [UInt8] = [32]
for b in Self.lowerBytes(p.desc) {
    let alnum = (b >= 48 && b <= 57) || (b >= 97 && b <= 122) || b >= 128
    words.append(alnum ? b : 32)
}
words.append(32)

And the check itself is a thin wrapper over C’s memmem, with no copying:

@inline(__always)
static func contains(_ s: [UInt8], _ p: [UInt8]) -> Bool {
    guard s.count >= p.count, !p.isEmpty else { return false }
    return s.withUnsafeBytes { sb in
        p.withUnsafeBytes { pb in memmem(sb.baseAddress!, s.count, pb.baseAddress!, p.count) != nil }
    }
}

Ranking in tiers

Speed doesn’t make search feel right. The obvious answer has to come first. Brewlight scores each package by the best kind of match it has, in fixed tiers, and lets popularity lift packages only within a tier:

static func score(_ e: Entry, q: [UInt8], dashQ: [UInt8], spaceQ: [UInt8], nulQ: [UInt8], nulQnul: [UInt8], popularity: Int) -> Int {
    if e.name == q { return 1000 }
    if e.display == q { return 980 }
    if contains(e.aliases, nulQnul) { return 950 }
    let bonus = popularityBonus(popularity)
    if hasPrefix(e.name, q) { return 720 + bonus - min(e.name.count - q.count, 20) }
    if contains(e.aliases, nulQ) { return 700 + bonus }
    if hasPrefix(e.display, q) { return 700 + bonus }
    if contains(e.segments, dashQ) { return 690 + bonus - min(e.name.count - q.count, 20) }
    if contains(e.name, q) { return 500 + bonus / 3 }
    if q.count >= 2 && contains(e.words, spaceQ) { return 300 + bonus / 3 }
    if q.count >= 3 && contains(e.host, q) { return 250 }
    if q.count >= 4 && contains(e.words, q) { return 200 }
    return 0
}
exact name1000 exact display name, exact alias950 to 980 name or alias starts with it, or one part of the name does690 to 870 name contains it500+ a description word starts with it300+ homepage domain250 description200 fuzzy100 popularity only reorders inside a tier (up to 150 points, on a log scale)
A popular package that merely contains your query never outranks one whose name starts with it.

Popularity is log10(installs) × 25, capped at 150, so a million installs is worth about 150 points. That’s enough to put node above an obscure node-something within the prefix tier, but never enough to jump a tier.

The sort is one more small trick. Instead of a comparator that checks score, then popularity, then name length, each hit gets one 64-bit key with all three packed in, and the sort compares integers:

var keyed = hits.map { h -> (key: Int64, hit: Hit) in
    let pop = Int64(min(h.index < popularity.count ? popularity[h.index] : 0, (1 << 30) - 1))
    let len = Int64(min(entries[h.index].name.count, 255))
    return ((Int64(h.score) << 40) | (pop << 8) | (255 - len), h)
}
keyed.sort { $0.key > $1.key }

Fuzzy matching, where the letters only need to appear in order, is the most expensive check, so it only runs when the direct matches are thin: fewer than 20 hits and a query of at least two letters.

Filters in the same box

The palette also understands filters, so type:cask is:outdated or cat:fonts mono work in the same field you type names into. Parsing is deliberately simple: split on spaces, peel off anything that looks like a filter, and search with what’s left:

for token in raw.split(separator: " ") {
    let t = String(token)
    let lower = t.lowercased()
    if lower.hasPrefix("tag:") { tags.append(String(t.dropFirst(4))) }
    else if lower.hasPrefix("tap:") { taps.append(String(lower.dropFirst(4))) }
    else if lower.hasPrefix("cat:") { categories.append(String(lower.dropFirst(4))) }
    else if lower == "is:outdated" { isOutdated = true }
    else if lower == "type:cask" { kind = .cask }
    // ...
    else { words.append(t) }
}

With several words, packages that match more of them rank first, and an exact match of the whole phrase ranks above everything else. A sentence like “an app to edit videos” is turned into filters before searching.

Brewlight's installed list with node selected, showing its version, size, dependencies and the exact brew command
A package's details, including the exact brew command behind each button.

Where it stops

It needs macOS 26, which leaves out a lot of Macs that run Homebrew perfectly well. And because it reads Homebrew’s files directly, a big change in how Homebrew lays those out would need an update to Brewlight.

brew install --cask brewlight

Brewlight is independent and not affiliated with Homebrew. The source is on GitHub.