Skip to content

Commit ba22dc2

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 ba22dc2

File tree

6 files changed

+348
-0
lines changed

6 files changed

+348
-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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 ReflectionException;
15+
use Respect\StringFormatter\Mixin\Builder;
16+
use Throwable;
17+
18+
use function array_reduce;
19+
use function sprintf;
20+
use function ucfirst;
21+
22+
/** @mixin Builder */
23+
final readonly class FormatterBuilder implements Formatter
24+
{
25+
/** @var array<Formatter> */
26+
private array $formatters;
27+
28+
public function __construct(Formatter ...$formatters)
29+
{
30+
$this->formatters = $formatters;
31+
}
32+
33+
public static function create(Formatter ...$formatters): self
34+
{
35+
return new self(...$formatters);
36+
}
37+
38+
public function format(string $input): string
39+
{
40+
if ($this->formatters === []) {
41+
throw new InvalidFormatterException('No formatters have been added to the builder');
42+
}
43+
44+
return array_reduce(
45+
$this->formatters,
46+
static fn(string $carry, Formatter $formatter) => $formatter->format($carry),
47+
$input,
48+
);
49+
}
50+
51+
/** @param array<int, mixed> $arguments */
52+
public function __call(string $name, array $arguments): self
53+
{
54+
/** @var class-string<Formatter> $class */
55+
$class = __NAMESPACE__ . '\\' . ucfirst($name) . 'Formatter';
56+
57+
try {
58+
$reflection = new ReflectionClass($class);
59+
if (!$reflection->isSubclassOf(Formatter::class)) {
60+
throw new InvalidFormatterException(sprintf('"%s" does not implement %s', $class, Formatter::class));
61+
}
62+
63+
if (!$reflection->isInstantiable()) {
64+
throw new InvalidFormatterException(sprintf('"%s" is not an instantiatable formatter', $class));
65+
}
66+
67+
return clone($this, ['formatters' => [...$this->formatters, $reflection->newInstanceArgs($arguments)]]);
68+
} catch (ReflectionException $exception) {
69+
throw new InvalidFormatterException(sprintf('Could not find formatter "%s"', $name), 0, $exception);
70+
} catch (Throwable $exception) {
71+
throw new InvalidFormatterException(sprintf('Could not build "%s" formatter', $name), 0, $exception);
72+
}
73+
}
74+
75+
/** @param array<int, mixed> $arguments */
76+
public static function __callStatic(string $name, array $arguments): self
77+
{
78+
return self::create()->__call($name, $arguments);
79+
}
80+
}

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

0 commit comments

Comments
 (0)