internal/filetypes: add auto interpretation

- automatically detect OpenAPI and JSONSchema
  based on strict criteria.
- json+schema no longer magically maps to jsonschema

For inputs, auto mode is now automatically enabled for .json
and .yaml/yml files. Any explicit tag, like json: or data: disables
auto mode.

Change-Id: I391179c4542b823c428e4989e31381e00caa4a45
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/5410
Reviewed-by: Marcel van Lohuizen <mpvl@google.com>
diff --git a/internal/encoding/detect.go b/internal/encoding/detect.go
new file mode 100644
index 0000000..b125a3f
--- /dev/null
+++ b/internal/encoding/detect.go
@@ -0,0 +1,67 @@
+// Copyright 2020 CUE 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 encoding
+
+import (
+	"net/url"
+	"path"
+	"strings"
+
+	"cuelang.org/go/cue"
+	"cuelang.org/go/cue/build"
+)
+
+// Detect detects the interpretation.
+func Detect(v cue.Value) (i build.Interpretation) {
+	switch {
+	case isOpenAPI(v):
+		return build.OpenAPI
+	case isJSONSchema(v):
+		return build.JSONSchema
+	}
+	return i
+}
+
+func isOpenAPI(v cue.Value) bool {
+	s, _ := v.Lookup("openapi").String()
+	if !strings.HasPrefix(s, "3.") {
+		return false
+	}
+	if _, err := v.Lookup("info", "title").String(); err != nil {
+		return false
+	}
+	if _, err := v.Lookup("info", "version").String(); err != nil {
+		return false
+	}
+	return true
+}
+
+func isJSONSchema(v cue.Value) bool {
+	s, err := v.Lookup("$schema").String()
+	if err != nil {
+		return false
+	}
+	u, err := url.Parse(s)
+	if err != nil {
+		return false
+	}
+	if u.Hostname() != "json-schema.org" {
+		return false
+	}
+	if _, base := path.Split(u.EscapedPath()); base != "schema" {
+		return false
+	}
+	return true
+}
diff --git a/internal/encoding/detect_test.go b/internal/encoding/detect_test.go
new file mode 100644
index 0000000..bdb1b9b
--- /dev/null
+++ b/internal/encoding/detect_test.go
@@ -0,0 +1,101 @@
+// Copyright 2020 CUE 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 encoding
+
+import (
+	"testing"
+
+	"cuelang.org/go/cue"
+	"cuelang.org/go/cue/build"
+)
+
+func TestDetect(t *testing.T) {
+	testCases := []struct {
+		name string
+		in   string
+		out  build.Interpretation
+	}{{
+		name: "validOpenAPI",
+		in: `
+		openapi: "3.0.0"
+		info: title: "Foo"
+		info: version: "v1alpha1"
+		`,
+		out: build.OpenAPI,
+	}, {
+		name: "noOpenAPI",
+		in: `
+		info: title: "Foo"
+		info: version: "v1alpha1"
+		`,
+	}, {
+		name: "noTitle",
+		in: `
+		openapi: "3.0.0"
+		info: version: "v1alpha1"
+		`,
+	}, {
+		name: "noVersion",
+		in: `
+		openapi: "3.0.0"
+		info: title: "Foo"
+		`,
+	}, {
+		name: "validJSONSchema",
+		in: `
+		$schema: "https://json-schema.org/schema#"
+		`,
+		out: build.JSONSchema,
+	}, {
+		name: "validJSONSchema",
+		in: `
+		$schema: "https://json-schema.org/draft-07/schema#"
+		`,
+		out: build.JSONSchema,
+	}, {
+		name: "noSchema",
+		in: `
+		$id: "https://acme.com/schema#"
+		`,
+	}, {
+		name: "wrongHost",
+		in: `
+		$schema: "https://acme.com/schema#"
+		`,
+	}, {
+		name: "invalidURL",
+		in: `
+		$schema: "://json-schema.org/draft-07"
+		`,
+	}, {
+		name: "invalidPath",
+		in: `
+		$schema: "https://json-schema.org/draft-07"
+		`,
+	}}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			var r cue.Runtime
+			inst, err := r.Compile(tc.name, tc.in)
+			if err != nil {
+				t.Fatal(err)
+			}
+			got := Detect(inst.Value())
+			if got != tc.out {
+				t.Errorf("got %v; want %v", got, tc.out)
+			}
+		})
+	}
+}
diff --git a/internal/encoding/encoding.go b/internal/encoding/encoding.go
index 83de8a3..14d579b 100644
--- a/internal/encoding/encoding.go
+++ b/internal/encoding/encoding.go
@@ -45,7 +45,7 @@
 	cfg       *Config
 	closer    io.Closer
 	next      func() (ast.Expr, error)
