diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 25ab9e5..ea1e3c5 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -49,7 +49,7 @@ jobs: - name: Run unit tests without race detector if: runner.os == 'Windows' run: | - go test -v -cover -coverpkg=./... ./... + go test -v -cover -coverpkg=.,./testo...,./internal/... ./... - name: Test examples output run: go test -v -tags e2e -count=1 ./examples_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee43d2..05a16bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +## Added + +- Auxiliary command line tool for Testo featuring linter, suites explorer and runner. + ## [1.6.0] - 2026-07-17 ### Added diff --git a/Makefile b/Makefile index bb731fa..4c3369d 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ doc: # get test coverage coverage: - go test -coverprofile=coverage.out -coverpkg=./... ./... + go test -coverprofile=coverage.out -coverpkg=.,./testo...,./internal/... ./... go tool cover -func coverage.out # visualize test coverage @@ -39,7 +39,7 @@ coverage-html: coverage go tool cover -html coverage.out install: - go install ./cmd/testo + go install ./cmd/... update-examples-output: ./update-examples-output.sh diff --git a/README.md b/README.md index d461db8..58a1d1a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Add some flavor to your tests with - Test reflection - deeply inspect test's meta-information. - Caching - key-value storage persistent between test runs. - [Zero dependencies](./go.mod). +- [Auxiliary command line tool](./cmd/testo) - linter, suites explorer and runner. ## Why Testo @@ -112,6 +113,25 @@ Makes it easier to run and debug individual suite tests and adds helpful snippet ![VSCode extension screenshot showing codelens buttons for running and debugging a test](./vscode-extension/example.png) +## Testo command line tool + +Testo has an auxiliary command line tool featuring linter, suites explorer and runner. + +[See more here](./cmd/testo). + +Example: + +```bash +go install github.com/ozontech/testo/cmd/testo + +testo lint ./... +testo run mypkg/Functional.TestFoo +testo suites -f "{{ .Package }}/{{ .Suite }}" | fzf +``` + +> [!NOTE] +> This command line tool is _completely optional_ and _is not required_ to run tests. + ## Minimum supported Go version Testo guarantees to support at least **3 latest major** [Go releases](https://go.dev/doc/devel/release). diff --git a/cmd/testo/README.md b/cmd/testo/README.md new file mode 100644 index 0000000..ae5256e --- /dev/null +++ b/cmd/testo/README.md @@ -0,0 +1,31 @@ +# Testo CTL + +Auxiliary command line tool for Testo featuring linter, suites explorer and runner. + +> [!WARNING] +> This tool is experimental, handle with care; +> may change without warning + +## Install + +```bash +go install github.com/ozontech/testo/cmd/testo +``` + +## Usage + +Run `testo -h` to see available commands: + +```txt +Usage: + testo [command] + +Available Commands: + lint Run testo linter + run Run testo suites + suites Show testo suites + tags Show project build tags + version Show testo version +``` + +Run `testo [command] -h` to show help for the given command. diff --git a/cmd/testo/internal/cli/cli.go b/cmd/testo/internal/cli/cli.go new file mode 100644 index 0000000..1697a44 --- /dev/null +++ b/cmd/testo/internal/cli/cli.go @@ -0,0 +1,212 @@ +package cli + +import ( + "bytes" + "cmp" + _ "embed" + "errors" + "flag" + "fmt" + "maps" + "os" + "slices" +) + +//go:embed logo.txt +var logo string + +func usage(f *flag.FlagSet) { + var buf bytes.Buffer + + fmt.Fprint(&buf, logo) + fmt.Fprintln(&buf, "Usage:") + fmt.Fprintf(&buf, " %s [command]\n\n", os.Args[0]) + fmt.Fprintln(&buf, "Available Commands:") + + for _, cmd := range slices.Sorted(maps.Keys(commands)) { + fmt.Fprintf(&buf, " %-10s %s\n", cmd, commands[cmd].Short) + } + + f.Output().Write(buf.Bytes()) + f.PrintDefaults() +} + +func Run() { + flag.Usage = func() { + usage(flag.CommandLine) + } + + if len(os.Args) < 2 { + flag.Parse() + flag.Usage() + + os.Exit(2) + } + + if err := run(os.Args[1], os.Args[2:]...); err != nil { + var errExit ExitError + + if !errors.As(err, &errExit) { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + errExit.Print() + os.Exit(errExit.Code) + } +} + +func run(command string, args ...string) error { + switch command { + case "-h", "-help", "--help": + usage(flag.CommandLine) + + return nil + } + + r, ok := commands[command] + if !ok { + fmt.Fprintf(flag.CommandLine.Output(), "unknown subcommand: %q\n\n", command) + + usage(flag.CommandLine) + os.Exit(2) + + return nil + } + + return r.Run(args...) +} + +type Command interface { + Run(args ...string) error +} + +var commands = make(map[string]registered) + +type registered struct { + Short string + Run func(args ...string) error +} + +type config struct { + Short string + Usage string + Long string + Args ArgsFunc +} + +type Option func(conf *config) + +func WithShort(short string) Option { + return func(conf *config) { + conf.Short = short + } +} + +func WithUsage(u string) Option { + return func(conf *config) { + conf.Usage = u + } +} + +func WithLong(u string) Option { + return func(conf *config) { + conf.Long = u + } +} + +func WithoutArgs() Option { + return func(conf *config) { + conf.Args = func(args ...string) error { + if len(args) == 0 { + return nil + } + + return fmt.Errorf("unexpected argument: %q", args[0]) + } + } +} + +type ArgsFunc func(args ...string) error + +func Add[C Command](name string, flags func(f *flag.FlagSet, cmd *C), options ...Option) { + var conf config + + for _, o := range options { + o(&conf) + } + + var command C + + commands[name] = registered{ + Short: conf.Short, + Run: func(args ...string) error { + f := flag.NewFlagSet(name, flag.ExitOnError) + + flags(f, &command) + + f.Usage = func() { + long := cmp.Or(conf.Long, conf.Short) + + if long != "" { + fmt.Fprintf(f.Output(), "%s\n\n", long) + } + + fmt.Fprintln(f.Output(), "Usage:") + + var hasFlags bool + + f.VisitAll(func(*flag.Flag) { hasFlags = true }) + + if conf.Usage != "" { + fmt.Fprintf(f.Output(), " %s %s %s\n", os.Args[0], name, conf.Usage) + } else if hasFlags { + fmt.Fprintf(f.Output(), " %s %s [flags]\n", os.Args[0], name) + } else { + fmt.Fprintf(f.Output(), " %s %s\n", os.Args[0], name) + } + + if hasFlags { + fmt.Fprintln(f.Output(), "\nFlags:") + + f.PrintDefaults() + } + } + + if err := parseFlagSet(f, args); err != nil { + return err + } + + positional := f.Args() + + if conf.Args != nil { + if err := conf.Args(positional...); err != nil { + return fmt.Errorf("%s %s: %w", os.Args[0], name, err) + } + } + + return command.Run(positional...) + }, + } +} + +func parseFlagSet(f *flag.FlagSet, args []string) error { + positional := make([]string, 0, len(args)) + + for { + if err := f.Parse(args); err != nil { + return err + } + + args = args[len(args)-f.NArg():] + if len(args) == 0 { + break + } + + positional = append(positional, args[0]) + + args = args[1:] + } + + return f.Parse(positional) +} diff --git a/cmd/testo/internal/cli/errors.go b/cmd/testo/internal/cli/errors.go new file mode 100644 index 0000000..2bca208 --- /dev/null +++ b/cmd/testo/internal/cli/errors.go @@ -0,0 +1,45 @@ +package cli + +import ( + "fmt" + "os" +) + +type ExitError struct { + Code int + + stdout string + stderr string +} + +func (e ExitError) Error() string { + return fmt.Sprintf("exit code %d", e.Code) +} + +func Exit(code int) ExitError { + return ExitError{ + Code: code, + } +} + +func (e ExitError) Stdout(s string) ExitError { + e.stdout = s + + return e +} + +func (e ExitError) Stderr(s string) ExitError { + e.stderr = s + + return e +} + +func (e ExitError) Print() { + if e.stdout != "" { + fmt.Fprint(os.Stdout, e.stdout) + } + + if e.stderr != "" { + fmt.Fprint(os.Stderr, e.stderr) + } +} diff --git a/cmd/testo/internal/cli/flag.go b/cmd/testo/internal/cli/flag.go new file mode 100644 index 0000000..5c20bb8 --- /dev/null +++ b/cmd/testo/internal/cli/flag.go @@ -0,0 +1,52 @@ +package cli + +import ( + "bytes" + "encoding/json" + "flag" + "text/template" +) + +var _ flag.Value = (*FlagTemplate)(nil) + +type FlagTemplate struct { + template *template.Template + source string +} + +func (f *FlagTemplate) Execute(v any) (string, error) { + var buf bytes.Buffer + + if err := f.template.Execute(&buf, v); err != nil { + return "", err + } + + return buf.String(), nil +} + +func (f *FlagTemplate) Set(s string) error { + funcs := template.FuncMap{ + "json": func(v any) string { + m, err := json.Marshal(v) + if err != nil { + return err.Error() + } + + return string(m) + }, + } + + parsed, err := template.New("flag").Funcs(funcs).Parse(s) + if err != nil { + return err + } + + f.template = parsed + f.source = s + + return nil +} + +func (f *FlagTemplate) String() string { + return f.source +} diff --git a/cmd/testo/internal/cli/logo.txt b/cmd/testo/internal/cli/logo.txt new file mode 100644 index 0000000..94edc3a --- /dev/null +++ b/cmd/testo/internal/cli/logo.txt @@ -0,0 +1,15 @@ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣷⡄ +⠀⠀⠀⠀⢀⣴⣿⣿⣷⣄⠀⣿⣿⣿⣿⣿⣿⣿⠇⣠⣾⣿⣿⣿⣦ +⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣷⡸⣿⣿⣿⣿⣿⣿⣼⣿⣿⣿⣿⣿⣿⡆ +⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ +⠀⠀⠀⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇ +⠀⠀⠀⠀⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿ +⠀⠀⠀⠀⠀⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⠁ +⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⠙⠿⠿⠿⠿⠹⣿⣿⣿⢏⣿⣿⠃ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⣴⣶⣶⣶⣤⣤⣤⣁⠘⠛⠁ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣉⣉⣀⣀⣈⣉⣉⠙⠛⠿⣿ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠿⣿⣿⣿⣿⣿⣿⣷⡶⠄ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠁ diff --git a/cmd/testo/internal/cmd/cmd.go b/cmd/testo/internal/cmd/cmd.go new file mode 100644 index 0000000..cdd3539 --- /dev/null +++ b/cmd/testo/internal/cmd/cmd.go @@ -0,0 +1,3 @@ +package cmd + +const DefaultTesto = "github.com/ozontech/testo" diff --git a/cmd/testo/internal/cmd/cmdlint/lint.go b/cmd/testo/internal/cmd/cmdlint/lint.go new file mode 100644 index 0000000..1d09b0a --- /dev/null +++ b/cmd/testo/internal/cmd/cmdlint/lint.go @@ -0,0 +1,57 @@ +package cmdlint + +import ( + "bytes" + "errors" + "flag" + + "github.com/ozontech/testo/cmd/testo/internal/cli" + "github.com/ozontech/testo/cmd/testo/internal/cmd" + "github.com/ozontech/testo/cmd/testo/internal/loader" +) + +func init() { + cli.Add("lint", func(f *flag.FlagSet, c *Cmd) { + f.StringVar( + &c.Load.Tags, + "tags", + "", + "build tags separated by comma, derived from source if empty", + ) + f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") + f.BoolVar(&c.Load.Strict, "strict", false, "enable strict mode") + f.BoolVar(&c.JSON, "json", false, "output json") + }, + cli.WithUsage("[flags] [pattern...] [flags]"), + cli.WithShort("Run testo linter")) +} + +type Cmd struct { + Load loader.Config + JSON bool +} + +func (c Cmd) Run(patterns ...string) error { + _, err := loader.Load(c.Load, patterns...) + if err == nil { + return nil + } + + var errLoad *loader.LoadError + + if !errors.As(err, &errLoad) { + return err + } + + var buf bytes.Buffer + + for _, d := range errLoad.Diagnostics { + if c.JSON { + d.JSON(&buf, errLoad.FSet) + } else { + d.Println(&buf, errLoad.FSet) + } + } + + return cli.Exit(1).Stdout(buf.String()) +} diff --git a/cmd/testo/internal/cmd/cmdrun/run.go b/cmd/testo/internal/cmd/cmdrun/run.go new file mode 100644 index 0000000..adf8775 --- /dev/null +++ b/cmd/testo/internal/cmd/cmdrun/run.go @@ -0,0 +1,297 @@ +package cmdrun + +import ( + "context" + "errors" + "flag" + "fmt" + "maps" + "os" + "os/exec" + "regexp" + "slices" + "strings" + + "github.com/ozontech/testo/cmd/testo/internal/cli" + "github.com/ozontech/testo/cmd/testo/internal/cmd" + "github.com/ozontech/testo/cmd/testo/internal/loader" +) + +func init() { + cli.Add("run", func(f *flag.FlagSet, c *Cmd) { + f.StringVar( + &c.Load.Tags, + "tags", + "", + "build tags separated by comma, derived from source if empty", + ) + f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") + f.BoolVar(&c.N, "n", false, "print the commands but do not run them") + f.BoolVar(&c.Verbose, "v", false, "verbose output") + f.BoolVar(&c.JSON, "json", false, "log verbose output and test results in JSON") + }, + cli.WithShort("Run testo suites"), + cli.WithUsage(`[flags] [pattern] [flags] -- [test flags] + +Patterns: + suite suite regex + suite.test suite and test regex + .test test regex + package/suite package and suite + package/suite.test package, suite and test regex + package/ package regex`), + ) +} + +type Cmd struct { + Load loader.Config + N bool + Verbose bool + JSON bool +} + +type runMatched struct { + Suite loader.Suite + Tests map[string]struct{} +} + +func (c Cmd) Run(patterns ...string) error { + id, extraFlags, err := c.parse(patterns...) + if err != nil { + return err + } + + suites, err := loader.Load(c.Load, "./...") + if err != nil { + return err + } + + matched := make(map[string]runMatched) + + if id != nil { + for _, s := range suites { + tests, ok := id.match(s) + + if !ok { + continue + } + + id := s.ID() + + if m, ok := matched[id]; ok { + maps.Copy(m.Tests, tests) + } else { + matched[id] = runMatched{ + Suite: s, + Tests: tests, + } + } + } + } else { + for _, s := range suites { + matched[s.ID()] = runMatched{Suite: s} + } + } + + if len(matched) == 0 { + if id != nil { + return fmt.Errorf("%q did not match any suites", id.Source) + } + + return errors.New("testo suites not found") + } + + res, err := c.buildGoTest(slices.Collect(maps.Values(matched)), extraFlags) + if err != nil { + return fmt.Errorf("failed to build go test command: %w", err) + } + + if c.N { + fmt.Println(res.String()) + + return nil + } + + return res.Run() +} + +func (c Cmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error) { + packages := make(map[string]struct{}) + suiteCallers := make(map[string]struct{}) + tests := make(map[string]struct{}) + + seenTags := make(map[string]bool) + var tags []string + + for _, m := range matched { + runners, err := m.Suite.Runners(context.Background()) + if err != nil { + return nil, fmt.Errorf("find runners for suite %q: %w", m.Suite.Name, err) + } + + for _, r := range runners { + packages[r.Dir] = struct{}{} + suiteCallers[fmt.Sprintf("^%s$/^%s$", r.Name, m.Suite.Name)] = struct{}{} + + for t := range strings.SplitSeq(r.Tags, ",") { + if seenTags[t] { + continue + } + + seenTags[t] = true + tags = append(tags, t) + } + } + + for t := range m.Tests { + tests[t] = struct{}{} + } + } + + if len(matched) > 0 && len(packages) == 0 { + return nil, errors.New("suite callers not found") + } + + args := []string{"test", "-tags", strings.Join(tags, ",")} + + if c.Verbose { + args = append(args, "-v") + } + + if c.JSON { + args = append(args, "-json") + } + + for p := range packages { + args = append(args, p) + } + + if len(packages) == 0 { + args = append(args, ".") + } + + if len(suiteCallers) > 0 { + args = append( + args, + "-run", + strings.Join(slices.Sorted(maps.Keys(suiteCallers)), "|"), + ) + } + + if len(tests) > 0 { + args = append( + args, + "-testo.m", + fmt.Sprintf( + "^(%s)$", + strings.Join(slices.Sorted(maps.Keys(tests)), "|"), + ), + ) + } + + args = append(args, extra...) + + command := exec.Command("go", args...) + + command.Stdout = os.Stdout + command.Stderr = os.Stderr + command.Env = os.Environ() + + return command, nil +} + +func (c Cmd) parse(args ...string) (id *runID, extra []string, err error) { + for i, p := range args { + if strings.HasPrefix(p, "-") { + extra = append(extra, args[i:]...) + + break + } + + if id != nil { + return nil, nil, fmt.Errorf("unexpected positional argument: %q", p) + } + + parsed, err := c.id(p) + if err != nil { + return nil, nil, fmt.Errorf("parse %q: %w", p, err) + } + + id = &parsed + } + + return id, extra, nil +} + +func (c Cmd) id(pattern string) (runID, error) { + id := runID{Source: pattern} + + var pkg, suite, test string + + pattern, rest, ok := strings.Cut(pattern, "/") + if ok { + pkg = pattern + + pattern = rest + } + + suite, test, _ = strings.Cut(pattern, ".") + + var err error + + if pkg != "" { + id.Package, err = regexp.Compile(pkg) + if err != nil { + return runID{}, fmt.Errorf("parse package %q: %w", pkg, err) + } + } + + if suite != "" { + id.Suite, err = regexp.Compile(suite) + if err != nil { + return runID{}, fmt.Errorf("parse suite %q: %w", suite, err) + } + } + + if test != "" { + id.Test, err = regexp.Compile(test) + if err != nil { + return runID{}, fmt.Errorf("parse test %q: %w", test, err) + } + } + + return id, nil +} + +type runID struct { + Package *regexp.Regexp + Suite *regexp.Regexp + Test *regexp.Regexp + + Source string +} + +func (id runID) match(suite loader.Suite) (tests map[string]struct{}, ok bool) { + if id.Package != nil && !id.Package.MatchString(suite.Package.Path) { + return nil, false + } + + if id.Suite != nil && !id.Suite.MatchString(suite.Name) { + return nil, false + } + + if id.Test == nil { + return nil, true + } + + tests = make(map[string]struct{}) + + for _, t := range suite.Tests { + if id.Test.MatchString(t.Name) { + tests[t.Name] = struct{}{} + ok = true + } + } + + return tests, ok +} diff --git a/cmd/testo/internal/cmd/cmdsuites/suites.go b/cmd/testo/internal/cmd/cmdsuites/suites.go new file mode 100644 index 0000000..7c8b433 --- /dev/null +++ b/cmd/testo/internal/cmd/cmdsuites/suites.go @@ -0,0 +1,141 @@ +package cmdsuites + +import ( + "flag" + "fmt" + "go/token" + "path/filepath" + + "github.com/ozontech/testo/cmd/testo/internal/cli" + "github.com/ozontech/testo/cmd/testo/internal/cmd" + "github.com/ozontech/testo/cmd/testo/internal/loader" +) + +func init() { + cli.Add("suites", func(f *flag.FlagSet, c *Cmd) { + c.Format.Set("{{ .Package }}/{{ .Suite }}") + + f.StringVar( + &c.Load.Tags, + "tags", + "", + "build tags separated by comma, derived from source if empty", + ) + f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") + f.Var(&c.Format, "f", "output format") + f.BoolVar(&c.Nul, "0", false, "output each line delimited by NUL byte") + }, + cli.WithShort("Show testo suites"), + cli.WithUsage(`[flags] [pattern...] [flags] + +Format: + flag -f accepts Go text/template string with the following data as input: + +`+templateTypes("\t")+` + +Examples: + pick suite test with fzf and bat preview + + testo suites ./... -0 -f '{{ .Package }}/{{ .Suite }}.{{ .Test }} {{ .Test.Pos.Path }} {{ .Test.Pos.Line }}' | fzf --read0 --delimiter " " --with-nth 1 --preview 'bat -Ss --color always --plain --tabs 4 --line-range {3}:+$FZF_PREVIEW_LINES {2}' --accept-nth 1 --preview-window up + + output as json and filter with jq + + testo suites ./... -f '{{ json .Test }}' | jq '. | select(.Parametrized)' +`), + ) +} + +type Cmd struct { + Load loader.Config + Format cli.FlagTemplate + Nul bool +} + +func (c Cmd) Run(patterns ...string) error { + suites, err := loader.Load(c.Load, patterns...) + if err != nil { + return err + } + + seen := make(map[string]bool) + + for _, s := range suites { + err := c.printSuite(s, seen) + if err != nil { + return err + } + } + + return nil +} + +func (c Cmd) printSuite(suite loader.Suite, seen map[string]bool) error { + newPos := func(p token.Position) Pos { + return Pos{ + Dir: filepath.Dir(p.Filename), + Filename: filepath.Base(p.Filename), + Path: p.Filename, + Line: p.Line, + Column: p.Column, + } + } + + newParams := func(ps []loader.Parameter) []Parameter { + s := make([]Parameter, 0, len(ps)) + + for _, p := range ps { + s = append(s, Parameter{Name: p.Name}) + } + + return s + } + + data := Data{ + Package: Package{ + Name: suite.Package.Name, + Path: suite.Package.Path, + Dir: suite.Package.Dir, + }, + Suite: Suite{ + Name: suite.Name, + Pos: newPos(suite.FSet.Position(suite.Pos)), + }, + } + + for _, t := range suite.Tests { + data.Tests = append(data.Tests, Test{ + Name: t.Name, + Pos: newPos(suite.FSet.Position(t.Pos)), + Parametrized: t.Parametrized, + Parameters: newParams(t.Parameters), + }) + } + + for _, t := range suite.Tests { + data.Test = Test{ + Name: t.Name, + Pos: newPos(suite.FSet.Position(t.Pos)), + Parametrized: t.Parametrized, + Parameters: newParams(t.Parameters), + } + + line, err := c.Format.Execute(data) + if err != nil { + return err + } + + if seen[line] { + continue + } + + seen[line] = true + + if c.Nul { + fmt.Print(line, "\x00") + } else { + fmt.Println(line) + } + } + + return nil +} diff --git a/cmd/testo/internal/cmd/cmdsuites/template.go b/cmd/testo/internal/cmd/cmdsuites/template.go new file mode 100644 index 0000000..51b8fb0 --- /dev/null +++ b/cmd/testo/internal/cmd/cmdsuites/template.go @@ -0,0 +1,99 @@ +package cmdsuites + +import ( + "bytes" + _ "embed" + "fmt" + "strings" + "unicode" +) + +//go:embed template.go +var currentFile string + +//types:start +type Data struct { + Package Package // suite package + Suite Suite // current suite + Test Test // current test for this suite + Tests []Test // all tests +} + +type Package struct { + Name string + Path string + Dir string +} + +type Suite struct { + Name string + Pos Pos +} + +type Test struct { + Name string + Pos Pos + Parametrized bool + Parameters []Parameter +} + +type Parameter struct { + Name string +} + +type Pos struct { + Dir string // file dir + Filename string // base file name + Path string // absolute path + Line int // 1-based line number + Column int // 1-based column number +} + +//types:end + +func (p Parameter) String() string { + return p.Name +} + +func (p Package) String() string { + return p.Name +} + +func (s Suite) String() string { + return s.Name +} + +func (t Test) String() string { + return t.Name +} + +func (p Pos) String() string { + return fmt.Sprintf("%s:%d:%d", p.Path, p.Line, p.Column) +} + +func templateTypes(indent string) string { + const start = "//types:start" + const end = "//types:end" + + var inTypes bool + + var buf bytes.Buffer + +lines: + for line := range strings.Lines(currentFile) { + switch { + case strings.HasPrefix(line, start): + inTypes = true + + case strings.HasPrefix(line, end): + break lines + + case inTypes: + buf.WriteString(indent) + buf.WriteString(strings.TrimRightFunc(line, unicode.IsSpace)) + buf.WriteString("\n") + } + } + + return strings.TrimRightFunc(buf.String(), unicode.IsSpace) +} diff --git a/cmd/testo/internal/cmd/cmdtags/tags.go b/cmd/testo/internal/cmd/cmdtags/tags.go new file mode 100644 index 0000000..2c1827b --- /dev/null +++ b/cmd/testo/internal/cmd/cmdtags/tags.go @@ -0,0 +1,77 @@ +package cmdtags + +import ( + "cmp" + "flag" + "fmt" + "maps" + "slices" + "strings" + + "github.com/ozontech/testo/cmd/testo/internal/cli" + "github.com/ozontech/testo/cmd/testo/internal/loader" +) + +func init() { + cli.Add("tags", func(f *flag.FlagSet, cmd *Cmd) { + f.BoolVar(&cmd.Tests, "tests", false, "only show build tags used in *_test.go files") + f.BoolVar( + &cmd.All, + "a", + false, + "include build tags cancelled by negations, e.g. //go:build !tag", + ) + }, + cli.WithShort("Show project build tags"), + cli.WithLong(`Show project build tags. + +It traverses all go files and parses //go:build directives. +Use -tests flag to traverse only *_test.go files. + +If same tag is both required and cancelled by different expressions it will be omitted. +Pass -a flag to change that. + + //go:build mytag + //go:build !mytag + +This command must be executed from the same directory as go module (project). +`), + cli.WithoutArgs(), + ) +} + +type Cmd struct { + All bool + Tests bool +} + +func (c Cmd) Run(...string) error { + add, remove, err := loader.BuildTags(c.Tests) + if err != nil { + return err + } + + if !c.All { + for k := range remove { + delete(add, k) + } + } + + if len(add) > 0 { + fmt.Println(join(keys(add))) + } + + return nil +} + +func join(s []string) string { + return strings.Join(s, ",") +} + +func keys[M ~map[K]V, K cmp.Ordered, V any](m M) []K { + s := slices.Collect(maps.Keys(m)) + + slices.Sort(s) + + return s +} diff --git a/cmd/testo/internal/cmd/cmdversion/version.go b/cmd/testo/internal/cmd/cmdversion/version.go new file mode 100644 index 0000000..537818a --- /dev/null +++ b/cmd/testo/internal/cmd/cmdversion/version.go @@ -0,0 +1,34 @@ +package cmdversion + +import ( + "flag" + "fmt" + "runtime" + "runtime/debug" + + "github.com/ozontech/testo/cmd/testo/internal/cli" +) + +func init() { + cli.Add( + "version", + func(*flag.FlagSet, *Cmd) {}, + cli.WithShort("Show testo version"), + cli.WithoutArgs(), + ) +} + +type Cmd struct{} + +func (Cmd) Run(...string) error { + version := "unknown" + + info, ok := debug.ReadBuildInfo() + if ok { + version = info.Main.Version + } + + fmt.Printf("testo version %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH) + + return nil +} diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go new file mode 100644 index 0000000..8b1b70c --- /dev/null +++ b/cmd/testo/internal/loader/loader.go @@ -0,0 +1,661 @@ +package loader + +import ( + "bufio" + "bytes" + "cmp" + "context" + "encoding/json" + "fmt" + "go/token" + "go/types" + "io" + "slices" + "strings" + + "github.com/ozontech/testo/cmd/testo/internal/packageslite" + "github.com/ozontech/testo/cmd/testo/internal/typeutil" + "github.com/ozontech/testo/internal/parse" +) + +type LoadError struct { + FSet *token.FileSet + Diagnostics []Diagnostic +} + +func (l *LoadError) Error() string { + msgs := make([]string, 0, len(l.Diagnostics)) + + for _, d := range l.Diagnostics { + var buf bytes.Buffer + + d.Print(&buf, l.FSet) + + msgs = append(msgs, buf.String()) + } + + return strings.Join(msgs, "\n") +} + +type Config struct { + Tags string + Testo string + Strict bool + + runners map[types.Type]map[SuiteRunner]struct{} +} + +func Load(cfg Config, patterns ...string) ([]Suite, error) { + if cfg.Tags == "" { + add, remove, err := BuildTags(false) + if err == nil { + tags := make([]string, 0, len(add)) + + for k := range add { + if _, ok := remove[k]; !ok { + tags = append(tags, k) + } + } + + slices.Sort(tags) + + cfg.Tags = strings.Join(tags, ",") + } + } + + fset := token.NewFileSet() + + pkgs, err := packageslite.Load(packageslite.Config{ + FSet: fset, + Tags: cfg.Tags, + }, patterns...) + if err != nil { + return nil, err + } + + var ( + suites []Suite + diagnostics []Diagnostic + ) + + for _, pkg := range pkgs { + if !pkg.Types.Complete() { + return nil, fmt.Errorf("package %q is not complete", pkg.Name) + } + + scope := pkg.Types.Scope() + + for _, name := range scope.Names() { + obj := scope.Lookup(name) + + suite, d, ok := cfg.asSuite(fset, pkg, pkgs, obj) + if !ok { + continue + } + + diagnostics = append(diagnostics, d...) + + suites = append(suites, suite) + } + } + + slices.SortFunc(diagnostics, func(a, b Diagnostic) int { + return strings.Compare( + fset.File(a.Pos).Name(), + fset.File(b.Pos).Name(), + ) + }) + + slices.SortFunc(suites, func(a, b Suite) int { + return cmp.Compare(a.Name, b.Name) + }) + + if len(diagnostics) > 0 { + return suites, &LoadError{ + FSet: fset, + Diagnostics: diagnostics, + } + } + + return suites, nil +} + +func (c *Config) asSuite( + fset *token.FileSet, + pkg *packageslite.Package, + pkgs []*packageslite.Package, + obj types.Object, +) (Suite, []Diagnostic, bool) { + named, ok := obj.Type().(*types.Named) + if !ok { + return Suite{}, nil, false + } + + s, ok := named.Underlying().(*types.Struct) + if !ok { + return Suite{}, nil, false + } + + var ( + t T + hasT bool + ) + + for f := range s.Fields() { + t, hasT = c.asT(f) + if hasT { + break + } + } + + if !hasT { + return Suite{}, nil, false + } + + suite := Suite{ + FSet: fset, + Pos: obj.Pos(), + Package: pkg, + Name: named.Obj().Name(), + T: t, + Type: obj.Type(), + } + + suite.Runners = func(ctx context.Context) ([]SuiteRunner, error) { + return c.loadRunners(ctx, fset, suite, pkgs) + } + + cases, diagnostics, fatal := c.collectCases(named) + if fatal { + return suite, diagnostics, true + } + + for m := range named.Methods() { + name := m.Name() + + const prefix = "Test" + + if !strings.HasPrefix(name, prefix) { + continue + } + + if !parse.IsTest(name, prefix) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: MalformedName( + fmt.Sprintf("first letter after %q in %q must not be lowercase", prefix, name), + ), + }) + + continue + } + + sig := m.Signature() + + if sig.Results().Len() > 0 { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(name + " must not return values"), + }) + + continue + } + + params := sig.Params() + + switch params.Len() { + case 1: + in := params.At(0) + + if !types.Identical(in.Type(), suite.T.Type) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf( + "%s must accept %s, got %s", + name, + typeutil.Format(suite.T.Type), + typeutil.Format(in.Type()), + ), + ), + }) + } + + suite.Tests = append(suite.Tests, SuiteTest{ + Name: name, + Pos: m.Pos(), + }) + + case 2: + first := params.At(0) + + if !types.Identical(first.Type(), suite.T.Type) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf( + "%s must accept %s, got %s", + name, + typeutil.Format(suite.T.Type), + typeutil.Format(first.Type()), + ), + ), + }) + + continue + } + + second := params.At(1) + + params, ok := second.Type().Underlying().(*types.Struct) + if !ok { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf( + "%s must accept struct as second parameter, got %s", + name, + second.Type(), + ), + ), + }) + + continue + } + + var invalidParams bool + + var parameters []Parameter + + for f := range params.Fields() { + if !f.Exported() { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf("%s parameters must be exported, got %s", name, f.Name()), + ), + }) + + invalidParams = true + + continue + } + + forParam, ok := cases[f.Name()] + if !ok { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf("%s requires unknown parameter %s", name, f.Name()), + ), + }) + + invalidParams = true + + continue + } + + if !types.AssignableTo(forParam.Type, f.Type()) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf( + "%s requires param %s to be of type %s, have %s", + name, + f.Name(), + f.Type(), + forParam.Type, + ), + ), + }) + + invalidParams = true + + continue + } + + parameters = append(parameters, Parameter{ + Name: f.Name(), + Type: f.Type(), + }) + } + + suite.Tests = append(suite.Tests, SuiteTest{ + Pos: m.Pos(), + Name: name, + Parametrized: true, + Parameters: parameters, + }) + + if invalidParams { + continue + } + + default: + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf( + "%s must accept either 1 or 2 parameters, got %d", + name, + params.Len(), + ), + ), + }) + } + } + + if c.Strict && len(diagnostics) == 0 && len(suite.Tests) == 0 { + diagnostics = append(diagnostics, Diagnostic{ + Pos: obj.Pos(), + Issue: TestsMissing(fmt.Sprintf("suite %s has no tests", suite.Name)), + }) + } + + return suite, diagnostics, true +} + +func (c *Config) asT(f *types.Var) (T, bool) { + if !f.Embedded() { + return T{}, false + } + + if f.Name() != "Suite" { + return T{}, false + } + + suite, ok := f.Type().(*types.Named) + if !ok { + return T{}, false + } + + if suite.Obj().Pkg().Path() != c.Testo { + return T{}, false + } + + aStruct := suite.Underlying().(*types.Struct) + field := aStruct.Field(0) + array := field.Type().Underlying().(*types.Array) + pointer := array.Elem().Underlying().(*types.Pointer) + + elem := pointer.Elem() + + return T{Type: elem}, true +} + +type Cases map[string]Case + +type Case struct { + Name string + Type types.Type +} + +func (c *Config) collectCases( + suite *types.Named, +) (cases Cases, diagnostics []Diagnostic, fatal bool) { + cases = make(Cases) + + const prefix = "Cases" + + for m := range suite.Methods() { + if !strings.HasPrefix(m.Name(), prefix) { + continue + } + + if !parse.IsTest(m.Name(), prefix) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: MalformedName( + fmt.Sprintf( + "first letter after %q in %q must not be lowercase", + prefix, + m.Name(), + ), + ), + }) + + fatal = true + + continue + } + + sig := m.Signature() + + if sig.Params().Len() != 0 { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(m.Name() + " must not accept parameters"), + }) + + fatal = true + + continue + } + + results := sig.Results() + + if results.Len() != 1 { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(m.Name() + " must return exactly one result"), + }) + + fatal = true + + continue + } + + out := results.At(0).Type().Underlying() + + s, ok := out.(*types.Slice) + if !ok { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf("%s must return a slice, got %s", m.Name(), out), + ), + }) + + fatal = true + + continue + } + + name := strings.TrimPrefix(m.Name(), prefix) + elem := s.Elem() + + if private, numFields := findPrivateFields(elem); len(private) > 0 { + if len(private) == numFields { + diagnostics = append(diagnostics, Diagnostic{ + Pos: private[0].Pos(), + Issue: PrivateField(fmt.Sprintf( + "type returned by %s%s contains only private fields", + prefix, name, + )), + }) + } else if c.Strict { + diagnostics = append(diagnostics, Diagnostic{ + Pos: private[0].Pos(), + Issue: PrivateField(fmt.Sprintf( + "type returned by %s%s contains private field %q", + prefix, name, private[0].Name(), + )), + }) + } + } + + cases[name] = Case{ + Name: name, + Type: elem, + } + } + + return cases, diagnostics, fatal +} + +func findPrivateFields(t types.Type) ([]*types.Var, int) { + s, ok := t.Underlying().(*types.Struct) + if !ok { + return nil, 0 + } + + private := make([]*types.Var, 0, s.NumFields()) + + for f := range s.Fields() { + if !f.Exported() { + private = append(private, f) + } + } + + return private, s.NumFields() +} + +type Diagnostic struct { + Pos token.Pos + Issue Issue +} + +func (d Diagnostic) JSON(w io.Writer, set *token.FileSet) { + type Entry struct { + File string `json:"file"` + Line int `json:"line"` + Kind string `json:"kind"` + Message string `json:"message"` + } + + file := set.File(d.Pos) + + entry := Entry{ + File: file.Name(), + Line: file.Line(d.Pos), + Kind: d.Issue.Kind(), + Message: d.Issue.Message(), + } + + _ = json.NewEncoder(w).Encode(entry) +} + +func (d Diagnostic) Print(w io.Writer, set *token.FileSet) { + file := set.File(d.Pos) + line := file.Line(d.Pos) + + fmt.Fprintf(w, "%s:%d: %s", file.Name(), line, d.Issue.String()) +} + +func (d Diagnostic) Println(w io.Writer, set *token.FileSet) { + b := bufio.NewWriter(w) + defer b.Flush() + + d.Print(b, set) + fmt.Fprintln(b) +} + +type Issue interface { + fmt.Stringer + + Kind() string + Message() string + + issue() +} + +type PrivateField string + +func (pf PrivateField) Kind() string { + return "private field" +} + +func (pf PrivateField) Message() string { + return string(pf) +} + +func (pf PrivateField) String() string { + return pf.Kind() + ": " + pf.Message() +} + +func (PrivateField) issue() {} + +type MalformedName string + +func (mn MalformedName) Kind() string { + return "malformed name" +} + +func (mn MalformedName) Message() string { + return string(mn) +} + +func (mn MalformedName) String() string { + return mn.Kind() + ": " + mn.Message() +} + +func (MalformedName) issue() {} + +type InvalidSignature string + +func (is InvalidSignature) Kind() string { + return "invalid signature" +} + +func (is InvalidSignature) Message() string { + return string(is) +} + +func (is InvalidSignature) String() string { + return is.Kind() + ": " + is.Message() +} + +func (InvalidSignature) issue() {} + +type TestsMissing string + +func (tm TestsMissing) Kind() string { + return "tests missing" +} + +func (tm TestsMissing) Message() string { + return string(tm) +} + +func (tm TestsMissing) String() string { + return tm.Kind() + ": " + tm.Message() +} + +func (TestsMissing) issue() {} + +type Suite struct { + FSet *token.FileSet + Pos token.Pos + Package *packageslite.Package + Name string + Tests []SuiteTest + T T + Runners func(ctx context.Context) ([]SuiteRunner, error) + Type types.Type +} + +func (s Suite) ID() string { + return s.Package.Path + "." + s.Name +} + +type SuiteRunner struct { + Dir string + Name string + Tags string +} + +type T struct { + Type types.Type +} + +type SuiteTest struct { + Name string + Parametrized bool + Parameters []Parameter + Pos token.Pos +} + +type Parameter struct { + Name string + Type types.Type +} diff --git a/cmd/testo/internal/loader/runner.go b/cmd/testo/internal/loader/runner.go new file mode 100644 index 0000000..2777ea8 --- /dev/null +++ b/cmd/testo/internal/loader/runner.go @@ -0,0 +1,147 @@ +package loader + +import ( + "context" + "go/ast" + "go/token" + "go/types" + "maps" + "path/filepath" + "slices" + "strings" + + "github.com/ozontech/testo/cmd/testo/internal/packageslite" + "github.com/ozontech/testo/internal/parse" +) + +func (c *Config) loadRunners( + ctx context.Context, + fset *token.FileSet, + suite Suite, + pkgs []*packageslite.Package, +) ([]SuiteRunner, error) { + if c.runners == nil { + c.runners = make(map[types.Type]map[SuiteRunner]struct{}) + } + + if runners, ok := c.runners[suite.Type]; ok { + return slices.Collect(maps.Keys(runners)), nil + } + + var runners []SuiteRunner + + for _, pkg := range pkgs { + for _, file := range pkg.Syntax { + tokenFile := fset.File(file.Pos()) + + if !strings.HasSuffix(tokenFile.Name(), "_test.go") { + continue + } + + var testName string + + ast.Inspect(file, func(n ast.Node) bool { + if f, ok := n.(*ast.FuncDecl); ok { + if parse.IsTest(f.Name.Name, "Test") { + testName = f.Name.Name + } + + return true + } + + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + + fun := pkg.Info.Uses[funcIdent(call)] + if fun == nil { + sel, ok := call.Fun.(*ast.SelectorExpr) + if ok { + fun = pkg.Info.Uses[sel.Sel] + } + } + + if fun == nil { + return true + } + + fn, ok := fun.(*types.Func) + if !ok { + return true + } + + if fn.Pkg() == nil || fn.Pkg().Path() != c.Testo || fn.Name() != "RunSuite" { + return true + } + + sig, ok := pkg.Info.Types[call.Fun].Type.(*types.Signature) + if !ok { + return true + } + + params := sig.Params() + + if params.Len() < 2 { + return true + } + + suiteType := elem(params.At(1).Type()) + + identical := types.Identical( + suiteType, + elem(suite.Type), + ) + + r := SuiteRunner{ + Name: testName, + Dir: filepath.Dir(tokenFile.Name()), + Tags: c.Tags, + } + + if identical { + runners = append(runners, r) + } else { + c.addRunner(suiteType, r) + } + + return true + }) + } + } + + return runners, nil +} + +func (c *Config) addRunner(suite types.Type, runner SuiteRunner) { + if _, ok := c.runners[suite]; ok { + c.runners[suite][runner] = struct{}{} + + return + } + + c.runners[suite] = map[SuiteRunner]struct{}{ + runner: {}, + } +} + +func funcIdent(call *ast.CallExpr) *ast.Ident { + switch f := call.Fun.(type) { + case *ast.Ident: + return f + + case *ast.SelectorExpr: + return f.Sel + + default: + return nil + } +} + +func elem(a types.Type) types.Type { + if ptr, ok := a.(*types.Pointer); ok { + return elem(ptr.Elem()) + } + + return a +} diff --git a/cmd/testo/internal/loader/tags.go b/cmd/testo/internal/loader/tags.go new file mode 100644 index 0000000..5bc8099 --- /dev/null +++ b/cmd/testo/internal/loader/tags.go @@ -0,0 +1,146 @@ +package loader + +import ( + "context" + "encoding/json" + "fmt" + "go/ast" + "go/build/constraint" + "go/parser" + "go/token" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func BuildTags(testOnly bool) (add, remove map[string]struct{}, err error) { + add = make(map[string]struct{}) + remove = make(map[string]struct{}) + + files, err := listGoFiles(context.Background(), testOnly) + if err != nil { + return nil, nil, err + } + + for _, f := range files { + buildTags(f, add, remove) + } + + return add, remove, nil +} + +func buildTags(file *ast.File, add, remove map[string]struct{}) { + for _, g := range file.Comments { + for _, c := range g.List { + if !constraint.IsGoBuild(c.Text) && !constraint.IsPlusBuild(c.Text) { + continue + } + + expr, err := constraint.Parse(c.Text) + if err != nil { + continue + } + + addConstraintExpr(expr, add, remove) + + } + } +} + +func addConstraintExpr(e constraint.Expr, add, remove map[string]struct{}) { + switch e := e.(type) { + case *constraint.AndExpr: + addConstraintExpr(e.X, add, remove) + addConstraintExpr(e.Y, add, remove) + + case *constraint.NotExpr: + addConstraintExpr(e.X, remove, add) + + case *constraint.OrExpr: + addConstraintExpr(e.X, add, remove) + addConstraintExpr(e.Y, add, remove) + + case *constraint.TagExpr: + add[e.Tag] = struct{}{} + } +} + +func listGoFiles(ctx context.Context, testOnly bool) ([]*ast.File, error) { + g := exec.CommandContext(ctx, "go", "list", "-m", "-json") + + out, err := g.Output() + if err != nil { + return nil, err + } + + var mod struct { + Dir string + } + + err = json.Unmarshal(out, &mod) + if err != nil { + return nil, err + } + + if mod.Dir == "" { + return nil, fmt.Errorf("outside of go module") + } + + files := make(map[string]struct{}) + + walkErr := fs.WalkDir( + os.DirFS(mod.Dir), + ".", + func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + if filepath.Base(path) == "testdata" { + return fs.SkipDir + } + + return nil + } + + suffix := ".go" + if testOnly { + suffix = "_test.go" + } + + if !strings.HasSuffix(d.Name(), suffix) { + return nil + } + + files[filepath.Join(mod.Dir, path)] = struct{}{} + + return nil + }, + ) + if walkErr != nil { + return nil, walkErr + } + + s := make([]*ast.File, 0, len(files)) + + fset := token.NewFileSet() + + for f := range files { + parsed, err := parser.ParseFile( + fset, + f, + nil, + parser.ParseComments|parser.PackageClauseOnly, + ) + if err != nil { + return nil, err + } + + s = append(s, parsed) + } + + return s, nil +} diff --git a/cmd/testo/internal/packageslite/packages.go b/cmd/testo/internal/packageslite/packages.go new file mode 100644 index 0000000..ef2b2bf --- /dev/null +++ b/cmd/testo/internal/packageslite/packages.go @@ -0,0 +1,303 @@ +// Package packageslite implement some functionality +// from the [golang.org/x/tools/go/packages]. +// +// [golang.org/x/tools/go/packages]: https://pkg.go.dev/golang.org/x/tools/go/packages +package packageslite + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +type Package struct { + Path string + Name string + Types *types.Package + Syntax []*ast.File + Dir string + TestImports []string + TestGoFiles []string + Info types.Info + + depOnly bool +} + +func (p *Package) Init(fset *token.FileSet, conf *types.Config) error { + info := types.Info{ + Uses: make(map[*ast.Ident]types.Object), + Types: make(map[ast.Expr]types.TypeAndValue), + } + + checked, err := conf.Check(p.Path, fset, p.Syntax, &info) + if err != nil { + return err + } + + p.Types = checked + p.Info = info + + return nil +} + +type Config struct { + FSet *token.FileSet + Tags string +} + +func Load(config Config, patterns ...string) ([]*Package, error) { + listed, err := goList(config.Tags, patterns...) + if err != nil { + return nil, err + } + + goPkgMap := make(map[string]*goPackage, len(listed)) + for i := range listed { + goPkgMap[listed[i].ImportPath] = &listed[i] + } + + archiveImp := archiveImporter(config.FSet, goPkgMap) + + pkgs := make(map[string]*Package) + + var importMap map[string]string + + conf := types.Config{ + // IgnoreFuncBodies: true, + DisableUnusedImportCheck: true, + FakeImportC: true, + Importer: importerFunc(func(path string) (*types.Package, error) { + if path == "unsafe" { + return types.Unsafe, nil + } + + if importMap != nil { + // Taken from https://github.com/tinygo-org/tinygo/pull/1588/files + if to, ok := importMap[path]; ok && !strings.HasSuffix(to, ".test]") { + path = to + } + } + + if pkg, ok := pkgs[path]; ok { + return pkg.Types, nil + } + + return archiveImp.Import(path) + }), + } + + for _, l := range listed { + importMap = l.ImportMap + + if l.DepOnly { + pkg, err := loadFromExport(&l, archiveImp) + if err != nil { + return nil, err + } + + pkgs[pkg.Path] = pkg + + continue + } + + pkg, err := l.Package(config.FSet) + if err != nil { + return nil, err + } + + err = pkg.Init(config.FSet, &conf) + if err != nil { + return nil, err + } + + pkgs[pkg.Path] = &pkg + } + + var direct []*Package + + for _, pkg := range pkgs { + if pkg.depOnly { + continue + } + + direct = append(direct, pkg) + } + + return direct, nil +} + +var _ types.Importer = (*importerFunc)(nil) + +type importerFunc func(path string) (*types.Package, error) + +func (i importerFunc) Import(path string) (*types.Package, error) { + return i(path) +} + +func archiveImporter(fset *token.FileSet, goPkgs map[string]*goPackage) types.Importer { + return importer.ForCompiler(fset, "gc", func(path string) (io.ReadCloser, error) { + if path == "unsafe" { + return nil, errors.New("unsafe is built-in") + } + + gp, ok := goPkgs[path] + if !ok { + return nil, fmt.Errorf("archive importer: unknown package %q", path) + } + + if gp.Export == "" { + return nil, fmt.Errorf("archive importer: no export data for %q", path) + } + + return os.Open(gp.Export) + }) +} + +func loadFromExport(gp *goPackage, imp types.Importer) (*Package, error) { + typesPkg, err := imp.Import(gp.ImportPath) + if err != nil { + return nil, fmt.Errorf("loading export of %s: %w", gp.ImportPath, err) + } + + return &Package{ + Path: gp.ImportPath, + Name: gp.Name, + Types: typesPkg, + depOnly: true, + }, nil +} + +type goPackage struct { + Dir string + ImportPath string + Name string + DepOnly bool + GoFiles []string + CGoFiles []string + Export string + Imports []string + ImportMap map[string]string + TestImports []string + TestGoFiles []string + Incomplete bool + Error *struct { + Err string + } + + order int +} + +func (gp goPackage) Package(fset *token.FileSet) (Package, error) { + names := slices.Concat( + gp.GoFiles, + gp.CGoFiles, + ) + + files := make([]*ast.File, 0, len(names)) + + for _, n := range names { + file, err := parser.ParseFile( + fset, + filepath.Join(gp.Dir, n), + nil, + parser.ParseComments|parser.SkipObjectResolution, + ) + if err != nil { + return Package{}, err + } + + files = append(files, file) + } + + return Package{ + Path: gp.ImportPath, + Name: gp.Name, + Types: types.NewPackage(gp.ImportPath, gp.Name), + Syntax: files, + TestImports: gp.TestImports, + TestGoFiles: gp.TestGoFiles, + Dir: gp.Dir, + depOnly: gp.DepOnly, + }, nil +} + +func goList(tags string, patterns ...string) ([]goPackage, error) { + args := []string{ + "list", + "-e", + "-deps", + "-test", + "-export", + "-buildvcs=false", + "-pgo=off", + "-tags", + tags, + // "-json=Dir,ImportPath,Name,DepOnly,GoFiles,CGoFiles,Imports,ImportMap,Incomplete,Error", + "-json", + "--", + } + + args = append(args, patterns...) + + //nolint:gosec // variable only affects patterns, safe to use + cmd := exec.Command("go", args...) + + cmd.Env = os.Environ() + + out, err := cmd.Output() + if err != nil { + var errExit *exec.ExitError + + if errors.As(err, &errExit) { + return nil, fmt.Errorf("go list: %w", err) + } + + return nil, err + } + + var packages []goPackage + + dec := json.NewDecoder(bytes.NewReader(out)) + + var i int + + for dec.More() { + i++ + + var pkg goPackage + + err = dec.Decode(&pkg) + if err != nil { + return nil, err + } + + if strings.HasSuffix(pkg.ImportPath, ".test") { + continue + } + + if len(pkg.Imports) > 0 { + pkg.order = i + } + + packages = append(packages, pkg) + } + + slices.SortStableFunc(packages, func(a, b goPackage) int { + return cmp.Compare(a.order, b.order) + }) + + return packages, nil +} diff --git a/cmd/testo/internal/packageslite/packages_test.go b/cmd/testo/internal/packageslite/packages_test.go new file mode 100644 index 0000000..200f849 --- /dev/null +++ b/cmd/testo/internal/packageslite/packages_test.go @@ -0,0 +1,20 @@ +package packageslite + +import ( + "go/token" + "testing" +) + +func BenchmarkLoad(b *testing.B) { + conf := Config{ + FSet: token.NewFileSet(), + Tags: "e2e,smoke,functional,integration", + } + + for b.Loop() { + _, err := Load(conf, "./...") + if err != nil { + b.Fatal(err) + } + } +} diff --git a/cmd/testo/internal/typeutil/util.go b/cmd/testo/internal/typeutil/util.go new file mode 100644 index 0000000..7605bab --- /dev/null +++ b/cmd/testo/internal/typeutil/util.go @@ -0,0 +1,26 @@ +package typeutil + +import "go/types" + +func Format(t types.Type) string { + switch t := t.(type) { + case *types.Named: + return formatNamed(t) + + case *types.Pointer: + return "*" + Format(t.Elem()) + + default: + return t.String() + } +} + +func formatNamed(t *types.Named) string { + obj := t.Obj() + + if pkg := obj.Pkg(); pkg != nil { + return pkg.Name() + "." + obj.Name() + } + + return obj.Name() +} diff --git a/cmd/testo/main.go b/cmd/testo/main.go new file mode 100644 index 0000000..5eaba68 --- /dev/null +++ b/cmd/testo/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/ozontech/testo/cmd/testo/internal/cli" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdlint" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdrun" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdsuites" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdtags" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdversion" +) + +func main() { + cli.Run() +} diff --git a/collector.go b/collector.go index 48dce28..96ccca1 100644 --- a/collector.go +++ b/collector.go @@ -6,9 +6,8 @@ import ( "slices" "strings" "testing" - "unicode" - "unicode/utf8" + "github.com/ozontech/testo/internal/parse" "github.com/ozontech/testo/internal/pragma" "github.com/ozontech/testo/internal/testnamer" "github.com/ozontech/testo/testoplugin" @@ -44,28 +43,6 @@ func (t plannedSuiteTest[Suite, T]) Annotations() []testoplugin.Option { return slices.Clone(t.inner.Options) } -// isTest states whether name is a valid test name (or other type, according to prefix). -// -// It checks if the next character after prefix is uppercase. -// -// TestFoo => true -// Test => true -// TestfooBar => false -func isTest(name, prefix string) bool { - if !strings.HasPrefix(name, prefix) { - return false - } - - // "Test" is ok - if len(name) == len(prefix) { - return true - } - - r, _ := utf8.DecodeRuneInString(name[len(prefix):]) - - return !unicode.IsLower(r) -} - func suiteCasesOf[Suite suite[T], T CommonT](tb testing.TB) map[string]suiteCase[Suite, T] { tb.Helper() @@ -78,7 +55,7 @@ func suiteCasesOf[Suite suite[T], T CommonT](tb testing.TB) map[string]suiteCase const prefix = "Cases" - if !isTest(method.Name, prefix) { + if !parse.IsTest(method.Name, prefix) { if !strings.HasPrefix(method.Name, prefix) { continue } @@ -252,7 +229,7 @@ func (tc *testsCollector[Suite, T]) Collect(tb testing.TB) suiteTests[Suite, T] const prefix = "Test" - if !isTest(method.Name, prefix) { + if !parse.IsTest(method.Name, prefix) { if !strings.HasPrefix(method.Name, prefix) { continue } diff --git a/go.sum b/go.sum deleted file mode 100644 index e69de29..0000000 diff --git a/internal/parse/parse.go b/internal/parse/parse.go index ff1ee50..193c934 100644 --- a/internal/parse/parse.go +++ b/internal/parse/parse.go @@ -4,6 +4,8 @@ package parse import ( "strconv" "strings" + "unicode" + "unicode/utf8" ) // Bool parses string as bool treating it as false @@ -13,3 +15,25 @@ func Bool(s string) bool { return b } + +// IsTest states whether name is a valid test name (or other type, according to prefix). +// +// It checks if the next character after prefix is uppercase. +// +// TestFoo => true +// Test => true +// TestfooBar => false +func IsTest(name, prefix string) bool { + if !strings.HasPrefix(name, prefix) { + return false + } + + // "Test" is ok + if len(name) == len(prefix) { + return true + } + + r, _ := utf8.DecodeRuneInString(name[len(prefix):]) + + return !unicode.IsLower(r) +}