blob: 20f37031564b5fb0e9132f1a703c917bbcf9b0eb [file] [edit]
// 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 golang
import (
"context"
"encoding/json"
"fmt"
"go/token"
"slices"
"strings"
"unicode"
"golang.org/x/mod/module"
"golang.org/x/tools/gopls/internal/protocol"
"golang.org/x/tools/gopls/internal/protocol/command"
"golang.org/x/tools/gopls/internal/settings"
"golang.org/x/tools/gopls/internal/util/morestrings"
)
// Ths file contains the code to mediate user dialogs in the client
// The logical flow is as follows:
// 1. Client sends 'textDocument/codeAction'
// gopls respond with one or more CodeActions, including one with kind either
// 'refactor.rewrite.addTags' or 'refactor.rewrite.removeTags'
// 2. Client sends 'codeAction/resolve' with the selected CodeActions
// gopls returns the same CodeAction
// 3. Client sends 'workspace/executeCommand' to get the dialog form
// gopls responds with the form to display to the user
// 4. Client sends 'command/resolve' with the filled out form
// gopls responds, with 'workspace/applyEdit' saying what to do
// 5. Client return ApplyWorkspaceEditResult with applied = true
// 6. Client sends a textDocument/didChange notification, with the edits applied (optional)
// In vscode-go, because it is a standard LSP client, cannot do step 4 as
// described. Instead it calls "workspace/executeCommand" with command
// 'gopls.lsp' and parameter.Method "command/resolve".
//
// ExecuteCommand() calls command.Dispatch() which calls LSP()
// which calls protocol.ServerDispatchCall("command/resolve")
// which calls ResolveCommand. command.Dispatch() switches on param.Command and LSP() passes param.Method
// to ServerDispatchCall
// The neovim flow is simpler, as 'command/resolve' can be invoked directly.
var addTagsForm = []protocol.FormField{
{
ID: "tags",
Description: `comma-separated list of tags to add; e.g.. "json,xml"`,
Type: protocol.FormFieldTypeString{Kind: protocol.FormFieldKindString},
Required: true,
Default: "json",
},
{
ID: "transform",
Description: `transform rule for added tags, e.g., "camelcase' or 'snakecase"`,
Type: protocol.FormFieldTypeEnum{
Kind: protocol.FormFieldKindEnum,
Entries: []protocol.FormEnumEntry{
{
Value: "camelcase",
Description: "camelCase",
},
{
Value: "lispcase",
Description: "lisp-case",
},
{
Value: "pascalcase",
Description: "PascalCase",
},
{
Value: "titlecase",
Description: "Title Case",
},
{
Value: "snakecase",
Description: "snake_case",
},
},
},
Required: true,
Default: "camelcase",
},
}
var removeTagsForm = []protocol.FormField{
{
ID: "tags",
Description: `comma-separated list of tags to remove; e.g., "json,xml"`,
Type: protocol.FormFieldTypeString{Kind: protocol.FormFieldKindString},
Required: true,
Default: "json", // TODO(?): put the existing tags here?
},
}
// ResolveCommand implements the interactive resolution step for workspace commands.
// It inspects the command name within the provided [protocol.ExecuteCommandParams]
// and delegates to the appropriate command-specific resolver (e.g., modify_tags,
// implement_interface).
//
// For full details on the interactive protocol, the multi-step handshake, and the
// conditions under which the parameter is modified or returned as-is, see the
// official documentation on the protocol interface and [protocol.Server.ResolveCommand].
func ResolveCommand(ctx context.Context, params *protocol.ExecuteCommandParams, options settings.ClientOptions) (*protocol.ExecuteCommandParams, error) {
switch params.Command {
case "gopls.modify_tags":
if err := resolveModifyTags(options, params); err != nil {
return nil, err
}
case "gopls.implement_interface":
if err := resolveImplementInterface(options, params); err != nil {
return nil, err
}
case "gopls.move_declaration":
if err := resolveMoveDeclaration(options, params); err != nil {
return nil, err
}
}
return params, nil
}
func resolveModifyTags(options settings.ClientOptions, param *protocol.ExecuteCommandParams) error {
var a0 command.ModifyTagsArgs
if err := command.UnmarshalArgs(param.Arguments, &a0); err != nil {
return err
}
switch a0.Modification {
case "add":
if !supportsDialog(options, addTagsForm) {
return nil
}
// First call, return the form.
if len(param.FormAnswers) == 0 {
param.FormFields = addTagsForm
return nil
}
v0, err := FormAnswer[string](&param.InteractiveParams, "tags")
if err != nil {
return err
}
if _, err = SanitizeTags(v0); err != nil {
form := slices.Clone(addTagsForm)
form[0].Error = err.Error()
param.FormFields = form
return nil
}
_, err = FormAnswer[string](&param.InteractiveParams, "transform")
if err != nil {
return err
}
// PJW: what happens when the user enters a bad value? (i think the client handles it)
param.FormFields = nil
return nil
case "remove":
if !supportsDialog(options, removeTagsForm) {
return nil
}
// First call, return the form
if len(param.FormAnswers) == 0 {
// TODO? show the user the current list of tags?
param.FormFields = removeTagsForm
return nil
}
v, err := FormAnswer[string](&param.InteractiveParams, "tags")
if err != nil {
return err
}
if _, err := SanitizeTags(v); err != nil {
form := slices.Clone(addTagsForm)
form[0].Error = err.Error()
param.FormFields = form
return nil
}
param.FormFields = nil
return nil
default:
return fmt.Errorf("unsupported modify tags operation: %s", a0.Modification)
}
}
func mustMarshal(x any) json.RawMessage {
data, err := json.Marshal(x)
if err != nil {
panic(err)
}
return json.RawMessage(data)
}
var implementInterfaceFormLazyEnum = []protocol.FormField{
{
ID: "interface",
Description: `fully qualified interface identifier path/to/pkg.interface; e.g., "net.Error"`,
Type: protocol.FormFieldTypeLazyEnum{
Kind: protocol.FormFieldKindLazyEnum,
Source: "workspaceSymbol",
Config: mustMarshal(InteractiveWorkspaceSymbolEnumConfig{
Kinds: []protocol.SymbolKind{protocol.Interface},
}),
},
Required: true,
Default: "error",
},
}
var implementInterfaceFormString = []protocol.FormField{
{
ID: "interface",
Description: `fully qualified interface identifier path/to/pkg.interface; e.g., "net.Error"`,
Type: protocol.FormFieldTypeString{
Kind: protocol.FormFieldKindString,
},
Required: true,
Default: "error",
},
}
var moveDeclarationFormString = []protocol.FormField{
{
ID: "string",
Description: "destination file uri for the moved declaration, e.g. file:///path/to/file.go",
Type: protocol.FormFieldTypeFile{
Kind: "string",
},
Required: true,
},
}
var moveDeclarationFormFile = []protocol.FormField{
{
ID: "file",
Description: "destination file for the moved declaration",
Type: protocol.FormFieldTypeFile{
Kind: "file",
},
Required: true,
},
}
func resolveImplementInterface(options settings.ClientOptions, param *protocol.ExecuteCommandParams) error {
var a0 command.ImplementInterfaceArgs
if err := command.UnmarshalArgs(param.Arguments, &a0); err != nil {
return err
}
var form []protocol.FormField
if ok := options.SupportedInteractiveInputTypes[settings.InteractiveInputTypeLazyEnum]; ok {
form = implementInterfaceFormLazyEnum
} else if ok := options.SupportedInteractiveInputTypes[settings.InteractiveInputTypeString]; ok {
form = implementInterfaceFormString
} else {
// This should not happen, as the gopls should not offer such code
// action if the language client does not support any kind above.
return fmt.Errorf("internal error: unsupported interactive input types: %v", options.SupportedInteractiveInputTypes)
}
// First call, return the empty form.
if len(param.FormAnswers) == 0 {
param.FormFields = form
return nil
}
v, err := FormAnswer[string](&param.InteractiveParams, "interface")
if err != nil {
return err
}
// Gopls only validates the syntax of the string; it does not verify that
// the package or interface actually exists in the workspace.
validInterface := func(ifaceStr string) error {
if ifaceStr == "error" {
return nil
}
pkgPath, ifaceName, ok := morestrings.CutLast(ifaceStr, ".")
if !ok {
return fmt.Errorf(`invalid interface type name: want string of form "example.com/pkg.Type", got %q`, ifaceStr)
}
if err := module.CheckImportPath(pkgPath); err != nil {
return fmt.Errorf("invalid package path %w", err)
}
if !token.IsIdentifier(ifaceName) {
return fmt.Errorf("invalid type name: %q", ifaceName)
}
return nil
}
if err := validInterface(v); err != nil {
// The client only sends back answers, not the original form fields.
// Clone the static form template so we can attach the validation
// error and send the complete form back for the client to re-render.
form := slices.Clone(form)
form[0].Error = err.Error()
param.FormFields = form
return nil
}
param.FormFields = nil
return nil
}
func resolveMoveDeclaration(options settings.ClientOptions, param *protocol.ExecuteCommandParams) error {
var a0 command.MoveDeclarationArgs
if err := command.UnmarshalArgs(param.Arguments, &a0); err != nil {
return err
}
var form []protocol.FormField
if ok := options.SupportedInteractiveInputTypes[settings.InteractiveInputTypeFile]; ok {
form = moveDeclarationFormFile
} else if ok := options.SupportedInteractiveInputTypes[settings.InteractiveInputTypeString]; ok {
form = moveDeclarationFormString
} else {
// This should not happen because gopls should not offer this code action if the
// language client does not support any kind above.
return fmt.Errorf("internal error: unsupported interactive input types: %v", options.SupportedInteractiveInputTypes)
}
// First call, return the empty form.
if len(param.FormAnswers) == 0 {
param.FormFields = form
return nil
}
file, err := FormAnswer[string](&param.InteractiveParams, "file")
if err != nil {
return err
}
if _, err := protocol.ParseDocumentURI(file); err != nil {
return err
}
param.FormFields = nil
return nil
}
// FormAnswer finds, validates, and returns the unique answer for id.
//
// It uses a linear scan since the number of answers is small (usually < 5).
func FormAnswer[T any](params *protocol.InteractiveParams, id string) (v T, err error) {
matches := 0
for _, ans := range params.FormAnswers {
if ans.ID == id {
matches++
val, ok := ans.Value.(T)
if !ok {
return v, fmt.Errorf("form answer %q has unexpected type %T, want %T", id, ans.Value, v)
}
v = val
}
}
if matches == 0 {
return v, fmt.Errorf("form lacks answer %q", id)
} else if matches > 1 {
return v, fmt.Errorf("form contains duplicate answer %q", id)
}
return v, nil
}
// SanitizeTags cleans up comma-separated tags and ensures they are valid.
func SanitizeTags(tags string) (string, error) {
parts := strings.Split(tags, ",")
var clean []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
// Use strings.ContainsFunc instead of a manual byte loop.
// It returns true if any rune in the string matches the condition.
if strings.ContainsFunc(p, func(r rune) bool {
// Space, colon, quote, or any non-printable character (like control chars)
return r == ' ' || r == ':' || r == '"' || !unicode.IsPrint(r)
}) {
return "", fmt.Errorf("illegal tag %q: cannot contain spaces, quotes, colons, or control characters", p)
}
clean = append(clean, p)
}
return strings.Join(clean, ","), nil
}