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

ICanBoogie / Inflector / 15253860611

26 May 2025 12:24PM UTC coverage: 95.977% (+1.5%) from 94.475%
15253860611

Pull #45

github

olvlvl
Avoid using helpers

Helpers are tested with a separate job
Pull Request #45: Avoid using helpers

167 of 174 relevant lines covered (95.98%)

58.22 hits per line

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

97.06
/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]
546✔
58
            ?? self::$inflectors[$locale] = new self(Inflections::get($locale));
546✔
59
    }
60

61
    private static function downcase(string $str): string
62
    {
63
        return StaticInflector::downcase($str);
558✔
64
    }
65

66
    private static function upcase(string $str): string
67
    {
68
        return StaticInflector::upcase($str);
8✔
69
    }
70

71
    private static function capitalize(string $str, bool $downcase_first_letter = false): string
72
    {
73
        return StaticInflector::capitalize($str, $downcase_first_letter);
9✔
74
    }
75

76
    /**
77
     * Inflections used by the inflector.
78
     *
79
     * @var Inflections
80
     */
81
    private $inflections;
82

83
    public function __construct(?Inflections $inflections = null)
84
    {
85
        $this->inflections = $inflections ?? new Inflections();
7✔
86
    }
7✔
87

88
    /**
89
     * Returns the {@see $inflections} property.
90
     *
91
     * @return mixed
92
     * @throws PropertyNotDefined in an attempt to read an inaccessible property. If the {@see PropertyNotDefined}
93
     * class is not available a {@see InvalidArgumentException} is thrown instead.
94
     */
95
    public function __get(string $property)
96
    {
97
        if ($property === 'inflections') {
8✔
98
            return $this->$property;
8✔
99
        }
100

101
        if (class_exists(PropertyNotDefined::class)) {
×
102
            throw new PropertyNotDefined([ $property, $this ]);
×
103
        } else {
104
            throw new InvalidArgumentException("Property not defined: $property");
×
105
        }
106
    }
107

108
    /**
109
     * Clone inflections.
110
     */
111
    public function __clone()
112
    {
113
        $this->inflections = clone $this->inflections;
7✔
114
    }
7✔
115

116
    /**
117
     * Applies inflection rules for {@see singularize} and {@see pluralize}.
118
     *
119
     * <pre>
120
     * $this->apply_inflections('post', $this->plurals);    // "posts"
121
     * $this->apply_inflections('posts', $this->singulars); // "post"
122
     * </pre>
123
     *
124
     * @param array<string, string> $rules
125
     */
126
    private function apply_inflections(string $word, array $rules): string
127
    {
128
        $rc = $word;
540✔
129

130
        if (!$rc) {
540✔
131
            return $rc;
1✔
132
        }
133

134
        if (preg_match('/\b[[:word:]]+\Z/u', self::downcase($rc), $matches)) {
539✔
135
            if (isset($this->inflections->uncountables[$matches[0]])) {
539✔
136
                return $rc;
128✔
137
            }
138
        }
139

140
        foreach ($rules as $rule => $replacement) {
412✔
141
            assert(is_string($rc));
142

143
            $rc = preg_replace($rule, $replacement, $rc, -1, $count);
412✔
144

145
            if ($count) {
412✔
146
                break;
412✔
147
            }
148
        }
149

150
        assert(is_string($rc));
151

152
        return $rc;
412✔
153
    }
154

155
    /**
156
     * Returns the plural form of the word in the string.
157
     *
158
     * <pre>
159
     * $this->pluralize('post');       // "posts"
160
     * $this->pluralize('children');   // "child"
161
     * $this->pluralize('sheep');      // "sheep"
162
     * $this->pluralize('words');      // "words"
163
     * $this->pluralize('CamelChild'); // "CamelChildren"
164
     * </pre>
165
     */
166
    public function pluralize(string $word): string
167
    {
168
        return $this->apply_inflections($word, $this->inflections->plurals);
272✔
169
    }
170

171
    /**
172
     * The reverse of {@see pluralize}, returns the singular form of a word in a string.
173
     *
174
     * <pre>
175
     * $this->singularize('posts');         // "post"
176
     * $this->singularize('children');      // "child"
177
     * $this->singularize('sheep');         // "sheep"
178
     * $this->singularize('word');          // "word"
179
     * $this->singularize('CamelChildren'); // "CamelChild"
180
     * </pre>
181
     */
182
    public function singularize(string $word): string
183
    {
184
        return $this->apply_inflections($word, $this->inflections->singulars);
270✔
185
    }
186

187
    /**
188
     * By default, {@see camelize} converts strings to UpperCamelCase.
189
     *
190
     * {@see camelize} will also convert "/" to "\" which is useful for converting paths to
191
     * namespaces.
192
     *
193
     * <pre>
194
     * $this->camelize('active_model');                // 'ActiveModel'
195
     * $this->camelize('active_model', true);          // 'activeModel'
196
     * $this->camelize('active_model/errors');         // 'ActiveModel\Errors'
197
     * $this->camelize('active_model/errors', true);   // 'activeModel\Errors'
198
     * </pre>
199
     *
200
     * As a rule of thumb you can think of {@see camelize} as the inverse of {@see underscore},
201
     * though there are cases where that does not hold:
202
     *
203
     * <pre>
204
     * $this->camelize($this->underscore('SSLError')); // "SslError"
205
     * </pre>
206
     *
207
     * @param bool $downcase_first_letter One of {@see UPCASE_FIRST_LETTER},
208
     * {@see DOWNCASE_FIRST_LETTER}.
209
     */
