Skip to content

Commit be18fab

Browse files
committed
feat: Filter relations from and to class
1 parent cb5877c commit be18fab

File tree

5 files changed

+293
-1
lines changed

5 files changed

+293
-1
lines changed

bin/php-class-diagram

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ $options = getopt('hv', [
3737
'header::',
3838
'include::',
3939
'exclude::',
40+
'from::',
41+
'to::',
42+
'depth::',
4043
], $rest_index);
4144
$arguments = array_slice($argv, $rest_index);
4245

@@ -65,7 +68,9 @@ OPTIONS
6568
--header='header string' additional header string. You can specify multiple header values.
6669
--include='wildcard' include target file pattern. (default: `*.php`) You can specify multiple include patterns.
6770
--exclude='wildcard' exclude target file pattern. You can specify multiple exclude patterns.
68-
71+
--from='class1,class2,...' comma separated list of classes to filter dependencies from
72+
--to='class1,class2,...' comma separated list of classes to filter dependencies to
73+
--depth=integer max depth of dependencies to show when using --from or --to filters
6974
EOS;
7075

7176
if (isset($options['v']) || isset($options['version'])) {

src/Config/Options.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,33 @@ public function hidePrivateMethods(): bool
166166
}
167167
return false;
168168
}
169+
170+
/**
171+
* @return array<string>
172+
*/
173+
public function fromClass(): array
174+
{
175+
if (!isset($this->opt['from'])) {
176+
return [];
177+
}
178+
179+
return explode(',', $this->opt['from']);
180+
}
181+
182+
/**
183+
* @return array<string>
184+
*/
185+
public function toClass(): array
186+
{
187+
if (!isset($this->opt['to'])) {
188+
return [];
189+
}
190+
191+
return explode(',', $this->opt['to']);
192+
}
193+
194+
public function depth(): int
195+
{
196+
return (int) ($this->opt['depth'] ?? PHP_INT_MAX);
197+
}
169198
}

src/DiagramElement/Relation.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ final class Relation
1111
private Options $options;
1212
private Package $package;
1313

14+
private RelationsFilter $relationsFilter;
15+
1416
/**
1517
* @param Entry[] $entries
1618
*/
1719
public function __construct(array $entries, Options $options)
1820
{
1921
$this->options = $options;
22+
$this->relationsFilter = new RelationsFilter($options);
2023
$this->package = new Package([], 'ROOT', $options);
2124
foreach ($entries as $e) {
2225
/** @var list<string> $paths */
@@ -66,7 +69,11 @@ public function getRelations(): array
6669
}, $this->package->getArrows());
6770

