Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions cmd/extension/extension_make.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package extension

import (
"github.com/spf13/cobra"

"github.com/shopware/shopware-cli/internal/extension"
"github.com/shopware/shopware-cli/internal/extension/scaffolding"
)

func newMakeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "make",
Short: "Add an example implementation to a plugin",
Long: `Add an example implementation of a single Shopware feature to the plugin in the
current directory. Generators only create missing files and extend the service
and route configuration, existing code is never changed.`,
}

for _, generator := range extension.Generators() {
cmd.AddCommand(newGeneratorCmd(generator))
}

return cmd
}

func newGeneratorCmd(generator scaffolding.Generator) *cobra.Command {
use := generator.Name
args := cobra.NoArgs

if generator.Args != "" {
use += " " + generator.Args
args = cobra.MinimumNArgs(1)
}

return &cobra.Command{
Use: use,
Short: generator.Short,
Args: args,
RunE: func(cmd *cobra.Command, args []string) error {
return extension.Make(cmd.Context(), generator, args)
},
}
}

func init() {
extensionRootCmd.AddCommand(newMakeCmd())
}
109 changes: 109 additions & 0 deletions internal/extension/make.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package extension

import (
"context"
"fmt"
"os"
"strings"

"github.com/shopware/shopware-cli/internal/extension/scaffolding"
"github.com/shopware/shopware-cli/internal/shop"
"github.com/shopware/shopware-cli/logging"
)

// MinimumMakeShopwareVersion is the oldest Shopware release the generated code
// runs on. 6.7.13.0 replaced the custom field installer with a declarative
// Resources/config/custom-fields.xml, which the generators rely on.
const MinimumMakeShopwareVersion = "6.7.13.0"

// Generators returns the generators that can be run inside an existing plugin.
func Generators() []scaffolding.Generator {
return scaffolding.Generators()
}

// Make runs a generator inside the plugin in the current directory.
func Make(ctx context.Context, generator scaffolding.Generator, args []string) error {
logger := logging.FromContext(ctx)

projectDir, err := shop.FindClosestShopwareProject(false)
if err != nil {
return err
}

if err := ensureMakeSupported(projectDir); err != nil {
return err
}

plugin, err := currentPlugin(ctx)
if err != nil {
return err
}

result, err := generator.Run(plugin, args)
if err != nil {
return err
}

for _, path := range result.Created {
logger.Infof("✓ created %s", path)
}
for _, path := range result.Updated {
logger.Infof("✓ updated %s", path)
}
for _, path := range result.Skipped {
logger.Infof("• skipped %s, it is already up to date", path)
}

return nil
}

// ensureMakeSupported fails when the project runs a Shopware release that does
// not understand the generated code.
func ensureMakeSupported(projectDir string) error {
supported, err := shop.IsShopwareVersion(projectDir, ">="+MinimumMakeShopwareVersion)
if err != nil {
return fmt.Errorf("cannot determine the Shopware version of %s: %w", projectDir, err)
}

if !supported {
return fmt.Errorf("the generators require Shopware %s or newer, %s uses an older release", MinimumMakeShopwareVersion, projectDir)
}

return nil
}

// currentPlugin describes the plugin the generators write into.
func currentPlugin(ctx context.Context) (scaffolding.PluginInfo, error) {
dir, err := os.Getwd()
if err != nil {
return scaffolding.PluginInfo{}, err
}

ext, err := GetExtensionByFolder(ctx, dir)
if err != nil {
return scaffolding.PluginInfo{}, fmt.Errorf("the current directory is not an extension: %w", err)
}

plugin, ok := ext.(*PlatformPlugin)
if !ok {
return scaffolding.PluginInfo{}, fmt.Errorf("the generators only support plugins, %s is of type %s", dir, ext.GetType())
}

namespace, className, err := splitPluginClass(plugin.Composer.Extra.ShopwarePluginClass)
if err != nil {
return scaffolding.PluginInfo{}, err
}

return scaffolding.PluginInfo{Dir: dir, Namespace: namespace, ClassName: className}, nil
}

