Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 13517] ファイルの概要

このコミットは、Go言語のVimプラグインに関連する以下の2つのファイルを変更しています。

  • misc/vim/ftplugin/go/import.vim: Go言語のソースコードにおけるインポート文の管理を行うVimスクリプトです。具体的には、:Importコマンドなどを提供し、新しいパッケージのインポートを自動的に追加・整理する機能を持っています。
  • misc/vim/ftplugin/go/test.sh: 上記import.vimスクリプトの動作を検証するための新しいシェルスクリプト形式のテストファイルです。

コミット

  • コミットハッシュ: 32944049007e1ddaac2138a9e6a018ee412c84be
  • 作者: David Symonds dsymonds@golang.org
  • 日付: Mon Jul 30 08:48:51 2012 +1000
  • コミットメッセージ: misc/vim: fix :Import insertion heuristic.

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/32944049007e1ddaac2138a9e6a018ee412c84be

元コミット内容

misc/vim: fix :Import insertion heuristic.

If a factored import group has a blank line, assume it is dividing
separate groups of imports (e.g. standard library vs. site-specific).
        import (
                "bytes"
                "io"

                "mycorp/package"
        )

The most common case is inserting new standard library imports,
which are usually (stylistically) the first group, so we should drop
"net" in the above example immediately after "io".

Since this logic is getting non-trivial, add a test.

R=golang-dev, minux.ma, franciscossouza
CC=golang-dev
https://golang.org/cl/6445043

変更の背景

Go言語のVimプラグインであるimport.vimは、Goソースコード内のインポート文を効率的に管理するための機能を提供しています。特に、複数のパッケージをまとめてインポートする「ファクタリングされたインポートグループ」(import (...)形式)において、新しいインポートを追加する際の挿入位置を決定するロジック(ヒューリスティック)に問題がありました。

Goのコーディングスタイルでは、gofmtツールなどによって、標準ライブラリのインポートとサードパーティ製またはプロジェクト固有のインポートの間には空行を挿入してグループ分けすることが一般的です。例えば、以下のような形式です。

import (
    "bytes"
    "io" // 標準ライブラリ

    "mycorp/package" // サードパーティ/プロジェクト固有
)

このコミット以前のimport.vimは、このような空行を適切に解釈せず、新しい標準ライブラリのインポート(例: "net")を追加しようとした際に、本来挿入されるべき標準ライブラリグループ内(例: "io"の直後)ではなく、空行を越えて"mycorp/package"のグループに挿入されてしまう、あるいは全く異なる不適切な位置に挿入されてしまうといった問題が発生していました。

この変更は、Goの標準的なコーディングスタイル、特にインポートのグループ分けの慣習に合わせたインポート挿入の挙動を修正し、より直感的で正しい位置にインポートが追加されるようにすることを目的としています。また、このインポート挿入ロジックが複雑化してきたため、回帰テストを追加することで将来的なバグを防ぎ、コードの堅牢性を高める意図もあります。

前提知識の解説

Go言語のインポート

Go言語では、他のパッケージで定義された関数、変数、型などを利用するためにimport文を使用します。インポート文には主に以下の2つの形式があります。

  • 単一インポート: import "fmt" のように、1つのパッケージをインポートします。
  • ファクタリングされたインポート(グループインポート):
    import (
        "fmt"
        "net/http"
        "os"
    )
    
    のように、複数のパッケージを括弧で囲んでまとめてインポートします。この形式は、複数のパッケージをインポートする際に推奨され、コードの可読性を高めます。

Go言語のインポートの慣習とgofmt

Goコミュニティでは、gofmtという公式のフォーマッタツールが広く利用されています。gofmtは、Goソースコードのフォーマットを自動的に整形し、Goの標準的なコーディングスタイルに準拠させます。インポート文に関しても、gofmtは以下の慣習を適用します。

  1. アルファベット順ソート: インポートされるパッケージのパスは、各グループ内でアルファベット順にソートされます。
  2. グループ化: 標準ライブラリのパッケージ、サードパーティのパッケージ(例: github.com/user/repo)、プロジェクト固有のパッケージは、通常、空行で区切られてグループ化されます。このグループ化は、コードの可読性を高め、どのパッケージがどのカテゴリに属するかを一目で理解できるようにするために重要です。

このコミットは、特にこの「空行によるグループ化」の慣習をimport.vimが正しく認識し、新しいインポートを適切なグループに挿入できるようにすることに焦点を当てています。

Vimエディタとftplugin

