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

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

このコミットは、Go言語の text/scanner パッケージにおいて、入力ソースの先頭にUTF-8バイトオーダーマーク (BOM) が存在する場合に、そのBOMをスキップするように変更を加えるものです。これにより、BOM付きのUTF-8ファイルが正しく処理されるようになります。

コミット

  • コミットハッシュ: d4cdfcf3d99e357b22e4098ae8dfbb04be02fd5d
  • 作者: Robert Griesemer gri@golang.org
  • コミット日時: 2012年9月7日 金曜日 17:15:42 -0700

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

https://github.com/golang/go/commit/d4cdfcf3d99e357b22e4098ae8dfbb04be02fd5d

元コミット内容

text/scanner: skip first character if it's a BOM

R=r
CC=golang-dev
https://golang.org/cl/6493097

変更の背景

text/scanner パッケージは、UTF-8エンコードされたテキストをスキャンし、トークン化するためのGo言語の標準ライブラリの一部です。多くのテキストエディタやシステムでは、UTF-8エンコードされたファイルに、そのエンコーディングを示すためにバイトオーダーマーク (BOM) をファイルの先頭に付加する場合があります。UTF-8のBOMは EF BB BF というバイト列で表現され、Unicode文字 U+FEFF に対応します。

しかし、UTF-8の仕様ではBOMは必須ではなく、むしろ推奨されていません。BOMが存在すると、それを認識しないパーサーやツールでは、ファイルの先頭に意図しない文字(U+FEFF)が挿入されたと解釈され、構文エラーや予期せぬ動作を引き起こす可能性があります。

このコミットの背景には、text/scanner がBOM付きのUTF-8ファイルを処理する際に、このBOMを正しく無視し、実際のテキストコンテンツからスキャンを開始できるようにする必要があったという問題意識があります。これにより、text/scanner を利用するアプリケーションが、BOMの有無にかかわらずUTF-8ファイルを透過的に扱えるようになり、互換性と堅牢性が向上します。

前提知識の解説

バイトオーダーマーク (BOM)

バイトオーダーマーク (BOM) は、Unicodeテキストファイルの先頭に置かれる特別なバイト列で、そのファイルがどのUnicodeエンコーディング(UTF-8, UTF-16, UTF-32など)を使用しているか、またUTF-16やUTF-32の場合はバイトオーダー(ビッグエンディアンかリトルエンディアンか)を示すために使用されます。

  • UTF-8におけるBOM: UTF-8の場合、BOMは EF BB BF という3バイトのシーケンスです。これはUnicodeのゼロ幅ノーブレークスペース (Zero Width No-Break Space, U+FEFF) のUTF-8エンコーディングと一致します。UTF-8はバイトオーダーの概念を持たないため、UTF-8におけるBOMはバイトオーダーを示す目的ではなく、単に「このファイルはUTF-8でエンコードされている」というシグネチャとして機能します。しかし、多くのUnix系システムやプログラミング言語のパーサーはBOMを期待せず、これを通常の文字として扱ってしまうため、問題を引き起こすことがあります。

text/scanner パッケージ

Go言語の text/scanner パッケージは、Goの字句解析器(lexer)の基盤となる機能を提供します。これは、ソースコードやその他のテキストデータを、識別子、キーワード、数値、文字列、コメントなどの「トークン」に分割するために使用されます。

  • Scanner 構造体: スキャン処理の状態を保持します。
  • Init メソッド: スキャナーを初期化し、入力ソース (io.Reader) を設定します。
  • Scan メソッド: 次のトークンを読み込み、その種類と値を返します。
  • Next メソッド: 次のUnicode文字を読み込み、内部バッファを進めます。
  • Peek メソッド: 次に読み込まれるUnicode文字を、実際に読み進めることなく「覗き見」します。これは、次の文字に基づいて現在のトークンの種類を判断する際などに利用されます。

このコミットでは、特に Peek メソッドがBOMの処理に関与します。Peek は通常、スキャナーがまだ何も読み込んでいない最初の状態でのみ、実際に next() を呼び出して最初の文字を読み込みます。このタイミングでBOMを検出してスキップすることで、後続のスキャン処理に影響を与えないようにします。

技術的詳細

この変更の核心は、text/scanner パッケージの Scanner 構造体の Peek() メソッドにBOMをスキップするロジックを追加したことです。

変更前、Peek() メソッドは、s.ch < 0 (つまり、まだ文字が読み込まれていない初期状態) の場合に s.next() を呼び出して次のUnicode文字を読み込み、それを s.ch に格納していました。

変更後、このロジックに以下の条件分岐が追加されました。

  1. s.ch < 0 の場合、つまりスキャナーがまだ初期状態である場合のみ、このBOMチェックが実行されます。
  2. s.ch = s.next() を呼び出して最初の文字を読み込みます。
  3. 読み込んだ文字が '\uFEFF' (Unicode BOM) であるかどうかをチェックします。
  4. もし '\uFEFF' であった場合、s.ch = s.next() を再度呼び出し、BOMの次の文字を読み込みます。これにより、BOMは実質的に無視され、スキャナーの内部状態はBOMの次の文字から始まることになります。

