| // 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) |
| } |
| } |