@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.
- package/CHANGELOG.md +1629 -0
- package/README.md +1041 -0
- package/docs/compaction.md +403 -0
- package/docs/config-usage.md +113 -0
- package/docs/custom-tools.md +541 -0
- package/docs/extension-loading.md +1004 -0
- package/docs/hooks.md +867 -0
- package/docs/rpc.md +1040 -0
- package/docs/sdk.md +994 -0
- package/docs/session-tree-plan.md +441 -0
- package/docs/session.md +240 -0
- package/docs/skills.md +290 -0
- package/docs/theme.md +670 -0
- package/docs/tree.md +197 -0
- package/docs/tui.md +341 -0
- package/examples/README.md +21 -0
- package/examples/custom-tools/README.md +124 -0
- package/examples/custom-tools/hello/index.ts +20 -0
- package/examples/custom-tools/question/index.ts +84 -0
- package/examples/custom-tools/subagent/README.md +172 -0
- package/examples/custom-tools/subagent/agents/planner.md +37 -0
- package/examples/custom-tools/subagent/agents/scout.md +50 -0
- package/examples/custom-tools/subagent/agents/worker.md +24 -0
- package/examples/custom-tools/subagent/agents.ts +156 -0
- package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
- package/examples/custom-tools/subagent/commands/implement.md +10 -0
- package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
- package/examples/custom-tools/subagent/index.ts +1002 -0
- package/examples/custom-tools/todo/index.ts +212 -0
- package/examples/hooks/README.md +56 -0
- package/examples/hooks/auto-commit-on-exit.ts +49 -0
- package/examples/hooks/confirm-destructive.ts +59 -0
- package/examples/hooks/custom-compaction.ts +116 -0
- package/examples/hooks/dirty-repo-guard.ts +52 -0
- package/examples/hooks/file-trigger.ts +41 -0
- package/examples/hooks/git-checkpoint.ts +53 -0
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +34 -0
- package/examples/hooks/protected-paths.ts +30 -0
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/snake.ts +343 -0
- package/examples/hooks/status-line.ts +40 -0
- package/examples/sdk/01-minimal.ts +22 -0
- package/examples/sdk/02-custom-model.ts +49 -0
- package/examples/sdk/03-custom-prompt.ts +44 -0
- package/examples/sdk/04-skills.ts +44 -0
- package/examples/sdk/05-tools.ts +90 -0
- package/examples/sdk/06-hooks.ts +61 -0
- package/examples/sdk/07-context-files.ts +36 -0
- package/examples/sdk/08-slash-commands.ts +42 -0
- package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
- package/examples/sdk/10-settings.ts +38 -0
- package/examples/sdk/11-sessions.ts +48 -0
- package/examples/sdk/12-full-control.ts +95 -0
- package/examples/sdk/README.md +154 -0
- package/package.json +89 -0
- package/src/bun-imports.d.ts +16 -0
- package/src/capability/context-file.ts +40 -0
- package/src/capability/extension.ts +48 -0
- package/src/capability/hook.ts +40 -0
- package/src/capability/index.ts +616 -0
- package/src/capability/instruction.ts +37 -0
- package/src/capability/mcp.ts +52 -0
- package/src/capability/prompt.ts +35 -0
- package/src/capability/rule.ts +56 -0
- package/src/capability/settings.ts +35 -0
- package/src/capability/skill.ts +49 -0
- package/src/capability/slash-command.ts +40 -0
- package/src/capability/system-prompt.ts +35 -0
- package/src/capability/tool.ts +38 -0
- package/src/capability/types.ts +166 -0
- package/src/cli/args.ts +259 -0
- package/src/cli/file-processor.ts +121 -0
- package/src/cli/list-models.ts +104 -0
- package/src/cli/plugin-cli.ts +661 -0
- package/src/cli/session-picker.ts +41 -0
- package/src/cli/update-cli.ts +274 -0
- package/src/cli.ts +10 -0
- package/src/config.ts +391 -0
- package/src/core/agent-session.ts +2178 -0
- package/src/core/auth-storage.ts +258 -0
- package/src/core/bash-executor.ts +197 -0
- package/src/core/compaction/branch-summarization.ts +315 -0
- package/src/core/compaction/compaction.ts +664 -0
- package/src/core/compaction/index.ts +7 -0
- package/src/core/compaction/utils.ts +153 -0
- package/src/core/custom-commands/bundled/review/index.ts +156 -0
- package/src/core/custom-commands/index.ts +15 -0
- package/src/core/custom-commands/loader.ts +226 -0
- package/src/core/custom-commands/types.ts +112 -0
- package/src/core/custom-tools/index.ts +22 -0
- package/src/core/custom-tools/loader.ts +248 -0
- package/src/core/custom-tools/types.ts +185 -0
- package/src/core/custom-tools/wrapper.ts +29 -0
- package/src/core/exec.ts +139 -0
- package/src/core/export-html/index.ts +159 -0
- package/src/core/export-html/template.css +774 -0
- package/src/core/export-html/template.generated.ts +2 -0
- package/src/core/export-html/template.html +45 -0
- package/src/core/export-html/template.js +1185 -0
- package/src/core/export-html/template.macro.ts +24 -0
- package/src/core/file-mentions.ts +54 -0
- package/src/core/hooks/index.ts +16 -0
- package/src/core/hooks/loader.ts +288 -0
- package/src/core/hooks/runner.ts +434 -0
- package/src/core/hooks/tool-wrapper.ts +98 -0
- package/src/core/hooks/types.ts +770 -0
- package/src/core/index.ts +53 -0
- package/src/core/logger.ts +112 -0
- package/src/core/mcp/client.ts +185 -0
- package/src/core/mcp/config.ts +248 -0
- package/src/core/mcp/index.ts +45 -0
- package/src/core/mcp/loader.ts +99 -0
- package/src/core/mcp/manager.ts +235 -0
- package/src/core/mcp/tool-bridge.ts +156 -0
- package/src/core/mcp/transports/http.ts +316 -0
- package/src/core/mcp/transports/index.ts +6 -0
- package/src/core/mcp/transports/stdio.ts +252 -0
- package/src/core/mcp/types.ts +228 -0
- package/src/core/messages.ts +211 -0
- package/src/core/model-registry.ts +334 -0
- package/src/core/model-resolver.ts +494 -0
- package/src/core/plugins/doctor.ts +67 -0
- package/src/core/plugins/index.ts +38 -0
- package/src/core/plugins/installer.ts +189 -0
- package/src/core/plugins/loader.ts +339 -0
- package/src/core/plugins/manager.ts +672 -0
- package/src/core/plugins/parser.ts +105 -0
- package/src/core/plugins/paths.ts +37 -0
- package/src/core/plugins/types.ts +190 -0
- package/src/core/sdk.ts +900 -0
- package/src/core/session-manager.ts +1837 -0
- package/src/core/settings-manager.ts +860 -0
- package/src/core/skills.ts +352 -0
- package/src/core/slash-commands.ts +132 -0
- package/src/core/system-prompt.ts +442 -0
- package/src/core/timings.ts +25 -0
- package/src/core/title-generator.ts +110 -0
- package/src/core/tools/ask.ts +193 -0
- package/src/core/tools/bash-interceptor.ts +120 -0
- package/src/core/tools/bash.ts +91 -0
- package/src/core/tools/context.ts +32 -0
- package/src/core/tools/edit-diff.ts +487 -0
- package/src/core/tools/edit.ts +140 -0
- package/src/core/tools/exa/company.ts +59 -0
- package/src/core/tools/exa/index.ts +63 -0
- package/src/core/tools/exa/linkedin.ts +59 -0
- package/src/core/tools/exa/mcp-client.ts +368 -0
- package/src/core/tools/exa/render.ts +200 -0
- package/src/core/tools/exa/researcher.ts +90 -0
- package/src/core/tools/exa/search.ts +338 -0
- package/src/core/tools/exa/types.ts +167 -0
- package/src/core/tools/exa/websets.ts +248 -0
- package/src/core/tools/find.ts +244 -0
- package/src/core/tools/grep.ts +584 -0
- package/src/core/tools/index.ts +283 -0
- package/src/core/tools/ls.ts +142 -0
- package/src/core/tools/lsp/client.ts +767 -0
- package/src/core/tools/lsp/clients/biome-client.ts +207 -0
- package/src/core/tools/lsp/clients/index.ts +49 -0
- package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
- package/src/core/tools/lsp/config.ts +845 -0
- package/src/core/tools/lsp/edits.ts +110 -0
- package/src/core/tools/lsp/index.ts +1364 -0
- package/src/core/tools/lsp/render.ts +560 -0
- package/src/core/tools/lsp/rust-analyzer.ts +145 -0
- package/src/core/tools/lsp/types.ts +495 -0
- package/src/core/tools/lsp/utils.ts +526 -0
- package/src/core/tools/notebook.ts +182 -0
- package/src/core/tools/output.ts +198 -0
- package/src/core/tools/path-utils.ts +61 -0
- package/src/core/tools/read.ts +507 -0
- package/src/core/tools/renderers.ts +820 -0
- package/src/core/tools/review.ts +275 -0
- package/src/core/tools/rulebook.ts +124 -0
- package/src/core/tools/task/agents.ts +158 -0
- package/src/core/tools/task/artifacts.ts +114 -0
- package/src/core/tools/task/commands.ts +157 -0
- package/src/core/tools/task/discovery.ts +217 -0
- package/src/core/tools/task/executor.ts +531 -0
- package/src/core/tools/task/index.ts +548 -0
- package/src/core/tools/task/model-resolver.ts +176 -0
- package/src/core/tools/task/parallel.ts +38 -0
- package/src/core/tools/task/render.ts +502 -0
- package/src/core/tools/task/subprocess-tool-registry.ts +89 -0
- package/src/core/tools/task/types.ts +142 -0
- package/src/core/tools/truncate.ts +265 -0
- package/src/core/tools/web-fetch.ts +2511 -0
- package/src/core/tools/web-search/auth.ts +199 -0
- package/src/core/tools/web-search/index.ts +583 -0
- package/src/core/tools/web-search/providers/anthropic.ts +198 -0
- package/src/core/tools/web-search/providers/exa.ts +196 -0
- package/src/core/tools/web-search/providers/perplexity.ts +195 -0
- package/src/core/tools/web-search/render.ts +372 -0
- package/src/core/tools/web-search/types.ts +180 -0
- package/src/core/tools/write.ts +63 -0
- package/src/core/ttsr.ts +211 -0
- package/src/core/utils.ts +187 -0
- package/src/discovery/agents-md.ts +75 -0
- package/src/discovery/builtin.ts +647 -0
- package/src/discovery/claude.ts +623 -0
- package/src/discovery/cline.ts +104 -0
- package/src/discovery/codex.ts +571 -0
- package/src/discovery/cursor.ts +266 -0
- package/src/discovery/gemini.ts +368 -0
- package/src/discovery/github.ts +120 -0
- package/src/discovery/helpers.test.ts +127 -0
- package/src/discovery/helpers.ts +249 -0
- package/src/discovery/index.ts +84 -0
- package/src/discovery/mcp-json.ts +127 -0
- package/src/discovery/vscode.ts +99 -0
- package/src/discovery/windsurf.ts +219 -0
- package/src/index.ts +192 -0
- package/src/main.ts +507 -0
- package/src/migrations.ts +156 -0
- package/src/modes/cleanup.ts +23 -0
- package/src/modes/index.ts +48 -0
- package/src/modes/interactive/components/armin.ts +382 -0
- package/src/modes/interactive/components/assistant-message.ts +86 -0
- package/src/modes/interactive/components/bash-execution.ts +199 -0
- package/src/modes/interactive/components/bordered-loader.ts +41 -0
- package/src/modes/interactive/components/branch-summary-message.ts +42 -0
- package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
- package/src/modes/interactive/components/custom-editor.ts +122 -0
- package/src/modes/interactive/components/diff.ts +147 -0
- package/src/modes/interactive/components/dynamic-border.ts +25 -0
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +296 -0
- package/src/modes/interactive/components/extensions/extension-list.ts +479 -0
- package/src/modes/interactive/components/extensions/index.ts +9 -0
- package/src/modes/interactive/components/extensions/inspector-panel.ts +313 -0
- package/src/modes/interactive/components/extensions/state-manager.ts +558 -0
- package/src/modes/interactive/components/extensions/types.ts +191 -0
- package/src/modes/interactive/components/hook-editor.ts +117 -0
- package/src/modes/interactive/components/hook-input.ts +64 -0
- package/src/modes/interactive/components/hook-message.ts +96 -0
- package/src/modes/interactive/components/hook-selector.ts +91 -0
- package/src/modes/interactive/components/model-selector.ts +560 -0
- package/src/modes/interactive/components/oauth-selector.ts +136 -0
- package/src/modes/interactive/components/plugin-settings.ts +481 -0
- package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
- package/src/modes/interactive/components/session-selector.ts +220 -0
- package/src/modes/interactive/components/settings-defs.ts +597 -0
- package/src/modes/interactive/components/settings-selector.ts +545 -0
- package/src/modes/interactive/components/show-images-selector.ts +45 -0
- package/src/modes/interactive/components/status-line/index.ts +4 -0
- package/src/modes/interactive/components/status-line/presets.ts +94 -0
- package/src/modes/interactive/components/status-line/segments.ts +350 -0
- package/src/modes/interactive/components/status-line/separators.ts +55 -0
- package/src/modes/interactive/components/status-line/types.ts +81 -0
- package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
- package/src/modes/interactive/components/status-line.ts +384 -0
- package/src/modes/interactive/components/theme-selector.ts +62 -0
- package/src/modes/interactive/components/thinking-selector.ts +64 -0
- package/src/modes/interactive/components/tool-execution.ts +946 -0
- package/src/modes/interactive/components/tree-selector.ts +877 -0
- package/src/modes/interactive/components/ttsr-notification.ts +82 -0
- package/src/modes/interactive/components/user-message-selector.ts +159 -0
- package/src/modes/interactive/components/user-message.ts +18 -0
- package/src/modes/interactive/components/visual-truncate.ts +50 -0
- package/src/modes/interactive/components/welcome.ts +228 -0
- package/src/modes/interactive/interactive-mode.ts +2669 -0
- package/src/modes/interactive/theme/dark.json +102 -0
- package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
- package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
- package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
- package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
- package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
- package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
- package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
- package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
- package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
- package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
- package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
- package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
- package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
- package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
- package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
- package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
- package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
- package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
- package/src/modes/interactive/theme/defaults/index.ts +67 -0
- package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
- package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
- package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
- package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
- package/src/modes/interactive/theme/defaults/light-github.json +114 -0
- package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
- package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
- package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
- package/src/modes/interactive/theme/defaults/light-one.json +105 -0
- package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
- package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
- package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
- package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
- package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
- package/src/modes/interactive/theme/light.json +99 -0
- package/src/modes/interactive/theme/theme-schema.json +424 -0
- package/src/modes/interactive/theme/theme.ts +2211 -0
- package/src/modes/print-mode.ts +163 -0
- package/src/modes/rpc/rpc-client.ts +527 -0
- package/src/modes/rpc/rpc-mode.ts +494 -0
- package/src/modes/rpc/rpc-types.ts +203 -0
- package/src/prompts/architect-plan.md +10 -0
- package/src/prompts/branch-summary-preamble.md +3 -0
- package/src/prompts/branch-summary.md +28 -0
- package/src/prompts/browser.md +71 -0
- package/src/prompts/compaction-summary.md +34 -0
- package/src/prompts/compaction-turn-prefix.md +16 -0
- package/src/prompts/compaction-update-summary.md +41 -0
- package/src/prompts/explore.md +82 -0
- package/src/prompts/implement-with-critic.md +11 -0
- package/src/prompts/implement.md +11 -0
- package/src/prompts/init.md +30 -0
- package/src/prompts/plan.md +54 -0
- package/src/prompts/reviewer.md +81 -0
- package/src/prompts/summarization-system.md +3 -0
- package/src/prompts/system-prompt.md +27 -0
- package/src/prompts/task.md +56 -0
- package/src/prompts/title-system.md +8 -0
- package/src/prompts/tools/ask.md +24 -0
- package/src/prompts/tools/bash.md +23 -0
- package/src/prompts/tools/edit.md +9 -0
- package/src/prompts/tools/find.md +6 -0
- package/src/prompts/tools/grep.md +12 -0
- package/src/prompts/tools/lsp.md +14 -0
- package/src/prompts/tools/output.md +23 -0
- package/src/prompts/tools/read.md +25 -0
- package/src/prompts/tools/web-fetch.md +8 -0
- package/src/prompts/tools/web-search.md +10 -0
- package/src/prompts/tools/write.md +10 -0
- package/src/utils/changelog.ts +99 -0
- package/src/utils/clipboard.ts +265 -0
- package/src/utils/fuzzy.ts +108 -0
- package/src/utils/mime.ts +30 -0
- package/src/utils/shell-snapshot.ts +218 -0
- package/src/utils/shell.ts +364 -0
- package/src/utils/tools-manager.ts +265 -0
|
@@ -0,0 +1,1364 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
4
|
+
import type { BunFile } from "bun";
|
|
5
|
+
import { type Theme, theme } from "../../../modes/interactive/theme/theme";
|
|
6
|
+
import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" };
|
|
7
|
+
import { logger } from "../../logger";
|
|
8
|
+
import { once, untilAborted } from "../../utils";
|
|
9
|
+
import { resolveToCwd } from "../path-utils";
|
|
10
|
+
import {
|
|
11
|
+
ensureFileOpen,
|
|
12
|
+
getActiveClients,
|
|
13
|
+
getOrCreateClient,
|
|
14
|
+
type LspServerStatus,
|
|
15
|
+
notifySaved,
|
|
16
|
+
refreshFile,
|
|
17
|
+
sendRequest,
|
|
18
|
+
setIdleTimeout,
|
|
19
|
+
syncContent,
|
|
20
|
+
} from "./client";
|
|
21
|
+
import { getLinterClient } from "./clients";
|
|
22
|
+
import { getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config";
|
|
23
|
+
import { applyTextEditsToString, applyWorkspaceEdit } from "./edits";
|
|
24
|
+
import { renderCall, renderResult } from "./render";
|
|
25
|
+
import * as rustAnalyzer from "./rust-analyzer";
|
|
26
|
+
import {
|
|
27
|
+
type CallHierarchyIncomingCall,
|
|
28
|
+
type CallHierarchyItem,
|
|
29
|
+
type CallHierarchyOutgoingCall,
|
|
30
|
+
type CodeAction,
|
|
31
|
+
type Command,
|
|
32
|
+
type Diagnostic,
|
|
33
|
+
type DocumentSymbol,
|
|
34
|
+
type Hover,
|
|
35
|
+
type Location,
|
|
36
|
+
type LocationLink,
|
|
37
|
+
type LspClient,
|
|
38
|
+
type LspParams,
|
|
39
|
+
type LspToolDetails,
|
|
40
|
+
lspSchema,
|
|
41
|
+
type ServerConfig,
|
|
42
|
+
type SymbolInformation,
|
|
43
|
+
type TextEdit,
|
|
44
|
+
type WorkspaceEdit,
|
|
45
|
+
} from "./types";
|
|
46
|
+
import {
|
|
47
|
+
extractHoverText,
|
|
48
|
+
fileToUri,
|
|
49
|
+
formatDiagnostic,
|
|
50
|
+
formatDiagnosticsSummary,
|
|
51
|
+
formatDocumentSymbol,
|
|
52
|
+
formatLocation,
|
|
53
|
+
formatSymbolInformation,
|
|
54
|
+
formatWorkspaceEdit,
|
|
55
|
+
sleep,
|
|
56
|
+
symbolKindToIcon,
|
|
57
|
+
uriToFile,
|
|
58
|
+
} from "./utils";
|
|
59
|
+
|
|
60
|
+
export type { LspServerStatus } from "./client";
|
|
61
|
+
export type { LspToolDetails } from "./types";
|
|
62
|
+
|
|
63
|
+
/** Result from warming up LSP servers */
|
|
64
|
+
export interface LspWarmupResult {
|
|
65
|
+
servers: Array<{
|
|
66
|
+
name: string;
|
|
67
|
+
status: "ready" | "error";
|
|
68
|
+
fileTypes: string[];
|
|
69
|
+
error?: string;
|
|
70
|
+
}>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Warm up LSP servers for a directory by connecting to all detected servers.
|
|
75
|
+
* This should be called at startup to avoid cold-start delays.
|
|
76
|
+
*
|
|
77
|
+
* @param cwd - Working directory to detect and start servers for
|
|
78
|
+
* @returns Status of each server that was started
|
|
79
|
+
*/
|
|
80
|
+
export async function warmupLspServers(cwd: string): Promise<LspWarmupResult> {
|
|
81
|
+
const config = loadConfig(cwd);
|
|
82
|
+
setIdleTimeout(config.idleTimeoutMs);
|
|
83
|
+
const servers: LspWarmupResult["servers"] = [];
|
|
84
|
+
const lspServers = getLspServers(config);
|
|
85
|
+
|
|
86
|
+
// Start all detected servers in parallel
|
|
87
|
+
const results = await Promise.allSettled(
|
|
88
|
+
lspServers.map(async ([name, serverConfig]) => {
|
|
89
|
+
const client = await getOrCreateClient(serverConfig, cwd);
|
|
90
|
+
return { name, client, fileTypes: serverConfig.fileTypes };
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
for (const result of results) {
|
|
95
|
+
if (result.status === "fulfilled") {
|
|
96
|
+
servers.push({
|
|
97
|
+
name: result.value.name,
|
|
98
|
+
status: "ready",
|
|
99
|
+
fileTypes: result.value.fileTypes,
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
// Extract server name from error if possible
|
|
103
|
+
const errorMsg = result.reason?.message ?? String(result.reason);
|
|
104
|
+
servers.push({
|
|
105
|
+
name: "unknown",
|
|
106
|
+
status: "error",
|
|
107
|
+
fileTypes: [],
|
|
108
|
+
error: errorMsg,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { servers };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get status of currently active LSP servers.
|
|
118
|
+
*/
|
|
119
|
+
export function getLspStatus(): LspServerStatus[] {
|
|
120
|
+
return getActiveClients();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Sync in-memory file content to all applicable LSP servers.
|
|
125
|
+
* Sends didOpen (if new) or didChange (if already open).
|
|
126
|
+
*
|
|
127
|
+
* @param absolutePath - Absolute path to the file
|
|
128
|
+
* @param content - The new file content
|
|
129
|
+
* @param cwd - Working directory for LSP config resolution
|
|
130
|
+
* @param servers - Servers to sync to
|
|
131
|
+
*/
|
|
132
|
+
async function syncFileContent(
|
|
133
|
+
absolutePath: string,
|
|
134
|
+
content: string,
|
|
135
|
+
cwd: string,
|
|
136
|
+
servers: Array<[string, ServerConfig]>,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
await Promise.allSettled(
|
|
139
|
+
servers.map(async ([_serverName, serverConfig]) => {
|
|
140
|
+
if (serverConfig.createClient) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const client = await getOrCreateClient(serverConfig, cwd);
|
|
144
|
+
await syncContent(client, absolutePath, content);
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Notify all LSP servers that a file was saved.
|
|
151
|
+
* Assumes content was already synced via syncFileContent.
|
|
152
|
+
*
|
|
153
|
+
* @param absolutePath - Absolute path to the file
|
|
154
|
+
* @param cwd - Working directory for LSP config resolution
|
|
155
|
+
* @param servers - Servers to notify
|
|
156
|
+
*/
|
|
157
|
+
async function notifyFileSaved(
|
|
158
|
+
absolutePath: string,
|
|
159
|
+
cwd: string,
|
|
160
|
+
servers: Array<[string, ServerConfig]>,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
await Promise.allSettled(
|
|
163
|
+
servers.map(async ([_serverName, serverConfig]) => {
|
|
164
|
+
if (serverConfig.createClient) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const client = await getOrCreateClient(serverConfig, cwd);
|
|
168
|
+
await notifySaved(client, absolutePath);
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Cache config per cwd to avoid repeated file I/O
|
|
174
|
+
const configCache = new Map<string, LspConfig>();
|
|
175
|
+
|
|
176
|
+
function getConfig(cwd: string): LspConfig {
|
|
177
|
+
let config = configCache.get(cwd);
|
|
178
|
+
if (!config) {
|
|
179
|
+
config = loadConfig(cwd);
|
|
180
|
+
setIdleTimeout(config.idleTimeoutMs);
|
|
181
|
+
configCache.set(cwd, config);
|
|
182
|
+
}
|
|
183
|
+
return config;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isCustomLinter(serverConfig: ServerConfig): boolean {
|
|
187
|
+
return Boolean(serverConfig.createClient);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function splitServers(servers: Array<[string, ServerConfig]>): {
|
|
191
|
+
lspServers: Array<[string, ServerConfig]>;
|
|
192
|
+
customLinterServers: Array<[string, ServerConfig]>;
|
|
193
|
+
} {
|
|
194
|
+
const lspServers: Array<[string, ServerConfig]> = [];
|
|
195
|
+
const customLinterServers: Array<[string, ServerConfig]> = [];
|
|
196
|
+
for (const entry of servers) {
|
|
197
|
+
if (isCustomLinter(entry[1])) {
|
|
198
|
+
customLinterServers.push(entry);
|
|
199
|
+
} else {
|
|
200
|
+
lspServers.push(entry);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { lspServers, customLinterServers };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getLspServers(config: LspConfig): Array<[string, ServerConfig]> {
|
|
207
|
+
return (Object.entries(config.servers) as Array<[string, ServerConfig]>).filter(
|
|
208
|
+
([, serverConfig]) => !isCustomLinter(serverConfig),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getLspServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> {
|
|
213
|
+
return getServersForFile(config, filePath).filter(([, serverConfig]) => !isCustomLinter(serverConfig));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getLspServerForFile(config: LspConfig, filePath: string): [string, ServerConfig] | null {
|
|
217
|
+
const servers = getLspServersForFile(config, filePath);
|
|
218
|
+
return servers.length > 0 ? servers[0] : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const FILE_SEARCH_MAX_DEPTH = 5;
|
|
222
|
+
const IGNORED_DIRS = new Set(["node_modules", "target", "dist", "build", ".git"]);
|
|
223
|
+
|
|
224
|
+
function findFileByExtensions(baseDir: string, extensions: string[], maxDepth: number): string | null {
|
|
225
|
+
const normalized = extensions.map((ext) => ext.toLowerCase());
|
|
226
|
+
const search = (dir: string, depth: number): string | null => {
|
|
227
|
+
if (depth > maxDepth) return null;
|
|
228
|
+
let entries: fs.Dirent[];
|
|
229
|
+
try {
|
|
230
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (entry.name.startsWith(".")) continue;
|
|
237
|
+
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
|
238
|
+
const fullPath = path.join(dir, entry.name);
|
|
239
|
+
|
|
240
|
+
if (entry.isFile()) {
|
|
241
|
+
const lowerName = entry.name.toLowerCase();
|
|
242
|
+
if (normalized.some((ext) => lowerName.endsWith(ext))) {
|
|
243
|
+
return fullPath;
|
|
244
|
+
}
|
|
245
|
+
} else if (entry.isDirectory()) {
|
|
246
|
+
const found = search(fullPath, depth + 1);
|
|
247
|
+
if (found) return found;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return search(baseDir, 0);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function findFileForServer(cwd: string, serverConfig: ServerConfig): string | null {
|
|
257
|
+
return findFileByExtensions(cwd, serverConfig.fileTypes, FILE_SEARCH_MAX_DEPTH);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function getRustServer(config: LspConfig): [string, ServerConfig] | null {
|
|
261
|
+
const entries = getLspServers(config);
|
|
262
|
+
const byName = entries.find(([name, server]) => name === "rust-analyzer" || server.command === "rust-analyzer");
|
|
263
|
+
if (byName) return byName;
|
|
264
|
+
|
|
265
|
+
for (const [name, server] of entries) {
|
|
266
|
+
if (
|
|
267
|
+
hasCapability(server, "flycheck") ||
|
|
268
|
+
hasCapability(server, "ssr") ||
|
|
269
|
+
hasCapability(server, "runnables") ||
|
|
270
|
+
hasCapability(server, "expandMacro") ||
|
|
271
|
+
hasCapability(server, "relatedTests")
|
|
272
|
+
) {
|
|
273
|
+
return [name, server];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getServerForWorkspaceAction(config: LspConfig, action: string): [string, ServerConfig] | null {
|
|
281
|
+
const entries = getLspServers(config);
|
|
282
|
+
if (entries.length === 0) return null;
|
|
283
|
+
|
|
284
|
+
if (action === "workspace_symbols") {
|
|
285
|
+
return entries[0];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (action === "flycheck" || action === "ssr" || action === "runnables" || action === "reload_workspace") {
|
|
289
|
+
return getRustServer(config);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function waitForDiagnostics(client: LspClient, uri: string, timeoutMs = 3000): Promise<Diagnostic[]> {
|
|
296
|
+
const start = Date.now();
|
|
297
|
+
while (Date.now() - start < timeoutMs) {
|
|
298
|
+
const diagnostics = client.diagnostics.get(uri);
|
|
299
|
+
if (diagnostics !== undefined) return diagnostics;
|
|
300
|
+
await sleep(100);
|
|
301
|
+
}
|
|
302
|
+
return client.diagnostics.get(uri) ?? [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Project type detection result */
|
|
306
|
+
interface ProjectType {
|
|
307
|
+
type: "rust" | "typescript" | "go" | "python" | "unknown";
|
|
308
|
+
command?: string[];
|
|
309
|
+
description: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Detect project type from root markers */
|
|
313
|
+
function detectProjectType(cwd: string): ProjectType {
|
|
314
|
+
// Check for Rust (Cargo.toml)
|
|
315
|
+
if (fs.existsSync(path.join(cwd, "Cargo.toml"))) {
|
|
316
|
+
return { type: "rust", command: ["cargo", "check", "--message-format=short"], description: "Rust (cargo check)" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Check for TypeScript (tsconfig.json)
|
|
320
|
+
if (fs.existsSync(path.join(cwd, "tsconfig.json"))) {
|
|
321
|
+
return { type: "typescript", command: ["npx", "tsc", "--noEmit"], description: "TypeScript (tsc --noEmit)" };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Check for Go (go.mod)
|
|
325
|
+
if (fs.existsSync(path.join(cwd, "go.mod"))) {
|
|
326
|
+
return { type: "go", command: ["go", "build", "./..."], description: "Go (go build)" };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Check for Python (pyproject.toml or pyrightconfig.json)
|
|
330
|
+
if (fs.existsSync(path.join(cwd, "pyproject.toml")) || fs.existsSync(path.join(cwd, "pyrightconfig.json"))) {
|
|
331
|
+
return { type: "python", command: ["pyright"], description: "Python (pyright)" };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { type: "unknown", description: "Unknown project type" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Run workspace diagnostics command and parse output */
|
|
338
|
+
async function runWorkspaceDiagnostics(
|
|
339
|
+
cwd: string,
|
|
340
|
+
config: LspConfig,
|
|
341
|
+
): Promise<{ output: string; projectType: ProjectType }> {
|
|
342
|
+
const projectType = detectProjectType(cwd);
|
|
343
|
+
|
|
344
|
+
// For Rust, use flycheck via rust-analyzer if available
|
|
345
|
+
if (projectType.type === "rust") {
|
|
346
|
+
const rustServer = getRustServer(config);
|
|
347
|
+
if (rustServer && hasCapability(rustServer[1], "flycheck")) {
|
|
348
|
+
const [_serverName, serverConfig] = rustServer;
|
|
349
|
+
try {
|
|
350
|
+
const client = await getOrCreateClient(serverConfig, cwd);
|
|
351
|
+
await rustAnalyzer.flycheck(client);
|
|
352
|
+
|
|
353
|
+
const collected: Array<{ filePath: string; diagnostic: Diagnostic }> = [];
|
|
354
|
+
for (const [diagUri, diags] of client.diagnostics.entries()) {
|
|
355
|
+
const relPath = path.relative(cwd, uriToFile(diagUri));
|
|
356
|
+
for (const diag of diags) {
|
|
357
|
+
collected.push({ filePath: relPath, diagnostic: diag });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (collected.length === 0) {
|
|
362
|
+
return { output: "No issues found", projectType };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const summary = formatDiagnosticsSummary(collected.map((d) => d.diagnostic));
|
|
366
|
+
const formatted = collected.slice(0, 50).map((d) => formatDiagnostic(d.diagnostic, d.filePath));
|
|
367
|
+
const more = collected.length > 50 ? `\n ... and ${collected.length - 50} more` : "";
|
|
368
|
+
return { output: `${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}${more}`, projectType };
|
|
369
|
+
} catch (err) {
|
|
370
|
+
logger.debug("LSP diagnostics failed, falling back to shell", { error: String(err) });
|
|
371
|
+
// Fall through to shell command
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Fall back to shell command
|
|
377
|
+
if (!projectType.command) {
|
|
378
|
+
return {
|
|
379
|
+
output: `Cannot detect project type. Supported: Rust (Cargo.toml), TypeScript (tsconfig.json), Go (go.mod), Python (pyproject.toml)`,
|
|
380
|
+
projectType,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const proc = Bun.spawn(projectType.command, {
|
|
386
|
+
cwd,
|
|
387
|
+
stdout: "pipe",
|
|
388
|
+
stderr: "pipe",
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
392
|
+
await proc.exited;
|
|
393
|
+
|
|
394
|
+
const combined = (stdout + stderr).trim();
|
|
395
|
+
if (!combined) {
|
|
396
|
+
return { output: "No issues found", projectType };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Limit output length
|
|
400
|
+
const lines = combined.split("\n");
|
|
401
|
+
if (lines.length > 50) {
|
|
402
|
+
return { output: `${lines.slice(0, 50).join("\n")}\n... and ${lines.length - 50} more lines`, projectType };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return { output: combined, projectType };
|
|
406
|
+
} catch (e) {
|
|
407
|
+
return { output: `Failed to run ${projectType.command.join(" ")}: ${e}`, projectType };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Result from getDiagnosticsForFile */
|
|
412
|
+
export interface FileDiagnosticsResult {
|
|
413
|
+
/** Name of the LSP server used (if available) */
|
|
414
|
+
server?: string;
|
|
415
|
+
/** Formatted diagnostic messages */
|
|
416
|
+
messages: string[];
|
|
417
|
+
/** Summary string (e.g., "2 error(s), 1 warning(s)") */
|
|
418
|
+
summary: string;
|
|
419
|
+
/** Whether there are any errors (severity 1) */
|
|
420
|
+
errored: boolean;
|
|
421
|
+
/** Whether the file was formatted */
|
|
422
|
+
formatter?: FileFormatResult;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Get diagnostics for a file using LSP or custom linter client.
|
|
427
|
+
*
|
|
428
|
+
* @param absolutePath - Absolute path to the file
|
|
429
|
+
* @param cwd - Working directory for LSP config resolution
|
|
430
|
+
* @param servers - Servers to query diagnostics for
|
|
431
|
+
* @returns Diagnostic results or undefined if no servers
|
|
432
|
+
*/
|
|
433
|
+
async function getDiagnosticsForFile(
|
|
434
|
+
absolutePath: string,
|
|
435
|
+
cwd: string,
|
|
436
|
+
servers: Array<[string, ServerConfig]>,
|
|
437
|
+
): Promise<FileDiagnosticsResult | undefined> {
|
|
438
|
+
if (servers.length === 0) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const uri = fileToUri(absolutePath);
|
|
443
|
+
const relPath = path.relative(cwd, absolutePath);
|
|
444
|
+
const allDiagnostics: Diagnostic[] = [];
|
|
445
|
+
const serverNames: string[] = [];
|
|
446
|
+
|
|
447
|
+
// Wait for diagnostics from all servers in parallel
|
|
448
|
+
const results = await Promise.allSettled(
|
|
449
|
+
servers.map(async ([serverName, serverConfig]) => {
|
|
450
|
+
// Use custom linter client if configured
|
|
451
|
+
if (serverConfig.createClient) {
|
|
452
|
+
const linterClient = getLinterClient(serverName, serverConfig, cwd);
|
|
453
|
+
const diagnostics = await linterClient.lint(absolutePath);
|
|
454
|
+
return { serverName, diagnostics };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Default: use LSP
|
|
458
|
+
const client = await getOrCreateClient(serverConfig, cwd);
|
|
459
|
+
// Content already synced + didSave sent, just wait for diagnostics
|
|
460
|
+
const diagnostics = await waitForDiagnostics(client, uri);
|
|
461
|
+
return { serverName, diagnostics };
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
for (const result of results) {
|
|
466
|
+
if (result.status === "fulfilled") {
|
|
467
|
+
serverNames.push(result.value.serverName);
|
|
468
|
+
allDiagnostics.push(...result.value.diagnostics);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (serverNames.length === 0) {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (allDiagnostics.length === 0) {
|
|
477
|
+
return {
|
|
478
|
+
server: serverNames.join(", "),
|
|
479
|
+
messages: [],
|
|
480
|
+
summary: "OK",
|
|
481
|
+
errored: false,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Deduplicate diagnostics by range + message (different servers might report similar issues)
|
|
486
|
+
const seen = new Set<string>();
|
|
487
|
+
const uniqueDiagnostics: Diagnostic[] = [];
|
|
488
|
+
for (const d of allDiagnostics) {
|
|
489
|
+
const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
|
|
490
|
+
if (!seen.has(key)) {
|
|
491
|
+
seen.add(key);
|
|
492
|
+
uniqueDiagnostics.push(d);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const formatted = uniqueDiagnostics.map((d) => formatDiagnostic(d, relPath));
|
|
497
|
+
const summary = formatDiagnosticsSummary(uniqueDiagnostics);
|
|
498
|
+
const hasErrors = uniqueDiagnostics.some((d) => d.severity === 1);
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
server: serverNames.join(", "),
|
|
502
|
+
messages: formatted,
|
|
503
|
+
summary,
|
|
504
|
+
errored: hasErrors,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export enum FileFormatResult {
|
|
509
|
+
UNCHANGED = "unchanged",
|
|
510
|
+
FORMATTED = "formatted",
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Default formatting options for LSP */
|
|
514
|
+
const DEFAULT_FORMAT_OPTIONS = {
|
|
515
|
+
tabSize: 3,
|
|
516
|
+
insertSpaces: true,
|
|
517
|
+
trimTrailingWhitespace: true,
|
|
518
|
+
insertFinalNewline: true,
|
|
519
|
+
trimFinalNewlines: true,
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Format content using LSP or custom linter client.
|
|
524
|
+
*
|
|
525
|
+
* @param absolutePath - Absolute path (for URI)
|
|
526
|
+
* @param content - Content to format
|
|
527
|
+
* @param cwd - Working directory for LSP config resolution
|
|
528
|
+
* @param servers - Servers to try formatting with
|
|
529
|
+
* @returns Formatted content, or original if no formatter available
|
|
530
|
+
*/
|
|
531
|
+
async function formatContent(
|
|
532
|
+
absolutePath: string,
|
|
533
|
+
content: string,
|
|
534
|
+
cwd: string,
|
|
535
|
+
servers: Array<[string, ServerConfig]>,
|
|
536
|
+
): Promise<string> {
|
|
537
|
+
if (servers.length === 0) {
|
|
538
|
+
return content;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const uri = fileToUri(absolutePath);
|
|
542
|
+
|
|
543
|
+
for (const [serverName, serverConfig] of servers) {
|
|
544
|
+
try {
|
|
545
|
+
// Use custom linter client if configured
|
|
546
|
+
if (serverConfig.createClient) {
|
|
547
|
+
const linterClient = getLinterClient(serverName, serverConfig, cwd);
|
|
548
|
+
return await linterClient.format(absolutePath, content);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Default: use LSP
|
|
552
|
+
const client = await getOrCreateClient(serverConfig, cwd);
|
|
553
|
+
|
|
554
|
+
const caps = client.serverCapabilities;
|
|
555
|
+
if (!caps?.documentFormattingProvider) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Request formatting (content already synced)
|
|
560
|
+
const edits = (await sendRequest(client, "textDocument/formatting", {
|
|
561
|
+
textDocument: { uri },
|
|
562
|
+
options: DEFAULT_FORMAT_OPTIONS,
|
|
563
|
+
})) as TextEdit[] | null;
|
|
564
|
+
|
|
565
|
+
if (!edits || edits.length === 0) {
|
|
566
|
+
return content;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Apply edits in-memory and return
|
|
570
|
+
return applyTextEditsToString(content, edits);
|
|
571
|
+
} catch {}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return content;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Options for creating the LSP writethrough callback */
|
|
578
|
+
export interface WritethroughOptions {
|
|
579
|
+
/** Whether to format the file using LSP after writing */
|
|
580
|
+
enableFormat?: boolean;
|
|
581
|
+
/** Whether to get LSP diagnostics after writing */
|
|
582
|
+
enableDiagnostics?: boolean;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Callback type for the LSP writethrough */
|
|
586
|
+
export type WritethroughCallback = (
|
|
587
|
+
dst: string,
|
|
588
|
+
content: string,
|
|
589
|
+
signal?: AbortSignal,
|
|
590
|
+
file?: BunFile,
|
|
591
|
+
) => Promise<FileDiagnosticsResult | undefined>;
|
|
592
|
+
|
|
593
|
+
/** No-op writethrough callback */
|
|
594
|
+
export async function writethroughNoop(
|
|
595
|
+
dst: string,
|
|
596
|
+
content: string,
|
|
597
|
+
_signal?: AbortSignal,
|
|
598
|
+
file?: BunFile,
|
|
599
|
+
): Promise<FileDiagnosticsResult | undefined> {
|
|
600
|
+
if (file) {
|
|
601
|
+
await file.write(content);
|
|
602
|
+
} else {
|
|
603
|
+
await Bun.write(dst, content);
|
|
604
|
+
}
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Create a writethrough callback for LSP aware write operations */
|
|
609
|
+
export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
|
|
610
|
+
const { enableFormat = false, enableDiagnostics = false } = options ?? {};
|
|
611
|
+
if (!enableFormat && !enableDiagnostics) {
|
|
612
|
+
return writethroughNoop;
|
|
613
|
+
}
|
|
614
|
+
return async (dst: string, content: string, signal?: AbortSignal, file?: BunFile) => {
|
|
615
|
+
const config = getConfig(cwd);
|
|
616
|
+
const servers = getServersForFile(config, dst);
|
|
617
|
+
if (servers.length === 0) {
|
|
618
|
+
return writethroughNoop(dst, content, signal, file);
|
|
619
|
+
}
|
|
620
|
+
const { lspServers, customLinterServers } = splitServers(servers);
|
|
621
|
+
|
|
622
|
+
let finalContent = content;
|
|
623
|
+
const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
|
|
624
|
+
const getWritePromise = once(() => writeContent(finalContent));
|
|
625
|
+
const useCustomFormatter = enableFormat && customLinterServers.length > 0;
|
|
626
|
+
|
|
627
|
+
let formatter: FileFormatResult | undefined;
|
|
628
|
+
let diagnostics: FileDiagnosticsResult | undefined;
|
|
629
|
+
try {
|
|
630
|
+
signal ??= AbortSignal.timeout(10_000);
|
|
631
|
+
await untilAborted(signal, async () => {
|
|
632
|
+
if (useCustomFormatter) {
|
|
633
|
+
// Custom linters (e.g. Biome CLI) require on-disk input.
|
|
634
|
+
await writeContent(content);
|
|
635
|
+
finalContent = await formatContent(dst, content, cwd, customLinterServers);
|
|
636
|
+
formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
|
|
637
|
+
await writeContent(finalContent);
|
|
638
|
+
await syncFileContent(dst, finalContent, cwd, lspServers);
|
|
639
|
+
} else {
|
|
640
|
+
// 1. Sync original content to LSP servers
|
|
641
|
+
await syncFileContent(dst, content, cwd, lspServers);
|
|
642
|
+
|
|
643
|
+
// 2. Format in-memory via LSP
|
|
644
|
+
if (enableFormat) {
|
|
645
|
+
finalContent = await formatContent(dst, content, cwd, lspServers);
|
|
646
|
+
formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 3. If formatted, sync formatted content to LSP servers
|
|
650
|
+
if (finalContent !== content) {
|
|
651
|
+
await syncFileContent(dst, finalContent, cwd, lspServers);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// 4. Write to disk
|
|
655
|
+
await getWritePromise();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 5. Notify saved to LSP servers
|
|
659
|
+
await notifyFileSaved(dst, cwd, lspServers);
|
|
660
|
+
|
|
661
|
+
// 6. Get diagnostics from all servers
|
|
662
|
+
if (enableDiagnostics) {
|
|
663
|
+
diagnostics = await getDiagnosticsForFile(dst, cwd, servers);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
} catch {
|
|
667
|
+
await getWritePromise();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (formatter !== undefined) {
|
|
671
|
+
diagnostics ??= {
|
|
672
|
+
server: servers.map(([name]) => name).join(", "),
|
|
673
|
+
messages: [],
|
|
674
|
+
summary: "OK",
|
|
675
|
+
errored: false,
|
|
676
|
+
};
|
|
677
|
+
diagnostics.formatter = formatter;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return diagnostics;
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Create an LSP tool */
|
|
685
|
+
export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolDetails, Theme> {
|
|
686
|
+
return {
|
|
687
|
+
name: "lsp",
|
|
688
|
+
label: "LSP",
|
|
689
|
+
description: lspDescription,
|
|
690
|
+
parameters: lspSchema,
|
|
691
|
+
renderCall,
|
|
692
|
+
renderResult,
|
|
693
|
+
execute: async (_toolCallId, params: LspParams, _signal) => {
|
|
694
|
+
const {
|
|
695
|
+
action,
|
|
696
|
+
file,
|
|
697
|
+
files,
|
|
698
|
+
line,
|
|
699
|
+
column,
|
|
700
|
+
end_line,
|
|
701
|
+
end_character,
|
|
702
|
+
query,
|
|
703
|
+
new_name,
|
|
704
|
+
replacement,
|
|
705
|
+
kind,
|
|
706
|
+
apply,
|
|
707
|
+
action_index,
|
|
708
|
+
include_declaration,
|
|
709
|
+
} = params;
|
|
710
|
+
|
|
711
|
+
const config = getConfig(cwd);
|
|
712
|
+
|
|
713
|
+
// Status action doesn't need a file
|
|
714
|
+
if (action === "status") {
|
|
715
|
+
const servers = Object.keys(config.servers);
|
|
716
|
+
const output =
|
|
717
|
+
servers.length > 0
|
|
718
|
+
? `Active language servers: ${servers.join(", ")}`
|
|
719
|
+
: "No language servers configured for this project";
|
|
720
|
+
return {
|
|
721
|
+
content: [{ type: "text", text: output }],
|
|
722
|
+
details: { action, success: true },
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Workspace diagnostics - check entire project
|
|
727
|
+
if (action === "workspace_diagnostics") {
|
|
728
|
+
const result = await runWorkspaceDiagnostics(cwd, config);
|
|
729
|
+
return {
|
|
730
|
+
content: [
|
|
731
|
+
{
|
|
732
|
+
type: "text",
|
|
733
|
+
text: `Workspace diagnostics (${result.projectType.description}):\n${result.output}`,
|
|
734
|
+
},
|
|
735
|
+
],
|
|
736
|
+
details: { action, success: true },
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Diagnostics can be batch or single-file - queries all applicable servers
|
|
741
|
+
if (action === "diagnostics") {
|
|
742
|
+
const targets = files?.length ? files : file ? [file] : null;
|
|
743
|
+
if (!targets) {
|
|
744
|
+
return {
|
|
745
|
+
content: [{ type: "text", text: "Error: file or files parameter required for diagnostics" }],
|
|
746
|
+
details: { action, success: false },
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const detailed = Boolean(files?.length);
|
|
751
|
+
const results: string[] = [];
|
|
752
|
+
const allServerNames = new Set<string>();
|
|
753
|
+
|
|
754
|
+
for (const target of targets) {
|
|
755
|
+
const resolved = resolveToCwd(target, cwd);
|
|
756
|
+
const servers = getServersForFile(config, resolved);
|
|
757
|
+
if (servers.length === 0) {
|
|
758
|
+
results.push(`${theme.status.error} ${target}: No language server found`);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const uri = fileToUri(resolved);
|
|
763
|
+
const relPath = path.relative(cwd, resolved);
|
|
764
|
+
const allDiagnostics: Diagnostic[] = [];
|
|
765
|
+
|
|
766
|
+
// Query all applicable servers for this file
|
|
767
|
+
for (const [serverName, serverConfig] of servers) {
|
|
768
|
+
allServerNames.add(serverName);
|
|
769
|
+
try {
|
|
770
|
+
if (serverConfig.createClient) {
|
|
771
|
+
const linterClient = getLinterClient(serverName, serverConfig, cwd);
|
|
772
|
+
const diagnostics = await linterClient.lint(resolved);
|
|
773
|
+
allDiagnostics.push(...diagnostics);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const client = await getOrCreateClient(serverConfig, cwd);
|
|
777
|
+
await refreshFile(client, resolved);
|
|
778
|
+
const diagnostics = await waitForDiagnostics(client, uri);
|
|
779
|
+
allDiagnostics.push(...diagnostics);
|
|
780
|
+
} catch {
|
|
781
|
+
// Server failed, continue with others
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Deduplicate diagnostics
|
|
786
|
+
const seen = new Set<string>();
|
|
787
|
+
const uniqueDiagnostics: Diagnostic[] = [];
|
|
788
|
+
for (const d of allDiagnostics) {
|
|
789
|
+
const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
|
|
790
|
+
if (!seen.has(key)) {
|
|
791
|
+
seen.add(key);
|
|
792
|
+
uniqueDiagnostics.push(d);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (!detailed && targets.length === 1) {
|
|
797
|
+
if (uniqueDiagnostics.length === 0) {
|
|
798
|
+
return {
|
|
799
|
+
content: [{ type: "text", text: "No diagnostics" }],
|
|
800
|
+
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const summary = formatDiagnosticsSummary(uniqueDiagnostics);
|
|
805
|
+
const formatted = uniqueDiagnostics.map((d) => formatDiagnostic(d, relPath));
|
|
806
|
+
const output = `${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}`;
|
|
807
|
+
return {
|
|
808
|
+
content: [{ type: "text", text: output }],
|
|
809
|
+
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (uniqueDiagnostics.length === 0) {
|
|
814
|
+
results.push(`${theme.status.success} ${relPath}: no issues`);
|
|
815
|
+
} else {
|
|
816
|
+
const summary = formatDiagnosticsSummary(uniqueDiagnostics);
|
|
817
|
+
results.push(`${theme.status.error} ${relPath}: ${summary}`);
|
|
818
|
+
for (const diag of uniqueDiagnostics) {
|
|
819
|
+
results.push(` ${formatDiagnostic(diag, relPath)}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
content: [{ type: "text", text: results.join("\n") }],
|
|
826
|
+
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const requiresFile =
|
|
831
|
+
!file &&
|
|
832
|
+
action !== "workspace_symbols" &&
|
|
833
|
+
action !== "flycheck" &&
|
|
834
|
+
action !== "ssr" &&
|
|
835
|
+
action !== "runnables" &&
|
|
836
|
+
action !== "reload_workspace";
|
|
837
|
+
|
|
838
|
+
if (requiresFile) {
|
|
839
|
+
return {
|
|
840
|
+
content: [{ type: "text", text: "Error: file parameter required for this action" }],
|
|
841
|
+
details: { action, success: false },
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const resolvedFile = file ? resolveToCwd(file, cwd) : null;
|
|
846
|
+
const serverInfo = resolvedFile
|
|
847
|
+
? getLspServerForFile(config, resolvedFile)
|
|
848
|
+
: getServerForWorkspaceAction(config, action);
|
|
849
|
+
|
|
850
|
+
if (!serverInfo) {
|
|
851
|
+
return {
|
|
852
|
+
content: [{ type: "text", text: "No language server found for this action" }],
|
|
853
|
+
details: { action, success: false },
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const [serverName, serverConfig] = serverInfo;
|
|
858
|
+
|
|
859
|
+
try {
|
|
860
|
+
const client = await getOrCreateClient(serverConfig, cwd);
|
|
861
|
+
let targetFile = resolvedFile;
|
|
862
|
+
if (action === "runnables" && !targetFile) {
|
|
863
|
+
targetFile = findFileForServer(cwd, serverConfig);
|
|
864
|
+
if (!targetFile) {
|
|
865
|
+
return {
|
|
866
|
+
content: [{ type: "text", text: "Error: no matching files found for runnables" }],
|
|
867
|
+
details: { action, serverName, success: false },
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (targetFile) {
|
|
873
|
+
await ensureFileOpen(client, targetFile);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const uri = targetFile ? fileToUri(targetFile) : "";
|
|
877
|
+
const position = { line: (line || 1) - 1, character: (column || 1) - 1 };
|
|
878
|
+
|
|
879
|
+
let output: string;
|
|
880
|
+
|
|
881
|
+
switch (action) {
|
|
882
|
+
// =====================================================================
|
|
883
|
+
// Standard LSP Operations
|
|
884
|
+
// =====================================================================
|
|
885
|
+
|
|
886
|
+
case "definition": {
|
|
887
|
+
const result = (await sendRequest(client, "textDocument/definition", {
|
|
888
|
+
textDocument: { uri },
|
|
889
|
+
position,
|
|
890
|
+
})) as Location | Location[] | LocationLink | LocationLink[] | null;
|
|
891
|
+
|
|
892
|
+
if (!result) {
|
|
893
|
+
output = "No definition found";
|
|
894
|
+
} else {
|
|
895
|
+
const raw = Array.isArray(result) ? result : [result];
|
|
896
|
+
const locations = raw.flatMap((loc) => {
|
|
897
|
+
if ("uri" in loc) {
|
|
898
|
+
return [loc as Location];
|
|
899
|
+
}
|
|
900
|
+
if ("targetUri" in loc) {
|
|
901
|
+
// Use targetSelectionRange (the precise identifier range) with fallback to targetRange
|
|
902
|
+
const link = loc as LocationLink;
|
|
903
|
+
return [{ uri: link.targetUri, range: link.targetSelectionRange ?? link.targetRange }];
|
|
904
|
+
}
|
|
905
|
+
return [];
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
if (locations.length === 0) {
|
|
909
|
+
output = "No definition found";
|
|
910
|
+
} else {
|
|
911
|
+
output = `Found ${locations.length} definition(s):\n${locations
|
|
912
|
+
.map((loc) => ` ${formatLocation(loc, cwd)}`)
|
|
913
|
+
.join("\n")}`;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
case "references": {
|
|
920
|
+
const result = (await sendRequest(client, "textDocument/references", {
|
|
921
|
+
textDocument: { uri },
|
|
922
|
+
position,
|
|
923
|
+
context: { includeDeclaration: include_declaration ?? true },
|
|
924
|
+
})) as Location[] | null;
|
|
925
|
+
|
|
926
|
+
if (!result || result.length === 0) {
|
|
927
|
+
output = "No references found";
|
|
928
|
+
} else {
|
|
929
|
+
const lines = result.map((loc) => ` ${formatLocation(loc, cwd)}`);
|
|
930
|
+
output = `Found ${result.length} reference(s):\n${lines.join("\n")}`;
|
|
931
|
+
}
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
case "hover": {
|
|
936
|
+
const result = (await sendRequest(client, "textDocument/hover", {
|
|
937
|
+
textDocument: { uri },
|
|
938
|
+
position,
|
|
939
|
+
})) as Hover | null;
|
|
940
|
+
|
|
941
|
+
if (!result || !result.contents) {
|
|
942
|
+
output = "No hover information";
|
|
943
|
+
} else {
|
|
944
|
+
output = extractHoverText(result.contents);
|
|
945
|
+
}
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
case "symbols": {
|
|
950
|
+
const result = (await sendRequest(client, "textDocument/documentSymbol", {
|
|
951
|
+
textDocument: { uri },
|
|
952
|
+
})) as (DocumentSymbol | SymbolInformation)[] | null;
|
|
953
|
+
|
|
954
|
+
if (!result || result.length === 0) {
|
|
955
|
+
output = "No symbols found";
|
|
956
|
+
} else if (!targetFile) {
|
|
957
|
+
return {
|
|
958
|
+
content: [{ type: "text", text: "Error: file parameter required for symbols" }],
|
|
959
|
+
details: { action, serverName, success: false },
|
|
960
|
+
};
|
|
961
|
+
} else {
|
|
962
|
+
const relPath = path.relative(cwd, targetFile);
|
|
963
|
+
// Check if hierarchical (DocumentSymbol) or flat (SymbolInformation)
|
|
964
|
+
if ("selectionRange" in result[0]) {
|
|
965
|
+
// Hierarchical
|
|
966
|
+
const lines = (result as DocumentSymbol[]).flatMap((s) => formatDocumentSymbol(s));
|
|
967
|
+
output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
|
|
968
|
+
} else {
|
|
969
|
+
// Flat
|
|
970
|
+
const lines = (result as SymbolInformation[]).map((s) => {
|
|
971
|
+
const line = s.location.range.start.line + 1;
|
|
972
|
+
const icon = symbolKindToIcon(s.kind);
|
|
973
|
+
return `${icon} ${s.name} @ line ${line}`;
|
|
974
|
+
});
|
|
975
|
+
output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
case "workspace_symbols": {
|
|
982
|
+
if (!query) {
|
|
983
|
+
return {
|
|
984
|
+
content: [{ type: "text", text: "Error: query parameter required for workspace_symbols" }],
|
|
985
|
+
details: { action, serverName, success: false },
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const result = (await sendRequest(client, "workspace/symbol", { query })) as
|
|
990
|
+
| SymbolInformation[]
|
|
991
|
+
| null;
|
|
992
|
+
|
|
993
|
+
if (!result || result.length === 0) {
|
|
994
|
+
output = `No symbols matching "${query}"`;
|
|
995
|
+
} else {
|
|
996
|
+
const lines = result.map((s) => formatSymbolInformation(s, cwd));
|
|
997
|
+
output = `Found ${result.length} symbol(s) matching "${query}":\n${lines
|
|
998
|
+
.map((l) => ` ${l}`)
|
|
999
|
+
.join("\n")}`;
|
|
1000
|
+
}
|
|
1001
|
+
break;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
case "rename": {
|
|
1005
|
+
if (!new_name) {
|
|
1006
|
+
return {
|
|
1007
|
+
content: [{ type: "text", text: "Error: new_name parameter required for rename" }],
|
|
1008
|
+
details: { action, serverName, success: false },
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const result = (await sendRequest(client, "textDocument/rename", {
|
|
1013
|
+
textDocument: { uri },
|
|
1014
|
+
position,
|
|
1015
|
+
newName: new_name,
|
|
1016
|
+
})) as WorkspaceEdit | null;
|
|
1017
|
+
|
|
1018
|
+
if (!result) {
|
|
1019
|
+
output = "Rename returned no edits";
|
|
1020
|
+
} else {
|
|
1021
|
+
const shouldApply = apply !== false;
|
|
1022
|
+
if (shouldApply) {
|
|
1023
|
+
const applied = await applyWorkspaceEdit(result, cwd);
|
|
1024
|
+
output = `Applied rename:\n${applied.map((a) => ` ${a}`).join("\n")}`;
|
|
1025
|
+
} else {
|
|
1026
|
+
const preview = formatWorkspaceEdit(result, cwd);
|
|
1027
|
+
output = `Rename preview:\n${preview.map((p) => ` ${p}`).join("\n")}`;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
case "actions": {
|
|
1034
|
+
if (!targetFile) {
|
|
1035
|
+
return {
|
|
1036
|
+
content: [{ type: "text", text: "Error: file parameter required for actions" }],
|
|
1037
|
+
details: { action, serverName, success: false },
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
await refreshFile(client, targetFile);
|
|
1042
|
+
const diagnostics = await waitForDiagnostics(client, uri);
|
|
1043
|
+
const endLine = (end_line ?? line ?? 1) - 1;
|
|
1044
|
+
const endCharacter = (end_character ?? column ?? 1) - 1;
|
|
1045
|
+
const range = { start: position, end: { line: endLine, character: endCharacter } };
|
|
1046
|
+
const relevantDiagnostics = diagnostics.filter(
|
|
1047
|
+
(d) => d.range.start.line <= range.end.line && d.range.end.line >= range.start.line,
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
const codeActionContext: { diagnostics: Diagnostic[]; only?: string[] } = {
|
|
1051
|
+
diagnostics: relevantDiagnostics,
|
|
1052
|
+
};
|
|
1053
|
+
if (kind) {
|
|
1054
|
+
codeActionContext.only = [kind];
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const result = (await sendRequest(client, "textDocument/codeAction", {
|
|
1058
|
+
textDocument: { uri },
|
|
1059
|
+
range,
|
|
1060
|
+
context: codeActionContext,
|
|
1061
|
+
})) as Array<CodeAction | Command> | null;
|
|
1062
|
+
|
|
1063
|
+
if (!result || result.length === 0) {
|
|
1064
|
+
output = "No code actions available";
|
|
1065
|
+
} else if (action_index !== undefined) {
|
|
1066
|
+
// Apply specific action
|
|
1067
|
+
if (action_index < 0 || action_index >= result.length) {
|
|
1068
|
+
return {
|
|
1069
|
+
content: [
|
|
1070
|
+
{
|
|
1071
|
+
type: "text",
|
|
1072
|
+
text: `Error: action_index ${action_index} out of range (0-${result.length - 1})`,
|
|
1073
|
+
},
|
|
1074
|
+
],
|
|
1075
|
+
details: { action, serverName, success: false },
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const isCommand = (candidate: CodeAction | Command): candidate is Command =>
|
|
1080
|
+
typeof (candidate as Command).command === "string";
|
|
1081
|
+
const isCodeAction = (candidate: CodeAction | Command): candidate is CodeAction =>
|
|
1082
|
+
!isCommand(candidate);
|
|
1083
|
+
const getCommandPayload = (
|
|
1084
|
+
candidate: CodeAction | Command,
|
|
1085
|
+
): { command: string; arguments?: unknown[] } | null => {
|
|
1086
|
+
if (isCommand(candidate)) {
|
|
1087
|
+
return { command: candidate.command, arguments: candidate.arguments };
|
|
1088
|
+
}
|
|
1089
|
+
if (candidate.command) {
|
|
1090
|
+
return { command: candidate.command.command, arguments: candidate.command.arguments };
|
|
1091
|
+
}
|
|
1092
|
+
return null;
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
const codeAction = result[action_index];
|
|
1096
|
+
|
|
1097
|
+
// Resolve if needed
|
|
1098
|
+
let resolvedAction = codeAction;
|
|
1099
|
+
if (
|
|
1100
|
+
isCodeAction(codeAction) &&
|
|
1101
|
+
!codeAction.edit &&
|
|
1102
|
+
codeAction.data &&
|
|
1103
|
+
client.serverCapabilities?.codeActionProvider
|
|
1104
|
+
) {
|
|
1105
|
+
const provider = client.serverCapabilities.codeActionProvider;
|
|
1106
|
+
if (typeof provider === "object" && provider.resolveProvider) {
|
|
1107
|
+
resolvedAction = (await sendRequest(client, "codeAction/resolve", codeAction)) as CodeAction;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (isCodeAction(resolvedAction) && resolvedAction.edit) {
|
|
1112
|
+
const applied = await applyWorkspaceEdit(resolvedAction.edit, cwd);
|
|
1113
|
+
output = `Applied "${codeAction.title}":\n${applied.map((a) => ` ${a}`).join("\n")}`;
|
|
1114
|
+
} else {
|
|
1115
|
+
const commandPayload = getCommandPayload(resolvedAction);
|
|
1116
|
+
if (commandPayload) {
|
|
1117
|
+
await sendRequest(client, "workspace/executeCommand", commandPayload);
|
|
1118
|
+
output = `Executed "${codeAction.title}"`;
|
|
1119
|
+
} else {
|
|
1120
|
+
output = `Code action "${codeAction.title}" has no edits or command to apply`;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
} else {
|
|
1124
|
+
// List available actions
|
|
1125
|
+
const lines = result.map((actionItem, i) => {
|
|
1126
|
+
if ("kind" in actionItem || "isPreferred" in actionItem || "edit" in actionItem) {
|
|
1127
|
+
const actionDetails = actionItem as CodeAction;
|
|
1128
|
+
const preferred = actionDetails.isPreferred ? " (preferred)" : "";
|
|
1129
|
+
const kindInfo = actionDetails.kind ? ` [${actionDetails.kind}]` : "";
|
|
1130
|
+
return ` [${i}] ${actionDetails.title}${kindInfo}${preferred}`;
|
|
1131
|
+
}
|
|
1132
|
+
return ` [${i}] ${actionItem.title}`;
|
|
1133
|
+
});
|
|
1134
|
+
output = `Available code actions:\n${lines.join(
|
|
1135
|
+
"\n",
|
|
1136
|
+
)}\n\nUse action_index parameter to apply a specific action.`;
|
|
1137
|
+
}
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
case "incoming_calls":
|
|
1142
|
+
case "outgoing_calls": {
|
|
1143
|
+
// First, prepare the call hierarchy item at the cursor position
|
|
1144
|
+
const prepareResult = (await sendRequest(client, "textDocument/prepareCallHierarchy", {
|
|
1145
|
+
textDocument: { uri },
|
|
1146
|
+
position,
|
|
1147
|
+
})) as CallHierarchyItem[] | null;
|
|
1148
|
+
|
|
1149
|
+
if (!prepareResult || prepareResult.length === 0) {
|
|
1150
|
+
output = "No callable symbol found at this position";
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const item = prepareResult[0];
|
|
1155
|
+
|
|
1156
|
+
if (action === "incoming_calls") {
|
|
1157
|
+
const calls = (await sendRequest(client, "callHierarchy/incomingCalls", { item })) as
|
|
1158
|
+
| CallHierarchyIncomingCall[]
|
|
1159
|
+
| null;
|
|
1160
|
+
|
|
1161
|
+
if (!calls || calls.length === 0) {
|
|
1162
|
+
output = `No callers found for "${item.name}"`;
|
|
1163
|
+
} else {
|
|
1164
|
+
const lines = calls.map((call) => {
|
|
1165
|
+
const loc = { uri: call.from.uri, range: call.from.selectionRange };
|
|
1166
|
+
const detail = call.from.detail ? ` (${call.from.detail})` : "";
|
|
1167
|
+
return ` ${call.from.name}${detail} @ ${formatLocation(loc, cwd)}`;
|
|
1168
|
+
});
|
|
1169
|
+
output = `Found ${calls.length} caller(s) of "${item.name}":\n${lines.join("\n")}`;
|
|
1170
|
+
}
|
|
1171
|
+
} else {
|
|
1172
|
+
const calls = (await sendRequest(client, "callHierarchy/outgoingCalls", { item })) as
|
|
1173
|
+
| CallHierarchyOutgoingCall[]
|
|
1174
|
+
| null;
|
|
1175
|
+
|
|
1176
|
+
if (!calls || calls.length === 0) {
|
|
1177
|
+
output = `"${item.name}" doesn't call any functions`;
|
|
1178
|
+
} else {
|
|
1179
|
+
const lines = calls.map((call) => {
|
|
1180
|
+
const loc = { uri: call.to.uri, range: call.to.selectionRange };
|
|
1181
|
+
const detail = call.to.detail ? ` (${call.to.detail})` : "";
|
|
1182
|
+
return ` ${call.to.name}${detail} @ ${formatLocation(loc, cwd)}`;
|
|
1183
|
+
});
|
|
1184
|
+
output = `"${item.name}" calls ${calls.length} function(s):\n${lines.join("\n")}`;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
break;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// =====================================================================
|
|
1191
|
+
// Rust-Analyzer Specific Operations
|
|
1192
|
+
// =====================================================================
|
|
1193
|
+
|
|
1194
|
+
case "flycheck": {
|
|
1195
|
+
if (!hasCapability(serverConfig, "flycheck")) {
|
|
1196
|
+
return {
|
|
1197
|
+
content: [{ type: "text", text: "Error: flycheck requires rust-analyzer" }],
|
|
1198
|
+
details: { action, serverName, success: false },
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
await rustAnalyzer.flycheck(client, resolvedFile ?? undefined);
|
|
1203
|
+
const collected: Array<{ filePath: string; diagnostic: Diagnostic }> = [];
|
|
1204
|
+
for (const [diagUri, diags] of client.diagnostics.entries()) {
|
|
1205
|
+
const relPath = path.relative(cwd, uriToFile(diagUri));
|
|
1206
|
+
for (const diag of diags) {
|
|
1207
|
+
collected.push({ filePath: relPath, diagnostic: diag });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (collected.length === 0) {
|
|
1212
|
+
output = "Flycheck: no issues found";
|
|
1213
|
+
} else {
|
|
1214
|
+
const summary = formatDiagnosticsSummary(collected.map((d) => d.diagnostic));
|
|
1215
|
+
const formatted = collected.slice(0, 20).map((d) => formatDiagnostic(d.diagnostic, d.filePath));
|
|
1216
|
+
const more = collected.length > 20 ? `\n ... and ${collected.length - 20} more` : "";
|
|
1217
|
+
output = `Flycheck ${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}${more}`;
|
|
1218
|
+
}
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
case "expand_macro": {
|
|
1223
|
+
if (!hasCapability(serverConfig, "expandMacro")) {
|
|
1224
|
+
return {
|
|
1225
|
+
content: [{ type: "text", text: "Error: expand_macro requires rust-analyzer" }],
|
|
1226
|
+
details: { action, serverName, success: false },
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (!targetFile) {
|
|
1231
|
+
return {
|
|
1232
|
+
content: [{ type: "text", text: "Error: file parameter required for expand_macro" }],
|
|
1233
|
+
details: { action, serverName, success: false },
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const result = await rustAnalyzer.expandMacro(client, targetFile, line || 1, column || 1);
|
|
1238
|
+
if (!result) {
|
|
1239
|
+
output = "No macro expansion at this position";
|
|
1240
|
+
} else {
|
|
1241
|
+
output = `Macro: ${result.name}\n\nExpansion:\n${result.expansion}`;
|
|
1242
|
+
}
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
case "ssr": {
|
|
1247
|
+
if (!hasCapability(serverConfig, "ssr")) {
|
|
1248
|
+
return {
|
|
1249
|
+
content: [{ type: "text", text: "Error: ssr requires rust-analyzer" }],
|
|
1250
|
+
details: { action, serverName, success: false },
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (!query) {
|
|
1255
|
+
return {
|
|
1256
|
+
content: [{ type: "text", text: "Error: query parameter (pattern) required for ssr" }],
|
|
1257
|
+
details: { action, serverName, success: false },
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if (!replacement) {
|
|
1262
|
+
return {
|
|
1263
|
+
content: [{ type: "text", text: "Error: replacement parameter required for ssr" }],
|
|
1264
|
+
details: { action, serverName, success: false },
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const shouldApply = apply === true;
|
|
1269
|
+
const result = await rustAnalyzer.ssr(client, query, replacement, !shouldApply);
|
|
1270
|
+
|
|
1271
|
+
if (shouldApply) {
|
|
1272
|
+
const applied = await applyWorkspaceEdit(result, cwd);
|
|
1273
|
+
output =
|
|
1274
|
+
applied.length > 0
|
|
1275
|
+
? `Applied SSR:\n${applied.map((a) => ` ${a}`).join("\n")}`
|
|
1276
|
+
: "SSR: no matches found";
|
|
1277
|
+
} else {
|
|
1278
|
+
const preview = formatWorkspaceEdit(result, cwd);
|
|
1279
|
+
output =
|
|
1280
|
+
preview.length > 0
|
|
1281
|
+
? `SSR preview:\n${preview.map((p) => ` ${p}`).join("\n")}`
|
|
1282
|
+
: "SSR: no matches found";
|
|
1283
|
+
}
|
|
1284
|
+
break;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
case "runnables": {
|
|
1288
|
+
if (!hasCapability(serverConfig, "runnables")) {
|
|
1289
|
+
return {
|
|
1290
|
+
content: [{ type: "text", text: "Error: runnables requires rust-analyzer" }],
|
|
1291
|
+
details: { action, serverName, success: false },
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
if (!targetFile) {
|
|
1296
|
+
return {
|
|
1297
|
+
content: [{ type: "text", text: "Error: file parameter required for runnables" }],
|
|
1298
|
+
details: { action, serverName, success: false },
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const result = await rustAnalyzer.runnables(client, targetFile, line);
|
|
1303
|
+
if (result.length === 0) {
|
|
1304
|
+
output = "No runnables found";
|
|
1305
|
+
} else {
|
|
1306
|
+
const lines = result.map((r) => {
|
|
1307
|
+
const args = r.args?.cargoArgs?.join(" ") || "";
|
|
1308
|
+
return ` [${r.kind}] ${r.label}${args ? ` (cargo ${args})` : ""}`;
|
|
1309
|
+
});
|
|
1310
|
+
output = `Found ${result.length} runnable(s):\n${lines.join("\n")}`;
|
|
1311
|
+
}
|
|
1312
|
+
break;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
case "related_tests": {
|
|
1316
|
+
if (!hasCapability(serverConfig, "relatedTests")) {
|
|
1317
|
+
return {
|
|
1318
|
+
content: [{ type: "text", text: "Error: related_tests requires rust-analyzer" }],
|
|
1319
|
+
details: { action, serverName, success: false },
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (!targetFile) {
|
|
1324
|
+
return {
|
|
1325
|
+
content: [{ type: "text", text: "Error: file parameter required for related_tests" }],
|
|
1326
|
+
details: { action, serverName, success: false },
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const result = await rustAnalyzer.relatedTests(client, targetFile, line || 1, column || 1);
|
|
1331
|
+
if (result.length === 0) {
|
|
1332
|
+
output = "No related tests found";
|
|
1333
|
+
} else {
|
|
1334
|
+
output = `Found ${result.length} related test(s):\n${result.map((t) => ` ${t}`).join("\n")}`;
|
|
1335
|
+
}
|
|
1336
|
+
break;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
case "reload_workspace": {
|
|
1340
|
+
await rustAnalyzer.reloadWorkspace(client);
|
|
1341
|
+
output = "Workspace reloaded successfully";
|
|
1342
|
+
break;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
default:
|
|
1346
|
+
output = `Unknown action: ${action}`;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
return {
|
|
1350
|
+
content: [{ type: "text", text: output }],
|
|
1351
|
+
details: { serverName, action, success: true },
|
|
1352
|
+
};
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1355
|
+
return {
|
|
1356
|
+
content: [{ type: "text", text: `LSP error: ${errorMessage}` }],
|
|
1357
|
+
details: { serverName, action, success: false },
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
},
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
export const lspTool = createLspTool(process.cwd());
|