diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ee2494b0..6fe6d97a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -489,7 +489,7 @@ parameters: - message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$attributes\.$#' identifier: property.notFound - count: 3 + count: 4 path: src/Phaseolies/Database/Entity/Builder.php - @@ -507,7 +507,7 @@ parameters: - message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$originalAttributes\.$#' identifier: property.notFound - count: 3 + count: 4 path: src/Phaseolies/Database/Entity/Builder.php - diff --git a/src/Phaseolies/Support/StringService.php b/src/Phaseolies/Support/StringService.php index a1307eb7..99efde31 100644 --- a/src/Phaseolies/Support/StringService.php +++ b/src/Phaseolies/Support/StringService.php @@ -38,7 +38,9 @@ public function len(string $input): int */ public function countWord(string $string): int { - preg_match_all('/\p{L}[\p{L}\p{Mn}\p{Pd}\'’]*/u', $string, $matches); + // A word starts with a letter or digit. A "." or "," only continues it + // between two digits, so 3.14 and 1,000 count as one word. + preg_match_all('/[\p{L}\p{Nd}](?:[\p{L}\p{Nd}\p{Mn}\p{Pd}\'’]|(?<=\p{Nd})[.,](?=\p{Nd}))*/u', $string, $matches); return count($matches[0]); } @@ -51,9 +53,9 @@ public function countWord(string $string): int */ public function isPalindrome(string $string): bool { - $cleaned = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $string)); + $cleaned = mb_strtolower(preg_replace('/[^\p{L}\p{Nd}]/u', '', $string) ?? '', 'UTF-8'); - return $cleaned === strrev($cleaned); + return $cleaned === $this->reverse($cleaned); } /** @@ -64,7 +66,15 @@ public function isPalindrome(string $string): bool */ public function random(int $length = 10): string { - return substr(str_shuffle(str_repeat('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', $length)), 0, $length); + $alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + $last = strlen($alphabet) - 1; + $result = ''; + + for ($i = 0; $i < $length; $i++) { + $result .= $alphabet[random_int(0, $last)]; + } + + return $result; } /** @@ -75,11 +85,9 @@ public function random(int $length = 10): string */ public function camel(string $input): string { - $input = str_replace(['-', '_'], ' ', $input); - $input = mb_convert_case($input, MB_CASE_TITLE, 'UTF-8'); - $input = str_replace(' ', '', $input); + $studly = $this->studly($input); - return lcfirst($input); + return mb_strtolower(mb_substr($studly, 0, 1, 'UTF-8'), 'UTF-8') . mb_substr($studly, 1, null, 'UTF-8'); } /** @@ -88,18 +96,23 @@ public function camel(string $input): string * @param string $string * @param int $visibleFromStart * @param int $visibleFromEnd - * @param string + * @param string $maskCharacter + * @return string */ public function mask(string $string, int $visibleFromStart = 1, int $visibleFromEnd = 1, string $maskCharacter = '*'): string { - $length = strlen($string); + $visibleFromStart = max(0, $visibleFromStart); + $visibleFromEnd = max(0, $visibleFromEnd); + + $length = mb_strlen($string, 'UTF-8'); if ($length <= $visibleFromStart + $visibleFromEnd) { return $string; } - $startPart = substr($string, 0, $visibleFromStart); - $endPart = substr($string, -$visibleFromEnd); + $startPart = mb_substr($string, 0, $visibleFromStart, 'UTF-8'); + + $endPart = $visibleFromEnd > 0 ? mb_substr($string, -$visibleFromEnd, null, 'UTF-8') : ''; $middlePart = str_repeat($maskCharacter, $length - $visibleFromStart - $visibleFromEnd); return $startPart . $middlePart . $endPart; @@ -115,7 +128,11 @@ public function mask(string $string, int $visibleFromStart = 1, int $visibleFrom */ public function truncate(string $string, int $maxLength, string $suffix = '...'): string { - return (strlen($string) > $maxLength) ? substr($string, 0, $maxLength) . $suffix : $string; + $maxLength = max(0, $maxLength); + + return (mb_strlen($string, 'UTF-8') > $maxLength) + ? mb_substr($string, 0, $maxLength, 'UTF-8') . $suffix + : $string; } /** @@ -126,7 +143,7 @@ public function truncate(string $string, int $maxLength, string $suffix = '...') */ public function snake(string $input): string { - return strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $input)); + return mb_strtolower(preg_replace('/([\p{Ll}\p{Nd}])(\p{Lu})/u', '$1_$2', $input) ?? $input, 'UTF-8'); } /** @@ -150,11 +167,12 @@ public function title(string $input): string public function slug(string $input, string $separator = '-'): string { $slug = mb_strtolower($input, 'UTF-8'); - $slug = preg_replace('/[\s,\.;\'"!\?]+/u', $separator, $slug); - $slug = preg_replace('/[^\p{L}\p{Nd}' . preg_quote($separator, '/') . ']+/u', '', $slug); - $slug = preg_replace('/' . preg_quote($separator, '/') . '{2,}/u', $separator, $slug); - return trim($slug, $separator); + $slug = preg_replace('/\p{M}+/u', '', $slug) ?? $slug; + + $slug = preg_replace('/[^\p{L}\p{Nd}]+/u', "\0", $slug) ?? $slug; + + return str_replace("\0", $separator, trim($slug, "\0")); } /** @@ -176,6 +194,10 @@ public function contains(string $haystack, string | array $needles, bool $ignore return false; } + if ($needles === '') { + return false; + } + if ($ignoreCase) { return mb_stripos($haystack, $needles, 0, 'UTF-8') !== false; } @@ -201,7 +223,10 @@ public function limitWords(string $string, int $words, string $end = '...', stri return ''; } - $wordArray = preg_split('/\s+/u', $trimmed); + $words = max(0, $words); + + // Fall back to a byte-wise split when the string is not valid UTF-8. + $wordArray = preg_split('/\s+/u', $trimmed) ?: preg_split('/\s+/', $trimmed); // If the total number of words is less than or equal to $words, return original. if (count($wordArray) <= $words) { @@ -252,17 +277,13 @@ public function removeWhiteSpace(string $input): string */ public function uuid(): string { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0xffff) - ); + $bytes = random_bytes(16); + + // Set the version (4) and variant (RFC 4122) bits. + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); } /** @@ -336,11 +357,24 @@ public function endsWith(string $haystack, string | array $needles, string $enco */ public function studly(string $input, string $encoding = 'UTF-8'): string { - $spaced = str_replace(['-', '_'], ' ', $input); + $segments = preg_split('/([-_\s]+)/u', $input, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$input]; - $titled = mb_convert_case($spaced, MB_CASE_TITLE, $encoding); + $studly = ''; - $studly = str_replace(' ', '', $titled); + foreach ($segments as $segment) { + if ($segment === '' || preg_match('/^[-_\s]+$/u', $segment)) { + continue; + } + + $first = mb_substr($segment, 0, 1, $encoding); + $rest = mb_substr($segment, 1, null, $encoding); + + // A word that already has humps (fooBar, iPhone, XMLParser) keeps + // them; any other word is normalised (HELLO, hello -> Hello). + $studly .= preg_match('/\p{Ll}/u', $rest) && preg_match('/\p{Lu}/u', $rest) + ? mb_strtoupper($first, $encoding) . $rest + : mb_convert_case($segment, MB_CASE_TITLE, $encoding); + } return $studly; } @@ -386,23 +420,31 @@ public function extractNumbers(string $input): string */ public function longestCommonSubstring(string $str1, string $str2): string { - $matrix = array_fill(0, strlen($str1) + 1, array_fill(0, strlen($str2) + 1, 0)); + $chars1 = mb_str_split($str1, 1, 'UTF-8'); + $chars2 = mb_str_split($str2, 1, 'UTF-8'); + $length2 = count($chars2); $maxLength = 0; $endIndex = 0; + $previous = array_fill(0, $length2 + 1, 0); + + foreach ($chars1 as $i => $char1) { + $current = array_fill(0, $length2 + 1, 0); - for ($i = 1; $i <= strlen($str1); $i++) { - for ($j = 1; $j <= strlen($str2); $j++) { - if ($str1[$i - 1] === $str2[$j - 1]) { - $matrix[$i][$j] = $matrix[$i - 1][$j - 1] + 1; - if ($matrix[$i][$j] > $maxLength) { - $maxLength = $matrix[$i][$j]; - $endIndex = $i; + foreach ($chars2 as $j => $char2) { + if ($char1 === $char2) { + $current[$j + 1] = $previous[$j] + 1; + + if ($current[$j + 1] > $maxLength) { + $maxLength = $current[$j + 1]; + $endIndex = $i + 1; } } } + + $previous = $current; } - return substr($str1, $endIndex - $maxLength, $maxLength); + return implode('', array_slice($chars1, $endIndex - $maxLength, $maxLength)); } /** @@ -448,15 +490,15 @@ public function extractEmails(string $input): array */ public function highlightKeyword(string $input, string $keyword, string $tag = 'strong'): string { - if (empty($keyword)) { + if ($keyword === '') { return $input; } - return preg_replace( - '/(' . preg_quote($keyword, '/') . ')/i', - "<$tag>$1", - $input - ); + $pattern = '/(' . preg_quote($keyword, '/') . ')/i'; + + return preg_replace($pattern . 'u', "<$tag>$1", $input) + ?? preg_replace($pattern, "<$tag>$1", $input) + ?? $input; } /** @@ -467,7 +509,7 @@ public function highlightKeyword(string $input, string $keyword, string $tag = ' */ public function toUpper(string $input): string { - return strtoupper($input); + return mb_strtoupper($input, 'UTF-8'); } /** @@ -479,11 +521,15 @@ public function toUpper(string $input): string */ public function suffixAppend(string $input, string $suffix): string { - if (str()->endsWith($input, $suffix)) { + if ($this->endsWith($input, $suffix)) { return $input; } - return strtoupper($input[0]) . substr($input, 1) . $suffix; + if ($input === '') { + return $suffix; + } + + return mb_strtoupper(mb_substr($input, 0, 1, 'UTF-8'), 'UTF-8') . mb_substr($input, 1, null, 'UTF-8') . $suffix; } /** @@ -495,7 +541,11 @@ public function suffixAppend(string $input, string $suffix): string */ public function removeSuffix(string $input, string $suffix): string { - return substr($input, 0, -strlen($suffix)); + if ($suffix === '' || ! $this->endsWith($input, $suffix)) { + return $input; + } + + return mb_substr($input, 0, mb_strlen($input, 'UTF-8') - mb_strlen($suffix, 'UTF-8'), 'UTF-8'); } /** @@ -544,8 +594,15 @@ public function before(string $subject, string $search): string */ public function between(string $subject, string $from, string $to): string { + if ($from === '' || $to === '') { + return $subject; + } + $start = $this->after($subject, $from); - return $this->before($start, $to); + $pos = mb_strrpos($start, $to, 0, 'UTF-8'); + + // Up to the last closing match, so a nested "[a[b]c]" yields "a[b]c". + return $pos === false ? $start : mb_substr($start, 0, $pos, 'UTF-8'); } /** diff --git a/tests/StringServiceTest.php b/tests/StringServiceTest.php index f00130d7..ba5624da 100644 --- a/tests/StringServiceTest.php +++ b/tests/StringServiceTest.php @@ -70,6 +70,66 @@ public function testMask() $this->assertEquals('john', $this->stringService->mask('john', 2, 2)); } + public function testMaskWithNoVisibleEndHidesTheEnd() + { + $this->assertSame('12*******', $this->stringService->mask('123456789', 2, 0)); + $this->assertSame('se*********', $this->stringService->mask('secretvalue', 2, 0)); + } + + public function testMaskWithNoVisibleEndNeverRepeatsTheOriginal() + { + $original = '1234567890'; + $masked = $this->stringService->mask($original, 3, 0); + + $this->assertSame(strlen($original), strlen($masked)); + $this->assertStringNotContainsString($original, $masked); + $this->assertStringNotContainsString('4567890', $masked); + } + + public function testMaskWithNoVisibleStartHidesTheStart() + { + $this->assertSame('*******89', $this->stringService->mask('123456789', 0, 2)); + } + + public function testMaskWithNothingVisibleMasksEverything() + { + $this->assertSame('*********', $this->stringService->mask('123456789', 0, 0)); + $this->assertSame('#####', $this->stringService->mask('abcde', 0, 0, '#')); + } + + public function testMaskKeepsTheOriginalLength() + { + foreach ([[0, 0], [1, 0], [0, 1], [2, 3], [4, 4]] as [$start, $end]) { + $this->assertSame( + 9, + strlen($this->stringService->mask('123456789', $start, $end)), + "visible {$start}/{$end} changed the length" + ); + } + } + + public function testMaskTreatsNegativeVisibleCountsAsZero() + { + $this->assertSame('*********', $this->stringService->mask('123456789', -2, -3)); + $this->assertSame('12*******', $this->stringService->mask('123456789', 2, -1)); + $this->assertSame('*******89', $this->stringService->mask('123456789', -1, 2)); + } + + public function testMaskReturnsShortStringsUnchanged() + { + $this->assertSame('', $this->stringService->mask('', 1, 1)); + $this->assertSame('ab', $this->stringService->mask('ab', 1, 1)); + $this->assertSame('abc', $this->stringService->mask('abc', 3, 0)); + $this->assertSame('abc', $this->stringService->mask('abc', 0, 3)); + } + + public function testMaskOnlyHidesTheMiddleWhenBothEndsAreVisible() + { + $this->assertSame('1*******9', $this->stringService->mask('123456789', 1, 1)); + $this->assertSame('123***789', $this->stringService->mask('123456789', 3, 3)); + $this->assertSame('12345678*', $this->stringService->mask('123456789', 8, 0)); + } + public function testTruncate() { $this->assertEquals('Hello...', $this->stringService->truncate('Hello World', 5)); @@ -241,6 +301,672 @@ public function testReturnsFalseForInvalidJson() $this->assertFalse($this->stringService->isJson('')); } + // ---- isPalindrome ----------------------------------------------------- + + public function testIsPalindromeHandlesNonLatinText() + { + $this->assertFalse($this->stringService->isPalindrome('привет')); + $this->assertFalse($this->stringService->isPalindrome('你好吗')); + $this->assertTrue($this->stringService->isPalindrome('топот')); + $this->assertTrue($this->stringService->isPalindrome('А роза упала на лапу Азора')); + $this->assertTrue($this->stringService->isPalindrome('été')); + $this->assertTrue($this->stringService->isPalindrome('ÉtÉ')); + } + + public function testIsPalindromeKeepsAsciiBehaviour() + { + $this->assertTrue($this->stringService->isPalindrome('Was it a car or a cat I saw?')); + $this->assertTrue($this->stringService->isPalindrome('12321')); + $this->assertFalse($this->stringService->isPalindrome('12345')); + $this->assertFalse($this->stringService->isPalindrome('ab-à')); + } + + // ---- random / uuid ---------------------------------------------------- + + public function testRandomReturnsTheRequestedLength() + { + foreach ([0, 1, 7, 32, 200] as $length) { + $this->assertSame($length, strlen($this->stringService->random($length))); + } + } + + public function testRandomWithNegativeLengthReturnsEmptyString() + { + $this->assertSame('', $this->stringService->random(-5)); + } + + public function testRandomOnlyUsesAlphanumericCharacters() + { + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{500}$/', $this->stringService->random(500)); + } + + public function testRandomCanRepeatCharactersWithinOneString() + { + // A shuffle of the alphabet can never repeat a character in a short + // string; real random picks do, so 62 draws of 400 chars always repeat. + $this->assertLessThan(400, count(array_unique(str_split($this->stringService->random(400))))); + } + + public function testRandomEventuallyUsesEveryCharacterClass() + { + $seen = array_unique(str_split($this->stringService->random(5000))); + + $this->assertGreaterThan(55, count($seen)); + $this->assertMatchesRegularExpression('/[a-z]/', implode('', $seen)); + $this->assertMatchesRegularExpression('/[A-Z]/', implode('', $seen)); + $this->assertMatchesRegularExpression('/[0-9]/', implode('', $seen)); + } + + public function testRandomValuesDoNotCollide() + { + $values = []; + + for ($i = 0; $i < 500; $i++) { + $values[] = $this->stringService->random(16); + } + + $this->assertCount(500, array_unique($values)); + } + + public function testUuidIsAlwaysAValidVersion4() + { + for ($i = 0; $i < 300; $i++) { + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $this->stringService->uuid() + ); + } + } + + public function testUuidValuesDoNotCollide() + { + $values = []; + + for ($i = 0; $i < 1000; $i++) { + $values[] = $this->stringService->uuid(); + } + + $this->assertCount(1000, array_unique($values)); + } + + public function testUuidVariantNibbleCoversEveryAllowedValue() + { + $variants = []; + + for ($i = 0; $i < 600; $i++) { + $variants[$this->stringService->uuid()[19]] = true; + } + + $this->assertEqualsCanonicalizing(['8', '9', 'a', 'b'], array_keys($variants)); + } + + // ---- truncate --------------------------------------------------------- + + public function testTruncateCountsCharactersNotBytes() + { + $this->assertSame('héllo...', $this->stringService->truncate('héllo wörld', 5)); + $this->assertSame('héllo', $this->stringService->truncate('héllo', 5)); + $this->assertSame('日本...', $this->stringService->truncate('日本語テキスト', 2)); + } + + public function testTruncateNeverProducesInvalidUtf8() + { + foreach (range(0, 8) as $max) { + $this->assertTrue(mb_check_encoding($this->stringService->truncate('héllo wörld 日本語', $max), 'UTF-8')); + } + } + + public function testTruncateWithNegativeLengthKeepsOnlyTheSuffix() + { + $this->assertSame('...', $this->stringService->truncate('hello world', -1)); + $this->assertSame('...', $this->stringService->truncate('hello world', 0)); + } + + public function testTruncateKeepsStringsThatFitExactly() + { + $this->assertSame('', $this->stringService->truncate('', 3)); + $this->assertSame('abc', $this->stringService->truncate('abc', 3)); + $this->assertSame('abc...', $this->stringService->truncate('abcd', 3)); + } + + // ---- mask (multibyte) ------------------------------------------------- + + public function testMaskCountsCharactersNotBytes() + { + $this->assertSame('h*********d', $this->stringService->mask('héllo wörld', 1, 1)); + $this->assertSame('日*****ト', $this->stringService->mask('日本語テキスト', 1, 1)); + $this->assertSame('日本*****', $this->stringService->mask('日本語テキスト', 2, 0)); + } + + public function testMaskNeverProducesInvalidUtf8() + { + foreach ([[1, 1], [2, 0], [0, 2], [3, 3]] as [$start, $end]) { + $this->assertTrue(mb_check_encoding($this->stringService->mask('héllo wörld 日本語', $start, $end), 'UTF-8')); + } + } + + public function testMaskKeepsTheCharacterLengthOfMultibyteText() + { + $this->assertSame(11, mb_strlen($this->stringService->mask('héllo wörld', 2, 2))); + } + + // ---- snake ------------------------------------------------------------ + + public function testSnakeHandlesNonAsciiLetters() + { + $this->assertSame('école_nom', $this->stringService->snake('ÉcoleNom')); + $this->assertSame('é_nom', $this->stringService->snake('éNom')); + $this->assertSame('straße_name', $this->stringService->snake('straßeName')); + } + + public function testSnakeKeepsAsciiBehaviour() + { + $this->assertSame('hello_world', $this->stringService->snake('helloWorld')); + $this->assertSame('hello_world', $this->stringService->snake('HelloWorld')); + $this->assertSame('user_profile_image', $this->stringService->snake('userProfileImage')); + $this->assertSame('already_snake', $this->stringService->snake('already_snake')); + $this->assertSame('', $this->stringService->snake('')); + } + + // ---- slug ------------------------------------------------------------- + + public function testSlugWithEmptySeparatorJoinsTheWords() + { + $this->assertSame('helloworld', $this->stringService->slug('Hello World', '')); + } + + public function testSlugWithEmptySeparatorStillStripsPunctuation() + { + $this->assertSame('helloworld', $this->stringService->slug('Hello, World!', '')); + } + + public function testSlugCollapsesRepeatedSeparatorsAndTrimsTheEdges() + { + $this->assertSame('hello-world', $this->stringService->slug(' Hello World!! ')); + $this->assertSame('hello__world', $this->stringService->slug('Hello World', '__')); + $this->assertSame('hello--world', $this->stringService->slug('Hello World', '--')); + } + + public function testSlugKeepsUnicodeLetters() + { + $this->assertSame('привет-мир', $this->stringService->slug('Привет Мир')); + } + + // ---- contains --------------------------------------------------------- + + public function testContainsNeverMatchesAnEmptyNeedle() + { + $this->assertFalse($this->stringService->contains('abc', '')); + $this->assertFalse($this->stringService->contains('abc', [''])); + $this->assertFalse($this->stringService->contains('', '')); + } + + public function testContainsStillMatchesOtherNeedlesNextToAnEmptyOne() + { + $this->assertTrue($this->stringService->contains('abc', ['', 'b'])); + $this->assertFalse($this->stringService->contains('abc', ['', 'x'])); + } + + public function testContainsRespectsCaseSensitivity() + { + $this->assertFalse($this->stringService->contains('Hello', 'hello', false)); + $this->assertTrue($this->stringService->contains('Hello', 'Hello', false)); + $this->assertTrue($this->stringService->contains('Ünï', 'ÜNÏ')); + } + + // ---- limitWords ------------------------------------------------------- + + public function testLimitWordsWithNonPositiveLimitReturnsOnlyTheEnding() + { + $this->assertSame('...', $this->stringService->limitWords('a b c d', 0)); + $this->assertSame('...', $this->stringService->limitWords('a b c d', -1)); + $this->assertSame('...', $this->stringService->limitWords('a b c d', -100)); + } + + public function testLimitWordsDoesNotThrowOnInvalidUtf8() + { + $this->assertSame("\xFF bad...", $this->stringService->limitWords("\xFF bad worse", 2)); + } + + public function testLimitWordsHandlesEmptyAndWhitespaceOnlyStrings() + { + $this->assertSame('', $this->stringService->limitWords('', 3)); + $this->assertSame('', $this->stringService->limitWords(" \n ", 3)); + } + + public function testLimitWordsSplitsOnAnyWhitespace() + { + $this->assertSame('a b...', $this->stringService->limitWords("a\tb\nc d", 2)); + } + + // ---- longestCommonSubstring ------------------------------------------ + + public function testLongestCommonSubstringNeverSplitsACharacter() + { + $this->assertSame('caf', $this->stringService->longestCommonSubstring('café', 'cafè')); + $this->assertTrue(mb_check_encoding($this->stringService->longestCommonSubstring('café', 'cafè'), 'UTF-8')); + $this->assertSame('本語', $this->stringService->longestCommonSubstring('日本語', '本語')); + } + + public function testLongestCommonSubstringFindsTheLongestRun() + { + $this->assertSame('abcd', $this->stringService->longestCommonSubstring('xxabcdyy', 'zabcdz')); + $this->assertSame('Hello ', $this->stringService->longestCommonSubstring('Hello World', 'Hello There')); + } + + public function testLongestCommonSubstringWithNothingInCommonIsEmpty() + { + $this->assertSame('', $this->stringService->longestCommonSubstring('abc', 'xyz')); + $this->assertSame('', $this->stringService->longestCommonSubstring('', 'abc')); + $this->assertSame('', $this->stringService->longestCommonSubstring('abc', '')); + $this->assertSame('', $this->stringService->longestCommonSubstring('', '')); + } + + public function testLongestCommonSubstringPrefersTheFirstOfEqualLengthRuns() + { + $this->assertSame('ab', $this->stringService->longestCommonSubstring('abXcd', 'cdYab')); + } + + public function testLongestCommonSubstringHandlesLongInputs() + { + $a = str_repeat('a', 1500) . 'MATCH' . str_repeat('b', 1500); + $b = str_repeat('c', 1500) . 'MATCH' . str_repeat('d', 1500); + + $this->assertSame('MATCH', $this->stringService->longestCommonSubstring($a, $b)); + } + + // ---- highlightKeyword ------------------------------------------------- + + public function testHighlightKeywordAcceptsAZeroKeyword() + { + $this->assertSame('a0b', $this->stringService->highlightKeyword('a0b', '0')); + } + + public function testHighlightKeywordIgnoresAnEmptyKeyword() + { + $this->assertSame('abc', $this->stringService->highlightKeyword('abc', '')); + } + + public function testHighlightKeywordMatchesNonAsciiCaseInsensitively() + { + $this->assertSame('Été', $this->stringService->highlightKeyword('Été', 'été')); + } + + public function testHighlightKeywordKeepsTheOriginalCaseOfTheMatch() + { + $this->assertSame('Hello world', $this->stringService->highlightKeyword('Hello world', 'hello', 'b')); + } + + public function testHighlightKeywordEscapesRegexCharactersInTheKeyword() + { + $this->assertSame('1+1', $this->stringService->highlightKeyword('1+1', '+')); + $this->assertSame('a.b', $this->stringService->highlightKeyword('a.b', '.')); + } + + public function testHighlightKeywordStillWorksOnInvalidUtf8() + { + $this->assertSame("\xFF a", $this->stringService->highlightKeyword("\xFF a", 'a')); + } + + public function testHighlightKeywordHighlightsEveryOccurrence() + { + $this->assertSame('aba', $this->stringService->highlightKeyword('aba', 'a')); + } + + // ---- toUpper ---------------------------------------------------------- + + public function testToUpperHandlesNonAsciiLetters() + { + $this->assertSame('ÉA', $this->stringService->toUpper('éa')); + $this->assertSame('ПРИВЕТ', $this->stringService->toUpper('привет')); + $this->assertSame('GET', $this->stringService->toUpper('get')); + $this->assertSame('', $this->stringService->toUpper('')); + } + + // ---- suffixAppend / removeSuffix ------------------------------------- + + public function testSuffixAppendWorksWithoutABootedContainer() + { + $this->assertSame('PostController', $this->stringService->suffixAppend('post', 'Controller')); + } + + public function testSuffixAppendLeavesAnExistingSuffixAlone() + { + $this->assertSame('PostController', $this->stringService->suffixAppend('PostController', 'Controller')); + } + + public function testSuffixAppendOfAnEmptyInputReturnsTheSuffix() + { + $this->assertSame('Controller', $this->stringService->suffixAppend('', 'Controller')); + } + + public function testSuffixAppendCapitalisesAMultibyteFirstLetter() + { + $this->assertSame('ÉcoleController', $this->stringService->suffixAppend('école', 'Controller')); + } + + public function testRemoveSuffixRemovesOnlyAMatchingSuffix() + { + $this->assertSame('Post', $this->stringService->removeSuffix('PostController', 'Controller')); + $this->assertSame('hello', $this->stringService->removeSuffix('hello', 'xyz')); + $this->assertSame('PostControllerX', $this->stringService->removeSuffix('PostControllerX', 'Controller')); + } + + public function testRemoveSuffixWithAnEmptySuffixReturnsTheInput() + { + $this->assertSame('hello', $this->stringService->removeSuffix('hello', '')); + $this->assertSame('', $this->stringService->removeSuffix('', '')); + } + + public function testRemoveSuffixIgnoresASuffixLongerThanTheInput() + { + $this->assertSame('ab', $this->stringService->removeSuffix('ab', 'abc')); + } + + public function testRemoveSuffixOfTheWholeStringLeavesNothing() + { + $this->assertSame('', $this->stringService->removeSuffix('Controller', 'Controller')); + } + + public function testRemoveSuffixHandlesMultibyteSuffixes() + { + $this->assertSame('café', $this->stringService->removeSuffix('café日本', '日本')); + $this->assertSame('日', $this->stringService->removeSuffix('日本', '本')); + } + + public function testSuffixAppendAndRemoveSuffixRoundTrip() + { + $name = $this->stringService->suffixAppend('invoice', 'Controller'); + + $this->assertSame('Invoice', $this->stringService->removeSuffix($name, 'Controller')); + } + + // ---- slug separators -------------------------------------------------- + + public function testSlugTurnsUnderscoresSlashesAndColonsIntoSeparators() + { + $this->assertSame('snake-case-title', $this->stringService->slug('snake_case_title')); + $this->assertSame('path-to-file', $this->stringService->slug('path/to/file')); + $this->assertSame('key-value', $this->stringService->slug('key:value')); + $this->assertSame('a-b-c', $this->stringService->slug('a\\b|c')); + } + + public function testSlugTurnsSymbolsIntoSeparators() + { + $this->assertSame('rock-roll', $this->stringService->slug('Rock & Roll')); + $this->assertSame('a-b', $this->stringService->slug('a&b')); + $this->assertSame('1-1', $this->stringService->slug('1+1')); + $this->assertSame('hello-world', $this->stringService->slug('(hello) [world]')); + } + + public function testSlugConvertsExistingSeparatorsToACustomOne() + { + $this->assertSame('hello_world', $this->stringService->slug('hello-world', '_')); + $this->assertSame('a_b_c', $this->stringService->slug('a-b c', '_')); + $this->assertSame('hello.world', $this->stringService->slug('hello world', '.')); + } + + public function testSlugKeepsExistingHyphensWithTheDefaultSeparator() + { + $this->assertSame('well-known-fact', $this->stringService->slug('well-known fact')); + $this->assertSame('a-b', $this->stringService->slug('a--b')); + } + + public function testSlugOfNothingMeaningfulIsEmpty() + { + $this->assertSame('', $this->stringService->slug('')); + $this->assertSame('', $this->stringService->slug('!!! ??? ---')); + $this->assertSame('', $this->stringService->slug('___', '_')); + } + + public function testSlugKeepsDigits() + { + $this->assertSame('top-10-tips', $this->stringService->slug('Top 10 Tips!')); + $this->assertSame('v2-0', $this->stringService->slug('v2.0')); + } + + public function testSlugDoesNotSplitWordsOnCombiningMarks() + { + $this->assertSame('cafe-au-lait', $this->stringService->slug("cafe\u{0301} au lait")); + $this->assertSame('ecole', $this->stringService->slug("e\u{0301}cole")); + } + + public function testSlugNeverStartsOrEndsWithTheSeparator() + { + foreach (['-a-', '_a_', '/a/', ' a ', '.a.'] as $input) { + $this->assertSame('a', $this->stringService->slug($input), "slug of '{$input}'"); + } + } + + public function testSlugIsIdempotent() + { + $slug = $this->stringService->slug('Hello, World_of/PHP & More!'); + + $this->assertSame($slug, $this->stringService->slug($slug)); + } + + // ---- studly / camel --------------------------------------------------- + + public function testStudlyKeepsExistingHumps() + { + $this->assertSame('FooBar', $this->stringService->studly('fooBar')); + $this->assertSame('FooBarBaz', $this->stringService->studly('fooBar_baz')); + $this->assertSame('IPhoneCase', $this->stringService->studly('iPhone case')); + $this->assertSame('McDonald', $this->stringService->studly('mcDonald')); + $this->assertSame('XMLParser', $this->stringService->studly('XMLParser')); + } + + public function testStudlyStillNormalisesWordsWithoutHumps() + { + $this->assertSame('HelloWorld', $this->stringService->studly('hello_world')); + $this->assertSame('HelloWorld', $this->stringService->studly('HELLO_WORLD')); + $this->assertSame('HelloWorld', $this->stringService->studly('Hello-World')); + $this->assertSame('UserId', $this->stringService->studly('user_ID')); + $this->assertSame('HelloWorld', $this->stringService->studly('hello world')); + } + + public function testStudlyHandlesMessySeparatorsAndEmptyInput() + { + $this->assertSame('', $this->stringService->studly('')); + $this->assertSame('', $this->stringService->studly('___')); + $this->assertSame('HelloWorld', $this->stringService->studly('__hello__world__')); + $this->assertSame('HelloWorld', $this->stringService->studly("hello\t\nworld")); + } + + public function testStudlyHandlesUnicode() + { + $this->assertSame('ÉcoleNom', $this->stringService->studly('école_nom')); + $this->assertSame('ÉcoleNom', $this->stringService->studly('écoleNom')); + $this->assertSame('ПриветМир', $this->stringService->studly('привет_мир')); + } + + public function testStudlyKeepsDigits() + { + $this->assertSame('User2Name', $this->stringService->studly('user2_name')); + $this->assertSame('Create2024Table', $this->stringService->studly('create_2024_table')); + } + + public function testCamelKeepsExistingHumps() + { + $this->assertSame('fooBar', $this->stringService->camel('fooBar')); + $this->assertSame('fooBarBaz', $this->stringService->camel('FooBar_baz')); + $this->assertSame('iPhoneCase', $this->stringService->camel('iPhone case')); + } + + public function testCamelKeepsSnakeAndKebabBehaviour() + { + $this->assertSame('helloWorld', $this->stringService->camel('hello_world')); + $this->assertSame('helloWorld', $this->stringService->camel('hello-world')); + $this->assertSame('helloWorld', $this->stringService->camel('HELLO_WORLD')); + $this->assertSame('helloWorld', $this->stringService->camel('Hello_World')); + $this->assertSame('userId', $this->stringService->camel('user_ID')); + } + + public function testCamelHandlesEmptyAndUnicodeInput() + { + $this->assertSame('', $this->stringService->camel('')); + $this->assertSame('écoleNom', $this->stringService->camel('école_nom')); + $this->assertSame('écoleNom', $this->stringService->camel('École_Nom')); + } + + public function testCamelOfAMigrationStyleName() + { + // Migrator::getMigrationClass() derives the class name this way. + $this->assertSame('20240101CreateUsersTable', $this->stringService->camel('2024_01_01_create_users_table')); + $this->assertSame('20240101CreateUsersTable', $this->stringService->camel('2024_01_01_CreateUsersTable')); + } + + public function testCamelAndSnakeRoundTripForPlainNames() + { + foreach (['hello_world', 'user_profile_image', 'order_item'] as $name) { + $this->assertSame($name, $this->stringService->snake($this->stringService->camel($name))); + } + } + + // ---- snake digits ----------------------------------------------------- + + public function testSnakeStartsANewWordAfterADigit() + { + $this->assertSame('user1_name', $this->stringService->snake('user1Name')); + $this->assertSame('address2_line', $this->stringService->snake('address2Line')); + $this->assertSame('hello_world2_go', $this->stringService->snake('HelloWorld2Go')); + $this->assertSame('level10_boss', $this->stringService->snake('level10Boss')); + } + + public function testSnakeLeavesTrailingAndLeadingDigitsAlone() + { + $this->assertSame('post2', $this->stringService->snake('Post2')); + $this->assertSame('version2', $this->stringService->snake('version2')); + $this->assertSame('2fa_code', $this->stringService->snake('2faCode')); + $this->assertSame('user_2', $this->stringService->snake('user_2')); + } + + public function testSnakeIsIdempotent() + { + foreach (['userName', 'address2Line', 'HelloWorld2Go', 'école_nom'] as $input) { + $once = $this->stringService->snake($input); + + $this->assertSame($once, $this->stringService->snake($once), "snake of '{$input}'"); + } + } + + public function testSnakeKeepsTheNamesTheFrameworkRelyOn() + { + // Table names and computed-property keys come from snake(). + $this->assertSame('blog_post', $this->stringService->snake('BlogPost')); + $this->assertSame('full_name', $this->stringService->snake('fullName')); + $this->assertSame('post_tags', $this->stringService->snake('PostTags')); + $this->assertSame('success', $this->stringService->snake('success')); + } + + // ---- between ---------------------------------------------------------- + + public function testBetweenReturnsNestedDelimitersUpToTheLastClosingMatch() + { + $this->assertSame('a[b]c', $this->stringService->between('[a[b]c]', '[', ']')); + $this->assertSame('x(y)z', $this->stringService->between('f(x(y)z)', '(', ')')); + } + + public function testBetweenWithASingleDelimitedValue() + { + $this->assertSame('b', $this->stringService->between('a [b] c', '[', ']')); + $this->assertSame('大阪', $this->stringService->between('東京-大阪-名古屋', '東京-', '-名古屋')); + } + + public function testBetweenSpansEverythingUpToTheLastClosingDelimiter() + { + $this->assertSame('b] c [d', $this->stringService->between('a [b] c [d] e', '[', ']')); + } + + public function testBetweenWithMultiCharacterDelimiters() + { + $this->assertSame('x', $this->stringService->between('<>', '<<', '>>')); + $this->assertSame('a>>b', $this->stringService->between('<>b>>', '<<', '>>')); + } + + public function testBetweenWithAMissingClosingDelimiterReturnsTheRest() + { + $this->assertSame('b', $this->stringService->between('a [b', '[', ']')); + } + + public function testBetweenWithAMissingOpeningDelimiterStartsAtTheBeginning() + { + $this->assertSame('abc', $this->stringService->between('abc]def', '[', ']')); + } + + public function testBetweenWithNeitherDelimiterReturnsTheSubject() + { + $this->assertSame('no-delims', $this->stringService->between('no-delims', '[', ']')); + } + + public function testBetweenWithAnEmptyDelimiterReturnsTheSubject() + { + $this->assertSame('abc', $this->stringService->between('abc', '', ']')); + $this->assertSame('abc', $this->stringService->between('abc', '[', '')); + $this->assertSame('', $this->stringService->between('', '[', ']')); + } + + public function testBetweenWithAdjacentDelimitersIsEmpty() + { + $this->assertSame('', $this->stringService->between('a[]b', '[', ']')); + } + + public function testBetweenWithIdenticalOpeningAndClosingDelimiters() + { + $this->assertSame('b', $this->stringService->between('a"b"c', '"', '"')); + } + + // ---- countWord numbers ------------------------------------------------ + + public function testCountWordCountsNumbers() + { + $this->assertSame(4, $this->stringService->countWord('I have 2 cats')); + $this->assertSame(1, $this->stringService->countWord('2024')); + $this->assertSame(3, $this->stringService->countWord('1 2 3')); + } + + public function testCountWordKeepsDecimalsAndThousandsTogether() + { + $this->assertSame(3, $this->stringService->countWord('pi is 3.14.')); + $this->assertSame(2, $this->stringService->countWord('1,000,000 users')); + $this->assertSame(2, $this->stringService->countWord('version v2.0')); + } + + public function testCountWordKeepsLettersAndDigitsInOneWord() + { + $this->assertSame(2, $this->stringService->countWord('2nd place')); + $this->assertSame(1, $this->stringService->countWord('abc123')); + $this->assertSame(2, $this->stringService->countWord('covid-19 cases')); + } + + public function testCountWordStillSplitsLettersOnPunctuation() + { + $this->assertSame(2, $this->stringService->countWord('a.b')); + $this->assertSame(3, $this->stringService->countWord('a,b,c')); + $this->assertSame(2, $this->stringService->countWord('Hello, World!')); + } + + public function testCountWordKeepsApostrophesAndUnicode() + { + $this->assertSame(2, $this->stringService->countWord("don't stop")); + $this->assertSame(2, $this->stringService->countWord('привет мир')); + $this->assertSame(1, $this->stringService->countWord('héllo')); + } + + public function testCountWordOfNothingIsZero() + { + $this->assertSame(0, $this->stringService->countWord('')); + $this->assertSame(0, $this->stringService->countWord(' ')); + $this->assertSame(0, $this->stringService->countWord('!!! ... ---')); + } + + public function testCountWordIgnoresATrailingSeparatorAfterANumber() + { + $this->assertSame(1, $this->stringService->countWord('100.')); + $this->assertSame(1, $this->stringService->countWord('100,')); + $this->assertSame(2, $this->stringService->countWord('100.,5')); + } + public function testStringServiceConformsToTheContract() { $this->assertInstanceOf(StringServiceInterface::class, $this->stringService); @@ -252,16 +978,16 @@ public function testApplicationBindsTheStringContractAndLegacyServiceNameToTheSa (new ReflectionClass(Container::class))->getConstructor()->invoke($app); $previous = Container::getInstance(); Container::setInstance($app); + $originalAppUrl = getenv('APP_URL'); try { - $originalAppUrl = getenv('APP_URL'); putenv('APP_URL=http://localhost'); (new FacadeLauncher($app))->register(); $contract = $app->get(StringServiceInterface::class); $this->assertInstanceOf(StringService::class, $contract); - $this->assertSame($contract, $app->get('str')); + $this->assertSame($contract, app('str')); $this->assertSame($contract, str()); } finally { Container::setInstance($previous);