Skip to content

Commit 95e123e

Browse files
jrfnlgsherwoodedorian
committed
✨ New Util\StatusWriter for writing output to STDERR
This is a different implementation of the principle for writing to `stdErr` as originally committed in 2020. In the original implementation: * The methods for writing to `stdErr` were added to the `Common` utility method class. As this is a group of methods which belong closely together, moving them to a separate class seems more appropriate. * The `[force]write()` method(s) had a (bool) `$suppressNewline` parameter with a default value of `false`. This has been changed to a (int) `$newlines` parameter with a default value of `1`, which provides more flexibility. * Writing to `STDERR` was hard-coded in the `forcePrint()` method. As PHPUnit has no facility for capturing output send to `stdErr`, this made testing output send to `stdErr` impossible. The stream to write to has now been set up as a (`private`) class property and a test helper trait and an abstract base class for testing output send to `stdErr` have been added to the testsuite. * The original implementation allowed for pausing/resuming the StatusWriter, but did not allow for "nested" pauses. Support for this has been added. This commit introduces the "wiring" for all this, i.e. the new `StatusWriter` class, fully covered by tests and the test helpers. Other notes: * Just like in the original implementation, this is still a "static" class as we need to be able to globally pause/unpause the `StatusWriter` when the PHPCS fixer is running, so we can't have separate instances of the `StatusWriter` class everywhere it is used. I did consider making it a singleton, but I see no upside to that compared to a static class as testing is awkward in both cases. * I did consider adding an interface to allow for additional `Writer` classes - I have one in mind for a new feature for PHPCS 4.1 -, but when looking at that new feature, I realized that the method signatures would need to diverge between those classes, so introducing an interface would make live harder not more straight-forward. Props to edorian for [inspiration for the test setup](https://stackoverflow.com/questions/8348927/is-there-a-way-test-stderr-output-in-phpunit). Co-authored-by: Greg Sherwood <[email protected]> Co-authored-by: Volker Dusch <[email protected]>
1 parent 27a2700 commit 95e123e

File tree

5 files changed

+754
-0
lines changed

5 files changed

+754
-0
lines changed

.github/CONTRIBUTING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,13 @@ To run the tests specific to the use of `PHP_CODESNIFFER_CBF === true`:
386386
In such cases, the `PHP_CodeSniffer\Tests\Core\Config\AbstractRealConfigTestCase` should be used as the base test class.
387387
* Tests for the `Runner` class often can't create their own `Config` object in the tests, so run into the same issue.
388388
Those tests should use the `PHP_CodeSniffer\Tests\Core\Runner\AbstractRunnerTestCase` base class, which will ensure the Config is clean.
389+
* Testing output sent to `stdErr` via the `StatusWriter` class is not possible by default using PHPUnit.
390+
A work-around is available, however, as the `StatusWriter` is a "static class", that work-around involves static properties which need to be (re-)set between tests to ensure tests are pure.
391+
So, to test output sent to `stdErr` via the `StatusWriter`, use the `PHP_CodeSniffer\Tests\Core\AbstractWriterTestCase` base class if your tests do not need their own `setUp()` and `tearDown()` methods.
392+
If your tests **_do_** need their own `setUp()` and `tearDown()` methods, or would benefit more from using one of the other base TestCase classes, use the `PHP_CodeSniffer\Tests\Core\StatusWriterTestHelper` trait and call the appropriate setup/teardown helper methods from within your own `setUp()` and `tearDown()` methods.
393+
Tests using the `AbstractWriterTestCase` class or the trait, also get access to the following test helpers for use when testing output sent to `stdErr`: `expectNoStdoutOutput()`, `assertStderrOutputSameString($expected)` and `assertStderrOutputMatchesRegex($regex)`.
394+
Generally speaking, it is a good idea to always add a call to `expectNoStdoutOutput()` in any test using the `assertStderrOutput*()` assertions to make sure there is no output leaking to `stdOut`.
395+
389396

390397
### Submitting Your Pull Request
391398

