Skip to content

Commit f2bf03d

Browse files
committed
Move and refactor more Convert methods
- Move `Convert::toInt()` to `Get::integer()` - Fix issue where `Get::value()` may inadvertently call an arbitrary function by only calling `Closure` instances - Add `Str::matchCase()` - In `Inflect::format()`: - Allow arbitrary placement of whitespace and hyphens in words - Allow words to be empty - Add `#` as a recognised word - Match the case of the placeholder - Add `Inflect::formatRange()` - Remove `Convert::pluralRange()` in favour of `Inflect::formatRange()`
1 parent ce36071 commit f2bf03d

File tree

7 files changed

+338
-66
lines changed

7 files changed

+338
-66
lines changed

src/Utility/Convert.php

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,6 @@
2020
*/
2121
final class Convert extends Utility
2222
{
23-
/**
24-
* Convert a value to an integer, preserving null
25-
*
26-
* @param mixed $value
27-
*/
28-
public static function toInt($value): ?int
29-
{
30-
if ($value === null) {
31-
return null;
32-
}
33-
return (int) $value;
34-
}
35-
3623
/**
3724
* Expand tabs to spaces
3825
*/
@@ -123,24 +110,6 @@ public static function ellipsize(string $value, int $length): string
123110
return $value;
124111
}
125112

