Skip to content

Commit a8ec245

Browse files
committed
Merge branch 'add-cli-methods'
2 parents f7d2d86 + 4271223 commit a8ec245

File tree

7 files changed

+93
-131
lines changed

7 files changed

+93
-131
lines changed

src/Cli/CliApplication.php

Lines changed: 26 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,18 @@
2828
*/
2929
class CliApplication extends Application implements ICliApplication
3030
{
31+
private const COMMAND_REGEX = '/^[a-z][a-z0-9_-]*$/iD';
32+
3133
/**
3234
* @var array<string,class-string<CliCommand>|mixed[]>
3335
*/
3436
private $CommandTree = [];
3537

36-
/**
37-
* @var CliCommand|null
38-
*/
39-
private $RunningCommand;
38+
private ?CliCommand $RunningCommand = null;
4039

41-
/**
42-
* @var int
43-
*/
44-
private $LastExitStatus = 0;
40+
private ?CliCommand $LastCommand = null;
41+
42+
private int $LastExitStatus = 0;
4543

4644
public function __construct(
4745
?string $basePath = null,
@@ -73,6 +71,14 @@ public function getRunningCommand(): ?CliCommand
7371
return $this->RunningCommand;
7472
}
7573

74+
/**
75+
* @inheritDoc
76+
*/
77+
public function getLastCommand(): ?CliCommand
78+
{
79+
return $this->LastCommand;
80+
}
81+
7682
/**
7783
* @inheritDoc
7884
*/
@@ -141,51 +147,25 @@ protected function getNode(array $name = [])
141147
}
142148

143149
/**
144-
* Register one, and only one, CliCommand for the lifetime of the container
145-
*
146-
* The command is registered with an empty name, placing it at the root of
147-
* the container's subcommand tree.
148-
*
149-
* @param class-string<CliCommand> $id The name of the class to instantiate.
150-
* @return $this
151-
* @throws LogicException if another command has already been registered.
150+
* @inheritDoc
152151
*/
153152
public function oneCommand(string $id)
154153
{
155154
return $this->command([], $id);
156155
}
157156

158157
/**
159-
* Register a CliCommand with the container
160-
*
161-
* For example, an executable PHP script called `sync-util` could register
162-
* `Acme\Canvas\Sync`, a {@see CliCommand} inheritor, as follows:
163-
*
164-
* ```php
165-
* (new CliApplication())
166-
* ->command(['sync', 'canvas'], \Acme\Canvas\Sync::class)
167-
* ->runAndExit();
168-
* ```
169-
*
170-
* Then, `Acme\Canvas\Sync` could be invoked with:
171-
*
172-
* ```shell
173-
* ./sync-util sync canvas
174-
* ```
175-
*
176-
* @param string[] $name The name of the command as an array of subcommands.
177-
*
178-
* Valid subcommands start with a letter, followed by any number of letters,
179-
* numbers, hyphens and underscores.
180-
* @param class-string<CliCommand> $id The name of the class to instantiate.
181-
* @return $this
182-
* @throws LogicException if `$name` is invalid or conflicts with a
183-
* registered command.
158+
* @inheritDoc
184159
*/
185160
public function command(array $name, string $id)
186161
{
187-
foreach ($name as $i => $subcommand) {
188-
Assert::isMatch($subcommand, '/^[a-zA-Z][a-zA-Z0-9_-]*$/', "\$name[$i]");
162+
foreach ($name as $subcommand) {
163+
if (!Pcre::match(self::COMMAND_REGEX, $subcommand)) {
164+
throw new LogicException(sprintf(
165+
'Subcommand does not start with a letter, followed by zero or more letters, numbers, hyphens or underscores: %s',
166+
$subcommand,
167+
));
168+
}
189169
}
190170

191171
if ($this->getNode($name) !== null) {
@@ -419,6 +399,9 @@ public function run()
419399
return $this;
420400
} finally {
421401
$this->RunningCommand = null;
402+
if ($command !== null) {
403+
$this->LastCommand = $command;
404+
}
422405
}
423406
}
424407

src/Cli/CliCommand.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,16 @@ final protected function getOptionValue(string $name)
889889
return $this->OptionValues[$option->Key] ?? null;
890890
}
891891

892+
/**
893+
* True if an option was given on the command line
894+
*/
895+
final protected function optionHasArgument(string $name): bool
896+
{
897+
$this->assertHasRun()->loadOptions();
898+
$option = $this->_getOption($name, false);
899+
return array_key_exists($option->Key, $this->ArgumentValues);
900+
}
901+
892902
/**
893903
* Get the given option
894904
*/
@@ -1150,6 +1160,8 @@ private function optionError(string $message): void
11501160
}
11511161

