// Copyright 2015 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.

// Toolstash provides a way to save, run, and restore a known good copy of the Go toolchain
// and to compare the object files generated by two toolchains.
//
// Usage:
//
//	toolstash [-n] [-v] save [tool...]
//	toolstash [-n] [-v] restore [tool...]
//	toolstash [-n] [-v] [-t] go run x.go
//	toolstash [-n] [-v] [-t] [-cmp] compile x.go
//
// The toolstash command manages a “stashed” copy of the Go toolchain
// kept in $GOROOT/pkg/toolstash. In this case, the toolchain means the
// tools available with the 'go tool' command as well as the go, godoc, and gofmt
// binaries.
//
// The command “toolstash save”, typically run when the toolchain is known to be working,
// copies the toolchain from its installed location to the toolstash directory.
// Its inverse, “toolchain restore”, typically run when the toolchain is known to be broken,
// copies the toolchain from the toolstash directory back to the installed locations.
// If additional arguments are given, the save or restore applies only to the named tools.
// Otherwise, it applies to all tools.
//
// Otherwise, toolstash's arguments should be a command line beginning with the
// name of a toolchain binary, which may be a short name like compile or a complete path
// to an installed binary. Toolstash runs the command line using the stashed
// copy of the binary instead of the installed one.
//
// The -n flag causes toolstash to print the commands that would be executed
// but not execute them. The combination -n -cmp shows the two commands
// that would be compared and then exits successfully. A real -cmp run might
// run additional commands for diagnosis of an output mismatch.
//
// The -v flag causes toolstash to print the commands being executed.
//
// The -t flag causes toolstash to print the time elapsed during while the
// command ran.
//
// # Comparing
//
// The -cmp flag causes toolstash to run both the installed and the stashed
// copy of an assembler or compiler and check that they produce identical
// object files. If not, toolstash reports the mismatch and exits with a failure status.
// As part of reporting the mismatch, toolstash reinvokes the command with
// the -S=2 flag and identifies the first divergence in the assembly output.
// If the command is a Go compiler, toolstash also determines whether the
// difference is triggered by optimization passes.
// On failure, toolstash leaves additional information in files named
// similarly to the default output file. If the compilation would normally
// produce a file x.6, the output from the stashed tool is left in x.6.stash
// and the debugging traces are left in x.6.log and x.6.stash.log.
//
// The -cmp flag is a no-op when the command line is not invoking an
// assembler or compiler.
//
// For example, when working on code cleanup that should not affect
// compiler output, toolstash can be used to compare the old and new
// compiler output:
//
//	toolstash save
//	<edit compiler sources>
//	go tool dist install cmd/compile # install compiler only
//	toolstash -cmp compile x.go
//
// # Go Command Integration
//
// The go command accepts a -toolexec flag that specifies a program
// to use to run the build tools.
//
// To build with the stashed tools:
//
//	go build -toolexec toolstash x.go
//
// To build with the stashed go command and the stashed tools:
//
//	toolstash go build -toolexec toolstash x.go
//
// To verify that code cleanup in the compilers does not make any
// changes to the objects being generated for the entire tree:
//
//	# Build working tree and save tools.
//	./make.bash
//	toolstash save
//
//	<edit compiler sources>
//
//	# Install new tools, but do not rebuild the rest of tree,
//	# since the compilers might generate buggy code.
//	go tool dist install cmd/compile
//
//	# Check that new tools behave identically to saved tools.
//	go build -toolexec 'toolstash -cmp' -a std
//
//	# If not, restore, in order to keep working on Go code.
//	toolstash restore
//
// # Version Skew
//
// The Go tools write the current Go version to object files, and (outside
// release branches) that version includes the hash and time stamp
// of the most recent Git commit. Functionally equivalent
// compilers built at different Git versions may produce object files that
// differ only in the recorded version. Toolstash ignores version mismatches
// when comparing object files, but the standard tools will refuse to compile
// or link together packages with different object versions.
//
// For the full build in the final example above to work, both the stashed
// and the installed tools must use the same version string.
// One way to ensure this is not to commit any of the changes being
// tested, so that the Git HEAD hash is the same for both builds.
// A more robust way to force the tools to have the same version string
// is to write a $GOROOT/VERSION file, which overrides the Git-based version
// computation:
//
//	echo devel >$GOROOT/VERSION
//
// The version can be arbitrary text, but to pass all.bash's API check, it must
// contain the substring “devel”. The VERSION file must be created before
// building either version of the toolchain.
package main // import "golang.org/x/tools/cmd/toolstash"

import (
	"bufio"
	"flag"
	"fmt"
	"io"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"
)

