Skip to content

Commit 3bd8cae

Browse files
committed
Add multiline_method_chaining rule
1 parent 17e14ad commit 3bd8cae

5 files changed

Lines changed: 258 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,14 @@ This rule keeps nested argument method chains aligned with the first object oper
117117
```
118118

119119
Statement-level chains still use PHP CS Fixer's regular `method_chaining_indentation` behavior.
120+
121+
#### `ChiefTools/multiline_method_chaining`
122+
123+
Once a method chain continues on a new line, each subsequent segment also starts on a new line:
124+
125+
```diff
126+
$items = $service->items()
127+
- ->filter()->values();
128+
+ ->filter()
129+
+ ->values();
130+
```

‎src/Config.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PhpCsFixer\Config as PhpCsFixerConfig;
77
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
88
use ChiefTools\PhpCsFixer\Fixer\BinaryOperatorAlignmentFixer;
9+
use ChiefTools\PhpCsFixer\Fixer\MultilineMethodChainingFixer;
910
use ChiefTools\PhpCsFixer\Fixer\PhpdocFullyQualifiedClassNamesFixer;
1011
use ChiefTools\PhpCsFixer\Fixer\NestedMethodChainingIndentationFixer;
1112

@@ -20,6 +21,7 @@ public static function make(Finder $finder, array $rules = []): PhpCsFixerConfig
2021
->setUnsupportedPhpVersionAllowed(true)
2122
->registerCustomFixers([
2223
new BinaryOperatorAlignmentFixer,
24+
new MultilineMethodChainingFixer,
2325
new NestedMethodChainingIndentationFixer,
2426
new PhpdocFullyQualifiedClassNamesFixer,
2527
])
@@ -35,6 +37,7 @@ public static function rules(): array
3537

3638
'ChiefTools/phpdoc_fqcn' => true,
3739
'ChiefTools/binary_operator_alignment' => true,
40+
'ChiefTools/multiline_method_chaining' => true,
3841
'ChiefTools/nested_method_chaining_indentation' => true,
3942

4043
'single_trait_insert_per_statement' => false,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php
2+
3+
namespace ChiefTools\PhpCsFixer\Fixer;
4+
5+
use SplFileInfo;
6+
use PhpCsFixer\AbstractFixer;
7+
use PhpCsFixer\Tokenizer\Token;
8+
use PhpCsFixer\Tokenizer\Tokens;
9+
use PhpCsFixer\FixerDefinition\CodeSample;
10+
use PhpCsFixer\FixerDefinition\FixerDefinition;
11+
use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
12+
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
13+
14+
class MultilineMethodChainingFixer extends AbstractFixer implements WhitespacesAwareFixerInterface
15+
{
16+
public function getName(): string
17+
{
18+
return 'ChiefTools/multiline_method_chaining';
19+
}
20+
21+
public function getDefinition(): FixerDefinitionInterface
22+
{
23+
return new FixerDefinition(
24+
'Every object operator after the first multiline chain segment should start on a new line.',
25+
[
26+
new CodeSample(
27+
<<<'PHP'
28+
<?php
29+
30+
$items = $service->items()
31+
->filter()->values();
32+
33+
PHP,
34+
),
35+
],
36+
);
37+
}
38+
39+
/** Must run before MethodChainingIndentationFixer. */
40+
public function getPriority(): int
41+
{
42+
return 1;
43+
}
44+
45+
public function isCandidate(Tokens $tokens): bool
46+
{
47+
return $tokens->isAnyTokenKindsFound(Token::getObjectOperatorKinds());
48+
}
49+
50+
protected function applyFix(SplFileInfo $file, Tokens $tokens): void
51+
{
52+
$lineEnding = $this->whitespacesConfig->getLineEnding();
53+
54+
for ($index = 1, $count = count($tokens); $index < $count; $index++) {
55+
if (!$tokens[$index]->isObjectOperator() || $this->startsOnNewLine($tokens, $index)) {
56+
continue;
57+
}
58+
59+
$previousOperatorIndex = $this->previousChainObjectOperator($tokens, $index);
60+
61+
if ($previousOperatorIndex === null || !$this->startsOnNewLine($tokens, $previousOperatorIndex)) {
62+
continue;
63+
}
64+
65+
$newline = new Token([T_WHITESPACE, $lineEnding]);
66+
67+
if ($tokens[$index - 1]->isWhitespace()) {
68+
$tokens[$index - 1] = $newline;
69+
70+
continue;
71+
}
72+
73+
$tokens->insertAt($index, $newline);
74+
$index++;
75+
$count++;
76+
}
77+
}
78+
79+
private function previousChainObjectOperator(Tokens $tokens, int $index): ?int
80+
{
81+
$segmentEndIndex = $tokens->getPrevMeaningfulToken($index);
82+
83+
while ($segmentEndIndex !== null && $this->closesBlockOfType($tokens[$segmentEndIndex], Tokens::BLOCK_TYPE_INDEX_SQUARE_BRACE)) {
84+
$openingBracketIndex = $tokens->findBlockStart(Tokens::BLOCK_TYPE_INDEX_SQUARE_BRACE, $segmentEndIndex);
85+
$segmentEndIndex = $tokens->getPrevMeaningfulToken($openingBracketIndex);
86+
}
87+
88+
if ($segmentEndIndex === null) {
89+
return null;
90+
}
91+
92+
if ($tokens[$segmentEndIndex]->equals(')')) {
93+
$openingParenthesisIndex = $tokens->findBlockStart(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $segmentEndIndex);
94+
$memberIndex = $tokens->getPrevMeaningfulToken($openingParenthesisIndex);
95+
96+
if ($memberIndex === null) {
97+
return null;
98+
}
99+
100+
$segmentEndIndex = $memberIndex;
101+
} elseif ($this->closesBlockOfType($tokens[$segmentEndIndex], Tokens::BLOCK_TYPE_DYNAMIC_PROP_BRACE)) {
102+
$openingBraceIndex = $tokens->findBlockStart(Tokens::BLOCK_TYPE_DYNAMIC_PROP_BRACE, $segmentEndIndex);
103+
$segmentEndIndex = $openingBraceIndex;
104+
}
105+
106+
$operatorIndex = $tokens->getPrevMeaningfulToken($segmentEndIndex);
107+
108+
return $operatorIndex !== null && $tokens[$operatorIndex]->isObjectOperator()
109+
? $operatorIndex
110+
: null;
111+
}
112+
113+
private function closesBlockOfType(Token $token, int $type): bool
114+
{
115+
$block = Tokens::detectBlockType($token);
116+
117+
return $block !== null && !$block['isStart'] && $block['type'] === $type;
118+
}
119+
120+
private function startsOnNewLine(Tokens $tokens, int $index): bool
121+
{
122+
$previousMeaningfulIndex = $tokens->getPrevMeaningfulToken($index);
123+
124+
if ($previousMeaningfulIndex === null) {
125+
return false;
126+
}
127+
128+
for ($i = $previousMeaningfulIndex + 1; $i < $index; $i++) {
129+
if (preg_match('/\R/', $tokens[$i]->getContent())) {
130+
return true;
131+
}
132+
}
133+
134+
return false;
135+
}
136+
}

‎tests/Unit/ConfigTest.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public function testItBuildsAPhpCsFixerConfigWithChiefToolsRules(): void
2626
$this->assertSame($finder, $config->getFinder());
2727
$this->assertTrue($config->getRules()['ChiefTools/binary_operator_alignment']);
2828
$this->assertTrue($config->getRules()['ChiefTools/phpdoc_fqcn']);
29+
$this->assertTrue($config->getRules()['ChiefTools/multiline_method_chaining']);
2930
$this->assertTrue($config->getRules()['ChiefTools/nested_method_chaining_indentation']);
3031
$this->assertTrue($config->getRules()['multiline_promoted_properties']);
3132
$this->assertContains('ChiefTools/binary_operator_alignment', array_map(
@@ -36,6 +37,10 @@ public function testItBuildsAPhpCsFixerConfigWithChiefToolsRules(): void
3637
static fn ($fixer): string => $fixer->getName(),
3738
$config->getCustomFixers(),
3839
));
40+
$this->assertContains('ChiefTools/multiline_method_chaining', array_map(
41+
static fn ($fixer): string => $fixer->getName(),
42+
$config->getCustomFixers(),
43+
));
3944
$this->assertContains('ChiefTools/nested_method_chaining_indentation', array_map(
4045
static fn ($fixer): string => $fixer->getName(),
4146
$config->getCustomFixers(),
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace ChiefTools\PhpCsFixer\Tests\Unit;
4+
5+
use SplFileInfo;
6+
use PHPUnit\Framework\TestCase;
7+
use PhpCsFixer\Tokenizer\Tokens;
8+
use ChiefTools\PhpCsFixer\Fixer\MultilineMethodChainingFixer;
9+
use PhpCsFixer\Fixer\Whitespace\MethodChainingIndentationFixer;
10+
11+
class MultilineMethodChainingFixerTest extends TestCase
12+
{
13+
public function testItMovesSubsequentChainSegmentsToNewLines(): void
14+
{
15+
$source = <<<'PHP'
16+
<?php
17+
18+
$items = $collection
19+
->map(
20+
static fn ($item) => $item->value,
21+
)
22+
->filter(
23+
static fn ($item) => $item->active,
24+
)->values();
25+
26+
PHP;
27+
28+
$expected = <<<'PHP'
29+
<?php
30+
31+
$items = $collection
32+
->map(
33+
static fn ($item) => $item->value,
34+
)
35+
->filter(
36+
static fn ($item) => $item->active,
37+
)
38+
->values();
39+
40+
PHP;
41+
42+
$this->assertSame($expected, $this->fix($source));
43+
}
44+
45+
public function testItMovesEveryRemainingSegmentToANewLine(): void
46+
{
47+
$source = <<<'PHP'
48+
<?php
49+
50+
$items = $service->items()
51+
->filter()->values()?->all();
52+
53+
PHP;
54+
55+
$expected = <<<'PHP'
56+
<?php
57+
58+
$items = $service->items()
59+
->filter()
60+
->values()
61+
?->all();
62+
63+
PHP;
64+
65+
$this->assertSame($expected, $this->fix($source));
66+
}
67+
68+
public function testItLeavesSingleLineChainsAlone(): void
69+
{
70+
$source = <<<'PHP'
71+
<?php
72+
73+
$items = $service->items()->filter()->values();
74+
75+
PHP;
76+
77+
$this->assertSame($source, $this->fix($source));
78+
}
79+
80+
public function testNestedArgumentChainsDoNotMakeTheOuterChainMultiline(): void
81+
{
82+
$source = <<<'PHP'
83+
<?php
84+
85+
$items = $service->items($other
86+
->filter())->values();
87+
88+
PHP;
89+
90+
$this->assertSame($source, $this->fix($source));
91+
}
92+
93+
private function fix(string $source): string
94+
{
95+
$tokens = Tokens::fromCode($source);
96+
$file = new SplFileInfo(__FILE__);
97+
98+
(new MultilineMethodChainingFixer)->fix($file, $tokens);
99+
(new MethodChainingIndentationFixer)->fix($file, $tokens);
100+
101+
return $tokens->generateCode();
102+
}
103+
}

0 commit comments

Comments
 (0)