From 012e92a772241d1241bd8bfe60c59096798ab0ef Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 11 Sep 2019 11:23:08 +0200 Subject: [PATCH 1/3] [String] a new component for object-oriented strings management with an abstract unit system --- .travis.yml | 2 +- composer.json | 8 +- src/Symfony/Component/String/.gitattributes | 2 + src/Symfony/Component/String/.gitignore | 3 + .../Component/String/AbstractString.php | 689 ++++++++++++++++++ .../String/AbstractUnicodeString.php | 472 ++++++++++++ src/Symfony/Component/String/BinaryString.php | 475 ++++++++++++ src/Symfony/Component/String/CHANGELOG.md | 7 + .../String/Exception/ExceptionInterface.php | 19 + .../Exception/InvalidArgumentException.php | 19 + .../String/Exception/RuntimeException.php | 19 + .../Component/String/GraphemeString.php | 346 +++++++++ src/Symfony/Component/String/LICENSE | 19 + src/Symfony/Component/String/README.md | 19 + .../Component/String/Resources/functions.php | 28 + src/Symfony/Component/String/Utf8String.php | 261 +++++++ src/Symfony/Component/String/composer.json | 37 + src/Symfony/Component/String/phpunit.xml.dist | 30 + 18 files changed, 2453 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Component/String/.gitattributes create mode 100644 src/Symfony/Component/String/.gitignore create mode 100644 src/Symfony/Component/String/AbstractString.php create mode 100644 src/Symfony/Component/String/AbstractUnicodeString.php create mode 100644 src/Symfony/Component/String/BinaryString.php create mode 100644 src/Symfony/Component/String/CHANGELOG.md create mode 100644 src/Symfony/Component/String/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/String/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/String/Exception/RuntimeException.php create mode 100644 src/Symfony/Component/String/GraphemeString.php create mode 100644 src/Symfony/Component/String/LICENSE create mode 100644 src/Symfony/Component/String/README.md create mode 100644 src/Symfony/Component/String/Resources/functions.php create mode 100644 src/Symfony/Component/String/Utf8String.php create mode 100644 src/Symfony/Component/String/composer.json create mode 100644 src/Symfony/Component/String/phpunit.xml.dist diff --git a/.travis.yml b/.travis.yml index b8a440004d787..c0f725ac677c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -270,7 +270,7 @@ install: (cd src/Symfony/Component/HttpFoundation; mv composer.bak composer.json) COMPONENTS=$(git diff --name-only src/ | grep composer.json || true) - if [[ $COMPONENTS && $LEGACY && $TRAVIS_PULL_REQUEST != false ]]; then + if [[ $COMPONENTS && $LEGACY && $TRAVIS_BRANCH != master && $TRAVIS_PULL_REQUEST != false ]]; then export FLIP='🙃' SYMFONY_VERSION=$(echo $SYMFONY_VERSION | awk '{print $1 - 1}') echo -e "\\n\\e[33;1mChecking out Symfony $SYMFONY_VERSION and running tests with patched components as deps\\e[0m" diff --git a/composer.json b/composer.json index 16f9ee25d6286..7a5818cae9951 100644 --- a/composer.json +++ b/composer.json @@ -27,8 +27,10 @@ "psr/log": "~1.0", "symfony/contracts": "^1.1.7|^2", "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-icu": "~1.0", "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0", "symfony/polyfill-php73": "^1.11" }, @@ -82,6 +84,7 @@ "symfony/sendgrid-mailer": "self.version", "symfony/serializer": "self.version", "symfony/stopwatch": "self.version", + "symfony/string": "self.version", "symfony/templating": "self.version", "symfony/translation": "self.version", "symfony/twig-bridge": "self.version", @@ -144,7 +147,10 @@ ] }, "autoload-dev": { - "files": [ "src/Symfony/Component/VarDumper/Resources/functions/dump.php" ] + "files": [ + "src/Symfony/Component/String/Resources/functions.php", + "src/Symfony/Component/VarDumper/Resources/functions/dump.php" + ] }, "repositories": [ { diff --git a/src/Symfony/Component/String/.gitattributes b/src/Symfony/Component/String/.gitattributes new file mode 100644 index 0000000000000..aa02dc6518d99 --- /dev/null +++ b/src/Symfony/Component/String/.gitattributes @@ -0,0 +1,2 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/src/Symfony/Component/String/.gitignore b/src/Symfony/Component/String/.gitignore new file mode 100644 index 0000000000000..5414c2c655e72 --- /dev/null +++ b/src/Symfony/Component/String/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/Symfony/Component/String/AbstractString.php b/src/Symfony/Component/String/AbstractString.php new file mode 100644 index 0000000000000..8e1c653fef5e7 --- /dev/null +++ b/src/Symfony/Component/String/AbstractString.php @@ -0,0 +1,689 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String; + +use Symfony\Component\String\Exception\ExceptionInterface; +use Symfony\Component\String\Exception\InvalidArgumentException; +use Symfony\Component\String\Exception\RuntimeException; + +/** + * Represents a string of abstract characters. + * + * Unicode defines 3 types of "characters" (bytes, code points and grapheme clusters). + * This class is the abstract type to use as a type-hint when the logic you want to + * implement doesn't care about the exact variant it deals with. + * + * @author Nicolas Grekas + * @author Hugo Hamon + * + * @throws ExceptionInterface + * + * @experimental in 5.0 + */ +abstract class AbstractString implements \JsonSerializable +{ + public const PREG_PATTERN_ORDER = \PREG_PATTERN_ORDER; + public const PREG_SET_ORDER = \PREG_SET_ORDER; + public const PREG_OFFSET_CAPTURE = \PREG_OFFSET_CAPTURE; + public const PREG_UNMATCHED_AS_NULL = \PREG_UNMATCHED_AS_NULL; + + public const PREG_SPLIT = 0; + public const PREG_SPLIT_NO_EMPTY = \PREG_SPLIT_NO_EMPTY; + 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; + + abstract public function __construct(string $string = ''); + + /** + * Unwraps instances of AbstractString back to strings. + * + * @return string[]|array + */ + public static function unwrap(array $values): array + { + foreach ($values as $k => $v) { + if ($v instanceof self) { + $values[$k] = $v->__toString(); + } elseif (\is_array($v) && $values[$k] !== $v = static::unwrap($v)) { + $values[$k] = $v; + } + } + + return $values; + } + + /** + * Wraps (and normalizes) strings in instances of AbstractString. + * + * @return static[]|array + */ + public static function wrap(array $values): array + { + $i = 0; + $keys = null; + + foreach ($values as $k => $v) { + ++$i; + + if (\is_string($k) && '' !== $k && $k !== $j = (string) new static($k)) { + $keys = $keys ?? array_keys($values); + array_splice($keys, $i, 1, [$j]); + } + + if (\is_string($v)) { + $values[$k] = new static($v); + } elseif (\is_array($v) && $values[$k] !== $v = static::wrap($v)) { + $values[$k] = $v; + } + } + + return null !== $keys ? array_combine($keys, $values) : $values; + } + + /** + * @param string|string[] $needle + * + * @return static + */ + public function after($needle, bool $includeNeedle = false, int $offset = 0): self + { + $str = clone $this; + $str->string = ''; + $i = \PHP_INT_MAX; + + foreach ((array) $needle as $n) { + $n = (string) $n; + $j = $this->indexOf($n, $offset); + + if (null !== $j && $j < $i) { + $i = $j; + $str->string = $n; + } + } + + if (\PHP_INT_MAX === $i) { + return $str; + } + + if (!$includeNeedle) { + $i += $str->length(); + } + + return $this->slice($i); + } + + /** + * @param string|string[] $needle + * + * @return static + */ + public function afterLast($needle, bool $includeNeedle = false, int $offset = 0): self + { + $str = clone $this; + $str->string = ''; + $i = null; + + foreach ((array) $needle as $n) { + $n = (string) $n; + $j = $this->indexOfLast($n, $offset); + + if (null !== $j && $j > $i) { + $i = $offset = $j; + $str->string = $n; + } + } + + if (null === $i) { + return $str; + } + + if (!$includeNeedle) { + $i += $str->length(); + } + + return $this->slice($i); + } + + /** + * @return static + */ + abstract public function append(string ...$suffix): self; + + /** + * @param string|string[] $needle + * + * @return static + */ + public function before($needle, bool $includeNeedle = false, int $offset = 0): self + { + $str = clone $this; + $str->string = ''; + $i = \PHP_INT_MAX; + + foreach ((array) $needle as $n) { + $n = (string) $n; + $j = $this->indexOf($n, $offset); + + if (null !== $j && $j < $i) { + $i = $j; + $str->string = $n; + } + } + + if (\PHP_INT_MAX === $i) { + return $str; + } + + if ($includeNeedle) { + $i += $str->length(); + } + + return $this->slice(0, $i); + } + + /** + * @param string|string[] $needle + * + * @return static + */ + public function beforeLast($needle, bool $includeNeedle = false, int $offset = 0): self + { + $str = clone $this; + $str->string = ''; + $i = null; + + foreach ((array) $needle as $n) { + $n = (string) $n; + $j = $this->indexOfLast($n, $offset); + + if (null !== $j && $j > $i) { + $i = $offset = $j; + $str->string = $n; + } + } + + if (null === $i) { + return $str; + } + + if ($includeNeedle) { + $i += $str->length(); + } + + return $this->slice(0, $i); + } + + /** + * @return static + */ + abstract public function camel(): self; + + /** + * @return static[] + */ + abstract public function chunk(int $length = 1): array; + + /** + * @return static + */ + public function collapseWhitespace(): self + { + $str = clone $this; + $str->string = trim(preg_replace('/(?:\s{2,}+|[^\S ])/', ' ', $str->string)); + + return $str; + } + + /** + * @param string|string[] $suffix + */ + public function endsWith($suffix): bool + { + if (!\is_array($suffix) && !$suffix instanceof \Traversable) { + throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, \get_class($this))); + } + + foreach ($suffix as $s) { + if ($this->endsWith((string) $s)) { + return true; + } + } + + return false; + } + + /** + * @return static + */ + public function ensureEnd(string $suffix): self + { + if (!$this->endsWith($suffix)) { + return $this->append($suffix); + } + + $suffix = preg_quote($suffix); + $regex = '{('.$suffix.')(?:'.$suffix.')++$}D'; + + return $this->replaceMatches($regex.($this->ignoreCase ? 'i' : ''), '$1'); + } + + /** + * @return static + */ + public function ensureStart(string $prefix): self + { + $prefix = new static($prefix); + + if (!$this->startsWith($prefix)) { + return $this->prepend($prefix); + } + + $str = clone $this; + $i = $prefixLen = $prefix->length(); + + while ($this->indexOf($prefix, $i) === $i) { + $str = $str->slice($prefixLen); + $i += $prefixLen; + } + + return $str; + } + + /** + * @param string|string[] $string + */ + public function equalsTo($string): bool + { + if (!\is_array($string) && !$string instanceof \Traversable) { + throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, \get_class($this))); + } + + foreach ($string as $s) { + if ($this->equalsTo((string) $s)) { + return true; + } + } + + return false; + } + + /** + * @return static + */ + abstract public function folded(): self; + + /** + * @return static + */ + public function ignoreCase(): self + { + $str = clone $this; + $str->ignoreCase = true; + + return $str; + } + + /** + * @param string|string[] $needle + */ + public function indexOf($needle, int $offset = 0): ?int + { + if (!\is_array($needle) && !$needle instanceof \Traversable) { + throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, \get_class($this))); + } + + $i = \PHP_INT_MAX; + + foreach ($needle as $n) { + $j = $this->indexOf((string) $n, $offset); + + if (null !== $j && $j < $i) { + $i = $j; + } + } + + return \PHP_INT_MAX === $i ? null : $i; + } + + /** + * @param string|string[] $needle + */ + public function indexOfLast($needle, int $offset = 0): ?int + { + if (!\is_array($needle) && !$needle instanceof \Traversable) { + throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, \get_class($this))); + } + + $i = null; + + foreach ($needle as $n) { + $j = $this->indexOfLast((string) $n, $offset); + + if (null !== $j && $j >= $i) { + $i = $offset = $j; + } + } + + return $i; + } + + public function isEmpty(): bool + { + return '' === $this->string; + } + + /** + * @return static + */ + abstract public function join(array $strings): self; + + public function jsonSerialize(): string + { + return $this->string; + } + + abstract public function length(): int; + + /** + * @return static + */ + abstract public function lower(): self; + + /** + * Matches the string using a regular expression. + * + * Pass PREG_PATTERN_ORDER or PREG_SET_ORDER as $flags to get all occurrences matching the pattern. + * + * @return array All matches in a multi-dimensional array ordered according to flags + */ + abstract public function match(string $pattern, int $flags = 0, int $offset = 0): array; + + /** + * @return static + */ + abstract public function padBoth(int $length, string $padStr = ' '): self; + + /** + * @return static + */ + abstract public function padEnd(int $length, string $padStr = ' '): self; + + /** + * @return static + */ + abstract public function padStart(int $length, string $padStr = ' '): self; + + /** + * @return static + */ + abstract public function prepend(string ...$prefix): self; + + /** + * @return static + */ + public function repeat(int $multiplier): self + { + if (0 > $multiplier) { + throw new InvalidArgumentException(sprintf('Multiplier must be positive, %d given.', $multiplier)); + } + + $str = clone $this; + $str->string = str_repeat($str->string, $multiplier); + + return $str; + } + + /** + * @return static + */ + abstract public function replace(string $from, string $to): self; + + /** + * @param string|callable $to + * + * @return static + */ + abstract public function replaceMatches(string $fromPattern, $to): self; + + /** + * @return static + */ + abstract public function slice(int $start = 0, int $length = null): self; + + /** + * @return static + */ + abstract public function snake(): self; + + /** + * @return static + */ + abstract public function splice(string $replacement, int $start = 0, int $length = null): self; + + /** + * @return static[] + */ + public function split(string $delimiter, int $limit = null, int $flags = null): array + { + if (null === $flags) { + throw new \TypeError('Split behavior when $flags is null must be implemented by child classes.'); + } + + if ($this->ignoreCase) { + $delimiter .= 'i'; + } + + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); + + try { + if (false === $chunks = preg_split($delimiter, $this->string, $limit, $flags)) { + $lastError = preg_last_error(); + + foreach (get_defined_constants(true)['pcre'] as $k => $v) { + if ($lastError === $v && '_ERROR' === substr($k, -6)) { + throw new RuntimeException('Splitting failed with '.$k.'.'); + } + } + + throw new RuntimeException('Splitting failed with unknown error code.'); + } + } finally { + restore_error_handler(); + } + + $str = clone $this; + + if (self::PREG_SPLIT_OFFSET_CAPTURE & $flags) { + foreach ($chunks as &$chunk) { + $str->string = $chunk[0]; + $chunk[0] = clone $str; + } + } else { + foreach ($chunks as &$chunk) { + $str->string = $chunk; + $chunk = clone $str; + } + } + + return $chunks; + } + + /** + * @param string|string[] $prefix + */ + public function startsWith($prefix): bool + { + if (!\is_array($prefix) && !$prefix instanceof \Traversable) { + throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, \get_class($this))); + } + + foreach ($prefix as $prefix) { + if ($this->startsWith((string) $prefix)) { + return true; + } + } + + return false; + } + + /** + * @return static + */ + abstract public function title(bool $allWords = false): self; + + public function toBinary(string $toEncoding = null): BinaryString + { + $b = new BinaryString(); + + $toEncoding = \in_array($toEncoding, ['utf8', 'utf-8', 'UTF8'], true) ? 'UTF-8' : $toEncoding; + + if (null === $toEncoding || $toEncoding === $fromEncoding = $this instanceof AbstractUnicodeString || preg_match('//u', $b->string) ? 'UTF-8' : 'Windows-1252') { + $b->string = $this->string; + + return $b; + } + + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); + + try { + try { + $b->string = mb_convert_encoding($this->string, $toEncoding, 'UTF-8'); + } catch (InvalidArgumentException $e) { + if (!\function_exists('iconv')) { + throw $e; + } + + $b->string = iconv('UTF-8', $toEncoding, $this->string); + } + } finally { + restore_error_handler(); + } + + return $b; + } + + public function toGrapheme(): GraphemeString + { + return new GraphemeString($this->string); + } + + public function toUtf8(): Utf8String + { + return new Utf8String($this->string); + } + + /** + * @return static + */ + abstract public function trim(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): self; + + /** + * @return static + */ + abstract public function trimEnd(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): self; + + /** + * @return static + */ + abstract public function trimStart(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): self; + + /** + * @return static + */ + public function truncate(int $length, string $ellipsis = ''): self + { + $stringLength = $this->length(); + + if ($stringLength <= $length) { + return clone $this; + } + + $ellipsisLength = '' !== $ellipsis ? (new static($ellipsis))->length() : 0; + + if ($length < $ellipsisLength) { + $ellipsisLength = 0; + } + + $str = $this->slice(0, $length - $ellipsisLength); + + return $ellipsisLength ? $str->trimEnd()->append($ellipsis) : $str; + } + + /** + * @return static + */ + abstract public function upper(): self; + + abstract public function width(bool $ignoreAnsiDecoration = true): int; + + /** + * @return static + */ + public function wordwrap(int $width = 75, string $break = "\n", bool $cut = false): self + { + $lines = '' !== $break ? $this->split($break) : [clone $this]; + $chars = []; + $mask = ''; + + if (1 === \count($lines) && '' === $lines[0]->string) { + return $lines[0]; + } + + foreach ($lines as $i => $line) { + if ($i) { + $chars[] = $break; + $mask .= '#'; + } + + foreach ($line->chunk() as $char) { + $chars[] = $char->string; + $mask .= ' ' === $char->string ? ' ' : '?'; + } + } + + $string = ''; + $j = 0; + $b = $i = -1; + $mask = wordwrap($mask, $width, '#', $cut); + + while (false !== $b = strpos($mask, '#', $b + 1)) { + for (++$i; $i < $b; ++$i) { + $string .= $chars[$j]; + unset($chars[$j++]); + } + + if ($break === $chars[$j] || ' ' === $chars[$j]) { + unset($chars[$j++]); + } + + $string .= $break; + } + + $str = clone $this; + $str->string = $string.implode('', $chars); + + return $str; + } + + public function __clone() + { + $this->ignoreCase = false; + } + + public function __toString(): string + { + return $this->string; + } +} diff --git a/src/Symfony/Component/String/AbstractUnicodeString.php b/src/Symfony/Component/String/AbstractUnicodeString.php new file mode 100644 index 0000000000000..c01c05b9374ef --- /dev/null +++ b/src/Symfony/Component/String/AbstractUnicodeString.php @@ -0,0 +1,472 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String; + +use Symfony\Component\String\Exception\ExceptionInterface; +use Symfony\Component\String\Exception\InvalidArgumentException; +use Symfony\Component\String\Exception\RuntimeException; + +/** + * Represents a string of abstract Unicode characters. + * + * Unicode defines 3 types of "characters" (bytes, code points and grapheme clusters). + * This class is the abstract type to use as a type-hint when the logic you want to + * implement is Unicode-aware but doesn't care about code points vs grapheme clusters. + * + * @author Nicolas Grekas + * + * @throws ExceptionInterface + * + * @experimental in 5.0 + */ +abstract class AbstractUnicodeString extends AbstractString +{ + public const NFC = \Normalizer::NFC; + public const NFD = \Normalizer::NFD; + public const NFKC = \Normalizer::NFKC; + public const NFKD = \Normalizer::NFKD; + + private const ASCII = "\x20\x65\x69\x61\x73\x6E\x74\x72\x6F\x6C\x75\x64\x5D\x5B\x63\x6D\x70\x27\x0A\x67\x7C\x68\x76\x2E\x66\x62\x2C\x3A\x3D\x2D\x71\x31\x30\x43\x32\x2A\x79\x78\x29\x28\x4C\x39\x41\x53\x2F\x50\x22\x45\x6A\x4D\x49\x6B\x33\x3E\x35\x54\x3C\x44\x34\x7D\x42\x7B\x38\x46\x77\x52\x36\x37\x55\x47\x4E\x3B\x4A\x7A\x56\x23\x48\x4F\x57\x5F\x26\x21\x4B\x3F\x58\x51\x25\x59\x5C\x09\x5A\x2B\x7E\x5E\x24\x40\x60\x7F\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F"; + private const FOLD_FROM = ['İ', 'µ', 'ſ', "\xCD\x85", 'ς', 'ϐ', 'ϑ', 'ϕ', 'ϖ', 'ϰ', 'ϱ', 'ϵ', 'ẛ', "\xE1\xBE\xBE", 'ß', 'İ', 'ʼn', 'ǰ', 'ΐ', 'ΰ', 'և', 'ẖ', 'ẗ', 'ẘ', 'ẙ', 'ẚ', 'ẞ', 'ὐ', 'ὒ', 'ὔ', 'ὖ', 'ᾀ', 'ᾁ', 'ᾂ', 'ᾃ', 'ᾄ', 'ᾅ', 'ᾆ', 'ᾇ', 'ᾈ', 'ᾉ', 'ᾊ', 'ᾋ', 'ᾌ', 'ᾍ', 'ᾎ', 'ᾏ', 'ᾐ', 'ᾑ', 'ᾒ', 'ᾓ', 'ᾔ', 'ᾕ', 'ᾖ', 'ᾗ', 'ᾘ', 'ᾙ', 'ᾚ', 'ᾛ', 'ᾜ', 'ᾝ', 'ᾞ', 'ᾟ', 'ᾠ', 'ᾡ', 'ᾢ', 'ᾣ', 'ᾤ', 'ᾥ', 'ᾦ', 'ᾧ', 'ᾨ', 'ᾩ', 'ᾪ', 'ᾫ', 'ᾬ', 'ᾭ', 'ᾮ', 'ᾯ', 'ᾲ', 'ᾳ', 'ᾴ', 'ᾶ', 'ᾷ', 'ᾼ', 'ῂ', 'ῃ', 'ῄ', 'ῆ', 'ῇ', 'ῌ', 'ῒ', 'ΐ', 'ῖ', 'ῗ', 'ῢ', 'ΰ', 'ῤ', 'ῦ', 'ῧ', 'ῲ', 'ῳ', 'ῴ', 'ῶ', 'ῷ', 'ῼ', 'ff', 'fi', 'fl', 'ffi', 'ffl', 'ſt', 'st', 'ﬓ', 'ﬔ', 'ﬕ', 'ﬖ', 'ﬗ']; + private const FOLD_TO = ['i̇', 'μ', 's', 'ι', 'σ', 'β', 'θ', 'φ', 'π', 'κ', 'ρ', 'ε', 'ṡ', 'ι', 'ss', 'i̇', 'ʼn', 'ǰ', 'ΐ', 'ΰ', 'եւ', 'ẖ', 'ẗ', 'ẘ', 'ẙ', 'aʾ', 'ss', 'ὐ', 'ὒ', 'ὔ', 'ὖ', 'ἀι', 'ἁι', 'ἂι', 'ἃι', 'ἄι', 'ἅι', 'ἆι', 'ἇι', 'ἀι', 'ἁι', 'ἂι', 'ἃι', 'ἄι', 'ἅι', 'ἆι', 'ἇι', 'ἠι', 'ἡι', 'ἢι', 'ἣι', 'ἤι', 'ἥι', 'ἦι', 'ἧι', 'ἠι', 'ἡι', 'ἢι', 'ἣι', 'ἤι', 'ἥι', 'ἦι', 'ἧι', 'ὠι', 'ὡι', 'ὢι', 'ὣι', 'ὤι', 'ὥι', 'ὦι', 'ὧι', 'ὠι', 'ὡι', 'ὢι', 'ὣι', 'ὤι', 'ὥι', 'ὦι', 'ὧι', 'ὰι', 'αι', 'άι', 'ᾶ', 'ᾶι', 'αι', 'ὴι', 'ηι', 'ήι', 'ῆ', 'ῆι', 'ηι', 'ῒ', 'ΐ', 'ῖ', 'ῗ', 'ῢ', 'ΰ', 'ῤ', 'ῦ', 'ῧ', 'ὼι', 'ωι', 'ώι', 'ῶ', 'ῶι', 'ωι', 'ff', 'fi', 'fl', 'ffi', 'ffl', 'st', 'st', 'մն', 'մե', 'մի', 'վն', 'մխ']; + private const UPPER_FROM = ['ß', 'ff', 'fi', 'fl', 'ffi', 'ffl', 'ſt', 'st', 'և', 'ﬓ', 'ﬔ', 'ﬕ', 'ﬖ', 'ﬗ', 'ʼn', 'ΐ', 'ΰ', 'ǰ', 'ẖ', 'ẗ', 'ẘ', 'ẙ', 'ẚ', 'ὐ', 'ὒ', 'ὔ', 'ὖ', 'ᾶ', 'ῆ', 'ῒ', 'ΐ', 'ῖ', 'ῗ', 'ῢ', 'ΰ', 'ῤ', 'ῦ', 'ῧ', 'ῶ +']; + private const UPPER_TO = ['SS', 'FF', 'FI', 'FL', 'FFI', 'FFL', 'ST', 'ST', 'ԵՒ', 'ՄՆ', 'ՄԵ', 'ՄԻ', 'ՎՆ', 'ՄԽ', 'ʼN', 'Ϊ́', 'Ϋ́', 'J̌', 'H̱', 'T̈', 'W̊', 'Y̊', 'Aʾ', 'Υ̓', 'Υ̓̀', 'Υ̓́', 'Υ̓͂', 'Α͂', 'Η͂', 'Ϊ̀', 'Ϊ́', 'Ι͂', 'Ϊ͂', 'Ϋ̀', 'Ϋ́', 'Ρ̓', 'Υ͂', 'Ϋ͂', 'Ω͂']; + private const TRANSLIT_FROM = ['Ð', 'Ø', 'Þ', 'ð', 'ø', 'þ', 'Đ', 'đ', 'Ħ', 'ħ', 'ı', 'ĸ', 'Ŋ', 'ŋ', 'Ŧ', 'ŧ', 'ƀ', 'Ɓ', 'Ƃ', 'ƃ', 'Ƈ', 'ƈ', 'Ɖ', 'Ɗ', 'Ƌ', 'ƌ', 'Ɛ', 'Ƒ', 'ƒ', 'Ɠ', 'ƕ', 'Ɩ', 'Ɨ', 'Ƙ', 'ƙ', 'ƚ', 'Ɲ', 'ƞ', 'Ƣ', 'ƣ', 'Ƥ', 'ƥ', 'ƫ', 'Ƭ', 'ƭ', 'Ʈ', 'Ʋ', 'Ƴ', 'ƴ', 'Ƶ', 'ƶ', 'Ǥ', 'ǥ', 'ȡ', 'Ȥ', 'ȥ', 'ȴ', 'ȵ', 'ȶ', 'ȷ', 'ȸ', 'ȹ', 'Ⱥ', 'Ȼ', 'ȼ', 'Ƚ', 'Ⱦ', 'ȿ', 'ɀ', 'Ƀ', 'Ʉ', 'Ɇ', 'ɇ', 'Ɉ', 'ɉ', 'Ɍ', 'ɍ', 'Ɏ', 'ɏ', 'ɓ', 'ɕ', 'ɖ', 'ɗ', 'ɛ', 'ɟ', 'ɠ', 'ɡ', 'ɢ', 'ɦ', 'ɧ', 'ɨ', 'ɪ', 'ɫ', 'ɬ', 'ɭ', 'ɱ', 'ɲ', 'ɳ', 'ɴ', 'ɶ', 'ɼ', 'ɽ', 'ɾ', 'ʀ', 'ʂ', 'ʈ', 'ʉ', 'ʋ', 'ʏ', 'ʐ', 'ʑ', 'ʙ', 'ʛ', 'ʜ', 'ʝ', 'ʟ', 'ʠ', 'ʣ', 'ʥ', 'ʦ', 'ʪ', 'ʫ', 'ᴀ', 'ᴁ', 'ᴃ', 'ᴄ', 'ᴅ', 'ᴆ', 'ᴇ', 'ᴊ', 'ᴋ', 'ᴌ', 'ᴍ', 'ᴏ', 'ᴘ', 'ᴛ', 'ᴜ', 'ᴠ', 'ᴡ', 'ᴢ', 'ᵫ', 'ᵬ', 'ᵭ', 'ᵮ', 'ᵯ', 'ᵰ', 'ᵱ', 'ᵲ', 'ᵳ', 'ᵴ', 'ᵵ', 'ᵶ', 'ᵺ', 'ᵻ', 'ᵽ', 'ᵾ', 'ᶀ', 'ᶁ', 'ᶂ', 'ᶃ', 'ᶄ', 'ᶅ', 'ᶆ', 'ᶇ', 'ᶈ', 'ᶉ', 'ᶊ', 'ᶌ', 'ᶍ', 'ᶎ', 'ᶏ', 'ᶑ', 'ᶒ', 'ᶓ', 'ᶖ', 'ᶙ', 'ẜ', 'ẝ', 'ẞ', 'Ỻ', 'ỻ', 'Ỽ', 'ỽ', 'Ỿ', 'ỿ', '₠', '₢', '₣', '₤', '₧', '₹', '℞', '〇', '′', '〝', '〞', '‖', '⁅', '⁆', '⁎', '、', '。', '〈', '〉', '《', '》', '〔', '〕', '〘', '〙', '〚', '〛', '︑', '︒', '︹', '︺', '︽', '︾', '︿', '﹀', '÷', '∥', '⦅', '⦆']; + private const TRANSLIT_TO = ['D', 'O', 'TH', 'd', 'o', 'th', 'D', 'd', 'H', 'h', 'i', 'q', 'N', 'n', 'T', 't', 'b', 'B', 'B', 'b', 'C', 'c', 'D', 'D', 'D', 'd', 'E', 'F', 'f', 'G', 'hv', 'I', 'I', 'K', 'k', 'l', 'N', 'n', 'OI', 'oi', 'P', 'p', 't', 'T', 't', 'T', 'V', 'Y', 'y', 'Z', 'z', 'G', 'g', 'd', 'Z', 'z', 'l', 'n', 't', 'j', 'db', 'qp', 'A', 'C', 'c', 'L', 'T', 's', 'z', 'B', 'U', 'E', 'e', 'J', 'j', 'R', 'r', 'Y', 'y', 'b', 'c', 'd', 'd', 'e', 'j', 'g', 'g', 'G', 'h', 'h', 'i', 'I', 'l', 'l', 'l', 'm', 'n', 'n', 'N', 'OE', 'r', 'r', 'r', 'R', 's', 't', 'u', 'v', 'Y', 'z', 'z', 'B', 'G', 'H', 'j', 'L', 'q', 'dz', 'dz', 'ts', 'ls', 'lz', 'A', 'AE', 'B', 'C', 'D', 'D', 'E', 'J', 'K', 'L', 'M', 'O', 'P', 'T', 'U', 'V', 'W', 'Z', 'ue', 'b', 'd', 'f', 'm', 'n', 'p', 'r', 'r', 's', 't', 'z', 'th', 'I', 'p', 'U', 'b', 'd', 'f', 'g', 'k', 'l', 'm', 'n', 'p', 'r', 's', 'v', 'x', 'z', 'a', 'd', 'e', 'e', 'i', 'u', 's', 's', 'SS', 'LL', 'll', 'V', 'v', 'Y', 'y', 'CE', 'Cr', 'Fr.', 'L.', 'Pts', 'Rs', 'Rx', '0', '\'', '"', '"', '||', '[', ']', '*', ',', '.', '<', '>', '<<', '>>', '[', ']', '[', ']', '[', ']', ',', '.', '[', ']', '<<', '>>', '<', '>', '/', '||', '((', '))']; + + private static $transliterators = []; + + /** + * @return static + */ + public static function fromCodePoints(int ...$codes): self + { + $string = ''; + + foreach ($codes as $code) { + if (0x80 > $code %= 0x200000) { + $string .= \chr($code); + } elseif (0x800 > $code) { + $string .= \chr(0xC0 | $code >> 6).\chr(0x80 | $code & 0x3F); + } elseif (0x10000 > $code) { + $string .= \chr(0xE0 | $code >> 12).\chr(0x80 | $code >> 6 & 0x3F).\chr(0x80 | $code & 0x3F); + } else { + $string .= \chr(0xF0 | $code >> 18).\chr(0x80 | $code >> 12 & 0x3F).\chr(0x80 | $code >> 6 & 0x3F).\chr(0x80 | $code & 0x3F); + } + } + + return new static($string); + } + + /** + * Generic UTF-8 to ASCII transliteration. + * + * Install the intl extension for best results. + * + * @param string[] $rules See "*-Latin" rules from Transliterator::listIDs() + */ + public function ascii(array $rules = []): self + { + $str = clone $this; + $s = $str->string; + $str->string = ''; + $step = 0; + + if (\function_exists('transliterator_transliterate')) { + $rules[] = '[:nonspacing mark:] remove'; + $rules[] = 'any-latin'; + } + + while (\strlen($s) !== $i = strspn($s, self::ASCII)) { + if (0 !== $i) { + $str->string .= substr($s, 0, $i); + $s = substr($s, $i); + } + + if (1 === ++$step) { + if (!normalizer_is_normalized($s, self::NFKD)) { + $s = normalizer_normalize($s, self::NFKD); + } + } elseif (2 === $step) { + $s = str_replace(self::TRANSLIT_FROM, self::TRANSLIT_TO, $s); + } elseif (3 === $step && '' !== $rule = strtolower(array_shift($rules))) { + $step = 2; + + if ('[:nonspacing mark:] remove' === $rule) { + $s = preg_replace('/\p{Mn}++/u', '', $s); + } elseif ('de-ascii' === $rule) { + $s = preg_replace("/([AUO])\u{0308}(?=\p{Ll})/u", '$1e', $s); + $s = str_replace(["a\u{0308}", "o\u{0308}", "u\u{0308}", "A\u{0308}", "O\u{0308}", "U\u{0308}"], ['ae', 'oe', 'ue', 'AE', 'OE', 'UE'], $s); + } elseif (\function_exists('transliterator_transliterate')) { + if (null === $transliterator = self::$transliterators[$rule] ?? self::$transliterators[$rule] = \Transliterator::create($rule)) { + throw new InvalidArgumentException(sprintf('Unknown transliteration rule "%s".', $rule)); + } + + $s = $transliterator->transliterate($s); + } + } elseif (!\function_exists('iconv')) { + $s = preg_replace('/[^\x00-\x7F]/u', '?', $s); + } elseif (\ICONV_IMPL === 'glibc') { + $s = iconv('UTF-8', 'ASCII//TRANSLIT', $s); + } else { + $s = preg_replace_callback('/[^\x00-\x7F]/u', static function ($c) { + $c = iconv('UTF-8', 'ASCII//IGNORE//TRANSLIT', $c[0]); + + return 1 < \strlen($c) ? ltrim($c, '\'`"^~') : (\strlen($c) ? $c : '?'); + }, $s); + } + } + + $str->string .= $s; + + return $str; + } + + public function camel(): parent + { + $str = clone $this; + $str->string = str_replace(' ', '', preg_replace_callback('/\b./u', static function ($m) use (&$i) { + return 1 === ++$i ? ('İ' === $m[0] ? 'i̇' : mb_strtolower($m[0], 'UTF-8')) : mb_convert_case($m[0], MB_CASE_TITLE, 'UTF-8'); + }, preg_replace('/[^\pL0-9]++/u', ' ', $this->string))); + + return $str; + } + + public function codePoint(int $offset = 0): ?int + { + $str = $offset ? $this->slice($offset, 1) : $this; + + return '' === $str->string ? null : mb_ord($str->string); + } + + public function folded(bool $compat = true): parent + { + $str = clone $this; + + if (!$compat || \PHP_VERSION_ID < 70300 || !\defined('Normalizer::NFKC_CF')) { + $str->string = normalizer_normalize($str->string, $compat ? \Normalizer::NFKC : \Normalizer::NFC); + $str->string = mb_strtolower(str_replace(self::FOLD_FROM, self::FOLD_TO, $this->string), 'UTF-8'); + } else { + $str->string = normalizer_normalize($str->string, \Normalizer::NFKC_CF); + } + + return $str; + } + + public function join(array $strings): parent + { + $str = clone $this; + $str->string = implode($this->string, $strings); + + if (!preg_match('//u', $str->string)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + return $str; + } + + public function lower(): parent + { + $str = clone $this; + $str->string = mb_strtolower(str_replace('İ', 'i̇', $str->string), 'UTF-8'); + + return $str; + } + + public function match(string $pattern, int $flags = 0, int $offset = 0): array + { + $match = ((\PREG_PATTERN_ORDER | \PREG_SET_ORDER) & $flags) ? 'preg_match_all' : 'preg_match'; + + if ($this->ignoreCase) { + $pattern .= 'i'; + } + + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); + + try { + if (false === $match($pattern.'u', $this->string, $matches, $flags | PREG_UNMATCHED_AS_NULL, $offset)) { + $lastError = preg_last_error(); + + foreach (get_defined_constants(true)['pcre'] as $k => $v) { + if ($lastError === $v && '_ERROR' === substr($k, -6)) { + throw new RuntimeException('Matching failed with '.$k.'.'); + } + } + + throw new RuntimeException('Matching failed with unknown error code.'); + } + } finally { + restore_error_handler(); + } + + return $matches; + } + + /** + * @return static + */ + public function normalize(int $form = self::NFC): self + { + if (!\in_array($form, [self::NFC, self::NFD, self::NFKC, self::NFKD])) { + throw new InvalidArgumentException('Unsupported normalization form.'); + } + + $str = clone $this; + normalizer_is_normalized($str->string, $form) ?: $str->string = normalizer_normalize($str->string, $form); + + return $str; + } + + public function padBoth(int $length, string $padStr = ' '): parent + { + if ('' === $padStr || !preg_match('//u', $padStr)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + $pad = clone $this; + $pad->string = $padStr; + + return $this->pad($length, $pad, STR_PAD_BOTH); + } + + public function padEnd(int $length, string $padStr = ' '): parent + { + if ('' === $padStr || !preg_match('//u', $padStr)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + $pad = clone $this; + $pad->string = $padStr; + + return $this->pad($length, $pad, STR_PAD_RIGHT); + } + + public function padStart(int $length, string $padStr = ' '): parent + { + if ('' === $padStr || !preg_match('//u', $padStr)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + $pad = clone $this; + $pad->string = $padStr; + + return $this->pad($length, $pad, STR_PAD_LEFT); + } + + public function replaceMatches(string $fromPattern, $to): parent + { + if ($this->ignoreCase) { + $fromPattern .= 'i'; + } + + if (\is_array($to) || $to instanceof \Closure) { + if (!\is_callable($to)) { + throw new \TypeError(sprintf('Argument 2 passed to %s::replaceMatches() must be callable, array given.', \get_class($this))); + } + + $replace = 'preg_replace_callback'; + $to = static function (array $m) use ($to): string { + $to = $to($m); + + if ('' !== $to && (!\is_string($to) || !preg_match('//u', $to))) { + throw new InvalidArgumentException('Replace callback must return a valid UTF-8 string.'); + } + + return $to; + }; + } elseif ('' !== $to && !preg_match('//u', $to)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } else { + $replace = 'preg_replace'; + } + + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); + + try { + if (null === $string = $replace($fromPattern.'u', $to, $this->string)) { + $lastError = preg_last_error(); + + foreach (get_defined_constants(true)['pcre'] as $k => $v) { + if ($lastError === $v && '_ERROR' === substr($k, -6)) { + throw new RuntimeException('Matching failed with '.$k.'.'); + } + } + + throw new RuntimeException('Matching failed with unknown error code.'); + } + } finally { + restore_error_handler(); + } + + $str = clone $this; + $str->string = $string; + + return $str; + } + + /** + * @return static + */ + public function slug(string $separator = '-'): self + { + return $this + ->ascii() + ->replace('@', $separator.'at'.$separator) + ->replaceMatches('/[^A-Za-z0-9]++/', $separator) + ->trim($separator); + } + + public function snake(): parent + { + $str = $this->camel()->title(); + $str->string = mb_strtolower(preg_replace(['/(\p{Lu}+)(\p{Lu}\p{Ll})/u', '/([\p{Ll}0-9])(\p{Lu})/u'], '\1_\2', $str->string), 'UTF-8'); + + return $str; + } + + public function title(bool $allWords = false): parent + { + $str = clone $this; + + if ($allWords) { + $str->string = preg_replace_callback('/\b./u', static function ($m) { + return mb_convert_case($m[0], MB_CASE_TITLE, 'UTF-8'); + }, $str->string); + } else { + $firstChar = mb_substr($str->string, 0, 1, 'UTF-8'); + $str->string = mb_convert_case($firstChar, MB_CASE_TITLE, 'UTF-8').substr($str->string, \strlen($firstChar)); + } + + return $str; + } + + public function trim(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): parent + { + if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" !== $chars && !preg_match('//u', $chars)) { + throw new InvalidArgumentException('Invalid UTF-8 chars.'); + } + $chars = preg_quote($chars); + + $str = clone $this; + $str->string = preg_replace("{^[$chars]++|[$chars]++$}uD", '', $str->string); + + return $str; + } + + public function trimEnd(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): parent + { + if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" !== $chars && !preg_match('//u', $chars)) { + throw new InvalidArgumentException('Invalid UTF-8 chars.'); + } + $chars = preg_quote($chars); + + $str = clone $this; + $str->string = preg_replace("{[$chars]++$}uD", '', $str->string); + + return $str; + } + + public function trimStart(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): parent + { + if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" !== $chars && !preg_match('//u', $chars)) { + throw new InvalidArgumentException('Invalid UTF-8 chars.'); + } + $chars = preg_quote($chars); + + $str = clone $this; + $str->string = preg_replace("{^[$chars]++}uD", '', $str->string); + + return $str; + } + + public function upper(): parent + { + $str = clone $this; + $str->string = mb_strtoupper($str->string, 'UTF-8'); + + if (\PHP_VERSION_ID < 70300) { + $str->string = str_replace(self::UPPER_FROM, self::UPPER_TO, $str->string); + } + + return $str; + } + + public function width(bool $ignoreAnsiDecoration = true): int + { + $width = 0; + $s = str_replace(["\x00", "\x05", "\x07"], '', $this->string); + + if (false !== strpos($s, "\r")) { + $s = str_replace(["\r\n", "\r"], "\n", $s); + } + + foreach (explode("\n", $s) as $s) { + if ($ignoreAnsiDecoration) { + $s = preg_replace('/\x1B(?: + \[ [\x30-\x3F]*+ [\x20-\x2F]*+ [0x40-\x7E] + | [P\]X^_] .*? \x1B\\\\ + | [\x41-\x7E] + )/x', '', $s); + } + + $w = substr_count($s, "\xAD") - substr_count($s, "\x08"); + $s = preg_replace('/[\x00\x05\x07\p{Mn}\p{Me}\p{Cf}\x{1160}-\x{11FF}\x{200B}]+/u', '', $s); + $s = preg_replace('/[\x{1100}-\x{115F}\x{2329}\x{232A}\x{2E80}-\x{303E}\x{3040}-\x{A4CF}\x{AC00}-\x{D7A3}\x{F900}-\x{FAFF}\x{FE10}-\x{FE19}\x{FE30}-\x{FE6F}\x{FF00}-\x{FF60}\x{FFE0}-\x{FFE6}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}]/u', '', $s, -1, $wide); + + if ($width < $w += mb_strlen($s, 'UTF-8') + ($wide << 1)) { + $width = $w; + } + } + + return $width; + } + + /** + * @return static + */ + private function pad(int $len, self $pad, int $type): parent + { + $sLen = $this->length(); + + if ($len <= $sLen) { + return clone $this; + } + + $padLen = $pad->length(); + $freeLen = $len - $sLen; + $len = $freeLen % $padLen; + + switch ($type) { + case STR_PAD_RIGHT: + return $this->append(str_repeat($pad->string, $freeLen / $padLen).($len ? $pad->slice(0, $len) : '')); + + case STR_PAD_LEFT: + return $this->prepend(str_repeat($pad->string, $freeLen / $padLen).($len ? $pad->slice(0, $len) : '')); + + case STR_PAD_BOTH: + $freeLen /= 2; + + $rightLen = ceil($freeLen); + $len = $rightLen % $padLen; + $str = $this->append(str_repeat($pad->string, $rightLen / $padLen).($len ? $pad->slice(0, $len) : '')); + + $leftLen = floor($freeLen); + $len = $leftLen % $padLen; + + return $str->prepend(str_repeat($pad->string, $leftLen / $padLen).($len ? $pad->slice(0, $len) : '')); + + default: + throw new InvalidArgumentException('Invalid padding type.'); + } + } +} diff --git a/src/Symfony/Component/String/BinaryString.php b/src/Symfony/Component/String/BinaryString.php new file mode 100644 index 0000000000000..7ec8b56aec387 --- /dev/null +++ b/src/Symfony/Component/String/BinaryString.php @@ -0,0 +1,475 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String; + +use Symfony\Component\String\Exception\ExceptionInterface; +use Symfony\Component\String\Exception\InvalidArgumentException; +use Symfony\Component\String\Exception\RuntimeException; + +/** + * Represents a binary-safe string of bytes. + * + * @author Nicolas Grekas + * @author Hugo Hamon + * + * @throws ExceptionInterface + * + * @experimental in 5.0 + */ +class BinaryString extends AbstractString +{ + public function __construct(string $string = '') + { + $this->string = $string; + } + + public static function fromRandom(int $length = 16): self + { + $string = ''; + + do { + $string .= str_replace(['/', '+', '='], '', base64_encode(random_bytes($length))); + } while (\strlen($string) < $length); + + return new static(substr($string, 0, $length)); + } + + public function byteCode(int $offset = 0): ?int + { + $str = $offset ? $this->slice($offset, 1) : $this; + + return '' === $str->string ? null : \ord($str->string); + } + + public function append(string ...$suffix): parent + { + $str = clone $this; + $str->string .= 1 >= \count($suffix) ? ($suffix[0] ?? '') : implode('', $suffix); + + return $str; + } + + public function camel(): parent + { + $str = clone $this; + $str->string = lcfirst(str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $this->string)))); + + return $str; + } + + public function chunk(int $length = 1): array + { + if (1 > $length) { + throw new InvalidArgumentException('The chunk length must be greater than zero.'); + } + + if ('' === $this->string) { + return []; + } + + $str = clone $this; + $chunks = []; + + foreach (str_split($this->string, $length) as $chunk) { + $str->string = $chunk; + $chunks[] = clone $str; + } + + return $chunks; + } + + public function endsWith($suffix): bool + { + if ($suffix instanceof parent) { + $suffix = $suffix->string; + } elseif (\is_array($suffix) || $suffix instanceof \Traversable) { + return parent::endsWith($suffix); + } else { + $suffix = (string) $suffix; + } + + return \strlen($this->string) - \strlen($suffix) === ($this->ignoreCase ? strripos($this->string, $suffix) : strrpos($this->string, $suffix)); + } + + public function equalsTo($string): bool + { + if ($string instanceof parent) { + $string = $string->string; + } elseif (\is_array($string) || $string instanceof \Traversable) { + return parent::equalsTo($string); + } else { + $string = (string) $string; + } + + if ('' !== $string && $this->ignoreCase) { + return 0 === strcasecmp($string, $this->string); + } + + return $string === $this->string; + } + + public function folded(): parent + { + $str = clone $this; + $str->string = strtolower($str->string); + + return $str; + } + + public function indexOf($needle, int $offset = 0): ?int + { + if ($needle instanceof parent) { + $needle = $needle->string; + } elseif (\is_array($needle) || $needle instanceof \Traversable) { + return parent::indexOf($needle, $offset); + } else { + $needle = (string) $needle; + } + + if ('' === $needle) { + return null; + } + + $i = $this->ignoreCase ? stripos($this->string, $needle, $offset) : strpos($this->string, $needle, $offset); + + return false === $i ? null : $i; + } + + public function indexOfLast($needle, int $offset = 0): ?int + { + if ($needle instanceof parent) { + $needle = $needle->string; + } elseif (\is_array($needle) || $needle instanceof \Traversable) { + return parent::indexOfLast($needle, $offset); + } else { + $needle = (string) $needle; + } + + if ('' === $needle) { + return null; + } + + $i = $this->ignoreCase ? strripos($this->string, $needle, $offset) : strrpos($this->string, $needle, $offset); + + return false === $i ? null : $i; + } + + public function isUtf8(): bool + { + return '' === $this->string || preg_match('//u', $this->string); + } + + public function join(array $strings): parent + { + $str = clone $this; + $str->string = implode($str->string, $strings); + + return $str; + } + + public function length(): int + { + return \strlen($this->string); + } + + public function lower(): parent + { + $str = clone $this; + $str->string = strtolower($str->string); + + return $str; + } + + public function match(string $pattern, int $flags = 0, int $offset = 0): array + { + $match = ((\PREG_PATTERN_ORDER | \PREG_SET_ORDER) & $flags) ? 'preg_match_all' : 'preg_match'; + + if ($this->ignoreCase) { + $pattern .= 'i'; + } + + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); + + try { + if (false === $match($pattern, $this->string, $matches, $flags | PREG_UNMATCHED_AS_NULL, $offset)) { + $lastError = preg_last_error(); + + foreach (get_defined_constants(true)['pcre'] as $k => $v) { + if ($lastError === $v && '_ERROR' === substr($k, -6)) { + throw new RuntimeException('Matching failed with '.$k.'.'); + } + } + + throw new RuntimeException('Matching failed with unknown error code.'); + } + } finally { + restore_error_handler(); + } + + return $matches; + } + + public function padBoth(int $length, string $padStr = ' '): parent + { + $str = clone $this; + $str->string = str_pad($this->string, $length, $padStr, STR_PAD_BOTH); + + return $str; + } + + public function padEnd(int $length, string $padStr = ' '): parent + { + $str = clone $this; + $str->string = str_pad($this->string, $length, $padStr, STR_PAD_RIGHT); + + return $str; + } + + public function padStart(int $length, string $padStr = ' '): parent + { + $str = clone $this; + $str->string = str_pad($this->string, $length, $padStr, STR_PAD_LEFT); + + return $str; + } + + public function prepend(string ...$prefix): parent + { + $str = clone $this; + $str->string = (1 >= \count($prefix) ? ($prefix[0] ?? '') : implode('', $prefix)).$str->string; + + return $str; + } + + public function replace(string $from, string $to): parent + { + $str = clone $this; + + if ('' !== $from) { + $str->string = $this->ignoreCase ? str_ireplace($from, $to, $this->string) : str_replace($from, $to, $this->string); + } + + return $str; + } + + public function replaceMatches(string $fromPattern, $to): parent + { + if ($this->ignoreCase) { + $fromPattern .= 'i'; + } + + if (\is_array($to)) { + if (!\is_callable($to)) { + throw new \TypeError(sprintf('Argument 2 passed to %s::replaceMatches() must be callable, array given.', \get_class($this))); + } + + $replace = 'preg_replace_callback'; + } else { + $replace = $to instanceof \Closure ? 'preg_replace_callback' : 'preg_replace'; + } + + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); + + try { + if (null === $string = $replace($fromPattern, $to, $this->string)) { + $lastError = preg_last_error(); + + foreach (get_defined_constants(true)['pcre'] as $k => $v) { + if ($lastError === $v && '_ERROR' === substr($k, -6)) { + throw new RuntimeException('Matching failed with '.$k.'.'); + } + } + + throw new RuntimeException('Matching failed with unknown error code.'); + } + } finally { + restore_error_handler(); + } + + $str = clone $this; + $str->string = $string; + + return $str; + } + + public function slice(int $start = 0, int $length = null): parent + { + $str = clone $this; + $str->string = (string) substr($this->string, $start, $length ?? \PHP_INT_MAX); + + return $str; + } + + public function snake(): parent + { + $str = $this->camel()->title(); + $str->string = strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1_\2', $str->string)); + + return $str; + } + + public function splice(string $replacement, int $start = 0, int $length = null): parent + { + $str = clone $this; + $str->string = substr_replace($this->string, $replacement, $start, $length ?? \PHP_INT_MAX); + + return $str; + } + + public function split(string $delimiter, int $limit = null, int $flags = null): array + { + if (1 > $limit = $limit ?? \PHP_INT_MAX) { + throw new InvalidArgumentException('Split limit must be a positive integer.'); + } + + if ('' === $delimiter) { + throw new InvalidArgumentException('Split delimiter is empty.'); + } + + if (null !== $flags) { + return parent::split($delimiter, $limit, $flags); + } + + $str = clone $this; + $chunks = $this->ignoreCase + ? preg_split('{'.preg_quote($delimiter).'}iD', $this->string, $limit) + : explode($delimiter, $this->string, $limit); + + foreach ($chunks as &$chunk) { + $str->string = $chunk; + $chunk = clone $str; + } + + return $chunks; + } + + public function startsWith($prefix): bool + { + if ($prefix instanceof parent) { + $prefix = $prefix->string; + } elseif (!\is_string($prefix)) { + return parent::startsWith($prefix); + } + + return '' !== $prefix && 0 === ($this->ignoreCase ? stripos($this->string, $prefix) : strpos($this->string, $prefix)); + } + + public function title(bool $allWords = false): parent + { + $str = clone $this; + $str->string = $allWords ? ucwords($str->string) : ucfirst($str->string); + + return $str; + } + + public function toGrapheme(string $fromEncoding = null): GraphemeString + { + return new GraphemeString($this->toUtf8($fromEncoding)->string); + } + + public function toUtf8(string $fromEncoding = null): Utf8String + { + $u = new Utf8String(); + + if (\in_array($fromEncoding, [null, 'utf8', 'utf-8', 'UTF8', 'UTF-8'], true) && preg_match('//u', $this->string)) { + $u->string = $this->string; + + return $u; + } + + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); + + try { + try { + $validEncoding = false !== mb_detect_encoding($this->string, $fromEncoding ?? 'Windows-1252', true); + } catch (InvalidArgumentException $e) { + if (!\function_exists('iconv')) { + throw $e; + } + + $u->string = iconv($fromEncoding ?? 'Windows-1252', 'UTF-8', $this->string); + + return $u; + } + } finally { + restore_error_handler(); + } + + if (!$validEncoding) { + throw new InvalidArgumentException(sprintf('Invalid "%s" string.', $fromEncoding ?? 'Windows-1252')); + } + + $u->string = mb_convert_encoding($this->string, 'UTF-8', $fromEncoding ?? 'Windows-1252'); + + return $u; + } + + public function trim(string $chars = " \t\n\r\0\x0B\x0C"): parent + { + $str = clone $this; + $str->string = trim($str->string, $chars); + + return $str; + } + + public function trimEnd(string $chars = " \t\n\r\0\x0B\x0C"): parent + { + $str = clone $this; + $str->string = rtrim($str->string, $chars); + + return $str; + } + + public function trimStart(string $chars = " \t\n\r\0\x0B\x0C"): parent + { + $str = clone $this; + $str->string = ltrim($str->string, $chars); + + return $str; + } + + public function upper(): parent + { + $str = clone $this; + $str->string = strtoupper($str->string); + + return $str; + } + + public function width(bool $ignoreAnsiDecoration = true): int + { + $width = 0; + $s = str_replace(["\x00", "\x05", "\x07"], '', $this->string); + + if (false !== strpos($s, "\r")) { + $s = str_replace(["\r\n", "\r"], "\n", $s); + } + + foreach (explode("\n", $s) as $s) { + if ($ignoreAnsiDecoration) { + $s = preg_replace('/\x1B(?: + \[ [\x30-\x3F]*+ [\x20-\x2F]*+ [0x40-\x7E] + | [P\]X^_] .*? \x1B\\\\ + | [\x41-\x7E] + )/x', '', $s); + } + + $w = substr_count($s, "\xAD") - substr_count($s, "\x08"); + + if ($width < $w += \strlen($s)) { + $width = $w; + } + } + + return $width; + } +} diff --git a/src/Symfony/Component/String/CHANGELOG.md b/src/Symfony/Component/String/CHANGELOG.md new file mode 100644 index 0000000000000..094072510d707 --- /dev/null +++ b/src/Symfony/Component/String/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.4.0 +----- + + * added the component diff --git a/src/Symfony/Component/String/Exception/ExceptionInterface.php b/src/Symfony/Component/String/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..e10a3f030bc23 --- /dev/null +++ b/src/Symfony/Component/String/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Exception; + +/** + * @experimental in 5.0 + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/String/Exception/InvalidArgumentException.php b/src/Symfony/Component/String/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000000..fff6207f9c663 --- /dev/null +++ b/src/Symfony/Component/String/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Exception; + +/** + * @experimental in 5.0 + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/String/Exception/RuntimeException.php b/src/Symfony/Component/String/Exception/RuntimeException.php new file mode 100644 index 0000000000000..747db4dc9d763 --- /dev/null +++ b/src/Symfony/Component/String/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Exception; + +/** + * @experimental in 5.0 + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/String/GraphemeString.php b/src/Symfony/Component/String/GraphemeString.php new file mode 100644 index 0000000000000..b1ecafacbf9b7 --- /dev/null +++ b/src/Symfony/Component/String/GraphemeString.php @@ -0,0 +1,346 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String; + +use Symfony\Component\String\Exception\ExceptionInterface; +use Symfony\Component\String\Exception\InvalidArgumentException; + +/** + * Represents a string of Unicode grapheme clusters encoded as UTF-8. + * + * A letter followed by combining characters (accents typically) forms what Unicode defines + * as a grapheme cluster: a character as humans mean it in written texts. This class knows + * about the concept and won't split a letter apart from its combining accents. It also + * ensures all string comparisons happen on their canonically-composed representation, + * ignoring e.g. the order in which accents are listed when a letter has many of them. + * + * @see https://unicode.org/reports/tr15/ + * + * @author Nicolas Grekas + * @author Hugo Hamon + * + * @throws ExceptionInterface + * + * @experimental in 5.0 + */ +class GraphemeString extends AbstractUnicodeString +{ + public function __construct(string $string = '') + { + $this->string = normalizer_is_normalized($string) ? $string : normalizer_normalize($string); + + if (false === $this->string) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + } + + public function append(string ...$suffix): AbstractString + { + $str = clone $this; + $str->string = $this->string.(1 >= \count($suffix) ? ($suffix[0] ?? '') : implode('', $suffix)); + normalizer_is_normalized($str->string) ?: $str->string = normalizer_normalize($str->string); + + if (false === $str->string) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + return $str; + } + + public function chunk(int $length = 1): array + { + if (1 > $length) { + throw new InvalidArgumentException('The chunk length must be greater than zero.'); + } + + if ('' === $this->string) { + return []; + } + + $rx = '/('; + while (65535 < $length) { + $rx .= '\X{65535}'; + $length -= 65535; + } + $rx .= '\X{'.$length.'})/us'; + + $str = clone $this; + $chunks = []; + + foreach (preg_split($rx, $this->string, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) as $chunk) { + $str->string = $chunk; + $chunks[] = clone $str; + } + + return $chunks; + } + + public function endsWith($suffix): bool + { + if ($suffix instanceof AbstractString) { + $suffix = $suffix->string; + } elseif (\is_array($suffix) || $suffix instanceof \Traversable) { + return parent::endsWith($suffix); + } else { + $suffix = (string) $suffix; + } + + $form = null === $this->ignoreCase ? \Normalizer::NFD : \Normalizer::NFC; + normalizer_is_normalized($suffix, $form) ?: $suffix = normalizer_normalize($suffix, $form); + + if ('' === $suffix || false === $suffix || false === $i = $this->ignoreCase ? grapheme_strripos($this->string, $suffix) : grapheme_strrpos($this->string, $suffix)) { + return false; + } + + return grapheme_strlen($this->string) - grapheme_strlen($suffix) === $i; + } + + public function equalsTo($string): bool + { + if ($string instanceof AbstractString) { + $string = $string->string; + } elseif (\is_array($string) || $string instanceof \Traversable) { + return parent::equalsTo($string); + } else { + $string = (string) $string; + } + + $form = null === $this->ignoreCase ? \Normalizer::NFD : \Normalizer::NFC; + normalizer_is_normalized($string, $form) ?: $string = normalizer_normalize($string, $form); + + if ('' !== $string && false !== $string && $this->ignoreCase) { + return grapheme_strlen($string) === grapheme_strlen($this->string) && 0 === grapheme_stripos($this->string, $string); + } + + return $string === $this->string; + } + + public function indexOf($needle, int $offset = 0): ?int + { + if ($needle instanceof AbstractString) { + $needle = $needle->string; + } elseif (\is_array($needle) || $needle instanceof \Traversable) { + return parent::indexOf($needle, $offset); + } else { + $needle = (string) $needle; + } + + $form = null === $this->ignoreCase ? \Normalizer::NFD : \Normalizer::NFC; + normalizer_is_normalized($needle, $form) ?: $needle = normalizer_normalize($needle, $form); + + if ('' === $needle || false === $needle) { + return null; + } + + $i = $this->ignoreCase ? grapheme_stripos($this->string, $needle, $offset) : grapheme_strpos($this->string, $needle, $offset); + + return false === $i ? null : $i; + } + + public function indexOfLast($needle, int $offset = 0): ?int + { + if ($needle instanceof AbstractString) { + $needle = $needle->string; + } elseif (\is_array($needle) || $needle instanceof \Traversable) { + return parent::indexOfLast($needle, $offset); + } else { + $needle = (string) $needle; + } + + $form = null === $this->ignoreCase ? \Normalizer::NFD : \Normalizer::NFC; + normalizer_is_normalized($needle, $form) ?: $needle = normalizer_normalize($needle, $form); + + if ('' === $needle || false === $needle) { + return null; + } + + $string = $this->string; + + if (0 > $offset) { + // workaround https://bugs.php.net/74264 + if (0 > $offset += grapheme_strlen($needle)) { + $string = grapheme_substr($string, 0, $offset); + } + $offset = 0; + } + + $i = $this->ignoreCase ? grapheme_strripos($string, $needle, $offset) : grapheme_strrpos($string, $needle, $offset); + + return false === $i ? null : $i; + } + + public function join(array $strings): AbstractString + { + $str = parent::join($strings); + normalizer_is_normalized($str->string) ?: $str->string = normalizer_normalize($str->string); + + return $str; + } + + public function length(): int + { + return grapheme_strlen($this->string); + } + + /** + * @return static + */ + public function normalize(int $form = self::NFC): parent + { + $str = clone $this; + + if (\in_array($form, [self::NFC, self::NFKC], true)) { + normalizer_is_normalized($str->string, $form) ?: $str->string = normalizer_normalize($str->string, $form); + } elseif (!\in_array($form, [self::NFD, self::NFKD], true)) { + throw new InvalidArgumentException('Unsupported normalization form.'); + } elseif (!normalizer_is_normalized($str->string, $form)) { + $str->string = normalizer_normalize($str->string, $form); + $str->ignoreCase = null; + } + + return $str; + } + + public function prepend(string ...$prefix): AbstractString + { + $str = clone $this; + $str->string = (1 >= \count($prefix) ? ($prefix[0] ?? '') : implode('', $prefix)).$this->string; + normalizer_is_normalized($str->string) ?: $str->string = normalizer_normalize($str->string); + + if (false === $str->string) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + return $str; + } + + public function replace(string $from, string $to): AbstractString + { + $str = clone $this; + normalizer_is_normalized($from) ?: $from = normalizer_normalize($from); + + if ('' !== $from && false !== $from) { + $tail = $str->string; + $result = ''; + $indexOf = $this->ignoreCase ? 'grapheme_stripos' : 'grapheme_strpos'; + + while (false !== $i = $indexOf($tail, $from)) { + $slice = grapheme_substr($tail, 0, $i); + $result .= $slice.$to; + $tail = substr($tail, \strlen($slice) + \strlen($from)); + } + + $str->string = $result .= $tail; + normalizer_is_normalized($str->string) ?: $str->string = normalizer_normalize($str->string); + + if (false === $str->string) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + } + + return $str; + } + + public function replaceMatches(string $fromPattern, $to): AbstractString + { + $str = parent::replaceMatches($fromPattern, $to); + normalizer_is_normalized($str->string) ?: $str->string = normalizer_normalize($str->string); + + return $str; + } + + public function slice(int $start = 0, int $length = null): AbstractString + { + $str = clone $this; + $str->string = (string) grapheme_substr($this->string, $start, $length ?? \PHP_INT_MAX); + + return $str; + } + + public function splice(string $replacement, int $start = 0, int $length = null): AbstractString + { + $str = clone $this; + $start = $start ? \strlen(grapheme_substr($this->string, 0, $start)) : 0; + $length = $length ? \strlen(grapheme_substr($this->string, $start, $length ?? \PHP_INT_MAX)) : $length; + $str->string = substr_replace($this->string, $replacement, $start, $length ?? \PHP_INT_MAX); + normalizer_is_normalized($str->string) ?: $str->string = normalizer_normalize($str->string); + + if (false === $str->string) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + return $str; + } + + public function split(string $delimiter, int $limit = null, int $flags = null): array + { + if (1 > $limit = $limit ?? \PHP_INT_MAX) { + throw new InvalidArgumentException('Split limit must be a positive integer.'); + } + + if ('' === $delimiter) { + throw new InvalidArgumentException('Split delimiter is empty.'); + } + + if (null !== $flags) { + return parent::split($delimiter.'u', $limit, $flags); + } + + normalizer_is_normalized($delimiter) ?: $delimiter = normalizer_normalize($delimiter); + + if (false === $delimiter) { + throw new InvalidArgumentException('Split delimiter is not a valid UTF-8 string.'); + } + + $str = clone $this; + $tail = $this->string; + $chunks = []; + $indexOf = $this->ignoreCase ? 'grapheme_stripos' : 'grapheme_strpos'; + + while (1 < $limit && false !== $i = $indexOf($tail, $delimiter)) { + $str->string = grapheme_substr($tail, 0, $i); + $chunks[] = clone $str; + $tail = substr($tail, \strlen($str->string) + \strlen($delimiter)); + --$limit; + } + + $str->string = $tail; + $chunks[] = clone $str; + + return $chunks; + } + + public function startsWith($prefix): bool + { + if ($prefix instanceof AbstractString) { + $prefix = $prefix->string; + } elseif (\is_array($prefix) || $prefix instanceof \Traversable) { + return parent::startsWith($prefix); + } else { + $prefix = (string) $prefix; + } + + $form = null === $this->ignoreCase ? \Normalizer::NFD : \Normalizer::NFC; + normalizer_is_normalized($prefix, $form) ?: $prefix = normalizer_normalize($prefix, $form); + + return '' !== $prefix && false !== $prefix && 0 === ($this->ignoreCase ? grapheme_stripos($this->string, $prefix) : grapheme_strpos($this->string, $prefix)); + } + + public function __clone() + { + if (null === $this->ignoreCase) { + normalizer_is_normalized($this->string) ?: $this->string = normalizer_normalize($this->string); + } + + $this->ignoreCase = false; + } +} diff --git a/src/Symfony/Component/String/LICENSE b/src/Symfony/Component/String/LICENSE new file mode 100644 index 0000000000000..1a1869751d250 --- /dev/null +++ b/src/Symfony/Component/String/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/String/README.md b/src/Symfony/Component/String/README.md new file mode 100644 index 0000000000000..4f3992a0b486d --- /dev/null +++ b/src/Symfony/Component/String/README.md @@ -0,0 +1,19 @@ +String Component +================ + +The String component provides an object-oriented API to strings and deals +with bytes, UTF-8 code points and grapheme clusters in a unified way. + +**This component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/string.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/String/Resources/functions.php b/src/Symfony/Component/String/Resources/functions.php new file mode 100644 index 0000000000000..cd941192b8e06 --- /dev/null +++ b/src/Symfony/Component/String/Resources/functions.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String; + +/** + * @experimental in 5.0 + */ +function u(string $string = ''): GraphemeString +{ + return new GraphemeString($string); +} + +/** + * @experimental in 5.0 + */ +function b(string $string = ''): BinaryString +{ + return new BinaryString($string); +} diff --git a/src/Symfony/Component/String/Utf8String.php b/src/Symfony/Component/String/Utf8String.php new file mode 100644 index 0000000000000..bcd5e7250bc84 --- /dev/null +++ b/src/Symfony/Component/String/Utf8String.php @@ -0,0 +1,261 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String; + +use Symfony\Component\String\Exception\ExceptionInterface; +use Symfony\Component\String\Exception\InvalidArgumentException; + +/** + * Represents a string of Unicode code points encoded as UTF-8. + * + * @author Nicolas Grekas + * @author Hugo Hamon + * + * @throws ExceptionInterface + * + * @experimental in 5.0 + */ +class Utf8String extends AbstractUnicodeString +{ + public function __construct(string $string = '') + { + if ('' !== $string && !preg_match('//u', $string)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + $this->string = $string; + } + + public function append(string ...$suffix): AbstractString + { + $str = clone $this; + $str->string .= 1 >= \count($suffix) ? ($suffix[0] ?? '') : implode('', $suffix); + + if (!preg_match('//u', $str->string)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + return $str; + } + + public function chunk(int $length = 1): array + { + if (1 > $length) { + throw new InvalidArgumentException('The chunk length must be greater than zero.'); + } + + if ('' === $this->string) { + return []; + } + + $rx = '/('; + while (65535 < $length) { + $rx .= '.{65535}'; + $length -= 65535; + } + $rx .= '.{'.$length.'})/us'; + + $str = clone $this; + $chunks = []; + + foreach (preg_split($rx, $this->string, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) as $chunk) { + $str->string = $chunk; + $chunks[] = clone $str; + } + + return $chunks; + } + + public function endsWith($suffix): bool + { + if ($suffix instanceof AbstractString) { + $suffix = $suffix->string; + } elseif (\is_array($suffix) || $suffix instanceof \Traversable) { + return parent::endsWith($suffix); + } else { + $suffix = (string) $suffix; + } + + if ('' === $suffix || !preg_match('//u', $suffix)) { + return false; + } + + if ($this->ignoreCase) { + return preg_match('{'.preg_quote($suffix).'$}iuD', $this->string); + } + + return \strlen($this->string) - \strlen($suffix) === strrpos($this->string, $suffix); + } + + public function equalsTo($string): bool + { + if ($string instanceof AbstractString) { + $string = $string->string; + } elseif (\is_array($string) || $string instanceof \Traversable) { + return parent::equalsTo($string); + } else { + $string = (string) $string; + } + + if ('' !== $string && $this->ignoreCase) { + return mb_strlen($string, 'UTF-8') === mb_strlen($this->string, 'UTF-8') && 0 === mb_stripos($this->string, $string, 0, 'UTF-8'); + } + + return $string === $this->string; + } + + public function indexOf($needle, int $offset = 0): ?int + { + if ($needle instanceof AbstractString) { + $needle = $needle->string; + } elseif (\is_array($needle) || $needle instanceof \Traversable) { + return parent::indexOf($needle, $offset); + } else { + $needle = (string) $needle; + } + + if ('' === $needle) { + return null; + } + + $i = $this->ignoreCase ? mb_stripos($this->string, $needle, $offset, 'UTF-8') : mb_strpos($this->string, $needle, $offset, 'UTF-8'); + + return false === $i ? null : $i; + } + + public function indexOfLast($needle, int $offset = 0): ?int + { + if ($needle instanceof AbstractString) { + $needle = $needle->string; + } elseif (\is_array($needle) || $needle instanceof \Traversable) { + return parent::indexOfLast($needle, $offset); + } else { + $needle = (string) $needle; + } + + if ('' === $needle) { + return null; + } + + $i = $this->ignoreCase ? mb_strripos($this->string, $needle, $offset, 'UTF-8') : mb_strrpos($this->string, $needle, $offset, 'UTF-8'); + + return false === $i ? null : $i; + } + + public function length(): int + { + return mb_strlen($this->string, 'UTF-8'); + } + + public function prepend(string ...$prefix): AbstractString + { + $str = clone $this; + $str->string = (1 >= \count($prefix) ? ($prefix[0] ?? '') : implode('', $prefix)).$this->string; + + if (!preg_match('//u', $str->string)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + return $str; + } + + public function replace(string $from, string $to): AbstractString + { + $str = clone $this; + + if ('' === $from || !preg_match('//u', $from)) { + return $str; + } + + if ('' !== $to && !preg_match('//u', $to)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + if ($this->ignoreCase) { + $str->string = implode($to, preg_split('{'.preg_quote($from).'}iuD', $this->string)); + } else { + $str->string = str_replace($from, $to, $this->string); + } + + return $str; + } + + public function slice(int $start = 0, int $length = null): AbstractString + { + $str = clone $this; + $str->string = mb_substr($this->string, $start, $length, 'UTF-8'); + + return $str; + } + + public function splice(string $replacement, int $start = 0, int $length = null): AbstractString + { + if (!preg_match('//u', $replacement)) { + throw new InvalidArgumentException('Invalid UTF-8 string.'); + } + + $str = clone $this; + $start = $start ? \strlen(mb_substr($this->string, 0, $start, 'UTF-8')) : 0; + $length = $length ? \strlen(mb_substr($this->string, $start, $length, 'UTF-8')) : $length; + $str->string = substr_replace($this->string, $replacement, $start, $length ?? \PHP_INT_MAX); + + return $str; + } + + public function split(string $delimiter, int $limit = null, int $flags = null): array + { + if (1 > $limit = $limit ?? \PHP_INT_MAX) { + throw new InvalidArgumentException('Split limit must be a positive integer.'); + } + + if ('' === $delimiter) { + throw new InvalidArgumentException('Split delimiter is empty.'); + } + + if (null !== $flags) { + return parent::split($delimiter.'u', $limit, $flags); + } + + if (!preg_match('//u', $delimiter)) { + throw new InvalidArgumentException('Split delimiter is not a valid UTF-8 string.'); + } + + $str = clone $this; + $chunks = $this->ignoreCase + ? preg_split('{'.preg_quote($delimiter).'}iuD', $this->string, $limit) + : explode($delimiter, $this->string, $limit); + + foreach ($chunks as &$chunk) { + $str->string = $chunk; + $chunk = clone $str; + } + + return $chunks; + } + + public function startsWith($prefix): bool + { + if ($prefix instanceof AbstractString) { + $prefix = $prefix->string; + } elseif (\is_array($prefix) || $prefix instanceof \Traversable) { + return parent::startsWith($prefix); + } else { + $prefix = (string) $prefix; + } + + if ('' === $prefix || !preg_match('//u', $prefix)) { + return false; + } + + return 0 === ($this->ignoreCase ? mb_stripos($this->string, $prefix, 0, 'UTF-8') : strpos($this->string, $prefix)); + } +} diff --git a/src/Symfony/Component/String/composer.json b/src/Symfony/Component/String/composer.json new file mode 100644 index 0000000000000..705abed3b8a16 --- /dev/null +++ b/src/Symfony/Component/String/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/string", + "type": "library", + "description": "Symfony String component", + "keywords": ["string", "utf8", "utf-8", "grapheme", "i18n", "unicode"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.9", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\String\\": "" }, + "files": [ "Resources/functions.php" ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + } +} diff --git a/src/Symfony/Component/String/phpunit.xml.dist b/src/Symfony/Component/String/phpunit.xml.dist new file mode 100644 index 0000000000000..619183a06d964 --- /dev/null +++ b/src/Symfony/Component/String/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + + From 82a00956bc6b8bb06feac025c66f448cdbcf9bbf Mon Sep 17 00:00:00 2001 From: Hugo Hamon Date: Wed, 8 Feb 2017 21:00:54 +0100 Subject: [PATCH 2/3] [String] add tests --- src/Symfony/Component/String/CHANGELOG.md | 4 +- .../String/Tests/AbstractAsciiTestCase.php | 732 ++++++++++++++++++ .../String/Tests/AbstractUtf8TestCase.php | 425 ++++++++++ .../String/Tests/BinaryStringTest.php | 23 + .../String/Tests/GraphemeStringTest.php | 203 +++++ .../Component/String/Tests/Utf8StringTest.php | 34 + 6 files changed, 1419 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php create mode 100644 src/Symfony/Component/String/Tests/AbstractUtf8TestCase.php create mode 100644 src/Symfony/Component/String/Tests/BinaryStringTest.php create mode 100644 src/Symfony/Component/String/Tests/GraphemeStringTest.php create mode 100644 src/Symfony/Component/String/Tests/Utf8StringTest.php diff --git a/src/Symfony/Component/String/CHANGELOG.md b/src/Symfony/Component/String/CHANGELOG.md index 094072510d707..28b9c6254196b 100644 --- a/src/Symfony/Component/String/CHANGELOG.md +++ b/src/Symfony/Component/String/CHANGELOG.md @@ -1,7 +1,7 @@ CHANGELOG ========= -4.4.0 +5.0.0 ----- - * added the component + * added the component as experimental diff --git a/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php b/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php new file mode 100644 index 0000000000000..baff67eaaf38b --- /dev/null +++ b/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php @@ -0,0 +1,732 @@ +assertSame('Symfony is a PHP framework!', (string) $bytes); + $this->assertSame(27, $bytes->length()); + $this->assertFalse($bytes->isEmpty()); + } + + public function testCreateFromEmptyString() + { + $instance = static::createFromString(''); + + $this->assertSame('', (string) $instance); + $this->assertSame(0, $instance->length()); + $this->assertTrue($instance->isEmpty()); + } + + /** + * @dataProvider provideLength + */ + public function testLength(int $length, string $string) + { + $instance = static::createFromString($string); + + $this->assertSame($length, $instance->length()); + } + + public static function provideLength(): array + { + return [ + [1, 'a'], + [2, 'is'], + [3, 'PHP'], + [4, 'Java'], + [7, 'Symfony'], + [10, 'pineapples'], + [22, 'Symfony is super cool!'], + ]; + } + + /** + * @dataProvider provideIndexOf + */ + public function testIndexOf(?int $result, string $string, string $needle, int $offset) + { + $instance = static::createFromString($string); + + $this->assertSame($result, $instance->indexOf($needle, $offset)); + } + + public static function provideIndexOf(): array + { + return [ + [null, 'abc', '', 0], + [null, 'ABC', '', 0], + [null, 'abc', 'd', 0], + [null, 'abc', 'a', 3], + [null, 'ABC', 'c', 0], + [null, 'ABC', 'c', 2], + [null, 'abc', 'a', -1], + [null, '123abc', 'B', -3], + [null, '123abc', 'b', 6], + [0, 'abc', 'a', 0], + [1, 'abc', 'b', 1], + [2, 'abc', 'c', 1], + [4, '123abc', 'b', -3], + ]; + } + + /** + * @dataProvider provideIndexOfIgnoreCase + */ + public function testIndexOfIgnoreCase(?int $result, string $string, string $needle, int $offset) + { + $instance = static::createFromString($string); + + $this->assertSame($result, $instance->ignoreCase()->indexOf($needle, $offset)); + } + + public static function provideIndexOfIgnoreCase(): array + { + return [ + [null, 'ABC', '', 0], + [null, 'ABC', '', 0], + [null, 'abc', 'a', 3], + [null, 'abc', 'A', 3], + [null, 'abc', 'a', -1], + [null, 'abc', 'A', -1], + [null, '123abc', 'B', 6], + [0, 'ABC', 'a', 0], + [0, 'ABC', 'A', 0], + [1, 'ABC', 'b', 0], + [1, 'ABC', 'b', 1], + [2, 'ABC', 'c', 0], + [2, 'ABC', 'c', 2], + [4, '123abc', 'B', -3], + ]; + } + + /** + * @dataProvider provideIndexOfLast + */ + public function testIndexOfLast(?int $result, string $string, string $needle, int $offset) + { + $instance = static::createFromString($string); + + $this->assertSame($result, $instance->indexOfLast($needle, $offset)); + } + + public static function provideIndexOfLast(): array + { + return [ + [null, 'abc', '', 0], + [null, 'abc', '', -2], + [null, 'elegant', 'z', -1], + [5, 'DEJAAAA', 'A', -2], + [74, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'i', 0], + [19, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'i', -40], + [6, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'ipsum', 0], + [57, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'amet', 0], + [57, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'amet', -10], + [22, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'amet', -30], + ]; + } + + /** + * @dataProvider provideIndexOfLastIgnoreCase + */ + public function testIndexOfLastIgnoreCase(?int $result, string $string, string $needle, int $offset) + { + $instance = static::createFromString($string); + + $this->assertSame($result, $instance->ignoreCase()->indexOfLast($needle, $offset)); + } + + public static function provideIndexOfLastIgnoreCase(): array + { + return [ + [null, 'abc', '', 0], + [null, 'abc', '', -2], + [null, 'elegant', 'z', -1], + [1, 'abc', 'b', 0], + [1, 'abc', 'b', -1], + [2, 'abcdefgh', 'c', -1], + [2, 'abcdefgh', 'C', -1], + [5, 'dejaaaa', 'A', -2], + [5, 'DEJAAAA', 'a', -2], + [74, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'I', 0], + [19, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'I', -40], + [6, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'IPSUM', 0], + [57, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'AmeT', 0], + [57, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'aMEt', -10], + [22, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'AMET', -30], + ]; + } + + /** + * @dataProvider provideSplit + */ + public function testSplit(string $string, string $delimiter, array $chunks, ?int $limit) + { + $this->assertEquals($chunks, static::createFromString($string)->split($delimiter, $limit)); + } + + public static function provideSplit(): array + { + return [ + [ + 'hello world', + ' ', + [ + static::createFromString('hello'), + static::createFromString('world'), + ], + null, + ], + [ + 'radar', + 'd', + [ + static::createFromString('ra'), + static::createFromString('ar'), + ], + 2, + ], + [ + 'foo,bar,baz,qux,kix', + ',', + [ + static::createFromString('foo'), + static::createFromString('bar'), + static::createFromString('baz'), + static::createFromString('qux'), + static::createFromString('kix'), + ], + null, + ], + [ + 'foo,bar,baz,qux,kix', + ',', + [ + static::createFromString('foo,bar,baz,qux,kix'), + ], + 1, + ], + [ + 'foo,bar,baz,qux,kix', + ',', + [ + static::createFromString('foo'), + static::createFromString('bar'), + static::createFromString('baz,qux,kix'), + ], + 3, + ], + [ + 'Quisque viverra tincidunt elit. Vestibulum convallis dui nec lacis suscipit cursus.', + 'is', + [ + static::createFromString('Qu'), + static::createFromString('que viverra tincidunt elit. Vestibulum convall'), + static::createFromString(' dui nec lac'), + static::createFromString(' suscipit cursus.'), + ], + null, + ], + ]; + } + + /** + * @dataProvider provideInvalidChunkLength + */ + public function testInvalidChunkLength(int $length) + { + $this->expectException(InvalidArgumentException::class); + + static::createFromString('foo|bar|baz')->chunk($length); + } + + public static function provideInvalidChunkLength(): array + { + return [ + [-2], + [-1], + [0], + ]; + } + + /** + * @dataProvider provideChunk + */ + public function testChunk(string $string, array $chunks, int $length) + { + $this->assertEquals($chunks, static::createFromString($string)->chunk($length)); + } + + public static function provideChunk() + { + return [ + [ + '', + [], + 1, + ], + [ + 'hello', + [ + static::createFromString('h'), + static::createFromString('e'), + static::createFromString('l'), + static::createFromString('l'), + static::createFromString('o'), + ], + 1, + ], + [ + 'hello you!', + [ + static::createFromString('h'), + static::createFromString('e'), + static::createFromString('l'), + static::createFromString('l'), + static::createFromString('o'), + static::createFromString(' '), + static::createFromString('y'), + static::createFromString('o'), + static::createFromString('u'), + static::createFromString('!'), + ], + 1, + ], + [ + 'hell', + [ + static::createFromString('h'), + static::createFromString('e'), + static::createFromString('l'), + static::createFromString('l'), + ], + 1, + ], + [ + 'hell', + [ + static::createFromString('he'), + static::createFromString('ll'), + ], + 2, + ], + [ + str_repeat('-', 65537), + [ + static::createFromString(str_repeat('-', 65536)), + static::createFromString('-'), + ], + 65536, + ], + ]; + } + + /** + * @dataProvider provideLower + */ + public function testLower(string $expected, string $origin) + { + $instance = static::createFromString($origin)->lower(); + + $this->assertNotSame(static::createFromString($origin), $instance); + $this->assertEquals(static::createFromString($expected), $instance); + $this->assertSame($expected, (string) $instance); + } + + public static function provideLower() + { + return [ + ['hello world', 'hello world'], + ['hello world', 'HELLO WORLD'], + ['hello world', 'Hello World'], + ['symfony', 'symfony'], + ['symfony', 'Symfony'], + ['symfony', 'sYmFOny'], + ]; + } + + /** + * @dataProvider provideUpper + */ + public function testUpper(string $expected, string $origin) + { + $instance = static::createFromString($origin)->upper(); + + $this->assertNotSame(static::createFromString($origin), $instance); + $this->assertEquals(static::createFromString($expected), $instance); + $this->assertSame($expected, (string) $instance); + } + + public static function provideUpper() + { + return [ + ['HELLO WORLD', 'hello world'], + ['HELLO WORLD', 'HELLO WORLD'], + ['HELLO WORLD', 'Hello World'], + ['SYMFONY', 'symfony'], + ['SYMFONY', 'Symfony'], + ['SYMFONY', 'sYmFOny'], + ]; + } + + /** + * @dataProvider provideTitle + */ + public function testTitle(string $expected, string $origin, bool $allWords) + { + $this->assertEquals( + static::createFromString($expected), + static::createFromString($origin)->title($allWords) + ); + } + + public static function provideTitle() + { + return [ + ['Hello world', 'hello world', false], + ['Hello World', 'hello world', true], + ['HELLO WORLD', 'HELLO WORLD', false], + ['HELLO WORLD', 'HELLO WORLD', true], + ['HELLO wORLD', 'hELLO wORLD', false], + ['HELLO WORLD', 'hELLO wORLD', true], + ['Symfony', 'symfony', false], + ['Symfony', 'Symfony', false], + ['SYmFOny', 'sYmFOny', false], + ]; + } + + /** + * @dataProvider provideSlice + */ + public function testSlice(string $expected, string $origin, int $start, int $length = null) + { + $this->assertEquals( + static::createFromString($expected), + static::createFromString($origin)->slice($start, $length) + ); + } + + public static function provideSlice() + { + return [ + ['Symfony', 'Symfony is awesome', 0, 7], + [' ', 'Symfony is awesome', 7, 1], + ['is', 'Symfony is awesome', 8, 2], + [' ', 'Symfony is awesome', 10, 1], + ['awesome', 'Symfony is awesome', 11, 7], + ]; + } + + /** + * @dataProvider provideAppend + */ + public function testAppend(string $expected, array $suffixes) + { + $instance = static::createFromString(''); + foreach ($suffixes as $suffix) { + $instance = $instance->append($suffix); + } + + $this->assertEquals($expected, $instance); + + $instance = static::createFromString('')->append(...$suffixes); + + $this->assertEquals(static::createFromString($expected), $instance); + } + + public static function provideAppend() + { + return [ + [ + 'Symfony', + ['Sym', 'fony'], + ], + [ + 'Hello World!', + ['Hel', 'lo', ' ', 'World', '!'], + ], + ]; + } + + /** + * @dataProvider provideAppend + */ + public function testPrepend(string $expected, array $prefixes) + { + $instance = static::createFromString(''); + foreach (array_reverse($prefixes) as $suffix) { + $instance = $instance->prepend($suffix); + } + + $this->assertEquals(static::createFromString($expected), $instance); + + $instance = static::createFromString('')->prepend(...$prefixes); + + $this->assertEquals(static::createFromString($expected), $instance); + } + + /** + * @dataProvider provideTrim + */ + public function testTrim(string $expected, string $origin, ?string $chars) + { + $result = static::createFromString($origin); + $result = null !== $chars ? $result->trim($chars) : $result->trim(); + + $this->assertEquals(static::createFromString($expected), $result); + } + + public static function provideTrim() + { + return [ + [ + "Symfony IS GREAT\t!!!", + " Symfony IS GREAT\t!!!\n", + null, + ], + [ + "Symfony IS GREAT\t!!!\n", + " Symfony IS GREAT\t!!!\n", + ' ', + ], + [ + " Symfony IS GREAT\t!!!", + " Symfony IS GREAT\t!!!\n", + "\n", + ], + [ + "Symfony IS GREAT\t", + " Symfony IS GREAT\t!!!\n", + " \n!", + ], + ]; + } + + /** + * @dataProvider provideTrimStart + */ + public function testTrimStart(string $expected, string $origin, ?string $chars) + { + $result = static::createFromString($origin); + $result = null !== $chars ? $result->trimStart($chars) : $result->trimStart(); + + $this->assertEquals(static::createFromString($expected), $result); + } + + public static function provideTrimStart() + { + return [ + [ + "Symfony is a PHP framework\n", + "\n\tSymfony is a PHP framework\n", + null, + ], + [ + "\tSymfony is a PHP framework\n", + "\n\tSymfony is a PHP framework\n", + "\n", + ], + ]; + } + + /** + * @dataProvider provideTrimEnd + */ + public function testTrimEnd(string $expected, string $origin, ?string $chars) + { + $result = static::createFromString($origin); + $result = null !== $chars ? $result->trimEnd($chars) : $result->trimEnd(); + + $this->assertEquals(static::createFromString($expected), $result); + } + + public static function provideTrimEnd() + { + return [ + [ + "\n\tSymfony is a PHP framework", + "\n\tSymfony is a PHP framework \n", + null, + ], + [ + "\n\tSymfony is a PHP framework ", + "\n\tSymfony is a PHP framework \n", + "\n", + ], + ]; + } + + /** + * @dataProvider provideBeforeAfter + */ + public function testBeforeAfter(string $expected, string $needle, string $origin, bool $before) + { + $result = static::createFromString($origin); + $result = $before ? $result->before($needle, false) : $result->after($needle, true); + $this->assertEquals(static::createFromString($expected), $result); + } + + public static function provideBeforeAfter() + { + return [ + ['', '', 'hello world', true], + ['', '', 'hello world', false], + ['', 'w', 'hello World', true], + ['', 'w', 'hello World', false], + ['hello ', 'w', 'hello world', true], + ['world', 'w', 'hello world', false], + ]; + } + + /** + * @dataProvider provideBeforeAfterIgnoreCase + */ + public function testBeforeAfterIgnoreCase(string $expected, string $needle, string $origin, bool $before) + { + $result = static::createFromString($origin)->ignoreCase(); + $result = $before ? $result->before($needle, false) : $result->after($needle, true); + $this->assertEquals(static::createFromString($expected), $result); + } + + public static function provideBeforeAfterIgnoreCase() + { + return [ + ['', '', 'hello world', true], + ['', '', 'hello world', false], + ['', 'foo', 'hello world', true], + ['', 'foo', 'hello world', false], + ['hello ', 'w', 'hello world', true], + ['world', 'w', 'hello world', false], + ['hello ', 'W', 'hello world', true], + ['world', 'W', 'hello world', false], + ]; + } + + /** + * @dataProvider provideBeforeAfterLast + */ + public function testBeforeAfterLast(string $expected, string $needle, string $origin, bool $before) + { + $result = static::createFromString($origin); + $result = $before ? $result->beforeLast($needle, false) : $result->afterLast($needle, true); + $this->assertEquals(static::createFromString($expected), $result); + } + + public static function provideBeforeAfterLast() + { + return [ + ['', '', 'hello world', true], + ['', '', 'hello world', false], + ['', 'L', 'hello world', true], + ['', 'L', 'hello world', false], + ['hello wor', 'l', 'hello world', true], + ['ld', 'l', 'hello world', false], + ['hello w', 'o', 'hello world', true], + ['orld', 'o', 'hello world', false], + ]; + } + + /** + * @dataProvider provideBeforeAfterLastIgnoreCase + */ + public function testBeforeAfterLastIgnoreCase(string $expected, string $needle, string $origin, bool $before) + { + $result = static::createFromString($origin)->ignoreCase(); + $result = $before ? $result->beforeLast($needle, false) : $result->afterLast($needle, true); + $this->assertEquals(static::createFromString($expected), $result); + } + + public static function provideBeforeAfterLastIgnoreCase() + { + return [ + ['', '', 'hello world', true], + ['', '', 'hello world', false], + ['', 'FOO', 'hello world', true], + ['', 'FOO', 'hello world', false], + ['hello wor', 'l', 'hello world', true], + ['ld', 'l', 'hello world', false], + ['hello wor', 'L', 'hello world', true], + ['ld', 'L', 'hello world', false], + ['hello w', 'O', 'hello world', true], + ['orld', 'O', 'hello world', false], + ]; + } + + /** + * @dataProvider provideFolded + */ + public function testFolded(string $expected, string $origin) + { + $this->assertEquals( + static::createFromString($expected), + static::createFromString($origin)->folded() + ); + } + + public static function provideFolded() + { + return [ + ['hello', 'HELlo'], + ['world', 'worLd'], + ]; + } + + /** + * @dataProvider provideReplace + */ + public function testReplace(string $expectedString, int $expectedCount, string $origin, string $from, string $to) + { + $origin = static::createFromString($origin); + $result = $origin->replace($from, $to); + + $this->assertEquals(static::createFromString($expectedString), $result); + } + + public static function provideReplace() + { + return [ + ['hello world', 0, 'hello world', '', ''], + ['hello world', 0, 'hello world', '', '_'], + ['helloworld', 1, 'hello world', ' ', ''], + ['hello_world', 1, 'hello world', ' ', '_'], + ['hemmo wormd', 3, 'hello world', 'l', 'm'], + ['hello world', 0, 'hello world', 'L', 'm'], + ]; + } + + /** + * @dataProvider provideReplaceIgnoreCase + */ + public function testReplaceIgnoreCase(string $expectedString, int $expectedCount, string $origin, string $from, string $to) + { + $origin = static::createFromString($origin); + $result = $origin->ignoreCase()->replace($from, $to); + + $this->assertEquals(static::createFromString($expectedString), $result); + } + + public static function provideReplaceIgnoreCase() + { + return [ + ['hello world', 0, 'hello world', '', ''], + ['hello world', 0, 'hello world', '', '_'], + ['helloworld', 1, 'hello world', ' ', ''], + ['hello_world', 1, 'hello world', ' ', '_'], + ['hemmo wormd', 3, 'hello world', 'l', 'm'], + ['heMMo worMd', 3, 'hello world', 'L', 'M'], + ]; + } +} diff --git a/src/Symfony/Component/String/Tests/AbstractUtf8TestCase.php b/src/Symfony/Component/String/Tests/AbstractUtf8TestCase.php new file mode 100644 index 0000000000000..d2216d5a561f6 --- /dev/null +++ b/src/Symfony/Component/String/Tests/AbstractUtf8TestCase.php @@ -0,0 +1,425 @@ +expectException(InvalidArgumentException::class); + + static::createFromString("\xE9"); + } + + public function provideCreateFromCodePoint(): array + { + return [ + ['', []], + ['*', [42]], + ['AZ', [65, 90]], + ['€', [8364]], + ['€', [0x20ac]], + ['Ʃ', [425]], + ['Ʃ', [0x1a9]], + ['☢☎❄', [0x2622, 0x260E, 0x2744]], + ]; + } + + public static function provideLength(): array + { + return [ + [1, 'a'], + [1, 'ß'], + [2, 'is'], + [3, 'PHP'], + [3, '한국어'], + [4, 'Java'], + [7, 'Symfony'], + [10, 'pineapples'], + [22, 'Symfony is super cool!'], + ]; + } + + public static function provideIndexOf(): array + { + return array_merge( + parent::provideIndexOf(), + [ + [1, '한국어', '국', 0], + [1, '한국어', '국', 1], + [null, '한국어', '국', 2], + [8, 'der Straße nach Paris', 'ß', 4], + ] + ); + } + + public static function provideIndexOfIgnoreCase(): array + { + return array_merge( + parent::provideIndexOfIgnoreCase(), + [ + [3, 'DÉJÀ', 'À', 0], + [3, 'DÉJÀ', 'à', 0], + [1, 'DÉJÀ', 'É', 1], + [1, 'DÉJÀ', 'é', 1], + [1, 'aςσb', 'ΣΣ', 0], + [16, 'der Straße nach Paris', 'Paris', 0], + [8, 'der Straße nach Paris', 'ß', 4], + ] + ); + } + + public static function provideIndexOfLast(): array + { + return array_merge( + parent::provideIndexOfLast(), + [ + [null, '한국어', '', 0], + [1, '한국어', '국', 0], + [5, '한국어어어어국국', '어', 0], + // see https://bugs.php.net/bug.php?id=74264 + [15, 'abcdéf12é45abcdéf', 'é', 0], + [8, 'abcdéf12é45abcdéf', 'é', -4], + ] + ); + } + + public static function provideIndexOfLastIgnoreCase(): array + { + return array_merge( + parent::provideIndexOfLastIgnoreCase(), + [ + [null, '한국어', '', 0], + [3, 'DÉJÀ', 'à', 0], + [3, 'DÉJÀ', 'À', 0], + [6, 'DÉJÀÀÀÀ', 'à', 0], + [6, 'DÉJÀÀÀÀ', 'à', 3], + [5, 'DÉJÀÀÀÀ', 'àà', 0], + [2, 'DÉJÀÀÀÀ', 'jà', 0], + [2, 'DÉJÀÀÀÀ', 'jà', -5], + [6, 'DÉJÀÀÀÀ!', 'à', -2], + // see https://bugs.php.net/bug.php?id=74264 + [5, 'DÉJÀÀÀÀ', 'à', -2], + [15, 'abcdéf12é45abcdéf', 'é', 0], + [8, 'abcdéf12é45abcdéf', 'é', -4], + [1, 'aςσb', 'ΣΣ', 0], + ] + ); + } + + public static function provideSplit(): array + { + return array_merge( + parent::provideSplit(), + [ + [ + '會|意|文|字|/|会|意|文|字', + '|', + [ + static::createFromString('會'), + static::createFromString('意'), + static::createFromString('文'), + static::createFromString('字'), + static::createFromString('/'), + static::createFromString('会'), + static::createFromString('意'), + static::createFromString('文'), + static::createFromString('字'), + ], + null, + ], + [ + '會|意|文|字|/|会|意|文|字', + '|', + [ + static::createFromString('會'), + static::createFromString('意'), + static::createFromString('文'), + static::createFromString('字'), + static::createFromString('/|会|意|文|字'), + ], + 5, + ], + ] + ); + } + + public static function provideChunk(): array + { + return array_merge( + parent::provideChunk(), + [ + [ + 'déjà', + [ + static::createFromString('d'), + static::createFromString('é'), + static::createFromString('j'), + static::createFromString('à'), + ], + 1, + ], + [ + 'déjà', + [ + static::createFromString('dé'), + static::createFromString('jà'), + ], + 2, + ], + ] + ); + } + + public function testTrimWithInvalidUtf8CharList() + { + $this->expectException(InvalidArgumentException::class); + + static::createFromString('Symfony')->trim("\xE9"); + } + + public function testTrimStartWithInvalidUtf8CharList() + { + $this->expectException(InvalidArgumentException::class); + + static::createFromString('Symfony')->trimStart("\xE9"); + } + + public function testTrimEndWithInvalidUtf8CharList() + { + $this->expectException(InvalidArgumentException::class); + + static::createFromString('Symfony')->trimEnd("\xE9"); + } + + public static function provideLower(): array + { + return array_merge( + parent::provideLower(), + [ + // French + ['garçon', 'garçon'], + ['garçon', 'GARÇON'], + ["œuvre d'art", "Œuvre d'Art"], + + // Spanish + ['el niño', 'El Niño'], + + // Romanian + ['împărat', 'Împărat'], + + // Random symbols + ['déjà σσς i̇iıi', 'DÉJÀ Σσς İIıi'], + ] + ); + } + + public static function provideUpper(): array + { + return array_merge( + parent::provideUpper(), + [ + // French + ['GARÇON', 'garçon'], + ['GARÇON', 'GARÇON'], + ["ŒUVRE D'ART", "Œuvre d'Art"], + + // Spanish + ['EL NIÑO', 'El Niño'], + + // Romanian + ['ÎMPĂRAT', 'Împărat'], + + // Random symbols + ['DÉJÀ ΣΣΣ İIII', 'Déjà Σσς İIıi'], + ] + ); + } + + public static function provideTitle(): array + { + return array_merge( + parent::provideTitle(), + [ + ['Deja', 'deja', false], + ['Σσς', 'σσς', false], + ['DEJa', 'dEJa', false], + ['ΣσΣ', 'σσΣ', false], + ['Deja Σσς DEJa ΣσΣ', 'deja σσς dEJa σσΣ', true], + ] + ); + } + + public static function provideSlice(): array + { + return array_merge( + parent::provideSlice(), + [ + ['jà', 'déjà', 2, null], + ['jà', 'déjà', 2, null], + ['jà', 'déjà', -2, null], + ['jà', 'déjà', -2, 3], + ['', 'déjà', -1, 0], + ['', 'déjà', 1, -4], + ['j', 'déjà', -2, -1], + ['', 'déjà', -2, -2], + ['', 'déjà', 5, 0], + ['', 'déjà', -5, 0], + ] + ); + } + + public static function provideAppend(): array + { + return array_merge( + parent::provideAppend(), + [ + [ + 'Déjà Σσς', + ['Déjà', ' ', 'Σσς'], + ], + [ + 'Déjà Σσς İIıi', + ['Déjà', ' Σσς', ' İIıi'], + ], + ] + ); + } + + public function testAppendInvalidUtf8String() + { + $this->expectException(InvalidArgumentException::class); + + static::createFromString('Symfony')->append("\xE9"); + } + + public static function providePrepend(): array + { + return array_merge( + parent::providePrepend(), + [ + [ + 'Σσς Déjà', + ['Déjà', 'Σσς '], + ], + [ + 'İIıi Σσς Déjà', + ['Déjà', 'Σσς ', 'İIıi '], + ], + ] + ); + } + + public function testPrependInvalidUtf8String() + { + $this->expectException(InvalidArgumentException::class); + + static::createFromString('Symfony')->prepend("\xE9"); + } + + public static function provideBeforeAfter(): array + { + return array_merge( + parent::provideBeforeAfter(), + [ + ['jàdéjà', 'jà', 'déjàdéjà', false], + ['dé', 'jà', 'déjàdéjà', true], + ] + ); + } + + public static function provideBeforeAfterIgnoreCase(): array + { + return array_merge( + parent::provideBeforeAfterIgnoreCase(), + [ + ['jàdéjà', 'JÀ', 'déjàdéjà', false], + ['dé', 'jÀ', 'déjàdéjà', true], + ['éjàdéjà', 'é', 'déjàdéjà', false], + ['d', 'é', 'déjàdéjà', true], + ['', 'Ç', 'déjàdéjà', false], + ['', 'Ç', 'déjàdéjà', true], + ] + ); + } + + public static function provideBeforeAfterLast(): array + { + return array_merge( + parent::provideBeforeAfterLast(), + [ + ['', 'Ç', 'déjàdéjà', false], + ['', 'Ç', 'déjàdéjà', true], + ['éjà', 'é', 'déjàdéjà', false], + ['déjàd', 'é', 'déjàdéjà', true], + ] + ); + } + + public static function provideBeforeAfterLastIgnoreCase(): array + { + return array_merge( + parent::provideBeforeAfterLastIgnoreCase(), + [ + ['', 'Ç', 'déjàdéjà', false], + ['éjà', 'é', 'déjàdéjà', false], + ['éjà', 'É', 'déjàdéjà', false], + ] + ); + } + + public static function provideToFoldedCase(): array + { + return array_merge( + parent::provideToFoldedCase(), + [ + ['déjà', 'DéjÀ'], + ['σσσ', 'Σσς'], + ['iıi̇i', 'Iıİi'], + ] + ); + } + + public static function provideReplace(): array + { + return array_merge( + parent::provideReplace(), + [ + ['ΣσΣ', 1, 'Σσς', 'ς', 'Σ'], + ['漢字はユニコード', 0, '漢字はユニコード', 'foo', 'bar'], + ['漢字ーユニコード', 1, '漢字はユニコード', 'は', 'ー'], + ['This is a jamais-vu situation!', 1, 'This is a déjà-vu situation!', 'déjà', 'jamais'], + ] + ); + } + + public static function provideReplaceIgnoreCase(): array + { + return array_merge( + parent::provideReplaceIgnoreCase(), + [ + // σ and ς are lowercase variants for Σ + ['ΣΣΣ', 3, 'σσσ', 'σ', 'Σ'], + ['ΣΣΣ', 3, 'σσσ', 'ς', 'Σ'], + ['Σσ', 1, 'σσσ', 'σσ', 'Σ'], + ['漢字はユニコード', 0, '漢字はユニコード', 'foo', 'bar'], + ['漢字ーユニコード', 1, '漢字はユニコード', 'は', 'ー'], + ['This is a jamais-vu situation!', 1, 'This is a déjà-vu situation!', 'DÉjÀ', 'jamais'], + ] + ); + } + + public function testReplaceWithInvalidUtf8Pattern() + { + $this->assertEquals('Symfony', static::createFromString('Symfony')->replace("\xE9", 'p')); + } + + public function testReplaceWithInvalidUtf8PatternReplacement() + { + $this->expectException(InvalidArgumentException::class); + + static::createFromString('Symfony')->replace('f', "\xE9"); + } +} diff --git a/src/Symfony/Component/String/Tests/BinaryStringTest.php b/src/Symfony/Component/String/Tests/BinaryStringTest.php new file mode 100644 index 0000000000000..3605b5db2c1b6 --- /dev/null +++ b/src/Symfony/Component/String/Tests/BinaryStringTest.php @@ -0,0 +1,23 @@ + + * + * 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 Symfony\Component\String\AbstractString; +use Symfony\Component\String\BinaryString; + +class BinaryStringTest extends AbstractAsciiTestCase +{ + protected static function createFromString(string $string): AbstractString + { + return new BinaryString($string); + } +} diff --git a/src/Symfony/Component/String/Tests/GraphemeStringTest.php b/src/Symfony/Component/String/Tests/GraphemeStringTest.php new file mode 100644 index 0000000000000..18775de077859 --- /dev/null +++ b/src/Symfony/Component/String/Tests/GraphemeStringTest.php @@ -0,0 +1,203 @@ + + * + * 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 Symfony\Component\String\AbstractString; +use Symfony\Component\String\GraphemeString; + +class GraphemeStringTest extends AbstractUtf8TestCase +{ + protected static function createFromString(string $string): AbstractString + { + return new GraphemeString($string); + } + + public static function provideLength(): array + { + return array_merge( + parent::provideLength(), + [ + // 5 letters + 3 combining marks + [5, 'अनुच्छेद'], + ] + ); + } + + public static function provideSplit(): array + { + return array_merge( + parent::provideSplit(), + [ + [ + 'अ.नु.च्.छे.द', + '.', + [ + static::createFromString('अ'), + static::createFromString('नु'), + static::createFromString('च्'), + static::createFromString('छे'), + static::createFromString('द'), + ], + null, + ], + ] + ); + } + + public static function provideChunk(): array + { + return array_merge( + parent::provideChunk(), + [ + [ + 'अनुच्छेद', + [ + static::createFromString('अ'), + static::createFromString('नु'), + static::createFromString('च्'), + static::createFromString('छे'), + static::createFromString('द'), + ], + 1, + ], + ] + ); + } + + public static function provideLower(): array + { + return array_merge( + parent::provideLower(), + [ + // Hindi + ['अनुच्छेद', 'अनुच्छेद'], + ] + ); + } + + public static function provideUpper(): array + { + return array_merge( + parent::provideUpper(), + [ + // Hindi + ['अनुच्छेद', 'अनुच्छेद'], + ] + ); + } + + public static function provideAppend(): array + { + return array_merge( + parent::provideAppend(), + [ + [ + 'तद्भव देशज', + ['तद्भव', ' ', 'देशज'], + ], + [ + 'तद्भव देशज विदेशी', + ['तद्भव', ' देशज', ' विदेशी'], + ], + ] + ); + } + + public static function providePrepend(): array + { + return array_merge( + parent::providePrepend(), + [ + [ + 'देशज तद्भव', + ['तद्भव', 'देशज '], + ], + [ + 'विदेशी देशज तद्भव', + ['तद्भव', 'देशज ', 'विदेशी '], + ], + ] + ); + } + + public static function provideBeforeAfter(): array + { + return array_merge( + parent::provideBeforeAfter(), + [ + ['द foo अनुच्छेद', 'द', 'अनुच्छेद foo अनुच्छेद', false], + ['अनुच्छे', 'द', 'अनुच्छेद foo अनुच्छेद', true], + ] + ); + } + + public static function provideBeforeAfterIgnoreCase(): array + { + return array_merge( + parent::provideBeforeAfterIgnoreCase(), + [ + ['', 'छेछे', 'दछेच्नुअ', false], + ['', 'छेछे', 'दछेच्नुअ', true], + ['छेच्नुअ', 'छे', 'दछेच्नुअ', false], + ['द', 'छे', 'दछेच्नुअ', true], + ] + ); + } + + public static function provideBeforeAfterLast(): array + { + return array_merge( + parent::provideBeforeAfterLast(), + [ + ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', false], + ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', true], + ['-दछेच्नु', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', false], + ['दछेच्नुअ-दछेच्नु-अद', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', true], + ] + ); + } + + public static function provideBeforeAfterLastIgnoreCase(): array + { + return array_merge( + parent::provideBeforeAfterLastIgnoreCase(), + [ + ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', false], + ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', true], + ['-दछेच्नु', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', false], + ['दछेच्नुअ-दछेच्नु-अद', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', true], + ] + ); + } + + public static function provideReplace(): array + { + return array_merge( + parent::provideReplace(), + [ + ['Das Innenministerium', 1, 'Das Außenministerium', 'Auß', 'Inn'], + ['दछेच्नुद-दछेच्नु-ददछेच्नु', 2, 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 'अ', 'द'], + ] + ); + } + + public static function provideReplaceIgnoreCase(): array + { + return array_merge( + parent::provideReplaceIgnoreCase(), + [ + ['Das Aussenministerium', 1, 'Das Außenministerium', 'auß', 'Auss'], + ['दछेच्नुद-दछेच्नु-ददछेच्नु', 2, 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 'अ', 'द'], + ] + ); + } +} diff --git a/src/Symfony/Component/String/Tests/Utf8StringTest.php b/src/Symfony/Component/String/Tests/Utf8StringTest.php new file mode 100644 index 0000000000000..b5381b44cfab5 --- /dev/null +++ b/src/Symfony/Component/String/Tests/Utf8StringTest.php @@ -0,0 +1,34 @@ + + * + * 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 Symfony\Component\String\AbstractString; +use Symfony\Component\String\Utf8String; + +class Utf8StringTest extends AbstractUtf8TestCase +{ + protected static function createFromString(string $string): AbstractString + { + return new Utf8String($string); + } + + public static function provideLength(): array + { + return array_merge( + parent::provideLength(), + [ + // 8 instead of 5 if it were processed as a grapheme cluster + [8, 'अनुच्छेद'], + ] + ); + } +} From dd8745acedddcfaf803ee32d8042b9e1828372ea Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Wed, 18 Sep 2019 22:29:16 +0200 Subject: [PATCH 3/3] [String] add more tests --- .../String/Tests/AbstractAsciiTestCase.php | 645 ++++++++++++++++-- .../String/Tests/AbstractUtf8TestCase.php | 112 ++- .../String/Tests/BinaryStringTest.php | 10 + .../String/Tests/GraphemeStringTest.php | 28 +- 4 files changed, 721 insertions(+), 74 deletions(-) diff --git a/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php b/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php index baff67eaaf38b..8f5de1be85dba 100644 --- a/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php +++ b/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php @@ -4,7 +4,10 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\String\AbstractString; +use Symfony\Component\String\BinaryString; use Symfony\Component\String\Exception\InvalidArgumentException; +use Symfony\Component\String\GraphemeString; +use Symfony\Component\String\Utf8String; abstract class AbstractAsciiTestCase extends TestCase { @@ -79,6 +82,7 @@ public static function provideIndexOf(): array [0, 'abc', 'a', 0], [1, 'abc', 'b', 1], [2, 'abc', 'c', 1], + [4, 'abacabab', 'ab', 1], [4, '123abc', 'b', -3], ]; } @@ -109,6 +113,7 @@ public static function provideIndexOfIgnoreCase(): array [1, 'ABC', 'b', 1], [2, 'ABC', 'c', 0], [2, 'ABC', 'c', 2], + [4, 'ABACaBAB', 'Ab', 1], [4, '123abc', 'B', -3], ]; } @@ -116,7 +121,7 @@ public static function provideIndexOfIgnoreCase(): array /** * @dataProvider provideIndexOfLast */ - public function testIndexOfLast(?int $result, string $string, string $needle, int $offset) + public function testIndexOfLast(?int $result, string $string, $needle, int $offset) { $instance = static::createFromString($string); @@ -129,6 +134,7 @@ public static function provideIndexOfLast(): array [null, 'abc', '', 0], [null, 'abc', '', -2], [null, 'elegant', 'z', -1], + [0, 'abc', ['abc'], 0], [5, 'DEJAAAA', 'A', -2], [74, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'i', 0], [19, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, amet sagittis felis.', 'i', -40], @@ -173,9 +179,9 @@ public static function provideIndexOfLastIgnoreCase(): array /** * @dataProvider provideSplit */ - public function testSplit(string $string, string $delimiter, array $chunks, ?int $limit) + public function testSplit(string $string, string $delimiter, array $chunks, ?int $limit, int $flags = null) { - $this->assertEquals($chunks, static::createFromString($string)->split($delimiter, $limit)); + $this->assertEquals($chunks, static::createFromString($string)->split($delimiter, $limit, $flags)); } public static function provideSplit(): array @@ -240,6 +246,44 @@ public static function provideSplit(): array ], null, ], + [ + 'foo,,bar, baz , qux,kix', + '/\s*,\s*/', + [ + static::createFromString('foo'), + static::createFromString(''), + static::createFromString('bar'), + static::createFromString('baz'), + static::createFromString('qux,kix'), + ], + 5, + AbstractString::PREG_SPLIT, + ], + [ + 'foo ,,bar, baz', + '/\s*(,)\s*/', + [ + static::createFromString('foo'), + static::createFromString(','), + static::createFromString(','), + static::createFromString('bar'), + static::createFromString(','), + static::createFromString('baz'), + ], + null, + AbstractString::PREG_SPLIT_NO_EMPTY | AbstractString::PREG_SPLIT_DELIM_CAPTURE, + ], + [ + 'foo, bar,baz', + '/\s*(,)\s*/', + [ + [static::createFromString('foo'), 0], + [static::createFromString('bar'), 5], + [static::createFromString('baz'), 9], + ], + null, + AbstractString::PREG_SPLIT_OFFSET_CAPTURE, + ], ]; } @@ -351,7 +395,7 @@ public static function provideLower() return [ ['hello world', 'hello world'], ['hello world', 'HELLO WORLD'], - ['hello world', 'Hello World'], + ['hello world!', 'Hello World!'], ['symfony', 'symfony'], ['symfony', 'Symfony'], ['symfony', 'sYmFOny'], @@ -375,7 +419,7 @@ public static function provideUpper() return [ ['HELLO WORLD', 'hello world'], ['HELLO WORLD', 'HELLO WORLD'], - ['HELLO WORLD', 'Hello World'], + ['HELLO WORLD!', 'Hello World!'], ['SYMFONY', 'symfony'], ['SYMFONY', 'Symfony'], ['SYMFONY', 'sYmFOny'], @@ -425,8 +469,11 @@ public static function provideSlice() ['Symfony', 'Symfony is awesome', 0, 7], [' ', 'Symfony is awesome', 7, 1], ['is', 'Symfony is awesome', 8, 2], + ['is awesome', 'Symfony is awesome', 8, null], [' ', 'Symfony is awesome', 10, 1], ['awesome', 'Symfony is awesome', 11, 7], + ['awesome', 'Symfony is awesome', -7, null], + ['awe', 'Symfony is awesome', -7, -4], ]; } @@ -450,6 +497,10 @@ public function testAppend(string $expected, array $suffixes) public static function provideAppend() { return [ + [ + '', + [], + ], [ 'Symfony', ['Sym', 'fony'], @@ -512,6 +563,11 @@ public static function provideTrim() " Symfony IS GREAT\t!!!\n", " \n!", ], + [ + "Symfony IS GREAT\t!!! \n", + " Symfony IS GREAT\t!!! \n", + ' ', + ], ]; } @@ -566,102 +622,125 @@ public static function provideTrimEnd() "\n\tSymfony is a PHP framework \n", "\n", ], + [ + "\n\tSymfony is a PHP framework \n", + "\n\tSymfony is a PHP framework \n", + ' ', + ], ]; } /** * @dataProvider provideBeforeAfter */ - public function testBeforeAfter(string $expected, string $needle, string $origin, bool $before) + public function testBeforeAfter(string $expected, string $needle, string $origin, int $offset, bool $before) { $result = static::createFromString($origin); - $result = $before ? $result->before($needle, false) : $result->after($needle, true); + $result = $before ? $result->before($needle, false, $offset) : $result->after($needle, true, $offset); $this->assertEquals(static::createFromString($expected), $result); } public static function provideBeforeAfter() { return [ - ['', '', 'hello world', true], - ['', '', 'hello world', false], - ['', 'w', 'hello World', true], - ['', 'w', 'hello World', false], - ['hello ', 'w', 'hello world', true], - ['world', 'w', 'hello world', false], + ['', '', 'hello world', 0, true], + ['', '', 'hello world', 0, false], + ['', 'w', 'hello World', 0, true], + ['', 'w', 'hello World', 0, false], + ['', 'o', 'hello world', 10, true], + ['', 'o', 'hello world', 10, false], + ['hello ', 'w', 'hello world', 0, true], + ['world', 'w', 'hello world', 0, false], + ['hello W', 'O', 'hello WORLD', 0, true], + ['ORLD', 'O', 'hello WORLD', 0, false], + ['abac', 'ab', 'abacabab', 1, true], + ['abab', 'ab', 'abacabab', 1, false], ]; } /** * @dataProvider provideBeforeAfterIgnoreCase */ - public function testBeforeAfterIgnoreCase(string $expected, string $needle, string $origin, bool $before) + public function testBeforeAfterIgnoreCase(string $expected, string $needle, string $origin, int $offset, bool $before) { $result = static::createFromString($origin)->ignoreCase(); - $result = $before ? $result->before($needle, false) : $result->after($needle, true); + $result = $before ? $result->before($needle, false, $offset) : $result->after($needle, true, $offset); $this->assertEquals(static::createFromString($expected), $result); } public static function provideBeforeAfterIgnoreCase() { return [ - ['', '', 'hello world', true], - ['', '', 'hello world', false], - ['', 'foo', 'hello world', true], - ['', 'foo', 'hello world', false], - ['hello ', 'w', 'hello world', true], - ['world', 'w', 'hello world', false], - ['hello ', 'W', 'hello world', true], - ['world', 'W', 'hello world', false], + ['', '', 'hello world', 0, true], + ['', '', 'hello world', 0, false], + ['', 'foo', 'hello world', 0, true], + ['', 'foo', 'hello world', 0, false], + ['', 'o', 'hello world', 10, true], + ['', 'o', 'hello world', 10, false], + ['hello ', 'w', 'hello world', 0, true], + ['world', 'w', 'hello world', 0, false], + ['hello ', 'W', 'hello world', 0, true], + ['world', 'W', 'hello world', 0, false], + ['Abac', 'Ab', 'AbacaBAb', 1, true], + ['aBAb', 'Ab', 'AbacaBAb', 1, false], ]; } /** * @dataProvider provideBeforeAfterLast */ - public function testBeforeAfterLast(string $expected, string $needle, string $origin, bool $before) + public function testBeforeAfterLast(string $expected, string $needle, string $origin, int $offset, bool $before) { $result = static::createFromString($origin); - $result = $before ? $result->beforeLast($needle, false) : $result->afterLast($needle, true); + $result = $before ? $result->beforeLast($needle, false, $offset) : $result->afterLast($needle, true, $offset); $this->assertEquals(static::createFromString($expected), $result); } public static function provideBeforeAfterLast() { return [ - ['', '', 'hello world', true], - ['', '', 'hello world', false], - ['', 'L', 'hello world', true], - ['', 'L', 'hello world', false], - ['hello wor', 'l', 'hello world', true], - ['ld', 'l', 'hello world', false], - ['hello w', 'o', 'hello world', true], - ['orld', 'o', 'hello world', false], + ['', '', 'hello world', 0, true], + ['', '', 'hello world', 0, false], + ['', 'L', 'hello world', 0, true], + ['', 'L', 'hello world', 0, false], + ['', 'o', 'hello world', 10, true], + ['', 'o', 'hello world', 10, false], + ['hello wor', 'l', 'hello world', 0, true], + ['ld', 'l', 'hello world', 0, false], + ['hello w', 'o', 'hello world', 0, true], + ['orld', 'o', 'hello world', 0, false], + ['abacab', 'ab', 'abacabab', 1, true], + ['ab', 'ab', 'abacabab', 1, false], ]; } /** * @dataProvider provideBeforeAfterLastIgnoreCase */ - public function testBeforeAfterLastIgnoreCase(string $expected, string $needle, string $origin, bool $before) + public function testBeforeAfterLastIgnoreCase(string $expected, string $needle, string $origin, int $offset, bool $before) { $result = static::createFromString($origin)->ignoreCase(); - $result = $before ? $result->beforeLast($needle, false) : $result->afterLast($needle, true); + $result = $before ? $result->beforeLast($needle, false, $offset) : $result->afterLast($needle, true, $offset); $this->assertEquals(static::createFromString($expected), $result); } public static function provideBeforeAfterLastIgnoreCase() { return [ - ['', '', 'hello world', true], - ['', '', 'hello world', false], - ['', 'FOO', 'hello world', true], - ['', 'FOO', 'hello world', false], - ['hello wor', 'l', 'hello world', true], - ['ld', 'l', 'hello world', false], - ['hello wor', 'L', 'hello world', true], - ['ld', 'L', 'hello world', false], - ['hello w', 'O', 'hello world', true], - ['orld', 'O', 'hello world', false], + ['', '', 'hello world', 0, true], + ['', '', 'hello world', 0, false], + ['', 'FOO', 'hello world', 0, true], + ['', 'FOO', 'hello world', 0, false], + ['', 'o', 'hello world', 10, true], + ['', 'o', 'hello world', 10, false], + ['hello wor', 'l', 'hello world', 0, true], + ['ld', 'l', 'hello world', 0, false], + ['hello wor', 'L', 'hello world', 0, true], + ['ld', 'L', 'hello world', 0, false], + ['hello w', 'O', 'hello world', 0, true], + ['orld', 'O', 'hello world', 0, false], + ['AbacaB', 'Ab', 'AbacaBaB', 1, true], + ['aB', 'Ab', 'AbacaBaB', 1, false], ]; } @@ -729,4 +808,480 @@ public static function provideReplaceIgnoreCase() ['heMMo worMd', 3, 'hello world', 'L', 'M'], ]; } + + /** + * @dataProvider provideCamel + */ + public function testCamel(string $expectedString, string $origin) + { + $instance = static::createFromString($origin)->camel(); + + $this->assertEquals(static::createFromString($expectedString), $instance); + } + + public static function provideCamel() + { + return [ + ['', ''], + ['symfonyIsGreat', 'symfony_is_great'], + ['symfony5IsGreat', 'symfony_5_is_great'], + ['symfonyIsGreat', 'Symfony is great'], + ['symfonyIsAGreatFramework', 'Symfony is a great framework'], + ['symfonyIsGREAT', '*Symfony* is GREAT!!'], + ]; + } + + /** + * @dataProvider provideSnake + */ + public function testSnake(string $expectedString, string $origin) + { + $instance = static::createFromString($origin)->snake(); + + $this->assertEquals(static::createFromString($expectedString), $instance); + } + + public static function provideSnake() + { + return [ + ['', ''], + ['symfony_is_great', 'symfonyIsGreat'], + ['symfony5_is_great', 'symfony5IsGreat'], + ['symfony5is_great', 'symfony5isGreat'], + ['symfony_is_great', 'Symfony is great'], + ['symfony_is_a_great_framework', 'symfonyIsAGreatFramework'], + ['symfony_is_great', 'symfonyIsGREAT'], + ['symfony_is_really_great', 'symfonyIsREALLYGreat'], + ]; + } + + /** + * @dataProvider provideStartsWith + */ + public function testStartsWith(bool $expected, string $origin, $prefix) + { + $this->assertSame($expected, static::createFromString($origin)->startsWith($prefix)); + } + + public static function provideStartsWith() + { + return [ + [false, '', ''], + [false, '', 'foo'], + [false, 'foo', ''], + [false, 'foo', 'o'], + [false, 'foo', 'F'], + [false, "\nfoo", 'f'], + [true, 'foo', 'f'], + [true, 'foo', 'fo'], + [true, 'foo', new BinaryString('f')], + [true, 'foo', new Utf8String('f')], + [true, 'foo', new GraphemeString('f')], + [true, 'foo', ['e', 'f', 'g']], + ]; + } + + /** + * @dataProvider provideStartsWithIgnoreCase + */ + public function testStartsWithIgnoreCase(bool $expected, string $origin, $prefix) + { + $this->assertSame($expected, static::createFromString($origin)->ignoreCase()->startsWith($prefix)); + } + + public static function provideStartsWithIgnoreCase() + { + return [ + [false, '', ''], + [false, '', 'foo'], + [false, 'foo', ''], + [false, 'foo', 'o'], + [false, "\nfoo", 'f'], + [true, 'foo', 'F'], + [true, 'FoO', 'foo'], + [true, 'foo', new BinaryString('F')], + [true, 'foo', new Utf8String('F')], + [true, 'foo', new GraphemeString('F')], + [true, 'foo', ['E', 'F', 'G']], + ]; + } + + /** + * @dataProvider provideEndsWith + */ + public function testEndsWith(bool $expected, string $origin, $suffix) + { + $this->assertSame($expected, static::createFromString($origin)->endsWith($suffix)); + } + + public static function provideEndsWith() + { + return [ + [false, '', ''], + [false, '', 'foo'], + [false, 'foo', ''], + [false, 'foo', 'f'], + [false, 'foo', 'O'], + [false, "foo\n", 'o'], + [true, 'foo', 'o'], + [true, 'foo', 'foo'], + [true, 'foo', new BinaryString('o')], + [true, 'foo', new Utf8String('o')], + [true, 'foo', new GraphemeString('o')], + [true, 'foo', ['a', 'o', 'u']], + ]; + } + + /** + * @dataProvider provideEndsWithIgnoreCase + */ + public function testEndsWithIgnoreCase(bool $expected, string $origin, $suffix) + { + $this->assertSame($expected, static::createFromString($origin)->ignoreCase()->endsWith($suffix)); + } + + public static function provideEndsWithIgnoreCase() + { + return [ + [false, '', ''], + [false, '', 'foo'], + [false, 'foo', ''], + [false, 'foo', 'f'], + [false, "foo\n", 'o'], + [true, 'foo', 'O'], + [true, 'Foo', 'foo'], + [true, 'foo', new BinaryString('O')], + [true, 'foo', new Utf8String('O')], + [true, 'foo', new GraphemeString('O')], + [true, 'foo', ['A', 'O', 'U']], + ]; + } + + /** + * @dataProvider provideEnsureStart + */ + public function testEnsureStart(string $expectedString, string $origin, $prefix) + { + $instance = static::createFromString($origin)->ensureStart($prefix); + + $this->assertEquals(static::createFromString($expectedString), $instance); + } + + public static function provideEnsureStart() + { + return [ + ['', '', ''], + ['foo', 'foo', ''], + ['foo', '', 'foo'], + ['foo', 'foo', 'foo'], + ['foobar', 'foobar', 'foo'], + ['foobar', 'bar', 'foo'], + ['foo', 'foofoofoo', 'foo'], + ['foobar', 'foofoofoobar', 'foo'], + ['fooFoobar', 'Foobar', 'foo'], + ["foo\nfoo", "\nfoo", 'foo'], + ]; + } + + /** + * @dataProvider provideEnsureStartIgnoreCase + */ + public function testEnsureStartIgnoreCase(string $expectedString, string $origin, $prefix) + { + $instance = static::createFromString($origin)->ignoreCase()->ensureStart($prefix); + + $this->assertEquals(static::createFromString($expectedString), $instance); + } + + public static function provideEnsureStartIgnoreCase() + { + return [ + ['', '', ''], + ['foo', 'foo', ''], + ['foo', '', 'foo'], + ['Foo', 'Foo', 'foo'], + ['Foobar', 'Foobar', 'foo'], + ['foobar', 'bar', 'foo'], + ['Foo', 'fOofoOFoo', 'foo'], + ['Foobar', 'fOofoOFoobar', 'foo'], + ["foo\nfoo", "\nfoo", 'foo'], + ]; + } + + /** + * @dataProvider provideEnsureEnd + */ + public function testEnsureEnd(string $expectedString, string $origin, $suffix) + { + $instance = static::createFromString($origin)->ensureEnd($suffix); + + $this->assertEquals(static::createFromString($expectedString), $instance); + } + + public static function provideEnsureEnd() + { + return [ + ['', '', ''], + ['foo', 'foo', ''], + ['foo', '', 'foo'], + ['foo', 'foo', 'foo'], + ['foobar', 'foobar', 'bar'], + ['foobar', 'foo', 'bar'], + ['foo', 'foofoofoo', 'foo'], + ['foobar', 'foobarbarbar', 'bar'], + ['fooBarbar', 'fooBar', 'bar'], + ["foo\nfoo", "foo\n", 'foo'], + ]; + } + + /** + * @dataProvider provideEnsureEndIgnoreCase + */ + public function testEnsureEndIgnoreCase(string $expectedString, string $origin, $suffix) + { + $instance = static::createFromString($origin)->ignoreCase()->ensureEnd($suffix); + + $this->assertEquals(static::createFromString($expectedString), $instance); + } + + public static function provideEnsureEndIgnoreCase() + { + return [ + ['', '', ''], + ['foo', 'foo', ''], + ['foo', '', 'foo'], + ['foo', 'foo', 'foo'], + ['fooBar', 'fooBar', 'bar'], + ['foobar', 'foo', 'bar'], + ['fOo', 'fOofoOFoo', 'foo'], + ['fooBar', 'fooBarbArbaR', 'bar'], + ["foo\nfoo", "foo\n", 'foo'], + ]; + } + + /** + * @dataProvider provideCollapseWhitespace + */ + public function testCollapseWhitespace(string $expectedString, string $origin) + { + $instance = static::createFromString($origin)->collapseWhitespace(); + + $this->assertEquals(static::createFromString($expectedString), $instance); + } + + public static function provideCollapseWhitespace() + { + return [ + ['', ''], + ['', " \t\r\n"], + ['foo bar', 'foo bar'], + ['foo bar baz', ' foo bar baz'], + ['foo bar baz', " foo\nbar \t baz\n"], + ]; + } + + /** + * @dataProvider provideEqualsTo + */ + public function testEqualsTo(bool $expected, string $origin, $other) + { + $this->assertSame($expected, static::createFromString($origin)->equalsTo($other)); + } + + public static function provideEqualsTo() + { + return [ + [true, '', ''], + [false, '', 'foo'], + [false, 'foo', ''], + [false, 'foo', 'Foo'], + [false, "foo\n", 'foo'], + [true, 'Foo bar', 'Foo bar'], + [true, 'Foo bar', new BinaryString('Foo bar')], + [true, 'Foo bar', new Utf8String('Foo bar')], + [true, 'Foo bar', new GraphemeString('Foo bar')], + [false, '', []], + [false, 'foo', ['bar', 'baz']], + [true, 'foo', ['bar', 'foo', 'baz']], + ]; + } + + /** + * @dataProvider provideEqualsToIgnoreCase + */ + public function testEqualsToIgnoreCase(bool $expected, string $origin, $other) + { + $this->assertSame($expected, static::createFromString($origin)->ignoreCase()->equalsTo($other)); + } + + public static function provideEqualsToIgnoreCase() + { + return [ + [true, '', ''], + [false, '', 'foo'], + [false, 'foo', ''], + [false, "foo\n", 'foo'], + [true, 'foo Bar', 'FOO bar'], + [true, 'foo Bar', new BinaryString('FOO bar')], + [true, 'foo Bar', new Utf8String('FOO bar')], + [true, 'foo Bar', new GraphemeString('FOO bar')], + [false, '', []], + [false, 'Foo', ['bar', 'baz']], + [true, 'Foo', ['bar', 'foo', 'baz']], + ]; + } + + /** + * @dataProvider provideIsEmpty + */ + public function testIsEmpty(bool $expected, string $origin) + { + $this->assertSame($expected, static::createFromString($origin)->isEmpty()); + } + + public static function provideIsEmpty() + { + return [ + [true, ''], + [false, ' '], + [false, "\n"], + [false, 'Foo bar'], + ]; + } + + /** + * @dataProvider provideJoin + */ + public function testJoin(string $expected, string $origin, array $join) + { + $instance = static::createFromString($origin)->join($join); + + $this->assertEquals(static::createFromString($expected), $instance); + } + + public static function provideJoin() + { + return [ + ['', '', []], + ['', ',', []], + ['foo', ',', ['foo']], + ['foobar', '', ['foo', 'bar']], + ['foo, bar', ', ', ['foo', 'bar']], + ]; + } + + /** + * @dataProvider provideRepeat + */ + public function testRepeat(string $expected, string $origin, int $multiplier) + { + $instance = static::createFromString($origin)->repeat($multiplier); + + $this->assertEquals(static::createFromString($expected), $instance); + } + + public static function provideRepeat() + { + return [ + ['', '', 0], + ['', '', 5], + ['', 'foo', 0], + ['foo', 'foo', 1], + ['foofoofoo', 'foo', 3], + ]; + } + + /** + * @dataProvider providePadBoth + */ + public function testPadBoth(string $expected, string $origin, int $length, string $padStr) + { + $instance = static::createFromString($origin)->padBoth($length, $padStr); + + $this->assertEquals(static::createFromString($expected), $instance); + } + + public static function providePadBoth() + { + return [ + ['', '', 0, '_'], + ['###', '', 3, '#'], + ['foo', 'foo', 2, '#'], + ['foo', 'foo', 3, '#'], + ['foo#', 'foo', 4, '#'], + ['#foo#', 'foo', 5, '#'], + ['##foo###', 'foo', 8, '#'], + ['#+#foo#+#', 'foo', 9, '#+'], + ]; + } + + /** + * @dataProvider providePadEnd + */ + public function testPadEnd(string $expected, string $origin, int $length, string $padStr) + { + $instance = static::createFromString($origin)->padEnd($length, $padStr); + + $this->assertEquals(static::createFromString($expected), $instance); + } + + public static function providePadEnd() + { + return [ + ['', '', 0, '_'], + ['###', '', 3, '#'], + ['foo', 'foo', 2, '#'], + ['foo', 'foo', 3, '#'], + ['foo#', 'foo', 4, '#'], + ['foo###', 'foo', 6, '#'], + ['foo#+#', 'foo', 6, '#+'], + ]; + } + + /** + * @dataProvider providePadStart + */ + public function testPadStart(string $expected, string $origin, int $length, string $padStr) + { + $instance = static::createFromString($origin)->padStart($length, $padStr); + + $this->assertEquals(static::createFromString($expected), $instance); + } + + public static function providePadStart() + { + return [ + ['', '', 0, '_'], + ['###', '', 3, '#'], + ['foo', 'foo', 2, '#'], + ['foo', 'foo', 3, '#'], + ['#foo', 'foo', 4, '#'], + ['###foo', 'foo', 6, '#'], + ['#+#foo', 'foo', 6, '#+'], + ]; + } + + /** + * @dataProvider provideTruncate + */ + public function testTruncate(string $expected, string $origin, int $length, string $ellipsis) + { + $instance = static::createFromString($origin)->truncate($length, $ellipsis); + + $this->assertEquals(static::createFromString($expected), $instance); + } + + public static function provideTruncate() + { + return [ + ['', '', 3, ''], + ['', 'foo', 0, '...'], + ['fo', 'foobar', 2, ''], + ['foobar', 'foobar', 10, ''], + ['foo', 'foo', 3, '...'], + ['fo', 'foobar', 2, '...'], + ['...', 'foobar', 3, '...'], + ['fo...', 'foobar', 5, '...'], + ]; + } } diff --git a/src/Symfony/Component/String/Tests/AbstractUtf8TestCase.php b/src/Symfony/Component/String/Tests/AbstractUtf8TestCase.php index d2216d5a561f6..9f63161ce01aa 100644 --- a/src/Symfony/Component/String/Tests/AbstractUtf8TestCase.php +++ b/src/Symfony/Component/String/Tests/AbstractUtf8TestCase.php @@ -226,6 +226,9 @@ public static function provideUpper(): array ['GARÇON', 'GARÇON'], ["ŒUVRE D'ART", "Œuvre d'Art"], + // German + ['ÄUSSERST', 'äußerst'], + // Spanish ['EL NIÑO', 'El Niño'], @@ -324,8 +327,8 @@ public static function provideBeforeAfter(): array return array_merge( parent::provideBeforeAfter(), [ - ['jàdéjà', 'jà', 'déjàdéjà', false], - ['dé', 'jà', 'déjàdéjà', true], + ['jàdéjà', 'jà', 'déjàdéjà', 0, false], + ['dé', 'jà', 'déjàdéjà', 0, true], ] ); } @@ -335,12 +338,12 @@ public static function provideBeforeAfterIgnoreCase(): array return array_merge( parent::provideBeforeAfterIgnoreCase(), [ - ['jàdéjà', 'JÀ', 'déjàdéjà', false], - ['dé', 'jÀ', 'déjàdéjà', true], - ['éjàdéjà', 'é', 'déjàdéjà', false], - ['d', 'é', 'déjàdéjà', true], - ['', 'Ç', 'déjàdéjà', false], - ['', 'Ç', 'déjàdéjà', true], + ['jàdéjà', 'JÀ', 'déjàdéjà', 0, false], + ['dé', 'jÀ', 'déjàdéjà', 0, true], + ['éjàdéjà', 'é', 'déjàdéjà', 0, false], + ['d', 'é', 'déjàdéjà', 0, true], + ['', 'Ç', 'déjàdéjà', 0, false], + ['', 'Ç', 'déjàdéjà', 0, true], ] ); } @@ -350,10 +353,10 @@ public static function provideBeforeAfterLast(): array return array_merge( parent::provideBeforeAfterLast(), [ - ['', 'Ç', 'déjàdéjà', false], - ['', 'Ç', 'déjàdéjà', true], - ['éjà', 'é', 'déjàdéjà', false], - ['déjàd', 'é', 'déjàdéjà', true], + ['', 'Ç', 'déjàdéjà', 0, false], + ['', 'Ç', 'déjàdéjà', 0, true], + ['éjà', 'é', 'déjàdéjà', 0, false], + ['déjàd', 'é', 'déjàdéjà', 0, true], ] ); } @@ -363,9 +366,9 @@ public static function provideBeforeAfterLastIgnoreCase(): array return array_merge( parent::provideBeforeAfterLastIgnoreCase(), [ - ['', 'Ç', 'déjàdéjà', false], - ['éjà', 'é', 'déjàdéjà', false], - ['éjà', 'É', 'déjàdéjà', false], + ['', 'Ç', 'déjàdéjà', 0, false], + ['éjà', 'é', 'déjàdéjà', 0, false], + ['éjà', 'É', 'déjàdéjà', 0, false], ] ); } @@ -422,4 +425,83 @@ public function testReplaceWithInvalidUtf8PatternReplacement() static::createFromString('Symfony')->replace('f', "\xE9"); } + + public static function provideCamel() + { + return array_merge( + parent::provideCamel(), + [ + ['symfonyIstÄußerstCool', 'symfony_ist_äußerst_cool'], + ] + ); + } + + public static function provideSnake() + { + return array_merge( + parent::provideSnake(), + [ + ['symfony_ist_äußerst_cool', 'symfonyIstÄußerstCool'], + ] + ); + } + + public static function provideEqualsTo() + { + return array_merge( + parent::provideEqualsTo(), + [ + [true, 'äußerst', 'äußerst'], + [false, 'BÄR', 'bär'], + [false, 'Bär', 'Bar'], + ] + ); + } + + public static function provideEqualsToIgnoreCase() + { + return array_merge( + parent::provideEqualsToIgnoreCase(), + [ + [true, 'Äußerst', 'äußerst'], + [false, 'Bär', 'Bar'], + ] + ); + } + + public static function providePadBoth(): array + { + return array_merge( + parent::providePadBoth(), + [ + ['äußerst', 'äußerst', 7, '+'], + ['+äußerst+', 'äußerst', 9, '+'], + ['äö.äöä', '.', 6, 'äö'], + ] + ); + } + + public static function providePadEnd(): array + { + return array_merge( + parent::providePadEnd(), + [ + ['äußerst', 'äußerst', 7, '+'], + ['äußerst+', 'äußerst', 8, '+'], + ['.äöä', '.', 4, 'äö'], + ] + ); + } + + public static function providePadStart(): array + { + return array_merge( + parent::providePadStart(), + [ + ['äußerst', 'äußerst', 7, '+'], + ['+äußerst', 'äußerst', 8, '+'], + ['äöä.', '.', 4, 'äö'], + ] + ); + } } diff --git a/src/Symfony/Component/String/Tests/BinaryStringTest.php b/src/Symfony/Component/String/Tests/BinaryStringTest.php index 3605b5db2c1b6..9b92632484db1 100644 --- a/src/Symfony/Component/String/Tests/BinaryStringTest.php +++ b/src/Symfony/Component/String/Tests/BinaryStringTest.php @@ -20,4 +20,14 @@ protected static function createFromString(string $string): AbstractString { return new BinaryString($string); } + + public static function provideLength(): array + { + return array_merge( + parent::provideLength(), + [ + [2, 'ä'], + ] + ); + } } diff --git a/src/Symfony/Component/String/Tests/GraphemeStringTest.php b/src/Symfony/Component/String/Tests/GraphemeStringTest.php index 18775de077859..7148d8b53f74b 100644 --- a/src/Symfony/Component/String/Tests/GraphemeStringTest.php +++ b/src/Symfony/Component/String/Tests/GraphemeStringTest.php @@ -134,8 +134,8 @@ public static function provideBeforeAfter(): array return array_merge( parent::provideBeforeAfter(), [ - ['द foo अनुच्छेद', 'द', 'अनुच्छेद foo अनुच्छेद', false], - ['अनुच्छे', 'द', 'अनुच्छेद foo अनुच्छेद', true], + ['द foo अनुच्छेद', 'द', 'अनुच्छेद foo अनुच्छेद', 0, false], + ['अनुच्छे', 'द', 'अनुच्छेद foo अनुच्छेद', 0, true], ] ); } @@ -145,10 +145,10 @@ public static function provideBeforeAfterIgnoreCase(): array return array_merge( parent::provideBeforeAfterIgnoreCase(), [ - ['', 'छेछे', 'दछेच्नुअ', false], - ['', 'छेछे', 'दछेच्नुअ', true], - ['छेच्नुअ', 'छे', 'दछेच्नुअ', false], - ['द', 'छे', 'दछेच्नुअ', true], + ['', 'छेछे', 'दछेच्नुअ', 0, false], + ['', 'छेछे', 'दछेच्नुअ', 0, true], + ['छेच्नुअ', 'छे', 'दछेच्नुअ', 0, false], + ['द', 'छे', 'दछेच्नुअ', 0, true], ] ); } @@ -158,10 +158,10 @@ public static function provideBeforeAfterLast(): array return array_merge( parent::provideBeforeAfterLast(), [ - ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', false], - ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', true], - ['-दछेच्नु', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', false], - ['दछेच्नुअ-दछेच्नु-अद', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', true], + ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 0, false], + ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 0, true], + ['-दछेच्नु', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', 0, false], + ['दछेच्नुअ-दछेच्नु-अद', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', 0, true], ] ); } @@ -171,10 +171,10 @@ public static function provideBeforeAfterLastIgnoreCase(): array return array_merge( parent::provideBeforeAfterLastIgnoreCase(), [ - ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', false], - ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', true], - ['-दछेच्नु', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', false], - ['दछेच्नुअ-दछेच्नु-अद', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', true], + ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 0, false], + ['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 0, true], + ['-दछेच्नु', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', 0, false], + ['दछेच्नुअ-दछेच्नु-अद', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', 0, true], ] ); } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy