Case-fold patterns.

This commit is contained in:
Nuno Cruces 2022-12-15 03:05:44 +00:00
parent df14a314e4
commit 96942f9acc
5 changed files with 162 additions and 80 deletions

102
file.go
View file

@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"unicode"
) )
// SelectFile displays the file selection dialog. // SelectFile displays the file selection dialog.
@ -69,6 +70,8 @@ func Filename(filename string) Option {
// FileFilter is an Option that sets a filename filter. // FileFilter is an Option that sets a filename filter.
// //
// On Windows and macOS filtering is always case-insensitive.
//
// macOS hides filename filters from the user, // macOS hides filename filters from the user,
// and only supports filtering by extension // and only supports filtering by extension
// (or "uniform type identifiers"). // (or "uniform type identifiers").
@ -78,6 +81,7 @@ func Filename(filename string) Option {
type FileFilter struct { type FileFilter struct {
Name string // display string that describes the filter (optional) Name string // display string that describes the filter (optional)
Patterns []string // filter patterns for the display string Patterns []string // filter patterns for the display string
CaseFold bool // if set patterns are matched case-insensitively
} }
func (f FileFilter) apply(o *options) { func (f FileFilter) apply(o *options) {
@ -91,7 +95,7 @@ func (f FileFilters) apply(o *options) {
o.fileFilters = append(o.fileFilters, f...) o.fileFilters = append(o.fileFilters, f...)
} }
// Windows' patterns need a name. // Windows patterns need a name.
func (f FileFilters) name() { func (f FileFilters) name() {
for i, filter := range f { for i, filter := range f {
if filter.Name == "" { if filter.Name == "" {
@ -100,47 +104,42 @@ func (f FileFilters) name() {
} }
} }
// Windows' patterns are case insensitive, don't support character classes or escaping. // Windows patterns are case-insensitive, don't support character classes or escaping.
// //
// First we remove character classes, then escaping. Patterns with literal wildcards are invalid. // First we remove character classes, then escaping. Patterns with literal wildcards are invalid.
// The semicolon is a separator, so we replace it with the single character wildcard. // The semicolon is a separator, so we replace it with the single character wildcard.
// Empty and invalid filters/patterns are ignored.
func (f FileFilters) simplify() { func (f FileFilters) simplify() {
var i = 0 for i := range f {
for _, filter := range f {
var j = 0 var j = 0
for _, pattern := range filter.Patterns { for _, pattern := range f[i].Patterns {
var escape, invalid bool var escape, invalid bool
var buf strings.Builder var buf strings.Builder
for _, r := range removeClasses(pattern) { for _, b := range []byte(removeClasses(pattern)) {
if !escape && r == '\\' { if !escape && b == '\\' {
escape = true escape = true
continue continue
} }
if escape && (r == '*' || r == '?') { if escape && (b == '*' || b == '?') {
invalid = true invalid = true
break break
} }
if r == ';' { if b == ';' {
r = '?' b = '?'
} }
buf.WriteRune(r) buf.WriteByte(b)
escape = false escape = false
} }
if buf.Len() > 0 && !invalid { if buf.Len() > 0 && !invalid {
filter.Patterns[j] = buf.String() f[i].Patterns[j] = buf.String()
j++ j++
} }
} }
if j > 0 { if j != 0 {
filter.Patterns = filter.Patterns[:j] f[i].Patterns = f[i].Patterns[:j]
f[i] = filter } else {
i++ f[i].Patterns = nil
} }
} }
for ; i < len(f); i++ {
f[i] = FileFilter{}
}
} }
// macOS types may be specified as extension strings without the leading period, // macOS types may be specified as extension strings without the leading period,
@ -163,17 +162,17 @@ func (f FileFilters) types() []string {
var escape bool var escape bool
var buf strings.Builder var buf strings.Builder
for _, r := range removeClasses(ext) { for _, b := range []byte(removeClasses(ext)) {
switch { switch {
case escape: case escape:
escape = false escape = false
case r == '\\': case b == '\\':
escape = true escape = true
continue continue
case r == '*' || r == '?': case b == '*' || b == '?':
return nil return nil
} }
buf.WriteRune(r) buf.WriteByte(b)
} }
if buf.Len() > 0 { if buf.Len() > 0 {
res = append(res, buf.String()) res = append(res, buf.String())
@ -187,8 +186,57 @@ func (f FileFilters) types() []string {
return append([]string{""}, res...) return append([]string{""}, res...)
} }
// Unix patterns are case-sensitive. Fold them if requested.
func (f FileFilters) casefold() {
for i := range f {
if !f[i].CaseFold {
continue
}
for j, pattern := range f[i].Patterns {
var class = -1
var escape bool
var buf strings.Builder
for i, r := range pattern {
switch {
case escape:
escape = false
case r == '\\':
escape = true
case class < 0:
if r == '[' {
class = i
}
case class < i-1:
if r == ']' {
class = -1
}
}
nr := unicode.SimpleFold(r)
if r == nr {
buf.WriteRune(r)
continue
}
if class < 0 {
buf.WriteByte('[')
}
buf.WriteRune(r)
for r != nr {
buf.WriteRune(nr)
nr = unicode.SimpleFold(nr)
}
if class < 0 {
buf.WriteByte(']')
}
}
f[i].Patterns[j] = buf.String()
}
}
}
// Remove character classes from pattern, assuming case insensitivity. // Remove character classes from pattern, assuming case insensitivity.
// Classes of one character (case insensitive) are replaced by the character. // Classes of one character (case-insensitive) are replaced by the character.
// Others are replaced by the single character wildcard. // Others are replaced by the single character wildcard.
func removeClasses(pattern string) string { func removeClasses(pattern string) string {
var res strings.Builder var res strings.Builder
@ -229,7 +277,7 @@ func removeClasses(pattern string) string {
// Find a character class in the pattern. // Find a character class in the pattern.
func findClass(pattern string) (start, end int) { func findClass(pattern string) (start, end int) {
start = -1 start = -1
escape := false var escape bool
for i, b := range []byte(pattern) { for i, b := range []byte(pattern) {
switch { switch {
case escape: case escape:
@ -240,7 +288,7 @@ func findClass(pattern string) (start, end int) {
if b == '[' { if b == '[' {
start = i start = i
} }
case 0 <= start && start < i-1: case start < i-1:
if b == ']' { if b == ']' {
return start, i + 1 return start, i + 1
} }

View file

@ -10,9 +10,9 @@ func TestFileFilters_name(t *testing.T) {
data FileFilters data FileFilters
want string want string
}{ }{
{FileFilters{{"", []string{`*.png`}}}, "*.png"}, {FileFilters{{"", []string{`*.png`}, true}}, "*.png"},
{FileFilters{{"", []string{`*.png`, `*.jpg`}}}, "*.png *.jpg"}, {FileFilters{{"", []string{`*.png`, `*.jpg`}, true}}, "*.png *.jpg"},
{FileFilters{{"Image files", []string{`*.png`, `*.jpg`}}}, "Image files"}, {FileFilters{{"Image files", []string{`*.png`, `*.jpg`}, true}}, "Image files"},
} }
for i, tt := range tests { for i, tt := range tests {
tt.data.name() tt.data.name()
@ -24,57 +24,88 @@ func TestFileFilters_name(t *testing.T) {
func TestFileFilters_simplify(t *testing.T) { func TestFileFilters_simplify(t *testing.T) {
tests := []struct { tests := []struct {
data FileFilters data []string
want []string want []string
}{ }{
{FileFilters{{"", []string{``}}}, nil}, {[]string{``}, nil},
{FileFilters{{"", []string{`*.png`}}}, []string{"*.png"}}, {[]string{`*.\?`}, nil},
{FileFilters{{"", []string{`*.pn?`}}}, []string{"*.pn?"}}, {[]string{`*.png`}, []string{"*.png"}},
{FileFilters{{"", []string{`*.pn;`}}}, []string{"*.pn?"}}, {[]string{`*.pn?`}, []string{"*.pn?"}},
{FileFilters{{"", []string{`*.pn\?`}}}, nil}, {[]string{`*.pn;`}, []string{"*.pn?"}},
{FileFilters{{"", []string{`*.[PpNnGg]`}}}, []string{"*.?"}}, {[]string{`*.[PpNnGg]`}, []string{"*.?"}},
{FileFilters{{"", []string{`*.[Pp][Nn][Gg]`}}}, []string{"*.PNG"}}, {[]string{`*.[Pp][Nn][Gg]`}, []string{"*.PNG"}},
{FileFilters{{"", []string{`*.[Pp][\Nn][G\g]`}}}, []string{"*.PNG"}}, {[]string{`*.[Pp][\Nn][G\g]`}, []string{"*.PNG"}},
{FileFilters{{"", []string{`*.[PNG`}}}, []string{"*.[PNG"}}, {[]string{`*.[PNG`}, []string{"*.[PNG"}},
{FileFilters{{"", []string{`*.]PNG`}}}, []string{"*.]PNG"}}, {[]string{`*.]PNG`}, []string{"*.]PNG"}},
{FileFilters{{"", []string{`*.[[]PNG`}}}, []string{"*.[PNG"}}, {[]string{`*.[[]PNG`}, []string{"*.[PNG"}},
{FileFilters{{"", []string{`*.[]]PNG`}}}, []string{"*.]PNG"}}, {[]string{`*.[]]PNG`}, []string{"*.]PNG"}},
{FileFilters{{"", []string{`*.[\[]PNG`}}}, []string{"*.[PNG"}}, {[]string{`*.[\[]PNG`}, []string{"*.[PNG"}},
{FileFilters{{"", []string{`*.[\]]PNG`}}}, []string{"*.]PNG"}}, {[]string{`*.[\]]PNG`}, []string{"*.]PNG"}},
{FileFilters{{"", []string{`public.png`}}}, []string{"public.png"}}, {[]string{`public.png`}, []string{"public.png"}},
} }
for i, tt := range tests { for i, tt := range tests {
tt.data.simplify() filters := FileFilters{FileFilter{Patterns: tt.data}}
if got := tt.data[0].Patterns; !reflect.DeepEqual(got, tt.want) { filters.simplify()
if got := filters[0].Patterns; !reflect.DeepEqual(got, tt.want) {
t.Errorf("FileFilters.simplify[%d] = %q; want %q", i, got, tt.want) t.Errorf("FileFilters.simplify[%d] = %q; want %q", i, got, tt.want)
} }
} }
} }
func TestFileFilters_casefold(t *testing.T) {
tests := []struct {
data []string
want []string
}{
{[]string{`*.png`}, []string{`*.[pP][nN][gG]`}},
{[]string{`*.pn?`}, []string{`*.[pP][nN]?`}},
{[]string{`*.pn;`}, []string{`*.[pP][nN];`}},
{[]string{`*.pn\?`}, []string{`*.[pP][nN]\?`}},
{[]string{`*.[PpNnGg]`}, []string{`*.[PppPNnnNGggG]`}},
{[]string{`*.[Pp][Nn][Gg]`}, []string{`*.[PppP][NnnN][GggG]`}},
{[]string{`*.[Pp][\Nn][G\g]`}, []string{`*.[PppP][\NnnN][Gg\gG]`}},
{[]string{`*.[PNG`}, []string{`*.[PpNnGg`}},
{[]string{`*.]PNG`}, []string{`*.][Pp][Nn][Gg]`}},
{[]string{`*.[[]PNG`}, []string{`*.[[][Pp][Nn][Gg]`}},
{[]string{`*.[]]PNG`}, []string{`*.[]][Pp][Nn][Gg]`}},
{[]string{`*.[\[]PNG`}, []string{`*.[\[][Pp][Nn][Gg]`}},
{[]string{`*.[\]]PNG`}, []string{`*.[\]][Pp][Nn][Gg]`}},
}
for i, tt := range tests {
filters := FileFilters{FileFilter{Patterns: tt.data}}
filters[0].CaseFold = true
filters.casefold()
if got := filters[0].Patterns; !reflect.DeepEqual(got, tt.want) {
t.Errorf("FileFilters.casefold[%d] = %q; want %q", i, got, tt.want)
}
}
}
func TestFileFilters_types(t *testing.T) { func TestFileFilters_types(t *testing.T) {
tests := []struct { tests := []struct {
data FileFilters data []string
want []string want []string
}{ }{
{FileFilters{{"", []string{``}}}, nil}, {[]string{``}, nil},
{FileFilters{{"", []string{`*.png`}}}, []string{"", "png"}}, {[]string{`*.png`}, []string{"", "png"}},
{FileFilters{{"", []string{`*.pn?`}}}, nil}, {[]string{`*.pn?`}, nil},
{FileFilters{{"", []string{`*.pn;`}}}, []string{"", "pn;"}}, {[]string{`*.pn;`}, []string{"", "pn;"}},
{FileFilters{{"", []string{`*.pn\?`}}}, []string{"", "pn?"}}, {[]string{`*.pn\?`}, []string{"", "pn?"}},
{FileFilters{{"", []string{`*.[PpNnGg]`}}}, nil}, {[]string{`*.[PpNnGg]`}, nil},
{FileFilters{{"", []string{`*.[Pp][Nn][Gg]`}}}, []string{"", "PNG"}}, {[]string{`*.[Pp][Nn][Gg]`}, []string{"", "PNG"}},
{FileFilters{{"", []string{`*.[Pp][\Nn][G\g]`}}}, []string{"", "PNG"}}, {[]string{`*.[Pp][\Nn][G\g]`}, []string{"", "PNG"}},
{FileFilters{{"", []string{`*.[PNG`}}}, []string{"", "[PNG"}}, {[]string{`*.[PNG`}, []string{"", "[PNG"}},
{FileFilters{{"", []string{`*.]PNG`}}}, []string{"", "]PNG"}}, {[]string{`*.]PNG`}, []string{"", "]PNG"}},
{FileFilters{{"", []string{`*.[[]PNG`}}}, []string{"", "[PNG"}}, {[]string{`*.[[]PNG`}, []string{"", "[PNG"}},
{FileFilters{{"", []string{`*.[]]PNG`}}}, []string{"", "]PNG"}}, {[]string{`*.[]]PNG`}, []string{"", "]PNG"}},
{FileFilters{{"", []string{`*.[\[]PNG`}}}, []string{"", "[PNG"}}, {[]string{`*.[\[]PNG`}, []string{"", "[PNG"}},
{FileFilters{{"", []string{`*.[\]]PNG`}}}, []string{"", "]PNG"}}, {[]string{`*.[\]]PNG`}, []string{"", "]PNG"}},
{FileFilters{{"", []string{`public.png`}}}, []string{"", "public.png"}}, {[]string{`public.png`}, []string{"", "public.png"}},
{FileFilters{{"", []string{`-public-.png`}}}, []string{"", "png"}}, {[]string{`-public-.png`}, []string{"", "png"}},
} }
for i, tt := range tests { for i, tt := range tests {
if got := tt.data.types(); !reflect.DeepEqual(got, tt.want) { filters := FileFilters{FileFilter{Patterns: tt.data}}
if got := filters.types(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("FileFilters.types[%d] = %v; want %v", i, got, tt.want) t.Errorf("FileFilters.types[%d] = %v; want %v", i, got, tt.want)
} }
} }

View file

@ -20,9 +20,9 @@ func ExampleSelectFile() {
zenity.SelectFile( zenity.SelectFile(
zenity.Filename(defaultPath), zenity.Filename(defaultPath),
zenity.FileFilters{ zenity.FileFilters{
{"Go files", []string{"*.go"}}, {"Go files", []string{"*.go"}, true},
{"Web files", []string{"*.html", "*.js", "*.css"}}, {"Web files", []string{"*.html", "*.js", "*.css"}, true},
{"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}}, {"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}, true},
}) })
} }
@ -30,9 +30,9 @@ func ExampleSelectFileMultiple() {
zenity.SelectFileMultiple( zenity.SelectFileMultiple(
zenity.Filename(defaultPath), zenity.Filename(defaultPath),
zenity.FileFilters{ zenity.FileFilters{
{"Go files", []string{"*.go"}}, {"Go files", []string{"*.go"}, true},
{"Web files", []string{"*.html", "*.js", "*.css"}}, {"Web files", []string{"*.html", "*.js", "*.css"}, true},
{"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}}, {"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}, true},
}) })
} }
@ -41,9 +41,9 @@ func ExampleSelectFileSave() {
zenity.ConfirmOverwrite(), zenity.ConfirmOverwrite(),
zenity.Filename(defaultName), zenity.Filename(defaultName),
zenity.FileFilters{ zenity.FileFilters{
{"Go files", []string{"*.go"}}, {"Go files", []string{"*.go"}, true},
{"Web files", []string{"*.html", "*.js", "*.css"}}, {"Web files", []string{"*.html", "*.js", "*.css"}, true},
{"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}}, {"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}, true},
}) })
} }
@ -232,7 +232,7 @@ func TestSelectFileSave_script(t *testing.T) {
str, err := zenity.SelectFileSave( str, err := zenity.SelectFileSave(
zenity.ConfirmOverwrite(), zenity.ConfirmOverwrite(),
zenity.Filename("Χρτο.go"), zenity.Filename("Χρτο.go"),
zenity.FileFilter{"Go files", []string{"*.go"}}, zenity.FileFilter{"Go files", []string{"*.go"}, true},
) )
if skip, err := skip(err); skip { if skip, err := skip(err); skip {
t.Skip("skipping:", err) t.Skip("skipping:", err)

View file

@ -340,6 +340,9 @@ func initFilters(filters FileFilters) []uint16 {
filters.name() filters.name()
var res []uint16 var res []uint16
for _, f := range filters { for _, f := range filters {
if f.Name == "" || len(f.Patterns) == 0 {
continue
}
res = append(res, utf16.Encode([]rune(f.Name))...) res = append(res, utf16.Encode([]rune(f.Name))...)
res = append(res, 0) res = append(res, 0)
for _, p := range f.Patterns { for _, p := range f.Patterns {

View file

@ -56,11 +56,11 @@ func Test_applyOptions(t *testing.T) {
{name: "ConfirmCreate", args: ConfirmCreate(), want: options{confirmCreate: true}}, {name: "ConfirmCreate", args: ConfirmCreate(), want: options{confirmCreate: true}},
{name: "ShowHidden", args: ShowHidden(), want: options{showHidden: true}}, {name: "ShowHidden", args: ShowHidden(), want: options{showHidden: true}},
{name: "Filename", args: Filename("file.go"), want: options{filename: "file.go"}}, {name: "Filename", args: Filename("file.go"), want: options{filename: "file.go"}},
{name: "FileFilter", args: FileFilter{"Go files", []string{"*.go"}}, want: options{ {name: "FileFilter", args: FileFilter{"Go files", []string{"*.go"}, true}, want: options{
fileFilters: FileFilters{{"Go files", []string{"*.go"}}}, fileFilters: FileFilters{{"Go files", []string{"*.go"}, true}},
}}, }},
{name: "FileFilters", args: FileFilters{{"Go files", []string{"*.go"}}}, want: options{ {name: "FileFilters", args: FileFilters{{"Go files", []string{"*.go"}, true}}, want: options{
fileFilters: FileFilters{{"Go files", []string{"*.go"}}}, fileFilters: FileFilters{{"Go files", []string{"*.go"}, true}},
}}, }},
// Color selection options // Color selection options