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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
parameters:
level: 8

errorFormat: symplify

paths:
- bin
- src
Expand Down
2 changes: 1 addition & 1 deletion src/Analyzer/DuplicatedScenarioTitlesAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
2 changes: 2 additions & 0 deletions src/DefinitionPatternsExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
2 changes: 2 additions & 0 deletions src/Enum/RuleIdentifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
160 changes: 160 additions & 0 deletions src/Rule/MissingContextDefinitionsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php

declare(strict_types=1);

namespace Rector\Behastan\Rule;

use Behat\Config\Config;
use Rector\Behastan\Contract\RuleInterface;
use Rector\Behastan\DefinitionPatternsExtractor;
use Rector\Behastan\Enum\RuleIdentifier;
use Rector\Behastan\Finder\BehatMetafilesFinder;
use Rector\Behastan\UsedInstructionResolver;
use Rector\Behastan\ValueObject\Pattern\NamedPattern;
use Rector\Behastan\ValueObject\PatternCollection;
use Rector\Behastan\ValueObject\RuleError;
use Symfony\Component\Finder\SplFileInfo;
use Webmozart\Assert\Assert;

// WIP
/**
* @todo extract service and test heavily
*/
final readonly class MissingContextDefinitionsRule implements RuleInterface
{
public function __construct(
private UsedInstructionResolver $usedInstructionResolver,
private DefinitionPatternsExtractor $definitionPatternsExtractor,
) {
}

/**
* @param SplFileInfo[] $contextFiles
* @param SplFileInfo[] $featureFiles
*
* @return RuleError[]
*/
public function process(
array $contextFiles,
array $featureFiles,
PatternCollection $patternCollection,
string $projectDirectory
): array {
$behatConfigFile = getcwd() . '/behat.php';

// nothing to analyse
if (! file_exists($behatConfigFile)) {
return [];
}

/** @var Config $behatConfiguration */
$behatConfiguration = require_once $behatConfigFile;

$suites = $behatConfiguration->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;
}
}
8 changes: 8 additions & 0 deletions src/ValueObject/Pattern/NamedPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '(.*?)') . '#';
}
}
39 changes: 7 additions & 32 deletions src/ValueObject/PatternCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
13 changes: 13 additions & 0 deletions stubs/Behat/Behat/Config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Behat\Config;

final class Config
{
public function toArray(): array
{
return [];
}
}
8 changes: 7 additions & 1 deletion tests/ValueObject/PatternCollectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ public function testRegexPatterns(): void
new RegexPattern('#here is more#', 'file1.php', 10, 'SomeClass', 'someMethod'),
]);

$this->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());
}
}