diff --git a/docs/rules.md b/docs/rules.md
index 653b1d9..3857101 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -1,4 +1,4 @@
-# 60 Rules Overview
+# 61 Rules Overview
## ChainExpectCallsRector
@@ -30,6 +30,20 @@ Chains consecutive `expect()` calls into a single chained expectation, combining
+## ConvertAndToExpectRector
+
+Splits `->and()` calls in `expect()` chains into separate `expect()` statements
+
+- class: [`Pest\Rector\Rules\ConvertAndToExpectRector`](../src/Rules/ConvertAndToExpectRector.php)
+
+```diff
+-expect($a)->toBe(10)->and($b)->toBe(20);
++expect($a)->toBe(10);
++expect($b)->toBe(20);
+```
+
+
+
## ConvertAssertToExpectRector
Converts `$this->assert*()` calls to Pest `expect()` chains
diff --git a/src/AbstractRector.php b/src/AbstractRector.php
index 29cc379..6b374e4 100644
--- a/src/AbstractRector.php
+++ b/src/AbstractRector.php
@@ -29,6 +29,7 @@
use PhpParser\Node\Stmt\TryCatch;
use PhpParser\Node\Stmt\While_;
use PhpParser\Node\VariadicPlaceholder;
+use Rector\NodeTypeResolver\Node\AttributeKey;
use Rector\PhpParser\Enum\NodeGroup;
use Rector\PhpParser\Node\FileNode;
use Rector\Rector\AbstractRector as BaseAbstractRector;
@@ -281,6 +282,43 @@ protected function rebuildMethodChain(Expr $base, array $methods): Expr
return $result;
}
+ protected function applyNewlineAttributes(Expr $chain): void
+ {
+ if (! defined(AttributeKey::class.'::NEWLINE_ON_FLUENT_CALL')) {
+ return;
+ }
+
+ $current = $chain;
+
+ while ($current instanceof MethodCall) {
+ $var = $current->var;
+
+ if ($var instanceof FuncCall) {
+ break;
+ }
+
+ if ($var instanceof PropertyFetch) {
+ $current = $var->var;
+
+ continue;
+ }
+
+ if (! $var instanceof MethodCall) {
+ break;
+ }
+
+ if ($this->isName($var->name, 'and')) {
+ $var->setAttribute(AttributeKey::NEWLINE_ON_FLUENT_CALL, true);
+ $current = $var->var;
+
+ continue;
+ }
+
+ $current->setAttribute(AttributeKey::NEWLINE_ON_FLUENT_CALL, true);
+ $current = $var;
+ }
+ }
+
/**
* @param array $sources
*/
diff --git a/src/Rules/ChainExpectCallsRector.php b/src/Rules/ChainExpectCallsRector.php
index f5e7d8f..b4758c8 100644
--- a/src/Rules/ChainExpectCallsRector.php
+++ b/src/Rules/ChainExpectCallsRector.php
@@ -13,7 +13,6 @@
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\ConstFetch;
-use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\NullsafePropertyFetch;
use PhpParser\Node\Expr\PropertyFetch;
@@ -420,41 +419,4 @@ private function mergeDifferentVariableChains(array &$stmts, int $key): bool
return true;
}
-
- private function applyNewlineAttributes(Expr $chain): void
- {
- if (! defined(AttributeKey::class.'::NEWLINE_ON_FLUENT_CALL')) {
- return;
- }
-
- $current = $chain;
-
- while ($current instanceof MethodCall) {
- $var = $current->var;
-
- if ($var instanceof FuncCall) {
- break;
- }
-
- if ($var instanceof PropertyFetch) {
- $current = $var->var;
-
- continue;
- }
-
- if (! $var instanceof MethodCall) {
- break;
- }
-
- if ($this->isName($var->name, 'and')) {
- $var->setAttribute(AttributeKey::NEWLINE_ON_FLUENT_CALL, true);
- $current = $var->var;
-
- continue;
- }
-
- $current->setAttribute(AttributeKey::NEWLINE_ON_FLUENT_CALL, true);
- $current = $var;
- }
- }
}
diff --git a/src/Rules/ConvertAndToExpectRector.php b/src/Rules/ConvertAndToExpectRector.php
new file mode 100644
index 0000000..8576ff3
--- /dev/null
+++ b/src/Rules/ConvertAndToExpectRector.php
@@ -0,0 +1,119 @@
+and()` calls in expect() chains into separate expect() statements',
+ [
+ new CodeSample(
+ <<<'CODE_SAMPLE'
+expect($a)->toBe(10)->and($b)->toBe(20);
+CODE_SAMPLE
+ ,
+ <<<'CODE_SAMPLE'
+expect($a)->toBe(10);
+expect($b)->toBe(20);
+CODE_SAMPLE
+ ),
+ ]
+ );
+ }
+
+ // @codeCoverageIgnoreEnd
+
+ /**
+ * @return array>
+ */
+ public function getNodeTypes(): array
+ {
+ return [Expression::class];
+ }
+
+ /**
+ * @param Expression $node
+ * @return array|null
+ */
+ public function refactor(Node $node): ?array
+ {
+ if (! $node->expr instanceof MethodCall) {
+ return null;
+ }
+
+ if (! $this->isExpectChain($node->expr)) {
+ return null;
+ }
+
+ $segments = [];
+ $segmentOutermost = $node->expr;
+ $current = $node->expr;
+ $child = null;
+
+ while ($current instanceof MethodCall || $current instanceof PropertyFetch) {
+ if (! $current instanceof MethodCall || ! $this->isName($current->name, 'and')) {
+ $child = $current;
+ $current = $current->var;
+
+ continue;
+ }
+
+ if (! isset($current->args[0]) || ! $current->args[0] instanceof Arg) {
+ return null;
+ }
+
+ $andValue = $current->args[0]->value;
+ $andValue->setAttribute(AttributeKey::ORIGINAL_NODE, null);
+ $expectCall = new FuncCall(new Name('expect'), [new Arg($andValue)]);
+
+ if ($child === null) {
+ $segments[] = $expectCall;
+ } else {
+ $child->var = $expectCall;
+ $segments[] = $segmentOutermost;
+ }
+
+ $segmentOutermost = $current->var;
+ $current = $current->var;
+ $child = null;
+ }
+
+ if ($segments === []) {
+ return null;
+ }
+
+ $segments[] = $segmentOutermost;
+ $segments = array_reverse($segments);
+
+ $newStmts = [];
+ foreach ($segments as $index => $segmentExpr) {
+ if ($index === 0) {
+ $node->expr = $segmentExpr;
+ $newStmts[] = $node;
+
+ continue;
+ }
+
+ $newStmts[] = new Expression($segmentExpr);
+ }
+
+ return $newStmts;
+ }
+}
diff --git a/tests/Rules/ChainExpectCallsRectorAndConvertAndToExpect/ChainExpectCallsRectorAndConvertAndToExpectTest.php b/tests/Rules/ChainExpectCallsRectorAndConvertAndToExpect/ChainExpectCallsRectorAndConvertAndToExpectTest.php
new file mode 100644
index 0000000..57dd32f
--- /dev/null
+++ b/tests/Rules/ChainExpectCallsRectorAndConvertAndToExpect/ChainExpectCallsRectorAndConvertAndToExpectTest.php
@@ -0,0 +1,15 @@
+doTestFile($filePath);
+})->with(
+ fn (): Iterator => FixtureFileFinder::yieldDirectory(__DIR__.'/Fixture')
+);
diff --git a/tests/Rules/ChainExpectCallsRectorAndConvertAndToExpect/Fixture/and_chain_split_and_rechained.php.inc b/tests/Rules/ChainExpectCallsRectorAndConvertAndToExpect/Fixture/and_chain_split_and_rechained.php.inc
new file mode 100644
index 0000000..d552af2
--- /dev/null
+++ b/tests/Rules/ChainExpectCallsRectorAndConvertAndToExpect/Fixture/and_chain_split_and_rechained.php.inc
@@ -0,0 +1,20 @@
+toBe(10)->and($b)->toBe(20)->not->toBeNull();
+expect($b)->toBeInt();
+
+?>
+-----
+toBe(10);
+expect($b)->toBe(20)->not->toBeNull()
+ ->toBeInt();
+
+?>
diff --git a/tests/Rules/ChainExpectCallsRectorAndConvertAndToExpect/config/configured_rule.php b/tests/Rules/ChainExpectCallsRectorAndConvertAndToExpect/config/configured_rule.php
new file mode 100644
index 0000000..33fee73
--- /dev/null
+++ b/tests/Rules/ChainExpectCallsRectorAndConvertAndToExpect/config/configured_rule.php
@@ -0,0 +1,14 @@
+rule(ConvertAndToExpectRector::class);
+ $rectorConfig->ruleWithConfiguration(ChainExpectCallsRector::class, [
+ ChainExpectCallsRector::MERGE_DIFFERENT_VARIABLES => false,
+ ]);
+};
diff --git a/tests/Rules/ConvertAndToExpectRector/ConvertAndToExpectRectorTest.php b/tests/Rules/ConvertAndToExpectRector/ConvertAndToExpectRectorTest.php
new file mode 100644
index 0000000..57dd32f
--- /dev/null
+++ b/tests/Rules/ConvertAndToExpectRector/ConvertAndToExpectRectorTest.php
@@ -0,0 +1,15 @@
+doTestFile($filePath);
+})->with(
+ fn (): Iterator => FixtureFileFinder::yieldDirectory(__DIR__.'/Fixture')
+);
diff --git a/tests/Rules/ConvertAndToExpectRector/Fixture/and_chain_split.php.inc b/tests/Rules/ConvertAndToExpectRector/Fixture/and_chain_split.php.inc
new file mode 100644
index 0000000..edcff98
--- /dev/null
+++ b/tests/Rules/ConvertAndToExpectRector/Fixture/and_chain_split.php.inc
@@ -0,0 +1,18 @@
+toBe(10)->and($b)->toBe(20);
+
+?>
+-----
+toBe(10);
+expect($b)->toBe(20);
+
+?>
diff --git a/tests/Rules/ConvertAndToExpectRector/Fixture/multiline_chain_formatting_preserved.php.inc b/tests/Rules/ConvertAndToExpectRector/Fixture/multiline_chain_formatting_preserved.php.inc
new file mode 100644
index 0000000..eaba2fb
--- /dev/null
+++ b/tests/Rules/ConvertAndToExpectRector/Fixture/multiline_chain_formatting_preserved.php.inc
@@ -0,0 +1,25 @@
+status->toBe('draft')
+ ->approved_at->toBeNull()
+ ->riskGroups->toHaveCount(2)
+ ->and($riskAssessment->status)->toBe('approved');
+
+?>
+-----
+status->toBe('draft')
+ ->approved_at->toBeNull()
+ ->riskGroups->toHaveCount(2);
+expect($riskAssessment->status)->toBe('approved');
+
+?>
diff --git a/tests/Rules/ConvertAndToExpectRector/Fixture/multiple_and_segments.php.inc b/tests/Rules/ConvertAndToExpectRector/Fixture/multiple_and_segments.php.inc
new file mode 100644
index 0000000..980a370
--- /dev/null
+++ b/tests/Rules/ConvertAndToExpectRector/Fixture/multiple_and_segments.php.inc
@@ -0,0 +1,21 @@
+toBe(10)->toBeInt()->and($b)->toBe(20)->and($c)->not->toBeInt();
+
+?>
+-----
+toBe(10)->toBeInt();
+expect($b)->toBe(20);
+expect($c)->not->toBeInt();
+
+?>
diff --git a/tests/Rules/ConvertAndToExpectRector/Fixture/no_and_untouched.php.inc b/tests/Rules/ConvertAndToExpectRector/Fixture/no_and_untouched.php.inc
new file mode 100644
index 0000000..1c025c7
--- /dev/null
+++ b/tests/Rules/ConvertAndToExpectRector/Fixture/no_and_untouched.php.inc
@@ -0,0 +1,5 @@
+toBe(10)->toBeInt();
diff --git a/tests/Rules/ConvertAndToExpectRector/config/configured_rule.php b/tests/Rules/ConvertAndToExpectRector/config/configured_rule.php
new file mode 100644
index 0000000..b50d1ad
--- /dev/null
+++ b/tests/Rules/ConvertAndToExpectRector/config/configured_rule.php
@@ -0,0 +1,10 @@
+rule(ConvertAndToExpectRector::class);
+};