// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package marker

// This file defines the marker test framework.
// See doc.go for extensive documentation.

import (
	"bytes"
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"go/token"
	"go/types"
	"io/fs"
	"log"
	"net/http/httptest"
	"os"
	"path"
	"path/filepath"
	"reflect"
	"regexp"
	"runtime"
	"slices"
	"sort"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/modelcontextprotocol/go-sdk/mcp"
	"golang.org/x/tools/gopls/internal/cache"
	"golang.org/x/tools/gopls/internal/debug"
	"golang.org/x/tools/gopls/internal/lsprpc"
	internalmcp "golang.org/x/tools/gopls/internal/mcp"
	"golang.org/x/tools/gopls/internal/protocol"
	"golang.org/x/tools/gopls/internal/test/compare"
	"golang.org/x/tools/gopls/internal/test/integration"
	"golang.org/x/tools/gopls/internal/test/integration/fake"
	"golang.org/x/tools/gopls/internal/util/bug"
	"golang.org/x/tools/gopls/internal/util/safetoken"
	"golang.org/x/tools/internal/diff"
	"golang.org/x/tools/internal/expect"
	"golang.org/x/tools/internal/gocommand"
	"golang.org/x/tools/internal/jsonrpc2"
	"golang.org/x/tools/internal/jsonrpc2/servertest"
	"golang.org/x/tools/internal/testenv"
	"golang.org/x/tools/txtar"
)

var update = flag.Bool("update", false, "if set, update test data during marker tests")

func TestMain(m *testing.M) {
	// golang/go#54461: enable additional debugging around hanging Go commands.
	gocommand.DebugHangingGoCommands = true

	bug.PanicOnBugs = true
	testenv.ExitIfSmallMachine()
	// Disable GOPACKAGESDRIVER, as it can cause spurious test failures.
	os.Setenv("GOPACKAGESDRIVER", "off") // ignore error
	integration.FilterToolchainPathAndGOROOT()
	os.Exit(m.Run())
}

// Test runs the marker tests from the testdata directory.
//
// See package documentation for details on how marker tests work.
//
// These tests were inspired by (and in many places copied from) a previous
// iteration of the marker tests built on top of the packagestest framework.
// Key design decisions motivating this reimplementation are as follows:
//   - The old tests had a single global session, causing interaction at a
//     distance and several awkward workarounds.
//   - The old tests could not be safely parallelized, because certain tests
//     manipulated the server options
//   - Relatedly, the old tests did not have a logic grouping of assertions into
//     a single unit, resulting in clusters of files serving clusters of
//     entangled assertions.
//   - The old tests used locations in the source as test names and as the
//     identity of golden content, meaning that a single edit could change the
//     name of an arbitrary number of subtests, and making it difficult to
//     manually edit golden content.
//   - The old tests did not hew closely to LSP concepts, resulting in, for
//     example, each marker implementation doing its own position
//     transformations, and inventing its own mechanism for configuration.
//   - The old tests had an ad-hoc session initialization process. The integration
//     test environment has had more time devoted to its initialization, and has a
//     more convenient API.
//   - The old tests lacked documentation, and often had failures that were hard
//     to understand. By starting from scratch, we can revisit these aspects.
func Test(t *testing.T) {
	if testing.Short() {
		builder := os.Getenv("GO_BUILDER_NAME")
		// Note that HasPrefix(builder, "darwin-" only matches legacy builders.
		// LUCI builder names start with x_tools-goN.NN.
		// We want to exclude solaris on both legacy and LUCI builders, as
		// it is timing out.
		if strings.HasPrefix(builder, "darwin-") || strings.Contains(builder, "solaris") {
			t.Skip("golang/go#64473: skipping with -short: this test is too slow on darwin and solaris builders")
		}
		if strings.HasSuffix(builder, "freebsd-amd64-race") {
			t.Skip("golang/go#71731: the marker tests are too slow to run on the amd64-race builder")
		}
	}
	// The marker tests must be able to run go/packages.Load.
	testenv.NeedsGoPackages(t)

	const dir = "testdata"
	tests, err := loadMarkerTests(dir)
	if err != nil {
		t.Fatal(err)
	}

	// Opt: use a shared cache.
	cache := cache.New(nil)

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()

			if test.skipReason != "" {
				t.Skip(test.skipReason)
			}
			if slices.Contains(test.skipGOOS, runtime.GOOS) {
				t.Skipf("skipping on %s due to -skip_goos", runtime.GOOS)
			}
			if slices.Contains(test.skipGOARCH, runtime.GOARCH) {
				t.Skipf("skipping on %s due to -skip_goarch", runtime.GOARCH)
			}

			// TODO(rfindley): it may be more useful to have full support for build
			// constraints.
			if test.minGoVersion != "" {
				var go1point int
				if _, err := fmt.Sscanf(test.minGoVersion, "go1.%d", &go1point); err != nil {
					t.Fatalf("parsing -min_go version: %v", err)
				}
				testenv.NeedsGo1Point(t, go1point)
			}
			if test.maxGoVersion != "" {
				// A max Go version may be useful when (e.g.) a recent go/types
				// fix makes it impossible to reproduce a certain older crash.
				var go1point int
				if _, err := fmt.Sscanf(test.maxGoVersion, "go1.%d", &go1point); err != nil {
					t.Fatalf("parsing -max_go version: %v", err)
				}
				testenv.SkipAfterGo1Point(t, go1point)
			}
			if test.minGoCommandVersion != "" {
				var go1point int
				if _, err := fmt.Sscanf(test.minGoCommandVersion, "go1.%d", &go1point); err != nil {
					t.Fatalf("parsing -min_go_command version: %v", err)
				}
				testenv.NeedsGoCommand1Point(t, go1point)
			}
			if test.maxGoCommandVersion != "" {
				var go1point int
				if _, err := fmt.Sscanf(test.maxGoCommandVersion, "go1.%d", &go1point); err != nil {
					t.Fatalf("parsing -max_go_command version: %v", err)
				}
				testenv.SkipAfterGoCommand1Point(t, go1point)
			}
			if test.cgo {
				if os.Getenv("CGO_ENABLED") == "0" {
					// NeedsTool causes the test to fail if cgo is available but disabled
					// on the current platform through the environment. I'm not sure why it
					// behaves this way, but if CGO_ENABLED=0 is set, we want to skip.
					t.Skip("skipping due to CGO_ENABLED=0")
				}
				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, test.mcp),
				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),
			}
			defer run.env.Close()

			// Support built-in pseudo-locations here.
			// fmtLoc coerces "got" Locations to these forms
			// so they can be compared without depending
			// on line numbers in external files.
			//
			// (We could actually define "builtin:int" etc
			// with the correct file/line/col for each
			// builtin symbol, but that doesn't seem to be
			// where the bugs are.)
			run.values["BUILTIN"] = protocol.Location{
				URI: "file:///pseudo/builtin/builtin.go",
			}
			run.values["UNSAFE"] = protocol.Location{
				URI: "file:///pseudo/unsafe/unsafe.go",
			}

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

			allDiags := make(map[string][]protocol.Diagnostic)
			if run.env.Editor.ServerCapabilities().DiagnosticProvider != nil {
				for name := range test.files {
					// golang/go#53275: support pull diagnostics for go.mod and go.work
					// files.
					if strings.HasSuffix(name, ".go") {
						allDiags[name] = run.env.Diagnostics(name)
					}
				}
			} else {
				// Wait for the didOpen notifications to be processed, then collect
				// diagnostics.

				run.env.AfterChange()
				var diags map[string]*protocol.PublishDiagnosticsParams
				run.env.AfterChange(integration.ReadAllDiagnostics(&diags))
				for path, params := range diags {
					allDiags[path] = params.Diagnostics
				}
			}

			for path, diags := range allDiags {
				uri := run.env.Sandbox.Workdir.URI(path)
				for _, diag := range diags {
					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 {
						// Note that loc is collapsed (start==end).
						// For formatting, show the exact span.
						exactLoc := protocol.Location{
							URI:   loc.URI,
							Range: diag.Range,
						}
						t.Errorf("%s: unexpected diagnostic: %q", run.fmtLoc(exactLoc), diag.Message)
					}
				}
			}

			// TODO(rfindley): use these for whole-file marker tests.
			for uri, extras := range run.extraNotes {
				for name, extra := range extras {
					if len(extra) > 0 {
						t.Errorf("%s: %d unused %q markers", run.env.Sandbox.Workdir.URIToPath(uri), len(extra), name)
					}
				}
			}

			// Now that all markers have executed, check whether there where any
			// unexpected error logs.
			// This guards against noisiness: see golang/go#66746)
			if !test.errorsOK {
				run.env.AfterChange(integration.NoErrorLogs())
			}

			formatted, err := formatTest(test)
			if err != nil {
				t.Errorf("formatTest: %v", err)
			} else if *update {
				filename := filepath.Join(dir, test.name)
				if err := os.WriteFile(filename, formatted, 0o644); err != nil {
					t.Error(err)
				}
			} else if !t.Failed() {
				// Verify that the testdata has not changed.
				//
				// Only check this if the test hasn't already failed, otherwise we'd
				// report duplicate mismatches of golden data.
				// Otherwise, verify that formatted content matches.
				if diff := compare.NamedText("formatted", "on-disk", string(formatted), string(test.content)); diff != "" {
					// at this point, the txtar file created by the test and the expected txtar file on the
					// disk differ. The differences are irrelevant if they are only the contents
					// of files from a unified diff. These files are dependent on which diff algorithm was used,
					// so instead checkDiffs has already checked that they correctly express the difference between
					// the test input and the test output. (If the test code uses the diff that created them, then
					// they would not differ, but that diff algorithms has been replaced.)
					checkDifferences(t, test.content, formatted)
				}
			}
		})
	}

	if abs, err := filepath.Abs(dir); err == nil && t.Failed() {
		t.Logf("(Filenames are relative to %s.)", abs)
	}
}

