asteris-llc/converge

View on GitHub
docs/extract.go

Summary

Maintainability
B
5 hrs
Test Coverage
// Copyright © 2016 Asteris, LLC
//
// 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 main

import (
    "bytes"
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
    "io/ioutil"
    "log"
    "strings"
    "text/template"

    "reflect"

    "github.com/spf13/pflag"
)

const durationComment = `
Acceptable formats are a number in seconds or a duration string. A Duration
represents the elapsed time between two instants as an int64 second count.
The representation limits the largest representable duration to approximately
290 years. A duration string is a possibly signed sequence of decimal numbers,
each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or
"2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
`

var (
    typeName      string
    fPath         string
    taskPath      string
    examplePath   string
    resourceName  string
    taskName      string
    stripDocLines int

    tmpl = template.Must(template.New("").Funcs(template.FuncMap{
        "fencedCode": fencedCode,
        "code":       func(x string) string { return "`" + x + "`" },
        "codeCommaJoin": func(items []string, terminal string) string {
            var out string
            if len(items) == 2 && terminal != "" {
                return fmt.Sprintf("`%s` %s `%s`", items[0], terminal, items[1])
            }
            for i, item := range items {
                out += "`" + item + "`"
                if i+1 != len(items) {
                    out += ", "
                }
                if i == len(items)-2 {
                    out += terminal + " "
                }
            }

            return out
        },
    }).Parse(`
{{.TopDoc}}

## Example

{{fencedCode .ExampleSource}}

## Parameters

Here are the HCL fields that you can specify, along with their expected types
and restrictions:

{{ range .Fields}}
- {{.Name}} ({{if .Required}}required {{end}}{{if ne .Base ""}}base {{.Base}} {{end}}{{.Type}})

{{ if .MutuallyExclusive}}
    Only one of {{codeCommaJoin .MutuallyExclusive "or"}} may be set.

{{end}}{{ if .ValidValues}}
    Valid values: {{codeCommaJoin .ValidValues "and"}}

{{end}}{{if ne .Doc ""}}  {{.Doc}}{{end}}
{{- end -}}
{{- if .HasExportedFields }}

## Exported Fields

Here are the fields that are exported for use with 'lookup'.  Re-exported fields
will have their own fields exported under the re-exported namespace.

{{range .GetExported}}
- {{.ExportedAs}} ({{.Type}})

{{if ne .Doc ""}}  {{.Doc}}{{end}} {{end}}
{{- range .GetReExported}}
- {{.ExportedAs}} re-exports fields from {{.Type}}

{{if ne .Doc ""}}  {{.Doc}}{{end}} {{end}} {{end}}
`))
)

func init() {
    pflag.StringVar(&typeName, "type", "", "type to extract and document")
    pflag.StringVar(&taskName, "task", "", "type of the resource to extract and document")
    pflag.StringVar(&fPath, "path", "", "source of Go file for extraction")
    pflag.StringVar(&taskPath, "task-path", "", "source file for the task for extraction")
    pflag.StringVar(&resourceName, "resource-name", "", "name to import resource in HCL source")
    pflag.StringVar(&examplePath, "example", "", "name of example file to include")
    pflag.IntVar(&stripDocLines, "strip-doc-lines", 0, "strip this many lines of docs from the type - so it doesn't all have to start with \"ModuleName blah blah...\"")

    pflag.Parse()
}

func main() {
    // read example file
    out, err := ioutil.ReadFile(examplePath)
    if err != nil {
        log.Fatal(err)
    }

    // read source file
    fset := token.NewFileSet()
    file, err := parser.ParseFile(
        fset,
        fPath,
        nil,
        parser.ParseComments,
    )
    if err != nil {
        log.Fatal(err)
    }

    extractor := &TypeExtractor{
        Target:        typeName,
        ExampleSource: string(out),
        ResourceName:  resourceName,
    }
    ast.Walk(extractor, file)

    if taskPath != "" && taskName != "" {
        fset = token.NewFileSet()
        file, err = parser.ParseFile(
            fset,
            taskPath,
            nil,
            parser.ParseComments,
        )

        if err != nil {
            log.Fatal(err)
        }

        exportedExtractor := &ExportExtractor{
            Target: taskName,
        }

        ast.Walk(exportedExtractor, file)
        extractor.ExportedFields = exportedExtractor
    }

    fmt.Println(extractor)
}

// Field represents a documentation field
type Field struct {
    Name              string
    Type              string
    Doc               string
    Required          bool
    Base              string
    MutuallyExclusive []string
    ValidValues       []string
    ExportedAs        string
}

// ExportExtractor handles exported data docs
type ExportExtractor struct {
    Target           string
    Doc              string
    ExportedFields   []*Field
    ReExportedFields []*Field
}

// Visit visits a node and logs exported fields
func (e *ExportExtractor) Visit(node ast.Node) (w ast.Visitor) {
    switch n := node.(type) {
    case *ast.File, *ast.TypeSpec, *ast.FieldList:
        return e
    case *ast.StructType:
        if n.Fields == nil && n.Incomplete {
            return nil
        }
        return e
    case *ast.GenDecl:
        spec, ok := n.Specs[0].(*ast.TypeSpec)
        if !ok {
            return nil
        }

        if spec.Name.Name != e.Target {
            return nil
        }
        return e
    case *ast.Field:
        if n.Names == nil || !ast.IsExported(n.Names[0].String()) {
            return e
        }

        typ := stringify(n.Type, "")
        doc := (&TypeExtractor{}).Docs(n.Doc, n.Comment)

        field := &Field{
            Name: n.Names[0].String(),
            Type: typ,
            Doc:  doc,
        }
        if n.Tag != nil {
            tag := reflect.StructTag(strings.Trim(n.Tag.Value, "`"))
            if export, ok := tag.Lookup("export"); ok {
                field.ExportedAs = export
                field.ExportedAs = fmt.Sprintf("`%s`", strings.SplitN(export, ",", 1)[0])
                e.ExportedFields = append(e.ExportedFields, field)
            } else if export, ok := tag.Lookup("re-export-as"); ok {
                field.ExportedAs = fmt.Sprintf("`%s`", strings.SplitN(export, ",", 1)[0])
                e.ReExportedFields = append(e.ReExportedFields, field)
            }
        }
        return e
    default:
        return nil
    }
}

