Skip to content

Commit c7d0138

Browse files
brianmcgeeBrian McGee
authored and
Brian McGee
committed
support formatter ordering (#20)
Allows specifying the order in which formatters are applied. Very simple for now, adding a `Before` field to the formatted config which allows the user to say that formatter `x` needs to be applied _before_ formatted `y`. ```toml [formatter.statix] command = "statix" includes = ["*.nix"] before = "deadnix" [formatter.deadnix] command = "statix" includes = ["*.nix"] ``` Signed-off-by: Brian McGee <[email protected]> Reviewed-on: https://git.numtide.com/numtide/treefmt/pulls/20 Reviewed-by: Jonas Chevalier <[email protected]> Co-authored-by: Brian McGee <[email protected]> Co-committed-by: Brian McGee <[email protected]>
1 parent 80e99b6 commit c7d0138

File tree

5 files changed

+249
-84
lines changed

5 files changed

+249
-84
lines changed

internal/cli/format.go

+75-31
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"os/signal"
9+
"strings"
910
"syscall"
1011
"time"
1112

@@ -64,33 +65,83 @@ func (f *Format) Run() error {
6465
}
6566
}
6667

68+
formatters := make(map[string]*format.Formatter)
69+
70+
// detect broken dependencies
71+
for name, config := range cfg.Formatters {
72+
before := config.Before
73+
if before != "" {
74+
// check child formatter exists
75+
_, ok := cfg.Formatters[before]
76+
if !ok {
77+
return fmt.Errorf("formatter %v is before %v but config for %v was not found", name, before, before)
78+
}
79+
}
80+
}
81+
82+
// dependency cycle detection
83+
for name, config := range cfg.Formatters {
84+
var ok bool
85+
var history []string
86+
childName := name
87+
for {
88+
// add to history
89+
history = append(history, childName)
90+
91+
if config.Before == "" {
92+
break
93+
} else if config.Before == name {
94+
return fmt.Errorf("formatter cycle detected %v", strings.Join(history, " -> "))
95+
}
96+
97+
// load child config
98+
childName = config.Before
99+
config, ok = cfg.Formatters[config.Before]
100+
if !ok {
101+
return fmt.Errorf("formatter not found: %v", config.Before)
102+
}
103+
}
104+
}
105+
67106
// init formatters
68-
for name, formatter := range cfg.Formatters {
107+
for name, config := range cfg.Formatters {
69108
if !includeFormatter(name) {
70109
// remove this formatter
71110
delete(cfg.Formatters, name)
72111
l.Debugf("formatter %v is not in formatter list %v, skipping", name, Cli.Formatters)
73112
continue
74113
}
75114

76-
err = formatter.Init(name, globalExcludes)
115+
formatter, err := format.NewFormatter(name, config, globalExcludes)
77116
if errors.Is(err, format.ErrFormatterNotFound) && Cli.AllowMissingFormatter {
78117
l.Debugf("formatter not found: %v", name)
79-
// remove this formatter
80-
delete(cfg.Formatters, name)
118+
continue
81119
} else if err != nil {
82120
return fmt.Errorf("%w: failed to initialise formatter: %v", err, name)
83121
}
122+
123+
formatters[name] = formatter
84124
}
85125

86-
ctx = format.RegisterFormatters(ctx, cfg.Formatters)
126+
// iterate the initialised formatters configuring parent/child relationships
127+
for _, formatter := range formatters {
128+
if formatter.Before() != "" {
129+
child, ok := formatters[formatter.Before()]
130+
if !ok {
131+
// formatter has been filtered out by the user
132+
formatter.ResetBefore()
133+
continue
134+
}
135+
formatter.SetChild(child)
136+
child.SetParent(formatter)
137+
}
138+
}
87139

88-
if err = cache.Open(Cli.TreeRoot, Cli.ClearCache, cfg.Formatters); err != nil {
140+
if err = cache.Open(Cli.TreeRoot, Cli.ClearCache, formatters); err != nil {
89141
return err
90142
}
91143

92144
//
93-
pendingCh := make(chan string, 1024)
94145
completedCh := make(chan string, 1024)
95146

96147
ctx = format.SetCompletedChannel(ctx, completedCh)
@@ -99,8 +150,8 @@ func (f *Format) Run() error {
99150
eg, ctx := errgroup.WithContext(ctx)
100151

101152
// start the formatters
102-
for name := range cfg.Formatters {
103-
formatter := cfg.Formatters[name]
153+
for name := range formatters {
154+
formatter := formatters[name]
104155
eg.Go(func() error {
105156
return formatter.Run(ctx)
106157
})
@@ -114,20 +165,13 @@ func (f *Format) Run() error {
114165
batchSize := 1024
115166
batch := make([]string, 0, batchSize)
116167

117-
var pending, completed, changes int
168+
var changes int
118169

119170
LOOP:
120171
for {
121172
select {
122173
case <-ctx.Done():
123174
return ctx.Err()
124-
case _, ok := <-pendingCh:
125-
if ok {
126-
pending += 1
127-
} else if pending == completed {
128-
break LOOP
129-
}
130-
131175
case path, ok := <-completedCh:
132176
if !ok {
133177
break LOOP
@@ -141,12 +185,6 @@ func (f *Format) Run() error {
141185
changes += count
142186
batch = batch[:0]
143187
}
144-
145-
completed += 1
146-
147-
if completed == pending {
148-
close(completedCh)
149-
}
150188
}
151189
}
152190

@@ -166,26 +204,32 @@ func (f *Format) Run() error {
166204
})
167205

168206
eg.Go(func() error {
169-
count := 0
170-
207+
// pass paths to each formatter
171208
for path := range pathsCh {
172-
for _, formatter := range cfg.Formatters {
209+
for _, formatter := range formatters {
173210
if formatter.Wants(path) {
174-
pendingCh <- path
175-
count += 1
176211
formatter.Put(path)
177212
}
178213
}
179214
}
180215

181-
for _, formatter := range cfg.Formatters {
216+
// indicate no more paths for each formatter
217+
for _, formatter := range formatters {
218+
if formatter.Parent() != nil {
219+
// this formatter is not a root, it will be closed by a parent
220+
continue
221+
}
182222
formatter.Close()
183223
}
184224

185-
if count == 0 {
186-
close(completedCh)
225+
// await completion
226+
for _, formatter := range formatters {
227+
formatter.AwaitCompletion()
187228
}
188229

230+
// indicate no more completion events
231+
close(completedCh)
232+
189233
return nil
190234
})
191235

internal/cli/format_test.go

+84-9
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func TestAllowMissingFormatter(t *testing.T) {
2525
configPath := tempDir + "/treefmt.toml"
2626

2727
test.WriteConfig(t, configPath, format.Config{
28-
Formatters: map[string]*format.Formatter{
28+
Formatters: map[string]*format.FormatterConfig{
2929
"foo-fmt": {
3030
Command: "foo-fmt",
3131
},
@@ -39,14 +39,35 @@ func TestAllowMissingFormatter(t *testing.T) {
3939
as.NoError(err)
4040
}
4141

42+
func TestDependencyCycle(t *testing.T) {
43+
as := require.New(t)
44+
45+
tempDir := t.TempDir()
46+
configPath := tempDir + "/treefmt.toml"
47+
48+
test.WriteConfig(t, configPath, format.Config{
49+
Formatters: map[string]*format.FormatterConfig{
50+
"a": {Command: "echo", Before: "b"},
51+
"b": {Command: "echo", Before: "c"},
52+
"c": {Command: "echo", Before: "a"},
53+
"d": {Command: "echo", Before: "e"},
54+
"e": {Command: "echo", Before: "f"},
55+
"f": {Command: "echo"},
56+
},
57+
})
58+
59+
_, err := cmd(t, "--config-file", configPath, "--tree-root", tempDir)
60+
as.ErrorContains(err, "formatter cycle detected a -> b -> c")
61+
}
62+
4263
func TestSpecifyingFormatters(t *testing.T) {
4364
as := require.New(t)
4465

4566
tempDir := test.TempExamples(t)
4667
configPath := tempDir + "/treefmt.toml"
4768

4869
test.WriteConfig(t, configPath, format.Config{
49-
Formatters: map[string]*format.Formatter{
70+
Formatters: map[string]*format.FormatterConfig{
5071
"elm": {
5172
Command: "echo",
5273
Includes: []string{"*.elm"},
@@ -95,7 +116,7 @@ func TestIncludesAndExcludes(t *testing.T) {
95116

96117
// test without any excludes
97118
config := format.Config{
98-
Formatters: map[string]*format.Formatter{
119+
Formatters: map[string]*format.FormatterConfig{
99120
"echo": {
100121
Command: "echo",
101122
Includes: []string{"*"},
@@ -167,7 +188,7 @@ func TestCache(t *testing.T) {
167188

168189
// test without any excludes
169190
config := format.Config{
170-
Formatters: map[string]*format.Formatter{
191+
Formatters: map[string]*format.FormatterConfig{
171192
"echo": {
172193
Command: "echo",
173194
Includes: []string{"*"},
@@ -202,7 +223,7 @@ func TestChangeWorkingDirectory(t *testing.T) {
202223

203224
// test without any excludes
204225
config := format.Config{
205-
Formatters: map[string]*format.Formatter{
226+
Formatters: map[string]*format.FormatterConfig{
206227
"echo": {
207228
Command: "echo",
208229
Includes: []string{"*"},
@@ -227,7 +248,7 @@ func TestFailOnChange(t *testing.T) {
227248

228249
// test without any excludes
229250
config := format.Config{
230-
Formatters: map[string]*format.Formatter{
251+
Formatters: map[string]*format.FormatterConfig{
231252
"echo": {
232253
Command: "echo",
233254
Includes: []string{"*"},
@@ -263,7 +284,7 @@ func TestBustCacheOnFormatterChange(t *testing.T) {
263284

264285
// start with 2 formatters
265286
config := format.Config{
266-
Formatters: map[string]*format.Formatter{
287+
Formatters: map[string]*format.FormatterConfig{
267288
"python": {
268289
Command: "black",
269290
Includes: []string{"*.py"},
@@ -307,7 +328,7 @@ func TestBustCacheOnFormatterChange(t *testing.T) {
307328
as.Contains(string(out), "0 files changed")
308329

309330
// add go formatter
310-
config.Formatters["go"] = &format.Formatter{
331+
config.Formatters["go"] = &format.FormatterConfig{
311332
Command: "gofmt",
312333
Options: []string{"-w"},
313334
Includes: []string{"*.go"},
@@ -358,7 +379,7 @@ func TestGitWorktree(t *testing.T) {
358379

359380
// basic config
360381
config := format.Config{
361-
Formatters: map[string]*format.Formatter{
382+
Formatters: map[string]*format.FormatterConfig{
362383
"echo": {
363384
Command: "echo",
364385
Includes: []string{"*"},
@@ -404,3 +425,57 @@ func TestGitWorktree(t *testing.T) {
404425
as.NoError(err)
405426
as.Contains(string(out), fmt.Sprintf("%d files changed", 55))
406427
}
428+
429+
func TestOrderingFormatters(t *testing.T) {
430+
as := require.New(t)
431+
432+
tempDir := test.TempExamples(t)
433+
configPath := path.Join(tempDir, "treefmt.toml")
434+
435+
// missing child
436+
test.WriteConfig(t, configPath, format.Config{
437+
Formatters: map[string]*format.FormatterConfig{
438+
"hs-a": {
439+
Command: "echo",
440+
Includes: []string{"*.hs"},
441+
Before: "hs-b",
442+
},
443+
},
444+
})
445+
446+
out, err := cmd(t, "--config-file", configPath, "--tree-root", tempDir)
447+
as.ErrorContains(err, "formatter hs-a is before hs-b but config for hs-b was not found")
448+
449+
// multiple roots
450+
test.WriteConfig(t, configPath, format.Config{
451+
Formatters: map[string]*format.FormatterConfig{
452+
"hs-a": {
453+
Command: "echo",
454+
Includes: []string{"*.hs"},
455+
Before: "hs-b",
456+
},
457+
"hs-b": {
458+
Command: "echo",
459+
Includes: []string{"*.hs"},
460+
Before: "hs-c",
461+
},
462+
"hs-c": {
463+
Command: "echo",
464+
Includes: []string{"*.hs"},
465+
},
466+
"py-a": {
467+
Command: "echo",
468+
Includes: []string{"*.py"},
469+
Before: "py-b",
470+
},
471+
"py-b": {
472+
Command: "echo",
473+
Includes: []string{"*.py"},
474+
},
475+
},
476+
})
477+
478+
out, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
479+
as.NoError(err)
480+
as.Contains(string(out), "8 files changed")
481+
}

internal/format/config.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ type Config struct {
88
// Excludes is an optional list of glob patterns used to exclude certain files from all formatters.
99
Excludes []string
1010
}
11-
Formatters map[string]*Formatter `toml:"formatter"`
11+
Formatters map[string]*FormatterConfig `toml:"formatter"`
1212
}
1313

1414
// ReadConfigFile reads from path and unmarshals toml into a Config instance.

internal/format/context.go

+2-13
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,17 @@ import (
55
)
66

77
const (
8-
formattersKey = "formatters"
98
completedChKey = "completedCh"
109
)
1110

12-
// RegisterFormatters is used to set a map of formatters in the provided context.
13-
func RegisterFormatters(ctx context.Context, formatters map[string]*Formatter) context.Context {
14-
return context.WithValue(ctx, formattersKey, formatters)
15-
}
16-
17-
// GetFormatters is used to retrieve a formatters map from the provided context.
18-
func GetFormatters(ctx context.Context) map[string]*Formatter {
19-
return ctx.Value(formattersKey).(map[string]*Formatter)
20-
}
21-
2211
// SetCompletedChannel is used to set a channel for indication processing completion in the provided context.
2312
func SetCompletedChannel(ctx context.Context, completedCh chan string) context.Context {
2413
return context.WithValue(ctx, completedChKey, completedCh)
2514
}
2615

27-
// MarkFormatComplete is used to indicate that all processing has finished for the provided path.
16+
// MarkPathComplete is used to indicate that all processing has finished for the provided path.
2817
// This is done by adding the path to the completion channel which should have already been set using
2918
// SetCompletedChannel.
30-
func MarkFormatComplete(ctx context.Context, path string) {
19+
func MarkPathComplete(ctx context.Context, path string) {
3120
ctx.Value(completedChKey).(chan string) <- path
3221
}

0 commit comments

Comments
 (0)