Files
summercms/internal/build/registry.go
Jakub Zych 8bc18c90b6 feat(build): scaffold working admin controllers with model, AdminFS and delete (quick-261028-pq8)
- make:admin-controller edits the singular of its name (--model overrides):
  ModelName, modelClass and the YAML under models/<model>/
- NewRecord returns &models.<Model>{} when the model exists; otherwise the
  nil body stays and the command says to run make:model
- config_list.yaml offers create and delete with showCheckboxes
- registry.gen.go embeds controllers/*/*.yaml and models/*/*.yaml once they
  exist and exposes generatedAdminFS(); scaffolded plugins implement
  pact.AdminAssets through it, and a plugin without AdminFS gets a note
- blog example moves its YAML to models/post; docs and help text follow
2026-10-28 19:15:23 +01:00

360 lines
9.5 KiB
Go

package build
import (
"bytes"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
)
const (
registryFileName = "registry.gen.go"
// generatedMark is the header registry.gen.go carries. Files scaffolded
// before the scaffoldDirective existed carry it too and stay listed.
generatedMark = "Code generated by summer make"
// scaffoldDirective, followed by a space and the artifact kind, is the
// first line of every file a make: command writes for the developer to
// edit. registry.gen.go lists the files that carry it.
scaffoldDirective = "//summer:make"
)
type identScanKind int
const (
scanStructs identScanKind = iota
scanMigrationFuncs
scanCommandFuncs
scanJobFuncs
scanAdminFuncs
)
type registryRef struct {
Package string
Ident string
SortKey string
// TakesApp marks a command function with one *backpack.App parameter.
TakesApp bool
}
type registryData struct {
Package string
Module string
Imports []string
Models []registryRef
Migrations []registryRef
Commands []registryRef
Jobs []registryRef
AdminControllers []registryRef
// AppCommands is set when at least one command takes the app.
AppCommands bool
// AdminPatterns are the go:embed globs of the admin YAML, only those
// that match a file (go:embed rejects an empty pattern).
AdminPatterns []string
}
// adminEmbedPatterns are the admin YAML globs registry.gen.go may embed:
// controller configuration and model fields and columns, never Go sources.
var adminEmbedPatterns = []string{"controllers/*/*.yaml", "models/*/*.yaml"}
func emptyRegistry(pkg, module string) registryData {
data := registryData{Package: pkg, Module: module}
data.prepare()
return data
}
func (d *registryData) prepare() {
d.Imports = uniqueLeafImports(d.Module, d.Models, d.Migrations, d.Commands, d.Jobs, d.AdminControllers)
d.AppCommands = false
for _, ref := range d.Commands {
if ref.TakesApp {
d.AppCommands = true
}
}
}
func uniqueLeafImports(module string, groups ...[]registryRef) []string {
if module == "" {
return nil
}
seen := make(map[string]struct{})
var imports []string
for _, group := range groups {
for _, ref := range group {
if ref.Package == "" {
continue
}
path := module + "/" + ref.Package
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
imports = append(imports, path)
}
}
sort.Strings(imports)
return imports
}
func refreshRegistry(pluginDir string) error {
pkg, err := packageNameOf(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
return err
}
module, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
if err != nil {
return err
}
data := registryData{Package: pkg, Module: module}
if data.Models, err = scanGeneratedIdents(filepath.Join(pluginDir, "models"), "models", scanStructs); err != nil {
return err
}
if data.Migrations, err = scanGeneratedIdents(filepath.Join(pluginDir, "updates"), "updates", scanMigrationFuncs); err != nil {
return err
}
if data.Commands, err = scanGeneratedIdents(filepath.Join(pluginDir, "console"), "console", scanCommandFuncs); err != nil {
return err
}
if data.Jobs, err = scanGeneratedIdents(filepath.Join(pluginDir, "jobs"), "jobs", scanJobFuncs); err != nil {
return err
}
if data.AdminControllers, err = scanGeneratedIdents(filepath.Join(pluginDir, "controllers"), "controllers", scanAdminFuncs); err != nil {
return err
}
sortRefs(data.Models)
sortRefs(data.Migrations)
sortRefs(data.Commands)
sortRefs(data.Jobs)
sortRefs(data.AdminControllers)
for _, pattern := range adminEmbedPatterns {
matches, err := filepath.Glob(filepath.Join(pluginDir, filepath.FromSlash(pattern)))
if err != nil {
return fmt.Errorf("build: glob %s: %w", pattern, err)
}
if len(matches) > 0 {
data.AdminPatterns = append(data.AdminPatterns, pattern)
}
}
return writeRegistry(pluginDir, data)
}
func packageNameOf(path string) (string, error) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, parser.PackageClauseOnly)
if err != nil {
return "", fmt.Errorf("build: parse %s: %w", path, err)
}
if file.Name == nil || file.Name.Name == "" {
return "", fmt.Errorf("build: %s has no package name", path)
}
return file.Name.Name, nil
}
func scanGeneratedIdents(dir, pkg string, kind identScanKind) ([]registryRef, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("build: read %s: %w", dir, err)
}
fset := token.NewFileSet()
var refs []registryRef
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || name == "doc.go" {
continue
}
path := filepath.Join(dir, name)
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("build: parse %s: %w", path, err)
}
if !isScaffoldedFile(file) {
continue
}
switch kind {
case scanStructs:
refs = append(refs, structRefs(pkg, name, file)...)
default:
want := resultTypeFor(kind)
refs = append(refs, funcRefs(pkg, name, file, want, kind == scanCommandFuncs)...)
}
}
return refs, nil
}
func resultTypeFor(kind identScanKind) string {
switch kind {
case scanMigrationFuncs:
return "gormigrate.Migration"
case scanCommandFuncs:
return "bonfire.Command"
case scanJobFuncs:
return "pact.Job"
case scanAdminFuncs:
return "pact.AdminController"
default:
return ""
}
}
// isScaffoldedFile reports whether file carries the //summer:make directive
// (alone or followed by a space and the kind) or the older generated-code
// header.
func isScaffoldedFile(file *ast.File) bool {
for _, cg := range file.Comments {
for _, c := range cg.List {
if c.Text == scaffoldDirective || strings.HasPrefix(c.Text, scaffoldDirective+" ") {
return true
}
if strings.Contains(c.Text, generatedMark) {
return true
}
}
}
return false
}
func structRefs(pkg, filename string, file *ast.File) []registryRef {
var refs []registryRef
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range gd.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok || ts.Name == nil || !ts.Name.IsExported() {
continue
}
if _, ok := ts.Type.(*ast.StructType); !ok {
continue
}
refs = append(refs, registryRef{Package: pkg, Ident: ts.Name.Name, SortKey: ts.Name.Name})
}
}
_ = filename
return refs
}
// funcRefs lists the exported functions of file that return want and take
// no parameters; with allowApp, also those whose only parameter is a
// *backpack.App.
func funcRefs(pkg, filename string, file *ast.File, want string, allowApp bool) []registryRef {
var refs []registryRef
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv != nil || fn.Name == nil || !fn.Name.IsExported() || fn.Type == nil {
continue
}
takesApp := false
if fn.Type.Params != nil && len(fn.Type.Params.List) > 0 {
if !allowApp || !isSingleAppParam(fn.Type.Params) {
continue
}
takesApp = true
}
if fn.Type.Results == nil || len(fn.Type.Results.List) != 1 {
continue
}
if astTypeName(fn.Type.Results.List[0].Type) != want {
continue
}
refs = append(refs, registryRef{
Package: pkg,
Ident: fn.Name.Name,
SortKey: strings.TrimSuffix(filename, ".go"),
TakesApp: takesApp,
})
}
return refs
}
// isSingleAppParam reports a parameter list of exactly one *backpack.App,
// named or not. astTypeName drops the star, so the pointer is checked here.
func isSingleAppParam(params *ast.FieldList) bool {
if len(params.List) != 1 || len(params.List[0].Names) > 1 {
return false
}
star, ok := params.List[0].Type.(*ast.StarExpr)
if !ok {
return false
}
sel, ok := star.X.(*ast.SelectorExpr)
if !ok || sel.Sel == nil {
return false
}
pkg, ok := sel.X.(*ast.Ident)
return ok && pkg.Name == "backpack" && sel.Sel.Name == "App"
}
func astTypeName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.StarExpr:
return astTypeName(t.X)
case *ast.SelectorExpr:
if id, ok := t.X.(*ast.Ident); ok && t.Sel != nil {
return id.Name + "." + t.Sel.Name
}
case *ast.Ident:
return t.Name
}
return ""
}
func sortRefs(refs []registryRef) {
sort.Slice(refs, func(i, j int) bool {
a, b := refs[i].SortKey, refs[j].SortKey
if a == "" {
a = refs[i].Ident
}
if b == "" {
b = refs[j].Ident
}
if a == b {
return refs[i].Ident < refs[j].Ident
}
return a < b
})
}
func writeRegistry(pluginDir string, data registryData) error {
data.prepare()
src, err := renderStub("registry.go", data)
if err != nil {
return err
}
path := filepath.Join(pluginDir, registryFileName)
existing, err := os.ReadFile(path)
if err == nil && bytes.Equal(existing, src) {
return nil
}
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("build: read %s: %w", path, err)
}
tmp, err := os.CreateTemp(pluginDir, registryFileName+".*")
if err != nil {
return fmt.Errorf("build: create %s temp: %w", registryFileName, err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(src); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("build: write %s temp: %w", registryFileName, err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("build: close %s temp: %w", registryFileName, err)
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return fmt.Errorf("build: rename %s: %w", registryFileName, err)
}
return nil
}