@@ -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,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.
265337fn 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.
450743fn find_ext ( manifest : Option < & PathBuf > ) -> AResult < cargo_metadata:: Target > {
451744 // TODO(david): Look for cargo manifest option or env
0 commit comments