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 ws.statuses[spec.ID] = "missing"
183 ws.errors[spec.ID] = err.Error()
184 m.mu.Unlock()
185 return nil, err
186 }
187 if m.closed || m.workspaces[ws.cwd] != ws {
188 m.mu.Unlock()
189 _ = client.Close()
190 return nil, errManagerClosed
191 }
192 if existing := ws.clients[spec.ID]; existing != nil {
193 m.mu.Unlock()
194 _ = client.Close()
195 return existing, nil
196 }
197 ws.clients[spec.ID] = client
198 ws.statuses[spec.ID] = "ready"
199 delete(ws.errors, spec.ID)
200 m.mu.Unlock()
201 return client, nil
202 }
203}
204
205func wsSettings(global, local map[string]any) map[string]any {
206 if len(global) == 0 && len(local) == 0 {
207 return nil
208 }
209 out := make(map[string]any, len(global)+len(local))
210 for key, value := range global {
211 out[key] = value
212 }
213 for key, value := range local {
214 out[key] = value
215 }
216 return out
217}
218
219func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) {
220 byPath := make(map[string][]Diagnostic)
221 cleared := make(map[string]bool)
222 for _, diagnostic := range diagnostics {
223 if diagnostic.Path == "" || !diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
224 continue
225 }
226 if diagnostic.Clear {
227 cleared[diagnostic.Path] = true
228 continue
229 }
230 byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic)
231 }
232 m.mu.Lock()
233 if ws.diagnostics[server] == nil {
234 ws.diagnostics[server] = make(map[string][]Diagnostic)
235 }
236 for path := range cleared {
237 delete(ws.diagnostics[server], path)
238 }
239 for path, values := range byPath {
240 ws.diagnostics[server][path] = values
241 }
242 m.mu.Unlock()
243}
244
245func (m *Manager) log(ws *workspace, server, message string) {
246 message = strings.TrimSpace(message)
247 if message == "" {
248 return
249 }
250 if len(message) > 4096 {
251 message = message[:4095] + "…"
252 }
253 m.mu.Lock()
254 ws.logs = append(ws.logs, server+": "+message)
255 if len(ws.logs) > 100 {
256 ws.logs = ws.logs[len(ws.logs)-100:]
257 }
258 m.mu.Unlock()
259 if m.options.OnLog != nil {
260 m.options.OnLog(server, message)
261 }
262}
263
264// Open opens a document in every applicable LSP server.
265func (m *Manager) Open(ctx context.Context, cwd, path string) error {
266 ws, err := m.workspace(cwd)
267 if err != nil {
268 return err
269 }
270 path, err = absoluteFile(ws.cwd, path)
271 if err != nil {
272 return err
273 }
274 text, err := os.ReadFile(path)
275 if err != nil {
276 return err
277 }
278 for _, spec := range m.applicable(ws, path) {
279 if spec.Kind != "lsp" {
280 continue
281 }
282 client, clientErr := m.ensureClient(ctx, ws, spec)
283 if clientErr != nil {
284 if err == nil {
285 err = clientErr
286 }
287 continue
288 }
289 hash := contentHash(text)
290 m.mu.Lock()
291 if ws.opened[spec.ID] == nil {
292 ws.opened[spec.ID] = make(map[string]string)
293 }
294 old, open := ws.opened[spec.ID][path]
295 m.mu.Unlock()
296 if !open {
297 languageID := spec.LanguageID
298 if languageID == "" {
299 languageID = languageForExtension(filepath.Ext(path))
300 }
301 if clientErr = client.DidOpen(path, languageID, string(text)); clientErr == nil {
302 m.mu.Lock()
303 ws.opened[spec.ID][path] = hash
304 m.mu.Unlock()
305 }
306 } else if old != hash {
307 if clientErr = client.DidChange(path, string(text)); clientErr == nil {
308 m.mu.Lock()
309 ws.opened[spec.ID][path] = hash
310 m.mu.Unlock()
311 }
312 }
313 if clientErr != nil && err == nil {
314 err = clientErr
315 }
316 }
317 return err
318}
319
320func (m *Manager) Save(ctx context.Context, cwd, path string) error {
321 openErr := m.Open(ctx, cwd, path)
322 ws, err := m.workspace(cwd)
323 if err != nil {
324 return err
325 }
326 path, err = absoluteFile(ws.cwd, path)
327 if err != nil {
328 return err
329 }
330 text, err := os.ReadFile(path)
331 if err != nil {
332 return err
333 }
334 for _, spec := range m.applicable(ws, path) {
335 if spec.Kind != "lsp" {
336 continue
337 }
338 m.mu.Lock()
339 client := ws.clients[spec.ID]
340 m.mu.Unlock()
341 if client != nil {
342 _ = client.DidSave(path, string(text))
343 }
344 }
345 return openErr
346}
347
348// Diagnostics obtains current LSP and CLI diagnostics. It deliberately waits
349// only a bounded debounce interval for asynchronous publishDiagnostics.
350func (m *Manager) Diagnostics(ctx context.Context, cwd, path string) ([]Diagnostic, error) {
351 return m.DiagnosticsWithOptions(ctx, cwd, path, true)
352}
353
354func (m *Manager) DiagnosticsWithOptions(ctx context.Context, cwd, path string, runCLI bool) ([]Diagnostic, error) {
355 ws, err := m.workspace(cwd)
356 if err != nil {
357 return nil, err
358 }
359 var firstErr error
360 if path != "" {
361 if saveErr := m.Save(ctx, ws.cwd, path); saveErr != nil && !errors.Is(saveErr, os.ErrNotExist) {
362 // Keep cached diagnostics, but make an unavailable or failed
363 // server visible to the model-facing lsp action.
364 firstErr = saveErr
365 }
366 }
367 for _, spec := range m.applicable(ws, path) {
368 if spec.Kind != "lsp" {
369 continue
370 }
371 m.mu.Lock()
372 client := ws.clients[spec.ID]
373 m.mu.Unlock()
374 if client != nil {
375 if waitErr := client.WaitForDiagnostics(ctx, m.options.DiagnosticDelay); waitErr != nil && firstErr == nil && !errors.Is(waitErr, context.Canceled) {
376 firstErr = waitErr
377 }
378 }
379 }
380 if runCLI {
381 if err := m.RunLinters(ctx, ws.cwd, path); err != nil && firstErr == nil {
382 firstErr = err
383 }
384 }
385 return m.snapshot(ws, path), firstErr
386}
387
388// ReduceDiagnostics returns only diagnostics that have not already been
389// surfaced for this workspace path. The ledger deliberately ignores line
390// and column changes, matching the behavior users want after a harmless edit:
391// unchanged problems do not consume another tool-result context window.
392// Passing an empty snapshot clears the path so a later recurrence is fresh.
393func (m *Manager) ReduceDiagnostics(cwd, path string, diagnostics []Diagnostic) []Diagnostic {
394 ws, err := m.workspace(cwd)
395 if err != nil {
396 return diagnostics
397 }
398 path, err = absoluteFile(ws.cwd, path)
399 if err != nil {
400 return diagnostics
401 }
402 m.mu.Lock()
403 defer m.mu.Unlock()
404 if len(diagnostics) == 0 {
405 delete(ws.surfaced, path)
406 return nil
407 }
408 previous := ws.surfaced[path]
409 current := make(map[string]bool, len(diagnostics))
410 fresh := make([]Diagnostic, 0, len(diagnostics))
411 for _, diagnostic := range diagnostics {
412 identity := DiagnosticIdentity(diagnostic)
413 current[identity] = true
414 if !previous[identity] {
415 fresh = append(fresh, diagnostic)
416 }
417 }
418 ws.surfaced[path] = current
419 return fresh
420}
421
422func (m *Manager) snapshot(ws *workspace, path string) []Diagnostic {
423 m.mu.Lock()
424 defer m.mu.Unlock()
425 var all []Diagnostic
426 for _, files := range ws.diagnostics {
427 for file, diagnostics := range files {
428 if path == "" || samePath(file, path) {
429 all = append(all, diagnostics...)
430 }
431 }
432 }
433 for _, diagnostics := range ws.cliDiagnostics {
434 for _, diagnostic := range diagnostics {
435 if path == "" || samePath(diagnostic.Path, path) {
436 all = append(all, diagnostic)
437 }
438 }
439 }
440 return DeduplicateDiagnostics(all)
441}
442
443// Request sends one request to a named LSP server.
444func (m *Manager) Request(ctx context.Context, cwd, server, method string, params any) (json.RawMessage, error) {
445 ws, err := m.workspace(cwd)
446 if err != nil {
447 return nil, err
448 }
449 spec, ok := ws.config.Servers[server]
450 if !ok || spec.Disabled {
451 return nil, fmt.Errorf("unknown or disabled LSP server %q", server)
452 }
453 if spec.Kind != "lsp" || spec.IsLinter {
454 return nil, fmt.Errorf("%s is a linter and cannot serve navigation requests", server)
455 }
456 client, err := m.ensureClient(ctx, ws, spec)
457 if err != nil {
458 return nil, err
459 }
460 return m.requestWithTimeout(ctx, client, method, params)
461}
462
463func (m *Manager) requestWithTimeout(ctx context.Context, client *Client, method string, params any) (json.RawMessage, error) {
464 if ctx == nil {
465 ctx = context.Background()
466 }
467 requestCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
468 defer cancel()
469 return client.Request(requestCtx, method, params)
470}
471
472// RequestAll sends a request to every applicable LSP server. If path is set,
473// the document is opened first so servers have current text and URI context.
474func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, params any) []Response {
475 ws, err := m.workspace(cwd)
476 if err != nil {
477 return []Response{{Error: err}}
478 }
479 if path != "" {
480 _ = m.Open(ctx, ws.cwd, path)
481 }
482 var responses []Response
483 for _, spec := range m.applicable(ws, path) {
484 if spec.Kind != "lsp" || spec.IsLinter {
485 continue
486 }
487 client, clientErr := m.ensureClient(ctx, ws, spec)
488 if clientErr != nil {
489 responses = append(responses, Response{Server: spec.ID, Error: clientErr})
490 continue
491 }
492 result, requestErr := m.requestWithTimeout(ctx, client, method, params)
493 responses = append(responses, Response{Server: spec.ID, Result: result, Error: requestErr})
494 }
495 return responses
496}
497
498// ApplyEdit applies a workspace edit under the selected workspace root.
499func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error {
500 return m.ApplyEdits(cwd, []WorkspaceEdit{edit})
501}
502
503// ApplyEdits preflights and applies multiple workspace edits as one operation.
504// This prevents a later unsafe edit from leaving earlier edits applied.
505func (m *Manager) ApplyEdits(cwd string, edits []WorkspaceEdit) error {
506 ws, err := m.workspace(cwd)
507 if err != nil {
508 return err
509 }
510 merged := WorkspaceEdit{Changes: make(map[string][]TextEdit)}
511 for _, edit := range edits {
512 for uri, changes := range edit.Changes {
513 merged.Changes[uri] = append(merged.Changes[uri], changes...)
514 }
515 merged.DocumentChanges = append(merged.DocumentChanges, edit.DocumentChanges...)
516 }
517 return applyWorkspaceEdit(ws.cwd, merged, m.options.CheckWritePath)
518}
519
520// Status describes configured and cached servers without starting missing
521// processes. It is safe to call for a workspace with no installed tools.
522type ServerStatus struct {
523 ID string `json:"id"`
524 Kind string `json:"kind"`
525 Command string `json:"command"`
526 Root string `json:"root,omitempty"`
527 State string `json:"state"`
528 Error string `json:"error,omitempty"`
529 Diagnostics int `json:"diagnostics,omitempty"`
530 Capabilities json.RawMessage `json:"capabilities,omitempty"`
531}
532
533func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) {
534 ws, err := m.workspace(cwd)
535 if err != nil {
536 return nil, err
537 }
538 specs := m.applicable(ws, path)
539 roots := make(map[string]string, len(specs))
540 resolveErrors := make(map[string]error, len(specs))
541 for _, spec := range specs {
542 roots[spec.ID] = displayWorkspacePath(ws.cwd, FindRoot(ws.cwd, spec.RootMarkers))
543 if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil {
544 resolveErrors[spec.ID] = resolveErr
545 }
546 }
547 m.mu.Lock()
548 defer m.mu.Unlock()
549 out := make([]ServerStatus, 0, len(specs))
550 for _, spec := range specs {
551 status := ServerStatus{ID: spec.ID, Kind: spec.Kind, Command: spec.Command, Root: roots[spec.ID], State: ws.statuses[spec.ID]}
552 if status.State == "" {
553 status.State = "stopped"
554 }
555 if status.State == "stopped" {
556 if resolveErr := resolveErrors[spec.ID]; resolveErr != nil {
557 status.State = "missing"
558 status.Error = resolveErr.Error()
559 }
560 }
561 if client := ws.clients[spec.ID]; client != nil {
562 status.Capabilities = client.Capabilities()
563 }
564 for _, values := range ws.diagnostics[spec.ID] {
565 status.Diagnostics += len(values)
566 }
567 status.Diagnostics += len(ws.cliDiagnostics[spec.ID])
568 if configuredErr := ws.errors[spec.ID]; configuredErr != "" {
569 status.Error = configuredErr
570 }
571 out = append(out, status)
572 }
573 return out, nil
574}
575
576func (m *Manager) Capabilities(cwd, path, server string) (map[string]json.RawMessage, error) {
577 ws, err := m.workspace(cwd)
578 if err != nil {
579 return nil, err
580 }
581 ensure := func(spec ServerConfig) (*Client, error) {
582 if spec.Kind != "lsp" || spec.IsLinter {
583 return nil, fmt.Errorf("%s is not a navigable LSP server", spec.ID)
584 }
585 m.mu.Lock()
586 client := ws.clients[spec.ID]
587 m.mu.Unlock()
588 if client != nil {
589 return client, nil
590 }
591 return m.ensureClient(context.Background(), ws, spec)
592 }
593 if server != "" {
594 spec, ok := ws.config.Servers[server]
595 if !ok || spec.Disabled {
596 return nil, fmt.Errorf("unknown or disabled server %q", server)
597 }
598 client, err := ensure(spec)
599 if err != nil {
600 return nil, err
601 }
602 return map[string]json.RawMessage{server: client.Capabilities()}, nil
603 }
604 statuses, err := m.Status(cwd, path)
605 if err != nil {
606 return nil, err
607 }
608 out := make(map[string]json.RawMessage)
609 var firstErr error
610 for _, status := range statuses {
611 spec := ws.config.Servers[status.ID]
612 if spec.Kind != "lsp" || spec.IsLinter {
613 continue
614 }
615 client, clientErr := ensure(spec)
616 if clientErr != nil {
617 if firstErr == nil {
618 firstErr = clientErr
619 }
620 continue
621 }
622 if capabilities := client.Capabilities(); len(capabilities) > 0 {
623 out[status.ID] = capabilities
624 }
625 }
626 return out, firstErr
627}
628
629// Reload terminates cached processes for cwd and reloads all lsp.json layers.
630func (m *Manager) Reload(cwd string) error {
631 cwd, err := absDir(cwd)
632 if err != nil {
633 return err
634 }
635 m.mu.Lock()
636 if m.closed {
637 m.mu.Unlock()
638 return errManagerClosed
639 }
640 ws := m.workspaces[cwd]
641 delete(m.workspaces, cwd)
642 m.mu.Unlock()
643 if ws != nil {
644 for _, client := range m.clientsSnapshot(ws) {
645 _ = client.Close()
646 }
647 }
648 return nil
649}
650
651func (m *Manager) clientsSnapshot(ws *workspace) []*Client {
652 m.mu.Lock()
653 defer m.mu.Unlock()
654 clients := make([]*Client, 0, len(ws.clients))
655 for _, client := range ws.clients {
656 clients = append(clients, client)
657 }
658 return clients
659}
660
661func (m *Manager) Close() error {
662 m.mu.Lock()
663 if m.closed {
664 m.mu.Unlock()
665 return nil
666 }
667 m.closed = true
668 workspaces := make([]*workspace, 0, len(m.workspaces))
669 for _, ws := range m.workspaces {
670 workspaces = append(workspaces, ws)
671 }
672 m.workspaces = make(map[string]*workspace)
673 m.mu.Unlock()
674 var first error
675 for _, ws := range workspaces {
676 for _, client := range m.clientsSnapshot(ws) {
677 if err := client.Close(); err != nil && first == nil {
678 first = err
679 }
680 }
681 }
682 return first
683}
684
685// RunLinters executes configured CLI providers without a shell. A non-zero
686// linter exit is normal when it emitted parseable findings.
687func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
688 if ctx == nil {
689 ctx = context.Background()
690 }
691 ws, err := m.workspace(cwd)
692 if err != nil {
693 return err
694 }
695 var files []string
696 if path != "" {
697 if abs, pathErr := absoluteFile(ws.cwd, path); pathErr == nil {
698 files = []string{abs}
699 }
700 }
701 var firstErr error
702 for _, spec := range m.applicable(ws, path) {
703 if spec.Kind != "cli" || spec.Disabled {
704 continue
705 }
706 command, resolveErr := ResolveCommand(ws.cwd, spec.Command)
707 if resolveErr != nil {
708 m.mu.Lock()
709 ws.statuses[spec.ID] = "missing"
710 ws.errors[spec.ID] = resolveErr.Error()
711 m.mu.Unlock()
712 if firstErr == nil {
713 firstErr = resolveErr
714 }
715 continue
716 }
717 args := make([]string, 0, len(spec.Args)+len(files))
718 containsFiles := false
719 relative := relativeFiles(ws.cwd, files)
720 for _, arg := range spec.Args {
721 if arg == "{files}" {
722 containsFiles = true
723 args = append(args, relative...)
724 continue
725 }
726 if strings.Contains(arg, "{files}") {
727 containsFiles = true
728 if len(relative) == 0 {
729 args = append(args, strings.ReplaceAll(arg, "{files}", ""))
730 } else {
731 for _, file := range relative {
732 args = append(args, strings.ReplaceAll(arg, "{files}", file))
733 }
734 }
735 continue
736 }
737 args = append(args, arg)
738 }
739 if spec.Mode == "workspace" || (spec.Mode == "" && len(files) == 0) {
740 if !containsFiles && (len(args) == 0 || args[len(args)-1] != ".") {
741 args = append(args, ".")
742 }
743 } else if !containsFiles {
744 args = append(args, relative...)
745 }
746 commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout)
747 cmd := exec.CommandContext(commandCtx, command, args...)
748 cmd.Dir = ws.cwd
749 cmd.Env = mergedEnvironment(spec.Env)
750 var stdout, stderr limitedBuffer
751 stdout.limit = 8 * 1024 * 1024
752 stderr.limit = 8 * 1024 * 1024
753 cmd.Stdout = &stdout
754 cmd.Stderr = &stderr
755 m.mu.Lock()
756 ws.statuses[spec.ID] = "running"
757 m.mu.Unlock()
758 runErr := cmd.Run()
759 cancel()
760 text := strings.TrimSpace(stdout.String())
761 if text == "" {
762 text = strings.TrimSpace(stderr.String())
763 }
764 diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text)
765 filtered := diagnostics[:0]
766 for _, diagnostic := range diagnostics {
767 if diagnostic.Path != "" && diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
768 filtered = append(filtered, diagnostic)
769 }
770 }
771 diagnostics = filtered
772 m.mu.Lock()
773 ws.cliDiagnostics[spec.ID] = diagnostics
774 if runErr != nil && len(diagnostics) == 0 {
775 ws.errors[spec.ID] = runErr.Error()
776 ws.statuses[spec.ID] = "error"
777 } else {
778 delete(ws.errors, spec.ID)
779 ws.statuses[spec.ID] = "ready"
780 }
781 m.mu.Unlock()
782 if runErr != nil && len(diagnostics) == 0 && firstErr == nil {
783 firstErr = fmt.Errorf("%s: %w", spec.ID, runErr)
784 }
785 }
786 return firstErr
787}
788
789func contentHash(data []byte) string {
790 sum := sha256.Sum256(data)
791 return hex.EncodeToString(sum[:])
792}
793
794func displayWorkspacePath(root, path string) string {
795 if path == "" {
796 return ""
797 }
798 rel, err := filepath.Rel(root, path)
799 if err != nil {
800 return ""
801 }
802 if rel == "." {
803 return "."
804 }
805 return filepath.ToSlash(rel)
806}
807
808func relativeFiles(cwd string, files []string) []string {
809 out := make([]string, len(files))
810 for i, path := range files {
811 rel, err := filepath.Rel(cwd, path)
812 if err != nil {
813 out[i] = path
814 } else {
815 out[i] = filepath.ToSlash(rel)
816 }
817 }
818 return out
819}
820func absoluteFile(cwd, path string) (string, error) {
821 if path == "" {
822 return "", errors.New("path is required")
823 }
824 if !filepath.IsAbs(path) {
825 path = filepath.Join(cwd, path)
826 }
827 return filepath.Abs(path)
828}
829func diagnosticInWorkspace(rootReal, path string) bool {
830 return safeWorkspacePath(rootReal, path) == nil
831}
832
833func samePath(a, b string) bool {
834 aa, _ := filepath.Abs(a)
835 bb, _ := filepath.Abs(b)
836 return filepath.Clean(aa) == filepath.Clean(bb)
837}
838
839type limitedBuffer struct {
840 buffer bytes.Buffer
841 limit int
842}
843
844func (b *limitedBuffer) Len() int { return b.buffer.Len() }
845
846func (b *limitedBuffer) String() string { return b.buffer.String() }
847
848func (b *limitedBuffer) Write(p []byte) (int, error) {
849 original := len(p)
850 if b.limit <= b.buffer.Len() {
851 return original, nil
852 }
853 room := b.limit - b.buffer.Len()
854 if len(p) > room {
855 p = p[:room]
856 }
857 _, _ = b.buffer.Write(p)
858 return original, nil
859}