Go 语言400行代码实现 INI 配置文件解析器:支持注释、转义与类型推断

54 阅读10分钟

INI 是一种历史悠久但依然广泛使用的配置文件格式,因其结构简单、易于阅读,常用于小型项目或嵌入式系统中。本文将带你用 Go 语言从零实现一个功能完整的 INI 配置文件解析器,支持以下特性:

  • 节(Section)与键值对(Key-Value)
  • 节前注释(Leading Comments)与行尾注释(Inline Comments)
  • 自动类型推断(整数、浮点数、布尔值、字符串)
  • 带转义的双引号字符串(如 "say "hello"")
  • 保留原始顺序(节顺序与键顺序)
  • 完整的读写 Roundtrip 能力(解析后写回内容一致)

我们将通过 词法分析(Lexer) + 语法解析(Parser) + 序列化(Writer) 的经典三段式结构来构建这个解析器,并附带完整的单元测试与性能基准。

一、数据结构设计

首先定义核心数据结构,用于在内存中表示 INI 文件的内容:

// IniConfig 表示整个 INI 配置
type IniConfig struct {
	SectionOrder []string              // 节的出现顺序(保留原始顺序)
	Sections     map[string]*Section   // 节名 -> 节内容
}

// Section 表示一个节(如 [server])
type Section struct {
	Name            string            // 节名
	KeyOrder        []string          // 键的出现顺序
	Keys            map[string]*Key   // 键名 -> 键值
	LeadingComments []string          // 节前的注释(包括空行)
}

// Key 表示一个键值对
type Key struct {
	Name    string      // 键名
	Value   interface{} // 值(支持多种类型)
	Comment string      // 行尾注释(如 ; 这是注释)
}

说明:我们只保留"节前注释",不保留"节后注释"或"键后空行",这是为了简化设计,同时满足大多数使用场景。

二、词法分析器(Lexer)

INI 文件的词法单元(Token)类型包括:

const (
	ILLEGAL TokenType = iota
	EOF
	SECTION   // [section]
	KEY       // key
	COMMENT   // ; 或 #
	EQUALS    // =
	NEWLINE   // \n
)

词法分析器 Lexer 逐字符扫描输入,识别出上述 Token。关键逻辑包括:

  • 遇到 [ 开始读取节名,直到 ]
  • 遇到 ; 或 # 读取整行作为注释
  • 遇到字母或 _ 开始读取标识符(键名)
  • 跳过空格和制表符,但保留换行符(用于注释分组)
func (l *Lexer) NextToken() Token {
	l.skipWhitespace()
	switch l.ch {
	case 0:      return Token{Type: EOF}
	case '\n':   return Token{Type: NEWLINE, Line: l.line}; l.line++; l.readChar()
	case '[':    // 解析 [section]
	case ';', '#': // 解析注释
	case '=':    return Token{Type: EQUALS}; l.readChar()
	default:
		if isLetter(l.ch) || l.ch == '_' {
			return Token{Type: KEY, Value: l.readIdentifier()}
		}
		// 非法字符
	}
}

三、语法解析器(Parser)

解析器 ParseINI 驱动词法分析器,构建 IniConfig 对象。

1. 注释处理

使用 pendingComments 缓存当前尚未归属的注释(或空行)。当遇到 [section] 时,将这些注释分配给该节作为 LeadingComments。

var pendingComments []string

for {
	tok := lexer.NextToken()
	switch tok.Type {
	case COMMENT:
		pendingComments = append(pendingComments, tok.Value)
	case NEWLINE:
		pendingComments = append(pendingComments, "")
	case SECTION:
		currentSection = &Section{
			Name:            tok.Value,
			LeadingComments: filterComments(pendingComments),
		}
		pendingComments = nil // 清空
	}
}

filterComments 会移除开头的空行,避免节前出现多余空白。

2. 键值解析

键后可能有三种情况:

  • key = value ; comment
  • key ; comment(无等号,值为 true)
  • key(无等号无注释,值为 true)

我们通过 splitValueAndComment 分离值与行尾注释:

valStr, trailing := splitValueAndComment(remainder)
value = parseValue(valStr)
comment = trailing

3. 值类型自动推断(parseValue)