Vimは、高機能なテキストエディタであり、プログラミングにおいて広く利用されています。ftplugin(filetype plugin)は、Vimの機能の一つで、特定のファイルタイプ(例: Go、Python、JavaScriptなど)を開いたときに自動的にロードされるスクリプトを指します。これにより、ファイルタイプに応じたカスタマイズされた機能(シンタックスハイライト、コード補完、フォーマット、特定のコマンドなど)を提供できます。

このコミットで変更されているmisc/vim/ftplugin/go/import.vimは、Go言語のファイルタイプ(go)に特化したVimプラグインであり、Goのインポート文を効率的に管理するためのカスタムコマンド(例: :Import)を提供しています。

ヒューリスティック

ヒューリスティックとは、厳密な最適解を保証しないが、実用的な時間内で十分な解を見つけるための経験則や発見的手法のことです。このコミットでは、新しいインポートを挿入する「最適な」位置を決定するためのロジックがヒューリスティックとして言及されています。Goのインポートの慣習(アルファベット順、グループ化)を考慮し、どこに新しいインポートを挿入すべきかを推測するアルゴリズムがこれに該当します。

技術的詳細

このコミットの主要な変更は、misc/vim/ftplugin/go/import.vimスクリプト内のs:SwitchImport関数におけるインポート挿入ロジックの改善です。この関数は、Goのインポートパスを引数として受け取り、現在のバッファにそのインポートを追加または更新する役割を担っています。

変更前は、ファクタリングされたインポートグループ内に空行が存在する場合、その空行が異なるインポートグループ(例: 標準ライブラリとサードパーティライブラリ)を区切るものとして適切に認識されていませんでした。そのため、新しい標準ライブラリのインポートを追加しようとした際に、空行を越えて不適切な位置に挿入されてしまう可能性がありました。

変更後のロジックでは、以下の点が改善されています。

  1. サイトプレフィックスの抽出: 新しくインポートしようとしているパス(path)から、github.com/mycorp/のような「サイトプレフィックス」を抽出するロジックが追加されました。これは、サードパーティ製パッケージのグループを識別するために使用されます。

    let siteprefix = matchstr(path, "^[^/]*/")
    
  2. 空行の検出と記録: インポートグループを走査する際に、最初に見つかった空行の行番号をfirstblank変数に記録するようになりました。これは、標準ライブラリのインポートが空行によって区切られたグループのどこに挿入されるべきかを判断するために重要です。

    let firstblank = -1
    " ...
    if empty(m)
        " ...
        if firstblank < 0
            let firstblank = line
        endif
        continue
    endif
    
  3. インポート挿入位置の決定ロジックの強化:

    • 標準ライブラリインポートの優先: もし新しいインポートがサイトプレフィックスを持たない(つまり標準ライブラリである)場合、それは最初のインポートグループ(通常は標準ライブラリのグループ)に属すると見なし、それ以上検索せずに現在の位置でループを抜けるようになりました。これにより、標準ライブラリのインポートが空行を越えて他のグループに挿入されるのを防ぎます。
      if siteprefix == ""
          " must be in the first group
          break
      endif
      
    • 空行後の挿入位置の調整: もし適切な挿入位置が見つからず、かつ空行が検出されていた場合、その空行の行番号を挿入候補位置(appendline)として設定するようになりました。これは、空行の直後に新しいインポートを挿入するケースに対応します。
      if appendline < 0 && firstblank >= 0
          let appendline = firstblank
      endif
      
    • サイトプレフィックスによるグループ内挿入: 既存のインポートパスと新しいインポートのサイトプレフィックスを比較し、同じサイトプレフィックスを持つインポートグループ内に挿入されるべきかを判断するロジックが追加されました。これにより、mycorp/foomycorp/barのような同じ組織のパッケージが適切にグループ化されます。
      if siteprefix == "" || firstblank < 0 || match(m[4], "^" . siteprefix) >= 0
          let appendline = line
      endif
      " ...
      elseif siteprefix != "" && match(m[4], "^" . siteprefix) >= 0
          " first entry of site group
          let appendline = line - 1
          break
      endif
      
      特に、新しいインポートがサイトプレフィックスを持ち、かつ既存のインポートがそのサイトプレフィックスで始まる場合、その既存のインポートの直前(line - 1)に挿入するよう調整し、ループを抜けることで、特定のサイトグループの先頭に新しいインポートを追加するケースに対応しています。

これらの変更により、import.vimはGoのインポート慣習(特に空行によるグループ分け)をより正確に理解し、新しいインポートを適切な位置に挿入できるようになりました。

テストの追加

このコミットでは、misc/vim/ftplugin/go/test.shという新しいテストスクリプトが追加されています。これは、import.vimの変更が正しく機能することを確認するためのものです。

