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$tag>",
- $input
- );
+ $pattern = '/(' . preg_quote($keyword, '/') . ')/i';
+
+ return preg_replace($pattern . 'u', "<$tag>$1$tag>", $input)
+ ?? preg_replace($pattern, "<$tag>$1$tag>", $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);