golintのcliの実装を読んでみる

Goでのプログラミングに欠かせない静的解析ツールgolintのソースコードリーディングをしてみました

リポジトリ: https://github.com/golang/lint
(2020/3/1 時点)

# ディレクトリ構成
```
.
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── go.mod
├── go.sum
├── golint
│   ├── golint.go
│   ├── import.go
│   └── importcomment.go
├── lint.go
├── lint_test.go
├── misc/
└── testdata/
```

パッケージが、ルートディレクトリ直下の**lint**パッケージとgolint/以下の**main**パッケージの2つに分かれています

- lint.go
静的解析のロジックはここに書かれています

- golint/golint.go
cliツールの実装が書かれています

- golint/import.go
golint.goで使う補助関数が書かれています

- golint/importcomment.go
go バージョン 1.12 以上の場合に追加するコメントが記述されています
build tagの設定で1.12以上の時のみビルド対象になります

- misc/
vimやemacs等のエディター用のスクリプトが入っています

# cli実装部分
golint/golint.goに`main()`があります
`main()`を読むと流れがわかります
コマンドラインの引数やフラグの処理は、標準パッケージの`flag`を使って実装されています

```go:main()
func main() {
	flag.Usage = usage
	flag.Parse()

	if flag.NArg() == 0 {
		lintDir(".")
	} else {
		// dirsRun, filesRun, and pkgsRun indicate whether golint is applied to
		// directory, file or package targets. The distinction affects which
		// checks are run. It is no valid to mix target types.
		var dirsRun, filesRun, pkgsRun int
		var args []string
		for _, arg := range flag.Args() {
			if strings.HasSuffix(arg, "/...") && isDir(arg[:len(arg)-len("/...")]) {
				dirsRun = 1
				for _, dirname := range allPackagesInFS(arg) {
					args = append(args, dirname)
				}
			} else if isDir(arg) {
				dirsRun = 1
				args = append(args, arg)
			} else if exists(arg) {
				filesRun = 1
				args = append(args, arg)
			} else {
				pkgsRun = 1
				args = append(args, arg)
			}
		}

		if dirsRun+filesRun+pkgsRun != 1 {
			usage()
			os.Exit(2)
		}
		switch {
		case dirsRun == 1:
			for _, dir := range args {
				lintDir(dir)
			}
		case filesRun == 1:
			lintFiles(args...)
		case pkgsRun == 1:
			for _, pkg := range importPaths(args) {
				lintPackage(pkg)
			}
		}
	}

	if *setExitStatus && suggestions > 0 {
		fmt.Fprintf(os.Stderr, "Found %d lint suggestions; failing.\n", suggestions)
		os.Exit(1)
	}
}
```

### フラグ定義・解析
`flag.Usage`にhelpのコマンドライン出力関数を格納して、`flag.Parse`でコマンドラインの入力を定義されたフラグに解析します

```go
flag.Usage = usage
flag.Parse()
```

解析するフラグと`usage`は`main()`の外で定義されています
フラグは`-min_confidence`と`-set_exit_status`2種類のみで、コマンドラインの入力にオプションがあった場合、`minConfidence`、`setExitStatus`にポインタが格納されます

```go
var (
	minConfidence = flag.Float64("min_confidence", 0.8, "minimum confidence of a problem to print it")
	setExitStatus = flag.Bool("set_exit_status", false, "set exit status to 1 if any issues are found")
	suggestions   int
)
```

`usage`はgolintの使用方法をstderrで出力する関数です
`flag.PrintDefaults()`は定義されているフラグの説明を表示してくれます

```go
func usage() {
	fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
	fmt.Fprintf(os.Stderr, "\tgolint [flags] # runs on package in current directory\n")
	fmt.Fprintf(os.Stderr, "\tgolint [flags] [packages]\n")
	fmt.Fprintf(os.Stderr, "\tgolint [flags] [directories] # where a '/...' suffix includes all sub-directories\n")
	fmt.Fprintf(os.Stderr, "\tgolint [flags] [files] # all must belong to a single package\n")
	fmt.Fprintf(os.Stderr, "Flags:\n")
	flag.PrintDefaults()
}
```

