diff --git a/UPGRADE-5.1.md b/UPGRADE-5.1.md index f93421b5d3..f9384e2942 100644 --- a/UPGRADE-5.1.md +++ b/UPGRADE-5.1.md @@ -71,6 +71,11 @@ HttpKernel not returning an array is deprecated * Deprecated support for `service:action` syntax to reference controllers. Use `serviceOrFqcn::method` instead. +Inflector +--------- + + * The component has been deprecated, use `EnglishInflector` from the String component instead. + Mailer ------ diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index 6acccd599e..649386d76a 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -66,6 +66,10 @@ HttpKernel * Made `WarmableInterface::warmUp()` return a list of classes or files to preload on PHP 7.4+ * Removed support for `service:action` syntax to reference controllers. Use `serviceOrFqcn::method` instead. +Inflector +--------- + + * The component has been removed, use `EnglishInflector` from the String component instead. Mailer ------ diff --git a/src/Symfony/Component/Inflector/CHANGELOG.md b/src/Symfony/Component/Inflector/CHANGELOG.md new file mode 100644 index 0000000000..ee4098b57f --- /dev/null +++ b/src/Symfony/Component/Inflector/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.1.0 +----- + + * The component has been deprecated, use `EnglishInflector` from the String component instead. diff --git a/src/Symfony/Component/Inflector/Inflector.php b/src/Symfony/Component/Inflector/Inflector.php index 70ac51fc0d..3e567e1ce7 100644 --- a/src/Symfony/Component/Inflector/Inflector.php +++ b/src/Symfony/Component/Inflector/Inflector.php @@ -11,315 +11,20 @@ namespace Symfony\Component\Inflector; +use Symfony\Component\String\Inflector\EnglishInflector; + +trigger_deprecation('symfony/inflector', '5.1', sprintf('The "%s" class is deprecated, use "%s" instead.', Inflector::class, EnglishInflector::class)); + /** * Converts words between singular and plural forms. * * @author Bernhard Schussek + * + * @deprecated since Symfony 5.1, use Symfony\Component\String\Inflector\EnglishInflector instead. */ final class Inflector { - /** - * Map English plural to singular suffixes. - * - * @see http://english-zone.com/spelling/plurals.html - */ - private static $pluralMap = [ - // First entry: plural suffix, reversed - // Second entry: length of plural suffix - // Third entry: Whether the suffix may succeed a vocal - // Fourth entry: Whether the suffix may succeed a consonant - // Fifth entry: singular suffix, normal - - // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) - ['a', 1, true, true, ['on', 'um']], - - // nebulae (nebula) - ['ea', 2, true, true, 'a'], - - // services (service) - ['secivres', 8, true, true, 'service'], - - // mice (mouse), lice (louse) - ['eci', 3, false, true, 'ouse'], - - // geese (goose) - ['esee', 4, false, true, 'oose'], - - // fungi (fungus), alumni (alumnus), syllabi (syllabus), radii (radius) - ['i', 1, true, true, 'us'], - - // men (man), women (woman) - ['nem', 3, true, true, 'man'], - - // children (child) - ['nerdlihc', 8, true, true, 'child'], - - // oxen (ox) - ['nexo', 4, false, false, 'ox'], - - // indices (index), appendices (appendix), prices (price) - ['seci', 4, false, true, ['ex', 'ix', 'ice']], - - // selfies (selfie) - ['seifles', 7, true, true, 'selfie'], - - // movies (movie) - ['seivom', 6, true, true, 'movie'], - - // feet (foot) - ['teef', 4, true, true, 'foot'], - - // geese (goose) - ['eseeg', 5, true, true, 'goose'], - - // teeth (tooth) - ['hteet', 5, true, true, 'tooth'], - - // news (news) - ['swen', 4, true, true, 'news'], - - // series (series) - ['seires', 6, true, true, 'series'], - - // babies (baby) - ['sei', 3, false, true, 'y'], - - // accesses (access), addresses (address), kisses (kiss) - ['sess', 4, true, false, 'ss'], - - // analyses (analysis), ellipses (ellipsis), fungi (fungus), - // neuroses (neurosis), theses (thesis), emphases (emphasis), - // oases (oasis), crises (crisis), houses (house), bases (base), - // atlases (atlas) - ['ses', 3, true, true, ['s', 'se', 'sis']], - - // objectives (objective), alternative (alternatives) - ['sevit', 5, true, true, 'tive'], - - // drives (drive) - ['sevird', 6, false, true, 'drive'], - - // lives (life), wives (wife) - ['sevi', 4, false, true, 'ife'], - - // moves (move) - ['sevom', 5, true, true, 'move'], - - // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf), caves (cave), staves (staff) - ['sev', 3, true, true, ['f', 've', 'ff']], - - // axes (axis), axes (ax), axes (axe) - ['sexa', 4, false, false, ['ax', 'axe', 'axis']], - - // indexes (index), matrixes (matrix) - ['sex', 3, true, false, 'x'], - - // quizzes (quiz) - ['sezz', 4, true, false, 'z'], - - // bureaus (bureau) - ['suae', 4, false, true, 'eau'], - - // fees (fee), trees (tree), employees (employee) - ['see', 3, true, true, 'ee'], - - // roses (rose), garages (garage), cassettes (cassette), - // waltzes (waltz), heroes (hero), bushes (bush), arches (arch), - // shoes (shoe) - ['se', 2, true, true, ['', 'e']], - - // tags (tag) - ['s', 1, true, true, ''], - - // chateaux (chateau) - ['xuae', 4, false, true, 'eau'], - - // people (person) - ['elpoep', 6, true, true, 'person'], - ]; - - /** - * Map English singular to plural suffixes. - * - * @see http://english-zone.com/spelling/plurals.html - */ - private static $singularMap = [ - // First entry: singular suffix, reversed - // Second entry: length of singular suffix - // Third entry: Whether the suffix may succeed a vocal - // Fourth entry: Whether the suffix may succeed a consonant - // Fifth entry: plural suffix, normal - - // criterion (criteria) - ['airetirc', 8, false, false, 'criterion'], - - // nebulae (nebula) - ['aluben', 6, false, false, 'nebulae'], - - // children (child) - ['dlihc', 5, true, true, 'children'], - - // prices (price) - ['eci', 3, false, true, 'ices'], - - // services (service) - ['ecivres', 7, true, true, 'services'], - - // lives (life), wives (wife) - ['efi', 3, false, true, 'ives'], - - // selfies (selfie) - ['eifles', 6, true, true, 'selfies'], - - // movies (movie) - ['eivom', 5, true, true, 'movies'], - - // lice (louse) - ['esuol', 5, false, true, 'lice'], - - // mice (mouse) - ['esuom', 5, false, true, 'mice'], - - // geese (goose) - ['esoo', 4, false, true, 'eese'], - - // houses (house), bases (base) - ['es', 2, true, true, 'ses'], - - // geese (goose) - ['esoog', 5, true, true, 'geese'], - - // caves (cave) - ['ev', 2, true, true, 'ves'], - - // drives (drive) - ['evird', 5, false, true, 'drives'], - - // objectives (objective), alternative (alternatives) - ['evit', 4, true, true, 'tives'], - - // moves (move) - ['evom', 4, true, true, 'moves'], - - // staves (staff) - ['ffats', 5, true, true, 'staves'], - - // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf) - ['ff', 2, true, true, 'ffs'], - - // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf) - ['f', 1, true, true, ['fs', 'ves']], - - // arches (arch) - ['hc', 2, true, true, 'ches'], - - // bushes (bush) - ['hs', 2, true, true, 'shes'], - - // teeth (tooth) - ['htoot', 5, true, true, 'teeth'], - - // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) - ['mu', 2, true, true, 'a'], - - // men (man), women (woman) - ['nam', 3, true, true, 'men'], - - // people (person) - ['nosrep', 6, true, true, ['persons', 'people']], - - // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) - ['noi', 3, true, true, 'ions'], - - // seasons (season), treasons (treason), poisons (poison), lessons (lesson) - ['nos', 3, true, true, 'sons'], - - // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) - ['no', 2, true, true, 'a'], - - // echoes (echo) - ['ohce', 4, true, true, 'echoes'], - - // heroes (hero) - ['oreh', 4, true, true, 'heroes'], - - // atlases (atlas) - ['salta', 5, true, true, 'atlases'], - - // irises (iris) - ['siri', 4, true, true, 'irises'], - - // analyses (analysis), ellipses (ellipsis), neuroses (neurosis) - // theses (thesis), emphases (emphasis), oases (oasis), - // crises (crisis) - ['sis', 3, true, true, 'ses'], - - // accesses (access), addresses (address), kisses (kiss) - ['ss', 2, true, false, 'sses'], - - // syllabi (syllabus) - ['suballys', 8, true, true, 'syllabi'], - - // buses (bus) - ['sub', 3, true, true, 'buses'], - - // circuses (circus) - ['suc', 3, true, true, 'cuses'], - - // fungi (fungus), alumni (alumnus), syllabi (syllabus), radii (radius) - ['su', 2, true, true, 'i'], - - // news (news) - ['swen', 4, true, true, 'news'], - - // feet (foot) - ['toof', 4, true, true, 'feet'], - - // chateaux (chateau), bureaus (bureau) - ['uae', 3, false, true, ['eaus', 'eaux']], - - // oxen (ox) - ['xo', 2, false, false, 'oxen'], - - // hoaxes (hoax) - ['xaoh', 4, true, false, 'hoaxes'], - - // indices (index) - ['xedni', 5, false, true, ['indicies', 'indexes']], - - // boxes (box) - ['xo', 2, false, true, 'oxes'], - - // indexes (index), matrixes (matrix) - ['x', 1, true, false, ['cies', 'xes']], - - // appendices (appendix) - ['xi', 2, false, true, 'ices'], - - // babies (baby) - ['y', 1, false, true, 'ies'], - - // quizzes (quiz) - ['ziuq', 4, true, false, 'quizzes'], - - // waltzes (waltz) - ['z', 1, true, true, 'zes'], - ]; - - /** - * A list of words which should not be inflected, reversed. - */ - private static $uninflected = [ - 'atad', - 'reed', - 'kcabdeef', - 'hsif', - 'ofni', - 'esoom', - 'seires', - 'peehs', - 'seiceps', - ]; + private static $englishInflector; /** * This class should not be instantiated. @@ -340,78 +45,11 @@ final class Inflector */ public static function singularize(string $plural) { - $pluralRev = strrev($plural); - $lowerPluralRev = strtolower($pluralRev); - $pluralLength = \strlen($lowerPluralRev); - - // Check if the word is one which is not inflected, return early if so - if (\in_array($lowerPluralRev, self::$uninflected, true)) { - return $plural; + if (1 === \count($singulars = self::getEnglishInflector()->singularize($plural))) { + return $singulars[0]; } - // The outer loop iterates over the entries of the plural table - // The inner loop $j iterates over the characters of the plural suffix - // in the plural table to compare them with the characters of the actual - // given plural suffix - foreach (self::$pluralMap as $map) { - $suffix = $map[0]; - $suffixLength = $map[1]; - $j = 0; - - // Compare characters in the plural table and of the suffix of the - // given plural one by one - while ($suffix[$j] === $lowerPluralRev[$j]) { - // Let $j point to the next character - ++$j; - - // Successfully compared the last character - // Add an entry with the singular suffix to the singular array - if ($j === $suffixLength) { - // Is there any character preceding the suffix in the plural string? - if ($j < $pluralLength) { - $nextIsVocal = false !== strpos('aeiou', $lowerPluralRev[$j]); - - if (!$map[2] && $nextIsVocal) { - // suffix may not succeed a vocal but next char is one - break; - } - - if (!$map[3] && !$nextIsVocal) { - // suffix may not succeed a consonant but next char is one - break; - } - } - - $newBase = substr($plural, 0, $pluralLength - $suffixLength); - $newSuffix = $map[4]; - - // Check whether the first character in the plural suffix - // is uppercased. If yes, uppercase the first character in - // the singular suffix too - $firstUpper = ctype_upper($pluralRev[$j - 1]); - - if (\is_array($newSuffix)) { - $singulars = []; - - foreach ($newSuffix as $newSuffixEntry) { - $singulars[] = $newBase.($firstUpper ? ucfirst($newSuffixEntry) : $newSuffixEntry); - } - - return $singulars; - } - - return $newBase.($firstUpper ? ucfirst($newSuffix) : $newSuffix); - } - - // Suffix is longer than word - if ($j === $pluralLength) { - break; - } - } - } - - // Assume that plural and singular is identical - return $plural; + return $singulars; } /** @@ -426,78 +64,19 @@ final class Inflector */ public static function pluralize(string $singular) { - $singularRev = strrev($singular); - $lowerSingularRev = strtolower($singularRev); - $singularLength = \strlen($lowerSingularRev); - - // Check if the word is one which is not inflected, return early if so - if (\in_array($lowerSingularRev, self::$uninflected, true)) { - return $singular; + if (1 === \count($plurals = self::getEnglishInflector()->pluralize($singular))) { + return $plurals[0]; } - // The outer loop iterates over the entries of the singular table - // The inner loop $j iterates over the characters of the singular suffix - // in the singular table to compare them with the characters of the actual - // given singular suffix - foreach (self::$singularMap as $map) { - $suffix = $map[0]; - $suffixLength = $map[1]; - $j = 0; + return $plurals; + } - // Compare characters in the singular table and of the suffix of the - // given plural one by one - - while ($suffix[$j] === $lowerSingularRev[$j]) { - // Let $j point to the next character - ++$j; - - // Successfully compared the last character - // Add an entry with the plural suffix to the plural array - if ($j === $suffixLength) { - // Is there any character preceding the suffix in the plural string? - if ($j < $singularLength) { - $nextIsVocal = false !== strpos('aeiou', $lowerSingularRev[$j]); - - if (!$map[2] && $nextIsVocal) { - // suffix may not succeed a vocal but next char is one - break; - } - - if (!$map[3] && !$nextIsVocal) { - // suffix may not succeed a consonant but next char is one - break; - } - } - - $newBase = substr($singular, 0, $singularLength - $suffixLength); - $newSuffix = $map[4]; - - // Check whether the first character in the singular suffix - // is uppercased. If yes, uppercase the first character in - // the singular suffix too - $firstUpper = ctype_upper($singularRev[$j - 1]); - - if (\is_array($newSuffix)) { - $plurals = []; - - foreach ($newSuffix as $newSuffixEntry) { - $plurals[] = $newBase.($firstUpper ? ucfirst($newSuffixEntry) : $newSuffixEntry); - } - - return $plurals; - } - - return $newBase.($firstUpper ? ucfirst($newSuffix) : $newSuffix); - } - - // Suffix is longer than word - if ($j === $singularLength) { - break; - } - } + private static function getEnglishInflector(): EnglishInflector + { + if (!self::$englishInflector) { + self::$englishInflector = new EnglishInflector(); } - // Assume that plural is singular with a trailing `s` - return $singular.'s'; + return self::$englishInflector; } } diff --git a/src/Symfony/Component/Inflector/README.md b/src/Symfony/Component/Inflector/README.md index 67568fb5a2..38c5620816 100644 --- a/src/Symfony/Component/Inflector/README.md +++ b/src/Symfony/Component/Inflector/README.md @@ -1,6 +1,11 @@ Inflector Component =================== +**CAUTION**: this component is deprecated since Symfony 5.1. Instead, use the +[String component EnglishInflector](https://github.com/symfony/symfony/tree/master/src/Symfony/Component/String/Inflector/EnglishInflector.php). + +----- + Inflector converts words between their singular and plural forms (English only). Resources diff --git a/src/Symfony/Component/Inflector/Tests/InflectorTest.php b/src/Symfony/Component/Inflector/Tests/InflectorTest.php index 9a93125dd4..ad618c9593 100644 --- a/src/Symfony/Component/Inflector/Tests/InflectorTest.php +++ b/src/Symfony/Component/Inflector/Tests/InflectorTest.php @@ -14,6 +14,9 @@ namespace Symfony\Component\Inflector\Tests; use PHPUnit\Framework\TestCase; use Symfony\Component\Inflector\Inflector; +/** + * @group legacy + */ class InflectorTest extends TestCase { public function singularizeProvider() diff --git a/src/Symfony/Component/Inflector/composer.json b/src/Symfony/Component/Inflector/composer.json index 515bf188b8..7373a93bac 100644 --- a/src/Symfony/Component/Inflector/composer.json +++ b/src/Symfony/Component/Inflector/composer.json @@ -24,7 +24,9 @@ ], "require": { "php": "^7.2.5", - "symfony/polyfill-ctype": "~1.8" + "symfony/deprecation-contracts": "^2.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/string": "^5.1" }, "autoload": { "psr-4": { "Symfony\\Component\\Inflector\\": "" }, diff --git a/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php b/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php index 686c6a77a1..8de931d9c5 100644 --- a/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php +++ b/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php @@ -11,7 +11,6 @@ namespace Symfony\Component\PropertyInfo\Extractor; -use Symfony\Component\Inflector\Inflector; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; @@ -21,6 +20,8 @@ use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\PropertyWriteInfo; use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\String\Inflector\EnglishInflector; +use Symfony\Component\String\Inflector\InflectorInterface; /** * Extracts data using the reflection API. @@ -62,6 +63,7 @@ class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTyp private $enableConstructorExtraction; private $methodReflectionFlags; private $propertyReflectionFlags; + private $inflector; private $arrayMutatorPrefixesFirst; private $arrayMutatorPrefixesLast; @@ -71,7 +73,7 @@ class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTyp * @param string[]|null $accessorPrefixes * @param string[]|null $arrayMutatorPrefixes */ - public function __construct(array $mutatorPrefixes = null, array $accessorPrefixes = null, array $arrayMutatorPrefixes = null, bool $enableConstructorExtraction = true, int $accessFlags = self::ALLOW_PUBLIC) + public function __construct(array $mutatorPrefixes = null, array $accessorPrefixes = null, array $arrayMutatorPrefixes = null, bool $enableConstructorExtraction = true, int $accessFlags = self::ALLOW_PUBLIC, InflectorInterface $inflector = null) { $this->mutatorPrefixes = null !== $mutatorPrefixes ? $mutatorPrefixes : self::$defaultMutatorPrefixes; $this->accessorPrefixes = null !== $accessorPrefixes ? $accessorPrefixes : self::$defaultAccessorPrefixes; @@ -79,6 +81,7 @@ class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTyp $this->enableConstructorExtraction = $enableConstructorExtraction; $this->methodReflectionFlags = $this->getMethodsFlags($accessFlags); $this->propertyReflectionFlags = $this->getPropertyFlags($accessFlags); + $this->inflector = $inflector ?? new EnglishInflector(); $this->arrayMutatorPrefixesFirst = array_merge($this->arrayMutatorPrefixes, array_diff($this->mutatorPrefixes, $this->arrayMutatorPrefixes)); $this->arrayMutatorPrefixesLast = array_reverse($this->arrayMutatorPrefixesFirst); @@ -284,7 +287,7 @@ class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTyp $camelized = $this->camelize($property); $constructor = $reflClass->getConstructor(); - $singulars = (array) Inflector::singularize($camelized); + $singulars = $this->inflector->singularize($camelized); $errors = []; if (null !== $constructor && $allowConstruct) { @@ -552,7 +555,7 @@ class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTyp private function getMutatorMethod(string $class, string $property): ?array { $ucProperty = ucfirst($property); - $ucSingulars = (array) Inflector::singularize($ucProperty); + $ucSingulars = $this->inflector->singularize($ucProperty); $mutatorPrefixes = \in_array($ucProperty, $ucSingulars, true) ? $this->arrayMutatorPrefixesLast : $this->arrayMutatorPrefixesFirst; @@ -592,7 +595,7 @@ class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTyp } foreach ($reflectionProperties as $reflectionProperty) { - foreach ((array) Inflector::singularize($reflectionProperty->name) as $name) { + foreach ($this->inflector->singularize($reflectionProperty->name) as $name) { if (strtolower($name) === strtolower($matches[2])) { return $reflectionProperty->name; } diff --git a/src/Symfony/Component/PropertyInfo/composer.json b/src/Symfony/Component/PropertyInfo/composer.json index 08333ad63e..290dbfc996 100644 --- a/src/Symfony/Component/PropertyInfo/composer.json +++ b/src/Symfony/Component/PropertyInfo/composer.json @@ -24,8 +24,8 @@ ], "require": { "php": "^7.2.5", - "symfony/inflector": "^4.4|^5.0", - "symfony/polyfill-php80": "^1.15" + "symfony/polyfill-php80": "^1.15", + "symfony/string": "^5.1" }, "require-dev": { "symfony/serializer": "^4.4|^5.0", diff --git a/src/Symfony/Component/String/Inflector/EnglishInflector.php b/src/Symfony/Component/String/Inflector/EnglishInflector.php new file mode 100644 index 0000000000..4cd05434d1 --- /dev/null +++ b/src/Symfony/Component/String/Inflector/EnglishInflector.php @@ -0,0 +1,477 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Inflector; + +final class EnglishInflector implements InflectorInterface +{ + /** + * Map English plural to singular suffixes. + * + * @see http://english-zone.com/spelling/plurals.html + */ + private static $pluralMap = [ + // First entry: plural suffix, reversed + // Second entry: length of plural suffix + // Third entry: Whether the suffix may succeed a vocal + // Fourth entry: Whether the suffix may succeed a consonant + // Fifth entry: singular suffix, normal + + // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) + ['a', 1, true, true, ['on', 'um']], + + // nebulae (nebula) + ['ea', 2, true, true, 'a'], + + // services (service) + ['secivres', 8, true, true, 'service'], + + // mice (mouse), lice (louse) + ['eci', 3, false, true, 'ouse'], + + // geese (goose) + ['esee', 4, false, true, 'oose'], + + // fungi (fungus), alumni (alumnus), syllabi (syllabus), radii (radius) + ['i', 1, true, true, 'us'], + + // men (man), women (woman) + ['nem', 3, true, true, 'man'], + + // children (child) + ['nerdlihc', 8, true, true, 'child'], + + // oxen (ox) + ['nexo', 4, false, false, 'ox'], + + // indices (index), appendices (appendix), prices (price) + ['seci', 4, false, true, ['ex', 'ix', 'ice']], + + // selfies (selfie) + ['seifles', 7, true, true, 'selfie'], + + // movies (movie) + ['seivom', 6, true, true, 'movie'], + + // feet (foot) + ['teef', 4, true, true, 'foot'], + + // geese (goose) + ['eseeg', 5, true, true, 'goose'], + + // teeth (tooth) + ['hteet', 5, true, true, 'tooth'], + + // news (news) + ['swen', 4, true, true, 'news'], + + // series (series) + ['seires', 6, true, true, 'series'], + + // babies (baby) + ['sei', 3, false, true, 'y'], + + // accesses (access), addresses (address), kisses (kiss) + ['sess', 4, true, false, 'ss'], + + // analyses (analysis), ellipses (ellipsis), fungi (fungus), + // neuroses (neurosis), theses (thesis), emphases (emphasis), + // oases (oasis), crises (crisis), houses (house), bases (base), + // atlases (atlas) + ['ses', 3, true, true, ['s', 'se', 'sis']], + + // objectives (objective), alternative (alternatives) + ['sevit', 5, true, true, 'tive'], + + // drives (drive) + ['sevird', 6, false, true, 'drive'], + + // lives (life), wives (wife) + ['sevi', 4, false, true, 'ife'], + + // moves (move) + ['sevom', 5, true, true, 'move'], + + // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf), caves (cave), staves (staff) + ['sev', 3, true, true, ['f', 've', 'ff']], + + // axes (axis), axes (ax), axes (axe) + ['sexa', 4, false, false, ['ax', 'axe', 'axis']], + + // indexes (index), matrixes (matrix) + ['sex', 3, true, false, 'x'], + + // quizzes (quiz) + ['sezz', 4, true, false, 'z'], + + // bureaus (bureau) + ['suae', 4, false, true, 'eau'], + + // fees (fee), trees (tree), employees (employee) + ['see', 3, true, true, 'ee'], + + // roses (rose), garages (garage), cassettes (cassette), + // waltzes (waltz), heroes (hero), bushes (bush), arches (arch), + // shoes (shoe) + ['se', 2, true, true, ['', 'e']], + + // tags (tag) + ['s', 1, true, true, ''], + + // chateaux (chateau) + ['xuae', 4, false, true, 'eau'], + + // people (person) + ['elpoep', 6, true, true, 'person'], + ]; + + /** + * Map English singular to plural suffixes. + * + * @see http://english-zone.com/spelling/plurals.html + */ + private static $singularMap = [ + // First entry: singular suffix, reversed + // Second entry: length of singular suffix + // Third entry: Whether the suffix may succeed a vocal + // Fourth entry: Whether the suffix may succeed a consonant + // Fifth entry: plural suffix, normal + + // criterion (criteria) + ['airetirc', 8, false, false, 'criterion'], + + // nebulae (nebula) + ['aluben', 6, false, false, 'nebulae'], + + // children (child) + ['dlihc', 5, true, true, 'children'], + + // prices (price) + ['eci', 3, false, true, 'ices'], + + // services (service) + ['ecivres', 7, true, true, 'services'], + + // lives (life), wives (wife) + ['efi', 3, false, true, 'ives'], + + // selfies (selfie) + ['eifles', 6, true, true, 'selfies'], + + // movies (movie) + ['eivom', 5, true, true, 'movies'], + + // lice (louse) + ['esuol', 5, false, true, 'lice'], + + // mice (mouse) + ['esuom', 5, false, true, 'mice'], + + // geese (goose) + ['esoo', 4, false, true, 'eese'], + + // houses (house), bases (base) + ['es', 2, true, true, 'ses'], + + // geese (goose) + ['esoog', 5, true, true, 'geese'], + + // caves (cave) + ['ev', 2, true, true, 'ves'], + + // drives (drive) + ['evird', 5, false, true, 'drives'], + + // objectives (objective), alternative (alternatives) + ['evit', 4, true, true, 'tives'], + + // moves (move) + ['evom', 4, true, true, 'moves'], + + // staves (staff) + ['ffats', 5, true, true, 'staves'], + + // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf) + ['ff', 2, true, true, 'ffs'], + + // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf) + ['f', 1, true, true, ['fs', 'ves']], + + // arches (arch) + ['hc', 2, true, true, 'ches'], + + // bushes (bush) + ['hs', 2, true, true, 'shes'], + + // teeth (tooth) + ['htoot', 5, true, true, 'teeth'], + + // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) + ['mu', 2, true, true, 'a'], + + // men (man), women (woman) + ['nam', 3, true, true, 'men'], + + // people (person) + ['nosrep', 6, true, true, ['persons', 'people']], + + // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) + ['noi', 3, true, true, 'ions'], + + // seasons (season), treasons (treason), poisons (poison), lessons (lesson) + ['nos', 3, true, true, 'sons'], + + // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) + ['no', 2, true, true, 'a'], + + // echoes (echo) + ['ohce', 4, true, true, 'echoes'], + + // heroes (hero) + ['oreh', 4, true, true, 'heroes'], + + // atlases (atlas) + ['salta', 5, true, true, 'atlases'], + + // irises (iris) + ['siri', 4, true, true, 'irises'], + + // analyses (analysis), ellipses (ellipsis), neuroses (neurosis) + // theses (thesis), emphases (emphasis), oases (oasis), + // crises (crisis) + ['sis', 3, true, true, 'ses'], + + // accesses (access), addresses (address), kisses (kiss) + ['ss', 2, true, false, 'sses'], + + // syllabi (syllabus) + ['suballys', 8, true, true, 'syllabi'], + + // buses (bus) + ['sub', 3, true, true, 'buses'], + + // circuses (circus) + ['suc', 3, true, true, 'cuses'], + + // fungi (fungus), alumni (alumnus), syllabi (syllabus), radii (radius) + ['su', 2, true, true, 'i'], + + // news (news) + ['swen', 4, true, true, 'news'], + + // feet (foot) + ['toof', 4, true, true, 'feet'], + + // chateaux (chateau), bureaus (bureau) + ['uae', 3, false, true, ['eaus', 'eaux']], + + // oxen (ox) + ['xo', 2, false, false, 'oxen'], + + // hoaxes (hoax) + ['xaoh', 4, true, false, 'hoaxes'], + + // indices (index) + ['xedni', 5, false, true, ['indicies', 'indexes']], + + // boxes (box) + ['xo', 2, false, true, 'oxes'], + + // indexes (index), matrixes (matrix) + ['x', 1, true, false, ['cies', 'xes']], + + // appendices (appendix) + ['xi', 2, false, true, 'ices'], + + // babies (baby) + ['y', 1, false, true, 'ies'], + + // quizzes (quiz) + ['ziuq', 4, true, false, 'quizzes'], + + // waltzes (waltz) + ['z', 1, true, true, 'zes'], + ]; + + /** + * A list of words which should not be inflected, reversed. + */ + private static $uninflected = [ + 'atad', + 'reed', + 'kcabdeef', + 'hsif', + 'ofni', + 'esoom', + 'seires', + 'peehs', + 'seiceps', + ]; + + /** + * {@inheritdoc} + */ + public function singularize(string $plural): array + { + $pluralRev = strrev($plural); + $lowerPluralRev = strtolower($pluralRev); + $pluralLength = \strlen($lowerPluralRev); + + // Check if the word is one which is not inflected, return early if so + if (\in_array($lowerPluralRev, self::$uninflected, true)) { + return [$plural]; + } + + // The outer loop iterates over the entries of the plural table + // The inner loop $j iterates over the characters of the plural suffix + // in the plural table to compare them with the characters of the actual + // given plural suffix + foreach (self::$pluralMap as $map) { + $suffix = $map[0]; + $suffixLength = $map[1]; + $j = 0; + + // Compare characters in the plural table and of the suffix of the + // given plural one by one + while ($suffix[$j] === $lowerPluralRev[$j]) { + // Let $j point to the next character + ++$j; + + // Successfully compared the last character + // Add an entry with the singular suffix to the singular array + if ($j === $suffixLength) { + // Is there any character preceding the suffix in the plural string? + if ($j < $pluralLength) { + $nextIsVocal = false !== strpos('aeiou', $lowerPluralRev[$j]); + + if (!$map[2] && $nextIsVocal) { + // suffix may not succeed a vocal but next char is one + break; + } + + if (!$map[3] && !$nextIsVocal) { + // suffix may not succeed a consonant but next char is one + break; + } + } + + $newBase = substr($plural, 0, $pluralLength - $suffixLength); + $newSuffix = $map[4]; + + // Check whether the first character in the plural suffix + // is uppercased. If yes, uppercase the first character in + // the singular suffix too + $firstUpper = ctype_upper($pluralRev[$j - 1]); + + if (\is_array($newSuffix)) { + $singulars = []; + + foreach ($newSuffix as $newSuffixEntry) { + $singulars[] = $newBase.($firstUpper ? ucfirst($newSuffixEntry) : $newSuffixEntry); + } + + return $singulars; + } + + return [$newBase.($firstUpper ? ucfirst($newSuffix) : $newSuffix)]; + } + + // Suffix is longer than word + if ($j === $pluralLength) { + break; + } + } + } + + // Assume that plural and singular is identical + return [$plural]; + } + + /** + * {@inheritdoc} + */ + public function pluralize(string $singular): array + { + $singularRev = strrev($singular); + $lowerSingularRev = strtolower($singularRev); + $singularLength = \strlen($lowerSingularRev); + + // Check if the word is one which is not inflected, return early if so + if (\in_array($lowerSingularRev, self::$uninflected, true)) { + return [$singular]; + } + + // The outer loop iterates over the entries of the singular table + // The inner loop $j iterates over the characters of the singular suffix + // in the singular table to compare them with the characters of the actual + // given singular suffix + foreach (self::$singularMap as $map) { + $suffix = $map[0]; + $suffixLength = $map[1]; + $j = 0; + + // Compare characters in the singular table and of the suffix of the + // given plural one by one + + while ($suffix[$j] === $lowerSingularRev[$j]) { + // Let $j point to the next character + ++$j; + + // Successfully compared the last character + // Add an entry with the plural suffix to the plural array + if ($j === $suffixLength) { + // Is there any character preceding the suffix in the plural string? + if ($j < $singularLength) { + $nextIsVocal = false !== strpos('aeiou', $lowerSingularRev[$j]); + + if (!$map[2] && $nextIsVocal) { + // suffix may not succeed a vocal but next char is one + break; + } + + if (!$map[3] && !$nextIsVocal) { + // suffix may not succeed a consonant but next char is one + break; + } + } + + $newBase = substr($singular, 0, $singularLength - $suffixLength); + $newSuffix = $map[4]; + + // Check whether the first character in the singular suffix + // is uppercased. If yes, uppercase the first character in + // the singular suffix too + $firstUpper = ctype_upper($singularRev[$j - 1]); + + if (\is_array($newSuffix)) { + $plurals = []; + + foreach ($newSuffix as $newSuffixEntry) { + $plurals[] = $newBase.($firstUpper ? ucfirst($newSuffixEntry) : $newSuffixEntry); + } + + return $plurals; + } + + return [$newBase.($firstUpper ? ucfirst($newSuffix) : $newSuffix)]; + } + + // Suffix is longer than word + if ($j === $singularLength) { + break; + } + } + } + + // Assume that plural is singular with a trailing `s` + return [$singular.'s']; + } +} diff --git a/src/Symfony/Component/String/Inflector/InflectorInterface.php b/src/Symfony/Component/String/Inflector/InflectorInterface.php new file mode 100644 index 0000000000..ad78070b05 --- /dev/null +++ b/src/Symfony/Component/String/Inflector/InflectorInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Inflector; + +interface InflectorInterface +{ + /** + * Returns the singular forms of a string. + * + * If the method can't determine the form with certainty, several possible singulars are returned. + * + * @return string[] An array of possible singular forms + */ + public function singularize(string $plural): array; + + /** + * Returns the plural forms of a string. + * + * If the method can't determine the form with certainty, several possible plurals are returned. + * + * @return string[] An array of possible plural forms + */ + public function pluralize(string $singular): array; +} diff --git a/src/Symfony/Component/String/Tests/EnglishInflectorTest.php b/src/Symfony/Component/String/Tests/EnglishInflectorTest.php new file mode 100644 index 0000000000..51b362bb2f --- /dev/null +++ b/src/Symfony/Component/String/Tests/EnglishInflectorTest.php @@ -0,0 +1,309 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\String\Inflector\EnglishInflector; + +class EnglishInflectorTest extends TestCase +{ + public function singularizeProvider() + { + // see http://english-zone.com/spelling/plurals.html + // see http://www.scribd.com/doc/3271143/List-of-100-Irregular-Plural-Nouns-in-English + return [ + ['accesses', 'access'], + ['addresses', 'address'], + ['agendas', 'agenda'], + ['alumnae', 'alumna'], + ['alumni', 'alumnus'], + ['analyses', ['analys', 'analyse', 'analysis']], + ['antennae', 'antenna'], + ['antennas', 'antenna'], + ['appendices', ['appendex', 'appendix', 'appendice']], + ['arches', ['arch', 'arche']], + ['atlases', ['atlas', 'atlase', 'atlasis']], + ['axes', ['ax', 'axe', 'axis']], + ['babies', 'baby'], + ['bacteria', ['bacterion', 'bacterium']], + ['bases', ['bas', 'base', 'basis']], + ['batches', ['batch', 'batche']], + ['beaux', 'beau'], + ['bees', 'bee'], + ['boxes', 'box'], + ['boys', 'boy'], + ['bureaus', 'bureau'], + ['bureaux', 'bureau'], + ['buses', ['bus', 'buse', 'busis']], + ['bushes', ['bush', 'bushe']], + ['calves', ['calf', 'calve', 'calff']], + ['cars', 'car'], + ['cassettes', ['cassett', 'cassette']], + ['caves', ['caf', 'cave', 'caff']], + ['chateaux', 'chateau'], + ['cheeses', ['chees', 'cheese', 'cheesis']], + ['children', 'child'], + ['circuses', ['circus', 'circuse', 'circusis']], + ['cliffs', 'cliff'], + ['committee', 'committee'], + ['crises', ['cris', 'crise', 'crisis']], + ['criteria', ['criterion', 'criterium']], + ['cups', 'cup'], + ['data', 'data'], + ['days', 'day'], + ['discos', 'disco'], + ['devices', ['devex', 'devix', 'device']], + ['drives', 'drive'], + ['drivers', 'driver'], + ['dwarves', ['dwarf', 'dwarve', 'dwarff']], + ['echoes', ['echo', 'echoe']], + ['elves', ['elf', 'elve', 'elff']], + ['emphases', ['emphas', 'emphase', 'emphasis']], + ['employees', 'employee'], + ['faxes', 'fax'], + ['fees', 'fee'], + ['feet', 'foot'], + ['feedback', 'feedback'], + ['foci', 'focus'], + ['focuses', ['focus', 'focuse', 'focusis']], + ['formulae', 'formula'], + ['formulas', 'formula'], + ['fungi', 'fungus'], + ['funguses', ['fungus', 'funguse', 'fungusis']], + ['garages', ['garag', 'garage']], + ['geese', 'goose'], + ['halves', ['half', 'halve', 'halff']], + ['hats', 'hat'], + ['heroes', ['hero', 'heroe']], + ['hippopotamuses', ['hippopotamus', 'hippopotamuse', 'hippopotamusis']], //hippopotami + ['hoaxes', 'hoax'], + ['hooves', ['hoof', 'hoove', 'hooff']], + ['houses', ['hous', 'house', 'housis']], + ['indexes', 'index'], + ['indices', ['index', 'indix', 'indice']], + ['ions', 'ion'], + ['irises', ['iris', 'irise', 'irisis']], + ['kisses', 'kiss'], + ['knives', 'knife'], + ['lamps', 'lamp'], + ['lessons', 'lesson'], + ['leaves', ['leaf', 'leave', 'leaff']], + ['lice', 'louse'], + ['lives', 'life'], + ['matrices', ['matrex', 'matrix', 'matrice']], + ['matrixes', 'matrix'], + ['men', 'man'], + ['mice', 'mouse'], + ['moves', 'move'], + ['movies', 'movie'], + ['nebulae', 'nebula'], + ['neuroses', ['neuros', 'neurose', 'neurosis']], + ['news', 'news'], + ['oases', ['oas', 'oase', 'oasis']], + ['objectives', 'objective'], + ['oxen', 'ox'], + ['parties', 'party'], + ['people', 'person'], + ['persons', 'person'], + ['phenomena', ['phenomenon', 'phenomenum']], + ['photos', 'photo'], + ['pianos', 'piano'], + ['plateaux', 'plateau'], + ['poisons', 'poison'], + ['poppies', 'poppy'], + ['prices', ['prex', 'prix', 'price']], + ['quizzes', 'quiz'], + ['radii', 'radius'], + ['roofs', 'roof'], + ['roses', ['ros', 'rose', 'rosis']], + ['sandwiches', ['sandwich', 'sandwiche']], + ['scarves', ['scarf', 'scarve', 'scarff']], + ['schemas', 'schema'], //schemata + ['seasons', 'season'], + ['selfies', 'selfie'], + ['series', 'series'], + ['services', 'service'], + ['sheriffs', 'sheriff'], + ['shoes', ['sho', 'shoe']], + ['species', 'species'], + ['spies', 'spy'], + ['staves', ['staf', 'stave', 'staff']], + ['stories', 'story'], + ['strata', ['straton', 'stratum']], + ['suitcases', ['suitcas', 'suitcase', 'suitcasis']], + ['syllabi', 'syllabus'], + ['tags', 'tag'], + ['teeth', 'tooth'], + ['theses', ['thes', 'these', 'thesis']], + ['thieves', ['thief', 'thieve', 'thieff']], + ['treasons', 'treason'], + ['trees', 'tree'], + ['waltzes', ['waltz', 'waltze']], + ['wives', 'wife'], + + // test casing: if the first letter was uppercase, it should remain so + ['Men', 'Man'], + ['GrandChildren', 'GrandChild'], + ['SubTrees', 'SubTree'], + + // Known issues + //['insignia', 'insigne'], + //['insignias', 'insigne'], + //['rattles', 'rattle'], + ]; + } + + public function pluralizeProvider() + { + // see http://english-zone.com/spelling/plurals.html + // see http://www.scribd.com/doc/3271143/List-of-100-Irregular-Plural-Nouns-in-English + return [ + ['access', 'accesses'], + ['address', 'addresses'], + ['agenda', 'agendas'], + ['alumnus', 'alumni'], + ['analysis', 'analyses'], + ['antenna', 'antennas'], //antennae + ['appendix', ['appendicies', 'appendixes']], + ['arch', 'arches'], + ['atlas', 'atlases'], + ['axe', 'axes'], + ['baby', 'babies'], + ['bacterium', 'bacteria'], + ['base', 'bases'], + ['batch', 'batches'], + ['beau', ['beaus', 'beaux']], + ['bee', 'bees'], + ['box', 'boxes'], + ['boy', 'boys'], + ['bureau', ['bureaus', 'bureaux']], + ['bus', 'buses'], + ['bush', 'bushes'], + ['calf', ['calfs', 'calves']], + ['car', 'cars'], + ['cassette', 'cassettes'], + ['cave', 'caves'], + ['chateau', ['chateaus', 'chateaux']], + ['cheese', 'cheeses'], + ['child', 'children'], + ['circus', 'circuses'], + ['cliff', 'cliffs'], + ['committee', 'committees'], + ['crisis', 'crises'], + ['criteria', 'criterion'], + ['cup', 'cups'], + ['data', 'data'], + ['day', 'days'], + ['disco', 'discos'], + ['device', 'devices'], + ['drive', 'drives'], + ['driver', 'drivers'], + ['dwarf', ['dwarfs', 'dwarves']], + ['echo', 'echoes'], + ['elf', ['elfs', 'elves']], + ['emphasis', 'emphases'], + ['fax', ['facies', 'faxes']], + ['feedback', 'feedback'], + ['focus', 'focuses'], + ['foot', 'feet'], + ['formula', 'formulas'], //formulae + ['fungus', 'fungi'], + ['garage', 'garages'], + ['goose', 'geese'], + ['half', ['halfs', 'halves']], + ['hat', 'hats'], + ['hero', 'heroes'], + ['hippopotamus', 'hippopotami'], //hippopotamuses + ['hoax', 'hoaxes'], + ['hoof', ['hoofs', 'hooves']], + ['house', 'houses'], + ['index', ['indicies', 'indexes']], + ['ion', 'ions'], + ['iris', 'irises'], + ['kiss', 'kisses'], + ['knife', 'knives'], + ['lamp', 'lamps'], + ['leaf', ['leafs', 'leaves']], + ['lesson', 'lessons'], + ['life', 'lives'], + ['louse', 'lice'], + ['man', 'men'], + ['matrix', ['matricies', 'matrixes']], + ['mouse', 'mice'], + ['move', 'moves'], + ['movie', 'movies'], + ['nebula', 'nebulae'], + ['neurosis', 'neuroses'], + ['news', 'news'], + ['oasis', 'oases'], + ['objective', 'objectives'], + ['ox', 'oxen'], + ['party', 'parties'], + ['person', ['persons', 'people']], + ['phenomenon', 'phenomena'], + ['photo', 'photos'], + ['piano', 'pianos'], + ['plateau', ['plateaus', 'plateaux']], + ['poison', 'poisons'], + ['poppy', 'poppies'], + ['price', 'prices'], + ['quiz', 'quizzes'], + ['radius', 'radii'], + ['roof', ['roofs', 'rooves']], + ['rose', 'roses'], + ['sandwich', 'sandwiches'], + ['scarf', ['scarfs', 'scarves']], + ['schema', 'schemas'], //schemata + ['season', 'seasons'], + ['selfie', 'selfies'], + ['series', 'series'], + ['service', 'services'], + ['sheriff', 'sheriffs'], + ['shoe', 'shoes'], + ['species', 'species'], + ['spy', 'spies'], + ['staff', 'staves'], + ['story', 'stories'], + ['stratum', 'strata'], + ['suitcase', 'suitcases'], + ['syllabus', 'syllabi'], + ['tag', 'tags'], + ['thief', ['thiefs', 'thieves']], + ['tooth', 'teeth'], + ['treason', 'treasons'], + ['tree', 'trees'], + ['waltz', 'waltzes'], + ['wife', 'wives'], + + // test casing: if the first letter was uppercase, it should remain so + ['Man', 'Men'], + ['GrandChild', 'GrandChildren'], + ['SubTree', 'SubTrees'], + ]; + } + + /** + * @dataProvider singularizeProvider + */ + public function testSingularize(string $plural, $singular) + { + $this->assertSame(\is_array($singular) ? $singular : [$singular], (new EnglishInflector())->singularize($plural)); + } + + /** + * @dataProvider pluralizeProvider + */ + public function testPluralize(string $singular, $plural) + { + $this->assertSame(\is_array($plural) ? $plural : [$plural], (new EnglishInflector())->pluralize($singular)); + } +}