1: <?php declare(strict_types=1);
2:
3: namespace Salient\Utility;
4:
5: use Salient\Contract\Core\Arrayable;
6: use Closure;
7: use Countable;
8: use InvalidArgumentException;
9: use Traversable;
10:
11: /**
12: * Convert English words to different forms
13: *
14: * @api
15: */
16: final class Inflect extends AbstractUtility
17: {
18: /**
19: * Inflect placeholders in a string in the singular if a range covers 1
20: * value, or in the plural otherwise
21: *
22: * For example:
23: *
24: * ```php
25: * <?php
26: * $message = Inflect::formatRange($from, $to, '{{#:on:from}} {{#:line}} {{#}}');
27: * ```
28: *
29: * The word used between `$from` and `$to` (default: `to`) can be given
30: * explicitly using the following syntax:
31: *
32: * ```php
33: * <?php
34: * $message = Inflect::formatRange($from, $to, '{{#:at:between}} {{#:value}} {{#:#:and}}');
35: * ```
36: *
37: * @see Inflect::format()
38: *
39: * @param int|float $from
40: * @param int|float $to
41: * @param mixed ...$values Passed to {@see sprintf()} with the inflected
42: * string if given.
43: */
44: public static function formatRange($from, $to, string $format, ...$values): string
45: {
46: if (is_float($from) xor is_float($to)) {
47: throw new InvalidArgumentException('$from and $to must be of the same type');
48: }
49:
50: $singular = $from === $to;
51: $zero = $singular && $from === 0;
52: $one = $singular && $from === 1;
53: $count = $singular ? ($zero ? 0 : 1) : 2;
54:
55: $callback = $singular
56: ? fn(): string =>
57: (string) $from
58: : fn(?string $pluralWord): string =>
59: sprintf('%s %s %s', $from, $pluralWord ?? 'to', $to);
60:
61: if ($zero) {
62: $no = 'no';
63: }
64:
65: $replace = [
66: '#' => $callback,
67: '' => $callback,
68: ];
69:
70: if ($zero || $one) {
71: $replace += [
72: 'no' => $no ?? $callback,
73: 'a' => $no ?? 'a',
74: 'an' => $no ?? 'an',
75: ];
76: } else {
77: $replace += [
78: 'no' => $callback,
79: 'a' => $callback,
80: 'an' => $callback,
81: ];
82: }
83:
84: return self::doFormat($count, $format, $replace, false, ...$values);
85: }
86:
87: /**
88: * Inflect placeholders in a string in the singular if a count is 1, or in
89: * the plural otherwise
90: *
91: * For example:
92: *
93: * ```php
94: * <?php
95: * $message = Inflect::format($count, '{{#}} {{#:entry}} {{#:was}} processed');
96: * ```
97: *
98: * The following words are recognised:
99: *
100: * - `#` (unconditionally replaced with a number)
101: * - `no` (replaced with a number if `$count` is not `0`)
102: * - `a`, `an` (replaced with a number if `$count` is plural, `no` if
103: * `$count` is `0`)
104: * - `are` / `is` (inflected)
105: * - `has` / `have` (inflected)
106: * - `was` / `were` (inflected)
107: *
108: * Other words are inflected by {@see Inflect::plural()} if `$count` is a
109: * value other than `1`, or used without inflection otherwise.
110: *
111: * The plural form of a word can be given explicitly using the following
112: * syntax:
113: *
114: * ```php
115: * <?php
116: * '{{#:matrix:matrices}}';
117: * ```
118: *
119: * @param Traversable<array-key,mixed>|Arrayable<array-key,mixed>|Countable|array<array-key,mixed>|int $count
120: * @param mixed ...$values Passed to {@see sprintf()} with the inflected
121: * string if given.
122: */
123: public static function format($count, string $format, ...$values): string
124: {
125: return self::doFormat(Get::count($count), $format, [], false, ...$values);
126: }
127:
128: /**
129: * Inflect placeholders in a string in the singular if a count is 0 or 1, or
130: * in the plural otherwise
131: *
132: * @param Traversable<array-key,mixed>|Arrayable<array-key,mixed>|Countable|array<array-key,mixed>|int $count
133: * @param mixed ...$values Passed to {@see sprintf()} with the inflected
134: * string if given.
135: */
136: public static function formatWithZeroAsOne($count, string $format, ...$values): string
137: {
138: return self::doFormat(Get::count($count), $format, [], true, ...$values);
139: }
140:
141: /**
142: * @param array<string,(Closure(string|null): string)|string> $replace
143: * @param mixed ...$values
144: */
145: private static function doFormat(int $count, string $format, array $replace, bool $zeroIsSingular, ...$values): string
146: {
147: $zero = $count === 0;
148: $singular = $count === 1 || ($zero && $zeroIsSingular);
149:
150: if ($zero) {
151: $no = 'no';
152: }
153:
154: $replace = array_replace($singular
155: ? [
156: '#' => (string) $count,
157: '' => $no ?? (string) $count,
158: 'no' => $no ?? (string) $count,
159: 'a' => $no ?? 'a',
160: 'an' => $no ?? 'an',
161: 'are' => 'is',
162: 'is' => 'is',
163: 'has' => 'has',
164: 'have' => 'has',
165: 'was' => 'was',
166: 'were' => 'was',
167: ]
168: : [
169: '#' => (string) $count,
170: '' => (string) $count,
171: 'no' => $no ?? (string) $count,
172: 'a' => $no ?? (string) $count,
173: 'an' => $no ?? (string) $count,
174: 'are' => 'are',
175: 'is' => 'are',
176: 'has' => 'have',
177: 'have' => 'have',
178: 'was' => 'were',
179: 'were' => 'were',
180: ], $replace);
181:
182: $format = Regex::replaceCallback(
183: '/\{\{#(?::(?<word>[-a-z0-9_\h]*+|#)(?::(?<plural_word>[-a-z0-9_\h]*+))?)?\}\}/i',
184: function ($matches) use ($singular, $replace): string {
185: $word = $matches['word'];
186: $plural = $matches['plural_word'];
187: if ($word === '') {
188: return $singular ? '' : (string) $plural;
189: }
190: $word ??= '';
191: $word = Get::value($replace[Str::lower($word)]
192: ?? ($singular
193: ? $word
194: : ($plural ?? self::plural($word))), $plural);
195: return $word === $matches['word'] || $matches['word'] === null
196: ? $word
197: : Str::matchCase($word, $matches['word']);
198: },
199: $format,
200: -1,
201: $count,
202: \PREG_UNMATCHED_AS_NULL,
203: );
204:
205: if ($values) {
206: return sprintf($format, ...$values);
207: }
208:
209: return $format;
210: }
211:
212: /**
213: * Get the plural form of a singular noun
214: *
215: * @todo Implement https://users.monash.edu/~damian/papers/HTML/Plurals.html
216: */
217: public static function plural(string $word): string
218: {
219: foreach ([
220: '/(sh?|ch|x|z| (?<! \A phot | \A pian | \A hal ) o) \Z/ix' => ['es', 0],
221: '/[^aeiou] y \Z/ix' => ['ies', -1],
222: '/is \Z/ix' => ['es', -2],
223: '/on \Z/ix' => ['a', -2],
224: ] as $pattern => [$replace, $offset]) {
225: if (Regex::match($pattern, $word)) {
226: if ($offset) {
227: return substr_replace($word, $replace, $offset);
228: }
229: return $word . $replace;
230: }
231: }
232:
233: return $word . 's';
234: }
235:
236: /**
237: * Get the indefinite article ("a" or "an") to use before a word
238: *
239: * Ported from PERL module `Lingua::EN::Inflexion`.
240: *
241: * @link https://metacpan.org/pod/Lingua::EN::Inflexion
242: */
243: public static function indefinite(string $word): string
244: {
245: $ordinalAn = '/\A [aefhilmnorsx] -?th \Z/ix';
246: $ordinalA = '/\A [bcdgjkpqtuvwyz] -?th \Z/ix';
247: $explicitAn = '/\A (?: euler | hour(?!i) | heir | honest | hono )/ix';
248: $singleAn = '/\A [aefhilmnorsx] \Z/ix';
249: $singleA = '/\A [bcdgjkpqtuvwyz] \Z/ix';
250:
251: // Strings of capitals (i.e. abbreviations) that start with a
252: // "vowel-sound" consonant followed by another consonant, and which are
253: // not likely to be real words
254: $abbrevAn = <<<'REGEX'
255: / \A (?!
256: FJO | [HLMNS]Y. | RY[EO] | SQU |
257: ( F[LR]? | [HL] | MN? | N | RH? | S[CHKLMNPTVW]? | X(YL)? ) [AEIOU]
258: )
259: [FHLMNRSX][A-Z] /xms
260: REGEX;
261:
262: // English words beginning with "Y" and followed by a consonant
263: $initialYAn = '/\A y (?: b[lor] | cl[ea] | fere | gg | p[ios] | rou | tt)/xi';
264:
265: // Handle ordinal forms
266: if (Regex::match($ordinalA, $word)) { return 'a'; }
267: if (Regex::match($ordinalAn, $word)) { return 'an'; }
268:
269: // Handle special cases
270: if (Regex::match($explicitAn, $word)) { return 'an'; }
271: if (Regex::match($singleAn, $word)) { return 'an'; }
272: if (Regex::match($singleA, $word)) { return 'a'; }
273:
274: // Handle abbreviations
275: if (Regex::match($abbrevAn, $word)) { return 'an'; }
276: if (Regex::match('/\A [aefhilmnorsx][.-]/xi', $word)) { return 'an'; }
277: if (Regex::match('/\A [a-z][.-]/xi', $word)) { return 'a'; }
278:
279: // Handle consonants
280: if (Regex::match('/\A [^aeiouy] /xi', $word)) { return 'a'; }
281:
282: // Handle special vowel-forms
283: if (Regex::match('/\A e [uw] /xi', $word)) { return 'a'; }
284: if (Regex::match('/\A onc?e \b /xi', $word)) { return 'a'; }
285: if (Regex::match('/\A uni (?: [^nmd] | mo) /xi', $word)) { return 'a'; }
286: if (Regex::match('/\A ut[th] /xi', $word)) { return 'an'; }
287: if (Regex::match('/\A u [bcfhjkqrst] [aeiou] /xi', $word)) { return 'a'; }
288:
289: // Handle special capitals
290: if (Regex::match('/\A U [NK] [AIEO]? /x', $word)) { return 'a'; }
291:
292: // Handle vowels
293: if (Regex::match('/\A [aeiou]/xi', $word)) { return 'an'; }
294:
295: // Handle words beginning with "Y"
296: if (Regex::match($initialYAn, $word)) { return 'an'; }
297:
298: // Otherwise, guess "a"
299: return 'a';
300: }
301: }
302: