From 8ae82b2f884caa1e49a067670b2420b38703019e Mon Sep 17 00:00:00 2001
From: Alex Clemmer <clemmer.alexander@gmail.com>
Date: Tue, 7 Nov 2017 15:16:50 -0800
Subject: [PATCH] Add ability to retrieve prototypes from vendor/

---
 cmd/prototype.go              |  63 +++++++++++--
 metadata/app/schema.go        |   1 +
 metadata/interface.go         |   3 +-
 metadata/registry.go          |  47 ++++++++++
 metadata/registry_managers.go |   3 +-
 prototype/specification.go    | 165 ++++++++++++++++++++++++++++++++++
 6 files changed, 274 insertions(+), 8 deletions(-)

diff --git a/cmd/prototype.go b/cmd/prototype.go
index d8ef245f..8519e2d9 100644
--- a/cmd/prototype.go
+++ b/cmd/prototype.go
@@ -92,7 +92,23 @@ var prototypeListCmd = &cobra.Command{
 			return fmt.Errorf("Command 'prototype list' does not take any arguments")
 		}
 
-		index := prototype.NewIndex([]*prototype.SpecificationSchema{})
+		cwd, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+		wd := metadata.AbsPath(cwd)
+
+		manager, err := metadata.Find(wd)
+		if err != nil {
+			return err
+		}
+
+		extProtos, err := manager.GetAllPrototypes()
+		if err != nil {
+			return err
+		}
+
+		index := prototype.NewIndex(extProtos)
 		protos, err := index.List()
 		if err != nil {
 			return err
@@ -115,9 +131,24 @@ var prototypeDescribeCmd = &cobra.Command{
 			return fmt.Errorf("Command 'prototype describe' requires a prototype name\n\n%s", cmd.UsageString())
 		}
 
+		cwd, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+		wd := metadata.AbsPath(cwd)
+
+		extProtos := prototype.SpecificationSchemas{}
+		manager, err := metadata.Find(wd)
+		if err == nil {
+			extProtos, err = manager.GetAllPrototypes()
+			if err != nil {
+				return err
+			}
+		}
+
 		query := args[0]
 
-		proto, err := fundUniquePrototype(query)
+		proto, err := fundUniquePrototype(query, extProtos)
 		if err != nil {
 			return err
 		}
@@ -199,9 +230,24 @@ var prototypePreviewCmd = &cobra.Command{
 			return fmt.Errorf("Command 'prototype preview' requires a prototype name\n\n%s", cmd.UsageString())
 		}
 
+		cwd, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+		wd := metadata.AbsPath(cwd)
+
+		extProtos := prototype.SpecificationSchemas{}
+		manager, err := metadata.Find(wd)
+		if err == nil {
+			extProtos, err = manager.GetAllPrototypes()
+			if err != nil {
+				return err
+			}
+		}
+
 		query := rawArgs[0]
 
-		proto, err := fundUniquePrototype(query)
+		proto, err := fundUniquePrototype(query, extProtos)
 		if err != nil {
 			return err
 		}
@@ -295,13 +341,18 @@ var prototypeUseCmd = &cobra.Command{
 			return fmt.Errorf("Command can only be run in a ksonnet application directory:\n\n%v", err)
 		}
 
+		extProtos, err := manager.GetAllPrototypes()
+		if err != nil {
+			return err
+		}
+
 		if len(rawArgs) < 1 {
 			return fmt.Errorf("Command requires a prototype name\n\n%s", cmd.UsageString())
 		}
 
 		query := rawArgs[0]
 
-		proto, err := fundUniquePrototype(query)
+		proto, err := fundUniquePrototype(query, extProtos)
 		if err != nil {
 			return err
 		}
@@ -455,8 +506,8 @@ func getParameters(proto *prototype.SpecificationSchema, flags *pflag.FlagSet) (
 	return values, nil
 }
 
-func fundUniquePrototype(query string) (*prototype.SpecificationSchema, error) {
-	index := prototype.NewIndex([]*prototype.SpecificationSchema{})
+func fundUniquePrototype(query string, extProtos prototype.SpecificationSchemas) (*prototype.SpecificationSchema, error) {
+	index := prototype.NewIndex(extProtos)
 
 	suffixProtos, err := index.SearchNames(query, prototype.Suffix)
 	if err != nil {
diff --git a/metadata/app/schema.go b/metadata/app/schema.go
index 3dbbf762..e02de24a 100644
--- a/metadata/app/schema.go
+++ b/metadata/app/schema.go
@@ -89,6 +89,7 @@ type RegistryRefSpecs map[string]*RegistryRefSpec
 
 type LibraryRefSpec struct {
 	Name       string          `json:"name"`
+	Registry   string          `json:"registry"`
 	GitVersion *GitVersionSpec `json:"gitVersion"`
 }
 
diff --git a/metadata/interface.go b/metadata/interface.go
index 1a4a88f7..9cee04be 100644
--- a/metadata/interface.go
+++ b/metadata/interface.go
@@ -72,10 +72,11 @@ type Manager interface {
 	// Spec API.
 	AppSpec() (*app.Spec, error)
 
-	// Registry API.
+	// Dependency/registry API.
 	AddRegistry(name, protocol, uri, version string) (*registry.Spec, error)
 	GetRegistry(name string) (*registry.Spec, string, error)
 	CacheDependency(registryName, libID, libName, libVersion string) (*parts.Spec, error)
+	GetAllPrototypes() (prototype.SpecificationSchemas, error)
 }
 
 // Find will recursively search the current directory and its parents for a
diff --git a/metadata/registry.go b/metadata/registry.go
index 08dbd9e6..f0ab341a 100644
--- a/metadata/registry.go
+++ b/metadata/registry.go
@@ -3,10 +3,13 @@ package metadata
 import (
 	"encoding/json"
 	"fmt"
+	"os"
+	"path/filepath"
 
 	"github.com/ksonnet/ksonnet/metadata/app"
 	"github.com/ksonnet/ksonnet/metadata/parts"
 	"github.com/ksonnet/ksonnet/metadata/registry"
+	"github.com/ksonnet/ksonnet/prototype"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/afero"
 )
@@ -143,6 +146,50 @@ func (m *manager) CacheDependency(registryName, libID, libName, libVersion strin
 	return parts, nil
 }
 
+func (m *manager) GetAllPrototypes() (prototype.SpecificationSchemas, error) {
+	appSpec, err := m.AppSpec()
+	if err != nil {
+		return nil, err
+	}
+
+	specs := prototype.SpecificationSchemas{}
+	for _, lib := range appSpec.Libraries {
+		protos := string(appendToAbsPath(m.vendorPath, lib.Registry, lib.Name, "prototypes"))
+		exists, err := afero.DirExists(m.appFS, protos)
+		if err != nil {
+			return nil, err
+		} else if !exists {
+			return prototype.SpecificationSchemas{}, nil // No prototypes to report.
+		}
+
+		err = afero.Walk(
+			m.appFS,
+			protos,
+			func(path string, info os.FileInfo, err error) error {
+				if info.IsDir() || filepath.Ext(path) != ".jsonnet" {
+					return nil
+				}
+
+				protoJsonnet, err := afero.ReadFile(m.appFS, path)
+				if err != nil {
+					return err
+				}
+
+				protoSpec, err := prototype.FromJsonnet(string(protoJsonnet))
+				if err != nil {
+					return err
+				}
+				specs = append(specs, protoSpec)
+				return nil
+			})
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return specs, nil
+}
+
 func (m *manager) registryDir(regManager registry.Manager) AbsPath {
 	return appendToAbsPath(m.registriesPath, regManager.RegistrySpecDir())
 }
diff --git a/metadata/registry_managers.go b/metadata/registry_managers.go
index ff73831a..438d3ed0 100644
--- a/metadata/registry_managers.go
+++ b/metadata/registry_managers.go
@@ -153,7 +153,8 @@ func (gh *gitHubRegistryManager) ResolveLibrary(libID, libAlias, libRefSpec stri
 	json.Unmarshal([]byte(partsSpecText), &parts)
 
 	refSpec := app.LibraryRefSpec{
-		Name: libAlias,
+		Name:     libAlias,
+		Registry: gh.Name,
 		GitVersion: &app.GitVersionSpec{
 			RefSpec:   libRefSpec,
 			CommitSHA: resolvedSHA,
diff --git a/prototype/specification.go b/prototype/specification.go
index 13e5cd7b..93fe78ab 100644
--- a/prototype/specification.go
+++ b/prototype/specification.go
@@ -1,6 +1,7 @@
 package prototype
 
 import (
+	"bytes"
 	"fmt"
 	"sort"
 	"strconv"
@@ -13,6 +14,108 @@ import (
 // convenient to simply expose all of them.
 //
 
+const (
+	apiVersionTag  = "@apiVersion"
+	nameTag        = "@name"
+	descriptionTag = "@description"
+	paramTag       = "@param"
+)
+
+func FromJsonnet(data string) (*SpecificationSchema, error) {
+	// Get comment block at the top of the file.
+	seenCommentLine := false
+	commentBlock := []string{}
+	text := strings.Split(data, "\n")
+	lastCommentLine := 0
+	for i, line := range text {
+		const commentPrefix = "// "
+		line = strings.TrimSpace(line)
+
+		// Skip blank lines
+		if line == "" && !seenCommentLine {
+			continue
+		}
+
+		if !strings.HasPrefix(line, "//") {
+			lastCommentLine = i
+			break
+		}
+
+		seenCommentLine = true
+
+		// Reject comments formatted like this, with no space between '//'
+		// and the first word:
+		//
+		//   //Foo
+		//
+		// But not the empty comment line:
+		//
+		//   //
+		//
+		// Also, trim any leading space between the '//' characters and
+		// the first word, and place that in `commentBlock`.
+		if !strings.HasPrefix(line, commentPrefix) {
+			if len(line) > 3 {
+				return nil, fmt.Errorf("Prototype heading comments are required to have a space after the '//' that begins the line")
+			}
+			commentBlock = append(commentBlock, strings.TrimPrefix(line, "//"))
+		} else {
+			commentBlock = append(commentBlock, strings.TrimPrefix(line, commentPrefix))
+		}
+	}
+
+	// Parse the prototypeInfo from the heading comment block.
+	pinfo := SpecificationSchema{}
+	pinfo.Template.JsonnetBody = text[lastCommentLine+1:]
+	firstPass := true
+	openTag := ""
+	var openText bytes.Buffer
+	for _, line := range commentBlock {
+		split := strings.SplitN(line, " ", 2)
+		if len(split) < 1 {
+			continue
+		}
+
+		if len(line) == 0 || strings.HasPrefix(line, " ") {
+			if openTag == "" {
+				return nil, fmt.Errorf("Free text is not allowed in heading comment of prototype spec, all text must be in a field. The line of the error:\n'%s'", line)
+			}
+			openText.WriteString(strings.TrimSpace(line) + "\n")
+			continue
+		} else if len(split) < 2 {
+			return nil, fmt.Errorf("Invalid field '%s', fields must have a non-whitespace value", line)
+		}
+
+		if err := pinfo.addField(openTag, openText.String()); !firstPass && err != nil {
+			return nil, err
+		}
+		openTag = split[0]
+		openText = bytes.Buffer{}
+		openText.WriteString(strings.TrimSpace(split[1]))
+		switch split[0] {
+		case apiVersionTag, nameTag, descriptionTag, paramTag: // Do nothing.
+		default:
+			return nil, fmt.Errorf(`Line in prototype heading comment is formatted incorrectly; '%s' is not
+recognized as a tag. Only tags can begin lines, and text that is wrapped must
+be indented. For example:
+  // @description This is a long description
+  //   that we are wrapping on two lines`, split[0])
+		}
+
+		firstPass = false
+	}
+
+	if err := pinfo.addField(openTag, openText.String()); !firstPass && err != nil {
+		return nil, err
+	}
+
+	if pinfo.Name == "" || pinfo.Template.JsonnetBody == nil || pinfo.Template.Description == "" {
+		return nil, fmt.Errorf("Invalid prototype specification, all fields are required. Object:\n%v", pinfo)
+	}
+
+	return &pinfo, nil
+}
+
 // SpecificationSchema is the JSON-serializable representation of a prototype
 // specification.
 type SpecificationSchema struct {
@@ -85,6 +188,51 @@ func (s *SpecificationSchema) RequiredParams() ParamSchemas {
 	return reqd
 }
 
+func (s *SpecificationSchema) addField(tag, text string) error {
+	switch tag {
+	case apiVersionTag:
+	case nameTag:
+		if s.Name != "" {
+			return fmt.Errorf("Prototype heading comment has two '@name' fields")
+		}
+		s.Name = text
+	case descriptionTag:
+		if s.Template.Description != "" {
+			return fmt.Errorf("Prototype heading comment has two '@description' fields")
+		}
+		s.Template.Description = text
+	case paramTag:
+		// NOTE: There is usually more than one `@param`, so we don't
+		// check length here.
+
+		split := strings.SplitN(text, " ", 3)
+		if len(split) < 3 {
+			return fmt.Errorf("Param fields must have '<name> <type> <description>, but got:\n%s", text)
+		}
+
+		pt, err := parseParamType(split[1])
+		if err != nil {
+			return err
+		}
+
+		s.Params = append(s.Params, &ParamSchema{
+			Name:        split[0],
+			Alias:       &split[0],
+			Description: split[2],
+			Default:     nil,
+			Type:        pt,
+		})
+	default:
+		return fmt.Errorf(`Line in prototype heading comment is formatted incorrectly; '%s' is not
+recognized as a tag. Only tags can begin lines, and text that is wrapped must
+be indented. For example:
+// @description This is a long description
+//   that we are wrapping on two lines`, tag)
+	}
+
+	return nil
+}
+
 // OptionalParams retrieves all parameters that can optionally be provided to a
 // prototype.
 func (s *SpecificationSchema) OptionalParams() ParamSchemas {
@@ -207,6 +355,23 @@ const (
 	Array ParamType = "array"
 )
 
+func parseParamType(t string) (ParamType, error) {
+	switch t {
+	case "number":
+		return Number, nil
+	case "string":
+		return String, nil
+	case "numberOrString":
+		return NumberOrString, nil
+	case "object":
+		return Object, nil
+	case "array":
+		return Array, nil
+	default:
+		return "", fmt.Errorf("Unknown param type '%s'", t)
+	}
+}
+
 func (pt ParamType) String() string {
 	switch pt {
 	case Number:
-- 
GitLab