diff --git a/builder.go b/builder.go deleted file mode 100644 index c1ff548..0000000 --- a/builder.go +++ /dev/null @@ -1,279 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "fmt" - "log" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/Tonkpils/snag/vow" - "github.com/shiena/ansicolor" - fsn "gopkg.in/fsnotify.v1" -) - -var mtimes = map[string]time.Time{} -var clearBuffer = func() { - fmt.Print("\033c") -} - -type Bob struct { - w *fsn.Watcher - mtx sync.RWMutex - curVow *vow.Vow - done chan struct{} - watching map[string]struct{} - watchDir string - - depWarning string - buildCmds [][]string - runCmds [][]string - ignoredItems []string - - verbose bool -} - -func NewBuilder(c config) (*Bob, error) { - w, err := fsn.NewWatcher() - if err != nil { - return nil, err - } - - parseCmd := func(cmd string) (c []string) { - s := bufio.NewScanner(strings.NewReader(cmd)) - s.Split(splitFunc) - for s.Scan() { - c = append(c, s.Text()) - } - - // check for environment variables inside script - if strings.Contains(cmd, "$$") { - replaceEnv(c) - } - return c - } - - buildCmds := make([][]string, len(c.Build)) - for i, s := range c.Build { - buildCmds[i] = parseCmd(s) - } - - runCmds := make([][]string, len(c.Run)) - for i, s := range c.Run { - runCmds[i] = parseCmd(s) - } - - return &Bob{ - w: w, - done: make(chan struct{}), - watching: map[string]struct{}{}, - buildCmds: buildCmds, - runCmds: runCmds, - depWarning: c.DepWarnning, - ignoredItems: c.IgnoredItems, - verbose: c.Verbose, - }, nil -} - -func splitFunc(data []byte, atEOF bool) (advance int, token []byte, err error) { - advance, token, err = bufio.ScanWords(data, atEOF) - if err != nil { - return - } - - if len(token) == 0 { - return - } - - b := token[0] - if b != '"' && b != '\'' { - return - } - - if token[len(token)-1] == b { - return - } - - chunk := data[advance-1:] - i := bytes.IndexByte(chunk, b) - if i == -1 { - advance = len(data) - token = append(token, chunk...) - return - } - - advance += i - token = append(token, chunk[:i+1]...) - - return -} - -func replaceEnv(cmds []string) { - for i, c := range cmds { - if !strings.HasPrefix(c, "$$") { - continue - } - - cmds[i] = os.Getenv(strings.TrimPrefix(c, "$$")) - } -} - -func (b *Bob) Close() error { - close(b.done) - return b.w.Close() -} - -func (b *Bob) Watch(path string) error { - b.watchDir = path - // this can never return false since we will always - // have at least one file in the directory (.snag.yml) - _ = b.watch(path) - b.execute() - - for { - select { - case ev := <-b.w.Events: - var queueBuild bool - switch { - case isCreate(ev.Op): - queueBuild = b.watch(ev.Name) - case isDelete(ev.Op): - if _, ok := b.watching[ev.Name]; ok { - b.w.Remove(ev.Name) - delete(b.watching, ev.Name) - } - queueBuild = true - case isModify(ev.Op): - queueBuild = true - } - if queueBuild { - b.maybeQueue(ev.Name) - } - case err := <-b.w.Errors: - log.Println("error:", err) - case <-b.done: - return nil - } - } -} - -func (b *Bob) maybeQueue(path string) { - if b.isExcluded(path) { - return - } - - stat, err := os.Stat(path) - if err != nil { - // we couldn't find the file - // most likely a deletion - delete(mtimes, path) - b.execute() - return - } - - mtime := stat.ModTime() - lasttime := mtimes[path] - if !mtime.Equal(lasttime) { - // the file has been modified and the - // file system event wasn't bogus - mtimes[path] = mtime - b.execute() - } -} - -func (b *Bob) stopCurVow() { - b.mtx.Lock() - if b.curVow != nil { - b.curVow.Stop() - } - b.mtx.Unlock() -} - -func (b *Bob) execute() { - b.stopCurVow() - - clearBuffer() - b.mtx.Lock() - - if len(b.depWarning) > 0 { - fmt.Printf("Deprecation Warnings!\n%s", b.depWarning) - } - - // setup the first command - firstCmd := b.buildCmds[0] - b.curVow = vow.To(firstCmd[0], firstCmd[1:]...) - - // setup the remaining commands - for i := 1; i < len(b.buildCmds); i++ { - cmd := b.buildCmds[i] - b.curVow = b.curVow.Then(cmd[0], cmd[1:]...) - } - - // setup all parallel commands - for i := 0; i < len(b.runCmds); i++ { - cmd := b.runCmds[i] - b.curVow = b.curVow.ThenAsync(cmd[0], cmd[1:]...) - } - b.curVow.Verbose = b.verbose - go b.curVow.Exec(ansicolor.NewAnsiColorWriter(os.Stdout)) - - b.mtx.Unlock() -} - -func (b *Bob) watch(path string) bool { - var shouldBuild bool - if _, ok := b.watching[path]; ok { - return false - } - filepath.Walk(path, func(p string, fi os.FileInfo, err error) error { - if fi == nil { - return filepath.SkipDir - } - - if !fi.IsDir() { - shouldBuild = true - return nil - } - - if b.isExcluded(p) { - return filepath.SkipDir - } - - if err := b.w.Add(p); err != nil { - return err - } - b.watching[p] = struct{}{} - - return nil - }) - return shouldBuild -} - -func (b *Bob) isExcluded(path string) bool { - // get the relative path - path = strings.TrimPrefix(path, b.watchDir+string(filepath.Separator)) - - for _, p := range b.ignoredItems { - if globMatch(p, path) { - return true - } - } - return false -} - -func isCreate(op fsn.Op) bool { - return op&fsn.Create == fsn.Create -} - -func isDelete(op fsn.Op) bool { - return op&fsn.Remove == fsn.Remove -} - -func isModify(op fsn.Op) bool { - return op&fsn.Write == fsn.Write || - op&fsn.Rename == fsn.Rename -} diff --git a/builder/builder.go b/builder/builder.go new file mode 100644 index 0000000..ca99384 --- /dev/null +++ b/builder/builder.go @@ -0,0 +1,154 @@ +package builder + +import ( + "bufio" + "bytes" + "fmt" + "os" + "strings" + "sync" + + "github.com/Tonkpils/snag/exchange" + "github.com/shiena/ansicolor" +) + +var clearBuffer = func() { + fmt.Print("\033c") +} + +type Config struct { + Build []string + Run []string + DepWarning string + Verbose bool +} + +type Builder interface { + Build(interface{}) +} + +type CmdBuilder struct { + ex exchange.SendListener + mtx sync.RWMutex + depWarning string + buildCmds [][]string + runCmds [][]string + curVow *vow + + verbose bool +} + +func New(ex exchange.SendListener, c Config) Builder { + parseCmd := func(cmd string) (c []string) { + s := bufio.NewScanner(strings.NewReader(cmd)) + s.Split(splitFunc) + for s.Scan() { + c = append(c, s.Text()) + } + + // check for environment variables inside script + if strings.Contains(cmd, "$$") { + replaceEnv(c) + } + return c + } + + buildCmds := make([][]string, len(c.Build)) + for i, s := range c.Build { + buildCmds[i] = parseCmd(s) + } + + runCmds := make([][]string, len(c.Run)) + for i, s := range c.Run { + runCmds[i] = parseCmd(s) + } + + return &CmdBuilder{ + buildCmds: buildCmds, + runCmds: runCmds, + depWarning: c.DepWarning, + verbose: c.Verbose, + } +} + +func splitFunc(data []byte, atEOF bool) (advance int, token []byte, err error) { + advance, token, err = bufio.ScanWords(data, atEOF) + if err != nil { + return + } + + if len(token) == 0 { + return + } + + b := token[0] + if b != '"' && b != '\'' { + return + } + + if token[len(token)-1] == b { + return + } + + chunk := data[advance-1:] + i := bytes.IndexByte(chunk, b) + if i == -1 { + advance = len(data) + token = append(token, chunk...) + return + } + + advance += i + token = append(token, chunk[:i+1]...) + + return +} + +func replaceEnv(cmds []string) { + for i, c := range cmds { + if !strings.HasPrefix(c, "$$") { + continue + } + + cmds[i] = os.Getenv(strings.TrimPrefix(c, "$$")) + } +} + +func (b *CmdBuilder) stopCurVow() { + b.mtx.Lock() + if b.curVow != nil { + b.curVow.Stop() + } + b.mtx.Unlock() +} + +func (b *CmdBuilder) Build(_ interface{}) { + b.stopCurVow() + + clearBuffer() + b.mtx.Lock() + + if len(b.depWarning) > 0 { + fmt.Printf("Deprecation Warnings!\n%s", b.depWarning) + } + + // setup the first command + firstCmd := b.buildCmds[0] + b.curVow = VowTo(firstCmd[0], firstCmd[1:]...) + + // setup the remaining commands + for i := 1; i < len(b.buildCmds); i++ { + cmd := b.buildCmds[i] + b.curVow = b.curVow.Then(cmd[0], cmd[1:]...) + } + + // setup all parallel commands + for i := 0; i < len(b.runCmds); i++ { + cmd := b.runCmds[i] + b.curVow = b.curVow.ThenAsync(cmd[0], cmd[1:]...) + } + b.curVow.Verbose = b.verbose + go b.curVow.Exec(ansicolor.NewAnsiColorWriter(os.Stdout)) + + b.mtx.Unlock() +} diff --git a/builder_test.go b/builder/builder_test.go similarity index 58% rename from builder_test.go rename to builder/builder_test.go index d2820d0..e1e0c2b 100644 --- a/builder_test.go +++ b/builder/builder_test.go @@ -1,4 +1,4 @@ -package main +package builder import ( "os" @@ -9,28 +9,32 @@ import ( "github.com/stretchr/testify/require" ) +func TestInterfaceCompatibility(t *testing.T) { + var _ Builder = &CmdBuilder{} +} + func TestNewBuilder(t *testing.T) { testEnv := "foobar" os.Setenv("TEST_ENV", testEnv) - c := config{ - Build: []string{"echo Hello World", "echo $$TEST_ENV"}, - Run: []string{"echo async here"}, - IgnoredItems: []string{"foo", "bar"}, - Verbose: true, + c := Config{ + Build: []string{"echo Hello World", "echo $$TEST_ENV"}, + Run: []string{"echo async here"}, + Verbose: true, } - b, err := NewBuilder(c) - assert.NoError(t, err) - assert.NotNil(t, b) + b := New(nil, c) + require.NotNil(t, b) - require.Len(t, b.buildCmds, 2) - assert.Equal(t, c.Build[0], strings.Join(b.buildCmds[0], " ")) - assert.Equal(t, testEnv, b.buildCmds[1][1]) + cb, ok := b.(*CmdBuilder) + require.True(t, ok) - require.Len(t, b.runCmds, 1) - assert.Equal(t, c.Run[0], strings.Join(b.runCmds[0], " ")) + require.Len(t, cb.buildCmds, 2) + assert.Equal(t, c.Build[0], strings.Join(cb.buildCmds[0], " ")) + assert.Equal(t, testEnv, cb.buildCmds[1][1]) - assert.Equal(t, c.Verbose, b.verbose) - assert.Equal(t, c.IgnoredItems, b.ignoredItems) + require.Len(t, cb.runCmds, 1) + assert.Equal(t, c.Run[0], strings.Join(cb.runCmds[0], " ")) + + assert.Equal(t, c.Verbose, cb.verbose) } func TestNewBuilder_CmdWithQuotes(t *testing.T) { @@ -69,26 +73,18 @@ func TestNewBuilder_CmdWithQuotes(t *testing.T) { } for _, test := range tests { - c := config{ + c := Config{ Build: []string{test.Command}, Run: []string{test.Command}, } - b, err := NewBuilder(c) - require.NoError(t, err) - - assert.Equal(t, test.Chunks, b.buildCmds[0]) - assert.Equal(t, test.Chunks, b.runCmds[0]) - } -} + b := New(nil, c) + require.NotNil(t, b) -func TestClose(t *testing.T) { - b, err := NewBuilder(config{}) - require.NoError(t, err) + cb, ok := b.(*CmdBuilder) + require.True(t, ok) - err = b.Close() - assert.NoError(t, err) - - _, ok := <-b.done - assert.False(t, ok, "channel 'done' was not closed") + assert.Equal(t, test.Chunks, cb.buildCmds[0]) + assert.Equal(t, test.Chunks, cb.runCmds[0]) + } } diff --git a/builder_windows.go b/builder/builder_windows.go similarity index 100% rename from builder_windows.go rename to builder/builder_windows.go diff --git a/vow/color.go b/builder/color.go similarity index 92% rename from vow/color.go rename to builder/color.go index 3d76442..463443a 100644 --- a/vow/color.go +++ b/builder/color.go @@ -1,4 +1,4 @@ -package vow +package builder import "github.com/fatih/color" diff --git a/vow/promise.go b/builder/promise.go similarity index 99% rename from vow/promise.go rename to builder/promise.go index 362c7c5..7360f34 100644 --- a/vow/promise.go +++ b/builder/promise.go @@ -1,4 +1,4 @@ -package vow +package builder import ( "bytes" diff --git a/builder/to.go b/builder/to.go new file mode 100644 index 0000000..5bb84d5 --- /dev/null +++ b/builder/to.go @@ -0,0 +1,60 @@ +package builder + +import ( + "io" + "sync/atomic" +) + +// vow represents a batch of commands being prepared to run +type vow struct { + canceled *int32 + + cmds []*promise + Verbose bool +} + +// VowTo returns a new vow that is configured to execute command given. +func VowTo(name string, args ...string) *vow { + return &vow{ + cmds: []*promise{newPromise(name, args...)}, + canceled: new(int32), + } +} + +// Then adds the given command to the list of commands the Vow will execute +func (v *vow) Then(name string, args ...string) *vow { + v.cmds = append(v.cmds, newPromise(name, args...)) + return v +} + +func (v *vow) ThenAsync(name string, args ...string) *vow { + v.cmds = append(v.cmds, newAsyncPromise(name, args...)) + return v +} + +// Stop terminates the active command and stops the execution of any future commands +func (v *vow) Stop() { + atomic.StoreInt32(v.canceled, 1) + for i := 0; i < len(v.cmds); i++ { + v.cmds[i].kill() + } +} + +func (v *vow) isCanceled() bool { + return atomic.LoadInt32(v.canceled) == 1 +} + +// Exec runs all of the commands a Vow has with all output redirected +// to the given writer and returns a Result +func (v *vow) Exec(w io.Writer) bool { + for i := 0; i < len(v.cmds); i++ { + if v.isCanceled() { + return false + } + + if err := v.cmds[i].Run(w, v.Verbose); err != nil { + return false + } + } + return true +} diff --git a/vow/to_test.go b/builder/to_test.go similarity index 69% rename from vow/to_test.go rename to builder/to_test.go index 184acf8..18d24cf 100644 --- a/vow/to_test.go +++ b/builder/to_test.go @@ -1,4 +1,4 @@ -package vow +package builder import ( "bytes" @@ -20,7 +20,7 @@ var ( func TestTo(t *testing.T) { cmd := "foo" args := []string{"Hello", "Worlf!"} - vow := To(cmd, args...) + vow := VowTo(cmd, args...) require.NotNil(t, vow) assert.Len(t, vow.cmds, 1) @@ -29,28 +29,28 @@ func TestTo(t *testing.T) { } func TestThen(t *testing.T) { - var vow Vow + var v vow totalCmds := 10 for i := 0; i < totalCmds; i++ { - vow.Then("foo", "bar", "baz") + v.Then("foo", "bar", "baz") } - vow.Then("foo").Then("another") + v.Then("foo").Then("another") - assert.Len(t, vow.cmds, totalCmds+2) + assert.Len(t, v.cmds, totalCmds+2) } func TestThenAsync(t *testing.T) { - var vow Vow - vow.ThenAsync("foo", "bar", "baz") + var v vow + v.ThenAsync("foo", "bar", "baz") - require.Len(t, vow.cmds, 1) - assert.True(t, vow.cmds[0].async) + require.Len(t, v.cmds, 1) + assert.True(t, v.cmds[0].async) } func TestStop(t *testing.T) { - vow := To(echoScript) + v := VowTo(echoScript) for i := 0; i < 50; i++ { - vow = vow.Then(echoScript) + v = v.Then(echoScript) } result := make(chan bool) @@ -59,26 +59,26 @@ func TestStop(t *testing.T) { started := make(chan struct{}) go func() { close(started) - result <- vow.Exec(ioutil.Discard) + result <- v.Exec(ioutil.Discard) }() <-started - vow.Stop() - assert.True(t, vow.isCanceled()) + v.Stop() + assert.True(t, v.isCanceled()) r := <-result assert.False(t, r) } func TestStopAsync(t *testing.T) { - vow := To(echoScript) - vow.ThenAsync(echoScript) + v := VowTo(echoScript) + v.ThenAsync(echoScript) - require.True(t, vow.Exec(ioutil.Discard)) + require.True(t, v.Exec(ioutil.Discard)) <-time.After(10 * time.Millisecond) - vow.Stop() - for _, p := range vow.cmds { + v.Stop() + for _, p := range v.cmds { p.cmdMtx.Lock() p.cmd.Wait() assert.True(t, p.cmd.ProcessState.Exited()) @@ -89,9 +89,9 @@ func TestStopAsync(t *testing.T) { func TestExec(t *testing.T) { var testBuf bytes.Buffer - vow := To(echoScript) - vow.Then(echoScript) - result := vow.Exec(&testBuf) + v := VowTo(echoScript) + v.Then(echoScript) + result := v.Exec(&testBuf) e := fmt.Sprintf( "%s %s%s%s %s%s", @@ -109,10 +109,10 @@ func TestExec(t *testing.T) { func TestExecCmdNotFound(t *testing.T) { var testBuf bytes.Buffer - vow := To(echoScript) - vow.Then("asdfasdf", "asdas") - vow.Then("Shoud", "never", "happen") - result := vow.Exec(&testBuf) + v := VowTo(echoScript) + v.Then("asdfasdf", "asdas") + v.Then("Shoud", "never", "happen") + result := v.Exec(&testBuf) e := fmt.Sprintf( "%s %s%s%s asdfasdf asdas%sexec: \"asdfasdf\": executable file not found in ", @@ -130,10 +130,10 @@ func TestExecCmdNotFound(t *testing.T) { func TestExecCmdFailed(t *testing.T) { var testBuf bytes.Buffer - vow := To(echoScript) - vow.Then(failScript) - vow.Then("Shoud", "never", "happen") - result := vow.Exec(&testBuf) + v := VowTo(echoScript) + v.Then(failScript) + v.Then("Shoud", "never", "happen") + result := v.Exec(&testBuf) e := fmt.Sprintf( "%s %s%s%s %s%s", @@ -152,9 +152,9 @@ func TestExecCmdFailed(t *testing.T) { func TestVowVerbose(t *testing.T) { var testBuf bytes.Buffer - vow := To(echoScript) - vow.Verbose = true - result := vow.Exec(&testBuf) + v := VowTo(echoScript) + v.Verbose = true + result := v.Exec(&testBuf) e := fmt.Sprintf( "%s %s%shello\r\n", statusInProgress, diff --git a/vow/to_win_test.go b/builder/to_win_test.go similarity index 87% rename from vow/to_win_test.go rename to builder/to_win_test.go index 0f3cd98..7f27cc9 100644 --- a/vow/to_win_test.go +++ b/builder/to_win_test.go @@ -1,6 +1,6 @@ // +build windows -package vow +package builder func init() { echoScript = `..\fixtures\echo.bat` diff --git a/exchange/exchange.go b/exchange/exchange.go new file mode 100644 index 0000000..7ba759d --- /dev/null +++ b/exchange/exchange.go @@ -0,0 +1,35 @@ +package exchange + +import "sync" + +type queue []func(interface{}) + +type SendListener interface { + Listen(string, func(interface{})) + Send(string, interface{}) +} + +type Exchange struct { + mtx sync.RWMutex + queues map[string]queue +} + +func New() SendListener { + return &Exchange{ + queues: map[string]queue{}, + } +} + +func (ex *Exchange) Listen(event string, fn func(interface{})) { + ex.mtx.Lock() + ex.queues[event] = append(ex.queues[event], fn) + ex.mtx.Unlock() +} + +func (ex *Exchange) Send(event string, data interface{}) { + ex.mtx.RLock() + for _, fn := range ex.queues[event] { + go fn(data) + } + ex.mtx.RUnlock() +} diff --git a/main.go b/main.go index b9f1c76..15c73fa 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,10 @@ import ( "flag" "log" "os" + + "github.com/Tonkpils/snag/builder" + "github.com/Tonkpils/snag/exchange" + "github.com/Tonkpils/snag/watcher" ) const ( @@ -39,18 +43,28 @@ func main() { log.Fatal(err) } - b, err := NewBuilder(c) + ex := exchange.New() + + bc := builder.Config{ + Build: c.Build, + Run: c.Run, + DepWarning: c.DepWarnning, + Verbose: c.Verbose, + } + b := builder.New(ex, bc) + ex.Listen("rebuild", b.Build) + + w, err := watcher.New(ex, c.IgnoredItems) if err != nil { log.Fatal(err) } - defer b.Close() wd, err := os.Getwd() if err != nil { log.Fatal(err) } - b.Watch(wd) + w.Watch(wd) } func handleSubCommand(cmd string) error { diff --git a/vow/to.go b/vow/to.go deleted file mode 100644 index 4069a12..0000000 --- a/vow/to.go +++ /dev/null @@ -1,64 +0,0 @@ -/* -Package vow provides a promise like api for executing -a batch of external commands -*/ -package vow - -import ( - "io" - "sync/atomic" -) - -// Vow represents a batch of commands being prepared to run -type Vow struct { - canceled *int32 - - cmds []*promise - Verbose bool -} - -// To returns a new Vow that is configured to execute command given. -func To(name string, args ...string) *Vow { - return &Vow{ - cmds: []*promise{newPromise(name, args...)}, - canceled: new(int32), - } -} - -// Then adds the given command to the list of commands the Vow will execute -func (vow *Vow) Then(name string, args ...string) *Vow { - vow.cmds = append(vow.cmds, newPromise(name, args...)) - return vow -} - -func (vow *Vow) ThenAsync(name string, args ...string) *Vow { - vow.cmds = append(vow.cmds, newAsyncPromise(name, args...)) - return vow -} - -// Stop terminates the active command and stops the execution of any future commands -func (vow *Vow) Stop() { - atomic.StoreInt32(vow.canceled, 1) - for i := 0; i < len(vow.cmds); i++ { - vow.cmds[i].kill() - } -} - -func (vow *Vow) isCanceled() bool { - return atomic.LoadInt32(vow.canceled) == 1 -} - -// Exec runs all of the commands a Vow has with all output redirected -// to the given writer and returns a Result -func (vow *Vow) Exec(w io.Writer) bool { - for i := 0; i < len(vow.cmds); i++ { - if vow.isCanceled() { - return false - } - - if err := vow.cmds[i].Run(w, vow.Verbose); err != nil { - return false - } - } - return true -} diff --git a/gitglob.go b/watcher/gitglob.go similarity index 99% rename from gitglob.go rename to watcher/gitglob.go index 839ba99..579832f 100644 --- a/gitglob.go +++ b/watcher/gitglob.go @@ -1,4 +1,4 @@ -package main +package watcher import ( "log" diff --git a/gitglob_test.go b/watcher/gitglob_test.go similarity index 99% rename from gitglob_test.go rename to watcher/gitglob_test.go index 54c7105..5fbfc52 100644 --- a/gitglob_test.go +++ b/watcher/gitglob_test.go @@ -1,4 +1,4 @@ -package main +package watcher import ( "os" diff --git a/watcher/watcher.go b/watcher/watcher.go new file mode 100644 index 0000000..6222ba0 --- /dev/null +++ b/watcher/watcher.go @@ -0,0 +1,156 @@ +package watcher + +import ( + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Tonkpils/snag/exchange" + fsn "gopkg.in/fsnotify.v1" +) + +const rebuild = "rebuild" + +type Watcher interface { + Watch(string) error +} + +type FSWatcher struct { + ex exchange.SendListener + fsn *fsn.Watcher + done chan struct{} + mtimes map[string]time.Time + watching map[string]struct{} + watchDir string + ignoredItems []string +} + +func New(ex exchange.SendListener, ignoredItems []string) (Watcher, error) { + f, err := fsn.NewWatcher() + if err != nil { + return nil, err + } + + return &FSWatcher{ + ex: ex, + fsn: f, + ignoredItems: ignoredItems, + mtimes: map[string]time.Time{}, + done: make(chan struct{}), + watching: map[string]struct{}{}, + }, nil +} + +func (w *FSWatcher) Watch(path string) error { + w.watchDir = path + // this can never return false since we will always + // have at least one file in the directory (.snag.yml) + _ = w.watch(path) + w.ex.Send(rebuild, nil) + + for { + select { + case ev := <-w.fsn.Events: + var queueBuild bool + switch { + case isCreate(ev.Op): + queueBuild = w.watch(ev.Name) + case isDelete(ev.Op): + if _, ok := w.watching[ev.Name]; ok { + w.fsn.Remove(ev.Name) + delete(w.watching, ev.Name) + } + queueBuild = true + case isModify(ev.Op): + queueBuild = true + } + if queueBuild { + w.maybeQueue(ev.Name) + } + case err := <-w.fsn.Errors: + log.Println("error:", err) + case <-w.done: + return nil + } + } +} + +func (w *FSWatcher) maybeQueue(path string) { + if w.isExcluded(path) { + return + } + + stat, err := os.Stat(path) + if err != nil { + // we couldn't find the file + // most likely a deletion + delete(w.mtimes, path) + w.ex.Send(rebuild, nil) + return + } + + mtime := stat.ModTime() + lasttime := w.mtimes[path] + if !mtime.Equal(lasttime) { + // the file has been modified and the + // file system event wasn't bogus + w.mtimes[path] = mtime + w.ex.Send(rebuild, nil) + } +} + +func (w *FSWatcher) watch(path string) bool { + var shouldBuild bool + if _, ok := w.watching[path]; ok { + return false + } + filepath.Walk(path, func(p string, fi os.FileInfo, err error) error { + if fi == nil { + return filepath.SkipDir + } + + if !fi.IsDir() { + shouldBuild = true + return nil + } + + if w.isExcluded(p) { + return filepath.SkipDir + } + + if err := w.fsn.Add(p); err != nil { + return err + } + w.watching[p] = struct{}{} + + return nil + }) + return shouldBuild +} + +func (w *FSWatcher) isExcluded(path string) bool { + // get the relative path + path = strings.TrimPrefix(path, w.watchDir+string(filepath.Separator)) + + for _, p := range w.ignoredItems { + if globMatch(p, path) { + return true + } + } + return false +} + +func isCreate(op fsn.Op) bool { + return op&fsn.Create == fsn.Create +} + +func isDelete(op fsn.Op) bool { + return op&fsn.Remove == fsn.Remove +} + +func isModify(op fsn.Op) bool { + return op&fsn.Write == fsn.Write || + op&fsn.Rename == fsn.Rename +}