Skip to content

Commit 0b85286

Browse files
committed
feat(cargo-php): cargo watch
1 parent cab1c2c commit 0b85286

File tree

3 files changed

+414
-0
lines changed

3 files changed

+414
-0
lines changed

crates/cli/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ dialoguer = "0.12"
2323
libloading = "0.9"
2424
cargo_metadata = "0.23"
2525
semver = "1.0"
26+
notify = "7.0"
27+
notify-debouncer-full = "0.4"
28+
ctrlc = "3.4"
29+
30+
[target.'cfg(unix)'.dependencies]
31+
libc = "0.2"
2632

2733
[lints.rust]
2834
missing_docs = "warn"

crates/cli/src/lib.rs

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ enum Args {
8888
/// extension classes, functions and constants.
8989
#[cfg(not(windows))]
9090
Stubs(Stubs),
91+
/// Watches for changes and automatically rebuilds and installs the extension.
92+
///
93+
/// This command watches Rust source files and Cargo.toml for changes,
94+
/// automatically rebuilding and reinstalling the extension when changes
95+
/// are detected. Optionally, it can also manage the PHP built-in development
96+
/// server, restarting it after each successful rebuild.
97+
#[cfg(not(windows))]
98+
Watch(Watch),
9199
}
92100

93101
#[allow(clippy::struct_excessive_bools)]
@@ -171,13 +179,46 @@ struct Stubs {
171179
no_default_features: bool,
172180
}
173181

182+
#[cfg(not(windows))]
183+
#[allow(clippy::struct_excessive_bools)]
184+
#[derive(Parser)]
185+
struct Watch {
186+
/// Start PHP built-in server and restart it on changes.
187+
#[arg(long)]
188+
serve: bool,
189+
/// Host and port for PHP server (e.g., localhost:8000).
190+
#[arg(long, default_value = "localhost:8000")]
191+
host: String,
192+
/// Document root for PHP server. Defaults to current directory.
193+
#[arg(long)]
194+
docroot: Option<PathBuf>,
195+
/// Whether to build the release version of the extension.
196+
#[arg(long)]
197+
release: bool,
198+
/// Path to the Cargo manifest of the extension. Defaults to the manifest in
199+
/// the directory the command is called.
200+
#[arg(long)]
201+
manifest: Option<PathBuf>,
202+
#[arg(short = 'F', long, num_args = 1..)]
203+
features: Option<Vec<String>>,
204+
#[arg(long)]
205+
all_features: bool,
206+
#[arg(long)]
207+
no_default_features: bool,
208+
/// Changes the path that the extension is copied to.
209+
#[arg(long)]
210+
install_dir: Option<PathBuf>,
211+
}
212+
174213
impl Args {
175214
pub fn handle(self) -> CrateResult {
176215
match self {
177216
Args::Install(install) => install.handle(),
178217
Args::Remove(remove) => remove.handle(),
179218
#[cfg(not(windows))]
180219
Args::Stubs(stubs) => stubs.handle(),
220+
#[cfg(not(windows))]
221+
Args::Watch(watch) => watch.handle(),
181222
}
182223
}
183224
}
@@ -260,6 +301,37 @@ impl Install {
260301
}
261302
}
262303

304+
/// Copies an extension to the PHP extension directory.
305+
///
306+
/// # Parameters
307+
///
308+
/// * `ext_path` - Path to the built extension file.
309+
/// * `install_dir` - Optional custom installation directory. If not provided,
310+
/// the default PHP extension directory is used.
311+
///
312+
/// # Returns
313+
///
314+
/// The path where the extension was installed.
315+
fn copy_extension(ext_path: &Utf8PathBuf, install_dir: Option<&PathBuf>) -> AResult<PathBuf> {
316+
let mut ext_dir = if let Some(dir) = install_dir {
317+
dir.clone()
318+
} else {
319+
get_ext_dir()?
320+
};
321+
322+
debug_assert!(ext_path.is_file());
323+
let ext_name = ext_path.file_name().expect("ext path wasn't a filepath");
324+
325+
if ext_dir.is_dir() {
326+
ext_dir.push(ext_name);
327+
}
328+
329+
std::fs::copy(ext_path.as_std_path(), &ext_dir)
330+
.with_context(|| "Failed to copy extension from target directory to extension directory")?;
331+
332+
Ok(ext_dir)
333+
}
334+
263335
/// Returns the path to the extension directory utilised by the PHP interpreter,
264336
/// creating it if one was returned but it does not exist.
265337
fn get_ext_dir() -> AResult<PathBuf> {
@@ -446,6 +518,227 @@ impl Stubs {
446518
}
447519
}
448520