6871
$relation_expressions = array_filter($relation_expressions);
72+
73+
$relation_expressions = $this->relationsFilter->filterRelations($relation_expressions);
74+
6975
sort($relation_expressions);
76+
$relation_expressions = $this->relationsFilter->addRemoveUnlinkedDirective($relation_expressions);
7077
return array_values(array_unique($relation_expressions));
7178
}
7279

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
namespace Smeghead\PhpClassDiagram\DiagramElement;
4+
5+
use InvalidArgumentException;
6+
use Smeghead\PhpClassDiagram\Config\Options;
7+
use Smeghead\PhpClassDiagram\Enums\DependenciesDirection;
8+
use function preg_match;
9+
10+
class RelationsFilter {
11+
12+
private int $maxDepth;
13+
/**
14+
* @var string[]
15+
*/
16+
private array $relationExpressions;
17+
private bool $removeUnlinked = false;
18+
19+
public function __construct(private Options $options)
20+
{
21+
}
22+
23+
/**
24+
* @param list<string> $relation_expressions
25+
* @return list<string>
26+
*/
27+
public function filterRelations(array $relation_expressions): array
28+
{
29+
$output = [];
30+
$fromClasses = $this->options->fromClass();
31+
$toClasses = $this->options->toClass();
32+
$this->maxDepth = $this->options->depth() - 1;
33+
$this->relationExpressions = $relation_expressions;
34+
35+
if ([] === $fromClasses && [] === $toClasses) {
36+
return $relation_expressions;
37+
}
38+
39+
if ([] !== $fromClasses) {
40+
$output = array_merge($output, $this->filterClasses($fromClasses, 'out'));
41+
$this->removeUnlinked = true;
42+
}
43+
44+
if ([] !== $toClasses) {
45+
$output = array_merge($output, $this->filterClasses($toClasses, 'in'));
46+
$this->removeUnlinked = true;
47+
}
48+
49+
return $output;
50+
}
51+
52+
/**
53+
* @param list<string> $relation_expressions
54+
* @return list<string>
55+
*/
56+
public function addRemoveUnlinkedDirective(array $relation_expressions): array
57+
{
58+
if ($this->removeUnlinked) {
59+
$relation_expressions[] = ' remove @unlinked';
60+
}
61+
return $relation_expressions;
62+
}
63+
64+
/**
65+
* @param array<string> $filteredClasses
66+
* @return array<string>
67+
*/
68+
public function filterClasses(array $filteredClasses, string $direction): array
69+
{
70+
$currentDepth = 0;
71+
/** @var array<string> $matches */
72+
$matches = [];
73+
do {
74+
$oldMatches = $matches;
75+
foreach ($matches as $match) {
76+
$parts = explode(' ', trim($match));
77+
$filteredClasses[] = $direction === 'out' ?
78+
end($parts) :
79+
array_shift($parts)
80+
;
81+
}
82+
$matches = array_filter($this->relationExpressions, function ($line) use ($filteredClasses, $direction) {
83+
$line = str_replace(['"1" ', '"*" '], '', $line);
84+
$line = trim($line);
85+
foreach ($filteredClasses as $filteredClass) {
86+
if (1 === preg_match($this->getFilteringRegex($filteredClass, $direction), $line)) {
87+
return true;
88+
}
89+
}
90+
return false;
91+
});
92+
$matches = array_unique($matches);
93+
$filteredClasses = array_unique($filteredClasses);
94+
} while (++$currentDepth <= $this->maxDepth && count(array_diff($matches, $oldMatches)) > 0);
95+
96+
return $matches;
97+
}
98+
99+
function getFilteringRegex(string $filteredClass, string $direction): string
100+
{
101+
$filteredClass = str_replace('*', '.*?', $filteredClass);
102+
103+
if (!in_array($direction, ['out', 'in'])) {
104+
throw new InvalidArgumentException("Invalid direction '$direction'");
105+
}
106+
107+
return match ($direction) {
108+
'in' => "/.*?> ({$filteredClass}$|[\w_]+{$filteredClass}$)/",
109+
'out' => "/^({$filteredClass}|^[\w_]+{$filteredClass}) .*?>.*?/",
110+
};
111+
}
112+
}

