This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/vendor/
|
||||
/.php-cs-fixer.cache
|
||||
/.phpunit.result.cache
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
@@ -0,0 +1,8 @@
|
||||
parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
treatPhpDocTypesAsCertain: false
|
||||
parallel:
|
||||
maximumNumberOfProcesses: 1
|
||||
@@ -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>
|
||||
@@ -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 !== '',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) : '');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(''));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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: '...'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user