v / cmd / tools / vwatch.v
639 lines · 601 sloc · 19.09 KB · 24273fc8972eae2dfb65e1d342f02fbb053a83b2
Raw
1module main
2
3import os
4import time
5import term
6import v.help
7
8const scan_timeout_s = get_scan_timeout_seconds()
9
10const max_v_cycles = 1000
11
12const scan_frequency_hz = 4
13
14const scan_period_ms = 1000 / scan_frequency_hz
15
16const max_scan_cycles = scan_timeout_s * scan_frequency_hz
17
18const default_veb_suffixes = '*.v,*.html,*.css,*.js,*.md,*.tr'
19
20fn get_scan_timeout_seconds() int {
21 env_vw_timeout := os.getenv('VWATCH_TIMEOUT').int()
22 if env_vw_timeout == 0 {
23 $if gcboehm ? {
24 return 35000000 // over 1 year
25 } $else {
26 return 5 * 60
27 }
28 }
29 return env_vw_timeout
30}
31
32//
33// Implements `v watch file.v` , `v watch run file.v` etc.
34// With this command, V will collect all .v files that are needed for the
35// compilation, then it will enter an infinite loop, monitoring them for
36// changes.
37//
38// When a change is detected, it will stop the current process, if it is
39// still running, then rerun/recompile/etc.
40//
41// In effect, this makes it easy to have an editor session and a separate
42// terminal, running just `v watch run file.v`, and you will see your
43// changes right after you save your .v file in your editor.
44//
45//
46// Since -gc boehm is not available on all platforms yet,
47// and this program leaks ~8MB/minute without it, the implementation here
48// is done similarly to vfmt in 2 modes, in the same executable:
49//
50// a) A parent/manager process that only manages a single worker
51// process. The parent process does mostly nothing except restarting
52// workers, thus it does not leak much.
53//
54// b) A worker process, doing the actual monitoring/polling.
55// Note: *workers are started with the --vwatchworker option*
56//
57// Worker processes will run for a limited number of iterations, then
58// they will do exit(255), and then the parent will start a new worker.
59// Exiting by any other code will cause the parent to also exit with the
60// same error code. This limits the potential leak that a worker process
61// can do, even without using the garbage collection mode.
62//
63
64struct VFileStat {
65 path string
66 mtime i64
67}
68
69@[unsafe]
70fn (mut vfs VFileStat) free() {
71 unsafe { vfs.path.free() }
72}
73
74enum RerunCommand {
75 restart
76 quit
77}
78
79struct Context {
80mut:
81 pid int // the pid of the current process; useful while debugging manager/worker interactions
82 is_worker bool // true in the workers, false in the manager process
83 check_period_ms int = scan_period_ms
84 vexe string
85 affected_paths []string
86 vfiles []VFileStat
87 opts []string
88 rerun_channel chan RerunCommand
89 child_process &os.Process = unsafe { nil }
90 is_exiting bool // set by SIGINT/Ctrl-C
91 v_cycles int // how many times the worker has restarted the V compiler
92 scan_cycles int // how many times the worker has scanned for source file changes
93 clear_terminal bool // whether to clear the terminal before each re-run
94 keep_running bool // when true, re-run the program automatically if it exits on its own. Useful for gg apps.
95 silent bool // when true, watch will not print a timestamp line before each re-run
96 add_files []string // path to additional files that have to be watched for changes
97 ignore_exts []string // extensions of files that will be ignored, even if they change (useful for sqlite.db files for example)
98 cmd_before_run string // a command to run before each re-run
99 cmd_after_run string // a command to run after each re-run
100 only_watch []string // If not empty, *all* files that trigger updates, should match *at least one* of these s.match_glob() patterns. This is also triggered for veb apps, to monitor for just *.v,*.js,*.css,*.html in veb projects.
101}
102
103struct ParsedWatchOptions {
104mut:
105 remaining_options []string
106 is_worker bool
107 silent bool
108 clear_terminal bool
109 keep_running bool
110 add_files []string = ['']
111 ignore_exts []string = ['']
112 only_watch []string = ['']
113 cmd_before_run string
114 cmd_after_run string
115 show_help bool
116}
117
118@[if debug_vwatch ?]
119fn (mut context Context) elog(msg string) {
120 eprintln('> vwatch ${context.pid}, ${msg}')
121}
122
123fn (context &Context) str() string {
124 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}'
125}
126
127fn (mut context Context) is_ext_ignored(pf string, pf_ext string) bool {
128 for ipattern in context.ignore_exts {
129 if pf_ext.match_glob(ipattern) {
130 return true
131 }
132 }
133 if pf_ext in ['', '.so', '.a'] {
134 // on unix, the executables saved by compilers, usually do not have extensions at all, and shared libs are .so
135 return true
136 }
137 if pf_ext in ['.exe', '.dll', '.def'] {
138 // on windows, files with these extensions will be generated by the compiler
139 return true
140 }
141 // ignore common backup files saved by editors like emacs/jed/vim:
142 if pf_ext == '.bak' {
143 return true
144 }
145 // ignore DB files (sqlite databases, that are likely to change during prototyping):
146 if pf_ext in ['.db', '.sqlite'] {
147 return true
148 }
149 if pf.starts_with('.#') {
150 return true
151 }
152 if pf.ends_with('~') {
153 return true
154 }
155 return false
156}
157
158fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat {
159 if context.affected_paths.len == 0 {
160 mut apaths := map[string]bool{}
161 // The next command will make V parse the program, and print all .v files,
162 // needed for its compilation, without actually compiling it.
163 copts := context.opts.join(' ')
164 cmd := '"${context.vexe}" -silent -print-watched-files ${copts}'
165 // context.elog('> cmd: ${cmd}')
166 mut paths := []string{}
167 if context.add_files.len > 0 && context.add_files[0] != '' {
168 paths << context.add_files
169 }
170 vfiles := os.execute(cmd)
171 if vfiles.exit_code == 0 {
172 paths_trimmed := vfiles.output.trim_space()
173 reported_used_files := paths_trimmed.split_any('\n')
174 $if trace_reported_used_files ? {
175 context.elog('reported_used_files: ${reported_used_files}')
176 }
177 paths << reported_used_files
178 }
179 mut is_veb_found := false
180 for vf in paths {
181 apaths[os.real_path(os.dir(vf))] = true
182 if vf.contains('veb.v') {
183 is_veb_found = true
184 }
185 }
186
187 if is_veb_found {
188 if !os.args.any(it.starts_with('--only-watch')) {
189 context.only_watch = default_veb_suffixes.split_any(',')
190 // veb is often used with SQLite .db or .sqlite3 files right next to the executable/source,
191 // that are updated by the veb app, causing a restart of the app, which in turn causes the
192 // browser to reload the current page, that probably triggered the update in the first place.
193 // Note that the problem is not specific to SQLite, any database that stores its files in the
194 // current (project) folder, will also cause this.
195 println('`v watch` detected that you are compiling a veb project.')
196 println(' Because of that, the `--only-watch=${default_veb_suffixes}` flag was also implied.')
197 println(' In result, `v watch` will ignore changes to other files.')
198 println(' Add your own --only-watch filter, if you wish to override that choice.')
199 println('')
200 }
201 // .tr translation files are loaded at runtime by veb (see vlib/veb/tr.v),
202 // so the compiler never reports them via -print-watched-files. Explicitly
203 // add `translations/` subdirectories of each project path, so edits to
204 // .tr files trigger a reload like .html does.
205 for path in apaths.keys() {
206 tr_dir := os.join_path_single(path, 'translations')
207 if os.is_dir(tr_dir) {
208 apaths[os.real_path(tr_dir)] = true
209 }
210 }
211 }
212 context.affected_paths = apaths.keys()
213 // context.elog('vfiles paths to be scanned: ${context.affected_paths}')
214 }
215 // scan all files in the found folders:
216 mut newstats := []VFileStat{}
217 for path in context.affected_paths {
218 mut files := os.ls(path) or { []string{} }
219 next_file: for pf in files {
220 pf_path := os.join_path_single(path, pf)
221 if context.only_watch.len > 0 {
222 // in the whitelist mode, first only allow files, which match at least one of the patterns in context.only_watch:
223 mut matched_pattern_idx := -1
224 for ow_pattern_idx, ow_pattern in context.only_watch {
225 if pf_path.match_glob(ow_pattern) {
226 matched_pattern_idx = ow_pattern_idx
227 context.elog('> ${@METHOD} matched --only-watch pattern: ${ow_pattern}, for file: ${pf_path}')
228 break
229 }
230 }
231 if matched_pattern_idx == -1 {
232 context.elog('> ${@METHOD} --only-watch ignored file: ${pf_path}')
233 continue
234 }
235 }
236 // by default allow everything, except very specific extensions (backup files, executables etc):
237 pf_ext := os.file_ext(pf).to_lower()
238 if context.is_ext_ignored(pf, pf_ext) {
239 context.elog('> ${@METHOD} ignored extension: ${pf_ext}, for file: ${pf_path}')
240 continue
241 }
242 f := os.join_path(path, pf)
243 fullpath := os.real_path(f)
244 mtime := os.file_last_mod_unix(fullpath)
245 newstats << VFileStat{fullpath, mtime}
246 }
247 }
248 // always add the v compiler itself, so that if it is recompiled with `v self`
249 // the watcher will rerun the compilation too
250 newstats << VFileStat{context.vexe, os.file_last_mod_unix(context.vexe)}
251 return newstats
252}
253
254fn (mut context Context) get_changed_vfiles() int {
255 mut changed := 0
256 newfiles := context.get_stats_for_affected_vfiles()
257 for vfs in newfiles {
258 mut found := false
259 for existing_vfs in context.vfiles {
260 if existing_vfs.path == vfs.path {
261 found = true
262 if existing_vfs.mtime != vfs.mtime {
263 context.elog('> new updates for file: ${vfs}')
264 changed++
265 }
266 break
267 }
268 }
269 if !found {
270 changed++
271 continue
272 }
273 }
274 context.vfiles = newfiles
275 if changed > 0 {
276 context.elog('> get_changed_vfiles: ${changed}')
277 }
278 return changed
279}
280
281fn change_detection_loop(ocontext &Context) {
282 mut context := unsafe { ocontext }
283 for {
284 if context.v_cycles >= max_v_cycles || context.scan_cycles >= max_scan_cycles {
285 context.is_exiting = true
286 context.kill_pgroup()
287 time.sleep(50 * time.millisecond)
288 exit(255)
289 }
290 if context.is_exiting {
291 return
292 }
293 changes := context.get_changed_vfiles()
294 if changes > 0 {
295 context.rerun_channel <- RerunCommand.restart
296 }
297 time.sleep(context.check_period_ms * time.millisecond)
298 context.scan_cycles++
299 }
300}
301
302fn (mut context Context) kill_pgroup() {
303 if unsafe { context.child_process == 0 } {
304 return
305 }
306 if context.child_process.is_alive() {
307 context.child_process.signal_pgkill()
308 }
309 context.child_process.wait()
310 context.child_process.close()
311}
312
313fn (mut context Context) run_before_cmd() {
314 if context.cmd_before_run != '' {
315 context.elog('> run_before_cmd: "${context.cmd_before_run}"')
316 os.system(context.cmd_before_run)
317 }
318}
319
320fn (mut context Context) run_after_cmd() {
321 if context.cmd_after_run != '' {
322 context.elog('> run_after_cmd: "${context.cmd_after_run}"')
323 os.system(context.cmd_after_run)
324 }
325}
326
327fn (mut context Context) compilation_runner_loop() {
328 cmd := '"${context.vexe}" ${context.opts.join(' ')}'
329 _ := <-context.rerun_channel
330 for {
331 context.elog('>> loop: v_cycles: ${context.v_cycles}')
332 if context.clear_terminal {
333 term.clear()
334 }
335 context.run_before_cmd()
336 timestamp := time.now().format_ss_milli()
337 context.child_process = os.new_process(context.vexe)
338 context.child_process.use_pgroup = true
339 context.child_process.set_args(context.opts)
340 context.child_process.run()
341 if !context.silent {
342 eprintln('${timestamp}: ${cmd} | pid: ${context.child_process.pid:7d} | reload cycle: ${context.v_cycles:5d}')
343 }
344 for {
345 mut notalive_count := 0
346 mut cmds := []RerunCommand{}
347 for {
348 if context.is_exiting {
349 return
350 }
351 if !context.child_process.is_alive() {
352 context.child_process.wait()
353 notalive_count++
354 if notalive_count == 1 {
355 // a short lived process finished, do cleanup:
356 context.run_after_cmd()
357 if context.keep_running {
358 break
359 }
360 }
361 }
362 select {
363 action := <-context.rerun_channel {
364 cmds << action
365 if action == .quit {
366 context.kill_pgroup()
367 return
368 }
369 }
370 100 * time.millisecond {
371 should_restart := RerunCommand.restart in cmds
372 cmds = []
373 if should_restart {
374 // context.elog('>>>>>>>> KILLING ${context.child_process.pid}')
375 context.kill_pgroup()
376 break
377 }
378 }
379 }
380 }
381 if !context.child_process.is_alive() {
382 context.elog('> child_process is no longer alive | notalive_count: ${notalive_count}')
383 context.child_process.wait()
384 context.child_process.close()
385 if notalive_count == 0 {
386 // a long running process was killed, do cleanup:
387 context.run_after_cmd()
388 }
389 break
390 }
391 }
392 context.v_cycles++
393 }
394}
395
396const ccontext = Context{}
397
398fn main() {
399 mut context := unsafe { &Context(voidptr(&ccontext)) }
400 context.pid = os.getpid()
401 context.vexe = os.getenv('VEXE')
402
403 watch_pos := os.args.index('watch')
404 all_args_before_watch_cmd := os.args#[1..watch_pos]
405 all_args_after_watch_cmd := os.args#[watch_pos + 1..]
406 // dump(os.getpid())
407 // dump(all_args_before_watch_cmd)
408 // dump(all_args_after_watch_cmd)
409
410 // Options after `run` should be ignored, since they are intended for the user program, not for the watcher.
411 // For example, `v watch run x.v -a -b -k', should pass all of -a -b -k to the compiled and run program.
412 only_watch_options, has_run := all_before('run', all_args_after_watch_cmd)
413 parsed_watch_options := parse_watch_options(only_watch_options) or {
414 eprintln('Error: ${err}')
415 exit(1)
416 }
417 if parsed_watch_options.show_help {
418 help.print_and_exit('watch', exit_code: 0)
419 }
420 context.is_worker = parsed_watch_options.is_worker
421 context.silent = parsed_watch_options.silent
422 context.clear_terminal = parsed_watch_options.clear_terminal
423 context.keep_running = parsed_watch_options.keep_running
424 context.add_files = parsed_watch_options.add_files
425 context.ignore_exts = parsed_watch_options.ignore_exts
426 context.only_watch = parsed_watch_options.only_watch
427 context.cmd_before_run = parsed_watch_options.cmd_before_run
428 context.cmd_after_run = parsed_watch_options.cmd_after_run
429 context.opts = []
430 context.opts << all_args_before_watch_cmd
431 context.opts << parsed_watch_options.remaining_options
432 if has_run {
433 context.opts << 'run'
434 context.opts << all_after('run', all_args_after_watch_cmd)
435 }
436 context.elog('>>> context.pid: ${context.pid}')
437 context.elog('>>> context.vexe: ${context.vexe}')
438 context.elog('>>> context.opts: ${context.opts}')
439 context.elog('>>> context.is_worker: ${context.is_worker}')
440 context.elog('>>> context.clear_terminal: ${context.clear_terminal}')
441 context.elog('>>> context.add_files: ${context.add_files}')
442 context.elog('>>> context.ignore_exts: ${context.ignore_exts}')
443 context.elog('>>> context.only_watch: ${context.only_watch}')
444 if context.is_worker {
445 context.worker_main()
446 } else {
447 context.manager_main(all_args_before_watch_cmd, all_args_after_watch_cmd)
448 }
449}
450
451// parse_watch_options parses only vwatch's own flags and leaves compiler flags untouched.
452// This avoids `flag.FlagParser` short-cluster handling from rewriting passthrough args like
453// `-backend` into `-baend`.
454fn parse_watch_options(args []string) !ParsedWatchOptions {
455 mut parsed := ParsedWatchOptions{}
456 mut i := 0
457 for i < args.len {
458 arg := args[i]
459 if arg == '--vwatchworker' {
460 parsed.is_worker = true
461 i++
462 continue
463 }
464 if is_watch_option_name(arg, 'silent', `s`) {
465 parsed.silent = true
466 i++
467 continue
468 }
469 if is_watch_option_name(arg, 'clear', `c`) {
470 parsed.clear_terminal = true
471 i++
472 continue
473 }
474 if is_watch_option_name(arg, 'keep', `k`) {
475 parsed.keep_running = true
476 i++
477 continue
478 }
479 if is_watch_option_name(arg, 'help', `h`) {
480 parsed.show_help = true
481 i++
482 continue
483 }
484 if value := watch_option_inline_value(arg, 'add', `a`) {
485 parsed.add_files = value.split_any(',')
486 i++
487 continue
488 }
489 if is_watch_option_name(arg, 'add', `a`) {
490 i++
491 if i >= args.len {
492 return error('missing value for `${arg}`')
493 }
494 parsed.add_files = args[i].split_any(',')
495 i++
496 continue
497 }
498 if value := watch_option_inline_value(arg, 'ignore', `i`) {
499 parsed.ignore_exts = value.split_any(',')
500 i++
501 continue
502 }
503 if is_watch_option_name(arg, 'ignore', `i`) {
504 i++
505 if i >= args.len {
506 return error('missing value for `${arg}`')
507 }
508 parsed.ignore_exts = args[i].split_any(',')
509 i++
510 continue
511 }
512 if value := watch_option_inline_value(arg, 'only-watch', `o`) {
513 parsed.only_watch = value.split_any(',')
514 i++
515 continue
516 }
517 if is_watch_option_name(arg, 'only-watch', `o`) {
518 i++
519 if i >= args.len {
520 return error('missing value for `${arg}`')
521 }
522 parsed.only_watch = args[i].split_any(',')
523 i++
524 continue
525 }
526 if value := watch_option_inline_value(arg, 'before', 0) {
527 parsed.cmd_before_run = value
528 i++
529 continue
530 }
531 if is_watch_option_name(arg, 'before', 0) {
532 i++
533 if i >= args.len {
534 return error('missing value for `${arg}`')
535 }
536 parsed.cmd_before_run = args[i]
537 i++
538 continue
539 }
540 if value := watch_option_inline_value(arg, 'after', 0) {
541 parsed.cmd_after_run = value
542 i++
543 continue
544 }
545 if is_watch_option_name(arg, 'after', 0) {
546 i++
547 if i >= args.len {
548 return error('missing value for `${arg}`')
549 }
550 parsed.cmd_after_run = args[i]
551 i++
552 continue
553 }
554 parsed.remaining_options << arg
555 i++
556 }
557 return parsed
558}
559
560fn is_watch_option_name(arg string, longhand string, shorthand u8) bool {
561 return arg == watch_short_option_name(shorthand) || arg == '-${longhand}'
562 || arg == '--${longhand}'
563}
564
565fn watch_option_inline_value(arg string, longhand string, shorthand u8) ?string {
566 prefixes := [watch_short_option_with_equals(shorthand), '-${longhand}=', '--${longhand}=']
567 for prefix in prefixes {
568 if prefix != '' && arg.starts_with(prefix) {
569 return arg[prefix.len..]
570 }
571 }
572 return none
573}
574
575fn watch_short_option_name(shorthand u8) string {
576 if shorthand == 0 {
577 return ''
578 }
579 return '-${shorthand.ascii_str()}'
580}
581
582fn watch_short_option_with_equals(shorthand u8) string {
583 short_option := watch_short_option_name(shorthand)
584 if short_option == '' {
585 return ''
586 }
587 return '${short_option}='
588}
589
590fn (mut context Context) manager_main(all_args_before_watch_cmd []string, all_args_after_watch_cmd []string) {
591 myexecutable := os.executable()
592 mut worker_opts := all_args_before_watch_cmd.clone()
593 worker_opts << ['watch', '--vwatchworker']
594 worker_opts << all_args_after_watch_cmd
595 for {
596 mut worker_process := os.new_process(myexecutable)
597 worker_process.set_args(worker_opts)
598 worker_process.run()
599 for {
600 if !worker_process.is_alive() {
601 worker_process.wait()
602 break
603 }
604 time.sleep(200 * time.millisecond)
605 }
606 if !(worker_process.code == 255 && worker_process.status == .exited) {
607 worker_process.close()
608 break
609 }
610 worker_process.close()
611 }
612}
613
614fn (mut context Context) worker_main() {
615 context.rerun_channel = chan RerunCommand{cap: 10}
616 os.signal_opt(.int, fn (_ os.Signal) {
617 mut context := unsafe { &Context(voidptr(&ccontext)) }
618 context.is_exiting = true
619 context.kill_pgroup()
620 }) or { panic(err) }
621 spawn context.compilation_runner_loop()
622 change_detection_loop(context)
623}
624
625fn all_before(needle string, all []string) ([]string, bool) {
626 needle_pos := all.index(needle)
627 if needle_pos == -1 {
628 return all, false
629 }
630 return all#[..needle_pos], true
631}
632
633fn all_after(needle string, all []string) []string {
634 needle_pos := all.index(needle)
635 if needle_pos == -1 {
636 return all
637 }
638 return all#[needle_pos + 1..]
639}
640