feat: add scaffolder
This commit is contained in:
163
internal/templates/loader.go
Normal file
163
internal/templates/loader.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
gotmpl "text/template"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// TemplateLoader reads a templates files and runs their respective templating on them.
|
||||
type TemplateLoader struct{}
|
||||
|
||||
func NewTemplateLoader() *TemplateLoader {
|
||||
return &TemplateLoader{}
|
||||
}
|
||||
|
||||
type File struct {
|
||||
content []byte
|
||||
path string
|
||||
RelPath string
|
||||
}
|
||||
|
||||
var funcs = gotmpl.FuncMap{
|
||||
"ReplaceAll": strings.ReplaceAll,
|
||||
"ToLower": strings.ToLower,
|
||||
"ToUpper": strings.ToUpper,
|
||||
}
|
||||
|
||||
// TemplatePath formats the template file path using go templates, this is useful for programmatically changing the output string using go tmpls
|
||||
func TemplatePath(template *Template) (string, error) {
|
||||
tmpl, err := gotmpl.New("path").Funcs(funcs).Parse(template.File.Default.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
output := bytes.NewBufferString("")
|
||||
if err := tmpl.Execute(output, template); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
templatePath := strings.TrimSpace(output.String())
|
||||
|
||||
return templatePath, nil
|
||||
}
|
||||
|
||||
// Load loads the template files from disk
|
||||
func (t *TemplateLoader) Load(ctx context.Context, template *Template) ([]File, error) {
|
||||
templateFilePath := path.Join(template.Path, "files")
|
||||
if _, err := os.Stat(templateFilePath); err != nil {
|
||||
return nil, fmt.Errorf("failed to lookup template files %s, %w", templateFilePath, err)
|
||||
}
|
||||
|
||||
filePaths := make([]string, 0)
|
||||
err := filepath.WalkDir(templateFilePath, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Is a file
|
||||
if d.Type().IsRegular() {
|
||||
filePaths = append(filePaths, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read template files: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
filesLock sync.Mutex
|
||||
files = make([]File, 0)
|
||||
)
|
||||
egrp, _ := errgroup.WithContext(ctx)
|
||||
for _, filePath := range filePaths {
|
||||
egrp.Go(func() error {
|
||||
fileContent, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file: %s, %w", filePath, err)
|
||||
}
|
||||
|
||||
filesLock.Lock()
|
||||
defer filesLock.Unlock()
|
||||
|
||||
files = append(files, File{
|
||||
content: fileContent,
|
||||
path: filePath,
|
||||
RelPath: strings.TrimPrefix(strings.TrimPrefix(filePath, templateFilePath), "/"),
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
}
|
||||
if err := egrp.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
type TemplatedFile struct {
|
||||
Content []byte
|
||||
DestinationPath string
|
||||
}
|
||||
|
||||
// TemplateFiles runs the actual templating on the files, and tells it where to go. The writes doesn't happen here yet.
|
||||
func (l *TemplateLoader) TemplateFiles(template *Template, files []File, scaffoldDest string) ([]TemplatedFile, error) {
|
||||
templatedFiles := make([]TemplatedFile, 0)
|
||||
for _, file := range files {
|
||||
tmpl, err := gotmpl.
|
||||
New(file.RelPath).
|
||||
Funcs(funcs).
|
||||
Parse(string(file.content))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse template file: %s, %w", file.RelPath, err)
|
||||
}
|
||||
|
||||
output := bytes.NewBufferString("")
|
||||
if err := tmpl.Execute(output, template); err != nil {
|
||||
return nil, fmt.Errorf("failed to write template file: %s, %w", file.RelPath, err)
|
||||
}
|
||||
|
||||
fileDir := path.Dir(file.RelPath)
|
||||
fileName := strings.TrimSuffix(path.Base(file.RelPath), ".gotmpl")
|
||||
if fileConfig, ok := template.File.Files[strings.TrimSuffix(file.RelPath, ".gotmpl")]; ok && fileConfig.Rename != "" {
|
||||
renameTmpl, err := gotmpl.New(file.RelPath).Funcs(funcs).Parse(fileConfig.Rename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse rename for: %s in scaffold.yaml: %w", file.RelPath, err)
|
||||
}
|
||||
|
||||
type RenameContext struct {
|
||||
Template
|
||||
OriginalFileName string
|
||||
}
|
||||
|
||||
output := bytes.NewBufferString("")
|
||||
if err := renameTmpl.Execute(output, RenameContext{
|
||||
Template: *template,
|
||||
OriginalFileName: fileName,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to template rename: %s, %w", file.RelPath, err)
|
||||
}
|
||||
|
||||
fileName = strings.TrimSpace(output.String())
|
||||
}
|
||||
|
||||
templatedFiles = append(templatedFiles, TemplatedFile{
|
||||
Content: output.Bytes(),
|
||||
DestinationPath: path.Join(scaffoldDest, fileDir, fileName),
|
||||
})
|
||||
}
|
||||
|
||||
return templatedFiles, nil
|
||||
}
|
97
internal/templates/templates.go
Normal file
97
internal/templates/templates.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TemplateIndexer loads all template specifications from the registry, allowing the caller to choose on of them, or simply display their properties.
|
||||
type TemplateIndexer struct{}
|
||||
|
||||
func NewTemplateIndexer() *TemplateIndexer {
|
||||
return &TemplateIndexer{}
|
||||
}
|
||||
|
||||
type Template struct {
|
||||
File TemplateFile
|
||||
Path string
|
||||
|
||||
Input map[string]string
|
||||
}
|
||||
|
||||
type TemplateDefault struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
type TemplateInputs map[string]TemplateInput
|
||||
|
||||
type TemplateInput struct {
|
||||
Type string `yaml:"type"`
|
||||
Description string `yaml:"description"`
|
||||
Default string `yaml:"default"`
|
||||
}
|
||||
|
||||
type TemplateFileConfig struct {
|
||||
Rename string `yaml:"rename"`
|
||||
}
|
||||
|
||||
type TemplateFile struct {
|
||||
Name string `yaml:"name"`
|
||||
Default TemplateDefault `yaml:"default"`
|
||||
Input TemplateInputs `yaml:"input"`
|
||||
Files map[string]TemplateFileConfig
|
||||
}
|
||||
|
||||
func (t *TemplateIndexer) Index(ctx context.Context, scaffoldRegistryFolder string, ui *slog.Logger) ([]Template, error) {
|
||||
ui.Debug("Loading templates...")
|
||||
|
||||
templateDirEntries, err := os.ReadDir(scaffoldRegistryFolder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read templates dir: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
templates = make([]Template, 0)
|
||||
templatesLock sync.Mutex
|
||||
)
|
||||
egrp, _ := errgroup.WithContext(ctx)
|
||||
for _, templateDirEntry := range templateDirEntries {
|
||||
egrp.Go(func() error {
|
||||
templatePath := path.Join(scaffoldRegistryFolder, templateDirEntry.Name())
|
||||
|
||||
content, err := os.ReadFile(path.Join(templatePath, "scaffold.yaml"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read: %s, %w", templateDirEntry.Name(), err)
|
||||
}
|
||||
|
||||
var template TemplateFile
|
||||
if err := yaml.Unmarshal(content, &template); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal template: %s, %w", string(content), err)
|
||||
}
|
||||
|
||||
templatesLock.Lock()
|
||||
defer templatesLock.Unlock()
|
||||
templates = append(templates, Template{
|
||||
File: template,
|
||||
Path: templatePath,
|
||||
Input: make(map[string]string),
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := egrp.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ui.Debug("Done loading templates...", "amount", len(templates))
|
||||
|
||||
return templates, nil
|
||||
}
|
84
internal/templates/writer.go
Normal file
84
internal/templates/writer.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const readWriteExec = 0o755
|
||||
const readExec = 0o644
|
||||
|
||||
type PromptOverride func(file TemplatedFile) (bool, error)
|
||||
|
||||
// FileWriter writes the actual files to disk, it optionally takes a promptOverride which allows the caller to stop a potential override of a file
|
||||
type FileWriter struct {
|
||||
promptOverride PromptOverride
|
||||
}
|
||||
|
||||
func NewFileWriter() *FileWriter {
|
||||
return &FileWriter{
|
||||
promptOverride: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FileWriter) WithPromptOverride(po PromptOverride) *FileWriter {
|
||||
f.promptOverride = po
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *FileWriter) Write(ctx context.Context, ui *slog.Logger, templatedFiles []TemplatedFile) error {
|
||||
var fileExistsLock sync.Mutex
|
||||
|
||||
egrp, _ := errgroup.WithContext(ctx)
|
||||
for _, file := range templatedFiles {
|
||||
egrp.Go(func() error {
|
||||
if _, err := os.Stat(file.DestinationPath); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("failed to check if file exists: %s, %w", file.DestinationPath, err)
|
||||
}
|
||||
} else {
|
||||
if f.promptOverride != nil {
|
||||
fileExistsLock.Lock()
|
||||
defer fileExistsLock.Unlock()
|
||||
|
||||
override, err := f.promptOverride(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get answer to whether a file should be overwritten or not: %w", err)
|
||||
}
|
||||
|
||||
if !override {
|
||||
ui.Warn("Skipping file", "file", file.DestinationPath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parent := path.Dir(file.DestinationPath); parent != "" && parent != "/" {
|
||||
if err := os.MkdirAll(parent, readWriteExec); err != nil {
|
||||
return fmt.Errorf("failed to create parent dir for: %s, %w", file.DestinationPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
ui.Info("writing file", "path", file.DestinationPath)
|
||||
if err := os.WriteFile(file.DestinationPath, file.Content, readExec); err != nil {
|
||||
return fmt.Errorf("failed to write file: %s, %w", file.DestinationPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := egrp.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Reference in New Issue
Block a user