Skip to content

Commit b91c5e3

Browse files
committed
feat: add notify based formatter
Close: #509
1 parent 45881a4 commit b91c5e3

File tree

7 files changed

+340
-42
lines changed

7 files changed

+340
-42
lines changed

config/config.go

+11
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type Config struct {
2929
Walk string `mapstructure:"walk" toml:"walk,omitempty"`
3030
WorkingDirectory string `mapstructure:"working-dir" toml:"-"`
3131
Stdin bool `mapstructure:"stdin" toml:"-"` // not allowed in config
32+
Watch bool `mapstructure:"watch" toml:"-"` // not allowed in config
3233

3334
FormatterConfigs map[string]*Formatter `mapstructure:"formatter" toml:"formatter,omitempty"`
3435

@@ -98,6 +99,10 @@ func SetFlags(fs *pflag.FlagSet) {
9899
"stdin", false,
99100
"Format the context passed in via stdin.",
100101
)
102+
fs.Bool(
103+
"watch", false,
104+
"Watch the filesystem for changes and apply formatters when changes are detected. (env $TREEFMT_WATCH)",
105+
)
101106
fs.String(
102107
"tree-root", "",
103108
"The root directory from which treefmt will start walking the filesystem (defaults to the directory "+
@@ -157,6 +162,7 @@ func FromViper(v *viper.Viper) (*Config, error) {
157162
"clear-cache": false,
158163
"no-cache": false,
159164
"stdin": false,
165+
"watch": false,
160166
"working-dir": ".",
161167
}
162168

@@ -185,6 +191,11 @@ func FromViper(v *viper.Viper) (*Config, error) {
185191
cfg.Walk = walk.Stdin.String()
186192
}
187193

194+
// if the watch flag was passed, we force the watch walk type
195+
if cfg.Watch {
196+
cfg.Walk = walk.Watch.String()
197+
}
198+
188199
// determine the tree root
189200
if cfg.TreeRoot == "" {
190201
// if none was specified, we first try with tree-root-file

test/test.go

+37
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,43 @@ import (
1414
"golang.org/x/sys/unix"
1515
)
1616

17+
//nolint:gochecknoglobals
18+
var ExamplesPaths = []string{
19+
"elm/elm.json",
20+
"elm/src/Main.elm",
21+
"emoji 🕰️/README.md",
22+
"go/go.mod",
23+
"go/main.go",
24+
"haskell/CHANGELOG.md",
25+
"haskell/Foo.hs",
26+
"haskell/Main.hs",
27+
"haskell/Nested/Foo.hs",
28+
"haskell/Setup.hs",
29+
"haskell/haskell.cabal",
30+
"haskell/treefmt.toml",
31+
"haskell-frontend/CHANGELOG.md",
32+
"haskell-frontend/Main.hs",
33+
"haskell-frontend/Setup.hs",
34+
"haskell-frontend/haskell-frontend.cabal",
35+
"html/index.html",
36+
"html/scripts/.gitkeep",
37+
"javascript/source/hello.js",
38+
"nix/sources.nix",
39+
"nixpkgs.toml",
40+
"python/main.py",
41+
"python/requirements.txt",
42+
"python/virtualenv_proxy.py",
43+
"ruby/bundler.rb",
44+
"rust/Cargo.toml",
45+
"rust/src/main.rs",
46+
"shell/foo.sh",
47+
"terraform/main.tf",
48+
"terraform/two.tf",
49+
"touch.toml",
50+
"treefmt.toml",
51+
"yaml/test.yaml",
52+
}
53+
1754
func WriteConfig(t *testing.T, path string, cfg *config.Config) {
1855
t.Helper()
1956

walk/filesystem_test.go

+1-38
Original file line numberDiff line numberDiff line change
@@ -13,43 +13,6 @@ import (
1313
"github.com/stretchr/testify/require"
1414
)
1515

16-
//nolint:gochecknoglobals
17-
var examplesPaths = []string{
18-
"elm/elm.json",
19-
"elm/src/Main.elm",
20-
"emoji 🕰️/README.md",
21-
"go/go.mod",
22-
"go/main.go",
23-
"haskell/CHANGELOG.md",
24-
"haskell/Foo.hs",
25-
"haskell/Main.hs",
26-
"haskell/Nested/Foo.hs",
27-
"haskell/Setup.hs",
28-
"haskell/haskell.cabal",
29-
"haskell/treefmt.toml",
30-
"haskell-frontend/CHANGELOG.md",
31-
"haskell-frontend/Main.hs",
32-
"haskell-frontend/Setup.hs",
33-
"haskell-frontend/haskell-frontend.cabal",
34-
"html/index.html",
35-
"html/scripts/.gitkeep",
36-
"javascript/source/hello.js",
37-
"nix/sources.nix",
38-
"nixpkgs.toml",
39-
"python/main.py",
40-
"python/requirements.txt",
41-
"python/virtualenv_proxy.py",
42-
"ruby/bundler.rb",
43-
"rust/Cargo.toml",
44-
"rust/src/main.rs",
45-
"shell/foo.sh",
46-
"terraform/main.tf",
47-
"terraform/two.tf",
48-
"touch.toml",
49-
"treefmt.toml",
50-
"yaml/test.yaml",
51-
}
52-
5316
func TestFilesystemReader(t *testing.T) {
5417
as := require.New(t)
5518

@@ -67,7 +30,7 @@ func TestFilesystemReader(t *testing.T) {
6730
n, err := r.Read(ctx, files)
6831

6932
for i := count; i < count+n; i++ {
70-
as.Equal(examplesPaths[i], files[i-count].RelPath)
33+
as.Equal(test.ExamplesPaths[i], files[i-count].RelPath)
7134
}
7235

7336
count += n

walk/type_enum.go

+8-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

walk/walk.go

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const (
2222
Stdin
2323
Filesystem
2424
Git
25+
Watch
2526

2627
BatchSize = 1024
2728
)
@@ -215,6 +216,8 @@ func NewReader(
215216
reader = NewFilesystemReader(root, path, statz, BatchSize)
216217
case Git:
217218
reader, err = NewGitReader(root, path, statz)
219+
case Watch:
220+
reader, err = NewWatchReader(root, path, statz, BatchSize)
218221

219222
default:
220223
return nil, fmt.Errorf("unknown walk type: %v", walkType)

walk/watch.go

+164
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package walk
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"log"
9+
"os"
10+
"os/signal"
11+
"path/filepath"
12+
"strings"
13+
"syscall"
14+
15+
"github.com/fsnotify/fsnotify"
16+
"github.com/numtide/treefmt/v2/stats"
17+
)
18+
19+
type WatchReader struct {
20+
root string
21+
path string
22+
23+
log *log.Logger
24+
stats *stats.Stats
25+
26+
watcher *fsnotify.Watcher
27+
}
28+
29+
func (f *WatchReader) Read(ctx context.Context, files []*File) (n int, err error) {
30+
// ensure we record how many files we traversed
31+
defer func() {
32+
f.stats.Add(stats.Traversed, n)
33+
}()
34+
35+
// listen for shutdown signal and cancel the context
36+
exit := make(chan os.Signal, 1)
37+
signal.Notify(exit, os.Interrupt, syscall.SIGTERM)
38+
39+
for n < len(files) {
40+
select {
41+
// since we can't detect exit using the context as watch
42+
// events are an unbounded channel, we need to check
43+
// for this explicitly
44+
case <-exit:
45+
return n, io.EOF
46+
47+
// exit early if the context was cancelled
48+
case <-ctx.Done():
49+
err = ctx.Err()
50+
if err == nil {
51+
return n, fmt.Errorf("context cancelled: %w", ctx.Err())
52+
}
53+
54+
return n, fmt.Errorf("context error: %w", err)
55+
56+
// read the next event from the channel
57+
case event, ok := <-f.watcher.Events:
58+
if !ok {
59+
// channel was closed, exit the loop
60+
return n, io.EOF
61+
}
62+
63+
// skip if the event which doesn't have content chhanged
64+
if !event.Has(fsnotify.Write) {
65+
return n, nil
66+
}
67+
68+
file, err := os.Open(event.Name)
69+
if errors.Is(err, os.ErrNotExist) {
70+
// file was deleted, skip it
71+
return n, nil
72+
} else if err != nil {
73+
return n, fmt.Errorf("failed to stat file %s: %w", event.Name, err)
74+
}
75+
defer file.Close()
76+
77+
info, err := file.Stat()
78+
if err != nil {
79+
return n, fmt.Errorf("failed to stat file %s: %w", event.Name, err)
80+
}
81+
82+
// determine a path relative to the root
83+
relPath, err := filepath.Rel(f.root, event.Name)
84+
if err != nil {
85+
return n, fmt.Errorf("failed to determine a relative path for %s: %w", event.Name, err)
86+
}
87+
88+
// add to the file array and increment n
89+
files[n] = &File{
90+
Path: event.Name,
91+
RelPath: relPath,
92+
Info: info,
93+
}
94+
n++
95+
96+
case err, ok := <-f.watcher.Errors:
97+
if !ok {
98+
return n, fmt.Errorf("failed to read from watcher: %w", err)
99+
}
100+
}
101+
}
102+
103+
return n, err
104+
}
105+
106+
// Close waits for all watcher processing to complete.
107+
func (f *WatchReader) Close() error {
108+
err := f.watcher.Close()
109+
if err != nil {
110+
return fmt.Errorf("failed to close watcher: %w", err)
111+
}
112+
113+
return nil
114+
}
115+
116+
func NewWatchReader(
117+
root string,
118+
path string,
119+
statz *stats.Stats,
120+
batchSize int,
121+
) (*WatchReader, error) {
122+
watcher, err := fsnotify.NewBufferedWatcher(uint(batchSize))
123+
if err != nil {
124+
log.Fatalf("failed to create watcher: %v", err)
125+
}
126+
127+
r := WatchReader{
128+
root: root,
129+
path: path,
130+
log: log.Default(),
131+
stats: statz,
132+
watcher: watcher,
133+
}
134+
135+
// path is relative to the root, so we create a fully qualified version
136+
// we also clean the path up in case there are any ../../ components etc.
137+
fqPath := filepath.Clean(filepath.Join(root, path))
138+
139+
// ensure the path is within the root
140+
if !strings.HasPrefix(fqPath, root) {
141+
return nil, fmt.Errorf("path '%s' is outside of the root '%s'", fqPath, root)
142+
}
143+
144+
// start watching for changes recursively
145+
err = filepath.Walk(fqPath, func(path string, info os.FileInfo, err error) error {
146+
if err != nil {
147+
return err
148+
}
149+
150+
if info.IsDir() {
151+
err := watcher.Add(path)
152+
if err != nil {
153+
return fmt.Errorf("failed to watch path %s: %w", path, err)
154+
}
155+
}
156+
157+
return nil
158+
})
159+
if err != nil {
160+
return nil, fmt.Errorf("failed to walk directory %s: %w", fqPath, err)
161+
}
162+
163+
return &r, nil
164+
}

0 commit comments

Comments
 (0)