Skip to content

Commit 1b906e5

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 8e250c0 commit 1b906e5

4 files changed

Lines changed: 236 additions & 2 deletions

File tree

docs/modifiers/ListModifier.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
## Supported Pipes
21+
22+
- `list` - Uses "and" as the conjunction (default behavior)
23+
- `list("and")` - Explicitly uses "and" as the conjunction
24+
- `list('and')` - Explicitly uses "and" as the conjunction (single quotes)
25+
- `list("or")` - Uses "or" as the conjunction
26+
- `list('or')` - Uses "or" as the conjunction (single quotes)
27+
28+
## Usage
29+
30+
```php
31+
use Respect\StringFormatter\PlaceholderFormatter;
32+
33+
$formatter = new PlaceholderFormatter(['fruits' => ['apple', 'banana', 'cherry']]);
34+
35+
echo $formatter->format('{{fruits|list}}');
36+
// Outputs: "apple, banana, and cherry"
37+
38+
echo $formatter->format('{{fruits|list("and")}}');
39+
// Outputs: "apple, banana, and cherry"
40+
41+
echo $formatter->format('{{fruits|list("or")}}');
42+
// Outputs: "apple, banana, or cherry"
43+
```
44+
45+
## Examples
46+
47+
| Parameters | Template | Output |
48+
| -------------------------------------------- | ----------------------------- | ----------------------------- |
49+
| `['items' => ['apple']]` | `"{{items|list}}"` | `"apple"` |
50+
| `['items' => ['apple', 'banana']]` | `"{{items|list}}"` | `"apple and banana"` |
51+
| `['items' => ['apple', 'banana']]` | `"{{items|list("or")}}"` | `"apple or banana"` |
52+
| `['items' => ['apple', 'banana', 'cherry']]` | `"{{items|list}}"` | `"apple, banana, and cherry"` |
53+
| `['items' => ['apple', 'banana', 'cherry']]` | `"{{items|list("or")}}"` | `"apple, banana, or cherry"` |
54+
| `['items' => []]` | `"{{items|list}}"` | `""` |
55+
56+
## Common Use Cases
57+
58+
1. **Displaying user selections** - Show selected options, tags, or categories
59+
2. **Error message formatting** - Present multiple validation errors clearly
60+
3. **Narrative text generation** - Create natural-sounding descriptions
61+
4. **Report generation** - Format lists of items in documents
62+
5. **Choice descriptions** - Format available options for users
63+
64+
## Integration
65+
66+
How this modifier fits in the typical chain:
67+
68+
```php
69+
CustomModifier -> ListModifier -> RawModifier -> StringifyModifier
70+
```
71+
72+
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: 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\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 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")', '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\')', 'list' => 'and',
39+
'list("or")', '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+
}
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\Modifier;
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\Modifier\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)