test/RelationsFilterTest.php

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PHPUnit\Framework\TestCase;
6+
7+
use Smeghead\PhpClassDiagram\Config\Options;
8+
use Smeghead\PhpClassDiagram\DiagramElement\RelationsFilter;
9+
10+
final class RelationsFilterTest extends TestCase
11+
{
12+
/**
13+
* @var array|string[]
14+
*/
15+
private array $fixture;
16+
17+
public function setUp(): void
18+
{
19+
$this->fixture = [
20+
' Entry "1" ..> "*" Arrow',
21+
' Entry "1" ..> "*" Arrow',
22+
' Package "1" ..> "*" Package',
23+
' Package "1" ..> "*" Entry',
24+
' Package ..> Entry',
25+
' Package "1" ..> "*" Arrow',
26+
' Package "1" ..> "*" Entry',
27+
' Package ..> Package',
28+
' PackageRelations ..> Package',
29+
' PackageRelations ..> Package',
30+
' Relation ..> Package',
31+
' Relation ..> RelationsFilter',
32+
' Relation "1" ..> "*" Entry',
33+
' Relation ..> Package',
34+
' Arrow <|-- ArrowDependency',
35+
' Arrow <|-- ArrowInheritance',
36+
' ExternalPackage_PackageHierarchy ..> ExternalPackage_PackageNode',
37+
' ExternalPackage_PackageNode "1" ..> "*" ExternalPackage_PackageNode',
38+
' ExternalPackage_PackageNode "1" ..> "*" ExternalPackage_PackageNode',
39+
' ExternalPackage_PackageHierarchy ..> ExternalPackage_PackageNode',
40+
' ExternalPackage_PackageNode ..> ExternalPackage_PackageNode',
41+
' Entry ..> Division_DivisionColor',
42+
' Entry ..> ArrowDependency',
43+
' Entry ..> ArrowDependency',
44+
' Entry ..> ArrowDependency',
45+
' Entry ..> ArrowInheritance',
46+
' Entry ..> ArrowDependency',
47+
' Package ..> Entry',
48+
' Package ..> Package',
49+
' Package ..> Package',
50+
' Package ..> Package',
51+
' PackageRelations ..> Package',
52+
' PackageRelations ..> ExternalPackage_PackageHierarchy',
53+
' PackageRelations ..> PackageArrow',
54+
' PackageRelations ..> PackageArrow',
55+
' Relation ..> RelationsFilter',
56+
' Relation ..> Package',
57+
' Relation ..> Package',
58+
' Relation ..> Arrow',
59+
' Relation ..> PackageRelations',
60+
];
61+
}
62+
63+
public function testFiltersInboundRelations(): void
64+
{
65+
$relationsFilter = new RelationsFilter(new Options([
66+
'to' => 'PackageNode'
67+
]));
68+
69+
$result = $relationsFilter->filterRelations($this->fixture);
70+
71+
$this->assertSame(" ExternalPackage_PackageHierarchy ..> ExternalPackage_PackageNode", $result[0]);
72+
$this->assertSame(" ExternalPackage_PackageNode \"1\" ..> \"*\" ExternalPackage_PackageNode", $result[1]);
73+
$this->assertSame(" ExternalPackage_PackageNode ..> ExternalPackage_PackageNode", $result[2]);
74+
$this->assertSame(" PackageRelations ..> ExternalPackage_PackageHierarchy", $result[3]);
75+
$this->assertSame(" Relation ..> PackageRelations", $result[4]);
76+
}
77+
78+
public function testFiltersInboundRelationsWithDepth(): void
79+
{
80+
$relationsFilter = new RelationsFilter(new Options([
81+
'to' => 'PackageNode',
82+
'depth' => 1
83+
]));
84+
85+
$result = $relationsFilter->filterRelations($this->fixture);
86+
87+
$this->assertSame(" ExternalPackage_PackageHierarchy ..> ExternalPackage_PackageNode", $result[0]);
88+
$this->assertSame(" ExternalPackage_PackageNode \"1\" ..> \"*\" ExternalPackage_PackageNode", $result[1]);
89+
$this->assertSame(" ExternalPackage_PackageNode ..> ExternalPackage_PackageNode", $result[2]);
90+
$this->assertCount(3, $result);
91+
}
92+
93+
public function testFiltersOutboundRelations(): void
94+
{
95+
$relationsFilter = new RelationsFilter(new Options([
96+
'from' => 'Package'
97+
]));
98+
99+
$result = $relationsFilter->filterRelations($this->fixture);
100+
101+
$this->assertSame(" Entry \"1\" ..> \"*\" Arrow", $result[0]);
102+
$this->assertSame(" Package \"1\" ..> \"*\" Package", $result[1]);
103+
$this->assertSame(" Package \"1\" ..> \"*\" Entry", $result[2]);
104+
$this->assertSame(" Package ..> Entry", $result[3]);
105+
$this->assertSame(" Package \"1\" ..> \"*\" Arrow", $result[4]);
106+
$this->assertSame(" Package ..> Package", $result[5]);
107+
$this->assertSame(" Entry ..> Division_DivisionColor", $result[6]);
108+
$this->assertSame(" Entry ..> ArrowDependency", $result[7]);
109+
$this->assertSame(" Entry ..> ArrowInheritance", $result[8]);
110+
}
111+
112+
public function testFiltersOutboundRelationsWithDepth(): void
113+
{
114+
$relationsFilter = new RelationsFilter(new Options([
115+
'from' => 'Package',
116+
'depth' => 1
117+
]));
118+
119+
$result = $relationsFilter->filterRelations($this->fixture);
120+
121+
$this->assertSame(" Package \"1\" ..> \"*\" Package", $result[0]);
122+
$this->assertSame(" Package \"1\" ..> \"*\" Entry", $result[1]);
123+
$this->assertSame(" Package ..> Entry", $result[2]);
124+
$this->assertSame(" Package \"1\" ..> \"*\" Arrow", $result[3]);
125+
$this->assertSame(" Package ..> Package", $result[4]);
126+
}
127+
128+
public function testGeneratesRemoveUnlinkedDirective(): void
129+
{
130+
$relationsFilter = new RelationsFilter(new Options([
131+
'from' => 'Package'
132+
]));
133+
134+
$relationsFilter->filterRelations($this->fixture);
135+
$result = $relationsFilter->addRemoveUnlinkedDirective([]);
136+
137+
$this->assertSame(" remove @unlinked", $result[0]);
138+
}
139+
}

0 commit comments

Comments
 (0)