Skip to content

Commit d3ee2d2

Browse files
committed
Add ListModifier ported from Respect\Validation
Implement flexible ListModifier that formats arrays into human-readable lists with configurable conjunctions (and/or), replacing the limited ListAndModifier approach.
1 parent 02759d3 commit d3ee2d2

4 files changed

Lines changed: 266 additions & 2 deletions

File tree

docs/modifiers/ListModifier.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# ListModifier
2+
3+
Formats arrays into human-readable lists using conjunctions like "and" or "or".
4+
5+
## Purpose
6+
7+
This modifier converts array values into natural language lists with proper comma placement and customizable conjunctions, making template output more readable for end users.
8+
9+
## Behavior
10+
11+
The modifier handles different array sizes with appropriate formatting:
12+
13+
- **Empty array**: Passes through to next modifier (no output)
14+
- **Single item**: Returns the item as-is
15+
- **Two items**: Joins with conjunction (" and " or " or ")
16+
- **Three or more items**: Uses Oxford comma style (e.g., "A, B, C, and D")
17+
18+
Each list item is processed through the modifier chain individually before formatting.
19+
20+
## Usage
21+
22+
```php
23+
use Respect\StringFormatter\PlaceholderFormatter;
24+
25+
$formatter = new PlaceholderFormatter(['fruits' => ['apple', 'banana', 'cherry']]);
26+
27+
echo $formatter->format('{{fruits|listAnd}}');
28+
// Outputs: "apple, banana, and cherry"
29+
30+
echo $formatter->format('{{fruits|listOr}}');
31+
// Outputs: "apple, banana, or cherry"
32+
```
33+
34+
## Examples
35+
36+
| Parameters | Template | Output |
37+
| -------------------------------------------- | -------------------------- | ----------------------------- |
38+
| `['items' => ['apple']]` | `"{{items|listAnd}}"` | `"apple"` |
39+
| `['items' => ['apple', 'banana']]` | `"{{items|listAnd}}"` | `"apple and banana"` |
40+
| `['items' => ['apple', 'banana']]` | `"{{items|listOr}}"` | `"apple or banana"` |
41+
| `['items' => ['apple', 'banana', 'cherry']]` | `"{{items|listAnd}}"` | `"apple, banana, and cherry"` |
42+
| `['items' => ['apple', 'banana', 'cherry']]` | `"{{items|listOr}}"` | `"apple, banana, or cherry"` |
43+
| `['items' => []]` | `"{{items|listAnd}}"` | `""` |
44+
45+
## Common Use Cases
46+
47+
1. **Displaying user selections** - Show selected options, tags, or categories
48+
2. **Error message formatting** - Present multiple validation errors clearly
49+
3. **Narrative text generation** - Create natural-sounding descriptions
50+
4. **Report generation** - Format lists of items in documents
51+
5. **Choice descriptions** - Format available options for users
52+
53+
## Integration
54+
55+
How this modifier fits in the typical chain:
56+
57+
```php
58+
CustomModifier -> ListModifier -> RawModifier -> StringifyModifier
59+
```
60+
61+
The modifier delegates individual item processing to the next modifier in the chain, ensuring consistent formatting behavior across the entire application.

docs/modifiers/Modifiers.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ Modifiers form a chain where each modifier can:
3333
The default modifier chain is:
3434

3535
```
36-
RawModifier -> StringifyModifier
36+
RawModifier -> ListModifier -> StringifyModifier
3737
```
3838

3939
- `StringifyModifier` is always the last modifier, using the stringifier to convert values to strings
4040
- `RawModifier` processes scalar values without stringifier formatting
41+
- `ListModifier` formats arrays as human-readable lists with "and" or "or" conjunctions
4142

4243
## Syntax Rules
4344

@@ -70,6 +71,7 @@ For non-scalar values with `|raw`, the modifier falls back to the next modifier
7071

7172
- **[RawModifier](RawModifier.md)** - Outputs scalar values directly without stringifier formatting
7273
- **[StringifyModifier](StringifyModifier.md)** - Default modifier that uses stringifier for all values
74+
- **[ListModifier](ListModifier.md)** - Formats arrays as human-readable lists with conjunctions
7375

7476
## Creating Custom Modifiers
7577

