Inital commit after splitting vom main project
PHP Composer / build (push) Successful in 35s

This commit is contained in:
René Halberstadt
2026-07-26 17:18:28 +02:00
commit 0b6dcce287
23 changed files with 6065 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
name: PHP Composer
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate composer.json and composer.lock
run: composer validate --strict
- name: Cache Composer packages
id: composer-cache
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: PHPStan
run: composer phpstan
- name: Code style check
run: composer cs-check
- name: Tests
run: composer test
+3
View File
@@ -0,0 +1,3 @@
/vendor/
/.php-cs-fixer.cache
/.phpunit.result.cache
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
$finder = Finder::create()
->in([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->name('*.php')
->notName('*.blade.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
return (new Config())
->setRiskyAllowed(true)
->setRules([
'@PER-CS2.0' => true,
'@PHP83Migration' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
'single_quote' => true,
'explicit_string_variable' => true,
'array_syntax' => ['syntax' => 'short'],
'trim_array_spaces' => true,
'no_whitespace_before_comma_in_array' => true,
'whitespace_after_comma_in_array' => ['ensure_single_space' => true],
'declare_strict_types' => true,
'strict_param' => true,
'strict_comparison' => true,
'phpdoc_align' => ['align' => 'left'],
'phpdoc_order' => true,
'phpdoc_trim' => true,
'phpdoc_scalar' => true,
'no_superfluous_phpdoc_tags' => ['remove_inheritdoc' => true],
'yoda_style' => false,
'no_useless_else' => true,
'no_useless_return' => true,
'concat_space' => ['spacing' => 'one'],
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try', 'if', 'foreach', 'for', 'while']],
])
->setFinder($finder);
+9
View File
@@ -0,0 +1,9 @@
FROM php:8.3-cli-alpine
RUN apk add --no-cache git unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN mkdir -p /tmp/composer && chown 1000:1000 /tmp/composer
WORKDIR /app
+29
View File
@@ -0,0 +1,29 @@
PHP = docker compose run --rm php
.PHONY: install test phpstan cs-fix cs-check audit shell build
build:
docker compose build
install:
$(PHP) composer install
test:
$(PHP) vendor/bin/phpunit --testsuite Unit --colors=always
phpstan:
$(PHP) vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=512M
cs-fix:
$(PHP) vendor/bin/php-cs-fixer fix --ansi
cs-check:
$(PHP) vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
audit:
$(PHP) composer audit
ci: audit phpstan cs-check test
shell:
docker compose run --rm php sh
+194
View File
@@ -0,0 +1,194 @@
# tez-utils-str
Focused PHP string utilities: case conversion, template interpolation, masking, cryptographically secure random strings, URL slugs, and multibyte-aware truncation. PHP 8.3+, no dependencies.
## Installation
```bash
composer require tez/utils-str
```
## Components
| Class | Purpose |
|-------|---------|
| `CaseConverter` | Converts between camelCase, PascalCase, snake_case, kebab-case, SCREAMING_SNAKE_CASE |
| `Interpolate` | Replaces `{placeholders}` in template strings, supports dot-notation |
| `Mask` | Masks emails, credit cards, IBANs, and arbitrary strings |
| `Random` | Cryptographically secure hex tokens, alphanumeric, and custom-charset strings |
| `Slugify` | Converts text to URL-safe slugs with transliteration |
| `Truncate` | Multibyte-aware truncation with optional word-boundary snapping |
---
## CaseConverter
Converts any supported format (camelCase, PascalCase, snake_case, kebab-case, SCREAMING_SNAKE_CASE) into any other. All formats are interchangeable.
```php
use Tez\Utils\Str\CaseConverter;
CaseConverter::toSnake('userProfileName'); // 'user_profile_name'
CaseConverter::toSnake('UserProfileName'); // 'user_profile_name'
CaseConverter::toSnake('user-profile-name'); // 'user_profile_name'
CaseConverter::toSnake('USER_PROFILE_NAME'); // 'user_profile_name'
CaseConverter::toCamel('user_profile_name'); // 'userProfileName'
CaseConverter::toPascal('user-profile-name'); // 'UserProfileName'
CaseConverter::toKebab('userProfileName'); // 'user-profile-name'
CaseConverter::toScreamingSnake('userId'); // 'USER_ID'
// Acronyms are handled correctly
CaseConverter::toSnake('HTMLParser'); // 'html_parser'
```
---
## Interpolate
Replaces `{placeholders}` in a template string with values from an array. Supports dot-notation for nested keys. Throws `InvalidArgumentException` for missing keys by default; a fallback can be provided.
```php
use Tez\Utils\Str\Interpolate;
Interpolate::render(
'Hello {name}, you have {count} messages.',
['name' => 'Anna', 'count' => 5],
);
// 'Hello Anna, you have 5 messages.'
// Nested keys via dot-notation
Interpolate::render(
'Welcome, {user.name}!',
['user' => ['name' => 'Clara']],
);
// 'Welcome, Clara!'
// Fallback for missing keys instead of throwing
Interpolate::render('Hello {missing}!', [], fallback: 'N/A');
// 'Hello N/A!'
// Custom delimiters
Interpolate::render('Hello :name!', ['name' => 'Bob'], prefix: ':', suffix: '');
// 'Hello Bob!'
```
---
## Mask
Masks sensitive strings for display in logs, UIs, and API responses.
```php
use Tez\Utils\Str\Mask;
// Email — keeps first character of the local part
Mask::email('test@example.com'); // 't***@example.com'
Mask::email('administrator@domain.org'); // 'a***@domain.org'
// Credit card — keeps last 4 digits, strips spaces/hyphens
Mask::creditCard('4111111111111111'); // '**** **** **** 1111'
Mask::creditCard('4000-0000-0000-9999'); // '**** **** **** 9999'
// IBAN — keeps country code + check digits (first 4) and last 2 characters
Mask::iban('DE89370400440532013000'); // 'DE89 **** **** **** **** 00'
Mask::iban('DE89 3704 0044 0532 0130 00'); // 'DE89 **** **** **** **** 00'
// Custom — configurable visible characters at start and end
Mask::custom('MySuperSecret', start: 2, end: 2); // 'My*********et'
Mask::custom('HelloWorld', start: 2, end: 0); // 'He********'
Mask::custom('HelloWorld', start: 2, end: 2, char: '#'); // 'He######ld'
```
---
## Random
Generates cryptographically secure random strings using `random_bytes()`. Uses rejection sampling for uniform distribution — no modulo bias.
```php
use Tez\Utils\Str\Random;
// Hex token (lowercase [0-9a-f])
Random::token(); // 32-character hex string (default)
Random::token(64); // 64-character hex string
Random::token(7); // odd lengths are supported
// Alphanumeric [a-zA-Z0-9]
Random::alphanumeric(); // 32 characters (default)
Random::alphanumeric(16); // 16 characters
// Custom character set
Random::charset(12, '0123456789'); // 12-digit numeric string
Random::charset(8, 'ABCDE'); // 8 characters from 'ABCDE'
```
Throws `InvalidArgumentException` when `$length < 1` or the charset is empty.
---
## Slugify
Converts text to a URL-safe slug. Transliterates common non-ASCII characters (German umlauts, French accents, Spanish ñ, Nordic æøå, and more) before lowercasing and replacing non-alphanumeric characters.
```php
use Tez\Utils\Str\Slugify;
Slugify::slug('Hello World'); // 'hello-world'
Slugify::slug('Ärger mit Umlauten!'); // 'aerger-mit-umlauten'
Slugify::slug('Straße'); // 'strasse'
Slugify::slug('Crème brûlée'); // 'creme-brulee'
Slugify::slug('Cañon'); // 'canon'
Slugify::slug('foo/bar::baz'); // 'foo-bar-baz'
Slugify::slug('PHP 8.3'); // 'php-8-3'
Slugify::slug('---hello---'); // 'hello'
// Custom separator
Slugify::slug('Hello World', '_'); // 'hello_world'
```
---
## Truncate
Multibyte-aware truncation. By default snaps back to the last complete word so no word is split. The total result length (including suffix) never exceeds `$limit`.
```php
use Tez\Utils\Str\Truncate;
// Word-boundary truncation (default)
Truncate::chars('The quick brown fox jumps over the lazy dog', 20);
// 'The quick brown fox…'
Truncate::chars('The quick brown fox', 15);
// 'The quick…' ← snapped back to last complete word
// Exact cut (no word-boundary snapping)
Truncate::chars('The quick brown fox', 14, wordBoundary: false);
// 'The quick bro…'
// Custom suffix
Truncate::chars('The quick brown fox jumps', 22, suffix: ' [...]');
// 'The quick brown [...]'
// No suffix
Truncate::chars('The quick brown fox', 9, suffix: '');
// 'The quick'
// Multibyte-aware — characters, not bytes
Truncate::chars('Ärger mit Umlauten', 6);
// 'Ärger…'
```
Throws `InvalidArgumentException` when `$limit` is shorter than the suffix length.
---
## Requirements
- PHP 8.3+
- No runtime dependencies
## License
MIT
+31
View File
@@ -0,0 +1,31 @@
{
"name": "tez/utils-str",
"type": "library",
"description": "String utility helpers",
"license": "MIT",
"autoload": {
"psr-4": {
"Tez\\Utils\\Str\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tez\\Utils\\Tests\\Str\\": "tests/"
}
},
"require": {
"php": ">=8.3"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"jetbrains/phpstorm-attributes": "^1.0",
"phpstan/phpstan": "^2.0",
"friendsofphp/php-cs-fixer": "^3.0"
},
"scripts": {
"cs-fix": "php-cs-fixer fix",
"cs-check": "php-cs-fixer fix --dry-run --diff",
"phpstan": "phpstan analyse",
"test": "phpunit --testsuite Unit --colors=always"
}
}
Generated
+4566
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
services:
php:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/app
- composer-cache:/tmp/composer
user: "${UID:-1000}:${GID:-1000}"
environment:
COMPOSER_HOME: /tmp/composer
volumes:
composer-cache:
+8
View File
@@ -0,0 +1,8 @@
parameters:
level: 9
paths:
- src
- tests
treatPhpDocTypesAsCertain: false
parallel:
maximumNumberOfProcesses: 1
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Str;
final class CaseConverter
{
/** Converts to camelCase. */
public static function toCamel(string $input): string
{
$words = self::words($input);
if ($words === []) {
return '';
}
return $words[0] . implode('', array_map('ucfirst', \array_slice($words, 1)));
}
/** Converts to PascalCase. */
public static function toPascal(string $input): string
{
return implode('', array_map('ucfirst', self::words($input)));
}
/** Converts to snake_case. */
public static function toSnake(string $input): string
{
return implode('_', self::words($input));
}
/** Converts to kebab-case. */
public static function toKebab(string $input): string
{
return implode('-', self::words($input));
}
/** Converts to SCREAMING_SNAKE_CASE. */
public static function toScreamingSnake(string $input): string
{
return mb_strtoupper(implode('_', self::words($input)));
}
/**
* Splits any supported format into lowercase words.
*
* Handles: camelCase, PascalCase, snake_case, kebab-case, SCREAMING_SNAKE_CASE.
*
* @return list<string>
*/
private static function words(string $input): array
{
// Insert underscore before transitions from lowercase/digit to uppercase
$input = preg_replace('/([a-z\d])([A-Z])/', '$1_$2', $input) ?? $input;
// Insert underscore before transitions from a run of uppercase into mixed (e.g. HTMLParser → HTML_Parser)
$input = preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1_$2', $input) ?? $input;
// Split on underscores, hyphens, and whitespace
$words = preg_split('/[_\-\s]+/', $input) ?: [];
return array_values(array_filter(
array_map('mb_strtolower', $words),
static fn(string $w): bool => $w !== '',
));
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Str;
final class Interpolate
{
/**
* Replaces placeholders in a template string with values from an array.
*
* Supports dot-notation for nested keys: "{user.name}" → $data['user']['name'].
* By default throws \InvalidArgumentException for missing keys.
* Pass a $fallback string to use it instead of throwing.
*
* @param array<string, mixed> $data
* @throws \InvalidArgumentException when a key is missing and no fallback is set
*/
public static function render(
string $template,
array $data,
string $prefix = '{',
string $suffix = '}',
?string $fallback = null,
): string {
$prefixQ = preg_quote($prefix, '/');
$suffixQ = preg_quote($suffix, '/');
$keyPattern = $suffix !== ''
? '[^' . $prefixQ . $suffixQ . ']+'
: '[a-zA-Z0-9_.]+';
return (string) preg_replace_callback(
'/' . $prefixQ . '(' . $keyPattern . ')' . $suffixQ . '/',
static function (array $matches) use ($data, $fallback): string {
$key = $matches[1];
$value = self::resolve($key, $data);
if ($value === null) {
if ($fallback !== null) {
return $fallback;
}
throw new \InvalidArgumentException(
sprintf('Missing key "%s" in template data.', $key),
);
}
return is_scalar($value) ? (string) $value : '';
},
$template,
);
}
/**
* Resolves a dot-notation key against a nested array.
*
* @param array<string, mixed> $data
*/
private static function resolve(string $key, array $data): mixed
{
$segments = explode('.', $key);
$current = $data;
foreach ($segments as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return null;
}
$current = $current[$segment];
}
return $current;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Str;
final class Mask
{
/**
* Masks the local part of an email address, keeping only the first character.
*
* Example: "test@example.com" → "t***@example.com"
*/
public static function email(string $email): string
{
$atPos = strpos($email, '@');
if ($email === '' || $atPos === false || $atPos === 0) {
return $email;
}
return substr($email, 0, 1) . '***' . substr($email, $atPos);
}
/**
* Masks a credit card number, keeping only the last 4 digits.
*
* Example: "4111111111111111" → "**** **** **** 1111"
*/
public static function creditCard(string $number): string
{
$digits = preg_replace('/\D/', '', $number);
if ($digits === null || strlen($digits) < 4) {
return $number;
}
$last4 = substr($digits, -4);
return '**** **** **** ' . $last4;
}
/**
* Masks an IBAN, keeping the country code + check digits (first 4 chars)
* and the last 2 characters. The result is formatted in groups of 4.
*
* Example: "DE89370400440532013000" → "DE89 **** **** **** **** 00"
*/
public static function iban(string $iban): string
{
$clean = str_replace(' ', '', $iban);
if (strlen($clean) < 7) {
return $iban;
}
$visible = 4;
$tail = 2;
$masked = substr($clean, 0, $visible)
. str_repeat('*', strlen($clean) - $visible - $tail)
. substr($clean, -$tail);
return implode(' ', str_split($masked, 4));
}
/**
* Masks a string with a configurable number of visible characters at the start and end.
*
* Example: Mask::custom('MySuperSecret', start: 2, end: 2) → "My*********et"
*
* @param non-empty-string $char
*/
public static function custom(
string $value,
int $start,
int $end,
string $char = '*',
): string {
$length = strlen($value);
if ($value === '' || $start + $end >= $length) {
return $value;
}
$maskLen = $length - $start - $end;
return substr($value, 0, $start)
. str_repeat($char, $maskLen)
. ($end > 0 ? substr($value, -$end) : '');
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Str;
final class Random
{
/**
* Returns a cryptographically secure hex string of $length characters.
*
* $length must be a positive integer. Odd values are supported —
* the required bytes are rounded up and the result is trimmed to $length.
*
* @throws \InvalidArgumentException
*/
public static function token(int $length = 32): string
{
if ($length < 1) {
throw new \InvalidArgumentException('$length must be >= 1.');
}
$bytes = (int) ceil($length / 2);
assert($bytes >= 1);
return substr(bin2hex(random_bytes($bytes)), 0, $length);
}
/**
* Returns a cryptographically secure random string drawn from [a-zA-Z0-9].
*
* Uses rejection sampling to guarantee uniform distribution (no modulo bias).
*
* @throws \InvalidArgumentException
*/
public static function alphanumeric(int $length = 32): string
{
return self::charset($length, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789');
}
/**
* Returns a cryptographically secure random string drawn from $chars.
*
* Uses rejection sampling to guarantee uniform distribution regardless of
* alphabet size.
*
* @throws \InvalidArgumentException
*/
public static function charset(int $length, string $chars): string
{
if ($length < 1) {
throw new \InvalidArgumentException('$length must be >= 1.');
}
if ($chars === '') {
throw new \InvalidArgumentException('$chars must not be empty.');
}
$charsetLen = strlen($chars);
$maxByte = 256 - (256 % $charsetLen);
$result = '';
while (strlen($result) < $length) {
$byte = ord(random_bytes(1));
if ($byte >= $maxByte) {
continue;
}
$result .= $chars[$byte % $charsetLen];
}
return $result;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Str;
final class Slugify
{
/**
* Transliteration table for common non-ASCII characters.
* Covers German, French, Spanish, Nordic, and other Western European scripts.
*
* @var array<string, string>
*/
private const TRANSLITERATIONS = [
// German
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue',
'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue',
'ß' => 'ss',
// French
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
'É' => 'e', 'È' => 'e', 'Ê' => 'e', 'Ë' => 'e',
'à' => 'a', 'â' => 'a', 'á' => 'a', 'ã' => 'a',
'À' => 'a', 'Â' => 'a', 'Á' => 'a', 'Ã' => 'a',
'ù' => 'u', 'û' => 'u', 'ú' => 'u',
'Ù' => 'u', 'Û' => 'u', 'Ú' => 'u',
'î' => 'i', 'ï' => 'i', 'í' => 'i', 'ì' => 'i',
'Î' => 'i', 'Ï' => 'i', 'Í' => 'i', 'Ì' => 'i',
'ô' => 'o', 'ó' => 'o', 'õ' => 'o',
'Ô' => 'o', 'Ó' => 'o', 'Õ' => 'o',
'ç' => 'c', 'Ç' => 'c',
// Spanish
'ñ' => 'n', 'Ñ' => 'n',
// Nordic
'å' => 'a', 'Å' => 'a',
'æ' => 'ae', 'Æ' => 'ae',
'ø' => 'o', 'Ø' => 'o',
];
/**
* Converts $input to a URL-safe slug.
*
* Transliterates known non-ASCII characters, lowercases the result,
* replaces any sequence of non-alphanumeric characters with $separator,
* and trims leading/trailing separators.
*
* Returns an empty string for blank input.
*/
public static function slug(string $input, string $separator = '-'): string
{
$text = strtr($input, self::TRANSLITERATIONS);
$text = mb_strtolower($text);
$quotedSep = preg_quote($separator, '/');
$text = preg_replace('/[^a-z0-9]+/', $separator, $text) ?? '';
$text = preg_replace('/^' . $quotedSep . '+|' . $quotedSep . '+$/', '', $text) ?? '';
return $text;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Str;
final class Truncate
{
/**
* Truncates $text to at most $limit characters (including the suffix).
*
* With $wordBoundary = true (default) the cut is moved back to the last
* complete word so no word is split in the middle.
* With $wordBoundary = false the cut is made at the exact character position.
*
* Returns the original string unchanged when mb_strlen($text) <= $limit.
* Throws \InvalidArgumentException when $limit is shorter than the suffix.
*
* @throws \InvalidArgumentException
*/
public static function chars(
string $text,
int $limit,
string $suffix = '…',
bool $wordBoundary = true,
): string {
$suffixLen = mb_strlen($suffix);
if ($limit < $suffixLen) {
throw new \InvalidArgumentException(
sprintf('$limit (%d) must be >= suffix length (%d).', $limit, $suffixLen),
);
}
if (mb_strlen($text) <= $limit) {
return $text;
}
$cutLength = $limit - $suffixLen;
$cut = mb_substr($text, 0, $cutLength);
if ($wordBoundary) {
$nextChar = mb_substr($text, $cutLength, 1);
if ($nextChar !== '' && $nextChar !== ' ') {
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false) {
$cut = mb_substr($cut, 0, $lastSpace);
}
}
}
return rtrim($cut) . $suffix;
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Str;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Str\CaseConverter;
final class CaseConverterTest extends TestCase
{
// -------------------------------------------------------------------------
// toSnake
// -------------------------------------------------------------------------
public function testCamelToSnake(): void
{
self::assertSame('user_profile_name', CaseConverter::toSnake('userProfileName'));
}
public function testPascalToSnake(): void
{
self::assertSame('user_profile_name', CaseConverter::toSnake('UserProfileName'));
}
public function testKebabToSnake(): void
{
self::assertSame('user_profile_name', CaseConverter::toSnake('user-profile-name'));
}
public function testScreamingSnakeToSnake(): void
{
self::assertSame('user_profile_name', CaseConverter::toSnake('USER_PROFILE_NAME'));
}
public function testSnakeToSnakeIsIdempotent(): void
{
self::assertSame('user_profile_name', CaseConverter::toSnake('user_profile_name'));
}
// -------------------------------------------------------------------------
// toCamel
// -------------------------------------------------------------------------
public function testSnakeToCamel(): void
{
self::assertSame('userProfileName', CaseConverter::toCamel('user_profile_name'));
}
public function testKebabToCamel(): void
{
self::assertSame('userProfileName', CaseConverter::toCamel('user-profile-name'));
}
public function testPascalToCamel(): void
{
self::assertSame('userProfileName', CaseConverter::toCamel('UserProfileName'));
}
public function testCamelToCamelIsIdempotent(): void
{
self::assertSame('userProfileName', CaseConverter::toCamel('userProfileName'));
}
// -------------------------------------------------------------------------
// toPascal
// -------------------------------------------------------------------------
public function testSnakeToPascal(): void
{
self::assertSame('UserProfileName', CaseConverter::toPascal('user_profile_name'));
}
public function testKebabToPascal(): void
{
self::assertSame('UserProfileName', CaseConverter::toPascal('user-profile-name'));
}
public function testCamelToPascal(): void
{
self::assertSame('UserProfileName', CaseConverter::toPascal('userProfileName'));
}
// -------------------------------------------------------------------------
// toKebab
// -------------------------------------------------------------------------
public function testPascalToKebab(): void
{
self::assertSame('user-profile-name', CaseConverter::toKebab('UserProfileName'));
}
public function testSnakeToKebab(): void
{
self::assertSame('user-profile-name', CaseConverter::toKebab('user_profile_name'));
}
public function testCamelToKebab(): void
{
self::assertSame('user-profile-name', CaseConverter::toKebab('userProfileName'));
}
// -------------------------------------------------------------------------
// toScreamingSnake
// -------------------------------------------------------------------------
public function testCamelToScreamingSnake(): void
{
self::assertSame('USER_ID', CaseConverter::toScreamingSnake('userId'));
}
public function testKebabToScreamingSnake(): void
{
self::assertSame('USER_PROFILE_NAME', CaseConverter::toScreamingSnake('user-profile-name'));
}
public function testSnakeToScreamingSnake(): void
{
self::assertSame('USER_PROFILE_NAME', CaseConverter::toScreamingSnake('user_profile_name'));
}
// -------------------------------------------------------------------------
// Special cases
// -------------------------------------------------------------------------
public function testSingleWord(): void
{
self::assertSame('name', CaseConverter::toSnake('name'));
self::assertSame('name', CaseConverter::toCamel('name'));
self::assertSame('Name', CaseConverter::toPascal('name'));
self::assertSame('NAME', CaseConverter::toScreamingSnake('name'));
}
public function testAcronymInPascal(): void
{
self::assertSame('html_parser', CaseConverter::toSnake('HTMLParser'));
}
public function testEmptyString(): void
{
self::assertSame('', CaseConverter::toCamel(''));
self::assertSame('', CaseConverter::toSnake(''));
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Str;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Str\Interpolate;
final class InterpolateTest extends TestCase
{
public function testBasicReplacement(): void
{
$result = Interpolate::render(
'Hello {name}, you have {count} messages.',
['name' => 'Anna', 'count' => 5],
);
self::assertSame('Hello Anna, you have 5 messages.', $result);
}
public function testNoPlaceholdersReturnsTemplateUnchanged(): void
{
self::assertSame('Hello World', Interpolate::render('Hello World', []));
}
public function testCustomDelimiters(): void
{
$result = Interpolate::render(
'Hello :name!',
['name' => 'Bob'],
prefix: ':',
suffix: '',
);
self::assertSame('Hello Bob!', $result);
}
public function testNestedKeyWithDotNotation(): void
{
$result = Interpolate::render(
'Welcome, {user.name}!',
['user' => ['name' => 'Clara']],
);
self::assertSame('Welcome, Clara!', $result);
}
public function testDeeplyNestedKey(): void
{
$result = Interpolate::render(
'{a.b.c}',
['a' => ['b' => ['c' => 'deep']]],
);
self::assertSame('deep', $result);
}
public function testMissingKeyThrowsByDefault(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Missing key "missing"');
Interpolate::render('Hello {missing}!', []);
}
public function testMissingKeyUsesFallback(): void
{
$result = Interpolate::render('Hello {missing}!', [], fallback: 'N/A');
self::assertSame('Hello N/A!', $result);
}
public function testEmptyStringFallback(): void
{
$result = Interpolate::render('{greeting} World', [], fallback: '');
self::assertSame(' World', $result);
}
public function testIntegerValueIsStringified(): void
{
$result = Interpolate::render('Count: {n}', ['n' => 42]);
self::assertSame('Count: 42', $result);
}
public function testEmptyTemplateReturnsEmpty(): void
{
self::assertSame('', Interpolate::render('', []));
}
public function testMissingNestedKeyThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
Interpolate::render('{user.email}', ['user' => ['name' => 'Anna']]);
}
public function testMultiplePlaceholdersReplacedInOrder(): void
{
$result = Interpolate::render(
'{a} {b} {a}',
['a' => 'foo', 'b' => 'bar'],
);
self::assertSame('foo bar foo', $result);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Str;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Str\Mask;
final class MaskTest extends TestCase
{
// --- email ---
public function testEmailMasksLocalPart(): void
{
self::assertSame('t***@example.com', Mask::email('test@example.com'));
}
public function testEmailKeepsOnlyFirstChar(): void
{
self::assertSame('a***@domain.org', Mask::email('administrator@domain.org'));
}
public function testEmailEmptyStringReturnsEmpty(): void
{
self::assertSame('', Mask::email(''));
}
public function testEmailWithoutAtSignReturnsOriginal(): void
{
self::assertSame('notanemail', Mask::email('notanemail'));
}
public function testEmailAtSignAtStartReturnsOriginal(): void
{
self::assertSame('@example.com', Mask::email('@example.com'));
}
// --- creditCard ---
public function testCreditCardShowsLastFourDigits(): void
{
self::assertSame('**** **** **** 1111', Mask::creditCard('4111111111111111'));
}
public function testCreditCardStripsSpaces(): void
{
self::assertSame('**** **** **** 1234', Mask::creditCard('4000 0000 0000 1234'));
}
public function testCreditCardStripsHyphens(): void
{
self::assertSame('**** **** **** 9999', Mask::creditCard('4000-0000-0000-9999'));
}
public function testCreditCardTooShortReturnsOriginal(): void
{
self::assertSame('123', Mask::creditCard('123'));
}
// --- iban ---
public function testIbanMasksMiddleSection(): void
{
self::assertSame('DE89 **** **** **** **** 00', Mask::iban('DE89370400440532013000'));
}
public function testIbanStripsExistingSpaces(): void
{
self::assertSame('DE89 **** **** **** **** 00', Mask::iban('DE89 3704 0044 0532 0130 00'));
}
public function testIbanTooShortReturnsOriginal(): void
{
self::assertSame('DE89', Mask::iban('DE89'));
}
// --- custom ---
public function testCustomMasksMiddle(): void
{
self::assertSame('My*********et', Mask::custom('MySuperSecret', start: 2, end: 2));
}
public function testCustomStartOnly(): void
{
self::assertSame('He********', Mask::custom('HelloWorld', start: 2, end: 0));
}
public function testCustomEndOnly(): void
{
self::assertSame('********ld', Mask::custom('HelloWorld', start: 0, end: 2));
}
public function testCustomDifferentMaskChar(): void
{
self::assertSame('He######ld', Mask::custom('HelloWorld', start: 2, end: 2, char: '#'));
}
public function testCustomStartPlusEndEqualsLengthReturnsOriginal(): void
{
self::assertSame('Hello', Mask::custom('Hello', start: 3, end: 2));
}
public function testCustomEmptyStringReturnsEmpty(): void
{
self::assertSame('', Mask::custom('', start: 1, end: 1));
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Str;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Str\Random;
final class RandomTest extends TestCase
{
// -------------------------------------------------------------------------
// token()
// -------------------------------------------------------------------------
public function testTokenReturnsCorrectLength(): void
{
self::assertSame(32, strlen(Random::token(32)));
self::assertSame(64, strlen(Random::token(64)));
}
public function testTokenDefaultLengthIs32(): void
{
self::assertSame(32, strlen(Random::token()));
}
public function testTokenReturnsOnlyHexChars(): void
{
self::assertMatchesRegularExpression('/^[0-9a-f]+$/', Random::token(64));
}
public function testTokenOddLengthWorks(): void
{
self::assertSame(7, strlen(Random::token(7)));
}
public function testTokenThrowsOnZeroLength(): void
{
$this->expectException(\InvalidArgumentException::class);
Random::token(0);
}
public function testTokenThrowsOnNegativeLength(): void
{
$this->expectException(\InvalidArgumentException::class);
Random::token(-1);
}
// -------------------------------------------------------------------------
// alphanumeric()
// -------------------------------------------------------------------------
public function testAlphanumericReturnsCorrectLength(): void
{
self::assertSame(16, strlen(Random::alphanumeric(16)));
}
public function testAlphanumericDefaultLengthIs32(): void
{
self::assertSame(32, strlen(Random::alphanumeric()));
}
public function testAlphanumericReturnsOnlyAlphanumericChars(): void
{
self::assertMatchesRegularExpression('/^[a-zA-Z0-9]+$/', Random::alphanumeric(128));
}
public function testAlphanumericThrowsOnZeroLength(): void
{
$this->expectException(\InvalidArgumentException::class);
Random::alphanumeric(0);
}
// -------------------------------------------------------------------------
// charset()
// -------------------------------------------------------------------------
public function testCharsetReturnsCorrectLength(): void
{
self::assertSame(12, strlen(Random::charset(12, '0123456789')));
}
public function testCharsetReturnsOnlyCharsFromAlphabet(): void
{
$alphabet = 'ABCDE';
$result = Random::charset(100, $alphabet);
self::assertMatchesRegularExpression('/^[ABCDE]+$/', $result);
}
public function testCharsetWithSingleCharReturnsRepeatedChar(): void
{
self::assertSame('aaaa', Random::charset(4, 'a'));
}
public function testCharsetThrowsOnZeroLength(): void
{
$this->expectException(\InvalidArgumentException::class);
Random::charset(0, 'abc');
}
public function testCharsetThrowsOnEmptyAlphabet(): void
{
$this->expectException(\InvalidArgumentException::class);
Random::charset(8, '');
}
// -------------------------------------------------------------------------
// Randomness sanity check
// -------------------------------------------------------------------------
public function testTwoTokensAreDifferent(): void
{
self::assertNotSame(Random::token(64), Random::token(64));
}
public function testTwoAlphanumericStringsAreDifferent(): void
{
self::assertNotSame(Random::alphanumeric(64), Random::alphanumeric(64));
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Str;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Str\Slugify;
final class SlugifyTest extends TestCase
{
public function testBasicSlug(): void
{
self::assertSame('hello-world', Slugify::slug('Hello World'));
}
public function testGermanUmlauts(): void
{
self::assertSame('aerger-mit-umlauten', Slugify::slug('Ärger mit Umlauten!'));
}
public function testGermanSharpS(): void
{
self::assertSame('strasse', Slugify::slug('Straße'));
}
public function testFrenchAccents(): void
{
self::assertSame('creme-brulee', Slugify::slug('Crème brûlée'));
}
public function testMultipleSpacesCollapsed(): void
{
self::assertSame('multiple-spaces', Slugify::slug(' Multiple Spaces '));
}
public function testCustomSeparator(): void
{
self::assertSame('hello_world', Slugify::slug('Hello World', '_'));
}
public function testSpecialCharactersReplaced(): void
{
self::assertSame('foo-bar-baz', Slugify::slug('foo/bar::baz'));
}
public function testLeadingAndTrailingSeparatorsRemoved(): void
{
self::assertSame('hello', Slugify::slug('---hello---'));
}
public function testEmptyStringReturnsEmpty(): void
{
self::assertSame('', Slugify::slug(''));
}
public function testBlankStringReturnsEmpty(): void
{
self::assertSame('', Slugify::slug(' '));
}
public function testNordicCharacters(): void
{
self::assertSame('ae-o', Slugify::slug('Æ Ø'));
}
public function testSpanishN(): void
{
self::assertSame('canon', Slugify::slug('Cañon'));
}
public function testAlreadyValidSlugUnchanged(): void
{
self::assertSame('hello-world-123', Slugify::slug('hello-world-123'));
}
public function testNumbersPreserved(): void
{
self::assertSame('php-8-3', Slugify::slug('PHP 8.3'));
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Str;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Str\Truncate;
final class TruncateTest extends TestCase
{
// -------------------------------------------------------------------------
// Short strings — returned unchanged
// -------------------------------------------------------------------------
public function testShortStringReturnedUnchanged(): void
{
self::assertSame('Short', Truncate::chars('Short', 20));
}
public function testStringExactlyAtLimitReturnedUnchanged(): void
{
self::assertSame('12345', Truncate::chars('12345', 5));
}
// -------------------------------------------------------------------------
// Word boundary (default)
// -------------------------------------------------------------------------
public function testTruncatesAtWordBoundary(): void
{
// cut position falls on a space → keep up to the space
self::assertSame(
'The quick brown fox…',
Truncate::chars('The quick brown fox jumps over the lazy dog', 20),
);
}
public function testTruncatesBackToLastWordWhenCutIsInsideWord(): void
{
// limit=15, suffix=1 → cut at 14 chars → 'The quick brow' → trims to 'The quick'
self::assertSame('The quick…', Truncate::chars('The quick brown fox', 15));
}
public function testWordBoundaryWithNoSpaceInCutKeepsRawCut(): void
{
self::assertSame('Hello…', Truncate::chars('Hellothere', 6));
}
// -------------------------------------------------------------------------
// Exact cut (wordBoundary = false)
// -------------------------------------------------------------------------
public function testExactCutIgnoresWordBoundary(): void
{
self::assertSame(
'The quick brown fox…',
Truncate::chars('The quick brown fox jumps', 20, wordBoundary: false),
);
}
public function testExactCutSplitsWord(): void
{
self::assertSame('The quick bro…', Truncate::chars('The quick brown fox', 14, wordBoundary: false));
}
// -------------------------------------------------------------------------
// Custom suffix
// -------------------------------------------------------------------------
public function testCustomSuffix(): void
{
// suffix=' [...]' (6 chars), limit=22 → cut at 16 → 'The quick brown ' → trims to 'The quick brown'
self::assertSame(
'The quick brown [...]',
Truncate::chars('The quick brown fox jumps', 22, suffix: ' [...]'),
);
}
public function testEmptySuffix(): void
{
self::assertSame('The quick', Truncate::chars('The quick brown fox', 9, suffix: ''));
}
// -------------------------------------------------------------------------
// Multibyte
// -------------------------------------------------------------------------
public function testMultibyteCharactersCountedCorrectly(): void
{
// 'Ärger mit Umlauten' — each char counts as 1 even though ä is multibyte
self::assertSame('Ärger…', Truncate::chars('Ärger mit Umlauten', 6));
}
// -------------------------------------------------------------------------
// Edge cases / exceptions
// -------------------------------------------------------------------------
public function testThrowsWhenLimitShorterThanSuffix(): void
{
$this->expectException(\InvalidArgumentException::class);
Truncate::chars('Hello', 2, suffix: '...');
}
public function testLimitEqualToSuffixLengthReturnsEmptySuffix(): void
{
// limit=3, suffix='...' (3 chars) → cutLength=0 → '' + '...'
self::assertSame('...', Truncate::chars('Hello world', 3, suffix: '...'));
}
}