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
を使って実装されています
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
でコマンドラインの入力を定義されたフラグに解析します
flag.Usage = usage
flag.Parse()
解析するフラグとusage
はmain()
の外で定義されています
フラグは-min_confidence
と-set_exit_status
2種類のみで、コマンドラインの入力にオプションがあった場合、minConfidence
、setExitStatus
にポインタが格納されます
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()
は定義されているフラグの説明を表示してくれます
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 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()
でとって条件分岐させてます
if flag.NArg() == 0 {
lintDir(".")
}
引数がある場合、引数解析用の変数を定義します
golintはディレクトリ指定、ファイル指定、パッケージ指定の3つのモードがあります
dirsRun
, filesRun
, pkgsRun
はモード指定のための変数です
args
はコマンドライン引数を入れるスライスです
var dirsRun, filesRun, pkgsRun int
var args []string
flag.Args()
で引数のリストをスライスで取得し、解析ループを回します
引数が以下の4つのケースのどれに当てはまるのかをみます
- 引数の末尾に
/...
が付いている場合 - 引数がディレクトリの場合
- 引数がファイルの場合
- 1,2,3どれにも当てはまらない場合
1,2の場合はディレクトリ指定モード、3の場合はファイル指定モード、4の場合はパッケージ指定モードに入ります
分岐先で、dirsRun || filesRun || pkgsRun
に1を代入してます
解析した引数はargs
にappend
します
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()
をラップしてますね
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
}
まず"..."
を取り除き、
i := strings.Index(pattern, "...")
dir, _ := path.Split(pattern[:i])
"./"
先頭にが付いている場合、prefix
として保存します
prefix := ""
if strings.HasPrefix(pattern, "./") {
prefix = "./"
}
matchPattern()
で文字列が一致するか判定するfunc
を生成します
正規表現で一致判定する関数を返してますね
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できるか確認
↓
一致判定
といった一連の流れを関数にして渡しています
// ディレクトリ以外を除外
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()
に戻ります
コマンドライン引数にディレクトリとファイルが混在している場合はエラーにします
if dirsRun+filesRun+pkgsRun != 1 {
usage()
os.Exit(2)
}
静的解析
args
を静的解析関数に渡します
ディレクトリ指定、ファイル指定、パッケージ指定それぞれ解析関数があります
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