design note · · 4 min

Never grep for a line number again

LSP is positional: to ask who calls a function you must first know its file, line and column. Agents get that by grepping, and grep does not know what a symbol is. We made every tool take the name instead.

On this page · 3 sections
  1. The name is the address
  2. Two language servers that lie about it
  3. What it does not do yet

Ask an agent to find the callers of Metrics::record and watch what it actually does. It greps for fn record, gets nine hits in five files, guesses which one is the definition, counts characters to find the column, and only then calls the tool that answers the question. Three of those four steps are the agent doing by hand what the analyzer has already done, and each one is a chance to be wrong: the grep can miss a definition split across lines, it can land on a comment, it can pick the record on a different type. The last step, the one that knows what a symbol is, gets a position that was produced by a tool that does not.

This is not the agent’s fault. LSP is positional by design, because it was written for an editor, and an editor always has a cursor. An agent has a name.

Two paths to the same answer: grep, count hits, guess a line and column, then call the tool; or call the tool with the symbol name
The positional path asks the agent to produce an address with a tool that does not understand symbols. The index already has one.

The name is the address

Every position tool now takes symbol instead of a position:

{ "name": "code_callers", "arguments": { "symbol": "Metrics::record" } }

The name can be bare (narrow_scope) or qualified by its container (Metrics::record, signal.ComputeSignal, Class.method), and path is accepted only as a hint to disambiguate. There is also code_symbols for searching the index directly, which prints [Kind] Container::name — file:line:col and is what you want when you are not sure what a thing is called.

$ code_symbols { "query": "run_shadow" }
3 symbol(s) matching `run_shadow`:
  [Function] run_shadow     — crates/prod-code-mcp/src/shadow.rs:165:14
  [Function] run_shadow     — crates/prod-code-gateway/src/shadow.rs:469:14
  [Function] run_shadow_cli — crates/prod-code-client/src/main.rs:1891:10

Underneath it is workspace/symbol, the request every language server already implements and almost no agent uses. For Rust the gateway answers it in process from the same database that answers hovers, taking the position from the symbol’s focus range, which is the name itself rather than the start of the item. For Go, C and C++, TypeScript, Python and Swift the request is forwarded to the language server. Then the hits are scored:

signal score
the name matches exactly +100
the qualifier matches the hit’s container +50 / +30
a path hint matches +30
the identifier is not really at that position −1000

That last row is the one that earns its keep: a stale index or a language-server bug produces a confident hit at a position where the name does not appear, and it is removed rather than ranked. If two different locations still tie, the tool does not guess: it returns an error listing the candidates, and the agent qualifies the name.

That refusal is a real answer, not a corner case. TailBuffer exists twice in our own repository, once in the gateway and once in the client, and both have a push:

$ code_callers { "symbol": "TailBuffer::push" }
`TailBuffer::push` is ambiguous (2 candidates); qualify it (Type::name) or pass `path`:
  [Function] TailBuffer::push — crates/prod-code-gateway/src/shadow.rs:58:12
  [Function] TailBuffer::push — crates/prod-code-mcp/src/exec.rs:123:12

$ code_callers { "symbol": "TailBuffer::push",
                 "path": "crates/prod-code-gateway/src/shadow.rs" }
`push`: 2 caller(s)
  • spawn_reader   …/crates/prod-code-gateway/src/shadow.rs:322:4
    call sites: 334:22
  • tail_buffer_keeps_only_the_end_and_counts_everything   …/shadow.rs:701:8
    call sites: 703:14, 704:14, 705:14, 708:14

A grep for fn push would have returned both definitions and left the choice to a model that cannot see the type. The index knows they are different functions.

Two language servers that lie about it

Both gotchas cost an afternoon, and both are worth knowing if you build on workspace/symbol.

The TypeScript server and clangd answer an empty list until at least one file of the project is open. Not an error, not a “not ready”: [], which is indistinguishable from “no such symbol”. So the search opens a representative source file first, the shortest non-test path under src/ or lib/, and retries once after 800 ms if the first answer is empty.

The other one was a wrong answer rather than a missing one. Ubuntu’s clangd 18 reports symbols declared in headers under the wrong file, so code_definition on a C++ symbol landed in a source file that merely included it. Newer clangd fixes it; the nodes now run 22.1.6 from the upstream release rather than the distribution package, and the gateway puts that on its own PATH.

There is a third thing we did not expect, and it is about the agent rather than the protocol. A stronger model, given tools that accept a name, uses the name. A smaller one keeps grepping for the line first and then passes the position, which still works and still costs the tokens. Making the good path available is not the same as making it the default, and the schema is what decides which one the model sees: the tools accepted symbol for a day before the MCP schema advertised it, and in that day nothing used it.

What it does not do yet

  • Ambiguity is reported, not resolved. Two symbols with the same name in different containers come back as an error listing both, and the caller has to qualify.
  • The index only holds what the language server puts in it: locals, closures and macro-generated names are not addressable by name.
  • A qualifier must match the container the language server reports, which for Rust is the impl self type or the module, not the full path you would write in code.
  • Scoring is heuristic. The 1000-point demotion for a symbol that is not really at its reported position is a workaround for stale indexes, not a guarantee.

If a tool asks an agent for a position, it has asked the wrong question. The agent knows the name; the analyzer knows where it is. Any step between those two facts is a place to be confidently wrong.

Cite this article
Citation
Alexander Panasenko (2026-09-22). Never grep for a line number again. https://prod.codes/blog/never-grep-for-a-line-number/