From faeaead9e19d81561b6a68c6ae273aa777c618de Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Sun, 8 Feb 2026 23:48:05 +0100 Subject: [PATCH] Add multiple pipes support for chaining modifiers in placeholders Placeholders can now chain multiple modifiers sequentially using the pipe separator, e.g. {{value|date:Y/m/d|mask:5-8}}. Each modifier receives the output of the previous one, applied left to right. Escaped pipes (\|) within modifier arguments are preserved as literal characters rather than treated as separators. Assisted-by: Copilot Assisted-by: OpenCode (ollama-cloud/glm-4.7) Assisted-by: Claude Code (Claude Opus 4.6) --- composer.lock | 16 ++-- docs/PlaceholderFormatter.md | 35 ++++++- docs/modifiers/Modifiers.md | 21 +++++ src/PlaceholderFormatter.php | 14 ++- tests/Unit/PlaceholderFormatterTest.php | 118 ++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 11 deletions(-) diff --git a/composer.lock b/composer.lock index 39b0470..527e69e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7cd3f6c15cba927199b28481f2ee9e53", + "content-hash": "3a55ef562d9ef3da73ee3986e81501d1", "packages": [ { "name": "respect/stringifier", @@ -870,16 +870,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -935,7 +935,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -955,7 +955,7 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", @@ -2680,7 +2680,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.5" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/docs/PlaceholderFormatter.md b/docs/PlaceholderFormatter.md index 3a65580..55fce42 100644 --- a/docs/PlaceholderFormatter.md +++ b/docs/PlaceholderFormatter.md @@ -59,6 +59,35 @@ echo $formatter->format('Phone: {{phone|pattern:(###) ###-####}}'); See the [FormatterModifier](modifiers/FormatterModifier.md) documentation for all available formatters and options. +#### Multiple Pipes + +You can chain multiple modifiers together using the pipe (`|`) character. Modifiers are applied sequentially from left to right. + +```php +$formatter = new PlaceholderFormatter([ + 'phone' => '1234567890', + 'value' => '12345', +]); + +// Apply pattern formatting, then mask sensitive data +echo $formatter->format('Phone: {{phone|pattern:(###) ###-####|mask:6-12}}'); +// Output: Phone: (123) ******90 + +// Apply number formatting, then mask +echo $formatter->format('Value: {{value|number:0|mask:1-3}}'); +// Output: Value: ***45 +``` + +**Escaped Pipes:** If you need to use the pipe character (`|`) as part of a modifier argument (not as a separator), escape it with a backslash (`\|`): + +```php +$formatter = new PlaceholderFormatter(['value' => '123456']); + +// Escaped pipe in pattern, then apply mask +echo $formatter->format('{{value|pattern:###\|###|mask:1-3}}'); +// Output: ***|456 +``` + You can also use other modifiers like `list` and `trans`: ```php @@ -91,15 +120,17 @@ Formats with additional parameters merged with constructor parameters. Construct ## Template Syntax -Placeholders follow the format `{{name}}` where `name` is a valid parameter key. Modifiers can be added after a pipe: `{{name|modifier}}`. +Placeholders follow the format `{{name}}` where `name` is a valid parameter key. Modifiers can be added after a pipe: `{{name|modifier}}`. Multiple modifiers can be chained: `{{name|modifier1|modifier2}}`. **Rules:** - Names must match `\w+` (letters, digits, underscore) - Names are case-sensitive - No whitespace inside braces or around the pipe +- Multiple pipes are separated by `|` and applied sequentially +- Escaped pipes (`\|`) within modifiers are treated as literal characters, not separators -**Valid:** `{{name}}`, `{{user_id}}`, `{{name|raw}}` +**Valid:** `{{name}}`, `{{user_id}}`, `{{name|raw}}`, `{{value|date:Y-m-d|mask:1-5}}` **Invalid:** `{name}`, `{{ name }}`, `{{first-name}}`, `{{}}` diff --git a/docs/modifiers/Modifiers.md b/docs/modifiers/Modifiers.md index 65b0082..1979ec6 100644 --- a/docs/modifiers/Modifiers.md +++ b/docs/modifiers/Modifiers.md @@ -15,6 +15,27 @@ Modifiers form a chain where each modifier can: 1. **Handle the value** and return a transformed string 2. **Pass the value** to the next modifier in the chain +### Chaining Multiple Modifiers + +You can chain multiple modifiers together by separating them with the pipe (`|`) character. Modifiers are applied sequentially from left to right, with each modifier receiving the output of the previous one. + +```php +$formatter = new PlaceholderFormatter([ + 'phone' => '1234567890', + 'value' => '123456', +]); + +// Apply pattern formatting, then mask sensitive data +echo $formatter->format('Phone: {{phone|pattern:(###) ###-####|mask:6-12}}'); +// Output: Phone: (123) ******90 + +// Escaped pipe in pattern argument, then apply mask +echo $formatter->format('{{value|pattern:###\|###|mask:1-3}}'); +// Output: ***|456 +``` + +**Important:** When using the pipe character (`|`) as part of a modifier argument (not as a separator), escape it with a backslash (`\|`). + ## Basic Usage ```php diff --git a/src/PlaceholderFormatter.php b/src/PlaceholderFormatter.php index 62e05f1..2c486e1 100644 --- a/src/PlaceholderFormatter.php +++ b/src/PlaceholderFormatter.php @@ -18,6 +18,7 @@ use function array_key_exists; use function preg_replace_callback; +use function preg_split; final readonly class PlaceholderFormatter implements Formatter { @@ -67,6 +68,17 @@ private function processPlaceholder(array $matches, array $parameters): string return $placeholder; } - return $this->modifier->modify($parameters[$name], $pipe); + $value = $parameters[$name]; + if ($pipe === null) { + return $this->modifier->modify($value, null); + } + + $pipes = preg_split('/(?modifier->modify($value, $pipe); + } + + /** @phpstan-ignore return.type */ + return $value; } } diff --git a/tests/Unit/PlaceholderFormatterTest.php b/tests/Unit/PlaceholderFormatterTest.php index 42b3542..5a23f32 100644 --- a/tests/Unit/PlaceholderFormatterTest.php +++ b/tests/Unit/PlaceholderFormatterTest.php @@ -671,4 +671,122 @@ public static function providerForEscapedPipes(): array ], ]; } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForMultiplePipes')] + public function itShouldHandleMultiplePipesInSequence( + array $parameters, + string $template, + string $expected, + ): void { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForMultiplePipes(): array + { + return [ + 'date then mask' => [ + ['value' => '2024-01-15'], + '{{value|date:Y/m/d|mask:5-8}}', + '2024****15', + ], + 'pattern then mask' => [ + ['phone' => '1234567890'], + '{{phone|pattern:(###) ###-####|mask:7-12}}', + '(123) ******90', + ], + 'number then mask' => [ + ['value' => '12345'], + '{{value|number:0|mask:1-2}}', + '**,345', + ], + 'pattern then number' => [ + ['value' => '12345'], + '{{value|pattern:###.##|number:2}}', + '123.45', + ], + 'three pipes: pattern, date, mask' => [ + ['value' => '20240115'], + '{{value|pattern:####-##-##|date:Y/m/d|mask:5-7}}', + '2024***/15', + ], + ]; + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForMultiplePipesWithEscaping')] + public function itShouldHandleMultiplePipesWithEscapedCharacters( + array $parameters, + string $template, + string $expected, + ): void { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForMultiplePipesWithEscaping(): array + { + return [ + 'pattern with escaped pipe then mask' => [ + ['value' => '123456'], + '{{value|pattern:###\|###|mask:1-3}}', + '***|456', + ], + 'pattern with escaped colon then pattern with escaped pipe' => [ + ['value' => '12345678'], + '{{value|pattern:####\:####|pattern:0000\|0000}}', + '1234|5678', + ], + ]; + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForEmptyPipe')] + public function itShouldHandleEmptyPipe( + array $parameters, + string $template, + string $expected, + ): void { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForEmptyPipe(): array + { + return [ + 'empty pipe at end' => [ + ['name' => 'John'], + 'Hello {{name|}}!', + 'Hello John!', + ], + 'empty pipes' => [ + ['name' => 'John'], + 'Hello {{name||}}!', + 'Hello John!', + ], + 'empty pipe in middle' => [ + ['first' => 'A', 'second' => 'B'], + '{{first|}}-{{second}}', + 'A-B', + ], + 'empty pipe with missing parameter' => [ + [], + 'Hello {{name|}}!', + 'Hello {{name|}}!', + ], + ]; + } }