var usageMessage = `usage: toolstash [-n] [-v] [-cmp] command line

Examples:
	toolstash save
	toolstash restore
	toolstash go run x.go
	toolstash compile x.go
	toolstash -cmp compile x.go

For details, godoc golang.org/x/tools/cmd/toolstash
`

func usage() {
	fmt.Fprint(os.Stderr, usageMessage)
	os.Exit(2)
}

var (
	goCmd   = flag.String("go", "go", "path to \"go\" command")
	norun   = flag.Bool("n", false, "print but do not run commands")
	verbose = flag.Bool("v", false, "print commands being run")
	cmp     = flag.Bool("cmp", false, "compare tool object files")
	timing  = flag.Bool("t", false, "print time commands take")
)

var (
	cmd       []string
	tool      string // name of tool: "go", "compile", etc
	toolStash string // path to stashed tool

	goroot   string
	toolDir  string
	stashDir string
	binDir   string
)

func canCmp(name string, args []string) bool {
	switch name {
	case "asm", "compile", "link":
		if len(args) == 1 && (args[0] == "-V" || strings.HasPrefix(args[0], "-V=")) {
			// cmd/go uses "compile -V=full" to query the tool's build ID.
			return false
		}
		return true
	}
	return len(name) == 2 && '0' <= name[0] && name[0] <= '9' && (name[1] == 'a' || name[1] == 'g' || name[1] == 'l')
}

var binTools = []string{"go", "godoc", "gofmt"}

func isBinTool(name string) bool {
	return strings.HasPrefix(name, "go")
}

