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 /**
15 * The Inflector transforms words from singular to plural, class names to table names, modularized
16 * class names to ones without, and class names to foreign keys. Inflections can be localized, the
17 * default english inflections for pluralization, singularization, and uncountable words are
18 * kept in `lib/inflections/en.php`.
19 *
20 * @property-read Inflections $inflections Inflections used by the inflector.
21 */
22 class Inflector
23 {
24 static private $inflectors = array();
25
26 /**
27 * Returns an inflector for the specified locale.
28 *
29 * Note: Inflectors are shared for the same locale. If you need to alter an inflector you
30 * MUST clone it first.
31 *
32 * @param string $locale
33 *
34 * @return \ICanBoogie\Inflector
35 */
36 static public function get($locale='en')
37 {
38 if (isset(self::$inflectors[$locale]))
39 {
40 return self::$inflectors[$locale];
41 }
42
43 return self::$inflectors[$locale] = new static(Inflections::get($locale));
44 }
45
46 /**
47 * Inflections used by the inflector.
48 *
49 * @var Inflections
50 */
51 protected $inflections;
52
53 /**
54 * Initializes the {@link $inflections} property.
55 *
56 * @param Inflections $inflections
57 */
58 protected function __construct(Inflections $inflections=null)
59 {
60 $this->inflections = $inflections ?: new Inflections;
61 }
62
63 /**
64 * Returns the {@link $inflections} property.
65 *
66 * @param string $property
67 *
68 * @throws PropertyNotDefined in attempt to read an unaccessible property. If the {@link PropertyNotDefined}
69 * class is not available a {@link \InvalidArgumentException} is thrown instead.
70 */
71 public function __get($property)
72 {
73 static $readers = array('inflections');
74
75 if (in_array($property, $readers))
76 {
77 return $this->$property;
78 }
79
80 if (class_exists('ICanBoogie\PropertyNotDefined'))
81 {
82 throw new PropertyNotDefined(array($property, $this));
83 }
84 else
85 {
86 throw new \InvalidArgumentException("Property not defined: $property");
87 }
88 }
89
90 /**
91 * Clone inflections.
92 */
93 public function __clone()
94 {
95 $this->inflections = clone $this->inflections;
96 }
97
98 /**
99 * Applies inflection rules for {@link singularize} and {@link pluralize}.
100 *
101 * <pre>
102 * $this->apply_inflections('post', $this->plurals); // "posts"
103 * $this->apply_inflections('posts', $this->singulars); // "post"
104 * </pre>
105 *
106 * @param string $word
107 * @param array $rules
108 *
109 * @return string
110 */
111 private function apply_inflections($word, array $rules)
112 {
113 $rc = (string) $word;
114
115 if (!$rc)
116 {
117 return $rc;
118 }
119
120 if (preg_match('/\b\w+\Z/', downcase($rc), $matches))
121 {
122 if (isset($this->inflections->uncountables[$matches[0]]))
123 {
124 return $rc;
125 }
126 }
127
128 foreach ($rules as $rule => $replacement)
129 {
130 $rc = preg_replace($rule, $replacement, $rc, -1, $count);
131
132 if ($count) break;
133 }
134
135 return $rc;
136 }
137
138 /**
139 * Returns the plural form of the word in the string.
140 *
141 * <pre>
142 * $this->pluralize('post'); // "posts"
143 * $this->pluralize('children'); // "child"
144 * $this->pluralize('sheep'); // "sheep"
145 * $this->pluralize('words'); // "words"
146 * $this->pluralize('CamelChild'); // "CamelChild"
147 * </pre>
148 *
149 * @param string $word
150 *
151 * @return string
152 */
153 public function pluralize($word)
154 {
155 return $this->apply_inflections($word, $this->inflections->plurals);
156 }
157
158 /**
159 * The reverse of {@link pluralize}, returns the singular form of a word in a string.
160 *
161 * <pre>
162 * $this->singularize('posts'); // "post"
163 * $this->singularize('childred'); // "child"
164 * $this->singularize('sheep'); // "sheep"
165 * $this->singularize('word'); // "word"
166 * $this->singularize('CamelChildren'); // "CamelChild"
167 * </pre>
168 *
169 * @param string $word
170 *
171 * @return string
172 */
173 public function singularize($word)
174 {
175 return $this->apply_inflections($word, $this->inflections->singulars);
176 }
177
178 /**
179 * By default, {@link camelize} converts strings to UpperCamelCase.
180 *
181 * {@link camelize} will also convert "/" to "\" which is useful for converting paths to
182 * namespaces.
183 *
184 * <pre>
185 * $this->camelize('active_model'); // 'ActiveModel'
186 * $this->camelize('active_model', true); // 'activeModel'
187 * $this->camelize('active_model/errors'); // 'ActiveModel\Errors'
188 * $this->camelize('active_model/errors', true); // 'activeModel\Errors'
189 * </pre>
190 *
191 * As a rule of thumb you can think of {@link camelize} as the inverse of {@link underscore},
192 * though there are cases where that does not hold:
193 *
194 * <pre>
195 * $this->camelize($this->underscore('SSLError')); // "SslError"
196 * </pre>
197 *
198 * @param string $term
199 * @param bool $downcase_first_letter If `false` then {@link camelize} produces
200 * lowerCamelCase.
201 *
202 * @return string
203 */
204 public function camelize($term, $downcase_first_letter=false)
205 {
206 $string = (string) $term;
207 $acronyms = $this->inflections->acronyms;
208
209 if ($downcase_first_letter)
210 {
211 $string = preg_replace_callback('/^(?:' . trim($this->inflections->acronym_regex, '/') . '(?=\b|[A-Z_])|\w)/', function($matches) {
212
213 return downcase($matches[0]);
214
215 }, $string, 1);
216 }
217 else
218 {
219 $string = preg_replace_callback('/^[a-z\d]*/', function($matches) use($acronyms) {
220
221 $m = $matches[0];
222
223 return !empty($acronyms[$m]) ? $acronyms[$m] : capitalize($m);
224
225 }, $string, 1);
226 }
227
228 $string = preg_replace_callback('/(?:_|-|(\/))([a-z\d]*)/i', function($matches) use($acronyms) {
229
230 list(, $m1, $m2) = $matches;
231
232 return $m1 . (isset($acronyms[$m2]) ? $acronyms[$m2] : capitalize($m2));
233
234 }, $string);
235
236 $string = str_replace('/', '\\', $string);
237
238 return $string;
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 {@link underscore} as the inverse of {@link 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 * @param string $camel_cased_word
259 *
260 * @return string
261 */
262 public function underscore($camel_cased_word)
263 {
264 $word = (string) $camel_cased_word;
265 $word = str_replace('\\', '/', $word);
266 $word = preg_replace_callback('/(?:([A-Za-z\d])|^)(' . trim($this->inflections->acronym_regex, '/') . ')(?=\b|[^a-z])/', function($matches) {
267
268 list(, $m1, $m2) = $matches;
269
270 return $m1 . ($m1 ? '_' : '') . downcase($m2);
271
272 }, $word);
273
274 $word = preg_replace('/([A-Z\d]+)([A-Z][a-z])/', '\1_\2', $word);
275 $word = preg_replace('/([a-z\d])([A-Z])/','\1_\2', $word);
276 $word = strtr($word, "-", "_");
277 $word = downcase($word);
278
279 return $word;
280 }
281
282 /**
283 * Capitalizes the first word and turns underscores into spaces and strips a trailing "_id",
284 * if any. Like {@link titleize()}, this is meant for creating pretty output.
285 *
286 * <pre>
287 * $this->humanize('employee_salary'); // "Employee salary"
288 * $this->humanize('author_id'); // "Author"
289 * </pre>
290 *
291 * @param string $lower_case_and_underscored_word
292 *
293 * @return string
294 */
295 public function humanize($lower_case_and_underscored_word)
296 {
297 $result = (string) $lower_case_and_underscored_word;
298
299 foreach ($this->inflections->humans as $rule => $replacement)
300 {
301 $result = preg_replace($rule, $replacement, $result, 1, $count);
302
303 if ($count) break;
304 }
305
306 $acronyms = $this->inflections->acronyms;
307
308 $result = preg_replace('/_id$/', "", $result);
309 $result = strtr($result, '_', ' ');
310 $result = preg_replace_callback('/([a-z\d]*)/i', function($matches) use($acronyms) {
311
312 list($m) = $matches;
313
314 return !empty($acronyms[$m]) ? $acronyms[$m] : downcase($m);
315 }, $result);
316
317 $result = preg_replace_callback('/^\w/', function($matches) {
318
319 return upcase($matches[0]);
320
321 }, $result);
322
323 return $result;
324 }
325
326 /**
327 * Capitalizes all the words and replaces some characters in the string to create a nicer
328 * looking title. {@link titleize()} is meant for creating pretty output. It is not used in
329 * the Rails internals.
330 *
331 * <pre>
332 * $this->titleize('man from the boondocks'); // "Man From The Boondocks"
333 * $this->titleize('x-men: the last stand'); // "X Men: The Last Stand"
334 * $this->titleize('TheManWithoutAPast'); // "The Man Without A Past"
335 * $this->titleize('raiders_of_the_lost_ark'); // "Raiders Of The Lost Ark"
336 * </pre>
337 *
338 * @param string $str
339 *
340 * @return string
341 */
342 public function titleize($str)
343 {
344 $str = $this->underscore($str);
345 $str = $this->humanize($str);
346 $str = preg_replace_callback('/\b(?<![\'’`])[a-z]/', function($matches) {
347
348 return capitalize($matches[0]);
349
350 }, $str);
351
352 return $str;
353 }
354
355 /**
356 * Replaces underscores with dashes in the string.
357 *
358 * <pre>
359 * $this->dasherize('puni_puni'); // "puni-puni"
360 * </pre>
361 *
362 * @param string $underscored_word
363 *
364 * @return string
365 */
366 public function dasherize($underscored_word)
367 {
368 return strtr($underscored_word, '_', '-');
369 }
370
371 /**
372 * Makes an hyphenated, lowercase form from the expression in the string.
373 *
374 * This is a combination of {@link underscore} and {@link dasherize}.
375 *
376 * @param string $str
377 *
378 * @return string
379 */
380 public function hyphenate($str)
381 {
382 return $this->dasherize($this->underscore($str));
383 }
384
385 /**
386 * Returns the suffix that should be added to a number to denote the position in an ordered
387 * sequence such as 1st, 2nd, 3rd, 4th.
388 *
389 * <pre>
390 * $this->ordinal(1); // "st"
391 * $this->ordinal(2); // "nd"
392 * $this->ordinal(1002); // "nd"
393 * $this->ordinal(1003); // "rd"
394 * $this->ordinal(-11); // "th"
395 * $this->ordinal(-1021); // "st"
396 * </pre>
397 */
398 public function ordinal($number)
399 {
400 $abs_number = abs($number);
401
402 if (($abs_number % 100) > 10 && ($abs_number % 100) < 14)
403 {
404 return 'th';
405 }
406
407 switch ($abs_number % 10)
408 {
409 case 1; return "st";
410 case 2; return "nd";
411 case 3; return "rd";
412 default: return "th";
413 }
414 }
415
416 /**
417 * Turns a number into an ordinal string used to denote the position in an ordered sequence
418 * such as 1st, 2nd, 3rd, 4th.
419 *
420 * <pre>
421 * $this->ordinalize(1); // "1st"
422 * $this->ordinalize(2); // "2nd"
423 * $this->ordinalize(1002); // "1002nd"
424 * $this->ordinalize(1003); // "1003rd"
425 * $this->ordinalize(-11); // "-11th"
426 * $this->ordinalize(-1021); // "-1021st"
427 * </pre>
428 */
429 public function ordinalize($number)
430 {
431 return $number . $this->ordinal($number);
432 }
433 }