parseValue 按优先级尝试解析:

  • 整数(8080 → int64)
  • 浮点数(3.14 → float64)
  • 布尔值(true/false,不区分大小写)
  • 带引号字符串(支持 " 和 \ 转义)
  • 原始字符串(其他情况)
func parseValue(s string) interface{} {
	s = strings.TrimSpace(s)
	if s == "" { return "" }
	if v, _ := strconv.ParseInt(s, 10, 64); err == nil { return v }
	if v, _ := strconv.ParseFloat(s, 64); err == nil { return v }
	if strings.ToLower(s) == "true" { return true }
	if strings.ToLower(s) == "false" { return false }
	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
		if unquoted, err := parseQuotedString(s); err == nil {
			return unquoted
		}
	}
	return s // fallback
}

转义处理:parseQuotedString 支持 " 和 \,其他字符原样保留。

四、序列化(WriteTo)

将 IniConfig 写回 INI 格式文本,需注意:

  • 保留节和键的原始顺序
  • 节前注释原样输出
  • 布尔值 true 且无注释时,省略 = true(如 tls)
  • 字符串含空格、=、#、;、" 时自动加双引号并转义
func formatValue(v interface{}) string {
	switch val := v.(type) {
	case string:
		if val == "" || strings.ContainsAny(val, " \t=\"#;") {
			escaped := strings.ReplaceAll(val, "\\", "\\\\")
			escaped = strings.ReplaceAll(escaped, `"`, `\"`)
			return `"` + escaped + `"`
		}
		return val
	case bool:    return strconv.FormatBool(val)
	case int64:   return strconv.FormatInt(val, 10)
	case float64: return strconv.FormatFloat(val, 'g', -1, 64)
	default:      return fmt.Sprintf("%v", v)
	}
}

五、完整示例

[test]
msg = "say \"hello\" world"
path = "C:\\Program Files\\中文"
normal = hello
flag

解析后:

  • msg → say "hello" world
  • path → C:\Program Files\中文
  • normal → hello
  • flag → true

写回后内容与原始输入语义一致,实现 Roundtrip 安全。

六、测试与性能

我们编写了全面的单元测试,覆盖:

  • 基本键值解析
  • 注释处理(节前、行尾)
  • 类型推断(整数、浮点、布尔、字符串)
  • 转义字符串
  • 空白处理
  • Get/Set 操作
  • Roundtrip 一致性

性能基准(AMD Ryzen 7 5700U):

BenchmarkParse-16      110815    10804 ns/op    6384 B/op    152 allocs/op
BenchmarkGetSet-16    4462018      264 ns/op      79 B/op      3 allocs/op
BenchmarkWrite-16      172732     7585 ns/op    2241 B/op     85 allocs/op

解析一个典型配置文件仅需 10 微秒,性能完全满足日常使用。

七、总结

本文实现的 INI 解析器具备以下优势:

✅ 功能完整:支持注释、转义、类型推断

✅ 语义保真:Roundtrip 安全,写回内容可再次解析

✅ 结构清晰:Lexer + Parser + Writer 三段式设计

✅ 性能优秀:微秒级解析,低内存分配

✅ 测试充分:覆盖边界情况与异常输入

虽然 Go 官方未提供 INI 支持,但通过本文的实现,你可以轻松在项目中集成一个轻量、可靠、可维护的配置方案。

完整源码

ini.go

package main

import (
	"fmt"
	"io"
	"os"
	"strconv"
	"strings"
	"unicode"
)

// ========================
// 数据结构
// ========================

type IniConfig struct {
	SectionOrder []string
	Sections     map[string]*Section
}

type Section struct {
	Name            string
	KeyOrder        []string
	Keys            map[string]*Key
	LeadingComments []string // 只保留节前注释
}

type Key struct {
	Name    string
	Value   interface{}
	Comment string // 行尾注释
}

// ========================
// Lexer
// ========================

type TokenType int

const (
	ILLEGAL TokenType = iota
	EOF
	SECTION
	KEY
	COMMENT
	EQUALS
	NEWLINE
)

type Token struct {
	Type  TokenType
	Value string
	Line  int
}

type Lexer struct {
	input        string
	position     int
	readPosition int
	ch           byte
	line         int
}

func NewLexer(input string) *Lexer {
	l := &Lexer{input: input, line: 1}
	l.readChar()
	return l
}

func (l *Lexer) readChar() {
	if l.readPosition >= len(l.input) {
		l.ch = 0
	} else {
		l.ch = l.input[l.readPosition]
	}
	l.position = l.readPosition
	l.readPosition++
}

func (l *Lexer) peekChar() byte {
	if l.readPosition >= len(l.input) {
		return 0
	}
	return l.input[l.readPosition]
}

