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 +![GitHub Release](https://img.shields.io/github/v/release/osspkg/go-unic) +![License](https://img.shields.io/badge/license-BSD--3--Clause-blue) +[![GoDoc](https://pkg.go.dev/badge/go.osspkg.com/unic)](https://pkg.go.dev/go.osspkg.com/unic) +[![RU Lang](https://img.shields.io/badge/lang-RU-green?style=flat)](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) + +![GitHub Release](https://img.shields.io/github/v/release/osspkg/go-unic) +![License](https://img.shields.io/badge/license-BSD--3--Clause-blue) +[![GoDoc](https://pkg.go.dev/badge/go.osspkg.com/unic)](https://pkg.go.dev/go.osspkg.com/unic) +[![EN Lang](https://img.shields.io/badge/lang-EN-green?style=flat)](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) + } + } + }) +}