goでディレクトリ構成を表示するコマンドラインツールを作ってみた
イントロダクション
ターミナルやコマンドプロンプトから指定したディレクトリの構成を表示またはCSVで出力できるコマンドラインツールを作成します。
経緯
最近ITの会社にプログラマーとして就職することができました。そこで初めてディレクトリ構成図を作り工程管理をすることを知りました。
メンターをしてくれた方はコピペ等駆使してっさっと作っていました。
自分の作ったプロジェクトのディレクトリ構成図を作るのならいいですが、人の作ったプロジェクトを効率よく間違いなく作るのは僕には無理なのでコマンドラインツールを作ることにしました。
私は仕事ではWindowsですが家ではMacを使っているのでクロスコンパイルができるGoで作成することにします。
Excelでディレクトリ構成図を作成するのですが汎用性を持たせるためにcsvで出力するようにします。
ネットを探すと同じツールはあるかもしれませんが、作るのが好きなので自分で作ります。
作り方
ディレクトリ構成
.
├── README.md
├── cmd # コマンドライン
│ ├── cmd.go
│ ├── csv.go
│ └── show.go
├── csv # csvファイル作成
│ └── csv.go
├── dirlist # ディレクトリ構成取得
│ └── dirlist.go
├── go.mod
├── go.sum
├── main.go
└── mymath # 整数関数
└── mymath.go
main.go
今回はcobraというコマンドラインツールを作成するためのライブラリを使用します。main.goでは/cmd/cmd.goで定義した物を実行するだけです。
package main
import (
"fmt"
"godeer/cmd"
"os"
)
func main() {
if err := cmd.RootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", os.Args[0], err)
os.Exit(-1)
}
}
mymath
もしかしたら私の検索不足かもしれませんが、goの標準パッケージには整数の最大値を求める関数が用意されていないような感じでした(浮動小数点数の最大値を求める関数は見つけたのですが...)。サードパーティ整をインストールするほどでもないので自分で関数を実装します。
package mymath
// IntMax 引数の内大きい方の値を返す。
func IntMax(a, b int) int {
if a > b {
return a
}
return b
}
cmd
ここではcobraパッケージを使用してコマンドをそれぞれ実装しています。
今回実装が必要なのは
- コマンド本体であるルートコマンド
- csvを出力するcsvコマンド
- ディレクトリ構造を表示するshowコマンド
です。
ルートコマンド
今回のコマンドラインツールの名前は"godeer"にします。
- 各種コマンド(csv, show)、フラグの初期化
- ルートコマンド(godeer)の定義
を行っています。
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
func init() {
cobra.OnInitialize()
RootCmd.AddCommand(
showCmd(),
csvCmd(),
)
RootCmd.PersistentFlags().IntP(
"nest",
"n",
5,
"Specify the depth of the directory",
)
RootCmd.PersistentFlags().StringP(
"char",
"c",
"utf-8",
"Select charcter code(utf-8, shift-jis)",
)
}
// RootCmd ルートコマンド
var RootCmd = &cobra.Command{
Use: "godeer",
Short: "directory structure show and output csv",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("godeer")
},
}
csvコマンド
csvを出力する際のコマンドを実装しています。
- 変数の初期化
- 引数、フラグの取得
- ディレクトリ構造の取得
- 出力
という流れで処理をしています。
package cmd
import (
"fmt"
"godeer/csv"
"godeer/dirlist"
"godeer/mymath"
"github.com/spf13/cobra"
)
func csvCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "csv",
Short: "dir structure output csv file. arg1: dirpath, arg2: savepath",
Args: cobra.RangeArgs(2, 2),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
/* 初期化 */
var err error
var pathString string
var nest int
var savePath string
var pathArray []dirlist.DirStruct
var header []string
var pathLen int
var flg string
/* 引数の取得 */
pathString = args[0]
savePath = args[1]
nest, err = cmd.Flags().GetInt("nest")
if err != nil {
return err
}
flg, err = cmd.Flags().GetString("char")
if err != nil {
return err
}
/* パス配列 */
// 取得
tempArray, err := dirlist.GetDirArray(pathString, nest, flg)
if err != nil {
fmt.Println(err)
return nil
}
// 整形
for _, path := range tempArray {
// 一番深いディレクトリの深さを取得
pathLen = mymath.IntMax(pathLen, len(path.Dir))
}
for _, path := range tempArray {
dl := len(path.Dir)
tempDir := path.Dir
// 深いディレクトリに合わせて空文字を配列に追加
if dl < pathLen {
for i := 0; i < pathLen-dl; i++ {
tempDir = append(tempDir, "")
}
}
// 整形したディレクトリ構造をpathArrayに渡す
pathArray = append(pathArray, dirlist.DirStruct{
Dir: tempDir,
File: path.File,
})
}
// headerの作成
for i := range pathArray[0].Dir {
col := fmt.Sprintf("第%d階層", i+1)
header = append(header, col)
}
header = append(header, "ファイル名")
// 文字コードの変換
var encodeHeader []string
switch flg {
case "shift-jis":
encodeHeader = dirlist.UtoSj(header)
default:
encodeHeader = header
}
/* 出力 */
err = csv.Write(savePath, encodeHeader, pathArray)
if err != nil {
return err
}
return nil
},
}
return cmd
}
showコマンド
ディレクトリ構造をコマンドラインに出力する際のコマンドを実装しています。
- 変数の初期化
- 引数、フラグの取得
- ディレクトリ構造の取得
- 画面出力
という流れで処理をしています。画面出力のための処理が入っただけでcsvと処理はさほど変わりありません。
package cmd
import (
"fmt"
"godeer/dirlist"
"godeer/mymath"
"github.com/spf13/cobra"
)
func showCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "show",
Short: "show dir structure on array style. arg1: dirpath",
Args: cobra.RangeArgs(1, 1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
/* 初期化 */
var pathString string
var nest int
var flg string
var err error
var pathArray []dirlist.DirStruct
var pathLen int
/* 引数の取得 */
pathString = args[0]
nest, err = cmd.Flags().GetInt("nest")
if err != nil {
return err
}
flg, err = cmd.Flags().GetString("char")
if err != nil {
return err
}
/* パス配列 */
// 取得
tempArray, err := dirlist.GetDirArray(pathString, nest, flg)
if err != nil {
fmt.Println(err)
return nil
}
// 整形
for _, path := range tempArray {
// 一番深いディレクトリの深さを取得
pathLen = mymath.IntMax(pathLen, len(path.Dir))
}
for _, path := range tempArray {
dl := len(path.Dir)
tempDir := path.Dir
// 深いディレクトリに合わせて空文字を配列に追加
if dl < pathLen {
for i := 0; i < pathLen-dl; i++ {
tempDir = append(tempDir, "")
}
}
// 整形したディレクトリ構造をpathArrayに渡す
pathArray = append(pathArray, dirlist.DirStruct{
Dir: tempDir,
File: path.File,
})
}
/* 画面出力 */
for _, path := range pathArray {
// 文字コードの変換
var tempDir []string
switch flg {
case "utf-8":
tempDir = path.Dir
case "shift-jis":
tempDir = dirlist.UtoSj(path.Dir)
default:
tempDir = path.Dir
}
deep := 1
// ディレクトリの表示
for _, dir := range tempDir {
fmt.Printf("第%d階層: %10s, ", deep, dir)
deep++
}
// ファイルの表示
fmt.Printf("ファイル: %s \n", path.File)
}
return nil
},
}
return cmd
}
csv
ここでは引数で取得したパスとヘッダーデータ、ボディデータを元にcsvファイルを出力する実装をしています。
これは/cmd/csv.goで読み込まれ実行されます。
package csv
import (
"encoding/csv"
"godeer/dirlist"
"os"
)
// Write pathに指定したファイルにdataを書き込んで出力
func Write(path string, header []string, data []dirlist.DirStruct) error {
/* 初期処理 */
var err error
var file *os.File
/* ファイルを開く */
file, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return err
}
/* 処理終了後ファイルを閉じる */
defer file.Close()
/* ファイルを空にする */
err = file.Truncate(0)
if err != nil {
return err
}
/* データの書き込み */
// ライターの作成
writer := csv.NewWriter(file)
// ライターバッファ
err = writer.Write(header)
if err != nil {
return err
}
for _, dirpath := range data {
pathArray := dirpath.Dir
pathArray = append(pathArray, dirpath.File)
err = writer.Write(pathArray)
if err != nil {
return err
}
}
// 書き込み
writer.Flush()
return nil
}
dirlist
ここが本題のディレクトリ構造を解析する実装になります。それぞれ関数で切り分けているので関数ごとに説明していきます。
GetDirArray
これが他のパッケージから一番呼ばれることになる関数です。処理としては
- 相対パスを取得
- 文字コードを変換
- 相対パスをディレクトリ・ファイルごとに分けて配列にする
という処理を行っています。
// GetDirArray 引数で指定されたパスのディレクトリ構造を配列で返す。第三引数は文字コードの指定
// 例)
// 引数:. 3 "utf-8" 返り値:[dir dir_a][dir dir_b test.txt][dir dir_b test2.txt]
func GetDirArray(dir string, nest int, char string) ([]DirStruct, error) {
paths, err := dirwalk(dir, nest)
if err != nil {
return nil, err
}
// 文字コードの指定
var encodePaths []string
switch char {
case "utf-8":
encodePaths = paths
case "shift-jis":
encodePaths = UtoSj(paths)
default:
encodePaths = paths
}
pathArray := pathSeparator(encodePaths)
return pathArray, nil
}
dirwalk
指定されたディレクトリの指定された深さまでのディレクトリ・ファイルのパスを返す関数です。
// dirwalk: パスで指定されたディレクトリ内の構造を配列として返す。
func dirwalk(dir string, nest int) ([]string, error) {
var paths []string
// ディレクトリ情報の取得
files, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}
if nest == 0 {
// 指定の深さまで達したら現在までのパスを返す。
paths = append(paths, dir)
return paths, nil
}
// 指定の深さに達してなければさらに探索する。
for _, file := range files {
// ファイルパス文字列の作成
filePath := filepath.Join(dir, file.Name())
if file.IsDir() && hasChild(filePath) {
temp, _ := dirwalk(filePath, nest-1)
paths = append(paths, temp...)
continue
}
paths = append(paths, filePath)
}
return paths, nil
}
pathSeparator
dirwalk()で取得できるのは相対パスの文字列なのでそれをセパレーターごとに分けて配列で返してくれる関数です。
// pathSeparator: パス文字列をセパレーターごとに分けて配列で返す。
// 例)dir/dir_b/test.txt => [dir dir_b test.txt]
func pathSeparator(paths []string) []DirStruct {
var sepPaths []DirStruct
separator := string(filepath.Separator)
for _, path := range paths {
// ディレクトリとファイルを分離
var dir, file string
if filepath.Ext(path) != "" {
// ファイルを指定するパスならディレクトリパスとファイル名に分ける
dir, file = filepath.Split(path)
} else {
// ディレクトリを指定しているパスならfileは空文字
dir = path
file = ""
}
// DirStructに格納
sepDir := strings.Split(dir, separator)
sepPath := DirStruct{
sepDir,
file,
}
sepPaths = append(sepPaths, sepPath)
}
return sepPaths
}
UtoSj
これは文字コードをgo標準のutf-8からwindowsで扱うshift-jisに変換する関数です。
機能から考えて別のパッケージにするべきだと思いますが、めんどくさかったのでdirlistの中で実装しています。
// UtoSj utf-8 => shift-JISに文字コードを変換
func UtoSj(strArray []string) []string {
var results []string
for _, str := range strArray {
sjStr, _, _ := transform.String(japanese.ShiftJIS.NewEncoder(), str)
results = append(results, sjStr)
}
return results
}
hasChild
上述のdirwalkはネットから拾ってきたコードを改変した物ですが、そのままだとファイルを持たないディレクトリを含めることができなかったのでディレクトリであれファイルであれ小要素をもつディレクトリを含めるためにそれを判定する関数が必要でした。
それがこの関数です。
// hasChild 引数で指定されたディレクトリがディレクトリまたはファイルと言った子要素を持つかどうかを判定する
// 子要素を持つ場合はtrue, そうでない場合はfalseを返す。
func hasChild(path string) bool {
files, err := ioutil.ReadDir(path)
if err != nil {
panic(err)
}
if len(files) == 0 {
return false
}
return true
}
ビルド
windows用にビルドするためには以下のコマンドを使います。
$ GOOS=windows GOARCH=386 go build -o godeer.exe
これでwindows用コマンドラインツールができたのでwindowsで実行するだけです。Macの場合は以下のようにそのままビルドしてあげればいいです
$ go build
または私の場合/go/bin/にパスを通しているので
$ go install
ですぐにコマンドラインツールとして使うことができます。
使ってみる
Macでの実行結果を載せておきます。
$ godeer --help +[main]
directory structure show and output csv
Usage:
godeer [flags]
godeer [command]
Available Commands:
csv dir structure output csv file. arg1: dirpath, arg2: savepath
help Help about any command
show show dir structure on array style. arg1: dirpath
Flags:
-c, --char string Select charcter code(utf-8, shift-jis) (default "utf-8")
-h, --help help for godeer
-n, --nest int Specify the depth of the directory (default 5)
Use "godeer [command] --help" for more information about a command.
$ godeer show . -n 3 +[main]
第1階層: , 第2階層: , 第3階層: , ファイル: .DS_Store
第1階層: .git, 第2階層: COMMIT_EDITMSG, 第3階層: , ファイル:
# 省略
第1階層: godeer, 第2階層: , 第3階層: , ファイル:
第1階層: , 第2階層: , 第3階層: , ファイル: godeer.exe
第1階層: , 第2階層: , 第3階層: , ファイル: main.go
第1階層: mymath, 第2階層: , 第3階層: , ファイル: mymath.go
もちろんcsvも出力できますし、windowsでも同じように使えました。
感想
思ってた以上に簡単に作ることができました。何よりMacでwindowsも考慮した形で作っていて気にかけなければいけない部分が少なくてすむのはありがたいです。
今後もツールが必要になった場合はgoを使って作ってみようと思います。
Discussion