// splitPluginClass separates the namespace from the class name of the fully
// qualified plugin class, e.g. Swag\BasicExample\SwagBasicExample.
func splitPluginClass(pluginClass string) (namespace string, className string, err error) {
separator := strings.LastIndex(pluginClass, `\`)
if separator <= 0 || separator == len(pluginClass)-1 {
return "", "", fmt.Errorf("composer.json needs a namespaced extra.shopware-plugin-class, got %q", pluginClass)
}

return pluginClass[:separator], pluginClass[separator+1:], nil
}
235 changes: 235 additions & 0 deletions internal/extension/scaffolding/generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package scaffolding

import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
)

// PluginInfo describes the existing plugin a generator writes into.
type PluginInfo struct {
// Dir is the absolute path of the plugin.
Dir string
// Namespace is the PHP namespace of the plugin, e.g. Swag\BasicExample.
Namespace string
// ClassName is the plugin class without its namespace, e.g. SwagBasicExample.
ClassName string
}

// Generator creates an example implementation of a single Shopware feature
// inside an existing plugin. Generators are additive: they create missing files
// and extend the service and route configuration, but never touch code that is
// already there.
type Generator struct {
// Name is the sub command name, e.g. "event-subscriber".
Name string
// Short describes the generator in the command list.
Short string
// Args documents the positional arguments, e.g. "ENTITY...".
// It is empty for generators that take no arguments.
Args string

build func(plugin PluginInfo, args []string) (output, error)
}

// Result lists the paths a generator run touched, relative to the plugin.
type Result struct {
// Created are files that did not exist before.
Created []string
// Updated are config files that gained a new block.
Updated []string
// Skipped are files that were already there and stayed untouched.
Skipped []string
}

// Run executes the generator inside the plugin.
func (g Generator) Run(plugin PluginInfo, args []string) (Result, error) {
var result Result

out, err := g.build(plugin, args)
if err != nil {
return result, err
}

for _, f := range out.Files {
state, err := createFile(plugin.Dir, f)
if err != nil {
return result, fmt.Errorf("create %s: %w", f.Path, err)
}

result.record(f.Path, state)
}

for _, s := range out.Snippets {
state, err := applySnippet(plugin.Dir, s)
if err != nil {
return result, fmt.Errorf("update %s: %w", s.Path, err)
}

result.record(s.Path, state)
}

return result, nil
}

// templateData holds every value the generator stubs can reference. The entity
// fields are only filled by the entity generator.
type templateData struct {
Namespace string
ClassName string
EntityName string
TableName string
Timestamp string
}

// file is a single file a generator creates from an embedded stub.
type file struct {
// Path is relative to the plugin directory and always uses forward slashes.
Path string
Stub string
// Raw copies the stub verbatim instead of rendering it. Twig stubs need
// this because Twig and Go templates share the {{ }} delimiters.
Raw bool
Data templateData
}

// snippet is a block of code added to a config file shared by all generators.
// A missing file is created from Intro and Outro, an existing one only gains
// the block. Content that is already present is never added twice.
type snippet struct {
// Path is relative to the plugin directory and always uses forward slashes.
Path string
Content string
Intro string
Outro string
}

// output is everything a single generator contributes to a plugin.
type output struct {
Files []file
Snippets []snippet
}

// fileState tells what happened to a file during a generator run.
type fileState int

const (
skipped fileState = iota
created
updated
)

func (r *Result) record(path string, state fileState) {
switch state {
case created:
r.Created = append(r.Created, path)
case updated:
r.Updated = append(r.Updated, path)
case skipped:
r.Skipped = append(r.Skipped, path)
}
}

// createFile renders a stub into the plugin, unless the file already exists.
func createFile(pluginDir string, f file) (fileState, error) {
dest := filepath.Join(pluginDir, filepath.FromSlash(f.Path))

_, err := os.Stat(dest)
if err == nil {
return skipped, nil
}
if !errors.Is(err, os.ErrNotExist) {
return skipped, fmt.Errorf("stat file: %w", err)
}

content, err := renderStub(f)
if err != nil {
return skipped, err
}

if err := writeFile(dest, content); err != nil {
return skipped, err
}

return created, nil
}

// applySnippet adds a block to a config file without losing its current content.
func applySnippet(pluginDir string, s snippet) (fileState, error) {
dest := filepath.Join(pluginDir, filepath.FromSlash(s.Path))

existing, err := os.ReadFile(dest)
if errors.Is(err, os.ErrNotExist) {
if err := writeFile(dest, s.Intro+s.Content+s.Outro); err != nil {
return skipped, err
}

return created, nil
}
if err != nil {
return skipped, fmt.Errorf("read file: %w", err)
}

content := string(existing)
if strings.Contains(content, s.Content) {
return skipped, nil
}

// The block of a PHP config file belongs inside the returned closure, so it
// goes in front of the closing "};" rather than at the end of the file.
if marker := strings.TrimSpace(s.Outro); marker != "" {
if at := strings.LastIndex(content, marker); at >= 0 {
content = content[:at] + s.Content + content[at:]

if err := writeFile(dest, content); err != nil {
return skipped, err
}

return updated, nil
}
}

if err := writeFile(dest, content+s.Content); err != nil {
return skipped, err
}

return updated, nil
}

func renderStub(f file) (string, error) {
stub, err := stubsFS.ReadFile(f.Stub)
if err != nil {
return "", fmt.Errorf("read stub: %w", err)
}

if f.Raw {
return string(stub), nil
}

tmpl, err := template.New(f.Path).Parse(string(stub))
if err != nil {
return "", fmt.Errorf("parse stub: %w", err)
}

var rendered strings.Builder
if err := tmpl.Execute(&rendered, f.Data); err != nil {
return "", fmt.Errorf("render stub: %w", err)
}

return rendered.String(), nil
}

func writeFile(dest, content string) error {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create subdirectories: %w", err)
}

if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
return fmt.Errorf("write file: %w", err)
}

return nil
}
Loading
Loading