Merge pull request #10 from bnema/feat/lsp-linter-support
feat(agent): add LSP and linter support
36 files · +4988 -41
M README.md +11 -6
| @@ -19,7 +19,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||
| 19 | 19 | ||
| 20 | 20 | - one static binary. | |
| 21 | 21 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | |
| 22 | - | - four tools (read, write, edit, bash). | |
| 22 | + | - five core built-in tools (read, write, edit, bash, and lsp); the conditional `skill` tool is available when skills are enabled. | |
| 23 | 23 | - three run modes (interactive tui, print, json). | |
| 24 | 24 | - built-in telegram bot. | |
| 25 | 25 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md). | |
| @@ -219,7 +219,8 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 219 | 219 | | `--no-session` | Don't read or write session files. | | |
| 220 | 220 | | `--cwd <path>` | Use `<path>` as the working directory. | | |
| 221 | 221 | | `--no-tools` | Disable all tools. | | |
| 222 | - | | `--tools <csv>` | Only enable the listed tools. | | |
| 222 | + | | `--no-lsp` | Disable the built-in LSP/linter tool for this run. | | |
| 223 | + | | `--tools <csv>` | Only enable the listed tools. Include `lsp` to select it explicitly. | | |
| 223 | 224 | | `--max-steps <n>` | Cap agent loop iterations (default 50). | | |
| 224 | 225 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | |
| 225 | 226 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | |
| @@ -232,8 +233,9 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 232 | 233 | - `write`: create or overwrite files, making parent directories as needed. | |
| 233 | 234 | - `edit`: one or more exact-match replacements in an existing file. | |
| 234 | 235 | - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | |
| 236 | + | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | |
| 235 | 237 | ||
| 236 | - | When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd. | |
| 238 | + | When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. | |
| 237 | 239 | ||
| 238 | 240 | ## Modes | |
| 239 | 241 | ||
| @@ -290,7 +292,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 290 | 292 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 291 | 293 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 292 | 294 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | |
| 293 | - | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-swarm, fast mode, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | |
| 295 | + | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-swarm, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | |
| 294 | 296 | | `/clear` | Clear the chat transcript. | | |
| 295 | 297 | | `/exit` | Exit zot. | | |
| 296 | 298 | ||
| @@ -399,6 +401,8 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 399 | 401 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | |
| 400 | 402 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details. | |
| 401 | 403 | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | |
| 404 | + | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | |
| 405 | + | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | |
| 402 | 406 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zot automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | |
| 403 | 407 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | |
| 404 | 408 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | |
| @@ -876,7 +880,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 876 | 880 | | `ctrl+d` | Exit on empty input. | | |
| 877 | 881 | | `ctrl+l` | Redraw the screen. | | |
| 878 | 882 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | |
| 879 | - | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | |
| 883 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). | | |
| 880 | 884 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | |
| 881 | 885 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| 882 | 886 | ||
| @@ -1038,12 +1042,13 @@ packages/provider/auth/ credential store, api-key probe, oauth, lo | |||
| 1038 | 1042 | packages/core/ agent loop, sessions, cost tracking, compaction | |
| 1039 | 1043 | packages/tui/ terminal raw-mode, input parser, editor, renderer, markdown, view | |
| 1040 | 1044 | packages/agent/ cli wiring, arg parsing, system prompt, config | |
| 1045 | + | packages/agent/lsp/ LSP clients, server discovery, linters, diagnostics | |
| 1041 | 1046 | packages/agent/extensions/ extension subprocess manager | |
| 1042 | 1047 | packages/agent/extproto/ extension wire-format types | |
| 1043 | 1048 | packages/agent/modes/ interactive tui, print, json, dialogs | |
| 1044 | 1049 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | |
| 1045 | 1050 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | |
| 1046 | - | packages/agent/tools/ read, write, edit, bash, sandbox | |
| 1051 | + | packages/agent/tools/ read, write, edit, bash, lsp, sandbox | |
| 1047 | 1052 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 1048 | 1053 | packages/agent/subagents/ named markdown profile discovery and dispatch | |
| 1049 | 1054 | packages/agent/swarm/ background subagent runtime | |
A docs/lsp.md +147 -0
| @@ -0,0 +1,147 @@ | |||
| 1 | + | # LSP and linter support | |
| 2 | + | ||
| 3 | + | zot exposes a built-in `lsp` tool for code intelligence and diagnostics. It | |
| 4 | + | communicates with stdio language servers through JSON-RPC and can also run | |
| 5 | + | configured command-line linters without a shell. | |
| 6 | + | ||
| 7 | + | ## Configuration | |
| 8 | + | ||
| 9 | + | zot loads JSON configuration from the following layers, in increasing | |
| 10 | + | precedence: | |
| 11 | + | ||
| 12 | + | - `$ZOT_HOME/lsp.json` | |
| 13 | + | - `<cwd>/.pi/lsp.json` | |
| 14 | + | - `<cwd>/.omp/lsp.json` | |
| 15 | + | - `<cwd>/lsp.json`, `<cwd>/.lsp.json`, and `<cwd>/.zot/lsp.json` | |
| 16 | + | ||
| 17 | + | A file may put providers below `servers`, `lspServers`, or `linters`, use a | |
| 18 | + | flat provider map, or use the `providers` array: | |
| 19 | + | ||
| 20 | + | ```json | |
| 21 | + | { | |
| 22 | + | "servers": { | |
| 23 | + | "gopls": { | |
| 24 | + | "command": "gopls", | |
| 25 | + | "fileTypes": [".go"], | |
| 26 | + | "rootMarkers": ["go.mod", "go.work"] | |
| 27 | + | }, | |
| 28 | + | "eslint": { | |
| 29 | + | "kind": "cli", | |
| 30 | + | "command": "eslint", | |
| 31 | + | "args": ["--format", "json"], | |
| 32 | + | "parser": "eslint-json", | |
| 33 | + | "mode": "files", | |
| 34 | + | "fileTypes": [".js", ".jsx", ".ts", ".tsx"], | |
| 35 | + | "rootMarkers": ["eslint.config.js", ".eslintrc"] | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ``` | |
| 40 | + | ||
| 41 | + | The `providers` array accepts built-in names and objects with `id`, `kind`, | |
| 42 | + | `command`, `args`, selectors, root markers, and nested `lsp`/`cli` options. | |
| 43 | + | Unknown provider metadata is ignored. Set `autoDetect` to `false` to | |
| 44 | + | keep only providers named or defined by the configuration file. | |
| 45 | + | ||
| 46 | + | Server overrides merge onto zot's built-in definitions. Built-ins cover common | |
| 47 | + | Go, Rust, TypeScript/JavaScript, Python, C/C++, YAML, JSON, and shell servers, | |
| 48 | + | plus ESLint, golangci-lint, and Ruff CLI providers. A provider is selected | |
| 49 | + | when it is enabled and its file type/root markers match the workspace. Command | |
| 50 | + | lookup prefers project-local bin directories and then `PATH`; missing commands | |
| 51 | + | are reported by `status` or the request result. Set `disabled` to `true` to | |
| 52 | + | turn off a provider. | |
| 53 | + | ||
| 54 | + | Useful server fields are: | |
| 55 | + | ||
| 56 | + | | Field | Meaning | | |
| 57 | + | | --- | --- | | |
| 58 | + | | `kind` | `lsp` (default) or `cli` | | |
| 59 | + | | `command`, `args` | Executable and arguments | | |
| 60 | + | | `fileTypes` | Extensions, basenames, or language IDs handled by the provider | | |
| 61 | + | | `languageId` | Optional LSP language ID sent on `textDocument/didOpen`; inferred when omitted | | |
| 62 | + | | `rootMarkers` | Files identifying a workspace root | | |
| 63 | + | | `isLinter` | LSP provider used for diagnostics, not navigation/refactors | | |
| 64 | + | | `parser` | CLI output parser: `eslint-json`, `golangci-lint-json`, `ruff-json`, `sarif`, or generic | | |
| 65 | + | | `mode` | CLI provider scope: `files` or `workspace` | | |
| 66 | + | | `settings` | Values returned for `workspace/configuration` requests | | |
| 67 | + | | `initializationOptions` | Values sent during LSP initialization | | |
| 68 | + | | `env` | Extra environment variables for the child process | | |
| 69 | + | ||
| 70 | + | CLI providers may use `{files}` as an argument or inside an argument. It is | |
| 71 | + | expanded to project-relative paths touched by the diagnostics request; an | |
| 72 | + | embedded placeholder produces one argument per path. CLI output is capped | |
| 73 | + | before parsing. | |
| 74 | + | ||
| 75 | + | ## Tool actions | |
| 76 | + | ||
| 77 | + | The model calls `lsp` with an `action` and an optional `path`, `line`, `column`, | |
| 78 | + | `server`, or `query`: | |
| 79 | + | ||
| 80 | + | - `diagnostics` — merge LSP and CLI diagnostics for a file, glob, or the whole workspace. `run_cli:false` skips command-line linters. | |
| 81 | + | - `definition`, `type_definition`, `implementation`, `references`, `hover` — query a language server at a 1-based line and column. | |
| 82 | + | - `symbols` — return document symbols, or workspace symbols when `query` is supplied and `path` is omitted or `*`. | |
| 83 | + | - `rename` — request a `WorkspaceEdit`; applies it by default, or previews it with `apply:false`. | |
| 84 | + | - `code_actions` — list actions, or apply returned edits with `apply:true`. | |
| 85 | + | - `status` — show configured providers, running state, and diagnostic counts without starting servers. | |
| 86 | + | - `reload` — stop cached LSP processes and reload configuration. | |
| 87 | + | - `capabilities` — inspect initialize capabilities for one or all servers. | |
| 88 | + | - `request` — send a raw JSON-RPC method with JSON `params`. | |
| 89 | + | ||
| 90 | + | `--no-lsp` disables the tool for one run. `--tools lsp` selects it when an | |
| 91 | + | explicit tool list is used. | |
| 92 | + | ||
| 93 | + | ## Diagnostics and context limits | |
| 94 | + | ||
| 95 | + | Diagnostics are deduplicated across providers using path, severity, code, | |
| 96 | + | start position, and normalized message. Results are ordered by severity and | |
| 97 | + | location. Repeated | |
| 98 | + | identical messages are represented as one occurrence group with example paths | |
| 99 | + | or lines. Cascades beneath missing-module errors are grouped as `SECONDARY` | |
| 100 | + | items so the root import problem remains visible without repeating a large | |
| 101 | + | error fan-out. | |
| 102 | + | ||
| 103 | + | The model-facing summary has a hard diagnostic cap (50 by default, with a | |
| 104 | + | smaller eight-item summary used after writes and edits). Messages are | |
| 105 | + | whitespace-compacted and clipped before formatting. Slow write-time checks are | |
| 106 | + | bounded to a short two-second budget; a successful file write or edit is never | |
| 107 | + | turned into a failed mutation because an LSP or linter is unavailable. | |
| 108 | + | ||
| 109 | + | Write diagnostics are enabled by default when LSP is enabled. Edit diagnostics | |
| 110 | + | are disabled by default to keep multi-edit sequences compact. The persisted | |
| 111 | + | configuration keys are `lsp_diagnostics_on_write` and | |
| 112 | + | `lsp_diagnostics_on_edit` when an embedding needs to override those defaults. | |
| 113 | + | Repeated post-write diagnostics are tracked per file by severity/code/message | |
| 114 | + | identity, so a line shift does not replay the same error. Clearing a file's | |
| 115 | + | diagnostics allows a later recurrence to be surfaced again. | |
| 116 | + | ||
| 117 | + | ## Settings | |
| 118 | + | ||
| 119 | + | `/settings` provides two independent switches, both enabled by default: | |
| 120 | + | ||
| 121 | + | - **lsp in main session** (`lsp_enabled`) controls the main agent and applies | |
| 122 | + | immediately to the live tool registry. | |
| 123 | + | - **lsp in sub-agents** (`subagent_lsp_enabled`) controls newly spawned swarm | |
| 124 | + | child processes. | |
| 125 | + | ||
| 126 | + | The sub-agent setting does not grant access to a child whose profile or | |
| 127 | + | explicit tool list omits `lsp`. Both settings can also be controlled through | |
| 128 | + | `$ZOT_HOME/config.json`. | |
| 129 | + | ||
| 130 | + | ## Safety and lifecycle | |
| 131 | + | ||
| 132 | + | Language servers and linters run as local child processes. Project and | |
| 133 | + | global LSP configuration is executable local configuration: review configured | |
| 134 | + | commands, arguments, environment variables, and workspace-local binaries before | |
| 135 | + | using it. zot does not add a separate trust prompt or sandbox around those child | |
| 136 | + | processes; it does avoid shell expansion and keeps returned workspace edits | |
| 137 | + | inside the session workspace. The `lsp` tool never executes configured commands | |
| 138 | + | through a shell. Workspace edits returned by | |
| 139 | + | rename and code actions are restricted to the session workspace, reject | |
| 140 | + | resource operations, validate UTF-16 LSP positions, and write through a | |
| 141 | + | temporary file before replacement. Server-initiated `workspace/applyEdit` | |
| 142 | + | requests use the same checks. | |
| 143 | + | ||
| 144 | + | LSP clients are cached per workspace and provider for the lifetime of the zot | |
| 145 | + | process. `reload` and process shutdown terminate cached children. Server | |
| 146 | + | stderr is kept out of model-facing tool results; diagnostics and protocol | |
| 147 | + | errors are returned through the established tool result channel. | |
M docs/subagents.md +3 -3
| @@ -23,7 +23,7 @@ Profiles are read-only inputs. zot does not execute files from these directories | |||
| 23 | 23 | --- | |
| 24 | 24 | name: reviewer | |
| 25 | 25 | description: Read-only code reviewer | |
| 26 | - | tools: read, write, edit, bash | |
| 26 | + | tools: read | |
| 27 | 27 | model: openai-codex/gpt-5.6-luna | |
| 28 | 28 | thinking: max | |
| 29 | 29 | systemPromptMode: replace | |
| @@ -40,13 +40,13 @@ Supported metadata: | |||
| 40 | 40 | |---|---| | |
| 41 | 41 | | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. | | |
| 42 | 42 | | `description` | Short description shown to the main agent. | | |
| 43 | - | | `tools` | Comma-separated or list-form tool names. zot currently enforces its own `read`, `write`, `edit`, and `bash` registry; unknown names do not grant capabilities. | | |
| 43 | + | | `tools` | Comma-separated or list-form tool names. zot enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | | |
| 44 | 44 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | |
| 45 | 45 | | `provider` | Optional separate provider ID for a model without a provider prefix. | | |
| 46 | 46 | | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. | | |
| 47 | 47 | | `systemPromptMode` | `append` (default) or `replace`. | | |
| 48 | 48 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | |
| 49 | - | | `inheritSkills` | Set to `false` to omit skill discovery from the child. | | |
| 49 | + | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | |
| 50 | 50 | ||
| 51 | 51 | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | |
| 52 | 52 | ||
M examples/extensions/mcp-bridge/bridge.go +1 -1
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | // mcp__<server>__<tool> | |
| 6 | 6 | // | |
| 7 | 7 | // The double underscore separates the server name from the tool name, | |
| 8 | - | // avoiding collisions with zot's built-in tools (read, write, edit, bash, skill). | |
| 8 | + | // avoiding collisions with zot's built-in tools (read, write, edit, bash, lsp, skill). | |
| 9 | 9 | package main | |
| 10 | 10 | ||
| 11 | 11 | import ( | |
M packages/agent/args.go +5 -1
| @@ -63,6 +63,7 @@ type Args struct { | |||
| 63 | 63 | ||
| 64 | 64 | CWD string | |
| 65 | 65 | NoTools bool | |
| 66 | + | NoLSP bool | |
| 66 | 67 | Tools []string | |
| 67 | 68 | MaxSteps int | |
| 68 | 69 | ||
| @@ -177,6 +178,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 177 | 178 | a.NoSess = true | |
| 178 | 179 | case "--no-tools": | |
| 179 | 180 | a.NoTools = true | |
| 181 | + | case "--no-lsp": | |
| 182 | + | a.NoLSP = true | |
| 180 | 183 | case "--list-models": | |
| 181 | 184 | a.ListModels = true | |
| 182 | 185 | case "--experimental-oauth": | |
| @@ -459,7 +462,8 @@ func PrintHelp(version string) { | |||
| 459 | 462 | section("workspace, tools, skills", | |
| 460 | 463 | row{"--cwd PATH", "treat PATH as the working directory"}, | |
| 461 | 464 | row{"--no-tools", "disable all tools"}, | |
| 462 | - | row{"--tools csv", "only enable the listed tools"}, | |
| 465 | + | row{"--no-lsp", "disable the built-in LSP/linter tool"}, | |
| 466 | + | row{"--tools csv", "only enable the listed tools (include lsp explicitly)"}, | |
| 463 | 467 | row{"--no-yolo", "ask before running every tool call"}, | |
| 464 | 468 | row{"-y, --yes", "accept zot run consent without prompting"}, | |
| 465 | 469 | row{"--no-ext", "skip extension discovery for this run"}, | |
M packages/agent/args_test.go +10 -0
| @@ -12,6 +12,16 @@ func TestParseArgsSubagentAndReasoning(t *testing.T) { | |||
| 12 | 12 | } | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | + | func TestParseArgsNoLSP(t *testing.T) { | |
| 16 | + | args, err := ParseArgs([]string{"--no-lsp"}) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | if !args.NoLSP { | |
| 21 | + | t.Fatal("--no-lsp did not disable LSP") | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 15 | 25 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | |
| 16 | 26 | args, err := ParseArgs([]string{"--temperature", "0"}) | |
| 17 | 27 | if err != nil { | |
M packages/agent/botcmd.go +2 -0
| @@ -218,6 +218,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error { | |||
| 218 | 218 | if err != nil { | |
| 219 | 219 | return err | |
| 220 | 220 | } | |
| 221 | + | defer closeResolvedLSP(resolved) | |
| 221 | 222 | ||
| 222 | 223 | if ok, cerr := spec.configured(ZotHome()); cerr != nil { | |
| 223 | 224 | return cerr | |
| @@ -269,6 +270,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error { | |||
| 269 | 270 | if err != nil { | |
| 270 | 271 | return err | |
| 271 | 272 | } | |
| 273 | + | defer closeResolvedLSP(next) | |
| 272 | 274 | agent.Client = next.NewClient() | |
| 273 | 275 | agent.Model = next.Model | |
| 274 | 276 | runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD) | |
M packages/agent/build.go +44 -5
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | 11 | zotdocs "github.com/patriceckhart/zot" | |
| 12 | + | "github.com/patriceckhart/zot/packages/agent/lsp" | |
| 12 | 13 | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 13 | 14 | "github.com/patriceckhart/zot/packages/agent/subagents" | |
| 14 | 15 | "github.com/patriceckhart/zot/packages/agent/tools" | |
| @@ -621,7 +622,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 621 | 622 | if args.PermissionSet != nil { | |
| 622 | 623 | sandbox.SetPermissions(args.PermissionSet) | |
| 623 | 624 | } | |
| 624 | - | reg := buildToolRegistry(args, args.CWD, sandbox) | |
| 625 | + | subagentSession := args.Mode == ModeSwarmAgent || strings.TrimSpace(args.Subagent) != "" | |
| 626 | + | lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession) | |
| 627 | + | reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession)) | |
| 625 | 628 | ||
| 626 | 629 | docsDir, _ := zotdocs.EnsureInstalled(ZotHome()) | |
| 627 | 630 | ||
| @@ -1028,6 +1031,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 1028 | 1031 | v.Sandbox = s | |
| 1029 | 1032 | case *tools.BashTool: | |
| 1030 | 1033 | v.Sandbox = s | |
| 1034 | + | case *tools.LSPTool: | |
| 1035 | + | v.Sandbox = s | |
| 1031 | 1036 | } | |
| 1032 | 1037 | _ = name | |
| 1033 | 1038 | } | |
| @@ -1044,16 +1049,29 @@ func (r Resolved) NewAgent() *core.Agent { | |||
| 1044 | 1049 | return a | |
| 1045 | 1050 | } | |
| 1046 | 1051 | ||
| 1047 | - | func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry { | |
| 1052 | + | func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry { | |
| 1048 | 1053 | if args.NoTools { | |
| 1049 | 1054 | return core.Registry{} | |
| 1050 | 1055 | } | |
| 1056 | + | var manager *lsp.Manager | |
| 1057 | + | if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) { | |
| 1058 | + | options := lsp.ManagerOptions{} | |
| 1059 | + | if sandbox != nil { | |
| 1060 | + | options.CheckWritePath = sandbox.CheckWritePath | |
| 1061 | + | } | |
| 1062 | + | manager = lsp.NewManagerWithOptions(options) | |
| 1063 | + | } | |
| 1051 | 1064 | all := map[string]core.Tool{ | |
| 1052 | 1065 | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | |
| 1053 | - | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox}, | |
| 1054 | - | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox}, | |
| 1066 | + | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | |
| 1067 | + | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1055 | 1068 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1056 | 1069 | } | |
| 1070 | + | if manager != nil { | |
| 1071 | + | lspTool := tools.NewLSPTool(cwd, manager) | |
| 1072 | + | lspTool.Sandbox = sandbox | |
| 1073 | + | all["lsp"] = lspTool | |
| 1074 | + | } | |
| 1057 | 1075 | reg := core.Registry{} | |
| 1058 | 1076 | if len(args.Tools) == 0 { | |
| 1059 | 1077 | for _, t := range all { | |
| @@ -1069,8 +1087,29 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis | |||
| 1069 | 1087 | return reg | |
| 1070 | 1088 | } | |
| 1071 | 1089 | ||
| 1090 | + | func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool { | |
| 1091 | + | if len(args.Tools) == 0 { | |
| 1092 | + | return true | |
| 1093 | + | } | |
| 1094 | + | for _, name := range args.Tools { | |
| 1095 | + | switch name { | |
| 1096 | + | case "lsp": | |
| 1097 | + | return true | |
| 1098 | + | case "write": | |
| 1099 | + | if diagnosticsOnWrite { | |
| 1100 | + | return true | |
| 1101 | + | } | |
| 1102 | + | case "edit": | |
| 1103 | + | if diagnosticsOnEdit { | |
| 1104 | + | return true | |
| 1105 | + | } | |
| 1106 | + | } | |
| 1107 | + | } | |
| 1108 | + | return false | |
| 1109 | + | } | |
| 1110 | + | ||
| 1072 | 1111 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1073 | - | order := []string{"read", "write", "edit", "bash"} | |
| 1112 | + | order := []string{"read", "write", "edit", "bash", "lsp"} | |
| 1074 | 1113 | var out []ToolSummary | |
| 1075 | 1114 | for _, name := range order { | |
| 1076 | 1115 | if t, ok := reg[name]; ok { | |
M packages/agent/build_test.go +29 -0
| @@ -9,9 +9,38 @@ import ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 12 | 13 | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 16 | + | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |
| 17 | + | root := t.TempDir() | |
| 18 | + | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false) | |
| 19 | + | lspTool, ok := registry["lsp"].(*tools.LSPTool) | |
| 20 | + | if !ok || lspTool.Manager == nil { | |
| 21 | + | t.Fatalf("lsp tool = %#v", registry["lsp"]) | |
| 22 | + | } | |
| 23 | + | t.Cleanup(func() { _ = lspTool.Manager.Close() }) | |
| 24 | + | if _, ok := registry["lsp"]; !ok { | |
| 25 | + | t.Fatal("default registry does not include lsp") | |
| 26 | + | } | |
| 27 | + | write, ok := registry["write"].(*tools.WriteTool) | |
| 28 | + | if !ok || write.LSP == nil || !write.LSPDiagnostics { | |
| 29 | + | t.Fatalf("write tool LSP wiring = %#v", write) | |
| 30 | + | } | |
| 31 | + | edit, ok := registry["edit"].(*tools.EditTool) | |
| 32 | + | if !ok || edit.LSP == nil || edit.LSPDiagnostics { | |
| 33 | + | t.Fatalf("edit tool LSP wiring = %#v", edit) | |
| 34 | + | } | |
| 35 | + | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 { | |
| 36 | + | t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled)) | |
| 37 | + | } | |
| 38 | + | readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true) | |
| 39 | + | if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil { | |
| 40 | + | t.Fatalf("read-only registry = %#v", readOnly) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 15 | 44 | func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |
| 16 | 45 | root := t.TempDir() | |
| 17 | 46 | zotHome := filepath.Join(root, "zot-home") | |
M packages/agent/cli.go +49 -2
| @@ -179,6 +179,16 @@ func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Age | |||
| 179 | 179 | return fallback | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | + | func closeAgentLSP(ag *core.Agent) { | |
| 183 | + | if ag != nil { | |
| 184 | + | _ = tools.CloseLSPManagers(ag.Tools) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func closeResolvedLSP(r Resolved) { | |
| 189 | + | _ = tools.CloseLSPManagers(r.ToolRegistry) | |
| 190 | + | } | |
| 191 | + | ||
| 182 | 192 | func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message { | |
| 183 | 193 | if keepTail <= 0 || len(msgs) <= keepTail { | |
| 184 | 194 | return provider.RepairOrphanedToolResults(msgs) | |
| @@ -648,6 +658,13 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 648 | 658 | defer stopExt() | |
| 649 | 659 | ||
| 650 | 660 | ag := r.NewAgent() | |
| 661 | + | initialAg := ag | |
| 662 | + | defer func() { | |
| 663 | + | closeAgentLSP(ag) | |
| 664 | + | if ag != initialAg { | |
| 665 | + | closeAgentLSP(initialAg) | |
| 666 | + | } | |
| 667 | + | }() | |
| 651 | 668 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 652 | 669 | sess, err := openOrCreateSession(args, r, ag, version) | |
| 653 | 670 | if err != nil { | |
| @@ -702,6 +719,13 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 702 | 719 | defer stopExt() | |
| 703 | 720 | ||
| 704 | 721 | ag := r.NewAgent() | |
| 722 | + | initialAg := ag | |
| 723 | + | defer func() { | |
| 724 | + | closeAgentLSP(ag) | |
| 725 | + | if ag != initialAg { | |
| 726 | + | closeAgentLSP(initialAg) | |
| 727 | + | } | |
| 728 | + | }() | |
| 705 | 729 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 706 | 730 | sess, err := openOrCreateSession(args, r, ag, version) | |
| 707 | 731 | if err != nil { | |
| @@ -795,6 +819,13 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 795 | 819 | defer stopExt() | |
| 796 | 820 | ||
| 797 | 821 | ag := r.NewAgent() | |
| 822 | + | initialAg := ag | |
| 823 | + | defer func() { | |
| 824 | + | closeAgentLSP(ag) | |
| 825 | + | if ag != initialAg { | |
| 826 | + | closeAgentLSP(initialAg) | |
| 827 | + | } | |
| 828 | + | }() | |
| 798 | 829 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 799 | 830 | sess, err := openOrCreateSession(args, r, ag, version) | |
| 800 | 831 | if err != nil { | |
| @@ -915,8 +946,10 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool | |||
| 915 | 946 | if mutateRegistry != nil { | |
| 916 | 947 | reg = mutateRegistry(reg) | |
| 917 | 948 | } | |
| 949 | + | oldTools := ag.Tools | |
| 918 | 950 | ag.SetTools(reg) | |
| 919 | 951 | ag.System = resolved.SystemPrompt | |
| 952 | + | _ = tools.CloseLSPManagers(oldTools) | |
| 920 | 953 | } | |
| 921 | 954 | ||
| 922 | 955 | // reloadResourcesAfterStartupPre reloads extensions (if any) and | |
| @@ -1152,6 +1185,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1152 | 1185 | return a | |
| 1153 | 1186 | } | |
| 1154 | 1187 | ||
| 1188 | + | var ag *core.Agent | |
| 1155 | 1189 | buildAgent := func() (*core.Agent, string, string, error) { | |
| 1156 | 1190 | resolved, err := Resolve(args, true) | |
| 1157 | 1191 | if err != nil { | |
| @@ -1209,7 +1243,6 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1209 | 1243 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 1210 | 1244 | } | |
| 1211 | 1245 | ||
| 1212 | - | var ag *core.Agent | |
| 1213 | 1246 | if r.HasCredential() { | |
| 1214 | 1247 | ag = wireAgentExt(r.NewAgent()) | |
| 1215 | 1248 | } | |
| @@ -1321,6 +1354,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1321 | 1354 | return a | |
| 1322 | 1355 | } | |
| 1323 | 1356 | wireAgentPersist(ag) | |
| 1357 | + | defer func() { | |
| 1358 | + | closeAgentLSP(liveInteractiveAgent(iv, ag)) | |
| 1359 | + | closeResolvedLSP(r) | |
| 1360 | + | }() | |
| 1324 | 1361 | ||
| 1325 | 1362 | // Re-wrap the build closures so any agent constructed by the TUI | |
| 1326 | 1363 | // (login, /model swap to a different provider) also gets the | |
| @@ -1601,6 +1638,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1601 | 1638 | activeModel = newModel | |
| 1602 | 1639 | persistMu.Unlock() | |
| 1603 | 1640 | ag = newAg | |
| 1641 | + | closeAgentLSP(currentAg) | |
| 1604 | 1642 | ||
| 1605 | 1643 | // Push the new state into the running Interactive. | |
| 1606 | 1644 | if iv != nil { | |
| @@ -1709,6 +1747,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1709 | 1747 | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | |
| 1710 | 1748 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 1711 | 1749 | FastMode: &fastMode, | |
| 1750 | + | LSPEnabled: initialCfg.LSPEnabled, | |
| 1751 | + | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | |
| 1712 | 1752 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 1713 | 1753 | JailByDefault: initialCfg.JailByDefault, | |
| 1714 | 1754 | QuickModelShortcuts: quickModelShortcuts, | |
| @@ -1766,6 +1806,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1766 | 1806 | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | |
| 1767 | 1807 | } | |
| 1768 | 1808 | }, | |
| 1809 | + | RefreshTools: func() { | |
| 1810 | + | current := liveInteractiveAgent(iv, ag) | |
| 1811 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | |
| 1812 | + | }, | |
| 1769 | 1813 | AuthManager: mgr, | |
| 1770 | 1814 | LlamaCPPConfig: ResolveLlamaCPPConfig, | |
| 1771 | 1815 | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | |
| @@ -1977,7 +2021,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1977 | 2021 | if !ok { | |
| 1978 | 2022 | return | |
| 1979 | 2023 | } | |
| 1980 | - | if finalAg := iv.Agent(); finalAg != nil { | |
| 2024 | + | finalAg := iv.Agent() | |
| 2025 | + | if finalAg != nil { | |
| 1981 | 2026 | persistMu.Lock() | |
| 1982 | 2027 | if sess != nil { | |
| 1983 | 2028 | writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs) | |
| @@ -1987,6 +2032,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1987 | 2032 | } | |
| 1988 | 2033 | persistMu.Unlock() | |
| 1989 | 2034 | } | |
| 2035 | + | closeAgentLSP(finalAg) | |
| 2036 | + | closeResolvedLSP(r) | |
| 1990 | 2037 | // Exit cleanly. Re-raising the signal would skip os.Exit's | |
| 1991 | 2038 | // at-exit hooks; explicit exit is fine because we've already | |
| 1992 | 2039 | // flushed the only at-risk state (the session file). | |
M packages/agent/config.go +40 -0
| @@ -73,6 +73,23 @@ type Config struct { | |||
| 73 | 73 | // default; nil/missing means disabled. Toggle from /settings. | |
| 74 | 74 | AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"` | |
| 75 | 75 | ||
| 76 | + | // LSPEnabled controls the built-in lsp tool and write-time diagnostics | |
| 77 | + | // for the main session. nil/missing means enabled. Toggle from | |
| 78 | + | // /settings. | |
| 79 | + | LSPEnabled *bool `json:"lsp_enabled,omitempty"` | |
| 80 | + | ||
| 81 | + | // SubagentLSPEnabled controls LSP availability in swarm sub-agent | |
| 82 | + | // processes. nil/missing means enabled. Toggle from /settings. | |
| 83 | + | SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"` | |
| 84 | + | ||
| 85 | + | // LSPDiagnosticsOnWrite enables bounded diagnostics after a successful | |
| 86 | + | // write. nil/missing means enabled when LSP itself is enabled. | |
| 87 | + | LSPDiagnosticsOnWrite *bool `json:"lsp_diagnostics_on_write,omitempty"` | |
| 88 | + | ||
| 89 | + | // LSPDiagnosticsOnEdit enables bounded diagnostics after a successful | |
| 90 | + | // edit. nil/missing means disabled to keep multi-edit sequences quiet. | |
| 91 | + | LSPDiagnosticsOnEdit *bool `json:"lsp_diagnostics_on_edit,omitempty"` | |
| 92 | + | ||
| 76 | 93 | // AutoCompactThreshold is the percentage of the model context window | |
| 77 | 94 | // that triggers automatic transcript compaction in interactive mode. | |
| 78 | 95 | // nil/missing means 85; zero disables percentage-based triggers. | |
| @@ -196,6 +213,29 @@ func (c Config) CompactUserInput() bool { | |||
| 196 | 213 | return c.CompactInput != nil && *c.CompactInput | |
| 197 | 214 | } | |
| 198 | 215 | ||
| 216 | + | // LSPEnabledFor reports whether LSP is enabled for a main or swarm | |
| 217 | + | // sub-agent session. Both settings default to true so new installations | |
| 218 | + | // get code intelligence without a migration or generated config file. | |
| 219 | + | func (c Config) LSPEnabledFor(subagent bool) bool { | |
| 220 | + | value := c.LSPEnabled | |
| 221 | + | if subagent { | |
| 222 | + | value = c.SubagentLSPEnabled | |
| 223 | + | } | |
| 224 | + | return value == nil || *value | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | |
| 228 | + | // preference without requiring a generated config entry. | |
| 229 | + | func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool { | |
| 230 | + | return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics | |
| 234 | + | // preference without requiring a generated config entry. | |
| 235 | + | func (c Config) LSPDiagnosticsOnEditEnabled(subagent bool) bool { | |
| 236 | + | return c.LSPEnabledFor(subagent) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit | |
| 237 | + | } | |
| 238 | + | ||
| 199 | 239 | // AuthPath returns the path to auth.json. | |
| 200 | 240 | func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } | |
| 201 | 241 | ||
M packages/agent/ext/ext.go +1 -1
| @@ -395,7 +395,7 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||
| 395 | 395 | // OpenAI accept). Call this BEFORE Run(); zot folds extension tools | |
| 396 | 396 | // into the agent's registry once the extension's ready frame fires. | |
| 397 | 397 | // | |
| 398 | - | // Naming conflicts with built-in tools (read, write, edit, bash, | |
| 398 | + | // Naming conflicts with built-in tools (read, write, edit, bash, lsp, | |
| 399 | 399 | // skill) are silently shadowed by the built-in. | |
| 400 | 400 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | |
| 401 | 401 | e.registerTool(name, description, schema, false, fn) | |
A packages/agent/lsp/client.go +678 -0
| @@ -0,0 +1,678 @@ | |||
| 1 | + | package lsp | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "sync/atomic" | |
| 16 | + | "time" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type rpcError struct { | |
| 20 | + | Code int `json:"code"` | |
| 21 | + | Message string `json:"message"` | |
| 22 | + | Data any `json:"data,omitempty"` | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | type rpcResponse struct { | |
| 26 | + | Result json.RawMessage | |
| 27 | + | Error *rpcError | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type rpcEnvelope struct { | |
| 31 | + | JSONRPC string `json:"jsonrpc"` | |
| 32 | + | ID json.RawMessage `json:"id"` | |
| 33 | + | Method string `json:"method"` | |
| 34 | + | Params json.RawMessage `json:"params"` | |
| 35 | + | Result json.RawMessage `json:"result"` | |
| 36 | + | Error *rpcError `json:"error"` | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // ClientOptions configures notifications and the small set of client-side | |
| 40 | + | // handlers required by real language servers. | |
| 41 | + | type ClientOptions struct { | |
| 42 | + | Settings map[string]any | |
| 43 | + | InitializationOptions any | |
| 44 | + | OnDiagnostics func([]Diagnostic) | |
| 45 | + | OnLog func(string) | |
| 46 | + | OnProgress func(string, json.RawMessage) | |
| 47 | + | ApplyEdit func(WorkspaceEdit) error | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Client is one stdio JSON-RPC LSP process. A Client is safe for concurrent | |
| 51 | + | // requests; the process itself is shared by all open documents in its root. | |
| 52 | + | type Client struct { | |
| 53 | + | Spec ServerConfig | |
| 54 | + | Root string | |
| 55 | + | Command string | |
| 56 | + | cmd *exec.Cmd | |
| 57 | + | stdin io.WriteCloser | |
| 58 | + | stdout io.ReadCloser | |
| 59 | + | stderr io.ReadCloser | |
| 60 | + | ||
| 61 | + | writeMu sync.Mutex | |
| 62 | + | applyEditMu sync.Mutex | |
| 63 | + | mu sync.Mutex | |
| 64 | + | pending map[int64]chan rpcResponse | |
| 65 | + | nextID int64 | |
| 66 | + | closed bool | |
| 67 | + | closeOnce sync.Once | |
| 68 | + | done chan struct{} | |
| 69 | + | doneOnce sync.Once | |
| 70 | + | readErr atomic.Value // string; one concrete type keeps atomic.Value safe | |
| 71 | + | ||
| 72 | + | options ClientOptions | |
| 73 | + | capMu sync.RWMutex | |
| 74 | + | caps json.RawMessage | |
| 75 | + | docsMu sync.Mutex | |
| 76 | + | docs map[string]int | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // NewClient starts a stdio server but does not initialize it. This separation | |
| 80 | + | // is convenient for tests and lets callers choose an initialization timeout. | |
| 81 | + | func NewClient(spec ServerConfig, root string) (*Client, error) { | |
| 82 | + | return NewClientWithOptions(spec, root, ClientOptions{}) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // NewClientWithOptions starts a client with notification callbacks installed. | |
| 86 | + | func NewClientWithOptions(spec ServerConfig, root string, options ClientOptions) (*Client, error) { | |
| 87 | + | if strings.EqualFold(spec.Kind, "cli") { | |
| 88 | + | return nil, fmt.Errorf("%s is a CLI provider, not an LSP server", spec.ID) | |
| 89 | + | } | |
| 90 | + | root, err := filepath.Abs(root) | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, err | |
| 93 | + | } | |
| 94 | + | command, err := ResolveCommand(root, spec.Command) | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, fmt.Errorf("%s: %w", spec.ID, err) | |
| 97 | + | } | |
| 98 | + | cmd := exec.Command(command, spec.Args...) | |
| 99 | + | cmd.WaitDelay = 2 * time.Second | |
| 100 | + | cmd.Dir = root | |
| 101 | + | cmd.Env = mergedEnvironment(spec.Env) | |
| 102 | + | stdin, err := cmd.StdinPipe() | |
| 103 | + | if err != nil { | |
| 104 | + | return nil, fmt.Errorf("LSP stdin: %w", err) | |
| 105 | + | } | |
| 106 | + | stdout, err := cmd.StdoutPipe() | |
| 107 | + | if err != nil { | |
| 108 | + | _ = stdin.Close() | |
| 109 | + | return nil, fmt.Errorf("LSP stdout: %w", err) | |
| 110 | + | } | |
| 111 | + | stderr, err := cmd.StderrPipe() | |
| 112 | + | if err != nil { | |
| 113 | + | _ = stdin.Close() | |
| 114 | + | _ = stdout.Close() | |
| 115 | + | return nil, fmt.Errorf("LSP stderr: %w", err) | |
| 116 | + | } | |
| 117 | + | if err := cmd.Start(); err != nil { | |
| 118 | + | _ = stdin.Close() | |
| 119 | + | _ = stdout.Close() | |
| 120 | + | _ = stderr.Close() | |
| 121 | + | return nil, fmt.Errorf("start %s: %w", spec.ID, err) | |
| 122 | + | } | |
| 123 | + | c := &Client{ | |
| 124 | + | Spec: spec, Root: root, Command: command, cmd: cmd, | |
| 125 | + | stdin: stdin, stdout: stdout, stderr: stderr, | |
| 126 | + | pending: make(map[int64]chan rpcResponse), done: make(chan struct{}), | |
| 127 | + | options: options, docs: make(map[string]int), | |
| 128 | + | } | |
| 129 | + | go c.readLoop() | |
| 130 | + | go c.stderrLoop() | |
| 131 | + | return c, nil | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // NewClientWithContext starts and initializes a client. | |
| 135 | + | func NewClientWithContext(ctx context.Context, spec ServerConfig, root string, options ClientOptions) (*Client, error) { | |
| 136 | + | client, err := NewClientWithOptions(spec, root, options) | |
| 137 | + | if err != nil { | |
| 138 | + | return nil, err | |
| 139 | + | } | |
| 140 | + | if err := client.Initialize(ctx); err != nil { | |
| 141 | + | _ = client.Close() | |
| 142 | + | return nil, err | |
| 143 | + | } | |
| 144 | + | return client, nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (c *Client) Initialize(ctx context.Context) error { | |
| 148 | + | if ctx == nil { | |
| 149 | + | ctx = context.Background() | |
| 150 | + | } | |
| 151 | + | folders := []map[string]any{{"uri": mustURI(c.Root), "name": filepath.Base(c.Root)}} | |
| 152 | + | params := map[string]any{ | |
| 153 | + | "processId": os.Getpid(), | |
| 154 | + | "rootUri": mustURI(c.Root), | |
| 155 | + | "workspaceFolders": folders, | |
| 156 | + | "capabilities": map[string]any{ | |
| 157 | + | "workspace": map[string]any{ | |
| 158 | + | "applyEdit": true, | |
| 159 | + | "workspaceFolders": true, | |
| 160 | + | "configuration": true, | |
| 161 | + | }, | |
| 162 | + | "textDocument": map[string]any{ | |
| 163 | + | "publishDiagnostics": map[string]any{"relatedInformation": true}, | |
| 164 | + | }, | |
| 165 | + | }, | |
| 166 | + | "initializationOptions": c.options.InitializationOptions, | |
| 167 | + | "clientInfo": map[string]any{"name": "zot", "version": "lsp"}, | |
| 168 | + | } | |
| 169 | + | result, err := c.Request(ctx, "initialize", params) | |
| 170 | + | if err != nil { | |
| 171 | + | return fmt.Errorf("initialize %s: %w", c.Spec.ID, err) | |
| 172 | + | } | |
| 173 | + | var response struct { | |
| 174 | + | Capabilities json.RawMessage `json:"capabilities"` | |
| 175 | + | } | |
| 176 | + | if err := json.Unmarshal(result, &response); err == nil { | |
| 177 | + | c.capMu.Lock() | |
| 178 | + | c.caps = append(c.caps[:0], response.Capabilities...) | |
| 179 | + | c.capMu.Unlock() | |
| 180 | + | } | |
| 181 | + | if err := c.Notify("initialized", map[string]any{}); err != nil { | |
| 182 | + | return err | |
| 183 | + | } | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // Capabilities returns the initialize result's capabilities. | |
| 188 | + | func (c *Client) Capabilities() json.RawMessage { | |
| 189 | + | c.capMu.RLock() | |
| 190 | + | defer c.capMu.RUnlock() | |
| 191 | + | return append(json.RawMessage(nil), c.caps...) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Request sends a raw JSON-RPC request and waits for its response. | |
| 195 | + | func (c *Client) Request(ctx context.Context, method string, params any) (json.RawMessage, error) { | |
| 196 | + | if ctx == nil { | |
| 197 | + | ctx = context.Background() | |
| 198 | + | } | |
| 199 | + | id := atomic.AddInt64(&c.nextID, 1) | |
| 200 | + | responseCh := make(chan rpcResponse, 1) | |
| 201 | + | c.mu.Lock() | |
| 202 | + | if c.closed { | |
| 203 | + | c.mu.Unlock() | |
| 204 | + | return nil, errors.New("LSP client is closed") | |
| 205 | + | } | |
| 206 | + | c.pending[id] = responseCh | |
| 207 | + | c.mu.Unlock() | |
| 208 | + | request := map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params} | |
| 209 | + | if err := c.send(request); err != nil { | |
| 210 | + | c.mu.Lock() | |
| 211 | + | delete(c.pending, id) | |
| 212 | + | c.mu.Unlock() | |
| 213 | + | return nil, err | |
| 214 | + | } | |
| 215 | + | select { | |
| 216 | + | case response := <-responseCh: | |
| 217 | + | if response.Error != nil { | |
| 218 | + | return nil, fmt.Errorf("LSP %s (%d): %s", method, response.Error.Code, response.Error.Message) | |
| 219 | + | } | |
| 220 | + | return response.Result, nil | |
| 221 | + | case <-ctx.Done(): | |
| 222 | + | c.mu.Lock() | |
| 223 | + | delete(c.pending, id) | |
| 224 | + | c.mu.Unlock() | |
| 225 | + | _ = c.Notify("$/cancelRequest", map[string]any{"id": id}) | |
| 226 | + | return nil, ctx.Err() | |
| 227 | + | case <-c.done: | |
| 228 | + | return nil, c.processError() | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Notify sends a JSON-RPC notification. | |
| 233 | + | func (c *Client) Notify(method string, params any) error { | |
| 234 | + | return c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params}) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func (c *Client) send(message any) error { | |
| 238 | + | payload, err := json.Marshal(message) | |
| 239 | + | if err != nil { | |
| 240 | + | return err | |
| 241 | + | } | |
| 242 | + | c.writeMu.Lock() | |
| 243 | + | defer c.writeMu.Unlock() | |
| 244 | + | c.mu.Lock() | |
| 245 | + | closed := c.closed | |
| 246 | + | c.mu.Unlock() | |
| 247 | + | if closed { | |
| 248 | + | return errors.New("LSP client is closed") | |
| 249 | + | } | |
| 250 | + | if err := WriteMessage(c.stdin, payload); err != nil { | |
| 251 | + | return fmt.Errorf("write LSP message: %w", err) | |
| 252 | + | } | |
| 253 | + | return nil | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // DidOpen publishes a complete document. Callers which already opened the URI | |
| 257 | + | // should use DidChange for subsequent full-content updates. | |
| 258 | + | func (c *Client) DidOpen(path, languageID, text string) error { | |
| 259 | + | uri, err := pathToURI(path) | |
| 260 | + | if err != nil { | |
| 261 | + | return err | |
| 262 | + | } | |
| 263 | + | version, alreadyOpen := c.nextDocumentVersion(uri) | |
| 264 | + | if alreadyOpen { | |
| 265 | + | return c.Notify("textDocument/didChange", map[string]any{ | |
| 266 | + | "textDocument": map[string]any{"uri": uri, "version": version}, | |
| 267 | + | "contentChanges": []map[string]any{{"text": text}}, | |
| 268 | + | }) | |
| 269 | + | } | |
| 270 | + | return c.Notify("textDocument/didOpen", map[string]any{ | |
| 271 | + | "textDocument": map[string]any{"uri": uri, "languageId": languageID, "version": version, "text": text}, | |
| 272 | + | }) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // DidChange sends a full-content change, which avoids version/range drift | |
| 276 | + | // between zot and servers that use different incremental-sync policies. | |
| 277 | + | func (c *Client) DidChange(path, text string) error { | |
| 278 | + | uri, err := pathToURI(path) | |
| 279 | + | if err != nil { | |
| 280 | + | return err | |
| 281 | + | } | |
| 282 | + | version, _ := c.nextDocumentVersion(uri) | |
| 283 | + | return c.Notify("textDocument/didChange", map[string]any{ | |
| 284 | + | "textDocument": map[string]any{"uri": uri, "version": version}, | |
| 285 | + | "contentChanges": []map[string]any{{"text": text}}, | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (c *Client) nextDocumentVersion(uri string) (int, bool) { | |
| 290 | + | c.docsMu.Lock() | |
| 291 | + | defer c.docsMu.Unlock() | |
| 292 | + | version := c.docs[uri] | |
| 293 | + | alreadyOpen := version != 0 | |
| 294 | + | if version == 0 { | |
| 295 | + | version = 1 | |
| 296 | + | } else { | |
| 297 | + | version++ | |
| 298 | + | } | |
| 299 | + | c.docs[uri] = version | |
| 300 | + | return version, alreadyOpen | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func (c *Client) DidSave(path, text string) error { | |
| 304 | + | uri, err := pathToURI(path) | |
| 305 | + | if err != nil { | |
| 306 | + | return err | |
| 307 | + | } | |
| 308 | + | params := map[string]any{"textDocument": map[string]any{"uri": uri}} | |
| 309 | + | if text != "" { | |
| 310 | + | params["text"] = text | |
| 311 | + | } | |
| 312 | + | return c.Notify("textDocument/didSave", params) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func (c *Client) readLoop() { | |
| 316 | + | reader := bufio.NewReaderSize(c.stdout, 64*1024) | |
| 317 | + | for { | |
| 318 | + | payload, err := ReadMessage(reader) | |
| 319 | + | if err != nil { | |
| 320 | + | if !errors.Is(err, io.EOF) { | |
| 321 | + | c.readErr.Store(err.Error()) | |
| 322 | + | } | |
| 323 | + | c.failPending(err) | |
| 324 | + | c.signalDone() | |
| 325 | + | return | |
| 326 | + | } | |
| 327 | + | var envelope rpcEnvelope | |
| 328 | + | if err := json.Unmarshal(payload, &envelope); err != nil { | |
| 329 | + | c.readErr.Store(fmt.Sprintf("decode LSP message: %v", err)) | |
| 330 | + | continue | |
| 331 | + | } | |
| 332 | + | if envelope.Method == "" && len(envelope.ID) > 0 { | |
| 333 | + | var id int64 | |
| 334 | + | if json.Unmarshal(envelope.ID, &id) == nil { | |
| 335 | + | c.mu.Lock() | |
| 336 | + | channel := c.pending[id] | |
| 337 | + | delete(c.pending, id) | |
| 338 | + | c.mu.Unlock() | |
| 339 | + | if channel != nil { | |
| 340 | + | channel <- rpcResponse{Result: envelope.Result, Error: envelope.Error} | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | continue | |
| 344 | + | } | |
| 345 | + | if envelope.Method != "" { | |
| 346 | + | // Notifications carry ordered state, so handle them on the | |
| 347 | + | // reader goroutine. Server requests may block while they wait | |
| 348 | + | // for an edit response, so those remain asynchronous. | |
| 349 | + | if len(envelope.ID) == 0 || string(envelope.ID) == "null" { | |
| 350 | + | c.handleServerMessage(envelope) | |
| 351 | + | } else { | |
| 352 | + | go c.handleServerMessage(envelope) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func (c *Client) handleServerMessage(message rpcEnvelope) { | |
| 359 | + | switch message.Method { | |
| 360 | + | case "textDocument/publishDiagnostics": | |
| 361 | + | c.handleDiagnostics(message.Params) | |
| 362 | + | return | |
| 363 | + | case "$/progress": | |
| 364 | + | if c.options.OnProgress != nil { | |
| 365 | + | c.options.OnProgress(message.Method, message.Params) | |
| 366 | + | } | |
| 367 | + | return | |
| 368 | + | case "window/logMessage", "window/showMessage", "telemetry/event": | |
| 369 | + | if c.options.OnLog != nil { | |
| 370 | + | var value struct { | |
| 371 | + | Message string `json:"message"` | |
| 372 | + | } | |
| 373 | + | _ = json.Unmarshal(message.Params, &value) | |
| 374 | + | c.options.OnLog(value.Message) | |
| 375 | + | } | |
| 376 | + | if len(message.ID) == 0 || string(message.ID) == "null" { | |
| 377 | + | return | |
| 378 | + | } | |
| 379 | + | _ = c.respond(message.ID, nil, nil) | |
| 380 | + | return | |
| 381 | + | case "workspace/configuration": | |
| 382 | + | _ = c.respond(message.ID, c.configuration(message.Params), nil) | |
| 383 | + | case "workspace/workspaceFolders": | |
| 384 | + | _ = c.respond(message.ID, []map[string]any{{"uri": mustURI(c.Root), "name": filepath.Base(c.Root)}}, nil) | |
| 385 | + | case "workspace/applyEdit": | |
| 386 | + | var params struct { | |
| 387 | + | Edit WorkspaceEdit `json:"edit"` | |
| 388 | + | } | |
| 389 | + | if err := json.Unmarshal(message.Params, ¶ms); err != nil { | |
| 390 | + | _ = c.respond(message.ID, map[string]any{"applied": false, "failureReason": err.Error()}, nil) | |
| 391 | + | return | |
| 392 | + | } | |
| 393 | + | c.applyEditMu.Lock() | |
| 394 | + | err := c.applyEdit(params.Edit) | |
| 395 | + | c.applyEditMu.Unlock() | |
| 396 | + | result := map[string]any{"applied": err == nil} | |
| 397 | + | if err != nil { | |
| 398 | + | result["failureReason"] = err.Error() | |
| 399 | + | } | |
| 400 | + | _ = c.respond(message.ID, result, nil) | |
| 401 | + | case "client/registerCapability", "window/workDoneProgress/create": | |
| 402 | + | _ = c.respond(message.ID, map[string]any{}, nil) | |
| 403 | + | default: | |
| 404 | + | if len(message.ID) > 0 && string(message.ID) != "null" { | |
| 405 | + | _ = c.respond(message.ID, nil, &rpcError{Code: -32601, Message: "method not supported"}) | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | func (c *Client) configuration(params json.RawMessage) []any { | |
| 411 | + | var request struct { | |
| 412 | + | Items []struct { | |
| 413 | + | Section string `json:"section"` | |
| 414 | + | } `json:"items"` | |
| 415 | + | } | |
| 416 | + | _ = json.Unmarshal(params, &request) | |
| 417 | + | out := make([]any, len(request.Items)) | |
| 418 | + | for i, item := range request.Items { | |
| 419 | + | if item.Section == "" { | |
| 420 | + | out[i] = c.options.Settings | |
| 421 | + | continue | |
| 422 | + | } | |
| 423 | + | out[i] = lookupSetting(c.options.Settings, item.Section) | |
| 424 | + | } | |
| 425 | + | return out | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | func lookupSetting(settings map[string]any, section string) any { | |
| 429 | + | if settings == nil { | |
| 430 | + | return nil | |
| 431 | + | } | |
| 432 | + | if value, ok := settings[section]; ok { | |
| 433 | + | return value | |
| 434 | + | } | |
| 435 | + | var value any = settings | |
| 436 | + | for _, part := range strings.Split(section, ".") { | |
| 437 | + | object, ok := value.(map[string]any) | |
| 438 | + | if !ok { | |
| 439 | + | return nil | |
| 440 | + | } | |
| 441 | + | value, ok = object[part] | |
| 442 | + | if !ok { | |
| 443 | + | return nil | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | return value | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func (c *Client) applyEdit(edit WorkspaceEdit) error { | |
| 450 | + | if c.options.ApplyEdit != nil { | |
| 451 | + | return c.options.ApplyEdit(edit) | |
| 452 | + | } | |
| 453 | + | return ApplyWorkspaceEdit(c.Root, edit) | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func (c *Client) respond(id json.RawMessage, result any, rpcErr *rpcError) error { | |
| 457 | + | message := map[string]any{"jsonrpc": "2.0", "id": id} | |
| 458 | + | if rpcErr != nil { | |
| 459 | + | message["error"] = rpcErr | |
| 460 | + | } else { | |
| 461 | + | message["result"] = result | |
| 462 | + | } | |
| 463 | + | return c.send(message) | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | func (c *Client) handleDiagnostics(params json.RawMessage) { | |
| 467 | + | var value struct { | |
| 468 | + | URI string `json:"uri"` | |
| 469 | + | Diagnostics []Diagnostic `json:"diagnostics"` | |
| 470 | + | } | |
| 471 | + | if err := json.Unmarshal(params, &value); err != nil { | |
| 472 | + | return | |
| 473 | + | } | |
| 474 | + | path, err := uriToPath(value.URI) | |
| 475 | + | if err != nil { | |
| 476 | + | return | |
| 477 | + | } | |
| 478 | + | for i := range value.Diagnostics { | |
| 479 | + | value.Diagnostics[i].Path = path | |
| 480 | + | value.Diagnostics[i].Server = c.Spec.ID | |
| 481 | + | } | |
| 482 | + | if len(value.Diagnostics) == 0 { | |
| 483 | + | // Preserve the URI on a clear so Manager can remove only this file's | |
| 484 | + | // snapshot rather than accidentally retaining stale diagnostics. | |
| 485 | + | value.Diagnostics = []Diagnostic{{Path: path, Server: c.Spec.ID, Clear: true}} | |
| 486 | + | } | |
| 487 | + | if c.options.OnDiagnostics != nil { | |
| 488 | + | c.options.OnDiagnostics(value.Diagnostics) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func (c *Client) stderrLoop() { | |
| 493 | + | if c.stderr == nil { | |
| 494 | + | return | |
| 495 | + | } | |
| 496 | + | var output limitedBuffer | |
| 497 | + | output.limit = 1 << 20 | |
| 498 | + | _, _ = io.Copy(&output, c.stderr) | |
| 499 | + | if output.Len() > 0 && c.options.OnLog != nil { | |
| 500 | + | c.options.OnLog(strings.TrimSpace(output.String())) | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func (c *Client) failPending(err error) { | |
| 505 | + | c.mu.Lock() | |
| 506 | + | defer c.mu.Unlock() | |
| 507 | + | for id, channel := range c.pending { | |
| 508 | + | delete(c.pending, id) | |
| 509 | + | channel <- rpcResponse{Error: &rpcError{Code: -32000, Message: err.Error()}} | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | func (c *Client) processError() error { | |
| 514 | + | if err, ok := c.readErr.Load().(string); ok && err != "" { | |
| 515 | + | return fmt.Errorf("LSP process stopped: %s", err) | |
| 516 | + | } | |
| 517 | + | return errors.New("LSP process stopped") | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | func (c *Client) signalDone() { c.doneOnce.Do(func() { close(c.done) }) } | |
| 521 | + | ||
| 522 | + | // Close stops the process and releases all pipes. It is safe to call more | |
| 523 | + | // than once and is also used by Manager.Reload. | |
| 524 | + | func (c *Client) Close() error { | |
| 525 | + | var waitErr error | |
| 526 | + | c.closeOnce.Do(func() { | |
| 527 | + | // Give a cooperative server a short shutdown window before killing | |
| 528 | + | // the process. Close remains bounded for servers that do not support | |
| 529 | + | // shutdown or are already wedged. | |
| 530 | + | if c.cmd.ProcessState == nil { | |
| 531 | + | shutdownCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) | |
| 532 | + | _, _ = c.Request(shutdownCtx, "shutdown", nil) | |
| 533 | + | cancel() | |
| 534 | + | _ = c.Notify("exit", nil) | |
| 535 | + | } | |
| 536 | + | // Serialize the closed transition with send so a writer that has | |
| 537 | + | // passed its closed check cannot race stdin.Close. | |
| 538 | + | c.writeMu.Lock() | |
| 539 | + | c.mu.Lock() | |
| 540 | + | c.closed = true | |
| 541 | + | c.mu.Unlock() | |
| 542 | + | c.signalDone() | |
| 543 | + | _ = c.stdin.Close() | |
| 544 | + | c.writeMu.Unlock() | |
| 545 | + | killed := false | |
| 546 | + | exited := make(chan error, 1) | |
| 547 | + | go func() { exited <- c.cmd.Wait() }() | |
| 548 | + | timer := time.NewTimer(500 * time.Millisecond) | |
| 549 | + | select { | |
| 550 | + | case waitErr = <-exited: | |
| 551 | + | timer.Stop() | |
| 552 | + | case <-timer.C: | |
| 553 | + | if c.cmd.Process != nil && c.cmd.ProcessState == nil { | |
| 554 | + | killed = c.cmd.Process.Kill() == nil | |
| 555 | + | } | |
| 556 | + | waitErr = <-exited | |
| 557 | + | } | |
| 558 | + | if killed { | |
| 559 | + | // A process terminated by this cleanup path normally returns an | |
| 560 | + | // ExitError; preserve unrelated wait failures such as pipe | |
| 561 | + | // errors so callers still get useful lifecycle diagnostics. | |
| 562 | + | var exitErr *exec.ExitError | |
| 563 | + | if errors.As(waitErr, &exitErr) { | |
| 564 | + | waitErr = nil | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | _ = c.stdout.Close() | |
| 568 | + | _ = c.stderr.Close() | |
| 569 | + | c.failPending(errors.New("LSP client closed")) | |
| 570 | + | }) | |
| 571 | + | return waitErr | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | func mustURI(path string) string { | |
| 575 | + | uri, _ := pathToURI(path) | |
| 576 | + | return uri | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | func mergedEnvironment(overrides map[string]string) []string { | |
| 580 | + | env := os.Environ() | |
| 581 | + | if len(overrides) == 0 { | |
| 582 | + | return env | |
| 583 | + | } | |
| 584 | + | index := make(map[string]int, len(env)) | |
| 585 | + | for i, item := range env { | |
| 586 | + | if key, _, ok := strings.Cut(item, "="); ok { | |
| 587 | + | index[key] = i | |
| 588 | + | } | |
| 589 | + | } | |
| 590 | + | for key, value := range overrides { | |
| 591 | + | item := key + "=" + value | |
| 592 | + | if i, ok := index[key]; ok { | |
| 593 | + | env[i] = item | |
| 594 | + | } else { | |
| 595 | + | env = append(env, item) | |
| 596 | + | } | |
| 597 | + | } | |
| 598 | + | return env | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | // ResolveCommand searches workspace-local tool directories before PATH. | |
| 602 | + | func ResolveCommand(cwd, command string) (string, error) { | |
| 603 | + | if strings.TrimSpace(command) == "" { | |
| 604 | + | return "", errors.New("empty command") | |
| 605 | + | } | |
| 606 | + | cwd, _ = filepath.Abs(cwd) | |
| 607 | + | candidates := []string{} | |
| 608 | + | if filepath.IsAbs(command) || strings.ContainsAny(command, `/\\`) { | |
| 609 | + | if filepath.IsAbs(command) { | |
| 610 | + | candidates = append(candidates, command) | |
| 611 | + | } else { | |
| 612 | + | candidates = append(candidates, filepath.Join(cwd, command)) | |
| 613 | + | } | |
| 614 | + | } else { | |
| 615 | + | // A model often runs in a package subdirectory while the project's | |
| 616 | + | // executable lives at the repository root. Search each workspace | |
| 617 | + | // ancestor before falling back to PATH. | |
| 618 | + | for dir := cwd; dir != ""; dir = filepath.Dir(dir) { | |
| 619 | + | for _, local := range []string{filepath.Join("node_modules", ".bin"), filepath.Join(".venv", "bin"), filepath.Join(".venv", "Scripts"), "bin", "Scripts"} { | |
| 620 | + | candidates = append(candidates, filepath.Join(dir, local, command)) | |
| 621 | + | } | |
| 622 | + | next := filepath.Dir(dir) | |
| 623 | + | if next == dir { | |
| 624 | + | break | |
| 625 | + | } | |
| 626 | + | } | |
| 627 | + | } | |
| 628 | + | for _, candidate := range candidates { | |
| 629 | + | for _, path := range executableVariants(candidate) { | |
| 630 | + | if isRunnable(path) { | |
| 631 | + | return path, nil | |
| 632 | + | } | |
| 633 | + | } | |
| 634 | + | } | |
| 635 | + | if path, err := exec.LookPath(command); err == nil { | |
| 636 | + | return path, nil | |
| 637 | + | } | |
| 638 | + | return "", fmt.Errorf("command %q not found in node_modules/.bin, .venv/bin, bin, or PATH", command) | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | func executableVariants(path string) []string { | |
| 642 | + | if filepath.Ext(path) != "" { | |
| 643 | + | return []string{path} | |
| 644 | + | } | |
| 645 | + | return []string{path, path + ".exe", path + ".cmd", path + ".bat"} | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | func isRunnable(path string) bool { | |
| 649 | + | info, err := os.Stat(path) | |
| 650 | + | if err != nil || info.IsDir() { | |
| 651 | + | return false | |
| 652 | + | } | |
| 653 | + | if info.Mode()&0111 != 0 || filepath.Ext(path) == ".cmd" || filepath.Ext(path) == ".bat" || filepath.Ext(path) == ".exe" { | |
| 654 | + | return true | |
| 655 | + | } | |
| 656 | + | return false | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | // WaitForDiagnostics gives asynchronous LSP servers a bounded opportunity to | |
| 660 | + | // publish diagnostics after didOpen/didChange. | |
| 661 | + | func (c *Client) WaitForDiagnostics(ctx context.Context, delay time.Duration) error { | |
| 662 | + | if ctx == nil { | |
| 663 | + | ctx = context.Background() | |
| 664 | + | } | |
| 665 | + | if delay <= 0 { | |
| 666 | + | return nil | |
| 667 | + | } | |
| 668 | + | timer := time.NewTimer(delay) | |
| 669 | + | defer timer.Stop() | |
| 670 | + | select { | |
| 671 | + | case <-timer.C: | |
| 672 | + | return nil | |
| 673 | + | case <-ctx.Done(): | |
| 674 | + | return ctx.Err() | |
| 675 | + | case <-c.done: | |
| 676 | + | return c.processError() | |
| 677 | + | } | |
| 678 | + | } | |
A packages/agent/lsp/config.go +693 -0
| @@ -0,0 +1,693 @@ | |||
| 1 | + | // Package lsp implements a small, dependency-free LSP and linter backend. | |
| 2 | + | package lsp | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "runtime" | |
| 11 | + | "strings" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ServerConfig describes either a stdio LSP server or a command-line linter. | |
| 15 | + | // Command is resolved relative to the workspace before it is started. | |
| 16 | + | type ServerConfig struct { | |
| 17 | + | ID string `json:"-"` | |
| 18 | + | Kind string `json:"kind,omitempty"` // "lsp" or "cli" | |
| 19 | + | Command string `json:"command"` | |
| 20 | + | Args []string `json:"args,omitempty"` | |
| 21 | + | FileTypes []string `json:"fileTypes,omitempty"` | |
| 22 | + | LanguageID string `json:"languageId,omitempty"` | |
| 23 | + | RootMarkers []string `json:"rootMarkers,omitempty"` | |
| 24 | + | IsLinter bool `json:"isLinter,omitempty"` | |
| 25 | + | Disabled bool `json:"disabled,omitempty"` | |
| 26 | + | Parser string `json:"parser,omitempty"` | |
| 27 | + | Mode string `json:"mode,omitempty"` // files or workspace for CLI linters | |
| 28 | + | RunOnStartup bool `json:"runOnStartup,omitempty"` | |
| 29 | + | RunOnChange bool `json:"runOnChange,omitempty"` | |
| 30 | + | Description string `json:"description,omitempty"` | |
| 31 | + | Env map[string]string `json:"env,omitempty"` | |
| 32 | + | Settings map[string]any `json:"settings,omitempty"` | |
| 33 | + | InitializationOptions any `json:"initializationOptions,omitempty"` | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Config is the merged global/project LSP configuration. | |
| 37 | + | type Config struct { | |
| 38 | + | Servers map[string]ServerConfig | |
| 39 | + | AutoDetect bool | |
| 40 | + | // Explicit contains servers named or defined by a config file. When | |
| 41 | + | // auto-detection is disabled, only these configured providers remain | |
| 42 | + | // eligible, matching the providers/autoDetect configuration behavior. | |
| 43 | + | Explicit map[string]bool | |
| 44 | + | Files []string | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // BuiltinServers returns the built-in language servers. The slice is a copy | |
| 48 | + | // and may be modified by callers. | |
| 49 | + | func BuiltinServers() []ServerConfig { | |
| 50 | + | return []ServerConfig{ | |
| 51 | + | {ID: "gopls", Kind: "lsp", Command: "gopls", FileTypes: []string{"go", ".go"}, RootMarkers: []string{"go.work", "go.mod"}, Description: "Go language server"}, | |
| 52 | + | {ID: "rust-analyzer", Kind: "lsp", Command: "rust-analyzer", FileTypes: []string{"rust", "rs", ".rs"}, RootMarkers: []string{"Cargo.toml"}, Description: "Rust language server"}, | |
| 53 | + | {ID: "typescript-language-server", Kind: "lsp", Command: "typescript-language-server", Args: []string{"--stdio"}, FileTypes: []string{"typescript", "typescriptreact", "javascript", "javascriptreact", "ts", "tsx", "js", "jsx", ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"}, RootMarkers: []string{"tsconfig.json", "jsconfig.json", "package.json"}, Description: "TypeScript and JavaScript language server"}, | |
| 54 | + | {ID: "pyright-langserver", Kind: "lsp", Command: "pyright-langserver", Args: []string{"--stdio"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"}, Description: "Python language server"}, | |
| 55 | + | {ID: "clangd", Kind: "lsp", Command: "clangd", FileTypes: []string{"c", "cpp", "c++", "h", "hpp", "cc", "cpp", "cxx", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"}, RootMarkers: []string{"compile_commands.json", "compile_flags.txt", "CMakeLists.txt"}, Description: "C and C++ language server"}, | |
| 56 | + | {ID: "yaml-language-server", Kind: "lsp", Command: "yaml-language-server", Args: []string{"--stdio"}, FileTypes: []string{"yaml", "yml", ".yaml", ".yml"}, Description: "YAML language server"}, | |
| 57 | + | {ID: "vscode-json-language-server", Kind: "lsp", Command: "vscode-json-language-server", Args: []string{"--stdio"}, FileTypes: []string{"json", "jsonc", ".json", ".jsonc"}, Description: "JSON language server"}, | |
| 58 | + | {ID: "bash-language-server", Kind: "lsp", Command: "bash-language-server", Args: []string{"start"}, FileTypes: []string{"shellscript", "bash", "sh", "zsh", ".sh", ".bash", ".zsh"}, RootMarkers: []string{".shellcheckrc"}, Description: "Bash language server"}, | |
| 59 | + | {ID: "eslint", Kind: "cli", Command: "eslint", Args: []string{"--format", "json"}, FileTypes: []string{"javascript", "javascriptreact", "typescript", "typescriptreact", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}, RootMarkers: []string{"eslint.config.js", "eslint.config.cjs", "eslint.config.mjs", "eslint.config.ts", ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml", ".eslintrc.yml"}, Parser: "eslint-json", Mode: "files", Description: "ESLint diagnostics"}, | |
| 60 | + | {ID: "golangci-lint", Kind: "cli", Command: "golangci-lint", Args: []string{"run", "--output.json.path", "stdout"}, FileTypes: []string{"go", ".go"}, RootMarkers: []string{".golangci.yml", ".golangci.yaml", ".golangci.toml", ".golangci.json"}, Parser: "golangci-lint-json", Mode: "workspace", Description: "golangci-lint diagnostics"}, | |
| 61 | + | {ID: "ruff", Kind: "cli", Command: "ruff", Args: []string{"check", "--output-format", "json"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"ruff.toml", ".ruff.toml", "pyproject.toml"}, Parser: "ruff-json", Mode: "files", Description: "Ruff diagnostics"}, | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // LoadConfig reads the global and project lsp.json files. Project files are | |
| 66 | + | // applied after the global file; within a project, lsp.json, .lsp.json, and | |
| 67 | + | // .zot/lsp.json are applied in that order. Missing files are harmless. | |
| 68 | + | func LoadConfig(cwd string) (Config, error) { | |
| 69 | + | cwd, err := absDir(cwd) | |
| 70 | + | if err != nil { | |
| 71 | + | return Config{}, err | |
| 72 | + | } | |
| 73 | + | var files []string | |
| 74 | + | if home := zotHome(); home != "" { | |
| 75 | + | files = append(files, filepath.Join(home, "lsp.json")) | |
| 76 | + | } | |
| 77 | + | files = append(files, | |
| 78 | + | filepath.Join(cwd, ".pi", "lsp.json"), | |
| 79 | + | filepath.Join(cwd, ".omp", "lsp.json"), | |
| 80 | + | filepath.Join(cwd, "lsp.json"), | |
| 81 | + | filepath.Join(cwd, ".lsp.json"), | |
| 82 | + | filepath.Join(cwd, ".zot", "lsp.json"), | |
| 83 | + | ) | |
| 84 | + | ||
| 85 | + | objects := make(map[string]map[string]json.RawMessage) | |
| 86 | + | explicit := make(map[string]bool) | |
| 87 | + | autoDetect := true | |
| 88 | + | for _, path := range files { | |
| 89 | + | entries, exists, data, err := readConfigFile(path) | |
| 90 | + | if err != nil { | |
| 91 | + | return Config{}, err | |
| 92 | + | } | |
| 93 | + | if len(data) > 0 { | |
| 94 | + | var metadata struct { | |
| 95 | + | AutoDetect *bool `json:"autoDetect"` | |
| 96 | + | } | |
| 97 | + | if json.Unmarshal(data, &metadata) == nil && metadata.AutoDetect != nil { | |
| 98 | + | autoDetect = *metadata.AutoDetect | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | if !exists { | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | for id := range entries { | |
| 105 | + | explicit[id] = true | |
| 106 | + | } | |
| 107 | + | for id := range providerIDs(data) { | |
| 108 | + | explicit[id] = true | |
| 109 | + | } | |
| 110 | + | for id, entry := range entries { | |
| 111 | + | if old := objects[id]; old != nil { | |
| 112 | + | objects[id] = mergeRawObjects(old, entry) | |
| 113 | + | } else { | |
| 114 | + | objects[id] = cloneRawObject(entry) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | merged := make(map[string]ServerConfig) | |
| 120 | + | for _, server := range BuiltinServers() { | |
| 121 | + | merged[server.ID] = server | |
| 122 | + | } | |
| 123 | + | for id, raw := range objects { | |
| 124 | + | decodeRaw := raw | |
| 125 | + | _, hasBuiltin := merged[id] | |
| 126 | + | if base, ok := merged[id]; ok { | |
| 127 | + | // Decode the already-merged JSON over the built-in by marshaling the | |
| 128 | + | // built-in first. This retains fields that were omitted in the file. | |
| 129 | + | baseRaw, _ := json.Marshal(base) | |
| 130 | + | baseMap := map[string]json.RawMessage{} | |
| 131 | + | _ = json.Unmarshal(baseRaw, &baseMap) | |
| 132 | + | decodeRaw = mergeRawObjects(baseMap, raw) | |
| 133 | + | } | |
| 134 | + | server, err := decodeServer(id, decodeRaw) | |
| 135 | + | if err != nil { | |
| 136 | + | return Config{}, fmt.Errorf("lsp server %q: %w", id, err) | |
| 137 | + | } | |
| 138 | + | // A parser/mode is an explicit CLI declaration even when the | |
| 139 | + | // overridden built-in was an LSP. This makes {"parser":"sarif"} | |
| 140 | + | // sufficient for a linter override. | |
| 141 | + | if hasBuiltin { | |
| 142 | + | if _, hasKind := raw["kind"]; !hasKind { | |
| 143 | + | if _, hasParser := raw["parser"]; hasParser { | |
| 144 | + | server.Kind = "cli" | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | merged[id] = server | |
| 149 | + | } | |
| 150 | + | for id, server := range merged { | |
| 151 | + | if server.ID == "" { | |
| 152 | + | server.ID = id | |
| 153 | + | merged[id] = server | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | return Config{Servers: merged, AutoDetect: autoDetect, Explicit: explicit, Files: files}, nil | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // LoadServers is a convenience for callers that only need auto-detected | |
| 160 | + | // server configurations. | |
| 161 | + | func LoadServers(cwd string) ([]ServerConfig, error) { | |
| 162 | + | config, err := LoadConfig(cwd) | |
| 163 | + | if err != nil { | |
| 164 | + | return nil, err | |
| 165 | + | } | |
| 166 | + | return config.EffectiveServers(cwd), nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // EffectiveServers returns enabled servers which are relevant to any file in | |
| 170 | + | // the workspace. It is useful for status UIs; Manager uses ApplicableServers | |
| 171 | + | // for a particular file. | |
| 172 | + | func (c Config) EffectiveServers(cwd string) []ServerConfig { | |
| 173 | + | var out []ServerConfig | |
| 174 | + | for _, server := range c.Servers { | |
| 175 | + | if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) { | |
| 176 | + | continue | |
| 177 | + | } | |
| 178 | + | if len(server.FileTypes) == 0 && len(server.RootMarkers) == 0 { | |
| 179 | + | out = append(out, server) | |
| 180 | + | continue | |
| 181 | + | } | |
| 182 | + | if hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes) { | |
| 183 | + | out = append(out, server) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | sortServers(out) | |
| 187 | + | return out | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // ApplicableServers selects servers for path. An empty path means the cwd. | |
| 191 | + | func (c Config) ApplicableServers(cwd, path string) []ServerConfig { | |
| 192 | + | var out []ServerConfig | |
| 193 | + | for _, server := range c.Servers { | |
| 194 | + | if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) || !serverMatchesPath(cwd, path, server) { | |
| 195 | + | continue | |
| 196 | + | } | |
| 197 | + | out = append(out, server) | |
| 198 | + | } | |
| 199 | + | sortServers(out) | |
| 200 | + | return out | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func serverMatchesPath(cwd, path string, server ServerConfig) bool { | |
| 204 | + | if len(server.FileTypes) == 0 { | |
| 205 | + | return len(server.RootMarkers) == 0 || hasRootMarker(cwd, server.RootMarkers) | |
| 206 | + | } | |
| 207 | + | if path != "" { | |
| 208 | + | if len(server.RootMarkers) > 0 && !hasRootMarker(cwd, server.RootMarkers) { | |
| 209 | + | return false | |
| 210 | + | } | |
| 211 | + | return fileTypeMatches(path, server.FileTypes) | |
| 212 | + | } | |
| 213 | + | return hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func hasMatchingFile(cwd string, types []string) bool { | |
| 217 | + | normalizedTypes := normalizeFileTypes(types) | |
| 218 | + | var found bool | |
| 219 | + | _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error { | |
| 220 | + | if err != nil { | |
| 221 | + | return nil | |
| 222 | + | } | |
| 223 | + | if found { | |
| 224 | + | return filepath.SkipAll | |
| 225 | + | } | |
| 226 | + | if entry.IsDir() { | |
| 227 | + | if path != cwd && (entry.Name() == ".git" || entry.Name() == "node_modules" || entry.Name() == ".venv") { | |
| 228 | + | return filepath.SkipDir | |
| 229 | + | } | |
| 230 | + | return nil | |
| 231 | + | } | |
| 232 | + | found = fileTypeMatchesNormalized(path, normalizedTypes) | |
| 233 | + | if found { | |
| 234 | + | return filepath.SkipAll | |
| 235 | + | } | |
| 236 | + | return nil | |
| 237 | + | }) | |
| 238 | + | return found | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func fileTypeMatches(path string, types []string) bool { | |
| 242 | + | return fileTypeMatchesNormalized(path, normalizeFileTypes(types)) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func normalizeFileTypes(types []string) []string { | |
| 246 | + | normalized := make([]string, 0, len(types)) | |
| 247 | + | for _, typ := range types { | |
| 248 | + | normalized = append(normalized, strings.ToLower(strings.TrimSpace(typ))) | |
| 249 | + | } | |
| 250 | + | return normalized | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func fileTypeMatchesNormalized(path string, types []string) bool { | |
| 254 | + | ext := strings.ToLower(filepath.Ext(path)) | |
| 255 | + | base := strings.ToLower(filepath.Base(path)) | |
| 256 | + | lang := languageForExtension(ext) | |
| 257 | + | for _, typ := range types { | |
| 258 | + | if typ == ext || typ == "."+strings.TrimPrefix(ext, ".") || typ == base || typ == lang || strings.TrimPrefix(typ, ".") == strings.TrimPrefix(ext, ".") { | |
| 259 | + | return true | |
| 260 | + | } | |
| 261 | + | if strings.HasPrefix(typ, "*.") && strings.HasSuffix(base, strings.TrimPrefix(typ, "*")) { | |
| 262 | + | return true | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | return false | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func hasRootMarker(cwd string, markers []string) bool { | |
| 269 | + | if len(markers) == 0 { | |
| 270 | + | return false | |
| 271 | + | } | |
| 272 | + | cwd, _ = absDir(cwd) | |
| 273 | + | for dir := cwd; dir != ""; dir = filepath.Dir(dir) { | |
| 274 | + | for _, marker := range markers { | |
| 275 | + | if _, err := os.Stat(filepath.Join(dir, marker)); err == nil { | |
| 276 | + | return true | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | next := filepath.Dir(dir) | |
| 280 | + | if next == dir { | |
| 281 | + | break | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | return false | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // FindRoot resolves a server's workspace root by walking up from cwd and | |
| 288 | + | // choosing the nearest directory containing one of its root markers. | |
| 289 | + | func FindRoot(cwd string, markers []string) string { | |
| 290 | + | cwd, err := absDir(cwd) | |
| 291 | + | if err != nil { | |
| 292 | + | return cwd | |
| 293 | + | } | |
| 294 | + | for dir := cwd; dir != ""; dir = filepath.Dir(dir) { | |
| 295 | + | for _, marker := range markers { | |
| 296 | + | if _, err := os.Stat(filepath.Join(dir, marker)); err == nil { | |
| 297 | + | return dir | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | next := filepath.Dir(dir) | |
| 301 | + | if next == dir { | |
| 302 | + | break | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | return cwd | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func sortServers(servers []ServerConfig) { | |
| 309 | + | rank := func(server ServerConfig) int { | |
| 310 | + | if server.Kind == "lsp" && !server.IsLinter { | |
| 311 | + | return 0 | |
| 312 | + | } | |
| 313 | + | if server.Kind == "lsp" { | |
| 314 | + | return 1 | |
| 315 | + | } | |
| 316 | + | return 2 | |
| 317 | + | } | |
| 318 | + | for i := 1; i < len(servers); i++ { | |
| 319 | + | for j := i; j > 0; j-- { | |
| 320 | + | left, right := servers[j-1], servers[j] | |
| 321 | + | if rank(left) < rank(right) || (rank(left) == rank(right) && left.ID <= right.ID) { | |
| 322 | + | break | |
| 323 | + | } | |
| 324 | + | servers[j-1], servers[j] = right, left | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func absDir(cwd string) (string, error) { | |
| 330 | + | if strings.TrimSpace(cwd) == "" { | |
| 331 | + | cwd, _ = os.Getwd() | |
| 332 | + | } | |
| 333 | + | abs, err := filepath.Abs(cwd) | |
| 334 | + | if err != nil { | |
| 335 | + | return "", err | |
| 336 | + | } | |
| 337 | + | info, err := os.Stat(abs) | |
| 338 | + | if err == nil && !info.IsDir() { | |
| 339 | + | return "", fmt.Errorf("workspace %q is not a directory", cwd) | |
| 340 | + | } | |
| 341 | + | return filepath.Clean(abs), nil | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func zotHome() string { | |
| 345 | + | if value := strings.TrimSpace(os.Getenv("ZOT_HOME")); value != "" { | |
| 346 | + | return value | |
| 347 | + | } | |
| 348 | + | if value := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); value != "" { | |
| 349 | + | return filepath.Join(value, "zot") | |
| 350 | + | } | |
| 351 | + | switch runtime.GOOS { | |
| 352 | + | case "darwin": | |
| 353 | + | home, _ := os.UserHomeDir() | |
| 354 | + | if home == "" { | |
| 355 | + | return "" | |
| 356 | + | } | |
| 357 | + | return filepath.Join(home, "Library", "Application Support", "zot") | |
| 358 | + | case "windows": | |
| 359 | + | if value := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); value != "" { | |
| 360 | + | return filepath.Join(value, "zot") | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | home, _ := os.UserHomeDir() | |
| 364 | + | if home == "" { | |
| 365 | + | return "" | |
| 366 | + | } | |
| 367 | + | return filepath.Join(home, ".local", "state", "zot") | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func providerIDs(data []byte) map[string]struct{} { | |
| 371 | + | ids := make(map[string]struct{}) | |
| 372 | + | var top struct { | |
| 373 | + | Providers []json.RawMessage `json:"providers"` | |
| 374 | + | } | |
| 375 | + | if json.Unmarshal(data, &top) != nil { | |
| 376 | + | return ids | |
| 377 | + | } | |
| 378 | + | for _, raw := range top.Providers { | |
| 379 | + | var id string | |
| 380 | + | if json.Unmarshal(raw, &id) == nil { | |
| 381 | + | if id = strings.TrimSpace(id); id != "" { | |
| 382 | + | ids[id] = struct{}{} | |
| 383 | + | } | |
| 384 | + | continue | |
| 385 | + | } | |
| 386 | + | var object struct { | |
| 387 | + | ID string `json:"id"` | |
| 388 | + | } | |
| 389 | + | if json.Unmarshal(raw, &object) == nil { | |
| 390 | + | if id = strings.TrimSpace(object.ID); id != "" { | |
| 391 | + | ids[id] = struct{}{} | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | return ids | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, []byte, error) { | |
| 399 | + | data, err := os.ReadFile(path) | |
| 400 | + | if errors.Is(err, os.ErrNotExist) { | |
| 401 | + | return nil, false, nil, nil | |
| 402 | + | } | |
| 403 | + | if err != nil { | |
| 404 | + | return nil, true, nil, err | |
| 405 | + | } | |
| 406 | + | var top map[string]json.RawMessage | |
| 407 | + | if err := json.Unmarshal(data, &top); err != nil { | |
| 408 | + | return nil, true, data, fmt.Errorf("read %s: %w", path, err) | |
| 409 | + | } | |
| 410 | + | root := top | |
| 411 | + | if raw, ok := top["providers"]; ok { | |
| 412 | + | // A providers array may contain compact string entries. Preserve | |
| 413 | + | // them (built-ins are already in the default registry) | |
| 414 | + | // and turn object entries into the map shape used internally. | |
| 415 | + | var providers []json.RawMessage | |
| 416 | + | if err := json.Unmarshal(raw, &providers); err != nil { | |
| 417 | + | return nil, true, data, fmt.Errorf("%s.providers must be an array: %w", path, err) | |
| 418 | + | } | |
| 419 | + | root = make(map[string]json.RawMessage) | |
| 420 | + | for index, provider := range providers { | |
| 421 | + | var id string | |
| 422 | + | if json.Unmarshal(provider, &id) == nil { | |
| 423 | + | continue | |
| 424 | + | } | |
| 425 | + | var object map[string]json.RawMessage | |
| 426 | + | if err := json.Unmarshal(provider, &object); err != nil { | |
| 427 | + | return nil, true, data, fmt.Errorf("%s.providers[%d] must be a string or object: %w", path, index, err) | |
| 428 | + | } | |
| 429 | + | idRaw, ok := object["id"] | |
| 430 | + | if !ok || json.Unmarshal(idRaw, &id) != nil || strings.TrimSpace(id) == "" { | |
| 431 | + | return nil, true, data, fmt.Errorf("%s.providers[%d] is missing id", path, index) | |
| 432 | + | } | |
| 433 | + | id = strings.TrimSpace(id) | |
| 434 | + | delete(object, "id") | |
| 435 | + | flattenProviderObject(object) | |
| 436 | + | root[id] = mustJSON(object) | |
| 437 | + | } | |
| 438 | + | } else { | |
| 439 | + | wrappedRoot := make(map[string]json.RawMessage) | |
| 440 | + | wrapped := false | |
| 441 | + | for _, key := range []string{"servers", "lspServers", "linters"} { | |
| 442 | + | if raw, ok := top[key]; ok { | |
| 443 | + | var section map[string]json.RawMessage | |
| 444 | + | if err := json.Unmarshal(raw, §ion); err != nil { | |
| 445 | + | return nil, true, data, fmt.Errorf("%s.%s must be an object: %w", path, key, err) | |
| 446 | + | } | |
| 447 | + | wrapped = true | |
| 448 | + | for id, value := range section { | |
| 449 | + | wrappedRoot[id] = value | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | if wrapped { | |
| 454 | + | root = wrappedRoot | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | out := make(map[string]map[string]json.RawMessage) | |
| 458 | + | for id, raw := range root { | |
| 459 | + | if id == "$schema" || id == "servers" || id == "lspServers" || id == "linters" || id == "providers" || id == "autoDetect" || id == "version" || id == "debug" || id == "status" || id == "lifecycle" || id == "excludePaths" || id == "idleTimeoutMs" { | |
| 460 | + | continue | |
| 461 | + | } | |
| 462 | + | var object map[string]json.RawMessage | |
| 463 | + | if err := json.Unmarshal(raw, &object); err != nil { | |
| 464 | + | // A string is a convenient shorthand for a command. | |
| 465 | + | var command string | |
| 466 | + | if json.Unmarshal(raw, &command) == nil { | |
| 467 | + | object = map[string]json.RawMessage{"command": json.RawMessage(strconvQuote(command))} | |
| 468 | + | } else { | |
| 469 | + | return nil, true, data, fmt.Errorf("%s.%s must be an object", path, id) | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | out[id] = object | |
| 473 | + | } | |
| 474 | + | return out, true, data, nil | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | func strconvQuote(s string) string { | |
| 478 | + | b, _ := json.Marshal(s) | |
| 479 | + | return string(b) | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | // flattenProviderObject adapts nested provider shapes to the flat | |
| 483 | + | // ServerConfig fields used by zot. Unknown provider metadata remains | |
| 484 | + | // harmless JSON and is ignored by decodeServer. | |
| 485 | + | func flattenProviderObject(object map[string]json.RawMessage) { | |
| 486 | + | if _, exists := object["command"]; !exists { | |
| 487 | + | var candidates []struct { | |
| 488 | + | Command string `json:"command"` | |
| 489 | + | Args []string `json:"args"` | |
| 490 | + | } | |
| 491 | + | if raw, ok := object["commandCandidates"]; ok && json.Unmarshal(raw, &candidates) == nil && len(candidates) > 0 && candidates[0].Command != "" { | |
| 492 | + | object["command"] = mustJSON(candidates[0].Command) | |
| 493 | + | if len(candidates[0].Args) > 0 { | |
| 494 | + | object["args"] = mustJSON(candidates[0].Args) | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | if _, exists := object["kind"]; !exists { | |
| 499 | + | if _, ok := object["cli"]; ok { | |
| 500 | + | object["kind"] = mustJSON("cli") | |
| 501 | + | } else if _, ok := object["lsp"]; ok { | |
| 502 | + | object["kind"] = mustJSON("lsp") | |
| 503 | + | } | |
| 504 | + | } | |
| 505 | + | if _, exists := object["fileTypes"]; !exists { | |
| 506 | + | var selectors []struct { | |
| 507 | + | LanguageID string `json:"languageId"` | |
| 508 | + | Extensions []string `json:"extensions"` | |
| 509 | + | Filenames []string `json:"filenames"` | |
| 510 | + | } | |
| 511 | + | if raw, ok := object["selectors"]; ok && json.Unmarshal(raw, &selectors) == nil { | |
| 512 | + | var fileTypes []string | |
| 513 | + | for _, selector := range selectors { | |
| 514 | + | if selector.LanguageID != "" { | |
| 515 | + | fileTypes = append(fileTypes, selector.LanguageID) | |
| 516 | + | } | |
| 517 | + | fileTypes = append(fileTypes, selector.Extensions...) | |
| 518 | + | fileTypes = append(fileTypes, selector.Filenames...) | |
| 519 | + | } | |
| 520 | + | if len(fileTypes) > 0 { | |
| 521 | + | object["fileTypes"] = mustJSON(uniqueStrings(fileTypes)) | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | if _, exists := object["fileTypes"]; !exists { | |
| 525 | + | var detect struct { | |
| 526 | + | FileExtensions []string `json:"fileExtensionsAny"` | |
| 527 | + | } | |
| 528 | + | if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil && len(detect.FileExtensions) > 0 { | |
| 529 | + | object["fileTypes"] = mustJSON(uniqueStrings(detect.FileExtensions)) | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | if _, exists := object["rootMarkers"]; !exists { | |
| 534 | + | var detect struct { | |
| 535 | + | RepoFiles []string `json:"repoFilesAny"` | |
| 536 | + | ConfigFiles []string `json:"configFilesAny"` | |
| 537 | + | } | |
| 538 | + | if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil { | |
| 539 | + | markers := append(append([]string{}, detect.RepoFiles...), detect.ConfigFiles...) | |
| 540 | + | if len(markers) > 0 { | |
| 541 | + | object["rootMarkers"] = mustJSON(uniqueStrings(markers)) | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | if _, exists := object["rootMarkers"]; !exists { | |
| 545 | + | if raw, ok := object["configMarkers"]; ok { | |
| 546 | + | object["rootMarkers"] = append(json.RawMessage(nil), raw...) | |
| 547 | + | } | |
| 548 | + | } | |
| 549 | + | } | |
| 550 | + | if raw, ok := object["lsp"]; ok { | |
| 551 | + | var nested map[string]json.RawMessage | |
| 552 | + | if json.Unmarshal(raw, &nested) == nil { | |
| 553 | + | if _, exists := object["initializationOptions"]; !exists { | |
| 554 | + | if value, ok := nested["initializationOptions"]; ok { | |
| 555 | + | object["initializationOptions"] = append(json.RawMessage(nil), value...) | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | } | |
| 559 | + | } | |
| 560 | + | if raw, ok := object["cli"]; ok { | |
| 561 | + | var nested map[string]json.RawMessage | |
| 562 | + | if json.Unmarshal(raw, &nested) == nil { | |
| 563 | + | for _, key := range []string{"parser", "mode", "runOnStartup", "runOnChange"} { | |
| 564 | + | if _, exists := object[key]; exists { | |
| 565 | + | continue | |
| 566 | + | } | |
| 567 | + | if value, ok := nested[key]; ok { | |
| 568 | + | object[key] = append(json.RawMessage(nil), value...) | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | } | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | func uniqueStrings(values []string) []string { | |
| 576 | + | seen := make(map[string]struct{}, len(values)) | |
| 577 | + | out := make([]string, 0, len(values)) | |
| 578 | + | for _, value := range values { | |
| 579 | + | if strings.TrimSpace(value) == "" { | |
| 580 | + | continue | |
| 581 | + | } | |
| 582 | + | if _, exists := seen[value]; exists { | |
| 583 | + | continue | |
| 584 | + | } | |
| 585 | + | seen[value] = struct{}{} | |
| 586 | + | out = append(out, value) | |
| 587 | + | } | |
| 588 | + | return out | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | func decodeServer(id string, raw map[string]json.RawMessage) (ServerConfig, error) { | |
| 592 | + | if _, exists := raw["initializationOptions"]; !exists { | |
| 593 | + | if value, ok := raw["initOptions"]; ok { | |
| 594 | + | raw["initializationOptions"] = append(json.RawMessage(nil), value...) | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | if command, ok := raw["command"]; ok { | |
| 598 | + | var list []string | |
| 599 | + | if err := json.Unmarshal(command, &list); err == nil { | |
| 600 | + | if len(list) == 0 { | |
| 601 | + | return ServerConfig{}, fmt.Errorf("command array is empty") | |
| 602 | + | } | |
| 603 | + | raw["command"] = json.RawMessage(strconvQuote(list[0])) | |
| 604 | + | if len(list) > 1 { | |
| 605 | + | args, _ := json.Marshal(list[1:]) | |
| 606 | + | if _, exists := raw["args"]; !exists { | |
| 607 | + | raw["args"] = args | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | } | |
| 611 | + | } | |
| 612 | + | data, err := json.Marshal(raw) | |
| 613 | + | if err != nil { | |
| 614 | + | return ServerConfig{}, err | |
| 615 | + | } | |
| 616 | + | var server ServerConfig | |
| 617 | + | if err := json.Unmarshal(data, &server); err != nil { | |
| 618 | + | return ServerConfig{}, err | |
| 619 | + | } | |
| 620 | + | server.ID = id | |
| 621 | + | if server.Kind == "" { | |
| 622 | + | if server.Parser != "" || server.Mode != "" { | |
| 623 | + | server.Kind = "cli" | |
| 624 | + | } else { | |
| 625 | + | server.Kind = "lsp" | |
| 626 | + | } | |
| 627 | + | } | |
| 628 | + | server.Kind = strings.ToLower(server.Kind) | |
| 629 | + | if server.Kind != "lsp" && server.Kind != "cli" { | |
| 630 | + | return ServerConfig{}, fmt.Errorf("kind must be lsp or cli") | |
| 631 | + | } | |
| 632 | + | if server.Kind == "cli" && server.Parser == "" { | |
| 633 | + | server.Parser = "generic" | |
| 634 | + | } | |
| 635 | + | return server, nil | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | func cloneRawObject(in map[string]json.RawMessage) map[string]json.RawMessage { | |
| 639 | + | out := make(map[string]json.RawMessage, len(in)) | |
| 640 | + | for key, value := range in { | |
| 641 | + | out[key] = append(json.RawMessage(nil), value...) | |
| 642 | + | } | |
| 643 | + | return out | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | func mergeRawObjects(base, override map[string]json.RawMessage) map[string]json.RawMessage { | |
| 647 | + | out := cloneRawObject(base) | |
| 648 | + | for key, value := range override { | |
| 649 | + | var baseObject, overrideObject map[string]json.RawMessage | |
| 650 | + | if json.Unmarshal(out[key], &baseObject) == nil && json.Unmarshal(value, &overrideObject) == nil { | |
| 651 | + | out[key] = mustJSON(mergeRawObjects(baseObject, overrideObject)) | |
| 652 | + | continue | |
| 653 | + | } | |
| 654 | + | out[key] = append(json.RawMessage(nil), value...) | |
| 655 | + | } | |
| 656 | + | return out | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | func mustJSON(value any) json.RawMessage { | |
| 660 | + | data, _ := json.Marshal(value) | |
| 661 | + | return data | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | func languageForExtension(ext string) string { | |
| 665 | + | switch strings.ToLower(ext) { | |
| 666 | + | case ".go": | |
| 667 | + | return "go" | |
| 668 | + | case ".rs": | |
| 669 | + | return "rust" | |
| 670 | + | case ".py": | |
| 671 | + | return "python" | |
| 672 | + | case ".ts": | |
| 673 | + | return "typescript" | |
| 674 | + | case ".tsx": | |
| 675 | + | return "typescriptreact" | |
| 676 | + | case ".js", ".mjs", ".cjs": | |
| 677 | + | return "javascript" | |
| 678 | + | case ".jsx": | |
| 679 | + | return "javascriptreact" | |
| 680 | + | case ".c", ".h": | |
| 681 | + | return "c" | |
| 682 | + | case ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx": | |
| 683 | + | return "cpp" | |
| 684 | + | case ".yaml", ".yml": | |
| 685 | + | return "yaml" | |
| 686 | + | case ".json", ".jsonc": | |
| 687 | + | return "json" | |
| 688 | + | case ".sh", ".bash", ".zsh": | |
| 689 | + | return "shellscript" | |
| 690 | + | default: | |
| 691 | + | return "" | |
| 692 | + | } | |
| 693 | + | } | |
A packages/agent/lsp/diagnostics.go +332 -0
| @@ -0,0 +1,332 @@ | |||
| 1 | + | package lsp | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "path/filepath" | |
| 6 | + | "regexp" | |
| 7 | + | "sort" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const DefaultDiagnosticCap = 50 | |
| 12 | + | ||
| 13 | + | var missingModulePatterns = []*regexp.Regexp{ | |
| 14 | + | regexp.MustCompile(`(?i)cannot find module ["']([^"']+)["']`), | |
| 15 | + | regexp.MustCompile(`(?i)could not find (?:a declaration file for )?module ["']([^"']+)["']`), | |
| 16 | + | regexp.MustCompile(`(?i)(?:cannot|could not) resolve (?:module|import|package) ["']?([^"' ]+)["']?`), | |
| 17 | + | regexp.MustCompile(`(?i)import .*?(["'][^"']+["']).*could not be resolved`), | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // DiagnosticKey is stable across providers. Server/source are intentionally | |
| 21 | + | // excluded so an LSP and a CLI linter reporting the same issue collapse. | |
| 22 | + | func DiagnosticKey(d Diagnostic) string { | |
| 23 | + | // Match the bridge's merge key: the start location is stable enough to | |
| 24 | + | // identify one report while providers may disagree on the end range. | |
| 25 | + | return strings.Join([]string{d.Path, defaultSeverity(d.Severity), d.Code, | |
| 26 | + | fmt.Sprintf("%d:%d", d.Range.Start.Line, d.Range.Start.Character), compactWhitespace(d.Message)}, "\x00") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func diagnosticIssueKey(d Diagnostic) string { | |
| 30 | + | return strings.Join([]string{defaultSeverity(d.Severity), d.Code, compactWhitespace(d.Message)}, "\x00") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // DiagnosticIdentity identifies the issue independently of its current | |
| 34 | + | // location. It is used by write/edit diagnostics to avoid replaying the same | |
| 35 | + | // error after a harmless line shift while still re-surfacing it after removal. | |
| 36 | + | func DiagnosticIdentity(d Diagnostic) string { | |
| 37 | + | return diagnosticIssueKey(d) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // DeduplicateDiagnostics removes duplicates across servers and sorts errors, | |
| 41 | + | // warnings, info, and hints in that order, then path and location. | |
| 42 | + | func DeduplicateDiagnostics(input []Diagnostic) []Diagnostic { | |
| 43 | + | seen := make(map[string]Diagnostic, len(input)) | |
| 44 | + | for _, diagnostic := range input { | |
| 45 | + | key := DiagnosticKey(diagnostic) | |
| 46 | + | if old, exists := seen[key]; !exists || old.Server == "" { | |
| 47 | + | seen[key] = diagnostic | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | out := make([]Diagnostic, 0, len(seen)) | |
| 51 | + | for _, diagnostic := range seen { | |
| 52 | + | out = append(out, diagnostic) | |
| 53 | + | } | |
| 54 | + | sort.SliceStable(out, func(i, j int) bool { | |
| 55 | + | if severityRank(out[i].Severity) != severityRank(out[j].Severity) { | |
| 56 | + | return severityRank(out[i].Severity) < severityRank(out[j].Severity) | |
| 57 | + | } | |
| 58 | + | if out[i].Path != out[j].Path { | |
| 59 | + | return out[i].Path < out[j].Path | |
| 60 | + | } | |
| 61 | + | if out[i].Range.Start.Line != out[j].Range.Start.Line { | |
| 62 | + | return out[i].Range.Start.Line < out[j].Range.Start.Line | |
| 63 | + | } | |
| 64 | + | if out[i].Range.Start.Character != out[j].Range.Start.Character { | |
| 65 | + | return out[i].Range.Start.Character < out[j].Range.Start.Character | |
| 66 | + | } | |
| 67 | + | if out[i].Message != out[j].Message { | |
| 68 | + | return out[i].Message < out[j].Message | |
| 69 | + | } | |
| 70 | + | return out[i].Code < out[j].Code | |
| 71 | + | }) | |
| 72 | + | return out | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func severityRank(value string) int { | |
| 76 | + | switch strings.ToLower(value) { | |
| 77 | + | case "error": | |
| 78 | + | return 0 | |
| 79 | + | case "warning": | |
| 80 | + | return 1 | |
| 81 | + | case "info": | |
| 82 | + | return 2 | |
| 83 | + | case "hint": | |
| 84 | + | return 3 | |
| 85 | + | default: | |
| 86 | + | return 2 | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // SummarizeDiagnostics renders a bounded diagnostic summary. Repeated issues | |
| 91 | + | // are represented once with occurrence/file counts; module-resolution errors | |
| 92 | + | // retain their root issue while grouping the cascade of secondary errors. | |
| 93 | + | func SummarizeDiagnostics(input []Diagnostic, root string, cap int) string { | |
| 94 | + | if cap <= 0 { | |
| 95 | + | cap = DefaultDiagnosticCap | |
| 96 | + | } | |
| 97 | + | diagnostics := DeduplicateDiagnostics(input) | |
| 98 | + | if len(diagnostics) == 0 { | |
| 99 | + | return "No diagnostics." | |
| 100 | + | } | |
| 101 | + | counts := [4]int{} | |
| 102 | + | for _, diagnostic := range diagnostics { | |
| 103 | + | switch severityRank(diagnostic.Severity) { | |
| 104 | + | case 0: | |
| 105 | + | counts[0]++ | |
| 106 | + | case 1: | |
| 107 | + | counts[1]++ | |
| 108 | + | case 2: | |
| 109 | + | counts[2]++ | |
| 110 | + | case 3: | |
| 111 | + | counts[3]++ | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | parts := make([]string, 0, 4) | |
| 115 | + | for i, label := range []string{"error", "warning", "info", "hint"} { | |
| 116 | + | if counts[i] > 0 { | |
| 117 | + | parts = append(parts, fmt.Sprintf("%d %s%s", counts[i], label, plural(counts[i]))) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | lines := []string{fmt.Sprintf("Diagnostics: %s (deduplicated %d)", strings.Join(parts, ", "), len(diagnostics))} | |
| 121 | + | secondary := moduleSecondary(diagnostics) | |
| 122 | + | groups := make(map[string][]Diagnostic) | |
| 123 | + | for _, diagnostic := range diagnostics { | |
| 124 | + | if secondary[DiagnosticKey(diagnostic)] { | |
| 125 | + | continue | |
| 126 | + | } | |
| 127 | + | key := diagnosticIssueKey(diagnostic) | |
| 128 | + | groups[key] = append(groups[key], diagnostic) | |
| 129 | + | } | |
| 130 | + | emitted := make(map[string]bool) | |
| 131 | + | emittedSecondary := make(map[string]bool) | |
| 132 | + | displayed := 0 | |
| 133 | + | for _, diagnostic := range diagnostics { | |
| 134 | + | if displayed >= cap { | |
| 135 | + | break | |
| 136 | + | } | |
| 137 | + | if secondary[DiagnosticKey(diagnostic)] { | |
| 138 | + | continue | |
| 139 | + | } | |
| 140 | + | groupKey := diagnosticIssueKey(diagnostic) | |
| 141 | + | bucket := groups[groupKey] | |
| 142 | + | if len(bucket) >= 3 || uniquePaths(bucket) >= 2 { | |
| 143 | + | if emitted[groupKey] { | |
| 144 | + | continue | |
| 145 | + | } | |
| 146 | + | emitted[groupKey] = true | |
| 147 | + | lines = append(lines, formatRepeated(bucket, root)) | |
| 148 | + | displayed += len(bucket) | |
| 149 | + | } else { | |
| 150 | + | lines = append(lines, "- "+FormatDiagnostic(diagnostic, root)) | |
| 151 | + | displayed++ | |
| 152 | + | } | |
| 153 | + | if displayed < cap && missingModuleName(diagnostic) != "" && !emittedSecondary[diagnostic.Path] { | |
| 154 | + | if secondaryLine, secondaryCount := formatSecondaryForFile(diagnostic.Path, diagnostics, secondary, root); secondaryLine != "" { | |
| 155 | + | lines = append(lines, "- "+secondaryLine) | |
| 156 | + | emittedSecondary[diagnostic.Path] = true | |
| 157 | + | displayed += secondaryCount | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | omitted := len(diagnostics) - minInt(displayed, len(diagnostics)) | |
| 162 | + | if omitted > 0 { | |
| 163 | + | lines = append(lines, fmt.Sprintf("... truncated; %d diagnostics omitted (hard cap %d)", omitted, cap)) | |
| 164 | + | } | |
| 165 | + | return strings.Join(lines, "\n") | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func formatRepeated(bucket []Diagnostic, root string) string { | |
| 169 | + | paths := make(map[string]bool) | |
| 170 | + | lines := make(map[int]bool) | |
| 171 | + | for _, diagnostic := range bucket { | |
| 172 | + | paths[diagnostic.Path] = true | |
| 173 | + | lines[diagnostic.Range.Start.Line+1] = true | |
| 174 | + | } | |
| 175 | + | example := make([]string, 0, 3) | |
| 176 | + | for _, diagnostic := range bucket { | |
| 177 | + | path := diagnostic.Path | |
| 178 | + | if root != "" { | |
| 179 | + | if rel, err := filepathRel(root, path); err == nil { | |
| 180 | + | path = rel | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | if !contains(example, path) && len(example) < 3 { | |
| 184 | + | example = append(example, path) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | extra := "" | |
| 188 | + | if len(example) > 0 { | |
| 189 | + | extra = fmt.Sprintf(" (examples: %s)", strings.Join(example, ", ")) | |
| 190 | + | } | |
| 191 | + | code := "" | |
| 192 | + | if bucket[0].Code != "" { | |
| 193 | + | code = " [" + bucket[0].Code + "]" | |
| 194 | + | } | |
| 195 | + | where := fmt.Sprintf("%d occurrences across %d files", len(bucket), len(paths)) | |
| 196 | + | if len(paths) == 1 { | |
| 197 | + | where = fmt.Sprintf("%d occurrences in %s", len(bucket), example[0]) | |
| 198 | + | } | |
| 199 | + | if len(paths) == 1 && len(lines) > 0 { | |
| 200 | + | where += fmt.Sprintf(" (lines: %s)", joinInts(lines, 3)) | |
| 201 | + | } | |
| 202 | + | message := truncateText(compactWhitespace(bucket[0].Message), 220) | |
| 203 | + | return fmt.Sprintf("%s: repeated %s%s %s%s", strings.ToUpper(defaultSeverity(bucket[0].Severity)), where, code, message, extra) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func moduleSecondary(diagnostics []Diagnostic) map[string]bool { | |
| 207 | + | roots := make(map[string]bool) | |
| 208 | + | byFile := make(map[string][]Diagnostic) | |
| 209 | + | for _, diagnostic := range diagnostics { | |
| 210 | + | byFile[diagnostic.Path] = append(byFile[diagnostic.Path], diagnostic) | |
| 211 | + | if missingModuleName(diagnostic) != "" { | |
| 212 | + | roots[diagnostic.Path] = true | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | secondary := make(map[string]bool) | |
| 216 | + | for path, fileDiagnostics := range byFile { | |
| 217 | + | if !roots[path] { | |
| 218 | + | continue | |
| 219 | + | } | |
| 220 | + | count := 0 | |
| 221 | + | for _, diagnostic := range fileDiagnostics { | |
| 222 | + | if missingModuleName(diagnostic) == "" { | |
| 223 | + | count++ | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | if count < 2 { | |
| 227 | + | continue | |
| 228 | + | } | |
| 229 | + | for _, diagnostic := range fileDiagnostics { | |
| 230 | + | if missingModuleName(diagnostic) == "" { | |
| 231 | + | secondary[DiagnosticKey(diagnostic)] = true | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | return secondary | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map[string]bool, root string) (string, int) { | |
| 239 | + | count := 0 | |
| 240 | + | modules := make(map[string]bool) | |
| 241 | + | for _, diagnostic := range diagnostics { | |
| 242 | + | if diagnostic.Path == path && secondary[DiagnosticKey(diagnostic)] { | |
| 243 | + | count++ | |
| 244 | + | } | |
| 245 | + | if diagnostic.Path == path && missingModuleName(diagnostic) != "" { | |
| 246 | + | modules[missingModuleName(diagnostic)] = true | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | if count == 0 { | |
| 250 | + | return "", 0 | |
| 251 | + | } | |
| 252 | + | shownPath := path | |
| 253 | + | if root != "" { | |
| 254 | + | if rel, err := filepathRel(root, path); err == nil { | |
| 255 | + | shownPath = rel | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | moduleNames := make([]string, 0, len(modules)) | |
| 259 | + | for name := range modules { | |
| 260 | + | moduleNames = append(moduleNames, name) | |
| 261 | + | } | |
| 262 | + | sort.Strings(moduleNames) | |
| 263 | + | return fmt.Sprintf("SECONDARY: grouped %d additional diagnostics in %s under module-resolution error (%s). Fix the root import error first, then re-check.", count, shownPath, strings.Join(moduleNames, ", ")), count | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func missingModuleName(diagnostic Diagnostic) string { | |
| 267 | + | if strings.ToLower(diagnostic.Severity) != "error" { | |
| 268 | + | return "" | |
| 269 | + | } | |
| 270 | + | for _, pattern := range missingModulePatterns { | |
| 271 | + | if match := pattern.FindStringSubmatch(diagnostic.Message); len(match) > 1 { | |
| 272 | + | return strings.Trim(match[1], `"'`) | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | return "" | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func uniquePaths(diagnostics []Diagnostic) int { | |
| 279 | + | paths := make(map[string]bool) | |
| 280 | + | for _, diagnostic := range diagnostics { | |
| 281 | + | paths[diagnostic.Path] = true | |
| 282 | + | } | |
| 283 | + | return len(paths) | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func plural(n int) string { | |
| 287 | + | if n == 1 { | |
| 288 | + | return "" | |
| 289 | + | } | |
| 290 | + | return "s" | |
| 291 | + | } | |
| 292 | + | func minInt(a, b int) int { | |
| 293 | + | if a < b { | |
| 294 | + | return a | |
| 295 | + | } | |
| 296 | + | return b | |
| 297 | + | } | |
| 298 | + | func contains(values []string, value string) bool { | |
| 299 | + | for _, item := range values { | |
| 300 | + | if item == value { | |
| 301 | + | return true | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | return false | |
| 305 | + | } | |
| 306 | + | func joinInts(values map[int]bool, max int) string { | |
| 307 | + | ordered := make([]int, 0, len(values)) | |
| 308 | + | for value := range values { | |
| 309 | + | ordered = append(ordered, value) | |
| 310 | + | } | |
| 311 | + | sort.Ints(ordered) | |
| 312 | + | if len(ordered) > max { | |
| 313 | + | ordered = ordered[:max] | |
| 314 | + | } | |
| 315 | + | parts := make([]string, len(ordered)) | |
| 316 | + | for i, value := range ordered { | |
| 317 | + | parts[i] = fmt.Sprint(value) | |
| 318 | + | } | |
| 319 | + | return strings.Join(parts, ", ") | |
| 320 | + | } | |
| 321 | + | func filepathRel(root, path string) (string, error) { | |
| 322 | + | // Kept here rather than importing agent/tools: lsp is intentionally | |
| 323 | + | // independent of the tool package. | |
| 324 | + | rel, err := filepath.Rel(root, path) | |
| 325 | + | if err != nil { | |
| 326 | + | return "", err | |
| 327 | + | } | |
| 328 | + | if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |
| 329 | + | return path, nil | |
| 330 | + | } | |
| 331 | + | return filepath.ToSlash(rel), nil | |
| 332 | + | } | |
A packages/agent/lsp/edits.go +252 -0
| @@ -0,0 +1,252 @@ | |||
| 1 | + | package lsp | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sort" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const maxWorkspaceEditFileBytes = 8 * 1024 * 1024 | |
| 14 | + | ||
| 15 | + | // ApplyWorkspaceEdit applies only text edits inside root. It rejects create, | |
| 16 | + | // rename, delete, non-file URIs, symlink escapes, malformed ranges, and files | |
| 17 | + | // larger than the bounded safety limit. | |
| 18 | + | func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | |
| 19 | + | return applyWorkspaceEdit(root, edit, nil) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func applyWorkspaceEdit(root string, edit WorkspaceEdit, checkWritePath func(string) error) error { | |
| 23 | + | root, err := filepath.Abs(root) | |
| 24 | + | if err != nil { | |
| 25 | + | return err | |
| 26 | + | } | |
| 27 | + | rootReal, err := filepath.EvalSymlinks(root) | |
| 28 | + | if err != nil { | |
| 29 | + | return fmt.Errorf("workspace root: %w", err) | |
| 30 | + | } | |
| 31 | + | all := make(map[string][]TextEdit, len(edit.Changes)+len(edit.DocumentChanges)) | |
| 32 | + | for uri, changes := range edit.Changes { | |
| 33 | + | all[uri] = append(all[uri], changes...) | |
| 34 | + | } | |
| 35 | + | for _, raw := range edit.DocumentChanges { | |
| 36 | + | var change struct { | |
| 37 | + | TextDocument struct { | |
| 38 | + | URI string `json:"uri"` | |
| 39 | + | } `json:"textDocument"` | |
| 40 | + | Edits []TextEdit `json:"edits"` | |
| 41 | + | Kind string `json:"kind"` | |
| 42 | + | } | |
| 43 | + | if err := json.Unmarshal(raw, &change); err != nil { | |
| 44 | + | return fmt.Errorf("workspace edit document change: %w", err) | |
| 45 | + | } | |
| 46 | + | if change.Kind != "" || change.TextDocument.URI == "" { | |
| 47 | + | return fmt.Errorf("workspace edit contains an unsupported resource operation") | |
| 48 | + | } | |
| 49 | + | all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...) | |
| 50 | + | } | |
| 51 | + | targets, err := prepareEditTargets(rootReal, all, checkWritePath) | |
| 52 | + | if err != nil { | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | type plannedEdit struct { | |
| 56 | + | location editTarget | |
| 57 | + | output []byte | |
| 58 | + | mode os.FileMode | |
| 59 | + | } | |
| 60 | + | plans := make([]plannedEdit, 0, len(targets)) | |
| 61 | + | for _, location := range targets { | |
| 62 | + | if len(location.changes) == 0 { | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | output, mode, err := prepareTextEdits(location.target, location.changes) | |
| 66 | + | if err != nil { | |
| 67 | + | return fmt.Errorf("validate edit to %s: %w", location.path, err) | |
| 68 | + | } | |
| 69 | + | plans = append(plans, plannedEdit{location: location, output: output, mode: mode}) | |
| 70 | + | } | |
| 71 | + | for _, plan := range plans { | |
| 72 | + | if err := writeTextEdits(plan.location.target, plan.output, plan.mode); err != nil { | |
| 73 | + | return fmt.Errorf("apply edit to %s: %w", plan.location.path, err) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | type editTarget struct { | |
| 80 | + | path string | |
| 81 | + | target string | |
| 82 | + | changes []TextEdit | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func prepareEditTargets(rootReal string, all map[string][]TextEdit, checkWritePath func(string) error) ([]editTarget, error) { | |
| 86 | + | targets := make([]editTarget, 0, len(all)) | |
| 87 | + | for uri, changes := range all { | |
| 88 | + | path, err := uriToPath(uri) | |
| 89 | + | if err != nil { | |
| 90 | + | return nil, err | |
| 91 | + | } | |
| 92 | + | if err := safeWorkspacePath(rootReal, path); err != nil { | |
| 93 | + | return nil, err | |
| 94 | + | } | |
| 95 | + | target, err := filepath.EvalSymlinks(path) | |
| 96 | + | if err != nil { | |
| 97 | + | return nil, fmt.Errorf("workspace edit target %q: %w", path, err) | |
| 98 | + | } | |
| 99 | + | if err := safeWorkspacePath(rootReal, target); err != nil { | |
| 100 | + | return nil, err | |
| 101 | + | } | |
| 102 | + | if checkWritePath != nil { | |
| 103 | + | if err := checkWritePath(target); err != nil { | |
| 104 | + | return nil, err | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | targets = append(targets, editTarget{path: path, target: target, changes: changes}) | |
| 108 | + | } | |
| 109 | + | return targets, nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func safeWorkspacePath(root, path string) error { | |
| 113 | + | abs, err := filepath.Abs(path) | |
| 114 | + | if err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | candidate := abs | |
| 118 | + | if resolved, err := filepath.EvalSymlinks(abs); err == nil { | |
| 119 | + | candidate = resolved | |
| 120 | + | } else { | |
| 121 | + | parent := filepath.Dir(abs) | |
| 122 | + | if resolvedParent, parentErr := filepath.EvalSymlinks(parent); parentErr == nil { | |
| 123 | + | candidate = filepath.Join(resolvedParent, filepath.Base(abs)) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | rel, err := filepath.Rel(root, candidate) | |
| 127 | + | if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { | |
| 128 | + | return fmt.Errorf("workspace edit path %q is outside workspace", path) | |
| 129 | + | } | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | type byteEdit struct { | |
| 134 | + | start int | |
| 135 | + | end int | |
| 136 | + | text string | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func prepareTextEdits(path string, edits []TextEdit) ([]byte, os.FileMode, error) { | |
| 140 | + | info, err := os.Stat(path) | |
| 141 | + | if err != nil { | |
| 142 | + | return nil, 0, err | |
| 143 | + | } | |
| 144 | + | if info.Size() > maxWorkspaceEditFileBytes { | |
| 145 | + | return nil, 0, fmt.Errorf("file is larger than %d bytes", maxWorkspaceEditFileBytes) | |
| 146 | + | } | |
| 147 | + | data, err := os.ReadFile(path) | |
| 148 | + | if err != nil { | |
| 149 | + | return nil, 0, err | |
| 150 | + | } | |
| 151 | + | converted := make([]byteEdit, 0, len(edits)) | |
| 152 | + | for _, edit := range edits { | |
| 153 | + | start, err := positionOffset(data, edit.Range.Start) | |
| 154 | + | if err != nil { | |
| 155 | + | return nil, 0, err | |
| 156 | + | } | |
| 157 | + | end, err := positionOffset(data, edit.Range.End) | |
| 158 | + | if err != nil { | |
| 159 | + | return nil, 0, err | |
| 160 | + | } | |
| 161 | + | if end < start { | |
| 162 | + | return nil, 0, fmt.Errorf("edit range ends before it starts") | |
| 163 | + | } | |
| 164 | + | converted = append(converted, byteEdit{start: start, end: end, text: edit.NewText}) | |
| 165 | + | } | |
| 166 | + | sort.Slice(converted, func(i, j int) bool { | |
| 167 | + | if converted[i].start != converted[j].start { | |
| 168 | + | return converted[i].start > converted[j].start | |
| 169 | + | } | |
| 170 | + | return converted[i].end > converted[j].end | |
| 171 | + | }) | |
| 172 | + | for i := 1; i < len(converted); i++ { | |
| 173 | + | if converted[i].end > converted[i-1].start { | |
| 174 | + | return nil, 0, fmt.Errorf("workspace text edits overlap") | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | var builder bytes.Buffer | |
| 178 | + | cursor := 0 | |
| 179 | + | for i := len(converted) - 1; i >= 0; i-- { | |
| 180 | + | edit := converted[i] | |
| 181 | + | _, _ = builder.Write(data[cursor:edit.start]) | |
| 182 | + | _, _ = builder.WriteString(edit.text) | |
| 183 | + | cursor = edit.end | |
| 184 | + | } | |
| 185 | + | _, _ = builder.Write(data[cursor:]) | |
| 186 | + | return builder.Bytes(), info.Mode().Perm(), nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func writeTextEdits(path string, out []byte, mode os.FileMode) error { | |
| 190 | + | tmp, err := os.CreateTemp(filepath.Dir(path), ".zot-lsp-edit-*") | |
| 191 | + | if err != nil { | |
| 192 | + | return err | |
| 193 | + | } | |
| 194 | + | tmpName := tmp.Name() | |
| 195 | + | defer func() { _ = os.Remove(tmpName) }() | |
| 196 | + | if err := tmp.Chmod(mode); err != nil { | |
| 197 | + | _ = tmp.Close() | |
| 198 | + | return err | |
| 199 | + | } | |
| 200 | + | if _, err := tmp.Write(out); err != nil { | |
| 201 | + | _ = tmp.Close() | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | if err := tmp.Sync(); err != nil { | |
| 205 | + | _ = tmp.Close() | |
| 206 | + | return err | |
| 207 | + | } | |
| 208 | + | if err := tmp.Close(); err != nil { | |
| 209 | + | return err | |
| 210 | + | } | |
| 211 | + | return os.Rename(tmpName, path) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func positionOffset(data []byte, position Position) (int, error) { | |
| 215 | + | if position.Line < 0 || position.Character < 0 { | |
| 216 | + | return 0, fmt.Errorf("invalid LSP position %d:%d", position.Line, position.Character) | |
| 217 | + | } | |
| 218 | + | lineStart := 0 | |
| 219 | + | line := 0 | |
| 220 | + | for line < position.Line { | |
| 221 | + | next := strings.IndexByte(string(data[lineStart:]), '\n') | |
| 222 | + | if next < 0 { | |
| 223 | + | return 0, fmt.Errorf("LSP line %d is outside file", position.Line) | |
| 224 | + | } | |
| 225 | + | lineStart += next + 1 | |
| 226 | + | line++ | |
| 227 | + | } | |
| 228 | + | lineEnd := len(data) | |
| 229 | + | if next := strings.IndexByte(string(data[lineStart:]), '\n'); next >= 0 { | |
| 230 | + | lineEnd = lineStart + next | |
| 231 | + | } | |
| 232 | + | lineBytes := data[lineStart:lineEnd] | |
| 233 | + | if len(lineBytes) > 0 && lineBytes[len(lineBytes)-1] == '\r' { | |
| 234 | + | lineBytes = lineBytes[:len(lineBytes)-1] | |
| 235 | + | lineEnd-- | |
| 236 | + | } | |
| 237 | + | units := 0 | |
| 238 | + | for offset, r := range string(lineBytes) { | |
| 239 | + | if units == position.Character { | |
| 240 | + | return lineStart + offset, nil | |
| 241 | + | } | |
| 242 | + | if r > 0xffff { | |
| 243 | + | units += 2 | |
| 244 | + | } else { | |
| 245 | + | units++ | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | if units == position.Character { | |
| 249 | + | return lineEnd, nil | |
| 250 | + | } | |
| 251 | + | return 0, fmt.Errorf("LSP character %d is outside line %d", position.Character, position.Line) | |
| 252 | + | } | |
A packages/agent/lsp/framing.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | package lsp | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | maxMessageBytes = 32 * 1024 * 1024 | |
| 15 | + | maxHeaderBytes = 8 * 1024 | |
| 16 | + | maxHeaderLines = 64 | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // WriteMessage writes one LSP Content-Length framed JSON-RPC message. | |
| 20 | + | func WriteMessage(w io.Writer, payload []byte) error { | |
| 21 | + | if len(payload) > maxMessageBytes { | |
| 22 | + | return fmt.Errorf("LSP message is too large: %d bytes", len(payload)) | |
| 23 | + | } | |
| 24 | + | header := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(payload))) | |
| 25 | + | if n, err := w.Write(header); err != nil { | |
| 26 | + | return err | |
| 27 | + | } else if n != len(header) { | |
| 28 | + | return io.ErrShortWrite | |
| 29 | + | } | |
| 30 | + | n, err := w.Write(payload) | |
| 31 | + | if err == nil && n != len(payload) { | |
| 32 | + | return io.ErrShortWrite | |
| 33 | + | } | |
| 34 | + | return err | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // ReadMessage reads one Content-Length framed LSP message. It accepts other | |
| 38 | + | // headers (including Content-Type) and either CRLF or LF header endings. | |
| 39 | + | func ReadMessage(r *bufio.Reader) ([]byte, error) { | |
| 40 | + | length := -1 | |
| 41 | + | headerBytes := 0 | |
| 42 | + | headerLines := 0 | |
| 43 | + | for { | |
| 44 | + | line, err := r.ReadSlice('\n') | |
| 45 | + | if err != nil { | |
| 46 | + | if err == bufio.ErrBufferFull { | |
| 47 | + | return nil, errors.New("LSP headers are too large") | |
| 48 | + | } | |
| 49 | + | return nil, err | |
| 50 | + | } | |
| 51 | + | headerBytes += len(line) | |
| 52 | + | headerLines++ | |
| 53 | + | if headerBytes > maxHeaderBytes || headerLines > maxHeaderLines { | |
| 54 | + | return nil, errors.New("LSP headers are too large") | |
| 55 | + | } | |
| 56 | + | lineText := strings.TrimRight(string(line), "\r\n") | |
| 57 | + | if lineText == "" { | |
| 58 | + | break | |
| 59 | + | } | |
| 60 | + | key, value, ok := strings.Cut(lineText, ":") | |
| 61 | + | if !ok { | |
| 62 | + | return nil, fmt.Errorf("malformed LSP header %q", lineText) | |
| 63 | + | } | |
| 64 | + | if strings.EqualFold(strings.TrimSpace(key), "content-length") { | |
| 65 | + | n, err := strconv.Atoi(strings.TrimSpace(value)) | |
| 66 | + | if err != nil || n < 0 { | |
| 67 | + | return nil, fmt.Errorf("invalid Content-Length %q", strings.TrimSpace(value)) | |
| 68 | + | } | |
| 69 | + | length = n | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | if length < 0 { | |
| 73 | + | return nil, errors.New("LSP message has no Content-Length") | |
| 74 | + | } | |
| 75 | + | if length > maxMessageBytes { | |
| 76 | + | return nil, fmt.Errorf("LSP message is too large: %d bytes", length) | |
| 77 | + | } | |
| 78 | + | payload := make([]byte, length) | |
| 79 | + | if _, err := io.ReadFull(r, payload); err != nil { | |
| 80 | + | return nil, err | |
| 81 | + | } | |
| 82 | + | return payload, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Frame is useful in tests and for callers which already have a complete | |
| 86 | + | // message. It returns the exact wire representation. | |
| 87 | + | func Frame(payload []byte) []byte { | |
| 88 | + | var b bytes.Buffer | |
| 89 | + | if err := WriteMessage(&b, payload); err != nil { | |
| 90 | + | panic(err) | |
| 91 | + | } | |
| 92 | + | return b.Bytes() | |
| 93 | + | } | |
A packages/agent/lsp/lsp_test.go +499 -0
| @@ -0,0 +1,499 @@ | |||
| 1 | + | package lsp | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "runtime" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "testing" | |
| 15 | + | "time" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestFakeLSPProcess(t *testing.T) { | |
| 19 | + | if os.Getenv("ZOT_FAKE_LSP") != "1" { | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | reader := bufio.NewReader(os.Stdin) | |
| 23 | + | for { | |
| 24 | + | payload, err := ReadMessage(reader) | |
| 25 | + | if err != nil { | |
| 26 | + | return | |
| 27 | + | } | |
| 28 | + | var message rpcEnvelope | |
| 29 | + | if json.Unmarshal(payload, &message) != nil { | |
| 30 | + | continue | |
| 31 | + | } | |
| 32 | + | switch message.Method { | |
| 33 | + | case "initialize": | |
| 34 | + | response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"capabilities":{"definitionProvider":true}}}`) | |
| 35 | + | _ = WriteMessage(os.Stdout, response) | |
| 36 | + | case "test/echo": | |
| 37 | + | response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"echo":true}}`) | |
| 38 | + | _ = WriteMessage(os.Stdout, response) | |
| 39 | + | case "shutdown": | |
| 40 | + | response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":null}`) | |
| 41 | + | _ = WriteMessage(os.Stdout, response) | |
| 42 | + | case "textDocument/didOpen": | |
| 43 | + | var params struct { | |
| 44 | + | TextDocument struct { | |
| 45 | + | URI string `json:"uri"` | |
| 46 | + | } `json:"textDocument"` | |
| 47 | + | } | |
| 48 | + | _ = json.Unmarshal(message.Params, ¶ms) | |
| 49 | + | response := map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": map[string]any{"uri": params.TextDocument.URI, "diagnostics": []any{map[string]any{"range": map[string]any{"start": map[string]int{"line": 0, "character": 0}, "end": map[string]int{"line": 0, "character": 1}}, "severity": 1, "code": "FAKE", "message": "fake problem"}}}} | |
| 50 | + | data, _ := json.Marshal(response) | |
| 51 | + | _ = WriteMessage(os.Stdout, data) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestClientFakeStdioRPC(t *testing.T) { | |
| 57 | + | root := t.TempDir() | |
| 58 | + | script := os.Args[0] | |
| 59 | + | spec := ServerConfig{ID: "fake", Kind: "lsp", Command: script, Args: []string{"-test.run=TestFakeLSPProcess"}, Env: map[string]string{"ZOT_FAKE_LSP": "1"}} | |
| 60 | + | diagnostics := make(chan []Diagnostic, 1) | |
| 61 | + | client, err := NewClientWithContext(context.Background(), spec, root, ClientOptions{OnDiagnostics: func(value []Diagnostic) { diagnostics <- value }}) | |
| 62 | + | if err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | t.Cleanup(func() { _ = client.Close() }) | |
| 66 | + | result, err := client.Request(context.Background(), "test/echo", map[string]any{}) | |
| 67 | + | if err != nil || !bytes.Contains(result, []byte(`"echo":true`)) { | |
| 68 | + | t.Fatalf("echo result = %s, err = %v", result, err) | |
| 69 | + | } | |
| 70 | + | path := filepath.Join(root, "main.go") | |
| 71 | + | if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | if err := client.DidOpen(path, "go", "package main\n"); err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | select { | |
| 78 | + | case values := <-diagnostics: | |
| 79 | + | if len(values) != 1 || values[0].Code != "FAKE" || values[0].Path != path { | |
| 80 | + | t.Fatalf("diagnostics = %#v", values) | |
| 81 | + | } | |
| 82 | + | case <-time.After(2 * time.Second): | |
| 83 | + | t.Fatal("timed out waiting for fake diagnostics") | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestFileURIPathRoundTrip(t *testing.T) { | |
| 88 | + | path := filepath.Join(t.TempDir(), "space name", "main.go") | |
| 89 | + | uri, err := pathToURI(path) | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | if !strings.HasPrefix(uri, "file:///") { | |
| 94 | + | t.Fatalf("URI = %q, want an absolute file URI", uri) | |
| 95 | + | } | |
| 96 | + | got, err := uriToPath(uri) | |
| 97 | + | if err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | abs, err := filepath.Abs(path) | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | if filepath.Clean(got) != filepath.Clean(abs) { | |
| 105 | + | t.Fatalf("round-trip path = %q, want %q", got, abs) | |
| 106 | + | } | |
| 107 | + | if runtime.GOOS != "windows" { | |
| 108 | + | posixPath, err := uriToPath("file:///x:tmp/main.go") | |
| 109 | + | if err != nil || posixPath != "/x:tmp/main.go" { | |
| 110 | + | t.Fatalf("POSIX drive-like URI = %q, err = %v", posixPath, err) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | if runtime.GOOS == "windows" { | |
| 114 | + | unc := `\\server\share\main.go` | |
| 115 | + | uncURI, err := pathToURI(unc) | |
| 116 | + | if err != nil { | |
| 117 | + | t.Fatal(err) | |
| 118 | + | } | |
| 119 | + | if !strings.HasPrefix(uncURI, "file://server/") { | |
| 120 | + | t.Fatalf("UNC URI = %q", uncURI) | |
| 121 | + | } | |
| 122 | + | uncPath, err := uriToPath(uncURI) | |
| 123 | + | if err != nil || filepath.Clean(uncPath) != filepath.Clean(unc) { | |
| 124 | + | t.Fatalf("UNC round trip = %q, err = %v", uncPath, err) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestFramingRejectsMalformedAndOversizedMessages(t *testing.T) { | |
| 130 | + | cases := []string{ | |
| 131 | + | "\r\n{}", | |
| 132 | + | "Content-Length: -1\r\n\r\n", | |
| 133 | + | "Content-Length: nope\r\n\r\n", | |
| 134 | + | "Content-Length: 1\r\n" + strings.Repeat("X", maxHeaderBytes) + "\r\n", | |
| 135 | + | "Content-Length: " + strconv.Itoa(maxMessageBytes+1) + "\r\n\r\n", | |
| 136 | + | } | |
| 137 | + | for _, wire := range cases { | |
| 138 | + | if _, err := ReadMessage(bufio.NewReader(strings.NewReader(wire))); err == nil { | |
| 139 | + | t.Fatalf("accepted malformed frame %q", wire[:min(len(wire), 80)]) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | var out bytes.Buffer | |
| 143 | + | if err := WriteMessage(&out, make([]byte, maxMessageBytes+1)); err == nil { | |
| 144 | + | t.Fatal("accepted oversized outbound frame") | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestFramingRoundTripWithAdditionalHeader(t *testing.T) { | |
| 149 | + | payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`) | |
| 150 | + | wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...) | |
| 151 | + | got, err := ReadMessage(bufio.NewReader(bytes.NewReader(wire))) | |
| 152 | + | if err != nil { | |
| 153 | + | t.Fatal(err) | |
| 154 | + | } | |
| 155 | + | if !bytes.Equal(got, payload) { | |
| 156 | + | t.Fatalf("payload = %q, want %q", got, payload) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestZotHomeEnvironmentPrecedence(t *testing.T) { | |
| 161 | + | configuredHome := filepath.Join(t.TempDir(), "zot") | |
| 162 | + | xdgHome := filepath.Join(t.TempDir(), "state") | |
| 163 | + | t.Setenv("ZOT_HOME", configuredHome) | |
| 164 | + | t.Setenv("XDG_STATE_HOME", xdgHome) | |
| 165 | + | if got := zotHome(); got != configuredHome { | |
| 166 | + | t.Fatalf("ZOT_HOME path = %q, want %q", got, configuredHome) | |
| 167 | + | } | |
| 168 | + | t.Setenv("ZOT_HOME", "") | |
| 169 | + | if got := zotHome(); got != filepath.Join(xdgHome, "zot") { | |
| 170 | + | t.Fatalf("XDG_STATE_HOME path = %q, want %q", got, filepath.Join(xdgHome, "zot")) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestLoadConfigMergesGlobalProjectAndBuiltins(t *testing.T) { | |
| 175 | + | root := t.TempDir() | |
| 176 | + | zotHome := t.TempDir() | |
| 177 | + | t.Setenv("ZOT_HOME", zotHome) | |
| 178 | + | if err := os.WriteFile(filepath.Join(zotHome, "lsp.json"), []byte(`{"servers":{"gopls":{"settings":{"global":true},"args":["--global"]}}}`), 0o644); err != nil { | |
| 179 | + | t.Fatal(err) | |
| 180 | + | } | |
| 181 | + | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil { | |
| 182 | + | t.Fatal(err) | |
| 183 | + | } | |
| 184 | + | if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"gopls":{"command":"local-gopls","args":["--local"],"settings":{"local":true}},"sarif-lint":{"command":"reviewdog","kind":"cli","parser":"sarif","fileTypes":[".go"]}}`), 0o644); err != nil { | |
| 185 | + | t.Fatal(err) | |
| 186 | + | } | |
| 187 | + | config, err := LoadConfig(root) | |
| 188 | + | if err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | gopls := config.Servers["gopls"] | |
| 192 | + | if gopls.Command != "local-gopls" || len(gopls.Args) != 1 || gopls.Args[0] != "--local" { | |
| 193 | + | t.Fatalf("gopls override = %#v", gopls) | |
| 194 | + | } | |
| 195 | + | if len(gopls.FileTypes) == 0 || len(gopls.RootMarkers) == 0 { | |
| 196 | + | t.Fatalf("builtin selectors were not retained: %#v", gopls) | |
| 197 | + | } | |
| 198 | + | if gopls.Settings["global"] != true || gopls.Settings["local"] != true { | |
| 199 | + | t.Fatalf("settings did not merge: %#v", gopls.Settings) | |
| 200 | + | } | |
| 201 | + | if config.Servers["sarif-lint"].Kind != "cli" || config.Servers["sarif-lint"].Parser != "sarif" { | |
| 202 | + | t.Fatalf("custom linter = %#v", config.Servers["sarif-lint"]) | |
| 203 | + | } | |
| 204 | + | selected := config.ApplicableServers(root, filepath.Join(root, "main.go")) | |
| 205 | + | var foundGopls, foundSarif bool | |
| 206 | + | for _, server := range selected { | |
| 207 | + | foundGopls = foundGopls || server.ID == "gopls" | |
| 208 | + | foundSarif = foundSarif || server.ID == "sarif-lint" | |
| 209 | + | } | |
| 210 | + | if !foundGopls || !foundSarif { | |
| 211 | + | t.Fatalf("selected servers = %#v", selected) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestLoadConfigAcceptsProviderArrays(t *testing.T) { | |
| 216 | + | root := t.TempDir() | |
| 217 | + | zotHome := t.TempDir() | |
| 218 | + | t.Setenv("ZOT_HOME", zotHome) | |
| 219 | + | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil { | |
| 220 | + | t.Fatal(err) | |
| 221 | + | } | |
| 222 | + | configFile := `{ | |
| 223 | + | "autoDetect": true, | |
| 224 | + | "providers": [ | |
| 225 | + | "gopls", | |
| 226 | + | {"id":"gopls","args":["serve"]}, | |
| 227 | + | {"id":"custom-lint","kind":"cli","command":"custom-lint","fileTypes":[".go"],"rootMarkers":["go.mod"],"cli":{"parser":"generic","mode":"files"}} | |
| 228 | + | ] | |
| 229 | + | }` | |
| 230 | + | if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(configFile), 0o644); err != nil { | |
| 231 | + | t.Fatal(err) | |
| 232 | + | } | |
| 233 | + | config, err := LoadConfig(root) | |
| 234 | + | if err != nil { | |
| 235 | + | t.Fatal(err) | |
| 236 | + | } | |
| 237 | + | if got := config.Servers["gopls"].Args; len(got) != 1 || got[0] != "serve" { | |
| 238 | + | t.Fatalf("gopls provider override = %#v", got) | |
| 239 | + | } | |
| 240 | + | lint := config.Servers["custom-lint"] | |
| 241 | + | if lint.Kind != "cli" || lint.Parser != "generic" || lint.Mode != "files" { | |
| 242 | + | t.Fatalf("custom provider = %#v", lint) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) { | |
| 247 | + | root := t.TempDir() | |
| 248 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 249 | + | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil { | |
| 250 | + | t.Fatal(err) | |
| 251 | + | } | |
| 252 | + | if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"autoDetect":false,"providers":["gopls"]}`), 0o644); err != nil { | |
| 253 | + | t.Fatal(err) | |
| 254 | + | } | |
| 255 | + | config, err := LoadConfig(root) | |
| 256 | + | if err != nil { | |
| 257 | + | t.Fatal(err) | |
| 258 | + | } | |
| 259 | + | selected := config.ApplicableServers(root, filepath.Join(root, "main.go")) | |
| 260 | + | if len(selected) != 1 || selected[0].ID != "gopls" { | |
| 261 | + | t.Fatalf("explicit providers with autoDetect=false = %#v", selected) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestResolveCommandPrefersWorkspaceTools(t *testing.T) { | |
| 266 | + | if runtime.GOOS == "windows" { | |
| 267 | + | t.Skip("executable bits are not represented on Windows") | |
| 268 | + | } | |
| 269 | + | root := t.TempDir() | |
| 270 | + | bin := filepath.Join(root, "node_modules", ".bin") | |
| 271 | + | if err := os.MkdirAll(bin, 0o755); err != nil { | |
| 272 | + | t.Fatal(err) | |
| 273 | + | } | |
| 274 | + | command := filepath.Join(bin, "fake-lsp") | |
| 275 | + | if err := os.WriteFile(command, []byte("#!/bin/sh\n"), 0o755); err != nil { | |
| 276 | + | t.Fatal(err) | |
| 277 | + | } | |
| 278 | + | got, err := ResolveCommand(root, "fake-lsp") | |
| 279 | + | if err != nil { | |
| 280 | + | t.Fatal(err) | |
| 281 | + | } | |
| 282 | + | if got != command { | |
| 283 | + | t.Fatalf("resolved %q, want %q", got, command) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func TestParsers(t *testing.T) { | |
| 288 | + | root := t.TempDir() | |
| 289 | + | eslint := `[ {"filePath":"src/a.js","messages":[{"ruleId":"no-x","severity":2,"message":"bad","line":2,"column":3}] } ]` | |
| 290 | + | got := ParseDiagnostics("eslint-json", "eslint", root, eslint) | |
| 291 | + | if len(got) != 1 || got[0].Severity != "error" || got[0].Range.Start.Line != 1 { | |
| 292 | + | t.Fatalf("eslint = %#v", got) | |
| 293 | + | } | |
| 294 | + | golangci := `{"Issues":[{"FromLinter":"govet","Text":"bad","Severity":"warning","Pos":{"Filename":"a.go","Line":3,"Column":4}}]}` | |
| 295 | + | if got = ParseDiagnostics("golangci-lint-json", "golangci", root, golangci); len(got) != 1 || got[0].Severity != "warning" { | |
| 296 | + | t.Fatalf("golangci = %#v", got) | |
| 297 | + | } | |
| 298 | + | ruff := `[{"code":"F401","message":"unused","filename":"a.py","location":{"row":1,"column":2}}]` | |
| 299 | + | if got = ParseDiagnostics("ruff-json", "ruff", root, ruff); len(got) != 1 || got[0].Code != "F401" { | |
| 300 | + | t.Fatalf("ruff = %#v", got) | |
| 301 | + | } | |
| 302 | + | sarif := `{"runs":[{"results":[{"ruleId":"R1","level":"error","message":{"text":"bad"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"a.c"},"region":{"startLine":4,"startColumn":2}}}]}]}]}` | |
| 303 | + | if got = ParseDiagnostics("sarif", "scan", root, sarif); len(got) != 1 || got[0].Range.Start.Line != 3 { | |
| 304 | + | t.Fatalf("sarif = %#v", got) | |
| 305 | + | } | |
| 306 | + | if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" { | |
| 307 | + | t.Fatalf("generic = %#v", got) | |
| 308 | + | } | |
| 309 | + | if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: cannot find name"); len(got) != 1 || got[0].Message != "cannot find name" { | |
| 310 | + | t.Fatalf("generic message = %#v", got) | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) { | |
| 315 | + | path := filepath.Join(t.TempDir(), "main.go") | |
| 316 | + | base := Diagnostic{Path: path, Severity: "error", Code: "E1", Message: "same", Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 1, Character: 3}}} | |
| 317 | + | endChanged := base | |
| 318 | + | endChanged.Range.End.Character = 20 | |
| 319 | + | otherCode := base | |
| 320 | + | otherCode.Code = "E2" | |
| 321 | + | otherStart := base | |
| 322 | + | otherStart.Range.Start.Line++ | |
| 323 | + | if got := DeduplicateDiagnostics([]Diagnostic{base, endChanged, otherCode, otherStart}); len(got) != 3 { | |
| 324 | + | t.Fatalf("deduplicated diagnostics = %#v, want three reports", got) | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func TestManagerClearsPublishedDiagnostics(t *testing.T) { | |
| 329 | + | manager := NewManager() | |
| 330 | + | root := t.TempDir() | |
| 331 | + | path := filepath.Join(root, "main.go") | |
| 332 | + | if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil { | |
| 333 | + | t.Fatal(err) | |
| 334 | + | } | |
| 335 | + | ws, err := manager.workspace(root) | |
| 336 | + | if err != nil { | |
| 337 | + | t.Fatal(err) | |
| 338 | + | } | |
| 339 | + | manager.storeDiagnostics(ws, "fake", []Diagnostic{{Path: path, Message: "problem"}}) | |
| 340 | + | if got := manager.snapshot(ws, path); len(got) != 1 { | |
| 341 | + | t.Fatalf("stored diagnostics = %#v", got) | |
| 342 | + | } | |
| 343 | + | manager.storeDiagnostics(ws, "fake", []Diagnostic{{Path: path, Clear: true}}) | |
| 344 | + | if got := manager.snapshot(ws, path); len(got) != 0 { | |
| 345 | + | t.Fatalf("cleared diagnostics = %#v", got) | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | func TestDiagnosticsDedupAndGrouping(t *testing.T) { | |
| 350 | + | root := t.TempDir() | |
| 351 | + | makeDiag := func(path, message string, line int, server string) Diagnostic { | |
| 352 | + | return Diagnostic{Path: filepath.Join(root, path), Severity: "error", Code: "E", Message: message, Server: server, Range: Range{Start: Position{Line: line}}} | |
| 353 | + | } | |
| 354 | + | input := []Diagnostic{makeDiag("a.go", "same", 0, "gopls"), makeDiag("a.go", "same", 0, "eslint"), makeDiag("a.go", `Cannot find module "missing"`, 1, "gopls"), makeDiag("a.go", "cascade one", 2, "gopls"), makeDiag("a.go", "cascade two", 3, "gopls"), makeDiag("b.go", "repeat", 1, "a"), makeDiag("c.go", "repeat", 2, "b"), makeDiag("d.go", "repeat", 3, "c")} | |
| 355 | + | if got := DeduplicateDiagnostics(input); len(got) != len(input)-1 { | |
| 356 | + | t.Fatalf("dedup count = %d", len(got)) | |
| 357 | + | } | |
| 358 | + | text := SummarizeDiagnostics(input, root, 50) | |
| 359 | + | if !strings.Contains(text, "repeated") || !strings.Contains(text, "SECONDARY") { | |
| 360 | + | t.Fatalf("summary lacks grouping:\n%s", text) | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) { | |
| 365 | + | root := t.TempDir() | |
| 366 | + | path := filepath.Join(root, "a.txt") | |
| 367 | + | if err := os.WriteFile(path, []byte("a😀b\n"), 0o644); err != nil { | |
| 368 | + | t.Fatal(err) | |
| 369 | + | } | |
| 370 | + | uri := URIForPath(path) | |
| 371 | + | ed := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: Range{Start: Position{Line: 0, Character: 1}, End: Position{Line: 0, Character: 3}}, NewText: "X"}}}} | |
| 372 | + | if err := ApplyWorkspaceEdit(root, ed); err != nil { | |
| 373 | + | t.Fatal(err) | |
| 374 | + | } | |
| 375 | + | data, _ := os.ReadFile(path) | |
| 376 | + | if string(data) != "aXb\n" { | |
| 377 | + | t.Fatalf("edited data = %q", data) | |
| 378 | + | } | |
| 379 | + | outside := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(filepath.Join(filepath.Dir(root), "outside.txt")): {{Range: Range{}, NewText: "x"}}}} | |
| 380 | + | if err := ApplyWorkspaceEdit(root, outside); err == nil { | |
| 381 | + | t.Fatal("outside edit was accepted") | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func TestWorkspaceEditPreflightsAllTargets(t *testing.T) { | |
| 386 | + | root := t.TempDir() | |
| 387 | + | inside := filepath.Join(root, "inside.txt") | |
| 388 | + | outside := filepath.Join(t.TempDir(), "outside.txt") | |
| 389 | + | if err := os.WriteFile(inside, []byte("old\n"), 0o644); err != nil { | |
| 390 | + | t.Fatal(err) | |
| 391 | + | } | |
| 392 | + | if err := os.WriteFile(outside, []byte("old\n"), 0o644); err != nil { | |
| 393 | + | t.Fatal(err) | |
| 394 | + | } | |
| 395 | + | rootReal, err := filepath.EvalSymlinks(root) | |
| 396 | + | if err != nil { | |
| 397 | + | t.Fatal(err) | |
| 398 | + | } | |
| 399 | + | _, err = prepareEditTargets(rootReal, map[string][]TextEdit{ | |
| 400 | + | URIForPath(inside): {{Range: Range{}, NewText: "inside"}}, | |
| 401 | + | URIForPath(outside): {{Range: Range{}, NewText: "outside"}}, | |
| 402 | + | }, nil) | |
| 403 | + | if err == nil { | |
| 404 | + | t.Fatal("workspace edit accepted an unsafe target after a safe target") | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func TestApplyWorkspaceEditFollowsInWorkspaceSymlink(t *testing.T) { | |
| 409 | + | if runtime.GOOS == "windows" { | |
| 410 | + | t.Skip("symlink permissions vary on Windows") | |
| 411 | + | } | |
| 412 | + | root := t.TempDir() | |
| 413 | + | target := filepath.Join(root, "target.txt") | |
| 414 | + | link := filepath.Join(root, "link.txt") | |
| 415 | + | if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil { | |
| 416 | + | t.Fatal(err) | |
| 417 | + | } | |
| 418 | + | if err := os.Symlink(target, link); err != nil { | |
| 419 | + | t.Fatal(err) | |
| 420 | + | } | |
| 421 | + | edit := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(link): {{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 3}}, NewText: "new"}}}} | |
| 422 | + | if err := ApplyWorkspaceEdit(root, edit); err != nil { | |
| 423 | + | t.Fatal(err) | |
| 424 | + | } | |
| 425 | + | data, err := os.ReadFile(target) | |
| 426 | + | if err != nil { | |
| 427 | + | t.Fatal(err) | |
| 428 | + | } | |
| 429 | + | if string(data) != "new\n" { | |
| 430 | + | t.Fatalf("target data = %q", data) | |
| 431 | + | } | |
| 432 | + | info, err := os.Lstat(link) | |
| 433 | + | if err != nil { | |
| 434 | + | t.Fatal(err) | |
| 435 | + | } | |
| 436 | + | if info.Mode()&os.ModeSymlink == 0 { | |
| 437 | + | t.Fatal("workspace edit replaced the symlink") | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func TestLimitedBufferCapsReaderCopy(t *testing.T) { | |
| 442 | + | var buffer limitedBuffer | |
| 443 | + | buffer.limit = 4 | |
| 444 | + | copied, err := io.Copy(&buffer, strings.NewReader("123456")) | |
| 445 | + | if err != nil { | |
| 446 | + | t.Fatal(err) | |
| 447 | + | } | |
| 448 | + | if copied != 6 || buffer.String() != "1234" { | |
| 449 | + | t.Fatalf("copied %d bytes into %q", copied, buffer.String()) | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func TestDiagnosticJSONCodeNumber(t *testing.T) { | |
| 454 | + | var diagnostic Diagnostic | |
| 455 | + | if err := json.Unmarshal([]byte(`{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":2,"code":123,"message":"warning"}`), &diagnostic); err != nil { | |
| 456 | + | t.Fatal(err) | |
| 457 | + | } | |
| 458 | + | if diagnostic.Code != "123" || diagnostic.Severity != "warning" { | |
| 459 | + | t.Fatalf("diagnostic = %#v", diagnostic) | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func TestManagerClosePreventsNewWorkspaces(t *testing.T) { | |
| 464 | + | manager := NewManager() | |
| 465 | + | root := t.TempDir() | |
| 466 | + | if err := manager.Close(); err != nil { | |
| 467 | + | t.Fatal(err) | |
| 468 | + | } | |
| 469 | + | if err := manager.Close(); err != nil { | |
| 470 | + | t.Fatal(err) | |
| 471 | + | } | |
| 472 | + | if _, err := manager.Config(root); err == nil { | |
| 473 | + | t.Fatal("closed manager accepted a new workspace") | |
| 474 | + | } | |
| 475 | + | if err := manager.Reload(root); err == nil { | |
| 476 | + | t.Fatal("closed manager accepted a reload") | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) { | |
| 481 | + | manager := NewManager() | |
| 482 | + | root := t.TempDir() | |
| 483 | + | path := filepath.Join(root, "main.go") | |
| 484 | + | first := Diagnostic{Path: path, Severity: "error", Message: "type mismatch", Range: Range{Start: Position{Line: 1, Character: 2}}} | |
| 485 | + | if got := manager.ReduceDiagnostics(root, path, []Diagnostic{first}); len(got) != 1 { | |
| 486 | + | t.Fatalf("first reduction returned %d diagnostics, want 1", len(got)) | |
| 487 | + | } | |
| 488 | + | shifted := first | |
| 489 | + | shifted.Range.Start.Line = 20 | |
| 490 | + | if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 0 { | |
| 491 | + | t.Fatalf("shifted diagnostic was replayed: %#v", got) | |
| 492 | + | } | |
| 493 | + | if got := manager.ReduceDiagnostics(root, path, nil); len(got) != 0 { | |
| 494 | + | t.Fatalf("clear returned %#v", got) | |
| 495 | + | } | |
| 496 | + | if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 1 { | |
| 497 | + | t.Fatalf("diagnostic did not reappear after clear: %#v", got) | |
| 498 | + | } | |
| 499 | + | } | |
A packages/agent/lsp/manager.go +859 -0
| @@ -0,0 +1,859 @@ | |||
| 1 | + | package lsp | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "encoding/hex" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "path/filepath" | |
| 14 | + | "strings" | |
| 15 | + | "sync" | |
| 16 | + | "time" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // ManagerOptions controls process and command timeouts. | |
| 20 | + | type ManagerOptions struct { | |
| 21 | + | RequestTimeout time.Duration | |
| 22 | + | DiagnosticDelay time.Duration | |
| 23 | + | LinterTimeout time.Duration | |
| 24 | + | Settings map[string]any | |
| 25 | + | OnLog func(server, message string) | |
| 26 | + | CheckWritePath func(path string) error | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Response is one raw request result tagged with the server which produced | |
| 30 | + | // it. Errors are retained so a partially installed workspace remains useful. | |
| 31 | + | type Response struct { | |
| 32 | + | Server string | |
| 33 | + | Result json.RawMessage | |
| 34 | + | Error error | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type workspace struct { | |
| 38 | + | cwd string | |
| 39 | + | rootReal string | |
| 40 | + | config Config | |
| 41 | + | clients map[string]*Client | |
| 42 | + | starting map[string]chan struct{} | |
| 43 | + | statuses map[string]string | |
| 44 | + | errors map[string]string | |
| 45 | + | diagnostics map[string]map[string][]Diagnostic | |
| 46 | + | cliDiagnostics map[string][]Diagnostic | |
| 47 | + | opened map[string]map[string]string // server -> path -> content hash | |
| 48 | + | surfaced map[string]map[string]bool // path -> location-independent diagnostic identities | |
| 49 | + | logs []string | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Manager owns per-workspace LSP processes. A process is cached by cwd and | |
| 53 | + | // server ID until Close or Reload, so a series of model calls does not spawn a | |
| 54 | + | // new language server for every request. | |
| 55 | + | type Manager struct { | |
| 56 | + | mu sync.Mutex | |
| 57 | + | workspaces map[string]*workspace | |
| 58 | + | options ManagerOptions | |
| 59 | + | closed bool | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | var errManagerClosed = errors.New("LSP manager is closed") | |
| 63 | + | ||
| 64 | + | func NewManager() *Manager { | |
| 65 | + | return NewManagerWithOptions(ManagerOptions{}) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func NewManagerWithOptions(options ManagerOptions) *Manager { | |
| 69 | + | if options.RequestTimeout <= 0 { | |
| 70 | + | options.RequestTimeout = 12 * time.Second | |
| 71 | + | } | |
| 72 | + | if options.DiagnosticDelay <= 0 { | |
| 73 | + | options.DiagnosticDelay = 300 * time.Millisecond | |
| 74 | + | } | |
| 75 | + | if options.LinterTimeout <= 0 { | |
| 76 | + | options.LinterTimeout = 30 * time.Second | |
| 77 | + | } | |
| 78 | + | return &Manager{workspaces: make(map[string]*workspace), options: options} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (m *Manager) workspace(cwd string) (*workspace, error) { | |
| 82 | + | cwd, err := absDir(cwd) | |
| 83 | + | if err != nil { | |
| 84 | + | return nil, err | |
| 85 | + | } | |
| 86 | + | m.mu.Lock() | |
| 87 | + | if m.closed { | |
| 88 | + | m.mu.Unlock() | |
| 89 | + | return nil, errManagerClosed | |
| 90 | + | } | |
| 91 | + | if value := m.workspaces[cwd]; value != nil { | |
| 92 | + | m.mu.Unlock() | |
| 93 | + | return value, nil | |
| 94 | + | } | |
| 95 | + | m.mu.Unlock() | |
| 96 | + | config, err := LoadConfig(cwd) | |
| 97 | + | if err != nil { | |
| 98 | + | return nil, err | |
| 99 | + | } | |
| 100 | + | rootReal := cwd | |
| 101 | + | if resolved, resolveErr := filepath.EvalSymlinks(cwd); resolveErr == nil { | |
| 102 | + | rootReal = resolved | |
| 103 | + | } | |
| 104 | + | value := &workspace{cwd: cwd, rootReal: rootReal, config: config, clients: make(map[string]*Client), starting: make(map[string]chan struct{}), statuses: make(map[string]string), errors: make(map[string]string), diagnostics: make(map[string]map[string][]Diagnostic), cliDiagnostics: make(map[string][]Diagnostic), opened: make(map[string]map[string]string), surfaced: make(map[string]map[string]bool)} | |
| 105 | + | m.mu.Lock() | |
| 106 | + | defer m.mu.Unlock() | |
| 107 | + | if m.closed { | |
| 108 | + | return nil, errManagerClosed | |
| 109 | + | } | |
| 110 | + | if existing := m.workspaces[cwd]; existing != nil { | |
| 111 | + | value = existing | |
| 112 | + | } else { | |
| 113 | + | m.workspaces[cwd] = value | |
| 114 | + | } | |
| 115 | + | return value, nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Config returns the effective merged configuration for cwd. | |
| 119 | + | func (m *Manager) Config(cwd string) (Config, error) { | |
| 120 | + | ws, err := m.workspace(cwd) | |
| 121 | + | if err != nil { | |
| 122 | + | return Config{}, err | |
| 123 | + | } | |
| 124 | + | return ws.config, nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (m *Manager) applicable(ws *workspace, path string) []ServerConfig { | |
| 128 | + | return ws.config.ApplicableServers(ws.cwd, path) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerConfig) (*Client, error) { | |
| 132 | + | if ctx == nil { | |
| 133 | + | ctx = context.Background() | |
| 134 | + | } | |
| 135 | + | for { | |
| 136 | + | m.mu.Lock() | |
| 137 | + | if m.closed || m.workspaces[ws.cwd] != ws { | |
| 138 | + | m.mu.Unlock() | |
| 139 | + | return nil, errManagerClosed | |
| 140 | + | } | |
| 141 | + | if client := ws.clients[spec.ID]; client != nil { | |
| 142 | + | m.mu.Unlock() | |
| 143 | + | return client, nil | |
| 144 | + | } | |
| 145 | + | if waiting := ws.starting[spec.ID]; waiting != nil { | |
| 146 | + | m.mu.Unlock() | |
| 147 | + | select { | |
| 148 | + | case <-waiting: | |
| 149 | + | continue | |
| 150 | + | case <-ctx.Done(): | |
| 151 | + | return nil, ctx.Err() | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | starting := make(chan struct{}) | |
| 155 | + | ws.starting[spec.ID] = starting | |
| 156 | + | ws.statuses[spec.ID] = "starting" | |
| 157 | + | m.mu.Unlock() | |
| 158 | + | root := FindRoot(ws.cwd, spec.RootMarkers) | |
| 159 | + | opts := ClientOptions{ | |
| 160 | + | Settings: wsSettings(m.options.Settings, spec.Settings), | |
| 161 | + | InitializationOptions: spec.InitializationOptions, | |
| 162 | + | OnDiagnostics: func(diagnostics []Diagnostic) { m.storeDiagnostics(ws, spec.ID, diagnostics) }, | |
| 163 | + | OnLog: func(message string) { m.log(ws, spec.ID, message) }, | |
| 164 | + | // A server's LSP root may be an ancestor of the session cwd. | |
| 165 | + | // Keep server-initiated edits inside the session workspace rather | |
| 166 | + | // than trusting that broader project root. | |
| 167 | + | ApplyEdit: func(edit WorkspaceEdit) error { | |
| 168 | + | return applyWorkspaceEdit(ws.cwd, edit, m.options.CheckWritePath) | |
| 169 | + | }, | |
| 170 | + | } | |
| 171 | + | startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout) | |
| 172 | + | client, err := NewClientWithContext(startCtx, spec, root, opts) | |
| 173 | + | cancel() | |
| 174 | + | m.mu.Lock() | |
| 175 | + | delete(ws.starting, spec.ID) | |
| 176 | + | close(starting) | |
| 177 | + | if err != nil { | |
| 178 | + | if m.closed || m.workspaces[ws.cwd] != ws { | |
| 179 | + | m.mu.Unlock() | |
| 180 | + | return nil, errManagerClosed | |
| 181 | + | } | |
| 182 | + | ws.statuses[spec.ID] = "missing" | |
| 183 | + | ws.errors[spec.ID] = err.Error() | |
| 184 | + | m.mu.Unlock() | |
| 185 | + | return nil, err | |
| 186 | + | } | |
| 187 | + | if m.closed || m.workspaces[ws.cwd] != ws { | |
| 188 | + | m.mu.Unlock() | |
| 189 | + | _ = client.Close() | |
| 190 | + | return nil, errManagerClosed | |
| 191 | + | } | |
| 192 | + | if existing := ws.clients[spec.ID]; existing != nil { | |
| 193 | + | m.mu.Unlock() | |
| 194 | + | _ = client.Close() | |
| 195 | + | return existing, nil | |
| 196 | + | } | |
| 197 | + | ws.clients[spec.ID] = client | |
| 198 | + | ws.statuses[spec.ID] = "ready" | |
| 199 | + | delete(ws.errors, spec.ID) | |
| 200 | + | m.mu.Unlock() | |
| 201 | + | return client, nil | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func wsSettings(global, local map[string]any) map[string]any { | |
| 206 | + | if len(global) == 0 && len(local) == 0 { | |
| 207 | + | return nil | |
| 208 | + | } | |
| 209 | + | out := make(map[string]any, len(global)+len(local)) | |
| 210 | + | for key, value := range global { | |
| 211 | + | out[key] = value | |
| 212 | + | } | |
| 213 | + | for key, value := range local { | |
| 214 | + | out[key] = value | |
| 215 | + | } | |
| 216 | + | return out | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) { | |
| 220 | + | byPath := make(map[string][]Diagnostic) | |
| 221 | + | cleared := make(map[string]bool) | |
| 222 | + | for _, diagnostic := range diagnostics { | |
| 223 | + | if diagnostic.Path == "" || !diagnosticInWorkspace(ws.rootReal, diagnostic.Path) { | |
| 224 | + | continue | |
| 225 | + | } | |
| 226 | + | if diagnostic.Clear { | |
| 227 | + | cleared[diagnostic.Path] = true | |
| 228 | + | continue | |
| 229 | + | } | |
| 230 | + | byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic) | |
| 231 | + | } | |
| 232 | + | m.mu.Lock() | |
| 233 | + | if ws.diagnostics[server] == nil { | |
| 234 | + | ws.diagnostics[server] = make(map[string][]Diagnostic) | |
| 235 | + | } | |
| 236 | + | for path := range cleared { | |
| 237 | + | delete(ws.diagnostics[server], path) | |
| 238 | + | } | |
| 239 | + | for path, values := range byPath { | |
| 240 | + | ws.diagnostics[server][path] = values | |
| 241 | + | } | |
| 242 | + | m.mu.Unlock() | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func (m *Manager) log(ws *workspace, server, message string) { | |
| 246 | + | message = strings.TrimSpace(message) | |
| 247 | + | if message == "" { | |
| 248 | + | return | |
| 249 | + | } | |
| 250 | + | if len(message) > 4096 { | |
| 251 | + | message = message[:4095] + "…" | |
| 252 | + | } | |
| 253 | + | m.mu.Lock() | |
| 254 | + | ws.logs = append(ws.logs, server+": "+message) | |
| 255 | + | if len(ws.logs) > 100 { | |
| 256 | + | ws.logs = ws.logs[len(ws.logs)-100:] | |
| 257 | + | } | |
| 258 | + | m.mu.Unlock() | |
| 259 | + | if m.options.OnLog != nil { | |
| 260 | + | m.options.OnLog(server, message) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // Open opens a document in every applicable LSP server. | |
| 265 | + | func (m *Manager) Open(ctx context.Context, cwd, path string) error { | |
| 266 | + | ws, err := m.workspace(cwd) | |
| 267 | + | if err != nil { | |
| 268 | + | return err | |
| 269 | + | } | |
| 270 | + | path, err = absoluteFile(ws.cwd, path) | |
| 271 | + | if err != nil { | |
| 272 | + | return err | |
| 273 | + | } | |
| 274 | + | text, err := os.ReadFile(path) | |
| 275 | + | if err != nil { | |
| 276 | + | return err | |
| 277 | + | } | |
| 278 | + | for _, spec := range m.applicable(ws, path) { | |
| 279 | + | if spec.Kind != "lsp" { | |
| 280 | + | continue | |
| 281 | + | } | |
| 282 | + | client, clientErr := m.ensureClient(ctx, ws, spec) | |
| 283 | + | if clientErr != nil { | |
| 284 | + | if err == nil { | |
| 285 | + | err = clientErr | |
| 286 | + | } | |
| 287 | + | continue | |
| 288 | + | } | |
| 289 | + | hash := contentHash(text) | |
| 290 | + | m.mu.Lock() | |
| 291 | + | if ws.opened[spec.ID] == nil { | |
| 292 | + | ws.opened[spec.ID] = make(map[string]string) | |
| 293 | + | } | |
| 294 | + | old, open := ws.opened[spec.ID][path] | |
| 295 | + | m.mu.Unlock() | |
| 296 | + | if !open { | |
| 297 | + | languageID := spec.LanguageID | |
| 298 | + | if languageID == "" { | |
| 299 | + | languageID = languageForExtension(filepath.Ext(path)) | |
| 300 | + | } | |
| 301 | + | if clientErr = client.DidOpen(path, languageID, string(text)); clientErr == nil { | |
| 302 | + | m.mu.Lock() | |
| 303 | + | ws.opened[spec.ID][path] = hash | |
| 304 | + | m.mu.Unlock() | |
| 305 | + | } | |
| 306 | + | } else if old != hash { | |
| 307 | + | if clientErr = client.DidChange(path, string(text)); clientErr == nil { | |
| 308 | + | m.mu.Lock() | |
| 309 | + | ws.opened[spec.ID][path] = hash | |
| 310 | + | m.mu.Unlock() | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | if clientErr != nil && err == nil { | |
| 314 | + | err = clientErr | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | return err | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func (m *Manager) Save(ctx context.Context, cwd, path string) error { | |
| 321 | + | openErr := m.Open(ctx, cwd, path) | |
| 322 | + | ws, err := m.workspace(cwd) | |
| 323 | + | if err != nil { | |
| 324 | + | return err | |
| 325 | + | } | |
| 326 | + | path, err = absoluteFile(ws.cwd, path) | |
| 327 | + | if err != nil { | |
| 328 | + | return err | |
| 329 | + | } | |
| 330 | + | text, err := os.ReadFile(path) | |
| 331 | + | if err != nil { | |
| 332 | + | return err | |
| 333 | + | } | |
| 334 | + | for _, spec := range m.applicable(ws, path) { | |
| 335 | + | if spec.Kind != "lsp" { | |
| 336 | + | continue | |
| 337 | + | } | |
| 338 | + | m.mu.Lock() | |
| 339 | + | client := ws.clients[spec.ID] | |
| 340 | + | m.mu.Unlock() | |
| 341 | + | if client != nil { | |
| 342 | + | _ = client.DidSave(path, string(text)) | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | return openErr | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // Diagnostics obtains current LSP and CLI diagnostics. It deliberately waits | |
| 349 | + | // only a bounded debounce interval for asynchronous publishDiagnostics. | |
| 350 | + | func (m *Manager) Diagnostics(ctx context.Context, cwd, path string) ([]Diagnostic, error) { | |
| 351 | + | return m.DiagnosticsWithOptions(ctx, cwd, path, true) | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func (m *Manager) DiagnosticsWithOptions(ctx context.Context, cwd, path string, runCLI bool) ([]Diagnostic, error) { | |
| 355 | + | ws, err := m.workspace(cwd) | |
| 356 | + | if err != nil { | |
| 357 | + | return nil, err | |
| 358 | + | } | |
| 359 | + | var firstErr error | |
| 360 | + | if path != "" { | |
| 361 | + | if saveErr := m.Save(ctx, ws.cwd, path); saveErr != nil && !errors.Is(saveErr, os.ErrNotExist) { | |
| 362 | + | // Keep cached diagnostics, but make an unavailable or failed | |
| 363 | + | // server visible to the model-facing lsp action. | |
| 364 | + | firstErr = saveErr | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | for _, spec := range m.applicable(ws, path) { | |
| 368 | + | if spec.Kind != "lsp" { | |
| 369 | + | continue | |
| 370 | + | } | |
| 371 | + | m.mu.Lock() | |
| 372 | + | client := ws.clients[spec.ID] | |
| 373 | + | m.mu.Unlock() | |
| 374 | + | if client != nil { | |
| 375 | + | if waitErr := client.WaitForDiagnostics(ctx, m.options.DiagnosticDelay); waitErr != nil && firstErr == nil && !errors.Is(waitErr, context.Canceled) { | |
| 376 | + | firstErr = waitErr | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | if runCLI { | |
| 381 | + | if err := m.RunLinters(ctx, ws.cwd, path); err != nil && firstErr == nil { | |
| 382 | + | firstErr = err | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | return m.snapshot(ws, path), firstErr | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // ReduceDiagnostics returns only diagnostics that have not already been | |
| 389 | + | // surfaced for this workspace path. The ledger deliberately ignores line | |
| 390 | + | // and column changes, matching the behavior users want after a harmless edit: | |
| 391 | + | // unchanged problems do not consume another tool-result context window. | |
| 392 | + | // Passing an empty snapshot clears the path so a later recurrence is fresh. | |
| 393 | + | func (m *Manager) ReduceDiagnostics(cwd, path string, diagnostics []Diagnostic) []Diagnostic { | |
| 394 | + | ws, err := m.workspace(cwd) | |
| 395 | + | if err != nil { | |
| 396 | + | return diagnostics | |
| 397 | + | } | |
| 398 | + | path, err = absoluteFile(ws.cwd, path) | |
| 399 | + | if err != nil { | |
| 400 | + | return diagnostics | |
| 401 | + | } | |
| 402 | + | m.mu.Lock() | |
| 403 | + | defer m.mu.Unlock() | |
| 404 | + | if len(diagnostics) == 0 { | |
| 405 | + | delete(ws.surfaced, path) | |
| 406 | + | return nil | |
| 407 | + | } | |
| 408 | + | previous := ws.surfaced[path] | |
| 409 | + | current := make(map[string]bool, len(diagnostics)) | |
| 410 | + | fresh := make([]Diagnostic, 0, len(diagnostics)) | |
| 411 | + | for _, diagnostic := range diagnostics { | |
| 412 | + | identity := DiagnosticIdentity(diagnostic) | |
| 413 | + | current[identity] = true | |
| 414 | + | if !previous[identity] { | |
| 415 | + | fresh = append(fresh, diagnostic) | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | ws.surfaced[path] = current | |
| 419 | + | return fresh | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func (m *Manager) snapshot(ws *workspace, path string) []Diagnostic { | |
| 423 | + | m.mu.Lock() | |
| 424 | + | defer m.mu.Unlock() | |
| 425 | + | var all []Diagnostic | |
| 426 | + | for _, files := range ws.diagnostics { | |
| 427 | + | for file, diagnostics := range files { | |
| 428 | + | if path == "" || samePath(file, path) { | |
| 429 | + | all = append(all, diagnostics...) | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | for _, diagnostics := range ws.cliDiagnostics { | |
| 434 | + | for _, diagnostic := range diagnostics { | |
| 435 | + | if path == "" || samePath(diagnostic.Path, path) { | |
| 436 | + | all = append(all, diagnostic) | |
| 437 | + | } | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | return DeduplicateDiagnostics(all) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // Request sends one request to a named LSP server. | |
| 444 | + | func (m *Manager) Request(ctx context.Context, cwd, server, method string, params any) (json.RawMessage, error) { | |
| 445 | + | ws, err := m.workspace(cwd) | |
| 446 | + | if err != nil { | |
| 447 | + | return nil, err | |
| 448 | + | } | |
| 449 | + | spec, ok := ws.config.Servers[server] | |
| 450 | + | if !ok || spec.Disabled { | |
| 451 | + | return nil, fmt.Errorf("unknown or disabled LSP server %q", server) | |
| 452 | + | } | |
| 453 | + | if spec.Kind != "lsp" || spec.IsLinter { | |
| 454 | + | return nil, fmt.Errorf("%s is a linter and cannot serve navigation requests", server) | |
| 455 | + | } | |
| 456 | + | client, err := m.ensureClient(ctx, ws, spec) | |
| 457 | + | if err != nil { | |
| 458 | + | return nil, err | |
| 459 | + | } | |
| 460 | + | return m.requestWithTimeout(ctx, client, method, params) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func (m *Manager) requestWithTimeout(ctx context.Context, client *Client, method string, params any) (json.RawMessage, error) { | |
| 464 | + | if ctx == nil { | |
| 465 | + | ctx = context.Background() | |
| 466 | + | } | |
| 467 | + | requestCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout) | |
| 468 | + | defer cancel() | |
| 469 | + | return client.Request(requestCtx, method, params) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | // RequestAll sends a request to every applicable LSP server. If path is set, | |
| 473 | + | // the document is opened first so servers have current text and URI context. | |
| 474 | + | func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, params any) []Response { | |
| 475 | + | ws, err := m.workspace(cwd) | |
| 476 | + | if err != nil { | |
| 477 | + | return []Response{{Error: err}} | |
| 478 | + | } | |
| 479 | + | if path != "" { | |
| 480 | + | _ = m.Open(ctx, ws.cwd, path) | |
| 481 | + | } | |
| 482 | + | var responses []Response | |
| 483 | + | for _, spec := range m.applicable(ws, path) { | |
| 484 | + | if spec.Kind != "lsp" || spec.IsLinter { | |
| 485 | + | continue | |
| 486 | + | } | |
| 487 | + | client, clientErr := m.ensureClient(ctx, ws, spec) | |
| 488 | + | if clientErr != nil { | |
| 489 | + | responses = append(responses, Response{Server: spec.ID, Error: clientErr}) | |
| 490 | + | continue | |
| 491 | + | } | |
| 492 | + | result, requestErr := m.requestWithTimeout(ctx, client, method, params) | |
| 493 | + | responses = append(responses, Response{Server: spec.ID, Result: result, Error: requestErr}) | |
| 494 | + | } | |
| 495 | + | return responses | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | // ApplyEdit applies a workspace edit under the selected workspace root. | |
| 499 | + | func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error { | |
| 500 | + | return m.ApplyEdits(cwd, []WorkspaceEdit{edit}) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // ApplyEdits preflights and applies multiple workspace edits as one operation. | |
| 504 | + | // This prevents a later unsafe edit from leaving earlier edits applied. | |
| 505 | + | func (m *Manager) ApplyEdits(cwd string, edits []WorkspaceEdit) error { | |
| 506 | + | ws, err := m.workspace(cwd) | |
| 507 | + | if err != nil { | |
| 508 | + | return err | |
| 509 | + | } | |
| 510 | + | merged := WorkspaceEdit{Changes: make(map[string][]TextEdit)} | |
| 511 | + | for _, edit := range edits { | |
| 512 | + | for uri, changes := range edit.Changes { | |
| 513 | + | merged.Changes[uri] = append(merged.Changes[uri], changes...) | |
| 514 | + | } | |
| 515 | + | merged.DocumentChanges = append(merged.DocumentChanges, edit.DocumentChanges...) | |
| 516 | + | } | |
| 517 | + | return applyWorkspaceEdit(ws.cwd, merged, m.options.CheckWritePath) | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | // Status describes configured and cached servers without starting missing | |
| 521 | + | // processes. It is safe to call for a workspace with no installed tools. | |
| 522 | + | type ServerStatus struct { | |
| 523 | + | ID string `json:"id"` | |
| 524 | + | Kind string `json:"kind"` | |
| 525 | + | Command string `json:"command"` | |
| 526 | + | Root string `json:"root,omitempty"` | |
| 527 | + | State string `json:"state"` | |
| 528 | + | Error string `json:"error,omitempty"` | |
| 529 | + | Diagnostics int `json:"diagnostics,omitempty"` | |
| 530 | + | Capabilities json.RawMessage `json:"capabilities,omitempty"` | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) { | |
| 534 | + | ws, err := m.workspace(cwd) | |
| 535 | + | if err != nil { | |
| 536 | + | return nil, err | |
| 537 | + | } | |
| 538 | + | specs := m.applicable(ws, path) | |
| 539 | + | roots := make(map[string]string, len(specs)) | |
| 540 | + | resolveErrors := make(map[string]error, len(specs)) | |
| 541 | + | for _, spec := range specs { | |
| 542 | + | roots[spec.ID] = displayWorkspacePath(ws.cwd, FindRoot(ws.cwd, spec.RootMarkers)) | |
| 543 | + | if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil { | |
| 544 | + | resolveErrors[spec.ID] = resolveErr | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | m.mu.Lock() | |
| 548 | + | defer m.mu.Unlock() | |
| 549 | + | out := make([]ServerStatus, 0, len(specs)) | |
| 550 | + | for _, spec := range specs { | |
| 551 | + | status := ServerStatus{ID: spec.ID, Kind: spec.Kind, Command: spec.Command, Root: roots[spec.ID], State: ws.statuses[spec.ID]} | |
| 552 | + | if status.State == "" { | |
| 553 | + | status.State = "stopped" | |
| 554 | + | } | |
| 555 | + | if status.State == "stopped" { | |
| 556 | + | if resolveErr := resolveErrors[spec.ID]; resolveErr != nil { | |
| 557 | + | status.State = "missing" | |
| 558 | + | status.Error = resolveErr.Error() | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | if client := ws.clients[spec.ID]; client != nil { | |
| 562 | + | status.Capabilities = client.Capabilities() | |
| 563 | + | } | |
| 564 | + | for _, values := range ws.diagnostics[spec.ID] { | |
| 565 | + | status.Diagnostics += len(values) | |
| 566 | + | } | |
| 567 | + | status.Diagnostics += len(ws.cliDiagnostics[spec.ID]) | |
| 568 | + | if configuredErr := ws.errors[spec.ID]; configuredErr != "" { | |
| 569 | + | status.Error = configuredErr | |
| 570 | + | } | |
| 571 | + | out = append(out, status) | |
| 572 | + | } | |
| 573 | + | return out, nil | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | func (m *Manager) Capabilities(cwd, path, server string) (map[string]json.RawMessage, error) { | |
| 577 | + | ws, err := m.workspace(cwd) | |
| 578 | + | if err != nil { | |
| 579 | + | return nil, err | |
| 580 | + | } | |
| 581 | + | ensure := func(spec ServerConfig) (*Client, error) { | |
| 582 | + | if spec.Kind != "lsp" || spec.IsLinter { | |
| 583 | + | return nil, fmt.Errorf("%s is not a navigable LSP server", spec.ID) | |
| 584 | + | } | |
| 585 | + | m.mu.Lock() | |
| 586 | + | client := ws.clients[spec.ID] | |
| 587 | + | m.mu.Unlock() | |
| 588 | + | if client != nil { | |
| 589 | + | return client, nil | |
| 590 | + | } | |
| 591 | + | return m.ensureClient(context.Background(), ws, spec) | |
| 592 | + | } | |
| 593 | + | if server != "" { | |
| 594 | + | spec, ok := ws.config.Servers[server] | |
| 595 | + | if !ok || spec.Disabled { | |
| 596 | + | return nil, fmt.Errorf("unknown or disabled server %q", server) | |
| 597 | + | } | |
| 598 | + | client, err := ensure(spec) | |
| 599 | + | if err != nil { | |
| 600 | + | return nil, err | |
| 601 | + | } | |
| 602 | + | return map[string]json.RawMessage{server: client.Capabilities()}, nil | |
| 603 | + | } | |
| 604 | + | statuses, err := m.Status(cwd, path) | |
| 605 | + | if err != nil { | |
| 606 | + | return nil, err | |
| 607 | + | } | |
| 608 | + | out := make(map[string]json.RawMessage) | |
| 609 | + | var firstErr error | |
| 610 | + | for _, status := range statuses { | |
| 611 | + | spec := ws.config.Servers[status.ID] | |
| 612 | + | if spec.Kind != "lsp" || spec.IsLinter { | |
| 613 | + | continue | |
| 614 | + | } | |
| 615 | + | client, clientErr := ensure(spec) | |
| 616 | + | if clientErr != nil { | |
| 617 | + | if firstErr == nil { | |
| 618 | + | firstErr = clientErr | |
| 619 | + | } | |
| 620 | + | continue | |
| 621 | + | } | |
| 622 | + | if capabilities := client.Capabilities(); len(capabilities) > 0 { | |
| 623 | + | out[status.ID] = capabilities | |
| 624 | + | } | |
| 625 | + | } | |
| 626 | + | return out, firstErr | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | // Reload terminates cached processes for cwd and reloads all lsp.json layers. | |
| 630 | + | func (m *Manager) Reload(cwd string) error { | |
| 631 | + | cwd, err := absDir(cwd) | |
| 632 | + | if err != nil { | |
| 633 | + | return err | |
| 634 | + | } | |
| 635 | + | m.mu.Lock() | |
| 636 | + | if m.closed { | |
| 637 | + | m.mu.Unlock() | |
| 638 | + | return errManagerClosed | |
| 639 | + | } | |
| 640 | + | ws := m.workspaces[cwd] | |
| 641 | + | delete(m.workspaces, cwd) | |
| 642 | + | m.mu.Unlock() | |
| 643 | + | if ws != nil { | |
| 644 | + | for _, client := range m.clientsSnapshot(ws) { | |
| 645 | + | _ = client.Close() | |
| 646 | + | } | |
| 647 | + | } | |
| 648 | + | return nil | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | func (m *Manager) clientsSnapshot(ws *workspace) []*Client { | |
| 652 | + | m.mu.Lock() | |
| 653 | + | defer m.mu.Unlock() | |
| 654 | + | clients := make([]*Client, 0, len(ws.clients)) | |
| 655 | + | for _, client := range ws.clients { | |
| 656 | + | clients = append(clients, client) | |
| 657 | + | } | |
| 658 | + | return clients | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | func (m *Manager) Close() error { | |
| 662 | + | m.mu.Lock() | |
| 663 | + | if m.closed { | |
| 664 | + | m.mu.Unlock() | |
| 665 | + | return nil | |
| 666 | + | } | |
| 667 | + | m.closed = true | |
| 668 | + | workspaces := make([]*workspace, 0, len(m.workspaces)) | |
| 669 | + | for _, ws := range m.workspaces { | |
| 670 | + | workspaces = append(workspaces, ws) | |
| 671 | + | } | |
| 672 | + | m.workspaces = make(map[string]*workspace) | |
| 673 | + | m.mu.Unlock() | |
| 674 | + | var first error | |
| 675 | + | for _, ws := range workspaces { | |
| 676 | + | for _, client := range m.clientsSnapshot(ws) { | |
| 677 | + | if err := client.Close(); err != nil && first == nil { | |
| 678 | + | first = err | |
| 679 | + | } | |
| 680 | + | } | |
| 681 | + | } | |
| 682 | + | return first | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | // RunLinters executes configured CLI providers without a shell. A non-zero | |
| 686 | + | // linter exit is normal when it emitted parseable findings. | |
| 687 | + | func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error { | |
| 688 | + | if ctx == nil { | |
| 689 | + | ctx = context.Background() | |
| 690 | + | } | |
| 691 | + | ws, err := m.workspace(cwd) | |
| 692 | + | if err != nil { | |
| 693 | + | return err | |
| 694 | + | } | |
| 695 | + | var files []string | |
| 696 | + | if path != "" { | |
| 697 | + | if abs, pathErr := absoluteFile(ws.cwd, path); pathErr == nil { | |
| 698 | + | files = []string{abs} | |
| 699 | + | } | |
| 700 | + | } | |
| 701 | + | var firstErr error | |
| 702 | + | for _, spec := range m.applicable(ws, path) { | |
| 703 | + | if spec.Kind != "cli" || spec.Disabled { | |
| 704 | + | continue | |
| 705 | + | } | |
| 706 | + | command, resolveErr := ResolveCommand(ws.cwd, spec.Command) | |
| 707 | + | if resolveErr != nil { | |
| 708 | + | m.mu.Lock() | |
| 709 | + | ws.statuses[spec.ID] = "missing" | |
| 710 | + | ws.errors[spec.ID] = resolveErr.Error() | |
| 711 | + | m.mu.Unlock() | |
| 712 | + | if firstErr == nil { | |
| 713 | + | firstErr = resolveErr | |
| 714 | + | } | |
| 715 | + | continue | |
| 716 | + | } | |
| 717 | + | args := make([]string, 0, len(spec.Args)+len(files)) | |
| 718 | + | containsFiles := false | |
| 719 | + | relative := relativeFiles(ws.cwd, files) | |
| 720 | + | for _, arg := range spec.Args { | |
| 721 | + | if arg == "{files}" { | |
| 722 | + | containsFiles = true | |
| 723 | + | args = append(args, relative...) | |
| 724 | + | continue | |
| 725 | + | } | |
| 726 | + | if strings.Contains(arg, "{files}") { | |
| 727 | + | containsFiles = true | |
| 728 | + | if len(relative) == 0 { | |
| 729 | + | args = append(args, strings.ReplaceAll(arg, "{files}", "")) | |
| 730 | + | } else { | |
| 731 | + | for _, file := range relative { | |
| 732 | + | args = append(args, strings.ReplaceAll(arg, "{files}", file)) | |
| 733 | + | } | |
| 734 | + | } | |
| 735 | + | continue | |
| 736 | + | } | |
| 737 | + | args = append(args, arg) | |
| 738 | + | } | |
| 739 | + | if spec.Mode == "workspace" || (spec.Mode == "" && len(files) == 0) { | |
| 740 | + | if !containsFiles && (len(args) == 0 || args[len(args)-1] != ".") { | |
| 741 | + | args = append(args, ".") | |
| 742 | + | } | |
| 743 | + | } else if !containsFiles { | |
| 744 | + | args = append(args, relative...) | |
| 745 | + | } | |
| 746 | + | commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout) | |
| 747 | + | cmd := exec.CommandContext(commandCtx, command, args...) | |
| 748 | + | cmd.Dir = ws.cwd | |
| 749 | + | cmd.Env = mergedEnvironment(spec.Env) | |
| 750 | + | var stdout, stderr limitedBuffer | |
| 751 | + | stdout.limit = 8 * 1024 * 1024 | |
| 752 | + | stderr.limit = 8 * 1024 * 1024 | |
| 753 | + | cmd.Stdout = &stdout | |
| 754 | + | cmd.Stderr = &stderr | |
| 755 | + | m.mu.Lock() | |
| 756 | + | ws.statuses[spec.ID] = "running" | |
| 757 | + | m.mu.Unlock() | |
| 758 | + | runErr := cmd.Run() | |
| 759 | + | cancel() | |
| 760 | + | text := strings.TrimSpace(stdout.String()) | |
| 761 | + | if text == "" { | |
| 762 | + | text = strings.TrimSpace(stderr.String()) | |
| 763 | + | } | |
| 764 | + | diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text) | |
| 765 | + | filtered := diagnostics[:0] | |
| 766 | + | for _, diagnostic := range diagnostics { | |
| 767 | + | if diagnostic.Path != "" && diagnosticInWorkspace(ws.rootReal, diagnostic.Path) { | |
| 768 | + | filtered = append(filtered, diagnostic) | |
| 769 | + | } | |
| 770 | + | } | |
| 771 | + | diagnostics = filtered | |
| 772 | + | m.mu.Lock() | |
| 773 | + | ws.cliDiagnostics[spec.ID] = diagnostics | |
| 774 | + | if runErr != nil && len(diagnostics) == 0 { | |
| 775 | + | ws.errors[spec.ID] = runErr.Error() | |
| 776 | + | ws.statuses[spec.ID] = "error" | |
| 777 | + | } else { | |
| 778 | + | delete(ws.errors, spec.ID) | |
| 779 | + | ws.statuses[spec.ID] = "ready" | |
| 780 | + | } | |
| 781 | + | m.mu.Unlock() | |
| 782 | + | if runErr != nil && len(diagnostics) == 0 && firstErr == nil { | |
| 783 | + | firstErr = fmt.Errorf("%s: %w", spec.ID, runErr) | |
| 784 | + | } | |
| 785 | + | } | |
| 786 | + | return firstErr | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | func contentHash(data []byte) string { | |
| 790 | + | sum := sha256.Sum256(data) | |
| 791 | + | return hex.EncodeToString(sum[:]) | |
| 792 | + | } | |
| 793 | + | ||
| 794 | + | func displayWorkspacePath(root, path string) string { | |
| 795 | + | if path == "" { | |
| 796 | + | return "" | |
| 797 | + | } | |
| 798 | + | rel, err := filepath.Rel(root, path) | |
| 799 | + | if err != nil { | |
| 800 | + | return "" | |
| 801 | + | } | |
| 802 | + | if rel == "." { | |
| 803 | + | return "." | |
| 804 | + | } | |
| 805 | + | return filepath.ToSlash(rel) | |
| 806 | + | } | |
| 807 | + | ||
| 808 | + | func relativeFiles(cwd string, files []string) []string { | |
| 809 | + | out := make([]string, len(files)) | |
| 810 | + | for i, path := range files { | |
| 811 | + | rel, err := filepath.Rel(cwd, path) | |
| 812 | + | if err != nil { | |
| 813 | + | out[i] = path | |
| 814 | + | } else { | |
| 815 | + | out[i] = filepath.ToSlash(rel) | |
| 816 | + | } | |
| 817 | + | } | |
| 818 | + | return out | |
| 819 | + | } | |
| 820 | + | func absoluteFile(cwd, path string) (string, error) { | |
| 821 | + | if path == "" { | |
| 822 | + | return "", errors.New("path is required") | |
| 823 | + | } | |
| 824 | + | if !filepath.IsAbs(path) { | |
| 825 | + | path = filepath.Join(cwd, path) | |
| 826 | + | } | |
| 827 | + | return filepath.Abs(path) | |
| 828 | + | } | |
| 829 | + | func diagnosticInWorkspace(rootReal, path string) bool { | |
| 830 | + | return safeWorkspacePath(rootReal, path) == nil | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | func samePath(a, b string) bool { | |
| 834 | + | aa, _ := filepath.Abs(a) | |
| 835 | + | bb, _ := filepath.Abs(b) | |
| 836 | + | return filepath.Clean(aa) == filepath.Clean(bb) | |
| 837 | + | } | |
| 838 | + | ||
| 839 | + | type limitedBuffer struct { | |
| 840 | + | buffer bytes.Buffer | |
| 841 | + | limit int | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | func (b *limitedBuffer) Len() int { return b.buffer.Len() } | |
| 845 | + | ||
| 846 | + | func (b *limitedBuffer) String() string { return b.buffer.String() } | |
| 847 | + | ||
| 848 | + | func (b *limitedBuffer) Write(p []byte) (int, error) { | |
| 849 | + | original := len(p) | |
| 850 | + | if b.limit <= b.buffer.Len() { | |
| 851 | + | return original, nil | |
| 852 | + | } | |
| 853 | + | room := b.limit - b.buffer.Len() | |
| 854 | + | if len(p) > room { | |
| 855 | + | p = p[:room] | |
| 856 | + | } | |
| 857 | + | _, _ = b.buffer.Write(p) | |
| 858 | + | return original, nil | |
| 859 | + | } | |
A packages/agent/lsp/parsers.go +287 -0
| @@ -0,0 +1,287 @@ | |||
| 1 | + | package lsp | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "path/filepath" | |
| 8 | + | "regexp" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ParseDiagnostics parses output from one of the built-in CLI linter formats. | |
| 14 | + | // Unknown parser names use the generic file:line:column parser. | |
| 15 | + | func ParseDiagnostics(parser, provider, cwd, output string) []Diagnostic { | |
| 16 | + | switch strings.ToLower(strings.TrimSpace(parser)) { | |
| 17 | + | case "eslint-json", "eslint": | |
| 18 | + | return parseESLint(provider, cwd, output) | |
| 19 | + | case "golangci-lint-json", "golangci", "golangci-json": | |
| 20 | + | return parseGolangCILint(provider, cwd, output) | |
| 21 | + | case "ruff-json", "ruff": | |
| 22 | + | return parseRuff(provider, cwd, output) | |
| 23 | + | case "sarif": | |
| 24 | + | return parseSARIF(provider, cwd, output) | |
| 25 | + | default: | |
| 26 | + | return parseGeneric(provider, cwd, output) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func parseESLint(provider, cwd, output string) []Diagnostic { | |
| 31 | + | var results []struct { | |
| 32 | + | FilePath string `json:"filePath"` | |
| 33 | + | Messages []struct { | |
| 34 | + | RuleID string `json:"ruleId"` | |
| 35 | + | Severity int `json:"severity"` | |
| 36 | + | Message string `json:"message"` | |
| 37 | + | Line int `json:"line"` | |
| 38 | + | Column int `json:"column"` | |
| 39 | + | EndLine int `json:"endLine"` | |
| 40 | + | EndColumn int `json:"endColumn"` | |
| 41 | + | } `json:"messages"` | |
| 42 | + | } | |
| 43 | + | if json.Unmarshal([]byte(output), &results) != nil { | |
| 44 | + | return parseGeneric(provider, cwd, output) | |
| 45 | + | } | |
| 46 | + | var out []Diagnostic | |
| 47 | + | for _, result := range results { | |
| 48 | + | path := normalizeDiagnosticPath(cwd, result.FilePath) | |
| 49 | + | for _, message := range result.Messages { | |
| 50 | + | severity := "info" | |
| 51 | + | if message.Severity >= 2 { | |
| 52 | + | severity = "error" | |
| 53 | + | } else if message.Severity == 1 { | |
| 54 | + | severity = "warning" | |
| 55 | + | } | |
| 56 | + | out = append(out, Diagnostic{Path: path, Server: provider, Source: provider, Severity: severity, Code: message.RuleID, Message: message.Message, Range: oneBasedRange(message.Line, message.Column, message.EndLine, message.EndColumn)}) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | return out | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func parseGolangCILint(provider, cwd, output string) []Diagnostic { | |
| 63 | + | var root struct { | |
| 64 | + | Issues []struct { | |
| 65 | + | FromLinter string `json:"FromLinter"` | |
| 66 | + | Text string `json:"Text"` | |
| 67 | + | Severity string `json:"Severity"` | |
| 68 | + | Pos struct { | |
| 69 | + | Filename string `json:"Filename"` | |
| 70 | + | Line int `json:"Line"` | |
| 71 | + | Column int `json:"Column"` | |
| 72 | + | Offset int `json:"Offset"` | |
| 73 | + | } `json:"Pos"` | |
| 74 | + | LineRange *struct { | |
| 75 | + | From int `json:"From"` | |
| 76 | + | To int `json:"To"` | |
| 77 | + | } `json:"LineRange"` | |
| 78 | + | } `json:"Issues"` | |
| 79 | + | } | |
| 80 | + | if json.Unmarshal([]byte(output), &root) != nil { | |
| 81 | + | return parseGeneric(provider, cwd, output) | |
| 82 | + | } | |
| 83 | + | var out []Diagnostic | |
| 84 | + | for _, issue := range root.Issues { | |
| 85 | + | severity := normalizeSeverity(issue.Severity) | |
| 86 | + | endLine := issue.Pos.Line | |
| 87 | + | if issue.LineRange != nil && issue.LineRange.To > endLine { | |
| 88 | + | endLine = issue.LineRange.To | |
| 89 | + | } | |
| 90 | + | out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, issue.Pos.Filename), Server: provider, Source: issue.FromLinter, Severity: severity, Code: issue.FromLinter, Message: issue.Text, Range: oneBasedRange(issue.Pos.Line, issue.Pos.Column, endLine, issue.Pos.Column)}) | |
| 91 | + | } | |
| 92 | + | return out | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func parseRuff(provider, cwd, output string) []Diagnostic { | |
| 96 | + | var records []struct { | |
| 97 | + | Code string `json:"code"` | |
| 98 | + | Message string `json:"message"` | |
| 99 | + | Filename string `json:"filename"` | |
| 100 | + | Location struct { | |
| 101 | + | Row int `json:"row"` | |
| 102 | + | Column int `json:"column"` | |
| 103 | + | } `json:"location"` | |
| 104 | + | EndLocation struct { | |
| 105 | + | Row int `json:"row"` | |
| 106 | + | Column int `json:"column"` | |
| 107 | + | } `json:"end_location"` | |
| 108 | + | Fix any `json:"fix"` | |
| 109 | + | } | |
| 110 | + | if json.Unmarshal([]byte(output), &records) != nil { | |
| 111 | + | return parseGeneric(provider, cwd, output) | |
| 112 | + | } | |
| 113 | + | var out []Diagnostic | |
| 114 | + | for _, record := range records { | |
| 115 | + | endLine, endColumn := record.EndLocation.Row, record.EndLocation.Column | |
| 116 | + | if endLine == 0 { | |
| 117 | + | endLine, endColumn = record.Location.Row, record.Location.Column | |
| 118 | + | } | |
| 119 | + | out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, record.Filename), Server: provider, Source: provider, Severity: "error", Code: record.Code, Message: record.Message, Range: oneBasedRange(record.Location.Row, record.Location.Column, endLine, endColumn)}) | |
| 120 | + | } | |
| 121 | + | return out | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func parseSARIF(provider, cwd, output string) []Diagnostic { | |
| 125 | + | var root struct { | |
| 126 | + | Runs []struct { | |
| 127 | + | Tool struct { | |
| 128 | + | Driver struct { | |
| 129 | + | Rules []struct { | |
| 130 | + | ID string `json:"id"` | |
| 131 | + | } `json:"rules"` | |
| 132 | + | } `json:"driver"` | |
| 133 | + | } `json:"tool"` | |
| 134 | + | Results []struct { | |
| 135 | + | RuleID string `json:"ruleId"` | |
| 136 | + | Level string `json:"level"` | |
| 137 | + | Message struct { | |
| 138 | + | Text string `json:"text"` | |
| 139 | + | } `json:"message"` | |
| 140 | + | Locations []struct { | |
| 141 | + | Physical struct { | |
| 142 | + | ArtifactLocation struct { | |
| 143 | + | URI string `json:"uri"` | |
| 144 | + | } `json:"artifactLocation"` | |
| 145 | + | Region struct { | |
| 146 | + | StartLine int `json:"startLine"` | |
| 147 | + | StartColumn int `json:"startColumn"` | |
| 148 | + | EndLine int `json:"endLine"` | |
| 149 | + | EndColumn int `json:"endColumn"` | |
| 150 | + | } `json:"region"` | |
| 151 | + | } `json:"physicalLocation"` | |
| 152 | + | } `json:"locations"` | |
| 153 | + | } `json:"results"` | |
| 154 | + | } `json:"runs"` | |
| 155 | + | } | |
| 156 | + | if json.Unmarshal([]byte(output), &root) != nil { | |
| 157 | + | return parseGeneric(provider, cwd, output) | |
| 158 | + | } | |
| 159 | + | var out []Diagnostic | |
| 160 | + | for _, run := range root.Runs { | |
| 161 | + | for _, result := range run.Results { | |
| 162 | + | if len(result.Locations) == 0 { | |
| 163 | + | continue | |
| 164 | + | } | |
| 165 | + | location := result.Locations[0].Physical | |
| 166 | + | path := location.ArtifactLocation.URI | |
| 167 | + | if strings.HasPrefix(path, "file://") { | |
| 168 | + | if decoded, err := uriToPath(path); err == nil { | |
| 169 | + | path = decoded | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, path), Server: provider, Source: provider, Severity: normalizeSeverity(result.Level), Code: result.RuleID, Message: result.Message.Text, Range: oneBasedRange(location.Region.StartLine, location.Region.StartColumn, location.Region.EndLine, location.Region.EndColumn)}) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | return out | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | var genericDiagnosticPattern = regexp.MustCompile(`(?i)^\s*(.+?):([0-9]+):([0-9]+)(?::\s*(error|err|fatal|failure|warning|warn|info|hint|note)\b)?\s*[-:]?\s*(.*)$`) | |
| 179 | + | ||
| 180 | + | func parseGeneric(provider, cwd, output string) []Diagnostic { | |
| 181 | + | var out []Diagnostic | |
| 182 | + | scanner := bufio.NewScanner(strings.NewReader(output)) | |
| 183 | + | for scanner.Scan() { | |
| 184 | + | line := strings.TrimSpace(scanner.Text()) | |
| 185 | + | match := genericDiagnosticPattern.FindStringSubmatch(line) | |
| 186 | + | if len(match) == 0 { | |
| 187 | + | continue | |
| 188 | + | } | |
| 189 | + | lineNo, _ := strconv.Atoi(match[2]) | |
| 190 | + | columnNo, _ := strconv.Atoi(match[3]) | |
| 191 | + | message := strings.TrimSpace(match[5]) | |
| 192 | + | if message == "" { | |
| 193 | + | message = line | |
| 194 | + | } | |
| 195 | + | severity := normalizeSeverity(match[4]) | |
| 196 | + | out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, match[1]), Server: provider, Source: provider, Severity: severity, Message: message, Range: oneBasedRange(lineNo, columnNo, lineNo, columnNo)}) | |
| 197 | + | } | |
| 198 | + | return out | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func oneBasedRange(line, column, endLine, endColumn int) Range { | |
| 202 | + | if line < 1 { | |
| 203 | + | line = 1 | |
| 204 | + | } | |
| 205 | + | if column < 1 { | |
| 206 | + | column = 1 | |
| 207 | + | } | |
| 208 | + | if endLine < 1 { | |
| 209 | + | endLine = line | |
| 210 | + | } | |
| 211 | + | if endColumn < 1 { | |
| 212 | + | endColumn = column | |
| 213 | + | } | |
| 214 | + | return Range{Start: Position{Line: line - 1, Character: column - 1}, End: Position{Line: endLine - 1, Character: endColumn - 1}} | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func normalizeSeverity(value string) string { | |
| 218 | + | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 219 | + | case "error", "err", "fatal", "failure": | |
| 220 | + | return "error" | |
| 221 | + | case "warning", "warn": | |
| 222 | + | return "warning" | |
| 223 | + | case "hint": | |
| 224 | + | return "hint" | |
| 225 | + | default: | |
| 226 | + | return "info" | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func normalizeDiagnosticPath(cwd, path string) string { | |
| 231 | + | path = strings.TrimSpace(path) | |
| 232 | + | if path == "" { | |
| 233 | + | return "" | |
| 234 | + | } | |
| 235 | + | if strings.HasPrefix(path, "file://") { | |
| 236 | + | if decoded, err := uriToPath(path); err == nil { | |
| 237 | + | path = decoded | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | if !filepath.IsAbs(path) { | |
| 241 | + | path = filepath.Join(cwd, path) | |
| 242 | + | } | |
| 243 | + | abs, err := filepath.Abs(path) | |
| 244 | + | if err == nil { | |
| 245 | + | return filepath.Clean(abs) | |
| 246 | + | } | |
| 247 | + | return filepath.Clean(path) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // FormatDiagnostic is the compact, model-facing form of one diagnostic. | |
| 251 | + | func FormatDiagnostic(d Diagnostic, root string) string { | |
| 252 | + | path := d.Path | |
| 253 | + | if root != "" { | |
| 254 | + | if rel, err := filepath.Rel(root, d.Path); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |
| 255 | + | path = filepath.ToSlash(rel) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | code := "" | |
| 259 | + | if d.Code != "" { | |
| 260 | + | code = " [" + d.Code + "]" | |
| 261 | + | } | |
| 262 | + | message := truncateText(compactWhitespace(d.Message), 220) | |
| 263 | + | return fmt.Sprintf("%s: %s:%d:%d%s %s", strings.ToUpper(defaultSeverity(d.Severity)), filepath.ToSlash(path), d.Range.Start.Line+1, d.Range.Start.Character+1, code, message) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func defaultSeverity(value string) string { | |
| 267 | + | if value == "" { | |
| 268 | + | return "info" | |
| 269 | + | } | |
| 270 | + | return value | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func compactWhitespace(value string) string { return strings.Join(strings.Fields(value), " ") } | |
| 274 | + | ||
| 275 | + | func truncateText(value string, limit int) string { | |
| 276 | + | if limit <= 0 { | |
| 277 | + | return "" | |
| 278 | + | } | |
| 279 | + | runes := []rune(value) | |
| 280 | + | if len(runes) <= limit { | |
| 281 | + | return value | |
| 282 | + | } | |
| 283 | + | if limit == 1 { | |
| 284 | + | return "…" | |
| 285 | + | } | |
| 286 | + | return string(runes[:limit-1]) + "…" | |
| 287 | + | } | |
A packages/agent/lsp/protocol.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | package lsp | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "net/url" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "strings" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Position and Range use the zero-based line and UTF-16 character indexing | |
| 13 | + | // defined by LSP. | |
| 14 | + | type Position struct { | |
| 15 | + | Line int `json:"line"` | |
| 16 | + | Character int `json:"character"` | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type Range struct { | |
| 20 | + | Start Position `json:"start"` | |
| 21 | + | End Position `json:"end"` | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type TextEdit struct { | |
| 25 | + | Range Range `json:"range"` | |
| 26 | + | NewText string `json:"newText"` | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // WorkspaceEdit is the safe subset of a workspace edit understood by zot. | |
| 30 | + | // Resource operations in DocumentChanges are deliberately rejected. | |
| 31 | + | type WorkspaceEdit struct { | |
| 32 | + | Changes map[string][]TextEdit `json:"changes,omitempty"` | |
| 33 | + | DocumentChanges []json.RawMessage `json:"documentChanges,omitempty"` | |
| 34 | + | ChangeAnnotations json.RawMessage `json:"changeAnnotations,omitempty"` | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Diagnostic is the normalized diagnostic representation shared by LSP and | |
| 38 | + | // CLI linters. Path is absolute when it came from a workspace-backed source. | |
| 39 | + | type Diagnostic struct { | |
| 40 | + | Path string `json:"path"` | |
| 41 | + | Range Range `json:"range"` | |
| 42 | + | Severity string `json:"severity"` | |
| 43 | + | Code string `json:"code,omitempty"` | |
| 44 | + | Message string `json:"message"` | |
| 45 | + | Source string `json:"source,omitempty"` | |
| 46 | + | Server string `json:"server,omitempty"` | |
| 47 | + | Clear bool `json:"-"` | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (d *Diagnostic) UnmarshalJSON(data []byte) error { | |
| 51 | + | var raw struct { | |
| 52 | + | Range Range `json:"range"` | |
| 53 | + | Severity json.RawMessage `json:"severity"` | |
| 54 | + | Code json.RawMessage `json:"code"` | |
| 55 | + | Message string `json:"message"` | |
| 56 | + | Source string `json:"source"` | |
| 57 | + | } | |
| 58 | + | if err := json.Unmarshal(data, &raw); err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | d.Range, d.Message, d.Source = raw.Range, raw.Message, raw.Source | |
| 62 | + | d.Severity = severityFromJSON(raw.Severity) | |
| 63 | + | d.Code = stringFromJSON(raw.Code) | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func severityFromJSON(raw json.RawMessage) string { | |
| 68 | + | var n int | |
| 69 | + | if json.Unmarshal(raw, &n) == nil { | |
| 70 | + | switch n { | |
| 71 | + | case 1: | |
| 72 | + | return "error" | |
| 73 | + | case 2: | |
| 74 | + | return "warning" | |
| 75 | + | case 3: | |
| 76 | + | return "info" | |
| 77 | + | case 4: | |
| 78 | + | return "hint" | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | var s string | |
| 82 | + | if json.Unmarshal(raw, &s) == nil { | |
| 83 | + | switch strings.ToLower(s) { | |
| 84 | + | case "error", "err", "fatal": | |
| 85 | + | return "error" | |
| 86 | + | case "warning", "warn": | |
| 87 | + | return "warning" | |
| 88 | + | case "hint": | |
| 89 | + | return "hint" | |
| 90 | + | default: | |
| 91 | + | return "info" | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | return "info" | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func stringFromJSON(raw json.RawMessage) string { | |
| 98 | + | if len(raw) == 0 || string(raw) == "null" { | |
| 99 | + | return "" | |
| 100 | + | } | |
| 101 | + | var s string | |
| 102 | + | if json.Unmarshal(raw, &s) == nil { | |
| 103 | + | return s | |
| 104 | + | } | |
| 105 | + | var n json.Number | |
| 106 | + | if json.Unmarshal(raw, &n) == nil { | |
| 107 | + | return n.String() | |
| 108 | + | } | |
| 109 | + | return strings.Trim(string(raw), `"`) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // URIForPath returns the escaped file URI used by LSP document params. | |
| 113 | + | func URIForPath(path string) string { | |
| 114 | + | uri, _ := pathToURI(path) | |
| 115 | + | return uri | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func pathToURI(path string) (string, error) { | |
| 119 | + | abs, err := filepath.Abs(path) | |
| 120 | + | if err != nil { | |
| 121 | + | return "", err | |
| 122 | + | } | |
| 123 | + | // url.URL escapes spaces and non-ASCII while retaining path separators. | |
| 124 | + | uriPath := filepath.ToSlash(abs) | |
| 125 | + | if runtime.GOOS == "windows" && strings.HasPrefix(uriPath, "//") { | |
| 126 | + | unc := strings.TrimPrefix(uriPath, "//") | |
| 127 | + | if host, rest, ok := strings.Cut(unc, "/"); ok { | |
| 128 | + | return (&url.URL{Scheme: "file", Host: host, Path: "/" + rest}).String(), nil | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | if !strings.HasPrefix(uriPath, "/") { | |
| 132 | + | uriPath = "/" + uriPath | |
| 133 | + | } | |
| 134 | + | u := &url.URL{Scheme: "file", Path: uriPath} | |
| 135 | + | return u.String(), nil | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func uriToPath(uri string) (string, error) { | |
| 139 | + | u, err := url.Parse(uri) | |
| 140 | + | if err != nil { | |
| 141 | + | return "", err | |
| 142 | + | } | |
| 143 | + | if u.Scheme != "" && !strings.EqualFold(u.Scheme, "file") { | |
| 144 | + | return "", fmt.Errorf("unsupported document URI scheme %q", u.Scheme) | |
| 145 | + | } | |
| 146 | + | path := u.Path | |
| 147 | + | if u.Host != "" && u.Host != "localhost" { | |
| 148 | + | path = "//" + u.Host + path | |
| 149 | + | } | |
| 150 | + | if path == "" { | |
| 151 | + | return "", fmt.Errorf("document URI has no path") | |
| 152 | + | } | |
| 153 | + | if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' { // file:///C:/... | |
| 154 | + | path = path[1:] | |
| 155 | + | } | |
| 156 | + | return filepath.Clean(filepath.FromSlash(path)), nil | |
| 157 | + | } | |
M packages/agent/modes/interactive.go +75 -1
| @@ -77,6 +77,14 @@ type InteractiveConfig struct { | |||
| 77 | 77 | // to enable it and reject requests when it remains enabled. | |
| 78 | 78 | FastMode *bool | |
| 79 | 79 | ||
| 80 | + | // LSPEnabled controls the main session's built-in lsp tool. nil means | |
| 81 | + | // enabled by default. | |
| 82 | + | LSPEnabled *bool | |
| 83 | + | ||
| 84 | + | // SubagentLSPEnabled controls whether newly spawned swarm children | |
| 85 | + | // receive the lsp tool. nil means enabled by default. | |
| 86 | + | SubagentLSPEnabled *bool | |
| 87 | + | ||
| 80 | 88 | // AutoCompactThreshold is the context-window percentage that triggers | |
| 81 | 89 | // automatic compaction. nil means 85; zero disables percentage-based | |
| 82 | 90 | // triggers while preserving payload-too-large recovery. | |
| @@ -159,6 +167,10 @@ type InteractiveConfig struct { | |||
| 159 | 167 | // it to rediscover skills / reload extensions installed by pre. | |
| 160 | 168 | OnStartupPreDone func() | |
| 161 | 169 | ||
| 170 | + | // RefreshTools re-resolves the live agent registry after a setting | |
| 171 | + | // changes the main session's tool availability. Optional for embedders. | |
| 172 | + | RefreshTools func() | |
| 173 | + | ||
| 162 | 174 | // AutoSubmitInitial, when true, auto-submits InitialInput after | |
| 163 | 175 | // StartupPre completes (or immediately when StartupPre is empty). | |
| 164 | 176 | // When false, InitialInput only pre-fills the editor. | |
| @@ -404,6 +416,11 @@ type terminalTitleSettingsStore interface { | |||
| 404 | 416 | SetTerminalTitleEnabled(enabled bool) error | |
| 405 | 417 | } | |
| 406 | 418 | ||
| 419 | + | type lspSettingsStore interface { | |
| 420 | + | SetLSPEnabled(enabled bool) error | |
| 421 | + | SetSubagentLSPEnabled(enabled bool) error | |
| 422 | + | } | |
| 423 | + | ||
| 407 | 424 | type showInstructionsSettingsStore interface { | |
| 408 | 425 | SetShowInstructionsAtStartup(enabled bool) error | |
| 409 | 426 | } | |
| @@ -3631,6 +3648,8 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3631 | 3648 | if !provider.SupportsFastMode(i.cfg.Provider) { | |
| 3632 | 3649 | fastModeHint = "only supported for OpenAI providers" | |
| 3633 | 3650 | } | |
| 3651 | + | lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled | |
| 3652 | + | subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled | |
| 3634 | 3653 | ||
| 3635 | 3654 | jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault | |
| 3636 | 3655 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| @@ -3757,6 +3776,18 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3757 | 3776 | value: fastMode, | |
| 3758 | 3777 | hint: fastModeHint, | |
| 3759 | 3778 | }, | |
| 3779 | + | { | |
| 3780 | + | key: "lsp_enabled", | |
| 3781 | + | label: "lsp in main session", | |
| 3782 | + | desc: "enable the lsp tool and code diagnostics for the main agent", | |
| 3783 | + | value: lspEnabled, | |
| 3784 | + | }, | |
| 3785 | + | { | |
| 3786 | + | key: "subagent_lsp_enabled", | |
| 3787 | + | label: "lsp in sub-agents", | |
| 3788 | + | desc: "allow newly spawned background sub-agents to use the lsp tool", | |
| 3789 | + | value: subagentLSPEnabled, | |
| 3790 | + | }, | |
| 3760 | 3791 | { | |
| 3761 | 3792 | key: "auto_compact_threshold", | |
| 3762 | 3793 | label: "auto-compact threshold", | |
| @@ -4196,6 +4227,39 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4196 | 4227 | i.statusOK = "fast mode " + onOff(value) | |
| 4197 | 4228 | i.statusErr = "" | |
| 4198 | 4229 | i.mu.Unlock() | |
| 4230 | + | case "lsp_enabled": | |
| 4231 | + | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | |
| 4232 | + | if err := store.SetLSPEnabled(value); err != nil { | |
| 4233 | + | i.mu.Lock() | |
| 4234 | + | i.statusErr = "settings: " + err.Error() | |
| 4235 | + | i.mu.Unlock() | |
| 4236 | + | return | |
| 4237 | + | } | |
| 4238 | + | } | |
| 4239 | + | val := value | |
| 4240 | + | i.cfg.LSPEnabled = &val | |
| 4241 | + | if i.cfg.RefreshTools != nil { | |
| 4242 | + | i.cfg.RefreshTools() | |
| 4243 | + | } | |
| 4244 | + | i.mu.Lock() | |
| 4245 | + | i.statusOK = "lsp in main session " + onOff(value) | |
| 4246 | + | i.statusErr = "" | |
| 4247 | + | i.mu.Unlock() | |
| 4248 | + | case "subagent_lsp_enabled": | |
| 4249 | + | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | |
| 4250 | + | if err := store.SetSubagentLSPEnabled(value); err != nil { | |
| 4251 | + | i.mu.Lock() | |
| 4252 | + | i.statusErr = "settings: " + err.Error() | |
| 4253 | + | i.mu.Unlock() | |
| 4254 | + | return | |
| 4255 | + | } | |
| 4256 | + | } | |
| 4257 | + | val := value | |
| 4258 | + | i.cfg.SubagentLSPEnabled = &val | |
| 4259 | + | i.mu.Lock() | |
| 4260 | + | i.statusOK = "lsp in sub-agents " + onOff(value) | |
| 4261 | + | i.statusErr = "" | |
| 4262 | + | i.mu.Unlock() | |
| 4199 | 4263 | case "jail_by_default": | |
| 4200 | 4264 | val := value | |
| 4201 | 4265 | i.cfg.JailByDefault = &val | |
| @@ -5130,7 +5194,10 @@ func (i *Interactive) doLogout(target string) { | |||
| 5130 | 5194 | if clearedCurrent { | |
| 5131 | 5195 | // The running agent was using a credential we just wiped. Drop | |
| 5132 | 5196 | // it so prompts can't go out with the stale client, and hint at | |
| 5133 | - | // /login. | |
| 5197 | + | // /login. Close any LSP processes owned by its tools first. | |
| 5198 | + | if i.agent != nil { | |
| 5199 | + | _ = tools.CloseLSPManagers(i.agent.Tools) | |
| 5200 | + | } | |
| 5134 | 5201 | i.agent = nil | |
| 5135 | 5202 | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | |
| 5136 | 5203 | } else { | |
| @@ -5722,6 +5789,9 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||
| 5722 | 5789 | } | |
| 5723 | 5790 | ag.SeedCost(carryCost) | |
| 5724 | 5791 | ||
| 5792 | + | if i.agent != nil && i.agent != ag { | |
| 5793 | + | _ = tools.CloseLSPManagers(i.agent.Tools) | |
| 5794 | + | } | |
| 5725 | 5795 | i.mu.Lock() | |
| 5726 | 5796 | i.agent = ag | |
| 5727 | 5797 | i.cfg.Provider = p | |
| @@ -5764,12 +5834,16 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 5764 | 5834 | } | |
| 5765 | 5835 | commitLogin := func() { | |
| 5766 | 5836 | i.mu.Lock() | |
| 5837 | + | oldAgent := i.agent | |
| 5767 | 5838 | i.agent = ag | |
| 5768 | 5839 | i.cfg.Provider = prov | |
| 5769 | 5840 | i.cfg.Model = model | |
| 5770 | 5841 | i.statusErr = "" | |
| 5771 | 5842 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 5772 | 5843 | i.mu.Unlock() | |
| 5844 | + | if oldAgent != nil { | |
| 5845 | + | _ = tools.CloseLSPManagers(oldAgent.Tools) | |
| 5846 | + | } | |
| 5773 | 5847 | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | |
| 5774 | 5848 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | |
| 5775 | 5849 | // Authentication can change the provider used by the live | |
A packages/agent/modes/lsp_setting_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | type failingLSPSettingsStore struct { | |
| 9 | + | SettingsStore | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | func (failingLSPSettingsStore) SetLSPEnabled(bool) error { | |
| 13 | + | return errors.New("persist lsp setting") | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (failingLSPSettingsStore) SetSubagentLSPEnabled(bool) error { | |
| 17 | + | return errors.New("persist subagent lsp setting") | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestLSPSettingToggleKeepsMemoryOnPersistenceFailure(t *testing.T) { | |
| 21 | + | mainEnabled := false | |
| 22 | + | subagentEnabled := false | |
| 23 | + | interactive := &Interactive{cfg: InteractiveConfig{ | |
| 24 | + | LSPEnabled: &mainEnabled, | |
| 25 | + | SubagentLSPEnabled: &subagentEnabled, | |
| 26 | + | SettingsStore: failingLSPSettingsStore{}, | |
| 27 | + | }} | |
| 28 | + | ||
| 29 | + | interactive.applySettingToggle("lsp_enabled", true) | |
| 30 | + | if *interactive.cfg.LSPEnabled { | |
| 31 | + | t.Fatal("main-session LSP changed after persistence failure") | |
| 32 | + | } | |
| 33 | + | interactive.applySettingToggle("subagent_lsp_enabled", true) | |
| 34 | + | if *interactive.cfg.SubagentLSPEnabled { | |
| 35 | + | t.Fatal("sub-agent LSP changed after persistence failure") | |
| 36 | + | } | |
| 37 | + | } | |
M packages/agent/rpc.go +6 -0
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 16 | 16 | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 17 | 17 | "github.com/patriceckhart/zot/packages/agent/modes" | |
| 18 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 18 | 19 | "github.com/patriceckhart/zot/packages/core" | |
| 19 | 20 | "github.com/patriceckhart/zot/packages/provider" | |
| 20 | 21 | ) | |
| @@ -79,6 +80,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 79 | 80 | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |
| 80 | 81 | ||
| 81 | 82 | ag := r.NewAgent() | |
| 83 | + | defer closeAgentLSP(ag) | |
| 82 | 84 | server.agent = ag | |
| 83 | 85 | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 84 | 86 | r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |
| @@ -111,7 +113,11 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 111 | 113 | return | |
| 112 | 114 | } | |
| 113 | 115 | resolved.MergeExtensionTools(adapter) | |
| 116 | + | oldTools := ag.Tools | |
| 114 | 117 | ag.SetTools(resolved.ToolRegistry) | |
| 118 | + | if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil { | |
| 119 | + | fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr) | |
| 120 | + | } | |
| 115 | 121 | }) | |
| 116 | 122 | ||
| 117 | 123 | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | |
M packages/agent/sdk/sdk.go +23 -3
| @@ -30,6 +30,7 @@ import ( | |||
| 30 | 30 | "sync" | |
| 31 | 31 | ||
| 32 | 32 | "github.com/patriceckhart/zot/packages/agent" | |
| 33 | + | agenttools "github.com/patriceckhart/zot/packages/agent/tools" | |
| 33 | 34 | "github.com/patriceckhart/zot/packages/core" | |
| 34 | 35 | "github.com/patriceckhart/zot/packages/provider" | |
| 35 | 36 | ) | |
| @@ -74,8 +75,8 @@ type Config struct { | |||
| 74 | 75 | // BaseURL overrides the provider base url (for tests / proxies). | |
| 75 | 76 | BaseURL string | |
| 76 | 77 | ||
| 77 | - | // Tools is the list of tools to enable. Nil/empty = all four | |
| 78 | - | // (read, write, edit, bash). Pass an empty-but-non-nil slice | |
| 78 | + | // Tools is the list of tools to enable. Nil/empty = all built-in | |
| 79 | + | // tools (read, write, edit, bash, lsp). Pass an empty-but-non-nil slice | |
| 79 | 80 | // (e.g. []string{}) plus NoTools=true to disable everything. | |
| 80 | 81 | Tools []string | |
| 81 | 82 | ||
| @@ -95,8 +96,9 @@ type Runtime struct { | |||
| 95 | 96 | model string | |
| 96 | 97 | cwd string | |
| 97 | 98 | ||
| 98 | - | // activeCancel is set while a Prompt is streaming. | |
| 99 | + | // activeCancel is set while a Prompt or Compact is running. | |
| 99 | 100 | activeCancel context.CancelFunc | |
| 101 | + | activeDone chan struct{} | |
| 100 | 102 | ||
| 101 | 103 | // closed signals that Close has been called. | |
| 102 | 104 | closed bool | |
| @@ -217,7 +219,9 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch | |||
| 217 | 219 | return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)") | |
| 218 | 220 | } | |
| 219 | 221 | subCtx, cancel := context.WithCancel(ctx) | |
| 222 | + | done := make(chan struct{}) | |
| 220 | 223 | r.activeCancel = cancel | |
| 224 | + | r.activeDone = done | |
| 221 | 225 | r.mu.Unlock() | |
| 222 | 226 | ||
| 223 | 227 | imgBlocks := make([]provider.ImageBlock, 0, len(images)) | |
| @@ -231,6 +235,8 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch | |||
| 231 | 235 | defer func() { | |
| 232 | 236 | r.mu.Lock() | |
| 233 | 237 | r.activeCancel = nil | |
| 238 | + | r.activeDone = nil | |
| 239 | + | close(done) | |
| 234 | 240 | r.mu.Unlock() | |
| 235 | 241 | }() | |
| 236 | 242 | err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) { | |
| @@ -266,11 +272,15 @@ func (r *Runtime) Compact(ctx context.Context, customInstructions string) (Compa | |||
| 266 | 272 | return CompactResult{}, ErrBusy | |
| 267 | 273 | } | |
| 268 | 274 | subCtx, cancel := context.WithCancel(ctx) | |
| 275 | + | done := make(chan struct{}) | |
| 269 | 276 | r.activeCancel = cancel | |
| 277 | + | r.activeDone = done | |
| 270 | 278 | r.mu.Unlock() | |
| 271 | 279 | defer func() { | |
| 272 | 280 | r.mu.Lock() | |
| 273 | 281 | r.activeCancel = nil | |
| 282 | + | r.activeDone = nil | |
| 283 | + | close(done) | |
| 274 | 284 | r.mu.Unlock() | |
| 275 | 285 | }() | |
| 276 | 286 | ||
| @@ -339,10 +349,20 @@ func (r *Runtime) Close() error { | |||
| 339 | 349 | } | |
| 340 | 350 | r.closed = true | |
| 341 | 351 | cancel := r.activeCancel | |
| 352 | + | done := r.activeDone | |
| 342 | 353 | r.mu.Unlock() | |
| 343 | 354 | if cancel != nil { | |
| 344 | 355 | cancel() | |
| 345 | 356 | } | |
| 357 | + | if done != nil { | |
| 358 | + | <-done | |
| 359 | + | } | |
| 360 | + | r.mu.Lock() | |
| 361 | + | ag := r.agent | |
| 362 | + | r.mu.Unlock() | |
| 363 | + | if ag != nil { | |
| 364 | + | return agenttools.CloseLSPManagers(ag.Tools) | |
| 365 | + | } | |
| 346 | 366 | return nil | |
| 347 | 367 | } | |
| 348 | 368 | ||
M packages/agent/settings_store.go +18 -0
| @@ -77,6 +77,24 @@ func (configSettingsStore) SetFastMode(enabled bool) error { | |||
| 77 | 77 | return SaveConfig(cfg) | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | + | func (configSettingsStore) SetLSPEnabled(enabled bool) error { | |
| 81 | + | cfg, err := LoadConfig() | |
| 82 | + | if err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | cfg.LSPEnabled = &enabled | |
| 86 | + | return SaveConfig(cfg) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (configSettingsStore) SetSubagentLSPEnabled(enabled bool) error { | |
| 90 | + | cfg, err := LoadConfig() | |
| 91 | + | if err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | cfg.SubagentLSPEnabled = &enabled | |
| 95 | + | return SaveConfig(cfg) | |
| 96 | + | } | |
| 97 | + | ||
| 80 | 98 | func (configSettingsStore) SetAutoCompactThreshold(percent int) error { | |
| 81 | 99 | cfg, err := LoadConfig() | |
| 82 | 100 | if err != nil { | |
M packages/agent/settings_store_test.go +55 -0
| @@ -106,3 +106,58 @@ func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) { | |||
| 106 | 106 | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |
| 107 | 107 | } | |
| 108 | 108 | } | |
| 109 | + | ||
| 110 | + | func TestConfigSettingsStorePersistsLSPDefaults(t *testing.T) { | |
| 111 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 112 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | if err := (configSettingsStore{}).SetLSPEnabled(false); err != nil { | |
| 117 | + | t.Fatal(err) | |
| 118 | + | } | |
| 119 | + | if err := (configSettingsStore{}).SetSubagentLSPEnabled(false); err != nil { | |
| 120 | + | t.Fatal(err) | |
| 121 | + | } | |
| 122 | + | cfg, err := LoadConfig() | |
| 123 | + | if err != nil { | |
| 124 | + | t.Fatal(err) | |
| 125 | + | } | |
| 126 | + | if cfg.LSPEnabled == nil || *cfg.LSPEnabled { | |
| 127 | + | t.Fatal("lsp_enabled was not persisted as disabled") | |
| 128 | + | } | |
| 129 | + | if cfg.SubagentLSPEnabled == nil || *cfg.SubagentLSPEnabled { | |
| 130 | + | t.Fatal("subagent_lsp_enabled was not persisted as disabled") | |
| 131 | + | } | |
| 132 | + | if cfg.Theme != "dark" { | |
| 133 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) { | |
| 138 | + | cfg := Config{} | |
| 139 | + | if !cfg.LSPEnabledFor(false) { | |
| 140 | + | t.Fatal("main-session LSP should default to enabled") | |
| 141 | + | } | |
| 142 | + | if !cfg.LSPEnabledFor(true) { | |
| 143 | + | t.Fatal("sub-agent LSP should default to enabled") | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | noMain, noSub := false, false | |
| 147 | + | cfg.LSPEnabled = &noMain | |
| 148 | + | cfg.SubagentLSPEnabled = &noSub | |
| 149 | + | if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) { | |
| 150 | + | t.Fatal("explicitly disabled LSP settings were ignored") | |
| 151 | + | } | |
| 152 | + | yesSub := true | |
| 153 | + | cfg.SubagentLSPEnabled = &yesSub | |
| 154 | + | write, edit := true, true | |
| 155 | + | cfg.LSPDiagnosticsOnWrite = &write | |
| 156 | + | cfg.LSPDiagnosticsOnEdit = &edit | |
| 157 | + | if cfg.LSPDiagnosticsOnWriteEnabled(false) || cfg.LSPDiagnosticsOnEditEnabled(false) { | |
| 158 | + | t.Fatal("diagnostics remained enabled with main-session LSP disabled") | |
| 159 | + | } | |
| 160 | + | if !cfg.LSPDiagnosticsOnWriteEnabled(true) || !cfg.LSPDiagnosticsOnEditEnabled(true) { | |
| 161 | + | t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings") | |
| 162 | + | } | |
| 163 | + | } | |
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +1 -1
| @@ -29,7 +29,7 @@ Three things an extension can do (any combination): | |||
| 29 | 29 | 2. **Tools** — register tools the LLM itself calls. Schema is | |
| 30 | 30 | JSON Schema; zot routes the model's `tool_call` to the | |
| 31 | 31 | extension's `tool_result`. Same lifecycle as built-in tools | |
| 32 | - | (read/write/edit/bash/skill). | |
| 32 | + | (read/write/edit/bash/lsp/skill). | |
| 33 | 33 | ||
| 34 | 34 | 3. **Lifecycle hooks** — subscribe to events | |
| 35 | 35 | (session_start, turn_start, tool_call, turn_end, | |
M packages/agent/swarm_agent.go +39 -9
| @@ -59,6 +59,13 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 59 | 59 | defer stopExt() | |
| 60 | 60 | ||
| 61 | 61 | ag := r.NewAgent() | |
| 62 | + | initialAg := ag | |
| 63 | + | defer func() { | |
| 64 | + | closeAgentLSP(ag) | |
| 65 | + | if ag != initialAg { | |
| 66 | + | closeAgentLSP(initialAg) | |
| 67 | + | } | |
| 68 | + | }() | |
| 62 | 69 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 63 | 70 | sess, err := openOrCreateSession(args, r, ag, version) | |
| 64 | 71 | if err != nil { | |
| @@ -114,8 +121,9 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 114 | 121 | turnCtx context.Context = ctx | |
| 115 | 122 | cancelFn context.CancelFunc | |
| 116 | 123 | busyTurn bool | |
| 124 | + | stopping bool | |
| 117 | 125 | turnNo int | |
| 118 | - | shutdown = make(chan struct{}) | |
| 126 | + | turns sync.WaitGroup | |
| 119 | 127 | ) | |
| 120 | 128 | ||
| 121 | 129 | runOne := func(prompt string) { | |
| @@ -157,11 +165,35 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 157 | 165 | mu.Unlock() | |
| 158 | 166 | } | |
| 159 | 167 | ||
| 168 | + | startTurn := func(prompt string) { | |
| 169 | + | mu.Lock() | |
| 170 | + | if stopping { | |
| 171 | + | mu.Unlock() | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | turns.Add(1) | |
| 175 | + | mu.Unlock() | |
| 176 | + | go func() { | |
| 177 | + | defer turns.Done() | |
| 178 | + | runOne(prompt) | |
| 179 | + | }() | |
| 180 | + | } | |
| 181 | + | stopAndWait := func(reason string) { | |
| 182 | + | mu.Lock() | |
| 183 | + | stopping = true | |
| 184 | + | if cancelFn != nil { | |
| 185 | + | cancelFn() | |
| 186 | + | } | |
| 187 | + | mu.Unlock() | |
| 188 | + | turns.Wait() | |
| 189 | + | em.emit("agent_stopped", map[string]any{"reason": reason}) | |
| 190 | + | } | |
| 191 | + | ||
| 160 | 192 | // Initial task: run before processing the inbox so the agent | |
| 161 | 193 | // "starts working" the moment it boots, matching what users | |
| 162 | 194 | // expect from `/swarm new <task>`. | |
| 163 | 195 | if args.Prompt != "" { | |
| 164 | - | go runOne(args.Prompt) | |
| 196 | + | startTurn(args.Prompt) | |
| 165 | 197 | } | |
| 166 | 198 | ||
| 167 | 199 | // Inbox loop: one supervisor message at a time. We don't spawn | |
| @@ -171,19 +203,17 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 171 | 203 | for { | |
| 172 | 204 | select { | |
| 173 | 205 | case <-ctx.Done(): | |
| 174 | - | em.emit("agent_stopped", map[string]any{"reason": "cancelled"}) | |
| 206 | + | stopAndWait("cancelled") | |
| 175 | 207 | return ctx.Err() | |
| 176 | - | case <-shutdown: | |
| 177 | - | em.emit("agent_stopped", map[string]any{"reason": "shutdown"}) | |
| 178 | - | return nil | |
| 179 | 208 | case msg, ok := <-ln.Lines(): | |
| 180 | 209 | if !ok { | |
| 181 | - | em.emit("agent_stopped", map[string]any{"reason": "inbox-closed"}) | |
| 210 | + | stopAndWait("inbox-closed") | |
| 182 | 211 | return nil | |
| 183 | 212 | } | |
| 184 | 213 | switch { | |
| 185 | 214 | case msg == "shutdown": | |
| 186 | - | close(shutdown) | |
| 215 | + | stopAndWait("shutdown") | |
| 216 | + | return nil | |
| 187 | 217 | case msg == "cancel": | |
| 188 | 218 | mu.Lock() | |
| 189 | 219 | if cancelFn != nil { | |
| @@ -192,7 +222,7 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 192 | 222 | mu.Unlock() | |
| 193 | 223 | case strings.HasPrefix(msg, "user "): | |
| 194 | 224 | prompt := strings.TrimPrefix(msg, "user ") | |
| 195 | - | go runOne(prompt) | |
| 225 | + | startTurn(prompt) | |
| 196 | 226 | default: | |
| 197 | 227 | em.emit("error", map[string]any{ | |
| 198 | 228 | "message": "unknown supervisor message: " + truncateForLog(msg, 200), | |
M packages/agent/tools/edit.go +8 -2
| @@ -8,14 +8,17 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | + | "github.com/patriceckhart/zot/packages/agent/lsp" | |
| 11 | 12 | "github.com/patriceckhart/zot/packages/core" | |
| 12 | 13 | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // EditTool applies one or more exact-match replacements to a file. | |
| 16 | 17 | type EditTool struct { | |
| 17 | - | CWD string | |
| 18 | - | Sandbox *Sandbox | |
| 18 | + | CWD string | |
| 19 | + | Sandbox *Sandbox | |
| 20 | + | LSP *lsp.Manager | |
| 21 | + | LSPDiagnostics bool | |
| 19 | 22 | } | |
| 20 | 23 | ||
| 21 | 24 | type editOp struct { | |
| @@ -61,6 +64,9 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 61 | 64 | if err := os.WriteFile(plan.path, plan.final, 0o644); err != nil { | |
| 62 | 65 | return core.ToolResult{}, err | |
| 63 | 66 | } | |
| 67 | + | if t.LSPDiagnostics { | |
| 68 | + | attachWriteDiagnostics(ctx, t.CWD, plan.path, t.LSP, &plan.result) | |
| 69 | + | } | |
| 64 | 70 | return plan.result, nil | |
| 65 | 71 | } | |
| 66 | 72 | ||
A packages/agent/tools/lsp.go +351 -0
| @@ -0,0 +1,351 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | "unicode/utf8" | |
| 12 | + | ||
| 13 | + | "github.com/patriceckhart/zot/packages/agent/lsp" | |
| 14 | + | "github.com/patriceckhart/zot/packages/core" | |
| 15 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const maxLSPToolOutput = 60 * 1024 | |
| 19 | + | ||
| 20 | + | // LSPTool exposes diagnostics, language navigation, and server management to | |
| 21 | + | // the model. Manager owns processes and is normally shared by all tool calls | |
| 22 | + | // in one agent session. | |
| 23 | + | type LSPTool struct { | |
| 24 | + | CWD string | |
| 25 | + | Manager *lsp.Manager | |
| 26 | + | Sandbox *Sandbox | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewLSPTool constructs the model-facing LSP tool. | |
| 30 | + | func NewLSPTool(cwd string, manager *lsp.Manager) *LSPTool { | |
| 31 | + | if manager == nil { | |
| 32 | + | manager = lsp.NewManager() | |
| 33 | + | } | |
| 34 | + | return &LSPTool{CWD: cwd, Manager: manager} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type lspArgs struct { | |
| 38 | + | Action string `json:"action"` | |
| 39 | + | Path string `json:"path,omitempty"` | |
| 40 | + | Line int `json:"line,omitempty"` | |
| 41 | + | Column int `json:"column,omitempty"` | |
| 42 | + | Query string `json:"query,omitempty"` | |
| 43 | + | NewName string `json:"new_name,omitempty"` | |
| 44 | + | Server string `json:"server,omitempty"` | |
| 45 | + | Method string `json:"method,omitempty"` | |
| 46 | + | Params json.RawMessage `json:"params,omitempty"` | |
| 47 | + | Apply *bool `json:"apply,omitempty"` | |
| 48 | + | Timeout int `json:"timeout_ms,omitempty"` | |
| 49 | + | Max int `json:"max,omitempty"` | |
| 50 | + | RunCLI *bool `json:"run_cli,omitempty"` | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | const lspSchema = `{"type":"object","properties":{"action":{"type":"string","enum":["diagnostics","definition","references","hover","symbols","rename","code_actions","type_definition","implementation","status","reload","capabilities","request"]},"path":{"type":"string","description":"File path relative to the workspace, required for document actions."},"line":{"type":"integer","minimum":1,"description":"1-based line for a document action."},"column":{"type":"integer","minimum":1,"description":"1-based column for a document action."},"query":{"type":"string","description":"Workspace symbol query; symbols uses document symbols when omitted."},"new_name":{"type":"string","description":"New identifier for rename."},"server":{"type":"string","description":"Optional server id for request or capabilities."},"method":{"type":"string","description":"Raw LSP method for request."},"params":{"type":"object","description":"Raw JSON-RPC params for request."},"apply":{"type":"boolean","description":"Apply a returned rename/code-action WorkspaceEdit. Only files inside the workspace are accepted."},"timeout_ms":{"type":"integer","minimum":1},"max":{"type":"integer","minimum":1,"maximum":50},"run_cli":{"type":"boolean","description":"Run configured CLI linters for diagnostics (default true)."}},"required":["action"]}` | |
| 54 | + | ||
| 55 | + | func (t *LSPTool) Name() string { return "lsp" } | |
| 56 | + | func (t *LSPTool) Description() string { | |
| 57 | + | return "Query configured language servers and linters for diagnostics, navigation, refactors, symbols, capabilities, and raw LSP requests." | |
| 58 | + | } | |
| 59 | + | func (t *LSPTool) Schema() json.RawMessage { return json.RawMessage(lspSchema) } | |
| 60 | + | ||
| 61 | + | func (t *LSPTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 62 | + | if ctx == nil { | |
| 63 | + | ctx = context.Background() | |
| 64 | + | } | |
| 65 | + | var args lspArgs | |
| 66 | + | if err := json.Unmarshal(raw, &args); err != nil { | |
| 67 | + | return core.ToolResult{}, fmt.Errorf("invalid lsp args: %w", err) | |
| 68 | + | } | |
| 69 | + | args.Action = strings.ToLower(strings.TrimSpace(args.Action)) | |
| 70 | + | if args.Action == "" { | |
| 71 | + | return core.ToolResult{}, fmt.Errorf("action is required") | |
| 72 | + | } | |
| 73 | + | if t.Manager == nil { | |
| 74 | + | return core.ToolResult{}, fmt.Errorf("lsp manager is unavailable") | |
| 75 | + | } | |
| 76 | + | cwd := t.CWD | |
| 77 | + | if cwd == "" { | |
| 78 | + | cwd, _ = os.Getwd() | |
| 79 | + | } | |
| 80 | + | absCWD, err := filepath.Abs(cwd) | |
| 81 | + | if err != nil { | |
| 82 | + | return core.ToolResult{}, err | |
| 83 | + | } | |
| 84 | + | path := "" | |
| 85 | + | if args.Path != "" && args.Path != "*" { | |
| 86 | + | path = resolvePath(absCWD, args.Path) | |
| 87 | + | if t.Sandbox != nil { | |
| 88 | + | if err := t.Sandbox.CheckPath(path); err != nil { | |
| 89 | + | return core.ToolResult{}, err | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | runCtx := ctx | |
| 94 | + | if args.Timeout > 0 { | |
| 95 | + | var cancel context.CancelFunc | |
| 96 | + | runCtx, cancel = context.WithTimeout(ctx, time.Duration(args.Timeout)*time.Millisecond) | |
| 97 | + | defer cancel() | |
| 98 | + | } | |
| 99 | + | var text string | |
| 100 | + | var details any | |
| 101 | + | var actionErr error | |
| 102 | + | switch args.Action { | |
| 103 | + | case "diagnostics": | |
| 104 | + | runCLI := true | |
| 105 | + | if args.RunCLI != nil { | |
| 106 | + | runCLI = *args.RunCLI | |
| 107 | + | } | |
| 108 | + | var diagnostics []lsp.Diagnostic | |
| 109 | + | if path != "" && hasGlobMeta(args.Path) { | |
| 110 | + | matches, globErr := filepath.Glob(path) | |
| 111 | + | if globErr != nil { | |
| 112 | + | actionErr = globErr | |
| 113 | + | break | |
| 114 | + | } | |
| 115 | + | for _, match := range matches { | |
| 116 | + | if t.Sandbox != nil { | |
| 117 | + | if checkErr := t.Sandbox.CheckPath(match); checkErr != nil { | |
| 118 | + | if actionErr == nil { | |
| 119 | + | actionErr = checkErr | |
| 120 | + | } | |
| 121 | + | continue | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | values, diagErr := t.Manager.DiagnosticsWithOptions(runCtx, absCWD, match, runCLI) | |
| 125 | + | if actionErr == nil { | |
| 126 | + | actionErr = diagErr | |
| 127 | + | } | |
| 128 | + | diagnostics = append(diagnostics, values...) | |
| 129 | + | } | |
| 130 | + | } else { | |
| 131 | + | diagnostics, actionErr = t.Manager.DiagnosticsWithOptions(runCtx, absCWD, path, runCLI) | |
| 132 | + | } | |
| 133 | + | max := boundedMax(args.Max) | |
| 134 | + | text = lsp.SummarizeDiagnostics(diagnostics, absCWD, max) | |
| 135 | + | visible := diagnostics | |
| 136 | + | if len(visible) > max { | |
| 137 | + | visible = visible[:max] | |
| 138 | + | } | |
| 139 | + | details = map[string]any{"diagnostics": visible, "count": len(diagnostics), "truncated": len(visible) < len(diagnostics), "run_cli": runCLI} | |
| 140 | + | case "status": | |
| 141 | + | var statuses []lsp.ServerStatus | |
| 142 | + | statuses, actionErr = t.Manager.Status(absCWD, path) | |
| 143 | + | text = prettyJSON(statuses) | |
| 144 | + | details = statuses | |
| 145 | + | case "reload": | |
| 146 | + | actionErr = t.Manager.Reload(absCWD) | |
| 147 | + | text = "Reloaded LSP and linter processes and configuration." | |
| 148 | + | case "capabilities": | |
| 149 | + | var capabilities map[string]json.RawMessage | |
| 150 | + | capabilities, actionErr = t.Manager.Capabilities(absCWD, path, args.Server) | |
| 151 | + | text = prettyJSON(capabilities) | |
| 152 | + | details = capabilities | |
| 153 | + | case "request": | |
| 154 | + | text, details, actionErr = t.executeRawRequest(runCtx, absCWD, path, args) | |
| 155 | + | case "definition", "references", "hover", "symbols", "rename", "code_actions", "type_definition", "implementation": | |
| 156 | + | text, details, actionErr = t.executeLanguageAction(runCtx, absCWD, path, args) | |
| 157 | + | default: | |
| 158 | + | return core.ToolResult{}, fmt.Errorf("unknown lsp action %q", args.Action) | |
| 159 | + | } | |
| 160 | + | if actionErr != nil && text == "" { | |
| 161 | + | return core.ToolResult{}, actionErr | |
| 162 | + | } | |
| 163 | + | text = boundLSPText(text) | |
| 164 | + | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: actionErr != nil, Details: details}, nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) { | |
| 168 | + | if path == "" && (args.Action != "symbols" || args.Query == "") { | |
| 169 | + | return "", nil, fmt.Errorf("path is required for %s", args.Action) | |
| 170 | + | } | |
| 171 | + | if args.Action != "symbols" && (args.Line < 1 || args.Column < 1) { | |
| 172 | + | return "", nil, fmt.Errorf("line and column (1-based) are required for %s", args.Action) | |
| 173 | + | } | |
| 174 | + | position := lsp.Position{Line: args.Line - 1, Character: args.Column - 1} | |
| 175 | + | method := map[string]string{"definition": "textDocument/definition", "references": "textDocument/references", "hover": "textDocument/hover", "symbols": "textDocument/documentSymbol", "rename": "textDocument/rename", "code_actions": "textDocument/codeAction", "type_definition": "textDocument/typeDefinition", "implementation": "textDocument/implementation"}[args.Action] | |
| 176 | + | var params map[string]any | |
| 177 | + | if args.Action == "symbols" { | |
| 178 | + | if args.Query != "" { | |
| 179 | + | method, params = "workspace/symbol", map[string]any{"query": args.Query} | |
| 180 | + | } else { | |
| 181 | + | params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}} | |
| 182 | + | } | |
| 183 | + | } else { | |
| 184 | + | params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "position": position} | |
| 185 | + | if args.Action == "references" { | |
| 186 | + | params["context"] = map[string]any{"includeDeclaration": true} | |
| 187 | + | } | |
| 188 | + | if args.Action == "code_actions" { | |
| 189 | + | params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "range": lsp.Range{Start: position, End: position}, "context": map[string]any{"diagnostics": []any{}}} | |
| 190 | + | } | |
| 191 | + | if args.Action == "rename" { | |
| 192 | + | if strings.TrimSpace(args.NewName) == "" { | |
| 193 | + | return "", nil, fmt.Errorf("new_name is required for rename") | |
| 194 | + | } | |
| 195 | + | params["newName"] = args.NewName | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | responses := t.Manager.RequestAll(ctx, cwd, path, method, params) | |
| 199 | + | apply := args.Apply != nil && *args.Apply | |
| 200 | + | if args.Action == "rename" && args.Apply == nil { | |
| 201 | + | apply = true | |
| 202 | + | } | |
| 203 | + | text, applyCount, applyErr := formatResponses(cwd, responses, args.Action, apply, t.Manager) | |
| 204 | + | if applyCount > 0 { | |
| 205 | + | text += fmt.Sprintf("\nApplied %d workspace edit(s).", applyCount) | |
| 206 | + | } | |
| 207 | + | return text, map[string]any{"action": args.Action, "responses": responses, "applied": applyCount}, applyErr | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) { | |
| 211 | + | if strings.TrimSpace(args.Method) == "" { | |
| 212 | + | return "", nil, fmt.Errorf("method is required for request") | |
| 213 | + | } | |
| 214 | + | var params any = map[string]any{} | |
| 215 | + | if len(args.Params) > 0 && string(args.Params) != "null" { | |
| 216 | + | if err := json.Unmarshal(args.Params, ¶ms); err != nil { | |
| 217 | + | return "", nil, fmt.Errorf("params must be JSON: %w", err) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | var responses []lsp.Response | |
| 221 | + | if path != "" { | |
| 222 | + | _ = t.Manager.Open(ctx, cwd, path) | |
| 223 | + | } | |
| 224 | + | if args.Server != "" { | |
| 225 | + | result, err := t.Manager.Request(ctx, cwd, args.Server, args.Method, params) | |
| 226 | + | responses = []lsp.Response{{Server: args.Server, Result: result, Error: err}} | |
| 227 | + | } else { | |
| 228 | + | responses = t.Manager.RequestAll(ctx, cwd, path, args.Method, params) | |
| 229 | + | } | |
| 230 | + | return formatRawResponses(responses), map[string]any{"method": args.Method, "responses": responses}, firstResponseError(responses) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func formatResponses(cwd string, responses []lsp.Response, action string, apply bool, manager *lsp.Manager) (string, int, error) { | |
| 234 | + | var b strings.Builder | |
| 235 | + | applied := 0 | |
| 236 | + | var firstErr error | |
| 237 | + | pendingEdits := make([]lsp.WorkspaceEdit, 0) | |
| 238 | + | for _, response := range responses { | |
| 239 | + | if response.Error != nil { | |
| 240 | + | fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error) | |
| 241 | + | if firstErr == nil { | |
| 242 | + | firstErr = response.Error | |
| 243 | + | } | |
| 244 | + | continue | |
| 245 | + | } | |
| 246 | + | fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result)) | |
| 247 | + | if apply && (action == "rename" || action == "code_actions") { | |
| 248 | + | pendingEdits = append(pendingEdits, workspaceEdits(response.Result, action)...) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | if len(pendingEdits) > 0 { | |
| 252 | + | if err := manager.ApplyEdits(cwd, pendingEdits); err != nil { | |
| 253 | + | if firstErr == nil { | |
| 254 | + | firstErr = err | |
| 255 | + | } | |
| 256 | + | fmt.Fprintf(&b, "apply: error: %s\n", err) | |
| 257 | + | } else { | |
| 258 | + | applied = len(pendingEdits) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | if b.Len() == 0 { | |
| 262 | + | return "No results.", applied, firstErr | |
| 263 | + | } | |
| 264 | + | return b.String(), applied, firstErr | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func workspaceEdits(raw json.RawMessage, action string) []lsp.WorkspaceEdit { | |
| 268 | + | if action == "rename" { | |
| 269 | + | var edit lsp.WorkspaceEdit | |
| 270 | + | if json.Unmarshal(raw, &edit) == nil && (len(edit.Changes) > 0 || len(edit.DocumentChanges) > 0) { | |
| 271 | + | return []lsp.WorkspaceEdit{edit} | |
| 272 | + | } | |
| 273 | + | return nil | |
| 274 | + | } | |
| 275 | + | var actions []struct { | |
| 276 | + | Edit lsp.WorkspaceEdit `json:"edit"` | |
| 277 | + | } | |
| 278 | + | if json.Unmarshal(raw, &actions) != nil { | |
| 279 | + | return nil | |
| 280 | + | } | |
| 281 | + | out := make([]lsp.WorkspaceEdit, 0, len(actions)) | |
| 282 | + | for _, action := range actions { | |
| 283 | + | if len(action.Edit.Changes) > 0 || len(action.Edit.DocumentChanges) > 0 { | |
| 284 | + | out = append(out, action.Edit) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | return out | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func formatRawResponses(responses []lsp.Response) string { | |
| 291 | + | var b strings.Builder | |
| 292 | + | for _, response := range responses { | |
| 293 | + | if response.Error != nil { | |
| 294 | + | fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error) | |
| 295 | + | continue | |
| 296 | + | } | |
| 297 | + | fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result)) | |
| 298 | + | } | |
| 299 | + | if b.Len() == 0 { | |
| 300 | + | return "No results." | |
| 301 | + | } | |
| 302 | + | return b.String() | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func firstResponseError(responses []lsp.Response) error { | |
| 306 | + | for _, response := range responses { | |
| 307 | + | if response.Error != nil { | |
| 308 | + | return response.Error | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | return nil | |
| 312 | + | } | |
| 313 | + | func fileURI(path string) string { return lsp.URIForPath(path) } | |
| 314 | + | func prettyJSON(raw any) string { | |
| 315 | + | var data []byte | |
| 316 | + | switch value := raw.(type) { | |
| 317 | + | case json.RawMessage: | |
| 318 | + | data = value | |
| 319 | + | default: | |
| 320 | + | data, _ = json.Marshal(value) | |
| 321 | + | } | |
| 322 | + | var value any | |
| 323 | + | if json.Unmarshal(data, &value) == nil { | |
| 324 | + | if formatted, err := json.MarshalIndent(value, "", " "); err == nil { | |
| 325 | + | return string(formatted) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | return strings.TrimSpace(string(data)) | |
| 329 | + | } | |
| 330 | + | func hasGlobMeta(value string) bool { | |
| 331 | + | return strings.ContainsAny(value, "*?[") | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func boundedMax(value int) int { | |
| 335 | + | if value <= 0 || value > lsp.DefaultDiagnosticCap { | |
| 336 | + | return lsp.DefaultDiagnosticCap | |
| 337 | + | } | |
| 338 | + | return value | |
| 339 | + | } | |
| 340 | + | func boundLSPText(value string) string { | |
| 341 | + | if len(value) <= maxLSPToolOutput { | |
| 342 | + | return value | |
| 343 | + | } | |
| 344 | + | suffix := fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput) | |
| 345 | + | available := maxLSPToolOutput - len(suffix) | |
| 346 | + | cut := value[:available] | |
| 347 | + | for len(cut) > 0 && !utf8.ValidString(cut) { | |
| 348 | + | cut = cut[:len(cut)-1] | |
| 349 | + | } | |
| 350 | + | return cut + suffix | |
| 351 | + | } | |
A packages/agent/tools/lsp_diagnostics.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/lsp" | |
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const writeDiagnosticsTimeout = 2 * time.Second | |
| 15 | + | ||
| 16 | + | // CloseLSPManagers closes every manager referenced by the registry. Write, | |
| 17 | + | // edit, and lsp share one manager in normal runs, so de-duplicate pointers | |
| 18 | + | // before closing them. This is used by mode owners when an agent or registry | |
| 19 | + | // is replaced; LSP processes should not outlive the session that created them. | |
| 20 | + | func CloseLSPManagers(reg core.Registry) error { | |
| 21 | + | managers := make(map[*lsp.Manager]struct{}) | |
| 22 | + | for _, tool := range reg { | |
| 23 | + | switch value := tool.(type) { | |
| 24 | + | case *LSPTool: | |
| 25 | + | if value != nil && value.Manager != nil { | |
| 26 | + | managers[value.Manager] = struct{}{} | |
| 27 | + | } | |
| 28 | + | case *WriteTool: | |
| 29 | + | if value != nil && value.LSP != nil { | |
| 30 | + | managers[value.LSP] = struct{}{} | |
| 31 | + | } | |
| 32 | + | case *EditTool: | |
| 33 | + | if value != nil && value.LSP != nil { | |
| 34 | + | managers[value.LSP] = struct{}{} | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | var firstErr error | |
| 39 | + | for manager := range managers { | |
| 40 | + | if err := manager.Close(); err != nil && firstErr == nil { | |
| 41 | + | firstErr = err | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | return firstErr | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // attachWriteDiagnostics performs a bounded post-write diagnostics pass. A | |
| 48 | + | // failed or slow language server must never turn a successful filesystem | |
| 49 | + | // mutation into a failed tool call, so callers only attach diagnostics that | |
| 50 | + | // were actually returned before the short budget expired. | |
| 51 | + | func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.Manager, result *core.ToolResult) { | |
| 52 | + | if manager == nil || result == nil { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | if ctx == nil { | |
| 56 | + | ctx = context.Background() | |
| 57 | + | } | |
| 58 | + | diagnosticsCtx, cancel := context.WithTimeout(ctx, writeDiagnosticsTimeout) | |
| 59 | + | defer cancel() | |
| 60 | + | diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path) | |
| 61 | + | if err != nil { | |
| 62 | + | if details, ok := result.Details.(map[string]any); ok { | |
| 63 | + | status := "unavailable" | |
| 64 | + | switch { | |
| 65 | + | case errors.Is(err, context.DeadlineExceeded): | |
| 66 | + | status = "timeout" | |
| 67 | + | case errors.Is(err, context.Canceled): | |
| 68 | + | status = "canceled" | |
| 69 | + | } | |
| 70 | + | details["diagnostics_status"] = status | |
| 71 | + | details["diagnostics_error"] = err.Error() | |
| 72 | + | } | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | diagnostics = manager.ReduceDiagnostics(cwd, path, diagnostics) | |
| 76 | + | if len(diagnostics) == 0 { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | summary := lsp.SummarizeDiagnostics(diagnostics, cwd, 8) | |
| 80 | + | if strings.TrimSpace(summary) == "" || summary == "No diagnostics." { | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | for index, block := range result.Content { | |
| 84 | + | text, ok := block.(provider.TextBlock) | |
| 85 | + | if !ok { | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | text.Text = strings.TrimRight(text.Text, "\n") + "\n\n" + summary + "\n" | |
| 89 | + | result.Content[index] = text | |
| 90 | + | break | |
| 91 | + | } | |
| 92 | + | if details, ok := result.Details.(map[string]any); ok { | |
| 93 | + | details["diagnostics"] = diagnostics | |
| 94 | + | details["diagnostics_summary"] = summary | |
| 95 | + | } | |
| 96 | + | } | |
A packages/agent/tools/lsp_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "unicode/utf8" | |
| 12 | + | ||
| 13 | + | "github.com/patriceckhart/zot/packages/agent/lsp" | |
| 14 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestLSPToolSchemaAndFormatting(t *testing.T) { | |
| 18 | + | tool := NewLSPTool(t.TempDir(), lsp.NewManager()) | |
| 19 | + | var schema map[string]any | |
| 20 | + | if err := json.Unmarshal(tool.Schema(), &schema); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | if tool.Name() != "lsp" || !strings.Contains(tool.Description(), "diagnostics") { | |
| 24 | + | t.Fatalf("tool metadata missing") | |
| 25 | + | } | |
| 26 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"status"}`), nil) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | text, ok := result.Content[0].(provider.TextBlock) | |
| 31 | + | if !ok || text.Text == "" { | |
| 32 | + | t.Fatalf("status result = %#v", result.Content) | |
| 33 | + | } | |
| 34 | + | if !strings.Contains(text.Text, "[") && !strings.Contains(text.Text, "{") { | |
| 35 | + | t.Fatalf("status was not formatted JSON: %q", text.Text) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestLSPToolGlobRespectsJailForSymlinkMatches(t *testing.T) { | |
| 40 | + | if runtime.GOOS == "windows" { | |
| 41 | + | t.Skip("symlink permissions vary on Windows") | |
| 42 | + | } | |
| 43 | + | root := t.TempDir() | |
| 44 | + | outside := t.TempDir() | |
| 45 | + | outsidePath := filepath.Join(outside, "outside.go") | |
| 46 | + | if err := os.WriteFile(outsidePath, []byte("package outside\\n"), 0o644); err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | if err := os.Symlink(outsidePath, filepath.Join(root, "link.go")); err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | sandbox := NewSandbox(root) | |
| 53 | + | sandbox.Lock() | |
| 54 | + | tool := NewLSPTool(root, lsp.NewManager()) | |
| 55 | + | tool.Sandbox = sandbox | |
| 56 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"diagnostics","path":"*.go","run_cli":false}`), nil) | |
| 57 | + | if err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | if !result.IsError { | |
| 61 | + | t.Fatalf("jailed symlink glob was accepted: %#v", result) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestBoundLSPText(t *testing.T) { | |
| 66 | + | value := boundLSPText(strings.Repeat("x", maxLSPToolOutput+10)) | |
| 67 | + | if len(value) > maxLSPToolOutput || !strings.Contains(value, "truncated") { | |
| 68 | + | t.Fatalf("bound output = %d bytes", len(value)) | |
| 69 | + | } | |
| 70 | + | unicodeValue := boundLSPText(strings.Repeat("界", maxLSPToolOutput/3+10)) | |
| 71 | + | if !utf8.ValidString(unicodeValue) { | |
| 72 | + | t.Fatal("bound output split a UTF-8 sequence") | |
| 73 | + | } | |
| 74 | + | } | |
M packages/agent/tools/read.go +1 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | // Package tools implements zot's built-in tools: read, write, edit, bash. | |
| 1 | + | // Package tools implements zot's built-in tools: read, write, edit, bash, and lsp. | |
| 2 | 2 | package tools | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
M packages/agent/tools/swarm_spawn.go +1 -1
| @@ -65,7 +65,7 @@ const swarmSpawnSchema = `{ | |||
| 65 | 65 | "properties": { | |
| 66 | 66 | "task": { | |
| 67 | 67 | "type": "string", | |
| 68 | - | "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same read/write/edit/bash tools as the main agent and shares this working directory, but starts with NO context from this conversation." | |
| 68 | + | "description": "The full task description for the sub-agent. Be specific: the child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it shares this working directory and starts with NO context from this conversation." | |
| 69 | 69 | }, | |
| 70 | 70 | "agent": { | |
| 71 | 71 | "type": "string", | |
M packages/agent/tools/write.go +11 -4
| @@ -8,14 +8,17 @@ import ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | + | "github.com/patriceckhart/zot/packages/agent/lsp" | |
| 11 | 12 | "github.com/patriceckhart/zot/packages/core" | |
| 12 | 13 | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // WriteTool writes content to a file, creating parent directories. | |
| 16 | 17 | type WriteTool struct { | |
| 17 | - | CWD string | |
| 18 | - | Sandbox *Sandbox | |
| 18 | + | CWD string | |
| 19 | + | Sandbox *Sandbox | |
| 20 | + | LSP *lsp.Manager | |
| 21 | + | LSPDiagnostics bool | |
| 19 | 22 | } | |
| 20 | 23 | ||
| 21 | 24 | type writeArgs struct { | |
| @@ -62,7 +65,7 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f | |||
| 62 | 65 | if len(a.Content) > 0 && !strings.HasSuffix(a.Content, "\n") { | |
| 63 | 66 | totalLines++ // count the last unterminated line | |
| 64 | 67 | } | |
| 65 | - | return core.ToolResult{ | |
| 68 | + | result := core.ToolResult{ | |
| 66 | 69 | Content: []provider.Content{provider.TextBlock{Text: a.Content}}, | |
| 67 | 70 | Details: map[string]any{ | |
| 68 | 71 | "path": path, | |
| @@ -70,5 +73,9 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f | |||
| 70 | 73 | "total_lines": totalLines, | |
| 71 | 74 | "start_line": 1, | |
| 72 | 75 | }, | |
| 73 | - | }, nil | |
| 76 | + | } | |
| 77 | + | if t.LSPDiagnostics { | |
| 78 | + | attachWriteDiagnostics(ctx, t.CWD, path, t.LSP, &result) | |
| 79 | + | } | |
| 80 | + | return result, nil | |
| 74 | 81 | } | |