detached 7f4c198

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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.
1919
2020 - one static binary.
2121 - 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.
2323 - three run modes (interactive tui, print, json).
2424 - built-in telegram bot.
2525 - 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:
6868 | `initializationOptions` | Values sent during LSP initialization |
6969 | `env` | Extra environment variables for the child process |
7070
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
7374 before parsing.
7475
7576 ## 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 the71+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 capped72+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 actions76 ## 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
2323 ---
2424 name: reviewer
2525 description: Read-only code reviewer
26-tools: read, write, edit, bash, lsp
26+tools: read
2727 model: openai-codex/gpt-5.6-luna
2828 thinking: max
2929 systemPromptMode: replace
@@ -23,7 +23,7 @@ Profiles are read-only inputs. zot does not execute files from these directories
23 ---23 ---
24 name: reviewer24 name: reviewer
25 description: Read-only code reviewer25 description: Read-only code reviewer
26-tools: read, write, edit, bash, lsp26+tools: read
27 model: openai-codex/gpt-5.6-luna27 model: openai-codex/gpt-5.6-luna
28 thinking: max28 thinking: max
29 systemPromptMode: replace29 systemPromptMode: replace
M packages/agent/build.go +22 -1
@@ -1054,7 +1054,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
10541054 return core.Registry{}
10551055 }
10561056 var manager *lsp.Manager
1057- if lspEnabled {
1057+ if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
10581058 manager = lsp.NewManager()
10591059 }
10601060 all := map[string]core.Tool{
@@ -1083,6 +1083,27 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
10831083 return reg
10841084 }
10851085
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+
10861107 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
10871108 order := []string{"read", "write", "edit", "bash", "lsp"}
10881109 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.Manager1056 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 reg1083 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 []ToolSummary1109 var out []ToolSummary
M packages/agent/build_test.go +9 -0
@@ -16,6 +16,11 @@ import (
1616 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
1717 root := t.TempDir()
1818 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() })
1924 if _, ok := registry["lsp"]; !ok {
2025 t.Fatal("default registry does not include lsp")
2126 }
@@ -30,6 +35,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
3035 if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 {
3136 t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled))
3237 }
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+ }
3342 }
3443
3544 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 {
227227 // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics
228228 // preference without requiring a generated config entry.
229229 func (c Config) LSPDiagnosticsOnWriteEnabled() bool {
230- return c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite
230+ return c.LSPEnabledFor(false) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite)
231231 }
232232
233233 // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics
234234 // preference without requiring a generated config entry.
235235 func (c Config) LSPDiagnosticsOnEditEnabled() bool {
236- return c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit
236+ return c.LSPEnabledFor(false) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit
237237 }
238238
239239 // 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 diagnostics227 // 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.LSPDiagnosticsOnWrite230+ return c.LSPEnabledFor(false) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite)
231 }231 }
232 232
233 // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics233 // 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.LSPDiagnosticsOnEdit236+ 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)
9595 return nil, fmt.Errorf("%s: %w", spec.ID, err)
9696 }
9797 cmd := exec.Command(command, spec.Args...)
98+ cmd.WaitDelay = 2 * time.Second
9899 cmd.Dir = root
99100 cmd.Env = mergedEnvironment(spec.Env)
100101 stdin, err := cmd.StdinPipe()
@@ -542,8 +543,13 @@ func (c *Client) Close() error {
542543 }
543544 waitErr = c.cmd.Wait()
544545 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+ }
547553 }
548554 _ = c.stdout.Close()
549555 _ = 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 = root99 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 = nil547+ // 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) {
8787 explicit := make(map[string]bool)
8888 autoDetect := true
8989 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+ }
9194 if len(data) > 0 {
9295 var metadata struct {
9396 AutoDetect *bool `json:"autoDetect"`
@@ -96,10 +99,6 @@ func LoadConfig(cwd string) (Config, error) {
9699 autoDetect = *metadata.AutoDetect
97100 }
98101 }
99- entries, exists, err := readConfigFile(path)
100- if err != nil {
101- return Config{}, err
102- }
103102 if !exists {
104103 continue
105104 }
@@ -123,24 +122,24 @@ func LoadConfig(cwd string) (Config, error) {
123122 merged[server.ID] = server
124123 }
125124 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]
130127 if base, ok := merged[id]; ok {
131128 // Decode the already-merged JSON over the built-in by marshaling the
132129 // built-in first. This retains fields that were omitted in the file.
133130 baseRaw, _ := json.Marshal(base)
134131 baseMap := map[string]json.RawMessage{}
135132 _ = 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 {
144143 if _, hasKind := raw["kind"]; !hasKind {
145144 if _, hasParser := raw["parser"]; hasParser {
146145 server.Kind = "cli"
@@ -210,28 +209,17 @@ func serverMatchesPath(cwd, path string, server ServerConfig) bool {
210209 if len(server.RootMarkers) > 0 && !hasRootMarker(cwd, server.RootMarkers) {
211210 return false
212211 }
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)
226213 }
227214 return hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes)
228215 }
229216
230217 func hasMatchingFile(cwd string, types []string) bool {
218+ normalizedTypes := normalizeFileTypes(types)
231219 var found bool
232220 _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error {
233221 if err != nil || found {
234- return filepath.SkipDir
222+ return filepath.SkipAll
235223 }
236224 if entry.IsDir() {
237225 if path != cwd && (entry.Name() == ".git" || entry.Name() == "node_modules" || entry.Name() == ".venv") {
@@ -239,12 +227,42 @@ func hasMatchingFile(cwd string, types []string) bool {
239227 }
240228 return nil
241229 }
242- found = serverMatchesPath(cwd, path, ServerConfig{FileTypes: types})
230+ found = fileTypeMatchesNormalized(path, normalizedTypes)
231+ if found {
232+ return filepath.SkipAll
233+ }
243234 return nil
244235 })
245236 return found
246237 }
247238
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+
248266 func hasRootMarker(cwd string, markers []string) bool {
249267 if len(markers) == 0 {
250268 return false
@@ -363,17 +381,17 @@ func providerIDs(data []byte) map[string]struct{} {
363381 return ids
364382 }
365383
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) {
367385 data, err := os.ReadFile(path)
368386 if errors.Is(err, os.ErrNotExist) {
369- return nil, false, nil
387+ return nil, false, nil, nil
370388 }
371389 if err != nil {
372- return nil, true, err
390+ return nil, true, nil, err
373391 }
374392 var top map[string]json.RawMessage
375393 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)
377395 }
378396 root := top
379397 if raw, ok := top["providers"]; ok {
@@ -382,7 +400,7 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e
382400 // and turn object entries into the map shape used internally.
383401 var providers []json.RawMessage
384402 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)
386404 }
387405 root = make(map[string]json.RawMessage)
388406 for index, provider := range providers {
@@ -392,11 +410,11 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e
392410 }
393411 var object map[string]json.RawMessage
394412 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)
396414 }
397415 idRaw, ok := object["id"]
398416 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)
400418 }
401419 id = strings.TrimSpace(id)
402420 delete(object, "id")
@@ -404,16 +422,23 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e
404422 root[id] = mustJSON(object)
405423 }
406424 } else {
425+ wrappedRoot := make(map[string]json.RawMessage)
426+ wrapped := false
407427 for _, key := range []string{"servers", "lspServers", "linters"} {
408428 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, &section); 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
412436 }
413- root = wrapped
414- break
415437 }
416438 }
439+ if wrapped {
440+ root = wrappedRoot
441+ }
417442 }
418443 out := make(map[string]map[string]json.RawMessage)
419444 for id, raw := range root {
@@ -427,12 +452,12 @@ func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, e
427452 if json.Unmarshal(raw, &command) == nil {
428453 object = map[string]json.RawMessage{"command": json.RawMessage(strconvQuote(command))}
429454 } 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)
431456 }
432457 }
433458 out[id] = object
434459 }
435- return out, true, nil
460+ return out, true, data, nil
436461 }
437462
438463 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 := true88 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.AutoDetect99 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 continue103 continue
105 }104 }
@@ -123,24 +122,24 @@ func LoadConfig(cwd string) (Config, error) {
123 merged[server.ID] = server122 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 the128 // 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 the138+ }
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 false210 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 bool219 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.SkipDir222+ 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 nil228 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 nil234 return nil
244 })235 })
245 return found236 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 false268 return false
@@ -363,17 +381,17 @@ func providerIDs(data []byte) map[string]struct{} {
363 return ids381 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, nil387+ return nil, false, nil, nil
370 }388 }
371 if err != nil {389 if err != nil {
372- return nil, true, err390+ return nil, true, nil, err
373 }391 }
374 var top map[string]json.RawMessage392 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 := top396 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.RawMessage401 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.RawMessage411 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.RawMessage429+ var section map[string]json.RawMessage
410- if err := json.Unmarshal(raw, &wrapped); err != nil {430+ if err := json.Unmarshal(raw, &section); 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] = object458 out[id] = object
434 }459 }
435- return out, true, nil460+ 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
1313 var missingModulePatterns = []*regexp.Regexp{
1414 regexp.MustCompile(`(?i)cannot find module ["']([^"']+)["']`),
1515 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) ["']?([^"' ]+)["']?`),
1717 regexp.MustCompile(`(?i)import .*?(["'][^"']+["']).*could not be resolved`),
1818 }
1919
@@ -199,10 +199,7 @@ func formatRepeated(bucket []Diagnostic, root string) string {
199199 if len(paths) == 1 && len(lines) > 0 {
200200 where += fmt.Sprintf(" (lines: %s)", joinInts(lines, 3))
201201 }
202- message := compactWhitespace(bucket[0].Message)
203- if len(message) > 220 {
204- message = message[:219] + "…"
205- }
202+ message := truncateText(compactWhitespace(bucket[0].Message), 220)
206203 return fmt.Sprintf("%s: repeated %s%s %s%s", strings.ToUpper(defaultSeverity(bucket[0].Severity)), where, code, message, extra)
207204 }
208205
@@ -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 {
5151 if err := safeWorkspacePath(rootReal, path); err != nil {
5252 return err
5353 }
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 {
5562 return fmt.Errorf("apply edit to %s: %w", path, err)
5663 }
5764 }
@@ -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 err52 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 (
55 "bytes"
66 "context"
77 "encoding/json"
8+ "io"
89 "os"
910 "path/filepath"
11+ "runtime"
1012 "strings"
1113 "testing"
1214 "time"
@@ -81,6 +83,42 @@ func TestClientFakeStdioRPC(t *testing.T) {
8183 }
8284 }
8385
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+
84122 func TestFramingRoundTripWithAdditionalHeader(t *testing.T) {
85123 payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`)
86124 wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...)
@@ -186,6 +224,9 @@ func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) {
186224 }
187225
188226 func TestResolveCommandPrefersWorkspaceTools(t *testing.T) {
227+ if runtime.GOOS == "windows" {
228+ t.Skip("executable bits are not represented on Windows")
229+ }
189230 root := t.TempDir()
190231 bin := filepath.Join(root, "node_modules", ".bin")
191232 if err := os.MkdirAll(bin, 0o755); err != nil {
@@ -226,6 +267,9 @@ func TestParsers(t *testing.T) {
226267 if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" {
227268 t.Fatalf("generic = %#v", got)
228269 }
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+ }
229273 }
230274
231275 func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) {
@@ -278,6 +322,51 @@ func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) {
278322 }
279323 }
280324
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+
281370 func TestDiagnosticJSONCodeNumber(t *testing.T) {
282371 var diagnostic Diagnostic
283372 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) {
288377 }
289378 }
290379
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+
291397 func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) {
292398 manager := NewManager()
293399 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 Diagnostic371 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
33 import (
44 "bytes"
55 "context"
6+ "crypto/sha256"
7+ "encoding/hex"
68 "encoding/json"
79 "errors"
810 "fmt"
@@ -33,6 +35,7 @@ type Response struct {
3335
3436 type workspace struct {
3537 cwd string
38+ rootReal string
3639 config Config
3740 clients map[string]*Client
3841 starting map[string]chan struct{}
@@ -52,8 +55,11 @@ type Manager struct {
5255 mu sync.Mutex
5356 workspaces map[string]*workspace
5457 options ManagerOptions
58+ closed bool
5559 }
5660
61+var errManagerClosed = errors.New("LSP manager is closed")
62+
5763 func NewManager() *Manager {
5864 return NewManagerWithOptions(ManagerOptions{})
5965 }
@@ -77,6 +83,10 @@ func (m *Manager) workspace(cwd string) (*workspace, error) {
7783 return nil, err
7884 }
7985 m.mu.Lock()
86+ if m.closed {
87+ m.mu.Unlock()
88+ return nil, errManagerClosed
89+ }
8090 if value := m.workspaces[cwd]; value != nil {
8191 m.mu.Unlock()
8292 return value, nil
@@ -86,14 +96,21 @@ func (m *Manager) workspace(cwd string) (*workspace, error) {
8696 if err != nil {
8797 return nil, err
8898 }
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)}
90104 m.mu.Lock()
105+ defer m.mu.Unlock()
106+ if m.closed {
107+ return nil, errManagerClosed
108+ }
91109 if existing := m.workspaces[cwd]; existing != nil {
92110 value = existing
93111 } else {
94112 m.workspaces[cwd] = value
95113 }
96- m.mu.Unlock()
97114 return value, nil
98115 }
99116
@@ -116,6 +133,10 @@ func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerCo
116133 }
117134 for {
118135 m.mu.Lock()
136+ if m.closed || m.workspaces[ws.cwd] != ws {
137+ m.mu.Unlock()
138+ return nil, errManagerClosed
139+ }
119140 if client := ws.clients[spec.ID]; client != nil {
120141 m.mu.Unlock()
121142 return client, nil
@@ -151,11 +172,20 @@ func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerCo
151172 delete(ws.starting, spec.ID)
152173 close(starting)
153174 if err != nil {
175+ if m.closed || m.workspaces[ws.cwd] != ws {
176+ m.mu.Unlock()
177+ return nil, errManagerClosed
178+ }
154179 ws.statuses[spec.ID] = "missing"
155180 ws.errors[spec.ID] = err.Error()
156181 m.mu.Unlock()
157182 return nil, err
158183 }
184+ if m.closed || m.workspaces[ws.cwd] != ws {
185+ m.mu.Unlock()
186+ _ = client.Close()
187+ return nil, errManagerClosed
188+ }
159189 if existing := ws.clients[spec.ID]; existing != nil {
160190 m.mu.Unlock()
161191 _ = client.Close()
@@ -186,7 +216,7 @@ func wsSettings(global, local map[string]any) map[string]any {
186216 func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) {
187217 byPath := make(map[string][]Diagnostic)
188218 for _, diagnostic := range diagnostics {
189- if diagnostic.Path == "" || !diagnosticInWorkspace(ws.cwd, diagnostic.Path) {
219+ if diagnostic.Path == "" || !diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
190220 continue
191221 }
192222 byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic)
@@ -249,7 +279,7 @@ func (m *Manager) Open(ctx context.Context, cwd, path string) error {
249279 }
250280 continue
251281 }
252- hash := string(text)
282+ hash := contentHash(text)
253283 m.mu.Lock()
254284 if ws.opened[spec.ID] == nil {
255285 ws.opened[spec.ID] = make(map[string]string)
@@ -574,6 +604,10 @@ func (m *Manager) Reload(cwd string) error {
574604 return err
575605 }
576606 m.mu.Lock()
607+ if m.closed {
608+ m.mu.Unlock()
609+ return errManagerClosed
610+ }
577611 ws := m.workspaces[cwd]
578612 delete(m.workspaces, cwd)
579613 m.mu.Unlock()
@@ -597,6 +631,11 @@ func (m *Manager) clientsSnapshot(ws *workspace) []*Client {
597631
598632 func (m *Manager) Close() error {
599633 m.mu.Lock()
634+ if m.closed {
635+ m.mu.Unlock()
636+ return nil
637+ }
638+ m.closed = true
600639 workspaces := make([]*workspace, 0, len(m.workspaces))
601640 for _, ws := range m.workspaces {
602641 workspaces = append(workspaces, ws)
@@ -657,7 +696,13 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
657696 }
658697 if strings.Contains(arg, "{files}") {
659698 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+ }
661706 continue
662707 }
663708 args = append(args, arg)
@@ -667,7 +712,7 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
667712 args = append(args, ".")
668713 }
669714 } else if !containsFiles {
670- args = append(args, relativeFiles(ws.cwd, files)...)
715+ args = append(args, relative...)
671716 }
672717 commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout)
673718 cmd := exec.CommandContext(commandCtx, command, args...)
@@ -690,7 +735,7 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
690735 diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text)
691736 filtered := diagnostics[:0]
692737 for _, diagnostic := range diagnostics {
693- if diagnostic.Path != "" && diagnosticInWorkspace(ws.cwd, diagnostic.Path) {
738+ if diagnostic.Path != "" && diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
694739 filtered = append(filtered, diagnostic)
695740 }
696741 }
@@ -712,6 +757,11 @@ func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
712757 return firstErr
713758 }
714759
760+func contentHash(data []byte) string {
761+ sum := sha256.Sum256(data)
762+ return hex.EncodeToString(sum[:])
763+}
764+
715765 func displayWorkspacePath(root, path string) string {
716766 if path == "" {
717767 return ""
@@ -747,14 +797,7 @@ func absoluteFile(cwd, path string) (string, error) {
747797 }
748798 return filepath.Abs(path)
749799 }
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 {
758801 return safeWorkspacePath(rootReal, path) == nil
759802 }
760803
@@ -765,19 +808,23 @@ func samePath(a, b string) bool {
765808 }
766809
767810 type limitedBuffer struct {
768- bytes.Buffer
769- limit int
811+ buffer bytes.Buffer
812+ limit int
770813 }
771814
815+func (b *limitedBuffer) Len() int { return b.buffer.Len() }
816+
817+func (b *limitedBuffer) String() string { return b.buffer.String() }
818+
772819 func (b *limitedBuffer) Write(p []byte) (int, error) {
773820 original := len(p)
774- if b.limit <= b.Len() {
821+ if b.limit <= b.buffer.Len() {
775822 return original, nil
776823 }
777- room := b.limit - b.Len()
824+ room := b.limit - b.buffer.Len()
778825 if len(p) > room {
779826 p = p[:room]
780827 }
781- _, _ = b.Buffer.Write(p)
828+ _, _ = b.buffer.Write(p)
782829 return original, nil
783830 }
@@ -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 string37 cwd string
38+ rootReal string
36 config Config39 config Config
37 clients map[string]*Client40 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.Mutex55 mu sync.Mutex
53 workspaces map[string]*workspace56 workspaces map[string]*workspace
54 options ManagerOptions57 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, err83 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, nil92 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, err97 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 = existing110 value = existing
93 } else {111 } else {
94 m.workspaces[cwd] = value112 m.workspaces[cwd] = value
95 }113 }
96- m.mu.Unlock()
97 return value, nil114 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, nil142 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, err182 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 continue220 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 continue280 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 err604 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 = true698 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 continue706 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 firstErr757 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) == nil801 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.Buffer811+ buffer bytes.Buffer
769- limit int812+ 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, nil822 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, nil829 return original, nil
783 }830 }
M packages/agent/lsp/parsers.go +16 -5
@@ -175,7 +175,7 @@ func parseSARIF(provider, cwd, output string) []Diagnostic {
175175 return out
176176 }
177177
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*(.*)$`)
179179
180180 func parseGeneric(provider, cwd, output string) []Diagnostic {
181181 var out []Diagnostic
@@ -259,10 +259,7 @@ func FormatDiagnostic(d Diagnostic, root string) string {
259259 if d.Code != "" {
260260 code = " [" + d.Code + "]"
261261 }
262- message := compactWhitespace(d.Message)
263- if len(message) > 220 {
264- message = message[:219] + "…"
265- }
262+ message := truncateText(compactWhitespace(d.Message), 220)
266263 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)
267264 }
268265
@@ -274,3 +271,17 @@ func defaultSeverity(value string) string {
274271 }
275272
276273 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 out175 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 []Diagnostic181 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 (
55 "fmt"
66 "net/url"
77 "path/filepath"
8+ "runtime"
89 "strings"
910 )
1011
@@ -120,7 +121,17 @@ func pathToURI(path string) (string, error) {
120121 return "", err
121122 }
122123 // 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}
124135 return u.String(), nil
125136 }
126137
@@ -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 "", err121 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(), nil135 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) {
42284228 i.statusErr = ""
42294229 i.mu.Unlock()
42304230 case "lsp_enabled":
4231- val := value
4232- i.cfg.LSPEnabled = &val
42334231 if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
42344232 if err := store.SetLSPEnabled(value); err != nil {
42354233 i.mu.Lock()
@@ -4238,6 +4236,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
42384236 return
42394237 }
42404238 }
4239+ val := value
4240+ i.cfg.LSPEnabled = &val
42414241 if i.cfg.RefreshTools != nil {
42424242 i.cfg.RefreshTools()
42434243 }
@@ -4246,8 +4246,6 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
42464246 i.statusErr = ""
42474247 i.mu.Unlock()
42484248 case "subagent_lsp_enabled":
4249- val := value
4250- i.cfg.SubagentLSPEnabled = &val
42514249 if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
42524250 if err := store.SetSubagentLSPEnabled(value); err != nil {
42534251 i.mu.Lock()
@@ -4256,6 +4254,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
42564254 return
42574255 }
42584256 }
4257+ val := value
4258+ i.cfg.SubagentLSPEnabled = &val
42594259 i.mu.Lock()
42604260 i.statusOK = "lsp in sub-agents " + onOff(value)
42614261 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 return4236 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 return4254 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 (
1515 "github.com/patriceckhart/zot/packages/agent/extensions"
1616 "github.com/patriceckhart/zot/packages/agent/extproto"
1717 "github.com/patriceckhart/zot/packages/agent/modes"
18+ "github.com/patriceckhart/zot/packages/agent/tools"
1819 "github.com/patriceckhart/zot/packages/core"
1920 "github.com/patriceckhart/zot/packages/provider"
2021 )
@@ -112,7 +113,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
112113 return
113114 }
114115 resolved.MergeExtensionTools(adapter)
116+ oldTools := ag.Tools
115117 ag.SetTools(resolved.ToolRegistry)
118+ _ = tools.CloseLSPManagers(oldTools)
116119 })
117120
118121 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 return113 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) {
149149 if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) {
150150 t.Fatal("explicitly disabled LSP settings were ignored")
151151 }
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+ }
152158 }
@@ -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 (
88 "path/filepath"
99 "strings"
1010 "time"
11+ "unicode/utf8"
1112
1213 "github.com/patriceckhart/zot/packages/agent/lsp"
1314 "github.com/patriceckhart/zot/packages/core"
@@ -336,5 +337,9 @@ func boundLSPText(value string) string {
336337 if len(value) <= maxLSPToolOutput {
337338 return value
338339 }
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)
340345 }
@@ -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 value338 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 (
88 "runtime"
99 "strings"
1010 "testing"
11+ "unicode/utf8"
1112
1213 "github.com/patriceckhart/zot/packages/agent/lsp"
1314 "github.com/patriceckhart/zot/packages/provider"
@@ -66,4 +67,8 @@ func TestBoundLSPText(t *testing.T) {
6667 if len(value) <= maxLSPToolOutput || !strings.Contains(value, "truncated") {
6768 t.Fatalf("bound output = %d bytes", len(value))
6869 }
70+ unicodeValue := boundLSPText(strings.Repeat("界", maxLSPToolOutput/3+10))
71+ if !utf8.ValidString(unicodeValue) {
72+ t.Fatal("bound output split a UTF-8 sequence")
73+ }
6974 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close