-	interpret func(*cue.Instance) (file *ast.File, id string, err error)
+	interpret interpretFunc
 	expr      ast.Expr
 	file      *ast.File
 	filename  string // may change on iteration for some formats
@@ -54,6 +54,8 @@
 	err       error
 }
 
+type interpretFunc func(*cue.Instance) (file *ast.File, id string, err error)
+
 // ID returns a canonical identifier for the decoded object or "" if no such
 // identifier could be found.
 func (i *Decoder) ID() string {
@@ -69,6 +71,7 @@
 		return
 	}
 	// Decoder level
+	i.file = nil
 	i.expr, i.err = i.next()
 	i.index++
 	if i.err != nil {
@@ -77,7 +80,8 @@
 	// Interpretations
 	if i.interpret != nil {
 		var r cue.Runtime
-		inst, err := r.CompileFile(i.File())
+		i.file = i.File()
+		inst, err := r.CompileFile(i.file)
 		if err != nil {
 			i.err = err
 			return
@@ -176,33 +180,22 @@
 
 	switch f.Interpretation {
 	case "":
+	case build.Auto:
+		openAPI := openAPIFunc(cfg, f)
+		jsonSchema := jsonSchemaFunc(cfg, f)
+		i.interpret = func(inst *cue.Instance) (file *ast.File, id string, err error) {
+			switch Detect(inst.Value()) {
+			case build.JSONSchema:
+				return jsonSchema(inst)
+			case build.OpenAPI:
+				return openAPI(inst)
+			}
+			return i.file, "", i.err
+		}
 	case build.OpenAPI:
-		i.interpret = func(i *cue.Instance) (file *ast.File, id string, err error) {
-			cfg := &openapi.Config{PkgName: cfg.PkgName}
-			file, err = simplify(openapi.Extract(i, cfg))
-			return file, "", err
-		}
+		i.interpret = openAPIFunc(cfg, f)
 	case build.JSONSchema:
-		i.interpret = func(i *cue.Instance) (file *ast.File, id string, err error) {
-			id = f.Tags["id"]
-			if id == "" {
-				id, _ = i.Lookup("$id").String()
-			}
-			if id != "" {
-				u, err := url.Parse(id)
-				if err != nil {
-					return nil, "", errors.Wrapf(err, token.NoPos, "invalid id")
-				}
-				u.Scheme = ""
-				id = strings.TrimPrefix(u.String(), "//")
-			}
-			cfg := &jsonschema.Config{
-				ID:      id,
-				PkgName: cfg.PkgName,
-			}
-			file, err = simplify(jsonschema.Extract(i, cfg))
-			return file, id, err
-		}
+		i.interpret = jsonSchemaFunc(cfg, f)
 	default:
 		i.err = fmt.Errorf("unsupported interpretation %q", f.Interpretation)
 	}
@@ -237,6 +230,37 @@
 	return i
 }
 
+func jsonSchemaFunc(cfg *Config, f *build.File) interpretFunc {
+	return func(i *cue.Instance) (file *ast.File, id string, err error) {
+		id = f.Tags["id"]
+		if id == "" {
+			id, _ = i.Lookup("$id").String()
+		}
+		if id != "" {
+			u, err := url.Parse(id)
+			if err != nil {
+				return nil, "", errors.Wrapf(err, token.NoPos, "invalid id")
+			}
+			u.Scheme = ""
+			id = strings.TrimPrefix(u.String(), "//")
+		}
+		cfg := &jsonschema.Config{
+			ID:      id,
+			PkgName: cfg.PkgName,
+		}
+		file, err = simplify(jsonschema.Extract(i, cfg))
+		return file, id, err
+	}
+}
+
+func openAPIFunc(c *Config, f *build.File) interpretFunc {
+	cfg := &openapi.Config{PkgName: c.PkgName}
+	return func(i *cue.Instance) (file *ast.File, id string, err error) {
+		file, err = simplify(openapi.Extract(i, cfg))
+		return file, "", err
+	}
+}
+
 func reader(f *build.File, stdin io.Reader) (io.ReadCloser, error) {
 	switch s := f.Source.(type) {
 	case nil: