Skip to content

Commit 99829b7

Browse files
committed
Add FormatterBuilder for fluent formatter chaining
The FormatterBuilder provides a fluent API to chain multiple formatters together, making it simpler to compose complex string transformations. Instead of manually instantiating and nesting formatters, developers can now use a readable builder pattern. Assisted-by: Claude Code (Claude Opus 4.5)
1 parent 0e4e571 commit 99829b7

File tree

6 files changed

+326
-0
lines changed

6 files changed

+326
-0
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ A powerful and flexible PHP library for formatting and transforming strings.
2020
composer require respect/string-formatter
2121
```
2222

23+
## Usage
24+
25+
You can use individual formatters directly or chain multiple formatters together using the `FormatterBuilder`:
26+
27+
```php
28+
use Respect\StringFormatter\FormatterBuilder as f;
29+
30+
echo f::create()
31+
->mask('7-12')
32+
->pattern('#### #### #### ####')
33+
->format('1234123412341234');
34+
// Output: 1234 12** **** 1234
35+
```
36+
2337
## Formatters
2438

2539
| Formatter | Description |

phpunit.xml.dist

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
44
cacheDirectory=".phpunit.cache">
55
<testsuites>
6+
<testsuite name="integration">
7+
<directory>tests/Integration/</directory>
8+
</testsuite>
69
<testsuite name="unit">
710
<directory>tests/Unit/</directory>
811
</testsuite>

src/FormatterBuilder.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
5+
* SPDX-License-Identifier: ISC
6+
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\StringFormatter;
12+
13+
use ReflectionClass;
14+
use Respect\StringFormatter\Mixin\Builder;
15+
16+
use function array_reduce;
17+
use function ucfirst;
18+
19+
/** @mixin Builder */
20+
final readonly class FormatterBuilder implements Formatter
21+
{
22+
/** @var array<Formatter> */
23+
private array $formatters;
24+
25+
public function __construct(Formatter ...$formatters)
26+
{
27+
$this->formatters = $formatters;
28+
}
29+
30+
public static function create(Formatter ...$formatters): self
31+
{
32+
return new self(...$formatters);
33+
}
34+
35+
public function format(string $input): string
36+
{
37+
if ($this->formatters === []) {
38+
throw new InvalidFormatterException('No formatters have been added to the builder');
39+
}
40+
41+
return array_reduce(
42+
$this->formatters,
43+
static fn(string $carry, Formatter $formatter) => $formatter->format($carry),
44+
$input,
45+
);
46+
}
47+
48+
/** @param array<int, mixed> $arguments */
49+
public function __call(string $name, array $arguments): self
50+
{
51+
/** @var class-string<Formatter> $class */
52+
$class = __NAMESPACE__ . '\\' . ucfirst($name) . 'Formatter';
53+
$reflection = new ReflectionClass($class);
54+
55+
return clone($this, ['formatters' => [...$this->formatters, $reflection->newInstanceArgs($arguments)]]);
56+
}
57+
58+
/** @param array<int, mixed> $arguments */
59+
public static function __callStatic(string $name, array $arguments): self
60+
{
61+
return self::create()->__call($name, $arguments);
62+
}
63+
}

src/Mixin/Builder.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
5+
* SPDX-License-Identifier: ISC
6+
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\StringFormatter\Mixin;
12+
13+
use Respect\StringFormatter\FormatterBuilder;
14+
15+
/** @mixin FormatterBuilder */
16+
interface Builder
17+
{
18+
public static function mask(string $range, string $replacement = '*'): Chain;
19+
20+
public static function pattern(string $pattern): Chain;
21+
22+
/** @param array<string, mixed> $parameters */
23+
public static function placeholder(array $parameters): Chain;
24+
}

src/Mixin/Chain.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
5+
* SPDX-License-Identifier: ISC
6+
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\StringFormatter\Mixin;
12+
13+
use Respect\StringFormatter\Formatter;
14+
use Respect\StringFormatter\FormatterBuilder;
15+
16+
interface Chain extends Formatter
17+
{
18+
public function mask(string $range, string $replacement = '*'): FormatterBuilder;
19+
20+
public function pattern(string $pattern): FormatterBuilder;
21+
22+
/** @param array<string, mixed> $parameters */
23+
public function placeholder(array $parameters): FormatterBuilder;
24+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
/*
4+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
5+
* SPDX-License-Identifier: ISC
6+
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\StringFormatter\Test\Integration;
12+
13+
use ArgumentCountError;
14+
use Error;
15+
use PHPUnit\Framework\Attributes\CoversClass;
16+
use PHPUnit\Framework\Attributes\Test;
17+
use PHPUnit\Framework\TestCase;
18+
use ReflectionException;
19+
use Respect\StringFormatter\FormatterBuilder;
20+
use Respect\StringFormatter\InvalidFormatterException;
21+
use Respect\StringFormatter\MaskFormatter;
22+
use Respect\StringFormatter\PatternFormatter;
23+
use Respect\StringFormatter\PlaceholderFormatter;
24+
25+
use function sprintf;
26+
27+
#[CoversClass(FormatterBuilder::class)]
28+
final class FormatterBuilderTest extends TestCase
29+
{
30+
#[Test]
31+
public function itShouldFormatWithSingleFormatter(): void
32+
{
33+
$input = '1234123412341234';
34+
$range = '1-3,8-12';
35+
$maskFormatter = new MaskFormatter($range);
36+
$expected = $maskFormatter->format($input);
37+
38+
$builder = new FormatterBuilder();
39+
40+
$actual = $builder->mask($range)->format($input);
41+
42+
self::assertSame($expected, $actual);
43+
}
44+
45+
#[Test]
46+
public function itShouldFormatWithMultipleFormatters(): void
47+
{
48+
$input = '1234123412341234';
49+
$range = '1-3,8-12';
50+
$pattern = '#### #### #### ####';
51+
$maskFormatter = new MaskFormatter($range);
52+
$patternFormatter = new PatternFormatter($pattern);
53+
$expected = $patternFormatter->format($maskFormatter->format($input));
54+
55+
$builder = new FormatterBuilder();
56+
57+
$actual = $builder->mask($range)->pattern($pattern)->format($input);
58+
59+
self::assertSame($expected, $actual);
60+
}
61+
62+
#[Test]
63+
public function itShouldThrowExceptionWhenFormattingWithoutFormatters(): void
64+
{
65+
$builder = new FormatterBuilder();
66+
67+
$this->expectException(InvalidFormatterException::class);
68+
$this->expectExceptionMessage('No formatters have been added to the builder');
69+
70+
$builder->format('test');
71+
}
72+
73+
#[Test]
74+
public function itShouldAllowCallingSameFormatterMultipleTimes(): void
75+
{
76+
$input = '1234567890';
77+
$firstRange = '1-3';
78+
$secondRange = '5-7';
79+
$firstMaskFormatter = new MaskFormatter($firstRange);
80+
$secondMaskFormatter = new MaskFormatter($secondRange);
81+
$expected = $secondMaskFormatter->format($firstMaskFormatter->format($input));
82+
83+
$builder = new FormatterBuilder();
84+
$builder = $builder->mask($firstRange)->mask($secondRange);
85+
86+
$actual = $builder->format($input);
87+
88+
self::assertSame($expected, $actual);
89+
}
90+
91+
#[Test]
92+
public function itShouldCreateMaskFormatterUsingStaticFactory(): void
93+
{
94+
$input = '1234567890';
95+
$range = '1-3';
96+
$maskFormatter = new MaskFormatter($range);
97+
$expected = $maskFormatter->format($input);
98+
99+
$actual = FormatterBuilder::mask($range)->format($input);
100+
101+
self::assertSame($expected, $actual);
102+
}
103+
104+
#[Test]
105+
public function itShouldCreatePatternFormatterUsingStaticFactory(): void
106+
{
107+
$input = '1234567890';
108+
$pattern = '###-###-####';
109+
$patternFormatter = new PatternFormatter($pattern);
110+
$expected = $patternFormatter->format($input);
111+
112+
$actual = FormatterBuilder::pattern($pattern)->format($input);
113+
114+
self::assertSame($expected, $actual);
115+
}
116+
117+
#[Test]
118+
public function itShouldCreatePlaceholderFormatterUsingStaticFactory(): void
119+
{
120+
$input = 'Hello, {{name}}!';
121+
$parameters = ['name' => 'World'];
122+
$placeholderFormatter = new PlaceholderFormatter($parameters);
123+
$expected = $placeholderFormatter->format($input);
124+
125+
$actual = FormatterBuilder::placeholder($parameters)->format($input);
126+
127+
self::assertSame($expected, $actual);
128+
}
129+
130+
#[Test]
131+
public function itShouldUsePlaceholderFormatter(): void
132+
{
133+
$input = 'Hello, {{name}}! Your balance is {{amount}}.';
134+
$parameters = [
135+
'name' => 'John',
136+
'amount' => 100.5,
137+
];
138+
$expected = (new PlaceholderFormatter($parameters))->format($input);
139+
140+
$builder = new FormatterBuilder();
141+
$actual = $builder->placeholder($parameters)->format($input);
142+
143+
self::assertSame($expected, $actual);
144+
}
145+
146+
#[Test]
147+
public function itShouldBuildFormatterWithMultipleArguments(): void
148+
{
149+
$input = '1234567890';
150+
$range = '1-3,7-9';
151+
$replacement = 'X';
152+
$maskFormatter = new MaskFormatter($range, $replacement);
153+
$expected = $maskFormatter->format($input);
154+
155+
$builder = new FormatterBuilder();
156+
$actual = $builder->mask($range, $replacement)->format($input);
157+
158+
self::assertSame($expected, $actual);
159+
}
160+
161+
#[Test]
162+
public function itShouldThrowExceptionWhenFormatterIsNotInstantiable(): void
163+
{
164+
$builder = new FormatterBuilder();
165+
166+
$this->expectException(Error::class);
167+
$this->expectExceptionMessage('Cannot instantiate interface Respect\StringFormatter\Formatter');
168+
169+
$builder->__call('', []);
170+
}
171+
172+
#[Test]
173+
public function itShouldThrowExceptionWhenFormatterArgumentIsMissing(): void
174+
{
175+
$builder = new FormatterBuilder();
176+
177+
$this->expectException(ArgumentCountError::class);
178+
$this->expectExceptionMessage(sprintf(
179+
'Too few arguments to function %s::__construct(), 0 passed and exactly 1 expected',
180+
PatternFormatter::class,
181+
));
182+
183+
/** @phpstan-ignore arguments.count */
184+
$builder->pattern();
185+
}
186+
187+
#[Test]
188+
public function itShouldThrowExceptionWhenFormatterDoesNotExist(): void
189+
{
190+
$builder = new FormatterBuilder();
191+
192+
$this->expectException(ReflectionException::class);
193+
$this->expectExceptionMessage('Class "Respect\StringFormatter\NonexistentFormatter" does not exist');
194+
195+
/** @phpstan-ignore method.notFound */
196+
$builder->nonexistent();
197+
}
198+
}

0 commit comments

Comments
 (0)