pupate

Puzzle transpiler
git clone https://git.woozle.org/neale/pupate.git

pupate / cmd / cocoon
Neale Pickett  ·  2025-03-10

main.go

  1package main
  2
  3import (
  4	"errors"
  5	"flag"
  6	"fmt"
  7	"io/fs"
  8	"log/slog"
  9	"net/url"
 10	"os"
 11	"os/signal"
 12	"os/user"
 13	"path/filepath"
 14	"strings"
 15	"time"
 16
 17	"github.com/dirtbags/pupate/docs"
 18	"github.com/dirtbags/pupate/examples"
 19	"github.com/dirtbags/pupate/internal/pupate"
 20	"github.com/dirtbags/pupate/variants"
 21	"github.com/dirtbags/pupate/web"
 22	"github.com/fsnotify/fsnotify"
 23	"github.com/lmittmann/tint"
 24	"gopkg.in/yaml.v3"
 25)
 26
 27// Version and date of this program
 28var Version, VersionDate = docs.Version()
 29
 30var (
 31	httpAddr     = flag.String("listen", "localhost:8080", "HTTP service address")
 32	safe      = flag.Bool("prod", false, "Don't bundle answers and debug information")
 33	variantPath  = flag.String("variant", "default", "Build variant name or path to variant on filesystem")
 34	loglevel     = flag.String("loglevel", "INFO", "Set threshold for log messages: DEBUG, INFO, WARN, ERROR")
 35	webdir       = flag.String("web", "", "Path to web files; takes precedence over variant web files")
 36	docsdir      = flag.String("docs", "", "Path to internal documentation")
 37	pollInterval = flag.Duration("poll", 0, "Change poll interval duration")
 38)
 39
 40type Context struct {
 41	*pupate.Context
 42
 43	// WebFS contains static web content
 44	WebFS fs.FS
 45
 46	// DocsFS contains documentation
 47	DocsFS fs.FS
 48
 49	// MyURL is the URL to me :)
 50	MyURL url.URL
 51
 52	// sourcecollections holds paths to directories containing category source trees.
 53	//
 54	// An entry might contain `netarch`, which in turn contains `core`, `sequence`, and `js`.
 55	// Each of those would contain puzzle directories.
 56	sourcecollections []string
 57
 58	// addListener adds an event listener.
 59	// The listener must be added again after every event is sent!
 60	addListener chan chan<- []string
 61
 62	// listeners is the internal list of listeners.
 63	// If you want a listener here, send it to addListener.
 64	listeners []chan<- []string
 65
 66	// watcher tells us when something on the filesystem changes.
 67	watcher *fsnotify.Watcher
 68
 69	// lastRebuild is the time when we last began a rebuild
 70	lastRebuild time.Time
 71
 72	// buildErrors is a list of errors from the last build
 73	buildErrors []error
 74}
 75
 76// GetSources returns a list of category directories
 77func (ctx *Context) GetSources() (sources []string, err error) {
 78	for _, srcdir := range ctx.sourcecollections {
 79		ctx.Watch(srcdir)
 80		dents, err := os.ReadDir(srcdir)
 81		if err != nil {
 82			return nil, err
 83		}
 84		for _, dent := range dents {
 85			if !dent.IsDir() {
 86				continue
 87			}
 88			name := dent.Name()
 89			if strings.HasPrefix(name, ".") {
 90				continue
 91			}
 92			path := filepath.Join(srcdir, dent.Name())
 93			sources = append(sources, path)
 94			ctx.Watch(path)
 95		}
 96	}
 97	return sources, nil
 98}
 99
