From 21f8515c18f52e6f7978210b3e0b6455da24422f Mon Sep 17 00:00:00 2001 From: euforic Date: Fri, 20 Mar 2026 14:32:45 -0700 Subject: [PATCH 1/4] refactor: modernize matr CLI runtime --- Matrfile.go | 28 --- README.md | 153 +++++++++++---- main.go => cmd/matr/main.go | 4 +- matr/discovery.go => discovery.go | 113 +++++++---- discovery_test.go | 143 ++++++++++++++ examples/Matrfile.go | 54 +++-- go.mod | 2 +- matr.go | 314 ++++++++++++++++++++++++++++++ matr/matr.go | 126 ------------ matr/task.go | 16 -- matr/template.go | 81 -------- matr/utils.go | 54 ----- parser/metadata_test.go | 170 ++++++++++++++++ parser/parser.go | 156 ++++++++++++--- parser/parser_test.go | 8 +- runtime_test.go | 118 +++++++++++ task.go | 78 ++++++++ template.go | 101 ++++++++++ timeout.go | 38 ++++ timeout_test.go | 92 +++++++++ usage_test.go | 99 ++++++++++ utils.go | 17 ++ 22 files changed, 1526 insertions(+), 439 deletions(-) delete mode 100644 Matrfile.go rename main.go => cmd/matr/main.go (50%) rename matr/discovery.go => discovery.go (60%) create mode 100644 discovery_test.go create mode 100644 matr.go delete mode 100644 matr/matr.go delete mode 100644 matr/task.go delete mode 100644 matr/template.go delete mode 100644 matr/utils.go create mode 100644 parser/metadata_test.go create mode 100644 runtime_test.go create mode 100644 task.go create mode 100644 template.go create mode 100644 timeout.go create mode 100644 timeout_test.go create mode 100644 usage_test.go create mode 100644 utils.go diff --git a/Matrfile.go b/Matrfile.go deleted file mode 100644 index abfbda8..0000000 --- a/Matrfile.go +++ /dev/null @@ -1,28 +0,0 @@ -//go:build matr - -package main - -import ( - "context" - "fmt" - - "github.com/euforic/matr/matr" -) - -// Test runs the go test command on the current project -func Test(ctx context.Context, args []string) error { - out, err := matr.Sh("go test -v ./...").Output() - if err != nil { - return err - } - - fmt.Println(string(out)) - - return nil -} - -// Run is the primary entrypoint to matrs cli tool. -func Run(ctx context.Context, args []string) error { - fmt.Println("Running matr") - return nil -} diff --git a/README.md b/README.md index cb560b2..c3d0a72 100644 --- a/README.md +++ b/README.md @@ -6,39 +6,51 @@ ## Install -matr command - ```bash -$ go get github.com/euforic/matr +$ go install github.com/euforic/matr/cmd/matr@latest ``` ## Usage -Execute target func +Execute a command ```bash $ matr [target] ``` -List available targets and matr flags +Show launcher flags ```bash $ matr -h -Usage: matr [target] args... - -Targets: - build Build will builds the project - run Run will run the project - proto Proto will build the protobuf files into golang files - test Test runs all go tests - bench Bench runs all the go benchmarks - docker Docker builds static go binary then builds a docker image for it +Usage of matr: + -clean + clean the matr cache + -h display usage info + -matrfile string + path to Matrfile (default "./Matrfile.go") + -no-cache + don't use the matr cache + -timeout duration + task execution timeout; 0 disables the timeout (default 5m0s) + -v display version +``` + +Get expanded help for a command + +```bash +$ matr build -h +``` + +Override the task timeout + +```bash +$ matr -timeout 30s test ``` -Get expanded help for a target +Disable the timeout entirely ```bash -$ matr -h [target] +$ matr -timeout 0 run ``` ## Matrfile @@ -57,48 +69,107 @@ package main ## Targets -Any exported function that is `func(ctx context.Context, args []string) error` is considered a +Any exported function that is `func(ctx context.Context, cmd *matr.Invocation, args []string) error` is considered a matr target. If the function returns an error it will print to stdout and cause the matrfile to exit with an exit with a non 0 exit code. Comments on the target function will become documentation accessible by running -`matr` (with no target). This will list all the build targets in this directory with the first -sentence from their docs. +`matr -h`. This will show the command docs, aliases, dependencies, and command-scoped +flags for that target. A target may be designated the default target, which is run when the user runs matr with no target specified. To denote the default, create a function named `Default`. If no default target is specified, running `matr` with no target will print the list of targets and docs. -## Dependencies +## Command Metadata + +Use comment metadata between the command docs and the function definition. + +```go +// @alias:b +// @depends:test,proto +// @release:bool,short=r;build with release settings +// @timeout:duration,short=t,default=30s;override build timeout +// @token:string,required;API token used for deploys +``` + +Supported flag types: + +- `bool` +- `string` +- `int` +- `duration` + +Metadata rules: -The helper function `matr.Deps(ctx, ...matr.HandlerFunc)` may be passed a context and any number of -functions (they do not have to be exported), and the Deps function will not return until all -declared dependencies have been run (and any dependencies they have are run). +- `@alias:` adds a command alias. +- `@depends:` declares make-like dependencies. +- `@:[,short=][,default=][,required];` declares a local flag. +- `required` is optional; flags are optional by default. +- command flags are command-scoped, not global. +- after parsing command flags, `args` contains only positional arguments. -### Example Dependencies +Dependencies are resolved as a serial DAG: + +- each dependency runs at most once per invocation +- execution stops on the first dependency error +- dependency cycles fail with an explicit error + +Use `cmd` inside handlers to read parsed flag values: + +- `cmd.Bool(name)` +- `cmd.String(name)` +- `cmd.Int(name)` +- `cmd.Duration(name)` + +Command help includes aliases, dependencies, defaults, and required flags. Example: + +```bash +$ matr build -h +matr build: + +Build compiles the project. + +Aliases: b +Depends on: test + +Flags: + --release, -r build with release settings + --timeout, -t override build timeout (default: 30s) + --token API token used for deploys (required) +``` + +## Example ```go -func Build(ctx context.Context, args []string) error { - err := matr.Deps(ctx, F, G) - fmt.Println("Build running") - return err -} +//go:build matr -func F(ctx context.Context, args []string) error { - err := matr.Deps(ctx, h) - fmt.Println("f running") - return err -} +package main -func G(ctx context.Context, args []string) error { - err := matr.Deps(ctx, F) - fmt.Println("g running") - return err +import ( + "context" + "fmt" + + "github.com/euforic/matr" +) + +// Build compiles the project. +// @alias:b +// @depends:test +// @release:bool,short=r;build with release settings +// @timeout:duration,short=t,default=30s;override build timeout +// @token:string,required;API token used for deploys +func Build(ctx context.Context, cmd *matr.Invocation, args []string) error { + fmt.Println("release:", cmd.Bool("release")) + fmt.Println("timeout:", cmd.Duration("timeout")) + fmt.Println("token:", cmd.String("token")) + fmt.Println("args:", args) + return nil } -func h(ctx context.Context, args []string) error { - fmt.Println("h running") - return err +// Test runs unit tests. +func Test(ctx context.Context, _ *matr.Invocation, args []string) error { + return matr.Sh("go test ./...").Run() } ``` diff --git a/main.go b/cmd/matr/main.go similarity index 50% rename from main.go rename to cmd/matr/main.go index a1c824f..3360b09 100644 --- a/main.go +++ b/cmd/matr/main.go @@ -1,8 +1,6 @@ package main -import ( - "github.com/euforic/matr/matr" -) +import "github.com/euforic/matr" func main() { matr.Run() diff --git a/matr/discovery.go b/discovery.go similarity index 60% rename from matr/discovery.go rename to discovery.go index c601378..352a595 100644 --- a/matr/discovery.go +++ b/discovery.go @@ -10,6 +10,8 @@ import ( "os" "os/exec" "path/filepath" + "runtime" + "time" "github.com/euforic/matr/parser" ) @@ -25,25 +27,26 @@ var ( versionFlag bool cleanFlag bool noCacheFlag bool + timeoutFlag time.Duration ) -// Run is the primary entrypoint to matrs cli tool. -// This is where the matrfile path is resolved, compiled and executed +// Run is the primary entrypoint to matr's CLI tool. func Run() { fs := flag.NewFlagSet("matr", flag.ExitOnError) fs.StringVar(&matrFilePath, "matrfile", "./Matrfile.go", "path to Matrfile") fs.BoolVar(&cleanFlag, "clean", false, "clean the matr cache") - fs.BoolVar(&helpFlag, "h", false, "Display usage info") - fs.BoolVar(&versionFlag, "v", false, "Display version") - fs.BoolVar(&noCacheFlag, "no-cache", false, "Don't use the matr cache") + fs.BoolVar(&helpFlag, "h", false, "display usage info") + fs.BoolVar(&versionFlag, "v", false, "display version") + fs.BoolVar(&noCacheFlag, "no-cache", false, "don't use the matr cache") + fs.DurationVar(&timeoutFlag, "timeout", defaultTaskTimeout, "task execution timeout; 0 disables the timeout") if err := fs.Parse(os.Args[1:]); err != nil { - fmt.Println(err) + _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } if cleanFlag { if err := clean(matrFilePath); err != nil { - fmt.Println(err) + _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } os.Exit(0) @@ -55,20 +58,19 @@ func Run() { } if versionFlag { - fmt.Printf("\nmatr version: %s\n\n", Version) + _, _ = fmt.Fprintf(os.Stdout, "\nmatr version: %s\n\n", Version) return } matrCachePath, err := build(matrFilePath, noCacheFlag) if err != nil { fs.Usage() - os.Stderr.WriteString(err.Error() + "\n") + _, _ = fmt.Fprintln(os.Stderr, err) return } - if err := run(matrCachePath, fs.Args()...); err != nil { - os.Stderr.WriteString(err.Error() + "\n") - return + if err := run(matrCachePath, timeoutFlag, fs.Args()...); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) } } @@ -83,31 +85,25 @@ func clean(matrfilePath string) error { } func parseMatrfile(path string) ([]parser.Command, error) { - var cmds []parser.Command - absPath, err := filepath.Abs(path) if err != nil { - return cmds, err + return nil, err } matrFilePath, err := getMatrfilePath(absPath) if err != nil { - return cmds, err - } - - cmds, err = parser.Parse(matrFilePath) - if err != nil { - return cmds, err + return nil, err } - return cmds, nil + return parser.Parse(matrFilePath) } -func run(matrCachePath string, args ...string) error { +func run(matrCachePath string, timeout time.Duration, args ...string) error { if _, err := os.Stat(filepath.Join(matrCachePath, "matr")); err != nil { return errors.New("matrfile has not been compiled") } c := exec.Command(filepath.Join(matrCachePath, "matr"), args...) + c.Env = append(os.Environ(), timeoutEnvVar+"="+timeout.String()) c.Stderr = os.Stderr c.Stdout = os.Stdout return c.Run() @@ -119,44 +115,41 @@ func build(matrFilePath string, noCache bool) (string, error) { return "", err } - newHash, err := getSha256(absPath) + newHash, err := buildHash(absPath) if err != nil { return "", err } matrCachePath := filepath.Join(filepath.Dir(absPath), defaultCacheFolder) - oldHash, err := os.ReadFile(filepath.Join(matrCachePath, "matrfile.sha256")) - if err == nil && !noCache { - if ok := bytes.Equal(oldHash, newHash); ok { + if err == nil && !noCache && bytes.Equal(oldHash, newHash) { + if _, statErr := os.Stat(filepath.Join(matrCachePath, "matr")); statErr == nil { return matrCachePath, nil } } if dir, err := os.Stat(matrCachePath); err != nil || !dir.IsDir() { - if err := os.Mkdir(matrCachePath, 0777); err != nil { + if err := os.Mkdir(matrCachePath, 0o777); err != nil { return "", err } } - if err := os.WriteFile(filepath.Join(matrCachePath, "matrfile.sha256"), newHash, 0644); err != nil { + if err := os.WriteFile(filepath.Join(matrCachePath, "matrfile.sha256"), newHash, 0o644); err != nil { return "", err } if !symlinkValid(matrCachePath) { - os.Remove(filepath.Join(matrCachePath, defaultMatrFile)) - if err := os.Symlink(absPath, filepath.Join(matrCachePath, defaultMatrFile)); err != nil { - if !os.IsExist(err) { - return "", err - } + _ = os.Remove(filepath.Join(matrCachePath, defaultMatrFile)) + if err := os.Symlink(absPath, filepath.Join(matrCachePath, defaultMatrFile)); err != nil && !os.IsExist(err) { + return "", err } } - f, err := os.OpenFile(filepath.Join(matrCachePath, "main.go"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + f, err := os.OpenFile(filepath.Join(matrCachePath, "main.go"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755) if err != nil { return "", err } - defer f.Close() + defer func() { _ = f.Close() }() cmds, err := parseMatrfile(absPath) if err != nil { @@ -177,19 +170,55 @@ func build(matrFilePath string, noCache bool) (string, error) { } func getSha256(path string) ([]byte, error) { - f, err := os.Open(path) + return hashFiles(path) +} + +func buildHash(matrfilePath string) ([]byte, error) { + inputs, err := buildHashInputs(matrfilePath) if err != nil { return nil, err } - defer f.Close() + return hashFiles(inputs...) +} + +func hashFiles(paths ...string) ([]byte, error) { h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return nil, err + for _, path := range paths { + f, err := os.Open(path) + if err != nil { + return nil, err + } + if _, err := io.Copy(h, f); err != nil { + _ = f.Close() + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } } - return h.Sum(nil), nil } +func buildHashInputs(matrfilePath string) ([]string, error) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + return nil, errors.New("unable to resolve build inputs") + } + + root := filepath.Dir(currentFile) + return []string{ + matrfilePath, + filepath.Join(root, "discovery.go"), + filepath.Join(root, "matr.go"), + filepath.Join(root, "task.go"), + filepath.Join(root, "template.go"), + filepath.Join(root, "timeout.go"), + filepath.Join(root, "utils.go"), + filepath.Join(root, "parser", "parser.go"), + filepath.Join(root, "parser", "strutil.go"), + }, nil +} + func getMatrfilePath(mfpath string) (string, error) { absPath, err := filepath.Abs(mfpath) if err != nil { @@ -205,11 +234,9 @@ func getMatrfilePath(mfpath string) (string, error) { } matrFilePath := filepath.Join(absPath, "Matrfile") - if _, err = os.Stat(matrFilePath + ".go"); err == nil { return matrFilePath + ".go", nil } - if _, err := os.Stat(matrFilePath); err == nil { return matrFilePath, nil } diff --git a/discovery_test.go b/discovery_test.go new file mode 100644 index 0000000..84e8de1 --- /dev/null +++ b/discovery_test.go @@ -0,0 +1,143 @@ +package matr + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestRunPassesTimeoutEnv(t *testing.T) { + t.Parallel() + + cacheDir := t.TempDir() + outputPath := filepath.Join(cacheDir, "timeout.txt") + execPath := filepath.Join(cacheDir, "matr") + + script := "#!/bin/sh\nprintf '%s' \"$MATR_TIMEOUT\" > \"$1\"\n" + if err := os.WriteFile(execPath, []byte(script), 0o755); err != nil { + t.Fatalf("write executable: %v", err) + } + + if err := run(cacheDir, 45*time.Second, outputPath); err != nil { + t.Fatalf("run returned error: %v", err) + } + + got, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output: %v", err) + } + + if string(got) != "45s" { + t.Fatalf("timeout mismatch: got %q want %q", got, "45s") + } +} + +func TestBuildRebuildsWhenBinaryMissing(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + matrfilePath := filepath.Join(dir, "Matrfile.go") + content := `//go:build matr + +package main + +import ( + "context" + + "github.com/euforic/matr" +) + +// Test runs the smoke task. +func Test(ctx context.Context, _ *matr.Invocation, args []string) error { + return nil +} +` + if err := os.WriteFile(matrfilePath, []byte(content), 0o644); err != nil { + t.Fatalf("write matrfile: %v", err) + } + + cacheDir := filepath.Join(dir, defaultCacheFolder) + if err := os.Mkdir(cacheDir, 0o755); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + hash, err := getSha256(matrfilePath) + if err != nil { + t.Fatalf("hash matrfile: %v", err) + } + if err := os.WriteFile(filepath.Join(cacheDir, "matrfile.sha256"), hash, 0o644); err != nil { + t.Fatalf("write hash: %v", err) + } + + outDir, err := build(matrfilePath, false) + if err != nil { + t.Fatalf("build returned error: %v", err) + } + + if outDir != cacheDir { + t.Fatalf("cache dir = %q, want %q", outDir, cacheDir) + } + if _, err := os.Stat(filepath.Join(cacheDir, "matr")); err != nil { + t.Fatalf("expected compiled binary: %v", err) + } +} + +func TestHashFilesChangesWhenAnyInputChanges(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + first := filepath.Join(dir, "first.txt") + second := filepath.Join(dir, "second.txt") + + if err := os.WriteFile(first, []byte("a"), 0o644); err != nil { + t.Fatalf("write first: %v", err) + } + if err := os.WriteFile(second, []byte("b"), 0o644); err != nil { + t.Fatalf("write second: %v", err) + } + + one, err := hashFiles(first, second) + if err != nil { + t.Fatalf("hash files: %v", err) + } + + if err := os.WriteFile(second, []byte("changed"), 0o644); err != nil { + t.Fatalf("rewrite second: %v", err) + } + + two, err := hashFiles(first, second) + if err != nil { + t.Fatalf("rehash files: %v", err) + } + + if string(one) == string(two) { + t.Fatal("expected hash to change when any input changes") + } +} + +func TestGetMatrfilePathResolvesDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + want := filepath.Join(dir, "Matrfile.go") + if err := os.WriteFile(want, []byte("//go:build matr\npackage main\n"), 0o644); err != nil { + t.Fatalf("write matrfile: %v", err) + } + + got, err := getMatrfilePath(dir) + if err != nil { + t.Fatalf("getMatrfilePath returned error: %v", err) + } + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestGetMatrfilePathMissingReturnsError(t *testing.T) { + t.Parallel() + + _, err := getMatrfilePath(filepath.Join(t.TempDir(), "missing")) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/examples/Matrfile.go b/examples/Matrfile.go index 4c8de62..a247993 100644 --- a/examples/Matrfile.go +++ b/examples/Matrfile.go @@ -9,20 +9,25 @@ import ( "strings" "time" - "github.com/euforic/matr/matr" + "github.com/euforic/matr" ) // Default is an example of overriding the default handler -func Default(ctx context.Context, args []string) error { +func Default(ctx context.Context, _ *matr.Invocation, args []string) error { fmt.Println("Running Custom Default HandlerFunc...") - Build(ctx, args) - return nil + return Build(ctx, &matr.Invocation{}, args) } -// Build is used as and example handler -func Build(ctx context.Context, args []string) error { - matr.Deps(ctx, Proto, Test) +// Build is used as an example handler. +// @alias:b +// @depends:proto,test +// @release:bool,short=r;build with release settings +// @timeout:duration,short=t,default=30s;override build timeout +func Build(ctx context.Context, cmd *matr.Invocation, args []string) error { fmt.Println("Building...") + fmt.Println("release:", cmd.Bool("release")) + fmt.Println("timeout:", cmd.Duration("timeout")) + fmt.Println("args:", strings.Join(args, ",")) out, err := matr.Sh(` ls -la @@ -34,47 +39,60 @@ func Build(ctx context.Context, args []string) error { } // PrintArgs prints the provided args -func PrintArgs(ctx context.Context, args []string) error { +func PrintArgs(ctx context.Context, _ *matr.Invocation, args []string) error { + _ = ctx fmt.Println("args:", "["+strings.Join(args, ",")+"]") return nil } -// Run is used as and example handler -func Run(ctx context.Context, args []string) error { - matr.Deps(ctx, Build) +// Run is used as an example handler. +// @depends:build +func Run(ctx context.Context, _ *matr.Invocation, args []string) error { + _ = ctx + _ = args fmt.Println("Running...") for { } } // notExported will run the project -func notExported(ctx context.Context, args []string) error { +func notExported(ctx context.Context, _ *matr.Invocation, args []string) error { + _ = ctx + _ = args fmt.Println("NotExported...") time.Sleep(1 * time.Second) return nil } // Proto will build the protobuf files into golang files -func Proto(ctx context.Context, args []string) error { +func Proto(ctx context.Context, _ *matr.Invocation, args []string) error { + _ = ctx + _ = args err := matr.Sh("echo \"build some proto file\"").Run() return err } // Test is used as and example handler -func Test(ctx context.Context, args []string) error { +func Test(ctx context.Context, _ *matr.Invocation, args []string) error { + _ = ctx + _ = args err := matr.Sh(`echo "Run unit tests..."`).Run() time.Sleep(1 * time.Second) return err } // Bench is used as and example handler -func Bench(ctx context.Context, args []string) error { +func Bench(ctx context.Context, _ *matr.Invocation, args []string) error { + _ = ctx + _ = args err := matr.Sh(`echo "Run benchmark......"`).Run() return err } // Docker is used as and example handler -func Docker(ctx context.Context, args []string) error { +func Docker(ctx context.Context, _ *matr.Invocation, args []string) error { + _ = ctx + _ = args err := matr.Sh(`echo "Build some docker file...."`).Run() return err } @@ -82,7 +100,9 @@ func Docker(ctx context.Context, args []string) error { // DockerCompose is used as and example handler // This is a multi line comment that should // show up in the full docs -func DockerCompose(ctx context.Context, args []string) error { +func DockerCompose(ctx context.Context, _ *matr.Invocation, args []string) error { + _ = ctx + _ = args err := matr.Sh(`echo "Build some docker-compose file...."`).Run() return err } diff --git a/go.mod b/go.mod index 84e6c27..b358a92 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/euforic/matr -go 1.19 +go 1.26 require ( github.com/google/go-cmp v0.5.9 diff --git a/matr.go b/matr.go new file mode 100644 index 0000000..ab70677 --- /dev/null +++ b/matr.go @@ -0,0 +1,314 @@ +package matr + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + "text/tabwriter" + "time" +) + +var Version = "v0.2.0" + +// Matr is the root structure. +type Matr struct { + tasks map[string]*Task + aliases map[string]string + order []string + onExit func(context.Context, error) + helpOut io.Writer + errorOut io.Writer +} + +// New creates a new Matr struct instance and returns a point to it. +func New() *Matr { + return &Matr{ + tasks: map[string]*Task{}, + aliases: map[string]string{}, + helpOut: os.Stdout, + errorOut: os.Stderr, + } +} + +// TaskNames returns the available task names in deterministic order. +func (m *Matr) TaskNames() []string { + names := append([]string(nil), m.order...) + sort.Strings(names) + return names +} + +// PrintUsage outputs usage docs to stdout. +func (m *Matr) PrintUsage(cmd string) { + var err error + + if cmd != "" { + task, ok := m.lookupTask(cmd) + if ok { + _, _ = fmt.Fprintf(m.helpOut, "matr %s:\n\n", task.Name) + _, _ = fmt.Fprintln(m.helpOut, task.Doc) + if len(task.Aliases) > 0 { + _, _ = fmt.Fprintf(m.helpOut, "\nAliases: %s\n", strings.Join(task.Aliases, ", ")) + } + if len(task.DependsOn) > 0 { + _, _ = fmt.Fprintf(m.helpOut, "Depends on: %s\n", strings.Join(task.DependsOn, ", ")) + } + if len(task.Flags) > 0 { + _, _ = fmt.Fprintln(m.helpOut, "\nFlags:") + for _, fl := range task.Flags { + _, _ = fmt.Fprintf(m.helpOut, " --%s", fl.Name) + if fl.Short != "" { + _, _ = fmt.Fprintf(m.helpOut, ", -%s", fl.Short) + } + if fl.Usage != "" { + _, _ = fmt.Fprintf(m.helpOut, "\t%s", fl.Usage) + } + var extras []string + if fl.Required { + extras = append(extras, "required") + } + if fl.Default != "" { + extras = append(extras, "default: "+fl.Default) + } + if len(extras) > 0 { + _, _ = fmt.Fprintf(m.helpOut, " (%s)", strings.Join(extras, ", ")) + } + _, _ = fmt.Fprintln(m.helpOut) + } + } + _, _ = fmt.Fprintln(m.helpOut) + return + } + err = fmt.Errorf("ERROR: no handler found for target %q", cmd) + } + + _, _ = fmt.Fprintln(m.helpOut, "\nRun Task: matr [target] [command flags] args...") + _, _ = fmt.Fprintln(m.helpOut, "\nTargets:") + tw := tabwriter.NewWriter(m.helpOut, 0, 0, 3, ' ', 0) + for _, name := range m.TaskNames() { + task := m.tasks[name] + if task.Name == "default" { + continue + } + _, _ = fmt.Fprintf(tw, "\t%s\t%s\n", task.Name, task.Summary) + } + _ = tw.Flush() + _, _ = fmt.Fprintln(m.helpOut, " ") + if err != nil { + _, _ = fmt.Fprintln(m.errorOut, err.Error()) + } +} + +// Handle registers a new task handler with matr. +func (m *Matr) Handle(task *Task) { + if task.Name == "" { + task.Name = "default" + } + key := strings.ToLower(task.Name) + if _, exists := m.tasks[key]; !exists { + m.order = append(m.order, key) + } + m.tasks[key] = task + for _, alias := range task.Aliases { + m.aliases[strings.ToLower(alias)] = key + } +} + +// Run executes the requested task function with the provided context and arguments. +func (m *Matr) Run(ctx context.Context, args ...string) error { + if len(args) == 0 { + if task, ok := m.lookupTask("default"); ok { + return m.runTask(ctx, task, nil, map[string]bool{}, map[string]bool{}) + } + m.PrintUsage("") + return nil + } + + if isHelpArg(args[0]) { + m.PrintUsage("") + return nil + } + + task, ok := m.lookupTask(args[0]) + if !ok { + _, _ = fmt.Fprintf(m.errorOut, "ERROR: no handler found for target %q\n", args[0]) + m.PrintUsage("") + return nil + } + + if len(args) > 1 && isHelpArg(args[1]) { + m.PrintUsage(task.Name) + return nil + } + + return m.runTask(ctx, task, args[1:], map[string]bool{}, map[string]bool{}) +} + +func (m *Matr) runTask(ctx context.Context, task *Task, argv []string, ran, visiting map[string]bool) error { + key := strings.ToLower(task.Name) + if ran[key] { + return nil + } + if visiting[key] { + return fmt.Errorf("dependency cycle detected at %q", task.Name) + } + visiting[key] = true + + for _, depName := range task.DependsOn { + dep, ok := m.lookupTask(depName) + if !ok { + return fmt.Errorf("unknown dependency %q for %q", depName, task.Name) + } + if err := m.runTask(ctx, dep, nil, ran, visiting); err != nil { + return err + } + } + + delete(visiting, key) + + invocation, err := newInvocation(task, argv) + if err != nil { + return err + } + + err = task.Handler(ctx, invocation, invocation.Args()) + ran[key] = true + if m.onExit != nil { + m.onExit(ctx, err) + } + return err +} + +func newInvocation(task *Task, argv []string) (*Invocation, error) { + fs := flag.NewFlagSet(task.Name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + + values := make(map[string]any, len(task.Flags)) + postParse := make([]func(), 0, len(task.Flags)) + for _, fl := range task.Flags { + after, err := registerFlag(fs, values, fl) + if err != nil { + return nil, err + } + postParse = append(postParse, after) + } + + if err := fs.Parse(argv); err != nil { + return nil, err + } + for _, fn := range postParse { + fn() + } + + for _, fl := range task.Flags { + if fl.Required && isZeroFlagValue(values[fl.Name]) { + return nil, fmt.Errorf("missing required flag --%s", fl.Name) + } + } + + return &Invocation{ + task: task, + values: values, + args: fs.Args(), + }, nil +} + +func registerFlag(fs *flag.FlagSet, values map[string]any, fl Flag) (func(), error) { + switch fl.Type { + case FlagBool: + def := false + if fl.Default != "" { + parsed, err := strconv.ParseBool(fl.Default) + if err != nil { + return nil, err + } + def = parsed + } + ptr := new(bool) + *ptr = def + fs.BoolVar(ptr, fl.Name, def, fl.Usage) + if fl.Short != "" { + fs.BoolVar(ptr, fl.Short, def, fl.Usage) + } + return func() { values[fl.Name] = *ptr }, nil + case FlagString: + ptr := new(string) + *ptr = fl.Default + fs.StringVar(ptr, fl.Name, fl.Default, fl.Usage) + if fl.Short != "" { + fs.StringVar(ptr, fl.Short, fl.Default, fl.Usage) + } + return func() { values[fl.Name] = *ptr }, nil + case FlagInt: + def := 0 + if fl.Default != "" { + parsed, err := strconv.Atoi(fl.Default) + if err != nil { + return nil, err + } + def = parsed + } + ptr := new(int) + *ptr = def + fs.IntVar(ptr, fl.Name, def, fl.Usage) + if fl.Short != "" { + fs.IntVar(ptr, fl.Short, def, fl.Usage) + } + return func() { values[fl.Name] = *ptr }, nil + case FlagDuration: + def := time.Duration(0) + if fl.Default != "" { + parsed, err := time.ParseDuration(fl.Default) + if err != nil { + return nil, err + } + def = parsed + } + ptr := new(time.Duration) + *ptr = def + fs.DurationVar(ptr, fl.Name, def, fl.Usage) + if fl.Short != "" { + fs.DurationVar(ptr, fl.Short, def, fl.Usage) + } + return func() { values[fl.Name] = *ptr }, nil + default: + return nil, fmt.Errorf("unsupported flag type %q", fl.Type) + } +} + +func isZeroFlagValue(v any) bool { + switch value := v.(type) { + case bool: + return !value + case string: + return value == "" + case int: + return value == 0 + case time.Duration: + return value == 0 + default: + return v == nil + } +} + +func isHelpArg(arg string) bool { + return arg == "-h" || arg == "--help" +} + +func (m *Matr) lookupTask(name string) (*Task, bool) { + key := strings.ToLower(name) + if target, ok := m.aliases[key]; ok { + key = target + } + task, ok := m.tasks[key] + return task, ok +} + +// OnExit executes a final function before matr exits. +func (m *Matr) OnExit(fn func(ctx context.Context, err error)) { + m.onExit = fn +} diff --git a/matr/matr.go b/matr/matr.go deleted file mode 100644 index ac9c9c0..0000000 --- a/matr/matr.go +++ /dev/null @@ -1,126 +0,0 @@ -package matr - -import ( - "context" - "errors" - "fmt" - "os" - "text/tabwriter" -) - -var Version = "v0.2.0" - -// ContextKey is used to identify matr values in the context -type ContextKey string - -const ( - ctxArgsKey ContextKey = "matr_args" -) - -// Matr is the root structure -type Matr struct { - tasks map[string]*Task - onExit func(context.Context, error) -} - -// New creates a new Matr struct instance and returns a point to it -func New() *Matr { - return &Matr{ - tasks: map[string]*Task{}, - } -} - -// TaskNames returns a silce of the available task names -func (m *Matr) TaskNames() []string { - names := []string{} - for n := range m.tasks { - names = append(names, n) - } - return names -} - -// PrintUsage is a helper function to output the usage docs to stdout -func (m *Matr) PrintUsage(cmd string) { - var err error - - if cmd != "" { - for _, c := range m.tasks { - if c.Name == cmd { - fmt.Println("matr " + cmd + " :\n") - fmt.Println(c.Doc) - fmt.Println("") - return - } - } - err = errors.New("ERROR: no handler found for target \"" + cmd + "\"") - } - - fmt.Println("\nRun Task: matr [target] args...") - - fmt.Println("\nTargets:") - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - for _, t := range m.tasks { - if t.Name == "Default" { - continue - } - fmt.Fprintf(tw, " %s\t%s\n", t.Name, t.Summary) - } - tw.Flush() - fmt.Println(" ") - if err != nil { - os.Stderr.WriteString(err.Error() + "\n") - return - } -} - -// Handle registers a new task handler with matr. The Handler will then be referenceable by the provided name, -// if a task is named "default" or "" that function will be called if no function name is provided. The -// default function is also a good place to output usage information for the available tasks. -// CallOptions can be used to allow for before and after Handler middleware functions. -func (m *Matr) Handle(task *Task) { - if task.Name == "" { - task.Name = "default" - } - m.tasks[task.Name] = task -} - -// Run will execute the requested task function with the provided context and arguments. -func (m *Matr) Run(ctx context.Context, args ...string) error { - argsLen := len(args) - if argsLen == 0 { - m.PrintUsage("") - return nil - } - - var handlerArgs []string - - if argsLen > 1 { - handlerArgs = args[1:] - - if args[1] == "-h" { - m.PrintUsage(args[0]) - return nil - } - } - - ctx = context.WithValue(ctx, ctxArgsKey, handlerArgs) - - task, ok := m.tasks[args[0]] - if !ok { - fmt.Fprintf(os.Stderr, "ERROR: no handler found for target \""+args[0]+"\"\n") - m.PrintUsage("") - return nil - } - - err := task.Handler(ctx, handlerArgs) - if m.onExit != nil { - m.onExit(ctx, err) - } - - return err -} - -// OnExit executes a final function before matr exits -func (m *Matr) OnExit(fn func(ctx context.Context, err error)) { - m.onExit = fn -} diff --git a/matr/task.go b/matr/task.go deleted file mode 100644 index 38aa339..0000000 --- a/matr/task.go +++ /dev/null @@ -1,16 +0,0 @@ -package matr - -import ( - "context" -) - -// Task struct that holds registered handler -type Task struct { - Name string - Summary string - Doc string - Handler HandlerFunc -} - -// The HandlerFunc type is an adapter to allow the use of ordinary functions as a matr task Handler. -type HandlerFunc func(c context.Context, args []string) error diff --git a/matr/template.go b/matr/template.go deleted file mode 100644 index 98572d1..0000000 --- a/matr/template.go +++ /dev/null @@ -1,81 +0,0 @@ -package matr - -import ( - "io" - "strings" - "text/template" - - "github.com/euforic/matr/parser" - "golang.org/x/text/cases" -) - -const defaultTemplate = `//go:build matr - -package main - -import ( - "context" - "os" - "os/signal" - "syscall" - "time" - - "github.com/euforic/matr/matr" -) - -func main() { - // Create new Matr instance - m := matr.New() - - {{- range .}} - {{if .IsExported }} - // {{if .Summary}}{{.Summary}}{{else}}{{.Name}}{{end}} - m.Handle(&matr.Task{ - Name: "{{cmdname .Name}}", - Summary: "{{trim .Summary}}", - Doc: ` + "`{{trim .Doc}}`," + ` - Handler: {{.Name}}, - }) - {{- end -}} - {{- end}} - - // Setup context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - // Setup signal handling for SIGINT and SIGTERM - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - - // Run Matr in a separate goroutine - errChan := make(chan error) - go func() { - errChan <- m.Run(ctx, os.Args[1:]...) - }() - - // Wait for Matr to finish, a timeout, or a signal - select { - case err := <-errChan: - if err != nil { - os.Stderr.WriteString("ERROR: " + err.Error() + "\n") - } - case <-ctx.Done(): - os.Stderr.WriteString("ERROR: Context timed out\n") - case <-sig: - cancel() - os.Stderr.WriteString("INFO: Received signal, shutting down\n") - } -}` - -// generate ... -func generate(cmds []parser.Command, w io.Writer) error { - // Create a new template and parse the letter into it. - t := template.Must(template.New("matr").Funcs(template.FuncMap{ - "title": cases.Title, - "trim": strings.TrimSpace, - "cmdname": func(name string) string { - return parser.LowerFirst(parser.CamelToHyphen(name)) - }, - }).Parse(defaultTemplate)) - return t.Execute(w, cmds) -} diff --git a/matr/utils.go b/matr/utils.go deleted file mode 100644 index 21dcf40..0000000 --- a/matr/utils.go +++ /dev/null @@ -1,54 +0,0 @@ -package matr - -import ( - "context" - "fmt" - "os/exec" -) - -type Cmd struct { - *exec.Cmd -} - -// Args returns the handler args from the context -func Args(ctx context.Context) []string { - args, ok := ctx.Value(ctxArgsKey).([]string) - if !ok { - return []string{} - } - return args -} - -// Arg returns the handler arg at the given position from the context -func Arg(ctx context.Context, idx int, defaultStr string) (string, bool) { - args, ok := ctx.Value(ctxArgsKey).([]string) - if !ok { - return defaultStr, false - } - if len(args) < idx+1 { - return defaultStr, false - } - return args[idx], true -} - -// Deps is a helper function to run the given dependent handlers -func Deps(ctx context.Context, fns ...HandlerFunc) error { - var err error - args := Args(ctx) - - for _, fn := range fns { - err := fn(ctx, args) - if err != nil { - return err - } - } - - return err -} - -// Sh is a helper function for executing shell commands -func Sh(cmdStr string, args ...interface{}) *Cmd { - cmdStr = fmt.Sprintf(cmdStr, args...) - c := exec.Command("sh", "-c", cmdStr) - return &Cmd{Cmd: c} -} diff --git a/parser/metadata_test.go b/parser/metadata_test.go new file mode 100644 index 0000000..39a97cc --- /dev/null +++ b/parser/metadata_test.go @@ -0,0 +1,170 @@ +package parser + +import ( + "os" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestParseCommandMetadata(t *testing.T) { + t.Parallel() + + path := writeTempGoFile(t, `//go:build matr + +package main + +import "context" + +// Build compiles the project. +// It produces an artifact. +// @alias:b +// @depends:test,proto +// @release:bool,short=r;build with release settings +// @timeout:duration,short=t,default=30s;override build timeout +// @token:string,required;API token used for deploys +func Build(ctx context.Context, cmd any, args []string) error { + return nil +} + +// Default runs when no command is provided. +func Default(ctx context.Context, cmd any, args []string) error { + return nil +} +`) + + cmds, err := Parse(path) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + + want := []Command{ + { + Name: "Build", + Summary: "Build compiles the project.", + Doc: "Build compiles the project.\nIt produces an artifact.", + IsExported: true, + Aliases: []string{"b"}, + DependsOn: []string{"test", "proto"}, + Flags: []Flag{ + {Name: "release", Type: "bool", Short: "r", Usage: "build with release settings"}, + {Name: "timeout", Type: "duration", Short: "t", Default: "30s", Usage: "override build timeout"}, + {Name: "token", Type: "string", Required: true, Usage: "API token used for deploys"}, + }, + }, + { + Name: "Default", + Summary: "Default runs when no command is provided.", + Doc: "Default runs when no command is provided.", + IsExported: true, + IsDefault: true, + }, + } + + if diff := cmp.Diff(want, cmds); diff != "" { + t.Fatalf("Parse mismatch (-want +got):\n%s", diff) + } +} + +func TestParseRejectsInvalidFlagMetadata(t *testing.T) { + t.Parallel() + + path := writeTempGoFile(t, `//go:build matr + +package main + +import "context" + + // Build compiles the project. + // @timeout:nope;invalid type + func Build(ctx context.Context, cmd any, args []string) error { + return nil + } +`) + + _, err := Parse(path) + if err == nil || !strings.Contains(err.Error(), "invalid flag type") { + t.Fatalf("expected invalid flag type error, got %v", err) + } +} + +func TestParseRejectsInvalidDependencyMetadata(t *testing.T) { + t.Parallel() + + path := writeTempGoFile(t, `//go:build matr + +package main + +import "context" + +// Build compiles the project. +// @depends:test, +func Build(ctx context.Context, cmd any, args []string) error { + return nil +} +`) + + _, err := Parse(path) + if err == nil || !strings.Contains(err.Error(), "invalid dependency metadata") { + t.Fatalf("expected invalid dependency metadata error, got %v", err) + } +} + +func TestParseRejectsInvalidFlagOption(t *testing.T) { + t.Parallel() + + path := writeTempGoFile(t, `//go:build matr + +package main + +import "context" + +// Build compiles the project. +// @timeout:duration,wat;invalid option +func Build(ctx context.Context, cmd any, args []string) error { + return nil +} +`) + + _, err := Parse(path) + if err == nil || !strings.Contains(err.Error(), "invalid flag option") { + t.Fatalf("expected invalid flag option error, got %v", err) + } +} + +func TestParseReturnsErrorForInvalidGo(t *testing.T) { + t.Parallel() + + path := writeTempGoFile(t, `//go:build matr + +package main + +func Build(`) + + _, err := Parse(path) + if err == nil { + t.Fatal("expected parse error") + } +} + +func writeTempGoFile(t *testing.T, content string) string { + t.Helper() + + tmpFile, err := os.CreateTemp("", "parser_metadata_test_*.go") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + t.Cleanup(func() { + _ = os.Remove(tmpFile.Name()) + }) + + if _, err := tmpFile.WriteString(content); err != nil { + t.Fatalf("write temp file: %v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("close temp file: %v", err) + } + + return tmpFile.Name() +} diff --git a/parser/parser.go b/parser/parser.go index 8a97e18..552fc5f 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -2,69 +2,171 @@ package parser import ( "errors" + "fmt" "go/ast" - "go/parser" + goparser "go/parser" "go/token" + "slices" "strings" ) -// Command defines a matr cmd +// Command defines a parsed matr command. type Command struct { Name string Summary string Doc string IsExported bool + IsDefault bool + Aliases []string + DependsOn []string + Flags []Flag } -// Param defines a matr HandlerFunc parameter -type Param struct { - Name string - Type string +// Flag describes a command-scoped flag extracted from Matrfile comments. +type Flag struct { + Name string + Type string + Short string + Default string + Required bool + Usage string } -// Parse parses a matr file and returns a list of commands +var validFlagTypes = []string{"bool", "string", "int", "duration"} + +// Parse parses a matr file and returns a list of commands. func Parse(file string) ([]Command, error) { - // Create the AST by parsing src. - fset := token.NewFileSet() // positions are relative to fset - f, err := parser.ParseFile(fset, file, nil, 4) + cmds := []Command{} + fset := token.NewFileSet() + f, err := goparser.ParseFile(fset, file, nil, goparser.ParseComments) if err != nil { - panic(err) + return cmds, err } - funcs := []Command{} if len(f.Comments) == 0 || f.Comments[0].Pos() != 1 || len(f.Comments[0].List) == 0 || f.Comments[0].List[0].Text != "//go:build matr" { - return funcs, errors.New("invalid Matrfile: matr build tag missing or incorrect") + return cmds, errors.New("invalid Matrfile: matr build tag missing or incorrect") } - for _, d := range f.Decls { - t, ok := d.(*ast.FuncDecl) + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) if !ok { continue } - funcs = append(funcs, parseCmd(t)) + cmd, err := parseCmd(fn) + if err != nil { + return nil, err + } + cmds = append(cmds, cmd) } - return funcs, nil + return cmds, nil } -func parseCmd(t *ast.FuncDecl) Command { +func parseCmd(fn *ast.FuncDecl) (Command, error) { cmd := Command{ - Name: t.Name.String(), - IsExported: ast.IsExported(t.Name.String()), + Name: fn.Name.Name, + IsExported: ast.IsExported(fn.Name.Name), + IsDefault: fn.Name.Name == "Default", + } + + if fn.Doc == nil { + return cmd, nil } - if t.Doc != nil && len(t.Doc.List) > 0 { - d := []string{} - for _, ds := range t.Doc.List { - d = append(d, strings.Replace(ds.Text, "//", "", 1)) + var docLines []string + for _, comment := range fn.Doc.List { + line := strings.TrimSpace(strings.TrimPrefix(comment.Text, "//")) + if line == "" { + continue + } + if strings.HasPrefix(line, "@") { + if err := applyMetadata(&cmd, line); err != nil { + return Command{}, fmt.Errorf("%s: %w", cmd.Name, err) + } + continue } + docLines = append(docLines, line) + } + + if len(docLines) > 0 { + cmd.Summary = docLines[0] + cmd.Doc = strings.Join(docLines, "\n") + } - cmd.Summary = strings.TrimLeft(d[0], " ") - cmd.Doc = strings.TrimLeft(strings.Join(d, "\n"), " ") + return cmd, nil +} + +func applyMetadata(cmd *Command, line string) error { + switch { + case strings.HasPrefix(line, "@alias:"): + alias := strings.TrimSpace(strings.TrimPrefix(line, "@alias:")) + if alias == "" { + return errors.New("invalid alias metadata") + } + cmd.Aliases = append(cmd.Aliases, alias) + return nil + case strings.HasPrefix(line, "@depends:"): + raw := strings.TrimSpace(strings.TrimPrefix(line, "@depends:")) + if raw == "" { + return errors.New("invalid dependency metadata") + } + for dep := range strings.SplitSeq(raw, ",") { + dep = strings.TrimSpace(dep) + if dep == "" { + return errors.New("invalid dependency metadata") + } + cmd.DependsOn = append(cmd.DependsOn, dep) + } + return nil + default: + flag, err := parseFlag(line) + if err != nil { + return err + } + cmd.Flags = append(cmd.Flags, flag) + return nil + } +} + +func parseFlag(line string) (Flag, error) { + meta, usage, _ := strings.Cut(strings.TrimPrefix(line, "@"), ";") + name, rest, ok := strings.Cut(meta, ":") + if !ok || strings.TrimSpace(name) == "" { + return Flag{}, errors.New("invalid flag metadata") + } + + parts := strings.Split(rest, ",") + flag := Flag{ + Name: strings.TrimSpace(name), + Type: strings.TrimSpace(parts[0]), + Usage: strings.TrimSpace(usage), + } + if !slices.Contains(validFlagTypes, flag.Type) { + return Flag{}, fmt.Errorf("invalid flag type %q", flag.Type) + } + + for _, raw := range parts[1:] { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + switch { + case raw == "required": + flag.Required = true + case strings.HasPrefix(raw, "short="): + flag.Short = strings.TrimSpace(strings.TrimPrefix(raw, "short=")) + if flag.Short == "" { + return Flag{}, errors.New("invalid short flag metadata") + } + case strings.HasPrefix(raw, "default="): + flag.Default = strings.TrimSpace(strings.TrimPrefix(raw, "default=")) + default: + return Flag{}, fmt.Errorf("invalid flag option %q", raw) + } } - return cmd + return flag, nil } diff --git a/parser/parser_test.go b/parser/parser_test.go index dc72827..1a821cf 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -61,14 +61,18 @@ func TestFunc2(ctx context.Context, args []string) { if err != nil { t.Fatalf("Failed to create temporary file: %v", err) } - defer os.Remove(tmpFile.Name()) + t.Cleanup(func() { + _ = os.Remove(tmpFile.Name()) + }) // Write the input content to the temporary file _, err = tmpFile.WriteString(tc.input) if err != nil { t.Fatalf("Failed to write to temporary file: %v", err) } - tmpFile.Close() + if err := tmpFile.Close(); err != nil { + t.Fatalf("Failed to close temporary file: %v", err) + } cmds, err := Parse(tmpFile.Name()) diff --git a/runtime_test.go b/runtime_test.go new file mode 100644 index 0000000..c707c6a --- /dev/null +++ b/runtime_test.go @@ -0,0 +1,118 @@ +package matr + +import ( + "context" + "errors" + "reflect" + "testing" + "time" +) + +func TestRunParsesFlagsAndDependencies(t *testing.T) { + t.Parallel() + + var order []string + + m := New() + m.Handle(&Task{ + Name: "proto", + Summary: "Generate code", + DependsOn: []string{"test"}, + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + order = append(order, "proto") + if len(args) != 0 { + t.Fatalf("proto args = %v, want none", args) + } + return nil + }, + }) + m.Handle(&Task{ + Name: "test", + Summary: "Run tests", + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + order = append(order, "test") + return nil + }, + }) + m.Handle(&Task{ + Name: "build", + Summary: "Build project", + Aliases: []string{"b"}, + DependsOn: []string{"proto", "test"}, + Flags: []Flag{ + {Name: "release", Type: FlagBool, Short: "r", Usage: "build with release settings"}, + {Name: "timeout", Type: FlagDuration, Default: "30s", Usage: "override timeout"}, + {Name: "token", Type: FlagString, Required: true, Usage: "API token"}, + }, + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + order = append(order, "build") + if !cmd.Bool("release") { + t.Fatal("release flag not parsed") + } + if got := cmd.Duration("timeout"); got != 45*time.Second { + t.Fatalf("timeout = %v, want %v", got, 45*time.Second) + } + if got := cmd.String("token"); got != "secret" { + t.Fatalf("token = %q, want %q", got, "secret") + } + if !reflect.DeepEqual(args, []string{"target"}) { + t.Fatalf("args = %v, want %v", args, []string{"target"}) + } + return nil + }, + }) + + err := m.Run(context.Background(), "b", "--release", "--timeout=45s", "--token=secret", "target") + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + + if got, want := order, []string{"test", "proto", "build"}; !reflect.DeepEqual(got, want) { + t.Fatalf("execution order = %v, want %v", got, want) + } +} + +func TestRunFailsForMissingRequiredFlag(t *testing.T) { + t.Parallel() + + m := New() + m.Handle(&Task{ + Name: "deploy", + Flags: []Flag{ + {Name: "token", Type: FlagString, Required: true, Usage: "API token"}, + }, + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + return nil + }, + }) + + err := m.Run(context.Background(), "deploy") + if err == nil || err.Error() == "" { + t.Fatalf("expected missing required flag error, got %v", err) + } +} + +func TestRunStopsOnDependencyError(t *testing.T) { + t.Parallel() + + m := New() + m.Handle(&Task{ + Name: "prep", + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + return errors.New("boom") + }, + }) + m.Handle(&Task{ + Name: "build", + DependsOn: []string{"prep"}, + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + t.Fatal("build should not run") + return nil + }, + }) + + err := m.Run(context.Background(), "build") + if err == nil || err.Error() != "boom" { + t.Fatalf("expected dependency error, got %v", err) + } +} diff --git a/task.go b/task.go new file mode 100644 index 0000000..ff1ebf9 --- /dev/null +++ b/task.go @@ -0,0 +1,78 @@ +package matr + +import ( + "context" + "time" +) + +// Task struct that holds registered handler. +type Task struct { + Name string + Summary string + Doc string + Aliases []string + DependsOn []string + Flags []Flag + Handler HandlerFunc +} + +// The HandlerFunc type is an adapter to allow the use of ordinary functions as a matr task Handler. +type HandlerFunc func(c context.Context, cmd *Invocation, args []string) error + +type FlagType string + +const ( + FlagBool FlagType = "bool" + FlagString FlagType = "string" + FlagInt FlagType = "int" + FlagDuration FlagType = "duration" +) + +type Flag struct { + Name string + Type FlagType + Short string + Default string + Required bool + Usage string +} + +type Invocation struct { + task *Task + values map[string]any + args []string +} + +func (i *Invocation) Name() string { + if i == nil || i.task == nil { + return "" + } + return i.task.Name +} + +func (i *Invocation) Args() []string { + if i == nil { + return nil + } + return append([]string(nil), i.args...) +} + +func (i *Invocation) Bool(name string) bool { + v, _ := i.values[name].(bool) + return v +} + +func (i *Invocation) String(name string) string { + v, _ := i.values[name].(string) + return v +} + +func (i *Invocation) Int(name string) int { + v, _ := i.values[name].(int) + return v +} + +func (i *Invocation) Duration(name string) time.Duration { + v, _ := i.values[name].(time.Duration) + return v +} diff --git a/template.go b/template.go new file mode 100644 index 0000000..253cb68 --- /dev/null +++ b/template.go @@ -0,0 +1,101 @@ +package matr + +import ( + "io" + "strings" + "text/template" + + "github.com/euforic/matr/parser" +) + +const defaultTemplate = `//go:build matr + +package main + +import ( + "os" + "os/signal" + "syscall" + + "github.com/euforic/matr" +) + +func main() { + m := matr.New() + + {{- range .}} + {{if .IsExported }} + m.Handle(&matr.Task{ + Name: "{{cmdname .Name}}", + Summary: "{{trim .Summary}}", + Doc: ` + "`{{trim .Doc}}`," + ` + Aliases: []string{ {{- range $i, $a := .Aliases}}{{if $i}}, {{end}}"{{$a}}"{{end}} }, + DependsOn: []string{ {{- range $i, $d := .DependsOn}}{{if $i}}, {{end}}"{{$d}}"{{end}} }, + Flags: []matr.Flag{ + {{- range .Flags }} + { + Name: {{ printf "%q" .Name }}, + Type: {{ flagType .Type }}, + Short: {{ printf "%q" .Short }}, + Default: {{ printf "%q" .Default }}, + Required: {{ .Required }}, + Usage: {{ printf "%q" .Usage }}, + }, + {{- end }} + }, + Handler: {{.Name}}, + }) + {{- end -}} + {{- end}} + + ctx, cancel, err := matr.ContextWithTimeoutValue(os.Getenv("` + timeoutEnvVar + `")) + if err != nil { + _, _ = os.Stderr.WriteString("ERROR: " + err.Error() + "\n") + os.Exit(1) + } + defer cancel() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + + errChan := make(chan error) + go func() { + errChan <- m.Run(ctx, os.Args[1:]...) + }() + + select { + case err := <-errChan: + if err != nil { + _, _ = os.Stderr.WriteString("ERROR: " + err.Error() + "\n") + } + case <-ctx.Done(): + _, _ = os.Stderr.WriteString("ERROR: Context timed out\n") + case <-sig: + cancel() + _, _ = os.Stderr.WriteString("INFO: Received signal, shutting down\n") + } +}` + +func generate(cmds []parser.Command, w io.Writer) error { + t := template.Must(template.New("matr").Funcs(template.FuncMap{ + "trim": strings.TrimSpace, + "cmdname": func(name string) string { + return parser.LowerFirst(parser.CamelToHyphen(name)) + }, + "flagType": func(name string) string { + switch name { + case "bool": + return "matr.FlagBool" + case "string": + return "matr.FlagString" + case "int": + return "matr.FlagInt" + case "duration": + return "matr.FlagDuration" + default: + return `matr.FlagType("` + name + `")` + } + }, + }).Parse(defaultTemplate)) + return t.Execute(w, cmds) +} diff --git a/timeout.go b/timeout.go new file mode 100644 index 0000000..f8e6569 --- /dev/null +++ b/timeout.go @@ -0,0 +1,38 @@ +package matr + +import ( + "context" + "fmt" + "time" +) + +const ( + timeoutEnvVar = "MATR_TIMEOUT" + defaultTaskTimeout = 5 * time.Minute +) + +// ContextWithTimeoutValue builds the runner context from the configured timeout value. +// An empty value uses the default timeout and zero disables the deadline. +func ContextWithTimeoutValue(timeoutValue string) (context.Context, context.CancelFunc, error) { + if timeoutValue == "" { + ctx, cancel := context.WithTimeout(context.Background(), defaultTaskTimeout) + return ctx, cancel, nil + } + + timeout, err := time.ParseDuration(timeoutValue) + if err != nil { + return nil, nil, fmt.Errorf("invalid timeout %q: %w", timeoutValue, err) + } + + if timeout < 0 { + return nil, nil, fmt.Errorf("invalid timeout %q: must be >= 0", timeoutValue) + } + + if timeout == 0 { + ctx, cancel := context.WithCancel(context.Background()) + return ctx, cancel, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + return ctx, cancel, nil +} diff --git a/timeout_test.go b/timeout_test.go new file mode 100644 index 0000000..0caf82e --- /dev/null +++ b/timeout_test.go @@ -0,0 +1,92 @@ +package matr + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestContextWithTimeoutValue(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + value string + wantErr string + wantDeadline bool + minUntil time.Duration + maxUntil time.Duration + }{ + { + name: "default timeout", + value: "", + wantDeadline: true, + minUntil: 4 * time.Minute, + maxUntil: 6 * time.Minute, + }, + { + name: "custom timeout", + value: "30s", + wantDeadline: true, + minUntil: 29 * time.Second, + maxUntil: 31 * time.Second, + }, + { + name: "disabled timeout", + value: "0s", + wantDeadline: false, + }, + { + name: "invalid timeout", + value: "later", + wantErr: "invalid timeout", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel, err := ContextWithTimeoutValue(tc.value) + if cancel != nil { + defer cancel() + } + + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got %v", tc.wantErr, err) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ctx == nil { + t.Fatal("expected context") + } + + assertDeadline(t, ctx, tc.wantDeadline, tc.minUntil, tc.maxUntil) + }) + } +} + +func assertDeadline(t *testing.T, ctx context.Context, wantDeadline bool, minUntil, maxUntil time.Duration) { + t.Helper() + + deadline, ok := ctx.Deadline() + if ok != wantDeadline { + t.Fatalf("deadline presence mismatch: want %v got %v", wantDeadline, ok) + } + + if !wantDeadline { + return + } + + until := time.Until(deadline) + if until < minUntil || until > maxUntil { + t.Fatalf("deadline out of range: got %v want between %v and %v", until, minUntil, maxUntil) + } +} diff --git a/usage_test.go b/usage_test.go new file mode 100644 index 0000000..6d6001e --- /dev/null +++ b/usage_test.go @@ -0,0 +1,99 @@ +package matr + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestPrintUsageIncludesAliasesDependenciesAndFlagMetadata(t *testing.T) { + t.Parallel() + + m := New() + var help bytes.Buffer + var errs bytes.Buffer + m.helpOut = &help + m.errorOut = &errs + + m.Handle(&Task{ + Name: "build", + Summary: "Build the project", + Doc: "Build the project.", + Aliases: []string{"b"}, + DependsOn: []string{"test", "proto"}, + Flags: []Flag{ + {Name: "release", Type: FlagBool, Short: "r", Usage: "build with release settings"}, + {Name: "token", Type: FlagString, Required: true, Usage: "API token"}, + {Name: "timeout", Type: FlagDuration, Default: "30s", Usage: "override build timeout"}, + }, + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + return nil + }, + }) + + m.PrintUsage("build") + + output := help.String() + for _, want := range []string{ + "Aliases: b", + "Depends on: test, proto", + "--release, -r", + "--token", + "required", + "default: 30s", + } { + if !strings.Contains(output, want) { + t.Fatalf("help output missing %q:\n%s", want, output) + } + } +} + +func TestRunInvokesDefaultTaskWithoutArgs(t *testing.T) { + t.Parallel() + + called := false + m := New() + m.Handle(&Task{ + Name: "default", + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + called = true + if len(args) != 0 { + t.Fatalf("default args = %v, want none", args) + } + return nil + }, + }) + + if err := m.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if !called { + t.Fatal("default task was not called") + } +} + +func TestRunFailsOnDependencyCycle(t *testing.T) { + t.Parallel() + + m := New() + m.Handle(&Task{ + Name: "build", + DependsOn: []string{"test"}, + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + return nil + }, + }) + m.Handle(&Task{ + Name: "test", + DependsOn: []string{"build"}, + Handler: func(ctx context.Context, cmd *Invocation, args []string) error { + return nil + }, + }) + + err := m.Run(context.Background(), "build") + if err == nil || !strings.Contains(err.Error(), "dependency cycle") { + t.Fatalf("expected dependency cycle error, got %v", err) + } +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..dca247e --- /dev/null +++ b/utils.go @@ -0,0 +1,17 @@ +package matr + +import ( + "fmt" + "os/exec" +) + +type Cmd struct { + *exec.Cmd +} + +// Sh is a helper function for executing shell commands. +func Sh(cmdStr string, args ...any) *Cmd { + cmdStr = fmt.Sprintf(cmdStr, args...) + c := exec.Command("sh", "-c", cmdStr) + return &Cmd{Cmd: c} +} From 4eec1466e95a34b085ab1806dec8c26fbd7058aa Mon Sep 17 00:00:00 2001 From: euforic Date: Fri, 20 Mar 2026 14:40:16 -0700 Subject: [PATCH 2/4] ci: refresh workflow action versions --- .github/workflows/go.yml | 10 +++++----- .github/workflows/release.yml | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e34daa1..01626aa 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,18 +1,18 @@ name: Go on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6.0.2 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v6.3.0 with: - go-version: 1.19 + go-version-file: go.mod - name: Build run: go build -v ./... - name: Test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44b2c2d..5882d54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,18 +9,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v2 + uses: actions/checkout@v6.0.2 with: fetch-depth: 0 # See: https://goreleaser.com/ci/actions/ - - name: Set up Go 1.19 - uses: actions/setup-go@v2 + - name: Set up Go + uses: actions/setup-go@v6.3.0 with: - go-version: 1.19 + go-version-file: go.mod id: go - name: Run GoReleaser - uses: goreleaser/goreleaser-action@master + uses: goreleaser/goreleaser-action@v6.4.0 with: version: latest - args: release --rm-dist + args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 5259f2f226ab418b6777dbcce1fcaa9c71eb54d5 Mon Sep 17 00:00:00 2001 From: euforic Date: Fri, 20 Mar 2026 14:40:51 -0700 Subject: [PATCH 3/4] ci: bump goreleaser action --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5882d54..7ae95dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: go-version-file: go.mod id: go - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.4.0 + uses: goreleaser/goreleaser-action@v7.0.0 with: version: latest args: release --clean From 80d0410d86617d8f6583649c0cb79187abb6e929 Mon Sep 17 00:00:00 2001 From: euforic Date: Fri, 20 Mar 2026 14:46:46 -0700 Subject: [PATCH 4/4] fix: tighten review follow-ups --- discovery.go | 18 +++++------------ discovery_test.go | 40 ++++++++++++++++++++++++++++++++++++++ matr.go | 6 ++++-- parser/metadata_test.go | 43 +++++++++++++++++++++++++++++++++++++++++ parser/parser.go | 6 ++++++ template.go | 3 +++ usage_test.go | 21 ++++++++++++++++++++ 7 files changed, 122 insertions(+), 15 deletions(-) diff --git a/discovery.go b/discovery.go index 352a595..6f0508c 100644 --- a/discovery.go +++ b/discovery.go @@ -10,7 +10,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "time" "github.com/euforic/matr/parser" @@ -66,11 +65,12 @@ func Run() { if err != nil { fs.Usage() _, _ = fmt.Fprintln(os.Stderr, err) - return + os.Exit(1) } if err := run(matrCachePath, timeoutFlag, fs.Args()...); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) } } @@ -200,22 +200,14 @@ func hashFiles(paths ...string) ([]byte, error) { } func buildHashInputs(matrfilePath string) ([]string, error) { - _, currentFile, _, ok := runtime.Caller(0) - if !ok { + execPath, err := os.Executable() + if err != nil { return nil, errors.New("unable to resolve build inputs") } - root := filepath.Dir(currentFile) return []string{ matrfilePath, - filepath.Join(root, "discovery.go"), - filepath.Join(root, "matr.go"), - filepath.Join(root, "task.go"), - filepath.Join(root, "template.go"), - filepath.Join(root, "timeout.go"), - filepath.Join(root, "utils.go"), - filepath.Join(root, "parser", "parser.go"), - filepath.Join(root, "parser", "strutil.go"), + execPath, }, nil } diff --git a/discovery_test.go b/discovery_test.go index 84e8de1..c28916b 100644 --- a/discovery_test.go +++ b/discovery_test.go @@ -3,6 +3,7 @@ package matr import ( "os" "path/filepath" + "strings" "testing" "time" ) @@ -141,3 +142,42 @@ func TestGetMatrfilePathMissingReturnsError(t *testing.T) { t.Fatal("expected error") } } + +func TestRunReturnsErrorWhenGeneratedTaskFails(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + matrfilePath := filepath.Join(dir, "Matrfile.go") + content := `//go:build matr + +package main + +import ( + "context" + "errors" + + "github.com/euforic/matr" +) + +// Fail always returns an error. +func Fail(ctx context.Context, _ *matr.Invocation, args []string) error { + return errors.New("boom") +} +` + if err := os.WriteFile(matrfilePath, []byte(content), 0o644); err != nil { + t.Fatalf("write matrfile: %v", err) + } + + cacheDir, err := build(matrfilePath, true) + if err != nil { + t.Fatalf("build returned error: %v", err) + } + + err = run(cacheDir, time.Second, "fail") + if err == nil { + t.Fatal("expected generated runner error") + } + if !strings.Contains(err.Error(), "exit status") { + t.Fatalf("expected exit status error, got %v", err) + } +} diff --git a/matr.go b/matr.go index ab70677..07d18b4 100644 --- a/matr.go +++ b/matr.go @@ -2,6 +2,7 @@ package matr import ( "context" + "errors" "flag" "fmt" "io" @@ -135,9 +136,10 @@ func (m *Matr) Run(ctx context.Context, args ...string) error { task, ok := m.lookupTask(args[0]) if !ok { - _, _ = fmt.Fprintf(m.errorOut, "ERROR: no handler found for target %q\n", args[0]) + msg := fmt.Sprintf("no handler found for target %q", args[0]) + _, _ = fmt.Fprintf(m.errorOut, "ERROR: %s\n", msg) m.PrintUsage("") - return nil + return errors.New(msg) } if len(args) > 1 && isHelpArg(args[1]) { diff --git a/parser/metadata_test.go b/parser/metadata_test.go index 39a97cc..a9540be 100644 --- a/parser/metadata_test.go +++ b/parser/metadata_test.go @@ -133,6 +133,49 @@ func Build(ctx context.Context, cmd any, args []string) error { } } +func TestParseRejectsInvalidShortFlagMetadata(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + meta string + wantErr string + }{ + { + name: "multiple chars", + meta: "@timeout:duration,short=ab;invalid short", + wantErr: "single character", + }, + { + name: "hyphen", + meta: "@timeout:duration,short=-;invalid short", + wantErr: "cannot start with '-'", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + path := writeTempGoFile(t, `//go:build matr + +package main + +import "context" + +// Build compiles the project. +// `+tc.meta+` +func Build(ctx context.Context, cmd any, args []string) error { + return nil +} +`) + + _, err := Parse(path) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected %q error, got %v", tc.wantErr, err) + } + }) + } +} + func TestParseReturnsErrorForInvalidGo(t *testing.T) { t.Parallel() diff --git a/parser/parser.go b/parser/parser.go index 552fc5f..42772a3 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -161,6 +161,12 @@ func parseFlag(line string) (Flag, error) { if flag.Short == "" { return Flag{}, errors.New("invalid short flag metadata") } + if len(flag.Short) != 1 { + return Flag{}, errors.New("short flag must be a single character") + } + if flag.Short[0] == '-' { + return Flag{}, errors.New("short flag cannot start with '-'") + } case strings.HasPrefix(raw, "default="): flag.Default = strings.TrimSpace(strings.TrimPrefix(raw, "default=")) default: diff --git a/template.go b/template.go index 253cb68..a9f5684 100644 --- a/template.go +++ b/template.go @@ -67,12 +67,15 @@ func main() { case err := <-errChan: if err != nil { _, _ = os.Stderr.WriteString("ERROR: " + err.Error() + "\n") + os.Exit(1) } case <-ctx.Done(): _, _ = os.Stderr.WriteString("ERROR: Context timed out\n") + os.Exit(1) case <-sig: cancel() _, _ = os.Stderr.WriteString("INFO: Received signal, shutting down\n") + os.Exit(130) } }` diff --git a/usage_test.go b/usage_test.go index 6d6001e..918d2fa 100644 --- a/usage_test.go +++ b/usage_test.go @@ -97,3 +97,24 @@ func TestRunFailsOnDependencyCycle(t *testing.T) { t.Fatalf("expected dependency cycle error, got %v", err) } } + +func TestRunReturnsErrorForUnknownTask(t *testing.T) { + t.Parallel() + + m := New() + var help bytes.Buffer + var errs bytes.Buffer + m.helpOut = &help + m.errorOut = &errs + + err := m.Run(context.Background(), "missing") + if err == nil || !strings.Contains(err.Error(), `no handler found for target "missing"`) { + t.Fatalf("expected unknown task error, got %v", err) + } + if !strings.Contains(errs.String(), `ERROR: no handler found for target "missing"`) { + t.Fatalf("stderr missing unknown task message:\n%s", errs.String()) + } + if !strings.Contains(help.String(), "Targets:") { + t.Fatalf("usage output missing command list:\n%s", help.String()) + } +}