1package lsp
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "sync"
16 "time"
17)
18
19// ManagerOptions controls process and command timeouts.
20type ManagerOptions struct {
21 RequestTimeout time.Duration
22 DiagnosticDelay time.Duration
23 LinterTimeout time.Duration
24 Settings map[string]any
25 OnLog func(server, message string)
26 CheckWritePath func(path string) error
27}
28
29// Response is one raw request result tagged with the server which produced
30// it. Errors are retained so a partially installed workspace remains useful.
31type Response struct {
32 Server string
33 Result json.RawMessage
34 Error error
35}
36
37type workspace struct {
38 cwd string
39 rootReal string
40 config Config
41 clients map[string]*Client
42 starting map[string]chan struct{}
43 statuses map[string]string
44 errors map[string]string
45 diagnostics map[string]map[string][]Diagnostic
46 cliDiagnostics map[string][]Diagnostic
47 opened map[string]map[string]string // server -> path -> content hash
48 surfaced map[string]map[string]bool // path -> location-independent diagnostic identities
49 logs []string
50}
51
52// Manager owns per-workspace LSP processes. A process is cached by cwd and
53// server ID until Close or Reload, so a series of model calls does not spawn a
54// new language server for every request.
55type Manager struct {
56 mu sync.Mutex
57 workspaces map[string]*workspace
58 options ManagerOptions
59 closed bool
60}
61
62var errManagerClosed = errors.New("LSP manager is closed")
63
64func NewManager() *Manager {
65 return NewManagerWithOptions(ManagerOptions{})
66}
67
68func NewManagerWithOptions(options ManagerOptions) *Manager {
69 if options.RequestTimeout <= 0 {
70 options.RequestTimeout = 12 * time.Second
71 }
72 if options.DiagnosticDelay <= 0 {
73 options.DiagnosticDelay = 300 * time.Millisecond
74 }
75 if options.LinterTimeout <= 0 {
76 options.LinterTimeout = 30 * time.Second
77 }
78 return &Manager{workspaces: make(map[string]*workspace), options: options}
79}
80
81func (m *Manager) workspace(cwd string) (*workspace, error) {
82 cwd, err := absDir(cwd)
83 if err != nil {
84 return nil, err
85 }
86 m.mu.Lock()
87 if m.closed {
88 m.mu.Unlock()
89 return nil, errManagerClosed
90 }
91 if value := m.workspaces[cwd]; value != nil {
92 m.mu.Unlock()
93 return value, nil
94 }
95 m.mu.Unlock()
96 config, err := LoadConfig(cwd)
97 if err != nil {
98 return nil, err
99 }
100 rootReal := cwd
101 if resolved, resolveErr := filepath.EvalSymlinks(cwd); resolveErr == nil {
102 rootReal = resolved
103 }
104 value := &workspace{cwd: cwd, rootReal: rootReal, config: config, clients: make(map[string]*Client), starting: make(map[string]chan struct{}), statuses: make(map[string]string), errors: make(map[string]string), diagnostics: make(map[string]map[string][]Diagnostic), cliDiagnostics: make(map[string][]Diagnostic), opened: make(map[string]map[string]string), surfaced: make(map[string]map[string]bool)}
105 m.mu.Lock()
106 defer m.mu.Unlock()
107 if m.closed {
108 return nil, errManagerClosed
109 }
110 if existing := m.workspaces[cwd]; existing != nil {
111 value = existing
112 } else {
113 m.workspaces[cwd] = value
114 }
115 return value, nil
116}
117
118// Config returns the effective merged configuration for cwd.
119func (m *Manager) Config(cwd string) (Config, error) {
120 ws, err := m.workspace(cwd)
121 if err != nil {
122 return Config{}, err
123 }
124 return ws.config, nil
125}
126
127func (m *Manager) applicable(ws *workspace, path string) []ServerConfig {
128 return ws.config.ApplicableServers(ws.cwd, path)
129}
130
131func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerConfig) (*Client, error) {
132 if ctx == nil {
133 ctx = context.Background()
134 }
135 for {
136 m.mu.Lock()
137 if m.closed || m.workspaces[ws.cwd] != ws {
138 m.mu.Unlock()
139 return nil, errManagerClosed
140 }
141 if client := ws.clients[spec.ID]; client != nil {
142 m.mu.Unlock()
143 return client, nil
144 }
145 if waiting := ws.starting[spec.ID]; waiting != nil {
146 m.mu.Unlock()
147 select {
148 case <-waiting:
149 continue
150 case <-ctx.Done():
151 return nil, ctx.Err()
152 }
153 }
154 starting := make(chan struct{})
155 ws.starting[spec.ID] = starting
156 ws.statuses[spec.ID] = "starting"
157 m.mu.Unlock()
158 root := FindRoot(ws.cwd, spec.RootMarkers)
159 opts := ClientOptions{
160 Settings: wsSettings(m.options.Settings, spec.Settings),
161 InitializationOptions: spec.InitializationOptions,
162 OnDiagnostics: func(diagnostics []Diagnostic) { m.storeDiagnostics(ws, spec.ID, diagnostics) },
163 OnLog: func(message string) { m.log(ws, spec.ID, message) },
164 // A server's LSP root may be an ancestor of the session cwd.
165 // Keep server-initiated edits inside the session workspace rather
166 // than trusting that broader project root.
167 ApplyEdit: func(edit WorkspaceEdit) error {
168 return applyWorkspaceEdit(ws.cwd, edit, m.options.CheckWritePath)
169 },
170 }
171 startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
172 client, err := NewClientWithContext(startCtx, spec, root, opts)
173 cancel()
174 m.mu.Lock()
175 delete(ws.starting, spec.ID)
176 close(starting)
177 if err != nil {
178 if m.closed || m.workspaces[ws.cwd] != ws {
179 m.mu.Unlock()
180 return nil, errManagerClosed
181 }
182 if ctx.Err() != nil {
183 ws.statuses[spec.ID] = "stopped"
184 delete(ws.errors, spec.ID)
185 m.mu.Unlock()
186 return nil, err
187 }
188 ws.statuses[spec.ID] = "missing"
189 ws.errors[spec.ID] = err.Error()
190 m.mu.Unlock()
191 return nil, err
192 }
193 if m.closed || m.workspaces[ws.cwd] != ws {
194 m.mu.Unlock()
195 _ = client.Close()
196 return nil, errManagerClosed
197 }
198 if existing := ws.clients[spec.ID]; existing != nil {
199 m.mu.Unlock()
200 _ = client.Close()
201 return existing, nil
202 }
203 ws.clients[spec.ID] = client
204 ws.statuses[spec.ID] = "ready"
205 delete(ws.errors, spec.ID)
206 m.mu.Unlock()
207 return client, nil
208 }
209}
210
211func wsSettings(global, local map[string]any) map[string]any {
212 if len(global) == 0 && len(local) == 0 {
213 return nil
214 }
215 out := make(map[string]any, len(global)+len(local))
216 for key, value := range global {
217 out[key] = value
218 }
219 for key, value := range local {
220 out[key] = value
221 }
222 return out
223}
224
225func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) {
226 byPath := make(map[string][]Diagnostic)
227 cleared := make(map[string]bool)
228 for _, diagnostic := range diagnostics {
229 if diagnostic.Path == "" || !diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
230 continue
231 }
232 if diagnostic.Clear {
233 cleared[diagnostic.Path] = true
234 continue
235 }
236 byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic)
237 }
238 m.mu.Lock()
239 if ws.diagnostics[server] == nil {
240 ws.diagnostics[server] = make(map[string][]Diagnostic)
241 }
242 for path := range cleared {
243 delete(ws.diagnostics[server], path)
244 }
245 for path, values := range byPath {
246 ws.diagnostics[server][path] = values
247 }
248 m.mu.Unlock()
249}
250
251func (m *Manager) log(ws *workspace, server, message string) {
252 message = strings.TrimSpace(message)
253 if message == "" {
254 return
255 }
256 if len(message) > 4096 {
257 message = message[:4095] + "…"
258 }
259 m.mu.Lock()
260 ws.logs = append(ws.logs, server+": "+message)
261 if len(ws.logs) > 100 {
262 ws.logs = ws.logs[len(ws.logs)-100:]
263 }
264 m.mu.Unlock()
265 if m.options.OnLog != nil {
266 m.options.OnLog(server, message)
267 }
268}
269
270// Open opens a document in every applicable LSP server.
271func (m *Manager) Open(ctx context.Context, cwd, path string) error {
272 ws, err := m.workspace(cwd)
273 if err != nil {
274 return err
275 }
276 path, err = absoluteFile(ws.cwd, path)
277 if err != nil {
278 return err
279 }
280 text, err := os.ReadFile(path)
281 if err != nil {
282 return err
283 }
284 for _, spec := range m.applicable(ws, path) {
285 if spec.Kind != "lsp" {
286 continue
287 }
288 client, clientErr := m.ensureClient(ctx, ws, spec)
289 if clientErr != nil {
290 if err == nil {
291 err = clientErr
292 }
293 continue
294 }
295 hash := contentHash(text)
296 m.mu.Lock()
297 if ws.opened[spec.ID] == nil {
298 ws.opened[spec.ID] = make(map[string]string)
299 }
300 old, open := ws.opened[spec.ID][path]
301 m.mu.Unlock()
302 if !open {
303 languageID := spec.LanguageID
304 if languageID == "" {
305 languageID = languageForExtension(filepath.Ext(path))
306 }
307 if clientErr = client.DidOpen(path, languageID, string(text)); clientErr == nil {
308 m.mu.Lock()
309 ws.opened[spec.ID][path] = hash
310 m.mu.Unlock()
311 }
312 } else if old != hash {
313 if clientErr = client.DidChange(path, string(text)); clientErr == nil {
314 m.mu.Lock()
315 ws.opened[spec.ID][path] = hash
316 m.mu.Unlock()
317 }
318 }
319 if clientErr != nil && err == nil {
320 err = clientErr
321 }
322 }
323 return err
324}
325
326func (m *Manager) Save(ctx context.Context, cwd, path string) error {
327 openErr := m.Open(ctx, cwd, path)
328 ws, err := m.workspace(cwd)
329 if err != nil {
330 return err
331 }
332 path, err = absoluteFile(ws.cwd, path)
333 if err != nil {
334 return err
335 }
336 text, err := os.ReadFile(path)
337 if err != nil {
338 return err
339 }
340 for _, spec := range m.applicable(ws, path) {
341 if spec.Kind != "lsp" {
342 continue
343 }
344 m.mu.Lock()
345 client := ws.clients[spec.ID]
346 m.mu.Unlock()
347 if client != nil {
348 _ = client.DidSave(path, string(text))
349 }
350 }
351 return openErr
352}
353
354// Diagnostics obtains current LSP and CLI diagnostics. It deliberately waits
355// only a bounded debounce interval for asynchronous publishDiagnostics.
356func (m *Manager) Diagnostics(ctx context.Context, cwd, path string) ([]Diagnostic, error) {
357 return m.DiagnosticsWithOptions(ctx, cwd, path, true)
358}
359
360func (m *Manager) DiagnosticsWithOptions(ctx context.Context, cwd, path string, runCLI bool) ([]Diagnostic, error) {
361 ws, err := m.workspace(cwd)
362 if err != nil {
363 return nil, err
364 }
365 var firstErr error
366 if path != "" {
367 if saveErr := m.Save(ctx, ws.cwd, path); saveErr != nil && !errors.Is(saveErr, os.ErrNotExist) {
368 // Keep cached diagnostics, but make an unavailable or failed
369 // server visible to the model-facing lsp action.
370 firstErr = saveErr
371 }
372 }
373 for _, spec := range m.applicable(ws, path) {
374 if spec.Kind != "lsp" {
375 continue
376 }
377 m.mu.Lock()
378 client := ws.clients[spec.ID]
379 m.mu.Unlock()
380 if client != nil {
381 if waitErr := client.WaitForDiagnostics(ctx, m.options.DiagnosticDelay); waitErr != nil && firstErr == nil && !errors.Is(waitErr, context.Canceled) {
382 firstErr = waitErr
383 }
384 }
385 }
386 if runCLI {
387 if err := m.RunLinters(ctx, ws.cwd, path); err != nil && firstErr == nil {
388 firstErr = err
389 }
390 }
391 return m.snapshot(ws, path), firstErr
392}
393
394// ReduceDiagnostics returns only diagnostics that have not already been
395// surfaced for this workspace path. The ledger deliberately ignores line
396// and column changes, matching the behavior users want after a harmless edit:
397// unchanged problems do not consume another tool-result context window.
398// Passing an empty snapshot clears the path so a later recurrence is fresh.
399func (m *Manager) ReduceDiagnostics(cwd, path string, diagnostics []Diagnostic) []Diagnostic {
400 ws, err := m.workspace(cwd)
401 if err != nil {
402 return diagnostics
403 }
404 path, err = absoluteFile(ws.cwd, path)
405 if err != nil {
406 return diagnostics
407 }
408 m.mu.Lock()
409 defer m.mu.Unlock()
410 if len(diagnostics) == 0 {
411 delete(ws.surfaced, path)
412 return nil
413 }
414 previous := ws.surfaced[path]
415 current := make(map[string]bool, len(diagnostics))
416 fresh := make([]Diagnostic, 0, len(diagnostics))
417 for _, diagnostic := range diagnostics {
418 identity := DiagnosticIdentity(diagnostic)
419 current[identity] = true
420 if !previous[identity] {
421 fresh = append(fresh, diagnostic)
422 }
423 }
424 ws.surfaced[path] = current
425 return fresh
426}
427
428func (m *Manager) snapshot(ws *workspace, path string) []Diagnostic {
429 m.mu.Lock()
430 defer m.mu.Unlock()
431 var all []Diagnostic
432 for _, files := range ws.diagnostics {
433 for file, diagnostics := range files {
434 if path == "" || samePath(file, path) {
435 all = append(all, diagnostics...)
436 }
437 }
438 }
439 for _, diagnostics := range ws.cliDiagnostics {
440 for _, diagnostic := range diagnostics {
441 if path == "" || samePath(diagnostic.Path, path) {
442 all = append(all, diagnostic)
443 }
444 }
445 }
446 return DeduplicateDiagnostics(all)
447}
448
449// Request sends one request to a named LSP server.
450func (m *Manager) Request(ctx context.Context, cwd, server, method string, params any) (json.RawMessage, error) {
451 ws, err := m.workspace(cwd)
452 if err != nil {
453 return nil, err
454 }
455 spec, ok := ws.config.Servers[server]
456 if !ok || spec.Disabled {
457 return nil, fmt.Errorf("unknown or disabled LSP server %q", server)
458 }
459 if spec.Kind != "lsp" || spec.IsLinter {
460 return nil, fmt.Errorf("%s is a linter and cannot serve navigation requests", server)
461 }
462 client, err := m.ensureClient(ctx, ws, spec)
463 if err != nil {
464 return nil, err
465 }
466 return m.requestWithTimeout(ctx, client, method, params)
467}
468
469func (m *Manager) requestWithTimeout(ctx context.Context, client *Client, method string, params any) (json.RawMessage, error) {
470 if ctx == nil {
471 ctx = context.Background()
472 }
473 requestCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
474 defer cancel()
475 return client.Request(requestCtx, method, params)
476}
477
478// RequestAll sends a request to every applicable LSP server. If path is set,
479// the document is opened first so servers have current text and URI context.
480func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, params any) []Response {
481 ws, err := m.workspace(cwd)
482 if err != nil {
483 return []Response{{Error: err}}
484 }
485 if path != "" {
486 _ = m.Open(ctx, ws.cwd, path)
487 }
488 var responses []Response
489 for _, spec := range m.applicable(ws, path) {
490 if spec.Kind != "lsp" || spec.IsLinter {
491 continue
492 }
493 client, clientErr := m.ensureClient(ctx, ws, spec)
494 if clientErr != nil {
495 responses = append(responses, Response{Server: spec.ID, Error: clientErr})
496 continue
497 }
498 result, requestErr := m.requestWithTimeout(ctx, client, method, params)
499 responses = append(responses, Response{Server: spec.ID, Result: result, Error: requestErr})
500 }
501 return responses
502}
503
504// ApplyEdit applies a workspace edit under the selected workspace root.
505func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error {
506 return m.ApplyEdits(cwd, []WorkspaceEdit{edit})
507}
508
509// ApplyEdits preflights and applies multiple workspace edits as one operation.
510// This prevents a later unsafe edit from leaving earlier edits applied.
511func (m *Manager) ApplyEdits(cwd string, edits []WorkspaceEdit) error {
512 ws, err := m.workspace(cwd)
513 if err != nil {
514 return err
515 }
516 merged := WorkspaceEdit{Changes: make(map[string][]TextEdit)}
517 for _, edit := range edits {
518 for uri, changes := range edit.Changes {
519 merged.Changes[uri] = append(merged.Changes[uri], changes...)
520 }
521 merged.DocumentChanges = append(merged.DocumentChanges, edit.DocumentChanges...)
522 }
523 return applyWorkspaceEdit(ws.cwd, merged, m.options.CheckWritePath)
524}
525
526// Status describes configured and cached servers without starting missing
527// processes. It is safe to call for a workspace with no installed tools.
528type ServerStatus struct {
529 ID string `json:"id"`
530 Kind string `json:"kind"`
531 Command string `json:"command"`
532 Root string `json:"root,omitempty"`
533 State string `json:"state"`
534 Error string `json:"error,omitempty"`
535 Diagnostics int `json:"diagnostics,omitempty"`
536 Capabilities json.RawMessage `json:"capabilities,omitempty"`
537}
538
539func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) {
540 ws, err := m.workspace(cwd)
541 if err != nil {
542 return nil, err
543 }
544 specs := m.applicable(ws, path)
545 roots := make(map[string]string, len(specs))
546 resolveErrors := make(map[string]error, len(specs))
547 for _, spec := range specs {
548 roots[spec.ID] = displayWorkspacePath(ws.cwd, FindRoot(ws.cwd, spec.RootMarkers))
549 if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil {
550 resolveErrors[spec.ID] = resolveErr
551 }
552 }
553 m.mu.Lock()
554 defer m.mu.Unlock()
555 out := make([]ServerStatus, 0, len(specs))
556 for _, spec := range specs {
557 status := ServerStatus{ID: spec.ID, Kind: spec.Kind, Command: spec.Command, Root: roots[spec.ID], State: ws.statuses[spec.ID]}
558 if status.State == "" {
559 status.State = "stopped"
560 }
561 if status.State == "stopped" {
562 if resolveErr := resolveErrors[spec.ID]; resolveErr != nil {
563 status.State = "missing"
564 status.Error = resolveErr.Error()
565 }
566 }
567 if client := ws.clients[spec.ID]; client != nil {
568 status.Capabilities = client.Capabilities()
569 }
570 for _, values := range ws.diagnostics[spec.ID] {
571 status.Diagnostics += len(values)
572 }
573 status.Diagnostics += len(ws.cliDiagnostics[spec.ID])
574 if configuredErr := ws.errors[spec.ID]; configuredErr != "" {
575 status.Error = configuredErr
576 }
577 out = append(out, status)
578 }
579 return out, nil
580}
581
582// Capabilities starts matching servers when needed. The context bounds server
583// startup and initialization.
584func (m *Manager) Capabilities(ctx context.Context, cwd, path, server string) (map[string]json.RawMessage, error) {
585 if ctx == nil {
586 ctx = context.Background()
587 }
588 ws, err := m.workspace(cwd)
589 if err != nil {
590 return nil, err
591 }
592 ensure := func(spec ServerConfig) (*Client, error) {
593 if spec.Kind != "lsp" || spec.IsLinter {
594 return nil, fmt.Errorf("%s is not a navigable LSP server", spec.ID)
595 }
596 m.mu.Lock()
597 client := ws.clients[spec.ID]
598 m.mu.Unlock()
599 if client != nil {
600 return client, nil
601 }
602 return m.ensureClient(ctx, ws, spec)
603 }
604 if server != "" {
605 spec, ok := ws.config.Servers[server]
606 if !ok || spec.Disabled {
607 return nil, fmt.Errorf("unknown or disabled server %q", server)
608 }
609 client, err := ensure(spec)
610 if err != nil {
611 return nil, err
612 }
613 return map[string]json.RawMessage{server: client.Capabilities()}, nil
614 }
615 statuses, err := m.Status(cwd, path)
616 if err != nil {
617 return nil, err
618 }
619 out := make(map[string]json.RawMessage)
620 var firstErr error
621 for _, status := range statuses {
622 if err := ctx.Err(); err != nil {
623 if firstErr == nil {
624 firstErr = err
625 }
626 break
627 }
628 spec := ws.config.Servers[status.ID]
629 if spec.Kind != "lsp" || spec.IsLinter {
630 continue
631 }
632 client, clientErr := ensure(spec)
633 if clientErr != nil {
634 if firstErr == nil {
635 firstErr = clientErr
636 }
637 continue
638 }
639 if capabilities := client.Capabilities(); len(capabilities) > 0 {
640 out[status.ID] = capabilities
641 }
642 }
643 return out, firstErr
644}
645
646// Reload terminates cached processes for cwd and reloads all lsp.json layers.
647func (m *Manager) Reload(cwd string) error {
648 cwd, err := absDir(cwd)
649 if err != nil {
650 return err
651 }
652 m.mu.Lock()
653 if m.closed {
654 m.mu.Unlock()
655 return errManagerClosed
656 }
657 ws := m.workspaces[cwd]
658 delete(m.workspaces, cwd)
659 m.mu.Unlock()
660 if ws != nil {
661 for _, client := range m.clientsSnapshot(ws) {
662 _ = client.Close()
663 }
664 }
665 return nil
666}
667
668func (m *Manager) clientsSnapshot(ws *workspace) []*Client {
669 m.mu.Lock()
670 defer m.mu.Unlock()
671 clients := make([]*Client, 0, len(ws.clients))
672 for _, client := range ws.clients {
673 clients = append(clients, client)
674 }
675 return clients
676}
677
678func (m *Manager) Close() error {
679 m.mu.Lock()
680 if m.closed {
681 m.mu.Unlock()
682 return nil
683 }
684 m.closed = true
685 workspaces := make([]*workspace, 0, len(m.workspaces))
686 for _, ws := range m.workspaces {
687 workspaces = append(workspaces, ws)
688 }
689 m.workspaces = make(map[string]*workspace)
690 m.mu.Unlock()
691 var first error
692 for _, ws := range workspaces {
693 for _, client := range m.clientsSnapshot(ws) {
694 if err := client.Close(); err != nil && first == nil {
695 first = err
696 }
697 }
698 }
699 return first
700}
701
702// RunLinters executes configured CLI providers without a shell. A non-zero
703// linter exit is normal when it emitted parseable findings.
704func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
705 if ctx == nil {
706 ctx = context.Background()
707 }
708 ws, err := m.workspace(cwd)
709 if err != nil {
710 return err
711 }
712 var files []string
713 if path != "" {
714 if abs, pathErr := absoluteFile(ws.cwd, path); pathErr == nil {
715 files = []string{abs}
716 }
717 }
718 var firstErr error
719 for _, spec := range m.applicable(ws, path) {
720 if spec.Kind != "cli" || spec.Disabled {
721 continue
722 }
723 command, resolveErr := ResolveCommand(ws.cwd, spec.Command)
724 if resolveErr != nil {
725 m.mu.Lock()
726 ws.statuses[spec.ID] = "missing"
727 ws.errors[spec.ID] = resolveErr.Error()
728 m.mu.Unlock()
729 if firstErr == nil {
730 firstErr = resolveErr
731 }
732 continue
733 }
734 args := make([]string, 0, len(spec.Args)+len(files))
735 containsFiles := false
736 relative := relativeFiles(ws.cwd, files)
737 for _, arg := range spec.Args {
738 if arg == "{files}" {
739 containsFiles = true
740 args = append(args, relative...)
741 continue
742 }
743 if strings.Contains(arg, "{files}") {
744 containsFiles = true
745 if len(relative) == 0 {
746 args = append(args, strings.ReplaceAll(arg, "{files}", ""))
747 } else {
748 for _, file := range relative {
749 args = append(args, strings.ReplaceAll(arg, "{files}", file))
750 }
751 }
752 continue
753 }
754 args = append(args, arg)
755 }
756 if spec.Mode == "workspace" || (spec.Mode == "" && len(files) == 0) {
757 if !containsFiles && (len(args) == 0 || args[len(args)-1] != ".") {
758 args = append(args, ".")
759 }
760 } else if !containsFiles {
761 args = append(args, relative...)
762 }
763 commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout)
764 cmd := exec.CommandContext(commandCtx, command, args...)
765 cmd.WaitDelay = 2 * time.Second
766 cmd.Dir = ws.cwd
767 cmd.Env = mergedEnvironment(spec.Env)
768 var stdout, stderr limitedBuffer
769 stdout.limit = 8 * 1024 * 1024
770 stderr.limit = 8 * 1024 * 1024
771 cmd.Stdout = &stdout
772 cmd.Stderr = &stderr
773 m.mu.Lock()
774 ws.statuses[spec.ID] = "running"
775 m.mu.Unlock()
776 runErr := cmd.Run()
777 cancel()
778 text := strings.TrimSpace(stdout.String())
779 if text == "" {
780 text = strings.TrimSpace(stderr.String())
781 }
782 diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text)
783 filtered := diagnostics[:0]
784 for _, diagnostic := range diagnostics {
785 if diagnostic.Path != "" && diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
786 filtered = append(filtered, diagnostic)
787 }
788 }
789 diagnostics = filtered
790 m.mu.Lock()
791 ws.cliDiagnostics[spec.ID] = diagnostics
792 if runErr != nil && len(diagnostics) == 0 {
793 ws.errors[spec.ID] = runErr.Error()
794 ws.statuses[spec.ID] = "error"
795 } else {
796 delete(ws.errors, spec.ID)
797 ws.statuses[spec.ID] = "ready"
798 }
799 m.mu.Unlock()
800 if runErr != nil && len(diagnostics) == 0 && firstErr == nil {
801 firstErr = fmt.Errorf("%s: %w", spec.ID, runErr)
802 }
803 }
804 return firstErr
805}
806
807func contentHash(data []byte) string {
808 sum := sha256.Sum256(data)
809 return hex.EncodeToString(sum[:])
810}
811
812func displayWorkspacePath(root, path string) string {
813 if path == "" {
814 return ""
815 }
816 rel, err := filepath.Rel(root, path)
817 if err != nil {
818 return ""
819 }
820 if rel == "." {
821 return "."
822 }
823 return filepath.ToSlash(rel)
824}
825
826func relativeFiles(cwd string, files []string) []string {
827 out := make([]string, len(files))
828 for i, path := range files {
829 rel, err := filepath.Rel(cwd, path)
830 if err != nil {
831 out[i] = path
832 } else {
833 out[i] = filepath.ToSlash(rel)
834 }
835 }
836 return out
837}
838func absoluteFile(cwd, path string) (string, error) {
839 if path == "" {
840 return "", errors.New("path is required")
841 }
842 if !filepath.IsAbs(path) {
843 path = filepath.Join(cwd, path)
844 }
845 return filepath.Abs(path)
846}
847func diagnosticInWorkspace(rootReal, path string) bool {
848 return safeWorkspacePath(rootReal, path) == nil
849}
850
851func samePath(a, b string) bool {
852 aa, _ := filepath.Abs(a)
853 bb, _ := filepath.Abs(b)
854 return filepath.Clean(aa) == filepath.Clean(bb)
855}
856
857type limitedBuffer struct {
858 buffer bytes.Buffer
859 limit int
860}
861
862func (b *limitedBuffer) Len() int { return b.buffer.Len() }
863
864func (b *limitedBuffer) String() string { return b.buffer.String() }
865
866func (b *limitedBuffer) Write(p []byte) (int, error) {
867 original := len(p)
868 if b.limit <= b.buffer.Len() {
869 return original, nil
870 }
871 room := b.limit - b.buffer.Len()
872 if len(p) > room {
873 p = p[:room]
874 }
875 _, _ = b.buffer.Write(p)
876 return original, nil
877}