210
    public function camelize(string $term, bool $downcase_first_letter = self::UPCASE_FIRST_LETTER): string
211
    {
212
        $string = $term;
11✔
213
        $acronyms = $this->inflections->acronyms;
11✔
214

215
        if ($downcase_first_letter) {
11✔
216
            $string = preg_replace_callback(
3✔
217
                '/^(?:'
218
                . trim($this->inflections->acronym_regex, '/')
3✔
219
                . '(?=\b|[[:upper:]_])|\w)/u',
3✔
220
                function (array $matches): string {
221
                    return self::downcase($matches[0]);
3✔
222
                },
3✔
223
                $string,
3✔
224
                1
3✔
225
            );
226
        } else {
227
            $string = preg_replace_callback(
8✔
228
                '/^[[:lower:]\d]*/u',
8✔
229
                function (array $matches) use ($acronyms): string {
230
                    $m = $matches[0];
8✔
231

232
                    return !empty($acronyms[$m]) ? $acronyms[$m] : self::capitalize($m, true);
8✔
233
                },
8✔
234
                $string,
8✔
235
                1
8✔
236
            );
237
        }
238

239
        assert(is_string($string));
240

241
        $string = preg_replace_callback(
11✔
242
            '/(?:_|-|(\/))([[:alnum:]]*)/u',
11✔
243
            function (array $matches) use ($acronyms): string {
244
                [ , $m1, $m2 ] = $matches;
9✔
245

246
                return $m1 . ($acronyms[$m2] ?? self::capitalize($m2, true));
9✔
247
            },
11✔
248
            $string
11✔
249
        );
250

251
        assert(is_string($string));
252

253
        return str_replace('/', '\\', $string);
11✔
254
    }
255

256
    /**
257
     * Makes an underscored, lowercase form from the expression in the string.
258
     *
259
     * Changes "\" to "/" to convert namespaces to paths.
260
     *
261
     * <pre>
262
     * $this->underscore('ActiveModel');        // 'active_model'
263
     * $this->underscore('ActiveModel\Errors'); // 'active_model/errors'
264
     * </pre>
265
     *
266
     * As a rule of thumb you can think of {@see underscore} as the inverse of {@see camelize()},
267
     * though there are cases where that does not hold:
268
     *
269
     * <pre>
270
     * $this->camelize($this->underscore('SSLError')); // "SslError"
271
     * </pre>
272
     */
273
    public function underscore(string $camel_cased_word): string
274
    {
275
        $word = $camel_cased_word;
10✔
276
        $word = str_replace('\\', '/', $word);
10✔
277
        $word = preg_replace_callback(
10✔
278
            '/(?:([[:alpha:]\d])|^)('
279
            . trim($this->inflections->acronym_regex, '/')
10✔
280
            . ')(?=\b|[^[:lower:]])/u',
10✔
281
            function (array $matches): string {
282
                [ , $m1, $m2 ] = $matches;
2✔
283

284
                return $m1 . ($m1 ? '_' : '') . self::downcase($m2);
2✔
285
            },
10✔
286
            $word
10✔
287
        );
288

289
        // @phpstan-ignore-next-line
290
        $word = preg_replace('/([[:upper:]\d]+)([[:upper:]][[:lower:]])/u', '\1_\2', $word);
10✔
291
        // @phpstan-ignore-next-line
292
        $word = preg_replace('/([[:lower:]\d])([[:upper:]])/u', '\1_\2', $word);
10✔
293
        // @phpstan-ignore-next-line
294
        $word = preg_replace('/\-+|\s+/', '_', $word);
10✔
295

296
        // @phpstan-ignore-next-line
297
        return self::downcase($word);
10✔
298
    }
299

300
    /**
301
     * Capitalizes the first word and turns underscores into spaces and strips a trailing "_id",
302
     * if any. Like {@see titleize()}, this is meant for creating pretty output.
303
     *
304
     * <pre>
305
     * $this->humanize('employee_salary'); // "Employee salary"
306
     * $this->humanize('author_id');       // "Author"
307
     * </pre>
308
     */
309
    public function humanize(string $lower_case_and_underscored_word): string
