Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
},
}
Expand All @@ -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")
Expand Down
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
1 change: 1 addition & 0 deletions internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
218 changes: 218 additions & 0 deletions internal/watcher.go
Original file line number Diff line number Diff line change
@@ -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
})
}