Name resolution for Neovim/a vim.pro plugin

Every parse in the project matches. One of them is yours.

Which definition does a name bind to — across files, following imports and scope? That is the question a text search cannot answer, and it is why a text search over-reports. Four questions, and every answer says how much to trust it.

Install Source ↗ The four questions
Fig. 1 — references(root, {path="src/lib.ts", lnum=1, name="target"}) two passes
PASS ONE — EVERY MENTION THE DEFINITION src/decoy.ts:1:10a different function, same name src/notes.md:8:3a comment src/page.ts:2:11a real call src/decoy.ts:2:11the decoy’s own call src/util.ts:44:6a string src/lib.ts:1:17 export function target() rg → 5 mentions · fidelity “text” 1 reference · fidelity “resolved”
01The question

A mention is not a reference.

Two files called the same thing are two files. Ripgrep cannot tell them apart, and neither can anything built on ripgrep — which is most of what an editor offers you. So the work is done in two passes, and the split is the honesty.

Pass one — candidatestext
$ rg --word-regexp --fixed-strings target

src/decoy.ts:1:10:function target(x) {
src/notes.md:8:3: -- target is the one that
src/page.ts:2:11:const x = target("a");
src/decoy.ts:2:11:const y = target(2);
src/util.ts:44:6: "target"

5 positions. Fast, over-broad, and never
reported as more than it is.
Pass two — resolvedresolved
A language server answers this in ONE
request — it searches the project itself.
Stack graphs is asked of each candidate
whether it binds to this definition.

src/page.ts:2:11   ← kept

Four dropped. Two of them are a DIFFERENT
function that merely shares a name; one is
a comment; one is a string.

Without an engine the first pass still runs
and the answer is labeled text, because a
name match is evidence of a mention and
nothing more.
02The contract

Every answer carries a fidelity, and it is never nil.

A consumer rendering an answer may never use a stronger word than the one it was handed. That is the entire contract, and it is what makes a wrong answer into a weak answer instead of a silent one.

INKED
resolved

An engine decided this. A binding, not a coincidence.

PENCIL
text

Ripgrep matched a name. A mention is not a reference; two files called the same thing are two files.

UNDRAWN
none

Nothing could be determined. The list is empty because nothing was answerable — not because the answer is empty.

A confident zero and an unanswerable question look identical if you only return a count. Why text and none are different words

The two directions degrade differently, on purpose. With no resolver installed, references() still has ripgrep's answer — which files mention the name — so it returns text. There is no textual approximation of what does this file reach, so depends() returns none rather than a zero it cannot stand behind.

03The four questions

Two directions, and they are not two names for one thing.

An entry point nobody calls has no references and a large footprint. That is what makes it an entry point. Conflating the two is how a tool reports zero about the most important file in a project.

definitionspoint

What the name at this position binds to. A batch, because each call is a subprocess and a footprint asks about hundreds.

referencesinbound

What binds to this definition. Who actually uses it — as against who happens to type the same word.

dependsoutbound

What this file reaches, one hop. Every identifier in it goes to the engine at once; each definition that lands in another file is a dependency.

closurefootprint

Outbound, transitive. Depth-bounded, because a footprint that reaches the whole repository tells you nothing — and because each hop is a subprocess.

The whole surface:h stackgraphs
local sg = require("stackgraphs")

sg.prepare(root, function(langs)          -- index. the only slow step

  sg.references(root, { path = "src/lib.ts", lnum = 1, name = "target" },
    function(a)
      a.hits       -- { { path = "src/page.ts", lnum = 2, col = 11 } }
      a.n, a.files -- 1 reference, in 1 file
      a.fidelity   -- "resolved" | "text" | "none"  — never nil
      a.reason     -- why, when it is not resolved
    end)

  sg.depends(root, "src/entry.ts", function(a) a.files -- one hop out end)
  sg.closure(root, { "src/entry.ts" }, { depth = 3 }, function(a) end)
  sg.definitions(root, positions, function(a) a.at -- keyed "path:lnum:col" end)
end)

sg.languages()   -- what this machine can resolve RIGHT NOW
sg.status()      -- a line per crate, for :checkhealth
04Two conventions, pinned

Neither of these fails loudly. They resolve nothing, quietly.

Both are asserted in the specs rather than documented and hoped for, because both have already produced a silent zero in real code. A silent zero is indistinguishable from a symbol nobody uses.

Paths are repo-relative — always, in and out

The engine prints absolute paths even when the query was relative. On macOS it prints the symlink-resolved absolute path, so a root under /var comes back under /private/var. A caller comparing those against its own relative paths gets no matches and no error.

you asked about src/page.ts:2:11
the engine says /private/var/folders/…/proj/src/lib.ts:1:17
you compare "/private/var/…/src/lib.ts" == "src/lib.ts" → false, forever

Positions are 1-based — line and column

Matching the engine's own convention and Neovim's line numbers. An off-by-one here does not throw; it simply asks about the character before the name and gets back nothing, which reads exactly like a symbol that binds to nothing.

col 11 const x = target("a"); → resolves
col 10 const x = target("a"); → has no definitions
05Why a protocol and not a wrapper

This is no longer an argument. It is a thing that happened.

The plugin was built on the tree-sitter-stack-graphs CLI. That engine is archived upstream — repository archived 2025‑09‑09, crates last published 2024‑12‑13. A language server is the primary backend now, because servers are maintained by the ecosystems whose languages they resolve, and for TypeScript the server is the compiler: ../lib/db.js resolving to db.js is not an approximation of module resolution, it is module resolution.

What did not changestable
stackgraphs/init.lua

  definitions · references
  depends     · closure
  languages   · status · prepare

  fidelity, always
  repo-relative paths
  1-based positions

No consumer changed. scry's fourteen
specs passed untouched against a
resolver that shares no code with the
one they were written for.
What the swap actually costswapped
stackgraphs/engine.lua   binaries, index, db
stackgraphs/query.lua    run, parse, filter
stackgraphs/lsp.lua      new

Two files touched, one added.

And the conventions earned their keep
on the first run: the new backend had
the SAME silent zero as the old one —
an unresolved root — in code sharing
none of its lines.
Depending on an archived engine is a decision. Letting it name your functions is an accident.
06Install

Neovim 0.10+, ripgrep, and a resolver for the languages you care about.

1

The plugin

No setup() call. It is a library — nothing runs until something asks it a question.

{ "vim-pro/stackgraphs.nvim" }
2

A language server, for the languages you want resolved

Anything on $PATH from the list below is picked up with no configuration. Install only what you need — the rest answer honestly rather than wrongly.

typescript · javascript · tsx  vtsls, typescript-language-server
python       basedpyright-langserver, pyright-langserver, pylsp
lua          lua-language-server
go           gopls
rust         rust-analyzer
c · cpp      clangd
java         jdtls
ruby         ruby-lsp, solargraph

# and setup{} names one the list does not know:
#   servers = { elixir = { cmd = { "elixir-ls" } } }
3

Or the stack-graphs engine, which still works

Archived upstream, and still the only backend that answers with no server running and no project toolchain set up. Binaries are looked for in this order — the second so an existing scry install is not made to redo it.

cargo install tree-sitter-stack-graphs-typescript --features cli \
  --root ~/.local/share/nvim/stackgraphs

stdpath("data")/stackgraphs/bin/<crate>
stdpath("data")/scry/bin/<crate>
$PATH
4

A missing resolver is not an error

It changes fidelity, which is what fidelity is for. :checkhealth reports both backends, and languages() only ever lists what is actually installed — mapped to the backend that will answer.

> require("stackgraphs").languages()
{ typescript = "lsp", javascript = "lsp", python = "stackgraphs" }
07Honesty ledger

What this cannot tell you, in one place.

Each of these was learned by being wrong about it. A drawing that does not mark its own tolerances is not a drawing.