Skip to content
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,17 @@ with the filename of the changed file. (The symbol may be changed with the
OPTIONS are given below:
--all=false:
Include normally ignored files (VCS and editor special files).
-m, --chmods=false:
Also trigger on chmod-only changes.
--clear=false:
Clear the screen before running each command.
-c, --config="":
A configuration file that describes how to run reflex
(or '-' to read the configuration from stdin).
-d, --decoration="plain":
How to decorate command output. Choices: none, plain, fancy.
-D, --directory=".":
Change to this directory before watching for changes.
-g, --glob=[]:
A shell glob expression to match filenames. (May be repeated.)
-G, --inverse-glob=[]:
Expand All @@ -50,6 +56,8 @@ OPTIONS are given below:
-R, --inverse-regex=[]:
A regular expression to exclude matching filenames.
(May be repeated.)
--name="":
Use this name instead of the numeric id in output tags.
--only-dirs=false:
Only match directories (not files).
--only-files=false:
Expand Down Expand Up @@ -102,8 +110,11 @@ only files that match all patterns and none of the inverse patterns are
selected.

The shell glob syntax is described
[here](http://golang.org/pkg/path/filepath/#Match), while the regular expression
syntax is described [here](https://code.google.com/p/re2/wiki/Syntax).
[here](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4#Match), while the
regular expression syntax is described
[here](https://code.google.com/p/re2/wiki/Syntax). In addition to the standard
shell glob syntax, `**` matches any number of path segments, so `-g '**/*.go'`
matches both `main.go` and `pkg/foo/bar.go`.

The path that is matched against the glob or regular expression does not have a
leading `./`. For example, if there is a file `./foobar.txt` that changes, then
Expand Down Expand Up @@ -154,6 +165,20 @@ Here's an example:
-sr '\.rb$' -- \
./bin/run_server.sh

Use `--directory` (`-D`) to run reflex from another directory. The change
happens before the configuration file is read and before watching starts, so
relative paths in the configuration and in the watched tree are resolved from
there.

The `--decoration` (`-d`) setting may also be given on a config line. A value
on the command line takes precedence; using different values on different
lines is an error.

If a file named `reflex.conf` exists in the working directory, reflex uses it
automatically when you run `reflex` without a config file, a command, or any
per-command flags. This means plain `reflex` picks up your config, while
`reflex -r '\.go$' make` and `reflex -c other.conf` behave as usual.

If you want to change the configuration file and have reflex reload it on the
fly, you can run reflex inside reflex:

Expand Down Expand Up @@ -211,6 +236,22 @@ use `--decoration` (`-d`) to change this output: `--decoration=none` will print
the output as is; `--decoration=fancy` will color each line differently
depending on which command it is, making it easier to distinguish the output.

If you give a command a name with `--name`, reflex uses that name in the tag
instead of the numeric id, which is easier to read when you run several
services. For example, a config line

--name=web -s -- ./bin/server

produces lines tagged `[web]`.

### Clearing the screen

With long-running services the output accumulates in your terminal. Pass
`--clear` to erase the screen before each command run, so you only see the
output of the latest run. When several reflexes are running, each start clears
the whole screen, including the other reflexes' output; the flag is most useful
with a single command.

### Ignored files

Reflex ignores a variety of version control and editor metadata files by
Expand Down
34 changes: 34 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ type Config struct {
inverseRegexes []string
inverseGlobs []string
subSymbol string
name string
startService bool
shutdownTimeout time.Duration
onlyFiles bool
onlyDirs bool
allFiles bool
decoration string
}

func (c *Config) registerFlags(f *flag.FlagSet) {
Expand All @@ -41,6 +43,8 @@ func (c *Config) registerFlags(f *flag.FlagSet) {
f.StringVar(&c.subSymbol, "substitute", defaultSubSymbol, `
The substitution symbol that is replaced with the filename
in a command.`)
f.StringVar(&c.name, "name", "", `
Use this name instead of the numeric id in output tags.`)
f.BoolVarP(&c.startService, "start-service", "s", false, `
Indicates that the command is a long-running process to be
restarted on matching changes.`)
Expand All @@ -54,6 +58,35 @@ func (c *Config) registerFlags(f *flag.FlagSet) {
Include normally ignored files (VCS and editor special files).`)
}

// registerConfigOnlyFlags registers flags that are only meaningful in a
// configuration file. Decoration is process-wide, so it is a global flag on
// the command line; allowing it per config line lets a config file set it
// once without duplicating the global flag definition.
func (c *Config) registerConfigOnlyFlags(f *flag.FlagSet) {
f.StringVarP(&c.decoration, "decoration", "d", "", `
How to decorate command output. Choices: none, plain, fancy.`)
}

// DecorationFromConfigs returns the decoration requested by the configs. It is
// an error for configs to ask for different decorations, since decoration
// applies to all output from the process.
func DecorationFromConfigs(configs []*Config) (string, error) {
decoration := ""
for _, c := range configs {
if c.decoration == "" {
continue
}
if decoration != "" && !strings.EqualFold(decoration, c.decoration) {
return "", fmt.Errorf("conflicting decorations in config: %q and %q",
decoration, c.decoration)
}
if decoration == "" {
decoration = c.decoration
}
}
return decoration, nil
}

// ReadConfigs reads configurations from either a file or, as a special case,
// stdin if "-" is given for path.
func ReadConfigs(path string) ([]*Config, error) {
Expand Down Expand Up @@ -118,6 +151,7 @@ parseFile:
flags := flag.NewFlagSet("", flag.ContinueOnError)
flags.SetOutput(io.Discard)
c.registerFlags(flags)
c.registerConfigOnlyFlags(flags)
if err := flags.Parse(parts); err != nil {
return nil, fmt.Errorf(errorf, err)
}
Expand Down
25 changes: 24 additions & 1 deletion config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func TestReadConfigs(t *testing.T) {
# Some comment here
-r '^a[0-9]+\.txt$' --only-dirs --substitute='[]' echo []

-g '*.go' -s --only-files echo hi
-g '*.go' -s --only-files --name=web -d fancy echo hi

-r foo -r bar -R baz -g a \
-G b -G c echo "hello
Expand Down Expand Up @@ -50,6 +50,8 @@ world"
startService: true,
shutdownTimeout: 500 * time.Millisecond,
onlyFiles: true,
name: "web",
decoration: "fancy",
},
{
command: []string{"echo", "hello\nworld"},
Expand Down Expand Up @@ -87,3 +89,24 @@ func TestReadConfigsBad(t *testing.T) {
}
}
}

func TestDecorationFromConfigs(t *testing.T) {
got, err := DecorationFromConfigs([]*Config{
{},
{decoration: "fancy"},
{decoration: "FANCY"},
})
if err != nil {
t.Fatal(err)
}
if got != "fancy" {
t.Errorf("got %q; want %q", got, "fancy")
}

if _, err := DecorationFromConfigs([]*Config{
{decoration: "plain"},
{decoration: "fancy"},
}); err == nil {
t.Error("expected an error for conflicting decorations")
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/cespare/reflex
go 1.24.0

require (
github.com/bmatcuk/doublestar/v4 v4.10.4
github.com/creack/pty v1.1.24
github.com/fsnotify/fsnotify v1.9.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/bmatcuk/doublestar/v4 v4.10.4 h1:oTKkRmRuOsKMqFe1AQazd9gGBFNEpOmgKp/xzB97dnA=
github.com/bmatcuk/doublestar/v4 v4.10.4/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
Expand Down
82 changes: 71 additions & 11 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@ import (

const defaultSubSymbol = "{}"

const defaultConfigFile = "reflex.conf"

var (
reflexes []*Reflex

flagConf string
flagSequential bool
flagDecoration string
flagDirectory string
flagClear bool
flagChmods bool
decoration Decoration
verbose bool
globalFlags = flag.NewFlagSet("", flag.ContinueOnError)
Expand Down Expand Up @@ -67,18 +72,36 @@ func init() {
(or '-' to read the configuration from stdin).`)
globalFlags.BoolVarP(&verbose, "verbose", "v", false, `
Verbose mode: print out more information about what reflex is doing.`)
globalFlags.BoolVarP(&flagChmods, "chmods", "m", false, `
Also trigger on chmod-only changes.`)
globalFlags.BoolVar(&flagClear, "clear", false, `
Clear the screen before running each command.`)
globalFlags.BoolVarP(&flagSequential, "sequential", "e", false, `
Don't run multiple commands at the same time.`)
globalFlags.StringVarP(&flagDecoration, "decoration", "d", "plain", `
How to decorate command output. Choices: none, plain, fancy.`)
globalFlags.StringVarP(&flagDirectory, "directory", "D", ".", `
Change to this directory before watching for changes.`)
globalConfig.registerFlags(globalFlags)
}

// flagWasSet reports whether name was given on the command line. The pflag
// fork this project uses has no FlagSet.Changed, so Visit is the way to ask.
func flagWasSet(name string) bool {
set := false
globalFlags.Visit(func(f *flag.Flag) {
if f.Name == name {
set = true
}
})
return set
}

func anyNonGlobalsRegistered() bool {
any := false
walkFn := func(f *flag.Flag) {
switch f.Name {
case "config", "verbose", "sequential", "decoration":
case "config", "verbose", "sequential", "decoration", "directory", "chmods", "clear":
default:
any = true
}
Expand Down Expand Up @@ -116,6 +139,23 @@ func cleanup(reason string) {
os.Exit(0)
}

// resolveConfigPath decides which config file to read. An explicit --config
// always wins. Otherwise reflex uses reflex.conf automatically when it was
// invoked with no command and no per-command flags; this keeps
// `reflex -r foo make` unaffected by a reflex.conf in the directory.
func resolveConfigPath(conf string, haveCommand, perCommandFlags bool) string {
if conf != "" {
return conf
}
if haveCommand || perCommandFlags {
return ""
}
if info, err := os.Stat(defaultConfigFile); err == nil && !info.IsDir() {
return defaultConfigFile
}
return ""
}

func main() {
log.SetFlags(0)
if err := globalFlags.Parse(os.Args[1:]); err != nil {
Expand All @@ -126,17 +166,15 @@ func main() {
if verbose {
printGlobals()
}
switch strings.ToLower(flagDecoration) {
case "none":
decoration = DecorationNone
case "plain":
decoration = DecorationPlain
case "fancy":
decoration = DecorationFancy
default:
log.Fatalf("Invalid decoration %s. Choices: none, plain, fancy.", flagDecoration)

if flagDirectory != "." {
if err := os.Chdir(flagDirectory); err != nil {
log.Fatalf("Could not change directory to %s: %v", flagDirectory, err)
}
}

flagConf = resolveConfigPath(flagConf, globalFlags.NArg() != 0, anyNonGlobalsRegistered())

var configs []*Config
if flagConf == "" {
if flagSequential {
Expand All @@ -145,7 +183,7 @@ func main() {
configs = []*Config{globalConfig}
} else {
if anyNonGlobalsRegistered() {
log.Fatal("Cannot set other flags along with --config other than --sequential, --verbose, and --decoration.")
log.Fatal("Cannot combine --config with per-command flags (such as --regex or --glob); only global flags are allowed.")
}
var err error
configs, err = ReadConfigs(flagConf)
Expand All @@ -157,6 +195,28 @@ func main() {
}
}

// Decoration applies to all output. A value given on the command line
// wins over one set in a config file.
if !flagWasSet("decoration") {
configDecoration, err := DecorationFromConfigs(configs)
if err != nil {
log.Fatalln(err)
}
if configDecoration != "" {
flagDecoration = configDecoration
}
}
switch strings.ToLower(flagDecoration) {
case "none":
decoration = DecorationNone
case "plain":
decoration = DecorationPlain
case "fancy":
decoration = DecorationFancy
default:
log.Fatalf("Invalid decoration %s. Choices: none, plain, fancy.", flagDecoration)
}

for _, config := range configs {
reflex, err := NewReflex(config)
if err != nil {
Expand Down
40 changes: 40 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import (
"os"
"testing"
)

func TestResolveConfigPath(t *testing.T) {
t.Chdir(t.TempDir())

if got := resolveConfigPath("other.conf", true, true); got != "other.conf" {
t.Errorf("explicit config: got %q; want %q", got, "other.conf")
}
if got := resolveConfigPath("", true, false); got != "" {
t.Errorf("command without config: got %q; want no config", got)
}
if got := resolveConfigPath("", false, true); got != "" {
t.Errorf("per-command flags without config: got %q; want no config", got)
}
if got := resolveConfigPath("", false, false); got != "" {
t.Errorf("no reflex.conf: got %q; want no config", got)
}

if err := os.WriteFile(defaultConfigFile, []byte("echo hi\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := resolveConfigPath("", false, false); got != defaultConfigFile {
t.Errorf("reflex.conf present: got %q; want %q", got, defaultConfigFile)
}

if err := os.Remove(defaultConfigFile); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(defaultConfigFile, 0o755); err != nil {
t.Fatal(err)
}
if got := resolveConfigPath("", false, false); got != "" {
t.Errorf("reflex.conf is a directory: got %q; want no config", got)
}
}
Loading