310
    {
311
        $result = $lower_case_and_underscored_word;
8✔
312

313
        foreach ($this->inflections->humans as $rule => $replacement) {
8✔
314
            // @phpstan-ignore-next-line
315
            $result = preg_replace($rule, $replacement, $result, 1, $count);
2✔
316

317
            if ($count) {
2✔
318
                break;
2✔
319
            }
320
        }
321

322
        $acronyms = $this->inflections->acronyms;
8✔
323

324
        // @phpstan-ignore-next-line
325
        $result = preg_replace('/_id$/', "", $result);
8✔
326
        // @phpstan-ignore-next-line
327
        $result = strtr($result, '_', ' ');
8✔
328
        $result = preg_replace_callback(
8✔
329
            '/([[:alnum:]]+)/u',
8✔
330
            function (array $matches) use ($acronyms): string {
331
                [ $m ] = $matches;
8✔
332

333
                return !empty($acronyms[$m]) ? $acronyms[$m] : self::downcase($m);
8✔
334
            },
8✔
335
            $result
8✔
336
        );
337

338
        assert(is_string($result));
339

340
        // @phpstan-ignore-next-line
341
        return preg_replace_callback('/^[[:lower:]]/u', function (array $matches): string {
342
            return self::upcase($matches[0]);
8✔
343
        }, $result);
8✔
344
    }
345

346
    /**
347
     * Capitalizes all the words and replaces some characters in the string to create a nicer
348
     * looking title. {@see titleize()} is meant for creating pretty output. It is not used in
349
     * the Rails internals.
350
     *
351
     * <pre>
352
     * $this->titleize('man from the boondocks');  // "Man From The Boondocks"
353
     * $this->titleize('x-men: the last stand');   // "X Men: The Last Stand"
354
     * $this->titleize('TheManWithoutAPast');      // "The Man Without A Past"
355
     * $this->titleize('raiders_of_the_lost_ark'); // "Raiders Of The Lost Ark"
356
     * </pre>
357
     */
358
    public function titleize(string $str): string
359
    {
360
        $str = $this->underscore($str);
3✔
361
        $str = $this->humanize($str);
3✔
362

363
        // @phpstan-ignore-next-line
364
        return preg_replace_callback('/\b(?<![\'’`])[[:lower:]]/u', function (array $matches): string {
365
            return self::upcase($matches[0]);
3✔
366
        }, $str);
3✔
367
    }
368

369
    /**
370
     * Replaces underscores with dashes in the string.
371
     *
372
     * <pre>
373
     * $this->dasherize('puni_puni'); // "puni-puni"
374
     * </pre>
375
     */
376
    public function dasherize(string $underscored_word): string
377
    {
378
        return strtr($underscored_word, '_', '-');
4✔
379
    }
380

381
    /**
382
     * Makes an hyphenated, lowercase form from the expression in the string.
383
     *
384
     * This is a combination of {@see underscore} and {@see dasherize}.
385
     */
386
    public function hyphenate(string $str): string
387
    {
388
        return $this->dasherize($this->underscore($str));
2✔
389
    }
390

391
    /**
392
     * Returns the suffix that should be added to a number to denote the position in an ordered
393
     * sequence such as 1st, 2nd, 3rd, 4th.
394
     *
395
     * <pre>
396
     * $this->ordinal(1);     // "st"
397
     * $this->ordinal(2);     // "nd"
398
     * $this->ordinal(1002);  // "nd"
399
     * $this->ordinal(1003);  // "rd"
400
     * $this->ordinal(-11);   // "th"
401
     * $this->ordinal(-1021); // "st"
402
     * </pre>
403
     */
404
    public function ordinal(int $number): string
405
    {
406
        $abs_number = abs($number);
2✔
407

408
        if (($abs_number % 100) > 10 && ($abs_number % 100) < 14) {
2✔
409
            return 'th';
2✔
410
        }
411

412
        switch ($abs_number % 10) {
2✔
413
            case 1:
2✔
414
                return "st";
2✔
415
            case 2:
2✔
416
                return "nd";
2✔
417
            case 3:
2✔
418
                return "rd";
2✔
419
            default:
420
                return "th";
2✔
421
        }
422
    }
423

424
    /**
425
     * Turns a number into an ordinal string used to denote the position in an ordered sequence
426
     * such as 1st, 2nd, 3rd, 4th.
427
     *
428
     * <pre>
429
     * $this->ordinalize(1);     // "1st"
430
     * $this->ordinalize(2);     // "2nd"
431
     * $this->ordinalize(1002);  // "1002nd"
432
     * $this->ordinalize(1003);  // "1003rd"
433
     * $this->ordinalize(-11);   // "-11th"
434
     * $this->ordinalize(-1021); // "-1021st"
435
     * </pre>
436
     */
437
    public function ordinalize(int $number): string
438
    {
439
        return $number . $this->ordinal($number);
1✔
440
    }
441

442
    /**
443
     * Returns true if the word is uncountable, false otherwise.
444
     *
445
     * <pre>
446
     * $this->is_uncountable('advice');    // true
447
     * $this->is_uncountable('weather');   // true
448
     * $this->is_uncountable('cat');       // false
449
     * </pre>
450
     */
451
    public function is_uncountable(string $word): bool
452
    {
453
        $rc = $word;
1✔
454

455
        return $rc
1✔
456
            && preg_match('/\b[[:word:]]+\Z/u', self::downcase($rc), $matches)
1✔
457
            && isset($this->inflections->uncountables[$matches[0]]);
1✔
458
    }
459
}
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

© 2026 Coveralls, Inc