diff --git a/features/shell.feature b/features/shell.feature index 301c61bc..de31c95b 100644 --- a/features/shell.feature +++ b/features/shell.feature @@ -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 + """ diff --git a/src/Shell_Command.php b/src/Shell_Command.php index edd2c611..38a2212e 100644 --- a/src/Shell_Command.php +++ b/src/Shell_Command.php @@ -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=] + * : 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( @@ -55,8 +78,36 @@ public function __invoke( $_, $assoc_args ) { /** * @var class-string $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; + } } diff --git a/src/WP_CLI/Shell/REPL.php b/src/WP_CLI/Shell/REPL.php index 15697bf0..418d15eb 100644 --- a/src/WP_CLI/Shell/REPL.php +++ b/src/WP_CLI/Shell/REPL.php @@ -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 ) ) { @@ -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; + } }