From d9ff80d9957dba18a0b34271f49b76d6d9bb9eaf Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Thu, 20 Aug 2026 17:07:36 +0300 Subject: [PATCH 1/2] first release --- .github/dependabot.yml | 7 - .github/workflows/ci.yml | 27 --- .github/workflows/codeql.yml | 38 ---- .gitignore | 22 -- .golangci.yml | 375 ---------------------------------- .idea/.gitignore | 8 - .idea/go-coma.iml | 9 - .idea/modules.xml | 8 - .idea/vcs.xml | 6 - LICENSE | 28 --- Makefile | 31 --- README.md | 4 - decoder.go | 25 --- encoder.go | 19 -- go.mod | 3 - internal/decode/decode.go | 99 --------- internal/dict/dict.go | 61 ------ internal/encode/encode.go | 27 --- internal/node/block.go | 57 ------ internal/node/draw.go | 52 ----- internal/node/key.go | 30 --- internal/node/search.go | 19 -- internal/splitter/splitter.go | 52 ----- tests/example1.conf | 27 --- tests/example1.golden.conf | 23 --- tests/unit_test.go | 60 ------ unic.go | 23 --- 27 files changed, 1140 deletions(-) delete mode 100755 .github/dependabot.yml delete mode 100755 .github/workflows/ci.yml delete mode 100755 .github/workflows/codeql.yml delete mode 100644 .gitignore delete mode 100755 .golangci.yml delete mode 100644 .idea/.gitignore delete mode 100644 .idea/go-coma.iml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml delete mode 100644 LICENSE delete mode 100755 Makefile delete mode 100644 README.md delete mode 100644 decoder.go delete mode 100644 encoder.go delete mode 100644 go.mod delete mode 100644 internal/decode/decode.go delete mode 100644 internal/dict/dict.go delete mode 100644 internal/encode/encode.go delete mode 100644 internal/node/block.go delete mode 100644 internal/node/draw.go delete mode 100644 internal/node/key.go delete mode 100644 internal/node/search.go delete mode 100644 internal/splitter/splitter.go delete mode 100644 tests/example1.conf delete mode 100644 tests/example1.golden.conf delete mode 100644 tests/unit_test.go delete mode 100644 unic.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100755 index da0407c..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ - -version: 2 -updates: - - package-ecosystem: "gomod" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100755 index ab48fe9..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,27 +0,0 @@ - -name: CI - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - go: [ '1.20', '1.21', '1.22' ] - steps: - - uses: actions/checkout@v3 - - - name: Setup Go - uses: actions/setup-go@v3 - with: - go-version: ${{ matrix.go }} - - - name: Run CI - env: - COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} - run: make ci 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 deleted file mode 100644 index 815f025..0000000 --- a/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -# 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 -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.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/ diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100755 index 8a69106..0000000 --- a/.golangci.yml +++ /dev/null @@ -1,375 +0,0 @@ - -# 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 - 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 - -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 - -# 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 - -# all available settings of specific linters -linters-settings: - govet: - # report about shadowed variables - check-shadowing: true - 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 - -linters: - disable-all: true - 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/LICENSE b/LICENSE deleted file mode 100644 index 2e4e67c..0000000 --- a/LICENSE +++ /dev/null @@ -1,28 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2024, OSSPkg Team - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile deleted file mode 100755 index 918d40d..0000000 --- a/Makefile +++ /dev/null @@ -1,31 +0,0 @@ - -.PHONY: install -install: - go install github.com/osspkg/devtool@latest - -.PHONY: setup -setup: - devtool setup-lib - -.PHONY: lint -lint: - devtool lint - -.PHONY: license -license: - devtool license - -.PHONY: build -build: - devtool build --arch=amd64 - -.PHONY: tests -tests: - devtool test - -.PHONY: pre-commite -pre-commite: setup lint build tests - -.PHONY: ci -ci: install setup lint build tests - diff --git a/README.md b/README.md deleted file mode 100644 index f562f1d..0000000 --- a/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# go-unic - -Universal Configuration Format - 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/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/go.mod b/go.mod deleted file mode 100644 index 0a197cd..0000000 --- a/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module go.osspkg.com/unic - -go 1.22.5 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/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/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 deleted file mode 100644 index 81e466d..0000000 --- a/unic.go +++ /dev/null @@ -1,23 +0,0 @@ -package unic - -import "bytes" - -func Marshal(in interface{}) ([]byte, error) { - var buf bytes.Buffer - enc := NewEncoder(&buf) - if err := enc.Encode(in); err != nil { - return nil, err - } - if err := enc.Done(); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func Unmarshal(in []byte, out interface{}) error { - dec, err := NewDecoder(bytes.NewBuffer(in)) - if err != nil { - return err - } - return dec.Decode(out) -} From b1a13a4576cb8dd6f68835cf446647e04e6a7431 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Thu, 20 Aug 2026 17:08:02 +0300 Subject: [PATCH 2/2] first release --- .github/dependabot.yml | 7 + .github/workflows/ci.yml | 27 ++ .gitignore | 20 ++ .golangci.yml | 68 ++++ .lic.yaml | 3 + LICENSE | 28 ++ Makefile | 31 ++ README.md | 317 ++++++++++++++++ README.ru.md | 323 +++++++++++++++++ decode.go | 542 ++++++++++++++++++++++++++++ decode_test.go | 78 ++++ encode.go | 577 ++++++++++++++++++++++++++++++ encode_test.go | 350 ++++++++++++++++++ errors.go | 21 ++ go.mod | 5 + go.sum | 2 + internal/pool/common.go | 26 ++ internal/pool/pool.go | 31 ++ merge.go | 245 +++++++++++++ parse.go | 343 ++++++++++++++++++ parse_test.go | 35 ++ scan.go | 289 +++++++++++++++ scan_test.go | 118 ++++++ tag.go | 169 +++++++++ tag_test.go | 83 +++++ unic.go | 188 ++++++++++ unic_test.go | 753 +++++++++++++++++++++++++++++++++++++++ 27 files changed, 4679 insertions(+) create mode 100755 .github/dependabot.yml create mode 100755 .github/workflows/ci.yml create mode 100755 .gitignore create mode 100755 .golangci.yml create mode 100755 .lic.yaml create mode 100755 LICENSE create mode 100755 Makefile create mode 100644 README.md create mode 100644 README.ru.md create mode 100644 decode.go create mode 100644 decode_test.go create mode 100644 encode.go create mode 100644 encode_test.go create mode 100644 errors.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/pool/common.go create mode 100644 internal/pool/pool.go create mode 100644 merge.go create mode 100644 parse.go create mode 100644 parse_test.go create mode 100644 scan.go create mode 100644 scan_test.go create mode 100644 tag.go create mode 100644 tag_test.go create mode 100644 unic.go create mode 100644 unic_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100755 index 0000000..da0407c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100755 index 0000000..7270150 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ + +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + go: [ '1.26' ] + steps: + - uses: actions/checkout@v3 + + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go }} + + - name: Run CI + env: + COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} + run: make ci diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..2d09f4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ + +.tools/ +bin/ +vendor/ +build/ +.idea/ +.vscode/ +coverage.txt +coverage.out +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.db +*.db-journal +*.mmdb +*.test +*.out +.env \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100755 index 0000000..f2b16a7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,68 @@ +version: "2" + +run: + go: "1.26" + timeout: 5m + tests: false + issues-exit-code: 1 + modules-download-mode: readonly + allow-parallel-runners: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + new: false + fix: false + +output: + formats: + text: + print-linter-name: true + print-issued-lines: true + +formatters: + exclusions: + paths: + - vendors/ + enable: + - gofmt + - goimports + +linters: + settings: + staticcheck: + checks: + - all + - -S1023 + - -ST1000 + - -ST1003 + - -ST1020 + gosec: + excludes: + - G104 + - G115 + - G301 + - G304 + - G306 + - G501 + - G505 + exclusions: + paths: + - vendors/ + default: none + enable: + - govet + - errcheck + - misspell + - gocyclo + - ineffassign + - unparam + - unused + - prealloc + - durationcheck + - staticcheck + - makezero + - nilerr + - errorlint + - bodyclose + - gosec 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 new file mode 100755 index 0000000..d6c3bf3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +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: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..c04cf9f --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ + +SHELL=/bin/bash + + +.PHONY: install +install: + go install go.osspkg.com/goppy/v3/cmd/goppy@latest + goppy setup-lib + +.PHONY: lint +lint: + goppy lint + +.PHONY: license +license: + goppy license + +.PHONY: build +build: + goppy build --arch=amd64 + +.PHONY: tests +tests: + goppy test + +.PHONY: pre-commit +pre-commit: install license lint tests build + +.PHONY: ci +ci: pre-commit + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7766440 --- /dev/null +++ b/README.md @@ -0,0 +1,317 @@ +# 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) +[![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/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/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 new file mode 100644 index 0000000..3b1a57c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module go.osspkg.com/unic + +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/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/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/unic.go b/unic.go new file mode 100644 index 0000000..05e6251 --- /dev/null +++ b/unic.go @@ -0,0 +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" + "fmt" + "reflect" + + "go.osspkg.com/bb" + + "go.osspkg.com/unic/internal/pool" +) + +// 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 + } + + 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) + } + + doc, err := parseDocument(buf) + if err != nil { + return err + } + + 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) + } + } + }) +}