From 04e19f51862c7b0395586131a22146c63932d351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Eric=20Janouch?= Date: Tue, 22 Jun 2021 03:45:20 +0200 Subject: [PATCH] hswg: use inotify to watch for changed documents Now we force the glob to be *.adoc, as well as *.asciidoc, and there can only be one document directory. The previous single-run mode is no longer supported. --- hswg/main.go | 244 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 195 insertions(+), 49 deletions(-) diff --git a/hswg/main.go b/hswg/main.go index 4626472..7854bf5 100644 --- a/hswg/main.go +++ b/hswg/main.go @@ -4,6 +4,7 @@ package main import ( "bytes" + "encoding/binary" "encoding/xml" "fmt" "html/template" @@ -11,10 +12,12 @@ import ( "io/ioutil" "log" "os" + "os/signal" "path/filepath" "regexp" "sort" "strings" + "syscall" "time" "unicode" "unicode/utf8" @@ -157,7 +160,14 @@ func (e *Entry) Published() *time.Time { } } -var extRE = regexp.MustCompile(`\.[^/.]*$`) +var ( + globs = []string{"*.adoc", "*.asciidoc"} + extRE = regexp.MustCompile(`\.[^/.]*$`) +) + +func pathToName(path string) string { + return stripExtension(filepath.Base(path)) +} func stripExtension(path string) string { return extRE.ReplaceAllString(path, "") @@ -172,7 +182,8 @@ func resultPath(path string) string { func makeLink(m *map[string]*Entry, name string) string { e := (*m)[name] - return fmt.Sprintf("%s", e.PathDestination, name) + return fmt.Sprintf("%s", + filepath.Clean(e.PathDestination), name) } var linkWordRE = regexp.MustCompile(`\b\p{Lu}\p{L}*\b`) @@ -219,31 +230,28 @@ func renderEntry(name string, e *Entry) error { return nil } -func loadEntries(globs []string) (map[string]*Entry, error) { - // Create a map from document names to their page entries. +func makeEntry(path string) *Entry { + return &Entry{ + PathSource: path, + PathDestination: resultPath(path), + } +} + +// loadEntries creates a map from document names to their page entries. +func loadEntries(dirname string) (map[string]*Entry, error) { entries := map[string]*Entry{} for _, glob := range globs { - matches, err := filepath.Glob(glob) + matches, err := filepath.Glob(filepath.Join(dirname, glob)) if err != nil { - return nil, fmt.Errorf("%s: %s\n", glob, err) + return nil, fmt.Errorf("%s: %s", dirname, err) } for _, path := range matches { - name := stripExtension(filepath.Base(path)) + name := pathToName(path) if conflict, ok := entries[name]; ok { - return nil, fmt.Errorf("%s: conflicts with %s\n", + return nil, fmt.Errorf("%s: conflicts with %s", name, conflict.PathSource) } - entries[name] = &Entry{ - PathSource: path, - PathDestination: resultPath(path), - backlinks: map[string]bool{}, - } - } - } - - for name, e := range entries { - if err := renderEntry(name, e); err != nil { - return nil, err + entries[name] = makeEntry(path) } } return entries, nil @@ -269,21 +277,6 @@ func writeEntry(e *Entry, t *template.Template, return t.Execute(f, e) } -func finalizeEntries(entries *map[string]*Entry) { - for name, e := range *entries { - // Expand LinkWords anywhere between . - // We want something like the inverse of Regexp.ReplaceAllStringFunc. - raw, last, expanded := e.raw, 0, bytes.NewBuffer(nil) - for _, where := range tagRE.FindAllIndex(raw, -1) { - _, _ = expanded.Write(expand(entries, name, raw[last:where[0]])) - _, _ = expanded.Write(raw[where[0]:where[1]]) - last = where[1] - } - _, _ = expanded.Write(expand(entries, name, raw[last:])) - e.Content = template.HTML(expanded.String()) - } -} - func writeIndex(path string, t *template.Template, entries *map[string]*Entry) error { // Reorder entries reversely, primarily by date, secondarily by filename. @@ -315,10 +308,144 @@ func writeIndex(path string, t *template.Template, return err } - // TODO(p): Splitting content to categories would be nice. + // TODO(p): Splitting content to categories would be nice. Or tags. return t.Execute(f, ordered) } +func finalizeEntries(entries *map[string]*Entry, t *template.Template, + indexPath string, indexT *template.Template) { + for name, e := range *entries { + e.backlinks = map[string]bool{} + if e.raw == nil { + if err := renderEntry(name, e); err != nil { + log.Printf("%s: %s\n", name, err) + } + } + } + for name, e := range *entries { + // Expand LinkWords anywhere between . + // We want something like the inverse of Regexp.ReplaceAllStringFunc. + raw, last, expanded := e.raw, 0, bytes.NewBuffer(nil) + for _, where := range tagRE.FindAllIndex(raw, -1) { + _, _ = expanded.Write(expand(entries, name, raw[last:where[0]])) + _, _ = expanded.Write(raw[where[0]:where[1]]) + last = where[1] + } + _, _ = expanded.Write(expand(entries, name, raw[last:])) + e.Content = template.HTML(expanded.String()) + } + for name, e := range *entries { + // Don't overwrite failed renders. + if e.raw == nil { + continue + } + if err := writeEntry(e, t, entries); err != nil { + log.Printf("%s: %s\n", name, err) + } + } + if err := writeIndex(indexPath, indexT, entries); err != nil { + log.Printf("%s: %s\n", indexPath, err) + } +} + +type watchEvent struct { + path string // the path of the target + present bool // if not, the file has been removed +} + +func dispatchEvents(dirname string, r io.Reader, ch chan<- *watchEvent) error { + var e syscall.InotifyEvent + for { + // FIXME(p): This has to respect the machine's endianness. + // Perhaps use the unsafe package. + err := binary.Read(r, binary.LittleEndian, &e) + if err == io.EOF { + return nil + } + if err != nil { + return err + } + + switch { + case e.Mask&syscall.IN_IGNORED != 0: + return fmt.Errorf("watch removed by kernel") + case e.Mask&syscall.IN_Q_OVERFLOW != 0: + log.Println("inotify: queue overflowed") + ch <- nil + continue + case e.Len == 0: + continue + } + + base := make([]byte, e.Len) + if n, err := r.Read(base); err != nil { + return err + } else if n < int(e.Len) { + return fmt.Errorf("short read") + } + + basename, interesting := string(base[:bytes.IndexByte(base, 0)]), false + for _, glob := range globs { + if matches, _ := filepath.Match(glob, basename); matches { + interesting = true + } + } + if !interesting { + continue + } + + event := &watchEvent{path: filepath.Join(dirname, basename)} + if e.Mask&syscall.IN_MODIFY != 0 || e.Mask&syscall.IN_MOVED_TO != 0 || + e.Mask&syscall.IN_CLOSE_WRITE != 0 { + event.present = true + ch <- event + } + if e.Mask&syscall.IN_DELETE != 0 || e.Mask&syscall.IN_MOVED_FROM != 0 { + event.present = false + ch <- event + } + } +} + +func watchDirectory(dirname string) (<-chan *watchEvent, error) { + inotifyFD, err := syscall.InotifyInit1(0) + if err != nil { + return nil, err + } + + // We're ignoring IN_CREATE, as it doesn't seem to be useful, + // and we're leaving out IN_MODIFY since VIM always triggers IN_CLOSE_WRITE, + // saving us from having to coalesce plentiful similar events. + _, err = syscall.InotifyAddWatch(inotifyFD, dirname, syscall.IN_ONLYDIR| + syscall.IN_MOVE|syscall.IN_DELETE|syscall.IN_CLOSE_WRITE) + if err != nil { + return nil, err + } + + inotifyFile := os.NewFile(uintptr(inotifyFD), "inotify") + buf := make([]byte, syscall.SizeofInotifyEvent+syscall.PathMax+1) + ch := make(chan *watchEvent) + go func() { + // Trigger an initial rendering run. + ch <- nil + + defer close(ch) + for { + n, err := inotifyFile.Read(buf) + if err != nil { + log.Println(err) + return + } + err = dispatchEvents(dirname, bytes.NewReader(buf[:n]), ch) + if err != nil { + log.Printf("inotify: %s\n", err) + return + } + } + }() + return ch, nil +} + func singleFile() { html, meta, err := Render(os.Stdin, configuration.NewConfiguration()) if err != nil { @@ -336,11 +463,11 @@ func main() { singleFile() return } - if len(os.Args) < 4 { - log.Fatalf("usage: %s TEMPLATE INDEX GLOB...\n", os.Args[0]) + if len(os.Args) != 4 { + log.Fatalf("usage: %s TEMPLATE INDEX DIRECTORY\n", os.Args[0]) } - argTemplate, argIndex, argGlobs := os.Args[1], os.Args[2], os.Args[3:] + argTemplate, argIndex, argDirectory := os.Args[1], os.Args[2], os.Args[3] // Read a template for entries. header, err := ioutil.ReadFile(argTemplate) @@ -362,21 +489,40 @@ func main() { log.Fatalln(err) } - // Process all entries. - entries, err := loadEntries(argGlobs) + // Re-render as needed, avoid having to trigger anything manually. + var entries map[string]*Entry + directoryWatch, err := watchDirectory(argDirectory) if err != nil { log.Fatalln(err) } - finalizeEntries(&entries) - for _, e := range entries { - if err := writeEntry(e, tmplEntry, &entries); err != nil { - log.Fatalln(err) + signals := make(chan os.Signal) + signal.Notify(signals, syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM) + for { + select { + case <-signals: + os.Exit(0) + case event, ok := <-directoryWatch: + if !ok { + os.Exit(1) + } + + if event == nil { + log.Println("reloading all files") + if entries, err = loadEntries(argDirectory); err != nil { + log.Println(err) + } + } else if event.present { + log.Printf("updating %s\n", event.path) + entries[pathToName(event.path)] = makeEntry(event.path) + } else { + log.Printf("removing %s\n", event.path) + delete(entries, pathToName(event.path)) + os.Remove(resultPath(event.path)) + } + + finalizeEntries(&entries, tmplEntry, argIndex, tmplIndex) + log.Println("done") } } - - // Write an index. - if err := writeIndex(argIndex, tmplIndex, &entries); err != nil { - log.Fatalln(err) - } }