Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ composer require behastan/behastan --dev
Some definitions have very similar masks, but even identical contents. Better use a one definitions with exact mask, to make your tests more precise and easier to maintain:

```bash
vendor/bin/behastan duplicated-definitions tests
vendor/bin/behastan analyze
```


Expand Down
2 changes: 0 additions & 2 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,3 @@ parameters:
excludePaths:
- */Fixture/*
- */Source/*

ignoreErrors:
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,54 @@
use PhpParser\PrettyPrinter\Standard;
use Rector\Behastan\PhpParser\SimplePhpParser;
use Rector\Behastan\Resolver\ClassMethodMasksResolver;
use Rector\Behastan\ValueObject\ClassMethodContextDefinition;
use Rector\Behastan\ValueObject\ContextDefinition;
use Symfony\Component\Finder\SplFileInfo;

final readonly class ClassMethodContextDefinitionsAnalyzer
final class ContextDefinitionsAnalyzer
{
/**
* @var array<string, array<string, ContextDefinition[]>>
*/
private array $contextDefinitionsByContentHash = [];

public function __construct(
private SimplePhpParser $simplePhpParser,
private NodeFinder $nodeFinder,
private Standard $printerStandard,
private ClassMethodMasksResolver $classMethodMasksResolver,
private readonly SimplePhpParser $simplePhpParser,
private readonly NodeFinder $nodeFinder,
private readonly Standard $printerStandard,
private readonly ClassMethodMasksResolver $classMethodMasksResolver,
) {
}