このアプローチの利点は以下の通りです。

  • 効率性: BOMチェックはスキャナーが最初に文字を読み込むとき、つまり Peek() が一度だけ呼び出されるときにのみ行われます。これにより、通常のテキスト処理のパフォーマンスに影響を与えません。
  • 正確性: BOMはファイルの先頭にのみ出現するべきであるため、この「最初の文字のみ」というチェックはBOMの性質に合致しています。ファイル途中に U+FEFF が出現した場合は、それは通常の文字として扱われます。
  • 透過性: text/scanner のユーザーは、入力ソースにBOMが含まれているかどうかを意識する必要がなくなります。スキャナーが自動的にBOMを処理するため、BOMの有無にかかわらず同じコードでテキストをスキャンできます。

また、scanner.go のパッケージコメントも更新され、BOMが検出された場合に破棄されることが明記されました。

テストファイル scanner_test.go には、TestScanNext 関数内に新しいテストケースが追加されました。このテストケースでは、BOMを含む文字列を Scanner に渡し、最初のBOMが正しく無視され、その後の if トークンが正しく認識されることを検証しています。さらに、文字列の途中に挿入されたBOM (BOMs 変数で表現される \uFEFF) が通常の文字として扱われることも確認されています。これは、BOMがファイルの先頭でのみ特別な意味を持つという実装の意図を反映しています。

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

src/pkg/text/scanner/scanner.go

--- a/src/pkg/text/scanner/scanner.go
+++ b/src/pkg/text/scanner/scanner.go
@@ -5,7 +5,8 @@
 // Package scanner provides a scanner and tokenizer for UTF-8-encoded text.
 // It takes an io.Reader providing the source, which then can be tokenized
 // through repeated calls to the Scan function.  For compatibility with
-// existing tools, the NUL character is not allowed.
+// existing tools, the NUL character is not allowed. If the first character
+// in the source is a UTF-8 encoded byte order mark (BOM), it is discarded.
 //
 // By default, a Scanner skips white space and Go comments and recognizes all
 // literals as defined by the Go language specification.  It may be
@@ -208,11 +209,6 @@ func (s *Scanner) Init(src io.Reader) *Scanner {\n \treturn s\n }\n \n-// TODO(gri): The code for next() and the internal scanner state could benefit\n-//            from a rethink. While next() is optimized for the common ASCII\n-//            case, the "corrections" needed for proper position tracking undo\n-//            some of the attempts for fast-path optimization.\n-\n // next reads and returns the next Unicode character. It is designed such\n // that only a minimal amount of work needs to be done in the common ASCII\n // case (one test to check for both ASCII and end-of-buffer, and one test\n@@ -316,7 +312,11 @@ func (s *Scanner) Next() rune {\n // character of the source.\n func (s *Scanner) Peek() rune {\n \tif s.ch < 0 {\n+\t\t// this code is only run for the very first character\n \t\ts.ch = s.next()\n+\t\tif s.ch == '\uFEFF' {\n+\t\t\ts.ch = s.next() // ignore BOM\n+\t\t}\n \t}\n \treturn s.ch\n }\

src/pkg/text/scanner/scanner_test.go

