1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\String;
13
14use Symfony\Component\String\Exception\ExceptionInterface;
15use Symfony\Component\String\Exception\InvalidArgumentException;
16use Symfony\Component\String\Exception\RuntimeException;
17
18/**
19 * Represents a string of abstract characters.
20 *
21 * Unicode defines 3 types of "characters" (bytes, code points and grapheme clusters).
22 * This class is the abstract type to use as a type-hint when the logic you want to
23 * implement doesn't care about the exact variant it deals with.
24 *
25 * @author Nicolas Grekas <p@tchwork.com>
26 * @author Hugo Hamon <hugohamon@neuf.fr>
27 *
28 * @throws ExceptionInterface
29 */
30abstract class AbstractString implements \Stringable, \JsonSerializable
31{
32    public const PREG_PATTERN_ORDER = \PREG_PATTERN_ORDER;
33    public const PREG_SET_ORDER = \PREG_SET_ORDER;
34    public const PREG_OFFSET_CAPTURE = \PREG_OFFSET_CAPTURE;
35    public const PREG_UNMATCHED_AS_NULL = \PREG_UNMATCHED_AS_NULL;
36
37    public const PREG_SPLIT = 0;
38    public const PREG_SPLIT_NO_EMPTY = \PREG_SPLIT_NO_EMPTY;
39    public const PREG_SPLIT_DELIM_CAPTURE = \PREG_SPLIT_DELIM_CAPTURE;
40    public const PREG_SPLIT_OFFSET_CAPTURE = \PREG_SPLIT_OFFSET_CAPTURE;
41
42    protected $string = '';
43    protected $ignoreCase = false;
44
45    abstract public function __construct(string $string = '');
46
47    /**
48     * Unwraps instances of AbstractString back to strings.
49     *
50     * @return string[]|array
51     */
52    public static function unwrap(array $values): array
53    {
54        foreach ($values as $k => $v) {
55            if ($v instanceof self) {
56                $values[$k] = $v->__toString();
57            } elseif (\is_array($v) && $values[$k] !== $v = static::unwrap($v)) {
58                $values[$k] = $v;
59            }
60        }
61
62        return $values;
63    }
64
65    /**
66     * Wraps (and normalizes) strings in instances of AbstractString.
67     *
68     * @return static[]|array
69     */
70    public static function wrap(array $values): array
71    {
72        $i = 0;
73        $keys = null;
74
75        foreach ($values as $k => $v) {
76            if (\is_string($k) && '' !== $k && $k !== $j = (string) new static($k)) {
77                $keys = $keys ?? array_keys($values);
78                $keys[$i] = $j;
79            }
80
81            if (\is_string($v)) {
82                $values[$k] = new static($v);
83            } elseif (\is_array($v) && $values[$k] !== $v = static::wrap($v)) {
84                $values[$k] = $v;
85            }
86
87            ++$i;
88        }
89
90        return null !== $keys ? array_combine($keys, $values) : $values;
91    }
92
93    /**
94     * @param string|string[] $needle
95     *
96     * @return static
97     */
98    public function after($needle, bool $includeNeedle = false, int $offset = 0): self
99    {
100        $str = clone $this;
101        $i = \PHP_INT_MAX;
102
103        foreach ((array) $needle as $n) {
104            $n = (string) $n;
105            $j = $this->indexOf($n, $offset);
106
107            if (null !== $j && $j < $i) {
108                $i = $j;
109                $str->string = $n;
110            }
111        }
112
113        if (\PHP_INT_MAX === $i) {
114            return $str;
115        }
116
117        if (!$includeNeedle) {
118            $i += $str->length();
119        }
120
121        return $this->slice($i);
122    }
123
124    /**
125     * @param string|string[] $needle
126     *
127     * @return static
128     */
129    public function afterLast($needle, bool $includeNeedle = false, int $offset = 0): self
130    {
131        $str = clone $this;
132        $i = null;
133
134        foreach ((array) $needle as $n) {
135            $n = (string) $n;
136            $j = $this->indexOfLast($n, $offset);
137
138            if (null !== $j && $j >= $i) {
139                $i = $offset = $j;
140                $str->string = $n;
141            }
142        }
143
144        if (null === $i) {
145            return $str;
146        }
147
148        if (!$includeNeedle) {
149            $i += $str->length();
150        }
151
152        return $this->slice($i);
153    }
154
155    /**
156     * @return static
157     */
158    abstract public function append(string ...$suffix): self;
159
160    /**
161     * @param string|string[] $needle
162     *
163     * @return static
164     */
165    public function before($needle, bool $includeNeedle = false, int $offset = 0): self
166    {
167        $str = clone $this;
168        $i = \PHP_INT_MAX;
169
170        foreach ((array) $needle as $n) {
171            $n = (string) $n;
172            $j = $this->indexOf($n, $offset);
173
174            if (null !== $j && $j < $i) {
175                $i = $j;
176                $str->string = $n;
177            }
178        }
179
180        if (\PHP_INT_MAX === $i) {
181            return $str;
182        }
183
184        if ($includeNeedle) {
185            $i += $str->length();
186        }
187
188        return $this->slice(0, $i);
189    }
190
191    /**
192     * @param string|string[] $needle
193     *
194     * @return static
195     */
196    public function beforeLast($needle, bool $includeNeedle = false, int $offset = 0): self
197    {
198        $str = clone $this;
199        $i = null;
200
201        foreach ((array) $needle as $n) {
202            $n = (string) $n;
203            $j = $this->indexOfLast($n, $offset);
204
205            if (null !== $j && $j >= $i) {
206                $i = $offset = $j;
207                $str->string = $n;
208            }
209        }
210
211        if (null === $i) {
212            return $str;
213        }
214
215        if ($includeNeedle) {
216            $i += $str->length();
217        }
218
219        return $this->slice(0, $i);
220    }
221
222    /**
223     * @return int[]
224     */
225    public function bytesAt(int $offset): array
226    {
227        $str = $this->slice($offset, 1);
228
229        return '' === $str->string ? [] : array_values(unpack('C*', $str->string));
230    }
231
232    /**
233     * @return static
234     */
235    abstract public function camel(): self;
236
237    /**
238     * @return static[]
239     */
240    abstract public function chunk(int $length = 1): array;
241
242    /**
243     * @return static
244     */
245    public function collapseWhitespace(): self
246    {
247        $str = clone $this;
248        $str->string = trim(preg_replace('/(?:\s{2,}+|[^\S ])/', ' ', $str->string));
249
250        return $str;
251    }
252
253    /**
254     * @param string|string[] $needle
255     */
256    public function containsAny($needle): bool
257    {
258        return null !== $this->indexOf($needle);
259    }
260
261    /**
262     * @param string|string[] $suffix
263     */
264    public function endsWith($suffix): bool
265    {
266        if (!\is_array($suffix) && !$suffix instanceof \Traversable) {
267            throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class));
268        }
269
270        foreach ($suffix as $s) {
271            if ($this->endsWith((string) $s)) {
272                return true;
273            }
274        }
275
276        return false;
277    }
278
279    /**
280     * @return static
281     */
282    public function ensureEnd(string $suffix): self
283    {
284        if (!$this->endsWith($suffix)) {
285            return $this->append($suffix);
286        }
287
288        $suffix = preg_quote($suffix);
289        $regex = '{('.$suffix.')(?:'.$suffix.')++$}D';
290
291        return $this->replaceMatches($regex.($this->ignoreCase ? 'i' : ''), '$1');
292    }
293
294    /**
295     * @return static
296     */
297    public function ensureStart(string $prefix): self
298    {
299        $prefix = new static($prefix);
300
301        if (!$this->startsWith($prefix)) {
302            return $this->prepend($prefix);
303        }
304
305        $str = clone $this;
306        $i = $prefixLen = $prefix->length();
307
308        while ($this->indexOf($prefix, $i) === $i) {
309            $str = $str->slice($prefixLen);
310            $i += $prefixLen;
311        }
312
313        return $str;
314    }
315
316    /**
317     * @param string|string[] $string
318     */
319    public function equalsTo($string): bool
320    {
321        if (!\is_array($string) && !$string instanceof \Traversable) {
322            throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class));
323        }
324
325        foreach ($string as $s) {
326            if ($this->equalsTo((string) $s)) {
327                return true;
328            }
329        }
330
331        return false;
332    }
333
334    /**
335     * @return static
336     */
337    abstract public function folded(): self;
338
339    /**
340     * @return static
341     */
342    public function ignoreCase(): self
343    {
344        $str = clone $this;
345        $str->ignoreCase = true;
346
347        return $str;
348    }
349
350    /**
351     * @param string|string[] $needle
352     */
353    public function indexOf($needle, int $offset = 0): ?int
354    {
355        if (!\is_array($needle) && !$needle instanceof \Traversable) {
356            throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class));
357        }
358
359        $i = \PHP_INT_MAX;
360
361        foreach ($needle as $n) {
362            $j = $this->indexOf((string) $n, $offset);
363
364            if (null !== $j && $j < $i) {
365                $i = $j;
366            }
367        }
368
369        return \PHP_INT_MAX === $i ? null : $i;
370    }
371
372    /**
373     * @param string|string[] $needle
374     */
375    public function indexOfLast($needle, int $offset = 0): ?int
376    {
377        if (!\is_array($needle) && !$needle instanceof \Traversable) {
378            throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class));
379        }
380
381        $i = null;
382
383        foreach ($needle as $n) {
384            $j = $this->indexOfLast((string) $n, $offset);
385
386            if (null !== $j && $j >= $i) {
387                $i = $offset = $j;
388            }
389        }
390
391        return $i;
392    }
393
394    public function isEmpty(): bool
395    {
396        return '' === $this->string;
397    }
398
399    /**
400     * @return static
401     */
402    abstract public function join(array $strings, string $lastGlue = null): self;
403
404    public function jsonSerialize(): string
405    {
406        return $this->string;
407    }
408
409    abstract public function length(): int;
410
411    /**
412     * @return static
413     */
414    abstract public function lower(): self;
415
416    /**
417     * Matches the string using a regular expression.
418     *
419     * Pass PREG_PATTERN_ORDER or PREG_SET_ORDER as $flags to get all occurrences matching the regular expression.
420     *
421     * @return array All matches in a multi-dimensional array ordered according to flags
422     */
423    abstract public function match(string $regexp, int $flags = 0, int $offset = 0): array;
424
425    /**
426     * @return static
427     */
428    abstract public function padBoth(int $length, string $padStr = ' '): self;
429
430    /**
431     * @return static
432     */
433    abstract public function padEnd(int $length, string $padStr = ' '): self;
434
435    /**
436     * @return static
437     */
438    abstract public function padStart(int $length, string $padStr = ' '): self;
439
440    /**
441     * @return static
442     */
443    abstract public function prepend(string ...$prefix): self;
444
445    /**
446     * @return static
447     */
448    public function repeat(int $multiplier): self
449    {
450        if (0 > $multiplier) {
451            throw new InvalidArgumentException(sprintf('Multiplier must be positive, %d given.', $multiplier));
452        }
453
454        $str = clone $this;
455        $str->string = str_repeat($str->string, $multiplier);
456
457        return $str;
458    }
459
460    /**
461     * @return static
462     */
463    abstract public function replace(string $from, string $to): self;
464
465    /**
466     * @param string|callable $to
467     *
468     * @return static
469     */
470    abstract public function replaceMatches(string $fromRegexp, $to): self;
471
472    /**
473     * @return static
474     */
475    abstract public function reverse(): self;
476
477    /**
478     * @return static
479     */
480    abstract public function slice(int $start = 0, int $length = null): self;
481
482    /**
483     * @return static
484     */
485    abstract public function snake(): self;
486
487    /**
488     * @return static
489     */
490    abstract public function splice(string $replacement, int $start = 0, int $length = null): self;
491
492    /**
493     * @return static[]
494     */
495    public function split(string $delimiter, int $limit = null, int $flags = null): array
496    {
497        if (null === $flags) {
498            throw new \TypeError('Split behavior when $flags is null must be implemented by child classes.');
499        }
500
501        if ($this->ignoreCase) {
502            $delimiter .= 'i';
503        }
504
505        set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); });
506
507        try {
508            if (false === $chunks = preg_split($delimiter, $this->string, $limit, $flags)) {
509                $lastError = preg_last_error();
510
511                foreach (get_defined_constants(true)['pcre'] as $k => $v) {
512                    if ($lastError === $v && '_ERROR' === substr($k, -6)) {
513                        throw new RuntimeException('Splitting failed with '.$k.'.');
514                    }
515                }
516
517                throw new RuntimeException('Splitting failed with unknown error code.');
518            }
519        } finally {
520            restore_error_handler();
521        }
522
523        $str = clone $this;
524
525        if (self::PREG_SPLIT_OFFSET_CAPTURE & $flags) {
526            foreach ($chunks as &$chunk) {
527                $str->string = $chunk[0];
528                $chunk[0] = clone $str;
529            }
530        } else {
531            foreach ($chunks as &$chunk) {
532                $str->string = $chunk;
533                $chunk = clone $str;
534            }
535        }
536
537        return $chunks;
538    }
539
540    /**
541     * @param string|string[] $prefix
542     */
543    public function startsWith($prefix): bool
544    {
545        if (!\is_array($prefix) && !$prefix instanceof \Traversable) {
546            throw new \TypeError(sprintf('Method "%s()" must be overridden by class "%s" to deal with non-iterable values.', __FUNCTION__, static::class));
547        }
548
549        foreach ($prefix as $prefix) {
550            if ($this->startsWith((string) $prefix)) {
551                return true;
552            }
553        }
554
555        return false;
556    }
557
558    /**
559     * @return static
560     */
561    abstract public function title(bool $allWords = false): self;
562
563    public function toByteString(string $toEncoding = null): ByteString
564    {
565        $b = new ByteString();
566
567        $toEncoding = \in_array($toEncoding, ['utf8', 'utf-8', 'UTF8'], true) ? 'UTF-8' : $toEncoding;
568
569        if (null === $toEncoding || $toEncoding === $fromEncoding = $this instanceof AbstractUnicodeString || preg_match('//u', $b->string) ? 'UTF-8' : 'Windows-1252') {
570            $b->string = $this->string;
571
572            return $b;
573        }
574
575        set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); });
576
577        try {
578            try {
579                $b->string = mb_convert_encoding($this->string, $toEncoding, 'UTF-8');
580            } catch (InvalidArgumentException $e) {
581                if (!\function_exists('iconv')) {
582                    throw $e;
583                }
584
585                $b->string = iconv('UTF-8', $toEncoding, $this->string);
586            }
587        } finally {
588            restore_error_handler();
589        }
590
591        return $b;
592    }
593
594    public function toCodePointString(): CodePointString
595    {
596        return new CodePointString($this->string);
597    }
598
599    public function toString(): string
600    {
601        return $this->string;
602    }
603
604    public function toUnicodeString(): UnicodeString
605    {
606        return new UnicodeString($this->string);
607    }
608
609    /**
610     * @return static
611     */
612    abstract public function trim(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): self;
613
614    /**
615     * @return static
616     */
617    abstract public function trimEnd(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): self;
618
619    /**
620     * @return static
621     */
622    abstract public function trimStart(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): self;
623
624    /**
625     * @return static
626     */
627    public function truncate(int $length, string $ellipsis = '', bool $cut = true): self
628    {
629        $stringLength = $this->length();
630
631        if ($stringLength <= $length) {
632            return clone $this;
633        }
634
635        $ellipsisLength = '' !== $ellipsis ? (new static($ellipsis))->length() : 0;
636
637        if ($length < $ellipsisLength) {
638            $ellipsisLength = 0;
639        }
640
641        if (!$cut) {
642            if (null === $length = $this->indexOf([' ', "\r", "\n", "\t"], ($length ?: 1) - 1)) {
643                return clone $this;
644            }
645
646            $length += $ellipsisLength;
647        }
648
649        $str = $this->slice(0, $length - $ellipsisLength);
650
651        return $ellipsisLength ? $str->trimEnd()->append($ellipsis) : $str;
652    }
653
654    /**
655     * @return static
656     */
657    abstract public function upper(): self;
658
659    /**
660     * Returns the printable length on a terminal.
661     */
662    abstract public function width(bool $ignoreAnsiDecoration = true): int;
663
664    /**
665     * @return static
666     */
667    public function wordwrap(int $width = 75, string $break = "\n", bool $cut = false): self
668    {
669        $lines = '' !== $break ? $this->split($break) : [clone $this];
670        $chars = [];
671        $mask = '';
672
673        if (1 === \count($lines) && '' === $lines[0]->string) {
674            return $lines[0];
675        }
676
677        foreach ($lines as $i => $line) {
678            if ($i) {
679                $chars[] = $break;
680                $mask .= '#';
681            }
682
683            foreach ($line->chunk() as $char) {
684                $chars[] = $char->string;
685                $mask .= ' ' === $char->string ? ' ' : '?';
686            }
687        }
688
689        $string = '';
690        $j = 0;
691        $b = $i = -1;
692        $mask = wordwrap($mask, $width, '#', $cut);
693
694        while (false !== $b = strpos($mask, '#', $b + 1)) {
695            for (++$i; $i < $b; ++$i) {
696                $string .= $chars[$j];
697                unset($chars[$j++]);
698            }
699
700            if ($break === $chars[$j] || ' ' === $chars[$j]) {
701                unset($chars[$j++]);
702            }
703
704            $string .= $break;
705        }
706
707        $str = clone $this;
708        $str->string = $string.implode('', $chars);
709
710        return $str;
711    }
712
713    public function __sleep(): array
714    {
715        return ['string'];
716    }
717
718    public function __clone()
719    {
720        $this->ignoreCase = false;
721    }
722
723    public function __toString(): string
724    {
725        return $this->string;
726    }
727}
728