blob: e4c8e8a1ae554650f877c12f253b583b1b83eeba [file] [log] [blame]
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package mcp
// This file defines the "diagnostics" operation, which is responsible for
// returning diagnostics for the input file.
import (
"bytes"
"context"
"fmt"
"io"
"path/filepath"
"slices"
"strings"
"golang.org/x/tools/gopls/internal/cache"
"golang.org/x/tools/gopls/internal/golang"
"golang.org/x/tools/gopls/internal/protocol"
"golang.org/x/tools/internal/diff"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type diagnosticsParams struct {
File string `json:"file" jsonschema:"the absolute path to the file to diagnose"`
}
func (h *handler) fileDiagnosticsHandler(ctx context.Context, req *mcp.CallToolRequest, params diagnosticsParams) (*mcp.CallToolResult, any, error) {
countGoFileDiagnosticsMCP.Inc()
fh, snapshot, release, err := h.fileOf(ctx, params.File)
if err != nil {
return nil, nil, err
}
defer release()
diagnostics, fixes, err := h.diagnoseFile(ctx, snapshot, fh.URI())
if err != nil {
return nil, nil, err
}
var builder strings.Builder
if len(diagnostics) == 0 {
return textResult("No diagnostics"), nil, nil
}
if err := summarizeDiagnostics(ctx, snapshot, &builder, diagnostics, fixes); err != nil {
return nil, nil, err
}
return textResult(builder.String()), nil, nil
}
// diagnoseFile diagnoses a single file, including go/analysis and quick fixes.
func (h *handler) diagnoseFile(ctx context.Context, snapshot *cache.Snapshot, uri protocol.DocumentURI) ([]*cache.Diagnostic, map[*cache.Diagnostic]*protocol.CodeAction, error) {
diagnostics, err := golang.DiagnoseFile(ctx, snapshot, uri)
if err != nil {
return nil, nil, err
}
if len(diagnostics) == 0 {
return nil, nil, nil
}
// LSP [protocol.Diagnostic]s do not carry code edits directly.
// Instead, gopls provides associated [protocol.CodeAction]s with their
// diagnostics field populated.
// Ignore errors. It is still valuable to provide only the diagnostic
// without any text edits.
// TODO(hxjiang): support code actions that returns call back command.
actions, _ := h.lspServer.CodeAction(ctx, &protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: uri,
},
Context: protocol.CodeActionContext{
Only: []protocol.CodeActionKind{protocol.QuickFix},
Diagnostics: cache.ToProtocolDiagnostics(diagnostics...),
},
})
type key struct {
Message string
Range protocol.Range
}
actionMap := make(map[key]*protocol.CodeAction)
for _, action := range actions {
for _, d := range action.Diagnostics {
k := key{d.Message, d.Range}
if alt, ok := actionMap[k]; !ok || !alt.IsPreferred && action.IsPreferred {
actionMap[k] = &action
}
}
}
fixes := make(map[*cache.Diagnostic]*protocol.CodeAction)
for _, d := range diagnostics {
if fix, ok := actionMap[key{d.Message, d.Range}]; ok {
fixes[d] = fix
}
}
return diagnostics, fixes, nil
}
func summarizeDiagnostics(ctx context.Context, snapshot *cache.Snapshot, w io.Writer, diagnostics []*cache.Diagnostic, fixes map[*cache.Diagnostic]*protocol.CodeAction) error {
for _, d := range diagnostics {
fmt.Fprintf(w, "%d:%d-%d:%d: [%s] %s\n", d.Range.Start.Line, d.Range.Start.Character, d.Range.End.Line, d.Range.End.Character, d.Severity, d.Message)
fix, ok := fixes[d]
if ok {
diff, err := toUnifiedDiff(ctx, snapshot, fix.Edit.DocumentChanges)
if err != nil {
return err
}
fmt.Fprintf(w, "Fix:\n%s\n", diff)
}
}
return nil
}
// toUnifiedDiff converts each [protocol.DocumentChange] into a separate
// unified diff.
// All returned diffs use forward slash ('/') as the file path separator for
// consistency, regardless of the original system's separator.
// Multiple changes targeting the same file are not consolidated.
// TODO(hxjiang): consolidate diffs to the same file.
func toUnifiedDiff(ctx context.Context, snapshot *cache.Snapshot, changes []protocol.DocumentChange) (string, error) {
var res strings.Builder
for _, change := range changes {
switch {
case change.CreateFile != nil:
res.WriteString(diff.Unified("/dev/null", filepath.ToSlash(change.CreateFile.URI.Path()), "", ""))
case change.DeleteFile != nil:
fh, err := snapshot.ReadFile(ctx, change.DeleteFile.URI)
if err != nil {
return "", err
}
content, err := fh.Content()
if err != nil {
return "", err
}
res.WriteString(diff.Unified(filepath.ToSlash(change.DeleteFile.URI.Path()), "/dev/null", string(content), ""))
case change.RenameFile != nil:
fh, err := snapshot.ReadFile(ctx, change.RenameFile.OldURI)
if err != nil {
return "", err
}
content, err := fh.Content()
if err != nil {
return "", err
}
res.WriteString(diff.Unified(filepath.ToSlash(change.RenameFile.OldURI.Path()), filepath.ToSlash(change.RenameFile.NewURI.Path()), string(content), string(content)))
case change.TextDocumentEdit != nil:
// Assumes gopls never return AnnotatedTextEdit.
sorted := protocol.AsTextEdits(change.TextDocumentEdit.Edits)
// As stated by the LSP, text edits ranges must never overlap.
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textEditArray
slices.SortFunc(sorted, func(a, b protocol.TextEdit) int {
if a.Range.Start.Line != b.Range.Start.Line {
return int(a.Range.Start.Line) - int(b.Range.Start.Line)
}
return int(a.Range.Start.Character) - int(b.Range.Start.Character)
})
fh, err := snapshot.ReadFile(ctx, change.TextDocumentEdit.TextDocument.URI)
if err != nil {
return "", err
}
content, err := fh.Content()
if err != nil {
return "", err
}
var newSrc bytes.Buffer
{
mapper := protocol.NewMapper(fh.URI(), content)
start := 0
for _, edit := range sorted {
l, r, err := mapper.RangeOffsets(edit.Range)
if err != nil {
return "", err
}
newSrc.Write(content[start:l])
newSrc.WriteString(edit.NewText)
start = r
}
newSrc.Write(content[start:])
}
res.WriteString(diff.Unified(filepath.ToSlash(fh.URI().Path()), filepath.ToSlash(fh.URI().Path()), string(content), newSrc.String()))
default:
continue // this shouldn't happen
}
res.WriteString("\n")
}
return res.String(), nil
}