Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
10 changes: 9 additions & 1 deletion bin/php-class-diagram
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -65,7 +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;

if (isset($options['v']) || isset($options['version'])) {
Expand Down
41 changes: 41 additions & 0 deletions src/Config/Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,45 @@ public function hidePrivateMethods(): bool
}
return false;
}

/**
* @return array<string>
*/
public function fromClass(): array
{
if (!isset($this->opt['rel-target-from'])) {
return [];
}

return explode(',', $this->opt['rel-target-from']);
}

/**
* @return array<string>
*/
public function toClass(): array
{
if (!isset($this->opt['rel-target-to'])) {
return [];
}

return explode(',', $this->opt['rel-target-to']);
}

/**
* @return array<string>
*/
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);
}
}
7 changes: 7 additions & 0 deletions src/DiagramElement/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $paths */
Expand Down Expand Up @@ -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));
}

Expand Down
118 changes: 118 additions & 0 deletions src/DiagramElement/RelationsFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

namespace Smeghead\PhpClassDiagram\DiagramElement;

use InvalidArgumentException;
use Smeghead\PhpClassDiagram\Config\Options;
use Smeghead\PhpClassDiagram\Enums\DependenciesDirection;
use function preg_match;

class RelationsFilter {

private int $maxDepth;
/**
* @var string[]
*/
private array $relationExpressions;
private bool $removeUnlinked = false;

public function __construct(private Options $options)
{
}

/**
* @param list<string> $relation_expressions
* @return list<string>
*/
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<string> $relation_expressions
* @return list<string>
*/
public function addRemoveUnlinkedDirective(array $relation_expressions): array
{
if ($this->removeUnlinked) {
$relation_expressions[] = ' remove @unlinked';
}
return $relation_expressions;
}

/**
* @param array<string> $filteredClasses
* @return array<string>
*/
public function filterClasses(array $filteredClasses, string $direction): array
{
$currentDepth = 0;
/** @var array<string> $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}) .*?>.*?/",
};
}
}
161 changes: 161 additions & 0 deletions test/RelationsFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

use Smeghead\PhpClassDiagram\Config\Options;
use Smeghead\PhpClassDiagram\DiagramElement\RelationsFilter;

final class RelationsFilterTest extends TestCase
{
/**
* @var array|string[]
*/
private array $fixture;

public function setUp(): void
{
$this->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]);
}
}
Loading