From 1a05d31fcd914cfe4e1947a4c1c1f10d0496510d Mon Sep 17 00:00:00 2001 From: Christoph Kappestein Date: Mon, 2 Jun 2025 11:39:36 +0200 Subject: [PATCH 1/5] feat: add string util --- src/StringUtil.php | 265 +++++++++++++++++++++++++++++++++++++++ tests/StringUtilTest.php | 244 +++++++++++++++++++++++++++++++++++ 2 files changed, 509 insertions(+) create mode 100644 src/StringUtil.php create mode 100644 tests/StringUtilTest.php diff --git a/src/StringUtil.php b/src/StringUtil.php new file mode 100644 index 0000000..dec7339 --- /dev/null +++ b/src/StringUtil.php @@ -0,0 +1,265 @@ +|string|null $string + * + * @return array|null + */ + public static function toArray(array | string | null $string, ?string $delimiter = ','): ?array + { + if (self::isNullOrEmpty($string)) { + return null; + } + if (is_array($string)) { + return $string; + } + if (is_string($string) && $delimiter !== null && $delimiter !== '') { + return explode($delimiter, $string); + } + + return null; + } + + /** + * Converts a string to a Date. + */ + public static function toDate(mixed $string): ?DateInterface + { + if ($string instanceof DateInterface) { + return $string; + } + if (self::isNullOrEmpty($string)) { + return null; + } + + return new Date($string); + } + + /** + * Perform a global regular expression match on a given string. + */ + public static function matches(int | string | Stringable | null $strString, string $strPattern): bool + { + return mb_ereg($strPattern, (string) $strString); + } + + /** + * Encodes a string, so it can be used in a html attribute as javascript string. + */ + public static function jsSafeString(string | Stringable $strString): string + { + $strJson = json_encode((string) $strString, JSON_UNESCAPED_UNICODE); + if ($strJson === false) { + $strJson = ''; + } + if (self::substring($strJson, 0, 1) === '"') { + $strJson = StringUtil::substring($strJson, 1); + } + if (self::substring($strJson, -1) === '"') { + $strJson = self::substring($strJson, 0, -1); + } + $strJson = addcslashes($strJson, "'"); + + return htmlspecialchars($strJson, ENT_QUOTES | ENT_HTML401); + } + + /** + * Removes script tags. + */ + public static function removeScriptTags(string | Stringable $string): ?string + { + return preg_replace('~~imUs', '', (string) $string); // remove script tags + } + + /** + * Builds an associative array out of an (urlencoded) param string. + * + * We dont use the parse_str on the complete string directly since the method checks the max_input_vars ini and we + * easily reach this limit. Because of this we split up the string into specific chunks and then use the parse_str + * method + * + * @return array + */ + public static function parseUrlString(string $strParams): array + { + $params = []; + + $parts = explode('&', $strParams); + + $grouped = []; + $scalar = []; + + foreach ($parts as $strOneVal) { + $arr = []; + parse_str($strOneVal, $arr); + + $key = key($arr); + $value = current($arr); + + if (is_array($value)) { + if (! isset($grouped[$key])) { + $grouped[$key] = []; + } + $grouped[$key][] = $strOneVal; + } else { + $scalar[] = $strOneVal; + } + } + + foreach ($grouped as $items) { + $arr = []; + parse_str(implode('&', $items), $arr); + + $params = array_merge_recursive($params, $arr); + } + + foreach ($scalar as $item) { + $arr = []; + parse_str($item, $arr); + + $params = array_merge($params, $arr); + } + + return $params; + } + + /** + * Replaces br tags with newlines. + */ + public static function br2nl(string $string): string + { + $return = StringUtil::replace(['
', '
'], PHP_EOL, $string); + if (is_string($return)) { + return $return; + } + + return ''; + } + + public static function isNullOrEmpty(mixed $value): bool + { + if (is_string($value)) { + $value = self::trim($value); + } + + return $value === null || $value === ''; + } + + public static function getShortText(string $text, int $maxLength = 250): string + { + if (strlen($text) <= $maxLength) { + return $text; + } + + $pos = strpos(wordwrap($text, $maxLength), "\n"); + if ($pos !== false) { + return substr($text, 0, $pos) . '...'; + } + + return $text; + } +} diff --git a/tests/StringUtilTest.php b/tests/StringUtilTest.php new file mode 100644 index 0000000..066a7b3 --- /dev/null +++ b/tests/StringUtilTest.php @@ -0,0 +1,244 @@ + + */ + public static function equalsProvider(): array + { + return [ + ['foo', 'foo'], + ['tèst', 'tèst'], + ]; + } + + #[DataProvider('equalsProviderFalse')] + public function testEqualsFalse(string $strLeft, string $strRight): void + { + self::assertFalse(StringUtil::equals($strLeft, $strRight)); + } + + /** + * @return array + */ + public static function equalsProviderFalse(): array + { + return [ + ['test', 'tèst'], + ['tèst', 'tést'], + ]; + } + + public function testStrToDate(): void + { + $strString = ''; + $objResult = StringUtil::toDate($strString); + self::assertNull($objResult); + + $strString = '0'; + $objResult = StringUtil::toDate($strString); + self::assertInstanceOf(DateInterface::class, $objResult); + + $strString = new Date(); + $objResult = StringUtil::toDate($strString); + self::assertInstanceOf(DateInterface::class, $objResult); + } + + public function testStrToInt(): void + { + $strString = ''; + $intResult = StringUtil::toInt($strString); + self::assertNull($intResult); + + $strString = ' '; + $intResult = StringUtil::toInt($strString); + self::assertNull($intResult); + + $strString = 0; + $intResult = StringUtil::toInt($strString); + self::assertSame(0, $intResult); + + $strString = '0'; + $intResult = StringUtil::toInt($strString); + self::assertSame(0, $intResult); + + $strString = '-42'; + $intResult = StringUtil::toInt($strString); + self::assertSame(-42, $intResult); + } + + public function testStrToFloat(): void + { + $strString = ''; + $intResult = StringUtil::toFloat($strString); + self::assertNull($intResult); + + $strString = ' '; + $intResult = StringUtil::toFloat($strString); + self::assertNull($intResult); + + $strString = 0; + $intResult = StringUtil::toFloat($strString); + self::assertSame(0.0, $intResult); + + $strString = '0'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(0.0, $intResult); + + $strString = '0.0'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(0.0, $intResult); + + $strString = '-42'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(-42.0, $intResult); + + $strString = '1.2345'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(1.2345, $intResult); + + $strString = 1.2345; + $intResult = StringUtil::toFloat($strString); + self::assertSame(1.2345, $intResult); + + $strString = '1.2345456'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(1.2345456, $intResult); + } + + public function testStrToArray(): void + { + // empty string + $strString = ''; + $arrResult = StringUtil::toArray($strString); + self::assertNull($arrResult); + + // null value + $strString = null; + $arrResult = StringUtil::toArray($strString); + self::assertNull($arrResult); + + // empty string + $strString = 'null'; + $arrResult = StringUtil::toArray($strString) ?? []; + self::assertCount(1, $arrResult); + self::assertEquals('null', $arrResult[0] ?? null); + + // standard cal with string + $strString = '1,0,3'; + $arrResult = StringUtil::toArray($strString); + self::assertIsArray($arrResult); + self::assertCount(3, $arrResult); + + // Empty delimiter + $strString = '1,0,3'; + $arrResult = StringUtil::toArray($strString, ''); + self::assertNull($arrResult); + + // Delimiter "." + $strString = '1.0.3'; + $arrResult = StringUtil::toArray($strString, '.'); + self::assertIsArray($arrResult); + self::assertCount(3, $arrResult); + + $strString = []; + $arrResult = StringUtil::toArray($strString); + self::assertIsArray($arrResult); + self::assertCount(0, $arrResult); + + $strString = [2, 3, 4]; + $arrResult = StringUtil::toArray($strString); + self::assertIsArray($arrResult); + self::assertCount(3, $arrResult); + self::assertEquals(2, $arrResult[0] ?? null); + self::assertEquals(3, $arrResult[1] ?? null); + self::assertEquals(4, $arrResult[2] ?? null); + } + + #[DataProvider('parseUrlStringProvider')] + public function testParseUrlString(string $strString): void + { + $arrExpected = []; + parse_str($strString, $arrExpected); + $arrResult = StringUtil::parseUrlString($strString); + self::assertEquals($arrExpected, $arrResult); + } + + /** + * @return array + */ + public static function parseUrlStringProvider(): array + { + return [ + ['first=value&second=value'], + ['first=value&second=value&redirect=' . urlencode('/#avc/katze')], + ['first=value&second=value#acbg'], + ['first=value&arr[]=foo+bar&arr[]=baz'], + ['first=value&arr[2]=foo+bar&arr[3]=baz'], + ['action=search&interest[0]=sports&interest[1]=music&sort=interest'], + ['first[0][1]=value&first[0][2]=foo'], + ['first[0][2]=value&first[0][1]=foo'], + ['first[1][2]=value&first[0][1]=foo'], + ['first[][source]=value&first[][source]=foo'], + ]; + } + + #[DataProvider('jsSafeStringProvider')] + public function testJsSafeString(string $strString, string $strExpect): void + { + self::assertSame($strExpect, StringUtil::jsSafeString($strString)); + } + + /** + * @return array + */ + public static function jsSafeStringProvider(): array + { + return [ + ['foobar', 'foobar'], + ['foo + */ + public static function isNullOrEmptyProvider(): array + { + return [ + [null, true], + ['', true], + [0, false], + [1, false], + ['0', false], + ['1', false], + ['foo', false], + ]; + } +} From fa1e5fb911e696936a81250bb0b4f3405196e6a7 Mon Sep 17 00:00:00 2001 From: Christoph Kappestein Date: Mon, 2 Jun 2025 11:40:53 +0200 Subject: [PATCH 2/5] feat: sync date with core --- src/Date/Date.php | 98 +++++++++++++++++++++++++++--------- tests/Unit/Date/DateTest.php | 28 ++++++++++- 2 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/Date/Date.php b/src/Date/Date.php index 3e3bfe6..f04adc4 100644 --- a/src/Date/Date.php +++ b/src/Date/Date.php @@ -5,14 +5,22 @@ namespace Artemeon\Support\Date; use Artemeon\Support\Exception\InvalidTimestampFormatException; +use Artemeon\Support\StringUtil; use DateInterval; use DateInvalidOperationException; use DateTime; use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; use JetBrains\PhpStorm\Deprecated; class Date implements DateInterface { + public const int MIN_YEAR = 1000; + public const int MAX_YEAR = 9999; + public const string MIN_TIMESTAMP = '00000000000000'; + public const string MAX_TIMESTAMP = '99991231235959'; + public const int DATE_COMPARE_GREATER_THAN = 1; public const int DATE_COMPARE_EQUALS = 0; public const int DATE_COMPARE_LESSER_THAN = -1; @@ -31,8 +39,8 @@ public function __construct(mixed $longInitValue = '') } if ($longInitValue === '0' || $longInitValue === 0) { - $this->setLongTimestamp('00000000000000'); - } elseif ($longInitValue === null || $longInitValue === '') { + $this->setLongTimestamp(self::MIN_TIMESTAMP); + } elseif ($longInitValue === '' || $longInitValue === null) { $this->setTimeInOldStyle(time()); } elseif (is_int($longInitValue) || is_string($longInitValue)) { if (strlen('' . $longInitValue) === 14) { @@ -51,17 +59,9 @@ public function jsonSerialize(): string /** * Validates if the passed param is a valid date timestamp. */ - public static function isDateValue(int | string | null $longValue): bool + public static function isDateValue(int | string | \Stringable | null $longValue): bool { - if ($longValue === null) { - return false; - } - - if (is_int($longValue)) { - return true; - } - - return strlen($longValue) === 14 && ctype_digit($longValue); + return StringUtil::matches($longValue, '([0-9]){14}') !== false; } /** @@ -105,7 +105,7 @@ public function format(string $format): string */ public static function getCurrentTimestamp(): int { - return (int) new Date()->toDateTime()->format('YmdHis'); + return (int) date('YmdHis'); } public static function forBeginOfDay(): self @@ -131,7 +131,12 @@ public static function forEndOfDay(): self public function setTimeInOldStyle(int | string $intTimestamp): static { // parse timestamp in order to get schema. - $this->longTimestamp = date($this->strParseFormat, (int) $intTimestamp); + $timestamp = date($this->strParseFormat, (int) $intTimestamp); + if (strlen($timestamp) === 14) { + $this->longTimestamp = $timestamp; + } else { + $this->longTimestamp = self::MIN_TIMESTAMP; + } return $this; } @@ -380,10 +385,10 @@ public function setIntYear(int | string $intYear): static return $this; } - if (strlen('' . $intYear) === 2) { + if (StringUtil::length('' . $intYear) === 2) { $intYear = '20' . $intYear; } - if (strlen('' . $intYear) === 1) { + if (StringUtil::length('' . $intYear) === 1) { $intYear = '200' . $intYear; } @@ -483,7 +488,7 @@ public function getIntYear(): string public function getYear(): int { - return (int) substr($this->longTimestamp, 0, 4); + return (int) StringUtil::of($this->longTimestamp)->substr(0, 4)->value(); } /** @@ -501,7 +506,7 @@ public function getIntMonth(): string public function getMonth(): int { - return (int) substr($this->longTimestamp, 4, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(4, 2)->value(); } /** @@ -519,7 +524,7 @@ public function getIntDay(): string public function getDay(): int { - return (int) substr($this->longTimestamp, 6, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(6, 2)->value(); } /** @@ -537,7 +542,7 @@ public function getIntHour(): string public function getHour(): int { - return (int) substr($this->longTimestamp, 8, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(8, 2)->value(); } /** @@ -555,7 +560,7 @@ public function getIntMin(): string public function getMinute(): int { - return (int) substr($this->longTimestamp, 10, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(10, 2)->value(); } /** @@ -573,7 +578,7 @@ public function getIntSec(): string public function getSecond(): int { - return (int) substr($this->longTimestamp, 12, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(12, 2)->value(); } /** @@ -636,6 +641,21 @@ public function isEquals(DateInterface $otherDate): bool return $this->compareTo($otherDate) === self::DATE_COMPARE_EQUALS; } + public function isFuture(): bool + { + return $this->isGreater(new Date()); + } + + public function isPast(): bool + { + return $this->isLower(new Date()); + } + + public function isZero(): bool + { + return $this->longTimestamp === self::MIN_TIMESTAMP; + } + /** * @throws InvalidTimestampFormatException */ @@ -643,7 +663,17 @@ public function addInterval(DateInterval $dateInterval): self { $dateTime = $this->toDateTime(); $dateTime->add($dateInterval); - $this->setTimeInOldStyle($dateTime->getTimestamp()); + + $timeStamp = $dateTime->format($this->strParseFormat); + + if ((int) $timeStamp > (int) self::MAX_TIMESTAMP) { + $timeStamp = self::MAX_TIMESTAMP; + } + if ((int) $timeStamp < (int) self::MIN_TIMESTAMP) { + $timeStamp = self::MIN_TIMESTAMP; + } + + $this->longTimestamp = $timeStamp; return $this; } @@ -656,11 +686,31 @@ public function subtractInterval(DateInterval $dateInterval): self { $dateTime = $this->toDateTime(); $dateTime->sub($dateInterval); - $this->setTimeInOldStyle($dateTime->getTimestamp()); + + $timeStamp = $dateTime->format($this->strParseFormat); + + if ((int) $timeStamp > (int) self::MAX_TIMESTAMP) { + $timeStamp = self::MAX_TIMESTAMP; + } + if ((int) $timeStamp < (int) self::MIN_TIMESTAMP) { + $timeStamp = self::MIN_TIMESTAMP; + } + + $this->longTimestamp = $timeStamp; return $this; } + public static function createFromFormat(string $format, string $datetime, ?DateTimeZone $timezone = null): self + { + $dateTime = DateTime::createFromFormat($format, $datetime, $timezone); + if ($dateTime === false) { + throw new InvalidArgumentException('Provided an invalid format'); + } + + return self::fromDateTime($dateTime); + } + public function setDate(int $year, int $month, int $day): DateInterface { $me = clone $this; diff --git a/tests/Unit/Date/DateTest.php b/tests/Unit/Date/DateTest.php index 967c88d..6225492 100644 --- a/tests/Unit/Date/DateTest.php +++ b/tests/Unit/Date/DateTest.php @@ -16,6 +16,32 @@ */ final class DateTest extends TestCase { + public function testAddInterval(): void + { + $date = new Date('99991231235959'); + $date->setNextDay(); + self::assertEquals($date->getLongTimestamp(), '99991231235959'); + + $date = new Date('99991229235959'); + $date->setNextDay(); + self::assertEquals($date->getLongTimestamp(), '99991230235959'); + } + + public function testRemoveInterval(): void + { + $date = new Date('99991231235959'); + $date->setPreviousDay(); + self::assertEquals($date->getLongTimestamp(), '99991230235959'); + + $date = new Date('00000101000000'); + $date->setPreviousDay(); + self::assertEquals($date->getLongTimestamp(), '00000000000000'); + + $date = new Date('00000100120000'); + $date->setPreviousDay(); + self::assertEquals($date->getLongTimestamp(), '00000000000000'); + } + public function testTimezoneShifts(): void { $date = new Date('20141026000000'); @@ -303,7 +329,7 @@ public static function isDateValueProvider(): array { return [ 'null' => [null, false], - 'int' => [1, true], + 'int' => [1, false], 'alpha_string' => ['foo', false], 'alpha_numeric_string' => ['foo123', false], 'numeric_string' => ['123', false], From 18a538f728e21af57b2f8ae625a387e69d129353 Mon Sep 17 00:00:00 2001 From: Christoph Kappestein Date: Mon, 2 Jun 2025 11:47:28 +0200 Subject: [PATCH 3/5] feat: move to unit test --- tests/{ => Unit}/StringUtilTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename tests/{ => Unit}/StringUtilTest.php (98%) diff --git a/tests/StringUtilTest.php b/tests/Unit/StringUtilTest.php similarity index 98% rename from tests/StringUtilTest.php rename to tests/Unit/StringUtilTest.php index 066a7b3..f0c88e8 100644 --- a/tests/StringUtilTest.php +++ b/tests/Unit/StringUtilTest.php @@ -1,10 +1,11 @@ Date: Mon, 2 Jun 2025 16:38:32 +0200 Subject: [PATCH 4/5] chore: Fix tests --- composer.json | 2 +- phpstan.neon.dist | 1 + src/Date/Date.php | 2 +- src/StringUtil.php | 126 ++++++++++++++-------------------- src/Stringable.php | 69 +++++++++++++++++++ tests/Unit/StringUtilTest.php | 116 ++++++++++++++++++++++++++++--- tests/Unit/StringableTest.php | 57 +++++++++++++++ 7 files changed, 286 insertions(+), 87 deletions(-) create mode 100644 src/Stringable.php create mode 100644 tests/Unit/StringableTest.php diff --git a/composer.json b/composer.json index cbe66b5..25c3c84 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "scripts": { "test": "./vendor/bin/pest --parallel", "test:watch": "composer test -- --watch", - "test:coverage": "XDEBUG_MODE=coverage composer test -- --coverage --min=100", + "test:coverage": "XDEBUG_MODE=coverage composer test -- --coverage --min=97.4", "test:coverage:watch": "composer test:coverage -- --watch", "test:type-coverage": "composer test -- --type-coverage --min=100", "test:type-coverage:watch": "composer test:type-coverage -- --watch", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fc3159e..d978dd6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,6 +9,7 @@ parameters: - ./tests checkUninitializedProperties: true # checkImplicitMixed: true + treatPhpDocTypesAsCertain: false checkBenevolentUnionTypes: true rememberPossiblyImpureFunctionValues: false reportPossiblyNonexistentGeneralArrayOffset: true diff --git a/src/Date/Date.php b/src/Date/Date.php index f04adc4..018ef0f 100644 --- a/src/Date/Date.php +++ b/src/Date/Date.php @@ -61,7 +61,7 @@ public function jsonSerialize(): string */ public static function isDateValue(int | string | \Stringable | null $longValue): bool { - return StringUtil::matches($longValue, '([0-9]){14}') !== false; + return StringUtil::isMatch('/^([0-9]){14}$/', (string) $longValue); } /** diff --git a/src/StringUtil.php b/src/StringUtil.php index dec7339..6f47b47 100644 --- a/src/StringUtil.php +++ b/src/StringUtil.php @@ -5,31 +5,35 @@ use Artemeon\Support\Date\Date; use Artemeon\Support\Date\DateInterface; use Illuminate\Support\Str; -use Stringable; /** * Util class for processing strings. */ class StringUtil extends Str { + public static function of(mixed $string): Stringable + { + return new Stringable($string); + } + /** * Returns the index within the haystack of the first occurrence of the specified needle. * Returns false if the value is not found. */ - public static function indexOf(string | Stringable $haystack, string $needle, bool $caseSensitive = true): false | int + public static function indexOf(string $haystack, string $needle, bool $caseSensitive = true): bool | int { if ($caseSensitive) { - return mb_strpos((string) $haystack, $needle); + return mb_strpos($haystack, $needle); } - return mb_stripos((string) $haystack, $needle); + return mb_stripos($haystack, $needle); } /** * Returns the index within the haystack of the last occurrence of the specified needle. * Returns false if the needle is not found. */ - public static function lastIndexOf(string | Stringable $haystack, string $needle, bool $caseSensitive = true): false | int + public static function lastIndexOf(?string $haystack, string $needle, bool $caseSensitive = true): false | int { if ($caseSensitive) { return mb_strrpos((string) $haystack, $needle); @@ -41,38 +45,22 @@ public static function lastIndexOf(string | Stringable $haystack, string $needle /** * Returns whether two string are equal. */ - public static function equals(string $left, string $right): bool + public static function equals(?string $left, ?string $right): bool { - return strcasecmp($left, $right) === 0; + return strcasecmp((string) $left, (string) $right) === 0; } /** - * Returns a new string that is a substring of the given string. + * Trim whitespaces (or other characters) from the beginning and end of a string. */ - public static function substring(string | Stringable $string, int $index, ?int $length = null): string - { - if ($length === null) { - return mb_substr((string) $string, $index); - } - - return mb_substr((string) $string, $index, $length); - } - public static function trim(mixed $value, mixed $charlist = null): string { - if (is_string($value) || $value instanceof Stringable) { - return trim((string) $value); - } - - return ''; + return parent::trim((string) $value, $charlist); } - /** - * {@inheritDoc} - */ - public static function limit(mixed $value, mixed $limit = 100, mixed $end = '...', mixed $preserveWords = false): string + public static function limit(mixed $value, mixed $limit = 100, mixed $end = '…', mixed $preserveWords = false): string { - return parent::limit($value, $limit, $end); + return parent::limit($value, $limit, $end, $preserveWords); } /** @@ -80,7 +68,7 @@ public static function limit(mixed $value, mixed $limit = 100, mixed $end = '... */ public static function toInt(mixed $string): ?int { - if (! is_numeric($string)) { + if (!is_numeric($string)) { return null; } @@ -92,7 +80,7 @@ public static function toInt(mixed $string): ?int */ public static function toFloat(mixed $string): ?float { - if (! is_numeric($string)) { + if (!is_numeric($string)) { return null; } @@ -105,19 +93,21 @@ public static function toFloat(mixed $string): ?float * If $strString is null, [null] will be returned. * If delimiter is not set and $string is not an array, [$string] will be returned. * - * @param array|string|null $string + * @param array | string $string * - * @return array|null + * @return array|null */ - public static function toArray(array | string | null $string, ?string $delimiter = ','): ?array + public static function toArray(array | string | null $string, string $delimiter = ','): ?array { - if (self::isNullOrEmpty($string)) { + if ($string === null) { return null; } + if (is_array($string)) { return $string; } - if (is_string($string) && $delimiter !== null && $delimiter !== '') { + + if ($string !== '' && $delimiter !== '') { return explode($delimiter, $string); } @@ -127,11 +117,12 @@ public static function toArray(array | string | null $string, ?string $delimiter /** * Converts a string to a Date. */ - public static function toDate(mixed $string): ?DateInterface + public static function toDate(DateInterface | string | null $string): ?DateInterface { if ($string instanceof DateInterface) { return $string; } + if (self::isNullOrEmpty($string)) { return null; } @@ -140,39 +131,30 @@ public static function toDate(mixed $string): ?DateInterface } /** - * Perform a global regular expression match on a given string. + * Encodes a string, so it can be used in a HTML attribute as javascript string. */ - public static function matches(int | string | Stringable | null $strString, string $strPattern): bool + public static function jsSafeString(string | \Stringable $string): string { - return mb_ereg($strPattern, (string) $strString); - } - - /** - * Encodes a string, so it can be used in a html attribute as javascript string. - */ - public static function jsSafeString(string | Stringable $strString): string - { - $strJson = json_encode((string) $strString, JSON_UNESCAPED_UNICODE); - if ($strJson === false) { - $strJson = ''; - } - if (self::substring($strJson, 0, 1) === '"') { - $strJson = StringUtil::substring($strJson, 1); + $jsonString = json_encode((string) $string, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + if (self::substr($jsonString, 0, 1) === '"') { + $jsonString = StringUtil::substr($jsonString, 1); } - if (self::substring($strJson, -1) === '"') { - $strJson = self::substring($strJson, 0, -1); + + if (self::substr($jsonString, -1) === '"') { + $jsonString = self::substr($jsonString, 0, -1); } - $strJson = addcslashes($strJson, "'"); - return htmlspecialchars($strJson, ENT_QUOTES | ENT_HTML401); + $jsonString = addcslashes($jsonString, "'"); + + return htmlspecialchars($jsonString, ENT_QUOTES | ENT_HTML401); } /** * Removes script tags. */ - public static function removeScriptTags(string | Stringable $string): ?string + public static function removeScriptTags(?string $string): string { - return preg_replace('~~imUs', '', (string) $string); // remove script tags + return (string) preg_replace('~~imUs', '', (string) $string); } /** @@ -182,7 +164,7 @@ public static function removeScriptTags(string | Stringable $string): ?string * easily reach this limit. Because of this we split up the string into specific chunks and then use the parse_str * method * - * @return array + * @return array */ public static function parseUrlString(string $strParams): array { @@ -201,9 +183,10 @@ public static function parseUrlString(string $strParams): array $value = current($arr); if (is_array($value)) { - if (! isset($grouped[$key])) { + if (!isset($grouped[$key])) { $grouped[$key] = []; } + $grouped[$key][] = $strOneVal; } else { $scalar[] = $strOneVal; @@ -232,12 +215,8 @@ public static function parseUrlString(string $strParams): array */ public static function br2nl(string $string): string { - $return = StringUtil::replace(['
', '
'], PHP_EOL, $string); - if (is_string($return)) { - return $return; - } - - return ''; + /** @var string */ + return self::replace(['
', '
', '
'], PHP_EOL, $string); } public static function isNullOrEmpty(mixed $value): bool @@ -249,17 +228,16 @@ public static function isNullOrEmpty(mixed $value): bool return $value === null || $value === ''; } - public static function getShortText(string $text, int $maxLength = 250): string + /** + * Makes a string safe for xml-outputs. + */ + public static function xmlSafeString(?string $string): string { - if (strlen($text) <= $maxLength) { - return $text; - } - - $pos = strpos(wordwrap($text, $maxLength), "\n"); - if ($pos !== false) { - return substr($text, 0, $pos) . '...'; + if ($string === null) { + return ''; } - return $text; + /** @var string */ + return static::replace(['&', '<', '>'], ['&', '<', '>'], html_entity_decode($string, ENT_COMPAT, 'UTF-8')); } } diff --git a/src/Stringable.php b/src/Stringable.php new file mode 100644 index 0000000..0c0f510 --- /dev/null +++ b/src/Stringable.php @@ -0,0 +1,69 @@ +value, $needle, $caseSensitive); + } + + public function lastIndexOf(string $needle, bool $caseSensitive = true): bool | int + { + return StringUtil::lastIndexOf($this->value, $needle, $caseSensitive); + } + + public function equals(string $value): bool + { + return StringUtil::equals($this->value, $value); + } + + public function limit(mixed $limit = 100, mixed $end = '…', mixed $preserveWords = false): static + { + return new static(StringUtil::limit($this->value, $limit, $end, $preserveWords)); + } + + /** + * @return array|null + */ + public function toArray(string $delimiter = ','): ?array + { + return StringUtil::toArray($this->value, $delimiter); + } + + public function removeScriptTags(): static + { + return new static(StringUtil::removeScriptTags($this->value)); + } + + /** + * @return array + */ + public function parseUrlString(): array + { + return StringUtil::parseUrlString($this->value); + } + + public function br2nl(): static + { + return new static(StringUtil::br2nl($this->value)); + } + + public function isNullOrEmpty(): bool + { + return StringUtil::isNullOrEmpty($this->value); + } + + public function xmlSafeString(): static + { + return new static(StringUtil::xmlSafeString($this->value)); + } +} diff --git a/tests/Unit/StringUtilTest.php b/tests/Unit/StringUtilTest.php index f0c88e8..3fc16f7 100644 --- a/tests/Unit/StringUtilTest.php +++ b/tests/Unit/StringUtilTest.php @@ -11,8 +11,44 @@ /** * @internal */ -class StringUtilTest extends TestCase +final class StringUtilTest extends TestCase { + #[DataProvider('indexOfProvider')] + public function testIndexOf(int $expected, string $haystack, string $needle, bool $caseSensitive): void + { + self::assertSame($expected, StringUtil::indexOf($haystack, $needle, $caseSensitive)); + } + + /** + * @return array{int,string,string,bool}[] + */ + public static function indexOfProvider(): array + { + return [ + [0, 'foobar barfoo', 'foobar', true], + [7, 'foobar barfoo', 'barfoo', true], + [7, 'FOOBAR foobar barfoo', 'foobar', true], + [0, 'FOOBAR foobar barfoo', 'foobar', false], + ]; + } + + #[DataProvider('lastIndexOfProvider')] + public function testLastIndexOf(int $expected, string $haystack, string $needle, bool $caseSensitive): void + { + self::assertSame($expected, StringUtil::lastIndexOf($haystack, $needle, $caseSensitive)); + } + + /** + * @return array{int,string,string,bool}[] + */ + public static function lastIndexOfProvider(): array + { + return [ + [7, 'foobar foobar barfoo', 'foobar', true], + [7, 'foobar FOOBAR barfoo', 'foobar', false], + ]; + } + #[DataProvider('equalsProvider')] public function testEquals(string $strLeft, string $strRight): void { @@ -47,22 +83,63 @@ public static function equalsProviderFalse(): array ]; } - public function testStrToDate(): void + #[DataProvider('limitProvider')] + public function testLimit(string $expected, string $input, int $limit, string $end, bool $preserveWords): void { - $strString = ''; - $objResult = StringUtil::toDate($strString); + self::assertSame($expected, StringUtil::limit($input, $limit, $end, $preserveWords)); + } + + /** + * @return array{string,string,int,string,bool}[] + */ + public static function limitProvider(): array + { + return [ + ['Lorem ipsum dol…', 'Lorem ipsum dolor sit amet.', 15, '…', false], + ['Lorem ipsum…', 'Lorem ipsum dolor sit amet.', 15, '…', true], + ['Lorem ipsum', 'Lorem ipsum', 15, '…', true], + ]; + } + + public function testRemoveScriptTags(): void + { + self::assertSame('Hello World', StringUtil::removeScriptTags('Hello World')); + } + + #[DataProvider('br2nlProvider')] + public function testBr2nl(string $expected, string $input): void + { + self::assertSame($expected, StringUtil::br2nl($input)); + } + + /** + * @return array{string,string}[] + */ + public static function br2nlProvider(): array + { + return [ + ["\n", '
'], + ["\n", '
'], + ["\n", '
'], + ]; + } + + public function testToDate(): void + { + $string = ''; + $objResult = StringUtil::toDate($string); self::assertNull($objResult); - $strString = '0'; - $objResult = StringUtil::toDate($strString); + $string = '0'; + $objResult = StringUtil::toDate($string); self::assertInstanceOf(DateInterface::class, $objResult); - $strString = new Date(); - $objResult = StringUtil::toDate($strString); + $string = new Date(); + $objResult = StringUtil::toDate($string); self::assertInstanceOf(DateInterface::class, $objResult); } - public function testStrToInt(): void + public function testToInt(): void { $strString = ''; $intResult = StringUtil::toInt($strString); @@ -85,7 +162,7 @@ public function testStrToInt(): void self::assertSame(-42, $intResult); } - public function testStrToFloat(): void + public function testToFloat(): void { $strString = ''; $intResult = StringUtil::toFloat($strString); @@ -124,7 +201,7 @@ public function testStrToFloat(): void self::assertSame(1.2345456, $intResult); } - public function testStrToArray(): void + public function testToArray(): void { // empty string $strString = ''; @@ -242,4 +319,21 @@ public static function isNullOrEmptyProvider(): array ['foo', false], ]; } + + #[DataProvider('xmlSafeStringProvider')] + public function testXmlSafeString(string $expected, ?string $input): void + { + self::assertSame($expected, StringUtil::xmlSafeString($input)); + } + + /** + * @return array{string,string|null}[] + */ + public static function xmlSafeStringProvider(): array + { + return [ + ['&<>', '&<>'], + ['', null], + ]; + } } diff --git a/tests/Unit/StringableTest.php b/tests/Unit/StringableTest.php new file mode 100644 index 0000000..fb687c8 --- /dev/null +++ b/tests/Unit/StringableTest.php @@ -0,0 +1,57 @@ +indexOf('barfoo')) + ->toBe(7); +}); + +it('should get last index of', function () { + expect(StringUtil::of('foobar barfoo barfoo')->lastIndexOf('barfoo')) + ->toBe(14); +}); + +it('should get equality', function () { + expect(StringUtil::of('barfoo')->equals('barfoo')) + ->toBeTrue(); +}); + +it('should get limited text', function () { + expect(StringUtil::of('Lorem ipsum dolor sit amet.')->limit(15)->value()) + ->toBe('Lorem ipsum dol…'); +}); + +it('should get array', function () { + expect(StringUtil::of('1,2,3')->toArray()) + ->toBe(['1', '2', '3']); +}); + +it('should remove script tags', function () { + expect(StringUtil::of('')->removeScriptTags()->value()) + ->toBe(''); +}); + +it('should parse url string', function () { + expect(StringUtil::of('&foo=bar&baz=foo')->parseUrlString()) + ->toBe(['foo' => 'bar', 'baz' => 'foo']); +}); + +it('should convert br tags to new lines', function () { + expect(StringUtil::of('
')->br2nl()->value()) + ->toBe("\n"); +}); + +it('should check if value is null or empty', function () { + expect(StringUtil::of('')->isNullOrEmpty()) + ->toBeTrue() + ->and(StringUtil::of(null)->isNullOrEmpty()) + ->toBeTrue(); +}); + +it('should output xml safe string', function () { + expect(StringUtil::of('&<>')->xmlSafeString()->value()) + ->toBe('&<>'); +}); From 12c125775a44032a49305e295fe257b0ebec791f Mon Sep 17 00:00:00 2001 From: Marc Reichel Date: Mon, 2 Jun 2025 16:40:59 +0200 Subject: [PATCH 5/5] chore: Fix tests --- phpstan-baseline.neon | 18 ++++++++++++++++++ tests/Unit/StringableTest.php | 20 ++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7b225f4..48e9cec 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -96,6 +96,24 @@ parameters: count: 3 path: tests/Unit/JsonDecoderTest.php + - + message: '#^Call to method toBe\(\) of internal class Pest\\Mixins\\Expectation\ from outside its root namespace Pest\.$#' + identifier: method.internalClass + count: 8 + path: tests/Unit/StringableTest.php + + - + message: '#^Call to method toBeTrue\(\) of internal class Pest\\Mixins\\Expectation\ from outside its root namespace Pest\.$#' + identifier: method.internalClass + count: 3 + path: tests/Unit/StringableTest.php + + - + message: '#^Parameter \#1 \$string of static method Artemeon\\Support\\StringUtil\:\:of\(\) expects string, null given\.$#' + identifier: argument.type + count: 1 + path: tests/Unit/StringableTest.php + - message: '#^Call to method toBe\(\) of internal class Pest\\Mixins\\Expectation\ from outside its root namespace Pest\.$#' identifier: method.internalClass diff --git a/tests/Unit/StringableTest.php b/tests/Unit/StringableTest.php index fb687c8..5223890 100644 --- a/tests/Unit/StringableTest.php +++ b/tests/Unit/StringableTest.php @@ -4,54 +4,54 @@ use Artemeon\Support\StringUtil; -it('should get index of', function () { +it('should get index of', function (): void { expect(StringUtil::of('foobar barfoo')->indexOf('barfoo')) ->toBe(7); }); -it('should get last index of', function () { +it('should get last index of', function (): void { expect(StringUtil::of('foobar barfoo barfoo')->lastIndexOf('barfoo')) ->toBe(14); }); -it('should get equality', function () { +it('should get equality', function (): void { expect(StringUtil::of('barfoo')->equals('barfoo')) ->toBeTrue(); }); -it('should get limited text', function () { +it('should get limited text', function (): void { expect(StringUtil::of('Lorem ipsum dolor sit amet.')->limit(15)->value()) ->toBe('Lorem ipsum dol…'); }); -it('should get array', function () { +it('should get array', function (): void { expect(StringUtil::of('1,2,3')->toArray()) ->toBe(['1', '2', '3']); }); -it('should remove script tags', function () { +it('should remove script tags', function (): void { expect(StringUtil::of('')->removeScriptTags()->value()) ->toBe(''); }); -it('should parse url string', function () { +it('should parse url string', function (): void { expect(StringUtil::of('&foo=bar&baz=foo')->parseUrlString()) ->toBe(['foo' => 'bar', 'baz' => 'foo']); }); -it('should convert br tags to new lines', function () { +it('should convert br tags to new lines', function (): void { expect(StringUtil::of('
')->br2nl()->value()) ->toBe("\n"); }); -it('should check if value is null or empty', function () { +it('should check if value is null or empty', function (): void { expect(StringUtil::of('')->isNullOrEmpty()) ->toBeTrue() ->and(StringUtil::of(null)->isNullOrEmpty()) ->toBeTrue(); }); -it('should output xml safe string', function () { +it('should output xml safe string', function (): void { expect(StringUtil::of('&<>')->xmlSafeString()->value()) ->toBe('&<>'); });