Skip to content

Commit 9cec346

Browse files
committed
feat(cargo-php): cargo watch
1 parent cab1c2c commit 9cec346

File tree

3 files changed

+421
-0
lines changed

3 files changed

+421
-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: 300 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,41 @@ 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(
316+
ext_path: &Utf8PathBuf,
317+
install_dir: Option<&PathBuf>,
318+
) -> AResult<PathBuf> {
319+
let mut ext_dir = if let Some(dir) = install_dir {
320+
dir.clone()
321+
} else {
322+
get_ext_dir()?
323+
};
324+
325+
debug_assert!(ext_path.is_file());
326+
let ext_name = ext_path.file_name().expect("ext path wasn't a filepath");
327+
328+
if ext_dir.is_dir() {
329+
ext_dir.push(ext_name);
330+
}
331+
332+
std::fs::copy(ext_path.as_std_path(), &ext_dir).with_context(|| {
333+
"Failed to copy extension from target directory to extension directory"
334+
})?;
335+
336+
Ok(ext_dir)
337+
}
338+
263339
/// Returns the path to the extension directory utilised by the PHP interpreter,
264340
/// creating it if one was returned but it does not exist.
265341
fn get_ext_dir() -> AResult<PathBuf> {
@@ -446,6 +522,230 @@ impl Stubs {
446522
}
447523
}
448524

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

0 commit comments

Comments
 (0)