package config import "io" import "fmt" import "math" import "bufio" import "errors" import "strconv" import "strings" import "unicode" type line any type comment string type entry struct { key string; value Value } var _ io.WriterTo = new(File) // File represents a config file. It preserves the order of the lines, as well // as blank lines and comments. type File struct { lines []line keys map[string] int } // NewFile creates a blank file with nothing in it. func NewFile () *File { return &File { keys: make(map[string] int), } } // Parse parses a config file from a reader. This function operates on a // best-effort basis: A file will always be returned, and any errors encountered // will be joined together. // // The general format of the file is as follows: // - Encoded in UTF-8 // - Consists of lines, separated by \n, or \r\n // - Lines can be any of these: // - Blank line: has only whitespace // - Comment: begins with a '#' // - Entry: a key/value pair separated by an '=' sign // // For entries, all whitespace on either side of the '=' sign, the key, or the // value is ignored. The key may contain any letter or digit, as well as '-' // and '.'. The value is always identified by its first rune (after the // preliminary whitespace of course) and can be one of: // - String: either a double-quoted string, or any string of runes not // identifiable as any other kind of value. The quoted string is always // unquoted when it is read. Either way, these escape sequences are // supported, and resolved when they are read: // - '\\': a literal backslash // - '\a': alert, bell // - '\b': backspace // - '\t': horizontal tab // - '\n': line feed // - '\v': vertical tab // - '\f': form feed // - '\r': carriage return // - '\"': double quote // - Number: a floating point value. It can be of the form: // - Inf // - -Inf // - NaN // - [0-9]+ // - [0-9]+\.[0-9]* // - Bool: a boolean value. It can be one of: // - true // - false // // Be aware that some unquoted strings, within reason, are subject to being read // as some other value in the future. For example, if there were suddenly a // third boolean value called glorble, the unquoted string glorble would be read // as a boolean value instead of a string. func Parse (reader io.Reader) (*File, error) { file := &File { keys: make(map[string] int), } errs := []error { } scanner := bufio.NewScanner(reader) for scanner.Scan() { text := strings.TrimSpace(scanner.Text()) switch { case text == "", strings.HasPrefix(text, "#"): file.lines = append(file.lines, comment(text)) default: entry, err := parseEntry(text) if err == nil { file.keys[entry.key] = len(file.lines) file.lines = append(file.lines, entry) } else { errs = append (errs, err ) } } } errs = append(errs, scanner.Err()) return file, errors.Join(errs...) } func parseEntry (str string) (entry, error) { key, value, ok := strings.Cut(str, "=") if !ok { return entry { }, ErrMalformedEntry } key = strings.TrimSpace(key) if !KeyValid(key) { return entry { }, ErrMalformedKey } value = strings.TrimSpace(value) parsedValue, err := ParseValue(value) if err != nil { return entry { }, err } return entry { key: key, value: parsedValue }, nil } // ParseValue parses a value from a string. For any Value v, // ParseValue(v.String()) should hold data exactly equal to v. This function // does not trim whitespace. func ParseValue (str string) (Value, error) { first := firstRune(str) switch { case str == "": return ValueString(""), nil case first == '"': value, ok := unescape(str) if !ok { return nil, ErrMalformedEscapeSequence } value, ok = unquote(value) if !ok { return nil, ErrMalformedStringValue } return ValueString(value), nil case first == '-' || (first >= '0' && first <= '9'): value, err := strconv.ParseFloat(str, 64) if err != nil { return nil, ErrMalformedNumberValue } return ValueNumber(value), nil case str == "false": return ValueBool(false), nil case str == "true": return ValueBool(true), nil case str == "Inf": return ValueNumber(math.Inf(1)), nil case str == "NaN": return ValueNumber(math.NaN()), nil default: value, ok := unescape(str) if !ok { return nil, ErrMalformedEscapeSequence } return ValueString(value), nil } } // Has returns whether the key exists. If the key is invalid, it returns false, // ErrMalformedKey. func (this *File) Has (key string) (bool, error) { if !KeyValid(key) { return false, ErrMalformedKey } if index, ok := this.keys[key]; ok { if _, ok := this.lines[index].(entry); ok { return true, nil } } return false, nil } // Get gets the keyed value. If the value is unspecified, it returns nil, // ErrNonexistentEntry. If the key is invalid, it returns nil, ErrMalformedKey. func (this *File) Get (key string) (Value, error) { if !KeyValid(key) { return nil, ErrMalformedKey } if index, ok := this.keys[key]; ok { if lin, ok := this.lines[index].(entry); ok { return lin.value, nil } } return nil, ErrNonexistentEntry } // Set sets a value. If the key is invalid, it returns ErrMalformedKey. func (this *File) Set (key string, value Value) error { if !KeyValid(key) { return ErrMalformedKey } ent := entry { key: key, value: value, } if index, ok := this.keys[key]; ok { this.lines[index] = ent return nil } this.keys[key] = len(this.lines) this.lines = append(this.lines, ent) return nil } // Reset removes the value from the file. If the value is set again, it will be // added back at the same location. Note that because of this, the positions of // lines are not forgotten until the file is written and reloaded. This is why // the method is called Reset and not Remove. If the key is invalid, it returns // ErrMalformedKey. func (this *File) Reset (key string) error { if !KeyValid(key) { return ErrMalformedKey } for index, lin := range this.lines { if lin, ok := lin.(entry); ok { if lin.key == key { this.lines[index] = nil } } } return nil } // Map creates and returns a map of keys to values. func (this *File) Map () map[string] Value { mp := make(map[string] Value) for key, index := range this.keys { if lin, ok := this.lines[index].(entry); ok { mp[key] = lin.value } } return mp } // Diff returns a set of keys that are different from the other file. func (this *File) Diff (other *File) map[string] struct { } { diff := make(map[string] struct { }) // - keys only we have // - keys we both have, but are different for key, index := range this.keys { thisEntry, ok := this.lines[index].(entry) if !ok { continue } otherIndex, ok := other.keys[key] if !ok { diff[key] = struct { } { } continue } otherEntry, ok := other.lines[otherIndex].(entry) if !ok { diff[key] = struct { } { } continue } if !thisEntry.value.Equals(otherEntry.value) { diff[key] = struct { } { } } } // - keys only they have for key := range other.keys { if otherHas, _ := other.Has(key); !otherHas { continue } if thisHas, _ := this.Has(key); !thisHas { diff[key] = struct { } { } } } return diff } // WriteTo writes the data in this file to an io.Writer. func (this *File) WriteTo (writer io.Writer) (n int64, err error) { for _, lin := range this.lines { nint := 0 switch lin := lin.(type) { case comment: nint, err = fmt.Fprintln(writer, string(lin)) case entry: nint, err = fmt.Fprintf(writer, "%s=%v\n", lin.key, lin.value) } n += int64(nint) if err != nil { return n, err } } return n, nil } // KeyValid returns whether a key contains only valid runes. They are: // - Letters // - Digits // - '-' // - '.' func KeyValid (key string) bool { for _, char := range key { valid := char == '.' || char == '-' || unicode.IsLetter(char) || unicode.IsDigit(char) if !valid { return false } } return true }