381 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			V
		
	
	
			
		
		
	
	
			381 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			V
		
	
	
| module main
 | |
| 
 | |
| import os
 | |
| import time
 | |
| import term
 | |
| import flag
 | |
| 
 | |
| const scan_timeout_s = get_scan_timeout_seconds()
 | |
| 
 | |
| const max_v_cycles = 1000
 | |
| 
 | |
| const scan_frequency_hz = 4
 | |
| 
 | |
| const scan_period_ms = 1000 / scan_frequency_hz
 | |
| 
 | |
| const max_scan_cycles = scan_timeout_s * scan_frequency_hz
 | |
| 
 | |
| fn get_scan_timeout_seconds() int {
 | |
| 	env_vw_timeout := os.getenv('VWATCH_TIMEOUT').int()
 | |
| 	if env_vw_timeout == 0 {
 | |
| 		$if gcboehm ? {
 | |
| 			return 35000000 // over 1 year
 | |
| 		} $else {
 | |
| 			return 5 * 60
 | |
| 		}
 | |
| 	}
 | |
| 	return env_vw_timeout
 | |
| }
 | |
| 
 | |
| //
 | |
| // Implements `v watch file.v` , `v watch run file.v` etc.
 | |
| // With this command, V will collect all .v files that are needed for the
 | |
| // compilation, then it will enter an infinite loop, monitoring them for
 | |
| // changes.
 | |
| //
 | |
| // When a change is detected, it will stop the current process, if it is
 | |
| // still running, then rerun/recompile/etc.
 | |
| //
 | |
| // In effect, this makes it easy to have an editor session and a separate
 | |
| // terminal, running just `v watch run file.v`, and you will see your
 | |
| // changes right after you save your .v file in your editor.
 | |
| //
 | |
| //
 | |
| //    Since -gc boehm is not available on all platforms yet,
 | |
| // and this program leaks ~8MB/minute without it, the implementation here
 | |
| // is done similarly to vfmt in 2 modes, in the same executable:
 | |
| //
 | |
| //   a) A parent/manager process that only manages a single worker
 | |
| //   process. The parent process does mostly nothing except restarting
 | |
| //   workers, thus it does not leak much.
 | |
| //
 | |
| //   b) A worker process, doing the actual monitoring/polling.
 | |
| //    Note: *workers are started with the --vwatchworker option*
 | |
| //
 | |
| //    Worker processes will run for a limited number of iterations, then
 | |
| // they will do exit(255), and then the parent will start a new worker.
 | |
| // Exiting by any other code will cause the parent to also exit with the
 | |
| // same error code. This limits the potential leak that a worker process
 | |
| // can do, even without using the garbage collection mode.
 | |
| //
 | |
| 
 | |
| struct VFileStat {
 | |
| 	path  string
 | |
| 	mtime i64
 | |
| }
 | |
| 
 | |
| [unsafe]
 | |
| fn (mut vfs VFileStat) free() {
 | |
| 	unsafe { vfs.path.free() }
 | |
| }
 | |
| 
 | |
| enum RerunCommand {
 | |
| 	restart
 | |
| 	quit
 | |
| }
 | |
| 
 | |
| struct Context {
 | |
| mut:
 | |
| 	pid             int  // the pid of the current process; useful while debugging manager/worker interactions
 | |
| 	is_worker       bool // true in the workers, false in the manager process
 | |
| 	check_period_ms int = scan_period_ms
 | |
| 	vexe            string
 | |
| 	affected_paths  []string
 | |
| 	vfiles          []VFileStat
 | |
| 	opts            []string
 | |
| 	rerun_channel   chan RerunCommand
 | |
| 	child_process   &os.Process
 | |
| 	is_exiting      bool     // set by SIGINT/Ctrl-C
 | |
| 	v_cycles        int      // how many times the worker has restarted the V compiler
 | |
| 	scan_cycles     int      // how many times the worker has scanned for source file changes
 | |
| 	clear_terminal  bool     // whether to clear the terminal before each re-run
 | |
| 	silent          bool     // when true, watch will not print a timestamp line before each re-run
 | |
| 	add_files       []string // path to additional files that have to be watched for changes
 | |
| 	ignore_exts     []string // extensions of files that will be ignored, even if they change (useful for sqlite.db files for example)
 | |
| 	cmd_before_run  string   // a command to run before each re-run
 | |
| 	cmd_after_run   string   // a command to run after each re-run
 | |
| }
 | |
| 
 | |
