Skip to content

Commit 5c29eec

Browse files
committed
feature symfony#59588 [Console] Add a Tree Helper + multiple Styles (smnandre)
This PR was squashed before being merged into the 7.3 branch. Discussion ---------- [Console] Add a Tree Helper + multiple Styles | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #... | License | MIT ## Feature The goal of the Tree helper is to ease rendering **hierarchical data** in the console: * file system * configuration * workflows * ... ## Usage Two methods available, manual or via SymfonyStyle ### SymfonyStyle (easiest) ```php $io = new SymfonyStyle($input, $ouput); $io->tree([ 'A' => [ 'A1', 'A2', ], 'B' => [ 'B1' => [ 'B11', ], 'B2', ], ]); ``` Will render ```console root ├── A │ ├── A1 │ └── A2 ├── B │ ├── B1 │ │ ├── B11 │ │ └── B12 │ └── B2 └── C ``` You also can pass a custom TreeStyle here, or one of the one availbes (see below) ### Manually You can build the Tree the way you want thanks to the TreeNode class. ```php $root = (new TreeNode('root')) ->addChild((new TreeNode('A')) ->addChild(new TreeNode('A1')) // ... $tree = new Tree($output, $root, $style); $tree->render(); ``` --- ## Styles Various styles are available (based on the styles of Table .. and some new ones), or you can also build your own Style from scratch. ### Default ```console root ├── A │ ├── A1 │ └── A2 ├── B │ ├── B1 │ │ ├── B11 │ │ └── B12 │ └── B2 └── C ``` ### Box ```console root ┃╸ A ┃ ┃╸ A1 ┃ ┗╸ A2 ┃╸ B ┃ ┃╸ B1 ┃ ┃ ┃╸ B11 ┃ ┃ ┗╸ B12 ┃ ┗╸ B2 ┗╸ C ``` ### Box Double ```console root ╠═ A ║ ╠═ A1 ║ ╚═ A2 ╠═ B ║ ╠═ B1 ║ ║ ╠═ B11 ║ ║ ╚═ B12 ║ ╚═ B2 ╚═ C ``` ### Rounded ```console root ├─ A │ ├─ A1 │ ╰─ A2 ├─ B │ ├─ B1 │ │ ├─ B11 │ │ ╰─ B12 │ ╰─ B2 ╰─ C ``` ### Compact ```console root ├ A │ ├ A1 │ └ A2 ├ B │ ├ B1 │ │ ├ B11 │ │ └ B12 │ └ B2 └ C ``` ### Light ```console root |-- A | |-- A1 | `-- A2 |-- B | |-- B1 | | |-- B11 | | `-- B12 | `-- B2 `-- C ``` ### Minimal ```console root A . A1 . . A2 B . B1 . . B11 . . . B12 . . B2 . C ``` --- Commits ------- 6a641c9 [Console] Add a Tree Helper + multiple Styles
2 parents ffdbc83 + 6a641c9 commit 5c29eec

File tree

9 files changed

+1045
-0
lines changed

9 files changed

+1045
-0
lines changed

src/Symfony/Component/Console/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ CHANGELOG
44
7.3
55
---
66

