diff --git a/composer.json b/composer.json index c2008b020..72bcda2f3 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "rector/rector": "^2.3.2", "shipmonk/composer-dependency-analyser": "^1.8", "symplify/easy-coding-standard": "^13.0", + "symplify/phpstan-extensions": "^12.0", "tomasvotruba/class-leak": "^2.1", "tracy/tracy": "^2.10" }, diff --git a/phpstan.neon b/phpstan.neon index 80b10f296..52a865ef1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,8 @@ parameters: level: 8 + errorFormat: symplify + paths: - bin - src diff --git a/src/Analyzer/DuplicatedScenarioTitlesAnalyzer.php b/src/Analyzer/DuplicatedScenarioTitlesAnalyzer.php index 7bfc74db7..378cfa516 100644 --- a/src/Analyzer/DuplicatedScenarioTitlesAnalyzer.php +++ b/src/Analyzer/DuplicatedScenarioTitlesAnalyzer.php @@ -33,6 +33,6 @@ public function analyze(array $featureFiles): array } } - return array_filter($scenarioNamesToFiles, fn(array $files): bool => count($files) > 1); + return array_filter($scenarioNamesToFiles, fn (array $files): bool => count($files) > 1); } } diff --git a/src/DefinitionPatternsExtractor.php b/src/DefinitionPatternsExtractor.php index 8fd384ff1..14ef119f0 100644 --- a/src/DefinitionPatternsExtractor.php +++ b/src/DefinitionPatternsExtractor.php @@ -36,6 +36,8 @@ public function __construct( */ public function extract(array $contextFiles): PatternCollection { + Assert::allIsInstanceOf($contextFiles, SplFileInfo::class); + foreach ($contextFiles as $contextFile) { Assert::endsWith($contextFile->getFilename(), '.php'); } diff --git a/src/Enum/RuleIdentifier.php b/src/Enum/RuleIdentifier.php index 3f89a4dd8..a20b78780 100644 --- a/src/Enum/RuleIdentifier.php +++ b/src/Enum/RuleIdentifier.php @@ -13,4 +13,6 @@ final class RuleIdentifier public const string DUPLICATED_PATTERNS = 'duplicated-patterns'; public const string UNUSED_DEFINITIONS = 'unused-definitions'; + + public const string MISSING_CONTEXT_DEFINITIONS = 'missing-context-definitions'; } diff --git a/src/Rule/MissingContextDefinitionsRule.php b/src/Rule/MissingContextDefinitionsRule.php new file mode 100644 index 000000000..1bce94b98 --- /dev/null +++ b/src/Rule/MissingContextDefinitionsRule.php @@ -0,0 +1,160 @@ +toArray()['profile']['suites'] ?? []; + + // most likely a bug, as at least one suite is expected + if ($suites === []) { + return []; + } + + $featureInstructionsWithoutDefinitions = []; + + foreach ($suites as $suiteName => $suiteConfiguration) { + // 1. find definitions in paths + $suiteFeatureFiles = BehatMetafilesFinder::findFeatureFiles($suiteConfiguration['paths']); + Assert::notEmpty($suiteFeatureFiles); + + $suiteContextFilePaths = $this->resolveClassesFilePaths($suiteConfiguration['contexts']); + Assert::notEmpty($suiteContextFilePaths); + + $suiteFeatureInstructions = $this->usedInstructionResolver->resolveInstructionsFromFeatureFiles( + $suiteFeatureFiles + ); + + // feature-used instructions + Assert::notEmpty($suiteFeatureInstructions); + + // definitions-provided instructions + $suitePatternCollection = $this->definitionPatternsExtractor->extract($suiteContextFilePaths); + + $featureInstructionsWithoutDefinitions = []; + foreach ($suiteFeatureInstructions as $featureInstruction) { + if ($this->isFeatureFoundInDefinitionPatterns($featureInstruction, $suitePatternCollection)) { + continue; + } + + $featureInstructionsWithoutDefinitions[$suiteName][] = $featureInstruction; + } + } + + $ruleErrors = []; + foreach ($featureInstructionsWithoutDefinitions as $suiteName => $featureInstructions) { + // WIP @todo + $ruleErrors[] = new RuleError( + sprintf( + 'Suite %s is missing Context definitions for %d feature instructions: %s', + $suiteName, + count($featureInstructions), + implode(PHP_EOL . ' * ', $featureInstructions) + ), + [], + $this->getIdentifier() + ); + } + + return $ruleErrors; + } + + public function getIdentifier(): string + { + return RuleIdentifier::MISSING_CONTEXT_DEFINITIONS; + } + + /** + * @param class-string[] $contextClasses + * @return SplFileInfo[] + */ + private function resolveClassesFilePaths(array $contextClasses): array + { + $contextFilePaths = []; + + foreach ($contextClasses as $contextClass) { + $reflectionClass = new \ReflectionClass($contextClass); + $fileName = $reflectionClass->getFileName(); + if ($fileName === false) { + continue; + } + + $contextFilePaths[] = new SplFileInfo($fileName, '', ''); + } + + return $contextFilePaths; + } + + private function isFeatureFoundInDefinitionPatterns( + string $featureInstruction, + PatternCollection $suitePatternCollection + ): bool { + // 1. is feature used in exact pattern? + if (in_array($featureInstruction, $suitePatternCollection->exactPatternStrings())) { + return true; + } + + // 2. is feature used in named pattern? + $namedPatterns = $suitePatternCollection->byType(NamedPattern::class); + foreach ($namedPatterns as $namedPattern) { + if (\Nette\Utils\Strings::match($featureInstruction, $namedPattern->getRegexPattern())) { + return true; + } + } + + // 3. is feature used in regex pattern? + foreach ($suitePatternCollection->regexPatternsStrings() as $regexPatternString) { + if (\Nette\Utils\Strings::match($featureInstruction, $regexPatternString)) { + return true; + } + } + + return false; + } +} diff --git a/src/ValueObject/Pattern/NamedPattern.php b/src/ValueObject/Pattern/NamedPattern.php index f65f79b73..08677c30b 100644 --- a/src/ValueObject/Pattern/NamedPattern.php +++ b/src/ValueObject/Pattern/NamedPattern.php @@ -4,6 +4,14 @@ namespace Rector\Behastan\ValueObject\Pattern; +use Entropy\Utils\Regex; + final class NamedPattern extends AbstractPattern { + private const string NAMED_MASK_REGEX = '#(\:[\W\w]+)#'; + + public function getRegexPattern(): string + { + return '#' . Regex::replace($this->pattern, self::NAMED_MASK_REGEX, '(.*?)') . '#'; + } } diff --git a/src/ValueObject/PatternCollection.php b/src/ValueObject/PatternCollection.php index c3affacb5..89e065698 100644 --- a/src/ValueObject/PatternCollection.php +++ b/src/ValueObject/PatternCollection.php @@ -4,7 +4,6 @@ namespace Rector\Behastan\ValueObject; -use InvalidArgumentException; use Rector\Behastan\ValueObject\Pattern\AbstractPattern; use Rector\Behastan\ValueObject\Pattern\ExactPattern; use Rector\Behastan\ValueObject\Pattern\RegexPattern; @@ -62,41 +61,17 @@ public function byType(string $type): array return array_filter($this->patterns, fn (AbstractPattern $pattern): bool => $pattern instanceof $type); } - public function regexPatternString(): string - { - $regexPatterns = $this->byType(RegexPattern::class); - - $regexPatternStrings = array_map( - fn (RegexPattern $regexPattern): string => $regexPattern->pattern, - $regexPatterns - ); - - return $this->combineRegexes($regexPatternStrings, '#'); - } - /** - * @param string[] $regexes Like ['/foo/i', '~bar\d+~', '#baz#u'] + * @return string[] */ - private function combineRegexes(array $regexes, string $delimiter = '#'): string + public function regexPatternsStrings(): array { - $parts = []; - - foreach ($regexes as $regex) { - // Very common case: regex is given like "/pattern/flags" - // Parse: delimiter + pattern + delimiter + flags - if (! preg_match('~^(.)(.*)\\1([a-zA-Z]*)$~s', $regex, $m)) { - throw new InvalidArgumentException('Invalid regex: ' . $regex); - } - - $pattern = $m[2]; - $flags = $m[3]; + $regexPatterns = $this->byType(RegexPattern::class); - // If you truly have mixed flags per-regex, you can't naively merge them. - // Best practice: normalize flags beforehand (same for all). - // We'll ignore per-regex flags here and let the caller decide final flags. - $parts[] = '(?:' . $pattern . ')'; - } + $regexPatternStrings = array_map(function (RegexPattern $regexPattern): string { + return $regexPattern->pattern; + }, $regexPatterns); - return $delimiter . '(?:' . implode('|', $parts) . ')' . $delimiter; + return array_values($regexPatternStrings); } } diff --git a/stubs/Behat/Behat/Config.php b/stubs/Behat/Behat/Config.php new file mode 100644 index 000000000..cc2390e04 --- /dev/null +++ b/stubs/Behat/Behat/Config.php @@ -0,0 +1,13 @@ +assertSame('#(?:(?:this is it)|(?:here is more))#', $patternCollection->regexPatternString()); + $this->assertSame(['#this is it#', '#here is more#'], $patternCollection->regexPatternsStrings()); + } + + public function testNamedPatterns(): void + { + $namedPattern = new NamedPattern('this is :me', 'file1.php', 10, 'SomeClass', 'someMethod'); + $this->assertSame('#this is (.*?)#', $namedPattern->getRegexPattern()); } }