diff --git a/cmd/mars-gen/main.go b/cmd/mars-gen/main.go index 13a7cc1..872d12a 100644 --- a/cmd/mars-gen/main.go +++ b/cmd/mars-gen/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "go/format" "os" @@ -9,8 +10,6 @@ import ( "time" "github.com/codegangsta/cli" - - "github.com/roblillack/mars" ) func fatalf(layout string, args ...interface{}) { @@ -109,7 +108,12 @@ func reverseRoutes(ctx *cli.Context) { } func generateSources(tpl, filename string, templateArgs map[string]interface{}) { - sourceCode := mars.ExecuteTemplate(template.Must(template.New("").Parse(tpl)), templateArgs) + var b bytes.Buffer + + tmpl := template.Must(template.New("").Parse(tpl)) + if err := tmpl.Execute(&b, templateArgs); err != nil { + fatalf("Unable to create source file: %v", err) + } if err := os.MkdirAll(path.Dir(filename), 0755); err != nil { fatalf("Unable to create dir: %v", err) @@ -122,7 +126,7 @@ func generateSources(tpl, filename string, templateArgs map[string]interface{}) } defer file.Close() - formatted, err := format.Source([]byte(sourceCode)) + formatted, err := format.Source(b.Bytes()) if err != nil { fatalf("Failed to format file: %v", err) } diff --git a/errors.go b/errors.go index 1e59a83..b740f35 100644 --- a/errors.go +++ b/errors.go @@ -17,6 +17,8 @@ type Error struct { Link string // A configurable link to wrap the error source in } +var _ error = &Error{} + // An object to hold the per-source-line details. type sourceLine struct { Source string @@ -27,6 +29,10 @@ type sourceLine struct { // Construct a plaintext version of the error, taking account that fields are optionally set. // Returns e.g. Compilation Error (in views/header.html:51): expected right delim in end; got "}" func (e *Error) Error() string { + if e == nil { + return "" + } + loc := "" if e.Path != "" { line := "" diff --git a/watcher.go b/internal/watcher/watcher.go similarity index 51% rename from watcher.go rename to internal/watcher/watcher.go index 806349d..c15d3b3 100644 --- a/watcher.go +++ b/internal/watcher/watcher.go @@ -1,6 +1,7 @@ -package mars +package watcher import ( + "fmt" "os" "path" "path/filepath" @@ -14,39 +15,31 @@ import ( type Listener interface { // Refresh is invoked by the watcher on relevant filesystem events. // If the listener returns an error, it is served to the user on the current request. - Refresh() *Error -} - -// DiscerningListener allows the receiver to selectively watch files. -type DiscerningListener interface { - Listener - WatchDir(info os.FileInfo) bool - WatchFile(basename string) bool + Refresh() error } // Watcher allows listeners to register to be notified of changes under a given // directory. type Watcher struct { // Parallel arrays of watcher/listener pairs. - watchers []*fsnotify.Watcher - listeners []Listener - forceRefresh bool - lastError int - notifyMutex sync.Mutex + watchers []*fsnotify.Watcher + listeners []Listener + lastError int + notifyMutex sync.Mutex } -func NewWatcher() *Watcher { +func New() *Watcher { return &Watcher{ - forceRefresh: true, - lastError: -1, + // forceRefresh: true, + lastError: -1, } } // Listen registers for events within the given root directories (recursively). -func (w *Watcher) Listen(listener Listener, roots ...string) { +func (w *Watcher) Listen(listener Listener, roots ...string) error { watcher, err := fsnotify.NewWatcher() if err != nil { - ERROR.Fatal(err) + return err } // Replace the unbuffered Event channel with a buffered one. @@ -70,15 +63,14 @@ func (w *Watcher) Listen(listener Listener, roots ...string) { fi, err := os.Stat(p) if err != nil { - ERROR.Println("Failed to stat watched path", p, ":", err) - continue + return fmt.Errorf("Failed to stat watched path %s: %w", p, err) } // If it is a file, watch that specific file. if !fi.IsDir() { err = watcher.Add(p) if err != nil { - ERROR.Println("Failed to watch", p, ":", err) + return fmt.Errorf("Failed to watch %s: %w", p, err) } continue } @@ -87,93 +79,67 @@ func (w *Watcher) Listen(listener Listener, roots ...string) { watcherWalker = func(path string, info os.FileInfo, err error) error { if err != nil { - ERROR.Println("Error walking path:", err) - return nil + return err } // is it a symlinked template? link, err := os.Lstat(path) if err == nil && link.Mode()&os.ModeSymlink == os.ModeSymlink { - TRACE.Println("Watcher symlink: ", path) // lookup the actual target & check for goodness targetPath, err := filepath.EvalSymlinks(path) if err != nil { - ERROR.Println("Failed to read symlink", err) - return err + return fmt.Errorf("failed to read symlink %s: %w", path, err) } targetInfo, err := os.Stat(targetPath) if err != nil { - ERROR.Println("Failed to stat symlink target", err) - return err + return fmt.Errorf("failed to stat symlink target %s of %s: %w", targetPath, path, err) } // set the template path to the target of the symlink path = targetPath info = targetInfo - filepath.Walk(path, watcherWalker) + if err := filepath.Walk(path, watcherWalker); err != nil { + return err + } } if info.IsDir() { - if dl, ok := listener.(DiscerningListener); ok { - if !dl.WatchDir(info) { - return filepath.SkipDir - } - } - - err = watcher.Add(path) - if err != nil { - ERROR.Println("Failed to watch", path, ":", err) + if err := watcher.Add(path); err != nil { + return err } } return nil } // Else, walk the directory tree. - filepath.Walk(p, watcherWalker) - } - - if w.eagerRebuildEnabled() { - // Create goroutine to notify file changes in real time - go w.NotifyWhenUpdated(listener, watcher) + if err := filepath.Walk(p, watcherWalker); err != nil { + return fmt.Errorf("error walking path %s: %w", p, err) + } } w.watchers = append(w.watchers, watcher) w.listeners = append(w.listeners, listener) -} -// NotifyWhenUpdated notifies the watcher when a file event is received. -func (w *Watcher) NotifyWhenUpdated(listener Listener, watcher *fsnotify.Watcher) { - for { - select { - case ev := <-watcher.Events: - if w.rebuildRequired(ev, listener) { - // Serialize listener.Refresh() calls. - w.notifyMutex.Lock() - listener.Refresh() - w.notifyMutex.Unlock() - } - case <-watcher.Errors: - continue - } - } + return nil } // Notify causes the watcher to forward any change events to listeners. // It returns the first (if any) error returned. -func (w *Watcher) Notify() *Error { +func (w *Watcher) Notify() error { // Serialize Notify() calls. w.notifyMutex.Lock() defer w.notifyMutex.Unlock() - for i, watcher := range w.watchers { - listener := w.listeners[i] + for idx, watcher := range w.watchers { + listener := w.listeners[idx] // Pull all pending events / errors from the watcher. refresh := false for { select { case ev := <-watcher.Events: - if w.rebuildRequired(ev, listener) { + // Ignore changes to dotfiles. + if !strings.HasPrefix(path.Base(ev.Name), ".") { refresh = true } continue @@ -185,50 +151,15 @@ func (w *Watcher) Notify() *Error { break } - if w.forceRefresh || refresh || w.lastError == i { + if refresh || w.lastError == idx { err := listener.Refresh() if err != nil { - w.lastError = i + w.lastError = idx return err } } } - w.forceRefresh = false w.lastError = -1 return nil } - -// If watcher.mode is set to eager, the application is rebuilt immediately -// when a source file is changed. -// This feature is available only in dev mode. -func (w *Watcher) eagerRebuildEnabled() bool { - return Config.BoolDefault("mode.dev", true) && - Config.BoolDefault("watch", true) && - Config.StringDefault("watcher.mode", "normal") == "eager" -} - -func (w *Watcher) rebuildRequired(ev fsnotify.Event, listener Listener) bool { - // Ignore changes to dotfiles. - if strings.HasPrefix(path.Base(ev.Name), ".") { - return false - } - - if dl, ok := listener.(DiscerningListener); ok { - if !dl.WatchFile(ev.Name) || ev.Op&fsnotify.Chmod == fsnotify.Chmod { - return false - } - } - return true -} - -var WatchFilter = func(c *Controller, fc []Filter) { - if MainWatcher != nil { - err := MainWatcher.Notify() - if err != nil { - c.Result = c.RenderError(err) - return - } - } - fc[0](c, fc[1:]) -} diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go new file mode 100644 index 0000000..ab35745 --- /dev/null +++ b/internal/watcher/watcher_test.go @@ -0,0 +1,115 @@ +package watcher + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +type SimpleRefresher struct { + Refreshed bool + Error error +} + +func (l *SimpleRefresher) Refresh() error { + l.Refreshed = true + return l.Error +} + +func TestWatcher(t *testing.T) { + w := New() + tmp, err := os.MkdirTemp("", "mars-watcher-test") + if err != nil { + t.Fatal(err) + } + + bla := &SimpleRefresher{} + if err := w.Listen(bla, tmp); err != nil { + t.Errorf("unable to setup listener: %w", err) + } + + if err := w.Notify(); err != nil { + t.Errorf("unable to notify listeners: %w", err) + } + if bla.Refreshed { + t.Error("No changes to tmp dir yet, should not have been refreshed.") + } + + bla.Refreshed = false + if f, err := os.Create(filepath.Join(tmp, "yep.dada")); err != nil { + t.Fatal(err) + } else { + fmt.Fprintln(f, "Hello world!") + f.Close() + } + + time.Sleep(1 * time.Second) + + if err := w.Notify(); err != nil { + t.Errorf("unable to notify listeners: %w", err) + } + if !bla.Refreshed { + t.Error("Should have been refreshed.") + } + + if err := os.RemoveAll(tmp); err != nil { + t.Fatal(err) + } +} + +func TestErrorWhileRefreshing(t *testing.T) { + w := New() + tmp, err := os.MkdirTemp("", "mars-watcher-test") + if err != nil { + t.Fatal(err) + } + + bla := &SimpleRefresher{Error: errors.New("uh-oh something went wrong!!!11")} + if err := w.Listen(bla, tmp); err != nil { + t.Errorf("unable to setup listener: %w", err) + } + + if err := w.Notify(); err != nil { + t.Errorf("unable to notify listeners: %w", err) + } + if bla.Refreshed { + t.Error("No changes to tmp dir yet, should not have been refreshed.") + } + + bla.Refreshed = false + if f, err := os.Create(filepath.Join(tmp, "yep.dada")); err != nil { + t.Fatal(err) + } else { + fmt.Fprintln(f, "Hello world!") + f.Close() + } + + time.Sleep(1 * time.Second) + + if err := w.Notify(); err == nil { + t.Error("No error while refreshing") + } else if err != bla.Error { + t.Error("Wrong error seen while refreshing: %w", err) + } + if !bla.Refreshed { + t.Error("Should have been refreshed.") + } + + bla.Refreshed = false + bla.Error = nil + time.Sleep(1 * time.Second) + + if err := w.Notify(); err != nil { + t.Errorf("error not resovled yet: %w", err) + } + if !bla.Refreshed { + t.Error("Should have been refreshed.") + } + + if err := os.RemoveAll(tmp); err != nil { + t.Fatal(err) + } +} diff --git a/mars.go b/mars.go index b5e5d1b..8ea3784 100644 --- a/mars.go +++ b/mars.go @@ -13,6 +13,8 @@ import ( "github.com/agtorre/gocolorize" "github.com/robfig/config" + + "github.com/roblillack/mars/internal/watcher" ) const ( @@ -219,7 +221,7 @@ func setup() { // The "watch" config variable can turn on and off all watching. // (As a convenient way to control it all together.) if Config.BoolDefault("watch", DevMode) { - MainWatcher = NewWatcher() + MainWatcher = watcher.New() Filters = append([]Filter{WatchFilter}, Filters...) } @@ -247,13 +249,15 @@ func initializeFallbacks() { func SetupViews() { MainTemplateLoader = NewTemplateLoader([]string{path.Join(BasePath, ViewsPath)}) if err := MainTemplateLoader.Refresh(); err != nil { - ERROR.Fatalln(err.Error()) + ERROR.Fatalln(err) } // If desired (or by default), create a watcher for templates and routes. // The watcher calls Refresh() on things on the first request. if MainWatcher != nil && Config.BoolDefault("watch.templates", true) { - MainWatcher.Listen(MainTemplateLoader, MainTemplateLoader.paths...) + if err := MainWatcher.Listen(MainTemplateLoader, MainTemplateLoader.paths...); err != nil { + ERROR.Fatalln(err) + } } } @@ -263,13 +267,15 @@ func SetupViews() { func SetupRouter() { MainRouter = NewRouter(filepath.Join(BasePath, RoutesFile)) if err := MainRouter.Refresh(); err != nil { - ERROR.Fatalln(err.Error()) + ERROR.Fatalln(err) } // If desired (or by default), create a watcher for templates and routes. // The watcher calls Refresh() on things on the first request. if MainWatcher != nil && Config.BoolDefault("watch.routes", true) { - MainWatcher.Listen(MainRouter, MainRouter.path) + if err := MainWatcher.Listen(MainRouter, MainRouter.path); err != nil { + ERROR.Fatalln(err) + } } } diff --git a/router.go b/router.go index b70ee31..a029158 100644 --- a/router.go +++ b/router.go @@ -144,17 +144,23 @@ func (router *Router) Route(req *http.Request) *RouteMatch { // Refresh re-reads the routes file and re-calculates the routing table. // Returns an error if a specified action could not be found. -func (router *Router) Refresh() (err *Error) { - router.Routes, err = parseRoutesFile(router.path, "", true) - if err != nil { - return +func (router *Router) Refresh() error { + if r, err := parseRoutesFile(router.path, "", true); err != nil { + return err + } else { + router.Routes = r } - err = router.updateTree() - return + + if err := router.updateTree(); err != nil { + return err + } + + return nil } func (router *Router) updateTree() *Error { router.Tree = pathtree.New() + for _, route := range router.Routes { err := router.Tree.Add(route.TreePath(), route) @@ -168,6 +174,7 @@ func (router *Router) updateTree() *Error { return routeError(err, route.routesPath, "", route.line) } } + return nil } @@ -180,6 +187,7 @@ func parseRoutesFile(routesPath, joinedPath string, validate bool) ([]*Route, *E Description: err.Error(), } } + return parseRoutes(routesPath, joinedPath, string(contentBytes), validate) } diff --git a/server.go b/server.go index 2ff102b..960f2c8 100644 --- a/server.go +++ b/server.go @@ -12,12 +12,14 @@ import ( "time" "golang.org/x/net/websocket" + + "github.com/roblillack/mars/internal/watcher" ) var ( MainRouter *Router MainTemplateLoader *TemplateLoader - MainWatcher *Watcher + MainWatcher *watcher.Watcher Server *http.Server SecureServer *http.Server ) diff --git a/template.go b/template.go index 2d4a0aa..170e692 100644 --- a/template.go +++ b/template.go @@ -223,7 +223,7 @@ func (loader *TemplateLoader) createEmptyTemplateSet() *Error { // This scans the views directory and parses all templates as Go Templates. // If a template fails to parse, the error is set on the loader. // (It's awkward to refresh a single Go Template) -func (loader *TemplateLoader) Refresh() *Error { +func (loader *TemplateLoader) Refresh() error { TRACE.Printf("Refreshing templates from %s", loader.paths) loader.compileError = nil @@ -357,7 +357,11 @@ func (loader *TemplateLoader) Refresh() *Error { // If there was an error with the Funcs, set it and return immediately. if funcErr != nil { loader.compileError = funcErr.(*Error) - return loader.compileError + if loader.compileError == nil { + return nil + } else { + return loader.compileError + } } } @@ -379,7 +383,11 @@ func (loader *TemplateLoader) Refresh() *Error { } } - return loader.compileError + if loader.compileError == nil { + return nil + } else { + return loader.compileError + } } func (loader *TemplateLoader) WatchDir(info os.FileInfo) bool { diff --git a/util.go b/util.go index c0013a5..8a62d41 100644 --- a/util.go +++ b/util.go @@ -1,8 +1,6 @@ package mars import ( - "bytes" - "io" "io/ioutil" "net/url" "os" @@ -11,18 +9,6 @@ import ( "strings" ) -// Add some more methods to the default Template. -type ExecutableTemplate interface { - Execute(io.Writer, interface{}) error -} - -// Execute a template and returns the result as a string. -func ExecuteTemplate(tmpl ExecutableTemplate, data interface{}) string { - var b bytes.Buffer - tmpl.Execute(&b, data) - return b.String() -} - // Reads the lines of the given file. Panics in the case of error. func MustReadLines(filename string) []string { r, err := ReadLines(filename) diff --git a/watchfilter.go b/watchfilter.go new file mode 100644 index 0000000..ba2f231 --- /dev/null +++ b/watchfilter.go @@ -0,0 +1,12 @@ +package mars + +var WatchFilter = func(c *Controller, fc []Filter) { + if MainWatcher != nil { + err := MainWatcher.Notify() + if err != nil { + c.Result = c.RenderError(err) + return + } + } + fc[0](c, fc[1:]) +}