// Package pzconfig читает и правит конфиги Project Zomboid: servertest.ini и // SandboxVars.lua. package pzconfig import ( "bytes" "fmt" "os" "path/filepath" "strings" ) // iniLine — одна строка файла. Пустой Key означает комментарий или пустую // строку, которую нужно сохранить как есть. type iniLine struct { Key string Value string Raw string // Comment — описание параметра: строки комментария прямо перед ним. // Project Zomboid пишет их на языке сервера, так что панели есть что // показать человеку, не выдумывая своих формулировок. Comment string } // INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок // строк и комментарии, чтобы правка одного параметра не переписывала файл. type INI struct { lines []iniLine index map[string]int // crlf — файл был с виндовыми переводами строк. Сохраняем как было: иначе // правка одного параметра переписывает весь файл целиком. crlf bool } // ParseINI разбирает содержимое конфига. func ParseINI(data []byte) *INI { ini := &INI{index: make(map[string]int)} raw := string(data) ini.crlf = strings.Contains(raw, "\r\n") text := strings.ReplaceAll(raw, "\r\n", "\n") // Комментарии копим до ближайшего параметра: в конфигах PZ описание стоит // строкой выше. Пустая строка обрывает связь — иначе описание прилипло бы // к следующему блоку. var comment []string for _, raw := range strings.Split(text, "\n") { trimmed := strings.TrimSpace(raw) key, value, found := strings.Cut(raw, "=") if strings.HasPrefix(trimmed, "#") { comment = append(comment, strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))) ini.lines = append(ini.lines, iniLine{Raw: raw}) continue } if !found || trimmed == "" { comment = nil ini.lines = append(ini.lines, iniLine{Raw: raw}) continue } key = strings.TrimSpace(key) ini.index[key] = len(ini.lines) ini.lines = append(ini.lines, iniLine{ Key: key, Value: strings.TrimSpace(value), Comment: strings.Join(comment, " "), }) comment = nil } return ini } // LoadINI читает конфиг с диска. func LoadINI(path string) (*INI, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } return ParseINI(data), nil } // Get возвращает значение параметра. func (i *INI) Get(key string) string { idx, ok := i.index[key] if !ok { return "" } return i.lines[idx].Value } // Has сообщает, есть ли такой параметр в файле. func (i *INI) Has(key string) bool { _, ok := i.index[key] return ok } // Set меняет значение, добавляя параметр в конец, если его ещё нет. func (i *INI) Set(key, value string) { // Перевод строки в значении сломал бы формат файла. value = strings.NewReplacer("\n", " ", "\r", " ").Replace(value) if idx, ok := i.index[key]; ok { i.lines[idx].Value = value return } i.index[key] = len(i.lines) i.lines = append(i.lines, iniLine{Key: key, Value: value}) } // Keys возвращает имена параметров в порядке их появления в файле. func (i *INI) Keys() []string { out := make([]string, 0, len(i.index)) for _, l := range i.lines { if l.Key != "" { out = append(out, l.Key) } } return out } // Comments возвращает описания параметров: ключ -> текст комментария. func (i *INI) Comments() map[string]string { out := make(map[string]string, len(i.index)) for _, l := range i.lines { if l.Key != "" && l.Comment != "" { out[l.Key] = l.Comment } } return out } // Map отдаёт все параметры в виде словаря. func (i *INI) Map() map[string]string { out := make(map[string]string, len(i.index)) for _, l := range i.lines { if l.Key != "" { out[l.Key] = l.Value } } return out } // Render собирает файл обратно в текст. func (i *INI) Render() []byte { newline := "\n" if i.crlf { newline = "\r\n" } var buf bytes.Buffer for n, l := range i.lines { if l.Key == "" { buf.WriteString(l.Raw) } else { buf.WriteString(l.Key) buf.WriteByte('=') buf.WriteString(l.Value) } if n < len(i.lines)-1 { buf.WriteString(newline) } } return buf.Bytes() } // SaveINI записывает конфиг атомарно, предварительно сняв копию прежней // версии рядом (.bak): испорченный конфиг остановит сервер. func SaveINI(path string, ini *INI) error { if err := backupFile(path); err != nil { return err } return writeAtomic(path, ini.Render(), 0o644) } func backupFile(path string) error { data, err := os.ReadFile(path) if os.IsNotExist(err) { return nil } if err != nil { return err } return os.WriteFile(path+".bak", data, 0o644) } func writeAtomic(path string, data []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, mode); err != nil { return fmt.Errorf("записать %s: %w", tmp, err) } return os.Rename(tmp, path) }