diff --git a/.circleci/config.yml b/.circleci/config.yml index 7ead624eece7db261949b2d6fb4531e2bd022f12..b6eec3de1bf00c32f24bdc1eec48da7d7c6f4274 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,8 +12,6 @@ jobs: sudo service docker stop sudo mount -t tmpfs -o size=100% tmpfs /var/lib/docker sudo service docker start - sudo mount -t tmpfs -o size=100% tmpfs $(pwd) - sudo chown -R circleci:circleci $(pwd) - checkout - run: name: Download images diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2b1845a603e8ec13e67589edc6bd286795760f39..d230680be7ea89e480c974fe6d3a8eea3a310ea8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,6 +18,8 @@ builds: - go test -v -timeout=1s -bench=. ./internal/... # Enforce UI assets build before Go build - test -f internal/cmd/ui/dist/index.html + post: + - test/build-completions.sh dist/{{ .ProjectName }}_{{ .Target }} changelog: disable: true @@ -32,6 +34,12 @@ nfpms: vendor: "Dalibo" homepage: "https://gitlab.com/dalibo/pg_migrate" license: "PostgreSQL" + contents: + - src: "dist/{{ .ProjectName }}_{{ .Target }}/usr" + dst: /usr + type: tree + file_info: + mode: 0644 release: prerelease: auto diff --git a/docs/references/cli.md b/docs/references/cli.md index 72aac7c8517c1dbcf8ff74231bfed12b307e9614..7353139fc4991064eda97a40b945c2aaeee5d27b 100644 --- a/docs/references/cli.md +++ b/docs/references/cli.md @@ -294,3 +294,56 @@ pg_migrate fills in all fields from template with JSON from corresponding file. e.g. `{{ .Source }}` is the JSON object from `.pg_migrate/Source.json`. [jq]: https://jqlang.org/ + + +## Shell Completion + +PostgreSQL Migrator provides command line auto-completion for bash, zsh and fish. +Installation from package enables shell completion out of the box. +After installation restart your shell with `exec /bin/bash` to enable completion. + +Manual installation requires a few steps. +Adapt to your installation. + +=== "bash" + + Append completion configuration to your bashrc. + + ``` bash + pg_migrate _complete --configuration >> ~/.bashrc + ``` + + Restart bash: + + ``` console + $ exec $SHELL + ``` + +=== "fish" + + Install completion with the following invocation: + + ``` console + $ pg_migrate _complete --shell=fish --configuration > ~/.config/fish/completions/pg_migrate.fish + ``` + + Restart fish: + + ``` console + $ exec fish + ``` + +=== "zsh" + + Save configuration in a sourced configuration file. + Ensure you have write access to first `fpath` entry. + + ``` shell + pg_migrate _complete --shell=zsh --configuration >> "${fpath[1]}/_pg_migrate" + ``` + + Restart zsh: + + ``` console + $ exec zsh + ``` diff --git a/go.mod b/go.mod index fb6a86e8be0a36ecce563d6bc88d52a91e50ac7e..9a41aa0de37c5b53591054f55241fbe5f28ecf68 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/arunsworld/nursery v0.6.0 github.com/dominikbraun/graph v0.23.0 github.com/go-sql-driver/mysql v1.9.3 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/goreleaser/fileglob v1.4.0 github.com/jackc/pgx/v5 v5.7.6 github.com/knadh/koanf/parsers/dotenv v1.1.0 github.com/knadh/koanf/providers/confmap v1.0.0 @@ -44,6 +46,7 @@ require ( github.com/felixge/fgprof v0.9.5 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect github.com/google/uuid v1.6.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect diff --git a/go.sum b/go.sum index 596c7872f568da11fefc1c5ebe03300d17dfb314..367fee702cba0226cce2f74756c53c83af1f7742 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpH github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/arunsworld/nursery v0.6.0 h1:w7Im3b6ZLPztrXheL095VaWu5u9d05Jk2YFvknG5B1M= github.com/arunsworld/nursery v0.6.0/go.mod h1:U+FGk31qgsGyvlx/RJLF5TcAiW2FRYv3414MREDzCOQ= +github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= +github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= @@ -43,6 +45,8 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= @@ -52,8 +56,12 @@ github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8I github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0= github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goreleaser/fileglob v1.4.0 h1:Y7zcUnzQjT1gbntacGAkIIfLv+OwojxTXBFxjSFoBBs= +github.com/goreleaser/fileglob v1.4.0/go.mod h1:1pbHx7hhmJIxNZvm6fi6WVrnP0tndq6p3ayWdLn1Yf8= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= @@ -93,6 +101,8 @@ github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= diff --git a/internal/cmd/complete/cmd.go b/internal/cmd/complete/cmd.go new file mode 100644 index 0000000000000000000000000000000000000000..9adb5ce6e094c42500aa14c144597898a09b3b15 --- /dev/null +++ b/internal/cmd/complete/cmd.go @@ -0,0 +1,102 @@ +package completec + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/lithammer/dedent" + "github.com/spf13/pflag" + "gitlab.com/dalibo/pg_migrate/internal/cmd" + "gitlab.com/dalibo/pg_migrate/internal/complete" +) + +type Cmd struct { + Root complete.Completer +} + +func (c *Cmd) String() string { + return "Command line completion" +} + +func (c *Cmd) Run(args ...string) error { + f := pflag.NewFlagSet("inspect", pflag.ContinueOnError) + f.BoolP("help", "?", false, "Show help and exit") + f.String("shell", filepath.Base(os.Getenv("SHELL")), "Select target shell") + f.Bool("configuration", false, "Generate configuration") + + f.Usage = func() { + _, _ = os.Stderr.WriteString(dedent.Dedent(` + Usage: pg_migrate [OPTIONS] _complete [OPTIONS] + + Configure or execute shell command line completion. + + Options: + `)[1:]) + f.PrintDefaults() + _, _ = os.Stderr.WriteString(dedent.Dedent(` + + To test completions generation, run: + + COMP_LINE="pg_migrate --verbo" go run . _complete + + Use pg_migrate --help for more information. + `)[1:]) + } + err := f.Parse(args) + if err != nil { + return cmd.Errorf("args: %s", err).WithCode(2) + } + if f.Changed("help") { + f.Usage() + return cmd.Exit(0) + } + + k := cmd.Koanf(f) + shell := complete.Shell(k.String("shell")) + if shell == nil { + return errors.New("unsupported shell") + } + if !k.Bool("configuration") { + complete.Complete(c.Root, shell) + panic("complete did not exit") + } + + command := "pg_migrate" + if cmd.DevMode { + command = "go run ." + } + completer := fmt.Sprintf("%s _complete --shell=%s --", command, k.String("shell")) + _, err = os.Stdout.WriteString(shell.Configuration("pg_migrate", completer)) + + return err +} + +func (c *Cmd) Flags() *pflag.FlagSet { + f := pflag.NewFlagSet("complete", pflag.ContinueOnError) + f.BoolP("help", "?", false, "Show help and exit") + f.String("shell", filepath.Base(os.Getenv("SHELL")), "Select target shell") + f.Bool("configuration", false, "Generate configuration") + + f.Usage = func() { + _, _ = os.Stderr.WriteString(dedent.Dedent(` + Usage: pg_migrate [OPTIONS] _complete [OPTIONS] [LINE] + + Configure on execute shell completion. + + Options: + `)[1:]) + f.PrintDefaults() + _, _ = os.Stderr.WriteString(dedent.Dedent(` + + Accept line either as a single quoted argument + or from COMP_LINE environment variable. + + Truncates command line at COMP_POINT if any. + + Use pg_migrate --help for more information. + `)[1:]) + } + return f +} diff --git a/internal/cmd/complete/complete.go b/internal/cmd/complete/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..eb7ca768ee8c022baa3422d01fe486185d508a02 --- /dev/null +++ b/internal/cmd/complete/complete.go @@ -0,0 +1,19 @@ +package completec + +import "gitlab.com/dalibo/pg_migrate/internal/complete" + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + complete.ParsePFlags(f, args) + return complete.Any( + complete.PropositionsFunc(func() ([]string, bool) { + return complete.PFlagValue( + f, args, + complete.Enum("shell", "bash ", "fish ", "zsh "), + ) + }), + complete.PropositionsFunc(func() ([]string, bool) { + return complete.PFlags(f) + }), + ) +} diff --git a/internal/cmd/convert/cmd.go b/internal/cmd/convert/cmd.go index e87b45cdbdc9a4e2a66ff2d5de19b9aa7b604751..946ee7ba4affbbd7af2be2a9e1aa25229182c264 100644 --- a/internal/cmd/convert/cmd.go +++ b/internal/cmd/convert/cmd.go @@ -60,8 +60,15 @@ func (c *Cmd) Run(args ...string) error { return err } +func (c *Cmd) Flags() *pflag.FlagSet { + f := pflag.NewFlagSet("convert", pflag.ContinueOnError) + f.BoolP("help", "?", false, "Show help") + f.Bool("refresh", cmd.DevMode, "Refresh target model") + return f +} + func (c *Cmd) parseFlags(args ...string) (*koanf.Koanf, error) { - var flags pflag.FlagSet + flags := c.Flags() flags.Usage = func() { _, _ = os.Stderr.WriteString(dedent.Dedent(` Usage: pg_migrate [OPTIONS] convert [OPTIONS] @@ -78,8 +85,6 @@ func (c *Cmd) parseFlags(args ...string) (*koanf.Koanf, error) { See pg_migrate --help for more informations. `)[1:]) } - flags.BoolP("help", "?", false, "Show help") - flags.Bool("refresh", cmd.DevMode, "Refresh target model.") err := flags.Parse(args) if err != nil { return nil, cmd.Errorf("args: %s", err).WithCode(2) @@ -89,7 +94,7 @@ func (c *Cmd) parseFlags(args ...string) (*koanf.Koanf, error) { os.Exit(0) } - return cmd.Koanf(&flags), nil + return cmd.Koanf(flags), nil } type converter interface { diff --git a/internal/cmd/convert/complete.go b/internal/cmd/convert/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..f4cd38a9e7f1ca247dd80e1f1c65d8739523153d --- /dev/null +++ b/internal/cmd/convert/complete.go @@ -0,0 +1,23 @@ +package convert + +import ( + "gitlab.com/dalibo/pg_migrate/internal/complete" +) + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + complete.ParsePFlags(f, args) + return complete.Any( + // Propose flags values. + complete.PropositionsFunc(func() ([]string, bool) { + return complete.PFlagValue( + f, args, + complete.PFlagBools(f)..., + ) + }), + // Propose flags + complete.PropositionsFunc(func() ([]string, bool) { + return complete.PFlags(f) + }), + ) +} diff --git a/internal/cmd/dump/cmd.go b/internal/cmd/dump/cmd.go index 7a3627e91a6748f4245bce4ed1ecc0d41dc2f854..59be04469e787794550c10acdf09b4dcf2b9be56 100644 --- a/internal/cmd/dump/cmd.go +++ b/internal/cmd/dump/cmd.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "slices" "time" "github.com/knadh/koanf/v2" @@ -137,8 +138,25 @@ func (c *Cmd) Run(args ...string) error { return err } +func (c *Cmd) Flags() *pflag.FlagSet { + f := pflag.NewFlagSet("dump", pflag.ContinueOnError) + f.BoolP("help", "?", false, "Show help") + f.BoolP("clean", "c", cmd.DevMode, "clean (drop) objects before recreating\nor truncate table before copy") + var target string + if !isatty.IsTerminal(os.Stdout.Fd()) && !slices.Contains(os.Args, "_complete") { + target = "stdout" + } + f.String("target", target, "DSN for query execution") + f.IntP("jobs", "j", 4, "use this many parallel jobs to dump") + f.BoolP("schema-only", "s", false, "dump only the schema, no data") + f.BoolP("data-only", "a", false, "dump only the data, not the schema") + f.Bool("refresh-stats", !cmd.DevMode, "refresh table statistics before planning dump") + f.String("section", "", "dump named section (pre-data, data, post-data)") + return f +} + func (c *Cmd) parseFlags(args ...string) (*koanf.Koanf, error) { - var flags pflag.FlagSet + flags := c.Flags() flags.Usage = func() { _, _ = os.Stderr.WriteString(dedent.Dedent(` Usage: pg_migrate [OPTIONS] dump [OPTIONS] @@ -162,18 +180,6 @@ func (c *Cmd) parseFlags(args ...string) (*koanf.Koanf, error) { See pg_migrate --help for more informations. `)[1:]) } - flags.BoolP("help", "?", false, "Show help") - flags.BoolP("clean", "c", cmd.DevMode, "clean (drop) objects before recreating\nor truncate table before copy") - var target string - if !isatty.IsTerminal(os.Stdout.Fd()) { - target = "stdout" - } - flags.String("target", target, "DSN for query execution") - flags.IntP("jobs", "j", 4, "use this many parallel jobs to dump") - flags.BoolP("schema-only", "s", false, "dump only the schema, no data") - flags.BoolP("data-only", "a", false, "dump only the data, not the schema") - flags.Bool("refresh-stats", !cmd.DevMode, "refresh table statistics before planning dump") - flags.String("section", "", "dump named section (pre-data, data, post-data)") err := flags.Parse(args) if err != nil { return nil, cmd.Errorf("args: %s", err).WithCode(2) @@ -183,7 +189,7 @@ func (c *Cmd) parseFlags(args ...string) (*koanf.Koanf, error) { os.Exit(0) } - return cmd.Koanf(&flags), nil + return cmd.Koanf(flags), nil } type dumper interface { diff --git a/internal/cmd/dump/complete.go b/internal/cmd/dump/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..160857cba8c243c359e2876f2edb3817b2e1edcd --- /dev/null +++ b/internal/cmd/dump/complete.go @@ -0,0 +1,35 @@ +package dump + +import ( + "gitlab.com/dalibo/pg_migrate/internal/complete" +) + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + complete.ParsePFlags(f, args) + + return complete.Any( + // Propose flag values. + complete.PropositionsFunc(func() ([]string, bool) { + return complete.PFlagValue( + f, args, + complete.Enum( + "section", + "pre-data \tCreate user, schema and tables", + "data \tCopy data and restart sequences", + "post-data \tCreate constraints, indexes, etc."), + complete.Enum( + "target", + "files \tExport de files in dump directory", + "stdout \tSerialize statements to stdout", + // Ensure no final space to let user continue DSN. + "postgresql://", + ), + ) + }), + // Propose flags. + complete.PropositionsFunc(func() ([]string, bool) { + return complete.PFlags(f) + }), + ) +} diff --git a/internal/cmd/init/cmd.go b/internal/cmd/init/cmd.go index f380186ff955a6f8495662ff2595ae9ec73d124d..8fafb2bc20688c72c23bb2a4a29941c5f3478be5 100644 --- a/internal/cmd/init/cmd.go +++ b/internal/cmd/init/cmd.go @@ -91,8 +91,16 @@ func (c *Cmd) Run(args ...string) error { return nil } +func (c *Cmd) Flags() *pflag.FlagSet { + f := pflag.NewFlagSet("pg_migrate", pflag.ContinueOnError) + f.BoolP("help", "?", false, "Show help") + f.String("source", "", "DSN for the source database") + f.String("target", "", "DSN for the target PostgreSQL database") + return f +} + func (c *Cmd) parseFlags(args ...string) error { - var flags pflag.FlagSet + flags := c.Flags() flags.Usage = func() { _, _ = os.Stderr.WriteString(dedent.Dedent(` Usage: pg_migrate [OPTIONS] init [OPTIONS] [PATH] @@ -123,9 +131,6 @@ func (c *Cmd) parseFlags(args ...string) error { See pg_migrate --help for more informations. `)[1:]) } - flags.BoolP("help", "?", false, "Show help") - flags.String("source", "", "DSN for the source database") - flags.String("target", "", "DSN for the target PostgreSQL database") err := flags.Parse(args) if err != nil { return cmd.Errorf("args: %s", err).WithCode(2) @@ -135,7 +140,7 @@ func (c *Cmd) parseFlags(args ...string) error { return cmd.Exit(0) } c.path, _ = filepath.Abs(flags.Arg(0)) - c.k = cmd.Koanf(&flags) + c.k = cmd.Koanf(flags) // Load .env from parameterized project path. dotEnv := filepath.Join(c.path, ".env") diff --git a/internal/cmd/init/complete.go b/internal/cmd/init/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..01d8fc26c5a83bb8f2b7f232790889762697dc73 --- /dev/null +++ b/internal/cmd/init/complete.go @@ -0,0 +1,25 @@ +package initc + +import ( + "gitlab.com/dalibo/pg_migrate/internal/complete" +) + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + complete.ParsePFlags(f, args) + return complete.Any( + // Complete flag value. + complete.PropositionsFunc(func() ([]string, bool) { + return complete.PFlagValue( + f, args, + // Ensure no final space to let user continue DSN. + complete.Enum("source", "mariadb://", "mysql://", "oracle://"), + complete.Enum("target", "postgresql://"), + ) + }), + // Fallback to complete flags and arguments. + complete.PropositionsFunc(func() ([]string, bool) { + return complete.PFlags(f) + }), + ) +} diff --git a/internal/cmd/inspect/cmd.go b/internal/cmd/inspect/cmd.go index 329d32a99a90b3fb37b5b63ed012b92752e76584..0e8665c7a2eb144653ef65d4ef88c52dd29694c5 100644 --- a/internal/cmd/inspect/cmd.go +++ b/internal/cmd/inspect/cmd.go @@ -84,10 +84,15 @@ func (c *Cmd) Run(args ...string) error { return err } -func (c *Cmd) parseArgs(args ...string) (*koanf.Koanf, error) { +func (c *Cmd) Flags() *pflag.FlagSet { f := pflag.NewFlagSet("inspect", pflag.ContinueOnError) f.BoolP("help", "?", false, "Show help and exit") f.Bool("refresh", cmd.DevMode, "Invalidate cache") + return f +} + +func (c *Cmd) parseArgs(args ...string) (*koanf.Koanf, error) { + f := c.Flags() f.Usage = func() { _, _ = os.Stderr.WriteString(dedent.Dedent(` Usage: pg_migrate [OPTIONS] inspect [OPTIONS] diff --git a/internal/cmd/inspect/complete.go b/internal/cmd/inspect/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..e248e865fcab34605e3b4d85d5dd75a74076e0a1 --- /dev/null +++ b/internal/cmd/inspect/complete.go @@ -0,0 +1,29 @@ +package inspect + +import ( + "gitlab.com/dalibo/pg_migrate/internal/cmd" + "gitlab.com/dalibo/pg_migrate/internal/complete" +) + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + complete.ParsePFlags(f, args) + return complete.Any( + // Complete flag value. + complete.PropositionsFunc(func() ([]string, bool) { + var valuers []complete.Proposer + if cmd.DevMode { + // Final space prevent continuation. + valuers = append(valuers, complete.Enum("refresh", "false ")) + } + return complete.PFlagValue( + f, args, + valuers..., + ) + }), + // Fallback to complete flags and arguments. + complete.PropositionsFunc(func() ([]string, bool) { + return complete.PFlags(f) + }), + ) +} diff --git a/internal/cmd/main/cmd.go b/internal/cmd/main/cmd.go index 672e963bb34d51a057236b1163ba1418f5c77f98..0361a1a19565cd59f9ca23977f76f25cd29a7c03 100644 --- a/internal/cmd/main/cmd.go +++ b/internal/cmd/main/cmd.go @@ -13,6 +13,7 @@ import ( "github.com/mattn/go-isatty" "github.com/spf13/pflag" "gitlab.com/dalibo/pg_migrate/internal/cmd" + completec "gitlab.com/dalibo/pg_migrate/internal/cmd/complete" "gitlab.com/dalibo/pg_migrate/internal/cmd/convert" "gitlab.com/dalibo/pg_migrate/internal/cmd/dump" initc "gitlab.com/dalibo/pg_migrate/internal/cmd/init" @@ -32,11 +33,11 @@ func Main() error { } watchdog(profiling) - st := state{} - return st.Run(os.Args[1:]) + c := Cmd{} + return c.Run(os.Args[1:]) } -type state struct { +type Cmd struct { SubName string SubArgs []string @@ -45,16 +46,25 @@ type state struct { var outdated = time.Hour * 24 * 7 * 8 -func (st *state) Run(args []string) error { - err := st.parseFlags(args) +func (c *Cmd) Run(args []string) error { + err := c.parseFlags(args) + + // Fast path for autocomplete + // Avoid configuring logs, emitting startup messages, changing directory, + // or even failing to parse partial command line. + if c.SubName == "_complete" { + return commands[c.SubName].Run(c.SubArgs...) + } + if err != nil { return err } - logging.Setup(st.k.Bool("verbose"), st.k.Bool("plain")) - if st.k.Bool("version") { + + logging.Setup(c.k.Bool("verbose"), c.k.Bool("plain")) + if c.k.Bool("version") { showVersion() return nil - } else if st.SubArgs == nil { + } else if c.SubArgs == nil { return cmd.Errorf("missing argument. Use --help for help.").WithCode(2) } @@ -62,34 +72,34 @@ func (st *state) Run(args []string) error { slog.Warn("Using a prerelease.", "version", cmd.Version) } - if !st.k.Bool("skip-date-warning") && cmd.BuildDate.Before(time.Now().Add(-1*outdated)) { + if !c.k.Bool("skip-date-warning") && cmd.BuildDate.Before(time.Now().Add(-1*outdated)) { slog.Warn("This pg_migrate build seems old.", "date", cmd.BuildDate.Format("2006-01-02")) slog.Warn("Check for updates at https://gitlab.com/dalibo/pg_migrate/-/releases.", "current", cmd.Version) slog.Warn("Suppress this warning with --skip-date-warning.") } - err = st.chdir() + err = c.chdir() if err != nil { return err } - if st.SubName == "" { + if c.SubName == "" { return cmd.Errorf("missing command. Use --help for help.").WithCode(2) } - sub, ok := commands[st.SubName] + sub, ok := commands[c.SubName] if !ok { - return cmd.Errorf("unknown command %q. Use --help for help.", st.SubName) + return cmd.Errorf("unknown command %q. Use --help for help.", c.SubName) } - if st.k.Bool("offline") { + if c.k.Bool("offline") { project.Offline() } - err = sub.Run(st.SubArgs...) + err = sub.Run(c.SubArgs...) if err != nil { if err.Error() != "" { - err = fmt.Errorf("%s: %w", st.SubName, err) + err = fmt.Errorf("%s: %w", c.SubName, err) } return err } @@ -97,8 +107,23 @@ func (st *state) Run(args []string) error { return nil } -func (st *state) parseFlags(args []string) error { - var flags pflag.FlagSet +func (c *Cmd) Flags() *pflag.FlagSet { + f := pflag.NewFlagSet("pg_migrate", pflag.ContinueOnError) + f.SetInterspersed(false) + // Ensure flags are consistent with PostgreSQL CLIs. + f.BoolP("version", "V", false, "Print version and exit") + f.BoolP("help", "?", false, "Print help and exit") + f.BoolP("verbose", "v", cmd.DevMode, "Show debug log messages") + _, nocolor := os.LookupEnv("NO_COLOR") // See. https://no-color.org/ + f.Bool("plain", nocolor || !isatty.IsTerminal(os.Stderr.Fd()), "Disable log coloration") + f.Bool("skip-date-warning", false, "Suppress the warning about outdated build") + f.Bool("offline", false, "Prevent source database access") + f.StringP("directory", "C", "", "Change to directory before doing anything") + return f +} + +func (c *Cmd) parseFlags(args []string) error { + flags := c.Flags() flags.Usage = func() { _, _ = os.Stderr.WriteString(dedent.Dedent(` PostgreSQL Migrator moves your database to PostgreSQL. @@ -134,38 +159,29 @@ func (st *state) parseFlags(args []string) error { Report bugs to https://gitlab.com/dalibo/pg_migrate/-/issues. `)[1:]) } - // Ensure flags are consistent with PostgreSQL CLIs. - flags.BoolP("version", "V", false, "Print version and exit") - flags.BoolP("help", "?", false, "Print help and exit") - flags.BoolP("verbose", "v", cmd.DevMode, "Show debug log messages") - _, nocolor := os.LookupEnv("NO_COLOR") // See. https://no-color.org/ - flags.Bool("plain", nocolor || !isatty.IsTerminal(os.Stderr.Fd()), "Disable log coloration") - flags.Bool("skip-date-warning", false, "Suppress the warning about outdated build") - flags.Bool("offline", false, "Prevent source database access") - flags.StringP("directory", "C", "", "Change to directory before doing anything") err := flags.Parse(args) if err != nil { return cmd.Errorf("args: %s", err).WithCode(2) } - st.k = cmd.Koanf(&flags) - if st.k.Bool("help") { + c.k = cmd.Koanf(flags) + if c.k.Bool("help") { flags.Usage() return cmd.Exit(0) } remaining := flags.Args() if len(remaining) > 0 { - st.SubName = remaining[0] + c.SubName = remaining[0] remaining = remaining[1:] } - st.SubArgs = remaining + c.SubArgs = remaining return nil } -func (st *state) chdir() error { - root := st.k.String("directory") +func (c *Cmd) chdir() error { + root := c.k.String("directory") if root == "" { return nil } @@ -177,20 +193,21 @@ func (st *state) chdir() error { return nil } -type Runner interface { +type runner interface { Run(args ...string) error // String is used to print command usage. } -var commands = map[string]Runner{ - "status": &status.Cmd{}, - "init": &initc.Cmd{}, - "inspect": &inspect.Cmd{}, - "report": &report.Cmd{}, - "ui": &ui.Cmd{}, - "convert": &convert.Cmd{}, - "dump": &dump.Cmd{}, - "_sql": &sql.Cmd{}, +var commands = map[string]runner{ + "status": &status.Cmd{}, + "init": &initc.Cmd{}, + "inspect": &inspect.Cmd{}, + "report": &report.Cmd{}, + "ui": &ui.Cmd{}, + "convert": &convert.Cmd{}, + "dump": &dump.Cmd{}, + "_sql": &sql.Cmd{}, + "_complete": &completec.Cmd{Root: &Cmd{}}, } func showVersion() { diff --git a/internal/cmd/main/complete.go b/internal/cmd/main/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..d79d99749db1d61bef8b43671e2124f4190f7ff8 --- /dev/null +++ b/internal/cmd/main/complete.go @@ -0,0 +1,54 @@ +package mainc + +import ( + "fmt" + + "gitlab.com/dalibo/pg_migrate/internal/complete" +) + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + newArg := args[len(args)-1] == "" + complete.ParsePFlags(f, args) + + return complete.Any( + // First, delegate to any sub-command. + complete.PropositionsFunc(func() ([]string, bool) { + sub, _ := commands[f.Arg(0)].(complete.Completer) + if sub == nil { // Maybe a partial sub-command. + return nil, false // Continue next proposer. + } + + _ = c.parseFlags(args) // Effective args processing. + _ = c.chdir() // Apply --directory before continue to sub-command. + + args = f.Args()[1:] + if len(args) == 0 && !newArg { // Bare sub-command without trailing space. + return nil, false // Let next proposer handle this. + } + if newArg { + // Restore new arg for sub-command. + args = append(args, "") + } + return sub.Complete(args), true + }), + // Or propose flag values. + complete.PropositionsFunc(func() ([]string, bool) { + proposers := complete.PFlagBools(f) + d, _ := f.GetString("directory") + proposers = append(proposers, complete.Dir("directory", d)) + return complete.PFlagValue( + f, args, + proposers..., + ) + }), + // Fallback to propose flags and arguments. + complete.PropositionsFunc(func() ([]string, bool) { + propositions, _ := complete.PFlags(f) + for k, c := range commands { + propositions = append(propositions, fmt.Sprintf("%s \t%s", k, c)) + } + return propositions, true + }), + ) +} diff --git a/internal/cmd/report/cmd.go b/internal/cmd/report/cmd.go index 1021a46f4ef5b0d2813af369242bddad10d133b3..09347514642dfb57ed38537f3300b91ad33d8db0 100644 --- a/internal/cmd/report/cmd.go +++ b/internal/cmd/report/cmd.go @@ -85,9 +85,14 @@ func (c *Cmd) Run(args ...string) error { return rules.Run() } -func (c *Cmd) parseArgs(args ...string) error { +func (c *Cmd) Flags() *pflag.FlagSet { f := pflag.NewFlagSet("report", pflag.ContinueOnError) f.BoolP("help", "?", false, "Show help and exit") + return f +} + +func (c *Cmd) parseArgs(args ...string) error { + f := c.Flags() f.Usage = func() { _, _ = os.Stderr.WriteString(dedent.Dedent(` Usage: pg_migrate report [OPTIONS] [clean | REPORTS...] diff --git a/internal/cmd/report/complete.go b/internal/cmd/report/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..6ad94b6bf2c76ce3ae22356c2ee7690b1692e0db --- /dev/null +++ b/internal/cmd/report/complete.go @@ -0,0 +1,46 @@ +package report + +import ( + "slices" + "strings" + + "gitlab.com/dalibo/pg_migrate/internal/complete" + "gitlab.com/dalibo/pg_migrate/internal/project" +) + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + complete.ParsePFlags(f, args) + _ = project.Open() + + propositions, _ := complete.PFlags(f) + + // clean is final. + if slices.Contains(args, "clean") { + return propositions + } + + // Propose clean if first arg is empty or partial clean word. + if strings.HasPrefix("clean", f.Arg(0)) { + propositions = append(propositions, complete.Proposition("clean", true, "Delete generated files.")) + } + + // Propose configured reports. + for _, r := range project.Current.Config.Reports { + if slices.Contains(args, r) { + continue + } + propositions = append(propositions, complete.Proposition(r, true, "")) + } + + jq, _ := complete.Glob("", "*.jq").Propose() + for _, p := range jq { + propositions = append(propositions, strings.Replace(p, ".jq", ".json", 1)) + } + tmpl, _ := complete.Glob("", "*.tmpl").Propose() + for _, p := range tmpl { + propositions = append(propositions, strings.Replace(p, ".tmpl", "", 1)) + } + + return propositions +} diff --git a/internal/cmd/sql/cmd.go b/internal/cmd/sql/cmd.go index 1447ae135a531b92dc2d28fee3964259ceef58d9..79024ea340aadd83e111b74670a5b0d99b9ad41a 100644 --- a/internal/cmd/sql/cmd.go +++ b/internal/cmd/sql/cmd.go @@ -58,8 +58,16 @@ func (c *Cmd) Run(args ...string) error { return nil } +func (c *Cmd) Flags() *pflag.FlagSet { + f := pflag.NewFlagSet("_sql", pflag.ContinueOnError) + f.BoolP("help", "?", false, "Show help") + f.Bool("semi-colon", !isatty.IsTerminal(os.Stdout.Fd()), "Append semi-colon for usql") + f.Int("errpos", 0, "Highlight error at char") + return f +} + func (c *Cmd) parseFlags(args ...string) (*koanf.Koanf, error) { - var flags pflag.FlagSet + flags := c.Flags() flags.Usage = func() { _, _ = os.Stderr.WriteString(dedent.Dedent(` Usage: pg_migrate [OPTIONS] _sql [OPTIONS] NAME @@ -79,9 +87,6 @@ func (c *Cmd) parseFlags(args ...string) (*koanf.Koanf, error) { See pg_migrate --help for more informations. `)[1:]) } - flags.BoolP("help", "?", false, "Show help") - flags.Bool("semi-colon", !isatty.IsTerminal(os.Stdout.Fd()), "Append semi-colon for usql") - flags.Int("errpos", 0, "Highlight error at char") err := flags.Parse(args) if err != nil { return nil, cmd.Errorf("args: %s", err).WithCode(2) @@ -90,7 +95,7 @@ func (c *Cmd) parseFlags(args ...string) (*koanf.Koanf, error) { flags.Usage() os.Exit(0) } - k := cmd.Koanf(&flags) + k := cmd.Koanf(flags) _ = k.Load(confmap.Provider(map[string]any{ "name": flags.Arg(0), }, k.Delim()), nil) diff --git a/internal/cmd/sql/complete.go b/internal/cmd/sql/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..245389091295aa73855bed53f67baa05398aac8c --- /dev/null +++ b/internal/cmd/sql/complete.go @@ -0,0 +1,10 @@ +package sql + +import "gitlab.com/dalibo/pg_migrate/internal/complete" + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + complete.ParsePFlags(f, args) + props, _ := complete.PFlags(f) + return props +} diff --git a/internal/cmd/status/cmd.go b/internal/cmd/status/cmd.go index 49102e29049fd56cde7152aef853aea3fc9b1ab0..d5b7aff889f851d845727443e52823ca90a99a17 100644 --- a/internal/cmd/status/cmd.go +++ b/internal/cmd/status/cmd.go @@ -35,9 +35,14 @@ func (c *Cmd) Run(args ...string) error { return t.Execute(os.Stdout, project.Current) } -func (c *Cmd) parseArgs(args ...string) error { +func (c *Cmd) Flags() *pflag.FlagSet { f := pflag.NewFlagSet("status", pflag.ContinueOnError) f.BoolP("help", "?", false, "Show help and exit") + return f +} + +func (c *Cmd) parseArgs(args ...string) error { + f := c.Flags() f.Usage = func() { _, _ = os.Stderr.WriteString(dedent.Dedent(` Usage: pg_migrate [OPTIONS] status [OPTIONS] diff --git a/internal/cmd/status/complete.go b/internal/cmd/status/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..337c353bee0c6cb743ec50f0b037926bfac8c302 --- /dev/null +++ b/internal/cmd/status/complete.go @@ -0,0 +1,10 @@ +package status + +import "gitlab.com/dalibo/pg_migrate/internal/complete" + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + complete.ParsePFlags(f, args) + propositions, _ := complete.PFlags(f) + return propositions +} diff --git a/internal/cmd/ui/cmd.go b/internal/cmd/ui/cmd.go index 47fb16a9026c9efcfd9330a25fecc6a8c57014b7..088f346182302d282576bbed42d091868140d400 100644 --- a/internal/cmd/ui/cmd.go +++ b/internal/cmd/ui/cmd.go @@ -119,10 +119,15 @@ func (c *Cmd) Run(args ...string) error { return nil } -func (c *Cmd) parseArgs(args ...string) error { +func (c *Cmd) Flags() *pflag.FlagSet { f := pflag.NewFlagSet("status", pflag.ContinueOnError) f.BoolP("help", "?", false, "Show help and exit") f.BoolVar(&c.open, "open", false, "Open browser") + return f +} + +func (c *Cmd) parseArgs(args ...string) error { + f := c.Flags() f.Usage = func() { _, _ = os.Stderr.WriteString(dedent.Dedent(` Usage: pg_migrate [OPTIONS] ui [OPTIONS] diff --git a/internal/cmd/ui/complete.go b/internal/cmd/ui/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..deaab8f83f3967a3a05f4ea03fda810e45d0e9bb --- /dev/null +++ b/internal/cmd/ui/complete.go @@ -0,0 +1,10 @@ +package ui + +import "gitlab.com/dalibo/pg_migrate/internal/complete" + +func (c *Cmd) Complete(args []string) []string { + f := c.Flags() + complete.ParsePFlags(f, args) + props, _ := complete.PFlags(f) + return props +} diff --git a/internal/complete/complete.go b/internal/complete/complete.go new file mode 100644 index 0000000000000000000000000000000000000000..34aa8254cf4f2a3e1078d7b8f78c68c69caa8c16 --- /dev/null +++ b/internal/complete/complete.go @@ -0,0 +1,169 @@ +package complete + +import ( + "fmt" + "os" + "slices" + "strconv" + "strings" + + "github.com/google/shlex" +) + +// Proposition formats an argument for completion +func Proposition(arg string, full bool, help string) string { + var b strings.Builder + b.Grow(len(arg) + 2 + len(help)) + b.WriteString(arg) + if full { + b.WriteRune(' ') + } + if help != "" { + b.WriteRune('\t') + b.WriteString(help) + } + return b.String() +} + +// Completer defines an object producing propositions from arguments. +type Completer interface { + // Complete returns the list of command line continuation + // + // args is the list of command line token **so far**. + // arguments after cursor are not accessible. + // If user begins a new argument, + // an empty string terminates args list. + // + // Returns a list of propositions as strings. + // + // A proposition is a string with specific trails: + // + // - a space to acknowledge a full proposition. + // - a tab and a help text. + // + // e.g.: "--section=" without space to let user set value right after equal. + // e.g.: "--verbose " with space to move on next flag. + // e.g.: "convert \tsub-command help" tab and help message. + // + // Use [Proposition] to format a proposition. + // Use [Formatter] to present proposition. + Complete(args []string) []string +} + +// Complete sends command line argument propositions to shell. +// +// Reads command line and point from COMP_ env vars. +// Parses line and ask completer to produce propositions. +// Filters propositions to match last argument prefix. +// Format proposition help. +// +// Exits process. +func Complete(c Completer, shell Formatter) { + line := getLine() + flag, propositions := generatePropositions(line, c) + if len(propositions) == 0 { + os.Exit(0) + } + + unique := len(propositions) == 1 + for _, proposition := range propositions { + _, _ = fmt.Println(shell.Format(flag, proposition, unique)) + } + os.Exit(0) +} + +func getLine() string { + line := os.Getenv("COMP_LINE") + if line == "" { + panic("missing command line to complete") + } + + pointS := os.Getenv("COMP_POINT") + if pointS == "" { + return line + } + + point, err := strconv.Atoi(pointS) + if err != nil { + panic(fmt.Sprintf("bad point: %s", pointS)) + } + + if point > len(line) { + panic(fmt.Sprintf("point farther than command line end: %d > %d", point, len(line))) + } + + return line[:point] +} + +// Hidden lists first chars of hidden propositions. +// +// - . for dot files. +// - _ for hidden commands. +// - - for flags. +// +// If [Proposer] produces only hidden propositions, +// complete shows them, even if user didn't type first character. +// This is the only cas where both propositions starting with ., _ or - +// are presented to user. +var Hidden string = "._-" + +func generatePropositions(line string, c Completer) (flag string, propositions []string) { + args, _ := shlex.Split(line) + if len(args) == 0 { + return + } + args = args[1:] // Remove command. + + newArg := strings.HasSuffix(line, " ") + if strings.HasSuffix(line, "=") { + newArg = true + flag = args[len(args)-1] + } + if newArg { + // Tell completer to complete a new argument. + args = append(args, "") + } + + var current string + if len(args) > 0 { + current = args[len(args)-1] + if strings.Contains(current, "=") { + // Split flag and current value. + // Proposer will produce values without flag= prefix. + // We need flag-less current value to filter propositions + // starting with current argument. + // Keep flag for fish to write flag back with propositions. + flag, current, _ = strings.Cut(current, "=") + } + } + + flag = strings.TrimSuffix(flag, "=") + + propositions = c.Complete(args) + + // Filter hidden values if current is not hidden. + hide := current == "" || !strings.Contains(Hidden, current[:1]) + if hide { + var unhidden []string + for _, v := range propositions { + if !strings.Contains(Hidden, v[:1]) { + unhidden = append(unhidden, v) + } + } + // If only hidden propositions, let them. + if len(unhidden) > 0 { + propositions = unhidden + } + } + + if current != "" { + // Filter values not matching current argument or current value itself. + propositions = slices.DeleteFunc(propositions, func(v string) bool { + return !strings.HasPrefix(v, current) + }) + } + + slices.SortStableFunc(propositions, strings.Compare) + + return +} diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2129699116c9cb8974f83bde234e610bc14299f4 --- /dev/null +++ b/internal/complete/complete_test.go @@ -0,0 +1,36 @@ +package complete + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type mock struct { + fn func(args []string) []string +} + +func (m mock) Complete(args []string) []string { + return m.fn(args) +} + +func (m mock) run(line string) []string { + _, propositions := generatePropositions(line, m) + return propositions +} + +func TestPrefix(t *testing.T) { + r := require.New(t) + + cmd := mock{fn: func(args []string) []string { + return []string{"pif", "paf", "pifpaf"} + }} + r.Equal( + []string{"paf", "pif", "pifpaf"}, + cmd.run("pg_migrate "), + ) + r.Equal( + []string{"pif", "pifpaf"}, + cmd.run("pg_migrate pi"), + ) +} diff --git a/internal/complete/configuration.fish b/internal/complete/configuration.fish new file mode 100644 index 0000000000000000000000000000000000000000..8d7df7777c9f03cb1c725bbf7b4686282f436e5f --- /dev/null +++ b/internal/complete/configuration.fish @@ -0,0 +1,6 @@ +function __complete_pg_migrate + set -x COMP_LINE (commandline --current-process --cut-at-cursor) + {{ .Completer }} +end +complete --command "{{ .Command }}" --erase +complete --command "{{ .Command }}" --keep-order --exclusive --arguments "(__complete_pg_migrate)" diff --git a/internal/complete/configuration.zsh b/internal/complete/configuration.zsh new file mode 100644 index 0000000000000000000000000000000000000000..97e3d7d64169d08d7169a6829a170cab46763482 --- /dev/null +++ b/internal/complete/configuration.zsh @@ -0,0 +1,49 @@ +#compdef {{ .Command }} + +_{{ .Command }}() { + local -x COMP_LINE="$BUFFER" + local -a args + local -a display + local -a propositions + + propositions=("${(@f)$({{ .Completer }} "_complete" --shell=zsh)}") + + for p in "${propositions[@]}"; do + args=(-o nosort -J pg_migrate -d display) + + d="$p" + p="${d/$'\t'*}" # Trim after tab + + # zsh requires --flag= prefix for value completions. + # This is ugly. Hide --flag= from display. + if [[ "$p" == *'='* ]] ; then + flag="${p/=*}" # Trim after equal + value="${p/-*=}" # Trim before equal + if [ -n "$value" ] && [ "$value" != 'false ' ] ; then + description= + if [[ $d == *$'\t'* ]] ; then + description=$'\t'"${d##*$'\t'}" # Trim before tab + fi + # Keep padding by writing as many space as flag chars. + d="$value${flag//?/ }$description" + fi + fi + + display=("$d") + + if [[ $d == *$'\t'* ]]; then + args+=(-l) # Force one column. + fi + + # Unlike bash zsh accepts values for a single argument, not for a CLI continuation. + # Thus, trailing space is escaped. Hack -o nospace if pg_migrate indicates an end of value. + if [[ $p == *' ' ]]; then + args+=(-S ' ') + else + args+=(-S '') + fi + p="${p% }" + + compadd "${args[@]}" -- "$p" + done +} diff --git a/internal/complete/doc.go b/internal/complete/doc.go new file mode 100644 index 0000000000000000000000000000000000000000..b0593397682d0d62c74e937eb019baec676c9bf3 --- /dev/null +++ b/internal/complete/doc.go @@ -0,0 +1,41 @@ +// Package complete provides imperative and composable shell completion. +// +// Completing a command line consists of returning a set of propositions to shell +// by writing in stdout one proposition per line. +// The target features of this package includes: +// +// - Composable and imperative API. +// - Inline documentation of propositions. +// - Hide proposition by prefix until user type prefix. e.g hidden filename. +// - Non intrusive configuration, easy for packaging. +// - Propose --opt=false booleans. +// - Exclude --help if other flag are set. Exclude flags when --help is set. +// +// Shells processes propositions differently: +// +// - Bash accepts proposition as full command line completion. +// - Zsh and fish accept proposition as single argument completion. +// - Fish accept a description after a tab. +// +// Also, shells have different handling of multiple propositions: +// +// - Bash simply list, user needs to type a letter to further complete. +// - Zsh and fish allow to loop propositions. +// +// POSIX flags accepts setting a value to a flag either with an equal sign or space. +// Shells behave differently on this matter. +// +// - Bash treat value after equal sign as a single command line argument. +// - Zsh and Fish wants completion of value to include the flag. +// +// i.e. to complete --section=, +// Zsh and fish expect proposition `--section=data` +// while bash expects `data`. +// +// The limitations of this package: +// +// - no completion for short options. +// - no quoting/escaping of path. +// - ignores command line after point. +// - ignores COMP_TYPE. +package complete diff --git a/internal/complete/pflags.go b/internal/complete/pflags.go new file mode 100644 index 0000000000000000000000000000000000000000..c3ccf12548fc6aa60937fbcc03b1cbe0a1101a36 --- /dev/null +++ b/internal/complete/pflags.go @@ -0,0 +1,177 @@ +package complete + +import ( + "fmt" + "slices" + "strings" + + "github.com/spf13/pflag" +) + +// ParsePFlags parses completion args +// +// Ensures current placeholder is not parsed. +// Accepts errors and unknown flags (e.g. partial flags). +func ParsePFlags(f *pflag.FlagSet, args []string) { + if len(args) < 1 { + return + } + + if args[len(args)-1] == "" { + args = args[:len(args)-1] + } + + f.ParseErrorsAllowlist.UnknownFlags = true + _ = f.Parse(args) +} + +// PFlags generates completion from sp13/pflag flagset +// +// Flagset should have command line arguments parsed. +// Proposes only unset flags. +func PFlags(pflags *pflag.FlagSet) ([]string, bool) { + var propositions []string + + // Don't bother completing command line with --help. + helping, _ := pflags.GetBool("help") + if helping { + return nil, true + } + + changed := len(pflags.Args()) > 0 + if !changed { + pflags.Visit(func(f *pflag.Flag) { + changed = true + }) + } + + pflags.VisitAll(func(f *pflag.Flag) { + if pflags.Changed(f.Name) { + return + } + + if f.Name == "help" && changed { + // Skip help if any flag is set. + return + } + + var b strings.Builder + b.Grow(64) + b.WriteString("--") + b.WriteString(f.Name) + + if f.Value.Type() == "bool" { + if f.Value.String() == "true" && !pflags.Changed(f.Name) { + b.WriteString("=false") + } + b.WriteRune(' ') + } else { + b.WriteRune('=') + } + + usage := strings.ReplaceAll(f.Usage, "\n", " ") + current := f.Value.String() + if usage != "" || current != "" { + b.WriteRune('\t') + } + b.WriteString(usage) + + if current != "" && f.Value.Type() != "bool" { + if usage != "" { + b.WriteRune(' ') + } + fmt.Fprintf(&b, "(current %s)", current) + } + propositions = append(propositions, b.String()) + }) + + return propositions, true +} + +// PFlagBools generates proposers for boolean flags +func PFlagBools(fs *pflag.FlagSet) []Proposer { + var proposers []Proposer + fs.VisitAll(func(f *pflag.Flag) { + if f.Value.Type() != "bool" { + return + } + if fs.Changed(f.Name) && f.Value.String() == "false" { + return + } + proposers = append(proposers, Enum(f.Name, "false ")) + }) + return proposers +} + +// PFlagCurrent determine current flag and value +func PFlagCurrent(args []string) (name string, value string) { + value = args[len(args)-1] + // Search for current flag. + // case 1: [--flag=value] incomplete value with flag. + // case 2: [--flag=, ""] waiting for value + // case 3: [--flag, ""] value after space + // case 4: [--flag, value] space-set value + // case 5: [--flag=value, ""] new flag after value. + // skip other cases. + if strings.Contains(value, "=") { // case 1: --flag=value. + name, value, _ = strings.Cut(value, "=") + } else if len(args) < 2 { // case ["--flag"], waiting for space, equal or completion. + return "", "" + } else { + name = args[len(args)-2] + if strings.Contains(name, "=") && !strings.HasSuffix(name, "=") { + return "", "" // case 5 + } + } + return name, value +} + +// PFlagValue proposes value for current flag. +// +// Accepts a set of proposer for some flags in flagset. +// Check args to determine which flag is being completed. +// Returns corresponding values. +// +// See [Enum] and [Dir] to define values for a flag. +func PFlagValue(pflags *pflag.FlagSet, args []string, proposers ...Proposer) ([]string, bool) { + if len(args) < 1 { + return nil, false + } + + flag, current := PFlagCurrent(args) + if flag == "" { + return nil, false + } + + for _, p := range proposers { + n := fmt.Sprint(p) // use .String() to get flag name. + f := pflags.Lookup(n) + if f == nil { + panic(fmt.Sprintf("Unknown flag %s", n)) + } + needle := fmt.Sprintf("--%s", f.Name) + n = fmt.Sprintf("-%s", f.Shorthand) + if f.Shorthand != "" && strings.HasPrefix(flag, n) { + needle = n + } + if !strings.HasPrefix(flag, needle) { + continue + } + + propositions, _ := p.Propose() + if current != "" { + // Drop irrelevant values. + propositions = slices.DeleteFunc(propositions, func(v string) bool { + v = strings.TrimSpace(v) + return !strings.HasPrefix(v, current) + }) + if len(propositions) == 1 && strings.TrimSpace(propositions[0]) == current { + return nil, true + } + } + + return propositions, true + } + + return nil, false +} diff --git a/internal/complete/pflags_test.go b/internal/complete/pflags_test.go new file mode 100644 index 0000000000000000000000000000000000000000..20df82991b4c5c9ca216cb09d28537200dd93b22 --- /dev/null +++ b/internal/complete/pflags_test.go @@ -0,0 +1,113 @@ +package complete + +import ( + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" +) + +func TestPFlags(t *testing.T) { + r := require.New(t) + + cmd := mock{fn: func(args []string) []string { + f := pflag.NewFlagSet("pg_migrate", pflag.ContinueOnError) + f.BoolP("verbose", "v", true, "") + f.BoolP("version", "V", false, "") + f.String("opt", "default", "opt usage") + _ = f.Parse(args) + propositions, _ := PFlags(f) + return propositions + }} + + r.Equal( + []string{ + "--opt=\topt usage (current default)", + "--verbose=false \t", + "--version \t", + }, + cmd.run("pg_migrate "), + ) + // Skip --opt + r.Equal( + []string{"--verbose=false \t", "--version \t"}, + cmd.run("pg_migrate --opt=pif "), + ) + r.Equal( + []string{"--verbose=false \t"}, + cmd.run("pg_migrate --verb"), + ) + // Skip --version + r.Equal( + []string{ + "--opt=\topt usage (current default)", + "--verbose=false \t", + }, + cmd.run("pg_migrate -V "), + ) +} + +func TestPFlagsValues(t *testing.T) { + r := require.New(t) + + cmd := mock{fn: func(args []string) []string { + f := pflag.NewFlagSet("pg_migrate", pflag.ContinueOnError) + f.String("opt", "", "opt usage") + _ = f.Parse(args) + propositions, _ := PFlagValue(f, args, Enum("opt", "paf ", "pif ", "pifpaf ")) + return propositions + }} + + r.Equal( + []string{"paf ", "pif ", "pifpaf "}, + cmd.run("pg_migrate --opt="), + ) + + r.Equal( + []string{"pif ", "pifpaf "}, + cmd.run("pg_migrate --opt=pi"), + ) + + r.Equal( + []string{"pif ", "pifpaf "}, + cmd.run("pg_migrate --opt pi"), + ) + + r.Equal( + []string{"pif ", "pifpaf "}, + cmd.run("pg_migrate --opt=pif"), + ) + + r.Equal( + []string(nil), + cmd.run("pg_migrate --opt=pif "), + ) + + r.Equal( + []string(nil), + cmd.run("pg_migrate --opt pif "), + ) +} + +func TestPFlagsHelp(t *testing.T) { + r := require.New(t) + + cmd := mock{fn: func(args []string) []string { + f := pflag.NewFlagSet("pg_migrate", pflag.ContinueOnError) + f.Bool("help", false, "") + f.Bool("version", false, "") + ParsePFlags(f, args) + propositions, _ := PFlags(f) + return propositions + }} + + r.Equal( + []string{"--help \t", "--version \t"}, + cmd.run("pg_migrate "), + ) + // Skip --help if something + r.Equal( + []string(nil), + cmd.run("pg_migrate --version "), + ) +} diff --git a/internal/complete/shell.go b/internal/complete/shell.go new file mode 100644 index 0000000000000000000000000000000000000000..72846c708df128f92924eadd4bb895d01c1dc5ec --- /dev/null +++ b/internal/complete/shell.go @@ -0,0 +1,182 @@ +package complete + +import ( + _ "embed" + "fmt" + "strings" + "text/template" + + "github.com/lithammer/dedent" +) + +// Formatter render proposition for a specific shell +// +// The formatting of proposition is tightly coupled with configuration. +// For example, +// zsh configuration is a shell function processing propositions +// as formatted by go completer. +type Formatter interface { + // Generates configuration for the shell to interpret proposition. + Configuration(command, completer string) string + // Format a proposition for specific shell. + Format(flag, proposition string, unique bool) string +} + +// Shell instanciate formatter for a known shell. +// +// Accepts bash, fish or zsh. +// Returns nil otherwise. +func Shell(name string) Formatter { + switch name { + case "bash": + return &bash{width: getTermWidth()} + case "zsh": + return &zsh{} + case "fish": + return &fish{} + default: + return nil + } +} + +// Bash has the following specificities: +// +// - proposition is appended as-is in command line. You can return multiple arguments. +// - no proposition cycle +// - no terminal sequence processing +// +// The hack to show description is simply to remove description only for unique proposition +// so that bash does not append description to command line. +// +// Actually, bash is dumb and easy to reasonate. +// +// To test bash support: +// +// source <(go run . _complete --configuration) +// pg_migrate +type bash struct { + width int +} + +func (b *bash) Configuration(command, completer string) string { + return fmt.Sprintf(dedent.Dedent(` + complete -o nospace -o nosort -o bashdefault -C "%s" %s + `)[1:], completer, command) +} + +func (b *bash) Format(_, proposition string, unique bool) string { + proposition, description, _ := strings.Cut(proposition, "\t") + if unique || description == "" { + // Drop description for unique proposition. + return proposition + } + + var buf strings.Builder + // Align description column at 24 columns. + n, _ := fmt.Fprintf(&buf, "%-24s%s", proposition, description) + if n < b.width { + // pad line to avoid bash to reflow completions with usage. + _, _ = fmt.Fprintf(&buf, "%*s", b.width-n, " ") + } + return buf.String() +} + +// Zsh has the following behaviour: +// +// - Is complex. +// - Configuration process output to configure zsh completion. +// - Accepts terminal escape for coloration +// - Cycle propositions. +// - Escape spaces. +// +// To test zsh support: +// +// source <(go run . _complete --shell=zsh --configuration) +// pg_migrate +type zsh struct{} + +//go:embed configuration.zsh +var zshScript string + +func (z *zsh) Configuration(command, completer string) string { + return mustRender(zshScript, map[string]string{ + "Command": command, + "Completer": completer, + }) +} + +func (z *zsh) Format(flag, proposition string, _ bool) string { + value, description, _ := strings.Cut(proposition, "\t") + if flag != "" { + // Prepend --flag= to values. + value = fmt.Sprintf("%s=%s", flag, value) + } + + if description == "" { + return value + } + + w := 1 + len(value)/8 + value += "\t" + // dimmed and italic description. + // Pad *after* tab so that zsh loops on values without padding. + display := fmt.Sprintf("%s%*s\033[2;3m%s\033[0m", value, max(0, 25-8*w), " ", description) + return display +} + +// Fish has the following behaviour: +// +// - Accepts description after a hard tab. +// - Escape space: proposition is an argument, not a command line continuation. +// - Fish append a space after proposition except if proposition ends with =, / or :. +// +// To test fish: +// +// go run . _complete --shell=fish --configuration | source +// pg_migrate +type fish struct{} + +//go:embed configuration.fish +var fishScript string + +func (f *fish) Configuration(command, completer string) string { + return mustRender(fishScript, map[string]string{ + "Command": command, + "Completer": completer, + }) +} + +func (f *fish) Format(flag, proposition string, _ bool) string { + proposition, description, _ := strings.Cut(proposition, "\t") + + // fish has different handling of = than bash. Drop it. + // proposition = strings.TrimRight(proposition, "=") + + // Fish does not add space after =/:. + // But always adds space otherwise. + // Let's fish handle space. + proposition = strings.TrimSpace(proposition) + + // Fish does not complete after equal. + // Write back equal notation. + if flag != "" { + proposition = flag + "=" + proposition + } + + // Put back description. + if description != "" { + proposition = fmt.Sprintf("%s\t%s", proposition, description) + } + + return proposition +} + +func mustRender(s string, args any) string { + template := template.Must(template.New("shell").Parse(s)) + var buf strings.Builder + err := template.Execute(&buf, args) + if err != nil { + panic(err) + } + return buf.String() +} diff --git a/internal/complete/term.go b/internal/complete/term.go new file mode 100644 index 0000000000000000000000000000000000000000..b59100d14aef57bc3c949740cc448e4a04eb5a24 --- /dev/null +++ b/internal/complete/term.go @@ -0,0 +1,52 @@ +package complete + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +func getTermWidth() int { + v, _ := strconv.Atoi(os.Getenv("COLUMNS")) + if v > 0 { + return v + } + + if os.Getenv("TERM") == "" { + return 80 + } + + tput, err := exec.LookPath("tput") + if err != nil { + return 80 + } + + r, w, err := os.Pipe() + if err != nil { + return 80 + } + defer r.Close() //nolint:errcheck + + attr := &os.ProcAttr{ + Files: []*os.File{os.Stdin, w, os.Stderr}, + Env: os.Environ(), + } + + proc, err := os.StartProcess(tput, []string{"tput", "cols"}, attr) + _ = w.Close() + if err != nil { + return 80 + } + + buf := make([]byte, 64) + n, _ := r.Read(buf) + _, _ = proc.Wait() + + out := strings.TrimSpace(string(buf[:n])) + v, _ = strconv.Atoi(out) + if v > 0 { + return v + } + return 80 +} diff --git a/internal/complete/values.go b/internal/complete/values.go new file mode 100644 index 0000000000000000000000000000000000000000..aded94839bfea8872d22e75150195a87026f5338 --- /dev/null +++ b/internal/complete/values.go @@ -0,0 +1,138 @@ +package complete + +import ( + "os" + "path/filepath" + "strings" + + "github.com/goreleaser/fileglob" +) + +// Proposer generates propositions from configuration +type Proposer interface { + // Name of the flag for which propositions are produced. + String() string + // Propose values for command line. + // + // If ok is false, you should continue next proposer. + // Otherwise, return to user, even if no propositions are produced. + // See [Any] for chaining proposers. + Propose() (propositions []string, ok bool) +} + +type enum struct { + flag string + propositions []string +} + +// Enum associates static propositions to a flag +func Enum(flag string, propositions ...string) enum { + return enum{ + flag: flag, + propositions: propositions, + } +} + +func (c enum) String() string { + return c.flag +} + +func (c enum) Propose() ([]string, bool) { + return c.propositions, true +} + +// PropositionsFunc implements [Proposer] with a func. +type PropositionsFunc func() ([]string, bool) + +func (f PropositionsFunc) String() string { + return "" +} + +func (f PropositionsFunc) Propose() ([]string, bool) { + return f() +} + +// Any tries several proposers until one returns completions +func Any(v ...Proposer) []string { + for _, c := range v { + propositions, ok := c.Propose() + if len(propositions) > 0 || ok { + return propositions + } + } + return nil +} + +// Dir complete directory +// +// Ignores hidden dirs unless user types dot. +func Dir(flag, current string) dir { + return dir{flag: flag, current: current} +} + +type dir struct { + flag string + current string +} + +func (d dir) String() string { + return d.flag +} + +func (d dir) Propose() ([]string, bool) { + _, err := os.Stat(d.current) + exists := err == nil + // Determine search directory from current value. + parent := d.current + if !exists { + parent = filepath.Dir(d.current) + } + + showHidden := d.current != "" && strings.HasPrefix(filepath.Base(d.current), ".") + + entries, _ := os.ReadDir(parent) + var propositions []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + v := filepath.Join(parent, entry.Name()) + "/" + if entry.Name()[0] == '.' && !showHidden { + continue + } + + if d.current != "" && !strings.HasPrefix(v, d.current) { + continue + } + propositions = append(propositions, v) + } + + if len(propositions) == 0 && exists { + // Confirm selected existing dir. + propositions = []string{d.current + " "} + } + + return propositions, true +} + +func Glob(flag, pattern string) glob { + return glob{flag: flag, pattern: pattern} +} + +type glob struct { + flag string + pattern string +} + +func (g glob) String() string { + return g.flag +} + +func (g glob) Propose() ([]string, bool) { + matches, _ := fileglob.Glob(g.pattern) + var propositions []string + for _, match := range matches { + propositions = append(propositions, Proposition(match, true, "")) + } + return propositions, true +} diff --git a/test/build-completions.sh b/test/build-completions.sh new file mode 100755 index 0000000000000000000000000000000000000000..d930108b25380892b2c45dab2b4356e486dcb0df --- /dev/null +++ b/test/build-completions.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# +# For execution by goreleaser. +# See .goreleaser.yml +# + +set -eux + +rootdir="$1" +share="$rootdir/usr/share" + +d="$share/bash-completion/completions" +mkdir -p "$d" +$rootdir/pg_migrate _complete --shell=bash --configuration > "$d/pg_migrate" + +d="$share/fish/vendor_completions.d" +mkdir -p "$d" +$rootdir/pg_migrate _complete --shell=fish --configuration > "$d/pg_migrate.fish" + +d="$share/zsh/vendor-completions" +mkdir -p "$d" +$rootdir/pg_migrate _complete --shell=zsh --configuration > "$d/_pg_migrate" diff --git a/test/cli/mysql.bats b/test/cli/mysql.bats index d6b552b6facc0f2bac89d4926eed078826312bde..a72d47cce5cbc9939d6a7c97dd0d10958a6c1291 100644 --- a/test/cli/mysql.bats +++ b/test/cli/mysql.bats @@ -6,6 +6,7 @@ load 'test_helper/bats-support/load' load 'test_helper/bats-assert/load' setup_file() { + export SHELL=/bin/bash # For _complete export PGMDIRECTORY="$PWD/test/results/mysql" mkdir -p "$PGMDIRECTORY" export source="$PGMDIRECTORY/.pg_migrate/Source.json" @@ -24,6 +25,21 @@ setup_file() { } @test "init" { + COMP_LINE="pg_migrate " run pg_migrate _complete + assert_output --partial "init " + + COMP_LINE="pg_migrate init " run pg_migrate _complete + assert_output --partial "--source=" + assert_output --partial "--target=" + + COMP_LINE="pg_migrate init --source=" run pg_migrate _complete + assert_output --partial "mariadb://" + assert_output --partial "mysql://" + assert_output --partial "oracle://" + + COMP_LINE="pg_migrate init --target=" run pg_migrate _complete + assert_output --partial "postgresql://" + pg_migrate --verbose init --source "mysql://sakila:N0tSecret@${MYSQL_HOST-localhost}:3306/sakila" # Reinit pg_migrate --verbose init --source "mysql://sakila:N0tSecret@${MYSQL_HOST-localhost}/sakila" @@ -42,6 +58,9 @@ setup_file() { } @test "inspect" { + COMP_LINE="pg_migrate inspect " run pg_migrate _complete + assert_output --partial "--refresh " + run --separate-stderr pg_migrate inspect [ $status -eq 0 ] test -f "$source" @@ -103,6 +122,13 @@ setup_file() { } @test "report" { + COMP_LINE="pg_migrate report " run pg_migrate _complete + assert_output --partial "clean " + assert_output --partial "report.md " + refute_output --partial "report.md.tmpl " + assert_output --partial "Summary.json " + refute_output --partial "Summary.jq " + run --separate-stderr pg_migrate --verbose report report.md echo "$stderr" >"$PGMDIRECTORY/report.log" report="$PGMDIRECTORY/report.md" @@ -115,6 +141,9 @@ setup_file() { } @test "convert" { + COMP_LINE="pg_migrate convert " run pg_migrate _complete + assert_output --partial "--refresh " + pg_migrate --verbose convert |& tee "$PGMDIRECTORY/convert.log" run jq -er ".Tables | length" "$target" assert_output "23" @@ -130,6 +159,9 @@ setup_file() { } @test "dump-files" { + COMP_LINE="pg_migrate dump " run pg_migrate _complete + assert_output --partial "--refresh-stats=false " + pg_migrate --verbose dump --target=files grep -q sakila "$PGMDIRECTORY/dump"/*.sql }