blob: da3d780ff41bd22a5cf7bcaf025208c863d84258 [file]
// Copyright 2026 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 marker
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"regexp"
"golang.org/x/tools/gopls/internal/protocol"
"golang.org/x/tools/gopls/internal/protocol/command"
"golang.org/x/tools/gopls/internal/test/integration"
"golang.org/x/tools/internal/expect"
)
func codeActionMarker(mark marker, loc protocol.Location, kind string) {
if !exactlyOneNamedArg(mark, "action", "edit", "result", "err") {
return
}
if end := namedArgFunc(mark, "end", convertNamedArgLocation, protocol.Location{}); end.URI != "" {
if end.URI != loc.URI {
mark.errorf("end marker is in a different file (%s)", filepath.Base(loc.URI.Path()))
return
}
loc.Range.End = end.Range.End
}
var (
edit = namedArg(mark, "edit", expect.Identifier(""))
result = namedArg(mark, "result", expect.Identifier(""))
wantAction = namedArg(mark, "action", expect.Identifier(""))
wantErr = namedArgFunc(mark, "err", convertStringMatcher, stringMatcher{})
)
var diag *protocol.Diagnostic
if re := namedArg(mark, "diag", (*regexp.Regexp)(nil)); re != nil {
d, ok := removeDiagnostic(mark, loc, false, re)
if !ok {
mark.errorf("no diagnostic at %v matches %q", loc, re)
return
}
diag = &d
}
action, err := resolveCodeAction(mark.run.env, loc.URI, loc.Range, protocol.CodeActionKind(kind), diag)
if err != nil {
if !wantErr.empty() {
wantErr.checkErr(mark, err)
} else {
mark.errorf("resolveCodeAction failed: %v", err)
}
return
}
// When 'action' is set, we simply compare the action, and don't apply it.
if wantAction != "" {
g := mark.getGolden(wantAction)
if action == nil {
mark.errorf("no matching codeAction")
return
}
data, err := json.MarshalIndent(action, "", "\t")
if err != nil {
mark.errorf("failed to marshal codeaction: %v", err)
return
}
data = bytes.ReplaceAll(data, []byte(mark.run.env.Sandbox.Workdir.RootURI()), []byte("$WORKDIR"))
compareGolden(mark, data, g)
return
}
var changes []protocol.DocumentChange
if namedArg(mark, "form0", "") != "" {
changes, err = applyCodeActionForm(mark, kind, action)
} else {
changes, err = applyCodeAction(mark.run.env, action)
}
if err != nil {
if !wantErr.empty() {
wantErr.checkErr(mark, err)
} else {
mark.errorf("codeAction failed: %v", err)
}
return
}
changed, err := changedFiles(mark.run.env, changes)
if err != nil {
mark.errorf("changedFiles failed: %v", err)
return
}
switch {
case edit != "":
g := mark.getGolden(edit)
checkDiffs(mark, changed, g)
case result != "":
g := mark.getGolden(result)
// Check the file state.
checkChangedFiles(mark, changed, g)
case !wantErr.empty():
wantErr.checkErr(mark, err)
default:
panic("unreachable")
}
}
func exactlyOneNamedArg(mark marker, names ...string) bool {
var found []string
for _, name := range names {
if _, ok := mark.note.NamedArgs[name]; ok {
found = append(found, name)
}
}
if len(found) != 1 {
mark.errorf("need exactly one of %v to be set, got %v", names, found)
return false
}
return true
}
func applyCodeActionForm(mark marker, _ string, action *protocol.CodeAction) ([]protocol.DocumentChange, error) {
// resolveCommand simulates how a client (like vscode-go) resolves interactive
// commands using a tunneling mechanism.
//
// Because some clients cannot send custom JSON-RPC methods directly, they tunnel
// a "command/resolve" request via the standard "workspace/executeCommand"
// method (specifically using the "gopls.lsp" command). This function replicates
// that JSON tunneling rather than calling [protocol.Server.ResolveCommand]
// directly, ensuring the actual over-the-wire pipeline is tested.
resolveCommand := func(unresolved *protocol.ExecuteCommandParams) (resolved *protocol.ExecuteCommandParams, err error) {
unresolvedJSON, err := json.Marshal(unresolved)
if err != nil {
return nil, fmt.Errorf("failed to marshal unresolved command: %v", err)
}
lspArgJSON, err := json.Marshal(command.LSPArgs{
Method: "command/resolve",
Param: unresolvedJSON,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal LSPArgs wrapper: %v", err)
}
lspCmd := &protocol.ExecuteCommandParams{
Command: "gopls.lsp",
Arguments: []json.RawMessage{lspArgJSON},
}
rawResponse, err := mark.run.env.Editor.Server.ExecuteCommand(mark.run.env.Ctx, lspCmd)
if err != nil {
return nil, fmt.Errorf("server.ExecuteCommand failed: %v", err)
}
responseJSON, err := json.Marshal(rawResponse)
if err != nil {
return nil, fmt.Errorf("failed to marshal server response: %v", err)
}
if err := json.Unmarshal(responseJSON, &resolved); err != nil {
return nil, fmt.Errorf("failed to unmarshal resolved command: %v", err)
}
return resolved, nil
}
cmd, err := resolveCommand(&protocol.ExecuteCommandParams{
Command: action.Command.Command,
Arguments: action.Command.Arguments,
})
if err != nil {
return nil, err
}
// now send the same command with a filled in "formAnswers" field
for _, nm := range []string{"form0", "form1"} {
if fm, ok := mark.note.NamedArgs[nm]; ok {
buf, err := json.Marshal(fm)
if err != nil || (false && len(buf) == 0) {
return nil, fmt.Errorf("failed to marshal string %s, %v", fm, err)
}
// TODO(hxjiang): support other kind of inputs.
cmd.FormAnswers = append(cmd.FormAnswers, fm.(string))
} else {
break // only want some of the form slots
}
}
cmd, err = resolveCommand(cmd)
if err != nil {
return nil, err
}
if len(cmd.FormFields) > 0 {
return nil, fmt.Errorf("got %v question after providing answers, expect 0", len(cmd.FormFields))
}
// catch the changes from the forthcoming ExecuteCommand
var changes []protocol.DocumentChange
restore := mark.run.env.Editor.Client().SetApplyEditHandler(func(ctx context.Context, wsedit *protocol.WorkspaceEdit) error {
changes = append(changes, wsedit.DocumentChanges...)
return nil
})
defer restore()
if _, err = mark.run.env.Editor.Server.ExecuteCommand(mark.run.env.Ctx, cmd); err != nil {
return nil, fmt.Errorf("third ExecuteCommand failed %v", err)
}
// the edits were a side effect captured by the ApplyEditHandler
return changes, nil
}
// not used for @codeaction, but codeactions
// codeAction executes a textDocument/codeAction request for the specified
// location and kind. If diag is non-nil, it is used as the code action
// context.
//
// The resulting map contains resulting file contents after the code action is
// applied. Currently, this function does not support code actions that return
// edits directly; it only supports code action commands.
func codeAction(env *integration.Env, uri protocol.DocumentURI, rng protocol.Range, kind protocol.CodeActionKind, diag *protocol.Diagnostic) (map[string][]byte, error) {
action, err := resolveCodeAction(env, uri, rng, kind, diag)
if err != nil {
return nil, err
}
changes, err := applyCodeAction(env, action)
if err != nil {
return nil, err
}
return changedFiles(env, changes)
}
// resolveCodeAction resolves the code action specified by the given location,
// kind, and diagnostic.
func resolveCodeAction(env *integration.Env, uri protocol.DocumentURI, rng protocol.Range, kind protocol.CodeActionKind, diag *protocol.Diagnostic) (*protocol.CodeAction, error) {
// Request all code actions that apply to the diagnostic.
// A production client would set Only=[kind],
// but we can give a better error if we don't filter.
params := &protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{URI: uri},
Range: rng,
Context: protocol.CodeActionContext{
Only: []protocol.CodeActionKind{protocol.Empty}, // => all
//TriggerKind: protocol.CodeActionTriggerKind(1 /*Invoked*/),
},
}
if diag != nil {
params.Context.Diagnostics = []protocol.Diagnostic{*diag}
}
actions, err := env.Editor.Server.CodeAction(env.Ctx, params)
if err != nil {
return nil, err
}
// Find the sole candidate CodeAction of exactly the specified kind
// (e.g. refactor.inline.call).
var candidates []protocol.CodeAction
for _, act := range actions {
if act.Kind == kind {
candidates = append(candidates, act)
}
}
if len(candidates) != 1 {
var msg bytes.Buffer
fmt.Fprintf(&msg, "found %d CodeActions of kind %s for this diagnostic, want 1", len(candidates), kind)
for _, act := range actions {
fmt.Fprintf(&msg, "\n\tfound %q (%s)", act.Title, act.Kind)
}
return nil, errors.New(msg.String())
}
action := candidates[0]
// Resolve code action edits first if the client has resolve support
// and the code action has no edits.
if action.Edit == nil {
editSupport, err := env.Editor.EditResolveSupport()
if err != nil {
return nil, err
}
if editSupport {
resolved, err := env.Editor.Server.ResolveCodeAction(env.Ctx, &action)
if err != nil {
return nil, err
}
action = *resolved
}
}
return &action, nil
}
// applyCodeAction applies the given code action, and captures the resulting
// document changes. This is not called for forms.
func applyCodeAction(env *integration.Env, action *protocol.CodeAction) ([]protocol.DocumentChange, error) {
// Collect any server-initiated changes created by workspace/applyEdit.
//
// We set up this handler immediately, not right before executing the code
// action command, so we can assert that neither the codeAction request nor
// codeAction resolve request cause edits as a side effect (golang/go#71405).
var changes []protocol.DocumentChange
restore := env.Editor.Client().SetApplyEditHandler(func(ctx context.Context, wsedit *protocol.WorkspaceEdit) error {
changes = append(changes, wsedit.DocumentChanges...)
return nil
})
defer restore()
if action.Edit == nil && action.Command == nil {
panic("bad action")
}
// Apply the codeAction.
//
// Spec:
// "If a code action provides an edit and a command, first the edit is
// executed and then the command."
// An action may specify an edit and/or a command, to be
// applied in that order. But since applyDocumentChanges(env,
// action.Edit.DocumentChanges) doesn't compose, for now we
// assert that actions return one or the other.
if action.Edit != nil {
if len(action.Edit.Changes) > 0 {
env.TB.Errorf("internal error: discarding unexpected CodeAction{Kind=%s, Title=%q}.Edit.Changes", action.Kind, action.Title)
}
if action.Edit.DocumentChanges != nil {
if action.Command != nil {
env.TB.Errorf("internal error: discarding unexpected CodeAction{Kind=%s, Title=%q}.Command", action.Kind, action.Title)
}
return action.Edit.DocumentChanges, nil
}
}
if action.Command != nil {
// This is a typical CodeAction command:
//
// Title: "Implement error"
// Command: gopls.apply_fix
// Arguments: [{"Fix":"stub_methods","URI":".../a.go","Range":...}}]
//
// The client makes an ExecuteCommand RPC to the server,
// which dispatches it to the ApplyFix handler.
// ApplyFix dispatches to the "stub_methods" fixer (the meat).
// The server then makes an ApplyEdit RPC to the client,
// whose WorkspaceEditFunc hook temporarily gathers the edits
// instead of applying them.
if _, err := env.Editor.Server.ExecuteCommand(env.Ctx, &protocol.ExecuteCommandParams{
Command: action.Command.Command,
Arguments: action.Command.Arguments,
}); err != nil {
return nil, err
}
return changes, nil // populated as a side effect of ExecuteCommand
}
return nil, nil
}