Add tool to check the WGSL spec examples compile

Automatically fetches the spec from the web, runs tint for all compilable examples.
Displays all examples that don't compile or compile when they shouldn't

Change-Id: I4718dd45ddec7f191ceed937ecb1c384d77d0eb5
Reviewed-on: https://dawn-review.googlesource.com/c/tint/+/36722
Commit-Queue: dan sinclair <dsinclair@chromium.org>
Reviewed-by: dan sinclair <dsinclair@chromium.org>
diff --git a/tools/check-spec-examples/main.go b/tools/check-spec-examples/main.go
new file mode 100644
index 0000000..7b11323
--- /dev/null
+++ b/tools/check-spec-examples/main.go
@@ -0,0 +1,312 @@
+// Copyright 2021 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.
+
+// check-spec-examples tests that WGSL specification examples compile as
+// expected.
+//
+// The tool parses the WGSL HTML specification from the web or from a local file
+// and then runs the WGSL compiler for all examples annotated with the 'wgsl'
+// and 'global-scope' or 'function-scope' HTML class types.
+//
+// To run:
+//   go get golang.org/x/net/html # Only required once
+//   go run tools/check-spec-examples/main.go --compiler=<path-to-tint>
+package main
+
+import (
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"golang.org/x/net/html"
+)
+
+const (
+	toolName        = "check-spec-examples"
+	defaultSpecPath = "https://gpuweb.github.io/gpuweb/wgsl.html"
+)
+
+var (
+	errInvalidArg = errors.New("Invalid arguments")
+)
+
+func main() {
+	flag.Usage = func() {
+		out := flag.CommandLine.Output()
+		fmt.Fprintf(out, "%v tests that WGSL specification examples compile as expected.\n", toolName)
+		fmt.Fprintf(out, "\n")
+		fmt.Fprintf(out, "Usage:\n")
+		fmt.Fprintf(out, "  %s [spec] [flags]\n", toolName)
+		fmt.Fprintf(out, "\n")
+		fmt.Fprintf(out, "spec is an optional local file path or URL to the WGSL specification.\n")
+		fmt.Fprintf(out, "If spec is omitted then the specification is fetched from %v\n", defaultSpecPath)
+		fmt.Fprintf(out, "\n")
+		fmt.Fprintf(out, "flags may be any combination of:\n")
+		flag.PrintDefaults()
+	}
+
+	err := run()
+	switch err {
+	case nil:
+		return
+	case errInvalidArg:
+		fmt.Fprintf(os.Stderr, "Error: %v\n\n", err)
+		flag.Usage()
+	default:
+		fmt.Fprintf(os.Stderr, "%v\n", err)
+	}
+	os.Exit(1)
+}
+
+func run() error {
+	// Parse flags
+	compilerPath := flag.String("compiler", "tint", "path to compiler executable")
+	verbose := flag.Bool("verbose", false, "print examples that pass")
+	flag.Parse()
+
+	// Try to find the WGSL compiler
+	compiler, err := exec.LookPath(*compilerPath)
+	if err != nil {
+		return fmt.Errorf("Failed to find WGSL compiler: %w", err)
+	}
+	if compiler, err = filepath.Abs(compiler); err != nil {
+		return fmt.Errorf("Failed to find WGSL compiler: %w", err)
+	}
+
+	// Check for explicit WGSL spec path
+	args := flag.Args()
+	specURL, _ := url.Parse(defaultSpecPath)
+	switch len(args) {
+	case 0:
+	case 1:
+		var err error
+		specURL, err = url.Parse(args[0])
+		if err != nil {
+			return err
+		}
+	default:
+		if len(args) > 1 {
+			return errInvalidArg
+		}
+	}
+
+	// The specURL might just be a local file path, in which case automatically
+	// add the 'file' URL scheme
+	if specURL.Scheme == "" {
+		specURL.Scheme = "file"
+	}
+
+	// Open the spec from HTTP(S) or from a local file
+	var specContent io.ReadCloser
+	switch specURL.Scheme {
+	case "http", "https":
+		response, err := http.Get(specURL.String())
+		if err != nil {
+			return fmt.Errorf("Failed to load the WGSL spec from '%v': %w", specURL, err)
+		}
+		specContent = response.Body
+	case "file":
+		specURL.Path, err = filepath.Abs(specURL.Path)
+		if err != nil {
+			return fmt.Errorf("Failed to load the WGSL spec from '%v': %w", specURL, err)
+		}
+
+		file, err := os.Open(specURL.Path)
+		if err != nil {
+			return fmt.Errorf("Failed to load the WGSL spec from '%v': %w", specURL, err)
+		}
+		specContent = file
+	default:
+		return fmt.Errorf("Unsupported URL scheme: %v", specURL.Scheme)
+	}
+	defer specContent.Close()
+
+	// Create the HTML parser
+	doc, err := html.Parse(specContent)
+	if err != nil {
+		return err
+	}
+
+	// Parse all the WGSL examples
+	examples := []example{}
+	if err := gatherExamples(doc, &examples); err != nil {
+		return err
+	}
+
+	// Create a temporary directory to hold the examples as separate files
+	tmpDir, err := ioutil.TempDir("", "wgsl-spec-examples")
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(tmpDir, 0666); err != nil {
+		return fmt.Errorf("Failed to create temporary directory: %w", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// For each compilable WGSL example...
+	for _, e := range examples {
+		exampleURL := specURL.String() + "#" + e.name
+
+		if err := tryCompile(compiler, tmpDir, e); err != nil {
+			if !e.expectError {
+				fmt.Printf("✘ %v ✘\n%v\n", exampleURL, err)
+				continue
+			}
+		} else if e.expectError {
+			fmt.Printf("✘ %v ✘\nCompiled even though it was marked with 'expect-error'\n", exampleURL)
+		}
+		if *verbose {
+			fmt.Printf("✔ %v ✔\n", exampleURL)
+		}
+	}
+	return nil
+}
+
+// Holds all the information about a single, compilable WGSL example in the spec
+type example struct {
+	name          string // The name (typically hash generated by bikeshed)
+	code          string // The example source
+	globalScope   bool   // Annotated with 'global-scope' ?
+	functionScope bool   // Annotated with 'function-scope' ?
+	expectError   bool   // Annotated with 'expect-error' ?
+}
+
+// tryCompile attempts to compile the example e in the directory wd, using the
+// compiler at the given path. If the example is annotated with 'function-scope'
+// then the code is wrapped with a basic vertex-stage-entry function.
+// If the first compile fails with an error message containing 'error v-0003',
+// then a dummy vertex-state-entry function is appended to the source, and
+// another attempt to compile the shader is made.
+func tryCompile(compiler, wd string, e example) error {
+	code := e.code
+	if e.functionScope {
+		code = "\n[[stage(vertex)]] fn main() -> void {\n" + code + "}\n"
+	}
+
+	addedStubFunction := false
+	for {
+		err := compile(compiler, wd, e.name, code)
+		if err == nil {
+			return nil
+		}
+
+		if !addedStubFunction && strings.Contains(err.Error(), "error v-0003") {
+			// error v-0003: At least one of vertex, fragment or compute shader
+			// must be present. Add a stub entry point to satisfy the compiler.
+			code += "\n[[stage(vertex)]] fn main() -> void {}\n"
+			addedStubFunction = true
+			continue
+		}
+
+		return err
+	}
+}
+
+// compile creates a file in wd and uses the compiler to attempt to compile it.
+func compile(compiler, wd, name, code string) error {
+	filename := name + ".wgsl"
+	path := filepath.Join(wd, filename)
+	if err := ioutil.WriteFile(path, []byte(code), 0666); err != nil {
+		return fmt.Errorf("Failed to write example file '%v'", path)
+	}
+	cmd := exec.Command(compiler, filename)
+	cmd.Dir = wd
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("%v\n%v", err, string(out))
+	}
+	return nil
+}
+
+// gatherExamples scans the HTML node and its children for blocks that contain
+// WGSL example code, populating the examples slice.
+func gatherExamples(node *html.Node, examples *[]example) error {
+	if hasClass(node, "example") && hasClass(node, "wgsl") {
+		e := example{
+			name:          nodeID(node),
+			code:          exampleCode(node),
+			globalScope:   hasClass(node, "global-scope"),
+			functionScope: hasClass(node, "function-scope"),
+			expectError:   hasClass(node, "expect-error"),
+		}
+		// If the example is annotated with a scope, then it can be compiled.
+		if e.globalScope || e.functionScope {
+			*examples = append(*examples, e)
+		}
+	}
+	for child := node.FirstChild; child != nil; child = child.NextSibling {
+		if err := gatherExamples(child, examples); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// exampleCode returns a string formed from all the TextNodes found in <pre>
+// blocks that are children of node.
+func exampleCode(node *html.Node) string {
+	sb := strings.Builder{}
+	for child := node.FirstChild; child != nil; child = child.NextSibling {
+		if child.Data == "pre" {
+			printNodeText(child, &sb)
+		}
+	}
+	return sb.String()
+}
+
+// printNodeText traverses node and its children, writing the Data of all
+// TextNodes to sb.
+func printNodeText(node *html.Node, sb *strings.Builder) {
+	if node.Type == html.TextNode {
+		sb.WriteString(node.Data)
+	}
+
+	for child := node.FirstChild; child != nil; child = child.NextSibling {
+		printNodeText(child, sb)
+	}
+}
+
+// hasClass returns true iff node is has the given "class" attribute.
+func hasClass(node *html.Node, class string) bool {
+	for _, attr := range node.Attr {
+		if attr.Key == "class" {
+			classes := strings.Split(attr.Val, " ")
+			for _, c := range classes {
+				if c == class {
+					return true
+				}
+			}
+		}
+	}
+	return false
+}
+
+// nodeID returns the given "id" attribute of node, or an empty string if there
+// is no "id" attribute.
+func nodeID(node *html.Node) string {
+	for _, attr := range node.Attr {
+		if attr.Key == "id" {
+			return attr.Val
+		}
+	}
+	return ""
+}