diff --git a/.github/workflows/base-docker.yml b/.github/workflows/base-docker.yml index a7e93fd4..ccf0f702 100644 --- a/.github/workflows/base-docker.yml +++ b/.github/workflows/base-docker.yml @@ -4,6 +4,8 @@ on: push: tags-ignore: - "*" + branches: + - main paths: - 'Dockerfile.base' diff --git a/.github/workflows/go_test.yml b/.github/workflows/go_test.yml index a185e804..b5a2f4ba 100644 --- a/.github/workflows/go_test.yml +++ b/.github/workflows/go_test.yml @@ -25,7 +25,6 @@ jobs: os: - ubuntu-latest - macos-14 - - windows-latest runs-on: ${{ matrix.os }} steps: diff --git a/.gitignore b/.gitignore index f95e9034..b2856bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ dist/ /project dump.sql* +/testdata + # Devenv .devenv* devenv.local.nix diff --git a/.golangci.yml b/.golangci.yml index 201baf6d..49dabc35 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -17,7 +17,6 @@ linters: - unused - whitespace - asciicheck - - godot - gocyclo - gocritic - errcheck @@ -40,7 +39,6 @@ linters: - ginkgolinter - gocheckcompilerdirectives - goconst - - godot - godox - nilnil exclusions: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index dd55116a..6adfef54 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,15 +15,10 @@ builds: - -trimpath goos: - linux - - windows - darwin ignore: - goos: linux goarch: '386' - - goos: windows - goarch: '386' - - goos: windows - goarch: 'arm64' archives: - name_template: >- @@ -32,9 +27,6 @@ archives: {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} - format_overrides: - - goos: windows - formats: ["zip"] files: - LICENSE - completions/* diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..80bb1461 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach Debugger", + "type": "go", + "request": "attach", + "mode": "remote", + "remotePath": "${workspaceFolder}", + "port": 40001, + "host": "localhost" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 5b6f093e..74ecb32c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,18 @@ { - "go.formatTool": "gofmt", - "go.useLanguageServer": true, - "gopls": { - "formatting.gofumpt": true + "go.lintTool": "golangci-lint", + "go.lintFlags": [ + "--path-mode=abs", + "--fast-only" + ], + "go.formatTool": "custom", + "go.alternateTools": { + "customFormatter": "golangci-lint" }, + "go.formatFlags": [ + "fmt", + "--stdin" + ], + "go.useLanguageServer": true, "go.diagnostic.vulncheck": "Imports", "go.inlayHints.functionTypeParameters": true, "go.inlayHints.assignVariableTypes": true, @@ -12,11 +21,14 @@ "files.exclude": { ".devenv": true, ".direnv": true, - ".idea": true + ".idea": true, + }, + "php.problems.exclude": { + "internal/phplint/testdata/invalid.php": true }, "go.toolsManagement.autoUpdate": false, "go.toolsManagement.checkForUpdates": "off", "yaml.schemas": { "https://raw.githubusercontent.com/shopware/shopware-cli/main/extension/shopware-extension-schema.json": "file:///workspaces/shopware-cli/.shopware-extension.yml" - } + }, } \ No newline at end of file diff --git a/.cursor/rules/repository.mdc b/.windsurf/rules/repository.md similarity index 93% rename from .cursor/rules/repository.mdc rename to .windsurf/rules/repository.md index 47f660d0..fcdf30ff 100644 --- a/.cursor/rules/repository.mdc +++ b/.windsurf/rules/repository.md @@ -1,7 +1,7 @@ --- +trigger: always_on description: globs: -alwaysApply: true --- # General rules diff --git a/.cursor/rules/test.mdc b/.windsurf/rules/test.md similarity index 85% rename from .cursor/rules/test.mdc rename to .windsurf/rules/test.md index e4ed2927..2e25e316 100644 --- a/.cursor/rules/test.mdc +++ b/.windsurf/rules/test.md @@ -1,8 +1,8 @@ --- -description: +trigger: glob globs: *_test.go -alwaysApply: false --- + - Use testify assert - Prefer assert.ElementsMatch on lists to ignore ordering issues - Use t.Setenv for environment variables diff --git a/Dockerfile.base b/Dockerfile.base index 4af46baa..fa840c9e 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -59,6 +59,18 @@ RUN < /etc/php/conf.d/docker.ini EOF +COPY internal/verifier/js /opt/verifier/js +COPY internal/verifier/php /opt/verifier/php + +RUN <") { + thinkEndIndex := strings.Index(text, "") + if thinkEndIndex != -1 { + text = text[thinkEndIndex+len(""):] + } + } + + start := strings.Index(text, "```twig") + end := strings.LastIndex(text, "```") + + if start == -1 || end == -1 { + return nil + } + + text = strings.TrimPrefix(text[start+7:end], "\n") + + contentStr := string(content) + if strings.TrimSpace(text) == strings.TrimSpace(contentStr) { + return nil + } + + return os.WriteFile(file, []byte(text), os.ModePerm) + }) + if err != nil { + return err + } + } + + return nil + }, +} + +func init() { + extensionAiTwigUpgradeCmd.Flags().String("model", "gemma3:4b", "The model to use for the upgrade") + extensionAiTwigUpgradeCmd.Flags().String("provider", "ollama", "The provider to use for the upgrade") + extensionAiCmd.AddCommand(extensionAiTwigUpgradeCmd) +} + +func cloneShopwareStorefront(ctx context.Context, version string) (string, error) { + tempDir, err := os.MkdirTemp(os.TempDir(), "shopware") + if err != nil { + return "", err + } + + git := exec.CommandContext(ctx, "git", "-c", "advice.detachedHead=false", "clone", "-q", "--branch", "v"+version, "https://github.com/shopware/storefront", tempDir, "--depth", "1") + output, err := git.CombinedOutput() + if err != nil { + logging.FromContext(ctx).Error(string(output)) + return "", err + } + + return tempDir, nil +} diff --git a/cmd/extension/extension_fix.go b/cmd/extension/extension_fix.go new file mode 100644 index 00000000..0d30317e --- /dev/null +++ b/cmd/extension/extension_fix.go @@ -0,0 +1,77 @@ +package extension + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/shopware/shopware-cli/extension" + "github.com/shopware/shopware-cli/internal/verifier" + "github.com/shopware/shopware-cli/logging" +) + +var extensionFixCmd = &cobra.Command{ + Use: "fix", + Short: "Fix an extension", + PreRunE: func(cmd *cobra.Command, args []string) error { + return verifier.SetupTools(cmd.Context(), cmd.Root().Version) + }, + RunE: func(cmd *cobra.Command, args []string) error { + allowNonGit, _ := cmd.Flags().GetBool("allow-non-git") + gitPath := filepath.Join(args[0], ".git") + if !allowNonGit { + if stat, err := os.Stat(gitPath); err != nil || !stat.IsDir() { + return fmt.Errorf("provided folder is not a git repository. Use --allow-non-git flag to run anyway") + } + } + + path, err := filepath.Abs(args[0]) + if err != nil { + return fmt.Errorf("cannot find path: %w", err) + } + + ext, err := extension.GetExtensionByFolder(path) + if err != nil { + return err + } + + toolCfg, err := verifier.ConvertExtensionToToolConfig(ext) + if err != nil { + return err + } + + logging.FromContext(cmd.Context()).Debugf("Running fixes for Shopware version: %s", toolCfg.MinShopwareVersion) + + var gr errgroup.Group + + tools := verifier.GetTools() + only, _ := cmd.Flags().GetString("only") + + tools, err = tools.Only(only) + if err != nil { + return err + } + + for _, tool := range tools { + tool := tool + gr.Go(func() error { + return tool.Fix(cmd.Context(), *toolCfg) + }) + } + + if err := gr.Wait(); err != nil { + return err + } + + return nil + }, +} + +func init() { + extensionRootCmd.AddCommand(extensionFixCmd) + extensionFixCmd.Flags().String("only", "", "Run only specific tools by name (comma-separated, e.g. phpstan,eslint)") + extensionFixCmd.Flags().Bool("allow-non-git", false, "Allow running the fix command on non-git repositories") +} diff --git a/cmd/extension/extension_format.go b/cmd/extension/extension_format.go new file mode 100644 index 00000000..6faf0e8e --- /dev/null +++ b/cmd/extension/extension_format.go @@ -0,0 +1,70 @@ +package extension + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/shopware/shopware-cli/extension" + "github.com/shopware/shopware-cli/internal/verifier" + "github.com/shopware/shopware-cli/logging" +) + +var extensionFormat = &cobra.Command{ + Use: "format", + Short: "Format an extension", + PreRunE: func(cmd *cobra.Command, args []string) error { + return verifier.SetupTools(cmd.Context(), cmd.Root().Version) + }, + RunE: func(cmd *cobra.Command, args []string) error { + dryRun, _ := cmd.Flags().GetBool("dry-run") + + path, err := filepath.Abs(args[0]) + if err != nil { + return fmt.Errorf("cannot find path: %w", err) + } + + ext, err := extension.GetExtensionByFolder(path) + if err != nil { + return err + } + + toolCfg, err := verifier.ConvertExtensionToToolConfig(ext) + if err != nil { + return err + } + + logging.FromContext(cmd.Context()).Debugf("Running fixes for Shopware version: %s", toolCfg.MinShopwareVersion) + + var gr errgroup.Group + + tools := verifier.GetTools() + only, _ := cmd.Flags().GetString("only") + + tools, err = tools.Only(only) + if err != nil { + return err + } + + for _, tool := range tools { + tool := tool + gr.Go(func() error { + return tool.Format(cmd.Context(), *toolCfg, dryRun) + }) + } + + if err := gr.Wait(); err != nil { + return err + } + + return nil + }, +} + +func init() { + extensionRootCmd.AddCommand(extensionFormat) + extensionFormat.Flags().String("only", "", "Run only specific tools by name (comma-separated, e.g. phpstan,eslint)") + extensionFormat.Flags().Bool("dry-run", false, "Run in dry run mode") +} diff --git a/cmd/extension/extension_validate.go b/cmd/extension/extension_validate.go index 10224661..f4d3e202 100644 --- a/cmd/extension/extension_validate.go +++ b/cmd/extension/extension_validate.go @@ -6,9 +6,11 @@ import ( "path/filepath" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" "github.com/shopware/shopware-cli/extension" - "github.com/shopware/shopware-cli/internal/table" + "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/verifier" "github.com/shopware/shopware-cli/logging" ) @@ -17,6 +19,25 @@ var extensionValidateCmd = &cobra.Command{ Short: "Validate a Extension", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + isFull, _ := cmd.Flags().GetBool("full") + reportingFormat, _ := cmd.Flags().GetString("reporter") + checkAgainst, _ := cmd.Flags().GetString("check-against") + tmpDir, err := os.MkdirTemp(os.TempDir(), "analyse-extension-*") + only, _ := cmd.Flags().GetString("only") + + // If the user does not want to run full validation, only run shopware-cli + if !isFull { + only = "sw-cli" + } + + if reportingFormat == "" { + reportingFormat = verifier.DetectDefaultReporter() + } + + if err != nil { + return fmt.Errorf("cannot create temporary directory: %w", err) + } + path, err := filepath.Abs(args[0]) if err != nil { return fmt.Errorf("cannot find path: %w", err) @@ -26,55 +47,96 @@ var extensionValidateCmd = &cobra.Command{ if err != nil { return fmt.Errorf("cannot find path: %w", err) } - - var ext extension.Extension + var toolCfg *verifier.ToolConfig if stat.IsDir() { - ext, err = extension.GetExtensionByFolder(path) - } else { - ext, err = extension.GetExtensionByZip(path) - } + if isFull { + if err := system.CopyFiles(args[0], tmpDir); err != nil { + return err + } + + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + logging.FromContext(cmd.Context()).Error("Failed to remove temporary directory:", err) + } + }() + } else { + tmpDir = args[0] + } - if err != nil { - return fmt.Errorf("cannot open extension: %w", err) - } + ext, err := extension.GetExtensionByFolder(path) + if err != nil { + return err + } - context := extension.RunValidation(cmd.Context(), ext) + toolCfg, err = verifier.ConvertExtensionToToolConfig(ext) + if err != nil { + return err + } - if stat.IsDir() { - context.ApplyIgnores([]extension.ConfigValidationIgnoreItem{ - { - Identifier: "zip.disallowed_file", - Message: ".gitignore is not allowed in the zip file", - }, - }) + toolCfg.InputWasDirectory = true + } else { + ext, err := extension.GetExtensionByZip(args[0]) + if err != nil { + return err + } + + toolCfg, err = verifier.ConvertExtensionToToolConfig(ext) + if err != nil { + return err + } } - if context.HasErrors() || context.HasWarnings() { - table := table.NewWriter(os.Stdout) - table.Header([]string{"Type", "Identifier", "Message"}) + toolCfg.CheckAgainst = checkAgainst + result := verifier.NewCheck() - for _, msg := range context.Errors() { - _ = table.Append([]string{"Error", msg.Identifier, msg.Message}) - } + var gr errgroup.Group - for _, msg := range context.Warnings() { - _ = table.Append([]string{"Warning", msg.Identifier, msg.Message}) - } + tools := verifier.GetTools() - _ = table.Render() + tools, err = tools.Only(only) + if err != nil { + return err } - if context.HasErrors() { - return fmt.Errorf("validation failed") + for _, tool := range tools { + tool := tool + gr.Go(func() error { + return tool.Check(cmd.Context(), result, *toolCfg) + }) } - logging.FromContext(cmd.Context()).Infof("Validation has been successful") + if err := gr.Wait(); err != nil { + return err + } - return nil + return verifier.DoCheckReport(result.RemoveByIdentifier(toolCfg.ValidationIgnores), reportingFormat) }, } func init() { extensionRootCmd.AddCommand(extensionValidateCmd) + extensionValidateCmd.PersistentFlags().Bool("full", false, "Run full validation including PHPStan, ESLint and Stylelint") + extensionValidateCmd.PersistentFlags().String("reporter", "", "Reporting format (summary, json, github, junit, markdown)") + extensionValidateCmd.PersistentFlags().String("check-against", "highest", "Check against Shopware Version (highest, lowest)") + extensionValidateCmd.PersistentFlags().String("only", "", "Run only specific tools by name (comma-separated, e.g. phpstan,eslint)") + extensionValidateCmd.PreRunE = func(cmd *cobra.Command, args []string) error { + reporter, _ := cmd.Flags().GetString("reporter") + if reporter != "summary" && reporter != "json" && reporter != "github" && reporter != "junit" && reporter != "markdown" && reporter != "" { + return fmt.Errorf("invalid reporter format: %s. Must be either 'summary', 'json', 'github', 'junit' or 'markdown'", reporter) + } + + mode, _ := cmd.Flags().GetString("check-against") + if mode != "highest" && mode != "lowest" { + return fmt.Errorf("invalid mode: %s. Must be either 'highest' or 'lowest'", mode) + } + + // Dont setup tools if we dont run full validation + full, _ := cmd.Flags().GetBool("full") + if !full { + return nil + } + + return verifier.SetupTools(cmd.Context(), cmd.Root().Version) + } } diff --git a/cmd/project/platform.go b/cmd/project/platform.go index 7a3762b7..f67b13df 100644 --- a/cmd/project/platform.go +++ b/cmd/project/platform.go @@ -112,12 +112,57 @@ func filterAndGetSources(cmd *cobra.Command, projectRoot string, shopCfg *shop.C onlyExtensions, _ := cmd.PersistentFlags().GetString("only-extensions") skipExtensions, _ := cmd.PersistentFlags().GetString("skip-extensions") + onlyCustomStatic, _ := cmd.PersistentFlags().GetBool("only-custom-static-extensions") if onlyExtensions != "" && skipExtensions != "" { return nil, fmt.Errorf("only-extensions and skip-extensions cannot be used together") } - if onlyExtensions == "" && skipExtensions == "" { + if onlyCustomStatic { + logging.FromContext(cmd.Context()).Infof("Only including extensions from custom/static-plugins directory") + logging.FromContext(cmd.Context()).Debugf("Found %d total extensions before filtering", len(sources)) + for _, s := range sources { + logging.FromContext(cmd.Context()).Debugf("Extension: %s, Path: %s", s.Name, s.Path) + } + + sources = slices.DeleteFunc(sources, func(s asset.Source) bool { + // First try to resolve any symlinks + resolvedPath, err := filepath.EvalSymlinks(s.Path) + if err != nil { + logging.FromContext(cmd.Context()).Errorf("Failed to resolve symlink for %s: %v", s.Path, err) + return true + } + + absPath, err := filepath.Abs(resolvedPath) + if err != nil { + logging.FromContext(cmd.Context()).Errorf("Failed to get absolute path for %s: %v", resolvedPath, err) + return true + } + + logging.FromContext(cmd.Context()).Debugf("Extension %s: Original path: %s, Resolved absolute path: %s", s.Name, s.Path, absPath) + + customStaticDir := filepath.Join("custom", "static-plugins") + + isCustomStatic := strings.Contains(absPath, customStaticDir) || strings.HasSuffix(absPath, customStaticDir) + + if !isCustomStatic { + logging.FromContext(cmd.Context()).Debugf("Excluding %s as it's not in custom/static-plugins", s.Name) + } + return !isCustomStatic + }) + + logging.FromContext(cmd.Context()).Debugf("Found %d custom/static extensions after filtering", len(sources)) + for _, s := range sources { + logging.FromContext(cmd.Context()).Debugf("Included extension: %s, Path: %s", s.Name, s.Path) + } + + logging.FromContext(cmd.Context()).Debugf("Included extensions:") + for _, s := range sources { + logging.FromContext(cmd.Context()).Debugf(" - %s", s.Name) + } + } + + if onlyExtensions == "" && skipExtensions == "" && !onlyCustomStatic { logging.FromContext(cmd.Context()).Infof("Excluding extensions based on project config: %s", strings.Join(shopCfg.Build.ExcludeExtensions, ", ")) sources = slices.DeleteFunc(sources, func(s asset.Source) bool { return slices.Contains(shopCfg.Build.ExcludeExtensions, s.Name) diff --git a/cmd/project/project_admin_build.go b/cmd/project/project_admin_build.go index 49baba72..f76d09fe 100644 --- a/cmd/project/project_admin_build.go +++ b/cmd/project/project_admin_build.go @@ -78,4 +78,5 @@ func init() { projectAdminBuildCmd.PersistentFlags().Bool("force-install-dependencies", false, "Force install NPM dependencies") projectAdminBuildCmd.PersistentFlags().String("only-extensions", "", "Only watch the given extensions (comma separated)") projectAdminBuildCmd.PersistentFlags().String("skip-extensions", "", "Skips the given extensions (comma separated)") + projectAdminBuildCmd.PersistentFlags().Bool("only-custom-static-extensions", false, "Only build extensions from custom/static-plugins directory") } diff --git a/cmd/project/project_extension_upload.go b/cmd/project/project_extension_upload.go index 6ef4f645..58c5c27b 100644 --- a/cmd/project/project_extension_upload.go +++ b/cmd/project/project_extension_upload.go @@ -185,7 +185,7 @@ var projectExtensionUploadCmd = &cobra.Command{ } } - logging.FromContext(cmd.Context()).Infof("Uploaded extension %s with version %s", name, version) + logging.FromContext(cmd.Context()).Infof("Uploaded extension %s with version %s", name, version.String()) if _, err := client.ExtensionManager.Refresh(adminCtx); err != nil { return fmt.Errorf("cannot refresh extension list: %w", err) diff --git a/cmd/project/project_fix.go b/cmd/project/project_fix.go new file mode 100644 index 00000000..eb67ec7a --- /dev/null +++ b/cmd/project/project_fix.go @@ -0,0 +1,77 @@ +package project + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/shopware/shopware-cli/internal/verifier" +) + +var projectFixCmd = &cobra.Command{ + Use: "fix", + Short: "Fix project", + PreRunE: func(cmd *cobra.Command, args []string) error { + return verifier.SetupTools(cmd.Context(), cmd.Root().Version) + }, + RunE: func(cmd *cobra.Command, args []string) error { + allowNonGit, _ := cmd.Flags().GetBool("allow-non-git") + gitPath := filepath.Join(args[0], ".git") + if !allowNonGit { + if stat, err := os.Stat(gitPath); err != nil || !stat.IsDir() { + return fmt.Errorf("provided folder is not a git repository. Use --allow-non-git flag to run anyway") + } + } + + var err error + only, _ := cmd.Flags().GetString("only") + + projectPath := "" + + if len(args) > 0 { + projectPath = args[0] + } else { + projectPath, err = findClosestShopwareProject() + if err != nil { + return err + } + } + + projectPath, err = filepath.Abs(projectPath) + if err != nil { + return fmt.Errorf("cannot find path: %w", err) + } + + toolCfg, err := verifier.GetConfigFromProject(projectPath) + if err != nil { + return err + } + + var gr errgroup.Group + + tools := verifier.GetTools() + + tools, err = tools.Only(only) + if err != nil { + return err + } + + for _, tool := range tools { + tool := tool + gr.Go(func() error { + return tool.Fix(cmd.Context(), *toolCfg) + }) + } + + return gr.Wait() + }, +} + +func init() { + projectRootCmd.AddCommand(projectFixCmd) + projectFixCmd.PersistentFlags().String("only", "", "Run only specific tools by name (comma-separated, e.g. phpstan,eslint)") + projectFixCmd.PersistentFlags().Bool("allow-non-git", false, "Allow running on non git repositories") +} diff --git a/cmd/project/project_format.go b/cmd/project/project_format.go new file mode 100644 index 00000000..e8ee4713 --- /dev/null +++ b/cmd/project/project_format.go @@ -0,0 +1,69 @@ +package project + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/shopware/shopware-cli/internal/verifier" +) + +var projectFormatCmd = &cobra.Command{ + Use: "format", + Short: "Format project", + PreRunE: func(cmd *cobra.Command, args []string) error { + return verifier.SetupTools(cmd.Context(), cmd.Root().Version) + }, + RunE: func(cmd *cobra.Command, args []string) error { + var err error + only, _ := cmd.Flags().GetString("only") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + projectPath := "" + + if len(args) > 0 { + projectPath = args[0] + } else { + projectPath, err = findClosestShopwareProject() + if err != nil { + return err + } + } + + projectPath, err = filepath.Abs(projectPath) + if err != nil { + return fmt.Errorf("cannot find path: %w", err) + } + + toolCfg, err := verifier.GetConfigFromProject(projectPath) + if err != nil { + return err + } + + var gr errgroup.Group + + tools := verifier.GetTools() + + tools, err = tools.Only(only) + if err != nil { + return err + } + + for _, tool := range tools { + tool := tool + gr.Go(func() error { + return tool.Format(cmd.Context(), *toolCfg, dryRun) + }) + } + + return gr.Wait() + }, +} + +func init() { + projectRootCmd.AddCommand(projectFormatCmd) + projectFormatCmd.PersistentFlags().String("only", "", "Run only specific tools by name (comma-separated, e.g. phpstan,eslint)") + projectFormatCmd.PersistentFlags().Bool("dry-run", false, "Run tools in dry run mode") +} diff --git a/cmd/project/project_storefront_build.go b/cmd/project/project_storefront_build.go index 8312378c..c243089b 100644 --- a/cmd/project/project_storefront_build.go +++ b/cmd/project/project_storefront_build.go @@ -78,4 +78,5 @@ func init() { projectStorefrontBuildCmd.PersistentFlags().Bool("force-install-dependencies", false, "Force install NPM dependencies") projectStorefrontBuildCmd.PersistentFlags().String("only-extensions", "", "Only watch the given extensions (comma separated)") projectStorefrontBuildCmd.PersistentFlags().String("skip-extensions", "", "Skips the given extensions (comma separated)") + projectStorefrontBuildCmd.PersistentFlags().Bool("only-custom-static-extensions", false, "Only build extensions from custom/static-plugins directory") } diff --git a/cmd/project/project_validate.go b/cmd/project/project_validate.go new file mode 100644 index 00000000..afa2f6bc --- /dev/null +++ b/cmd/project/project_validate.go @@ -0,0 +1,88 @@ +package project + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/verifier" +) + +var projectValidateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate project", + PreRunE: func(cmd *cobra.Command, args []string) error { + return verifier.SetupTools(cmd.Context(), cmd.Root().Version) + }, + RunE: func(cmd *cobra.Command, args []string) error { + reportingFormat, _ := cmd.Flags().GetString("reporter") + only, _ := cmd.Flags().GetString("only") + tmpDir, err := os.MkdirTemp(os.TempDir(), "analyse-project-*") + if err != nil { + return fmt.Errorf("cannot create temporary directory: %w", err) + } + + projectPath := "" + + if len(args) > 0 { + projectPath = args[0] + } else { + projectPath, err = findClosestShopwareProject() + if err != nil { + return err + } + } + + projectPath, err = filepath.Abs(projectPath) + if err != nil { + return fmt.Errorf("cannot find path: %w", err) + } + + if reportingFormat == "" { + reportingFormat = verifier.DetectDefaultReporter() + } + + if err := system.CopyFiles(projectPath, tmpDir); err != nil { + return err + } + + toolCfg, err := verifier.GetConfigFromProject(tmpDir) + if err != nil { + return err + } + + result := verifier.NewCheck() + + var gr errgroup.Group + + tools := verifier.GetTools() + + tools, err = tools.Only(only) + if err != nil { + return err + } + + for _, tool := range tools { + tool := tool + gr.Go(func() error { + return tool.Check(cmd.Context(), result, *toolCfg) + }) + } + + if err := gr.Wait(); err != nil { + return err + } + + return verifier.DoCheckReport(result.RemoveByIdentifier(toolCfg.ValidationIgnores), reportingFormat) + }, +} + +func init() { + projectRootCmd.AddCommand(projectValidateCmd) + projectValidateCmd.PersistentFlags().String("reporter", "", "Reporting format (summary, json, github, junit, markdown)") + projectValidateCmd.PersistentFlags().String("only", "", "Run only specific tools by name (comma-separated, e.g. phpstan,eslint)") +} diff --git a/cmd/root.go b/cmd/root.go index 20ff44a6..abdff529 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "context" "os" + "slices" "github.com/spf13/cobra" @@ -27,13 +28,7 @@ var rootCmd = &cobra.Command{ } func Execute(ctx context.Context) { - verbose := false - - if err := rootCmd.ParseFlags(os.Args); err == nil { - verbose, _ = rootCmd.PersistentFlags().GetBool("verbose") - } - - ctx = logging.WithLogger(ctx, logging.NewLogger(verbose)) + ctx = logging.WithLogger(ctx, logging.NewLogger(slices.Contains(os.Args, "--verbose"))) accountApi.SetUserAgent("shopware-cli/" + version) if err := rootCmd.ExecuteContext(ctx); err != nil { diff --git a/extension/bundle_test.go b/extension/bundle_test.go index a84343b9..9ebb26cb 100644 --- a/extension/bundle_test.go +++ b/extension/bundle_test.go @@ -89,7 +89,7 @@ func TestCreateBundle(t *testing.T) { version, err := bundle.GetVersion() assert.NoError(t, err) - assert.Equal(t, "1.0.0.0", version.String()) + assert.Equal(t, "1.0.0", version.String()) // does notthing bundle.Validate(getTestContext(), &ValidationContext{}) diff --git a/extension/snippet_validator.go b/extension/snippet_validator.go index f6c68569..c65373b6 100644 --- a/extension/snippet_validator.go +++ b/extension/snippet_validator.go @@ -106,7 +106,7 @@ func validateStorefrontSnippetsByPath(snippetFolder, rootDir string, context *Va continue } - compareSnippets(mainFileContent, file, context, rootDir) + compareSnippets(mainFileContent, mainFile, file, context, rootDir) } } @@ -190,7 +190,7 @@ func validateAdministrationByPath(adminFolder, rootDir string, context *Validati } if len(mainFile) == 0 { - context.AddWarning("snippet.validator", fmt.Sprintf("No en-GB.json file found in %s, using %s", folder, files[0])) + context.AddWarning("snippet.validator", fmt.Sprintf("No en-GB.json file found in %s, using %s", strings.ReplaceAll(folder, rootDir+"/", ""), strings.ReplaceAll(files[0], rootDir+"/", ""))) mainFile = files[0] } @@ -211,14 +211,14 @@ func validateAdministrationByPath(adminFolder, rootDir string, context *Validati continue } - compareSnippets(mainFileContent, file, context, rootDir) + compareSnippets(mainFileContent, mainFile, file, context, rootDir) } } return nil } -func compareSnippets(mainFile []byte, file string, context *ValidationContext, extensionRoot string) { +func compareSnippets(mainFile []byte, mainFilePath, file string, context *ValidationContext, extensionRoot string) { checkFile, err := os.ReadFile(file) if err != nil { context.AddError("snippet.validator", fmt.Sprintf("Cannot read file '%s', due '%s'", file, err)) @@ -239,6 +239,8 @@ func compareSnippets(mainFile []byte, file string, context *ValidationContext, e return } + normalizedMainFilePath := strings.ReplaceAll(mainFilePath, extensionRoot+"/", "") + for _, diff := range compare { normalizedPath := strings.ReplaceAll(file, extensionRoot+"/", "") @@ -248,7 +250,7 @@ func compareSnippets(mainFile []byte, file string, context *ValidationContext, e } if diff.Type == jsondiff.OperationAdd { - context.AddWarning("snippet.validator", fmt.Sprintf("Snippet file: %s, missing key \"%s\" in this snippet file, but defined in the main language (\"%s\")", normalizedPath, diff.Path, mainFile)) + context.AddWarning("snippet.validator", fmt.Sprintf("Snippet file: %s, missing key \"%s\" in this snippet file, but defined in the main language (%s)", normalizedPath, diff.Path, normalizedMainFilePath)) continue } diff --git a/extension/zip.go b/extension/zip.go index f6baa490..6b16a522 100644 --- a/extension/zip.go +++ b/extension/zip.go @@ -523,13 +523,15 @@ func PrepareExtensionForRelease(ctx context.Context, sourceRoot, extensionRoot s logging.FromContext(ctx).Infof("Generated changelog for version %s", v.String()) - content, err := changelog.GenerateChangelog(ctx, sourceRoot, ext.GetExtensionConfig().Changelog) + content, err := changelog.GenerateChangelog(ctx, v.String(), sourceRoot, ext.GetExtensionConfig().Changelog) if err != nil { return err } changelogFile := fmt.Sprintf("# %s\n%s", v.String(), content) + logging.FromContext(ctx).Debugf("Changelog:\n%s", changelogFile) + if err := os.WriteFile(path.Join(extensionRoot, "CHANGELOG_en-GB.md"), []byte(changelogFile), os.ModePerm); err != nil { return err } diff --git a/go.mod b/go.mod index ddafb028..501f7929 100644 --- a/go.mod +++ b/go.mod @@ -16,13 +16,14 @@ require ( github.com/evanw/esbuild v0.25.4 github.com/friendsofshopware/go-shopware-admin-api-sdk v0.0.0-20240608075117-3c16ae8b5f02 github.com/go-sql-driver/mysql v1.9.2 + github.com/google/generative-ai-go v0.20.1 github.com/google/uuid v1.6.0 github.com/gorilla/schema v1.4.1 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 github.com/microcosm-cc/bluemonday v1.0.27 github.com/otiai10/copy v1.14.1 - github.com/shyim/go-version v0.0.0-20250513054659-15e244b52011 + github.com/shyim/go-version v0.0.0-20250514132108-aa2854d2c40b github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/tetratelabs/wazero v1.9.0 @@ -32,10 +33,17 @@ require ( github.com/yuin/goldmark v1.7.11 go.uber.org/zap v1.27.0 golang.org/x/text v0.25.0 + google.golang.org/api v0.186.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + cloud.google.com/go v0.115.0 // indirect + cloud.google.com/go/ai v0.8.0 // indirect + cloud.google.com/go/auth v0.6.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect + cloud.google.com/go/compute/metadata v0.3.0 // indirect + cloud.google.com/go/longrunning v0.5.7 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect @@ -48,6 +56,14 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.5 // indirect github.com/jaswdr/faker/v2 v2.5.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -57,6 +73,17 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect + go.opentelemetry.io/otel v1.26.0 // indirect + go.opentelemetry.io/otel/metric v1.26.0 // indirect + go.opentelemetry.io/otel/trace v1.26.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect + google.golang.org/grpc v1.64.1 // indirect ) require ( @@ -74,6 +101,7 @@ require ( github.com/otiai10/mint v1.6.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sergi/go-diff v1.3.1 github.com/shyim/go-htmlprinter v0.0.0-20250417052954-e3e325d9ba3f github.com/spf13/pflag v1.0.6 // indirect github.com/tidwall/gjson v1.18.0 // indirect diff --git a/go.sum b/go.sum index 1bcc36d3..48a4f64b 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,21 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= +cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= +cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= +cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= +cloud.google.com/go/auth v0.6.0 h1:5x+d6b5zdezZ7gmLWD1m/xNjnaQ2YDhmIz/HH3doy1g= +cloud.google.com/go/auth v0.6.0/go.mod h1:b4acV+jLQDyjwm4OXHYjNvRi4jvGBzHWJRtJcy+2P4g= +cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= +cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU= +cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -26,6 +40,7 @@ github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= @@ -56,6 +71,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -64,24 +81,66 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanw/esbuild v0.25.4 h1:k1bTSim+usBG27w7BfOCorhgx3tO+6bAfMj5pR+6SKg= github.com/evanw/esbuild v0.25.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.2 h1:SPb1KFFmM+ybpEjPUhCCkZOM5xlovT5UbrMvWnXyBns= github.com/frankban/quicktest v1.14.2/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/friendsofshopware/go-shopware-admin-api-sdk v0.0.0-20240608075117-3c16ae8b5f02 h1:M8DoMB8zLcxWCbltAWhgofyB+Vc35VnYVMCUS6hLV2E= github.com/friendsofshopware/go-shopware-admin-api-sdk v0.0.0-20240608075117-3c16ae8b5f02/go.mod h1:1Ou4HCVCMqON0XEz/uLlhPlY3sp3JVs3sKymxcYUx5k= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ= +github.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= +github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= @@ -98,8 +157,11 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -128,24 +190,39 @@ github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shyim/go-htmlprinter v0.0.0-20250417052954-e3e325d9ba3f h1:b7RObIj6TnesTadmtJ9pidOTxW72lODQcvwrLaje6/g= github.com/shyim/go-htmlprinter v0.0.0-20250417052954-e3e325d9ba3f/go.mod h1:UZ9KS4PRWtcsdL8IqdeXQK+L+L1tDqe6+lfmq9b12eo= github.com/shyim/go-mad v0.0.0-20250401132841-d19165fb2107 h1:95bYA7SIB+eQkQtEkJIxhq/Etg4DLBer8whBCmCr0SI= github.com/shyim/go-mad v0.0.0-20250401132841-d19165fb2107/go.mod h1:hBy5kFLYGAHENquvUiKijj2QSKCmZ86u9YvZMi83zIE= github.com/shyim/go-version v0.0.0-20250513054659-15e244b52011 h1:Fv97bFEM3NPyDWLtTnbGP81ahVqlXytsFhlqBcG74GY= github.com/shyim/go-version v0.0.0-20250513054659-15e244b52011/go.mod h1:z47ygE4N7EC0H58FP5j5cXZtl1pSnfjwYJsHtiVtcwU= +github.com/shyim/go-version v0.0.0-20250514104457-ded98488bd6f h1:0zgN0ZKq9EmT7sL0MEn9V70rGiwkj78Vv1WD0xmys8c= +github.com/shyim/go-version v0.0.0-20250514104457-ded98488bd6f/go.mod h1:z47ygE4N7EC0H58FP5j5cXZtl1pSnfjwYJsHtiVtcwU= +github.com/shyim/go-version v0.0.0-20250514131704-4ce943532a2c h1:gJ1D2/OkhbH/TOFffIx42cSBAF2qbuJ0JXlDr4zljrM= +github.com/shyim/go-version v0.0.0-20250514131704-4ce943532a2c/go.mod h1:z47ygE4N7EC0H58FP5j5cXZtl1pSnfjwYJsHtiVtcwU= +github.com/shyim/go-version v0.0.0-20250514132108-aa2854d2c40b h1:BBiMTvsse8YOfQuP/bFqxJjeqhkJtY1M0ZlGiwP5xtA= +github.com/shyim/go-version v0.0.0-20250514132108-aa2854d2c40b/go.mod h1:z47ygE4N7EC0H58FP5j5cXZtl1pSnfjwYJsHtiVtcwU= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= @@ -170,32 +247,107 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= +go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= +go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= +go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= +go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= +go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= +go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.186.0 h1:n2OPp+PPXX0Axh4GuSsL5QL8xQCTb2oDwyzPnQvqUug= +google.golang.org/api v0.186.0/go.mod h1:hvRbBmgoje49RV3xqVXrmP6w93n6ehGgIVPYrGtBFFc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 h1:MuYw1wJzT+ZkybKfaOXKp5hJiZDn2iHaXRw0mRYdHSc= +google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4/go.mod h1:px9SlOOZBg1wM1zdnr8jEL4CNGUBZ+ZKYtNPApNQc4c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= +google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go index 530e4808..178fe783 100644 --- a/internal/changelog/changelog.go +++ b/internal/changelog/changelog.go @@ -35,7 +35,7 @@ type Commit struct { } // GenerateChangelog generates a changelog from the git repository. -func GenerateChangelog(ctx context.Context, repository string, cfg Config) (string, error) { +func GenerateChangelog(ctx context.Context, currentVersion string, repository string, cfg Config) (string, error) { var err error if cfg.Template == "" { @@ -50,7 +50,7 @@ func GenerateChangelog(ctx context.Context, repository string, cfg Config) (stri return "", err } - commits, err := git.GetCommits(ctx, repository) + commits, err := git.GetCommits(ctx, currentVersion, repository) if err != nil { return "", err } diff --git a/internal/git/git.go b/internal/git/git.go index 65748ccb..56c45053 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -6,7 +6,12 @@ import ( "os" "os/exec" "path" + "sort" "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/logging" ) type GitCommit struct { @@ -31,55 +36,81 @@ func runGit(ctx context.Context, repo string, args ...string) (string, error) { return strings.Trim(gitOuput, " "), nil } -func getPreviousTag(ctx context.Context, repo string) (string, error) { - if err := unshallowRepository(ctx, repo); err != nil { - return "", err - } - +func getPreviousTag(ctx context.Context, currentTag, repo string) (string, error) { previousVersion := os.Getenv("SHOPWARE_CLI_PREVIOUS_TAG") if previousVersion != "" { return previousVersion, nil } - commits, err := runGit(ctx, repo, "log", "--pretty=format:%h", "--no-merges") + tags, err := runGit(ctx, repo, "tag", "--list") if err != nil { - return "", fmt.Errorf("cannot get previous tag: %w", err) + return "", fmt.Errorf("failed to run git command: %w", err) } - commitsArray := strings.Split(commits, "\n") - for commit := range commitsArray { - contains, err := runGit(ctx, repo, "tag", "--contains", commitsArray[commit]) - if err != nil { - return "", fmt.Errorf("cannot get previous tag: %w", err) - } + // direct tag match + tagsArray := strings.Split(tags, "\n") - if contains == "" { + var tagList []*version.Version + + for _, tag := range tagsArray { + v, err := version.NewVersion(tag) + if err != nil { continue } - matchingTags := strings.Split(contains, "\n") + tagList = append(tagList, v) + } + + currentVersion := version.Must(version.NewVersion(currentTag)) - if len(matchingTags) == 0 { - continue + sort.Sort(sort.Reverse(version.Collection(tagList))) + + // same major version + currentMajor := currentVersion.Segments()[0] + for _, tag := range tagList { + if tag.Segments()[0] == currentMajor && tag.LessThan(currentVersion) { + return tag.String(), nil } + } - return matchingTags[0], nil + // Look at previous major version + for _, tag := range tagList { + if tag.Segments()[0] == currentMajor-1 { + return tag.String(), nil + } + } + + commits, err := runGit(ctx, repo, "log", "--pretty=format:%h", "--no-merges") + if err != nil { + return "", fmt.Errorf("cannot get previous tag: %w", err) } + commitsArray := strings.Split(commits, "\n") + // if no tag was found, return the first commit return commitsArray[len(commitsArray)-1], nil } -func GetCommits(ctx context.Context, repo string) ([]GitCommit, error) { +func GetCommits(ctx context.Context, currentVersion, repo string) ([]GitCommit, error) { if err := unshallowRepository(ctx, repo); err != nil { return nil, err } - previousTag, err := getPreviousTag(ctx, repo) + currentTag, err := getTagForVersion(ctx, currentVersion, repo) + if err != nil { + return nil, err + } + + logging.FromContext(ctx).Debugf("Current tag: %s", currentTag) + + previousTag, err := getPreviousTag(ctx, currentTag, repo) if err != nil { return nil, err } + logging.FromContext(ctx).Debugf("Previous tag: %s", previousTag) + logging.FromContext(ctx).Debugf("Diffing %s..HEAD", previousTag) + commits, err := runGit(ctx, repo, "log", "--pretty=format:%h|%s", previousTag+"..HEAD", "--no-merges") if err != nil { return nil, fmt.Errorf("cannot get commits: %w", err) @@ -103,6 +134,33 @@ func GetCommits(ctx context.Context, repo string) ([]GitCommit, error) { return gitCommits, nil } +func getTagForVersion(ctx context.Context, version, repo string) (string, error) { + version = strings.TrimPrefix(version, "v") + + tags, err := runGit(ctx, repo, "tag", "--list") + if err != nil { + return "", fmt.Errorf("failed to run git command: %w", err) + } + + // direct tag match + tagsArray := strings.Split(tags, "\n") + for _, tag := range tagsArray { + if tag == version { + return tag, nil + } + } + + // tag prefix match + for _, tag := range tagsArray { + tag = strings.TrimPrefix(tag, "v") + if strings.HasPrefix(tag, version) { + return tag, nil + } + } + + return version, nil +} + func GetPublicVCSURL(ctx context.Context, repo string) (string, error) { origin, err := runGit(ctx, repo, "config", "--get", "remote.origin.url") if err != nil { diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 62127657..dba47442 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -13,7 +13,7 @@ func TestInvalidGitRepository(t *testing.T) { repo := "invalid" ctx := t.Context() - tag, err := getPreviousTag(ctx, repo) + tag, err := getPreviousTag(ctx, "", repo) assert.Error(t, err) assert.Empty(t, tag) } @@ -25,11 +25,15 @@ func TestNoTags(t *testing.T) { runCommand(t, tmpDir, "add", "a") runCommand(t, tmpDir, "commit", "-m", "initial commit", "--no-verify", "--no-gpg-sign") - tag, err := getPreviousTag(t.Context(), tmpDir) + tag, err := getPreviousTag(t.Context(), "1.0.0", tmpDir) assert.NoError(t, err) assert.NotEmpty(t, tag) - commits, err := GetCommits(t.Context(), tmpDir) + currentTag, err := getTagForVersion(t.Context(), "1.0.0", tmpDir) + assert.NoError(t, err) + assert.Equal(t, "1.0.0", currentTag) + + commits, err := GetCommits(t.Context(), "1.0.0", tmpDir) assert.NoError(t, err) assert.Len(t, commits, 0) } @@ -45,11 +49,15 @@ func TestWithOneTagAndCommit(t *testing.T) { runCommand(t, tmpDir, "add", "b") runCommand(t, tmpDir, "commit", "-m", "second commit", "--no-verify", "--no-gpg-sign") - tag, err := getPreviousTag(t.Context(), tmpDir) + tag, err := getPreviousTag(t.Context(), "1.0.0", tmpDir) + assert.NoError(t, err) + assert.NotEqual(t, tag, "v1.0.0") + + currentTag, err := getTagForVersion(t.Context(), "1.0.0", tmpDir) assert.NoError(t, err) - assert.Equal(t, tag, "v1.0.0") + assert.Equal(t, "1.0.0", currentTag) - commits, err := GetCommits(t.Context(), tmpDir) + commits, err := GetCommits(t.Context(), "1.0.0", tmpDir) assert.NoError(t, err) assert.Len(t, commits, 1) assert.Equal(t, commits[0].Message, "second commit") diff --git a/internal/html/parser.go b/internal/html/parser.go new file mode 100644 index 00000000..179cd3d3 --- /dev/null +++ b/internal/html/parser.go @@ -0,0 +1,1425 @@ +package html + +import ( + "fmt" + "strings" + "unicode" +) + +const htmlCommentStart = "") + + return builder.String() +} + +// TemplateExpressionNode represents a {{...}} template expression. +type TemplateExpressionNode struct { + Expression string + Line int +} + +// Dump returns the template expression with {{ }} delimiters. +func (t *TemplateExpressionNode) Dump(indent int) string { + return "{{" + t.Expression + "}}" +} + +// ElementNode represents an HTML element. +type ElementNode struct { + Tag string + Attributes NodeList + Children NodeList + SelfClosing bool + Line int // added field +} + +// Dump returns the HTML representation of the element and its children. +// +//nolint:gocyclo +func (e *ElementNode) Dump(indent int) string { + var builder strings.Builder + indentStr := indentConfig.GetIndent() + + // Add initial indentation + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + + builder.WriteString("<" + e.Tag) + + attributesDidNewLine := false + + // Add attributes + if len(e.Attributes) > 0 { + if len(e.Attributes) == 1 { + attributeStr := e.Attributes[0].Dump(indent + 1) + _, isIfNode := e.Attributes[0].(*TwigIfNode) + + if len(attributeStr) > 80 || isIfNode { + builder.WriteString("\n") + builder.WriteString(attributeStr) + builder.WriteString("\n") + attributesDidNewLine = true + } else { + if !isIfNode { + attributeStr = e.Attributes[0].Dump(0) + } + builder.WriteString(" ") + builder.WriteString(attributeStr) + } + } else { + for _, attr := range e.Attributes { + builder.WriteString("\n") + attributesDidNewLine = true + builder.WriteString(attr.Dump(indent + 1)) + } + builder.WriteString("\n") + } + } + + if attributesDidNewLine { + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + } + + // Handle self-closing tags + if e.SelfClosing { + builder.WriteString("/>") + return builder.String() + } + + builder.WriteString(">") + + // Handle children + if len(e.Children) > 0 { + // Special case: if all children are text/comments/template expressions, keep them on same line + allSimpleNodes := true + hasLongTemplateExpression := false + multipleTemplateExpressions := 0 + multipleShortTemplateExpressions := false + + // Count template expressions and check for long ones + for _, child := range e.Children { + if tplExpr, ok := child.(*TemplateExpressionNode); ok { + multipleTemplateExpressions++ + if len(tplExpr.Dump(0)) > 30 { + hasLongTemplateExpression = true + } + } else if _, ok := child.(*RawNode); !ok { + if _, ok := child.(*CommentNode); !ok { + allSimpleNodes = false + break + } + } + } + + // Check if we have multiple short template expressions + if multipleTemplateExpressions > 1 && !hasLongTemplateExpression { + // Check if they're short enough to stay on one line + totalLength := 0 + for _, child := range e.Children { + if tplExpr, ok := child.(*TemplateExpressionNode); ok { + totalLength += len(tplExpr.Dump(indent + 1)) + } + } + // If the combined length is short, keep them on the same line + if totalLength <= 100 { + multipleShortTemplateExpressions = true + } + } + + if allSimpleNodes { + // Format based on content + if hasLongTemplateExpression || (multipleTemplateExpressions > 1 && !multipleShortTemplateExpressions) { + // For template expressions that are long or multiple long ones, add nice formatting + builder.WriteString("\n") + for _, child := range e.Children { + if _, ok := child.(*TemplateExpressionNode); ok { + for j := 0; j < indent+1; j++ { + builder.WriteString(indentStr) + } + builder.WriteString(child.Dump(indent+1) + "\n") + } else if raw, ok := child.(*RawNode); ok { + trimmed := strings.TrimSpace(raw.Text) + if trimmed != "" { + for j := 0; j < indent+1; j++ { + builder.WriteString(indentStr) + } + builder.WriteString(trimmed + "\n") + } + } else { + builder.WriteString(child.Dump(indent + 1)) + } + } + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + } else { + // For simple content, keep on the same line + for _, child := range e.Children { + builder.WriteString(child.Dump(indent)) + } + } + } else { + // For complex nodes, format with proper indentation + var nonEmptyChildren NodeList + for _, child := range e.Children { + if raw, ok := child.(*RawNode); ok { + if strings.TrimSpace(raw.Text) != "" { + nonEmptyChildren = append(nonEmptyChildren, raw) + } + } else { + nonEmptyChildren = append(nonEmptyChildren, child) + } + } + + // Check for template elements and add extra newlines between them + for i, child := range nonEmptyChildren { + builder.WriteString("\n") + + // Add an extra newline between template elements + if i > 0 && isTemplateElement(child) && isTemplateElement(nonEmptyChildren[i-1]) { + builder.WriteString("\n") + } + + if elementChild, ok := child.(*ElementNode); ok { + builder.WriteString(elementChild.Dump(indent + 1)) + } else { + for j := 0; j < indent+1; j++ { + builder.WriteString(indentStr) + } + builder.WriteString(strings.TrimSpace(child.Dump(indent + 1))) + } + } + builder.WriteString("\n") + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + } + } + + builder.WriteString("") + return builder.String() +} + +// TwigBlockNode represents a twig block. +type TwigBlockNode struct { + Name string + Children NodeList + Line int +} + +// Dump returns the twig block with proper formatting. +func (t *TwigBlockNode) Dump(indent int) string { + var builder strings.Builder + indentStr := indentConfig.GetIndent() + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + builder.WriteString("{% block " + t.Name + " %}") + + // Filter out empty nodes and normalize newlines + var nonEmptyChildren NodeList + for _, child := range t.Children { + if raw, ok := child.(*RawNode); ok { + if strings.TrimSpace(raw.Text) != "" { + nonEmptyChildren = append(nonEmptyChildren, raw) + } + } else if twigBlock, ok := child.(*TwigBlockNode); ok { + if strings.TrimSpace(twigBlock.Dump(0)) != "" { + nonEmptyChildren = append(nonEmptyChildren, twigBlock) + } + } else { + nonEmptyChildren = append(nonEmptyChildren, child) + } + } + + if len(nonEmptyChildren) > 0 { + builder.WriteString("\n") + for i, child := range nonEmptyChildren { + if elementChild, ok := child.(*ElementNode); ok { + builder.WriteString(elementChild.Dump(indent + 1)) + } else { + builder.WriteString(child.Dump(indent + 1)) + } + + _, isComment := child.(*CommentNode) + + if i < len(nonEmptyChildren)-1 { + // Add an extra newline between elements + if isComment { + builder.WriteString("\n") + } else { + builder.WriteString("\n\n") + } + } + } + builder.WriteString("\n") + + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + + builder.WriteString("{% endblock %}") + } else { + builder.WriteString("{% endblock %}") + } + + return builder.String() +} + +// TwigIfNode represents a Twig if block +type TwigIfNode struct { + Condition string + Children NodeList + ElseIfConditions []string + ElseIfChildren []NodeList + ElseChildren NodeList + Line int +} + +// Dump returns the twig if block with proper formatting +// +//nolint:gocyclo +func (t *TwigIfNode) Dump(indent int) string { + var builder strings.Builder + indentStr := indentConfig.GetIndent() + + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + + builder.WriteString("{% if " + t.Condition + " %}") + + // Filter out empty nodes and normalize newlines for if branch + var nonEmptyChildren NodeList + for _, child := range t.Children { + if raw, ok := child.(*RawNode); ok { + if strings.TrimSpace(raw.Text) != "" { + nonEmptyChildren = append(nonEmptyChildren, raw) + } + } else { + nonEmptyChildren = append(nonEmptyChildren, child) + } + } + + if len(nonEmptyChildren) > 0 { + builder.WriteString("\n") + for i, child := range nonEmptyChildren { + if elementChild, ok := child.(*ElementNode); ok { + builder.WriteString(elementChild.Dump(indent + 1)) + } else { + for i := 0; i < indent+1; i++ { + builder.WriteString(indentStr) + } + builder.WriteString(strings.TrimSpace(child.Dump(indent + 1))) + } + if i < len(nonEmptyChildren)-1 { + // Add an extra newline between elements + builder.WriteString("\n") + } + } + builder.WriteString("\n") + } + + // Handle elseif branches if they exist + for i, condition := range t.ElseIfConditions { + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + builder.WriteString("{% elseif " + condition + " %}") + + // Filter out empty nodes and normalize newlines for elseif branch + nonEmptyChildren = NodeList{} + for _, child := range t.ElseIfChildren[i] { + if raw, ok := child.(*RawNode); ok { + if strings.TrimSpace(raw.Text) != "" { + nonEmptyChildren = append(nonEmptyChildren, raw) + } + } else { + nonEmptyChildren = append(nonEmptyChildren, child) + } + } + + if len(nonEmptyChildren) > 0 { + builder.WriteString("\n") + for j, child := range nonEmptyChildren { + if elementChild, ok := child.(*ElementNode); ok { + builder.WriteString(elementChild.Dump(indent + 1)) + } else { + for i := 0; i < indent+1; i++ { + builder.WriteString(indentStr) + } + builder.WriteString(strings.TrimSpace(child.Dump(indent + 1))) + } + if j < len(nonEmptyChildren)-1 { + // Add an extra newline between elements + builder.WriteString("\n") + } + } + builder.WriteString("\n") + } + } + + // Handle else branch if it exists + if len(t.ElseChildren) > 0 { + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + builder.WriteString("{% else %}") + + // Filter out empty nodes and normalize newlines for else branch + var nonEmptyElseChildren NodeList + for _, child := range t.ElseChildren { + if raw, ok := child.(*RawNode); ok { + if strings.TrimSpace(raw.Text) != "" { + nonEmptyElseChildren = append(nonEmptyElseChildren, raw) + } + } else { + nonEmptyElseChildren = append(nonEmptyElseChildren, child) + } + } + + if len(nonEmptyElseChildren) > 0 { + builder.WriteString("\n") + for i, child := range nonEmptyElseChildren { + if elementChild, ok := child.(*ElementNode); ok { + builder.WriteString(elementChild.Dump(indent + 1)) + } else { + for i := 0; i < indent+1; i++ { + builder.WriteString(indentStr) + } + builder.WriteString(strings.TrimSpace(child.Dump(indent + 1))) + } + if i < len(nonEmptyElseChildren)-1 { + // Add an extra newline between elements + builder.WriteString("\n") + } + } + builder.WriteString("\n") + } + } + + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + + builder.WriteString("{% endif %}") + return builder.String() +} + +// ParentNode represents a twig parent() call +type ParentNode struct { + Line int +} + +func (p *ParentNode) Dump(indent int) string { + var builder strings.Builder + indentStr := indentConfig.GetIndent() + for i := 0; i < indent; i++ { + builder.WriteString(indentStr) + } + + builder.WriteString("{% parent() %}") + + return builder.String() +} + +// Parser holds the state for our simple parser. +type Parser struct { + input string + pos int + length int +} + +// NewParser creates a new parser for the given input. +func NewParser(input string) (NodeList, error) { + p := &Parser{input: input, pos: 0, length: len(input)} + + return p.parseNodes("") +} + +// current returns the current byte (or zero if at the end). +func (p *Parser) current() byte { + if p.pos >= p.length { + return 0 + } + return p.input[p.pos] +} + +// peek returns the next n characters (or what remains). +func (p *Parser) peek(n int) string { + if p.pos+n > p.length { + return p.input[p.pos:] + } + return p.input[p.pos : p.pos+n] +} + +// skipWhitespace advances the position over any whitespace. +func (p *Parser) skipWhitespace() { + for p.pos < p.length && + (p.input[p.pos] == ' ' || p.input[p.pos] == '\n' || + p.input[p.pos] == '\r' || p.input[p.pos] == '\t') { + p.pos++ + } +} + +// Helper to get line number at a given position. +func (p *Parser) getLineAt(pos int) int { + return strings.Count(p.input[:pos], "\n") + 1 +} + +// parseComment parses an HTML comment and returns a CommentNode +func (p *Parser) parseComment() (*CommentNode, error) { + if p.peek(4) != htmlCommentStart { + //nolint: nilnil + return nil, nil + } + startPos := p.pos + p.pos += 4 // skip "") + if idx == -1 { + return nil, fmt.Errorf("unterminated comment starting at pos %d", startPos) + } + + commentText := strings.TrimSpace(p.input[start : start+idx]) + p.pos += idx + 3 // skip past "-->" + + return &CommentNode{ + Text: commentText, + Line: p.getLineAt(startPos), + }, nil +} + +// parseNodes parses a list of nodes until an optional stop tag (used for element children). +// +//nolint:gocyclo +func (p *Parser) parseNodes(stopTag string) (NodeList, error) { + var nodes NodeList + rawStart := p.pos + + for p.pos < p.length { + // Check for endblock if we're parsing twig block children + if stopTag == "" && p.peek(2) == "{%" { + peek := p.input[p.pos:] + if strings.HasPrefix(peek, "{% endblock") { + break + } + } + + if p.peek(2) == "{%" { + if p.pos > rawStart { + text := p.input[rawStart:p.pos] + if strings.TrimSpace(text) != "" { + nodes = append(nodes, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + + // Try parsing twig directives first + directive, err := p.parseTwigDirective() + if err != nil { + return nodes, err + } + if directive != nil { + nodes = append(nodes, directive) + rawStart = p.pos + continue + } + + // If not a directive, try parsing as a block + startPos := p.pos + block, err := p.parseTwigBlock() + if err != nil { + return nodes, err + } + if block != nil { + nodes = append(nodes, block) + rawStart = p.pos + continue + } + + // If not a block, try parsing as an if statement + p.pos = startPos + ifNode, err := p.parseTwigIf() + if err != nil { + return nodes, err + } + if ifNode != nil { + nodes = append(nodes, ifNode) + rawStart = p.pos + continue + } + + // If it wasn't a block or if statement, reset position and continue as raw text + p.pos = startPos + } + + // Parse template expressions {{ ... }} + if p.peek(2) == "{{" { + if p.pos > rawStart { + text := p.input[rawStart:p.pos] + if text != "" { + nodes = append(nodes, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + + expression, err := p.parseTemplateExpression() + if err != nil { + return nodes, err + } + + nodes = append(nodes, expression) + rawStart = p.pos + continue + } + + if p.peek(4) == htmlCommentStart { + if p.pos > rawStart { + text := p.input[rawStart:p.pos] + if strings.TrimSpace(text) != "" { + nodes = append(nodes, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + comment, err := p.parseComment() + if err != nil { + return nodes, err + } + nodes = append(nodes, comment) + rawStart = p.pos + continue + } + + // If we're about to hit a closing tag for the current element, break. + if p.current() == '<' && p.peek(2) == " rawStart { + text := p.input[rawStart:p.pos] + if strings.TrimSpace(text) != "" { + nodes = append(nodes, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + element, err := p.parseElement() + if err != nil { + return nodes, err + } + nodes = append(nodes, element) + rawStart = p.pos + } else { + p.pos++ + } + } + + if rawStart < p.pos { + text := p.input[rawStart:p.pos] + if strings.TrimSpace(text) != "" { + nodes = append(nodes, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + return nodes, nil +} + +// isVoidElement returns true if the tag is a void element (e.g.,
does not require a closing tag) +func isVoidElement(tag string) bool { + switch strings.ToLower(tag) { + case "area", "base", "br", "col", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr": + return true + } + return false +} + +// parseElement parses an HTML element starting at the current position (assumes a '<'). +func (p *Parser) parseElement() (Node, error) { + // Record start position for line number. + startPos := p.pos + if p.current() != '<' { + //nolint: nilnil + return nil, nil + } + p.pos++ // skip '<' + p.skipWhitespace() + + tagName := p.parseTagName() + if tagName == "" { + return nil, fmt.Errorf("empty tag name at pos %d", p.pos) + } + + node := &ElementNode{ + Tag: tagName, + Attributes: NodeList{}, + Children: NodeList{}, + Line: p.getLineAt(startPos), + } + + // Parse element attributes. + for p.pos < p.length { + p.skipWhitespace() + // Check for Twig directives within attributes + if p.peek(2) == "{%" { + ifNode, err := p.parseTwigIf() + if err != nil { + return nil, err + } + if ifNode != nil { + node.Attributes = append(node.Attributes, ifNode) + // After parsing a Twig directive, we need to skip whitespace again + p.skipWhitespace() + continue + } + } + + if p.current() == '>' || (p.current() == '/' && p.peek(2) == "/>") { + break + } + attrName := p.parseAttrName() + if attrName == "" { + break + } + p.skipWhitespace() + var attrVal string + if p.current() == '=' { + p.pos++ // skip '=' + p.skipWhitespace() + attrVal = p.parseAttrValue() + } + // Append attribute preserving order. + node.Attributes = append(node.Attributes, Attribute{Key: attrName, Value: attrVal}) + } + + // Check for self-closing tag. + if p.current() == '/' { + p.pos++ // skip '/' + if p.current() != '>' { + return nil, fmt.Errorf("expected '>' after '/' at pos %d", p.pos) + } + p.pos++ // skip '>' + node.SelfClosing = true + return node, nil + } + if p.current() == '>' { + p.pos++ // skip '>' + if isVoidElement(tagName) { + node.SelfClosing = true + return node, nil + } + } else { + // Add more context to the error message + surroundingText := "" + start := p.pos - 10 + if start < 0 { + start = 0 + } + end := p.pos + 10 + if end > p.length { + end = p.length + } + surroundingText = p.input[start:end] + return nil, fmt.Errorf("expected '>' at pos %d, surrounding text: '%s', current byte: '%c'", p.pos, surroundingText, p.current()) + } + + // Parse children until the corresponding closing tag. + children, err := p.parseElementChildren(node.Tag) + if err != nil { + return nil, err + } + node.Children = children + + return node, nil +} + +// parseElementChildren parses the child nodes of an element until the closing tag is reached. +func (p *Parser) parseElementChildren(tag string) (NodeList, error) { + var children NodeList + rawStart := p.pos + + for p.pos < p.length { + if p.peek(4) == htmlCommentStart { + if p.pos > rawStart { + text := p.input[rawStart:p.pos] + if text != "" { + children = append(children, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + comment, err := p.parseComment() + if err != nil { + return children, err + } + children = append(children, comment) + rawStart = p.pos + continue + } + + // Parse template expressions {{ ... }} + if p.peek(2) == "{{" { + if p.pos > rawStart { + text := p.input[rawStart:p.pos] + if text != "" { + children = append(children, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + + expression, err := p.parseTemplateExpression() + if err != nil { + return children, err + } + + children = append(children, expression) + rawStart = p.pos + continue + } + + // Check for a closing tag. + if p.current() == '<' && p.peek(2) == "' { + p.pos++ // skip '>' + } else { + return children, + fmt.Errorf("expected '>' for closing tag at pos %d", p.pos) + } + if closingTag == tag { + // Add any raw text before the closing tag. + if rawStart < savedPos { + text := p.input[rawStart:savedPos] + if text != "" { + children = append(children, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + return children, nil + } else { + // Not the matching closing tag; reset and continue. + p.pos = savedPos + } + } + + if p.current() == '<' && p.peek(2) != htmlCommentStart { + if p.pos > rawStart { + text := p.input[rawStart:p.pos] + if text != "" { + children = append(children, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + child, err := p.parseElement() + if err != nil { + return children, err + } + children = append(children, child) + rawStart = p.pos + } else { + p.pos++ + } + } + return children, nil +} + +// parseTagName parses a tag or attribute name (letters, digits, '-' and ':'). +func (p *Parser) parseTagName() string { + start := p.pos + for p.pos < p.length { + c := p.input[p.pos] + if unicode.IsLetter(rune(c)) || unicode.IsDigit(rune(c)) || c == '-' || c == ':' { + p.pos++ + } else { + break + } + } + return p.input[start:p.pos] +} + +// parseAttrName parses an attribute name. +func (p *Parser) parseAttrName() string { + start := p.pos + // Accept characters until whitespace, '=', '>', or '/' + for p.pos < p.length { + c := p.input[p.pos] + if c == ' ' || c == '\n' || c == '\r' || c == '\t' || + c == '=' || c == '>' || c == '/' { + break + } + p.pos++ + } + return p.input[start:p.pos] +} + +// parseAttrValue parses an attribute value (expects a quoted string). +func (p *Parser) parseAttrValue() string { + if p.current() == '"' { + p.pos++ // skip opening " + start := p.pos + // Continue until we find a closing quote or reach the end + for p.pos < p.length && p.current() != '"' { + p.pos++ + } + val := p.input[start:p.pos] + if p.pos < p.length && p.current() == '"' { + p.pos++ // skip closing " + } + return val + } + // Allow unquoted values. + start := p.pos + for p.pos < p.length && + p.current() != ' ' && p.current() != '>' && p.current() != '\n' && p.current() != '\r' { + p.pos++ + } + return p.input[start:p.pos] +} + +func (p *Parser) parseTwigDirective() (Node, error) { + if p.peek(2) != "{%" { + //nolint: nilnil + return nil, nil + } + + startPos := p.pos + p.pos += 2 // skip "{%" + p.skipWhitespace() + + // Check if it's a parent() call + if strings.HasPrefix(p.input[p.pos:], "parent()") { + p.pos += 8 // skip "parent()" + p.skipWhitespace() + if p.peek(2) != "%}" { + return nil, fmt.Errorf("unclosed parent directive at pos %d", startPos) + } + p.pos += 2 // skip "%}" + return &ParentNode{Line: p.getLineAt(startPos)}, nil + } + + // Handle {% parent %} directive (without parentheses) + if strings.HasPrefix(p.input[p.pos:], "parent") { + p.pos += 6 // skip "parent" + p.skipWhitespace() + if p.peek(2) != "%}" { + return nil, fmt.Errorf("unclosed parent directive at pos %d", startPos) + } + p.pos += 2 // skip "%}" + return &ParentNode{Line: p.getLineAt(startPos)}, nil + } + + // Reset position if it's not a recognized directive + p.pos = startPos + //nolint: nilnil + return nil, nil +} + +func (p *Parser) parseTwigBlock() (Node, error) { + if p.peek(2) != "{%" { + //nolint: nilnil + return nil, nil + } + + startPos := p.pos + p.pos += 2 // skip "{%" + p.skipWhitespace() + + // Check if it's a block + if !strings.HasPrefix(p.input[p.pos:], "block") { + p.pos = startPos + //nolint: nilnil + return nil, nil + } + p.pos += 5 // skip "block" + p.skipWhitespace() + + // Parse block name + start := p.pos + for p.pos < p.length && p.current() != '%' && p.current() != ' ' { + p.pos++ + } + name := strings.TrimSpace(p.input[start:p.pos]) + + // Skip to end of opening tag + for p.pos < p.length && p.peek(2) != "%}" { + p.pos++ + } + if p.peek(2) != "%}" { + return nil, fmt.Errorf("unclosed block tag at pos %d", startPos) + } + p.pos += 2 // skip "%}" + + // Parse children until endblock + children, err := p.parseNodes("") + if err != nil { + return nil, err + } + + // Look for endblock + p.skipWhitespace() + if !strings.HasPrefix(p.input[p.pos:], "{%") { + return nil, fmt.Errorf("missing endblock at pos %d", p.pos) + } + p.pos += 2 // skip "{%" + p.skipWhitespace() + + if !strings.HasPrefix(p.input[p.pos:], "endblock") { + return nil, fmt.Errorf("missing endblock at pos %d", p.pos) + } + p.pos += 8 // skip "endblock" + + // Skip to end of closing tag + for p.pos < p.length && p.peek(2) != "%}" { + p.pos++ + } + if p.peek(2) != "%}" { + return nil, fmt.Errorf("unclosed endblock tag at pos %d", p.pos) + } + p.pos += 2 // skip "%}" + + return &TwigBlockNode{ + Name: name, + Children: children, + Line: p.getLineAt(startPos), + }, nil +} + +// parseTwigIf parses a {% if ... %} ... {% endif %} block and returns a TwigIfNode +func (p *Parser) parseTwigIf() (Node, error) { + if p.peek(2) != "{%" { + //nolint: nilnil + return nil, nil + } + + startPos := p.pos + p.pos += 2 // skip "{%" + p.skipWhitespace() + + // Check if it's an if statement + if !strings.HasPrefix(p.input[p.pos:], "if") { + p.pos = startPos + //nolint: nilnil + return nil, nil + } + p.pos += 2 // skip "if" + p.skipWhitespace() + + // Parse condition + start := p.pos + for p.pos < p.length && p.peek(2) != "%}" { + p.pos++ + } + condition := strings.TrimSpace(p.input[start:p.pos]) + + if p.peek(2) != "%}" { + return nil, fmt.Errorf("unclosed if tag at pos %d", startPos) + } + p.pos += 2 // skip "%}" + + // Parse the if branch + ifChildren, err := p.parseIfBranch() + if err != nil { + return nil, err + } + + // Initialize elseif condition and children slices + var elseIfConditions []string + var elseIfChildren []NodeList + + // Parse any elseif branches + for { + // Check if we've reached an elseif + if p.peek(2) == "{%" && strings.HasPrefix(p.input[p.pos+2:], " elseif") { + p.pos += 2 // skip "{%" + p.skipWhitespace() + p.pos += 6 // skip "elseif" + p.skipWhitespace() + + // Parse elseif condition + start := p.pos + for p.pos < p.length && p.peek(2) != "%}" { + p.pos++ + } + elseifCondition := strings.TrimSpace(p.input[start:p.pos]) + + if p.peek(2) != "%}" { + return nil, fmt.Errorf("unclosed elseif tag at pos %d", p.pos) + } + p.pos += 2 // skip "%}" + + // Parse elseif branch + elseifBranch, err := p.parseIfBranch() + if err != nil { + return nil, err + } + + // Add to slices + elseIfConditions = append(elseIfConditions, elseifCondition) + elseIfChildren = append(elseIfChildren, elseifBranch) + } else { + break + } + } + + // Parse the else branch if it exists + var elseChildren NodeList + if p.peek(2) == "{%" && strings.HasPrefix(p.input[p.pos+2:], " else") { + p.pos += 2 // skip "{%" + p.skipWhitespace() + p.pos += 4 // skip "else" + p.skipWhitespace() + + // Skip to the end of the else tag + for p.pos < p.length && p.peek(2) != "%}" { + p.pos++ + } + if p.peek(2) != "%}" { + return nil, fmt.Errorf("unclosed else tag at pos %d", p.pos) + } + p.pos += 2 // skip "%}" + + // Parse else branch + elseChildren, err = p.parseIfBranch() + if err != nil { + return nil, err + } + } + + // Look for endif + if p.peek(2) != "{%" { + return nil, fmt.Errorf("missing endif at pos %d", p.pos) + } + p.pos += 2 // skip "{%" + p.skipWhitespace() + + if !strings.HasPrefix(p.input[p.pos:], "endif") { + return nil, fmt.Errorf("missing endif at pos %d", p.pos) + } + p.pos += 5 // skip "endif" + + // Skip to end of closing tag + for p.pos < p.length && p.peek(2) != "%}" { + p.pos++ + } + if p.peek(2) != "%}" { + return nil, fmt.Errorf("unclosed endif tag at pos %d", p.pos) + } + p.pos += 2 // skip "%}" + + return &TwigIfNode{ + Condition: condition, + Children: ifChildren, + ElseIfConditions: elseIfConditions, + ElseIfChildren: elseIfChildren, + ElseChildren: elseChildren, + Line: p.getLineAt(startPos), + }, nil +} + +// parseIfBranch parses the contents of an if or else branch until it encounters +// an {% else %}, {% elseif %} or {% endif %} tag +func (p *Parser) parseIfBranch() (NodeList, error) { + var nodes NodeList + rawStart := p.pos + + for p.pos < p.length { + // Check for else, elseif or endif + if p.peek(2) == "{%" { + nextTag := p.input[p.pos+2:] + if strings.HasPrefix(strings.TrimSpace(nextTag), "else") || + strings.HasPrefix(strings.TrimSpace(nextTag), "elseif") || + strings.HasPrefix(strings.TrimSpace(nextTag), "endif") { + break + } + } + + // Handle raw text + if p.pos > rawStart { + if p.peek(2) == "{%" || p.peek(2) == "{{" || p.peek(4) == htmlCommentStart || p.current() == '<' { + text := p.input[rawStart:p.pos] + if text != "" { + nodes = append(nodes, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + rawStart = p.pos + } + } + + // Try parsing twig directives first + directive, err := p.parseTwigDirective() + if err != nil { + return nodes, err + } + if directive != nil { + nodes = append(nodes, directive) + rawStart = p.pos + continue + } + + // If not a directive, try parsing as a block + block, err := p.parseTwigBlock() + if err != nil { + return nodes, err + } + if block != nil { + nodes = append(nodes, block) + rawStart = p.pos + continue + } + + // Try parsing template expressions {{ ... }} + expr, err := p.parseTemplateExpression() + if err != nil { + return nodes, err + } + if expr != nil { + nodes = append(nodes, expr) + rawStart = p.pos + continue + } + + // Try parsing HTML comments + comment, err := p.parseComment() + if err != nil { + return nodes, err + } + if comment != nil { + nodes = append(nodes, comment) + rawStart = p.pos + continue + } + + // Try parsing HTML elements + element, err := p.parseElement() + if err != nil { + return nodes, err + } + if element != nil { + nodes = append(nodes, element) + rawStart = p.pos + continue + } + + // If nothing matched, advance one character + if p.pos < p.length { + p.pos++ + } else { + break + } + } + + // Add any remaining raw text + if p.pos > rawStart { + text := p.input[rawStart:p.pos] + if text != "" { + nodes = append(nodes, &RawNode{ + Text: text, + Line: p.getLineAt(rawStart), + }) + } + } + + return nodes, nil +} + +// parseTemplateExpression parses a {{...}} template expression and returns a TemplateExpressionNode +func (p *Parser) parseTemplateExpression() (*TemplateExpressionNode, error) { + if p.peek(2) != "{{" { + //nolint: nilnil + return nil, nil + } + + startPos := p.pos + p.pos += 2 // skip "{{" + + // Find the closing "}}" + start := p.pos + idx := strings.Index(p.input[p.pos:], "}}") + if idx == -1 { + return nil, fmt.Errorf("unterminated template expression starting at pos %d", startPos) + } + + expression := p.input[start : start+idx] + p.pos += idx + 2 // skip past "}}" + + return &TemplateExpressionNode{ + Expression: expression, + Line: p.getLineAt(startPos), + }, nil +} + +func TraverseNode(n NodeList, f func(*ElementNode)) { + for _, node := range n { + switch node := node.(type) { + case *ElementNode: + f(node) + for _, child := range node.Children { + TraverseNode(NodeList{child}, f) + } + case *TwigBlockNode: + TraverseNode(node.Children, f) + case *TemplateExpressionNode: + // Template expressions don't have children to traverse + continue + } + } +} diff --git a/internal/html/parser_test.go b/internal/html/parser_test.go new file mode 100644 index 00000000..4b11a9a6 --- /dev/null +++ b/internal/html/parser_test.go @@ -0,0 +1,123 @@ +package html + +import ( + "os" + "path" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormattingOfHTML(t *testing.T) { + swBlock := &ElementNode{ + Tag: "sw-button", + Attributes: NodeList{ + &Attribute{ + Key: "label", + Value: "Click me", + }, + &Attribute{ + Key: "variant", + Value: "primary", + }, + }, + } + + node := &ElementNode{Tag: "template", Attributes: NodeList{}, Children: NodeList{swBlock}} + + assert.Equal(t, ``, node.Dump(0)) + + simpleButton := &ElementNode{ + Tag: "sw-button", + Children: NodeList{ + &RawNode{Text: "Click me"}, + }, + } + + assert.Equal(t, `Click me`, simpleButton.Dump(0)) +} + +func TestFormatting(t *testing.T) { + files, err := os.ReadDir("testdata") + if err != nil { + t.Fatal(err) + } + + for _, f := range files { + if f.IsDir() { + continue + } + + t.Run(f.Name(), func(t *testing.T) { + name := f.Name() + + data, err := os.ReadFile(path.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + + stringData := string(data) + stringParts := strings.SplitN(stringData, "-----", 2) + stringParts[0] = strings.TrimRight(stringParts[0], "\n") + stringParts[1] = strings.TrimLeft(stringParts[1], "\n") + + if len(stringParts) != 2 { + t.Fatalf("file %s does not contain expected delimiter", name) + } + + parsed, err := NewParser(stringParts[0]) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, stringParts[1], parsed.Dump(0)) + + parsed, err = NewParser(parsed.Dump(0)) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, stringParts[1], parsed.Dump(0)) + }) + } +} + +func TestChangeElement(t *testing.T) { + node, err := NewParser(``) + assert.NoError(t, err) + TraverseNode(node, func(n *ElementNode) { + n.Tag = "mt-select" + var newAttributes NodeList + for _, attr := range n.Attributes { + if attribute, ok := attr.(Attribute); ok { + if attribute.Key == "@update:value" { + attribute.Key = "@update:modelValue" + } + newAttributes = append(newAttributes, attribute) + } else { + newAttributes = append(newAttributes, attr) + } + } + n.Attributes = newAttributes + }) + assert.Equal(t, ``, node.Dump(0)) +} + +func TestBlockParsing(t *testing.T) { + input := `{% block name %}{% endblock %}` + + node, err := NewParser(input) + assert.NoError(t, err) + + assert.Equal(t, input, node.Dump(0)) + + block, ok := node[0].(*TwigBlockNode) + assert.True(t, ok) + assert.Equal(t, "name", block.Name) +} diff --git a/internal/html/testdata/01-basic-element.txt b/internal/html/testdata/01-basic-element.txt new file mode 100644 index 00000000..7fbdaf28 --- /dev/null +++ b/internal/html/testdata/01-basic-element.txt @@ -0,0 +1,3 @@ +Click me +----- +Click me \ No newline at end of file diff --git a/internal/html/testdata/02-sub-nodes.txt b/internal/html/testdata/02-sub-nodes.txt new file mode 100644 index 00000000..dc603518 --- /dev/null +++ b/internal/html/testdata/02-sub-nodes.txt @@ -0,0 +1,7 @@ + +----- + \ No newline at end of file diff --git a/internal/html/testdata/03-attributes-single.txt b/internal/html/testdata/03-attributes-single.txt new file mode 100644 index 00000000..94cf38b5 --- /dev/null +++ b/internal/html/testdata/03-attributes-single.txt @@ -0,0 +1,3 @@ +Click me +----- +Click me \ No newline at end of file diff --git a/internal/html/testdata/04-attributes.txt b/internal/html/testdata/04-attributes.txt new file mode 100644 index 00000000..bc674fd1 --- /dev/null +++ b/internal/html/testdata/04-attributes.txt @@ -0,0 +1,6 @@ +Click me +----- +Click me \ No newline at end of file diff --git a/internal/html/testdata/05-children-with-comment.txt b/internal/html/testdata/05-children-with-comment.txt new file mode 100644 index 00000000..fdb35efc --- /dev/null +++ b/internal/html/testdata/05-children-with-comment.txt @@ -0,0 +1,3 @@ + +----- + \ No newline at end of file diff --git a/internal/html/testdata/06-multiple-comments.txt b/internal/html/testdata/06-multiple-comments.txt new file mode 100644 index 00000000..0471603d --- /dev/null +++ b/internal/html/testdata/06-multiple-comments.txt @@ -0,0 +1,3 @@ +
Content
+----- +
Content
\ No newline at end of file diff --git a/internal/html/testdata/07-comment-with-nested-tags.txt b/internal/html/testdata/07-comment-with-nested-tags.txt new file mode 100644 index 00000000..c410404e --- /dev/null +++ b/internal/html/testdata/07-comment-with-nested-tags.txt @@ -0,0 +1,4 @@ +
actual content
+----- + +
actual content
\ No newline at end of file diff --git a/internal/html/testdata/08-comment-with-special-characters.txt b/internal/html/testdata/08-comment-with-special-characters.txt new file mode 100644 index 00000000..896ae312 --- /dev/null +++ b/internal/html/testdata/08-comment-with-special-characters.txt @@ -0,0 +1,3 @@ +
+----- +
\ No newline at end of file diff --git a/internal/html/testdata/09-elements-with-block.txt b/internal/html/testdata/09-elements-with-block.txt new file mode 100644 index 00000000..e98d2a44 --- /dev/null +++ b/internal/html/testdata/09-elements-with-block.txt @@ -0,0 +1,5 @@ +{% block foo %}Click me{% endblock %} +----- +{% block foo %} + Click me +{% endblock %} \ No newline at end of file diff --git a/internal/html/testdata/10-multi-line-breaks-get-removed.txt b/internal/html/testdata/10-multi-line-breaks-get-removed.txt new file mode 100644 index 00000000..eeb25b6d --- /dev/null +++ b/internal/html/testdata/10-multi-line-breaks-get-removed.txt @@ -0,0 +1,10 @@ +{% block test %}Click me + + +Click me{% endblock %} +----- +{% block test %} + Click me + + Click me +{% endblock %} \ No newline at end of file diff --git a/internal/html/testdata/11-multi-line-between-elements-only-one.txt b/internal/html/testdata/11-multi-line-between-elements-only-one.txt new file mode 100644 index 00000000..df015397 --- /dev/null +++ b/internal/html/testdata/11-multi-line-between-elements-only-one.txt @@ -0,0 +1,7 @@ + +----- + \ No newline at end of file diff --git a/internal/html/testdata/12-multi-line-between-only-elements.txt b/internal/html/testdata/12-multi-line-between-only-elements.txt new file mode 100644 index 00000000..df5e86d8 --- /dev/null +++ b/internal/html/testdata/12-multi-line-between-only-elements.txt @@ -0,0 +1,15 @@ + +----- + \ No newline at end of file diff --git a/internal/html/testdata/13-long-attribute-is-on-new-line.txt b/internal/html/testdata/13-long-attribute-is-on-new-line.txt new file mode 100644 index 00000000..2ae5fd0a --- /dev/null +++ b/internal/html/testdata/13-long-attribute-is-on-new-line.txt @@ -0,0 +1,5 @@ + +----- + \ No newline at end of file diff --git a/internal/html/testdata/14-html-element-with-content.txt b/internal/html/testdata/14-html-element-with-content.txt new file mode 100644 index 00000000..a7dd9c85 --- /dev/null +++ b/internal/html/testdata/14-html-element-with-content.txt @@ -0,0 +1,7 @@ + +----- + \ No newline at end of file diff --git a/internal/html/testdata/15-multiple-template-elements.txt b/internal/html/testdata/15-multiple-template-elements.txt new file mode 100644 index 00000000..133c3c42 --- /dev/null +++ b/internal/html/testdata/15-multiple-template-elements.txt @@ -0,0 +1,9 @@ + +----- + + + \ No newline at end of file diff --git a/internal/html/testdata/16-multiple-template-elements-with-root.txt b/internal/html/testdata/16-multiple-template-elements-with-root.txt new file mode 100644 index 00000000..76593986 --- /dev/null +++ b/internal/html/testdata/16-multiple-template-elements-with-root.txt @@ -0,0 +1,11 @@ + +----- + + + + + \ No newline at end of file diff --git a/internal/html/testdata/17-starting-tag-in-html-node.txt b/internal/html/testdata/17-starting-tag-in-html-node.txt new file mode 100644 index 00000000..78402224 --- /dev/null +++ b/internal/html/testdata/17-starting-tag-in-html-node.txt @@ -0,0 +1,5 @@ +

{{ $tc('swag-customized-products.detail.tabGeneral.cardExclusion.emptyTitle', (searchTerm.length <= 0) ? 1 : 0) }}

+----- +

+ {{ $tc('swag-customized-products.detail.tabGeneral.cardExclusion.emptyTitle', (searchTerm.length <= 0) ? 1 : 0) }} +

\ No newline at end of file diff --git a/internal/html/testdata/18-template-expression-in-div.txt b/internal/html/testdata/18-template-expression-in-div.txt new file mode 100644 index 00000000..019c9d25 --- /dev/null +++ b/internal/html/testdata/18-template-expression-in-div.txt @@ -0,0 +1,3 @@ +
{{ someVariable }}
+----- +
{{ someVariable }}
\ No newline at end of file diff --git a/internal/html/testdata/19-multiple-template-expressions.txt b/internal/html/testdata/19-multiple-template-expressions.txt new file mode 100644 index 00000000..b68a8a37 --- /dev/null +++ b/internal/html/testdata/19-multiple-template-expressions.txt @@ -0,0 +1,3 @@ +
{{ firstVar }}{{ secondVar }}
+----- +
{{ firstVar }}{{ secondVar }}
\ No newline at end of file diff --git a/internal/html/testdata/20-template-expression-with-text.txt b/internal/html/testdata/20-template-expression-with-text.txt new file mode 100644 index 00000000..94dc93e3 --- /dev/null +++ b/internal/html/testdata/20-template-expression-with-text.txt @@ -0,0 +1,3 @@ +
Before {{ expression }} After
+----- +
Before {{ expression }} After
\ No newline at end of file diff --git a/internal/html/testdata/21-template-expression-in-nested-elements.txt b/internal/html/testdata/21-template-expression-in-nested-elements.txt new file mode 100644 index 00000000..a48a42d8 --- /dev/null +++ b/internal/html/testdata/21-template-expression-in-nested-elements.txt @@ -0,0 +1,5 @@ +
{{ nestedExpression }}
+----- +
+ {{ nestedExpression }} +
\ No newline at end of file diff --git a/internal/html/testdata/22-template-expression-in-router-link.txt b/internal/html/testdata/22-template-expression-in-router-link.txt new file mode 100644 index 00000000..c74a64d6 --- /dev/null +++ b/internal/html/testdata/22-template-expression-in-router-link.txt @@ -0,0 +1,5 @@ +{{ item.mainPseudovariant.product.translated.name }} +----- + + {{ item.mainPseudovariant.product.translated.name }} + \ No newline at end of file diff --git a/internal/html/testdata/23-multiple-long-template-expressions.txt b/internal/html/testdata/23-multiple-long-template-expressions.txt new file mode 100644 index 00000000..28c30137 --- /dev/null +++ b/internal/html/testdata/23-multiple-long-template-expressions.txt @@ -0,0 +1,6 @@ +
{{ item.mainPseudovariant.product.translated.name }}{{ item.mainPseudovariant.product.translated.description }}
+----- +
+ {{ item.mainPseudovariant.product.translated.name }} + {{ item.mainPseudovariant.product.translated.description }} +
\ No newline at end of file diff --git a/internal/html/testdata/24-html-comment-before-element.txt b/internal/html/testdata/24-html-comment-before-element.txt new file mode 100644 index 00000000..e8f51d59 --- /dev/null +++ b/internal/html/testdata/24-html-comment-before-element.txt @@ -0,0 +1,9 @@ + + +

Content

+
+----- + + +

Content

+
\ No newline at end of file diff --git a/internal/html/testdata/25-if.txt b/internal/html/testdata/25-if.txt new file mode 100644 index 00000000..8e0f8453 --- /dev/null +++ b/internal/html/testdata/25-if.txt @@ -0,0 +1,7 @@ +{% if foo %} + +{% endif %} +----- +{% if foo %} + +{% endif %} \ No newline at end of file diff --git a/internal/html/testdata/26-if-else.txt b/internal/html/testdata/26-if-else.txt new file mode 100644 index 00000000..80e6f559 --- /dev/null +++ b/internal/html/testdata/26-if-else.txt @@ -0,0 +1,11 @@ +{% if foo %} + +{% else %} + +{% endif %} +----- +{% if foo %} + +{% else %} + +{% endif %} \ No newline at end of file diff --git a/internal/html/testdata/27-if-elseif-else.txt b/internal/html/testdata/27-if-elseif-else.txt new file mode 100644 index 00000000..12363f51 --- /dev/null +++ b/internal/html/testdata/27-if-elseif-else.txt @@ -0,0 +1,19 @@ +{% if foo %} + +{% elseif bla %} + +{% elseif yea %} + +{% else %} + +{% endif %} +----- +{% if foo %} + +{% elseif bla %} + +{% elseif yea %} + +{% else %} + +{% endif %} \ No newline at end of file diff --git a/internal/html/testdata/28-if-while-attrs.txt b/internal/html/testdata/28-if-while-attrs.txt new file mode 100644 index 00000000..4b66e5b5 --- /dev/null +++ b/internal/html/testdata/28-if-while-attrs.txt @@ -0,0 +1,7 @@ + +----- + \ No newline at end of file diff --git a/internal/html/testdata/29-block-nesting.txt b/internal/html/testdata/29-block-nesting.txt new file mode 100644 index 00000000..47977fb8 --- /dev/null +++ b/internal/html/testdata/29-block-nesting.txt @@ -0,0 +1,16 @@ +{% block a %} +{% block b %} +{% block c %} +{% block d %} +{% endblock %} +{% endblock %} +{% endblock %} +{% endblock %} +----- +{% block a %} + {% block b %} + {% block c %} + {% block d %}{% endblock %} + {% endblock %} + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/internal/html/testdata/30-attribute-long-closing-correctly-formatted.txt b/internal/html/testdata/30-attribute-long-closing-correctly-formatted.txt new file mode 100644 index 00000000..dcabacd3 --- /dev/null +++ b/internal/html/testdata/30-attribute-long-closing-correctly-formatted.txt @@ -0,0 +1,13 @@ +
+
+

Hello World

+
+
+----- +
+
+

Hello World

+
+
\ No newline at end of file diff --git a/internal/html/testdata/31-block-parent.txt b/internal/html/testdata/31-block-parent.txt new file mode 100644 index 00000000..31858afb --- /dev/null +++ b/internal/html/testdata/31-block-parent.txt @@ -0,0 +1,11 @@ +{% block a %} +{% block b %} +{% parent() %} +{% endblock %} +{% endblock %} +----- +{% block a %} + {% block b %} + {% parent() %} + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/internal/html/testdata/32-multi-attribute-selfclose.txt b/internal/html/testdata/32-multi-attribute-selfclose.txt new file mode 100644 index 00000000..6143e9da --- /dev/null +++ b/internal/html/testdata/32-multi-attribute-selfclose.txt @@ -0,0 +1,10 @@ +
+ +
+----- +
+ +
\ No newline at end of file diff --git a/internal/html/testdata/33-comment-over-block.txt b/internal/html/testdata/33-comment-over-block.txt new file mode 100644 index 00000000..ea953698 --- /dev/null +++ b/internal/html/testdata/33-comment-over-block.txt @@ -0,0 +1,8 @@ + + + + +{% block sw_import_export_tabs_profiles %}{% endblock %} +----- + +{% block sw_import_export_tabs_profiles %}{% endblock %} \ No newline at end of file diff --git a/internal/html/testdata/34-comment-over-block-nested.txt b/internal/html/testdata/34-comment-over-block-nested.txt new file mode 100644 index 00000000..61ea6e08 --- /dev/null +++ b/internal/html/testdata/34-comment-over-block-nested.txt @@ -0,0 +1,17 @@ + + + + +{% block sw_import_export_tabs_profiles %} + + + + {% block foo %} + {% endblock %} +{% endblock %} +----- + +{% block sw_import_export_tabs_profiles %} + + {% block foo %}{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/internal/html/testdata/35-element-content-format.txt b/internal/html/testdata/35-element-content-format.txt new file mode 100644 index 00000000..0d24fc6e --- /dev/null +++ b/internal/html/testdata/35-element-content-format.txt @@ -0,0 +1,5 @@ +{{ $tc('iwvs-import-export.page.colorTab') }} +----- + + {{ $tc('iwvs-import-export.page.colorTab') }} + \ No newline at end of file diff --git a/internal/html/testdata/36-block-around-if.txt b/internal/html/testdata/36-block-around-if.txt new file mode 100644 index 00000000..00f0511e --- /dev/null +++ b/internal/html/testdata/36-block-around-if.txt @@ -0,0 +1,41 @@ + +{% block sw_cms_element_product_slider_config_settings_min_width %} +{% parent %} + + +{% block sw_cms_element_product_slider_config_settings_slides_mobile %} + +{% endblock %} +{% endblock %} +----- + +{% block sw_cms_element_product_slider_config_settings_min_width %} + {% parent() %} + + + {% block sw_cms_element_product_slider_config_settings_slides_mobile %} + + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/internal/html/testdata/37-formatting-element.txt b/internal/html/testdata/37-formatting-element.txt new file mode 100644 index 00000000..a32151ff --- /dev/null +++ b/internal/html/testdata/37-formatting-element.txt @@ -0,0 +1,25 @@ +{% block sw_cms_block_product_listing_preview %} +
+
+
+
+
+
+
+ + +
+{% endblock %} +----- +{% block sw_cms_block_product_listing_preview %} +
+
+
+
+
+
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/internal/html/testdata/38-formatting-element-content-oneliner.txt b/internal/html/testdata/38-formatting-element-content-oneliner.txt new file mode 100644 index 00000000..3a48587f --- /dev/null +++ b/internal/html/testdata/38-formatting-element-content-oneliner.txt @@ -0,0 +1,7 @@ +
+ {{ somewhatLongFooBarBaz }}/{{ somewhatLongerFooBarBaz }} +
+----- +
+ {{ somewhatLongFooBarBaz }}/{{ somewhatLongerFooBarBaz }} +
\ No newline at end of file diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go new file mode 100644 index 00000000..13bb134d --- /dev/null +++ b/internal/llm/gemini.go @@ -0,0 +1,49 @@ +package llm + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/google/generative-ai-go/genai" + "google.golang.org/api/option" + + "github.com/shopware/shopware-cli/logging" +) + +type GeminiClient struct { + client *genai.Client +} + +func newGeminiClient() (*GeminiClient, error) { + apiKey := os.Getenv("GEMINI_API_KEY") + + if apiKey == "" { + return nil, fmt.Errorf("GEMINI_API_KEY is not set") + } + + client, err := genai.NewClient(context.Background(), option.WithAPIKey(apiKey)) + if err != nil { + return nil, err + } + + return &GeminiClient{client: client}, nil +} + +func (c *GeminiClient) Generate(ctx context.Context, prompt string, options *LLMOptions) (string, error) { + resp, err := c.client.GenerativeModel(options.Model).GenerateContent(ctx, genai.Text(options.SystemPrompt+"\n\n"+prompt)) + if err != nil { + if strings.Contains(err.Error(), "Resource has been exhausted") { + logging.FromContext(ctx).Warn("Resource exhausted, waiting 15 seconds before retrying") + time.Sleep(15 * time.Second) + + return c.Generate(ctx, prompt, options) + } + + return "", err + } + + return string(resp.Candidates[0].Content.Parts[0].(genai.Text)), nil +} diff --git a/internal/llm/main.go b/internal/llm/main.go new file mode 100644 index 00000000..31a0dfe2 --- /dev/null +++ b/internal/llm/main.go @@ -0,0 +1,27 @@ +package llm + +import "context" + +type LLMOptions struct { + Model string + SystemPrompt string +} + +type LLMClient interface { + Generate(ctx context.Context, prompt string, options *LLMOptions) (string, error) +} + +func NewLLMClient(provider string) (LLMClient, error) { + switch provider { + case "ollama": + return newOpenAIClient(), nil + case "openai": + return newOpenAIClient(), nil + case "gemini": + return newGeminiClient() + case "openrouter": + return newOpenRouterClient() + } + + panic("Invalid provider: " + provider) +} diff --git a/internal/llm/openai.go b/internal/llm/openai.go new file mode 100644 index 00000000..63e48a85 --- /dev/null +++ b/internal/llm/openai.go @@ -0,0 +1,141 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/shopware/shopware-cli/logging" +) + +// Client represents an OpenAI API client. +type Client struct { + host string + apiKey string + client *http.Client +} + +// ChatMessage represents a message in the chat completion request. +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// ChatCompletionRequest represents the request body for chat completion. +type ChatCompletionRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` +} + +// ChatCompletionResponse represents the response from the chat completion endpoint. +type ChatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []struct { + Index int `json:"index"` + Message ChatMessage `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +// newOpenAIClient creates a new OpenAI client instance. +func newOpenAIClient() *Client { + host := os.Getenv("OPENAI_API_HOST") + ollamaHost := os.Getenv("OLLAMA_HOST") + if host == "" { + if ollamaHost != "" { + host = ollamaHost + } else { + host = "https://api.openai.com" + } + } + + apiKey := os.Getenv("OPENAI_API_KEY") + + return &Client{ + host: host, + apiKey: apiKey, + client: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +// Generate sends a chat completion request to the OpenAI API +func (c *Client) Generate(ctx context.Context, prompt string, options *LLMOptions) (string, error) { + messages := []ChatMessage{ + { + Role: "user", + Content: prompt, + }, + } + + if options.SystemPrompt != "" { + // Insert system message at the beginning + messages = append([]ChatMessage{{ + Role: "system", + Content: options.SystemPrompt, + }}, messages...) + } + + reqBody := ChatCompletionRequest{ + Model: options.Model, + Messages: messages, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/v1/chat/completions", c.host), bytes.NewBuffer(jsonBody)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) + } + + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + logging.FromContext(ctx).Warn("failed to close response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + return "", fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + var response ChatCompletionResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if len(response.Choices) == 0 { + return "", fmt.Errorf("no completion choices returned") + } + + return response.Choices[0].Message.Content, nil +} diff --git a/internal/llm/openai_test.go b/internal/llm/openai_test.go new file mode 100644 index 00000000..4c860a9a --- /dev/null +++ b/internal/llm/openai_test.go @@ -0,0 +1,186 @@ +package llm + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenAIGenerate(t *testing.T) { + // Create a test server to mock the OpenAI API + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check request method and path + assert.Equal(t, http.MethodPost, r.Method, "Request method should be POST") + assert.Equal(t, "/v1/chat/completions", r.URL.Path, "Request path should be /v1/chat/completions") + + // Check for authorization header + authHeader := r.Header.Get("Authorization") + assert.Equal(t, "Bearer test-api-key", authHeader, "Authorization header should be properly set") + + // Decode request body to verify content + var reqBody map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&reqBody) + if !assert.NoError(t, err, "Should decode request body without error") { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Check model + model, ok := reqBody["model"].(string) + assert.True(t, ok, "Model should be a string") + assert.Equal(t, "gpt-3.5-turbo", model, "Model should be gpt-3.5-turbo") + + // Check messages + messages, ok := reqBody["messages"].([]interface{}) + assert.True(t, ok, "Messages should be an array") + + // Different response based on request type + var responseContent string + switch len(messages) { + case 1: + // Regular prompt + responseContent = "This is a test response to a regular prompt" + case 2: + // With system prompt + responseContent = "This is a test response with system context" + default: + assert.Failf(t, "Unexpected messages length", "Got %d messages, expected 1 or 2", len(messages)) + responseContent = "Unexpected configuration" + } + + // Send a successful response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + // Simplified mock response that matches the ChatCompletionResponse structure + responseJSON := fmt.Sprintf(`{ + "id": "test-id", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-3.5-turbo", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "%s" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15 + } + }`, responseContent) + + _, err = w.Write([]byte(responseJSON)) + assert.NoError(t, err, "Should write response without error") + })) + defer server.Close() + + // Set environment variables for the test + t.Setenv("OPENAI_API_HOST", server.URL) + t.Setenv("OPENAI_API_KEY", "test-api-key") + + // Get an OpenAI client instance + client, err := NewLLMClient("openai") + require.NoError(t, err, "Creating OpenAI client should not error") + + // Test cases + tests := []struct { + name string + prompt string + options *LLMOptions + expected string + expectError bool + errorMessage string + }{ + { + name: "basic prompt", + prompt: "Hello, world!", + options: &LLMOptions{Model: "gpt-3.5-turbo"}, + expected: "This is a test response to a regular prompt", + }, + { + name: "with system prompt", + prompt: "Hello, assistant!", + options: &LLMOptions{Model: "gpt-3.5-turbo", SystemPrompt: "You are a helpful assistant"}, + expected: "This is a test response with system context", + }, + } + + // Run test cases + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := client.Generate(t.Context(), tt.prompt, tt.options) + + if tt.expectError { + assert.Error(t, err, "Should return an error") + if tt.errorMessage != "" { + assert.Contains(t, err.Error(), tt.errorMessage, "Error should contain expected message") + } + return + } + + assert.NoError(t, err, "Should not return an error") + assert.Equal(t, tt.expected, result, "Should return expected result") + }) + } +} + +func TestOpenAIGenerateErrors(t *testing.T) { + // Test case 1: Server error + t.Run("server error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"error": {"message": "Server error", "type": "server_error"}}`)) + assert.NoError(t, err, "Should write response without error") + })) + defer server.Close() + + // Set environment variables for the test + t.Setenv("OPENAI_API_HOST", server.URL) + t.Setenv("OPENAI_API_KEY", "test-key") + + client, err := NewLLMClient("openai") + assert.NoError(t, err, "Creating OpenAI client should not error") + + _, err = client.Generate(t.Context(), "Test prompt", &LLMOptions{ + Model: "gpt-3.5-turbo", + }) + + assert.Error(t, err, "Should return an error when server returns error") + assert.Contains(t, err.Error(), "unexpected status code", "Error should mention unexpected status code") + }) + + // Test case 2: Empty choices + t.Run("empty choices", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{"id": "test-id", "object": "chat.completion", "choices": [], "usage": {"prompt_tokens": 10, "completion_tokens": 0, "total_tokens": 10}}`)) + assert.NoError(t, err, "Should write response without error") + })) + defer server.Close() + + // Set environment variables for the test + t.Setenv("OPENAI_API_HOST", server.URL) + t.Setenv("OPENAI_API_KEY", "test-key") + + client, err := NewLLMClient("openai") + require.NoError(t, err, "Creating OpenAI client should not error") + + _, err = client.Generate(t.Context(), "Test prompt", &LLMOptions{ + Model: "gpt-3.5-turbo", + }) + + assert.Error(t, err, "Should return an error when response has empty choices") + assert.Contains(t, err.Error(), "no completion choices", "Error should mention no completion choices") + }) +} diff --git a/internal/llm/openrouter.go b/internal/llm/openrouter.go new file mode 100644 index 00000000..0463f0a0 --- /dev/null +++ b/internal/llm/openrouter.go @@ -0,0 +1,121 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/shopware/shopware-cli/logging" +) + +// OpenRouterClient represents an OpenRouter API client. +type OpenRouterClient struct { + client *http.Client + apiKey string +} + +// OpenRouterRequest represents the request body for text generation. +type OpenRouterRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// OpenRouterResponse represents the response from the OpenRouter API. +type OpenRouterResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +// newOpenRouterClient creates a new OpenRouter client instance. +func newOpenRouterClient() (*OpenRouterClient, error) { + apiKey := os.Getenv("OPENROUTER_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("OPENROUTER_API_KEY is not set") + } + + return &OpenRouterClient{ + apiKey: apiKey, + client: &http.Client{ + Timeout: 120 * time.Second, + }, + }, nil +} + +// Generate sends a generation request to the OpenRouter API. +func (c *OpenRouterClient) Generate(ctx context.Context, prompt string, options *LLMOptions) (string, error) { + messages := []Message{ + { + Role: "system", + Content: options.SystemPrompt, + }, + { + Role: "user", + Content: prompt, + }, + } + + reqBody := OpenRouterRequest{ + Model: options.Model, + Messages: messages, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonBody)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("HTTP-Referer", "https://github.com/shopwareLabs/extension-verifier") + req.Header.Set("X-Title", "Shopware Extension Verifier") + + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to close response body: %v\n", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + var response OpenRouterResponse + if err := json.Unmarshal(body, &response); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if len(response.Choices) == 0 { + logging.FromContext(ctx).Error("no response choices returned", "body", string(body)) + return "", fmt.Errorf("no response choices returned") + } + + return response.Choices[0].Message.Content, nil +} diff --git a/internal/system/fs.go b/internal/system/fs.go new file mode 100644 index 00000000..95822520 --- /dev/null +++ b/internal/system/fs.go @@ -0,0 +1,103 @@ +package system + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +func CopyFiles(currentPath string, targetPath string) error { + // When the currentPath folder does not exist, return + if _, err := os.Stat(currentPath); os.IsNotExist(err) { + return nil + } + + // Create target directory if it doesn't exist + if err := os.MkdirAll(targetPath, 0o755); err != nil { + return fmt.Errorf("failed to create target directory: %w", err) + } + + // Walk through the current directory + return filepath.Walk(currentPath, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("failed to access path %q: %w", path, err) + } + + // Get the relative path + relPath, err := filepath.Rel(currentPath, path) + if err != nil { + return fmt.Errorf("failed to get relative path for %q: %w", path, err) + } + + // Skip development environment and VCS metadata folders + // (e.g., .devenv, .direnv, .git) + if info.IsDir() && (relPath == ".devenv" || relPath == ".direnv" || relPath == ".git") { + return filepath.SkipDir + } + + // Construct target path + targetFilePath := filepath.Join(targetPath, relPath) + + // If it's a directory, create it in target + if info.IsDir() { + return os.MkdirAll(targetFilePath, 0o755) + } + + // Copy the file + return copyFile(path, targetFilePath) + }) +} + +func copyFile(src, dst string) error { + // Check if it's a symlink + info, err := os.Lstat(src) + if err != nil { + return fmt.Errorf("failed to get file info: %w", err) + } + + // If it's a symlink, create a new symlink + if info.Mode()&os.ModeSymlink != 0 { + linkTarget, err := os.Readlink(src) + if err != nil { + return fmt.Errorf("failed to read symlink: %w", err) + } + return os.Symlink(linkTarget, dst) + } + + // Open source file + sourceFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer func() { + if closeErr := sourceFile.Close(); closeErr != nil { + err = fmt.Errorf("failed to close source file: %w", closeErr) + } + }() + + // Create target file + targetFile, err := os.Create(dst) + if err != nil { + return fmt.Errorf("failed to create target file: %w", err) + } + defer func() { + if closeErr := targetFile.Close(); closeErr != nil { + err = fmt.Errorf("failed to close target file: %w", closeErr) + } + }() + + // Copy the contents + if _, err := io.Copy(targetFile, sourceFile); err != nil { + return fmt.Errorf("failed to copy file contents: %w", err) + } + + // Copy file permissions + sourceInfo, err := os.Stat(src) + if err != nil { + return fmt.Errorf("failed to get source file info: %w", err) + } + + return os.Chmod(dst, sourceInfo.Mode()) +} diff --git a/internal/system/fs_test.go b/internal/system/fs_test.go new file mode 100644 index 00000000..667c3aa3 --- /dev/null +++ b/internal/system/fs_test.go @@ -0,0 +1,79 @@ +package system + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCopyFiles(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + defer func() { + err := os.RemoveAll(tempDir) + assert.NoError(t, err, "Failed to remove temporary directory") + }() + + // Create source directory structure + srcDir := filepath.Join(tempDir, "src") + err := os.MkdirAll(srcDir, 0o755) + assert.NoError(t, err, "Failed to create source directory") + + // Create a normal file + normalFile := filepath.Join(srcDir, "normal.txt") + err = os.WriteFile(normalFile, []byte("normal content"), 0o644) + assert.NoError(t, err, "Failed to create normal file") + + // Create a .devenv directory with a file + devenvDir := filepath.Join(srcDir, ".devenv") + err = os.MkdirAll(devenvDir, 0o755) + assert.NoError(t, err, "Failed to create .devenv directory") + devenvFile := filepath.Join(devenvDir, "devenv.txt") + err = os.WriteFile(devenvFile, []byte("devenv content"), 0o644) + assert.NoError(t, err, "Failed to create file in .devenv") + + // Create a .direnv directory with a file + direnvDir := filepath.Join(srcDir, ".direnv") + err = os.MkdirAll(direnvDir, 0o755) + assert.NoError(t, err, "Failed to create .direnv directory") + direnvFile := filepath.Join(direnvDir, "direnv.txt") + err = os.WriteFile(direnvFile, []byte("direnv content"), 0o644) + assert.NoError(t, err, "Failed to create file in .direnv") + + // Create a regular subdirectory with a file + subDir := filepath.Join(srcDir, "subdir") + err = os.MkdirAll(subDir, 0o755) + assert.NoError(t, err, "Failed to create subdirectory") + subFile := filepath.Join(subDir, "sub.txt") + err = os.WriteFile(subFile, []byte("sub content"), 0o644) + assert.NoError(t, err, "Failed to create file in subdirectory") + + // Create destination directory + dstDir := filepath.Join(tempDir, "dst") + + // Copy files from src to dst + err = CopyFiles(srcDir, dstDir) + assert.NoError(t, err, "copyFiles failed") + + // Check if normal file was copied + dstNormalFile := filepath.Join(dstDir, "normal.txt") + _, err = os.Stat(dstNormalFile) + assert.False(t, os.IsNotExist(err), "Normal file was not copied") + + // Check if file in subdirectory was copied + dstSubFile := filepath.Join(dstDir, "subdir", "sub.txt") + _, err = os.Stat(dstSubFile) + assert.False(t, os.IsNotExist(err), "File in subdirectory was not copied") + + // Check if .devenv directory was excluded + dstDevenvDir := filepath.Join(dstDir, ".devenv") + _, err = os.Stat(dstDevenvDir) + assert.True(t, os.IsNotExist(err), ".devenv directory was not excluded") + + // Check if .direnv directory was excluded + dstDirenvDir := filepath.Join(dstDir, ".direnv") + _, err = os.Stat(dstDirenvDir) + assert.True(t, os.IsNotExist(err), ".direnv directory was not excluded") +} diff --git a/internal/twigparser/node_list.go b/internal/twigparser/node_list.go new file mode 100644 index 00000000..ef08a146 --- /dev/null +++ b/internal/twigparser/node_list.go @@ -0,0 +1,96 @@ +package twigparser + +import "strings" + +type NodeList []Node + +func (nl NodeList) Find(predicate func(Node) bool) NodeList { + var result NodeList + for _, node := range nl { + if predicate(node) { + result = append(result, node) + } + // If the node is a BlockNode, search recursively in its children. + if block, ok := node.(*BlockNode); ok { + nestedMatches := block.Children.Find(predicate) + result = append(result, nestedMatches...) + } + } + return result +} + +func (nl NodeList) FindBlock(name string) *BlockNode { + matches := nl.Find(func(node Node) bool { + block, ok := node.(*BlockNode) + return ok && block.Name == name + }) + if len(matches) == 0 { + return nil + } + return matches[0].(*BlockNode) +} + +func (nl NodeList) Extends() *SwExtendsNode { + matches := nl.Find(func(node Node) bool { + _, ok := node.(*SwExtendsNode) + return ok + }) + + if len(matches) == 0 { + return nil + } + + return matches[0].(*SwExtendsNode) +} + +func (nl NodeList) BlockNames() []string { + matches := nl.Find(func(node Node) bool { + _, ok := node.(*BlockNode) + return ok + }) + + var result []string + + for _, node := range matches { + result = append(result, node.(*BlockNode).Name) + } + + return result +} + +func (nl NodeList) Traverse(visitor func(Node) Node) NodeList { + for i, node := range nl { + // If the node has children, traverse them first. + if block, ok := node.(*BlockNode); ok { + block.Children = block.Children.Traverse(visitor) + } + // Apply the visitor function. + nl[i] = visitor(node) + } + return nl +} + +func (nl NodeList) RemoveWhitespace() NodeList { + return nl.Find(func(node Node) bool { + _, isWhitespace := node.(*WhitespaceNode) + return !isWhitespace + }) +} + +func (nl NodeList) String() string { + var sb strings.Builder + + for _, node := range nl { + sb.WriteString(node.String("")) + } + + return sb.String() +} + +func (nl NodeList) Dump() string { + var sb strings.Builder + for _, node := range nl { + sb.WriteString(node.Dump()) + } + return sb.String() +} diff --git a/internal/twigparser/nodes.go b/internal/twigparser/nodes.go new file mode 100644 index 00000000..f257dd99 --- /dev/null +++ b/internal/twigparser/nodes.go @@ -0,0 +1,221 @@ +package twigparser + +import ( + "fmt" + "strings" +) + +// Node represents an AST node. +type Node interface { + // String provides a debug representation with indentation. + String(indent string) string + // Dump outputs the node (and its children) back into source code. + Dump() string +} + +// TextNode holds non‑whitespace plain text. +type TextNode struct { + Text string +} + +func (t *TextNode) String(indent string) string { + escaped := strings.ReplaceAll(t.Text, "\n", "\\n") + return fmt.Sprintf("%sTextNode(%q)", indent, escaped) +} + +func (t *TextNode) Dump() string { + return t.Text +} + +// WhitespaceNode holds a text fragment that consists solely of +// whitespace (spaces, tabs, newlines, etc). +type WhitespaceNode struct { + Text string +} + +func (w *WhitespaceNode) String(indent string) string { + escaped := strings.ReplaceAll(w.Text, "\n", "\\n") + return fmt.Sprintf("%sWhitespaceNode(%q)", indent, escaped) +} + +func (w *WhitespaceNode) Dump() string { + return w.Text +} + +// BlockNode represents a Twig block (with opening tag {% block %} +// and ending tag {% endblock %}), and contains nested child nodes. +type BlockNode struct { + Name string + Children NodeList +} + +func (b *BlockNode) String(indent string) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("%sBlockNode(Name: %s)\n", indent, b.Name)) + for _, child := range b.Children { + sb.WriteString(child.String(indent + " ")) + sb.WriteString("\n") + } + return sb.String() +} + +func (b *BlockNode) Dump() string { + var sb strings.Builder + sb.WriteString("{% block " + b.Name + " %}") + for _, child := range b.Children { + sb.WriteString(child.Dump()) + } + sb.WriteString("{% endblock %}") + return sb.String() +} + +// ParentNode represents the Twig expression {{ parent() }}. +type ParentNode struct{} + +func (p *ParentNode) String(indent string) string { + return fmt.Sprintf("%sParentNode(parent())", indent) +} + +func (p *ParentNode) Dump() string { + return "{{ parent() }}" +} + +// SwExtendsNode represents the Twig tag for extending a template. +// It supports both simple and object-literal syntaxes. +type SwExtendsNode struct { + Template string + Scopes []string +} + +func (s *SwExtendsNode) String(indent string) string { + if len(s.Scopes) > 0 { + return fmt.Sprintf("%sSwExtendsNode(Template: %q, Scopes: %q)", indent, s.Template, s.Scopes) + } + return fmt.Sprintf("%sSwExtendsNode(Template: %q)", indent, s.Template) +} + +func (s *SwExtendsNode) Dump() string { + // Dump in canonical form. + if len(s.Scopes) > 0 { + // Build a scopes array such as ['default', 'subscription'] + var scopesParts []string + for _, scope := range s.Scopes { + scopesParts = append(scopesParts, fmt.Sprintf("'%s'", scope)) + } + return fmt.Sprintf("{%% sw_extends { template: '%s', scopes: [%s] } %%}", + s.Template, strings.Join(scopesParts, ", ")) + } + // Simple syntax? + return "{% sw_extends '" + s.Template + "' %}" +} + +// ForNode represents a for-loop in the template. +type ForNode struct { + Var string + Collection string + Children NodeList +} + +func (f *ForNode) String(indent string) string { + // Minimal debug representation. + s := indent + "ForNode(Var: " + f.Var + ", Collection: " + f.Collection + ")\n" + for _, child := range f.Children { + s += child.String(indent+" ") + "\n" + } + return s +} + +func (f *ForNode) Dump() string { + var sb strings.Builder + sb.WriteString("{% for " + f.Var + " in " + f.Collection + " %}") + // ...existing code... + for _, child := range f.Children { + sb.WriteString(child.Dump()) + } + sb.WriteString("{% endfor %}") + return sb.String() +} + +// PrintNode represents an expression that prints a variable. +type PrintNode struct { + Expression string +} + +func (p *PrintNode) String(indent string) string { + return indent + "PrintNode(" + p.Expression + ")" +} + +func (p *PrintNode) Dump() string { + return "{{ " + p.Expression + " }}" +} + +// DeprecatedNode represents a deprecated tag in the template. +type DeprecatedNode struct { + Message string +} + +func (d *DeprecatedNode) String(indent string) string { + return indent + "DeprecatedNode(" + d.Message + ")" +} + +func (d *DeprecatedNode) Dump() string { + return "{% deprecated '" + d.Message + "' %}" +} + +// SetNode represents a 'set' assignment in the template. +type SetNode struct { + Variables []string // left-hand side variable(s) + Values []string // right-hand side expression(s) for inline assignment; empty when IsBlock is true + IsBlock bool // true when using block assignment + Children NodeList // block assignment content +} + +func (s *SetNode) String(indent string) string { + if s.IsBlock { + return fmt.Sprintf("%sSetNode(Block, Variables: %v)", indent, s.Variables) + } + return fmt.Sprintf("%sSetNode(Inline, Variables: %v, Values: %v)", indent, s.Variables, s.Values) +} + +func (s *SetNode) Dump() string { + if s.IsBlock { + return "{% set " + joinNames(s.Variables) + " %}" + s.Children.Dump() + "{% endset %}" + } + return "{% set " + joinNames(s.Variables) + " = " + joinNames(s.Values) + " %}" +} + +// joinNames is a helper to join a slice of strings with ", ". +func joinNames(arr []string) string { + return strings.Join(arr, ", ") +} + +// AutoescapeNode represents an autoescape block in the template. +type AutoescapeNode struct { + Strategy string // e.g. "html" + Children NodeList // content within the autoescape block +} + +func (a *AutoescapeNode) String(indent string) string { + return fmt.Sprintf("%sAutoescapeNode(Strategy: %s)", indent, a.Strategy) +} + +func (a *AutoescapeNode) Dump() string { + return "{% autoescape %}" + a.Children.Dump() + "{% endautoescape %}" +} + +// TypesNode represents a types definition tag such as {% types score: 'number' %} +type TypesNode struct { + Types map[string]string +} + +func (t *TypesNode) String(indent string) string { + return fmt.Sprintf("%sTypesNode(%v)", indent, t.Types) +} + +func (t *TypesNode) Dump() string { + var parts []string + for key, value := range t.Types { + parts = append(parts, fmt.Sprintf("%s: %s", key, value)) + } + return "{% types " + strings.Join(parts, " ") + " %}" +} diff --git a/internal/twigparser/parser_types.go b/internal/twigparser/parser_types.go new file mode 100644 index 00000000..629e95ed --- /dev/null +++ b/internal/twigparser/parser_types.go @@ -0,0 +1,32 @@ +package twigparser + +import ( + "errors" + "strings" +) + +// parseTypes parses the content of a types tag. +// For example, given "score: 'number'" it returns a TypesNode with the mapping. +func parseTypes(content string) (Node, error) { + typesMap := make(map[string]string) + if strings.TrimSpace(content) == "" { + return nil, errors.New("no types provided") + } + // For simplicity, assume tokens do not contain spaces. + tokens := strings.Fields(content) + for _, token := range tokens { + parts := strings.SplitN(token, ":", 2) + if len(parts) != 2 { + return nil, errors.New("invalid types format") + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + // Remove quotes if present. + if len(value) >= 2 && ((value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"')) { + value = value[1 : len(value)-1] + } + // Preserve quotes in dump. + typesMap[key] = "'" + value + "'" + } + return &TypesNode{Types: typesMap}, nil +} diff --git a/internal/twigparser/twig.go b/internal/twigparser/twig.go new file mode 100644 index 00000000..e47a9cc0 --- /dev/null +++ b/internal/twigparser/twig.go @@ -0,0 +1,362 @@ +package twigparser + +import ( + "errors" + "strings" + "unicode" +) + +const ( + tagTypeExpr = "expr" + tagTypeBlock = "block" +) + +// tokenizeText splits a text string into a slice of Nodes. Each continuous +// segment of pure whitespace becomes a WhitespaceNode, whereas every other +// segment becomes a TextNode. +func tokenizeText(text string) NodeList { + var nodes NodeList + if len(text) == 0 { + return nodes + } + + var current strings.Builder + // Determine the type for the first rune. + firstRune, _ := utf8DecodeRuneInString(text) + inWhitespace := isWhitespace(firstRune) + + for _, r := range text { + if isWhitespace(r) == inWhitespace { + current.WriteRune(r) + } else { + token := current.String() + if inWhitespace { + nodes = append(nodes, &WhitespaceNode{Text: token}) + } else { + nodes = append(nodes, &TextNode{Text: token}) + } + current.Reset() + inWhitespace = isWhitespace(r) + current.WriteRune(r) + } + } + // Flush remaining token. + if current.Len() > 0 { + token := current.String() + if inWhitespace { + nodes = append(nodes, &WhitespaceNode{Text: token}) + } else { + nodes = append(nodes, &TextNode{Text: token}) + } + } + return nodes +} + +// utf8DecodeRuneInString is a helper to decode the first rune. +// If the string is empty, it returns 0. +func utf8DecodeRuneInString(s string) (r rune, size int) { + if len(s) == 0 { + return 0, 0 + } + return []rune(s)[0], 1 +} + +// isWhitespace returns true if the rune is a whitespace character. +func isWhitespace(r rune) bool { + return unicode.IsSpace(r) +} + +// ParseTemplate is the entry point that builds an AST for the template. +func ParseTemplate(input string) (NodeList, error) { + pos := 0 + return parseNodes(input, &pos, false) +} + +// parseNodes walks through the input string from position *pos and returns +// a slice of nodes. It recognizes two types of tags: +// - Block tags: delimited by {% ... %}. +// - Expression tags: delimited by {{ ... }}. +// +// If stopOnEndBlock is true, parsing stops when a matching {% endblock %} is +// encountered. +// nolint: gocyclo +func parseNodes(input string, pos *int, stopOnEndBlock bool) ([]Node, error) { + var nodes []Node + + for *pos < len(input) { + relativeBlock := strings.Index(input[*pos:], "{%") + relativeExpr := strings.Index(input[*pos:], "{{") + var nextTagIndex int + var tagType string + + switch { + case relativeBlock == -1 && relativeExpr == -1: + nextTagIndex = -1 + case relativeBlock == -1: + nextTagIndex = relativeExpr + tagType = tagTypeExpr + case relativeExpr == -1: + nextTagIndex = relativeBlock + tagType = tagTypeBlock + case relativeBlock < relativeExpr: + nextTagIndex = relativeBlock + tagType = tagTypeBlock + default: + nextTagIndex = relativeExpr + tagType = tagTypeExpr + } + + if nextTagIndex == -1 { + remaining := input[*pos:] + nodes = append(nodes, tokenizeText(remaining)...) + *pos = len(input) + break + } + + tagStart := *pos + nextTagIndex + if tagStart > *pos { + text := input[*pos:tagStart] + nodes = append(nodes, tokenizeText(text)...) + } + + if tagType == tagTypeBlock { + closeTagIndex := strings.Index(input[tagStart:], "%}") + if closeTagIndex == -1 { + return nil, errors.New("unclosed block tag") + } + tagEnd := tagStart + closeTagIndex + 2 + // Get tag content inside the delimiters. + tagContent := strings.TrimSpace(input[tagStart+2 : tagStart+closeTagIndex]) + + // Check for deprecated tag. + if strings.HasPrefix(tagContent, "deprecated ") { + message := strings.TrimSpace(tagContent[len("deprecated "):]) + // Remove surrounding quotes if present. + message = strings.Trim(message, `"'`) + nodes = append(nodes, &DeprecatedNode{Message: message}) + *pos = tagEnd + continue + } + + // Handle 'autoescape' tag. + if strings.HasPrefix(tagContent, "autoescape") { + // Optionally, support a custom strategy. + strategy := "html" + parts := strings.Fields(tagContent) + if len(parts) > 1 { + strategy = strings.Trim(parts[1], `"'`) + } + *pos = tagEnd + children, err := parseNodes(input, pos, true) + if err != nil { + return nil, err + } + nodes = append(nodes, &AutoescapeNode{ + Strategy: strategy, + Children: children, + }) + continue + } else if strings.HasPrefix(tagContent, "endautoescape") { + if stopOnEndBlock { + *pos = tagEnd + return nodes, nil + } + // Treat unexpected end tag as literal text. + nodes = append(nodes, tokenizeText(input[tagStart:tagEnd])...) + *pos = tagEnd + continue + } + + // ADD: Handle 'set' tag. + if strings.HasPrefix(tagContent, "set ") { + assignment := strings.TrimSpace(tagContent[len("set "):]) + if strings.Contains(assignment, "=") { + // Inline assignment. + parts := strings.SplitN(assignment, "=", 2) + lhs := strings.TrimSpace(parts[0]) + rhs := strings.TrimSpace(parts[1]) + varNames := splitAndTrim(lhs, ",") + varValues := splitAndTrim(rhs, ",") + nodes = append(nodes, &SetNode{ + Variables: varNames, + Values: varValues, + IsBlock: false, + }) + *pos = tagEnd + continue + } else { + // Block assignment. + varNames := splitAndTrim(assignment, ",") + *pos = tagEnd + children, err := parseNodes(input, pos, true) + if err != nil { + return nil, err + } + nodes = append(nodes, &SetNode{ + Variables: varNames, + IsBlock: true, + Children: children, + }) + continue + } + } + + // Handle 'types' tag. + if strings.HasPrefix(tagContent, "types") { + remainder := strings.TrimSpace(tagContent[len("types"):]) + typesNode, err := parseTypes(remainder) + if err != nil { + return nil, err + } + nodes = append(nodes, typesNode) + *pos = tagEnd + continue + } + + // Handle block tags + switch { + case strings.HasPrefix(tagContent, "block "): + parts := strings.Fields(tagContent) + if len(parts) < 2 { + return nil, errors.New("invalid block tag: no block name") + } + blockName := parts[1] + *pos = tagEnd + children, err := parseNodes(input, pos, true) + if err != nil { + return nil, err + } + block := &BlockNode{ + Name: blockName, + Children: children, + } + nodes = append(nodes, block) + + case strings.HasPrefix(tagContent, "sw_extends"): + // New support for sw_extends. + remainder := strings.TrimSpace(tagContent[len("sw_extends"):]) + var tmpl string + scopes := []string{} + if strings.HasPrefix(remainder, "{") { + // Extended syntax: an object literal. + startIdx := strings.Index(remainder, "{") + endIdx := strings.LastIndex(remainder, "}") + if startIdx == -1 || endIdx == -1 || endIdx <= startIdx { + return nil, errors.New("invalid sw_extends syntax: missing or mismatched braces") + } + objectContent := strings.TrimSpace(remainder[startIdx+1 : endIdx]) + var err error + tmpl, scopes, err = parseSwExtendsLiteral(objectContent) + if err != nil { + return nil, err + } + } else { + // Simple syntax. + parts := strings.Fields(tagContent) + if len(parts) < 2 { + return nil, errors.New("invalid sw_extends tag: missing template path") + } + tmpl = strings.Trim(parts[1], `"'`) + } + nodes = append(nodes, &SwExtendsNode{Template: tmpl, Scopes: scopes}) + *pos = tagEnd + + case strings.HasPrefix(tagContent, "endblock") || strings.HasPrefix(tagContent, "endfor"): + if stopOnEndBlock { + *pos = tagEnd + return nodes, nil + } + // If an endblock appears unexpectedly, treat it as literal text. + nodes = append(nodes, tokenizeText(input[tagStart:tagEnd])...) + *pos = tagEnd + + default: + // Unrecognized block tag: treat it as literal text. + nodes = append(nodes, tokenizeText(input[tagStart:tagEnd])...) + *pos = tagEnd + } + continue + } else if tagType == tagTypeExpr { + closeExprIndex := strings.Index(input[tagStart:], "}}") + if closeExprIndex == -1 { + return nil, errors.New("unclosed expression tag") + } + tagEnd := tagStart + closeExprIndex + 2 + exprContent := strings.TrimSpace(input[tagStart+2 : tagStart+closeExprIndex]) + if exprContent == "parent()" { + nodes = append(nodes, &ParentNode{}) + } else { + // Create a PrintNode for expressions like {{ a_variable }} + nodes = append(nodes, &PrintNode{Expression: exprContent}) + } + *pos = tagEnd + } + } + + return nodes, nil +} + +// splitAndTrim splits the string s by the given sep and trims whitespace from each element. +func splitAndTrim(s, sep string) []string { + parts := strings.Split(s, sep) + var results []string + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + results = append(results, trimmed) + } + } + return results +} + +// parseSwExtendsLiteral parses the object literal inside a sw_extends tag. +// It expects an input like: +// +// template: '@Storefront/storefront/page/checkout/finish/finish-details.html.twig', +// scopes: ['default', 'subscription'] +func parseSwExtendsLiteral(s string) (string, []string, error) { + var tmpl string + var scopes []string + + // Parse the "template:" value. + templateIdx := strings.Index(s, "template:") + if templateIdx == -1 { + return "", nil, errors.New("sw_extends literal missing 'template' key") + } + rest := s[templateIdx+len("template:"):] + // Find the end of the template value (either a comma or end-of-string). + endIdx := strings.Index(rest, ",") + var tmplValue string + if endIdx == -1 { + tmplValue = rest + } else { + tmplValue = rest[:endIdx] + } + tmpl = strings.TrimSpace(tmplValue) + tmpl = strings.Trim(tmpl, `"'`) + + // Look for the "scopes:" key. + scopesIdx := strings.Index(s, "scopes:") + if scopesIdx != -1 { + rest = s[scopesIdx+len("scopes:"):] + rest = strings.TrimSpace(rest) + if len(rest) > 0 && rest[0] == '[' { + // Find the closing bracket. + endArrIdx := strings.Index(rest, "]") + if endArrIdx == -1 { + return "", nil, errors.New("invalid scopes array: missing ']'") + } + arrContent := rest[1:endArrIdx] + // Split by commas. + parts := strings.Split(arrContent, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + p = strings.Trim(p, `"'`) + if p != "" { + scopes = append(scopes, p) + } + } + } + } + return tmpl, scopes, nil +} diff --git a/internal/twigparser/twig_test.go b/internal/twigparser/twig_test.go new file mode 100644 index 00000000..6b79572a --- /dev/null +++ b/internal/twigparser/twig_test.go @@ -0,0 +1,185 @@ +package twigparser + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBlockParsing(t *testing.T) { + template := `{% block content %} +{% block page_account_address_form_create_personal %} + {{ parent() }} + {% endblock %} + + {% block page_account_address_form_create_general %} + {{ parent() }} + {% endblock %} +{% endblock %}` + + nodes, err := ParseTemplate(template) + + assert.NoError(t, err) + + assert.NotNil(t, nodes.FindBlock("content")) + assert.NotNil(t, nodes.FindBlock("page_account_address_form_create_personal")) + assert.NotNil(t, nodes.FindBlock("page_account_address_form_create_general")) +} + +func TestTraversing(t *testing.T) { + template := `{% block content %} +{{ parent() }} +{% endblock %}` + + nodes, err := ParseTemplate(template) + + assert.NoError(t, err) + + nodes.Traverse(func(node Node) Node { + if block, ok := node.(*BlockNode); ok { + filteredNodes := block.Children.RemoveWhitespace() + + if _, ok := filteredNodes[0].(*ParentNode); ok && len(filteredNodes) == 1 { + return &TextNode{Text: fmt.Sprintf("{{ block(\"%s\") }}", block.Name)} + } + } + + return node + }) + + assert.Equal(t, "{{ block(\"content\") }}", nodes.Dump()) +} + +func TestSwExtendsParsing(t *testing.T) { + testcases := []struct { + template string + path string + scopes []string + }{ + { + template: `{% sw_extends 'foo.twig' %}`, + path: "foo.twig", + scopes: []string{}, + }, + { + template: `{% sw_extends { + template: '@Storefront/storefront/page/checkout/finish/finish-details.html.twig', + scopes: ['default', 'subscription'] + } +%}`, + path: "@Storefront/storefront/page/checkout/finish/finish-details.html.twig", + scopes: []string{"default", "subscription"}, + }, + } + + for _, tc := range testcases { + nodes, err := ParseTemplate(tc.template) + + assert.NoError(t, err) + + extends := nodes.Extends() + + assert.NotNil(t, extends) + assert.Equal(t, tc.path, extends.Template) + assert.Equal(t, tc.scopes, extends.Scopes) + } +} + +func TestPrintNodeParsing(t *testing.T) { + template := `{{ a_variable }}` + nodes, err := ParseTemplate(template) + assert.NoError(t, err) + + var printNode *PrintNode + for _, node := range nodes { + if pn, ok := node.(*PrintNode); ok { + printNode = pn + break + } + } + assert.NotNil(t, printNode) + assert.Equal(t, "a_variable", printNode.Expression) + assert.Equal(t, "{{ a_variable }}", nodes.Dump()) +} + +func TestDeprecatedNodeParsing(t *testing.T) { + template := `{% deprecated 'The "base.html.twig" template is deprecated, use "layout.html.twig" instead.' %}` + nodes, err := ParseTemplate(template) + assert.NoError(t, err) + + var deprecatedNode *DeprecatedNode + for _, node := range nodes { + if dn, ok := node.(*DeprecatedNode); ok { + deprecatedNode = dn + break + } + } + assert.NotNil(t, deprecatedNode) + expectedMsg := `The "base.html.twig" template is deprecated, use "layout.html.twig" instead.` + assert.Equal(t, expectedMsg, deprecatedNode.Message) + assert.Equal(t, "{% deprecated '"+expectedMsg+"' %}", nodes.Dump()) +} + +func TestSetNodeParsing(t *testing.T) { + // Inline set assignment. + inlineTemplate := `{% set name = 'Fabien' %}` + nodes, err := ParseTemplate(inlineTemplate) + assert.NoError(t, err) + + var inlineSet *SetNode + for _, node := range nodes { + if sn, ok := node.(*SetNode); ok && sn.IsBlock == false { + inlineSet = sn + break + } + } + assert.NotNil(t, inlineSet) + assert.Equal(t, []string{"name"}, inlineSet.Variables) + assert.Equal(t, []string{"'Fabien'"}, inlineSet.Values) + + // Block set assignment. + blockTemplate := `{% set content %} + +{% endset %}` + nodes, err = ParseTemplate(blockTemplate) + assert.NoError(t, err) + + var blockSet *SetNode + for _, node := range nodes { + if sn, ok := node.(*SetNode); ok && sn.IsBlock == true { + blockSet = sn + break + } + } + assert.NotNil(t, blockSet) + assert.Equal(t, []string{"content"}, blockSet.Variables) + // Check that the dumped content contains the inner HTML. + dumped := blockSet.Dump() + assert.Contains(t, dumped, "
") +} + +func TestAutoescapeNodeParsing(t *testing.T) { + template := `{% autoescape %} + Everything will be automatically escaped. + {% endautoescape %}` + nodes, err := ParseTemplate(template) + assert.NoError(t, err) + + var autoNode *AutoescapeNode + for _, node := range nodes { + if an, ok := node.(*AutoescapeNode); ok { + autoNode = an + break + } + } + assert.NotNil(t, autoNode) + // Default strategy "html" is used. + assert.Equal(t, "html", autoNode.Strategy) + + dumped := autoNode.Dump() + assert.Contains(t, dumped, "{% autoescape %}") + assert.Contains(t, dumped, "{% endautoescape %}") +} diff --git a/internal/verifier/admin_twig.go b/internal/verifier/admin_twig.go new file mode 100644 index 00000000..8b31636c --- /dev/null +++ b/internal/verifier/admin_twig.go @@ -0,0 +1,164 @@ +package verifier + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/sergi/go-diff/diffmatchpatch" + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" + "github.com/shopware/shopware-cli/internal/verifier/admintwiglinter" + "github.com/shopware/shopware-cli/logging" +) + +type AdminTwigLinter struct{} + +func (a AdminTwigLinter) Name() string { + return "admin-twig" +} + +func (a AdminTwigLinter) Check(ctx context.Context, check *Check, config ToolConfig) error { + fixers := admintwiglinter.GetFixers(version.Must(version.NewVersion(config.MinShopwareVersion))) + + for _, p := range config.AdminDirectories { + err := filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + if filepath.Ext(path) != admintwiglinter.TwigExtension { + return nil + } + + file, err := os.ReadFile(path) + if err != nil { + return err + } + + parsed, err := html.NewParser(string(file)) + if err != nil { + return fmt.Errorf("failed to parse %s: %w", path, err) + } + + for _, fixer := range fixers { + for _, message := range fixer.Check(parsed) { + check.AddResult(CheckResult{ + Message: message.Message, + Path: strings.TrimPrefix(strings.TrimPrefix(path, "/private"), config.RootDir+"/"), + Line: 0, + Severity: message.Severity, + Identifier: fmt.Sprintf("admintwiglinter/%s", message.Identifier), + }) + } + } + + return nil + }) + if err != nil { + return err + } + } + + return nil +} + +func (a AdminTwigLinter) Fix(ctx context.Context, config ToolConfig) error { + fixers := admintwiglinter.GetFixers(version.Must(version.NewVersion(config.MinShopwareVersion))) + + for _, p := range config.AdminDirectories { + err := filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + if filepath.Ext(path) != admintwiglinter.TwigExtension { + return nil + } + + file, err := os.ReadFile(path) + if err != nil { + return err + } + + parsed, err := html.NewParser(string(file)) + if err != nil { + return err + } + + for _, fixer := range fixers { + if err := fixer.Fix(parsed); err != nil { + return err + } + } + + return os.WriteFile(path, []byte(parsed.Dump(0)), os.ModePerm) + }) + if err != nil { + return err + } + } + + return nil +} + +func (a AdminTwigLinter) Format(ctx context.Context, config ToolConfig, dryRun bool) error { + dmp := diffmatchpatch.New() + + for _, p := range config.AdminDirectories { + err := filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + if filepath.Ext(path) != admintwiglinter.TwigExtension { + return nil + } + + file, err := os.ReadFile(path) + if err != nil { + return err + } + + parsed, err := html.NewParser(string(file)) + if err != nil { + return fmt.Errorf("failed to parse %s: %w", path, err) + } + + if dryRun { + diffs := dmp.DiffMain(string(file), parsed.Dump(0), false) + + logging.FromContext(ctx).Info(dmp.DiffPrettyText(diffs)) + + return nil + } else { + return os.WriteFile(path, []byte(parsed.Dump(0)), os.ModePerm) + } + }) + if err != nil { + return err + } + } + + return nil +} + +func init() { + AddTool(AdminTwigLinter{}) +} diff --git a/internal/verifier/admintwiglinter/constants.go b/internal/verifier/admintwiglinter/constants.go new file mode 100644 index 00000000..8155dace --- /dev/null +++ b/internal/verifier/admintwiglinter/constants.go @@ -0,0 +1,30 @@ +package admintwiglinter + +// Common string constants used across the package +const ( + // File extensions + TwigExtension = ".twig" + + // Attribute values + CriticalValue = "critical" + MediumValue = "medium" + DefaultValue = "default" + + // Element tags + TemplateTag = "template" + + // Attribute keys + ValueAttr = "value" + SizeAttr = "size" + ColonValueAttr = ":value" + VModelValueAttr = "v-model:value" + VModelAttr = "v-model" + ModelValueAttr = "model-value" + UpdateValueAttr = "@update:value" + UpdateModelValueAttr = "@update:model-value" + LabelSlotAttr = "#label" + HintSlotAttr = "#hint" + BaseFieldMountedAttr = "@base-field-mounted" + IsInvalidAttr = "isInvalid" + AiBadgeAttr = "aiBadge" +) diff --git a/internal/verifier/admintwiglinter/fix_alert.go b/internal/verifier/admintwiglinter/fix_alert.go new file mode 100644 index 00000000..b26fbc21 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_alert.go @@ -0,0 +1,75 @@ +package admintwiglinter + +import ( + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type AlertFixer struct{} + +func init() { + AddFixer(AlertFixer{}) +} + +func (a AlertFixer) Check(nodes []html.Node) []CheckError { + var errors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-alert" { + errors = append(errors, CheckError{ + Message: "sw-alert is removed, use mt-banner instead. Please review conversion for variant changes.", + Severity: "error", + Identifier: "sw-alert", + Line: node.Line, + }) + } + }) + return errors +} + +func (a AlertFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (a AlertFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-alert" { + node.Tag = "mt-banner" + var newAttrs html.NodeList + + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + if attr.Key == "variant" { + switch attr.Value { + case "success": + attr.Value = "positive" + newAttrs = append(newAttrs, attr) + case "error": + attr.Value = "critical" + newAttrs = append(newAttrs, attr) + case "warning": + attr.Value = "attention" + newAttrs = append(newAttrs, attr) + case "info": + // Keep info as is + newAttrs = append(newAttrs, attr) + default: + // Keep any other variants unchanged + newAttrs = append(newAttrs, attr) + } + } else { + // Preserve all other attributes + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + + node.Attributes = newAttrs + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_alert_test.go b/internal/verifier/admintwiglinter/fix_alert_test.go new file mode 100644 index 00000000..312bafc0 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_alert_test.go @@ -0,0 +1,56 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAlertFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: `Message`, + after: `Message`, + }, + { + description: "info variant remains unchanged", + before: `Info message`, + after: `Info message`, + }, + { + description: "success variant converts to positive", + before: `Success message`, + after: `Success message`, + }, + { + description: "error variant converts to critical", + before: `Error message`, + after: `Error message`, + }, + { + description: "warning variant converts to attention", + before: `Warning message`, + after: `Warning message`, + }, + { + description: "preserve other attributes", + before: `Error message`, + after: `Error message`, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(AlertFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_button.go b/internal/verifier/admintwiglinter/fix_button.go new file mode 100644 index 00000000..f654d690 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_button.go @@ -0,0 +1,96 @@ +package admintwiglinter + +import ( + "fmt" + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type ButtonFixer struct{} + +func init() { + AddFixer(ButtonFixer{}) +} + +func (b ButtonFixer) Check(nodes []html.Node) []CheckError { + // ...existing code... + var errors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-button" { + errors = append(errors, CheckError{ + Message: "sw-button is removed, use mt-button instead. Please review conversion for variant and router-link.", + Severity: "error", + Identifier: "sw-button", + Line: node.Line, + }) + } + }) + return errors +} + +func (b ButtonFixer) Supports(v *version.Version) bool { + // ...existing code... + return shopware67Constraint.Check(v) +} + +func (b ButtonFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-button" { + node.Tag = "mt-button" + var newAttrs html.NodeList + // Flags to determine additional properties. + addGhost := false + + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case "variant": + lower := strings.ToLower(attr.Value) + switch lower { + case "ghost": + // Remove variant and set ghost. + addGhost = true + case "danger": + // Change value to critical. + attr.Value = CriticalValue + newAttrs = append(newAttrs, attr) + case "ghost-danger": + // Set critical and also ghost. + attr.Value = CriticalValue + newAttrs = append(newAttrs, attr) + addGhost = true + case "contrast", "context": + // Remove attribute + default: + newAttrs = append(newAttrs, attr) + } + case "router-link": + // Replace with @click event. + val := attr.Value + newAttrs = append(newAttrs, html.Attribute{ + Key: "@click", + Value: fmt.Sprintf("this.$router.push('%s')", val), + }) + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + + if addGhost { + newAttrs = append(newAttrs, html.Attribute{ + Key: "ghost", + }) + } + node.Attributes = newAttrs + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_button_test.go b/internal/verifier/admintwiglinter/fix_button_test.go new file mode 100644 index 00000000..84b9c212 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_button_test.go @@ -0,0 +1,60 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestButtonFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: `Save`, + after: `Save`, + }, + { + description: "remove variant ghost and add ghost attribute", + before: `Save`, + after: `Save`, + }, + { + description: "replace danger variant with critical", + before: `Delete`, + after: `Delete`, + }, + { + description: "replace ghost-danger variant with critical and add ghost", + before: `Delete`, + after: `Delete`, + }, + { + description: "remove contrast variant", + before: `Info`, + after: `Info`, + }, + { + description: "remove context variant", + before: `Info`, + after: `Info`, + }, + { + description: "replace router-link with @click", + before: `Go to example`, + after: `Go to example`, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(ButtonFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_card.go b/internal/verifier/admintwiglinter/fix_card.go new file mode 100644 index 00000000..b3b508e5 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_card.go @@ -0,0 +1,79 @@ +package admintwiglinter + +import ( + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type CardFixer struct{} + +func init() { + AddFixer(CardFixer{}) +} + +func (c CardFixer) Check(nodes []html.Node) []CheckError { + // ...existing code... + var errors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-card" { + errors = append(errors, CheckError{ + Message: "sw-card is removed, use mt-card instead. Review conversion for aiBadge and contentPadding.", + Severity: "error", + Identifier: "sw-card", + Line: node.Line, + }) + } + }) + return errors +} + +func (c CardFixer) Supports(v *version.Version) bool { + // ...existing code... + return shopware67Constraint.Check(v) +} + +func (c CardFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-card" { + node.Tag = "mt-card" + var newAttrs html.NodeList + aiBadgeFound := false + // Process attributes: remove aiBadge and contentPadding. + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case "aiBadge", "contentPadding": + if attr.Key == "aiBadge" { + aiBadgeFound = true + } + // remove attribute + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + + // If aiBadge was present, add title slot with sw-ai-copilot-badge. + if aiBadgeFound { + aiBadgeSlot := &html.ElementNode{ + Tag: "slot", + Attributes: html.NodeList{ + html.Attribute{Key: "name", Value: "title"}, + }, + Children: html.NodeList{ + &html.ElementNode{Tag: "sw-ai-copilot-badge"}, + }, + } + // Prepend the title slot to existing children. + node.Children = append(html.NodeList{aiBadgeSlot}, node.Children...) + } + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_card_test.go b/internal/verifier/admintwiglinter/fix_card_test.go new file mode 100644 index 00000000..a596e7d8 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_card_test.go @@ -0,0 +1,42 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCardFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: `Hello World`, + after: `Hello World`, + }, + { + description: "remove contentPadding property", + before: `Hello World`, + after: `Hello World`, + }, + { + description: "convert aiBadge property to title slot", + before: `Hello World`, + after: ` + + + + Hello World +`, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(CardFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_checkbox_field.go b/internal/verifier/admintwiglinter/fix_checkbox_field.go new file mode 100644 index 00000000..1532e03e --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_checkbox_field.go @@ -0,0 +1,104 @@ +package admintwiglinter + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type CheckboxFieldFixer struct{} + +func init() { + AddFixer(CheckboxFieldFixer{}) +} + +func (c CheckboxFieldFixer) Check(nodes []html.Node) []CheckError { + // ...existing code... + var errs []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-checkbox-field" { + errs = append(errs, CheckError{ + Message: "sw-checkbox-field is removed, use mt-checkbox instead. Review conversion for props, events and slots.", + Severity: "error", + Identifier: "sw-checkbox-field", + Line: node.Line, + }) + } + }) + return errs +} + +func (c CheckboxFieldFixer) Supports(v *version.Version) bool { + // ...existing code... + return shopware67Constraint.Check(v) +} + +func (c CheckboxFieldFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-checkbox-field" { + node.Tag = "mt-checkbox" + var newAttrs html.NodeList + // Process attribute conversions. + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case ColonValueAttr: + newAttrs = append(newAttrs, html.Attribute{Key: ":checked", Value: attr.Value}) + case VModelAttr, VModelValueAttr: + newAttrs = append(newAttrs, html.Attribute{Key: "v-model:checked", Value: attr.Value}) + case "id", "ghostValue", "padded": + // remove these attributes without replacement + case "partlyChecked": + newAttrs = append(newAttrs, html.Attribute{Key: "partial"}) + case UpdateValueAttr: + newAttrs = append(newAttrs, html.Attribute{Key: "@update:checked", Value: attr.Value}) + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + + // Process children for slot conversion. + var labelText string + var remainingChildren html.NodeList + for _, child := range node.Children { + if elem, ok := child.(*html.ElementNode); ok && elem.Tag == "template" { + // Handle label slot. + for _, a := range elem.Attributes { + if attr, ok := a.(html.Attribute); ok { + if attr.Key == "#label" || attr.Key == "v-slot:label" { + var sb strings.Builder + for _, inner := range elem.Children { + sb.WriteString(strings.TrimSpace(inner.Dump(0))) + } + labelText = sb.String() + goto SkipChild + } + // Remove hint slot. + if attr.Key == "v-slot:hint" || attr.Key == "#hint" { + goto SkipChild + } + } + } + } + remainingChildren = append(remainingChildren, child) + SkipChild: + } + node.Children = remainingChildren + if labelText != "" { + node.Attributes = append(node.Attributes, html.Attribute{ + Key: "label", + Value: labelText, + }) + } + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_checkbox_field_test.go b/internal/verifier/admintwiglinter/fix_checkbox_field_test.go new file mode 100644 index 00000000..61f9b51d --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_checkbox_field_test.go @@ -0,0 +1,71 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCheckboxFieldFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: ``, + after: ``, + }, + { + description: "replace value with checked", + before: ``, + after: ``, + }, + { + description: "replace v-model with v-model:checked", + before: ``, + after: ``, + }, + { + description: "replace v-model:value with v-model:checked", + before: ``, + after: ``, + }, + { + description: "convert label slot to label prop", + before: ``, + after: ``, + }, + { + description: "remove hint slot", + before: ``, + after: ``, + }, + { + description: "remove id and ghostValue props", + before: ``, + after: ``, + }, + { + description: "convert partlyChecked to partial", + before: ``, + after: ``, + }, + { + description: "replace @update:value with @update:checked", + before: ``, + after: ``, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(CheckboxFieldFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_colorpicker.go b/internal/verifier/admintwiglinter/fix_colorpicker.go new file mode 100644 index 00000000..8180b43b --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_colorpicker.go @@ -0,0 +1,94 @@ +package admintwiglinter + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type ColorpickerFixer struct{} + +func init() { + AddFixer(ColorpickerFixer{}) +} + +func (c ColorpickerFixer) Check(nodes []html.Node) []CheckError { + var checkErrors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-colorpicker" { + checkErrors = append(checkErrors, CheckError{ + Message: "sw-colorpicker is removed, use mt-colorpicker instead. Please review conversion for label property.", + Severity: "error", + Identifier: "sw-colorpicker", + Line: node.Line, + }) + } + }) + return checkErrors +} + +func (c ColorpickerFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (c ColorpickerFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-colorpicker" { + node.Tag = "mt-colorpicker" + + var newAttrs html.NodeList + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case ColonValueAttr: + attr.Key = ":model-value" + newAttrs = append(newAttrs, attr) + case VModelValueAttr: + attr.Key = VModelAttr + newAttrs = append(newAttrs, attr) + case UpdateValueAttr: + attr.Key = UpdateModelValueAttr + newAttrs = append(newAttrs, attr) + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + + // Process label slot: extract inner text and add as label attribute. + label := "" + for _, child := range node.Children { + if elem, ok := child.(*html.ElementNode); ok { + if elem.Tag == TemplateTag { + for _, a := range elem.Attributes { + if attr, ok := a.(html.Attribute); ok { + if attr.Key == LabelSlotAttr { + var sb strings.Builder + for _, inner := range elem.Children { + sb.WriteString(strings.TrimSpace(inner.Dump(0))) + } + label = sb.String() + } + } + } + } + } + } + node.Children = html.NodeList{} + if label != "" { + node.Attributes = append(node.Attributes, html.Attribute{ + Key: "label", + Value: label, + }) + } + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_colorpicker_test.go b/internal/verifier/admintwiglinter/fix_colorpicker_test.go new file mode 100644 index 00000000..3d42274d --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_colorpicker_test.go @@ -0,0 +1,47 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestColorpickerFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: ``, + after: ``, + }, + { + description: "replace value with model-value", + before: ``, + after: ``, + }, + { + description: "replace v-model:value with v-model", + before: ``, + after: ``, + }, + { + description: "replace update:value event", + before: ``, + after: ``, + }, + { + description: "process label slot", + before: ``, + after: ``, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(ColorpickerFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_datepicker.go b/internal/verifier/admintwiglinter/fix_datepicker.go new file mode 100644 index 00000000..1ec8ec36 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_datepicker.go @@ -0,0 +1,101 @@ +package admintwiglinter + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type DatepickerFixer struct{} + +func init() { + AddFixer(DatepickerFixer{}) +} + +func (d DatepickerFixer) Check(nodes []html.Node) []CheckError { + var checkErrors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-datepicker" { + checkErrors = append(checkErrors, CheckError{ + Message: "sw-datepicker is removed, use mt-datepicker instead. Please review the conversion for the label property.", + Severity: "error", + Identifier: "sw-datepicker", + Line: node.Line, + }) + } + }) + return checkErrors +} + +func (d DatepickerFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (d DatepickerFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-datepicker" { + node.Tag = "mt-datepicker" + + var newAttrs html.NodeList + // Update attribute names. + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case ":value": + attr.Key = ":model-value" + newAttrs = append(newAttrs, attr) + case VModelValueAttr: + attr.Key = VModelAttr + newAttrs = append(newAttrs, attr) + case UpdateValueAttr: + attr.Key = "@update:model-value" + newAttrs = append(newAttrs, attr) + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + + // Convert label slot to label property. + label := "" + var remainingChildren html.NodeList + for _, child := range node.Children { + if elem, ok := child.(*html.ElementNode); ok { + if elem.Tag == TemplateTag { + for _, a := range elem.Attributes { + if attr, ok := a.(html.Attribute); ok { + if attr.Key == LabelSlotAttr { + var sb strings.Builder + for _, inner := range elem.Children { + sb.WriteString(strings.TrimSpace(inner.Dump(0))) + } + label = sb.String() + goto SkipChild + } + } + } + } + } + remainingChildren = append(remainingChildren, child) + SkipChild: + } + + node.Children = remainingChildren + if label != "" { + node.Attributes = append(node.Attributes, html.Attribute{ + Key: "label", + Value: label, + }) + } + } + }) + + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_datepicker_test.go b/internal/verifier/admintwiglinter/fix_datepicker_test.go new file mode 100644 index 00000000..00d21ab1 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_datepicker_test.go @@ -0,0 +1,36 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDatepickerFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: ``, + after: ``, + }, + { + description: "convert label slot to prop", + before: ``, + after: ``, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(DatepickerFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_email_field.go b/internal/verifier/admintwiglinter/fix_email_field.go new file mode 100644 index 00000000..d56ec928 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_email_field.go @@ -0,0 +1,101 @@ +package admintwiglinter + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type EmailFieldFixer struct{} + +func init() { + AddFixer(EmailFieldFixer{}) +} + +func (e EmailFieldFixer) Check(nodes []html.Node) []CheckError { + var errors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-email-field" { + errors = append(errors, CheckError{ + Message: "sw-email-field is removed, use mt-email-field instead. Review conversion for props, events and label slot.", + Severity: "error", + Identifier: "sw-email-field", + Line: node.Line, + }) + } + }) + return errors +} + +func (e EmailFieldFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (e EmailFieldFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-email-field" { + node.Tag = "mt-email-field" + var newAttrs html.NodeList + + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case ValueAttr: + attr.Key = "model-value" + newAttrs = append(newAttrs, attr) + case "v-model:value": + attr.Key = "v-model" + newAttrs = append(newAttrs, attr) + case SizeAttr: + if attr.Value == MediumValue { + attr.Value = DefaultValue + } + newAttrs = append(newAttrs, attr) + case IsInvalidAttr, AiBadgeAttr, BaseFieldMountedAttr: + // remove attribute + case UpdateValueAttr: + attr.Key = UpdateModelValueAttr + newAttrs = append(newAttrs, attr) + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + + // Process label slot. + label := "" + for _, child := range node.Children { + if elem, ok := child.(*html.ElementNode); ok && elem.Tag == TemplateTag { + for _, a := range elem.Attributes { + if attr, ok := a.(html.Attribute); ok { + if attr.Key == LabelSlotAttr { + var sb strings.Builder + for _, inner := range elem.Children { + sb.WriteString(strings.TrimSpace(inner.Dump(0))) + } + label = sb.String() + goto SkipChild + } + } + } + } + SkipChild: + } + node.Children = nil + if label != "" { + node.Attributes = append(node.Attributes, html.Attribute{ + Key: "label", + Value: label, + }) + } + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_email_field_test.go b/internal/verifier/admintwiglinter/fix_email_field_test.go new file mode 100644 index 00000000..6df8b02e --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_email_field_test.go @@ -0,0 +1,71 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEmailFieldFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: ``, + after: ``, + }, + { + description: "replace value with model-value", + before: ``, + after: ``, + }, + { + description: "replace v-model:value with v-model", + before: ``, + after: ``, + }, + { + description: "replace size medium with default", + before: ``, + after: ``, + }, + { + description: "remove isInvalid attribute", + before: ``, + after: ``, + }, + { + description: "remove aiBadge attribute", + before: ``, + after: ``, + }, + { + description: "replace update:value event", + before: ``, + after: ``, + }, + { + description: "remove base-field-mounted event", + before: ``, + after: ``, + }, + { + description: "process label slot", + before: ` + +`, + after: ``, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(EmailFieldFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_external_link.go b/internal/verifier/admintwiglinter/fix_external_link.go new file mode 100644 index 00000000..ec5dc95d --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_external_link.go @@ -0,0 +1,55 @@ +package admintwiglinter + +import ( + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type ExternalLinkFixer struct{} + +func init() { + AddFixer(ExternalLinkFixer{}) +} + +func (e ExternalLinkFixer) Check(nodes []html.Node) []CheckError { + var checkErrors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-external-link" { + checkErrors = append(checkErrors, CheckError{ + Message: "sw-external-link is removed, use mt-external-link instead and remove the icon property.", + Severity: "error", + Identifier: "sw-external-link", + Line: node.Line, + }) + } + }) + return checkErrors +} + +func (e ExternalLinkFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (e ExternalLinkFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-external-link" { + node.Tag = "mt-external-link" + var newAttrs html.NodeList + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + if attr.Key == "icon" { + continue + } + newAttrs = append(newAttrs, attr) + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_external_link_test.go b/internal/verifier/admintwiglinter/fix_external_link_test.go new file mode 100644 index 00000000..bacd5739 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_external_link_test.go @@ -0,0 +1,32 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExternalLinkFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: `Hello World`, + after: `Hello World`, + }, + { + description: "remove icon attribute", + before: `Hello World`, + after: `Hello World`, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(ExternalLinkFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_icon.go b/internal/verifier/admintwiglinter/fix_icon.go new file mode 100644 index 00000000..29fbc9d1 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_icon.go @@ -0,0 +1,85 @@ +package admintwiglinter + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type IconFixer struct{} + +func init() { + AddFixer(IconFixer{}) +} + +func (i IconFixer) Check(nodes []html.Node) []CheckError { + var errors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-icon" { + errors = append(errors, CheckError{ + Message: "sw-icon is removed, use mt-icon instead with proper size prop.", + Severity: "error", + Identifier: "sw-icon", + Line: node.Line, + }) + } + }) + return errors +} + +func (i IconFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (i IconFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-icon" { + node.Tag = "mt-icon" + hasSize := false + var newAttrs html.NodeList + + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch strings.ToLower(attr.Key) { + case "small": + // Replace "small" with size="16px" + newAttrs = append(newAttrs, html.Attribute{ + Key: "size", + Value: "16px", + }) + hasSize = true + case "large": + // Replace "large" with size="32px" + newAttrs = append(newAttrs, html.Attribute{ + Key: "size", + Value: "32px", + }) + hasSize = true + case "size": + // keep existing size prop + newAttrs = append(newAttrs, attr) + hasSize = true + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + + // If no size related prop is set, add default size="24px" + if !hasSize { + newAttrs = append(newAttrs, html.Attribute{ + Key: "size", + Value: "24px", + }) + } + node.Attributes = newAttrs + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_icon_test.go b/internal/verifier/admintwiglinter/fix_icon_test.go new file mode 100644 index 00000000..7ebcc3bc --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_icon_test.go @@ -0,0 +1,46 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIconFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement with default size", + before: ``, + after: ``, + }, + { + description: "replace small with size 16px", + before: ``, + after: ``, + }, + { + description: "replace large with size 32px", + before: ``, + after: ``, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(IconFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_loader.go b/internal/verifier/admintwiglinter/fix_loader.go new file mode 100644 index 00000000..f96520b3 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_loader.go @@ -0,0 +1,41 @@ +package admintwiglinter + +import ( + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type LoaderFixer struct{} + +func init() { + AddFixer(LoaderFixer{}) +} + +func (l LoaderFixer) Check(nodes []html.Node) []CheckError { + var errs []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-loader" { + errs = append(errs, CheckError{ + Message: "sw-loader is removed, use mt-loader instead.", + Severity: "error", + Identifier: "sw-loader", + Line: node.Line, + }) + } + }) + return errs +} + +func (l LoaderFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (l LoaderFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-loader" { + node.Tag = "mt-loader" + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_loader_test.go b/internal/verifier/admintwiglinter/fix_loader_test.go new file mode 100644 index 00000000..b5efd254 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_loader_test.go @@ -0,0 +1,25 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoaderFixer(t *testing.T) { + cases := []struct { + before string + after string + }{ + { + before: ``, + after: ``, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(LoaderFixer{}, c.before) + assert.NoError(t, err) + assert.Equal(t, c.after, newStr) + } +} diff --git a/internal/verifier/admintwiglinter/fix_number_field.go b/internal/verifier/admintwiglinter/fix_number_field.go new file mode 100644 index 00000000..9e80db48 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_number_field.go @@ -0,0 +1,99 @@ +package admintwiglinter + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type NumberFieldFixer struct{} + +func init() { + AddFixer(NumberFieldFixer{}) +} + +func (n NumberFieldFixer) Check(nodes []html.Node) []CheckError { + var errs []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-number-field" { + errs = append(errs, CheckError{ + Message: "sw-number-field is removed, use mt-number-field instead. Please review conversion for props, events and label slot.", + Severity: "error", + Identifier: "sw-number-field", + Line: node.Line, + }) + } + }) + return errs +} + +func (n NumberFieldFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (n NumberFieldFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-number-field" { + node.Tag = "mt-number-field" + var newAttrs html.NodeList + + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case ColonValueAttr: + newAttrs = append(newAttrs, html.Attribute{ + Key: ":model-value", + Value: attr.Value, + }) + case VModelValueAttr: + attr.Key = VModelAttr + newAttrs = append(newAttrs, attr) + case "@update:value": + newAttrs = append(newAttrs, html.Attribute{ + Key: "@change", + Value: attr.Value, + }) + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + + var label string + var remainingChildren html.NodeList + for _, child := range node.Children { + if elem, ok := child.(*html.ElementNode); ok && elem.Tag == TemplateTag { + for _, a := range elem.Attributes { + if attr, ok := a.(html.Attribute); ok { + if attr.Key == LabelSlotAttr { + var sb strings.Builder + for _, inner := range elem.Children { + sb.WriteString(strings.TrimSpace(inner.Dump(0))) + } + label = sb.String() + goto SkipChild + } + } + } + } + remainingChildren = append(remainingChildren, child) + SkipChild: + } + node.Children = remainingChildren + if label != "" { + node.Attributes = append(node.Attributes, html.Attribute{ + Key: "label", + Value: label, + }) + } + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_number_field_test.go b/internal/verifier/admintwiglinter/fix_number_field_test.go new file mode 100644 index 00000000..4f7a4c7e --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_number_field_test.go @@ -0,0 +1,47 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNumberFieldFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: ``, + after: ``, + }, + { + description: "replace :value with :model-value", + before: ``, + after: ``, + }, + { + description: "convert v-model:value to :model-value and @change", + before: ``, + after: ``, + }, + { + description: "convert label slot to label prop", + before: ``, + after: ``, + }, + { + description: "replace @update:value with @change", + before: ``, + after: ``, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(NumberFieldFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_passwordfield.go b/internal/verifier/admintwiglinter/fix_passwordfield.go new file mode 100644 index 00000000..95a890d7 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_passwordfield.go @@ -0,0 +1,119 @@ +package admintwiglinter + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type PasswordFieldFixer struct{} + +func init() { + AddFixer(PasswordFieldFixer{}) +} + +func (p PasswordFieldFixer) Check(nodes []html.Node) []CheckError { + var checkErrors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-password-field" { + checkErrors = append(checkErrors, CheckError{ + Message: "sw-password-field is removed, use mt-password-field instead. Please review conversion for label/hint properties.", + Severity: "error", + Identifier: "sw-password-field", + Line: node.Line, + }) + } + }) + return checkErrors +} + +func (p PasswordFieldFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (p PasswordFieldFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-password-field" { + node.Tag = "mt-password-field" + + // Update or remove attributes + var newAttrs html.NodeList + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case "value": + attr.Key = "model-value" + newAttrs = append(newAttrs, attr) + case VModelValueAttr: + attr.Key = "v-model" + newAttrs = append(newAttrs, attr) + case "size": + if attr.Value == "medium" { + attr.Value = "default" + } + newAttrs = append(newAttrs, attr) + case "isInvalid": + // remove attribute + case "@update:value": + attr.Key = "@update:model-value" + newAttrs = append(newAttrs, attr) + case "@base-field-mounted": + // remove attribute + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + + // Process slot children for label and hint + var label, hint string + for _, child := range node.Children { + if elem, ok := child.(*html.ElementNode); ok && elem.Tag == "template" { + for _, a := range elem.Attributes { + if attr, ok := a.(html.Attribute); ok { + if attr.Key == "#label" { + var sb strings.Builder + for _, inner := range elem.Children { + sb.WriteString(strings.TrimSpace(inner.Dump(0))) + } + label = strings.Replace(sb.String(), "Label", "label", 1) + goto SkipChild + } + if attr.Key == "#hint" { + var sb strings.Builder + for _, inner := range elem.Children { + sb.WriteString(strings.TrimSpace(inner.Dump(0))) + } + hint = strings.Replace(sb.String(), "Hint", "hint", 1) + goto SkipChild + } + } + } + } + SkipChild: + } + // Remove original children after processing slots + node.Children = nil + if label != "" { + node.Attributes = append(node.Attributes, html.Attribute{ + Key: "label", + Value: label, + }) + } + if hint != "" { + node.Attributes = append(node.Attributes, html.Attribute{ + Key: "hint", + Value: hint, + }) + } + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_passwordfield_test.go b/internal/verifier/admintwiglinter/fix_passwordfield_test.go new file mode 100644 index 00000000..9bb8508a --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_passwordfield_test.go @@ -0,0 +1,75 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPasswordFieldFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "basic component replacement", + before: ``, + after: ``, + }, + { + description: "replace value with model-value", + before: ``, + after: ``, + }, + { + description: "replace v-model:value with v-model", + before: ``, + after: ``, + }, + { + description: "replace size medium with default", + before: ``, + after: ``, + }, + { + description: "remove isInvalid attribute", + before: ``, + after: ``, + }, + { + description: "replace update:value event", + before: ``, + after: ``, + }, + { + description: "remove base-field-mounted event", + before: ``, + after: ``, + }, + { + description: "process label slot", + before: ` + +`, + after: ``, + }, + { + description: "process hint slot", + before: ` + +`, + after: ``, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(PasswordFieldFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_progress_bar.go b/internal/verifier/admintwiglinter/fix_progress_bar.go new file mode 100644 index 00000000..591fb232 --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_progress_bar.go @@ -0,0 +1,65 @@ +package admintwiglinter + +import ( + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type ProgressBarFixer struct{} + +func init() { + AddFixer(ProgressBarFixer{}) +} + +func (p ProgressBarFixer) Check(nodes []html.Node) []CheckError { + var errors []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-progress-bar" { + errors = append(errors, CheckError{ + Message: "sw-progress-bar is removed, use mt-progress-bar instead.", + Severity: "error", + Identifier: "sw-progress-bar", + Line: node.Line, + }) + } + }) + return errors +} + +func (p ProgressBarFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (p ProgressBarFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-progress-bar" { + node.Tag = "mt-progress-bar" + var newAttrs html.NodeList + + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case ValueAttr: + attr.Key = ModelValueAttr + newAttrs = append(newAttrs, attr) + case VModelValueAttr: + attr.Key = VModelAttr + newAttrs = append(newAttrs, attr) + case UpdateValueAttr: + attr.Key = UpdateModelValueAttr + newAttrs = append(newAttrs, attr) + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + } + }) + return nil +} diff --git a/internal/verifier/admintwiglinter/fix_progress_bar_test.go b/internal/verifier/admintwiglinter/fix_progress_bar_test.go new file mode 100644 index 00000000..018d08cc --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_progress_bar_test.go @@ -0,0 +1,37 @@ +package admintwiglinter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProgressBarFixer(t *testing.T) { + cases := []struct { + description string + before string + after string + }{ + { + description: "replace value with model-value", + before: ``, + after: ``, + }, + { + description: "replace v-model:value with v-model", + before: ``, + after: ``, + }, + { + description: "replace update:value event", + before: ``, + after: ``, + }, + } + + for _, c := range cases { + newStr, err := runFixerOnString(ProgressBarFixer{}, c.before) + assert.NoError(t, err, c.description) + assert.Equal(t, c.after, newStr, c.description) + } +} diff --git a/internal/verifier/admintwiglinter/fix_select_field.go b/internal/verifier/admintwiglinter/fix_select_field.go new file mode 100644 index 00000000..24bdb72e --- /dev/null +++ b/internal/verifier/admintwiglinter/fix_select_field.go @@ -0,0 +1,143 @@ +package admintwiglinter + +import ( + "encoding/json" + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/html" +) + +type SelectFieldFixer struct{} + +func init() { + AddFixer(SelectFieldFixer{}) +} + +func (s SelectFieldFixer) Check(nodes []html.Node) []CheckError { + var errs []CheckError + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-select-field" { + errs = append(errs, CheckError{ + Message: "sw-select-field is removed, use mt-select instead. Review conversion for props, slots and events.", + Severity: "error", + Identifier: "sw-select-field", + Line: node.Line, + }) + } + }) + return errs +} + +func (s SelectFieldFixer) Supports(v *version.Version) bool { + return shopware67Constraint.Check(v) +} + +func (s SelectFieldFixer) Fix(nodes []html.Node) error { + html.TraverseNode(nodes, func(node *html.ElementNode) { + if node.Tag == "sw-select-field" { + node.Tag = "mt-select" + + var newAttrs html.NodeList + // Flag to check if options prop is already set. + optionsSet := false + + for _, attrNode := range node.Attributes { + // Check if the attribute is an html.Attribute + if attr, ok := attrNode.(html.Attribute); ok { + switch attr.Key { + case ":value": + newAttrs = append(newAttrs, html.Attribute{Key: ":model-value", Value: attr.Value}) + case "v-model:value": + newAttrs = append(newAttrs, html.Attribute{Key: "v-model", Value: attr.Value}) + case ":aside": + // Remove aside prop. + case ":options": + // Convert options format: replace "name" with "label" and "id" with "value" + converted := strings.ReplaceAll(attr.Value, "name", "label") + converted = strings.ReplaceAll(converted, "id", "value") + newAttrs = append(newAttrs, html.Attribute{Key: ":options", Value: converted}) + optionsSet = true + case UpdateValueAttr: + newAttrs = append(newAttrs, html.Attribute{Key: UpdateModelValueAttr, Value: attr.Value}) + default: + newAttrs = append(newAttrs, attr) + } + } else { + // If it's not an html.Attribute (e.g., TwigIfNode), preserve it as is + newAttrs = append(newAttrs, attrNode) + } + } + node.Attributes = newAttrs + + // Process children for slot conversion. + var labelText string + var optionObjects []map[string]interface{} + + for _, child := range node.Children { + if elem, ok := child.(*html.ElementNode); ok { + // Convert label slot to label prop. + if elem.Tag == TemplateTag { + for _, a := range elem.Attributes { + if attr, ok := a.(html.Attribute); ok { + if attr.Key == LabelSlotAttr || attr.Key == "v-slot:label" { + var sb strings.Builder + for _, inner := range elem.Children { + sb.WriteString(strings.TrimSpace(inner.Dump(0))) + } + labelText = sb.String() + goto SkipChild + } + } + } + } + // Collect