Nothing matches. Try a shorter fragment — the search looks at headings, flag names, and body text.
Start here
Homebrew is the supported install path on macOS and Linux. It installs the exact archives the release workflow built, whose checksums entered the formula only after their build-provenance attestations verified:
brew install mneves75/tap/hayOr build from source — one line, or inspect first (Rust edition 2024, MSRV 1.88):
curl -fsSL https://raw.githubusercontent.com/mneves75/hay/v0.3.0/install.sh | HAY_REF=v0.3.0 bash
# inspect-first form:
git clone --depth 1 --branch v0.3.0 https://github.com/mneves75/hay.git && cd hay && HAY_REF=v0.3.0 ./install.shThe script gates on the toolchain version, clones, builds release, installs via
cargo install, and smoke-tests the installed binary. Set
CARGO_INSTALL_ROOT to choose its destination; the script passes that root to Cargo
and verifies the same path. Then search exactly like you would with ripgrep:
hay validateSession src/ # ranked results, best first
hay -t ts -w fetchUser # TypeScript only, whole words
hay -C 3 -m 20 'parseConfig' # context, capped result countThe deal hay offers is narrow and honest: each complete search returns the exact match set from an equivalently configured ripgrep invocation, verified continuously by a differential test that normalizes traversal inputs on both sides — but ordered by likelihood of being the answer instead of by file path. If the corresponding orderings differ in which lines match, that is a bug; the project treats it as such.
One-sentence mental model: ripgrep answers “where does this string appear?”; hay answers “where is the thing I’m asking about — most likely first?”
The mental model
hay is built around one opinion, borrowed from how coding agents read: a line that
declares a thing is worth more than a line that merely mentions it. When an agent
searches validateSession, the function definition is the answer; the forty call sites
are supporting material; the dead planning document that mentions the name is noise.
Mechanically, hay uses ripgrep’s matching and walking crates with a deterministic ignore
policy: repository .gitignore rules apply, while global gitignore,
.git/info/exclude, .ignore, and .rgignore do not. The
differential harness disables those same non-.gitignore sources for ripgrep. hay then retains the
20,000 strongest-by-prescore candidates, scores them, sorts them, and prints. Scoring never
changes which retained lines match — only their order.
If you want the full story — why ranking exists at all, what a bootstrap interval is, and the C bug the benchmark itself caught — read BENCHMARK_FEYNMAN.html. It assumes no information-retrieval background.
How results are ranked
Every matching line gets a score from four signals. The defaults:
| signal | weight | what it rewards |
|---|---|---|
definition | +6.0 | The line declares the query rather than mentioning it: a declaration keyword before it
(fn, function, class, def, const,
typed-declaration shapes like static int foo(), or key-style shapes
(query:, "query":). The match must sit at a word boundary — a hit inside
SemanticFingerprint is not a declaration of fingerprint. |
path | −1.0 … +1.0 | A prior on where answers live. Source directories (/src/, /lib/,
…) score up; tests, fixtures/prose/data, and buried trees (/vendor/, /dist/,
/archive/, /node_modules/, …) score down. A vendored test is buried first:
buried wins over everything. |
word | +0.5 … +1.0 | How exactly the match sits inside the line’s identifiers: +1.0 for a whole
identifier (auth), +0.5 when the query starts one
(authenticate), nothing when it is buried inside a longer name
(oauthToken). Pre-registered in the design document and finally measured in 0.2.0:
+0.010 to +0.022 MRR across the public corpora. |
tf | 0.5 max | Gentle term frequency within the line — a line naming the symbol twice outranks a drive-by mention, deliberately damped so repetition cannot buy a win. |
Tie-breaks fall back to file path, so output is deterministic for a given tree.
Results are interleaved by file
After scoring, hay round-robins the ranked list by file: the first pass carries each
file’s strongest line, the second its next-strongest, and so on. An agent opens files, so a
first page of ten files is worth more than ten lines of one module — measured on the behavioural
corpus, this alone lifted the answer-in-top-10 rate from 59.2% to 78.0% without changing a single
score. The sequence of distinct files is unchanged, so -l output is identical.
hay --no-diversify config . # strict score order insteadModes that do not rank
-c, --count-matches, -v, -o and
--stream have nothing to order. They run ripgrep's way — its parallel traversal, its
output, its per-file -m, and no candidate cap, so a pattern matching
more than 20,000 lines is answered exhaustively rather than exiting 2. Until 0.3.0 the first four
exited 2 telling you to go and use rg; a search tool you can only reach for once you
already know the question ranks is a decision you have to make before you can use it.
hay --stream config . # everything rg would print, streaming, no cap
hay -c config . # matching lines per file
hay -o config . # just the matched substrings
hay -v config . # the lines that did not matchThese are the only modes whose order is not deterministic, because the order is
ripgrep's. A sorted walk was measured at 8.0 s to the first line of a Linux-kernel search against
ripgrep's 1.1 s; the parallel walk brings that to 2.3 s. Every mode that ranks is deterministic.
--explain is refused here — there is no ranking to explain.
One divergence survives: -m 0 means no limit everywhere in hay, where
ripgrep treats it as print nothing. hay's meaning is the documented one and the
measurement kit depends on it, so it stays — stated here rather than discovered.
Turning signals off
Each signal has an ablation switch, because a contribution you cannot switch off is a belief, not a measurement:
hay --no-definition validateSession .
hay --no-path validateSession .
hay --no-word validateSession .
hay --no-tf validateSession .
hay --no-diversify validateSession .Four other signals were built, measured and deleted: exact-case matching, a comment penalty, binary whole-word matching, a filename match, and (in 0.3.0) a markdown-heading signal that reversed hay's documentation deficit and still did not ship, because the benchmark that liked it defines its ground truth as the thing the signal detects.
This is also how several published claims here were proven: the definition signal’s value on C was established by turning it off and watching the kernel score collapse to ripgrep’s.
Flags
The parser is lexopt — the same crate ripgrep moved to — so combined short flags
(-in), --flag=value, attached values (-C3) and --
all work as you expect.
Picking the pattern
-e, --regexp | Add a pattern; repeat for the union of all patterns. |
|---|---|
-i, --ignore-case | Case-insensitive matching. |
-w, --word-regexp | Match whole words only. |
-F, --fixed-strings | Treat the pattern as a literal string — no regex metacharacters. Agents searching identifiers should default to this. |
Narrowing the tree
-g, --glob | Include/exclude files by glob; repeatable. Leading ! excludes. |
|---|---|
-t, --type | Only search a named file type (ts, rust, …); repeatable. --type-list prints them all. |
-T, --type-not | Never search a named file type; repeatable. |
--hidden | Also search hidden files and directories. |
--no-ignore | Do not respect .gitignore. Like ripgrep, ignore rules only apply inside a git repository. |
Shaping the output
-l, --files-with-matches | Print only file paths, best-ranked file first. Wins over --json: under both flags you get plain paths, because hay’s JSON contract has no files-only message (see Output). |
|---|---|
-n / --no-line-number | Line numbers are on by default; this turns them off. |
-A/-B/-C <N> | After/before/either-side context lines. Partial overrides behave like ripgrep 14+: -C1 -A2 means -B1 -A2. Context never reorders results and never duplicates a line inside one window block; a real gap between windows is marked --. |
-m, --max-count <N> | Stop after N ranked results (default 50; 0 = no limit). Unlike ripgrep, where -m bounds matches per file, this bounds the total output — see the cap. |
--json | Ripgrep-shaped JSON Lines: match and context messages only. No begin/end/summary — those are file-scoped and meaningless once output is rank-ordered. |
--explain | Prefix each result with its score and per-signal breakdown (see Explain). |
--type-list | Print known file types and exit. |
-h, -V | Help and version. |
Deliberately absent
Three ripgrep staples are refused — loudly, not silently — because ranking and the flag are mutually meaningless. Each refusal says why and names the ripgrep equivalent:
-v | Inverting the match leaves nothing to rank: every non-matching line would score identically. Use rg -v. |
|---|---|
-c | hay ranks lines, it does not count them. Use rg -c. |
-o | Only-matching prints substrings, not lines — no line, nothing to rank. Use rg -o. |
Output formats
Plain
Ripgrep-compatible path:line:text, rank-ordered, with -- marking a true
discontinuity between context windows:
$ hay -C1 -F validateSession src/
src/auth.ts:2:export function validateSession(t: string) {
src/auth.ts-3- return t
--
src/session.ts:17:// delegates to validateSessionJSON Lines (--json)
Ripgrep-shaped objects, one per line, match and context messages
only:
{"type":"match","data":{"path":{"text":"src/auth.ts"},
"lines":{"text":"export function validateSession(t: string) {\n"},
"line_number":2,"absolute_offset":31,
"submatches":[{"match":{"text":"validateSession"},"start":16,"end":30}]}}- No
begin/end/summaryrecords: those describe a file-scoped stream, and rank order interleaves files by design. - Under
-l --jsonyou get plain paths — the files-only question has no match record to carry it, and a lonebeginwould be neither the documented contract nor rg-shaped. - Every line is one valid JSON object, so
jq -c 'select(.type=="match")'composes cleanly (recipe below).
--explain: reading a score
Each result is prefixed with its total score and the per-signal breakdown:
$ hay --explain -F validateSession src/ | head -1
9.00 [def +6.0 path +1.0 word +1.0 tf +1.00] src/auth.ts:2:export function validateSession(t: string) {| field | meaning |
|---|---|
| total | Sum of the weighted signals. Higher ranks first; equal totals tie-break by path. |
def | Definition signal: +6.0 when the line declares the query, +0.0 otherwise. |
path | Path-class prior for the file’s directory class (source up, tests down, buried furthest down). |
word | Match exactness: +1.0 whole identifier, +0.5 prefix of one, +0.0 buried inside a longer name. |
tf | Damped term frequency within the line. |
Use it to audit, not to tune blindly. The weights are hand-set, and fitting
them on the corpus they are published against is the exact mistake this project exists to warn
about. --explain answers “why did this line rank here?”; it does not certify that the
weights are optimal.
Differences from ripgrep
| difference | why |
|---|---|
| Rank-ordered results | The entire product. Path order is shelf order; agents need answer order. |
-m bounds total results, not matches-per-file | An agent wants “the twenty best lines”, not “twenty per file”. |
--json emits only match/context | begin/end/summary assume a file-scoped stream; rank order interleaves files. |
-l wins over --json | Plain paths beat a bare begin with no end. |
| Deterministic ignore inputs | Repository .gitignore rules apply; global gitignore, .git/info/exclude, .ignore, and .rgignore do not. |
| A 20,000-candidate cap (see the cap) | Memory bound with a loud stderr notice instead of unbounded buffering. |
For equivalent flags and normalized traversal inputs, match semantics are parity by construction. The differential test fails the build if hay and the corresponding ripgrep invocation disagree on which lines match.
Exit codes
| code | meaning |
|---|---|
0 | Results were found (or help/version/type-list printed). |
1 | The search ran fine and found nothing. Not an error. |
2 | Something is wrong or incomplete: bad flags, unreadable paths, the candidate cap truncated results. The reason is always on stderr. |
The rule this repo lives by: a serious defect is a quiet wrong answer, never a crash. Exit 2 exists so “here are some results” can never silently mean “here are all the results”. Scripts should treat 2 as “retry narrower”, not as success.
The candidate cap
hay buffers up to 20,000 matching lines to score them. A pattern broader than
that (say, -e e on the Linux kernel) ranks only the 20,000
strongest-by-prescore candidates — and says so on stderr while still exiting 2, because the
printed list is not every match
in the tree. For exhaustive counting or completeness checks on very broad patterns, use
rg. That is not a limitation hay apologises for; it is the trade that makes ranking
affordable, stated where you can see it.
Recipes for agent loops
Claude Code, Codex CLI, pi, Aider, Cursor — they all read search results the same way: first page, act on it. That is exactly the surface hay improves, so wiring it into an agent is one paragraph of instructions plus the right defaults.
The universal snippet — paste into AGENTS.md. Codex CLI and pi read
it natively. Claude Code reads only CLAUDE.md — point it at the same source with a
one-line CLAUDE.md containing @AGENTS.md, or
ln -s AGENTS.md CLAUDE.md (both are Anthropic's documented patterns). Aider and Cursor
need their own wiring — see "Make hay the default" below.
## Code search
Use `hay` instead of `rg` for concept and symbol searches:
hay -F -m 10 "<symbol>" src/
- always `-F` when searching identifiers (they are literals, not regexes)
- exit 0 = results ranked best-first; 1 = no match; 2 = INCOMPLETE — read stderr,
then narrow (add -g/-t) rather than trusting the output wholesale
- need exhaustive lists or match counts? fall back to `rg`
- structured: `hay --json -F "sym" . | jq 'select(.type=="match")'| agent | instructions live in | notes |
|---|---|---|
| Claude Code | CLAUDE.md (share one source via @AGENTS.md or a symlink — it does not read AGENTS.md itself) | snippet works verbatim; Claude narrows well on exit 2 if told to read stderr |
| Codex CLI | AGENTS.md (repo root, then ~/.codex/AGENTS.md) | same snippet; state the `-F` default explicitly and it holds |
| pi | AGENTS.md (project root) | pi reads it before acting; the jq recipe fits its bash-tool loop directly |
| Cursor / Aider / others | .cursor/rules/*.mdc; CONVENTIONS.md via --read | neither loads prose automatically — wiring below; any agent that shells out can run the same commands |
Make hay the default, not just available
The reliable way to make an agent reach for hay first is an instruction-level default:
a rule that names hay as the default and rg as the explicit fallback. Agents follow
tool rules far more reliably than they discover tools. Strengthen the universal snippet's first
line to a default:
## Code search — defaults
`hay` is the DEFAULT search tool in this repo. Reach for it first for every
"where is X / what defines X / how does X work" search:
hay -F -m 10 "<symbol>" .
Use `rg` only when the task needs every match, an exact count, `-v`, or
path-ordered output — and say so when you switch.- Claude Code — put the rule in
CLAUDE.md(or keep it inAGENTS.mdand add aCLAUDE.mdcontaining just@AGENTS.md— Claude Code does not readAGENTS.mddirectly). Pre-approve the binary so the default costs no permission prompts:/permissions→ allowBash(hay:*). Claude Code's built-inGreptool is ripgrep internally and cannot be rewired; the rule should therefore say "prefer runninghayin the shell over the Grep tool for concept searches", which Claude honors. - Codex CLI — the same rule in the repo's
AGENTS.mdholds per project; for every project, put it in~/.codex/AGENTS.md. State thergfallback explicitly or Codex will treat hay as a synonym and lose the exhaustiveness distinction. - pi — the repo
AGENTS.mdis read natively; nothing else needed. - Aider — a
CONVENTIONS.mdfile is inert on its own: load it withaider --read CONVENTIONS.md, or persist it in.aider.conf.ymlasread: [CONVENTIONS.md]. - Cursor — rules are
.mdcfiles under.cursor/rules/with a rule type; make this one always-on (alwaysApply: truein the frontmatter, or the "Always" type in the UI), otherwise it may be absent exactly when the agent picks its search tool. - Verify it took — ask the agent "where is
<some symbol>defined?" and check the transcript ranhay. A default that was never observed firing is a claim, not a configuration.
Do not shim or alias rg → hay. It is tempting and it is
wrong twice over. Agents and editor tooling call rg expecting exact ripgrep semantics —
file-grouped order, -v/-c/-o, begin/end
JSON events — and hay deliberately declines or reshapes all of those. And a shim converts every
exhaustive search (renames, security sweeps, error-string hunts) into a ranked, capped one
without telling anyone — exactly the quiet wrong answer this tool's exit codes exist to prevent.
The default belongs in instructions, where the agent knows which tool it chose and why.
Find the definition, read it
# fixed-string, top hit only — the common agent case
hay -F -m 1 "$symbol" "$root"Structured consumption
# every match as TSV: path, line number, trimmed text
hay --json -F "$symbol" "$root" \
| jq -r 'select(.type=="match")
| [.data.path.text, .data.line_number,
(.data.lines.text | gsub("\\n$";"") | .[0:160])] | @tsv'Context without reordering surprises
hay -C 3 -m 10 -F "$symbol" "$root" # blocks stay grouped, gaps show as --Honesty check on a big tree
hay -m 0 -F "$symbol" "$root" 2>err.log || true
grep -q "ranked the .* strongest" err.log && echo "truncated — switch to rg for exhaustiveness"Install on an agent's machine
brew install mneves75/tap/hay # macOS and Linux
# or from source:
curl -fsSL https://raw.githubusercontent.com/mneves75/hay/v0.3.0/install.sh | HAY_REF=v0.3.0 bash # inspect first!
# inspect-first form:
git clone --depth 1 --branch v0.3.0 https://github.com/mneves75/hay.git && cd hay && HAY_REF=v0.3.0 ./install.shFAQ
Is it faster than ripgrep? Roughly comparable on typical queries; ranking adds work, measured honestly on the benchmark page. Read the timing chart there — ratios, not absolutes, survive machine noise.
Why did my test file rank low? The path prior downgrades tests deliberately:
in agent transcripts, tests are rarely the answer to “where is X defined”. Overriding is a
one-flag matter (--no-path), and the ablation switches exist precisely so you never
have to trust the prior.
Can I use it as a drop-in in scripts? Yes, wherever scripts consume
path:line:text and tolerate rank order plus the three declined flags above. Where
scripts need exhaustiveness guarantees, keep rg.
Does the ranking ever change which lines match? Never. That is invariant #1, policed by a differential test against ripgrep on every change to walking, matching, or output.
Where do the numbers on the website come from? BENCHMARK.md /
benchmark.html: public corpora, ground truth from a parser (ast-grep), paired
bootstrap intervals cross-checked by Fisher randomization. The honest headline is also there: the
task favours hay by construction, so read margins, and weigh them against the behavioural
evaluation described in README.md, whose ground truth nobody designed around the
tool.
The measurement kit
hay ships inside a repository that is mostly instrument: measure-mrr.ts turns local
agent transcripts into a behavioural test collection (queries paired with the files the agent
opened next), benchmark.ts runs the public-corpus comparison, and
BENCHMARK_FEYNMAN.md explains both from zero. Developer-facing commands live in
HOWTO.md; the negative result that started it all is the top of
README.md. One rule for visitors: grep-hygiene.ts is the falsified metric,
kept reproducible — do not extend it, do not quote it.