test.shスクリプトは、以下の手順でテストを実行します。

  1. ベースファイルの作成: base.goというテスト用のGoファイルを作成します。このファイルには、標準ライブラリとmycorp/fooというカスタムパッケージを含むファクタリングされたインポートグループが含まれており、標準ライブラリとカスタムパッケージの間には空行があります。これは、Goの典型的なインポート構造を模倣しています。
  2. test_one関数の定義: 新しいインポートパスと期待される正規表現パターンを引数として受け取るヘルパー関数test_oneを定義します。
  3. Vimの実行とインポート: vimコマンドを非対話モードで起動し、import.vimを読み込み、指定された新しいインポートを追加し、結果をtest.goに保存します。
  4. gofmtによる検証: gofmt test.go | cmp test.goを実行し、gofmtによる整形後にファイル内容が変更されていないことを確認します。これは、import.vimがGoのフォーマット規則を壊していないことを保証します。
  5. grepによる内容検証: grep -P -q "(?s)$2" test.goを実行し、期待される正規表現パターンがtest.go内に存在することを確認します。これにより、新しいインポートが正しい位置に挿入されたことを検証します。
  6. 複数のテストケース: 複数のtest_one呼び出しを通じて、様々なシナリオ(標準ライブラリのインポート、サイトプレフィックスを持つインポート、既存のインポートのプレフィックスと一致するインポートなど)をカバーしています。

このテストの追加は、複雑なヒューリスティックの変更に対する回帰テストとして非常に重要であり、将来的な変更による意図しない挙動の発生を防ぐのに役立ちます。

コアとなるコードの変更箇所

misc/vim/ftplugin/go/import.vim の変更

--- a/misc/vim/ftplugin/go/import.vim
+++ b/misc/vim/ftplugin/go/import.vim
@@ -12,7 +12,7 @@
 "       in the current Go buffer, using proper style and ordering.
 "       If {path} is already being imported, an error will be
 "       displayed and the buffer will be untouched.
