Skip to content

Commit b8a99a8

Browse files
committed
Add QuoteModifier to support flexible string quoting
I’ve ported the `QuoteModifier` from Respect\Validation, but I’ve taken a different approach for this implementation. While the original version relied on and implementation of `Quoter` (from Respect\Stringifier) dependency, I decided to remove it for this context. The `Quoter` interface system was designed to handle complex stringification levels and deep nesting—functionality that is simply overkill for this modifier. By stripping that dependency away, we’ve eliminated unnecessary complexity. A major benefit of this simplification is increased flexibility. Instead of being locked into a predefined quoting strategy, users can now define their preferred quoting character (such as ', ", or backticks) directly in the constructor. Assisted-by: OpenCode (GLM-4.6) Assisted-by: Gemini 3 (Thinking)
1 parent a85a876 commit b8a99a8

6 files changed

Lines changed: 316 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,27 @@ When creating new formatters:
3333

3434
All formatters must implement the `Respect\StringFormatter\Formatter` interface.
3535

36+
## Modifier Development
37+
38+
When creating new modifiers:
39+
40+
1. **Follow Chain of Responsibility pattern**: Check pipe value and delegate to next modifier
41+
2. **Use template structure**: Similar to `src/Modifier/QuoteModifier.php`
42+
3. **Test with TestingModifier**: Located in `tests/Helper/TestingModifier.php`
43+
4. **Handle type checking**: Always check input types before processing
44+
5. **Return string values**: Modifiers must return strings
45+
6. **Use Stringifier Quoter**: For string operations, inject `\Respect\Stringifier\Quoter` with CodeQuoter as default
46+
47+
All modifiers must implement the `Respect\StringFormatter\Modifier` interface.
48+
49+
## Testing Guidelines
50+
51+
1. **Avoid PHPUnit mocks**: Create custom test implementations instead of using createMock()
52+
2. **Use custom test quoter**: Follow pattern in `tests/Helper/TestingQuoter.php`
53+
3. **Test contracts not implementations**: Verify interactions without depending on specific behavior
54+
4. **Make test properties public**: When using anonymous classes to access test state
55+
5. **Verify method calls**: Track whether methods were called and with what parameters
56+
3657
## Commit Guidelines
3758

3859
Follow the detailed rules in `docs/contributing/commit-guidelines.md`:

docs/modifiers/Modifiers.md

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

3535
```
36-
RawModifier -> ListModifier -> StringifyModifier
36+
RawModifier -> ListModifier -> QuoteModifier -> 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
4141
- `ListModifier` formats arrays as human-readable lists with "and" or "or" conjunctions
42+
- `QuoteModifier` quotes string values using the stringifier quoter
4243

4344
## Syntax Rules
4445

@@ -72,6 +73,7 @@ For non-scalar values with `|raw`, the modifier falls back to the next modifier
7273
- **[RawModifier](RawModifier.md)** - Outputs scalar values directly without stringifier formatting
7374
- **[StringifyModifier](StringifyModifier.md)** - Default modifier that uses stringifier for all values
7475
- **[ListModifier](ListModifier.md)** - Formats arrays as human-readable lists with conjunctions
76+
- **[QuoteModifier](QuoteModifier.md)** - Quotes string values using a stringifier quoter
7577

7678
## Creating Custom Modifiers
7779

@@ -93,4 +95,4 @@ $formatter = new PlaceholderFormatter(
9395
);
9496
```
9597

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

docs/modifiers/QuoteModifier.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# QuoteModifier
2+
3+
The `QuoteModifier` adds simple quoting to string values using a configurable quote character.
4+
5+
## Purpose
6+
7+
The `|quote` modifier applies string quoting to values, handling escape sequences by escaping the quote character within the string.
8+
9+
## Behavior
10+
11+
The modifier only processes string values. For non-string values, it passes them to the next modifier in the chain.
12+
13+
### Quoting Behavior
14+
15+
- Strings are quoted using the configured quote character (default: `` ` ``)
16+
- Only the configured quote character is escaped within the string using `addcslashes()`
17+
- Non-string values are passed to the next modifier unchanged
18+
- Other quote types (like " or ') are not escaped unless they are the configured quote character
19+
20+
## Usage
21+
22+
```php
23+
use Respect\StringFormatter\PlaceholderFormatter;
24+
use Respect\StringFormatter\Modifier\QuoteModifier;
25+
use Respect\StringFormatter\Modifier\StringifyModifier;
26+
27+
$formatter = new PlaceholderFormatter([
28+
'name' => 'John "Johnny" Doe',
29+
'path' => 'C:\Users\Files\My Document.txt',
30+
'status' => 'It\'s working',
31+
'age' => 30,
32+
'data' => ['x' => 1]
33+
]);
34+
35+
echo $formatter->format('{{name|quote}}');
36+
// Outputs: '`John "Johnny" Doe`'
37+
38+
echo $formatter->format('{{status|quote}}');
39+
// Outputs: '`It's working`'
40+
41+
echo $formatter->format('{{age|quote}}');
42+
// Outputs: "30" (passed to StringifyModifier, no quoting applied)
43+
```
44+
45+
## Examples
46+
47+
### Basic Quoting
48+
49+
| Parameters | Template | Output |
50+
| ----------------------------- | ----------------------- | ------------------- |
51+
| `['name' => 'John']` | `"{{name|quote}}"` | "`John`" |
52+
| `['text' => 'Hello "World"']` | `"{{text|quote}}"` | "`Hello \"World\"`" |
53+
| `['path' => 'C:\temp']` | `"{{path|quote}}"` | "`C:\temp`" |
54+
55+
### Escape Sequences
56+
57+
| Parameters | Template | Output |
58+
| ------------------------------ | ----------------------- | ------------------ |
59+
| `['text' => "Line1\nLine2"]` | `"{{text|quote}}"` | "`Line1\nLine2`" |
60+
| `['text' => "Tab\tSeparated"]` | `"{{text|quote}}"` | "`Tab\tSeparated`" |
61+
| `['text' => "Back\\Slash"]` | `"{{text|quote}}"` | "`Back\Slash`" |
62+
63+
### Non-string Values
64+
65+
| Parameters | Template | Output |
66+
| ------------------------- | ------------------------- | ----------- |
67+
| `['age' => 30]` | `"{{age|quote}}"` | "`30`" |
68+
| `['active' => true]` | `"{{active|quote}}"` | "`true`" |
69+
| `['items' => ['a', 'b']]` | `"{{items|quote}}"` | `["a","b"]` |
70+
71+
## Custom Quote Character
72+
73+
You can create a `QuoteModifier` with a custom quote character:
74+
75+
```php
76+
use Respect\StringFormatter\PlaceholderFormatter;
77+
use Respect\StringFormatter\Modifier\QuoteModifier;
78+
use Respect\StringFormatter\Modifier\RawModifier;
79+
use Respect\StringFormatter\Modifier\StringifyModifier;
80+
81+
// Custom quote character
82+
$quoteModifier = new QuoteModifier($nextModifier, "'");
83+
84+
$formatter = new PlaceholderFormatter(['text' => $value], $quoteModifier);
85+
```
86+
87+
## Implementation Notes
88+
89+
The `QuoteModifier`:
90+
91+
1. Checks if the pipe value is `"quote"` and the input value is a string
92+
2. If both conditions are met, wraps the string with the configured quote character
93+
3. Escapes any occurrences of the quote character within the string using `addcslashes()`
94+
4. Otherwise, passes the value to the next modifier in the chain
95+
5. Uses backticks (`` ` ``) as the default quote character
96+
97+
## Common Use Cases
98+
99+
1. **CSV output** - Properly quote fields containing special characters
100+
2. **Code generation** - Quote string literals in generated code
101+
3. **Configuration files** - Ensure string values are properly escaped
102+
4. **Logging** - Prepare string values for logging with proper escaping
103+
104+
## Integration
105+
106+
`QuoteModifier` is typically positioned early in the chain:
107+
108+
```php
109+
CustomModifier -> QuoteModifier -> RawModifier -> StringifyModifier
110+
```
111+
112+
This ensures string quoting is applied before other processing, while still allowing raw output and stringification fallbacks.

src/Modifier/QuoteModifier.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Modifier;
6+
7+
use Respect\StringFormatter\Modifier;
8+
9+
use function addcslashes;
10+
use function is_scalar;
11+
use function sprintf;
12+
13+
final readonly class QuoteModifier implements Modifier
14+
{
15+
public function __construct(
16+
private Modifier $nextModifier,
17+
private string $quote = '`',
18+
) {
19+
}
20+
21+
public function modify(mixed $value, string|null $pipe): string
22+
{
23+
if ($pipe !== 'quote') {
24+
return $this->nextModifier->modify($value, $pipe);
25+
}
26+
27+
if (!is_scalar($value)) {
28+
return $this->nextModifier->modify($value, null);
29+
}
30+
31+
return sprintf('%s%s%s', $this->quote, addcslashes((string) $value, $this->quote), $this->quote);
32+
}
33+
}

tests/Helper/TestingQuoter.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Test\Helper;
6+
7+
use Respect\Stringifier\Quoter;
8+
9+
use function uniqid;
10+
11+
final class TestingQuoter implements Quoter
12+
{
13+
private string $result;
14+
15+
public function __construct(string|null $result = null)
16+
{
17+
$this->result = $result ?? uniqid();
18+
}
19+
20+
public function quote(string $string, int $depth): string
21+
{
22+
return $this->result;
23+
}
24+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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\QuoteModifier;
12+
use Respect\StringFormatter\Test\Helper\TestingModifier;
13+
14+
#[CoversClass(QuoteModifier::class)]
15+
final class QuoteModifierTest extends TestCase
16+
{
17+
#[Test]
18+
#[DataProvider('providerForPipeNotQuoteCases')]
19+
public function itShouldDelegateToNextModifierWhenPipeIsNotQuote(mixed $value, string|null $pipe): void
20+
{
21+
$nextModifier = new TestingModifier();
22+
$modifier = new QuoteModifier($nextModifier);
23+
$expected = $nextModifier->modify($value, $pipe);
24+
25+
$actual = $modifier->modify($value, $pipe);
26+
27+
self::assertSame($expected, $actual);
28+
}
29+
30+
/** @return array<string, array{0: mixed, 1: string|null}> */
31+
public static function providerForPipeNotQuoteCases(): array
32+
{
33+
return [
34+
'non-string pipe value' => ['some string', 'notQuote'],
35+
'null pipe value' => ['some string', null],
36+
];
37+
}
38+
39+
#[Test]
40+
#[DataProvider('providerForNonScalarWithQuotePipe')]
41+
public function itShouldDelegateToNextModifierWhenValueIsNotScalarAndPipeIsQuote(mixed $value): void
42+
{
43+
$nextModifier = new TestingModifier();
44+
$modifier = new QuoteModifier($nextModifier);
45+
// Non-scalar values with 'quote' pipe should delegate with null pipe
46+
$expected = $nextModifier->modify($value, null);
47+
48+
$actual = $modifier->modify($value, 'quote');
49+
50+
self::assertSame($expected, $actual);
51+
}
52+
53+
/** @return array<string, array{0: mixed}> */
54+
public static function providerForNonScalarWithQuotePipe(): array
55+
{
56+
return [
57+
'array value with quote pipe' => [['not', 'a', 'string']],
58+
'null value with quote pipe' => [null],
59+
'object value with quote pipe' => [(object) ['key' => 'value']],
60+
];
61+
}
62+
63+
#[Test]
64+
#[DataProvider('providerForScalarQuotingCases')]
65+
public function itShouldQuoteScalarValuesWithQuotePipe(
66+
mixed $value,
67+
string $expected,
68+
): void {
69+
$modifier = new QuoteModifier(new TestingModifier());
70+
71+
$actual = $modifier->modify($value, 'quote');
72+
73+
self::assertSame($expected, $actual);
74+
}
75+
76+
/** @return array<string, array{0: mixed, 1: string}> */
77+
public static function providerForScalarQuotingCases(): array
78+
{
79+
return [
80+
'integer value' => [42, '`42`'],
81+
'float value' => [3.14, '`3.14`'],
82+
'boolean true value' => [true, '`1`'],
83+
'boolean false value' => [false, '``'],
84+
];
85+
}
86+
87+
#[Test]
88+
#[DataProvider('providerForStringQuoting')]
89+
public function itShouldQuoteStringsWithVariousContent(string $input, string $expected, string $quote = '`'): void
90+
{
91+
$modifier = new QuoteModifier(new TestingModifier(), $quote);
92+
93+
$actual = $modifier->modify($input, 'quote');
94+
95+
self::assertSame($expected, $actual);
96+
}
97+
98+
/** @return array<string, array{0: string, 1: string, 2: string}> */
99+
public static function providerForStringQuoting(): array
100+
{
101+
return [
102+
'simple string' => ['hello', '`hello`', '`'],
103+
'empty string' => ['', '``', '`'],
104+
'string with backticks' => ['he `hello` there', '`he \\`hello\\` there`', '`'],
105+
'string with backslashes' => ['path\\to\\file', '`path\\to\\file`', '`'],
106+
'string with special characters' => ['!@#$%^&*()', '`!@#$%^&*()`', '`'],
107+
'string with newlines' => ["line1\nline2", "`line1\nline2`", '`'],
108+
'unicode characters' => ['héllo 🌍', '`héllo 🌍`', '`'],
109+
'emoji string' => ['😀🎉', '`😀🎉`', '`'],
110+
'mixed language string' => ['Hello 世界', '`Hello 世界`', '`'],
111+
'html entities' => ['&lt;div&gt;', '`&lt;div&gt;`', '`'],
112+
'url string' => ['https://example.com', '`https://example.com`', '`'],
113+
'email string' => ['user@example.com', '`user@example.com`', '`'],
114+
'single quote string' => ["don't", "`don't`", '`'],
115+
'string with single quotes' => ["he's 'great'", "`he's 'great'`", '`'],
116+
'string with mixed quotes' => ['say "hello" and \'goodbye\'', "`say \"hello\" and 'goodbye'`", '`'],
117+
'test with single quotes' => ["can't stop", '"can\'t stop"', '"'],
118+
'test with double quotes' => ['hello "world"', '"hello \"world\""', '"'],
119+
'test with both quotes' => ['hello "world" and \'test\'', '"hello \"world\" and \'test\'"', '"'],
120+
];
121+
}
122+
}

0 commit comments

Comments
 (0)