package config import "io" import "os" import "fmt" import "sort" import "bufio" import "strings" import "strconv" import "image/color" import "path/filepath" // when making changes to this file, look at // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html // Error represents an error that can be returned by functions or methods in // this module. type Error int const ( // ErrorIllegalName is thrown when an application name contains illegal // characters such as a slash. ErrorIllegalName Error = iota // ErrorNoSeparator is thrown when a configuration file has an // incorrectly formatted key-value pair. ErrorNoSeparator // ErrorUnknownParameter is thrown when an unknown key is encountered in // a configuration file. ErrorUnknownParameter // ErrorWrongColorLength is thrown when a configuration file has a color // literal with a total length unequal to 7. ErrorWrongColorLength // ErrorMalformedColorLiteral is thrown when a configuration file has an // improperly formatted color literal, or a color literal was expected // and something else was encountered. ErrorMalformedColorLiteral // ErrorMalformedIntegerLiteral is thrown when a configuration file has // an improperly formatted integer literal, or an integer literal was // expected and something else was encountered. ErrorMalformedIntegerLiteral // ErrorMalformedFloatLiteral is thrown when a configuration file has // an improperly formatted float literal, or a float literal was // expected and something else was encountered. ErrorMalformedFloatLiteral ) // Error returns a description of the error. func (err Error) Error () (description string) { switch err { case ErrorIllegalName: description = "name contains illegal characters" case ErrorNoSeparator: description = "key:value pair has no separator" case ErrorUnknownParameter: description = "unknown parameter" case ErrorWrongColorLength: description = "color literal has the wrong length" case ErrorMalformedColorLiteral: description = "malformed color literal" case ErrorMalformedIntegerLiteral: description = "malformed integer literal" case ErrorMalformedFloatLiteral: description = "malformed float literal" } return } // Type represents the data type of a configuration parameter. type Type int const ( // string // It is just a basic string with inner whitespace preserved. No quotes // should be used in the file. TypeString Type = iota // Type: image/color.RGBA // Represented as a 24 bit hexadecimal number (case insensitive) // preceded with a # sign where the first two digits represent the red // channel, the middle two digits represent the green channel, and the // last two digits represent the blue channel. TypeColor // Type: int // An integer literal, like 123456789 TypeInteger // Type: float64 // A floating point literal, like 1234.56789 TypeFloat // Type: bool // Values true, yes, on, and 1 are all truthy (case insensitive) and // anything else is falsy. TypeBoolean ) // Config holds a list of configuration parameters. type Config struct { // LegalParameters holds the names and types of all parameters that can // be parsed. If the parser runs into a parameter that is not listed // here, it will print out an error message and keep on parsing. LegalParameters map[string] Type // Parameters holds the names and values of all parsed parameters. If a // value is non-nil, it can be safely type asserted into whatever type // was requested. Parameters map[string] any } // Load loads and parses the files /etc/xdg//.conf and // /.config//.conf, unless the corresponding XDG environment // variables are set - then it uses those. func (config *Config) Load (name string) (err error) { if nameIsIllegal(name) { err = ErrorIllegalName return } for _, directory := range configDirs { path := filepath.Join(directory, name, name + ".conf") file, fileErr := os.Open(path) if fileErr != nil { continue } parseErr := config.LoadFrom(file) defer file.Close() if parseErr != nil { println ( "config: error in file", path + ":", parseErr.Error()) } } return } // LoadFrom parses a configuration file from an io.Reader. Configuration files // are divided into lines where each line may be blank, a comment, or a // key-value pair. If the line is blank or begins with a # character, it is // ignored. Else, the line must have a key and a value separated by a colon. // Before they are processed, leading and trailing whitespace is trimmed from // the key and the value. Keys are case sensitive. func (config *Config) LoadFrom (reader io.Reader) (err error) { if config.LegalParameters == nil { config.LegalParameters = make(map[string] Type) } if config.Parameters == nil { config.Parameters = make(map[string] any) } scanner := bufio.NewScanner(reader) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if len(line) == 0 { continue } if line[0] == '#' { continue } key, value, found := strings.Cut(scanner.Text(), ":") if !found { err = ErrorNoSeparator return } key = strings.TrimSpace(key) value = strings.TrimSpace(value) what, isKnown := config.LegalParameters[key] if !isKnown { err = ErrorUnknownParameter return } switch what { case TypeString: config.Parameters[key] = value case TypeColor: var valueColor color.Color valueColor, err = parseColor(value) if err != nil { return } config.Parameters[key] = valueColor case TypeInteger: var valueInt int valueInt, err = strconv.Atoi(value) if err != nil { err = ErrorMalformedIntegerLiteral return } config.Parameters[key] = valueInt case TypeFloat: var valueFloat float64 valueFloat, err = strconv.ParseFloat(value, 64) if err != nil { err = ErrorMalformedFloatLiteral return } config.Parameters[key] = valueFloat case TypeBoolean: value = strings.ToLower(value) truthy := value == "true" || value == "yes" || value == "on" || value == "1" config.Parameters[key] = truthy } } return } // Save overwrites the main user configuration file, which is located at // /.config//.conf unless $XDG_CONFIG_HOME has been set, in // which case the value of that variable is used instead. func (config *Config) Save (name string) (err error) { if nameIsIllegal(name) { err = ErrorIllegalName return } err = os.MkdirAll(configHome, 0755) if err != nil { return } file, err := os.OpenFile ( filepath.Join(configHome, name, name + ".conf"), os.O_WRONLY | os.O_CREATE | os.O_TRUNC, 0744) if err != nil { return } defer file.Close() err = config.SaveTo(file) if err != nil { return } return } // SaveTo writes the configuration data to the specified io.Writer. Keys are // alphabetically sorted. func (config *Config) SaveTo (writer io.Writer) (err error) { keys := make([]string, len(config.Parameters)) index := 0 for key, _ := range config.Parameters { keys[index] = key index ++ } sort.Strings(keys) for _, key := range keys { value := config.Parameters[key] switch value.(type) { case string: fmt.Fprintf(writer,"%s: %s\n", key, value.(string)) case color.RGBA: colorValue := value.(color.RGBA) colorInt := uint64(colorValue.R) << 16 | uint64(colorValue.G) << 8 | uint64(colorValue.B) fmt.Fprintf(writer,"%s: #%06x\n", key, colorInt) case int: fmt.Fprintf(writer,"%s: %d\n", key, value.(int)) case float64: fmt.Fprintf(writer,"%s: %f\n", key, value.(float64)) case bool: fmt.Fprintf(writer,"%s: %t\n", key, value.(bool)) default: fmt.Fprintf(writer,"# %s: unknown type\n", key) } } return } func parseColor (value string) (valueColor color.Color, err error) { if value[0] == '#' { if len(value) != 7 { err = ErrorWrongColorLength return } var colorInt uint64 colorInt, err = strconv.ParseUint(value[1:7], 16, 24) if err != nil { err = ErrorMalformedColorLiteral return } valueColor = color.RGBA { R: uint8(colorInt >> 16), G: uint8(colorInt >> 8), B: uint8(colorInt), A: 0xFF, } } else { err = ErrorMalformedColorLiteral return } return } func nameIsIllegal (name string) (legal bool) { legal = strings.ContainsAny(name, "/\\|:.%") return }