@@ -17,9 +17,12 @@ use anv_types::check;
1717use anv_viz:: { RinkRenderer , SvgOptions , TimelineRenderer } ;
1818use clap:: { Parser , Subcommand } ;
1919use miette:: { Diagnostic , NamedSource , Report , SourceSpan } ;
20+ use notify_debouncer_mini:: { new_debouncer, notify:: RecursiveMode } ;
2021use std:: fs;
2122use std:: path:: PathBuf ;
2223use std:: process:: ExitCode ;
24+ use std:: sync:: mpsc:: channel;
25+ use std:: time:: Duration ;
2326
2427/// Anvomidav - A domain-specific language for figure skating choreography.
2528#[ derive( Parser ) ]
@@ -50,7 +53,8 @@ enum Commands {
5053EXAMPLES:
5154 anv check program.anv
5255 anv check *.anv --verbose
53- anv check short.anv free.anv" ) ]
56+ anv check short.anv free.anv
57+ anv check *.anv --watch (watch for changes)" ) ]
5458 Check {
5559 /// Source files to check
5660 #[ arg( required = true ) ]
@@ -59,6 +63,10 @@ EXAMPLES:
5963 /// Show detailed output
6064 #[ arg( short, long) ]
6165 verbose : bool ,
66+
67+ /// Watch for file changes and re-check automatically
68+ #[ arg( short, long) ]
69+ watch : bool ,
6270 } ,
6371
6472 /// Parse a source file and print the AST
@@ -92,14 +100,19 @@ EXAMPLES:
92100
93101EXAMPLES:
94102 anv fmt program.anv
95- anv fmt *.anv --check (verify formatting without changes)" ) ]
103+ anv fmt *.anv --check (verify formatting without changes)
104+ anv fmt *.anv --watch (watch for changes)" ) ]
96105 Fmt {
97106 /// Source files to format
98107 files : Vec < PathBuf > ,
99108
100109 /// Check if files are formatted without modifying them
101110 #[ arg( long) ]
102111 check : bool ,
112+
113+ /// Watch for file changes and re-format automatically
114+ #[ arg( short, long) ]
115+ watch : bool ,
103116 } ,
104117
105118 /// Create a new Anvomidav project
@@ -262,66 +275,214 @@ fn main() -> ExitCode {
262275 }
263276}
264277
265- fn run ( cli : Cli ) -> miette:: Result < ( ) > {
266- match cli. command {
267- Commands :: Check { files, verbose } => {
268- let mut has_errors = false ;
278+ /// Check files for errors, returning true if all passed.
279+ fn check_files ( files : & [ PathBuf ] , verbose : bool ) -> bool {
280+ let mut has_errors = false ;
281+
282+ for ( file_idx, path) in files. iter ( ) . enumerate ( ) {
283+ if verbose {
284+ eprintln ! ( "Checking {}..." , path. display( ) ) ;
285+ }
286+
287+ let source = match fs:: read_to_string ( path) {
288+ Ok ( s) => s,
289+ Err ( e) => {
290+ eprintln ! ( "Failed to read {}: {}" , path. display( ) , e) ;
291+ has_errors = true ;
292+ continue ;
293+ }
294+ } ;
295+
296+ let file_id = FileId ( file_idx as u32 ) ;
269297
270- for ( file_idx, path) in files. iter ( ) . enumerate ( ) {
271- if verbose {
272- eprintln ! ( "Checking {}..." , path. display( ) ) ;
298+ // Parse
299+ let program = match parse ( & source, file_id) {
300+ Ok ( p) => p,
301+ Err ( errors) => {
302+ has_errors = true ;
303+ for err in errors {
304+ let span: SourceSpan = ( err. span . start , err. span . end - err. span . start ) . into ( ) ;
305+ let report = Report :: new ( CliError :: ParseError {
306+ src : NamedSource :: new ( path. display ( ) . to_string ( ) , source. clone ( ) ) ,
307+ span,
308+ message : err. message . clone ( ) ,
309+ label : err. label . clone ( ) . unwrap_or_else ( || "here" . into ( ) ) ,
310+ help : err. help . clone ( ) ,
311+ } ) ;
312+ eprintln ! ( "{:?}" , report) ;
313+ }
314+ continue ;
315+ }
316+ } ;
317+
318+ // Type check
319+ if let Err ( diagnostics) = check ( & program, file_id) {
320+ has_errors = true ;
321+ for diag in diagnostics. iter ( ) {
322+ let code_str = diag. code . as_ref ( ) . map ( |c| c. to_string ( ) ) . unwrap_or_default ( ) ;
323+ eprintln ! (
324+ "{}:{}: {} [{}]" ,
325+ path. display( ) ,
326+ diag. labels. first( ) . map( |l| l. span. start) . unwrap_or( 0 ) ,
327+ diag. message,
328+ code_str
329+ ) ;
330+ for note in & diag. notes {
331+ eprintln ! ( " note: {:?}" , note) ;
273332 }
333+ }
334+ }
335+ }
274336
275- let source = fs:: read_to_string ( path) . map_err ( |e| CliError :: ReadError {
276- path : path. display ( ) . to_string ( ) ,
277- source : e,
278- } ) ?;
279-
280- let file_id = FileId ( file_idx as u32 ) ;
281-
282- // Parse
283- let program = match parse ( & source, file_id) {
284- Ok ( p) => p,
285- Err ( errors) => {
286- has_errors = true ;
287- for err in errors {
288- let span: SourceSpan = ( err. span . start , err. span . end - err. span . start ) . into ( ) ;
289- let report = Report :: new ( CliError :: ParseError {
290- src : NamedSource :: new ( path. display ( ) . to_string ( ) , source. clone ( ) ) ,
291- span,
292- message : err. message . clone ( ) ,
293- label : err. label . clone ( ) . unwrap_or_else ( || "here" . into ( ) ) ,
294- help : err. help . clone ( ) ,
295- } ) ;
296- eprintln ! ( "{:?}" , report) ;
297- }
298- continue ;
299- }
300- } ;
337+ if !has_errors && verbose {
338+ eprintln ! ( "All checks passed!" ) ;
339+ }
301340
302- // Type check
303- if let Err ( diagnostics) = check ( & program, file_id) {
304- has_errors = true ;
305- for diag in diagnostics. iter ( ) {
306- let code_str = diag. code . as_ref ( ) . map ( |c| c. to_string ( ) ) . unwrap_or_default ( ) ;
307- eprintln ! (
308- "{}:{}: {} [{}]" ,
309- path. display( ) ,
310- diag. labels. first( ) . map( |l| l. span. start) . unwrap_or( 0 ) ,
311- diag. message,
312- code_str
313- ) ;
314- for note in & diag. notes {
315- eprintln ! ( " note: {:?}" , note) ;
316- }
317- }
341+ !has_errors
342+ }
343+
344+ /// Format files, returning true if all formatted successfully.
345+ fn format_files ( files : & [ PathBuf ] , check_only : bool ) -> bool {
346+ if files. is_empty ( ) {
347+ eprintln ! ( "No files specified" ) ;
348+ return true ;
349+ }
350+
351+ let mut success = true ;
352+
353+ for path in files {
354+ let source = match fs:: read_to_string ( path) {
355+ Ok ( s) => s,
356+ Err ( e) => {
357+ eprintln ! ( "Failed to read {}: {}" , path. display( ) , e) ;
358+ success = false ;
359+ continue ;
360+ }
361+ } ;
362+
363+ // Format the source
364+ let formatted = match anv_syntax:: format:: format ( & source) {
365+ Ok ( f) => f,
366+ Err ( errors) => {
367+ for err in errors {
368+ let span: SourceSpan = ( err. span . start , err. span . end - err. span . start ) . into ( ) ;
369+ let report = Report :: new ( CliError :: ParseError {
370+ src : NamedSource :: new ( path. display ( ) . to_string ( ) , source. clone ( ) ) ,
371+ span,
372+ message : err. message . clone ( ) ,
373+ label : err. label . clone ( ) . unwrap_or_else ( || "here" . into ( ) ) ,
374+ help : err. help . clone ( ) ,
375+ } ) ;
376+ eprintln ! ( "{:?}" , report) ;
318377 }
378+ success = false ;
379+ continue ;
319380 }
381+ } ;
320382
321- if has_errors {
322- Err ( miette:: miette!( "check failed with errors" ) ) ?;
323- } else if verbose {
324- eprintln ! ( "All checks passed!" ) ;
383+ let changed = source != formatted;
384+
385+ if check_only {
386+ if changed {
387+ eprintln ! ( "{}: would be reformatted" , path. display( ) ) ;
388+ success = false ;
389+ } else {
390+ eprintln ! ( "{}: ok" , path. display( ) ) ;
391+ }
392+ } else if changed {
393+ if let Err ( e) = fs:: write ( path, & formatted) {
394+ eprintln ! ( "Failed to write {}: {}" , path. display( ) , e) ;
395+ success = false ;
396+ } else {
397+ eprintln ! ( "{}: formatted" , path. display( ) ) ;
398+ }
399+ } else {
400+ eprintln ! ( "{}: unchanged" , path. display( ) ) ;
401+ }
402+ }
403+
404+ success
405+ }
406+
407+ /// Watch files for changes and run a callback when they change.
408+ fn watch_files < F > ( files : & [ PathBuf ] , mut on_change : F ) -> miette:: Result < ( ) >
409+ where
410+ F : FnMut ( ) -> bool ,
411+ {
412+ eprintln ! ( "Watching for changes... (press Ctrl+C to stop)" ) ;
413+
414+ // Run once initially
415+ on_change ( ) ;
416+
417+ // Set up file watcher
418+ let ( tx, rx) = channel ( ) ;
419+ let mut debouncer = new_debouncer ( Duration :: from_millis ( 300 ) , tx)
420+ . map_err ( |e| miette:: miette!( "Failed to create watcher: {}" , e) ) ?;
421+
422+ // Watch parent directories of all files
423+ let mut watched_dirs = std:: collections:: HashSet :: new ( ) ;
424+ for path in files {
425+ if let Some ( parent) = path. parent ( ) {
426+ let parent = if parent. as_os_str ( ) . is_empty ( ) {
427+ PathBuf :: from ( "." )
428+ } else {
429+ parent. to_path_buf ( )
430+ } ;
431+ if watched_dirs. insert ( parent. clone ( ) ) {
432+ debouncer
433+ . watcher ( )
434+ . watch ( & parent, RecursiveMode :: NonRecursive )
435+ . map_err ( |e| miette:: miette!( "Failed to watch {}: {}" , parent. display( ) , e) ) ?;
436+ }
437+ }
438+ }
439+
440+ // Process events
441+ loop {
442+ match rx. recv ( ) {
443+ Ok ( Ok ( events) ) => {
444+ // Check if any of our files changed
445+ let our_files: std:: collections:: HashSet < _ > = files
446+ . iter ( )
447+ . filter_map ( |p| p. canonicalize ( ) . ok ( ) )
448+ . collect ( ) ;
449+
450+ let changed = events. iter ( ) . any ( |event| {
451+ event
452+ . path
453+ . canonicalize ( )
454+ . map ( |p| our_files. contains ( & p) )
455+ . unwrap_or ( false )
456+ } ) ;
457+
458+ if changed {
459+ eprintln ! ( "\n --- File changed, re-running... ---\n " ) ;
460+ on_change ( ) ;
461+ }
462+ }
463+ Ok ( Err ( error) ) => {
464+ eprintln ! ( "Watch error: {}" , error) ;
465+ }
466+ Err ( e) => {
467+ eprintln ! ( "Watch channel error: {}" , e) ;
468+ break ;
469+ }
470+ }
471+ }
472+
473+ Ok ( ( ) )
474+ }
475+
476+ fn run ( cli : Cli ) -> miette:: Result < ( ) > {
477+ match cli. command {
478+ Commands :: Check { files, verbose, watch } => {
479+ if watch {
480+ watch_files ( & files, || check_files ( & files, verbose) ) ?;
481+ } else {
482+ let success = check_files ( & files, verbose) ;
483+ if !success {
484+ return Err ( miette:: miette!( "check failed with errors" ) ) ;
485+ }
325486 }
326487
327488 Ok ( ( ) )
@@ -384,36 +545,13 @@ fn run(cli: Cli) -> miette::Result<()> {
384545 Ok ( ( ) )
385546 }
386547
387- Commands :: Fmt { files, check } => {
388- if files. is_empty ( ) {
389- eprintln ! ( "No files specified" ) ;
390- return Ok ( ( ) ) ;
391- }
392-
393- for path in & files {
394- let source = fs:: read_to_string ( path) . map_err ( |e| CliError :: ReadError {
395- path : path. display ( ) . to_string ( ) ,
396- source : e,
397- } ) ?;
398-
399- // TODO: Implement formatter
400- // For now, just verify the file parses
401- let _program = parse ( & source, FileId ( 0 ) ) . map_err ( |errors| {
402- let err = & errors[ 0 ] ;
403- let span: SourceSpan = ( err. span . start , err. span . end - err. span . start ) . into ( ) ;
404- CliError :: ParseError {
405- src : NamedSource :: new ( path. display ( ) . to_string ( ) , source. clone ( ) ) ,
406- span,
407- message : err. message . clone ( ) ,
408- label : err. label . clone ( ) . unwrap_or_else ( || "here" . into ( ) ) ,
409- help : err. help . clone ( ) ,
410- }
411- } ) ?;
412-
413- if check {
414- eprintln ! ( "{}: would reformat" , path. display( ) ) ;
415- } else {
416- eprintln ! ( "{}: formatted (no-op, formatter not implemented)" , path. display( ) ) ;
548+ Commands :: Fmt { files, check : check_only, watch } => {
549+ if watch {
550+ watch_files ( & files, || format_files ( & files, check_only) ) ?;
551+ } else {
552+ let success = format_files ( & files, check_only) ;
553+ if !success && check_only {
554+ return Err ( miette:: miette!( "Some files would be reformatted" ) ) ;
417555 }
418556 }
419557
0 commit comments