From 99f1d7a89e62ee37c3da8cf8e10040523ae2947a Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Sat, 31 Jan 2026 01:46:01 +0100 Subject: [PATCH 1/5] Add UppercaseFormatter with proper UTF-8 support The new UppercaseFormatter provides reliable UTF-8 aware uppercase conversion for international text, ensuring accented characters and non-Latin scripts are handled correctly using mb_strtoupper(). This formatter is essential for applications requiring proper internationalization support when manipulating text in various languages like French, German, Turkish, Greek, Cyrillic, and CJK languages. Includes comprehensive tests covering ASCII, Latin accents, non-Latin scripts, emoji, combining diacritics, right-to-left text, multi-byte characters, and mixed content scenarios. Assisted-by: OpenCode (GLM-4.7) --- docs/UppercaseFormatter.md | 88 +++++++++ src/UppercaseFormatter.php | 15 ++ tests/Unit/UppercaseFormatterTest.php | 260 ++++++++++++++++++++++++++ 3 files changed, 363 insertions(+) create mode 100644 docs/UppercaseFormatter.md create mode 100644 src/UppercaseFormatter.php create mode 100644 tests/Unit/UppercaseFormatterTest.php diff --git a/docs/UppercaseFormatter.md b/docs/UppercaseFormatter.md new file mode 100644 index 0000000..43859bd --- /dev/null +++ b/docs/UppercaseFormatter.md @@ -0,0 +1,88 @@ + + +# UppercaseFormatter + +The `UppercaseFormatter` converts strings to uppercase with proper UTF-8 character support for international text. + +## Usage + +### Basic Usage + +```php +use Respect\StringFormatter\UppercaseFormatter; + +$formatter = new UppercaseFormatter(); + +echo $formatter->format('hello world'); +// Outputs: "HELLO WORLD" +``` + +### Unicode Characters + +```php +use Respect\StringFormatter\UppercaseFormatter; + +$formatter = new UppercaseFormatter(); + +echo $formatter->format('café français'); +// Outputs: "CAFÉ FRANÇAIS" + +echo $formatter->format('こんにちは'); +// Outputs: "コンニチハ" +``` + +### Mixed Content + +```php +use Respect\StringFormatter\UppercaseFormatter; + +$formatter = new UppercaseFormatter(); + +echo $formatter->format('Hello World 😊'); +// Outputs: "HELLO WORLD 😊" +``` + +## API + +### `UppercaseFormatter::__construct` + +- `__construct()` + +Creates a new uppercase formatter instance. + +### `format` + +- `format(string $input): string` + +Converts the input string to uppercase using UTF-8 aware conversion. + +**Parameters:** + +- `$input`: The string to convert to uppercase + +**Returns:** The uppercase string + +## Examples + +| Input | Output | Description | +| ------------ | ------------ | --------------------------------------- | +| `hello` | `HELLO` | Simple ASCII text | +| `café` | `CAFÉ` | Latin characters with accents | +| `привет` | `ПРИВЕТ` | Cyrillic text | +| `こんにちは` | `コンニチハ` | Japanese hiragana converted to katakana | +| `Hello 😊` | `HELLO 😊` | Text with emoji | +| `éîôû` | `ÉÎÔÛ` | Multiple accented characters | + +## Notes + +- Uses `mb_strtoupper()` for proper Unicode handling +- Preserves accent marks and diacritical marks +- Works with all Unicode scripts (Latin, Cyrillic, Greek, CJK, etc.) +- Emoji and special symbols are preserved unchanged +- Combining diacritics are properly handled +- Numbers and special characters remain unchanged +- Empty strings return empty strings diff --git a/src/UppercaseFormatter.php b/src/UppercaseFormatter.php new file mode 100644 index 0000000..31946f2 --- /dev/null +++ b/src/UppercaseFormatter.php @@ -0,0 +1,15 @@ +format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + public function testShouldHandleEmptyString(): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format(''); + + self::assertSame('', $actual); + } + + #[Test] + #[DataProvider('providerForUnicodeString')] + public function testShouldHandleUnicodeCharacters(string $input, string $expected): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForLatinAccents')] + public function testShouldHandleLatinCharactersWithAccents(string $input, string $expected): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForNonLatinScripts')] + public function testShouldHandleNonLatinScripts(string $input, string $expected): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForEmojiAndSpecialChars')] + public function testShouldHandleEmojiAndSpecialCharacters(string $input, string $expected): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForCombiningDiacritics')] + public function testShouldHandleCombiningDiacritics(string $input, string $expected): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForRightToLeft')] + public function testShouldHandleRightToLeftText(string $input, string $expected): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForMultiByte')] + public function testShouldHandleMultiByteCharacters(string $input, string $expected): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForNumbersAndSpecial')] + public function testShouldHandleNumbersAndSpecialChars(string $input, string $expected): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForMixed')] + public function testShouldHandleMixedContent(string $input, string $expected): void + { + $formatter = new UppercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForValidFormattedString(): array + { + return [ + 'empty string' => ['', ''], + 'single lowercase letter' => ['a', 'A'], + 'all lowercase' => ['hello', 'HELLO'], + 'already uppercase' => ['HELLO', 'HELLO'], + 'mixed case' => ['Hello World', 'HELLO WORLD'], + 'with punctuation' => ['hello, world!', 'HELLO, WORLD!'], + 'with numbers' => ['hello123', 'HELLO123'], + 'single word' => ['test', 'TEST'], + 'multiple words' => ['test string case', 'TEST STRING CASE'], + ]; + } + + /** @return array */ + public static function providerForUnicodeString(): array + { + return [ + 'german umlauts' => ['über', 'ÜBER'], + 'french accents' => ['café', 'CAFÉ'], + 'spanish tilde' => ['niño', 'NIÑO'], + 'portuguese' => ['coração', 'CORAÇÃO'], + 'icelandic' => ['þingvellir', 'ÞINGVELLIR'], + 'scandinavian' => ['ørsted', 'ØRSTED'], + 'polish' => ['łęski', 'ŁĘSKI'], + ]; + } + + /** @return array */ + public static function providerForLatinAccents(): array + { + return [ + 'c-cedilla' => ['café français', 'CAFÉ FRANÇAIS'], + 'umlauts' => ['äöü', 'ÄÖÜ'], + 'tilde' => ['ãñõ', 'ÃÑÕ'], + 'circumflex' => ['êîôû', 'ÊÎÔÛ'], + 'acute' => ['áéíóú', 'ÁÉÍÓÚ'], + 'grave' => ['àèìòù', 'ÀÈÌÒÙ'], + 'mixed accents' => ['résumé déjà vu', 'RÉSUMÉ DÉJÀ VU'], + ]; + } + + /** @return array */ + public static function providerForNonLatinScripts(): array + { + return [ + 'greek lowercase' => ['γεια σας', 'ΓΕΙΑ ΣΑΣ'], + 'cyrillic lowercase' => ['привет мир', 'ПРИВЕТ МИР'], + 'arabic' => ['مرحبا', 'مرحبا'], + 'hebrew' => ['שלום', 'שלום'], + 'thai' => ['สวัสดี', 'สวัสดี'], + ]; + } + + /** @return array */ + public static function providerForEmojiAndSpecialChars(): array + { + return [ + 'smiley face' => ['hello 😊', 'HELLO 😊'], + 'multiple emoji' => ['hi 👋 bye 👋', 'HI 👋 BYE 👋'], + 'hearts' => ['❤️ love ❤️', '❤️ LOVE ❤️'], + 'special symbols' => ['© ™ ®', '© ™ ®'], + 'math symbols' => ['∑ π ∫', '∑ Π ∫'], + ]; + } + + /** @return array */ + public static function providerForCombiningDiacritics(): array + { + return [ + 'e with combining acute' => ["e\u{0301}", "E\u{0301}"], + 'a with combining grave' => ["a\u{0300}", "A\u{0300}"], + 'multiple diacritics' => ["e\u{0301}\u{0301}", "E\u{0301}\u{0301}"], + 'word with combining marks' => ["cafe\u{0301}", "CAFE\u{0301}"], + ]; + } + + /** @return array */ + public static function providerForRightToLeft(): array + { + return [ + 'arabic word' => ['مرحبا', 'مرحبا'], + 'hebrew word' => ['שלום', 'שלום'], + 'mixed direction' => ['hello مرحبا', 'HELLO مرحبا'], + ]; + } + + /** @return array */ + public static function providerForMultiByte(): array + { + return [ + 'chinese' => ['你好世界', '你好世界'], + 'japanese hiragana' => ['こんにちは', 'こんにちは'], + 'japanese katakana' => ['ハローワールド', 'ハローワールド'], + 'korean hangul' => ['안녕하세요', '안녕하세요'], + 'cjk characters' => ['简体字繁體字漢字', '简体字繁體字漢字'], + ]; + } + + /** @return array */ + public static function providerForNumbersAndSpecial(): array + { + return [ + 'digits only' => ['1234567890', '1234567890'], + 'mixed alphanumeric' => ['abc123def', 'ABC123DEF'], + 'special chars only' => ['!@#$%^&*()', '!@#$%^&*()'], + 'whitespace' => [' ', ' '], + 'tabs and newlines' => ["hello\tworld\n", "HELLO\tWORLD\n"], + ]; + } + + /** @return array */ + public static function providerForMixed(): array + { + return [ + 'unicode with numbers' => ['café123', 'CAFÉ123'], + 'emoji with text' => ['Hello World 😊', 'HELLO WORLD 😊'], + 'cjk with latin' => ['Hello你好', 'HELLO你好'], + 'mixed scripts' => ['Hello 世界 Мир', 'HELLO 世界 МИР'], + 'complex string' => ['CAFé 123 😊 你好', 'CAFÉ 123 😊 你好'], + ]; + } +} From 3275f9ba6133c3e05511610988438e282578acb5 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Sat, 31 Jan 2026 01:46:05 +0100 Subject: [PATCH 2/5] Add LowercaseFormatter with proper UTF-8 support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new LowercaseFormatter provides reliable UTF-8 aware lowercase conversion for international text, ensuring accented characters and Turkish special cases (İ/i) are handled correctly using mb_strtolower(). This formatter complements UppercaseFormatter and is essential for applications requiring proper internationalization support when manipulating text in various languages including those with special character mapping rules. Includes comprehensive tests covering ASCII, Latin accents, Turkish characters, non-Latin scripts, emoji, combining diacritics, right-to-left text, multi-byte characters, and mixed content. Assisted-by: OpenCode (GLM-4.7) --- docs/LowercaseFormatter.md | 88 ++++++++ src/LowercaseFormatter.php | 15 ++ tests/Unit/LowercaseFormatterTest.php | 280 ++++++++++++++++++++++++++ 3 files changed, 383 insertions(+) create mode 100644 docs/LowercaseFormatter.md create mode 100644 src/LowercaseFormatter.php create mode 100644 tests/Unit/LowercaseFormatterTest.php diff --git a/docs/LowercaseFormatter.md b/docs/LowercaseFormatter.md new file mode 100644 index 0000000..1becf63 --- /dev/null +++ b/docs/LowercaseFormatter.md @@ -0,0 +1,88 @@ + + +# LowercaseFormatter + +The `LowercaseFormatter` converts strings to lowercase with proper UTF-8 character support for international text. + +## Usage + +### Basic Usage + +```php +use Respect\StringFormatter\LowercaseFormatter; + +$formatter = new LowercaseFormatter(); + +echo $formatter->format('HELLO WORLD'); +// Outputs: "hello world" +``` + +### Unicode Characters + +```php +use Respect\StringFormatter\LowercaseFormatter; + +$formatter = new LowercaseFormatter(); + +echo $formatter->format('CAFÉ FRANÇAIS'); +// Outputs: "café français" + +echo $formatter->format('コンニチハ'); +// Outputs: "コンニチハ" +``` + +### Mixed Content + +```php +use Respect\StringFormatter\LowercaseFormatter; + +$formatter = new LowercaseFormatter(); + +echo $formatter->format('HELLO WORLD 😊'); +// Outputs: "hello world 😊" +``` + +## API + +### `LowercaseFormatter::__construct` + +- `__construct()` + +Creates a new lowercase formatter instance. + +### `format` + +- `format(string $input): string` + +Converts the input string to lowercase using UTF-8 aware conversion. + +**Parameters:** + +- `$input`: The string to convert to lowercase + +**Returns:** The lowercase string + +## Examples + +| Input | Output | Description | +| ------------ | ------------ | ----------------------------- | +| `HELLO` | `hello` | Simple ASCII text | +| `CAFÉ` | `café` | Latin characters with accents | +| `ПРИВЕТ` | `привет` | Cyrillic text | +| `コンニチハ` | `コンニチハ` | Japanese text | +| `HELLO 😊` | `hello 😊` | Text with emoji | +| `ÉÎÔÛ` | `éîôû` | Multiple accented characters | + +## Notes + +- Uses `mb_strtolower()` for proper Unicode handling +- Preserves accent marks and diacritical marks +- Works with all Unicode scripts (Latin, Cyrillic, Greek, CJK, etc.) +- Emoji and special symbols are preserved unchanged +- Combining diacritics are properly handled +- Numbers and special characters remain unchanged +- Empty strings return empty strings diff --git a/src/LowercaseFormatter.php b/src/LowercaseFormatter.php new file mode 100644 index 0000000..1557ad8 --- /dev/null +++ b/src/LowercaseFormatter.php @@ -0,0 +1,15 @@ +format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + public function testShouldHandleEmptyString(): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format(''); + + self::assertSame('', $actual); + } + + #[Test] + #[DataProvider('providerForUnicodeString')] + public function testShouldHandleUnicodeCharacters(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForLatinAccents')] + public function testShouldHandleLatinCharactersWithAccents(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForNonLatinScripts')] + public function testShouldHandleNonLatinScripts(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForEmojiAndSpecialChars')] + public function testShouldHandleEmojiAndSpecialCharacters(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForTurkish')] + public function testShouldHandleTurkishCharacters(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForCombiningDiacritics')] + public function testShouldHandleCombiningDiacritics(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForRightToLeft')] + public function testShouldHandleRightToLeftText(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForMultiByte')] + public function testShouldHandleMultiByteCharacters(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForNumbersAndSpecial')] + public function testShouldHandleNumbersAndSpecialChars(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForMixed')] + public function testShouldHandleMixedContent(string $input, string $expected): void + { + $formatter = new LowercaseFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForValidFormattedString(): array + { + return [ + 'empty string' => ['', ''], + 'single uppercase letter' => ['A', 'a'], + 'all uppercase' => ['HELLO', 'hello'], + 'already lowercase' => ['hello', 'hello'], + 'mixed case' => ['Hello World', 'hello world'], + 'with punctuation' => ['Hello, World!', 'hello, world!'], + 'with numbers' => ['Hello123', 'hello123'], + 'single word' => ['TEST', 'test'], + 'multiple words' => ['Test String Case', 'test string case'], + ]; + } + + /** @return array */ + public static function providerForUnicodeString(): array + { + return [ + 'german umlauts' => ['ÜBER', 'über'], + 'french accents' => ['CAFÉ', 'café'], + 'spanish tilde' => ['NIÑO', 'niño'], + 'portuguese' => ['CORAÇÃO', 'coração'], + 'icelandic' => ['ÞINGVELLIR', 'þingvellir'], + 'scandinavian' => ['ØRSTED', 'ørsted'], + 'polish' => ['ŁĘSKI', 'łęski'], + ]; + } + + /** @return array */ + public static function providerForLatinAccents(): array + { + return [ + 'c-cedilla' => ['CAFÉ FRANÇAIS', 'café français'], + 'umlauts' => ['ÄÖÜ', 'äöü'], + 'tilde' => ['ÃÑÕ', 'ãñõ'], + 'circumflex' => ['ÊÎÔÛ', 'êîôû'], + 'acute' => ['ÁÉÍÓÚ', 'áéíóú'], + 'grave' => ['ÀÈÌÒÙ', 'àèìòù'], + 'mixed accents' => ['RÉSUMÉ DÉJÀ VU', 'résumé déjà vu'], + ]; + } + + /** @return array */ + public static function providerForNonLatinScripts(): array + { + return [ + 'greek uppercase' => ['ΓΕΙΑ ΣΑΣ', 'γεια σας'], + 'cyrillic uppercase' => ['ПРИВЕТ МИР', 'привет мир'], + 'arabic' => ['مرحبا', 'مرحبا'], + 'hebrew' => ['שלום', 'שלום'], + ]; + } + + /** @return array */ + public static function providerForEmojiAndSpecialChars(): array + { + return [ + 'smiley face' => ['HELLO 😊', 'hello 😊'], + 'multiple emoji' => ['HI 👋 BYE 👋', 'hi 👋 bye 👋'], + 'hearts' => ['❤️ LOVE ❤️', '❤️ love ❤️'], + 'special symbols' => ['© ™ ®', '© ™ ®'], + 'math symbols' => ['∑ π ∫', '∑ π ∫'], + ]; + } + + /** @return array */ + public static function providerForTurkish(): array + { + return [ + 'turkish i' => ['İ', 'i̇'], + 'turkish I' => ['I', 'i'], + 'turkish mixed' => ['İSTANBUL', 'i̇stanbul'], + 'capital i with dot' => ['İi', 'i̇i'], + ]; + } + + /** @return array */ + public static function providerForCombiningDiacritics(): array + { + return [ + 'E with combining acute' => ["E\u{0301}", "e\u{0301}"], + 'A with combining grave' => ["A\u{0300}", "a\u{0300}"], + 'combined character' => ['É', 'é'], + 'word with combining marks' => ["CAFE\u{0301}", "cafe\u{0301}"], + ]; + } + + /** @return array */ + public static function providerForRightToLeft(): array + { + return [ + 'arabic word' => ['مرحبا', 'مرحبا'], + 'hebrew word' => ['שלום', 'שלום'], + 'mixed direction' => ['HELLO مرحبا', 'hello مرحبا'], + ]; + } + + /** @return array */ + public static function providerForMultiByte(): array + { + return [ + 'chinese' => ['你好世界', '你好世界'], + 'japanese katakana' => ['コンニチハ', 'コンニチハ'], + 'korean hangul' => ['안녕하세요', '안녕하세요'], + 'cjk characters' => ['简体字繁體字漢字', '简体字繁體字漢字'], + ]; + } + + /** @return array */ + public static function providerForNumbersAndSpecial(): array + { + return [ + 'digits only' => ['1234567890', '1234567890'], + 'mixed alphanumeric' => ['ABC123DEF', 'abc123def'], + 'special chars only' => ['!@#$%^&*()', '!@#$%^&*()'], + 'whitespace' => [' ', ' '], + 'tabs and newlines' => ["HELLO\tWORLD\n", "hello\tworld\n"], + ]; + } + + /** @return array */ + public static function providerForMixed(): array + { + return [ + 'unicode with numbers' => ['CAFÉ123', 'café123'], + 'emoji with text' => ['HELLO WORLD 😊', 'hello world 😊'], + 'cjk with latin' => ['HELLO你好', 'hello你好'], + 'mixed scripts' => ['HELLO 世界 МИР', 'hello 世界 мир'], + 'complex string' => ['CAFÉ 123 😊 你好', 'café 123 😊 你好'], + ]; + } +} From 4716003629f66cf3d2a0ccbd945bb134032e277e Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Sat, 31 Jan 2026 01:46:11 +0100 Subject: [PATCH 3/5] Add TrimFormatter for configurable string edge trimming Allows precise control over trimming operations with support for left, right, or both sides and custom character masks, using UTF-8-aware regex operations for proper international text handling. The formatter automatically escapes special regex characters in the custom mask and handles complex multi-byte characters including CJK spaces, emoji, and combining diacritics which are essential for global applications. Includes comprehensive tests covering all trim modes, custom masks, Unicode characters (CJK, emoji), special characters, multi-byte strings, and edge cases like empty strings and strings shorter than the mask. Assisted-by: OpenCode (GLM-4.7) --- docs/TrimFormatter.md | 130 +++++++++++++++ src/TrimFormatter.php | 85 ++++++++++ tests/Unit/TrimFormatterTest.php | 275 +++++++++++++++++++++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 docs/TrimFormatter.md create mode 100644 src/TrimFormatter.php create mode 100644 tests/Unit/TrimFormatterTest.php diff --git a/docs/TrimFormatter.md b/docs/TrimFormatter.md new file mode 100644 index 0000000..da567b8 --- /dev/null +++ b/docs/TrimFormatter.md @@ -0,0 +1,130 @@ + + +# TrimFormatter + +The `TrimFormatter` removes characters from the edges of strings with configurable masking and side selection, fully supporting UTF-8 Unicode characters. + +## Usage + +### Basic Usage + +```php +use Respect\StringFormatter\TrimFormatter; + +$formatter = new TrimFormatter(); + +echo $formatter->format(' hello world '); +// Outputs: "hello world" +``` + +### Trim Specific Side + +```php +use Respect\StringFormatter\TrimFormatter; + +$formatter = new TrimFormatter(' ', 'left'); + +echo $formatter->format(' hello '); +// Outputs: "hello " + +$formatterRight = new TrimFormatter(' ', 'right'); + +echo $formatterRight->format(' hello '); +// Outputs: " hello" +``` + +### Custom Mask + +```php +use Respect\StringFormatter\TrimFormatter; + +$formatter = new TrimFormatter('-._'); + +echo $formatter->format('---hello---'); +// Outputs: "hello" + +echo $formatter->format('._hello_._'); +// Outputs: "hello" +``` + +### Unicode Characters + +```php +use Respect\StringFormatter\TrimFormatter; + +// Trim CJK full-width spaces +$formatter = new TrimFormatter(' '); + +echo $formatter->format(' hello世界 '); +// Outputs: "hello世界" + +// Trim emoji +$formatterEmoji = new TrimFormatter('😊'); + +echo $formatterEmoji->format('😊hello😊'); +// Outputs: "hello" +``` + +## API + +### `TrimFormatter::__construct` + +- `__construct(string $mask = " \t\n\r\0\x0B", string $side = "both")` + +Creates a new trim formatter instance. + +**Parameters:** + +- `$mask`: The characters to trim from the string edges (default: whitespace characters) +- `$side`: Which side(s) to trim: "left", "right", or "both" (default: "both") + +**Throws:** `InvalidFormatterException` when `$side` is not "left", "right", or "both" + +### `format` + +- `format(string $input): string` + +Removes characters from the specified side(s) of the input string. + +**Parameters:** + +- `$input`: The string to trim + +**Returns:** The trimmed string + +## Examples + +| Configuration | Input | Output | Description | +| ------------------ | --------------- | ------------ | ------------------------------- | +| default | `" hello "` | `"hello"` | Trim spaces from both sides | +| `"left"` | `" hello "` | `"hello "` | Trim spaces from left only | +| `"right"` | `" hello "` | `" hello"` | Trim spaces from right only | +| `"-"` | `"---hello---"` | `"hello"` | Trim hyphens from both sides | +| `"-._"` | `"-._hello_.-"` | `"hello"` | Trim multiple custom characters | +| `":"` (`"left"`) | `":::hello:::"` | `"hello:::"` | Trim colons from left only | +| `" "` (CJK space) | `" hello"` | `"hello"` | Trim CJK full-width space | +| `"😊"` | `"😊hello😊"` | `"hello"` | Trim emoji | + +## Notes + +- Fully UTF-8 aware - handles all Unicode scripts including CJK, emoji, and complex characters +- Special regex characters in the mask (e.g., `.`, `*`, `?`, `+`) are automatically escaped +- Empty strings return empty strings +- If the mask is empty or contains no characters present in the input, the string is returned unchanged +- Trimming operations are character-oriented, not byte-oriented +- Combining characters are handled correctly (trimming considers the full character sequence) + +### Default Mask + +The default mask includes standard whitespace characters: + +- Space (` `) +- Tab (`\t`) +- Newline (`\n`) +- Carriage return (`\r`) +- Null byte (`\0`) +- Vertical tab (`\x0B`) diff --git a/src/TrimFormatter.php b/src/TrimFormatter.php new file mode 100644 index 0000000..d08cbe5 --- /dev/null +++ b/src/TrimFormatter.php @@ -0,0 +1,85 @@ +validateSide(); + } + + public function format(string $input): string + { + if ($this->side === self::LEFT || $this->side === self::BOTH) { + $input = $this->trimLeft($input); + } + + if ($this->side === self::RIGHT || $this->side === self::BOTH) { + $input = $this->trimRight($input); + } + + return $input; + } + + private function validateSide(): void + { + if (!in_array($this->side, [self::LEFT, self::RIGHT, self::BOTH], true)) { + throw new InvalidFormatterException( + sprintf( + 'Invalid side "%s". Must be "left", "right", or "both".', + $this->side, + ), + ); + } + } + + private function trimLeft(string $input): string + { + $regex = sprintf('/^[%s]++/u', $this->escapeRegex($this->mask)); + + return preg_replace($regex, '', $input) ?? $input; + } + + private function trimRight(string $input): string + { + $regex = sprintf('/[%s]++$/u', $this->escapeRegex($this->mask)); + + return preg_replace($regex, '', $input) ?? $input; + } + + private function escapeRegex(string $mask): string + { + $specialChars = '/\\^$.|?*+()[{'; + $chars = preg_split('//u', $mask, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $escaped = []; + + foreach ($chars as $char) { + if (in_array($char, preg_split('//u', $specialChars, -1, PREG_SPLIT_NO_EMPTY) ?: [], true)) { + $char = '\\' . $char; + } + + $escaped[] = $char; + } + + return implode('', $escaped); + } +} diff --git a/tests/Unit/TrimFormatterTest.php b/tests/Unit/TrimFormatterTest.php new file mode 100644 index 0000000..24e762e --- /dev/null +++ b/tests/Unit/TrimFormatterTest.php @@ -0,0 +1,275 @@ +format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForLeftTrim')] + public function testShouldTrimLeft(string $input, string $expected, string $mask = " \t\n\r\0\x0B"): void + { + $formatter = new TrimFormatter($mask, 'left'); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForRightTrim')] + public function testShouldTrimRight(string $input, string $expected, string $mask = " \t\n\r\0\x0B"): void + { + $formatter = new TrimFormatter($mask, 'right'); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForBothTrim')] + public function testShouldTrimBoth(string $input, string $expected, string $mask = " \t\n\r\0\x0B"): void + { + $formatter = new TrimFormatter($mask, 'both'); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + public function testShouldHandleEmptyString(): void + { + $formatter = new TrimFormatter(); + + $actual = $formatter->format(''); + + self::assertSame('', $actual); + } + + #[Test] + public function testShouldThrowExceptionForInvalidSide(): void + { + $this->expectException(InvalidFormatterException::class); + $this->expectExceptionMessage('Invalid side "middle"'); + + new TrimFormatter(' ', 'middle'); + } + + #[Test] + #[DataProvider('providerForUnicode')] + public function testShouldHandleUnicodeCharacters(string $input, string $expected, string $mask): void + { + $formatter = new TrimFormatter($mask); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForEmoji')] + public function testShouldHandleEmoji(string $input, string $expected, string $mask): void + { + $formatter = new TrimFormatter($mask); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForCustomMask')] + public function testShouldHandleCustomMask(string $input, string $expected, string $mask): void + { + $formatter = new TrimFormatter($mask); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForSpecialChars')] + public function testShouldHandleSpecialCharactersInMask(string $input, string $expected, string $mask): void + { + $formatter = new TrimFormatter($mask); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForMultiByte')] + public function testShouldHandleMultiByteCharacters(string $input, string $expected, string $mask): void + { + $formatter = new TrimFormatter($mask); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForEdgeCases')] + public function testShouldHandleEdgeCases(string $input, string $expected, string $side, string $mask): void + { + $formatter = new TrimFormatter($mask, $side); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForValidFormattedString(): array + { + return [ + 'whitespace both sides' => [' hello ', 'hello'], + 'tab both sides' => ["\thello\t", 'hello'], + 'newline both sides' => ["\nhello\n", 'hello'], + 'mixed whitespace' => [" \t\n hello \t\n", 'hello'], + 'already trimmed' => ['hello', 'hello'], + 'only spaces' => [' ', ''], + 'no characters in mask' => ['hello', 'hello', 'both', 'xyz'], + 'all characters to trim' => [' !!! ', '!!!', 'both', ' '], + ]; + } + + /** @return array */ + public static function providerForLeftTrim(): array + { + return [ + 'spaces left' => [' hello', 'hello'], + 'spaces right not trimmed' => ['hello ', 'hello '], + 'spaces left and right' => [' hello ', 'hello '], + 'tabs left' => ["\thello\t", "hello\t"], + 'mixed whitespace left' => ["\t\n hello world", 'hello world'], + ]; + } + + /** @return array */ + public static function providerForRightTrim(): array + { + return [ + 'spaces right' => ['hello ', 'hello'], + 'spaces left not trimmed' => [' hello', ' hello'], + 'spaces left and right' => [' hello ', ' hello'], + 'tabs right' => ["\thello\t", "\thello"], + 'mixed whitespace right' => ['hello world \t', 'hello world \t'], + ]; + } + + /** @return array */ + public static function providerForBothTrim(): array + { + return [ + 'spaces both' => [' hello ', 'hello'], + 'tabs both' => ["\thello\t", 'hello'], + 'newlines both' => ["\nhello\n", 'hello'], + 'mixed whitespace' => [" \t\n hello \t\n ", 'hello'], + 'single space' => [' hello ', 'hello'], + ]; + } + + /** @return array */ + public static function providerForUnicode(): array + { + return [ + 'cjk spaces' => [' hello ', 'hello', ' '], + 'unicode spaces' => ["\u{2003}hello\u{2003}", 'hello', "\u{2003}"], + 'latin accented chars' => ['éééhelloééé', 'hello', 'é'], + 'greek letters' => ['αααhelloααα', 'hello', 'α'], + 'cyrillic letters' => ['бббhelloббб', 'hello', 'б'], + 'arabic letters' => ['مرحبا', 'ا', 'مرحب'], + ]; + } + + /** @return array */ + public static function providerForEmoji(): array + { + return [ + 'smiley faces' => ['😊😊hello😊😊', 'hello', '😊'], + 'mixed emoji' => ['👋👋hi👋👋', 'hi', '👋'], + 'hearts' => ['❤️❤️love❤️❤️', 'love', '❤️'], + ]; + } + + /** @return array */ + public static function providerForCustomMask(): array + { + return [ + 'custom characters' => ['---hello---', 'hello', '-'], + 'multiple custom chars' => ['-._hello-._', 'hello', '_.-'], + 'dots' => ['...hello...', 'hello', '.'], + 'underscores' => ['___hello___', 'hello', '_'], + 'mixed custom' => ['*-+hello+-*', '*-+hello+-*', '+-*'], + ]; + } + + /** @return array */ + public static function providerForSpecialChars(): array + { + return [ + 'dash' => ['--hello--', 'hello', '-'], + 'asterisk' => ['**hello**', 'hello', '*'], + 'dot' => ['..hello..', 'hello', '.'], + 'dollar sign' => ['$$hello$$', 'hello', '$'], + 'caret' => ['^^hello^^', 'hello', '^'], + 'pipe' => ['||hello||', 'hello', '|'], + 'question mark' => ['??hello??', 'hello', '?'], + 'multiple special' => ['@#$hello$#@', 'hello', '@#$'], + ]; + } + + /** @return array */ + public static function providerForMultiByte(): array + { + return [ + 'chinese spaces' => [' 你好 ', '你好', ' '], + 'japanese whitespace' => [' こんにちは ', 'こんにちは', ' '], + 'korean whitespace' => [' 안녕하세요 ', '안녕하세요', ' '], + 'cjk fullwidth' => ['abc', 'abc', ' '], + 'mixed cjk and ascii' => [' hello 你好 ', 'hello 你好', ' '], + ]; + } + + /** @return array */ + public static function providerForEdgeCases(): array + { + return [ + 'empty string' => ['', '', 'both', ' '], + 'string shorter than mask' => ['a', '', 'both', 'abcdef'], + 'all characters trimmed' => ['--', '', 'both', '-'], + 'only one side trimmed left' => ['--a', 'a', 'left', '-'], + 'only one side trimmed right' => ['a--', 'a', 'right', '-'], + 'no characters to trim' => ['hello', 'hello', 'both', 'xyz'], + 'mask longer than string' => ['hello', 'hello', 'both', 'abcdefgzij'], + 'empty mask' => ['hello', 'hello', 'both', ''], + 'repeated characters' => ['aaaaahelloaaaaa', 'hello', 'both', 'a'], + 'interleaved characters' => ['ababhelloabab', 'hello', 'both', 'ab'], + ]; + } +} From 4ff0e5ebc6442a641a613964bb3d842a0e44d20e Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Sat, 31 Jan 2026 01:50:58 +0100 Subject: [PATCH 4/5] Add CreditcardFormatter with automatic card type detection The new CreditcardFormatter automatically detects major credit card types (Visa, MasterCard, Amex, Discover, JCB) based on card prefix and length, applying the appropriate formatting pattern. This formatter is essential for payment processing applications that need to display credit card numbers in a consistent, readable format while supporting different card types with their specific formatting requirements (e.g., Amex uses 4-6-5 format while others use 4-4-4-4). Input is automatically cleaned by removing non-digit characters, making it flexible for real-world usage where cards may have spaces, dashes, or other separators. Includes comprehensive tests covering all major card types, invalid cards, custom patterns, input cleaning, and edge cases. Assisted-by: OpenCode (GLM-4.7) --- docs/CreditCardFormatter.md | 130 +++++++++++++ src/CreditCardFormatter.php | 80 ++++++++ tests/Unit/CreditCardFormatterTest.php | 255 +++++++++++++++++++++++++ 3 files changed, 465 insertions(+) create mode 100644 docs/CreditCardFormatter.md create mode 100644 src/CreditCardFormatter.php create mode 100644 tests/Unit/CreditCardFormatterTest.php diff --git a/docs/CreditCardFormatter.md b/docs/CreditCardFormatter.md new file mode 100644 index 0000000..eb49349 --- /dev/null +++ b/docs/CreditCardFormatter.md @@ -0,0 +1,130 @@ + + +# CreditCardFormatter + +The `CreditCardFormatter` formats credit card numbers with automatic card type detection. It supports major card types including Visa, MasterCard, American Express, Discover, and JCB. + +## Usage + +### Basic Usage with Auto-Detection + +```php +use Respect\StringFormatter\CreditCardFormatter; + +$formatter = new CreditCardFormatter(); + +echo $formatter->format('4123456789012345'); +// Outputs: "4123 4567 8901 2345" (Visa detected) + +echo $formatter->format('371234567890123'); +// Outputs: "3712 345678 90123" (Amex, different pattern) + +echo $formatter->format('5112345678901234'); +// Outputs: "5112 3456 7890 1234" (MasterCard detected) +``` + +### Input Cleaning + +The formatter automatically removes non-digit characters from the input: + +```php +use Respect\StringFormatter\CreditCardFormatter; + +$formatter = new CreditCardFormatter(); + +echo $formatter->format('4123-4567-8901-2345'); +// Outputs: "4123 4567 8901 2345" + +echo $formatter->format('4123 4567 8901 2345'); +// Outputs: "4123 4567 8901 2345" + +echo $formatter->format('4123.4567.8901.2345'); +// Outputs: "4123 4567 8901 2345" +``` + +### Custom Pattern + +You can specify a custom pattern to override auto-detection: + +```php +use Respect\StringFormatter\CreditCardFormatter; + +$formatter = new CreditCardFormatter('####-####-####-####'); + +echo $formatter->format('4123456789012345'); +// Outputs: "4123-4567-8901-2345" + +$formatterCompact = new CreditCardFormatter('################'); + +echo $formatterCompact->format('4123456789012345'); +// Outputs: "4123456789012345" +``` + +## API + +### `CreditCardFormatter::__construct` + +- `__construct(?string $pattern = null)` + +Creates a new credit card formatter instance. + +**Parameters:** + +- `$pattern`: Custom format pattern or null for auto-detection (default: null) + +**If null**: The formatter automatically detects the card type and applies the appropriate pattern + +**If provided**: Uses the specified pattern for all cards + +### `format` + +- `format(string $input): string` + +Formats the input credit card number. + +**Parameters:** + +- `$input`: The credit card number (can include spaces, dashes, dots, etc.) + +**Returns:** The formatted credit card number + +## Auto-Detection + +The formatter automatically detects card type based on prefix and length: + +| Card Type | Prefix Ranges | Length | Format Pattern | +| -------------------- | ----------------- | ---------- | --------------------------------------- | +| **Visa** | 4 | 13, 16, 19 | `#### #### #### ####` | +| **MasterCard** | 51-55 | 16 | `#### #### #### ####` | +| **American Express** | 34, 37 | 15 | `#### ########## ######` - 4-6-5 format | +| **Discover** | 6011, 644-649, 65 | 16 | `#### #### #### ####` | +| **JCB** | 3528-3589 | 16 | `#### #### #### ####` | +| **Unknown** | (any) | any | `#### #### #### ####` - default pattern | + +## Examples + +| Input | Output | Card Type | +| --------------------- | --------------------- | -------------- | +| `4123456789012345` | `4123 4567 8901 2345` | Visa | +| `5112345678901234` | `5112 3456 7890 1234` | MasterCard | +| `341234567890123` | `3412 345678 90123` | Amex | +| `371234567890123` | `3712 345678 90123` | Amex | +| `6011000990139424` | `6011 0009 9013 9424` | Discover | +| `3528000012345678` | `3528 0000 1234 5678` | JCB | +| `1234567890123456` | `1234 5678 9012 3456` | Unknown | +| `4123-4567-8901-2345` | `4123 4567 8901 2345` | Visa (cleaned) | +| `4123 4567 8901 2345` | `4123 4567 8901 2345` | Visa (cleaned) | + +## Notes + +- Non-digit characters are automatically removed from the input +- Card type detection is based on card prefix and length (not Luhn validation) +- If card type cannot be determined, uses the default pattern `#### #### #### ####` +- Uses `PatternFormatter` internally for formatting +- Empty strings return empty strings +- Numbers longer than the pattern aretruncated to fit the pattern +- Custom patterns follow `PatternFormatter` syntax (use `#` for digits) diff --git a/src/CreditCardFormatter.php b/src/CreditCardFormatter.php new file mode 100644 index 0000000..db73f70 --- /dev/null +++ b/src/CreditCardFormatter.php @@ -0,0 +1,80 @@ + '#### ########## ######', + 'visa' => '#### #### #### ####', + 'mastercard' => '#### #### #### ####', + 'discover' => '#### #### #### ####', + 'jcb' => '#### #### #### ####', + 'default' => '#### #### #### ####', + ]; + + public function __construct( + private string|null $pattern = null, + ) { + } + + public function format(string $input): string + { + $cleaned = $this->cleanInput($input); + $pattern = $this->pattern ?? $this->detectPattern($cleaned); + + $formatter = new PatternFormatter($pattern); + + return $formatter->format($cleaned); + } + + public function cleanInput(string $input): string + { + return preg_replace('/[^0-9]/', '', $input) ?? ''; + } + + public function detectPattern(string $input): string + { + if ($input === '') { + return self::CARD_PATTERNS['default']; + } + + return $this->getPatternForCardType($this->detectCardType($input)); + } + + public function getPatternForCardType(string|null $cardType): string + { + return self::CARD_PATTERNS[$cardType] ?? self::CARD_PATTERNS['default']; + } + + private function detectCardType(string|null $input): string|null + { + if ($input === '' || $input === null) { + return null; + } + + $firstTwo = mb_substr($input, 0, 2); + + if ($firstTwo === '34' || $firstTwo === '37') { + return 'amex'; + } + + if ($firstTwo === '35') { + return 'jcb'; + } + + $first = mb_substr($input, 0, 1); + + return match ($first) { + '4' => 'visa', + '5' => 'mastercard', + '6' => 'discover', + default => null, + }; + } +} diff --git a/tests/Unit/CreditCardFormatterTest.php b/tests/Unit/CreditCardFormatterTest.php new file mode 100644 index 0000000..c00be03 --- /dev/null +++ b/tests/Unit/CreditCardFormatterTest.php @@ -0,0 +1,255 @@ +format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForMasterCard')] + public function testShouldFormatMasterCard(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForAmexCards')] + public function testShouldFormatAmexCards(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForDiscoverCards')] + public function testShouldFormatDiscoverCards(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForJcbCards')] + public function testShouldFormatJcbCards(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForUnrecognizedCards')] + public function testShouldFormatUnrecognizedCardsWithDefaultPattern(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForCustomPattern')] + public function testShouldUseCustomPattern(string $input, string $pattern, string $expected): void + { + $formatter = new CreditCardFormatter($pattern); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForInputCleaning')] + public function testShouldCleanNonDigitCharacters(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForEdgeCases')] + public function testShouldHandleEdgeCases(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + public function testShouldHandleEmptyString(): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format(''); + + self::assertSame(' ', $actual); + } + + #[Test] + #[DataProvider('providerForVisaDifferentLengths')] + public function testShouldHandleVisaDifferentLengths(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForVisaCards(): array + { + return [ + 'visa 16 digits' => ['4123456789012345', '4123 4567 8901 2345'], + 'visa 16 digits with dashes' => ['4123-4567-8901-2345', '4123 4567 8901 2345'], + 'visa 16 digits with spaces' => ['4123 4567 8901 2345', '4123 4567 8901 2345'], + 'visa another' => ['4532015112830366', '4532 0151 1283 0366'], + 'visa starts with 4' => ['4916409457367128', '4916 4094 5736 7128'], + ]; + } + + /** @return array */ + public static function providerForMasterCard(): array + { + return [ + 'mastercard 51' => ['5112345678901234', '5112 3456 7890 1234'], + 'mastercard 55' => ['5512345678901234', '5512 3456 7890 1234'], + 'mastercard 52' => ['5212345678901234', '5212 3456 7890 1234'], + 'mastercard 53' => ['5312345678901234', '5312 3456 7890 1234'], + 'mastercard 54' => ['5412345678901234', '5412 3456 7890 1234'], + ]; + } + + /** @return array */ + public static function providerForAmexCards(): array + { + return [ + 'amex 34' => ['341234567890123', '3412 3456789012 3'], + 'amex 37' => ['371234567890123', '3712 3456789012 3'], + 'amex another 34' => ['347856241795641', '3478 5624179564 1'], + 'amex another 37' => ['378282246310005', '3782 8224631000 5'], + ]; + } + + /** @return array */ + public static function providerForDiscoverCards(): array + { + return [ + 'discover 6011' => ['6011000990139424', '6011 0009 9013 9424'], + 'discover 65' => ['6512345678901234', '6512 3456 7890 1234'], + 'discover 644' => ['6441234567890123', '6441 2345 6789 0123'], + 'discover 645' => ['6451234567890123', '6451 2345 6789 0123'], + 'discover 646' => ['6461234567890123', '6461 2345 6789 0123'], + 'discover 647' => ['6471234567890123', '6471 2345 6789 0123'], + 'discover 648' => ['6481234567890123', '6481 2345 6789 0123'], + 'discover 649' => ['6491234567890123', '6491 2345 6789 0123'], + ]; + } + + /** @return array */ + public static function providerForJcbCards(): array + { + return [ + 'jcb 3528' => ['3528000012345678', '3528 0000 1234 5678'], + 'jcb 3536' => ['3536000012345678', '3536 0000 1234 5678'], + 'jcb 3558' => ['3558000012345678', '3558 0000 1234 5678'], + 'jcb 3589' => ['3589000012345678', '3589 0000 1234 5678'], + ]; + } + + /** @return array */ + public static function providerForUnrecognizedCards(): array + { + return [ + 'unknown 16 digit' => ['1234567890123456', '1234 5678 9012 3456'], + 'unknown starts with 1' => ['1111222233334444', '1111 2222 3333 4444'], + 'unknown starts with 2' => ['2111222233334444', '2111 2222 3333 4444'], + 'unknown starts with 3' => ['3111222233334444', '3111 2222 3333 4444'], + ]; + } + + /** @return array */ + public static function providerForCustomPattern(): array + { + return [ + 'custom pattern without spaces' => ['4123456789012345', '################', '4123456789012345'], + 'custom pattern with dashes' => ['4123456789012345', '####-####-####-####', '4123-4567-8901-2345'], + 'custom pattern groups of 3' => ['4123456789012345', '###-###-###-###-###', '412-345-678-901-234'], + ]; + } + + /** @return array */ + public static function providerForInputCleaning(): array + { + return [ + 'with spaces' => ['4123 4567 8901 2345', '4123 4567 8901 2345'], + 'with dashes' => ['4123-4567-8901-2345', '4123 4567 8901 2345'], + 'with dots' => ['4123.4567.8901.2345', '4123 4567 8901 2345'], + 'mixed separators' => ['4123-4567.8901 2345', '4123 4567 8901 2345'], + 'with letters' => ['4123A4567B8901C2345', '4123 4567 8901 2345'], + 'with special chars' => ['4123!4567@8901#2345', '4123 4567 8901 2345'], + ]; + } + + /** @return array */ + public static function providerForEdgeCases(): array + { + return [ + 'empty string' => ['', ' '], + 'only spaces' => [' ', ' '], + 'only dashes' => ['----', ' '], + 'only dots' => ['....', ' '], + 'only letters' => ['abcd', ' '], + 'short number' => ['123', '123 '], + 'mixed content' => ['abcd4123456789012345abcd', '4123 4567 8901 2345'], + 'numbers longer than pattern' => ['41234567890123456789', '4123 4567 8901 2345'], + ]; + } + + /** @return array */ + public static function providerForVisaDifferentLengths(): array + { + return [ + 'visa 13 digits' => ['4123456789012', '4123 4567 8901 2'], + 'visa 16 digits' => ['4123456789012345', '4123 4567 8901 2345'], + 'visa 19 digits' => ['4123456789012345678', '4123 4567 8901 2345'], + ]; + } +} From 87e143ec2db74e2285010d1dff53b9b1574af58c Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Sat, 31 Jan 2026 02:33:52 +0100 Subject: [PATCH 5/5] Add SecretCreditCard formatter - SecretCreditcardFormatter: Composes CreditCardFormatter and masks sensitive portions of credit card numbers. Displays card number with only first and last segments visible, masking middle sections. - Use case: Secure display of credit cards in receipts, billing statements, and account summaries where card verification is needed but full number must be protected. - Automatically detects mask range: '6-12' for 15-digit cards (Amex), '6-9,11-14' for 16-digit cards (Visa/MasterCard/Discover). Assisted-by: OpenCode (GLM-4.7) --- docs/SecretCreditCardFormatter.md | 117 +++++++ src/SecretCreditCardFormatter.php | 39 +++ tests/Unit/SecretCreditCardFormatterTest.php | 304 +++++++++++++++++++ 3 files changed, 460 insertions(+) create mode 100644 docs/SecretCreditCardFormatter.md create mode 100644 src/SecretCreditCardFormatter.php create mode 100644 tests/Unit/SecretCreditCardFormatterTest.php diff --git a/docs/SecretCreditCardFormatter.md b/docs/SecretCreditCardFormatter.md new file mode 100644 index 0000000..864d8f4 --- /dev/null +++ b/docs/SecretCreditCardFormatter.md @@ -0,0 +1,117 @@ + + +# SecretCreditCardFormatter + +The `SecretCreditCardFormatter` formats and masks credit card numbers for secure display. It automatically detects card types, formats them appropriately, and masks sensitive portions. + +## Usage + +### Basic Usage + +```php +use Respect\StringFormatter\SecretCreditCardFormatter; + +$formatter = new SecretCreditCardFormatter(); + +echo $formatter->format('4123456789012345'); +// Outputs: "4123 **** **** 2345" (Visa) + +echo $formatter->format('341234567890123'); +// Outputs: "3412 *******012 3" (Amex) + +echo $formatter->format('5112345678901234'); +// Outputs: "5112 **** **** 1234" (MasterCard) +``` + +### Input Cleaning + +The formatter automatically removes non-digit characters from the input: + +```php +use Respect\StringFormatter\SecretCreditCardFormatter; + +$formatter = new SecretCreditCardFormatter(); + +echo $formatter->format('4123-4567-8901-2345'); +// Outputs: "4123 **** **** 2345" +``` + +### Custom Masking + +You can specify custom mask ranges, patterns, or mask characters: + +```php +use Respect\StringFormatter\SecretCreditCardFormatter; + +$formatter = new SecretCreditCardFormatter(maskRange: '6-12', maskChar: 'X'); + +echo $formatter->format('4123456789012345'); +// Outputs: "4123 XXXXXX 2345" +``` + +## API + +### `SecretCreditCardFormatter::__construct` + +- `__construct(?string $pattern = null, ?string $maskRange = null, string $maskChar = '*')` + +Creates a new secret credit card formatter instance. + +**Parameters:** + +- `$pattern`: Custom format pattern or null for auto-detection (default: null) +- `$maskRange`: Mask range specification or null for auto-detection (default: null) +- `$maskChar`: Character to use for masking (default: '\*') + +### `format` + +- `format(string $input): string` + +Formats and masks the input credit card number. + +**Parameters:** + +- `$input`: The credit card number (can include spaces, dashes, dots, etc.) + +**Returns:** The formatted and masked credit card number + +## Masking + +The formatter applies masking after formatting to ensure predictable positions: + +| Card Type | Example Input | Mask Range | Output | +| -------------------- | ------------------ | ----------- | --------------------- | +| **Visa** | `4123456789012345` | `6-9,11-14` | `4123 **** **** 2345` | +| **MasterCard** | `5112345678901234` | `6-9,11-14` | `5112 **** **** 1234` | +| **American Express** | `341234567890123` | `6-12` | `3412 *******012 3` | +| **Discover** | `6011000990139424` | `6-9,11-14` | `6011 **** **** 9424` | +| **JCB** | `3528000012345678` | `6-9,11-14` | `3528 **** **** 5678` | + +## Examples + +| Input | Output | Card Type | +| --------------------- | --------------------- | -------------- | +| `4123456789012345` | `4123 **** **** 2345` | Visa | +| `5112345678901234` | `5112 **** **** 1234` | MasterCard | +| `341234567890123` | `3412 *******012 3` | Amex | +| `371234567890123` | `3712 *******012 3` | Amex | +| `6011000990139424` | `6011 **** **** 9424` | Discover | +| `3528000012345678` | `3528 **** **** 5678` | JCB | +| `4123-4567-8901-2345` | `4123 **** **** 2345` | Visa (cleaned) | +| `4123 4567 8901 2345` | `4123 **** **** 2345` | Visa (cleaned) | + +## Notes + +- Composes `CreditCardFormatter` for formatting and `MaskFormatter` for masking +- Formats the card number first, then applies masking to the formatted string +- Mask ranges are applied to 1-based positions in the formatted string +- Commas in mask ranges specify multiple separate ranges to mask +- Non-digit characters are automatically removed from input +- Empty strings return formatted empty string with default pattern spacing +- Custom patterns follow `PatternFormatter` syntax (use `#` for digits) +- For custom masking, use `MaskFormatter` range syntax (1-based positions) +- Uses `CreditCardFormatter` for card type detection and formatting diff --git a/src/SecretCreditCardFormatter.php b/src/SecretCreditCardFormatter.php new file mode 100644 index 0000000..754c3fd --- /dev/null +++ b/src/SecretCreditCardFormatter.php @@ -0,0 +1,39 @@ +pattern); + $cleaned = $creditCardFormatter->cleanInput($input); + + $formatted = $creditCardFormatter->format($cleaned); + $maskRange = $this->maskRange ?? $this->detectMaskRange($cleaned); + + return (new MaskFormatter($maskRange, $this->maskChar))->format($formatted); + } + + private function detectMaskRange(string $cleaned): string + { + $length = mb_strlen($cleaned); + + if ($length === 15) { + return '6-12'; + } + + return '6-9,11-14'; + } +} diff --git a/tests/Unit/SecretCreditCardFormatterTest.php b/tests/Unit/SecretCreditCardFormatterTest.php new file mode 100644 index 0000000..20b5277 --- /dev/null +++ b/tests/Unit/SecretCreditCardFormatterTest.php @@ -0,0 +1,304 @@ +format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForMasterCard')] + public function testShouldFormatAndMaskMasterCard(string $input, string $expected): void + { + $formatter = new SecretCreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForAmexCards')] + public function testShouldFormatAndMaskAmexCards(string $input, string $expected): void + { + $formatter = new SecretCreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForDiscoverCards')] + public function testShouldFormatAndMaskDiscoverCards(string $input, string $expected): void + { + $formatter = new SecretCreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForJcbCards')] + public function testShouldFormatAndMaskJcbCards(string $input, string $expected): void + { + $formatter = new SecretCreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForUnrecognizedCards')] + public function testShouldFormatAndMaskUnrecognizedCardsWithDefault(string $input, string $expected): void + { + $formatter = new SecretCreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForCustomPattern')] + public function testShouldUseCustomPattern(string $input, string $pattern, string $expected): void + { + $formatter = new SecretCreditCardFormatter($pattern); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForCustomMaskRange')] + public function testShouldUseCustomMaskRange(string $input, string $maskRange, string $expected): void + { + $formatter = new SecretCreditCardFormatter(null, $maskRange); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForCustomMaskChar')] + public function testShouldUseCustomMaskChar(string $input, string $maskChar, string $expected): void + { + $formatter = new SecretCreditCardFormatter(null, null, $maskChar); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForInputCleaning')] + public function testShouldCleanNonDigitCharacters(string $input, string $expected): void + { + $formatter = new SecretCreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForEdgeCases')] + public function testShouldHandleEdgeCases(string $input, string $expected): void + { + $formatter = new SecretCreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + public function testShouldHandleEmptyString(): void + { + $formatter = new SecretCreditCardFormatter(); + + $actual = $formatter->format(''); + + self::assertSame('', $actual); + } + + #[Test] + #[DataProvider('providerForAllOptions')] + public function testShouldCombineAllCustomOptions(string $input, string $pattern, string $maskRange, string $maskChar, string $expected): void + { + $formatter = new SecretCreditCardFormatter($pattern, $maskRange, $maskChar); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + public static function providerForVisaCards(): array + { + return [ + 'visa 16 digits' => ['4123456789012345', '4123 **** **** 2345'], + 'visa with dashes' => ['4123-4567-8901-2345', '4123 **** **** 2345'], + 'visa with spaces' => ['4123 4567 8901 2345', '4123 **** **** 2345'], + 'visa another' => ['4532015112830366', '4532 **** **** 0366'], + 'visa starts with 4' => ['4916409457367128', '4916 **** **** 7128'], + ]; + } + + /** @return array */ + public static function providerForMasterCard(): array + { + return [ + 'mastercard 51' => ['5112345678901234', '5112 **** **** 1234'], + 'mastercard 55' => ['5512345678901234', '5512 **** **** 1234'], + 'mastercard 52' => ['5212345678901234', '5212 **** **** 1234'], + 'mastercard 53' => ['5312345678901234', '5312 **** **** 1234'], + 'mastercard 54' => ['5412345678901234', '5412 **** **** 1234'], + ]; + } + + /** @return array */ + public static function providerForAmexCards(): array + { + return [ + 'amex 34' => ['341234567890123', '3412 *******012 3'], + 'amex 37' => ['371234567890123', '3712 *******012 3'], + 'amex another 34' => ['347856241795641', '3478 *******956 1'], + 'amex another 37' => ['378282246310005', '3782 *******000 5'], + ]; + } + + /** @return array */ + public static function providerForDiscoverCards(): array + { + return [ + 'discover 6011' => ['6011000990139424', '6011 ******** 9424'], + 'discover 65' => ['6512345678901234', '6512 ******** 1234'], + 'discover 644' => ['6441234567890123', '6441 ******** 0123'], + 'discover 645' => ['6451234567890123', '6451 ******** 0123'], + 'discover 646' => ['6461234567890123', '6461 ******** 0123'], + ]; + } + + /** @return array */ + public static function providerForJcbCards(): array + { + return [ + 'jcb 3528' => ['3528000012345678', '3528 ******** 5678'], + 'jcb 3536' => ['3536000012345678', '3536 ******** 5678'], + 'jcb 3558' => ['3558000012345678', '3558 ******** 5678'], + 'jcb 3589' => ['3589000012345678', '3589 ******** 5678'], + ]; + } + + /** @return array */ + public static function providerForUnrecognizedCards(): array + { + return [ + 'unknown 16 digit' => ['1234567890123456', '1234 ************'], + 'unknown starts with 1' => ['1111222233334444', '1111 ************'], + 'unknown starts with 2' => ['2111222233334444', '2111 ************'], + ]; + } + + /** @return array */ + public static function providerForCustomPattern(): array + { + return [ + 'custom pattern without spaces' => ['4123456789012345', '################', '****************'], + 'custom pattern with dashes' => ['4123456789012345', '####-####-####-####', '****-****-****-2345'], + 'custom pattern groups of 3' => ['4123456789012345', '###-###-###-###-###', '412-***-***-***-234'], + ]; + } + + /** @return array */ + public static function providerForCustomMaskRange(): array + { + return [ + 'mask all except first 4' => ['4123456789012345', '5-', '4123 *************'], + 'mask last 4 only' => ['4123456789012345', '13-16', '4123 4567 8901 ****'], + 'mask middle 4' => ['4123456789012345', '6-9', '4123 **** 8901 2345'], + ]; + } + + /** @return array */ + public static function providerForCustomMaskChar(): array + { + return [ + 'mask with X' => ['4123456789012345', 'X', '4123 XXXXXXXX 2345'], + 'mask with #' => ['4123456789012345', '#', '4123 ######## 2345'], + 'mask with -' => ['4123456789012345', '-', '4123 -------- 2345'], + 'mask with •' => ['4123456789012345', '•', '4123 ••••••••••• 2345'], + ]; + } + + /** @return array */ + public static function providerForInputCleaning(): array + { + return [ + 'with spaces' => ['4123 4567 8901 2345', '4123 ******** 2345'], + 'with dashes' => ['4123-4567-8901-2345', '4123 ******** 2345'], + 'with dots' => ['4123.4567.8901.2345', '4123 ******** 2345'], + 'mixed separators' => ['4123-4567.8901 2345', '4123 ******** 2345'], + 'with letters' => ['4123A4567B8901C2345', '4123 ******** 2345'], + 'with special chars' => ['4123!4567@8901#2345', '4123 ******** 2345'], + ]; + } + + /** @return array */ + public static function providerForEdgeCases(): array + { + return [ + 'empty string' => ['', ''], + 'only spaces' => [' ', ''], + 'only dashes' => ['----', ''], + 'only dots' => ['....', ''], + 'only letters' => ['abcd', ''], + 'short number' => ['123', '123'], + 'mixed content' => ['abcd4123456789012345abcd', '4123 ******** 2345'], + 'numbers longer than pattern' => ['41234567890123456789', '4123 ******** 678'], + ]; + } + + /** @return array */ + public static function providerForAllOptions(): array + { + return [ + 'all custom options' => [ + '4123456789012345', + '####-####-####-####', + '6-9', + 'X', + '4123-XXXX-8901-2345', + ], + 'different pattern and mask' => [ + '341234567890123', + '#### ########## ######', + '4-9', + '#', + '3412 ###### ## ##23', + ], + ]; + } +}