feature #33553 [String] a new component for object-oriented strings management with an abstract unit system (nicolas-grekas, hhamon, gharlan)

This PR was merged into the 5.0-dev branch.

Discussion
----------

[String] a new component for object-oriented strings management with an abstract unit system

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | -
| License       | MIT
| Doc PR        | -

This is a reboot of #22184 (thanks @hhamon for working on it) and a generalization of my previous work on the topic ([patchwork/utf8](https://github.com/tchwork/utf8)). Unlike existing libraries (including `patchwork/utf8`), this component provides a unified API for the 3 unit systems of strings: bytes, code points and grapheme clusters.

The unified API is defined by the `AbstractString` class. It has 2 direct child classes: `BinaryString` and `AbstractUnicodeString`, itself extended by `Utf8String` and `GraphemeString`.

All objects are immutable and provide clear edge-case semantics, using exceptions and/or (nullable) types!

Two helper functions are provided to create such strings:
```php
new GraphemeString('foo') == u('foo'); // when dealing with Unicode, prefer grapheme units
new BinaryString('foo') == b('foo');
```

`GraphemeString` is the most linguistic-friendly variant of them, which means it's the one ppl should use most of the time *when dealing with written text*.

Future ideas:
 - improve tests
 - add more docblocks (only where they'd add value!)
 - consider adding more methods in the string API (`is*()?`, `*Encode()`?, etc.)
 - first class Emoji support
 - merge the Inflector component into this one
 - use `width()` to improve `truncate()` and `wordwrap()`
 - move method `slug()` to a dedicated locale-aware service class
 - propose your ideas (send PRs after merge)

Out of (current) scope:
 - what [intl](https://php.net/intl) provides (collations, transliterations, confusables, segmentation, etc)

Here is the unified API I'm proposing in this PR, borrowed from looking at many existing libraries, but also Java, Python, JavaScript and Go.

```php
function __construct(string $string = '');
static function unwrap(array $values): array
static function wrap(array $values): array
function after($needle, bool $includeNeedle = false, int $offset = 0): self;
function afterLast($needle, bool $includeNeedle = false, int $offset = 0): self;
function append(string ...$suffix): self;
function before($needle, bool $includeNeedle = false, int $offset = 0): self;
function beforeLast($needle, bool $includeNeedle = false, int $offset = 0): self;
function camel(): self;
function chunk(int $length = 1): array;
function collapseWhitespace(): self
function endsWith($suffix): bool;
function ensureEnd(string $suffix): self;
function ensureStart(string $prefix): self;
function equalsTo($string): bool;
function folded(): self;
function ignoreCase(): self;
function indexOf($needle, int $offset = 0): ?int;
function indexOfLast($needle, int $offset = 0): ?int;
function isEmpty(): bool;
function join(array $strings): self;
function jsonSerialize(): string;
function length(): int;
function lower(): self;
function match(string $pattern, int $flags = 0, int $offset = 0): array;
function padBoth(int $length, string $padStr = ' '): self;
function padEnd(int $length, string $padStr = ' '): self;
function padStart(int $length, string $padStr = ' '): self;
function prepend(string ...$prefix): self;
function repeat(int $multiplier): self;
function replace(string $from, string $to): self;
function replaceMatches(string $fromPattern, $to): self;
function slice(int $start = 0, int $length = null): self;
function snake(): self;
function splice(string $replacement, int $start = 0, int $length = null): self;
function split(string $delimiter, int $limit = null, int $flags = null): array;
function startsWith($prefix): bool;
function title(bool $allWords = false): self;
function toBinary(string $toEncoding = null): BinaryString;
function toGrapheme(): GraphemeString;
function toUtf8(): Utf8String;
function trim(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): self;
function trimEnd(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): self;
function trimStart(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): self;
function truncate(int $length, string $ellipsis = ''): self;
function upper(): self;
function width(bool $ignoreAnsiDecoration = true): int;
function wordwrap(int $width = 75, string $break = "\n", bool $cut = false): self;
function __clone();
function __toString(): string;
```

`AbstractUnicodeString` adds these:
```php
static function fromCodePoints(int ...$codes): self;
function ascii(array $rules = []): self;
function codePoint(int $index = 0): ?int;
function folded(bool $compat = true): parent;
function normalize(int $form = self::NFC): self;
function slug(string $separator = '-'): self;
```

and `BinaryString`:
```php
static function fromRandom(int $length = 16): self;
function byteCode(int $index = 0): ?int;
function isUtf8(): bool;
function toUtf8(string $fromEncoding = null): Utf8String;
function toGrapheme(string $fromEncoding = null): GraphemeString;
```

Case insensitive operations are done with the `ignoreCase()` method.
e.g. `b('abc')->ignoreCase()->indexOf('B')` will return `1`.

For reference, CLDR transliterations (used in the `ascii()` method) are defined here:
https://github.com/unicode-org/cldr/tree/master/common/transforms

Commits
-------

dd8745aced [String] add more tests
82a00956bc [String] add tests
012e92a772 [String] a new component for object-oriented strings management with an abstract unit system
This commit is contained in:
Fabien Potencier 2019-09-26 10:14:27 +02:00
commit 5d154fb304
23 changed files with 4517 additions and 2 deletions

View File

@ -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"

View File

@ -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": [
{

View File

@ -0,0 +1,2 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore

View File

@ -0,0 +1,3 @@
composer.lock
phpunit.xml
vendor/

View File

@ -0,0 +1,689 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
* @author Hugo Hamon <hugohamon@neuf.fr>
*
* @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;
}
}

View File

@ -0,0 +1,472 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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.');
}
}
}