// checkDifferences reports whether the txtars in result and disk
// differ, other than in the contents of golden files containing unified diffs
func checkDifferences(t *testing.T, result, disk []byte) {
	got := txtar.Parse(result) // the computed slice of file retains the ordering in result
	want := txtar.Parse(disk)
	for i, tf := range got.Files {
		if i >= len(want.Files) {
			t.Errorf("test failed to create file %s", tf.Name)
			continue
		}
		// if they are both golden files (name starts with @) and both unified diffs
		// (both start with @@, then the difference is insignificant)
		ff := want.Files[i]
		if strings.HasPrefix(tf.Name, "@") && strings.HasPrefix(ff.Name, "@") && tf.Name == ff.Name &&
			bytes.HasPrefix(tf.Data, []byte("@@")) && bytes.HasPrefix(ff.Data, []byte("@@")) {
			continue
		}
		if !bytes.Equal(tf.Data, ff.Data) {
			t.Errorf("%s differs, got:%q want:%q", tf.Name, tf.Data, ff.Data)
		}
	}
}

// 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.TB }

// server returns the LSP server for the marker test run.
func (m marker) editor() *fake.Editor { return m.run.env.Editor }

// server returns the LSP server for the marker test run.
func (m marker) server() protocol.Server { return m.run.env.Editor.Server }

// uri returns the URI of the file containing the marker.
func (mark marker) uri() protocol.DocumentURI {
	return mark.run.env.Sandbox.Workdir.URI(mark.run.test.fset.File(mark.note.Pos).Name())
}

// document returns a protocol.TextDocumentIdentifier for the current file.
func (mark marker) document() protocol.TextDocumentIdentifier {
	return protocol.TextDocumentIdentifier{URI: mark.uri()}
}

// path returns the relative path to the file containing the marker.
func (mark marker) path() string {
	return mark.run.env.Sandbox.Workdir.RelPath(mark.run.test.fset.File(mark.note.Pos).Name())
}

// mapper returns a *protocol.Mapper for the current file.
func (mark marker) mapper() *protocol.Mapper {
	mapper, err := mark.editor().Mapper(mark.path())
	if err != nil {
		mark.T().Fatalf("failed to get mapper for current mark: %v", err)
	}
	return mapper
}

// errorf reports a formatted error with a prefix indicating the position of
// the marker note.
//
// It formats the error message using mark.sprintf.
func (mark marker) errorf(format string, args ...any) {
	mark.T().Helper()
	msg := mark.sprintf(format, args...)
	// TODO(adonovan): consider using fmt.Fprintf(os.Stderr)+t.Fail instead of
	// t.Errorf to avoid reporting uninteresting positions in the Go source of
	// the driver. However, this loses the order of stderr wrt "FAIL: TestFoo"
	// subtest dividers.
	mark.T().Errorf("%s: %s", mark.run.fmtPos(mark.note.Pos), msg)
}

