spf13/cobraでサブコマンドとして外部コマンドを実行する

tblsには、tbls-cacoo のような外部コマンドをtbls cacoo のようなサブコマンドとして実行できるような機能を実装しています。私はこれを取り敢えず”外部サブコマンド機能”と呼んでいます。

外部サブコマンド機能は、

  1. コマンド名が tbls-XXX という命名規則であること
  2. 1をtbls XXX という形であくまで tbls を実行すること

という2つの条件をみたすことで、環境変数経由でtblsで取得した情報を外部コマンドに渡しています。

これにより、外部コマンド側では少ないコードベースで機能を実現できるようになっています。

実際に、例えばtbls-cacooのコードにはデータベースに接続するような実装は含まれていませんが、以下のようなコマンドでCacooで対象のデータベースのER図を生成するためのCSVを出力できます。

$ TBLS_DSN=postgres://dbuser:dbpass@localhost:5432/dbname tbls cacoo csv --out cacoo.csv
$ ls
tbls.yml
$ tbls cacoo csv --out cacoo.csv

tblsの外部サブコマンドの仕組み

tblsの外部サブコマンドの仕組みですが、具体的には以下のエントリの 追記 に書かれている手法をほぼそのまま採用しています。

pocketberserker.hatenablog.com

追記 以外の部分もspf13/cobraの挙動の説明など参考になります)

コードはここらへんで、関係がある部分を抜き出すと以下のようになります。

// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
    Use:                "tbls",
    SilenceErrors:      true,
    SilenceUsage:       true,
    Args:               cobra.ArbitraryArgs,
    DisableFlagParsing: true,
    Run: func(cmd *cobra.Command, args []string) {
        if len(args) == 0 {
            cmd.Println(cmd.UsageString()) // argsがないのであれば通常通りUsageを表示する
            return
        }
        envs := os.Environ()
        subCommand := args[0]
        path, err := exec.LookPath(cmd.Use + "-" + subCommand) // `tbls-*` なコマンドが存在するかを確認
        if err != nil {
            if strings.HasPrefix(subCommand, "-") {
                cmd.PrintErrf("Error: unknown flag: '%s'\n", subCommand)
                cmd.HelpFunc()(cmd, args)
                return
            }
            cmd.PrintErrln(`Error: unknown command "` + subCommand + `" for "tbls"`)
            cmd.PrintErrln("Run 'tbls --help' for usage.")
            return
        }

        configPath, args := parseConfigPath(args[1:]) // argsから `-c` `--config` オプションだけ外部コマンドに渡さずにパースして抜き出す

        // configPathの処理。ここでデータソースに接続したりして必要な環境変数(envs)を組み立てている

        c := exec.Command(path, args...) // 外部コマンドの実行
        c.Env = envs
        c.Stdout = os.Stdout
        c.Stdin = os.Stdin
        c.Stderr = os.Stderr
        if err := c.Run(); err != nil {
            printError(err)
            os.Exit(1)
        }
    },
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        printError(err)
        os.Exit(1)
    }
}

元のエントリと比べて違う点は、argsから -c --config オプションだけ限定してパースしていることくらいです。

外部サブコマンドのbash/zsh補完

spf13/cobraは、簡単なbash補完用の関数やzsh補完用の関数は自動生成してくれる機能を提供してくれています。

tblsではこれに手を加えてtbls [TAB] でサブコマンドに加えて外部サブコマンドも候補にでるようにしています。

$ tbls
build       -- tbls-build
cacoo       -- tbls-cacoo
completion  -- output shell completion code
coverage    -- measure document coverage
diff        -- diff database and document
doc         -- document a database
help        -- Help about any command
lint        -- check database document
meta        -- tbls-meta
out         -- analyzes a database and output
version     -- print tbls version

実装はzshだと、

subcommands=( ${(@f)"$(print -l ${^path}/tbls-*(-*N:t) | sort -u | sed -e 's/^tbls-\(.*\)$/\1:tbls-\1/')"} )

のようにPATHにある実行可能な tbls-XXXX といった外部コマンドを取得して(ここ)、補完候補に加えています。

そして、補完候補が入力されたときは

  case "$words[1]" in
  completion)
    _tbls_completion
    ;;
  coverage)
    _tbls_coverage
    ;;
  diff)
    _tbls_diff
    ;;
  doc)
    _tbls_doc
    ;;
  help)
    _tbls_help
    ;;
  lint)
    _tbls_lint
    ;;
  out)
    _tbls_out
    ;;
  version)
    _tbls_version
    ;;
  *)
    if type _tbls-"$words[1]" 1>/dev/null 2>/dev/null
    then
      _tbls-"$words[1]"
    fi
    ;;
  esac

のように、tblsが持っていないサブコマンド ( "$words[1]" )が選択されたときは _tbls-"$words[1]" (外部コマンド側の補完関数)の存在を確認、存在していればそちらの補完処理を渡すような実装になっています(ここ)。

実際の補完関数は tbls completion bashtbls completion zsh を実行してみてください。

上記の補完関数の実現には、syohexさんやbuty4649さんにbashzshの補完機能についての情報を提供してもらったり、syohexさんに実際に修正PRをもらったりしました。この場を借りてお礼を申し上げます。ありがとうございました!

というわけで

"外部サブコマンド" という、ニッチな機能実現のお話でした。