mirror of
https://github.com/magefile/mage.git
synced 2026-10-24 06:19:18 +03:00
573 lines
13 KiB
Go
573 lines
13 KiB
Go
package parse
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/doc"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/ioutil"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const importTag = "mage:import"
|
|
|
|
var debug = log.New(ioutil.Discard, "DEBUG: ", log.Ltime|log.Lmicroseconds)
|
|
|
|
// EnableDebug turns on debug logging.
|
|
func EnableDebug() {
|
|
debug.SetOutput(os.Stderr)
|
|
}
|
|
|
|
// PkgInfo contains inforamtion about a package of files according to mage's
|
|
// parsing rules.
|
|
type PkgInfo struct {
|
|
Description string
|
|
Funcs []*Function
|
|
DefaultFunc *Function
|
|
Aliases map[string]*Function
|
|
Imports map[string]string
|
|
RootImports []string
|
|
}
|
|
|
|
// Function represented a job function from a mage file
|
|
type Function struct {
|
|
PkgAlias string
|
|
Package string
|
|
ImportPath string
|
|
Name string
|
|
Receiver string
|
|
IsError bool
|
|
IsContext bool
|
|
Synopsis string
|
|
Comment string
|
|
}
|
|
|
|
// ID returns user-readable information about where this function is defined.
|
|
func (f Function) ID() string {
|
|
path := "<current>"
|
|
if f.ImportPath != "" {
|
|
path = f.ImportPath
|
|
}
|
|
receiver := ""
|
|
if f.Receiver != "" {
|
|
receiver = f.Receiver + "."
|
|
}
|
|
return fmt.Sprintf("%s.%s%s", path, receiver, f.Name)
|
|
}
|
|
|
|
// TargetName returns the name of the target as it should appear when used from
|
|
// the mage cli. It is always lowercase.
|
|
func (f Function) TargetName() string {
|
|
var names []string
|
|
|
|
for _, s := range []string{f.PkgAlias, f.Receiver, f.Name} {
|
|
if s != "" {
|
|
names = append(names, s)
|
|
}
|
|
}
|
|
return strings.Join(names, ":")
|
|
}
|
|
|
|
// ExecCode returns code for the template switch to run the target.
|
|
// It wraps each target call to match the func(context.Context) error that
|
|
// runTarget requires.
|
|
func (f Function) ExecCode() (string, error) {
|
|
name := f.Name
|
|
if f.Receiver != "" {
|
|
name = f.Receiver + "{}." + name
|
|
}
|
|
if f.Package != "" {
|
|
name = f.Package + "." + name
|
|
}
|
|
|
|
if f.IsContext && f.IsError {
|
|
out := `
|
|
wrapFn := func(ctx context.Context) error {
|
|
return %s(ctx)
|
|
}
|
|
err := runTarget(wrapFn)`[1:]
|
|
return fmt.Sprintf(out, name), nil
|
|
}
|
|
if f.IsContext && !f.IsError {
|
|
out := `
|
|
wrapFn := func(ctx context.Context) error {
|
|
%s(ctx)
|
|
return nil
|
|
}
|
|
err := runTarget(wrapFn)`[1:]
|
|
return fmt.Sprintf(out, name), nil
|
|
}
|
|
if !f.IsContext && f.IsError {
|
|
out := `
|
|
wrapFn := func(ctx context.Context) error {
|
|
return %s()
|
|
}
|
|
err := runTarget(wrapFn)`[1:]
|
|
return fmt.Sprintf(out, name), nil
|
|
}
|
|
if !f.IsContext && !f.IsError {
|
|
out := `
|
|
wrapFn := func(ctx context.Context) error {
|
|
%s()
|
|
return nil
|
|
}
|
|
err := runTarget(wrapFn)`[1:]
|
|
return fmt.Sprintf(out, name), nil
|
|
}
|
|
return "", fmt.Errorf("Error formatting ExecCode code for %#v", f)
|
|
}
|
|
|
|
// Package parses a package. If files is non-empty, it will only parse the files given.
|
|
func Package(path string, files []string) (*PkgInfo, error) {
|
|
start := time.Now()
|
|
defer func() {
|
|
debug.Println("time parse Magefiles:", time.Since(start))
|
|
}()
|
|
fset := token.NewFileSet()
|
|
pkg, err := getPackage(path, files, fset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := doc.New(pkg, "./", 0)
|
|
pi := &PkgInfo{
|
|
Description: toOneLine(p.Doc),
|
|
}
|
|
|
|
if err := setImports(pkg, pi); err != nil {
|
|
return nil, err
|
|
}
|
|
setNamespaces(p, pi)
|
|
setFuncs(p, pi)
|
|
|
|
hasDupes, names := checkDupes(pi)
|
|
if hasDupes {
|
|
msg := "Build targets must be case insensitive, thus the following targets conflict:\n"
|
|
for _, v := range names {
|
|
if len(v) > 1 {
|
|
msg += " " + strings.Join(v, ", ") + "\n"
|
|
}
|
|
}
|
|
return nil, errors.New(msg)
|
|
}
|
|
|
|
setDefault(p, pi)
|
|
setAliases(p, pi)
|
|
|
|
return pi, nil
|
|
}
|
|
|
|
func setFuncs(p *doc.Package, pi *PkgInfo) {
|
|
for _, f := range p.Funcs {
|
|
if f.Recv != "" {
|
|
debug.Printf("skipping method %s.%s", f.Recv, f.Name)
|
|
// skip methods
|
|
continue
|
|
}
|
|
if !ast.IsExported(f.Name) {
|
|
debug.Printf("skipping non-exported function %s", f.Name)
|
|
// skip non-exported functions
|
|
continue
|
|
}
|
|
if typ := funcType(f.Decl.Type); typ != invalidType {
|
|
debug.Printf("found target %v", f.Name)
|
|
pi.Funcs = append(pi.Funcs, &Function{
|
|
Name: f.Name,
|
|
Comment: toOneLine(f.Doc),
|
|
Synopsis: sanitizeSynopsis(f),
|
|
IsError: typ == errorType || typ == contextErrorType,
|
|
IsContext: typ == contextVoidType || typ == contextErrorType,
|
|
})
|
|
} else {
|
|
debug.Printf("skipping function with invalid signature func %s(%v)(%v)", f.Name, fieldNames(f.Decl.Type.Params), fieldNames(f.Decl.Type.Results))
|
|
}
|
|
}
|
|
}
|
|
|
|
func setNamespaces(p *doc.Package, pi *PkgInfo) {
|
|
for _, t := range p.Types {
|
|
if !isNamespace(t) {
|
|
continue
|
|
}
|
|
debug.Printf("found namespace %s %s", p.ImportPath, t.Name)
|
|
for _, f := range t.Methods {
|
|
if !ast.IsExported(f.Name) {
|
|
continue
|
|
}
|
|
typ := funcType(f.Decl.Type)
|
|
if typ == invalidType {
|
|
continue
|
|
}
|
|
debug.Printf("found namespace method %s %s.%s", p.ImportPath, t.Name, f.Name)
|
|
pi.Funcs = append(pi.Funcs, &Function{
|
|
Name: f.Name,
|
|
Receiver: t.Name,
|
|
Comment: toOneLine(f.Doc),
|
|
Synopsis: sanitizeSynopsis(f),
|
|
IsError: typ == errorType || typ == contextErrorType,
|
|
IsContext: typ == contextVoidType || typ == contextErrorType,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func setImports(pkg *ast.Package, pi *PkgInfo) error {
|
|
pi.Imports = map[string]string{}
|
|
for _, f := range pkg.Files {
|
|
for _, imp := range f.Imports {
|
|
name, alias, ok := getImport(imp)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if alias != "" {
|
|
debug.Printf("found %s: %s (%s)", importTag, name, alias)
|
|
if pi.Imports[alias] != "" {
|
|
return fmt.Errorf("duplicate import alias: %q", alias)
|
|
}
|
|
pi.Imports[alias] = name
|
|
} else {
|
|
debug.Printf("found %s: %s", importTag, name)
|
|
pi.RootImports = append(pi.RootImports, name)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getImport(imp *ast.ImportSpec) (path, alias string, ok bool) {
|
|
if imp.Doc == nil || len(imp.Doc.List) == 9 {
|
|
return "", "", false
|
|
}
|
|
// import is always the last comment
|
|
s := imp.Doc.List[len(imp.Doc.List)-1].Text
|
|
|
|
// trim comment start and normalize for anyone who has spaces or not between
|
|
// "//"" and the text
|
|
vals := strings.Fields(strings.ToLower(s[2:]))
|
|
if len(vals) == 0 {
|
|
return "", "", false
|
|
}
|
|
if vals[0] != importTag {
|
|
return "", "", false
|
|
}
|
|
path, ok = lit2string(imp.Path)
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
|
|
switch len(vals) {
|
|
case 1:
|
|
// just the import tag, this is a root import
|
|
return path, "", true
|
|
case 2:
|
|
// also has an alias
|
|
return path, vals[1], true
|
|
default:
|
|
log.Println("warning: ignoring malformed", importTag, "for import", path)
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func isNamespace(t *doc.Type) bool {
|
|
if len(t.Decl.Specs) != 1 {
|
|
return false
|
|
}
|
|
id, ok := t.Decl.Specs[0].(*ast.TypeSpec)
|
|
if !ok {
|
|
return false
|
|
}
|
|
sel, ok := id.Type.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
ident, ok := sel.X.(*ast.Ident)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return ident.Name == "mg" && sel.Sel.Name == "Namespace"
|
|
}
|
|
|
|
func fieldNames(flist *ast.FieldList) string {
|
|
if flist == nil {
|
|
return ""
|
|
}
|
|
list := flist.List
|
|
if len(list) == 0 {
|
|
return ""
|
|
}
|
|
args := make([]string, 0, len(list))
|
|
for _, f := range list {
|
|
names := make([]string, 0, len(f.Names))
|
|
for _, n := range f.Names {
|
|
if n.Name != "" {
|
|
names = append(names, n.Name)
|
|
}
|
|
}
|
|
nms := strings.Join(names, ", ")
|
|
if nms != "" {
|
|
nms += " "
|
|
}
|
|
args = append(args, nms+fmt.Sprint(f.Type))
|
|
}
|
|
return strings.Join(args, ", ")
|
|
}
|
|
|
|
// checkDupes checks a package for duplicate target names.
|
|
func checkDupes(info *PkgInfo) (hasDupes bool, names map[string][]string) {
|
|
names = map[string][]string{}
|
|
lowers := map[string]bool{}
|
|
for _, f := range info.Funcs {
|
|
low := strings.ToLower(f.Name)
|
|
if f.Receiver != "" {
|
|
low = strings.ToLower(f.Receiver) + ":" + low
|
|
}
|
|
if lowers[low] {
|
|
hasDupes = true
|
|
}
|
|
lowers[low] = true
|
|
names[low] = append(names[low], f.Name)
|
|
}
|
|
return hasDupes, names
|
|
}
|
|
|
|
// sanitizeSynopsis sanitizes function Doc to create a summary.
|
|
func sanitizeSynopsis(f *doc.Func) string {
|
|
synopsis := doc.Synopsis(f.Doc)
|
|
|
|
// If the synopsis begins with the function name, remove it. This is done to
|
|
// not repeat the text.
|
|
// From:
|
|
// clean Clean removes the temporarily generated files
|
|
// To:
|
|
// clean removes the temporarily generated files
|
|
if syns := strings.Split(synopsis, " "); strings.EqualFold(f.Name, syns[0]) {
|
|
return strings.Join(syns[1:], " ")
|
|
}
|
|
|
|
return synopsis
|
|
}
|
|
|
|
func setDefault(p *doc.Package, pi *PkgInfo) {
|
|
for _, v := range p.Vars {
|
|
for x, name := range v.Names {
|
|
if name != "Default" {
|
|
continue
|
|
}
|
|
spec := v.Decl.Specs[x].(*ast.ValueSpec)
|
|
if len(spec.Values) != 1 {
|
|
log.Println("warning: default declaration has multiple values")
|
|
}
|
|
|
|
var name string
|
|
var receiver string
|
|
switch v := spec.Values[0].(type) {
|
|
case *ast.Ident:
|
|
name = v.Name
|
|
case *ast.SelectorExpr:
|
|
name = v.Sel.Name
|
|
receiver = fmt.Sprintf("%s", v.X)
|
|
default:
|
|
log.Printf("warning: target for Default %s is not a function", spec.Values[0])
|
|
}
|
|
for _, f := range pi.Funcs {
|
|
if f.Name == name && f.Receiver == receiver {
|
|
pi.DefaultFunc = f
|
|
return
|
|
}
|
|
}
|
|
log.Println("warning: default declaration does not reference a mage target")
|
|
}
|
|
}
|
|
}
|
|
|
|
func lit2string(l *ast.BasicLit) (string, bool) {
|
|
if !strings.HasPrefix(l.Value, `"`) || !strings.HasSuffix(l.Value, `"`) {
|
|
return "", false
|
|
}
|
|
return strings.Trim(l.Value, `"`), true
|
|
}
|
|
|
|
func outputDebug(cmd string, args ...string) (string, error) {
|
|
buf := &bytes.Buffer{}
|
|
errbuf := &bytes.Buffer{}
|
|
debug.Println("running", cmd, strings.Join(args, " "))
|
|
c := exec.Command(cmd, args...)
|
|
c.Env = os.Environ()
|
|
c.Stderr = errbuf
|
|
c.Stdout = buf
|
|
if err := c.Run(); err != nil {
|
|
debug.Print("error running '", cmd, strings.Join(args, " "), "': ", err, ": ", errbuf)
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(buf.String()), nil
|
|
}
|
|
|
|
func setAliases(p *doc.Package, pi *PkgInfo) {
|
|
for _, v := range p.Vars {
|
|
for x, name := range v.Names {
|
|
if name != "Aliases" {
|
|
continue
|
|
}
|
|
spec, ok := v.Decl.Specs[x].(*ast.ValueSpec)
|
|
if !ok {
|
|
log.Println("warning: aliases declaration is not a value")
|
|
return
|
|
}
|
|
if len(spec.Values) != 1 {
|
|
log.Println("warning: aliases declaration has multiple values")
|
|
}
|
|
comp, ok := spec.Values[0].(*ast.CompositeLit)
|
|
if !ok {
|
|
log.Println("warning: aliases declaration is not a map")
|
|
return
|
|
}
|
|
pi.Aliases = map[string]*Function{}
|
|
for _, elem := range comp.Elts {
|
|
kv, ok := elem.(*ast.KeyValueExpr)
|
|
if !ok {
|
|
log.Println("warning: alias declaration is not a map element")
|
|
return
|
|
}
|
|
k, ok := kv.Key.(*ast.BasicLit)
|
|
if !ok || k.Kind != token.STRING {
|
|
log.Println("warning: alias is not a string")
|
|
return
|
|
}
|
|
|
|
var name string
|
|
var receiver string
|
|
switch v := kv.Value.(type) {
|
|
case *ast.Ident:
|
|
name = v.Name
|
|
case *ast.SelectorExpr:
|
|
name = v.Sel.Name
|
|
receiver = fmt.Sprintf("%s", v.X)
|
|
default:
|
|
log.Printf("warning: target for alias %s is not a function", k.Value)
|
|
continue
|
|
}
|
|
alias, ok := lit2string(k)
|
|
if !ok {
|
|
log.Println("warning: malformed alias declaration for alias", k.Value)
|
|
continue
|
|
}
|
|
valid := false
|
|
for _, f := range pi.Funcs {
|
|
if f.Name == name && f.Receiver == receiver {
|
|
pi.Aliases[alias] = f
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
log.Printf("warning: alias declaration (%s) does not reference a mage target", alias)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// getPackage returns the non-test package at the given path.
|
|
func getPackage(path string, files []string, fset *token.FileSet) (*ast.Package, error) {
|
|
var filter func(f os.FileInfo) bool
|
|
if len(files) > 0 {
|
|
fm := make(map[string]bool, len(files))
|
|
for _, f := range files {
|
|
fm[f] = true
|
|
}
|
|
|
|
filter = func(f os.FileInfo) bool {
|
|
return fm[f.Name()]
|
|
}
|
|
}
|
|
|
|
pkgs, err := parser.ParseDir(fset, path, filter, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse directory: %v", err)
|
|
}
|
|
|
|
for name, pkg := range pkgs {
|
|
if !strings.HasSuffix(name, "_test") {
|
|
return pkg, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("no non-test packages found in %s", path)
|
|
}
|
|
|
|
func hasContextParam(ft *ast.FuncType) bool {
|
|
if ft.Params.NumFields() != 1 {
|
|
return false
|
|
}
|
|
ret := ft.Params.List[0]
|
|
sel, ok := ret.Type.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
pkg, ok := sel.X.(*ast.Ident)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if pkg.Name != "context" {
|
|
return false
|
|
}
|
|
return sel.Sel.Name == "Context"
|
|
}
|
|
|
|
func hasVoidReturn(ft *ast.FuncType) bool {
|
|
res := ft.Results
|
|
return res.NumFields() == 0
|
|
}
|
|
|
|
func hasErrorReturn(ft *ast.FuncType) bool {
|
|
res := ft.Results
|
|
if res.NumFields() != 1 {
|
|
return false
|
|
}
|
|
ret := res.List[0]
|
|
if len(ret.Names) > 1 {
|
|
return false
|
|
}
|
|
return fmt.Sprint(ret.Type) == "error"
|
|
}
|
|
|
|
type functype int
|
|
|
|
const (
|
|
invalidType functype = iota
|
|
voidType
|
|
errorType
|
|
contextVoidType
|
|
contextErrorType
|
|
)
|
|
|
|
func funcType(ft *ast.FuncType) functype {
|
|
if hasContextParam(ft) {
|
|
if hasVoidReturn(ft) {
|
|
return contextVoidType
|
|
}
|
|
if hasErrorReturn(ft) {
|
|
return contextErrorType
|
|
}
|
|
}
|
|
if ft.Params.NumFields() == 0 {
|
|
if hasVoidReturn(ft) {
|
|
return voidType
|
|
}
|
|
if hasErrorReturn(ft) {
|
|
return errorType
|
|
}
|
|
}
|
|
return invalidType
|
|
}
|
|
|
|
func toOneLine(s string) string {
|
|
return strings.TrimSpace(strings.Replace(s, "\n", " ", -1))
|
|
}
|