├── .crystal-version ├── .travis.yml ├── spec ├── watchbird_spec.cr ├── spec_helper.cr └── watchbird │ ├── watcher_spec.cr │ ├── pattern_spec.cr │ └── notifier_spec.cr ├── src ├── watchbird │ ├── version.cr │ ├── notifier.cr │ ├── event.cr │ ├── pattern.cr │ ├── watcher.cr │ └── notifier │ │ └── inotify.cr ├── watchbird.cr └── dsl.cr ├── shard.yml ├── watchbird.cr ├── .gitignore ├── LICENSE └── README.md /.crystal-version: -------------------------------------------------------------------------------- 1 | 0.19.0 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /spec/watchbird_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | -------------------------------------------------------------------------------- /src/watchbird/version.cr: -------------------------------------------------------------------------------- 1 | module WatchBird 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: watchbird 2 | version: 0.1.0 3 | 4 | authors: 5 | - agatan 6 | 7 | license: MIT 8 | -------------------------------------------------------------------------------- /src/watchbird/notifier.cr: -------------------------------------------------------------------------------- 1 | require "./event" 2 | 3 | {% if flag?(:linux) %} 4 | require "./notifier/inotify" 5 | {% else %} 6 | {% raise "not implemented yet in darwin" %} 7 | {% end %} 8 | -------------------------------------------------------------------------------- /watchbird.cr: -------------------------------------------------------------------------------- 1 | require "./src/dsl" 2 | 3 | watch "./src/**/*.cr" do |_| 4 | puts `crystal spec` 5 | end 6 | 7 | watch "./spec/**/*.cr" do |_| 8 | puts `crystal spec` 9 | end 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /libs/ 3 | /.crystal/ 4 | /.shards/ 5 | 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in application that uses them 9 | /shard.lock 10 | 11 | -------------------------------------------------------------------------------- /src/watchbird.cr: -------------------------------------------------------------------------------- 1 | require "./watchbird/watcher" 2 | require "./watchbird/event" 3 | require "./watchbird/notifier" 4 | require "./watchbird/version" 5 | 6 | module WatchBird 7 | # TODO Put your code here 8 | end 9 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/watchbird" 3 | 4 | def with_tmpdir 5 | dirname = `mktemp -d`.chomp 6 | begin 7 | yield dirname 8 | ensure 9 | `rm -r #{dirname}` 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/dsl.cr: -------------------------------------------------------------------------------- 1 | require "./watchbird/watcher" 2 | 3 | module WatchBird::DSL 4 | def watch(path, &blk : Event -> Void) 5 | Watcher::INSTANCE.register(path, blk) 6 | end 7 | end 8 | 9 | include WatchBird::DSL 10 | 11 | at_exit do 12 | WatchBird::Watcher::INSTANCE.run 13 | end 14 | -------------------------------------------------------------------------------- /src/watchbird/event.cr: -------------------------------------------------------------------------------- 1 | module WatchBird 2 | @[Flags] 3 | enum EventType 4 | Modify 5 | Delete 6 | Create 7 | end 8 | 9 | class Event 10 | getter name : String 11 | getter status : EventType 12 | 13 | @is_dir : Bool 14 | 15 | def initialize(@status, @name, @is_dir); end 16 | 17 | def dir? 18 | @is_dir 19 | end 20 | 21 | def ==(other) 22 | name == other.name && status == other.status && dir? == other.dir? 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/watchbird/watcher_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe WatchBird do 4 | describe "Watcher" do 5 | context "with relative path pattern" do 6 | it "runs block when pattern matched file changes" do 7 | with_tmpdir do |dirname| 8 | Dir.cd(dirname) 9 | `touch #{dirname}/test` 10 | chan = Channel(WatchBird::Event).new 11 | WatchBird::Watcher.watch do |watcher| 12 | watcher.register "test" do |ev| 13 | chan.send(ev) 14 | end 15 | spawn { watcher.run } 16 | File.write("#{dirname}#{File::SEPARATOR}test", "test watcher") 17 | event = chan.receive 18 | event.name.should eq "#{dirname}#{File::SEPARATOR}test" 19 | event.status.should eq WatchBird::EventType::Modify 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /src/watchbird/pattern.cr: -------------------------------------------------------------------------------- 1 | module WatchBird 2 | class Pattern 3 | getter fixed : String 4 | getter rest : Array(String) 5 | getter pattern : String 6 | 7 | def initialize(pat : String) 8 | abs_path = File.expand_path(pat) 9 | @pattern = abs_path 10 | dirs = abs_path.split(File::SEPARATOR) 11 | fixed_path = dirs[0] 12 | if dirs.size > 0 13 | dirs.shift 14 | while dirs.size > 0 && Dir.exists?("#{fixed_path}#{File::SEPARATOR}#{dirs[0]}") 15 | fixed_path += File::SEPARATOR + dirs[0] 16 | dirs.shift 17 | end 18 | end 19 | @fixed = fixed_path 20 | if pat[-1] == File::SEPARATOR || @fixed.size < abs_path.size 21 | @fixed += File::SEPARATOR 22 | end 23 | if @fixed.size < abs_path.size 24 | @rest = abs_path[@fixed.size..-1].split(File::SEPARATOR) 25 | else 26 | @rest = [] of String 27 | end 28 | end 29 | 30 | def match?(path) 31 | Dir.glob(pattern).includes?(path) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 agatan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WatchBird 2 | 3 | WatchBird monitors directories and files, runs tasks automatically. 4 | 5 | This library is strongly inspired by [guard](https://github.com/guard/guard) and [crake](https://github.com/MakeNowJust/crake). 6 | Thanks ;) 7 | 8 | ***notice:*** 9 | WatchBird is under development and has few features yet. 10 | 11 | # Features 12 | 13 | WatchBird is just a library (inspired by [crake](https://github.com/MakeNowJust/crake)). 14 | You can use this library in your application. 15 | it also provides DSL module. 16 | You can use this library with DSL syntax like *guard*. 17 | 18 | ## Installation 19 | 20 | Add this to your application's `shard.yml`: 21 | 22 | ```yaml 23 | dependencies: 24 | watchbird: 25 | github: agatan/watchbird 26 | ``` 27 | 28 | # Usage 29 | See this code: 30 | 31 | ```crystal 32 | require "watchbird/dsl" 33 | 34 | watch "./**/*.cr" do |ev| 35 | # ev.status is WatchBird::EventType. 36 | # can be Modify, Create and Delete. 37 | puts ev.status 38 | # ev.name is changed target's fullpath. It is an absolute path. 39 | puts ev.name 40 | # ev.dir? returns true when changed target is a directory 41 | puts ev.dir? 42 | end 43 | ``` 44 | 45 | and execute `crystal watchbird.cr`. 46 | 47 | This repository contains sample `watchbird.cr` file to run spec automatically. 48 | 49 | ## Contributing 50 | 51 | 1. Fork it ( https://github.com/agatan/watchbird/fork ) 52 | 2. Create your feature branch (git checkout -b my-new-feature) 53 | 3. Commit your changes (git commit -am 'Add some feature') 54 | 4. Push to the branch (git push origin my-new-feature) 55 | 5. Create a new Pull Request 56 | 57 | ## Contributors 58 | 59 | - [agatan](https://github.com/agatan) Naomichi Agata - creator, maintainer 60 | -------------------------------------------------------------------------------- /src/watchbird/watcher.cr: -------------------------------------------------------------------------------- 1 | require "./event" 2 | require "./notifier" 3 | require "./pattern" 4 | 5 | module WatchBird 6 | class Watcher 7 | INSTANCE = new 8 | 9 | @notifier : Notifier 10 | 11 | def initialize 12 | @notifier = Notifier.new 13 | @patterns = [] of Pattern 14 | @callbacks = [] of Event -> 15 | end 16 | 17 | def self.watch(&blk) 18 | ins = new 19 | begin 20 | yield ins 21 | ensure 22 | ins.close 23 | end 24 | end 25 | 26 | def register(pattern : String, &blk : Event ->) 27 | register(Pattern.new(pattern), blk) 28 | end 29 | 30 | def register(pattern : String, cb) 31 | register(Pattern.new(pattern), cb) 32 | end 33 | 34 | def register(pattern : Pattern, cb) 35 | @patterns << pattern 36 | @callbacks << cb 37 | register_to_notifeir(pattern) 38 | end 39 | 40 | def run 41 | loop do 42 | event = @notifier.wait 43 | unless event 44 | return 45 | end 46 | @patterns.each_with_index do |pat, i| 47 | if pat.match?(event.not_nil!.name) 48 | @callbacks[i].call(event.not_nil!) 49 | end 50 | end 51 | end 52 | end 53 | 54 | def close 55 | @notifier.close 56 | end 57 | 58 | private def register_to_notifeir(pattern) 59 | @notifier.register(pattern.fixed) 60 | Dir.foreach(pattern.fixed) do |name| 61 | unless name == "." || name == ".." 62 | fullname = pattern.fixed 63 | if fullname[-1] != File::SEPARATOR 64 | fullname += File::SEPARATOR 65 | end 66 | fullname += name 67 | if Dir.exists?(fullname) 68 | @notifier.register(fullname) 69 | end 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/watchbird/pattern_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/watchbird/pattern" 3 | 4 | describe WatchBird::Pattern do 5 | it "should separate fixed path and glob pattern" do 6 | pat = WatchBird::Pattern.new("/tmp/*") 7 | pat.fixed.should eq "/tmp/" 8 | pat.pattern.should eq "/tmp/*" 9 | end 10 | 11 | it "should not separate non-glob pattern" do 12 | pat = WatchBird::Pattern.new("/tmp/") 13 | pat.fixed.should eq "/tmp/" 14 | end 15 | 16 | it "should expand pattern" do 17 | Dir.cd "/tmp" 18 | pat = WatchBird::Pattern.new("file") 19 | pat.fixed.should eq "/tmp/" 20 | end 21 | 22 | it "should expand pattern and separate glob" do 23 | Dir.cd "/tmp" 24 | pat = WatchBird::Pattern.new("src/*.cr") 25 | pat.fixed.should eq "/tmp/" 26 | pat.pattern.should eq "/tmp/src/*.cr" 27 | end 28 | 29 | it "should regard non-exist directory as glob pattern" do 30 | pat = WatchBird::Pattern.new("/tmp/hogehogefugafuga/*/test") 31 | unless Dir.exists?("/tmp/hogehogefugafuga/") 32 | pat.fixed.should eq "/tmp/" 33 | pat.pattern.should eq "/tmp/hogehogefugafuga/*/test" 34 | end 35 | end 36 | 37 | describe "match?" do 38 | it "should return true if path matches pattern" do 39 | with_tmpdir do |dirname| 40 | pat = WatchBird::Pattern.new("#{dirname}#{File::SEPARATOR}**/*.txt") 41 | Dir.mkdir("#{dirname}#{File::SEPARATOR}test") 42 | filename = "#{dirname}#{File::SEPARATOR}test#{File::SEPARATOR}test.txt" 43 | File.write(filename, "test") 44 | pat.match?(filename).should eq true 45 | end 46 | end 47 | 48 | it "should return false if path doesn't match pattern" do 49 | pat = WatchBird::Pattern.new("/tmp/test/**/*.txt") 50 | pat.match?("/hoge").should eq false 51 | pat.match?("/tmp/false").should eq false 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/watchbird/notifier_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe WatchBird do 4 | describe "notifier" do 5 | it "should notify file create event" do 6 | with_tmpdir do |dirname| 7 | ino = WatchBird::Notifier.new 8 | ino.register(dirname) 9 | chan = Channel(WatchBird::Event | Nil).new 10 | spawn do 11 | chan.send(ino.wait) 12 | end 13 | `touch #{dirname}/create-file` 14 | 15 | event = chan.receive 16 | event.should eq WatchBird::Event.new( 17 | WatchBird::EventType::Create, 18 | "#{dirname}/create-file", 19 | false) 20 | end 21 | end 22 | 23 | it "should notify file modify event" do 24 | with_tmpdir do |dirname| 25 | `touch #{dirname}/modify-file` 26 | ino = WatchBird::Notifier.new 27 | ino.register(dirname) 28 | chan = Channel(WatchBird::Event | Nil).new 29 | spawn do 30 | chan.send(ino.wait) 31 | end 32 | `echo test > #{dirname}/modify-file` 33 | 34 | event = chan.receive 35 | event.should eq WatchBird::Event.new( 36 | WatchBird::EventType::Modify, 37 | "#{dirname}/modify-file", 38 | false) 39 | end 40 | end 41 | 42 | it "should notify file delete event" do 43 | with_tmpdir do |dirname| 44 | `touch #{dirname}/delete-file` 45 | ino = WatchBird::Notifier.new 46 | ino.register(dirname) 47 | chan = Channel(WatchBird::Event | Nil).new 48 | spawn do 49 | chan.send(ino.wait) 50 | end 51 | `rm #{dirname}/delete-file` 52 | 53 | event = chan.receive 54 | event.should eq WatchBird::Event.new( 55 | WatchBird::EventType::Delete, 56 | "#{dirname}/delete-file", 57 | false) 58 | end 59 | end 60 | 61 | it "should notify directory create event" do 62 | with_tmpdir do |dirname| 63 | ino = WatchBird::Notifier.new 64 | ino.register(dirname) 65 | chan = Channel(WatchBird::Event | Nil).new 66 | spawn do 67 | chan.send(ino.wait) 68 | end 69 | `mkdir #{dirname}/create-dir` 70 | 71 | event = chan.receive 72 | event.should eq WatchBird::Event.new( 73 | WatchBird::EventType::Create, 74 | "#{dirname}/create-dir", 75 | true) 76 | end 77 | end 78 | 79 | it "should notify file modify directory" do 80 | with_tmpdir do |dirname| 81 | `touch #{dirname}/modify-file` 82 | ino = WatchBird::Notifier.new 83 | ino.register(dirname) 84 | chan = Channel(WatchBird::Event | Nil).new 85 | spawn do 86 | chan.send(ino.wait) 87 | end 88 | `echo test > #{dirname}/modify-file` 89 | 90 | event = chan.receive 91 | event.should eq WatchBird::Event.new( 92 | WatchBird::EventType::Modify, 93 | "#{dirname}/modify-file", 94 | false) 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /src/watchbird/notifier/inotify.cr: -------------------------------------------------------------------------------- 1 | require "./event" 2 | 3 | lib LibInotify 4 | struct Event 5 | wd : LibC::Int 6 | mask : UInt32 7 | cookie : UInt32 8 | len : UInt32 9 | end 10 | 11 | fun inotify_init : LibC::Int 12 | fun inotify_add_watch(fd : LibC::Int, name : LibC::Char*, mask : UInt32) : LibC::Int 13 | fun inotify_rm_watch(fd : LibC::Int, close : LibC::Int) : LibC::Int 14 | 15 | # Supported events suitable for MASK parameter of INOTIFY_ADD_WATCH. 16 | IN_ACCESS = 0x00000001 # File was accessed. 17 | IN_MODIFY = 0x00000002 # File was modified. 18 | IN_ATTRIB = 0x00000004 # Metadata changed. 19 | IN_CLOSE_WRITE = 0x00000008 # Writtable file was closed. 20 | IN_CLOSE_NOWRITE = 0x00000010 # Unwrittable file closed. 21 | IN_CLOSE = (IN_CLOSE_WRITE | IN_CLOSE_NOWRITE) # Close. 22 | IN_OPEN = 0x00000020 # File was opened. 23 | IN_MOVED_FROM = 0x00000040 # File was moved from X. 24 | IN_MOVED_TO = 0x00000080 # File was moved to Y. 25 | IN_MOVE = (IN_MOVED_FROM | IN_MOVED_TO) # Moves. 26 | IN_CREATE = 0x00000100 # Subfile was created. 27 | IN_DELETE = 0x00000200 # Subfile was deleted. 28 | IN_DELETE_SELF = 0x00000400 # Self was deleted. 29 | IN_MOVE_SELF = 0x00000800 # Self was moved. 30 | 31 | # Events sent by the kernel. 32 | IN_UNMOUNT = 0x00002000 # Backing fs was unmounted. 33 | IN_Q_OVERFLOW = 0x00004000 # Event queued overflowed. 34 | IN_IGNORED = 0x00008000 # File was ignored. 35 | 36 | # Special flags. 37 | IN_ONLYDIR = 0x01000000 # Only watch the path if it is a directory. 38 | IN_DONT_FOLLOW = 0x02000000 # Do not follow a sym link. 39 | IN_EXCL_UNLINK = 0x04000000 # Exclude events on unlinked objects. 40 | IN_MASK_ADD = 0x20000000 # Add to the mask of an already existing watch. 41 | IN_ISDIR = 0x40000000 # Event occurred against dir. 42 | IN_ONESHOT = 0x80000000 # Only send event once. 43 | 44 | # All events which a program can wait on. 45 | IN_ALL_EVENTS = (IN_ACCESS | IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE | IN_CLOSE_NOWRITE | IN_OPEN | IN_MOVED_FROM | IN_MOVED_TO | IN_CREATE | IN_DELETE | IN_DELETE_SELF | IN_MOVE_SELF) 46 | end 47 | 48 | module WatchBird 49 | class Notifier 50 | def initialize 51 | @fd = LibInotify.inotify_init 52 | if @fd < 0 53 | raise Errno.new("inotify_init") 54 | end 55 | @io = IO::FileDescriptor.new(@fd) 56 | @watch = {} of LibC::Int => String 57 | @watch_rev = {} of String => LibC::Int 58 | end 59 | 60 | def register(path) 61 | wd = LibInotify.inotify_add_watch( 62 | @fd, 63 | path, 64 | LibInotify::IN_MODIFY | 65 | LibInotify::IN_CREATE | 66 | LibInotify::IN_DELETE | 67 | LibInotify::IN_DELETE_SELF | 68 | LibInotify::IN_MOVE | 69 | LibInotify::IN_MOVE_SELF) 70 | if wd < 0 71 | raise Errno.new("inotify_add_watch") 72 | end 73 | @watch[wd] = path 74 | @watch_rev[path] = wd 75 | end 76 | 77 | def unregister(path) 78 | if @watch_rev[path]? 79 | wd = @watch_rev[path] 80 | if LibInotify.inotify_rm_watch(@fd, wd) < 0 81 | raise Errno.new("inotify_rm_watch") 82 | end 83 | @watch_rev.delete(path) 84 | @watch.delete(wd) 85 | end 86 | end 87 | 88 | def wait 89 | buf = uninitialized UInt8[sizeof(LibInotify::Event)] 90 | 91 | begin 92 | size = @io.read(buf.to_slice) 93 | raise "inotify read() returned 0!" if size == 0 94 | rescue e : IO::Error 95 | if e.message == "closed stream" 96 | return 97 | else 98 | raise e 99 | end 100 | end 101 | 102 | inotify_event = buf.to_unsafe.as(LibInotify::Event*).value 103 | 104 | string_buf = uninitialized UInt8[512] 105 | name_slice = string_buf.to_slice[0, inotify_event.len] 106 | raise "inotify read() returned 0!" if @io.read(name_slice) == 0 107 | 108 | # Remove null bytes from end 109 | last_index = 0 110 | (name_slice.size - 1).downto(0) do |i| 111 | if name_slice[i] == 0 112 | last_index = i 113 | else 114 | break 115 | end 116 | end 117 | 118 | inotify_name = String.new(name_slice[0, last_index]) 119 | 120 | name = @watch[inotify_event.wd] 121 | if name[-1] == File::SEPARATOR 122 | name += inotify_name 123 | else 124 | name += File::SEPARATOR + inotify_name 125 | end 126 | is_dir = inotify_event.mask & LibInotify::IN_ISDIR != 0 127 | Event.new(convert_event(inotify_event.mask), name, is_dir) 128 | end 129 | 130 | def close 131 | @io.close 132 | end 133 | 134 | private def convert_event(flag) 135 | event_type = EventType::None 136 | if flag & LibInotify::IN_MODIFY != 0 137 | event_type |= EventType::Modify 138 | end 139 | if flag & LibInotify::IN_CREATE != 0 140 | event_type |= EventType::Create 141 | end 142 | {LibInotify::IN_DELETE, LibInotify::IN_DELETE_SELF, 143 | LibInotify::IN_MOVE_SELF, LibInotify::IN_MOVE}.each do |del| 144 | if flag & del != 0 145 | event_type |= EventType::Delete 146 | end 147 | end 148 | event_type 149 | end 150 | end 151 | end 152 | --------------------------------------------------------------------------------