// valueMarkerFunc returns a wrapper around a function that allows it to be
// called during the processing of value markers (e.g. @value(v, 123)) with marker
// arguments converted to function parameters. The provided function's first
// parameter must be of type 'marker', and it must return a value.
//
// Unlike action markers, which are executed for actions such as test
// assertions, value markers are all evaluated first, and each computes
// a value that is recorded by its identifier, which is the marker's first
// argument. These values may be referred to from an action marker by
// this identifier, e.g. @action(... , v, ...).
//
// For example, given a fn with signature
//
//	func(mark marker, label, details, kind string) CompletionItem
//
// The result of valueMarkerFunc can associated with @item notes, and invoked
// as follows:
//
//	//@item(FooCompletion, "Foo", "func() int", "func")
//
// The provided fn should not mutate the test environment.
func valueMarkerFunc(fn any) func(marker) {
	ftype := reflect.TypeOf(fn)
	if ftype.NumIn() == 0 || ftype.In(0) != markerType {
		panic(fmt.Sprintf("value marker function %#v must accept marker as its first argument", ftype))
	}
	if ftype.NumOut() != 1 {
		panic(fmt.Sprintf("value marker function %#v must have exactly 1 result", ftype))
	}

	return func(mark marker) {
		if len(mark.note.Args) == 0 || !is[expect.Identifier](mark.note.Args[0]) {
			mark.errorf("first argument to a value marker function must be an identifier")
			return
		}
		id := mark.note.Args[0].(expect.Identifier)
		if alt, ok := mark.run.values[id]; ok {
			mark.errorf("%s already declared as %T", id, alt)
			return
		}
		args := append([]any{mark}, mark.note.Args[1:]...)
		argValues, err := convertArgs(mark, ftype, args)
		if err != nil {
			mark.errorf("%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. Any named arguments that may be used by the marker func must be
// listed in allowedNames.
//
// The provided fn should not mutate the test environment.
func actionMarkerFunc(fn any, allowedNames ...string) 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))
	}

	var allowed map[string]bool
	if len(allowedNames) > 0 {
		allowed = make(map[string]bool)
		for _, name := range allowedNames {
			allowed[name] = true
		}
	}

	return func(mark marker) {
		for name := range mark.note.NamedArgs {
			if !allowed[name] {
				mark.errorf("unexpected named argument %q", name)
			}
		}

		args := append([]any{mark}, mark.note.Args...)
		argValues, err := convertArgs(mark, ftype, args)
		if err != nil {
			mark.errorf("%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
}

// namedArg returns the named argument for name, or the default value.
func namedArg[T any](mark marker, name string, dflt T) T {
	if v, ok := mark.note.NamedArgs[name]; ok {
		if e, ok := v.(T); ok {
			return e
		} else {
			v, err := convert(mark, v, reflect.TypeOf(dflt))
			if err != nil {
				mark.errorf("invalid value for %q: could not convert %v (%T) to %T", name, v, v, dflt)
				return dflt
			}
			return v.(T)
		}
	}
	return dflt
}

func namedArgFunc[T any](mark marker, name string, f func(marker, any) (T, error), dflt T) T {
	if v, ok := mark.note.NamedArgs[name]; ok {
		if v2, err := f(mark, v); err == nil {
			return v2
		} else {
			mark.errorf("invalid value for %q: %v: %v", name, v, err)
		}
	}
	return dflt
}

// is reports whether arg is a T.
func is[T any](arg any) bool {
	_, ok := arg.(T)
	return ok
}

// Supported value marker functions. See [valueMarkerFunc] for more details.
var valueMarkerFuncs = map[string]func(marker){
	"loc":    valueMarkerFunc(locMarker),
	"item":   valueMarkerFunc(completionItemMarker),
	"hiloc":  valueMarkerFunc(highlightLocationMarker),
	"defloc": valueMarkerFunc(defLocMarker),
}

// Supported action marker functions. See [actionMarkerFunc] for more details.
//
// See doc.go for marker documentation.
var actionMarkerFuncs = map[string]func(marker){
	"acceptcompletion": actionMarkerFunc(acceptCompletionMarker),
	"codeaction":       actionMarkerFunc(codeActionMarker, "end", "diag", "action", "result", "edit", "err", "form0", "form1"),
	"codelenses":       actionMarkerFunc(codeLensesMarker),
	"complete":         actionMarkerFunc(completeMarker),
	"def":              actionMarkerFunc(defMarker),
	"diag":             actionMarkerFunc(diagMarker, "exact"),
	"documentlink":     actionMarkerFunc(documentLinkMarker),
	"foldingrange":     actionMarkerFunc(foldingRangeMarker),
	"format":           actionMarkerFunc(formatMarker),
	"highlight":        actionMarkerFunc(highlightMarker),
	"highlightall":     actionMarkerFunc(highlightAllMarker),
	"hover":            actionMarkerFunc(hoverMarker),
	"hovererr":         actionMarkerFunc(hoverErrMarker),
	"implementation":   actionMarkerFunc(implementationMarker, "err"),
	"incomingcalls":    actionMarkerFunc(incomingCallsMarker),
	"inlayhints":       actionMarkerFunc(inlayhintsMarker),
	"outgoingcalls":    actionMarkerFunc(outgoingCallsMarker),
	"preparerename":    actionMarkerFunc(prepareRenameMarker, "span"),
	"rank":             actionMarkerFunc(rankMarker),
	"refs":             actionMarkerFunc(refsMarker),
	"rename":           actionMarkerFunc(renameMarker),
	"renameerr":        actionMarkerFunc(renameErrMarker),
	"selectionrange":   actionMarkerFunc(selectionRangeMarker),
	"signature":        actionMarkerFunc(signatureMarker),
	"snippet":          actionMarkerFunc(snippetMarker),
	"subtypes":         actionMarkerFunc(subtypesMarker),
	"supertypes":       actionMarkerFunc(supertypesMarker),
	"quickfix":         actionMarkerFunc(quickfixMarker),
	"quickfixerr":      actionMarkerFunc(quickfixErrMarker),
	"symbol":           actionMarkerFunc(symbolMarker),
	"token":            actionMarkerFunc(tokenMarker),
	"typedef":          actionMarkerFunc(typedefMarker, "err"),
	"workspacesymbol":  actionMarkerFunc(workspaceSymbolMarker),
	"mcptool":          actionMarkerFunc(mcpToolMarker, "location", "output"),
}

// markerTest holds all the test data extracted from a test txtar archive.
//
// See the documentation for RunMarkerTests for more information on the archive
// format.
type markerTest struct {
	name         string                        // relative path to the txtar file in the testdata dir
	fset         *token.FileSet                // fileset used for parsing notes
	content      []byte                        // raw test content
	archive      *txtar.Archive                // original test archive
	settings     map[string]any                // gopls settings
	capabilities []byte                        // content of capabilities.json file
	env          map[string]string             // editor environment
	proxyFiles   map[string][]byte             // proxy content
	files        map[string][]byte             // data files from the archive (excluding special files)
	notes        []*expect.Note                // extracted notes from data files
	golden       map[expect.Identifier]*Golden // extracted golden content, by identifier name

	skipReason string   // the skip reason extracted from the "skip" archive file
	flags      []string // flags extracted from the special "flags" archive file.

	// Parsed flags values. See the flag definitions below for documentation.
	minGoVersion, maxGoVersion               string // min/max version of Go runtime
	minGoCommandVersion, maxGoCommandVersion string // min/max version of ambient go command

	cgo              bool
	writeGoSum       []string
	skipGOOS         []string
	skipGOARCH       []string
	ignoreExtraDiags bool
	filterBuiltins   bool
	filterKeywords   bool
	errorsOK         bool
	mcp              bool
}

// flagSet returns the flagset used for parsing the special "flags" file in the
// test archive.
func (t *markerTest) flagSet() *flag.FlagSet {
	flags := flag.NewFlagSet(t.name, flag.ContinueOnError)
	flags.StringVar(&t.minGoVersion, "min_go", "", "if set, the minimum go1.X version required for this test")
	flags.StringVar(&t.maxGoVersion, "max_go", "", "if set, the maximum go1.X version required for this test")
	flags.StringVar(&t.minGoCommandVersion, "min_go_command", "", "if set, the minimum go1.X go command version required for this test")
	flags.StringVar(&t.maxGoCommandVersion, "max_go_command", "", "if set, the maximum go1.X go command version required for this test")
	flags.BoolVar(&t.cgo, "cgo", false, "if set, requires cgo (both the cgo tool and CGO_ENABLED=1)")
	flags.Var((*stringListValue)(&t.writeGoSum), "write_sumfile", "if set, write the sumfile for these directories")
	flags.Var((*stringListValue)(&t.skipGOOS), "skip_goos", "if set, skip this test on these GOOS values")
	flags.Var((*stringListValue)(&t.skipGOARCH), "skip_goarch", "if set, skip this test on these GOARCH values")
	flags.BoolVar(&t.ignoreExtraDiags, "ignore_extra_diags", false, "if set, suppress errors for unmatched diagnostics")
	flags.BoolVar(&t.filterBuiltins, "filter_builtins", true, "if set, filter builtins from completion results")
	flags.BoolVar(&t.filterKeywords, "filter_keywords", true, "if set, filter keywords from completion results")
	flags.BoolVar(&t.errorsOK, "errors_ok", false, "if set, Error level log messages are acceptable in this test")
	flags.BoolVar(&t.mcp, "mcp", false, "if set, enable model context protocol client and server in this test")
	return flags
}

// stringListValue implements flag.Value.
type stringListValue []string

func (l *stringListValue) Set(s string) error {
	if s != "" {
		for d := range strings.SplitSeq(s, ",") {
			*l = append(*l, strings.TrimSpace(d))
		}
	}
	return nil
}

func (l stringListValue) String() string {
	return strings.Join([]string(l), ",")
}

func (mark *marker) getGolden(id expect.Identifier) *Golden {
	t := mark.run.test
	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
	}
	if golden.firstReference == "" {
		golden.firstReference = mark.path()
	}
	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
	firstReference string            // file name first referencing this golden content
	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.
// The optional only parameter contains fragments of the test names to be loaded.
// If it is missing, all the tests are loaded. (Useful for faster debugging.)
func loadMarkerTests(dir string, only ...string) ([]*markerTest, error) {
	var tests []*markerTest
	var use *regexp.Regexp
	if len(only) > 0 {
		var err error
		use, err = regexp.Compile(strings.Join(only, "|"))
		if err != nil {
			return nil, err
		}
	} else {
		use = regexp.MustCompile(".")
	}
	err := filepath.WalkDir(dir, func(path string, _ fs.DirEntry, err error) error {
		if strings.HasSuffix(path, ".txt") {
			if !use.MatchString(path) {
				return nil
			}
			content, err := os.ReadFile(path)
			if err != nil {
				return err
			}

			name := filepath.ToSlash(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),
	}
	seen := make(map[string]bool)
	for _, file := range archive.Files {
		if seen[file.Name] {
			return nil, fmt.Errorf("duplicate archive section %q", file.Name)
		}
		seen[file.Name] = true
		switch {
		case file.Name == "skip":
			reason := strings.ReplaceAll(string(file.Data), "\n", " ")
			reason = strings.TrimSpace(reason)
			test.skipReason = reason

		case file.Name == "flags":
			test.flags = strings.Fields(string(file.Data))

		case file.Name == "settings.json":
			if err := json.Unmarshal(file.Data, &test.settings); err != nil {
				return nil, err
			}

		case file.Name == "capabilities.json":
			test.capabilities = file.Data // lazily unmarshalled by the editor

		case file.Name == "env":
			test.env = make(map[string]string)
			fields := strings.Fields(string(file.Data))
			for _, field := range fields {
				key, value, ok := strings.Cut(field, "=")
				if !ok {
					return nil, fmt.Errorf("env vars must be formatted as var=value, got %q", field)
				}
				test.env[key] = value
			}

		case strings.HasPrefix(file.Name, "@"): // golden content
			idstring, name, _ := strings.Cut(file.Name[len("@"):], "/")
			id := expect.Identifier(idstring)
			// Note that a file.Name of just "@id" gives (id, name) = ("id", "").
			if _, ok := test.golden[id]; !ok {
				test.golden[id] = &Golden{
					id:   id,
					data: make(map[string][]byte),
				}
			}
			test.golden[id].data[name] = file.Data

		case strings.HasPrefix(file.Name, "proxy/"):
			name := file.Name[len("proxy/"):]
			if test.proxyFiles == nil {
				test.proxyFiles = make(map[string][]byte)
			}
			test.proxyFiles[name] = file.Data

		default: // ordinary file content
			notes, err := expect.Parse(test.fset, file.Name, file.Data)
			if err != nil {
				return nil, fmt.Errorf("parsing notes in %q: %v", file.Name, err)
			}

			// Reject common misspelling: "// @mark".
			// TODO(adonovan): permit "// @" within a string. Detect multiple spaces.
			if i := bytes.Index(file.Data, []byte("// @")); i >= 0 {
				line := 1 + bytes.Count(file.Data[:i], []byte("\n"))
				return nil, fmt.Errorf("%s:%d: unwanted space before marker (// @)", file.Name, line)
			}

			// The 'go list' command doesn't work correctly with modules named
			// testdata", so don't allow it as a module name (golang/go#65406).
			// (Otherwise files within it will end up in an ad hoc
			// package, "command-line-arguments/$TMPDIR/...".)
			if filepath.Base(file.Name) == "go.mod" &&
				bytes.Contains(file.Data, []byte("module testdata")) {
				return nil, fmt.Errorf("'testdata' is not a valid module name")
			}

			test.notes = append(test.notes, notes...)
			test.files[file.Name] = file.Data
		}

		// Print a warning if we see what looks like "-- filename --"
		// without the second "--". It's not necessarily wrong,
		// but it should almost never appear in our test inputs.
		if bytes.Contains(file.Data, []byte("\n-- ")) {
			log.Printf("ill-formed '-- filename --' header in %s?", file.Name)
		}
	}

	// Parse flags after loading files, as they may have been set by the "flags" file.
	set := test.flagSet()
	if err := set.Parse(test.flags); err != nil {
		return nil, fmt.Errorf("parsing flags: %v", err)
	}
	if set.NArg() > 0 {
		return nil, fmt.Errorf("surplus arguments after flags: %q", set.Args())
	}

	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)
	firstReferences := make(map[string]string)
	for id, g := range test.golden {
		for name, data := range g.updated {
			filename := "@" + path.Join(string(id), name) // name may be ""
			updatedGolden[filename] = data
			firstReferences[filename] = g.firstReference
		}
	}

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

	// ...but insert new golden files after their first reference.
	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
	})
	for _, g := range newGoldenFiles {
		insertAt := len(arch.Files)
		if firstRef := firstReferences[g.Name]; firstRef != "" {
			for i, f := range arch.Files {
				if f.Name == firstRef {
					// Insert alphabetically among golden files following the test file.
					for i++; i < len(arch.Files); i++ {
						f := arch.Files[i]
						if !strings.HasPrefix(f.Name, "@") || f.Name >= g.Name {
							insertAt = i
							break
						}
					}
					break
				}
			}
		}
		arch.Files = slices.Insert(arch.Files, insertAt, g)
	}

	return txtar.Format(arch), nil
}

// newEnv creates a new environment for a marker test.
//
// TODO(rfindley): simplify and refactor the construction of testing
// environments across integration tests, marker tests, and benchmarks.
func newEnv(t *testing.T, cache *cache.Cache, files, proxyFiles map[string][]byte, writeGoSum []string, config fake.EditorConfig, enableMCP bool) *integration.Env {
	sandbox, err := fake.NewSandbox(&fake.SandboxConfig{
		RootDir:    t.TempDir(),
		Files:      files,
		ProxyFiles: proxyFiles,
	})
	if err != nil {
		t.Fatal(err)
	}

	for _, dir := range writeGoSum {
		if _, err := sandbox.RunGoCommand(context.Background(), dir, "list", []string{"-mod=mod", "..."}, []string{"GOWORK=off"}, true); err != nil {
			t.Fatal(err)
		}
	}

	// Put a debug instance in the context to prevent logging to stderr.
	// See associated TODO in runner.go: we should revisit this pattern.
	ctx := context.Background()
	ctx = debug.WithInstance(ctx, "")

	awaiter := integration.NewAwaiter(sandbox.Workdir)

	ss := lsprpc.NewStreamServer(cache, false, nil)

	var mcpServer *httptest.Server
	if enableMCP {
		mcpServer = httptest.NewServer(internalmcp.HTTPHandler(ss, false, nil))
	}

	server := servertest.NewPipeServer(ss, jsonrpc2.NewRawStream)
	editor, err := fake.NewEditor(sandbox, config).Connect(ctx, server, awaiter.Hooks())
	if err != nil {
		sandbox.Close() // ignore error
		t.Fatal(err)
	}

	if err := awaiter.Await(ctx, integration.OnceMet(
		integration.InitialWorkspaceLoad,
		integration.NoShownMessage(""),
	)); err != nil {
		sandbox.Close() // ignore error
		t.Fatal(err)
	}

	var mcpSession *mcp.ClientSession
	if enableMCP {
		client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v1.0.0"}, nil)
		mcpSession, err = client.Connect(ctx, &mcp.SSEClientTransport{Endpoint: mcpServer.URL}, nil)
		if err != nil {
			t.Fatalf("fail to connect to mcp server: %v", err)
		}
	}

	return &integration.Env{
		TB:         t,
		Ctx:        ctx,
		Editor:     editor,
		Sandbox:    sandbox,
		Awaiter:    awaiter,
		MCPSession: mcpSession,
		MCPServer:  mcpServer,
	}
}

// A markerTestRun holds the state of one run of a marker test archive.
type markerTestRun struct {
	test     *markerTest
	env      *integration.Env
	settings map[string]any

	// Collected information.
	// Each @diag/@quickfix marker eliminates an entry from diags.
	values map[expect.Identifier]any
	diags  map[protocol.Location][]protocol.Diagnostic // diagnostics by position; location end == start

	// Notes that weren't associated with a top-level marker func. They may be
	// consumed by another marker (e.g. @codelenses collects @codelens markers).
	// Any notes that aren't consumed are flagged as an error.
	extraNotes map[protocol.DocumentURI]map[string][]*expect.Note
}

// sprintf returns a formatted string after applying pre-processing to
// arguments of the following types:
//   - token.Pos: formatted using (*markerTestRun).fmtPos
//   - protocol.Location: formatted using (*markerTestRun).fmtLoc
func (c *marker) sprintf(format string, args ...any) string {
	if false {
		_ = fmt.Sprintf(format, args...) // enable vet printf checker
	}
	var args2 []any
	for _, arg := range args {
		switch arg := arg.(type) {
		case token.Pos:
			args2 = append(args2, c.run.fmtPos(arg))
		case protocol.Location:
			args2 = append(args2, c.run.fmtLoc(arg))
		default:
			args2 = append(args2, arg)
		}
	}
	return fmt.Sprintf(format, args2...)
}

// fmtPos formats the given pos in the context of the test, using
// archive-relative paths for files and including the line number in the full
// archive file.
func (run *markerTestRun) fmtPos(pos token.Pos) string {
	file := run.test.fset.File(pos)
	if file == nil {
		run.env.TB.Errorf("position %d not in test fileset", pos)
		return "<invalid location>"
	}
	m, err := run.env.Editor.Mapper(file.Name())
	if err != nil {
		run.env.TB.Errorf("%s", err)
		return "<invalid location>"
	}
	loc, err := m.PosLocation(file, pos, pos)
	if err != nil {
		run.env.TB.Errorf("Mapper(%s).PosLocation failed: %v", file.Name(), err)
	}
	return run.fmtLoc(loc)
}

// fmtLoc formats the given location in the context of the test, using
// archive-relative paths for files and including the line number in the full
// archive file.
func (run *markerTestRun) fmtLoc(loc protocol.Location) string {
	if loc == (protocol.Location{}) {
		run.env.TB.Errorf("unable to find %s in test archive", loc)
		return "<invalid location>"
	}

	// Format builtin locations as "file:///pseudo/builtin/builtin.go:0:0",
	// which is the predefined BUILTIN marker; ditto UNSAFE.
	if strings.HasSuffix(string(loc.URI), "src/unsafe/unsafe.go") ||
		strings.HasSuffix(string(loc.URI), "src/builtin/builtin.go") {
		name := filepath.Base(filepath.Dir(loc.URI.Path())) // "unsafe" or "builtin"
		loc.URI = protocol.DocumentURI("file:///pseudo/" + name + "/" + name + ".go")
		loc.Range = protocol.Range{}
	}

	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 == "" {
		// Fall back to formatting the "lsp" location.
		// These will be in UTF-16, but we probably don't need to clarify that,
		// since it will be implied by the file:// URI format.
		return summarizeLoc(string(loc.URI),
			int(loc.Range.Start.Line), int(loc.Range.Start.Character),
			int(loc.Range.End.Line), int(loc.Range.End.Character))
	}
	name, startLine, startCol, endLine, endCol := run.mapLocation(loc)
	innerSpan := summarizeLoc(name, startLine, startCol, endLine, endCol)
	outerSpan := summarizeLoc(run.test.name, lines+startLine, startCol, lines+endLine, endCol)
	return fmt.Sprintf("%s (%s)", innerSpan, outerSpan)
}

// mapLocation returns the relative path and utf8 span of the corresponding
// location, which must be a valid location in an archive file.
func (run *markerTestRun) mapLocation(loc protocol.Location) (name string, startLine, startCol, endLine, endCol int) {
	// Note: Editor.Mapper fails if loc.URI is not open, but we always open all
	// archive files, so this is probably OK.
	//
	// In the future, we may want to have the editor read contents from disk if
	// the URI is not open.
	name = run.env.Sandbox.Workdir.URIToPath(loc.URI)
	m, err := run.env.Editor.Mapper(name)
	if err != nil {
		run.env.TB.Errorf("internal error: %v", err)
		return
	}
	start, end, err := m.RangeOffsets(loc.Range)
	if err != nil {
		run.env.TB.Errorf("error formatting location %s: %v", loc, err)
		return
	}
	startLine, startCol = m.OffsetLineCol8(start)
	endLine, endCol = m.OffsetLineCol8(end)
	return name, startLine, startCol, endLine, endCol
}

// fmtLocForGolden is like fmtLoc, but chooses more succinct and stable
// formatting, such as would be used for formatting locations in Golden
// content.
func (run *markerTestRun) fmtLocForGolden(loc protocol.Location) string {
	if loc == (protocol.Location{}) {
		return "<invalid location>"
	}
	name := run.env.Sandbox.Workdir.URIToPath(loc.URI)
	// Note: we check IsAbs on filepaths rather than the slash-ified name for
	// accurate handling of windows drive letters.
	if filepath.IsAbs(filepath.FromSlash(name)) {
		// Don't format any position information in this case, since it will be
		// volatile.
		return "<external>"
	}
	return summarizeLoc(run.mapLocation(loc))
}

// summarizeLoc formats a summary of the given location, in the form
//
//	<name>:<startLine>:<startCol>[-[<endLine>:]endCol]
func summarizeLoc(name string, startLine, startCol, endLine, endCol int) string {
	span := fmt.Sprintf("%s:%d:%d", name, startLine, startCol)
	if startLine != endLine || startCol != endCol {
		span += "-"
		if endLine != startLine {
			span += fmt.Sprintf("%d:", endLine)
		}
		span += fmt.Sprintf("%d", endCol)
	}
	return span
}

// ---- converters ----

// Types with special handling.
var (
	goldenType        = reflect.TypeFor[*Golden]()
	markerType        = reflect.TypeFor[marker]()
	stringMatcherType = reflect.TypeFor[stringMatcher]()
)

// Custom conversions.
//
// These functions are called after valueMarkerFuncs have run to convert
// arguments into the desired parameter types.
//
// Converters should return an error rather than calling marker.errorf().
var customConverters = map[reflect.Type]func(marker, any) (any, error){
	reflect.TypeFor[protocol.Location](): converter(convertLocation),
	reflect.TypeFor[completionLabel]():   converter(convertCompletionLabel),
}

// converter transforms a typed argument conversion function to an untyped
// conversion function.
func converter[T any](f func(marker, any) (T, error)) func(marker, any) (any, error) {
	return func(m marker, arg any) (any, error) {
		return f(m, arg)
	}
}

func convert(mark marker, arg any, paramType reflect.Type) (any, error) {
	// Handle stringMatcher and golden parameters before resolving identifiers,
	// because golden content lives in a separate namespace from other
	// identifiers.
	// TODO(rfindley): simplify by flattening the namespace. This interacts
	// poorly with named argument resolution.
	switch paramType {
	case stringMatcherType:
		return convertStringMatcher(mark, arg)
	case goldenType:
		id, ok := arg.(expect.Identifier)
		if !ok {
			return nil, fmt.Errorf("invalid input type %T: golden key must be an identifier", arg)
		}
		return mark.getGolden(id), nil
	}
	if id, ok := arg.(expect.Identifier); ok {
		if arg2, ok := mark.run.values[id]; ok {
			arg = arg2
		}
	}
	if converter, ok := customConverters[paramType]; ok {
		arg2, err := converter(mark, arg)
		if err != nil {
			return nil, err
		}
		arg = arg2
	}
	if reflect.TypeOf(arg).AssignableTo(paramType) {
		return arg, nil // no conversion required
	}
	return nil, fmt.Errorf("cannot convert %v (%T) to %s", arg, arg, paramType)
}

// convertNamedArgLocation is a workaround for converting locations referenced
// by a named argument. See the TODO in [convert]: this wouldn't be necessary
// if we flattened the namespace such that golden content lived in the same
// namespace as values.
func convertNamedArgLocation(mark marker, arg any) (protocol.Location, error) {
	if id, ok := arg.(expect.Identifier); ok {
		if v, ok := mark.run.values[id]; ok {
			if loc, ok := v.(protocol.Location); ok {
				return loc, nil
			}
			return protocol.Location{}, fmt.Errorf("invalid location value %v", v)
		}
	}
	return convertLocation(mark, arg)
}

// convertLocation converts a string or regexp argument into the protocol
// location corresponding to the first position of the string (or first match
// of the regexp) in the line preceding the note.
func convertLocation(mark marker, arg any) (protocol.Location, error) {
	// matchContent is used to match the given argument against the file content
	// starting at the marker line.
	var matchContent func([]byte) (int, int, error)

	switch arg := arg.(type) {
	case protocol.Location:
		return arg, nil // nothing to do
	case string:
		matchContent = func(content []byte) (int, int, error) {
			idx := bytes.Index(content, []byte(arg))
			if idx < 0 {
				return 0, 0, fmt.Errorf("substring %q not found", arg)
			}
			return idx, idx + len(arg), nil
		}
	case *regexp.Regexp:
		matchContent = func(content []byte) (int, int, error) {
			matches := arg.FindSubmatchIndex(content)
			if len(matches) == 0 {
				return 0, 0, fmt.Errorf("no match for regexp %q", arg)
			}
			switch len(matches) {
			case 2:
				// no subgroups: return the range of the regexp expression
				return matches[0], matches[1], nil
			case 4:
				// one subgroup: return its range
				return matches[2], matches[3], nil
			default:
				return 0, 0, fmt.Errorf("invalid location regexp %q: expect either 0 or 1 subgroups, got %d", arg, len(matches)/2-1)
			}
		}
	default:
		return protocol.Location{}, fmt.Errorf("cannot convert argument type %T to location (must be a string or regexp to match the preceding line)", arg)
	}

	// Now use matchFunc to match a range starting on the marker line.

	file := mark.run.test.fset.File(mark.note.Pos)
	posn := safetoken.Position(file, mark.note.Pos)
	lineStart := file.LineStart(posn.Line)
	lineStartOff, lineEndOff, err := safetoken.Offsets(file, lineStart, mark.note.Pos)
	if err != nil {
		return protocol.Location{}, err
	}
	m := mark.mapper()
	start, end, err := matchContent(m.Content[lineStartOff:])
	if err != nil {
		return protocol.Location{}, err
	}
	startOff, endOff := lineStartOff+start, lineStartOff+end
	if startOff > lineEndOff {
		// The start of the match must be between the start of the line and the
		// marker position (inclusive).
		return protocol.Location{}, fmt.Errorf("no matching range found starting on the current line")
	}
	return m.OffsetLocation(startOff, endOff)
}

// completionLabel is a special parameter type that may be converted from a
// string literal, or extracted from a completion item.
//
// See [convertCompletionLabel].
type completionLabel string

// convertCompletionLabel coerces an argument to a [completionLabel] parameter
// type.
//
// If the arg is a string, it is trivially converted. If the arg is a
// completionItem, its label is extracted.
//
// This allows us to stage a migration of the "snippet" marker to a simpler
// model where the completion label can just be listed explicitly.
func convertCompletionLabel(mark marker, arg any) (completionLabel, error) {
	switch arg := arg.(type) {
	case string:
		return completionLabel(arg), nil
	case completionItem:
		return completionLabel(arg.Label), nil
	default:
		return "", fmt.Errorf("cannot convert argument type %T to completion label (must be a string or completion item)", arg)
	}
}

// convertStringMatcher converts a string, regexp, or identifier
// argument into a stringMatcher. The string is a substring of the
// expected error, the regexp is a pattern than matches the expected
// error, and the identifier is a golden file containing the expected
// error.
func convertStringMatcher(mark marker, arg any) (stringMatcher, error) {
	switch arg := arg.(type) {
	case string:
		return stringMatcher{substr: arg}, nil
	case *regexp.Regexp:
		return stringMatcher{pattern: arg}, nil
	case expect.Identifier:
		golden := mark.getGolden(arg)
		return stringMatcher{golden: golden}, nil
	default:
		return stringMatcher{}, fmt.Errorf("cannot convert %T to wantError (want: string, regexp, or identifier)", arg)
	}
}

// A stringMatcher represents an expectation of a specific string value.
//
// It may be indicated in one of three ways, in 'expect' notation:
//   - an identifier 'foo', to compare (exactly) with the contents of the golden
//     section @foo;
//   - a pattern expression re"ab.*c", to match against a regular expression;
//   - a string literal "abc", to check for a substring.
type stringMatcher struct {
	golden  *Golden
	pattern *regexp.Regexp
	substr  string
}

// empty reports whether the receiver is an empty stringMatcher.
func (sm stringMatcher) empty() bool {
	return sm.golden == nil && sm.pattern == nil && sm.substr == ""
}

func (sm stringMatcher) String() string {
	if sm.golden != nil {
		return fmt.Sprintf("content from @%s entry", sm.golden.id)
	} else if sm.pattern != nil {
		return fmt.Sprintf("content matching %#q", sm.pattern)
	} else {
		return fmt.Sprintf("content with substring %q", sm.substr)
	}
}

// checkErr asserts that the given error matches the stringMatcher's expectations.
func (sm stringMatcher) checkErr(mark marker, err error) {
	if err == nil {
		mark.errorf("@%s succeeded unexpectedly, want %v", mark.note.Name, sm)
		return
	}
	sm.check(mark, err.Error())
}

// check asserts that the given content matches the stringMatcher's expectations.
func (sm stringMatcher) check(mark marker, got string) {
	if sm.golden != nil {
		compareGolden(mark, []byte(got), sm.golden)
	} else if sm.pattern != nil {
		// Content must match the regular expression pattern.
		if !sm.pattern.MatchString(got) {
			mark.errorf("got %q, does not match pattern %#q", got, sm.pattern)
		}
	} else if !strings.Contains(got, sm.substr) {
		// Content must contain the expected substring.
		mark.errorf("got %q, want substring %q", got, sm.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:\n-- got --\n%s\n-- want --\n%s\n-- diff --\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 checks that the diff content stored in the given golden
// directory converts the original contents into the changed contents.
// (It does _not_ check that the diff has a specific notation, as
// there are many valid ways to notate the same patch and we do not
// want tests to break whenever our diff algorithm evolves.)
func checkDiffs(mark marker, changed map[string][]byte, golden *Golden) {
	for name, after := range changed {
		before := mark.run.env.FileContent(name)
		edits := diff.Lines(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.
		// (an artifact of ToUnified, but not stored in the marker files)
		difflines := strings.Split(d, "\n")
		var got string
		if len(difflines) >= 2 && strings.HasPrefix(difflines[1], "+++") {
			got = strings.Join(difflines[2:], "\n")
		} else {
			got = d // "" in packagedecl.txt:147
		}
		// the call to Get is so that the -update flag will update the test.
		// normally it just returns 'got'.
		tdiffs, ok := golden.Get(mark.T(), name, []byte(got))
		if !ok {
			mark.errorf("%s: unexpected change to file %s; got diff:\n%s",
				mark.note.Name, name, got)
			return
		}
		want, err := diff.ApplyUnified(string(tdiffs), before)
		if err != nil {
			mark.errorf("in @%s golden section %s: %v; actual diff:\n%s",
				mark.note.Name, name, err, d)
		}
		if want != string(after) {
			mark.errorf("%s: got:\n%s\nwant:\n%s", mark.note.Name, want, after)
		}
	}

	// Report unmet expectations.
	for filename := range golden.data {
		if _, ok := changed[filename]; !ok {
			want, _ := golden.Get(mark.T(), filename, nil)
			mark.errorf("%s: missing change to file %s; want:\n%s",
				mark.note.Name, filename, want)
		}
	}
}

// ---- marker functions ----

// TODO(rfindley): consolidate documentation of these markers. They are already
// documented above, so much of the documentation here is redundant.

// completionItem is a simplified summary of a completion item.
type completionItem struct {
	Label, Detail, Kind, Documentation string
}

func completionItemMarker(mark marker, label string, other ...string) completionItem {
	if len(other) > 3 {
		mark.errorf("too many arguments to @item: expect at most 4")
	}
	item := completionItem{
		Label: label,
	}
	if len(other) > 0 {
		item.Detail = other[0]
	}
	if len(other) > 1 {
		item.Kind = other[1]
	}
	if len(other) > 2 {
		item.Documentation = other[2]
	}
	return item
}

func rankMarker(mark marker, src protocol.Location, items ...completionLabel) {
	// Separate positive and negative items (expectations).
	var pos, neg []completionLabel
	for _, item := range items {
		if strings.HasPrefix(string(item), "!") {
			neg = append(neg, item)
		} else {
			pos = append(pos, item)
		}
	}

	// Collect results that are present in items, preserving their order.
	list := mark.run.env.Completion(src)
	var got []string
	for _, g := range list.Items {
		for _, w := range pos {
			if g.Label == string(w) {
				got = append(got, g.Label)
				break
			}
		}
		for _, w := range neg {
			if g.Label == string(w[len("!"):]) {
				mark.errorf("got unwanted completion: %s", g.Label)
				break
			}
		}
	}
	var want []string
	for _, w := range pos {
		want = append(want, string(w))
	}
	if diff := cmp.Diff(want, got); diff != "" {
		mark.errorf("completion rankings do not match (-want +got):\n%s", diff)
	}
}

func snippetMarker(mark marker, src protocol.Location, label completionLabel, want string) {
	list := mark.run.env.Completion(src)
	var (
		found bool
		got   string
		all   []string // for errors
	)
	items := filterBuiltinsAndKeywords(mark, list.Items)
	for _, i := range items {
		all = append(all, i.Label)
		if i.Label == string(label) {
			found = true
			if i.TextEdit != nil {
				if edit, err := protocol.SelectCompletionTextEdit(i, false); err == nil {
					got = edit.NewText
				}
			}
			break
		}
	}
	if !found {
		mark.errorf("no completion item found matching %s (got: %v)", label, all)
		return
	}
	if got != want {
		mark.errorf("snippets do not match: got:\n%q\nwant:\n%q", got, want)
	}
}

// completeMarker implements the @complete marker, running
// textDocument/completion at the given src location and asserting that the
// results match the expected results.
func completeMarker(mark marker, src protocol.Location, want ...completionItem) {
	list := mark.run.env.Completion(src)
	items := filterBuiltinsAndKeywords(mark, list.Items)
	var got []completionItem
	for i, item := range items {
		simplified := completionItem{
			Label:  item.Label,
			Detail: item.Detail,
			Kind:   fmt.Sprint(item.Kind),
		}
		if item.Documentation != nil {
			switch v := item.Documentation.Value.(type) {
			case string:
				simplified.Documentation = v
			case protocol.MarkupContent:
				simplified.Documentation = strings.TrimSpace(v.Value) // trim newlines
			}
		}
		// Support short-hand notation: if Detail, Kind, or Documentation are omitted from the
		// item, don't match them.
		if i < len(want) {
			if want[i].Detail == "" {
				simplified.Detail = ""
			}
			if want[i].Kind == "" {
				simplified.Kind = ""
			}
			if want[i].Documentation == "" {
				simplified.Documentation = ""
			}
		}
		got = append(got, simplified)
	}
	if len(want) == 0 {
		want = nil // got is nil if empty
	}
	if diff := cmp.Diff(want, got); diff != "" {
		mark.errorf("Completion(...) returned unexpected results (-want +got):\n%s", diff)
	}
}

// filterBuiltinsAndKeywords filters out builtins and keywords from completion
// results.
//
// It over-approximates, and does not detect if builtins are shadowed.
func filterBuiltinsAndKeywords(mark marker, items []protocol.CompletionItem) []protocol.CompletionItem {
	keep := 0
	for _, item := range items {
		if mark.run.test.filterKeywords && item.Kind == protocol.KeywordCompletion {
			continue
		}
		if mark.run.test.filterBuiltins && types.Universe.Lookup(item.Label) != nil {
			continue
		}
		items[keep] = item
		keep++
	}
	return items[:keep]
}

// acceptCompletionMarker implements the @acceptCompletion marker, running
// textDocument/completion at the given src location and accepting the
// candidate with the given label. The resulting source must match the provided
// golden content.
func acceptCompletionMarker(mark marker, src protocol.Location, label string, golden *Golden) {
	list := mark.run.env.Completion(src)
	var selected *protocol.CompletionItem
	for _, item := range list.Items {
		if item.Label == label {
			selected = &item
			break
		}
	}
	if selected == nil {
		mark.errorf("Completion(...) did not return an item labeled %q", label)
		return
	}
	edit, err := protocol.SelectCompletionTextEdit(*selected, false)
	if err != nil {
		mark.errorf("Completion(...) did not return a valid edit: %v", err)
		return
	}
	filename := mark.path()
	mapper := mark.mapper()
	patched, _, err := protocol.ApplyEdits(mapper, append([]protocol.TextEdit{edit}, selected.AdditionalTextEdits...))
	if err != nil {
		mark.errorf("ApplyProtocolEdits failed: %v", err)
		return
	}
	changes := map[string][]byte{filename: patched}
	// Check the file state.
	checkChangedFiles(mark, changes, golden)
}

// defMarker implements the @def marker, running textDocument/definition at
// the given location and asserting that there the results match want.
func defMarker(mark marker, loc protocol.Location, want ...protocol.Location) {
	env := mark.run.env
	got, err := env.Editor.Definitions(env.Ctx, loc)
	if err != nil {
		mark.errorf("definition request failed: %v", err)
		return
	}

	if err := compareLocations(mark, got, want); err != nil {
		mark.errorf("def failed: %v", err)
	}
}

// typedefMarker implements the @typedef marker.
func typedefMarker(mark marker, loc protocol.Location, want ...protocol.Location) {
	wantErr := namedArgFunc(mark, "err", convertStringMatcher, stringMatcher{})

	env := mark.run.env
	got, err := env.Editor.TypeDefinitions(env.Ctx, loc)
	if err != nil && wantErr.empty() {
		mark.errorf("typeDefinition at %s failed: %v", loc, err)
		return
	}
	if !wantErr.empty() {
		wantErr.checkErr(mark, err)
		return
	}

	if err := compareLocations(mark, got, want); err != nil {
		mark.errorf("typedef failed: %v", err)
	}
}

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 {
		// We assume the server populates these optional fields.
		insert(*rng.StartLine, *rng.StartCharacter, fmt.Sprintf("<%d kind=%q>", i, rng.Kind))
		insert(*rng.EndLine, *rng.EndCharacter, fmt.Sprintf("</%d>", i))
	}
	filename := mark.path()
	mapper, err := env.Editor.Mapper(filename)
	if err != nil {
		mark.errorf("Editor.Mapper(%s) failed: %v", filename, err)
		return
	}
	got, _, err := protocol.ApplyEdits(mapper, edits)
	if err != nil {
		mark.errorf("ApplyProtocolEdits failed: %v", err)
		return
	}
	want, _ := g.Get(mark.T(), "", got)
	if diff := compare.Bytes(want, got); diff != "" {
		mark.errorf("foldingRange mismatch:\n%s", diff)
	}
}

// formatMarker implements the @format marker.
func formatMarker(mark marker, golden *Golden) {
	edits, err := mark.server().Formatting(mark.ctx(), &protocol.DocumentFormattingParams{
		TextDocument: mark.document(),
	})
	var got []byte
	if err != nil {
		got = []byte(err.Error() + "\n") // all golden content is newline terminated
	} else {
		env := mark.run.env
		filename := mark.path()
		mapper, err := env.Editor.Mapper(filename)
		if err != nil {
			mark.errorf("Editor.Mapper(%s) failed: %v", filename, err)
		}

		got, _, err = protocol.ApplyEdits(mapper, edits)
		if err != nil {
			mark.errorf("ApplyProtocolEdits failed: %v", err)
			return
		}
	}

	compareGolden(mark, got, golden)
}

func highlightLocationMarker(mark marker, loc protocol.Location, kindName expect.Identifier) protocol.DocumentHighlight {
	var kind protocol.DocumentHighlightKind
	switch kindName {
	case "read":
		kind = protocol.Read
	case "write":
		kind = protocol.Write
	case "text":
		kind = protocol.Text
	default:
		mark.errorf("invalid highlight kind: %q", kindName)
	}

	return protocol.DocumentHighlight{
		Range: loc.Range,
		Kind:  kind,
	}
}

func sortDocumentHighlights(s []protocol.DocumentHighlight) {
	sort.Slice(s, func(i, j int) bool {
		return protocol.CompareRange(s[i].Range, s[j].Range) < 0
	})
}

// highlightAllMarker makes textDocument/highlight
// requests at locations of equivalence classes. Given input
// highlightall(X1, X2, ..., Xn), the marker checks
// highlight(X1) = highlight(X2) = ... = highlight(Xn) = {X1, X2, ..., Xn}.
// It is not the general rule for all highlighting, and use @highlight
// for asymmetric cases.
//
// TODO(b/288111111): this is a bit of a hack. We should probably
// have a more general way of testing that a function is idempotent.
func highlightAllMarker(mark marker, all ...protocol.DocumentHighlight) {
	sortDocumentHighlights(all)
	for _, src := range all {
		loc := mark.uri().Location(src.Range)
		got := mark.run.env.DocumentHighlight(loc)
		sortDocumentHighlights(got)

		if d := cmp.Diff(all, got); d != "" {
			mark.errorf("DocumentHighlight(%v) mismatch (-want +got):\n%s", loc, d)
		}
	}
}

func highlightMarker(mark marker, src protocol.DocumentHighlight, dsts ...protocol.DocumentHighlight) {
	loc := mark.uri().Location(src.Range)
	got := mark.run.env.DocumentHighlight(loc)

	sortDocumentHighlights(got)
	sortDocumentHighlights(dsts)

	if diff := cmp.Diff(dsts, got, cmpopts.EquateEmpty()); diff != "" {
		mark.errorf("DocumentHighlight(%v) mismatch (-want +got):\n%s", src, diff)
	}
}

func hoverMarker(mark marker, src, dst protocol.Location, sc stringMatcher) {
	content, gotDst := mark.run.env.Hover(src)
	if gotDst != dst {
		mark.errorf("hover location does not match:\n\tgot: %s\n\twant %s)", mark.run.fmtLoc(gotDst), mark.run.fmtLoc(dst))
	}
	gotMD := ""
	if content != nil {
		gotMD = content.Value

		// Normalize the absolute path for cross-platform consistency before
		// replacement. This ensures Windows paths (e.g., "C:/...") become
		// "/C:/..." to match Unix-like paths.
		abs := mark.run.env.Sandbox.Workdir.RootURI().Path()
		if protocol.IsWindowsDrivePath(abs) {
			abs = "/" + abs
		}
		gotMD = strings.ReplaceAll(gotMD, filepath.ToSlash(abs), "$WORKDIR")
	}
	sc.check(mark, gotMD)
}

func hoverErrMarker(mark marker, src protocol.Location, em stringMatcher) {
	_, _, err := mark.editor().Hover(mark.ctx(), src)
	em.checkErr(mark, err)
}

// locMarker implements the @loc marker.
func locMarker(mark marker, loc protocol.Location) protocol.Location { return loc }

// defLocMarker implements the @defloc marker, which binds a location to the
// (first) result of a jump-to-definition request.
func defLocMarker(mark marker, loc protocol.Location) protocol.Location {
	return mark.run.env.FirstDefinition(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) {
	exact := namedArg(mark, "exact", false)
	if _, ok := removeDiagnostic(mark, loc, exact, 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, matchEnd bool, re *regexp.Regexp) (protocol.Diagnostic, bool) {
	key := loc
	key.Range.End = key.Range.Start // diagnostics ignore end position.
	diags := mark.run.diags[key]
	for i, diag := range diags {
		if re.MatchString(diag.Message) && (!matchEnd || diag.Range.End == loc.Range.End) {
			mark.run.diags[key] = slices.Delete(diags, i, i+1)
			return diag, true
		}
	}
	return protocol.Diagnostic{}, false
}

// renameMarker implements the @rename(location, new, golden) marker.
func renameMarker(mark marker, loc protocol.Location, newName string, golden *Golden) {
	changed, err := rename(mark.run.env, loc, newName)
	if err != nil {
		mark.errorf("rename failed: %v. (Use @renameerr for expected errors.)", err)
		return
	}
	checkDiffs(mark, changed, golden)
}

// renameErrMarker implements the @renamererr(location, new, error) marker.
func renameErrMarker(mark marker, loc protocol.Location, newName string, wantErr stringMatcher) {
	_, err := rename(mark.run.env, loc, newName)
	wantErr.checkErr(mark, err)
}

func selectionRangeMarker(mark marker, loc protocol.Location, g *Golden) {
	ranges, err := mark.server().SelectionRange(mark.ctx(), &protocol.SelectionRangeParams{
		TextDocument: mark.document(),
		Positions:    []protocol.Position{loc.Range.Start},
	})
	if err != nil {
		mark.errorf("SelectionRange failed: %v", err)
		return
	}
	var buf bytes.Buffer
	m := mark.mapper()
	for i, path := range ranges {
		fmt.Fprintf(&buf, "Ranges %d:", i)
		rng := path
		for {
			s, e, err := m.RangeOffsets(rng.Range)
			if err != nil {
				mark.errorf("RangeOffsets failed: %v", err)
				return
			}

			var snippet string
			if e-s < 30 {
				snippet = string(m.Content[s:e])
			} else {
				snippet = string(m.Content[s:s+15]) + "..." + string(m.Content[e-15:e])
			}

			fmt.Fprintf(&buf, "\n\t%v %q", rng.Range, strings.ReplaceAll(snippet, "\n", "\\n"))

			if rng.Parent == nil {
				break
			}
			rng = *rng.Parent
		}
		buf.WriteRune('\n')
	}
	compareGolden(mark, buf.Bytes(), g)
}

func tokenMarker(mark marker, loc protocol.Location, tokenType, mod string) {
	tokens := mark.run.env.SemanticTokensRange(loc)
	if len(tokens) != 1 {
		mark.errorf("got %d tokens, want 1", len(tokens))
		return
	}
	tok := tokens[0]
	if tok.TokenType != tokenType {
		mark.errorf("token type = %q, want %q", tok.TokenType, tokenType)
	}
	if tok.Mod != mod {
		mark.errorf("token mod = %q, want %q", tok.Mod, mod)
	}
}

func signatureMarker(mark marker, src protocol.Location, label string, active int64) {
	got := mark.run.env.SignatureHelp(src)
	var gotLabels []string // for better error messages
	if got != nil {
		for _, s := range got.Signatures {
			gotLabels = append(gotLabels, s.Label)
		}
	}
	if label == "" {
		// A null result is expected.
		// (There's no point having a @signatureerr marker
		// because the server handler suppresses all errors.)
		if got != nil && len(gotLabels) > 0 {
			mark.errorf("signatureHelp = %v, want 0 signatures", gotLabels)
		}
		return
	}
	if got == nil || len(got.Signatures) != 1 {
		mark.errorf("signatureHelp = %v, want exactly 1 signature", gotLabels)
		return
	}
	if got := gotLabels[0]; got != label {
		mark.errorf("signatureHelp: got label %q, want %q", got, label)
	}
	gotActiveParameter := int64(-1) // => missing
	if got.ActiveParameter != nil {
		gotActiveParameter = int64(*got.ActiveParameter)
	}
	if gotActiveParameter != active {
		mark.errorf("signatureHelp: got active parameter %d, want %d", gotActiveParameter, active)
	}
}

// rename returns the new contents of the files that would be modified
// by renaming the identifier at loc to newName.
func rename(env *integration.Env, loc protocol.Location, newName string) (map[string][]byte, error) {
	// We call Server.Rename directly, instead of
	//   env.Editor.Rename(env.Ctx, loc, newName)
	// to isolate Rename from PrepareRename, and because we don't
	// want to modify the file system in a scenario with multiple
	// @rename markers.

	wsedit, err := env.Editor.Server.Rename(env.Ctx, &protocol.RenameParams{
		TextDocumentPositionParams: protocol.LocationTextDocumentPositionParams(loc),
		NewName:                    newName,
	})
	if err != nil {
		return nil, err
	}
	return changedFiles(env, wsedit.DocumentChanges)
}

// changedFiles applies the given sequence of document changes to the
// editor buffer content, recording the final contents in the returned map.
// The actual editor state is not changed.
// Deleted files are indicated by a content of []byte(nil).
//
// See also:
//   - Editor.applyWorkspaceEdit ../integration/fake/editor.go for the
//     implementation of this operation used in normal testing.
//   - client.applyWorkspaceEdit in ../../../cmd/cmd.go for the
//     CLI variant.
func changedFiles(env *integration.Env, changes []protocol.DocumentChange) (map[string][]byte, error) {
	uriToPath := env.Sandbox.Workdir.URIToPath

	// latest maps each updated file name to a mapper holding its
	// current contents, or nil if the file has been deleted.
	latest := make(map[protocol.DocumentURI]*protocol.Mapper)

	// read reads a file. It returns an error if the file never
	// existed or was deleted.
	read := func(uri protocol.DocumentURI) (*protocol.Mapper, error) {
		if m, ok := latest[uri]; ok {
			if m == nil {
				return nil, fmt.Errorf("read: file %s was deleted", uri)
			}
			return m, nil
		}
		return env.Editor.Mapper(uriToPath(uri))
	}

	// write (over)writes a file. A nil content indicates a deletion.
	write := func(uri protocol.DocumentURI, content []byte) {
		var m *protocol.Mapper
		if content != nil {
			m = protocol.NewMapper(uri, content)
		}
		latest[uri] = m
	}

	// Process the sequence of changes.
	for _, change := range changes {
		switch {
		case change.TextDocumentEdit != nil:
			uri := change.TextDocumentEdit.TextDocument.URI
			m, err := read(uri)
			if err != nil {
				return nil, err // missing
			}
			patched, _, err := protocol.ApplyEdits(m, protocol.AsTextEdits(change.TextDocumentEdit.Edits))
			if err != nil {
				return nil, err // bad edit
			}
			write(uri, patched)

		case change.RenameFile != nil:
			old := change.RenameFile.OldURI
			new := change.RenameFile.NewURI
			info, err := os.Stat(old.Path())
			if err != nil {
				return nil, err
			}
			if info.IsDir() {
				// Walk through all the files in the old directory and copy
				// their content to the new directory.
				// TODO(mkalil): This currently only handles renaming the file's
				// innermost directory. Need to handle renames of outer directories
				// directories when implementing package move refactoring.
				for _, file := range env.ListFiles(old.Path()) {
					oldFile := protocol.URIFromPath(path.Join(old.Path(), path.Base(file)))
					m, err := read(oldFile)
					if err != nil {
						return nil, err // missing
					}
					write(oldFile, nil)

					newFile := protocol.URIFromPath(path.Join(new.Path(), path.Base(file)))
					if _, err := read(oldFile); err == nil {
						return nil, fmt.Errorf("RenameFile: destination %s exists", new)
					}
					write(newFile, m.Content)
				}
			} else {
				m, err := read(old)
				if err != nil {
					return nil, err // missing
				}
				write(old, nil)

				if _, err := read(old); err == nil {
					return nil, fmt.Errorf("RenameFile: destination %s exists", new)
				}
				write(new, m.Content)
			}

		case change.CreateFile != nil:
			uri := change.CreateFile.URI
			if _, err := read(uri); err == nil {
				return nil, fmt.Errorf("CreateFile %s: file exists", uri)
			}
			write(uri, []byte("")) // initially empty

		case change.DeleteFile != nil:
			uri := change.DeleteFile.URI
			if _, err := read(uri); err != nil {
				// The specified URI could be a file or a directory.
				files := env.ListFiles(uriToPath(uri))
				if files == nil {
					return nil, fmt.Errorf("DeleteFile %s: file does not exist", uri)
				}
				if len(files) > 0 {
					// Directory is not empty. If "Recursive" is true, we delete
					// every file in the folder. Otherwise, we continue without
					// deleting the directory.
					if change.DeleteFile.Options != nil && change.DeleteFile.Options.Recursive {
						for _, f := range files {
							write(protocol.URIFromPath(f), nil)
						}
					}
					continue
				}
			}
			write(uri, nil)

		default:
			return nil, fmt.Errorf("invalid DocumentChange")
		}
	}

	// Convert into result form.
	result := make(map[string][]byte)
	for uri, mapper := range latest {
		var content []byte
		if mapper != nil {
			content = mapper.Content
		}
		result[uriToPath(uri)] = content
	}

	return result, nil
}

// 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 := mark.uri().Location(l.Range)
		fmt.Fprintln(&b, mark.run.fmtLocForGolden(loc), *l.Target)
	}

	compareGolden(mark, b.Bytes(), g)
}

// consumeExtraNotes runs the provided func for each extra note with the given
// name, and deletes all matching notes.
func (mark marker) consumeExtraNotes(name string, f func(marker)) {
	uri := mark.uri()
	notes := mark.run.extraNotes[uri][name]
	delete(mark.run.extraNotes[uri], name)

	for _, note := range notes {
		f(marker{run: mark.run, note: note})
	}
}

// quickfixMarker implements the @quickfix(location, regexp,
// kind, golden) marker. It acts like @diag(location, regexp), to set
// the expectation of a diagnostic, but then it applies the "quickfix"
// code action (which must be unique) suggested by the matched diagnostic.
func quickfixMarker(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, false, re)
	if !ok {
		mark.errorf("no diagnostic at %v matches %q", loc, re)
		return
	}

	// Apply the fix it suggests.
	changed, err := codeAction(mark, loc.URI, diag.Range, "quickfix", &diag)
	if err != nil {
		mark.errorf("quickfix failed: %v. (Use @quickfixerr for expected errors.)", err)
		return
	}

	// Check the file state.
	checkDiffs(mark, changed, golden)
}

func quickfixErrMarker(mark marker, loc protocol.Location, re *regexp.Regexp, wantErr stringMatcher) {
	loc.Range.End = loc.Range.Start // diagnostics ignore end position.
	// Find and remove the matching diagnostic.
	diag, ok := removeDiagnostic(mark, loc, false, re)
	if !ok {
		mark.errorf("no diagnostic at %v matches %q", loc, re)
		return
	}

	// Apply the fix it suggests.
	_, err := codeAction(mark, loc.URI, diag.Range, "quickfix", &diag)
	wantErr.checkErr(mark, err)
}

// 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) {
	wantErr := namedArgFunc(mark, "err", convertStringMatcher, stringMatcher{})

	got, err := mark.server().Implementation(mark.ctx(), &protocol.ImplementationParams{
		TextDocumentPositionParams: protocol.LocationTextDocumentPositionParams(src),
	})
	if err != nil && wantErr.empty() {
		mark.errorf("implementation at %s failed: %v", src, err)
		return
	}
	if !wantErr.empty() {
		wantErr.checkErr(mark, err)
		return
	}
	if err := compareLocations(mark, got, want); err != nil {
		mark.errorf("implementation: %v", err)
	}
}

func mcpToolMarker(mark marker, tool string, rawArgs string) {
	if !mark.run.test.mcp {
		mark.errorf("mcp not enabled: add -mcp")
		return
	}
	args := make(map[string]any)
	if err := json.Unmarshal([]byte(rawArgs), &args); err != nil {
		mark.errorf("fail to unmarshal arguments to map[string]any: %v", err)
		return
	}

	// substitutePaths replaces instances of $WORKDIR in string or []string values with
	// the actual working directory.
	var substitutePaths func(any) any
	substitutePaths = func(v any) any {
		switch v := v.(type) {
		case string:
			return strings.ReplaceAll(v, "$WORKDIR", mark.run.env.Sandbox.Workdir.RootURI().Path())

		case []any:
			for i, e := range v {
				if _, ok := e.(string); ok {
					v[i] = substitutePaths(e).(string)
				}
			}
		}
		return v
	}
	// Hack: replace '$WORKDIR' in string arguments with the actual sandbox
	// working directory.
	for k, v := range args {
		args[k] = substitutePaths(v)
	}

	if loc := namedArg(mark, "location", protocol.Location{}); loc != (protocol.Location{}) {
		args["location"] = loc
	}

	res, err := mark.run.env.MCPSession.CallTool(mark.ctx(), &mcp.CallToolParams{
		Name:      tool,
		Arguments: args,
	})
	if err != nil {
		mark.errorf("failed to call mcp tool: %v", err)
		return
	}

	var buf bytes.Buffer
	for i, c := range res.Content {
		text, ok := c.(*mcp.TextContent)
		if !ok {
			mark.errorf("unsupported return content[%v] type: %T", i, c)
			continue
		}
		buf.WriteString(text.Text)
	}
	if !bytes.HasSuffix(buf.Bytes(), []byte{'\n'}) {
		buf.WriteString("\n") // all golden content is newline terminated
	}

	got := buf.String()
	// For portability, replace all (potential) filepath separators with "/".
	got = strings.ReplaceAll(got, string(filepath.Separator), "/")
	// To ensure consistent unified diff output, the working directory path
	// is replaced with "$WORKDIR". This addresses cases where MCP tools
	// include absolute file paths in generated diffs.
	got = strings.ReplaceAll(got, filepath.ToSlash(mark.run.env.Sandbox.Workdir.RootURI().Path()), "$WORKDIR")

	output := namedArg(mark, "output", expect.Identifier(""))
	golden := mark.getGolden(output)
	want, _ := golden.Get(mark.T(), "", []byte(got))
	if diff := compare.Text(string(want), got); diff != "" {
		mark.errorf("unexpected mcp tools call %s return: diff:\n%s", tool, diff)
	}
}

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, call.From.URI.Location(call.From.Range))
		}
		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, call.To.URI.Location(call.To.Range))
		}
		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
	}
	item := items[0]
	if loc := item.URI.Location(item.Range); 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 err := compareLocations(mark, calls, want); err != nil {
		mark.errorf("%s failed: %v", mark.note.Name, err)
	}
}