11521162
/**
1163+
* Get the command's options
1164+
*
11531165
* @return list<CliOption>
11541166
*/
11551167
final protected function getOptions(): array
@@ -1321,7 +1333,7 @@ final protected function getExitStatus(): int
13211333
* Get the number of times the command has run, including the current run
13221334
* (if applicable)
13231335
*/
1324-
final protected function getRuns(): int
1336+
final public function getRuns(): int
13251337
{
13261338
return $this->Runs;
13271339
}

src/Cli/Contract/ICliApplication.php

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,65 +11,73 @@
1111
interface ICliApplication extends IApplication
1212
{
1313
/**
14-
* Get the command started from the command line
14+
* Get the command invoked by run()
15+
*
16+
* This method should only return a command that is currently running.
1517
*/
1618
public function getRunningCommand(): ?ICliCommand;
1719

20+
/**
21+
* Get the command most recently invoked by run()
22+
*
23+
* This method should only return a command that ran to completion or failed
24+
* with an exception.
25+
*/
26+
public function getLastCommand(): ?ICliCommand;
27+
1828
/**
1929
* Get the return value most recently recorded by run()
2030
*
21-
* Returns `0` if {@see ICliApplication::run()} has not recorded a return
22-
* value.
31+
* This method should return `0` if a return value has not been recorded.
2332
*/
2433
public function getLastExitStatus(): int;
2534

2635
/**
2736
* Register a command with the container
2837
*
2938
* @param string[] $name The name of the command as an array of subcommands.
30-
*
3139
* Valid subcommands start with a letter, followed by any number of letters,
3240
* numbers, hyphens and underscores.
33-
* @param class-string<ICliCommand> $id The name of the class to register.
41+
* @param class-string<ICliCommand> $id
3442
* @return $this
3543
* @throws LogicException if `$name` is invalid or conflicts with a
3644
* registered command.
3745
*/
3846
public function command(array $name, string $id);
3947

4048
/**
41-
* Register one, and only one, ICliCommand for the lifetime of the container
49+
* Register one, and only one, command for the lifetime of the container
4250
*
43-
* The command is registered with an empty name, placing it at the root of
44-
* the container's subcommand tree.
51+
* Calling this method should have the same effect as calling
52+
* {@see ICliApplication::command()} with an empty command name.
4553
*
46-
* @param class-string<ICliCommand> $id The name of the class to register.
54+
* @param class-string<ICliCommand> $id
4755
* @return $this
4856
* @throws LogicException if another command has already been registered.
49-
*
50-
* @see ICliApplication::command()
5157
*/
5258
public function oneCommand(string $id);
5359

5460
/**
5561
* Process command line arguments passed to the script and record a return
5662
* value
5763
*
58-
* The first applicable action is taken:
64+
* This method should take the first applicable action:
5965
*
6066
* - If `--help` is the only remaining argument after processing subcommand
61-
* arguments, a help message is printed to `STDOUT` and the return value
62-
* is `0`.
63-
* - If `--version` is the only remaining argument, the application's name
64-
* and version number is printed to `STDOUT` and the return value is `0`.
65-
* - If subcommand arguments resolve to a registered command, it is invoked
66-
* and the return value is its exit status.
67+
* arguments, print a help message to `STDOUT`. Return value: `0`
68+
*
69+
* - If `--version` is the only remaining argument, print the application's
70+
* name and version number to `STDOUT`. Return value: `0`
71+
*
72+
* - If subcommand arguments resolve to a registered command, create an
73+
* instance of the command and run it. Return value: command exit status
74+
*
6775
* - If, after processing subcommand arguments, there are no further
68-
* arguments but there are further subcommands, a one-line synopsis of
69-
* each registered subcommand is printed and the return value is `0`.
76+
* arguments but there are further subcommands, print a one-line synopsis
77+
* of each registered subcommand. Return value: `0`
7078
*
71-
* Otherwise, an error is reported, a one-line synopsis of each registered
72-
* subcommand is printed, and the return value is `1`.
79+
* - Report an error and print a one-line synopsis of each registered
80+
* subcommand. Return value: `1`
7381
*
7482
* @return $this
7583
*/
@@ -78,18 +86,18 @@ public function run();
7886
/**
7987
* Exit with the return value most recently recorded by run()
8088
*
81-
* The exit status is `0` if {@see ICliApplication::run()} has not recorded
82-
* a return value.
89+
* This method should use exit status `0` if a return value has not been
90+
* recorded.
8391
*
8492
* @return never
8593
*/
8694
public function exit();
8795

8896
/**
89-
* Exit after processing command line arguments passed to the script
97+
* Process command line arguments passed to the script and exit with the
98+
* recorded return value
9099
*
91-
* The return value recorded by {@see ICliApplication::run()} is used as the
92-
* exit status.
100+
* See {@see ICliApplication::run()} for details.
93101
*
94102
* @return never
95103
*/

src/Utility/Assert.php

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -158,29 +158,6 @@ public static function isString(
158158
self::throw('{} must be a string', $name, $exception);
159159
}
160160

161-
/**
162-
* Assert that a value is a string that matches a regular expression
163-
*
164-
* @template TException of Throwable
165-
*
166-
* @param mixed $value
167-
* @param class-string<TException> $exception
168-
* @throws TException if `$value` is not a string or does not match
169-
* `$pattern`.
170-
* @phpstan-assert string $value
171-
*/
172-
public static function isMatch(
173-
$value,
174-
string $pattern,
175-
?string $name = null,
176-
string $exception = AssertionFailedException::class
177-
): void {
178-
if (is_string($value) && Pcre::match($pattern, $value)) {
179-
return;
180-
}
181-
self::throw(sprintf('{} must match regular expression: %s', $pattern), $name, $exception);
182-
}
183-
184161
/**
185162
* Assert that PHP is running on the command line
186163
*

tests/fixtures/Cli/Command/TestOptions.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
use Lkrms\Cli\Catalog\CliOptionValueType;
77
use Lkrms\Cli\CliCommand;
88
use Lkrms\Cli\CliOption;
9-
use Lkrms\Support\Date\DateFormatter;
109
use Lkrms\Utility\Json;
1110
use DateTimeInterface;
1211

@@ -104,7 +103,11 @@ protected function getOptionList(): array
104103

105104
protected function run(string ...$args)
106105
{
107-
$dateFormatter = new DateFormatter();
106+
foreach ($this->getOptions() as $option) {
107+
if ($this->optionHasArgument($option->Name)) {
108+
$hasArg[$option->Name] = true;
109+
}
110+
}
108111

109112
echo Json::prettyPrint([
110113
'args' => $args,
@@ -117,6 +120,7 @@ protected function run(string ...$args)
117120
'RepeatableValue' => $this->RepeatableValue,
118121
'RequiredValue' => $this->RequiredValue,
119122
],
123+
'hasArg' => $hasArg ?? [],
120124
]) . \PHP_EOL;
121125
}
122126
}

tests/unit/Cli/CliApplicationTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,25 @@ public function testCommandOptionValues(): void
129129
"timezone_type": 1,
130130
"timezone": "+11:00"
131131
}
132+
},
133+
"hasArg": {
134+
"start": true
132135
}
133136
}
134137

135138
EOF);
136139
$app = $this->App->oneCommand(TestOptions::class);
137140
$this->assertSame(0, $app->run()->getLastExitStatus());
141+
$this->assertInstanceOf(TestOptions::class, $command = $app->getLastCommand());
142+
$this->assertSame(1, $command->getRuns());
143+
}
144+
145+
public function testInvalidSubcommand(): void
146+
{
147+
$this->expectException(LogicException::class);
148+
$this->expectExceptionMessage('Subcommand does not start with a letter, followed by zero or more letters, numbers, hyphens or underscores: _options');
149+
150+
$this->App->command(['_options', 'test'], TestOptions::class);
138151
}
139152

140153
public function testCommandCollision(): void

tests/unit/Utility/AssertTest.php

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -279,41 +279,6 @@ public static function isStringProvider(): array
279279
];
280280
}
281281

282-
/**
283-
* @dataProvider isMatchProvider
284-
*
285-
* @param mixed $value
286-
*/
287-
public function testIsMatch(bool $isMatch, $value, string $pattern, ?string $name = null, ?string $message = null): void
288-
{
289-
if ($isMatch) {
290-
$this->expectNotToPerformAssertions();
291-
} else {
292-
$this->expectException(AssertionFailedException::class);
293-
if ($message !== null) {
294-
$this->expectExceptionMessage($message);
295-
}
296-
}
297-
Assert::isMatch($value, $pattern, $name);
298-
}
299-
300-
/**
301-
* @return array<array{bool,mixed,string,3?:string|null,4?:string|null}>
302-
*/
303-
public static function isMatchProvider(): array
304-
{
305-
return [
306-
[true, '', '/.*/'],
307-
[true, 'Text', '/^t.+t$/i'],
308-
[false, '', '/.+/'],
309-
[false, null, '/.+/', '$arg', '$arg must match regular expression: /.+/'],
310-
[false, 0, '/.*/', null, 'value must match regular expression: /.*/'],
311-
[false, 1, '/.*/'],
312-
[false, false, '/.*/'],
313-
[false, true, '/.*/'],
314-
];
315-
}
316-
317282
public function testRunningOnCli(): void
318283
{
319284
if (\PHP_SAPI === 'cli') {

0 commit comments

Comments
 (0)