diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ab48fe9..7270150 100755
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- go: [ '1.20', '1.21', '1.22' ]
+ go: [ '1.26' ]
steps:
- uses: actions/checkout@v3
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100755
index ddee4f0..0000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-
-name: "CodeQL"
-
-on:
- push:
- branches: [ "master" ]
- pull_request:
- branches: [ "master" ]
- schedule:
- - cron: '16 8 * * 1'
-
-jobs:
- analyze:
- name: Analyze
- runs-on: ubuntu-latest
- permissions:
- actions: read
- contents: read
- security-events: write
-
- strategy:
- fail-fast: false
- matrix:
- language: [ 'go' ]
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v3
-
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v2
- with:
- languages: ${{ matrix.language }}
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
- with:
- category: "/language:${{matrix.language}}"
diff --git a/.gitignore b/.gitignore
old mode 100644
new mode 100755
index 815f025..2d09f4d
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,20 @@
-# If you prefer the allow list template instead of the deny list, see community template:
-# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
-#
-# Binaries for programs and plugins
+
+.tools/
+bin/
+vendor/
+build/
+.idea/
+.vscode/
+coverage.txt
+coverage.out
*.exe
*.exe~
*.dll
*.so
*.dylib
-
-# Test binary, built with `go test -c`
+*.db
+*.db-journal
+*.mmdb
*.test
-
-# Output of the go coverage tool, specifically when used with LiteIDE
*.out
-
-# Dependency directories (remove the comment below to include it)
-# vendor/
-
-# Go workspace file
-go.work
-.tools/
+.env
\ No newline at end of file
diff --git a/.golangci.yml b/.golangci.yml
index 8a69106..f2b16a7 100755
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,375 +1,68 @@
+version: "2"
-# options for analysis running
run:
- # timeout for analysis, e.g. 30s, 5m, default is 1m
- deadline: 5m
-
- # exit code when at least one issue was found, default is 1
+ go: "1.26"
+ timeout: 5m
+ tests: false
issues-exit-code: 1
-
- # include test files or not, default is true
- tests: true
-
- # which files to skip: they will be analyzed, but issues from them
- # won't be reported. Default value is empty list, but there is
- # no need to include all autogenerated files, we confidently recognize
- # autogenerated files. If it's not please let us know.
- skip-files:
- - easyjson
+ modules-download-mode: readonly
+ allow-parallel-runners: true
issues:
- # Independently from option 'exclude' we use default exclude patterns,
- # it can be disabled by this option. To list all
- # excluded by default patterns execute 'golangci-lint run --help'.
- # Default value for this option is true.
- exclude-use-default: false
- # Excluding configuration per-path, per-linter, per-text and per-source
- exclude-rules:
- # Exclude some linters from running on tests files.
- - path: _test\.go
- linters:
- - prealloc
- - errcheck
+ max-issues-per-linter: 0
+ max-same-issues: 0
+ new: false
+ fix: false
-# output configuration options
output:
- # colored-line-number|line-number|json|tab|checkstyle, default is "colored-line-number"
- format: colored-line-number
-
- # print lines of code with issue, default is true
- print-issued-lines: true
-
- # print linter name in the end of issue text, default is true
- print-linter-name: true
+ formats:
+ text:
+ print-linter-name: true
+ print-issued-lines: true
-# all available settings of specific linters
-linters-settings:
- govet:
- # report about shadowed variables
- check-shadowing: true
+formatters:
+ exclusions:
+ paths:
+ - vendors/
enable:
- # report mismatches between assembly files and Go declarations
- - asmdecl
- # check for useless assignments
- - assign
- # check for common mistakes using the sync/atomic package
- - atomic
- # check for non-64-bits-aligned arguments to sync/atomic functions
- - atomicalign
- # check for common mistakes involving boolean operators
- - bools
- # check that +build tags are well-formed and correctly located
- - buildtag
- # detect some violations of the cgo pointer passing rules
- - cgocall
- # check for unkeyed composite literals
- - composites
- # check for locks erroneously passed by value
- - copylocks
- # check for calls of reflect.DeepEqual on error values
- - deepequalerrors
- # report passing non-pointer or non-error values to errors.As
- - errorsas
- # find calls to a particular function
- - findcall
- # report assembly that clobbers the frame pointer before saving it
- - framepointer
- # check for mistakes using HTTP responses
- - httpresponse
- # detect impossible interface-to-interface type assertions
- - ifaceassert
- # check references to loop variables from within nested functions
- - loopclosure
- # check cancel func returned by context.WithCancel is called
- - lostcancel
- # check for useless comparisons between functions and nil
- - nilfunc
- # check for redundant or impossible nil comparisons
- - nilness
- # check consistency of Printf format strings and arguments
- - printf
- # check for comparing reflect.Value values with == or reflect.DeepEqual
- - reflectvaluecompare
- # check for possible unintended shadowing of variables
- - shadow
- # check for shifts that equal or exceed the width of the integer
- - shift
- # check for unbuffered channel of os.Signal
- - sigchanyzer
- # check the argument type of sort.Slice
- - sortslice
- # check signature of methods of well-known interfaces
- - stdmethods
- # check for string(int) conversions
- - stringintconv
- # check that struct field tags conform to reflect.StructTag.Get
- - structtag
- # report calls to (*testing.T).Fatal from goroutines started by a test.
- - testinggoroutine
- # check for common mistaken usages of tests and examples
- - tests
- # report passing non-pointer or non-interface values to unmarshal
- - unmarshal
- # check for unreachable code
- - unreachable
- # check for invalid conversions of uintptr to unsafe.Pointer
- - unsafeptr
- # check for unused results of calls to some functions
- - unusedresult
- # checks for unused writes
- - unusedwrite
- disable:
- # find structs that would use less memory if their fields were sorted
- - fieldalignment
- gofmt:
- # simplify code: gofmt with '-s' option, true by default
- simplify: true
- errcheck:
- # report about not checking of errors in type assetions: 'a := b.(MyStruct)';
- # default is false: such cases aren't reported by default.
- check-type-assertions: true
- # report about assignment of errors to blank identifier: 'num, _ := strconv.Atoi(numStr)';
- # default is false: such cases aren't reported by default.
- check-blank: true
- gocyclo:
- # minimal code complexity to report, 30 by default (but we recommend 10-20)
- min-complexity: 15
- misspell:
- # Correct spellings using locale preferences for US or UK.
- # Default is to use a neutral variety of English.
- # Setting locale to US will correct the British spelling of 'colour' to 'color'.
- locale: US
- prealloc:
- # XXX: we don't recommend using this linter before doing performance profiling.
- # For most programs usage of prealloc will be a premature optimization.
- # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them.
- # True by default.
- simple: true
- range-loops: true # Report preallocation suggestions on range loops, true by default
- for-loops: true # Report preallocation suggestions on for loops, false by default
- unparam:
- # Inspect exported functions, default is false. Set to true if no external program/library imports your code.
- # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors:
- # if it's called for subdir of a project it can't find external interfaces. All text editor integrations
- # with golangci-lint call it on a directory with the changed file.
- check-exported: false
- gci:
- # Section configuration to compare against.
- # Section names are case-insensitive and may contain parameters in ().
- # The default order of sections is 'standard > default > custom > blank > dot',
- # If 'custom-order' is 'true', it follows the order of 'sections' option.
- # Default: ["standard", "default"]
- #sections:
- #- standard # Standard section: captures all standard packages.
- #- default # Default section: contains all imports that could not be matched to another section type.
- #- blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled.
- #- dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled.
- # Skip generated files.
- # Default: true
- skip-generated: true
- # Enable custom order of sections.
- # If 'true', make the section order the same as the order of 'sections'.
- # Default: false
- custom-order: false
- gosec:
- # To select a subset of rules to run.
- # Available rules: https://github.com/securego/gosec#available-rules
- # Default: [] - means include all rules
- includes:
- - G101 # Look for hard coded credentials
- - G102 # Bind to all interfaces
- - G103 # Audit the use of unsafe block
- - G104 # Audit errors not checked
- - G106 # Audit the use of ssh.InsecureIgnoreHostKey
- - G107 # Url provided to HTTP request as taint input
- - G108 # Profiling endpoint automatically exposed on /debug/pprof
- - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32
- - G110 # Potential DoS vulnerability via decompression bomb
- - G111 # Potential directory traversal
- - G112 # Potential slowloris attack
- - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772)
- - G114 # Use of net/http serve function that has no support for setting timeouts
- - G201 # SQL query construction using format string
- - G202 # SQL query construction using string concatenation
- - G203 # Use of unescaped data in HTML templates
- - G204 # Audit use of command execution
- - G301 # Poor file permissions used when creating a directory
- - G302 # Poor file permissions used with chmod
- - G303 # Creating tempfile using a predictable path
- - G304 # File path provided as taint input
- - G305 # File traversal when extracting zip/tar archive
- - G306 # Poor file permissions used when writing to a new file
- - G307 # Deferring a method which returns an error
- - G401 # Detect the usage of DES, RC4, MD5 or SHA1
- - G402 # Look for bad TLS connection settings
- - G403 # Ensure minimum RSA key length of 2048 bits
- - G404 # Insecure random number source (rand)
- - G501 # Import blocklist: crypto/md5
- - G502 # Import blocklist: crypto/des
- - G503 # Import blocklist: crypto/rc4
- - G504 # Import blocklist: net/http/cgi
- - G505 # Import blocklist: crypto/sha1
- - G601 # Implicit memory aliasing of items from a range statement
- # To specify a set of rules to explicitly exclude.
- # Available rules: https://github.com/securego/gosec#available-rules
- # Default: []
- excludes:
- - G101 # Look for hard coded credentials
- - G102 # Bind to all interfaces
- - G103 # Audit the use of unsafe block
- - G104 # Audit errors not checked
- - G106 # Audit the use of ssh.InsecureIgnoreHostKey
- - G107 # Url provided to HTTP request as taint input
- - G108 # Profiling endpoint automatically exposed on /debug/pprof
- - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32
- - G110 # Potential DoS vulnerability via decompression bomb
- - G111 # Potential directory traversal
- - G112 # Potential slowloris attack
- - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772)
- - G114 # Use of net/http serve function that has no support for setting timeouts
- - G201 # SQL query construction using format string
- - G202 # SQL query construction using string concatenation
- - G203 # Use of unescaped data in HTML templates
- - G204 # Audit use of command execution
- - G301 # Poor file permissions used when creating a directory
- - G302 # Poor file permissions used with chmod
- - G303 # Creating tempfile using a predictable path
- - G304 # File path provided as taint input
- - G305 # File traversal when extracting zip/tar archive
- - G306 # Poor file permissions used when writing to a new file
- - G307 # Deferring a method which returns an error
- - G401 # Detect the usage of DES, RC4, MD5 or SHA1
- - G402 # Look for bad TLS connection settings
- - G403 # Ensure minimum RSA key length of 2048 bits
- - G404 # Insecure random number source (rand)
- - G501 # Import blocklist: crypto/md5
- - G502 # Import blocklist: crypto/des
- - G503 # Import blocklist: crypto/rc4
- - G504 # Import blocklist: net/http/cgi
- - G505 # Import blocklist: crypto/sha1
- - G601 # Implicit memory aliasing of items from a range statement
- # Exclude generated files
- # Default: false
- exclude-generated: true
- # Filter out the issues with a lower severity than the given value.
- # Valid options are: low, medium, high.
- # Default: low
- severity: medium
- # Filter out the issues with a lower confidence than the given value.
- # Valid options are: low, medium, high.
- # Default: low
- confidence: medium
- # Concurrency value.
- # Default: the number of logical CPUs usable by the current process.
- concurrency: 12
- # To specify the configuration of rules.
- config:
- # Globals are applicable to all rules.
- global:
- # If true, ignore #nosec in comments (and an alternative as well).
- # Default: false
- nosec: true
- # Add an alternative comment prefix to #nosec (both will work at the same time).
- # Default: ""
- "#nosec": "#my-custom-nosec"
- # Define whether nosec issues are counted as finding or not.
- # Default: false
- show-ignored: true
- # Audit mode enables addition checks that for normal code analysis might be too nosy.
- # Default: false
- audit: true
- G101:
- # Regexp pattern for variables and constants to find.
- # Default: "(?i)passwd|pass|password|pwd|secret|token|pw|apiKey|bearer|cred"
- pattern: "(?i)example"
- # If true, complain about all cases (even with low entropy).
- # Default: false
- ignore_entropy: false
- # Maximum allowed entropy of the string.
- # Default: "80.0"
- entropy_threshold: "80.0"
- # Maximum allowed value of entropy/string length.
- # Is taken into account if entropy >= entropy_threshold/2.
- # Default: "3.0"
- per_char_threshold: "3.0"
- # Calculate entropy for first N chars of the string.
- # Default: "16"
- truncate: "32"
- # Additional functions to ignore while checking unhandled errors.
- # Following functions always ignored:
- # bytes.Buffer:
- # - Write
- # - WriteByte
- # - WriteRune
- # - WriteString
- # fmt:
- # - Print
- # - Printf
- # - Println
- # - Fprint
- # - Fprintf
- # - Fprintln
- # strings.Builder:
- # - Write
- # - WriteByte
- # - WriteRune
- # - WriteString
- # io.PipeWriter:
- # - CloseWithError
- # hash.Hash:
- # - Write
- # os:
- # - Unsetenv
- # Default: {}
- G104:
- fmt:
- - Fscanf
- G111:
- # Regexp pattern to find potential directory traversal.
- # Default: "http\\.Dir\\(\"\\/\"\\)|http\\.Dir\\('\\/'\\)"
- pattern: "custom\\.Dir\\(\\)"
- # Maximum allowed permissions mode for os.Mkdir and os.MkdirAll
- # Default: "0750"
- G301: "0750"
- # Maximum allowed permissions mode for os.OpenFile and os.Chmod
- # Default: "0600"
- G302: "0600"
- # Maximum allowed permissions mode for os.WriteFile and ioutil.WriteFile
- # Default: "0600"
- G306: "0600"
-
- lll:
- # Max line length, lines longer will be reported.
- # '\t' is counted as 1 character by default, and can be changed with the tab-width option.
- # Default: 120.
- line-length: 120
- # Tab width in spaces.
- # Default: 1
- tab-width: 1
+ - gofmt
+ - goimports
linters:
- disable-all: true
+ settings:
+ staticcheck:
+ checks:
+ - all
+ - -S1023
+ - -ST1000
+ - -ST1003
+ - -ST1020
+ gosec:
+ excludes:
+ - G104
+ - G115
+ - G301
+ - G304
+ - G306
+ - G501
+ - G505
+ exclusions:
+ paths:
+ - vendors/
+ default: none
enable:
- govet
- - gofmt
- errcheck
- misspell
- gocyclo
- ineffassign
- - goimports
- - nakedret
- unparam
- unused
- prealloc
- durationcheck
- - nolintlint
- staticcheck
- makezero
- nilerr
- errorlint
- bodyclose
- - exportloopref
- - gci
- gosec
- - lll
- fast: false
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b8..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/go-coma.iml b/.idea/go-coma.iml
deleted file mode 100644
index 5e764c4..0000000
--- a/.idea/go-coma.iml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 097ec90..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1dd..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.lic.yaml b/.lic.yaml
new file mode 100755
index 0000000..ee29af9
--- /dev/null
+++ b/.lic.yaml
@@ -0,0 +1,3 @@
+author: "Mikhail Knyazhev "
+lic_short: "BSD 3-Clause"
+lic_file: LICENSE
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
old mode 100644
new mode 100755
index 2e4e67c..d6c3bf3
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
BSD 3-Clause License
-Copyright (c) 2024, OSSPkg Team
+Copyright (c) 2024-2026, Mikhail Knyazhev
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
diff --git a/Makefile b/Makefile
index 918d40d..c04cf9f 100755
--- a/Makefile
+++ b/Makefile
@@ -1,31 +1,31 @@
+SHELL=/bin/bash
+
+
.PHONY: install
install:
- go install github.com/osspkg/devtool@latest
-
-.PHONY: setup
-setup:
- devtool setup-lib
+ go install go.osspkg.com/goppy/v3/cmd/goppy@latest
+ goppy setup-lib
.PHONY: lint
lint:
- devtool lint
+ goppy lint
.PHONY: license
license:
- devtool license
+ goppy license
.PHONY: build
build:
- devtool build --arch=amd64
+ goppy build --arch=amd64
.PHONY: tests
tests:
- devtool test
+ goppy test
-.PHONY: pre-commite
-pre-commite: setup lint build tests
+.PHONY: pre-commit
+pre-commit: install license lint tests build
.PHONY: ci
-ci: install setup lint build tests
+ci: pre-commit
diff --git a/README.md b/README.md
index f562f1d..7766440 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,317 @@
-# go-unic
+# Universal Configuration Format (UNIC)
-Universal Configuration Format
+
+
+[](https://pkg.go.dev/go.osspkg.com/unic)
+[](README.ru.md)
+
+**go-unic** is a reliable, high‑performance Go library for parsing, serialising, and working with configuration files in the **Universal Configuration Format (UNIC)**. UNIC combines readability, a minimalistic syntax, and flexibility – letting you describe complex data structures as easily as with JSON or YAML, but with a syntax that feels more natural for humans.
+
+## 📋 Table of Contents
+
+- [Features](#-features)
+- [Installation](#-installation)
+- [Quick Start](#-quick-start)
+ - [Unmarshal (deserialisation)](#1-unmarshal-deserialisation)
+ - [Marshal (serialisation)](#2-marshal-serialisation)
+- [UNIC Syntax](#-unic-syntax)
+ - [Basic constructs](#basic-constructs)
+ - [String escaping](#string-escaping)
+ - [Comments](#comments)
+ - [Attributes](#attributes)
+- [Struct Tags (options)](#-struct-tags-options)
+- [Examples](#-examples)
+- [Comparison with other formats](#-comparison-with-other-formats)
+- [Contributing](#-contributing)
+- [License](#-license)
+
+---
+
+## 🚀 Features
+
+- **Human‑readable syntax** – intuitive, without superfluous symbols (like Nginx or HCL).
+- **Support for all major Go types**: structs, slices, maps, scalars (numbers, strings, booleans).
+- **Flexible tag‑based control** – set field names, default values, omit empty fields, attributes, comments.
+- **Structure merging** – automatically combine fields when serialising multiple objects with the same key.
+- **Arbitrary nesting depth** – blocks, lists, and maps can be combined freely.
+- **Support for `any` interfaces** – deserialise into `map[string]any` or `[]any` when needed.
+- **High performance** – minimal reflection usage, cached struct metadata.
+- **Zero allocations** during parsing (uses a `bb` buffer).
+
+---
+
+## 📦 Installation
+
+```bash
+go get -u go.osspkg.com/unic@latest
+```
+
+---
+
+## 💡 Quick Start
+
+### 1. Unmarshal (deserialisation)
+
+To convert UNIC data into a Go struct, use `unic.Unmarshal`:
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "go.osspkg.com/unic"
+)
+
+type Config struct {
+ LogLevel int `unic:"log_level,default=1"`
+ Port int `unic:"port"`
+ Features []string `unic:"features,omitempty"`
+}
+
+func main() {
+ data, err := os.ReadFile("config.unic")
+ if err != nil {
+ fmt.Printf("Error reading file: %v\n", err)
+ return
+ }
+
+ var cfg Config
+ if err := unic.Unmarshal(data, &cfg); err != nil {
+ fmt.Printf("Parsing error: %v\n", err)
+ return
+ }
+
+ fmt.Printf("Configuration: log_level=%d, port=%d, features=%v\n",
+ cfg.LogLevel, cfg.Port, cfg.Features)
+}
+```
+
+### 2. Marshal (serialisation)
+
+To write a struct to UNIC format, use `unic.Marshal`:
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "go.osspkg.com/unic"
+)
+
+func main() {
+ cfg := Config{
+ LogLevel: 2,
+ Port: 8080,
+ Features: []string{"auth", "metrics"},
+ }
+
+ data, err := unic.Marshal(cfg)
+ if err != nil {
+ fmt.Printf("Serialisation error: %v\n", err)
+ return
+ }
+
+ if err := os.WriteFile("config.unic", data, 0644); err != nil {
+ fmt.Printf("Write error: %v\n", err)
+ }
+}
+```
+
+---
+
+## 📐 UNIC Syntax
+
+UNIC is a text file containing fields, blocks, lists, and maps. Basic rules:
+
+### Basic constructs
+
+| Construct | Example | Description |
+|----------------|-------------------------------------|----------------------------------------------------------------|
+| **Field** | `key value;` | Assigns a scalar value (string, number, boolean). |
+| **Block** | `key { field1 val1; field2 val2; }` | Groups fields (similar to a struct). |
+| **List** | `key [val1, val2, val3];` | Ordered collection of values. |
+| **Map** | `key (key1, val1, key2, val2);` | Key‑value pairs (keys are always strings). |
+| **Attributes** | `key attr1 attr2 { ... }` | Values before an opening block brace become struct attributes. |
+
+### String escaping
+
+To avoid conflicts with system characters (`{}[]();,#`), spaces, quotes, or line breaks, the following rules apply:
+
+- If the string contains `"`, `{`, `}`, `[`, `]`, `(`, `)`, `#`, `;`, `,` or spaces – enclose it in single quotes: `'hello "world"'`.
+- If the string contains `'`, `{`, `}`, `[`, `]`, `(`, `)`, `#`, `;`, `,` or spaces – enclose it in double quotes: `"hello 'world'"`.
+- If the string contains both `'` and `"` as well as special characters or line breaks – use triple backticks: `` ```hello 'world' "foo"``` ``.
+
+Example:
+
+```
+message 'Hello, "friend"!';
+path "C:\\Program Files\\App";
+multiline ```first line
+second line```;
+```
+
+### Comments
+
+- **Single‑line** – after `;` on the same line:
+ ```
+ port 80; # standard port
+ ```
+- **Block** – after `{` on the same line (applies to the entire block):
+ ```
+ server { # server settings
+ host '127.0.0.1';
+ }
+ ```
+
+### Attributes
+
+If values are given before an opening block brace, they are interpreted as struct attributes. The `attr=N` tag sets the ordinal number (starting from 1).
+Example:
+
+```
+server web 80 { host 'localhost'; }
+```
+
+corresponds to the struct:
+
+```go
+package main
+
+type Server struct {
+ Tag string `unic:"tag,attr=1"` // "web"
+ Port int `unic:"port,attr=2"` // 80
+ Host string `unic:"host"`
+}
+```
+
+---
+
+## 🏷️ Struct Tags (options)
+
+The `unic` tag has the format: `unic:"name[,option1='value'][,option2='value']..."`
+
+Available options:
+
+| Option | Description |
+|-------------|----------------------------------------------------------------------------------------------|
+| `name` | Field name in the configuration (mandatory). |
+| `default` | Default value (for scalars or lists separated by `;`). |
+| `omitempty` | If the field is empty (zero value), it is omitted during serialisation and ignored on parse. |
+| `attr` | Ordinal number of a block attribute (number > 0). |
+| `desc` | Comment added during serialisation. |
+
+Example:
+
+```go
+package main
+
+type Config struct {
+ LogLevel int `unic:"log_level,default=1,desc='log level'"`
+ Servers []Server `unic:"server"`
+}
+
+type Server struct {
+ Name string `unic:"name,attr=1"`
+ Port int `unic:"port"`
+}
+```
+
+---
+
+## 🔍 Examples
+
+### Nested structs and lists
+
+Configuration:
+
+```
+log_level 1;
+servers {
+ server web {
+ port 80;
+ host 'localhost';
+ }
+ server admin {
+ port 8080;
+ host '127.0.0.1';
+ auth (user1, passwd1, user2, passwd2);
+ }
+}
+```
+
+Go struct:
+
+```go
+package main
+
+type Config struct {
+ LogLevel int `unic:"log_level"`
+ Servers struct {
+ Servers []struct {
+ Name string `unic:"name,attr=1"`
+ Port int `unic:"port"`
+ Host string `unic:"host"`
+ Auth map[string]string `unic:"auth,omitempty"`
+ } `unic:"server"`
+ } `unic:"servers"`
+}
+```
+
+### Serialising multiple structs into one file
+
+`unic.Marshal` accepts several arguments – all are merged into one document. If fields with the same name appear in different structs, they are combined (merged) into a single block.
+
+```go
+package main
+
+type Part1 struct {
+ Common string `unic:"common"`
+ A int `unic:"a"`
+}
+type Part2 struct {
+ Common string `unic:"common"`
+ B bool `unic:"b"`
+}
+data, _ := unic.Marshal(Part1{Common:"shared", A:42}, Part2{Common:"shared", B:true})
+// Output:
+// common shared;
+// a 42;
+// b true;
+```
+
+---
+
+## ⚖️ Comparison with other formats
+
+| Format | Readability | Complex structures | Comments | Performance (Go) |
+|----------|-------------|---------------------|----------|-------------------------------|
+| **UNIC** | ★★★★★ | ★★★★★ | ✔ | High (reflection with cache) |
+| JSON | ★★★☆☆ | ★★★★☆ | ✘ | Very high |
+| YAML | ★★★★☆ | ★★★★★ | ✔ | Medium |
+| TOML | ★★★★☆ | ★★★☆☆ | ✔ | Medium |
+| HCL | ★★★★☆ | ★★★★★ | ✔ | Medium |
+
+**UNIC** offers the best balance between readability and performance, especially if you need comments, attributes, and flexible struct merging.
+
+---
+
+## 🤝 Contributing
+
+We welcome your ideas and improvements! To contribute:
+
+1. Fork the repository.
+2. Create a branch for your feature (`git checkout -b feature/amazing-feature`).
+3. Make your changes and write tests.
+4. Ensure all linters and tests pass (`make pre-commit`).
+5. Open a pull request.
+
+---
+
+## 📄 License
+
+Distributed under the **BSD 3‑Clause** License. See the [LICENSE](LICENSE) file for details.
diff --git a/README.ru.md b/README.ru.md
new file mode 100644
index 0000000..a38b382
--- /dev/null
+++ b/README.ru.md
@@ -0,0 +1,323 @@
+# Universal Configuration Format (UNIC)
+
+
+
+[](https://pkg.go.dev/go.osspkg.com/unic)
+[](README.md)
+
+**go-unic** — это надёжная и высокопроизводительная библиотека на Go для парсинга, сериализации и работы с конфигурационными файлами в формате **Universal
+Configuration Format (UNIC)**. UNIC сочетает в себе читаемость, минималистичный синтаксис и гибкость, позволяя описывать сложные структуры данных так же просто,
+как и привычные JSON или YAML, но с более удобным для человека синтаксисом.
+
+## 📋 Оглавление
+
+- [Возможности](#-возможности)
+- [Установка](#-установка)
+- [Быстрый старт](#-быстрый-старт)
+ - [Десериализация (Unmarshal)](#1-десериализация-unmarshal)
+ - [Сериализация (Marshal)](#2-сериализация-marshal)
+- [Синтаксис формата UNIC](#-синтаксис-формата-unic)
+ - [Основные конструкции](#основные-конструкции)
+ - [Экранирование строк](#экранирование-строк)
+ - [Комментарии](#комментарии)
+ - [Атрибуты](#атрибуты)
+- [Теги структур (опции)](#-теги-структур-опции)
+- [Примеры](#-примеры)
+- [Сравнение с другими форматами](#-сравнение-с-другими-форматами)
+- [Внесение вклада](#-внесение-вклада)
+- [Лицензия](#-лицензия)
+
+---
+
+## 🚀 Возможности
+
+- **Человекочитаемый синтаксис** – интуитивно понятный, без лишних символов (как Nginx или HCL).
+- **Поддержка всех основных типов Go**: структуры, срезы, карты, скаляры (числа, строки, булевы).
+- **Гибкое управление через теги** – задавайте имена полей, значения по умолчанию, пропуск пустых полей, атрибуты, комментарии.
+- **Слияние структур** – автоматическое объединение полей при сериализации нескольких объектов с одинаковым ключом.
+- **Вложенность любой глубины** – блоки, списки, карты можно комбинировать.
+- **Поддержка интерфейсов `any`** – при необходимости можно десериализовать в `map[string]any` или `[]any`.
+- **Высокая производительность** – минимальное использование рефлексии, кэширование метаданных структур.
+- **Нулевые аллокации** при парсинге (используется буфер `bb`).
+
+---
+
+## 📦 Установка
+
+```bash
+go get -u go.osspkg.com/unic@latest
+```
+
+---
+
+## 💡 Быстрый старт
+
+### 1. Десериализация (Unmarshal)
+
+Для преобразования данных UNIC в структуру Go используйте `unic.Unmarshal`:
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "go.osspkg.com/unic"
+)
+
+type Config struct {
+ LogLevel int `unic:"log_level,default=1"`
+ Port int `unic:"port"`
+ Features []string `unic:"features,omitempty"`
+}
+
+func main() {
+ data, err := os.ReadFile("config.unic")
+ if err != nil {
+ fmt.Printf("Ошибка чтения файла: %v\n", err)
+ return
+ }
+
+ var cfg Config
+ if err := unic.Unmarshal(data, &cfg); err != nil {
+ fmt.Printf("Ошибка парсинга: %v\n", err)
+ return
+ }
+
+ fmt.Printf("Конфигурация: log_level=%d, port=%d, features=%v\n",
+ cfg.LogLevel, cfg.Port, cfg.Features)
+}
+```
+
+### 2. Сериализация (Marshal)
+
+Для записи структуры в формат UNIC используйте `unic.Marshal`:
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "go.osspkg.com/unic"
+)
+
+func main() {
+ cfg := Config{
+ LogLevel: 2,
+ Port: 8080,
+ Features: []string{"auth", "metrics"},
+ }
+
+ data, err := unic.Marshal(cfg)
+ if err != nil {
+ fmt.Printf("Ошибка сериализации: %v\n", err)
+ return
+ }
+
+ if err := os.WriteFile("config.unic", data, 0644); err != nil {
+ fmt.Printf("Ошибка записи: %v\n", err)
+ }
+}
+```
+
+---
+
+## 📐 Синтаксис формата UNIC
+
+Формат UNIC представляет собой текстовый файл с набором полей, блоков, списков и карт. Основные правила:
+
+### Основные конструкции
+
+| Конструкция | Пример | Описание |
+|--------------|-------------------------------------|----------------------------------------------------------------|
+| **Поле** | `key value;` | Присваивание скалярного значения (строка, число, булево). |
+| **Блок** | `key { field1 val1; field2 val2; }` | Группировка полей (аналог структуры). |
+| **Список** | `key [val1, val2, val3];` | Упорядоченный набор значений. |
+| **Карта** | `key (key1, val1, key2, val2);` | Пары ключ-значение (ключи всегда строки). |
+| **Атрибуты** | `key attr1 attr2 { ... }` | Значения перед открывающей скобкой блока – атрибуты структуры. |
+
+### Экранирование строк
+
+Чтобы избежать конфликтов с системными символами (`{}[]();,#`), пробелами, кавычками или переносами строк, используются следующие правила:
+
+- Если строка содержит `"`, `{`, `}`, `[`, `]`, `(`, `)`, `#`, `;`, `,` или пробелы – обрамляем её одинарными кавычками: `'hello "world"'`.
+- Если строка содержит `'`, `{`, `}`, `[`, `]`, `(`, `)`, `#`, `;`, `,` или пробелы – обрамляем двойными кавычками: `"hello 'world'"`.
+- Если строка содержит одновременно `'` и `"`, а также спецсимволы или переносы строк – используем тройные обратные кавычки: `` ```hello 'world' "foo"``` ``.
+
+Пример:
+
+```
+message 'Hello, "friend"!';
+path "C:\\Program Files\\App";
+multiline ```first line
+second line```;
+```
+
+### Комментарии
+
+- **Однострочные** – после `;` в той же строке:
+ ```
+ port 80; # стандартный порт
+ ```
+- **Блочные** – после `{` на той же строке (распространяются на весь блок):
+ ```
+ server { # настройки сервера
+ host '127.0.0.1';
+ }
+ ```
+
+### Атрибуты
+
+Если перед открывающей скобкой блока указаны значения, они интерпретируются как атрибуты структуры. В теге `attr=N` задаётся порядковый номер (начиная с 1).
+Пример:
+
+```
+server web 80 { host 'localhost'; }
+```
+
+соответствует структуре:
+
+```go
+package main
+
+type Server struct {
+ Tag string `unic:"tag,attr=1"` // "web"
+ Port int `unic:"port,attr=2"` // 80
+ Host string `unic:"host"`
+}
+
+```
+
+---
+
+## 🏷️ Теги структур (опции)
+
+Тег `unic` имеет формат: `unic:"имя[,опция1='значение'][,опция2='значение']..."`
+
+Доступные опции:
+
+| Опция | Описание |
+|-------------|------------------------------------------------------------------------------------------|
+| `name` | Имя поля в конфигурации (обязательно). |
+| `default` | Значение по умолчанию (для скаляров или списков, разделённых `;`). |
+| `omitempty` | Если значение пустое (нулевое), поле не сериализуется и игнорируется при десериализации. |
+| `attr` | Порядковый номер атрибута блока (число > 0). |
+| `desc` | Комментарий, который будет добавлен при сериализации. |
+
+Пример:
+
+```go
+package main
+
+type Config struct {
+ LogLevel int `unic:"log_level,default=1,desc='уровень логирования'"`
+ Servers []Server `unic:"server"`
+}
+
+type Server struct {
+ Name string `unic:"name,attr=1"`
+ Port int `unic:"port"`
+}
+
+```
+
+---
+
+## 🔍 Примеры
+
+### Вложенные структуры и списки
+
+Конфигурация:
+
+```
+log_level 1;
+servers {
+ server web {
+ port 80;
+ host 'localhost';
+ }
+ server admin {
+ port 8080;
+ host '127.0.0.1';
+ auth (user1, passwd1, user2, passwd2);
+ }
+}
+```
+
+Структура Go:
+
+```go
+package main
+
+type Config struct {
+ LogLevel int `unic:"log_level"`
+ Servers struct {
+ Servers []struct {
+ Name string `unic:"name,attr=1"`
+ Port int `unic:"port"`
+ Host string `unic:"host"`
+ Auth map[string]string `unic:"auth,omitempty"`
+ } `unic:"server"`
+ } `unic:"servers"`
+}
+
+```
+
+### Сериализация нескольких структур в один файл
+
+`unic.Marshal` может принимать несколько аргументов – все они будут объединены в один документ. Если поля с одинаковыми именами присутствуют в разных
+структурах, они будут объединены (сложены) в один блок.
+
+```go
+package main
+
+type Part1 struct {
+ Common string `unic:"common"`
+ A int `unic:"a"`
+}
+type Part2 struct {
+ Common string `unic:"common"`
+ B bool `unic:"b"`
+}
+data, _ := unic.Marshal(Part1{Common:"shared", A:42}, Part2{Common:"shared", B:true})
+// Вывод:
+// common shared;
+// a 42;
+// b true;
+
+```
+
+---
+
+## ⚖️ Сравнение с другими форматами
+
+| Формат | Читаемость | Поддержка сложных структур | Комментарии | Производительность (Go) |
+|----------|------------|----------------------------|-------------|-----------------------------|
+| **UNIC** | ★★★★★ | ★★★★★ | ✔ | Высокая (рефлексия с кэшем) |
+| JSON | ★★★☆☆ | ★★★★☆ | ✘ | Очень высокая |
+| YAML | ★★★★☆ | ★★★★★ | ✔ | Средняя |
+| TOML | ★★★★☆ | ★★★☆☆ | ✔ | Средняя |
+| HCL | ★★★★☆ | ★★★★★ | ✔ | Средняя |
+
+**UNIC** предлагает лучший баланс между читаемостью и производительностью, особенно если вам нужны комментарии, атрибуты и гибкое объединение структур.
+
+---
+
+## 🤝 Внесение вклада
+
+Мы приветствуем ваши идеи и улучшения! Чтобы внести вклад:
+
+1. Форкните репозиторий.
+2. Создайте ветку для вашей фичи (`git checkout -b feature/amazing-feature`).
+3. Внесите изменения и напишите тесты.
+4. Убедитесь, что все линтеры и тесты проходят (`make pre-commit`).
+5. Отправьте пул-реквест.
+
+---
+
+## 📄 Лицензия
+
+Распространяется под лицензией **BSD 3-Clause**. Подробности в файле [LICENSE](LICENSE).
diff --git a/decode.go b/decode.go
new file mode 100644
index 0000000..ec34509
--- /dev/null
+++ b/decode.go
@@ -0,0 +1,542 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
+package unic
+
+import (
+ "fmt"
+ "reflect"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+type structMeta struct {
+ fields []*boundField
+ byName map[string]int
+}
+
+type boundField struct {
+ fieldTag
+ index []int
+ typ reflect.Type
+}
+
+func decodeValue(dv reflect.Value, n *node, path string) error {
+ if n == nil || n.kind == nodeEmpty {
+ return nil
+ }
+ dv, err := alloc(dv)
+ if err != nil {
+ return errAt(path, err)
+ }
+
+ if dv.Kind() == reflect.Interface && dv.NumMethod() == 0 {
+ var val any
+ val, err = n.asAny()
+ if err != nil {
+ return errAt(path, err)
+ }
+ dv.Set(reflect.ValueOf(val))
+ return nil
+ }
+
+ switch n.kind {
+ case nodeScalar:
+ return assignScalar(dv, n.scalar, path)
+ case nodeList:
+ return assignList(dv, n, path)
+ case nodeMap:
+ return assignMap(dv, n, path)
+ case nodeBlock:
+ return assignBlock(dv, n, path)
+ default:
+ return errAt(path, errUnknownNode)
+ }
+}
+
+func assignBlock(dv reflect.Value, n *node, path string) error {
+ switch dv.Kind() {
+ case reflect.Struct:
+ return decodeStruct(dv, n, path)
+ case reflect.Map:
+ return decodeBlockMap(dv, n, path)
+ case reflect.Slice, reflect.Array:
+ return decodeValue(dv, &node{kind: nodeList, list: []*node{n}, line: n.line, col: n.col}, path)
+ default:
+ return errAt(path, fmt.Errorf("cannot unmarshal block into %s", dv.Type()))
+ }
+}
+
+func decodeStruct(dv reflect.Value, n *node, path string) error {
+ meta, err := inspectStruct(dv.Type())
+ if err != nil {
+ return errAt(path, err)
+ }
+
+ if err = decodeStructAttrs(dv, n, meta, path); err != nil {
+ return err
+ }
+
+ groups, order := buildFieldGroups(n)
+ seen, err := decodeStructFields(dv, meta, groups, order, path)
+ if err != nil {
+ return err
+ }
+
+ return decodeStructRemaining(dv, meta, seen, path)
+}
+
+func decodeStructAttrs(dv reflect.Value, n *node, meta *structMeta, path string) error {
+ for _, f := range meta.fields {
+ if f.attr <= 0 {
+ continue
+ }
+ fv, err := fieldByIndex(dv, f.index)
+ if err != nil {
+ return errAt(joinPath(path, f.name), err)
+ }
+ if f.attr <= len(n.attrs) {
+ if err = decodeValue(fv, n.attrs[f.attr-1], joinPath(path, f.name)); err != nil {
+ return err
+ }
+ continue
+ }
+ if f.hasDefault {
+ if err = applyDefault(fv, f, joinPath(path, f.name)); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func buildFieldGroups(n *node) (map[string][]*node, []string) {
+ groups := make(map[string][]*node, len(n.fields))
+ order := make([]string, 0, len(n.fields))
+ for _, fl := range n.fields {
+ if _, ok := groups[fl.key]; !ok {
+ order = append(order, fl.key)
+ }
+ groups[fl.key] = append(groups[fl.key], fl.val)
+ }
+ return groups, order
+}
+
+func decodeStructFields(
+ dv reflect.Value, meta *structMeta, groups map[string][]*node,
+ order []string, path string,
+) (map[string]struct{}, error) {
+ seen := make(map[string]struct{}, len(order))
+ for _, key := range order {
+ idx, ok := meta.byName[key]
+ if !ok || meta.fields[idx].attr > 0 {
+ continue
+ }
+ f := meta.fields[idx]
+ seen[key] = struct{}{}
+ fv, err := fieldByIndex(dv, f.index)
+ if err != nil {
+ return nil, errAt(joinPath(path, key), err)
+ }
+ vals := groups[key]
+ if len(vals) == 1 && f.omitempty && isEmptyNode(vals[0]) {
+ continue
+ }
+ if isSliceOrArray(fv) && f.attr == 0 {
+ if err = decodeSliceField(fv, vals, joinPath(path, key)); err != nil {
+ return nil, err
+ }
+ continue
+ }
+ if len(vals) > 1 {
+ return nil, errAt(joinPath(path, key), errDuplicateField)
+ }
+ if err = decodeValue(fv, vals[0], joinPath(path, key)); err != nil {
+ return nil, err
+ }
+ }
+ return seen, nil
+}
+
+func decodeStructRemaining(
+ dv reflect.Value, meta *structMeta,
+ seen map[string]struct{}, path string,
+) error {
+ for _, f := range meta.fields {
+ if f.attr > 0 {
+ continue
+ }
+ if _, ok := seen[f.name]; ok {
+ continue
+ }
+
+ fv, err := fieldByIndex(dv, f.index)
+ if err != nil {
+ return errAt(joinPath(path, f.name), err)
+ }
+ if f.omitempty && !f.hasDefault {
+ continue
+ }
+ if f.hasDefault {
+ if err = applyDefault(fv, f, joinPath(path, f.name)); err != nil {
+ return err
+ }
+ continue
+ }
+ if structLike(fv.Type()) {
+ if err = decodeValue(fv, &node{kind: nodeBlock}, joinPath(path, f.name)); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func decodeSliceField(fv reflect.Value, vals []*node, path string) error {
+ if len(vals) == 1 && vals[0].kind == nodeList {
+ return decodeValue(fv, vals[0], path)
+ }
+
+ if fv.Kind() == reflect.Array {
+ if len(vals) > fv.Len() {
+ return errAt(path, errArrayManyValues)
+ }
+ for i, val := range vals {
+ if err := decodeValue(fv.Index(i), val, indexPath(path, i)); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+
+ sl := reflect.MakeSlice(fv.Type(), 0, len(vals))
+ for i, val := range vals {
+ el := reflect.New(fv.Type().Elem()).Elem()
+ if err := decodeValue(el, val, indexPath(path, i)); err != nil {
+ return err
+ }
+ sl = reflect.Append(sl, el)
+ }
+ fv.Set(sl)
+ return nil
+}
+
+func decodeBlockMap(dv reflect.Value, n *node, path string) error {
+ if dv.Type().Key().Kind() != reflect.String {
+ return errAt(path, errMapKeyString)
+ }
+ if dv.IsNil() {
+ dv.Set(reflect.MakeMapWithSize(dv.Type(), len(n.fields)))
+ }
+ elemType := dv.Type().Elem()
+ for _, fl := range n.fields {
+ el := reflect.New(elemType).Elem()
+ if err := decodeValue(el, fl.val, joinPath(path, fl.key)); err != nil {
+ return err
+ }
+ dv.SetMapIndex(reflect.ValueOf(fl.key), el)
+ }
+ return nil
+}
+
+func assignList(dv reflect.Value, n *node, path string) error {
+ switch dv.Kind() {
+ case reflect.Slice:
+ sl := reflect.MakeSlice(dv.Type(), len(n.list), len(n.list))
+ for i, item := range n.list {
+ if err := decodeValue(sl.Index(i), item, indexPath(path, i)); err != nil {
+ return err
+ }
+ }
+ dv.Set(sl)
+ return nil
+ case reflect.Array:
+ if len(n.list) > dv.Len() {
+ return errAt(path, errArrayManyValues)
+ }
+ for i, item := range n.list {
+ if err := decodeValue(dv.Index(i), item, indexPath(path, i)); err != nil {
+ return err
+ }
+ }
+ return nil
+ default:
+ if len(n.list) == 1 {
+ return decodeValue(dv, n.list[0], path)
+ }
+ return errAt(path, fmt.Errorf("cannot unmarshal list into %s", dv.Type()))
+ }
+}
+
+func assignMap(dv reflect.Value, n *node, path string) error {
+ if dv.Kind() != reflect.Map {
+ return errAt(path, fmt.Errorf("cannot unmarshal map into %s", dv.Type()))
+ }
+ if dv.IsNil() {
+ dv.Set(reflect.MakeMapWithSize(dv.Type(), len(n.pairs)))
+ }
+ kt, vt := dv.Type().Key(), dv.Type().Elem()
+ for i, p := range n.pairs {
+ key := reflect.New(kt).Elem()
+ if err := decodeValue(key, p.key, indexPath(path, i)+".key"); err != nil {
+ return err
+ }
+ val := reflect.New(vt).Elem()
+ if err := decodeValue(val, p.val, indexPath(path, i)+".val"); err != nil {
+ return err
+ }
+ dv.SetMapIndex(key, val)
+ }
+ return nil
+}
+
+func assignScalar(dv reflect.Value, s, path string) error {
+ switch dv.Kind() {
+ case reflect.String:
+ dv.SetString(s)
+ case reflect.Bool:
+ b, err := parseBool(s)
+ if err != nil {
+ return errAt(path, err)
+ }
+ dv.SetBool(b)
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ n, err := strconv.ParseInt(s, 10, dv.Type().Bits())
+ if err != nil {
+ return errAt(path, fmt.Errorf("invalid integer %q", s))
+ }
+ dv.SetInt(n)
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
+ n, err := strconv.ParseUint(s, 10, dv.Type().Bits())
+ if err != nil {
+ return errAt(path, fmt.Errorf("invalid unsigned integer %q", s))
+ }
+ dv.SetUint(n)
+ case reflect.Float32, reflect.Float64:
+ n, err := strconv.ParseFloat(s, dv.Type().Bits())
+ if err != nil {
+ return errAt(path, fmt.Errorf("invalid float %q", s))
+ }
+ dv.SetFloat(n)
+ default:
+ return errAt(path, fmt.Errorf("cannot unmarshal %q into %s", s, dv.Type()))
+ }
+ return nil
+}
+
+func applyDefault(fv reflect.Value, f *boundField, path string) error {
+ if isSliceOrArray(fv) {
+ parts := splitDefaultList(f.defaultVal)
+ items := make([]*node, 0, len(parts))
+ for _, p := range parts {
+ items = append(items, &node{kind: nodeScalar, scalar: p})
+ }
+ return decodeValue(fv, &node{kind: nodeList, list: items}, path)
+ }
+ return decodeValue(fv, &node{kind: nodeScalar, scalar: f.defaultVal}, path)
+}
+
+var cacheStructMeta sync.Map
+
+func inspectStruct(t reflect.Type) (*structMeta, error) {
+ v, ok := cacheStructMeta.Load(t)
+ if ok {
+ return v.(*structMeta), nil
+ }
+
+ meta := &structMeta{
+ byName: make(map[string]int, t.NumField()),
+ fields: make([]*boundField, 0, t.NumField()),
+ }
+ for i := 0; i < t.NumField(); i++ {
+ sf := t.Field(i)
+ if sf.PkgPath != "" && !sf.Anonymous {
+ continue
+ }
+ raw, ok := sf.Tag.Lookup("unic")
+ if !ok {
+ continue
+ }
+ ft, keep, err := parseUnicTag(raw)
+ if err != nil {
+ return nil, fmt.Errorf("field %s: %w", sf.Name, err)
+ }
+ if !keep {
+ continue
+ }
+ if !isExported(sf.Name) {
+ continue
+ }
+ bf := &boundField{fieldTag: ft, index: sf.Index, typ: sf.Type}
+ if _, exists := meta.byName[ft.name]; exists {
+ return nil, fmt.Errorf("duplicate unic tag %q", ft.name)
+ }
+ meta.fields = append(meta.fields, bf)
+ meta.byName[ft.name] = len(meta.fields) - 1
+ }
+
+ cacheStructMeta.Store(t, meta)
+
+ return meta, nil
+}
+
+func fieldByIndex(v reflect.Value, index []int) (reflect.Value, error) {
+ for i, x := range index {
+ if v.Kind() == reflect.Pointer {
+ if v.IsNil() {
+ if !v.CanSet() {
+ return reflect.Value{}, errCanSetEmbeddedPtr
+ }
+ v.Set(reflect.New(v.Type().Elem()))
+ }
+ v = v.Elem()
+ }
+ if v.Kind() != reflect.Struct {
+ return reflect.Value{}, errNotStruct
+ }
+ v = v.Field(x)
+ if i == len(index)-1 {
+ return alloc(v)
+ }
+ }
+ return v, nil
+}
+
+func alloc(v reflect.Value) (reflect.Value, error) {
+ for v.Kind() == reflect.Pointer {
+ if v.IsNil() {
+ if !v.CanSet() {
+ return reflect.Value{}, errCantAllocatePtr
+ }
+ v.Set(reflect.New(v.Type().Elem()))
+ }
+ v = v.Elem()
+ }
+ return v, nil
+}
+
+func (n *node) asAny() (any, error) {
+ switch n.kind {
+ case nodeScalar:
+ if i, err := strconv.ParseInt(n.scalar, 10, 64); err == nil {
+ return i, nil
+ }
+ if f, err := strconv.ParseFloat(n.scalar, 64); err == nil {
+ return f, nil
+ }
+ if b, err := parseBool(n.scalar); err == nil {
+ return b, nil
+ }
+ return n.scalar, nil
+ case nodeList:
+ out := make([]any, 0, len(n.list))
+ for _, item := range n.list {
+ v, err := item.asAny()
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, nil
+ case nodeMap:
+ out := make(map[string]any, len(n.pairs))
+ for _, p := range n.pairs {
+ k, err := p.key.asAny()
+ if err != nil {
+ return nil, err
+ }
+ ks, ok := k.(string)
+ if !ok {
+ ks = fmt.Sprint(k)
+ }
+ v, err := p.val.asAny()
+ if err != nil {
+ return nil, err
+ }
+ out[ks] = v
+ }
+ return out, nil
+ case nodeBlock:
+ out := make(map[string]any)
+ for _, f := range n.fields {
+ v, err := f.val.asAny()
+ if err != nil {
+ return nil, err
+ }
+ if prev, ok := out[f.key]; ok {
+ if sl, ok := prev.([]any); ok {
+ out[f.key] = append(sl, v)
+ } else {
+ out[f.key] = []any{prev, v}
+ }
+ } else {
+ out[f.key] = v
+ }
+ }
+ return out, nil
+ default:
+ return nil, errUnknownNode
+ }
+}
+
+func parseBool(s string) (bool, error) {
+ switch strings.ToLower(s) {
+ case "true", "1":
+ return true, nil
+ case "false", "0":
+ return false, nil
+ default:
+ return false, fmt.Errorf("invalid bool %q", s)
+ }
+}
+
+func isSliceOrArray(v reflect.Value) bool {
+ k := v.Kind()
+ return k == reflect.Slice || k == reflect.Array
+}
+
+func structLike(t reflect.Type) bool {
+ for t.Kind() == reflect.Pointer {
+ t = t.Elem()
+ }
+ return t.Kind() == reflect.Struct
+}
+
+func isEmptyNode(n *node) bool {
+ if n == nil || n.kind == nodeEmpty {
+ return true
+ }
+ switch n.kind {
+ case nodeScalar:
+ return n.scalar == ""
+ case nodeList:
+ return len(n.list) == 0
+ case nodeMap:
+ return len(n.pairs) == 0
+ case nodeBlock:
+ return len(n.fields) == 0 && len(n.attrs) == 0
+ default:
+ return false
+ }
+}
+
+func joinPath(base, name string) string {
+ if base == "" {
+ return name
+ }
+ return base + "." + name
+}
+
+func indexPath(base string, i int) string {
+ return base + "[" + strconv.FormatInt(int64(i), 10) + "]"
+}
+
+func errAt(path string, err error) error {
+ if path == "" {
+ return fmt.Errorf("unic: %w", err)
+ }
+ return fmt.Errorf("unic: %s: %w", path, err)
+}
diff --git a/decode_test.go b/decode_test.go
new file mode 100644
index 0000000..1f4fcfa
--- /dev/null
+++ b/decode_test.go
@@ -0,0 +1,78 @@
+package unic
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestUnit_FieldByIndexAndAlloc(t *testing.T) {
+ t.Parallel()
+ type inner struct{ X int }
+ type wrap struct{ Inner *inner }
+ var w wrap
+ fv, err := fieldByIndex(reflect.ValueOf(&w).Elem(), []int{0, 0})
+ if err != nil {
+ t.Fatal(err)
+ }
+ fv.SetInt(9)
+ if w.Inner == nil || w.Inner.X != 9 {
+ t.Fatalf("%+v", w)
+ }
+ if _, err = fieldByIndex(reflect.ValueOf(1), []int{0}); err == nil {
+ t.Fatal("expected not a struct")
+ }
+ var p *int
+ if _, err = alloc(reflect.ValueOf(p)); err == nil {
+ t.Fatal("expected unsettable pointer")
+ }
+}
+
+func TestUnit_DecodeUnknownNodeAndStructLikePointer(t *testing.T) {
+ t.Parallel()
+ var n int
+ if err := decodeValue(reflect.ValueOf(&n).Elem(), &node{kind: 99}, "n"); err == nil {
+ t.Fatal("unknown node")
+ }
+ type cfg struct {
+ Inner *struct {
+ X int `unic:"x,default=4"`
+ } `unic:"inner"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte(""), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Inner != nil {
+ t.Fatalf("%+v", got)
+ }
+}
+
+func TestUnit_HelpersEmptyNodeAndAsAny(t *testing.T) {
+ t.Parallel()
+ if !isEmptyNode(&node{}) {
+ t.Fatal("nil")
+ }
+ if !isEmptyNode(nil) {
+ t.Fatal("nil")
+ }
+ if !isEmptyNode(&node{kind: nodeMap}) || !isEmptyNode(&node{kind: nodeBlock}) {
+ t.Fatal("empty containers")
+ }
+ if isEmptyNode(&node{kind: 99}) {
+ t.Fatal("unknown")
+ }
+ if _, err := (&node{kind: 99}).asAny(); err == nil {
+ t.Fatal("asAny unknown")
+ }
+ if err := decodeValue(reflect.ValueOf(0), nil, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := errAt("", ioStrError("x")); err == nil || !strings.Contains(err.Error(), "unic:") {
+ t.Fatalf("%v", err)
+ }
+}
+
+type ioStrError string
+
+func (e ioStrError) Error() string { return string(e) }
diff --git a/decoder.go b/decoder.go
deleted file mode 100644
index 7ed573c..0000000
--- a/decoder.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package unic
-
-import (
- "io"
-
- "go.osspkg.com/unic/internal/decode"
- "go.osspkg.com/unic/internal/node"
-)
-
-type Decoder struct {
- root *node.Block
-}
-
-func NewDecoder(r io.Reader) (*Decoder, error) {
- parser := decode.New(r)
- if err := parser.Decode(); err != nil {
- return nil, err
- }
- dec := &Decoder{root: parser.GetBlock()}
- return dec, nil
-}
-
-func (dec *Decoder) Decode(v interface{}) error {
- return nil
-}
diff --git a/encode.go b/encode.go
new file mode 100644
index 0000000..86cda18
--- /dev/null
+++ b/encode.go
@@ -0,0 +1,577 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
+package unic
+
+import (
+ "fmt"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "unicode"
+
+ "go.osspkg.com/bb"
+)
+
+type encoder struct {
+ buf *bb.Buffer
+ depth int
+}
+
+//nolint:unused
+func (e *encoder) writeRoot(v reflect.Value, path string) error {
+ v = marshalDeref(v)
+ if !v.IsValid() {
+ return fmt.Errorf("unic: Marshal(nil)")
+ }
+ switch v.Kind() {
+ case reflect.Struct:
+ return e.writeStructBody(v, path)
+ case reflect.Map:
+ return e.writeMapFields(v, path)
+ default:
+ return fmt.Errorf("unic: Marshal(unsupported type %s)", v.Type())
+ }
+}
+
+func (e *encoder) writeStructBody(v reflect.Value, path string) error {
+ meta, err := inspectStruct(v.Type())
+ if err != nil {
+ return errAt(path, err)
+ }
+ for _, f := range meta.fields {
+ if f.attr > 0 {
+ continue
+ }
+ fv := valueAt(v, f.index)
+ if f.omitempty && isEmptyValue(fv) {
+ continue
+ }
+ if err = e.writeNamed(f, fv, joinPath(path, f.name)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (e *encoder) writeNamed(f *boundField, v reflect.Value, path string) (err error) {
+ v = marshalDeref(v)
+ if isStructSlice(v) {
+ for i := 0; i < v.Len(); i++ {
+ if err = e.writeBlock(f.name, v.Index(i), f.desc, joinPath(path, f.name)); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ // Только структуры и карты со значениями-структурами/картами идут в блок
+ if v.IsValid() && (v.Kind() == reflect.Struct || (v.Kind() == reflect.Map && mapAsBlock(v))) {
+ return e.writeBlock(f.name, v, f.desc, path)
+ }
+
+ if err = e.writeIndent(); err != nil {
+ return err
+ }
+ if err = e.writeToken(f.name); err != nil {
+ return err
+ }
+ if err = e.writeByte(' '); err != nil {
+ return err
+ }
+ if err = e.writeInline(v, path); err != nil {
+ return err
+ }
+ if err = e.writeByte(';'); err != nil {
+ return err
+ }
+ if err = e.writeDesc(f.desc); err != nil {
+ return err
+ }
+ return e.writeByte('\n')
+}
+
+func (e *encoder) writeEmptyBlock(name string) (err error) {
+ if err = e.writeIndent(); err != nil {
+ return err
+ }
+ if err = e.writeToken(name); err != nil {
+ return err
+ }
+ if err = e.writeString(" {}"); err != nil {
+ return err
+ }
+ return e.writeByte('\n')
+}
+
+func (e *encoder) writeMergedBlock(name string, vals []interface{}, path string) (err error) {
+ if err = e.writeIndent(); err != nil {
+ return err
+ }
+ if err = e.writeToken(name); err != nil {
+ return err
+ }
+ if err = e.writeString(" {"); err != nil {
+ return err
+ }
+ if err = e.writeByte('\n'); err != nil {
+ return err
+ }
+ e.depth++
+ seen := make(map[string]struct{}, len(vals)*2)
+ for _, val := range vals {
+ v := reflect.ValueOf(val)
+ v = marshalDeref(v)
+ if !v.IsValid() || v.Kind() != reflect.Struct {
+ continue
+ }
+ var meta *structMeta
+ meta, err = inspectStruct(v.Type())
+ if err != nil {
+ return errAt(path, err)
+ }
+ for _, f := range meta.fields {
+ if f.attr > 0 {
+ continue
+ }
+ if _, ok := seen[f.name]; ok {
+ continue
+ }
+ fv := valueAt(v, f.index)
+ if f.omitempty && isEmptyValue(fv) {
+ continue
+ }
+ if err = e.writeNamed(f, fv, joinPath(path, f.name)); err != nil {
+ return err
+ }
+ seen[f.name] = struct{}{}
+ }
+ }
+ e.depth--
+ if err = e.writeIndent(); err != nil {
+ return err
+ }
+ return e.writeString("}\n")
+}
+
+func (e *encoder) writeBlock(name string, v reflect.Value, desc, path string) (err error) {
+ v = marshalDeref(v)
+ if !v.IsValid() || (v.Kind() == reflect.Ptr && v.IsNil()) {
+ if err = e.writeIndent(); err != nil {
+ return err
+ }
+ if err = e.writeToken(name); err != nil {
+ return err
+ }
+ if err = e.writeString(" {}"); err != nil {
+ return err
+ }
+ if err = e.writeDesc(desc); err != nil {
+ return err
+ }
+ return e.writeByte('\n')
+ }
+
+ if err = e.writeIndent(); err != nil {
+ return err
+ }
+ if err = e.writeToken(name); err != nil {
+ return err
+ }
+ if v.Kind() == reflect.Struct {
+ if err = e.writeAttrs(v, path); err != nil {
+ return err
+ }
+ }
+ if err = e.writeString(" {"); err != nil {
+ return err
+ }
+ if err = e.writeDesc(desc); err != nil {
+ return err
+ }
+ if err = e.writeByte('\n'); err != nil {
+ return err
+ }
+ e.depth++
+ switch v.Kind() {
+ case reflect.Struct:
+ err = e.writeStructBody(v, path)
+ case reflect.Map:
+ err = e.writeMapFields(v, path)
+ default:
+ err = errAt(path, fmt.Errorf("cannot marshal %s as block", v.Type()))
+ }
+ e.depth--
+ if err != nil {
+ return err
+ }
+ if err = e.writeIndent(); err != nil {
+ return err
+ }
+ return e.writeString("}\n")
+}
+
+func (e *encoder) writeAttrs(v reflect.Value, path string) error {
+ meta, err := inspectStruct(v.Type())
+ if err != nil {
+ return errAt(path, err)
+ }
+ maxIdx := 0
+ byAttr := make(map[int]*boundField, 2)
+ for _, f := range meta.fields {
+ if f.attr > 0 {
+ byAttr[f.attr] = f
+ if f.attr > maxIdx {
+ maxIdx = f.attr
+ }
+ }
+ }
+ for i := 1; i <= maxIdx; i++ {
+ f, ok := byAttr[i]
+ if !ok {
+ continue
+ }
+ fv := marshalDeref(valueAt(v, f.index))
+ if f.omitempty && isEmptyValue(fv) {
+ continue
+ }
+ var atom string
+ if atom, err = formatAtom(fv); err != nil {
+ return errAt(joinPath(path, f.name), err)
+ }
+ if err = e.writeByte(' '); err != nil {
+ return err
+ }
+ if err = e.writeString(atom); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (e *encoder) writeMapFields(v reflect.Value, path string) error {
+ keys, err := sortedMapKeys(v)
+ if err != nil {
+ return errAt(path, err)
+ }
+ for _, k := range keys {
+ var name string
+ if name, err = mapFieldName(k); err != nil {
+ return errAt(path, err)
+ }
+ fv := v.MapIndex(k)
+ fv = marshalDeref(fv)
+ if fv.IsValid() && (fv.Kind() == reflect.Struct || fv.Kind() == reflect.Map) {
+ if err = e.writeBlock(name, fv, "", joinPath(path, name)); err != nil {
+ return err
+ }
+ } else {
+ f := &boundField{fieldTag: fieldTag{name: name}}
+ if err = e.writeNamed(f, v.MapIndex(k), joinPath(path, name)); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func mapFieldName(k reflect.Value) (string, error) {
+ k = marshalDeref(k)
+ if k.Kind() == reflect.String {
+ return k.String(), nil
+ }
+ return formatAtom(k)
+}
+
+func (e *encoder) writeInline(v reflect.Value, path string) error {
+ v = marshalDeref(v)
+ if !v.IsValid() {
+ return e.writeString("''")
+ }
+ switch v.Kind() {
+ case reflect.Slice, reflect.Array:
+ return e.writeList(v, path)
+ case reflect.Map:
+ return e.writeMapPairs(v, path)
+ case reflect.Struct:
+ return errAt(path, errNestedStructBlock)
+ default:
+ s, err := formatAtom(v)
+ if err != nil {
+ return errAt(path, err)
+ }
+ return e.writeString(s)
+ }
+}
+
+func (e *encoder) writeList(v reflect.Value, path string) (err error) {
+ if err = e.writeByte('['); err != nil {
+ return err
+ }
+ for i := 0; i < v.Len(); i++ {
+ if i > 0 {
+ if err = e.writeString(", "); err != nil {
+ return err
+ }
+ }
+ if err = e.writeInline(v.Index(i), indexPath(path, i)); err != nil {
+ return err
+ }
+ }
+ return e.writeByte(']')
+}
+
+func (e *encoder) writeMapPairs(v reflect.Value, path string) (err error) {
+ if err = e.writeByte('('); err != nil {
+ return err
+ }
+ var keys []reflect.Value
+ if keys, err = sortedMapKeys(v); err != nil {
+ return err
+ }
+ for i, k := range keys {
+ if i > 0 {
+ if err = e.writeString(", "); err != nil {
+ return err
+ }
+ }
+ var ks string
+
+ if ks, err = formatAtom(k); err != nil {
+ return errAt(path, err)
+ }
+ if err = e.writeString(ks); err != nil {
+ return err
+ }
+ if err = e.writeString(", "); err != nil {
+ return err
+ }
+ val := marshalDeref(v.MapIndex(k))
+ if val.IsValid() && val.Kind() == reflect.Struct {
+ if err = e.writeInlineBlock(val, joinPath(path, ks)); err != nil {
+ return err
+ }
+ } else if err = e.writeInline(val, joinPath(path, ks)); err != nil {
+ return err
+ }
+ }
+ return e.writeByte(')')
+}
+
+func (e *encoder) writeInlineBlock(v reflect.Value, path string) (err error) {
+ if err = e.writeByte(' '); err != nil {
+ return err
+ }
+ if err = e.writeByte('{'); err != nil {
+ return err
+ }
+ e.depth++
+ if err = e.writeByte('\n'); err != nil {
+ return err
+ }
+ var meta *structMeta
+ if meta, err = inspectStruct(v.Type()); err != nil {
+ return errAt(path, err)
+ }
+ for _, f := range meta.fields {
+ if f.attr > 0 {
+ continue
+ }
+ fv := valueAt(v, f.index)
+ if f.omitempty && isEmptyValue(fv) {
+ continue
+ }
+ if err = e.writeNamed(f, fv, joinPath(path, f.name)); err != nil {
+ return err
+ }
+ }
+ e.depth--
+ if err = e.writeIndent(); err != nil {
+ return err
+ }
+ return e.writeByte('}')
+}
+
+func (e *encoder) writeDesc(desc string) error {
+ if len(desc) == 0 {
+ return nil
+ }
+ desc = sanitizeDesc(desc)
+ if len(desc) == 0 {
+ return nil
+ }
+ if err := e.writeString(" # "); err != nil {
+ return err
+ }
+ return e.writeString(desc)
+}
+
+func (e *encoder) writeIndent() (err error) {
+ for i := 0; i < e.depth; i++ {
+ if err = e.writeByte('\t'); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (e *encoder) writeToken(s string) error {
+ return e.writeString(quoteValue(s))
+}
+
+func (e *encoder) writeString(s string) error {
+ _, err := e.buf.WriteString(s)
+ return err
+}
+
+func (e *encoder) writeByte(c byte) error {
+ return e.buf.WriteByte(c)
+}
+
+func mapAsBlock(v reflect.Value) bool {
+ t := v.Type().Elem()
+ for t.Kind() == reflect.Pointer {
+ t = t.Elem()
+ }
+ return t.Kind() == reflect.Struct || t.Kind() == reflect.Map
+}
+
+func isStructSlice(v reflect.Value) bool {
+ if !v.IsValid() || (v.Kind() != reflect.Slice && v.Kind() != reflect.Array) {
+ return false
+ }
+ t := v.Type().Elem()
+ for t.Kind() == reflect.Pointer {
+ t = t.Elem()
+ }
+ return t.Kind() == reflect.Struct
+}
+
+func marshalDeref(v reflect.Value) reflect.Value {
+ for v.IsValid() && (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) {
+ if v.IsNil() {
+ return reflect.Value{}
+ }
+ v = v.Elem()
+ }
+ return v
+}
+
+func valueAt(v reflect.Value, index []int) reflect.Value {
+ for _, i := range index {
+ v = marshalDeref(v)
+ if !v.IsValid() || v.Kind() != reflect.Struct {
+ return reflect.Value{}
+ }
+ v = v.Field(i)
+ }
+ return v
+}
+
+func isEmptyValue(v reflect.Value) bool {
+ v = marshalDeref(v)
+ if !v.IsValid() {
+ return true
+ }
+ return v.IsZero()
+}
+
+func sortedMapKeys(v reflect.Value) ([]reflect.Value, error) {
+ keys := v.MapKeys()
+ var first error
+ sort.Slice(keys, func(i, j int) bool {
+ a, err1 := formatAtom(keys[i])
+ b, err2 := formatAtom(keys[j])
+ if err1 != nil {
+ first = err1
+ return false
+ }
+ if err2 != nil {
+ first = err2
+ return true
+ }
+ return a < b
+ })
+ return keys, first
+}
+
+func formatAtom(v reflect.Value) (string, error) {
+ v = marshalDeref(v)
+ if !v.IsValid() {
+ return "''", nil
+ }
+ switch v.Kind() {
+ case reflect.String:
+ return quoteValue(v.String()), nil
+ case reflect.Bool:
+ if v.Bool() {
+ return "true", nil
+ }
+ return "false", nil
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ return strconv.FormatInt(v.Int(), 10), nil
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
+ return strconv.FormatUint(v.Uint(), 10), nil
+ case reflect.Float32, reflect.Float64:
+ return strconv.FormatFloat(v.Float(), 'g', -1, v.Type().Bits()), nil
+ default:
+ return "", fmt.Errorf("cannot marshal %s as scalar", v.Type())
+ }
+}
+
+func quoteValue(s string) string {
+ if s == "" {
+ return "''"
+ }
+ var hasSingle, hasDouble, hasTick, hasNL, hasMetaChar bool
+ for _, r := range s {
+ switch r {
+ case '\'':
+ hasSingle = true
+ case '"':
+ hasDouble = true
+ case '`':
+ hasTick = true
+ case '\n', '\r':
+ hasNL = true
+ goto LabelEnd
+ default:
+ if unicode.IsSpace(r) || strings.ContainsRune("{}[]()#;,", r) {
+ hasMetaChar = true
+ }
+ }
+ }
+LabelEnd:
+ if !hasSingle && !hasDouble && !hasTick && !hasNL && !hasMetaChar {
+ return s
+ }
+ if hasNL || hasTick || (hasSingle && hasDouble) {
+ return "```" + s + "```"
+ }
+ if hasSingle {
+ return `"` + s + `"`
+ }
+ return "'" + s + "'"
+}
+
+//nolint:unused
+func hasMeta(s string) bool {
+ for _, r := range s {
+ if unicode.IsSpace(r) {
+ return true
+ }
+ switch r {
+ case '{', '}', '[', ']', '(', ')', '#', ';', ',', '"', '\'':
+ return true
+ }
+ }
+ return false
+}
+
+func sanitizeDesc(s string) string {
+ s = strings.ReplaceAll(s, "\r", " ")
+ s = strings.ReplaceAll(s, "\n", " ")
+ return strings.TrimSpace(s)
+}
diff --git a/encode_test.go b/encode_test.go
new file mode 100644
index 0000000..3db5082
--- /dev/null
+++ b/encode_test.go
@@ -0,0 +1,350 @@
+package unic
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ "go.osspkg.com/bb"
+)
+
+func TestUnit_MarshalUnsupportedAndNilPointer(t *testing.T) {
+ t.Parallel()
+ if _, err := Marshal(1); err == nil {
+ t.Fatal("int")
+ }
+ var p *struct {
+ A int `unic:"a"`
+ }
+ if _, err := Marshal(p); err == nil {
+ t.Fatal("nil pointer")
+ }
+ var i any
+ if _, err := Marshal(i); err == nil {
+ t.Fatal("nil interface")
+ }
+}
+
+func TestUnit_MarshalPointerAndTopMap(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ A int `unic:"a"`
+ }
+ v := &cfg{A: 2}
+ data, err := Marshal(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), "a 2;") {
+ t.Fatalf("%s", data)
+ }
+ data, err = Marshal(map[string]int{"b": 3, "a": 1})
+ if err == nil {
+ t.Fatal("marshaling map got no error")
+ }
+ if data != nil {
+ t.Fatalf("marshaling map got result:%s", data)
+ }
+}
+
+func TestUnit_MarshalMapIntKeysAndBlockMap(t *testing.T) {
+ t.Parallel()
+ type inner struct {
+ X int `unic:"x"`
+ }
+ type cfg struct {
+ M map[string]inner `unic:"m"`
+ P map[string]int `unic:"p"`
+ U uint16 `unic:"u"`
+ F float32 `unic:"f"`
+ B bool `unic:"b"`
+ S string `unic:"s"`
+ E string `unic:"e"`
+ L []int `unic:"l"`
+ }
+ data, err := Marshal(cfg{
+ M: map[string]inner{"z": {X: 1}, "a": {X: 2}},
+ P: map[string]int{},
+ U: 4,
+ F: 1.5,
+ B: false,
+ S: "has {brace}",
+ E: "",
+ L: nil,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := string(data)
+ if !strings.Contains(out, "m {") || !strings.Contains(out, "a {") || !strings.Contains(out, "x 2;") {
+ t.Fatalf("block map: %q", out)
+ }
+ if !strings.Contains(out, "p ();") {
+ t.Fatalf("empty map: %q", out)
+ }
+ if !strings.Contains(out, "u 4;") || !strings.Contains(out, "f 1.5;") || !strings.Contains(out, "b false;") {
+ t.Fatalf("scalars: %q", out)
+ }
+ if !strings.Contains(out, "s 'has {brace}';") {
+ t.Fatalf("quote: %q", out)
+ }
+ if !strings.Contains(out, "e '';") {
+ t.Fatalf("empty string: %q", out)
+ }
+ if !strings.Contains(out, "l [];") {
+ t.Fatalf("nil slice: %q", out)
+ }
+
+ data, err = Marshal(map[int]int{2: 20, 1: 10})
+ if err == nil {
+ t.Fatal("marshaling map got no error")
+ }
+ if data != nil {
+ t.Fatalf("marshaling map got result: %q", string(data))
+ }
+}
+
+func TestUnit_MarshalQuotesSpecialAndBacktick(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ Space string `unic:"space"`
+ Tick string `unic:"tick"`
+ Both string `unic:"both"`
+ Hash string `unic:"hash"`
+ }
+ data, err := Marshal(cfg{
+ Space: "a b",
+ Tick: "x`y",
+ Both: `it's "ok"`,
+ Hash: "a#b",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got cfg
+ if err := Unmarshal(data, &got); err != nil {
+ t.Fatalf("%v\n%s", err, data)
+ }
+ if got.Space != "a b" || got.Tick != "x`y" || got.Both != `it's "ok"` || got.Hash != "a#b" {
+ t.Fatalf("%+v\n%s", got, data)
+ }
+}
+
+func TestUnit_MarshalAttrOmitemptyAndDesc(t *testing.T) {
+ t.Parallel()
+ type item struct {
+ Tag string `unic:"tag,attr=1,omitempty"`
+ Alt string `unic:"alt,attr=2"`
+ N int `unic:"n,desc='num'"`
+ }
+ type cfg struct {
+ Items []item `unic:"item,desc='блок'"`
+ }
+ data, err := Marshal(cfg{Items: []item{
+ {Tag: "", Alt: "x", N: 1},
+ {Tag: "web", Alt: "y", N: 2},
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := string(data)
+ if !strings.Contains(out, "item x { # блок") && !strings.Contains(out, "item x {") {
+ t.Fatalf("attr skip:\n%s", out)
+ }
+ if !strings.Contains(out, "n 1; # num") {
+ t.Fatalf("desc:\n%s", out)
+ }
+ if !strings.Contains(out, "item web x {") && !strings.Contains(out, "item web y {") {
+ t.Fatalf("attrs:\n%s", out)
+ }
+}
+
+func TestUnit_MarshalNestedListAndInvalidField(t *testing.T) {
+ t.Parallel()
+ type ok struct {
+ Nest [][]int `unic:"nest"`
+ }
+ data, err := Marshal(ok{Nest: [][]int{{1}, {2, 3}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), "nest [[1], [2, 3]];") {
+ t.Fatalf("%s", data)
+ }
+ type bad struct {
+ C chan int `unic:"c"`
+ }
+ if _, err := Marshal(bad{C: make(chan int)}); err == nil {
+ t.Fatal("expected chan error")
+ }
+}
+
+func TestUnit_MarshalSliceOfStructPointers(t *testing.T) {
+ t.Parallel()
+ type item struct {
+ N int `unic:"n"`
+ }
+ type cfg struct {
+ Items []*item `unic:"item"`
+ }
+ data, err := Marshal(cfg{Items: []*item{{N: 1}, {N: 2}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got cfg
+ if err := Unmarshal(data, &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Items) != 2 || got.Items[0].N != 1 || got.Items[1].N != 2 {
+ t.Fatalf("%+v\n%s", got, data)
+ }
+}
+
+func TestUnit_QuoteValueAndHasMeta(t *testing.T) {
+ t.Parallel()
+ if quoteValue("") != "''" {
+ t.Fatal("empty")
+ }
+ if quoteValue("plain") != "plain" {
+ t.Fatal("plain")
+ }
+ if quoteValue("a b") != "'a b'" {
+ t.Fatal("space")
+ }
+ if quoteValue(`say "hi"`) != `'say "hi"'` {
+ t.Fatal("double")
+ }
+ if quoteValue("it's") != `"it's"` {
+ t.Fatal("single")
+ }
+ if quoteValue("it's \"x\"") != "```it's \"x\"```" {
+ t.Fatal("both")
+ }
+ if !hasMeta(";") || hasMeta("ok") {
+ t.Fatal("meta")
+ }
+}
+
+func TestUnit_SanitizeDesc(t *testing.T) {
+ t.Parallel()
+ b := sanitizeDesc(" a\nb\r ")
+ if b != "a b" {
+ t.Fatalf("%q", b)
+ }
+}
+
+func TestUnit_FormatAtomKinds(t *testing.T) {
+ t.Parallel()
+ s, err := formatAtom(reflect.ValueOf("x"))
+ if err != nil || s != "x" {
+ t.Fatal(s, err)
+ }
+ s, err = formatAtom(reflect.ValueOf(true))
+ if err != nil || s != "true" {
+ t.Fatal(s, err)
+ }
+ s, err = formatAtom(reflect.ValueOf(uint(3)))
+ if err != nil || s != "3" {
+ t.Fatal(s, err)
+ }
+ if _, err = formatAtom(reflect.ValueOf([]int{1})); err == nil {
+ t.Fatal("slice atom")
+ }
+ if !isEmptyValue(reflect.Value{}) {
+ t.Fatal("invalid")
+ }
+}
+
+func TestUnit_MarshalNilStructPointerFieldAndNilSliceElem(t *testing.T) {
+ t.Parallel()
+ type item struct {
+ N int `unic:"n"`
+ }
+ type cfg struct {
+ Inner *item `unic:"inner"`
+ Items []*item `unic:"item"`
+ }
+ data, err := Marshal(cfg{Items: []*item{nil, {N: 1}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := string(data)
+ if !strings.Contains(out, "inner '';") && !strings.Contains(out, "inner {") {
+ t.Fatalf("fail1: %s", out)
+ }
+ if !strings.Contains(out, "item {") {
+ t.Fatalf("fail2: %s", out)
+ }
+}
+
+func TestUnit_MarshalAttrGapAndMapBlockPointer(t *testing.T) {
+ t.Parallel()
+ type item struct {
+ Alt string `unic:"alt,attr=2"`
+ N int `unic:"n"`
+ }
+ type inner struct {
+ X int `unic:"x"`
+ }
+ type cfg struct {
+ Item item `unic:"item"`
+ M map[string]*inner `unic:"m"`
+ }
+ data, err := Marshal(cfg{
+ Item: item{Alt: "z", N: 1},
+ M: map[string]*inner{"k": {X: 3}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), "item z {") {
+ t.Fatalf("%s", data)
+ }
+ if !strings.Contains(string(data), "m {") || !strings.Contains(string(data), "x 3;") {
+ t.Fatalf("%s", data)
+ }
+}
+
+func TestUnit_MarshalErrorsOnBadMapKeyAndInlineStruct(t *testing.T) {
+ t.Parallel()
+ if _, err := Marshal(map[[2]int]int{{1, 2}: 3}); err == nil {
+ t.Fatal("array map key")
+ }
+ type cfg struct {
+ L []any `unic:"l"`
+ }
+ if _, err := Marshal(cfg{L: []any{struct {
+ X int `unic:"x"`
+ }{X: 1}}}); err == nil {
+ t.Fatal("inline struct")
+ }
+ e := &encoder{buf: bb.New(16)}
+ if err := e.writeBlock("n", reflect.ValueOf(1), "", "n"); err == nil {
+ t.Fatal("block int")
+ }
+}
+
+func TestUnit_WriteInlineNilAndMapPairsError(t *testing.T) {
+ t.Parallel()
+ e := &encoder{buf: bb.New(32)}
+ if err := e.writeInline(reflect.Value{}, "x"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestUnit_MapAsBlockAndStructSlice(t *testing.T) {
+ t.Parallel()
+ if isStructSlice(reflect.ValueOf(0)) {
+ t.Fatal("int")
+ }
+ if !isStructSlice(reflect.ValueOf([]struct{ A int }{{}})) {
+ t.Fatal("slice struct")
+ }
+ m := map[string]struct{ A int }{}
+ if !mapAsBlock(reflect.ValueOf(m)) {
+ t.Fatal("map block")
+ }
+ if mapAsBlock(reflect.ValueOf(map[string]int{})) {
+ t.Fatal("map pairs")
+ }
+}
diff --git a/encoder.go b/encoder.go
deleted file mode 100644
index 585a9e0..0000000
--- a/encoder.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package unic
-
-import "io"
-
-type Encoder struct {
- w io.Writer
-}
-
-func NewEncoder(w io.Writer) *Encoder {
- return &Encoder{w: w}
-}
-
-func (e *Encoder) Done() error {
- return nil
-}
-
-func (e *Encoder) Encode(v interface{}) error {
- return nil
-}
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..03ac850
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
+package unic
+
+import "errors"
+
+//nolint:unused
+var (
+ errUnknownNode = errors.New("unknown node")
+ errDuplicateField = errors.New("duplicate field")
+ errArrayManyValues = errors.New("too many values for array")
+ errMapKeyString = errors.New("map key must be string")
+ errCanSetEmbeddedPtr = errors.New("cannot set embedded pointer")
+ errNotStruct = errors.New("not a struct")
+ errNotMaps = errors.New("not a maps")
+ errCantAllocatePtr = errors.New("cannot allocate pointer")
+ errNestedStructBlock = errors.New("nested struct must be a block")
+)
diff --git a/go.mod b/go.mod
index 0a197cd..3b1a57c 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
module go.osspkg.com/unic
-go 1.22.5
+go 1.26.0
+
+require go.osspkg.com/bb v1.0.1
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..6edd2d6
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+go.osspkg.com/bb v1.0.1 h1:AWyt2WUyHcyxbkAbiKHo69IPbm4Cvt9fWAcU0rpyRAs=
+go.osspkg.com/bb v1.0.1/go.mod h1:bh7v2dIXJC0ge8BZLsh10EFRbJSLjXCmxq4IwSogc58=
diff --git a/internal/decode/decode.go b/internal/decode/decode.go
deleted file mode 100644
index 0326338..0000000
--- a/internal/decode/decode.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package decode
-
-import (
- "bufio"
- "fmt"
- "io"
-
- "go.osspkg.com/unic/internal/dict"
- "go.osspkg.com/unic/internal/node"
- "go.osspkg.com/unic/internal/splitter"
-)
-
-const (
- caseOpen = iota + 1
- caseValue
- caseAttach
- caseDeattach
- caseClose
-)
-
-type Decoder struct {
- scanner *bufio.Scanner
- block *node.Block
- comment bool
-}
-
-func New(r io.Reader) *Decoder {
- scanner := bufio.NewScanner(r)
- scanner.Split(splitter.Func)
- return &Decoder{
- scanner: scanner,
- block: node.NewBlock(),
- comment: false,
- }
-}
-
-func (v *Decoder) GetBlock() *node.Block {
- return v.block
-}
-
-func (v *Decoder) Decode() error {
- next := caseOpen
-
- for v.scanner.Scan() {
- data := v.scanner.Text()
-
- if v.ignore(data) {
- continue
- }
-
- switch data {
- case dict.KeyEnd:
- next = caseClose
- case dict.BlockOpen:
- next = caseAttach
- case dict.BlockClose:
- next = caseDeattach
- }
-
- switch next {
- case caseOpen:
- v.block = v.block.NextBlock()
- v.block.Key().Set(data)
- next = caseValue
- case caseClose:
- v.block = v.block.PreviousBlock()
- next = caseOpen
- case caseValue:
- v.block.Key().Set(data)
- case caseAttach:
- next = caseOpen
- case caseDeattach:
- v.block = v.block.PreviousBlock()
- next = caseOpen
- }
- }
-
- if !v.block.IsRoot() {
- return fmt.Errorf("not all closing brackets of the block were found")
- }
-
- return nil
-}
-
-func (v *Decoder) ignore(data string) bool {
- if data == dict.NewLine && v.comment {
- v.comment = false
- return true
- }
- if data == dict.Comment && !v.comment {
- v.comment = true
- return true
- }
- if v.comment || data == dict.NewLine {
- return true
- }
-
- return false
-}
diff --git a/internal/dict/dict.go b/internal/dict/dict.go
deleted file mode 100644
index bcff2a2..0000000
--- a/internal/dict/dict.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package dict
-
-import "unicode/utf8"
-
-const (
- Comment = "#"
- NewLine = "\n"
- Space = " "
- KeyEnd = ";"
- BlockOpen = "{"
- BlockClose = "}"
- Apostrophe = "`"
-)
-
-func IsMultiline(data []byte) bool {
- start := 0
- for width := 0; start < len(data); start += width {
- var r rune
- r, width = utf8.DecodeRune(data[start:])
- if IsSkipChar(r) {
- return true
- }
- }
- return false
-}
-
-func IsRawChar(r rune) bool {
- switch r {
- case '`':
- return true
- }
- return false
-}
-
-func IsStopChar(r rune) bool {
- switch r {
- case '{', '}', '#', ';', '\n':
- return true
- }
- return false
-}
-
-func IsSkipChar(r rune) bool {
- if r <= '\u00FF' {
- switch r {
- case ' ', '\t', '\v', '\f', '\r':
- return true
- case '\u0085', '\u00A0':
- return true
- }
- return false
- }
- if '\u2000' <= r && r <= '\u200a' {
- return true
- }
- switch r {
- case '\u1680', '\u2028', '\u2029', '\u202f', '\u205f', '\u3000':
- return true
- }
- return false
-}
diff --git a/internal/encode/encode.go b/internal/encode/encode.go
deleted file mode 100644
index ba7218f..0000000
--- a/internal/encode/encode.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package encode
-
-import (
- "bytes"
- "io"
-
- "go.osspkg.com/unic/internal/node"
-)
-
-type Encoder struct {
- w io.Writer
-}
-
-func New(w io.Writer) *Encoder {
- return &Encoder{
- w: w,
- }
-}
-
-func (v *Encoder) Encode(b *node.Block) error {
- buff := bytes.NewBuffer(nil)
- for _, c := range b.Root().Child() {
- node.DrawBlock(buff, 0, c)
- }
- _, err := buff.WriteTo(v.w)
- return err
-}
diff --git a/internal/node/block.go b/internal/node/block.go
deleted file mode 100644
index e935f5c..0000000
--- a/internal/node/block.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package node
-
-type Block struct {
- key *Key
- child []*Block
- parent *Block
-}
-
-func NewBlock() *Block {
- return &Block{
- key: NewKey(),
- child: nil,
- parent: nil,
- }
-}
-
-func (v *Block) IsRoot() bool {
- return v.parent == nil
-}
-
-func (v *Block) HasChild() bool {
- return len(v.child) > 0
-}
-
-func (v *Block) Root() (b *Block) {
- b = v
- for {
- if b.parent == nil {
- break
- }
- b = v.parent
- }
- return
-}
-
-func (v *Block) PreviousBlock() (b *Block) {
- b = v
- if b.parent != nil {
- b = v.parent
- }
- return
-}
-
-func (v *Block) NextBlock() *Block {
- b := NewBlock()
- b.parent = v
- v.child = append(v.child, b)
- return b
-}
-
-func (v *Block) Key() *Key {
- return v.key
-}
-
-func (v *Block) Child() []*Block {
- return v.child
-}
diff --git a/internal/node/draw.go b/internal/node/draw.go
deleted file mode 100644
index e2d5be2..0000000
--- a/internal/node/draw.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package node
-
-import (
- "io"
- "strings"
-
- "go.osspkg.com/unic/internal/dict"
-)
-
-func DrawKey(w io.StringWriter, k *Key) {
- w.WriteString(k.key) //nolint:errcheck
- for _, value := range k.values {
- if dict.IsMultiline([]byte(value)) {
- w.WriteString(dict.Space) //nolint:errcheck
- w.WriteString(dict.Apostrophe) //nolint:errcheck
- w.WriteString(value) //nolint:errcheck
- w.WriteString(dict.Apostrophe) //nolint:errcheck
- continue
- }
- w.WriteString(dict.Space) //nolint:errcheck
- w.WriteString(value) //nolint:errcheck
- }
-}
-
-func DrawBlock(w io.StringWriter, level int, b *Block) {
- w.WriteString(indentSpace(level)) //nolint:errcheck
- DrawKey(w, b.Key())
- if !b.HasChild() {
- w.WriteString(dict.KeyEnd) //nolint:errcheck
- w.WriteString(dict.NewLine) //nolint:errcheck
- return
- }
-
- w.WriteString(dict.Space) //nolint:errcheck
- w.WriteString(dict.BlockOpen) //nolint:errcheck
- w.WriteString(dict.NewLine) //nolint:errcheck
-
- level++
- for _, c := range b.Child() {
- DrawBlock(w, level, c)
- }
- level--
-
- w.WriteString(indentSpace(level)) //nolint:errcheck
- w.WriteString(dict.BlockClose) //nolint:errcheck
- w.WriteString(dict.NewLine) //nolint:errcheck
-
-}
-
-func indentSpace(c int) string {
- return strings.Repeat(dict.Space, c*4)
-}
diff --git a/internal/node/key.go b/internal/node/key.go
deleted file mode 100644
index 2a5462f..0000000
--- a/internal/node/key.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package node
-
-type Key struct {
- key string
- values []string
-}
-
-func NewKey() *Key {
- return &Key{}
-}
-
-func (v *Key) IsValid() bool {
- return len(v.key) == 0
-}
-
-func (v *Key) Set(s string) {
- if len(v.key) == 0 {
- v.key = s
- } else {
- v.values = append(v.values, s)
- }
-}
-
-func (v *Key) Key() string {
- return v.key
-}
-
-func (v *Key) Values() []string {
- return append([]string{}, v.values...)
-}
diff --git a/internal/node/search.go b/internal/node/search.go
deleted file mode 100644
index 25e3195..0000000
--- a/internal/node/search.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package node
-
-func Search(b *Block, keys ...string) *Key {
- b = b.Root()
- for _, key := range keys {
- var has bool
- for _, block := range b.Child() {
- if key == block.Key().key {
- b = block
- has = true
- break
- }
- }
- if !has {
- return nil
- }
- }
- return b.Key()
-}
diff --git a/internal/pool/common.go b/internal/pool/common.go
new file mode 100644
index 0000000..90b1ed1
--- /dev/null
+++ b/internal/pool/common.go
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
+package pool
+
+import (
+ "go.osspkg.com/bb"
+)
+
+type TransBytes struct {
+ B []byte
+}
+
+func (b *TransBytes) Reset() {
+ b.B = b.B[:0]
+}
+
+var Bytes = New[*TransBytes](func() *TransBytes {
+ return &TransBytes{B: make([]byte, 0, 512)}
+})
+
+var Buffer = New[*bb.Buffer](func() *bb.Buffer {
+ return bb.New(512)
+})
diff --git a/internal/pool/pool.go b/internal/pool/pool.go
new file mode 100644
index 0000000..de358a3
--- /dev/null
+++ b/internal/pool/pool.go
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
+package pool
+
+import "sync"
+
+type TPool interface {
+ Reset()
+}
+
+type Pool[T TPool] struct {
+ pool *sync.Pool
+}
+
+func New[T TPool](callNew func() T) *Pool[T] {
+ return &Pool[T]{
+ pool: &sync.Pool{New: func() any { return callNew() }},
+ }
+}
+
+func (v *Pool[T]) Get() T {
+ return v.pool.Get().(T)
+}
+
+func (v *Pool[T]) Put(t T) {
+ t.Reset()
+ v.pool.Put(t)
+}
diff --git a/internal/splitter/splitter.go b/internal/splitter/splitter.go
deleted file mode 100644
index 99634f7..0000000
--- a/internal/splitter/splitter.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package splitter
-
-import (
- "unicode/utf8"
-
- "go.osspkg.com/unic/internal/dict"
-)
-
-func Func(data []byte, atEOF bool) (int, []byte, error) {
- start := 0
- raw := false
-
- for width := 0; start < len(data); start += width {
- var char rune
- char, width = utf8.DecodeRune(data[start:])
-
- switch true {
- case dict.IsRawChar(char) && !raw:
- raw = true
- start += width
- case dict.IsSkipChar(char):
- continue
- case dict.IsStopChar(char):
- return start + width, data[start : start+width], nil
- default:
- }
-
- break
- }
-
- for width, i := 0, start; i < len(data); i += width {
- var r rune
- r, width = utf8.DecodeRune(data[i:])
- switch true {
- case dict.IsRawChar(r) && raw:
- return i + width, data[start:i], nil
- case raw:
- continue
- case dict.IsStopChar(r):
- return i, data[start:i], nil
- case dict.IsSkipChar(r):
- return i + width, data[start:i], nil
- default:
- }
- }
-
- if atEOF && len(data) > start {
- return len(data), data[start:], nil
- }
-
- return start, nil, nil
-}
diff --git a/merge.go b/merge.go
new file mode 100644
index 0000000..a5b011a
--- /dev/null
+++ b/merge.go
@@ -0,0 +1,245 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
+package unic
+
+import (
+ "reflect"
+)
+
+//nolint:unused
+func mergeValues(a, b interface{}) (interface{}, error) {
+ if a == nil && b == nil {
+ return nil, nil
+ }
+ if a == nil {
+ return b, nil
+ }
+ if b == nil {
+ return a, nil
+ }
+
+ va := reflect.ValueOf(a)
+ vb := reflect.ValueOf(b)
+
+ wasPtrA := va.Kind() == reflect.Ptr
+ wasPtrB := vb.Kind() == reflect.Ptr
+
+ baseA := derefValue(va)
+ baseB := derefValue(vb)
+ if !baseA.IsValid() || !baseB.IsValid() {
+ if !baseA.IsValid() {
+ return b, nil
+ }
+ return a, nil
+ }
+
+ // Если оба — структуры, сливаем их поля через карты
+ if baseA.Kind() == reflect.Struct && baseB.Kind() == reflect.Struct {
+ mapA, err := structToMap(baseA)
+ if err != nil {
+ return nil, err
+ }
+ mapB, err := structToMap(baseB)
+ if err != nil {
+ return nil, err
+ }
+ mergedMap, err := mergeMapValues(mapA, mapB)
+ if err != nil {
+ return nil, err
+ }
+ return mergedMap, nil
+ }
+
+ // Если типы не совпадают (и не обе структуры) -> список
+ if baseA.Type() != baseB.Type() {
+ return []interface{}{a, b}, nil
+ }
+
+ var result interface{}
+ var err error
+ switch baseA.Kind() {
+ case reflect.Struct:
+ result, err = mergeStruct(baseA, baseB)
+ case reflect.Map:
+ result, err = mergeMap(baseA, baseB)
+ case reflect.Slice:
+ result, err = mergeSlice(baseA, baseB)
+ case reflect.Array:
+ result, err = mergeArray(baseA, baseB)
+ default:
+ return b, nil // скаляры перезаписываются
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if wasPtrA && wasPtrB {
+ resPtr := reflect.New(baseA.Type())
+ resPtr.Elem().Set(reflect.ValueOf(result))
+ return resPtr.Interface(), nil
+ }
+ return result, nil
+}
+
+//nolint:unused
+func derefValue(v reflect.Value) reflect.Value {
+ for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
+ if v.IsNil() {
+ return reflect.Value{}
+ }
+ v = v.Elem()
+ }
+ return v
+}
+
+//nolint:unused
+func mergeStruct(va, vb reflect.Value) (interface{}, error) {
+ t := va.Type()
+ result := reflect.New(t).Elem()
+ result.Set(va)
+
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+ if field.PkgPath != "" && !field.Anonymous {
+ continue
+ }
+ fvA := va.Field(i)
+ fvB := vb.Field(i)
+
+ if fvA.CanInterface() && fvB.CanInterface() {
+ merged, err := mergeValues(fvA.Interface(), fvB.Interface())
+ if err != nil {
+ return nil, err
+ }
+
+ if result.Field(i).CanSet() {
+ result.Field(i).Set(reflect.ValueOf(merged))
+ }
+ }
+ }
+ return result.Interface(), nil
+}
+
+//nolint:unused
+func mergeMap(va, vb reflect.Value) (interface{}, error) {
+ t := va.Type()
+ result := reflect.MakeMap(t)
+
+ for _, key := range va.MapKeys() {
+ valA := va.MapIndex(key)
+ valB := vb.MapIndex(key)
+ if valB.IsValid() {
+ merged, err := mergeValues(valA.Interface(), valB.Interface())
+ if err != nil {
+ return nil, err
+ }
+ result.SetMapIndex(key, reflect.ValueOf(merged))
+ } else {
+ result.SetMapIndex(key, valA)
+ }
+ }
+
+ for _, key := range vb.MapKeys() {
+ if !va.MapIndex(key).IsValid() {
+ result.SetMapIndex(key, vb.MapIndex(key))
+ }
+ }
+ return result.Interface(), nil
+}
+
+//nolint:unused,unparam
+func mergeSlice(va, vb reflect.Value) (interface{}, error) {
+ totalLen := va.Len() + vb.Len()
+ result := reflect.MakeSlice(va.Type(), totalLen, totalLen)
+ reflect.Copy(result, va)
+ reflect.Copy(result.Slice(va.Len(), totalLen), vb)
+ return result.Interface(), nil
+}
+
+//nolint:unused
+func mergeArray(va, vb reflect.Value) (interface{}, error) {
+ if va.Len() != vb.Len() {
+ return vb.Interface(), nil
+ }
+ t := va.Type()
+ result := reflect.New(t).Elem()
+ for i := 0; i < va.Len(); i++ {
+ merged, err := mergeValues(va.Index(i).Interface(), vb.Index(i).Interface())
+ if err != nil {
+ return nil, err
+ }
+ result.Index(i).Set(reflect.ValueOf(merged))
+ }
+ return result.Interface(), nil
+}
+
+//nolint:unused
+func structToMap(v reflect.Value) (map[string]interface{}, error) {
+ v = derefValue(v)
+ if v.Kind() != reflect.Struct {
+ return nil, errNotStruct
+ }
+ t := v.Type()
+ result := make(map[string]interface{})
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+ if field.PkgPath != "" && !field.Anonymous {
+ continue
+ }
+ tag, ok := field.Tag.Lookup("unic")
+ if !ok {
+ continue // пропускаем поля без тега
+ }
+ ft, keep, err := parseUnicTag(tag)
+ if err != nil {
+ return nil, err
+ }
+ if !keep {
+ continue
+ }
+ fv := v.Field(i)
+ if !fv.CanInterface() {
+ continue
+ }
+ if fv.Kind() == reflect.Struct {
+ subMap, err := structToMap(fv)
+ if err != nil {
+ return nil, err
+ }
+ result[ft.name] = subMap
+ } else {
+ result[ft.name] = fv.Interface()
+ }
+ }
+ return result, nil
+}
+
+//nolint:unused
+func mergeMapValues(a, b map[string]interface{}) (map[string]interface{}, error) {
+ va := reflect.ValueOf(a)
+ vb := reflect.ValueOf(b)
+ if va.Kind() != reflect.Map || vb.Kind() != reflect.Map {
+ return nil, errNotMaps
+ }
+ mergedVal, err := mergeMap(va, vb)
+ if err != nil {
+ return nil, err
+ }
+ return mergedVal.(map[string]interface{}), nil
+}
+
+func hasAttrs(t reflect.Type) (bool, error) {
+ meta, err := inspectStruct(t)
+ if err != nil {
+ return false, err
+ }
+ for _, f := range meta.fields {
+ if f.attr > 0 {
+ return true, nil
+ }
+ }
+ return false, nil
+}
diff --git a/parse.go b/parse.go
new file mode 100644
index 0000000..0a4e479
--- /dev/null
+++ b/parse.go
@@ -0,0 +1,343 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
+package unic
+
+import (
+ "fmt"
+
+ "go.osspkg.com/bb"
+)
+
+type nodeKind int
+
+const (
+ nodeEmpty nodeKind = iota
+ nodeScalar
+ nodeList
+ nodeMap
+ nodeBlock
+)
+
+type pair struct {
+ key, val *node
+}
+
+type field struct {
+ key string
+ val *node
+}
+
+type node struct {
+ kind nodeKind
+ scalar string
+ list []*node
+ pairs []*pair
+ attrs []*node
+ fields []*field
+ line, col int
+}
+
+type parser struct {
+ s *scanner
+}
+
+func parseDocument(buf *bb.Buffer) (*node, error) {
+ p := &parser{s: newScanner(buf)}
+ fields, err := p.parseFields(true)
+ if err != nil {
+ return nil, err
+ }
+ t, err := p.s.peek()
+ if err != nil {
+ return nil, err
+ }
+ if t.kind != tokEOF {
+ return nil, fmt.Errorf("unic:%d:%d: unexpected %s", t.line, t.col, t.val)
+ }
+ return &node{kind: nodeBlock, fields: fields, line: 1, col: 1}, nil
+}
+
+func (p *parser) parseFields(top bool) ([]*field, error) {
+ fields := make([]*field, 0, 10)
+ for {
+ t, err := p.s.peek()
+ if err != nil {
+ return nil, err
+ }
+ if t.kind == tokEOF {
+ if top {
+ return fields, nil
+ }
+ return nil, fmt.Errorf("unic:%d:%d: unclosed block", t.line, t.col)
+ }
+ if t.kind == tokRBrace {
+ if top {
+ return nil, fmt.Errorf("unic:%d:%d: unexpected '}'", t.line, t.col)
+ }
+ return fields, nil
+ }
+ f, err := p.parseField()
+ if err != nil {
+ return nil, err
+ }
+ fields = append(fields, f)
+ }
+}
+
+func (p *parser) parseField() (*field, error) {
+ key, err := p.s.next()
+ if err != nil {
+ return nil, err
+ }
+ if key.kind != tokIdent && key.kind != tokString {
+ return nil, fmt.Errorf("unic:%d:%d: expected field name, got %s", key.line, key.col, tokenName(key))
+ }
+
+ t, err := p.s.peek()
+ if err != nil {
+ return nil, err
+ }
+
+ switch t.kind {
+ case tokLBrack:
+ list, err := p.parseList()
+ if err != nil {
+ return nil, err
+ }
+ if err = p.expect(tokSemi); err != nil {
+ return nil, err
+ }
+ return &field{key: key.val, val: list}, nil
+ case tokLParen:
+ m, err := p.parseMap()
+ if err != nil {
+ return nil, err
+ }
+ if err = p.expect(tokSemi); err != nil {
+ return nil, err
+ }
+ return &field{key: key.val, val: m}, nil
+ case tokLBrace:
+ block, err := p.parseBlock(nil)
+ if err != nil {
+ return nil, err
+ }
+ return &field{key: key.val, val: block}, nil
+ case tokSemi:
+ _, _ = p.s.next()
+ return &field{key: key.val, val: &node{kind: nodeScalar, line: t.line, col: t.col}}, nil
+ case tokEOF, tokRBrace:
+ return nil, fmt.Errorf("unic:%d:%d: expected value or ';' after %s", key.line, key.col, key.val)
+ default:
+ }
+
+ vals := make([]*node, 0, 10)
+ for {
+ t, err = p.s.peek()
+ if err != nil {
+ return nil, err
+ }
+ switch t.kind {
+ case tokSemi:
+ _, _ = p.s.next()
+ return &field{key: key.val, val: valuesNode(vals, key.line, key.col)}, nil
+ case tokLBrace:
+ block, err := p.parseBlock(vals)
+ if err != nil {
+ return nil, err
+ }
+ return &field{key: key.val, val: block}, nil
+ case tokIdent, tokString:
+ tok, err := p.s.next()
+ if err != nil {
+ return nil, err
+ }
+ vals = append(vals, &node{kind: nodeScalar, scalar: tok.val, line: tok.line, col: tok.col})
+ default:
+ return nil, fmt.Errorf("unic:%d:%d: unexpected %s in field %s", t.line, t.col, tokenName(t), key.val)
+ }
+ }
+}
+
+func valuesNode(vals []*node, line, col int) *node {
+ switch len(vals) {
+ case 0:
+ return &node{kind: nodeScalar, line: line, col: col}
+ case 1:
+ return vals[0]
+ default:
+ return &node{kind: nodeList, list: vals, line: line, col: col}
+ }
+}
+
+func (p *parser) parseBlock(attrs []*node) (*node, error) {
+ open, err := p.s.next()
+ if err != nil {
+ return nil, err
+ }
+ if open.kind != tokLBrace {
+ return nil, fmt.Errorf("unic:%d:%d: expected '{'", open.line, open.col)
+ }
+ fields, err := p.parseFields(false)
+ if err != nil {
+ return nil, err
+ }
+ if err = p.expect(tokRBrace); err != nil {
+ return nil, err
+ }
+ return &node{kind: nodeBlock, attrs: attrs, fields: fields, line: open.line, col: open.col}, nil
+}
+
+func (p *parser) parseList() (*node, error) {
+ open, err := p.s.next()
+ if err != nil {
+ return nil, err
+ }
+ n := &node{kind: nodeList, line: open.line, col: open.col, list: make([]*node, 0, 4)}
+ for {
+ t, err := p.s.peek()
+ if err != nil {
+ return nil, err
+ }
+ if t.kind == tokRBrack {
+ _, _ = p.s.next()
+ return n, nil
+ }
+ item, err := p.parseValue()
+ if err != nil {
+ return nil, err
+ }
+ n.list = append(n.list, item)
+ t, err = p.s.peek()
+ if err != nil {
+ return nil, err
+ }
+ switch t.kind {
+ case tokComma:
+ _, _ = p.s.next()
+ case tokRBrack:
+ continue
+ default:
+ return nil, fmt.Errorf("unic:%d:%d: expected ',' or ']' in list", t.line, t.col)
+ }
+ }
+}
+
+func (p *parser) parseMap() (*node, error) {
+ open, err := p.s.next()
+ if err != nil {
+ return nil, err
+ }
+ n := &node{kind: nodeMap, line: open.line, col: open.col, pairs: make([]*pair, 0, 4)}
+ items := make([]*node, 0, 10)
+ for {
+ t, err := p.s.peek()
+ if err != nil {
+ return nil, err
+ }
+ if t.kind == tokRParen {
+ _, _ = p.s.next()
+ break
+ }
+ item, err := p.parseValue()
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ t, err = p.s.peek()
+ if err != nil {
+ return nil, err
+ }
+ switch t.kind {
+ case tokComma:
+ _, _ = p.s.next()
+ case tokRParen:
+ continue
+ default:
+ return nil, fmt.Errorf("unic:%d:%d: expected ',' or ')' in map", t.line, t.col)
+ }
+ }
+ if len(items)%2 != 0 {
+ return nil, fmt.Errorf("unic:%d:%d: map must contain an even number of values", n.line, n.col)
+ }
+ for i := 0; i < len(items); i += 2 {
+ n.pairs = append(n.pairs, &pair{key: items[i], val: items[i+1]})
+ }
+ return n, nil
+}
+
+func (p *parser) parseValue() (*node, error) {
+ t, err := p.s.peek()
+ if err != nil {
+ return nil, err
+ }
+ switch t.kind {
+ case tokLBrack:
+ return p.parseList()
+ case tokLParen:
+ return p.parseMap()
+ case tokLBrace:
+ return p.parseBlock(nil)
+ case tokIdent, tokString:
+ tok, err := p.s.next()
+ if err != nil {
+ return nil, err
+ }
+ return &node{kind: nodeScalar, scalar: tok.val, line: tok.line, col: tok.col}, nil
+ default:
+ return nil, fmt.Errorf("unic:%d:%d: expected value, got %s", t.line, t.col, tokenName(t))
+ }
+}
+
+func (p *parser) expect(k tokenKind) error {
+ t, err := p.s.next()
+ if err != nil {
+ return err
+ }
+ if t.kind != k {
+ return fmt.Errorf("unic:%d:%d: expected %s, got %s", t.line, t.col, kindName(k), tokenName(t))
+ }
+ return nil
+}
+
+func tokenName(t token) string {
+ if t.kind == tokEOF {
+ return "EOF"
+ }
+ if t.val != "" {
+ return t.val
+ }
+ return kindName(t.kind)
+}
+
+func kindName(k tokenKind) string {
+ switch k {
+ case tokEOF:
+ return "EOF"
+ case tokIdent:
+ return "identifier"
+ case tokString:
+ return "string"
+ case tokLBrace:
+ return "'{'"
+ case tokRBrace:
+ return "'}'"
+ case tokLBrack:
+ return "'['"
+ case tokRBrack:
+ return "']'"
+ case tokLParen:
+ return "'('"
+ case tokRParen:
+ return "')'"
+ case tokSemi:
+ return "';'"
+ case tokComma:
+ return "','"
+ default:
+ return "token"
+ }
+}
diff --git a/parse_test.go b/parse_test.go
new file mode 100644
index 0000000..df78bc0
--- /dev/null
+++ b/parse_test.go
@@ -0,0 +1,35 @@
+package unic
+
+import "testing"
+
+func TestUnit_KindAndTokenName(t *testing.T) {
+ t.Parallel()
+ names := map[tokenKind]string{
+ tokEOF: "EOF",
+ tokIdent: "identifier",
+ tokString: "string",
+ tokLBrace: "'{'",
+ tokRBrace: "'}'",
+ tokLBrack: "'['",
+ tokRBrack: "']'",
+ tokLParen: "'('",
+ tokRParen: "')'",
+ tokSemi: "';'",
+ tokComma: "','",
+ 99: "token",
+ }
+ for k, want := range names {
+ if got := kindName(k); got != want {
+ t.Fatalf("kindName(%d)=%q want %q", k, got, want)
+ }
+ }
+ if tokenName(token{kind: tokEOF}) != "EOF" {
+ t.Fatal("eof")
+ }
+ if tokenName(token{kind: tokIdent, val: "x"}) != "x" {
+ t.Fatal("val")
+ }
+ if tokenName(token{kind: tokIdent}) != "identifier" {
+ t.Fatal("empty val")
+ }
+}
diff --git a/scan.go b/scan.go
new file mode 100644
index 0000000..2cc58dd
--- /dev/null
+++ b/scan.go
@@ -0,0 +1,289 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
+package unic
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "unicode"
+ "unicode/utf8"
+
+ "go.osspkg.com/bb"
+
+ "go.osspkg.com/unic/internal/pool"
+)
+
+type tokenKind int
+
+const (
+ tokEOF tokenKind = iota
+ tokIdent
+ tokString
+ tokLBrace
+ tokRBrace
+ tokLBrack
+ tokRBrack
+ tokLParen
+ tokRParen
+ tokSemi
+ tokComma
+)
+
+type token struct {
+ kind tokenKind
+ val string
+ line, col int
+}
+
+type scanner struct {
+ buf *bb.Buffer
+ line, col int
+ prevLine, prevCol int
+ peeked token
+ hasPeek bool
+}
+
+func newScanner(buf *bb.Buffer) *scanner {
+ return &scanner{buf: buf, line: 1, col: 1, prevLine: 1, prevCol: 1}
+}
+
+func (s *scanner) next() (token, error) {
+ if s.hasPeek {
+ s.hasPeek = false
+ return s.peeked, nil
+ }
+ return s.scan()
+}
+
+func (s *scanner) peek() (token, error) {
+ if s.hasPeek {
+ return s.peeked, nil
+ }
+ t, err := s.scan()
+ if err != nil {
+ return token{}, err
+ }
+ s.peeked = t
+ s.hasPeek = true
+ return t, nil
+}
+
+func (s *scanner) scan() (token, error) {
+ for {
+ r, err := s.peekRune()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return token{kind: tokEOF, line: s.line, col: s.col}, nil
+ }
+ return token{}, s.wrap(err)
+ }
+ if unicode.IsSpace(r) {
+ if _, err = s.readRune(); err != nil {
+ return token{}, s.wrap(err)
+ }
+ continue
+ }
+ if r == '#' {
+ if err = s.skipComment(); err != nil {
+ return token{}, err
+ }
+ continue
+ }
+ break
+ }
+
+ line, col := s.line, s.col
+ r, err := s.peekRune()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return token{kind: tokEOF, line: line, col: col}, nil
+ }
+ return token{}, s.wrap(err)
+ }
+
+ switch r {
+ case '{':
+ _, _ = s.readRune()
+ return token{kind: tokLBrace, val: "{", line: line, col: col}, nil
+ case '}':
+ _, _ = s.readRune()
+ return token{kind: tokRBrace, val: "}", line: line, col: col}, nil
+ case '[':
+ _, _ = s.readRune()
+ return token{kind: tokLBrack, val: "[", line: line, col: col}, nil
+ case ']':
+ _, _ = s.readRune()
+ return token{kind: tokRBrack, val: "]", line: line, col: col}, nil
+ case '(':
+ _, _ = s.readRune()
+ return token{kind: tokLParen, val: "(", line: line, col: col}, nil
+ case ')':
+ _, _ = s.readRune()
+ return token{kind: tokRParen, val: ")", line: line, col: col}, nil
+ case ';':
+ _, _ = s.readRune()
+ return token{kind: tokSemi, val: ";", line: line, col: col}, nil
+ case ',':
+ _, _ = s.readRune()
+ return token{kind: tokComma, val: ",", line: line, col: col}, nil
+ case '\'', '"':
+ return s.scanQuoted(r)
+ case '`':
+ return s.scanRaw()
+ default:
+ if isDelim(r) {
+ return token{}, fmt.Errorf("unic:%d:%d: unexpected %q", line, col, r)
+ }
+ return s.scanIdent()
+ }
+}
+
+func (s *scanner) scanIdent() (token, error) {
+ line, col := s.line, s.col
+
+ b := pool.Bytes.Get()
+ defer pool.Bytes.Put(b)
+
+ for {
+ r, err := s.peekRune()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return token{}, s.wrap(err)
+ }
+ if unicode.IsSpace(r) || isDelim(r) {
+ break
+ }
+ r, err = s.readRune()
+ if err != nil {
+ return token{}, s.wrap(err)
+ }
+ b.B = utf8.AppendRune(b.B, r)
+ }
+ if len(b.B) == 0 {
+ return token{}, fmt.Errorf("unic:%d:%d: empty token", line, col)
+ }
+ return token{kind: tokIdent, val: string(b.B), line: line, col: col}, nil
+}
+
+func (s *scanner) scanQuoted(quote rune) (token, error) {
+ line, col := s.line, s.col
+ if _, err := s.readRune(); err != nil {
+ return token{}, s.wrap(err)
+ }
+
+ b := pool.Bytes.Get()
+ defer pool.Bytes.Put(b)
+
+ for {
+ r, err := s.readRune()
+ if errors.Is(err, io.EOF) {
+ return token{}, fmt.Errorf("unic:%d:%d: unterminated string", line, col)
+ }
+ if err != nil {
+ return token{}, s.wrap(err)
+ }
+ if r == quote {
+ return token{kind: tokString, val: string(b.B), line: line, col: col}, nil
+ }
+ b.B = utf8.AppendRune(b.B, r)
+ }
+}
+
+func (s *scanner) scanRaw() (token, error) {
+ line, col := s.line, s.col
+ for i := 0; i < 3; i++ {
+ r, err := s.readRune()
+ if err != nil {
+ return token{}, fmt.Errorf("unic:%d:%d: unterminated raw string", line, col)
+ }
+ if r != '`' {
+ return token{}, fmt.Errorf("unic:%d:%d: raw strings must start with ```", line, col)
+ }
+ }
+
+ b := pool.Bytes.Get()
+ defer pool.Bytes.Put(b)
+
+ ticks := 0
+ for {
+ r, err := s.readRune()
+ if errors.Is(err, io.EOF) {
+ return token{}, fmt.Errorf("unic:%d:%d: unterminated raw string", line, col)
+ }
+ if err != nil {
+ return token{}, s.wrap(err)
+ }
+ if r == '`' {
+ ticks++
+ if ticks == 3 {
+ return token{kind: tokString, val: string(b.B), line: line, col: col}, nil
+ }
+ continue
+ }
+ for ticks > 0 {
+ b.B = append(b.B, '`')
+ ticks--
+ }
+ b.B = utf8.AppendRune(b.B, r)
+ }
+}
+
+func (s *scanner) skipComment() error {
+ for {
+ r, err := s.readRune()
+ if errors.Is(err, io.EOF) {
+ return nil
+ }
+ if err != nil {
+ return s.wrap(err)
+ }
+ if r == '\n' {
+ return nil
+ }
+ }
+}
+
+func (s *scanner) peekRune() (rune, error) {
+ r, _, err := s.buf.ReadRune()
+ if err != nil {
+ return 0, err
+ }
+ if err = s.buf.UnreadRune(); err != nil {
+ return 0, err
+ }
+ return r, nil
+}
+
+func (s *scanner) readRune() (rune, error) {
+ r, _, err := s.buf.ReadRune()
+ if err != nil {
+ return 0, err
+ }
+ s.prevLine, s.prevCol = s.line, s.col
+ if r == '\n' {
+ s.line++
+ s.col = 1
+ } else {
+ s.col++
+ }
+ return r, nil
+}
+
+func (s *scanner) wrap(err error) error {
+ return fmt.Errorf("unic:%d:%d: %w", s.line, s.col, err)
+}
+
+func isDelim(r rune) bool {
+ switch r {
+ case '{', '}', '[', ']', '(', ')', '#', ';', ',', '\'', '"', '`':
+ return true
+ default:
+ return false
+ }
+}
diff --git a/scan_test.go b/scan_test.go
new file mode 100644
index 0000000..9fb3f6f
--- /dev/null
+++ b/scan_test.go
@@ -0,0 +1,118 @@
+package unic
+
+import (
+ "io"
+ "strings"
+ "testing"
+
+ "go.osspkg.com/bb"
+)
+
+func TestUnit_ScannerTokensAndErrors(t *testing.T) {
+ t.Parallel()
+ buf := bb.FromBytes([]byte("k { } [ ] ( ) ; ,"))
+ if _, err := buf.Seek(0, bb.SeekStart); err != nil {
+ t.Fatal(err)
+ }
+ s := newScanner(buf)
+ var kinds []tokenKind
+ for {
+ tok, err := s.next()
+ if err != nil {
+ t.Fatal(err)
+ }
+ kinds = append(kinds, tok.kind)
+ if tok.kind == tokEOF {
+ break
+ }
+ }
+ want := []tokenKind{tokIdent, tokLBrace, tokRBrace, tokLBrack, tokRBrack, tokLParen, tokRParen, tokSemi, tokComma, tokEOF}
+ if len(kinds) != len(want) {
+ t.Fatalf("%v", kinds)
+ }
+ for i := range want {
+ if kinds[i] != want[i] {
+ t.Fatalf("i=%d %v", i, kinds)
+ }
+ }
+}
+
+func TestUnit_ScannerPeekTwice(t *testing.T) {
+ t.Parallel()
+ buf := bb.FromBytes([]byte("ab"))
+ if _, err := buf.Seek(0, bb.SeekStart); err != nil {
+ t.Fatal(err)
+ }
+ s := newScanner(buf)
+ a, err := s.peek()
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := s.peek()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a != b || a.val != "ab" {
+ t.Fatalf("%+v %+v", a, b)
+ }
+ n, err := s.next()
+ if err != nil || n.val != "ab" {
+ t.Fatalf("%+v %v", n, err)
+ }
+}
+
+func TestUnit_ScannerRawEmbeddedTick(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ S string `unic:"s"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("s ```a`b```;"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.S != "a`b" {
+ t.Fatalf("%q", got.S)
+ }
+}
+
+func TestUnit_ScannerErrors(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ S string `unic:"s"`
+ }
+ var c cfg
+ for _, in := range []string{
+ "s 'oops;",
+ "s \"oops;",
+ "s ```oops;",
+ "s `nope;",
+ "s ``;",
+ } {
+ if err := Unmarshal([]byte(in), &c); err == nil {
+ t.Fatalf("expected error for %q", in)
+ }
+ }
+}
+
+func TestUnit_ScannerCommentEOF(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ A int `unic:"a"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("a 1; # trailing"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.A != 1 {
+ t.Fatal(got.A)
+ }
+}
+
+func TestUnit_ScannerWrap(t *testing.T) {
+ t.Parallel()
+ s := newScanner(bb.New(8))
+ err := s.wrap(io.EOF)
+ if err == nil || !strings.Contains(err.Error(), "unic:1:1") {
+ t.Fatalf("%v", err)
+ }
+}
diff --git a/tag.go b/tag.go
new file mode 100644
index 0000000..226d16d
--- /dev/null
+++ b/tag.go
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
+package unic
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+type fieldTag struct {
+ name string
+ hasDefault bool
+ defaultVal string
+ omitempty bool
+ attr int
+ desc string
+}
+
+func parseUnicTag(tag string) (fieldTag, bool, error) {
+ tag = strings.TrimSpace(tag)
+ if tag == "" || tag == "-" {
+ return fieldTag{}, false, nil
+ }
+
+ name, rest, err := splitTagName(tag)
+ if err != nil {
+ return fieldTag{}, false, err
+ }
+ ft := fieldTag{name: name}
+ for rest != "" {
+ var opt string
+ opt, rest, err = splitTagOption(rest)
+ if err != nil {
+ return fieldTag{}, false, err
+ }
+ if opt == "" {
+ continue
+ }
+ key, val, hasVal := splitOpt(opt)
+ switch key {
+ case "omitempty":
+ ft.omitempty = true
+ case "default":
+ ft.hasDefault = true
+ ft.defaultVal = val
+ case "attr":
+ if !hasVal {
+ return fieldTag{}, false, fmt.Errorf("unic: attr requires a positive index")
+ }
+ n, err := strconv.Atoi(val)
+ if err != nil || n <= 0 {
+ return fieldTag{}, false, fmt.Errorf("unic: attr must be > 0")
+ }
+ ft.attr = n
+ case "desc":
+ ft.desc = val
+ default:
+ // unknown options are ignored so new flags do not break decode
+ }
+ }
+ if ft.name == "" {
+ return fieldTag{}, false, nil
+ }
+ return ft, true, nil
+}
+
+//nolint:unparam
+func splitTagName(tag string) (string, string, error) {
+ i := 0
+ for i < len(tag) {
+ r, size := utf8.DecodeRuneInString(tag[i:])
+ if r == ',' {
+ break
+ }
+ i += size
+ }
+ return strings.TrimSpace(tag[:i]), strings.TrimSpace(trimComma(tag[i:])), nil
+}
+
+func splitTagOption(s string) (opt, rest string, err error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return "", "", nil
+ }
+ i := 0
+ for i < len(s) {
+ r, size := utf8.DecodeRuneInString(s[i:])
+ if r == '\'' || r == '"' {
+ end, qerr := skipQuoted(s, i)
+ if qerr != nil {
+ return "", "", qerr
+ }
+ i = end
+ continue
+ }
+ if r == ',' {
+ return strings.TrimSpace(s[:i]), strings.TrimSpace(trimComma(s[i:])), nil
+ }
+ i += size
+ }
+ return strings.TrimSpace(s), "", nil
+}
+
+func skipQuoted(s string, i int) (int, error) {
+ quote, size := utf8.DecodeRuneInString(s[i:])
+ i += size
+ for i < len(s) {
+ r, size := utf8.DecodeRuneInString(s[i:])
+ i += size
+ if r == quote {
+ return i, nil
+ }
+ }
+ return 0, fmt.Errorf("unic: unterminated quote in struct tag")
+}
+
+func splitOpt(opt string) (key, val string, hasVal bool) {
+ key, val, hasVal = strings.Cut(opt, "=")
+ key = strings.TrimSpace(key)
+ val = strings.TrimSpace(val)
+ if hasVal {
+ val = unquoteTagValue(val)
+ }
+ return key, val, hasVal
+}
+
+func unquoteTagValue(v string) string {
+ if len(v) >= 2 {
+ if (v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"') {
+ return v[1 : len(v)-1]
+ }
+ }
+ return v
+}
+
+func trimComma(s string) string {
+ s = strings.TrimSpace(s)
+ if strings.HasPrefix(s, ",") {
+ return strings.TrimSpace(s[1:])
+ }
+ return s
+}
+
+func splitDefaultList(v string) []string {
+ if v == "" {
+ return nil
+ }
+ parts := strings.Split(v, ";")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ out = append(out, p)
+ }
+ return out
+}
+
+func isExported(name string) bool {
+ r, _ := utf8.DecodeRuneInString(name)
+ return unicode.IsUpper(r)
+}
diff --git a/tag_test.go b/tag_test.go
new file mode 100644
index 0000000..aacc6a4
--- /dev/null
+++ b/tag_test.go
@@ -0,0 +1,83 @@
+package unic
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestUnit_ParseUnicTag(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ in string
+ want fieldTag
+ keep bool
+ wantErr bool
+ }{
+ {in: "", keep: false},
+ {in: "-", keep: false},
+ {in: " ", keep: false},
+ {
+ in: "port,default=80,desc='номер порта'",
+ keep: true,
+ want: fieldTag{name: "port", hasDefault: true, defaultVal: "80", desc: "номер порта"},
+ },
+ {
+ in: `host,default="127.0.0.1",omitempty`,
+ keep: true,
+ want: fieldTag{name: "host", hasDefault: true, defaultVal: "127.0.0.1", omitempty: true},
+ },
+ {
+ in: "tag,attr=1,unknown=x",
+ keep: true,
+ want: fieldTag{name: "tag", attr: 1},
+ },
+ {
+ in: "flags,default='a;b'",
+ keep: true,
+ want: fieldTag{name: "flags", hasDefault: true, defaultVal: "a;b"},
+ },
+ {in: "x,attr", wantErr: true},
+ {in: "x,attr=0", wantErr: true},
+ {in: "x,attr=-1", wantErr: true},
+ {in: "x,attr=nope", wantErr: true},
+ {in: "x,desc='oops", wantErr: true},
+ }
+ for _, tt := range tests {
+ got, keep, err := parseUnicTag(tt.in)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("parseUnicTag(%q) err=nil", tt.in)
+ }
+ continue
+ }
+ if err != nil {
+ t.Fatalf("parseUnicTag(%q): %v", tt.in, err)
+ }
+ if keep != tt.keep || !reflect.DeepEqual(got, tt.want) {
+ t.Fatalf("parseUnicTag(%q)=(%+v,%v) want (%+v,%v)", tt.in, got, keep, tt.want, tt.keep)
+ }
+ }
+}
+
+func TestUnit_SplitDefaultList(t *testing.T) {
+ t.Parallel()
+ if splitDefaultList("") != nil {
+ t.Fatal("empty")
+ }
+ got := splitDefaultList(" a ; ; b ;")
+ if len(got) != 2 || got[0] != "a" || got[1] != "b" {
+ t.Fatalf("%v", got)
+ }
+}
+
+func TestUnit_ParseUnicTagEmptyNameAndCommas(t *testing.T) {
+ t.Parallel()
+ _, keep, err := parseUnicTag(",omitempty")
+ if err != nil || keep {
+ t.Fatalf("keep=%v err=%v", keep, err)
+ }
+ ft, keep, err := parseUnicTag("port,,desc='x'")
+ if err != nil || !keep || ft.name != "port" || ft.desc != "x" {
+ t.Fatalf("%+v %v %v", ft, keep, err)
+ }
+}
diff --git a/tests/example1.conf b/tests/example1.conf
deleted file mode 100644
index 7be3890..0000000
--- a/tests/example1.conf
+++ /dev/null
@@ -1,27 +0,0 @@
-keybase aaa;
-
-block1 {
- @extend /etc/hosts;
-
- list1 aaa bbb ccc;
- single1 128; # comment1
-
- sub_block2{ # коментарий 1
-
- multiline `text 1
- 'text2 text 3
-` `Привет мир!;# `;
-
- sub_block_with_value data1 data2 {
- list1 aaa bbb 1234 ;
- }
- sub_block_with_value data3 data4 {
- list2 aaa bbb 1234 ;
- }
-
- aaaa { name aaa; }
- }
-}
-
-aaaa
-{ name aaa; }
diff --git a/tests/example1.golden.conf b/tests/example1.golden.conf
deleted file mode 100644
index bcb950a..0000000
--- a/tests/example1.golden.conf
+++ /dev/null
@@ -1,23 +0,0 @@
-keybase aaa;
-block1 {
- @extend /etc/hosts;
- list1 aaa bbb ccc;
- single1 128;
- sub_block2 {
- multiline `text 1
- 'text2 text 3
-` `Привет мир!;# `;
- sub_block_with_value data1 data2 {
- list1 aaa bbb 1234;
- }
- sub_block_with_value data3 data4 {
- list2 aaa bbb 1234;
- }
- aaaa {
- name aaa;
- }
- }
-}
-aaaa {
- name aaa;
-}
diff --git a/tests/unit_test.go b/tests/unit_test.go
deleted file mode 100644
index 6ee76d6..0000000
--- a/tests/unit_test.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package tests
-
-import (
- "bytes"
- "fmt"
- "os"
- "reflect"
- "testing"
-
- "go.osspkg.com/unic/internal/decode"
- "go.osspkg.com/unic/internal/encode"
- "go.osspkg.com/unic/internal/node"
-)
-
-func TestUnit_Node_DecoderEncoder(t *testing.T) {
- b, err := os.ReadFile("./example1.conf")
- if err != nil {
- t.Error(err)
- t.FailNow()
- }
-
- expected, err := os.ReadFile("./example1.golden.conf")
- if err != nil {
- t.Error(err)
- t.FailNow()
- }
-
- dec := decode.New(bytes.NewBuffer(b))
- if err = dec.Decode(); err != nil {
- t.Error(err)
- t.FailNow()
- }
-
- val := node.Search(dec.GetBlock(), "block1", "sub_block2", "sub_block_with_value")
- if val == nil {
- t.Errorf("search is empty")
- t.FailNow()
- }
-
- if !reflect.DeepEqual(val.Values(), []string{"data1", "data2"}) {
- t.Errorf("search value not equal: %#v", val)
- t.FailNow()
- }
-
- var buf bytes.Buffer
- enc := encode.New(&buf)
- if err = enc.Encode(dec.GetBlock()); err != nil {
- t.Error(err)
- t.FailNow()
- }
-
- actual := buf.Bytes()
- if !bytes.Equal(expected, actual) {
- // fmt.Println("--- want: ---")
- // fmt.Println(string(expected))
- // fmt.Println("--- got: ---")
- fmt.Println(string(actual))
- t.FailNow()
- }
-}
diff --git a/unic.go b/unic.go
index 81e466d..05e6251 100644
--- a/unic.go
+++ b/unic.go
@@ -1,23 +1,188 @@
+/*
+ * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved.
+ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
+ */
+
package unic
-import "bytes"
+import (
+ "bytes"
+ "fmt"
+ "reflect"
+
+ "go.osspkg.com/bb"
+
+ "go.osspkg.com/unic/internal/pool"
+)
-func Marshal(in interface{}) ([]byte, error) {
- var buf bytes.Buffer
- enc := NewEncoder(&buf)
- if err := enc.Encode(in); err != nil {
- return nil, err
+// Unmarshal parses UNIC data and stores the result in v.
+// v must be a non-nil pointer to a struct.
+func Unmarshal(data []byte, args ...any) error {
+ if len(data) == 0 || len(args) == 0 {
+ return nil
}
- if err := enc.Done(); err != nil {
- return nil, err
+
+ buf := pool.Buffer.Get()
+ defer pool.Buffer.Put(buf)
+
+ data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
+ if _, err := buf.Write(data); err != nil {
+ return fmt.Errorf("unic: initialize buffer: %w", err)
+ }
+ if _, err := buf.Seek(0, bb.SeekStart); err != nil {
+ return fmt.Errorf("unic: seek buffer: %w", err)
}
- return buf.Bytes(), nil
-}
-func Unmarshal(in []byte, out interface{}) error {
- dec, err := NewDecoder(bytes.NewBuffer(in))
+ doc, err := parseDocument(buf)
if err != nil {
return err
}
- return dec.Decode(out)
+
+ for _, arg := range args {
+ rv, err := decodeTarget(arg)
+ if err != nil {
+ return err
+ }
+
+ if _, err = inspectStruct(rv.Type()); err != nil {
+ return fmt.Errorf("unic: %w", err)
+ }
+
+ if err = decodeValue(rv, doc, ""); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func decodeTarget(v any) (reflect.Value, error) {
+ if v == nil {
+ return reflect.Value{}, fmt.Errorf("unic: Unmarshal(nil)")
+ }
+
+ rv := reflect.ValueOf(v)
+ if rv.Kind() != reflect.Pointer || rv.IsNil() {
+ return reflect.Value{}, fmt.Errorf("unic: Unmarshal(non-nil pointer required)")
+ }
+
+ rv = rv.Elem()
+ if rv.Kind() != reflect.Struct {
+ return reflect.Value{}, fmt.Errorf("unic: Unmarshal(non-struct pointer required)")
+ }
+
+ return rv, nil
+}
+
+// Marshal returns the UNIC encoding of v.
+// v must be a struct or a non-nil pointer to struct.
+func Marshal(args ...any) ([]byte, error) {
+ if len(args) == 0 {
+ return nil, nil
+ }
+
+ fieldsMap := make(map[string][]interface{})
+ for _, arg := range args {
+ rv, err := encodeTarget(arg)
+ if err != nil {
+ return nil, err
+ }
+
+ meta, err := inspectStruct(rv.Type())
+ if err != nil {
+ return nil, err
+ }
+
+ for _, bf := range meta.fields {
+ if bf.attr > 0 {
+ continue
+ }
+ fv := valueAt(rv, bf.index)
+ if bf.omitempty && isEmptyValue(fv) {
+ continue
+ }
+ fieldsMap[bf.name] = append(fieldsMap[bf.name], fv.Interface())
+ }
+ }
+
+ mergedGroups := make(map[string][]interface{})
+ for name, vals := range fieldsMap {
+ if len(vals) > 1 {
+ allStructs := true
+ hasAnyAttr := false
+ for _, val := range vals {
+ fv := marshalDeref(reflect.ValueOf(val))
+ if fv.Kind() != reflect.Struct {
+ allStructs = false
+ break
+ }
+ has, err := hasAttrs(fv.Type())
+ if err != nil {
+ return nil, err
+ }
+ if has {
+ hasAnyAttr = true
+ break
+ }
+ }
+ if allStructs && !hasAnyAttr {
+ mergedGroups[name] = vals
+ delete(fieldsMap, name)
+ continue
+ }
+ }
+ }
+
+ buf := pool.Buffer.Get()
+ defer pool.Buffer.Put(buf)
+
+ e := &encoder{buf: buf}
+
+ for name, vals := range mergedGroups {
+ if err := e.writeMergedBlock(name, vals, ""); err != nil {
+ return nil, err
+ }
+ }
+
+ for name, vals := range fieldsMap {
+ for _, val := range vals {
+ fv := reflect.ValueOf(val)
+ if fv.Kind() == reflect.Ptr && fv.IsNil() {
+ if err := e.writeEmptyBlock(name); err != nil {
+ return nil, err
+ }
+ continue
+ }
+ fv = marshalDeref(fv)
+ if !fv.IsValid() {
+ continue
+ }
+ f := &boundField{fieldTag: fieldTag{name: name}}
+ if err := e.writeNamed(f, fv, ""); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ return e.buf.Bytes(), nil
+}
+
+func encodeTarget(v any) (reflect.Value, error) {
+ if v == nil {
+ return reflect.Value{}, fmt.Errorf("unic: Marshal(nil)")
+ }
+
+ rv := reflect.ValueOf(v)
+ for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface {
+ if rv.IsNil() {
+ return reflect.Value{}, fmt.Errorf("unic: Marshal(nil)")
+ }
+ rv = rv.Elem()
+ }
+
+ if rv.Kind() != reflect.Struct {
+ return reflect.Value{}, fmt.Errorf("unic: Marshal(non-struct pointer required)")
+ }
+
+ return rv, nil
}
diff --git a/unic_test.go b/unic_test.go
new file mode 100644
index 0000000..b0f43c2
--- /dev/null
+++ b/unic_test.go
@@ -0,0 +1,753 @@
+package unic
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+const readmeConfig = `
+log_level 1;
+servers {
+ domains ['localhost', 'local.host'];
+ server web { # веб
+ port 80; # номер порта
+ host '127.0.0.1'; # IP или домен
+ ttl [1, 2, 3];
+ ssl ['/etc/ssl/host1.pem', '/etc/ssl/host1.pem']; # пути для сертификатов
+ }
+ server admin {
+ port 80; # номер порта
+ host '127.0.0.2'; # IP или домен
+ auth (user1, passwd1, user2, passwd2);
+ }
+ route admin {
+ prefix /api/admin/v1; # префикс методов api для админки
+ middleware [log, oauth]; # набор миделвар
+ }
+}
+`
+
+type readmeConfigStruct struct {
+ LogLevel int `unic:"log_level,default=1,desc='уровень логирования'"`
+ Servers struct {
+ Domains []string `unic:"domains,default='localhost;local.host',desc='список доменов'"`
+ Servers []struct {
+ Tag string `unic:"tag,attr=1,desc='веб'"`
+ Port int `unic:"port,default=80,desc='номер порта'"`
+ Host string `unic:"host,default='127.0.0.1',desc='IP или домен'"`
+ Ttl []int `unic:"ttl,omitempty"`
+ Ssl []string `unic:"ssl,omitempty,desc='пути для сертификатов'"`
+ Auth map[string]string `unic:"auth,omitempty"`
+ } `unic:"server"`
+ Routes []struct {
+ Tag string `unic:"tag,attr=1"`
+ Prefix string `unic:"prefix"`
+ Middleware []string `unic:"middleware"`
+ } `unic:"route"`
+ } `unic:"servers,desc='настройки серверов'"`
+}
+
+func TestUnit_UnmarshalREADME(t *testing.T) {
+ var cfg readmeConfigStruct
+ if err := Unmarshal([]byte(readmeConfig), &cfg); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.LogLevel != 1 {
+ t.Fatalf("LogLevel=%d", cfg.LogLevel)
+ }
+ if got := cfg.Servers.Domains; len(got) != 2 || got[0] != "localhost" || got[1] != "local.host" {
+ t.Fatalf("Domains=%v", got)
+ }
+ if len(cfg.Servers.Servers) != 2 {
+ t.Fatalf("servers=%d", len(cfg.Servers.Servers))
+ }
+ web := cfg.Servers.Servers[0]
+ if web.Tag != "web" || web.Port != 80 || web.Host != "127.0.0.1" {
+ t.Fatalf("web=%+v", web)
+ }
+ if len(web.Ttl) != 3 || web.Ttl[0] != 1 || web.Ttl[2] != 3 {
+ t.Fatalf("ttl=%v", web.Ttl)
+ }
+ if len(web.Ssl) != 2 || web.Ssl[0] != "/etc/ssl/host1.pem" {
+ t.Fatalf("ssl=%v", web.Ssl)
+ }
+ admin := cfg.Servers.Servers[1]
+ if admin.Tag != "admin" || admin.Host != "127.0.0.2" {
+ t.Fatalf("admin=%+v", admin)
+ }
+ if admin.Auth["user1"] != "passwd1" || admin.Auth["user2"] != "passwd2" {
+ t.Fatalf("auth=%v", admin.Auth)
+ }
+ if len(cfg.Servers.Routes) != 1 {
+ t.Fatalf("routes=%d", len(cfg.Servers.Routes))
+ }
+ rt := cfg.Servers.Routes[0]
+ if rt.Tag != "admin" || rt.Prefix != "/api/admin/v1" {
+ t.Fatalf("route=%+v", rt)
+ }
+ if len(rt.Middleware) != 2 || rt.Middleware[0] != "log" || rt.Middleware[1] != "oauth" {
+ t.Fatalf("middleware=%v", rt.Middleware)
+ }
+}
+
+func TestUnit_UnmarshalDefaults(t *testing.T) {
+ type cfg struct {
+ LogLevel int `unic:"log_level,default=1"`
+ Name string `unic:"name,default='svc'"`
+ Flags []string `unic:"flags,default='a;b'"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte(""), &got); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, cfg{}) {
+ t.Fatalf("%+v", got)
+ }
+}
+
+func TestUnit_UnmarshalQuotes(t *testing.T) {
+ type cfg struct {
+ A string `unic:"a"`
+ B string `unic:"b"`
+ C string `unic:"c"`
+ }
+ in := "a 'hello \" world';\nb \"hello ' world\";\nc ```hello '\n\t world\"```;\n"
+ var got cfg
+ if err := Unmarshal([]byte(in), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.A != `hello " world` {
+ t.Fatalf("a=%q", got.A)
+ }
+ if got.B != `hello ' world` {
+ t.Fatalf("b=%q", got.B)
+ }
+ if got.C != "hello '\n\t world\"" {
+ t.Fatalf("c=%q", got.C)
+ }
+}
+
+func TestUnit_UnmarshalMyConfig(t *testing.T) {
+ type MyConfig struct {
+ ServiceName string `unic:"service_name"`
+ Port int `unic:"port"`
+ Features []bool `unic:"features"`
+ }
+ var cfg MyConfig
+ in := "service_name api-gateway;\nport 8080;\nfeatures [true, false];\n"
+ if err := Unmarshal([]byte(in), &cfg); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.ServiceName != "api-gateway" || cfg.Port != 8080 {
+ t.Fatalf("%+v", cfg)
+ }
+ if len(cfg.Features) != 2 || !cfg.Features[0] || cfg.Features[1] {
+ t.Fatalf("features=%v", cfg.Features)
+ }
+}
+
+func TestUnit_UnmarshalErrors(t *testing.T) {
+ var n int
+ if err := Unmarshal([]byte("x 1;"), n); err == nil {
+ t.Fatal("expected pointer error")
+ }
+ type cfg struct {
+ Port int `unic:"port"`
+ }
+ var c cfg
+ if err := Unmarshal([]byte("port abc;"), &c); err == nil {
+ t.Fatal("expected type error")
+ }
+ if err := Unmarshal([]byte("foo {"), &c); err == nil {
+ t.Fatal("expected unclosed block")
+ }
+}
+
+func TestUnit_UnmarshalOmitempty(t *testing.T) {
+ type cfg struct {
+ Ttl []int `unic:"ttl,omitempty"`
+ Port int `unic:"port,default=80"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("ttl [];"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Ttl != nil {
+ t.Fatalf("ttl=%v", got.Ttl)
+ }
+ if got.Port != 80 {
+ t.Fatalf("port=%d", got.Port)
+ }
+}
+
+func TestUnit_UnmarshalMapTopLevel(t *testing.T) {
+ out := map[string]any{}
+ if err := Unmarshal([]byte("a 1;\nb [x, y];\n"), &out); err == nil {
+ t.Fatal("unmarshalling map got no error")
+ }
+}
+
+func TestUnit_MarshalRoundTripREADME(t *testing.T) {
+ var cfg readmeConfigStruct
+ if err := Unmarshal([]byte(readmeConfig), &cfg); err != nil {
+ t.Fatal(err)
+ }
+ data, err := Marshal(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got readmeConfigStruct
+ if err := Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal marshaled: %v\n%s", err, data)
+ }
+ if !reflect.DeepEqual(cfg, got) {
+ t.Fatalf("round trip mismatch\nmarshaled:\n%s\nwant=%+v\ngot=%+v", data, cfg, got)
+ }
+ out := string(data)
+ for _, frag := range []string{
+ "log_level 1;",
+ "servers {",
+ "server web {",
+ "server admin {",
+ "route admin {",
+ "auth (user1, passwd1, user2, passwd2);",
+ } {
+ if !strings.Contains(out, frag) {
+ t.Fatalf("missing %q in:\n%s", frag, out)
+ }
+ }
+}
+
+func TestUnit_MarshalQuotes(t *testing.T) {
+ type cfg struct {
+ A string `unic:"a"`
+ B string `unic:"b"`
+ C string `unic:"c"`
+ }
+ data, err := Marshal(cfg{
+ A: `hello " world`,
+ B: `hello ' world`,
+ C: "hello '\n\t world\"",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got cfg
+ if err := Unmarshal(data, &got); err != nil {
+ t.Fatalf("%v\n%s", err, data)
+ }
+ if got.A != `hello " world` || got.B != `hello ' world` || got.C != "hello '\n\t world\"" {
+ t.Fatalf("got=%+v\n%s", got, data)
+ }
+}
+
+func TestUnit_MarshalMultiModel(t *testing.T) {
+ type cfg1 struct {
+ Port int `unic:"port"`
+ }
+ type cfg2 struct {
+ Ttl []int `unic:"ttl,omitempty"`
+ }
+
+ type full1 struct {
+ Serv cfg1 `unic:"serv"`
+ }
+ type full2 struct {
+ Serv cfg2 `unic:"serv"`
+ }
+
+ data, err := Marshal(
+ full1{Serv: cfg1{Port: 123}},
+ full2{Serv: cfg2{Ttl: []int{1, 2, 3}}},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expected := "serv {\n\tport 123;\n\tttl [1, 2, 3];\n}\n"
+
+ if string(data) != expected {
+ t.Fatalf("MarshalMultiModel: got=%q\nwant=%q", string(data), expected)
+ }
+}
+
+func TestUnit_MarshalMultiModelWithAttr(t *testing.T) {
+ type cfg1 struct {
+ Tag string `unic:"tag,attr=1"`
+ Port int `unic:"port"`
+ }
+ type cfg2 struct {
+ Tag string `unic:"tag,attr=1"`
+ Ttl []int `unic:"ttl,omitempty"`
+ }
+
+ type full1 struct {
+ Serv cfg1 `unic:"serv"`
+ }
+ type full2 struct {
+ Serv cfg2 `unic:"serv"`
+ }
+
+ data, err := Marshal(
+ full1{Serv: cfg1{Port: 123, Tag: "A"}},
+ full2{Serv: cfg2{Ttl: []int{1, 2, 3}, Tag: "B"}},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expected := `serv A {
+ port 123;
+}
+serv B {
+ ttl [1, 2, 3];
+}
+`
+
+ if string(data) != expected {
+ t.Fatalf("TestUnit_MarshalMultiModelWithAttr: got=%q\nwant=%q", string(data), expected)
+ }
+}
+
+func TestUnit_MarshalOmitempty(t *testing.T) {
+ type cfg struct {
+ Ttl []int `unic:"ttl,omitempty"`
+ Port int `unic:"port"`
+ }
+ data, err := Marshal(cfg{Port: 80})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(data), "ttl") {
+ t.Fatalf("omitempty ttl present:\n%s", data)
+ }
+ if !strings.Contains(string(data), "port 80;") {
+ t.Fatalf("missing port:\n%s", data)
+ }
+}
+
+func TestUnit_MarshalMyConfig(t *testing.T) {
+ type MyConfig struct {
+ ServiceName string `unic:"service_name"`
+ Port int `unic:"port"`
+ Features []bool `unic:"features"`
+ }
+ data, err := Marshal(MyConfig{
+ ServiceName: "api-gateway",
+ Port: 8080,
+ Features: []bool{true, false},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got MyConfig
+ if err := Unmarshal(data, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.ServiceName != "api-gateway" || got.Port != 8080 || len(got.Features) != 2 || !got.Features[0] || got.Features[1] {
+ t.Fatalf("%+v\n%s", got, data)
+ }
+}
+
+func TestUnit_MarshalNil(t *testing.T) {
+ if _, err := Marshal(nil); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestUnit_DecodeArrayFromRepeatedKeys(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ A [2]int `unic:"a"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("a 1; a 2;"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.A != [2]int{1, 2} {
+ t.Fatalf("%v", got.A)
+ }
+ if err := Unmarshal([]byte("a 1; a 2; a 3;"), &got); err == nil {
+ t.Fatal("expected overflow")
+ }
+}
+
+func TestUnit_ParseValueBlockInListAndMissingSemi(t *testing.T) {
+ t.Parallel()
+ type item struct {
+ X int `unic:"x"`
+ }
+ type cfg struct {
+ Items []item `unic:"items"`
+ A int `unic:"a"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("items [{ x 1; }, { x 2; }];"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Items) != 2 || got.Items[1].X != 2 {
+ t.Fatalf("%+v", got)
+ }
+ if err := Unmarshal([]byte("a [1, 2]"), &got); err == nil {
+ t.Fatal("expected missing semicolon")
+ }
+}
+
+func TestUnit_UnmarshalSingleBlockIntoSlice(t *testing.T) {
+ t.Parallel()
+ type item struct {
+ N int `unic:"n"`
+ }
+ type cfg struct {
+ Items []item `unic:"item"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("item { n 5; }"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Items) != 1 || got.Items[0].N != 5 {
+ t.Fatalf("%+v", got)
+ }
+}
+
+func TestUnit_UnmarshalNilAndPointer(t *testing.T) {
+ t.Parallel()
+ if err := Unmarshal(nil, nil); err != nil {
+ t.Fatal("nil target")
+ }
+ var p *struct {
+ A int `unic:"a"`
+ }
+ if err := Unmarshal([]byte("a 1;"), p); err == nil {
+ t.Fatal("nil pointer")
+ }
+ var n int
+ if err := Unmarshal([]byte("a 1;"), &n); err == nil {
+ t.Fatal("non-struct")
+ }
+}
+
+func TestUnit_ParseSyntaxErrors(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ A int `unic:"a"`
+ }
+ var c cfg
+ cases := []string{
+ "}",
+ "a",
+ "a [1,2",
+ "a [1 2];",
+ "a (x, 1",
+ "a (x 1);",
+ "a (only);",
+ "a { b 1;",
+ "; a 1;",
+ "a , 1;",
+ "a [ {; ];",
+ "a 1 extra;",
+ }
+ for _, in := range cases {
+ if err := Unmarshal([]byte(in), &c); err == nil {
+ t.Fatalf("expected error for %q", in)
+ }
+ }
+}
+
+func TestUnit_ParseListsMapsEmptyAndTrailingComma(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ L []int `unic:"l"`
+ M map[string]int `unic:"m"`
+ E []int `unic:"e"`
+ N []int `unic:"n"`
+ }
+ var got cfg
+ in := "l [1, 2,];\nm (a, 1, b, 2,);\ne [];\nn 3 4;\n"
+ if err := Unmarshal([]byte(in), &got); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got.L, []int{1, 2}) {
+ t.Fatalf("L=%v", got.L)
+ }
+ if got.M["a"] != 1 || got.M["b"] != 2 {
+ t.Fatalf("M=%v", got.M)
+ }
+ if got.E == nil || len(got.E) != 0 {
+ t.Fatalf("E=%v", got.E)
+ }
+ if !reflect.DeepEqual(got.N, []int{3, 4}) {
+ t.Fatalf("N=%v", got.N)
+ }
+}
+
+func TestUnit_ParseQuotedKeyEmptyValueNested(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ Weird string `unic:"weird key"`
+ Empty string `unic:"empty"`
+ Nest [][]int `unic:"nest"`
+ Maps []map[string]int `unic:"maps"`
+ }
+ in := "'weird key' ok;\nempty;\nnest [[1], [2, 3]];\nmaps [(a, 1)];\n"
+ var got cfg
+ if err := Unmarshal([]byte(in), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Weird != "ok" || got.Empty != "" {
+ t.Fatalf("%+v", got)
+ }
+ if len(got.Nest) != 2 || got.Nest[1][1] != 3 {
+ t.Fatalf("nest=%v", got.Nest)
+ }
+ if got.Maps[0]["a"] != 1 {
+ t.Fatalf("maps=%v", got.Maps)
+ }
+}
+
+func TestUnit_UnmarshalScalarsPointersArrays(t *testing.T) {
+ t.Parallel()
+ type inner struct {
+ X int `unic:"x"`
+ }
+ type cfg struct {
+ B bool `unic:"b"`
+ U uint `unic:"u"`
+ F float64 `unic:"f"`
+ P *int `unic:"p"`
+ S *inner `unic:"s"`
+ Arr [2]int `unic:"arr"`
+ One int `unic:"one"`
+ Untagged int
+ Skip string `unic:"-"`
+ hidden int `unic:"hidden"`
+ }
+ in := "b false;\nu 8;\nf 1.5;\np 7;\ns { x 4; }\narr [9, 10];\none [11];\n"
+ var got cfg
+ if err := Unmarshal([]byte(in), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.B || got.U != 8 || got.F != 1.5 || got.P == nil || *got.P != 7 {
+ t.Fatalf("%+v p=%v", got, got.P)
+ }
+ if got.S == nil || got.S.X != 4 {
+ t.Fatalf("s=%+v", got.S)
+ }
+ if got.Arr != [2]int{9, 10} || got.One != 11 {
+ t.Fatalf("arr=%v one=%d", got.Arr, got.One)
+ }
+ if got.Untagged != 0 || got.Skip != "" || got.hidden != 0 {
+ t.Fatalf("skipped fields set: %+v", got)
+ }
+}
+
+func TestUnit_UnmarshalBoolZeroAndTrue(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ A bool `unic:"a"`
+ B bool `unic:"b"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("a 1;\nb 0;"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if !got.A || got.B {
+ t.Fatalf("%+v", got)
+ }
+}
+
+func TestUnit_UnmarshalNestedDefaultsAndAttrDefault(t *testing.T) {
+ t.Parallel()
+ type item struct {
+ Tag string `unic:"tag,attr=1,default=web"`
+ N int `unic:"n"`
+ }
+ type cfg struct {
+ Inner struct {
+ X int `unic:"x,default=3"`
+ } `unic:"inner"`
+ Item item `unic:"item"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("item { n 2; }"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Inner.X != 3 {
+ t.Fatalf("inner default %d", got.Inner.X)
+ }
+ if got.Item.Tag != "web" || got.Item.N != 2 {
+ t.Fatalf("%+v", got.Item)
+ }
+}
+
+func TestUnit_UnmarshalBlockMapAndParenMap(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ Block map[string]int `unic:"block"`
+ Paren map[int]int `unic:"paren"`
+ }
+ var got cfg
+ in := "block { a 1; b 2; }\nparen (1, 10, 2, 20);\n"
+ if err := Unmarshal([]byte(in), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Block["a"] != 1 || got.Block["b"] != 2 {
+ t.Fatalf("block=%v", got.Block)
+ }
+ if got.Paren[1] != 10 || got.Paren[2] != 20 {
+ t.Fatalf("paren=%v", got.Paren)
+ }
+}
+
+func TestUnit_UnmarshalInterfaceAndAnyTree(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ V any `unic:"v"`
+ W any `unic:"w"`
+ M any `unic:"m"`
+ }
+ var got cfg
+ in := "v { a 1; a 2; a 3; }\nw 1.25;\nm (k, v);\n"
+ if err := Unmarshal([]byte(in), &got); err != nil {
+ t.Fatal(err)
+ }
+ m := got.V.(map[string]any)
+ sl := m["a"].([]any)
+ if len(sl) != 3 || sl[0].(int64) != 1 || sl[2].(int64) != 3 {
+ t.Fatalf("v=%v", got.V)
+ }
+ if got.W.(float64) != 1.25 {
+ t.Fatalf("w=%v", got.W)
+ }
+ mp := got.M.(map[string]any)
+ if mp["k"] != "v" {
+ t.Fatalf("m=%v", got.M)
+ }
+}
+
+func TestUnit_UnmarshalTypeErrors(t *testing.T) {
+ t.Parallel()
+ type cfgUint struct {
+ U uint `unic:"u"`
+ }
+ type cfgFloat struct {
+ F float32 `unic:"f"`
+ }
+ type cfgDup struct {
+ A int `unic:"a"`
+ }
+ type cfgArr struct {
+ A [1]int `unic:"a"`
+ }
+ type cfgMapKey struct {
+ M map[int]int `unic:"m"`
+ }
+ type cfgDupTag struct {
+ A int `unic:"x"`
+ B int `unic:"x"`
+ }
+ type cfgBlock struct {
+ N int `unic:"n"`
+ }
+ cases := []struct {
+ dst any
+ in string
+ }{
+ {&cfgUint{}, "u -1;"},
+ {&cfgFloat{}, "f nope;"},
+ {&cfgDup{}, "a 1; a 2;"},
+ {&cfgArr{}, "a [1, 2];"},
+ {&cfgMapKey{}, "m { a 1; }"},
+ {&cfgDupTag{}, "x 1;"},
+ {&cfgBlock{}, "n { x 1; }"},
+ {&cfgDup{}, "a [1, 2];"},
+ }
+ for _, tt := range cases {
+ if err := Unmarshal([]byte(tt.in), tt.dst); err == nil {
+ t.Fatalf("expected error for %q -> %T", tt.in, tt.dst)
+ }
+ }
+}
+
+func TestUnit_UnmarshalOmitemptyEmptyMapAndScalar(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ S string `unic:"s,omitempty"`
+ M map[string]string `unic:"m,omitempty"`
+ B struct {
+ X int `unic:"x"`
+ } `unic:"b,omitempty"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("s ;\nm ();\nb {}"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.S != "" || got.M != nil || got.B.X != 0 {
+ t.Fatalf("%+v", got)
+ }
+}
+
+func TestUnit_UnmarshalUnknownFieldsIgnored(t *testing.T) {
+ t.Parallel()
+ type cfg struct {
+ A int `unic:"a"`
+ }
+ var got cfg
+ if err := Unmarshal([]byte("a 1;\nzzz 2;"), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.A != 1 {
+ t.Fatal(got)
+ }
+}
+
+/*
+goos: linux
+goarch: amd64
+pkg: go.osspkg.com/unic
+cpu: 12th Gen Intel(R) Core(TM) i9-12900KF
+Benchmark_Unmarshal
+Benchmark_Unmarshal-24 283897 5598 ns/op 10318 B/op 185 allocs/op
+PASS
+*/
+func Benchmark_Unmarshal(b *testing.B) {
+ configData := []byte(readmeConfig)
+ b.ReportAllocs()
+ b.ResetTimer()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ var cfg readmeConfigStruct
+ if err := Unmarshal(configData, &cfg); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+}
+
+/*
+goos: linux
+goarch: amd64
+pkg: go.osspkg.com/unic
+cpu: 12th Gen Intel(R) Core(TM) i9-12900KF
+Benchmark_Marshal
+Benchmark_Marshal-24 1685868 707.7 ns/op 1363 B/op 35 allocs/op
+PASS
+*/
+func Benchmark_Marshal(b *testing.B) {
+ configData := []byte(readmeConfig)
+ var cfg readmeConfigStruct
+ if err := Unmarshal(configData, &cfg); err != nil {
+ b.Fatal(err)
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ if _, err := Marshal(cfg); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+}