```:usage出力
Usage of golint:
	golint [flags] # runs on package in current directory
	golint [flags] [packages]
	golint [flags] [directories] # where a '/...' suffix includes all sub-directories
	golint [flags] [files] # all must belong to a single package
Flags:
  -min_confidence float
    	minimum confidence of a problem to print it (default 0.8)
  -set_exit_status
    	set exit status to 1 if any issues are found
```

### コマンドライン引数解析
コマンドラインの引数が無い場合はカレントディレクトリのファイルをlint対象にします
引数の数を`flag.NArg()`でとって条件分岐させてます

```go
if flag.NArg() == 0 {
	lintDir(".")
} 
```

引数がある場合、引数解析用の変数を定義します
golintはディレクトリ指定、ファイル指定、パッケージ指定の3つのモードがあります
`dirsRun`, `filesRun`, `pkgsRun`はモード指定のための変数です
`args`はコマンドライン引数を入れるスライスです

```go
var dirsRun, filesRun, pkgsRun int
var args []string
```

`flag.Args()` で引数のリストをスライスで取得し、解析ループを回します
引数が以下の4つのケースのどれに当てはまるのかをみます

1. 引数の末尾に`/...`が付いている場合
2. 引数がディレクトリの場合
3. 引数がファイルの場合
4. 1,2,3どれにも当てはまらない場合

1,2の場合はディレクトリ指定モード、3の場合はファイル指定モード、4の場合はパッケージ指定モードに入ります
分岐先で、`dirsRun || filesRun || pkgsRun`に1を代入してます
解析した引数は`args`に`append`します

```go
for _, arg := range flag.Args() {
	if strings.HasSuffix(arg, "/...") && isDir(arg[:len(arg)-len("/...")]) {
		dirsRun = 1
		for _, dirname := range allPackagesInFS(arg) {
			args = append(args, dirname)
		}
	} else if isDir(arg) {
		dirsRun = 1
		args = append(args, arg)
	} else if exists(arg) {
		filesRun = 1
		args = append(args, arg)
	} else {
		pkgsRun = 1
		args = append(args, arg)
	}
}
```

ケース1の場合は`allPackagesInFS()`を使って、引数のディレクトリパス内にあるパッケージディレクトリを全てとってきます

`allPackagesInFS()`はgolint/import.goに実装されています
`matchPackagesInFS()`をラップしてますね

```go:import.go
func allPackagesInFS(pattern string) []string {
	pkgs := matchPackagesInFS(pattern)
	if len(pkgs) == 0 {
		fmt.Fprintf(os.Stderr, "warning: %q matched no packages\n", pattern)
	}
	return pkgs
}

func matchPackagesInFS(pattern string) []string {
	// Find directory to begin the scan.
	// Could be smarter but this one optimization
	// is enough for now, since ... is usually at the
	// end of a path.
	i := strings.Index(pattern, "...")
	dir, _ := path.Split(pattern[:i])

	// pattern begins with ./ or ../.
	// path.Clean will discard the ./ but not the ../.
	// We need to preserve the ./ for pattern matching
	// and in the returned import paths.
	prefix := ""
	if strings.HasPrefix(pattern, "./") {
		prefix = "./"
	}
	match := matchPattern(pattern)

	var pkgs []string
	filepath.Walk(dir, func(path string, fi os.FileInfo, err error) error {
		if err != nil || !fi.IsDir() {
			return nil
		}
		if path == dir {
			// filepath.Walk starts at dir and recurses. For the recursive case,
			// the path is the result of filepath.Join, which calls filepath.Clean.
			// The initial case is not Cleaned, though, so we do this explicitly.
			//
			// This converts a path like "./io/" to "io". Without this step, running
			// "cd $GOROOT/src/pkg; go list ./io/..." would incorrectly skip the io
			// package, because prepending the prefix "./" to the unclean path would
			// result in "././io", and match("././io") returns false.
			path = filepath.Clean(path)
		}

		// Avoid .foo, _foo, and testdata directory trees, but do not avoid "." or "..".
		_, elem := filepath.Split(path)
		dot := strings.HasPrefix(elem, ".") && elem != "." && elem != ".."
		if dot || strings.HasPrefix(elem, "_") || elem == "testdata" {
			return filepath.SkipDir
		}

		name := prefix + filepath.ToSlash(path)
		if !match(name) {
			return nil
		}
		if _, err = build.ImportDir(path, 0); err != nil {
			if _, noGo := err.(*build.NoGoError); !noGo {
				log.Print(err)
			}
			return nil
		}
		pkgs = append(pkgs, name)
		return nil
	})
	return pkgs
}
```

