• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ICanBoogie / Inflector / 11893385119

18 Nov 2024 01:23PM UTC coverage: 94.475% (+4.4%) from 90.058%
11893385119

push

github

olvlvl
Add StaticInflector

20 of 23 new or added lines in 2 files covered. (86.96%)

6 existing lines in 2 files now uncovered.

171 of 181 relevant lines covered (94.48%)

53.41 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.97
/lib/Inflector.php
1
<?php
2

3
/*
4
 * This file is part of the ICanBoogie package.
5
 *
6
 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
namespace ICanBoogie;
13

14
use InvalidArgumentException;
15

16
use function assert;
17
use function is_string;
18

19
/**
20
 * The Inflector transforms words from singular to plural, class names to table names, modularized
21
 * class names to ones without, and class names to foreign keys. Inflections can be localized, the
22
 * default English inflections for pluralization, singularization, and uncountable words are
23
 * kept in `lib/Inflections/en.php`.
24
 *
25
 * @property-read Inflections $inflections Inflections used by the inflector.
26
 */
27
class Inflector
28
{
29
    /**
30
     * Default inflector locale.
31
     */
32
    public const DEFAULT_LOCALE = 'en';
33

34
    /**
35
     * {@see camelize()} option to downcase the first letter.
36
     */
37
    public const DOWNCASE_FIRST_LETTER = true;
38

39
    /**
40
     * {@see camelize()} option to keep the first letter as is.
41
     */
42
    public const UPCASE_FIRST_LETTER = false;
43

44
    /**
45
     * @var array<string, Inflector>
46
     */
47
    private static $inflectors = [];
48

49
    /**
50
     * Returns an inflector for the specified locale.
51
     *
52
     * Note: Inflectors are shared for the same locale. If you need to alter an inflector you
53
     * MUST clone it first.
54
     */
55
    public static function get(string $locale = self::DEFAULT_LOCALE): self
56
    {
57
        return self::$inflectors[$locale]
555✔
58
            ?? self::$inflectors[$locale] = new self(Inflections::get($locale));
555✔
59
    }
60

61
    /**
62
     * Inflections used by the inflector.
63
     *
64
     * @var Inflections
65
     */
66
    private $inflections;
67

68
    public function __construct(?Inflections $inflections = null)
69
    {
70
        $this->inflections = $inflections ?? new Inflections();
7✔
71
    }
7✔
72

73
    /**
74
     * Returns the {@see $inflections} property.
75
     *
76
     * @return mixed
77
     * @throws PropertyNotDefined in an attempt to read an inaccessible property. If the {@see PropertyNotDefined}
78
     * class is not available a {@see InvalidArgumentException} is thrown instead.
79
     */
80
    public function __get(string $property)
81
    {
82
        if ($property === 'inflections') {
8✔
83
            return $this->$property;
8✔
84
        }
85

UNCOV
86
        if (class_exists(PropertyNotDefined::class)) {
×
UNCOV
87
            throw new PropertyNotDefined([ $property, $this ]);
×
88
        } else {
89
            throw new InvalidArgumentException("Property not defined: $property");
×
90
        }
91
    }
92

93
    /**
94
     * Clone inflections.
95
     */
96
    public function __clone()
97
    {
98
        $this->inflections = clone $this->inflections;
7✔
99
    }
7✔
100

101
    /**
102
     * Applies inflection rules for {@see singularize} and {@see pluralize}.
103
     *
104
     * <pre>
105
     * $this->apply_inflections('post', $this->plurals);    // "posts"
106
     * $this->apply_inflections('posts', $this->singulars); // "post"
107
     * </pre>
108
     *
109
     * @param array<string, string> $rules
110
     */
111
    private function apply_inflections(string $word, array $rules): string
112
    {
113
        $rc = $word;
544✔
114

115
        if (!$rc) {
544✔
116
            return $rc;
1✔
117
        }
118

119
        if (preg_match('/\b[[:word:]]+\Z/u', downcase($rc), $matches)) {
543✔
120
            if (isset($this->inflections->uncountables[$matches[0]])) {
543✔
121
                return $rc;
128✔
122
            }
123
        }
124

125
        foreach ($rules as $rule => $replacement) {
416✔
126
            assert(is_string($rc));
127

128
            $rc = preg_replace($rule, $replacement, $rc, -1, $count);
416✔
129

130
            if ($count) {
416✔
131
                break;
416✔
132
            }
133
        }
134

135
        assert(is_string($rc));
136

137
        return $rc;
416✔
138
    }
139

140
    /**
141
     * Returns the plural form of the word in the string.
142
     *
143
     * <pre>
144
     * $this->pluralize('post');       // "posts"
145
     * $this->pluralize('children');   // "child"
146
     * $this->pluralize('sheep');      // "sheep"
147
     * $this->pluralize('words');      // "words"
148
     * $this->pluralize('CamelChild'); // "CamelChildren"
149
     * </pre>
150
     */
151
    public function pluralize(string $word): string
152
    {
153
        return $this->apply_inflections($word, $this->inflections->plurals);
274✔
154
    }
155

156
    /**
157
     * The reverse of {@see pluralize}, returns the singular form of a word in a string.
158
     *
159
     * <pre>
160
     * $this->singularize('posts');         // "post"
161
     * $this->singularize('children');      // "child"
162
     * $this->singularize('sheep');         // "sheep"
163
     * $this->singularize('word');          // "word"
164
     * $this->singularize('CamelChildren'); // "CamelChild"
165
     * </pre>
166
     */
167
    public function singularize(string $word): string
168
    {
169
        return $this->apply_inflections($word, $this->inflections->singulars);
272✔
170
    }
171

172
    /**
173
     * By default, {@see camelize} converts strings to UpperCamelCase.
174
     *
175
     * {@see camelize} will also convert "/" to "\" which is useful for converting paths to
176
     * namespaces.
177
     *
178
     * <pre>
179
     * $this->camelize('active_model');                // 'ActiveModel'
180
     * $this->camelize('active_model', true);          // 'activeModel'
181
     * $this->camelize('active_model/errors');         // 'ActiveModel\Errors'
182
     * $this->camelize('active_model/errors', true);   // 'activeModel\Errors'
183
     * </pre>
184
     *
185
     * As a rule of thumb you can think of {@see camelize} as the inverse of {@see underscore},
186
     * though there are cases where that does not hold:
187
     *
188
     * <pre>
189
     * $this->camelize($this->underscore('SSLError')); // "SslError"
190
     * </pre>
191
     *
192
     * @param bool $downcase_first_letter One of {@see UPCASE_FIRST_LETTER},
193
     * {@see DOWNCASE_FIRST_LETTER}.
194
     */
195
    public function camelize(string $term, bool $downcase_first_letter = self::UPCASE_FIRST_LETTER): string
196
    {
197
        $string = $term;
12✔
198
        $acronyms = $this->inflections->acronyms;
12✔
199

200
        if ($downcase_first_letter) {
12✔
201
            $string = preg_replace_callback(
3✔
202
                '/^(?:'
203
                . trim($this->inflections->acronym_regex, '/')
3✔
204
                . '(?=\b|[[:upper:]_])|\w)/u',
3✔
205
                function (array $matches): string {
206
                    return downcase($matches[0]);
3✔
207
                },
3✔
208
                $string,
3✔
209
                1
3✔
210
            );
211
        } else {
212
            $string = preg_replace_callback(
9✔
213
                '/^[[:lower:]\d]*/u',
9✔
214
                function (array $matches) use ($acronyms): string {
215
                    $m = $matches[0];
9✔
216

217
                    return !empty($acronyms[$m]) ? $acronyms[$m] : capitalize($m, true);
9✔
218
                },
9✔
219
                $string,
9✔
220
                1
9✔
221
            );
222
        }
223

224
        assert(is_string($string));
225

226
        $string = preg_replace_callback(
12✔
227
            '/(?:_|-|(\/))([[:alnum:]]*)/u',
12✔
228
            function (array $matches) use ($acronyms): string {
229
                [ , $m1, $m2 ] = $matches;
10✔
230

231
                return $m1 . ($acronyms[$m2] ?? capitalize($m2, true));
10✔
232
            },
12✔
233
            $string
12✔
234
        );
235

236
        assert(is_string($string));
237

238
        return str_replace('/', '\\', $string);
12✔
239
    }
240

241
    /**
242
     * Makes an underscored, lowercase form from the expression in the string.
243
     *
244
     * Changes "\" to "/" to convert namespaces to paths.
245
     *
246
     * <pre>
247
     * $this->underscore('ActiveModel');        // 'active_model'
248
     * $this->underscore('ActiveModel\Errors'); // 'active_model/errors'
249
     * </pre>
250
     *
251
     * As a rule of thumb you can think of {@see underscore} as the inverse of {@see camelize()},
252
     * though there are cases where that does not hold:
253
     *
254
     * <pre>
255
     * $this->camelize($this->underscore('SSLError')); // "SslError"
256
     * </pre>
257
     */
258
    public function underscore(string $camel_cased_word): string
259
    {
260
        $word = $camel_cased_word;
13✔
261
        $word = str_replace('\\', '/', $word);
13✔
262
        $word = preg_replace_callback(
13✔
263
            '/(?:([[:alpha:]\d])|^)('
264
            . trim($this->inflections->acronym_regex, '/')
13✔
265
            . ')(?=\b|[^[:lower:]])/u',
13✔
266
            function (array $matches): string {
267
                [ , $m1, $m2 ] = $matches;
2✔
268

269
                return $m1 . ($m1 ? '_' : '') . downcase($m2);
2✔
270
            },
13✔
271
            $word
13✔
272
        );
273

274
        // @phpstan-ignore-next-line
275
        $word = preg_replace('/([[:upper:]\d]+)([[:upper:]][[:lower:]])/u', '\1_\2', $word);
13✔
276
        // @phpstan-ignore-next-line
277
        $word = preg_replace('/([[:lower:]\d])([[:upper:]])/u', '\1_\2', $word);
13✔
278
        // @phpstan-ignore-next-line
279
        $word = preg_replace('/\-+|\s+/', '_', $word);
13✔
280

281
        // @phpstan-ignore-next-line
282
        return downcase($word);
13✔
283
    }
284

285
    /**
286
     * Capitalizes the first word and turns underscores into spaces and strips a trailing "_id",
287
     * if any. Like {@see titleize()}, this is meant for creating pretty output.
288
     *
289
     * <pre>
290
     * $this->humanize('employee_salary'); // "Employee salary"
291
     * $this->humanize('author_id');       // "Author"
292
     * </pre>
293
     */
294
    public function humanize(string $lower_case_and_underscored_word): string
295
    {
296
        $result = $lower_case_and_underscored_word;
10✔
297

298
        foreach ($this->inflections->humans as $rule => $replacement) {
10✔
299
            // @phpstan-ignore-next-line
300
            $result = preg_replace($rule, $replacement, $result, 1, $count);
2✔
301

302
            if ($count) {
2✔
303
                break;
2✔
304
            }
305
        }
306

307
        $acronyms = $this->inflections->acronyms;
10✔
308

309
        // @phpstan-ignore-next-line
310
        $result = preg_replace('/_id$/', "", $result);
10✔
311
        // @phpstan-ignore-next-line
312
        $result = strtr($result, '_', ' ');
10✔
313
        $result = preg_replace_callback(
10✔
314
            '/([[:alnum:]]+)/u',
10✔
315
            function (array $matches) use ($acronyms): string {
316
                [ $m ] = $matches;
10✔
317

318
                return !empty($acronyms[$m]) ? $acronyms[$m] : downcase($m);
10✔
319
            },
10✔
320
            $result
10✔
321
        );
322

323
        assert(is_string($result));
324

325
        // @phpstan-ignore-next-line
326
        return preg_replace_callback('/^[[:lower:]]/u', function (array $matches): string {
327
            return upcase($matches[0]);
10✔
328
        }, $result);
10✔
329
    }
330

331
    /**
332
     * Capitalizes all the words and replaces some characters in the string to create a nicer
333
     * looking title. {@see titleize()} is meant for creating pretty output. It is not used in
334
     * the Rails internals.
335
     *
336
     * <pre>
337
     * $this->titleize('man from the boondocks');  // "Man From The Boondocks"
338
     * $this->titleize('x-men: the last stand');   // "X Men: The Last Stand"
339
     * $this->titleize('TheManWithoutAPast');      // "The Man Without A Past"
340
     * $this->titleize('raiders_of_the_lost_ark'); // "Raiders Of The Lost Ark"
341
     * </pre>
342
     */
343
    public function titleize(string $str): string
344
    {
345
        $str = $this->underscore($str);
4✔
346
        $str = $this->humanize($str);
4✔
347

348
        // @phpstan-ignore-next-line
349
        return preg_replace_callback('/\b(?<![\'’`])[[:lower:]]/u', function (array $matches): string {
350
            return upcase($matches[0]);
4✔
351
        }, $str);
4✔
352
    }
353

354
    /**
355
     * Replaces underscores with dashes in the string.
356
     *
357
     * <pre>
358
     * $this->dasherize('puni_puni'); // "puni-puni"
359
     * </pre>
360
     */
361
    public function dasherize(string $underscored_word): string
362
    {
363
        return strtr($underscored_word, '_', '-');
5✔
364
    }
365

366
    /**
367
     * Makes an hyphenated, lowercase form from the expression in the string.
368
     *
369
     * This is a combination of {@see underscore} and {@see dasherize}.
370
     */
371
    public function hyphenate(string $str): string
372
    {
373
        return $this->dasherize($this->underscore($str));
3✔
374
    }
375

376
    /**
377
     * Returns the suffix that should be added to a number to denote the position in an ordered
378
     * sequence such as 1st, 2nd, 3rd, 4th.
379
     *
380
     * <pre>
381
     * $this->ordinal(1);     // "st"
382
     * $this->ordinal(2);     // "nd"
383
     * $this->ordinal(1002);  // "nd"
384
     * $this->ordinal(1003);  // "rd"
385
     * $this->ordinal(-11);   // "th"
386
     * $this->ordinal(-1021); // "st"
387
     * </pre>
388
     */
389
    public function ordinal(int $number): string
390
    {
391
        $abs_number = abs($number);
2✔
392

393
        if (($abs_number % 100) > 10 && ($abs_number % 100) < 14) {
2✔
394
            return 'th';
2✔
395
        }
396

397
        switch ($abs_number % 10) {
2✔
398
            case 1:
2✔
399
                return "st";
2✔
400
            case 2:
2✔
401
                return "nd";
2✔
402
            case 3:
2✔
403
                return "rd";
2✔
404
            default:
405
                return "th";
2✔
406
        }
407
    }
408

409
    /**
410
     * Turns a number into an ordinal string used to denote the position in an ordered sequence
411
     * such as 1st, 2nd, 3rd, 4th.
412
     *
413
     * <pre>
414
     * $this->ordinalize(1);     // "1st"
415
     * $this->ordinalize(2);     // "2nd"
416
     * $this->ordinalize(1002);  // "1002nd"
417
     * $this->ordinalize(1003);  // "1003rd"
418
     * $this->ordinalize(-11);   // "-11th"
419
     * $this->ordinalize(-1021); // "-1021st"
420
     * </pre>
421
     */
422
    public function ordinalize(int $number): string
423
    {
424
        return $number . $this->ordinal($number);
1✔
425
    }
426

427
    /**
428
     * Returns true if the word is uncountable, false otherwise.
429
     *
430
     * <pre>
431
     * $this->is_uncountable('advice');    // true
432
     * $this->is_uncountable('weather');   // true
433
     * $this->is_uncountable('cat');       // false
434
     * </pre>
435
     */
436
    public function is_uncountable(string $word): bool
437
    {
438
        $rc = $word;
1✔
439

440
        return $rc
1✔
441
            && preg_match('/\b[[:word:]]+\Z/u', downcase($rc), $matches)
1✔
442
            && isset($this->inflections->uncountables[$matches[0]]);
1✔
443
    }
444
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc