Skip to content
dpepPublic

About

Ruby code intelligence for agents: position to meaning, definition to references

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

trekr logo

trekr

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).

Install

brew install dpep/tools/trekr    # or: cargo install trekr

VS 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).

Usage

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 free

The 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 tally

Exit 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.

On rails

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::Querying

Name 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::RelationMethods

A 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.

References to a method, not a name

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-excluded lists 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.

In a view

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_method exposes, the app's helpers (app/helpers, as helper :all includes them), then ActionView::Base — resolved_via: view.
  • A component's template runs on the component: badge_component.html.erb beside badge_component.rb, or any template in badge_component/, calls on the class that file declares, private methods too — resolved_via: sidecar. Elsewhere outside app/views, a bare call's self is not named.
  • @post in posts/show.html.erb is typed from PostsController#show: its writes, a before_action that runs for it, an action that render :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) --def answers the partial's file (under: template). In the partial, a local a render hands it is defined at that key and typed by its value — resolved_via: render.
  • In RABL, self is the engine, then the view; object @post is typed from the controller, and attributes :title, child(:comments) and a node block's parameter resolve on the object of their scope (rabl:object); a template that extends another 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.

In Claude Code

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.

In VS Code

The same server, plus receiver-aware completion. Install trekr from the Marketplace — search "trekr" in the Extensions view, or:

code --install-extension dpep.trekr

It 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.

Running it

Where it keeps things

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.

Usage counts

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.

In a very large repo

  • Turn on git's own caches: git config core.untrackedCache true and git config core.fsmonitor true. Every --index starts with a git status; on a synthetic 336k-file monorepo the two take it from 2.7 s to 0.09 s.
  • A first --index needs 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.

Known limits

The full list, with the reasoning, is in docs/ARCHITECTURE.md. The ones most likely to surprise you:

  • X = Class.new(Base) do … end (and Struct.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 to X.
  • class Foo < Struct.new(:a) declares its members on Foo, where Ruby puts them on an anonymous class between the two.
  • An @ivar receiver 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 rbs gem the app's Ruby carries (bundled since Ruby 3.0). A super that 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's SafeBuffer returns its own. No Ruby found for a checkout, or no rbs, means nothing is known of core, and --index says 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 answers residue for 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 refine is not modeled. In a view, route helpers (posts_path) are residue, and every template sees every helper: clear_helpers and a controller's own helper list are not modeled.

Development

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_each

make 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.

Credits

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.

License

MIT — see LICENSE.txt. Third-party notices, including Rubydex's, are in NOTICE.md.

About

Ruby code intelligence for agents: position to meaning, definition to references

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages