diff --git a/cmd/root.go b/cmd/root.go index d0d9d3e..5d7979d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,7 +19,7 @@ var ( var rootCmd = &cobra.Command{ Use: "prism", Short: "Prism is a wrapper around go test to make it simple and beautiful", - Long: internal.Header() + "\n\n" + `Prism is a wrapper around Go's built in test command that aims to make it beautiful and organized. + Long: internal.Header() + "\n\n" + `Prism is a wrapper around Go's built in test command that aims to make it beautiful and organized. Issues? Requests? Feedback? Let me know! -- github.com/DaltonSW/prism`, Args: cobra.ArbitraryArgs, @@ -29,6 +29,10 @@ Issues? Requests? Feedback? Let me know! -- github.com/DaltonSW/prism`, } }, Run: func(cmd *cobra.Command, args []string) { + if internal.GlobalConfig.Watch { + internal.Watch(args) + return + } internal.Execute(args) }, } @@ -51,6 +55,7 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&internal.GlobalConfig.Verbose, "verbose", "v", internal.GlobalConfig.Verbose, "Include test sub-output") rootCmd.PersistentFlags().BoolVarP(&internal.GlobalConfig.OnlyFails, "only-fails", "f", internal.GlobalConfig.OnlyFails, "Only run failing tests") rootCmd.PersistentFlags().BoolVar(&internal.GlobalConfig.NoBar, "no-bar", internal.GlobalConfig.NoBar, "Hide the summary bar at the end of test output") + rootCmd.PersistentFlags().BoolVarP(&internal.GlobalConfig.Watch, "watch", "w", internal.GlobalConfig.Watch, "Watch for file changes and rerun tests") rootCmd.PersistentFlags().BoolVar(&internal.GlobalConfig.NoColor, "no-color", internal.GlobalConfig.NoColor, "Disable color output entirely") rootCmd.PersistentFlags().BoolVar(&internal.GlobalConfig.ShowColor, "color", internal.GlobalConfig.ShowColor, "Force color output, overridding NO_COLOR environment variable") diff --git a/go.mod b/go.mod index b7e9c52..b6fd5a5 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,10 @@ require ( github.com/charmbracelet/fang v0.4.2 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea github.com/charmbracelet/log v0.4.2 + github.com/charmbracelet/x/ansi v0.10.1 + github.com/charmbracelet/x/term v0.2.1 + github.com/fsnotify/fsnotify v1.10.1 + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/spf13/cobra v1.9.1 github.com/yarlson/pin v0.9.1 ) @@ -15,10 +19,8 @@ require ( github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect diff --git a/go.sum b/go.sum index 36f0367..0bd03d8 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,11 @@ github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -59,11 +62,15 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -80,5 +87,6 @@ golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config.go b/internal/config.go index 48ac56d..d1c970d 100644 --- a/internal/config.go +++ b/internal/config.go @@ -26,6 +26,7 @@ type Config struct { NoBar bool `json:"no_bar"` NoColor bool `json:"no_color"` ShowColor bool `json:"show_color"` + Watch bool `json:"watch"` } // GlobalConfig holds the active configuration for the current process. diff --git a/internal/watcher.go b/internal/watcher.go new file mode 100644 index 0000000..eb613f4 --- /dev/null +++ b/internal/watcher.go @@ -0,0 +1,218 @@ +package internal + +import ( + "fmt" + "io/fs" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" + "github.com/fsnotify/fsnotify" + ignore "github.com/sabhiram/go-gitignore" +) + +const watchDebounce = 200 * time.Millisecond + +var watchStatusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + +// Watch runs the initial test pass then enters a debounced rerun loop keyed on .go file changes. `r` force rerun, `q` or ctrl+c quits. +func Watch(args []string) { + scope := "./..." + if len(args) > 0 { + scope = strings.Join(args, " ") + } + fmt.Printf("[watch] watching %s\n", scope) + + watcher, err := fsnotify.NewWatcher() + if err != nil { + fmt.Fprintf(os.Stderr, "watcher: %v\n", err) + return + } + defer watcher.Close() + + matcher := loadIgnore(".") + if err := watchRecursive(watcher, ".", matcher, nil); err != nil { + fmt.Fprintf(os.Stderr, "watch setup: %v\n", err) + } + + // Pre-render ui strings to avoid allocations during the run loop. + status := watchStatusStyle.Render("[watch] idle · press r to rerun · q to quit") + clear := ansi.CursorHomePosition + ansi.EraseEntireScreen + ansi.EraseEntireDisplay + + Execute(args) + fmt.Println(status) + + fd := os.Stdin.Fd() + oldState, err := term.MakeRaw(fd) + if err != nil { + fmt.Fprintf(os.Stderr, "raw mode: %v\n", err) + return + } + defer term.Restore(fd, oldState) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + + keyCh := make(chan byte, 1) + go readKeys(keyCh) + + rerun := func(trigger string) { + _ = term.Restore(fd, oldState) + fmt.Printf("%s[watch] trigger: %s\n\n", clear, trigger) + Execute(args) + fmt.Println(status) + if _, err := term.MakeRaw(fd); err != nil { + fmt.Fprintf(os.Stderr, "raw mode: %v\n", err) + } + } + + var ( + timer *time.Timer + timerC <-chan time.Time + lastTrigger string + ) + + schedule := func(trigger string) { + lastTrigger = trigger + if timer == nil { + timer = time.NewTimer(watchDebounce) + } else { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(watchDebounce) + } + timerC = timer.C + } + + for { + select { + case ev, ok := <-watcher.Events: + if !ok { + return + } + if ev.Has(fsnotify.Create) { + if info, err := os.Stat(ev.Name); err == nil && info.IsDir() { + // fsnotify only sees changes from here on; a directory + // that arrived with files already inside it needs a manual nudge. + var foundGoFile string + err := watchRecursive(watcher, ev.Name, matcher, func(p string) { + if foundGoFile == "" { + foundGoFile = p + } + }) + if err != nil { + fmt.Fprintf(os.Stderr, "watch add: %v\n", err) + } + if foundGoFile != "" { + schedule(filepath.Base(foundGoFile)) + } + } + continue + } + if !strings.HasSuffix(ev.Name, ".go") { + continue + } + schedule(filepath.Base(ev.Name)) + + case <-timerC: + timer, timerC = nil, nil + rerun(lastTrigger) + + case b, ok := <-keyCh: + if !ok { + return + } + switch b { + case 'r', 'R': + if timer != nil { + timer.Stop() + timer, timerC = nil, nil + } + rerun("manual") + case 'q', 'Q', 0x03: // Ctrl+C + return + } + + case <-sigCh: + return + + case err, ok := <-watcher.Errors: + if !ok { + return + } + fmt.Fprintf(os.Stderr, "watch error: %v\n", err) + } + } +} + +// readKeys streams bytes from stdin until it hits EOF or a read error. +func readKeys(out chan<- byte) { + defer close(out) + b := make([]byte, 1) + for { + if _, err := os.Stdin.Read(b); err != nil { + return + } + out <- b[0] + } +} + +func loadIgnore(root string) *ignore.GitIgnore { + patterns := []string{".git/"} + path := filepath.Join(root, ".gitignore") + if data, err := os.ReadFile(path); err == nil { + for line := range strings.SplitSeq(string(data), "\n") { + if line != "" { + patterns = append(patterns, line) + } + } + } + return ignore.CompileIgnoreLines(patterns...) +} + +// isIgnored reports whether path matches the .gitignore patterns. isDir +// must reflect whether path is a directory, since gitignore patterns can +// be directory only. +func isIgnored(path string, isDir bool, matcher *ignore.GitIgnore) bool { + if matcher == nil { + return false + } + if isDir { + // Ensure trailing slash for directory matching + if !strings.HasSuffix(path, string(filepath.Separator)) { + path += string(filepath.Separator) + } + } + return matcher.MatchesPath(path) +} + +// watchRecursive adds directories to the watcher. If onGoFile is provided, +// it will call it with the first .go file it encounters. +func watchRecursive(w *fsnotify.Watcher, root string, matcher *ignore.GitIgnore, onGoFile func(string)) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if isIgnored(path, true, matcher) { + return filepath.SkipDir + } + return w.Add(path) + } + if onGoFile != nil && strings.HasSuffix(path, ".go") && !isIgnored(path, false, matcher) { + onGoFile(path) + } + return nil + }) +}