tools/src: Move template logic of 'gen' to package

And add tests.

This is useful for other tooling.

Change-Id: Ia399071baf6d4bb617f3c73e4ccd4ed72d522c2e
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/111020
Commit-Queue: Ben Clayton <bclayton@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
Reviewed-by: Antonio Maiorano <amaiorano@google.com>
diff --git a/tools/src/cmd/gen/main.go b/tools/src/cmd/gen/main.go
index 653adbb..cdf91da 100644
--- a/tools/src/cmd/gen/main.go
+++ b/tools/src/cmd/gen/main.go
@@ -30,13 +30,12 @@
 	"runtime"
 	"strconv"
 	"strings"
-	"text/template"
 	"time"
-	"unicode"
 
 	"dawn.googlesource.com/dawn/tools/src/container"
 	"dawn.googlesource.com/dawn/tools/src/fileutils"
 	"dawn.googlesource.com/dawn/tools/src/glob"
+	"dawn.googlesource.com/dawn/tools/src/template"
 	"dawn.googlesource.com/dawn/tools/src/tint/intrinsic/gen"
 	"dawn.googlesource.com/dawn/tools/src/tint/intrinsic/parser"
 	"dawn.googlesource.com/dawn/tools/src/tint/intrinsic/resolver"
@@ -325,7 +324,6 @@
 `
 
 type generator struct {
-	template  *template.Template
 	cache     *genCache
 	writeFile WriteFile
 	rnd       *rand.Rand
@@ -342,32 +340,13 @@
 // syntax.
 func generate(tmpl string, cache *genCache, w io.Writer, writeFile WriteFile) error {
 	g := generator{
-		template:  template.New("<template>"),
 		cache:     cache,
 		writeFile: writeFile,
 		rnd:       rand.New(rand.NewSource(4561123)),
 	}
-	if err := g.bindAndParse(g.template, tmpl); err != nil {
-		return err
-	}
-	return g.template.Execute(w, nil)
-}
 
-func (g *generator) bindAndParse(t *template.Template, text string) error {
-	_, err := t.Funcs(map[string]interface{}{
-		"Map":                   newMap,
-		"Iterate":               iterate,
-		"Title":                 strings.Title,
-		"PascalCase":            pascalCase,
+	funcs := map[string]interface{}{
 		"SplitDisplayName":      gen.SplitDisplayName,
-		"Contains":              strings.Contains,
-		"HasPrefix":             strings.HasPrefix,
-		"HasSuffix":             strings.HasSuffix,
-		"TrimPrefix":            strings.TrimPrefix,
-		"TrimSuffix":            strings.TrimSuffix,
-		"TrimLeft":              strings.TrimLeft,
-		"TrimRight":             strings.TrimRight,
-		"Split":                 strings.Split,
 		"Scramble":              g.scramble,
 		"IsEnumEntry":           is(sem.EnumEntry{}),
 		"IsEnumMatcher":         is(sem.EnumMatcher{}),
@@ -387,63 +366,9 @@
 		"Sem":                   g.cache.sem,
 		"IntrinsicTable":        g.cache.intrinsicTable,
 		"Permute":               g.cache.permute,
-		"Eval":                  g.eval,
-		"Import":                g.importTmpl,
 		"WriteFile":             func(relPath, content string) (string, error) { return "", g.writeFile(relPath, content) },
-	}).Option("missingkey=error").Parse(text)
-	return err
-}
-
-// eval executes the sub-template with the given name and argument, returning
-// the generated output
-func (g *generator) eval(template string, args ...interface{}) (string, error) {
-	target := g.template.Lookup(template)
-	if target == nil {
-		return "", fmt.Errorf("template '%v' not found", template)
 	}
-	sb := strings.Builder{}
-
-	var err error
-	if len(args) == 1 {
-		err = target.Execute(&sb, args[0])
-	} else {
-		m := newMap()
-		if len(args)%2 != 0 {
-			return "", fmt.Errorf("Eval expects a single argument or list name-value pairs")
-		}
-		for i := 0; i < len(args); i += 2 {
-			name, ok := args[i].(string)
-			if !ok {
-				return "", fmt.Errorf("Eval argument %v is not a string", i)
-			}
-			m.Put(name, args[i+1])
-		}
-		err = target.Execute(&sb, m)
-	}
-
-	if err != nil {
-		return "", fmt.Errorf("while evaluating '%v': %v", template, err)
-	}
-	return sb.String(), nil
-}
-
-// importTmpl parses the template at the given project-relative path, merging
-// the template definitions into the global namespace.
-// Note: The body of the template is not executed.
-func (g *generator) importTmpl(path string) (string, error) {
-	if strings.Contains(path, "..") {
-		return "", fmt.Errorf("import path must not contain '..'")
-	}
-	path = filepath.Join(fileutils.DawnRoot(), path)
-	data, err := ioutil.ReadFile(path)
-	if err != nil {
-		return "", fmt.Errorf("failed to open '%v': %w", path, err)
-	}
-	t := g.template.New("")
-	if err := g.bindAndParse(t, string(data)); err != nil {
-		return "", fmt.Errorf("failed to parse '%v': %w", path, err)
-	}
-	return "", nil
+	return template.Run(tmpl, w, funcs)
 }
 
 // scramble randomly modifies the input string so that it is no longer equal to
@@ -477,24 +402,6 @@
 	return string(bytes), nil
 }
 
-// Map is a simple generic key-value map, which can be used in the template
-type Map map[interface{}]interface{}
-
-func newMap() Map { return Map{} }
-
-// Put adds the key-value pair into the map.
-// Put always returns an empty string so nothing is printed in the template.
-func (m Map) Put(key, value interface{}) string {
-	m[key] = value
-	return ""
-}
-
-// Get looks up and returns the value with the given key. If the map does not
-// contain the given key, then nil is returned.
-func (m Map) Get(key interface{}) interface{} {
-	return m[key]
-}
-
 // is returns a function that returns true if the value passed to the function
 // matches the type of 'ty'.
 func is(ty interface{}) func(interface{}) bool {
@@ -525,43 +432,6 @@
 	return s.Index(count-1).Interface() == v
 }
 
-// iterate returns a slice of length 'n', with each element equal to its index.
-// Useful for: {{- range Iterate $n -}}<this will be looped $n times>{{end}}
-func iterate(n int) []int {
-	out := make([]int, n)
-	for i := range out {
-		out[i] = i
-	}
-	return out
-}
-
-// pascalCase returns the snake-case string s transformed into 'PascalCase',
-// Rules:
-// * The first letter of the string is capitalized
-// * Characters following an underscore or number are capitalized
-// * Underscores are removed from the returned string
-// See: https://en.wikipedia.org/wiki/Camel_case
-func pascalCase(s string) string {
-	b := strings.Builder{}
-	upper := true
-	for _, r := range s {
-		if r == '_' {
-			upper = true
-			continue
-		}
-		if upper {
-			b.WriteRune(unicode.ToUpper(r))
-			upper = false
-		} else {
-			b.WriteRune(r)
-		}
-		if unicode.IsNumber(r) {
-			upper = true
-		}
-	}
-	return b.String()
-}
-
 // Invokes the clang-format executable at 'exe' to format the file content 'in'.
 // Returns the formatted file.
 func clangFormat(in, exe string) (string, error) {
diff --git a/tools/src/template/template.go b/tools/src/template/template.go
new file mode 100644
index 0000000..d70635b
--- /dev/null
+++ b/tools/src/template/template.go
@@ -0,0 +1,190 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// package template wraps the golang "text/template" package to provide an
+// enhanced template generator.
+package template
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+	"path/filepath"
+	"strings"
+	"text/template"
+	"unicode"
+
+	"dawn.googlesource.com/dawn/tools/src/fileutils"
+)
+
+// The template function binding table
+type Functions map[string]interface{}
+
+// Run executes the template tmpl, writing the output to w.
+// funcs are the functions provided to the template.
+// See https://golang.org/pkg/text/template/ for documentation on the template
+// syntax.
+func Run(tmpl string, w io.Writer, funcs Functions) error {
+	g := generator{
+		template: template.New("<template>"),
+	}
+
+	// Add a bunch of generic useful functions
+	g.funcs = Functions{
+		"Contains":   strings.Contains,
+		"Eval":       g.eval,
+		"HasPrefix":  strings.HasPrefix,
+		"HasSuffix":  strings.HasSuffix,
+		"Import":     g.importTmpl,
+		"Iterate":    iterate,
+		"Map":        newMap,
+		"PascalCase": pascalCase,
+		"Split":      strings.Split,
+		"Title":      strings.Title,
+		"TrimLeft":   strings.TrimLeft,
+		"TrimPrefix": strings.TrimPrefix,
+		"TrimRight":  strings.TrimRight,
+		"TrimSuffix": strings.TrimSuffix,
+	}
+
+	// Append custom functions
+	for name, fn := range funcs {
+		g.funcs[name] = fn
+	}
+
+	if err := g.bindAndParse(g.template, tmpl); err != nil {
+		return err
+	}
+
+	return g.template.Execute(w, nil)
+}
+
+type generator struct {
+	template *template.Template
+	funcs    Functions
+}
+
+func (g *generator) bindAndParse(t *template.Template, tmpl string) error {
+	_, err := t.
+		Funcs(map[string]interface{}(g.funcs)).
+		Option("missingkey=error").
+		Parse(tmpl)
+	return err
+}
+
+// eval executes the sub-template with the given name and argument, returning
+// the generated output
+func (g *generator) eval(template string, args ...interface{}) (string, error) {
+	target := g.template.Lookup(template)
+	if target == nil {
+		return "", fmt.Errorf("template '%v' not found", template)
+	}
+	sb := strings.Builder{}
+
+	var err error
+	if len(args) == 1 {
+		err = target.Execute(&sb, args[0])
+	} else {
+		m := newMap()
+		if len(args)%2 != 0 {
+			return "", fmt.Errorf("Eval expects a single argument or list name-value pairs")
+		}
+		for i := 0; i < len(args); i += 2 {
+			name, ok := args[i].(string)
+			if !ok {
+				return "", fmt.Errorf("Eval argument %v is not a string", i)
+			}
+			m.Put(name, args[i+1])
+		}
+		err = target.Execute(&sb, m)
+	}
+
+	if err != nil {
+		return "", fmt.Errorf("while evaluating '%v': %v", template, err)
+	}
+	return sb.String(), nil
+}
+
+// importTmpl parses the template at the given project-relative path, merging
+// the template definitions into the global namespace.
+// Note: The body of the template is not executed.
+func (g *generator) importTmpl(path string) (string, error) {
+	if strings.Contains(path, "..") {
+		return "", fmt.Errorf("import path must not contain '..'")
+	}
+	path = filepath.Join(fileutils.DawnRoot(), path)
+	data, err := ioutil.ReadFile(path)
+	if err != nil {
+		return "", fmt.Errorf("failed to open '%v': %w", path, err)
+	}
+	if err := g.bindAndParse(g.template.New(""), string(data)); err != nil {
+		return "", fmt.Errorf("failed to parse '%v': %w", path, err)
+	}
+	return "", nil
+}
+
+// Map is a simple generic key-value map, which can be used in the template
+type Map map[interface{}]interface{}
+
+func newMap() Map { return Map{} }
+
+// Put adds the key-value pair into the map.
+// Put always returns an empty string so nothing is printed in the template.
+func (m Map) Put(key, value interface{}) string {
+	m[key] = value
+	return ""
+}
+
+// Get looks up and returns the value with the given key. If the map does not
+// contain the given key, then nil is returned.
+func (m Map) Get(key interface{}) interface{} {
+	return m[key]
+}
+
+// iterate returns a slice of length 'n', with each element equal to its index.
+// Useful for: {{- range Iterate $n -}}<this will be looped $n times>{{end}}
+func iterate(n int) []int {
+	out := make([]int, n)
+	for i := range out {
+		out[i] = i
+	}
+	return out
+}
+
+// pascalCase returns the snake-case string s transformed into 'PascalCase',
+// Rules:
+// * The first letter of the string is capitalized
+// * Characters following an underscore or number are capitalized
+// * Underscores are removed from the returned string
+// See: https://en.wikipedia.org/wiki/Camel_case
+func pascalCase(s string) string {
+	b := strings.Builder{}
+	upper := true
+	for _, r := range s {
+		if r == '_' || r == ' ' {
+			upper = true
+			continue
+		}
+		if upper {
+			b.WriteRune(unicode.ToUpper(r))
+			upper = false
+		} else {
+			b.WriteRune(r)
+		}
+		if unicode.IsNumber(r) {
+			upper = true
+		}
+	}
+	return b.String()
+}
diff --git a/tools/src/template/template_test.go b/tools/src/template/template_test.go
new file mode 100644
index 0000000..f18e727
--- /dev/null
+++ b/tools/src/template/template_test.go
@@ -0,0 +1,225 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package template_test
+
+import (
+	"bytes"
+	"testing"
+
+	"dawn.googlesource.com/dawn/tools/src/template"
+	"github.com/google/go-cmp/cmp"
+)
+
+func check(t *testing.T, tmpl, expected string, fns template.Functions) {
+	t.Helper()
+	w := &bytes.Buffer{}
+	err := template.Run(tmpl, w, fns)
+	if err != nil {
+		t.Errorf("template.Run() failed with %v", err)
+		return
+	}
+	got := w.String()
+	if diff := cmp.Diff(expected, got); diff != "" {
+		t.Errorf("output was not as expected. Diff:\n%v", diff)
+	}
+}
+
+func TestContains(t *testing.T) {
+	tmpl := `
+{{ Contains "hello world" "hello"}}
+{{ Contains "hello world" "fish"}}
+`
+	expected := `
+true
+false
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TestEvalSingleParameter(t *testing.T) {
+	tmpl := `
+pre-eval
+{{ Eval "T" 123 }}
+{{ Eval "T" "cat" }}
+post-eval
+
+pre-define
+{{- define "T"}}
+  . is {{.}}
+{{- end }}
+post-define
+`
+	expected := `
+pre-eval
+
+  . is 123
+
+  . is cat
+post-eval
+
+pre-define
+post-define
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TestEvalParameterPairs(t *testing.T) {
+	tmpl := `
+pre-eval
+{{ Eval "T" "number" 123 "animal" "cat" }}
+post-eval
+
+pre-define
+{{- define "T"}}
+  .number is {{.number}}
+  .animal is {{.animal}}
+{{- end }}
+post-define
+`
+	expected := `
+pre-eval
+
+  .number is 123
+  .animal is cat
+post-eval
+
+pre-define
+post-define
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TestHasPrefix(t *testing.T) {
+	tmpl := `
+{{ HasPrefix "hello world" "hello"}}
+{{ HasPrefix "hello world" "world"}}
+`
+	expected := `
+true
+false
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TestIterate(t *testing.T) {
+	tmpl := `
+{{- range $i := Iterate 5}}
+  {{$i}}
+{{- end}}
+`
+	expected := `
+  0
+  1
+  2
+  3
+  4
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TestMap(t *testing.T) {
+	tmpl := `
+	{{- $m := Map }}
+	{{- $m.Put "one" 1 }}
+	{{- $m.Put "two" 2 }}
+	one: {{ $m.Get "one" }}
+	two: {{ $m.Get "two" }}
+`
+	expected := `
+	one: 1
+	two: 2
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TestPascalCase(t *testing.T) {
+	tmpl := `
+{{ PascalCase "hello world" }}
+{{ PascalCase "hello_world" }}
+`
+	expected := `
+HelloWorld
+HelloWorld
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TestSplit(t *testing.T) {
+	tmpl := `
+{{- range $i, $s := Split "cat_says_meow" "_" }}
+  {{$i}}: '{{$s}}'
+{{- end }}
+`
+	expected := `
+  0: 'cat'
+  1: 'says'
+  2: 'meow'
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TestTitle(t *testing.T) {
+	tmpl := `
+{{Title "hello world"}}
+`
+	expected := `
+Hello World
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TrimLeft(t *testing.T) {
+	tmpl := `
+'{{TrimLeft "hello world", "hel"}}'
+`
+	expected := `
+'o world'
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TrimPrefix(t *testing.T) {
+	tmpl := `
+'{{TrimLeft "hello world", "hel"}}'
+'{{TrimLeft "hello world", "heo"}}'
+`
+	expected := `
+'o world'
+'hello world'
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TrimRight(t *testing.T) {
+	tmpl := `
+'{{TrimRight "hello world", "wld"}}'
+`
+	expected := `
+'hello wor'
+`
+	check(t, tmpl, expected, nil)
+}
+
+func TrimSuffix(t *testing.T) {
+	tmpl := `
+'{{TrimRight "hello world", "rld"}}'
+'{{TrimRight "hello world", "wld"}}'
+`
+	expected := `
+'hello wo'
+'hello world'
+`
+	check(t, tmpl, expected, nil)
+}