Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow using stderr for prompt output #109

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/Concerns/Cursor.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ trait Cursor
*/
public function hideCursor(): void
{
static::writeDirectly("\e[?25l");
static::writer()->writeDirectly("\e[?25l");

static::$cursorHidden = true;
}
Expand All @@ -24,7 +24,7 @@ public function hideCursor(): void
*/
public function showCursor(): void
{
static::writeDirectly("\e[?25h");
static::writer()->writeDirectly("\e[?25h");

static::$cursorHidden = false;
}
Expand Down Expand Up @@ -58,6 +58,6 @@ public function moveCursor(int $x, int $y = 0): void
$sequence .= "\e[{$y}B"; // Down
}

static::writeDirectly($sequence);
static::writer()->writeDirectly($sequence);
}
}
4 changes: 2 additions & 2 deletions src/Concerns/Erase.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ public function eraseLines(int $count): void
$clear .= "\e[G";
}

static::writeDirectly($clear);
static::writer()->writeDirectly($clear);
}

/**
* Erase from cursor until end of screen.
*/
public function eraseDown(): void
{
static::writeDirectly("\e[J");
static::writer()->writeDirectly("\e[J");
}
}
5 changes: 3 additions & 2 deletions src/Concerns/FakesInputOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,12 @@ public static function assertStrippedOutputDoesntContain(string $string): void
*/
public static function content(): string
{
if (! static::output() instanceof BufferedConsoleOutput) {
$output = static::writer()->getOutput();
if (! $output instanceof BufferedConsoleOutput) {
throw new RuntimeException('Prompt must be faked before accessing content.');
}

return static::output()->content();
return $output->content();
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Note.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function prompt(): bool

$this->state = 'submit';

static::output()->write($this->renderTheme());
static::writer()->write($this->renderTheme());

return true;
}
Expand Down
2 changes: 2 additions & 0 deletions src/Output/BufferedConsoleOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Laravel\Prompts\Output;

use Symfony\Component\Console\Output\ConsoleOutput;

class BufferedConsoleOutput extends ConsoleOutput
{
/**
Expand Down
24 changes: 17 additions & 7 deletions src/Output/ConsoleOutput.php → src/Output/PromptWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@

namespace Laravel\Prompts\Output;

use Symfony\Component\Console\Output\ConsoleOutput as SymfonyConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;

class ConsoleOutput extends SymfonyConsoleOutput
class PromptWriter
{
/**
* How many new lines were written by the last output.
*/
protected int $newLinesWritten = 1;

public function __construct(
private OutputInterface $output
) {
}

/**
* How many new lines were written by the last output.
*/
Expand All @@ -22,15 +27,15 @@ public function newLinesWritten(): int
/**
* Write the output and capture the number of trailing new lines.
*/
protected function doWrite(string $message, bool $newline): void
public function write(string $message, bool $newline = false): void
{
parent::doWrite($message, $newline);
$this->output->write($message, $newline);

if ($newline) {
$message .= \PHP_EOL;
}

$trailingNewLines = strlen($message) - strlen(rtrim($message, \PHP_EOL));
$trailingNewLines = \strlen($message) - \strlen(rtrim($message, \PHP_EOL));

if (trim($message) === '') {
$this->newLinesWritten += $trailingNewLines;
Expand All @@ -42,8 +47,13 @@ protected function doWrite(string $message, bool $newline): void
/**
* Write output directly, bypassing newline capture.
*/
public function writeDirectly(string $message): void
public function writeDirectly(string $message, bool $newline = false): void
{
$this->output->write($message, $newline);
}

public function getOutput(): OutputInterface
{
parent::doWrite($message, false);
return $this->output;
}
}
49 changes: 22 additions & 27 deletions src/Prompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
namespace Laravel\Prompts;

use Closure;
use Laravel\Prompts\Output\ConsoleOutput;
use Laravel\Prompts\Output\PromptWriter;
use RuntimeException;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;

Expand Down Expand Up @@ -65,9 +67,9 @@ abstract class Prompt
protected static ?Closure $validateUsing;

/**
* The output instance.
* The writer instance.
*/
protected static OutputInterface $output;
protected static PromptWriter $writer;

/**
* The terminal instance.
Expand All @@ -91,7 +93,7 @@ public function prompt(): mixed
return $this->fallback();
}

static::$interactive ??= stream_isatty(STDIN);
static::$interactive ??= stream_isatty(STDIN) && (stream_isatty(STDOUT) || stream_isatty(STDERR));

if (! static::$interactive) {
return $this->default();
Expand All @@ -102,7 +104,7 @@ public function prompt(): mixed
try {
static::terminal()->setTty('-icanon -isig -echo');
} catch (Throwable $e) {
static::output()->writeln("<comment>{$e->getMessage()}</comment>");
static::writer()->write("<comment>{$e->getMessage()}</comment>", true);
static::fallbackWhen(true);

return $this->fallback();
Expand Down Expand Up @@ -154,37 +156,30 @@ public function newLinesWritten(): int
*/
protected function capturePreviousNewLines(): void
{
$this->newLinesWritten = method_exists(static::output(), 'newLinesWritten')
? static::output()->newLinesWritten()
: 1;
$this->newLinesWritten = static::writer()->newLinesWritten();
}

/**
* Set the output instance.
*/
public static function setOutput(OutputInterface $output): void
{
self::$output = $output;
if ($output instanceof ConsoleOutputInterface && stream_isatty(STDERR) && ! stream_isatty(STDOUT)) {
$output = $output->getErrorOutput();
}
self::$writer = new PromptWriter($output);
Comment on lines +167 to +170
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Laravel sends an instance of \Illuminate\Console\OutputStyle, which doesn't implement ConsoleOutputInterface, meaning we won't use STDERR with Laravel even when STDOUT is redirected elsewhere.

Laravel's OutputStyle class is an extension of Symfony's SymfonyStyle and OutputStyle classes, which are a wrapper around an underlying OutputInterface. It doesn't expose the getErrorOutput method but instead exposes a getErrorStyle method. Unfortunately, this returns a new instance of SymfonyStyle rather than Laravel's OutputStyle class (due to their use of self).

Even if we could get it to return a new instance of Laravel's OutputStyle, the newline tracking would break because Laravel will still be using its original OutputStyle instance rather than the new instance we'd be using in Prompts.

It's a complicated problem because we need to keep track of the newlines between Prompts's and Laravel's output. Both Laravel and Prompts need to know how many trailing newlines were in the previous output, regardless of where that previous output came from, so that they each know how many newlines to emit before any new output. If Laravel isn't aware of the trailing newlines that Prompts has written, it won't be able to output the correct amount of leading newlines before any new output, and vice versa. It gets more complicated with Symfony because they have a separate instance for STDERR. If Laravel and Prompts use different instances, they won't be aware of each other's trailing newlines.

The only solutions I can think of are:

  1. Add logic to Laravel to make it use an STDERR output instance when appropriate (and share that instance with Prompts). This could have unintended consequences in user commands that expect to output on STDOUT regardless of any redirection, so it's probably a breaking change. Alternatively,
  2. Add a method to Laravel's OutputStyle class (or create a new wrapper class) that allows us to write to STDERR, while still tracking trailing newlines regardless of the stream. It would potentially need to be smart enough to track newlines separately when streams are redirected to different places because, in that scenario, the trailing newlines from the previous output of another stream shouldn't be considered. Prompts could then choose to output to STDERR and both Laravel and Prompts would still know how many leading newlines they needed to output.

Either way it's a big change covering two code bases. I still see the value in doing this, but I'm concerned about the effort and risk involved.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed explanation. I can't think of any other solutions, so you're welcome to close this if you want.
I learned a lot about console output, and it was nice to meet you guys 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @mortenscheel! Nice to meet you as well 🙂

Happy to reconsider this at some point if we can figure out a good solution. Having one blank line between output looks great, but it certainly adds a lot of challenges 😅

}

/**
* Get the current output instance.
* Get the prompt writer.
*/
protected static function output(): OutputInterface
protected static function writer(): PromptWriter
{
return self::$output ??= new ConsoleOutput();
}
if (! isset(self::$writer)) {
self::setOutput(new ConsoleOutput());
}

/**
* Write output directly, bypassing newline capture.
*/
protected static function writeDirectly(string $message): void
{
match (true) {
method_exists(static::output(), 'writeDirectly') => static::output()->writeDirectly($message),
method_exists(static::output(), 'getOutput') => static::output()->getOutput()->write($message),
default => static::output()->write($message),
};
return self::$writer;
}

/**
Expand Down Expand Up @@ -215,7 +210,7 @@ protected function render(): void
}

if ($this->state === 'initial') {
static::output()->write($frame);
static::writer()->write($frame);

$this->state = 'active';
$this->prevFrame = $frame;
Expand All @@ -228,7 +223,7 @@ protected function render(): void
// Ensure that the full frame is buffered so subsequent output can see how many trailing newlines were written.
if ($this->state === 'submit') {
$this->eraseDown();
static::output()->write($frame);
static::writer()->write($frame);

$this->prevFrame = '';

Expand All @@ -242,15 +237,15 @@ protected function render(): void
$this->moveCursor(0, $diffLine);
$this->eraseLines(1);
$lines = explode(PHP_EOL, $frame);
static::output()->write($lines[$diffLine]);
static::writer()->write($lines[$diffLine]);
$this->moveCursor(0, count($lines) - $diffLine - 1);
} elseif (count($diff) > 1) { // Re-render everything past the first change
$diffLine = $diff[0];
$this->moveCursor(0, $diffLine);
$this->eraseDown();
$lines = explode(PHP_EOL, $frame);
$newLines = array_slice($lines, $diffLine);
static::output()->write(implode(PHP_EOL, $newLines));
static::writer()->write(implode(PHP_EOL, $newLines));
}

$this->prevFrame = $frame;
Expand Down
2 changes: 1 addition & 1 deletion src/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function prompt(): bool

$this->state = 'submit';

static::output()->write($this->renderTheme());
static::writer()->write($this->renderTheme());

return true;
}
Expand Down
Loading