Skip to content

Commit 7d5a76d

Browse files
committed
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)
1 parent 1a07f89 commit 7d5a76d

File tree

5 files changed

+194
-11
lines changed

5 files changed

+194
-11
lines changed

composer.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/PlaceholderFormatter.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,35 @@ echo $formatter->format('Phone: {{phone|pattern:(###) ###-####}}');
5959

6060
See the [FormatterModifier](modifiers/FormatterModifier.md) documentation for all available formatters and options.
6161

62+
#### Multiple Pipes
63+
64+
You can chain multiple modifiers together using the pipe (`|`) character. Modifiers are applied sequentially from left to right.
65+
66+
```php
67+
$formatter = new PlaceholderFormatter([
68+
'phone' => '1234567890',
69+
'value' => '12345',
70+
]);
71+
72+
// Apply pattern formatting, then mask sensitive data
73+
echo $formatter->format('Phone: {{phone|pattern:(###) ###-####|mask:6-12}}');
74+
// Output: Phone: (123) ******90
75+
76+
// Apply number formatting, then mask
77+
echo $formatter->format('Value: {{value|number:0|mask:1-3}}');
78+
// Output: Value: ***45
79+
```
80+
81+
**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 (`\|`):
82+
83+
```php
84+
$formatter = new PlaceholderFormatter(['value' => '123456']);
85+
86+
// Escaped pipe in pattern, then apply mask
87+
echo $formatter->format('{{value|pattern:###\|###|mask:1-3}}');
88+
// Output: ***|456
89+
```
90+
6291
You can also use other modifiers like `list` and `trans`:
6392

6493
```php
@@ -91,15 +120,17 @@ Formats with additional parameters merged with constructor parameters. Construct
91120

92121
## Template Syntax
93122

94-
Placeholders follow the format `{{name}}` where `name` is a valid parameter key. Modifiers can be added after a pipe: `{{name|modifier}}`.
123+
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}}`.
95124

96125
**Rules:**
97126

98127
- Names must match `\w+` (letters, digits, underscore)
99128
- Names are case-sensitive
100129
- No whitespace inside braces or around the pipe
130+
- Multiple pipes are separated by `|` and applied sequentially
131+
- Escaped pipes (`\|`) within modifiers are treated as literal characters, not separators
101132

102-
**Valid:** `{{name}}`, `{{user_id}}`, `{{name|raw}}`
133+
**Valid:** `{{name}}`, `{{user_id}}`, `{{name|raw}}`, `{{value|date:Y-m-d|mask:1-5}}`
103134

104135
**Invalid:** `{name}`, `{{ name }}`, `{{first-name}}`, `{{}}`
105136

docs/modifiers/Modifiers.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,27 @@ Modifiers form a chain where each modifier can:
1515
1. **Handle the value** and return a transformed string
1616
2. **Pass the value** to the next modifier in the chain
1717

18+
### Chaining Multiple Modifiers
19+
20+
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.
21+
22+
```php
23+
$formatter = new PlaceholderFormatter([
24+
'phone' => '1234567890',
25+
'value' => '123456',
26+
]);
27+
28+
// Apply pattern formatting, then mask sensitive data
29+
echo $formatter->format('Phone: {{phone|pattern:(###) ###-####|mask:6-12}}');
30+
// Output: Phone: (123) ******90
31+
32+
// Escaped pipe in pattern argument, then apply mask
33+
echo $formatter->format('{{value|pattern:###\|###|mask:1-3}}');
34+
// Output: ***|456
35+
```
36+
37+
**Important:** When using the pipe character (`|`) as part of a modifier argument (not as a separator), escape it with a backslash (`\|`).
38+
1839
## Basic Usage
1940

