Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
Command
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
4 / 4
10
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 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 consists of letters, digits, `.`, `_`, and `-`, starts with a
15 * letter or digit, and is matched case-insensitively. It may carry a group
16 * prefix of the same form, separated by a single colon, e.g. `db:migrate`;
17 * any other name is rejected.
18 * The prefix namespaces the command on the command line. The help overview
19 * lists the command under its `group`, by default the capitalized prefix or,
20 * without one, General; commands with the same group share a section.
21 *
22 * The attribute and its properties are public API; the methods serve the
23 * runner.
24 *
25 * @api
26 */
27#[Attribute(Attribute::TARGET_CLASS)]
28final class Command
29{
30    /**
31     * A single colon only: a second one would collide with the prefixed
32     * lookup of another command.
33     */
34    private const string NAME = '/^(?:([a-z0-9][a-z0-9._-]*):)?([a-z0-9][a-z0-9._-]*)\z/';
35
36    public readonly string $name;
37    public readonly string $prefix;
38
39    public function __construct(
40        string $name,
41        public readonly string $description = '',
42        public readonly string $group = '',
43    ) {
44        if (preg_match(self::NAME, strtolower($name), $parts) !== 1) {
45            throw new ValueError("Invalid command name '{$name}'");
46        }
47
48        $this->prefix = $parts[1];
49        $this->name = $parts[2];
50    }
51
52    /** @internal */
53    public function full(): string
54    {
55        return $this->prefix === '' ? $this->name : "{$this->prefix}:{$this->name}";
56    }
57
58    /** @internal */
59    public function title(): string
60    {
61        if ($this->group !== '') {
62            return $this->group;
63        }
64
65        return $this->prefix === '' ? 'General' : ucfirst($this->prefix);
66    }
67
68    /**
69     * Reads the attribute off a command instance or class.
70     *
71     * @internal
72     *
73     * @param class-string|object $command
74     */
75    public static function of(object|string $command): self
76    {
77        $class = is_object($command) ? $command::class : $command;
78        $attributes = new ReflectionClass($class)->getAttributes(self::class);
79
80        if ($attributes === []) {
81            throw new ValueError("Command class '{$class}' has no #[Command] attribute");
82        }
83
84        return $attributes[0]->newInstance();
85    }
86}