Initial commit: hotpcalc CLI
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user