100// Ask the OS to tell us about changes in this directory.
101func (ctx *Context) Watch(path string) {
102	if ctx.watcher == nil {
103		return
104	}
105
106	// Also watch any subdirectories
107	dents, _ := os.ReadDir(path)
108	for _, dent := range dents {
109		if !dent.IsDir() {
110			continue
111		}
112		ctx.Watch(filepath.Join(path, dent.Name()))
113	}
114
115	ctx.watcher.Add(path)
116}
117
118// WatchAndRebuild pupates all categories whenever a file changes.
119//
120// All listeners will be notified of the change.
121//
122// Any errors preventing monitoring result in a call to panic().
123func (ctx *Context) WatchAndRebuild() {
124	pollTicker := time.Tick(*pollInterval)
125	delayTimer := time.NewTimer(250 * time.Millisecond)
126	ctx.listeners = make([]chan<- []string, 0)
127	for {
128		select {
129		case err, ok := <-ctx.watcher.Errors:
130			if !ok {
131				return
132			}
133			slog.Error(err.Error())
134			panic(err)
135
136		case wevent, ok := <-ctx.watcher.Events:
137			if !ok {
138				return
139			}
140
141			slog.Debug("fsnotify", "event", wevent)
142
143			name := filepath.Base(wevent.Name)
144			switch {
145			case strings.HasPrefix(name, "."):
146				continue
147			case strings.HasPrefix(name, "#"):
148				continue
149			case strings.HasSuffix(name, "~"):
150				continue
151			case wevent.Op.Has(fsnotify.Chmod):
152				continue
153			case wevent.Op.Has(fsnotify.Create):
154				if info, err := os.Stat(wevent.Name); err != nil {
155					slog.Error(err.Error())
156				} else if info.IsDir() {
157					ctx.Watch(wevent.Name)
158				}
159			case wevent.Op.Has(fsnotify.Write):
160				if info, err := os.Stat(wevent.Name); err != nil {
161					continue
162				} else if info.IsDir() {
163					continue
164				}
165			}
166
167			// Debounce: editors may have more than one file to write.
168			delayTimer = time.NewTimer(100 * time.Millisecond)
169
170		case now := <-delayTimer.C:
171			go ctx.rebuild(now)
172
173		case now := <-pollTicker:
174			// Simulate fsnotify by looking for newer files
175			wdf := func(path string, d fs.DirEntry, err error) error {
176				info, err := d.Info()
177				if err != nil {
178					return err
179				}
180				if info.ModTime().After(ctx.lastRebuild) {
181					go ctx.rebuild(now)
182					return fs.SkipAll
183				}
184				return nil
185			}
186			for _, dn := range ctx.sourcecollections {
187				if err := filepath.WalkDir(dn, wdf); err != nil {
188					slog.Error(err.Error())
189				}
190			}
191
192		case listener, ok := <-ctx.addListener:
193			if !ok {
194				return
195			}
196			ctx.listeners = append(ctx.listeners, listener)
197			continue
198		}
199	}
200}
201
202func (ctx *Context) buildError(err error) {
203	slog.Error(err.Error())
204	ctx.buildErrors = append(ctx.buildErrors, err)
205}
206
207func (ctx *Context) rebuild(now time.Time) {
208	slog.Info("rebuilding")
209	ctx.lastRebuild = now
210	ctx.buildErrors = make([]error, 0)
211
212	dents, _ := os.ReadDir(ctx.OutDir)
213	for _, dent := range dents {
214		path := filepath.Join(ctx.OutDir, dent.Name())
215		os.RemoveAll(path)
216	}
217
218	event := make([]string, 0)
219	if srcdirs, err := ctx.GetSources(); err != nil {
220		ctx.buildError(err)
221	} else {
222		for _, srcdir := range srcdirs {
223			slog.Debug("pupating", "srcdir", srcdir)
224
225			var msg string
226			cat, errs := ctx.BuildCategory(srcdir)
227			for _, err := range errs {
228				msg = fmt.Sprintf("error: %v", err)
229				ctx.buildError(err)
230			}
231			if cat != nil {
232				// We can have errors and also a built category
233				slog.Debug("pupated", "cat", cat.ID)
234				msg = fmt.Sprintf("built: %s", cat.ID)
235			}
236			event = append(event, msg)
237
238		}
239	}
240
241	slog.Info("rebuild complete")
242
243	// Broadcast event to all listeners
244	for _, listener := range ctx.listeners {
245		listener <- event
246	}
247	ctx.listeners = ctx.listeners[:0]
248}
249
250func usage() {
251	fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [FLAGS] [CATEGORIES...]\n", os.Args[0])
252	flag.PrintDefaults()
253	fmt.Fprintln(flag.CommandLine.Output(), "Version", Version, "-", VersionDate)
254}
255
256// pause blocks until a signal is received
257func pause(sigs ...os.Signal) {
258	c := make(chan os.Signal, 1)
259	signal.Notify(c, sigs...)
260	<-c
261	signal.Stop(c)
262}
263
264func run() error {
265	// This takes up to 30s on my Windows box,
266	// but the module caches it.
267	go user.Current()
268
269	flag.Usage = usage
270	flag.Parse()
271
272	sloglevel := new(slog.LevelVar)
273	if err := sloglevel.UnmarshalText([]byte(*loglevel)); err != nil {
274		fmt.Fprintln(os.Stderr, err)
275		os.Exit(1)
276	}
277	loghandler := tint.NewHandler(os.Stderr, &tint.Options{
278		Level:      sloglevel,
279		TimeFormat: time.TimeOnly,
280	})
281	slog.SetDefault(slog.New(loghandler))
282
283	ctx := &Context{
284		Context:           &pupate.Context{},
285		addListener:       make(chan chan<- []string, 50),
286		sourcecollections: flag.Args(),
287		WebFS:             web.FS,
288		DocsFS:            docs.FS,
289	}
290	defer close(ctx.addListener)
291
292	// If no flags, set up a "categories" directory, and serve it
293	if len(ctx.sourcecollections) == 0 {
294		defaultDir := filepath.Join(".", "categories")
295		ctx.sourcecollections = append(ctx.sourcecollections, defaultDir)
296		slog.Info("no CATEGORIES directories specified, using default", "directory", defaultDir)
297		if _, err := os.Stat(defaultDir); errors.Is(err, os.ErrNotExist) {
298			if err := os.CopyFS(defaultDir, examples.FS); err != nil {
299				return err
300			}
301			slog.Info("created and populated", "directory", defaultDir)
302		}
303	}
304
305	// Prepare to watch for changes
306	ctx.watcher, _ = fsnotify.NewWatcher()
307
308	// Set up variant FS
309	if _, err := os.Stat(*variantPath); err == nil {
310		ctx.Watch(*variantPath)
311		ctx.VariantFS = os.DirFS(*variantPath)
312	} else {
313		ctx.VariantFS, err = fs.Sub(variants.FS, *variantPath)
314		if err != nil {
315			return err
316
317		}
318	}
319
320	if fsys, err := fs.Sub(ctx.VariantFS, "puzzle"); err != nil {
321		return err
322	} else {
323		ctx.PuzzleTemplateFS = fsys
324	}
325
326	if fsys, err := fs.Sub(ctx.VariantFS, "category"); err != nil {
327		return err
328	} else {
329		ctx.CategoryTemplateFS = fsys
330	}
331
332	if *webdir != "" {
333		ctx.WebFS = os.DirFS(*webdir)
334		ctx.Watch(*webdir)
335	}
336
337	if *docsdir != "" {
338		ctx.DocsFS = os.DirFS(*docsdir)
339		ctx.Watch(*docsdir)
340	}
341
342	// Create temporary directory to store pupated puzzles
343	targetdir, err := os.MkdirTemp("", "cocoon-")
344	if err != nil {
345		return err
346	}
347	defer os.RemoveAll(targetdir)
348	defer slog.Info("removing temporary directory", "dir", targetdir)
349	slog.Info("created temporary directory", "dir", targetdir)
350	ctx.OutDir = targetdir
351
352	ctx.Config = &pupate.Config{
353		Variant: filepath.Base(*variantPath),
354		Unsafe:  !*safe,
355	}
356	if configRaw, err := fs.ReadFile(ctx.VariantFS, "config.yaml"); err != nil {
357		// log.Println("Using builtin config")
358	} else if err := yaml.Unmarshal(configRaw, ctx.Config); err != nil {
359		return fmt.Errorf("parsing %s/config.yaml: %v", *variantPath, err)
360	}
361
362	if err := ctx.ServeHTTP(*httpAddr); err != nil {
363		return err
364	}
365	slog.Info("started HTTP server", "addr", *httpAddr, "url", ctx.MyURL.String())
366	pause(os.Interrupt) // Wait for ^C
367	slog.Info("exiting")
368	return nil
369}
370
371func main() {
372	if err := run(); err != nil {
373		slog.Error(err.Error())
374		os.Exit(1)
375	}
376}