diff --git a/bin/php-class-diagram b/bin/php-class-diagram index 63bc72d..c4ff86a 100755 --- a/bin/php-class-diagram +++ b/bin/php-class-diagram @@ -37,6 +37,10 @@ $options = getopt('hv', [ 'header::', 'include::', 'exclude::', + 'rel-target-from::', + 'rel-target-to::', + 'rel-target::', + 'rel-target-depth::', ], $rest_index); $arguments = array_slice($argv, $rest_index); @@ -65,6 +69,11 @@ OPTIONS --header='header string' additional header string. You can specify multiple header values. --include='wildcard' include target file pattern. (default: `*.php`) You can specify multiple include patterns. --exclude='wildcard' exclude target file pattern. You can specify multiple exclude patterns. + --rel-target-from='clases' comma separated list of classes to filter dependencies from + --rel-target-to='classes' comma separated list of classes to filter dependencies to + --rel-target='classes' comma separated list of classes to filter dependencies from or to. this option overrides + --rel-target-from and --rel-target-to if set. + --rel-target-depth=integer max depth of dependencies to show when using --from or --to filters EOS; diff --git a/src/Config/Options.php b/src/Config/Options.php index 3973162..1b8977a 100644 --- a/src/Config/Options.php +++ b/src/Config/Options.php @@ -166,4 +166,45 @@ public function hidePrivateMethods(): bool } return false; } + + /** + * @return array + */ + public function fromClass(): array + { + if (!isset($this->opt['rel-target-from'])) { + return []; + } + + return explode(',', $this->opt['rel-target-from']); + } + + /** + * @return array + */ + public function toClass(): array + { + if (!isset($this->opt['rel-target-to'])) { + return []; + } + + return explode(',', $this->opt['rel-target-to']); + } + + /** + * @return array + */ + public function targetClass(): array + { + if (!isset($this->opt['rel-target'])) { + return []; + } + + return explode(',', $this->opt['rel-target']); + } + + public function depth(): int + { + return (int) ($this->opt['rel-target-depth'] ?? PHP_INT_MAX); + } } diff --git a/src/DiagramElement/Relation.php b/src/DiagramElement/Relation.php index 10cb018..eb56283 100644 --- a/src/DiagramElement/Relation.php +++ b/src/DiagramElement/Relation.php @@ -11,12 +11,15 @@ final class Relation private Options $options; private Package $package; + private RelationsFilter $relationsFilter; + /** * @param Entry[] $entries */ public function __construct(array $entries, Options $options) { $this->options = $options; + $this->relationsFilter = new RelationsFilter($options); $this->package = new Package([], 'ROOT', $options); foreach ($entries as $e) { /** @var list $paths */ @@ -66,7 +69,11 @@ public function getRelations(): array }, $this->package->getArrows()); $relation_expressions = array_filter($relation_expressions); + + $relation_expressions = $this->relationsFilter->filterRelations($relation_expressions); + sort($relation_expressions); + $relation_expressions = $this->relationsFilter->addRemoveUnlinkedDirective($relation_expressions); return array_values(array_unique($relation_expressions)); } diff --git a/src/DiagramElement/RelationsFilter.php b/src/DiagramElement/RelationsFilter.php new file mode 100644 index 0000000..b069fb1 --- /dev/null +++ b/src/DiagramElement/RelationsFilter.php @@ -0,0 +1,118 @@ + $relation_expressions + * @return list + */ + public function filterRelations(array $relation_expressions): array + { + $output = []; + $fromClasses = $this->options->fromClass(); + $toClasses = $this->options->toClass(); + + if ([] !== $this->options->targetClass()) { + $fromClasses = $this->options->targetClass(); + $toClasses = $this->options->targetClass(); + } + + $this->maxDepth = $this->options->depth() - 1; + $this->relationExpressions = $relation_expressions; + + if ([] === $fromClasses && [] === $toClasses) { + return $relation_expressions; + } + + if ([] !== $fromClasses) { + $output = array_merge($output, $this->filterClasses($fromClasses, 'out')); + $this->removeUnlinked = true; + } + + if ([] !== $toClasses) { + $output = array_merge($output, $this->filterClasses($toClasses, 'in')); + $this->removeUnlinked = true; + } + + return $output; + } + + /** + * @param list $relation_expressions + * @return list + */ + public function addRemoveUnlinkedDirective(array $relation_expressions): array + { + if ($this->removeUnlinked) { + $relation_expressions[] = ' remove @unlinked'; + } + return $relation_expressions; + } + + /** + * @param array $filteredClasses + * @return array + */ + public function filterClasses(array $filteredClasses, string $direction): array + { + $currentDepth = 0; + /** @var array $matches */ + $matches = []; + do { + $oldMatches = $matches; + foreach ($matches as $match) { + $parts = explode(' ', trim($match)); + $filteredClasses[] = $direction === 'out' ? + end($parts) : + array_shift($parts) + ; + } + $matches = array_filter($this->relationExpressions, function ($line) use ($filteredClasses, $direction) { + $line = str_replace(['"1" ', '"*" '], '', $line); + $line = trim($line); + foreach ($filteredClasses as $filteredClass) { + if (1 === preg_match($this->getFilteringRegex($filteredClass, $direction), $line)) { + return true; + } + } + return false; + }); + $matches = array_unique($matches); + $filteredClasses = array_unique($filteredClasses); + } while (++$currentDepth <= $this->maxDepth && count(array_diff($matches, $oldMatches)) > 0); + + return $matches; + } + + function getFilteringRegex(string $filteredClass, string $direction): string + { + $filteredClass = str_replace('*', '.*?', $filteredClass); + + if (!in_array($direction, ['out', 'in'])) { + throw new InvalidArgumentException("Invalid direction '$direction'"); + } + + return match ($direction) { + 'in' => "/.*?> ({$filteredClass}$|[\w]+_{$filteredClass}$)/", + 'out' => "/^({$filteredClass}|^[\w]+_{$filteredClass}) .*?>.*?/", + }; + } +} diff --git a/test/RelationsFilterTest.php b/test/RelationsFilterTest.php new file mode 100644 index 0000000..d58ad96 --- /dev/null +++ b/test/RelationsFilterTest.php @@ -0,0 +1,161 @@ +fixture = [ + ' Entry "1" ..> "*" Arrow', + ' Entry "1" ..> "*" Arrow', + ' Package "1" ..> "*" Package', + ' Package "1" ..> "*" Entry', + ' Package ..> Entry', + ' Package "1" ..> "*" Arrow', + ' Package "1" ..> "*" Entry', + ' Package ..> Package', + ' PackageRelations ..> Package', + ' PackageRelations ..> Package', + ' Relation ..> Package', + ' Relation ..> RelationsFilter', + ' Relation "1" ..> "*" Entry', + ' Relation ..> Package', + ' Arrow <|-- ArrowDependency', + ' Arrow <|-- ArrowInheritance', + ' ExternalPackage_PackageHierarchy ..> ExternalPackage_PackageNode', + ' ExternalPackage_PackageNode "1" ..> "*" ExternalPackage_PackageNode', + ' ExternalPackage_PackageNode "1" ..> "*" ExternalPackage_PackageNode', + ' ExternalPackage_PackageHierarchy ..> ExternalPackage_PackageNode', + ' ExternalPackage_PackageNode ..> ExternalPackage_PackageNode', + ' Entry ..> Division_DivisionColor', + ' Entry ..> ArrowDependency', + ' Entry ..> ArrowDependency', + ' Entry ..> ArrowDependency', + ' Entry ..> ArrowInheritance', + ' Entry ..> ArrowDependency', + ' Package ..> Entry', + ' Package ..> Package', + ' Package ..> Package', + ' Package ..> Package', + ' PackageRelations ..> Package', + ' PackageRelations ..> ExternalPackage_PackageHierarchy', + ' PackageRelations ..> PackageArrow', + ' PackageRelations ..> PackageArrow', + ' Relation ..> RelationsFilter', + ' Relation ..> Package', + ' Relation ..> Package', + ' Relation ..> Arrow', + ' Relation ..> PackageRelations', + ]; + } + + public function testFiltersInboundRelations(): void + { + $relationsFilter = new RelationsFilter(new Options([ + 'rel-target-to' => 'PackageNode' + ])); + + $result = $relationsFilter->filterRelations($this->fixture); + + $this->assertSame(" ExternalPackage_PackageHierarchy ..> ExternalPackage_PackageNode", $result[0]); + $this->assertSame(" ExternalPackage_PackageNode \"1\" ..> \"*\" ExternalPackage_PackageNode", $result[1]); + $this->assertSame(" ExternalPackage_PackageNode ..> ExternalPackage_PackageNode", $result[2]); + $this->assertSame(" PackageRelations ..> ExternalPackage_PackageHierarchy", $result[3]); + $this->assertSame(" Relation ..> PackageRelations", $result[4]); + } + + public function testFiltersTargetRelations(): void + { + $relationsFilter = new RelationsFilter(new Options([ + 'rel-target' => 'Entry' + ])); + + $result = $relationsFilter->filterRelations($this->fixture); + + $this->assertSame(" Entry \"1\" ..> \"*\" Arrow", $result[0]); + $this->assertSame(" Entry ..> Division_DivisionColor", $result[1]); + $this->assertSame(" Entry ..> ArrowDependency", $result[2]); + $this->assertSame(" Entry ..> ArrowInheritance", $result[3]); + $this->assertSame(" Package \"1\" ..> \"*\" Package", $result[4]); + $this->assertSame(" Package \"1\" ..> \"*\" Entry", $result[5]); + $this->assertSame(" Package ..> Entry", $result[6]); + $this->assertSame(" Package ..> Package", $result[7]); + $this->assertSame(" PackageRelations ..> Package", $result[8]); + $this->assertSame(" Relation ..> Package", $result[9]); + $this->assertSame(" Relation \"1\" ..> \"*\" Entry", $result[10]); + $this->assertSame(" Relation ..> PackageRelations", $result[11]); + } + + public function testFiltersInboundRelationsWithDepth(): void + { + $relationsFilter = new RelationsFilter(new Options([ + 'rel-target-to' => 'PackageNode', + 'rel-target-depth' => 1 + ])); + + $result = $relationsFilter->filterRelations($this->fixture); + + $this->assertSame(" ExternalPackage_PackageHierarchy ..> ExternalPackage_PackageNode", $result[0]); + $this->assertSame(" ExternalPackage_PackageNode \"1\" ..> \"*\" ExternalPackage_PackageNode", $result[1]); + $this->assertSame(" ExternalPackage_PackageNode ..> ExternalPackage_PackageNode", $result[2]); + $this->assertCount(3, $result); + } + + public function testFiltersOutboundRelations(): void + { + $relationsFilter = new RelationsFilter(new Options([ + 'rel-target-from' => 'Package' + ])); + + $result = $relationsFilter->filterRelations($this->fixture); + + $this->assertSame(" Entry \"1\" ..> \"*\" Arrow", $result[0]); + $this->assertSame(" Package \"1\" ..> \"*\" Package", $result[1]); + $this->assertSame(" Package \"1\" ..> \"*\" Entry", $result[2]); + $this->assertSame(" Package ..> Entry", $result[3]); + $this->assertSame(" Package \"1\" ..> \"*\" Arrow", $result[4]); + $this->assertSame(" Package ..> Package", $result[5]); + $this->assertSame(" Entry ..> Division_DivisionColor", $result[6]); + $this->assertSame(" Entry ..> ArrowDependency", $result[7]); + $this->assertSame(" Entry ..> ArrowInheritance", $result[8]); + } + + public function testFiltersOutboundRelationsWithDepth(): void + { + $relationsFilter = new RelationsFilter(new Options([ + 'rel-target-from' => 'Package', + 'rel-target-depth' => 1 + ])); + + $result = $relationsFilter->filterRelations($this->fixture); + + $this->assertSame(" Package \"1\" ..> \"*\" Package", $result[0]); + $this->assertSame(" Package \"1\" ..> \"*\" Entry", $result[1]); + $this->assertSame(" Package ..> Entry", $result[2]); + $this->assertSame(" Package \"1\" ..> \"*\" Arrow", $result[3]); + $this->assertSame(" Package ..> Package", $result[4]); + } + + public function testGeneratesRemoveUnlinkedDirective(): void + { + $relationsFilter = new RelationsFilter(new Options([ + 'rel-target-from' => 'Package' + ])); + + $relationsFilter->filterRelations($this->fixture); + $result = $relationsFilter->addRemoveUnlinkedDirective([]); + + $this->assertSame(" remove @unlinked", $result[0]); + } +}