Precise, semantic code operations — rename and diagnostics — for LLM coding
agents, backed by real Language Server Protocol servers instead of
grep-and-replace.
The premise: LSP is editor-shaped; an agent is intent-shaped. An editor has a
cursor, so a code position is free. An agent has only a symbol name and an
intent. The hard part — turning "rename getUser" into precise zero-indexed
UTF-16 coordinates and a verified, cross-file edit — is exactly what this tool
does, so the model never has to count columns (and quietly corrupt a string or
miss a reference).
Status: early, active research. A working v0 (
lsp-tool, a stateless Rust CLI) doesrename+diagnosticsagainst ty for Python; Go, Rust, and TypeScript are the next targets. Built for the Tilth agent harness, but usable by any tool that can shell out.
It began as a raw LSP testbed — lsp_raw_client.py, a
from-scratch client that speaks JSON-RPC over stdio and prints every frame, to
learn the protocol on the wire rather than through a library. That surfaced the
design (semantic verbs over a transparent proxy; the UTF-16 and apply-order
traps) and a build-vs-reuse question — settled in Rust's favor by a
Rust-vs-Python bake-off. Three docs carry the thinking:
- documentation.md — the decided architecture plus an LSP protocol reference (diagrams, glossary, spec links).
- research.md — the rationale, and the Rust-vs-Python comparison (behavior, lines-of-code-to-maintain, latency).
- planning.md — thin and forward-looking: what's next.
uv sync # installs ty into .venv/bin/ty, plus dev tools (pytest, ruff)A stateless Rust CLI (lsp-tool-rs/) the harness shells out to
for rename and diagnostics, JSON on stdout. It spawns a language server as a
subprocess and speaks LSP over stdio — the language-agnostic seam (ty is Rust,
gopls is Go; the client doesn't care). For Python it drives ty directly
(.venv/bin/ty, no Python in the loop). An earlier Python-on-multilspy trial
lives in lsp-tool-py/, kept for the comparison that chose the
language — see research.md.
Run from the repo root (--workspace defaults to .):
# Rust — the implementation. First `cargo run` compiles, then it's instant.
cargo run --manifest-path lsp-tool-rs/Cargo.toml -- diagnostics sample.py
cargo run --manifest-path lsp-tool-rs/Cargo.toml -- rename sample.py 5 10 salutation
# Python — early trial, kept for the comparison.
uv run python lsp-tool-py/lsp_tool.py diagnostics sample.pyrename takes <file> <line> <character> <new-name> (0-indexed). Without
--apply it prints the WorkspaceEdit; add --apply to edit the files in place
(restore with git checkout sample.py consumer.py).
uv run python lsp_raw_client.py # drive a session, print every frame
uv run pytest # test the WorkspaceEdit apply logicYou get a stream of → SEND and ← RECV blocks — the actual JSON-RPC frames to
and from ty server (ty's own logs go to stderr, prefixed [ty stderr]). The
run ends with ✎ APPLIED blocks showing the files after a rename is applied,
then restores the originals so it stays repeatable. The script is a from-scratch
LSP client — framing, lifecycle, and message shapes in one ~450-line file you can
read top to bottom — built to make the protocol legible, not to be a useful
client. It's where the tool's design came from.
LSP is just JSON-RPC 2.0 framed with Content-Length headers. Libraries
like pygls, lsprotocol, or VS Code's client hide that under typed
request/response helpers, which is great for building tools but bad for
learning what's actually on the wire. This script keeps the framing,
the lifecycle, and the message shapes all in one ~450-line file you can
read top to bottom.
LSP over stdio is JSON-RPC framed with Content-Length headers — see
documentation.md §2 for the wire
format. In this repo the Framer class owns it: send() prefixes the JSON
with the header block; recv() reads headers until the blank line, then reads
exactly N bytes of body (never readline() on the body — the JSON can
contain newlines), decodes, and returns a dict.
JSON-RPC has three shapes — request, response, notification — and this script
exercises all of them (initialize/hover are requests; initialized/
didOpen/exit are notifications; publishDiagnostics is one the server
pushes at us). See
documentation.md §3
for what distinguishes them and the per-sender id rule.
Every session follows the same skeleton — initialize → initialized → work
→ shutdown → exit, diagrammed in
documentation.md §4. The next section
is that lifecycle made concrete: the exact frames this script sends and gets
back.
-
initialize(request, id=1). Client announces who it is, where the workspace root is, and which features it supports. The response is the server's capabilities — the menu of methods it can answer. ty's response includeshoverProvider,definitionProvider,renameProvider.prepareProvider,diagnosticProviderwithinterFileDependencies, and a lot more. Worth reading carefully — this single response tells you everything you can ask ty to do. -
initialized(notification). No body, no reply. Until the client sends this, the server is forbidden from sending unsolicited messages. Right after this notification the floodgates open. -
textDocument/didOpen(notification, ×2). Hands ty the current text ofsample.pyandconsumer.py. LSP servers do not read files from disk — the client is the source of truth for buffer state. Each open draws an unpromptedtextDocument/publishDiagnostics:sample.pyreports one diagnostic (invalid-argument-type, withrelatedInformationpointing at wheregreetand its parameter were declared);consumer.pyreports an emptydiagnostics: []— ty's way of saying "checked, clean." Both files must be open for the later rename to find references across them. -
textDocument/hover(request, id=2). Asks "what's at line 5, col 10?". ty returns a markdown code block with the resolved signature:def greet(name: str) -> str. (Positions are zero-indexed{line, character}; see documentation.md §5 on the UTF-16 encoding.) -
textDocument/prepareRename(request, id=3). The "can I rename this, and what span?" probe. ty returns theRangeofgreet(line 5, chars 10–15) — the bare-Rangeform — ornullif the position isn't renameable. (documentation.md §7 lists all four result shapes a robust client must handle.) -
textDocument/rename(request, id=4). Sends the position plusnewName: "salutation"; ty returns aWorkspaceEditwhosechangesmap carries four edits across two files — thedef greetandgreet(123)insample.py, plus theimport greetandgreet("world")inconsumer.py. It does not touch the word "greet" insample.py's decoy comment/f-string orconsumer.py's docstring — those aren't references to the symbol. This is the object an LLM should apply, never synthesize; the encodings and apply-order rules are in documentation.md §7. -
Apply the
WorkspaceEdit.apply_workspace_edit()writes the four edits to disk, completing the rename round-trip; the script prints the edited files (✎ APPLIED) then restores the originals so it stays re-runnable. See Applying the WorkspaceEdit. -
shutdown(request, id=5) thenexit(notification). Two-step so a client can confirm a clean stop (shutdownreturnsnull) before the process actually goes away.
apply_workspace_edit() turns the rename's WorkspaceEdit into file writes,
completing the round-trip; the pure apply_text_edits(text, edits) -> str does
the splicing and is the piece worth lifting into a real tool. It reads both
changes and documentChanges and refuses Create/Rename/Delete resource
ops loudly rather than dropping them. The two traps it handles — UTF-16 offset
conversion and bottom-to-top application — are explained in
documentation.md §5 and
§7, and exercised by
test_apply.py (uv run pytest).
The server is allowed to interleave notifications between our request
and its response. In this run, the diagnostic notification arrives
before the hover response. _wait_for_id keeps reading frames until
it sees one whose id matches the request we sent.
Each new request is one more framer.send(...) plus a _wait_for_id.
Good next experiments against the same files:
textDocument/definitionat position (5, 10) — should return the range ofdef greeton line 0.textDocument/referenceswithcontext: {includeDeclaration: true}— should return every occurrence ofgreetacross both open files.textDocument/codeActionover the diagnostic range — ty advertisescodeActionProviderwith aquickfixkind, so this is the path to "offer the fix for the type error," and the action's edit is again aWorkspaceEdit.textDocument/didChangeto edit a buffer in memory (without touching disk), then re-request hover/diagnostics to watch results update — the core loop of an interactive client. This pairs naturally withapply_text_editsto keep the server's view and disk in sync.
documentation.md— the decided architecture oflsp-toolplus an LSP protocol reference (message shapes, lifecycle, rename workflow, diagnostics) with diagrams, a glossary, and spec links.research.md— the rationale behind the design, and the Rust-vs-Python v0 comparison (behavior, lines-of-code-to-maintain, latency).planning.md— thin and forward-looking: current reality and next steps.lsp-tool-rs/— the tool, in Rust: hand-rolled framing, a ty-drivenrename/diagnosticsCLI, and a UTF-16-aware applier. Build/run withcargo.lsp-tool-py/— an early Python-on-multilspy trial, superseded by the Rust implementation; kept forresearch.md.lsp_raw_client.py— the raw testbed client described above.sample.py— small program defininggreet, with a deliberate type error (greet(123)wheregreetexpectsstr) plus two decoy uses of the word "greet" (a comment and an f-string) that rename must ignore.consumer.py— imports and callsgreetfromsample.py, so a single rename produces edits in two files (the cross-file case).test_apply.py— tests for the WorkspaceEdit apply logic (UTF-16 offset conversion, bottom-to-top application, both edit encodings).uv run pytest.pyproject.toml/uv.lock— pinty(protocol output),multilspy(the Python v0), and dev tools (pytest,ruff). The lockfile keeps runs reproducible.
documentation.md carries the concept notes and the deep links into the LSP 3.17 spec. External: JSON-RPC 2.0 · ty docs.