Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/libs/
/.crystal/
/.shards/
guardian
/bin/guardian


# Libraries don't need dependency lock
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions shard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 15 additions & 1 deletion src/guardian.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -38,11 +39,24 @@ else
clear_on_action = true
end

options.on "--verbose", "More logging" do
verbose += 1
end

options.invalid_option do
puts options
exit
end
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
90 changes: 90 additions & 0 deletions src/guardian/command_run.cr
Original file line number Diff line number Diff line change
@@ -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
95 changes: 67 additions & 28 deletions src/guardian/watcher.cr
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
require "yaml"
require "colorize"
require "file"
require "./command_run"

module Guardian
class WatcherYML
include YAML::Serializable

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)
Expand All @@ -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
Expand All @@ -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

Expand All @@ -58,35 +72,50 @@ 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|
if watch_file? file
@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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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