diff --git a/packages/go/driver/cmd/fmtkit-go/main.go b/packages/go/driver/cmd/fmtkit-go/main.go index c3e094d..5429c6f 100644 --- a/packages/go/driver/cmd/fmtkit-go/main.go +++ b/packages/go/driver/cmd/fmtkit-go/main.go @@ -1,14 +1,15 @@ +// Command fmtkit-go is the standalone Go formatter CLI. Its command surface +// lives in internal/app (app.GoCLI); this entrypoint only carries the version +// stamped in by -X main.version and the signal handling. package main import ( "context" - "fmt" - "io" "os" "os/signal" "syscall" - "go.ollin.sh/fmtkit/driver/internal/cli" + "go.ollin.sh/fmtkit/driver/internal/app" ) var version = "dev" @@ -18,49 +19,10 @@ func main() { // os.Exit skips deferred calls, so release the signal handler explicitly // before exiting with the captured code. - code := run(ctx, os.Args[1:], os.Stdout, os.Stderr) + code := app. + GoCLI(version, os.Stdout, os.Stderr). + Dispatch(ctx, os.Args[1:]) stop() os.Exit(code) } - -func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { - printUsage(stderr) - - return 1 - } - - switch args[0] { - case "check": - return cli. - NewRunner(stdout, stderr). - Run(ctx, cli.CheckMode, args[1:]) - case "format": - return cli. - NewRunner(stdout, stderr). - Run(ctx, cli.FormatMode, args[1:]) - case "sources": - return cli.RunSources(ctx, args[1:], stdout, stderr) - case "version", "--version", "-version": - _, _ = fmt.Fprintf(stdout, "fmtkit %s\n", version) - - return 0 - case "help", "--help", "-h": - printUsage(stderr) - - return 0 - default: - _, _ = fmt.Fprintf(stderr, "unknown subcommand - {%q}\n\n", args[0]) - - printUsage(stderr) - - return 1 - } -} - -func printUsage(w io.Writer) { - _, _ = fmt.Fprintf(w, "fmtkit check [paths...]\n\n") - _, _ = fmt.Fprintf(w, "fmtkit format [paths...]\n\n") - _, _ = fmt.Fprintf(w, "fmtkit sources [--include-declarations] [paths...]\n\n") -} diff --git a/packages/go/driver/cmd/fmtkit-go/main_test.go b/packages/go/driver/cmd/fmtkit-go/main_test.go index ed1b154..d2bf842 100644 --- a/packages/go/driver/cmd/fmtkit-go/main_test.go +++ b/packages/go/driver/cmd/fmtkit-go/main_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "go.ollin.sh/fmtkit/driver/internal/app" "go.ollin.sh/fmtkit/driver/testutil" ) @@ -492,7 +493,7 @@ func runCLI(t *testing.T, workdir string, args ...string) (int, string, string) var stdout strings.Builder var stderr strings.Builder - exitCode := run(context.Background(), args, &stdout, &stderr) + exitCode := app.GoCLI("dev", &stdout, &stderr).Dispatch(context.Background(), args) return exitCode, stdout.String(), stderr.String() } diff --git a/packages/go/driver/cmd/fmtkit/main.go b/packages/go/driver/cmd/fmtkit/main.go index 43750e7..afd624a 100644 --- a/packages/go/driver/cmd/fmtkit/main.go +++ b/packages/go/driver/cmd/fmtkit/main.go @@ -20,8 +20,8 @@ func main() { // os.Exit skips deferred calls, so release the signal handler explicitly // before exiting with the captured code. code := app. - New(version, os.Stdout, os.Stderr). - Run(ctx, os.Args[1:]) + Umbrella(version, os.Stdout, os.Stderr). + Dispatch(ctx, os.Args[1:]) stop() os.Exit(code) diff --git a/packages/go/driver/internal/app/app.go b/packages/go/driver/internal/app/app.go index b1743b5..370dd34 100644 --- a/packages/go/driver/internal/app/app.go +++ b/packages/go/driver/internal/app/app.go @@ -5,68 +5,155 @@ import ( "fmt" "io" - "go.ollin.sh/fmtkit/driver/internal/cli" + "go.ollin.sh/fmtkit/driver/internal/command" + "go.ollin.sh/fmtkit/driver/internal/gotool" + "go.ollin.sh/fmtkit/driver/internal/sourcefiles" + report "go.ollin.sh/fmtkit/driver/report" ) -// App is the fmtkit command surface. The version is injected by the binary so -// release builds keep stamping it through -X main.version. -type App struct { +// deps carries what every command handler needs: the version stamped by the +// binary and the output streams. It is a pointer so the usage printer can be +// wired in after the Set is built. +type deps struct { version string stdout io.Writer stderr io.Writer + + // usage prints the enclosing Set's usage text; wired after the Set exists so + // the flag-parsing handlers can reprint it on a bad argument. + usage func(io.Writer) } -func New(version string, stdout, stderr io.Writer) App { - return App{ - version: version, - stdout: stdout, - stderr: stderr, +// umbrellaHeader is the top line of the umbrella usage; the per-command lines +// follow from each Command's Usage. +const umbrellaHeader = "usage: fmtkit [args...]\n" + +// Umbrella builds the fmtkit command surface: the pipeline commands plus the +// embedded Go formatter CLI reached through `fmtkit go`. +func Umbrella(version string, stdout, stderr io.Writer) command.Set { + d := &deps{version: version, stdout: stdout, stderr: stderr} + + // The Go CLI reached through `fmtkit go` prints "fmtkit go ..." usage and + // adopts the umbrella's exit code for bad subcommands. + goSet := d.goCommandSet("fmtkit go", 2) + + set := command.Set{ + Name: "fmtkit", + Header: umbrellaHeader, + ErrExit: 2, + Stderr: stderr, + Commands: []command.Command{ + { + Name: "format", + Usage: " format [--ts] [--go] [--quiet] [paths...] format changed files (vs HEAD) and untracked ones\n", + Run: d.runFormat, + }, + { + Name: "format-all", + Usage: " format-all [--ts] [--go] [--quiet] format every file, against .\n --ts only TS/Vue lint + formatting; --go only Go formatting; default: all\n", + Run: d.runFormatAll, + }, + { + Name: "go", + Usage: " go run the Go formatter CLI\n", + Run: goSet.Dispatch, + }, + { + Name: "ts", + Usage: " ts [paths...] run TS/Vue formatting support and oxfmt\n", + Run: d.runTS, + }, + { + Name: "lint", + Usage: " lint [paths...] lint TS/Vue files with oxlint\n", + Run: d.runLint, + }, + { + Name: "check", + Usage: " check [args...] run the Go formatter in check mode\n", + Run: d.runCheck, + }, + { + Name: "version", + Aliases: []string{"--version", "-version"}, + Usage: " version print the fmtkit version\n", + Run: d.runVersion, + }, + }, } + + d.usage = set.PrintUsage + + return set } -// Run dispatches a subcommand to its handler; each mode lives in its own file. -func (a App) Run(ctx context.Context, args []string) int { - if len(args) == 0 { - printUsage(a.stderr) +// GoCLI builds the standalone fmtkit-go command surface: check, format, +// sources, version, and help, exiting 1 on a bad subcommand. +func GoCLI(version string, stdout, stderr io.Writer) command.Set { + d := &deps{version: version, stdout: stdout, stderr: stderr} + + set := d.goCommandSet("fmtkit", 1) + + d.usage = set.PrintUsage - return 2 + return set +} + +// goCommandSet builds the Go formatter command group. name is the usage prefix +// ("fmtkit" standalone, "fmtkit go" under the umbrella) and errExit is the +// exit code for an empty or unknown subcommand. +func (d *deps) goCommandSet(name string, errExit int) command.Set { + usage := func(sub string) string { + return name + " " + sub + "\n\n" } - mode := args[0] - rest := args[1:] - - switch mode { - case "format": - return a.runFormat(ctx, rest) - case "format-all": - return a.runFormatAll(ctx, rest) - case "ts": - return a.runTS(ctx, rest) - case "lint": - return a.runLint(ctx, rest) - case "go": - return a.runGo(ctx, rest) - case "check": - return cli. - NewRunner(a.stdout, a.stderr). - Run(ctx, cli.CheckMode, rest) - case "version", "--version", "-version": - return a.printVersion() - case "help", "--help", "-h": - printUsage(a.stderr) - - return 0 - default: - _, _ = fmt.Fprintf(a.stderr, "unknown subcommand - {%q}\n\n", mode) - - printUsage(a.stderr) - - return 2 + return command.Set{ + Name: name, + ErrExit: errExit, + Stderr: d.stderr, + Commands: []command.Command{ + { + Name: "check", + Usage: usage("check [paths...]"), + Run: d.runCheck, + }, + { + Name: "format", + Usage: usage("format [paths...]"), + Run: d.runGoFormat, + }, + { + Name: "sources", + Usage: usage("sources [--include-declarations] [paths...]"), + Run: func(ctx context.Context, args []string) int { + return sourcefiles.Run(ctx, args, d.stdout, d.stderr) + }, + }, + { + Name: "version", + Aliases: []string{"--version", "-version"}, + Run: d.runVersion, + }, + }, } } -func (a App) printVersion() int { - _, _ = fmt.Fprintf(a.stdout, "fmtkit %s\n", a.version) +// goRunner is the unscoped Go formatter runner shared by `check` and the +// standalone `format`. +func (d *deps) goRunner() gotool.Runner { + return gotool.Runner{Stdout: d.stdout, Stderr: d.stderr} +} + +func (d *deps) runCheck(ctx context.Context, args []string) int { + return d.goRunner().Run(ctx, report.ModeCheck, args) +} + +func (d *deps) runGoFormat(ctx context.Context, args []string) int { + return d.goRunner().Run(ctx, report.ModeFormat, args) +} + +func (d *deps) runVersion(_ context.Context, _ []string) int { + _, _ = fmt.Fprintf(d.stdout, "fmtkit %s\n", d.version) return 0 } diff --git a/packages/go/driver/internal/app/app_test.go b/packages/go/driver/internal/app/app_test.go index 15dfdec..d737413 100644 --- a/packages/go/driver/internal/app/app_test.go +++ b/packages/go/driver/internal/app/app_test.go @@ -42,7 +42,7 @@ func runCLI(t *testing.T, workdir string, args ...string) (int, string, string) var stderr strings.Builder // "dev" mirrors the unstamped binary: no embedded TS assets. - exitCode := New("dev", &stdout, &stderr).Run(context.Background(), args) + exitCode := Umbrella("dev", &stdout, &stderr).Dispatch(context.Background(), args) return exitCode, stdout.String(), stderr.String() } diff --git a/packages/go/driver/internal/app/exit.go b/packages/go/driver/internal/app/exit.go index d8964ae..f218f0e 100644 --- a/packages/go/driver/internal/app/exit.go +++ b/packages/go/driver/internal/app/exit.go @@ -8,7 +8,7 @@ import ( // reportError maps a tool failure onto an exit code, propagating the child's // own code when it already reported the problem itself. -func (a App) reportError(err error) int { +func (d *deps) reportError(err error) int { if err == nil { return 0 } @@ -19,7 +19,7 @@ func (a App) reportError(err error) int { return exit.ExitCode() } - _, _ = fmt.Fprintf(a.stderr, "fmtkit: %v\n", err) + _, _ = fmt.Fprintf(d.stderr, "fmtkit: %v\n", err) return 1 } diff --git a/packages/go/driver/internal/app/format.go b/packages/go/driver/internal/app/format.go index fe6fe03..8433b11 100644 --- a/packages/go/driver/internal/app/format.go +++ b/packages/go/driver/internal/app/format.go @@ -5,53 +5,54 @@ import ( "fmt" "io" - "go.ollin.sh/fmtkit/driver/internal/cli" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/gotool" "go.ollin.sh/fmtkit/driver/internal/orchestrator" - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" "go.ollin.sh/fmtkit/driver/internal/tsruntime" + report "go.ollin.sh/fmtkit/driver/report" ) // runFormat formats what diverges from HEAD — modified files, staged or not, // plus untracked ones — so an everyday format stays proportional to the diff. // Use format-all to cover every file. -func (a App) runFormat(ctx context.Context, args []string) int { +func (d *deps) runFormat(ctx context.Context, args []string) int { opts, paths, err := parseFormatArgs(args) if err != nil { - _, _ = fmt.Fprintf(a.stderr, "%v\n\n", err) + _, _ = fmt.Fprintf(d.stderr, "%v\n\n", err) - printUsage(a.stderr) + d.usage(d.stderr) return 2 } - return a.runPipeline(ctx, paths, opts, sourcefiles.SelectionChanged) + return d.runPipeline(ctx, paths, opts, gitfiles.SelectionChanged) } // runFormatAll covers every non-ignored file rather than just the working // tree's changes, pinned to the current directory, so it takes flags but // rejects paths. -func (a App) runFormatAll(ctx context.Context, args []string) int { +func (d *deps) runFormatAll(ctx context.Context, args []string) int { opts, extra, err := parseFormatArgs(args) if err != nil || len(extra) != 0 { if err != nil { - _, _ = fmt.Fprintf(a.stderr, "%v\n\n", err) + _, _ = fmt.Fprintf(d.stderr, "%v\n\n", err) } - printUsage(a.stderr) + d.usage(d.stderr) return 2 } - return a.runPipeline(ctx, []string{"."}, opts, sourcefiles.SelectionAll) + return d.runPipeline(ctx, []string{"."}, opts, gitfiles.SelectionAll) } -func (a App) runPipeline(ctx context.Context, paths []string, opts formatOptions, selection sourcefiles.Selection) int { +func (d *deps) runPipeline(ctx context.Context, paths []string, opts formatOptions, selection gitfiles.Selection) int { pipeline := orchestrator.Pipeline{ Tools: orchestrator.Tools{ TS: func(ctx context.Context, scopes []string, output io.Writer) error { - assets, err := tsruntime.Resolve(a.version) + assets, err := tsruntime.Resolve(d.version) if err != nil { return err @@ -60,7 +61,7 @@ func (a App) runPipeline(ctx context.Context, paths []string, opts formatOptions return tsruntime.NewInvoker(assets).RunPipeline(ctx, tsruntime.Request{Scopes: scopes, Selection: selection, Stdout: output, Stderr: output}) }, Lint: func(ctx context.Context, scopes []string, output io.Writer) error { - assets, err := tsruntime.Resolve(a.version) + assets, err := tsruntime.Resolve(d.version) if err != nil { return err @@ -69,14 +70,14 @@ func (a App) runPipeline(ctx context.Context, paths []string, opts formatOptions return tsruntime.NewInvoker(assets).RunLint(ctx, tsruntime.Request{Scopes: scopes, Selection: selection, Fix: true, Stdout: output, Stderr: output}) }, Go: func(ctx context.Context, args []string, output io.Writer) int { - return cli. - NewScopedRunner(output, output, selection). - Run(ctx, cli.FormatMode, args[1:]) + return gotool. + Runner{Stdout: output, Stderr: output, Scope: selection}. + Run(ctx, report.ModeFormat, args[1:]) }, }, Steps: opts.steps, Quiet: opts.quiet, - Stderr: a.stderr, + Stderr: d.stderr, } return pipeline.RunFormat(ctx, paths) diff --git a/packages/go/driver/internal/app/golang.go b/packages/go/driver/internal/app/golang.go deleted file mode 100644 index dbe7d3e..0000000 --- a/packages/go/driver/internal/app/golang.go +++ /dev/null @@ -1,43 +0,0 @@ -package app - -import ( - "context" - "fmt" - - "go.ollin.sh/fmtkit/driver/internal/cli" -) - -// runGo mirrors the fmtkit-go command surface so `fmtkit go ...` behaves like -// the container's Go formatter CLI. -func (a App) runGo(ctx context.Context, args []string) int { - if len(args) == 0 { - printGoUsage(a.stderr) - - return 2 - } - - switch args[0] { - case "check": - return cli. - NewRunner(a.stdout, a.stderr). - Run(ctx, cli.CheckMode, args[1:]) - case "format": - return cli. - NewRunner(a.stdout, a.stderr). - Run(ctx, cli.FormatMode, args[1:]) - case "sources": - return cli.RunSources(ctx, args[1:], a.stdout, a.stderr) - case "version", "--version", "-version": - return a.printVersion() - case "help", "--help", "-h": - printGoUsage(a.stderr) - - return 0 - default: - _, _ = fmt.Fprintf(a.stderr, "unknown subcommand - {%q}\n\n", args[0]) - - printGoUsage(a.stderr) - - return 2 - } -} diff --git a/packages/go/driver/internal/app/ts.go b/packages/go/driver/internal/app/ts.go index f5353d6..65ab0f4 100644 --- a/packages/go/driver/internal/app/ts.go +++ b/packages/go/driver/internal/app/ts.go @@ -6,22 +6,22 @@ import ( "go.ollin.sh/fmtkit/driver/internal/tsruntime" ) -func (a App) runTS(ctx context.Context, paths []string) int { - assets, err := tsruntime.Resolve(a.version) +func (d *deps) runTS(ctx context.Context, paths []string) int { + assets, err := tsruntime.Resolve(d.version) if err != nil { - return a.reportError(err) + return d.reportError(err) } - return a.reportError(tsruntime.NewInvoker(assets).RunPipeline(ctx, tsruntime.Request{Scopes: paths, Stdout: a.stdout, Stderr: a.stderr})) + return d.reportError(tsruntime.NewInvoker(assets).RunPipeline(ctx, tsruntime.Request{Scopes: paths, Stdout: d.stdout, Stderr: d.stderr})) } -func (a App) runLint(ctx context.Context, paths []string) int { - assets, err := tsruntime.Resolve(a.version) +func (d *deps) runLint(ctx context.Context, paths []string) int { + assets, err := tsruntime.Resolve(d.version) if err != nil { - return a.reportError(err) + return d.reportError(err) } - return a.reportError(tsruntime.NewInvoker(assets).RunLint(ctx, tsruntime.Request{Scopes: paths, Stdout: a.stdout, Stderr: a.stderr})) + return d.reportError(tsruntime.NewInvoker(assets).RunLint(ctx, tsruntime.Request{Scopes: paths, Stdout: d.stdout, Stderr: d.stderr})) } diff --git a/packages/go/driver/internal/app/usage.go b/packages/go/driver/internal/app/usage.go deleted file mode 100644 index 64e125a..0000000 --- a/packages/go/driver/internal/app/usage.go +++ /dev/null @@ -1,24 +0,0 @@ -package app - -import ( - "fmt" - "io" -) - -func printUsage(w io.Writer) { - _, _ = fmt.Fprintf(w, "usage: fmtkit [args...]\n") - _, _ = fmt.Fprintf(w, " format [--ts] [--go] [--quiet] [paths...] format changed files (vs HEAD) and untracked ones\n") - _, _ = fmt.Fprintf(w, " format-all [--ts] [--go] [--quiet] format every file, against .\n") - _, _ = fmt.Fprintf(w, " --ts only TS/Vue lint + formatting; --go only Go formatting; default: all\n") - _, _ = fmt.Fprintf(w, " go run the Go formatter CLI\n") - _, _ = fmt.Fprintf(w, " ts [paths...] run TS/Vue formatting support and oxfmt\n") - _, _ = fmt.Fprintf(w, " lint [paths...] lint TS/Vue files with oxlint\n") - _, _ = fmt.Fprintf(w, " check [args...] run the Go formatter in check mode\n") - _, _ = fmt.Fprintf(w, " version print the fmtkit version\n") -} - -func printGoUsage(w io.Writer) { - _, _ = fmt.Fprintf(w, "fmtkit go check [paths...]\n\n") - _, _ = fmt.Fprintf(w, "fmtkit go format [paths...]\n\n") - _, _ = fmt.Fprintf(w, "fmtkit go sources [--include-declarations] [paths...]\n\n") -} diff --git a/packages/go/driver/internal/cli/mode.go b/packages/go/driver/internal/cli/mode.go deleted file mode 100644 index 03999b9..0000000 --- a/packages/go/driver/internal/cli/mode.go +++ /dev/null @@ -1,12 +0,0 @@ -package cli - -type Mode string - -const ( - CheckMode Mode = "check" - FormatMode Mode = "format" -) - -func (m Mode) String() string { - return string(m) -} diff --git a/packages/go/driver/internal/cli/mode_test.go b/packages/go/driver/internal/cli/mode_test.go deleted file mode 100644 index 52ce60c..0000000 --- a/packages/go/driver/internal/cli/mode_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package cli - -import "testing" - -func TestModeString(t *testing.T) { - if got := CheckMode.String(); got != "check" { - t.Fatalf("CheckMode.String() = %q", got) - } - - if got := FormatMode.String(); got != "format" { - t.Fatalf("FormatMode.String() = %q", got) - } -} diff --git a/packages/go/driver/internal/cli/options.go b/packages/go/driver/internal/cli/options.go deleted file mode 100644 index b703525..0000000 --- a/packages/go/driver/internal/cli/options.go +++ /dev/null @@ -1,12 +0,0 @@ -package cli - -type options struct { - mode Mode - configPath string - reportRoot string - outputFormat string - positional []string - // jobs overrides config.Concurrency when not -1. -1 means "unset" - // (no override); 0 means "use NumCPU"; positive values pin the worker count. - jobs int -} diff --git a/packages/go/driver/internal/cli/parser.go b/packages/go/driver/internal/cli/parser.go deleted file mode 100644 index 80aab8e..0000000 --- a/packages/go/driver/internal/cli/parser.go +++ /dev/null @@ -1,66 +0,0 @@ -package cli - -import ( - "flag" - "io" - "os" - "strconv" - "strings" -) - -type parser struct { - stderr io.Writer -} - -func newParser(stderr io.Writer) parser { - return parser{stderr: stderr} -} - -func (p parser) Parse(mode Mode, args []string) (options, error) { - fs := flag.NewFlagSet(mode.String(), flag.ContinueOnError) - fs.SetOutput(p.stderr) - - configPath := fs.String("config", "", "Path to fmtkit YAML config") - reportRoot := fs.String("cwd", "", "Path used for config discovery and report-relative file paths") - outputFormat := fs.String("format", "text", "Output format: text, json, agent") - jobs := fs.Int("jobs", envJobs(), "Max files processed in parallel (0 = NumCPU; also reads FMTKIT_JOBS)") - - if err := fs.Parse(args); err != nil { - return options{}, err - } - - return options{ - mode: mode, - configPath: *configPath, - reportRoot: *reportRoot, - outputFormat: *outputFormat, - positional: fs.Args(), - jobs: *jobs, - }, nil -} - -// envJobs reads FMTKIT_JOBS as the default for the --jobs flag. -// Returns -1 when the env var is unset so the runner can distinguish -// "unset" from an explicit 0 (which means "use NumCPU"). -// Invalid values fall back to -1 as well. -func envJobs() int { - val, ok := os.LookupEnv("FMTKIT_JOBS") - - if !ok { - return -1 - } - - raw := strings.TrimSpace(val) - - if raw == "" { - return -1 - } - - n, err := strconv.Atoi(raw) - - if err != nil || n < 0 { - return -1 - } - - return n -} diff --git a/packages/go/driver/internal/cli/runner.go b/packages/go/driver/internal/cli/runner.go deleted file mode 100644 index 7e6d006..0000000 --- a/packages/go/driver/internal/cli/runner.go +++ /dev/null @@ -1,186 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "io" - "os" - "strings" - - driverconfig "go.ollin.sh/fmtkit/driver/config" - "go.ollin.sh/fmtkit/driver/internal/gitfiles" - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" - driverreport "go.ollin.sh/fmtkit/driver/report" - "go.ollin.sh/fmtkit/formatter" - formatterconfig "go.ollin.sh/fmtkit/formatter/config" - formatterengine "go.ollin.sh/fmtkit/formatter/engine" - "go.ollin.sh/fmtkit/vet" -) - -type Runner struct { - stdout io.Writer - stderr io.Writer - parser parser - - // selection is how much of the working tree the formatter covers. The zero - // value covers everything, which is what `fmtkit go` and `fmtkit check` want. - selection sourcefiles.Selection -} - -func NewRunner(stdout, stderr io.Writer) Runner { - return Runner{ - stdout: stdout, - stderr: stderr, - parser: newParser(stderr), - } -} - -// NewScopedRunner returns a Runner whose formatter covers only the part of the -// working tree that selection names. -func NewScopedRunner(stdout, stderr io.Writer, selection sourcefiles.Selection) Runner { - runner := NewRunner(stdout, stderr) - runner.selection = selection - - return runner -} - -func (r Runner) Run(ctx context.Context, mode Mode, args []string) int { - opts, err := r.parser.Parse(mode, args) - - if err != nil { - return 1 - } - - workRoot, err := os.Getwd() - - if err != nil { - r.writeError("resolve cwd: %v\n", err) - - return 1 - } - - reportRoot := workRoot - - if strings.TrimSpace(opts.reportRoot) != "" { - reportRoot = opts.reportRoot - } - - cfg, err := driverconfig.Load(reportRoot, opts.configPath) - - if err != nil { - r.writeError("%v\n", err) - - return 1 - } - - runPaths := opts.positional - - formatterCfg := cfg.Formatter() - - if opts.jobs != -1 { - formatterCfg.Concurrency = opts.jobs - } - - formatterReport, err := r.runFormatter(ctx, mode, runPaths, formatterCfg) - - if err != nil { - r.writeError("%v\n", err) - - return 1 - } - - result := driverreport.Combined{ - Formatter: formatterReport, - Vet: vet.Run(ctx, workRoot, cfg.VetConfig()), - } - - if err := driverreport.Render(r.stdout, opts.outputFormat, reportRoot, mode.String(), result); err != nil { - r.writeError("render report: %v\n", err) - - return 1 - } - - return exitCode(mode, result) -} - -func (r Runner) runFormatter(ctx context.Context, mode Mode, paths []string, cfg formatterconfig.Config) (formatterengine.Report, error) { - if r.selection == sourcefiles.SelectionChanged { - files, err := changedGoFiles(ctx, paths, cfg) - - if err != nil { - return formatterengine.Report{}, err - } - - switch mode { - case CheckMode: - return formatter.CheckFiles(ctx, files, cfg) - case FormatMode: - return formatter.FormatFiles(ctx, files, cfg) - default: - return formatterengine.Report{}, fmt.Errorf("unsupported mode %q", mode) - } - } - - switch mode { - case CheckMode: - return formatter.Check(ctx, paths, cfg) - case FormatMode: - return formatter.Format(ctx, paths, cfg) - default: - return formatterengine.Report{}, fmt.Errorf("unsupported mode %q", mode) - } -} - -// changedGoFiles narrows the files the formatter owns down to the ones the -// working tree has touched. The engine reports what it owns; gitfiles keeps only -// the subset git reports as changed (see Tree.IntersectChanged for why this is -// an intersection rather than a direct `git ls-files *.go`). -func changedGoFiles(ctx context.Context, paths []string, cfg formatterconfig.Config) ([]string, error) { - owned, err := formatterengine.CollectGoFiles(paths, cfg) - - if err != nil { - return nil, err - } - - if len(owned) == 0 { - return nil, nil - } - - cwd, err := os.Getwd() - - if err != nil { - return nil, fmt.Errorf("resolve cwd: %w", err) - } - - tree, err := gitfiles.NewTree(cwd) - - if err != nil { - return nil, err - } - - return tree.IntersectChanged(ctx, paths, owned) -} - -func exitCode(mode Mode, result driverreport.Combined) int { - if result.Vet.ErrorCount() > 0 { - return 1 - } - - if mode == CheckMode { - if result.Formatter.Result == formatterengine.ResultPass { - return 0 - } - - return 1 - } - - if result.Formatter.ErrorCount() > 0 { - return 1 - } - - return 0 -} - -func (r Runner) writeError(format string, args ...any) { - _, _ = fmt.Fprintf(r.stderr, format, args...) -} diff --git a/packages/go/driver/internal/cli/sources.go b/packages/go/driver/internal/cli/sources.go deleted file mode 100644 index 7f7817c..0000000 --- a/packages/go/driver/internal/cli/sources.go +++ /dev/null @@ -1,12 +0,0 @@ -package cli - -import ( - "context" - "io" - - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" -) - -func RunSources(ctx context.Context, args []string, stdout, stderr io.Writer) int { - return sourcefiles.Run(ctx, args, stdout, stderr) -} diff --git a/packages/go/driver/internal/command/command.go b/packages/go/driver/internal/command/command.go new file mode 100644 index 0000000..e0d4832 --- /dev/null +++ b/packages/go/driver/internal/command/command.go @@ -0,0 +1,99 @@ +// Package command is the CLI dispatch table shared by both fmtkit binaries. +// A Set is a named group of Commands with its own usage text and error exit +// code, so the two binaries' deliberate divergences (the umbrella exits 2 on a +// bad subcommand and prefixes its Go usage with "fmtkit go", the standalone +// fmtkit-go exits 1 and prefixes with "fmtkit") live in Set fields rather than +// in branching code. +package command + +import ( + "context" + "fmt" + "io" +) + +// Command is one dispatchable subcommand. Name is how it is invoked; Aliases +// are equivalent spellings (e.g. --version). Usage is this command's line(s) in +// the parent Set's usage text. Run receives the arguments after the command +// name and returns the process exit code. +type Command struct { + Name string + Aliases []string + Usage string + Run func(ctx context.Context, args []string) int +} + +// Set is a named group of Commands. Header prefixes the usage text; ErrExit is +// the exit code for an empty or unknown subcommand; Stderr is where usage and +// errors are written. +type Set struct { + Name string + Header string + Commands []Command + ErrExit int + Stderr io.Writer +} + +func (c Command) matches(name string) bool { + if name == c.Name { + return true + } + + for _, alias := range c.Aliases { + if name == alias { + return true + } + } + + return false +} + +// Dispatch routes args to a command. An empty argument list or an unknown +// subcommand prints the usage and returns ErrExit; help (help/--help/-h) prints +// the usage and returns 0; otherwise the matching command runs. +func (s Set) Dispatch(ctx context.Context, args []string) int { + if len(args) == 0 { + s.PrintUsage(s.Stderr) + + return s.ErrExit + } + + name := args[0] + rest := args[1:] + + if isHelp(name) { + s.PrintUsage(s.Stderr) + + return 0 + } + + for _, command := range s.Commands { + if command.matches(name) { + return command.Run(ctx, rest) + } + } + + _, _ = fmt.Fprintf(s.Stderr, "unknown subcommand - {%q}\n\n", name) + + s.PrintUsage(s.Stderr) + + return s.ErrExit +} + +// PrintUsage writes the Set's Header followed by each command's Usage line. +func (s Set) PrintUsage(w io.Writer) { + _, _ = io.WriteString(w, s.Header) + + for _, command := range s.Commands { + _, _ = io.WriteString(w, command.Usage) + } +} + +func isHelp(name string) bool { + switch name { + case "help", "--help", "-h": + return true + default: + return false + } +} diff --git a/packages/go/driver/internal/command/command_test.go b/packages/go/driver/internal/command/command_test.go new file mode 100644 index 0000000..19fc1a6 --- /dev/null +++ b/packages/go/driver/internal/command/command_test.go @@ -0,0 +1,152 @@ +package command + +import ( + "bytes" + "context" + "testing" +) + +// fixtureSet builds a Set with a run-recording command, one aliased command, +// and the given error exit code. +func fixtureSet(errExit int, stderr *bytes.Buffer, ran *string) Set { + record := func(name string) func(context.Context, []string) int { + return func(_ context.Context, args []string) int { + *ran = name + + return len(args) + } + } + + return Set{ + Name: "tool", + Header: "usage: tool \n", + ErrExit: errExit, + Stderr: stderr, + Commands: []Command{ + {Name: "do", Usage: " do do the thing\n", Run: record("do")}, + { + Name: "ping", + Aliases: []string{"--ping", "-p"}, + Usage: " ping ping the thing\n", + Run: record("ping"), + }, + {Name: "version", Aliases: []string{"--version"}, Run: record("version")}, + }, + } +} + +func TestDispatchRoutesToCommand(t *testing.T) { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(2, &stderr, &ran) + + if code := set.Dispatch(context.Background(), []string{"do", "a", "b"}); code != 2 { + t.Fatalf("Run should receive 2 args, got exit %d", code) + } + + if ran != "do" { + t.Fatalf("expected do to run, got %q", ran) + } + + if stderr.Len() != 0 { + t.Fatalf("unexpected stderr: %q", stderr.String()) + } +} + +func TestDispatchMatchesAliases(t *testing.T) { + for _, alias := range []string{"ping", "--ping", "-p"} { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(1, &stderr, &ran) + + if code := set.Dispatch(context.Background(), []string{alias}); code != 0 { + t.Fatalf("alias %q: unexpected exit %d", alias, code) + } + + if ran != "ping" { + t.Fatalf("alias %q did not route to ping, ran %q", alias, ran) + } + } +} + +func TestDispatchEmptyPrintsUsageAndErrExit(t *testing.T) { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(2, &stderr, &ran) + + if code := set.Dispatch(context.Background(), nil); code != 2 { + t.Fatalf("empty args exit = %d, want ErrExit 2", code) + } + + want := "usage: tool \n do do the thing\n ping ping the thing\n" + + if stderr.String() != want { + t.Fatalf("usage mismatch\n got: %q\nwant: %q", stderr.String(), want) + } +} + +func TestDispatchUnknownPrintsErrorThenUsage(t *testing.T) { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(1, &stderr, &ran) + + if code := set.Dispatch(context.Background(), []string{"bogus"}); code != 1 { + t.Fatalf("unknown exit = %d, want ErrExit 1", code) + } + + want := "unknown subcommand - {\"bogus\"}\n\nusage: tool \n do do the thing\n ping ping the thing\n" + + if stderr.String() != want { + t.Fatalf("unknown output mismatch\n got: %q\nwant: %q", stderr.String(), want) + } + + if ran != "" { + t.Fatalf("no command should have run, ran %q", ran) + } +} + +func TestDispatchHelpPrintsUsageAndExitsZero(t *testing.T) { + for _, arg := range []string{"help", "--help", "-h"} { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(2, &stderr, &ran) + + if code := set.Dispatch(context.Background(), []string{arg}); code != 0 { + t.Fatalf("help arg %q: exit = %d, want 0", arg, code) + } + + if ran != "" { + t.Fatalf("help must not run a command, ran %q", ran) + } + + if stderr.Len() == 0 { + t.Fatalf("help arg %q printed no usage", arg) + } + } +} + +func TestPrintUsageComposesHeaderAndCommands(t *testing.T) { + var out bytes.Buffer + + var ran string + + set := fixtureSet(2, &bytes.Buffer{}, &ran) + + set.PrintUsage(&out) + + want := "usage: tool \n do do the thing\n ping ping the thing\n" + + if out.String() != want { + t.Fatalf("PrintUsage mismatch\n got: %q\nwant: %q", out.String(), want) + } +} diff --git a/packages/go/driver/internal/gotool/execute.go b/packages/go/driver/internal/gotool/execute.go new file mode 100644 index 0000000..b89ccc6 --- /dev/null +++ b/packages/go/driver/internal/gotool/execute.go @@ -0,0 +1,118 @@ +package gotool + +import ( + "context" + "fmt" + "os" + + driverconfig "go.ollin.sh/fmtkit/driver/config" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + report "go.ollin.sh/fmtkit/driver/report" + "go.ollin.sh/fmtkit/formatter" + formatterconfig "go.ollin.sh/fmtkit/formatter/config" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +// Request is one Go check/format run: the mode, the paths to cover, the loaded +// config, the working-tree root git and vet run against, and how much of that +// tree the run scopes to. +type Request struct { + Mode report.Mode + Paths []string + Config driverconfig.Config + Root string + Scope gitfiles.Selection +} + +// Outcome is the combined formatter and vet report produced for a mode, ready +// to render or to reduce to an exit code. +type Outcome struct { + Combined report.Combined + Mode report.Mode +} + +// ExitCode reduces the outcome to a process exit code under its mode's policy. +func (o Outcome) ExitCode() int { + return o.Combined.ExitCode(o.Mode) +} + +// Execute runs the Go formatter (scoped as the request asks) and go vet, and +// returns the combined outcome. It is the reusable core the standalone runner +// and the umbrella pipeline both drive. +func Execute(ctx context.Context, req Request) (Outcome, error) { + formatterReport, err := runFormatter(ctx, req, req.Config.Formatter()) + + if err != nil { + return Outcome{}, err + } + + combined := report.Combined{ + Formatter: formatterReport, + Vet: vet.Run(ctx, req.Root, req.Config.VetConfig()), + } + + return Outcome{Combined: combined, Mode: req.Mode}, nil +} + +func runFormatter(ctx context.Context, req Request, cfg formatterconfig.Config) (formatterengine.Report, error) { + if req.Scope == gitfiles.SelectionChanged { + files, err := changedGoFiles(ctx, req.Root, req.Paths, cfg) + + if err != nil { + return formatterengine.Report{}, err + } + + switch req.Mode { + case report.ModeCheck: + return formatter.CheckFiles(ctx, files, cfg) + case report.ModeFormat: + return formatter.FormatFiles(ctx, files, cfg) + default: + return formatterengine.Report{}, fmt.Errorf("unsupported mode %q", req.Mode) + } + } + + switch req.Mode { + case report.ModeCheck: + return formatter.Check(ctx, req.Paths, cfg) + case report.ModeFormat: + return formatter.Format(ctx, req.Paths, cfg) + default: + return formatterengine.Report{}, fmt.Errorf("unsupported mode %q", req.Mode) + } +} + +// changedGoFiles narrows the files the formatter owns down to the ones the +// working tree has touched. The engine reports what it owns; gitfiles keeps only +// the subset git reports as changed (see Tree.IntersectChanged for why this is +// an intersection rather than a direct `git ls-files *.go`). +func changedGoFiles(ctx context.Context, root string, paths []string, cfg formatterconfig.Config) ([]string, error) { + owned, err := formatterengine.CollectGoFiles(paths, cfg) + + if err != nil { + return nil, err + } + + if len(owned) == 0 { + return nil, nil + } + + if root == "" { + cwd, err := os.Getwd() + + if err != nil { + return nil, fmt.Errorf("resolve cwd: %w", err) + } + + root = cwd + } + + tree, err := gitfiles.NewTree(root) + + if err != nil { + return nil, err + } + + return tree.IntersectChanged(ctx, paths, owned) +} diff --git a/packages/go/driver/internal/gotool/execute_test.go b/packages/go/driver/internal/gotool/execute_test.go new file mode 100644 index 0000000..cded312 --- /dev/null +++ b/packages/go/driver/internal/gotool/execute_test.go @@ -0,0 +1,106 @@ +package gotool + +import ( + "context" + "os" + "path/filepath" + "testing" + + driverconfig "go.ollin.sh/fmtkit/driver/config" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" +) + +func TestExecuteCheckReportsViolationWithoutRewriting(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "sample.go") + + if err := os.WriteFile(file, []byte(spacingViolationSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + outcome, err := Execute(context.Background(), Request{ + Mode: report.ModeCheck, + Paths: []string{file}, + Config: driverconfig.Default(), + Root: dir, + }) + + if err != nil { + t.Fatalf("execute: %v", err) + } + + if outcome.Mode != report.ModeCheck { + t.Fatalf("outcome mode = %q", outcome.Mode) + } + + if outcome.Combined.Formatter.Result == formatterengine.ResultPass { + t.Fatalf("expected a non-pass result for the violation") + } + + if outcome.ExitCode() != 1 { + t.Fatalf("check exit = %d, want 1", outcome.ExitCode()) + } + + if got, _ := os.ReadFile(file); string(got) != spacingViolationSource { + t.Fatal("check mode must not rewrite the file") + } +} + +func TestExecuteFormatRewritesFile(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "sample.go") + + if err := os.WriteFile(file, []byte(spacingViolationSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + outcome, err := Execute(context.Background(), Request{ + Mode: report.ModeFormat, + Paths: []string{file}, + Config: driverconfig.Default(), + Root: dir, + }) + + if err != nil { + t.Fatalf("execute: %v", err) + } + + if outcome.ExitCode() != 0 { + t.Fatalf("format exit = %d, want 0", outcome.ExitCode()) + } + + got, err := os.ReadFile(file) + + if err != nil { + t.Fatalf("read sample: %v", err) + } + + if string(got) == spacingViolationSource { + t.Fatal("format mode should rewrite the file") + } +} + +func TestExecuteChangedScopeOutsideGitTreeErrors(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "sample.go") + + if err := os.WriteFile(file, []byte(cleanSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + // A changed scope needs a git tree; a bare temp dir has none, so Execute must + // surface the error rather than silently formatting everything. + _, err := Execute(context.Background(), Request{ + Mode: report.ModeFormat, + Paths: []string{file}, + Config: driverconfig.Default(), + Root: dir, + Scope: gitfiles.SelectionChanged, + }) + + if err == nil { + t.Fatal("expected an error scoping to changes outside a git tree") + } +} diff --git a/packages/go/driver/internal/gotool/parser.go b/packages/go/driver/internal/gotool/parser.go new file mode 100644 index 0000000..3b846e5 --- /dev/null +++ b/packages/go/driver/internal/gotool/parser.go @@ -0,0 +1,87 @@ +package gotool + +import ( + "flag" + "fmt" + "io" + "os" + "strconv" + "strings" + + report "go.ollin.sh/fmtkit/driver/report" +) + +// Invocation is the parsed form of a `check`/`format` command line: the flags +// (--config --cwd --format --jobs, plus FMTKIT_JOBS) resolved to typed values +// and the positional paths. +type Invocation struct { + Mode report.Mode + ConfigPath string + ReportRoot string + Output report.Format + Paths []string + + // Jobs overrides config.Concurrency when not -1. -1 means "unset" (no + // override); 0 means "use NumCPU"; positive values pin the worker count. + Jobs int +} + +// ParseInvocation parses the shared check/format flag set for mode. Flag errors +// (already reported to stderr by the flag package) and an unknown --format +// value both surface as an error so the caller can exit non-zero. +func ParseInvocation(mode report.Mode, args []string, stderr io.Writer) (Invocation, error) { + fs := flag.NewFlagSet(string(mode), flag.ContinueOnError) + fs.SetOutput(stderr) + + configPath := fs.String("config", "", "Path to fmtkit YAML config") + reportRoot := fs.String("cwd", "", "Path used for config discovery and report-relative file paths") + outputFormat := fs.String("format", "text", "Output format: text, json, agent") + jobs := fs.Int("jobs", envJobs(), "Max files processed in parallel (0 = NumCPU; also reads FMTKIT_JOBS)") + + if err := fs.Parse(args); err != nil { + return Invocation{}, err + } + + format, err := report.ParseFormat(*outputFormat) + + if err != nil { + _, _ = fmt.Fprintf(stderr, "%v\n", err) + + return Invocation{}, err + } + + return Invocation{ + Mode: mode, + ConfigPath: *configPath, + ReportRoot: *reportRoot, + Output: format, + Paths: fs.Args(), + Jobs: *jobs, + }, nil +} + +// envJobs reads FMTKIT_JOBS as the default for the --jobs flag. +// Returns -1 when the env var is unset so the runner can distinguish +// "unset" from an explicit 0 (which means "use NumCPU"). +// Invalid values fall back to -1 as well. +func envJobs() int { + val, ok := os.LookupEnv("FMTKIT_JOBS") + + if !ok { + return -1 + } + + raw := strings.TrimSpace(val) + + if raw == "" { + return -1 + } + + n, err := strconv.Atoi(raw) + + if err != nil || n < 0 { + return -1 + } + + return n +} diff --git a/packages/go/driver/internal/cli/parser_test.go b/packages/go/driver/internal/gotool/parser_test.go similarity index 54% rename from packages/go/driver/internal/cli/parser_test.go rename to packages/go/driver/internal/gotool/parser_test.go index 1af3eea..d0405d1 100644 --- a/packages/go/driver/internal/cli/parser_test.go +++ b/packages/go/driver/internal/gotool/parser_test.go @@ -1,33 +1,35 @@ -package cli +package gotool import ( "io" "os" "reflect" "testing" + + report "go.ollin.sh/fmtkit/driver/report" ) -func TestParseDefaults(t *testing.T) { +func TestParseInvocationDefaults(t *testing.T) { unsetJobsEnv(t) - opts, err := newParser(io.Discard).Parse(CheckMode, nil) + inv, err := ParseInvocation(report.ModeCheck, nil, io.Discard) if err != nil { t.Fatalf("parse: %v", err) } - want := options{ - mode: CheckMode, - outputFormat: "text", - jobs: -1, + want := Invocation{ + Mode: report.ModeCheck, + Output: report.FormatText, + Jobs: -1, } - if !reflect.DeepEqual(opts, want) { - t.Fatalf("unexpected defaults: %#v", opts) + if !reflect.DeepEqual(inv, want) { + t.Fatalf("unexpected defaults: %#v", inv) } } -func TestParseAllFlags(t *testing.T) { +func TestParseInvocationAllFlags(t *testing.T) { unsetJobsEnv(t) args := []string{ @@ -38,38 +40,44 @@ func TestParseAllFlags(t *testing.T) { "main.go", "pkg", } - opts, err := newParser(io.Discard).Parse(FormatMode, args) + inv, err := ParseInvocation(report.ModeFormat, args, io.Discard) if err != nil { t.Fatalf("parse: %v", err) } - want := options{ - mode: FormatMode, - configPath: "custom.yml", - reportRoot: "/repo", - outputFormat: "json", - positional: []string{"main.go", "pkg"}, - jobs: 4, + want := Invocation{ + Mode: report.ModeFormat, + ConfigPath: "custom.yml", + ReportRoot: "/repo", + Output: report.FormatJSON, + Paths: []string{"main.go", "pkg"}, + Jobs: 4, } - if !reflect.DeepEqual(opts, want) { - t.Fatalf("unexpected options: %#v", opts) + if !reflect.DeepEqual(inv, want) { + t.Fatalf("unexpected invocation: %#v", inv) } } -func TestParseRejectsUnknownFlag(t *testing.T) { - if _, err := newParser(io.Discard).Parse(CheckMode, []string{"--bogus"}); err == nil { +func TestParseInvocationRejectsUnknownFlag(t *testing.T) { + if _, err := ParseInvocation(report.ModeCheck, []string{"--bogus"}, io.Discard); err == nil { t.Fatal("expected unknown flag error") } } -func TestParseRejectsNonNumericJobs(t *testing.T) { - if _, err := newParser(io.Discard).Parse(CheckMode, []string{"--jobs", "abc"}); err == nil { +func TestParseInvocationRejectsNonNumericJobs(t *testing.T) { + if _, err := ParseInvocation(report.ModeCheck, []string{"--jobs", "abc"}, io.Discard); err == nil { t.Fatal("expected invalid --jobs error") } } +func TestParseInvocationRejectsUnknownFormat(t *testing.T) { + if _, err := ParseInvocation(report.ModeCheck, []string{"--format", "yaml"}, io.Discard); err == nil { + t.Fatal("expected unsupported format error") + } +} + func TestEnvJobs(t *testing.T) { cases := []struct { name string diff --git a/packages/go/driver/internal/gotool/runner.go b/packages/go/driver/internal/gotool/runner.go new file mode 100644 index 0000000..0b6bb2d --- /dev/null +++ b/packages/go/driver/internal/gotool/runner.go @@ -0,0 +1,85 @@ +package gotool + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + driverconfig "go.ollin.sh/fmtkit/driver/config" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + report "go.ollin.sh/fmtkit/driver/report" +) + +// Runner is the thin orchestration around Execute: it parses the command line, +// loads config, runs the core, renders the report, and returns the exit code. +// +// Scope is how much of the working tree the formatter covers. The zero value +// (SelectionAll) covers everything, which is what `fmtkit go` and `fmtkit +// check` want; a scoped runner narrows to the working tree's changes. +type Runner struct { + Stdout io.Writer + Stderr io.Writer + Scope gitfiles.Selection +} + +// Run parses args for mode, executes the Go formatter and vet, renders the +// report, and returns the process exit code. +func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { + inv, err := ParseInvocation(mode, args, r.Stderr) + + if err != nil { + return 1 + } + + workRoot, err := os.Getwd() + + if err != nil { + r.errf("resolve cwd: %v\n", err) + + return 1 + } + + reportRoot := workRoot + + if strings.TrimSpace(inv.ReportRoot) != "" { + reportRoot = inv.ReportRoot + } + + cfg, err := driverconfig.Load(reportRoot, inv.ConfigPath) + + if err != nil { + r.errf("%v\n", err) + + return 1 + } + + outcome, err := Execute(ctx, Request{ + Mode: mode, + Paths: inv.Paths, + Config: cfg.WithJobs(inv.Jobs), + Root: workRoot, + Scope: r.Scope, + }) + + if err != nil { + r.errf("%v\n", err) + + return 1 + } + + renderer := report.Renderer{Root: reportRoot, Mode: mode} + + if err := renderer.Render(r.Stdout, inv.Output, outcome.Combined); err != nil { + r.errf("render report: %v\n", err) + + return 1 + } + + return outcome.ExitCode() +} + +func (r Runner) errf(format string, args ...any) { + _, _ = fmt.Fprintf(r.Stderr, format, args...) +} diff --git a/packages/go/driver/internal/cli/runner_test.go b/packages/go/driver/internal/gotool/runner_test.go similarity index 73% rename from packages/go/driver/internal/cli/runner_test.go rename to packages/go/driver/internal/gotool/runner_test.go index b756481..942ef37 100644 --- a/packages/go/driver/internal/cli/runner_test.go +++ b/packages/go/driver/internal/gotool/runner_test.go @@ -1,4 +1,4 @@ -package cli +package gotool import ( "bytes" @@ -9,63 +9,10 @@ import ( "strings" "testing" - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" - driverreport "go.ollin.sh/fmtkit/driver/report" - formatterengine "go.ollin.sh/fmtkit/formatter/engine" - "go.ollin.sh/fmtkit/vet" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + report "go.ollin.sh/fmtkit/driver/report" ) -func TestExitCode(t *testing.T) { - cases := []struct { - name string - mode Mode - result driverreport.Combined - want int - }{ - { - name: "vet errors fail either mode", - mode: FormatMode, - result: driverreport.Combined{Vet: vet.Report{Errors: []vet.ErrorResult{{Message: "boom"}}}}, - want: 1, - }, - { - name: "check passes on pass result", - mode: CheckMode, - result: driverreport.Combined{Formatter: formatterengine.Report{Result: "pass"}}, - want: 0, - }, - { - name: "check fails on non-pass result", - mode: CheckMode, - result: driverreport.Combined{Formatter: formatterengine.Report{Result: "fail"}}, - want: 1, - }, - { - name: "format fails on formatter errors", - mode: FormatMode, - result: driverreport.Combined{Formatter: formatterengine.Report{ - Result: "fail", - Errors: []formatterengine.ErrorResult{{Message: "walk failed"}}, - }}, - want: 1, - }, - { - name: "format succeeds after applying fixes", - mode: FormatMode, - result: driverreport.Combined{Formatter: formatterengine.Report{Result: "fixed"}}, - want: 0, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := exitCode(tc.mode, tc.result); got != tc.want { - t.Fatalf("exitCode(%s) = %d, want %d", tc.mode, got, tc.want) - } - }) - } -} - const cleanSource = `package sample func run() { @@ -85,7 +32,7 @@ func run() { // runInTempModulelessDir writes source to a Go file in a fresh temp dir, // chdirs there (so vet finds no module and skips), and runs the CLI. -func runInTempModulelessDir(t *testing.T, source string, mode Mode, extraArgs ...string) (code int, stdout, stderr string, file string) { +func runInTempModulelessDir(t *testing.T, source string, mode report.Mode, extraArgs ...string) (code int, stdout, stderr string, file string) { t.Helper() dir := t.TempDir() @@ -99,13 +46,13 @@ func runInTempModulelessDir(t *testing.T, source string, mode Mode, extraArgs .. var out, errOut bytes.Buffer - code = NewRunner(&out, &errOut).Run(context.Background(), mode, append(extraArgs, file)) + code = Runner{Stdout: &out, Stderr: &errOut}.Run(context.Background(), mode, append(extraArgs, file)) return code, out.String(), errOut.String(), file } func TestRunnerRunCleanFileJSON(t *testing.T) { - code, stdout, stderr, _ := runInTempModulelessDir(t, cleanSource, CheckMode, "--format", "json") + code, stdout, stderr, _ := runInTempModulelessDir(t, cleanSource, report.ModeCheck, "--format", "json") if code != 0 { t.Fatalf("exit = %d, stderr: %s", code, stderr) @@ -121,7 +68,7 @@ func TestRunnerRunCleanFileJSON(t *testing.T) { } func TestRunnerRunCheckModeReportsViolation(t *testing.T) { - code, stdout, _, file := runInTempModulelessDir(t, spacingViolationSource, CheckMode) + code, stdout, _, file := runInTempModulelessDir(t, spacingViolationSource, report.ModeCheck) if code != 1 { t.Fatalf("exit = %d, stdout: %s", code, stdout) @@ -139,7 +86,7 @@ func TestRunnerRunCheckModeReportsViolation(t *testing.T) { } func TestRunnerRunFormatModeRewritesFile(t *testing.T) { - code, stdout, stderr, file := runInTempModulelessDir(t, spacingViolationSource, FormatMode) + code, stdout, stderr, file := runInTempModulelessDir(t, spacingViolationSource, report.ModeFormat) if code != 0 { t.Fatalf("exit = %d, stdout: %s, stderr: %s", code, stdout, stderr) @@ -161,7 +108,7 @@ func TestRunnerRunFormatModeRewritesFile(t *testing.T) { } func TestRunnerRunRejectsUnsupportedFormat(t *testing.T) { - code, _, stderr, _ := runInTempModulelessDir(t, cleanSource, CheckMode, "--format", "yaml") + code, _, stderr, _ := runInTempModulelessDir(t, cleanSource, report.ModeCheck, "--format", "yaml") if code != 1 { t.Fatalf("exit = %d", code) @@ -179,11 +126,62 @@ func TestRunnerRunRejectsUnknownFlag(t *testing.T) { var out, errOut bytes.Buffer - if code := NewRunner(&out, &errOut).Run(context.Background(), CheckMode, []string{"--bogus"}); code != 1 { + if code := (Runner{Stdout: &out, Stderr: &errOut}).Run(context.Background(), report.ModeCheck, []string{"--bogus"}); code != 1 { t.Fatalf("exit = %d", code) } } +func TestRunnerReportsConfigLoadError(t *testing.T) { + dir := t.TempDir() + + if err := os.WriteFile(filepath.Join(dir, "sample.go"), []byte(cleanSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + t.Chdir(dir) + + var out, errOut bytes.Buffer + + // An explicit --config path that does not exist makes config.Load fail, so + // the runner reports it on stderr and exits 1 before running the formatter. + code := Runner{Stdout: &out, Stderr: &errOut}.Run(context.Background(), report.ModeCheck, + []string{"--config", filepath.Join(dir, "missing.yml"), "sample.go"}) + + if code != 1 { + t.Fatalf("exit = %d, want 1", code) + } + + if !strings.Contains(errOut.String(), "load config") { + t.Fatalf("expected a config-load error on stderr, got: %q", errOut.String()) + } +} + +func TestRunnerHonorsReportRootFlag(t *testing.T) { + work := t.TempDir() + reportRoot := t.TempDir() + + if err := os.WriteFile(filepath.Join(work, "sample.go"), []byte(cleanSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + t.Chdir(work) + + var out, errOut bytes.Buffer + + // --cwd points config discovery and report-relative paths at reportRoot while + // the process stays in work; a clean file still passes. + code := Runner{Stdout: &out, Stderr: &errOut}.Run(context.Background(), report.ModeCheck, + []string{"--cwd", reportRoot, "--format", "json", "sample.go"}) + + if code != 0 { + t.Fatalf("exit = %d, stderr: %s", code, errOut.String()) + } + + if !strings.Contains(out.String(), `"result":"pass"`) { + t.Fatalf("unexpected output: %s", out.String()) + } +} + // generatedViolationSource carries the same spacing violation as // spacingViolationSource, but is marked generated so the engine must never // rewrite it. @@ -281,7 +279,7 @@ func TestScopedRunnerFormatsOnlyTheWorkingTreesChanges(t *testing.T) { var out, errOut bytes.Buffer - code := NewScopedRunner(&out, &errOut, sourcefiles.SelectionChanged).Run(context.Background(), FormatMode, nil) + code := Runner{Stdout: &out, Stderr: &errOut, Scope: gitfiles.SelectionChanged}.Run(context.Background(), report.ModeFormat, nil) if code != 0 { t.Fatalf("exit code = %d, want 0\n%s\n%s", code, out.String(), errOut.String()) @@ -305,7 +303,7 @@ func TestUnscopedRunnerFormatsEveryOwnedFile(t *testing.T) { var out, errOut bytes.Buffer - code := NewRunner(&out, &errOut).Run(context.Background(), FormatMode, nil) + code := Runner{Stdout: &out, Stderr: &errOut}.Run(context.Background(), report.ModeFormat, nil) if code != 0 { t.Fatalf("exit code = %d, want 0\n%s\n%s", code, out.String(), errOut.String()) @@ -342,7 +340,7 @@ func TestScopedRunnerOnACleanTreeFormatsNothing(t *testing.T) { var out, errOut bytes.Buffer - code := NewScopedRunner(&out, &errOut, sourcefiles.SelectionChanged).Run(context.Background(), FormatMode, nil) + code := Runner{Stdout: &out, Stderr: &errOut, Scope: gitfiles.SelectionChanged}.Run(context.Background(), report.ModeFormat, nil) if code != 0 { t.Fatalf("exit code = %d, want 0\n%s\n%s", code, out.String(), errOut.String()) diff --git a/packages/go/driver/internal/sourcefiles/command.go b/packages/go/driver/internal/sourcefiles/command.go index 94b2654..9aef8fc 100644 --- a/packages/go/driver/internal/sourcefiles/command.go +++ b/packages/go/driver/internal/sourcefiles/command.go @@ -6,19 +6,13 @@ import ( "fmt" "io" "os" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" ) -// Run is the transitional entry point kept so existing callers compile -// unchanged; it delegates to RunCLI. -// -// Transitional: G5 adopts RunCLI directly. +// Run parses the `sources` subcommand flags, collects the formattable files +// under the given scopes, and prints them NUL-separated to stdout. func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { - return RunCLI(ctx, args, stdout, stderr) -} - -// RunCLI parses the `sources` subcommand flags and prints the collected files -// NUL-separated to stdout. -func RunCLI(ctx context.Context, args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("sources", flag.ContinueOnError) fs.SetOutput(stderr) @@ -43,11 +37,15 @@ func RunCLI(ctx context.Context, args []string, stdout, stderr io.Writer) int { } } - files, warnings, err := Collect(ctx, Options{ - Cwd: cwd, - IncludeDeclarations: *includeDeclarations, - Scopes: fs.Args(), - }) + collector, err := New(cwd, gitfiles.SelectionAll, *includeDeclarations) + + if err != nil { + _, _ = fmt.Fprintf(stderr, "[sources] %v\n", err) + + return 1 + } + + files, warnings, err := collector.Formattable(ctx, fs.Args()) for _, warning := range warnings { _, _ = fmt.Fprintf(stderr, "[sources] %s\n", warning) diff --git a/packages/go/driver/internal/sourcefiles/command_test.go b/packages/go/driver/internal/sourcefiles/command_test.go index 7223441..030eeb1 100644 --- a/packages/go/driver/internal/sourcefiles/command_test.go +++ b/packages/go/driver/internal/sourcefiles/command_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -func TestRunCLIPrintsNULSeparatedFiles(t *testing.T) { +func TestRunPrintsNULSeparatedFiles(t *testing.T) { dir := initRepo(t) writeFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") writeFile(t, filepath.Join(dir, "src", "notes.md"), "# Notes\n") @@ -17,7 +17,7 @@ func TestRunCLIPrintsNULSeparatedFiles(t *testing.T) { var stdout, stderr bytes.Buffer - code := RunCLI(context.Background(), []string{"--cwd", dir, "src"}, &stdout, &stderr) + code := Run(context.Background(), []string{"--cwd", dir, "src"}, &stdout, &stderr) if code != 0 { t.Fatalf("RunCLI exit = %d, stderr: %s", code, stderr.String()) @@ -41,14 +41,14 @@ func TestRunCLIPrintsNULSeparatedFiles(t *testing.T) { } } -func TestRunCLIIncludesDeclarationsFlag(t *testing.T) { +func TestRunIncludesDeclarationsFlag(t *testing.T) { dir := initRepo(t) writeFile(t, filepath.Join(dir, "types.d.ts"), "declare const value: string;\n") gitAdd(t, dir, ".") var stdout, stderr bytes.Buffer - code := RunCLI(context.Background(), []string{"--cwd", dir, "--include-declarations"}, &stdout, &stderr) + code := Run(context.Background(), []string{"--cwd", dir, "--include-declarations"}, &stdout, &stderr) if code != 0 { t.Fatalf("RunCLI exit = %d, stderr: %s", code, stderr.String()) @@ -61,14 +61,14 @@ func TestRunCLIIncludesDeclarationsFlag(t *testing.T) { } } -func TestRunCLIWarnsOnMissingScopes(t *testing.T) { +func TestRunWarnsOnMissingScopes(t *testing.T) { dir := initRepo(t) writeFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") gitAdd(t, dir, ".") var stdout, stderr bytes.Buffer - code := RunCLI(context.Background(), []string{"--cwd", dir, "missing"}, &stdout, &stderr) + code := Run(context.Background(), []string{"--cwd", dir, "missing"}, &stdout, &stderr) if code != 0 { t.Fatalf("RunCLI exit = %d", code) @@ -79,7 +79,7 @@ func TestRunCLIWarnsOnMissingScopes(t *testing.T) { } } -func TestRunCLIDefaultsToWorkingDirectory(t *testing.T) { +func TestRunDefaultsToWorkingDirectory(t *testing.T) { dir := initRepo(t) writeFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") gitAdd(t, dir, ".") @@ -87,7 +87,7 @@ func TestRunCLIDefaultsToWorkingDirectory(t *testing.T) { var stdout, stderr bytes.Buffer - if code := RunCLI(context.Background(), nil, &stdout, &stderr); code != 0 { + if code := Run(context.Background(), nil, &stdout, &stderr); code != 0 { t.Fatalf("RunCLI exit = %d, stderr: %s", code, stderr.String()) } @@ -99,32 +99,14 @@ func TestRunCLIDefaultsToWorkingDirectory(t *testing.T) { } } -func TestRunCLIReportsBadFlags(t *testing.T) { +func TestRunReportsBadFlags(t *testing.T) { var stdout, stderr bytes.Buffer - if code := RunCLI(context.Background(), []string{"--nope"}, &stdout, &stderr); code != 1 { + if code := Run(context.Background(), []string{"--nope"}, &stdout, &stderr); code != 1 { t.Fatalf("expected exit 1 for an unknown flag, got %d", code) } } -func TestRunDelegatesToRunCLI(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") - gitAdd(t, dir, ".") - - var stdout, stderr bytes.Buffer - - if code := Run(context.Background(), []string{"--cwd", dir}, &stdout, &stderr); code != 0 { - t.Fatalf("Run exit = %d, stderr: %s", code, stderr.String()) - } - - got := splitNUL(stdout.String()) - - if len(got) != 1 || got[0] != filepath.Join(dir, "app.ts") { - t.Fatalf("Run output mismatch, got %#v", got) - } -} - func splitNUL(s string) []string { if s == "" { return nil diff --git a/packages/go/driver/internal/sourcefiles/prettierignore_test.go b/packages/go/driver/internal/sourcefiles/prettierignore_test.go index 8302ff7..9b7e5c6 100644 --- a/packages/go/driver/internal/sourcefiles/prettierignore_test.go +++ b/packages/go/driver/internal/sourcefiles/prettierignore_test.go @@ -1,11 +1,12 @@ package sourcefiles import ( - "context" "os" "path/filepath" "reflect" "testing" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" ) func TestCollectSurfacesUnreadablePrettierIgnore(t *testing.T) { @@ -19,7 +20,7 @@ func TestCollectSurfacesUnreadablePrettierIgnore(t *testing.T) { t.Fatalf("mkdir .prettierignore: %v", err) } - if _, _, err := Collect(context.Background(), Options{Cwd: dir}); err == nil { + if _, _, err := collectFormattable(t, dir, false, gitfiles.SelectionAll); err == nil { t.Fatal("expected an error from an unreadable .prettierignore") } } @@ -32,7 +33,7 @@ func TestCollectHonorsPrettierIgnore(t *testing.T) { writeFile(t, filepath.Join(dir, "dist", "bundle.ts"), "const bundle = 1;\n") gitAdd(t, dir, ".") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll) if err != nil { t.Fatalf("collect: %v", err) @@ -56,7 +57,7 @@ func TestCollectLintableHonorsPrettierIgnore(t *testing.T) { writeFile(t, filepath.Join(dir, "vendor", "lib.ts"), "const lib = 1;\n") gitAdd(t, dir, ".") - files, _, err := CollectLintable(context.Background(), Options{Cwd: dir}) + files, _, err := collectLintable(t, dir, false, gitfiles.SelectionAll) if err != nil { t.Fatalf("collect lintable: %v", err) diff --git a/packages/go/driver/internal/sourcefiles/sourcefiles.go b/packages/go/driver/internal/sourcefiles/sourcefiles.go index 0c1a145..07a7367 100644 --- a/packages/go/driver/internal/sourcefiles/sourcefiles.go +++ b/packages/go/driver/internal/sourcefiles/sourcefiles.go @@ -16,11 +16,6 @@ import ( "go.ollin.sh/fmtkit/driver/internal/prettierignore" ) -// Selection re-exports gitfiles.Selection so existing callers keep compiling. -// -// Transitional: G5 adopts gitfiles.Selection directly. -type Selection = gitfiles.Selection - // Collector composes git discovery, the extension taxonomy, and the // .prettierignore matcher into the formatter's and linter's file lists. type Collector struct { @@ -29,29 +24,21 @@ type Collector struct { Filter filetypes.Filter } -// Options configures the transitional Collect/CollectLintable wrappers. -// -// Transitional: G5 adopts Collector directly. -type Options struct { - Cwd string - IncludeDeclarations bool - Scopes []string - - // Selection defaults to SelectionAll. - Selection Selection -} +// New builds a Collector rooted at cwd covering selection, keeping the files +// the taxonomy classifies. IncludeDeclarations keeps .d.ts declaration files. +func New(cwd string, selection gitfiles.Selection, includeDeclarations bool) (Collector, error) { + tree, err := gitfiles.NewTree(cwd) -const ( - // SelectionAll covers every non-ignored file: tracked plus untracked. - // - // Transitional: G5 adopts gitfiles.SelectionAll directly. - SelectionAll = gitfiles.SelectionAll + if err != nil { + return Collector{}, err + } - // SelectionChanged covers only what has diverged from HEAD. - // - // Transitional: G5 adopts gitfiles.SelectionChanged directly. - SelectionChanged = gitfiles.SelectionChanged -) + return Collector{ + Tree: tree, + Selection: selection, + Filter: filetypes.Filter{IncludeDeclarations: includeDeclarations}, + }, nil +} // Formattable lists the files the formatter owns under the given scopes: the TS // and Vue families plus the HTML and Markdown documents whose embedded scripts @@ -151,57 +138,3 @@ func (c Collector) honorPrettierIgnore(cwd string, files []string) ([]string, er return matcher.FilterAbs(cwd, files) } - -// Collect lists the files the formatter owns under the given scopes. -// -// Transitional: G5 adopts Collector directly. -func Collect(ctx context.Context, opts Options) ([]string, []string, error) { - collector, err := collectorFor(opts) - - if err != nil { - return nil, nil, err - } - - return collector.Formattable(ctx, opts.Scopes) -} - -// CollectLintable lists only the files oxlint can lint under the given scopes. -// -// Transitional: G5 adopts Collector directly. -func CollectLintable(ctx context.Context, opts Options) ([]string, []string, error) { - collector, err := collectorFor(opts) - - if err != nil { - return nil, nil, err - } - - return collector.Lintable(ctx, opts.Scopes) -} - -// ChangedPaths lists every file that diverges from HEAD under the given scopes, -// whatever its extension, skipping .prettierignore filtering. -// -// Transitional: G5 adopts gitfiles.Tree directly. -func ChangedPaths(ctx context.Context, cwd string, scopes []string) ([]string, error) { - tree, err := gitfiles.NewTree(cwd) - - if err != nil { - return nil, err - } - - return tree.ChangedPaths(ctx, scopes) -} - -func collectorFor(opts Options) (Collector, error) { - tree, err := gitfiles.NewTree(opts.Cwd) - - if err != nil { - return Collector{}, err - } - - return Collector{ - Tree: tree, - Selection: opts.Selection, - Filter: filetypes.Filter{IncludeDeclarations: opts.IncludeDeclarations}, - }, nil -} diff --git a/packages/go/driver/internal/sourcefiles/sourcefiles_test.go b/packages/go/driver/internal/sourcefiles/sourcefiles_test.go index cd35cc4..bc7b554 100644 --- a/packages/go/driver/internal/sourcefiles/sourcefiles_test.go +++ b/packages/go/driver/internal/sourcefiles/sourcefiles_test.go @@ -7,8 +7,37 @@ import ( "path/filepath" "reflect" "testing" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" ) +// collectFormattable and collectLintable build a Collector rooted at cwd and +// run the corresponding discovery, so each test names only the axes it cares +// about (declarations, selection, scopes). +func collectFormattable(t *testing.T, cwd string, includeDeclarations bool, selection gitfiles.Selection, scopes ...string) ([]string, []string, error) { + t.Helper() + + collector, err := New(cwd, selection, includeDeclarations) + + if err != nil { + t.Fatalf("new collector: %v", err) + } + + return collector.Formattable(context.Background(), scopes) +} + +func collectLintable(t *testing.T, cwd string, includeDeclarations bool, selection gitfiles.Selection, scopes ...string) ([]string, []string, error) { + t.Helper() + + collector, err := New(cwd, selection, includeDeclarations) + + if err != nil { + t.Fatalf("new collector: %v", err) + } + + return collector.Lintable(context.Background(), scopes) +} + func TestCollectFiltersSourceFiles(t *testing.T) { dir := initRepo(t) writeFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") @@ -18,7 +47,7 @@ func TestCollectFiltersSourceFiles(t *testing.T) { writeFile(t, filepath.Join(dir, "src", "index.html"), "\n") gitAdd(t, dir, ".") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Scopes: []string{"src"}}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect: %v", err) @@ -46,7 +75,7 @@ func TestCollectCanIncludeDeclarationFiles(t *testing.T) { writeFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") gitAdd(t, dir, ".") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, IncludeDeclarations: true, Scopes: []string{"src"}}) + files, warnings, err := collectFormattable(t, dir, true, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect: %v", err) @@ -77,7 +106,7 @@ func TestCollectLintableExcludesNonScriptDocuments(t *testing.T) { gitAdd(t, dir, ".") // Formatting owns the HTML and Markdown documents alongside the TS/Vue files. - formatFiles, _, err := Collect(context.Background(), Options{Cwd: dir, Scopes: []string{"src"}}) + formatFiles, _, err := collectFormattable(t, dir, false, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect: %v", err) @@ -97,7 +126,7 @@ func TestCollectLintableExcludesNonScriptDocuments(t *testing.T) { // Linting sees only the TS/Vue files: no HTML, no Markdown, and .d.ts stays // out unless declarations are requested. - lintFiles, _, err := CollectLintable(context.Background(), Options{Cwd: dir, Scopes: []string{"src"}}) + lintFiles, _, err := collectLintable(t, dir, false, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect lintable: %v", err) @@ -120,7 +149,7 @@ func TestCollectLintableCanIncludeDeclarationFiles(t *testing.T) { writeFile(t, filepath.Join(dir, "src", "index.html"), "\n") gitAdd(t, dir, ".") - files, _, err := CollectLintable(context.Background(), Options{Cwd: dir, IncludeDeclarations: true, Scopes: []string{"src"}}) + files, _, err := collectLintable(t, dir, true, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect lintable: %v", err) @@ -145,7 +174,7 @@ func TestCollectIncludesUntrackedAndIgnoresIgnored(t *testing.T) { writeFile(t, filepath.Join(dir, "untracked.vue"), "\n") writeFile(t, filepath.Join(dir, "ignored.ts"), "const ignored = true;\n") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll) if err != nil { t.Fatalf("collect: %v", err) @@ -171,10 +200,8 @@ func TestCollectScopesAndDeduplicatesFiles(t *testing.T) { writeFile(t, filepath.Join(dir, "other", "app.ts"), "const value = 2;\n") gitAdd(t, dir, ".") - files, warnings, err := Collect(context.Background(), Options{ - Cwd: dir, - Scopes: []string{"src", filepath.Join(dir, "src", "app.ts"), "missing"}, - }) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll, + "src", filepath.Join(dir, "src", "app.ts"), "missing") if err != nil { t.Fatalf("collect: %v", err) @@ -191,29 +218,6 @@ func TestCollectScopesAndDeduplicatesFiles(t *testing.T) { } } -func TestChangedPathsShimDelegatesToGitfiles(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, ".prettierignore"), "main.go\n") - writeFile(t, filepath.Join(dir, "main.go"), "package main\n") - gitAdd(t, dir, ".") - - files, err := ChangedPaths(context.Background(), dir, nil) - - if err != nil { - t.Fatalf("changed paths: %v", err) - } - - // The shim forwards to gitfiles, which does not consult .prettierignore. - want := []string{ - filepath.Join(dir, ".prettierignore"), - filepath.Join(dir, "main.go"), - } - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - func initRepo(t *testing.T) string { t.Helper() @@ -269,7 +273,7 @@ func TestCollectChangedCoversOnlyTheWorkingTreesChanges(t *testing.T) { writeFile(t, filepath.Join(dir, "untracked.vue"), "\n") writeFile(t, filepath.Join(dir, "ignored.ts"), "const ignored = true;\n") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) if err != nil { t.Fatalf("collect: %v", err) @@ -305,7 +309,7 @@ func TestCollectChangedIncludesStagedFiles(t *testing.T) { // A staged deletion leaves no file to format and must stay out. run(t, dir, "git", "rm", "-q", "removed.ts") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) if err != nil { t.Fatalf("collect: %v", err) @@ -327,7 +331,7 @@ func TestCollectChangedWorksBeforeTheFirstCommit(t *testing.T) { writeFile(t, filepath.Join(dir, "staged.ts"), "const staged = 1;\n") gitAdd(t, dir, "staged.ts") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) if err != nil { t.Fatalf("collect: %v", err) @@ -350,7 +354,7 @@ func TestCollectAllCoversCommittedFilesThatChangedSelectionSkips(t *testing.T) { gitAdd(t, dir, "untouched.ts") gitCommit(t, dir) - changed, _, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) + changed, _, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) if err != nil { t.Fatalf("collect changed: %v", err) @@ -360,7 +364,7 @@ func TestCollectAllCoversCommittedFilesThatChangedSelectionSkips(t *testing.T) { t.Fatalf("a clean working tree has no changes, got: %#v", changed) } - all, _, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionAll}) + all, _, err := collectFormattable(t, dir, false, gitfiles.SelectionAll) if err != nil { t.Fatalf("collect all: %v", err) @@ -379,7 +383,9 @@ func TestCollectDefaultsToAll(t *testing.T) { gitAdd(t, dir, "untouched.ts") gitCommit(t, dir) - files, _, err := Collect(context.Background(), Options{Cwd: dir}) + // The zero gitfiles.Selection is SelectionAll, so a Collector built with it + // must cover committed files a changed run would skip. + files, _, err := collectFormattable(t, dir, false, gitfiles.Selection(0)) if err != nil { t.Fatalf("collect: %v", err) diff --git a/packages/go/driver/internal/tsruntime/invoker.go b/packages/go/driver/internal/tsruntime/invoker.go index 958e97a..d53142a 100644 --- a/packages/go/driver/internal/tsruntime/invoker.go +++ b/packages/go/driver/internal/tsruntime/invoker.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" "go.ollin.sh/fmtkit/driver/internal/sidecarproto" "go.ollin.sh/fmtkit/driver/internal/sourcefiles" ) @@ -18,8 +19,8 @@ type Request struct { Scopes []string // Selection is how much of the working tree to cover within Scopes. It - // defaults to sourcefiles.SelectionAll. - Selection sourcefiles.Selection + // defaults to gitfiles.SelectionAll. + Selection gitfiles.Selection // Fix, when set, lets RunLint apply oxlint's safe fixes (--fix) rather // than only reporting violations. @@ -152,22 +153,24 @@ func (i Invoker) sourcesCwd() (string, error) { return cwd, nil } -func collect(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { - return sourcefiles.Collect(ctx, sourcefiles.Options{ - Cwd: cwd, - IncludeDeclarations: includeDeclarations, - Scopes: scopes, - Selection: selection, - }) +func collect(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection gitfiles.Selection) ([]string, []string, error) { + collector, err := sourcefiles.New(cwd, selection, includeDeclarations) + + if err != nil { + return nil, nil, err + } + + return collector.Formattable(ctx, scopes) } -func collectLintable(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { - return sourcefiles.CollectLintable(ctx, sourcefiles.Options{ - Cwd: cwd, - IncludeDeclarations: includeDeclarations, - Scopes: scopes, - Selection: selection, - }) +func collectLintable(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection gitfiles.Selection) ([]string, []string, error) { + collector, err := sourcefiles.New(cwd, selection, includeDeclarations) + + if err != nil { + return nil, nil, err + } + + return collector.Lintable(ctx, scopes) } // oxfmtConfigFor resolves the oxfmt config by precedence: the FMTKIT_OXFMTRC diff --git a/packages/go/driver/report/agent.go b/packages/go/driver/report/agent.go index fe00cc6..fc18e71 100644 --- a/packages/go/driver/report/agent.go +++ b/packages/go/driver/report/agent.go @@ -42,12 +42,12 @@ type agentViolation struct { Message string `json:"message"` } -// RenderAgent writes the agent-oriented JSON report representation. -func RenderAgent(w io.Writer, cwd string, report Combined) error { +// renderAgent writes the agent-oriented JSON report representation. +func (r Renderer) renderAgent(w io.Writer, report Combined) error { encoder := json.NewEncoder(w) encoder.SetIndent("", " ") - return encoder.Encode(toAgentReport(projectReport(cwd, report))) + return encoder.Encode(toAgentReport(projectReport(r.Root, report))) } func toAgentReport(report projectedReport) agentReport { diff --git a/packages/go/driver/report/json.go b/packages/go/driver/report/json.go index 20904f4..7a1c20a 100644 --- a/packages/go/driver/report/json.go +++ b/packages/go/driver/report/json.go @@ -37,9 +37,9 @@ type jsonViolation struct { Message string `json:"message"` } -// RenderJSON writes the JSON report representation. -func RenderJSON(w io.Writer, cwd string, report Combined) error { - return json.NewEncoder(w).Encode(toJSONReport(projectReport(cwd, report))) +// renderJSON writes the JSON report representation. +func (r Renderer) renderJSON(w io.Writer, report Combined) error { + return json.NewEncoder(w).Encode(toJSONReport(projectReport(r.Root, report))) } func toJSONReport(report projectedReport) jsonReport { diff --git a/packages/go/driver/report/projection_test.go b/packages/go/driver/report/projection_test.go index 75e1c67..e79da82 100644 --- a/packages/go/driver/report/projection_test.go +++ b/packages/go/driver/report/projection_test.go @@ -52,7 +52,7 @@ func TestProjectReportNormalizesFormatterAndVetResults(t *testing.T) { func TestRenderJSONUsesProjectedReport(t *testing.T) { var out bytes.Buffer - if err := RenderJSON(&out, "/work", sampleCombinedReport()); err != nil { + if err := (Renderer{Root: "/work"}).renderJSON(&out, sampleCombinedReport()); err != nil { t.Fatalf("render json: %v", err) } @@ -66,7 +66,7 @@ func TestRenderJSONUsesProjectedReport(t *testing.T) { func TestRenderAgentUsesProjectedReport(t *testing.T) { var out bytes.Buffer - if err := RenderAgent(&out, "/work", sampleCombinedReport()); err != nil { + if err := (Renderer{Root: "/work"}).renderAgent(&out, sampleCombinedReport()); err != nil { t.Fatalf("render agent: %v", err) } diff --git a/packages/go/driver/report/render.go b/packages/go/driver/report/render.go index 4225ccc..98d979f 100644 --- a/packages/go/driver/report/render.go +++ b/packages/go/driver/report/render.go @@ -9,26 +9,94 @@ import ( "go.ollin.sh/fmtkit/vet" ) +// Mode is whether the CLI is checking or rewriting files. It drives the verbs +// in the text render ("Checked"/"would apply" vs "Formatted"/"applied") and the +// exit-code policy (see Combined.ExitCode). +type Mode string + +// Format is the output representation the CLI renders. +type Format string + // Combined contains the formatter and vet reports rendered by the CLI. type Combined struct { Formatter formatterengine.Report `json:"formatter"` Vet vet.Report `json:"vet"` } +// Renderer writes a Combined report. Root is the base that file paths are made +// relative to; Mode selects the check/format verbs in the text render. +type Renderer struct { + Root string + Mode Mode +} + type jsonErrorMessage struct { File string `json:"file"` Message string `json:"message"` } +const ( + // ModeCheck reports what would change without touching files. + ModeCheck Mode = "check" + + // ModeFormat rewrites files in place. + ModeFormat Mode = "format" +) + +const ( + // FormatText is the human-readable, sectioned report. + FormatText Format = "text" + + // FormatJSON is the compact single-line JSON report. + FormatJSON Format = "json" + + // FormatAgent is the indented, agent-oriented JSON report. + FormatAgent Format = "agent" +) + +// ParseFormat resolves a --format flag value to a Format. Unknown values are +// rejected with the same error the CLI has always returned for them. +func ParseFormat(s string) (Format, error) { + switch Format(s) { + case FormatText, FormatJSON, FormatAgent: + return Format(s), nil + default: + return "", errors.New("unsupported output format") + } +} + +// ExitCode maps a combined report onto a process exit code for the given mode. +// Vet errors always fail. In check mode any non-pass formatter result fails; in +// format mode only formatter errors (not fixable violations) fail. +func (c Combined) ExitCode(m Mode) int { + if c.Vet.ErrorCount() > 0 { + return 1 + } + + if m == ModeCheck { + if c.Formatter.Result == formatterengine.ResultPass { + return 0 + } + + return 1 + } + + if c.Formatter.ErrorCount() > 0 { + return 1 + } + + return 0 +} + // Render writes the report in the requested output format. -func Render(w io.Writer, format, cwd, mode string, report Combined) error { +func (r Renderer) Render(w io.Writer, format Format, report Combined) error { switch format { - case "text": - return RenderText(w, cwd, mode, report) - case "json": - return RenderJSON(w, cwd, report) - case "agent": - return RenderAgent(w, cwd, report) + case FormatText: + return r.renderText(w, report) + case FormatJSON: + return r.renderJSON(w, report) + case FormatAgent: + return r.renderAgent(w, report) default: return errors.New("unsupported output format") } diff --git a/packages/go/driver/report/render_test.go b/packages/go/driver/report/render_test.go index ab509ff..a32a50d 100644 --- a/packages/go/driver/report/render_test.go +++ b/packages/go/driver/report/render_test.go @@ -15,10 +15,12 @@ func TestRenderDispatch(t *testing.T) { t.Cleanup(func() { color.NoColor = previous }) - for _, format := range []string{"text", "json", "agent"} { + renderer := Renderer{Root: "/work", Mode: ModeCheck} + + for _, format := range []Format{FormatText, FormatJSON, FormatAgent} { var out bytes.Buffer - if err := Render(&out, format, "/work", "check", sampleCombinedReport()); err != nil { + if err := renderer.Render(&out, format, sampleCombinedReport()); err != nil { t.Fatalf("render %s: %v", format, err) } @@ -29,13 +31,89 @@ func TestRenderDispatch(t *testing.T) { var out bytes.Buffer - err := Render(&out, "yaml", "/work", "check", sampleCombinedReport()) + err := renderer.Render(&out, Format("yaml"), sampleCombinedReport()) if err == nil || err.Error() != "unsupported output format" { t.Fatalf("expected unsupported format error, got %v", err) } } +func TestParseFormat(t *testing.T) { + for _, tc := range []struct { + in string + want Format + }{ + {"text", FormatText}, + {"json", FormatJSON}, + {"agent", FormatAgent}, + } { + got, err := ParseFormat(tc.in) + + if err != nil { + t.Fatalf("ParseFormat(%q): %v", tc.in, err) + } + + if got != tc.want { + t.Fatalf("ParseFormat(%q) = %q, want %q", tc.in, got, tc.want) + } + } + + if _, err := ParseFormat("yaml"); err == nil || err.Error() != "unsupported output format" { + t.Fatalf("expected unsupported format error, got %v", err) + } +} + +func TestExitCode(t *testing.T) { + cases := []struct { + name string + mode Mode + report Combined + want int + }{ + { + name: "vet errors fail either mode", + mode: ModeFormat, + report: Combined{Vet: vet.Report{Errors: []vet.ErrorResult{{Message: "boom"}}}}, + want: 1, + }, + { + name: "check passes on pass result", + mode: ModeCheck, + report: Combined{Formatter: formatterengine.Report{Result: "pass"}}, + want: 0, + }, + { + name: "check fails on non-pass result", + mode: ModeCheck, + report: Combined{Formatter: formatterengine.Report{Result: "fail"}}, + want: 1, + }, + { + name: "format fails on formatter errors", + mode: ModeFormat, + report: Combined{Formatter: formatterengine.Report{ + Result: "fail", + Errors: []formatterengine.ErrorResult{{Message: "walk failed"}}, + }}, + want: 1, + }, + { + name: "format succeeds after applying fixes", + mode: ModeFormat, + report: Combined{Formatter: formatterengine.Report{Result: "fixed"}}, + want: 0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.report.ExitCode(tc.mode); got != tc.want { + t.Fatalf("ExitCode(%s) = %d, want %d", tc.mode, got, tc.want) + } + }) + } +} + func TestCombinedResult(t *testing.T) { cases := []struct { name string diff --git a/packages/go/driver/report/text.go b/packages/go/driver/report/text.go index 5314a6b..f80ec3b 100644 --- a/packages/go/driver/report/text.go +++ b/packages/go/driver/report/text.go @@ -9,13 +9,13 @@ import ( formatterengine "go.ollin.sh/fmtkit/formatter/engine" ) -// RenderText writes the human-readable text report representation. -func RenderText(w io.Writer, cwd, mode string, report Combined) error { +// renderText writes the human-readable text report representation. +func (r Renderer) renderText(w io.Writer, report Combined) error { if _, err := color.New(color.Bold).Fprintf(w, "\nFormatter\n\n"); err != nil { return err } - if err := renderFormatterText(w, cwd, mode, report.Formatter); err != nil { + if err := renderFormatterText(w, r.Root, r.Mode, report.Formatter); err != nil { return err } @@ -23,10 +23,10 @@ func RenderText(w io.Writer, cwd, mode string, report Combined) error { return err } - return renderVetText(w, cwd, report) + return renderVetText(w, r.Root, report) } -func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.Report) error { +func renderFormatterText(w io.Writer, cwd string, mode Mode, report formatterengine.Report) error { if report.Files == 0 && len(report.Errors) == 0 { if _, err := color.New(color.FgYellow).Fprintf(w, " No Go files found.\n\n"); err != nil { return err @@ -42,7 +42,7 @@ func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.R } else { action := "Checked" - if mode == "format" { + if mode == ModeFormat { action = "Formatted" } @@ -87,7 +87,7 @@ func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.R if result.Changed { verb := "would apply" - if mode == "format" { + if mode == ModeFormat { verb = "applied" } diff --git a/packages/go/driver/report/text_test.go b/packages/go/driver/report/text_test.go index f8ca68a..84d1d6e 100644 --- a/packages/go/driver/report/text_test.go +++ b/packages/go/driver/report/text_test.go @@ -13,7 +13,7 @@ import ( // renderTextPlain renders without ANSI escapes so substring asserts are // stable. color.NoColor is global state, so these tests must not run in // parallel. -func renderTextPlain(t *testing.T, cwd, mode string, report Combined) string { +func renderTextPlain(t *testing.T, cwd string, mode Mode, report Combined) string { t.Helper() previous := color.NoColor @@ -23,7 +23,7 @@ func renderTextPlain(t *testing.T, cwd, mode string, report Combined) string { var out bytes.Buffer - if err := RenderText(&out, cwd, mode, report); err != nil { + if err := (Renderer{Root: cwd, Mode: mode}).renderText(&out, report); err != nil { t.Fatalf("render text: %v", err) }