Skip to content

Commit c0e672c

Browse files
committed
feat: include/exclude by detected MIME type
Intended for cases like scripts, which may lack extensions indicating their file/language type.
1 parent c911282 commit c0e672c

File tree

18 files changed

+381
-132
lines changed

18 files changed

+381
-132
lines changed

cmd/init/init.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ options = []
6161
includes = [ "*.<language-extension>" ]
6262
# Glob patterns of files to exclude
6363
excludes = []
64+
# MIME types of files to include
65+
allowed-mimetypes = [ "text/x-mylanguage" ]
66+
# MIME types of files to exclude
67+
disallowed-mimetypes = [ "application/json" ]
6468
# Controls the order of application when multiple formatters match the same file
6569
# Lower the number, the higher the precedence
6670
# Default is 0

config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type Config struct {
2929
CI bool `mapstructure:"ci" toml:"-"` // not allowed in config
3030
ClearCache bool `mapstructure:"clear-cache" toml:"-"` // not allowed in config
3131
CPUProfile string `mapstructure:"cpu-profile" toml:"cpu-profile,omitempty"`
32+
DisallowedMimetypes []string `mapstructure:"disallowed-mimetypes" toml:"excludes,omitempty"`
3233
Excludes []string `mapstructure:"excludes" toml:"excludes,omitempty"`
3334
FailOnChange bool `mapstructure:"fail-on-change" toml:"fail-on-change,omitempty"`
3435
Formatters []string `mapstructure:"formatters" toml:"formatters,omitempty"`
@@ -56,6 +57,10 @@ type Formatter struct {
5657
Command string `mapstructure:"command" toml:"command"`
5758
// Options are an optional list of args to be passed to Command.
5859
Options []string `mapstructure:"options,omitempty" toml:"options,omitempty"`
60+
// AllowedMimetypes is an optional list of MIME types used to determine whether this Formatter should be applied against a path.
61+
AllowedMimetypes []string `mapstructure:"allowed-mimetypes,omitempty" toml:"includes,omitempty"`
62+
// DisallowedMimetypes is an optional list of MIME types used to exclude certain files from this Formatter.
63+
DisallowedMimetypes []string `mapstructure:"disallowed-mimetypes,omitempty" toml:"includes,omitempty"`
5964
// Includes is a list of glob patterns used to determine whether this Formatter should be applied against a path.
6065
Includes []string `mapstructure:"includes,omitempty" toml:"includes,omitempty"`
6166
// Excludes is an optional list of glob patterns used to exclude certain files from this Formatter.
@@ -86,6 +91,10 @@ func SetFlags(fs *pflag.FlagSet) {
8691
"cpu-profile", "",
8792
"The file into which a cpu profile will be written. (env $TREEFMT_CPU_PROFILE)",
8893
)
94+
fs.StringSlice(
95+
"disallowed-mimetypes", nil,
96+
"Exclude files matching the specified MIME types. (env $TREEFMT_DISALLOWED_MIMETYPES)",
97+
)
8998
fs.StringSlice(
9099
"excludes", nil,
91100
"Exclude files or directories matching the specified globs. (env $TREEFMT_EXCLUDES)",

docs/site/getting-started/configure.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
[MIME types]: https://github.com/gabriel-vasile/mimetype/blob/17b303270b920bc619feadef12cad28d70fcb6e0/supported_mimes.md
2+
13
# Configure
24

35
`treefmt`'s behaviour can be influenced in one of three ways:
@@ -127,6 +129,28 @@ The file into which a [pprof](https://github.com/google/pprof) cpu profile will
127129
cpu-profile = "./cpu.pprof"
128130
```
129131

132+
### `disallowed-mimetypes`
133+
134+
An optional list of [MIME types][] used to exclude files from all formatters.
135+
136+
=== "Flag"
137+
138+
```console
139+
treefmt --disallowed-mimetypes application/json,image/png
140+
```
141+
142+
=== "Env"
143+
144+
```console
145+
TREEFMT_DISALLOWED_MIMETYPES="application/json,image/png" treefmt
146+
```
147+
148+
=== "Config"
149+
150+
```toml
151+
disallowed-mimetypes = ["application/json", "image/png"]
152+
```
153+
130154
### `excludes`
131155

132156
An optional list of [glob patterns](#glob-patterns-format) used to exclude files from all formatters.
@@ -450,6 +474,11 @@ command = "deadnix"
450474
options = ["-e"]
451475
includes = ["*.nix"]
452476
priority = 2
477+
478+
[formatter.shfmt]
479+
command = "shfmt"
480+
options = ["-i", "2", "-w"]
481+
allowed-mimetypes = ["text/x-shellscript"]
453482
```
454483

455484
### `command`
@@ -462,19 +491,27 @@ An optional list of args to be passed to `command`.
462491

463492
### `includes`
464493

465-
A list of [glob patterns](#glob-patterns-format) used to determine whether the formatter should be applied against a given path.
494+
An optional list of [glob patterns](#glob-patterns-format) used to determine whether the formatter should be applied against a given path.
466495

467496
### `excludes`
468497

469498
An optional list of [glob patterns](#glob-patterns-format) used to exclude certain files from this formatter.
470499

500+
### `allowed-mimetypes`
501+
502+
An optional list of [MIME types][] used to determine whether the formatter should be applied against a given path.
503+
504+
### `disallowed-mimetypes`
505+
506+
An optional list of [MIME types][] used to exclude certain files from this formatter.
507+
471508
### `priority`
472509

473510
Influences the order of execution. Greater precedence is given to lower numbers, with the default being `0`.
474511

475512
## Same file, multiple formatters?
476513

477-
For each file, `treefmt` determines a list of formatters based on the configured `includes` / `excludes` rules. This list is
514+
For each file, `treefmt` determines a list of formatters based on the configured `includes` / `excludes` and `allowed-mimetypes` / `disallowed-mimetypes` rules. This list is
478515
then sorted, first by priority (lower the value, higher the precedence) and secondly by formatter name (lexicographically).
479516

480517
The resultant sequence of formatters is used to create a batch key, and similarly matched files get added to that batch
@@ -486,6 +523,10 @@ Another consequence is that formatting is deterministic for a given file and a g
486523
By setting the priority fields appropriately, you can control the order in which those formatters are applied for any
487524
files they _both happen to match on_.
488525

526+
## Required settings
527+
528+
Each formatter must have at least one entry in the `includes` list **or** the `allowed-mimetypes` list. This is the minimal requirement for `treefmt` to identify which files are subject to a given formatter.
529+
489530
## Glob patterns format
490531

491532
This is a variant of the Unix glob pattern. It supports all the usual

docs/site/guides/unmatched-formatters.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ $ treefmt --on-unmatched warn
3636
### Enforcing Strict Matching
3737

3838
Another stricter policy approach is to fail the run if any unmatched files are found.
39-
This can be paired with an `excludes` list to ignore specific files:
39+
This can be paired with `excludes` and/or `disallowed-mimetypes` lists to ignore specific files:
4040

4141
`treefmt.toml`:
4242

@@ -49,4 +49,10 @@ excludes = [
4949
"LICENCE",
5050
"go.sum",
5151
]
52+
53+
# List MIME types to explicity ignore
54+
disallowed-mimetypes = [
55+
"image/x-portable-pixmap",
56+
"text/rtf",
57+
]
5258
```

format/composite.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"slices"
1010

1111
"github.com/charmbracelet/log"
12-
"github.com/gobwas/glob"
1312
"github.com/numtide/treefmt/v2/config"
1413
"github.com/numtide/treefmt/v2/stats"
1514
"github.com/numtide/treefmt/v2/walk"
@@ -25,9 +24,9 @@ var ErrFormattingFailures = errors.New("formatting failures detected")
2524
// CompositeFormatter handles the application of multiple Formatter instances based on global excludes and individual
2625
// formatter configuration.
2726
type CompositeFormatter struct {
28-
cfg *config.Config
29-
stats *stats.Stats
30-
globalExcludes []glob.Glob
27+
cfg *config.Config
28+
stats *stats.Stats
29+
globalMatcher *CompositeMatcher
3130

3231
unmatchedLevel log.Level
3332

@@ -38,9 +37,8 @@ type CompositeFormatter struct {
3837
// match filters the file against global excludes and returns a list of formatters that want to process the file.
3938
func (c *CompositeFormatter) match(file *walk.File) (bool, []*Formatter) {
4039
// first check if this file has been globally excluded
41-
if pathMatches(file.RelPath, c.globalExcludes) {
40+
if !c.globalMatcher.Wants(file.RelPath) {
4241
log.Debugf("path matched global excludes: %s", file.RelPath)
43-
4442
return true, nil
4543
}
4644

@@ -147,12 +145,23 @@ func NewCompositeFormatter(
147145
statz *stats.Stats,
148146
batchSize int,
149147
) (*CompositeFormatter, error) {
148+
matchers := make([]Matcher, 2)
149+
150+
var err error
151+
150152
// compile global exclude globs
151-
globalExcludes, err := compileGlobs(cfg.Excludes)
153+
matchers[0], err = NewGlobExclusionMatcher(cfg.Excludes)
152154
if err != nil {
153155
return nil, fmt.Errorf("failed to compile global excludes: %w", err)
154156
}
155157

158+
matchers[1], err = NewMimetypeExclusionMatcher(cfg.DisallowedMimetypes)
159+
if err != nil {
160+
return nil, fmt.Errorf("failed to assemble global disallowed MIME types: %w", err)
161+
}
162+
163+
globalMatcher := NewCompositeMatcher(matchers)
164+
156165
// parse unmatched log level
157166
unmatchedLevel, err := log.ParseLevel(cfg.OnUnmatched)
158167
if err != nil {
@@ -191,7 +200,7 @@ func NewCompositeFormatter(
191200
return &CompositeFormatter{
192201
cfg: cfg,
193202
stats: statz,
194-
globalExcludes: globalExcludes,
203+
globalMatcher: globalMatcher,
195204
unmatchedLevel: unmatchedLevel,
196205

197206
scheduler: scheduler,

format/formatter.go

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
"time"
1414

1515
"github.com/charmbracelet/log"
16-
"github.com/gobwas/glob"
1716
"github.com/numtide/treefmt/v2/config"
1817
"github.com/numtide/treefmt/v2/walk"
1918
"mvdan.cc/sh/v3/expand"
@@ -37,9 +36,8 @@ type Formatter struct {
3736
executable string // path to the executable described by Command
3837
workingDir string
3938

40-
// internal, compiled versions of Includes and Excludes.
41-
includes []glob.Glob
42-
excludes []glob.Glob
39+
// internal, compiled versions of inclusion and exclusion matchers.
40+
matcher *CompositeMatcher
4341
}
4442

4543
func (f *Formatter) Name() string {
@@ -123,15 +121,15 @@ func (f *Formatter) Apply(ctx context.Context, files []*walk.File) error {
123121
return nil
124122
}
125123

126-
// Wants is used to determine if a Formatter wants to process a path based on it's configured Includes and Excludes
127-
// patterns.
124+
// Wants is used to determine if a Formatter wants to process a path based on
125+
// its configured Includes and Excludes patterns, plus its AllowedMimetypes and
126+
// DisallowedMimetypes MIME types.
128127
// Returns true if the Formatter should be applied to file, false otherwise.
129128
func (f *Formatter) Wants(file *walk.File) bool {
130-
match := !pathMatches(file.RelPath, f.excludes) && pathMatches(file.RelPath, f.includes)
129+
match := f.matcher.Wants(file.RelPath)
131130
if match {
132131
f.log.Debugf("match: %v", file)
133132
}
134-
135133
return match
136134
}
137135

@@ -171,20 +169,34 @@ func newFormatter(
171169
f.log = log.WithPrefix("formatter | " + name)
172170
}
173171

174-
// check there is at least one include
175-
if len(cfg.Includes) == 0 {
176-
return nil, fmt.Errorf("formatter '%v' has no includes", f.name)
172+
// check there is at least one include or allowed MIME type
173+
if len(cfg.Includes) == 0 && len(cfg.AllowedMimetypes) == 0 {
174+
return nil, fmt.Errorf("formatter '%v' has no includes or allowed MIME types", f.name)
177175
}
178176

179-
f.includes, err = compileGlobs(cfg.Includes)
177+
matchers := make([]Matcher, 4)
178+
179+
matchers[0], err = NewGlobInclusionMatcher(cfg.Includes)
180180
if err != nil {
181181
return nil, fmt.Errorf("failed to compile formatter '%v' includes: %w", f.name, err)
182182
}
183183

184-
f.excludes, err = compileGlobs(cfg.Excludes)
184+
matchers[1], err = NewGlobExclusionMatcher(cfg.Excludes)
185185
if err != nil {
186186
return nil, fmt.Errorf("failed to compile formatter '%v' excludes: %w", f.name, err)
187187
}
188188

189+
matchers[2], err = NewMimetypeInclusionMatcher(cfg.AllowedMimetypes)
190+
if err != nil {
191+
return nil, fmt.Errorf("failed to assemble formatter '%v' allowed MIME types: %w", f.name, err)
192+
}
193+
194+
matchers[3], err = NewMimetypeExclusionMatcher(cfg.DisallowedMimetypes)
195+
if err != nil {
196+
return nil, fmt.Errorf("failed to assemble formatter '%v' disallowed MIME types: %w", f.name, err)
197+
}
198+
199+
f.matcher = NewCompositeMatcher(matchers)
200+
189201
return &f, nil
190202
}

format/glob.go

Lines changed: 0 additions & 33 deletions
This file was deleted.

format/glob_test.go

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,36 @@ package format
44
import (
55
"testing"
66

7-
"github.com/gobwas/glob"
87
"github.com/stretchr/testify/require"
98
)
109

1110
func TestGlobs(t *testing.T) {
1211
r := require.New(t)
1312

1413
var (
15-
globs []glob.Glob
16-
err error
14+
matcher *globMatcher
15+
err error
1716
)
1817

1918
// File extension
20-
globs, err = compileGlobs([]string{"*.txt"})
19+
matcher, err = newGlobMatcher([]string{"*.txt"})
2120
r.NoError(err)
22-
r.True(pathMatches("test/foo/bar.txt", globs))
23-
r.False(pathMatches("test/foo/bar.txtz", globs))
24-
r.False(pathMatches("test/foo/bar.flob", globs))
21+
r.True(matcher.MatchesPath("test/foo/bar.txt"))
22+
r.False(matcher.MatchesPath("test/foo/bar.txtz"))
23+
r.False(matcher.MatchesPath("test/foo/bar.flob"))
2524

2625
// Prefix matching
27-
globs, err = compileGlobs([]string{"test/*"})
26+
matcher, err = newGlobMatcher([]string{"test/*"})
2827
r.NoError(err)
29-
r.True(pathMatches("test/bar.txt", globs))
30-
r.True(pathMatches("test/foo/bar.txt", globs))
31-
r.False(pathMatches("/test/foo/bar.txt", globs))
28+
r.True(matcher.MatchesPath("test/bar.txt"))
29+
r.True(matcher.MatchesPath("test/foo/bar.txt"))
30+
r.False(matcher.MatchesPath("/test/foo/bar.txt"))
3231

3332
// Exact matches
3433
// File extension
35-
globs, err = compileGlobs([]string{"LICENSE"})
34+
matcher, err = newGlobMatcher([]string{"LICENSE"})
3635
r.NoError(err)
37-
r.True(pathMatches("LICENSE", globs))
38-
r.False(pathMatches("test/LICENSE", globs))
39-
r.False(pathMatches("LICENSE.txt", globs))
36+
r.True(matcher.MatchesPath("LICENSE"))
37+
r.False(matcher.MatchesPath("test/LICENSE"))
38+
r.False(matcher.MatchesPath("LICENSE.txt"))
4039
}

0 commit comments

Comments
 (0)