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 }