func inlayhintsMarker(mark marker, g *Golden) {
	hints := mark.run.env.InlayHints(mark.path())

	// Map inlay hints to text edits.
	edits := make([]protocol.TextEdit, len(hints))
	for i, hint := range hints {
		var paddingLeft, paddingRight string
		if hint.PaddingLeft {
			paddingLeft = " "
		}
		if hint.PaddingRight {
			paddingRight = " "
		}
		edits[i] = protocol.TextEdit{
			Range:   protocol.Range{Start: hint.Position, End: hint.Position},
			NewText: fmt.Sprintf("<%s%s%s>", paddingLeft, hint.Label[0].Value, paddingRight),
		}
	}

	m := mark.mapper()
	got, _, err := protocol.ApplyEdits(m, edits)
	if err != nil {
		mark.errorf("ApplyProtocolEdits: %v", err)
		return
	}

	compareGolden(mark, got, g)
}

func prepareRenameMarker(mark marker, src protocol.Location, placeholder string) {
	params := &protocol.PrepareRenameParams{
		TextDocumentPositionParams: protocol.LocationTextDocumentPositionParams(src),
	}
	got, err := mark.server().PrepareRename(mark.ctx(), params)
	if err != nil {
		mark.T().Fatal(err)
	}
	if placeholder == "" {
		if got != nil {
			mark.errorf("PrepareRename(...) = %v, want nil", got)
		}
		return
	}

	want := &protocol.PrepareRenameResult{
		Placeholder: placeholder,
	}
	if span := namedArg(mark, "span", protocol.Location{}); span != (protocol.Location{}) {
		want.Range = span.Range
	} else {
		got.Range = protocol.Range{} // ignore Range
	}
	if diff := cmp.Diff(want, got); diff != "" {
		mark.errorf("mismatching PrepareRename result:\n%s", diff)
	}
}