src/Util/Writers/StatusWriter.php

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<?php
2+
/**
3+
* Status Writer to send output to STDERR.
4+
*
5+
* ---------------------------------------------------------------------------------------------
6+
* This class is intended for internal use only and is not part of the public API.
7+
* This also means that it has no promise of backward compatibility. Use at your own risk.
8+
* ---------------------------------------------------------------------------------------------
9+
*
10+
* @internal
11+
*
12+
* @author Greg Sherwood <[email protected]>
13+
* @author Juliette Reinders Folmer <[email protected]>
14+
* @copyright 2025 PHPCSStandards and contributors
15+
* @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
16+
*/
17+
18+
namespace PHP_CodeSniffer\Util\Writers;
19+
20+
final class StatusWriter
21+
{
22+
23+
/**
24+
* The stream to write to.
25+
*
26+
* @var resource
27+
*/
28+
private static $stream = STDERR;
29+
30+
/**
31+
* If TRUE, requests to print a status message will be ignored.
32+
*
33+
* @var boolean
34+
*/
35+
private static $paused = false;
36+
37+
/**
38+
* Number of open pause requests.
39+
*
40+
* If the writer is paused from different places, we only want to resume when all those places
41+
* have given the okay for it. Let's call it "pause nesting".
42+
*
43+
* @var integer
44+
*/
45+
private static $pauseCount = 0;
46+
47+
48+
/**
49+
* Prints a status message to STDERR.
50+
*
51+
* If status messages have been paused, the message will be not be output.
52+
* Use forceWrite() to forcibly print a message in this case.
53+
*
54+
* @param string $message The message to print.
55+
* @param int $indent How many levels to indent the message.
56+
* Tabs are used to indent status
57+
* messages.
58+
* @param int $newlines Number of new lines to add to the messages.
59+
* Defaults to 1. Set to 0 to suppress adding a new line.
60+
*
61+
* @return void
62+
*/
63+
public static function write($message, $indent=0, $newlines=1)
64+
{
65+
if (self::$paused === true) {
66+
return;
67+
}
68+
69+
self::forceWrite($message, $indent, $newlines);
70+
71+
}//end write()
72+
73+
74+
/**
75+
* Prints a status message to STDERR, even if status messages have been paused.
76+
*
77+
* @param string $message The message to print.
78+
* @param int $indent How many levels to indent the message.
79+
* Tabs are used to indent status
80+
* messages.
81+
* @param int $newlines Number of new lines to add to the messages.
82+
* Defaults to 1. Set to 0 to suppress adding a new line.
83+
*
84+
* @return void
85+
*/
86+
public static function forceWrite($message, $indent=0, $newlines=1)
87+
{
88+
if ($indent > 0) {
89+
$message = str_repeat("\t", $indent).$message;
90+
}
91+
92+
if ($newlines > 0) {
93+
$message .= str_repeat(PHP_EOL, $newlines);
94+
}
95+
96+
fwrite(self::$stream, $message);
97+
98+
}//end forceWrite()
99+
100+
101+
/**
102+
* Prints a new line to STDERR.
103+
*
104+
* @param int $nr Number of new lines to print.
105+
* Defaults to 1.
106+
*
107+
* @return void
108+
*/
109+
public static function writeNewline($nr=1)
110+
{
111+
self::write('', 0, $nr);
112+
113+
}//end writeNewline()
114+
115+
116+
/**
117+
* Prints a new line to STDERR, even if status messages have been paused.
118+
*
119+
* @param int $nr Number of new lines to print.
120+
* Defaults to 1.
121+
*
122+
* @return void
123+
*/
124+
public static function forceWriteNewline($nr=1)
125+
{
126+
self::forceWrite('', 0, $nr);
127+
128+
}//end forceWriteNewline()
129+
130+
131+
/**
132+
* Pauses the printing of status messages.
133+
*
134+
* @return void
135+
*/
136+
public static function pause()
137+
{
138+
self::$paused = true;
139+
++self::$pauseCount;
140+
141+
}//end pause()
142+
143+
144+
/**
145+
* Resumes the printing of status messages.
146+
*
147+
* @return void
148+
*/
149+
public static function resume()
150+
{
151+
if (self::$pauseCount > 0) {
152+
--self::$pauseCount;
153+
}
154+
155+
if (self::$pauseCount === 0) {
156+
self::$paused = false;
157+
}
158+
159+
}//end resume()
160+
161+
162+
/**
163+
* Check whether the StatusWriter is paused.
164+
*
165+
* @return bool
166+
*/
167+
public static function isPaused()
168+
{
169+
return self::$paused;
170+
171+
}//end isPaused()
172+
173+
174+
}//end class

tests/Core/AbstractWriterTestCase.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
/**
3+
* Base class for testing output sent to STDERR.
4+
*
5+
* @copyright 2025 PHPCSStandards and contributors
6+
* @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
7+
*/
8+
9+
namespace PHP_CodeSniffer\Tests\Core;
10+
11+
use PHP_CodeSniffer\Tests\Core\StatusWriterTestHelper;
12+
use PHPUnit\Framework\TestCase;
13+
14+
abstract class AbstractWriterTestCase extends TestCase
15+
{
16+
use StatusWriterTestHelper;
17+
18+
}//end class

