- kiosk.ReadIdentity: ID()/Requires() as literals or constants across packages and modules, read with go/parser, never run - plugin minimums from go.mod requires; v0.0.0 is an error, a pseudo-version warns - kiosk.Validate, kiosk.CheckT (no testing import) - plugin:add reads IDs through kiosk.ReadIdentity, so const-ID plugins register
301 lines
8.7 KiB
Go
301 lines
8.7 KiB
Go
package kiosk
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"go/ast"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/yuin/goldmark"
|
|
gast "github.com/yuin/goldmark/ast"
|
|
"github.com/yuin/goldmark/text"
|
|
)
|
|
|
|
// DeriveOptions changes the persisted name and description overrides. A
|
|
// nil field keeps the override already in plugin.yaml; a pointer to ""
|
|
// clears it; any other value sets it.
|
|
type DeriveOptions struct {
|
|
Name *string
|
|
Description *string
|
|
}
|
|
|
|
// goMod is the part of `go mod edit -json` output Derive reads.
|
|
type goMod struct {
|
|
Module struct {
|
|
Path string
|
|
}
|
|
Require []goModRequire
|
|
}
|
|
|
|
type goModRequire struct {
|
|
Path string
|
|
Version string
|
|
Indirect bool
|
|
}
|
|
|
|
// version returns the go.mod require version of path, or "".
|
|
func (m goMod) version(path string) (string, bool) {
|
|
for _, r := range m.Require {
|
|
if r.Path == path {
|
|
return r.Version, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
var pseudoVersionRe = regexp.MustCompile(`^v\d+\.\d+\.\d+-(?:[0-9A-Za-z.-]+\.)?\d{14}-[0-9a-f]{12}$`)
|
|
|
|
// isPseudoVersion reports whether v is a Go pseudo-version.
|
|
func isPseudoVersion(v string) bool { return pseudoVersionRe.MatchString(v) }
|
|
|
|
// Derive builds the manifest of the plugin in dir from its sources: the
|
|
// module and the version minimums from the nearest go.mod, the ID and
|
|
// requirements from plugin.go, the name and description from README.md.
|
|
// Overrides come from an existing plugin.yaml in dir, changed by opts.
|
|
// No plugin code is compiled or run. The returned strings are warnings.
|
|
func Derive(ctx context.Context, dir string, opts DeriveOptions) (Manifest, []string, error) {
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return Manifest{}, nil, fmt.Errorf("kiosk: resolve %s: %w", dir, err)
|
|
}
|
|
if st, err := os.Stat(abs); err != nil || !st.IsDir() {
|
|
return Manifest{}, nil, fmt.Errorf("kiosk: %s is not a plugin directory", dir)
|
|
}
|
|
root, err := moduleRoot(abs)
|
|
if err != nil {
|
|
return Manifest{}, nil, err
|
|
}
|
|
mod, err := readGoMod(ctx, root)
|
|
if err != nil {
|
|
return Manifest{}, nil, err
|
|
}
|
|
var warnings []string
|
|
m := Manifest{Schema: SchemaVersion, Module: mod.Module.Path}
|
|
m.Package, err = packagePath(mod.Module.Path, root, abs)
|
|
if err != nil {
|
|
return Manifest{}, nil, err
|
|
}
|
|
|
|
fw, ok := mod.version(FrameworkModule)
|
|
switch {
|
|
case !ok:
|
|
return Manifest{}, nil, fmt.Errorf("kiosk: %s does not require %s; the framework require is the plugin's framework minimum", filepath.Join(root, "go.mod"), FrameworkModule)
|
|
case fw == "v0.0.0":
|
|
return Manifest{}, nil, fmt.Errorf("kiosk: %s requires %s v0.0.0, which is not a released version; require a framework tag", filepath.Join(root, "go.mod"), FrameworkModule)
|
|
case isPseudoVersion(fw):
|
|
warnings = append(warnings, fmt.Sprintf("framework minimum %s is a pseudo-version; require a framework tag before publishing", fw))
|
|
}
|
|
m.Framework = fw
|
|
|
|
r := newResolver(ctx, abs)
|
|
r.modLoaded, r.root, r.mod = true, root, mod
|
|
ident, err := r.identity(abs)
|
|
if err != nil {
|
|
return Manifest{}, nil, err
|
|
}
|
|
m.ID = ident.ID
|
|
reqs, w, err := r.minimums(ident.Requires)
|
|
warnings = append(warnings, w...)
|
|
if err != nil {
|
|
return Manifest{}, warnings, err
|
|
}
|
|
m.Requires = reqs
|
|
|
|
existing, err := readExisting(abs)
|
|
if err != nil {
|
|
return Manifest{}, nil, err
|
|
}
|
|
ov := Overrides{}
|
|
if existing.Overrides != nil {
|
|
ov = *existing.Overrides
|
|
}
|
|
if opts.Name != nil {
|
|
ov.Name = strings.TrimSpace(*opts.Name)
|
|
}
|
|
if opts.Description != nil {
|
|
ov.Description = strings.TrimSpace(*opts.Description)
|
|
}
|
|
if ov != (Overrides{}) {
|
|
m.Overrides = &ov
|
|
}
|
|
|
|
h1, summary, err := readReadme(filepath.Join(abs, "README.md"))
|
|
if err != nil {
|
|
return Manifest{}, nil, err
|
|
}
|
|
m.Name = h1
|
|
if ov.Name != "" {
|
|
m.Name = ov.Name
|
|
} else if h1 == m.ID {
|
|
return Manifest{}, nil, fmt.Errorf("kiosk: README.md's H1 is the plugin ID %s, not a display name; pass --name=\"…\" (it is kept in %s)", m.ID, FileName)
|
|
}
|
|
m.Description = summary
|
|
if ov.Description != "" {
|
|
m.Description = ov.Description
|
|
}
|
|
if m.Description == "" {
|
|
return Manifest{}, nil, fmt.Errorf("kiosk: README.md has no summary paragraph after its H1; add the one-sentence summary or pass --description=\"…\"")
|
|
}
|
|
return m, warnings, nil
|
|
}
|
|
|
|
// moduleRoot walks up from dir to the nearest directory with a go.mod.
|
|
func moduleRoot(dir string) (string, error) {
|
|
for d := dir; ; {
|
|
if st, err := os.Stat(filepath.Join(d, "go.mod")); err == nil && !st.IsDir() {
|
|
return d, nil
|
|
}
|
|
parent := filepath.Dir(d)
|
|
if parent == d {
|
|
return "", fmt.Errorf("kiosk: no go.mod in %s or above", dir)
|
|
}
|
|
d = parent
|
|
}
|
|
}
|
|
|
|
// packagePath is the import path of dir inside the module rooted at root.
|
|
func packagePath(modPath, root, dir string) (string, error) {
|
|
rel, err := filepath.Rel(root, dir)
|
|
if err != nil {
|
|
return "", fmt.Errorf("kiosk: package path: %w", err)
|
|
}
|
|
if rel == "." {
|
|
return modPath, nil
|
|
}
|
|
return modPath + "/" + filepath.ToSlash(rel), nil
|
|
}
|
|
|
|
// readGoMod runs `go mod edit -json` in root.
|
|
func readGoMod(ctx context.Context, root string) (goMod, error) {
|
|
out, err := runGo(ctx, root, "mod", "edit", "-json")
|
|
if err != nil {
|
|
return goMod{}, err
|
|
}
|
|
var m goMod
|
|
if err := json.Unmarshal(out, &m); err != nil {
|
|
return goMod{}, fmt.Errorf("kiosk: read %s: %w", filepath.Join(root, "go.mod"), err)
|
|
}
|
|
if m.Module.Path == "" {
|
|
return goMod{}, fmt.Errorf("kiosk: %s has no module path", filepath.Join(root, "go.mod"))
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// runGo runs the go command in dir without a shell and returns stdout.
|
|
// Workspaces are off: a plugin manifest describes the module on its own.
|
|
func runGo(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, "go", args...)
|
|
cmd.Dir = dir
|
|
env := os.Environ()
|
|
filtered := make([]string, 0, len(env)+1)
|
|
for _, e := range env {
|
|
if !strings.HasPrefix(e, "GOWORK=") {
|
|
filtered = append(filtered, e)
|
|
}
|
|
}
|
|
cmd.Env = append(filtered, "GOWORK=off")
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kiosk: go %s in %s: %w\n%s", strings.Join(args, " "), dir, err, bytes.TrimSpace(stderr.Bytes()))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// readExisting parses dir's plugin.yaml, or returns the zero manifest when
|
|
// there is none.
|
|
func readExisting(dir string) (Manifest, error) {
|
|
raw, err := os.ReadFile(filepath.Join(dir, FileName))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return Manifest{}, nil
|
|
}
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("kiosk: read %s: %w", filepath.Join(dir, FileName), err)
|
|
}
|
|
m, err := Parse(raw)
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("%w\n(delete %s and rerun summer plugin:manifest; pass --name/--description again if it had overrides)", err, filepath.Join(dir, FileName))
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// readReadme returns the plain text of the README's first H1 and of the
|
|
// first paragraph after it, whitespace collapsed.
|
|
func readReadme(path string) (name, summary string, err error) {
|
|
src, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("kiosk: the plugin needs a README.md whose H1 is the plugin name: %w", err)
|
|
}
|
|
doc := goldmark.New().Parser().Parse(text.NewReader(src))
|
|
var h1 gast.Node
|
|
for n := doc.FirstChild(); n != nil; n = n.NextSibling() {
|
|
if h, ok := n.(*gast.Heading); ok && h.Level == 1 {
|
|
h1 = h
|
|
break
|
|
}
|
|
}
|
|
if h1 == nil {
|
|
return "", "", fmt.Errorf("kiosk: %s has no H1; its first-level heading is the plugin name", path)
|
|
}
|
|
name = plainText(h1, src)
|
|
if name == "" {
|
|
return "", "", fmt.Errorf("kiosk: %s has an empty H1", path)
|
|
}
|
|
for n := h1.NextSibling(); n != nil; n = n.NextSibling() {
|
|
if p, ok := n.(*gast.Paragraph); ok {
|
|
summary = plainText(p, src)
|
|
break
|
|
}
|
|
if _, ok := n.(*gast.Heading); ok {
|
|
break
|
|
}
|
|
}
|
|
return name, summary, nil
|
|
}
|
|
|
|
// plainText is the text content of n with whitespace collapsed: code
|
|
// spans and link texts keep their text, markup is dropped.
|
|
func plainText(n gast.Node, src []byte) string {
|
|
var b strings.Builder
|
|
_ = gast.Walk(n, func(c gast.Node, entering bool) (gast.WalkStatus, error) {
|
|
if !entering {
|
|
return gast.WalkContinue, nil
|
|
}
|
|
switch t := c.(type) {
|
|
case *gast.Text:
|
|
b.Write(t.Segment.Value(src))
|
|
if t.SoftLineBreak() || t.HardLineBreak() {
|
|
b.WriteByte(' ')
|
|
}
|
|
case *gast.String:
|
|
b.Write(t.Value)
|
|
case *gast.AutoLink:
|
|
b.Write(t.Label(src))
|
|
return gast.WalkSkipChildren, nil
|
|
case *gast.RawHTML:
|
|
return gast.WalkSkipChildren, nil
|
|
}
|
|
return gast.WalkContinue, nil
|
|
})
|
|
return strings.Join(strings.Fields(b.String()), " ")
|
|
}
|
|
|
|
// lastReturn is the single result of fn's last top-level return, or nil.
|
|
func lastReturn(fn *ast.FuncDecl) ast.Expr {
|
|
var res ast.Expr
|
|
for _, stmt := range fn.Body.List {
|
|
if ret, ok := stmt.(*ast.ReturnStmt); ok && len(ret.Results) == 1 {
|
|
res = ret.Results[0]
|
|
}
|
|
}
|
|
return res
|
|
}
|