func (l *Lexer) skipWhitespace() {
	for l.ch == ' ' || l.ch == '\t' {
		l.readChar()
	}
}

func (l *Lexer) readIdentifier() string {
	start := l.position
	for isLetter(l.ch) || isDigit(l.ch) || l.ch == '_' || l.ch == '-' || l.ch == '.' {
		l.readChar()
	}
	return l.input[start:l.position]
}

func (l *Lexer) readLineRemainder() string {
	start := l.position
	for l.ch != '\n' && l.ch != 0 {
		l.readChar()
	}
	return l.input[start:l.position]
}

func isLetter(ch byte) bool {
	return ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z')
}

func isDigit(ch byte) bool {
	return '0' <= ch && ch <= '9'
}

func (l *Lexer) NextToken() Token {
	var tok Token
	l.skipWhitespace()

	switch l.ch {
	case 0:
		tok = Token{Type: EOF, Line: l.line}
	case '\n':
		tok = Token{Type: NEWLINE, Value: "\n", Line: l.line}
		l.line++
		l.readChar()
	case '[':
		l.readChar()
		tok.Type = SECTION
		start := l.position
		for l.ch != ']' && l.ch != 0 && l.ch != '\n' {
			l.readChar()
		}
		tok.Value = strings.TrimSpace(l.input[start:l.position])
		if l.ch == ']' {
			l.readChar()
		}
	case ';', '#':
		tok.Type = COMMENT
		start := l.position
		for l.ch != '\n' && l.ch != 0 {
			l.readChar()
		}
		tok.Value = l.input[start:l.position]
		l.readChar()
	case '=':
		tok = Token{Type: EQUALS, Value: "=", Line: l.line}
		l.readChar()
	default:
		if isLetter(l.ch) || l.ch == '_' {
			tok.Type = KEY
			tok.Value = l.readIdentifier()
		} else {
			tok = Token{Type: ILLEGAL, Value: string(l.ch), Line: l.line}
			l.readChar()
		}
	}
	return tok
}

// ========================
// splitValueAndComment
// ========================

func splitValueAndComment(s string) (value, comment string) {
	s = strings.TrimLeftFunc(s, unicode.IsSpace)
	inQuotes := false
	for i, ch := range s {
		if ch == '"' {
			inQuotes = !inQuotes
		}
		if !inQuotes && (ch == ';' || ch == '#') {
			value = strings.TrimRightFunc(s[:i], unicode.IsSpace)
			comment = strings.TrimLeftFunc(s[i+1:], unicode.IsSpace)
			return
		}
	}
	value = s
	comment = ""
	return
}

// ========================
// 解析带转义的双引号字符串
// ========================

func parseQuotedString(s string) (string, error) {
	if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
		return "", fmt.Errorf("not a quoted string")
	}
	inner := s[1 : len(s)-1]
	var result strings.Builder
	for i := 0; i < len(inner); i++ {
		c := inner[i]
		if c == '\\' && i+1 < len(inner) {
			next := inner[i+1]
			if next == '"' || next == '\\' {
				result.WriteByte(next)
				i++ // skip next char
				continue
			}
		}
		result.WriteByte(c)
	}
	return result.String(), nil
}

// ========================
// Parser
// ========================

func ParseINI(input string) (*IniConfig, error) {
	lexer := NewLexer(input)
	cfg := &IniConfig{
		Sections:     make(map[string]*Section),
		SectionOrder: nil,
	}

	var currentSection *Section
	var pendingComments []string

	for {
		tok := lexer.NextToken()
		switch tok.Type {
		case EOF:
			return cfg, nil

		case COMMENT:
			pendingComments = append(pendingComments, tok.Value)

		case NEWLINE:
			pendingComments = append(pendingComments, "")

		case SECTION:
			currentSection = &Section{
				Name:            tok.Value,
				Keys:            make(map[string]*Key),
				KeyOrder:        nil,
				LeadingComments: filterComments(pendingComments),
			}
			cfg.Sections[tok.Value] = currentSection
			cfg.SectionOrder = append(cfg.SectionOrder, tok.Value)
			pendingComments = nil

		case KEY:
			if currentSection == nil {
				return nil, fmt.Errorf("key %q outside any section at line %d", tok.Value, tok.Line)
			}

			keyName := tok.Value
			var value interface{} = true
			var comment string

			nextTok := lexer.NextToken()
			if nextTok.Type == EQUALS {
				remainder := lexer.readLineRemainder()
				valStr, trailing := splitValueAndComment(remainder)
				value = parseValue(valStr)
				comment = trailing
			} else if nextTok.Type == COMMENT {
				comment = nextTok.Value
			} else if nextTok.Type == NEWLINE || nextTok.Type == EOF {
				// ok
			} else {
				return nil, fmt.Errorf("unexpected token %q after key at line %d", nextTok.Value, tok.Line)
			}

			if _, exists := currentSection.Keys[keyName]; !exists {
				currentSection.KeyOrder = append(currentSection.KeyOrder, keyName)
			}
			currentSection.Keys[keyName] = &Key{
				Name:    keyName,
				Value:   value,
				Comment: comment,
			}

		case ILLEGAL:
			return nil, fmt.Errorf("illegal character %q at line %d", tok.Value, tok.Line)
		}
	}
}

