style: allow specifying multiple headers on dynamic styles

Change the `Style` struct to hold a map of header -> pattern mappings
instead of a single pair. Must all match for a style to be applied,
effectively implementing an AND condition.

Technically, this actually changes the matching from O(n) to O(n*m), `n`
being the number of styleset entries and `m` the number of
(user-specified) header patterns.

Realistically, in most cases m == 1 will hold true anyway and more than
2-3 header patterns will not be used in practice much either -- so this
is fine.

Update man pages accordingly. Mention the ordering-sensitive property of
the matching.

Add unit tests to ensure it works as expected.

Changelog-added: Dynamic message list style can now match on multiple
 email headers.
Signed-off-by: Christoph Heiss <christoph@c8h4.io>
Acked-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
Christoph Heiss
2024-12-16 21:39:50 +01:00
committed by Robin Jarry
parent a8bac8b75e
commit 65dc87db62
3 changed files with 176 additions and 31 deletions

View File

@@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"maps"
"os"
"regexp"
"strconv"
@@ -121,6 +122,11 @@ var StyleNames = map[string]StyleObject{
"selector_chooser": STYLE_SELECTOR_CHOOSER,
}
type StyleHeaderPattern struct {
RawPattern string
Re *regexp.Regexp
}
type Style struct {
Fg vaxis.Color
Bg vaxis.Color
@@ -130,9 +136,9 @@ type Style struct {
Reverse bool
Italic bool
Dim bool
header string // only for msglist
pattern string // only for msglist
re *regexp.Regexp // only for msglist
// Only for msglist, maps header -> pattern/regexp
// All regexps must match in order for the style to be applied
headerPatterns map[string]*StyleHeaderPattern
}
func (s Style) Get() vaxis.Style {
@@ -182,6 +188,12 @@ func (s *Style) Reset() *Style {
return s
}
func (s *Style) hasSameHeaderPatterns(other map[string]*StyleHeaderPattern) bool {
return maps.EqualFunc(s.headerPatterns, other, func(a, b *StyleHeaderPattern) bool {
return a.RawPattern == b.RawPattern
})
}
func boolSwitch(val string, cur_val bool) (bool, error) {
switch val {
case "true":
@@ -370,14 +382,23 @@ func (c *StyleConf) getStyle(h *mail.Header) *Style {
if h == nil {
return &c.base
}
style := &c.base
// All dynamic styles must be iterated through, as later ones might be a
// narrower match based due to multiple header patterns.
for _, s := range c.dynamic {
val, _ := h.Text(s.header)
if s.re.MatchString(val) {
s = c.base.composeWith([]*Style{&s})
return &s
allMatch := true
for header, pattern := range s.headerPatterns {
val, _ := h.Text(header)
allMatch = allMatch && pattern.Re.MatchString(val)
}
if allMatch {
s := c.base.composeWith([]*Style{&s})
style = &s
}
}
return &c.base
return style
}
func (ss StyleSet) Get(so StyleObject, h *mail.Header) vaxis.Style {
@@ -483,18 +504,30 @@ func (ss *StyleSet) ParseStyleSet(file *ini.File) error {
return nil
}
var styleObjRe = regexp.MustCompile(`^([\w\*\?]+)(?:\.([\w-]+),(.+?))?(\.selected)?\.(\w+)$`)
var (
styleObjRe = regexp.MustCompile(`^([\w\*\?]+)(?:\.([\w-]+,.+?)+?)?(\.selected)?\.(\w+)$`)
styleHeaderPatternsRe = regexp.MustCompile(`([\w-]+),(.+?)\.`)
)
func (ss *StyleSet) parseKey(key *ini.Key, selected bool) error {
groups := styleObjRe.FindStringSubmatch(key.Name())
if groups == nil {
return errors.New("invalid style syntax: " + key.Name())
}
if (groups[4] == ".selected") != selected {
if (groups[3] == ".selected") != selected {
return nil
}
obj, attr := groups[1], groups[5]
header, pattern := groups[2], groups[3]
obj, attr := groups[1], groups[4]
// As there can be multiple header patterns, match them separately, one
// by one
headerMatches := styleHeaderPatternsRe.FindAllStringSubmatch(groups[2]+".", -1)
headerPatterns := make(map[string]*StyleHeaderPattern)
for _, match := range headerMatches {
headerPatterns[match[1]] = &StyleHeaderPattern{
RawPattern: match[2],
}
}
objRe, err := fnmatchToRegex(obj)
if err != nil {
@@ -506,12 +539,12 @@ func (ss *StyleSet) parseKey(key *ini.Key, selected bool) error {
continue
}
if !selected {
err = ss.objects[so].update(header, pattern, attr, key.Value())
err = ss.objects[so].update(headerPatterns, attr, key.Value())
if err != nil {
return fmt.Errorf("%s=%s: %w", key.Name(), key.Value(), err)
}
}
err = ss.selected[so].update(header, pattern, attr, key.Value())
err = ss.selected[so].update(headerPatterns, attr, key.Value())
if err != nil {
return fmt.Errorf("%s=%s: %w", key.Name(), key.Value(), err)
}
@@ -523,34 +556,41 @@ func (ss *StyleSet) parseKey(key *ini.Key, selected bool) error {
return nil
}
func (c *StyleConf) update(header, pattern, attr, val string) error {
if header == "" || pattern == "" {
func (c *StyleConf) update(headerPatterns map[string]*StyleHeaderPattern, attr, val string) error {
if len(headerPatterns) == 0 {
return (&c.base).Set(attr, val)
}
// Check existing entries and overwrite ones with same header/pattern
for i := range c.dynamic {
s := &c.dynamic[i]
if s.header == header && s.pattern == pattern {
if s.hasSameHeaderPatterns(headerPatterns) {
return s.Set(attr, val)
}
}
s := Style{
header: header,
pattern: pattern,
}
if strings.HasPrefix(pattern, "~") {
pattern = pattern[1:]
} else {
pattern = "^" + regexp.QuoteMeta(pattern) + "$"
}
re, err := regexp.Compile(pattern)
s := Style{}
err := (&s).Set(attr, val)
if err != nil {
return err
}
err = (&s).Set(attr, val)
if err != nil {
return err
for _, p := range headerPatterns {
var pattern string
if strings.HasPrefix(p.RawPattern, "~") {
pattern = p.RawPattern[1:]
} else {
pattern = "^" + regexp.QuoteMeta(p.RawPattern) + "$"
}
re, err := regexp.Compile(pattern)
if err != nil {
return err
}
p.Re = re
}
s.re = re
s.headerPatterns = headerPatterns
c.dynamic = append(c.dynamic, s)
return nil
}

89
config/style_test.go Normal file
View File

@@ -0,0 +1,89 @@
package config
import (
"testing"
"github.com/emersion/go-message/mail"
"github.com/go-ini/ini"
)
const multiHeaderStyleset string = `
msglist_*.fg = salmon
msglist_*.From,~^"Bob Foo".fg = khaki
msglist_*.From,~^"Bob Foo".selected.fg = palegreen
msglist_*.From,~^"Bob Foo".Subject,~PATCH.fg = coral
msglist_*.From,~^"Bob Foo".Subject,~PATCH.X-Baz,exact.X-Clacks-Overhead,~Pratchett$.fg = plum
msglist_*.From,~^"Bob Foo".Subject,~PATCH.X-Clacks-Overhead,~Pratchett$.fg = pink
`
func TestStyleMultiHeaderPattern(t *testing.T) {
ini, err := ini.Load([]byte(multiHeaderStyleset))
if err != nil {
t.Errorf("failed to load styleset: %v", err)
}
ss := NewStyleSet()
err = ss.ParseStyleSet(ini)
if err != nil {
t.Errorf("failed to parse styleset: %v", err)
}
t.Run("default color", func(t *testing.T) {
var h mail.Header
h.SetAddressList("From", []*mail.Address{{"Alice Foo", "alice@foo.org"}})
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
if s.Foreground != colorNames["salmon"] {
t.Errorf("expected:#%v got:#%v", colorNames["salmon"], s.Foreground)
}
})
t.Run("single header", func(t *testing.T) {
var h mail.Header
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "bob@foo.org"}})
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
if s.Foreground != colorNames["khaki"] {
t.Errorf("expected:#%v got:#%v", colorNames["khaki"], s.Foreground)
}
})
t.Run("two headers", func(t *testing.T) {
var h mail.Header
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
h.SetSubject("[PATCH] tests")
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
if s.Foreground != colorNames["coral"] {
t.Errorf("expected:#%x got:#%x", colorNames["coral"], s.Foreground)
}
})
t.Run("multiple headers", func(t *testing.T) {
var h mail.Header
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
h.SetSubject("[PATCH] tests")
h.SetText("X-Clacks-Overhead", "GNU Terry Pratchett")
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
if s.Foreground != colorNames["pink"] {
t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground)
}
})
t.Run("preserves order-sensitivity", func(t *testing.T) {
var h mail.Header
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
h.SetSubject("[PATCH] tests")
h.SetText("X-Clacks-Overhead", "GNU Terry Pratchett")
h.SetText("X-Baz", "exact")
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
// The "pink" entry comes later, so will overrule the more exact
// match with color "plum"
if s.Foreground != colorNames["pink"] {
t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground)
}
})
}

View File

@@ -315,6 +315,10 @@ syntax is as follows:
If _<header_value>_ starts with a tilde character _~_, it will be interpreted as
a regular expression.
_<header>,<header_value>_ can be specified multiple times to narrow down matches
to more than one email header value. In that case, all given headers must match
for the dynamic style to apply.
Examples:
```
@@ -323,6 +327,7 @@ msglist\*.X-Sourcehut-Patchset-Update,NEEDS\_REVISION.fg = yellow
msglist\*.X-Sourcehut-Patchset-Update,REJECTED.fg = red
"msglist_*.Subject,~^(\\[[\w-]+\]\\s*)?\\[(RFC )?PATCH.fg" = #ffffaf
"msglist_*.Subject,~^(\\[[\w-]+\]\\s*)?\\[(RFC )?PATCH.selected.fg" = #ffffaf
"msglist_*.From,~^Bob.Subject,~^(\\[[\w-]+\]\\s*)?\\[(RFC )?PATCH.selected.fg" = #ffffaf
```
When a dynamic style is matched to an email header, it will be used in priority
@@ -336,6 +341,17 @@ msglist_*.Subject,~foobar.fg = red
An email with _foobar_ in its subject will be colored in _red_ all the time,
since *msglist_\** also applies to *msglist\_marked*.
When multiple _<header>,<header_value>_ pairs are given, the last style which
matches all given patterns will be applied. Provided the following styleset:
```
msglist_*.From,~^Bob.Subject,~foobar.fg = red
msglist_*.From,~^Bob.fg = blue
```
An email from _Bob_ with _foobar_ in its subject will be colored in _blue_,
since the second style is a full match too.
# COLORS
The color values are set using any of the following methods: