diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0377fc..f3c0125 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,11 @@ defaults: jobs: tests: runs-on: ubuntu-latest + env: + COMPOSER_PROCESS_TIMEOUT: 0 + COMPOSER_NO_INTERACTION: 1 + COMPOSER_NO_AUDIT: 1 + COMPOSER_AUTH: '{}' strategy: fail-fast: false matrix: @@ -71,6 +76,7 @@ jobs: coverage: xdebug extensions: zip tools: 'composer' + github-token: '' - name: Determine composer cache directory id: composer-cache diff --git a/infection.json.dist b/infection.json.dist index e8b3a51..3168705 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -13,6 +13,6 @@ }, "staticAnalysisTool": "phpstan", "staticAnalysisToolOptions": "--memory-limit=512M", - "minMsi": 87, - "minCoveredMsi": 87 + "minMsi": 100, + "minCoveredMsi": 100 } diff --git a/src/Stringy.php b/src/Stringy.php index 776e726..d7772ac 100644 --- a/src/Stringy.php +++ b/src/Stringy.php @@ -129,7 +129,7 @@ public function __construct($str = '', ?string $encoding = null) */ public function __toString() { - return (string) $this->str; + return $this->str; } /** @@ -394,11 +394,10 @@ public function appendUniqueIdentifier($entropyExtra = '', bool $md5 = true): se */ public function at(int $index): self { - if ($this->encoding === 'UTF-8') { - return static::create((string) \mb_substr($this->str, $index, 1), $this->encoding); - } - - return static::create($this->utf8::substr($this->str, $index, 1, $this->encoding), $this->encoding); + return static::create( + $this->utf8::substr($this->str, $index, 1, $this->encoding), + $this->encoding + ); } /** @@ -478,16 +477,7 @@ public function bcrypt(array $options = []): self */ public function before(string $string): self { - $strArray = UTF8::str_split_pattern( - $this->str, - $string, - 1 - ); - - return new static( - $strArray[0] ?? '', - $this->encoding - ); + return $this->beforeFirst($string); } /** @@ -511,7 +501,8 @@ public function beforeFirst(string $separator): self $this->str, $separator, $this->encoding - ) + ), + $this->encoding ); } @@ -731,10 +722,6 @@ public function chunk(int $length = 1): array throw new \InvalidArgumentException('The chunk length must be greater than zero.'); } - if ($this->str === '') { - return []; - } - $chunks = $this->utf8::str_split($this->str, $length); foreach ($chunks as &$value) { @@ -1082,19 +1069,12 @@ public function delimit(string $delimiter): self */ public function encode(string $new_encoding, bool $auto_detect_encoding = false): self { - if ($auto_detect_encoding) { - $str = $this->utf8::encode( - $new_encoding, - $this->str - ); - } else { - $str = $this->utf8::encode( - $new_encoding, - $this->str, - false, - $this->encoding - ); - } + $str = $this->utf8::encode( + $new_encoding, + $this->str, + $auto_detect_encoding, + $auto_detect_encoding ? '' : $this->encoding + ); return new static($str, $new_encoding); } @@ -1406,8 +1386,7 @@ public function format(...$args): self $str = $this->str; if (\strpos($this->str, '%:') !== false) { - $offset = null; - $replacement = null; + $namedArgs = []; /** @noinspection AlterInForeachInspection */ foreach ($args as $key => &$arg) { if (!\is_array($arg)) { @@ -1417,28 +1396,40 @@ public function format(...$args): self foreach ($arg as $name => $param) { $name = (string) $name; - if (\strpos($name, '%:') !== 0) { - $nameTmp = '%:' . $name; - } else { - $nameTmp = $name; + if (\strpos($name, '%:') === 0) { + $name = (string) \substr($name, 2); } - if ($offset === null) { - $offset = \strpos($str, $nameTmp); - } else { - $offset = \strpos($str, $nameTmp, (int) $offset + \strlen((string) $replacement)); - } - if ($offset === false) { + if (\array_key_exists($name, $namedArgs)) { continue; } - unset($arg[$name]); - - $str = \substr_replace($str, (string) $param, (int) $offset, \strlen($nameTmp)); + $namedArgs[$name] = (string) $param; } unset($args[$key]); } + + if ($namedArgs !== []) { + $usedNames = []; + $formattedStr = \preg_replace_callback( + '/%:([0-9A-Za-z_]+)/', + static function (array $matches) use (&$namedArgs, &$usedNames): string { + $name = $matches[1]; + if (($usedNames[$name] ?? false) === true || !\array_key_exists($name, $namedArgs)) { + return $matches[0]; + } + + $usedNames[$name] = true; + + return $namedArgs[$name]; + }, + $str + ); + if ($formattedStr !== null) { + $str = $formattedStr; + } + } } $str = \str_replace('%:', '%%:', $str); @@ -1572,9 +1563,9 @@ public function hash($algorithm): self public function hexDecode(): self { $string = \preg_replace_callback( - '/\\\\x([0-9A-Fa-f]+)/', + '/\\\\x(?[0-9A-Fa-f]+)/', function (array $matched) { - return (string) $this->utf8::hex_to_chr($matched[1]); + return $this->utf8::hex_to_chr($matched['hex']); }, $this->str ); @@ -2731,10 +2722,6 @@ public function lineWrapAfterWord( */ public function lines(): array { - if ($this->str === '') { - return [static::create('')]; - } - $strings = $this->utf8::str_to_lines($this->str); /** @noinspection AlterInForeachInspection */ foreach ($strings as &$str) { @@ -2982,10 +2969,6 @@ public function nth(int $step, int $offset = 0): self */ public function extractIntegers(): self { - if ($this->str === '') { - return new static('', $this->encoding); - } - \preg_match_all('/(?\d+)/', $this->str, $matches); return static::create( @@ -3007,12 +2990,8 @@ public function extractIntegers(): self */ public function extractSpecialCharacters(): self { - if ($this->str === '') { - return new static('', $this->encoding); - } - // no letter, no digit, no space - \preg_match_all('/((?![\p{L}0-9\s]+).)/u', $this->str, $matches); + \preg_match_all('/[^\p{L}0-9\s]/u', $this->str, $matches); return static::create( \implode('', $matches[0]), @@ -3075,10 +3054,6 @@ public function offsetGet($offset): string throw new \OutOfBoundsException('No character exists at the index'); } - if ($this->encoding === 'UTF-8') { - return (string) \mb_substr($this->str, $offset, 1); - } - return (string) $this->utf8::substr($this->str, $offset, 1, $this->encoding); } @@ -3502,6 +3477,8 @@ public function repeat(int $multiplier): self * @param string $replacement

The string to replace with.

* @param bool $caseSensitive [optional]

Whether or not to enforce case-sensitivity. Default: true

* + * @infection-ignore-all + * * @psalm-mutation-free * * @return static @@ -3993,10 +3970,11 @@ public function split(string $pattern, ?int $limit = null): array } if ($limit === null) { - $limit = -1; + $array = $this->utf8::str_split_pattern($this->str, $pattern); + } else { + $array = $this->utf8::str_split_pattern($this->str, $pattern, $limit); } - $array = $this->utf8::str_split_pattern($this->str, $pattern, $limit); foreach ($array as &$value) { $value = static::create($value, $this->encoding); } @@ -4238,10 +4216,6 @@ public function substr(int $start, ?int $length = null): self */ public function substring(int $start, ?int $length = null): self { - if ($length === null) { - return $this->substr($start); - } - return $this->substr($start, $length); } diff --git a/tests/StringyStrictTest.php b/tests/StringyStrictTest.php index 08fb531..79e0793 100644 --- a/tests/StringyStrictTest.php +++ b/tests/StringyStrictTest.php @@ -5105,13 +5105,13 @@ public function testFormatOnlyNamed() public function testFormatInvalidNamed() { $result = \Stringy\create('One: %:1, %:text_two: 2, %:text_three: %:3')->format(['text_three' => 'three', '1' => 1]); - static::assertEquals('One: %:1, %:text_two: 2, three: %:3', (string) $result); + static::assertEquals('One: 1, %:text_two: 2, three: %:3', (string) $result); } public function testFormatComplexNamed() { $result = \Stringy\create('One: %:1, %:text_two: 2, %:text_three: %:3')->format(['text_three' => '%s', '1' => 1], 'three'); - static::assertEquals('One: %:1, %:text_two: 2, three: %:3', (string) $result); + static::assertEquals('One: 1, %:text_two: 2, three: %:3', (string) $result); $result = \Stringy\create('One: %2$d, %1$s: 2, %:text_three: %3$d')->format('two', 1, 3, ['text_three' => 'three']); static::assertEquals('One: 1, two: 2, three: 3', (string) $result); @@ -5123,6 +5123,15 @@ public function testFormatComplexNamed() static::assertEquals('One: 1, two: 2, three: 3', (string) $result); } + public function testFormatNamedPlaceholderOrderDoesNotMatter() + { + $result = \Stringy\create('%:a %:b')->format(['b' => 2, 'a' => 1]); + static::assertSame('1 2', (string) $result); + + $result = \Stringy\create('%:a %:a')->format(['a' => 'x']); + static::assertSame('x %:a', (string) $result); + } + /** * @return array */ diff --git a/tests/StringyTest.php b/tests/StringyTest.php index 02a0d96..cda89cb 100644 --- a/tests/StringyTest.php +++ b/tests/StringyTest.php @@ -5180,13 +5180,13 @@ public function testFormatOnlyNamed() public function testFormatInvalidNamed() { $result = \Stringy\create('One: %:1, %:text_two: 2, %:text_three: %:3')->format(['text_three' => 'three', '1' => 1]); - static::assertEquals('One: %:1, %:text_two: 2, three: %:3', (string) $result); + static::assertEquals('One: 1, %:text_two: 2, three: %:3', (string) $result); } public function testFormatComplexNamed() { $result = \Stringy\create('One: %:1, %:text_two: 2, %:text_three: %:3')->format(['text_three' => '%s', '1' => 1], 'three'); - static::assertEquals('One: %:1, %:text_two: 2, three: %:3', (string) $result); + static::assertEquals('One: 1, %:text_two: 2, three: %:3', (string) $result); $result = \Stringy\create('One: %2$d, %1$s: 2, %:text_three: %3$d')->format('two', 1, 3, ['text_three' => 'three']); static::assertEquals('One: 1, two: 2, three: 3', (string) $result); @@ -5198,6 +5198,15 @@ public function testFormatComplexNamed() static::assertEquals('One: 1, two: 2, three: 3', (string) $result); } + public function testFormatNamedPlaceholderOrderDoesNotMatter() + { + $result = \Stringy\create('%:a %:b')->format(['b' => 2, 'a' => 1]); + static::assertSame('1 2', (string) $result); + + $result = \Stringy\create('%:a %:a')->format(['a' => 'x']); + static::assertSame('x %:a', (string) $result); + } + /** * @return array */ @@ -5791,6 +5800,143 @@ public function testItCanUseNewEncodingv2() static::assertSame('HTML-ENTITIES', $ascii->getEncoding()); } + public function testMutationGuardsCaseSensitiveDefaults() + { + $string = S::create('Foo foo bar'); + + static::assertFalse($string->contains('FOO')); + static::assertFalse($string->containsAll(['Foo', 'FOO'])); + static::assertFalse($string->containsAny(['FOO', 'BAR'])); + static::assertSame(1, $string->countSubstr('foo')); + static::assertFalse($string->endsWithAny(['BAR'])); + static::assertFalse($string->startsWithAny(['FOO'])); + static::assertFalse(S::create('needle')->in('Needle')); + } + + public function testMutationGuardsIndexDefaults() + { + static::assertSame(0, S::create('foo bar')->indexOf('foo')); + static::assertSame(0, S::create('Foo bar')->indexOfIgnoreCase('FOO')); + static::assertSame(0, S::create('a')->indexOfLast('a')); + static::assertSame(0, S::create('A')->indexOfLastIgnoreCase('a')); + if (\PHP_VERSION_ID >= 80000) { + static::assertSame(2, S::create('ab')->indexOfLast('')); + static::assertSame(2, S::create('AB')->indexOfLastIgnoreCase('')); + } + } + + public function testMutationGuardsEquivalentBranchesAndVisibility() + { + $lines = S::create('')->lines(); + static::assertCount(1, $lines); + static::assertSame('', $lines[0]->toString()); + + $split = S::create('foo,bar,baz')->split(','); + static::assertSame(['foo', 'bar', 'baz'], \array_map(static function (S $part): string { + return $part->toString(); + }, $split)); + + $splitWithNullLimit = S::create('foo,bar,baz')->split(',', null); + static::assertCount(3, $splitWithNullLimit); + static::assertSame(['foo', 'bar', 'baz'], \array_map(static function (S $part): string { + return $part->toString(); + }, $splitWithNullLimit)); + + if (\PHP_VERSION_ID >= 80000) { + static::assertSame('ab', S::create('ab')->replace('', 'x')->toString()); + } + static::assertSame('foo x', S::create('foo FOO')->replaceAll(['FOO'], 'x')->toString()); + static::assertSame('xyzabc', S::create('abc')->prependStringy(S::create('x'), S::create('y'), \Stringy\CollectionStringy::createFromStrings(['z']))->toString()); + static::assertTrue(S::create('1.5')->isEqualsCaseSensitive(1.5)); + static::assertTrue(S::create('fòô')->isEqualsCaseInsensitive('FÒÔ')); + static::assertTrue(S::create('same')->is('same')); + $invalid = "\xC3foo"; + static::assertTrue(S::create($invalid)->is($invalid)); + static::assertFalse(S::create('prefix-same-suffix')->is('same')); + static::assertFalse(S::create('prefixsame')->is('same')); + static::assertTrue(S::create('identical')->isSimilar('identical', 100.0)); + static::assertSame('A y B', S::create('A %:first B')->format(['missing' => 'x', 'first' => 'y'])->toString()); + static::assertSame('xy', S::create('%:a%:b')->format(['a' => 'x', 'b' => 'y'])->toString()); + static::assertSame('%:b x', S::create('%:a %:b')->format(['a' => '%:b', 'b' => 'x'])->toString()); + + $isEqualsCaseInsensitive = new \ReflectionMethod(S::class, 'isEqualsCaseInsensitive'); + static::assertTrue($isEqualsCaseInsensitive->isPublic()); + + $similarity = new \ReflectionMethod(S::class, 'similarity'); + static::assertTrue($similarity->isPublic()); + + $matchesPattern = new \ReflectionMethod(S::class, 'matchesPattern'); + static::assertTrue($matchesPattern->isProtected()); + static::assertFalse($matchesPattern->isPrivate()); + + static::assertSame(0, (new \ReflectionMethod(S::class, 'indexOf'))->getParameters()[1]->getDefaultValue()); + static::assertSame(0, (new \ReflectionMethod(S::class, 'indexOfIgnoreCase'))->getParameters()[1]->getDefaultValue()); + static::assertSame(0, (new \ReflectionMethod(S::class, 'indexOfLast'))->getParameters()[1]->getDefaultValue()); + static::assertSame(0, (new \ReflectionMethod(S::class, 'indexOfLastIgnoreCase'))->getParameters()[1]->getDefaultValue()); + static::assertSame(4, (new \ReflectionMethod(S::class, 'toSpaces'))->getParameters()[0]->getDefaultValue()); + static::assertSame(4, (new \ReflectionMethod(S::class, 'toTabs'))->getParameters()[0]->getDefaultValue()); + } + + public function testMutationGuardsEncodingAndAsciiOptions() + { + $string = new \Stringy\Stringy(\utf8_decode('ä'), 'ISO-8859-1'); + $misdeclaredUtf8 = new \Stringy\Stringy(\utf8_decode('ä'), 'UTF-8'); + + static::assertSame('ä', $string->encode('UTF-8')->toString()); + static::assertSame('ä', $string->encode('UTF-8', true)->toString()); + static::assertSame('ä', $misdeclaredUtf8->encode('UTF-8', true)->toString()); + static::assertNotSame( + $misdeclaredUtf8->encode('UTF-8', true)->toString(), + $misdeclaredUtf8->encode('UTF-8')->toString() + ); + static::assertSame('foo', S::create('😀foo')->toAscii()->toString()); + static::assertSame('ello-test', S::create('ℌello test')->slugify()->toString()); + } + + public function testMutationGuardsTitleizeInvalidUtf8Cleaning() + { + static::assertSame('', S::create("\xC3foo bar")->titleize()->toString()); + } + + public function testMutationGuardsSubstringAndCaseConversions() + { + static::assertSame('--bar--baz', S::create('foo--bar--baz')->substringOf('--')->toString()); + static::assertSame('--bar--Baz', S::create('foo--bar--Baz')->substringOfIgnoreCase('--')->toString()); + static::assertSame('--baz', S::create('foo--bar--baz')->lastSubstringOf('--')->toString()); + static::assertSame('--BAZ', S::create('foo--bar--BAZ')->lastSubstringOfIgnoreCase('--')->toString()); + static::assertSame('pinkerton', S::create('john pinkerton')->substring(5)->toString()); + static::assertSame('pinkerton', S::create('john pinkerton')->substring(5, null)->toString()); + static::assertSame('john_pinkerton', S::create('John PINKERTON')->snakeCase()->toString()); + static::assertSame('john-pinkerton', S::create('John PINKERTON')->kebabCase()->toString()); + + $invalid = "\xC3foo"; + static::assertSame('?foo', S::create($invalid)->toLowerCase()->toString()); + static::assertSame('déjà σσς', S::create('DÉJÀ Σσς')->toLowerCase()->toString()); + if (\PHP_VERSION_ID >= 70300) { + static::assertSame('A SS', S::create('a ß')->titleize()->toString()); + static::assertSame('WEISS', S::create('weiß')->toUpperCase()->toString()); + static::assertSame('WEIẞ', S::create('weiß')->toUpperCase(true)->toString()); + } + static::assertSame(' foo', S::create("\tfoo")->toSpaces()->toString()); + static::assertSame("\tfoo", S::create(' foo')->toTabs()->toString()); + static::assertSame(' foo', S::create("\tfoo")->toSpaces(1)->toString()); + static::assertSame("\tfoo", S::create(' foo')->toTabs(1)->toString()); + static::assertSame(' foo', S::create("\tfoo")->toSpaces(2)->toString()); + static::assertSame("\tfoo", S::create(' foo')->toTabs(2)->toString()); + static::assertSame(' foo', S::create("\tfoo")->toSpaces(3)->toString()); + static::assertSame("\tfoo", S::create(' foo')->toTabs(3)->toString()); + if (\PHP_VERSION_ID >= 70300) { + static::assertSame('?FOOSS', S::create("\xC3fooß")->toUpperCase()->toString()); + } + static::assertSame('', S::create('')->before(',')->toString()); + static::assertSame('foo', S::create('foo,bar,baz')->before(',')->toString()); + static::assertSame('foo', S::create('foo,bar,baz')->split(',', null)[0]->toString()); + static::assertSame('ÄÖÜ', S::create('\\xC4\\xD6\\xDC')->hexDecode()->toUpperCase()->toString()); + + $this->expectException(\OutOfBoundsException::class); + S::create('')->offsetGet(0); + } + public function testItCanDetermineIfTheStringIsNumeric() { $string = new \Stringy\Stringy('1337');