Skip to content

Commit 31328e7

Browse files
committed
Add ListModifier to support human-readable array formatting
I based this new modifier on the `ListAndModifier` and `ListOrModifier` from Respect\Validation, but I wanted to improve on that design. Instead of maintaining two separate modifiers that repeat almost identical behavior just to change a conjunction, I’ve unified them into a single, versatile `ListModifier`. The power of this new approach lies in its flexibility; it allows for configurable conjunctions directly via the pipe syntax. This allows us to transform raw arrays into lists that actually make sense to a human reader. Whether we need a list joined by "and," "or," we can now produce more natural strings instead of falling back on a simple comma-separated list. Assisted-by: OpenCode (GLM-4.6) Assisted-by: Gemini 3 (Thinking)
1 parent 676d264 commit 31328e7

5 files changed

Lines changed: 206 additions & 1 deletion

File tree

docs/modifiers/ListModifier.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# ListModifier
2+
3+
The `|list` modifier formats arrays into human-readable lists with conjunctions.
4+
5+
## Behavior
6+
7+
| Array Size | Output Format |
8+
| ---------- | --------------------------- |
9+
| Empty | Delegates to next modifier |
10+
| 1 item | `apple` |
11+
| 2 items | `apple and banana` |
12+
| 3+ items | `apple, banana, and cherry` |
13+
14+
## Pipes
15+
16+
- `|list` or `|list:and` - Uses :and as conjunction
17+
- `|list:or` - Uses :or as conjunction
18+
19+
## Usage
20+
21+
```php
22+
use Respect\StringFormatter\PlaceholderFormatter;
23+
24+
$formatter = new PlaceholderFormatter([
25+
'fruits' => ['apple', 'banana', 'cherry'],
26+
]);
27+
28+
echo $formatter->format('I like {{fruits|list}}');
29+
// Output: I like apple, banana, and cherry
30+
31+
echo $formatter->format('Choose {{fruits|list:or}}');
32+
// Output: Choose apple, banana, or cherry
33+
```
34+
35+
## Examples
36+
37+
| Parameters | Template | Output |
38+
| ------------------------------ | --------------------- | ------------- |
39+
| `['items' => ['a']]` | `{{items\|list}}` | `a` |
40+
| `['items' => ['a', 'b']]` | `{{items\|list}}` | `a and b` |
41+
| `['items' => ['a', 'b']]` | `{{items\|list:or}}` | `a or b` |
42+
| `['items' => ['a', 'b', 'c']]` | `{{items\|list:and}}` | `a, b, and c` |
43+
| `['items' => ['a', 'b', 'c']]` | `{{items\|list:or}}` | `a, b, or c` |

docs/modifiers/Modifiers.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ If no modifier is provided, the formatter uses `StringifyModifier` by default.
4949
## Available Modifiers
5050

5151
- **[AutoQuoteModifier](AutoQuoteModifier.md)** - Quotes string values by default, `|raw` bypasses quoting
52+
- **[ListModifier](ListModifier.md)** - Formats arrays as human-readable lists with conjunctions
5253
- **[StringifyModifier](StringifyModifier.md)** - Converts values to strings (default)
5354

5455
## Creating Custom Modifiers

src/Modifiers/ListModifier.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Modifiers;
6+
7+
use Respect\StringFormatter\Modifier;
8+
9+
use function array_map;
10+
use function array_pop;
11+
use function count;
12+
use function implode;
13+
use function in_array;
14+
use function is_array;
15+
16+
final readonly class ListModifier implements Modifier
17+
{
18+
private const array ALLOWED_PIPES = ['list', 'list:and', 'list:or'];
19+
20+
public function __construct(
21+
private Modifier $nextModifier,
22+
) {
23+
}
24+
25+
public function modify(mixed $value, string|null $pipe): string
26+
{
27+
if (!$pipe || !in_array($pipe, self::ALLOWED_PIPES) || !is_array($value)) {
28+
return $this->nextModifier->modify($value, $pipe);
29+
}
30+
31+
if ($value === []) {
32+
return $this->nextModifier->modify($value, $pipe);
33+
}
34+
35+
$modifiedValues = array_map(fn($item) => $this->nextModifier->modify($item, null), $value);
36+
37+
$glue = match ($pipe) {
38+
'list:and', 'list' => 'and',
39+
'list:or' => 'or',
40+
};
41+
42+
if (count($value) < 3) {
43+
return implode(' ' . $glue . ' ', $modifiedValues);
44+
}
45+
46+
$last = array_pop($modifiedValues);
47+
48+
return implode(', ', $modifiedValues) . ', ' . $glue . ' ' . $last;
49+
}
50+
}

