// Package keyValue implements the basic key/value file format as described in // desktop-entry-spec version 1.5. At the moment, these files can only be read // from and not written to. // The specification can be read at: // https://specifications.freedesktop.org/desktop-entry-spec/1.5/ package keyValue // This implementation does not support writing files, because, and I quote: // Lines beginning with a # and blank lines are considered comments and will // be ignored, however they should be preserved across reads and writes of the // desktop entry file. // MISS ME WITH THAT SHIT! import "io" import "fmt" import "bufio" import "errors" import "strings" import "strconv" import "unicode" import "git.tebibyte.media/tomo/xdg/locale" type keyValueError string func (err keyValueError) Error () string { return string(err) } const ( // ErrUnexpectedRune indicates an unexpected rune was encountered. ErrUnexpectedRune = keyValueError("unexpected rune") // ErrInvalidGroupHeader indicates a wrongly formatted group header was // encountered. ErrInvalidGroupHeader = keyValueError("invalid group header") // ErrInvalidEntry indicates a wrongly formatted key/value entry was // encountered. ErrInvalidEntry = keyValueError("invalid entry") // ErrDuplicateGroup indicates that two or more groups with the same // name were found. ErrDuplicateGroup = keyValueError("duplicate group name") // ErrDuplicateEntry indicates that two or more keys with the same name // were found. ErrDuplicateEntry = keyValueError("duplicate entry name") // ErrDuplicateLocalization indicates that two or more localized values // with the same locale were provided for the same entry. ErrDuplicateLocalization = keyValueError("duplicate localization") // ErrNoDefaultValue indicates that localized values were provided for // an entry with no default value. ErrNoDefaultValue = keyValueError("no default value") // ErrEntryOutsideGroup indicates that an entry was found. ErrEntryOutsideGroup = keyValueError("entry outside group") // ErrUnsupportedEscape indicates that an unsupported escape sequence // was found. ErrUnsupportedEscape = keyValueError("unsupported escape") // ErrStringNotASCII indicates that a string or iconstring value was not // found to contain valid ASCII text. ErrStringNotASCII = keyValueError("string not ascii") // ErrBooleanNotTrueOrFalse indicates that a boolean value was not found // to equal "true" or "false". ErrBooleanNotTrueOrFalse = keyValueError("boolean not true or false") ) // Parse parses a key/value file from a Reader. func Parse (reader io.Reader) (File, error) { buffer := bufio.NewReader(reader) var file = File { } var group Group var specifiedValues map[string] struct { } for { line, err := buffer.ReadString('\n') if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } line = strings.TrimSpace(line) switch { // comment case line == "", strings.HasPrefix(line, "#"): // group header case strings.HasPrefix(line, "["): // check integrity of prev. group if group != nil { for key := range group { if _, ok := specifiedValues[key]; !ok { return nil, ErrNoDefaultValue } } } // create new group name, err := parseGroupHeader(line) if err != nil { return nil, err } group = Group { } specifiedValues = map[string] struct { } { } _, exists := file[name] if exists { return nil, ErrDuplicateGroup } file[name] = group // key/value pair default: if group == nil { return nil, ErrEntryOutsideGroup } key, value, loc, err := parseEntry(line) if err != nil { return nil, err } if loc != (locale.Locale { }) { // localized value entry, ok := group[key] if !ok { entry = newEntry() } entry.Localized[loc] = value group[key] = entry } else { // default value _, specified := specifiedValues[key] if specified { return nil, ErrDuplicateEntry } entry, exists := group[key] if !exists { entry = newEntry() } entry.Value = value group[key] = entry specifiedValues[key] = struct { } { } } } } return file, nil } func newEntry () Entry { return Entry { Localized: map[locale.Locale] string { }, } } func parseGroupHeader (line string) (string, error) { line = strings.TrimPrefix(line, "[") if !strings.HasSuffix(line, "]") { return "", ErrInvalidGroupHeader } line = strings.TrimSuffix(line, "]") if strings.ContainsAny(line, "[]") { return "", ErrInvalidGroupHeader } return line, nil } func parseEntry (line string) (key, val string, loc locale.Locale, err error) { keyb := strings.Builder { } locb := strings.Builder { } valb := strings.Builder { } state := 0 const getKey = 0 const getLoc = 1 const waitVal = 2 const getVal = 3 for _, char := range line { switch state { case getKey: switch char { case '[': state = getLoc case '=': state = getVal default: keyb.WriteRune(char) } case waitVal: if char == '=' { state = getVal } case getLoc: switch char { case ']': state = waitVal default: locb.WriteRune(char) } case getVal: valb.WriteRune(char) } } if state != getVal { err = ErrInvalidEntry; return } if locb.Len() > 0 { loc, err = locale.Parse(locb.String()) if err != nil { return} } key = strings.TrimSpace(keyb.String()) val = strings.TrimSpace(valb.String()) if !isKeyValid(key) { err = ErrInvalidEntry; return } return } func isKeyValid (key string) bool { for _, char := range key { if char == '-' { continue } if 'A' <= char && char <= 'Z' { continue } if 'a' <= char && char <= 'z' { continue } return false } return true } // File represents a key/value file. type File map[string] Group // String returns a string representation of the file. func (file File) String () string { outb := strings.Builder { } for name, group := range file { fmt.Fprintf(&outb, "[%s]\n", name) for key, entry := range group { fmt.Fprintf(&outb, "%s=%s\n", key, entry.Value) for loc, localized := range entry.Localized { fmt.Fprintf(&outb, "%s[%v]=%s\n", key, loc, localized) } } } out := outb.String() if len(out) > 0 { out = out[:len(out) - 2] } return out } // Group represents a group of entries. type Group map[string] Entry // Entry represents an entry in a group. type Entry struct { Value string Localized map[locale.Locale] string } // Localize returns a localized value matching the specified locale, which // should be LC_MESSAGES for a system-localized value. // // The matching is done as follows. If locale is of the form // lang_COUNTRY.ENCODING@MODIFIER, then it will match a key of the form // lang_COUNTRY@MODIFIER. If such a key does not exist, it will attempt to match // lang_COUNTRY followed by lang@MODIFIER. Then, a match against lang by itself // will be attempted. Finally, if no matching key is found the required key // without a locale specified is used. The encoding from the locale value is // ignored when matching. func (entry Entry) Localize (locale locale.Locale) string { withoutEncoding := locale withoutEncoding.Encoding = "" value, ok := entry.Localized[withoutEncoding] if ok { return value } withoutModifier := withoutEncoding withoutModifier.Modifier = "" value, ok = entry.Localized[withoutModifier] if ok { return value } withoutCountry := withoutEncoding withoutCountry.Country = "" value, ok = entry.Localized[withoutCountry] if ok { return value } return entry.Value } // 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 } // TODO have functions to parse/validate all data types // ParseString parses a value of type string. // Values of type string may contain all ASCII characters except for control // characters. // The escape sequences \s, \n, \t, \r, and \\ are supported, meaning ASCII // space, newline, tab, carriage return, and backslash, respectively. func ParseString (value string) (string, error) { value, err := escapeString(value) if err != nil { return "", err } if !isAsciiText(value) { return "", ErrStringNotASCII } return value, nil } // ParseLocaleString parses a value of type localestring. // Values of type localestring are user displayable, and are encoded in UTF-8. // The escape sequences \s, \n, \t, \r, and \\ are supported, meaning ASCII // space, newline, tab, carriage return, and backslash, respectively. func ParseLocaleString (value string) (string, error) { value, err := escapeString(value) if err != nil { return "", err } return value, nil } // ParseIconString parses a value of type iconstring. // Values of type iconstring are the names of icons; these may be absolute // paths, or symbolic names for icons located using the algorithm described in // the Icon Theme Specification. Such values are not user-displayable, and are // encoded in UTF-8. // The escape sequences \s, \n, \t, \r, and \\ are supported, meaning ASCII // space, newline, tab, carriage return, and backslash, respectively. func ParseIconString (value string) (string, error) { value, err := escapeString(value) if err != nil { return "", err } if !isAsciiText(value) { return "", ErrStringNotASCII } return value, nil } // ParseBoolean parses a value of type boolean. // Values of type boolean must either be the string true or false. func ParseBoolean (value string) (bool, error) { if value == "true" { return true, nil } if value == "false" { return false, nil } 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. // Values of type numeric must be a valid floating point number as recognized by // the %f specifier for scanf in the C locale. func ParseNumeric (value string) (float64, error) { // TODO ensure this is compliant return strconv.ParseFloat(value, 64) } // 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 // input string may be optionally terminated by a semicolon. Trailing empty // strings must always be terminated with a semicolon. Semicolons in these // values need to be escaped using \;. func ParseMultiple[T any] (parser func (string) (T, error), value string) ([]T, error) { return parseMultiple(parser, value, ';') } // ParseMultipleComma is like ParseMultiple, but uses a comma as a separator // instead of a semicolon. This is used to parse icon theme files because the // freedesktop people haven't yet learned the word "consistency". func ParseMultipleComma[T any] (parser func (string) (T, error), value string) ([]T, error) { return parseMultiple(parser, value, ',') } func parseMultiple[T any] (parser func (string) (T, error), value string, sep rune) ([]T, error) { values := []T { } builder := strings.Builder { } newValue := func () error { value, err := parser(builder.String()) if err != nil { return err } values = append(values, value) builder.Reset() return nil } backslash := false for _, char := range value { switch char { case '\\': backslash = true case sep: if backslash { builder.WriteRune(sep) backslash = false } else { err := newValue() if err != nil { return nil, err } } default: if backslash { builder.WriteRune('\\') backslash = false } builder.WriteRune(char) } } if backslash { builder.WriteRune('\\') } if builder.Len() > 0 { err := newValue() if err != nil { return nil, err } } return values, nil } func isAsciiText (value string) bool { for _, char := range value { // must be an ascii character that isn't a control character. if char <= 0x1F || char > unicode.MaxASCII { return false } } return true } func escapeString (value string) (string, error) { builder := strings.Builder { } backslash := false for _, char := range value { if char == '\\' { backslash = true; continue } if backslash { switch char { case 's': builder.WriteRune(' ') case 'n': builder.WriteRune('\n') case 't': builder.WriteRune('\t') case 'r': builder.WriteRune('\r') case '\\': builder.WriteRune('\\') default: return "", ErrUnsupportedEscape } } else { builder.WriteRune(char) } } return builder.String(), nil }