A generic source-code resolver in Zig, built on tree-sitter, for editor navigation without language servers.
  • Zig 78.8%
  • Tree-sitter Query 13.4%
  • Python 6.2%
  • Shell 1.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker 39fc5139d7 stream: carry the definition's kind
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.
2026-09-03 08:25:58 +02:00
queries packs: xml and typst, plus strip_name_suffixes 2026-08-24 22:22:00 +02:00
scripts manifest: pin what the URL names, and check it 2026-08-24 23:27:46 +02:00
src stream: carry the definition's kind 2026-09-03 08:25:58 +02:00
tests packs: xml and typst, plus strip_name_suffixes 2026-08-24 22:22:00 +02:00
tools packs: xml and typst, plus strip_name_suffixes 2026-08-24 22:22:00 +02:00
vendor/tree-sitter-markdown-inline §81: Markdown hashtags, and the parser that could not see them 2026-08-09 10:00:56 +02:00
.devkit.conf Initial commit: repo skeleton and tooling 2026-08-01 11:43:33 +02:00
.gitattributes Initial commit: repo skeleton and tooling 2026-08-01 11:43:33 +02:00
.gitignore go-stdlib is fetched and pinned like every other corpus 2026-08-06 10:43:31 +02:00
build.zig packs: xml and typst, plus strip_name_suffixes 2026-08-24 22:22:00 +02:00
build.zig.zon manifest: pin what the URL names, and check it 2026-08-24 23:27:46 +02:00
CLAUDE.md DESIGN: split the contract from the journal 2026-08-19 16:27:41 +02:00
context.md manifest: pin what the URL names, and check it 2026-08-24 23:27:46 +02:00
DESIGN.md manifest: pin what the URL names, and check it 2026-08-24 23:27:46 +02:00
JOURNAL.md manifest: pin what the URL names, and check it 2026-08-24 23:27:46 +02:00
LICENSE Initial commit: repo skeleton and tooling 2026-08-01 11:43:33 +02:00
README.md packs: xml and typst, plus strip_name_suffixes 2026-08-24 22:22:00 +02:00
TODO.md DESIGN: split the contract from the journal 2026-08-19 16:27:41 +02:00

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 inline nodes.
  • 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 / ## Indexing into a heading path, with no line of the resolver knowing which language it is looking at. YAML's *alias finds its &anchor through 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, #let bindings like any language, and <intro>/@intro as a definition and a reference.
  • Goto-definition from a cursor, with the qualifier walk that makes a.b.c query the qualified form rather than the bare name.
  • A workspace symbol indexname → 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 returns Lines.at for Index. and misses the point.
  • References, when asked for (Index.keep_references, off by default because they outnumber definitions 4:1). resolve.usesOf runs 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) passes x as 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.zon read 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 .gitignore matching — which the library imports nowhere, because a walk is the host's job.
  • Takes a grammar as an input. An opaque *const anyopaque plus query text — opaque because a host's own translate-c of tree_sitter/api.h produces 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 exact rung 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.