LoginSignup
1
0

More than 3 years have passed since last update.

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

Posted at

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を使って実装されています

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でコマンドラインの入力を定義されたフラグに解析します

flag.Usage = usage
flag.Parse()

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

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出力
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. 引数がファイルの場合
  4. 1,2,3どれにも当てはまらない場合

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

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()をラップしてますね

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
}

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

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)

pkgsmain()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)
    }
}

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

まとめ

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

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

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0