- 重构 sysconf 为文档模型 INI Parser 与 Config Framework - 强化 hosts 解析、插入校验、写回与异常输入处理 - 完善 StarCmd 生命周期、等待 API、流式输出与 IO 重定向 - 扩展跨平台文件时间、文件锁、内存、进程与网络能力 - 将 Windows 进程适配更新到 b612.me/wincmd v0.1.0 - 移除本地 wincmd/win32api replace,改用发布版依赖 - 将最低 Go 版本提升到 1.18 - 补充 hosts、sysconf、FileLock、StarCmd 与平台适配回归测试
1193 lines
28 KiB
Go
1193 lines
28 KiB
Go
package sysconf
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
var (
|
|
ErrDocumentClosed = errors.New("document is nil")
|
|
ErrSectionNotFound = errors.New("section not found")
|
|
ErrKeyNotFound = errors.New("key not found")
|
|
)
|
|
|
|
type Document struct {
|
|
mu sync.RWMutex
|
|
sections []*Section
|
|
sectionIndex map[string][]*Section
|
|
SectionOpen string
|
|
SectionClose string
|
|
Assign string
|
|
AssignDelimiters []string
|
|
CommentHeads []string
|
|
AllowInline bool
|
|
InlineCommentRequiresSpace bool
|
|
AllowNoValue bool
|
|
AllowMulti bool
|
|
AllowContinuation bool
|
|
TrimSpace bool
|
|
CaseSensitive bool
|
|
Strict bool
|
|
}
|
|
|
|
type Section struct {
|
|
Name string
|
|
HeaderComment string
|
|
Raw string
|
|
Newline string
|
|
Entries []*Entry
|
|
entryIndex map[string][]*Entry
|
|
CaseSensitive bool
|
|
rawName string
|
|
parsedHeaderComment string
|
|
}
|
|
|
|
type Entry struct {
|
|
Key string
|
|
Values []string
|
|
Comment string
|
|
Raw string
|
|
NoValue bool
|
|
Delimiter string
|
|
Newline string
|
|
kind lineKind
|
|
|
|
parsedKey string
|
|
parsedValues []string
|
|
parsedComment string
|
|
parsedNoValue bool
|
|
parsedDelimiter string
|
|
}
|
|
|
|
type parsedLine struct {
|
|
kind lineKind
|
|
raw string
|
|
newline string
|
|
key string
|
|
value string
|
|
comment string
|
|
sectionName string
|
|
noValue bool
|
|
delimiter string
|
|
line int
|
|
}
|
|
|
|
type lineKind int
|
|
|
|
const (
|
|
lineEmpty lineKind = iota
|
|
lineComment
|
|
lineSection
|
|
linePair
|
|
lineRaw
|
|
)
|
|
|
|
func NewDocument() *Document {
|
|
return &Document{
|
|
sectionIndex: make(map[string][]*Section),
|
|
SectionOpen: "[",
|
|
SectionClose: "]",
|
|
Assign: "=",
|
|
AssignDelimiters: []string{"=", ":"},
|
|
CommentHeads: []string{"#", ";"},
|
|
AllowInline: true,
|
|
InlineCommentRequiresSpace: true,
|
|
AllowNoValue: true,
|
|
AllowMulti: true,
|
|
AllowContinuation: true,
|
|
TrimSpace: true,
|
|
}
|
|
}
|
|
|
|
type ParseError struct {
|
|
Line int
|
|
Column int
|
|
Message string
|
|
}
|
|
|
|
func (e *ParseError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.Column > 0 {
|
|
return fmt.Sprintf("sysconf: parse error at line %d, column %d: %s", e.Line, e.Column, e.Message)
|
|
}
|
|
return fmt.Sprintf("sysconf: parse error at line %d: %s", e.Line, e.Message)
|
|
}
|
|
|
|
func normalize(text string, caseSensitive bool) string {
|
|
if caseSensitive {
|
|
return text
|
|
}
|
|
return strings.ToLower(text)
|
|
}
|
|
|
|
func (d *Document) Parse(data []byte) error {
|
|
if d == nil {
|
|
return ErrDocumentClosed
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
d.sections = nil
|
|
d.sectionIndex = make(map[string][]*Section)
|
|
|
|
current := d.appendSection("", "", "", "")
|
|
lines := splitSourceLines(string(data))
|
|
for idx := 0; idx < len(lines); idx++ {
|
|
source := lines[idx]
|
|
raw := source.text
|
|
text := source.text
|
|
newline := source.newline
|
|
if d.AllowContinuation {
|
|
var err error
|
|
raw, text, newline, idx, err = d.consumeContinuation(lines, idx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
line, err := parseRawLine(raw, text, d, source.line)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
line.newline = newline
|
|
switch line.kind {
|
|
case lineSection:
|
|
current = d.appendSection(line.sectionName, line.comment, line.raw, line.newline)
|
|
case linePair, lineComment, lineEmpty, lineRaw:
|
|
if current == nil {
|
|
current = d.ensureSection("")
|
|
}
|
|
current.addParsed(line)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type sourceLine struct {
|
|
text string
|
|
newline string
|
|
line int
|
|
}
|
|
|
|
func splitSourceLines(text string) []sourceLine {
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
lines := make([]sourceLine, 0)
|
|
start := 0
|
|
line := 1
|
|
for idx := 0; idx < len(text); idx++ {
|
|
switch text[idx] {
|
|
case '\n':
|
|
lines = append(lines, sourceLine{text: text[start:idx], newline: "\n", line: line})
|
|
start = idx + 1
|
|
line++
|
|
case '\r':
|
|
newline := "\r"
|
|
end := idx + 1
|
|
if end < len(text) && text[end] == '\n' {
|
|
newline = "\r\n"
|
|
end++
|
|
}
|
|
lines = append(lines, sourceLine{text: text[start:idx], newline: newline, line: line})
|
|
start = end
|
|
idx = end - 1
|
|
line++
|
|
}
|
|
}
|
|
if start < len(text) {
|
|
lines = append(lines, sourceLine{text: text[start:], line: line})
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (d *Document) consumeContinuation(lines []sourceLine, idx int) (string, string, string, int, error) {
|
|
source := lines[idx]
|
|
raw := source.text
|
|
text := source.text
|
|
newline := source.newline
|
|
for hasContinuation(text) {
|
|
if idx+1 >= len(lines) {
|
|
if d.Strict {
|
|
return raw, text, newline, idx, &ParseError{Line: source.line, Column: continuationColumn(text), Message: "line continuation has no following line"}
|
|
}
|
|
return raw, text, newline, idx, nil
|
|
}
|
|
next := lines[idx+1]
|
|
raw += newline + next.text
|
|
text = trimContinuation(text) + strings.TrimLeft(next.text, " \t")
|
|
newline = next.newline
|
|
idx++
|
|
}
|
|
return raw, text, newline, idx, nil
|
|
}
|
|
|
|
func hasContinuation(text string) bool {
|
|
trimmed := strings.TrimRight(text, " \t")
|
|
if !strings.HasSuffix(trimmed, `\`) {
|
|
return false
|
|
}
|
|
count := 0
|
|
for idx := len(trimmed) - 1; idx >= 0 && trimmed[idx] == '\\'; idx-- {
|
|
count++
|
|
}
|
|
return count%2 == 1
|
|
}
|
|
|
|
func trimContinuation(text string) string {
|
|
trimmed := strings.TrimRight(text, " \t")
|
|
return trimmed[:len(trimmed)-1]
|
|
}
|
|
|
|
func continuationColumn(text string) int {
|
|
trimmed := strings.TrimRight(text, " \t")
|
|
return len(trimmed)
|
|
}
|
|
|
|
func parseRawLine(raw, text string, doc *Document, lineNo int) (parsedLine, error) {
|
|
trimmed := text
|
|
if doc.TrimSpace {
|
|
trimmed = strings.TrimSpace(text)
|
|
}
|
|
if trimmed == "" {
|
|
return parsedLine{kind: lineEmpty, raw: raw, line: lineNo}, nil
|
|
}
|
|
for _, head := range doc.CommentHeads {
|
|
if strings.HasPrefix(trimmed, head) {
|
|
return parsedLine{kind: lineComment, raw: raw, comment: strings.TrimSpace(strings.TrimPrefix(trimmed, head)), line: lineNo}, nil
|
|
}
|
|
}
|
|
if doc.SectionOpen != "" && doc.SectionClose != "" && strings.HasPrefix(trimmed, doc.SectionOpen) {
|
|
line, err := parseSectionLine(raw, trimmed, doc, lineNo)
|
|
if err != nil {
|
|
return parsedLine{}, err
|
|
}
|
|
return line, nil
|
|
}
|
|
delimiter, idx := findAssignDelimiter(trimmed, doc.assignDelimiters())
|
|
if idx < 0 {
|
|
if doc.AllowNoValue {
|
|
key, comment, err := splitInlineComment(trimmed, doc, lineNo)
|
|
if err != nil {
|
|
return parsedLine{}, err
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
if key == "" && doc.Strict {
|
|
return parsedLine{}, &ParseError{Line: lineNo, Column: 1, Message: "empty key"}
|
|
}
|
|
return parsedLine{kind: linePair, raw: raw, key: key, comment: comment, noValue: true, delimiter: doc.writeDelimiter(), line: lineNo}, nil
|
|
}
|
|
if doc.Strict {
|
|
return parsedLine{}, &ParseError{Line: lineNo, Column: 1, Message: "missing key/value delimiter"}
|
|
}
|
|
return parsedLine{kind: lineRaw, raw: raw, line: lineNo}, nil
|
|
}
|
|
key := strings.TrimSpace(trimmed[:idx])
|
|
if key == "" && doc.Strict {
|
|
return parsedLine{}, &ParseError{Line: lineNo, Column: 1, Message: "empty key"}
|
|
}
|
|
right := strings.TrimSpace(trimmed[idx+len(delimiter):])
|
|
value, comment, err := splitInlineComment(right, doc, lineNo)
|
|
if err != nil {
|
|
return parsedLine{}, err
|
|
}
|
|
value, err = parseValue(value, doc, lineNo)
|
|
if err != nil {
|
|
return parsedLine{}, err
|
|
}
|
|
return parsedLine{kind: linePair, raw: raw, key: key, value: value, comment: comment, delimiter: delimiter, line: lineNo}, nil
|
|
}
|
|
|
|
func parseSectionLine(raw, trimmed string, doc *Document, lineNo int) (parsedLine, error) {
|
|
end := strings.Index(trimmed[len(doc.SectionOpen):], doc.SectionClose)
|
|
if end < 0 {
|
|
if doc.Strict {
|
|
return parsedLine{}, &ParseError{Line: lineNo, Column: len(trimmed), Message: "section header is missing closing marker"}
|
|
}
|
|
return parsedLine{kind: lineRaw, raw: raw, line: lineNo}, nil
|
|
}
|
|
end += len(doc.SectionOpen)
|
|
name := strings.TrimSpace(trimmed[len(doc.SectionOpen):end])
|
|
if name == "" && doc.Strict {
|
|
return parsedLine{}, &ParseError{Line: lineNo, Column: len(doc.SectionOpen) + 1, Message: "empty section name"}
|
|
}
|
|
tail := strings.TrimSpace(trimmed[end+len(doc.SectionClose):])
|
|
comment := ""
|
|
if tail != "" {
|
|
var err error
|
|
rest, inlineComment, err := splitInlineComment(tail, doc, lineNo)
|
|
if err != nil {
|
|
return parsedLine{}, err
|
|
}
|
|
if strings.TrimSpace(rest) != "" {
|
|
if doc.Strict {
|
|
return parsedLine{}, &ParseError{Line: lineNo, Column: end + len(doc.SectionClose) + 1, Message: "unexpected text after section header"}
|
|
}
|
|
return parsedLine{kind: lineRaw, raw: raw, line: lineNo}, nil
|
|
}
|
|
comment = inlineComment
|
|
}
|
|
return parsedLine{kind: lineSection, raw: raw, sectionName: name, comment: comment, line: lineNo}, nil
|
|
}
|
|
|
|
func (d *Document) assignDelimiters() []string {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
if len(d.AssignDelimiters) > 0 {
|
|
out := make([]string, 0, len(d.AssignDelimiters))
|
|
for _, delimiter := range d.AssignDelimiters {
|
|
if delimiter != "" {
|
|
out = append(out, delimiter)
|
|
}
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
return len(out[i]) > len(out[j])
|
|
})
|
|
return out
|
|
}
|
|
if d.Assign == "" {
|
|
return nil
|
|
}
|
|
return []string{d.Assign}
|
|
}
|
|
|
|
func (d *Document) writeDelimiter() string {
|
|
if d == nil {
|
|
return "="
|
|
}
|
|
if d.Assign != "" {
|
|
return d.Assign
|
|
}
|
|
if len(d.AssignDelimiters) > 0 {
|
|
for _, delimiter := range d.AssignDelimiters {
|
|
if delimiter != "" {
|
|
return delimiter
|
|
}
|
|
}
|
|
}
|
|
return "="
|
|
}
|
|
|
|
func findAssignDelimiter(text string, delimiters []string) (string, int) {
|
|
if len(delimiters) == 0 {
|
|
return "", -1
|
|
}
|
|
var quote byte
|
|
escaped := false
|
|
for idx := 0; idx < len(text); idx++ {
|
|
ch := text[idx]
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if ch == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if ch == '"' || ch == '\'' {
|
|
if quote == 0 {
|
|
quote = ch
|
|
continue
|
|
}
|
|
if quote == ch {
|
|
quote = 0
|
|
continue
|
|
}
|
|
}
|
|
if quote != 0 {
|
|
continue
|
|
}
|
|
for _, delimiter := range delimiters {
|
|
if delimiter != "" && strings.HasPrefix(text[idx:], delimiter) {
|
|
return delimiter, idx
|
|
}
|
|
}
|
|
}
|
|
return "", -1
|
|
}
|
|
|
|
func splitInlineComment(text string, doc *Document, lineNo int) (string, string, error) {
|
|
if !doc.AllowInline {
|
|
return strings.TrimSpace(text), "", nil
|
|
}
|
|
var quote byte
|
|
escaped := false
|
|
for i := 0; i < len(text); i++ {
|
|
ch := text[i]
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if ch == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if ch == '"' || ch == '\'' {
|
|
if quote == 0 {
|
|
quote = ch
|
|
continue
|
|
}
|
|
if quote == ch {
|
|
quote = 0
|
|
continue
|
|
}
|
|
}
|
|
if quote != 0 {
|
|
continue
|
|
}
|
|
for _, head := range doc.CommentHeads {
|
|
if head == "" || !strings.HasPrefix(text[i:], head) {
|
|
continue
|
|
}
|
|
if doc.InlineCommentRequiresSpace && i > 0 && text[i-1] != ' ' && text[i-1] != '\t' {
|
|
continue
|
|
}
|
|
return strings.TrimSpace(text[:i]), strings.TrimSpace(text[i+len(head):]), nil
|
|
}
|
|
}
|
|
if quote != 0 && doc.Strict {
|
|
return "", "", &ParseError{Line: lineNo, Column: len(text), Message: "unterminated quoted value"}
|
|
}
|
|
return strings.TrimSpace(text), "", nil
|
|
}
|
|
|
|
func parseValue(value string, doc *Document, lineNo int) (string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
quote := value[0]
|
|
if quote != '"' && quote != '\'' {
|
|
return value, nil
|
|
}
|
|
escaped := false
|
|
end := -1
|
|
for idx := 1; idx < len(value); idx++ {
|
|
ch := value[idx]
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if ch == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if ch == quote {
|
|
end = idx
|
|
break
|
|
}
|
|
}
|
|
if end < 0 {
|
|
if doc.Strict {
|
|
return "", &ParseError{Line: lineNo, Column: 1, Message: "unterminated quoted value"}
|
|
}
|
|
return value, nil
|
|
}
|
|
if strings.TrimSpace(value[end+1:]) != "" {
|
|
if doc.Strict {
|
|
return "", &ParseError{Line: lineNo, Column: end + 2, Message: "unexpected text after quoted value"}
|
|
}
|
|
return value, nil
|
|
}
|
|
return unescapeQuotedValue(value[1:end], quote), nil
|
|
}
|
|
|
|
func unescapeQuotedValue(value string, quote byte) string {
|
|
var builder strings.Builder
|
|
builder.Grow(len(value))
|
|
escaped := false
|
|
for idx := 0; idx < len(value); idx++ {
|
|
ch := value[idx]
|
|
if !escaped {
|
|
if ch == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
builder.WriteByte(ch)
|
|
continue
|
|
}
|
|
switch ch {
|
|
case 'n':
|
|
builder.WriteByte('\n')
|
|
case 'r':
|
|
builder.WriteByte('\r')
|
|
case 't':
|
|
builder.WriteByte('\t')
|
|
case '\\':
|
|
builder.WriteByte('\\')
|
|
case '"', '\'':
|
|
if ch == quote {
|
|
builder.WriteByte(ch)
|
|
} else {
|
|
builder.WriteByte('\\')
|
|
builder.WriteByte(ch)
|
|
}
|
|
default:
|
|
builder.WriteByte('\\')
|
|
builder.WriteByte(ch)
|
|
}
|
|
escaped = false
|
|
}
|
|
if escaped {
|
|
builder.WriteByte('\\')
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func (p parsedLine) toEntry() *Entry {
|
|
switch p.kind {
|
|
case lineComment:
|
|
return &Entry{Raw: p.raw, Comment: p.comment, Newline: p.newline, kind: p.kind}
|
|
case lineEmpty:
|
|
return &Entry{Raw: p.raw, Newline: p.newline, kind: p.kind}
|
|
case lineRaw:
|
|
return &Entry{Raw: p.raw, Newline: p.newline, kind: p.kind}
|
|
}
|
|
entry := &Entry{
|
|
Key: p.key,
|
|
Comment: p.comment,
|
|
Raw: p.raw,
|
|
NoValue: p.noValue,
|
|
Delimiter: p.delimiter,
|
|
Newline: p.newline,
|
|
kind: p.kind,
|
|
}
|
|
if !p.noValue {
|
|
entry.Values = []string{p.value}
|
|
}
|
|
entry.rememberParsed()
|
|
return entry
|
|
}
|
|
|
|
func (e *Entry) rememberParsed() {
|
|
if e == nil {
|
|
return
|
|
}
|
|
e.parsedKey = e.Key
|
|
e.parsedValues = append([]string(nil), e.Values...)
|
|
e.parsedComment = e.Comment
|
|
e.parsedNoValue = e.NoValue
|
|
e.parsedDelimiter = e.Delimiter
|
|
}
|
|
|
|
func (e *Entry) parsedPairUnchanged() bool {
|
|
if e == nil || e.kind != linePair || e.Raw == "" {
|
|
return false
|
|
}
|
|
if e.Key != e.parsedKey || e.Comment != e.parsedComment || e.NoValue != e.parsedNoValue || e.Delimiter != e.parsedDelimiter {
|
|
return false
|
|
}
|
|
if len(e.Values) != len(e.parsedValues) {
|
|
return false
|
|
}
|
|
for idx := range e.Values {
|
|
if e.Values[idx] != e.parsedValues[idx] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (d *Document) ensureSection(name string) *Section {
|
|
d.rebuildSectionIndexLocked()
|
|
key := normalize(name, d.CaseSensitive)
|
|
if sections := d.sectionIndex[key]; len(sections) > 0 {
|
|
return sections[0]
|
|
}
|
|
return d.appendSection(name, "", "", "\n")
|
|
}
|
|
|
|
func (d *Document) rebuildSectionIndexLocked() {
|
|
if d.sectionIndex == nil {
|
|
d.sectionIndex = make(map[string][]*Section)
|
|
}
|
|
for key := range d.sectionIndex {
|
|
delete(d.sectionIndex, key)
|
|
}
|
|
for _, section := range d.sections {
|
|
if section == nil {
|
|
continue
|
|
}
|
|
key := normalize(section.Name, d.CaseSensitive)
|
|
d.sectionIndex[key] = append(d.sectionIndex[key], section)
|
|
}
|
|
}
|
|
|
|
func (d *Document) appendSection(name, comment, raw, newline string) *Section {
|
|
section := &Section{
|
|
Name: name,
|
|
HeaderComment: comment,
|
|
Raw: raw,
|
|
Newline: newline,
|
|
CaseSensitive: d.CaseSensitive,
|
|
entryIndex: make(map[string][]*Entry),
|
|
rawName: name,
|
|
parsedHeaderComment: comment,
|
|
}
|
|
if section.Newline == "" && raw == "" {
|
|
section.Newline = "\n"
|
|
}
|
|
key := normalize(name, d.CaseSensitive)
|
|
d.sections = append(d.sections, section)
|
|
d.sectionIndex[key] = append(d.sectionIndex[key], section)
|
|
return section
|
|
}
|
|
|
|
func (s *Section) addParsed(line parsedLine) {
|
|
switch line.kind {
|
|
case linePair:
|
|
entry := line.toEntry()
|
|
s.addEntry(entry)
|
|
case lineComment, lineEmpty, lineRaw:
|
|
s.Entries = append(s.Entries, line.toEntry())
|
|
}
|
|
}
|
|
|
|
func (s *Section) addEntry(entry *Entry) {
|
|
if s.entryIndex == nil {
|
|
s.entryIndex = make(map[string][]*Entry)
|
|
}
|
|
key := normalize(entry.Key, s.CaseSensitive)
|
|
s.Entries = append(s.Entries, entry)
|
|
s.entryIndex[key] = append(s.entryIndex[key], entry)
|
|
}
|
|
|
|
func (d *Document) Section(name string) *Section {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.rebuildSectionIndexLocked()
|
|
sections := d.sectionIndex[normalize(name, d.CaseSensitive)]
|
|
if len(sections) == 0 {
|
|
return nil
|
|
}
|
|
return sections[0]
|
|
}
|
|
|
|
func (d *Document) SectionsByName(name string) []*Section {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.rebuildSectionIndexLocked()
|
|
sections := d.sectionIndex[normalize(name, d.CaseSensitive)]
|
|
return append([]*Section(nil), sections...)
|
|
}
|
|
|
|
func (d *Document) Sections() []*Section {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return append([]*Section(nil), d.sections...)
|
|
}
|
|
|
|
func (s *Section) Entry(key string) *Entry {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
entries := s.entryIndex[normalize(key, s.CaseSensitive)]
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
return entries[0]
|
|
}
|
|
|
|
func (s *Section) EntriesByKey(key string) []*Entry {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
entries := s.entryIndex[normalize(key, s.CaseSensitive)]
|
|
return append([]*Entry(nil), entries...)
|
|
}
|
|
|
|
func (s *Section) Keys() []string {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(s.entryIndex))
|
|
for _, entries := range s.entryIndex {
|
|
if len(entries) == 0 {
|
|
continue
|
|
}
|
|
keys = append(keys, entries[0].Key)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func (s *Section) Get(key string) string {
|
|
if entry := s.Entry(key); entry != nil && len(entry.Values) > 0 {
|
|
return entry.Values[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Section) GetAll(key string) []string {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
entries := s.EntriesByKey(key)
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
values := make([]string, 0)
|
|
for _, entry := range entries {
|
|
values = append(values, entry.Values...)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func (s *Section) Int(key string) int {
|
|
v, _ := strconv.Atoi(s.Get(key))
|
|
return v
|
|
}
|
|
|
|
func (s *Section) Int64(key string) int64 {
|
|
v, _ := strconv.ParseInt(s.Get(key), 10, 64)
|
|
return v
|
|
}
|
|
|
|
func (s *Section) Int32(key string) int32 {
|
|
v, _ := strconv.ParseInt(s.Get(key), 10, 32)
|
|
return int32(v)
|
|
}
|
|
|
|
func (s *Section) Float64(key string) float64 {
|
|
v, _ := strconv.ParseFloat(s.Get(key), 64)
|
|
return v
|
|
}
|
|
|
|
func (s *Section) Float32(key string) float32 {
|
|
v, _ := strconv.ParseFloat(s.Get(key), 32)
|
|
return float32(v)
|
|
}
|
|
|
|
func (s *Section) Bool(key string) bool {
|
|
v, _ := strconv.ParseBool(s.Get(key))
|
|
return v
|
|
}
|
|
|
|
func (s *Section) SetBool(key string, value bool, comment string) error {
|
|
return s.Set(key, strconv.FormatBool(value), comment)
|
|
}
|
|
|
|
func (s *Section) SetFloat64(key string, prec int, value float64, comment string) error {
|
|
return s.Set(key, strconv.FormatFloat(value, 'f', prec, 64), comment)
|
|
}
|
|
|
|
func (s *Section) SetFloat32(key string, prec int, value float32, comment string) error {
|
|
return s.Set(key, strconv.FormatFloat(float64(value), 'f', prec, 32), comment)
|
|
}
|
|
|
|
func (s *Section) SetUint64(key string, value uint64, comment string) error {
|
|
return s.Set(key, strconv.FormatUint(value, 10), comment)
|
|
}
|
|
|
|
func (s *Section) SetInt64(key string, value int64, comment string) error {
|
|
return s.Set(key, strconv.FormatInt(value, 10), comment)
|
|
}
|
|
|
|
func (s *Section) SetInt32(key string, value int32, comment string) error {
|
|
return s.Set(key, strconv.FormatInt(int64(value), 10), comment)
|
|
}
|
|
|
|
func (s *Section) SetInt(key string, value int, comment string) error {
|
|
return s.Set(key, strconv.Itoa(value), comment)
|
|
}
|
|
|
|
type countingWriter struct {
|
|
w io.Writer
|
|
n int64
|
|
}
|
|
|
|
func (w *countingWriter) Write(data []byte) (int, error) {
|
|
n, err := w.w.Write(data)
|
|
w.n += int64(n)
|
|
return n, err
|
|
}
|
|
|
|
func (d *Document) WriteTo(w io.Writer) (int64, error) {
|
|
if d == nil {
|
|
return 0, ErrDocumentClosed
|
|
}
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
counting := &countingWriter{w: w}
|
|
for _, section := range d.sections {
|
|
if section == nil {
|
|
continue
|
|
}
|
|
if section.Raw != "" && section.Name == section.rawName && section.HeaderComment == section.parsedHeaderComment {
|
|
if err := writeLine(counting, section.Raw, section.Newline, true); err != nil {
|
|
return counting.n, err
|
|
}
|
|
} else if section.Name != "" && d.SectionOpen != "" {
|
|
if err := writeLine(counting, d.formatSectionHeader(section), section.Newline, false); err != nil {
|
|
return counting.n, err
|
|
}
|
|
}
|
|
for _, entry := range section.Entries {
|
|
if err := d.writeEntry(counting, entry); err != nil {
|
|
return counting.n, err
|
|
}
|
|
}
|
|
}
|
|
return counting.n, nil
|
|
}
|
|
|
|
func writeLine(w io.Writer, text, newline string, preserveNoNewline bool) error {
|
|
if text != "" {
|
|
if _, err := io.WriteString(w, text); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if newline != "" {
|
|
_, err := io.WriteString(w, newline)
|
|
return err
|
|
}
|
|
if preserveNoNewline {
|
|
return nil
|
|
}
|
|
_, err := io.WriteString(w, "\n")
|
|
return err
|
|
}
|
|
|
|
func (d *Document) writeEntry(w io.Writer, entry *Entry) error {
|
|
if entry == nil {
|
|
return nil
|
|
}
|
|
if entry.kind != linePair && entry.Raw != "" {
|
|
return writeLine(w, entry.Raw, entry.Newline, true)
|
|
}
|
|
if entry.parsedPairUnchanged() {
|
|
return writeLine(w, entry.Raw, entry.Newline, true)
|
|
}
|
|
newline := entry.Newline
|
|
if newline == "" {
|
|
newline = "\n"
|
|
}
|
|
delimiter := entry.Delimiter
|
|
if delimiter == "" {
|
|
delimiter = d.Assign
|
|
}
|
|
if delimiter == "" {
|
|
delimiter = "="
|
|
}
|
|
if entry.NoValue || len(entry.Values) == 0 {
|
|
text := entry.Key
|
|
if entry.Comment != "" && d.AllowInline && len(d.CommentHeads) > 0 {
|
|
text += " " + d.CommentHeads[0] + entry.Comment
|
|
return writeLine(w, text, newline, false)
|
|
}
|
|
if err := writeLine(w, text, newline, false); err != nil {
|
|
return err
|
|
}
|
|
if entry.Comment != "" && len(d.CommentHeads) > 0 {
|
|
return writeLine(w, d.CommentHeads[0]+entry.Comment, newline, false)
|
|
}
|
|
return nil
|
|
}
|
|
value := d.formatValue(entry.Values[0])
|
|
text := entry.Key + delimiter + value
|
|
if entry.Comment != "" && d.AllowInline && len(d.CommentHeads) > 0 {
|
|
text += " " + d.CommentHeads[0] + entry.Comment
|
|
}
|
|
if err := writeLine(w, text, newline, false); err != nil {
|
|
return err
|
|
}
|
|
if d.AllowMulti && len(entry.Values) > 1 {
|
|
for _, extra := range entry.Values[1:] {
|
|
extraValue := d.formatValue(extra)
|
|
if err := writeLine(w, entry.Key+delimiter+extraValue, newline, false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Document) formatSectionHeader(section *Section) string {
|
|
text := d.SectionOpen + section.Name + d.SectionClose
|
|
if section.HeaderComment != "" && d.AllowInline && len(d.CommentHeads) > 0 {
|
|
text += " " + d.CommentHeads[0] + section.HeaderComment
|
|
}
|
|
return text
|
|
}
|
|
|
|
func (d *Document) formatValue(value string) string {
|
|
if !d.valueNeedsQuotes(value) {
|
|
return value
|
|
}
|
|
return quoteValue(value)
|
|
}
|
|
|
|
func (d *Document) valueNeedsQuotes(value string) bool {
|
|
if value == "" {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(value) != value || strings.ContainsAny(value, "\r\n\t") {
|
|
return true
|
|
}
|
|
if value[0] == '"' || value[0] == '\'' {
|
|
return true
|
|
}
|
|
if d != nil && d.AllowInline {
|
|
for _, head := range d.CommentHeads {
|
|
if head != "" && strings.Contains(value, head) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func quoteValue(value string) string {
|
|
var builder strings.Builder
|
|
builder.Grow(len(value) + 2)
|
|
builder.WriteByte('"')
|
|
for idx := 0; idx < len(value); idx++ {
|
|
switch value[idx] {
|
|
case '\\':
|
|
builder.WriteString(`\\`)
|
|
case '"':
|
|
builder.WriteString(`\"`)
|
|
case '\n':
|
|
builder.WriteString(`\n`)
|
|
case '\r':
|
|
builder.WriteString(`\r`)
|
|
case '\t':
|
|
builder.WriteString(`\t`)
|
|
default:
|
|
builder.WriteByte(value[idx])
|
|
}
|
|
}
|
|
builder.WriteByte('"')
|
|
return builder.String()
|
|
}
|
|
|
|
func (d *Document) Bytes() []byte {
|
|
var buf bytes.Buffer
|
|
_, _ = d.WriteTo(&buf)
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func (d *Document) Save(path string) error {
|
|
if d == nil {
|
|
return ErrDocumentClosed
|
|
}
|
|
return os.WriteFile(path, d.Bytes(), 0o644)
|
|
}
|
|
|
|
func (d *Document) SaveAtomic(path string) error {
|
|
if d == nil {
|
|
return ErrDocumentClosed
|
|
}
|
|
return writeFileAtomic(path, d.Bytes(), 0o644)
|
|
}
|
|
|
|
func writeFileAtomic(path string, data []byte, defaultPerm os.FileMode) error {
|
|
perm := defaultPerm
|
|
if info, err := os.Stat(path); err == nil {
|
|
perm = info.Mode().Perm()
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
dir := filepath.Dir(path)
|
|
base := filepath.Base(path)
|
|
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
keepTmp := false
|
|
defer func() {
|
|
if !keepTmp {
|
|
_ = os.Remove(tmpPath)
|
|
}
|
|
}()
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Chmod(perm); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
return err
|
|
}
|
|
keepTmp = true
|
|
syncParentDir(dir)
|
|
return nil
|
|
}
|
|
|
|
func syncParentDir(dir string) {
|
|
f, err := os.Open(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = f.Sync()
|
|
_ = f.Close()
|
|
}
|
|
|
|
func (s *Section) Exist(key string) bool {
|
|
return s.Entry(key) != nil
|
|
}
|
|
|
|
func (s *Section) Comment(key string) string {
|
|
if entry := s.Entry(key); entry != nil {
|
|
return entry.Comment
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Section) SetComment(key, comment string) error {
|
|
entry := s.Entry(key)
|
|
if entry == nil {
|
|
return ErrKeyNotFound
|
|
}
|
|
entry.Comment = comment
|
|
return nil
|
|
}
|
|
|
|
func (s *Section) Set(key, value, comment string) error {
|
|
return s.SetAll(key, []string{value}, comment)
|
|
}
|
|
|
|
func (s *Section) SetAll(key string, values []string, comment string) error {
|
|
if s == nil {
|
|
return ErrSectionNotFound
|
|
}
|
|
if s.entryIndex == nil {
|
|
s.entryIndex = make(map[string][]*Entry)
|
|
}
|
|
normalized := normalize(key, s.CaseSensitive)
|
|
entries := s.entryIndex[normalized]
|
|
if len(entries) == 0 {
|
|
entry := &Entry{Key: key, Values: append([]string(nil), values...), Comment: comment, Newline: s.Newline}
|
|
if len(values) == 0 {
|
|
entry.NoValue = true
|
|
}
|
|
s.addEntry(entry)
|
|
return nil
|
|
}
|
|
first := entries[0]
|
|
first.Values = append([]string(nil), values...)
|
|
first.Comment = comment
|
|
first.NoValue = len(values) == 0
|
|
if len(entries) > 1 {
|
|
s.entryIndex[normalized] = []*Entry{first}
|
|
filtered := s.Entries[:0]
|
|
for _, entry := range s.Entries {
|
|
if normalize(entry.Key, s.CaseSensitive) != normalized {
|
|
filtered = append(filtered, entry)
|
|
continue
|
|
}
|
|
if entry == first {
|
|
filtered = append(filtered, entry)
|
|
}
|
|
}
|
|
s.Entries = filtered
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Section) AddValue(key, value, comment string) error {
|
|
if s == nil {
|
|
return ErrSectionNotFound
|
|
}
|
|
if s.entryIndex == nil {
|
|
s.entryIndex = make(map[string][]*Entry)
|
|
}
|
|
normalized := normalize(key, s.CaseSensitive)
|
|
entry := &Entry{Key: key, Values: []string{value}, Comment: comment, Newline: s.Newline}
|
|
s.Entries = append(s.Entries, entry)
|
|
s.entryIndex[normalized] = append(s.entryIndex[normalized], entry)
|
|
return nil
|
|
}
|
|
|
|
func (s *Section) Delete(key string) error {
|
|
if s == nil {
|
|
return ErrSectionNotFound
|
|
}
|
|
normalized := normalize(key, s.CaseSensitive)
|
|
if len(s.entryIndex[normalized]) == 0 {
|
|
return ErrKeyNotFound
|
|
}
|
|
delete(s.entryIndex, normalized)
|
|
filtered := s.Entries[:0]
|
|
for _, entry := range s.Entries {
|
|
if normalize(entry.Key, s.CaseSensitive) == normalized {
|
|
continue
|
|
}
|
|
filtered = append(filtered, entry)
|
|
}
|
|
s.Entries = filtered
|
|
return nil
|
|
}
|
|
|
|
func (s *Section) DeleteValue(key, value string) error {
|
|
normalized := normalize(key, s.CaseSensitive)
|
|
entries := s.entryIndex[normalized]
|
|
if len(entries) == 0 {
|
|
return ErrKeyNotFound
|
|
}
|
|
kept := make([]*Entry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
filtered := entry.Values[:0]
|
|
for _, item := range entry.Values {
|
|
if item == value {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
if len(filtered) == 0 {
|
|
continue
|
|
}
|
|
entry.Values = filtered
|
|
kept = append(kept, entry)
|
|
}
|
|
if len(kept) == 0 {
|
|
delete(s.entryIndex, normalized)
|
|
} else {
|
|
s.entryIndex[normalized] = kept
|
|
}
|
|
filteredEntries := s.Entries[:0]
|
|
for _, entry := range s.Entries {
|
|
if normalize(entry.Key, s.CaseSensitive) != normalized {
|
|
filteredEntries = append(filteredEntries, entry)
|
|
continue
|
|
}
|
|
for _, keptEntry := range kept {
|
|
if entry == keptEntry {
|
|
filteredEntries = append(filteredEntries, entry)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
s.Entries = filteredEntries
|
|
return nil
|
|
}
|