7+
* Add `TreeHelper` and `TreeStyle` to display tree-like structures
8+
* Add `SymfonyStyle::createTree()`
79
* Add support for invokable commands and add `#[Argument]` and `#[Option]` attributes to define input arguments and options
810
* Deprecate not declaring the parameter type in callable commands defined through `setCode` method
911
* Add support for help definition via `AsCommand` attribute
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Helper;
13+
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
16+
/**
17+
* The TreeHelper class provides methods to display tree-like structures.
18+
*
19+
* @author Simon André <[email protected]>
20+
*
21+
* @implements \RecursiveIterator<int, TreeNode>
22+
*/
23+
final class TreeHelper implements \RecursiveIterator
24+
{
25+
/**
26+
* @var \Iterator<int, TreeNode>
27+
*/
28+
private \Iterator $children;
29+
30+
private function __construct(
31+
private readonly OutputInterface $output,
32+
private readonly TreeNode $node,
33+
private readonly TreeStyle $style,
34+
) {
35+
$this->children = new \IteratorIterator($this->node->getChildren());
36+
$this->children->rewind();
37+
}
38+
39+
public static function createTree(OutputInterface $output, string|TreeNode|null $root = null, iterable $values = [], ?TreeStyle $style = null): self
40+
{
41+
$node = $root instanceof TreeNode ? $root : new TreeNode($root ?? '');
42+
43+
return new self($output, TreeNode::fromValues($values, $node), $style ?? TreeStyle::default());
44+
}
45+
46+
public function current(): TreeNode
47+
{
48+
return $this->children->current();
49+
}
50+
51+
public function key(): int
52+
{
53+
return $this->children->key();
54+
}
55+
56+
public function next(): void
57+
{
58+
$this->children->next();
59+
}
60+
61+
public function rewind(): void
62+
{
63+
$this->children->rewind();
64+
}
65+
66+
public function valid(): bool
67+
{
68+
return $this->children->valid();
69+
}
70+
71+
public function hasChildren(): bool
72+
{
73+
if (null === $current = $this->current()) {
74+
return false;
75+
}
76+
77+
foreach ($current->getChildren() as $child) {
78+
return true;
79+
}
80+
81+
return false;
82+
}
83+
84+
public function getChildren(): \RecursiveIterator
85+
{
86+
return new self($this->output, $this->current(), $this->style);
87+
}
88+
89+
/**
90+
* Recursively renders the tree to the output, applying the tree style.
91+
*/
92+
public function render(): void
93+
{
94+
$treeIterator = new \RecursiveTreeIterator($this);
95+
96+
$this->style->applyPrefixes($treeIterator);
97+
98+
$this->output->writeln($this->node->getValue());
99+
100+
$visited = new \SplObjectStorage();
101+
foreach ($treeIterator as $node) {
102+
$currentNode = $node instanceof TreeNode ? $node : $treeIterator->getInnerIterator()->current();
103+
if ($visited->contains($currentNode)) {
104+
throw new \LogicException(\sprintf('Cycle detected at node: "%s".', $currentNode->getValue()));
105+
}
106+
$visited->attach($currentNode);
107+
108+
$this->output->writeln($node);
109+
}
110+
}
111+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Helper;
13+
14+
/**
15+
* @implements \IteratorAggregate<TreeNode>
16+
*
17+
* @author Simon André <[email protected]>
18+
*/
19+
final class TreeNode implements \Countable, \IteratorAggregate
20+
{
21+
/**
22+
* @var array<TreeNode|callable(): \Generator>
23+
*/
24+
private array $children = [];
25+
26+
public function __construct(
27+
private readonly string $value = '',
28+
iterable $children = [],
29+
) {
30+
foreach ($children as $child) {
31+
$this->addChild($child);
32+
}
33+
}
34+
35+
public static function fromValues(iterable $nodes, ?self $node = null): self
36+
{
37+
$node ??= new self();
38+
foreach ($nodes as $key => $value) {
39+
if (is_iterable($value)) {
40+
$child = new self($key);
41+
self::fromValues($value, $child);
42+
$node->addChild($child);
43+
} elseif ($value instanceof self) {
44+
$node->addChild($value);
45+
} else {
46+
$node->addChild(new self($value));
47+
}
48+
}
49+
50+
return $node;
51+
}
52+
53+
public function getValue(): string
54+
{
55+
return $this->value;
56+
}
57+
58+
public function addChild(self|string|callable $node): self
59+
{
60+
if (\is_string($node)) {
61+
$node = new self($node, $this);
62+
}
63+
64+
$this->children[] = $node;
65+
66+
return $this;
67+
}
68+
69+
/**
70+
* @return \Traversable<int, TreeNode>
71+
*/
72+
public function getChildren(): \Traversable
73+
{
74+
foreach ($this->children as $child) {
75+
if (\is_callable($child)) {
76+
yield from $child();
77+
} elseif ($child instanceof self) {
78+
yield $child;
79+
}
80+
}
81+
}
82+
83+
/**
84+
* @return \Traversable<int, TreeNode>
85+
*/
86+
public function getIterator(): \Traversable
87+
{
88+
return $this->getChildren();
89+
}
90+
91+
public function count(): int
92+
{
93+
$count = 0;
94+
foreach ($this->getChildren() as $child) {
95+
++$count;
96+
}
97+
98+
return $count;
99+
}
100+
101+
public function __toString(): string
102+
{
103+
return $this->value;
104+
}
105+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Helper;
13+
14+
/**
15+
* Configures the output of the Tree helper.
16+
*
17+
* @author Simon André <[email protected]>
18+
*/
19+
final class TreeStyle
20+
{
21+
public function __construct(
22+
private readonly string $prefixEndHasNext,
23+
private readonly string $prefixEndLast,
24+
private readonly string $prefixLeft,
25+
private readonly string $prefixMidHasNext,
26+
private readonly string $prefixMidLast,
27+
private readonly string $prefixRight,
28+
) {
29+
}
30+
31+
public static function box(): self
32+
{
33+
return new self('┃╸ ', '┗╸ ', '', '', ' ', '');
34+
}
35+
36+
public static function boxDouble(): self
37+
{
38+
return new self('╠═ ', '╚═ ', '', '', ' ', '');
39+
}
40+
41+
public static function compact(): self
42+
{
43+
return new self('', '', '', '', ' ', '');
44+
}
45+
46+
public static function default(): self
47+
{
48+
return new self('├── ', '└── ', '', '', ' ', '');
49+
}
50+
51+
public static function light(): self
52+
{
53+
return new self('|-- ', '`-- ', '', '| ', ' ', '');
54+
}
55+
56+
public static function minimal(): self
57+
{
58+
return new self('. ', '. ', '', '. ', ' ', '');
59+
}
60+
61+
public static function rounded(): self
62+
{
63+
return new self('├─ ', '╰─ ', '', '', ' ', '');
64+
}
65+
66+
/**
67+
* @internal
68+
*/
69+
public function applyPrefixes(\RecursiveTreeIterator $iterator): void
70+
{
71+
$iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_LEFT, $this->prefixLeft);
72+
$iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_MID_HAS_NEXT, $this->prefixMidHasNext);
73+
$iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_MID_LAST, $this->prefixMidLast);
74+
$iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_HAS_NEXT, $this->prefixEndHasNext);
75+
$iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_LAST, $this->prefixEndLast);
76+
$iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_RIGHT, $this->prefixRight);
77+
}
78+
}

