diff --git a/README.md b/README.md index 2dc62d7..0b4dff0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,153 @@ -# Config +# Go Config Parser -A super-simple config file parser that has a Lua-like format. \ No newline at end of file +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 \ No newline at end of file diff --git a/bench/bench_test.go b/bench/bench_test.go new file mode 100644 index 0000000..4d5e542 --- /dev/null +++ b/bench/bench_test.go @@ -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) + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..629aa86 --- /dev/null +++ b/config.go @@ -0,0 +1,194 @@ +package config + +import ( + "fmt" + "io" + "strconv" +) + +// Config holds a single hierarchical structure like JSON +type Config struct { + data map[string]any +} + +// NewConfig creates a new empty config +func NewConfig() *Config { + return &Config{ + data: make(map[string]any), + } +} + +// 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) +} + +// Load parses a config from a reader +func Load(r io.Reader) (*Config, error) { + parser := NewParser(r) + return parser.Parse() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ad173ff --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2ee2c3d --- /dev/null +++ b/go.sum @@ -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= diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..356b01d --- /dev/null +++ b/parser.go @@ -0,0 +1,171 @@ +package config + +import ( + "fmt" + "io" +) + +// Parser parses configuration files +type Parser struct { + scanner *Scanner + config *Config + currentObject map[string]any + stack []map[string]any +} + +// NewParser creates a new parser with a reader and empty config +func NewParser(r io.Reader) *Parser { + config := NewConfig() + return &Parser{ + scanner: NewScanner(r), + config: config, + currentObject: config.data, + stack: make([]map[string]any, 0, 8), // Pre-allocate stack with reasonable capacity + } +} + +// Error creates an error with line information from the scanner +func (p *Parser) Error(msg string) error { + return fmt.Errorf("line %d: %s", p.scanner.line, msg) +} + +// Parse parses the config file and returns a Config +func (p *Parser) Parse() (*Config, error) { + err := p.parseContent() + if err != nil { + return nil, err + } + return p.config, nil +} + +// pushObject enters a new object scope +func (p *Parser) pushObject(obj map[string]any) { + p.stack = append(p.stack, p.currentObject) + p.currentObject = obj +} + +// popObject exits the current object scope +func (p *Parser) popObject() { + n := len(p.stack) + if n > 0 { + p.currentObject = p.stack[n-1] + p.stack = p.stack[:n-1] + } +} + +// parseContent is the main parsing function +func (p *Parser) parseContent() error { + skipErr := p.scanner.SkipWhitespace() + for ; skipErr == nil; skipErr = p.scanner.SkipWhitespace() { + r, peekErr := p.scanner.PeekRune() + if peekErr == io.EOF { + break + } + if peekErr != nil { + return peekErr + } + + // Handle comments + if r == '-' { + if err := p.scanner.ScanComment(); err != nil { + return err + } + continue + } + + // Handle name=value pairs or named objects + if isLetter(r) { + name, err := p.scanner.ScanName() + if err != nil { + return err + } + + if err = p.scanner.SkipWhitespace(); err != nil { + return err + } + + r, err = p.scanner.PeekRune() + if err != nil && err != io.EOF { + return err + } + + // Assignment or direct map/array + if r == '=' { + // It's a standard key=value pair + p.scanner.ReadRune() // consume '=' + + if err = p.scanner.SkipWhitespace(); err != nil { + return err + } + + value, err := p.scanner.ScanValue() + if err != nil { + return err + } + + // Store the value directly + if mapValue, ok := value.(map[string]any); ok { + // Add an entry in current object + newMap := make(map[string]any, 8) // Pre-allocate with capacity + p.currentObject[name] = newMap + + // Process the map contents + p.pushObject(newMap) + + // Copy values from scanned map to our object + for k, v := range mapValue { + p.currentObject[k] = v + } + + p.popObject() + } else { + // Direct storage for primitives and arrays + p.currentObject[name] = value + } + } else if r == '{' { + // It's a map/array without '=' + value, err := p.scanner.ScanValue() + if err != nil { + return err + } + + // Store the complex value directly + if mapValue, ok := value.(map[string]any); ok { + // Add an entry in current object + newMap := make(map[string]any, 8) // Pre-allocate with capacity + p.currentObject[name] = newMap + + // Process the map contents + p.pushObject(newMap) + + // Copy values from scanned map to our object + for k, v := range mapValue { + p.currentObject[k] = v + } + + p.popObject() + } else { + // Direct storage for arrays + p.currentObject[name] = value + } + } else { + return p.Error("expected '=' or '{' after name") + } + + continue + } + + return p.Error("unexpected character") + } + + if skipErr != nil && skipErr != io.EOF { + return skipErr + } + + return nil +} + +// Helper function +func isLetter(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..96cdaf0 --- /dev/null +++ b/scanner.go @@ -0,0 +1,484 @@ +package config + +import ( + "bufio" + "errors" + "fmt" + "io" + "strconv" + "strings" + "unicode" +) + +// Scanner handles the low-level parsing of the configuration format +type Scanner struct { + reader *bufio.Reader + line int // Current line number + col int // Current column position + buffer []rune +} + +// NewScanner creates a new scanner with the given reader +func NewScanner(r io.Reader) *Scanner { + return &Scanner{ + reader: bufio.NewReader(r), + line: 1, // Start at line 1 + col: 0, + buffer: make([]rune, 0, 64), + } +} + +// ReadRune reads a single rune from the input +func (s *Scanner) ReadRune() (rune, int, error) { + r, i, err := s.reader.ReadRune() + if err == nil { + if r == '\n' { + s.line++ + s.col = 0 + } else { + s.col++ + } + } + return r, i, err +} + +// PeekRune looks at the next rune without consuming it +func (s *Scanner) PeekRune() (rune, error) { + r, _, err := s.reader.ReadRune() + if err != nil { + return 0, err + } + s.reader.UnreadRune() + return r, nil +} + +// 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 { + r, err := s.PeekRune() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if !unicode.IsSpace(r) { + return nil + } + _, _, err = s.ReadRune() + if err != nil { + return err + } + } +} + +// peekAndCheckRune checks if the next rune matches expected without consuming it +func (s *Scanner) peekAndCheckRune(expected rune) (bool, error) { + r, err := s.PeekRune() + if err != nil { + return false, err + } + return r == expected, nil +} + +// consumeIfMatch consumes the next rune if it matches expected +func (s *Scanner) consumeIfMatch(expected rune) (bool, error) { + matches, err := s.peekAndCheckRune(expected) + if err != nil || !matches { + return false, err + } + + _, _, err = s.ReadRune() // consume the rune + return err == nil, err +} + +// ScanComment processes a comment +func (s *Scanner) ScanComment() error { + // Consume the first dash + _, _, err := s.ReadRune() + if err != nil { + return err + } + + // Check for second dash + r, _, err := s.ReadRune() + if err != nil { + return err + } + if r != '-' { + return s.Error("invalid comment") + } + + // Check for block comment [[ + r, err = s.PeekRune() + if err == nil && r == '[' { + _, _, _ = s.ReadRune() // consume first [ + r, err = s.PeekRune() + if err == nil && r == '[' { + _, _, _ = s.ReadRune() // consume second [ + return s.scanBlockComment() + } + } + + // Line comment + for { + r, _, err := s.ReadRune() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if r == '\n' { + return nil + } + } +} + +// scanBlockComment processes a block comment +func (s *Scanner) scanBlockComment() error { + for { + r, _, err := s.ReadRune() + if err != nil { + return s.Error("unclosed block comment") + } + + if r == ']' { + r, err = s.PeekRune() + if err == nil && r == ']' { + _, _, _ = s.ReadRune() // consume second ] + return nil + } + } + } +} + +// ScanName reads a name identifier +func (s *Scanner) ScanName() (string, error) { + s.buffer = s.buffer[:0] // Reset buffer + + // Read first character + r, _, err := s.ReadRune() + if err != nil { + return "", err + } + + if !unicode.IsLetter(r) { + return "", s.Error("name must start with letter") + } + s.buffer = append(s.buffer, r) + + // Read rest of name + for { + r, err := s.PeekRune() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' { + break + } + s.buffer = append(s.buffer, r) + _, _, _ = s.ReadRune() + } + + return string(s.buffer), nil +} + +// processArrayElement processes a single array element +func (s *Scanner) processArrayElement() (any, error) { + r, err := s.PeekRune() + if err != nil { + return nil, err + } + + // Handle identifier-like elements + if unicode.IsLetter(r) { + name, err := s.ScanName() + if err != nil { + return nil, err + } + + // Try to convert to appropriate type + convertedValue, err := s.ConvertValue(name) + if err == nil { + return convertedValue, nil + } + return name, nil + } + + // Handle other element types (strings, numbers, etc.) + return s.ScanValue() +} + +// processMapEntry processes a key-value pair in a map +func (s *Scanner) processMapEntry() (string, any, bool, error) { + name, err := s.ScanName() + if err != nil { + return "", nil, false, err + } + + err = s.SkipWhitespace() + if err != nil { + return "", nil, false, err + } + + // Check for equals sign + isEquals, err := s.consumeIfMatch('=') + if err != nil && err != io.EOF { + return "", nil, false, err + } + + if isEquals { + value, err := s.ScanValue() + if err != nil { + return "", nil, false, err + } + return name, value, true, nil // true indicates this is a map entry + } + + // Check for opening brace (nested map/array) + isBrace, err := s.peekAndCheckRune('{') + if err != nil && err != io.EOF { + return "", nil, false, err + } + + if isBrace { + value, err := s.ScanValue() + if err != nil { + return "", nil, false, err + } + return name, value, true, nil // true indicates this is a map entry + } + + // If neither equals nor brace, it's an array element (name as string) + return name, name, false, nil // false indicates this is not a map entry +} + +// ScanValue processes and returns a value from the config +func (s *Scanner) ScanValue() (any, error) { + err := s.SkipWhitespace() + if err != nil { + return nil, err + } + + r, err := s.PeekRune() + if err != nil { + return nil, err + } + + // Check if it's an array/map + if r == '{' { + return s.ScanArrayOrMap() + } + + // Check if it's a quoted string + if r == '"' { + return s.ScanString() + } + + // Otherwise, treat it as a simple value + var value []rune + for { + r, err := s.PeekRune() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if unicode.IsSpace(r) || r == '}' { + break + } + value = append(value, r) + _, _, _ = s.ReadRune() + } + + if len(value) == 0 { + return nil, s.Error("empty value") + } + + // Convert value to appropriate type + return s.ConvertValue(string(value)) +} + +// ScanArrayOrMap processes arrays and maps +func (s *Scanner) ScanArrayOrMap() (any, error) { + // Consume opening brace + _, _, err := s.ReadRune() + if err != nil { + return nil, err + } + + // Initialize a new map to store contents + contents := make(map[string]any) + // And a slice to track array elements + var arrayElements []any + isArray := true + + for { + err := s.SkipWhitespace() + if err != nil { + return nil, err + } + + r, err := s.PeekRune() + if err == io.EOF { + return nil, s.Error("unclosed array/map") + } + if err != nil { + return nil, err + } + + // Check for closing brace + if r == '}' { + _, _, _ = s.ReadRune() // consume the closing brace + break + } + + // Handle comments inside arrays/maps + if r == '-' { + err = s.ScanComment() + if err != nil { + return nil, err + } + continue + } + + // If we find a named property, it might be a map entry + if unicode.IsLetter(r) { + name, value, isMapEntry, err := s.processMapEntry() + if err != nil { + return nil, err + } + + if isMapEntry { + // It's a key-value pair for a map + isArray = false + contents[name] = value + } else { + // It's an array element + arrayElements = append(arrayElements, value) + } + continue + } + + // Handle array elements that start with quotes, numbers, etc. + value, err := s.processArrayElement() + if err != nil { + return nil, err + } + arrayElements = append(arrayElements, value) + } + + // Check for array/map distinction and return appropriate result + if isArray && len(contents) == 0 { + return arrayElements, nil + } + return contents, nil +} + +// ScanString reads a quoted string +func (s *Scanner) ScanString() (any, error) { + // Consume opening quote + _, _, err := s.ReadRune() + if err != nil { + return nil, err + } + + var builder strings.Builder + builder.Grow(64) // Preallocate with reasonable capacity + + for { + r, _, err := s.ReadRune() + if err != nil { + return nil, s.Error("unterminated string") + } + + if r == '"' { + break + } + + // Handle escape sequences + if r == '\\' { + escaped, _, err := s.ReadRune() + if err != nil { + return nil, err + } + switch escaped { + case '"': + builder.WriteRune('"') + case '\\': + builder.WriteRune('\\') + case 'n': + builder.WriteRune('\n') + case 't': + builder.WriteRune('\t') + default: + builder.WriteRune('\\') + builder.WriteRune(escaped) + } + } else { + builder.WriteRune(r) + } + } + + return builder.String(), nil +} + +// ConvertValue converts string values to their appropriate types +func (s *Scanner) ConvertValue(value string) (any, error) { + // Fast path for booleans + if value == "true" { + return true, nil + } + if value == "false" { + return false, nil + } + + // Early exit for empty values + if len(value) == 0 { + return nil, errors.New("empty value") + } + + // Check for number type in one pass + isNegative := value[0] == '-' + startIdx := 0 + if isNegative { + if len(value) == 1 { + return nil, errors.New("invalid value: -") + } + startIdx = 1 + } + + hasDot := false + for i := startIdx; i < len(value); i++ { + if value[i] == '.' { + if hasDot { + return nil, errors.New("invalid number format") + } + hasDot = true + } else if value[i] < '0' || value[i] > '9' { + return nil, errors.New("invalid value format: " + value) + } + } + + // Process as integer or float based on presence of decimal + if !hasDot { + return strconv.ParseInt(value, 10, 64) + } + + // Float (ensure not ending with dot) + if value[len(value)-1] != '.' { + return strconv.ParseFloat(value, 64) + } + + return nil, errors.New("invalid value format: " + value) +} diff --git a/tests/config_test.go b/tests/config_test.go new file mode 100644 index 0000000..3d5be5f --- /dev/null +++ b/tests/config_test.go @@ -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") + } +}