View File

@ -0,0 +1,475 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
* @author Hugo Hamon <hugohamon@neuf.fr>
*
* @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;
}
}

View File

@ -0,0 +1,7 @@
CHANGELOG
=========
5.0.0
-----
* added the component as experimental

View File

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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
{
}

View File

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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
{
}

View File

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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
{
}

View File

@ -0,0 +1,346 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
* @author Hugo Hamon <hugohamon@neuf.fr>
*
* @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;
}
}

View File

@ -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.

View File

@ -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)

View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,507 @@
<?php
namespace Symfony\Component\String\Tests;
use Symfony\Component\String\Exception\InvalidArgumentException;
abstract class AbstractUtf8TestCase extends AbstractAsciiTestCase
{
public function testCreateFromStringWithInvalidUtf8Input()
{
$this->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"],
// German
['ÄUSSERST', 'äußerst'],
// 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à', 0, false],
['dé', 'jà', 'déjàdéjà', 0, true],
]
);
}
public static function provideBeforeAfterIgnoreCase(): array
{
return array_merge(
parent::provideBeforeAfterIgnoreCase(),
[
['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],
]
);
}
public static function provideBeforeAfterLast(): array
{
return array_merge(
parent::provideBeforeAfterLast(),
[
['', 'Ç', '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],
]
);
}
public static function provideBeforeAfterLastIgnoreCase(): array
{
return array_merge(
parent::provideBeforeAfterLastIgnoreCase(),
[
['', 'Ç', 'déjàdéjà', 0, false],
['éjà', 'é', 'déjàdéjà', 0, false],
['éjà', 'É', 'déjàdéjà', 0, 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");
}
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, 'äö'],
]
);
}
}

View File

@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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);
}
public static function provideLength(): array
{
return array_merge(
parent::provideLength(),
[
[2, 'ä'],
]
);
}
}

View File

@ -0,0 +1,203 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 अनुच्छेद', 0, false],
['अनुच्छे', 'द', 'अनुच्छेद foo अनुच्छेद', 0, true],
]
);
}
public static function provideBeforeAfterIgnoreCase(): array
{
return array_merge(
parent::provideBeforeAfterIgnoreCase(),
[
['', 'छेछे', 'दछेच्नुअ', 0, false],
['', 'छेछे', 'दछेच्नुअ', 0, true],
['छेच्नुअ', 'छे', 'दछेच्नुअ', 0, false],
['द', 'छे', 'दछेच्नुअ', 0, true],
]
);
}
public static function provideBeforeAfterLast(): array
{
return array_merge(
parent::provideBeforeAfterLast(),
[
['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 0, false],
['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 0, true],
['-दछेच्नु', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', 0, false],
['दछेच्नुअ-दछेच्नु-अद', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', 0, true],
]
);
}
public static function provideBeforeAfterLastIgnoreCase(): array
{
return array_merge(
parent::provideBeforeAfterLastIgnoreCase(),
[
['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 0, false],
['', 'छेछे', 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 0, true],
['-दछेच्नु', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', 0, false],
['दछेच्नुअ-दछेच्नु-अद', '-द', 'दछेच्नुअ-दछेच्नु-अद-दछेच्नु', 0, 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, 'दछेच्नुअ-दछेच्नु-अदछेच्नु', 'अ', 'द'],
]
);
}
}

View File

@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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, 'अनुच्छेद'],
]
);
}
}

View File

@ -0,0 +1,261 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
* @author Hugo Hamon <hugohamon@neuf.fr>
*
* @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));
}
}

View File

@ -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"
}
}
}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony String Component Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./</directory>
<exclude>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</whitelist>
</filter>
</phpunit>