270 lines
8.3 KiB
Go
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
|
|
}
|