--- a/src/pkg/text/scanner/scanner_test.go
+++ b/src/pkg/text/scanner/scanner_test.go
@@ -358,8 +358,10 @@ func TestScanSelectedMask(t *testing.T) {\n }\n \n func TestScanNext(t *testing.T) {\n-\ts := new(Scanner).Init(bytes.NewBufferString("if a == bcd /* comment */ {\\n\\ta += c\\n} // line comment ending in eof"))\n-\tcheckTok(t, s, 1, s.Scan(), Ident, "if")\n+\tconst BOM = '\uFEFF'\n+\tBOMs := string(BOM)\n+\ts := new(Scanner).Init(bytes.NewBufferString(BOMs + "if a == bcd /* com" + BOMs + "ment */ {\\n\\ta += c\\n}" + BOMs + "// line comment ending in eof"))\n+\tcheckTok(t, s, 1, s.Scan(), Ident, "if") // the first BOM is ignored\n \tcheckTok(t, s, 1, s.Scan(), Ident, "a")\n \tcheckTok(t, s, 1, s.Scan(), '=', "=")\n \tcheckTok(t, s, 0, s.Next(), '=', "")\n@@ -372,6 +374,7 @@ func TestScanNext(t *testing.T) {\n \tcheckTok(t, s, 0, s.Next(), '=', "")\n \tcheckTok(t, s, 2, s.Scan(), Ident, "c")\n \tcheckTok(t, s, 3, s.Scan(), '}', "}")\n+\tcheckTok(t, s, 3, s.Scan(), BOM, BOMs)\n \tcheckTok(t, s, 3, s.Scan(), -1, "")\n \tif s.ErrorCount != 0 {\n \t\tt.Errorf("%d errors", s.ErrorCount)\n```

## コアとなるコードの解説

### `src/pkg/text/scanner/scanner.go` の変更

-   **パッケージコメントの更新**:
    ```go
    // existing tools, the NUL character is not allowed. If the first character
    // in the source is a UTF-8 encoded byte order mark (BOM), it is discarded.
    ```
    `text/scanner` パッケージの冒頭のコメントが更新され、UTF-8 BOMがソースの最初の文字である場合に破棄されることが明示されました。これは、このパッケージの新しい振る舞いを文書化する重要な変更です。

-   **`Peek()` メソッドの変更**:
    ```go
    func (s *Scanner) Peek() rune {
    	if s.ch < 0 {
    		// this code is only run for the very first character
    		s.ch = s.next()
    		if s.ch == '\uFEFF' {
    			s.ch = s.next() // ignore BOM
    		}
    	}
    	return s.ch
    }
    ```
    これがこのコミットの主要な機能変更です。
    -   `if s.ch < 0` の条件は、`Scanner` がまだ初期化されておらず、最初の文字を読み込んでいない状態であることを示します。この条件が真の場合にのみ、以下のBOMチェックロジックが実行されます。
    -   `s.ch = s.next()`: まず、入力ストリームから最初のUnicode文字を読み込み、`s.ch` に格納します。
    -   `if s.ch == '\uFEFF'`: 読み込んだ文字がUnicodeのBOM文字 (`U+FEFF`) であるかをチェックします。
    -   `s.ch = s.next() // ignore BOM`: もしBOM文字であった場合、`s.next()` を再度呼び出してBOMの次の文字を読み込み、それを `s.ch` に格納します。これにより、BOM文字は実質的にスキップされ、スキャナーはBOMの次の有効な文字から処理を開始します。コメント `// ignore BOM` はこの意図を明確に示しています。
    -   このロジックは、`Peek()` がスキャナーのライフサイクルで最初に呼び出されるときにのみ実行されるため、効率的です。

-   **TODOコメントの削除**:
    ```diff
    - // TODO(gri): The code for next() and the internal scanner state could benefit
    - //            from a rethink. While next() is optimized for the common ASCII
    - //            case, the "corrections" needed for proper position tracking undo
    - //            some of the attempts for fast-path optimization.
    ```
    以前のTODOコメントが削除されました。これは、`next()` メソッドと内部スキャナーの状態に関する再考の必要性が、このBOM処理の変更によって解消されたか、あるいは別の方法で対処されたことを示唆している可能性があります。

### `src/pkg/text/scanner/scanner_test.go` の変更

-   **BOM定数の定義**:
    ```go
    	const BOM = '\uFEFF'
    	BOMs := string(BOM)
    ```
    テスト内でBOM文字 (`U+FEFF`) を表す定数 `BOM` と、その文字列表現 `BOMs` が定義されました。これにより、テストコードの可読性が向上します。

-   **テスト文字列の変更**:
    ```diff
    -	s := new(Scanner).Init(bytes.NewBufferString("if a == bcd /* comment */ {\\n\\ta += c\\n} // line comment ending in eof"))
    -	checkTok(t, s, 1, s.Scan(), Ident, "if")
    +	s := new(Scanner).Init(bytes.NewBufferString(BOMs + "if a == bcd /* com" + BOMs + "ment */ {\\n\\ta += c\\n}" + BOMs + "// line comment ending in eof"))
    +	checkTok(t, s, 1, s.Scan(), Ident, "if") // the first BOM is ignored
    ```
    `TestScanNext` 関数内で使用されるテスト文字列が変更されました。
    -   文字列の先頭に `BOMs` (BOM文字) が追加されました。
    -   コメントの途中 (`/* com` と `ment */` の間) にも `BOMs` が挿入されました。
    -   行コメントの末尾にも `BOMs` が挿入されました。
    -   最初の `checkTok` のコメントが `// the first BOM is ignored` に更新され、テストの意図が明確になりました。

-   **追加の `checkTok` 呼び出し**:
    ```diff
    +	checkTok(t, s, 3, s.Scan(), BOM, BOMs)
    ```
    テストの最後に、文字列の途中に挿入されたBOMが、通常の文字としてスキャンされることを検証する `checkTok` 呼び出しが追加されました。これは、`Peek()` メソッドのBOMスキップロジックが「最初の文字のみ」に適用されることを確認するための重要なテストです。もし途中のBOMもスキップされてしまうと、このテストは失敗します。

これらの変更により、`text/scanner` はBOM付きのUTF-8ファイルをより堅牢に処理できるようになり、同時にその振る舞いがテストによって保証されるようになりました。

## 関連リンク

-   Go CL 6493097: [https://golang.org/cl/6493097](https://golang.org/cl/6493097)

## 参考にした情報源リンク

-   バイトオーダーマーク - Wikipedia: [https://ja.wikipedia.org/wiki/%E3%83%90%E3%82%A4%E3%83%88%E3%82%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%83%9E%E3%83%BC%E3%82%AF](https://ja.wikipedia.org/wiki/%E3%83%90%E3%82%A4%E3%83%88%E3%82%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%83%9E%E3%83%BC%E3%82%AF)
-   UTF-8 with BOM - Stack Overflow: [https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-with-bom](https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-with-bom)
-   GoDoc text/scanner: [https://pkg.go.dev/text/scanner](https://pkg.go.dev/text/scanner)