diff --git a/.gitignore b/.gitignore index d0056c2..350eada 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ /libs/ /.crystal/ /.shards/ -guardian +/bin/guardian # Libraries don't need dependency lock diff --git a/README.md b/README.md index 25ec118..bbd6833 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,14 @@ run: shards install ### `%file%` Variable -Guardian replaces `%file%` variable in commands with the changed file. +Guardian replaces `%file%` variable in commands with the changed file(s). ```yaml files: ./**/*.txt run: echo "%file% is changed" ``` -Think you have a `hello.txt` in your directory, and Guardian will run `echo "hello.txt is changed"` command when it's changed. +Think you have a `hello.txt` & `goodbye.txt` in your directory, and Guardian will run `echo "hello.txt goodbye.txt is changed"` command when it's changed. ## Running Guardian diff --git a/shard.yml b/shard.yml index 5549d8e..61447c7 100644 --- a/shard.yml +++ b/shard.yml @@ -9,6 +9,11 @@ targets: guardian: main: src/guardian.cr +dependencies: + pty: + github: crystal-posix/pty.cr + branch: main + crystal: 0.35.1 license: MIT diff --git a/src/guardian.cr b/src/guardian.cr index b110805..9b3b47d 100644 --- a/src/guardian.cr +++ b/src/guardian.cr @@ -4,6 +4,7 @@ require "colorize" ignore_executables = true clear_on_action = false +verbose = 0 case ARGV[0]? when "init" puts "\"init\" has been deprecated please use: -i or --init" @@ -38,6 +39,10 @@ else clear_on_action = true end + options.on "--verbose", "More logging" do + verbose += 1 + end + options.invalid_option do puts options exit @@ -45,4 +50,13 @@ else end end -Guardian::Watcher.new ignore_executables, clear_on_action +watcher = Guardian::Watcher.new ignore_executables, verbose, clear_on_action +Signal::HUP.trap { watcher.shutdown } +Signal::QUIT.trap { watcher.shutdown } +Signal::TERM.trap { watcher.shutdown } +begin + watcher.run +ensure + watcher.close + # puts Dir.glob("/tmp/guardian*") +end diff --git a/src/guardian/command_run.cr b/src/guardian/command_run.cr new file mode 100644 index 0000000..4c0de4c --- /dev/null +++ b/src/guardian/command_run.cr @@ -0,0 +1,90 @@ +require "pty/process" + +module Guardian + class CommandRun + # Only used with %file% substitution + enum MissingFiles + # (default) Removes missing files from the command args + # Suitable for use with `crystal spec` + Remove + # Keep missing files as command arguments + Preserve + # Skip running the command when any files are missing + SkipCommand + # Remove all %file% args from command + # Suitable for use with `crystal spec` + StripArgs + end + + private FILE_NULL = File.new(File::NULL, "r") + + private getter? has_command_substitution : Bool + @process_output = Pty::Process.new + @temp_file : File = File.tempfile("guardian", ".out") + + @modified_files = Set(String).new + + def initialize(@raw_command : String, missing_files : String) + @missing_files = MissingFiles.parse(missing_files) + @has_command_substitution = /%file%/ === @raw_command + end + + def enqueue(file) : Nil + @modified_files << file + end + + # returns true(success), false(failure) + def run : Bool + cmd = command + return true unless command + + win_size = Pty.tty_win_size + win_size = {win_size[0 - 2], win_size[1]} if win_size + + @temp_file.truncate 0 + _, status = @process_output.run(command.not_nil!, input: FILE_NULL, shell: true, win_size: win_size) do |_, _, outputerr| + IO.copy outputerr, @temp_file + nil + end + + if status.success? + STDOUT.puts "#{"✔".colorize(:green)} #{"$".colorize(:dark_gray)} #{cmd.colorize(:cyan)}" + else + STDOUT.puts "#{"✖".colorize(:red)} #{"$".colorize(:dark_gray)} #{cmd.colorize(:cyan)}" + @temp_file.rewind + @temp_file.each_line do |line| + puts " #{line.gsub(/\n$/, "").colorize(:dark_gray)}" + end + end + + status.success? + ensure + @modified_files.clear + end + + def close + @process_output.signal? Signal::TERM + @temp_file.close + @temp_file.delete rescue nil + end + + private def command : String? + if has_command_substitution? + existing_files = @modified_files.select { |file| + File.exists?(file) + }.to_a + if existing_files.size == @modified_files.size || @missing_files.remove? + @raw_command.gsub(/%file%/, existing_files.join(" ")) + elsif @missing_files.strip_args? + @raw_command + elsif @missing_files.skip_command? + nil + else + raise "unsupported missing_files value #{@missing_files.inspect}" + end + else + @raw_command + end + end + end +end diff --git a/src/guardian/watcher.cr b/src/guardian/watcher.cr index 99df2e7..b9cf584 100644 --- a/src/guardian/watcher.cr +++ b/src/guardian/watcher.cr @@ -1,6 +1,7 @@ require "yaml" require "colorize" require "file" +require "./command_run" module Guardian class WatcherYML @@ -8,21 +9,28 @@ module Guardian property files : String property run : String - end + property missing_files = "remove" + @[YAML::Field(ignore: true)] + @command : CommandRun? + def command + @command ||= CommandRun.new(run, missing_files) + end + end class Watcher - setter files + @files = Set(String).new + @runners = Hash(String, Set(CommandRun)).new { |h, k| h[k] = Set(CommandRun).new } + @timestamps = {} of String => Time + @watchers = [] of WatcherYML - def initialize(@ignore_executables = true, @clear_on_action = false) - file = "./guardian.yml" + @task_queue = Set(CommandRun).new - @files = [] of String - @runners = {} of String => Array(String) - @timestamps = {} of String => Time - @watchers = [] of WatcherYML + @shutdown = false + def initialize(@ignore_executables = true, @verbose = 0, @clear_on_action = false) + file = "guardian.yml" if File.exists?(file) || File.exists?(file = file.insert(2, '.')) YAML.parse_all(File.read(file)).each do |yaml| @watchers << WatcherYML.from_yaml(yaml.to_yaml) @@ -31,7 +39,9 @@ module Guardian puts "#{"guardian.yml".colorize(:red)} does not exist!" exit 1 end + end + def run collect_files start_watching end @@ -49,7 +59,11 @@ module Guardian loop do watch_changes watch_newfiles + break if @shutdown + run_tasks + break if @shutdown sleep 1 + break if @shutdown end end @@ -58,9 +72,9 @@ module Guardian end def collect_files - @files = [] of String - @runners = {} of String => Array(String) - @timestamps = {} of String => Time + @files.clear + @runners.clear + @timestamps.clear @watchers.each do |watcher| Dir.glob(watcher.files) do |file| @@ -68,25 +82,40 @@ module Guardian @files << file @timestamps[file] = file_creation_date(file) - unless @runners.has_key? file - @runners[file] = [watcher.run] - else - @runners[file] << watcher.run - end + puts "runner #{file.inspect} -> #{watcher.run.inspect} #{@runners[file].size}" if @verbose > 0 + @runners[file] << watcher.command end end end end - def run_tasks(file) - @runners[file].each do |command| - command = command.gsub(/%file%/, file) - puts "#{"$".colorize(:dark_gray)} #{command.colorize(:cyan)}" - output = `#{command}` - output.lines.each do |line| - puts " #{line.gsub(/\n$/, "").colorize(:dark_gray)}" - end + # Multiple files may trigger the same command + # Queue and dedup them + private def queue_tasks(file) + commands = @runners[file] + commands.each do |command| + command.enqueue file + @task_queue << command + end + end + + private def run_tasks : Nil + return if @task_queue.empty? + + errors = 0 + @task_queue.each do |command| + success = command.run + errors += 1 unless success + break if @shutdown + end + + if errors == 0 + puts "◼".colorize(:dark_gray) + else + puts "#{"◼".colorize(:red)} errors=#{errors}" end + ensure + @task_queue.clear end def watch_changes @@ -112,18 +141,18 @@ module Guardian end end @timestamps[file] = check_time - run_tasks file + queue_tasks file end rescue puts "#{"-".colorize(:red)} #{file}" - run_tasks file + queue_tasks file collect_files end end end def watch_newfiles - files = [] of String + files = Set(String).new @watchers.each do |watcher| Dir.glob(watcher.files) do |file| if watch_file? file @@ -138,7 +167,7 @@ module Guardian new_files.each do |file| puts "#{"+".colorize(:green)} #{file}" collect_files - run_tasks file + queue_tasks file end end end @@ -147,5 +176,15 @@ module Guardian return unless @clear_on_action system "clear" end + + def shutdown + puts "shutting down" if @verbose > 0 + @shutdown = true + end + + def close + # Cleanup temp files + @watchers.each &.command.close + end end end