blob: a128bcb7f187d8e2ff2d7b000c3ce7c1fee51ba4 [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 marker
// This file defines the marker test framework.
// See doc.go for extensive documentation.
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"go/token"
"go/types"
"io/fs"
"log"
"os"
"path"
"path/filepath"
"reflect"
"regexp"
"runtime"
"slices"
"sort"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"golang.org/x/tools/go/expect"
"golang.org/x/tools/gopls/internal/cache"
"golang.org/x/tools/gopls/internal/debug"
"golang.org/x/tools/gopls/internal/lsprpc"
"golang.org/x/tools/gopls/internal/protocol"
"golang.org/x/tools/gopls/internal/test/compare"
"golang.org/x/tools/gopls/internal/test/integration"
"golang.org/x/tools/gopls/internal/test/integration/fake"
"golang.org/x/tools/gopls/internal/util/bug"
"golang.org/x/tools/gopls/internal/util/safetoken"
"golang.org/x/tools/internal/diff"
"golang.org/x/tools/internal/diff/myers"
"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")
func TestMain(m *testing.M) {
bug.PanicOnBugs = true
testenv.ExitIfSmallMachine()
// Disable GOPACKAGESDRIVER, as it can cause spurious test failures.
os.Setenv("GOPACKAGESDRIVER", "off")
os.Exit(m.Run())
}
// Test runs the marker tests from the testdata directory.
//
// See package documentation for details on how marker tests work.
//
// 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 integration
// test 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.
func Test(t *testing.T) {
if testing.Short() {
builder := os.Getenv("GO_BUILDER_NAME")
// Note that HasPrefix(builder, "darwin-" only matches legacy builders.
// LUCI builder names start with x_tools-goN.NN.
// We want to exclude solaris on both legacy and LUCI builders, as
// it is timing out.
if strings.HasPrefix(builder, "darwin-") || strings.Contains(builder, "solaris") {
t.Skip("golang/go#64473: skipping with -short: this test is too slow on darwin and solaris builders")
}
}
// The marker tests must be able to run go/packages.Load.
testenv.NeedsGoPackages(t)
const dir = "testdata"
tests, err := loadMarkerTests(dir)
if err != nil {
t.Fatal(err)
}
// Opt: use a shared cache.
cache := cache.New(nil)
// Opt: seed the cache and file cache by type-checking and analyzing common
// standard library packages.
seedCache(t, cache)
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
if test.skipReason != "" {
t.Skip(test.skipReason)
}
if slices.Contains(test.skipGOOS, runtime.GOOS) {
t.Skipf("skipping on %s due to -skip_goos", runtime.GOOS)
}
if slices.Contains(test.skipGOARCH, runtime.GOARCH) {
t.Skipf("skipping on %s due to -skip_goarch", runtime.GOARCH)
}
// 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, go1point)
}
if test.maxGoVersion != "" {
var go1point int
if _, err := fmt.Sscanf(test.maxGoVersion, "go1.%d", &go1point); err != nil {
t.Fatalf("parsing -max_go version: %v", err)
}
testenv.SkipAfterGo1Point(t, go1point)
}
if test.cgo {
testenv.NeedsTool(t, "cgo")
}
config := fake.EditorConfig{
Settings: test.settings,
CapabilitiesJSON: test.capabilities,
Env: test.env,
}
if _, ok := config.Settings["diagnosticsDelay"]; !ok {
if config.Settings == nil {
config.Settings = make(map[string]any)
}
config.Settings["diagnosticsDelay"] = "10ms"
}
// inv: config.Settings != nil
run := &markerTestRun{
test: test,
env: newEnv(t, cache, test.files, test.proxyFiles, test.writeGoSum, config),
settings: config.Settings,
values: make(map[expect.Identifier]any),
diags: make(map[protocol.Location][]protocol.Diagnostic),
extraNotes: make(map[protocol.DocumentURI]map[string][]*expect.Note),
}
// TODO(rfindley): make it easier to clean up the integration test 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)
}
// Wait for the didOpen notifications to be processed, then collect
// diagnostics.
var diags map[string]*protocol.PublishDiagnosticsParams
run.env.AfterChange(integration.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: protocol.Range{
Start: diag.Range.Start,
End: diag.Range.Start, // ignore end positions
},
}
run.diags[loc] = append(run.diags[loc], diag)
}
}
var markers []marker
for _, note := range test.notes {
mark := marker{run: run, note: note}
if fn, ok := valueMarkerFuncs[note.Name]; ok {
fn(mark)
} else if _, ok := actionMarkerFuncs[note.Name]; ok {
markers = append(markers, mark) // save for later
} else {
uri := mark.uri()
if run.extraNotes[uri] == nil {
run.extraNotes[uri] = make(map[string][]*expect.Note)
}
run.extraNotes[uri][note.Name] = append(run.extraNotes[uri][note.Name], note)
}
}
// Invoke each remaining marker in the test.
for _, mark := range markers {
actionMarkerFuncs[mark.note.Name](mark)
}
// Any remaining (un-eliminated) diagnostics are an error.
if !test.ignoreExtraDiags {
for loc, diags := range run.diags {
for _, diag := range diags {
t.Errorf("%s: unexpected diagnostic: %q", run.fmtLoc(loc), diag.Message)
}
}
}
// TODO(rfindley): use these for whole-file marker tests.
for uri, extras := range run.extraNotes {
for name, extra := range extras {
if len(extra) > 0 {
t.Errorf("%s: %d unused %q markers", run.env.Sandbox.Workdir.URIToPath(uri), len(extra), name)
}
}
}
// Now that all markers have executed, check whether there where any
// unexpected error logs.
// This guards against noisiness: see golang/go#66746)
if !test.errorsOK {
run.env.AfterChange(integration.NoErrorLogs())
}
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 if !t.Failed() {
// Verify that the testdata has not changed.
//
// Only check this if the test hasn't already failed, otherwise we'd
// report duplicate mismatches of golden data.
// 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)
}
}
// seedCache populates the file cache by type checking and analyzing standard
// library packages that are reachable from tests.
//
// Most tests are themselves small codebases, and yet may reference large
// amounts of standard library code. Since tests are heavily parallelized, they
// naively end up type checking and analyzing many of the same standard library
// packages. By seeding the cache, we ensure cache hits for these standard
// library packages, significantly reducing the amount of work done by each
// test.
//
// The following command was used to determine the set of packages to import
// below:
//
// rm -rf ~/.cache/gopls && \
// go test -count=1 ./internal/test/marker -cpuprofile=prof -v
//
// Look through the individual test timings to see which tests are slow, then
// look through the imports of slow tests to see which standard library
// packages are imported. Choose high level packages such as go/types that
// import others such as fmt or go/ast. After doing so, re-run the command and
// verify that the total samples in the collected profile decreased.
func seedCache(t *testing.T, cache *cache.Cache) {
start := time.Now()
// The the doc string for details on how this seed was produced.
seed := `package p
import (
_ "net/http"
_ "sort"
_ "go/types"
_ "testing"
)
`
// Create a test environment for the seed file.
env := newEnv(t, cache, map[string][]byte{"p.go": []byte(seed)}, nil, nil, fake.EditorConfig{})
// See other TODO: this cleanup logic is too messy.
defer env.Editor.Shutdown(context.Background()) // ignore error
defer env.Sandbox.Close() // ignore error
env.Awaiter.Await(context.Background(), integration.InitialWorkspaceLoad)
// Opening the file is necessary to trigger analysis.
env.OpenFile("p.go")
// As a checksum, verify that the file has no errors after analysis.
// This isn't strictly necessary, but helps avoid incorrect seeding due to
// typos.
env.AfterChange(integration.NoDiagnostics())
t.Logf("warming the cache took %s", time.Since(start))
}
// A marker holds state for the execution of a single @marker
// annotation in the source.
type marker struct {
run *markerTestRun
note *expect.Note
}
// ctx returns the mark context.
func (m marker) ctx() context.Context { return m.run.env.Ctx }
// T returns the testing.TB for this mark.
func (m marker) T() testing.TB { return m.run.env.T }
// server returns the LSP server for the marker test run.
func (m marker) editor() *fake.Editor { return m.run.env.Editor }
// server returns the LSP server for the marker test run.
func (m marker) server() protocol.Server { return m.run.env.Editor.Server }
// uri returns the URI of the file containing the marker.
func (mark marker) uri() protocol.DocumentURI {
return mark.run.env.Sandbox.Workdir.URI(mark.run.test.fset.File(mark.note.Pos).Name())
}
// document returns a protocol.TextDocumentIdentifier for the current file.
func (mark marker) document() protocol.TextDocumentIdentifier {
return protocol.TextDocumentIdentifier{URI: mark.uri()}
}
// path returns the relative path to the file containing the marker.
func (mark marker) path() string {
return mark.run.env.Sandbox.Workdir.RelPath(mark.run.test.fset.File(mark.note.Pos).Name())
}
// mapper returns a *protocol.Mapper for the current file.
func (mark marker) mapper() *protocol.Mapper {
mapper, err := mark.editor().Mapper(mark.path())
if err != nil {
mark.T().Fatalf("failed to get mapper for current mark: %v", err)
}
return mapper
}
// 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 ...any) {
mark.T().Helper()
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.T().Errorf("%s: %s", mark.run.fmtPos(mark.note.Pos), msg)
}
// valueMarkerFunc returns a wrapper around a function that allows it to be
// called during the processing of value markers (e.g. @value(v, 123)) with marker
// arguments converted to function parameters. The provided function's first
// parameter must be of type 'marker', and it must return a value.
//
// Unlike action markers, which are executed for actions such as test
// assertions, value markers are all evaluated first, and each computes
// a value that is recorded by its identifier, which is the marker's first
// argument. These values may be referred to from an action marker by
// this identifier, e.g. @action(... , v, ...).
//
// For example, given a fn with signature
//
// func(mark marker, label, details, kind string) CompletionItem
//
// The result of valueMarkerFunc can associated with @item notes, and invoked
// as follows:
//
// //@item(FooCompletion, "Foo", "func() int", "func")
//
// The provided fn should not mutate the test environment.
func valueMarkerFunc(fn any) func(marker) {
ftype := reflect.TypeOf(fn)
if ftype.NumIn() == 0 || ftype.In(0) != markerType {
panic(fmt.Sprintf("value marker function %#v must accept marker as its first argument", ftype))
}
if ftype.NumOut() != 1 {
panic(fmt.Sprintf("value marker function %#v must have exactly 1 result", ftype))
}
return func(mark marker) {
if len(mark.note.Args) == 0 || !is[expect.Identifier](mark.note.Args[0]) {
mark.errorf("first argument to a value marker function must be an identifier")
return
}
id := mark.note.Args[0].(expect.Identifier)
if alt, ok := mark.run.values[id]; ok {
mark.errorf("%s already declared as %T", id, alt)
return
}
args := append([]any{mark}, mark.note.Args[1:]...)
argValues, err := convertArgs(mark, ftype, args)
if err != nil {
mark.errorf("converting args: %v", err)
return
}
results := reflect.ValueOf(fn).Call(argValues)
mark.run.values[id] = results[0].Interface()
}
}
// actionMarkerFunc returns a wrapper around a function that allows it to be
// called during the processing of action markers (e.g. @action("abc", 123))
// with marker arguments converted to function parameters. The provided
// function's first parameter must be of type 'marker', and it must not return
// any values.
//
// The provided fn should not mutate the test environment.
func actionMarkerFunc(fn any) func(marker) {
ftype := reflect.TypeOf(fn)
if ftype.NumIn() == 0 || ftype.In(0) != markerType {
panic(fmt.Sprintf("action marker function %#v must accept marker as its first argument", ftype))
}
if ftype.NumOut() != 0 {
panic(fmt.Sprintf("action marker function %#v cannot have results", ftype))
}
return func(mark marker) {
args := append([]any{mark}, mark.note.Args...)
argValues, err := convertArgs(mark, ftype, args)
if err != nil {
mark.errorf("converting args: %v", err)
return
}
reflect.ValueOf(fn).Call(argValues)
}
}
func convertArgs(mark marker, ftype reflect.Type, args []any) ([]reflect.Value, error) {
var (
argValues []reflect.Value
pnext int // next param index
p reflect.Type // current param
)
for i, arg := range args {
if i < ftype.NumIn() {
p = ftype.In(pnext)
pnext++
} else if p == nil || !ftype.IsVariadic() {
// The actual number of arguments expected by the mark varies, depending
// on whether this is a value marker or an action marker.
//
// Since this error indicates a bug, probably OK to have an imprecise
// error message here.
return nil, fmt.Errorf("too many arguments to %s", mark.note.Name)
}
elemType := p
if ftype.IsVariadic() && pnext == ftype.NumIn() {
elemType = p.Elem()
}
var v reflect.Value
if id, ok := arg.(expect.Identifier); ok && id == "_" {
v = reflect.Zero(elemType)
} else {
a, err := convert(mark, arg, elemType)
if err != nil {
return nil, err
}
v = reflect.ValueOf(a)
}
argValues = append(argValues, v)
}
// Check that we have sufficient arguments. If the function is variadic, we
// do not need arguments for the final parameter.
if pnext < ftype.NumIn()-1 || pnext == ftype.NumIn()-1 && !ftype.IsVariadic() {
// Same comment as above: OK to be vague here.
return nil, fmt.Errorf("not enough arguments to %s", mark.note.Name)
}
return argValues, nil
}
// is reports whether arg is a T.
func is[T any](arg any) bool {
_, ok := arg.(T)
return ok
}
// Supported value marker functions. See [valueMarkerFunc] for more details.
var valueMarkerFuncs = map[string]func(marker){
"loc": valueMarkerFunc(locMarker),
"item": valueMarkerFunc(completionItemMarker),
"hiloc": valueMarkerFunc(highlightLocationMarker),
}
// Supported action marker functions. See [actionMarkerFunc] for more details.
var actionMarkerFuncs = map[string]func(marker){
"acceptcompletion": actionMarkerFunc(acceptCompletionMarker),
"codeaction": actionMarkerFunc(codeActionMarker),
"codeactionedit": actionMarkerFunc(codeActionEditMarker),
"codeactionerr": actionMarkerFunc(codeActionErrMarker),
"codelenses": actionMarkerFunc(codeLensesMarker),
"complete": actionMarkerFunc(completeMarker),
"def": actionMarkerFunc(defMarker),
"diag": actionMarkerFunc(diagMarker),
"documentlink": actionMarkerFunc(documentLinkMarker),
"foldingrange": actionMarkerFunc(foldingRangeMarker),
"format": actionMarkerFunc(formatMarker),
"highlight": actionMarkerFunc(highlightMarker),
"highlightall": actionMarkerFunc(highlightAllMarker),
"hover": actionMarkerFunc(hoverMarker),
"hovererr": actionMarkerFunc(hoverErrMarker),
"implementation": actionMarkerFunc(implementationMarker),
"incomingcalls": actionMarkerFunc(incomingCallsMarker),
"inlayhints": actionMarkerFunc(inlayhintsMarker),
"outgoingcalls": actionMarkerFunc(outgoingCallsMarker),
"preparerename": actionMarkerFunc(prepareRenameMarker),
"rank": actionMarkerFunc(rankMarker),
"refs": actionMarkerFunc(refsMarker),
"rename": actionMarkerFunc(renameMarker),
"renameerr": actionMarkerFunc(renameErrMarker),
"selectionrange": actionMarkerFunc(selectionRangeMarker),
"signature": actionMarkerFunc(signatureMarker),
"snippet": actionMarkerFunc(snippetMarker),
"suggestedfix": actionMarkerFunc(suggestedfixMarker),
"suggestedfixerr": actionMarkerFunc(suggestedfixErrMarker),
"symbol": actionMarkerFunc(symbolMarker),
"token": actionMarkerFunc(tokenMarker),
"typedef": actionMarkerFunc(typedefMarker),
"workspacesymbol": actionMarkerFunc(workspaceSymbolMarker),
}
// 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]any // gopls settings
capabilities []byte // content of capabilities.json file
env map[string]string // editor environment
proxyFiles map[string][]byte // proxy content
files map[string][]byte // data files from the archive (excluding special files)
notes []*expect.Note // extracted notes from data files
golden map[expect.Identifier]*Golden // extracted golden content, by identifier name
skipReason string // the skip reason extracted from the "skip" archive file
flags []string // flags extracted from the special "flags" archive file.
// Parsed flags values. See the flag definitions below for documentation.
minGoVersion string
maxGoVersion string
cgo bool
writeGoSum []string
skipGOOS []string
skipGOARCH []string
ignoreExtraDiags bool
filterBuiltins bool
filterKeywords bool
errorsOK bool
}
// 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")
flags.StringVar(&t.maxGoVersion, "max_go", "", "if set, the maximum go1.X version required for this test")
flags.BoolVar(&t.cgo, "cgo", false, "if set, requires cgo (both the cgo tool and CGO_ENABLED=1)")
flags.Var((*stringListValue)(&t.writeGoSum), "write_sumfile", "if set, write the sumfile for these directories")
flags.Var((*stringListValue)(&t.skipGOOS), "skip_goos", "if set, skip this test on these GOOS values")
flags.Var((*stringListValue)(&t.skipGOARCH), "skip_goarch", "if set, skip this test on these GOARCH values")
flags.BoolVar(&t.ignoreExtraDiags, "ignore_extra_diags", false, "if set, suppress errors for unmatched diagnostics")
flags.BoolVar(&t.filterBuiltins, "filter_builtins", true, "if set, filter builtins from completion results")
flags.BoolVar(&t.filterKeywords, "filter_keywords", true, "if set, filter keywords from completion results")
flags.BoolVar(&t.errorsOK, "errors_ok", false, "if set, Error level log messages are acceptable in this test")
return flags
}
// stringListValue implements flag.Value.
type stringListValue []string
func (l *stringListValue) Set(s string) error {
if s != "" {
for _, d := range strings.Split(s, ",") {
*l = append(*l, strings.TrimSpace(d))
}
}
return nil
}
func (l stringListValue) String() string {
return strings.Join([]string(l), ",")
}
func (t *markerTest) getGolden(id expect.Identifier) *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 expect.Identifier
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.
func loadMarkerTests(dir string) ([]*markerTest, error) {
var tests []*markerTest
err := filepath.WalkDir(dir, func(path string, _ 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 err
})
return tests, err
}
func loadMarkerTest(name string, content []byte) (*markerTest, error) {
archive := txtar.Parse(content)
if len(archive.Files) == 0 {
return nil, fmt.Errorf("txtar file has no '-- filename --' sections")
}
if bytes.Contains(archive.Comment, []byte("\n-- ")) {
// This check is conservative, but the comment is only a comment.
return nil, fmt.Errorf("ill-formed '-- filename --' header in comment")
}
test := &markerTest{
name: name,
fset: token.NewFileSet(),
content: content,
archive: archive,
files: make(map[string][]byte),
golden: make(map[expect.Identifier]*Golden),
}
for _, file := range archive.Files {
switch {
case file.Name == "skip":
reason := strings.ReplaceAll(string(file.Data), "\n", " ")
reason = strings.TrimSpace(reason)
test.skipReason = reason
case file.Name == "flags":
test.flags = strings.Fields(string(file.Data))
case file.Name == "settings.json":
if err := json.Unmarshal(file.Data, &test.settings); err != nil {
return nil, err
}
case file.Name == "capabilities.json":
test.capabilities = file.Data // lazily unmarshalled by the editor
case file.Name == "env":
test.env = make(map[string]string)
fields := strings.Fields(string(file.Data))
for _, field := range fields {
key, value, ok := strings.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
idstring, name, _ := strings.Cut(file.Name[len("@"):], "/")
id := expect.Identifier(idstring)
// 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
case strings.HasPrefix(file.Name, "proxy/"):
name := file.Name[len("proxy/"):]
if test.proxyFiles == nil {
test.proxyFiles = make(map[string][]byte)
}
test.proxyFiles[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)
}
// Reject common misspelling: "// @mark".
// TODO(adonovan): permit "// @" within a string. Detect multiple spaces.
if i := bytes.Index(file.Data, []byte("// @")); i >= 0 {
line := 1 + bytes.Count(file.Data[:i], []byte("\n"))
return nil, fmt.Errorf("%s:%d: unwanted space before marker (// @)", file.Name, line)
}
// The 'go list' command doesn't work correct with modules named
// testdata", so don't allow it as a module name (golang/go#65406).
// (Otherwise files within it will end up in an ad hoc
// package, "command-line-arguments/$TMPDIR/...".)
if filepath.Base(file.Name) == "go.mod" &&
bytes.Contains(file.Data, []byte("module testdata")) {
return nil, fmt.Errorf("'testdata' is not a valid module name")
}
test.notes = append(test.notes, notes...)
test.files[file.Name] = file.Data
}
// Print a warning if we see what looks like "-- filename --"
// without the second "--". It's not necessarily wrong,
// but it should almost never appear in our test inputs.
if bytes.Contains(file.Data, []byte("\n-- ")) {
log.Printf("ill-formed '-- filename --' header in %s?", file.Name)
}
}
// Parse flags after loading files, as they may have been set by the "flags"
// file.
if err := test.flagSet().Parse(test.flags); err != nil {
return nil, fmt.Errorf("parsing flags: %v", err)
}
return test, nil
}
// 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(string(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 "skip", "flags", "settings.json", "capabilities.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 strings.HasPrefix(file.Name, "proxy/") { // proxy 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 {
// TODO(rfindley): it looks like this implicitly removes trailing newlines
// from golden content. Is there any way to fix that? Perhaps we should
// just make the diff tolerant of missing newlines?
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 integration tests, marker tests, and benchmarks.
func newEnv(t *testing.T, cache *cache.Cache, files, proxyFiles map[string][]byte, writeGoSum []string, config fake.EditorConfig) *integration.Env {
sandbox, err := fake.NewSandbox(&fake.SandboxConfig{
RootDir: t.TempDir(),
Files: files,
ProxyFiles: proxyFiles,
})
if err != nil {
t.Fatal(err)
}
for _, dir := range writeGoSum {
if err := sandbox.RunGoCommand(context.Background(), dir, "list", []string{"-mod=mod", "..."}, []string{"GOWORK=off"}, true); 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 := integration.NewAwaiter(sandbox.Workdir)
ss := lsprpc.NewStreamServer(cache, false, nil)
server := servertest.NewPipeServer(ss, jsonrpc2.NewRawStream)
editor, err := fake.NewEditor(sandbox, config).Connect(ctx, server, awaiter.Hooks())
if err != nil {
sandbox.Close() // ignore error
t.Fatal(err)
}
if err := awaiter.Await(ctx, integration.InitialWorkspaceLoad); err != nil {
sandbox.Close() // ignore error
t.Fatal(err)
}
return &integration.Env{
T: t,
Ctx: ctx,
Editor: editor,
Sandbox: sandbox,
Awaiter: awaiter,
}
}
// A markerTestRun holds the state of one run of a marker test archive.
type markerTestRun struct {
test *markerTest
env *integration.Env
settings map[string]any
// Collected information.
// Each @diag/@suggestedfix marker eliminates an entry from diags.
values map[expect.Identifier]any
diags map[protocol.Location][]protocol.Diagnostic // diagnostics by position; location end == start
// Notes that weren't associated with a top-level marker func. They may be
// consumed by another marker (e.g. @codelenses collects @codelens markers).
// Any notes that aren't consumed are flagged as an error.
extraNotes map[protocol.DocumentURI]map[string][]*expect.Note
}
// 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 ...any) string {
if false {
_ = fmt.Sprintf(format, args...) // enable vet printf checker
}
var args2 []any
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...)
}
// fmtPos 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 {
formatted := run.fmtLocDetails(loc, true)
if formatted == "" {
run.env.T.Errorf("unable to find %s in test archive", loc)
return "<invalid location>"
}
return formatted
}
// See fmtLoc. If includeTxtPos is not set, the position in the full archive
// file is omitted.
//
// If the location cannot be found within the archive, fmtLocDetails returns "".
func (run *markerTestRun) fmtLocDetails(loc protocol.Location, includeTxtPos bool) string {
if loc == (protocol.Location{}) {
return ""
}
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 == "" {
return ""
}
m, err := run.env.Editor.Mapper(name)
if err != nil {
run.env.T.Errorf("internal error: %v", err)
return "<invalid location>"
}
start, end, err := m.RangeOffsets(loc.Range)
if err != nil {
run.env.T.Errorf("error formatting location %s: %v", loc, err)
return "<invalid location>"
}
var (
startLine, startCol8 = m.OffsetLineCol8(start)
endLine, endCol8 = m.OffsetLineCol8(end)
)
innerSpan := fmt.Sprintf("%d:%d", startLine, startCol8) // relative to the embedded file
outerSpan := fmt.Sprintf("%d:%d", lines+startLine, startCol8) // relative to the archive file
if start != end {
if endLine == startLine {
innerSpan += fmt.Sprintf("-%d", endCol8)
outerSpan += fmt.Sprintf("-%d", endCol8)
} else {
innerSpan += fmt.Sprintf("-%d:%d", endLine, endCol8)
outerSpan += fmt.Sprintf("-%d:%d", lines+endLine, endCol8)
}
}
if includeTxtPos {
return fmt.Sprintf("%s:%s (%s:%s)", name, innerSpan, run.test.name, outerSpan)
} else {
return fmt.Sprintf("%s:%s", name, innerSpan)
}
}
// ---- converters ----
// Types with special handling.
var (
goldenType = reflect.TypeOf(&Golden{})
markerType = reflect.TypeOf(marker{})
stringMatcherType = reflect.TypeOf(stringMatcher{})
)
// Custom conversions.
//
// These functions are called after valueMarkerFuncs have run to convert
// arguments into the desired parameter types.
//
// Converters should return an error rather than calling marker.errorf().
var customConverters = map[reflect.Type]func(marker, any) (any, error){
reflect.TypeOf(protocol.Location{}): converter(convertLocation),
reflect.TypeOf(completionLabel("")): converter(convertCompletionLabel),
}
// converter transforms a typed argument conversion function to an untyped
// conversion function.
func converter[T any](f func(marker, any) (T, error)) func(marker, any) (any, error) {
return func(m marker, arg any) (any, error) {
return f(m, arg)
}
}
func convert(mark marker, arg any, paramType reflect.Type) (any, error) {
// Handle stringMatcher and golden parameters before resolving identifiers,
// because golden content lives in a separate namespace from other
// identifiers.
switch paramType {
case stringMatcherType:
return convertStringMatcher(mark, arg)
case goldenType:
id, ok := arg.(expect.Identifier)
if !ok {
return nil, fmt.Errorf("invalid input type %T: golden key must be an identifier", arg)
}
return mark.run.test.getGolden(id), nil
}
if id, ok := arg.(expect.Identifier); ok {
if arg2, ok := mark.run.values[id]; ok {
arg = arg2
}
}
if converter, ok := customConverters[paramType]; ok {
arg2, err := converter(mark, arg)
if err != nil {
return nil, fmt.Errorf("converting for input type %T to %v: %v", arg, paramType, err)
}
arg = arg2
}
if reflect.TypeOf(arg).AssignableTo(paramType) {
return arg, nil // no conversion required
}
return nil, fmt.Errorf("cannot convert %v (%T) to %s", arg, arg, paramType)
}
// convertLocation converts a string or regexp argument into the protocol
// location corresponding to the first position of the string (or first match
// of the regexp) in the line preceding the note.
func convertLocation(mark marker, arg any) (protocol.Location, error) {
// matchContent is used to match the given argument against the file content
// starting at the marker line.
var matchContent func([]byte) (int, int, error)
switch arg := arg.(type) {
case protocol.Location:
return arg, nil // nothing to do
case string:
matchContent = func(content []byte) (int, int, error) {
idx := bytes.Index(content, []byte(arg))
if idx < 0 {
return 0, 0, fmt.Errorf("substring %q not found", arg)
}
return idx, idx + len(arg), nil
}
case *regexp.Regexp:
matchContent = func(content []byte) (int, int, error) {
matches := arg.FindSubmatchIndex(content)
if len(matches) == 0 {
return 0, 0, fmt.Errorf("no match for regexp %q", arg)
}
switch len(matches) {
case 2:
// no subgroups: return the range of the regexp expression
return matches[0], matches[1], nil
case 4:
// one subgroup: return its range
return matches[2], matches[3], nil
default:
return 0, 0, fmt.Errorf("invalid location regexp %q: expect either 0 or 1 subgroups, got %d", arg, len(matches)/2-1)
}
}
default:
return protocol.Location{}, fmt.Errorf("cannot convert argument type %T to location (must be a string or regexp to match the preceding line)", arg)
}
// Now use matchFunc to match a range starting on the marker line.
file := mark.run.test.fset.File(mark.note.Pos)
posn := safetoken.Position(file, mark.note.Pos)
lineStart := file.LineStart(posn.Line)
lineStartOff, lineEndOff, err := safetoken.Offsets(file, lineStart, mark.note.Pos)
if err != nil {
return protocol.Location{}, err
}
m := mark.mapper()
start, end, err := matchContent(m.Content[lineStartOff:])
if err != nil {
return protocol.Location{}, err
}
startOff, endOff := lineStartOff+start, lineStartOff+end
if startOff > lineEndOff {
// The start of the match must be between the start of the line and the
// marker position (inclusive).
return protocol.Location{}, fmt.Errorf("no matching range found starting on the current line")
}
return m.OffsetLocation(startOff, endOff)
}
// completionLabel is a special parameter type that may be converted from a
// string literal, or extracted from a completion item.
//
// See [convertCompletionLabel].
type completionLabel string
// convertCompletionLabel coerces an argument to a [completionLabel] parameter
// type.
//
// If the arg is a string, it is trivially converted. If the arg is a
// completionItem, its label is extracted.
//
// This allows us to stage a migration of the "snippet" marker to a simpler
// model where the completion label can just be listed explicitly.
func convertCompletionLabel(mark marker, arg any) (completionLabel, error) {
switch arg := arg.(type) {
case string:
return completionLabel(arg), nil
case completionItem:
return completionLabel(arg.Label), nil
default:
return "", fmt.Errorf("cannot convert argument type %T to completion label (must be a string or completion item)", arg)
}
}
// convertStringMatcher converts a string, regexp, or identifier
// argument into a stringMatcher. 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 convertStringMatcher(mark marker, arg any) (stringMatcher, error) {
switch arg := arg.(type) {
case string:
return stringMatcher{substr: arg}, nil
case *regexp.Regexp:
return stringMatcher{pattern: arg}, nil
case expect.Identifier:
golden := mark.run.test.getGolden(arg)
return stringMatcher{golden: golden}, nil
default:
return stringMatcher{}, fmt.Errorf("cannot convert %T to wantError (want: string, regexp, or identifier)", arg)
}
}
// A stringMatcher represents an expectation of a specific string value.
//
// It may be indicated in one of three ways, in 'expect' notation:
// - an identifier 'foo', to compare (exactly) 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 stringMatcher struct {
golden *Golden
pattern *regexp.Regexp
substr string
}
func (sc stringMatcher) String() string {
if sc.golden != nil {
return fmt.Sprintf("content from @%s entry", sc.golden.id)
} else if sc.pattern != nil {
return fmt.Sprintf("content matching %#q", sc.pattern)
} else {
return fmt.Sprintf("content with substring %q", sc.substr)
}
}
// checkErr asserts that the given error matches the stringMatcher's expectations.
func (sc stringMatcher) checkErr(mark marker, err error) {
if err == nil {
mark.errorf("@%s succeeded unexpectedly, want %v", mark.note.Name, sc)
return
}
sc.check(mark, err.Error())
}
// check asserts that the given content matches the stringMatcher's expectations.
func (sc stringMatcher) check(mark marker, got string) {
if sc.golden != nil {
compareGolden(mark, []byte(got), sc.golden)
} else if sc.pattern != nil {
// Content must match the regular expression pattern.
if !sc.pattern.MatchString(got) {
mark.errorf("got %q, does not match pattern %#q", got, sc.pattern)
}
} else if !strings.Contains(got, sc.substr) {
// Content must contain the expected substring.
mark.errorf("got %q, want substring %q", got, sc.substr)
}
}
// 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.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.T(), filename, nil)
mark.errorf("%s: missing change to file %s; want:\n%s",
mark.note.Name, filename, want)
}
}
}
// checkDiffs computes unified diffs for each changed file, and compares with
// the diff content stored in the given golden directory.
func checkDiffs(mark marker, changed map[string][]byte, golden *Golden) {
diffs := make(map[string]string)
for name, after := range changed {
before := mark.run.env.FileContent(name)
// TODO(golang/go#64023): switch back to diff.Strings.
// The attached issue is only one obstacle to switching.
// Another is that different diff algorithms produce
// different results, so if we commit diffs in test
// expectations, then we need to either (1) state
// which diff implementation they use and never change
// it, or (2) don't compare diffs, but instead apply
// the "want" diff and check that it produces the
// "got" output. Option 2 is more robust, as it allows
// the test expectation to use any valid diff.
edits := myers.ComputeEdits(before, string(after))
d, err := diff.ToUnified("before", "after", before, edits, 0)
if err != nil {
// Can't happen: edits are consistent.
log.Fatalf("internal error in diff.ToUnified: %v", err)
}
// Trim the unified header from diffs, as it is unnecessary and repetitive.
difflines := strings.Split(d, "\n")
if len(difflines) >= 2 && strings.HasPrefix(difflines[1], "+++") {
diffs[name] = strings.Join(difflines[2:], "\n")
} else {
diffs[name] = d
}
}
// Check changed files match expectations.
for filename, got := range diffs {
if want, ok := golden.Get(mark.T(), filename, []byte(got)); !ok {
mark.errorf("%s: unexpected change to file %s; got diff:\n%s",
mark.note.Name, filename, got)
} else if got != string(want) {
mark.errorf("%s: wrong diff for %s:\n\ngot:\n%s\n\nwant:\n%s\n",
mark.note.Name, filename, got, want)
}
}
// Report unmet expectations.
for filename := range golden.data {
if _, ok := changed[filename]; !ok {
want, _ := golden.Get(mark.T(), filename, nil)
mark.errorf("%s: missing change to file %s; want:\n%s",
mark.note.Name, filename, want)
}
}
}
// ---- marker functions ----
// TODO(rfindley): consolidate documentation of these markers. They are already
// documented above, so much of the documentation here is redundant.
// completionItem is a simplified summary of a completion item.
type completionItem struct {
Label, Detail, Kind, Documentation string
}
func completionItemMarker(mark marker, label string, other ...string) completionItem {
if len(other) > 3 {
mark.errorf("too many arguments to @item: expect at most 4")
}
item := completionItem{
Label: label,
}
if len(other) > 0 {
item.Detail = other[0]
}
if len(other) > 1 {
item.Kind = other[1]
}
if len(other) > 2 {
item.Documentation = other[2]
}
return item
}
func rankMarker(mark marker, src protocol.Location, items ...completionLabel) {
// Separate positive and negative items (expectations).
var pos, neg []completionLabel
for _, item := range items {
if strings.HasPrefix(string(item), "!") {
neg = append(neg, item)
} else {
pos = append(pos, item)
}
}
// Collect results that are present in items, preserving their order.
list := mark.run.env.Completion(src)
var got []string
for _, g := range list.Items {
for _, w := range pos {
if g.Label == string(w) {
got = append(got, g.Label)
break
}
}
for _, w := range neg {
if g.Label == string(w[len("!"):]) {
mark.errorf("got unwanted completion: %s", g.Label)
break
}
}
}
var want []string
for _, w := range pos {
want = append(want, string(w))
}
if diff := cmp.Diff(want, got); diff != "" {
mark.errorf("completion rankings do not match (-want +got):\n%s", diff)
}
}
func snippetMarker(mark marker, src protocol.Location, label completionLabel, want string) {
list := mark.run.env.Completion(src)
var (
found bool
got string
all []string // for errors
)
items := filterBuiltinsAndKeywords(mark, list.Items)
for _, i := range items {
all = append(all, i.Label)
if i.Label == string(label) {
found = true
if i.TextEdit != nil {
if edit, err := protocol.SelectCompletionTextEdit(i, false); err == nil {
got = edit.NewText
}
}
break
}
}
if !found {
mark.errorf("no completion item found matching %s (got: %v)", label, all)
return
}
if got != want {
mark.errorf("snippets do not match: got:\n%q\nwant:\n%q", got, want)
}
}
// completeMarker implements the @complete marker, running
// textDocument/completion at the given src location and asserting that the
// results match the expected results.
func completeMarker(mark marker, src protocol.Location, want ...completionItem) {
list := mark.run.env.Completion(src)
items := filterBuiltinsAndKeywords(mark, list.Items)
var got []completionItem
for i, item := range items {
simplified := completionItem{
Label: item.Label,
Detail: item.Detail,
Kind: fmt.Sprint(item.Kind),
}
if item.Documentation != nil {
switch v := item.Documentation.Value.(type) {
case string:
simplified.Documentation = v
case protocol.MarkupContent:
simplified.Documentation = strings.TrimSpace(v.Value) // trim newlines
}
}
// Support short-hand notation: if Detail, Kind, or Documentation are omitted from the
// item, don't match them.
if i < len(want) {
if want[i].Detail == "" {
simplified.Detail = ""
}
if want[i].Kind == "" {
simplified.Kind = ""
}
if want[i].Documentation == "" {
simplified.Documentation = ""
}
}
got = append(got, simplified)
}
if len(want) == 0 {
want = nil // got is nil if empty
}
if diff := cmp.Diff(want, got); diff != "" {
mark.errorf("Completion(...) returned unexpect results (-want +got):\n%s", diff)
}
}
// filterBuiltinsAndKeywords filters out builtins and keywords from completion
// results.
//
// It over-approximates, and does not detect if builtins are shadowed.
func filterBuiltinsAndKeywords(mark marker, items []protocol.CompletionItem) []protocol.CompletionItem {
keep := 0
for _, item := range items {
if mark.run.test.filterKeywords && item.Kind == protocol.KeywordCompletion {
continue
}
if mark.run.test.filterBuiltins && types.Universe.Lookup(item.Label) != nil {
continue
}
items[keep] = item
keep++
}
return items[:keep]
}
// acceptCompletionMarker implements the @acceptCompletion marker, running
// textDocument/completion at the given src location and accepting the
// candidate with the given label. The resulting source must match the provided
// golden content.
func acceptCompletionMarker(mark marker, src protocol.Location, label string, golden *Golden) {
list := mark.run.env.Completion(src)
var selected *protocol.CompletionItem
for _, item := range list.Items {
if item.Label == label {
selected = &item
break
}
}
if selected == nil {
mark.errorf("Completion(...) did not return an item labeled %q", label)
return
}
edit, err := protocol.SelectCompletionTextEdit(*selected, false)
if err != nil {
mark.errorf("Completion(...) did not return a valid edit: %v", err)
return
}
filename := mark.path()
mapper := mark.mapper()
patched, _, err := protocol.ApplyEdits(mapper, append([]protocol.TextEdit{edit}, selected.AdditionalTextEdits...))
if err != nil {
mark.errorf("ApplyProtocolEdits failed: %v", err)
return
}
changes := map[string][]byte{filename: patched}
// Check the file state.
checkChangedFiles(mark, changes, golden)
}
// defMarker implements the @def 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))
}
}
func typedefMarker(mark marker, src, dst protocol.Location) {
got := mark.run.env.TypeDefinition(src)
if got != dst {
mark.errorf("type definition location does not match:\n\tgot: %s\n\twant %s",
mark.run.fmtLoc(got), mark.run.fmtLoc(dst))
}
}
func foldingRangeMarker(mark marker, g *Golden) {
env := mark.run.env
ranges, err := mark.server().FoldingRange(env.Ctx, &protocol.FoldingRangeParams{
TextDocument: mark.document(),
})
if err != nil {
mark.errorf("foldingRange failed: %v", err)
return
}
var edits []protocol.TextEdit
insert := func(line, char uint32, text string) {
pos := protocol.Position{Line: line, Character: char}
edits = append(edits, protocol.TextEdit{
Range: protocol.Range{
Start: pos,
End: pos,
},
NewText: text,
})
}
for i, rng := range ranges {
insert(rng.StartLine, rng.StartCharacter, fmt.Sprintf("<%d kind=%q>", i, rng.Kind))
insert(rng.EndLine, rng.EndCharacter, fmt.Sprintf("</%d>", i))
}
filename := mark.path()
mapper, err := env.Editor.Mapper(filename)
if err != nil {
mark.errorf("Editor.Mapper(%s) failed: %v", filename, err)
return
}
got, _, err := protocol.ApplyEdits(mapper, edits)
if err != nil {
mark.errorf("ApplyProtocolEdits failed: %v", err)
return
}
want, _ := g.Get(mark.T(), "", got)
if diff := compare.Bytes(want, got); diff != "" {
mark.errorf("foldingRange mismatch:\n%s", diff)
}
}
// formatMarker implements the @format marker.
func formatMarker(mark marker, golden *Golden) {
edits, err := mark.server().Formatting(mark.ctx(), &protocol.DocumentFormattingParams{
TextDocument: mark.document(),
})
var got []byte
if err != nil {
got = []byte(err.Error() + "\n") // all golden content is newline terminated
} else {
env := mark.run.env
filename := mark.path()
mapper, err := env.Editor.Mapper(filename)
if err != nil {
mark.errorf("Editor.Mapper(%s) failed: %v", filename, err)
}
got, _, err = protocol.ApplyEdits(mapper, edits)
if err != nil {
mark.errorf("ApplyProtocolEdits failed: %v", err)
return
}
}
compareGolden(mark, got, golden)
}
func highlightLocationMarker(mark marker, loc protocol.Location, kindName expect.Identifier) protocol.DocumentHighlight {
var kind protocol.DocumentHighlightKind
switch kindName {
case "read":
kind = protocol.Read
case "write":
kind = protocol.Write
case "text":
kind = protocol.Text
default:
mark.errorf("invalid highlight kind: %q", kindName)
}
return protocol.DocumentHighlight{
Range: loc.Range,
Kind: kind,
}
}
func sortDocumentHighlights(s []protocol.DocumentHighlight) {
sort.Slice(s, func(i, j int) bool {
return protocol.CompareRange(s[i].Range, s[j].Range) < 0
})
}
// highlightAllMarker makes textDocument/highlight
// requests at locations of equivalence classes. Given input
// highlightall(X1, X2, ..., Xn), the marker checks
// highlight(X1) = highlight(X2) = ... = highlight(Xn) = {X1, X2, ..., Xn}.
// It is not the general rule for all highlighting, and use @highlight
// for asymmetric cases.
//
// TODO(b/288111111): this is a bit of a hack. We should probably
// have a more general way of testing that a function is idempotent.
func highlightAllMarker(mark marker, all ...protocol.DocumentHighlight) {
sortDocumentHighlights(all)
for _, src := range all {
loc := protocol.Location{URI: mark.uri(), Range: src.Range}
got := mark.run.env.DocumentHighlight(loc)
sortDocumentHighlights(got)
if d := cmp.Diff(all, got); d != "" {
mark.errorf("DocumentHighlight(%v) mismatch (-want +got):\n%s", loc, d)
}
}
}
func highlightMarker(mark marker, src protocol.DocumentHighlight, dsts ...protocol.DocumentHighlight) {
loc := protocol.Location{URI: mark.uri(), Range: src.Range}
got := mark.run.env.DocumentHighlight(loc)
sortDocumentHighlights(got)
sortDocumentHighlights(dsts)
if diff := cmp.Diff(dsts, got, cmpopts.EquateEmpty()); diff != "" {
mark.errorf("DocumentHighlight(%v) mismatch (-want +got):\n%s", src, diff)
}
}
func hoverMarker(mark marker, src, dst protocol.Location, sc stringMatcher) {
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
}
sc.check(mark, gotMD)
}
func hoverErrMarker(mark marker, src protocol.Location, em stringMatcher) {
_, _, err := mark.editor().Hover(mark.ctx(), src)
em.checkErr(mark, err)
}
// locMarker implements the @loc marker. It is executed before other
// markers, so that locations are available.
func locMarker(mark marker, loc protocol.Location) protocol.Location { return 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 _, ok := removeDiagnostic(mark, loc, re); !ok {
mark.errorf("no diagnostic at %v matches %q", loc, re)
}
}
// removeDiagnostic looks for a diagnostic matching loc at the given position.
//
// If found, it returns (diag, true), and eliminates the matched diagnostic
// from the unmatched set.
//
// If not found, it returns (protocol.Diagnostic{}, false).
func removeDiagnostic(mark marker, loc protocol.Location, re *regexp.Regexp) (protocol.Diagnostic, bool) {
loc.Range.End = loc.Range.Start // diagnostics ignore end position.
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, true
}
}
return protocol.Diagnostic{}, false
}
// renameMarker implements the @rename(location, new, golden) marker.
func renameMarker(mark marker, loc protocol.Location, newName string, golden *Golden) {
changed, err := rename(mark.run.env, loc, newName)
if err != nil {
mark.errorf("rename failed: %v. (Use @renameerr for expected errors.)", err)
return
}
checkDiffs(mark, changed, golden)
}
// renameErrMarker implements the @renamererr(location, new, error) marker.
func renameErrMarker(mark marker, loc protocol.Location, newName string, wantErr stringMatcher) {
_, err := rename(mark.run.env, loc, newName)
wantErr.checkErr(mark, err)
}
func selectionRangeMarker(mark marker, loc protocol.Location, g *Golden) {
ranges, err := mark.server().SelectionRange(mark.ctx(), &protocol.SelectionRangeParams{
TextDocument: mark.document(),
Positions: []protocol.Position{loc.Range.Start},
})
if err != nil {
mark.errorf("SelectionRange failed: %v", err)
return
}
var buf bytes.Buffer
m := mark.mapper()
for i, path := range ranges {
fmt.Fprintf(&buf, "Ranges %d:", i)
rng := path
for {
s, e, err := m.RangeOffsets(rng.Range)
if err != nil {
mark.errorf("RangeOffsets failed: %v", err)
return
}
var snippet string
if e-s < 30 {
snippet = string(m.Content[s:e])
} else {
snippet = string(m.Content[s:s+15]) + "..." + string(m.Content[e-15:e])
}
fmt.Fprintf(&buf, "\n\t%v %q", rng.Range, strings.ReplaceAll(snippet, "\n", "\\n"))
if rng.Parent == nil {
break
}
rng = *rng.Parent
}
buf.WriteRune('\n')
}
compareGolden(mark, buf.Bytes(), g)
}
func tokenMarker(mark marker, loc protocol.Location, tokenType, mod string) {
tokens := mark.run.env.SemanticTokensRange(loc)
if len(tokens) != 1 {
mark.errorf("got %d tokens, want 1", len(tokens))
return
}
tok := tokens[0]
if tok.TokenType != tokenType {
mark.errorf("token type = %q, want %q", tok.TokenType, tokenType)
}
if tok.Mod != mod {
mark.errorf("token mod = %q, want %q", tok.Mod, mod)
}
}
func signatureMarker(mark marker, src protocol.Location, label string, active int64) {
got := mark.run.env.SignatureHelp(src)
var gotLabels []string // for better error messages
if got != nil {
for _, s := range got.Signatures {
gotLabels = append(gotLabels, s.Label)
}
}
if label == "" {
// A null result is expected.
// (There's no point having a @signatureerr marker
// because the server handler suppresses all errors.)
if got != nil && len(gotLabels) > 0 {
mark.errorf("signatureHelp = %v, want 0 signatures", gotLabels)
}
return
}
if got == nil || len(got.Signatures) != 1 {
mark.errorf("signatureHelp = %v, want exactly 1 signature", gotLabels)
return
}
if got := gotLabels[0]; got != label {
mark.errorf("signatureHelp: got label %q, want %q", got, label)
}
if got := int64(got.ActiveParameter); got != active {
mark.errorf("signatureHelp: got active parameter %d, want %d", got, active)
}
}
// rename returns the new contents of the files that would be modified
// by renaming the identifier at loc to newName.
func rename(env *integration.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.
wsedit, err := env.Editor.Server.Rename(env.Ctx, &protocol.RenameParams{
TextDocument: protocol.TextDocumentIdentifier{URI: loc.URI},
Position: loc.Range.Start,
NewName: newName,
})
if err != nil {
return nil, err
}
return changedFiles(env, wsedit.DocumentChanges)
}
// changedFiles applies the given sequence of document changes to the
// editor buffer content, recording the final contents in the returned map.
// The actual editor state is not changed.
// Deleted files are indicated by a content of []byte(nil).
//
// See also:
// - Editor.applyWorkspaceEdit ../integration/fake/editor.go for the
// implementation of this operation used in normal testing.
// - cmdClient.applyWorkspaceEdit in ../../../cmd/cmd.go for the
// CLI variant.
func changedFiles(env *integration.Env, changes []protocol.DocumentChange) (map[string][]byte, error) {
uriToPath := env.Sandbox.Workdir.URIToPath
// latest maps each updated file name to a mapper holding its
// current contents, or nil if the file has been deleted.
latest := make(map[protocol.DocumentURI]*protocol.Mapper)
// read reads a file. It returns an error if the file never
// existed or was deleted.
read := func(uri protocol.DocumentURI) (*protocol.Mapper, error) {
if m, ok := latest[uri]; ok {
if m == nil {
return nil, fmt.Errorf("read: file %s was deleted", uri)
}
return m, nil
}
return env.Editor.Mapper(uriToPath(uri))
}
// write (over)writes a file. A nil content indicates a deletion.
write := func(uri protocol.DocumentURI, content []byte) {
var m *protocol.Mapper
if content != nil {
m = protocol.NewMapper(uri, content)
}
latest[uri] = m
}
// Process the sequence of changes.
for _, change := range changes {
switch {
case change.TextDocumentEdit != nil:
uri := change.TextDocumentEdit.TextDocument.URI
m, err := read(uri)
if err != nil {
return nil, err // missing
}
patched, _, err := protocol.ApplyEdits(m, protocol.AsTextEdits(change.TextDocumentEdit.Edits))
if err != nil {
return nil, err // bad edit
}
write(uri, patched)
case change.RenameFile != nil:
old := change.RenameFile.OldURI
m, err := read(old)
if err != nil {
return nil, err // missing
}
write(old, nil)
new := change.RenameFile.NewURI
if _, err := read(old); err == nil {
return nil, fmt.Errorf("RenameFile: destination %s exists", new)
}
write(new, m.Content)
case change.CreateFile != nil:
uri := change.CreateFile.URI
if _, err := read(uri); err == nil {
return nil, fmt.Errorf("CreateFile %s: file exists", uri)
}
write(uri, []byte("")) // initially empty
case change.DeleteFile != nil:
uri := change.DeleteFile.URI
if _, err := read(uri); err != nil {
return nil, fmt.Errorf("DeleteFile %s: file does not exist", uri)
}
write(uri, nil)
default:
return nil, fmt.Errorf("invalid DocumentChange")
}
}
// Convert into result form.
result := make(map[string][]byte)
for uri, mapper := range latest {
var content []byte
if mapper != nil {
content = mapper.Content
}
result[uriToPath(uri)] = content
}
return result, nil
}
func codeActionMarker(mark marker, start, end protocol.Location, actionKind string, g *Golden) {
// Request the range from start.Start to end.End.
loc := start
loc.Range.End = end.Range.End
// Apply the fix it suggests.
changed, err := codeAction(mark.run.env, loc.URI, loc.Range, protocol.CodeActionKind(actionKind), nil)
if err != nil {
mark.errorf("codeAction failed: %v", err)
return
}
// Check the file state.
checkChangedFiles(mark, changed, g)
}
func codeActionEditMarker(mark marker, loc protocol.Location, actionKind string, g *Golden) {
changed, err := codeAction(mark.run.env, loc.URI, loc.Range, protocol.CodeActionKind(actionKind), nil)
if err != nil {
mark.errorf("codeAction failed: %v", err)
return
}
checkDiffs(mark, changed, g)
}
func codeActionErrMarker(mark marker, start, end protocol.Location, actionKind string, wantErr stringMatcher) {
loc := start
loc.Range.End = end.Range.End
_, err := codeAction(mark.run.env, loc.URI, loc.Range, protocol.CodeActionKind(actionKind), nil)
wantErr.checkErr(mark, err)
}
// codeLensesMarker runs the @codelenses() marker, collecting @codelens marks
// in the current file and comparing with the result of the
// textDocument/codeLens RPC.
func codeLensesMarker(mark marker) {
type codeLens struct {
Range protocol.Range
Title string
}
lenses := mark.run.env.CodeLens(mark.path())
var got []codeLens
for _, lens := range lenses {
title := ""
if lens.Command != nil {
title = lens.Command.Title
}
got = append(got, codeLens{lens.Range, title})
}
var want []codeLens
mark.consumeExtraNotes("codelens", actionMarkerFunc(func(_ marker, loc protocol.Location, title string) {
want = append(want, codeLens{loc.Range, title})
}))
for _, s := range [][]codeLens{got, want} {
sort.Slice(s, func(i, j int) bool {
li, lj := s[i], s[j]
if c := protocol.CompareRange(li.Range, lj.Range); c != 0 {
return c < 0
}
return li.Title < lj.Title
})
}
if diff := cmp.Diff(want, got); diff != "" {
mark.errorf("codelenses: unexpected diff (-want +got):\n%s", diff)
}
}
func documentLinkMarker(mark marker, g *Golden) {
var b bytes.Buffer
links := mark.run.env.DocumentLink(mark.path())
for _, l := range links {
if l.Target == nil {
mark.errorf("%s: nil link target", l.Range)
continue
}
loc := protocol.Location{URI: mark.uri(), Range: l.Range}
fmt.Fprintln(&b, mark.run.fmtLocDetails(loc, false), *l.Target)
}
compareGolden(mark, b.Bytes(), g)
}
// consumeExtraNotes runs the provided func for each extra note with the given
// name, and deletes all matching notes.
func (mark marker) consumeExtraNotes(name string, f func(marker)) {
uri := mark.uri()
notes := mark.run.extraNotes[uri][name]
delete(mark.run.extraNotes[uri], name)
for _, note := range notes {
f(marker{run: mark.run, note: note})
}
}
// 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, golden *Golden) {
loc.Range.End = loc.Range.Start // diagnostics ignore end position.
// Find and remove the matching diagnostic.
diag, ok := removeDiagnostic(mark, loc, re)
if !ok {
mark.errorf("no diagnostic at %v matches %q", loc, re)
return
}
// Apply the fix it suggests.
changed, err := codeAction(mark.run.env, loc.URI, diag.Range, "quickfix", &diag)
if err != nil {
mark.errorf("suggestedfix failed: %v. (Use @suggestedfixerr for expected errors.)", err)
return
}
// Check the file state.
checkDiffs(mark, changed, golden)
}
func suggestedfixErrMarker(mark marker, loc protocol.Location, re *regexp.Regexp, wantErr stringMatcher) {
loc.Range.End = loc.Range.Start // diagnostics ignore end position.
// Find and remove the matching diagnostic.
diag, ok := removeDiagnostic(mark, loc, re)
if !ok {
mark.errorf("no diagnostic at %v matches %q", loc, re)
return
}
// Apply the fix it suggests.
_, err := codeAction(mark.run.env, loc.URI, diag.Range, "quickfix", &diag)
wantErr.checkErr(mark, err)
}
// 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) {
changes, err := codeActionChanges(env, uri, rng, kind, diag)
if err != nil {
return nil, err
}
return changedFiles(env, changes)
}
// codeActionChanges executes a textDocument/codeAction request for the
// specified location and kind, and captures the resulting document changes.
// If diag is non-nil, it is used as the code action context.
func codeActionChanges(env *integration.Env, uri protocol.DocumentURI, rng protocol.Range, kind protocol.CodeActionKind, diag *protocol.Diagnostic) ([]protocol.DocumentChange, 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
},
}
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 {
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), kind)
}
action := candidates[0]
// 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.
// 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.Edit = resolved.Edit
}
}
if action.Edit != nil {
if len(action.Edit.Changes) > 0 {
env.T.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.T.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" suggestedfix hook (the meat).
// The server then makes an ApplyEdit RPC to the client,
// whose WorkspaceEditFunc hook temporarily gathers the edits
// instead of applying them.
var changes []protocol.DocumentChange
cli := env.Editor.Client()
restore := cli.SetApplyEditHandler(func(ctx context.Context, wsedit *protocol.WorkspaceEdit) error {
changes = append(changes, wsedit.DocumentChanges...)
return nil
})
defer restore()
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
}
// refsMarker implements the @refs marker.
func refsMarker(mark marker, src protocol.Location, want ...protocol.Location) {
refs := func(includeDeclaration bool, want []protocol.Location) error {
got, err := mark.server().References(mark.ctx(), &protocol.ReferenceParams{
TextDocumentPositionParams: protocol.LocationTextDocumentPositionParams(src),
Context: protocol.ReferenceContext{
IncludeDeclaration: includeDeclaration,
},
})
if err != nil {
return err
}
return compareLocations(mark, got, want)
}
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)
}
}
}
// implementationMarker implements the @implementation marker.
func implementationMarker(mark marker, src protocol.Location, want ...protocol.Location) {
got, err := mark.server().Implementation(mark.ctx(), &protocol.ImplementationParams{
TextDocumentPositionParams: protocol.LocationTextDocumentPositionParams(src),
})
if err != nil {
mark.errorf("implementation at %s failed: %v", src, err)
return
}
if err := compareLocations(mark, got, want); err != nil {
mark.errorf("implementation: %v", err)
}
}
func itemLocation(item protocol.CallHierarchyItem) protocol.Location {
return protocol.Location{
URI: item.URI,
Range: item.Range,
}
}
func incomingCallsMarker(mark marker, src protocol.Location, want ...protocol.Location) {
getCalls := func(item protocol.CallHierarchyItem) ([]protocol.Location, error) {
calls, err := mark.server().IncomingCalls(mark.ctx(), &protocol.CallHierarchyIncomingCallsParams{Item: item})
if err != nil {
return nil, err
}
var locs []protocol.Location
for _, call := range calls {
locs = append(locs, itemLocation(call.From))
}
return locs, nil
}
callHierarchy(mark, src, getCalls, want)
}
func outgoingCallsMarker(mark marker, src protocol.Location, want ...protocol.Location) {
getCalls := func(item protocol.CallHierarchyItem) ([]protocol.Location, error) {
calls, err := mark.server().OutgoingCalls(mark.ctx(), &protocol.CallHierarchyOutgoingCallsParams{Item: item})
if err != nil {
return nil, err
}
var locs []protocol.Location
for _, call := range calls {
locs = append(locs, itemLocation(call.To))
}
return locs, nil
}
callHierarchy(mark, src, getCalls, want)
}
type callHierarchyFunc = func(protocol.CallHierarchyItem) ([]protocol.Location, error)
func callHierarchy(mark marker, src protocol.Location, getCalls callHierarchyFunc, want []protocol.Location) {
items, err := mark.server().PrepareCallHierarchy(mark.ctx(), &protocol.CallHierarchyPrepareParams{
TextDocumentPositionParams: protocol.LocationTextDocumentPositionParams(src),
})
if err != nil {
mark.errorf("PrepareCallHierarchy failed: %v", err)
return
}
if nitems := len(items); nitems != 1 {
mark.errorf("PrepareCallHierarchy returned %d items, want exactly 1", nitems)
return
}
if loc := itemLocation(items[0]); loc != src {
mark.errorf("PrepareCallHierarchy found call %v, want %v", loc, src)
return
}
calls, err := getCalls(items[0])
if err != nil {
mark.errorf("call hierarchy failed: %v", err)
return
}
if calls == nil {
calls = []protocol.Location{}
}
// TODO(rfindley): why aren't call hierarchy results stable?
sortLocs := func(locs []protocol.Location) {
sort.Slice(locs, func(i, j int) bool {
return protocol.CompareLocation(locs[i], locs[j]) < 0
})
}
sortLocs(want)
sortLocs(calls)
if d := cmp.Diff(want, calls); d != "" {
mark.errorf("call hierarchy: unexpected results (-want +got):\n%s", d)
}
}
func inlayhintsMarker(mark marker, g *Golden) {
hints := mark.run.env.InlayHints(mark.path())
// Map inlay hints to text edits.
edits := make([]protocol.TextEdit, len(hints))
for i, hint := range hints {
var paddingLeft, paddingRight string
if hint.PaddingLeft {
paddingLeft = " "
}
if hint.PaddingRight {
paddingRight = " "
}
edits[i] = protocol.TextEdit{
Range: protocol.Range{Start: hint.Position, End: hint.Position},
NewText: fmt.Sprintf("<%s%s%s>", paddingLeft, hint.Label[0].Value, paddingRight),
}
}
m := mark.mapper()
got, _, err := protocol.ApplyEdits(m, edits)
if err != nil {
mark.errorf("ApplyProtocolEdits: %v", err)
return
}
compareGolden(mark, got, g)
}
func prepareRenameMarker(mark marker, src, spn protocol.Location, placeholder string) {
params := &protocol.PrepareRenameParams{
TextDocumentPositionParams: protocol.LocationTextDocumentPositionParams(src),
}
got, err := mark.server().PrepareRename(mark.ctx(), params)
if err != nil {
mark.T().Fatal(err)
}
if placeholder == "" {
if got != nil {
mark.errorf("PrepareRename(...) = %v, want nil", got)
}
return
}
want := &protocol.PrepareRenameResult{Range: spn.Range, Placeholder: placeholder}
if diff := cmp.Diff(want, got); diff != "" {
mark.errorf("mismatching PrepareRename result:\n%s", diff)
}
}
// symbolMarker implements the @symbol marker.
func symbolMarker(mark marker, golden *Golden) {
// Retrieve information about all symbols in this file.
symbols, err := mark.server().DocumentSymbol(mark.ctx(), &protocol.DocumentSymbolParams{
TextDocument: protocol.TextDocumentIdentifier{URI: mark.uri()},
})
if err != nil {
mark.errorf("DocumentSymbol request failed: %v", err)
return
}
// Format symbols one per line, sorted (in effect) by first column, a dotted name.
var lines []string
for _, symbol := range symbols {
// Each result element is a union of (legacy)
// SymbolInformation and (new) DocumentSymbol,
// so we ascertain which one and then transcode.
data, err := json.Marshal(symbol)
if err != nil {
mark.T().Fatal(err)
}
if _, ok := symbol.(map[string]any)["location"]; ok {
// This case is not reached because Editor initialization
// enables HierarchicalDocumentSymbolSupport.
// TODO(adonovan): test this too.
var sym protocol.SymbolInformation
if err := json.Unmarshal(data, &sym); err != nil {
mark.T().Fatal(err)
}
mark.errorf("fake Editor doesn't support SymbolInformation")
} else {
var sym protocol.DocumentSymbol // new hierarchical hotness
if err := json.Unmarshal(data, &sym); err != nil {
mark.T().Fatal(err)
}
// Print each symbol in the response tree.
var visit func(sym protocol.DocumentSymbol, prefix []string)
visit = func(sym protocol.DocumentSymbol, prefix []string) {
var out strings.Builder
out.WriteString(strings.Join(prefix, "."))
fmt.Fprintf(&out, " %q", sym.Detail)
if delta := sym.Range.End.Line - sym.Range.Start.Line; delta > 0 {
fmt.Fprintf(&out, " +%d lines", delta)
}
lines = append(lines, out.String())
for _, child := range sym.Children {
visit(child, append(prefix, child.Name))
}
}
visit(sym, []string{sym.Name})
}
}
sort.Strings(lines)
lines = append(lines, "") // match trailing newline in .txtar file
got := []byte(strings.Join(lines, "\n"))
// Compare with golden.
want, ok := golden.Get(mark.T(), "", got)
if !ok {
mark.errorf("%s: missing golden file @%s", mark.note.Name, golden.id)
} else if diff := cmp.Diff(string(got), string(want)); diff != "" {
mark.errorf("%s: unexpected output: got:\n%s\nwant:\n%s\ndiff:\n%s",
mark.note.Name, got, want, diff)
}
}
// compareLocations returns an error message if got and want are not
// the same set of locations. The marker is used only for fmtLoc.
func compareLocations(mark marker, got, want []protocol.Location) error {
toStrings := func(locs []protocol.Location) []string {
strs := make([]string, len(locs))
for i, loc := range locs {
strs[i] = mark.run.fmtLoc(loc)
}
sort.Strings(strs)
return strs
}
if diff := cmp.Diff(toStrings(want), toStrings(got)); diff != "" {
return fmt.Errorf("incorrect result locations: (got %d, want %d):\n%s",
len(got), len(want), diff)
}
return nil
}
func workspaceSymbolMarker(mark marker, query string, golden *Golden) {
params := &protocol.WorkspaceSymbolParams{
Query: query,
}
gotSymbols, err := mark.server().Symbol(mark.ctx(), params)
if err != nil {
mark.errorf("Symbol(%q) failed: %v", query, err)
return
}
var got bytes.Buffer
for _, s := range gotSymbols {
// Omit the txtar position of the symbol location; otherwise edits to the
// txtar archive lead to unexpected failures.
loc := mark.run.fmtLocDetails(s.Location, false)
// TODO(rfindley): can we do better here, by detecting if the location is
// relative to GOROOT?
if loc == "" {
loc = "<unknown>"
}
fmt.Fprintf(&got, "%s %s %s\n", loc, s.Name, s.Kind)
}
compareGolden(mark, got.Bytes(), golden)
}
// compareGolden compares the content of got with that of g.Get(""), reporting
// errors on any mismatch.
//
// TODO(rfindley): use this helper in more places.
func compareGolden(mark marker, got []byte, g *Golden) {
want, ok := g.Get(mark.T(), "", got)
if !ok {
mark.errorf("missing golden file @%s", g.id)
return
}
// Normalize newline termination: archive files (i.e. Golden content) can't
// contain non-newline terminated files, except in the special case where the
// file is completely empty.
//
// Note that txtar partitions a contiguous byte slice, so we must copy before
// appending.
normalize := func(s []byte) []byte {
if n := len(s); n > 0 && s[n-1] != '\n' {
s = append(s[:n:n], '\n') // don't mutate array
}
return s
}
got = normalize(got)
want = normalize(want)
if diff := compare.Bytes(want, got); diff != "" {
mark.errorf("%s does not match @%s:\n%s", mark.note.Name, g.id, diff)
}
}