- Zig 78.8%
- Tree-sitter Query 13.4%
- Python 6.2%
- Shell 1.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
A progress view shows the kind beside the name — quote's symbol picker has a column for it — and the index has already interned the string, so carrying it costs a pointer and no allocation. Better here than derived by the host: `kind` comes from the pack that extracted the definition, and a host guessing it back from the path or the name would be inventing an answer bearing already knows. |
||
| queries | ||
| scripts | ||
| src | ||
| tests | ||
| tools | ||
| vendor/tree-sitter-markdown-inline | ||
| .devkit.conf | ||
| .gitattributes | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| CLAUDE.md | ||
| context.md | ||
| DESIGN.md | ||
| JOURNAL.md | ||
| LICENSE | ||
| README.md | ||
| TODO.md | ||
bearing
A generic source-code resolver built on tree-sitter,
in Zig (0.16) — editor navigation without language servers.
Consumed in-process by the quote editor, and over a CLI by anything
else.
Status: working. Seventeen languages, 318 tests, scored against real language servers on sixteen corpora.
DESIGN.md is short and everything in it is true of the code as it stands. JOURNAL.md is the dated record of how it got that way — what was tried, what it measured, what was rejected. Read the journal for why and the design for what; a present-tense sentence in the journal is a statement about the day it was written.
The premise
Tree-sitter's tags.scm is a flat, scope-free, name-keyed extraction. It cannot
resolve names — no scopes, no types, no imports, no arity, no qualified names.
So bearing is a candidate ranker, not a resolver. It returns ranked candidates carrying an ordinal confidence tier and the reasons that fired; the consumer decides whether to collapse them to one. If an API like this returns a single answer, the API is lying.
What that buys: no server lifecycle, no per-language toolchain, no project configuration, no waiting for an index. It works on the first keystroke in a repo cloned thirty seconds ago, in a file that does not compile, in a language nobody wrote a server for — and behaves identically everywhere. That uniformity is the product.
What it gives up, stated flatly: rename, type-aware completion, diagnostics, precise find-references, hover types. A rename that is 95% correct is a catastrophe.
How well it works
Top-1 agreement with a real language server for the language, over sixteen corpora. The oracle is always a system with strictly more information than bearing has — gopls, rust-analyzer, clangd, pyright, ruby-lsp, typescript-language-server, zls, marksman.
The oracle ANSWERS are committed (tests/corpus/*.truth), so scoring runs no
language server and is reproducible offline; scripts/corpus-fetch gets the
sources, which are not in this repo.
| corpus | top-1 | corpus | top-1 | |
|---|---|---|---|---|
| python-smoke | 100.0% | js-axios | 99.2% | |
| markdown-mdbook | 100.0% | zig-simgrep | 99.1% | |
| markdown-foam | 100.0% | rust-semver | 97.6% | |
| go-yaml | 97.2% | python-flask | 96.9% | |
| python-click | 96.6% | c-cjson | 95.8% | |
| go-stdlib | 95.6% | ts-yargs | 94.1% | |
| ruby-sinatra | 93.2% | rust-regex | 91.0% | |
| cpp-leveldb | 88.6% | cpp-fmt | 82.5% |
zig build oracle re-scores them. C++ is lowest and its ceiling is honest: without
preprocessor state and include order, a translation unit is not identifiable.
zig build completion scores the other question, and needs no language server at
all: where the source writes dvui.Window, a completion asked after dvui.W must
offer Window, because the code compiles. The corpus is the truth. It reports
candidates/position beside recall, since a filter that shortens the list at equal
recall is the improvement worth having.
What is in it
- Seventeen languages, eighteen packs — Zig, Go, Rust, C, C++, Python, Ruby,
JavaScript, TypeScript, Scheme, Markdown, JSON, TOML, YAML, XML, Typst, shell.
Markdown is two grammars, because its block grammar cannot see inside its own
inlinenodes. - Config formats are packs too, and cheap ones. JSON, TOML and YAML have no
functions, no calls, no scopes and no types, so a key's path is containment and
nothing else — the same walk that turns
# Design / ## Indexinginto a heading path, with no line of the resolver knowing which language it is looking at. YAML's*aliasfinds its&anchorthrough the scope tree rather than the index, because an anchor binds inside its own document and a name-keyed lookup would jump into another file's. Typst is both at once — nesting sections like Markdown,#letbindings like any language, and<intro>/@introas a definition and a reference. - Goto-definition from a cursor, with the qualifier walk that makes
a.b.cquery the qualified form rather than the bare name. - A workspace symbol index —
name → postings, per-file invalidation, and a dirty-buffer overlay that is just "index the path again". - Containment as structure, not a string. A definition carries its parent, so
"the members of
Widget" is a tree walk rather than a suffix test against a qualified name — which returnsLines.atforIndex.and misses the point. - References, when asked for (
Index.keep_references, off by default because they outnumber definitions 4:1).resolve.usesOfruns the resolver BACKWARDS: one forward resolve per use site, keeping those where the target is among the candidates, each carrying the tier it earned. Approximate by construction —c.area()is reported under every same-named method it could reach, because dispatch on an untyped receiver is not something to guess at. - Links — Markdown destinations, wiki-links, heading slugs and
#hashtags. - Docs, signatures and arity, read while the source is still in hand, because
the index keeps no source (a consumer reading a definition has no file open).
Arity only where a pack declares how to count it — Zig deliberately does not,
since
x.method(a)passesxas an ordinary named parameter and every member call would come out one argument short. - Persistence — an index encodes to bytes and back; on the Go standard library that is 88 ms against a 2.5 s rebuild, so a cold start decodes rather than parses. Records carry their own stamp and schema fingerprint, so a stale one is caught rather than served.
- Dependencies — Zig's
build.zig.zonread as a manifest, so dependency → directory is a lookup rather than a crawl (the hash is the directory name). Indexed at a tier ranked strictly below your own code, by default. Other ecosystems' manifests are unwritten.
Using it
As a library, in-process. That is what quote does; see the shape below.
As a command, for everything else:
zig build
./zig-out/bin/bearing def src/index.zig:515:34 --root src
# src/index.zig:359:1: qualified_suffix function index.Index.intern
Line and column are 1-based and the column is in bytes, which is what vim's
line('.') and col('.') give. Exit status is the policy, so a shell caller does
not have to parse anything: 0 a confident single answer (jump), 1 several
candidates (show the picker), 2 nothing found. --json adds the reasons that
fired.
bearing complete <file>:<line>:<col> answers the other question — what names
could go here. It reads backwards from the cursor, so std.me| lists members
of std beginning with "me" and std.| lists all of them, which needs the
standard library indexed:
./zig-out/bin/bearing complete app.zig:4:9 --root . --std
# mem alias std.mem
# meta alias std.meta
bearing symbols <query> is the workspace picker — every definition whose
qualified name contains the query, your own code first:
./zig-out/bin/bearing symbols Index.add --root src
# src/index.zig:838:1: function index.Index.addEdge
bearing members <container> asks what is INSIDE something, by containment
rather than by name — so it never returns a same-named member of something else:
./zig-out/bin/bearing members index.Index --root src
# src/index.zig:266:1: struct index.Index
# src/index.zig:267:1: field index.Index.gpa
# …every direct member; a grandchild belongs to its own parent
bearing uses <definition> is the reverse of def, and is approximate on
purpose — bearing ranks, so a use site is reported under every definition it
could resolve to, with the tier that says how much is known. It caps and says so
rather than truncating in silence:
./zig-out/bin/bearing uses index.Index.intern --root src --limit=3
# src/index.zig:515:1: kind_compatible intern
# src/index.zig:516:1: kind_compatible intern
# src/index.zig:538:1: kind_compatible intern
# … showing 3 of 20 (--limit=N for more)
The walk
Declared dependencies are read from build.zig.zon and indexed automatically;
--no-deps turns that off. --dep DIR adds one tree nothing declares.
--std adds the standard library of every language the walk found — zig, go,
python, ruby — located from that toolchain's own layout on PATH. No
subprocess is spawned: the binary is resolved, its real path taken, a suffix
joined, and the result verified by a file that must exist under it, so an
install this does not recognise reports nothing rather than an empty tree.
--std=zig,go names them instead, for a tree carrying fixtures in languages it
is not written in. Rust is not in the table, because rustc on a rustup install
is a shim with no prefix to join to:
./zig-out/bin/bearing def src/x.rs:10:5 \
--dep "$(rustc --print sysroot)/lib/rustlib/src/rust/library"
A standard library has no .gitignore, so the walk pays its raw file count — and
most of that count is not library. $GOROOT/src is 7710 .go files of which
1219 sit under testdata and 1753 end _test.go; 1131 of python's 1852 sit
under test. Each row therefore carries its own exclusions. Zig's 550 files cost
0.84 s cold and 0.03 s warm.
The walk honours .gitignore, stacked per directory, and that is not a nicety:
over one project's ten dependencies it visits 74 469 files without it and
506 with, because two of them vendor gitignored test corpora. --no-gitignore
restores the plain walk for indexing generated or vendored code on purpose.
Files over 1 MB are skipped (--max-file-bytes=N), and -v counts them —
the largest files in a real tree are generated parser.c state tables that cost
parse time out of all proportion to the symbols they yield.
Extraction runs on every core (--threads=N), which is 2.25x at 8. Insertion
stays single-threaded and in walk order, so the answer does not depend on which
thread finished first: verified byte-identical at 1, 2, 4 and 8 threads.
The index is cached under $XDG_CACHE_HOME/bearing (or ~/.cache), keyed by the
root's real path, and only what a (size, mtime) stat disagrees with is
re-extracted. Entries unused for 30 days are dropped, and the directory is capped
at 4 GB. --no-cache rebuilds every time; -v says how much was reused and where
the time went.
Two costs a cache cannot remove, both per-process and both about 100 ms together:
compiling the pack's queries, and analysing the buffer the cursor is in. They are
why a resident process — an LSP skin on the same substrate — is the next step for
anything latency-sensitive. Everything else is now cheap: a warm run over a project
and seventeen dependencies re-extracts nothing and spends 3 ms in extraction. tools/cli.zig is written to be read: a tree walk, a
grammar table and a cursor query, which is everything bearing leaves to a caller.
Shape
- Library, in-process. Synchronous core, caller-owned threading, cancellable via
a
*std.atomic.Value(bool)flag. No daemon, no async runtime inside bearing. - Depends on the tree-sitter runtime and nothing else. No grammar is a
dependency. The CLI is a host and takes one more —
plumbuz, for.gitignorematching — which the library imports nowhere, because a walk is the host's job. - Takes a grammar as an input. An opaque
*const anyopaqueplus query text — opaque because a host's owntranslate-coftree_sitter/api.hproduces a distinct Zig type from ours for the same C struct. bearing never loads a grammar, which is what keeps it testable standalone and free of any host's registry. - Language knowledge lives in data files, never in the resolver. The moment the
core says
if (lang == .python), the property being built for is gone. - Tiers are ordinal and include an
exactrung that no tags-based path reaches, for evidence that is not a guess — a Markdown link destination is one. A general provider interface for non-tags evidence was a design intention that was never built (DESIGN §3), and is not something that exists yet.
Development
scripts/setup-tools # clone + build devkit, simgrep, zigmap into .tools/
zig build test # 281 tests
zig build oracle # goto-definition, sixteen corpora against real servers
zig build completion # completion recall, against the corpora themselves
zig build bench # cost, against the recorded tests/baseline.zon
simgrep finds code duplication (--min-lines=N --min-clones=M); zigmap generates
codemaps. Both are expected by CLAUDE.md before any refactoring.
zig build bench prints a delta against checked-in numbers, because a measurement
recorded only in prose decays silently — one here went 10x off over eleven sessions
before anyone looked.
License
MIT — see LICENSE. Every query file is bearing-authored and MIT.
One generated parser is vendored: vendor/tree-sitter-markdown-inline, because
upstream gates tag and wiki_link behind generation-time flags, so no parser you
can fetch can see a #hashtag. scripts/regen-markdown-inline reproduces it, and
proves it reproduces the pinned parser byte-for-byte before writing a different
one. Grammars are otherwise fetched, never copied.