From fc7ec3bc5b75ea1b8bd35c394f8ff3be7665f7ae Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 14:48:17 +0000 Subject: [PATCH 01/16] fsnotify --- .version | 2 +- cmd/templ/generatecmd/cmd.go | 277 +++++++++++++++ cmd/templ/generatecmd/eventhandler.go | 212 +++++++++++ cmd/templ/generatecmd/fatalerror.go | 23 ++ cmd/templ/generatecmd/main.go | 466 +------------------------ cmd/templ/generatecmd/watcher/watch.go | 107 ++++++ go.mod | 1 + go.sum | 2 + 8 files changed, 636 insertions(+), 454 deletions(-) create mode 100644 cmd/templ/generatecmd/cmd.go create mode 100644 cmd/templ/generatecmd/eventhandler.go create mode 100644 cmd/templ/generatecmd/fatalerror.go create mode 100644 cmd/templ/generatecmd/watcher/watch.go diff --git a/.version b/.version index e5e3a9d68..136c78f7c 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.2.545 \ No newline at end of file +0.2.546 \ No newline at end of file diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go new file mode 100644 index 000000000..8cc2d5158 --- /dev/null +++ b/cmd/templ/generatecmd/cmd.go @@ -0,0 +1,277 @@ +package generatecmd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "path/filepath" + "sync" + "time" + + "github.com/a-h/templ" + "github.com/a-h/templ/cmd/templ/generatecmd/modcheck" + "github.com/a-h/templ/cmd/templ/generatecmd/proxy" + "github.com/a-h/templ/cmd/templ/generatecmd/run" + "github.com/a-h/templ/cmd/templ/generatecmd/watcher" + "github.com/a-h/templ/generator" + "github.com/cenkalti/backoff/v4" + "github.com/cli/browser" + "github.com/fsnotify/fsnotify" +) + +func NewGenerate(log *slog.Logger, args Arguments) *Generate { + return &Generate{ + Log: log, + Args: &args, + } +} + +type Generate struct { + Log *slog.Logger + Args *Arguments +} + +func (cmd Generate) Run(ctx context.Context) (err error) { + if cmd.Args.Watch && cmd.Args.FileName != "" { + return fmt.Errorf("cannot watch a single file, remove the -f or -watch flag") + } + + if cmd.Args.PPROFPort > 0 { + go func() { + _ = http.ListenAndServe(fmt.Sprintf("localhost:%d", cmd.Args.PPROFPort), nil) + }() + } + + // Use absolute path. + if !path.IsAbs(cmd.Args.Path) { + cmd.Args.Path, err = filepath.Abs(cmd.Args.Path) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + } + + // Configure generator. + var opts []generator.GenerateOpt + if cmd.Args.IncludeVersion { + opts = append(opts, generator.WithVersion(templ.Version())) + } + if cmd.Args.IncludeTimestamp { + opts = append(opts, generator.WithTimestamp(time.Now())) + } + + // Check the version of the templ module. + if err := modcheck.Check(cmd.Args.Path); err != nil { + cmd.Log.Warn("templ version check failed", slog.Any("error", err)) + } + + fseh := NewFSEventHandler(cmd.Log, cmd.Args.Path, cmd.Args.Watch, opts, cmd.Args.GenerateSourceMapVisualisations, cmd.Args.KeepOrphanedFiles) + + // If we're processing a single file, don't bother setting up the channels/multithreaing. + if cmd.Args.FileName != "" { + _, err = fseh.HandleEvent(ctx, fsnotify.Event{ + Name: cmd.Args.FileName, + Op: fsnotify.Create, + }) + return err + } + + // Create channels: + // For the initial filesystem walk and subsequent (optional) fsnotify events. + events := make(chan fsnotify.Event) + // count of events currently being processed by the event handler. + var eventsWG sync.WaitGroup + // Used to check that the event handler has completed. + var eventHandlerWG sync.WaitGroup + // For errs from the watcher. + errs := make(chan error) + // For triggering actions after generation has completed. + postGeneration := make(chan struct{}) + // Used to check that the post-generation handler has completed. + var postGenerationWG sync.WaitGroup + + // Waitgroup for the push process. + var pushHandlerWG sync.WaitGroup + + // Start process to push events into the channel. + pushHandlerWG.Add(1) + go func() { + defer pushHandlerWG.Done() + defer close(events) + defer close(errs) + cmd.Log.Info("Walking directory", slog.String("path", cmd.Args.Path), slog.Bool("devMode", cmd.Args.Watch)) + err = watcher.WalkFiles(ctx, cmd.Args.Path, events) + if err != nil { + cmd.Log.Error("WalkFiles failed, exiting", slog.Any("error", err)) + errs <- FatalError{Err: fmt.Errorf("failed to walk files: %w", err)} + return + } + if !cmd.Args.Watch { + cmd.Log.Debug("Dev mode not enabled, process can finish early") + return + } + cmd.Log.Info("Watching files") + rw, err := watcher.Recursive(ctx, cmd.Args.Path, events, errs) + if err != nil { + cmd.Log.Error("Recursive watcher setup failed, exiting", slog.Any("error", err)) + errs <- FatalError{Err: fmt.Errorf("failed to setup recursive watcher: %w", err)} + return + } + cmd.Log.Debug("Waiting for context to be cancelled to stop watching files") + <-ctx.Done() + cmd.Log.Debug("Context cancelled, closing watcher") + if err = rw.Close(); err != nil { + cmd.Log.Error("Failed to close watcher", slog.Any("error", err)) + err = nil + } + cmd.Log.Debug("Waiting for events to be processed") + eventsWG.Wait() + cmd.Log.Debug("All pending events processed, waitinf for post-generation to complete") + postGenerationWG.Wait() + cmd.Log.Debug("All post-generation events processed, running walk again, but in production mode") + fseh.DevMode = false + err = watcher.WalkFiles(ctx, cmd.Args.Path, events) + if err != nil { + cmd.Log.Error("Post dev mode WalkFiles failed", slog.Any("error", err)) + errs <- FatalError{Err: fmt.Errorf("failed to walk files: %w", err)} + return + } + }() + + // Start process to handle events. + eventHandlerWG.Add(1) + sem := make(chan struct{}, cmd.Args.WorkerCount) + go func() { + defer eventHandlerWG.Done() + defer close(postGeneration) + cmd.Log.Debug("Starting event handler") + for event := range events { + cmd.Log.Debug("Event received, waiting for queue slot", slog.Any("event", event)) + eventsWG.Add(1) + sem <- struct{}{} + generated, err := fseh.HandleEvent(ctx, event) + if err != nil { + cmd.Log.Error("Event handler failed", slog.Any("error", err)) + errs <- err + } + <-sem + eventsWG.Done() + cmd.Log.Debug("Event handler completed", slog.Any("event", event), slog.Bool("generated", generated)) + if generated { + postGeneration <- struct{}{} + } + } + }() + + // Start process to handle post-generation events. + postGenerationWG.Add(1) + var firstPostGeneration bool + go func() { + defer postGenerationWG.Done() + cmd.Log.Debug("Starting post-generation handler") + timeout := time.NewTimer(time.Hour * 24 * 365) + var p *proxy.Handler + for range postGeneration { + select { + case <-postGeneration: + if !timeout.Stop() { + <-timeout.C + } + timeout.Reset(time.Millisecond * 100) + case <-timeout.C: + cmd.Log.Debug("No more post-generation events received for at least 100ms") + if cmd.Args.Command != "" { + cmd.Log.Debug("Executing command", slog.String("command", cmd.Args.Command)) + if _, err := run.Run(ctx, cmd.Args.Path, cmd.Args.Command); err != nil { + cmd.Log.Error("Error executing command", slog.Any("error", err)) + } + } + if firstPostGeneration { + cmd.Log.Debug("First post-generation event received, starting proxy") + firstPostGeneration = false + p, err = cmd.StartProxy(ctx) + if err != nil { + cmd.Log.Error("Failed to start proxy", slog.Any("error", err)) + } + } + // Send server-sent event. + if p != nil { + p.SendSSE("message", "reload") + } + } + } + }() + + // Read errors. + for err := range errs { + if err != nil { + if errors.Is(err, context.Canceled) { + cmd.Log.Debug("Context cancelled, exiting") + return nil + } + if errors.Is(err, FatalError{}) { + cmd.Log.Debug("Fatal error, exiting") + return err + } + cmd.Log.Error("Error received", slog.Any("error", err)) + } + } + + // Wait for everything to complete. + cmd.Log.Debug("Waiting for push handler to complete") + pushHandlerWG.Wait() + cmd.Log.Debug("Waiting for event handler to complete") + eventHandlerWG.Wait() + cmd.Log.Debug("Waiting for post-generation handler to complete") + postGenerationWG.Wait() + + return nil +} + +func (cmd *Generate) StartProxy(ctx context.Context) (p *proxy.Handler, err error) { + if cmd.Args.Proxy == "" { + cmd.Log.Debug("No proxy URL specified, not starting proxy") + return nil, nil + } + var target *url.URL + target, err = url.Parse(cmd.Args.Proxy) + if err != nil { + return nil, FatalError{Err: fmt.Errorf("failed to parse proxy URL: %w", err)} + } + if cmd.Args.ProxyPort == 0 { + cmd.Args.ProxyPort = 7331 + } + p = proxy.New(cmd.Args.ProxyPort, target) + go func() { + cmd.Log.Info("Proxying", slog.String("from", p.URL), slog.String("to", p.Target.String())) + if err := http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", cmd.Args.ProxyPort), p); err != nil { + cmd.Log.Error("Proxy failed", slog.Any("error", err)) + } + }() + if !cmd.Args.OpenBrowser { + cmd.Log.Debug("Not opening browser") + return p, nil + } + go func() { + cmd.Log.Debug("Waiting for proxy to be ready", slog.String("url", p.URL)) + backoff := backoff.NewExponentialBackOff() + backoff.InitialInterval = time.Second + var client http.Client + client.Timeout = 1 * time.Second + for { + if _, err := client.Get(p.URL); err == nil { + break + } + d := backoff.NextBackOff() + cmd.Log.Debug("Proxy not ready, retrying", slog.String("url", p.URL), slog.Any("backoff", d)) + time.Sleep(d) + } + if err := browser.OpenURL(p.URL); err != nil { + cmd.Log.Error("Failed to open browser", slog.Any("error", err)) + } + }() + return p, nil +} diff --git a/cmd/templ/generatecmd/eventhandler.go b/cmd/templ/generatecmd/eventhandler.go new file mode 100644 index 000000000..8db17ccd2 --- /dev/null +++ b/cmd/templ/generatecmd/eventhandler.go @@ -0,0 +1,212 @@ +package generatecmd + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "fmt" + "go/format" + "io" + "log/slog" + "os" + "path" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/a-h/templ/cmd/templ/visualize" + "github.com/a-h/templ/generator" + "github.com/a-h/templ/parser/v2" + "github.com/fsnotify/fsnotify" +) + +func NewFSEventHandler(log *slog.Logger, dir string, devMode bool, genOpts []generator.GenerateOpt, genSourceMapVis bool, keepOrphanedFiles bool) *FSEventHandler { + if !path.IsAbs(dir) { + dir, _ = filepath.Abs(dir) + } + fseh := &FSEventHandler{ + Log: log, + dir: dir, + stdout: os.Stdout, + fileNameToLastModTime: make(map[string]time.Time), + hashes: make(map[string][sha256.Size]byte), + genOpts: genOpts, + genSourceMapVis: genSourceMapVis, + DevMode: devMode, + keepOrphanedFiles: keepOrphanedFiles, + } + if devMode { + fseh.genOpts = append(fseh.genOpts, generator.WithExtractStrings()) + } + return fseh +} + +type FSEventHandler struct { + Log *slog.Logger + // dir is the root directory being processed. + dir string + stdout io.Writer + stderr io.Writer + fileNameToLastModTime map[string]time.Time + hashes map[string][sha256.Size]byte + genOpts []generator.GenerateOpt + genSourceMapVis bool + DevMode bool + keepOrphanedFiles bool +} + +func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) (generated bool, err error) { + // Handle _templ.go files. + if !event.Has(fsnotify.Remove) && strings.HasSuffix(event.Name, "_templ.go") { + _, err = os.Stat(strings.TrimSuffix(event.Name, "_templ.go") + ".templ") + if err != nil { + return false, nil + } + // File is orphaned. + if h.keepOrphanedFiles { + return false, nil + } + if err = os.Remove(event.Name); err != nil { + h.Log.Warn("Failed to remove orphaned file", slog.Any("error", err)) + } + return true, nil + } + // Handle _templ.txt files. + if !event.Has(fsnotify.Remove) && strings.HasSuffix(event.Name, "_templ.txt") { + if h.DevMode { + // Don't do anything in watch mode. + return false, nil + } + if err = os.Remove(event.Name); err != nil { + h.Log.Warn("Failed to remove watch mode text file", slog.Any("error", err)) + return false, nil + } + h.Log.Debug("Deleted watch mode file", slog.String("file", event.Name)) + return false, nil + } + + // Handle .templ files. + if !strings.HasSuffix(event.Name, ".templ") { + return false, nil + } + + // If the file hasn't been updated since the last time we processed it, ignore it. + lastModTime := h.fileNameToLastModTime[event.Name] + fileInfo, err := os.Stat(event.Name) + if err != nil { + return false, fmt.Errorf("failed to get file info: %w", err) + } + if fileInfo.ModTime().Before(lastModTime) { + return false, nil + } + + // Start a processor. + h.fileNameToLastModTime[event.Name] = fileInfo.ModTime() + + start := time.Now() + diag, err := h.generate(ctx, event.Name) + if err != nil { + h.Log.Error("Error generating code", slog.String("file", event.Name), slog.Any("error", err)) + return false, fmt.Errorf("failed to generate code for %q: %w", event.Name, err) + } + if len(diag) > 0 { + for _, d := range diag { + h.Log.Warn(d.Message, slog.String("from", fmt.Sprintf("%d:%d", d.Range.From.Line, d.Range.From.Col)), slog.String("to", fmt.Sprintf("%d:%d", d.Range.To.Line, d.Range.To.Col))) + } + return + } + h.Log.Debug("Generated code for %q in %s\n", event.Name, time.Since(start)) + + return true, nil +} + +// generate Go code for a single template. +// If a basePath is provided, the filename included in error messages is relative to it. +func (h *FSEventHandler) generate(ctx context.Context, fileName string) (diagnostics []parser.Diagnostic, err error) { + t, err := parser.Parse(fileName) + if err != nil { + return nil, fmt.Errorf("%s parsing error: %w", fileName, err) + } + targetFileName := strings.TrimSuffix(fileName, ".templ") + "_templ.go" + + // Only use relative filenames to the basepath for filenames in runtime error messages. + relFilePath, err := filepath.Rel(h.dir, fileName) + if err != nil { + return nil, fmt.Errorf("failed to get relative path for %q: %w", fileName, err) + } + + var b bytes.Buffer + sourceMap, literals, err := generator.Generate(t, &b, append(h.genOpts, generator.WithFileName(relFilePath))...) + if err != nil { + return nil, fmt.Errorf("%s generation error: %w", fileName, err) + } + + formattedGoCode, err := format.Source(b.Bytes()) + if err != nil { + return nil, fmt.Errorf("%s source formatting error: %w", fileName, err) + } + + // Hash output, and write out the file if the goCodeHash has changed. + goCodeHash := sha256.Sum256(formattedGoCode) + if h.hashes[targetFileName] != goCodeHash { + if err = os.WriteFile(targetFileName, formattedGoCode, 0o644); err != nil { + return nil, fmt.Errorf("failed to write target file %q: %w", targetFileName, err) + } + h.hashes[targetFileName] = goCodeHash + } + + // Add the txt file if it has changed. + if len(literals) > 0 { + txtFileName := strings.TrimSuffix(fileName, ".templ") + "_templ.txt" + txtHash := sha256.Sum256([]byte(literals)) + if h.hashes[txtFileName] != txtHash { + if err = os.WriteFile(txtFileName, []byte(literals), 0o644); err != nil { + return nil, fmt.Errorf("failed to write string literal file %q: %w", txtFileName, err) + } + h.hashes[txtFileName] = txtHash + } + } + + if h.genSourceMapVis { + err = generateSourceMapVisualisation(ctx, fileName, targetFileName, sourceMap) + } + return t.Diagnostics, err +} + +func generateSourceMapVisualisation(ctx context.Context, templFileName, goFileName string, sourceMap *parser.SourceMap) error { + if err := ctx.Err(); err != nil { + return err + } + var templContents, goContents []byte + var templErr, goErr error + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + templContents, templErr = os.ReadFile(templFileName) + }() + go func() { + defer wg.Done() + goContents, goErr = os.ReadFile(goFileName) + }() + wg.Wait() + if templErr != nil { + return templErr + } + if goErr != nil { + return templErr + } + + targetFileName := strings.TrimSuffix(templFileName, ".templ") + "_templ_sourcemap.html" + w, err := os.Create(targetFileName) + if err != nil { + return fmt.Errorf("%s sourcemap visualisation error: %w", templFileName, err) + } + defer w.Close() + b := bufio.NewWriter(w) + defer b.Flush() + + return visualize.HTML(templFileName, string(templContents), string(goContents), sourceMap).Render(ctx, b) +} diff --git a/cmd/templ/generatecmd/fatalerror.go b/cmd/templ/generatecmd/fatalerror.go new file mode 100644 index 000000000..e1092df4c --- /dev/null +++ b/cmd/templ/generatecmd/fatalerror.go @@ -0,0 +1,23 @@ +package generatecmd + +type FatalError struct { + Err error +} + +func (e FatalError) Error() string { + return e.Err.Error() +} + +func (e FatalError) Unwrap() error { + return e.Err +} + +func (e FatalError) Is(target error) bool { + _, ok := target.(FatalError) + return ok +} + +func (e FatalError) As(target interface{}) bool { + _, ok := target.(*FatalError) + return ok +} diff --git a/cmd/templ/generatecmd/main.go b/cmd/templ/generatecmd/main.go index fc2652302..a287e968c 100644 --- a/cmd/templ/generatecmd/main.go +++ b/cmd/templ/generatecmd/main.go @@ -1,37 +1,13 @@ package generatecmd import ( - "bufio" - "bytes" "context" - "crypto/sha256" _ "embed" - "errors" - "fmt" - "go/format" "io" - "net/http" - "net/url" - "os" - "path" - "path/filepath" + "log/slog" "runtime" - "strings" - "sync" - "time" _ "net/http/pprof" - - "github.com/a-h/templ" - "github.com/a-h/templ/cmd/templ/generatecmd/modcheck" - "github.com/a-h/templ/cmd/templ/generatecmd/proxy" - "github.com/a-h/templ/cmd/templ/generatecmd/run" - "github.com/a-h/templ/cmd/templ/visualize" - "github.com/a-h/templ/generator" - "github.com/a-h/templ/parser/v2" - "github.com/cenkalti/backoff/v4" - "github.com/cli/browser" - "github.com/fatih/color" ) type Arguments struct { @@ -46,6 +22,7 @@ type Arguments struct { GenerateSourceMapVisualisations bool IncludeVersion bool IncludeTimestamp bool + Level string // PPROFPort is the port to run the pprof server on. PPROFPort int KeepOrphanedFiles bool @@ -53,434 +30,17 @@ type Arguments struct { var defaultWorkerCount = runtime.NumCPU() -func Run(ctx context.Context, w io.Writer, args Arguments) (err error) { - if args.PPROFPort > 0 { - go func() { - _ = http.ListenAndServe(fmt.Sprintf("localhost:%d", args.PPROFPort), nil) - }() - } - - err = runCmd(ctx, w, args) - if errors.Is(err, context.Canceled) { - return nil - } - - return err -} - -func runCmd(ctx context.Context, w io.Writer, args Arguments) error { - var err error - - if args.Watch && args.FileName != "" { - return fmt.Errorf("cannot watch a single file, remove the -f or -watch flag") - } - var opts []generator.GenerateOpt - if args.IncludeVersion { - opts = append(opts, generator.WithVersion(templ.Version())) - } - if args.IncludeTimestamp { - opts = append(opts, generator.WithTimestamp(time.Now())) - } - if args.FileName != "" { - return processSingleFile(ctx, w, "", args.FileName, nil, args.GenerateSourceMapVisualisations, opts) - } - var target *url.URL - if args.Proxy != "" { - target, err = url.Parse(args.Proxy) - if err != nil { - return fmt.Errorf("failed to parse proxy URL: %w", err) - } - } - if args.ProxyPort == 0 { - args.ProxyPort = 7331 - } - - if args.WorkerCount == 0 { - args.WorkerCount = defaultWorkerCount - } - if !path.IsAbs(args.Path) { - args.Path, err = filepath.Abs(args.Path) - if err != nil { - return err - } - } - - var p *proxy.Handler - if args.Proxy != "" { - p = proxy.New(args.ProxyPort, target) - } - fmt.Fprintln(w, "Processing path:", args.Path) - - if err := modcheck.Check(args.Path); err != nil { - logWarning(w, "templ version check failed: %v\n", err) - } - - if args.Watch { - err = generateWatched(ctx, w, args, opts, p) - if err != nil && !errors.Is(err, context.Canceled) { - return err - } - } - - return generateProduction(context.Background(), w, args, opts, p) -} - -func generateWatched(ctx context.Context, w io.Writer, args Arguments, opts []generator.GenerateOpt, p *proxy.Handler) error { - fmt.Fprintln(w, "Generating dev code:", args.Path) - start := time.Now() - - bo := backoff.NewExponentialBackOff() - bo.InitialInterval = time.Millisecond * 500 - bo.MaxInterval = time.Second * 3 - bo.MaxElapsedTime = 0 - - var firstRunComplete bool - fileNameToLastModTime := make(map[string]time.Time) - fileNameToHash := make(map[string][sha256.Size]byte) - - for !firstRunComplete || args.Watch { - changesFound, errs := processChanges( - ctx, w, - fileNameToLastModTime, fileNameToHash, - args.Path, args.GenerateSourceMapVisualisations, - opts, args.WorkerCount, true, args.KeepOrphanedFiles) - if len(errs) > 0 { - if errors.Is(errs[0], context.Canceled) { - return errs[0] - } - if !args.Watch { - return fmt.Errorf("failed to process path: %v", errors.Join(errs...)) - } - logError(w, "Error processing path: %v\n", errors.Join(errs...)) - } - if changesFound > 0 { - if len(errs) > 0 { - logError(w, "Generated code for %d templates with %d errors in %s\n", changesFound, len(errs), time.Since(start)) - } else { - logSuccess(w, "Generated code for %d templates with %d errors in %s\n", changesFound, len(errs), time.Since(start)) - } - if args.Command != "" { - fmt.Fprintf(w, "Executing command: %s\n", args.Command) - if _, err := run.Run(ctx, args.Path, args.Command); err != nil { - fmt.Fprintf(w, "Error starting command: %v\n", err) - } - } - // Send server-sent event. - if p != nil { - p.SendSSE("message", "reload") - } - - if !firstRunComplete && p != nil { - go func() { - fmt.Fprintf(w, "Proxying from %s to target: %s\n", p.URL, p.Target.String()) - if err := http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", args.ProxyPort), p); err != nil { - fmt.Fprintf(w, "Error starting proxy: %v\n", err) - } - }() - if args.OpenBrowser { - go func() { - fmt.Fprintf(w, "Opening URL: %s\n", p.Target.String()) - if err := openURL(w, p.URL); err != nil { - fmt.Fprintf(w, "Error opening URL: %v\n", err) - } - }() - } - } - } - - if firstRunComplete { - if changesFound > 0 { - bo.Reset() - } - time.Sleep(bo.NextBackOff()) - } - - firstRunComplete = true - start = time.Now() - } - - return nil -} - -func generateProduction(ctx context.Context, w io.Writer, args Arguments, opts []generator.GenerateOpt, p *proxy.Handler) error { - fmt.Fprintln(w, "Generating production code:", args.Path) - start := time.Now() - - changesFound, errs := processChanges( - ctx, w, nil, nil, - args.Path, args.GenerateSourceMapVisualisations, - opts, args.WorkerCount, false, args.KeepOrphanedFiles) - if len(errs) > 0 { - if errors.Is(errs[0], context.Canceled) { - return errs[0] - } - logError(w, "Error processing path: %v\n", errors.Join(errs...)) - } - - if changesFound > 0 { - if len(errs) > 0 { - logError(w, "Generated code for %d templates with %d errors in %s\n", changesFound, len(errs), time.Since(start)) - } else { - logSuccess(w, "Generated code for %d templates with %d errors in %s\n", changesFound, len(errs), time.Since(start)) - } - if args.Command != "" { - fmt.Fprintf(w, "Executing command: %s\n", args.Command) - if _, err := run.Run(ctx, args.Path, args.Command); err != nil { - fmt.Fprintf(w, "Error starting command: %v\n", err) - } - } +func Run(ctx context.Context, stderr io.Writer, args Arguments) (err error) { + level := slog.LevelWarn.Level() + if args.Level == "debug" || args.Level == "verbose" { + level = slog.LevelDebug.Level() } - - return nil -} - -func shouldSkipDir(dir string) bool { - if dir == "." { - return false - } - if dir == "vendor" || dir == "node_modules" { - return true + if args.Level == "info" { + level = slog.LevelInfo.Level() } - _, name := path.Split(dir) - // These directories are ignored by the Go tool. - if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { - return true - } - return false -} - -func processChanges(ctx context.Context, stdout io.Writer, fileNameToLastModTime map[string]time.Time, hashes map[string][sha256.Size]byte, path string, generateSourceMapVisualisations bool, opts []generator.GenerateOpt, maxWorkerCount int, watching, keepOrphanedFiles bool) (changesFound int, errs []error) { - sem := make(chan struct{}, maxWorkerCount) - var wg sync.WaitGroup - - if watching { - opts = append(opts, generator.WithExtractStrings()) - } - - if fileNameToLastModTime == nil { - fileNameToLastModTime = make(map[string]time.Time) - } - - err := filepath.WalkDir(path, func(fileName string, info os.DirEntry, err error) error { - if err != nil { - return err - } - if err = ctx.Err(); err != nil { - return err - } - if info.IsDir() && shouldSkipDir(fileName) { - return filepath.SkipDir - } - if info.IsDir() { - return nil - } - - orphaned := !keepOrphanedFiles && strings.HasSuffix(fileName, "_templ.go") - if orphaned { - // Make sure the generated file is orphaned - // by checking if the corresponding .templ file exists. - if _, err := os.Stat(strings.TrimSuffix(fileName, "_templ.go") + ".templ"); err == nil { - orphaned = false - } - } - - devTextFile := !watching && strings.HasSuffix(fileName, "_templ.txt") - if orphaned || devTextFile { - if err = os.Remove(fileName); err != nil { - return fmt.Errorf("failed to remove file: %w", err) - } - logWarning(stdout, "Deleted file %q\n", fileName) - return nil - } - - if strings.HasSuffix(fileName, ".templ") { - lastModTime := fileNameToLastModTime[fileName] - fileInfo, err := info.Info() - if err != nil { - return fmt.Errorf("failed to get file info: %w", err) - } - if fileInfo.ModTime().After(lastModTime) { - fileNameToLastModTime[fileName] = fileInfo.ModTime() - changesFound++ - - // Start a processor, but limit to maxWorkerCount. - sem <- struct{}{} - wg.Add(1) - go func() { - defer wg.Done() - if err := processSingleFile(ctx, stdout, path, fileName, hashes, generateSourceMapVisualisations, opts); err != nil { - errs = append(errs, err) - } - <-sem - }() - } - } - return nil - }) - if err != nil { - errs = append(errs, err) - } - - wg.Wait() - - return changesFound, errs -} - -func openURL(w io.Writer, url string) error { - backoff := backoff.NewExponentialBackOff() - backoff.InitialInterval = time.Second - var client http.Client - client.Timeout = 1 * time.Second - for { - if _, err := client.Get(url); err == nil { - break - } - d := backoff.NextBackOff() - fmt.Fprintf(w, "Server not ready. Retrying in %v...\n", d) - time.Sleep(d) - } - return browser.OpenURL(url) -} - -// processSingleFile generates Go code for a single template. -// If a basePath is provided, the filename included in error messages is relative to it. -func processSingleFile(ctx context.Context, stdout io.Writer, basePath, fileName string, hashes map[string][sha256.Size]byte, generateSourceMapVisualisations bool, opts []generator.GenerateOpt) (err error) { - start := time.Now() - diag, err := generate(ctx, basePath, fileName, hashes, generateSourceMapVisualisations, opts) - if err != nil { - return err - } - var b bytes.Buffer - defer func() { - _, _ = b.WriteTo(stdout) - }() - if len(diag) > 0 { - logWarning(&b, "Generated code for %q in %s\n", fileName, time.Since(start)) - printDiagnostics(&b, fileName, diag) - return nil - } - logSuccess(&b, "Generated code for %q in %s\n", fileName, time.Since(start)) - return nil -} - -func printDiagnostics(w io.Writer, fileName string, diags []parser.Diagnostic) { - for _, d := range diags { - fmt.Fprint(w, "\t") - logWarning(w, "%s (%d:%d)\n", d.Message, d.Range.From.Line, d.Range.From.Col) - } - fmt.Fprintln(w) -} - -// generate Go code for a single template. -// If a basePath is provided, the filename included in error messages is relative to it. -func generate(ctx context.Context, basePath, fileName string, hashes map[string][sha256.Size]byte, generateSourceMapVisualisations bool, opts []generator.GenerateOpt) (diagnostics []parser.Diagnostic, err error) { - if err = ctx.Err(); err != nil { - return - } - - if hashes == nil { - hashes = make(map[string][sha256.Size]byte) - } - - t, err := parser.Parse(fileName) - if err != nil { - return nil, fmt.Errorf("%s parsing error: %w", fileName, err) - } - targetFileName := strings.TrimSuffix(fileName, ".templ") + "_templ.go" - - // Only use relative filenames to the basepath for filenames in runtime error messages. - errorMessageFileName := fileName - if basePath != "" { - errorMessageFileName, _ = filepath.Rel(basePath, fileName) - } - - var b bytes.Buffer - sourceMap, literals, err := generator.Generate(t, &b, append(opts, generator.WithFileName(errorMessageFileName))...) - if err != nil { - return nil, fmt.Errorf("%s generation error: %w", fileName, err) - } - - formattedGoCode, err := format.Source(b.Bytes()) - if err != nil { - return nil, fmt.Errorf("%s source formatting error: %w", fileName, err) - } - - // Hash output, and write out the file if the goCodeHash has changed. - goCodeHash := sha256.Sum256(formattedGoCode) - if hashes[targetFileName] != goCodeHash { - if err = os.WriteFile(targetFileName, formattedGoCode, 0o644); err != nil { - return nil, fmt.Errorf("failed to write target file %q: %w", targetFileName, err) - } - hashes[targetFileName] = goCodeHash - } - - // Add the txt file if it has changed. - if len(literals) > 0 { - txtFileName := strings.TrimSuffix(fileName, ".templ") + "_templ.txt" - txtHash := sha256.Sum256([]byte(literals)) - if hashes[txtFileName] != txtHash { - if err = os.WriteFile(txtFileName, []byte(literals), 0o644); err != nil { - return nil, fmt.Errorf("failed to write string literal file %q: %w", txtFileName, err) - } - hashes[txtFileName] = txtHash - } - } - - if generateSourceMapVisualisations { - err = generateSourceMapVisualisation(ctx, fileName, targetFileName, sourceMap) - } - return t.Diagnostics, err -} - -func generateSourceMapVisualisation(ctx context.Context, templFileName, goFileName string, sourceMap *parser.SourceMap) error { - if err := ctx.Err(); err != nil { - return err - } - var templContents, goContents []byte - var templErr, goErr error - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - templContents, templErr = os.ReadFile(templFileName) - }() - go func() { - defer wg.Done() - goContents, goErr = os.ReadFile(goFileName) - }() - wg.Wait() - if templErr != nil { - return templErr - } - if goErr != nil { - return templErr - } - - targetFileName := strings.TrimSuffix(templFileName, ".templ") + "_templ_sourcemap.html" - w, err := os.Create(targetFileName) - if err != nil { - return fmt.Errorf("%s sourcemap visualisation error: %w", templFileName, err) - } - defer w.Close() - b := bufio.NewWriter(w) - defer b.Flush() - - return visualize.HTML(templFileName, string(templContents), string(goContents), sourceMap).Render(ctx, b) -} - -func logError(w io.Writer, format string, a ...any) { - logWithDecoration(w, "✗", color.FgRed, format, a...) -} - -func logWarning(w io.Writer, format string, a ...any) { - logWithDecoration(w, "!", color.FgYellow, format, a...) -} - -func logSuccess(w io.Writer, format string, a ...any) { - logWithDecoration(w, "✓", color.FgGreen, format, a...) -} - -func logWithDecoration(w io.Writer, decoration string, col color.Attribute, format string, a ...any) { - color.New(col).Fprintf(w, "(%s) ", decoration) - fmt.Fprintf(w, format, a...) + log := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{ + AddSource: true, + Level: level, + })) + return NewGenerate(log, args).Run(ctx) } diff --git a/cmd/templ/generatecmd/watcher/watch.go b/cmd/templ/generatecmd/watcher/watch.go new file mode 100644 index 000000000..185e1c294 --- /dev/null +++ b/cmd/templ/generatecmd/watcher/watch.go @@ -0,0 +1,107 @@ +package watcher + +import ( + "context" + "os" + "path" + "path/filepath" + "strings" + + "github.com/fsnotify/fsnotify" +) + +func Recursive(ctx context.Context, path string, out chan fsnotify.Event, errors chan error) (w *RecursiveWatcher, err error) { + fsnw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + w = &RecursiveWatcher{ + ctx: ctx, + w: fsnw, + Events: out, + Errors: errors, + } + go w.loop() + return w, w.Add(path) +} + +// WalkFiles walks the file tree rooted at path, sending a Create event for each +// file it encounters. +func WalkFiles(ctx context.Context, path string, out chan fsnotify.Event) (err error) { + return filepath.WalkDir(path, func(path string, info os.DirEntry, err error) error { + if err != nil { + return nil + } + if info.IsDir() && shouldSkipDir(path) { + return filepath.SkipDir + } + out <- fsnotify.Event{ + Name: path, + Op: fsnotify.Create, + } + return nil + }) +} + +type RecursiveWatcher struct { + ctx context.Context + w *fsnotify.Watcher + Events chan fsnotify.Event + Errors chan error +} + +func (w *RecursiveWatcher) Close() error { + return w.w.Close() +} + +func (w *RecursiveWatcher) loop() error { + for { + select { + case <-w.ctx.Done(): + return context.Canceled + case event, ok := <-w.w.Events: + if !ok { + return nil + } + if event.Has(fsnotify.Create) { + w.Add(event.Name) + } + w.Events <- event + case err, ok := <-w.w.Errors: + if !ok { + return nil + } + w.Errors <- err + } + } +} + +func (w *RecursiveWatcher) Add(dir string) error { + return filepath.WalkDir(dir, func(dir string, info os.DirEntry, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + return nil + } + if shouldSkipDir(dir) { + return filepath.SkipDir + } + return w.w.Add(dir) + }) +} + +func shouldSkipDir(dir string) bool { + if dir == "." { + return false + } + if dir == "vendor" || dir == "node_modules" { + return true + } + _, name := path.Split(dir) + // These directories are ignored by the Go tool. + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + return true + } + return false +} diff --git a/go.mod b/go.mod index 59a9c42fb..0091eebea 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/cenkalti/backoff/v4 v4.2.1 github.com/cli/browser v1.2.0 github.com/fatih/color v1.16.0 + github.com/fsnotify/fsnotify v1.7.0 github.com/google/go-cmp v0.6.0 github.com/natefinch/atomic v1.0.1 github.com/rs/cors v1.8.3 diff --git a/go.sum b/go.sum index c98893a9d..b5ec7bc8a 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= From 5b1572bc487d6c8ec461c9d7e1029fe974f14b8e Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 14:53:51 +0000 Subject: [PATCH 02/16] feat: add log verbosity flag --- cmd/templ/main.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/templ/main.go b/cmd/templ/main.go index 43a7e5ee6..a55f08e0e 100644 --- a/cmd/templ/main.go +++ b/cmd/templ/main.go @@ -92,6 +92,8 @@ Args: Port to run the pprof server on. -keep-orphaned-files Keeps orphaned generated templ files. (default false) + -level + Log verbosity level. (default "warn") -help Print help and exit. @@ -126,6 +128,7 @@ func generateCmd(w io.Writer, args []string) (code int) { workerCountFlag := cmd.Int("w", runtime.NumCPU(), "") pprofPortFlag := cmd.Int("pprof", 0, "") keepOrphanedFilesFlag := cmd.Bool("keep-orphaned-files", false, "") + levelFlag := cmd.String("level", "warn", "") helpFlag := cmd.Bool("help", false, "") err := cmd.Parse(args) if err != nil || *helpFlag { @@ -153,6 +156,7 @@ func generateCmd(w io.Writer, args []string) (code int) { GenerateSourceMapVisualisations: *sourceMapVisualisationsFlag, IncludeVersion: *includeVersionFlag, IncludeTimestamp: *includeTimestampFlag, + Level: *levelFlag, PPROFPort: *pprofPortFlag, KeepOrphanedFiles: *keepOrphanedFilesFlag, }) From 1ec31370736569bf1fedc1d4358164e4f2c40884 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 15:56:56 +0000 Subject: [PATCH 03/16] run events in goroutine --- cmd/templ/generatecmd/cmd.go | 46 +++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index 8cc2d5158..321a707e8 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -89,7 +89,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { // For errs from the watcher. errs := make(chan error) // For triggering actions after generation has completed. - postGeneration := make(chan struct{}) + postGeneration := make(chan struct{}, 256) // Used to check that the post-generation handler has completed. var postGenerationWG sync.WaitGroup @@ -152,17 +152,18 @@ func (cmd Generate) Run(ctx context.Context) (err error) { cmd.Log.Debug("Event received, waiting for queue slot", slog.Any("event", event)) eventsWG.Add(1) sem <- struct{}{} - generated, err := fseh.HandleEvent(ctx, event) - if err != nil { - cmd.Log.Error("Event handler failed", slog.Any("error", err)) - errs <- err - } - <-sem - eventsWG.Done() - cmd.Log.Debug("Event handler completed", slog.Any("event", event), slog.Bool("generated", generated)) - if generated { - postGeneration <- struct{}{} - } + go func(event fsnotify.Event) { + defer eventsWG.Done() + defer func() { <-sem }() + generated, err := fseh.HandleEvent(ctx, event) + if err != nil { + cmd.Log.Error("Event handler failed", slog.Any("error", err)) + errs <- err + } + if generated { + postGeneration <- struct{}{} + } + }(event) } }() @@ -207,17 +208,18 @@ func (cmd Generate) Run(ctx context.Context) (err error) { // Read errors. for err := range errs { - if err != nil { - if errors.Is(err, context.Canceled) { - cmd.Log.Debug("Context cancelled, exiting") - return nil - } - if errors.Is(err, FatalError{}) { - cmd.Log.Debug("Fatal error, exiting") - return err - } - cmd.Log.Error("Error received", slog.Any("error", err)) + if err == nil { + continue + } + if errors.Is(err, context.Canceled) { + cmd.Log.Debug("Context cancelled, exiting") + return nil + } + if errors.Is(err, FatalError{}) { + cmd.Log.Debug("Fatal error, exiting") + return err } + cmd.Log.Error("Error received", slog.Any("error", err)) } // Wait for everything to complete. From 47eafdd5440a7305fcc11cddbea09fd7efbc8710 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 15:57:38 +0000 Subject: [PATCH 04/16] feat: improve log --- cmd/templ/generatecmd/eventhandler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/eventhandler.go b/cmd/templ/generatecmd/eventhandler.go index 8db17ccd2..70c786d8e 100644 --- a/cmd/templ/generatecmd/eventhandler.go +++ b/cmd/templ/generatecmd/eventhandler.go @@ -117,7 +117,7 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) } return } - h.Log.Debug("Generated code for %q in %s\n", event.Name, time.Since(start)) + h.Log.Debug("Generated code", slog.String("file", event.Name), slog.Duration("in", time.Since(start))) return true, nil } From 8d0b76703714d883078062bb1e3cb1b82fd1103b Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 15:59:22 +0000 Subject: [PATCH 05/16] feat: wait for completion of gouroutines before moving on --- cmd/templ/generatecmd/cmd.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index 321a707e8..8dc28dccd 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -165,6 +165,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { } }(event) } + eventsWG.Wait() }() // Start process to handle post-generation events. From c4f40435299efb7f84fab564a53227d26a8fd284 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 16:10:50 +0000 Subject: [PATCH 06/16] feat: use mutex to prevent concurrent map read/write --- cmd/templ/generatecmd/eventhandler.go | 81 +++++++++++++++++---------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/cmd/templ/generatecmd/eventhandler.go b/cmd/templ/generatecmd/eventhandler.go index 70c786d8e..e0cc33046 100644 --- a/cmd/templ/generatecmd/eventhandler.go +++ b/cmd/templ/generatecmd/eventhandler.go @@ -27,15 +27,17 @@ func NewFSEventHandler(log *slog.Logger, dir string, devMode bool, genOpts []gen dir, _ = filepath.Abs(dir) } fseh := &FSEventHandler{ - Log: log, - dir: dir, - stdout: os.Stdout, - fileNameToLastModTime: make(map[string]time.Time), - hashes: make(map[string][sha256.Size]byte), - genOpts: genOpts, - genSourceMapVis: genSourceMapVis, - DevMode: devMode, - keepOrphanedFiles: keepOrphanedFiles, + Log: log, + dir: dir, + stdout: os.Stdout, + fileNameToLastModTime: make(map[string]time.Time), + fileNameToLastModTimeMutex: &sync.Mutex{}, + hashes: make(map[string][sha256.Size]byte), + hashesMutex: &sync.Mutex{}, + genOpts: genOpts, + genSourceMapVis: genSourceMapVis, + DevMode: devMode, + keepOrphanedFiles: keepOrphanedFiles, } if devMode { fseh.genOpts = append(fseh.genOpts, generator.WithExtractStrings()) @@ -46,15 +48,17 @@ func NewFSEventHandler(log *slog.Logger, dir string, devMode bool, genOpts []gen type FSEventHandler struct { Log *slog.Logger // dir is the root directory being processed. - dir string - stdout io.Writer - stderr io.Writer - fileNameToLastModTime map[string]time.Time - hashes map[string][sha256.Size]byte - genOpts []generator.GenerateOpt - genSourceMapVis bool - DevMode bool - keepOrphanedFiles bool + dir string + stdout io.Writer + stderr io.Writer + fileNameToLastModTime map[string]time.Time + fileNameToLastModTimeMutex *sync.Mutex + hashes map[string][sha256.Size]byte + hashesMutex *sync.Mutex + genOpts []generator.GenerateOpt + genSourceMapVis bool + DevMode bool + keepOrphanedFiles bool } func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) (generated bool, err error) { @@ -93,18 +97,11 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) } // If the file hasn't been updated since the last time we processed it, ignore it. - lastModTime := h.fileNameToLastModTime[event.Name] - fileInfo, err := os.Stat(event.Name) - if err != nil { - return false, fmt.Errorf("failed to get file info: %w", err) - } - if fileInfo.ModTime().Before(lastModTime) { + if !h.UpsertLastModTime(event.Name) { return false, nil } // Start a processor. - h.fileNameToLastModTime[event.Name] = fileInfo.ModTime() - start := time.Now() diag, err := h.generate(ctx, event.Name) if err != nil { @@ -122,6 +119,32 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) return true, nil } +func (h *FSEventHandler) UpsertLastModTime(fileName string) (updated bool) { + fileInfo, err := os.Stat(fileName) + if err != nil { + return false + } + h.fileNameToLastModTimeMutex.Lock() + defer h.fileNameToLastModTimeMutex.Unlock() + lastModTime := h.fileNameToLastModTime[fileName] + if !fileInfo.ModTime().After(lastModTime) { + return false + } + h.fileNameToLastModTime[fileName] = fileInfo.ModTime() + return true +} + +func (h *FSEventHandler) UpsertHash(fileName string, hash [sha256.Size]byte) (updated bool) { + h.hashesMutex.Lock() + defer h.hashesMutex.Unlock() + lastHash := h.hashes[fileName] + if lastHash == hash { + return false + } + h.hashes[fileName] = hash + return true +} + // generate Go code for a single template. // If a basePath is provided, the filename included in error messages is relative to it. func (h *FSEventHandler) generate(ctx context.Context, fileName string) (diagnostics []parser.Diagnostic, err error) { @@ -150,22 +173,20 @@ func (h *FSEventHandler) generate(ctx context.Context, fileName string) (diagnos // Hash output, and write out the file if the goCodeHash has changed. goCodeHash := sha256.Sum256(formattedGoCode) - if h.hashes[targetFileName] != goCodeHash { + if h.UpsertHash(targetFileName, goCodeHash) { if err = os.WriteFile(targetFileName, formattedGoCode, 0o644); err != nil { return nil, fmt.Errorf("failed to write target file %q: %w", targetFileName, err) } - h.hashes[targetFileName] = goCodeHash } // Add the txt file if it has changed. if len(literals) > 0 { txtFileName := strings.TrimSuffix(fileName, ".templ") + "_templ.txt" txtHash := sha256.Sum256([]byte(literals)) - if h.hashes[txtFileName] != txtHash { + if h.UpsertHash(txtFileName, txtHash) { if err = os.WriteFile(txtFileName, []byte(literals), 0o644); err != nil { return nil, fmt.Errorf("failed to write string literal file %q: %w", txtFileName, err) } - h.hashes[txtFileName] = txtHash } } From a9b75a77506f312a13d9857435c1b7cc10fabdc9 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 16:20:38 +0000 Subject: [PATCH 07/16] notice that the channel has been closed in the select --- cmd/templ/generatecmd/cmd.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index 8dc28dccd..a584b2eb8 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -89,7 +89,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { // For errs from the watcher. errs := make(chan error) // For triggering actions after generation has completed. - postGeneration := make(chan struct{}, 256) + postGeneration := make(chan *fsnotify.Event, 256) // Used to check that the post-generation handler has completed. var postGenerationWG sync.WaitGroup @@ -161,7 +161,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { errs <- err } if generated { - postGeneration <- struct{}{} + postGeneration <- &event } }(event) } @@ -176,9 +176,13 @@ func (cmd Generate) Run(ctx context.Context) (err error) { cmd.Log.Debug("Starting post-generation handler") timeout := time.NewTimer(time.Hour * 24 * 365) var p *proxy.Handler + loop: for range postGeneration { select { - case <-postGeneration: + case v := <-postGeneration: + if v == nil { + break loop + } if !timeout.Stop() { <-timeout.C } From dd1ee1e752c20db660a9af8fc0d99dea28a50995 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 18:22:21 +0000 Subject: [PATCH 08/16] feat: configure logging --- cmd/templ/generatecmd/cmd.go | 21 +++-- cmd/templ/generatecmd/eventhandler.go | 7 +- cmd/templ/generatecmd/main.go | 11 ++- cmd/templ/generatecmd/watcher/watch.go | 20 +++++ cmd/templ/sloghandler/handler.go | 106 +++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 cmd/templ/sloghandler/handler.go diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index a584b2eb8..f313b87e2 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -102,7 +102,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { defer pushHandlerWG.Done() defer close(events) defer close(errs) - cmd.Log.Info("Walking directory", slog.String("path", cmd.Args.Path), slog.Bool("devMode", cmd.Args.Watch)) + cmd.Log.Debug("Walking directory", slog.String("path", cmd.Args.Path), slog.Bool("devMode", cmd.Args.Watch)) err = watcher.WalkFiles(ctx, cmd.Args.Path, events) if err != nil { cmd.Log.Error("WalkFiles failed, exiting", slog.Any("error", err)) @@ -149,10 +149,10 @@ func (cmd Generate) Run(ctx context.Context) (err error) { defer close(postGeneration) cmd.Log.Debug("Starting event handler") for event := range events { - cmd.Log.Debug("Event received, waiting for queue slot", slog.Any("event", event)) eventsWG.Add(1) sem <- struct{}{} go func(event fsnotify.Event) { + cmd.Log.Debug("Processing file", slog.String("file", event.Name)) defer eventsWG.Done() defer func() { <-sem }() generated, err := fseh.HandleEvent(ctx, event) @@ -165,24 +165,26 @@ func (cmd Generate) Run(ctx context.Context) (err error) { } }(event) } + // Wait for all events to be processed before closing. eventsWG.Wait() }() // Start process to handle post-generation events. postGenerationWG.Add(1) - var firstPostGeneration bool + var firstPostGenerationExecuted bool go func() { defer postGenerationWG.Done() cmd.Log.Debug("Starting post-generation handler") timeout := time.NewTimer(time.Hour * 24 * 365) var p *proxy.Handler - loop: - for range postGeneration { + for { select { case v := <-postGeneration: if v == nil { - break loop + cmd.Log.Debug("Post-generation event channel closed, exiting") + return } + // Reset timer. if !timeout.Stop() { <-timeout.C } @@ -195,9 +197,9 @@ func (cmd Generate) Run(ctx context.Context) (err error) { cmd.Log.Error("Error executing command", slog.Any("error", err)) } } - if firstPostGeneration { + if !firstPostGenerationExecuted { cmd.Log.Debug("First post-generation event received, starting proxy") - firstPostGeneration = false + firstPostGenerationExecuted = true p, err = cmd.StartProxy(ctx) if err != nil { cmd.Log.Error("Failed to start proxy", slog.Any("error", err)) @@ -205,8 +207,11 @@ func (cmd Generate) Run(ctx context.Context) (err error) { } // Send server-sent event. if p != nil { + cmd.Log.Debug("Sending reload event") p.SendSSE("message", "reload") } + // Reset timer. + timeout.Reset(time.Millisecond * 100) } } }() diff --git a/cmd/templ/generatecmd/eventhandler.go b/cmd/templ/generatecmd/eventhandler.go index e0cc33046..88ddfe81c 100644 --- a/cmd/templ/generatecmd/eventhandler.go +++ b/cmd/templ/generatecmd/eventhandler.go @@ -65,13 +65,14 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) // Handle _templ.go files. if !event.Has(fsnotify.Remove) && strings.HasSuffix(event.Name, "_templ.go") { _, err = os.Stat(strings.TrimSuffix(event.Name, "_templ.go") + ".templ") - if err != nil { - return false, nil + if !os.IsNotExist(err) { + return false, err } // File is orphaned. if h.keepOrphanedFiles { return false, nil } + h.Log.Debug("Deleting orphaned Go file", slog.String("file", event.Name)) if err = os.Remove(event.Name); err != nil { h.Log.Warn("Failed to remove orphaned file", slog.Any("error", err)) } @@ -83,11 +84,11 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) // Don't do anything in watch mode. return false, nil } + h.Log.Debug("Deleting watch mode file", slog.String("file", event.Name)) if err = os.Remove(event.Name); err != nil { h.Log.Warn("Failed to remove watch mode text file", slog.Any("error", err)) return false, nil } - h.Log.Debug("Deleted watch mode file", slog.String("file", event.Name)) return false, nil } diff --git a/cmd/templ/generatecmd/main.go b/cmd/templ/generatecmd/main.go index a287e968c..d24ba43f0 100644 --- a/cmd/templ/generatecmd/main.go +++ b/cmd/templ/generatecmd/main.go @@ -8,6 +8,8 @@ import ( "runtime" _ "net/http/pprof" + + "github.com/a-h/templ/cmd/templ/sloghandler" ) type Arguments struct { @@ -32,14 +34,17 @@ var defaultWorkerCount = runtime.NumCPU() func Run(ctx context.Context, stderr io.Writer, args Arguments) (err error) { level := slog.LevelWarn.Level() - if args.Level == "debug" || args.Level == "verbose" { + if args.Level == "debug" { level = slog.LevelDebug.Level() } if args.Level == "info" { level = slog.LevelInfo.Level() } - log := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{ - AddSource: true, + // The built-in attributes with keys "time", "level", "source", and "msg" + // are passed to this function, except that time is omitted + // if zero, and source is omitted if AddSource is false. + log := slog.New(sloghandler.NewHandler(stderr, &slog.HandlerOptions{ + AddSource: args.Level == "debug", Level: level, })) return NewGenerate(log, args).Run(ctx) diff --git a/cmd/templ/generatecmd/watcher/watch.go b/cmd/templ/generatecmd/watcher/watch.go index 185e1c294..36d66aa57 100644 --- a/cmd/templ/generatecmd/watcher/watch.go +++ b/cmd/templ/generatecmd/watcher/watch.go @@ -35,6 +35,9 @@ func WalkFiles(ctx context.Context, path string, out chan fsnotify.Event) (err e if info.IsDir() && shouldSkipDir(path) { return filepath.SkipDir } + if !shouldIncludeFile(path) { + return nil + } out <- fsnotify.Event{ Name: path, Op: fsnotify.Create, @@ -43,6 +46,19 @@ func WalkFiles(ctx context.Context, path string, out chan fsnotify.Event) (err e }) } +func shouldIncludeFile(name string) bool { + if strings.HasSuffix(name, ".templ") { + return true + } + if strings.HasSuffix(name, "_templ.go") { + return true + } + if strings.HasSuffix(name, "_templ.txt") { + return true + } + return false +} + type RecursiveWatcher struct { ctx context.Context w *fsnotify.Watcher @@ -66,6 +82,10 @@ func (w *RecursiveWatcher) loop() error { if event.Has(fsnotify.Create) { w.Add(event.Name) } + // Only notify on templ related files. + if !shouldIncludeFile(event.Name) { + continue + } w.Events <- event case err, ok := <-w.w.Errors: if !ok { diff --git a/cmd/templ/sloghandler/handler.go b/cmd/templ/sloghandler/handler.go new file mode 100644 index 000000000..99d20381e --- /dev/null +++ b/cmd/templ/sloghandler/handler.go @@ -0,0 +1,106 @@ +package sloghandler + +import ( + "context" + "io" + "log/slog" + "strings" + "sync" + + "github.com/fatih/color" +) + +var _ slog.Handler = &Handler{} + +type Options struct { + slog.HandlerOptions +} + +type Handler struct { + h slog.Handler + m *sync.Mutex + w io.Writer +} + +var levelToIcon = map[slog.Level]string{ + slog.LevelDebug: "(✓)", + slog.LevelInfo: "(✓)", + slog.LevelWarn: "(!)", + slog.LevelError: "(✗)", +} +var levelToColor = map[slog.Level]color.Attribute{ + slog.LevelDebug: color.FgCyan, + slog.LevelInfo: color.FgGreen, + slog.LevelWarn: color.FgYellow, + slog.LevelError: color.FgRed, +} + +func NewHandler(w io.Writer, opts *slog.HandlerOptions) *Handler { + if opts == nil { + opts = &slog.HandlerOptions{} + } + return &Handler{ + w: w, + h: slog.NewTextHandler(w, &slog.HandlerOptions{ + Level: opts.Level, + AddSource: opts.AddSource, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if opts.ReplaceAttr != nil { + a = opts.ReplaceAttr(groups, a) + } + if a.Key == slog.LevelKey { + level, ok := levelToIcon[a.Value.Any().(slog.Level)] + if !ok { + level = a.Value.Any().(slog.Level).String() + } + a.Value = slog.StringValue(level) + return a + } + if a.Key == slog.TimeKey { + return slog.Attr{} + } + return a + }, + }), + m: &sync.Mutex{}, + } +} + +func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool { + return h.h.Enabled(ctx, level) +} + +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &Handler{h: h.h.WithAttrs(attrs), w: h.w, m: h.m} +} + +func (h *Handler) WithGroup(name string) slog.Handler { + return &Handler{h: h.h.WithGroup(name), w: h.w, m: h.m} +} + +var keyColor color.Attribute = color.Faint & color.FgBlack +var valueColor color.Attribute = color.Faint & color.FgBlack + +func (h *Handler) Handle(ctx context.Context, r slog.Record) (err error) { + var sb strings.Builder + + sb.WriteString(color.New(levelToColor[r.Level]).Sprint(levelToIcon[r.Level])) + sb.WriteString(" ") + sb.WriteString(r.Message) + + if r.NumAttrs() != 0 { + sb.WriteString(" [") + r.Attrs(func(a slog.Attr) bool { + sb.WriteString(color.New(keyColor).Sprintf(" %s=%s", a.Key, a.Value.String())) + return true + }) + sb.WriteString(" ]") + } + + sb.WriteString("\n") + + h.m.Lock() + defer h.m.Unlock() + _, err = io.WriteString(h.w, sb.String()) + return err +} From 1f7408b5d8b3bb74390713338df87a9915c391c8 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 19:36:04 +0000 Subject: [PATCH 09/16] feat: fix up proxy --- cmd/templ/generatecmd/cmd.go | 48 +++++++++++++++++++-------- cmd/templ/generatecmd/eventhandler.go | 45 +++++++++++++------------ cmd/templ/generatecmd/sse/server.go | 3 +- examples/counter-basic/main.go | 2 +- 4 files changed, 62 insertions(+), 36 deletions(-) diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index f313b87e2..699f82e66 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -35,6 +35,12 @@ type Generate struct { Args *Arguments } +type GenerationEvent struct { + Event fsnotify.Event + GoUpdated bool + TextUpdated bool +} + func (cmd Generate) Run(ctx context.Context) (err error) { if cmd.Args.Watch && cmd.Args.FileName != "" { return fmt.Errorf("cannot watch a single file, remove the -f or -watch flag") @@ -65,14 +71,14 @@ func (cmd Generate) Run(ctx context.Context) (err error) { // Check the version of the templ module. if err := modcheck.Check(cmd.Args.Path); err != nil { - cmd.Log.Warn("templ version check failed", slog.Any("error", err)) + cmd.Log.Warn("templ version check: " + err.Error()) } fseh := NewFSEventHandler(cmd.Log, cmd.Args.Path, cmd.Args.Watch, opts, cmd.Args.GenerateSourceMapVisualisations, cmd.Args.KeepOrphanedFiles) // If we're processing a single file, don't bother setting up the channels/multithreaing. if cmd.Args.FileName != "" { - _, err = fseh.HandleEvent(ctx, fsnotify.Event{ + _, _, err = fseh.HandleEvent(ctx, fsnotify.Event{ Name: cmd.Args.FileName, Op: fsnotify.Create, }) @@ -89,9 +95,10 @@ func (cmd Generate) Run(ctx context.Context) (err error) { // For errs from the watcher. errs := make(chan error) // For triggering actions after generation has completed. - postGeneration := make(chan *fsnotify.Event, 256) + postGeneration := make(chan *GenerationEvent, 256) // Used to check that the post-generation handler has completed. var postGenerationWG sync.WaitGroup + var postGenerationEventsWG sync.WaitGroup // Waitgroup for the push process. var pushHandlerWG sync.WaitGroup @@ -129,8 +136,8 @@ func (cmd Generate) Run(ctx context.Context) (err error) { } cmd.Log.Debug("Waiting for events to be processed") eventsWG.Wait() - cmd.Log.Debug("All pending events processed, waitinf for post-generation to complete") - postGenerationWG.Wait() + cmd.Log.Debug("All pending events processed, waiting for pending post-generation events to complete") + postGenerationEventsWG.Wait() cmd.Log.Debug("All post-generation events processed, running walk again, but in production mode") fseh.DevMode = false err = watcher.WalkFiles(ctx, cmd.Args.Path, events) @@ -155,13 +162,17 @@ func (cmd Generate) Run(ctx context.Context) (err error) { cmd.Log.Debug("Processing file", slog.String("file", event.Name)) defer eventsWG.Done() defer func() { <-sem }() - generated, err := fseh.HandleEvent(ctx, event) + goUpdated, textUpdated, err := fseh.HandleEvent(ctx, event) if err != nil { cmd.Log.Error("Event handler failed", slog.Any("error", err)) errs <- err } - if generated { - postGeneration <- &event + if goUpdated || textUpdated { + postGeneration <- &GenerationEvent{ + Event: event, + GoUpdated: goUpdated, + TextUpdated: textUpdated, + } } }(event) } @@ -176,22 +187,30 @@ func (cmd Generate) Run(ctx context.Context) (err error) { defer postGenerationWG.Done() cmd.Log.Debug("Starting post-generation handler") timeout := time.NewTimer(time.Hour * 24 * 365) + var goUpdated, textUpdated bool var p *proxy.Handler for { select { - case v := <-postGeneration: - if v == nil { + case ge := <-postGeneration: + if ge == nil { cmd.Log.Debug("Post-generation event channel closed, exiting") return } + goUpdated = goUpdated || ge.GoUpdated + textUpdated = textUpdated || ge.TextUpdated // Reset timer. if !timeout.Stop() { <-timeout.C } timeout.Reset(time.Millisecond * 100) case <-timeout.C: - cmd.Log.Debug("No more post-generation events received for at least 100ms") - if cmd.Args.Command != "" { + if !goUpdated && !textUpdated { + // Nothing to process, reset timer and wait again. + timeout.Reset(time.Hour * 24 * 365) + break + } + postGenerationEventsWG.Add(1) + if cmd.Args.Command != "" && goUpdated { cmd.Log.Debug("Executing command", slog.String("command", cmd.Args.Command)) if _, err := run.Run(ctx, cmd.Args.Path, cmd.Args.Command); err != nil { cmd.Log.Error("Error executing command", slog.Any("error", err)) @@ -206,12 +225,15 @@ func (cmd Generate) Run(ctx context.Context) (err error) { } } // Send server-sent event. - if p != nil { + if p != nil && textUpdated || goUpdated { cmd.Log.Debug("Sending reload event") p.SendSSE("message", "reload") } + postGenerationEventsWG.Done() // Reset timer. timeout.Reset(time.Millisecond * 100) + textUpdated = false + goUpdated = false } } }() diff --git a/cmd/templ/generatecmd/eventhandler.go b/cmd/templ/generatecmd/eventhandler.go index 88ddfe81c..d4b2a8534 100644 --- a/cmd/templ/generatecmd/eventhandler.go +++ b/cmd/templ/generatecmd/eventhandler.go @@ -61,53 +61,53 @@ type FSEventHandler struct { keepOrphanedFiles bool } -func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) (generated bool, err error) { +func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) (goUpdated, textUpdated bool, err error) { // Handle _templ.go files. if !event.Has(fsnotify.Remove) && strings.HasSuffix(event.Name, "_templ.go") { _, err = os.Stat(strings.TrimSuffix(event.Name, "_templ.go") + ".templ") if !os.IsNotExist(err) { - return false, err + return false, false, err } // File is orphaned. if h.keepOrphanedFiles { - return false, nil + return false, false, nil } h.Log.Debug("Deleting orphaned Go file", slog.String("file", event.Name)) if err = os.Remove(event.Name); err != nil { h.Log.Warn("Failed to remove orphaned file", slog.Any("error", err)) } - return true, nil + return true, false, nil } // Handle _templ.txt files. if !event.Has(fsnotify.Remove) && strings.HasSuffix(event.Name, "_templ.txt") { if h.DevMode { - // Don't do anything in watch mode. - return false, nil + // Don't delete the file if we're in dev mode, but mark that text was updated. + return false, true, nil } h.Log.Debug("Deleting watch mode file", slog.String("file", event.Name)) if err = os.Remove(event.Name); err != nil { h.Log.Warn("Failed to remove watch mode text file", slog.Any("error", err)) - return false, nil + return false, false, nil } - return false, nil + return false, false, nil } // Handle .templ files. if !strings.HasSuffix(event.Name, ".templ") { - return false, nil + return false, false, nil } // If the file hasn't been updated since the last time we processed it, ignore it. if !h.UpsertLastModTime(event.Name) { - return false, nil + return false, false, nil } // Start a processor. start := time.Now() - diag, err := h.generate(ctx, event.Name) + goUpdated, textUpdated, diag, err := h.generate(ctx, event.Name) if err != nil { h.Log.Error("Error generating code", slog.String("file", event.Name), slog.Any("error", err)) - return false, fmt.Errorf("failed to generate code for %q: %w", event.Name, err) + return goUpdated, textUpdated, fmt.Errorf("failed to generate code for %q: %w", event.Name, err) } if len(diag) > 0 { for _, d := range diag { @@ -117,7 +117,7 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) } h.Log.Debug("Generated code", slog.String("file", event.Name), slog.Duration("in", time.Since(start))) - return true, nil + return goUpdated, textUpdated, nil } func (h *FSEventHandler) UpsertLastModTime(fileName string) (updated bool) { @@ -148,35 +148,36 @@ func (h *FSEventHandler) UpsertHash(fileName string, hash [sha256.Size]byte) (up // generate Go code for a single template. // If a basePath is provided, the filename included in error messages is relative to it. -func (h *FSEventHandler) generate(ctx context.Context, fileName string) (diagnostics []parser.Diagnostic, err error) { +func (h *FSEventHandler) generate(ctx context.Context, fileName string) (goUpdated, textUpdated bool, diagnostics []parser.Diagnostic, err error) { t, err := parser.Parse(fileName) if err != nil { - return nil, fmt.Errorf("%s parsing error: %w", fileName, err) + return false, false, nil, fmt.Errorf("%s parsing error: %w", fileName, err) } targetFileName := strings.TrimSuffix(fileName, ".templ") + "_templ.go" // Only use relative filenames to the basepath for filenames in runtime error messages. relFilePath, err := filepath.Rel(h.dir, fileName) if err != nil { - return nil, fmt.Errorf("failed to get relative path for %q: %w", fileName, err) + return false, false, nil, fmt.Errorf("failed to get relative path for %q: %w", fileName, err) } var b bytes.Buffer sourceMap, literals, err := generator.Generate(t, &b, append(h.genOpts, generator.WithFileName(relFilePath))...) if err != nil { - return nil, fmt.Errorf("%s generation error: %w", fileName, err) + return false, false, nil, fmt.Errorf("%s generation error: %w", fileName, err) } formattedGoCode, err := format.Source(b.Bytes()) if err != nil { - return nil, fmt.Errorf("%s source formatting error: %w", fileName, err) + return false, false, nil, fmt.Errorf("%s source formatting error: %w", fileName, err) } // Hash output, and write out the file if the goCodeHash has changed. goCodeHash := sha256.Sum256(formattedGoCode) if h.UpsertHash(targetFileName, goCodeHash) { + goUpdated = true if err = os.WriteFile(targetFileName, formattedGoCode, 0o644); err != nil { - return nil, fmt.Errorf("failed to write target file %q: %w", targetFileName, err) + return false, false, nil, fmt.Errorf("failed to write target file %q: %w", targetFileName, err) } } @@ -185,8 +186,9 @@ func (h *FSEventHandler) generate(ctx context.Context, fileName string) (diagnos txtFileName := strings.TrimSuffix(fileName, ".templ") + "_templ.txt" txtHash := sha256.Sum256([]byte(literals)) if h.UpsertHash(txtFileName, txtHash) { + textUpdated = true if err = os.WriteFile(txtFileName, []byte(literals), 0o644); err != nil { - return nil, fmt.Errorf("failed to write string literal file %q: %w", txtFileName, err) + return false, false, nil, fmt.Errorf("failed to write string literal file %q: %w", txtFileName, err) } } } @@ -194,7 +196,8 @@ func (h *FSEventHandler) generate(ctx context.Context, fileName string) (diagnos if h.genSourceMapVis { err = generateSourceMapVisualisation(ctx, fileName, targetFileName, sourceMap) } - return t.Diagnostics, err + + return goUpdated, textUpdated, t.Diagnostics, err } func generateSourceMapVisualisation(ctx context.Context, templFileName, goFileName string, sourceMap *parser.SourceMap) error { diff --git a/cmd/templ/generatecmd/sse/server.go b/cmd/templ/generatecmd/sse/server.go index c847ed982..fb7fe923d 100644 --- a/cmd/templ/generatecmd/sse/server.go +++ b/cmd/templ/generatecmd/sse/server.go @@ -50,8 +50,10 @@ func (s *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Connection", "keep-alive") id := atomic.AddInt64(&s.counter, 1) + s.m.Lock() events := make(chan event) s.requests[id] = events + s.m.Unlock() defer func() { s.m.Lock() defer s.m.Unlock() @@ -70,7 +72,6 @@ loop: } timer.Reset(time.Second * 5) case e := <-events: - fmt.Println("Sending reload event...") if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Type, e.Data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/examples/counter-basic/main.go b/examples/counter-basic/main.go index 4c9cb95bb..6c10367ee 100644 --- a/examples/counter-basic/main.go +++ b/examples/counter-basic/main.go @@ -63,7 +63,7 @@ func main() { // Start the server. fmt.Println("listening on :8080") - if err := http.ListenAndServe(":8080", muxWithSessionMiddleware); err != nil { + if err := http.ListenAndServe("127.0.0.1:8080", muxWithSessionMiddleware); err != nil { log.Printf("error listening: %v", err) } } From d07f47050f45ee920f3346dd142006f406b6ddd1 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 19:48:06 +0000 Subject: [PATCH 10/16] feat: update proxy command --- cmd/templ/generatecmd/cmd.go | 11 +++++++++++ cmd/templ/generatecmd/main.go | 12 +++++++----- cmd/templ/main.go | 4 ++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index 699f82e66..e546244fa 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -181,6 +181,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { }() // Start process to handle post-generation events. + var updates int postGenerationWG.Add(1) var firstPostGenerationExecuted bool go func() { @@ -198,6 +199,9 @@ func (cmd Generate) Run(ctx context.Context) (err error) { } goUpdated = goUpdated || ge.GoUpdated textUpdated = textUpdated || ge.TextUpdated + if goUpdated || textUpdated { + updates++ + } // Reset timer. if !timeout.Stop() { <-timeout.C @@ -261,6 +265,13 @@ func (cmd Generate) Run(ctx context.Context) (err error) { eventHandlerWG.Wait() cmd.Log.Debug("Waiting for post-generation handler to complete") postGenerationWG.Wait() + if cmd.Args.Command != "" { + cmd.Log.Debug("Killing command", slog.String("command", cmd.Args.Command)) + if err := run.KillAll(); err != nil { + cmd.Log.Error("Error killing command", slog.Any("error", err)) + } + } + cmd.Log.Info("Complete", slog.Int("updates", updates)) return nil } diff --git a/cmd/templ/generatecmd/main.go b/cmd/templ/generatecmd/main.go index d24ba43f0..86276c6bf 100644 --- a/cmd/templ/generatecmd/main.go +++ b/cmd/templ/generatecmd/main.go @@ -33,12 +33,14 @@ type Arguments struct { var defaultWorkerCount = runtime.NumCPU() func Run(ctx context.Context, stderr io.Writer, args Arguments) (err error) { - level := slog.LevelWarn.Level() - if args.Level == "debug" { + level := slog.LevelInfo.Level() + switch args.Level { + case "debug": level = slog.LevelDebug.Level() - } - if args.Level == "info" { - level = slog.LevelInfo.Level() + case "warn": + level = slog.LevelWarn.Level() + case "error": + level = slog.LevelError.Level() } // The built-in attributes with keys "time", "level", "source", and "msg" // are passed to this function, except that time is omitted diff --git a/cmd/templ/main.go b/cmd/templ/main.go index a55f08e0e..21db70933 100644 --- a/cmd/templ/main.go +++ b/cmd/templ/main.go @@ -93,7 +93,7 @@ Args: -keep-orphaned-files Keeps orphaned generated templ files. (default false) -level - Log verbosity level. (default "warn") + Log verbosity level. (default "info") -help Print help and exit. @@ -128,7 +128,7 @@ func generateCmd(w io.Writer, args []string) (code int) { workerCountFlag := cmd.Int("w", runtime.NumCPU(), "") pprofPortFlag := cmd.Int("pprof", 0, "") keepOrphanedFilesFlag := cmd.Bool("keep-orphaned-files", false, "") - levelFlag := cmd.String("level", "warn", "") + levelFlag := cmd.String("level", "info", "") helpFlag := cmd.Bool("help", false, "") err := cmd.Parse(args) if err != nil || *helpFlag { From f91de76cc7f62c000b14af89aeeb3bcbdb5be555 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 20:12:11 +0000 Subject: [PATCH 11/16] fix: invalid boolean expression grouping --- cmd/templ/generatecmd/cmd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index e546244fa..bd21e3259 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -229,7 +229,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { } } // Send server-sent event. - if p != nil && textUpdated || goUpdated { + if p != nil && (textUpdated || goUpdated) { cmd.Log.Debug("Sending reload event") p.SendSSE("message", "reload") } From 83c155c091f0dc89f721834c7f4c43f6ee50c575 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 20:18:21 +0000 Subject: [PATCH 12/16] feat: set the default number of workers --- cmd/templ/generatecmd/cmd.go | 9 +++++++-- cmd/templ/generatecmd/main.go | 3 --- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index bd21e3259..8c8aa8e38 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -9,6 +9,7 @@ import ( "net/url" "path" "path/filepath" + "runtime" "sync" "time" @@ -23,11 +24,15 @@ import ( "github.com/fsnotify/fsnotify" ) -func NewGenerate(log *slog.Logger, args Arguments) *Generate { - return &Generate{ +func NewGenerate(log *slog.Logger, args Arguments) (g *Generate) { + g = &Generate{ Log: log, Args: &args, } + if g.Args.WorkerCount == 0 { + g.Args.WorkerCount = runtime.NumCPU() + } + return g } type Generate struct { diff --git a/cmd/templ/generatecmd/main.go b/cmd/templ/generatecmd/main.go index 86276c6bf..0669ccf5f 100644 --- a/cmd/templ/generatecmd/main.go +++ b/cmd/templ/generatecmd/main.go @@ -5,7 +5,6 @@ import ( _ "embed" "io" "log/slog" - "runtime" _ "net/http/pprof" @@ -30,8 +29,6 @@ type Arguments struct { KeepOrphanedFiles bool } -var defaultWorkerCount = runtime.NumCPU() - func Run(ctx context.Context, stderr io.Writer, args Arguments) (err error) { level := slog.LevelInfo.Level() switch args.Level { From 9928921ee4bbba725e326d523006ab7e177171c0 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 20:24:53 +0000 Subject: [PATCH 13/16] chore: fix lint issues --- cmd/templ/generatecmd/cmd.go | 9 +++------ cmd/templ/generatecmd/eventhandler.go | 4 ---- cmd/templ/generatecmd/watcher/watch.go | 12 +++++++----- cmd/templ/sloghandler/handler.go | 17 ++++++++--------- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index 8c8aa8e38..4dc4e1ced 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -115,8 +115,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { defer close(events) defer close(errs) cmd.Log.Debug("Walking directory", slog.String("path", cmd.Args.Path), slog.Bool("devMode", cmd.Args.Watch)) - err = watcher.WalkFiles(ctx, cmd.Args.Path, events) - if err != nil { + if err := watcher.WalkFiles(ctx, cmd.Args.Path, events); err != nil { cmd.Log.Error("WalkFiles failed, exiting", slog.Any("error", err)) errs <- FatalError{Err: fmt.Errorf("failed to walk files: %w", err)} return @@ -135,9 +134,8 @@ func (cmd Generate) Run(ctx context.Context) (err error) { cmd.Log.Debug("Waiting for context to be cancelled to stop watching files") <-ctx.Done() cmd.Log.Debug("Context cancelled, closing watcher") - if err = rw.Close(); err != nil { + if err := rw.Close(); err != nil { cmd.Log.Error("Failed to close watcher", slog.Any("error", err)) - err = nil } cmd.Log.Debug("Waiting for events to be processed") eventsWG.Wait() @@ -145,8 +143,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { postGenerationEventsWG.Wait() cmd.Log.Debug("All post-generation events processed, running walk again, but in production mode") fseh.DevMode = false - err = watcher.WalkFiles(ctx, cmd.Args.Path, events) - if err != nil { + if err := watcher.WalkFiles(ctx, cmd.Args.Path, events); err != nil { cmd.Log.Error("Post dev mode WalkFiles failed", slog.Any("error", err)) errs <- FatalError{Err: fmt.Errorf("failed to walk files: %w", err)} return diff --git a/cmd/templ/generatecmd/eventhandler.go b/cmd/templ/generatecmd/eventhandler.go index d4b2a8534..eb8ef7e13 100644 --- a/cmd/templ/generatecmd/eventhandler.go +++ b/cmd/templ/generatecmd/eventhandler.go @@ -7,7 +7,6 @@ import ( "crypto/sha256" "fmt" "go/format" - "io" "log/slog" "os" "path" @@ -29,7 +28,6 @@ func NewFSEventHandler(log *slog.Logger, dir string, devMode bool, genOpts []gen fseh := &FSEventHandler{ Log: log, dir: dir, - stdout: os.Stdout, fileNameToLastModTime: make(map[string]time.Time), fileNameToLastModTimeMutex: &sync.Mutex{}, hashes: make(map[string][sha256.Size]byte), @@ -49,8 +47,6 @@ type FSEventHandler struct { Log *slog.Logger // dir is the root directory being processed. dir string - stdout io.Writer - stderr io.Writer fileNameToLastModTime map[string]time.Time fileNameToLastModTimeMutex *sync.Mutex hashes map[string][sha256.Size]byte diff --git a/cmd/templ/generatecmd/watcher/watch.go b/cmd/templ/generatecmd/watcher/watch.go index 36d66aa57..297da8d85 100644 --- a/cmd/templ/generatecmd/watcher/watch.go +++ b/cmd/templ/generatecmd/watcher/watch.go @@ -70,17 +70,19 @@ func (w *RecursiveWatcher) Close() error { return w.w.Close() } -func (w *RecursiveWatcher) loop() error { +func (w *RecursiveWatcher) loop() { for { select { case <-w.ctx.Done(): - return context.Canceled + return case event, ok := <-w.w.Events: if !ok { - return nil + return } if event.Has(fsnotify.Create) { - w.Add(event.Name) + if err := w.Add(event.Name); err != nil { + w.Errors <- err + } } // Only notify on templ related files. if !shouldIncludeFile(event.Name) { @@ -89,7 +91,7 @@ func (w *RecursiveWatcher) loop() error { w.Events <- event case err, ok := <-w.w.Errors: if !ok { - return nil + return } w.Errors <- err } diff --git a/cmd/templ/sloghandler/handler.go b/cmd/templ/sloghandler/handler.go index 99d20381e..9ee5b8cec 100644 --- a/cmd/templ/sloghandler/handler.go +++ b/cmd/templ/sloghandler/handler.go @@ -28,11 +28,11 @@ var levelToIcon = map[slog.Level]string{ slog.LevelWarn: "(!)", slog.LevelError: "(✗)", } -var levelToColor = map[slog.Level]color.Attribute{ - slog.LevelDebug: color.FgCyan, - slog.LevelInfo: color.FgGreen, - slog.LevelWarn: color.FgYellow, - slog.LevelError: color.FgRed, +var levelToColor = map[slog.Level]*color.Color{ + slog.LevelDebug: color.New(color.FgCyan), + slog.LevelInfo: color.New(color.FgGreen), + slog.LevelWarn: color.New(color.FgYellow), + slog.LevelError: color.New(color.FgRed), } func NewHandler(w io.Writer, opts *slog.HandlerOptions) *Handler { @@ -78,20 +78,19 @@ func (h *Handler) WithGroup(name string) slog.Handler { return &Handler{h: h.h.WithGroup(name), w: h.w, m: h.m} } -var keyColor color.Attribute = color.Faint & color.FgBlack -var valueColor color.Attribute = color.Faint & color.FgBlack +var keyValueColor = color.New(color.Faint & color.FgBlack) func (h *Handler) Handle(ctx context.Context, r slog.Record) (err error) { var sb strings.Builder - sb.WriteString(color.New(levelToColor[r.Level]).Sprint(levelToIcon[r.Level])) + sb.WriteString(levelToColor[r.Level].Sprint(levelToIcon[r.Level])) sb.WriteString(" ") sb.WriteString(r.Message) if r.NumAttrs() != 0 { sb.WriteString(" [") r.Attrs(func(a slog.Attr) bool { - sb.WriteString(color.New(keyColor).Sprintf(" %s=%s", a.Key, a.Value.String())) + sb.WriteString(keyValueColor.Sprintf(" %s=%s", a.Key, a.Value.String())) return true }) sb.WriteString(" ]") From 1a1ba9731926fe6382bb45d4d9c773ad75632a3c Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 28 Jan 2024 20:27:48 +0000 Subject: [PATCH 14/16] chore: bump nix deps hash --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 132470a4f..1969ef78a 100644 --- a/flake.nix +++ b/flake.nix @@ -34,7 +34,7 @@ name = "templ"; src = gitignore.lib.gitignoreSource ./.; subPackages = [ "cmd/templ" ]; - vendorHash = "sha256-4tHofTnSNI/MBmrGdGsLNoXjxUC0+Gwp3PzzUwfUkQU="; + vendorHash = "sha256-LQxJ7vFGOiWwUtf+bSUn32W9g5Q67Ul5C3mvnakbyqg="; CGO_ENABLED = 0; flags = [ "-trimpath" From 8f6018461e65ebe3e41e722132d7e7333029469c Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Tue, 30 Jan 2024 08:24:12 +0000 Subject: [PATCH 15/16] refactor: fix a few more minors --- .version | 2 +- cmd/templ/generatecmd/cmd.go | 9 ++++----- cmd/templ/sloghandler/handler.go | 4 ---- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/.version b/.version index 136c78f7c..a212db8ff 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.2.546 \ No newline at end of file +0.2.549 \ No newline at end of file diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index 4dc4e1ced..aa89088c7 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -90,6 +90,9 @@ func (cmd Generate) Run(ctx context.Context) (err error) { return err } + // Start timer. + start := time.Now() + // Create channels: // For the initial filesystem walk and subsequent (optional) fsnotify events. events := make(chan fsnotify.Event) @@ -249,10 +252,6 @@ func (cmd Generate) Run(ctx context.Context) (err error) { if err == nil { continue } - if errors.Is(err, context.Canceled) { - cmd.Log.Debug("Context cancelled, exiting") - return nil - } if errors.Is(err, FatalError{}) { cmd.Log.Debug("Fatal error, exiting") return err @@ -273,7 +272,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) { cmd.Log.Error("Error killing command", slog.Any("error", err)) } } - cmd.Log.Info("Complete", slog.Int("updates", updates)) + cmd.Log.Info("Complete", slog.Int("updates", updates), slog.Duration("duration", time.Since(start))) return nil } diff --git a/cmd/templ/sloghandler/handler.go b/cmd/templ/sloghandler/handler.go index 9ee5b8cec..289405d84 100644 --- a/cmd/templ/sloghandler/handler.go +++ b/cmd/templ/sloghandler/handler.go @@ -12,10 +12,6 @@ import ( var _ slog.Handler = &Handler{} -type Options struct { - slog.HandlerOptions -} - type Handler struct { h slog.Handler m *sync.Mutex From 47d3459852883b8bc3bda2388ef96af3fc762fe9 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Tue, 30 Jan 2024 08:44:45 +0000 Subject: [PATCH 16/16] chore: bump deps and version --- .version | 2 +- flake.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.version b/.version index af4e41cce..a212db8ff 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.2.547 \ No newline at end of file +0.2.549 \ No newline at end of file diff --git a/flake.nix b/flake.nix index 69b11da0c..76070f7a8 100644 --- a/flake.nix +++ b/flake.nix @@ -34,7 +34,7 @@ name = "templ"; src = gitignore.lib.gitignoreSource ./.; subPackages = [ "cmd/templ" ]; - vendorHash = "sha256-Bk895ApJhpIHmQ5hApgdJRAJVZn3PUGJoNO1T7rIPz0="; + vendorHash = "sha256-U/KFUGi47dSE1YxKWOUlxvUR1BKI1snRjZlXZ8hY24c="; CGO_ENABLED = 0; flags = [ "-trimpath"