tests/Core/StatusWriterTestHelper.php

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
/**
3+
* Helper methods for testing output sent to STDERR.
4+
*
5+
* @copyright 2025 PHPCSStandards and contributors
6+
* @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
7+
*/
8+
9+
namespace PHP_CodeSniffer\Tests\Core;
10+
11+
use PHP_CodeSniffer\Util\Writers\StatusWriter;
12+
use ReflectionProperty;
13+
14+
trait StatusWriterTestHelper
15+
{
16+
17+
/**
18+
* Stream to capture the output.
19+
*
20+
* @var resource
21+
*/
22+
private $stream;
23+
24+
25+
/**
26+
* Redirect the StatusWriter output from STDERR to memory.
27+
*
28+
* If the setUp() method is overloaded, call the redirectStatusWriterOutputToStream() method from your own setUp().
29+
*
30+
* @return void
31+
*/
32+
protected function setUp(): void
33+
{
34+
$this->redirectStatusWriterOutputToStream();
35+
36+
}//end setUp()
37+
38+
39+
/**
40+
* Reset all static properties on the StatusWriter class.
41+
*
42+
* If the tearDown() method is overloaded, call the resetStatusWriterProperties() method from your own tearDown().
43+
*
44+
* @return void
45+
*/
46+
protected function tearDown(): void
47+
{
48+
$this->resetStatusWriterProperties();
49+
50+
}//end tearDown()
51+
52+
53+
/**
54+
* Redirect the output from STDERR to memory.
55+
*
56+
* This method should typically be called from within the setUp() method of the test using this trait.
57+
*
58+
* @return void
59+
*/
60+
protected function redirectStatusWriterOutputToStream(): void
61+
{
62+
$stream = fopen('php://memory', 'rw');
63+
64+
if ($stream === false) {
65+
return;
66+
}
67+
68+
$this->stream = $stream;
69+
70+
$streamProperty = new ReflectionProperty(StatusWriter::class, 'stream');
71+
$streamProperty->setAccessible(true);
72+
$streamProperty->setValue(null, $this->stream);
73+
$streamProperty->setAccessible(false);
74+
75+
}//end redirectStatusWriterOutputToStream()
76+
77+
78+
/**
79+
* Reset static property.
80+
*
81+
* This method should typically be called from within the tearDown() method of the test using this trait.
82+
*
83+
* @return void
84+
*/
85+
protected function resetStatusWriterStream(): void
86+
{
87+
// Reset the static property to its default.
88+
$streamProperty = new ReflectionProperty(StatusWriter::class, 'stream');
89+
$streamProperty->setAccessible(true);
90+
$streamProperty->setValue(null, STDERR);
91+
$streamProperty->setAccessible(false);
92+
93+
}//end resetStatusWriterStream()
94+
95+
96+
/**
97+
* Reset all static properties on the StatusWriter class.
98+
*
99+
* @return void
100+
*/
101+
protected function resetStatusWriterProperties(): void
102+
{
103+
while (StatusWriter::isPaused() === true) {
104+
StatusWriter::resume();
105+
}
106+
107+
$this->resetStatusWriterStream();
108+
109+
}//end resetStatusWriterProperties()
110+
111+
112+
/**
113+
* Assert that no output was sent to STDOUT.
114+
*
115+
* @return void
116+
*/
117+
public function expectNoStdoutOutput()
118+
{
119+
$this->expectOutputString('');
120+
121+
}//end expectNoStdoutOutput()
122+
123+
124+
/**
125+
* Verify output sent to STDERR is the same as expected output.
126+
*
127+
* @param string $expected The expected STDERR output.
128+
*
129+
* @return void
130+
*/
131+
public function assertStderrOutputSameString($expected)
132+
{
133+
fseek($this->stream, 0);
134+
$output = stream_get_contents($this->stream);
135+
136+
$this->assertIsString($output);
137+
$this->assertSame($expected, $output);
138+
139+
}//end assertStderrOutputSameString()
140+
141+
142+
/**
143+
* Verify output sent to STDERR complies with an expected regex pattern.
144+
*
145+
* @param string $regex The regular expression to use to verify the STDERR output complies with expectations.
146+
*
147+
* @return void
148+
*/
149+
public function assertStderrOutputMatchesRegex($regex)
150+
{
151+
fseek($this->stream, 0);
152+
$output = stream_get_contents($this->stream);
153+
154+
$this->assertIsString($output);
155+
156+
if (method_exists($this, 'assertMatchesRegularExpression') === true) {
157+
$this->assertMatchesRegularExpression($regex, $output);
158+
} else {
159+
// PHPUnit < 9.1.0.
160+
$this->assertRegExp($regex, $output);
161+
}
162+
163+
}//end assertStderrOutputMatchesRegex()
164+
165+
166+
}//end trait

0 commit comments

Comments
 (0)