126-
/**
127-
* Get a phrase like "between lines 3 and 11" or "on platform 23"
128-
*
129-
* @param string|null $plural `"{$singular}s"` is used if `$plural` is
130-
* `null`.
131-
*/
132-
public static function pluralRange(
133-
int $from,
134-
int $to,
135-
string $singular,
136-
?string $plural = null,
137-
string $preposition = 'on'
138-
): string {
139-
return $to - $from
140-
? sprintf('between %s %d and %d', $plural === null ? $singular . 's' : $plural, $from, $to)
141-
: sprintf('%s %s %d', $preposition, $singular, $from);
142-
}
143-
144113
/**
145114
* Convert a list of "key=value" strings to an array like ["key" => "value"]
146115
*

src/Utility/Get.php

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Lkrms\Support\Catalog\RegularExpression as Regex;
1111
use Lkrms\Utility\Catalog\CopyFlag;
1212
use Psr\Container\ContainerInterface as PsrContainerInterface;
13+
use Closure;
1314
use DateTimeInterface;
1415
use DateTimeZone;
1516
use ReflectionClass;
@@ -47,6 +48,21 @@ public static function boolean($value): ?bool
4748
return (bool) $value;
4849
}
4950

51+
/**
52+
* Cast a value to integer, preserving null
53+
*
54+
* @param mixed $value
55+
* @return ($value is null ? null : int)
56+
*/
57+
public static function integer($value): ?int
58+
{
59+
if ($value === null) {
60+
return null;
61+
}
62+
63+
return (int) $value;
64+
}
65+
5066
/**
5167
* Convert a scalar to the type it appears to be
5268
*
@@ -65,13 +81,14 @@ public static function apparent($value, bool $toFloat = true, bool $toBool = tru
6581
if (!is_string($value)) {
6682
throw new InvalidArgumentTypeException(1, 'value', 'int|float|string|bool|null', $value);
6783
}
68-
if (Str::lower(trim($value)) === 'null') {
84+
$trimmed = trim($value);
85+
if (Str::lower($trimmed) === 'null') {
6986
return null;
7087
}
7188
if (Pcre::match('/^' . Regex::INTEGER_STRING . '$/', $value)) {
7289
return (int) $value;
7390
}
74-
if ($toFloat && is_numeric($value)) {
91+
if ($toFloat && is_numeric($trimmed)) {
7592
return (float) $value;
7693
}
7794
if ($toBool && Pcre::match(
@@ -86,17 +103,17 @@ public static function apparent($value, bool $toFloat = true, bool $toBool = tru
86103
}
87104

88105
/**
89-
* Resolve a callable to its return value
106+
* Resolve a closure to its return value
90107
*
91108
* @template T
92109
*
93-
* @param (callable(mixed...): T)|T $value
94-
* @param mixed ...$args Passed to `$value` if it is callable.
110+
* @param (Closure(mixed...): T)|T $value
111+
* @param mixed ...$args Passed to `$value` if it is a closure.
95112
* @return T
96113
*/
97114
public static function value($value, ...$args)
98115
{
99-
if (is_callable($value)) {
116+
if ($value instanceof Closure) {
100117
return $value(...$args);
101118
}
102119
return $value;

src/Utility/Inflect.php

Lines changed: 101 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,83 @@
33
namespace Lkrms\Utility;
44

55
use Lkrms\Concept\Utility;
6+
use Lkrms\Exception\InvalidArgumentException;
7+
use Closure;
68

79
/**
810
* Inflect English words
911
*/
10-
class Inflect extends Utility
12+
final class Inflect extends Utility
1113
{
14+
/**
15+
* Inflect placeholders in a string in the singular if a range covers 1
16+
* value, or in the plural otherwise
17+
*
18+
* For example:
19+
*
20+
* ```php
21+
* <?php
22+
* $message = Inflect::formatRange('{{#:on:from}} {{#:line}} {{#}}', $from, $to);
23+
* ```
24+
*
25+
* The word used between `$from` and `$to` (default: `to`) can be given
26+
* explicitly using the following syntax:
27+
*
28+
* ```php
29+
* <?php
30+
* $message = Inflect::formatRange('{{#:at:between}} {{#:value}} {{#:#:and}}', $from, $to);
31+
* ```
32+
*
33+
* @see Inflect::format()
34+
*
35+
* @param int|float $from
36+
* @param int|float $to
37+
* @param mixed ...$values Passed to {@see sprintf()} with the inflected
38+
* string if given.
39+
*/
40+
public static function formatRange(string $format, $from, $to, ...$values): string
41+
{
42+
if (is_float($from) xor is_float($to)) {
43+
throw new InvalidArgumentException('$from and $to must be of the same type');
44+
}
45+
46+
$singular = $from === $to;
47+
$zero = $singular && $from === 0;
48+
$one = $singular && $from === 1;
49+
$count = $singular ? ($zero ? 0 : 1) : 2;
50+
51+
$callback = $singular
52+
? fn(): string =>
53+
(string) $from
54+
: fn(?string $pluralWord): string =>
55+
sprintf('%s %s %s', $from, $pluralWord ?? 'to', $to);
56+
57+
if ($zero) {
58+
$no = 'no';
59+
}
60+
61+
$replace = [
62+
'#' => $callback,
63+
'' => $callback,
64+
];
65+
66+
if ($zero || $one) {
67+
$replace += [
68+
'no' => $no ?? $callback,
69+
'a' => $no ?? 'a',
70+
'an' => $no ?? 'an',
71+
];
72+
} else {
73+
$replace += [
74+
'no' => $callback,
75+
'a' => $callback,
76+
'an' => $callback,
77+
];
78+
}
79+
80+
return self::doFormat($format, $count, $replace, false, ...$values);
81+
}
82+
1283
/**
1384
* Inflect placeholders in a string in the singular if a count is 1, or in
1485
* the plural otherwise
@@ -20,8 +91,9 @@ class Inflect extends Utility
2091
* $message = Inflect::format('{{#}} {{#:entry}} {{#:was}} processed', $count);
2192
* ```
2293
*
23-
* The following verbs are recognised:
94+
* The following words are recognised:
2495
*
96+
* - `#` (unconditionally replaced with a number)
2597
* - `no` (replaced with a number if `$count` is not `0`)
2698
* - `a`, `an` (replaced with a number if `$count` is plural, `no` if
2799
* `$count` is `0`)
@@ -40,30 +112,31 @@ class Inflect extends Utility
40112
* '{{#:matrix:matrices}}';
41113
* ```
42114
*
43-
* @param mixed ...$values Passed to {@see sprintf()} with the inflected string
44-
* if given.
115+
* @param mixed ...$values Passed to {@see sprintf()} with the inflected
116+
* string if given.
45117
*/
46118
public static function format(string $format, int $count, ...$values): string
47119
{
48-
return self::doFormat($format, $count, false, ...$values);
120+
return self::doFormat($format, $count, [], false, ...$values);
49121
}
50122

51123
/**
52124
* Inflect placeholders in a string in the singular if a count is 0 or 1, or
53125
* in the plural otherwise
54126
*
55-
* @param mixed ...$values Passed to {@see sprintf()} with the inflected string
56-
* if given.
127+
* @param mixed ...$values Passed to {@see sprintf()} with the inflected
128+
* string if given.
57129
*/
58130
public static function formatWithSingularZero(string $format, int $count, ...$values): string
59131
{
60-
return self::doFormat($format, $count, true, ...$values);
132+
return self::doFormat($format, $count, [], true, ...$values);
61133
}
62134

63135
/**
136+
* @param array<string,(Closure(string|null): string)|string> $replace
64137
* @param mixed ...$values
65138
*/
66-
private static function doFormat(string $format, int $count, bool $zeroIsSingular, ...$values): string
139+
private static function doFormat(string $format, int $count, array $replace, bool $zeroIsSingular, ...$values): string
67140
{
68141
$zero = $count === 0;
69142
$singular = $count === 1 || ($zero && $zeroIsSingular);
@@ -72,8 +145,9 @@ private static function doFormat(string $format, int $count, bool $zeroIsSingula
72145
$no = 'no';
73146
}
74147

75-
$replace = $singular
148+
$replace = array_replace($singular
76149
? [
150+
'#' => (string) $count,
77151
'' => $no ?? (string) $count,
78152
'no' => $no ?? (string) $count,
79153
'a' => $no ?? 'a',
@@ -86,6 +160,7 @@ private static function doFormat(string $format, int $count, bool $zeroIsSingula
86160
'were' => 'was',
87161
]
88162
: [
163+
'#' => (string) $count,
89164
'' => (string) $count,
90165
'no' => $no ?? (string) $count,
91166
'a' => $no ?? (string) $count,
@@ -96,18 +171,29 @@ private static function doFormat(string $format, int $count, bool $zeroIsSingula
96171
'have' => 'have',
97172
'was' => 'were',
98173
'were' => 'were',
99-
];
174+
], $replace);
100175

101176
$format = Pcre::replaceCallback(
102-
'/\{\{#(?::(?<word>[a-z]++(?:(?:\h++|-)[a-z]++)*+)(?::(?<plural_word>[a-z]++(?:(?:\h++|-)[a-z]++)*+))?)?\}\}/i',
177+
'/\{\{#(?::(?<word>[-a-z0-9_\h]*+|#)(?::(?<plural_word>[-a-z0-9_\h]*+))?)?\}\}/i',
103178
function (array $match) use ($singular, $replace): string {
104-
$word = $match['word'] ?? '';
105-
return $replace[Str::lower($word)]
179+
$word = $match['word'];
180+
$plural = $match['plural_word'];
181+
if ($word === '') {
182+
return $singular ? '' : (string) $plural;
183+
}
184+
$word ??= (string) $word;
185+
$word = Get::value($replace[Str::lower($word)]
106186
?? ($singular
107187
? $word
108-
: $match['plural_word'] ?? self::plural($word));
188+
: ($plural ?? self::plural($word))), $plural);
189+
return $word === $match['word'] || $match['word'] === null
190+
? $word
191+
: Str::matchCase($word, $match['word']);
109192
},
110193
$format,
194+
-1,
195+
$count,
196+
\PREG_UNMATCHED_AS_NULL,
111197
);
112198

113199
if ($values) {

src/Utility/Str.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,40 @@ public static function upperFirst(string $string): string
5757
return $string;
5858
}
5959

60+
/**
61+
* Match an ASCII string's case to another string
62+
*/
63+
public static function matchCase(string $string, string $match): string
64+
{
65+
$match = trim($match);
66+
67+
if ($match === '') {
68+
return $string;
69+
}
70+
71+
$upper = strpbrk($match, Char::ALPHABETIC_UPPER);
72+
$hasUpper = $upper !== false;
73+
$hasLower = strpbrk($match, Char::ALPHABETIC_LOWER) !== false;
74+
75+
if ($hasUpper && !$hasLower && strlen($match) > 1) {
76+
return self::upper($string);
77+
}
78+
79+
if (!$hasUpper && $hasLower) {
80+
return self::lower($string);
81+
}
82+
83+
if (
84+
// @phpstan-ignore-next-line
85+
(!$hasUpper && !$hasLower) ||
86+
$upper !== $match
87+
) {
88+
return $string;
89+
}
90+
91+
return self::upperFirst(self::lower($string));
92+
}
93+
6094
/**
6195
* Apply an end-of-line sequence to a string
6296
*/

tests/unit/Utility/GetTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ public static function booleanProvider(): array
5454
];
5555
}
5656

57+
/**
58+
* @dataProvider integerProvider
59+
*
60+
* @param mixed $value
61+
*/
62+
public function testInteger(?int $expected, $value): void
63+
{
64+
$this->assertSame($expected, Get::integer($value));
65+
}
66+
67+
/**
68+
* @return array<string,array{int|null,mixed}>
69+
*/
70+
public static function integerProvider(): array
71+
{
72+
return [
73+
'null' => [null, null],
74+
'false' => [0, false],
75+
'true' => [1, true],
76+
'5' => [5, 5],
77+
'5.5' => [5, 5.5],
78+
"'5'" => [5, '5'],
79+
"'5.5'" => [5, '5.5'],
80+
"'foo'" => [0, 'foo'],
81+
];
82+
}
83+
5784
/**
5885
* @dataProvider apparentProvider
5986
*
@@ -99,6 +126,7 @@ public function testApparentWithInvalidValue(): void
99126
{
100127
$this->expectException(InvalidArgumentException::class);
101128
$this->expectExceptionMessage('Argument #1 ($value) must be of type int|float|string|bool|null, stdClass given');
129+
// @phpstan-ignore-next-line
102130
Get::apparent(new stdClass());
103131
}
104132

0 commit comments

Comments
 (0)