ByteString.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\String;
  11. use Random\Randomizer;
  12. use Symfony\Component\String\Exception\ExceptionInterface;
  13. use Symfony\Component\String\Exception\InvalidArgumentException;
  14. use Symfony\Component\String\Exception\RuntimeException;
  15. /**
  16. * Represents a binary-safe string of bytes.
  17. *
  18. * @author Nicolas Grekas <p@tchwork.com>
  19. * @author Hugo Hamon <hugohamon@neuf.fr>
  20. *
  21. * @throws ExceptionInterface
  22. */
  23. class ByteString extends AbstractString
  24. {
  25. private const ALPHABET_ALPHANUMERIC = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
  26. public function __construct(string $string = '')
  27. {
  28. $this->string = $string;
  29. }
  30. /*
  31. * The following method was derived from code of the Hack Standard Library (v4.40 - 2020-05-03)
  32. *
  33. * https://github.com/hhvm/hsl/blob/80a42c02f036f72a42f0415e80d6b847f4bf62d5/src/random/private.php#L16
  34. *
  35. * Code subject to the MIT license (https://github.com/hhvm/hsl/blob/master/LICENSE).
  36. *
  37. * Copyright (c) 2004-2020, Facebook, Inc. (https://www.facebook.com/)
  38. */
  39. public static function fromRandom(int $length = 16, ?string $alphabet = null): self
  40. {
  41. if ($length <= 0) {
  42. throw new InvalidArgumentException(\sprintf('A strictly positive length is expected, "%d" given.', $length));
  43. }
  44. $alphabet ??= self::ALPHABET_ALPHANUMERIC;
  45. $alphabetSize = \strlen($alphabet);
  46. $bits = (int) ceil(log($alphabetSize, 2.0));
  47. if ($bits <= 0 || $bits > 56) {
  48. throw new InvalidArgumentException('The length of the alphabet must in the [2^1, 2^56] range.');
  49. }
  50. return new static((new Randomizer())->getBytesFromString($alphabet, $length));
  51. }
  52. public function bytesAt(int $offset): array
  53. {
  54. $str = $this->string[$offset] ?? '';
  55. return '' === $str ? [] : [\ord($str)];
  56. }
  57. public function append(string ...$suffix): static
  58. {
  59. $str = clone $this;
  60. $str->string .= 1 >= \count($suffix) ? ($suffix[0] ?? '') : implode('', $suffix);
  61. return $str;
  62. }
  63. public function camel(): static
  64. {
  65. $str = clone $this;
  66. $parts = explode(' ', trim(ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $this->string))));
  67. $parts[0] = 1 !== \strlen($parts[0]) && ctype_upper($parts[0]) ? $parts[0] : lcfirst($parts[0]);
  68. $str->string = implode('', $parts);
  69. return $str;
  70. }
  71. public function chunk(int $length = 1): array
  72. {
  73. if (1 > $length) {
  74. throw new InvalidArgumentException('The chunk length must be greater than zero.');
  75. }
  76. if ('' === $this->string) {
  77. return [];
  78. }
  79. $str = clone $this;
  80. $chunks = [];
  81. foreach (str_split($this->string, $length) as $chunk) {
  82. $str->string = $chunk;
  83. $chunks[] = clone $str;
  84. }
  85. return $chunks;
  86. }
  87. public function endsWith(string|iterable|AbstractString $suffix): bool
  88. {
  89. if ($suffix instanceof AbstractString) {
  90. $suffix = $suffix->string;
  91. } elseif (!\is_string($suffix)) {
  92. return parent::endsWith($suffix);
  93. }
  94. return '' !== $suffix && \strlen($this->string) >= \strlen($suffix) && 0 === substr_compare($this->string, $suffix, -\strlen($suffix), null, $this->ignoreCase);
  95. }
  96. public function equalsTo(string|iterable|AbstractString $string): bool
  97. {
  98. if ($string instanceof AbstractString) {
  99. $string = $string->string;
  100. } elseif (!\is_string($string)) {
  101. return parent::equalsTo($string);
  102. }
  103. if ('' !== $string && $this->ignoreCase) {
  104. return 0 === strcasecmp($string, $this->string);
  105. }
  106. return $string === $this->string;
  107. }
  108. public function folded(): static
  109. {
  110. $str = clone $this;
  111. $str->string = strtolower($str->string);
  112. return $str;
  113. }
  114. public function indexOf(string|iterable|AbstractString $needle, int $offset = 0): ?int
  115. {
  116. if ($needle instanceof AbstractString) {
  117. $needle = $needle->string;
  118. } elseif (!\is_string($needle)) {
  119. return parent::indexOf($needle, $offset);
  120. }
  121. if ('' === $needle) {
  122. return null;
  123. }
  124. $i = $this->ignoreCase ? stripos($this->string, $needle, $offset) : strpos($this->string, $needle, $offset);
  125. return false === $i ? null : $i;
  126. }
  127. public function indexOfLast(string|iterable|AbstractString $needle, int $offset = 0): ?int
  128. {
  129. if ($needle instanceof AbstractString) {
  130. $needle = $needle->string;
  131. } elseif (!\is_string($needle)) {
  132. return parent::indexOfLast($needle, $offset);
  133. }
  134. if ('' === $needle) {
  135. return null;
  136. }
  137. $i = $this->ignoreCase ? strripos($this->string, $needle, $offset) : strrpos($this->string, $needle, $offset);
  138. return false === $i ? null : $i;
  139. }
  140. public function isUtf8(): bool
  141. {
  142. return '' === $this->string || preg_match('//u', $this->string);
  143. }
  144. public function join(array $strings, ?string $lastGlue = null): static
  145. {
  146. $str = clone $this;
  147. $tail = null !== $lastGlue && 1 < \count($strings) ? $lastGlue.array_pop($strings) : '';
  148. $str->string = implode($this->string, $strings).$tail;
  149. return $str;
  150. }
  151. public function length(): int
  152. {
  153. return \strlen($this->string);
  154. }
  155. public function lower(): static
  156. {
  157. $str = clone $this;
  158. $str->string = strtolower($str->string);
  159. return $str;
  160. }
  161. public function match(string $regexp, int $flags = 0, int $offset = 0): array
  162. {
  163. $match = ((\PREG_PATTERN_ORDER | \PREG_SET_ORDER) & $flags) ? 'preg_match_all' : 'preg_match';
  164. if ($this->ignoreCase) {
  165. $regexp .= 'i';
  166. }
  167. set_error_handler(static fn ($t, $m) => throw new InvalidArgumentException($m));
  168. try {
  169. if (false === $match($regexp, $this->string, $matches, $flags | \PREG_UNMATCHED_AS_NULL, $offset)) {
  170. throw new RuntimeException('Matching failed with error: '.preg_last_error_msg());
  171. }
  172. } finally {
  173. restore_error_handler();
  174. }
  175. return $matches;
  176. }
  177. public function padBoth(int $length, string $padStr = ' '): static
  178. {
  179. $str = clone $this;
  180. $str->string = str_pad($this->string, $length, $padStr, \STR_PAD_BOTH);
  181. return $str;
  182. }
  183. public function padEnd(int $length, string $padStr = ' '): static
  184. {
  185. $str = clone $this;
  186. $str->string = str_pad($this->string, $length, $padStr, \STR_PAD_RIGHT);
  187. return $str;
  188. }
  189. public function padStart(int $length, string $padStr = ' '): static
  190. {
  191. $str = clone $this;
  192. $str->string = str_pad($this->string, $length, $padStr, \STR_PAD_LEFT);
  193. return $str;
  194. }
  195. public function prepend(string ...$prefix): static
  196. {
  197. $str = clone $this;
  198. $str->string = (1 >= \count($prefix) ? ($prefix[0] ?? '') : implode('', $prefix)).$str->string;
  199. return $str;
  200. }
  201. public function replace(string $from, string $to): static
  202. {
  203. $str = clone $this;
  204. if ('' !== $from) {
  205. $str->string = $this->ignoreCase ? str_ireplace($from, $to, $this->string) : str_replace($from, $to, $this->string);
  206. }
  207. return $str;
  208. }
  209. public function replaceMatches(string $fromRegexp, string|callable $to): static
  210. {
  211. if ($this->ignoreCase) {
  212. $fromRegexp .= 'i';
  213. }
  214. $replace = \is_array($to) || $to instanceof \Closure ? 'preg_replace_callback' : 'preg_replace';
  215. set_error_handler(static fn ($t, $m) => throw new InvalidArgumentException($m));
  216. try {
  217. if (null === $string = $replace($fromRegexp, $to, $this->string)) {
  218. $lastError = preg_last_error();
  219. foreach (get_defined_constants(true)['pcre'] as $k => $v) {
  220. if ($lastError === $v && str_ends_with($k, '_ERROR')) {
  221. throw new RuntimeException('Matching failed with '.$k.'.');
  222. }
  223. }
  224. throw new RuntimeException('Matching failed with unknown error code.');
  225. }
  226. } finally {
  227. restore_error_handler();
  228. }
  229. $str = clone $this;
  230. $str->string = $string;
  231. return $str;
  232. }
  233. public function reverse(): static
  234. {
  235. $str = clone $this;
  236. $str->string = strrev($str->string);
  237. return $str;
  238. }
  239. public function slice(int $start = 0, ?int $length = null): static
  240. {
  241. $str = clone $this;
  242. $str->string = substr($this->string, $start, $length ?? \PHP_INT_MAX);
  243. return $str;
  244. }
  245. public function snake(): static
  246. {
  247. $str = $this->camel();
  248. $str->string = strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1_\2', $str->string));
  249. return $str;
  250. }
  251. public function splice(string $replacement, int $start = 0, ?int $length = null): static
  252. {
  253. $str = clone $this;
  254. $str->string = substr_replace($this->string, $replacement, $start, $length ?? \PHP_INT_MAX);
  255. return $str;
  256. }
  257. public function split(string $delimiter, ?int $limit = null, ?int $flags = null): array
  258. {
  259. if (1 > $limit ??= \PHP_INT_MAX) {
  260. throw new InvalidArgumentException('Split limit must be a positive integer.');
  261. }
  262. if ('' === $delimiter) {
  263. throw new InvalidArgumentException('Split delimiter is empty.');
  264. }
  265. if (null !== $flags) {
  266. return parent::split($delimiter, $limit, $flags);
  267. }
  268. $str = clone $this;
  269. $chunks = $this->ignoreCase
  270. ? preg_split('{'.preg_quote($delimiter).'}iD', $this->string, $limit)
  271. : explode($delimiter, $this->string, $limit);
  272. foreach ($chunks as &$chunk) {
  273. $str->string = $chunk;
  274. $chunk = clone $str;
  275. }
  276. return $chunks;
  277. }
  278. public function startsWith(string|iterable|AbstractString $prefix): bool
  279. {
  280. if ($prefix instanceof AbstractString) {
  281. $prefix = $prefix->string;
  282. } elseif (!\is_string($prefix)) {
  283. return parent::startsWith($prefix);
  284. }
  285. return '' !== $prefix && 0 === ($this->ignoreCase ? strncasecmp($this->string, $prefix, \strlen($prefix)) : strncmp($this->string, $prefix, \strlen($prefix)));
  286. }
  287. public function title(bool $allWords = false): static
  288. {
  289. $str = clone $this;
  290. $str->string = $allWords ? ucwords($str->string) : ucfirst($str->string);
  291. return $str;
  292. }
  293. public function toUnicodeString(?string $fromEncoding = null): UnicodeString
  294. {
  295. return new UnicodeString($this->toCodePointString($fromEncoding)->string);
  296. }
  297. public function toCodePointString(?string $fromEncoding = null): CodePointString
  298. {
  299. $u = new CodePointString();
  300. if (\in_array($fromEncoding, [null, 'utf8', 'utf-8', 'UTF8', 'UTF-8'], true) && preg_match('//u', $this->string)) {
  301. $u->string = $this->string;
  302. return $u;
  303. }
  304. set_error_handler(static fn ($t, $m) => throw new InvalidArgumentException($m));
  305. try {
  306. try {
  307. $validEncoding = false !== mb_detect_encoding($this->string, $fromEncoding ?? 'Windows-1252', true);
  308. } catch (InvalidArgumentException $e) {
  309. if (!\function_exists('iconv')) {
  310. throw $e;
  311. }
  312. $u->string = iconv($fromEncoding ?? 'Windows-1252', 'UTF-8', $this->string);
  313. return $u;
  314. }
  315. } finally {
  316. restore_error_handler();
  317. }
  318. if (!$validEncoding) {
  319. throw new InvalidArgumentException(\sprintf('Invalid "%s" string.', $fromEncoding ?? 'Windows-1252'));
  320. }
  321. $u->string = mb_convert_encoding($this->string, 'UTF-8', $fromEncoding ?? 'Windows-1252');
  322. return $u;
  323. }
  324. public function trim(string $chars = " \t\n\r\0\x0B\x0C"): static
  325. {
  326. $str = clone $this;
  327. $str->string = trim($str->string, $chars);
  328. return $str;
  329. }
  330. public function trimEnd(string $chars = " \t\n\r\0\x0B\x0C"): static
  331. {
  332. $str = clone $this;
  333. $str->string = rtrim($str->string, $chars);
  334. return $str;
  335. }
  336. public function trimStart(string $chars = " \t\n\r\0\x0B\x0C"): static
  337. {
  338. $str = clone $this;
  339. $str->string = ltrim($str->string, $chars);
  340. return $str;
  341. }
  342. public function upper(): static
  343. {
  344. $str = clone $this;
  345. $str->string = strtoupper($str->string);
  346. return $str;
  347. }
  348. public function width(bool $ignoreAnsiDecoration = true): int
  349. {
  350. $string = preg_match('//u', $this->string) ? $this->string : preg_replace('/[\x80-\xFF]/', '?', $this->string);
  351. return (new CodePointString($string))->width($ignoreAnsiDecoration);
  352. }
  353. }