1<?php
2/**
3 * Copyright 2007-2017 Horde LLC (http://www.horde.org/)
4 *
5 * @category   Horde
6 * @package    Support
7 * @license    http://www.horde.org/licenses/bsd
8 */
9
10/**
11 * Horde Inflector class.
12 *
13 * @todo Add the locale-bubbling pattern from
14 *       Horde_Date_Parser/Horde_Support_Numerizer
15 *
16 * @category   Horde
17 * @package    Support
18 * @license    http://www.horde.org/licenses/bsd
19 */
20class Horde_Support_Inflector
21{
22    /**
23     * Inflection cache
24     *
25     * @var array
26     */
27    protected $_cache = array();
28
29    /**
30     * Rules for pluralizing English nouns.
31     *
32     * @var array
33     */
34    protected $_pluralizationRules = array(
35        '/move$/i' => 'moves',
36        '/sex$/i' => 'sexes',
37        '/child$/i' => 'children',
38        '/man$/i' => 'men',
39        '/foot$/i' => 'feet',
40        '/person$/i' => 'people',
41        '/(quiz)$/i' => '$1zes',
42        '/^(ox)$/i' => '$1en',
43        '/(m|l)ouse$/i' => '$1ice',
44        '/(matr|vert|ind)ix|ex$/i' => '$1ices',
45        '/(x|ch|ss|sh)$/i' => '$1es',
46        '/([^aeiouy]|qu)ies$/i' => '$1y',
47        '/([^aeiouy]|qu)y$/i' => '$1ies',
48        '/(?:([^f])fe|([lr])f)$/i' => '$1$2ves',
49        '/sis$/i' => 'ses',
50        '/([ti])um$/i' => '$1a',
51        '/(buffal|tomat)o$/i' => '$1oes',
52        '/(bu)s$/i' => '$1ses',
53        '/(alias|status)$/i' => '$1es',
54        '/(octop|vir)us$/i' => '$1i',
55        '/(ax|test)is$/i' => '$1es',
56        '/s$/i' => 's',
57        '/$/' => 's',
58    );
59
60    /**
61     * Rules for singularizing English nouns.
62     *
63     * @var array
64     */
65    protected $_singularizationRules = array(
66        '/cookies$/i' => 'cookie',
67        '/moves$/i' => 'move',
68        '/sexes$/i' => 'sex',
69        '/children$/i' => 'child',
70        '/men$/i' => 'man',
71        '/feet$/i' => 'foot',
72        '/people$/i' => 'person',
73        '/databases$/i'=> 'database',
74        '/(quiz)zes$/i' => '\1',
75        '/(matr)ices$/i' => '\1ix',
76        '/(vert|ind)ices$/i' => '\1ex',
77        '/^(ox)en/i' => '\1',
78        '/(alias|status)es$/i' => '\1',
79        '/([octop|vir])i$/i' => '\1us',
80        '/(cris|ax|test)es$/i' => '\1is',
81        '/(shoe)s$/i' => '\1',
82        '/(o)es$/i' => '\1',
83        '/(bus)es$/i' => '\1',
84        '/([m|l])ice$/i' => '\1ouse',
85        '/(x|ch|ss|sh)es$/i' => '\1',
86        '/(m)ovies$/i' => '\1ovie',
87        '/(s)eries$/i' => '\1eries',
88        '/([^aeiouy]|qu)ies$/i' => '\1y',
89        '/([lr])ves$/i' => '\1f',
90        '/(tive)s$/i' => '\1',
91        '/(hive)s$/i' => '\1',
92        '/([^f])ves$/i' => '\1fe',
93        '/(^analy)ses$/i' => '\1sis',
94        '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i' => '\1\2sis',
95        '/([ti])a$/i' => '\1um',
96        '/(n)ews$/i' => '\1ews',
97        '/(.*)s$/i' => '\1',
98    );
99
100    /**
101     * An array of words with the same singular and plural spellings.
102     *
103     * @var array
104     */
105    protected $_uncountables = array(
106        'aircraft',
107        'cannon',
108        'deer',
109        'equipment',
110        'fish',
111        'information',
112        'money',
113        'moose',
114        'rice',
115        'series',
116        'sheep',
117        'species',
118        'swine',
119    );
120
121    /**
122     * Constructor.
123     *
124     * Stores a map of the uncountable words for quicker checks.
125     */
126    public function __construct()
127    {
128        $this->_uncountables_keys = array_flip($this->_uncountables);
129    }
130
131    /**
132     * Adds an uncountable word.
133     *
134     * @param string $word The uncountable word.
135     */
136    public function uncountable($word)
137    {
138        $this->_uncountables[] = $word;
139        $this->_uncountables_keys[$word] = true;
140    }
141
142    /**
143     * Singular English word to pluralize.
144     *
145     * @param string $word Word to pluralize.
146     *
147     * @return string Plural form of $word.
148     */
149    public function pluralize($word)
150    {
151        if ($plural = $this->getCache($word, 'pluralize')) {
152            return $plural;
153        }
154
155        if (isset($this->_uncountables_keys[$word])) {
156            return $word;
157        }
158
159        foreach ($this->_pluralizationRules as $regexp => $replacement) {
160            $plural = preg_replace($regexp, $replacement, $word, -1, $matches);
161            if ($matches > 0) {
162                return $this->setCache($word, 'pluralize', $plural);
163            }
164        }
165
166        return $this->setCache($word, 'pluralize', $word);
167    }
168
169    /**
170     * Plural English word to singularize.
171     *
172     * @param string $word Word to singularize.
173     *
174     * @return string Singular form of $word.
175     */
176    public function singularize($word)
177    {
178        if ($singular = $this->getCache($word, 'singularize')) {
179            return $singular;
180        }
181
182        if (isset($this->_uncountables_keys[$word])) {
183            return $word;
184        }
185
186        foreach ($this->_singularizationRules as $regexp => $replacement) {
187            $singular = preg_replace($regexp, $replacement, $word, -1, $matches);
188            if ($matches > 0) {
189                return $this->setCache($word, 'singularize', $singular);
190            }
191        }
192
193        return $this->setCache($word, 'singularize', $word);
194    }
195
196    /**
197     * Camel-cases a word.
198     *
199     * @todo Do we want locale-specific or locale-independent camel casing?
200     *
201     * @param string $word         The word to camel-case.
202     * @param string $firstLetter  Whether to upper or lower case the first.
203     *                             letter of each slash-separated section.
204     *
205     * @return string Camelized $word
206     */
207    public function camelize($word, $firstLetter = 'upper')
208    {
209        if ($camelized = $this->getCache($word, 'camelize' . $firstLetter)) {
210            return $camelized;
211        }
212
213        $camelized = $word;
214        if (Horde_String::lower($camelized) != $camelized &&
215            strpos($camelized, '_') !== false) {
216            $camelized = str_replace('_', '/', $camelized);
217        }
218        if (strpos($camelized, '/') !== false) {
219            $camelized = str_replace('/', '/ ', $camelized);
220        }
221        if (strpos($camelized, '_') !== false) {
222            $camelized = strtr($camelized, '_', ' ');
223        }
224
225        $camelized = str_replace(' ', '', Horde_String::ucwords($camelized));
226
227        if ($firstLetter == 'lower') {
228            $parts = array();
229            foreach (explode('/', $camelized) as $part) {
230                $part[0] = Horde_String::lower($part[0]);
231                $parts[] = $part;
232            }
233            $camelized = implode('/', $parts);
234        }
235
236        return $this->setCache($word, 'camelize' . $firstLetter, $camelized);
237    }
238
239    /**
240     * Capitalizes all the words and replaces some characters in the string to
241     * create a nicer looking title.
242     *
243     * Titleize is meant for creating pretty output.
244     *
245     * See:
246     * - http://daringfireball.net/2008/05/title_case
247     * - http://daringfireball.net/2008/08/title_case_update
248     *
249     * Examples:
250     * 1. titleize("man from the boondocks") => "Man From The Boondocks"
251     * 2. titleize("x-men: the last stand")  => "X Men: The Last Stand"
252     */
253    public function titleize($word)
254    {
255        throw new Exception('not implemented yet');
256    }
257
258    /**
259     * The reverse of camelize().
260     *
261     * Makes an underscored form from the expression in the string.
262     *
263     * Examples:
264     * 1. underscore("ActiveRecord")        => "active_record"
265     * 2. underscore("ActiveRecord_Errors") => "active_record_errors"
266     *
267     * @todo Do we want locale-specific or locale-independent lowercasing?
268     */
269    public function underscore($camelCasedWord)
270    {
271        $word = $camelCasedWord;
272        if ($result = $this->getCache($word, 'underscore')) {
273            return $result;
274        }
275        $result = Horde_String::lower(preg_replace('/([a-z])([A-Z])/', "\${1}_\${2}", $word));
276        return $this->setCache($word, 'underscore', $result);
277    }
278
279    /**
280     * Replaces underscores with dashes in the string.
281     *
282     * Example:
283     * 1. dasherize("puni_puni") => "puni-puni"
284     */
285    public function dasherize($underscoredWord)
286    {
287        if ($result = $this->getCache($underscoredWord, 'dasherize')) {
288            return $result;
289        }
290
291        $result = str_replace('_', '-', $this->underscore($underscoredWord));
292        return $this->setCache($underscoredWord, 'dasherize', $result);
293    }
294
295    /**
296     * Capitalizes the first word and turns underscores into spaces and strips
297     * _id.
298     *
299     * Like titleize(), this is meant for creating pretty output.
300     *
301     * Examples:
302     * 1. humanize("employee_salary") => "Employee salary"
303     * 2. humanize("author_id")       => "Author"
304     */
305    public function humanize($lowerCaseAndUnderscoredWord)
306    {
307        $word = $lowerCaseAndUnderscoredWord;
308        if ($result = $this->getCache($word, 'humanize')) {
309            return $result;
310        }
311
312        $result = ucfirst(str_replace('_', ' ', $this->underscore($word)));
313        if (substr($result, -3, 3) == ' id') {
314            $result = str_replace(' id', '', $result);
315        }
316        return $this->setCache($word, 'humanize', $result);
317    }
318
319    /**
320     * Removes the module part from the expression in the string.
321     *
322     * Examples:
323     * 1. demodulize("Fax_Job") => "Job"
324     * 1. demodulize("User")    => "User"
325     */
326    public function demodulize($classNameInModule)
327    {
328        $result = explode('_', $classNameInModule);
329        return array_pop($result);
330    }
331
332    /**
333     * Creates the name of a table like Rails does for models to table names.
334     *
335     * This method uses the pluralize() method on the last word in the string.
336     *
337     * Examples:
338     * 1. tableize("RawScaledScorer") => "raw_scaled_scorers"
339     * 2. tableize("egg_and_ham")     => "egg_and_hams"
340     * 3. tableize("fancyCategory")   => "fancy_categories"
341     */
342    public function tableize($className)
343    {
344        if ($result = $this->getCache($className, 'tableize')) {
345            return $result;
346        }
347
348        $result = $this->pluralize($this->underscore($className));
349        $result = str_replace('/', '_', $result);
350        return $this->setCache($className, 'tableize', $result);
351    }
352
353    /**
354     * Creates a class name from a table name like Rails does for table names
355     * to models.
356     *
357     * Examples:
358     * 1. classify("egg_and_hams") => "EggAndHam"
359     * 2. classify("post")         => "Post"
360     */
361    public function classify($tableName)
362    {
363        if ($result = $this->getCache($tableName, 'classify')) {
364            return $result;
365        }
366        $result = $this->camelize($this->singularize($tableName));
367
368        // classes use underscores instead of slashes for namespaces
369        $result = str_replace('/', '_', $result);
370        return $this->setCache($tableName, 'classify', $result);
371    }
372
373    /**
374     * Creates a foreign key name from a class name.
375     *
376     * $separateClassNameAndIdWithUnderscore sets whether the method should put
377     * '_' between the name and 'id'.
378     *
379     * Examples:
380     * 1. foreignKey("Message")        => "message_id"
381     * 2. foreignKey("Message", false) => "messageid"
382     * 3. foreignKey("Fax_Job")        => "fax_job_id"
383     */
384    public function foreignKey($className, $separateClassNameAndIdWithUnderscore = true)
385    {
386        throw new Exception('not implemented yet');
387    }
388
389    /**
390     * Turns a number into an ordinal string used to denote the position in an
391     * ordered sequence such as 1st, 2nd, 3rd, 4th.
392     *
393     * Examples:
394     * 1. ordinalize(1)      => "1st"
395     * 2. ordinalize(2)      => "2nd"
396     * 3. ordinalize(1002)   => "1002nd"
397     * 4. ordinalize(1003)   => "1003rd"
398     */
399    public function ordinalize($number)
400    {
401        throw new Exception('not implemented yet');
402    }
403
404    /**
405     * Clears the inflection cache.
406     */
407    public function clearCache()
408    {
409        $this->_cache = array();
410    }
411
412    /**
413     * Retuns a cached inflection.
414     *
415     * @return string | false
416     */
417    public function getCache($word, $rule)
418    {
419        return isset($this->_cache[$word . '|' . $rule]) ?
420            $this->_cache[$word . '|' . $rule] : false;
421    }
422
423    /**
424     * Caches an inflection.
425     *
426     * @param string $word   The word being inflected.
427     * @param string $rule   The inflection rule.
428     * @param string $value  The inflected value of $word.
429     *
430     * @return string The inflected value
431     */
432    public function setCache($word, $rule, $value)
433    {
434        $this->_cache[$word . '|' . $rule] = $value;
435        return $value;
436    }
437}
438