From adfa98a608104d8f6b4d008d144523f8b79f0016 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Thu, 20 Jun 2024 17:52:34 +0200 Subject: [PATCH 01/14] Prefix all sprintf() calls --- AbstractString.php | 12 ++++++------ AbstractUnicodeString.php | 4 ++-- ByteString.php | 4 ++-- LazyString.php | 4 ++-- Resources/WcswidthDataGenerator.php | 2 +- Slugger/AsciiSlugger.php | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/AbstractString.php b/AbstractString.php index 253d2dc..d68f334 100644 --- a/AbstractString.php +++ b/AbstractString.php @@ -263,7 +263,7 @@ public function containsAny(string|iterable $needle): bool public function endsWith(string|iterable $suffix): bool { if (\is_string($suffix)) { - throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); + throw new \TypeError(\sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); } foreach ($suffix as $s) { @@ -312,7 +312,7 @@ public function ensureStart(string $prefix): static public function equalsTo(string|iterable $string): bool { if (\is_string($string)) { - throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); + throw new \TypeError(\sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); } foreach ($string as $s) { @@ -340,7 +340,7 @@ public function ignoreCase(): static public function indexOf(string|iterable $needle, int $offset = 0): ?int { if (\is_string($needle)) { - throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); + throw new \TypeError(\sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); } $i = \PHP_INT_MAX; @@ -362,7 +362,7 @@ public function indexOf(string|iterable $needle, int $offset = 0): ?int public function indexOfLast(string|iterable $needle, int $offset = 0): ?int { if (\is_string($needle)) { - throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); + throw new \TypeError(\sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); } $i = null; @@ -414,7 +414,7 @@ abstract public function prepend(string ...$prefix): static; public function repeat(int $multiplier): static { if (0 > $multiplier) { - throw new InvalidArgumentException(sprintf('Multiplier must be positive, %d given.', $multiplier)); + throw new InvalidArgumentException(\sprintf('Multiplier must be positive, %d given.', $multiplier)); } $str = clone $this; @@ -481,7 +481,7 @@ public function split(string $delimiter, ?int $limit = null, ?int $flags = null) public function startsWith(string|iterable $prefix): bool { if (\is_string($prefix)) { - throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); + throw new \TypeError(\sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class)); } foreach ($prefix as $prefix) { diff --git a/AbstractUnicodeString.php b/AbstractUnicodeString.php index f3acb2e..45649ff 100644 --- a/AbstractUnicodeString.php +++ b/AbstractUnicodeString.php @@ -124,7 +124,7 @@ public function ascii(array $rules = []): self } if (null === $transliterator) { - throw new InvalidArgumentException(sprintf('Unknown transliteration rule "%s".', $rule)); + throw new InvalidArgumentException(\sprintf('Unknown transliteration rule "%s".', $rule)); } self::$transliterators['any-latin/bgn'] = $transliterator; @@ -139,7 +139,7 @@ public function ascii(array $rules = []): self $c = (string) iconv('UTF-8', 'ASCII//TRANSLIT', $c[0]); if ('' === $c && '' === iconv('UTF-8', 'ASCII//TRANSLIT', '²')) { - throw new \LogicException(sprintf('"%s" requires a translit-able iconv implementation, try installing "gnu-libiconv" if you\'re using Alpine Linux.', static::class)); + throw new \LogicException(\sprintf('"%s" requires a translit-able iconv implementation, try installing "gnu-libiconv" if you\'re using Alpine Linux.', static::class)); } return 1 < \strlen($c) ? ltrim($c, '\'`"^~') : ('' !== $c ? $c : '?'); diff --git a/ByteString.php b/ByteString.php index e6b56ae..98bad02 100644 --- a/ByteString.php +++ b/ByteString.php @@ -46,7 +46,7 @@ public function __construct(string $string = '') public static function fromRandom(int $length = 16, ?string $alphabet = null): self { if ($length <= 0) { - throw new InvalidArgumentException(sprintf('A strictly positive length is expected, "%d" given.', $length)); + throw new InvalidArgumentException(\sprintf('A strictly positive length is expected, "%d" given.', $length)); } $alphabet ??= self::ALPHABET_ALPHANUMERIC; @@ -441,7 +441,7 @@ public function toCodePointString(?string $fromEncoding = null): CodePointString } if (!$validEncoding) { - throw new InvalidArgumentException(sprintf('Invalid "%s" string.', $fromEncoding ?? 'Windows-1252')); + throw new InvalidArgumentException(\sprintf('Invalid "%s" string.', $fromEncoding ?? 'Windows-1252')); } $u->string = mb_convert_encoding($this->string, 'UTF-8', $fromEncoding ?? 'Windows-1252'); diff --git a/LazyString.php b/LazyString.php index 8f2bbbf..b86d733 100644 --- a/LazyString.php +++ b/LazyString.php @@ -26,7 +26,7 @@ class LazyString implements \Stringable, \JsonSerializable public static function fromCallable(callable|array $callback, mixed ...$arguments): static { if (\is_array($callback) && !\is_callable($callback) && !(($callback[0] ?? null) instanceof \Closure || 2 < \count($callback))) { - throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be a callable or a [Closure, method] lazy-callable, "%s" given.', __METHOD__, '['.implode(', ', array_map('get_debug_type', $callback)).']')); + throw new \TypeError(\sprintf('Argument 1 passed to "%s()" must be a callable or a [Closure, method] lazy-callable, "%s" given.', __METHOD__, '['.implode(', ', array_map('get_debug_type', $callback)).']')); } $lazyString = new static(); @@ -94,7 +94,7 @@ public function __toString(): string $r = new \ReflectionFunction($this->value); $callback = $r->getStaticVariables()['callback']; - $e = new \TypeError(sprintf('Return value of %s() passed to %s::fromCallable() must be of the type string, %s returned.', $callback, static::class, $type)); + $e = new \TypeError(\sprintf('Return value of %s() passed to %s::fromCallable() must be of the type string, %s returned.', $callback, static::class, $type)); } throw $e; diff --git a/Resources/WcswidthDataGenerator.php b/Resources/WcswidthDataGenerator.php index dd6b923..19e6e89 100644 --- a/Resources/WcswidthDataGenerator.php +++ b/Resources/WcswidthDataGenerator.php @@ -71,7 +71,7 @@ private function write(string $fileName, string $version, array $rawData): void $content = $this->getHeader($version).'return '.VarExporter::export($this->format($rawData)).";\n"; if (!file_put_contents($this->outDir.'/'.$fileName, $content)) { - throw new RuntimeException(sprintf('The "%s" file could not be written.', $fileName)); + throw new RuntimeException(\sprintf('The "%s" file could not be written.', $fileName)); } } diff --git a/Slugger/AsciiSlugger.php b/Slugger/AsciiSlugger.php index d254532..9d4edf1 100644 --- a/Slugger/AsciiSlugger.php +++ b/Slugger/AsciiSlugger.php @@ -92,7 +92,7 @@ public function getLocale(): string public function withEmoji(bool|string $emoji = true): static { if (false !== $emoji && !class_exists(EmojiTransliterator::class)) { - throw new \LogicException(sprintf('You cannot use the "%s()" method as the "symfony/emoji" package is not installed. Try running "composer require symfony/emoji".', __METHOD__)); + throw new \LogicException(\sprintf('You cannot use the "%s()" method as the "symfony/emoji" package is not installed. Try running "composer require symfony/emoji".', __METHOD__)); } $new = clone $this; From 531bcd7d236925c5e4ee52fb5b272f9277aef872 Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Sun, 16 Jun 2024 17:17:26 +0200 Subject: [PATCH 02/14] chore: CS fixes --- Inflector/EnglishInflector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Inflector/EnglishInflector.php b/Inflector/EnglishInflector.php index 56f03b7..9c1b9cb 100644 --- a/Inflector/EnglishInflector.php +++ b/Inflector/EnglishInflector.php @@ -141,7 +141,7 @@ final class EnglishInflector implements InflectorInterface // shoes (shoe) ['se', 2, true, true, ['', 'e']], - // status (status) + // status (status) ['sutats', 6, true, true, 'status'], // tags (tag) From 25ad779fed5c907f820454d981f26b2f945a14ab Mon Sep 17 00:00:00 2001 From: Baptiste Leduc Date: Thu, 30 May 2024 14:00:21 +0200 Subject: [PATCH 03/14] [String] Add WORD_STRICT mode to truncate method --- AbstractString.php | 13 ++++++++-- CHANGELOG.md | 5 ++++ Tests/AbstractAsciiTestCase.php | 15 ++++++++++-- TruncateMode.php | 42 +++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 TruncateMode.php diff --git a/AbstractString.php b/AbstractString.php index d68f334..176a7ae 100644 --- a/AbstractString.php +++ b/AbstractString.php @@ -605,7 +605,7 @@ public function trimSuffix($suffix): static return $str; } - public function truncate(int $length, string $ellipsis = '', bool $cut = true): static + public function truncate(int $length, string $ellipsis = '', bool|TruncateMode $cut = TruncateMode::Char): static { $stringLength = $this->length(); @@ -619,7 +619,8 @@ public function truncate(int $length, string $ellipsis = '', bool $cut = true): $ellipsisLength = 0; } - if (!$cut) { + $desiredLength = $length; + if (TruncateMode::WordAfter === $cut || TruncateMode::WordBefore === $cut || !$cut) { if (null === $length = $this->indexOf([' ', "\r", "\n", "\t"], ($length ?: 1) - 1)) { return clone $this; } @@ -629,6 +630,14 @@ public function truncate(int $length, string $ellipsis = '', bool $cut = true): $str = $this->slice(0, $length - $ellipsisLength); + if (TruncateMode::WordBefore === $cut) { + if (0 === $ellipsisLength && $desiredLength === $this->indexOf([' ', "\r", "\n", "\t"], $length)) { + return $str; + } + + $str = $str->beforeLast([' ', "\r", "\n", "\t"]); + } + return $ellipsisLength ? $str->trimEnd()->append($ellipsis) : $str; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 621cedf..f1f7204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.2 +--- + + * Add `TruncateMode` enum to handle more truncate methods + 7.1 --- diff --git a/Tests/AbstractAsciiTestCase.php b/Tests/AbstractAsciiTestCase.php index f8b0509..3cbcd08 100644 --- a/Tests/AbstractAsciiTestCase.php +++ b/Tests/AbstractAsciiTestCase.php @@ -16,6 +16,7 @@ use Symfony\Component\String\ByteString; use Symfony\Component\String\CodePointString; use Symfony\Component\String\Exception\InvalidArgumentException; +use Symfony\Component\String\TruncateMode; use Symfony\Component\String\UnicodeString; abstract class AbstractAsciiTestCase extends TestCase @@ -1500,22 +1501,24 @@ public static function providePadStart() /** * @dataProvider provideTruncate */ - public function testTruncate(string $expected, string $origin, int $length, string $ellipsis, bool $cut = true) + public function testTruncate(string $expected, string $origin, int $length, string $ellipsis, bool|TruncateMode $cut = TruncateMode::Char) { $instance = static::createFromString($origin)->truncate($length, $ellipsis, $cut); $this->assertEquals(static::createFromString($expected), $instance); } - public static function provideTruncate() + public static function provideTruncate(): array { return [ ['', '', 3, ''], ['', 'foo', 0, '...'], ['foo', 'foo', 0, '...', false], + ['foo', 'foo', 0, '...', TruncateMode::WordAfter], ['fo', 'foobar', 2, ''], ['foobar', 'foobar', 10, ''], ['foobar', 'foobar', 10, '...', false], + ['foobar', 'foobar', 10, '...', TruncateMode::WordAfter], ['foo', 'foo', 3, '...'], ['fo', 'foobar', 2, '...'], ['...', 'foobar', 3, '...'], @@ -1524,6 +1527,14 @@ public static function provideTruncate() ['foobar...', 'foobar foo', 7, '...', false], ['foobar foo...', 'foobar foo a', 10, '...', false], ['foobar foo aar', 'foobar foo aar', 12, '...', false], + ['foobar...', 'foobar foo', 6, '...', TruncateMode::WordAfter], + ['foobar...', 'foobar foo', 7, '...', TruncateMode::WordAfter], + ['foobar foo...', 'foobar foo a', 10, '...', TruncateMode::WordAfter], + ['foobar foo aar', 'foobar foo aar', 12, '...', TruncateMode::WordAfter], + ['foobar foo', 'foobar foo aar', 10, '', TruncateMode::WordBefore], + ['foobar...', 'foobar foo aar', 10, '...', TruncateMode::WordBefore], + ['Lorem ipsum', 'Lorem ipsum dolor sit amet', 14, '', TruncateMode::WordBefore], + ['Lorem...', 'Lorem ipsum dolor sit amet', 10, '...', TruncateMode::WordBefore], ]; } diff --git a/TruncateMode.php b/TruncateMode.php new file mode 100644 index 0000000..12568cd --- /dev/null +++ b/TruncateMode.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String; + +enum TruncateMode +{ + /** + * Will cut exactly at given length. + * + * Length: 14 + * Source: Lorem ipsum dolor sit amet + * Output: Lorem ipsum do + */ + case Char; + + /** + * Returns the string up to the last complete word containing the specified length. + * + * Length: 14 + * Source: Lorem ipsum dolor sit amet + * Output: Lorem ipsum + */ + case WordBefore; + + /** + * Returns the string up to the complete word after or at the given length. + * + * Length: 14 + * Source: Lorem ipsum dolor sit amet + * Output: Lorem ipsum dolor + */ + case WordAfter; +} From 10e1cbfff6d8f2c14f8f6236d5a1d9bca48797e8 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 6 Jul 2024 09:57:16 +0200 Subject: [PATCH 04/14] Update .gitattributes --- .gitattributes | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index 0f57d86..166549d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,4 @@ /Resources/WcswidthDataGenerator.php export-ignore /Tests export-ignore /phpunit.xml.dist export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore From f9fde996ab74dcc429d00161da2fad36a1f47d13 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Fri, 19 Jul 2024 14:33:38 +0200 Subject: [PATCH 05/14] fix truncating in WordBefore mode with length after last space --- AbstractString.php | 4 +++- Tests/AbstractAsciiTestCase.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/AbstractString.php b/AbstractString.php index 176a7ae..81636c4 100644 --- a/AbstractString.php +++ b/AbstractString.php @@ -620,11 +620,13 @@ public function truncate(int $length, string $ellipsis = '', bool|TruncateMode $ } $desiredLength = $length; - if (TruncateMode::WordAfter === $cut || TruncateMode::WordBefore === $cut || !$cut) { + if (TruncateMode::WordAfter === $cut || !$cut) { if (null === $length = $this->indexOf([' ', "\r", "\n", "\t"], ($length ?: 1) - 1)) { return clone $this; } + $length += $ellipsisLength; + } elseif (TruncateMode::WordBefore === $cut && null !== $this->indexOf([' ', "\r", "\n", "\t"], ($length ?: 1) - 1)) { $length += $ellipsisLength; } diff --git a/Tests/AbstractAsciiTestCase.php b/Tests/AbstractAsciiTestCase.php index 0c04f30..196b55a 100644 --- a/Tests/AbstractAsciiTestCase.php +++ b/Tests/AbstractAsciiTestCase.php @@ -1534,14 +1534,42 @@ public static function provideTruncate(): array ['foobar...', 'foobar foo', 7, '...', false], ['foobar foo...', 'foobar foo a', 10, '...', false], ['foobar foo aar', 'foobar foo aar', 12, '...', false], + ['foobar', 'foobar foo', 6, '', TruncateMode::Char], + ['foobar', 'foobar foo', 6, '', TruncateMode::WordAfter], + ['foobar', 'foobar foo', 6, '', TruncateMode::WordBefore], + ['foo...', 'foobar foo', 6, '...', TruncateMode::Char], ['foobar...', 'foobar foo', 6, '...', TruncateMode::WordAfter], + ['foobar...', 'foobar foo', 6, '...', TruncateMode::WordBefore], + ['foobar ', 'foobar foo', 7, '', TruncateMode::Char], + ['foobar', 'foobar foo', 7, '', TruncateMode::WordAfter], + ['foobar', 'foobar foo', 7, '', TruncateMode::WordBefore], + ['foob...', 'foobar foo', 7, '...', TruncateMode::Char], ['foobar...', 'foobar foo', 7, '...', TruncateMode::WordAfter], + ['foobar...', 'foobar foo', 7, '...', TruncateMode::WordBefore], + ['foobar foo', 'foobar foo a', 10, '', TruncateMode::Char], + ['foobar foo', 'foobar foo a', 10, '', TruncateMode::WordAfter], + ['foobar foo', 'foobar foo a', 10, '', TruncateMode::WordBefore], + ['foobar...', 'foobar foo a', 10, '...', TruncateMode::Char], ['foobar foo...', 'foobar foo a', 10, '...', TruncateMode::WordAfter], + ['foobar...', 'foobar foo a', 10, '...', TruncateMode::WordBefore], + ['foobar foo a', 'foobar foo aar', 12, '', TruncateMode::Char], + ['foobar foo aar', 'foobar foo aar', 12, '', TruncateMode::WordAfter], + ['foobar foo', 'foobar foo aar', 12, '', TruncateMode::WordBefore], + ['foobar fo...', 'foobar foo aar', 12, '...', TruncateMode::Char], ['foobar foo aar', 'foobar foo aar', 12, '...', TruncateMode::WordAfter], + ['foobar...', 'foobar foo aar', 12, '...', TruncateMode::WordBefore], + ['foobar foo', 'foobar foo aar', 10, '', TruncateMode::Char], ['foobar foo', 'foobar foo aar', 10, '', TruncateMode::WordBefore], + ['foobar foo', 'foobar foo aar', 10, '', TruncateMode::WordAfter], + ['foobar...', 'foobar foo aar', 10, '...', TruncateMode::Char], ['foobar...', 'foobar foo aar', 10, '...', TruncateMode::WordBefore], + ['foobar foo...', 'foobar foo aar', 10, '...', TruncateMode::WordAfter], + ['Lorem ipsum do', 'Lorem ipsum dolor sit amet', 14, '', TruncateMode::Char], ['Lorem ipsum', 'Lorem ipsum dolor sit amet', 14, '', TruncateMode::WordBefore], + ['Lorem ipsum dolor', 'Lorem ipsum dolor sit amet', 14, '', TruncateMode::WordAfter], + ['Lorem i...', 'Lorem ipsum dolor sit amet', 10, '...', TruncateMode::Char], ['Lorem...', 'Lorem ipsum dolor sit amet', 10, '...', TruncateMode::WordBefore], + ['Lorem ipsum...', 'Lorem ipsum dolor sit amet', 10, '...', TruncateMode::WordAfter], ]; } From a97458d764c940e73b5debf56fe2054b9423eab4 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Mon, 29 Jul 2024 09:33:48 +0200 Subject: [PATCH 06/14] Remove useless code --- ByteString.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ByteString.php b/ByteString.php index 98bad02..5cbfd6d 100644 --- a/ByteString.php +++ b/ByteString.php @@ -340,7 +340,7 @@ public function reverse(): static public function slice(int $start = 0, ?int $length = null): static { $str = clone $this; - $str->string = (string) substr($this->string, $start, $length ?? \PHP_INT_MAX); + $str->string = substr($this->string, $start, $length ?? \PHP_INT_MAX); return $str; } From f1f4d05da68b136a32593bf967cc716f94b2d95b Mon Sep 17 00:00:00 2001 From: Dennis Tobar Date: Tue, 10 Sep 2024 22:55:42 -0300 Subject: [PATCH 07/14] [String] Add Spanish inflector with some rules --- Inflector/SpanishInflector.php | 126 ++++++++++++++++++ Tests/Inflector/SpanishInflectorTest.php | 158 +++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 Inflector/SpanishInflector.php create mode 100644 Tests/Inflector/SpanishInflectorTest.php diff --git a/Inflector/SpanishInflector.php b/Inflector/SpanishInflector.php new file mode 100644 index 0000000..4b98cb6 --- /dev/null +++ b/Inflector/SpanishInflector.php @@ -0,0 +1,126 @@ + + * + * 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 SpanishInflector implements InflectorInterface +{ + /** + * A list of all rules for pluralise. + * + * @see https://www.spanishdict.com/guide/spanish-plural-noun-forms + * @see https://www.rae.es/gram%C3%A1tica/morfolog%C3%ADa/la-formaci%C3%B3n-del-plural-plurales-en-s-y-plurales-en-es-reglas-generales + */ + // First entry: regex + // Second entry: replacement + private const PLURALIZE_REGEXP = [ + // Specials sí, no + ['/(sí|no)$/i', '\1es'], + + // Words ending with vowel must use -s (RAE 3.2a, 3.2c) + ['/(a|e|i|o|u|á|é|í|ó|ú)$/i', '\1s'], + + // Word ending in s or x and the previous letter is accented (RAE 3.2n) + ['/ás$/i', 'ases'], + ['/és$/i', 'eses'], + ['/ís$/i', 'ises'], + ['/ós$/i', 'oses'], + ['/ús$/i', 'uses'], + + // Words ending in -ión must changed to -iones + ['/ión$/i', '\1iones'], + + // Words ending in some consonants must use -es (RAE 3.2k) + ['/(l|r|n|d|j|s|x|ch|y)$/i', '\1es'], + + // Word ending in z, must changed to ces + ['/(z)$/i', 'ces'], + ]; + + /** + * A list of all rules for singularize. + */ + private const SINGULARIZE_REGEXP = [ + // Specials sí, no + ['/(sí|no)es$/i', '\1'], + + // Words ending in -ión must changed to -iones + ['/iones$/i', '\1ión'], + + // Word ending in z, must changed to ces + ['/ces$/i', 'z'], + + // Word ending in s or x and the previous letter is accented (RAE 3.2n) + ['/(\w)ases$/i', '\1ás'], + ['/eses$/i', 'és'], + ['/ises$/i', 'ís'], + ['/(\w{2,})oses$/i', '\1ós'], + ['/(\w)uses$/i', '\1ús'], + + // Words ending in some consonants and -es, must be the consonants + ['/(l|r|n|d|j|s|x|ch|y)e?s$/i', '\1'], + + // Words ended with vowel and s, must be vowel + ['/(a|e|i|o|u|á|é|ó|í|ú)s$/i', '\1'], + ]; + + private const UNINFLECTED_RULES = [ + // Words ending with pies (RAE 3.2n) + '/.*(piés)$/i', + ]; + + private const UNINFLECTED = '/^(lunes|martes|miércoles|jueves|viernes|análisis|torax|yo|pies)$/i'; + + public function singularize(string $plural): array + { + if ($this->isInflectedWord($plural)) { + return [$plural]; + } + + foreach (self::SINGULARIZE_REGEXP as $rule) { + [$regexp, $replace] = $rule; + + if (1 === preg_match($regexp, $plural)) { + return [preg_replace($regexp, $replace, $plural)]; + } + } + + return [$plural]; + } + + public function pluralize(string $singular): array + { + if ($this->isInflectedWord($singular)) { + return [$singular]; + } + + foreach (self::PLURALIZE_REGEXP as $rule) { + [$regexp, $replace] = $rule; + + if (1 === preg_match($regexp, $singular)) { + return [preg_replace($regexp, $replace, $singular)]; + } + } + + return [$singular.'s']; + } + + private function isInflectedWord(string $word): bool + { + foreach (self::UNINFLECTED_RULES as $rule) { + if (1 === preg_match($rule, $word)) { + return true; + } + } + + return 1 === preg_match(self::UNINFLECTED, $word); + } +} diff --git a/Tests/Inflector/SpanishInflectorTest.php b/Tests/Inflector/SpanishInflectorTest.php new file mode 100644 index 0000000..f0b8e42 --- /dev/null +++ b/Tests/Inflector/SpanishInflectorTest.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Tests\Inflector; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\String\Inflector\SpanishInflector; + +class SpanishInflectorTest extends TestCase +{ + public static function singularizeProvider(): array + { + return [ + // vowels (RAE 3.2a, 3.2c) + ['peras', 'pera'], + ['especies', 'especie'], + ['álcalis', 'álcali'], + ['códigos', 'código'], + ['espíritus', 'espíritu'], + + // accented (RAE 3.2a, 3.2c) + ['papás', 'papá'], + ['cafés', 'café'], + ['isrealís', 'isrealí'], + ['burós', 'buró'], + ['tisús', 'tisú'], + + // ending in -ión + ['aviones', 'avión'], + ['camiones', 'camión'], + + // ending in some letters (RAE 3.2k) + ['amores', 'amor'], + ['antifaces', 'antifaz'], + ['atriles', 'atril'], + ['fácsimiles', 'fácsimil'], + ['vides', 'vid'], + ['reyes', 'rey'], + ['relojes', 'reloj'], + ['faxes', 'fax'], + ['sándwiches', 'sándwich'], + ['cánones', 'cánon'], + + // (RAE 3.2n) + ['adioses', 'adiós'], + ['aguarrases', 'aguarrás'], + ['arneses', 'arnés'], + ['autobuses', 'autobús'], + ['kermeses', 'kermés'], + ['palmareses', 'palmarés'], + ['toses', 'tos'], + + // Special + ['síes', 'sí'], + ['noes', 'no'], + ]; + } + + public static function pluralizeProvider(): array + { + return [ + // vowels (RAE 3.2a, 3.2c) + ['pera', 'peras'], + ['especie', 'especies'], + ['álcali', 'álcalis'], + ['código', 'códigos'], + ['espíritu', 'espíritus'], + + // accented (RAE 3.2a, 3.2c) + ['papá', 'papás'], + ['café', 'cafés'], + ['isrealí', 'isrealís'], + ['buró', 'burós'], + ['tisú', 'tisús'], + + // ending in -ión + ['avión', 'aviones'], + ['camión', 'camiones'], + + // ending in some letters (RAE 3.2k) + ['amor', 'amores'], + ['antifaz', 'antifaces'], + ['atril', 'atriles'], + ['fácsimil', 'fácsimiles'], + ['vid', 'vides'], + ['rey', 'reyes'], + ['reloj', 'relojes'], + ['fax', 'faxes'], + ['sándwich', 'sándwiches'], + ['cánon', 'cánones'], + + // (RAE 3.2n) + ['adiós', 'adioses'], + ['aguarrás', 'aguarrases'], + ['arnés', 'arneses'], + ['autobús', 'autobuses'], + ['kermés', 'kermeses'], + ['palmarés', 'palmareses'], + ['tos', 'toses'], + + // Specials + ['sí', 'síes'], + ['no', 'noes'], + ]; + } + + public static function uninflectedProvider(): array + { + return [ + ['lunes'], + ['rodapiés'], + ['reposapiés'], + ['miércoles'], + ['pies'], + ]; + } + + /** + * @dataProvider singularizeProvider + */ + public function testSingularize(string $plural, $singular) + { + $this->assertSame( + \is_array($singular) ? $singular : [$singular], + (new SpanishInflector())->singularize($plural) + ); + } + + /** + * @dataProvider pluralizeProvider + */ + public function testPluralize(string $singular, $plural) + { + $this->assertSame( + \is_array($plural) ? $plural : [$plural], + (new SpanishInflector())->pluralize($singular) + ); + } + + /** + * @dataProvider uninflectedProvider + */ + public function testUninflected(string $word) + { + $this->assertSame( + \is_array($word) ? $word : [$word], + (new SpanishInflector())->pluralize($word) + ); + } +} From 8133473e9c048c97c698d6606efe2fe4ca629657 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Wed, 25 Sep 2024 13:30:45 +0200 Subject: [PATCH 08/14] [String] Add the `AbstractString::kebab()` method --- AbstractString.php | 5 +++++ CHANGELOG.md | 1 + Tests/AbstractAsciiTestCase.php | 29 +++++++++++++++++++++++++++++ Tests/AbstractUnicodeTestCase.php | 9 +++++++++ 4 files changed, 44 insertions(+) diff --git a/AbstractString.php b/AbstractString.php index 81636c4..500d7c3 100644 --- a/AbstractString.php +++ b/AbstractString.php @@ -433,6 +433,11 @@ abstract public function slice(int $start = 0, ?int $length = null): static; abstract public function snake(): static; + public function kebab(): static + { + return $this->snake()->replace('_', '-'); + } + abstract public function splice(string $replacement, int $start = 0, ?int $length = null): static; /** diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f7204..ff505b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add `TruncateMode` enum to handle more truncate methods + * Add the `AbstractString::kebab()` method 7.1 --- diff --git a/Tests/AbstractAsciiTestCase.php b/Tests/AbstractAsciiTestCase.php index e046255..ee4890f 100644 --- a/Tests/AbstractAsciiTestCase.php +++ b/Tests/AbstractAsciiTestCase.php @@ -1089,6 +1089,35 @@ public static function provideSnake() ]; } + /** + * @dataProvider provideKebab + */ + public function testKebab(string $expectedString, string $origin) + { + $instance = static::createFromString($origin)->kebab(); + + $this->assertEquals(static::createFromString($expectedString), $instance); + $this->assertNotSame($origin, $instance, 'Strings should be immutable'); + } + + public static function provideKebab(): array + { + return [ + ['', ''], + ['x-y', 'x_y'], + ['x-y', 'X_Y'], + ['xu-yo', 'xu_yo'], + ['symfony-is-great', 'symfonyIsGreat'], + ['symfony123-is-great', 'symfony123IsGreat'], + ['symfony123is-great', 'symfony123isGreat'], + ['symfony-is-great', 'Symfony is great'], + ['symfony-is-a-great-framework', 'symfonyIsAGreatFramework'], + ['symfony-is-great', 'symfonyIsGREAT'], + ['symfony-is-really-great', 'symfonyIsREALLYGreat'], + ['symfony', 'SYMFONY'], + ]; + } + /** * @dataProvider provideStartsWith */ diff --git a/Tests/AbstractUnicodeTestCase.php b/Tests/AbstractUnicodeTestCase.php index e838c44..bde19d7 100644 --- a/Tests/AbstractUnicodeTestCase.php +++ b/Tests/AbstractUnicodeTestCase.php @@ -665,6 +665,15 @@ public static function provideSnake() ); } + public static function provideKebab(): array + { + return [ + ...parent::provideKebab(), + ['symfony-ist-äußerst-cool', 'symfonyIstÄußerstCool'], + ['symfony-with-emojis', 'Symfony with 😃 emojis'], + ]; + } + public static function provideEqualsTo() { return array_merge( From 205580699b4d3e11f7b679faf2c0f57ffca6981c Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 18 Oct 2024 16:04:52 +0200 Subject: [PATCH 09/14] Remove always true/false occurrences --- Tests/Inflector/SpanishInflectorTest.php | 2 +- UnicodeString.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Inflector/SpanishInflectorTest.php b/Tests/Inflector/SpanishInflectorTest.php index f0b8e42..b10509a 100644 --- a/Tests/Inflector/SpanishInflectorTest.php +++ b/Tests/Inflector/SpanishInflectorTest.php @@ -151,7 +151,7 @@ public function testPluralize(string $singular, $plural) public function testUninflected(string $word) { $this->assertSame( - \is_array($word) ? $word : [$word], + [$word], (new SpanishInflector())->pluralize($word) ); } diff --git a/UnicodeString.php b/UnicodeString.php index 4b16caf..b458de0 100644 --- a/UnicodeString.php +++ b/UnicodeString.php @@ -286,7 +286,7 @@ public function splice(string $replacement, int $start = 0, ?int $length = null) $str = clone $this; $start = $start ? \strlen(grapheme_substr($this->string, 0, $start)) : 0; - $length = $length ? \strlen(grapheme_substr($this->string, $start, $length ?? 2147483647)) : $length; + $length = $length ? \strlen(grapheme_substr($this->string, $start, $length)) : $length; $str->string = substr_replace($this->string, $replacement, $start, $length ?? 2147483647); if (normalizer_is_normalized($str->string)) { From 163411410d03d2962f1d7bab3edb8fecf6cc3b38 Mon Sep 17 00:00:00 2001 From: Raffaele Carelle Date: Fri, 11 Oct 2024 17:14:32 +0200 Subject: [PATCH 10/14] [String] Add `AbstractString::pascal()` method --- AbstractString.php | 5 +++++ CHANGELOG.md | 5 +++++ Tests/AbstractAsciiTestCase.php | 27 +++++++++++++++++++++++++++ Tests/AbstractUnicodeTestCase.php | 11 +++++++++++ 4 files changed, 48 insertions(+) diff --git a/AbstractString.php b/AbstractString.php index 500d7c3..fc60f8f 100644 --- a/AbstractString.php +++ b/AbstractString.php @@ -438,6 +438,11 @@ public function kebab(): static return $this->snake()->replace('_', '-'); } + public function pascal(): static + { + return $this->camel()->title(); + } + abstract public function splice(string $replacement, int $start = 0, ?int $length = null): static; /** diff --git a/CHANGELOG.md b/CHANGELOG.md index ff505b1..ac4b8fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + +* Add the `AbstractString::pascal()` method + 7.2 --- diff --git a/Tests/AbstractAsciiTestCase.php b/Tests/AbstractAsciiTestCase.php index ee4890f..e673f27 100644 --- a/Tests/AbstractAsciiTestCase.php +++ b/Tests/AbstractAsciiTestCase.php @@ -1118,6 +1118,33 @@ public static function provideKebab(): array ]; } + /** + * @dataProvider providePascal + */ + public function testPascal(string $expectedString, string $origin) + { + $instance = static::createFromString($origin)->pascal(); + + $this->assertEquals(static::createFromString($expectedString), $instance); + $this->assertNotSame($origin, $instance, 'Strings should be immutable'); + } + + public static function providePascal(): array + { + return [ + ['', ''], + ['XY', 'x_y'], + ['XuYo', 'xu_yo'], + ['SymfonyIsGreat', 'symfony_is_great'], + ['Symfony5IsGreat', 'symfony_5_is_great'], + ['SymfonyIsGreat', 'Symfony is great'], + ['SYMFONYISGREAT', 'SYMFONY_IS_GREAT'], + ['SymfonyIsAGreatFramework', 'Symfony is a great framework'], + ['SymfonyIsGREAT', '*Symfony* is GREAT!!'], + ['SYMFONY', 'SYMFONY'], + ]; + } + /** * @dataProvider provideStartsWith */ diff --git a/Tests/AbstractUnicodeTestCase.php b/Tests/AbstractUnicodeTestCase.php index bde19d7..2433f89 100644 --- a/Tests/AbstractUnicodeTestCase.php +++ b/Tests/AbstractUnicodeTestCase.php @@ -655,6 +655,17 @@ public static function provideCamel() ); } + public static function providePascal(): array + { + return array_merge( + parent::providePascal(), + [ + ['SymfonyIstÄußerstCool', 'symfonyIstÄußerstCool'], + ['SymfonyWithEmojis', 'Symfony with 😃 emojis'], + ] + ); + } + public static function provideSnake() { return array_merge( From 373a11f2d03e71934a0023888edf3328a583e4ec Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 5 Jan 2025 17:34:30 +0100 Subject: [PATCH 11/14] Fix typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac4b8fb..0782ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ CHANGELOG 7.3 --- -* Add the `AbstractString::pascal()` method + * Add the `AbstractString::pascal()` method 7.2 --- From a75110076ac661a4a52792b5a1d286d113653a70 Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Fri, 21 Feb 2025 02:28:48 +0100 Subject: [PATCH 12/14] chore: PHP CS Fixer - allow header validator --- Resources/WcswidthDataGenerator.php | 7 +++++++ Resources/data/wcswidth_table_wide.php | 7 +++++++ Resources/data/wcswidth_table_zero.php | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/Resources/WcswidthDataGenerator.php b/Resources/WcswidthDataGenerator.php index 19e6e89..005b148 100644 --- a/Resources/WcswidthDataGenerator.php +++ b/Resources/WcswidthDataGenerator.php @@ -83,10 +83,17 @@ private function getHeader(string $version): string + * * This file has been auto-generated by the Symfony String Component for internal use. * * Unicode version: $version * Date: $date + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. */ diff --git a/Resources/data/wcswidth_table_wide.php b/Resources/data/wcswidth_table_wide.php index 6a75094..b2c94c3 100644 --- a/Resources/data/wcswidth_table_wide.php +++ b/Resources/data/wcswidth_table_wide.php @@ -1,10 +1,17 @@ + * * This file has been auto-generated by the Symfony String Component for internal use. * * Unicode version: 16.0.0 * Date: 2024-09-11T08:21:22+00:00 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. */ return [ diff --git a/Resources/data/wcswidth_table_zero.php b/Resources/data/wcswidth_table_zero.php index fdd7f3c..287c36c 100644 --- a/Resources/data/wcswidth_table_zero.php +++ b/Resources/data/wcswidth_table_zero.php @@ -1,10 +1,17 @@ + * * This file has been auto-generated by the Symfony String Component for internal use. * * Unicode version: 16.0.0 * Date: 2024-09-11T08:21:22+00:00 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. */ return [ From 71c369598bfa44bb9815fc26b26e592d1d989be1 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Thu, 17 Apr 2025 13:34:07 +0200 Subject: [PATCH 13/14] ignore the current locale before transliterating ASCII codes with iconv() --- AbstractUnicodeString.php | 22 ++++++++++++++-------- Tests/Slugger/AsciiSluggerTest.php | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/AbstractUnicodeString.php b/AbstractUnicodeString.php index 70598e4..bd84b25 100644 --- a/AbstractUnicodeString.php +++ b/AbstractUnicodeString.php @@ -135,15 +135,21 @@ public function ascii(array $rules = []): self } elseif (!\function_exists('iconv')) { $s = preg_replace('/[^\x00-\x7F]/u', '?', $s); } else { - $s = @preg_replace_callback('/[^\x00-\x7F]/u', static function ($c) { - $c = (string) iconv('UTF-8', 'ASCII//TRANSLIT', $c[0]); - - if ('' === $c && '' === iconv('UTF-8', 'ASCII//TRANSLIT', '²')) { - throw new \LogicException(sprintf('"%s" requires a translit-able iconv implementation, try installing "gnu-libiconv" if you\'re using Alpine Linux.', static::class)); - } + $previousLocale = setlocale(\LC_CTYPE, 0); + try { + setlocale(\LC_CTYPE, 'C'); + $s = @preg_replace_callback('/[^\x00-\x7F]/u', static function ($c) { + $c = (string) iconv('UTF-8', 'ASCII//TRANSLIT', $c[0]); + + if ('' === $c && '' === iconv('UTF-8', 'ASCII//TRANSLIT', '²')) { + throw new \LogicException(sprintf('"%s" requires a translit-able iconv implementation, try installing "gnu-libiconv" if you\'re using Alpine Linux.', static::class)); + } - return 1 < \strlen($c) ? ltrim($c, '\'`"^~') : ('' !== $c ? $c : '?'); - }, $s); + return 1 < \strlen($c) ? ltrim($c, '\'`"^~') : ('' !== $c ? $c : '?'); + }, $s); + } finally { + setlocale(\LC_CTYPE, $previousLocale); + } } } diff --git a/Tests/Slugger/AsciiSluggerTest.php b/Tests/Slugger/AsciiSluggerTest.php index 703212f..7a6c06a 100644 --- a/Tests/Slugger/AsciiSluggerTest.php +++ b/Tests/Slugger/AsciiSluggerTest.php @@ -106,4 +106,19 @@ public static function provideSlugEmojiTests(): iterable 'undefined_locale', // Behaves the same as if emoji support is disabled ]; } + + /** + * @requires extension intl + */ + public function testSlugEmojiWithSetLocale() + { + if (!setlocale(LC_ALL, 'C.UTF-8')) { + $this->markTestSkipped('Unable to switch to the "C.UTF-8" locale.'); + } + + $slugger = new AsciiSlugger(); + $slugger = $slugger->withEmoji(true); + + $this->assertSame('a-and-a-go-to', (string) $slugger->slug('a 😺, 🐈‍⬛, and a 🦁 go to 🏞️... 😍 🎉 💛', '-')); + } } From 73e2c6966a5aef1d4892873ed5322245295370c6 Mon Sep 17 00:00:00 2001 From: Korvin Szanto Date: Thu, 17 Apr 2025 09:33:24 -0700 Subject: [PATCH 14/14] Support nexus -> nexuses pluralization --- Inflector/EnglishInflector.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Inflector/EnglishInflector.php b/Inflector/EnglishInflector.php index a5be28d..73db80c 100644 --- a/Inflector/EnglishInflector.php +++ b/Inflector/EnglishInflector.php @@ -333,6 +333,9 @@ final class EnglishInflector implements InflectorInterface // conspectuses (conspectus), prospectuses (prospectus) ['sutcep', 6, true, true, 'pectuses'], + // nexuses (nexus) + ['suxen', 5, false, false, 'nexuses'], + // fungi (fungus), alumni (alumnus), syllabi (syllabus), radii (radius) ['su', 2, true, true, 'i'],