Skip to content

Commit 3e8ac28

Browse files
committed
add the Argument and Option value objects
1 parent bc568eb commit 3e8ac28

4 files changed

Lines changed: 568 additions & 0 deletions

File tree

system/CLI/Input/Argument.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\CLI\Input;
15+
16+
use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException;
17+
18+
/**
19+
* Value object describing a single positional argument declared by a spark command.
20+
*/
21+
final readonly class Argument
22+
{
23+
/**
24+
* @var non-empty-string
25+
*/
26+
public string $name;
27+
28+
/**
29+
* @var list<string>|string|null
30+
*/
31+
public array|string|null $default;
32+
33+
/**
34+
* @param list<string>|string|null $default
35+
*
36+
* @throws InvalidArgumentDefinitionException
37+
*/
38+
public function __construct(
39+
string $name,
40+
public string $description = '',
41+
public bool $required = false,
42+
public bool $isArray = false,
43+
array|string|null $default = null,
44+
) {
45+
if ($name === '') {
46+
throw new InvalidArgumentDefinitionException(lang('Commands.emptyArgumentName'));
47+
}
48+
49+
if (preg_match('/[^a-zA-Z0-9_-]/', $name) !== 0) {
50+
throw new InvalidArgumentDefinitionException(lang('Commands.invalidArgumentName', [$name]));
51+
}
52+
53+
if ($name === 'extra_arguments') {
54+
throw new InvalidArgumentDefinitionException(lang('Commands.reservedArgumentName'));
55+
}
56+
57+
$this->name = $name;
58+
59+
if ($this->isArray && $this->required) {
60+
throw new InvalidArgumentDefinitionException(lang('Commands.arrayArgumentCannotBeRequired', [$this->name]));
61+
}
62+
63+
if ($this->required && $default !== null) {
64+
throw new InvalidArgumentDefinitionException(lang('Commands.requiredArgumentNoDefault', [$this->name]));
65+
}
66+
67+
if ($this->isArray) {
68+
if ($default !== null && ! is_array($default)) {
69+
throw new InvalidArgumentDefinitionException(lang('Commands.arrayArgumentInvalidDefault', [$this->name]));
70+
}
71+
72+
$default ??= [];
73+
} elseif (! $this->required) {
74+
if ($default === null) {
75+
throw new InvalidArgumentDefinitionException(lang('Commands.optionalArgumentNoDefault', [$this->name]));
76+
}
77+
78+
if (is_array($default)) {
79+
throw new InvalidArgumentDefinitionException(lang('Commands.nonArrayArgumentWithArrayDefault', [$this->name]));
80+
}
81+
}
82+
83+
$this->default = $default;
84+
}
85+
}

