blob: 8b4c61edbe12acef365fa3f68ba314f8c9daa01d [file] [log] [blame]
// Copyright 2023 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 regtest
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"go/token"
"io/fs"
"os"
"path"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"golang.org/x/tools/go/expect"
"golang.org/x/tools/gopls/internal/hooks"
"golang.org/x/tools/gopls/internal/lsp/cache"
"golang.org/x/tools/gopls/internal/lsp/debug"
"golang.org/x/tools/gopls/internal/lsp/fake"
"golang.org/x/tools/gopls/internal/lsp/lsprpc"
"golang.org/x/tools/gopls/internal/lsp/protocol"
"golang.org/x/tools/gopls/internal/lsp/safetoken"
"golang.org/x/tools/gopls/internal/lsp/source"
"golang.org/x/tools/gopls/internal/lsp/tests"
"golang.org/x/tools/gopls/internal/lsp/tests/compare"
"golang.org/x/tools/internal/diff"
"golang.org/x/tools/internal/jsonrpc2"
"golang.org/x/tools/internal/jsonrpc2/servertest"
"golang.org/x/tools/internal/testenv"
"golang.org/x/tools/txtar"
)
var update = flag.Bool("update", false, "if set, update test data during marker tests")
// RunMarkerTests runs "marker" tests in the given test data directory.
// (In practice: ../../regtest/marker/testdata)
//
// A marker test uses the '//@' marker syntax of the x/tools/go/expect package
// to annotate source code with various information such as locations and
// arguments of LSP operations to be executed by the test. The syntax following
// '@' is parsed as a comma-separated list of ordinary Go function calls, for
// example
//
// //@foo(a, "b", 3),bar(0)
//
// and delegates to a corresponding function to perform LSP-related operations.
// See the Marker types documentation below for a list of supported markers.
//
// Each call argument is converted to the type of the corresponding parameter of
// the designated function. The conversion logic may use the surrounding context,
// such as the position or nearby text. See the Argument conversion section below
// for the full set of special conversions. As a special case, the blank
// identifier '_' is treated as the zero value of the parameter type.
//
// The test runner collects test cases by searching the given directory for
// files with the .txt extension. Each file is interpreted as a txtar archive,
// which is extracted to a temporary directory. The relative path to the .txt
// file is used as the subtest name. The preliminary section of the file
// (before the first archive entry) is a free-form comment.
//
// These tests were inspired by (and in many places copied from) a previous
// iteration of the marker tests built on top of the packagestest framework.
// Key design decisions motivating this reimplementation are as follows:
// - The old tests had a single global session, causing interaction at a
// distance and several awkward workarounds.
// - The old tests could not be safely parallelized, because certain tests
// manipulated the server options
// - Relatedly, the old tests did not have a logic grouping of assertions into
// a single unit, resulting in clusters of files serving clusters of
// entangled assertions.
// - The old tests used locations in the source as test names and as the
// identity of golden content, meaning that a single edit could change the
// name of an arbitrary number of subtests, and making it difficult to
// manually edit golden content.
// - The old tests did not hew closely to LSP concepts, resulting in, for
// example, each marker implementation doing its own position
// transformations, and inventing its own mechanism for configuration.
// - The old tests had an ad-hoc session initialization process. The regtest
// environment has had more time devoted to its initialization, and has a
// more convenient API.
// - The old tests lacked documentation, and often had failures that were hard
// to understand. By starting from scratch, we can revisit these aspects.
//
// # Special files
//
// There are three types of file within the test archive that are given special
// treatment by the test runner:
// - "flags": this file is treated as a whitespace-separated list of flags
// that configure the MarkerTest instance. For example, -min_go=go1.18 sets
// the minimum required Go version for the test.
// TODO(rfindley): support flag values containing whitespace.
// - "settings.json": this file is parsed as JSON, and used as the
// session configuration (see gopls/doc/settings.md)
// - "env": this file is parsed as a list of VAR=VALUE fields specifying the
// editor environment.
// - Golden files: Within the archive, file names starting with '@' are
// treated as "golden" content, and are not written to disk, but instead are
// made available to test methods expecting an argument of type *Golden,
// using the identifier following '@'. For example, if the first parameter of
// Foo were of type *Golden, the test runner would convert the identifier a
// in the call @foo(a, "b", 3) into a *Golden by collecting golden file
// data starting with "@a/".
//
// # Marker types
//
// The following markers are supported within marker tests:
//
// - complete(location, ...labels): specifies expected completion results at
// the given location.
//
// - diag(location, regexp): specifies an expected diagnostic matching the
// given regexp at the given location. The test runner requires
// a 1:1 correspondence between observed diagnostics and diag annotations
//
// - def(src, dst location): perform a textDocument/definition request at
// the src location, and check the the result points to the dst location.
//
// - hover(src, dst location, g Golden): perform a textDocument/hover at the
// src location, and checks that the result is the dst location, with hover
// content matching "hover.md" in the golden data g.
//
// - loc(name, location): specifies the name for a location in the source. These
// locations may be referenced by other markers.
//
// - rename(location, new, golden): specifies a renaming of the
// identifier at the specified location to the new name.
// The golden directory contains the transformed files.
//
// - renameerr(location, new, wantError): specifies a renaming that
// fails with an error that matches the expectation.
//
// - suggestedfix(location, regexp, kind, golden): like diag, the location and
// regexp identify an expected diagnostic. This diagnostic must
// to have exactly one associated code action of the specified kind.
// This action is executed for its editing effects on the source files.
// Like rename, the golden directory contains the expected transformed files.
//
// - refs(location, want ...location): executes a 'references' query at the
// first location and asserts that the result is the set of 'want' locations.
// The first want location must be the declaration (assumedly unique).
//
// # Argument conversion
//
// Marker arguments are first parsed by the go/expect package, which accepts
// the following tokens as defined by the Go spec:
// - string, int64, float64, and rune literals
// - true and false
// - nil
// - identifiers (type expect.Identifier)
// - regular expressions, denoted the two tokens re"abc" (type *regexp.Regexp)
//
// These values are passed as arguments to the corresponding parameter of the
// test function. Additional value conversions may occur for these argument ->
// parameter type pairs:
// - string->regexp: the argument is parsed as a regular expressions.
// - string->location: the argument is converted to the location of the first
// instance of the argument in the partial line preceding the note.
// - regexp->location: the argument is converted to the location of the first
// match for the argument in the partial line preceding the note. If the
// regular expression contains exactly one subgroup, the position of the
// subgroup is used rather than the position of the submatch.
// - name->location: the argument is replaced by the named location.
// - name->Golden: the argument is used to look up golden content prefixed by
// @<argument>.
// - {string,regexp,identifier}->wantError: a wantError type specifies
// an expected error message, either in the form of a substring that
// must be present, a regular expression that it must match, or an
// identifier (e.g. foo) such that the archive entry @foo
// exists and contains the exact expected error.
//
// # Example
//
// Here is a complete example:
//
// -- a.go --
// package a
//
// const abc = 0x2a //@hover("b", "abc", abc),hover(" =", "abc", abc)
// -- @abc/hover.md --
// ```go
// const abc untyped int = 42
// ```
//
// @hover("b", "abc", abc),hover(" =", "abc", abc)
//
// In this example, the @hover annotation tells the test runner to run the
// hoverMarker function, which has parameters:
//
// (mark marker, src, dsc protocol.Location, g *Golden).
//
// The first argument holds the test context, including fake editor with open
// files, and sandboxed directory.
//
// Argument converters translate the "b" and "abc" arguments into locations by
// interpreting each one as a regular expression and finding the location of
// its first match on the preceding portion of the line, and the abc identifier
// into a dictionary of golden content containing "hover.md". Then the
// hoverMarker method executes a textDocument/hover LSP request at the src
// position, and ensures the result spans "abc", with the markdown content from
// hover.md. (Note that the markdown content includes the expect annotation as
// the doc comment.)
//
// The next hover on the same line asserts the same result, but initiates the
// hover immediately after "abc" in the source. This tests that we find the
// preceding identifier when hovering.
//
// # Updating golden files
//
// To update golden content in the test archive, it is easier to regenerate
// content automatically rather than edit it by hand. To do this, run the
// tests with the -update flag. Only tests that actually run will be updated.
//
// In some cases, golden content will vary by Go version (for example, gopls
// produces different markdown at Go versions before the 1.19 go/doc update).
// By convention, the golden content in test archives should match the output
// at Go tip. Each test function can normalize golden content for older Go
// versions.
//
// Note that -update does not cause missing @diag or @loc markers to be added.
//
// # TODO
//
// This API is a work-in-progress, as we migrate existing marker tests from
// internal/lsp/tests.
//
// Remaining TODO:
// - parallelize/optimize test execution
// - reorganize regtest packages (and rename to just 'test'?)
// - Rename the files .txtar.
//
// Existing marker tests to port:
// - CallHierarchy
// - CodeLens
// - Diagnostics
// - CompletionItems
// - Completions
// - CompletionSnippets
// - UnimportedCompletions
// - DeepCompletions
// - FuzzyCompletions
// - CaseSensitiveCompletions
// - RankCompletions
// - FoldingRanges
// - Formats
// - Imports
// - SemanticTokens
// - FunctionExtractions
// - MethodExtractions
// - Definitions
// - Implementations
// - Highlights
// - Renames
// - PrepareRenames
// - Symbols
// - InlayHints
// - WorkspaceSymbols
// - Signatures
// - Links
// - AddImport
// - SelectionRanges
func RunMarkerTests(t *testing.T, dir string) {
// The marker tests must be able to run go/packages.Load.
testenv.NeedsGoPackages(t)
tests, err := loadMarkerTests(dir)
if err != nil {
t.Fatal(err)
}
// Opt: use a shared cache.
// TODO(rfindley): opt: use a memoize store with no eviction.
cache := cache.New(nil)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// TODO(rfindley): it may be more useful to have full support for build
// constraints.
if test.minGoVersion != "" {
var go1point int
if _, err := fmt.Sscanf(test.minGoVersion, "go1.%d", &go1point); err != nil {
t.Fatalf("parsing -min_go version: %v", err)
}
testenv.NeedsGo1Point(t, 18)
}
config := fake.EditorConfig{
Settings: test.settings,
Env: test.env,
}
if _, ok := config.Settings["diagnosticsDelay"]; !ok {
if config.Settings == nil {
config.Settings = make(map[string]interface{})
}
config.Settings["diagnosticsDelay"] = "10ms"
}
run := &markerTestRun{
test: test,
env: newEnv(t, cache, test.files, config),
locations: make(map[expect.Identifier]protocol.Location),
diags: make(map[protocol.Location][]protocol.Diagnostic),
}
// TODO(rfindley): make it easier to clean up the regtest environment.
defer run.env.Editor.Shutdown(context.Background()) // ignore error
defer run.env.Sandbox.Close() // ignore error
// Open all files so that we operate consistently with LSP clients, and
// (pragmatically) so that we have a Mapper available via the fake
// editor.
//
// This also allows avoiding mutating the editor state in tests.
for file := range test.files {
run.env.OpenFile(file)
}
// Pre-process locations.
var markers []marker
for _, note := range test.notes {
mark := marker{run: run, note: note}
switch note.Name {
case "loc":
mark.execute()
default:
markers = append(markers, mark)
}
}
// Wait for the didOpen notifications to be processed, then collect
// diagnostics.
var diags map[string]*protocol.PublishDiagnosticsParams
run.env.AfterChange(ReadAllDiagnostics(&diags))
for path, params := range diags {
uri := run.env.Sandbox.Workdir.URI(path)
for _, diag := range params.Diagnostics {
loc := protocol.Location{
URI: uri,
Range: diag.Range,
}
run.diags[loc] = append(run.diags[loc], diag)
}
}
// Invoke each remaining marker in the test.
for _, mark := range markers {
mark.execute()
}
// Any remaining (un-eliminated) diagnostics are an error.
for loc, diags := range run.diags {
for _, diag := range diags {
t.Errorf("%s: unexpected diagnostic: %q", run.fmtLoc(loc), diag.Message)
}
}
formatted, err := formatTest(test)
if err != nil {
t.Errorf("formatTest: %v", err)
} else if *update {
filename := filepath.Join(dir, test.name)
if err := os.WriteFile(filename, formatted, 0644); err != nil {
t.Error(err)
}
} else {
// On go 1.19 and later, verify that the testdata has not changed.
//
// On earlier Go versions, the golden test data varies due to different
// markdown escaping.
//
// Only check this if the test hasn't already failed, otherwise we'd
// report duplicate mismatches of golden data.
if testenv.Go1Point() >= 19 && !t.Failed() {
// Otherwise, verify that formatted content matches.
if diff := compare.NamedText("formatted", "on-disk", string(formatted), string(test.content)); diff != "" {
t.Errorf("formatted test does not match on-disk content:\n%s", diff)
}
}
}
})
}
if abs, err := filepath.Abs(dir); err == nil && t.Failed() {
t.Logf("(Filenames are relative to %s.)", abs)
}
}
// A marker holds state for the execution of a single @marker
// annotation in the source.
type marker struct {
run *markerTestRun
note *expect.Note
}
// errorf reports an error with a prefix indicating the position of the marker note.
//
// It formats the error message using mark.sprintf.
func (mark marker) errorf(format string, args ...interface{}) {
msg := mark.sprintf(format, args...)
// TODO(adonovan): consider using fmt.Fprintf(os.Stderr)+t.Fail instead of
// t.Errorf to avoid reporting uninteresting positions in the Go source of
// the driver. However, this loses the order of stderr wrt "FAIL: TestFoo"
// subtest dividers.
mark.run.env.T.Errorf("%s: %s", mark.run.fmtPos(mark.note.Pos), msg)
}
// execute invokes the marker's function with the arguments from note.
func (mark marker) execute() {
fn, ok := markerFuncs[mark.note.Name]
if !ok {
mark.errorf("no marker function named %s", mark.note.Name)
return
}
// The first converter corresponds to the *Env argument.
// All others must be converted from the marker syntax.
args := []reflect.Value{reflect.ValueOf(mark)}
var convert converter
for i, in := range mark.note.Args {
if i < len(fn.converters) {
convert = fn.converters[i]
} else if !fn.variadic {
mark.errorf("got %d arguments to %s, expect %d",
len(mark.note.Args), mark.note.Name, len(fn.converters))
return
}
// Special handling for the blank identifier: treat it as the zero value.
if ident, ok := in.(expect.Identifier); ok && ident == "_" {
zero := reflect.Zero(fn.paramTypes[i])
args = append(args, zero)
continue
}
out, err := convert(mark, in)
if err != nil {
mark.errorf("converting argument #%d of %s (%v): %v", i, mark.note.Name, in, err)
return
}
args = append(args, reflect.ValueOf(out))
}
fn.fn.Call(args)
}
// Supported marker functions.
//
// Each marker function must accept a marker as its first argument, with
// subsequent arguments converted from the marker arguments.
//
// Marker funcs should not mutate the test environment (e.g. via opening files
// or applying edits in the editor).
var markerFuncs = map[string]markerFunc{
"complete": makeMarkerFunc(completeMarker),
"def": makeMarkerFunc(defMarker),
"diag": makeMarkerFunc(diagMarker),
"hover": makeMarkerFunc(hoverMarker),
"loc": makeMarkerFunc(locMarker),
"rename": makeMarkerFunc(renameMarker),
"renameerr": makeMarkerFunc(renameErrMarker),
"suggestedfix": makeMarkerFunc(suggestedfixMarker),
"refs": makeMarkerFunc(refsMarker),
}
// markerTest holds all the test data extracted from a test txtar archive.
//
// See the documentation for RunMarkerTests for more information on the archive
// format.
type markerTest struct {
name string // relative path to the txtar file in the testdata dir
fset *token.FileSet // fileset used for parsing notes
content []byte // raw test content
archive *txtar.Archive // original test archive
settings map[string]interface{} // gopls settings
env map[string]string // editor environment
files map[string][]byte // data files from the archive (excluding special files)
notes []*expect.Note // extracted notes from data files
golden map[string]*Golden // extracted golden content, by identifier name
// flags holds flags extracted from the special "flags" archive file.
flags []string
// Parsed flags values.
minGoVersion string
}
// flagSet returns the flagset used for parsing the special "flags" file in the
// test archive.
func (t *markerTest) flagSet() *flag.FlagSet {
flags := flag.NewFlagSet(t.name, flag.ContinueOnError)
flags.StringVar(&t.minGoVersion, "min_go", "", "if set, the minimum go1.X version required for this test")
return flags
}
func (t *markerTest) getGolden(id string) *Golden {
golden, ok := t.golden[id]
// If there was no golden content for this identifier, we must create one
// to handle the case where -update is set: we need a place to store
// the updated content.
if !ok {
golden = &Golden{id: id}
// TODO(adonovan): the separation of markerTest (the
// static aspects) from markerTestRun (the dynamic
// ones) is evidently bogus because here we modify
// markerTest during execution. Let's merge the two.
t.golden[id] = golden
}
return golden
}
// Golden holds extracted golden content for a single @<name> prefix.
//
// When -update is set, golden captures the updated golden contents for later
// writing.
type Golden struct {
id string
data map[string][]byte // key "" => @id itself
updated map[string][]byte
}
// Get returns golden content for the given name, which corresponds to the
// relative path following the golden prefix @<name>/. For example, to access
// the content of @foo/path/to/result.json from the Golden associated with
// @foo, name should be "path/to/result.json".
//
// If -update is set, the given update function will be called to get the
// updated golden content that should be written back to testdata.
//
// Marker functions must use this method instead of accessing data entries
// directly otherwise the -update operation will delete those entries.
//
// TODO(rfindley): rethink the logic here. We may want to separate Get and Set,
// and not delete golden content that isn't set.
func (g *Golden) Get(t testing.TB, name string, updated []byte) ([]byte, bool) {
if existing, ok := g.updated[name]; ok {
// Multiple tests may reference the same golden data, but if they do they
// must agree about its expected content.
if diff := compare.NamedText("existing", "updated", string(existing), string(updated)); diff != "" {
t.Errorf("conflicting updates for golden data %s/%s:\n%s", g.id, name, diff)
}
}
if g.updated == nil {
g.updated = make(map[string][]byte)
}
g.updated[name] = updated
if *update {
return updated, true
}
res, ok := g.data[name]
return res, ok
}
// loadMarkerTests walks the given dir looking for .txt files, which it
// interprets as a txtar archive.
//
// See the documentation for RunMarkerTests for more details on the test data
// archive.
//
// TODO(rfindley): this test could sanity check the results. For example, it is
// too easy to write "// @" instead of "//@", which we will happy skip silently.
func loadMarkerTests(dir string) ([]*markerTest, error) {
var tests []*markerTest
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if strings.HasSuffix(path, ".txt") {
content, err := os.ReadFile(path)
if err != nil {
return err
}
name := strings.TrimPrefix(path, dir+string(filepath.Separator))
test, err := loadMarkerTest(name, content)
if err != nil {
return fmt.Errorf("%s: %v", path, err)
}
tests = append(tests, test)
}
return nil
})
return tests, err
}
func loadMarkerTest(name string, content []byte) (*markerTest, error) {
archive := txtar.Parse(content)
test := &markerTest{
name: name,
fset: token.NewFileSet(),
content: content,
archive: archive,
files: make(map[string][]byte),
golden: make(map[string]*Golden),
}
for _, file := range archive.Files {
switch {
case file.Name == "flags":
test.flags = strings.Fields(string(file.Data))
if err := test.flagSet().Parse(test.flags); err != nil {
return nil, fmt.Errorf("parsing flags: %v", err)
}
case file.Name == "settings.json":
if err := json.Unmarshal(file.Data, &test.settings); err != nil {
return nil, err
}
case file.Name == "env":
test.env = make(map[string]string)
fields := strings.Fields(string(file.Data))
for _, field := range fields {
// TODO: use strings.Cut once we are on 1.18+.
key, value, ok := cut(field, "=")
if !ok {
return nil, fmt.Errorf("env vars must be formatted as var=value, got %q", field)
}
test.env[key] = value
}
case strings.HasPrefix(file.Name, "@"): // golden content
id, name, _ := cut(file.Name[len("@"):], "/")
// Note that a file.Name of just "@id" gives (id, name) = ("id", "").
if _, ok := test.golden[id]; !ok {
test.golden[id] = &Golden{
id: id,
data: make(map[string][]byte),
}
}
test.golden[id].data[name] = file.Data
default: // ordinary file content
notes, err := expect.Parse(test.fset, file.Name, file.Data)
if err != nil {
return nil, fmt.Errorf("parsing notes in %q: %v", file.Name, err)
}
test.notes = append(test.notes, notes...)
test.files[file.Name] = file.Data
}
}
return test, nil
}
// cut is a copy of strings.Cut.
//
// TODO: once we only support Go 1.18+, just use strings.Cut.
func cut(s, sep string) (before, after string, found bool) {
if i := strings.Index(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
}
// formatTest formats the test as a txtar archive.
func formatTest(test *markerTest) ([]byte, error) {
arch := &txtar.Archive{
Comment: test.archive.Comment,
}
updatedGolden := make(map[string][]byte)
for id, g := range test.golden {
for name, data := range g.updated {
filename := "@" + path.Join(id, name) // name may be ""
updatedGolden[filename] = data
}
}
// Preserve the original ordering of archive files.
for _, file := range test.archive.Files {
switch file.Name {
// Preserve configuration files exactly as they were. They must have parsed
// if we got this far.
case "flags", "settings.json", "env":
arch.Files = append(arch.Files, file)
default:
if _, ok := test.files[file.Name]; ok { // ordinary file
arch.Files = append(arch.Files, file)
} else if data, ok := updatedGolden[file.Name]; ok { // golden file
arch.Files = append(arch.Files, txtar.File{Name: file.Name, Data: data})
delete(updatedGolden, file.Name)
}
}
}
// ...followed by any new golden files.
var newGoldenFiles []txtar.File
for filename, data := range updatedGolden {
newGoldenFiles = append(newGoldenFiles, txtar.File{Name: filename, Data: data})
}
// Sort new golden files lexically.
sort.Slice(newGoldenFiles, func(i, j int) bool {
return newGoldenFiles[i].Name < newGoldenFiles[j].Name
})
arch.Files = append(arch.Files, newGoldenFiles...)
return txtar.Format(arch), nil
}
// newEnv creates a new environment for a marker test.
//
// TODO(rfindley): simplify and refactor the construction of testing
// environments across regtests, marker tests, and benchmarks.
func newEnv(t *testing.T, cache *cache.Cache, files map[string][]byte, config fake.EditorConfig) *Env {
sandbox, err := fake.NewSandbox(&fake.SandboxConfig{
RootDir: t.TempDir(),
GOPROXY: "https://proxy.golang.org",
Files: files,
})
if err != nil {
t.Fatal(err)
}
// Put a debug instance in the context to prevent logging to stderr.
// See associated TODO in runner.go: we should revisit this pattern.
ctx := context.Background()
ctx = debug.WithInstance(ctx, "", "off")
awaiter := NewAwaiter(sandbox.Workdir)
ss := lsprpc.NewStreamServer(cache, false, hooks.Options)
server := servertest.NewPipeServer(ss, jsonrpc2.NewRawStream)
const skipApplyEdits = true // capture edits but don't apply them
editor, err := fake.NewEditor(sandbox, config).Connect(ctx, server, awaiter.Hooks(), skipApplyEdits)
if err != nil {
sandbox.Close() // ignore error
t.Fatal(err)
}
if err := awaiter.Await(ctx, InitialWorkspaceLoad); err != nil {
sandbox.Close() // ignore error
t.Fatal(err)
}
return &Env{
T: t,
Ctx: ctx,
Editor: editor,
Sandbox: sandbox,
Awaiter: awaiter,
}
}
// A markerFunc is a reflectively callable @mark implementation function.
type markerFunc struct {
fn reflect.Value // the func to invoke
paramTypes []reflect.Type // parameter types, for zero values
converters []converter // to convert non-blank arguments
variadic bool
}
// A markerTestRun holds the state of one run of a marker test archive.
type markerTestRun struct {
test *markerTest
env *Env
// Collected information.
// Each @diag/@suggestedfix marker eliminates an entry from diags.
locations map[expect.Identifier]protocol.Location
diags map[protocol.Location][]protocol.Diagnostic
}
// sprintf returns a formatted string after applying pre-processing to
// arguments of the following types:
// - token.Pos: formatted using (*markerTestRun).fmtPos
// - protocol.Location: formatted using (*markerTestRun).fmtLoc
func (c *marker) sprintf(format string, args ...interface{}) string {
if false {
_ = fmt.Sprintf(format, args...) // enable vet printf checker
}
var args2 []interface{}
for _, arg := range args {
switch arg := arg.(type) {
case token.Pos:
args2 = append(args2, c.run.fmtPos(arg))
case protocol.Location:
args2 = append(args2, c.run.fmtLoc(arg))
default:
args2 = append(args2, arg)
}
}
return fmt.Sprintf(format, args2...)
}
// fmtLoc formats the given pos in the context of the test, using
// archive-relative paths for files and including the line number in the full
// archive file.
func (run *markerTestRun) fmtPos(pos token.Pos) string {
file := run.test.fset.File(pos)
if file == nil {
run.env.T.Errorf("position %d not in test fileset", pos)
return "<invalid location>"
}
m, err := run.env.Editor.Mapper(file.Name())
if err != nil {
run.env.T.Errorf("%s", err)
return "<invalid location>"
}
loc, err := m.PosLocation(file, pos, pos)
if err != nil {
run.env.T.Errorf("Mapper(%s).PosLocation failed: %v", file.Name(), err)
}
return run.fmtLoc(loc)
}
// fmtLoc formats the given location in the context of the test, using
// archive-relative paths for files and including the line number in the full
// archive file.
func (run *markerTestRun) fmtLoc(loc protocol.Location) string {
if loc == (protocol.Location{}) {
return "<missing location>"
}
lines := bytes.Count(run.test.archive.Comment, []byte("\n"))
var name string
for _, f := range run.test.archive.Files {
lines++ // -- separator --
uri := run.env.Sandbox.Workdir.URI(f.Name)
if uri == loc.URI {
name = f.Name
break
}
lines += bytes.Count(f.Data, []byte("\n"))
}
if name == "" {
run.env.T.Errorf("unable to find %s in test archive", loc)
return "<invalid location>"
}
m, err := run.env.Editor.Mapper(name)
if err != nil {
run.env.T.Errorf("internal error: %v", err)
return "<invalid location>"
}
s, err := m.LocationSpan(loc)
if err != nil {
run.env.T.Errorf("error formatting location %s: %v", loc, err)
return "<invalid location>"
}
innerSpan := fmt.Sprintf("%d:%d", s.Start().Line(), s.Start().Column()) // relative to the embedded file
outerSpan := fmt.Sprintf("%d:%d", lines+s.Start().Line(), s.Start().Column()) // relative to the archive file
if s.Start() != s.End() {
if s.End().Line() == s.Start().Line() {
innerSpan += fmt.Sprintf("-%d", s.End().Column())
outerSpan += fmt.Sprintf("-%d", s.End().Column())
} else {
innerSpan += fmt.Sprintf("-%d:%d", s.End().Line(), s.End().Column())
innerSpan += fmt.Sprintf("-%d:%d", lines+s.End().Line(), s.End().Column())
}
}
return fmt.Sprintf("%s:%s (%s:%s)", name, innerSpan, run.test.name, outerSpan)
}
// makeMarkerFunc uses reflection to create a markerFunc for the given func value.
func makeMarkerFunc(fn interface{}) markerFunc {
mi := markerFunc{
fn: reflect.ValueOf(fn),
}
mtyp := mi.fn.Type()
mi.variadic = mtyp.IsVariadic()
if mtyp.NumIn() == 0 || mtyp.In(0) != markerType {
panic(fmt.Sprintf("marker function %#v must accept marker as its first argument", mi.fn))
}
if mtyp.NumOut() != 0 {
panic(fmt.Sprintf("marker function %#v must not have results", mi.fn))
}
for a := 1; a < mtyp.NumIn(); a++ {
in := mtyp.In(a)
if mi.variadic && a == mtyp.NumIn()-1 {
in = in.Elem() // for ...T, convert to T
}
mi.paramTypes = append(mi.paramTypes, in)
c := makeConverter(in)
mi.converters = append(mi.converters, c)
}
return mi
}
// ---- converters ----
// converter is the signature of argument converters.
// A converter should return an error rather than calling marker.errorf().
type converter func(marker, interface{}) (interface{}, error)
// Types with special conversions.
var (
goldenType = reflect.TypeOf(&Golden{})
locationType = reflect.TypeOf(protocol.Location{})
markerType = reflect.TypeOf(marker{})
regexpType = reflect.TypeOf(&regexp.Regexp{})
wantErrorType = reflect.TypeOf(wantError{})
)
func makeConverter(paramType reflect.Type) converter {
switch paramType {
case goldenType:
return goldenConverter
case locationType:
return locationConverter
case wantErrorType:
return wantErrorConverter
default:
return func(_ marker, arg interface{}) (interface{}, error) {
if argType := reflect.TypeOf(arg); argType != paramType {
return nil, fmt.Errorf("cannot convert type %s to %s", argType, paramType)
}
return arg, nil
}
}
}
// locationConverter converts a string argument into the protocol location
// corresponding to the first position of the string in the line preceding the
// note.
func locationConverter(mark marker, arg interface{}) (interface{}, error) {
switch arg := arg.(type) {
case string:
startOff, preceding, m, err := linePreceding(mark.run, mark.note.Pos)
if err != nil {
return protocol.Location{}, err
}
idx := bytes.Index(preceding, []byte(arg))
if idx < 0 {
return nil, fmt.Errorf("substring %q not found in %q", arg, preceding)
}
off := startOff + idx
return m.OffsetLocation(off, off+len(arg))
case *regexp.Regexp:
return findRegexpInLine(mark.run, mark.note.Pos, arg)
case expect.Identifier:
loc, ok := mark.run.locations[arg]
if !ok {
return nil, fmt.Errorf("no location named %q", arg)
}
return loc, nil
default:
return nil, fmt.Errorf("cannot convert argument type %T to location (must be a string to match the preceding line)", arg)
}
}
// findRegexpInLine searches the partial line preceding pos for a match for the
// regular expression re, returning a location spanning the first match. If re
// contains exactly one subgroup, the position of this subgroup match is
// returned rather than the position of the full match.
func findRegexpInLine(run *markerTestRun, pos token.Pos, re *regexp.Regexp) (protocol.Location, error) {
startOff, preceding, m, err := linePreceding(run, pos)
if err != nil {
return protocol.Location{}, err
}
matches := re.FindSubmatchIndex(preceding)
if len(matches) == 0 {
return protocol.Location{}, fmt.Errorf("no match for regexp %q found in %q", re, string(preceding))
}
var start, end int
switch len(matches) {
case 2:
// no subgroups: return the range of the regexp expression
start, end = matches[0], matches[1]
case 4:
// one subgroup: return its range
start, end = matches[2], matches[3]
default:
return protocol.Location{}, fmt.Errorf("invalid location regexp %q: expect either 0 or 1 subgroups, got %d", re, len(matches)/2-1)
}
return m.OffsetLocation(start+startOff, end+startOff)
}
func linePreceding(run *markerTestRun, pos token.Pos) (int, []byte, *protocol.Mapper, error) {
file := run.test.fset.File(pos)
posn := safetoken.Position(file, pos)
lineStart := file.LineStart(posn.Line)
startOff, endOff, err := safetoken.Offsets(file, lineStart, pos)
if err != nil {
return 0, nil, nil, err
}
m, err := run.env.Editor.Mapper(file.Name())
if err != nil {
return 0, nil, nil, err
}
return startOff, m.Content[startOff:endOff], m, nil
}
// wantErrorConverter converts a string, regexp, or identifier
// argument into a wantError. The string is a substring of the
// expected error, the regexp is a pattern than matches the expected
// error, and the identifier is a golden file containing the expected
// error.
func wantErrorConverter(mark marker, arg interface{}) (interface{}, error) {
switch arg := arg.(type) {
case string:
return wantError{substr: arg}, nil
case *regexp.Regexp:
return wantError{pattern: arg}, nil
case expect.Identifier:
golden := mark.run.test.getGolden(string(arg))
return wantError{golden: golden}, nil
default:
return nil, fmt.Errorf("cannot convert %T to wantError (want: string, regexp, or identifier)", arg)
}
}
// A wantError represents an expectation of a specific error message.
//
// It may be indicated in one of three ways, in 'expect' notation:
// - an identifier 'foo', to compare with the contents of the golden section @foo;
// - a pattern expression re"ab.*c", to match against a regular expression;
// - a string literal "abc", to check for a substring.
type wantError struct {
golden *Golden
pattern *regexp.Regexp
substr string
}
func (we wantError) String() string {
if we.golden != nil {
return fmt.Sprintf("error from @%s entry", we.golden.id)
} else if we.pattern != nil {
return fmt.Sprintf("error matching %#q", we.pattern)
} else {
return fmt.Sprintf("error with substring %q", we.substr)
}
}
// check asserts that 'err' matches the wantError's expectations.
func (we wantError) check(mark marker, err error) {
if err == nil {
mark.errorf("@%s succeeded unexpectedly, want %v", mark.note.Name, we)
return
}
got := err.Error()
if we.golden != nil {
// Error message must match @id golden file.
wantBytes, ok := we.golden.Get(mark.run.env.T, "", []byte(got))
if !ok {
mark.errorf("@%s: missing @%s entry", mark.note.Name, we.golden.id)
return
}
want := strings.TrimSpace(string(wantBytes))
if got != want {
// (ignore leading/trailing space)
mark.errorf("@%s failed with wrong error: got:\n%s\nwant:\n%s\ndiff:\n%s",
mark.note.Name, got, want, compare.Text(want, got))
}
} else if we.pattern != nil {
// Error message must match regular expression pattern.
if !we.pattern.MatchString(got) {
mark.errorf("got error %q, does not match pattern %#q", got, we.pattern)
}
} else if !strings.Contains(got, we.substr) {
// Error message must contain expected substring.
mark.errorf("got error %q, want substring %q", got, we.substr)
}
}
// goldenConverter converts an identifier into the Golden directory of content
// prefixed by @<ident> in the test archive file.
func goldenConverter(mark marker, arg interface{}) (interface{}, error) {
switch arg := arg.(type) {
case expect.Identifier:
return mark.run.test.getGolden(string(arg)), nil
default:
return nil, fmt.Errorf("invalid input type %T: golden key must be an identifier", arg)
}
}
// checkChangedFiles compares the files changed by an operation with their expected (golden) state.
func checkChangedFiles(mark marker, changed map[string][]byte, golden *Golden) {
// Check changed files match expectations.
for filename, got := range changed {
if want, ok := golden.Get(mark.run.env.T, filename, got); !ok {
mark.errorf("%s: unexpected change to file %s; got:\n%s",
mark.note.Name, filename, got)
} else if string(got) != string(want) {
mark.errorf("%s: wrong file content for %s: got:\n%s\nwant:\n%s\ndiff:\n%s",
mark.note.Name, filename, got, want,
compare.Bytes(want, got))
}
}
// Report unmet expectations.
for filename := range golden.data {
if _, ok := changed[filename]; !ok {
want, _ := golden.Get(mark.run.env.T, filename, nil)
mark.errorf("%s: missing change to file %s; want:\n%s",
mark.note.Name, filename, want)
}
}
}
// ---- marker functions ----
// completeMarker implements the @complete marker, running
// textDocument/completion at the given src location and asserting that the
// results match the expected results.
//
// TODO(rfindley): for now, this is just a quick check against the expected
// completion labels. We could do more by assembling richer completion items,
// as is done in the old marker tests. Does that add value? If so, perhaps we
// should support a variant form of the argument, labelOrItem, which allows the
// string form or item form.
func completeMarker(mark marker, src protocol.Location, want ...string) {
list := mark.run.env.Completion(src)
var got []string
for _, item := range list.Items {
got = append(got, item.Label)
}
if diff := cmp.Diff(want, got); diff != "" {
mark.errorf("Completion(...) returned unexpect results (-want +got):\n%s", diff)
}
}
// defMarker implements the @godef marker, running textDocument/definition at
// the given src location and asserting that there is exactly one resulting
// location, matching dst.
//
// TODO(rfindley): support a variadic destination set.
func defMarker(mark marker, src, dst protocol.Location) {
got := mark.run.env.GoToDefinition(src)
if got != dst {
mark.errorf("definition location does not match:\n\tgot: %s\n\twant %s",
mark.run.fmtLoc(got), mark.run.fmtLoc(dst))
}
}
// hoverMarker implements the @hover marker, running textDocument/hover at the
// given src location and asserting that the resulting hover is over the dst
// location (typically a span surrounding src), and that the markdown content
// matches the golden content.
func hoverMarker(mark marker, src, dst protocol.Location, golden *Golden) {
content, gotDst := mark.run.env.Hover(src)
if gotDst != dst {
mark.errorf("hover location does not match:\n\tgot: %s\n\twant %s)", mark.run.fmtLoc(gotDst), mark.run.fmtLoc(dst))
}
gotMD := ""
if content != nil {
gotMD = content.Value
}
wantMD := ""
if golden != nil {
wantBytes, _ := golden.Get(mark.run.env.T, "hover.md", []byte(gotMD))
wantMD = string(wantBytes)
}
// Normalize newline termination: archive files can't express non-newline
// terminated files.
if strings.HasSuffix(wantMD, "\n") && !strings.HasSuffix(gotMD, "\n") {
gotMD += "\n"
}
if diff := tests.DiffMarkdown(wantMD, gotMD); diff != "" {
mark.errorf("hover markdown mismatch (-want +got):\n%s", diff)
}
}
// locMarker implements the @loc marker. It is executed before other
// markers, so that locations are available.
func locMarker(mark marker, name expect.Identifier, loc protocol.Location) {
if prev, dup := mark.run.locations[name]; dup {
mark.errorf("location %q already declared at %s",
name, mark.run.fmtLoc(prev))
return
}
mark.run.locations[name] = loc
}
// diagMarker implements the @diag marker. It eliminates diagnostics from
// the observed set in mark.test.
func diagMarker(mark marker, loc protocol.Location, re *regexp.Regexp) {
if _, err := removeDiagnostic(mark, loc, re); err != nil {
mark.errorf("%v", err)
}
}
func removeDiagnostic(mark marker, loc protocol.Location, re *regexp.Regexp) (protocol.Diagnostic, error) {
diags := mark.run.diags[loc]
for i, diag := range diags {
if re.MatchString(diag.Message) {
mark.run.diags[loc] = append(diags[:i], diags[i+1:]...)
return diag, nil
}
}
return protocol.Diagnostic{}, errors.New(mark.sprintf("no diagnostic at %v matches %q", loc, re))
}
// renameMarker implements the @rename(location, new, golden) marker.
func renameMarker(mark marker, loc protocol.Location, newName expect.Identifier, golden *Golden) {
changed, err := rename(mark.run.env, loc, string(newName))
if err != nil {
mark.errorf("rename failed: %v. (Use @renameerr for expected errors.)", err)
return
}
checkChangedFiles(mark, changed, golden)
}
// renameErrMarker implements the @renamererr(location, new, error) marker.
func renameErrMarker(mark marker, loc protocol.Location, newName expect.Identifier, wantErr wantError) {
_, err := rename(mark.run.env, loc, string(newName))
wantErr.check(mark, err)
}
// rename returns the new contents of the files that would be modified
// by renaming the identifier at loc to newName.
func rename(env *Env, loc protocol.Location, newName string) (map[string][]byte, error) {
// We call Server.Rename directly, instead of
// env.Editor.Rename(env.Ctx, loc, newName)
// to isolate Rename from PrepareRename, and because we don't
// want to modify the file system in a scenario with multiple
// @rename markers.
editMap, err := env.Editor.Server.Rename(env.Ctx, &protocol.RenameParams{
TextDocument: protocol.TextDocumentIdentifier{URI: loc.URI},
Position: loc.Range.Start,
NewName: string(newName),
})
if err != nil {
return nil, err
}
return applyDocumentChanges(env, editMap.DocumentChanges)
}
// applyDocumentChanges returns the effect of applying the document
// changes to the contents of the Editor buffers. The actual editor
// buffers are unchanged.
func applyDocumentChanges(env *Env, changes []protocol.DocumentChanges) (map[string][]byte, error) {
result := make(map[string][]byte)
for _, change := range changes {
if change.RenameFile != nil {
// rename
oldFile := env.Sandbox.Workdir.URIToPath(change.RenameFile.OldURI)
newFile := env.Sandbox.Workdir.URIToPath(change.RenameFile.NewURI)
mapper, err := env.Editor.Mapper(oldFile)
if err != nil {
return nil, err
}
result[newFile] = mapper.Content
} else {
// edit
filename := env.Sandbox.Workdir.URIToPath(change.TextDocumentEdit.TextDocument.URI)
mapper, err := env.Editor.Mapper(filename)
if err != nil {
return nil, err
}
patched, _, err := source.ApplyProtocolEdits(mapper, change.TextDocumentEdit.Edits)
if err != nil {
return nil, err
}
result[filename] = patched
}
}
return result, nil
}
// suggestedfixMarker implements the @suggestedfix(location, regexp,
// kind, golden) marker. It acts like @diag(location, regexp), to set
// the expectation of a diagnostic, but then it applies the first code
// action of the specified kind suggested by the matched diagnostic.
func suggestedfixMarker(mark marker, loc protocol.Location, re *regexp.Regexp, actionKind string, golden *Golden) {
// Find and remove the matching diagnostic.
diag, err := removeDiagnostic(mark, loc, re)
if err != nil {
mark.errorf("%v", err)
return
}
// Apply the fix it suggests.
changed, err := suggestedfix(mark.run.env, loc, diag, actionKind)
if err != nil {
mark.errorf("suggestedfix failed: %v. (Use @suggestedfixerr for expected errors.)", err)
return
}
// Check the file state.
checkChangedFiles(mark, changed, golden)
}
func suggestedfix(env *Env, loc protocol.Location, diag protocol.Diagnostic, actionKind string) (map[string][]byte, error) {
// Request all code actions that apply to the diagnostic.
// (The protocol supports filtering using Context.Only={actionKind}
// but we can give a better error if we don't filter.)
actions, err := env.Editor.Server.CodeAction(env.Ctx, &protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{URI: loc.URI},
Range: diag.Range,
Context: protocol.CodeActionContext{
Only: nil, // => all kinds
Diagnostics: []protocol.Diagnostic{diag},
},
})
if err != nil {
return nil, err
}
// Find the sole candidates CodeAction of the specified kind (e.g. refactor.rewrite).
var candidates []protocol.CodeAction
for _, act := range actions {
if act.Kind == protocol.CodeActionKind(actionKind) {
candidates = append(candidates, act)
}
}
if len(candidates) != 1 {
for _, act := range actions {
env.T.Logf("found CodeAction Kind=%s Title=%q", act.Kind, act.Title)
}
return nil, fmt.Errorf("found %d CodeActions of kind %s for this diagnostic, want 1", len(candidates), actionKind)
}
action := candidates[0]
// 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 all commands used in the @suggestedfix tests
// return only a command.
if action.Edit.DocumentChanges != nil {
env.T.Errorf("internal error: discarding unexpected CodeAction{Kind=%s, Title=%q}.Edit.DocumentChanges", action.Kind, action.Title)
}
if action.Command == nil {
return nil, fmt.Errorf("missing CodeAction{Kind=%s, Title=%q}.Command", action.Kind, action.Title)
}
// 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" suggestedfix hook (the meat).
// The server then makes an ApplyEdit RPC to the client,
// whose Awaiter hook gathers the edits instead of applying them.
_ = env.Awaiter.takeDocumentChanges() // reset (assuming Env is confined to this thread)
if _, err := env.Editor.Server.ExecuteCommand(env.Ctx, &protocol.ExecuteCommandParams{
Command: action.Command.Command,
Arguments: action.Command.Arguments,
}); err != nil {
env.T.Fatalf("error converting command %q to edits: %v", action.Command.Command, err)
}
return applyDocumentChanges(env, env.Awaiter.takeDocumentChanges())
}
// TODO(adonovan): suggestedfixerr
// refsMarker implements the @refs marker.
func refsMarker(mark marker, src protocol.Location, want ...protocol.Location) {
refs := func(includeDeclaration bool, want []protocol.Location) error {
params := &protocol.ReferenceParams{
TextDocumentPositionParams: protocol.LocationTextDocumentPositionParams(src),
Context: protocol.ReferenceContext{
IncludeDeclaration: includeDeclaration,
},
}
got, err := mark.run.env.Editor.Server.References(mark.run.env.Ctx, params)
if err != nil {
return err
}
// Compare the sets of locations.
toString := func(locs []protocol.Location) string {
// TODO(adonovan): use generic JoinValues(locs, fmtLoc).
strs := make([]string, len(locs))
for i, loc := range locs {
strs[i] = mark.run.fmtLoc(loc)
}
sort.Strings(strs)
return strings.Join(strs, "\n")
}
gotStr := toString(got)
wantStr := toString(want)
if gotStr != wantStr {
return fmt.Errorf("incorrect references (got %d, want %d) at %s:\n%s",
len(got), len(want),
mark.run.fmtLoc(src),
diff.Unified("want", "got", wantStr, gotStr))
}
return nil
}
for _, includeDeclaration := range []bool{false, true} {
// Ignore first 'want' location if we didn't request the declaration.
// TODO(adonovan): don't assume a single declaration:
// there may be >1 if corresponding methods are considered.
want := want
if !includeDeclaration && len(want) > 0 {
want = want[1:]
}
if err := refs(includeDeclaration, want); err != nil {
mark.errorf("refs(includeDeclaration=%t) failed: %v",
includeDeclaration, err)
}
}
}