| [if debug_vwatch ?]
 | |
| fn (mut context Context) elog(msg string) {
 | |
| 	eprintln('> vwatch $context.pid, $msg')
 | |
| }
 | |
| 
 | |
| fn (context &Context) str() string {
 | |
| 	return 'Context{ pid: $context.pid, is_worker: $context.is_worker, check_period_ms: $context.check_period_ms, vexe: $context.vexe, opts: $context.opts, is_exiting: $context.is_exiting, vfiles: $context.vfiles'
 | |
| }
 | |
| 
 | |
| fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat {
 | |
| 	if context.affected_paths.len == 0 {
 | |
| 		mut apaths := map[string]bool{}
 | |
| 		// The next command will make V parse the program, and print all .v files,
 | |
| 		// needed for its compilation, without actually compiling it.
 | |
| 		copts := context.opts.join(' ')
 | |
| 		cmd := '"$context.vexe" -silent -print-v-files $copts'
 | |
| 		// context.elog('> cmd: $cmd')
 | |
| 		mut paths := []string{}
 | |
| 		if context.add_files.len > 0 && context.add_files[0] != '' {
 | |
| 			paths << context.add_files
 | |
| 		}
 | |
| 		vfiles := os.execute(cmd)
 | |
| 		if vfiles.exit_code == 0 {
 | |
| 			paths_trimmed := vfiles.output.trim_space()
 | |
| 			paths << paths_trimmed.split('\n')
 | |
| 		}
 | |
| 		for vf in paths {
 | |
| 			apaths[os.real_path(os.dir(vf))] = true
 | |
| 		}
 | |
| 		context.affected_paths = apaths.keys()
 | |
| 		// context.elog('vfiles paths to be scanned: $context.affected_paths')
 | |
| 	}
 | |
| 	// scan all files in the found folders
 | |
| 	mut newstats := []VFileStat{}
 | |
| 	for path in context.affected_paths {
 | |
| 		mut files := os.ls(path) or { []string{} }
 | |
| 		for pf in files {
 | |
| 			pf_ext := os.file_ext(pf).to_lower()
 | |
| 			if pf_ext in ['', '.bak', '.exe', '.dll', '.so', '.def'] {
 | |
| 				continue
 | |
| 			}
 | |
| 			if pf_ext in context.ignore_exts {
 | |
| 				continue
 | |
| 			}
 | |
| 			if pf.starts_with('.#') {
 | |
| 				continue
 | |
| 			}
 | |
| 			if pf.ends_with('~') {
 | |
| 				continue
 | |
| 			}
 | |
| 			f := os.join_path(path, pf)
 | |
| 			fullpath := os.real_path(f)
 | |
| 			mtime := os.file_last_mod_unix(fullpath)
 | |
| 			newstats << VFileStat{fullpath, mtime}
 | |
| 		}
 | |
| 	}
 | |
| 	// always add the v compiler itself, so that if it is recompiled with `v self`
 | |
| 	// the watcher will rerun the compilation too
 | |
| 	newstats << VFileStat{context.vexe, os.file_last_mod_unix(context.vexe)}
 | |
| 	return newstats
 | |
| }
 | |
| 
 | |
| fn (mut context Context) get_changed_vfiles() int {
 | |
| 	mut changed := 0
 | |
| 	newfiles := context.get_stats_for_affected_vfiles()
 | |
| 	for vfs in newfiles {
 | |
| 		mut found := false
 | |
| 		for existing_vfs in context.vfiles {
 | |
| 			if existing_vfs.path == vfs.path {
 | |
| 				found = true
 | |
| 				if existing_vfs.mtime != vfs.mtime {
 | |
| 					context.elog('> new updates for file: $vfs')
 | |
| 					changed++
 | |
| 				}
 | |
| 				break
 | |
| 			}
 | |
| 		}
 | |
| 		if !found {
 | |
| 			changed++
 | |
| 			continue
 | |
| 		}
 | |
| 	}
 | |
| 	context.vfiles = newfiles
 | |
| 	if changed > 0 {
 | |
| 		context.elog('> get_changed_vfiles: $changed')
 | |
| 	}
 | |
| 	return changed
 | |
| }
 | |
| 
 | |