/**
* @param SplFileInfo[] $contextFileInfos
* @return ClassMethodContextDefinition[]
* @return ContextDefinition[]
*/
public function resolve(array $contextFileInfos): array
{
$classMethodContextDefinitionByClassMethodHash = $this->resolveAndGroupByContentHash($contextFileInfos);

$classMethodContextDefinitions = [];
foreach ($classMethodContextDefinitionByClassMethodHash as $classMethodContextDefinition) {
$classMethodContextDefinitions = array_merge(
$classMethodContextDefinitions,
$classMethodContextDefinition
);
$contextDefinitionByClassMethodHash = $this->resolveAndGroupByContentHash($contextFileInfos);

$allContextDefinitions = [];
foreach ($contextDefinitionByClassMethodHash as $contextDefinition) {
$allContextDefinitions = array_merge($allContextDefinitions, $contextDefinition);
}

return $classMethodContextDefinitions;
return $allContextDefinitions;
}

/**
* @param SplFileInfo[] $contextFileInfos
* @return array<string, ClassMethodContextDefinition[]>
* @return array<string, ContextDefinition[]>
*/
public function resolveAndGroupByContentHash(array $contextFileInfos): array
{
$classMethodContextDefinitionByClassMethodHash = [];
// re-use cached result if already done
$cacheKey = sha1((string) json_encode($contextFileInfos));

if (isset($this->contextDefinitionsByContentHash[$cacheKey])) {
return $this->contextDefinitionsByContentHash[$cacheKey];
}

$contextDefinitionByContentsHash = [];

foreach ($contextFileInfos as $contextFileInfo) {
$contextClassStmts = $this->simplePhpParser->parseFilePath($contextFileInfo->getRealPath());
Expand All @@ -58,6 +67,7 @@ public function resolveAndGroupByContentHash(array $contextFileInfos): array
if (! $class instanceof Class_) {
continue;
}

if (! $class->namespacedName instanceof Name) {
continue;
}
Expand All @@ -68,19 +78,20 @@ public function resolveAndGroupByContentHash(array $contextFileInfos): array
if (! $classMethod->isPublic()) {
continue;
}

if ($classMethod->isMagic()) {
continue;
}
$classMethodHash = $this->createClassMethodHash($classMethod);

$classMethodHash = $this->createClassMethodHash($classMethod);
$rawMasks = $this->classMethodMasksResolver->resolve($classMethod);

// no masks :(
if ($rawMasks === []) {
continue;
}

$classMethodContextDefinition = new ClassMethodContextDefinition(
$contextDefinition = new ContextDefinition(
$contextFileInfo->getRealPath(),
$className,
$classMethod->name->toString(),
Expand All @@ -89,11 +100,13 @@ public function resolveAndGroupByContentHash(array $contextFileInfos): array
$classMethod->getStartLine()
);

$classMethodContextDefinitionByClassMethodHash[$classMethodHash][] = $classMethodContextDefinition;
$contextDefinitionByContentsHash[$classMethodHash][] = $contextDefinition;
}
}

return $classMethodContextDefinitionByClassMethodHash;
$this->contextDefinitionsByContentHash[$cacheKey] = $contextDefinitionByContentsHash;

return $contextDefinitionByContentsHash;
}

private function createClassMethodHash(ClassMethod $classMethod): string
Expand Down
12 changes: 6 additions & 6 deletions src/Analyzer/UnusedDefinitionsAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
namespace Rector\Behastan\Analyzer;

use Nette\Utils\Strings;
use Rector\Behastan\DefinitionMasksResolver;
use Rector\Behastan\DefinitionMasksExtractor;
use Rector\Behastan\Reporting\MaskCollectionStatsPrinter;
use Rector\Behastan\UsedInstructionResolver;
use Rector\Behastan\ValueObject\Mask\AbstractMask;
use Rector\Behastan\ValueObject\Mask\ExactMask;
use Rector\Behastan\ValueObject\Mask\NamedMask;
use Rector\Behastan\ValueObject\Mask\RegexMask;
use Rector\Behastan\ValueObject\Mask\SkippedMask;
use Rector\Behastan\ValueObject\MaskCollection;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Finder\SplFileInfo;
use Webmozart\Assert\Assert;
Expand All @@ -29,8 +30,8 @@

public function __construct(
private SymfonyStyle $symfonyStyle,
private DefinitionMasksResolver $definitionMasksResolver,
private UsedInstructionResolver $usedInstructionResolver,
private DefinitionMasksExtractor $definitionMasksExtractor,
private MaskCollectionStatsPrinter $maskCollectionStatsPrinter,
) {
}
Expand All @@ -41,7 +42,7 @@ public function __construct(
*
* @return AbstractMask[]
*/
public function analyse(array $contextFiles, array $featureFiles): array
public function analyse(array $contextFiles, array $featureFiles, MaskCollection $maskCollection): array
{
Assert::allIsInstanceOf($contextFiles, SplFileInfo::class);
foreach ($contextFiles as $contextFile) {
Expand All @@ -53,9 +54,8 @@ public function analyse(array $contextFiles, array $featureFiles): array
Assert::endsWith($featureFile->getFilename(), '.feature');
}

$maskCollection = $this->definitionMasksResolver->resolve($contextFiles);

$this->maskCollectionStatsPrinter->printStats($maskCollection);
$maskCollection = $this->definitionMasksExtractor->extract($contextFiles);
$this->maskCollectionStatsPrinter->print($maskCollection);

$featureInstructions = $this->usedInstructionResolver->resolveInstructionsFromFeatureFiles($featureFiles);
$maskProgressBar = $this->symfonyStyle->createProgressBar($maskCollection->count());
Expand Down
138 changes: 138 additions & 0 deletions src/Command/AnalyzeCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

declare(strict_types=1);

namespace Rector\Behastan\Command;

use Rector\Behastan\Contract\RuleInterface;
use Rector\Behastan\DefinitionMasksExtractor;
use Rector\Behastan\Enum\Option;
use Rector\Behastan\Finder\BehatMetafilesFinder;
use Rector\Behastan\Reporting\MaskCollectionStatsPrinter;
use Rector\Behastan\ValueObject\RuleError;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Webmozart\Assert\Assert;

final class AnalyzeCommand extends Command
{
/**
* @param RuleInterface[] $rules
*/
public function __construct(
private readonly SymfonyStyle $symfonyStyle,
private readonly DefinitionMasksExtractor $definitionMasksExtractor,
private readonly MaskCollectionStatsPrinter $maskCollectionStatsPrinter,
private readonly array $rules
) {
parent::__construct();

Assert::allObject($rules);
Assert::allIsInstanceOf($rules, RuleInterface::class);
Assert::notEmpty($rules);
Assert::greaterThan(count($rules), 2);
}

protected function configure(): void
{
$this->setName('analyze');
$this->setDescription('Run complete static analysis on Behat definitions and features');

$this->addArgument(
Option::PROJECT_DIRECTORY,
InputArgument::OPTIONAL,
'Project directory (we find *.Context.php definition files and *.feature script files there)',
getcwd()
);

$this->addOption(
'skip',
null,
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Skip a rule by identifier'
);
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$testDirectory = $input->getArgument(Option::PROJECT_DIRECTORY);
Assert::directory($testDirectory);

$contextFileInfos = BehatMetafilesFinder::findContextFiles([$testDirectory]);
if ($contextFileInfos === []) {
$this->symfonyStyle->error(sprintf(
'No *.Context files found in "%s". Please provide correct test directory',
$testDirectory
));
return self::FAILURE;
}

$featureFileInfos = BehatMetafilesFinder::findFeatureFiles([$testDirectory]);
if ($featureFileInfos === []) {
$this->symfonyStyle->error(sprintf(
'No *.feature files found in "%s". Please provide correct test directory',
$testDirectory
));
return self::FAILURE;
}

$this->symfonyStyle->writeln(sprintf(
'<fg=green>Found %d Context and %d feature files</>',
count($contextFileInfos),
count($featureFileInfos)
));
$this->symfonyStyle->writeln('<fg=yellow>Extracting definitions masks...</>');

$maskCollection = $this->definitionMasksExtractor->extract($contextFileInfos);
$this->symfonyStyle->newLine();

$this->maskCollectionStatsPrinter->print($maskCollection);

$this->symfonyStyle->newLine();

// @todo skip by "--skip" option

$this->symfonyStyle->writeln('<fg=yellow>Running analysis...</>');

/** @var RuleError[] $allRuleErrors */
$allRuleErrors = [];
foreach ($this->rules as $rule) {
$ruleErrors = $rule->process($contextFileInfos, $featureFileInfos, $maskCollection, $testDirectory);
$allRuleErrors = array_merge($allRuleErrors, $ruleErrors);
}

if ($allRuleErrors === []) {
$this->symfonyStyle->success('No errors found. Good job!');

return self::SUCCESS;
}

$this->symfonyStyle->newLine(2);

$i = 1;
foreach ($allRuleErrors as $allRuleError) {
$this->symfonyStyle->writeln(sprintf('<fg=yellow>%d) %s</>', $i, $allRuleError->getMessage()));
foreach ($allRuleError->getLineFilePaths() as $lineFilePath) {
// compared to listing() this allow to make paths clickable in IDE
$this->symfonyStyle->writeln($lineFilePath);
}

$this->symfonyStyle->newLine(2);

++$i;
}

$this->symfonyStyle->newLine();
$this->symfonyStyle->error(sprintf(
'Found %d error%s',
count($allRuleErrors),
count($allRuleErrors) > 1 ? 's' : ''
));

return self::FAILURE;
}
}
Loading