[インデックス 13707] ファイルの概要
このコミットは、Go言語の標準ライブラリ text/template
パッケージにおける字句解析器(lexer)と構文解析器(parser)の挙動を根本的に変更し、テンプレート内のスペースの扱いを「意味のあるもの」として再定義するものです。これにより、テンプレート言語の表現力が向上し、将来的な構文拡張の基盤が築かれました。
コミット
commit de13e8dccdc9acccf55ebc1b306a0e83b08d8704
Author: Rob Pike <r@golang.org>
Date: Wed Aug 29 21:42:53 2012 -0700
text/template: make spaces significant
Other than catching an error case that was missed before, this
CL introduces no changes to the template language or API.
For simplicity, templates use spaces as argument separators.
This means that spaces are significant: .x .y is not the same as .x.y.
In the existing code, these cases are discriminated by the lexer,
but that means for instance that (a b).x cannot be distinguished
from (a b) .x, which is lousy. Although that syntax is not
supported yet, we want to support it and this CL is a necessary
step.
This CL emits a "space" token (actually a run of spaces) from
the lexer so the parser can discriminate these cases. It therefore
fixes a couple of undisclosed bugs ("hi".x is now an error) but
doesn't otherwise change the language. Later CLs will amend
the grammar to make .X a proper operator.
There is one unpleasantness: With space a token, three-token
lookahead is now required when parsing variable declarations
to discriminate them from plain variable references. Otherwise
the change isn't bad.
The CL also moves the debugging print code out of the lexer
into the test, which is the only place it's needed or useful.
Step towards resolving issue 3999.
It still remains to move field chaining out of the lexer
and into the parser and make field access an operator.
R=golang-dev, dsymonds
CC=golang-dev
https://golang.org/cl/6492054
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/de13e8dccdc9acccf55ebc1b306a0e83b08d8704
元コミット内容
このコミットは、text/template
パッケージにおいて、テンプレート内のスペースを字句解析器(lexer)が意味のあるトークンとして扱うように変更します。これにより、テンプレート言語の引数分離がより厳密になり、将来的に (a b).x
のような構文をサポートするための基盤を構築します。この変更は、既存のテンプレート言語やAPIに直接的な変更を加えるものではありませんが、これまで見過ごされていたいくつかのエラーケース(例: "hi".x
がエラーとなる)を修正します。スペースがトークンとして扱われるようになったため、構文解析器(parser)は変数宣言を通常の変数参照と区別するために3トークンの先読みが必要になります。また、デバッグ用の出力コードが字句解析器からテストコードに移動されました。この変更は、Issue 3999の解決に向けた一歩であり、フィールドチェイニングを字句解析器から構文解析器に移し、フィールドアクセスを適切な演算子として扱うための準備でもあります。
変更の背景
Go言語の text/template
パッケージは、HTMLやテキストの生成に広く利用される強力なテンプレートエンジンです。このテンプレート言語では、これまで引数の区切りにスペースが使用されていましたが、字句解析の段階でスペースが単なる無視される空白文字として扱われていました。
この設計にはいくつかの問題がありました。特に、.(field)
のようなフィールドアクセスと、(.expression) .field
のような括弧で囲まれた式の後に続くフィールドアクセスを区別することが困難でした。例えば、 (a b).x
と (a b) .x
は、スペースが無視される場合、字句解析器にとっては同じものとして扱われてしまい、構文解析器がこれらを正しく解釈することができませんでした。これは、将来的に (expression).field
のようなより複雑な式をサポートする上で大きな障壁となっていました。
このコミットの主な目的は、この問題を解決し、テンプレート言語の構文解析をより堅牢にすることです。スペースを意味のあるトークンとして扱うことで、字句解析器は引数の区切りを明確に識別できるようになり、構文解析器がより複雑な構文を正確に解釈するための情報を提供できるようになります。これは、Issue 3999(text/template: field chaining should be an operator, not lexer magic
)の解決に向けた重要なステップでもあります。
前提知識の解説
このコミットを理解するためには、以下の概念についての基本的な知識が必要です。
- 字句解析器 (Lexer / Scanner): ソースコード(この場合はテンプレート文字列)を読み込み、意味のある最小単位である「トークン」のストリームに変換するプログラムの一部です。例えば、
{{.Name}}
という文字列は、{{
(左デリミタ)、.
(ドット)、Name
(識別子)、}}
(右デリミタ)といったトークンに分割されます。 - 構文解析器 (Parser): 字句解析器が生成したトークンのストリームを受け取り、そのトークン列が言語の文法規則に合致するかどうかを検証し、抽象構文木(AST: Abstract Syntax Tree)を構築するプログラムの一部です。ASTは、プログラムの構造を階層的に表現したものです。
- トークン (Token): 字句解析器によって識別される、言語における意味のある最小単位です。キーワード、識別子、演算子、リテラルなどがトークンに該当します。
- 先読み (Lookahead): 構文解析器が現在のトークンだけでなく、後続のいくつかのトークンも見て、次にどのような文法規則を適用すべきかを決定する技術です。このコミットでは、3トークンの先読みが必要になるケースが発生します。
- Go言語の
text/template
パッケージ: Go言語に組み込まれているテンプレートエンジンで、データ構造をテキスト出力に変換するために使用されます。{{...}}
で囲まれた部分が「アクション」と呼ばれ、Goの式や関数呼び出し、制御構造などを記述できます。 - フィールドチェイニング (Field Chaining):
.
演算子を使って、構造体のフィールドやマップのキーにアクセスする構文です。例:.User.Address.Street
。
技術的詳細
このコミットの技術的な核心は、text/template
の字句解析器と構文解析器におけるスペースの扱いの変更にあります。
-
itemSpace
トークンの導入:- これまで字句解析器はスペース(半角スペース、タブ)を単なる無視される空白文字として扱っていました。
- このコミットでは、
itemSpace
という新しいトークンタイプが導入されました。字句解析器は、1つ以上の連続するスペースをitemSpace
トークンとして発行するようになります。 - これにより、
{{.x .y}}
のようなテンプレートにおいて、.x
と.y
の間にスペースが存在することが構文解析器に明示的に伝えられるようになります。
-
字句解析器 (
lex.go
) の変更:itemType
列挙型にitemSpace
が追加されました。lexInsideAction
関数(アクション内部の字句解析を担当)が変更され、スペース文字を検出した場合にlexSpace
関数を呼び出すようになりました。lexSpace
という新しい関数が追加され、連続するスペースを読み込み、itemSpace
トークンとして発行します。isSpace
関数は、改行文字(\n
,\r
)をスペースと見なさなくなり、これらはisEndOfLine
という新しい関数で処理されるようになりました。これにより、改行の扱いがより明確になります。- デバッグ用の
itemName
マップとString()
メソッドがlex.go
からlex_test.go
に移動され、プロダクションコードからデバッグ関連のコードが分離されました。
-
構文解析器 (
parse.go
) の変更:Tree
構造体のtoken
フィールド(トークンの先読みバッファ)が[2]item
から[3]item
に拡張されました。これは、後述する変数宣言の解析において3トークンの先読みが必要になったためです。backup3
という新しい関数が追加され、3つのトークンをバッファにバックアップできるようになりました。nextNonSpace()
とpeekNonSpace()
という新しいヘルパー関数が導入されました。これらの関数は、内部的にnext()
やpeek()
を呼び出し、itemSpace
トークンをスキップして、次の非スペーストークンを返すように設計されています。これにより、構文解析器はスペーストークンを意識することなく、意味のあるトークンに直接アクセスできるようになります。expect
,expectOneOf
,parse
,itemList
,textOrAction
,action
,pipeline
,templateControl
,command
など、多くの構文解析関数がt.next()
やt.peek()
の代わりにt.nextNonSpace()
やt.peekNonSpace()
を使用するように変更されました。これは、スペーストークンが導入されたことで、構文解析器が明示的にスペースをスキップする必要が生じたためです。- 変数宣言の解析における3トークン先読み:
pipeline
関数内で変数宣言(例:{{$v := 3}}
)を解析するロジックが変更されました。$v
のような変数トークンの後に、:=
や,
といった宣言を示すトークンが続くかどうかを判断するために、最大3トークンの先読みが必要になりました。例えば、{{$x 23}}
のような「変数と引数」のパターンと{{$x := 23}}
のような「変数宣言」のパターンを区別するためです。 terminate
という新しい関数が追加され、コマンドの引数が正しくスペースで区切られていることを検証するようになりました。これにより、{{printf 3
x}}
のような隣接する引数(スペースがない)がエラーとして扱われるようになります。
-
テストコード (
lex_test.go
,parse_test.go
) の変更:lex_test.go
では、itemSpace
トークンが導入されたことに伴い、多くのテストケースが更新され、期待されるトークン列にitemSpace
が含まれるようになりました。parse_test.go
では、隣接する引数(スペースがない)がエラーとなる新しいテストケースが追加されました。
これらの変更により、text/template
の字句解析と構文解析はより厳密になり、スペースが構文的に意味を持つようになりました。これは、テンプレート言語の将来的な拡張性(特にフィールドアクセスや複雑な式のサポート)にとって不可欠な基盤となります。
コアとなるコードの変更箇所
src/pkg/text/template/parse/lex.go
--- a/src/pkg/text/template/parse/lex.go
+++ b/src/pkg/text/template/parse/lex.go
@@ -52,6 +52,7 @@ const (
itemRawString // raw quoted string (includes quotes)
itemRightDelim // right action delimiter
itemRightParen // ')' inside action
+ itemSpace // run of spaces separating arguments
itemString // quoted string (includes quotes)
itemText // plain text
itemVariable // variable starting with '$', such as '$' or '$1' or '$hello'.
@@ -301,7 +301,7 @@ func lexRightDelim(l *lexer) stateFn {
// lexInsideAction scans the elements inside action delimiters.
func lexInsideAction(l *lexer) stateFn {
// Either number, quoted string, or identifier.
- // Spaces separate and are ignored.
+ // Spaces separate arguments; runs of spaces turn into itemSpace.
// Pipe symbols separate and are emitted.
if strings.HasPrefix(l.input[l.pos:], l.rightDelim) {
if l.parenDepth == 0 {
@@ -310,10 +310,10 @@ func lexInsideAction(l *lexer) stateFn {
return l.errorf("unclosed left paren")
}
switch r := l.next(); {
- case r == eof || r == '\n':
+ case r == eof || isEndOfLine(r):
return l.errorf("unclosed action")
case isSpace(r):
- l.ignore()
+ return lexSpace
case r == ':':
if l.next() != '=' {
return l.errorf("expected :=")
@@ -354,12 +314,6 @@ func lexInsideAction(l *lexer) stateFn {
if l.parenDepth < 0 {
return l.errorf("unexpected right paren %#U", r)
}
- // Catch the mistake of (a).X, which will parse as two args.
- // See issue 3999. TODO: Remove once arg parsing is
- // better defined.
- if l.peek() == '.' {
- return l.errorf("cannot evaluate field of parenthesized expression")
- }
return lexInsideAction
case r <= unicode.MaxASCII && unicode.IsPrint(r):
l.emit(itemChar)
@@ -370,6 +324,16 @@ func lexInsideAction(l *lexer) stateFn {
return lexInsideAction
}
+// lexSpace scans a run of space characters.
+// One space has already been seen.
+func lexSpace(l *lexer) stateFn {
+ for isSpace(l.peek()) {
+ l.next()
+ }
+ l.emit(itemSpace)
+ return lexInsideAction
+}
+
// lexIdentifier scans an alphanumeric or field.
func lexIdentifier(l *lexer) stateFn {
Loop:
@@ -409,11 +373,12 @@ Loop:
// arithmetic.
func (l *lexer) atTerminator() bool {
r := l.peek()
- if isSpace(r) {
+ if isSpace(r) || isEndOfLine(r) {
return true
}
switch r {
case ' ', '\t', '\n', '\r':
return true
}
return false
+ return r == ' ' || r == '\t'
+}
+
+// isEndOfLine reports whether r is an end-of-line character
+func isEndOfLine(r rune) bool {
+ return r == '\r' || r == '\n'
}
// isAlphaNumeric reports whether r is an alphabetic, digit, or underscore.
src/pkg/text/template/parse/parse.go
--- a/src/pkg/text/template/parse/parse.go
+++ b/src/pkg/text/template/parse/parse.go
@@ -23,7 +23,7 @@ type Tree struct {
// Parsing only; cleared after parse.
funcs []map[string]interface{}
lex *lexer
- token [2]item // two-token lookahead for parser.
+ token [3]item // three-token lookahead for parser.
peekCount int
vars []string // variables defined at the moment.
}
@@ -53,12 +53,21 @@ func (t *Tree) backup() {
t.peekCount++
}
-// backup2 backs the input stream up two tokens
+// backup2 backs the input stream up two tokens.
+// The zeroth token is already there.
func (t *Tree) backup2(t1 item) {
t.token[1] = t1
t.peekCount = 2
}
+// backup3 backs the input stream up three tokens
+// The zeroth token is already there.
+func (t *Tree) backup3(t2, t1 item) { // Reverse order: we're pushing back.
+ t.token[1] = t1
+ t.token[2] = t2
+ t.peekCount = 3
+}
+
// peek returns but does not consume the next token.
func (t *Tree) peek() item {
if t.peekCount > 0 {
@@ -69,6 +78,29 @@ func (t *Tree) peek() item {
return t.token[0]
}
+// nextNonSpace returns the next non-space token.
+func (t *Tree) nextNonSpace() (token item) {
+ for {
+ token = t.next()
+ if token.typ != itemSpace {
+ break
+ }
+ }
+ return token
+}
+
+// peekNonSpace returns but does not consume the next non-space token.
+func (t *Tree) peekNonSpace() (token item) {
+ for {
+ token = t.next()
+ if token.typ != itemSpace {
+ break
+ }
+ }
+ t.backup()
+ return token
+}
+
// Parsing.
// New allocates a new parse tree with the given name.
@@ -93,7 +125,7 @@ func (t *Tree) error(err error) {
// expect consumes the next token and guarantees it has the required type.
func (t *Tree) expect(expected itemType, context string) item {
- token := t.next()
+ token := t.nextNonSpace()
if token.typ != expected {
t.errorf("expected %s in %s; got %s", expected, context, token)
}
@@ -102,7 +134,7 @@ func (t *Tree) expect(expected itemType, context string) item {
// expectOneOf consumes the next token and guarantees it has one of the required types.
func (t *Tree) expectOneOf(expected1, expected2 itemType, context string) item {
- token := t.next()
+ token := t.nextNonSpace()
if token.typ != expected1 && token.typ != expected2 {
t.errorf("expected %s or %s in %s; got %s", expected1, expected2, context, token)
}
@@ -223,7 +255,7 @@ func (t *Tree) parse(treeSet map[string]*Tree) (next Node) {
for t.peek().typ != itemEOF {
if t.peek().typ == itemLeftDelim {
delim := t.next()
- if t.next().typ == itemDefine {
+ if t.nextNonSpace().typ == itemDefine {
newT := New("definition") // name will be updated once we know it.
newT.startParse(t.funcs, t.lex)
newT.parseDefinition(treeSet)
@@ -266,7 +298,7 @@ func (t *Tree) parseDefinition(treeSet map[string]*Tree) {
// Terminates at {{end}} or {{else}}, returned separately.
func (t *Tree) itemList() (list *ListNode, next Node) {
list = newList()
- for t.peek().typ != itemEOF {
+ for t.peekNonSpace().typ != itemEOF {
n := t.textOrAction()
switch n.Type() {
case nodeEnd, nodeElse:
@@ -281,7 +313,7 @@ func (t *Tree) itemList() (list *ListNode, next Node) {
// textOrAction:
// text | action
func (t *Tree) textOrAction() Node {
- switch token := t.next(); token.typ {
+ switch token := t.nextNonSpace(); token.typ {
case itemText:
return newText(token.val)
case itemLeftDelim:
@@ -298,7 +330,7 @@ func (t *Tree) textOrAction() Node {
// Left delim is past. Now get actions.
// First word could be a keyword such as range.
func (t *Tree) action() (n Node) {
- switch token := t.next(); token.typ {
+ switch token := t.nextNonSpace(); token.typ {
case itemElse:
return t.elseControl()
case itemEnd:
@@ -324,10 +356,15 @@ func (t *Tree) pipeline(context string) (pipe *PipeNode) {
var decl []*VariableNode
// Are there declarations?\n for {
- if v := t.peek(); v.typ == itemVariable {
+ if v := t.peekNonSpace(); v.typ == itemVariable {
t.next()
- if next := t.peek(); next.typ == itemColonEquals || (next.typ == itemChar && next.val == ",") {
- t.next()
+ // Since space is a token, we need 3-token look-ahead here in the worst case:
+ // in "$x foo" we need to read "foo" (as opposed to ":=") to know that $x is an
+ // argument variable rather than a declaration. So remember the token
+ // adjacent to the variable so we can push it back if necessary.
+ tokenAfterVariable := t.peek()
+ if next := t.peekNonSpace(); next.typ == itemColonEquals || (next.typ == itemChar && next.val == ",") {
+ t.nextNonSpace()
variable := newVariable(v.val)
if len(variable.Ident) != 1 {
t.errorf("illegal variable in declaration: %s", v.val)
@@ -339,7 +376,7 @@ func (t *Tree) pipeline(context string) (pipe *PipeNode) {
t.errorf("too many declarations in %s", context)
}
+ } else if tokenAfterVariable.typ == itemSpace {
+ t.backup3(v, tokenAfterVariable)
} else {
t.backup2(v)
}
@@ -348,7 +385,7 @@ func (t *Tree) pipeline(context string) (pipe *PipeNode) {
}
pipe = newPipeline(t.lex.lineNumber(), decl)
for {
- switch token := t.next(); token.typ {
+ switch token := t.nextNonSpace(); token.typ {
case itemRightDelim, itemRightParen:
if len(pipe.Cmds) == 0 {
t.errorf("missing value for %s", context)
@@ -432,7 +469,7 @@ func (t *Tree) elseControl() Node {
// to a string.
func (t *Tree) templateControl() Node {
var name string
- switch token := t.next(); token.typ {
+ switch token := t.nextNonSpace(); token.typ {
case itemString, itemRawString:
s, err := strconv.Unquote(token.val)
if err != nil {
@@ -443,7 +480,7 @@ func (t *Tree) templateControl() Node {
\tt.unexpected(token, "template invocation")
}
var pipe *PipeNode
- if t.next().typ != itemRightDelim {
+ if t.nextNonSpace().typ != itemRightDelim {
t.backup()
// Do not pop variables; they persist until "end".
pipe = t.pipeline("template")
@@ -458,7 +495,7 @@ func (t *Tree) command() *CommandNode {
cmd := newCommand()
Loop:
for {
- switch token := t.next(); token.typ {
+ switch token := t.nextNonSpace(); token.typ {
case itemRightDelim, itemRightParen:
t.backup()
break Loop
@@ -466,7 +503,7 @@ Loop:
break Loop
case itemLeftParen:
p := t.pipeline("parenthesized expression")
- if t.next().typ != itemRightParen {
+ if t.nextNonSpace().typ != itemRightParen {
t.errorf("missing right paren in parenthesized expression")
}
cmd.append(p)
@@ -502,6 +539,7 @@ Loop:
default:
t.unexpected(token, "command")
}
+ t.terminate()
}
if len(cmd.Args) == 0 {
t.errorf("empty command")
@@ -509,6 +547,17 @@ Loop:
return cmd
}
+// terminate checks that the next token terminates an argument. This guarantees
+// that arguments are space-separated, for example that (2)3 does not parse.
+func (t *Tree) terminate() {
+ token := t.peek()
+ switch token.typ {
+ case itemChar, itemPipe, itemRightDelim, itemRightParen, itemSpace:
+ return
+ }
+ t.unexpected(token, "argument list (missing space?)")
+}
+
// hasFunction reports if a function name exists in the Tree's maps.
func (t *Tree) hasFunction(name string) bool {
for _, funcMap := range t.funcs {
コアとなるコードの解説
このコミットのコアとなる変更は、字句解析器がスペースを itemSpace
トークンとして発行するようになったこと、そして構文解析器がこの新しいトークンを適切に処理するように適応したことです。
字句解析器 (lex.go
) の変更点:
itemSpace
の追加:itemType
にitemSpace
が追加され、スペースが正式なトークンタイプとして認識されるようになりました。lexInsideAction
の変更: テンプレートのアクション内部を解析する際に、スペース文字 (isSpace(r)
) を検出すると、これまでは単に無視 (l.ignore()
) していましたが、lexSpace
関数を呼び出すように変更されました。lexSpace
関数の導入: この新しい関数は、連続するスペース文字を読み込み、それらをまとめて1つのitemSpace
トークンとして発行します。これにより、構文解析器は引数間のスペースの存在を明確に認識できるようになります。isSpace
とisEndOfLine
の分離:isSpace
関数は半角スペースとタブのみをチェックするように変更され、改行文字はisEndOfLine
という新しい関数で処理されるようになりました。これにより、改行のセマンティクスがより明確に分離されました。
構文解析器 (parse.go
) の変更点:
- 3トークン先読みの導入:
Tree
構造体のtoken
バッファが[2]item
から[3]item
に拡張され、backup3
関数が追加されました。これは、特に変数宣言(例:{{$v := 3}}
)と通常の変数参照(例:{{$v 3}}
)を区別するために必要となります。スペースがトークンになったことで、$v
の後にスペース、そして:=
が続くのか、それともスペースの後に別の引数が続くのかを判断するために、より多くのトークンを先読みする必要が生じました。 nextNonSpace()
とpeekNonSpace()
の導入: これらの関数は、字句解析器が発行するitemSpace
トークンを透過的にスキップし、次の「意味のある」トークンを構文解析器に提供します。これにより、構文解析器のロジックは、ほとんどの場合、スペーストークンの存在を意識することなく、これまでと同様に動作できます。- 既存関数の更新:
expect
,expectOneOf
,pipeline
,command
など、多くの構文解析関数がt.next()
やt.peek()
の代わりにt.nextNonSpace()
やt.peekNonSpace()
を使用するように変更されました。これにより、スペーストークンが自動的にスキップされ、構文解析のロジックが簡潔に保たれます。 - 変数宣言の解析ロジックの強化:
pipeline
関数内の変数宣言の解析部分が、3トークン先読みを利用して、変数とそれに続くトークンの関係をより正確に判断するように修正されました。これにより、$x foo
のような構文が、$x := foo
のような宣言と誤って解釈されることを防ぎます。 terminate
関数の導入:command
関数内で呼び出されるterminate
関数は、引数の後に続くトークンが、引数を正しく終端するものである(スペース、パイプ、右デリミタ、右括弧など)ことを検証します。これにより、{{printf 3
x}}
のように引数がスペースで区切られていない不正な構文がエラーとして検出されるようになります。
これらの変更により、text/template
の構文解析はより厳密になり、テンプレート内のスペースが単なる装飾ではなく、構文的に重要な意味を持つようになりました。これは、テンプレート言語の将来的な拡張性、特に複雑な式や演算子の導入にとって不可欠な基盤となります。
関連リンク
参考にした情報源リンク
- Go CL 6492054: text/template: make spaces significant (元の Gerrit Change-ID)
- Go Issue 3999 (このコミットが解決に向けた一歩であるとされているIssue)
- Go言語のtext/templateパッケージに関する公式ドキュメント
- 字句解析器と構文解析器の基本概念
- 抽象構文木 (AST) の概念