@@ -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+
174213impl 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.
265341fn 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.
450750fn find_ext ( manifest : Option < & PathBuf > ) -> AResult < cargo_metadata:: Target > {
451751 // TODO(david): Look for cargo manifest option or env
0 commit comments