😺

goでディレクトリ構成を表示するコマンドラインツールを作ってみた

2020/12/14に公開

イントロダクション

ターミナルやコマンドプロンプトから指定したディレクトリの構成を表示または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で定義した物を実行するだけです。

main.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の標準パッケージには整数の最大値を求める関数が用意されていないような感じでした(浮動小数点数の最大値を求める関数は見つけたのですが...)。サードパーティ整をインストールするほどでもないので自分で関数を実装します。

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)の定義

を行っています。

cmd.go
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を出力する際のコマンドを実装しています。

  • 変数の初期化
  • 引数、フラグの取得
  • ディレクトリ構造の取得
  • 出力

という流れで処理をしています。

csv.go
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と処理はさほど変わりありません。

show.go
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で読み込まれ実行されます。

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