| fn change_detection_loop(ocontext &Context) {
 | |
| 	mut context := unsafe { ocontext }
 | |
| 	for {
 | |
| 		if context.v_cycles >= max_v_cycles || context.scan_cycles >= max_scan_cycles {
 | |
| 			context.is_exiting = true
 | |
| 			context.kill_pgroup()
 | |
| 			time.sleep(50 * time.millisecond)
 | |
| 			exit(255)
 | |
| 		}
 | |
| 		if context.is_exiting {
 | |
| 			return
 | |
| 		}
 | |
| 		changes := context.get_changed_vfiles()
 | |
| 		if changes > 0 {
 | |
| 			context.rerun_channel <- RerunCommand.restart
 | |
| 		}
 | |
| 		time.sleep(context.check_period_ms * time.millisecond)
 | |
| 		context.scan_cycles++
 | |
| 	}
 | |
| }
 | |
| 
 | |
| fn (mut context Context) kill_pgroup() {
 | |
| 	if unsafe { context.child_process == 0 } {
 | |
| 		return
 | |
| 	}
 | |
| 	if context.child_process.is_alive() {
 | |
| 		context.child_process.signal_pgkill()
 | |
| 	}
 | |
| 	context.child_process.wait()
 | |
| }
 | |
| 
 | |
