Skip to content
Open
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
37 changes: 37 additions & 0 deletions features/shell.feature
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,40 @@ Feature: WordPress REPL
bool(true)
"""
And STDERR should be empty

Scenario: Restart shell
Given a WP install
And a session file:
"""
$a = 1;
restart
$b = 2;
"""

When I run `wp shell --basic < session`
Then STDOUT should contain:
"""
Restarting shell...
"""
And STDOUT should contain:
"""
=> int(2)
"""

Scenario: Exit shell
Given a WP install
And a session file:
"""
$a = 1;
exit
"""

When I run `wp shell --basic < session`
Then STDOUT should contain:
"""
=> int(1)
"""
And STDOUT should not contain:
"""
exit
"""
55 changes: 53 additions & 2 deletions src/Shell_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,37 @@ class Shell_Command extends WP_CLI_Command {
* : Force the use of WP-CLI's built-in PHP REPL, even if the Boris or
* PsySH PHP REPLs are available.
*
* [--watch=<path>]
* : Watch a file or directory for changes and automatically restart the shell.
* Only works with the built-in REPL (--basic).
*
* ## EXAMPLES
*
* # Call get_bloginfo() to get the name of the site.
* $ wp shell
* wp> get_bloginfo( 'name' );
* => string(6) "WP-CLI"
*
* # Restart the shell to reload code changes.
* $ wp shell
* wp> restart
* Restarting shell...
* wp>
*
* # Watch a directory for changes and auto-restart.
* $ wp shell --watch=wp-content/plugins/my-plugin
* wp> // Make changes to files in the plugin directory
* Detected changes in wp-content/plugins/my-plugin, restarting shell...
* wp>
*/
public function __invoke( $_, $assoc_args ) {
$watch_path = Utils\get_flag_value( $assoc_args, 'watch', false );

if ( $watch_path && ! Utils\get_flag_value( $assoc_args, 'basic' ) ) {
WP_CLI::warning( 'The --watch option only works with the built-in REPL. Enabling --basic mode.' );
$assoc_args['basic'] = true;
}

$class = WP_CLI\Shell\REPL::class;

$implementations = array(
Expand Down Expand Up @@ -55,8 +78,36 @@ public function __invoke( $_, $assoc_args ) {
/**
* @var class-string<WP_CLI\Shell\REPL> $class
*/
$repl = new $class( 'wp> ' );
$repl->start();
if ( $watch_path ) {
$watch_path = $this->resolve_watch_path( $watch_path );
}

do {
$repl = new $class( 'wp> ' );
if ( $watch_path ) {
$repl->set_watch_path( $watch_path );
}
$exit_code = $repl->start();
} while ( WP_CLI\Shell\REPL::EXIT_CODE_RESTART === $exit_code );
}
}

/**
* Resolve and validate the watch path.
*
* @param string $path Path to watch.
* @return string Absolute path to watch.
*/
private function resolve_watch_path( $path ) {
if ( ! file_exists( $path ) ) {
WP_CLI::error( "Watch path does not exist: {$path}" );
}

$realpath = realpath( $path );
if ( false === $realpath ) {
WP_CLI::error( "Could not resolve watch path: {$path}" );
}

return $realpath;
}
}
83 changes: 82 additions & 1 deletion src/WP_CLI/Shell/REPL.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,53 @@ class REPL {

private $history_file;

private $watch_path;

private $watch_mtime;

const EXIT_CODE_RESTART = 10;

public function __construct( $prompt ) {
$this->prompt = $prompt;

$this->set_history_file();
}

/**
* Set a path to watch for changes.
*
* @param string $path Path to watch for changes.
*/
public function set_watch_path( $path ) {
$this->watch_path = $path;
$this->watch_mtime = $this->get_recursive_mtime( $path );
}

public function start() {
// @phpstan-ignore while.alwaysTrue
while ( true ) {
// Check for file changes if watching
if ( $this->watch_path && $this->has_changes() ) {
WP_CLI::log( "Detected changes in {$this->watch_path}, restarting shell..." );
return self::EXIT_CODE_RESTART;
}

$line = $this->prompt();

if ( '' === $line ) {
continue;
}

// Check for special exit command
if ( 'exit' === trim( $line ) ) {
return 0;
}

// Check for special restart command
if ( 'restart' === trim( $line ) ) {
WP_CLI::log( 'Restarting shell...' );
return self::EXIT_CODE_RESTART;
}

$line = rtrim( $line, ';' ) . ';';

if ( self::starts_with( self::non_expressions(), $line ) ) {
Expand Down Expand Up @@ -153,4 +185,53 @@ private function set_history_file() {
private static function starts_with( $tokens, $line ) {
return preg_match( "/^($tokens)[\(\s]+/", $line );
}

/**
* Check if the watched path has changes.
*
* @return bool True if changes detected, false otherwise.
*/
private function has_changes() {
if ( ! $this->watch_path ) {
return false;
}

$current_mtime = $this->get_recursive_mtime( $this->watch_path );
return $current_mtime !== $this->watch_mtime;
}

/**
* Get the most recent modification time for a path recursively.
*
* @param string $path Path to check.
* @return int Most recent modification time.
*/
private function get_recursive_mtime( $path ) {
$mtime = 0;

if ( is_file( $path ) ) {
$file_mtime = filemtime( $path );
return false !== $file_mtime ? $file_mtime : 0;
}

if ( is_dir( $path ) ) {
$dir_mtime = filemtime( $path );
$mtime = false !== $dir_mtime ? $dir_mtime : 0;

$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( $path, \RecursiveDirectoryIterator::SKIP_DOTS ),
\RecursiveIteratorIterator::SELF_FIRST
);

foreach ( $iterator as $file ) {
/** @var \SplFileInfo $file */
$file_mtime = $file->getMTime();
if ( $file_mtime > $mtime ) {
$mtime = $file_mtime;
}
}
}

return $mtime;
}
}