まず`"..."`を取り除き、

```go
i := strings.Index(pattern, "...")
dir, _ := path.Split(pattern[:i])
```

`"./"`先頭にが付いている場合、`prefix`として保存します

```go
prefix := ""
if strings.HasPrefix(pattern, "./") {
	prefix = "./"
}
```

`matchPattern()`で文字列が一致するか判定する`func`を生成します
正規表現で一致判定する関数を返してますね

```go
func matchPattern(pattern string) func(name string) bool {
	re := regexp.QuoteMeta(pattern)
	re = strings.Replace(re, `\.\.\.`, `.*`, -1)
	// Special case: foo/... matches foo too.
	if strings.HasSuffix(re, `/.*`) {
		re = re[:len(re)-len(`/.*`)] + `(/.*)?`
	}
	reg := regexp.MustCompile(`^` + re + `$`)
	return func(name string) bool {
		return reg.MatchString(name)
	}
}
```

カレントディレクトリのパス`dir`と判別関数`match`を使って、解析対象ディレクトリのリストアップをします
ファイルを探す処理は、`filepath.Walk()` を使うことで簡単に実装できます
`filepath.Walk()`は、`func(path string, fi os.FileInfo, err error) error`の形式の関数をファイルごとに実行させることが出来ます

ディレクトリ以外を除外
↓
`dir`とpathが同じ場合、pathを綺麗に
↓
`.xxx`、`_xxx`および`testdata`を除外
↓
パッケージとしてimportできるか確認
↓
一致判定

といった一連の流れを関数にして渡しています

```go
// ディレクトリ以外を除外
if err != nil || !fi.IsDir() {
	return nil
}

// `dir`とpathが同じ場合、pathを綺麗に
if path == dir {
	path = filepath.Clean(path)
}

// `.xxx`、`_xxx`および`testdata`を除外
_, elem := filepath.Split(path)
dot := strings.HasPrefix(elem, ".") && elem != "." && elem != ".."
if dot || strings.HasPrefix(elem, "_") || elem == "testdata" {
	return filepath.SkipDir
}

// 一致判定
name := prefix + filepath.ToSlash(path)
if !match(name) {
	return nil
}

// パッケージとしてimportできるか確認
if _, err = build.ImportDir(path, 0); err != nil {
	if _, noGo := err.(*build.NoGoError); !noGo {
		log.Print(err)
	}
	return nil
}
pkgs = append(pkgs, name)
```

`pkgs`が`main()`の`args`に入ります

### 解析モード確認

`main()`に戻ります
コマンドライン引数にディレクトリとファイルが混在している場合はエラーにします

```go
if dirsRun+filesRun+pkgsRun != 1 {
	usage()
	os.Exit(2)
}
```

### 静的解析
`args`を静的解析関数に渡します
ディレクトリ指定、ファイル指定、パッケージ指定それぞれ解析関数があります

```go
switch {
case dirsRun == 1:
	for _, dir := range args {
		lintDir(dir)
	}
case filesRun == 1:
	lintFiles(args...)
case pkgsRun == 1:
	for _, pkg := range importPaths(args) {
		lintPackage(pkg)
	}
}
```

解析結果の出力は解析関数内で行います

# まとめ
思ってたよりコードが少なくて驚きました
`flag`や`path`など、標準パッケージが優秀なのは有り難いですね
次はcliツール自作をやってみたいです

Qiita書きながらのコードリーディングはきっちり理解しようとする気概が出てくるので良いなと思いました
解析部分の記事は気が向いたら書きます

> 参考
https://mattn.kaoriya.net/software/lang/go/20171024130616.htm