Skip to content

Commit d01565e

Browse files
authored
Prevent argument count errors by wrapping callables (#234)
1 parent 144968c commit d01565e

File tree

6 files changed

+129
-9
lines changed

6 files changed

+129
-9
lines changed

.php-cs-fixer.dist.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
$config = new PhpCsFixer\Config();
3838
$config
3939
->setRiskyAllowed(true)
40+
->setUnsupportedPhpVersionAllowed(true)
4041
->setRules([
4142
'header_comment' => ['comment_type' => 'PHPDoc', 'header' => $header, 'separate' => 'bottom', 'location' => 'after_open'],
4243
'@PER-CS' => true,

src/Standard.php

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
namespace Pipeline;
2222

23+
use ArgumentCountError;
2324
use ArrayIterator;
2425
use CallbackFilterIterator;
2526
use Countable;
@@ -1380,23 +1381,45 @@ public function finalVariance(
13801381
/**
13811382
* Eagerly iterates over the sequence using the provided callback. Discards the sequence after iteration.
13821383
*
1383-
* @param callable $func A callback such as fn($value, $key); return value is ignored.
1384+
* @param callable(TValue, TKey=): void $func A callback such as fn($value, $key); return value is ignored.
13841385
* @param bool $discard Whether to discard the pipeline's iterator.
13851386
*/
13861387
public function each(callable $func, bool $discard = true): void
1388+
{
1389+
try {
1390+
$this->eachInternal($func);
1391+
} finally {
1392+
if ($discard) {
1393+
$this->discard();
1394+
}
1395+
}
1396+
}
1397+
1398+
/**
1399+
* @param callable(TValue, TKey=): void $func
1400+
*/
1401+
private function eachInternal(callable $func): void
13871402
{
13881403
if ($this->empty()) {
13891404
return;
13901405
}
13911406

1392-
try {
1393-
foreach ($this->pipeline as $key => $value) {
1407+
foreach ($this->pipeline as $key => $value) {
1408+
try {
1409+
$func($value, $key);
1410+
} catch (ArgumentCountError) {
1411+
// Optimization to reduce the number of argument count errors when calling internal callables.
1412+
// This error is thrown when too many arguments are passed to a built-in function (that are sensitive
1413+
// to extra arguments), so we can wrap it to prevent the errors later. On the other hand, if there
1414+
// are too little arguments passed, it will blow up just a line later.
1415+
$func = self::wrapInternalCallable($func);
13941416
$func($value, $key);
1395-
}
1396-
} finally {
1397-
if ($discard) {
1398-
$this->discard();
13991417
}
14001418
}
14011419
}
1420+
1421+
private static function wrapInternalCallable(callable $func): callable
1422+
{
1423+
return static fn($value) => $func($value);
1424+
}
14021425
}

tests/EachTest.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020

2121
namespace Tests\Pipeline;
2222

23+
use ArgumentCountError;
2324
use LogicException;
2425
use PHPUnit\Framework\TestCase;
2526
use Pipeline\Standard;
2627
use ArrayIterator;
28+
use SplQueue;
29+
use Tests\Pipeline\Fixtures\CallableThrower;
2730

2831
use function Pipeline\map;
2932
use function Pipeline\take;
@@ -158,4 +161,58 @@ public function testNotVoidReturn(): void
158161

159162
$this->addToAssertionCount(1);
160163
}
164+
165+
public function testStrictArity(): void
166+
{
167+
$queue = new SplQueue();
168+
$pipeline = fromArray([1, 2, 3]);
169+
$pipeline->each($queue->enqueue(...));
170+
171+
$this->assertSame([1, 2, 3], take($queue)->toList());
172+
}
173+
174+
public function testVariadicInternal(): void
175+
{
176+
$this->expectOutputString("123");
177+
178+
$pipeline = fromArray(['1', '2', '3']);
179+
$pipeline->each(printf(...));
180+
}
181+
182+
public function testVariadicInternalOnIterator(): void
183+
{
184+
$this->expectOutputString("123");
185+
186+
$pipeline = take(new ArrayIterator(['1', '2', '3']));
187+
$pipeline->each(printf(...));
188+
}
189+
190+
public function testArgumentCountError(): void
191+
{
192+
$pipeline = fromArray(['1', '2', '3']);
193+
194+
$this->expectException(ArgumentCountError::class);
195+
$this->expectExceptionMessage('Too few arguments');
196+
$pipeline->each(static function ($a, $b, $c): void {});
197+
}
198+
199+
/**
200+
* Test that the reassignment of the callable inside the loop will affect all iterations.
201+
*/
202+
public function testCallableReassigned(): void
203+
{
204+
$callback = new CallableThrower();
205+
206+
$pipeline = fromArray(['1', '2', '3']);
207+
$pipeline->each($callback);
208+
209+
$this->assertSame(4, $callback->callCount, 'Expected 1 initial call that throws + 3 successful calls after wrapping');
210+
211+
$this->assertSame([
212+
['1', 0],
213+
['1'],
214+
['2'],
215+
['3'],
216+
], $callback->args);
217+
}
161218
}

tests/Fixtures/CallableThrower.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2017, 2018 Alexey Kopytko <[email protected]>
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace Tests\Pipeline\Fixtures;
20+
21+
use ArgumentCountError;
22+
23+
class CallableThrower
24+
{
25+
public array $args = [];
26+
public int $callCount = 0;
27+
28+
public function __invoke(...$args): void
29+
{
30+
$this->args[] = $args;
31+
$this->callCount++;
32+
33+
// @phpstan-ignore-next-line
34+
if (1 === $this->callCount) {
35+
throw new ArgumentCountError();
36+
}
37+
}
38+
}

tests/Inference/TypeInferenceTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ public function testExtractFixtureNamesFromTests(): void
127127
;
128128

129129
/** @var array<string, string> $result */
130-
$this->assertSame(['Foo' => 'Foo'], $result);
130+
$this->assertArrayHasKey('Foo', $result);
131+
$this->assertSame('Foo', $result['Foo']);
131132
}
132133
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
*
3737
* @internal
3838
*/
39-
class TypeAnnotationConsistencyTest extends TestCase
39+
class TypeConsistencyTest extends TestCase
4040
{
4141
/**
4242
* @dataProvider providePublicMethods

0 commit comments

Comments
 (0)