From 0b85286d94066e49a45d7ff7f880db715783e650 Mon Sep 17 00:00:00 2001 From: Vasily Zorin Date: Tue, 23 Dec 2025 20:55:49 +0700 Subject: [PATCH] feat(cargo-php): cargo watch --- crates/cli/Cargo.toml | 6 + crates/cli/src/lib.rs | 293 +++++++++++++++++++++++++ guide/src/getting-started/cargo-php.md | 115 ++++++++++ 3 files changed, 414 insertions(+) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3c860dc1f8..fc595c141d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -23,6 +23,12 @@ dialoguer = "0.12" libloading = "0.9" cargo_metadata = "0.23" semver = "1.0" +notify = "7.0" +notify-debouncer-full = "0.4" +ctrlc = "3.4" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" [lints.rust] missing_docs = "warn" diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index fe744141f3..f6e1dcf4a7 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -88,6 +88,14 @@ enum Args { /// extension classes, functions and constants. #[cfg(not(windows))] Stubs(Stubs), + /// Watches for changes and automatically rebuilds and installs the extension. + /// + /// This command watches Rust source files and Cargo.toml for changes, + /// automatically rebuilding and reinstalling the extension when changes + /// are detected. Optionally, it can also manage the PHP built-in development + /// server, restarting it after each successful rebuild. + #[cfg(not(windows))] + Watch(Watch), } #[allow(clippy::struct_excessive_bools)] @@ -171,6 +179,37 @@ struct Stubs { no_default_features: bool, } +#[cfg(not(windows))] +#[allow(clippy::struct_excessive_bools)] +#[derive(Parser)] +struct Watch { + /// Start PHP built-in server and restart it on changes. + #[arg(long)] + serve: bool, + /// Host and port for PHP server (e.g., localhost:8000). + #[arg(long, default_value = "localhost:8000")] + host: String, + /// Document root for PHP server. Defaults to current directory. + #[arg(long)] + docroot: Option, + /// Whether to build the release version of the extension. + #[arg(long)] + release: bool, + /// Path to the Cargo manifest of the extension. Defaults to the manifest in + /// the directory the command is called. + #[arg(long)] + manifest: Option, + #[arg(short = 'F', long, num_args = 1..)] + features: Option>, + #[arg(long)] + all_features: bool, + #[arg(long)] + no_default_features: bool, + /// Changes the path that the extension is copied to. + #[arg(long)] + install_dir: Option, +} + impl Args { pub fn handle(self) -> CrateResult { match self { @@ -178,6 +217,8 @@ impl Args { Args::Remove(remove) => remove.handle(), #[cfg(not(windows))] Args::Stubs(stubs) => stubs.handle(), + #[cfg(not(windows))] + Args::Watch(watch) => watch.handle(), } } } @@ -260,6 +301,37 @@ impl Install { } } +/// Copies an extension to the PHP extension directory. +/// +/// # Parameters +/// +/// * `ext_path` - Path to the built extension file. +/// * `install_dir` - Optional custom installation directory. If not provided, +/// the default PHP extension directory is used. +/// +/// # Returns +/// +/// The path where the extension was installed. +fn copy_extension(ext_path: &Utf8PathBuf, install_dir: Option<&PathBuf>) -> AResult { + let mut ext_dir = if let Some(dir) = install_dir { + dir.clone() + } else { + get_ext_dir()? + }; + + debug_assert!(ext_path.is_file()); + let ext_name = ext_path.file_name().expect("ext path wasn't a filepath"); + + if ext_dir.is_dir() { + ext_dir.push(ext_name); + } + + std::fs::copy(ext_path.as_std_path(), &ext_dir) + .with_context(|| "Failed to copy extension from target directory to extension directory")?; + + Ok(ext_dir) +} + /// Returns the path to the extension directory utilised by the PHP interpreter, /// creating it if one was returned but it does not exist. fn get_ext_dir() -> AResult { @@ -446,6 +518,227 @@ impl Stubs { } } +#[cfg(not(windows))] +impl Watch { + #[allow(clippy::too_many_lines)] + pub fn handle(self) -> CrateResult { + use notify::RecursiveMode; + use notify_debouncer_full::new_debouncer; + use std::{ + process::Child, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::channel, + }, + time::Duration, + }; + + let artifact = find_ext(self.manifest.as_ref())?; + let manifest_path = self.get_manifest_path()?; + + // Initial build and install + println!("[cargo-php] Initial build..."); + let ext_path = build_ext( + &artifact, + self.release, + self.features.clone(), + self.all_features, + self.no_default_features, + )?; + copy_extension(&ext_path, self.install_dir.as_ref())?; + println!("[cargo-php] Build successful, extension installed."); + + // Start PHP server if requested + let mut php_process: Option = if self.serve { + Some(self.start_php_server()?) + } else { + None + }; + + // Setup signal handler for graceful shutdown + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); + ctrlc::set_handler(move || { + r.store(false, Ordering::SeqCst); + }) + .context("Failed to set Ctrl+C handler")?; + + // Setup file watcher + let (tx, rx) = channel(); + let mut debouncer = new_debouncer(Duration::from_millis(500), None, tx) + .context("Failed to create file watcher")?; + + // Determine paths to watch + let watch_paths = Self::determine_watch_paths(&manifest_path)?; + for path in &watch_paths { + debouncer + .watch(path, RecursiveMode::Recursive) + .with_context(|| format!("Failed to watch {}", path.display()))?; + } + + println!("[cargo-php] Watching for changes... Press Ctrl+C to stop."); + + // Main watch loop + while running.load(Ordering::SeqCst) { + // Use a short timeout to periodically check the running flag + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(Ok(events)) => { + if !Self::is_relevant_event(&events) { + continue; + } + + println!("\n[cargo-php] Change detected, rebuilding..."); + + // Kill PHP server if running + if let Some(mut process) = php_process.take() { + Self::kill_php_server(&mut process)?; + } + + // Rebuild and install + match build_ext( + &artifact, + self.release, + self.features.clone(), + self.all_features, + self.no_default_features, + ) { + Ok(ext_path) => { + if let Err(e) = copy_extension(&ext_path, self.install_dir.as_ref()) { + eprintln!("[cargo-php] Failed to install extension: {e}"); + eprintln!("[cargo-php] Waiting for changes..."); + } else { + println!("[cargo-php] Build successful, extension installed."); + + // Restart PHP server if in serve mode + if self.serve { + match self.start_php_server() { + Ok(process) => php_process = Some(process), + Err(e) => { + eprintln!( + "[cargo-php] Failed to restart PHP server: {e}" + ); + } + } + } + } + } + Err(e) => { + eprintln!("[cargo-php] Build failed: {e}"); + eprintln!("[cargo-php] Waiting for changes..."); + } + } + } + Ok(Err(errors)) => { + for e in errors { + eprintln!("[cargo-php] Watch error: {e}"); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // Just a timeout, continue checking running flag + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + bail!("File watcher channel disconnected"); + } + } + } + + // Cleanup on exit + println!("\n[cargo-php] Shutting down..."); + if let Some(mut process) = php_process.take() { + Self::kill_php_server(&mut process)?; + } + + Ok(()) + } + + fn get_manifest_path(&self) -> AResult { + if let Some(manifest) = &self.manifest { + Ok(manifest.clone()) + } else { + let cwd = std::env::current_dir().context("Failed to get current directory")?; + Ok(cwd.join("Cargo.toml")) + } + } + + fn determine_watch_paths(manifest_path: &std::path::Path) -> AResult> { + let project_root = manifest_path + .parent() + .context("Failed to get project root")?; + + let mut paths = vec![project_root.join("src"), manifest_path.to_path_buf()]; + + // Add build.rs if it exists + let build_rs = project_root.join("build.rs"); + if build_rs.exists() { + paths.push(build_rs); + } + + Ok(paths) + } + + fn is_relevant_event(events: &[notify_debouncer_full::DebouncedEvent]) -> bool { + events.iter().any(|event| { + event.paths.iter().any(|path: &PathBuf| { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "rs" || ext == "toml") + }) + }) + } + + fn start_php_server(&self) -> AResult { + let docroot = self + .docroot + .as_deref() + .unwrap_or_else(|| std::path::Path::new(".")); + + let child = Command::new("php") + .arg("-S") + .arg(&self.host) + .arg("-t") + .arg(docroot) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("Failed to start PHP server")?; + + println!("[cargo-php] PHP server started on http://{}", self.host); + Ok(child) + } + + fn kill_php_server(process: &mut std::process::Child) -> AResult<()> { + use std::time::Duration; + + println!("[cargo-php] Stopping PHP server..."); + + // Send SIGTERM on Unix + #[allow(clippy::cast_possible_wrap)] + unsafe { + libc::kill(process.id() as i32, libc::SIGTERM); + } + + // Wait up to 2 seconds for graceful shutdown + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(2); + + loop { + if process.try_wait()?.is_some() { + break; + } + if start.elapsed() > timeout { + // Force kill after timeout + process.kill()?; + process.wait()?; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + Ok(()) + } +} + /// Attempts to find an extension in the target directory. fn find_ext(manifest: Option<&PathBuf>) -> AResult { // TODO(david): Look for cargo manifest option or env diff --git a/guide/src/getting-started/cargo-php.md b/guide/src/getting-started/cargo-php.md index a5fd89dc0a..d0cb6e4361 100644 --- a/guide/src/getting-started/cargo-php.md +++ b/guide/src/getting-started/cargo-php.md @@ -6,6 +6,7 @@ the manifest directory of an extension, it allows you to do the following: - Generate IDE stub files - Install the extension - Remove the extension +- Watch for changes and hot reload (development mode) ## System Requirements @@ -211,5 +212,119 @@ OPTIONS: Bypasses the confirmation prompt ``` +## Watch Mode (Hot Reload) + +The `watch` command provides a development workflow that automatically rebuilds +and reinstalls your extension whenever source files change. This eliminates the +need to manually run `cargo php install` after every code change. + +Optionally, it can also manage PHP's built-in development server, restarting it +after each successful rebuild to pick up the new extension. + +### Usage + +```text +$ cargo php watch --help +cargo-php-watch + +Watches for changes and automatically rebuilds and installs the extension. + +This command watches Rust source files and Cargo.toml for changes, automatically +rebuilding and reinstalling the extension when changes are detected. Optionally, +it can also manage the PHP built-in development server, restarting it after each +successful rebuild. + +USAGE: + cargo-php watch [OPTIONS] + +OPTIONS: + --serve + Start PHP built-in server and restart it on changes + + --host + Host and port for PHP server (e.g., localhost:8000) + [default: localhost:8000] + + --docroot + Document root for PHP server. Defaults to current directory + + --release + Whether to build the release version of the extension + + --manifest + Path to the Cargo manifest of the extension. Defaults to the manifest + in the directory the command is called + + -F, --features ... + Features to enable during build + + --all-features + Enable all features + + --no-default-features + Disable default features + + --install-dir + Changes the path that the extension is copied to + + -h, --help + Print help information +``` + +### Examples + +Watch for changes and rebuild (manage PHP server separately): + +```text +$ cargo php watch +[cargo-php] Initial build... +[cargo-php] Build successful, extension installed. +[cargo-php] Watching for changes... Press Ctrl+C to stop. +``` + +Watch with PHP development server: + +```text +$ cargo php watch --serve +[cargo-php] Initial build... +[cargo-php] Build successful, extension installed. +[cargo-php] PHP server started on http://localhost:8000 +[cargo-php] Watching for changes... Press Ctrl+C to stop. +``` + +Custom server settings: + +```text +$ cargo php watch --serve --host 0.0.0.0:3000 --docroot ./public +``` + +Release mode with specific features: + +```text +$ cargo php watch --release --features "feature1,feature2" +``` + +### What Gets Watched + +The watch command monitors the following for changes: + +- All `.rs` files in the `src/` directory (recursively) +- `Cargo.toml` (dependency and configuration changes) +- `build.rs` (if present) + +Changes to other files (PHP scripts, configuration, etc.) are ignored and won't +trigger a rebuild. + +### Error Handling + +If a build fails due to compilation errors, the watch command will: + +1. Display the error message +2. Continue watching for changes +3. Attempt to rebuild when you fix the error + +This allows you to keep the watch process running while you fix compilation +issues. + [`cargo-php`]: https://crates.io/crates/cargo-php [phpstorm-stubs]: https://github.com/JetBrains/phpstorm-stubs#readme