func filterComments(lines []string) []string {
	var result []string
	for _, line := range lines {
		if line != "" || len(result) > 0 {
			result = append(result, line)
		}
	}
	return result
}

// ========================
// parseValue:支持转义引号
// ========================

func parseValue(s string) interface{} {
	s = strings.TrimSpace(s)
	if s == "" {
		return ""
	}
	if v, err := strconv.ParseInt(s, 10, 64); err == nil {
		return v
	}
	if v, err := strconv.ParseFloat(s, 64); err == nil {
		return v
	}
	if strings.ToLower(s) == "true" {
		return true
	}
	if strings.ToLower(s) == "false" {
		return false
	}
	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
		if unquoted, err := parseQuotedString(s); err == nil {
			return unquoted
		}
	}
	return s
}

func (cfg *IniConfig) Get(section, key string) (interface{}, bool) {
	if sec, ok := cfg.Sections[section]; ok {
		if k, ok := sec.Keys[key]; ok {
			return k.Value, true
		}
	}
	return nil, false
}

func (cfg *IniConfig) Set(section, key string, value interface{}) {
	sec, exists := cfg.Sections[section]
	if !exists {
		sec = &Section{
			Name:            section,
			Keys:            make(map[string]*Key),
			KeyOrder:        []string{key},
			LeadingComments: nil,
		}
		cfg.Sections[section] = sec
		cfg.SectionOrder = append(cfg.SectionOrder, section)
		sec.Keys[key] = &Key{Name: key, Value: value}
		return
	}
	if _, keyExists := sec.Keys[key]; !keyExists {
		sec.KeyOrder = append(sec.KeyOrder, key)
	}
	sec.Keys[key] = &Key{Name: key, Value: value}
}

// ========================
// WriteTo
// ========================

func (cfg *IniConfig) WriteTo(w io.Writer) error {
	for i, secName := range cfg.SectionOrder {
		sec := cfg.Sections[secName]

		for _, c := range sec.LeadingComments {
			if c == "" {
				fmt.Fprintln(w)
			} else {
				fmt.Fprintln(w, c)
			}
		}
		fmt.Fprintf(w, "[%s]\n", secName)

		for _, keyName := range sec.KeyOrder {
			key := sec.Keys[keyName]
			if key.Value == true && key.Comment == "" {
				fmt.Fprintf(w, "%s\n", key.Name)
			} else {
				valStr := formatValue(key.Value)
				line := fmt.Sprintf("%s = %s", key.Name, valStr)
				if key.Comment != "" {
					line += " ;" + key.Comment
				}
				fmt.Fprintln(w, line)
			}
		}

		if i < len(cfg.SectionOrder)-1 {
			fmt.Fprintln(w)
		}
	}
	return nil
}

func formatValue(v interface{}) string {
	switch val := v.(type) {
	case string:
		if val == "" || strings.ContainsAny(val, " \t=\"#;") {
			// Escape quotes and backslashes
			escaped := strings.ReplaceAll(val, "\\", "\\\\")
			escaped = strings.ReplaceAll(escaped, `"`, `\"`)
			return `"` + escaped + `"`
		}
		return val
	case bool:
		return strconv.FormatBool(val)
	case int64:
		return strconv.FormatInt(val, 10)
	case float64:
		return strconv.FormatFloat(val, 'g', -1, 64)
	default:
		return fmt.Sprintf("%v", v)
	}
}

// ========================
// main(用于测试)
// ========================