src/PlaceholderFormatter.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Respect\StringFormatter;
66

7+
use Respect\StringFormatter\Modifiers\ListModifier;
78
use Respect\StringFormatter\Modifiers\StringifyModifier;
89

910
use function array_key_exists;
@@ -15,7 +16,7 @@
1516
/** @param array<string, mixed> $parameters */
1617
public function __construct(
1718
private array $parameters,
18-
private Modifier $modifier = new StringifyModifier(),
19+
private Modifier $modifier = new ListModifier(new StringifyModifier()),
1920
) {
2021
}
2122

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Test\Unit\Modifiers;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\Attributes\Test;
10+
use PHPUnit\Framework\TestCase;
11+
use Respect\StringFormatter\Modifiers\ListModifier;
12+
use Respect\StringFormatter\Test\Helper\TestingModifier;
13+
14+
#[CoversClass(ListModifier::class)]
15+
final class ListModifierTest extends TestCase
16+
{
17+
#[Test]
18+
#[DataProvider('providerNonSupportedValuesAndPipes')]
19+
public function itShouldDelegateToNextModifier(string|null $pipe, mixed $value): void
20+
{
21+
$nextModifier = new TestingModifier();
22+
23+
$modifier = new ListModifier($nextModifier);
24+
25+
$result = $modifier->modify($value, $pipe);
26+
27+
self::assertSame($nextModifier->modify($value, $pipe), $result);
28+
}
29+
30+
/** @return array<string, array{0: string|null, 1: mixed}> */
31+
public static function providerNonSupportedValuesAndPipes(): array
32+
{
33+
return [
34+
'pipe is null' => [null, ['a', 'b', 'c']],
35+
'pipe is not list' => ['notList', ['a', 'b', 'c']],
36+
'value is not array' => ['list:and', 'not an array'],
37+
'value is empty array' => ['list:or', []],
38+
'modifier is not well formatted' => ['list(and")', []],
39+
];
40+
}
41+
42+
/** @param array<int|string, string> $value */
43+
#[Test]
44+
#[DataProvider('providerSupportedValuesAndPipes')]
45+
public function itShouldModifyValue(string $pipe, array $value, string $expected): void
46+
{
47+
$modifier = new ListModifier(new TestingModifier());
48+
49+
$result = $modifier->modify($value, $pipe);
50+
51+
self::assertSame($expected, $result);
52+
}
53+
54+
/** @return array<string, array{0: string, 1: array<int|string, string>, 2: string}> */
55+
public static function providerSupportedValuesAndPipes(): array
56+
{
57+
return [
58+
'with a single value' => [
59+
'list',
60+
['apple'],
61+
'apple',
62+
],
63+
':and with a single value' => [
64+
'list:and',
65+
['apple'],
66+
'apple',
67+
],
68+
':or with a single value' => [
69+
'list:or',
70+
['apple'],
71+
'apple',
72+
],
73+
'with two values' => [
74+
'list',
75+
['apple', 'banana'],
76+
'apple and banana',
77+
],
78+
':and with two values' => [
79+
'list:and',
80+
['apple', 'banana'],
81+
'apple and banana',
82+
],
83+
':or with two values' => [
84+
'list:or',
85+
['apple', 'banana'],
86+
'apple or banana',
87+
],
88+
'with multiple values' => [
89+
'list',
90+
['apple', 'banana', 'cherry', 'date', 'elderberry'],
91+
'apple, banana, cherry, date, and elderberry',
92+
],
93+
':and with multiple values' => [
94+
'list:and',
95+
['apple', 'banana', 'cherry', 'date', 'elderberry'],
96+
'apple, banana, cherry, date, and elderberry',
97+
],
98+
':or with multiple values' => [
99+
'list:or',
100+
['apple', 'banana', 'cherry', 'date', 'elderberry'],
101+
'apple, banana, cherry, date, or elderberry',
102+
],
103+
'with associative array' => [
104+
'list',
105+
['a' => 'apple', 'b' => 'banana', 'c' => 'cherry', 'd' => 'date', 'e' => 'elderberry'],
106+
'apple, banana, cherry, date, and elderberry',
107+
],
108+
];
109+
}
110+
}

0 commit comments

Comments
 (0)