Extends Claude Code's LSP: references and definitions name the docs that use a symbol, an edit that removes a name the docs still use says so, /refs finds…

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">
<h3>Install in Claude Code</h3>
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.
Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.
Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions
<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">
Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.
The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.
<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">
One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.
These are the statusline, eta and smart-compact mods.
<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">
Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.
That's the cells mod.
<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">
When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.
That's the delegate mod.
A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.
That's the route mod.
When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.
That's the lsp-plus mod.
Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.
That's the seek mod.
<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">
/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).
That's the modpack plugin, which installs the other eight.
In Claude Code, type:
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:
/modpack:setup
It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.
By hand instead: SETUP.md has every step.
Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.
We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.
| Cost per job | Without | With the pack | |
|---|---|---|---|
| In a long session (Claude has already read the whole project) | $0.44 | $0.29 | −33% |
| Bigger jobs in a long session (a feature, tests, a rename) | $0.91 | $0.57 | −37% |
| In a fresh session | $0.37 | $0.35 | −4% |
| Bigger jobs in a fresh session | $0.55 | $0.51 | −8%, and 15% faster |
| Quick questions in a fresh session | $0.23 | $0.24 | +2%, about even |
<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">
<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">
<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">
How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.
Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.
Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).
Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.
I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.
Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.
How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.
Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.
What does it cost? Nothing. It's free and open source.
One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:
⏵⏵ auto mode on Opus 5.5 xhigh 412k ━━━━━━────── 5h 34% ↺2h14 7d 12% ↺3d ● ready · cold in 44m
$.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off./rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
placement | line | tail: appended dim to Claude Code's hint. band: above the prompt. off. |
limits | always | half: a plan window only once it is half used. off: never. |
animate | true | The highlight along the gauge. |
An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:
| Option | Cost |
|---|---|
| Let it expire, re-cache on return | $3.20 |
| One keep-alive (re-read the cached prefix; a read resets the TTL) | $0.09 |
| Compact before it expires | ~$0.87, plus detail lost to the summary |
A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:
compactPenaltyUsd. It stops after maxIdleHours.minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.
On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.
| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
strategy | smart | compact: compact at the first expiry. keepalive: never compact. off: status line only. |
cacheTtl | auto | 1h or 5m to override detection. |
minSavingsUsd | 0.25 | Below this re-cache cost, the cache is left to expire. |
compactPenaltyUsd | 0.5 | Higher keeps the cache warm longer before compacting. |
minCompactTokens | 100000 | Never compacts a smaller context. |
maxIdleHours | 6 | Stops keep-alives after this long idle. |
compactInstructions | empty | Passed to idle compactions, like /compact <text>. |
Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.
Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.
read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.
A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d
hooks/register.ts 456 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 DOC_GLOBS,
5 codeSpanNames,
6 driftNote,
7 editedNames,
8 fencedLines,
9 identifierAt,
10 isDoc,
11 listPlaces,
12 mentionsAsCode,
13 parseGrep,
14 referencesNote,
15} from './docs'
16import type { Mention } from './docs'
17import { LANGUAGES, LSP_FILES, codeHits, definitionAt, nudge, searchPatterns, symbolSearch, usesAsCode } from './nudge'
18import type { SymbolSearch } from './nudge'
19import { answerNote, looksCold, parseIncoming, parseOutline, parseReferences, qualified } from './answer'
20import { TF_FILES, dirOf, inLocals, labelColumn, tfAddress } from './terraform'
21import { blastNote, changedSignatures, headerPattern } from './blast'
22import type { TfSearch } from './terraform'
23
24type Engine = EngineInterface
25type LspInput = { operation?: string; filePath?: string; line?: number; character?: number }
26
27// The LSP calls whose answer is about one name, and where the docs' mentions belong.
28const NAMED_OPS = new Set(['findReferences', 'goToDefinition', 'incomingCalls'])
29// The cells mod's tool: where it is loaded, the notes name its one-step calls.
30const CELLS_TOOL = 'mcp__cells__run_cell'
31const NOT_DOCS = DOC_GLOBS.map(g => `:(exclude)${g}`)
32const MAX_DOC_FILES = 20
33const MAX_REMOVED = 6
34
35let docs = true
36let drift = true
37let nudging = true
38let answering = false
39let warming = false
40let blasting = false
41// The languages the repository has enough of whose server did not answer the warm-up, as the person reads them.
42let missing: string[] = []
43// Symbols already nudged about this session: the next note is one line.
44const nudged = new Set<string>()
45// Names already reported as drifted this session: each is said once.
46const told = new Set<string>()
47
48const join = (cwd: string, path: string) => (path.startsWith('/') ? path : `${cwd}/${path}`)
49
50/** `git` in the session's folder; undefined where git fails (no repository). `git grep` exits 1 on no match. */
51async function git($: Engine, cwd: string, args: string[]): Promise<string | undefined> {
52 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 8000 }).catch(() => undefined)
53 return r !== undefined && (r.exitCode === 0 || r.exitCode === 1) ? r.stdout : undefined
54}
55
56/** The doc lines that mention `name` as code. */
57async function docMentions($: Engine, cwd: string, name: string): Promise<Mention[]> {
58 const out = await git($, cwd, ['grep', '--untracked', '-n', '-I', '-w', '-F', '-e', name, '--', ...DOC_GLOBS])
59 if (!out) return []
60 const hits = parseGrep(out)
61 const files = [...new Set(hits.map(h => h.path))].slice(0, MAX_DOC_FILES)
62 const found: Mention[] = []
63 for (const path of files) {
64 const text = await $.fs.read(join(cwd, path)).catch(() => '')
65 const fenced = fencedLines(text)
66 for (const h of hits) if (h.path === path && mentionsAsCode(h.text, name, fenced.has(h.line))) found.push(h)
67 }
68 return found
69}
70
71/** Whether any file but the docs still names `name`. */
72async function codeUses($: Engine, cwd: string, name: string): Promise<boolean> {
73 const out = await git($, cwd, ['grep', '--untracked', '-l', '-I', '-w', '-F', '-e', name, '--', '.', ...NOT_DOCS])
74 return out === undefined || out.trim() !== ''
75}
76
77/** A note for each name an edit removed from the code that the docs still mention. */
78async function driftNotes($: Engine, cwd: string, before: string, after: string): Promise<string[]> {
79 const { removed, added } = editedNames(before, after)
80 const notes: string[] = []
81 for (const name of removed.slice(0, MAX_REMOVED)) {
82 if (told.has(name) || (await codeUses($, cwd, name))) continue
83 const found = await docMentions($, cwd, name)
84 if (found.length === 0) continue
85 told.add(name)
86 notes.push(driftNote(name, found, removed.length === 1 && added.length === 1 ? added[0] : undefined))
87 }
88 return notes
89}
90
91/** /refs: every code reference in the docs that no longer resolves. */
92async function checkDocs($: Engine, cwd: string): Promise<string> {
93 const listed = await git($, cwd, ['ls-files', '--cached', '--others', '--exclude-standard', '--', ...DOC_GLOBS])
94 if (listed === undefined) return 'No git repository here, so there are no docs to check.'
95 const files = [...new Set(listed.split('\n').filter(Boolean))].slice(0, 300)
96 const byName = new Map<string, Mention[]>()
97 const badPaths: Mention[] = []
98 for (const path of files) {
99 const text = await $.fs.read(join(cwd, path)).catch(() => '')
100 for (const { name, line } of codeSpanNames(text)) {
101 const list = byName.get(name) ?? []
102 list.push({ path, line, text: '' })
103 byName.set(name, list)
104 }
105 const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : ''
106 for (const [i, row] of text.split('\n').entries()) {
107 for (const m of row.matchAll(/\]\(((?!https?:|mailto:|#)[^)\s#]+)(?:#[^)]*)?\)/g)) {
108 const target = m[1] as string
109 const there = await $.fs.exists(join(cwd, target.startsWith('/') ? target.slice(1) : dir + target)).catch(() => true)
110 if (!there) badPaths.push({ path, line: i + 1, text: target })
111 }
112 }
113 }
114 const names = [...byName.keys()]
115 const known = new Set<string>()
116 for (let i = 0; i < names.length; i += 100) {
117 const chunk = names.slice(i, i + 100)
118 const out = await git($, cwd, ['grep', '--untracked', '-o', '-h', '-I', '-w', '-F', ...chunk.flatMap(n => ['-e', n]), '--', '.', ...NOT_DOCS])
119 for (const hit of (out ?? '').split('\n')) if (hit) known.add(hit)
120 }
121 const stale = names.filter(n => !known.has(n))
122 const refs = [...byName.values()].reduce((n, l) => n + l.length, 0)
123 const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
124 const docs = count(files.length, 'doc', 'docs')
125 if (stale.length === 0 && badPaths.length === 0) return `All ${count(refs, 'code reference', 'code references')} in ${docs} resolve.`
126 const parts = [
127 `${docs}, ${count(refs, 'code reference', 'code references')}: ${count(stale.length, 'name', 'names')} no file in the code has, ${count(badPaths.length, 'link', 'links')} to files that do not exist.`,
128 ]
129 if (stale.length > 0) parts.push('', 'Names:', ...stale.slice(0, 40).map(n => ` \`${n}\` ${listPlaces(byName.get(n) ?? [], 4)}`))
130 if (badPaths.length > 0) parts.push('', 'Links:', ...badPaths.slice(0, 40).map(b => ` ${b.text} ${b.path}:${b.line}`))
131 return parts.join('\n')
132}
133
134/**
135 * The note on a grep for a code symbol: which LSP call answers it exactly, and,
136 * with the cells mod loaded, the call in run_cell that needs no LSP tool loaded.
137 */
138async function symbolNote($: Engine, command: string, r: { deny?: string; text?: string }): Promise<string | undefined> {
139 const search = nudging && r.deny === undefined ? symbolSearch(command) : undefined
140 const out = r.text ?? ''
141 if (search === undefined) return undefined
142 const hits = codeHits(out)
143 if (hits.length === 0 && !out.split('\n').some(l => LSP_FILES.test(l.split(':')[0] ?? ''))) return undefined
144 if (hits.length > 0 && !hits.some(h => usesAsCode(h.text, search.name))) return undefined
145 const again = nudged.has(search.name)
146 nudged.add(search.name)
147 const cells = (await $.tool.list().catch(() => [])).some(t => t.name === CELLS_TOOL) ? { file: hits[0]?.path } : undefined
148 return nudge(search, definitionAt(hits, search.name), again, cells)
149}
150
151/** What the LSP tool says where the server has no call hierarchy for the position. */
152const NO_CALLS = /^No call hierarchy item/
153
154/** One LSP call; its text, or undefined where the server failed, is missing or was refused. */
155async function lsp($: Engine, input: Record<string, unknown>): Promise<string | undefined> {
156 const r = await $.tool.call({ tool: 'LSP', ...input } as never).catch(() => undefined)
157 if (r === undefined || r.deny !== undefined || r.isError) return undefined
158 const text = r.text ?? ''
159 return /^(Error performing|No LSP server)/.test(text) ? undefined : text
160}
161
162/** Where `name` is defined: from the grep's own hits, else the one symbol of that name the server knows. */
163async function definitionOf($: Engine, cwd: string, name: string, hits: ReturnType<typeof codeHits>): Promise<{ path: string; line: number; character: number; text: string } | undefined> {
164 const at = definitionAt(hits, name)
165 if (at) return { ...at, text: hits.find(h => h.path === at.path && h.line === at.line)?.text ?? '' }
166 const any = hits[0]
167 if (!any) return undefined
168 const listed = await lsp($, { operation: 'workspaceSymbol', filePath: join(cwd, any.path), line: 1, character: 1, query: name })
169 if (!listed) return undefined
170 const exact: Array<{ file: string; kind: string; line: number }> = []
171 let file = ''
172 for (const row of listed.split('\n')) {
173 const header = /^(\S.*):$/.exec(row)
174 if (header && !row.startsWith('Found')) file = header[1] as string
175 const m = /^\s+(\S+) \((\w+)\) - Line (\d+)/.exec(row)
176 if (m && m[1] === name && file) exact.push({ file, kind: m[2] as string, line: Number(m[3]) })
177 }
178 const named = exact.filter(s => !/^(Variable|Constant|Field|Property)$/.test(s.kind))
179 const one = named.length === 1 ? named[0] : exact.length === 1 ? exact[0] : undefined
180 if (!one) return undefined
181 const text = ((await $.fs.read(join(cwd, one.file)).catch(() => '')) ?? '').split('\n')[one.line - 1] ?? ''
182 const col = new RegExp(`(^|[^\\w$])${name.replace(/\$/g, '\\$')}(?![\\w$])`).exec(text)
183 if (!col) return undefined
184 return { path: one.file, line: one.line, character: col.index + (col[1] ?? '').length + 1, text }
185}
186
187/**
188 * The language server's answer to a symbol grep, read by the hook: for a function, who calls
189 * it (qualified by each caller's file outline); otherwise its references. A server still
190 * indexing knows only the file it was asked about: asked again for up to three seconds, and
191 * without a whole answer there is no note, never a partial one.
192 */
193async function symbolAnswer($: Engine, search: SymbolSearch, out: string): Promise<string | undefined> {
194 const hits = codeHits(out)
195 const uses = hits.filter(h => usesAsCode(h.text, search.name))
196 if (uses.length === 0) return undefined
197 const cwd = await $.session.cwd()
198 const def = await definitionOf($, cwd, search.name, hits)
199 if (!def) return undefined
200 let callable = /\b(def|function|fn|func|fun)\s/.test(def.text) || /\b(async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*[:{]/.test(def.text)
201 const grepFiles = [...new Set(uses.map(h => h.path))]
202 const ask = () => lsp($, { operation: callable ? 'incomingCalls' : 'findReferences', filePath: join(cwd, def.path), line: def.line, character: def.character })
203 let text = await ask()
204 // A server without call hierarchy (ruby-lsp, bash-language-server) still has the references.
205 if (callable && (text === undefined || NO_CALLS.test(text))) {
206 callable = false
207 text = await ask()
208 }
209 const filesOf = (t: string) => (callable ? parseIncoming(t).map(c => c.file) : parseReferences(t).map(r => r.file))
210 for (let tries = 0; text !== undefined && looksCold(filesOf(text), grepFiles, def.path) && tries < 2; tries++) {
211 await $.process.run(['sleep', '1.5'])
212 text = await ask()
213 }
214 if (text === undefined || looksCold(filesOf(text), grepFiles, def.path)) return undefined
215 if (!callable) {
216 const references = parseReferences(text)
217 return references.length === 0 ? undefined : answerNote(search.name, { references })
218 }
219 const callers = parseIncoming(text)
220 if (callers.length === 0) return undefined
221 const outlines = new Map<string, ReturnType<typeof parseOutline>>()
222 await Promise.all(
223 [...new Set(callers.map(c => c.file))].map(async f => {
224 const o = await lsp($, { operation: 'documentSymbol', filePath: join(cwd, f), line: 1, character: 1 })
225 outlines.set(f, o ? parseOutline(o) : [])
226 }),
227 )
228 return answerNote(search.name, { callers: callers.map(c => ({ ...c, qualified: qualified(outlines.get(c.file) ?? [], c.name, c.line) })) })
229}
230
231/** Functions an edit changed the parameters of, asked about at most; rows of callers shown at most. */
232const MAX_CHANGED = 2
233const MAX_BLAST_ROWS = 12
234
235/**
236 * For each function whose parameters an edit changed: who calls it, from the language server
237 * (incomingCalls, or the references where the server has no call hierarchy), as the edit
238 * leaves the file. Nothing where nobody calls it.
239 */
240async function blastNotes($: Engine, filePath: string, before: string, after: string): Promise<string[]> {
241 const changed = changedSignatures(before, after).slice(0, MAX_CHANGED)
242 if (changed.length === 0) return []
243 const lines = ((await $.fs.read(filePath).catch(() => '')) ?? '').split('\n')
244 const notes: string[] = []
245 for (const name of changed) {
246 const header = headerPattern(name.replace(/\$/g, '\\$'))
247 const i = lines.findIndex(l => header.test(l))
248 if (i < 0) continue
249 const m = header.exec(lines[i] as string) as RegExpExecArray
250 const at = { filePath, line: i + 1, character: m.index + m[0].length - name.length + 1 }
251 const calls = await lsp($, { operation: 'incomingCalls', ...at })
252 const rows: string[] = []
253 let count = 0
254 const files = new Set<string>()
255 if (calls !== undefined && !NO_CALLS.test(calls) && parseIncoming(calls).length > 0) {
256 const callers = parseIncoming(calls)
257 for (const f of [...new Set(callers.map(c => c.file))]) {
258 const here = callers.filter(c => c.file === f)
259 count += here.reduce((n, c) => n + Math.max(1, c.at.length), 0)
260 files.add(f)
261 rows.push(`${f}: ${here.map(c => `${c.name} (${c.at.join(', ') || c.line})`).join(', ')}`)
262 }
263 } else {
264 const refs = parseReferences((await lsp($, { operation: 'findReferences', ...at })) ?? '').filter(r => !(filePath.endsWith(r.file) && r.line === at.line))
265 for (const f of [...new Set(refs.map(r => r.file))]) {
266 const here = refs.filter(r => r.file === f)
267 count += here.length
268 files.add(f)
269 rows.push(`${f}: ${here.map(r => r.line).join(', ')}`)
270 }
271 }
272 if (rows.length > 0) notes.push(blastNote(name, rows.slice(0, MAX_BLAST_ROWS), count, files.size))
273 }
274 return notes
275}
276
277/** Declarations asked about at most: an address declared in more modules than this is a grep's job. */
278const MAX_TF_DECLARATIONS = 5
279
280/**
281 * terraform-ls's references for a Terraform address a grep searched for, asked at each declaration
282 * in a directory where the grep found it (a module sees only its own blocks) and merged. The server
283 * leaves the declaration out; where it finds nothing in a directory the grep found uses in, it is
284 * still indexing: asked again for up to three seconds, and without a whole answer there is none.
285 */
286async function tfAnswer($: Engine, tf: TfSearch, out: string): Promise<string | undefined> {
287 const hits = [...out.matchAll(/^([^\s:]+):(\d+):(.*)$/gm)].map(m => ({ path: m[1] as string, line: Number(m[2]) })).filter(h => TF_FILES.test(h.path))
288 if (hits.length === 0) return undefined
289 const dirs = new Set(hits.map(h => dirOf(h.path)))
290 const cwd = await $.session.cwd()
291 const listed = await git($, cwd, ['grep', '--untracked', '-n', '-E', '-e', tf.declares, '--', '*.tf'])
292 if (!listed) return undefined
293 const declarations: Array<{ path: string; line: number; character: number }> = []
294 for (const m of listed.matchAll(/^([^:\n]+):(\d+):(.*)$/gm)) {
295 const [path, line, text] = [m[1] as string, Number(m[2]), m[3] as string]
296 if (!dirs.has(dirOf(path))) continue
297 if (tf.address.startsWith('local.') && !inLocals(((await $.fs.read(join(cwd, path)).catch(() => '')) ?? '').split('\n'), line)) continue
298 const character = labelColumn(text, tf.label)
299 if (character !== undefined) declarations.push({ path, line, character })
300 }
301 if (declarations.length === 0 || declarations.length > MAX_TF_DECLARATIONS) return undefined
302 const references: ReturnType<typeof parseReferences> = []
303 for (const d of declarations) {
304 const ask = () => lsp($, { operation: 'findReferences', filePath: join(cwd, d.path), line: d.line, character: d.character })
305 const usesHere = hits.some(h => dirOf(h.path) === dirOf(d.path) && !(h.path === d.path && h.line === d.line))
306 let text = await ask()
307 for (let tries = 0; text !== undefined && usesHere && parseReferences(text).length === 0 && tries < 2; tries++) {
308 await $.process.run(['sleep', '1.5'])
309 text = await ask()
310 }
311 if (text === undefined || (usesHere && parseReferences(text).length === 0)) return undefined
312 references.push(...parseReferences(text))
313 }
314 return references.length === 0 ? undefined : answerNote(tf.address, { references })
315}
316
317/**
318 * Starts the language servers the repository will need, before anyone asks: one question about
319 * one tracked file of each kind a server reads. A server opens the project on its first
320 * question and answers whole from the second on. The file asked about is the smallest of its
321 * kind: the server reports that file's problems and Claude Code puts them into the conversation
322 * (measured: click's docs/conf.py and its missing theme import, in every run, +4.5% on small
323 * tasks), and a near-empty file has nothing to report.
324 */
325async function warmUp($: Engine) {
326 const cwd = await $.session.cwd()
327 // Each tracked file under cwd with its size: "<mode> blob <hash> <size>\t<path>", NUL-separated.
328 const listed = await git($, cwd, ['ls-tree', '-r', '-l', '-z', 'HEAD'])
329 if (!listed) return
330 const found = new Map<string, { file: string; size: number; count: number; name: string }>()
331 for (const entry of listed.split('\0')) {
332 const m = /^\d+ blob \w+\s+(\d+)\t(.+)$/.exec(entry)
333 if (!m) continue
334 const f = m[2] as string
335 const size = Number(m[1])
336 const ext = /\.([^./]+)$/.exec(f)?.[1]
337 const language = ext ? languageOf(ext) : undefined
338 if (!language || !LSP_FILES.test(f) || /(^|\/)(node_modules|vendor|dist|build)\//.test(f)) continue
339 const had = found.get(language.kind)
340 const count = (had?.count ?? 0) + 1
341 found.set(language.kind, !had || size < had.size ? { file: f, size, count, name: language.name } : { ...had, count })
342 }
343 const answers = await Promise.all(
344 [...found.values()].map(async l => ({ l, text: await lspText($, { operation: 'documentSymbol', filePath: join(cwd, l.file), line: 1, character: 1 }) })),
345 )
346 missing = answers.filter(a => a.l.count >= MIN_FILES && a.text !== undefined && NO_SERVER.test(a.text)).map(a => `${a.l.name} (${a.l.count} files)`)
347 if (missing.length > 0) $.ui.log(missingLine(missing))
348}
349
350/** Which server a file's extension needs, and the language's name as the person knows it. */
351function languageOf(ext: string): { kind: string; name: string } | undefined {
352 return LANGUAGES.find(l => l.exts.includes(ext))
353}
354
355/** A language with fewer files than this (a build script, a fixture) is not worth a server, nor a word. */
356const MIN_FILES = 5
357/** What the LSP tool answers where no plugin names a server for the file, or the server is not on the PATH. */
358const NO_SERVER = /^No LSP server|^Error performing.*(ENOENT|spawn|not found|failed to start)/is
359
360/** One LSP call's text as the tool gave it, failures included; undefined where the call was refused or threw. */
361async function lspText($: Engine, input: Record<string, unknown>): Promise<string | undefined> {
362 const r = await $.tool.call({ tool: 'LSP', ...input } as never).catch(() => undefined)
363 if (r === undefined || r.deny !== undefined) return undefined
364 return r.text ?? ''
365}
366
367/** The line the person sees where a language of theirs has no server. */
368export function missingLine(languages: readonly string[]): string {
369 return `lsp-plus: no language server answers for ${languages.join(', ')} in this repository, so symbol searches fall back to grep, and lsp-plus, cells and delegate answer less well. Run /lsp-plus:setup to set one up.`
370}
371
372/** The system-prompt section where a language has no server: Claude offers the setup once, the person decides. */
373export function missingNote(languages: readonly string[]): string {
374 return `# Language servers missing
375
376lsp-plus found no working language server for ${languages.join(', ')} in this repository: symbol searches fall back to grep, without the server's exact references, definitions and callers. Where those would have helped the person, tell them once and offer /lsp-plus:setup, which installs the servers with them. Don't bring it up again after that.`
377}
378
379export const register: Register = (on, options) => {
380 if (options.enabled === false) return
381 docs = options.docs !== false
382 drift = options.drift !== false
383 nudging = options.nudge === true // off unless asked for: never acted on in 45 benchmark notes
384 answering = options.answer !== false // measured: 25% fewer requests on a rename, even on a trace
385 blasting = options.blast === true // off until measured
386 warming = options.warm !== false
387 missing = []
388
389 on('session.start', async ($, e, next) => {
390 await $.command.register({ name: 'refs', description: 'Check the docs for code names and links that no longer resolve' })
391 if (warming) void warmUp($).catch(() => undefined)
392 return next(e)
393 })
394
395 // Where the warm-up found a language without a server, Claude is told too, so it can offer the setup. The system
396 // prompt is fixed at the session's first request: a warm-up still running then says nothing to Claude this session.
397 on('prompt.compose', async ($, e, next) => {
398 const composed = await next(e)
399 if (missing.length === 0) return composed
400 return { sections: [...composed.sections, { id: 'lsp-plus-missing', text: missingNote(missing), scope: 'session' as const }] }
401 }).catch(($, e, next) => next(e))
402
403 on('command.run', { command: 'refs' }, async $ => ({ text: await checkDocs($, await $.session.cwd()) }))
404
405 // References and definitions name places in the code; the docs that use the name join them.
406 on('tool.call', { tool: 'LSP' }, async ($, e, next) => {
407 const r = await next(e)
408 const input = e as unknown as LspInput
409 if (!docs || r.deny !== undefined || r.isError || !NAMED_OPS.has(input.operation ?? '')) return r
410 // The LSP tool reports a server that failed or is missing as a plain result.
411 if (/^(Error performing|No LSP server)/.test(r.text ?? '')) return r
412 if (typeof input.filePath !== 'string' || typeof input.line !== 'number' || typeof input.character !== 'number') return r
413 const cwd = await $.session.cwd()
414 const source = await $.fs.read(join(cwd, input.filePath)).catch(() => undefined)
415 const name = source === undefined ? undefined : identifierAt(source.split('\n')[input.line - 1] ?? '', input.character)
416 if (name === undefined || name.length < 3) return r
417 const found = await docMentions($, cwd, name)
418 if (found.length === 0) return r
419 return { ...r, context: [...(r.context ?? []), referencesNote(name, found)] }
420 }).catch(($, e, next) => next(e))
421
422 // A grep for a code symbol runs as asked; the note says which LSP call answers it exactly.
423 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
424 const r = await next(e)
425 if (r.deny !== undefined) return r
426 const search = answering ? symbolSearch(e.command) : undefined
427 let answer = search === undefined ? undefined : await symbolAnswer($, search, r.text ?? '').catch(() => undefined)
428 // A Terraform address (var.region, aws_s3_bucket.logs) is no identifier; the module's declarations answer it.
429 const tf = answering && answer === undefined ? searchPatterns(e.command).map(tfAddress).find(t => t !== undefined) : undefined
430 if (tf !== undefined) answer = await tfAnswer($, tf, r.text ?? '').catch(() => undefined)
431 const text = answer ?? (await symbolNote($, e.command, r))
432 return text === undefined ? r : { ...r, context: [...(r.context ?? []), text] }
433 }).catch(($, e, next) => next(e))
434
435 // While code is being written: a name the edit took away that the docs still use, and (blast)
436 // who calls a function whose parameters it changed.
437 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
438 const r = await next(e)
439 const input = e as unknown as { file_path?: string; old_string?: string; new_string?: string }
440 if ((!drift && !blasting) || r.deny !== undefined || r.isError || typeof input.file_path !== 'string' || isDoc(input.file_path)) return r
441 const notes = drift ? await driftNotes($, await $.session.cwd(), input.old_string ?? '', input.new_string ?? '') : []
442 if (blasting) notes.push(...(await blastNotes($, input.file_path, input.old_string ?? '', input.new_string ?? '').catch(() => [])))
443 return notes.length === 0 ? r : { ...r, context: [...(r.context ?? []), ...notes] }
444 }).catch(($, e, next) => next(e))
445
446 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
447 const input = e as unknown as { file_path?: string; content?: string }
448 const path = typeof input.file_path === 'string' && !isDoc(input.file_path) ? input.file_path : undefined
449 const before = drift && path !== undefined ? await $.fs.read(path).catch(() => undefined) : undefined
450 const r = await next(e)
451 if (before === undefined || r.deny !== undefined || r.isError) return r
452 const notes = await driftNotes($, await $.session.cwd(), before, input.content ?? '')
453 return notes.length === 0 ? r : { ...r, context: [...(r.context ?? []), ...notes] }
454 }).catch(($, e, next) => next(e))
455}
456hooks/docs.ts 136 lines1// Pure helpers: which name an LSP call is about, which names an edit removed,
2// and which lines of the docs mention a name as code.
3
4/** Files read as documentation. */
5export const DOC_GLOBS = ['*.md', '*.mdx', '*.markdown', '*.rst', '*.adoc'] as const
6
7export const isDoc = (path: string) => /\.(md|mdx|markdown|rst|adoc)$/i.test(path)
8
9const WORD = /[A-Za-z0-9_$]/
10
11/** The identifier at a 1-based character of a line, or the next one to its right. */
12export function identifierAt(line: string, character: number): string | undefined {
13 let i = Math.max(0, Math.min(line.length - 1, character - 1))
14 if (!WORD.test(line[i] ?? '')) {
15 const rest = line.slice(i).search(/[A-Za-z_$]/)
16 if (rest < 0) return undefined
17 i += rest
18 }
19 let start = i
20 while (start > 0 && WORD.test(line[start - 1] ?? '')) start--
21 let end = i
22 while (end < line.length && WORD.test(line[end] ?? '')) end++
23 const name = line.slice(start, end)
24 return /^[A-Za-z_$]/.test(name) ? name : undefined
25}
26
27const DEFINITION =
28 /\b(?:function\*?|class|interface|type|enum|struct|trait|def|fn|func|const|let|var|val|module|namespace|record|macro_rules!)\s+([A-Za-z_$][\w$]*)|\bfunc\s+\([^)]*\)\s*([A-Za-z_]\w*)/g
29
30/** The names a piece of source defines, by the keywords most languages define with. */
31export function definedNames(source: string): Set<string> {
32 const names = new Set<string>()
33 for (const m of source.matchAll(DEFINITION)) {
34 const name = m[1] ?? m[2]
35 if (name !== undefined && name.length >= 3) names.add(name)
36 }
37 return names
38}
39
40export function identifiers(source: string): Set<string> {
41 return new Set(source.match(/[A-Za-z_$][\w$]*/g) ?? [])
42}
43
44/**
45 * What an edit took out of the code: names the old text defined that the new
46 * text no longer mentions at all, and the names it newly defines (a rename
47 * shows as one of each).
48 */
49export function editedNames(before: string, after: string): { removed: string[]; added: string[] } {
50 const inAfter = identifiers(after)
51 const inBefore = identifiers(before)
52 const removed = [...definedNames(before)].filter(n => !inAfter.has(n))
53 const added = [...definedNames(after)].filter(n => !inBefore.has(n))
54 return { removed, added }
55}
56
57export type Mention = { path: string; line: number; text: string }
58
59/** `git grep -n` output (`path:line:text`) as mentions. */
60export function parseGrep(out: string): Mention[] {
61 const found: Mention[] = []
62 for (const row of out.split('\n')) {
63 const m = /^(.+?):(\d+):(.*)$/.exec(row)
64 if (m) found.push({ path: m[1] as string, line: Number(m[2]), text: m[3] as string })
65 }
66 return found
67}
68
69const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
70
71/** Lines of a doc that sit inside fenced code blocks, 1-based. */
72export function fencedLines(doc: string): Set<number> {
73 const inside = new Set<number>()
74 let fence: string | undefined
75 doc.split('\n').forEach((row, i) => {
76 const m = /^\s*(```|~~~)/.exec(row)
77 if (m) {
78 if (fence === undefined) fence = m[1]
79 else if (m[1] === fence) fence = undefined
80 return
81 }
82 if (fence !== undefined) inside.add(i + 1)
83 })
84 return inside
85}
86
87/**
88 * Whether a doc line mentions `name` as code rather than as an ordinary word:
89 * inside backticks, called, reached through a dot, in a fenced block, or a name
90 * no prose would use (camelCase, snake_case, a digit, a `$`).
91 */
92export function mentionsAsCode(text: string, name: string, fenced: boolean): boolean {
93 const n = escape(name)
94 if (!new RegExp(`(^|[^\\w$])${n}($|[^\\w$])`).test(text)) return false
95 if (fenced) return true
96 if (/[a-z][A-Z]|_|\d|\$/.test(name) || /^[A-Z][a-z]+[A-Z]/.test(name)) return true
97 if (new RegExp('`[^`]*(^|[^\\w$])' + n + '($|[^\\w$])[^`]*`').test(text)) return true
98 return new RegExp(`(\\.${n}\\b|\\b${n}\\s*\\()`).test(text)
99}
100
101/** "README.md:42, docs/api.md:7, … and 3 more" */
102export function listPlaces(found: readonly Mention[], max = 8): string {
103 const shown = found.slice(0, max).map(m => `${m.path}:${m.line}`)
104 const more = found.length - shown.length
105 return shown.join(', ') + (more > 0 ? `, and ${more} more` : '')
106}
107
108export function referencesNote(name: string, found: readonly Mention[]): string {
109 const lines = found.slice(0, 8).map(m => ` ${m.path}:${m.line} ${m.text.trim().slice(0, 140)}`)
110 const more = found.length > 8 ? `\n … and ${found.length - 8} more` : ''
111 return `The docs mention \`${name}\` in ${found.length} place${found.length === 1 ? '' : 's'} the language server does not index:\n${lines.join('\n')}${more}`
112}
113
114export function driftNote(name: string, found: readonly Mention[], renamedTo?: string): string {
115 const rename = renamedTo === undefined ? '' : ` (renamed to \`${renamedTo}\`?)`
116 return `The docs still mention \`${name}\`${rename}, which no file in the code defines or uses any more: ${listPlaces(found)}. If they describe this code, update them in this change.`
117}
118
119/** Code spans in a doc that name a symbol: `name`, `name()`, `a.b.name`. */
120export function codeSpanNames(doc: string): { name: string; line: number }[] {
121 const out: { name: string; line: number }[] = []
122 doc.split('\n').forEach((row, i) => {
123 for (const m of row.matchAll(/`([^`\s]+)`/g)) {
124 const span = m[1] as string
125 const call = /^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(\(\))?$/.exec(span)
126 if (!call) continue
127 const name = (call[1] as string).split('.').at(-1) as string
128 const codeish = call[2] !== undefined || (call[1] as string).includes('.') || /[a-z][A-Z]|_|\d/.test(name)
129 if (codeish && name.length >= 3 && !STOP.has(name)) out.push({ name, line: i + 1 })
130 }
131 })
132 return out
133}
134
135const STOP = new Set(['true', 'false', 'null', 'undefined', 'None', 'True', 'False', 'self', 'this', 'README', 'TODO'])
136hooks/nudge.ts 181 lines1// A text search for a code symbol (grep, rg, ag, git grep, sed or awk), in code a
2// language server covers: the search runs as asked, and Claude is told which LSP
3// call answers it exactly. A nudge, never a block.
4
5/**
6 * The languages a language server reads, by file extension: the server the warm-up starts
7 * once per `kind`, and the language's name as the person knows it. The extensions are those
8 * the servers' plugins claim (the official ones, and lsp-plus's own).
9 */
10export const LANGUAGES: ReadonlyArray<{ exts: readonly string[]; kind: string; name: string }> = [
11 { exts: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts'], kind: 'ts', name: 'TypeScript/JavaScript' },
12 { exts: ['py', 'pyi'], kind: 'py', name: 'Python' },
13 { exts: ['c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'hxx'], kind: 'c', name: 'C/C++' },
14 { exts: ['rs'], kind: 'rs', name: 'Rust' },
15 { exts: ['go'], kind: 'go', name: 'Go' },
16 { exts: ['swift'], kind: 'swift', name: 'Swift' },
17 { exts: ['java'], kind: 'java', name: 'Java' },
18 { exts: ['kt', 'kts'], kind: 'kt', name: 'Kotlin' },
19 { exts: ['cs'], kind: 'cs', name: 'C#' },
20 { exts: ['php'], kind: 'php', name: 'PHP' },
21 { exts: ['rb', 'rake', 'gemspec', 'ru'], kind: 'rb', name: 'Ruby' },
22 { exts: ['lua'], kind: 'lua', name: 'Lua' },
23 { exts: ['sh', 'bash'], kind: 'sh', name: 'shell' },
24 { exts: ['tf', 'tfvars'], kind: 'tf', name: 'Terraform' },
25]
26
27/** Source files a language server reads. */
28export const LSP_FILES = new RegExp(`\\.(${LANGUAGES.flatMap(l => l.exts).join('|')})$`)
29
30const SEARCHERS = /^(?:grep|egrep|fgrep|rg|ag|ack)$/
31const DEF_WORDS = '(?:def|function|class|fn|func|fun|const|let|var|val|interface|type|struct|enum|trait|protocol|object|module)'
32// What may stand between the keyword and the name: Go's receiver `func (s *Server) Name`, Kotlin's
33// type parameters and receiver type `fun <T> List<T>.name`, Ruby's `def self.name`.
34const RECEIVER = '(?:\\([^)]*\\)\\s*|<[^>]*>\\s*)?(?:[\\w$]+(?:<[^>]*>)?\\??\\.)?'
35
36export type SymbolSearch = { name: string; kind: 'uses' | 'definition' }
37
38/** Splits a shell command line into words, honouring quotes; enough for a grep's arguments. */
39function words(segment: string): string[] {
40 const out: string[] = []
41 for (const m of segment.matchAll(/'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/g)) out.push(m[1] ?? m[2]?.replace(/\\(.)/g, '$1') ?? (m[3] as string))
42 return out
43}
44
45/** The pattern a sed or awk program searches for: `sed -n '/name/p'`, `awk '/name/ {...}'`. */
46function scriptPattern(tool: string, args: string[]): string | undefined {
47 for (const a of args) {
48 if (a.startsWith('-')) continue
49 const m = tool === 'sed' ? /^\/(.+?)\/(?:p|=)$/.exec(a) : /^\/(.+?)\/(?:\s*\{.*\})?$/s.exec(a)
50 return m?.[1]
51 }
52 return undefined
53}
54
55/** The pattern of one grep-like command, past its options and the values they take. */
56function grepPattern(args: string[]): string | undefined {
57 for (let i = 0; i < args.length; i++) {
58 const a = args[i] as string
59 if (a === '-e' || a === '--regexp') return args[i + 1]
60 if (/^--?[A-Za-z]/.test(a)) {
61 if (/^(-[ABCmfgtT]|--(include|exclude|exclude-dir|glob|type|type-add|max-count|context|after-context|before-context))$/.test(a)) i++
62 continue
63 }
64 return a
65 }
66 return undefined
67}
68
69/**
70 * The pattern of each command of the line that searches files: after `cd …&&`,
71 * behind `xargs`, in a pipeline; grep, egrep, rg, ag, ack, git grep, and sed or
72 * awk printing what matches.
73 */
74export function searchPatterns(command: string): string[] {
75 const patterns: string[] = []
76 // Each command of the line; in a pipeline only its head or an xargs stage reads files (a grep fed by a pipe filters text).
77 const stages = command
78 .split(/\s*(?:&&|\|\||;)\s*/)
79 .flatMap(cmd => cmd.split(/\s*\|\s*/).filter((stage, i) => i === 0 || /^xargs\b/.test(stage.trim())))
80 for (const raw of stages) {
81 let args = words(raw.trim())
82 if (args[0] === 'xargs') {
83 // xargs' own options (and the values of -n, -I, -P, -L, -s, -d) come before the command it runs.
84 let i = 1
85 while (i < args.length && (args[i] as string).startsWith('-')) i += /^-[nIPLsd]$/.test(args[i] as string) ? 2 : 1
86 args = args.slice(i)
87 }
88 const tool = args[0] ?? ''
89 let pattern: string | undefined
90 if (tool === 'git' && args[1] === 'grep') pattern = grepPattern(args.slice(2))
91 else if (SEARCHERS.test(tool)) pattern = grepPattern(args.slice(1))
92 else if ((tool === 'sed' && args.includes('-n')) || tool === 'awk') pattern = scriptPattern(tool, args.slice(1))
93 if (pattern !== undefined) patterns.push(pattern)
94 }
95 return patterns
96}
97
98/**
99 * Whether a shell command searches for one code symbol. Its pattern must name
100 * one identifier (bare, `-w`, `\bname\b`, `name(`, `.name`) or its definition
101 * (`def name`, `function name`); alternations and free text are not symbols.
102 */
103export function symbolSearch(command: string): SymbolSearch | undefined {
104 for (const pattern of searchPatterns(command)) {
105 const found = asSymbol(pattern)
106 if (found !== undefined) return found
107 }
108 return undefined
109}
110
111function asSymbol(pattern: string): SymbolSearch | undefined {
112 const p = pattern.replace(/\\b|\\<|\\>/g, '').replace(/^\^\s*/, '')
113 const def = new RegExp(`^(?:export\\s+)?${DEF_WORDS}\\s+(?:\\\\?\\([^)]*\\)\\s*)?(?:[\\w$]+\\\\?\\.)?([A-Za-z_$][\\w$]*)\\b`).exec(p)
114 if (def) return { name: def[1] as string, kind: 'definition' }
115 const use = /^\.?([A-Za-z_$][\w$]*)(?:\\?\(|\s*\\?\()?$/.exec(p)
116 if (use && (use[1] as string).length >= 3) return { name: use[1] as string, kind: 'uses' }
117 return undefined
118}
119
120export type Hit = { path: string; line: number; text: string }
121
122/** `path:line:text` rows of a grep's output (with -n), only in files a language server reads. */
123export function codeHits(output: string): Hit[] {
124 const hits: Hit[] = []
125 for (const row of output.split('\n')) {
126 const m = /^([^\s:]+):(\d+):(.*)$/.exec(row)
127 if (m && LSP_FILES.test(m[1] as string)) hits.push({ path: m[1] as string, line: Number(m[2]), text: m[3] as string })
128 }
129 return hits
130}
131
132/**
133 * Whether a source line uses `name` as code: outside its string literals and a
134 * trailing comment. A name met only in strings (a mod's name in a message, a
135 * key) is no symbol to ask the language server about.
136 */
137export function usesAsCode(text: string, name: string): boolean {
138 const code = text.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""').replace(/(^|\s)(#|\/\/).*$/, '')
139 return new RegExp(`(^|[^\\w$])${name.replace(/\$/g, '\\$')}(?![\\w$])`).test(code)
140}
141
142/** Among the hits, where `name` is defined, at the column the LSP tool asks for (1-based). */
143export function definitionAt(hits: readonly Hit[], name: string): { path: string; line: number; character: number } | undefined {
144 const re = new RegExp(`\\b${DEF_WORDS}\\s+${RECEIVER}(${name.replace(/\$/g, '\\$')})\\b`)
145 for (const h of hits) {
146 const m = re.exec(h.text)
147 if (m) return { path: h.path, line: h.line, character: m.index + m[0].length - name.length + 1 }
148 }
149 return undefined
150}
151
152/**
153 * The note: what the language server answers better, the exact call, and what
154 * it cannot see, so grep keeps that part. It adds a way; it takes none away.
155 *
156 * With the cells mod's run_cell in the session, the note also names the call
157 * there: refs() asks the server without the LSP tool loaded first (it is
158 * deferred) and gives each place with its line, and rename() renames every
159 * code reference through Edit. `file` is a file that uses the name, for when
160 * the definition is not among the hits.
161 */
162export function nudge(
163 search: SymbolSearch,
164 at: { path: string; line: number; character: number } | undefined,
165 again: boolean,
166 cells: { file?: string } | undefined = undefined,
167): string {
168 const where = at ? `LSP findReferences on ${at.path}:${at.line}:${at.character}` : `LSP workspaceSymbol with query "${search.name}"`
169 const cell = cells === undefined ? undefined : at ? `refs('${at.path}', '${search.name}', { line: ${at.line} })` : cells.file ? `refs('${cells.file}', '${search.name}')` : undefined
170 if (again) {
171 const or = cell ? ` (or ${cell} in run_cell)` : ''
172 return `\`${search.name}\`: ${where}${or} gives its exact code uses; grep stays needed for strings, docs and dynamic uses.`
173 }
174 const what = at
175 ? 'lists exactly its uses in the code, without comments, strings or namesakes, and incomingCalls there lists the functions that call it'
176 : 'finds its definition, and findReferences there lists exactly its uses in the code'
177 const note = `\`${search.name}\` is a code symbol in files a language server covers: ${where} ${what}. The server does not see strings, dynamic uses or most docs, so for a rename or a complete sweep keep the grep for those.`
178 if (cell === undefined) return note
179 return `${note} In run_cell, ${cell} asks the same in one step without loading the LSP tool, each place with its line; rename(path, name, newName) there renames every code reference through Edit and lists where the name is left as text.`
180}
181hooks/answer.ts 111 lines1// The language server's answer to a symbol grep, attached to the grep's own result: the
2// functions that call it, or its references, read from the server by the hook itself. No
3// advice and no second step for Claude: the exact code answer is already there, and grep's
4// output stays for what the server cannot see (strings, comments, docs, namesakes).
5
6/** One place the server lists: a caller (with the lines it calls from) or a reference line. */
7export type Caller = { file: string; name: string; kind: string; line: number; at: number[] }
8
9/** `incomingCalls` text: `file:` headers, then ` name (Kind) - Line N [calls at: L:C, …]`. */
10export function parseIncoming(text: string): Caller[] {
11 const out: Caller[] = []
12 let file = ''
13 for (const row of text.split('\n')) {
14 const header = /^(\S.*):$/.exec(row)
15 if (header && !row.startsWith('Found')) {
16 file = header[1] as string
17 continue
18 }
19 const m = /^\s+(\S+) \((\w+)\) - Line (\d+)(?: \[calls at: ([^\]]*)\])?/.exec(row)
20 if (m && file) {
21 const at = (m[4] ?? '').split(',').map(s => Number(s.trim().split(':')[0])).filter(n => n > 0)
22 out.push({ file, name: m[1] as string, kind: m[2] as string, line: Number(m[3]), at })
23 }
24 }
25 return out
26}
27
28/** `findReferences` text: `file:` headers, then ` Line L:C` rows (or `file:L:C` for a single one). */
29export function parseReferences(text: string): Array<{ file: string; line: number }> {
30 const out: Array<{ file: string; line: number }> = []
31 let file = ''
32 for (const row of text.split('\n')) {
33 const single = /^\s+(\S+?):(\d+):(\d+)$/.exec(row)
34 if (single) {
35 out.push({ file: single[1] as string, line: Number(single[2]) })
36 continue
37 }
38 const header = /^(\S.*):$/.exec(row)
39 if (header && !row.startsWith('Found')) {
40 file = header[1] as string
41 continue
42 }
43 const m = /^\s+Line (\d+):\d+/.exec(row)
44 if (m && file) out.push({ file, line: Number(m[1]) })
45 }
46 return out
47}
48
49/** `documentSymbol` text, as a tree by indentation: each symbol with the names of those it sits in. */
50export function parseOutline(text: string): Array<{ name: string; kind: string; line: number; path: string[] }> {
51 const out: Array<{ name: string; kind: string; line: number; path: string[] }> = []
52 const stack: Array<{ depth: number; name: string }> = []
53 for (const row of text.split('\n')) {
54 const m = /^(\s*)(\S+) \((\w+)\) - Line (\d+)/.exec(row)
55 if (!m) continue
56 const depth = (m[1] as string).length
57 while (stack.length > 0 && (stack.at(-1) as { depth: number }).depth >= depth) stack.pop()
58 out.push({ name: m[2] as string, kind: m[3] as string, line: Number(m[4]), path: stack.map(s => s.name) })
59 stack.push({ depth, name: m[2] as string })
60 }
61 return out
62}
63
64/** A caller's qualified name from its file's outline: `Class.method`, `outer.inner`; its plain name when not found. */
65export function qualified(outline: ReturnType<typeof parseOutline>, name: string, line: number): string {
66 const hit = outline.find(s => s.name === name && s.line === line)
67 return hit ? [...hit.path, name].join('.') : name
68}
69
70const MAX_CHARS = 1600
71
72/**
73 * The note: the server's callers (qualified, with their call lines) or references, by file,
74 * compact and capped. It says what it covers, so grep's output keeps its part.
75 */
76export function answerNote(
77 name: string,
78 found: { callers: Array<Caller & { qualified: string }> } | { references: Array<{ file: string; line: number }> },
79): string {
80 const rows: string[] = []
81 let head: string
82 if ('callers' in found) {
83 const files = [...new Set(found.callers.map(c => c.file))]
84 head = `\`${name}\` per the language server: called from ${found.callers.length} function${found.callers.length === 1 ? '' : 's'} in ${files.length} file${files.length === 1 ? '' : 's'}`
85 for (const f of files) {
86 const here = found.callers.filter(c => c.file === f).map(c => `${c.qualified} (${c.at.join(', ')})`)
87 rows.push(`${f}: ${here.join(', ')}`)
88 }
89 } else {
90 const files = [...new Set(found.references.map(r => r.file))]
91 head = `\`${name}\` per the language server: ${found.references.length} reference${found.references.length === 1 ? '' : 's'} in code, in ${files.length} file${files.length === 1 ? '' : 's'}`
92 for (const f of files) rows.push(`${f}: ${found.references.filter(r => r.file === f).map(r => r.line).join(', ')}`)
93 }
94 const tail = '(code only: strings, comments, docs and namesakes are in the grep above)'
95 let body = ''
96 let shown = 0
97 for (const row of rows) {
98 if (body.length + row.length > MAX_CHARS) break
99 body += `\n${row}`
100 shown++
101 }
102 const more = shown < rows.length ? `\n… and ${rows.length - shown} more files` : ''
103 return `${head} ${tail}:${body}${more}`
104}
105
106/** Whether an answer looks like a server still indexing: it knows only the file it was asked about. */
107export function looksCold(files: readonly string[], grepFiles: readonly string[], asked: string): boolean {
108 const others = grepFiles.filter(f => f !== asked)
109 return others.length > 0 && files.every(f => f === asked)
110}
111hooks/terraform.ts 75 lines1// Terraform names what it declares by address: var.region, local.tags, module.vpc,
2// aws_s3_bucket.logs, data.aws_iam_policy_document.read. A grep for an address, or for the
3// block that declares it, gets terraform-ls's references. A block is seen only within its
4// module, the directory it is in, so the server is asked at each declaration in a directory
5// where the grep found the address.
6
7export type TfSearch = {
8 /** The address as the configuration writes it. */
9 address: string
10 /** The declaring line, as an extended regular expression for git grep. */
11 declares: string
12 /** The name in the declaring line the server is asked about. */
13 label: string
14}
15
16const ID = '[A-Za-z_][A-Za-z0-9_-]*'
17
18/** The address a grep's pattern names, from a use (`var.region`) or its declaration (`variable "region"`). */
19export function tfAddress(pattern: string): TfSearch | undefined {
20 const p = pattern
21 .replace(/\\b|\\<|\\>/g, '')
22 .replace(/\\([."])/g, '$1')
23 .replace(/^\^\s*/, '')
24 .trim()
25 const block = (kind: string, ...labels: string[]) => `^[[:space:]]*${kind}[[:space:]]+${labels.map(l => `"${l}"`).join('[[:space:]]+')}`
26 let m: RegExpExecArray | null
27 if ((m = new RegExp(`^var\\.(${ID})$`).exec(p)) || (m = new RegExp(`^variable\\s+"(${ID})"`).exec(p))) {
28 const name = m[1] as string
29 return { address: `var.${name}`, declares: block('variable', name), label: name }
30 }
31 if ((m = new RegExp(`^module\\.(${ID})$`).exec(p)) || (m = new RegExp(`^module\\s+"(${ID})"`).exec(p))) {
32 const name = m[1] as string
33 return { address: `module.${name}`, declares: block('module', name), label: name }
34 }
35 if ((m = new RegExp(`^local\\.(${ID})$`).exec(p))) {
36 const name = m[1] as string
37 return { address: `local.${name}`, declares: `^[[:space:]]*${name}[[:space:]]*=`, label: name }
38 }
39 if ((m = new RegExp(`^data\\.(${ID})\\.(${ID})$`).exec(p)) || (m = new RegExp(`^data\\s+"(${ID})"\\s+"(${ID})"`).exec(p))) {
40 const [type, name] = [m[1] as string, m[2] as string]
41 return { address: `data.${type}.${name}`, declares: block('data', type, name), label: name }
42 }
43 // A resource type is the provider's name, an underscore and the kind: aws_s3_bucket.
44 if ((m = new RegExp(`^([a-z][a-z0-9]*_[A-Za-z0-9_]+)\\.(${ID})$`).exec(p)) || (m = new RegExp(`^resource\\s+"(${ID})"\\s+"(${ID})"`).exec(p))) {
45 const [type, name] = [m[1] as string, m[2] as string]
46 return { address: `${type}.${name}`, declares: block('resource', type, name), label: name }
47 }
48 return undefined
49}
50
51export const TF_FILES = /\.tf$/
52
53/** The directory a repository path is in ('' at the root). */
54export function dirOf(path: string): string {
55 const i = path.lastIndexOf('/')
56 return i < 0 ? '' : path.slice(0, i)
57}
58
59/** Whether a line of a .tf file sits in a `locals` block: the block it is in starts at the last unindented line above. */
60export function inLocals(lines: readonly string[], line: number): boolean {
61 for (let i = line - 2; i >= 0; i--) {
62 const row = lines[i] as string
63 if (/^\S/.test(row)) return /^locals\s*\{/.test(row)
64 }
65 return false
66}
67
68/** The 1-based column of the declaration's label in its line: the quoted name, or a local's name. */
69export function labelColumn(text: string, label: string): number | undefined {
70 const quoted = text.lastIndexOf(`"${label}"`)
71 if (quoted >= 0) return quoted + 2
72 const bare = new RegExp(`^\\s*${label}\\s*=`).exec(text)
73 return bare ? text.indexOf(label) + 1 : undefined
74}
75hooks/blast.ts 45 lines1// An edit that changes a function's parameters: the callers the language server knows, which
2// may still pass the old ones. Code-graph tools print a "blast radius" after every edit from a
3// stored graph; this one asks the live server, and only when a signature changed.
4
5// What may stand between the keyword and the name: Go's receiver `func (s *Server) Name`, Kotlin's
6// type parameters and receiver type `fun <T> List<T>.name`.
7const BEFORE_NAME = '(?:\\([^)]*\\)\\s*|<[^>]*>\\s*)?(?:[\\w$]+(?:<[^>]*>)?\\??\\.)?'
8
9/** A definition's header up to its name: the keyword, what may stand before the name, the name. */
10export function headerPattern(name = '[A-Za-z_$][\\w$]*'): RegExp {
11 return new RegExp(`\\b(?:def|function|fn|func|fun)\\s+${BEFORE_NAME}(${name})\\b`)
12}
13
14/** A definition's header: up to the name, an optional type-parameter list, then `(`. */
15const HEADER = new RegExp(`${headerPattern().source}\\s*(?:<[^>]*>)?\\s*\\(`, 'g')
16
17/** Each function a piece of source defines, with its parameter list as written (balanced parentheses, whitespace dropped). */
18export function signatures(source: string): Map<string, string> {
19 const out = new Map<string, string>()
20 for (const m of source.matchAll(HEADER)) {
21 let depth = 1
22 let i = (m.index as number) + m[0].length
23 const start = i
24 for (; i < source.length && depth > 0; i++) {
25 if (source[i] === '(') depth++
26 else if (source[i] === ')') depth--
27 }
28 // A header cut off by the edit's boundary is no signature to compare.
29 if (depth === 0) out.set(m[1] as string, source.slice(start, i - 1).replace(/#.*$/gm, '').replace(/\s+/g, '').replace(/,$/, ''))
30 }
31 return out
32}
33
34/** The functions an edit kept but whose parameter list it changed. */
35export function changedSignatures(before: string, after: string): string[] {
36 const was = signatures(before)
37 const now = signatures(after)
38 return [...now].filter(([name, params]) => was.has(name) && was.get(name) !== params).map(([name]) => name)
39}
40
41/** The note: who calls it, from the server, and why that matters now. */
42export function blastNote(name: string, rows: readonly string[], count: number, files: number): string {
43 return `\`${name}\`'s parameters changed; per the language server it is called from ${count} place${count === 1 ? '' : 's'} in ${files} file${files === 1 ? '' : 's'}, which may still pass the old ones:\n${rows.join('\n')}`
44}
45