This repository has been archived on 2024-05-03. You can view files and clone it, but cannot push or open issues or pull requests.
treefmt/internal/format/format.go
Brian McGee 811f883a2b feat/bust-cache-validators-change (#14)
Tracks the mod time and size of a formatter's executable in bolt.

The cache is busted using the following criteria:

- a new formatter has been configured.
- an existing formatter has changed (mod time or size)
- an existing formatter has been removed from config

Also implemented better resolution of symlinks when determining a formatters executable path.

Reviewed-on: #14
Reviewed-by: Jonas Chevalier <zimbatm@noreply.git.numtide.com>
Co-authored-by: Brian McGee <brian@bmcgee.ie>
Co-committed-by: Brian McGee <brian@bmcgee.ie>
2024-01-03 08:08:57 +00:00

185 lines
4.7 KiB
Go

package format
import (
"context"
"errors"
"fmt"
"os/exec"
"time"
"github.com/charmbracelet/log"
"github.com/gobwas/glob"
)
// ErrFormatterNotFound is returned when the Command for a Formatter is not available.
var ErrFormatterNotFound = errors.New("formatter not found")
// Formatter represents a command which should be applied to a filesystem.
type Formatter struct {
// Command is the command invoke when applying this Formatter.
Command string
// Options are an optional list of args to be passed to Command.
Options []string
// Includes is a list of glob patterns used to determine whether this Formatter should be applied against a path.
Includes []string
// Excludes is an optional list of glob patterns used to exclude certain files from this Formatter.
Excludes []string
name string
log *log.Logger
executable string // path to the executable described by Command
// internal compiled versions of Includes and Excludes.
includes []glob.Glob
excludes []glob.Glob
// inbox is used to accept new paths for formatting.
inbox chan string
// Entries from inbox are batched according to batchSize and stored in batch for processing when the batchSize has
// been reached or Close is invoked.
batch []string
batchSize int
}
// Executable returns the path to the executable defined by Command
func (f *Formatter) Executable() string {
return f.executable
}
// Init is used to initialise internal state before this Formatter is ready to accept paths.
func (f *Formatter) Init(name string, globalExcludes []glob.Glob) error {
var err error
// capture the name from the config file
f.name = name
// test if the formatter is available
executable, err := exec.LookPath(f.Command)
if errors.Is(err, exec.ErrNotFound) {
return ErrFormatterNotFound
} else if err != nil {
return err
}
f.executable = executable
// initialise internal state
f.log = log.WithPrefix("format | " + name)
f.batchSize = 1024
f.inbox = make(chan string, f.batchSize)
f.batch = make([]string, f.batchSize)
f.batch = f.batch[:0]
f.includes, err = CompileGlobs(f.Includes)
if err != nil {
return fmt.Errorf("%w: formatter '%v' includes", err, f.name)
}
f.excludes, err = CompileGlobs(f.Excludes)
if err != nil {
return fmt.Errorf("%w: formatter '%v' excludes", err, f.name)
}
f.excludes = append(f.excludes, globalExcludes...)
return nil
}
// Wants is used to test if a Formatter wants path based on it's configured Includes and Excludes patterns.
// Returns true if the Formatter should be applied to path, false otherwise.
func (f *Formatter) Wants(path string) bool {
match := !PathMatches(path, f.excludes) && PathMatches(path, f.includes)
if match {
f.log.Debugf("match: %v", path)
}
return match
}
// Put add path into this Formatter's inbox for processing.
func (f *Formatter) Put(path string) {
f.inbox <- path
}
// Run is the main processing loop for this Formatter.
// It accepts a context which is used to lookup certain dependencies and for cancellation.
func (f *Formatter) Run(ctx context.Context) (err error) {
LOOP:
// keep processing until ctx has been cancelled or inbox has been closed
for {
select {
case <-ctx.Done():
// ctx has been cancelled
err = ctx.Err()
break LOOP
case path, ok := <-f.inbox:
// check if the inbox has been closed
if !ok {
break LOOP
}
// add path to the current batch
f.batch = append(f.batch, path)
if len(f.batch) == f.batchSize {
// drain immediately
if err := f.apply(ctx); err != nil {
break LOOP
}
}
}
}
// check if LOOP was exited due to an error
if err != nil {
return
}
// processing any lingering batch
return f.apply(ctx)
}
// apply executes Command against the latest batch of paths.
// It accepts a context which is used to lookup certain dependencies and for cancellation.
func (f *Formatter) apply(ctx context.Context) error {
// empty check
if len(f.batch) == 0 {
return nil
}
// construct args, starting with config
args := f.Options
// append each file path
for _, path := range f.batch {
args = append(args, path)
}
// execute
start := time.Now()
cmd := exec.CommandContext(ctx, f.Command, args...)
if out, err := cmd.CombinedOutput(); err != nil {
f.log.Debugf("\n%v", string(out))
// todo log output
return err
}
f.log.Infof("%v files processed in %v", len(f.batch), time.Now().Sub(start))
// mark each path in this batch as completed
for _, path := range f.batch {
MarkFormatComplete(ctx, path)
}
// reset batch
f.batch = f.batch[:0]
return nil
}
// Close is used to indicate that a Formatter should process any remaining paths and then stop it's processing loop.
func (f *Formatter) Close() {
close(f.inbox)
}