Compare commits

...

16 Commits

6 changed files with 695 additions and 23 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 8
charset = utf-8
[*.md]
indent_style = space
indent_size = 2

View File

@@ -3,6 +3,4 @@
[![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/xdg.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/xdg) [![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/xdg.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/xdg)
This module seeks to implement parts of the XDG specification that do not rely This module seeks to implement parts of the XDG specification that do not rely
on other software. Parts of the specification that do, eg. mpris-spec and on other software.
notifcation-spec, may in the future be implemented in a separate module that
depends on this one.

View File

@@ -206,7 +206,7 @@ func listDefault (list string, defaul ...string) []string {
index := 0 index := 0
for _, dir := range envDirs { for _, dir := range envDirs {
if Valid(dir) != nil { if Valid(dir) == nil {
dirs[index] = dir dirs[index] = dir
index ++ index ++
} }

548
icon-theme/icon-theme.go Normal file
View File

@@ -0,0 +1,548 @@
// Package iconTheme implements icon-theme-spec version 0.13.
package iconTheme
import "os"
import "io"
import "fmt"
import "log"
import "image"
import "strings"
import "path/filepath"
import "git.tebibyte.media/tomo/xdg/basedir"
import "git.tebibyte.media/tomo/xdg/key-value"
type iconThemeError string
func (err iconThemeError) Error () string { return string(err) }
const (
// ErrThemeNotFound indicates that a theme could not be found.
ErrThemeNotFound = iconThemeError("theme not found")
// ErrGroupMissing indicates that a required group was not found in an
// index.theme file.
ErrGroupMissing = iconThemeError("group missing")
// ErrIconThemeGroupMissing indicates that the [Icon Theme] group was
// not found while parsing an index.theme file.
ErrIconThemeGroupMissing = iconThemeError("icon theme group missing")
// ErrDirectoryGroupMissing indicates that a directory group was not
// found while parsing an index.Theme file.
ErrDirectoryGroupMissing = iconThemeError("directory group missing")
// ErrEntryMissing indicates that a required entry was not found in an
// index.theme file.
ErrEntryMissing = iconThemeError("entry missing")
// ErrBadIconSizeType indicates that an invalid value for an icon size
// type was given.
ErrBadIconSizeType = iconThemeError("bad icon size type")
// ErrCircularDependency indicates that an icon theme depends on one of
// its dependents.
ErrCircularDependency = iconThemeError("circular dependency")
// ErrIconNotFound indicates that an icon could not be found.
ErrIconNotFound = iconThemeError("icon not found")
)
// ThemeDirs returns the set of directories in which themes should be looked for
// in order of preference.
//
// It will return $HOME/.icons (for backwards compatibility), in
// $XDG_DATA_DIRS/icons and in /usr/share/pixmaps. Applications may further add
// their own icon directories to this list.
func ThemeDirs () ([]string, error) {
home, err := os.UserHomeDir()
if err != nil { return nil, err }
dataDirs, err := basedir.DataDirs()
if err != nil { return nil, err }
dirs := make([]string, len(dataDirs) + 2)
dirs[0] = filepath.Join(home, ".icons")
dirs[len(dirs) - 1] = "/usr/share/pixmaps"
for index, dataDir := range dataDirs {
dirs[index + 1] = filepath.Join(dataDir, "icons")
}
return dirs, nil
}
// FindTheme returns the theme of the given name located within the given search
// paths. If no search path is provided, the result of ThemeDirs is used. This
// function will also recursively find and read inherited themes. If an
// inherited theme is malformed and cannot be read, hicolor will be used
// instead. The directory structure and inheritence tree of a theme are only
// determined when this function is run, if a theme has been changed and needs
// to be reloaded this function should be called again.
func FindTheme (name string, path ...string) (Theme, error) {
return findTheme(name, nil, false, path...)
}
// FindThemeWarn is like FindTheme, but emits warnings using the log package if
// it encounters a recoverable error (such as failing to parse/find an inherited
// theme).
func FindThemeWarn (name string, path ...string) (Theme, error) {
return findTheme(name, nil, true, path...)
}
func findTheme (name string, trail []string, warn bool, path ...string) (Theme, error) {
for _, step := range trail {
if step == name {
return Theme { }, ErrCircularDependency
}
}
if len(path) == 0 {
themeDirs, err := ThemeDirs()
if err != nil { return Theme { }, nil }
path = themeDirs
}
// find paths to all instances of this theme
instances := []string { }
for _, dir := range path {
entries, err := os.ReadDir(dir)
if err != nil { continue }
for _, entry := range entries {
if entry.Name() == name {
instances = append (
instances,
filepath.Join(dir, entry.Name()))
break
}
}
}
if len(instances) == 0 { return Theme { }, ErrThemeNotFound }
// find an index.theme
var lastParseErr error
var theme Theme
var parents []string
for _, instance := range instances {
indexPath := filepath.Join(instance, "index.theme")
file, err := os.Open(indexPath)
lastParseErr = err
if err != nil { continue }
defer file.Close()
theme, parents, err = parseThemeIndex(file)
lastParseErr = err
if err != nil { continue }
break
}
if lastParseErr != nil { return Theme { }, lastParseErr }
// expand directories to cover all instances
for index, directory := range theme.Directories {
directory.Paths = make([]string, len(instances))
for pathIndex, path := range directory.Paths {
directory.Paths[pathIndex] = filepath.Join (
instances[pathIndex],
path, directory.Name)
}
theme.Directories[index] = directory
}
// this will not stomp memory its fine
trail = append(trail, name)
// parse parents
for _, parent := range parents {
parentTheme, err := findTheme(parent, trail, warn, path...)
if err != nil {
if warn {
log.Printf (
"xdg: could not parse inherited theme '%s': %v",
parent, err)
}
continue
}
theme.Inherits = append(theme.Inherits, parentTheme)
}
// if no parents were successfuly parsed, inherit from hicolor
if len(theme.Inherits) == 0 && name != "hicolor" {
hicolor, err := findTheme("hicolor", trail, warn, path...)
if err != nil { return Theme { }, err }
theme.Inherits = []Theme { hicolor }
}
return theme, nil
}
func parseThemeIndex (reader io.Reader) (theme Theme, parents []string, err error) {
file, err := keyValue.Parse(reader)
if err != nil { return Theme { }, nil, err }
iconThemeGroup, ok := file["Icon Theme"]
if !ok { return Theme { }, nil, ErrIconThemeGroupMissing }
// Name
entry, ok := iconThemeGroup["Name"]
if !ok { return Theme { }, nil, ErrEntryMissing }
theme.Name, err = entry.Unescape()
if err != nil { return Theme { }, nil, err }
// Comment
entry, ok = iconThemeGroup["Comment"]
if !ok { return Theme { }, nil, ErrEntryMissing }
theme.Comment, err = entry.Unescape()
if err != nil { return Theme { }, nil, err }
// Inherits (optional)
if entry, ok := iconThemeGroup["Inherits"]; ok {
parents, err = keyValue.ParseMultiple (
keyValue.ParseString,
entry.Value, ',')
if !ok { return Theme { }, nil, err }
}
// Directories
entry, ok = iconThemeGroup["Directories"]
if !ok { return Theme { }, nil, ErrEntryMissing }
theme.Directories, err = parseDirectories(entry, file)
if err != nil { return Theme { }, nil, err }
// ScaledDirectories (optional)
if entry, ok := iconThemeGroup["ScaledDirectories"]; ok {
theme.ScaledDirectories, err = parseDirectories(entry, file)
if err != nil { return Theme { }, nil, err }
}
// Hidden (optional)
if entry, ok := iconThemeGroup["Hidden"]; ok {
theme.Hidden, err = keyValue.ParseBoolean(entry.Value)
if err != nil { return Theme { }, nil, err }
}
// Example (optional)
if entry, ok := iconThemeGroup["Example"]; ok {
theme.Example, err = keyValue.ParseString(entry.Value)
if err != nil { return Theme { }, nil, err }
}
return theme, parents, nil
}
func parseDirectories (listEntry keyValue.Entry, file keyValue.File) ([]Directory, error) {
names, err := keyValue.ParseMultiple(keyValue.ParseString, listEntry.Value, ',')
if err != nil { return nil, err }
directories := make([]Directory, len(names))
for index, name := range names {
directoryGroup, ok := file[name]
if !ok { return nil, ErrDirectoryGroupMissing }
directory := Directory {
Name: name,
}
// Size
entry, ok := directoryGroup["Size"]
if !ok { return nil, ErrEntryMissing }
directory.Size, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
// Scale (optional)
if entry, ok := directoryGroup["Scale"]; ok {
directory.Scale, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
} else {
directory.Scale = 1
}
// Context (optional)
if entry, ok := directoryGroup["Context"]; ok {
directory.Context, err = keyValue.ParseString(entry.Value)
if err != nil { return nil, err }
}
// Type (optional)
if entry, ok := directoryGroup["Type"]; ok {
stringTy, err := keyValue.ParseString(entry.Value)
if err != nil { return nil, err }
directory.Type, err = IconSizeTypeFromString(stringTy)
if err != nil { return nil, err }
} else {
directory.Type = IconSizeTypeThreshold
}
// MaxSize (optional)
if entry, ok := directoryGroup["MaxSize"]; ok {
directory.MaxSize, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
} else {
directory.MaxSize = directory.Size
}
// MinSize (optional)
if entry, ok := directoryGroup["MinSize"]; ok {
directory.MinSize, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
} else {
directory.MinSize = directory.Size
}
// Threshold (optional)
if entry, ok := directoryGroup["Threshold"]; ok {
directory.Threshold, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
} else {
directory.Threshold = 2
}
directories[index] = directory
}
return directories, nil
}
// Theme represents an icon theme.
type Theme struct {
Name keyValue.Entry
Comment keyValue.Entry
Inherits []Theme
Directories []Directory
ScaledDirectories []Directory
Hidden bool
Example string
}
// FindIcon finds the best icon of the given name for the given size and scale.
// If a list of supported filetypes is not given, { PNG, SVG, XPM } is used
// instead.
func (theme Theme) FindIcon (name string, size, scale int, supported ...IconFileType) (Icon, error) {
// this method is equivalent to FindIconHelper
if len(supported) == 0 { supported = []IconFileType{ PNG, SVG, XPM } }
// try to find icon in this theme first
icon, err := theme.findIconHere(name, size, scale, supported...)
if err == nil { return icon, nil }
// if that didn't work, recurse through parent themes
for _, parent := range theme.Inherits {
icon, err := parent.FindIcon(name, size, scale, supported...)
if err == nil { return icon, nil }
}
return Icon { }, ErrIconNotFound
}
func (theme Theme) findIconHere (name string, size, scale int, supported ...IconFileType) (Icon, error) {
// this method is equivalent to LookupIcon
// stage 1: attempt to find an exact match
for _, directory := range theme.Directories {
if directory.Fits(size, scale) {
for _, path := range directory.Paths {
for _, ty := range supported {
icon, err := parseIcon(filepath.Join (
path, ty.Extend(name)))
if err == nil { return icon, err }
}
}
}
}
// stage 2: find the best icon matching the name
minimalDistance := int((^uint(0)) >> 1)
closestFilename := ""
for _, directory := range theme.Directories {
sizeDistance := directory.SizeDistance(size, scale)
if sizeDistance < minimalDistance {
for _, path := range directory.Paths {
for _, ty := range supported {
iconFilename := filepath.Join (
path, ty.Extend(name))
if _, err := os.Stat(iconFilename); err == nil {
closestFilename = iconFilename
minimalDistance = sizeDistance
}
}
}
}
}
if closestFilename != "" {
icon, err := parseIcon(closestFilename)
if err == nil { return icon, err }
}
return Icon { }, ErrIconNotFound
}
// Directory represents a directory of an icon theme which directly contains
// icons.
type Directory struct {
Name string
Paths []string
Size int
Scale int
Context string
Type IconSizeType
MaxSize int
MinSize int
Threshold int
}
// Fits returns whether icons in the directory fit the given size and scale.
func (directory Directory) Fits (size, scale int) bool {
// this method is equivalent to DirectoryMatchesSize
if directory.Scale != scale { return false }
switch directory.Type {
case IconSizeTypeFixed:
return directory.Size == size
case IconSizeTypeScalable:
return directory.MinSize <= size && size <= directory.MaxSize
case IconSizeTypeThreshold:
return directory.Size - directory.Threshold <=
size && size <=
directory.Size + directory.Threshold
}
return false
}
// SizeDistance returns how close the size of this directory's icons is to the
// given size. A smaller number is closer.
func (directory Directory) SizeDistance (size, scale int) int {
// this method is equivalent to DirectorySizeDistance
// TODO: the pseudocode for DirectorySizeDistance in the spec seems
// incredibly wrong, and the code here has been altered to correct for
// that. figure out why the spec is the way it is.
scaledSize := size * scale
scaledDir := directory.Size * directory.Scale
scaledMin := directory.MinSize * directory.Scale
scaledMax := directory.MaxSize * directory.Scale
threshMin := (directory.Size - directory.Threshold) * directory.Scale
threshMax := (directory.Size + directory.Threshold) * directory.Scale
switch directory.Type {
case IconSizeTypeFixed:
difference := scaledDir - scaledSize
if difference > 0 {
return difference
} else {
return -difference
}
case IconSizeTypeScalable:
if scaledSize < scaledMin { return scaledMin - scaledSize }
if scaledSize > scaledMax { return scaledSize - scaledMax }
case IconSizeTypeThreshold:
if scaledSize < threshMin { return threshMin - scaledSize }
if scaledSize > threshMax { return scaledSize - threshMin }
}
return 0
}
// IconFileType specifies the file type of an icon.
type IconFileType uint; const (
PNG IconFileType = iota
SVG
XPM
)
// String returns a string representation of the file type.
func (ty IconFileType) String () string {
switch ty {
case PNG: return ".png"
case SVG: return ".svg"
case XPM: return ".xpm"
default: return fmt.Sprintf("IconFileType(%d)", ty)
}
}
// Extend applies the file extension to a file name.
func (ty IconFileType) Extend (name string) string {
return name + ty.String()
}
// IconSizeType specifies how an icon can be scaled. Fixed icons cannot be
// scaled, scalable icons can be scaled to any size within a min and max range,
// and threshold icons can be scaled to any size within a certain distance of
// their normal size.
type IconSizeType int; const (
IconSizeTypeFixed IconSizeType = iota
IconSizeTypeScalable
IconSizeTypeThreshold
)
// String returns a string representation of the type.
func (ty IconSizeType) String () string {
switch ty {
case IconSizeTypeFixed: return "Fixed"
case IconSizeTypeScalable: return "Scalable"
case IconSizeTypeThreshold: return "Threshold"
default: return fmt.Sprintf("IconSizeType(%d)", ty)
}
}
// IconSizeTypeFromString converts a astring into an IconSizeType, returning an
// error if it could not be converted.
func IconSizeTypeFromString (value string) (IconSizeType, error) {
switch value {
case "Fixed": return IconSizeTypeFixed, nil
case "Scalable": return IconSizeTypeScalable, nil
case "Threshold": return IconSizeTypeThreshold, nil
default: return 0, ErrBadIconSizeType
}
}
// Icon represents an icon, and its associated data (if applicable).
type Icon struct {
Path string
FileType IconFileType
DisplayName keyValue.Entry
EmbeddedTextRectangle image.Rectangle
AttachPoints []image.Point
}
func parseIcon (path string) (Icon, error) {
_, err := os.Stat(path)
if err != nil { return Icon { }, err }
ext := filepath.Ext(path)
dataPath := strings.TrimSuffix(path, ext) + ".icon"
if data, err := parseIconData(dataPath); err != nil {
data.Path = path
return data, nil
} else {
return Icon {
Path: path,
}, nil
}
}
func parseIconData (path string) (Icon, error) {
reader, err := os.Open(path)
if err != nil { return Icon { }, err }
file, err := keyValue.Parse(reader)
if err != nil { return Icon { }, err }
iconDataGroup, ok := file["Icon Data"]
if !ok { return Icon { }, ErrGroupMissing }
icon := Icon { }
// DisplayName (optional)
if entry, ok := iconDataGroup["DisplayName"]; ok {
icon.DisplayName, err = entry.Unescape()
if err != nil { return Icon { }, err }
}
// EmbeddedTextRectangle (optional)
if entry, ok := iconDataGroup["EmbeddedTextRectangle"]; ok {
embeddedTextRectangle, err := keyValue.ParseMultiple(keyValue.ParseInteger, entry.Value, ',')
if len(embeddedTextRectangle) == 4 {
icon.EmbeddedTextRectangle = image.Rect (
embeddedTextRectangle[0],
embeddedTextRectangle[1],
embeddedTextRectangle[2],
embeddedTextRectangle[3])
if err != nil { return Icon { }, err }
}
}
// TODO figure out what the actual fuck the spec is talking about with
// regard to AttatchPoints. What even the fuck is that supposed to mean.
// Ohhhh a list of points separated by |. Mf hoe what do you define as
// a point.
return icon, nil
}

View File

@@ -64,9 +64,21 @@ func Parse (reader io.Reader) (File, error) {
var file = File { } var file = File { }
var group Group var group Group
var specifiedValues map[string] struct { } var specifiedValues map[string] struct { }
checkGroupIntegrity := func () error {
if group != nil {
for key := range group {
if _, ok := specifiedValues[key]; !ok {
return ErrNoDefaultValue
}
}
}
return nil
}
for { for {
line, err := buffer.ReadString('\n') line, err := buffer.ReadString('\n')
if errors.Is(err, io.EOF) { return file, nil } if errors.Is(err, io.EOF) { break }
if err != nil { return nil, err } if err != nil { return nil, err }
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
@@ -78,13 +90,8 @@ func Parse (reader io.Reader) (File, error) {
// group header // group header
case strings.HasPrefix(line, "["): case strings.HasPrefix(line, "["):
// check integrity of prev. group // check integrity of prev. group
if group != nil { err := checkGroupIntegrity()
for key := range group { if err != nil { return File { }, err }
if _, ok := specifiedValues[key]; !ok {
return nil, ErrNoDefaultValue
}
}
}
// create new group // create new group
name, err := parseGroupHeader(line) name, err := parseGroupHeader(line)
@@ -109,15 +116,23 @@ func Parse (reader io.Reader) (File, error) {
group[key] = entry group[key] = entry
} else { } else {
// default value // default value
_, exists := group[key] _, specified := specifiedValues[key]
if exists { return nil, ErrDuplicateEntry } if specified { return nil, ErrDuplicateEntry }
entry := newEntry()
entry, exists := group[key]
if !exists { entry = newEntry() }
entry.Value = value entry.Value = value
group[key] = entry group[key] = entry
specifiedValues[key] = struct { } { } specifiedValues[key] = struct { } { }
} }
} }
} }
err := checkGroupIntegrity()
if err != nil { return File { }, err }
return file, nil
} }
func newEntry () Entry { func newEntry () Entry {
@@ -250,7 +265,23 @@ func (entry Entry) Localize (locale locale.Locale) string {
return entry.Value return entry.Value
} }
// TODO have functions to parse/validate all data types // Unescape returns a new copy of this entry with its main value parsed and
// unescaped as a string, and its localized values parsed and unescaped as
// localestrings.
func (entry Entry) Unescape () (Entry, error) {
value, err := ParseString(entry.Value)
if err != nil { return Entry { }, err }
localizedValue := Entry {
Value: value,
Localized: make(map[locale.Locale] string),
}
for name, localized := range entry.Localized {
unescaped, err := ParseLocaleString(localized)
if err != nil { return Entry { }, err }
localizedValue.Localized[name] = unescaped
}
return localizedValue, nil
}
// ParseString parses a value of type string. // ParseString parses a value of type string.
// Values of type string may contain all ASCII characters except for control // Values of type string may contain all ASCII characters except for control
@@ -269,6 +300,8 @@ func ParseString (value string) (string, error) {
// The escape sequences \s, \n, \t, \r, and \\ are supported, meaning ASCII // The escape sequences \s, \n, \t, \r, and \\ are supported, meaning ASCII
// space, newline, tab, carriage return, and backslash, respectively. // space, newline, tab, carriage return, and backslash, respectively.
func ParseLocaleString (value string) (string, error) { func ParseLocaleString (value string) (string, error) {
value, err := escapeString(value)
if err != nil { return "", err }
return value, nil return value, nil
} }
@@ -294,6 +327,15 @@ func ParseBoolean (value string) (bool, error) {
return false, ErrBooleanNotTrueOrFalse return false, ErrBooleanNotTrueOrFalse
} }
// ParseInteger parses a value of type integer.
// The geniuses at freedesktop never explained this type at all, or how it
// should be parsed.
func ParseInteger (value string) (int, error) {
// TODO ensure this is compliant
integer, err := strconv.ParseInt(value, 10, 64)
return int(integer), err
}
// ParseNumeric parses a value of type numeric. // ParseNumeric parses a value of type numeric.
// Values of type numeric must be a valid floating point number as recognized by // Values of type numeric must be a valid floating point number as recognized by
// the %f specifier for scanf in the C locale. // the %f specifier for scanf in the C locale.
@@ -303,11 +345,11 @@ func ParseNumeric (value string) (float64, error) {
} }
// ParseMultiple parses multiple of a value type. Any value parsing function can // ParseMultiple parses multiple of a value type. Any value parsing function can
// be specified. The multiple values should be separated by a semicolon and the // be specified. The multiple values should be separated by a separator and the
// input string may be optionally terminated by a semicolon. Trailing empty // input string may be optionally terminated by a separator. Trailing empty
// strings must always be terminated with a semicolon. Semicolons in these // strings must always be terminated with a separator. Separators in these
// values need to be escaped using \;. // values need to be escaped using \<sep>.
func ParseMultiple[T any] (parser func (string) (T, error), value string) ([]T, error) { func ParseMultiple[T any] (parser func (string) (T, error), value string, sep rune) ([]T, error) {
values := []T { } values := []T { }
builder := strings.Builder { } builder := strings.Builder { }
@@ -324,9 +366,9 @@ func ParseMultiple[T any] (parser func (string) (T, error), value string) ([]T,
switch char { switch char {
case '\\': case '\\':
backslash = true backslash = true
case ';': case sep:
if backslash { if backslash {
builder.WriteRune(';') builder.WriteRune(sep)
backslash = false backslash = false
} else { } else {
err := newValue() err := newValue()

View File

@@ -52,6 +52,15 @@ func testParseString (test *testing.T, input string, correct File) {
} }
} }
func testParseErr (test *testing.T, input string, correct error) {
_, err := Parse(strings.NewReader(input))
if err != correct {
test.Fatalf (
"errors are different\n---correct---\n%v\n---got---\n%v\n",
err, correct)
}
}
func testEntry (value string) Entry { func testEntry (value string) Entry {
entry := newEntry() entry := newEntry()
entry.Value = value entry.Value = value
@@ -168,3 +177,66 @@ File {
"Icon": testEntry("fooview-new"), "Icon": testEntry("fooview-new"),
}, },
})} })}
func TestParseLocalized (test *testing.T) {
testParseString(test, `
[Group 0]
Name=something
Name[xx_XX]=other thing
Name[yy_YY]=another thing
Name[zz_ZZ]=yet another thing
[Group 1]
Name[xx_XX]=other thing
Name[yy_YY]=another thing
Name=something
Name[zz_ZZ]=yet another thing
`,
File {
"Group 0": Group {
"Name": Entry {
Value: "something",
Localized: map[locale.Locale] string {
locale.Locale {
Lang: "xx",
Country: "XX",
}: "other thing",
locale.Locale {
Lang: "yy",
Country: "YY",
}: "another thing",
locale.Locale {
Lang: "zz",
Country: "ZZ",
}: "yet another thing",
},
},
},
"Group 1": Group {
"Name": Entry {
Value: "something",
Localized: map[locale.Locale] string {
locale.Locale {
Lang: "xx",
Country: "XX",
}: "other thing",
locale.Locale {
Lang: "yy",
Country: "YY",
}: "another thing",
locale.Locale {
Lang: "zz",
Country: "ZZ",
}: "yet another thing",
},
},
},
})}
func TestParseErrNoDefaultValue (test *testing.T) {
testParseErr(test, `
[Group 0]
Name[xx_XX]=other thing
Name[yy_YY]=another thing
Name[zz_ZZ]=yet another thing
`, ErrNoDefaultValue)
}