testmark-go

go.go

package testexec

// Go-specific execution support for testmark.
// This file provides intelligent Go program execution with package resolution.

import (
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/warpfork/go-testmark/v2"
)

// TestGo runs Go code from a testmark entry with intelligent package resolution.
// This is a convenience function that handles common Go testing scenarios.
func TestGo(t *testing.T, entry *testmark.DirEntry) {
	TestGoWithTimeout(t, entry, 30*time.Second)
}

// TestGoWithTimeout runs Go code with a custom timeout.
func TestGoWithTimeout(t *testing.T, entry *testmark.DirEntry, timeout time.Duration) {
	config := DefaultConfig()
	config.Timeout = timeout
	config.ExecFn = SmartGoExecFunc()
	Test(t, entry, config)
}

// SmartGoExecFunc creates an ExecFunc that intelligently handles Go packages.
// It supports:
// - "." for current package
// - "./path" for relative packages
// - "package" for packages in current module
// - "module/package" for full module paths
func SmartGoExecFunc() ExecFunc {
	return func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
		if len(args) == 0 {
			return 1, fmt.Errorf("no command specified")
		}

		// Handle Go-specific commands
		if args[0] == "go" || strings.HasSuffix(args[0], ".go") || isGoPackageReference(args[0]) {
			return runGoCommand(ctx, args, stdin, stdout, stderr)
		}

		// Fall back to regular execution
		return DefaultExecFunc(ctx, args, stdin, stdout, stderr)
	}
}

// isGoPackageReference checks if the argument looks like a Go package reference
func isGoPackageReference(arg string) bool {
	// Explicit Go references
	if arg == "." || strings.HasPrefix(arg, "./") || strings.HasPrefix(arg, "../") {
		return true
	}

	// Module paths (contain slash)
	if strings.Contains(arg, "/") && !strings.Contains(arg, " ") {
		return true
	}

	// Avoid common system commands
	commonCommands := []string{"echo", "ls", "cat", "grep", "awk", "sed", "sort", "uniq", "wc", "head", "tail", "curl", "wget"}
	for _, cmd := range commonCommands {
		if arg == cmd {
			return false
		}
	}

	// .go files are Go references
	if strings.HasSuffix(arg, ".go") {
		return true
	}

	// Simple names that look like Go packages (no spaces, no common extensions)
	return !strings.Contains(arg, " ") && !strings.HasSuffix(arg, ".exe") &&
		!strings.HasSuffix(arg, ".sh") && !strings.HasSuffix(arg, ".py") &&
		!strings.HasSuffix(arg, ".js") && len(arg) > 0 &&
		!strings.Contains(arg, "=") // avoid env vars
}

// runGoCommand handles Go-specific command execution with smart package resolution
func runGoCommand(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
	// If it's already a "go" command, run it as-is
	if args[0] == "go" {
		return DefaultExecFunc(ctx, args, stdin, stdout, stderr)
	}

	// Handle .go files
	if strings.HasSuffix(args[0], ".go") {
		goArgs := []string{"go", "run"}
		goArgs = append(goArgs, args...)
		return DefaultExecFunc(ctx, goArgs, stdin, stdout, stderr)
	}

	// Handle package references
	pkgRef := args[0]
	resolvedPkg, err := resolvePackageReference(pkgRef)
	if err != nil {
		return 1, fmt.Errorf("failed to resolve package %q: %w", pkgRef, err)
	}

	goArgs := []string{"go", "run", resolvedPkg}
	if len(args) > 1 {
		goArgs = append(goArgs, args[1:]...) // Add remaining args as program arguments
	}

	return DefaultExecFunc(ctx, goArgs, stdin, stdout, stderr)
}

// resolvePackageReference converts various package reference formats to something `go run` can use
func resolvePackageReference(pkgRef string) (string, error) {
	switch {
	case pkgRef == ".":
		// Current package
		return ".", nil

	case strings.HasPrefix(pkgRef, "./") || strings.HasPrefix(pkgRef, "../"):
		// Relative path - use as-is
		return pkgRef, nil

	case strings.Contains(pkgRef, "/"):
		// Already a module/package path - use as-is
		return pkgRef, nil

	default:
		// Simple package name - try to resolve within current module
		return resolveLocalPackage(pkgRef)
	}
}

// resolveLocalPackage tries to find a package within the current module
func resolveLocalPackage(pkgName string) (string, error) {
	// Try to find the module root
	moduleRoot, err := findModuleRoot()
	if err != nil {
		// If we can't find module root, assume it's a relative package
		return fmt.Sprintf("./%s", pkgName), nil
	}

	// Look for the package directory
	pkgPath := filepath.Join(moduleRoot, pkgName)
	if _, err := os.Stat(pkgPath); err == nil {
		return fmt.Sprintf("./%s", pkgName), nil
	}

	// If not found, assume it's an external package
	return pkgName, nil
}

// findModuleRoot walks up the directory tree looking for go.mod
func findModuleRoot() (string, error) {
	dir, err := os.Getwd()
	if err != nil {
		return "", err
	}

	for {
		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
			return dir, nil
		}

		parent := filepath.Dir(dir)
		if parent == dir {
			// Reached root directory
			break
		}
		dir = parent
	}

	return "", fmt.Errorf("go.mod not found")
}

// Convenience functions for common testing scenarios

v2/testexec/go.go · 5.0 KiB · 93c0df2a6e