@oh-my-pi/pi-coding-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. package/CHANGELOG.md +1629 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/config-usage.md +113 -0
  5. package/docs/custom-tools.md +541 -0
  6. package/docs/extension-loading.md +1004 -0
  7. package/docs/hooks.md +867 -0
  8. package/docs/rpc.md +1040 -0
  9. package/docs/sdk.md +994 -0
  10. package/docs/session-tree-plan.md +441 -0
  11. package/docs/session.md +240 -0
  12. package/docs/skills.md +290 -0
  13. package/docs/theme.md +670 -0
  14. package/docs/tree.md +197 -0
  15. package/docs/tui.md +341 -0
  16. package/examples/README.md +21 -0
  17. package/examples/custom-tools/README.md +124 -0
  18. package/examples/custom-tools/hello/index.ts +20 -0
  19. package/examples/custom-tools/question/index.ts +84 -0
  20. package/examples/custom-tools/subagent/README.md +172 -0
  21. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +89 -0
  57. package/src/bun-imports.d.ts +16 -0
  58. package/src/capability/context-file.ts +40 -0
  59. package/src/capability/extension.ts +48 -0
  60. package/src/capability/hook.ts +40 -0
  61. package/src/capability/index.ts +616 -0
  62. package/src/capability/instruction.ts +37 -0
  63. package/src/capability/mcp.ts +52 -0
  64. package/src/capability/prompt.ts +35 -0
  65. package/src/capability/rule.ts +56 -0
  66. package/src/capability/settings.ts +35 -0
  67. package/src/capability/skill.ts +49 -0
  68. package/src/capability/slash-command.ts +40 -0
  69. package/src/capability/system-prompt.ts +35 -0
  70. package/src/capability/tool.ts +38 -0
  71. package/src/capability/types.ts +166 -0
  72. package/src/cli/args.ts +259 -0
  73. package/src/cli/file-processor.ts +121 -0
  74. package/src/cli/list-models.ts +104 -0
  75. package/src/cli/plugin-cli.ts +661 -0
  76. package/src/cli/session-picker.ts +41 -0
  77. package/src/cli/update-cli.ts +274 -0
  78. package/src/cli.ts +10 -0
  79. package/src/config.ts +391 -0
  80. package/src/core/agent-session.ts +2178 -0
  81. package/src/core/auth-storage.ts +258 -0
  82. package/src/core/bash-executor.ts +197 -0
  83. package/src/core/compaction/branch-summarization.ts +315 -0
  84. package/src/core/compaction/compaction.ts +664 -0
  85. package/src/core/compaction/index.ts +7 -0
  86. package/src/core/compaction/utils.ts +153 -0
  87. package/src/core/custom-commands/bundled/review/index.ts +156 -0
  88. package/src/core/custom-commands/index.ts +15 -0
  89. package/src/core/custom-commands/loader.ts +226 -0
  90. package/src/core/custom-commands/types.ts +112 -0
  91. package/src/core/custom-tools/index.ts +22 -0
  92. package/src/core/custom-tools/loader.ts +248 -0
  93. package/src/core/custom-tools/types.ts +185 -0
  94. package/src/core/custom-tools/wrapper.ts +29 -0
  95. package/src/core/exec.ts +139 -0
  96. package/src/core/export-html/index.ts +159 -0
  97. package/src/core/export-html/template.css +774 -0
  98. package/src/core/export-html/template.generated.ts +2 -0
  99. package/src/core/export-html/template.html +45 -0
  100. package/src/core/export-html/template.js +1185 -0
  101. package/src/core/export-html/template.macro.ts +24 -0
  102. package/src/core/file-mentions.ts +54 -0
  103. package/src/core/hooks/index.ts +16 -0
  104. package/src/core/hooks/loader.ts +288 -0
  105. package/src/core/hooks/runner.ts +434 -0
  106. package/src/core/hooks/tool-wrapper.ts +98 -0
  107. package/src/core/hooks/types.ts +770 -0
  108. package/src/core/index.ts +53 -0
  109. package/src/core/logger.ts +112 -0
  110. package/src/core/mcp/client.ts +185 -0
  111. package/src/core/mcp/config.ts +248 -0
  112. package/src/core/mcp/index.ts +45 -0
  113. package/src/core/mcp/loader.ts +99 -0
  114. package/src/core/mcp/manager.ts +235 -0
  115. package/src/core/mcp/tool-bridge.ts +156 -0
  116. package/src/core/mcp/transports/http.ts +316 -0
  117. package/src/core/mcp/transports/index.ts +6 -0
  118. package/src/core/mcp/transports/stdio.ts +252 -0
  119. package/src/core/mcp/types.ts +228 -0
  120. package/src/core/messages.ts +211 -0
  121. package/src/core/model-registry.ts +334 -0
  122. package/src/core/model-resolver.ts +494 -0
  123. package/src/core/plugins/doctor.ts +67 -0
  124. package/src/core/plugins/index.ts +38 -0
  125. package/src/core/plugins/installer.ts +189 -0
  126. package/src/core/plugins/loader.ts +339 -0
  127. package/src/core/plugins/manager.ts +672 -0
  128. package/src/core/plugins/parser.ts +105 -0
  129. package/src/core/plugins/paths.ts +37 -0
  130. package/src/core/plugins/types.ts +190 -0
  131. package/src/core/sdk.ts +900 -0
  132. package/src/core/session-manager.ts +1837 -0
  133. package/src/core/settings-manager.ts +860 -0
  134. package/src/core/skills.ts +352 -0
  135. package/src/core/slash-commands.ts +132 -0
  136. package/src/core/system-prompt.ts +442 -0
  137. package/src/core/timings.ts +25 -0
  138. package/src/core/title-generator.ts +110 -0
  139. package/src/core/tools/ask.ts +193 -0
  140. package/src/core/tools/bash-interceptor.ts +120 -0
  141. package/src/core/tools/bash.ts +91 -0
  142. package/src/core/tools/context.ts +32 -0
  143. package/src/core/tools/edit-diff.ts +487 -0
  144. package/src/core/tools/edit.ts +140 -0
  145. package/src/core/tools/exa/company.ts +59 -0
  146. package/src/core/tools/exa/index.ts +63 -0
  147. package/src/core/tools/exa/linkedin.ts +59 -0
  148. package/src/core/tools/exa/mcp-client.ts +368 -0
  149. package/src/core/tools/exa/render.ts +200 -0
  150. package/src/core/tools/exa/researcher.ts +90 -0
  151. package/src/core/tools/exa/search.ts +338 -0
  152. package/src/core/tools/exa/types.ts +167 -0
  153. package/src/core/tools/exa/websets.ts +248 -0
  154. package/src/core/tools/find.ts +244 -0
  155. package/src/core/tools/grep.ts +584 -0
  156. package/src/core/tools/index.ts +283 -0
  157. package/src/core/tools/ls.ts +142 -0
  158. package/src/core/tools/lsp/client.ts +767 -0
  159. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  160. package/src/core/tools/lsp/clients/index.ts +49 -0
  161. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  162. package/src/core/tools/lsp/config.ts +845 -0
  163. package/src/core/tools/lsp/edits.ts +110 -0
  164. package/src/core/tools/lsp/index.ts +1364 -0
  165. package/src/core/tools/lsp/render.ts +560 -0
  166. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  167. package/src/core/tools/lsp/types.ts +495 -0
  168. package/src/core/tools/lsp/utils.ts +526 -0
  169. package/src/core/tools/notebook.ts +182 -0
  170. package/src/core/tools/output.ts +198 -0
  171. package/src/core/tools/path-utils.ts +61 -0
  172. package/src/core/tools/read.ts +507 -0
  173. package/src/core/tools/renderers.ts +820 -0
  174. package/src/core/tools/review.ts +275 -0
  175. package/src/core/tools/rulebook.ts +124 -0
  176. package/src/core/tools/task/agents.ts +158 -0
  177. package/src/core/tools/task/artifacts.ts +114 -0
  178. package/src/core/tools/task/commands.ts +157 -0
  179. package/src/core/tools/task/discovery.ts +217 -0
  180. package/src/core/tools/task/executor.ts +531 -0
  181. package/src/core/tools/task/index.ts +548 -0
  182. package/src/core/tools/task/model-resolver.ts +176 -0
  183. package/src/core/tools/task/parallel.ts +38 -0
  184. package/src/core/tools/task/render.ts +502 -0
  185. package/src/core/tools/task/subprocess-tool-registry.ts +89 -0
  186. package/src/core/tools/task/types.ts +142 -0
  187. package/src/core/tools/truncate.ts +265 -0
  188. package/src/core/tools/web-fetch.ts +2511 -0
  189. package/src/core/tools/web-search/auth.ts +199 -0
  190. package/src/core/tools/web-search/index.ts +583 -0
  191. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  192. package/src/core/tools/web-search/providers/exa.ts +196 -0
  193. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  194. package/src/core/tools/web-search/render.ts +372 -0
  195. package/src/core/tools/web-search/types.ts +180 -0
  196. package/src/core/tools/write.ts +63 -0
  197. package/src/core/ttsr.ts +211 -0
  198. package/src/core/utils.ts +187 -0
  199. package/src/discovery/agents-md.ts +75 -0
  200. package/src/discovery/builtin.ts +647 -0
  201. package/src/discovery/claude.ts +623 -0
  202. package/src/discovery/cline.ts +104 -0
  203. package/src/discovery/codex.ts +571 -0
  204. package/src/discovery/cursor.ts +266 -0
  205. package/src/discovery/gemini.ts +368 -0
  206. package/src/discovery/github.ts +120 -0
  207. package/src/discovery/helpers.test.ts +127 -0
  208. package/src/discovery/helpers.ts +249 -0
  209. package/src/discovery/index.ts +84 -0
  210. package/src/discovery/mcp-json.ts +127 -0
  211. package/src/discovery/vscode.ts +99 -0
  212. package/src/discovery/windsurf.ts +219 -0
  213. package/src/index.ts +192 -0
  214. package/src/main.ts +507 -0
  215. package/src/migrations.ts +156 -0
  216. package/src/modes/cleanup.ts +23 -0
  217. package/src/modes/index.ts +48 -0
  218. package/src/modes/interactive/components/armin.ts +382 -0
  219. package/src/modes/interactive/components/assistant-message.ts +86 -0
  220. package/src/modes/interactive/components/bash-execution.ts +199 -0
  221. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  222. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  223. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  224. package/src/modes/interactive/components/custom-editor.ts +122 -0
  225. package/src/modes/interactive/components/diff.ts +147 -0
  226. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  227. package/src/modes/interactive/components/extensions/extension-dashboard.ts +296 -0
  228. package/src/modes/interactive/components/extensions/extension-list.ts +479 -0
  229. package/src/modes/interactive/components/extensions/index.ts +9 -0
  230. package/src/modes/interactive/components/extensions/inspector-panel.ts +313 -0
  231. package/src/modes/interactive/components/extensions/state-manager.ts +558 -0
  232. package/src/modes/interactive/components/extensions/types.ts +191 -0
  233. package/src/modes/interactive/components/hook-editor.ts +117 -0
  234. package/src/modes/interactive/components/hook-input.ts +64 -0
  235. package/src/modes/interactive/components/hook-message.ts +96 -0
  236. package/src/modes/interactive/components/hook-selector.ts +91 -0
  237. package/src/modes/interactive/components/model-selector.ts +560 -0
  238. package/src/modes/interactive/components/oauth-selector.ts +136 -0
  239. package/src/modes/interactive/components/plugin-settings.ts +481 -0
  240. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  241. package/src/modes/interactive/components/session-selector.ts +220 -0
  242. package/src/modes/interactive/components/settings-defs.ts +597 -0
  243. package/src/modes/interactive/components/settings-selector.ts +545 -0
  244. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  245. package/src/modes/interactive/components/status-line/index.ts +4 -0
  246. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  247. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  248. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  249. package/src/modes/interactive/components/status-line/types.ts +81 -0
  250. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  251. package/src/modes/interactive/components/status-line.ts +384 -0
  252. package/src/modes/interactive/components/theme-selector.ts +62 -0
  253. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  254. package/src/modes/interactive/components/tool-execution.ts +946 -0
  255. package/src/modes/interactive/components/tree-selector.ts +877 -0
  256. package/src/modes/interactive/components/ttsr-notification.ts +82 -0
  257. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  258. package/src/modes/interactive/components/user-message.ts +18 -0
  259. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  260. package/src/modes/interactive/components/welcome.ts +228 -0
  261. package/src/modes/interactive/interactive-mode.ts +2669 -0
  262. package/src/modes/interactive/theme/dark.json +102 -0
  263. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  264. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  265. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  266. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  267. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  268. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  269. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  270. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  271. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  272. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  273. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  274. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  275. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  276. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  277. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  278. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  279. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  280. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  281. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  282. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  283. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  284. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  285. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  286. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  287. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  288. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  289. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  290. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  291. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  292. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  293. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  294. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  295. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  296. package/src/modes/interactive/theme/light.json +99 -0
  297. package/src/modes/interactive/theme/theme-schema.json +424 -0
  298. package/src/modes/interactive/theme/theme.ts +2211 -0
  299. package/src/modes/print-mode.ts +163 -0
  300. package/src/modes/rpc/rpc-client.ts +527 -0
  301. package/src/modes/rpc/rpc-mode.ts +494 -0
  302. package/src/modes/rpc/rpc-types.ts +203 -0
  303. package/src/prompts/architect-plan.md +10 -0
  304. package/src/prompts/branch-summary-preamble.md +3 -0
  305. package/src/prompts/branch-summary.md +28 -0
  306. package/src/prompts/browser.md +71 -0
  307. package/src/prompts/compaction-summary.md +34 -0
  308. package/src/prompts/compaction-turn-prefix.md +16 -0
  309. package/src/prompts/compaction-update-summary.md +41 -0
  310. package/src/prompts/explore.md +82 -0
  311. package/src/prompts/implement-with-critic.md +11 -0
  312. package/src/prompts/implement.md +11 -0
  313. package/src/prompts/init.md +30 -0
  314. package/src/prompts/plan.md +54 -0
  315. package/src/prompts/reviewer.md +81 -0
  316. package/src/prompts/summarization-system.md +3 -0
  317. package/src/prompts/system-prompt.md +27 -0
  318. package/src/prompts/task.md +56 -0
  319. package/src/prompts/title-system.md +8 -0
  320. package/src/prompts/tools/ask.md +24 -0
  321. package/src/prompts/tools/bash.md +23 -0
  322. package/src/prompts/tools/edit.md +9 -0
  323. package/src/prompts/tools/find.md +6 -0
  324. package/src/prompts/tools/grep.md +12 -0
  325. package/src/prompts/tools/lsp.md +14 -0
  326. package/src/prompts/tools/output.md +23 -0
  327. package/src/prompts/tools/read.md +25 -0
  328. package/src/prompts/tools/web-fetch.md +8 -0
  329. package/src/prompts/tools/web-search.md +10 -0
  330. package/src/prompts/tools/write.md +10 -0
  331. package/src/utils/changelog.ts +99 -0
  332. package/src/utils/clipboard.ts +265 -0
  333. package/src/utils/fuzzy.ts +108 -0
  334. package/src/utils/mime.ts +30 -0
  335. package/src/utils/shell-snapshot.ts +218 -0
  336. package/src/utils/shell.ts +364 -0
  337. package/src/utils/tools-manager.ts +265 -0