func main() {
	log.SetFlags(0)
	log.SetPrefix("toolstash: ")

	flag.Usage = usage
	flag.Parse()
	cmd = flag.Args()

	if len(cmd) < 1 {
		usage()
	}

	s, err := exec.Command(*goCmd, "env", "GOROOT").CombinedOutput()
	if err != nil {
		log.Fatalf("%s env GOROOT: %v", *goCmd, err)
	}
	goroot = strings.TrimSpace(string(s))
	toolDir = filepath.Join(goroot, fmt.Sprintf("pkg/tool/%s_%s", runtime.GOOS, runtime.GOARCH))
	stashDir = filepath.Join(goroot, "pkg/toolstash")

	binDir = os.Getenv("GOBIN")
	if binDir == "" {
		binDir = filepath.Join(goroot, "bin")
	}

	switch cmd[0] {
	case "save":
		save()
		return

	case "restore":
		restore()
		return
	}

	tool = exeName(cmd[0])
	if i := strings.LastIndexAny(tool, `/\`); i >= 0 {
		tool = tool[i+1:]
	}

	if !strings.HasPrefix(tool, "a.out") {
		toolStash = filepath.Join(stashDir, tool)
		if _, err := os.Stat(toolStash); err != nil {
			log.Print(err)
			os.Exit(2)
		}

		if *cmp && canCmp(tool, cmd[1:]) {
			compareTool()
			return
		}
		cmd[0] = toolStash
	}

	if *norun {
		fmt.Printf("%s\n", strings.Join(cmd, " "))
		return
	}
	if *verbose {
		log.Print(strings.Join(cmd, " "))
	}
	xcmd := exec.Command(cmd[0], cmd[1:]...)
	xcmd.Stdin = os.Stdin
	xcmd.Stdout = os.Stdout
	xcmd.Stderr = os.Stderr
	err = xcmd.Run()
	if err != nil {
		log.Fatal(err)
	}
	os.Exit(0)
}

func compareTool() {
	if !strings.Contains(cmd[0], "/") && !strings.Contains(cmd[0], `\`) {
		cmd[0] = filepath.Join(toolDir, tool)
	}

	outfile, ok := cmpRun(false, cmd)
	if ok {
		os.Remove(outfile + ".stash")
		return
	}

	extra := "-S=2"
	switch {
	default:
		log.Fatalf("unknown tool %s", tool)

	case tool == "compile" || strings.HasSuffix(tool, "g"): // compiler
		useDashN := true
		dashcIndex := -1
		for i, s := range cmd {
			if s == "-+" {
				// Compiling runtime. Don't use -N.
				useDashN = false
			}
			if strings.HasPrefix(s, "-c=") {
				dashcIndex = i
			}
		}
		cmdN := injectflags(cmd, nil, useDashN)
		_, ok := cmpRun(false, cmdN)
		if !ok {
			if useDashN {
				log.Printf("compiler output differs, with optimizers disabled (-N)")
			} else {
				log.Printf("compiler output differs")
			}
			if dashcIndex >= 0 {
				cmd[dashcIndex] = "-c=1"
			}
			cmd = injectflags(cmd, []string{"-v", "-m=2"}, useDashN)
			break
		}
		if dashcIndex >= 0 {
			cmd[dashcIndex] = "-c=1"
		}
		cmd = injectflags(cmd, []string{"-v", "-m=2"}, false)
		log.Printf("compiler output differs, only with optimizers enabled")

	case tool == "asm" || strings.HasSuffix(tool, "a"): // assembler
		log.Printf("assembler output differs")

	case tool == "link" || strings.HasSuffix(tool, "l"): // linker
		log.Printf("linker output differs")
		extra = "-v=2"
	}

	cmdS := injectflags(cmd, []string{extra}, false)
	outfile, _ = cmpRun(true, cmdS)

	fmt.Fprintf(os.Stderr, "\n%s\n", compareLogs(outfile))
	os.Exit(2)
}

func injectflags(cmd []string, extra []string, addDashN bool) []string {
	x := []string{cmd[0]}
	if addDashN {
		x = append(x, "-N")
	}
	x = append(x, extra...)
	x = append(x, cmd[1:]...)
	return x
}

func cmpRun(keepLog bool, cmd []string) (outfile string, match bool) {
	cmdStash := make([]string, len(cmd))
	copy(cmdStash, cmd)
	cmdStash[0] = toolStash
	for i, arg := range cmdStash {
		if arg == "-o" {
			outfile = cmdStash[i+1]
			cmdStash[i+1] += ".stash"
			break
		}
		if strings.HasSuffix(arg, ".s") || strings.HasSuffix(arg, ".go") && '0' <= tool[0] && tool[0] <= '9' {
			outfile = filepath.Base(arg[:strings.LastIndex(arg, ".")] + "." + tool[:1])
			cmdStash = append([]string{cmdStash[0], "-o", outfile + ".stash"}, cmdStash[1:]...)
			break
		}
	}

	if outfile == "" {
		log.Fatalf("cannot determine output file for command: %s", strings.Join(cmd, " "))
	}

	if *norun {
		fmt.Printf("%s\n", strings.Join(cmd, " "))
		fmt.Printf("%s\n", strings.Join(cmdStash, " "))
		os.Exit(0)
	}

	out, err := runCmd(cmd, keepLog, outfile+".log")
	if err != nil {
		log.Printf("running: %s", strings.Join(cmd, " "))
		os.Stderr.Write(out)
		log.Fatal(err)
	}

	outStash, err := runCmd(cmdStash, keepLog, outfile+".stash.log")
	if err != nil {
		log.Printf("running: %s", strings.Join(cmdStash, " "))
		log.Printf("installed tool succeeded but stashed tool failed.\n")
		if len(out) > 0 {
			log.Printf("installed tool output:")
			os.Stderr.Write(out)
		}
		if len(outStash) > 0 {
			log.Printf("stashed tool output:")
			os.Stderr.Write(outStash)
		}
		log.Fatal(err)
	}

	return outfile, sameObject(outfile, outfile+".stash")
}

func sameObject(file1, file2 string) bool {
	f1, err := os.Open(file1)
	if err != nil {
		log.Fatal(err)
	}
	defer f1.Close()

	f2, err := os.Open(file2)
	if err != nil {
		log.Fatal(err)
	}
	defer f2.Close()

	b1 := bufio.NewReader(f1)
	b2 := bufio.NewReader(f2)

	// Go object files and archives contain lines of the form
	//	go object <goos> <goarch> <version>
	// By default, the version on development branches includes
	// the Git hash and time stamp for the most recent commit.
	// We allow the versions to differ.
	if !skipVersion(b1, b2, file1, file2) {
		return false
	}

	lastByte := byte(0)
	for {
		c1, err1 := b1.ReadByte()
		c2, err2 := b2.ReadByte()
		if err1 == io.EOF && err2 == io.EOF {
			return true
		}
		if err1 != nil {
			log.Fatalf("reading %s: %v", file1, err1)
		}
		if err2 != nil {
			log.Fatalf("reading %s: %v", file2, err2)
		}
		if c1 != c2 {
			return false
		}
		if lastByte == '`' && c1 == '\n' {
			if !skipVersion(b1, b2, file1, file2) {
				return false
			}
		}
		lastByte = c1
	}
}

