Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
21 / 21
93.33% covered (success)
93.33%
28 / 30
48.39% covered (danger)
48.39%
15 / 31
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Command
100.00% covered (success)
100.00%
21 / 21
93.33% covered (success)
93.33%
28 / 30
48.39% covered (danger)
48.39%
15 / 31
100.00% covered (success)
100.00%
4 / 4
36.24
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
85.71% covered (warning)
85.71%
12 / 14
27.27% covered (danger)
27.27%
6 / 22
100.00% covered (success)
100.00%
1 / 1
14.62
 full
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 title
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 of
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7use Attribute;
8use ReflectionClass;
9use ValueError;
10
11/**
12 * Declares a class as a console command.
13 *
14 * The name may carry a group prefix separated by a single colon, e.g.
15 * `db:migrate`; further colons are rejected.
16 * The prefix namespaces the command on the command line and groups it in the
17 * help overview under the capitalized prefix; `group` overrides that
18 * displayed title.
19 *
20 * @api
21 */
22#[Attribute(Attribute::TARGET_CLASS)]
23final class Command
24{
25    public readonly string $name;
26    public readonly string $prefix;
27
28    public function __construct(
29        string $name,
30        public readonly string $description = '',
31        public readonly string $group = '',
32    ) {
33        $lower = strtolower($name);
34        $prefix = '';
35        $bare = $lower;
36
37        if (str_contains($lower, ':')) {
38            /** @var array{0: string, 1: string} $parts */
39            $parts = explode(':', $lower, limit: 2);
40            [$prefix, $bare] = $parts;
41
42            // A single colon only: a second one would collide with the
43            // prefixed lookup of another command.
44            if ($prefix === '' || str_contains($bare, ':')) {
45                throw new ValueError("Invalid command name '{$name}'");
46            }
47        }
48
49        if ($bare === '') {
50            throw new ValueError("Invalid command name '{$name}'");
51        }
52
53        $this->prefix = $prefix;
54        $this->name = $bare;
55    }
56
57    public function full(): string
58    {
59        return $this->prefix === '' ? $this->name : "{$this->prefix}:{$this->name}";
60    }
61
62    public function title(): string
63    {
64        if ($this->group !== '') {
65            return $this->group;
66        }
67
68        return $this->prefix === '' ? 'General' : ucfirst($this->prefix);
69    }
70
71    /**
72     * Reads the attribute off a command instance or class.
73     *
74     * @param class-string|object $command
75     */
76    public static function of(object|string $command): self
77    {
78        $class = is_object($command) ? $command::class : $command;
79        $attributes = new ReflectionClass($class)->getAttributes(self::class);
80
81        if ($attributes === []) {
82            throw new ValueError("Command class '{$class}' has no #[Command] attribute");
83        }
84
85        return $attributes[0]->newInstance();
86    }
87}