Skip to content
Merged
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
75 changes: 51 additions & 24 deletions src/Shell.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class Shell extends Application
private bool $lastExecSuccess = true;
private bool $suppressReturnValue = false;
private bool $nonInteractive = false;
private int $executionDepth = 0;
private ?int $errorReporting = null;
private bool $interactiveSignalCharsEnabled = false;
private bool $outputWritten = false;
Expand Down Expand Up @@ -709,11 +710,18 @@ public function doRun(InputInterface $input, OutputInterface $output): int
$this->clearPendingCode();
$this->warmAutoloader();

if ($this->config->getInputInteractive()) {
// @todo should it be possible to have raw output in an interactive run?
return $this->doInteractiveRun();
} else {
return $this->doNonInteractiveRun($this->config->rawOutput());
// Treat the whole run as one execution, so nested execute() calls don't reload includes.
$this->executionDepth++;

try {
if ($this->config->getInputInteractive()) {
// @todo should it be possible to have raw output in an interactive run?
return $this->doInteractiveRun();
} else {
return $this->doNonInteractiveRun($this->config->rawOutput());
}
} finally {
$this->executionDepth--;
}
}

Expand Down Expand Up @@ -748,7 +756,9 @@ private function doInteractiveRun(): int

try {
$this->beforeRun();
$this->loadIncludes();
if ($this->executionDepth === 1) {
$this->loadIncludes();
}
$loop = new ExecutionLoopClosure($this);
$exitCode = $loop->execute();
$this->afterRun($exitCode ?? 0);
Expand Down Expand Up @@ -785,7 +795,9 @@ private function doNonInteractiveRun(bool $rawOutput): int
}

$this->beforeRun();
$this->loadIncludes();
if ($this->executionDepth === 1) {
$this->loadIncludes();
}

// For non-interactive execution, read only from the input buffer or from piped input.
// Otherwise it'll try to readline and hang, waiting for user input with no indication of
Expand Down Expand Up @@ -830,6 +842,8 @@ protected function configureIO(InputInterface $input, OutputInterface $output):

/**
* Load user-defined includes.
*
* The shell output must be configured first; otherwise, reporting an include failure will abort loading.
*/
private function loadIncludes()
{
Expand Down Expand Up @@ -1328,7 +1342,7 @@ public function getBoundClass()
}

/**
* Add includes, to be parsed and executed before running the interactive shell.
* Add includes to be parsed and executed before the outermost shell execution.
*
* @param array $includes
*/
Expand All @@ -1338,7 +1352,7 @@ public function setIncludes(array $includes = [])
}

/**
* Get PHP files to be parsed and executed before running the interactive shell.
* Get PHP files to be parsed and executed before the outermost shell execution.
*
* @return string[]
*/
Expand Down Expand Up @@ -2206,6 +2220,9 @@ protected function getMessageLabel(\Throwable $e): string
/**
* Execute code in the shell execution context.
*
* Configured includes are loaded before the outermost execution. The shell
* output must be configured first so include failures can be reported.
*
* @param string $code
* @param bool $throwExceptions
*
Expand All @@ -2215,25 +2232,35 @@ public function execute(string $code, bool $throwExceptions = false)
{
$this->boot();

$this->setCode($code, true);
$this->executionDepth++;

if ($logger = $this->config->getLogger()) {
$logger->logExecute($code);
}
try {
if ($this->executionDepth === 1) {
$this->loadIncludes();
}

$closure = new ExecutionClosure($this);
$this->setCode($code, true);

if ($throwExceptions) {
return $closure->execute();
}
if ($logger = $this->config->getLogger()) {
$logger->logExecute($code);
}

try {
return $closure->execute();
} catch (BreakException $_e) {
// Re-throw BreakException so it can propagate exit codes
throw $_e;
} catch (\Throwable $_e) {
$this->writeException($_e);
$closure = new ExecutionClosure($this);

if ($throwExceptions) {
return $closure->execute();
}

try {
return $closure->execute();
} catch (BreakException $_e) {
// Re-throw BreakException so it can propagate exit codes
throw $_e;
} catch (\Throwable $_e) {
$this->writeException($_e);
}
} finally {
$this->executionDepth--;
}
}

Expand Down
83 changes: 83 additions & 0 deletions test/ShellTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,89 @@ public function testIncludesWithScopeVariables()
$this->assertNull($shell->getScopeVariable('_'));
}

public function testExecuteLoadsIncludesOnlyOnceAcrossNestedExecutions()
{
$include = TempPaths::file('psysh-test-include-');
\file_put_contents($include, '<?php $included = true;');

$shell = new class($this->getConfig()) extends Shell {
public int $includeReads = 0;

public function getIncludes(): array
{
$this->includeReads++;

return parent::getIncludes();
}
};
$shell->setOutput($this->getOutput());
$shell->setIncludes([$include]);
$shell->setScopeVariables(['shell' => $shell]);

$result = $shell->execute('return [$included, $shell->execute("return 42;", true)];', true);

$this->assertSame([true, 42], $result);
$this->assertSame(1, $shell->includeReads);
}

public function testRunDoesNotReloadIncludesForNestedCommands()
{
$include = TempPaths::file('psysh-test-include-');
\file_put_contents($include, '<?php $included = true;');

$config = $this->getConfig([
'usePcntl' => false,
'interactiveMode' => Configuration::INTERACTIVE_MODE_DISABLED,
]);
$shell = new class($config) extends Shell {
public int $includeReads = 0;

public function getIncludes(): array
{
$this->includeReads++;

return parent::getIncludes();
}
};
$shell->setIncludes([$include]);
$shell->addInput('timeit 1 + 1', true);
$shell->addInput('exit', true);

$shell->run(null, $this->getOutput());

$this->assertSame(2, $shell->getScopeVariable('_'));
$this->assertSame(1, $shell->includeReads);
}

public function testIncludesLoadOnlyOnceWhenEvaluatedCodeReentersRun()
{
$include = TempPaths::file('psysh-test-include-');
\file_put_contents($include, '<?php $included = true;');

$config = $this->getConfig([
'usePcntl' => false,
'interactiveMode' => Configuration::INTERACTIVE_MODE_DISABLED,
]);
$shell = new class($config) extends Shell {
public int $includeReads = 0;

public function getIncludes(): array
{
$this->includeReads++;

return parent::getIncludes();
}
};
$output = $this->getOutput();
$shell->setOutput($output);
$shell->setIncludes([$include]);
$shell->setScopeVariables(['shell' => $shell, 'output' => $output]);
$shell->addInput('exit', true);

$this->assertSame(0, $shell->execute('return $shell->run(null, $output);', true));
$this->assertSame(1, $shell->includeReads);
}

public function testIncludesContinueAfterThrowable()
{
$invalid = TempPaths::file('psysh-test-include-');
Expand Down