func skipVersion(b1, b2 *bufio.Reader, file1, file2 string) bool {
	// Consume "go object " prefix, if there.
	prefix := "go object "
	for i := 0; i < len(prefix); i++ {
		c1, err1 := b1.ReadByte()
		c2, err2 := b2.ReadByte()
		if err1 == io.EOF && err2 == io.EOF {
			return true
		}
		if err1 != nil {
			log.Fatalf("reading %s: %v", file1, err1)
		}
		if err2 != nil {
			log.Fatalf("reading %s: %v", file2, err2)
		}
		if c1 != c2 {
			return false
		}
		if c1 != prefix[i] {
			return true // matching bytes, just not a version
		}
	}

	// Keep comparing until second space.
	// Must continue to match.
	// If we see a \n, it's not a version string after all.
	for numSpace := 0; numSpace < 2; {
		c1, err1 := b1.ReadByte()
		c2, err2 := b2.ReadByte()
		if err1 == io.EOF && err2 == io.EOF {
			return true
		}
		if err1 != nil {
			log.Fatalf("reading %s: %v", file1, err1)
		}
		if err2 != nil {
			log.Fatalf("reading %s: %v", file2, err2)
		}
		if c1 != c2 {
			return false
		}
		if c1 == '\n' {
			return true
		}
		if c1 == ' ' {
			numSpace++
		}
	}

	// Have now seen 'go object goos goarch ' in both files.
	// Now they're allowed to diverge, until the \n, which
	// must be present.
	for {
		c1, err1 := b1.ReadByte()
		if err1 == io.EOF {
			log.Fatalf("reading %s: unexpected EOF", file1)
		}
		if err1 != nil {
			log.Fatalf("reading %s: %v", file1, err1)
		}
		if c1 == '\n' {
			break
		}
	}
	for {
		c2, err2 := b2.ReadByte()
		if err2 == io.EOF {
			log.Fatalf("reading %s: unexpected EOF", file2)
		}
		if err2 != nil {
			log.Fatalf("reading %s: %v", file2, err2)
		}
		if c2 == '\n' {
			break
		}
	}

	// Consumed "matching" versions from both.
	return true
}

func runCmd(cmd []string, keepLog bool, logName string) (output []byte, err error) {
	if *verbose {
		log.Print(strings.Join(cmd, " "))
	}

	if *timing {
		t0 := time.Now()
		defer func() {
			log.Printf("%.3fs elapsed # %s\n", time.Since(t0).Seconds(), strings.Join(cmd, " "))
		}()
	}

	xcmd := exec.Command(exeName(cmd[0]), cmd[1:]...)
	if !keepLog {
		return xcmd.CombinedOutput()
	}

	f, err := os.Create(logName)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Fprintf(f, "GOOS=%s GOARCH=%s %s\n", os.Getenv("GOOS"), os.Getenv("GOARCH"), strings.Join(cmd, " "))
	xcmd.Stdout = f
	xcmd.Stderr = f
	defer f.Close()
	return nil, xcmd.Run()
}

func save() {
	if err := os.MkdirAll(stashDir, 0777); err != nil {
		log.Fatal(err)
	}

	toolDir := filepath.Join(goroot, fmt.Sprintf("pkg/tool/%s_%s", runtime.GOOS, runtime.GOARCH))
	files, err := os.ReadDir(toolDir)
	if err != nil {
		log.Fatal(err)
	}

	for _, file := range files {
		info, err := file.Info()
		if err != nil {
			log.Fatal(err)
		}
		if shouldSave(file.Name()) && info.Mode().IsRegular() {
			cp(filepath.Join(toolDir, file.Name()), filepath.Join(stashDir, file.Name()))
		}
	}

	for _, name := range binTools {
		if !shouldSave(name) {
			continue
		}
		bin := exeName(name)
		src := filepath.Join(binDir, bin)
		if _, err := os.Stat(src); err == nil {
			cp(src, filepath.Join(stashDir, bin))
		}
	}

	checkShouldSave()
}

func restore() {
	files, err := os.ReadDir(stashDir)
	if err != nil {
		log.Fatal(err)
	}

	for _, file := range files {
		info, err := file.Info()
		if err != nil {
			log.Fatal(err)
		}
		if shouldSave(file.Name()) && info.Mode().IsRegular() {
			targ := toolDir
			if isBinTool(file.Name()) {
				targ = binDir
			}
			cp(filepath.Join(stashDir, file.Name()), filepath.Join(targ, file.Name()))
		}
	}

	checkShouldSave()
}

func shouldSave(name string) bool {
	if len(cmd) == 1 {
		return true
	}
	ok := false
	for i, arg := range cmd {
		if i > 0 && name == arg {
			ok = true
			cmd[i] = "DONE"
		}
	}
	return ok
}

func checkShouldSave() {
	var missing []string
	for _, arg := range cmd[1:] {
		if arg != "DONE" {
			missing = append(missing, arg)
		}
	}
	if len(missing) > 0 {
		log.Fatalf("%s did not find tools: %s", cmd[0], strings.Join(missing, " "))
	}
}

func cp(src, dst string) {
	if *verbose {
		fmt.Printf("cp %s %s\n", src, dst)
	}
	data, err := os.ReadFile(src)
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile(dst, data, 0777); err != nil {
		log.Fatal(err)
	}
}

func exeName(name string) string {
	if runtime.GOOS == "windows" {
		return name + ".exe"
	}
	return name
}