func subtypesMarker(mark marker, src protocol.Location, want ...protocol.Location) {
	typeHierarchy(mark, src, want, func(item protocol.TypeHierarchyItem) ([]protocol.TypeHierarchyItem, error) {
		return mark.server().Subtypes(mark.ctx(), &protocol.TypeHierarchySubtypesParams{Item: item})
	})
}

func supertypesMarker(mark marker, src protocol.Location, want ...protocol.Location) {
	typeHierarchy(mark, src, want, func(item protocol.TypeHierarchyItem) ([]protocol.TypeHierarchyItem, error) {
		return mark.server().Supertypes(mark.ctx(), &protocol.TypeHierarchySupertypesParams{Item: item})
	})
}

type typeHierarchyFunc = func(item protocol.TypeHierarchyItem) ([]protocol.TypeHierarchyItem, error)

func typeHierarchy(mark marker, src protocol.Location, want []protocol.Location, get typeHierarchyFunc) {
	items, err := mark.server().PrepareTypeHierarchy(mark.ctx(), &protocol.TypeHierarchyPrepareParams{
		TextDocumentPositionParams: protocol.LocationTextDocumentPositionParams(src),
	})
	if err != nil {
		mark.errorf("PrepareTypeHierarchy failed: %v", err)
		return
	}
	if nitems := len(items); nitems != 1 {
		mark.errorf("PrepareTypeHierarchy returned %d items, want exactly 1", nitems)
		return
	}
	if loc := (protocol.Location{URI: items[0].URI, Range: items[0].Range}); loc != src {
		mark.errorf("PrepareTypeHierarchy found type %v, want %v", loc, src)
		return
	}
	items, err = get(items[0])
	if err != nil {
		mark.errorf("type hierarchy failed: %v", err)
		return
	}
	got := []protocol.Location{} // non-nil; cmp.Diff cares
	for _, item := range items {
		got = append(got, item.URI.Location(item.Range))
	}
	if d := cmp.Diff(want, got); d != "" {
		mark.errorf("type hierarchy: unexpected results (-want +got):\n%s", d)
	}
}

// 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.fmtLocForGolden(s.Location)
		if loc == "" {
			loc = "<unknown>"
		}
		fmt.Fprintf(&got, "%s %s %s\n", loc, s.Name, s.Kind)
	}

	compareGolden(mark, got.Bytes(), golden)
}

// compareGolden compares the content of got with that of g.Get(""), reporting
// errors on any mismatch.
//
// TODO(rfindley): use this helper in more places.
func compareGolden(mark marker, got []byte, g *Golden) {
	want, ok := g.Get(mark.T(), "", got)
	if !ok {
		mark.errorf("missing golden file @%s", g.id)
		return
	}
	// Normalize newline termination: archive files (i.e. Golden content) can't
	// contain non-newline terminated files, except in the special case where the
	// file is completely empty.
	//
	// Note that txtar partitions a contiguous byte slice, so we must copy before
	// appending.
	normalize := func(s []byte) []byte {
		if n := len(s); n > 0 && s[n-1] != '\n' {
			s = append(s[:n:n], '\n') // don't mutate array
		}
		return s
	}
	got = normalize(got)
	want = normalize(want)
	if diff := compare.Bytes(want, got); diff != "" {
		mark.errorf("%s does not match @%s:\n%s", mark.note.Name, g.id, diff)
	}
}
