// Package ini реализует минимальный разбор и запись ini-файлов без внешних // зависимостей. Порядок секций, порядок ключей и строки комментариев // сохраняются, чтобы файл, отредактированный пользователем вручную, // не портился при перезаписи. package ini import ( "bufio" "bytes" "fmt" "io" "strings" ) // Section — одна секция ini-файла. type Section struct { name string // lines хранит строки секции в исходном порядке: комментарии, пустые // строки и пары ключ-значение. Это позволяет перезаписать файл, // не потеряв авторское оформление. lines []line } type line struct { raw string // комментарий или пустая строка; используется, если key == "" key string value string } // File — разобранный ini-файл. type File struct { preamble []string // комментарии и пустые строки до первой секции sections []*Section } // New создаёт пустой файл. func New() *File { return &File{} } // Preamble возвращает строки, стоящие до первой секции. func (f *File) Preamble() []string { return f.preamble } // SetPreamble задаёт строки, которые будут записаны до первой секции. // Строки комментариев должны начинаться с ';' или '#'. func (f *File) SetPreamble(lines []string) { f.preamble = lines } // Sections возвращает имена секций в порядке их появления в файле. func (f *File) Sections() []string { names := make([]string, len(f.sections)) for i, s := range f.sections { names[i] = s.name } return names } // Section возвращает секцию по имени или nil, если её нет. func (f *File) Section(name string) *Section { for _, s := range f.sections { if s.name == name { return s } } return nil } // AddSection возвращает существующую секцию или создаёт новую в конце файла. func (f *File) AddSection(name string) *Section { if s := f.Section(name); s != nil { return s } s := &Section{name: name} f.sections = append(f.sections, s) return s } // RemoveSection удаляет секцию; возвращает true, если она была. func (f *File) RemoveSection(name string) bool { for i, s := range f.sections { if s.name == name { f.sections = append(f.sections[:i], f.sections[i+1:]...) return true } } return false } // Name возвращает имя секции. func (s *Section) Name() string { return s.name } // Get возвращает значение ключа; для отсутствующего ключа — пустую строку. func (s *Section) Get(key string) string { for _, l := range s.lines { if l.key == key { return l.value } } return "" } // Has сообщает, есть ли такой ключ в секции. func (s *Section) Has(key string) bool { for _, l := range s.lines { if l.key == key { return true } } return false } // Keys возвращает имена ключей в порядке их появления в секции. func (s *Section) Keys() []string { var keys []string for _, l := range s.lines { if l.key != "" { keys = append(keys, l.key) } } return keys } // Set задаёт значение ключа. Существующий ключ обновляется на своём месте, // новый добавляется в конец секции. func (s *Section) Set(key, value string) { for i := range s.lines { if s.lines[i].key == key { s.lines[i].value = value return } } s.lines = append(s.lines, line{key: key, value: value}) } // Unset удаляет ключ; возвращает true, если он был. func (s *Section) Unset(key string) bool { for i, l := range s.lines { if l.key == key { s.lines = append(s.lines[:i], s.lines[i+1:]...) return true } } return false } func parseError(n int, format string, args ...any) error { return fmt.Errorf("строка %d: %s", n, fmt.Sprintf(format, args...)) } // Parse разбирает ini-файл из r. func Parse(r io.Reader) (*File, error) { f := New() sc := bufio.NewScanner(r) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) var cur *Section for n := 1; sc.Scan(); n++ { raw := strings.TrimRight(sc.Text(), " \t\r") trimmed := strings.TrimSpace(raw) switch { case trimmed == "" || strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "#"): if cur == nil { f.preamble = append(f.preamble, raw) } else { cur.lines = append(cur.lines, line{raw: raw}) } case strings.HasPrefix(trimmed, "["): if !strings.HasSuffix(trimmed, "]") { return nil, parseError(n, "незакрытое имя секции %q", trimmed) } name := strings.TrimSpace(trimmed[1 : len(trimmed)-1]) if name == "" { return nil, parseError(n, "пустое имя секции") } if f.Section(name) != nil { return nil, parseError(n, "секция %q встречается повторно", name) } cur = f.AddSection(name) default: i := strings.IndexByte(trimmed, '=') if i < 0 { return nil, parseError(n, "строка не является парой ключ=значение: %q", trimmed) } key := strings.TrimSpace(trimmed[:i]) if key == "" { return nil, parseError(n, "пустое имя ключа") } if cur == nil { return nil, parseError(n, "ключ %q вне секции", key) } if cur.Has(key) { return nil, parseError(n, "ключ %q в секции [%s] встречается повторно", key, cur.name) } cur.lines = append(cur.lines, line{key: key, value: strings.TrimSpace(trimmed[i+1:])}) } } if err := sc.Err(); err != nil { return nil, fmt.Errorf("чтение ini: %w", err) } return f, nil } // validValue проверяет, что значение можно записать без потери смысла: // экранирования в формате нет, поэтому переводы строки запрещены. func validValue(key, value string) error { if strings.ContainsAny(value, "\r\n") { return fmt.Errorf("значение ключа %q содержит перевод строки", key) } return nil } // WriteTo записывает файл. Секции и ключи выводятся в исходном порядке, // значения внутри секции выравниваются по самому длинному имени ключа. func (f *File) WriteTo(w io.Writer) (int64, error) { var buf bytes.Buffer for _, l := range f.preamble { buf.WriteString(l) buf.WriteByte('\n') } for _, s := range f.sections { // Отделяем секцию пустой строкой, но не плодим их: в исходном файле // пустая строка перед секцией уже могла быть сохранена как часть // предыдущей секции или шапки. separate(&buf) fmt.Fprintf(&buf, "[%s]\n", s.name) width := 0 for _, l := range s.lines { if l.key != "" && len(l.key) > width { width = len(l.key) } } for _, l := range s.lines { if l.key == "" { buf.WriteString(l.raw) buf.WriteByte('\n') continue } if err := validValue(l.key, l.value); err != nil { return 0, err } fmt.Fprintf(&buf, "%-*s = %s\n", width, l.key, l.value) } } n, err := w.Write(buf.Bytes()) return int64(n), err } // separate дописывает пустую строку-разделитель, если её ещё нет. func separate(buf *bytes.Buffer) { b := buf.Bytes() if len(b) == 0 || bytes.HasSuffix(b, []byte("\n\n")) { return } buf.WriteByte('\n') } // Bytes возвращает содержимое файла в виде среза байтов. func (f *File) Bytes() ([]byte, error) { var buf bytes.Buffer if _, err := f.WriteTo(&buf); err != nil { return nil, err } return buf.Bytes(), nil }