func main() {
	input := `[test]
msg = "say \"hello\" world"
path = "C:\\Program Files\\中文"
normal = hello
flag
`

	cfg, err := ParseINI(input)
	if err != nil {
		panic(err)
	}

	if msg, ok := cfg.Get("test", "msg"); ok {
		fmt.Printf("Parsed msg: [%s]\n", msg) // should be: say "hello" world
	}
	if path, ok := cfg.Get("test", "path"); ok {
		fmt.Printf("Parsed path: [%s]\n", path) // should be: say "hello" world
	}

	fmt.Println("\n--- Roundtrip ---")
	cfg.WriteTo(os.Stdout)
}

ini_test.go

// 文件:ini_test.go
package main

import (
	"bytes"
	"fmt"
	"reflect"
	"testing"
)

// ========================
// 功能测试
// ========================

func TestParseSimple(t *testing.T) {
	input := `[server]
host = localhost
port = 8080
tls
`
	cfg, err := ParseINI(input)
	if err != nil {
		t.Fatalf("Parse failed: %v", err)
	}

	if len(cfg.SectionOrder) != 1 || cfg.SectionOrder[0] != "server" {
		t.Errorf("Expected section 'server', got %v", cfg.SectionOrder)
	}

	sec := cfg.Sections["server"]
	if sec == nil {
		t.Fatal("Section 'server' not found")
	}

	if host, ok := cfg.Get("server", "host"); !ok || host != "localhost" {
		t.Errorf("host = %v, expected 'localhost'", host)
	}
	if port, ok := cfg.Get("server", "port"); !ok || port != int64(8080) {
		t.Errorf("port = %v, expected 8080", port)
	}
	if tls, ok := cfg.Get("server", "tls"); !ok || tls != true {
		t.Errorf("tls = %v, expected true", tls)
	}
}

func TestParseWithComments(t *testing.T) {
	input := `; Server config
[server]
host = localhost ; inline comment
port = 1111

; Database section
[database]
user = admin
password = "my secret"
timeout = 5.5
`
	cfg, err := ParseINI(input)
	if err != nil {
		t.Fatalf("Parse failed: %v", err)
	}

	serverSec := cfg.Sections["server"]
	if serverSec.LeadingComments[0] != "; Server config" {
		t.Errorf("Wrong leading comments: %v", serverSec.LeadingComments)
	}

	if hostKey := serverSec.Keys["host"]; hostKey.Comment != "inline comment" {
		t.Errorf("Inline comment mismatch: %q", hostKey.Comment)
	}

	// [database] 的 leading 应包含 "; Database section"
	dbSec := cfg.Sections["database"]
	if len(dbSec.LeadingComments) != 1 || dbSec.LeadingComments[0] != "; Database section" {
		t.Errorf("Database leading comment: %v", dbSec.LeadingComments)
	}
}

func TestRoundtrip(t *testing.T) {
	cases := []string{
		`[empty]`,
		`[test]
key`,
		`[test]
key = value`,
		`[test]
key = "quoted value"`,
		`[numbers]
int = 42
float = 3.14
bool = true`,
		`; comment
[section]
key = value ; inline
; trailing`,
	}

	for i, input := range cases {
		cfg, err := ParseINI(input)
		if err != nil {
			t.Fatalf("Case %d: Parse failed: %v", i, err)
		}

		var buf bytes.Buffer
		err = cfg.WriteTo(&buf)
		if err != nil {
			t.Fatalf("Case %d: Write failed: %v", i, err)
		}
		output := buf.String()

		// Parse again
		cfg2, err := ParseINI(output)
		if err != nil {
			t.Fatalf("Case %d: Re-parse failed:\nInput:\n%s\nOutput:\n%s\nError: %v", i, input, output, err)
		}

		// Compare structures (simplified)
		if !reflect.DeepEqual(cfg.SectionOrder, cfg2.SectionOrder) {
			t.Errorf("Case %d: SectionOrder mismatch", i)
		}
		for secName, sec1 := range cfg.Sections {
			sec2 := cfg2.Sections[secName]
			if sec2 == nil {
				t.Errorf("Case %d: Section %s missing in roundtrip", i, secName)
				continue
			}
			if !reflect.DeepEqual(sec1.KeyOrder, sec2.KeyOrder) {
				t.Errorf("Case %d: KeyOrder mismatch in %s", i, secName)
			}
			for k, v1 := range sec1.Keys {
				v2 := sec2.Keys[k]
				if v2 == nil || !reflect.DeepEqual(v1.Value, v2.Value) || v1.Comment != v2.Comment {
					t.Errorf("Case %d: Key %s mismatch: %v vs %v", i, k, v1, v2)
				}
			}
			if !reflect.DeepEqual(sec1.LeadingComments, sec2.LeadingComments) {
				t.Errorf("Case %d: LeadingComments mismatch in %s", i, secName)
			}
		}
	}
}

