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"
"path/filepath"
"strings"
"unicode"
)
// SelectFile displays the file selection dialog.
@ -69,6 +70,8 @@ func Filename(filename string) Option {
// 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,
// and only supports filtering by extension
// (or "uniform type identifiers").
@ -78,6 +81,7 @@ func Filename(filename string) Option {
type FileFilter struct {
Name string // display string that describes the filter (optional)
Patterns []string // filter patterns for the display string
CaseFold bool // if set patterns are matched case-insensitively
}
func (f FileFilter) apply(o *options) {
@ -91,7 +95,7 @@ func (f FileFilters) apply(o *options) {
o.fileFilters = append(o.fileFilters, f...)
}
// Windows' patterns need a name.
// Windows patterns need a name.
func (f FileFilters) name() {
for i, filter := range f {
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.
// 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() {
var i = 0
for _, filter := range f {
for i := range f {
var j = 0
for _, pattern := range filter.Patterns {
for _, pattern := range f[i].Patterns {
var escape, invalid bool
var buf strings.Builder
for _, r := range removeClasses(pattern) {
if !escape && r == '\\' {
for _, b := range []byte(removeClasses(pattern)) {
if !escape && b == '\\' {
escape = true
continue
}
if escape && (r == '*' || r == '?') {
if escape && (b == '*' || b == '?') {
invalid = true
break
}
if r == ';' {
r = '?'
if b == ';' {
b = '?'
}
buf.WriteRune(r)
buf.WriteByte(b)
escape = false
}
if buf.Len() > 0 && !invalid {
filter.Patterns[j] = buf.String()
f[i].Patterns[j] = buf.String()
j++
}
}
if j > 0 {
filter.Patterns = filter.Patterns[:j]
f[i] = filter
i++
if j != 0 {
f[i].Patterns = f[i].Patterns[:j]
} else {
f[i].Patterns = nil
}
}
for ; i < len(f); i++ {
f[i] = FileFilter{}
}
}
// 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 buf strings.Builder
for _, r := range removeClasses(ext) {
for _, b := range []byte(removeClasses(ext)) {
switch {
case escape:
escape = false
case r == '\\':
case b == '\\':
escape = true
continue
case r == '*' || r == '?':
case b == '*' || b == '?':
return nil
}
buf.WriteRune(r)
buf.WriteByte(b)
}
if buf.Len() > 0 {
res = append(res, buf.String())
@ -187,8 +186,57 @@ func (f FileFilters) types() []string {
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.
// 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.
func removeClasses(pattern string) string {
var res strings.Builder
@ -229,7 +277,7 @@ func removeClasses(pattern string) string {
// Find a character class in the pattern.
func findClass(pattern string) (start, end int) {
start = -1
escape := false
var escape bool
for i, b := range []byte(pattern) {
switch {
case escape:
@ -240,7 +288,7 @@ func findClass(pattern string) (start, end int) {
if b == '[' {
start = i
}
case 0 <= start && start < i-1:
case start < i-1:
if b == ']' {
return start, i + 1
}

View file

@ -10,9 +10,9 @@ func TestFileFilters_name(t *testing.T) {
data FileFilters
want string
}{
{FileFilters{{"", []string{`*.png`}}}, "*.png"},
{FileFilters{{"", []string{`*.png`, `*.jpg`}}}, "*.png *.jpg"},
{FileFilters{{"Image files", []string{`*.png`, `*.jpg`}}}, "Image files"},
{FileFilters{{"", []string{`*.png`}, true}}, "*.png"},
{FileFilters{{"", []string{`*.png`, `*.jpg`}, true}}, "*.png *.jpg"},
{FileFilters{{"Image files", []string{`*.png`, `*.jpg`}, true}}, "Image files"},
}
for i, tt := range tests {
tt.data.name()
@ -24,57 +24,88 @@ func TestFileFilters_name(t *testing.T) {
func TestFileFilters_simplify(t *testing.T) {
tests := []struct {
data FileFilters
data []string
want []string
}{
{FileFilters{{"", []string{``}}}, nil},
{FileFilters{{"", []string{`*.png`}}}, []string{"*.png"}},
{FileFilters{{"", []string{`*.pn?`}}}, []string{"*.pn?"}},
{FileFilters{{"", []string{`*.pn;`}}}, []string{"*.pn?"}},
{FileFilters{{"", []string{`*.pn\?`}}}, nil},
{FileFilters{{"", []string{`*.[PpNnGg]`}}}, []string{"*.?"}},
{FileFilters{{"", []string{`*.[Pp][Nn][Gg]`}}}, []string{"*.PNG"}},
{FileFilters{{"", []string{`*.[Pp][\Nn][G\g]`}}}, []string{"*.PNG"}},
{FileFilters{{"", []string{`*.[PNG`}}}, []string{"*.[PNG"}},
{FileFilters{{"", []string{`*.]PNG`}}}, []string{"*.]PNG"}},
{FileFilters{{"", []string{`*.[[]PNG`}}}, []string{"*.[PNG"}},
{FileFilters{{"", []string{`*.[]]PNG`}}}, []string{"*.]PNG"}},
{FileFilters{{"", []string{`*.[\[]PNG`}}}, []string{"*.[PNG"}},
{FileFilters{{"", []string{`*.[\]]PNG`}}}, []string{"*.]PNG"}},
{FileFilters{{"", []string{`public.png`}}}, []string{"public.png"}},
{[]string{``}, nil},
{[]string{`*.\?`}, nil},
{[]string{`*.png`}, []string{"*.png"}},
{[]string{`*.pn?`}, []string{"*.pn?"}},
{[]string{`*.pn;`}, []string{"*.pn?"}},
{[]string{`*.[PpNnGg]`}, []string{"*.?"}},
{[]string{`*.[Pp][Nn][Gg]`}, []string{"*.PNG"}},
{[]string{`*.[Pp][\Nn][G\g]`}, []string{"*.PNG"}},
{[]string{`*.[PNG`}, []string{"*.[PNG"}},
{[]string{`*.]PNG`}, []string{"*.]PNG"}},
{[]string{`*.[[]PNG`}, []string{"*.[PNG"}},
{[]string{`*.[]]PNG`}, []string{"*.]PNG"}},
{[]string{`*.[\[]PNG`}, []string{"*.[PNG"}},
{[]string{`*.[\]]PNG`}, []string{"*.]PNG"}},
{[]string{`public.png`}, []string{"public.png"}},
}
for i, tt := range tests {
tt.data.simplify()
if got := tt.data[0].Patterns; !reflect.DeepEqual(got, tt.want) {
filters := FileFilters{FileFilter{Patterns: tt.data}}
filters.simplify()
if got := filters[0].Patterns; !reflect.DeepEqual(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) {
tests := []struct {
data FileFilters
data []string
want []string
}{
{FileFilters{{"", []string{``}}}, nil},
{FileFilters{{"", []string{`*.png`}}}, []string{"", "png"}},
{FileFilters{{"", []string{`*.pn?`}}}, nil},
{FileFilters{{"", []string{`*.pn;`}}}, []string{"", "pn;"}},
{FileFilters{{"", []string{`*.pn\?`}}}, []string{"", "pn?"}},
{FileFilters{{"", []string{`*.[PpNnGg]`}}}, nil},
{FileFilters{{"", []string{`*.[Pp][Nn][Gg]`}}}, []string{"", "PNG"}},
{FileFilters{{"", []string{`*.[Pp][\Nn][G\g]`}}}, []string{"", "PNG"}},
{FileFilters{{"", []string{`*.[PNG`}}}, []string{"", "[PNG"}},
{FileFilters{{"", []string{`*.]PNG`}}}, []string{"", "]PNG"}},
{FileFilters{{"", []string{`*.[[]PNG`}}}, []string{"", "[PNG"}},
{FileFilters{{"", []string{`*.[]]PNG`}}}, []string{"", "]PNG"}},
{FileFilters{{"", []string{`*.[\[]PNG`}}}, []string{"", "[PNG"}},
{FileFilters{{"", []string{`*.[\]]PNG`}}}, []string{"", "]PNG"}},
{FileFilters{{"", []string{`public.png`}}}, []string{"", "public.png"}},
{FileFilters{{"", []string{`-public-.png`}}}, []string{"", "png"}},
{[]string{``}, nil},
{[]string{`*.png`}, []string{"", "png"}},
{[]string{`*.pn?`}, nil},
{[]string{`*.pn;`}, []string{"", "pn;"}},
{[]string{`*.pn\?`}, []string{"", "pn?"}},
{[]string{`*.[PpNnGg]`}, nil},
{[]string{`*.[Pp][Nn][Gg]`}, []string{"", "PNG"}},
{[]string{`*.[Pp][\Nn][G\g]`}, []string{"", "PNG"}},
{[]string{`*.[PNG`}, []string{"", "[PNG"}},
{[]string{`*.]PNG`}, []string{"", "]PNG"}},
{[]string{`*.[[]PNG`}, []string{"", "[PNG"}},
{[]string{`*.[]]PNG`}, []string{"", "]PNG"}},
{[]string{`*.[\[]PNG`}, []string{"", "[PNG"}},
{[]string{`*.[\]]PNG`}, []string{"", "]PNG"}},
{[]string{`public.png`}, []string{"", "public.png"}},
{[]string{`-public-.png`}, []string{"", "png"}},
}
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)
}
}

View file

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

View file

@ -340,6 +340,9 @@ func initFilters(filters FileFilters) []uint16 {
filters.name()
var res []uint16
for _, f := range filters {
if f.Name == "" || len(f.Patterns) == 0 {
continue
}
res = append(res, utf16.Encode([]rune(f.Name))...)
res = append(res, 0)
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: "ShowHidden", args: ShowHidden(), want: options{showHidden: true}},
{name: "Filename", args: Filename("file.go"), want: options{filename: "file.go"}},
{name: "FileFilter", args: FileFilter{"Go files", []string{"*.go"}}, want: options{
fileFilters: FileFilters{{"Go files", []string{"*.go"}}},
{name: "FileFilter", args: FileFilter{"Go files", []string{"*.go"}, true}, want: options{
fileFilters: FileFilters{{"Go files", []string{"*.go"}, true}},
}},
{name: "FileFilters", args: FileFilters{{"Go files", []string{"*.go"}}}, want: options{
fileFilters: FileFilters{{"Go files", []string{"*.go"}}},
{name: "FileFilters", args: FileFilters{{"Go files", []string{"*.go"}, true}}, want: options{
fileFilters: FileFilters{{"Go files", []string{"*.go"}, true}},
}},
// Color selection options