fix(agent): harden LSP lifecycle and workspace edits
- commit
- 7f4c198791d3ebbf53d09c0d0bdc009aa34766f6
- author
- brice · · 2026-08-04 11:54 UTC
- committer
- brice · · 2026-08-04 12:56 UTC
- parent
- 408d4f4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +389 -92
M README.md +1 -1
| @@ -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 | - | - five built-in tools (read, write, edit, bash, and lsp). | |
| 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). | |
| @@ -19,7 +19,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 19 | 19 | ||||
| 20 | - one static binary. | 20 | - one static binary. | ||
| 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. | 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 | - | - five built-in tools (read, write, edit, bash, and lsp). | 22 | + | - five core built-in tools (read, write, edit, bash, and lsp); the conditional `skill` tool is available when skills are enabled. |
| 23 | - three run modes (interactive tui, print, json). | 23 | - three run modes (interactive tui, print, json). | ||
| 24 | - built-in telegram bot. | 24 | - built-in telegram bot. | ||
| 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). | 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). | ||
M docs/lsp.md +3 -2
| @@ -68,8 +68,9 @@ Useful server fields are: | |||
| 68 | 68 | | `initializationOptions` | Values sent during LSP initialization | | |
| 69 | 69 | | `env` | Extra environment variables for the child process | | |
| 70 | 70 | ||
| 71 | - | CLI providers may use `{files}` in an argument. It is replaced with the | |
| 72 | - | project-relative paths touched by the diagnostics request. CLI output is capped | |
| 71 | + | CLI providers may use `{files}` as an argument or inside an argument. It is | |
| 72 | + | expanded to project-relative paths touched by the diagnostics request; an | |
| 73 | + | embedded placeholder produces one argument per path. CLI output is capped | |
| 73 | 74 | before parsing. | |
| 74 | 75 | ||
| 75 | 76 | ## Tool actions | |
| @@ -68,8 +68,9 @@ Useful server fields are: | |||||
| 68 | | `initializationOptions` | Values sent during LSP initialization | | 68 | | `initializationOptions` | Values sent during LSP initialization | | ||
| 69 | | `env` | Extra environment variables for the child process | | 69 | | `env` | Extra environment variables for the child process | | ||
| 70 | 70 | ||||
| 71 | - | CLI providers may use `{files}` in an argument. It is replaced with the | 71 | + | CLI providers may use `{files}` as an argument or inside an argument. It is |
| 72 | - | project-relative paths touched by the diagnostics request. CLI output is capped | 72 | + | expanded to project-relative paths touched by the diagnostics request; an |
| 73 | + | embedded placeholder produces one argument per path. CLI output is capped | |||
| 73 | before parsing. | 74 | before parsing. | ||
| 74 | 75 | ||||
| 75 | ## Tool actions | 76 | ## Tool actions | ||
M docs/subagents.md +1 -1
| @@ -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, lsp | |
| 26 | + | tools: read | |
| 27 | 27 | model: openai-codex/gpt-5.6-luna | |
| 28 | 28 | thinking: max | |
| 29 | 29 | systemPromptMode: replace | |
| @@ -23,7 +23,7 @@ Profiles are read-only inputs. zot does not execute files from these directories | |||||
| 23 | --- | 23 | --- | ||
| 24 | name: reviewer | 24 | name: reviewer | ||
| 25 | description: Read-only code reviewer | 25 | description: Read-only code reviewer | ||
| 26 | - | tools: read, write, edit, bash, lsp | 26 | + | tools: read |
| 27 | model: openai-codex/gpt-5.6-luna | 27 | model: openai-codex/gpt-5.6-luna | ||
| 28 | thinking: max | 28 | thinking: max | ||
| 29 | systemPromptMode: replace | 29 | systemPromptMode: replace | ||
M packages/agent/build.go +22 -1
| @@ -1054,7 +1054,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1054 | 1054 | return core.Registry{} | |
| 1055 | 1055 | } | |
| 1056 | 1056 | var manager *lsp.Manager | |
| 1057 | - | if lspEnabled { | |
| 1057 | + | if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) { | |
| 1058 | 1058 | manager = lsp.NewManager() | |
| 1059 | 1059 | } | |
| 1060 | 1060 | all := map[string]core.Tool{ | |
| @@ -1083,6 +1083,27 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1083 | 1083 | return reg | |
| 1084 | 1084 | } | |
| 1085 | 1085 | ||
| 1086 | + | func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool { | |
| 1087 | + | if len(args.Tools) == 0 { | |
| 1088 | + | return true | |
| 1089 | + | } | |
| 1090 | + | for _, name := range args.Tools { | |
| 1091 | + | switch name { | |
| 1092 | + | case "lsp": | |
| 1093 | + | return true | |
| 1094 | + | case "write": | |
| 1095 | + | if diagnosticsOnWrite { | |
| 1096 | + | return true | |
| 1097 | + | } | |
| 1098 | + | case "edit": | |
| 1099 | + | if diagnosticsOnEdit { | |
| 1100 | + | return true | |
| 1101 | + | } | |
| 1102 | + | } | |
| 1103 | + | } | |
| 1104 | + | return false | |
| 1105 | + | } | |
| 1106 | + | ||
| 1086 | 1107 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1087 | 1108 | order := []string{"read", "write", "edit", "bash", "lsp"} | |
| 1088 | 1109 | var out []ToolSummary | |
| @@ -1054,7 +1054,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||||
| 1054 | return core.Registry{} | 1054 | return core.Registry{} | ||
| 1055 | } | 1055 | } | ||
| 1056 | var manager *lsp.Manager | 1056 | var manager *lsp.Manager | ||
| 1057 | - | if lspEnabled { | 1057 | + | if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) { |
| 1058 | manager = lsp.NewManager() | 1058 | manager = lsp.NewManager() | ||
| 1059 | } | 1059 | } | ||
| 1060 | all := map[string]core.Tool{ | 1060 | all := map[string]core.Tool{ | ||
| @@ -1083,6 +1083,27 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||||
| 1083 | return reg | 1083 | return reg | ||
| 1084 | } | 1084 | } | ||
| 1085 | 1085 | ||||
| 1086 | + | func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool { | |||
| 1087 | + | if len(args.Tools) == 0 { | |||
| 1088 | + | return true | |||
| 1089 | + | } | |||
| 1090 | + | for _, name := range args.Tools { | |||
| 1091 | + | switch name { | |||
| 1092 | + | case "lsp": | |||
| 1093 | + | return true | |||
| 1094 | + | case "write": | |||
| 1095 | + | if diagnosticsOnWrite { | |||
| 1096 | + | return true | |||
| 1097 | + | } | |||
| 1098 | + | case "edit": | |||
| 1099 | + | if diagnosticsOnEdit { | |||
| 1100 | + | return true | |||
| 1101 | + | } | |||
| 1102 | + | } | |||
| 1103 | + | } | |||
| 1104 | + | return false | |||
| 1105 | + | } | |||
| 1106 | + | ||||
| 1086 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | 1107 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | ||
| 1087 | order := []string{"read", "write", "edit", "bash", "lsp"} | 1108 | order := []string{"read", "write", "edit", "bash", "lsp"} | ||
| 1088 | var out []ToolSummary | 1109 | var out []ToolSummary | ||
M packages/agent/build_test.go +9 -0
| @@ -16,6 +16,11 @@ import ( | |||
| 16 | 16 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |
| 17 | 17 | root := t.TempDir() | |
| 18 | 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() }) | |
| 19 | 24 | if _, ok := registry["lsp"]; !ok { | |
| 20 | 25 | t.Fatal("default registry does not include lsp") | |
| 21 | 26 | } | |
| @@ -30,6 +35,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||
| 30 | 35 | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 { | |
| 31 | 36 | t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled)) | |
| 32 | 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 | + | } | |
| 33 | 42 | } | |
| 34 | 43 | ||
| 35 | 44 | func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |
| @@ -16,6 +16,11 @@ import ( | |||||
| 16 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | 16 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | ||
| 17 | root := t.TempDir() | 17 | root := t.TempDir() | ||
| 18 | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false) | 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() }) | |||
| 19 | if _, ok := registry["lsp"]; !ok { | 24 | if _, ok := registry["lsp"]; !ok { | ||
| 20 | t.Fatal("default registry does not include lsp") | 25 | t.Fatal("default registry does not include lsp") | ||
| 21 | } | 26 | } | ||
| @@ -30,6 +35,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||||
| 30 | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 { | 35 | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 { | ||
| 31 | t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled)) | 36 | t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled)) | ||
| 32 | } | 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 | + | } | |||
| 33 | } | 42 | } | ||
| 34 | 43 | ||||
| 35 | func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | 44 | func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | ||
M packages/agent/config.go +2 -2
| @@ -227,13 +227,13 @@ func (c Config) LSPEnabledFor(subagent bool) bool { | |||
| 227 | 227 | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | |
| 228 | 228 | // preference without requiring a generated config entry. | |
| 229 | 229 | func (c Config) LSPDiagnosticsOnWriteEnabled() bool { | |
| 230 | - | return c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite | |
| 230 | + | return c.LSPEnabledFor(false) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite) | |
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | 233 | // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics | |
| 234 | 234 | // preference without requiring a generated config entry. | |
| 235 | 235 | func (c Config) LSPDiagnosticsOnEditEnabled() bool { | |
| 236 | - | return c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit | |
| 236 | + | return c.LSPEnabledFor(false) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit | |
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | 239 | // AuthPath returns the path to auth.json. | |
| @@ -227,13 +227,13 @@ func (c Config) LSPEnabledFor(subagent bool) bool { | |||||
| 227 | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | 227 | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | ||
| 228 | // preference without requiring a generated config entry. | 228 | // preference without requiring a generated config entry. | ||
| 229 | func (c Config) LSPDiagnosticsOnWriteEnabled() bool { | 229 | func (c Config) LSPDiagnosticsOnWriteEnabled() bool { | ||
| 230 | - | return c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite | 230 | + | return c.LSPEnabledFor(false) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite) |
| 231 | } | 231 | } | ||
| 232 | 232 | ||||
| 233 | // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics | 233 | // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics | ||
| 234 | // preference without requiring a generated config entry. | 234 | // preference without requiring a generated config entry. | ||
| 235 | func (c Config) LSPDiagnosticsOnEditEnabled() bool { | 235 | func (c Config) LSPDiagnosticsOnEditEnabled() bool { | ||
| 236 | - | return c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit | 236 | + | return c.LSPEnabledFor(false) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit |
| 237 | } | 237 | } | ||
| 238 | 238 | ||||
| 239 | // AuthPath returns the path to auth.json. | 239 | // AuthPath returns the path to auth.json. | ||
M packages/agent/lsp/client.go +8 -2
| @@ -95,6 +95,7 @@ func NewClientWithOptions(spec ServerConfig, root string, options ClientOptions) | |||
| 95 | 95 | return nil, fmt.Errorf("%s: %w", spec.ID, err) | |
| 96 | 96 | } | |
| 97 | 97 | cmd := exec.Command(command, spec.Args...) | |
| 98 | + | cmd.WaitDelay = 2 * time.Second | |
| 98 | 99 | cmd.Dir = root | |
| 99 | 100 | cmd.Env = mergedEnvironment(spec.Env) | |
| 100 | 101 | stdin, err := cmd.StdinPipe() | |
| @@ -542,8 +543,13 @@ func (c *Client) Close() error { | |||
| 542 | 543 | } | |
| 543 | 544 | waitErr = c.cmd.Wait() | |
| 544 | 545 | if killed { | |
| 545 | - | // The kill was part of normal cleanup, not a failed operation. | |
| 546 | - | waitErr = nil | |
| 546 | + | // A process terminated by this cleanup path normally returns an | |
| 547 | + | // ExitError; preserve unrelated wait failures such as pipe | |
| 548 | + | // errors so callers still get useful lifecycle diagnostics. | |
| 549 | + | var exitErr *exec.ExitError | |
| 550 | + | if errors.As(waitErr, &exitErr) { | |
| 551 | + | waitErr = nil | |
| 552 | + | } | |
| 547 | 553 | } | |
| 548 | 554 | _ = c.stdout.Close() | |
| 549 | 555 | _ = c.stderr.Close() | |
| @@ -95,6 +95,7 @@ func NewClientWithOptions(spec ServerConfig, root string, options ClientOptions) | |||||
| 95 | return nil, fmt.Errorf("%s: %w", spec.ID, err) | 95 | return nil, fmt.Errorf("%s: %w", spec.ID, err) | ||
| 96 | } | 96 | } | ||
| 97 | cmd := exec.Command(command, spec.Args...) | 97 | cmd := exec.Command(command, spec.Args...) | ||
| 98 | + | cmd.WaitDelay = 2 * time.Second | |||
| 98 | cmd.Dir = root | 99 | cmd.Dir = root | ||
| 99 | cmd.Env = mergedEnvironment(spec.Env) | 100 | cmd.Env = mergedEnvironment(spec.Env) | ||
| 100 | stdin, err := cmd.StdinPipe() | 101 | stdin, err := cmd.StdinPipe() | ||
| @@ -542,8 +543,13 @@ func (c *Client) Close() error { | |||||
| 542 | } | 543 | } | ||
| 543 | waitErr = c.cmd.Wait() | 544 | waitErr = c.cmd.Wait() | ||
| 544 | if killed { | 545 | if killed { | ||
| 545 | - | // The kill was part of normal cleanup, not a failed operation. | 546 | + | // A process terminated by this cleanup path normally returns an |
| 546 | - | waitErr = nil | 547 | + | // ExitError; preserve unrelated wait failures such as pipe |
| 548 | + | // errors so callers still get useful lifecycle diagnostics. | |||
| 549 | + | var exitErr *exec.ExitError | |||
| 550 | + | if errors.As(waitErr, &exitErr) { | |||
| 551 | + | waitErr = nil | |||
| 552 | + | } | |||
| 547 | } | 553 | } | ||
| 548 | _ = c.stdout.Close() | 554 | _ = c.stdout.Close() | ||
| 549 | _ = c.stderr.Close() | 555 | _ = c.stderr.Close() | ||
M packages/agent/lsp/config.go +71 -46
| @@ -87,7 +87,10 @@ func LoadConfig(cwd string) (Config, error) { | |||
| 87 | 87 | explicit := make(map[string]bool) | |
| 88 | 88 | autoDetect := true | |
| 89 | 89 | for _, path := range files { | |
| 90 | - | data, _ := os.ReadFile(path) | |
| 90 | + | entries, exists, data, err := readConfigFile(path) | |
| 91 | + | if err != nil { | |
| 92 | + | return Config{}, err | |
| 93 | + | } | |
| 91 | 94 | if len(data) > 0 { | |
| 92 | 95 | var metadata struct { | |
| 93 | 96 | AutoDetect *bool `json:"autoDetect"` | |
| @@ -96,10 +99,6 @@ func LoadConfig(cwd string) (Config, error) { | |||
| 96 | 99 | autoDetect = *metadata.AutoDetect | |
| 97 | 100 | } | |
| 98 | 101 | } | |
| 99 | - | entries, exists, err := readConfigFile(path) | |
| 100 | - | if err != nil { | |
| 101 | - | return Config{}, err | |
| 102 | - | } | |
| 103 | 102 | if !exists { | |
| 104 | 103 | continue | |
| 105 | 104 | } | |
| @@ -123,24 +122,24 @@ func LoadConfig(cwd string) (Config, error) { | |||
| 123 | 122 | merged[server.ID] = server | |
| 124 | 123 | } | |
| 125 | 124 | for id, raw := range objects { | |
| 126 | - | server, err := decodeServer(id, raw) | |
| 127 | - | if err != nil { | |
| 128 | - | return Config{}, fmt.Errorf("lsp server %q: %w", id, err) | |
| 129 | - | } | |
| 125 | + | decodeRaw := raw | |
| 126 | + | _, hasBuiltin := merged[id] | |
| 130 | 127 | if base, ok := merged[id]; ok { | |
| 131 | 128 | // Decode the already-merged JSON over the built-in by marshaling the | |
| 132 | 129 | // built-in first. This retains fields that were omitted in the file. | |
| 133 | 130 | baseRaw, _ := json.Marshal(base) | |
| 134 | 131 | baseMap := map[string]json.RawMessage{} | |
| 135 | 132 | _ = json.Unmarshal(baseRaw, &baseMap) | |
| 136 | - | mergedRaw := mergeRawObjects(baseMap, raw) | |
| 137 | - | server, err = decodeServer(id, mergedRaw) | |
| 138 | - | if err != nil { | |
| 139 | - | return Config{}, fmt.Errorf("lsp server %q: %w", id, err) | |
| 140 | - | } | |
| 141 | - | // A parser/mode is an explicit CLI declaration even when the | |
| 142 | - | // overridden built-in was an LSP. This makes {"parser":"sarif"} | |
| 143 | - | // sufficient for a linter override. | |
| 133 | + | decodeRaw = mergeRawObjects(baseMap, raw) | |
| 134 | + | } | |
| 135 | + | server, err := decodeServer(id, decodeRaw) | |
| 136 | + | if err != nil { | |
| 137 | + | return Config{}, fmt.Errorf("lsp server %q: %w", id, err) | |
| 138 | + | } | |
| 139 | + | // A parser/mode is an explicit CLI declaration even when the | |
| 140 | + | // overridden built-in was an LSP. This makes {"parser":"sarif"} | |
| 141 | + | // sufficient for a linter override. | |
| 142 | + | if hasBuiltin { | |
| 144 | 143 | if _, hasKind := raw["kind"]; !hasKind { | |
| 145 | 144 | if _, hasParser := raw["parser"]; hasParser { | |
| 146 | 145 | server.Kind = "cli" | |
| @@ -210,28 +209,17 @@ func serverMatchesPath(cwd, path string, server ServerConfig) bool { | |||
| 210 | 209 | if len(server.RootMarkers) > 0 && !hasRootMarker(cwd, server.RootMarkers) { | |
| 211 | 210 | return false | |
| 212 | 211 | } | |
| 213 | - | ext := strings.ToLower(filepath.Ext(path)) | |
| 214 | - | base := strings.ToLower(filepath.Base(path)) | |
| 215 | - | lang := languageForExtension(ext) | |
| 216 | - | for _, typ := range server.FileTypes { | |
| 217 | - | typ = strings.ToLower(strings.TrimSpace(typ)) | |
| 218 | - | if typ == ext || typ == "."+strings.TrimPrefix(ext, ".") || typ == base || typ == lang || strings.TrimPrefix(typ, ".") == strings.TrimPrefix(ext, ".") { | |
| 219 | - | return true | |
| 220 | - | } | |
| 221 | - | if strings.HasPrefix(typ, "*.") && strings.HasSuffix(base, strings.TrimPrefix(typ, "*")) { | |
| 222 | - | return true | |
| 223 | - | } | |
| 224 | - | } | |
| 225 | - | return false | |
| 212 | + | return fileTypeMatches(path, server.FileTypes) | |
| 226 | 213 | } | |
| 227 | 214 | return hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes) | |
| 228 | 215 | } | |
| 229 | 216 | ||
| 230 | 217 | func hasMatchingFile(cwd string, types []string) bool { | |
| 218 | + | normalizedTypes := normalizeFileTypes(types) | |
| 231 | 219 | var found bool | |
| 232 | 220 | _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error { | |
| 233 | 221 | if err != nil || found { | |
| 234 | - | return filepath.SkipDir | |
| 222 | + | return filepath.SkipAll | |
| 235 | 223 | } | |
| 236 | 224 | if entry.IsDir() { | |
| 237 | 225 | if path != cwd && (entry.Name() == ".git" || entry.Name() == "node_modules" || entry.Name() == ".venv") { | |
| @@ -239,12 +227,42 @@ func hasMatchingFile(cwd string, types []string) bool { | |||
| 239 | 227 | } | |
| 240 | 228 | return nil | |
| 241 | 229 | } | |
| 242 | - | found = serverMatchesPath(cwd, path, ServerConfig{FileTypes: types}) | |
| 230 | + | found = fileTypeMatchesNormalized(path, normalizedTypes) | |
| 231 | + | if found { | |
| 232 | + | return filepath.SkipAll | |
| 233 | + | } | |
| 243 | 234 | return nil | |
| 244 | 235 | }) | |
| 245 | 236 | return found | |
| 246 | 237 | } | |
| 247 | 238 | ||
| 239 | + | func fileTypeMatches(path string, types []string) bool { | |
| 240 | + | return fileTypeMatchesNormalized(path, normalizeFileTypes(types)) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func normalizeFileTypes(types []string) []string { | |
| 244 | + | normalized := make([]string, 0, len(types)) | |
| 245 | + | for _, typ := range types { | |
| 246 | + | normalized = append(normalized, strings.ToLower(strings.TrimSpace(typ))) | |
| 247 | + | } | |
| 248 | + | return normalized | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func fileTypeMatchesNormalized(path string, types []string) bool { | |
| 252 | + | ext := strings.ToLower(filepath.Ext(path)) | |
| 253 | + | base := strings.ToLower(filepath.Base(path)) | |
| 254 | + | lang := languageForExtension(ext) | |
| 255 | + | for _, typ := range types { | |
| 256 | + | if typ == ext || typ == "."+strings.TrimPrefix(ext, ".") || typ == base || typ == lang || strings.TrimPrefix(typ, ".") == strings.TrimPrefix(ext, ".") { | |
| 257 | + | return true | |
| 258 | + | } | |
| 259 | + | if strings.HasPrefix(typ, "*.") && strings.HasSuffix(base, strings.TrimPrefix(typ, "*")) { | |
| 260 | + | return true | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | return false | |
| 264 | + | } | |
| 265 | + | ||
| 248 | 266 | func hasRootMarker(cwd string, markers []string) bool { | |
| 249 | 267 | if len(markers) == 0 { | |
| 250 | 268 | return false | |
| @@ -363,17 +381,17 @@ func providerIDs(data []byte) map[string]struct{} { | |||
| 363 | 381 | return ids | |
| 364 | 382 | } | |
| 365 | 383 | ||
| 366 | - | func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, error) { | |
| 384 | + | func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, []byte, error) { | |
| 367 | 385 | data, err := os.ReadFile(path) | |
| 368 | 386 | if errors.Is(err, os.ErrNotExist) { | |
| 369 | - | return nil, false, nil | |
| 387 | + | return nil, false, nil, nil | |
| 370 | 388 | } | |
| 371 | 389 | if err != nil { | |
| 372 | - | return nil, true, err | |
| 390 | + | return nil, true, nil, err | |
| 373 | 391 | } | |
| 374 | 392 | var top map[string]json.RawMessage | |
| 375 | 393 | if err := json.Unmarshal(data, &top); err != nil { | |
| 376 | - | return nil, true, fmt.Errorf("read %s: %w", path, err) | |
| 394 | + | return nil, true, data, fmt.Errorf("read %s: %w", path, err) | |
| 377 | 395 | } | |
| 378 | 396 | root := top | |
| 379 | 397 | if raw, ok := top["providers"]; ok { | |
| @@ -382,7 +400,7 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e | |||
| 382 | 400 | // and turn object entries into the map shape used internally. | |
| 383 | 401 | var providers []json.RawMessage | |
| 384 | 402 | if err := json.Unmarshal(raw, &providers); err != nil { | |
| 385 | - | return nil, true, fmt.Errorf("%s.providers must be an array: %w", path, err) | |
| 403 | + | return nil, true, data, fmt.Errorf("%s.providers must be an array: %w", path, err) | |
| 386 | 404 | } | |
| 387 | 405 | root = make(map[string]json.RawMessage) | |
| 388 | 406 | for index, provider := range providers { | |
| @@ -392,11 +410,11 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e | |||
| 392 | 410 | } | |
| 393 | 411 | var object map[string]json.RawMessage | |
| 394 | 412 | if err := json.Unmarshal(provider, &object); err != nil { | |
| 395 | - | return nil, true, fmt.Errorf("%s.providers[%d] must be a string or object: %w", path, index, err) | |
| 413 | + | return nil, true, data, fmt.Errorf("%s.providers[%d] must be a string or object: %w", path, index, err) | |
| 396 | 414 | } | |
| 397 | 415 | idRaw, ok := object["id"] | |
| 398 | 416 | if !ok || json.Unmarshal(idRaw, &id) != nil || strings.TrimSpace(id) == "" { | |
| 399 | - | return nil, true, fmt.Errorf("%s.providers[%d] is missing id", path, index) | |
| 417 | + | return nil, true, data, fmt.Errorf("%s.providers[%d] is missing id", path, index) | |
| 400 | 418 | } | |
| 401 | 419 | id = strings.TrimSpace(id) | |
| 402 | 420 | delete(object, "id") | |
| @@ -404,16 +422,23 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e | |||
| 404 | 422 | root[id] = mustJSON(object) | |
| 405 | 423 | } | |
| 406 | 424 | } else { | |
| 425 | + | wrappedRoot := make(map[string]json.RawMessage) | |
| 426 | + | wrapped := false | |
| 407 | 427 | for _, key := range []string{"servers", "lspServers", "linters"} { | |
| 408 | 428 | if raw, ok := top[key]; ok { | |
| 409 | - | var wrapped map[string]json.RawMessage | |
| 410 | - | if err := json.Unmarshal(raw, &wrapped); err != nil { | |
| 411 | - | return nil, true, fmt.Errorf("%s.%s must be an object: %w", path, key, err) | |
| 429 | + | var section map[string]json.RawMessage | |
| 430 | + | if err := json.Unmarshal(raw, §ion); err != nil { | |
| 431 | + | return nil, true, data, fmt.Errorf("%s.%s must be an object: %w", path, key, err) | |
| 432 | + | } | |
| 433 | + | wrapped = true | |
| 434 | + | for id, value := range section { | |
| 435 | + | wrappedRoot[id] = value | |
| 412 | 436 | } | |
| 413 | - | root = wrapped | |
| 414 | - | break | |
| 415 | 437 | } | |
| 416 | 438 | } | |
| 439 | + | if wrapped { | |
| 440 | + | root = wrappedRoot | |
| 441 | + | } | |
| 417 | 442 | } | |
| 418 | 443 | out := make(map[string]map[string]json.RawMessage) | |
| 419 | 444 | for id, raw := range root { | |
| @@ -427,12 +452,12 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e | |||
| 427 | 452 | if json.Unmarshal(raw, &command) == nil { | |
| 428 | 453 | object = map[string]json.RawMessage{"command": json.RawMessage(strconvQuote(command))} | |
| 429 | 454 | } else { | |
| 430 | - | return nil, true, fmt.Errorf("%s.%s must be an object", path, id) | |
| 455 | + | return nil, true, data, fmt.Errorf("%s.%s must be an object", path, id) | |
| 431 | 456 | } | |
| 432 | 457 | } | |
| 433 | 458 | out[id] = object | |
| 434 | 459 | } | |
| 435 | - | return out, true, nil | |
| 460 | + | return out, true, data, nil | |
| 436 | 461 | } | |
| 437 | 462 | ||
| 438 | 463 | func strconvQuote(s string) string { | |
| @@ -87,7 +87,10 @@ func LoadConfig(cwd string) (Config, error) { | |||||
| 87 | explicit := make(map[string]bool) | 87 | explicit := make(map[string]bool) | ||
| 88 | autoDetect := true | 88 | autoDetect := true | ||
| 89 | for _, path := range files { | 89 | for _, path := range files { | ||
| 90 | - | data, _ := os.ReadFile(path) | 90 | + | entries, exists, data, err := readConfigFile(path) |
| 91 | + | if err != nil { | |||
| 92 | + | return Config{}, err | |||
| 93 | + | } | |||
| 91 | if len(data) > 0 { | 94 | if len(data) > 0 { | ||
| 92 | var metadata struct { | 95 | var metadata struct { | ||
| 93 | AutoDetect *bool `json:"autoDetect"` | 96 | AutoDetect *bool `json:"autoDetect"` | ||
| @@ -96,10 +99,6 @@ func LoadConfig(cwd string) (Config, error) { | |||||
| 96 | autoDetect = *metadata.AutoDetect | 99 | autoDetect = *metadata.AutoDetect | ||
| 97 | } | 100 | } | ||
| 98 | } | 101 | } | ||
| 99 | - | entries, exists, err := readConfigFile(path) | |||
| 100 | - | if err != nil { | |||
| 101 | - | return Config{}, err | |||
| 102 | - | } | |||
| 103 | if !exists { | 102 | if !exists { | ||
| 104 | continue | 103 | continue | ||
| 105 | } | 104 | } | ||
| @@ -123,24 +122,24 @@ func LoadConfig(cwd string) (Config, error) { | |||||
| 123 | merged[server.ID] = server | 122 | merged[server.ID] = server | ||
| 124 | } | 123 | } | ||
| 125 | for id, raw := range objects { | 124 | for id, raw := range objects { | ||
| 126 | - | server, err := decodeServer(id, raw) | 125 | + | decodeRaw := raw |
| 127 | - | if err != nil { | 126 | + | _, hasBuiltin := merged[id] |
| 128 | - | return Config{}, fmt.Errorf("lsp server %q: %w", id, err) | |||
| 129 | - | } | |||
| 130 | if base, ok := merged[id]; ok { | 127 | if base, ok := merged[id]; ok { | ||
| 131 | // Decode the already-merged JSON over the built-in by marshaling the | 128 | // Decode the already-merged JSON over the built-in by marshaling the | ||
| 132 | // built-in first. This retains fields that were omitted in the file. | 129 | // built-in first. This retains fields that were omitted in the file. | ||
| 133 | baseRaw, _ := json.Marshal(base) | 130 | baseRaw, _ := json.Marshal(base) | ||
| 134 | baseMap := map[string]json.RawMessage{} | 131 | baseMap := map[string]json.RawMessage{} | ||
| 135 | _ = json.Unmarshal(baseRaw, &baseMap) | 132 | _ = json.Unmarshal(baseRaw, &baseMap) | ||
| 136 | - | mergedRaw := mergeRawObjects(baseMap, raw) | 133 | + | decodeRaw = mergeRawObjects(baseMap, raw) |
| 137 | - | server, err = decodeServer(id, mergedRaw) | 134 | + | } |
| 138 | - | if err != nil { | 135 | + | server, err := decodeServer(id, decodeRaw) |
| 139 | - | return Config{}, fmt.Errorf("lsp server %q: %w", id, err) | 136 | + | if err != nil { |
| 140 | - | } | 137 | + | return Config{}, fmt.Errorf("lsp server %q: %w", id, err) |
| 141 | - | // A parser/mode is an explicit CLI declaration even when the | 138 | + | } |
| 142 | - | // overridden built-in was an LSP. This makes {"parser":"sarif"} | 139 | + | // A parser/mode is an explicit CLI declaration even when the |
| 143 | - | // sufficient for a linter override. | 140 | + | // overridden built-in was an LSP. This makes {"parser":"sarif"} |
| 141 | + | // sufficient for a linter override. | |||
| 142 | + | if hasBuiltin { | |||
| 144 | if _, hasKind := raw["kind"]; !hasKind { | 143 | if _, hasKind := raw["kind"]; !hasKind { | ||
| 145 | if _, hasParser := raw["parser"]; hasParser { | 144 | if _, hasParser := raw["parser"]; hasParser { | ||
| 146 | server.Kind = "cli" | 145 | server.Kind = "cli" | ||
| @@ -210,28 +209,17 @@ func serverMatchesPath(cwd, path string, server ServerConfig) bool { | |||||
| 210 | if len(server.RootMarkers) > 0 && !hasRootMarker(cwd, server.RootMarkers) { | 209 | if len(server.RootMarkers) > 0 && !hasRootMarker(cwd, server.RootMarkers) { | ||
| 211 | return false | 210 | return false | ||
| 212 | } | 211 | } | ||
| 213 | - | ext := strings.ToLower(filepath.Ext(path)) | 212 | + | return fileTypeMatches(path, server.FileTypes) |
| 214 | - | base := strings.ToLower(filepath.Base(path)) | |||
| 215 | - | lang := languageForExtension(ext) | |||
| 216 | - | for _, typ := range server.FileTypes { | |||
| 217 | - | typ = strings.ToLower(strings.TrimSpace(typ)) | |||
| 218 | - | if typ == ext || typ == "."+strings.TrimPrefix(ext, ".") || typ == base || typ == lang || strings.TrimPrefix(typ, ".") == strings.TrimPrefix(ext, ".") { | |||
| 219 | - | return true | |||
| 220 | - | } | |||
| 221 | - | if strings.HasPrefix(typ, "*.") && strings.HasSuffix(base, strings.TrimPrefix(typ, "*")) { | |||
| 222 | - | return true | |||
| 223 | - | } | |||
| 224 | - | } | |||
| 225 | - | return false | |||
| 226 | } | 213 | } | ||
| 227 | return hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes) | 214 | return hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes) | ||
| 228 | } | 215 | } | ||
| 229 | 216 | ||||
| 230 | func hasMatchingFile(cwd string, types []string) bool { | 217 | func hasMatchingFile(cwd string, types []string) bool { | ||
| 218 | + | normalizedTypes := normalizeFileTypes(types) | |||
| 231 | var found bool | 219 | var found bool | ||
| 232 | _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error { | 220 | _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error { | ||
| 233 | if err != nil || found { | 221 | if err != nil || found { | ||
| 234 | - | return filepath.SkipDir | 222 | + | return filepath.SkipAll |
| 235 | } | 223 | } | ||
| 236 | if entry.IsDir() { | 224 | if entry.IsDir() { | ||
| 237 | if path != cwd && (entry.Name() == ".git" || entry.Name() == "node_modules" || entry.Name() == ".venv") { | 225 | if path != cwd && (entry.Name() == ".git" || entry.Name() == "node_modules" || entry.Name() == ".venv") { | ||
| @@ -239,12 +227,42 @@ func hasMatchingFile(cwd string, types []string) bool { | |||||
| 239 | } | 227 | } | ||
| 240 | return nil | 228 | return nil | ||
| 241 | } | 229 | } | ||
| 242 | - | found = serverMatchesPath(cwd, path, ServerConfig{FileTypes: types}) | 230 | + | found = fileTypeMatchesNormalized(path, normalizedTypes) |
| 231 | + | if found { | |||
| 232 | + | return filepath.SkipAll | |||
| 233 | + | } | |||
| 243 | return nil | 234 | return nil | ||
| 244 | }) | 235 | }) | ||
| 245 | return found | 236 | return found | ||
| 246 | } | 237 | } | ||
| 247 | 238 | ||||
| 239 | + | func fileTypeMatches(path string, types []string) bool { | |||
| 240 | + | return fileTypeMatchesNormalized(path, normalizeFileTypes(types)) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func normalizeFileTypes(types []string) []string { | |||
| 244 | + | normalized := make([]string, 0, len(types)) | |||
| 245 | + | for _, typ := range types { | |||
| 246 | + | normalized = append(normalized, strings.ToLower(strings.TrimSpace(typ))) | |||
| 247 | + | } | |||
| 248 | + | return normalized | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func fileTypeMatchesNormalized(path string, types []string) bool { | |||
| 252 | + | ext := strings.ToLower(filepath.Ext(path)) | |||
| 253 | + | base := strings.ToLower(filepath.Base(path)) | |||
| 254 | + | lang := languageForExtension(ext) | |||
| 255 | + | for _, typ := range types { | |||
| 256 | + | if typ == ext || typ == "."+strings.TrimPrefix(ext, ".") || typ == base || typ == lang || strings.TrimPrefix(typ, ".") == strings.TrimPrefix(ext, ".") { | |||
| 257 | + | return true | |||
| 258 | + | } | |||
| 259 | + | if strings.HasPrefix(typ, "*.") && strings.HasSuffix(base, strings.TrimPrefix(typ, "*")) { | |||
| 260 | + | return true | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | return false | |||
| 264 | + | } | |||
| 265 | + | ||||
| 248 | func hasRootMarker(cwd string, markers []string) bool { | 266 | func hasRootMarker(cwd string, markers []string) bool { | ||
| 249 | if len(markers) == 0 { | 267 | if len(markers) == 0 { | ||
| 250 | return false | 268 | return false | ||
| @@ -363,17 +381,17 @@ func providerIDs(data []byte) map[string]struct{} { | |||||
| 363 | return ids | 381 | return ids | ||
| 364 | } | 382 | } | ||
| 365 | 383 | ||||
| 366 | - | func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, error) { | 384 | + | func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, []byte, error) { |
| 367 | data, err := os.ReadFile(path) | 385 | data, err := os.ReadFile(path) | ||
| 368 | if errors.Is(err, os.ErrNotExist) { | 386 | if errors.Is(err, os.ErrNotExist) { | ||
| 369 | - | return nil, false, nil | 387 | + | return nil, false, nil, nil |
| 370 | } | 388 | } | ||
| 371 | if err != nil { | 389 | if err != nil { | ||
| 372 | - | return nil, true, err | 390 | + | return nil, true, nil, err |
| 373 | } | 391 | } | ||
| 374 | var top map[string]json.RawMessage | 392 | var top map[string]json.RawMessage | ||
| 375 | if err := json.Unmarshal(data, &top); err != nil { | 393 | if err := json.Unmarshal(data, &top); err != nil { | ||
| 376 | - | return nil, true, fmt.Errorf("read %s: %w", path, err) | 394 | + | return nil, true, data, fmt.Errorf("read %s: %w", path, err) |
| 377 | } | 395 | } | ||
| 378 | root := top | 396 | root := top | ||
| 379 | if raw, ok := top["providers"]; ok { | 397 | if raw, ok := top["providers"]; ok { | ||
| @@ -382,7 +400,7 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e | |||||
| 382 | // and turn object entries into the map shape used internally. | 400 | // and turn object entries into the map shape used internally. | ||
| 383 | var providers []json.RawMessage | 401 | var providers []json.RawMessage | ||
| 384 | if err := json.Unmarshal(raw, &providers); err != nil { | 402 | if err := json.Unmarshal(raw, &providers); err != nil { | ||
| 385 | - | return nil, true, fmt.Errorf("%s.providers must be an array: %w", path, err) | 403 | + | return nil, true, data, fmt.Errorf("%s.providers must be an array: %w", path, err) |
| 386 | } | 404 | } | ||
| 387 | root = make(map[string]json.RawMessage) | 405 | root = make(map[string]json.RawMessage) | ||
| 388 | for index, provider := range providers { | 406 | for index, provider := range providers { | ||
| @@ -392,11 +410,11 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e | |||||
| 392 | } | 410 | } | ||
| 393 | var object map[string]json.RawMessage | 411 | var object map[string]json.RawMessage | ||
| 394 | if err := json.Unmarshal(provider, &object); err != nil { | 412 | if err := json.Unmarshal(provider, &object); err != nil { | ||
| 395 | - | return nil, true, fmt.Errorf("%s.providers[%d] must be a string or object: %w", path, index, err) | 413 | + | return nil, true, data, fmt.Errorf("%s.providers[%d] must be a string or object: %w", path, index, err) |
| 396 | } | 414 | } | ||
| 397 | idRaw, ok := object["id"] | 415 | idRaw, ok := object["id"] | ||
| 398 | if !ok || json.Unmarshal(idRaw, &id) != nil || strings.TrimSpace(id) == "" { | 416 | if !ok || json.Unmarshal(idRaw, &id) != nil || strings.TrimSpace(id) == "" { | ||
| 399 | - | return nil, true, fmt.Errorf("%s.providers[%d] is missing id", path, index) | 417 | + | return nil, true, data, fmt.Errorf("%s.providers[%d] is missing id", path, index) |
| 400 | } | 418 | } | ||
| 401 | id = strings.TrimSpace(id) | 419 | id = strings.TrimSpace(id) | ||
| 402 | delete(object, "id") | 420 | delete(object, "id") | ||
| @@ -404,16 +422,23 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e | |||||
| 404 | root[id] = mustJSON(object) | 422 | root[id] = mustJSON(object) | ||
| 405 | } | 423 | } | ||
| 406 | } else { | 424 | } else { | ||
| 425 | + | wrappedRoot := make(map[string]json.RawMessage) | |||
| 426 | + | wrapped := false | |||
| 407 | for _, key := range []string{"servers", "lspServers", "linters"} { | 427 | for _, key := range []string{"servers", "lspServers", "linters"} { | ||
| 408 | if raw, ok := top[key]; ok { | 428 | if raw, ok := top[key]; ok { | ||
| 409 | - | var wrapped map[string]json.RawMessage | 429 | + | var section map[string]json.RawMessage |
| 410 | - | if err := json.Unmarshal(raw, &wrapped); err != nil { | 430 | + | if err := json.Unmarshal(raw, §ion); err != nil { |
| 411 | - | return nil, true, fmt.Errorf("%s.%s must be an object: %w", path, key, err) | 431 | + | return nil, true, data, fmt.Errorf("%s.%s must be an object: %w", path, key, err) |
| 432 | + | } | |||
| 433 | + | wrapped = true | |||
| 434 | + | for id, value := range section { | |||
| 435 | + | wrappedRoot[id] = value | |||
| 412 | } | 436 | } | ||
| 413 | - | root = wrapped | |||
| 414 | - | break | |||
| 415 | } | 437 | } | ||
| 416 | } | 438 | } | ||
| 439 | + | if wrapped { | |||
| 440 | + | root = wrappedRoot | |||
| 441 | + | } | |||
| 417 | } | 442 | } | ||
| 418 | out := make(map[string]map[string]json.RawMessage) | 443 | out := make(map[string]map[string]json.RawMessage) | ||
| 419 | for id, raw := range root { | 444 | for id, raw := range root { | ||
| @@ -427,12 +452,12 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e | |||||
| 427 | if json.Unmarshal(raw, &command) == nil { | 452 | if json.Unmarshal(raw, &command) == nil { | ||
| 428 | object = map[string]json.RawMessage{"command": json.RawMessage(strconvQuote(command))} | 453 | object = map[string]json.RawMessage{"command": json.RawMessage(strconvQuote(command))} | ||
| 429 | } else { | 454 | } else { | ||
| 430 | - | return nil, true, fmt.Errorf("%s.%s must be an object", path, id) | 455 | + | return nil, true, data, fmt.Errorf("%s.%s must be an object", path, id) |
| 431 | } | 456 | } | ||
| 432 | } | 457 | } | ||
| 433 | out[id] = object | 458 | out[id] = object | ||
| 434 | } | 459 | } | ||
| 435 | - | return out, true, nil | 460 | + | return out, true, data, nil |
| 436 | } | 461 | } | ||
| 437 | 462 | ||||
| 438 | func strconvQuote(s string) string { | 463 | func strconvQuote(s string) string { | ||
M packages/agent/lsp/diagnostics.go +2 -5
| @@ -13,7 +13,7 @@ const DefaultDiagnosticCap = 50 | |||
| 13 | 13 | var missingModulePatterns = []*regexp.Regexp{ | |
| 14 | 14 | regexp.MustCompile(`(?i)cannot find module ["']([^"']+)["']`), | |
| 15 | 15 | regexp.MustCompile(`(?i)could not find (?:a declaration file for )?module ["']([^"']+)["']`), | |
| 16 | - | regexp.MustCompile(`(?i)cannot resolve ["']?([^"' ]+)["']?`), | |
| 16 | + | regexp.MustCompile(`(?i)(?:cannot|could not) resolve (?:module|import|package) ["']?([^"' ]+)["']?`), | |
| 17 | 17 | regexp.MustCompile(`(?i)import .*?(["'][^"']+["']).*could not be resolved`), | |
| 18 | 18 | } | |
| 19 | 19 | ||
| @@ -199,10 +199,7 @@ func formatRepeated(bucket []Diagnostic, root string) string { | |||
| 199 | 199 | if len(paths) == 1 && len(lines) > 0 { | |
| 200 | 200 | where += fmt.Sprintf(" (lines: %s)", joinInts(lines, 3)) | |
| 201 | 201 | } | |
| 202 | - | message := compactWhitespace(bucket[0].Message) | |
| 203 | - | if len(message) > 220 { | |
| 204 | - | message = message[:219] + "…" | |
| 205 | - | } | |
| 202 | + | message := truncateText(compactWhitespace(bucket[0].Message), 220) | |
| 206 | 203 | return fmt.Sprintf("%s: repeated %s%s %s%s", strings.ToUpper(defaultSeverity(bucket[0].Severity)), where, code, message, extra) | |
| 207 | 204 | } | |
| 208 | 205 | ||
| @@ -13,7 +13,7 @@ const DefaultDiagnosticCap = 50 | |||||
| 13 | var missingModulePatterns = []*regexp.Regexp{ | 13 | var missingModulePatterns = []*regexp.Regexp{ | ||
| 14 | regexp.MustCompile(`(?i)cannot find module ["']([^"']+)["']`), | 14 | regexp.MustCompile(`(?i)cannot find module ["']([^"']+)["']`), | ||
| 15 | regexp.MustCompile(`(?i)could not find (?:a declaration file for )?module ["']([^"']+)["']`), | 15 | regexp.MustCompile(`(?i)could not find (?:a declaration file for )?module ["']([^"']+)["']`), | ||
| 16 | - | regexp.MustCompile(`(?i)cannot resolve ["']?([^"' ]+)["']?`), | 16 | + | regexp.MustCompile(`(?i)(?:cannot|could not) resolve (?:module|import|package) ["']?([^"' ]+)["']?`), |
| 17 | regexp.MustCompile(`(?i)import .*?(["'][^"']+["']).*could not be resolved`), | 17 | regexp.MustCompile(`(?i)import .*?(["'][^"']+["']).*could not be resolved`), | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| @@ -199,10 +199,7 @@ func formatRepeated(bucket []Diagnostic, root string) string { | |||||
| 199 | if len(paths) == 1 && len(lines) > 0 { | 199 | if len(paths) == 1 && len(lines) > 0 { | ||
| 200 | where += fmt.Sprintf(" (lines: %s)", joinInts(lines, 3)) | 200 | where += fmt.Sprintf(" (lines: %s)", joinInts(lines, 3)) | ||
| 201 | } | 201 | } | ||
| 202 | - | message := compactWhitespace(bucket[0].Message) | 202 | + | message := truncateText(compactWhitespace(bucket[0].Message), 220) |
| 203 | - | if len(message) > 220 { | |||
| 204 | - | message = message[:219] + "…" | |||
| 205 | - | } | |||
| 206 | return fmt.Sprintf("%s: repeated %s%s %s%s", strings.ToUpper(defaultSeverity(bucket[0].Severity)), where, code, message, extra) | 203 | return fmt.Sprintf("%s: repeated %s%s %s%s", strings.ToUpper(defaultSeverity(bucket[0].Severity)), where, code, message, extra) | ||
| 207 | } | 204 | } | ||
| 208 | 205 | ||||
M packages/agent/lsp/edits.go +8 -1
| @@ -51,7 +51,14 @@ func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | |||
| 51 | 51 | if err := safeWorkspacePath(rootReal, path); err != nil { | |
| 52 | 52 | return err | |
| 53 | 53 | } | |
| 54 | - | if err := applyTextEdits(path, changes); err != nil { | |
| 54 | + | target, err := filepath.EvalSymlinks(path) | |
| 55 | + | if err != nil { | |
| 56 | + | return fmt.Errorf("workspace edit target %q: %w", path, err) | |
| 57 | + | } | |
| 58 | + | if err := safeWorkspacePath(rootReal, target); err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | if err := applyTextEdits(target, changes); err != nil { | |
| 55 | 62 | return fmt.Errorf("apply edit to %s: %w", path, err) | |
| 56 | 63 | } | |
| 57 | 64 | } | |
| @@ -51,7 +51,14 @@ func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | |||||
| 51 | if err := safeWorkspacePath(rootReal, path); err != nil { | 51 | if err := safeWorkspacePath(rootReal, path); err != nil { | ||
| 52 | return err | 52 | return err | ||
| 53 | } | 53 | } | ||
| 54 | - | if err := applyTextEdits(path, changes); err != nil { | 54 | + | target, err := filepath.EvalSymlinks(path) |
| 55 | + | if err != nil { | |||
| 56 | + | return fmt.Errorf("workspace edit target %q: %w", path, err) | |||
| 57 | + | } | |||
| 58 | + | if err := safeWorkspacePath(rootReal, target); err != nil { | |||
| 59 | + | return err | |||
| 60 | + | } | |||
| 61 | + | if err := applyTextEdits(target, changes); err != nil { | |||
| 55 | return fmt.Errorf("apply edit to %s: %w", path, err) | 62 | return fmt.Errorf("apply edit to %s: %w", path, err) | ||
| 56 | } | 63 | } | ||
| 57 | } | 64 | } | ||
M packages/agent/lsp/lsp_test.go +106 -0
| @@ -5,8 +5,10 @@ import ( | |||
| 5 | 5 | "bytes" | |
| 6 | 6 | "context" | |
| 7 | 7 | "encoding/json" | |
| 8 | + | "io" | |
| 8 | 9 | "os" | |
| 9 | 10 | "path/filepath" | |
| 11 | + | "runtime" | |
| 10 | 12 | "strings" | |
| 11 | 13 | "testing" | |
| 12 | 14 | "time" | |
| @@ -81,6 +83,42 @@ func TestClientFakeStdioRPC(t *testing.T) { | |||
| 81 | 83 | } | |
| 82 | 84 | } | |
| 83 | 85 | ||
| 86 | + | func TestFileURIPathRoundTrip(t *testing.T) { | |
| 87 | + | path := filepath.Join(t.TempDir(), "space name", "main.go") | |
| 88 | + | uri, err := pathToURI(path) | |
| 89 | + | if err != nil { | |
| 90 | + | t.Fatal(err) | |
| 91 | + | } | |
| 92 | + | if !strings.HasPrefix(uri, "file:///") { | |
| 93 | + | t.Fatalf("URI = %q, want an absolute file URI", uri) | |
| 94 | + | } | |
| 95 | + | got, err := uriToPath(uri) | |
| 96 | + | if err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | abs, err := filepath.Abs(path) | |
| 100 | + | if err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | if filepath.Clean(got) != filepath.Clean(abs) { | |
| 104 | + | t.Fatalf("round-trip path = %q, want %q", got, abs) | |
| 105 | + | } | |
| 106 | + | if runtime.GOOS == "windows" { | |
| 107 | + | unc := `\\server\share\main.go` | |
| 108 | + | uncURI, err := pathToURI(unc) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | if !strings.HasPrefix(uncURI, "file://server/") { | |
| 113 | + | t.Fatalf("UNC URI = %q", uncURI) | |
| 114 | + | } | |
| 115 | + | uncPath, err := uriToPath(uncURI) | |
| 116 | + | if err != nil || filepath.Clean(uncPath) != filepath.Clean(unc) { | |
| 117 | + | t.Fatalf("UNC round trip = %q, err = %v", uncPath, err) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 84 | 122 | func TestFramingRoundTripWithAdditionalHeader(t *testing.T) { | |
| 85 | 123 | payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`) | |
| 86 | 124 | wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...) | |
| @@ -186,6 +224,9 @@ func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) { | |||
| 186 | 224 | } | |
| 187 | 225 | ||
| 188 | 226 | func TestResolveCommandPrefersWorkspaceTools(t *testing.T) { | |
| 227 | + | if runtime.GOOS == "windows" { | |
| 228 | + | t.Skip("executable bits are not represented on Windows") | |
| 229 | + | } | |
| 189 | 230 | root := t.TempDir() | |
| 190 | 231 | bin := filepath.Join(root, "node_modules", ".bin") | |
| 191 | 232 | if err := os.MkdirAll(bin, 0o755); err != nil { | |
| @@ -226,6 +267,9 @@ func TestParsers(t *testing.T) { | |||
| 226 | 267 | if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" { | |
| 227 | 268 | t.Fatalf("generic = %#v", got) | |
| 228 | 269 | } | |
| 270 | + | if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: cannot find name"); len(got) != 1 || got[0].Message != "cannot find name" { | |
| 271 | + | t.Fatalf("generic message = %#v", got) | |
| 272 | + | } | |
| 229 | 273 | } | |
| 230 | 274 | ||
| 231 | 275 | func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) { | |
| @@ -278,6 +322,51 @@ func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) { | |||
| 278 | 322 | } | |
| 279 | 323 | } | |
| 280 | 324 | ||
| 325 | + | func TestApplyWorkspaceEditFollowsInWorkspaceSymlink(t *testing.T) { | |
| 326 | + | if runtime.GOOS == "windows" { | |
| 327 | + | t.Skip("symlink permissions vary on Windows") | |
| 328 | + | } | |
| 329 | + | root := t.TempDir() | |
| 330 | + | target := filepath.Join(root, "target.txt") | |
| 331 | + | link := filepath.Join(root, "link.txt") | |
| 332 | + | if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil { | |
| 333 | + | t.Fatal(err) | |
| 334 | + | } | |
| 335 | + | if err := os.Symlink(target, link); err != nil { | |
| 336 | + | t.Fatal(err) | |
| 337 | + | } | |
| 338 | + | edit := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(link): {{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 3}}, NewText: "new"}}}} | |
| 339 | + | if err := ApplyWorkspaceEdit(root, edit); err != nil { | |
| 340 | + | t.Fatal(err) | |
| 341 | + | } | |
| 342 | + | data, err := os.ReadFile(target) | |
| 343 | + | if err != nil { | |
| 344 | + | t.Fatal(err) | |
| 345 | + | } | |
| 346 | + | if string(data) != "new\n" { | |
| 347 | + | t.Fatalf("target data = %q", data) | |
| 348 | + | } | |
| 349 | + | info, err := os.Lstat(link) | |
| 350 | + | if err != nil { | |
| 351 | + | t.Fatal(err) | |
| 352 | + | } | |
| 353 | + | if info.Mode()&os.ModeSymlink == 0 { | |
| 354 | + | t.Fatal("workspace edit replaced the symlink") | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func TestLimitedBufferCapsReaderCopy(t *testing.T) { | |
| 359 | + | var buffer limitedBuffer | |
| 360 | + | buffer.limit = 4 | |
| 361 | + | copied, err := io.Copy(&buffer, strings.NewReader("123456")) | |
| 362 | + | if err != nil { | |
| 363 | + | t.Fatal(err) | |
| 364 | + | } | |
| 365 | + | if copied != 6 || buffer.String() != "1234" { | |
| 366 | + | t.Fatalf("copied %d bytes into %q", copied, buffer.String()) | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | ||
| 281 | 370 | func TestDiagnosticJSONCodeNumber(t *testing.T) { | |
| 282 | 371 | var diagnostic Diagnostic | |
| 283 | 372 | 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 { | |
| @@ -288,6 +377,23 @@ func TestDiagnosticJSONCodeNumber(t *testing.T) { | |||
| 288 | 377 | } | |
| 289 | 378 | } | |
| 290 | 379 | ||
| 380 | + | func TestManagerClosePreventsNewWorkspaces(t *testing.T) { | |
| 381 | + | manager := NewManager() | |
| 382 | + | root := t.TempDir() | |
| 383 | + | if err := manager.Close(); err != nil { | |
| 384 | + | t.Fatal(err) | |
| 385 | + | } | |
| 386 | + | if err := manager.Close(); err != nil { | |
| 387 | + | t.Fatal(err) | |
| 388 | + | } | |
| 389 | + | if _, err := manager.Config(root); err == nil { | |
| 390 | + | t.Fatal("closed manager accepted a new workspace") | |
| 391 | + | } | |
| 392 | + | if err := manager.Reload(root); err == nil { | |
| 393 | + | t.Fatal("closed manager accepted a reload") | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | ||
| 291 | 397 | func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) { | |
| 292 | 398 | manager := NewManager() | |
| 293 | 399 | root := t.TempDir() | |
| @@ -5,8 +5,10 @@ import ( | |||||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "encoding/json" | 7 | "encoding/json" | ||
| 8 | + | "io" | |||
| 8 | "os" | 9 | "os" | ||
| 9 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | + | "runtime" | |||
| 10 | "strings" | 12 | "strings" | ||
| 11 | "testing" | 13 | "testing" | ||
| 12 | "time" | 14 | "time" | ||
| @@ -81,6 +83,42 @@ func TestClientFakeStdioRPC(t *testing.T) { | |||||
| 81 | } | 83 | } | ||
| 82 | } | 84 | } | ||
| 83 | 85 | ||||
| 86 | + | func TestFileURIPathRoundTrip(t *testing.T) { | |||
| 87 | + | path := filepath.Join(t.TempDir(), "space name", "main.go") | |||
| 88 | + | uri, err := pathToURI(path) | |||
| 89 | + | if err != nil { | |||
| 90 | + | t.Fatal(err) | |||
| 91 | + | } | |||
| 92 | + | if !strings.HasPrefix(uri, "file:///") { | |||
| 93 | + | t.Fatalf("URI = %q, want an absolute file URI", uri) | |||
| 94 | + | } | |||
| 95 | + | got, err := uriToPath(uri) | |||
| 96 | + | if err != nil { | |||
| 97 | + | t.Fatal(err) | |||
| 98 | + | } | |||
| 99 | + | abs, err := filepath.Abs(path) | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | if filepath.Clean(got) != filepath.Clean(abs) { | |||
| 104 | + | t.Fatalf("round-trip path = %q, want %q", got, abs) | |||
| 105 | + | } | |||
| 106 | + | if runtime.GOOS == "windows" { | |||
| 107 | + | unc := `\\server\share\main.go` | |||
| 108 | + | uncURI, err := pathToURI(unc) | |||
| 109 | + | if err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | if !strings.HasPrefix(uncURI, "file://server/") { | |||
| 113 | + | t.Fatalf("UNC URI = %q", uncURI) | |||
| 114 | + | } | |||
| 115 | + | uncPath, err := uriToPath(uncURI) | |||
| 116 | + | if err != nil || filepath.Clean(uncPath) != filepath.Clean(unc) { | |||
| 117 | + | t.Fatalf("UNC round trip = %q, err = %v", uncPath, err) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 84 | func TestFramingRoundTripWithAdditionalHeader(t *testing.T) { | 122 | func TestFramingRoundTripWithAdditionalHeader(t *testing.T) { | ||
| 85 | payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`) | 123 | payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`) | ||
| 86 | wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...) | 124 | wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...) | ||
| @@ -186,6 +224,9 @@ func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) { | |||||
| 186 | } | 224 | } | ||
| 187 | 225 | ||||
| 188 | func TestResolveCommandPrefersWorkspaceTools(t *testing.T) { | 226 | func TestResolveCommandPrefersWorkspaceTools(t *testing.T) { | ||
| 227 | + | if runtime.GOOS == "windows" { | |||
| 228 | + | t.Skip("executable bits are not represented on Windows") | |||
| 229 | + | } | |||
| 189 | root := t.TempDir() | 230 | root := t.TempDir() | ||
| 190 | bin := filepath.Join(root, "node_modules", ".bin") | 231 | bin := filepath.Join(root, "node_modules", ".bin") | ||
| 191 | if err := os.MkdirAll(bin, 0o755); err != nil { | 232 | if err := os.MkdirAll(bin, 0o755); err != nil { | ||
| @@ -226,6 +267,9 @@ func TestParsers(t *testing.T) { | |||||
| 226 | if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" { | 267 | if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" { | ||
| 227 | t.Fatalf("generic = %#v", got) | 268 | t.Fatalf("generic = %#v", got) | ||
| 228 | } | 269 | } | ||
| 270 | + | if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: cannot find name"); len(got) != 1 || got[0].Message != "cannot find name" { | |||
| 271 | + | t.Fatalf("generic message = %#v", got) | |||
| 272 | + | } | |||
| 229 | } | 273 | } | ||
| 230 | 274 | ||||
| 231 | func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) { | 275 | func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) { | ||
| @@ -278,6 +322,51 @@ func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) { | |||||
| 278 | } | 322 | } | ||
| 279 | } | 323 | } | ||
| 280 | 324 | ||||
| 325 | + | func TestApplyWorkspaceEditFollowsInWorkspaceSymlink(t *testing.T) { | |||
| 326 | + | if runtime.GOOS == "windows" { | |||
| 327 | + | t.Skip("symlink permissions vary on Windows") | |||
| 328 | + | } | |||
| 329 | + | root := t.TempDir() | |||
| 330 | + | target := filepath.Join(root, "target.txt") | |||
| 331 | + | link := filepath.Join(root, "link.txt") | |||
| 332 | + | if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil { | |||
| 333 | + | t.Fatal(err) | |||
| 334 | + | } | |||
| 335 | + | if err := os.Symlink(target, link); err != nil { | |||
| 336 | + | t.Fatal(err) | |||
| 337 | + | } | |||
| 338 | + | edit := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(link): {{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 3}}, NewText: "new"}}}} | |||
| 339 | + | if err := ApplyWorkspaceEdit(root, edit); err != nil { | |||
| 340 | + | t.Fatal(err) | |||
| 341 | + | } | |||
| 342 | + | data, err := os.ReadFile(target) | |||
| 343 | + | if err != nil { | |||
| 344 | + | t.Fatal(err) | |||
| 345 | + | } | |||
| 346 | + | if string(data) != "new\n" { | |||
| 347 | + | t.Fatalf("target data = %q", data) | |||
| 348 | + | } | |||
| 349 | + | info, err := os.Lstat(link) | |||
| 350 | + | if err != nil { | |||
| 351 | + | t.Fatal(err) | |||
| 352 | + | } | |||
| 353 | + | if info.Mode()&os.ModeSymlink == 0 { | |||
| 354 | + | t.Fatal("workspace edit replaced the symlink") | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func TestLimitedBufferCapsReaderCopy(t *testing.T) { | |||
| 359 | + | var buffer limitedBuffer | |||
| 360 | + | buffer.limit = 4 | |||
| 361 | + | copied, err := io.Copy(&buffer, strings.NewReader("123456")) | |||
| 362 | + | if err != nil { | |||
| 363 | + | t.Fatal(err) | |||
| 364 | + | } | |||
| 365 | + | if copied != 6 || buffer.String() != "1234" { | |||
| 366 | + | t.Fatalf("copied %d bytes into %q", copied, buffer.String()) | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | ||||
| 281 | func TestDiagnosticJSONCodeNumber(t *testing.T) { | 370 | func TestDiagnosticJSONCodeNumber(t *testing.T) { | ||
| 282 | var diagnostic Diagnostic | 371 | var diagnostic Diagnostic | ||
| 283 | 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 { | 372 | 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 { | ||
| @@ -288,6 +377,23 @@ func TestDiagnosticJSONCodeNumber(t *testing.T) { | |||||
| 288 | } | 377 | } | ||
| 289 | } | 378 | } | ||
| 290 | 379 | ||||
| 380 | + | func TestManagerClosePreventsNewWorkspaces(t *testing.T) { | |||
| 381 | + | manager := NewManager() | |||
| 382 | + | root := t.TempDir() | |||
| 383 | + | if err := manager.Close(); err != nil { | |||
| 384 | + | t.Fatal(err) | |||
| 385 | + | } | |||
| 386 | + | if err := manager.Close(); err != nil { | |||
| 387 | + | t.Fatal(err) | |||
| 388 | + | } | |||
| 389 | + | if _, err := manager.Config(root); err == nil { | |||
| 390 | + | t.Fatal("closed manager accepted a new workspace") | |||
| 391 | + | } | |||
| 392 | + | if err := manager.Reload(root); err == nil { | |||
| 393 | + | t.Fatal("closed manager accepted a reload") | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 396 | + | ||||
| 291 | func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) { | 397 | func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) { | ||
| 292 | manager := NewManager() | 398 | manager := NewManager() | ||
| 293 | root := t.TempDir() | 399 | root := t.TempDir() | ||
M packages/agent/lsp/manager.go +67 -20
| @@ -3,6 +3,8 @@ package lsp | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "encoding/hex" | |
| 6 | 8 | "encoding/json" | |
| 7 | 9 | "errors" | |
| 8 | 10 | "fmt" | |
| @@ -33,6 +35,7 @@ type Response struct { | |||
| 33 | 35 | ||
| 34 | 36 | type workspace struct { | |
| 35 | 37 | cwd string | |
| 38 | + | rootReal string | |
| 36 | 39 | config Config | |
| 37 | 40 | clients map[string]*Client | |
| 38 | 41 | starting map[string]chan struct{} | |
| @@ -52,8 +55,11 @@ type Manager struct { | |||
| 52 | 55 | mu sync.Mutex | |
| 53 | 56 | workspaces map[string]*workspace | |
| 54 | 57 | options ManagerOptions | |
| 58 | + | closed bool | |
| 55 | 59 | } | |
| 56 | 60 | ||
| 61 | + | var errManagerClosed = errors.New("LSP manager is closed") | |
| 62 | + | ||
| 57 | 63 | func NewManager() *Manager { | |
| 58 | 64 | return NewManagerWithOptions(ManagerOptions{}) | |
| 59 | 65 | } | |
| @@ -77,6 +83,10 @@ func (m *Manager) workspace(cwd string) (*workspace, error) { | |||
| 77 | 83 | return nil, err | |
| 78 | 84 | } | |
| 79 | 85 | m.mu.Lock() | |
| 86 | + | if m.closed { | |
| 87 | + | m.mu.Unlock() | |
| 88 | + | return nil, errManagerClosed | |
| 89 | + | } | |
| 80 | 90 | if value := m.workspaces[cwd]; value != nil { | |
| 81 | 91 | m.mu.Unlock() | |
| 82 | 92 | return value, nil | |
| @@ -86,14 +96,21 @@ func (m *Manager) workspace(cwd string) (*workspace, error) { | |||
| 86 | 96 | if err != nil { | |
| 87 | 97 | return nil, err | |
| 88 | 98 | } | |
| 89 | - | value := &workspace{cwd: cwd, 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)} | |
| 99 | + | rootReal := cwd | |
| 100 | + | if resolved, resolveErr := filepath.EvalSymlinks(cwd); resolveErr == nil { | |
| 101 | + | rootReal = resolved | |
| 102 | + | } | |
| 103 | + | 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)} | |
| 90 | 104 | m.mu.Lock() | |
| 105 | + | defer m.mu.Unlock() | |
| 106 | + | if m.closed { | |
| 107 | + | return nil, errManagerClosed | |
| 108 | + | } | |
| 91 | 109 | if existing := m.workspaces[cwd]; existing != nil { | |
| 92 | 110 | value = existing | |
| 93 | 111 | } else { | |
| 94 | 112 | m.workspaces[cwd] = value | |
| 95 | 113 | } | |
| 96 | - | m.mu.Unlock() | |
| 97 | 114 | return value, nil | |
| 98 | 115 | } | |
| 99 | 116 | ||
| @@ -116,6 +133,10 @@ func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerCo | |||
| 116 | 133 | } | |
| 117 | 134 | for { | |
| 118 | 135 | m.mu.Lock() | |
| 136 | + | if m.closed || m.workspaces[ws.cwd] != ws { | |
| 137 | + | m.mu.Unlock() | |
| 138 | + | return nil, errManagerClosed | |
| 139 | + | } | |
| 119 | 140 | if client := ws.clients[spec.ID]; client != nil { | |
| 120 | 141 | m.mu.Unlock() | |
| 121 | 142 | return client, nil | |
| @@ -151,11 +172,20 @@ func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerCo | |||
| 151 | 172 | delete(ws.starting, spec.ID) | |
| 152 | 173 | close(starting) | |
| 153 | 174 | if err != nil { | |
| 175 | + | if m.closed || m.workspaces[ws.cwd] != ws { | |
| 176 | + | m.mu.Unlock() | |
| 177 | + | return nil, errManagerClosed | |
| 178 | + | } | |
| 154 | 179 | ws.statuses[spec.ID] = "missing" | |
| 155 | 180 | ws.errors[spec.ID] = err.Error() | |
| 156 | 181 | m.mu.Unlock() | |
| 157 | 182 | return nil, err | |
| 158 | 183 | } | |
| 184 | + | if m.closed || m.workspaces[ws.cwd] != ws { | |
| 185 | + | m.mu.Unlock() | |
| 186 | + | _ = client.Close() | |
| 187 | + | return nil, errManagerClosed | |
| 188 | + | } | |
| 159 | 189 | if existing := ws.clients[spec.ID]; existing != nil { | |
| 160 | 190 | m.mu.Unlock() | |
| 161 | 191 | _ = client.Close() | |
| @@ -186,7 +216,7 @@ func wsSettings(global, local map[string]any) map[string]any { | |||
| 186 | 216 | func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) { | |
| 187 | 217 | byPath := make(map[string][]Diagnostic) | |
| 188 | 218 | for _, diagnostic := range diagnostics { | |
| 189 | - | if diagnostic.Path == "" || !diagnosticInWorkspace(ws.cwd, diagnostic.Path) { | |
| 219 | + | if diagnostic.Path == "" || !diagnosticInWorkspace(ws.rootReal, diagnostic.Path) { | |
| 190 | 220 | continue | |
| 191 | 221 | } | |
| 192 | 222 | byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic) | |
| @@ -249,7 +279,7 @@ func (m *Manager) Open(ctx context.Context, cwd, path string) error { | |||
| 249 | 279 | } | |
| 250 | 280 | continue | |
| 251 | 281 | } | |
| 252 | - | hash := string(text) | |
| 282 | + | hash := contentHash(text) | |
| 253 | 283 | m.mu.Lock() | |
| 254 | 284 | if ws.opened[spec.ID] == nil { | |
| 255 | 285 | ws.opened[spec.ID] = make(map[string]string) | |
| @@ -574,6 +604,10 @@ func (m *Manager) Reload(cwd string) error { | |||
| 574 | 604 | return err | |
| 575 | 605 | } | |
| 576 | 606 | m.mu.Lock() | |
| 607 | + | if m.closed { | |
| 608 | + | m.mu.Unlock() | |
| 609 | + | return errManagerClosed | |
| 610 | + | } | |
| 577 | 611 | ws := m.workspaces[cwd] | |
| 578 | 612 | delete(m.workspaces, cwd) | |
| 579 | 613 | m.mu.Unlock() | |
| @@ -597,6 +631,11 @@ func (m *Manager) clientsSnapshot(ws *workspace) []*Client { | |||
| 597 | 631 | ||
| 598 | 632 | func (m *Manager) Close() error { | |
| 599 | 633 | m.mu.Lock() | |
| 634 | + | if m.closed { | |
| 635 | + | m.mu.Unlock() | |
| 636 | + | return nil | |
| 637 | + | } | |
| 638 | + | m.closed = true | |
| 600 | 639 | workspaces := make([]*workspace, 0, len(m.workspaces)) | |
| 601 | 640 | for _, ws := range m.workspaces { | |
| 602 | 641 | workspaces = append(workspaces, ws) | |
| @@ -657,7 +696,13 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error { | |||
| 657 | 696 | } | |
| 658 | 697 | if strings.Contains(arg, "{files}") { | |
| 659 | 698 | containsFiles = true | |
| 660 | - | args = append(args, strings.ReplaceAll(arg, "{files}", strings.Join(relative, " "))) | |
| 699 | + | if len(relative) == 0 { | |
| 700 | + | args = append(args, strings.ReplaceAll(arg, "{files}", "")) | |
| 701 | + | } else { | |
| 702 | + | for _, file := range relative { | |
| 703 | + | args = append(args, strings.ReplaceAll(arg, "{files}", file)) | |
| 704 | + | } | |
| 705 | + | } | |
| 661 | 706 | continue | |
| 662 | 707 | } | |
| 663 | 708 | args = append(args, arg) | |
| @@ -667,7 +712,7 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error { | |||
| 667 | 712 | args = append(args, ".") | |
| 668 | 713 | } | |
| 669 | 714 | } else if !containsFiles { | |
| 670 | - | args = append(args, relativeFiles(ws.cwd, files)...) | |
| 715 | + | args = append(args, relative...) | |
| 671 | 716 | } | |
| 672 | 717 | commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout) | |
| 673 | 718 | cmd := exec.CommandContext(commandCtx, command, args...) | |
| @@ -690,7 +735,7 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error { | |||
| 690 | 735 | diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text) | |
| 691 | 736 | filtered := diagnostics[:0] | |
| 692 | 737 | for _, diagnostic := range diagnostics { | |
| 693 | - | if diagnostic.Path != "" && diagnosticInWorkspace(ws.cwd, diagnostic.Path) { | |
| 738 | + | if diagnostic.Path != "" && diagnosticInWorkspace(ws.rootReal, diagnostic.Path) { | |
| 694 | 739 | filtered = append(filtered, diagnostic) | |
| 695 | 740 | } | |
| 696 | 741 | } | |
| @@ -712,6 +757,11 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error { | |||
| 712 | 757 | return firstErr | |
| 713 | 758 | } | |
| 714 | 759 | ||
| 760 | + | func contentHash(data []byte) string { | |
| 761 | + | sum := sha256.Sum256(data) | |
| 762 | + | return hex.EncodeToString(sum[:]) | |
| 763 | + | } | |
| 764 | + | ||
| 715 | 765 | func displayWorkspacePath(root, path string) string { | |
| 716 | 766 | if path == "" { | |
| 717 | 767 | return "" | |
| @@ -747,14 +797,7 @@ func absoluteFile(cwd, path string) (string, error) { | |||
| 747 | 797 | } | |
| 748 | 798 | return filepath.Abs(path) | |
| 749 | 799 | } | |
| 750 | - | func diagnosticInWorkspace(root, path string) bool { | |
| 751 | - | rootReal, err := filepath.EvalSymlinks(root) | |
| 752 | - | if err != nil { | |
| 753 | - | rootReal, err = filepath.Abs(root) | |
| 754 | - | if err != nil { | |
| 755 | - | return false | |
| 756 | - | } | |
| 757 | - | } | |
| 800 | + | func diagnosticInWorkspace(rootReal, path string) bool { | |
| 758 | 801 | return safeWorkspacePath(rootReal, path) == nil | |
| 759 | 802 | } | |
| 760 | 803 | ||
| @@ -765,19 +808,23 @@ func samePath(a, b string) bool { | |||
| 765 | 808 | } | |
| 766 | 809 | ||
| 767 | 810 | type limitedBuffer struct { | |
| 768 | - | bytes.Buffer | |
| 769 | - | limit int | |
| 811 | + | buffer bytes.Buffer | |
| 812 | + | limit int | |
| 770 | 813 | } | |
| 771 | 814 | ||
| 815 | + | func (b *limitedBuffer) Len() int { return b.buffer.Len() } | |
| 816 | + | ||
| 817 | + | func (b *limitedBuffer) String() string { return b.buffer.String() } | |
| 818 | + | ||
| 772 | 819 | func (b *limitedBuffer) Write(p []byte) (int, error) { | |
| 773 | 820 | original := len(p) | |
| 774 | - | if b.limit <= b.Len() { | |
| 821 | + | if b.limit <= b.buffer.Len() { | |
| 775 | 822 | return original, nil | |
| 776 | 823 | } | |
| 777 | - | room := b.limit - b.Len() | |
| 824 | + | room := b.limit - b.buffer.Len() | |
| 778 | 825 | if len(p) > room { | |
| 779 | 826 | p = p[:room] | |
| 780 | 827 | } | |
| 781 | - | _, _ = b.Buffer.Write(p) | |
| 828 | + | _, _ = b.buffer.Write(p) | |
| 782 | 829 | return original, nil | |
| 783 | 830 | } | |
| @@ -3,6 +3,8 @@ package lsp | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "crypto/sha256" | |||
| 7 | + | "encoding/hex" | |||
| 6 | "encoding/json" | 8 | "encoding/json" | ||
| 7 | "errors" | 9 | "errors" | ||
| 8 | "fmt" | 10 | "fmt" | ||
| @@ -33,6 +35,7 @@ type Response struct { | |||||
| 33 | 35 | ||||
| 34 | type workspace struct { | 36 | type workspace struct { | ||
| 35 | cwd string | 37 | cwd string | ||
| 38 | + | rootReal string | |||
| 36 | config Config | 39 | config Config | ||
| 37 | clients map[string]*Client | 40 | clients map[string]*Client | ||
| 38 | starting map[string]chan struct{} | 41 | starting map[string]chan struct{} | ||
| @@ -52,8 +55,11 @@ type Manager struct { | |||||
| 52 | mu sync.Mutex | 55 | mu sync.Mutex | ||
| 53 | workspaces map[string]*workspace | 56 | workspaces map[string]*workspace | ||
| 54 | options ManagerOptions | 57 | options ManagerOptions | ||
| 58 | + | closed bool | |||
| 55 | } | 59 | } | ||
| 56 | 60 | ||||
| 61 | + | var errManagerClosed = errors.New("LSP manager is closed") | |||
| 62 | + | ||||
| 57 | func NewManager() *Manager { | 63 | func NewManager() *Manager { | ||
| 58 | return NewManagerWithOptions(ManagerOptions{}) | 64 | return NewManagerWithOptions(ManagerOptions{}) | ||
| 59 | } | 65 | } | ||
| @@ -77,6 +83,10 @@ func (m *Manager) workspace(cwd string) (*workspace, error) { | |||||
| 77 | return nil, err | 83 | return nil, err | ||
| 78 | } | 84 | } | ||
| 79 | m.mu.Lock() | 85 | m.mu.Lock() | ||
| 86 | + | if m.closed { | |||
| 87 | + | m.mu.Unlock() | |||
| 88 | + | return nil, errManagerClosed | |||
| 89 | + | } | |||
| 80 | if value := m.workspaces[cwd]; value != nil { | 90 | if value := m.workspaces[cwd]; value != nil { | ||
| 81 | m.mu.Unlock() | 91 | m.mu.Unlock() | ||
| 82 | return value, nil | 92 | return value, nil | ||
| @@ -86,14 +96,21 @@ func (m *Manager) workspace(cwd string) (*workspace, error) { | |||||
| 86 | if err != nil { | 96 | if err != nil { | ||
| 87 | return nil, err | 97 | return nil, err | ||
| 88 | } | 98 | } | ||
| 89 | - | value := &workspace{cwd: cwd, 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)} | 99 | + | rootReal := cwd |
| 100 | + | if resolved, resolveErr := filepath.EvalSymlinks(cwd); resolveErr == nil { | |||
| 101 | + | rootReal = resolved | |||
| 102 | + | } | |||
| 103 | + | 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)} | |||
| 90 | m.mu.Lock() | 104 | m.mu.Lock() | ||
| 105 | + | defer m.mu.Unlock() | |||
| 106 | + | if m.closed { | |||
| 107 | + | return nil, errManagerClosed | |||
| 108 | + | } | |||
| 91 | if existing := m.workspaces[cwd]; existing != nil { | 109 | if existing := m.workspaces[cwd]; existing != nil { | ||
| 92 | value = existing | 110 | value = existing | ||
| 93 | } else { | 111 | } else { | ||
| 94 | m.workspaces[cwd] = value | 112 | m.workspaces[cwd] = value | ||
| 95 | } | 113 | } | ||
| 96 | - | m.mu.Unlock() | |||
| 97 | return value, nil | 114 | return value, nil | ||
| 98 | } | 115 | } | ||
| 99 | 116 | ||||
| @@ -116,6 +133,10 @@ func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerCo | |||||
| 116 | } | 133 | } | ||
| 117 | for { | 134 | for { | ||
| 118 | m.mu.Lock() | 135 | m.mu.Lock() | ||
| 136 | + | if m.closed || m.workspaces[ws.cwd] != ws { | |||
| 137 | + | m.mu.Unlock() | |||
| 138 | + | return nil, errManagerClosed | |||
| 139 | + | } | |||
| 119 | if client := ws.clients[spec.ID]; client != nil { | 140 | if client := ws.clients[spec.ID]; client != nil { | ||
| 120 | m.mu.Unlock() | 141 | m.mu.Unlock() | ||
| 121 | return client, nil | 142 | return client, nil | ||
| @@ -151,11 +172,20 @@ func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerCo | |||||
| 151 | delete(ws.starting, spec.ID) | 172 | delete(ws.starting, spec.ID) | ||
| 152 | close(starting) | 173 | close(starting) | ||
| 153 | if err != nil { | 174 | if err != nil { | ||
| 175 | + | if m.closed || m.workspaces[ws.cwd] != ws { | |||
| 176 | + | m.mu.Unlock() | |||
| 177 | + | return nil, errManagerClosed | |||
| 178 | + | } | |||
| 154 | ws.statuses[spec.ID] = "missing" | 179 | ws.statuses[spec.ID] = "missing" | ||
| 155 | ws.errors[spec.ID] = err.Error() | 180 | ws.errors[spec.ID] = err.Error() | ||
| 156 | m.mu.Unlock() | 181 | m.mu.Unlock() | ||
| 157 | return nil, err | 182 | return nil, err | ||
| 158 | } | 183 | } | ||
| 184 | + | if m.closed || m.workspaces[ws.cwd] != ws { | |||
| 185 | + | m.mu.Unlock() | |||
| 186 | + | _ = client.Close() | |||
| 187 | + | return nil, errManagerClosed | |||
| 188 | + | } | |||
| 159 | if existing := ws.clients[spec.ID]; existing != nil { | 189 | if existing := ws.clients[spec.ID]; existing != nil { | ||
| 160 | m.mu.Unlock() | 190 | m.mu.Unlock() | ||
| 161 | _ = client.Close() | 191 | _ = client.Close() | ||
| @@ -186,7 +216,7 @@ func wsSettings(global, local map[string]any) map[string]any { | |||||
| 186 | func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) { | 216 | func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) { | ||
| 187 | byPath := make(map[string][]Diagnostic) | 217 | byPath := make(map[string][]Diagnostic) | ||
| 188 | for _, diagnostic := range diagnostics { | 218 | for _, diagnostic := range diagnostics { | ||
| 189 | - | if diagnostic.Path == "" || !diagnosticInWorkspace(ws.cwd, diagnostic.Path) { | 219 | + | if diagnostic.Path == "" || !diagnosticInWorkspace(ws.rootReal, diagnostic.Path) { |
| 190 | continue | 220 | continue | ||
| 191 | } | 221 | } | ||
| 192 | byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic) | 222 | byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic) | ||
| @@ -249,7 +279,7 @@ func (m *Manager) Open(ctx context.Context, cwd, path string) error { | |||||
| 249 | } | 279 | } | ||
| 250 | continue | 280 | continue | ||
| 251 | } | 281 | } | ||
| 252 | - | hash := string(text) | 282 | + | hash := contentHash(text) |
| 253 | m.mu.Lock() | 283 | m.mu.Lock() | ||
| 254 | if ws.opened[spec.ID] == nil { | 284 | if ws.opened[spec.ID] == nil { | ||
| 255 | ws.opened[spec.ID] = make(map[string]string) | 285 | ws.opened[spec.ID] = make(map[string]string) | ||
| @@ -574,6 +604,10 @@ func (m *Manager) Reload(cwd string) error { | |||||
| 574 | return err | 604 | return err | ||
| 575 | } | 605 | } | ||
| 576 | m.mu.Lock() | 606 | m.mu.Lock() | ||
| 607 | + | if m.closed { | |||
| 608 | + | m.mu.Unlock() | |||
| 609 | + | return errManagerClosed | |||
| 610 | + | } | |||
| 577 | ws := m.workspaces[cwd] | 611 | ws := m.workspaces[cwd] | ||
| 578 | delete(m.workspaces, cwd) | 612 | delete(m.workspaces, cwd) | ||
| 579 | m.mu.Unlock() | 613 | m.mu.Unlock() | ||
| @@ -597,6 +631,11 @@ func (m *Manager) clientsSnapshot(ws *workspace) []*Client { | |||||
| 597 | 631 | ||||
| 598 | func (m *Manager) Close() error { | 632 | func (m *Manager) Close() error { | ||
| 599 | m.mu.Lock() | 633 | m.mu.Lock() | ||
| 634 | + | if m.closed { | |||
| 635 | + | m.mu.Unlock() | |||
| 636 | + | return nil | |||
| 637 | + | } | |||
| 638 | + | m.closed = true | |||
| 600 | workspaces := make([]*workspace, 0, len(m.workspaces)) | 639 | workspaces := make([]*workspace, 0, len(m.workspaces)) | ||
| 601 | for _, ws := range m.workspaces { | 640 | for _, ws := range m.workspaces { | ||
| 602 | workspaces = append(workspaces, ws) | 641 | workspaces = append(workspaces, ws) | ||
| @@ -657,7 +696,13 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error { | |||||
| 657 | } | 696 | } | ||
| 658 | if strings.Contains(arg, "{files}") { | 697 | if strings.Contains(arg, "{files}") { | ||
| 659 | containsFiles = true | 698 | containsFiles = true | ||
| 660 | - | args = append(args, strings.ReplaceAll(arg, "{files}", strings.Join(relative, " "))) | 699 | + | if len(relative) == 0 { |
| 700 | + | args = append(args, strings.ReplaceAll(arg, "{files}", "")) | |||
| 701 | + | } else { | |||
| 702 | + | for _, file := range relative { | |||
| 703 | + | args = append(args, strings.ReplaceAll(arg, "{files}", file)) | |||
| 704 | + | } | |||
| 705 | + | } | |||
| 661 | continue | 706 | continue | ||
| 662 | } | 707 | } | ||
| 663 | args = append(args, arg) | 708 | args = append(args, arg) | ||
| @@ -667,7 +712,7 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error { | |||||
| 667 | args = append(args, ".") | 712 | args = append(args, ".") | ||
| 668 | } | 713 | } | ||
| 669 | } else if !containsFiles { | 714 | } else if !containsFiles { | ||
| 670 | - | args = append(args, relativeFiles(ws.cwd, files)...) | 715 | + | args = append(args, relative...) |
| 671 | } | 716 | } | ||
| 672 | commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout) | 717 | commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout) | ||
| 673 | cmd := exec.CommandContext(commandCtx, command, args...) | 718 | cmd := exec.CommandContext(commandCtx, command, args...) | ||
| @@ -690,7 +735,7 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error { | |||||
| 690 | diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text) | 735 | diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text) | ||
| 691 | filtered := diagnostics[:0] | 736 | filtered := diagnostics[:0] | ||
| 692 | for _, diagnostic := range diagnostics { | 737 | for _, diagnostic := range diagnostics { | ||
| 693 | - | if diagnostic.Path != "" && diagnosticInWorkspace(ws.cwd, diagnostic.Path) { | 738 | + | if diagnostic.Path != "" && diagnosticInWorkspace(ws.rootReal, diagnostic.Path) { |
| 694 | filtered = append(filtered, diagnostic) | 739 | filtered = append(filtered, diagnostic) | ||
| 695 | } | 740 | } | ||
| 696 | } | 741 | } | ||
| @@ -712,6 +757,11 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error { | |||||
| 712 | return firstErr | 757 | return firstErr | ||
| 713 | } | 758 | } | ||
| 714 | 759 | ||||
| 760 | + | func contentHash(data []byte) string { | |||
| 761 | + | sum := sha256.Sum256(data) | |||
| 762 | + | return hex.EncodeToString(sum[:]) | |||
| 763 | + | } | |||
| 764 | + | ||||
| 715 | func displayWorkspacePath(root, path string) string { | 765 | func displayWorkspacePath(root, path string) string { | ||
| 716 | if path == "" { | 766 | if path == "" { | ||
| 717 | return "" | 767 | return "" | ||
| @@ -747,14 +797,7 @@ func absoluteFile(cwd, path string) (string, error) { | |||||
| 747 | } | 797 | } | ||
| 748 | return filepath.Abs(path) | 798 | return filepath.Abs(path) | ||
| 749 | } | 799 | } | ||
| 750 | - | func diagnosticInWorkspace(root, path string) bool { | 800 | + | func diagnosticInWorkspace(rootReal, path string) bool { |
| 751 | - | rootReal, err := filepath.EvalSymlinks(root) | |||
| 752 | - | if err != nil { | |||
| 753 | - | rootReal, err = filepath.Abs(root) | |||
| 754 | - | if err != nil { | |||
| 755 | - | return false | |||
| 756 | - | } | |||
| 757 | - | } | |||
| 758 | return safeWorkspacePath(rootReal, path) == nil | 801 | return safeWorkspacePath(rootReal, path) == nil | ||
| 759 | } | 802 | } | ||
| 760 | 803 | ||||
| @@ -765,19 +808,23 @@ func samePath(a, b string) bool { | |||||
| 765 | } | 808 | } | ||
| 766 | 809 | ||||
| 767 | type limitedBuffer struct { | 810 | type limitedBuffer struct { | ||
| 768 | - | bytes.Buffer | 811 | + | buffer bytes.Buffer |
| 769 | - | limit int | 812 | + | limit int |
| 770 | } | 813 | } | ||
| 771 | 814 | ||||
| 815 | + | func (b *limitedBuffer) Len() int { return b.buffer.Len() } | |||
| 816 | + | ||||
| 817 | + | func (b *limitedBuffer) String() string { return b.buffer.String() } | |||
| 818 | + | ||||
| 772 | func (b *limitedBuffer) Write(p []byte) (int, error) { | 819 | func (b *limitedBuffer) Write(p []byte) (int, error) { | ||
| 773 | original := len(p) | 820 | original := len(p) | ||
| 774 | - | if b.limit <= b.Len() { | 821 | + | if b.limit <= b.buffer.Len() { |
| 775 | return original, nil | 822 | return original, nil | ||
| 776 | } | 823 | } | ||
| 777 | - | room := b.limit - b.Len() | 824 | + | room := b.limit - b.buffer.Len() |
| 778 | if len(p) > room { | 825 | if len(p) > room { | ||
| 779 | p = p[:room] | 826 | p = p[:room] | ||
| 780 | } | 827 | } | ||
| 781 | - | _, _ = b.Buffer.Write(p) | 828 | + | _, _ = b.buffer.Write(p) |
| 782 | return original, nil | 829 | return original, nil | ||
| 783 | } | 830 | } | ||
M packages/agent/lsp/parsers.go +16 -5
| @@ -175,7 +175,7 @@ func parseSARIF(provider, cwd, output string) []Diagnostic { | |||
| 175 | 175 | return out | |
| 176 | 176 | } | |
| 177 | 177 | ||
| 178 | - | var genericDiagnosticPattern = regexp.MustCompile(`^\s*(.+?):([0-9]+):([0-9]+)(?::\s*([A-Za-z]+))?\s*[-:]?\s*(.*)$`) | |
| 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 | 179 | ||
| 180 | 180 | func parseGeneric(provider, cwd, output string) []Diagnostic { | |
| 181 | 181 | var out []Diagnostic | |
| @@ -259,10 +259,7 @@ func FormatDiagnostic(d Diagnostic, root string) string { | |||
| 259 | 259 | if d.Code != "" { | |
| 260 | 260 | code = " [" + d.Code + "]" | |
| 261 | 261 | } | |
| 262 | - | message := compactWhitespace(d.Message) | |
| 263 | - | if len(message) > 220 { | |
| 264 | - | message = message[:219] + "…" | |
| 265 | - | } | |
| 262 | + | message := truncateText(compactWhitespace(d.Message), 220) | |
| 266 | 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) | |
| 267 | 264 | } | |
| 268 | 265 | ||
| @@ -274,3 +271,17 @@ func defaultSeverity(value string) string { | |||
| 274 | 271 | } | |
| 275 | 272 | ||
| 276 | 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 | + | } | |
| @@ -175,7 +175,7 @@ func parseSARIF(provider, cwd, output string) []Diagnostic { | |||||
| 175 | return out | 175 | return out | ||
| 176 | } | 176 | } | ||
| 177 | 177 | ||||
| 178 | - | var genericDiagnosticPattern = regexp.MustCompile(`^\s*(.+?):([0-9]+):([0-9]+)(?::\s*([A-Za-z]+))?\s*[-:]?\s*(.*)$`) | 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 | 179 | ||||
| 180 | func parseGeneric(provider, cwd, output string) []Diagnostic { | 180 | func parseGeneric(provider, cwd, output string) []Diagnostic { | ||
| 181 | var out []Diagnostic | 181 | var out []Diagnostic | ||
| @@ -259,10 +259,7 @@ func FormatDiagnostic(d Diagnostic, root string) string { | |||||
| 259 | if d.Code != "" { | 259 | if d.Code != "" { | ||
| 260 | code = " [" + d.Code + "]" | 260 | code = " [" + d.Code + "]" | ||
| 261 | } | 261 | } | ||
| 262 | - | message := compactWhitespace(d.Message) | 262 | + | message := truncateText(compactWhitespace(d.Message), 220) |
| 263 | - | if len(message) > 220 { | |||
| 264 | - | message = message[:219] + "…" | |||
| 265 | - | } | |||
| 266 | 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) | 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) | ||
| 267 | } | 264 | } | ||
| 268 | 265 | ||||
| @@ -274,3 +271,17 @@ func defaultSeverity(value string) string { | |||||
| 274 | } | 271 | } | ||
| 275 | 272 | ||||
| 276 | func compactWhitespace(value string) string { return strings.Join(strings.Fields(value), " ") } | 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 | + | } | |||
M packages/agent/lsp/protocol.go +12 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "net/url" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "runtime" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -120,7 +121,17 @@ func pathToURI(path string) (string, error) { | |||
| 120 | 121 | return "", err | |
| 121 | 122 | } | |
| 122 | 123 | // url.URL escapes spaces and non-ASCII while retaining path separators. | |
| 123 | - | u := &url.URL{Scheme: "file", Path: filepath.ToSlash(abs)} | |
| 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} | |
| 124 | 135 | return u.String(), nil | |
| 125 | 136 | } | |
| 126 | 137 | ||
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "net/url" | 6 | "net/url" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "runtime" | |||
| 8 | "strings" | 9 | "strings" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -120,7 +121,17 @@ func pathToURI(path string) (string, error) { | |||||
| 120 | return "", err | 121 | return "", err | ||
| 121 | } | 122 | } | ||
| 122 | // url.URL escapes spaces and non-ASCII while retaining path separators. | 123 | // url.URL escapes spaces and non-ASCII while retaining path separators. | ||
| 123 | - | u := &url.URL{Scheme: "file", Path: filepath.ToSlash(abs)} | 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} | |||
| 124 | return u.String(), nil | 135 | return u.String(), nil | ||
| 125 | } | 136 | } | ||
| 126 | 137 | ||||
M packages/agent/modes/interactive.go +4 -4
| @@ -4228,8 +4228,6 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4228 | 4228 | i.statusErr = "" | |
| 4229 | 4229 | i.mu.Unlock() | |
| 4230 | 4230 | case "lsp_enabled": | |
| 4231 | - | val := value | |
| 4232 | - | i.cfg.LSPEnabled = &val | |
| 4233 | 4231 | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | |
| 4234 | 4232 | if err := store.SetLSPEnabled(value); err != nil { | |
| 4235 | 4233 | i.mu.Lock() | |
| @@ -4238,6 +4236,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4238 | 4236 | return | |
| 4239 | 4237 | } | |
| 4240 | 4238 | } | |
| 4239 | + | val := value | |
| 4240 | + | i.cfg.LSPEnabled = &val | |
| 4241 | 4241 | if i.cfg.RefreshTools != nil { | |
| 4242 | 4242 | i.cfg.RefreshTools() | |
| 4243 | 4243 | } | |
| @@ -4246,8 +4246,6 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4246 | 4246 | i.statusErr = "" | |
| 4247 | 4247 | i.mu.Unlock() | |
| 4248 | 4248 | case "subagent_lsp_enabled": | |
| 4249 | - | val := value | |
| 4250 | - | i.cfg.SubagentLSPEnabled = &val | |
| 4251 | 4249 | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | |
| 4252 | 4250 | if err := store.SetSubagentLSPEnabled(value); err != nil { | |
| 4253 | 4251 | i.mu.Lock() | |
| @@ -4256,6 +4254,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4256 | 4254 | return | |
| 4257 | 4255 | } | |
| 4258 | 4256 | } | |
| 4257 | + | val := value | |
| 4258 | + | i.cfg.SubagentLSPEnabled = &val | |
| 4259 | 4259 | i.mu.Lock() | |
| 4260 | 4260 | i.statusOK = "lsp in sub-agents " + onOff(value) | |
| 4261 | 4261 | i.statusErr = "" | |
| @@ -4228,8 +4228,6 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4228 | i.statusErr = "" | 4228 | i.statusErr = "" | ||
| 4229 | i.mu.Unlock() | 4229 | i.mu.Unlock() | ||
| 4230 | case "lsp_enabled": | 4230 | case "lsp_enabled": | ||
| 4231 | - | val := value | |||
| 4232 | - | i.cfg.LSPEnabled = &val | |||
| 4233 | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | 4231 | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | ||
| 4234 | if err := store.SetLSPEnabled(value); err != nil { | 4232 | if err := store.SetLSPEnabled(value); err != nil { | ||
| 4235 | i.mu.Lock() | 4233 | i.mu.Lock() | ||
| @@ -4238,6 +4236,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4238 | return | 4236 | return | ||
| 4239 | } | 4237 | } | ||
| 4240 | } | 4238 | } | ||
| 4239 | + | val := value | |||
| 4240 | + | i.cfg.LSPEnabled = &val | |||
| 4241 | if i.cfg.RefreshTools != nil { | 4241 | if i.cfg.RefreshTools != nil { | ||
| 4242 | i.cfg.RefreshTools() | 4242 | i.cfg.RefreshTools() | ||
| 4243 | } | 4243 | } | ||
| @@ -4246,8 +4246,6 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4246 | i.statusErr = "" | 4246 | i.statusErr = "" | ||
| 4247 | i.mu.Unlock() | 4247 | i.mu.Unlock() | ||
| 4248 | case "subagent_lsp_enabled": | 4248 | case "subagent_lsp_enabled": | ||
| 4249 | - | val := value | |||
| 4250 | - | i.cfg.SubagentLSPEnabled = &val | |||
| 4251 | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | 4249 | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | ||
| 4252 | if err := store.SetSubagentLSPEnabled(value); err != nil { | 4250 | if err := store.SetSubagentLSPEnabled(value); err != nil { | ||
| 4253 | i.mu.Lock() | 4251 | i.mu.Lock() | ||
| @@ -4256,6 +4254,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4256 | return | 4254 | return | ||
| 4257 | } | 4255 | } | ||
| 4258 | } | 4256 | } | ||
| 4257 | + | val := value | |||
| 4258 | + | i.cfg.SubagentLSPEnabled = &val | |||
| 4259 | i.mu.Lock() | 4259 | i.mu.Lock() | ||
| 4260 | i.statusOK = "lsp in sub-agents " + onOff(value) | 4260 | i.statusOK = "lsp in sub-agents " + onOff(value) | ||
| 4261 | i.statusErr = "" | 4261 | i.statusErr = "" | ||
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 | + | } | |
| @@ -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 +3 -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 | ) | |
| @@ -112,7 +113,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 112 | 113 | return | |
| 113 | 114 | } | |
| 114 | 115 | resolved.MergeExtensionTools(adapter) | |
| 116 | + | oldTools := ag.Tools | |
| 115 | 117 | ag.SetTools(resolved.ToolRegistry) | |
| 118 | + | _ = tools.CloseLSPManagers(oldTools) | |
| 116 | 119 | }) | |
| 117 | 120 | ||
| 118 | 121 | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/patriceckhart/zot/packages/agent/extensions" | 15 | "github.com/patriceckhart/zot/packages/agent/extensions" | ||
| 16 | "github.com/patriceckhart/zot/packages/agent/extproto" | 16 | "github.com/patriceckhart/zot/packages/agent/extproto" | ||
| 17 | "github.com/patriceckhart/zot/packages/agent/modes" | 17 | "github.com/patriceckhart/zot/packages/agent/modes" | ||
| 18 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 18 | "github.com/patriceckhart/zot/packages/core" | 19 | "github.com/patriceckhart/zot/packages/core" | ||
| 19 | "github.com/patriceckhart/zot/packages/provider" | 20 | "github.com/patriceckhart/zot/packages/provider" | ||
| 20 | ) | 21 | ) | ||
| @@ -112,7 +113,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||||
| 112 | return | 113 | return | ||
| 113 | } | 114 | } | ||
| 114 | resolved.MergeExtensionTools(adapter) | 115 | resolved.MergeExtensionTools(adapter) | ||
| 116 | + | oldTools := ag.Tools | |||
| 115 | ag.SetTools(resolved.ToolRegistry) | 117 | ag.SetTools(resolved.ToolRegistry) | ||
| 118 | + | _ = tools.CloseLSPManagers(oldTools) | |||
| 116 | }) | 119 | }) | ||
| 117 | 120 | ||||
| 118 | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | 121 | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | ||
M packages/agent/settings_store_test.go +6 -0
| @@ -149,4 +149,10 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) { | |||
| 149 | 149 | if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) { | |
| 150 | 150 | t.Fatal("explicitly disabled LSP settings were ignored") | |
| 151 | 151 | } | |
| 152 | + | write, edit := true, true | |
| 153 | + | cfg.LSPDiagnosticsOnWrite = &write | |
| 154 | + | cfg.LSPDiagnosticsOnEdit = &edit | |
| 155 | + | if cfg.LSPDiagnosticsOnWriteEnabled() || cfg.LSPDiagnosticsOnEditEnabled() { | |
| 156 | + | t.Fatal("diagnostics remained enabled with main-session LSP disabled") | |
| 157 | + | } | |
| 152 | 158 | } | |
| @@ -149,4 +149,10 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) { | |||||
| 149 | if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) { | 149 | if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) { | ||
| 150 | t.Fatal("explicitly disabled LSP settings were ignored") | 150 | t.Fatal("explicitly disabled LSP settings were ignored") | ||
| 151 | } | 151 | } | ||
| 152 | + | write, edit := true, true | |||
| 153 | + | cfg.LSPDiagnosticsOnWrite = &write | |||
| 154 | + | cfg.LSPDiagnosticsOnEdit = &edit | |||
| 155 | + | if cfg.LSPDiagnosticsOnWriteEnabled() || cfg.LSPDiagnosticsOnEditEnabled() { | |||
| 156 | + | t.Fatal("diagnostics remained enabled with main-session LSP disabled") | |||
| 157 | + | } | |||
| 152 | } | 158 | } | ||
M packages/agent/tools/lsp.go +6 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "time" | |
| 11 | + | "unicode/utf8" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/patriceckhart/zot/packages/agent/lsp" | |
| 13 | 14 | "github.com/patriceckhart/zot/packages/core" | |
| @@ -336,5 +337,9 @@ func boundLSPText(value string) string { | |||
| 336 | 337 | if len(value) <= maxLSPToolOutput { | |
| 337 | 338 | return value | |
| 338 | 339 | } | |
| 339 | - | return value[:maxLSPToolOutput] + fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput) | |
| 340 | + | cut := value[:maxLSPToolOutput] | |
| 341 | + | for len(cut) > 0 && !utf8.ValidString(cut) { | |
| 342 | + | cut = cut[:len(cut)-1] | |
| 343 | + | } | |
| 344 | + | return cut + fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput) | |
| 340 | 345 | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | + | "unicode/utf8" | |||
| 11 | 12 | ||||
| 12 | "github.com/patriceckhart/zot/packages/agent/lsp" | 13 | "github.com/patriceckhart/zot/packages/agent/lsp" | ||
| 13 | "github.com/patriceckhart/zot/packages/core" | 14 | "github.com/patriceckhart/zot/packages/core" | ||
| @@ -336,5 +337,9 @@ func boundLSPText(value string) string { | |||||
| 336 | if len(value) <= maxLSPToolOutput { | 337 | if len(value) <= maxLSPToolOutput { | ||
| 337 | return value | 338 | return value | ||
| 338 | } | 339 | } | ||
| 339 | - | return value[:maxLSPToolOutput] + fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput) | 340 | + | cut := value[:maxLSPToolOutput] |
| 341 | + | for len(cut) > 0 && !utf8.ValidString(cut) { | |||
| 342 | + | cut = cut[:len(cut)-1] | |||
| 343 | + | } | |||
| 344 | + | return cut + fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput) | |||
| 340 | } | 345 | } | ||
M packages/agent/tools/lsp_test.go +5 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "runtime" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | + | "unicode/utf8" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/patriceckhart/zot/packages/agent/lsp" | |
| 13 | 14 | "github.com/patriceckhart/zot/packages/provider" | |
| @@ -66,4 +67,8 @@ func TestBoundLSPText(t *testing.T) { | |||
| 66 | 67 | if len(value) <= maxLSPToolOutput || !strings.Contains(value, "truncated") { | |
| 67 | 68 | t.Fatalf("bound output = %d bytes", len(value)) | |
| 68 | 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 | + | } | |
| 69 | 74 | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "runtime" | 8 | "runtime" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "testing" | 10 | "testing" | ||
| 11 | + | "unicode/utf8" | |||
| 11 | 12 | ||||
| 12 | "github.com/patriceckhart/zot/packages/agent/lsp" | 13 | "github.com/patriceckhart/zot/packages/agent/lsp" | ||
| 13 | "github.com/patriceckhart/zot/packages/provider" | 14 | "github.com/patriceckhart/zot/packages/provider" | ||
| @@ -66,4 +67,8 @@ func TestBoundLSPText(t *testing.T) { | |||||
| 66 | if len(value) <= maxLSPToolOutput || !strings.Contains(value, "truncated") { | 67 | if len(value) <= maxLSPToolOutput || !strings.Contains(value, "truncated") { | ||
| 67 | t.Fatalf("bound output = %d bytes", len(value)) | 68 | t.Fatalf("bound output = %d bytes", len(value)) | ||
| 68 | } | 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 | + | } | |||
| 69 | } | 74 | } | ||