diff --git a/AbstractString.php b/AbstractString.php index f55c721..500d7c3 100644 --- a/AbstractString.php +++ b/AbstractString.php @@ -39,8 +39,8 @@ abstract class AbstractString implements \Stringable, \JsonSerializable public const PREG_SPLIT_DELIM_CAPTURE = \PREG_SPLIT_DELIM_CAPTURE; public const PREG_SPLIT_OFFSET_CAPTURE = \PREG_SPLIT_OFFSET_CAPTURE; - protected $string = ''; - protected $ignoreCase = false; + protected string $string = ''; + protected ?bool $ignoreCase = false; abstract public function __construct(string $string = ''); @@ -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; @@ -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; /** @@ -481,7 +486,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) { @@ -605,7 +610,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,16 +624,27 @@ public function truncate(int $length, string $ellipsis = '', bool $cut = true): $ellipsisLength = 0; } - if (!$cut) { + $desiredLength = $length; + 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; } $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/AbstractUnicodeString.php b/AbstractUnicodeString.php index bd84b25..cf280cd 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; @@ -142,7 +142,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 : '?'); @@ -226,6 +226,21 @@ public function lower(): static return $str; } + /** + * @param string $locale In the format language_region (e.g. tr_TR) + */ + public function localeLower(string $locale): static + { + if (null !== $transliterator = $this->getLocaleTransliterator($locale, 'Lower')) { + $str = clone $this; + $str->string = $transliterator->transliterate($str->string); + + return $str; + } + + return $this->lower(); + } + public function match(string $regexp, int $flags = 0, int $offset = 0): array { $match = ((\PREG_PATTERN_ORDER | \PREG_SET_ORDER) & $flags) ? 'preg_match_all' : 'preg_match'; @@ -369,6 +384,21 @@ public function title(bool $allWords = false): static return $str; } + /** + * @param string $locale In the format language_region (e.g. tr_TR) + */ + public function localeTitle(string $locale): static + { + if (null !== $transliterator = $this->getLocaleTransliterator($locale, 'Title')) { + $str = clone $this; + $str->string = $transliterator->transliterate($str->string); + + return $str; + } + + return $this->title(); + } + public function trim(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): static { if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" !== $chars && !preg_match('//u', $chars)) { @@ -456,6 +486,21 @@ public function upper(): static return $str; } + /** + * @param string $locale In the format language_region (e.g. tr_TR) + */ + public function localeUpper(string $locale): static + { + if (null !== $transliterator = $this->getLocaleTransliterator($locale, 'Upper')) { + $str = clone $this; + $str->string = $transliterator->transliterate($str->string); + + return $str; + } + + return $this->upper(); + } + public function width(bool $ignoreAnsiDecoration = true): int { $width = 0; @@ -593,4 +638,33 @@ private function wcswidth(string $string): int return $width; } + + private function getLocaleTransliterator(string $locale, string $id): ?\Transliterator + { + $rule = $locale.'-'.$id; + if (\array_key_exists($rule, self::$transliterators)) { + return self::$transliterators[$rule]; + } + + if (null !== $transliterator = self::$transliterators[$rule] = \Transliterator::create($rule)) { + return $transliterator; + } + + // Try to find a parent locale (nl_BE -> nl) + if (false === $i = strpos($locale, '_')) { + return null; + } + + $parentRule = substr_replace($locale, '-'.$id, $i); + + // Parent locale was already cached, return and store as current locale + if (\array_key_exists($parentRule, self::$transliterators)) { + return self::$transliterators[$rule] = self::$transliterators[$parentRule]; + } + + // Create transliterator based on parent locale and cache the result on both initial and parent locale values + $transliterator = \Transliterator::create($parentRule); + + return self::$transliterators[$rule] = self::$transliterators[$parentRule] = $transliterator; + } } diff --git a/ByteString.php b/ByteString.php index 3ebe43c..5cbfd6d 100644 --- a/ByteString.php +++ b/ByteString.php @@ -11,6 +11,7 @@ namespace Symfony\Component\String; +use Random\Randomizer; use Symfony\Component\String\Exception\ExceptionInterface; use Symfony\Component\String\Exception\InvalidArgumentException; use Symfony\Component\String\Exception\RuntimeException; @@ -45,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; @@ -55,6 +56,10 @@ public static function fromRandom(int $length = 16, ?string $alphabet = null): s throw new InvalidArgumentException('The length of the alphabet must in the [2^1, 2^56] range.'); } + if (\PHP_VERSION_ID >= 80300) { + return new static((new Randomizer())->getBytesFromString($alphabet, $length)); + } + $ret = ''; while ($length > 0) { $urandomLength = (int) ceil(2 * $length * $bits / 8.0); @@ -335,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; } @@ -436,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/CHANGELOG.md b/CHANGELOG.md index 31a3b54..ff505b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ CHANGELOG ========= +7.2 +--- + + * Add `TruncateMode` enum to handle more truncate methods + * Add the `AbstractString::kebab()` method + +7.1 +--- + + * Add `localeLower()`, `localeUpper()`, `localeTitle()` methods to `AbstractUnicodeString` + 6.2 --- 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/LazyString.php b/LazyString.php index 3d893ef..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; @@ -129,7 +129,7 @@ private static function getPrettyName(callable $callback): string } elseif ($callback instanceof \Closure) { $r = new \ReflectionFunction($callback); - if (str_contains($r->name, '{closure') || !$class = \PHP_VERSION_ID >= 80111 ? $r->getClosureCalledClass() : $r->getClosureScopeClass()) { + if ($r->isAnonymous() || !$class = $r->getClosureCalledClass()) { return $r->name; } diff --git a/Resources/WcswidthDataGenerator.php b/Resources/WcswidthDataGenerator.php index 6425ecc..19e6e89 100644 --- a/Resources/WcswidthDataGenerator.php +++ b/Resources/WcswidthDataGenerator.php @@ -21,13 +21,11 @@ */ final class WcswidthDataGenerator { - private string $outDir; private HttpClientInterface $client; - public function __construct(string $outDir) - { - $this->outDir = $outDir; - + public function __construct( + private string $outDir, + ) { $this->client = HttpClient::createForBaseUri('https://www.unicode.org/Public/UNIDATA/'); } @@ -73,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/Resources/bin/update-data.php b/Resources/bin/update-data.php index c0bd1c1..2ba0cd8 100644 --- a/Resources/bin/update-data.php +++ b/Resources/bin/update-data.php @@ -18,7 +18,7 @@ error_reporting(\E_ALL); set_error_handler(static function (int $type, string $msg, string $file, int $line): void { - throw new \ErrorException($msg, 0, $type, $file, $line); + throw new ErrorException($msg, 0, $type, $file, $line); }); set_exception_handler(static function (Throwable $exception): void { diff --git a/Slugger/AsciiSlugger.php b/Slugger/AsciiSlugger.php index a9693d4..9d4edf1 100644 --- a/Slugger/AsciiSlugger.php +++ b/Slugger/AsciiSlugger.php @@ -11,7 +11,7 @@ namespace Symfony\Component\String\Slugger; -use Symfony\Component\Intl\Transliterator\EmojiTransliterator; +use Symfony\Component\Emoji\EmojiTransliterator; use Symfony\Component\String\AbstractUnicodeString; use Symfony\Component\String\UnicodeString; use Symfony\Contracts\Translation\LocaleAwareInterface; @@ -55,7 +55,6 @@ class AsciiSlugger implements SluggerInterface, LocaleAwareInterface 'zh' => 'Han-Latin', ]; - private ?string $defaultLocale; private \Closure|array $symbolsMap = [ 'en' => ['@' => 'at', '&' => 'and'], ]; @@ -68,16 +67,14 @@ class AsciiSlugger implements SluggerInterface, LocaleAwareInterface */ private array $transliterators = []; - public function __construct(?string $defaultLocale = null, array|\Closure|null $symbolsMap = null) - { - $this->defaultLocale = $defaultLocale; + public function __construct( + private ?string $defaultLocale = null, + array|\Closure|null $symbolsMap = null, + ) { $this->symbolsMap = $symbolsMap ?? $this->symbolsMap; } - /** - * @return void - */ - public function setLocale(string $locale) + public function setLocale(string $locale): void { $this->defaultLocale = $locale; } @@ -95,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/intl" package is not installed. Try running "composer require symfony/intl".', __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; diff --git a/Tests/AbstractAsciiTestCase.php b/Tests/AbstractAsciiTestCase.php index 3e3d970..ee4890f 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 @@ -1088,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 */ @@ -1508,22 +1538,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, '...'], @@ -1532,6 +1564,42 @@ 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::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], ]; } diff --git a/Tests/AbstractUnicodeTestCase.php b/Tests/AbstractUnicodeTestCase.php index 0432fa7..bde19d7 100644 --- a/Tests/AbstractUnicodeTestCase.php +++ b/Tests/AbstractUnicodeTestCase.php @@ -50,6 +50,48 @@ public function testAsciiClosureRule() $this->assertSame('Dieser Wert sollte grOEsser oder gleich', (string) $s->ascii([$rule])); } + /** + * @dataProvider provideLocaleLower + * + * @requires extension intl + */ + public function testLocaleLower(string $locale, string $expected, string $origin) + { + $instance = static::createFromString($origin)->localeLower($locale); + + $this->assertNotSame(static::createFromString($origin), $instance); + $this->assertEquals(static::createFromString($expected), $instance); + $this->assertSame($expected, (string) $instance); + } + + /** + * @dataProvider provideLocaleUpper + * + * @requires extension intl + */ + public function testLocaleUpper(string $locale, string $expected, string $origin) + { + $instance = static::createFromString($origin)->localeUpper($locale); + + $this->assertNotSame(static::createFromString($origin), $instance); + $this->assertEquals(static::createFromString($expected), $instance); + $this->assertSame($expected, (string) $instance); + } + + /** + * @dataProvider provideLocaleTitle + * + * @requires extension intl + */ + public function testLocaleTitle(string $locale, string $expected, string $origin) + { + $instance = static::createFromString($origin)->localeTitle($locale); + + $this->assertNotSame(static::createFromString($origin), $instance); + $this->assertEquals(static::createFromString($expected), $instance); + $this->assertSame($expected, (string) $instance); + } + public static function provideCreateFromCodePoint(): array { return [ @@ -298,6 +340,78 @@ public static function provideLower(): array ); } + public static function provideLocaleLower(): array + { + return [ + // Lithuanian + // Introduce an explicit dot above when lowercasing capital I's and J's + // whenever there are more accents above. + // LATIN CAPITAL LETTER I WITH OGONEK -> LATIN SMALL LETTER I WITH OGONEK + ['lt', 'į', 'Į'], + // LATIN CAPITAL LETTER I WITH GRAVE -> LATIN SMALL LETTER I COMBINING DOT ABOVE + ['lt', 'i̇̀', 'Ì'], + // LATIN CAPITAL LETTER I WITH ACUTE -> LATIN SMALL LETTER I COMBINING DOT ABOVE COMBINING ACUTE ACCENT + ['lt', 'i̇́', 'Í'], + // LATIN CAPITAL LETTER I WITH TILDE -> LATIN SMALL LETTER I COMBINING DOT ABOVE COMBINING TILDE + ['lt', 'i̇̃', 'Ĩ'], + + // Turkish and Azeri + // When lowercasing, remove dot_above in the sequence I + dot_above, which will turn into 'i'. + // LATIN CAPITAL LETTER I WITH DOT ABOVE -> LATIN SMALL LETTER I + ['tr', 'i', 'İ'], + ['tr_TR', 'i', 'İ'], + ['az', 'i', 'İ'], + + // Default casing rules + // LATIN CAPITAL LETTER I WITH DOT ABOVE -> LATIN SMALL LETTER I COMBINING DOT ABOVE + ['en_US', 'i̇', 'İ'], + ['en', 'i̇', 'İ'], + ]; + } + + public static function provideLocaleUpper(): array + { + return [ + // Turkish and Azeri + // When uppercasing, i turns into a dotted capital I + // LATIN SMALL LETTER I -> LATIN CAPITAL LETTER I WITH DOT ABOVE + ['tr', 'İ', 'i'], + ['tr_TR', 'İ', 'i'], + ['az', 'İ', 'i'], + + // Greek + // Remove accents when uppercasing + // GREEK SMALL LETTER ALPHA WITH TONOS -> GREEK CAPITAL LETTER ALPHA + ['el', 'Α', 'ά'], + ['el_GR', 'Α', 'ά'], + + // Default casing rules + // GREEK SMALL LETTER ALPHA WITH TONOS -> GREEK CAPITAL LETTER ALPHA WITH TONOS + ['en_US', 'Ά', 'ά'], + ['en', 'Ά', 'ά'], + ]; + } + + public static function provideLocaleTitle(): array + { + return [ + // Greek + // Titlecasing words, should keep the accents on the first letter + ['el', 'Άδικος', 'άδικος'], + ['el_GR', 'Άδικος', 'άδικος'], + ['en', 'Άδικος', 'άδικος'], + + // Dutch + // Title casing should treat 'ij' as one character + ['nl_NL', 'IJssel', 'ijssel'], + ['nl_BE', 'IJssel', 'ijssel'], + ['nl', 'IJssel', 'ijssel'], + + // Default casing rules + ['en', 'Ijssel', 'ijssel'], + ]; + } + public static function provideUpper(): array { return array_merge( @@ -551,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( diff --git a/Tests/Inflector/SpanishInflectorTest.php b/Tests/Inflector/SpanishInflectorTest.php new file mode 100644 index 0000000..b10509a --- /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( + [$word], + (new SpanishInflector())->pluralize($word) + ); + } +} diff --git a/Tests/Slugger/AsciiSluggerTest.php b/Tests/Slugger/AsciiSluggerTest.php index 7a6c06a..7604f3b 100644 --- a/Tests/Slugger/AsciiSluggerTest.php +++ b/Tests/Slugger/AsciiSluggerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\String; +namespace Symfony\Component\String\Tests\Slugger; use PHPUnit\Framework\TestCase; use Symfony\Component\String\Slugger\AsciiSlugger; 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; +} diff --git a/UnicodeString.php b/UnicodeString.php index 75af2da..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)) { @@ -362,10 +362,7 @@ public function startsWith(string|iterable|AbstractString $prefix): bool return $prefix === grapheme_extract($this->string, \strlen($prefix), \GRAPHEME_EXTR_MAXBYTES); } - /** - * @return void - */ - public function __wakeup() + public function __wakeup(): void { if (!\is_string($this->string)) { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); diff --git a/composer.json b/composer.json index 56c1368..10d0ee6 100644 --- a/composer.json +++ b/composer.json @@ -16,18 +16,19 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, "require-dev": { - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/intl": "^6.2|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/emoji": "^7.1", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0" + "symfony/var-exporter": "^6.4|^7.0" }, "conflict": { "symfony/translation-contracts": "<2.5"