-" 
+"
 "   :ImportAs {localname} {path}\n"
 "       Same as Import, but uses a custom local name for the package.\n"
 @@ -58,6 +58,12 @@ function! s:SwitchImport(enabled, localname, path)\n         return\n     endif\n \n+    " Extract any site prefix (e.g. github.com/).\n+    " If other imports with the same prefix are grouped separately,\n+    " we will add this new import with them.\n+    " Only up to and including the first slash is used.\n+    let siteprefix = matchstr(path, "^[^/]*/")\n+\n     let qpath = '"' . path . '"'\n     if a:localname != ''\n         let qlocalpath = a:localname . ' ' . qpath\n@@ -83,16 +89,31 @@ function! s:SwitchImport(enabled, localname, path)\n             let appendstr = qlocalpath\n             let indentstr = 1\n             let appendline = line\n+            let firstblank = -1\n+            let lastprefix = ""\n             while line <= line("$")\n                 let line = line + 1\n                 let linestr = getline(line)\n                 let m = matchlist(getline(line), '^\\()\\|\\(\\s\\+\\)\\(\\S*\\s*\\)"\\(.\\+\\)"\\)\')\n                 if empty(m)\n+                    if siteprefix == ""\n+                        " must be in the first group\n+                        break\n+                    endif\n+                    " record this position, but keep looking\n+                    if firstblank < 0\n+                        let firstblank = line\n+                    endif\n                     continue\n                 endif\n                 if m[1] == ')'\n+                    " if there's no match, add it to the first group\n+                    if appendline < 0 && firstblank >= 0\n+                        let appendline = firstblank\n+                    endif\n                     break\n                 endif\n+                let lastprefix = matchstr(m[4], "^[^/]*/")\n                 if a:localname != '' && m[3] != ''\n                     let qlocalpath = printf('%-' . (len(m[3])-1) . 's %s', a:localname, qpath)\n                 endif\n@@ 103,7 +124,16 @@ function! s:SwitchImport(enabled, localname, path)\n                     let deleteline = line\n                     break\n                 elseif m[4] < path\n-                    let appendline = line\n+                    " don't set candidate position if we have a site prefix,\n+                    " we've passed a blank line, and this doesn't share the same\n+                    " site prefix.\n+                    if siteprefix == "" || firstblank < 0 || match(m[4], "^" . siteprefix) >= 0\n+                        let appendline = line\n+                    endif\n+                elseif siteprefix != "" && match(m[4], "^" . siteprefix) >= 0\n+                    " first entry of site group\n+                    let appendline = line - 1\n+                    break\n                 endif\n             endwhile\n             break\n```

### `misc/vim/ftplugin/go/test.sh` の追加

```diff
--- /dev/null
+++ b/misc/vim/ftplugin/go/test.sh
@@ -0,0 +1,61 @@
+#!/bin/bash -e
+#
+# Copyright 2012 The Go Authors. All rights reserved.\n"
+# Use of this source code is governed by a BSD-style\n"
+# license that can be found in the LICENSE file.\n"
+#\n"
+# Tests for import.vim.\n"
+\n"
+cd $(dirname $0)\n"
+\n"
+cat > base.go <<EOF\n"
+package test\n"
+\n"
+import (\n"
+\t"bytes"\n"
+\t"io"\n"
+\t"net"\n"
+\n"
+\t"mycorp/foo"\n"
+)\n"
+EOF\n"
+\n"
+fail=0\n"
+\n"
+# usage: test_one new_import pattern\n"
+# Pattern is a PCRE expression that will match across lines.\n"
+test_one() {\n"
+  echo 2>&1 -n "Import $1: "\n"
+  vim -e -s -u /dev/null -U /dev/null --noplugin -c "source import.vim" \\\n"
+    -c "Import $1" -c 'wq! test.go' base.go\n"
+  # ensure blank lines are treated correctly\n"
+  if ! gofmt test.go | cmp test.go; then\n"
+    echo 2>&1 "gofmt conflict"\n"
+    gofmt test.go | diff -u test.go - | sed "s/^/\\t/" 2>&1\n"
+    fail=1\n"
+    return\n"
+  fi\n"
+  if ! grep -P -q "(?s)$2" test.go; then\n"
+    echo 2>&1 "$2 did not match"\n"
+    cat test.go | sed "s/^/\\t/" 2>&1\n"
+    fail=1\n"
+    return\n"
+  fi\n"
+  echo 2>&1 "ok"\n"
+}\n"
+\n"
+test_one baz '"baz".*"bytes"'\n"
+test_one io/ioutil '"io".*"io/ioutil".*"net"'\n"
+test_one myc '"io".*"myc".*"net"'  # prefix of a site prefix\n"
+test_one nat '"io".*"nat".*"net"'\n"
+test_one net/http '"net".*"net/http".*"mycorp/foo"'\n"
+test_one zoo '"net".*"zoo".*"mycorp/foo"'\n"
+test_one mycorp/bar '"net".*"mycorp/bar".*"mycorp/foo"'\n"
+test_one mycorp/goo '"net".*"mycorp/foo".*"mycorp/goo"'\n"
+\n"
+rm -f base.go test.go\n"
+if [ $fail -gt 0 ]; then\n"
+  echo 2>&1 "FAIL"\n"
+  exit 1\n"
+fi\n"
+echo 2>&1 "PASS"\n"

コアとなるコードの解説

misc/vim/ftplugin/go/import.vim の変更点詳細