@@ -0,0 +1,560 @@
1
+ /**
2
+ * LSP Tool TUI Rendering
3
+ *
4
+ * Renders LSP tool calls and results in the TUI with:
5
+ * - Syntax-highlighted hover information
6
+ * - Color-coded diagnostics by severity
7
+ * - Grouped references and symbols
8
+ * - Collapsible/expandable views
9
+ */
10
+
11
+ import type { AgentToolResult, RenderResultOptions } from "@oh-my-pi/pi-agent-core";
12
+ import { Text } from "@oh-my-pi/pi-tui";
13
+ import { highlight, supportsLanguage } from "cli-highlight";
14
+ import type { Theme } from "../../../modes/interactive/theme/theme";
15
+ import type { LspParams, LspToolDetails } from "./types";
16
+
17
+ // =============================================================================
18
+ // Call Rendering
19
+ // =============================================================================
20
+
21
+ /**
22
+ * Render the LSP tool call in the TUI.
23
+ * Shows: "lsp <operation> <file/filecount>"
24
+ */
25
+ export function renderCall(args: unknown, theme: Theme): Text {
26
+ const p = args as LspParams & { file?: string; files?: string[] };
27
+
28
+ let text = theme.fg("toolTitle", theme.bold("LSP "));
29
+ text += theme.fg("accent", p.action || "?");
30
+
31
+ if (p.file) {
32
+ text += ` ${theme.fg("muted", p.file)}`;
33
+ } else if (p.files?.length) {
34
+ text += ` ${theme.fg("muted", `${p.files.length} file(s)`)}`;
35
+ }
36
+
37
+ return new Text(text, 0, 0);
38
+ }
39
+
40
+ // =============================================================================
41
+ // Result Rendering
42
+ // =============================================================================
43
+
44
+ /**
45
+ * Render LSP tool result with intelligent formatting based on result type.
46
+ * Detects hover, diagnostics, references, symbols, etc. and formats accordingly.
47
+ */
48
+ export function renderResult(
49
+ result: AgentToolResult<LspToolDetails>,
50
+ options: RenderResultOptions,
51
+ theme: Theme,
52
+ ): Text {
53
+ const content = result.content?.[0];
54
+ if (!content || content.type !== "text" || !("text" in content) || !content.text) {
55
+ return new Text(theme.fg("error", "No result"), 0, 0);
56
+ }
57
+
58
+ const text = content.text;
59
+ const lines = text.split("\n").filter((l) => l.trim());
60
+ const expanded = options.expanded;
61
+
62
+ // Detect result type and render accordingly
63
+ const codeBlockMatch = text.match(/```(\w*)\n([\s\S]*?)```/);
64
+ if (codeBlockMatch) {
65
+ return renderHover(codeBlockMatch, text, lines, expanded, theme);
66
+ }
67
+
68
+ const errorMatch = text.match(/(\d+)\s+error\(s\)/);
69
+ const warningMatch = text.match(/(\d+)\s+warning\(s\)/);
70
+ if (errorMatch || warningMatch || text.includes(theme.status.error)) {
71
+ return renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme);
72
+ }
73
+
74
+ const refMatch = text.match(/(\d+)\s+reference\(s\)/);
75
+ if (refMatch) {
76
+ return renderReferences(refMatch, lines, expanded, theme);
77
+ }
78
+
79
+ const symbolsMatch = text.match(/Symbols in (.+):/);
80
+ if (symbolsMatch) {
81
+ return renderSymbols(symbolsMatch, lines, expanded, theme);
82
+ }
83
+
84
+ // Default fallback rendering
85
+ return renderGeneric(text, lines, expanded, theme);
86
+ }
87
+
88
+ // =============================================================================
89
+ // Hover Rendering
90
+ // =============================================================================
91
+
92
+ /**
93
+ * Render hover information with syntax-highlighted code blocks.
94
+ */
95
+ function renderHover(
96
+ codeBlockMatch: RegExpMatchArray,
97
+ fullText: string,
98
+ _lines: string[],
99
+ expanded: boolean,
100
+ theme: Theme,
101
+ ): Text {
102
+ const lang = codeBlockMatch[1] || "";
103
+ const code = codeBlockMatch[2].trim();
104
+ const afterCode = fullText.slice(fullText.indexOf("```", 3) + 3).trim();
105
+
106
+ const codeLines = highlightCode(code, lang, theme);
107
+ const icon = theme.styledSymbol("status.info", "accent");
108
+ const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : "";
109
+
110
+ if (expanded) {
111
+ const h = theme.boxSharp.horizontal;
112
+ const v = theme.boxSharp.vertical;
113
+ const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`;
114
+ const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
115
+ let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}`;
116
+ output += `\n ${theme.fg("mdCodeBlockBorder", top)}`;
117
+ for (const line of codeLines) {
118
+ output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${line}`;
119
+ }
120
+ output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`;
121
+ if (afterCode) {
122
+ output += `\n ${theme.fg("muted", afterCode)}`;
123
+ }
124
+ return new Text(output, 0, 0);
125
+ }
126
+
127
+ // Collapsed view
128
+ const firstCodeLine = codeLines[0] || "";
129
+ const expandHint = theme.fg("dim", " (Ctrl+O to expand)");
130
+
131
+ let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}${expandHint}`;
132
+ const h = theme.boxSharp.horizontal;
133
+ const v = theme.boxSharp.vertical;
134
+ const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
135
+ output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${firstCodeLine}`;
136
+
137
+ if (codeLines.length > 1) {
138
+ output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${theme.fg(
139
+ "muted",
140
+ `${theme.format.ellipsis} ${codeLines.length - 1} more lines`,
141
+ )}`;
142
+ }
143
+
144
+ if (afterCode) {
145
+ const ellipsis = theme.format.ellipsis;
146
+ const sliceLen = Math.max(0, 60 - ellipsis.length);
147
+ const docPreview = afterCode.length > 60 ? `${afterCode.slice(0, sliceLen)}${ellipsis}` : afterCode;
148
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", docPreview)}`;
149
+ } else {
150
+ output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`;
151
+ }
152
+
153
+ return new Text(output, 0, 0);
154
+ }
155
+
156
+ /**
157
+ * Syntax highlight code using highlight.ts.
158
+ */
159
+ function highlightCode(codeText: string, language: string, theme: Theme): string[] {
160
+ const validLang = language && supportsLanguage(language) ? language : undefined;
161
+ try {
162
+ const cliTheme = {
163
+ keyword: (s: string) => theme.fg("syntaxKeyword", s),
164
+ built_in: (s: string) => theme.fg("syntaxType", s),
165
+ literal: (s: string) => theme.fg("syntaxNumber", s),
166
+ number: (s: string) => theme.fg("syntaxNumber", s),
167
+ string: (s: string) => theme.fg("syntaxString", s),
168
+ comment: (s: string) => theme.fg("syntaxComment", s),
169
+ function: (s: string) => theme.fg("syntaxFunction", s),
170
+ title: (s: string) => theme.fg("syntaxFunction", s),
171
+ class: (s: string) => theme.fg("syntaxType", s),
172
+ type: (s: string) => theme.fg("syntaxType", s),
173
+ attr: (s: string) => theme.fg("syntaxVariable", s),
174
+ variable: (s: string) => theme.fg("syntaxVariable", s),
175
+ params: (s: string) => theme.fg("syntaxVariable", s),
176
+ operator: (s: string) => theme.fg("syntaxOperator", s),
177
+ punctuation: (s: string) => theme.fg("syntaxPunctuation", s),
178
+ };
179
+ return highlight(codeText, { language: validLang, ignoreIllegals: true, theme: cliTheme }).split("\n");
180
+ } catch {
181
+ return codeText.split("\n");
182
+ }
183
+ }
184
+
185
+ // =============================================================================
186
+ // Diagnostics Rendering
187
+ // =============================================================================
188
+
189
+ /**
190
+ * Render diagnostics with color-coded severity.
191
+ */
192
+ function renderDiagnostics(
193
+ errorMatch: RegExpMatchArray | null,
194
+ warningMatch: RegExpMatchArray | null,
195
+ lines: string[],
196
+ expanded: boolean,
197
+ theme: Theme,
198
+ ): Text {
199
+ const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0;
200
+ const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0;
201
+
202
+ const icon =
203
+ errorCount > 0
204
+ ? theme.styledSymbol("status.error", "error")
205
+ : warnCount > 0
206
+ ? theme.styledSymbol("status.warning", "warning")
207
+ : theme.styledSymbol("status.success", "success");
208
+
209
+ const meta: string[] = [];
210
+ if (errorCount > 0) meta.push(`${errorCount} error${errorCount !== 1 ? "s" : ""}`);
211
+ if (warnCount > 0) meta.push(`${warnCount} warning${warnCount !== 1 ? "s" : ""}`);
212
+ if (meta.length === 0) meta.push("No issues");
213
+
214
+ const diagLines = lines.filter((l) => l.includes(theme.status.error) || /:\d+:\d+/.test(l));
215
+ const parsedDiagnostics = diagLines
216
+ .map((line) => parseDiagnosticLine(line))
217
+ .filter((diag): diag is ParsedDiagnostic => diag !== null);
218
+ const fallbackDiagnostics: RawDiagnostic[] = diagLines.map((line) => ({ raw: line.trim() }));
219
+
220
+ if (expanded) {
221
+ let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}`;
222
+ const items: DiagnosticItem[] = parsedDiagnostics.length > 0 ? parsedDiagnostics : fallbackDiagnostics;
223
+ for (let i = 0; i < items.length; i++) {
224
+ const item = items[i];
225
+ const isLast = i === items.length - 1;
226
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
227
+ const detailPrefix = isLast ? " " : `${theme.tree.vertical} `;
228
+ if ("raw" in item) {
229
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg("muted", item.raw)}`;
230
+ continue;
231
+ }
232
+ const severityColor = severityToColor(item.severity);
233
+ const location = `${item.file}:${item.line}:${item.col}`;
234
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)} ${theme.fg(
235
+ "dim",
236
+ `[${item.severity}]`,
237
+ )}`;
238
+ if (item.message) {
239
+ output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg("muted", trimTo(item.message, 120, theme))}`;
240
+ }
241
+ }
242
+ return new Text(output, 0, 0);
243
+ }
244
+
245
+ // Collapsed view
246
+ const expandHint = theme.fg("dim", " (Ctrl+O to expand)");
247
+ let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}${expandHint}`;
248
+
249
+ const previewItems: DiagnosticItem[] =
250
+ parsedDiagnostics.length > 0 ? parsedDiagnostics.slice(0, 3) : fallbackDiagnostics.slice(0, 3);
251
+ const remaining =
252
+ (parsedDiagnostics.length > 0 ? parsedDiagnostics.length : fallbackDiagnostics.length) - previewItems.length;
253
+ for (let i = 0; i < previewItems.length; i++) {
254
+ const item = previewItems[i];
255
+ const isLast = i === previewItems.length - 1 && remaining <= 0;
256
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
257
+ if ("raw" in item) {
258
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg("muted", item.raw)}`;
259
+ continue;
260
+ }
261
+ const severityColor = severityToColor(item.severity);
262
+ const location = `${item.file}:${item.line}:${item.col}`;
263
+ const message = item.message ? ` ${theme.fg("muted", trimTo(item.message, 80, theme))}` : "";
264
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`;
265
+ }
266
+ if (remaining > 0) {
267
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
268
+ "muted",
269
+ `${theme.format.ellipsis} ${remaining} more`,
270
+ )}`;
271
+ }
272
+
273
+ return new Text(output, 0, 0);
274
+ }
275
+
276
+ // =============================================================================
277
+ // References Rendering
278
+ // =============================================================================
279
+
280
+ /**
281
+ * Render references grouped by file.
282
+ */
283
+ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): Text {
284
+ const refCount = Number.parseInt(refMatch[1], 10);
285
+ const icon =
286
+ refCount > 0 ? theme.styledSymbol("status.success", "success") : theme.styledSymbol("status.warning", "warning");
287
+
288
+ const locLines = lines.filter((l) => /^\s*\S+:\d+:\d+/.test(l));
289
+
290
+ // Group by file
291
+ const byFile = new Map<string, Array<[string, string]>>();
292
+ for (const loc of locLines) {
293
+ const match = loc.trim().match(/^(.+):(\d+):(\d+)$/);
294
+ if (match) {
295
+ const [, file, line, col] = match;
296
+ if (!byFile.has(file)) byFile.set(file, []);
297
+ byFile.get(file)!.push([line, col]);
298
+ }
299
+ }
300
+
301
+ const files = Array.from(byFile.keys());
302
+
303
+ const renderGrouped = (maxFiles: number, maxLocsPerFile: number, showHint: boolean): string => {
304
+ const expandHint = showHint ? theme.fg("dim", " (Ctrl+O to expand)") : "";
305
+ let output = `${icon} ${theme.fg("toolTitle", "References")} ${theme.fg("dim", `${refCount} found`)}${expandHint}`;
306
+
307
+ const filesToShow = files.slice(0, maxFiles);
308
+ for (let fi = 0; fi < filesToShow.length; fi++) {
309
+ const file = filesToShow[fi];
310
+ const locs = byFile.get(file)!;
311
+ const isLastFile = fi === filesToShow.length - 1 && files.length <= maxFiles;
312
+ const fileBranch = isLastFile ? theme.tree.last : theme.tree.branch;
313
+ const fileCont = isLastFile ? " " : `${theme.tree.vertical} `;
314
+
315
+ const fileMeta = `${locs.length} reference${locs.length !== 1 ? "s" : ""}`;
316
+ output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("accent", file)} ${theme.fg("dim", fileMeta)}`;
317
+
318
+ if (maxLocsPerFile > 0) {
319
+ const locsToShow = locs.slice(0, maxLocsPerFile);
320
+ for (let li = 0; li < locsToShow.length; li++) {
321
+ const [line, col] = locsToShow[li];
322
+ const isLastLoc = li === locsToShow.length - 1 && locs.length <= maxLocsPerFile;
323
+ const locBranch = isLastLoc ? theme.tree.last : theme.tree.branch;
324
+ const locCont = isLastLoc ? " " : `${theme.tree.vertical} `;
325
+ output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locBranch)} ${theme.fg(
326
+ "muted",
327
+ `line ${line}, col ${col}`,
328
+ )}`;
329
+ if (expanded) {
330
+ const context = `at ${file}:${line}:${col}`;
331
+ output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locCont)}${theme.fg(
332
+ "muted",
333
+ trimTo(context, 120, theme),
334
+ )}`;
335
+ }
336
+ }
337
+ if (locs.length > maxLocsPerFile) {
338
+ output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", theme.tree.last)} ${theme.fg(
339
+ "muted",
340
+ `${theme.format.ellipsis} ${locs.length - maxLocsPerFile} more`,
341
+ )}`;
342
+ }
343
+ }
344
+ }
345
+
346
+ if (files.length > maxFiles) {
347
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
348
+ "muted",
349
+ `${theme.format.ellipsis} ${files.length - maxFiles} more files`,
350
+ )}`;
351
+ }
352
+
353
+ return output;
354
+ };
355
+
356
+ if (expanded) {
357
+ return new Text(renderGrouped(files.length, 3, false), 0, 0);
358
+ }
359
+
360
+ return new Text(renderGrouped(3, 1, true), 0, 0);
361
+ }
362
+
363
+ // =============================================================================
364
+ // Symbols Rendering
365
+ // =============================================================================
366
+
367
+ /**
368
+ * Render document symbols in a hierarchical tree.
369
+ */
370
+ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): Text {
371
+ const fileName = symbolsMatch[1];
372
+ const icon = theme.styledSymbol("status.info", "accent");
373
+
374
+ interface SymbolInfo {
375
+ name: string;
376
+ line: string;
377
+ indent: number;
378
+ icon: string;
379
+ }
380
+
381
+ const symbolLines = lines.filter((l) => l.includes("@") && l.includes("line"));
382
+ const symbols: SymbolInfo[] = [];
383
+
384
+ for (const line of symbolLines) {
385
+ const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
386
+ const symMatch = line.trim().match(/^(\S+)\s+(.+?)\s*@\s*line\s*(\d+)/);
387
+ if (symMatch) {
388
+ symbols.push({ icon: symMatch[1], name: symMatch[2], line: symMatch[3], indent });
389
+ }
390
+ }
391
+
392
+ const isLastSibling = (i: number): boolean => {
393
+ const myIndent = symbols[i].indent;
394
+ for (let j = i + 1; j < symbols.length; j++) {
395
+ const nextIndent = symbols[j].indent;
396
+ if (nextIndent === myIndent) return false;
397
+ if (nextIndent < myIndent) return true;
398
+ }
399
+ return true;
400
+ };
401
+
402
+ const getPrefix = (i: number): string => {
403
+ const myIndent = symbols[i].indent;
404
+ if (myIndent === 0) return " ";
405
+
406
+ let prefix = " ";
407
+ for (let level = 2; level <= myIndent; level += 2) {
408
+ let ancestorIdx = -1;
409
+ for (let j = i - 1; j >= 0; j--) {
410
+ if (symbols[j].indent === level - 2) {
411
+ ancestorIdx = j;
412
+ break;
413
+ }
414
+ }
415
+ if (ancestorIdx >= 0 && isLastSibling(ancestorIdx)) {
416
+ prefix += " ";
417
+ } else {
418
+ prefix += `${theme.tree.vertical} `;
419
+ }
420
+ }
421
+ return prefix;
422
+ };
423
+
424
+ const topLevelCount = symbols.filter((s) => s.indent === 0).length;
425
+
426
+ if (expanded) {
427
+ let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}`;
428
+
429
+ for (let i = 0; i < symbols.length; i++) {
430
+ const sym = symbols[i];
431
+ const prefix = getPrefix(i);
432
+ const isLast = isLastSibling(i);
433
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
434
+ const detailPrefix = isLast ? " " : `${theme.tree.vertical} `;
435
+ output += `\n${prefix}${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg(
436
+ "accent",
437
+ sym.name,
438
+ )}`;
439
+ output += `\n${prefix}${theme.fg("dim", detailPrefix)}${theme.fg("muted", `line ${sym.line}`)}`;
440
+ }
441
+ return new Text(output, 0, 0);
442
+ }
443
+
444
+ // Collapsed: show first 3 top-level symbols
445
+ const expandHint = theme.fg("dim", " (Ctrl+O to expand)");
446
+ let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`;
447
+
448
+ const topLevel = symbols.filter((s) => s.indent === 0).slice(0, 3);
449
+ for (let i = 0; i < topLevel.length; i++) {
450
+ const sym = topLevel[i];
451
+ const isLast = i === topLevel.length - 1 && topLevelCount <= 3;
452
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
453
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg(
454
+ "accent",
455
+ sym.name,
456
+ )} ${theme.fg("muted", `line ${sym.line}`)}`;
457
+ }
458
+ if (topLevelCount > 3) {
459
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
460
+ "muted",
461
+ `${theme.format.ellipsis} ${topLevelCount - 3} more`,
462
+ )}`;
463
+ }
464
+
465
+ return new Text(output, 0, 0);
466
+ }
467
+
468
+ // =============================================================================
469
+ // Generic Rendering
470
+ // =============================================================================
471
+
472
+ /**
473
+ * Generic fallback rendering for unknown result types.
474
+ */
475
+ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: Theme): Text {
476
+ const hasError = text.includes("Error:") || text.includes(theme.status.error);
477
+ const hasSuccess = text.includes(theme.status.success) || text.includes("Applied");
478
+
479
+ const icon =
480
+ hasError && !hasSuccess
481
+ ? theme.styledSymbol("status.error", "error")
482
+ : hasSuccess && !hasError
483
+ ? theme.styledSymbol("status.success", "success")
484
+ : theme.styledSymbol("status.info", "accent");
485
+
486
+ if (expanded) {
487
+ let output = `${icon} ${theme.fg("toolTitle", "LSP")} ${theme.fg("dim", "Output")}`;
488
+ for (let i = 0; i < lines.length; i++) {
489
+ const isLast = i === lines.length - 1;
490
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
491
+ output += `\n ${theme.fg("dim", branch)} ${lines[i]}`;
492
+ }
493
+ return new Text(output, 0, 0);
494
+ }
495
+
496
+ const firstLine = lines[0] || "No output";
497
+ const expandHint = lines.length > 1 ? theme.fg("dim", " (Ctrl+O to expand)") : "";
498
+ let output = `${icon} ${theme.fg("toolTitle", "LSP")} ${theme.fg("dim", firstLine.slice(0, 60))}${expandHint}`;
499
+
500
+ if (lines.length > 1) {
501
+ const previewLines = lines.slice(1, 4);
502
+ for (let i = 0; i < previewLines.length; i++) {
503
+ const isLast = i === previewLines.length - 1 && lines.length <= 4;
504
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
505
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", previewLines[i].trim().slice(0, 80))}`;
506
+ }
507
+ if (lines.length > 4) {
508
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
509
+ "muted",
510
+ `${theme.format.ellipsis} ${lines.length - 4} more lines`,
511
+ )}`;
512
+ }
513
+ }
514
+
515
+ return new Text(output, 0, 0);
516
+ }
517
+
518
+ // =============================================================================
519
+ // Parsing Helpers
520
+ // =============================================================================
521
+
522
+ interface ParsedDiagnostic {
523
+ file: string;
524
+ line: string;
525
+ col: string;
526
+ severity: string;
527
+ message: string;
528
+ }
529
+
530
+ interface RawDiagnostic {
531
+ raw: string;
532
+ }
533
+
534
+ type DiagnosticItem = ParsedDiagnostic | RawDiagnostic;
535
+
536
+ function parseDiagnosticLine(line: string): ParsedDiagnostic | null {
537
+ const match = line.trim().match(/^(.*):(\d+):(\d+)\s+\[(\w+)\]\s*(.*)$/);
538
+ if (!match) return null;
539
+ const [, file, lineNum, colNum, severity, message] = match;
540
+ return { file, line: lineNum, col: colNum, severity: severity.toLowerCase(), message };
541
+ }
542
+
543
+ function severityToColor(severity: string): "error" | "warning" | "accent" | "dim" {
544
+ switch (severity) {
545
+ case "error":
546
+ return "error";
547
+ case "warning":
548
+ return "warning";
549
+ case "info":
550
+ return "accent";
551
+ default:
552
+ return "dim";
553
+ }
554
+ }
555
+
556
+ function trimTo(value: string, maxLength: number, theme: Theme): string {
557
+ if (value.length <= maxLength) return value;
558
+ const sliceLen = Math.max(0, maxLength - theme.format.ellipsis.length);
559
+ return `${value.slice(0, sliceLen)}${theme.format.ellipsis}`;
560
+ }
@@ -0,0 +1,145 @@
1
+ import { sendNotification, sendRequest } from "./client";
2
+ import type { Diagnostic, ExpandMacroResult, LspClient, RelatedTest, Runnable, WorkspaceEdit } from "./types";
3
+ import { fileToUri } from "./utils";
4
+
5
+ /**
6
+ * Wait for specified milliseconds.
7
+ */
8
+ async function sleep(ms: number): Promise<void> {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+
12
+ /**
13
+ * Run flycheck (cargo check) and collect diagnostics.
14
+ * Sends rust-analyzer/runFlycheck notification and waits for diagnostics to accumulate.
15
+ *
16
+ * @param client - LSP client instance
17
+ * @param file - Optional file path to check (if not provided, checks entire workspace)
18
+ * @returns Array of all collected diagnostics
19
+ */
20
+ export async function flycheck(client: LspClient, file?: string): Promise<Diagnostic[]> {
21
+ const textDocument = file ? { uri: fileToUri(file) } : null;
22
+ await sendNotification(client, "rust-analyzer/runFlycheck", { textDocument });
23
+
24
+ // Wait for diagnostics to accumulate (2 seconds as per reference)
25
+ await sleep(2000);
26
+
27
+ // Collect all diagnostics from client
28
+ const allDiags: Diagnostic[] = [];
29
+ for (const diags of Array.from(client.diagnostics.values())) {
30
+ allDiags.push(...diags);
31
+ }
32
+
33
+ return allDiags;
34
+ }
35
+
36
+ /**
37
+ * Expand macro at the given position.
38
+ *
39
+ * @param client - LSP client instance
40
+ * @param file - File path containing the macro
41
+ * @param line - 1-based line number
42
+ * @param character - 1-based character offset
43
+ * @returns ExpandMacroResult with macro name and expansion, or null if no macro at position
44
+ */
45
+ export async function expandMacro(
46
+ client: LspClient,
47
+ file: string,
48
+ line: number,
49
+ character: number,
50
+ ): Promise<ExpandMacroResult | null> {
51
+ const result = (await sendRequest(client, "rust-analyzer/expandMacro", {
52
+ textDocument: { uri: fileToUri(file) },
53
+ position: { line: line - 1, character: character - 1 },
54
+ })) as ExpandMacroResult | null;
55
+
56
+ return result;
57
+ }
58
+
59
+ /**
60
+ * Perform structural search and replace (SSR).
61
+ *
62
+ * @param client - LSP client instance
63
+ * @param pattern - Search pattern
64
+ * @param replacement - Replacement pattern
65
+ * @param parseOnly - If true, returns matches only; if false, returns WorkspaceEdit to apply
66
+ * @returns WorkspaceEdit containing matches or changes to apply
67
+ */
68
+ export async function ssr(
69
+ client: LspClient,
70
+ pattern: string,
71
+ replacement: string,
72
+ parseOnly = true,
73
+ ): Promise<WorkspaceEdit> {
74
+ const result = (await sendRequest(client, "experimental/ssr", {
75
+ query: `${pattern} ==>> ${replacement}`,
76
+ parseOnly,
77
+ textDocument: { uri: "" }, // SSR searches workspace-wide
78
+ position: { line: 0, character: 0 },
79
+ selections: [],
80
+ })) as WorkspaceEdit;
81
+
82
+ return result;
83
+ }
84
+
85
+ /**
86
+ * Get runnables (tests, binaries, examples) for a file.
87
+ *
88
+ * @param client - LSP client instance
89
+ * @param file - File path to query
90
+ * @param line - Optional 1-based line number to get runnables at specific position
91
+ * @returns Array of Runnable items
92
+ */
93
+ export async function runnables(client: LspClient, file: string, line?: number): Promise<Runnable[]> {
94
+ const params: { textDocument: { uri: string }; position?: { line: number; character: number } } = {
95
+ textDocument: { uri: fileToUri(file) },
96
+ };
97
+
98
+ if (line !== undefined) {
99
+ params.position = { line: line - 1, character: 0 };
100
+ }
101
+
102
+ const result = (await sendRequest(client, "experimental/runnables", params)) as Runnable[];
103
+ return result ?? [];
104
+ }
105
+
106
+ /**
107
+ * Get related tests for a position (e.g., tests for a function).
108
+ *
109
+ * @param client - LSP client instance
110
+ * @param file - File path
111
+ * @param line - 1-based line number
112
+ * @param character - 1-based character offset
113
+ * @returns Array of test runnable labels
114
+ */
115
+ export async function relatedTests(
116
+ client: LspClient,
117
+ file: string,
118
+ line: number,
119
+ character: number,
120
+ ): Promise<string[]> {
121
+ const tests = (await sendRequest(client, "rust-analyzer/relatedTests", {
122
+ textDocument: { uri: fileToUri(file) },
123
+ position: { line: line - 1, character: character - 1 },
124
+ })) as RelatedTest[];
125
+
126
+ if (!tests?.length) return [];
127
+
128
+ const labels: string[] = [];
129
+ for (const t of tests) {
130
+ if (t.runnable?.label) {
131
+ labels.push(t.runnable.label);
132
+ }
133
+ }
134
+
135
+ return labels;
136
+ }
137
+
138
+ /**
139
+ * Reload workspace (re-index Cargo projects).
140
+ *
141
+ * @param client - LSP client instance
142
+ */
143
+ export async function reloadWorkspace(client: LspClient): Promise<void> {
144
+ await sendRequest(client, "rust-analyzer/reloadWorkspace", null);
145
+ }