src/Symfony/Component/Console/Style/SymfonyStyle.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
use Symfony\Component\Console\Helper\Table;
2222
use Symfony\Component\Console\Helper\TableCell;
2323
use Symfony\Component\Console\Helper\TableSeparator;
24+
use Symfony\Component\Console\Helper\TreeHelper;
25+
use Symfony\Component\Console\Helper\TreeNode;
26+
use Symfony\Component\Console\Helper\TreeStyle;
2427
use Symfony\Component\Console\Input\InputInterface;
2528
use Symfony\Component\Console\Output\ConsoleOutputInterface;
2629
use Symfony\Component\Console\Output\ConsoleSectionOutput;
@@ -369,6 +372,24 @@ private function getProgressBar(): ProgressBar
369372
?? throw new RuntimeException('The ProgressBar is not started.');
370373
}
371374

375+
/**
376+
* @param iterable<string, iterable|string|TreeNode> $nodes
377+
*/
378+
public function tree(iterable $nodes, string $root = ''): void
379+
{
380+
$this->createTree($nodes, $root)->render();
381+
}
382+
383+
/**
384+
* @param iterable<string, iterable|string|TreeNode> $nodes
385+
*/
386+
public function createTree(iterable $nodes, string $root = ''): TreeHelper
387+
{
388+
$output = $this->output instanceof ConsoleOutputInterface ? $this->output->section() : $this->output;
389+
390+
return TreeHelper::createTree($output, $root, $nodes, TreeStyle::default());
391+
}
392+
372393
private function autoPrependBlock(): void
373394
{
374395
$chars = substr(str_replace(\PHP_EOL, "\n", $this->bufferedOutput->fetch()), -2);

0 commit comments

Comments
 (0)