| // Copyright 2023 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package regtest |
| |
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "flag" |
| "fmt" |
| "go/token" |
| "go/types" |
| "io/fs" |
| "log" |
| "os" |
| "path" |
| "path/filepath" |
| "reflect" |
| "regexp" |
| "runtime" |
| "sort" |
| "strings" |
| "testing" |
| |
| "github.com/google/go-cmp/cmp" |
| "golang.org/x/tools/go/expect" |
| "golang.org/x/tools/gopls/internal/hooks" |
| "golang.org/x/tools/gopls/internal/lsp/cache" |
| "golang.org/x/tools/gopls/internal/lsp/debug" |
| "golang.org/x/tools/gopls/internal/lsp/fake" |
| "golang.org/x/tools/gopls/internal/lsp/lsprpc" |
| "golang.org/x/tools/gopls/internal/lsp/protocol" |
| "golang.org/x/tools/gopls/internal/lsp/safetoken" |
| "golang.org/x/tools/gopls/internal/lsp/tests" |
| "golang.org/x/tools/gopls/internal/lsp/tests/compare" |
| "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") |
| |
| // RunMarkerTests runs "marker" tests in the given test data directory. |
| // (In practice: ../../regtest/marker/testdata) |
| // |
| // Use this command to run the tests: |
| // |
| // $ go test ./gopls/internal/regtest/marker [-update] |
| // |
| // A marker test uses the '//@' marker syntax of the x/tools/go/expect package |
| // to annotate source code with various information such as locations and |
| // arguments of LSP operations to be executed by the test. The syntax following |
| // '@' is parsed as a comma-separated list of ordinary Go function calls, for |
| // example |
| // |
| // //@foo(a, "b", 3),bar(0) |
| // |
| // and delegates to a corresponding function to perform LSP-related operations. |
| // See the Marker types documentation below for a list of supported markers. |
| // |
| // Each call argument is converted to the type of the corresponding parameter of |
| // the designated function. The conversion logic may use the surrounding context, |
| // such as the position or nearby text. See the Argument conversion section below |
| // for the full set of special conversions. As a special case, the blank |
| // identifier '_' is treated as the zero value of the parameter type. |
| // |
| // The test runner collects test cases by searching the given directory for |
| // files with the .txt extension. Each file is interpreted as a txtar archive, |
| // which is extracted to a temporary directory. The relative path to the .txt |
| // file is used as the subtest name. The preliminary section of the file |
| // (before the first archive entry) is a free-form comment. |
| // |
| // These tests were inspired by (and in many places copied from) a previous |
| // iteration of the marker tests built on top of the packagestest framework. |
| // Key design decisions motivating this reimplementation are as follows: |
| // - The old tests had a single global session, causing interaction at a |
| // distance and several awkward workarounds. |
| // - The old tests could not be safely parallelized, because certain tests |
| // manipulated the server options |
| // - Relatedly, the old tests did not have a logic grouping of assertions into |
| // a single unit, resulting in clusters of files serving clusters of |
| // entangled assertions. |
| // - The old tests used locations in the source as test names and as the |
| // identity of golden content, meaning that a single edit could change the |
| // name of an arbitrary number of subtests, and making it difficult to |
| // manually edit golden content. |
| // - The old tests did not hew closely to LSP concepts, resulting in, for |
| // example, each marker implementation doing its own position |
| // transformations, and inventing its own mechanism for configuration. |
| // - The old tests had an ad-hoc session initialization process. The regtest |
| // environment has had more time devoted to its initialization, and has a |
| // more convenient API. |
| // - The old tests lacked documentation, and often had failures that were hard |
| // to understand. By starting from scratch, we can revisit these aspects. |
| // |
| // # Special files |
| // |
| // There are several types of file within the test archive that are given special |
| // treatment by the test runner: |
| // - "skip": the presence of this file causes the test to be skipped, with |
| // the file content used as the skip message. |
| // - "flags": this file is treated as a whitespace-separated list of flags |
| // that configure the MarkerTest instance. Supported flags: |
| // -min_go=go1.20 sets the minimum Go version for the test; |
| // -cgo requires that CGO_ENABLED is set and the cgo tool is available |
| // -write_sumfile=a,b,c instructs the test runner to generate go.sum files |
| // in these directories before running the test. |
| // -skip_goos=a,b,c instructs the test runner to skip the test for the |
| // listed GOOS values. |
| // -ignore_extra_diags suppresses errors for unmatched diagnostics |
| // TODO(rfindley): using build constraint expressions for -skip_goos would |
| // be clearer. |
| // -filter_builtins=false disables the filtering of builtins from |
| // completion results. |
| // -filter_keywords=false disables the filtering of keywords from |
| // completion results. |
| // TODO(rfindley): support flag values containing whitespace. |
| // - "settings.json": this file is parsed as JSON, and used as the |
| // session configuration (see gopls/doc/settings.md) |
| // - "capabilities.json": this file is parsed as JSON client capabilities, |
| // and applied as an overlay over the default editor client capabilities. |
| // see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#clientCapabilities |
| // for more details. |
| // - "env": this file is parsed as a list of VAR=VALUE fields specifying the |
| // editor environment. |
| // - Golden files: Within the archive, file names starting with '@' are |
| // treated as "golden" content, and are not written to disk, but instead are |
| // made available to test methods expecting an argument of type *Golden, |
| // using the identifier following '@'. For example, if the first parameter of |
| // Foo were of type *Golden, the test runner would convert the identifier a |
| // in the call @foo(a, "b", 3) into a *Golden by collecting golden file |
| // data starting with "@a/". |
| // - proxy files: any file starting with proxy/ is treated as a Go proxy |
| // file. If present, these files are written to a separate temporary |
| // directory and GOPROXY is set to file://<proxy directory>. |
| // |
| // # Marker types |
| // |
| // Markers are of two kinds. A few are "value markers" (e.g. @item), which are |
| // processed in a first pass and each computes a value that may be referred to |
| // by name later. Most are "action markers", which are processed in a second |
| // pass and take some action such as testing an LSP operation; they may refer |
| // to values computed by value markers. |
| // |
| // The following markers are supported within marker tests: |
| // |
| // - acceptcompletion(location, label, golden): specifies that accepting the |
| // completion candidate produced at the given location with provided label |
| // results in the given golden state. |
| // |
| // - codeaction(start, end, kind, golden, ...titles): specifies a code action |
| // to request for the given range. To support multi-line ranges, the range |
| // is defined to be between start.Start and end.End. The golden directory |
| // contains changed file content after the code action is applied. |
| // If titles are provided, they are used to filter the matching code |
| // action. |
| // |
| // TODO(rfindley): consolidate with codeactionedit, via a @loc2 marker that |
| // allows binding multi-line locations. |
| // |
| // - codeactionedit(range, kind, golden, ...titles): a shorter form of |
| // codeaction. Invokes a code action of the given kind for the given |
| // in-line range, and compares the resulting formatted unified *edits* |
| // (notably, not the full file content) with the golden directory. |
| // |
| // - codeactionerr(start, end, kind, wantError): specifies a codeaction that |
| // fails with an error that matches the expectation. |
| // |
| // - codelens(location, title): specifies that a codelens is expected at the |
| // given location, with given title. Must be used in conjunction with |
| // @codelenses. |
| // |
| // - codelenses(): specifies that textDocument/codeLens should be run for the |
| // current document, with results compared to the @codelens annotations in |
| // the current document. |
| // |
| // - complete(location, ...items): specifies expected completion results at |
| // the given location. Must be used in conjunction with @item. |
| // |
| // - diag(location, regexp): specifies an expected diagnostic matching the |
| // given regexp at the given location. The test runner requires |
| // a 1:1 correspondence between observed diagnostics and diag annotations. |
| // The diagnostics source and kind fields are ignored, to reduce fuss. |
| // |
| // The specified location must match the start position of the diagnostic, |
| // but end positions are ignored. |
| // |
| // TODO(adonovan): in the older marker framework, the annotation asserted |
| // two additional fields (source="compiler", kind="error"). Restore them? |
| // |
| // - def(src, dst location): perform a textDocument/definition request at |
| // the src location, and check the result points to the dst location. |
| // |
| // - documentLink(golden): asserts that textDocument/documentLink returns |
| // links as described by the golden file. |
| // |
| // - foldingrange(golden): perform a textDocument/foldingRange for the |
| // current document, and compare with the golden content, which is the |
| // original source annotated with numbered tags delimiting the resulting |
| // ranges (e.g. <1 kind="..."> ... </1>). |
| // |
| // - format(golden): perform a textDocument/format request for the enclosing |
| // file, and compare against the named golden file. If the formatting |
| // request succeeds, the golden file must contain the resulting formatted |
| // source. If the formatting request fails, the golden file must contain |
| // the error message. |
| // |
| // - highlight(src location, dsts ...location): makes a |
| // textDocument/highlight request at the given src location, which should |
| // highlight the provided dst locations. |
| // |
| // - hover(src, dst location, g Golden): perform a textDocument/hover at the |
| // src location, and checks that the result is the dst location, with hover |
| // content matching "hover.md" in the golden data g. |
| // |
| // - implementations(src location, want ...location): makes a |
| // textDocument/implementation query at the src location and |
| // checks that the resulting set of locations matches want. |
| // |
| // - incomingcalls(src location, want ...location): makes a |
| // callHierarchy/incomingCalls query at the src location, and checks that |
| // the set of call.From locations matches want. |
| // |
| // - item(label, details, kind): defines a completion item with the provided |
| // fields. This information is not positional, and therefore @item markers |
| // may occur anywhere in the source. Used in conjunction with @complete, |
| // snippet, or rank. |
| // |
| // TODO(rfindley): rethink whether floating @item annotations are the best |
| // way to specify completion results. |
| // |
| // - loc(name, location): specifies the name for a location in the source. These |
| // locations may be referenced by other markers. |
| // |
| // - outgoingcalls(src location, want ...location): makes a |
| // callHierarchy/outgoingCalls query at the src location, and checks that |
| // the set of call.To locations matches want. |
| // |
| // - preparerename(src, spn, placeholder): asserts that a textDocument/prepareRename |
| // request at the src location expands to the spn location, with given |
| // placeholder. If placeholder is "", this is treated as a negative |
| // assertion and prepareRename should return nil. |
| // |
| // - rename(location, new, golden): specifies a renaming of the |
| // identifier at the specified location to the new name. |
| // The golden directory contains the transformed files. |
| // |
| // - renameerr(location, new, wantError): specifies a renaming that |
| // fails with an error that matches the expectation. |
| // |
| // - signature(location, label, active): specifies that |
| // signatureHelp at the given location should match the provided string, with |
| // the active parameter (an index) highlighted. |
| // |
| // - suggestedfix(location, regexp, kind, golden): like diag, the location and |
| // regexp identify an expected diagnostic. This diagnostic must |
| // to have exactly one associated code action of the specified kind. |
| // This action is executed for its editing effects on the source files. |
| // Like rename, the golden directory contains the expected transformed files. |
| // |
| // - rank(location, ...completionItem): executes a textDocument/completion |
| // request at the given location, and verifies that each expected |
| // completion item occurs in the results, in the expected order. Other |
| // unexpected completion items may occur in the results. |
| // TODO(rfindley): this exists for compatibility with the old marker tests. |
| // Replace this with rankl, and rename. |
| // |
| // - rankl(location, ...label): like rank, but only cares about completion |
| // item labels. |
| // |
| // - refs(location, want ...location): executes a textDocument/references |
| // request at the first location and asserts that the result is the set of |
| // 'want' locations. The first want location must be the declaration |
| // (assumedly unique). |
| // |
| // - snippet(location, completionItem, snippet): executes a |
| // textDocument/completion request at the location, and searches for a |
| // result with label matching that of the provided completion item |
| // (TODO(rfindley): accept a label rather than a completion item). Check |
| // the the result snippet matches the provided snippet. |
| // |
| // - symbol(golden): makes a textDocument/documentSymbol request |
| // for the enclosing file, formats the response with one symbol |
| // per line, sorts it, and compares against the named golden file. |
| // Each line is of the form: |
| // |
| // dotted.symbol.name kind "detail" +n lines |
| // |
| // where the "+n lines" part indicates that the declaration spans |
| // several lines. The test otherwise makes no attempt to check |
| // location information. There is no point to using more than one |
| // @symbol marker in a given file. |
| // |
| // - token(location, tokenType, mod): makes a textDocument/semanticTokens/range |
| // request at the given location, and asserts that the result includes |
| // exactly one token with the given token type and modifier string. |
| // |
| // - workspacesymbol(query, golden): makes a workspace/symbol request for the |
| // given query, formats the response with one symbol per line, and compares |
| // against the named golden file. As workspace symbols are by definition a |
| // workspace-wide request, the location of the workspace symbol marker does |
| // not matter. Each line is of the form: |
| // |
| // location name kind |
| // |
| // # Argument conversion |
| // |
| // Marker arguments are first parsed by the go/expect package, which accepts |
| // the following tokens as defined by the Go spec: |
| // - string, int64, float64, and rune literals |
| // - true and false |
| // - nil |
| // - identifiers (type expect.Identifier) |
| // - regular expressions, denoted the two tokens re"abc" (type *regexp.Regexp) |
| // |
| // These values are passed as arguments to the corresponding parameter of the |
| // test function. Additional value conversions may occur for these argument -> |
| // parameter type pairs: |
| // - string->regexp: the argument is parsed as a regular expressions. |
| // - string->location: the argument is converted to the location of the first |
| // instance of the argument in the partial line preceding the note. |
| // - regexp->location: the argument is converted to the location of the first |
| // match for the argument in the partial line preceding the note. If the |
| // regular expression contains exactly one subgroup, the position of the |
| // subgroup is used rather than the position of the submatch. |
| // - name->location: the argument is replaced by the named location. |
| // - name->Golden: the argument is used to look up golden content prefixed by |
| // @<argument>. |
| // - {string,regexp,identifier}->wantError: a wantError type specifies |
| // an expected error message, either in the form of a substring that |
| // must be present, a regular expression that it must match, or an |
| // identifier (e.g. foo) such that the archive entry @foo |
| // exists and contains the exact expected error. |
| // |
| // # Example |
| // |
| // Here is a complete example: |
| // |
| // -- a.go -- |
| // package a |
| // |
| // const abc = 0x2a //@hover("b", "abc", abc),hover(" =", "abc", abc) |
| // -- @abc/hover.md -- |
| // ```go |
| // const abc untyped int = 42 |
| // ``` |
| // |
| // @hover("b", "abc", abc),hover(" =", "abc", abc) |
| // |
| // In this example, the @hover annotation tells the test runner to run the |
| // hoverMarker function, which has parameters: |
| // |
| // (mark marker, src, dsc protocol.Location, g *Golden). |
| // |
| // The first argument holds the test context, including fake editor with open |
| // files, and sandboxed directory. |
| // |
| // Argument converters translate the "b" and "abc" arguments into locations by |
| // interpreting each one as a regular expression and finding the location of |
| // its first match on the preceding portion of the line, and the abc identifier |
| // into a dictionary of golden content containing "hover.md". Then the |
| // hoverMarker method executes a textDocument/hover LSP request at the src |
| // position, and ensures the result spans "abc", with the markdown content from |
| // hover.md. (Note that the markdown content includes the expect annotation as |
| // the doc comment.) |
| // |
| // The next hover on the same line asserts the same result, but initiates the |
| // hover immediately after "abc" in the source. This tests that we find the |
| // preceding identifier when hovering. |
| // |
| // # Updating golden files |
| // |
| // To update golden content in the test archive, it is easier to regenerate |
| // content automatically rather than edit it by hand. To do this, run the |
| // tests with the -update flag. Only tests that actually run will be updated. |
| // |
| // In some cases, golden content will vary by Go version (for example, gopls |
| // produces different markdown at Go versions before the 1.19 go/doc update). |
| // By convention, the golden content in test archives should match the output |
| // at Go tip. Each test function can normalize golden content for older Go |
| // versions. |
| // |
| // Note that -update does not cause missing @diag or @loc markers to be added. |
| // |
| // # TODO |
| // |
| // - reorganize regtest packages (and rename to just 'test'?) |
| // - Rename the files .txtar. |
| // - Provide some means by which locations in the standard library |
| // (or builtin.go) can be named, so that, for example, we can we |
| // can assert that MyError implements the built-in error type. |
| // - If possible, improve handling for optional arguments. Rather than have |
| // multiple variations of a marker, it would be nice to support a more |
| // flexible signature: can codeaction, codeactionedit, codeactionerr, and |
| // suggestedfix be consolidated? |
| func RunMarkerTests(t *testing.T, dir string) { |
| // The marker tests must be able to run go/packages.Load. |
| testenv.NeedsGoPackages(t) |
| |
| tests, err := loadMarkerTests(dir) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| // Opt: use a shared cache. |
| cache := cache.New(nil) |
| |
| for _, test := range tests { |
| test := test |
| t.Run(test.name, func(t *testing.T) { |
| t.Parallel() |
| if test.skipReason != "" { |
| t.Skip(test.skipReason) |
| } |
| for _, goos := range test.skipGOOS { |
| if runtime.GOOS == goos { |
| t.Skipf("skipping on %s due to -skip_goos", runtime.GOOS) |
| } |
| } |
| |
| // 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.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 regtest environment. |
| defer run.env.Editor.Shutdown(context.Background()) // ignore error |
| defer run.env.Sandbox.Close() // ignore error |
| |
| // Open all files so that we operate consistently with LSP clients, and |
| // (pragmatically) so that we have a Mapper available via the fake |
| // editor. |
| // |
| // This also allows avoiding mutating the editor state in tests. |
| for file := range test.files { |
| run.env.OpenFile(file) |
| } |
| // Wait for the didOpen notifications to be processed, then collect |
| // diagnostics. |
| var diags map[string]*protocol.PublishDiagnosticsParams |
| run.env.AfterChange(ReadAllDiagnostics(&diags)) |
| for path, params := range diags { |
| uri := run.env.Sandbox.Workdir.URI(path) |
| for _, diag := range params.Diagnostics { |
| loc := protocol.Location{ |
| URI: uri, |
| Range: 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) |
| } |
| } |
| } |
| |
| formatted, err := formatTest(test) |
| if err != nil { |
| t.Errorf("formatTest: %v", err) |
| } else if *update { |
| filename := filepath.Join(dir, test.name) |
| if err := os.WriteFile(filename, formatted, 0644); err != nil { |
| t.Error(err) |
| } |
| } else { |
| // On go 1.19 and later, verify that the testdata has not changed. |
| // |
| // On earlier Go versions, the golden test data varies due to different |
| // markdown escaping. |
| // |
| // Only check this if the test hasn't already failed, otherwise we'd |
| // report duplicate mismatches of golden data. |
| if testenv.Go1Point() >= 19 && !t.Failed() { |
| // Otherwise, verify that formatted content matches. |
| if diff := compare.NamedText("formatted", "on-disk", string(formatted), string(test.content)); diff != "" { |
| t.Errorf("formatted test does not match on-disk content:\n%s", diff) |
| } |
| } |
| } |
| }) |
| } |
| |
| if abs, err := filepath.Abs(dir); err == nil && t.Failed() { |
| t.Logf("(Filenames are relative to %s.)", abs) |
| } |
| } |
| |
| // A marker holds state for the execution of a single @marker |
| // annotation in the source. |
| type marker struct { |
| run *markerTestRun |
| note *expect.Note |
| } |
| |
| // 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) 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.run.env.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) { |
| 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), |
| } |
| |
| // 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), |
| "hover": actionMarkerFunc(hoverMarker), |
| "implementation": actionMarkerFunc(implementationMarker), |
| "incomingcalls": actionMarkerFunc(incomingCallsMarker), |
| "inlayhints": actionMarkerFunc(inlayhintsMarker), |
| "outgoingcalls": actionMarkerFunc(outgoingCallsMarker), |
| "preparerename": actionMarkerFunc(prepareRenameMarker), |
| "rank": actionMarkerFunc(rankMarker), |
| "rankl": actionMarkerFunc(ranklMarker), |
| "refs": actionMarkerFunc(refsMarker), |
| "rename": actionMarkerFunc(renameMarker), |
| "renameerr": actionMarkerFunc(renameErrMarker), |
| "selectionrange": actionMarkerFunc(selectionRangeMarker), |
| "signature": actionMarkerFunc(signatureMarker), |
| "snippet": actionMarkerFunc(snippetMarker), |
| "suggestedfix": actionMarkerFunc(suggestedfixMarker), |
| "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. |
| minGoVersion string |
| cgo bool |
| writeGoSum []string // comma separated dirs to write go sum for |
| skipGOOS []string // comma separated GOOS values to skip |
| ignoreExtraDiags bool |
| filterBuiltins bool |
| filterKeywords 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.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.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") |
| 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) |
| } |
| |
| 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 regtests, marker tests, and benchmarks. |
| func newEnv(t *testing.T, cache *cache.Cache, files, proxyFiles map[string][]byte, writeGoSum []string, config fake.EditorConfig) *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 := NewAwaiter(sandbox.Workdir) |
| ss := lsprpc.NewStreamServer(cache, false, hooks.Options) |
| server := servertest.NewPipeServer(ss, jsonrpc2.NewRawStream) |
| const skipApplyEdits = true // capture edits but don't apply them |
| editor, err := fake.NewEditor(sandbox, config).Connect(ctx, server, awaiter.Hooks(), skipApplyEdits) |
| if err != nil { |
| sandbox.Close() // ignore error |
| t.Fatal(err) |
| } |
| if err := awaiter.Await(ctx, InitialWorkspaceLoad); err != nil { |
| sandbox.Close() // ignore error |
| t.Fatal(err) |
| } |
| return &Env{ |
| T: t, |
| Ctx: ctx, |
| Editor: editor, |
| Sandbox: sandbox, |
| Awaiter: awaiter, |
| } |
| } |
| |
| // A markerTestRun holds the state of one run of a marker test archive. |
| type markerTestRun struct { |
| test *markerTest |
| env *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...) |
| } |
| |
| // fmtLoc formats the given pos in the context of the test, using |
| // archive-relative paths for files and including the line number in the full |
| // archive file. |
| func (run *markerTestRun) fmtPos(pos token.Pos) string { |
| file := run.test.fset.File(pos) |
| if file == nil { |
| run.env.T.Errorf("position %d not in test fileset", pos) |
| return "<invalid location>" |
| } |
| m, err := run.env.Editor.Mapper(file.Name()) |
| if err != nil { |
| run.env.T.Errorf("%s", err) |
| return "<invalid location>" |
| } |
| loc, err := m.PosLocation(file, pos, pos) |
| if err != nil { |
| run.env.T.Errorf("Mapper(%s).PosLocation failed: %v", file.Name(), err) |
| } |
| return run.fmtLoc(loc) |
| } |
| |
| // fmtLoc formats the given location in the context of the test, using |
| // archive-relative paths for files and including the line number in the full |
| // archive file. |
| func (run *markerTestRun) fmtLoc(loc protocol.Location) string { |
| 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 ---- |
| |
| // converter is the signature of argument converters. |
| // A converter should return an error rather than calling marker.errorf(). |
| type converter func(marker, any) (any, error) |
| |
| // Types with special conversions. |
| var ( |
| goldenType = reflect.TypeOf(&Golden{}) |
| locationType = reflect.TypeOf(protocol.Location{}) |
| markerType = reflect.TypeOf(marker{}) |
| regexpType = reflect.TypeOf(®exp.Regexp{}) |
| wantErrorType = reflect.TypeOf(wantError{}) |
| ) |
| |
| func convert(mark marker, arg any, paramType reflect.Type) (any, error) { |
| if paramType == 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 arg, ok := mark.run.values[id]; ok { |
| if !reflect.TypeOf(arg).AssignableTo(paramType) { |
| return nil, fmt.Errorf("cannot convert %v (%T) to %s", arg, arg, paramType) |
| } |
| return arg, nil |
| } |
| } |
| if reflect.TypeOf(arg).AssignableTo(paramType) { |
| return arg, nil // no conversion required |
| } |
| switch paramType { |
| case locationType: |
| return convertLocation(mark, arg) |
| case wantErrorType: |
| return convertWantError(mark, arg) |
| default: |
| 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) { |
| switch arg := arg.(type) { |
| case string: |
| startOff, preceding, m, err := linePreceding(mark.run, mark.note.Pos) |
| if err != nil { |
| return protocol.Location{}, err |
| } |
| idx := bytes.Index(preceding, []byte(arg)) |
| if idx < 0 { |
| return protocol.Location{}, fmt.Errorf("substring %q not found in %q", arg, preceding) |
| } |
| off := startOff + idx |
| return m.OffsetLocation(off, off+len(arg)) |
| case *regexp.Regexp: |
| return findRegexpInLine(mark.run, mark.note.Pos, arg) |
| default: |
| return protocol.Location{}, fmt.Errorf("cannot convert argument type %T to location (must be a string to match the preceding line)", arg) |
| } |
| } |
| |
| // findRegexpInLine searches the partial line preceding pos for a match for the |
| // regular expression re, returning a location spanning the first match. If re |
| // contains exactly one subgroup, the position of this subgroup match is |
| // returned rather than the position of the full match. |
| func findRegexpInLine(run *markerTestRun, pos token.Pos, re *regexp.Regexp) (protocol.Location, error) { |
| startOff, preceding, m, err := linePreceding(run, pos) |
| if err != nil { |
| return protocol.Location{}, err |
| } |
| |
| matches := re.FindSubmatchIndex(preceding) |
| if len(matches) == 0 { |
| return protocol.Location{}, fmt.Errorf("no match for regexp %q found in %q", re, string(preceding)) |
| } |
| var start, end int |
| switch len(matches) { |
| case 2: |
| // no subgroups: return the range of the regexp expression |
| start, end = matches[0], matches[1] |
| case 4: |
| // one subgroup: return its range |
| start, end = matches[2], matches[3] |
| default: |
| return protocol.Location{}, fmt.Errorf("invalid location regexp %q: expect either 0 or 1 subgroups, got %d", re, len(matches)/2-1) |
| } |
| |
| return m.OffsetLocation(start+startOff, end+startOff) |
| } |
| |
| func linePreceding(run *markerTestRun, pos token.Pos) (int, []byte, *protocol.Mapper, error) { |
| file := run.test.fset.File(pos) |
| posn := safetoken.Position(file, pos) |
| lineStart := file.LineStart(posn.Line) |
| startOff, endOff, err := safetoken.Offsets(file, lineStart, pos) |
| if err != nil { |
| return 0, nil, nil, err |
| } |
| m, err := run.env.Editor.Mapper(file.Name()) |
| if err != nil { |
| return 0, nil, nil, err |
| } |
| return startOff, m.Content[startOff:endOff], m, nil |
| } |
| |
| // convertWantError converts a string, regexp, or identifier |
| // argument into a wantError. The string is a substring of the |
| // expected error, the regexp is a pattern than matches the expected |
| // error, and the identifier is a golden file containing the expected |
| // error. |
| func convertWantError(mark marker, arg any) (wantError, error) { |
| switch arg := arg.(type) { |
| case string: |
| return wantError{substr: arg}, nil |
| case *regexp.Regexp: |
| return wantError{pattern: arg}, nil |
| case expect.Identifier: |
| golden := mark.run.test.getGolden(arg) |
| return wantError{golden: golden}, nil |
| default: |
| return wantError{}, fmt.Errorf("cannot convert %T to wantError (want: string, regexp, or identifier)", arg) |
| } |
| } |
| |
| // A wantError represents an expectation of a specific error message. |
| // |
| // It may be indicated in one of three ways, in 'expect' notation: |
| // - an identifier 'foo', to compare with the contents of the golden section @foo; |
| // - a pattern expression re"ab.*c", to match against a regular expression; |
| // - a string literal "abc", to check for a substring. |
| type wantError struct { |
| golden *Golden |
| pattern *regexp.Regexp |
| substr string |
| } |
| |
| func (we wantError) String() string { |
| if we.golden != nil { |
| return fmt.Sprintf("error from @%s entry", we.golden.id) |
| } else if we.pattern != nil { |
| return fmt.Sprintf("error matching %#q", we.pattern) |
| } else { |
| return fmt.Sprintf("error with substring %q", we.substr) |
| } |
| } |
| |
| // check asserts that 'err' matches the wantError's expectations. |
| func (we wantError) check(mark marker, err error) { |
| if err == nil { |
| mark.errorf("@%s succeeded unexpectedly, want %v", mark.note.Name, we) |
| return |
| } |
| got := err.Error() |
| |
| if we.golden != nil { |
| // Error message must match @id golden file. |
| wantBytes, ok := we.golden.Get(mark.T(), "", []byte(got)) |
| if !ok { |
| mark.errorf("@%s: missing @%s entry", mark.note.Name, we.golden.id) |
| return |
| } |
| want := strings.TrimSpace(string(wantBytes)) |
| if got != want { |
| // (ignore leading/trailing space) |
| mark.errorf("@%s failed with wrong error: got:\n%s\nwant:\n%s\ndiff:\n%s", |
| mark.note.Name, got, want, compare.Text(want, got)) |
| } |
| |
| } else if we.pattern != nil { |
| // Error message must match regular expression pattern. |
| if !we.pattern.MatchString(got) { |
| mark.errorf("got error %q, does not match pattern %#q", got, we.pattern) |
| } |
| |
| } else if !strings.Contains(got, we.substr) { |
| // Error message must contain expected substring. |
| mark.errorf("got error %q, want substring %q", got, we.substr) |
| } |
| } |
| |
| // 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. |
| 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 ...completionItem) { |
| list := mark.run.env.Completion(src) |
| var got []string |
| // Collect results that are present in items, preserving their order. |
| for _, g := range list.Items { |
| for _, w := range items { |
| if g.Label == w.Label { |
| got = append(got, g.Label) |
| break |
| } |
| } |
| } |
| var want []string |
| for _, w := range items { |
| want = append(want, w.Label) |
| } |
| if diff := cmp.Diff(want, got); diff != "" { |
| mark.errorf("completion rankings do not match (-want +got):\n%s", diff) |
| } |
| } |
| |
| func ranklMarker(mark marker, src protocol.Location, labels ...string) { |
| list := mark.run.env.Completion(src) |
| var got []string |
| // Collect results that are present in items, preserving their order. |
| for _, g := range list.Items { |
| for _, label := range labels { |
| if g.Label == label { |
| got = append(got, g.Label) |
| break |
| } |
| } |
| } |
| if diff := cmp.Diff(labels, got); diff != "" { |
| mark.errorf("completion rankings do not match (-want +got):\n%s", diff) |
| } |
| } |
| |
| func snippetMarker(mark marker, src protocol.Location, item completionItem, 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 == item.Label { |
| found = true |
| if i.TextEdit != nil { |
| got = i.TextEdit.NewText |
| } |
| break |
| } |
| } |
| if !found { |
| mark.errorf("no completion item found matching %s (got: %v)", item.Label, all) |
| return |
| } |
| if got != want { |
| mark.errorf("snippets do not match: got %q, want %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 |
| } |
| filename := mark.path() |
| mapper := mark.mapper() |
| patched, _, err := protocol.ApplyEdits(mapper, append([]protocol.TextEdit{*selected.TextEdit}, 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, "format", got, golden) |
| } |
| |
| func highlightMarker(mark marker, src protocol.Location, dsts ...protocol.Location) { |
| highlights := mark.run.env.DocumentHighlight(src) |
| var got []protocol.Range |
| for _, h := range highlights { |
| got = append(got, h.Range) |
| } |
| |
| var want []protocol.Range |
| for _, d := range dsts { |
| want = append(want, d.Range) |
| } |
| |
| sortRanges := func(s []protocol.Range) { |
| sort.Slice(s, func(i, j int) bool { |
| return protocol.CompareRange(s[i], s[j]) < 0 |
| }) |
| } |
| |
| sortRanges(got) |
| sortRanges(want) |
| |
| if diff := cmp.Diff(want, got); diff != "" { |
| mark.errorf("DocumentHighlight(%v) mismatch (-want +got):\n%s", src, diff) |
| } |
| } |
| |
| // hoverMarker implements the @hover marker, running textDocument/hover at the |
| // given src location and asserting that the resulting hover is over the dst |
| // location (typically a span surrounding src), and that the markdown content |
| // matches the golden content. |
| func hoverMarker(mark marker, src, dst protocol.Location, golden *Golden) { |
| content, gotDst := mark.run.env.Hover(src) |
| if gotDst != dst { |
| mark.errorf("hover location does not match:\n\tgot: %s\n\twant %s)", mark.run.fmtLoc(gotDst), mark.run.fmtLoc(dst)) |
| } |
| gotMD := "" |
| if content != nil { |
| gotMD = content.Value |
| } |
| wantMD := "" |
| if golden != nil { |
| wantBytes, _ := golden.Get(mark.T(), "hover.md", []byte(gotMD)) |
| wantMD = string(wantBytes) |
| } |
| // Normalize newline termination: archive files can't express non-newline |
| // terminated files. |
| if strings.HasSuffix(wantMD, "\n") && !strings.HasSuffix(gotMD, "\n") { |
| gotMD += "\n" |
| } |
| if diff := tests.DiffMarkdown(wantMD, gotMD); diff != "" { |
| mark.errorf("hover markdown mismatch (-want +got):\n%s", diff) |
| } |
| } |
| |
| // locMarker implements the @loc marker. It is executed before other |
| // markers, so that locations are available. |
| func locMarker(mark marker, 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 wantError) { |
| _, err := rename(mark.run.env, loc, newName) |
| wantErr.check(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, "selection range", 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) |
| 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(got.Signatures) > 0 { |
| mark.errorf("signatureHelp = %v, want 0 signatures", got) |
| } |
| return |
| } |
| if got == nil || len(got.Signatures) != 1 { |
| mark.errorf("signatureHelp = %v, want exactly 1 signature", got) |
| return |
| } |
| if got := got.Signatures[0].Label; 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 *Env, loc protocol.Location, newName string) (map[string][]byte, error) { |
| // We call Server.Rename directly, instead of |
| // env.Editor.Rename(env.Ctx, loc, newName) |
| // to isolate Rename from PrepareRename, and because we don't |
| // want to modify the file system in a scenario with multiple |
| // @rename markers. |
| |
| editMap, err := env.Editor.Server.Rename(env.Ctx, &protocol.RenameParams{ |
| TextDocument: protocol.TextDocumentIdentifier{URI: loc.URI}, |
| Position: loc.Range.Start, |
| NewName: newName, |
| }) |
| if err != nil { |
| return nil, err |
| } |
| |
| fileChanges := make(map[string][]byte) |
| if err := applyDocumentChanges(env, editMap.DocumentChanges, fileChanges); err != nil { |
| return nil, fmt.Errorf("applying document changes: %v", err) |
| } |
| return fileChanges, nil |
| } |
| |
| // applyDocumentChanges applies the given document changes to the editor buffer |
| // content, recording the resulting contents in the fileChanges map. It is an |
| // error for a change to an edit a file that is already present in the |
| // fileChanges map. |
| func applyDocumentChanges(env *Env, changes []protocol.DocumentChanges, fileChanges map[string][]byte) error { |
| getMapper := func(path string) (*protocol.Mapper, error) { |
| if _, ok := fileChanges[path]; ok { |
| return nil, fmt.Errorf("internal error: %s is already edited", path) |
| } |
| return env.Editor.Mapper(path) |
| } |
| |
| for _, change := range changes { |
| if change.RenameFile != nil { |
| // rename |
| oldFile := env.Sandbox.Workdir.URIToPath(change.RenameFile.OldURI) |
| mapper, err := getMapper(oldFile) |
| if err != nil { |
| return err |
| } |
| newFile := env.Sandbox.Workdir.URIToPath(change.RenameFile.NewURI) |
| fileChanges[newFile] = mapper.Content |
| } else { |
| // edit |
| filename := env.Sandbox.Workdir.URIToPath(change.TextDocumentEdit.TextDocument.URI) |
| mapper, err := getMapper(filename) |
| if err != nil { |
| return err |
| } |
| patched, _, err := protocol.ApplyEdits(mapper, change.TextDocumentEdit.Edits) |
| if err != nil { |
| return err |
| } |
| fileChanges[filename] = patched |
| } |
| } |
| |
| return nil |
| } |
| |
| func codeActionMarker(mark marker, start, end protocol.Location, actionKind string, g *Golden, titles ...string) { |
| // 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, actionKind, nil, titles) |
| 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, titles ...string) { |
| changed, err := codeAction(mark.run.env, loc.URI, loc.Range, actionKind, nil, titles) |
| 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 wantError) { |
| loc := start |
| loc.Range.End = end.Range.End |
| _, err := codeAction(mark.run.env, loc.URI, loc.Range, actionKind, nil, nil) |
| wantErr.check(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, "documentLink", 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, nil) |
| if err != nil { |
| mark.errorf("suggestedfix failed: %v. (Use @suggestedfixerr for expected errors.)", err) |
| return |
| } |
| |
| // Check the file state. |
| checkDiffs(mark, changed, golden) |
| } |
| |
| // 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 *Env, uri protocol.DocumentURI, rng protocol.Range, actionKind string, diag *protocol.Diagnostic, titles []string) (map[string][]byte, error) { |
| changes, err := codeActionChanges(env, uri, rng, actionKind, diag, titles) |
| if err != nil { |
| return nil, err |
| } |
| fileChanges := make(map[string][]byte) |
| if err := applyDocumentChanges(env, changes, fileChanges); err != nil { |
| return nil, fmt.Errorf("applying document changes: %v", err) |
| } |
| return fileChanges, nil |
| } |
| |
| // 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. |
| // If titles is non-empty, the code action title must be present among the provided titles. |
| func codeActionChanges(env *Env, uri protocol.DocumentURI, rng protocol.Range, actionKind string, diag *protocol.Diagnostic, titles []string) ([]protocol.DocumentChanges, error) { |
| // Request all code actions that apply to the diagnostic. |
| // (The protocol supports filtering using Context.Only={actionKind} |
| // but we can give a better error if we don't filter.) |
| params := &protocol.CodeActionParams{ |
| TextDocument: protocol.TextDocumentIdentifier{URI: uri}, |
| Range: rng, |
| Context: protocol.CodeActionContext{ |
| Only: nil, // => all kinds |
| }, |
| } |
| 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 candidates CodeAction of the specified kind (e.g. refactor.rewrite). |
| var candidates []protocol.CodeAction |
| for _, act := range actions { |
| if act.Kind == protocol.CodeActionKind(actionKind) { |
| if len(titles) > 0 { |
| for _, f := range titles { |
| if act.Title == f { |
| candidates = append(candidates, act) |
| break |
| } |
| } |
| } else { |
| 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 matching filters %v for this diagnostic, want 1", len(candidates), actionKind, titles) |
| } |
| 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. |
| if action.Edit != nil { |
| if action.Edit.Changes != nil { |
| 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 Awaiter hook gathers the edits instead of applying them. |
| |
| _ = env.Awaiter.takeDocumentChanges() // reset (assuming Env is confined to this thread) |
| |
| if _, err := env.Editor.Server.ExecuteCommand(env.Ctx, &protocol.ExecuteCommandParams{ |
| Command: action.Command.Command, |
| Arguments: action.Command.Arguments, |
| }); err != nil { |
| return nil, err |
| } |
| return env.Awaiter.takeDocumentChanges(), nil |
| } |
| |
| return nil, nil |
| } |
| |
| // TODO(adonovan): suggestedfixerr |
| |
| // refsMarker implements the @refs marker. |
| func refsMarker(mark marker, src protocol.Location, want ...protocol.Location) { |
| refs := func(includeDeclaration bool, want []protocol.Location) error { |
| 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, "inlay hints", 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.PrepareRename2Gn{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, fmt.Sprintf("Symbol(%q)", query), 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, op string, got []byte, g *Golden) { |
| want, ok := g.Get(mark.T(), "", got) |
| if !ok { |
| mark.errorf("missing golden file @%s", g.id) |
| return |
| } |
| if diff := compare.Bytes(want, got); diff != "" { |
| mark.errorf("%s mismatch:\n%s", op, diff) |
| } |
| } |