s:SwitchImport関数は、Goのインポートパスを引数として受け取り、現在のバッファにそのインポートを追加または更新する役割を担っています。

  1. siteprefix変数の導入:

    let siteprefix = matchstr(path, "^[^/]*/")
    

    新しくインポートしようとしているパス(path)から、最初のスラッシュまでの部分(例: "github.com/""mycorp/")をsiteprefixとして抽出します。これは、サードパーティ製や組織固有のインポートをグループ化する際のキーとなります。

  2. firstblank変数の導入:

    let firstblank = -1
    

    インポートグループを走査するループ内で、最初に見つかった空行の行番号を記録するために使用されます。初期値は-1で、まだ空行が見つかっていないことを示します。

  3. インポート挿入ロジックの修正:

    • 標準ライブラリの優先挿入:

      if empty(m)
          if siteprefix == ""
              " must be in the first group
              break
          endif
          " record this position, but keep looking
          if firstblank < 0
              let firstblank = line
          endif
          continue
      endif
      

      empty(m)は現在の行がインポート文ではない(つまり空行やコメント行など)ことを示します。 もしsiteprefixが空(つまり標準ライブラリのインポート)であれば、そのインポートは最初のグループ(通常は標準ライブラリのグループ)に挿入されるべきであるため、それ以上検索せずにループを抜けます。 siteprefixが空でなく、かつ空行が見つかっていない場合、現在の行をfirstblankとして記録し、検索を続行します。これは、空行がグループの区切りであることを認識し、その後の処理で適切な挿入位置を決定するために重要です。

    • 閉じ括弧)の処理と空行後の挿入:

      if m[1] == ')'
          " if there's no match, add it to the first group
          if appendline < 0 && firstblank >= 0
              let appendline = firstblank
          endif
          break
      endif
      

      インポートグループの閉じ括弧)に到達した場合の処理です。もし適切な挿入位置(appendline)がまだ設定されておらず、かつ空行(firstblank)が検出されていた場合、その空行の行番号をappendlineに設定します。これにより、空行の直後に新しいインポートが挿入されるようになります。

    • 既存インポートとの比較とサイトプレフィックスによるグループ化:

      let lastprefix = matchstr(m[4], "^[^/]*/")
      " ...
      elseif m[4] < path
          " don't set candidate position if we have a site prefix,
          " we've passed a blank line, and this doesn't share the same
          " site prefix.
          if siteprefix == "" || firstblank < 0 || match(m[4], "^" . siteprefix) >= 0
              let appendline = line
          endif
      elseif siteprefix != "" && match(m[4], "^" . siteprefix) >= 0
          " first entry of site group
          let appendline = line - 1
          break
      endif
      

      m[4]は現在の行のインポートパスです。 m[4] < pathは、現在のインポートパスが新しいインポートパスよりも辞書順で前にある場合(つまり、新しいインポートが現在のインポートの後に来るべき場合)の処理です。 ここで重要なのは、siteprefix == "" || firstblank < 0 || match(m[4], "^" . siteprefix) >= 0という条件です。

      • siteprefix == "":新しいインポートが標準ライブラリの場合。
      • firstblank < 0:まだ空行が見つかっていない場合。
      • match(m[4], "^" . siteprefix) >= 0:現在のインポートパスが新しいインポートパスと同じサイトプレフィックスを持つ場合。 これらの条件のいずれかが真であれば、現在の行を挿入候補位置(appendline)として設定します。これにより、標準ライブラリは標準ライブラリグループ内に、同じサイトプレフィックスを持つインポートは同じグループ内に挿入されるようになります。

      elseif siteprefix != "" && match(m[4], "^" . siteprefix) >= 0のブロックは、新しいインポートがサイトプレフィックスを持ち、かつ現在のインポートがそのサイトプレフィックスで始まる場合(つまり、新しいインポートが既存のサイトグループの先頭に挿入されるべき場合)に、現在の行の直前(line - 1)を挿入位置として設定し、ループを抜けます。

これらの変更により、import.vimはGoのインポートのグループ化とソートの慣習をより正確に反映し、新しいインポートを適切な位置に挿入できるようになりました。

misc/vim/ftplugin/go/test.sh の追加詳細

このシェルスクリプトは、import.vimの機能テストを自動化するために作成されました。

  • #!/bin/bash -e: スクリプトがBashで実行され、エラーが発生した場合は即座に終了することを示します。
  • cd $(dirname $0): スクリプトが実行されているディレクトリに移動します。これにより、関連ファイル(import.vimなど)を相対パスで参照できます。
  • cat > base.go <<EOF ... EOF: テストのベースとなるbase.goファイルを作成します。このファイルは、標準ライブラリとカスタムパッケージのインポートが空行で区切られている典型的なGoのインポート構造を模倣しています。
  • test_one() { ... }: テストケースを実行するためのヘルパー関数です。
    • vim -e -s -u /dev/null -U /dev/null --noplugin -c "source import.vim" \ -c "Import $1" -c 'wq! test.go' base.go: Vimを非対話モードで起動し、import.vimを読み込み、Importコマンドで指定されたパッケージ($1)をbase.goにインポートし、結果をtest.goに保存します。
    • gofmt test.go | cmp test.go: gofmtを実行し、その出力とtest.goの内容を比較します。これにより、import.vimによる変更がgofmtのフォーマット規則に違反していないことを確認します。
    • grep -P -q "(?s)$2" test.go: test.go内に期待される正規表現パターン($2)が存在するかどうかをチェックします。これにより、新しいインポートが正しい位置に挿入されたことを検証します。(?s)は、.が改行にもマッチするようにするPCREオプションです。
  • test_one ...: 複数のテストケースが定義されており、様々なインポートシナリオ(標準ライブラリ、サイトプレフィックス、既存のインポートとの相対位置など)をカバーしています。
  • エラーハンドリングと終了コード: fail変数を使用してテストの成否を記録し、最終的にfailが0より大きければスクリプトはエラー終了(exit 1)し、そうでなければ成功終了(echo "PASS")します。

このテストスクリプトは、import.vimの変更が意図した通りに機能し、かつGoの標準的なコーディングスタイルを維持していることを自動的に検証するための堅牢なメカニズムを提供します。

関連リンク

参考にした情報源リンク