system/CLI/Input/Option.php

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\CLI\Input;
15+
16+
use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException;
17+
18+
/**
19+
* Value object describing a single option declared by a command.
20+
*/
21+
final readonly class Option
22+
{
23+
/**
24+
* @var non-empty-string
25+
*/
26+
public string $name;
27+
28+
/**
29+
* @var non-empty-string|null
30+
*/
31+
public ?string $shortcut;
32+
33+
public bool $acceptsValue;
34+
35+
/**
36+
* @var non-empty-string|null
37+
*/
38+
public ?string $valueLabel;
39+
40+
/**
41+
* @var non-empty-string|null
42+
*/
43+
public ?string $negation;
44+
45+
/**
46+
* @var bool|list<string>|string|null
47+
*/
48+
public array|bool|string|null $default;
49+
50+
/**
51+
* @param bool|list<string>|string|null $default
52+
*
53+
* @throws InvalidOptionDefinitionException
54+
*/
55+
public function __construct(
56+
string $name,
57+
?string $shortcut = null,
58+
public string $description = '',
59+
bool $acceptsValue = false,
60+
public bool $requiresValue = false,
61+
?string $valueLabel = null,
62+
public bool $isArray = false,
63+
public bool $negatable = false,
64+
array|bool|string|null $default = null,
65+
) {
66+
if (str_starts_with($name, '--')) {
67+
$name = substr($name, 2);
68+
}
69+
70+
if ($name === '') {
71+
throw new InvalidOptionDefinitionException(lang('Commands.emptyOptionName'));
72+
}
73+
74+
if (preg_match('/^-|[^a-zA-Z0-9_-]/', $name) !== 0) {
75+
throw new InvalidOptionDefinitionException(lang('Commands.invalidOptionName', [$name]));
76+
}
77+
78+
if ($name === 'extra_options') {
79+
throw new InvalidOptionDefinitionException(lang('Commands.reservedOptionName'));
80+
}
81+
82+
$this->name = $name;
83+
84+
if ($shortcut !== null) {
85+
if (str_starts_with($shortcut, '-')) {
86+
$shortcut = substr($shortcut, 1);
87+
}
88+
89+
if ($shortcut === '') {
90+
throw new InvalidOptionDefinitionException(lang('Commands.emptyShortcutName'));
91+
}
92+
93+
if (preg_match('/[^a-zA-Z0-9]/', $shortcut) !== 0) {
94+
throw new InvalidOptionDefinitionException(lang('Commands.invalidShortcutName', [$shortcut]));
95+
}
96+
97+
if (strlen($shortcut) > 1) {
98+
throw new InvalidOptionDefinitionException(lang('Commands.invalidShortcutNameLength', [$shortcut]));
99+
}
100+
}
101+
102+
$this->shortcut = $shortcut;
103+
104+
// A "requires value" or "is array" option implicitly accepts a value.
105+
$acceptsValue = $acceptsValue || $requiresValue || $isArray;
106+
107+
$this->acceptsValue = $acceptsValue;
108+
109+
if ($isArray && $negatable) {
110+
throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionCannotBeArray', [$name]));
111+
}
112+
113+
if ($acceptsValue && $negatable) {
114+
throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionMustNotAcceptValue', [$name]));
115+
}
116+
117+
if ($isArray && ! $requiresValue) {
118+
throw new InvalidOptionDefinitionException(lang('Commands.arrayOptionMustRequireValue', [$name]));
119+
}
120+
121+
if (! $acceptsValue && ! $negatable && $default !== null) {
122+
throw new InvalidOptionDefinitionException(lang('Commands.optionNoValueAndNoDefault', [$name]));
123+
}
124+
125+
if ($requiresValue && ! $isArray && ! is_string($default)) {
126+
throw new InvalidOptionDefinitionException(lang('Commands.optionRequiresStringDefaultValue', [$name]));
127+
}
128+
129+
if ($negatable && ! is_bool($default)) {
130+
throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionInvalidDefault', [$name]));
131+
}
132+
133+
if ($isArray) {
134+
if ($default !== null && ! is_array($default)) {
135+
throw new InvalidOptionDefinitionException(lang('Commands.arrayOptionInvalidDefault', [$name]));
136+
}
137+
138+
if ($default === []) {
139+
throw new InvalidOptionDefinitionException(lang('Commands.arrayOptionEmptyArrayDefault', [$name]));
140+
}
141+
142+
$default ??= [];
143+
}
144+
145+
$this->valueLabel = $acceptsValue ? ($valueLabel ?? $name) : null;
146+
$this->negation = $negatable ? sprintf('no-%s', $name) : null;
147+
$this->default = $acceptsValue || $negatable ? $default : false;
148+
}
149+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\CLI\Input;
15+
16+
use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException;
17+
use CodeIgniter\Test\CIUnitTestCase;
18+
use PHPUnit\Framework\Attributes\CoversClass;
19+
use PHPUnit\Framework\Attributes\DataProvider;
20+
use PHPUnit\Framework\Attributes\Group;
21+
22+
/**
23+
* @internal
24+
*/
25+
#[CoversClass(Argument::class)]
26+
#[Group('Others')]
27+
final class ArgumentTest extends CIUnitTestCase
28+
{
29+
public function testBasicArgumentExposesProperties(): void
30+
{
31+
$argument = new Argument(
32+
name: 'path',
33+
description: 'The path to operate on.',
34+
required: true,
35+
);
36+
37+
$this->assertSame('path', $argument->name);
38+
$this->assertSame('The path to operate on.', $argument->description);
39+
$this->assertTrue($argument->required);
40+
$this->assertFalse($argument->isArray);
41+
$this->assertNull($argument->default);
42+
}
43+
44+
public function testArrayArgumentDefaultsToEmptyArrayWhenOmitted(): void
45+
{
46+
$argument = new Argument(name: 'tags', isArray: true);
47+
48+
$this->assertTrue($argument->isArray);
49+
$this->assertFalse($argument->required);
50+
$this->assertSame([], $argument->default);
51+
}
52+
53+
public function testArrayArgumentRetainsExplicitDefault(): void
54+
{
55+
$argument = new Argument(name: 'tags', isArray: true, default: ['a', 'b']);
56+
57+
$this->assertSame(['a', 'b'], $argument->default);
58+
}
59+
60+
public function testOptionalArgumentRetainsStringDefault(): void
61+
{
62+
$argument = new Argument(name: 'driver', default: 'file');
63+
64+
$this->assertFalse($argument->required);
65+
$this->assertSame('file', $argument->default);
66+
}
67+
68+
/**
69+
* @param array<string, mixed> $parameters
70+
*/
71+
#[DataProvider('provideInvalidDefinitionsAreRejected')]
72+
public function testInvalidDefinitionsAreRejected(string $message, array $parameters): void
73+
{
74+
$this->expectException(InvalidArgumentDefinitionException::class);
75+
$this->expectExceptionMessage($message);
76+
77+
new Argument(...$parameters);
78+
}
79+
80+
/**
81+
* @return iterable<string, array{string, array<string, mixed>}>
82+
*/
83+
public static function provideInvalidDefinitionsAreRejected(): iterable
84+
{
85+
yield 'empty name' => [
86+
'Argument name cannot be empty.',
87+
['name' => ''],
88+
];
89+
90+
yield 'invalid name' => [
91+
'Argument name "invalid name" is not valid.',
92+
['name' => 'invalid name'],
93+
];
94+
95+
yield 'reserved name' => [
96+
'Argument name "extra_arguments" is reserved and cannot be used.',
97+
['name' => 'extra_arguments'],
98+
];
99+
100+
yield 'required array argument' => [
101+
'Array argument "test" cannot be required.',
102+
['name' => 'test', 'required' => true, 'isArray' => true],
103+
];
104+
105+
yield 'required argument with default value' => [
106+
'Argument "test" is required and must not have a default value.',
107+
['name' => 'test', 'required' => true, 'default' => 'value'],
108+
];
109+
110+
yield 'optional argument with null default value' => [
111+
'Argument "test" is optional and must have a default value.',
112+
['name' => 'test'],
113+
];
114+
115+
yield 'array argument with non-array default value' => [
116+
'Array argument "test" must have an array default value or null.',
117+
['name' => 'test', 'isArray' => true, 'default' => 'value'],
118+
];
119+
120+
yield 'non-array argument with array default value' => [
121+
'Argument "test" does not accept an array default value.',
122+
['name' => 'test', 'default' => ['value']],
123+
];
124+
}
125+
}

0 commit comments

Comments
 (0)