diff --git a/src/Tempest/Support/src/ArrayHelper.php b/src/Tempest/Support/src/ArrayHelper.php index f1465d030..441740bf4 100644 --- a/src/Tempest/Support/src/ArrayHelper.php +++ b/src/Tempest/Support/src/ArrayHelper.php @@ -39,6 +39,8 @@ public function __construct( $this->array = $input; } elseif ($input instanceof self) { $this->array = $input->array; + } elseif ($input === null) { + $this->array = []; } else { $this->array = [$input]; } @@ -432,6 +434,10 @@ public static function explode(string|Stringable $string, string $separator = ' return new self([(string) $string]); } + if ((string) $string === '') { + return new self(); + } + return new self(explode($separator, (string) $string)); } @@ -449,7 +455,9 @@ public function equals(array|self $other): bool * Returns the first item in the instance that matches the given `$filter`. * If `$filter` is `null`, returns the first item. * - * @param Closure(mixed $value, mixed $key): bool $filter + * @param null|Closure(TValue $value, TKey $key): bool $filter + * + * @return TValue */ public function first(?Closure $filter = null): mixed { @@ -474,7 +482,9 @@ public function first(?Closure $filter = null): mixed * Returns the last item in the instance that matches the given `$filter`. * If `$filter` is `null`, returns the last item. * - * @param Closure(mixed $value, mixed $key): bool $filter + * @param null|Closure(TValue $value, TKey $key): bool $filter + * + * @return TValue */ public function last(?Closure $filter = null): mixed { @@ -608,7 +618,11 @@ public function each(Closure $each): self /** * Returns a new instance of the array, with each item transformed by the given callback. * - * @param Closure(mixed $value, mixed $key): mixed $map + * @template TMapValue + * + * @param Closure(TValue, TKey): TMapValue $map + * + * @return static */ public function map(Closure $map): self { @@ -654,11 +668,13 @@ public function mapWithKeys(Closure $map): self * * @return mixed|ArrayHelper */ - public function get(string $key, mixed $default = null): mixed + public function get(int|string $key, mixed $default = null): mixed { $value = $this->array; - $keys = explode('.', $key); + $keys = is_int($key) + ? [$key] + : explode('.', $key); foreach ($keys as $key) { if (! isset($value[$key])) { @@ -678,11 +694,13 @@ public function get(string $key, mixed $default = null): mixed /** * Asserts whether a value identified by the specified `$key` exists. */ - public function has(string $key): bool + public function has(int|string $key): bool { $array = $this->array; - $keys = explode('.', $key); + $keys = is_int($key) + ? [$key] + : explode('.', $key); foreach ($keys as $key) { if (! isset($array[$key])) { @@ -802,6 +820,51 @@ public function join(string $glue = ', ', ?string $finalGlue = ' and '): StringH return str($last); } + /** + * Flattens the instance to a single-level array, or until the specified `$depth` is reached. + * + * ### Example + * ```php + * arr(['foo', ['bar', 'baz']])->flatten(); // ['foo', 'bar', 'baz'] + * ``` + */ + public function flatten(int|float $depth = INF): self + { + $result = []; + + foreach ($this->array as $item) { + if (! is_array($item)) { + $result[] = $item; + + continue; + } + + $values = $depth === 1 + ? array_values($item) + : arr($item)->flatten($depth - 1); + + foreach ($values as $value) { + $result[] = $value; + } + } + + return new self($result); + } + + /** + * Returns a new instance of the array, with each item transformed by the given callback, then flattens it by the specified depth. + * + * @template TMapValue + * + * @param Closure(TValue, TKey): TMapValue[] $map + * + * @return static + */ + public function flatMap(Closure $map, int|float $depth = 1): self + { + return $this->map($map)->flatten($depth); + } + /** * Dumps the instance. */ diff --git a/src/Tempest/Support/src/StringHelper.php b/src/Tempest/Support/src/StringHelper.php index eea1b3ab7..0193b6522 100644 --- a/src/Tempest/Support/src/StringHelper.php +++ b/src/Tempest/Support/src/StringHelper.php @@ -14,9 +14,11 @@ final readonly class StringHelper implements Stringable { - public function __construct( - private string $string = '', - ) { + private string $string; + + public function __construct(?string $string = '') + { + $this->string = $string ?? ''; } /** @@ -481,6 +483,24 @@ public function replaceStart(Stringable|string $search, Stringable|string $repla return $this->replaceFirst($search, $replace); } + /** + * Replaces the portion of the specified `$length` at the specified `$position` with the specified `$replace`. + * + * ### Example + * ```php + * str('Lorem dolor')->replaceAt(6, 5, 'ipsum'); // Lorem ipsum + * ``` + */ + public function replaceAt(int $position, int $length, Stringable|string $replace): self + { + if ($length < 0) { + $position += $length; + $length = abs($length); + } + + return new self(substr_replace($this->string, (string) $replace, $position, $length)); + } + /** * Appends the given strings to the instance. */ @@ -636,6 +656,71 @@ public function excerpt(int $from, int $to, bool $asArray = false): self|ArrayHe return new self(implode(PHP_EOL, $lines)); } + /** + * Truncates the instance to the specified amount of characters. + * + * ### Example + * ```php + * str('Lorem ipsum')->truncate(5, end: '...'); // Lorem... + * ``` + */ + public function truncate(int $characters, string $end = ''): self + { + if (mb_strwidth($this->string, 'UTF-8') <= $characters) { + return $this; + } + + return new self(rtrim(mb_strimwidth($this->string, 0, $characters, encoding: 'UTF-8')) . $end); + } + + /** + * Gets parts of the instance. + * + * ### Example + * ```php + * str('Lorem ipsum')->substr(0, length: 5); // Lorem + * str('Lorem ipsum')->substr(6); // ipsum + * ``` + */ + public function substr(int $start, ?int $length = null): self + { + return new self(mb_substr($this->string, $start, $length)); + } + + /** + * Takes the specified amount of characters. If `$length` is negative, starts from the end. + */ + public function take(int $length): self + { + if ($length < 0) { + return $this->substr($length); + } + + return $this->substr(0, $length); + } + + /** + * Splits the instance into chunks of the specified `$length`. + */ + public function split(int $length): ArrayHelper + { + if ($length <= 0) { + return new ArrayHelper(); + } + + if ($this->equals('')) { + return new ArrayHelper(['']); + } + + $chunks = []; + + foreach (str_split($this->string, $length) as $chunk) { + $chunks[] = $chunk; + } + + return new ArrayHelper($chunks); + } + private function normalizeString(mixed $value): mixed { if ($value instanceof Stringable) { @@ -653,6 +738,41 @@ public function explode(string $separator = ' '): ArrayHelper return ArrayHelper::explode($this->string, $separator); } + /** + * Strips HTML and PHP tags from the instance. + * + * @param null|string|string[] $allowed Allowed tags. + * + * ### Example + * ```php + * str('

Lorem ipsum

')->stripTags(); // Lorem ipsum + * str('

Lorem ipsum

')->stripTags(allowed: 'strong'); // Lorem ipsum + * ``` + */ + public function stripTags(null|string|array $allowed = null): self + { + $allowed = arr($allowed) + ->map(fn (string $tag) => str($tag)->wrap('<', '>')->toString()) + ->toArray(); + + return new self(strip_tags($this->string, $allowed)); + } + + /** + * Inserts the specified `$string` at the specified `$position`. + * + * ### Example + * ```php + * str('Lorem ipsum sit amet')->insertAt(11, ' dolor'); // Lorem ipsum dolor sit amet + * ``` + */ + public function insertAt(int $position, string $string): self + { + return new self( + mb_substr($this->string, 0, $position) . $string . mb_substr($this->string, $position) + ); + } + /** * Implodes the given array into a string by a separator. */ diff --git a/src/Tempest/Support/src/functions.php b/src/Tempest/Support/src/functions.php index 3bb5eaa8a..43cdd15b0 100644 --- a/src/Tempest/Support/src/functions.php +++ b/src/Tempest/Support/src/functions.php @@ -6,7 +6,7 @@ /** * Creates an instance of {@see StringHelper} using the given `$string`. */ - function str(string $string = ''): StringHelper + function str(?string $string = ''): StringHelper { return new StringHelper($string); } diff --git a/src/Tempest/Support/tests/ArrayHelperTest.php b/src/Tempest/Support/tests/ArrayHelperTest.php index 91f3246b2..a2fa7c9c6 100644 --- a/src/Tempest/Support/tests/ArrayHelperTest.php +++ b/src/Tempest/Support/tests/ArrayHelperTest.php @@ -1382,6 +1382,66 @@ public function test_sort_keys_by_callback(): void ); } + public function test_flatten(): void + { + $this->assertTrue(arr(['#foo', '#bar', '#baz'])->flatten()->equals(['#foo', '#bar', '#baz'])); + $this->assertTrue(arr([['#foo', '#bar'], '#baz'])->flatten()->equals(['#foo', '#bar', '#baz'])); + $this->assertTrue(arr([['#foo', null], '#baz', null])->flatten()->equals(['#foo', null, '#baz', null])); + $this->assertTrue(arr([['#foo', '#bar'], ['#baz']])->flatten()->equals(['#foo', '#bar', '#baz'])); + $this->assertTrue(arr([['#foo', ['#bar']], ['#baz']])->flatten()->equals(['#foo', '#bar', '#baz'])); + $this->assertTrue(arr([['#foo', ['#bar', ['#baz']]], '#zap'])->flatten()->equals(['#foo', '#bar', '#baz', '#zap'])); + + $this->assertTrue(arr([['#foo', ['#bar', ['#baz']]], '#zap'])->flatten(depth: 1)->equals(['#foo', ['#bar', ['#baz']], '#zap'])); + $this->assertTrue(arr([['#foo', ['#bar', ['#baz']]], '#zap'])->flatten(depth: 2)->equals(['#foo', '#bar', ['#baz'], '#zap'])); + } + + public function test_flatmap(): void + { + // basic + $this->assertTrue( + arr([ + ['name' => 'Makise', 'hobbies' => ['Science', 'Programming']], + ['name' => 'Okabe', 'hobbies' => ['Science', 'Anime']], + ])->flatMap(fn (array $person) => $person['hobbies']) + ->equals(['Science', 'Programming', 'Science', 'Anime']), + ); + + // deeply nested + $likes = arr([ + ['name' => 'Enzo', 'likes' => [ + 'manga' => ['Tower of God', 'The Beginning After The End'], + 'languages' => ['PHP', 'TypeScript'], + ]], + ['name' => 'Jon', 'likes' => [ + 'manga' => ['One Piece', 'Naruto'], + 'languages' => ['Python'], + ]], + ]); + + $this->assertTrue( + $likes->flatMap(fn (array $person) => $person['likes'], depth: 1) + ->equals([ + ['Tower of God', 'The Beginning After The End'], + ['PHP', 'TypeScript'], + ['One Piece', 'Naruto'], + ['Python'], + ]), + ); + + $this->assertTrue( + $likes->flatMap(fn (array $person) => $person['likes'], depth: INF) + ->equals([ + 'Tower of God', + 'The Beginning After The End', + 'PHP', + 'TypeScript', + 'One Piece', + 'Naruto', + 'Python', + ]), + ); + } + public function test_basic_reduce(): void { $collection = arr([ diff --git a/src/Tempest/Support/tests/StringHelperTest.php b/src/Tempest/Support/tests/StringHelperTest.php index ed95ad89f..0d8fc114b 100644 --- a/src/Tempest/Support/tests/StringHelperTest.php +++ b/src/Tempest/Support/tests/StringHelperTest.php @@ -481,4 +481,80 @@ public function test_start(): void $this->assertSame('Leon Scott Kennedy', str('Scott Kennedy')->start('Leon ')->toString()); $this->assertSame('Leon Scott Kennedy', str('Leon Scott Kennedy')->start('Leon ')->toString()); } + + public function test_limit(): void + { + $this->assertSame('Lorem', str('Lorem ipsum')->truncate(5)->toString()); + $this->assertSame('Lorem...', str('Lorem ipsum')->truncate(5, end: '...')->toString()); + $this->assertSame('...', str('Lorem ipsum')->truncate(0, end: '...')->toString()); + $this->assertSame('L...', str('Lorem ipsum')->truncate(1, end: '...')->toString()); + $this->assertSame('Lorem ipsum', str('Lorem ipsum')->truncate(100)->toString()); + $this->assertSame('Lorem ipsum', str('Lorem ipsum')->truncate(100, end: '...')->toString()); + } + + public function test_substr(): void + { + $this->assertSame('Lorem', str('Lorem ipsum')->substr(0, length: 5)->toString()); + $this->assertSame('ipsum', str('Lorem ipsum')->substr(6, length: 5)->toString()); + $this->assertSame('ipsum', str('Lorem ipsum')->substr(6)->toString()); + $this->assertSame('ipsum', str('Lorem ipsum')->substr(-5)->toString()); + $this->assertSame('ipsum', str('Lorem ipsum')->substr(-5, length: 5)->toString()); + } + + public function test_take(): void + { + // positive + $this->assertSame('Lorem', str('Lorem ipsum')->take(5)->toString()); + $this->assertSame('Lorem ipsum', str('Lorem ipsum')->take(100)->toString()); + + // negative + $this->assertSame('ipsum', str('Lorem ipsum')->take(-5)->toString()); + } + + public function test_split(): void + { + $this->assertSame([PHP_EOL], str(PHP_EOL)->split(100)->toArray()); + $this->assertSame([''], str('')->split(1)->toArray()); + $this->assertSame([], str('123')->split(-1)->toArray()); + $this->assertSame(['1', '2', '3'], str('123')->split(1)->toArray()); + $this->assertSame(['123'], str('123')->split(1000)->toArray()); + $this->assertSame(['foo', 'bar', 'baz'], str('foobarbaz')->split(3)->toArray()); + $this->assertSame(['foo', 'bar', 'baz', '22'], str('foobarbaz22')->split(3)->toArray()); + } + + public function test_insert_at(): void + { + $this->assertSame('foo', str()->insertAt(0, 'foo')->toString()); + $this->assertSame('foo', str()->insertAt(-1, 'foo')->toString()); + $this->assertSame('foo', str()->insertAt(100, 'foo')->toString()); + $this->assertSame('foo', str()->insertAt(-100, 'foo')->toString()); + $this->assertSame('foobar', str('bar')->insertAt(0, 'foo')->toString()); + $this->assertSame('barfoo', str('bar')->insertAt(3, 'foo')->toString()); + $this->assertSame('foobarbaz', str('foobaz')->insertAt(3, 'bar')->toString()); + $this->assertSame('123', str('13')->insertAt(-1, '2')->toString()); + } + + public function test_replace_at(): void + { + $this->assertSame('foobar', str('foo2bar')->replaceAt(4, -1, '')->toString()); + $this->assertSame('foobar', str('foo2bar')->replaceAt(3, 1, '')->toString()); + $this->assertSame('fooquxbar', str('foo2bar')->replaceAt(3, 1, 'qux')->toString()); + $this->assertSame('foobarbaz', str('barbaz')->replaceAt(0, 0, 'foo')->toString()); + $this->assertSame('barbazfoo', str('barbaz')->replaceAt(6, 0, 'foo')->toString()); + $this->assertSame('bar', str('foo')->replaceAt(0, 3, 'bar')->toString()); + $this->assertSame('abc1', str('abcd')->replaceAt(-1, 1, '1')->toString()); + $this->assertSame('ab1d', str('abcd')->replaceAt(-1, -1, '1')->toString()); + $this->assertSame('abc', str('abc')->replaceAt(3, 1, '')->toString()); + } + + public function test_strip_tags(): void + { + $this->assertSame('Hello World', str('

Hello World

')->stripTags()->toString()); + $this->assertSame('Hello World', str('

Hello World

')->stripTags()->toString()); + $this->assertSame('Hello World', str('

Hello World

')->stripTags(allowed: '')->toString()); + $this->assertSame('

Hello World

', str('

Hello World

')->stripTags(allowed: '

')->toString()); + + $this->assertSame('Hello World', str('

Hello World

')->stripTags(allowed: 'strong')->toString()); + $this->assertSame('

Hello World

', str('

Hello World

')->stripTags(allowed: 'p')->toString()); + } }