From 614cb07b06c43babc5f6dd9210b92b013de8fbf5 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Mon, 30 Mar 2026 22:28:20 +0800 Subject: [PATCH 1/2] run: add command package runner support --- cmd/internal/run/run.go | 9 +- cmd/internal/run/runner.go | 40 +++ cmd/internal/run/runner_binary.go | 139 +++++++++ cmd/internal/run/runner_project.go | 178 ++++++++++++ cmd/internal/run/runner_test.go | 441 +++++++++++++++++++++++++++++ 5 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 cmd/internal/run/runner.go create mode 100644 cmd/internal/run/runner_binary.go create mode 100644 cmd/internal/run/runner_project.go create mode 100644 cmd/internal/run/runner_test.go diff --git a/cmd/internal/run/run.go b/cmd/internal/run/run.go index 2d7ba52d8..61464f952 100644 --- a/cmd/internal/run/run.go +++ b/cmd/internal/run/run.go @@ -14,7 +14,7 @@ * limitations under the License. */ -// Package run implements the “gop run” command. +// Package run implements the "gop run" command. package run import ( @@ -79,6 +79,13 @@ func runCmd(cmd *base.Command, args []string) { panic("TODO: profile not impl") } + if handled, err := tryRunWithCommandRunner(proj, args, "."); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } else if handled { + return + } + noChdir := *flagNoChdir conf, err := tool.NewDefaultConf(".", tool.ConfFlagNoTestFiles, pass.Tags()) if err != nil { diff --git a/cmd/internal/run/runner.go b/cmd/internal/run/runner.go new file mode 100644 index 000000000..4fe4c6ad9 --- /dev/null +++ b/cmd/internal/run/runner.go @@ -0,0 +1,40 @@ +package run + +import ( + "os" + "os/exec" + + "github.com/goplus/xgo/x/xgoprojs" +) + +func tryRunWithCommandRunner(proj xgoprojs.Proj, args []string, workDir string) (bool, error) { + projectDir, err := resolveProjectDir(proj, workDir) + if err != nil { + return false, err + } + + runner, err := readCommandRunner(projectDir) + if err != nil { + return false, err + } + if runner == nil { + return false, nil + } + + binaryPath, cleanup, err := prepareRunnerBinary(projectDir, runner) + if err != nil { + return true, err + } + defer cleanup() + + return true, runCommandRunner(binaryPath, projectDir, args) +} + +func runCommandRunner(binaryPath, projectDir string, args []string) error { + cmd := exec.Command(binaryPath, append([]string{projectDir}, args...)...) + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} diff --git a/cmd/internal/run/runner_binary.go b/cmd/internal/run/runner_binary.go new file mode 100644 index 000000000..273875378 --- /dev/null +++ b/cmd/internal/run/runner_binary.go @@ -0,0 +1,139 @@ +package run + +import ( + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" + + "github.com/goplus/mod/modcache" + "github.com/goplus/mod/modfile" +) + +func prepareRunnerBinary(projectDir string, runner *modfile.Runner) (string, func(), error) { + pkgPath := runner.Path + version := runner.Version + + pkg, err := lookupModulePackage(projectDir, pkgPath) + if err != nil { + return "", nil, err + } + if pkg != nil && pkg.ModDir != "" && !modcache.InPath(pkg.ModDir) { + return buildLocalRunnerBinary(pkg.Dir) + } + if version == "" { + version = "latest" + } + return installTempRunnerBinary(pkgPath, version) +} + +func buildLocalRunnerBinary(packageDir string) (string, func(), error) { + return withRunnerTempBinary("build", func(tempDir string) (string, error) { + binaryPath := filepath.Join(tempDir, "runner"+runnerBinaryExt()) + if err := buildRunnerExecutable(packageDir, binaryPath); err != nil { + return "", err + } + return binaryPath, nil + }) +} + +func installTempRunnerBinary(pkgPath, version string) (string, func(), error) { + return withRunnerTempBinary("install", func(tempDir string) (string, error) { + return installRunnerBinaryToDir(tempDir, pkgPath, version) + }) +} + +func withRunnerTempBinary(kind string, prepare func(tempDir string) (string, error)) (string, func(), error) { + tempDir, err := newRunnerTempDir(kind) + if err != nil { + return "", nil, err + } + cleanup := func() { _ = os.RemoveAll(tempDir) } + binaryPath, err := prepare(tempDir) + if err != nil { + cleanup() + return "", nil, err + } + return binaryPath, cleanup, nil +} + +func installRunnerExecutable(targetDir, pkgPath, version string) error { + output, err := goCommandOutputWithEnv("", []string{"GOBIN=" + targetDir}, "install", pkgPath+"@"+version) + if err != nil { + return formatGoCommandError(fmt.Sprintf("install runner %s@%s", pkgPath, version), output, err) + } + return nil +} + +func installRunnerBinaryToDir(targetDir, pkgPath, version string) (string, error) { + if err := installRunnerExecutable(targetDir, pkgPath, version); err != nil { + return "", err + } + binaryPath := filepath.Join(targetDir, runnerBinaryName(pkgPath)) + if _, err := os.Stat(binaryPath); err != nil { + return "", fmt.Errorf("installed runner binary %s: %w", binaryPath, err) + } + return binaryPath, nil +} + +func runnerBinaryName(pkgPath string) string { + return path.Base(pkgPath) + runnerBinaryExt() +} + +func newRunnerTempDir(kind string) (string, error) { + return os.MkdirTemp("", "xgo-runner-"+kind+"-*") +} + +func runnerBinaryExt() string { + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} + +func buildRunnerExecutable(packageDir, binaryPath string) error { + if err := validateMainPackage(packageDir); err != nil { + return err + } + output, err := goCommandOutput(packageDir, "build", "-o", binaryPath, ".") + if err != nil { + return formatGoCommandError(fmt.Sprintf("build runner in %s", packageDir), output, err) + } + return nil +} + +func validateMainPackage(packageDir string) error { + output, err := goCommandOutput(packageDir, "list", "-f", "{{.Name}}", ".") + if err != nil { + return formatGoCommandError(fmt.Sprintf("inspect runner package %s", packageDir), output, err) + } + if output != "main" { + return fmt.Errorf("runner package %s is not a main package", packageDir) + } + return nil +} + +func formatGoCommandError(prefix, output string, err error) error { + if output == "" { + return fmt.Errorf("%s: %w", prefix, err) + } + return fmt.Errorf("%s: %w\n%s", prefix, err, output) +} + +func goCommandOutput(dir string, args ...string) (string, error) { + return goCommandOutputWithEnv(dir, nil, args...) +} + +func goCommandOutputWithEnv(dir string, extraEnv []string, args ...string) (string, error) { + cmd := exec.Command("go", args...) + cmd.Env = append(os.Environ(), "GOWORK=off") + cmd.Env = append(cmd.Env, extraEnv...) + if dir != "" { + cmd.Dir = dir + } + output, err := cmd.CombinedOutput() + return strings.TrimSpace(string(output)), err +} diff --git a/cmd/internal/run/runner_project.go b/cmd/internal/run/runner_project.go new file mode 100644 index 000000000..a66437da6 --- /dev/null +++ b/cmd/internal/run/runner_project.go @@ -0,0 +1,178 @@ +package run + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goplus/mod/modcache" + "github.com/goplus/mod/modfetch" + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/xgomod" + "github.com/goplus/xgo/x/xgoprojs" +) + +func resolveProjectDir(proj xgoprojs.Proj, workDir string) (string, error) { + switch v := proj.(type) { + case *xgoprojs.DirProj: + return absolutePath(workDir, v.Dir) + case *xgoprojs.FilesProj: + return resolveFilesProjectDir(workDir, v.Files) + case *xgoprojs.PkgPathProj: + return resolveProjectPackageDir(workDir, v.Path) + default: + return "", fmt.Errorf("unsupported project type %T", proj) + } +} + +func resolveFilesProjectDir(workDir string, files []string) (string, error) { + if len(files) == 0 { + return "", fmt.Errorf("no files in project") + } + return absolutePath(workDir, filepath.Dir(files[0])) +} + +func absolutePath(workDir, target string) (string, error) { + if filepath.IsAbs(target) { + return filepath.Clean(target), nil + } + if workDir == "" { + workDir = "." + } + return filepath.Abs(filepath.Join(workDir, target)) +} + +func resolveProjectPackageDir(workDir, pkgPath string) (string, error) { + if strings.HasSuffix(pkgPath, "/...") { + return "", fmt.Errorf("project path %q cannot use /... with command runner", pkgPath) + } + pkgPath, version := splitPackageSpec(pkgPath) + workDir = normalizeWorkDir(workDir) + + if dir, ok, err := resolveLocalPackageDir(workDir, pkgPath); err != nil { + return "", err + } else if ok { + return dir, nil + } + return resolveDownloadedPackageDir(pkgPath, version) +} + +func normalizeWorkDir(workDir string) string { + if workDir == "" { + return "." + } + return workDir +} + +func resolveLocalPackageDir(workDir, pkgPath string) (string, bool, error) { + pkg, err := lookupModulePackage(workDir, pkgPath) + if err != nil { + return "", false, err + } + if pkg == nil { + return "", false, nil + } + return pkg.Dir, true, nil +} + +func resolveDownloadedPackageDir(pkgPath, version string) (string, error) { + spec := packageSpec(pkgPath, version) + modVer, relPath, err := modfetch.GetPkg(spec, "") + if err != nil { + return "", err + } + modDir, err := modcache.Path(modVer) + if err != nil { + return "", err + } + dir := modDir + if relPath != "" { + dir = filepath.Join(modDir, relPath) + } + return filepath.Abs(dir) +} + +func readCommandRunner(projectDir string) (*modfile.Runner, error) { + return readRunnerFromGopMod(projectDir) +} + +func readRunnerFromGopMod(projectDir string) (*modfile.Runner, error) { + gopModPath, data, err := readProjectGopMod(projectDir) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + + parsed, err := modfile.ParseLax(gopModPath, data, nil) + if err != nil { + return nil, err + } + if len(parsed.Projects) == 0 { + return nil, nil + } + runner := parsed.Projects[0].Runner + if err := validateRunnerSpec(runner); err != nil { + return nil, err + } + return runner, nil +} + +func validateRunnerSpec(runner *modfile.Runner) error { + if runner == nil { + return nil + } + if strings.HasSuffix(runner.Path, "/...") { + return fmt.Errorf("runner path %q cannot use /... pattern", runner.Path) + } + if strings.Contains(runner.Path, "@") { + basePath, _ := splitPackageSpec(runner.Path) + return fmt.Errorf("runner path %q must not include @version; use `runner %s `", runner.Path, basePath) + } + return nil +} + +func readProjectGopMod(projectDir string) (string, []byte, error) { + gopModPath := filepath.Join(projectDir, "gop.mod") + data, err := os.ReadFile(gopModPath) + if err != nil { + if os.IsNotExist(err) { + return gopModPath, nil, nil + } + return "", nil, err + } + return gopModPath, data, nil +} + +func lookupModulePackage(workDir, pkgPath string) (*xgomod.Package, error) { + mod, err := xgomod.Load(workDir) + if err != nil { + return nil, nil + } + pkg, err := mod.Lookup(pkgPath) + if err != nil { + return nil, nil + } + dir, err := filepath.Abs(pkg.Dir) + if err != nil { + return nil, err + } + pkg.Dir = dir + return pkg, nil +} + +func packageSpec(pkgPath, version string) string { + if version == "" { + version = "latest" + } + return pkgPath + "@" + version +} + +func splitPackageSpec(pkgPath string) (string, string) { + if pos := strings.IndexByte(pkgPath, '@'); pos > 0 { + return pkgPath[:pos], pkgPath[pos+1:] + } + return pkgPath, "" +} diff --git a/cmd/internal/run/runner_test.go b/cmd/internal/run/runner_test.go new file mode 100644 index 000000000..cf63be6a2 --- /dev/null +++ b/cmd/internal/run/runner_test.go @@ -0,0 +1,441 @@ +package run + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestResolveProjectDir(t *testing.T) { + root := t.TempDir() + filesDir := filepath.Join(root, "files") + if err := os.MkdirAll(filesDir, 0755); err != nil { + t.Fatal(err) + } + file := filepath.Join(filesDir, "main.gop") + if err := os.WriteFile(file, []byte("package main\n"), 0644); err != nil { + t.Fatal(err) + } + + moduleRoot := filepath.Join(root, "module") + writeFile(t, filepath.Join(moduleRoot, "go.mod"), "module example.com/app\n\ngo 1.21\n") + writeFile(t, filepath.Join(moduleRoot, "pkg", "main.gop"), "package main\n") + + cases := []struct { + name string + proj xgoprojs.Proj + want string + }{ + {name: "dir", proj: &xgoprojs.DirProj{Dir: filesDir}, want: filesDir}, + {name: "files", proj: &xgoprojs.FilesProj{Files: []string{file}}, want: filesDir}, + {name: "pkg", proj: &xgoprojs.PkgPathProj{Path: "example.com/app/pkg"}, want: filepath.Join(moduleRoot, "pkg")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveProjectDir(tc.proj, moduleRoot) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("resolveProjectDir() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestResolveProjectPackageDirRejectsPattern(t *testing.T) { + _, err := resolveProjectPackageDir(".", "example.com/app/...") + if err == nil { + t.Fatal("resolveProjectPackageDir() error = nil, want error") + } + if !strings.Contains(err.Error(), "/...") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReadCommandRunnerFromGopMod(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner example.com/runner/cmd/pcrun v1.2.3 +`) + + runner, err := readCommandRunner(projectDir) + if err != nil { + t.Fatal(err) + } + if runner == nil { + t.Fatal("readCommandRunner() returned nil") + } + if runner.Path != "example.com/runner/cmd/pcrun" || runner.Version != "v1.2.3" { + t.Fatalf("unexpected runner: %+v", runner) + } +} + +func TestReadCommandRunnerOnlyReadsProjectGopMod(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner example.com/runner/cmd/pcrun +`) + projectDir := filepath.Join(root, "sub", "project") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatal(err) + } + + runner, err := readCommandRunner(projectDir) + if err != nil { + t.Fatal(err) + } + if runner != nil { + t.Fatalf("readCommandRunner() = %+v, want nil", runner) + } +} + +func TestReadCommandRunnerAbsent(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +`) + + runner, err := readCommandRunner(projectDir) + if err != nil { + t.Fatal(err) + } + if runner != nil { + t.Fatalf("readCommandRunner() = %+v, want nil", runner) + } +} + +func TestLookupModulePackagePrefersModule(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "go.mod"), "module example.com/app\n\ngo 1.21\n") + writeFile(t, filepath.Join(root, "cmd", "runner", "main.go"), "package main\nfunc main() {}\n") + + pkg, err := lookupModulePackage(root, "example.com/app/cmd/runner") + if err != nil { + t.Fatal(err) + } + if pkg == nil { + t.Fatal("lookupModulePackage() should resolve local module") + } + if pkg.Dir != filepath.Join(root, "cmd", "runner") { + t.Fatalf("lookupModulePackage() dir = %q", pkg.Dir) + } +} + +func TestLookupModulePackagePrefersReplace(t *testing.T) { + root := t.TempDir() + runnerRoot := filepath.Join(root, "runner") + appRoot := filepath.Join(root, "app") + + writeFile(t, filepath.Join(runnerRoot, "go.mod"), "module example.com/runner\n\ngo 1.21\n") + writeFile(t, filepath.Join(runnerRoot, "cmd", "pcrun", "main.go"), "package main\nfunc main() {}\n") + writeFile(t, filepath.Join(appRoot, "go.mod"), `module example.com/app + +go 1.21 + +require example.com/runner v0.0.0 + +replace example.com/runner => ../runner +`) + + pkg, err := lookupModulePackage(appRoot, "example.com/runner/cmd/pcrun") + if err != nil { + t.Fatal(err) + } + if pkg == nil { + t.Fatal("lookupModulePackage() should resolve local replace") + } + if pkg.Dir != filepath.Join(runnerRoot, "cmd", "pcrun") { + t.Fatalf("lookupModulePackage() dir = %q", pkg.Dir) + } +} + +func TestReadCommandRunnerRejectsPathVersionSyntax(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner example.com/runner/cmd/pcrun@latest +`) + + _, err := readCommandRunner(projectDir) + if err == nil { + t.Fatal("readCommandRunner() error = nil, want error") + } + if !strings.Contains(err.Error(), "must not include @version") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInstallTempRunnerBinaryUsesExplicitVersionQuery(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-based fake go test") + } + + installLog := filepath.Join(t.TempDir(), "install.log") + fakeGoDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(fakeGoDir, 0755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(fakeGoDir, "go"), `#!/bin/sh +set -eu +cmd="$1" +shift +case "$cmd" in +install) + printf '%s\n' "$1" >>"$FAKE_GO_INSTALL_LOG" + cat >"$GOBIN/pcrun" <<'EOF' +#!/bin/sh +printf 'remote' > "$1" +EOF + chmod +x "$GOBIN/pcrun" + exit 0 + ;; +esac +echo "unexpected go command: $cmd $*" >&2 +exit 1 +`) + if err := os.Chmod(filepath.Join(fakeGoDir, "go"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_GO_INSTALL_LOG", installLog) + t.Setenv("PATH", fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + binaryPath, cleanup, err := installTempRunnerBinary("example.com/runner/cmd/pcrun", "v1.2.3") + if err != nil { + t.Fatal(err) + } + defer cleanup() + + data, err := os.ReadFile(installLog) + if err != nil { + t.Fatal(err) + } + lines := strings.Fields(string(data)) + if len(lines) != 1 || lines[0] != "example.com/runner/cmd/pcrun@v1.2.3" { + t.Fatalf("unexpected install log: %q", data) + } + + outputFile := filepath.Join(t.TempDir(), "runner.out") + cmd := exec.Command(binaryPath, outputFile) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run temp runner: %v\n%s", err, out) + } + got, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + if string(got) != "remote" { + t.Fatalf("temp runner output = %q, want remote", got) + } +} + +func TestInstallTempRunnerBinaryUsesLatestQuery(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-based fake go test") + } + + installLog := filepath.Join(t.TempDir(), "install.log") + fakeGoDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(fakeGoDir, 0755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(fakeGoDir, "go"), `#!/bin/sh +set -eu +cmd="$1" +shift +case "$cmd" in +install) + printf '%s\n' "$1" >>"$FAKE_GO_INSTALL_LOG" + cat >"$GOBIN/pcrun" <<'EOF' +#!/bin/sh +printf 'remote-latest' > "$1" +EOF + chmod +x "$GOBIN/pcrun" + exit 0 + ;; +esac +echo "unexpected go command: $cmd $*" >&2 +exit 1 +`) + if err := os.Chmod(filepath.Join(fakeGoDir, "go"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_GO_INSTALL_LOG", installLog) + t.Setenv("PATH", fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + binaryPath, cleanup, err := installTempRunnerBinary("example.com/runner/cmd/pcrun", "latest") + if err != nil { + t.Fatal(err) + } + defer cleanup() + + data, err := os.ReadFile(installLog) + if err != nil { + t.Fatal(err) + } + lines := strings.Fields(string(data)) + if len(lines) != 1 || lines[0] != "example.com/runner/cmd/pcrun@latest" { + t.Fatalf("unexpected install log: %q", data) + } + + outputFile := filepath.Join(t.TempDir(), "runner.out") + cmd := exec.Command(binaryPath, outputFile) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run temp runner: %v\n%s", err, out) + } + got, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + if string(got) != "remote-latest" { + t.Fatalf("temp runner output = %q, want remote-latest", got) + } +} + +func TestBuildLocalRunnerBinaryRebuildsEveryTime(t *testing.T) { + sourceDir := filepath.Join(t.TempDir(), "runner") + writeFile(t, filepath.Join(sourceDir, "go.mod"), "module example.com/runner\n\ngo 1.21\n") + writeFile(t, filepath.Join(sourceDir, "main.go"), `package main + +import "os" + +func main() { + _ = os.WriteFile(os.Args[1], []byte("v1"), 0644) +} +`) + + binary1, cleanup1, err := buildLocalRunnerBinary(sourceDir) + if err != nil { + t.Fatal(err) + } + defer cleanup1() + writeFile(t, filepath.Join(sourceDir, "main.go"), `package main + +import "os" + +func main() { + _ = os.WriteFile(os.Args[1], []byte("v2"), 0644) +} +`) + binary2, cleanup2, err := buildLocalRunnerBinary(sourceDir) + if err != nil { + t.Fatal(err) + } + defer cleanup2() + if binary1 == binary2 { + t.Fatal("local runner should not reuse cached binary path") + } + + outputFile := filepath.Join(t.TempDir(), "runner.out") + cmd := exec.Command(binary2, outputFile) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run rebuilt local runner: %v\n%s", err, out) + } + data, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + if string(data) != "v2" { + t.Fatalf("rebuilt local runner output = %q, want v2", data) + } +} + +func TestRunWithCommandRunner(t *testing.T) { + root := t.TempDir() + runnerRoot := filepath.Join(root, "runner") + projectDir := filepath.Join(root, "project") + outputFile := filepath.Join(root, "runner.out") + + writeFile(t, filepath.Join(runnerRoot, "go.mod"), "module example.com/runner\n\ngo 1.21\n") + writeFile(t, filepath.Join(runnerRoot, "cmd", "pcrun", "main.go"), `package main + +import ( + "os" + "strings" +) + +func main() { + data := os.Args[1] + "\n" + strings.Join(os.Args[2:], "|") + if err := os.WriteFile(os.Getenv("TEST_RUNNER_OUTPUT"), []byte(data), 0644); err != nil { + panic(err) + } +} +`) + writeFile(t, filepath.Join(projectDir, "go.mod"), `module example.com/app + +go 1.21 + +require example.com/runner v0.0.0 + +replace example.com/runner => ../runner +`) + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner example.com/runner/cmd/pcrun +`) + + t.Setenv("TEST_RUNNER_OUTPUT", outputFile) + handled, err := tryRunWithCommandRunner(&xgoprojs.DirProj{Dir: projectDir}, []string{"alpha", "beta"}, ".") + if err != nil { + t.Fatal(err) + } + if !handled { + t.Fatal("tryRunWithCommandRunner() = false, want true") + } + + data, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + got := string(data) + want := projectDir + "\nalpha|beta" + if got != want { + t.Fatalf("runner output = %q, want %q", got, want) + } +} + +func TestBuildRunnerExecutableRejectsNonMainPackage(t *testing.T) { + sourceDir := t.TempDir() + writeFile(t, filepath.Join(sourceDir, "go.mod"), "module example.com/runner\n\ngo 1.21\n") + writeFile(t, filepath.Join(sourceDir, "runner.go"), "package helper\n") + + err := buildRunnerExecutable(sourceDir, filepath.Join(t.TempDir(), "runner"+runnerBinaryExt())) + if err == nil { + t.Fatal("buildRunnerExecutable() error = nil, want error") + } + if !strings.Contains(err.Error(), "not a main package") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunnerBinaryExt(t *testing.T) { + if runtime.GOOS == "windows" && runnerBinaryExt() != ".exe" { + t.Fatal("windows runner binary must end with .exe") + } + if runtime.GOOS != "windows" && runnerBinaryExt() != "" { + t.Fatal("non-windows runner binary should not have extension") + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} From f51b0ef4ecec58758af2d1426ee35ff7fefec401 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Sun, 5 Apr 2026 15:35:31 +0800 Subject: [PATCH 2/2] run: simplify command runner support for multi-project gop.mod --- cmd/internal/run/run.go | 2 +- cmd/internal/run/runner.go | 26 +-- cmd/internal/run/runner_binary.go | 118 ++----------- cmd/internal/run/runner_command.go | 34 ++++ cmd/internal/run/runner_package.go | 69 ++++++++ cmd/internal/run/runner_project.go | 175 +++++++++----------- cmd/internal/run/runner_test.go | 256 +++++++++++++++-------------- 7 files changed, 332 insertions(+), 348 deletions(-) create mode 100644 cmd/internal/run/runner_command.go create mode 100644 cmd/internal/run/runner_package.go diff --git a/cmd/internal/run/run.go b/cmd/internal/run/run.go index 61464f952..28aaa5b5e 100644 --- a/cmd/internal/run/run.go +++ b/cmd/internal/run/run.go @@ -79,7 +79,7 @@ func runCmd(cmd *base.Command, args []string) { panic("TODO: profile not impl") } - if handled, err := tryRunWithCommandRunner(proj, args, "."); err != nil { + if handled, err := runWithConfiguredRunner(proj, args, "."); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } else if handled { diff --git a/cmd/internal/run/runner.go b/cmd/internal/run/runner.go index 4fe4c6ad9..9f148c5e5 100644 --- a/cmd/internal/run/runner.go +++ b/cmd/internal/run/runner.go @@ -1,19 +1,14 @@ package run -import ( - "os" - "os/exec" +import "github.com/goplus/xgo/x/xgoprojs" - "github.com/goplus/xgo/x/xgoprojs" -) - -func tryRunWithCommandRunner(proj xgoprojs.Proj, args []string, workDir string) (bool, error) { - projectDir, err := resolveProjectDir(proj, workDir) +func runWithConfiguredRunner(proj xgoprojs.Proj, args []string, workDir string) (bool, error) { + projectDirectory, err := resolveProjectDir(proj, workDir) if err != nil { return false, err } - runner, err := readCommandRunner(projectDir) + runner, err := loadProjectRunner(proj, projectDirectory) if err != nil { return false, err } @@ -21,20 +16,11 @@ func tryRunWithCommandRunner(proj xgoprojs.Proj, args []string, workDir string) return false, nil } - binaryPath, cleanup, err := prepareRunnerBinary(projectDir, runner) + runnerBinaryPath, cleanup, err := installRunnerBinary(runner) if err != nil { return true, err } defer cleanup() - return true, runCommandRunner(binaryPath, projectDir, args) -} - -func runCommandRunner(binaryPath, projectDir string, args []string) error { - cmd := exec.Command(binaryPath, append([]string{projectDir}, args...)...) - cmd.Dir = projectDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - return cmd.Run() + return true, executeRunnerBinary(runnerBinaryPath, projectDirectory, args) } diff --git a/cmd/internal/run/runner_binary.go b/cmd/internal/run/runner_binary.go index 273875378..6a38f2ff3 100644 --- a/cmd/internal/run/runner_binary.go +++ b/cmd/internal/run/runner_binary.go @@ -3,56 +3,24 @@ package run import ( "fmt" "os" - "os/exec" "path" "path/filepath" "runtime" - "strings" - "github.com/goplus/mod/modcache" "github.com/goplus/mod/modfile" ) -func prepareRunnerBinary(projectDir string, runner *modfile.Runner) (string, func(), error) { - pkgPath := runner.Path - version := runner.Version - - pkg, err := lookupModulePackage(projectDir, pkgPath) +func installRunnerBinary(runner *modfile.Runner) (string, func(), error) { + temporaryDirectory, err := os.MkdirTemp("", "xgo-runner-install-*") if err != nil { return "", nil, err } - if pkg != nil && pkg.ModDir != "" && !modcache.InPath(pkg.ModDir) { - return buildLocalRunnerBinary(pkg.Dir) - } - if version == "" { - version = "latest" - } - return installTempRunnerBinary(pkgPath, version) -} -func buildLocalRunnerBinary(packageDir string) (string, func(), error) { - return withRunnerTempBinary("build", func(tempDir string) (string, error) { - binaryPath := filepath.Join(tempDir, "runner"+runnerBinaryExt()) - if err := buildRunnerExecutable(packageDir, binaryPath); err != nil { - return "", err - } - return binaryPath, nil - }) -} - -func installTempRunnerBinary(pkgPath, version string) (string, func(), error) { - return withRunnerTempBinary("install", func(tempDir string) (string, error) { - return installRunnerBinaryToDir(tempDir, pkgPath, version) - }) -} - -func withRunnerTempBinary(kind string, prepare func(tempDir string) (string, error)) (string, func(), error) { - tempDir, err := newRunnerTempDir(kind) - if err != nil { - return "", nil, err + cleanup := func() { + _ = os.RemoveAll(temporaryDirectory) } - cleanup := func() { _ = os.RemoveAll(tempDir) } - binaryPath, err := prepare(tempDir) + + binaryPath, err := installRunnerBinaryToDirectory(temporaryDirectory, runner.Path, runner.Version) if err != nil { cleanup() return "", nil, err @@ -60,80 +28,24 @@ func withRunnerTempBinary(kind string, prepare func(tempDir string) (string, err return binaryPath, cleanup, nil } -func installRunnerExecutable(targetDir, pkgPath, version string) error { - output, err := goCommandOutputWithEnv("", []string{"GOBIN=" + targetDir}, "install", pkgPath+"@"+version) +func installRunnerBinaryToDirectory(targetDirectory, packagePath, version string) (string, error) { + packageReference := packageRef(packagePath, version) + output, err := runGoCommand("", []string{"GOBIN=" + targetDirectory}, "install", packageReference) if err != nil { - return formatGoCommandError(fmt.Sprintf("install runner %s@%s", pkgPath, version), output, err) + return "", fmt.Errorf("install runner %s: %w\n%s", packageReference, err, output) } - return nil -} -func installRunnerBinaryToDir(targetDir, pkgPath, version string) (string, error) { - if err := installRunnerExecutable(targetDir, pkgPath, version); err != nil { - return "", err - } - binaryPath := filepath.Join(targetDir, runnerBinaryName(pkgPath)) + binaryPath := filepath.Join(targetDirectory, runnerBinaryFilename(packagePath)) if _, err := os.Stat(binaryPath); err != nil { return "", fmt.Errorf("installed runner binary %s: %w", binaryPath, err) } return binaryPath, nil } -func runnerBinaryName(pkgPath string) string { - return path.Base(pkgPath) + runnerBinaryExt() -} - -func newRunnerTempDir(kind string) (string, error) { - return os.MkdirTemp("", "xgo-runner-"+kind+"-*") -} - -func runnerBinaryExt() string { +func runnerBinaryFilename(packagePath string) string { + filename := path.Base(packagePath) if runtime.GOOS == "windows" { - return ".exe" - } - return "" -} - -func buildRunnerExecutable(packageDir, binaryPath string) error { - if err := validateMainPackage(packageDir); err != nil { - return err - } - output, err := goCommandOutput(packageDir, "build", "-o", binaryPath, ".") - if err != nil { - return formatGoCommandError(fmt.Sprintf("build runner in %s", packageDir), output, err) - } - return nil -} - -func validateMainPackage(packageDir string) error { - output, err := goCommandOutput(packageDir, "list", "-f", "{{.Name}}", ".") - if err != nil { - return formatGoCommandError(fmt.Sprintf("inspect runner package %s", packageDir), output, err) - } - if output != "main" { - return fmt.Errorf("runner package %s is not a main package", packageDir) - } - return nil -} - -func formatGoCommandError(prefix, output string, err error) error { - if output == "" { - return fmt.Errorf("%s: %w", prefix, err) - } - return fmt.Errorf("%s: %w\n%s", prefix, err, output) -} - -func goCommandOutput(dir string, args ...string) (string, error) { - return goCommandOutputWithEnv(dir, nil, args...) -} - -func goCommandOutputWithEnv(dir string, extraEnv []string, args ...string) (string, error) { - cmd := exec.Command("go", args...) - cmd.Env = append(os.Environ(), "GOWORK=off") - cmd.Env = append(cmd.Env, extraEnv...) - if dir != "" { - cmd.Dir = dir + filename += ".exe" } - output, err := cmd.CombinedOutput() - return strings.TrimSpace(string(output)), err + return filename } diff --git a/cmd/internal/run/runner_command.go b/cmd/internal/run/runner_command.go new file mode 100644 index 000000000..d39e72559 --- /dev/null +++ b/cmd/internal/run/runner_command.go @@ -0,0 +1,34 @@ +package run + +import ( + "os" + "os/exec" + "strings" +) + +func executeRunnerBinary(binaryPath, projectDirectory string, args []string) error { + cmd := newCommandInDir(binaryPath, projectDirectory, append([]string{projectDirectory}, args...)...) + // Configured runners are project-controlled executables, so they intentionally + // inherit the caller environment just like other tools launched by xgo. + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} + +func runGoCommand(directory string, extraEnv []string, args ...string) (string, error) { + cmd := newCommandInDir("go", directory, args...) + // Runner installation/build should not be redirected by an ambient go.work file. + cmd.Env = append(os.Environ(), "GOWORK=off") + cmd.Env = append(cmd.Env, extraEnv...) + output, err := cmd.CombinedOutput() + return strings.TrimSpace(string(output)), err +} + +func newCommandInDir(command, directory string, args ...string) *exec.Cmd { + cmd := exec.Command(command, args...) + if directory != "" { + cmd.Dir = directory + } + return cmd +} diff --git a/cmd/internal/run/runner_package.go b/cmd/internal/run/runner_package.go new file mode 100644 index 000000000..1065da126 --- /dev/null +++ b/cmd/internal/run/runner_package.go @@ -0,0 +1,69 @@ +package run + +import ( + "errors" + "path/filepath" + + "github.com/goplus/mod/modcache" + "github.com/goplus/mod/modfetch" + "github.com/goplus/mod/xgomod" +) + +func downloadPackageDir(pkgPath, version string) (string, error) { + spec := packageRef(pkgPath, version) + modVer, relPath, err := modfetch.GetPkg(spec, "") + if err != nil { + return "", err + } + modDir, err := modcache.Path(modVer) + if err != nil { + return "", err + } + directory := modDir + if relPath != "" { + directory = filepath.Join(modDir, relPath) + } + return filepath.Abs(directory) +} + +func lookupPackageDir(workDir, pkgPath string) (string, error) { + mod, err := xgomod.Load(workDir) + if err = ignoreMissing(err); err != nil { + return "", err + } + if mod == nil { + return "", nil + } + + pkg, err := mod.Lookup(pkgPath) + if err = ignoreMissing(err); err != nil { + return "", err + } + if pkg == nil { + return "", nil + } + + directory, err := filepath.Abs(pkg.Dir) + if err != nil { + return "", err + } + return directory, nil +} + +func packageRef(pkgPath, version string) string { + if version == "" { + version = "latest" + } + return pkgPath + "@" + version +} + +func ignoreMissing(err error) error { + if err == nil || xgomod.IsNotFound(err) { + return nil + } + var missing *xgomod.MissingError + if errors.As(err, &missing) { + return nil + } + return err +} diff --git a/cmd/internal/run/runner_project.go b/cmd/internal/run/runner_project.go index a66437da6..f68ba62dd 100644 --- a/cmd/internal/run/runner_project.go +++ b/cmd/internal/run/runner_project.go @@ -1,26 +1,26 @@ package run import ( + "errors" "fmt" + "io/fs" "os" "path/filepath" "strings" - "github.com/goplus/mod/modcache" - "github.com/goplus/mod/modfetch" "github.com/goplus/mod/modfile" - "github.com/goplus/mod/xgomod" "github.com/goplus/xgo/x/xgoprojs" + "golang.org/x/mod/module" ) func resolveProjectDir(proj xgoprojs.Proj, workDir string) (string, error) { switch v := proj.(type) { case *xgoprojs.DirProj: - return absolutePath(workDir, v.Dir) + return resolvePath(workDir, v.Dir) case *xgoprojs.FilesProj: return resolveFilesProjectDir(workDir, v.Files) case *xgoprojs.PkgPathProj: - return resolveProjectPackageDir(workDir, v.Path) + return resolvePackageProjectDir(workDir, v.Path) default: return "", fmt.Errorf("unsupported project type %T", proj) } @@ -30,10 +30,10 @@ func resolveFilesProjectDir(workDir string, files []string) (string, error) { if len(files) == 0 { return "", fmt.Errorf("no files in project") } - return absolutePath(workDir, filepath.Dir(files[0])) + return resolvePath(workDir, filepath.Dir(files[0])) } -func absolutePath(workDir, target string) (string, error) { +func resolvePath(workDir, target string) (string, error) { if filepath.IsAbs(target) { return filepath.Clean(target), nil } @@ -43,61 +43,20 @@ func absolutePath(workDir, target string) (string, error) { return filepath.Abs(filepath.Join(workDir, target)) } -func resolveProjectPackageDir(workDir, pkgPath string) (string, error) { - if strings.HasSuffix(pkgPath, "/...") { - return "", fmt.Errorf("project path %q cannot use /... with command runner", pkgPath) - } - pkgPath, version := splitPackageSpec(pkgPath) - workDir = normalizeWorkDir(workDir) - - if dir, ok, err := resolveLocalPackageDir(workDir, pkgPath); err != nil { - return "", err - } else if ok { - return dir, nil - } - return resolveDownloadedPackageDir(pkgPath, version) -} - -func normalizeWorkDir(workDir string) string { +func resolvePackageProjectDir(workDir, pkgPath string) (string, error) { + pkgPath, version, _ := strings.Cut(pkgPath, "@") if workDir == "" { - return "." - } - return workDir -} - -func resolveLocalPackageDir(workDir, pkgPath string) (string, bool, error) { - pkg, err := lookupModulePackage(workDir, pkgPath) - if err != nil { - return "", false, err - } - if pkg == nil { - return "", false, nil - } - return pkg.Dir, true, nil -} - -func resolveDownloadedPackageDir(pkgPath, version string) (string, error) { - spec := packageSpec(pkgPath, version) - modVer, relPath, err := modfetch.GetPkg(spec, "") - if err != nil { - return "", err + workDir = "." } - modDir, err := modcache.Path(modVer) - if err != nil { + if packageDirectory, err := lookupPackageDir(workDir, pkgPath); err != nil { return "", err + } else if packageDirectory != "" { + return packageDirectory, nil } - dir := modDir - if relPath != "" { - dir = filepath.Join(modDir, relPath) - } - return filepath.Abs(dir) + return downloadPackageDir(pkgPath, version) } -func readCommandRunner(projectDir string) (*modfile.Runner, error) { - return readRunnerFromGopMod(projectDir) -} - -func readRunnerFromGopMod(projectDir string) (*modfile.Runner, error) { +func loadProjectRunner(proj xgoprojs.Proj, projectDir string) (*modfile.Runner, error) { gopModPath, data, err := readProjectGopMod(projectDir) if err != nil { return nil, err @@ -110,69 +69,83 @@ func readRunnerFromGopMod(projectDir string) (*modfile.Runner, error) { if err != nil { return nil, err } - if len(parsed.Projects) == 0 { + project, err := selectTargetProject(parsed.Projects, proj, projectDir) + if err != nil { + return nil, err + } + if project == nil || project.Runner == nil { return nil, nil } - runner := parsed.Projects[0].Runner - if err := validateRunnerSpec(runner); err != nil { - return nil, err + runner := project.Runner + if err := module.CheckImportPath(runner.Path); err != nil { + return nil, fmt.Errorf("invalid runner path %q: %w", runner.Path, err) } return runner, nil } -func validateRunnerSpec(runner *modfile.Runner) error { - if runner == nil { - return nil +func selectTargetProject(projects []*modfile.Project, proj xgoprojs.Proj, projectDir string) (*modfile.Project, error) { + switch len(projects) { + case 0: + return nil, nil + case 1: + return projects[0], nil + } + + targetFilenames, err := collectTargetFilenames(proj, projectDir) + if err != nil { + return nil, err } - if strings.HasSuffix(runner.Path, "/...") { - return fmt.Errorf("runner path %q cannot use /... pattern", runner.Path) + + gopModPath := filepath.Join(projectDir, "gop.mod") + var matched *modfile.Project + for _, filename := range targetFilenames { + ext := modfile.ClassExt(filename) + for _, project := range projects { + if ext == project.Ext && project.IsProj(ext, filename) { + if matched != nil && matched != project { + return nil, fmt.Errorf("multiple projects in %s match run target", gopModPath) + } + matched = project + } + } } - if strings.Contains(runner.Path, "@") { - basePath, _ := splitPackageSpec(runner.Path) - return fmt.Errorf("runner path %q must not include @version; use `runner %s `", runner.Path, basePath) + return matched, nil +} + +func collectTargetFilenames(proj xgoprojs.Proj, projectDir string) ([]string, error) { + switch v := proj.(type) { + case *xgoprojs.FilesProj: + filenames := make([]string, 0, len(v.Files)) + for _, file := range v.Files { + filenames = append(filenames, filepath.Base(file)) + } + return filenames, nil + case *xgoprojs.DirProj, *xgoprojs.PkgPathProj: + entries, err := os.ReadDir(projectDir) + if err != nil { + return nil, err + } + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + files = append(files, entry.Name()) + } + return files, nil + default: + return nil, fmt.Errorf("unsupported project type %T", proj) } - return nil } func readProjectGopMod(projectDir string) (string, []byte, error) { gopModPath := filepath.Join(projectDir, "gop.mod") data, err := os.ReadFile(gopModPath) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { return gopModPath, nil, nil } return "", nil, err } return gopModPath, data, nil } - -func lookupModulePackage(workDir, pkgPath string) (*xgomod.Package, error) { - mod, err := xgomod.Load(workDir) - if err != nil { - return nil, nil - } - pkg, err := mod.Lookup(pkgPath) - if err != nil { - return nil, nil - } - dir, err := filepath.Abs(pkg.Dir) - if err != nil { - return nil, err - } - pkg.Dir = dir - return pkg, nil -} - -func packageSpec(pkgPath, version string) string { - if version == "" { - version = "latest" - } - return pkgPath + "@" + version -} - -func splitPackageSpec(pkgPath string) (string, string) { - if pos := strings.IndexByte(pkgPath, '@'); pos > 0 { - return pkgPath[:pos], pkgPath[pos+1:] - } - return pkgPath, "" -} diff --git a/cmd/internal/run/runner_test.go b/cmd/internal/run/runner_test.go index cf63be6a2..46544ddcd 100644 --- a/cmd/internal/run/runner_test.go +++ b/cmd/internal/run/runner_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/goplus/mod/modfile" "github.com/goplus/xgo/x/xgoprojs" ) @@ -49,16 +50,6 @@ func TestResolveProjectDir(t *testing.T) { } } -func TestResolveProjectPackageDirRejectsPattern(t *testing.T) { - _, err := resolveProjectPackageDir(".", "example.com/app/...") - if err == nil { - t.Fatal("resolveProjectPackageDir() error = nil, want error") - } - if !strings.Contains(err.Error(), "/...") { - t.Fatalf("unexpected error: %v", err) - } -} - func TestReadCommandRunnerFromGopMod(t *testing.T) { projectDir := t.TempDir() writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 @@ -66,13 +57,14 @@ func TestReadCommandRunnerFromGopMod(t *testing.T) { project main.spx Game github.com/example/app runner example.com/runner/cmd/pcrun v1.2.3 `) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") - runner, err := readCommandRunner(projectDir) + runner, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) if err != nil { t.Fatal(err) } if runner == nil { - t.Fatal("readCommandRunner() returned nil") + t.Fatal("loadProjectRunner() returned nil") } if runner.Path != "example.com/runner/cmd/pcrun" || runner.Version != "v1.2.3" { t.Fatalf("unexpected runner: %+v", runner) @@ -91,12 +83,12 @@ runner example.com/runner/cmd/pcrun t.Fatal(err) } - runner, err := readCommandRunner(projectDir) + runner, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) if err != nil { t.Fatal(err) } if runner != nil { - t.Fatalf("readCommandRunner() = %+v, want nil", runner) + t.Fatalf("loadProjectRunner() = %+v, want nil", runner) } } @@ -106,34 +98,42 @@ func TestReadCommandRunnerAbsent(t *testing.T) { project main.spx Game github.com/example/app `) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") - runner, err := readCommandRunner(projectDir) + runner, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) if err != nil { t.Fatal(err) } if runner != nil { - t.Fatalf("readCommandRunner() = %+v, want nil", runner) + t.Fatalf("loadProjectRunner() = %+v, want nil", runner) } } -func TestLookupModulePackagePrefersModule(t *testing.T) { +func TestLookupPackageDirPrefersModule(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "go.mod"), "module example.com/app\n\ngo 1.21\n") writeFile(t, filepath.Join(root, "cmd", "runner", "main.go"), "package main\nfunc main() {}\n") - pkg, err := lookupModulePackage(root, "example.com/app/cmd/runner") + directory, err := lookupPackageDir(root, "example.com/app/cmd/runner") if err != nil { t.Fatal(err) } - if pkg == nil { - t.Fatal("lookupModulePackage() should resolve local module") + if directory != filepath.Join(root, "cmd", "runner") { + t.Fatalf("lookupPackageDir() dir = %q", directory) } - if pkg.Dir != filepath.Join(root, "cmd", "runner") { - t.Fatalf("lookupModulePackage() dir = %q", pkg.Dir) +} + +func TestLookupPackageDirPropagatesLoadError(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "go.mod"), "module example.com/app\n\nrequire (\n") + + _, err := lookupPackageDir(root, "example.com/app") + if err == nil { + t.Fatal("lookupPackageDir() error = nil, want error") } } -func TestLookupModulePackagePrefersReplace(t *testing.T) { +func TestLookupPackageDirPrefersReplace(t *testing.T) { root := t.TempDir() runnerRoot := filepath.Join(root, "runner") appRoot := filepath.Join(root, "app") @@ -149,15 +149,12 @@ require example.com/runner v0.0.0 replace example.com/runner => ../runner `) - pkg, err := lookupModulePackage(appRoot, "example.com/runner/cmd/pcrun") + directory, err := lookupPackageDir(appRoot, "example.com/runner/cmd/pcrun") if err != nil { t.Fatal(err) } - if pkg == nil { - t.Fatal("lookupModulePackage() should resolve local replace") - } - if pkg.Dir != filepath.Join(runnerRoot, "cmd", "pcrun") { - t.Fatalf("lookupModulePackage() dir = %q", pkg.Dir) + if directory != filepath.Join(runnerRoot, "cmd", "pcrun") { + t.Fatalf("lookupPackageDir() dir = %q", directory) } } @@ -168,17 +165,36 @@ func TestReadCommandRunnerRejectsPathVersionSyntax(t *testing.T) { project main.spx Game github.com/example/app runner example.com/runner/cmd/pcrun@latest `) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") + + _, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) + if err == nil { + t.Fatal("loadProjectRunner() error = nil, want error") + } + if !strings.Contains(err.Error(), "invalid runner path") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReadCommandRunnerRejectsInvalidImportPath(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner "bad path" +`) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") - _, err := readCommandRunner(projectDir) + _, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) if err == nil { - t.Fatal("readCommandRunner() error = nil, want error") + t.Fatal("loadProjectRunner() error = nil, want error") } - if !strings.Contains(err.Error(), "must not include @version") { + if !strings.Contains(err.Error(), "invalid runner path") { t.Fatalf("unexpected error: %v", err) } } -func TestInstallTempRunnerBinaryUsesExplicitVersionQuery(t *testing.T) { +func TestInstallRunnerUsesExplicitVersionQuery(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-based fake go test") } @@ -212,7 +228,7 @@ exit 1 t.Setenv("FAKE_GO_INSTALL_LOG", installLog) t.Setenv("PATH", fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) - binaryPath, cleanup, err := installTempRunnerBinary("example.com/runner/cmd/pcrun", "v1.2.3") + binaryPath, cleanup, err := installRunnerBinary(&modfile.Runner{Path: "example.com/runner/cmd/pcrun", Version: "v1.2.3"}) if err != nil { t.Fatal(err) } @@ -241,7 +257,7 @@ exit 1 } } -func TestInstallTempRunnerBinaryUsesLatestQuery(t *testing.T) { +func TestInstallRunnerUsesLatestQuery(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-based fake go test") } @@ -275,7 +291,7 @@ exit 1 t.Setenv("FAKE_GO_INSTALL_LOG", installLog) t.Setenv("PATH", fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) - binaryPath, cleanup, err := installTempRunnerBinary("example.com/runner/cmd/pcrun", "latest") + binaryPath, cleanup, err := installRunnerBinary(&modfile.Runner{Path: "example.com/runner/cmd/pcrun", Version: "latest"}) if err != nil { t.Fatal(err) } @@ -304,96 +320,57 @@ exit 1 } } -func TestBuildLocalRunnerBinaryRebuildsEveryTime(t *testing.T) { - sourceDir := filepath.Join(t.TempDir(), "runner") - writeFile(t, filepath.Join(sourceDir, "go.mod"), "module example.com/runner\n\ngo 1.21\n") - writeFile(t, filepath.Join(sourceDir, "main.go"), `package main - -import "os" - -func main() { - _ = os.WriteFile(os.Args[1], []byte("v1"), 0644) -} -`) - - binary1, cleanup1, err := buildLocalRunnerBinary(sourceDir) - if err != nil { - t.Fatal(err) - } - defer cleanup1() - writeFile(t, filepath.Join(sourceDir, "main.go"), `package main - -import "os" - -func main() { - _ = os.WriteFile(os.Args[1], []byte("v2"), 0644) -} -`) - binary2, cleanup2, err := buildLocalRunnerBinary(sourceDir) - if err != nil { - t.Fatal(err) - } - defer cleanup2() - if binary1 == binary2 { - t.Fatal("local runner should not reuse cached binary path") +func TestRunWithConfiguredRunner(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-based fake go test") } + projectDir := t.TempDir() outputFile := filepath.Join(t.TempDir(), "runner.out") - cmd := exec.Command(binary2, outputFile) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("run rebuilt local runner: %v\n%s", err, out) - } - data, err := os.ReadFile(outputFile) - if err != nil { + fakeGoDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(fakeGoDir, 0755); err != nil { t.Fatal(err) } - if string(data) != "v2" { - t.Fatalf("rebuilt local runner output = %q, want v2", data) - } -} - -func TestRunWithCommandRunner(t *testing.T) { - root := t.TempDir() - runnerRoot := filepath.Join(root, "runner") - projectDir := filepath.Join(root, "project") - outputFile := filepath.Join(root, "runner.out") - - writeFile(t, filepath.Join(runnerRoot, "go.mod"), "module example.com/runner\n\ngo 1.21\n") - writeFile(t, filepath.Join(runnerRoot, "cmd", "pcrun", "main.go"), `package main - -import ( - "os" - "strings" -) - -func main() { - data := os.Args[1] + "\n" + strings.Join(os.Args[2:], "|") - if err := os.WriteFile(os.Getenv("TEST_RUNNER_OUTPUT"), []byte(data), 0644); err != nil { - panic(err) - } -} -`) - writeFile(t, filepath.Join(projectDir, "go.mod"), `module example.com/app - -go 1.21 - -require example.com/runner v0.0.0 - -replace example.com/runner => ../runner + writeFile(t, filepath.Join(fakeGoDir, "go"), `#!/bin/sh +set -eu +cmd="$1" +shift +case "$cmd" in +install) + cat >"$GOBIN/pcrun" <<'EOF' +#!/bin/sh +set -eu +{ + printf '%s\n' "$1" + shift + printf '%s' "$*" +} >"$TEST_RUNNER_OUTPUT" +EOF + chmod +x "$GOBIN/pcrun" + exit 0 + ;; +esac +echo "unexpected go command: $cmd $*" >&2 +exit 1 `) + if err := os.Chmod(filepath.Join(fakeGoDir, "go"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TEST_RUNNER_OUTPUT", outputFile) writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 project main.spx Game github.com/example/app runner example.com/runner/cmd/pcrun `) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") - t.Setenv("TEST_RUNNER_OUTPUT", outputFile) - handled, err := tryRunWithCommandRunner(&xgoprojs.DirProj{Dir: projectDir}, []string{"alpha", "beta"}, ".") + handled, err := runWithConfiguredRunner(&xgoprojs.DirProj{Dir: projectDir}, []string{"alpha", "beta"}, ".") if err != nil { t.Fatal(err) } if !handled { - t.Fatal("tryRunWithCommandRunner() = false, want true") + t.Fatal("runWithConfiguredRunner() = false, want true") } data, err := os.ReadFile(outputFile) @@ -401,32 +378,65 @@ runner example.com/runner/cmd/pcrun t.Fatal(err) } got := string(data) - want := projectDir + "\nalpha|beta" + want := projectDir + "\nalpha beta" if got != want { t.Fatalf("runner output = %q, want %q", got, want) } } -func TestBuildRunnerExecutableRejectsNonMainPackage(t *testing.T) { - sourceDir := t.TempDir() - writeFile(t, filepath.Join(sourceDir, "go.mod"), "module example.com/runner\n\ngo 1.21\n") - writeFile(t, filepath.Join(sourceDir, "runner.go"), "package helper\n") +func TestReadCommandRunnerSelectsMatchingProjectForFiles(t *testing.T) { + projectDir := t.TempDir() + alphaFile := filepath.Join(projectDir, "main.alpha") + betaFile := filepath.Join(projectDir, "main.beta") + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.alpha App github.com/example/alpha +runner example.com/runner/cmd/alpha + +project main.beta App github.com/example/beta +runner example.com/runner/cmd/beta +`) + writeFile(t, alphaFile, "") + writeFile(t, betaFile, "") + + runner, err := loadProjectRunner(&xgoprojs.FilesProj{Files: []string{betaFile}}, projectDir) + if err != nil { + t.Fatal(err) + } + if runner == nil || runner.Path != "example.com/runner/cmd/beta" { + t.Fatalf("loadProjectRunner() = %+v, want beta runner", runner) + } +} + +func TestReadCommandRunnerRejectsAmbiguousDirectoryProject(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.alpha App github.com/example/alpha +runner example.com/runner/cmd/alpha + +project main.beta App github.com/example/beta +runner example.com/runner/cmd/beta +`) + writeFile(t, filepath.Join(projectDir, "main.alpha"), "") + writeFile(t, filepath.Join(projectDir, "main.beta"), "") - err := buildRunnerExecutable(sourceDir, filepath.Join(t.TempDir(), "runner"+runnerBinaryExt())) + _, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) if err == nil { - t.Fatal("buildRunnerExecutable() error = nil, want error") + t.Fatal("loadProjectRunner() error = nil, want error") } - if !strings.Contains(err.Error(), "not a main package") { + if !strings.Contains(err.Error(), "multiple projects") { t.Fatalf("unexpected error: %v", err) } } -func TestRunnerBinaryExt(t *testing.T) { - if runtime.GOOS == "windows" && runnerBinaryExt() != ".exe" { - t.Fatal("windows runner binary must end with .exe") +func TestRunnerBinaryFilename(t *testing.T) { + name := runnerBinaryFilename("example.com/runner/cmd/pcrun") + if runtime.GOOS == "windows" && name != "pcrun.exe" { + t.Fatalf("windows runner binary = %q, want pcrun.exe", name) } - if runtime.GOOS != "windows" && runnerBinaryExt() != "" { - t.Fatal("non-windows runner binary should not have extension") + if runtime.GOOS != "windows" && name != "pcrun" { + t.Fatalf("non-windows runner binary = %q, want pcrun", name) } }