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