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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/extension/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ type ConfigValidation struct {
// PhpVersion overrides the PHP version used for linting (e.g. "8.4").
// When set, this takes precedence over the version derived from composer.json or the static Shopware-to-PHP mapping.
PhpVersion string `yaml:"php_version,omitempty"`
// PhpstanConfig points PHPStan at an custom config instead of the bundled one.
PhpstanConfig string `yaml:"phpstan_config,omitempty"`
}

type ConfigValidationList []validation.ToolConfigIgnore
Expand Down Expand Up @@ -344,6 +346,12 @@ func validateExtensionConfig(config *Config) error {
}
}

if config.Validation.PhpstanConfig != "" {
if err := validateRelativePath(config.Validation.PhpstanConfig); err != nil {
return fmt.Errorf("validation.phpstan_config: %w", err)
}
}

return nil
}

Expand Down
4 changes: 4 additions & 0 deletions internal/extension/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,10 @@
"php_version": {
"type": "string",
"description": "PhpVersion overrides the PHP version used for linting (e.g. \"8.4\").\nWhen set, this takes precedence over the version derived from composer.json or the static Shopware-to-PHP mapping."
},
"phpstan_config": {
"type": "string",
"description": "PhpstanConfig points PHPStan at an custom config instead of the bundled one."
}
},
"additionalProperties": false,
Expand Down
24 changes: 24 additions & 0 deletions internal/extension/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,30 @@ func TestValidateExtensionConfig(t *testing.T) {
assert.NoError(t, err)
})

t.Run("accepts a relative validation.phpstan_config", func(t *testing.T) {
config := &Config{Validation: ConfigValidation{PhpstanConfig: "phpstan-verifier.neon"}}

assert.NoError(t, validateExtensionConfig(config))
})

t.Run("fails when validation.phpstan_config is absolute", func(t *testing.T) {
config := &Config{Validation: ConfigValidation{PhpstanConfig: "/etc/phpstan.neon"}}
err := validateExtensionConfig(config)

assert.Error(t, err)
assert.Contains(t, err.Error(), "validation.phpstan_config")
assert.Contains(t, err.Error(), "must be relative")
})

t.Run("fails when validation.phpstan_config escapes the extension", func(t *testing.T) {
config := &Config{Validation: ConfigValidation{PhpstanConfig: "../phpstan.neon"}}
err := validateExtensionConfig(config)

assert.Error(t, err)
assert.Contains(t, err.Error(), "validation.phpstan_config")
assert.Contains(t, err.Error(), "must not escape")
})

t.Run("fails when English tags exceed 5", func(t *testing.T) {
tags := []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"}
config := &Config{
Expand Down
29 changes: 17 additions & 12 deletions internal/verifier/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ import (
)

func ConvertExtensionToToolConfig(ext extension.Extension) (*ToolConfig, error) {
cfg := newToolConfig(ext)

constraint, err := ext.GetShopwareVersionConstraint()
if err != nil {
return nil, err
}

if err := determineVersionRange(cfg, constraint); err != nil {
return nil, err
}

return cfg, nil
}

func newToolConfig(ext extension.Extension) *ToolConfig {
var ignores []validation.ToolConfigIgnore

for _, ignore := range ext.GetExtensionConfig().Validation.Ignore {
Expand All @@ -23,26 +38,16 @@ func ConvertExtensionToToolConfig(ext extension.Extension) (*ToolConfig, error)
})
}

cfg := &ToolConfig{
return &ToolConfig{
ToolDirectory: GetToolDirectory(),
Extension: ext,
ValidationIgnores: ignores,
PhpstanConfig: ext.GetExtensionConfig().Validation.PhpstanConfig,
RootDir: ext.GetPath(),
SourceDirectories: ext.GetSourceDirs(),
AdminDirectories: getAdminFolders(ext),
Comment thread
larskemper marked this conversation as resolved.
StorefrontDirectories: getStorefrontFolders(ext),
}

constraint, err := ext.GetShopwareVersionConstraint()
if err != nil {
return nil, err
}

if err := determineVersionRange(cfg, constraint); err != nil {
return nil, err
}

return cfg, nil
}

// getShopwareVersions returns the available Shopware versions. It is a package
Expand Down
27 changes: 27 additions & 0 deletions internal/verifier/extension_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package verifier

import (
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

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

func TestConvertExtensionToToolConfigCopiesPhpstanConfig(t *testing.T) {
pluginDir := filepath.Join(t.TempDir(), "SwagExample")
testhelper.WriteFile(t, filepath.Join(pluginDir, "composer.json"), testhelper.PluginComposer("test/swag-example", "1.0.0", `SwagExample\SwagExample`).String())
testhelper.WriteFile(t, filepath.Join(pluginDir, ".shopware-extension.yml"), "validation:\n phpstan_config: phpstan-verifier.neon\n")
testhelper.WriteFile(t, filepath.Join(pluginDir, "phpstan-verifier.neon"), "parameters:\n")

ext, err := extension.GetExtensionByFolder(t.Context(), pluginDir)
require.NoError(t, err)

cfg := newToolConfig(ext)

assert.Equal(t, "phpstan-verifier.neon", cfg.PhpstanConfig)
assert.Equal(t, ext.GetPath(), cfg.RootDir)
}
39 changes: 34 additions & 5 deletions internal/verifier/phpstan.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"context"
_ "embed"
"encoding/json"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"strings"

Expand Down Expand Up @@ -62,16 +64,18 @@ func (p PhpStan) Check(ctx context.Context, check *Check, config ToolConfig) err
return nil
}

configArguments, err := p.configArguments(config)

if err != nil {
return err
}

if err := installComposerDeps(ctx, config.RootDir, config.CheckAgainst); err != nil {
return err
}

for _, sourceDirectory := range config.SourceDirectories {
phpstanArguments := []string{"-dmemory_limit=2G", path.Join(config.ToolDirectory, "php", "vendor", "bin", "phpstan"), "analyse", "--no-progress", "--no-interaction", "--error-format=json", sourceDirectory}

if !p.configExists(config.RootDir) {
phpstanArguments = append(phpstanArguments, "--configuration", path.Join(config.ToolDirectory, "php", "configs", "phpstan.neon"))
}
phpstanArguments := append([]string{"-dmemory_limit=2G", path.Join(config.ToolDirectory, "php", "vendor", "bin", "phpstan"), "analyse", "--no-progress", "--no-interaction", "--error-format=json", sourceDirectory}, configArguments...)

if logging.IsVerbose(ctx) {
phpstanArguments = append(phpstanArguments, "-v")
Expand Down Expand Up @@ -144,6 +148,31 @@ func (p PhpStan) Check(ctx context.Context, check *Check, config ToolConfig) err
return nil
}

// configArguments returns the "--configuration" pair PHPStan should run with, or nothing when the
// extension ships a config PHPStan discovers by itself.
func (p PhpStan) configArguments(config ToolConfig) ([]string, error) {
if config.PhpstanConfig != "" {
resolved := filepath.Join(config.RootDir, config.PhpstanConfig)

info, err := os.Stat(resolved)
if err != nil {
return nil, fmt.Errorf("validation.phpstan_config %q cannot be read: %w", config.PhpstanConfig, err)
}

if info.IsDir() {
return nil, fmt.Errorf("validation.phpstan_config %q is a directory, expected a config file", config.PhpstanConfig)
}

return []string{"--configuration", resolved}, nil
}

if p.configExists(config.RootDir) {
return nil, nil
}

return []string{"--configuration", path.Join(config.ToolDirectory, "php", "configs", "phpstan.neon")}, nil
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
func isPhpStanNoFilesOutput(output string) bool {
return strings.Contains(output, "No files found to analyse")
}
Expand Down
94 changes: 94 additions & 0 deletions internal/verifier/phpstan_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package verifier

import (
"os"
"path"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestPhpStan_isUselessDeprecation(t *testing.T) {
Expand Down Expand Up @@ -102,3 +106,93 @@ func TestIsPhpStanNoFilesOutput(t *testing.T) {
})
}
}

func TestPhpStan_configArguments(t *testing.T) {
toolDir := t.TempDir()
bundledConfig := path.Join(toolDir, "php", "configs", "phpstan.neon")

tests := []struct {
name string
phpstanConfig string
rootFiles []string
rootDirs []string
wantConfig string
wantErr string
}{
{
name: "extension supplied config is used",
phpstanConfig: "phpstan-verifier.neon",
rootFiles: []string{"phpstan-verifier.neon"},
wantConfig: "phpstan-verifier.neon",
},
{
name: "extension supplied config wins over a discovered one",
phpstanConfig: "phpstan-verifier.neon",
rootFiles: []string{"phpstan-verifier.neon", "phpstan.neon.dist"},
wantConfig: "phpstan-verifier.neon",
},
{
name: "discovered config is left to phpstan",
rootFiles: []string{"phpstan.neon.dist"},
},
{
name: "bundled config when the extension has none",
wantConfig: bundledConfig,
},
{
name: "unreadable file is reported",
phpstanConfig: "phpstan-verifier.neon",
wantErr: "cannot be read",
},
{
name: "directory is reported",
phpstanConfig: "configs",
rootDirs: []string{"configs"},
wantErr: "is a directory",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rootDir := t.TempDir()

for _, file := range tt.rootFiles {
require.NoError(t, os.WriteFile(path.Join(rootDir, file), []byte("parameters:\n"), 0o600))
}

for _, dir := range tt.rootDirs {
require.NoError(t, os.Mkdir(path.Join(rootDir, dir), 0o750))
}

arguments, err := PhpStan{}.configArguments(ToolConfig{
ToolDirectory: toolDir,
RootDir: rootDir,
PhpstanConfig: tt.phpstanConfig,
})

if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), "validation.phpstan_config")
assert.Contains(t, err.Error(), tt.wantErr)

return
}

require.NoError(t, err)

if tt.wantConfig == "" {
assert.Empty(t, arguments)

return
}

wantConfig := tt.wantConfig

if !filepath.IsAbs(wantConfig) {
wantConfig = filepath.Join(rootDir, wantConfig)
}

assert.Equal(t, []string{"--configuration", wantConfig}, arguments)
})
}
}
2 changes: 2 additions & 0 deletions internal/verifier/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ type ToolConfig struct {
SourceDirectories []string
// Contains a list of identifiers that are ignored
ValidationIgnores []validation.ToolConfigIgnore
// Path to an extension-supplied PHPStan config, relative to RootDir. Empty means the bundled config is used.
PhpstanConfig string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: not sure if this struct should contain a PHPStan specific field, as it's part of the Tool interface below, means basically all tools (like PHPStan, Rector, ESLint, ...) receive the same ToolConfig struct as input.

One other idea with a much smaller changeset could also be adjusting:

var possiblePHPStanConfigs = []string{
"phpstan.neon",
"phpstan.neon.dist",
"phpstan.dist.neon",
}

to auto discover a "custom / SW CLI specific" phpstan config first (e.g. your phpstan-verifier.neon) before looking for the default phpstan config, but that would also feel like a workaround 😬 .

I think all the other tools currently all rely on either auto discovery of their specific config or use a bundled one, so this use case would be new and we might want to consider allowing overriding the used config files for all tools then, having it as a proper feature (I guess we have to discuss that team internally if we want to support that) 🤔

// Contains a list of directories that are considered as admin code
AdminDirectories []string
// Contains a list of directories that are considered as storefront code
Expand Down
Loading