Compare commits

...

7 Commits

Author SHA1 Message Date
bb3478eb47 ref 7 2025-03-03 07:47:41 -06:00
8258a967a2 ref 6 2025-03-03 07:12:15 -06:00
e7584879a3 ref 5 2025-03-02 20:36:52 -06:00
88c917210d ref 4 2025-03-02 06:05:58 -06:00
a531dedc5c ref 3 2025-03-02 05:58:12 -06:00
4091a45658 ref 2 2025-03-01 21:23:33 -06:00
3563c0f8ee ref 1 2025-03-01 21:10:04 -06:00
8 changed files with 2122 additions and 2 deletions

154
README.md
View File

@ -1,3 +1,153 @@
# Config
# Go Config Parser
A super-simple config file parser that has a Lua-like format.
A lightweight, intuitive configuration parser for Go applications with a clean, readable syntax.`
## Features
- Simple, human-readable configuration format
- Strong typing with automatic type conversion
- Nested structures with dot notation access
- Support for arrays and maps
- Inline and block comments
- Fast parsing with no dependencies
- Full test coverage
## Configuration Format
This parser uses a clean, minimal syntax that's easy to read and write:
```
host = "localhost"
port = 8080
debug = true
allowed_ips {
"192.168.1.1"
"192.168.1.2"
"10.0.0.1"
}
database {
host = "db.example.com"
port = 5432
credentials {
username = "admin"
password = "secure123"
}
}
-- This is a line comment
--[[ This is a
block comment spanning
multiple lines ]]
```
## Installation
```bash
go get git.sharkk.net/Go/Config
```
## Usage
### Basic Usage
```go
package main
import (
"fmt"
"os"
config "git.sharkk.net/Go/Config"
)
func main() {
// Load configuration from file
file, err := os.Open("config.conf")
if err != nil {
panic(err)
}
defer file.Close()
cfg, err := config.Load(file)
if err != nil {
panic(err)
}
// Access values with type conversion
host, err := cfg.GetString("database.host")
port, err := cfg.GetInt("database.port")
debug, err := cfg.GetBool("debug")
// Use default values for missing keys
timeout := cfg.GetOr("timeout", 30).(int64)
}
```
### Type Conversion
The parser automatically converts values to appropriate types:
```go
// These will return properly typed values
boolValue, err := cfg.GetBool("feature.enabled")
intValue, err := cfg.GetInt("server.port")
floatValue, err := cfg.GetFloat("threshold")
stringValue, err := cfg.GetString("app.name")
// For complex types
arrayValue, err := cfg.GetArray("allowed_ips")
mapValue, err := cfg.GetMap("database")
// Generic getter (returns interface{})
value, err := cfg.Get("some.key")
```
### Accessing Arrays and Maps
Access array elements and nested map values using dot notation:
```go
// Access the first element in the array
firstIP, err := cfg.GetString("allowed_ips.0")
// Access deeply nested values
username, err := cfg.GetString("database.credentials.username")
```
## Performance
This parser provides competitive performance compared to popular formats like JSON, YAML, and TOML:
| Benchmark | Operations | Time (ns/op) | Memory (B/op) | Allocations (allocs/op) |
|-----------|----------:|-------------:|--------------:|------------------------:|
| **Small Config Files** |
| Config | 718,893 | 1,611 | 5,256 | 25 |
| JSON | 1,000,000 | 1,170 | 1,384 | 23 |
| YAML | 213,438 | 5,668 | 8,888 | 82 |
| TOML | 273,586 | 4,505 | 4,520 | 67 |
| **Medium Config Files** |
| Config | 138,517 | 8,777 | 11,247 | 114 |
| JSON | 241,069 | 4,996 | 5,344 | 89 |
| YAML | 47,695 | 24,183 | 21,577 | 347 |
| TOML | 66,411 | 17,709 | 16,349 | 208 |
| **Large Config Files** |
| Config | 33,177 | 35,591 | 31,791 | 477 |
| JSON | 66,384 | 18,066 | 18,138 | 297 |
| YAML | 12,482 | 95,248 | 65,574 | 1,208 |
| TOML | 17,594 | 67,928 | 66,038 | 669 |
*Benchmarked on AMD Ryzen 9 7950X 16-Core Processor*
## Why Choose This Parser?
- **Readability**: Simple syntax that's easy for humans to read and write
- **Flexibility**: Supports various data types and nested structures
- **Performance**: Fast parsing with minimal overhead
- **Type Safety**: Strong typing with automatic conversion
- **Simplicity**: No external dependencies required
## License
MIT

617
bench/bench_test.go Normal file
View File

@ -0,0 +1,617 @@
package config
import (
"encoding/json"
"strconv"
"strings"
"testing"
config "git.sharkk.net/Go/Config"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v3"
)
// Original benchmarks
func BenchmarkSmallConfig(b *testing.B) {
// Small config with just a few key-value pairs
smallConfig := `
host = "localhost"
port = 8080
debug = true
timeout = 30
`
b.ResetTimer()
for i := 0; i < b.N; i++ {
reader := strings.NewReader(smallConfig)
_, err := config.Load(reader)
if err != nil {
b.Fatalf("Failed to parse small config: %v", err)
}
}
}
func BenchmarkMediumConfig(b *testing.B) {
// Medium config with nested structures and arrays
mediumConfig := `
app {
name = "TestApp"
version = "1.0.0"
enableLogging = true
}
database {
host = "db.example.com"
port = 5432
credentials {
username = "admin"
password = "secure123"
}
}
features = {
"authentication"
"authorization"
"reporting"
"analytics"
}
timeouts {
connect = 5
read = 10
write = 10
idle = 60
}
-- Comments to add some parsing overhead
endpoints {
api = "/api/v1"
web = "/web"
admin = "/admin"
health = "/health"
}
`
b.ResetTimer()
for i := 0; i < b.N; i++ {
reader := strings.NewReader(mediumConfig)
_, err := config.Load(reader)
if err != nil {
b.Fatalf("Failed to parse medium config: %v", err)
}
}
}
func BenchmarkLargeConfig(b *testing.B) {
// Simpler large config with careful bracket matching
largeConfig := `
application {
name = "EnterpriseApp"
version = "2.5.1"
environment = "production"
debug = false
maxConnections = 1000
timeout = 30
retryCount = 3
logLevel = "info"
}
-- Database cluster configuration
databases {
primary {
host = "primary-db.example.com"
port = 5432
maxConnections = 100
credentials {
username = "app_user"
password = "super_secret"
ssl = true
timeout = 5
}
}
replica {
host = "replica-db.example.com"
port = 5432
maxConnections = 200
credentials {
username = "read_user"
password = "read_only_pw"
ssl = true
}
}
}
allowedIPs {
"192.168.1.1"
"192.168.1.2"
"192.168.1.3"
"192.168.1.4"
"192.168.1.5"
}
-- Add 50 numbered settings to make the config large
settings {`
// Add many numbered settings
var builder strings.Builder
builder.WriteString(largeConfig)
for i := 1; i <= 50; i++ {
builder.WriteString("\n\t\tsetting")
builder.WriteString(strconv.Itoa(i))
builder.WriteString(" = ")
builder.WriteString(strconv.Itoa(i * 10))
}
// Close the settings block and add one more block
builder.WriteString(`
}
roles {
admin {
permissions = {
"read"
"write"
"delete"
"admin"
}
}
user {
permissions = {
"read"
"write"
}
}
}
`)
largeConfig = builder.String()
b.ResetTimer()
for i := 0; i < b.N; i++ {
reader := strings.NewReader(largeConfig)
_, err := config.Load(reader)
if err != nil {
b.Fatalf("Failed to parse large config: %v", err)
}
}
}
// JSON Benchmarks
func BenchmarkSmallConfigJSON(b *testing.B) {
smallConfigJSON := `{
"host": "localhost",
"port": 8080,
"debug": true,
"timeout": 30
}`
b.ResetTimer()
for i := 0; i < b.N; i++ {
reader := strings.NewReader(smallConfigJSON)
var result map[string]interface{}
decoder := json.NewDecoder(reader)
err := decoder.Decode(&result)
if err != nil {
b.Fatalf("Failed to parse small JSON config: %v", err)
}
}
}
func BenchmarkMediumConfigJSON(b *testing.B) {
mediumConfigJSON := `{
"app": {
"name": "TestApp",
"version": "1.0.0",
"enableLogging": true
},
"database": {
"host": "db.example.com",
"port": 5432,
"credentials": {
"username": "admin",
"password": "secure123"
}
},
"features": [
"authentication",
"authorization",
"reporting",
"analytics"
],
"timeouts": {
"connect": 5,
"read": 10,
"write": 10,
"idle": 60
},
"endpoints": {
"api": "/api/v1",
"web": "/web",
"admin": "/admin",
"health": "/health"
}
}`
b.ResetTimer()
for i := 0; i < b.N; i++ {
reader := strings.NewReader(mediumConfigJSON)
var result map[string]interface{}
decoder := json.NewDecoder(reader)
err := decoder.Decode(&result)
if err != nil {
b.Fatalf("Failed to parse medium JSON config: %v", err)
}
}
}
func BenchmarkLargeConfigJSON(b *testing.B) {
// Start building the large JSON config
largeConfigJSON := `{
"application": {
"name": "EnterpriseApp",
"version": "2.5.1",
"environment": "production",
"debug": false,
"maxConnections": 1000,
"timeout": 30,
"retryCount": 3,
"logLevel": "info"
},
"databases": {
"primary": {
"host": "primary-db.example.com",
"port": 5432,
"maxConnections": 100,
"credentials": {
"username": "app_user",
"password": "super_secret",
"ssl": true,
"timeout": 5
}
},
"replica": {
"host": "replica-db.example.com",
"port": 5432,
"maxConnections": 200,
"credentials": {
"username": "read_user",
"password": "read_only_pw",
"ssl": true
}
}
},
"allowedIPs": [
"192.168.1.1",
"192.168.1.2",
"192.168.1.3",
"192.168.1.4",
"192.168.1.5"
],
"settings": {`
var builder strings.Builder
builder.WriteString(largeConfigJSON)
// Add many numbered settings
for i := 1; i <= 50; i++ {
if i > 1 {
builder.WriteString(",")
}
builder.WriteString("\n\t\t\t\"setting")
builder.WriteString(strconv.Itoa(i))
builder.WriteString("\": ")
builder.WriteString(strconv.Itoa(i * 10))
}
// Close the settings block and add roles
builder.WriteString(`
},
"roles": {
"admin": {
"permissions": [
"read",
"write",
"delete",
"admin"
]
},
"user": {
"permissions": [
"read",
"write"
]
}
}
}`)
largeConfigJSONString := builder.String()
b.ResetTimer()
for i := 0; i < b.N; i++ {
reader := strings.NewReader(largeConfigJSONString)
var result map[string]interface{}
decoder := json.NewDecoder(reader)
err := decoder.Decode(&result)
if err != nil {
b.Fatalf("Failed to parse large JSON config: %v", err)
}
}
}
// YAML Benchmarks
func BenchmarkSmallConfigYAML(b *testing.B) {
smallConfigYAML := `
host: localhost
port: 8080
debug: true
timeout: 30
`
b.ResetTimer()
for i := 0; i < b.N; i++ {
var result map[string]interface{}
err := yaml.Unmarshal([]byte(smallConfigYAML), &result)
if err != nil {
b.Fatalf("Failed to parse small YAML config: %v", err)
}
}
}
func BenchmarkMediumConfigYAML(b *testing.B) {
mediumConfigYAML := `
app:
name: TestApp
version: 1.0.0
enableLogging: true
database:
host: db.example.com
port: 5432
credentials:
username: admin
password: secure123
features:
- authentication
- authorization
- reporting
- analytics
timeouts:
connect: 5
read: 10
write: 10
idle: 60
# Comments to add some parsing overhead
endpoints:
api: /api/v1
web: /web
admin: /admin
health: /health
`
b.ResetTimer()
for i := 0; i < b.N; i++ {
var result map[string]interface{}
err := yaml.Unmarshal([]byte(mediumConfigYAML), &result)
if err != nil {
b.Fatalf("Failed to parse medium YAML config: %v", err)
}
}
}
func BenchmarkLargeConfigYAML(b *testing.B) {
// Start building the large YAML config
largeConfigYAML := `
application:
name: EnterpriseApp
version: 2.5.1
environment: production
debug: false
maxConnections: 1000
timeout: 30
retryCount: 3
logLevel: info
# Database cluster configuration
databases:
primary:
host: primary-db.example.com
port: 5432
maxConnections: 100
credentials:
username: app_user
password: super_secret
ssl: true
timeout: 5
replica:
host: replica-db.example.com
port: 5432
maxConnections: 200
credentials:
username: read_user
password: read_only_pw
ssl: true
allowedIPs:
- 192.168.1.1
- 192.168.1.2
- 192.168.1.3
- 192.168.1.4
- 192.168.1.5
# Add 50 numbered settings to make the config large
settings:
`
var builder strings.Builder
builder.WriteString(largeConfigYAML)
// Add many numbered settings
for i := 1; i <= 50; i++ {
builder.WriteString(" setting")
builder.WriteString(strconv.Itoa(i))
builder.WriteString(": ")
builder.WriteString(strconv.Itoa(i * 10))
builder.WriteString("\n")
}
// Add roles
builder.WriteString(`
roles:
admin:
permissions:
- read
- write
- delete
- admin
user:
permissions:
- read
- write
`)
largeConfigYAMLString := builder.String()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var result map[string]interface{}
err := yaml.Unmarshal([]byte(largeConfigYAMLString), &result)
if err != nil {
b.Fatalf("Failed to parse large YAML config: %v", err)
}
}
}
// TOML Benchmarks
func BenchmarkSmallConfigTOML(b *testing.B) {
smallConfigTOML := `
host = "localhost"
port = 8080
debug = true
timeout = 30
`
b.ResetTimer()
for i := 0; i < b.N; i++ {
var result map[string]interface{}
err := toml.Unmarshal([]byte(smallConfigTOML), &result)
if err != nil {
b.Fatalf("Failed to parse small TOML config: %v", err)
}
}
}
func BenchmarkMediumConfigTOML(b *testing.B) {
mediumConfigTOML := `
[app]
name = "TestApp"
version = "1.0.0"
enableLogging = true
[database]
host = "db.example.com"
port = 5432
[database.credentials]
username = "admin"
password = "secure123"
features = ["authentication", "authorization", "reporting", "analytics"]
[timeouts]
connect = 5
read = 10
write = 10
idle = 60
# Comments to add some parsing overhead
[endpoints]
api = "/api/v1"
web = "/web"
admin = "/admin"
health = "/health"
`
b.ResetTimer()
for i := 0; i < b.N; i++ {
var result map[string]interface{}
err := toml.Unmarshal([]byte(mediumConfigTOML), &result)
if err != nil {
b.Fatalf("Failed to parse medium TOML config: %v", err)
}
}
}
func BenchmarkLargeConfigTOML(b *testing.B) {
// Start building the large TOML config
largeConfigTOML := `
[application]
name = "EnterpriseApp"
version = "2.5.1"
environment = "production"
debug = false
maxConnections = 1000
timeout = 30
retryCount = 3
logLevel = "info"
# Database cluster configuration
[databases.primary]
host = "primary-db.example.com"
port = 5432
maxConnections = 100
[databases.primary.credentials]
username = "app_user"
password = "super_secret"
ssl = true
timeout = 5
[databases.replica]
host = "replica-db.example.com"
port = 5432
maxConnections = 200
[databases.replica.credentials]
username = "read_user"
password = "read_only_pw"
ssl = true
allowedIPs = ["192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4", "192.168.1.5"]
# Add 50 numbered settings to make the config large
[settings]
`
var builder strings.Builder
builder.WriteString(largeConfigTOML)
// Add many numbered settings
for i := 1; i <= 50; i++ {
builder.WriteString("setting")
builder.WriteString(strconv.Itoa(i))
builder.WriteString(" = ")
builder.WriteString(strconv.Itoa(i * 10))
builder.WriteString("\n")
}
// Add roles
builder.WriteString(`
[roles.admin]
permissions = ["read", "write", "delete", "admin"]
[roles.user]
permissions = ["read", "write"]
`)
largeConfigTOMLString := builder.String()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var result map[string]interface{}
err := toml.Unmarshal([]byte(largeConfigTOMLString), &result)
if err != nil {
b.Fatalf("Failed to parse large TOML config: %v", err)
}
}
}

595
config.go Normal file
View File

@ -0,0 +1,595 @@
package config
import (
"fmt"
"io"
"strconv"
)
// ParseState represents a parsing level state
type ParseState struct {
object map[string]any
arrayElements []any
isArray bool
currentKey string
expectValue bool
}
// Config holds a single hierarchical structure like JSON and handles parsing
type Config struct {
data map[string]any
scanner *Scanner
currentObject map[string]any
stack []map[string]any
currentToken Token
}
// NewConfig creates a new empty config
func NewConfig() *Config {
cfg := &Config{
data: make(map[string]any, 16), // Pre-allocate with expected capacity
stack: make([]map[string]any, 0, 8),
}
cfg.currentObject = cfg.data
return cfg
}
// Get retrieves a value from the config using dot notation
func (c *Config) Get(key string) (any, error) {
if key == "" {
return c.data, nil
}
// Parse the dot-notation path manually
var start, i int
var current any = c.data
for i = 0; i < len(key); i++ {
if key[i] == '.' || i == len(key)-1 {
end := i
if i == len(key)-1 && key[i] != '.' {
end = i + 1
}
part := key[start:end]
// Handle current node based on its type
switch node := current.(type) {
case map[string]any:
// Simple map lookup
val, ok := node[part]
if !ok {
return nil, fmt.Errorf("key %s not found", part)
}
current = val
case []any:
// Must be numeric index
index, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid array index: %s", part)
}
if index < 0 || index >= len(node) {
return nil, fmt.Errorf("array index out of bounds: %d", index)
}
current = node[index]
default:
return nil, fmt.Errorf("cannot access %s in non-container value", part)
}
// If we've processed the entire key, return the current value
if i == len(key)-1 || (i < len(key)-1 && key[i] == '.' && end == i) {
if i == len(key)-1 {
return current, nil
}
}
start = i + 1
}
}
return current, nil
}
// GetOr retrieves a value or returns a default if not found
func (c *Config) GetOr(key string, defaultValue any) any {
val, err := c.Get(key)
if err != nil {
return defaultValue
}
return val
}
// GetString gets a value as string
func (c *Config) GetString(key string) (string, error) {
val, err := c.Get(key)
if err != nil {
return "", err
}
switch v := val.(type) {
case string:
return v, nil
case bool:
return strconv.FormatBool(v), nil
case int64:
return strconv.FormatInt(v, 10), nil
case float64:
return strconv.FormatFloat(v, 'f', -1, 64), nil
default:
return "", fmt.Errorf("value for key %s cannot be converted to string", key)
}
}
// GetBool gets a value as boolean
func (c *Config) GetBool(key string) (bool, error) {
val, err := c.Get(key)
if err != nil {
return false, err
}
switch v := val.(type) {
case bool:
return v, nil
case string:
return strconv.ParseBool(v)
default:
return false, fmt.Errorf("value for key %s cannot be converted to bool", key)
}
}
// GetInt gets a value as int64
func (c *Config) GetInt(key string) (int64, error) {
val, err := c.Get(key)
if err != nil {
return 0, err
}
switch v := val.(type) {
case int64:
return v, nil
case float64:
return int64(v), nil
case string:
return strconv.ParseInt(v, 10, 64)
default:
return 0, fmt.Errorf("value for key %s cannot be converted to int", key)
}
}
// GetFloat gets a value as float64
func (c *Config) GetFloat(key string) (float64, error) {
val, err := c.Get(key)
if err != nil {
return 0, err
}
switch v := val.(type) {
case float64:
return v, nil
case int64:
return float64(v), nil
case string:
return strconv.ParseFloat(v, 64)
default:
return 0, fmt.Errorf("value for key %s cannot be converted to float", key)
}
}
// GetArray gets a value as []any
func (c *Config) GetArray(key string) ([]any, error) {
val, err := c.Get(key)
if err != nil {
return nil, err
}
if arr, ok := val.([]any); ok {
return arr, nil
}
return nil, fmt.Errorf("value for key %s is not an array", key)
}
// GetMap gets a value as map[string]any
func (c *Config) GetMap(key string) (map[string]any, error) {
val, err := c.Get(key)
if err != nil {
return nil, err
}
if m, ok := val.(map[string]any); ok {
return m, nil
}
return nil, fmt.Errorf("value for key %s is not a map", key)
}
// --- Parser Methods (integrated into Config) ---
// Error creates an error with line information from the current token
func (c *Config) Error(msg string) error {
return fmt.Errorf("line %d, column %d: %s",
c.currentToken.Line, c.currentToken.Column, msg)
}
// Parse parses the config from a reader
func (c *Config) Parse(r io.Reader) error {
c.scanner = NewScanner(r)
c.currentObject = c.data
err := c.parseContent()
// Clean up scanner resources even on success
if c.scanner != nil {
ReleaseScanner(c.scanner)
c.scanner = nil
}
return err
}
// nextToken gets the next meaningful token (skipping comments)
func (c *Config) nextToken() (Token, error) {
for {
token, err := c.scanner.NextToken()
if err != nil {
return token, err
}
// Skip comment tokens
if token.Type != TokenComment {
c.currentToken = token
return token, nil
}
}
}
// parseContent is the main parsing function
func (c *Config) parseContent() error {
for {
token, err := c.nextToken()
if err != nil {
return err
}
// Check for end of file
if token.Type == TokenEOF {
break
}
// We expect top level entries to be names
if token.Type != TokenName {
return c.Error("expected name at top level")
}
// Get the property name - copy to create a stable key
nameBytes := token.Value
name := string(nameBytes)
// Get the next token (should be = or {)
token, err = c.nextToken()
if err != nil {
return err
}
var value any
if token.Type == TokenEquals {
// It's a standard key=value assignment
value, err = c.parseValue()
if err != nil {
return err
}
} else if token.Type == TokenOpenBrace {
// It's a map/array without '='
value, err = c.parseObject()
if err != nil {
return err
}
} else {
return c.Error("expected '=' or '{' after name")
}
// Store the value in the config
if mapValue, ok := value.(map[string]any); ok {
// Add an entry in current object
newMap := make(map[string]any, 8) // Pre-allocate with capacity
c.currentObject[name] = newMap
// Process the map contents
c.stack = append(c.stack, c.currentObject)
c.currentObject = newMap
// Copy values from scanned map to our object
for k, v := range mapValue {
c.currentObject[k] = v
}
// Restore parent object
n := len(c.stack)
if n > 0 {
c.currentObject = c.stack[n-1]
c.stack = c.stack[:n-1]
}
} else {
// Direct storage for primitives and arrays
c.currentObject[name] = value
}
}
return nil
}
// parseValue parses a value after an equals sign
func (c *Config) parseValue() (any, error) {
token, err := c.nextToken()
if err != nil {
return nil, err
}
switch token.Type {
case TokenString:
// Copy the value for string stability
return string(token.Value), nil
case TokenNumber:
strValue := string(token.Value)
for i := 0; i < len(strValue); i++ {
if strValue[i] == '.' {
// It's a float
val, err := strconv.ParseFloat(strValue, 64)
if err != nil {
return nil, c.Error(fmt.Sprintf("invalid float: %s", strValue))
}
return val, nil
}
}
// It's an integer
val, err := strconv.ParseInt(strValue, 10, 64)
if err != nil {
return nil, c.Error(fmt.Sprintf("invalid integer: %s", strValue))
}
return val, nil
case TokenBoolean:
return bytesEqual(token.Value, []byte("true")), nil
case TokenOpenBrace:
// It's a map or array
return c.parseObject()
case TokenName:
// Treat as a string value - copy to create a stable string
return string(token.Value), nil
default:
return nil, c.Error(fmt.Sprintf("unexpected token: %v", token.Type))
}
}
// parseObject parses a map or array
func (c *Config) parseObject() (any, error) {
// Initialize stack with first state
stack := []*ParseState{{
object: make(map[string]any, 8),
arrayElements: make([]any, 0, 8),
isArray: true,
}}
for len(stack) > 0 {
// Get current state from top of stack
current := stack[len(stack)-1]
token, err := c.nextToken()
if err != nil {
return nil, err
}
// Handle closing brace - finish current object/array
if token.Type == TokenCloseBrace {
// Determine result based on what we've collected
var result any
if current.isArray && len(current.object) == 0 {
result = current.arrayElements
} else {
result = current.object
}
// Pop the stack
stack = stack[:len(stack)-1]
// If stack is empty, we're done with the root object
if len(stack) == 0 {
return result, nil
}
// Otherwise, add result to parent
parent := stack[len(stack)-1]
if parent.expectValue {
parent.object[parent.currentKey] = result
parent.expectValue = false
} else {
parent.arrayElements = append(parent.arrayElements, result)
}
continue
}
// Handle tokens based on type
switch token.Type {
case TokenName:
name := string(token.Value)
// Look ahead to determine context
nextToken, err := c.nextToken()
if err != nil {
return nil, err
}
if nextToken.Type == TokenEquals {
// Key-value pair
current.isArray = false
current.currentKey = name
current.expectValue = true
// Parse the value
valueToken, err := c.nextToken()
if err != nil {
return nil, err
}
if valueToken.Type == TokenOpenBrace {
// Push new state for nested object/array
newState := &ParseState{
object: make(map[string]any, 8),
arrayElements: make([]any, 0, 8),
isArray: true,
}
stack = append(stack, newState)
} else {
// Handle primitive value
value := c.tokenToValue(valueToken)
current.object[name] = value
}
} else if nextToken.Type == TokenOpenBrace {
// Nested object with name
current.isArray = false
current.currentKey = name
current.expectValue = true
// Push new state for nested object
newState := &ParseState{
object: make(map[string]any, 8),
arrayElements: make([]any, 0, 8),
isArray: true,
}
stack = append(stack, newState)
} else {
// Array element
c.scanner.UnreadToken(nextToken)
// Convert name to appropriate type
value := c.convertNameValue(name)
current.arrayElements = append(current.arrayElements, value)
}
case TokenString, TokenNumber, TokenBoolean:
value := c.tokenToValue(token)
if current.expectValue {
current.object[current.currentKey] = value
current.expectValue = false
} else {
current.arrayElements = append(current.arrayElements, value)
}
case TokenOpenBrace:
// New nested object/array
newState := &ParseState{
object: make(map[string]any, 8),
arrayElements: make([]any, 0, 8),
isArray: true,
}
stack = append(stack, newState)
default:
return nil, c.Error(fmt.Sprintf("unexpected token: %v", token.Type))
}
}
return nil, fmt.Errorf("unexpected end of parsing")
}
// Load parses a config from a reader
func Load(r io.Reader) (*Config, error) {
config := NewConfig()
err := config.Parse(r)
if err != nil {
return nil, err
}
return config, nil
}
// Helpers
func isLetter(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}
func isDigit(b byte) bool {
return b >= '0' && b <= '9'
}
// ParseNumber converts a string to a number (int64 or float64)
func ParseNumber(s string) (any, error) {
// Check if it has a decimal point
for i := 0; i < len(s); i++ {
if s[i] == '.' {
// It's a float
return strconv.ParseFloat(s, 64)
}
}
// It's an integer
return strconv.ParseInt(s, 10, 64)
}
// bytesEqual compares a byte slice with either a string or byte slice
func bytesEqual(b []byte, s []byte) bool {
if len(b) != len(s) {
return false
}
for i := 0; i < len(b); i++ {
if b[i] != s[i] {
return false
}
}
return true
}
// isDigitOrMinus checks if a string starts with a digit or minus sign
func isDigitOrMinus(s string) bool {
if len(s) == 0 {
return false
}
return isDigit(s[0]) || (s[0] == '-' && len(s) > 1 && isDigit(s[1]))
}
// parseStringAsNumber tries to parse a string as a number (float or int)
func parseStringAsNumber(s string) (any, error) {
// Check if it has a decimal point
for i := 0; i < len(s); i++ {
if s[i] == '.' {
// It's a float
return strconv.ParseFloat(s, 64)
}
}
// It's an integer
return strconv.ParseInt(s, 10, 64)
}
func (c *Config) tokenToValue(token Token) any {
switch token.Type {
case TokenString:
return string(token.Value)
case TokenNumber:
val, _ := parseStringAsNumber(string(token.Value))
return val
case TokenBoolean:
return bytesEqual(token.Value, []byte("true"))
default:
return string(token.Value)
}
}
func (c *Config) convertNameValue(name string) any {
if name == "true" {
return true
} else if name == "false" {
return false
} else if isDigitOrMinus(name) {
val, err := parseStringAsNumber(name)
if err == nil {
return val
}
}
return name
}

8
go.mod Normal file
View File

@ -0,0 +1,8 @@
module git.sharkk.net/Go/Config
go 1.23.5
require (
github.com/BurntSushi/toml v1.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

5
go.sum Normal file
View File

@ -0,0 +1,5 @@
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

404
scanner.go Normal file
View File

@ -0,0 +1,404 @@
package config
import (
"bufio"
"errors"
"fmt"
"io"
"sync"
)
// Pre-declared errors to reduce allocations
var (
ErrUnterminatedString = errors.New("unterminated string")
ErrUnterminatedEscape = errors.New("unterminated escape sequence")
ErrUnterminatedComment = errors.New("unclosed block comment")
ErrInvalidComment = errors.New("invalid comment")
ErrNameStartWithLetter = errors.New("name must start with letter")
)
// Scanner handles the low-level parsing of the configuration format
type Scanner struct {
reader *bufio.Reader
line int
col int
buffer []byte
token Token // Current token for unread
}
// scannerPool helps reuse scanner objects
var scannerPool = sync.Pool{
New: func() interface{} {
return &Scanner{
line: 1,
col: 0,
buffer: make([]byte, 0, 128),
}
},
}
// NewScanner creates a new scanner from a pool
func NewScanner(r io.Reader) *Scanner {
s := scannerPool.Get().(*Scanner)
s.reader = bufio.NewReader(r)
s.line = 1
s.col = 0
s.buffer = s.buffer[:0]
s.token = Token{Type: TokenError}
return s
}
// ReleaseScanner returns a scanner to the pool
func ReleaseScanner(s *Scanner) {
if s != nil {
// Clear references but keep allocated memory
s.reader = nil
s.buffer = s.buffer[:0]
scannerPool.Put(s)
}
}
// ReadByte reads a single byte from the input
func (s *Scanner) ReadByte() (byte, error) {
b, err := s.reader.ReadByte()
if err == nil {
if b == '\n' {
s.line++
s.col = 0
} else {
s.col++
}
}
return b, err
}
// PeekByte looks at the next byte without consuming it
func (s *Scanner) PeekByte() (byte, error) {
b, err := s.reader.Peek(1)
if err != nil {
return 0, err
}
return b[0], nil
}
// PeekBytes looks at the next n bytes without consuming them
func (s *Scanner) PeekBytes(n int) ([]byte, error) {
return s.reader.Peek(n)
}
// UnreadByte pushes back a byte to the reader
func (s *Scanner) UnreadByte() error {
err := s.reader.UnreadByte()
if err == nil && s.col > 0 {
s.col--
}
return err
}
// Error creates an error with line and column information
func (s *Scanner) Error(msg string) error {
return fmt.Errorf("line %d, column %d: %s", s.line, s.col, msg)
}
// SkipWhitespace skips whitespace characters
func (s *Scanner) SkipWhitespace() error {
for {
b, err := s.PeekByte()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// Fast check for common whitespace bytes
if b != ' ' && b != '\t' && b != '\n' && b != '\r' {
return nil
}
_, err = s.ReadByte()
if err != nil {
return err
}
}
}
// UnreadToken stores a token to be returned by the next call to NextToken
func (s *Scanner) UnreadToken(token Token) {
s.token = token
}
// NextToken scans and returns the next token
func (s *Scanner) NextToken() (Token, error) {
if s.token.Type != TokenError {
// We have a stored token
token := s.token
s.token = Token{Type: TokenError} // Reset
return token, nil
}
// Skip whitespace
err := s.SkipWhitespace()
if err == io.EOF {
return Token{Type: TokenEOF}, nil
}
if err != nil {
return Token{Type: TokenError, Value: []byte(err.Error())}, err
}
b, err := s.PeekByte()
if err != nil {
if err == io.EOF {
return Token{Type: TokenEOF}, nil
}
return Token{Type: TokenError, Value: []byte(err.Error())}, err
}
// Record start position for error reporting
startLine, startColumn := s.line, s.col
// Process based on first character
switch {
case b == '=':
_, _ = s.ReadByte() // consume equals
return Token{Type: TokenEquals, Line: startLine, Column: startColumn}, nil
case b == '{':
_, _ = s.ReadByte() // consume open brace
return Token{Type: TokenOpenBrace, Line: startLine, Column: startColumn}, nil
case b == '}':
_, _ = s.ReadByte() // consume close brace
return Token{Type: TokenCloseBrace, Line: startLine, Column: startColumn}, nil
case b == '-':
// Could be a comment or a negative number
peekBytes, err := s.PeekBytes(2)
if err == nil && len(peekBytes) == 2 && peekBytes[1] == '-' {
err = s.scanComment()
if err != nil {
return Token{Type: TokenError, Value: []byte(err.Error())}, err
}
return Token{Type: TokenComment, Line: startLine, Column: startColumn}, nil
}
// Check if it's a negative number
if err == nil && len(peekBytes) == 2 && isDigit(peekBytes[1]) {
return s.scanNumber(startLine, startColumn)
}
// Just a single dash
_, _ = s.ReadByte() // consume dash
return Token{Type: TokenError, Value: []byte("unexpected '-'")},
s.Error("unexpected '-'")
case b == '"':
return s.scanString(startLine, startColumn)
case isLetter(b):
return s.scanName(startLine, startColumn)
case isDigit(b):
return s.scanNumber(startLine, startColumn)
default:
_, _ = s.ReadByte() // consume the unexpected character
return Token{Type: TokenError, Value: []byte(fmt.Sprintf("unexpected character: %c", b)), Line: startLine, Column: startColumn},
s.Error(fmt.Sprintf("unexpected character: %c", b))
}
}
// scanComment processes a comment
func (s *Scanner) scanComment() error {
// Consume the first dash
_, err := s.ReadByte()
if err != nil {
return err
}
// Check for second dash
b, err := s.ReadByte()
if err != nil {
return err
}
if b != '-' {
return ErrInvalidComment
}
// Check for block comment [[
if b1, err := s.PeekByte(); err == nil && b1 == '[' {
_, _ = s.ReadByte() // consume first [
if b2, err := s.PeekByte(); err == nil && b2 == '[' {
_, _ = s.ReadByte() // consume second [
// Process block comment
for {
b, err := s.ReadByte()
if err != nil {
return ErrUnterminatedComment
}
if b == ']' {
if n, err := s.PeekByte(); err == nil && n == ']' {
_, _ = s.ReadByte() // consume second ]
return nil
}
}
}
}
}
// Line comment - consume until newline or EOF
for {
b, err := s.ReadByte()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if b == '\n' {
return nil
}
}
}
// scanString scans a quoted string
func (s *Scanner) scanString(startLine, startColumn int) (Token, error) {
// Reset buffer
s.buffer = s.buffer[:0]
// Consume opening quote
_, err := s.ReadByte()
if err != nil {
return Token{Type: TokenError, Value: []byte(err.Error())}, err
}
for {
b, err := s.ReadByte()
if err != nil {
return Token{Type: TokenError, Value: []byte(ErrUnterminatedString.Error())}, ErrUnterminatedString
}
if b == '"' {
break
}
// Handle escape sequences
if b == '\\' {
escaped, err := s.ReadByte()
if err != nil {
return Token{Type: TokenError, Value: []byte(ErrUnterminatedEscape.Error())}, ErrUnterminatedEscape
}
switch escaped {
case '"':
s.buffer = append(s.buffer, '"')
case '\\':
s.buffer = append(s.buffer, '\\')
case 'n':
s.buffer = append(s.buffer, '\n')
case 't':
s.buffer = append(s.buffer, '\t')
default:
s.buffer = append(s.buffer, '\\', escaped)
}
} else {
s.buffer = append(s.buffer, b)
}
}
// Return token with buffer value - important: consumer must copy if needed
return Token{
Type: TokenString,
Value: s.buffer,
Line: startLine,
Column: startColumn,
}, nil
}
// scanName scans an identifier
func (s *Scanner) scanName(startLine, startColumn int) (Token, error) {
// Reset buffer
s.buffer = s.buffer[:0]
// Read first character
b, err := s.ReadByte()
if err != nil {
return Token{Type: TokenError, Value: []byte(err.Error())}, err
}
if !isLetter(b) {
return Token{Type: TokenError, Value: []byte(ErrNameStartWithLetter.Error())}, ErrNameStartWithLetter
}
s.buffer = append(s.buffer, b)
// Read rest of name
for {
b, err := s.PeekByte()
if err == io.EOF {
break
}
if err != nil {
return Token{Type: TokenError, Value: []byte(err.Error())}, err
}
if !isLetter(b) && !isDigit(b) && b != '_' {
break
}
s.buffer = append(s.buffer, b)
_, _ = s.ReadByte()
}
// Check if it's a boolean - use direct byte comparison
tokenType := TokenName
if bytesEqual(s.buffer, []byte("true")) || bytesEqual(s.buffer, []byte("false")) {
tokenType = TokenBoolean
}
return Token{
Type: tokenType,
Value: s.buffer, // Direct buffer reference - consumer must copy!
Line: startLine,
Column: startColumn,
}, nil
}
// scanNumber scans a numeric value
func (s *Scanner) scanNumber(startLine, startColumn int) (Token, error) {
// Reset buffer
s.buffer = s.buffer[:0]
// Read first character (might be a minus sign or digit)
b, err := s.ReadByte()
if err != nil {
return Token{Type: TokenError, Value: []byte(err.Error())}, err
}
s.buffer = append(s.buffer, b)
// Scan the rest of the number
hasDot := false
for {
b, err := s.PeekByte()
if err != nil {
if err == io.EOF {
break
}
return Token{Type: TokenError, Value: []byte(err.Error())}, err
}
if b == '.' && !hasDot {
hasDot = true
_, _ = s.ReadByte()
s.buffer = append(s.buffer, b)
} else if isDigit(b) {
_, _ = s.ReadByte()
s.buffer = append(s.buffer, b)
} else {
break
}
}
return Token{
Type: TokenNumber,
Value: s.buffer, // Direct buffer reference - consumer must copy!
Line: startLine,
Column: startColumn,
}, nil
}

316
tests/config_test.go Normal file
View File

@ -0,0 +1,316 @@
package config_test
import (
"reflect"
"strings"
"testing"
config "git.sharkk.net/Go/Config"
)
func TestBasicKeyValuePairs(t *testing.T) {
input := `
boolTrue = true
boolFalse = false
integer = 42
negativeInt = -10
floatValue = 3.14
negativeFloat = -2.5
stringValue = "hello world"
`
config, err := config.Load(strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify boolean values
boolTrue, err := config.GetBool("boolTrue")
if err != nil || boolTrue != true {
t.Errorf("expected boolTrue=true, got %v, err: %v", boolTrue, err)
}
boolFalse, err := config.GetBool("boolFalse")
if err != nil || boolFalse != false {
t.Errorf("expected boolFalse=false, got %v, err: %v", boolFalse, err)
}
// Verify integer values
integer, err := config.GetInt("integer")
if err != nil || integer != 42 {
t.Errorf("expected integer=42, got %v, err: %v", integer, err)
}
negativeInt, err := config.GetInt("negativeInt")
if err != nil || negativeInt != -10 {
t.Errorf("expected negativeInt=-10, got %v, err: %v", negativeInt, err)
}
// Verify float values
floatValue, err := config.GetFloat("floatValue")
if err != nil || floatValue != 3.14 {
t.Errorf("expected floatValue=3.14, got %v, err: %v", floatValue, err)
}
negativeFloat, err := config.GetFloat("negativeFloat")
if err != nil || negativeFloat != -2.5 {
t.Errorf("expected negativeFloat=-2.5, got %v, err: %v", negativeFloat, err)
}
// Verify string values
stringValue, err := config.GetString("stringValue")
if err != nil || stringValue != "hello world" {
t.Errorf("expected stringValue=\"hello world\", got %v, err: %v", stringValue, err)
}
// Verify missing key handling
_, err = config.GetString("nonExistentKey")
if err == nil {
t.Errorf("expected error for non-existent key, got nil")
}
// Test GetOr with defaults
defaultBool := config.GetOr("nonExistentKey", true).(bool)
if defaultBool != true {
t.Errorf("expected GetOr to return default true, got %v", defaultBool)
}
defaultString := config.GetOr("nonExistentKey", "default").(string)
if defaultString != "default" {
t.Errorf("expected GetOr to return 'default', got %v", defaultString)
}
}
func TestComments(t *testing.T) {
input := `
-- This is a line comment
key1 = "value1"
--[[ This is a
block comment spanning
multiple lines ]]
key2 = "value2"
settings {
-- Comment inside a map
timeout = 30
--[[ Another block comment ]]
retries = 3
}
`
config, err := config.Load(strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify that comments are properly ignored and values are parsed
val1, err := config.GetString("key1")
if err != nil || val1 != "value1" {
t.Errorf("expected key1=\"value1\", got %v, err: %v", val1, err)
}
val2, err := config.GetString("key2")
if err != nil || val2 != "value2" {
t.Errorf("expected key2=\"value2\", got %v, err: %v", val2, err)
}
// Verify nested values after comments
timeout, err := config.GetInt("settings.timeout")
if err != nil || timeout != 30 {
t.Errorf("expected settings.timeout=30, got %v, err: %v", timeout, err)
}
retries, err := config.GetInt("settings.retries")
if err != nil || retries != 3 {
t.Errorf("expected settings.retries=3, got %v, err: %v", retries, err)
}
}
func TestArrays(t *testing.T) {
input := `
-- Simple array
fruits {
"apple"
"banana"
"cherry"
}
-- Array with equals sign
numbers = {
1
2
3
4
5
}
-- Mixed types array
mixed {
"string"
42
true
3.14
}
`
config, err := config.Load(strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify simple array
fruits, err := config.GetArray("fruits")
if err != nil {
t.Fatalf("failed to get fruits array: %v", err)
}
expectedFruits := []any{"apple", "banana", "cherry"}
if !reflect.DeepEqual(fruits, expectedFruits) {
t.Errorf("expected fruits=%v, got %v", expectedFruits, fruits)
}
// Verify array accessed with index notation
apple, err := config.GetString("fruits.0")
if err != nil || apple != "apple" {
t.Errorf("expected fruits.0=\"apple\", got %v, err: %v", apple, err)
}
// Verify array with equals sign
numbers, err := config.GetArray("numbers")
if err != nil {
t.Fatalf("failed to get numbers array: %v", err)
}
// Check array length
if len(numbers) != 5 {
t.Errorf("expected 5 numbers, got %d", len(numbers))
}
// Verify first number
firstNumber, err := config.GetInt("numbers.0")
if err != nil || firstNumber != 1 {
t.Errorf("expected numbers.0=1, got %v, err: %v", firstNumber, err)
}
// Verify mixed types array
mixed, err := config.GetArray("mixed")
if err != nil {
t.Fatalf("failed to get mixed array: %v", err)
}
if len(mixed) != 4 {
t.Errorf("expected 4 items in mixed array, got %d", len(mixed))
}
// Check types in mixed array
stringVal, err := config.GetString("mixed.0")
if err != nil || stringVal != "string" {
t.Errorf("expected mixed.0=\"string\", got %v, err: %v", stringVal, err)
}
intVal, err := config.GetInt("mixed.1")
if err != nil || intVal != 42 {
t.Errorf("expected mixed.1=42, got %v, err: %v", intVal, err)
}
boolVal, err := config.GetBool("mixed.2")
if err != nil || boolVal != true {
t.Errorf("expected mixed.2=true, got %v, err: %v", boolVal, err)
}
floatVal, err := config.GetFloat("mixed.3")
if err != nil || floatVal != 3.14 {
t.Errorf("expected mixed.3=3.14, got %v, err: %v", floatVal, err)
}
}
func TestMaps(t *testing.T) {
input := `
-- Simple map
server {
host = "localhost"
port = 8080
}
-- Map with equals sign
database = {
username = "admin"
password = "secret"
enabled = true
maxConnections = 100
}
-- Nested maps
application {
name = "MyApp"
version = "1.0.0"
settings {
theme = "dark"
notifications = true
logging {
level = "info"
file = "app.log"
}
}
}
`
config, err := config.Load(strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify simple map
serverMap, err := config.GetMap("server")
if err != nil {
t.Fatalf("failed to get server map: %v", err)
}
if len(serverMap) != 2 {
t.Errorf("expected 2 items in server map, got %d", len(serverMap))
}
// Verify dot notation access
host, err := config.GetString("server.host")
if err != nil || host != "localhost" {
t.Errorf("expected server.host=\"localhost\", got %v, err: %v", host, err)
}
port, err := config.GetInt("server.port")
if err != nil || port != 8080 {
t.Errorf("expected server.port=8080, got %v, err: %v", port, err)
}
// Verify map with equals sign
dbUser, err := config.GetString("database.username")
if err != nil || dbUser != "admin" {
t.Errorf("expected database.username=\"admin\", got %v, err: %v", dbUser, err)
}
dbEnabled, err := config.GetBool("database.enabled")
if err != nil || dbEnabled != true {
t.Errorf("expected database.enabled=true, got %v, err: %v", dbEnabled, err)
}
// Verify deeply nested maps
appName, err := config.GetString("application.name")
if err != nil || appName != "MyApp" {
t.Errorf("expected application.name=\"MyApp\", got %v, err: %v", appName, err)
}
theme, err := config.GetString("application.settings.theme")
if err != nil || theme != "dark" {
t.Errorf("expected application.settings.theme=\"dark\", got %v, err: %v", theme, err)
}
logLevel, err := config.GetString("application.settings.logging.level")
if err != nil || logLevel != "info" {
t.Errorf("expected application.settings.logging.level=\"info\", got %v, err: %v", logLevel, err)
}
// Test non-existent nested paths
_, err = config.GetString("application.settings.nonexistent")
if err == nil {
t.Errorf("expected error for non-existent nested key, got nil")
}
}

25
token.go Normal file
View File

@ -0,0 +1,25 @@
package config
// TokenType represents the type of token
type TokenType int
const (
TokenError TokenType = iota
TokenEOF
TokenName
TokenString
TokenNumber
TokenBoolean
TokenEquals
TokenOpenBrace
TokenCloseBrace
TokenComment
)
// Token represents a lexical token
type Token struct {
Type TokenType
Value []byte // Not modified after returning - caller must copy if needed
Line int
Column int
}