// TypeExtractor extracts documentation information
type TypeExtractor struct {
    Target string

    // information we get from the source
    TopDoc string
    Fields []*Field

    // information we get externally (from flags)
    ExampleSource string
    ResourceName  string

    // information about exported fields
    ExportedFields *ExportExtractor
}

// HasExportedFields returns true if any fields are exported
func (te *TypeExtractor) HasExportedFields() bool {
    return te.ExportedFields != nil
}

// GetExported gets the exported fields
func (te *TypeExtractor) GetExported() []*Field {
    return te.ExportedFields.ExportedFields
}

// GetReExported gets the re-exported fields
func (te *TypeExtractor) GetReExported() []*Field {
    return te.ExportedFields.ReExportedFields
}

// Visit inspects each node in the Ast and returns a Visitor
func (te *TypeExtractor) Visit(node ast.Node) (w ast.Visitor) {
    if node == nil {
        return nil
    }

    switch n := node.(type) {

    case *ast.File:
        // don't care, but recurse
        return te

    case *ast.GenDecl:
        spec, ok := n.Specs[0].(*ast.TypeSpec)
        if !ok {
            return nil
        }

        if spec.Name.Name != te.Target {
            return nil
        }

        te.TopDoc = stripLines(te.Docs(n.Doc, spec.Doc, spec.Comment))

        return te

    case *ast.TypeSpec:
        // we've taken care of this as a field in the *ast.GenDecl case, just recurse here
        return te

    case *ast.StructType:
        if n.Fields == nil && n.Incomplete {
            return nil
        }

        return te

    case *ast.FieldList:
        // recurse to walk over the fields
        return te

    case *ast.Field:
        if !ast.IsExported(n.Names[0].String()) {
            return te
        }

        typ := stringify(n.Type, "optional")
        doc := te.Docs(n.Doc, n.Comment)
        if strings.Contains(typ, "duration") {
            doc += durationComment
        }
        field := &Field{
            Name: n.Names[0].String(),
            Type: typ,
            Doc:  doc,
        }

        if n.Tag != nil {
            tag := reflect.StructTag(strings.Trim(n.Tag.Value, "`"))
            if hcl, ok := tag.Lookup("hcl"); ok {
                field.Name = fmt.Sprintf("`%s`", strings.SplitN(hcl, ",", 1)[0])
            }

            if docType, ok := tag.Lookup("doc_type"); ok {
                field.Type = docType
            }

            if base, ok := tag.Lookup("base"); ok {
                field.Base = base
            }

            if required, ok := tag.Lookup("required"); ok && required == "true" {
                field.Required = true
            }

            if mutuallyexclusive, ok := tag.Lookup("mutually_exclusive"); ok {
                field.MutuallyExclusive = strings.Split(mutuallyexclusive, ",")
            }

            if validvalues, ok := tag.Lookup("valid_values"); ok {
                field.ValidValues = strings.Split(validvalues, ",")
            }
        }

        te.Fields = append(te.Fields, field)

        return nil

    default:
        return nil
    }
}

// Docs generates a documentation string
func (*TypeExtractor) Docs(gs ...*ast.CommentGroup) string {
    var out []string
    for _, g := range gs {
        if g != nil {
            out = append(out, g.Text())
        }
    }

    return strings.Join(out, "\n\n")
}

func (te *TypeExtractor) String() string {
    var out bytes.Buffer

    err := tmpl.Execute(&out, te)
    if err != nil {
        log.Fatal(err)
    }

    return out.String()
}

func stripLines(doc string) string {
    lines := strings.Split(doc, "\n")
    return strings.Join(lines[stripDocLines:], "\n")
}

func fencedCode(in string) string {
    return fmt.Sprintf("```hcl\n%s\n```\n", in)
}

// stringify a type expression to a human-readable format.  The pointersAs
// string will be used to prefix a pointer type.
func stringify(node ast.Expr, pointersAs string) string {
    switch n := node.(type) {
    case *ast.Ident:
        return n.Name

    case *ast.ArrayType:
        return fmt.Sprintf("list of %ss", stringify(n.Elt, pointersAs))

    case *ast.MapType:
        return fmt.Sprintf("map of %s to %s", stringify(n.Key, pointersAs), stringify(n.Value, pointersAs))

    case *ast.InterfaceType:
        return "anything"

    case *ast.StarExpr:
        if pointersAs == "" {
            return fmt.Sprintf("%s", stringify(n.X, pointersAs))
        }
        return fmt.Sprintf("%s %s", pointersAs, stringify(n.X, pointersAs))

    case *ast.SelectorExpr:
        selExp := fmt.Sprintf("%s.%s", stringify(n.X, pointersAs), stringify(n.Sel, pointersAs))
        switch selExp {
        case "time.Duration":
            return "duration"
        case "pkg.State":
            return "State"
        case "resource.Value":
            return "anything"
        default:
            return selExp
        }

    default:
        return fmt.Sprintf("%T", n)
    }
}