2041
```php

src/PlaceholderFormatter.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
use Respect\StringFormatter\Modifiers\TransModifier;
1818

1919
use function array_key_exists;
20+
use function is_array;
2021
use function preg_replace_callback;
22+
use function preg_split;
2123

2224
final readonly class PlaceholderFormatter implements Formatter
2325
{
@@ -67,6 +69,17 @@ private function processPlaceholder(array $matches, array $parameters): string
6769
return $placeholder;
6870
}
6971

70-
return $this->modifier->modify($parameters[$name], $pipe);
72+
$value = $parameters[$name];
73+
if ($pipe === null) {
74+
return $this->modifier->modify($value, null);
75+
}
76+
77+
$pipes = preg_split('/(?<!\\\\)\|/', $pipe) ?: [];
78+
foreach ($pipes as $pipe) {
79+
$value = $this->modifier->modify($value, $pipe);
80+
}
81+
82+
/** @phpstan-ignore return.type */
83+
return $value;
7184
}
7285
}

tests/Unit/PlaceholderFormatterTest.php

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,4 +671,122 @@ public static function providerForEscapedPipes(): array
671671
],
672672
];
673673
}
674+
675+
/** @param array<string, mixed> $parameters */
676+
#[Test]
677+
#[DataProvider('providerForMultiplePipes')]
678+
public function itShouldHandleMultiplePipesInSequence(
679+
array $parameters,
680+
string $template,
681+
string $expected,
682+
): void {
683+
$formatter = new PlaceholderFormatter($parameters);
684+
$actual = $formatter->format($template);
685+
686+
self::assertSame($expected, $actual);
687+
}
688+
689+
/** @return array<string, array{0: array<string, mixed>, 1: string, 2: string}> */
690+
public static function providerForMultiplePipes(): array
691+
{
692+
return [
693+
'date then mask' => [
694+
['value' => '2024-01-15'],
695+
'{{value|date:Y/m/d|mask:5-8}}',
696+
'2024****15',
697+
],
698+
'pattern then mask' => [
699+
['phone' => '1234567890'],
700+
'{{phone|pattern:(###) ###-####|mask:7-12}}',
701+
'(123) ******90',
702+
],
703+
'number then mask' => [
704+
['value' => '12345'],
705+
'{{value|number:0|mask:1-2}}',
706+
'**,345',
707+
],
708+
'pattern then number' => [
709+
['value' => '12345'],
710+
'{{value|pattern:###.##|number:2}}',
711+
'123.45',
712+
],
713+
'three pipes: pattern, date, mask' => [
714+
['value' => '20240115'],
715+
'{{value|pattern:####-##-##|date:Y/m/d|mask:5-7}}',
716+
'2024***/15',
717+
],
718+
];
719+
}
720+
721+
/** @param array<string, mixed> $parameters */
722+
#[Test]
723+
#[DataProvider('providerForMultiplePipesWithEscaping')]
724+
public function itShouldHandleMultiplePipesWithEscapedCharacters(
725+
array $parameters,
726+
string $template,
727+
string $expected,
728+
): void {
729+
$formatter = new PlaceholderFormatter($parameters);
730+
$actual = $formatter->format($template);
731+
732+
self::assertSame($expected, $actual);
733+
}
734+
735+
/** @return array<string, array{0: array<string, mixed>, 1: string, 2: string}> */
736+
public static function providerForMultiplePipesWithEscaping(): array
737+
{
738+
return [
739+
'pattern with escaped pipe then mask' => [
740+
['value' => '123456'],
741+
'{{value|pattern:###\|###|mask:1-3}}',
742+
'***|456',
743+
],
744+
'pattern with escaped colon then pattern with escaped pipe' => [
745+
['value' => '12345678'],
746+
'{{value|pattern:####\:####|pattern:0000\|0000}}',
747+
'1234|5678',
748+
],
749+
];
750+
}
751+
752+
/** @param array<string, mixed> $parameters */
753+
#[Test]
754+
#[DataProvider('providerForEmptyPipe')]
755+
public function itShouldHandleEmptyPipe(
756+
array $parameters,
757+
string $template,
758+
string $expected,
759+
): void {
760+
$formatter = new PlaceholderFormatter($parameters);
761+
$actual = $formatter->format($template);
762+
763+
self::assertSame($expected, $actual);
764+
}
765+
766+
/** @return array<string, array{0: array<string, mixed>, 1: string, 2: string}> */
767+
public static function providerForEmptyPipe(): array
768+
{
769+
return [
770+
'empty pipe at end' => [
771+
['name' => 'John'],
772+
'Hello {{name|}}!',
773+
'Hello John!',
774+
],
775+
'empty pipes' => [
776+
['name' => 'John'],
777+
'Hello {{name||}}!',
778+
'Hello John!',
779+
],
780+
'empty pipe in middle' => [
781+
['first' => 'A', 'second' => 'B'],
782+
'{{first|}}-{{second}}',
783+
'A-B',
784+
],
785+
'empty pipe with missing parameter' => [
786+
[],
787+
'Hello {{name|}}!',
788+
'Hello {{name|}}!',
789+
],
790+
];
791+
}
674792
}

0 commit comments

Comments
 (0)