func TestGetSet(t *testing.T) {
	cfg := &IniConfig{
		Sections:     make(map[string]*Section),
		SectionOrder: nil,
	}

	cfg.Set("app", "debug", true)
	cfg.Set("app", "port", int64(3000))
	cfg.Set("db", "host", "localhost")

	if v, ok := cfg.Get("app", "debug"); !ok || v != true {
		t.Errorf("Get debug failed: %v", v)
	}
	if v, ok := cfg.Get("app", "port"); !ok || v != int64(3000) {
		t.Errorf("Get port failed: %v", v)
	}
	if v, ok := cfg.Get("db", "host"); !ok || v != "localhost" {
		t.Errorf("Get host failed: %v", v)
	}

	// Overwrite
	cfg.Set("app", "port", int64(5000))
	if v, ok := cfg.Get("app", "port"); !ok || v != int64(5000) {
		t.Errorf("Overwrite failed: %v", v)
	}

	// Non-existent
	if _, ok := cfg.Get("missing", "key"); ok {
		t.Error("Get non-existent key should fail")
	}
}

func TestQuotedValues(t *testing.T) {
	input := `[test]
empty = ""
space = "hello world"
quote = "say \"hi\""
special = "a;b#c"
`
	cfg, err := ParseINI(input)
	if err != nil {
		t.Fatalf("Parse failed: %v", err)
	}

	tests := map[string]string{
		"empty":   "",
		"space":   "hello world",
		"quote":   `say "hi"`,
		"special": "a;b#c",
	}

	for key, expected := range tests {
		if v, ok := cfg.Get("test", key); !ok || v != expected {
			t.Errorf("Key %s: expected %q, got %q", key, expected, v)
		}
	}
}

// ========================
// 性能测试
// ========================

const largeINI = `
; Generated config
[server]
host = localhost
port = 8080
tls = true
workers = 16
timeout = 30.5
log_level = info
; more settings
max_conns = 1000
buffer_size = 65536

[database]
host = db.example.com
port = 5432
user = admin
password = "secret123"
ssl = true
pool_size = 20

[cache]
type = redis
addr = 127.0.0.1:6379
ttl = 3600

[feature_flags]
new_ui = true
beta = false
experimental = true
`

func BenchmarkParse(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_, err := ParseINI(largeINI)
		if err != nil {
			b.Fatalf("Parse failed: %v", err)
		}
	}
}

func BenchmarkGetSet(b *testing.B) {
	cfg, _ := ParseINI(largeINI)
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		cfg.Get("server", "port")
		cfg.Set("server", "dynamic_key_"+fmt.Sprint(i%100), i)
	}
}

func BenchmarkWrite(b *testing.B) {
	cfg, _ := ParseINI(largeINI)
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		var buf bytes.Buffer
		_ = cfg.WriteTo(&buf)
	}
}

// ========================
// 辅助函数(用于测试)
// ========================

// 如果你在 ini.go 中没有暴露 Section/Key 结构,可加 getter,但为测试我们假设可访问
// 或者通过 Get + WriteTo 间接验证

func TestEmptyAndWhitespace(t *testing.T) {
	input := `

[  spaced  ]
  key  =  value  

`
	cfg, err := ParseINI(input)
	if err != nil {
		t.Fatalf("Parse failed: %v", err)
	}
	if v, ok := cfg.Get("spaced", "key"); !ok || v != "value" {
		t.Errorf("Expected 'value', got %v", v)
	}
}

func TestBooleanParsing(t *testing.T) {
	input := `[bools]
t1 = true
t2 = True
t3 = TRUE
f1 = false
f2 = False
f3 = FALSE
not_bool = trueish
`
	cfg, _ := ParseINI(input)

	tests := map[string]interface{}{
		"t1":       true,
		"t2":       true,
		"t3":       true,
		"f1":       false,
		"f2":       false,
		"f3":       false,
		"not_bool": "trueish",
	}

	for k, expected := range tests {
		if v, ok := cfg.Get("bools", k); !ok || v != expected {
			t.Errorf("Key %s: expected %v (%T), got %v (%T)", k, expected, expected, v, v)
		}
	}
}

写在最后

是否支持多行字符串? 是否允许重复键(如 PHP 的 key[])? 是否支持节继承(如 [dev:base])? 这些都可以作为后续扩展方向。INI 虽小,但做好并不容易。希望本文能为你提供一个扎实的起点。