Ruby code intelligence: turning position → meaning, definition → references. Supports Rails, RSpec, RBIs, gems, worktrees, and scale.
On real Rails code, trekr puts the right definition first 81% of the time on discourse and 64% on mastodon, to ruby-lsp's 51% on both, is wrong less often than ruby-lsp on both, uses under half the memory, and restarts instantly from its saved index (how it compares).
brew install dpep/tools/trekr # or: cargo install trekrVS Code Extension: trekr from the Marketplace (more below).
Upgrading needs no editor restart. A running trekr --lsp watches the
binary it was launched as — brew upgrade re-pointing the trekr symlink
counts, and so does removing the old version — and at a quiet moment hands
the session to the new build in place, unsaved buffers and all (DEC-050).
It waits for a background index it started to finish first. If the new
build's index format changed, the index is rebuilt in the background and
answers say they are partial until it is done (DEC-275). An older build
still running beside a newer one's index never writes into it: it keeps its
own beside it (trekr.v<version>.db) until it is upgraded too (DEC-300).
trekr --index # index up front; the first query does it otherwise
trekr --status # this checkout and its gems; --all lists every checkout
trekr --symbols lib/thing.rb # outline a file before reading it
trekr --refs 'Widget#save' # references narrowed by receiver
trekr --refs Widget # every mention of a name in this checkout
trekr --refs spec/widget_spec.rb:12 # what reads the let (or method) at a position
trekr --def lib/thing.rb:12:5 # what is this name, and where is it defined
trekr lib/thing.rb:12:5 --explain # the same, and why it came out that way
trekr Widget#save # a card: where it is defined, how many sites reach it
trekr --ancestors Widget # the linearized ancestor chain
trekr --dead app/models # methods and classes nothing appears to use, graded
trekr --dead spec # and the lets, subjects and shared groups nothing reads
trekr --gc --dry-run # what old gem versions and deleted worktrees would freeThe bare forms are sugar: a position is --def, and Owner#method or a
Constant is a card — the definition and, for a method, the reference
counts by tier (--refs lists the sites); for an Active Record model, its
table from db/schema.rb or db/structure.sql (table in JSON: columns
with type, null and default, the primary key, the indexes).
There is no setup step: the first query in a checkout indexes it, once,
and later queries use the index. A question about the whole checkout —
--refs, --dead, --ancestors, a card — waits for the whole index, since a
caller or an override may be in any file: about 4 s on discourse, 17 s on a
100k-file monorepo. A position (--def, FILE:LINE:COL) waits only for its
file and the files and gems it names, and answers in under a second with
warming while the rest is read in the background; a miss there waits for
the rest, so it is never a "try again". Past a second the query says on
stderr what it is doing, and at a terminal shows a progress line that clears
itself; Ctrl-C stops the wait, not the index, which a later query picks up.
A checkout whose index an upgrade dropped, or one cut short, is indexed the
same way, and two queries at once share one index. --no-index (or
TREKR_NO_INDEX=1) answers from what is indexed instead: a checkout nobody
indexed is not_indexed, exit 2. --status never indexes.
Every command honors --json and --ndjson, because the intended caller is an
agent. Under --ndjson a row set (--refs, --dead, --symbols, --usage)
streams one row per line, then ends with one {"answer": {…}} line: the rest
of the --json answer (counts, summary, status…) plus rows, how many
lines came before it. That line is always written, even for an empty set, so a
reader can tell a finished stream from a broken one:
trekr --refs 'Post#publish' -J | jq -c 'select(.answer | not)' # the sites
trekr --refs 'Post#publish' -J | tail -1 | jq .answer.counts # the tallyExit codes mean one thing each: 0 an answer, 1 nothing found (status
says how sure), 2 no answer yet (run the hint, then ask again), and
64–74 an error, which under --json is one {"error", "kind", "code"}
object on stdout. An answer given while a first index is still running
carries warming, claims nothing certain, and --dead lists nothing until
it ends. Every status, exit code and JSON field is described in
docs/OUTPUT.md.
A bare name lists every mention — definitions, and calls with the owner they
resolve to, or the receiver's shape when they don't. find_each has 33 in
rails; five of them:
$ trekr --refs find_each
activerecord/lib/active_record/destroy_association_async_job.rb:30:82 call other
activerecord/lib/active_record/querying.rb:24:14 definition method
activerecord/lib/active_record/relation/batches.rb:85:9 definition method
activerecord/lib/active_record/relation/batches.rb:91:19 call symbol
activerecord/test/cases/batches_test.rb:25:12 call ActiveRecord::QueryingName the owner and the same sites come back tiered by whether they can reach that method (below):
$ trekr --refs 'ActiveRecord::Batches#find_each'
activerecord/lib/active_record/relation/batches.rb:85:9 definition
activerecord/test/cases/batches_test.rb:25:12 confirmed the receiver's class delegates this to a value whose type runs it
...
activerecord/lib/active_record/destroy_association_async_job.rb:30:82 possible untyped receiver, enclosing class shares a namespace with the owner
...
17 confirmed, 10 possible, 2 excluded of 29 same-name call sites
excluded: 2 resolve to a different owner, 0 define no such name, 0 wrong arity--def reparses the one file with Prism, then walks Ruby's own
constant-lookup ladder — enclosing lexical scopes, then the innermost scope's
ancestors, then the top level:
$ trekr --def activerecord/lib/active_record/relation.rb:69:70
activerecord/lib/active_record/relation/batches.rb:7:10 ActiveRecord::Batches
$ trekr --ancestors ActiveRecord::Relation | head -3
ActiveRecord::Relation
ActiveRecord::TokenFor::RelationMethods
ActiveRecord::SignedId::RelationMethodsA column on no name — whitespace, punctuation, most strings — answers for the
nearest name on that line and says so (snapped_to in JSON); FILE:LINE
takes the line's first name. The exception: the string in
it_behaves_like "a widget" answers the shared_examples "a widget" it
includes, and a symbol that names no method (on: :create) is residue,
not a snap to its neighbour.
98 % of rails constant references resolve (91 % discourse) with core and
the gems indexed; rails' remainder is one optional adapter that is not
installed. A method call goes up a receiver ladder — self, a constant, a
local typed from X.new or a Sorbet sig, a Rails association — and when the
receiver cannot be pinned down, the answer is residue with ranked candidates,
never a silent guess. Every answer carries status, confidence, and
resolved_via.
--dead grades candidates for deletion; it never calls anything dead:
$ trekr --dead activerecord/lib/active_record/associations
override activerecord/lib/active_record/associations/collection_proxy.rb:1210 ActiveRecord::Associations::CollectionProxy#pretty_print — no call names it, but it overrides ActiveRecord::Relation#pretty_print, so a call of that may run it (lower confidence: a hook `pp` calls by name)
single-caller activerecord/lib/active_record/associations/preloader/association.rb:33 ActiveRecord::Associations::Preloader::Association::LoaderQuery#load_records_in_batch — one possible call, at activerecord/lib/active_record/associations/preloader/batch.rb:49: its receiver is untyped; its caller, group_and_load_similar, is itself a candidate (lower confidence: untyped caller)
convention-only activerecord/lib/active_record/associations/association.rb:212 ActiveRecord::Associations::Association#marshal_dump — named only by a symbol handed to a macro (1) (lower confidence: send, a hook Marshal calls by name)
super-only activerecord/lib/active_record/associations/belongs_to_association.rb:104 ActiveRecord::Associations::BelongsToAssociation#target_changed? — reached only by `super` from ActiveRecord::Associations::BelongsToPolymorphicAssociation (lower confidence: public_send)
…
149 candidates in 34 file(s): 8 unreferenced, 1 test-only, 3 override, 7 convention-only, 2 super-only, 128 single-caller (50 clear, 99 lower)It lists methods, then a spec's lets, subjects, group defs and shared
groups, read the way RSpec runs them, then classes, modules and constants.
Each row is in one tier, from least evidence of use to most —
unreferenced, shadowed (every call lands on an override), test-only
(only specs and tests call or name it), override, convention-only (only
a macro symbol, a route, a test runner or a library's naming convention
reaches it), super-only, single-caller — and is
clear or lower confidence. A lower row's caveat names the way in
trekr cannot see: a Slim view, an extensionless script, a name built at
runtime, a hook Ruby calls by name, an unindexed ancestor, a gem. A row
reached from outside the repo — a gem's public API, a routed action, a
job — says so (entry_point) and is always lower. Every spec-member row
is lower too: run the spec file after deleting one.
The tiers, the caveats, and the hand-checked measurements behind the grading are in docs/OUTPUT.md.
This is the one no other Ruby tool has. Ask about a method, and every call site is sorted by whether its receiver can actually reach it:
$ trekr --refs 'ActiveRecord::ConnectionHandling#lease_connection'
activerecord/lib/active_record/connection_handling.rb:317:9 definition
actioncable/test/subscription_adapter/postgresql_test.rb:26:26 confirmed the receiver's type resolves here
actioncable/test/subscription_adapter/postgresql_test.rb:97:38 confirmed the receiver's type resolves here
...
activerecord/lib/active_record/connection_handling.rb:318:23 possible untyped receiver, but the enclosing class inherits from the owner
...
1088 confirmed, 123 possible, 57 excluded of 1268 same-name call sites
excluded: 54 resolve to a different owner, 3 define no such name, 0 wrong arity- Confirmed: the receiver's type resolves, and Ruby's own lookup from it lands here — for a method the owner inherits, from the owner or a subclass.
- Possible: the receiver is untyped and nothing rules the site out. Ranked by proximity, never dropped.
- Excluded: not listed, but counted, because that count is the difference
between this and a grep.
--include-excludedlists them with their reason, so the claim is auditable rather than asserted.
An initialize is called as new: --refs 'Widget#initialize' lists each
X.new whose class runs it — Widget's, or a subclass's that inherits it —
with "called_as": "new", beside the supers that reach it. A class whose
own def self.new returns another class's new is ruled out; one whose
new returns nothing trekr reads (Active Record's ends in an if) leaves its
sites possible. --def on a plain X.new goes to the initialize it runs.
An RSpec predicate matcher is a call of the predicate it runs:
expect(page).to have_event_count(1) is listed under has_event_count?, and
be_visible under visible?, with called_as naming the matcher — so
--dead counts it too.
A position asks about what is there: trekr --refs spec/widget_spec.rb:12
on a let, a subject or a group's def lists every read RSpec would run
it for, with from — group, nested_group, enclosing_group,
shared_group, includer, helper, super, subject — and on a method's
definition or a call of it, the method's references as above. The editor's
Find References on a let gives the same list.
rg -w lease_connection returns 1,320 lines in rails — the 1,268 call sites
plus the comments — with no way to tell them apart. Widget.save and
Widget#save are different questions and answer differently.
ERB (*.erb), Haml (*.haml) and RABL (*.rabl) templates are indexed like Ruby files, at
their own lines and columns, so --def, --refs, --dead and the editor
answer in them, and a helper or model method a view calls has that call.
- A bare call runs on the view: a name a controller's
helper_methodexposes, the app's helpers (app/helpers, ashelper :allincludes them), thenActionView::Base—resolved_via: view. - A component's template runs on the component:
badge_component.html.erbbesidebadge_component.rb, or any template inbadge_component/, calls on the class that file declares, private methods too —resolved_via: sidecar. Elsewhere outsideapp/views, a bare call'sselfis not named. @postinposts/show.html.erbis typed fromPostsController#show: its writes, abefore_actionthat runs for it, an action thatrender :shows; a mailer template from its mailer's action —resolved_via: controller.- On a
render's name (render "row",render partial: "shared/nav",render @posts)--defanswers the partial's file (under: template). In the partial, a local arenderhands it is defined at that key and typed by its value —resolved_via: render. - In RABL,
selfis the engine, then the view;object @postis typed from the controller, andattributes :title,child(:comments)and anodeblock's parameter resolve on the object of their scope (rabl:object); a template thatextendsanother lends it its object.
A Haml template's if/else chain is read flat — each condition and
branch a statement — and a do block binds its parameters inside it. Route helpers
(posts_path) are residue, and Slim is not read (--dead says when a Slim
view names a row). Measured against a runtime trace of
mastodon's mailer templates, DEC-523.
trekr --lsp speaks LSP: definition, declaration (a Sorbet .rbi
signature), references, hover, document and workspace symbols,
implementation, call hierarchy, require strings as links, and Prism syntax
diagnostics. It answers on methods and constants, and on
locals, parameters and instance variables — whose other mentions it also
highlights. Hover on a model shows its table's columns and indexes, and on a
column's attribute (post.title) the column's type, null and default — read
from db/schema.rb, or db/structure.sql for an app that dumps SQL, without
booting the app. It keeps the index current as files are saved, and indexes an
unindexed checkout in the background, the files you have open first.
claude/INSTALL.md wires up the skill and the server.
The same server, plus receiver-aware completion. Install trekr from the Marketplace — search "trekr" in the Extensions view, or:
code --install-extension dpep.trekrIt finds the trekr binary on your PATH. To build the extension from
source, see its README.
It is meant to replace Ruby LSP and Sorbet as the Ruby language server, not run beside them — two servers answer every request twice. What you gain: definitions and references that follow the receiver instead of the bare name, completion from the receiver's real ancestors, one index shared by every worktree, and nothing to boot. What you give up: rename, formatting, semantic highlighting, signature help, inlay hints, test code lenses, Sorbet's type errors and ruby-lsp-rails' routes. Most have a standalone extension; the extension's README says which.
The index is ~/.local/share/trekr/trekr.db. TREKR_DB=/some/path.db points
every command at another one — a throwaway for CI, or one per project. Beside
it: tree snapshots (trekr.trees/), the working tree's edits as the last
query read them (trekr.overlays/), Ruby core as readable stub files
(trekr.core/rbs-<version>-<key>/String.rb, where a core definition lands),
the language server's lsp.log, and the usage counts (trekr.usage.db).
trekr --help lists the variables. A version of a file nothing maps any more
is kept 14 days for a switch back, then collected; TREKR_RETENTION (30d,
never) changes that — set it where your editor's language server sees it
too, such as your shell profile, since the server starts cleanups of its own.
A damaged index, or one whose upgrade fails, is not an error: trekr moves it
aside (trekr.db.broken-<time>), says where on stderr, and rebuilds it. A
trekr that finds a newer trekr's index leaves it alone and keeps its own
beside it (trekr.v<version>.db). An index stopped mid-way may leave the
early copy it wrote for the editor (trekr.db.early-<pid>/). trekr --status
lists all three, and trekr --gc removes them: a set-aside copy and an early
copy at once, another trekr's index once it has been idle for --older-than.
trekr --usage shows which commands and editor features get used, by whom (an
agent, a person, an editor), how often they come back empty, and how slow. It
counts locally — no queries, paths or repository names — in trekr.usage.db.
TREKR_USAGE=off stops the counting entirely (--usage then says so and
exits 1); a path in TREKR_USAGE moves the file.
trekr --usage --misses lists which editor clicks came back empty or
unsure: file, line, column, the token under the cursor and trekr's one-line
reason. They are read from lsp.log, so TREKR_LOG=off turns them off too.
--days N narrows the window; --json gives one object per miss.
- Turn on git's own caches:
git config core.untrackedCache trueandgit config core.fsmonitor true. Every--indexstarts with agit status; on a synthetic 336k-file monorepo the two take it from 2.7 s to 0.09 s. - A first
--indexneeds free disk of about twice the store's final size while it runs: the checkout's files land in one transaction, and the WAL holds all of it until the commit. At 336k files the store is 4.4 GB and the first index takes about four minutes.
The full list, with the reasoning, is in docs/ARCHITECTURE.md. The ones most likely to surprise you:
X = Class.new(Base) do … end(andStruct.new,Data.define,Module.new) is read as a class body. Right for its methods; a liberty for its constants, which Ruby scopes to the enclosing scope and trekr toX.class Foo < Struct.new(:a)declares its members onFoo, where Ruby puts them on an anonymous class between the two.- An
@ivarreceiver is typed by a vote of every write to it in that class in that file — not only the writes that reach the read, and not the class's other files. - Core comes from the
rbsgem the app's Ruby carries (bundled since Ruby 3.0). Asuperthat lands in core is only as right as those signatures are complete, and a chain through a core method (x.gsub(a, b).downcase) takes their documented return type, even where a subclass such as ActiveSupport'sSafeBufferreturns its own. No Ruby found for a checkout, or no rbs, means nothing is known of core, and--indexsays so. - A method defined in a loop over a list another file assigns
(
METHODS_WITH_QUERY.each { class_eval "def #{m}…" }) is not named. Its class answersresiduefor such a name, never "no such method", and the string's own calls are still read. - Slim templates are not read (ERB, Haml and RABL are), and
refineis not modeled. In a view, route helpers (posts_path) are residue, and every template sees every helper:clear_helpersand a controller's ownhelperlist are not modeled.
make check # the commit gate: fmt, clippy, tests, docs, the extension's unit tests
make bench # reproduce the corpus benchmarks in docs/ARCHITECTURE.md
make dogfood REPO=/path/to/rails Q=find_eachmake bench and make dogfood read corpora from CORPORA/REPO, which
default to this author's checkout layout — point them at your own clones of
rails, discourse, mastodon, and CRuby. make dogfood is not optional
ceremony: running --refs on real Rails keeps finding defects no
fixture-sized test can reach.
Conventions are in CLAUDE.md; decisions already made and turned down are in docs/DECISIONS.md — check it before proposing an alternative.
Ruby semantics are lifted, with attribution, from
Shopify's Rubydex (MIT) — its
docs/ruby-behaviors.md is the conformance spec this extractor is written
against, and a block of resolution cases in src/tree/mod.rs is ported from its
test suite. trekr does not depend on the crate; the reasons are in
PLAN §8. Parsing is
Prism. Store and CLI conventions come from
rq, Prism patterns from
rwr.
MIT — see LICENSE.txt. Third-party notices, including Rubydex's, are in NOTICE.md.