| fn (mut context Context) run_before_cmd() {
 | |
| 	if context.cmd_before_run != '' {
 | |
| 		context.elog('> run_before_cmd: "$context.cmd_before_run"')
 | |
| 		os.system(context.cmd_before_run)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| fn (mut context Context) run_after_cmd() {
 | |
| 	if context.cmd_after_run != '' {
 | |
| 		context.elog('> run_after_cmd: "$context.cmd_after_run"')
 | |
| 		os.system(context.cmd_after_run)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| fn (mut context Context) compilation_runner_loop() {
 | |
| 	cmd := '"$context.vexe" ${context.opts.join(' ')}'
 | |
| 	_ := <-context.rerun_channel
 | |
| 	for {
 | |
| 		context.elog('>> loop: v_cycles: $context.v_cycles')
 | |
| 		if context.clear_terminal {
 | |
| 			term.clear()
 | |
| 		}
 | |
| 		context.run_before_cmd()
 | |
| 		timestamp := time.now().format_ss_milli()
 | |
| 		context.child_process = os.new_process(context.vexe)
 | |
| 		context.child_process.use_pgroup = true
 | |
| 		context.child_process.set_args(context.opts)
 | |
| 		context.child_process.run()
 | |
| 		if !context.silent {
 | |
| 			eprintln('$timestamp: $cmd | pid: ${context.child_process.pid:7d} | reload cycle: ${context.v_cycles:5d}')
 | |
| 		}
 | |
| 		for {
 | |
| 			mut notalive_count := 0
 | |
| 			mut cmds := []RerunCommand{}
 | |
| 			for {
 | |
| 				if context.is_exiting {
 | |
| 					return
 | |
| 				}
 | |
| 				if !context.child_process.is_alive() {
 | |
| 					context.child_process.wait()
 | |
| 					notalive_count++
 | |
| 					if notalive_count == 1 {
 | |
| 						// a short lived process finished, do cleanup:
 | |
| 						context.run_after_cmd()
 | |
| 					}
 | |
| 				}
 | |
| 				select {
 | |
| 					action := <-context.rerun_channel {
 | |
| 						cmds << action
 | |
| 						if action == .quit {
 | |
| 							context.kill_pgroup()
 | |
| 							return
 | |
| 						}
 | |
| 					}
 | |
| 					100 * time.millisecond {
 | |
| 						should_restart := RerunCommand.restart in cmds
 | |
| 						cmds = []
 | |
| 						if should_restart {
 | |
| 							// context.elog('>>>>>>>> KILLING $context.child_process.pid')
 | |
| 							context.kill_pgroup()
 | |
| 							break
 | |
| 						}
 | |
| 					}
 | |
| 				}
 | |
| 			}
 | |
| 			if !context.child_process.is_alive() {
 | |
| 				context.child_process.wait()
 | |
| 				context.child_process.close()
 | |
| 				if notalive_count == 0 {
 | |
| 					// a long running process was killed, do cleanup:
 | |
| 					context.run_after_cmd()
 | |
| 				}
 | |
| 				break
 | |
| 			}
 | |
| 		}
 | |
| 		context.v_cycles++
 | |
| 	}
 | |
| }
 | |
| 
 | |
| const ccontext = Context{
 | |
| 	child_process: 0
 | |
| }
 | |
| 
 | |
| fn main() {
 | |
| 	mut context := unsafe { &Context(voidptr(&ccontext)) }
 | |
| 	context.pid = os.getpid()
 | |
| 	context.vexe = os.getenv('VEXE')
 | |
| 
 | |
| 	mut fp := flag.new_flag_parser(os.args[1..])
 | |
| 	fp.application('v watch')
 | |
| 	if os.args[1] == 'watch' {
 | |
| 		fp.skip_executable()
 | |
| 	}
 | |
| 	fp.version('0.0.2')
 | |
| 	fp.description('Collect all .v files needed for a compilation, then re-run the compilation when any of the source changes.')
 | |
| 	fp.arguments_description('[--silent] [--clear] [--ignore .db] [--add /path/to/a/file.v] [run] program.v')
 | |
| 	fp.allow_unknown_args()
 | |
| 	fp.limit_free_args_to_at_least(1)?
 | |
| 	context.is_worker = fp.bool('vwatchworker', 0, false, 'Internal flag. Used to distinguish vwatch manager and worker processes.')
 | |
| 	context.silent = fp.bool('silent', `s`, false, 'Be more silent; do not print the watch timestamp before each re-run.')
 | |
| 	context.clear_terminal = fp.bool('clear', `c`, false, 'Clears the terminal before each re-run.')
 | |
| 	context.add_files = fp.string('add', `a`, '', 'Add more files to be watched. Useful with `v watch -add=/tmp/feature.v run cmd/v /tmp/feature.v`, if you change *both* the compiler, and the feature.v file.').split(',')
 | |
| 	context.ignore_exts = fp.string('ignore', `i`, '', 'Ignore files having these extensions. Useful with `v watch -ignore=.db run server.v`, if your server writes to an sqlite.db file in the same folder.').split(',')
 | |
| 	show_help := fp.bool('help', `h`, false, 'Show this help screen.')
 | |
| 	context.cmd_before_run = fp.string('before', 0, '', 'A command to execute *before* each re-run.')
 | |
| 	context.cmd_after_run = fp.string('after', 0, '', 'A command to execute *after* each re-run.')
 | |
| 	if show_help {
 | |
| 		println(fp.usage())
 | |
| 		exit(0)
 | |
| 	}
 | |
| 	remaining_options := fp.finalize() or {
 | |
| 		eprintln('Error: $err')
 | |
| 		exit(1)
 | |
| 	}
 | |
| 	context.opts = remaining_options
 | |
| 	context.elog('>>> context.pid: $context.pid')
 | |
| 	context.elog('>>> context.vexe: $context.vexe')
 | |
| 	context.elog('>>> context.opts: $context.opts')
 | |
| 	context.elog('>>> context.is_worker: $context.is_worker')
 | |
| 	context.elog('>>> context.clear_terminal: $context.clear_terminal')
 | |
| 	context.elog('>>> context.add_files: $context.add_files')
 | |
| 	context.elog('>>> context.ignore_exts: $context.ignore_exts')
 | |
| 	if context.is_worker {
 | |
| 		context.worker_main()
 | |
| 	} else {
 | |
| 		context.manager_main()
 | |
| 	}
 | |
| }
 | |
| 
 | |
| fn (mut context Context) manager_main() {
 | |
| 	myexecutable := os.executable()
 | |
| 	mut worker_opts := ['--vwatchworker']
 | |
| 	worker_opts << os.args[2..]
 | |
| 	for {
 | |
| 		mut worker_process := os.new_process(myexecutable)
 | |
| 		worker_process.set_args(worker_opts)
 | |
| 		worker_process.run()
 | |
| 		for {
 | |
| 			if !worker_process.is_alive() {
 | |
| 				worker_process.wait()
 | |
| 				break
 | |
| 			}
 | |
| 			time.sleep(200 * time.millisecond)
 | |
| 		}
 | |
| 		if !(worker_process.code == 255 && worker_process.status == .exited) {
 | |
| 			worker_process.close()
 | |
| 			break
 | |
| 		}
 | |
| 		worker_process.close()
 | |
| 	}
 | |
| }
 | |
| 
 | |
| fn (mut context Context) worker_main() {
 | |
| 	context.rerun_channel = chan RerunCommand{cap: 10}
 | |
| 	os.signal_opt(.int, fn (_ os.Signal) {
 | |
| 		mut context := unsafe { &Context(voidptr(&ccontext)) }
 | |
| 		context.is_exiting = true
 | |
| 		context.kill_pgroup()
 | |
| 	}) or { panic(err) }
 | |
| 	go context.compilation_runner_loop()
 | |
| 	change_detection_loop(context)
 | |
| }
 |