Skip to content

Commit

Permalink
feat: add notify based formatter
Browse files Browse the repository at this point in the history
shikanime committed Jan 22, 2025

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 45881a4 commit 3415520
Showing 4 changed files with 178 additions and 4 deletions.
11 changes: 11 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -29,6 +29,7 @@ type Config struct {
Walk string `mapstructure:"walk" toml:"walk,omitempty"`
WorkingDirectory string `mapstructure:"working-dir" toml:"-"`
Stdin bool `mapstructure:"stdin" toml:"-"` // not allowed in config
Watch bool `mapstructure:"watch" toml:"-"` // not allowed in config

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

@@ -98,6 +99,10 @@ func SetFlags(fs *pflag.FlagSet) {
"stdin", false,
"Format the context passed in via stdin.",
)
fs.Bool(
"watch", false,
"Watch the filesystem for changes and apply formatters when changes are detected. (env $TREEFMT_WATCH)",
)
fs.String(
"tree-root", "",
"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) {
"clear-cache": false,
"no-cache": false,
"stdin": false,
"watch": false,
"working-dir": ".",
}

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

// if the watch flag was passed, we force the watch walk type
if cfg.Watch {
cfg.Walk = walk.Watch.String()
}

// determine the tree root
if cfg.TreeRoot == "" {
// if none was specified, we first try with tree-root-file
12 changes: 8 additions & 4 deletions walk/type_enum.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions walk/walk.go
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@ const (
Stdin
Filesystem
Git
Watch

BatchSize = 1024
)
@@ -215,6 +216,8 @@ func NewReader(
reader = NewFilesystemReader(root, path, statz, BatchSize)
case Git:
reader, err = NewGitReader(root, path, statz)
case Watch:
reader, err = NewWatchReader(root, path, statz)

default:
return nil, fmt.Errorf("unknown walk type: %v", walkType)
156 changes: 156 additions & 0 deletions walk/watch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package walk

import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"

"github.com/fsnotify/fsnotify"
"github.com/numtide/treefmt/v2/stats"
"golang.org/x/sync/errgroup"
)

type WatchReader struct {
root string
path string

log *log.Logger
stats *stats.Stats

eg *errgroup.Group
watcher *fsnotify.Watcher
}

func (f *WatchReader) Read(ctx context.Context, files []*File) (n int, err error) {
// ensure we record how many files we traversed
defer func() {
f.stats.Add(stats.Traversed, n)
}()

LOOP:
// keep filling files up to it's length
for n < len(files) {
select {
// exit early if the context was cancelled
case <-ctx.Done():
err = ctx.Err()
if err == nil {
return n, fmt.Errorf("context cancelled: %w", ctx.Err())
}

return n, nil

// read the next event from the channel
case event, ok := <-f.watcher.Events:
if !ok {
// channel was closed, exit the loop
err = io.EOF

break LOOP
}

// skip if the event is a chmod or rename event since it doesn't
// change the

// file contents
if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) {
continue
}

file, err := os.Open(event.Name)
if errors.Is(err, os.ErrNotExist) {
// file was deleted, skip it
continue
} else if err != nil {
return n, fmt.Errorf("failed to stat file %s: %w", event.Name, err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return n, fmt.Errorf("failed to stat file %s: %w", event.Name, err)
}

fmt.Print("event:", event.Op, event.Name)
// determine a path relative to the root
relPath, err := filepath.Rel(f.root, event.Name)
if err != nil {
return n, fmt.Errorf("failed to determine a relative path for %s: %w", event.Name, err)
}

// add to the file array and increment n
files[n] = &File{
Path: event.Name,
RelPath: relPath,
Info: info,
}
n++

case err, ok := <-f.watcher.Errors:
if !ok {
return n, fmt.Errorf("failed to read from watcher: %w", err)
}
f.log.Printf("error: %s", err)
}
}

return n, err
}

// Close waits for all watcher processing to complete.
func (f *WatchReader) Close() error {
err := f.watcher.Close()
if err != nil {
return fmt.Errorf("failed to close watcher: %w", err)
}

err = f.eg.Wait()
if err != nil {
return fmt.Errorf("failed to wait for processing to complete: %w", err)
}

return nil
}

func NewWatchReader(
root string,
path string,
statz *stats.Stats,
) (*WatchReader, error) {
// create an error group for managing the processing loop
eg := errgroup.Group{}

watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatalf("failed to create watcher: %v", err)
}

r := WatchReader{
root: root,
path: path,
log: log.Default(),
stats: statz,
eg: &eg,
watcher: watcher,
}

// path is relative to the root, so we create a fully qualified version
// we also clean the path up in case there are any ../../ components etc.
fqPath := filepath.Clean(filepath.Join(root, path))

// ensure the path is within the root
if !strings.HasPrefix(fqPath, root) {
return nil, fmt.Errorf("path '%s' is outside of the root '%s'", fqPath, root)
}

// start watching the path
if err := watcher.Add(fqPath); err != nil {
return nil, fmt.Errorf("failed to watch path %s: %w", fqPath, err)
}

return &r, nil
}

0 comments on commit 3415520

Please sign in to comment.