diff --git a/config/services.php b/config/services.php index aa5d406b..4302f01e 100644 --- a/config/services.php +++ b/config/services.php @@ -104,6 +104,7 @@ use Qossmic\Deptrac\Supportive\OutputFormatter\GraphVizOutputImageFormatter; use Qossmic\Deptrac\Supportive\OutputFormatter\JsonOutputFormatter; use Qossmic\Deptrac\Supportive\OutputFormatter\JUnitOutputFormatter; +use Qossmic\Deptrac\Supportive\OutputFormatter\MermaidJSOutputFormatter; use Qossmic\Deptrac\Supportive\OutputFormatter\TableOutputFormatter; use Qossmic\Deptrac\Supportive\OutputFormatter\XMLOutputFormatter; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -417,6 +418,9 @@ $services ->set(CodeclimateOutputFormatter::class) ->tag('output_formatter'); + $services + ->set(MermaidJSOutputFormatter::class) + ->tag('output_formatter'); /* * Console diff --git a/deptrac.config.php b/deptrac.config.php index f055aa29..0d24200c 100755 --- a/deptrac.config.php +++ b/deptrac.config.php @@ -8,6 +8,7 @@ use Qossmic\Deptrac\Contract\Config\DeptracConfig; use Qossmic\Deptrac\Contract\Config\EmitterType; use Qossmic\Deptrac\Contract\Config\Formatter\GraphvizConfig; +use Qossmic\Deptrac\Contract\Config\Formatter\MermaidJsConfig; use Qossmic\Deptrac\Contract\Config\Layer; use Qossmic\Deptrac\Contract\Config\Ruleset; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -106,6 +107,11 @@ ->pointsToGroup(true) ->groups('Contract', $contract) ->groups('Supportive', $supportive, $file, $symfony, $console, $dependencyInjection, $outputFormatter, $time) + ->groups('Core', $analyser, $ast, $dependency, $inputCollector, $layer), + MermaidJsConfig::create() + ->direction('TD') + ->groups('Contract', $contract) + ->groups('Supportive', $supportive, $file, $symfony, $console, $dependencyInjection, $outputFormatter, $time) ->groups('Core', $analyser, $ast, $dependency, $inputCollector, $layer) ); }; diff --git a/deptrac.yaml b/deptrac.yaml index 270d15fd..05cbe6bf 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -36,6 +36,24 @@ deptrac: - Dependency - InputCollector - Layer + mermaidjs: + direction: TD + groups: + Contract: + - Contract + Supportive: + - Supportive + - File + Symfony: + - Console + - DependencyInjection + - OutputFormatter + Core: + - Analyser + - Ast + - Dependency + - InputCollector + - Layer layers: # Domains diff --git a/docs/formatters.md b/docs/formatters.md index 9fcd6a30..d074e189 100644 --- a/docs/formatters.md +++ b/docs/formatters.md @@ -141,6 +141,87 @@ Will produce the following graph: #### Pointing to groups instead of nodes With `formatters.graphviz.pointToGroups` set to `true`, when you have a node inside a groups with the same name as the group itself, edges pointing to that node will point to the group instead. This might be useful for example if you want to provide a "public API" for a module defined by a group. +## MermaidJS Formatter + +The MermaidJS formatter is a console formatter, which generates a mermaid.js compatible graph definition. It can be activated with `--formatter=mermaidjs`. +With the -o option you can specify the output file. + +Available options: + +``` +--formatter=mermaidjs +--output= path to a dumped file +``` +With this example +Yaml Config: + +```yaml +deptrac: + layers: + - User Frontend + - User Backend + - Admin Frontend + - Admin Backend + formatters: + mermaidjs: + direction: TD + groups: + User: + - User Frontend + - User Backend + Admin: + - Admin Frontend + - Admin Backend +``` + +This will produce the following graph: + +```mermaid +flowchart TD; + subgraph ContractGroup; + Contract; + end; + subgraph SupportiveGroup; + Supportive; + File; + end; + subgraph SymfonyGroup; + Console; + DependencyInjection; + OutputFormatter; + end; + subgraph CoreGroup; + Analyser; + Ast; + Dependency; + InputCollector; + Layer; + end; + Contract -->|6| Symfony; + InputCollector -->|3| File; + InputCollector -->|7| Symfony; + Dependency -->|36| Ast; + Layer -->|68| Ast; + Layer -->|8| Symfony; + Analyser -->|18| Ast; + Analyser -->|23| Dependency; + Analyser -->|6| Layer; + Analyser -->|10| Symfony; + Ast -->|3| Symfony; + Ast -->|3| InputCollector; + Ast -->|7| File; + OutputFormatter -->|5| Symfony; + OutputFormatter -->|1| DependencyInjection; + File -->|9| Symfony; + DependencyInjection -->|37| Symfony; + Console -->|66| Symfony; + Console -->|2| DependencyInjection; + Console -->|16| Analyser; + Console -->|5| File; + Console -->|4| OutputFormatter; + Console -->|4| Time; +``` + ## JSON Formatter By default, Json formatter dumps information to *STDOUT*. It can be activated diff --git a/src/Contract/Config/Formatter/MermaidJsConfig.php b/src/Contract/Config/Formatter/MermaidJsConfig.php new file mode 100644 index 00000000..84efd88c --- /dev/null +++ b/src/Contract/Config/Formatter/MermaidJsConfig.php @@ -0,0 +1,59 @@ + */ + private array $groups = []; + + public static function create(): self + { + return new self(); + } + + public function getName(): string + { + return $this->name; + } + + public function direction(string $direction): self + { + $this->direction = $direction; + + return $this; + } + + public function groups(string $name, Layer ...$layerConfigs): self + { + foreach ($layerConfigs as $layerConfig) { + $this->groups[$name][] = $layerConfig; + } + + return $this; + } + + public function toArray(): array + { + $output = []; + + if ([] !== $this->groups) { + $output['groups'] = array_map( + static fn (array $configs) => array_map(static fn (Layer $layer) => $layer->name, $configs), + $this->groups + ); + } + + $output['direction'] = $this->direction; + + return $output; + } +} diff --git a/src/Supportive/DependencyInjection/Configuration.php b/src/Supportive/DependencyInjection/Configuration.php index 7f98a758..5a47ad7b 100644 --- a/src/Supportive/DependencyInjection/Configuration.php +++ b/src/Supportive/DependencyInjection/Configuration.php @@ -176,6 +176,21 @@ private function appendFormatters(ArrayNodeDefinition $node): void }) ->end() ->end() + ->arrayNode('mermaidjs') + ->info('Configure MermaidJS output formatter') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('direction')->defaultValue('TD') + ->end() + ->arrayNode('groups') + ->info('Combine multiple layers to a group') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->end() ->arrayNode('codeclimate') ->addDefaultsIfNotSet() ->info('Configure Codeclimate output formatters') diff --git a/src/Supportive/OutputFormatter/MermaidJSOutputFormatter.php b/src/Supportive/OutputFormatter/MermaidJSOutputFormatter.php new file mode 100644 index 00000000..7cf7c09e --- /dev/null +++ b/src/Supportive/OutputFormatter/MermaidJSOutputFormatter.php @@ -0,0 +1,117 @@ +} */ + private array $config; + private const GRAPH_TYPE = 'flowchart %s;'; + + private const GRAPH_END = ' end;'; + private const SUBGRAPH = ' subgraph %sGroup;'; + private const LAYER = ' %s;'; + private const GRAPH_NODE_FORMAT = ' %s -->|%d| %s;'; + private const VIOLATION_STYLE_FORMAT = ' linkStyle %d stroke:red,stroke-width:4px;'; + + public function __construct(FormatterConfiguration $config) + { + /** @var array{direction: string, groups: array} $extractedConfig */ + $extractedConfig = $config->getConfigFor('mermaidjs'); + $this->config = $extractedConfig; + } + + public static function getName(): string + { + return 'mermaidjs'; + } + + public function finish( + OutputResult $result, + OutputInterface $output, + OutputFormatterInput $outputFormatterInput + ): void { + $graph = $this->parseResults($result); + $violations = $result->violations(); + $buffer = ''; + + $buffer .= sprintf(self::GRAPH_TYPE.PHP_EOL, $this->config['direction']); + + foreach ($this->config['groups'] as $subGraphName => $layers) { + $buffer .= sprintf(self::SUBGRAPH.PHP_EOL, $subGraphName); + + foreach ($layers as $layer) { + $buffer .= sprintf(self::LAYER.PHP_EOL, $layer); + } + + $buffer .= self::GRAPH_END.PHP_EOL; + } + + $linkCount = 0; + $violationsLinks = []; + $violationGraphLinks = []; + + foreach ($violations as $violation) { + if (!isset($violationsLinks[$violation->getDependerLayer()][$violation->getDependentLayer()])) { + $violationsLinks[$violation->getDependerLayer()][$violation->getDependentLayer()] = 1; + } else { + ++$violationsLinks[$violation->getDependerLayer()][$violation->getDependentLayer()]; + } + } + + foreach ($violationsLinks as $dependerLayer => $layers) { + foreach ($layers as $dependentLayer => $count) { + $buffer .= sprintf(self::GRAPH_NODE_FORMAT.PHP_EOL, $dependerLayer, $count, $dependentLayer); + $violationGraphLinks[] = $linkCount; + ++$linkCount; + } + } + + foreach ($graph as $dependerLayer => $layers) { + foreach ($layers as $dependentLayer => $count) { + if (!isset($violationsLinks[$dependerLayer][$dependentLayer])) { + $buffer .= sprintf(self::GRAPH_NODE_FORMAT.PHP_EOL, $dependerLayer, $count, $dependentLayer); + } + } + } + + foreach ($violationGraphLinks as $linkNumber) { + $buffer .= sprintf(self::VIOLATION_STYLE_FORMAT.PHP_EOL, $linkNumber); + } + + if (null !== $outputFormatterInput->outputPath) { + file_put_contents($outputFormatterInput->outputPath, $buffer); + } else { + $output->writeRaw($buffer); + } + } + + /** + * @return array>> + */ + protected function parseResults(OutputResult $result): array + { + $graph = []; + + foreach ($result->allowed() as $rule) { + if (!isset($graph[$rule->getDependerLayer()][$rule->getDependentLayer()])) { + $graph[$rule->getDependerLayer()][$rule->getDependentLayer()] = 1; + } else { + ++$graph[$rule->getDependerLayer()][$rule->getDependentLayer()]; + } + } + + return $graph; + } +} diff --git a/tests/Supportive/DependencyInjection/DeptracExtensionTest.php b/tests/Supportive/DependencyInjection/DeptracExtensionTest.php index b2d607b0..54b01c57 100644 --- a/tests/Supportive/DependencyInjection/DeptracExtensionTest.php +++ b/tests/Supportive/DependencyInjection/DeptracExtensionTest.php @@ -24,6 +24,10 @@ final class DeptracExtensionTest extends TestCase 'groups' => [], 'point_to_groups' => false, ], + 'mermaidjs' => [ + 'direction' => 'TD', + 'groups' => [], + ], 'codeclimate' => [ 'severity' => [ 'failure' => 'major', diff --git a/tests/Supportive/OutputFormatter/MermaidJSOutputFormatterTest.php b/tests/Supportive/OutputFormatter/MermaidJSOutputFormatterTest.php new file mode 100644 index 00000000..81ae7b24 --- /dev/null +++ b/tests/Supportive/OutputFormatter/MermaidJSOutputFormatterTest.php @@ -0,0 +1,72 @@ +addRule(new Allowed($dependency, 'LayerA', 'LayerB')); + $analysisResult->addRule(new Allowed($dependency, 'LayerC', 'LayerD')); + $analysisResult->addRule(new Allowed($dependency, 'LayerA', 'LayerC')); + + $analysisResult->addRule(new Violation($dependency, 'LayerA', 'LayerC', new DummyViolationCreatingRule())); + $analysisResult->addRule(new Violation($dependency, 'LayerB', 'LayerC', new DummyViolationCreatingRule())); + + $bufferedOutput = new BufferedOutput(); + + $output = $this->createSymfonyOutput($bufferedOutput); + $outputFormatterInput = new OutputFormatterInput(null, true, true, false); + + $mermaidJSOutputFormatter = new MermaidJSOutputFormatter(new FormatterConfiguration([ + 'mermaidjs' => [ + 'direction' => 'TD', + 'groups' => [ + 'User' => [ + 'LayerA', + 'LayerB', + ], + 'Admin' => [ + 'LayerC', + 'LayerD', + ], + ], + ], + ])); + $mermaidJSOutputFormatter->finish(OutputResult::fromAnalysisResult($analysisResult), $output, $outputFormatterInput); + $this->assertSame(file_get_contents(__DIR__.'/data/mermaidjs-expected.txt'), $bufferedOutput->fetch()); + } + + private function createSymfonyOutput(BufferedOutput $bufferedOutput): SymfonyOutput + { + return new SymfonyOutput( + $bufferedOutput, + new Style(new SymfonyStyle($this->createMock(InputInterface::class), $bufferedOutput)) + ); + } +} diff --git a/tests/Supportive/OutputFormatter/data/mermaidjs-expected.txt b/tests/Supportive/OutputFormatter/data/mermaidjs-expected.txt new file mode 100644 index 00000000..466dfebd --- /dev/null +++ b/tests/Supportive/OutputFormatter/data/mermaidjs-expected.txt @@ -0,0 +1,15 @@ +flowchart TD; + subgraph UserGroup; + LayerA; + LayerB; + end; + subgraph AdminGroup; + LayerC; + LayerD; + end; + LayerA -->|1| LayerC; + LayerB -->|1| LayerC; + LayerA -->|1| LayerB; + LayerC -->|1| LayerD; + linkStyle 0 stroke:red,stroke-width:4px; + linkStyle 1 stroke:red,stroke-width:4px;