Files

270 lines
8.3 KiB
Go

// 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
}