521+
#[cfg(not(windows))]
522+
impl Watch {
523+
#[allow(clippy::too_many_lines)]
524+
pub fn handle(self) -> CrateResult {
525+
use notify::RecursiveMode;
526+
use notify_debouncer_full::new_debouncer;
527+
use std::{
528+
process::Child,
529+
sync::{
530+
Arc,
531+
atomic::{AtomicBool, Ordering},
532+
mpsc::channel,
533+
},
534+
time::Duration,
535+
};
536+
537+
let artifact = find_ext(self.manifest.as_ref())?;
538+
let manifest_path = self.get_manifest_path()?;
539+
540+
// Initial build and install
541+
println!("[cargo-php] Initial build...");
542+
let ext_path = build_ext(
543+
&artifact,
544+
self.release,
545+
self.features.clone(),
546+
self.all_features,
547+
self.no_default_features,
548+
)?;
549+
copy_extension(&ext_path, self.install_dir.as_ref())?;
550+
println!("[cargo-php] Build successful, extension installed.");
551+
552+
// Start PHP server if requested
553+
let mut php_process: Option<Child> = if self.serve {
554+
Some(self.start_php_server()?)
555+
} else {
556+
None
557+
};
558+
559+
// Setup signal handler for graceful shutdown
560+
let running = Arc::new(AtomicBool::new(true));
561+
let r = running.clone();
562+
ctrlc::set_handler(move || {
563+
r.store(false, Ordering::SeqCst);
564+
})
565+
.context("Failed to set Ctrl+C handler")?;
566+
567+
// Setup file watcher
568+
let (tx, rx) = channel();
569+
let mut debouncer = new_debouncer(Duration::from_millis(500), None, tx)
570+
.context("Failed to create file watcher")?;
571+
572+
// Determine paths to watch
573+
let watch_paths = Self::determine_watch_paths(&manifest_path)?;
574+
for path in &watch_paths {
575+
debouncer
576+
.watch(path, RecursiveMode::Recursive)
577+
.with_context(|| format!("Failed to watch {}", path.display()))?;
578+
}
579+
580+
println!("[cargo-php] Watching for changes... Press Ctrl+C to stop.");
581+
582+
// Main watch loop
583+
while running.load(Ordering::SeqCst) {
584+
// Use a short timeout to periodically check the running flag
585+
match rx.recv_timeout(Duration::from_millis(100)) {
586+
Ok(Ok(events)) => {
587+
if !Self::is_relevant_event(&events) {
588+
continue;
589+
}
590+
591+
println!("\n[cargo-php] Change detected, rebuilding...");
592+
593+
// Kill PHP server if running
594+
if let Some(mut process) = php_process.take() {
595+
Self::kill_php_server(&mut process)?;
596+
}
597+
598+
// Rebuild and install
599+
match build_ext(
600+
&artifact,
601+
self.release,
602+
self.features.clone(),
603+
self.all_features,
604+
self.no_default_features,
605+
) {
606+
Ok(ext_path) => {
607+
if let Err(e) = copy_extension(&ext_path, self.install_dir.as_ref()) {
608+
eprintln!("[cargo-php] Failed to install extension: {e}");
609+
eprintln!("[cargo-php] Waiting for changes...");
610+
} else {
611+
println!("[cargo-php] Build successful, extension installed.");
612+
613+
// Restart PHP server if in serve mode
614+
if self.serve {
615+
match self.start_php_server() {
616+
Ok(process) => php_process = Some(process),
617+
Err(e) => {
618+
eprintln!(
619+
"[cargo-php] Failed to restart PHP server: {e}"
620+
);
621+
}
622+
}
623+
}
624+
}
625+
}
626+
Err(e) => {
627+
eprintln!("[cargo-php] Build failed: {e}");
628+
eprintln!("[cargo-php] Waiting for changes...");
629+
}
630+
}
631+
}
632+
Ok(Err(errors)) => {
633+
for e in errors {
634+
eprintln!("[cargo-php] Watch error: {e}");
635+
}
636+
}
637+
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
638+
// Just a timeout, continue checking running flag
639+
}
640+
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
641+
bail!("File watcher channel disconnected");
642+
}
643+
}
644+
}
645+
646+
// Cleanup on exit
647+
println!("\n[cargo-php] Shutting down...");
648+
if let Some(mut process) = php_process.take() {
649+
Self::kill_php_server(&mut process)?;
650+
}
651+
652+
Ok(())
653+
}
654+
655+
fn get_manifest_path(&self) -> AResult<PathBuf> {
656+
if let Some(manifest) = &self.manifest {
657+
Ok(manifest.clone())
658+
} else {
659+
let cwd = std::env::current_dir().context("Failed to get current directory")?;
660+
Ok(cwd.join("Cargo.toml"))
661+
}
662+
}
663+
664+
fn determine_watch_paths(manifest_path: &std::path::Path) -> AResult<Vec<PathBuf>> {
665+
let project_root = manifest_path
666+
.parent()
667+
.context("Failed to get project root")?;
668+
669+
let mut paths = vec![project_root.join("src"), manifest_path.to_path_buf()];
670+
671+
// Add build.rs if it exists
672+
let build_rs = project_root.join("build.rs");
673+
if build_rs.exists() {
674+
paths.push(build_rs);
675+
}
676+
677+
Ok(paths)
678+
}
679+
680+
fn is_relevant_event(events: &[notify_debouncer_full::DebouncedEvent]) -> bool {
681+
events.iter().any(|event| {
682+
event.paths.iter().any(|path: &PathBuf| {
683+
path.extension()
684+
.and_then(|ext| ext.to_str())
685+
.is_some_and(|ext| ext == "rs" || ext == "toml")
686+
})
687+
})
688+
}
689+
690+
fn start_php_server(&self) -> AResult<std::process::Child> {
691+
let docroot = self
692+
.docroot
693+
.as_deref()
694+
.unwrap_or_else(|| std::path::Path::new("."));
695+
696+
let child = Command::new("php")
697+
.arg("-S")
698+
.arg(&self.host)
699+
.arg("-t")
700+
.arg(docroot)
701+
.stdout(Stdio::inherit())
702+
.stderr(Stdio::inherit())
703+
.spawn()
704+
.context("Failed to start PHP server")?;
705+
706+
println!("[cargo-php] PHP server started on http://{}", self.host);
707+
Ok(child)
708+
}
709+
710+
fn kill_php_server(process: &mut std::process::Child) -> AResult<()> {
711+
use std::time::Duration;
712+
713+
println!("[cargo-php] Stopping PHP server...");
714+
715+
// Send SIGTERM on Unix
716+
#[allow(clippy::cast_possible_wrap)]
717+
unsafe {
718+
libc::kill(process.id() as i32, libc::SIGTERM);
719+
}
720+
721+
// Wait up to 2 seconds for graceful shutdown
722+
let start = std::time::Instant::now();
723+
let timeout = Duration::from_secs(2);
724+
725+
loop {
726+
if process.try_wait()?.is_some() {
727+
break;
728+
}
729+
if start.elapsed() > timeout {
730+
// Force kill after timeout
731+
process.kill()?;
732+
process.wait()?;
733+
break;
734+
}
735+
std::thread::sleep(Duration::from_millis(100));
736+
}
737+
738+
Ok(())
739+
}
740+
}
741+
449742
/// Attempts to find an extension in the target directory.
450743
fn find_ext(manifest: Option<&PathBuf>) -> AResult<cargo_metadata::Target> {
451744
// TODO(david): Look for cargo manifest option or env

0 commit comments

Comments
 (0)