@@ -91,4 +93,4 @@ $formatter = new PlaceholderFormatter(
9193
);
9294
```
9395

94-
If no modifier is provided, the formatter uses a default `RawModifier` with `StringifyModifier`.
96+
If no modifier is provided, the formatter uses a default `RawModifier` with `ListModifier` and `StringifyModifier`.

src/Modifier/ListModifier.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Modifier;
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 is_array;
14+
use function str_starts_with;
15+
16+
final readonly class ListModifier implements Modifier
17+
{
18+
public function __construct(
19+
private Modifier $nextModifier,
20+
) {
21+
}
22+
23+
public function modify(mixed $value, string|null $pipe): string
24+
{
25+
if (!$pipe || !str_starts_with($pipe, 'list') || !is_array($value)) {
26+
return $this->nextModifier->modify($value, $pipe);
27+
}
28+
29+
if (count($value) === 0) {
30+
return $this->nextModifier->modify($value, $pipe);
31+
}
32+
33+
if (count($value) === 1) {
34+
return $this->nextModifier->modify($value[0], null);
35+
}
36+
37+
$conjunction = $this->extractConjunction($pipe);
38+
39+
if (count($value) === 2) {
40+
return $this->nextModifier->modify($value[0], null) . ' ' . $conjunction . ' '
41+
. $this->nextModifier->modify($value[1], null);
42+
}
43+
44+
$lastItem = array_pop($value);
45+
$items = array_map(fn($item) => $this->nextModifier->modify($item, null), $value);
46+
47+
return implode(', ', $items) . ', ' . $conjunction . ' ' . $this->nextModifier->modify($lastItem, null);
48+
}
49+
50+
private function extractConjunction(string $pipe): string
51+
{
52+
if ($pipe === 'listAnd') {
53+
return 'and';
54+
}
55+
56+
if ($pipe === 'listOr') {
57+
return 'or';
58+
}
59+
60+
return 'and'; // Default fallback
61+
}
62+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\Tests\Unit\Formatter;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use PHPUnit\Framework\TestCase;
9+
use Respect\StringFormatter\Modifier\ListModifier;
10+
use Respect\StringFormatter\Test\Helper\TestingModifier;
11+
12+
final class ListModifierTest extends TestCase
13+
{
14+
#[Test]
15+
public function itShouldNotModifyWhenPipeDoesNotMatch(): void
16+
{
17+
$nextModifier = new TestingModifier();
18+
$modifier = new ListModifier($nextModifier);
19+
20+
$value = ['apple', 'banana'];
21+
$pipe = 'invalid';
22+
23+
$result = $modifier->modify($value, $pipe);
24+
25+
self::assertSame($nextModifier->modify($value, $pipe), $result);
26+
}
27+
28+
#[Test]
29+
public function itShouldNotModifyWhenValueIsNotArray(): void
30+
{
31+
$nextModifier = new TestingModifier();
32+
$modifier = new ListModifier($nextModifier);
33+
34+
$value = 'string value';
35+
$pipe = 'listAnd';
36+
37+
$result = $modifier->modify($value, $pipe);
38+
39+
self::assertSame($nextModifier->modify($value, $pipe), $result);
40+
}
41+
42+
#[Test]
43+
public function itShouldPassThroughWhenArrayIsEmpty(): void
44+
{
45+
$nextModifier = new TestingModifier();
46+
$modifier = new ListModifier($nextModifier);
47+
48+
$value = [];
49+
$pipe = 'listAnd';
50+
51+
$result = $modifier->modify($value, $pipe);
52+
53+
self::assertSame($nextModifier->modify($value, $pipe), $result);
54+
}
55+
56+
#[Test]
57+
public function itShouldHandleSingleItemArray(): void
58+
{
59+
$nextModifier = new TestingModifier();
60+
$modifier = new ListModifier($nextModifier);
61+
62+
$value = ['apple'];
63+
$pipe = 'listAnd';
64+
65+
$result = $modifier->modify($value, $pipe);
66+
67+
self::assertSame($nextModifier->modify('apple', null), $result);
68+
}
69+
70+
#[Test]
71+
public function itShouldHandleTwoItemsArrayWithAnd(): void
72+
{
73+
$nextModifier = new TestingModifier();
74+
$modifier = new ListModifier($nextModifier);
75+
76+
$value = ['apple', 'banana'];
77+
$pipe = 'listAnd';
78+
79+
$result = $modifier->modify($value, $pipe);
80+
81+
self::assertSame(
82+
$nextModifier->modify('apple', null) . ' and ' . $nextModifier->modify('banana', null),
83+
$result,
84+
);
85+
}
86+
87+
#[Test]
88+
public function itShouldHandleTwoItemsArrayWithOr(): void
89+
{
90+
$nextModifier = new TestingModifier();
91+
$modifier = new ListModifier($nextModifier);
92+
93+
$value = ['apple', 'banana'];
94+
$pipe = 'listOr';
95+
96+
$result = $modifier->modify($value, $pipe);
97+
98+
self::assertSame(
99+
$nextModifier->modify('apple', null) . ' or ' . $nextModifier->modify('banana', null),
100+
$result,
101+
);
102+
}
103+
104+
#[Test]
105+
public function itShouldHandleMultipleItemsArrayWithAnd(): void
106+
{
107+
$nextModifier = new TestingModifier();
108+
$modifier = new ListModifier($nextModifier);
109+
110+
$value = ['apple', 'banana', 'cherry'];
111+
$pipe = 'listAnd';
112+
113+
$result = $modifier->modify($value, $pipe);
114+
115+
$expected = $nextModifier->modify('apple', null) . ', '
116+
. $nextModifier->modify('banana', null) . ', and '
117+
. $nextModifier->modify('cherry', null);
118+
119+
self::assertSame($expected, $result);
120+
}
121+
122+
#[Test]
123+
public function itShouldHandleMultipleItemsArrayWithOr(): void
124+
{
125+
$nextModifier = new TestingModifier();
126+
$modifier = new ListModifier($nextModifier);
127+
128+
$value = ['apple', 'banana', 'cherry'];
129+
$pipe = 'listOr';
130+
131+
$result = $modifier->modify($value, $pipe);
132+
133+
$expected = $nextModifier->modify('apple', null) . ', '
134+
. $nextModifier->modify('banana', null) . ', or '
135+
. $nextModifier->modify('cherry', null);
136+
137+
self::assertSame($expected, $result);
138+
}
139+
}

0 commit comments

Comments
 (0)