From cbabfbd3941692f2c195256ec8a3352b38db4255 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Thu, 3 Sep 2026 07:21:00 +0300 Subject: [PATCH] fix panic --- .gitignore | 3 +- README.md | 13 +- README.ru.md | 15 +- decode.go | 4 +- decode_test.go | 20 +++ docs/skills/osspkg-lib-unic-usage/SKILL.md | 41 +++++ .../references/code-examples.md | 169 ++++++++++++++++++ fuzz_test.go | 79 ++++++++ merge.go | 42 ++--- merge_test.go | 48 +++++ scan.go | 10 +- 11 files changed, 415 insertions(+), 29 deletions(-) create mode 100644 docs/skills/osspkg-lib-unic-usage/SKILL.md create mode 100644 docs/skills/osspkg-lib-unic-usage/references/code-examples.md create mode 100644 fuzz_test.go create mode 100644 merge_test.go diff --git a/.gitignore b/.gitignore index 2d09f4d..2ca7bf0 100755 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ coverage.out *.mmdb *.test *.out -.env \ No newline at end of file +.env +testdata/ \ No newline at end of file diff --git a/README.md b/README.md index 7766440..d86ab62 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ ## πŸš€ 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). +- **Support for the major Go field 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. @@ -122,6 +122,9 @@ func main() { } ``` +`Marshal` accepts a struct or a pointer to a struct. Maps, slices, and scalar +values are supported as fields; a map cannot be passed as the top-level value. + --- ## πŸ“ UNIC Syntax @@ -146,6 +149,9 @@ To avoid conflicts with system characters (`{}[]();,#`), spaces, quotes, or line - 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"``` ``. +Input must be valid UTF-8. Invalid UTF-8 sequences are rejected with a parse +error. + Example: ``` @@ -264,7 +270,10 @@ type Config struct { ### 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. +`unic.Marshal` accepts several struct arguments – all are written into one +document. Equal repeated scalar values are deduplicated, while struct values +without attributes and with the same name are combined into one block. Maps, +slices, and values with attributes are not structurally merged. ```go package main diff --git a/README.ru.md b/README.ru.md index a38b382..2263105 100644 --- a/README.ru.md +++ b/README.ru.md @@ -32,7 +32,7 @@ Configuration Format (UNIC)**. UNIC сочСтаСт Π² сСбС Ρ‡ΠΈΡ‚Π°Π΅ΠΌΠΎ ## πŸš€ ВозмоТности - **Π§Π΅Π»ΠΎΠ²Π΅ΠΊΠΎΡ‡ΠΈΡ‚Π°Π΅ΠΌΡ‹ΠΉ синтаксис** – ΠΈΠ½Ρ‚ΡƒΠΈΡ‚ΠΈΠ²Π½ΠΎ понятный, Π±Π΅Π· Π»ΠΈΡˆΠ½ΠΈΡ… символов (ΠΊΠ°ΠΊ Nginx ΠΈΠ»ΠΈ HCL). -- **ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° всСх основных Ρ‚ΠΈΠΏΠΎΠ² Go**: структуры, срСзы, ΠΊΠ°Ρ€Ρ‚Ρ‹, скаляры (числа, строки, Π±ΡƒΠ»Π΅Π²Ρ‹). +- **ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° основных Ρ‚ΠΈΠΏΠΎΠ² ΠΏΠΎΠ»Π΅ΠΉ Go**: структуры, срСзы, ΠΊΠ°Ρ€Ρ‚Ρ‹, скаляры (числа, строки, Π±ΡƒΠ»Π΅Π²Ρ‹). - **Π“ΠΈΠ±ΠΊΠΎΠ΅ ΡƒΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ Ρ‡Π΅Ρ€Π΅Π· Ρ‚Π΅Π³ΠΈ** – Π·Π°Π΄Π°Π²Π°ΠΉΡ‚Π΅ ΠΈΠΌΠ΅Π½Π° ΠΏΠΎΠ»Π΅ΠΉ, значСния ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ, пропуск пустых ΠΏΠΎΠ»Π΅ΠΉ, Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚Ρ‹, ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΈ. - **БлияниС структур** – автоматичСскоС объСдинСниС ΠΏΠΎΠ»Π΅ΠΉ ΠΏΡ€ΠΈ сСриализации Π½Π΅ΡΠΊΠΎΠ»ΡŒΠΊΠΈΡ… ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² с ΠΎΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²Ρ‹ΠΌ ΠΊΠ»ΡŽΡ‡ΠΎΠΌ. - **Π’Π»ΠΎΠΆΠ΅Π½Π½ΠΎΡΡ‚ΡŒ любой Π³Π»ΡƒΠ±ΠΈΠ½Ρ‹** – Π±Π»ΠΎΠΊΠΈ, списки, ΠΊΠ°Ρ€Ρ‚Ρ‹ ΠΌΠΎΠΆΠ½ΠΎ ΠΊΠΎΠΌΠ±ΠΈΠ½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ. @@ -123,6 +123,10 @@ func main() { } ``` +`Marshal` ΠΏΡ€ΠΈΠ½ΠΈΠΌΠ°Π΅Ρ‚ структуру ΠΈΠ»ΠΈ ΡƒΠΊΠ°Π·Π°Ρ‚Π΅Π»ΡŒ Π½Π° структуру. ΠšΠ°Ρ€Ρ‚Ρ‹, срСзы ΠΈ +скаляры ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°ΡŽΡ‚ΡΡ ΠΊΠ°ΠΊ поля структуры; ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‚ΡŒ ΠΊΠ°Ρ€Ρ‚Ρƒ нСпосрСдствСнно ΠΊΠ°ΠΊ +ΠΊΠΎΡ€Π½Π΅Π²ΠΎΠ΅ Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ нСльзя. + --- ## πŸ“ Бинтаксис Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π° UNIC @@ -147,6 +151,9 @@ func main() { - Если строка содСрТит `'`, `{`, `}`, `[`, `]`, `(`, `)`, `#`, `;`, `,` ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΠ±Π΅Π»Ρ‹ – обрамляСм Π΄Π²ΠΎΠΉΠ½Ρ‹ΠΌΠΈ ΠΊΠ°Π²Ρ‹Ρ‡ΠΊΠ°ΠΌΠΈ: `"hello 'world'"`. - Если строка содСрТит ΠΎΠ΄Π½ΠΎΠ²Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎ `'` ΠΈ `"`, Π° Ρ‚Π°ΠΊΠΆΠ΅ спСцсимволы ΠΈΠ»ΠΈ пСрСносы строк – ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ Ρ‚Ρ€ΠΎΠΉΠ½Ρ‹Π΅ ΠΎΠ±Ρ€Π°Ρ‚Π½Ρ‹Π΅ ΠΊΠ°Π²Ρ‹Ρ‡ΠΊΠΈ: `` ```hello 'world' "foo"``` ``. +Π’Ρ…ΠΎΠ΄Π½Ρ‹Π΅ Π΄Π°Π½Π½Ρ‹Π΅ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ΠΌ UTF-8. НСкоррСктныС UTF-8 +ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ ΠΎΡ‚ΠΊΠ»ΠΎΠ½ΡΡŽΡ‚ΡΡ с ошибкой Ρ€Π°Π·Π±ΠΎΡ€Π°. + ΠŸΡ€ΠΈΠΌΠ΅Ρ€: ``` @@ -268,8 +275,10 @@ type Config struct { ### БСриализация Π½Π΅ΡΠΊΠΎΠ»ΡŒΠΊΠΈΡ… структур Π² ΠΎΠ΄ΠΈΠ½ Ρ„Π°ΠΉΠ» -`unic.Marshal` ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΡ€ΠΈΠ½ΠΈΠΌΠ°Ρ‚ΡŒ нСсколько Π°Ρ€Π³ΡƒΠΌΠ΅Π½Ρ‚ΠΎΠ² – всС ΠΎΠ½ΠΈ Π±ΡƒΠ΄ΡƒΡ‚ ΠΎΠ±ΡŠΠ΅Π΄ΠΈΠ½Π΅Π½Ρ‹ Π² ΠΎΠ΄ΠΈΠ½ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚. Если поля с ΠΎΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²Ρ‹ΠΌΠΈ ΠΈΠΌΠ΅Π½Π°ΠΌΠΈ ΠΏΡ€ΠΈΡΡƒΡ‚ΡΡ‚Π²ΡƒΡŽΡ‚ Π² Ρ€Π°Π·Π½Ρ‹Ρ… -структурах, ΠΎΠ½ΠΈ Π±ΡƒΠ΄ΡƒΡ‚ ΠΎΠ±ΡŠΠ΅Π΄ΠΈΠ½Π΅Π½Ρ‹ (слоТСны) Π² ΠΎΠ΄ΠΈΠ½ Π±Π»ΠΎΠΊ. +`unic.Marshal` ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΡ€ΠΈΠ½ΠΈΠΌΠ°Ρ‚ΡŒ нСсколько структур – всС ΠΎΠ½ΠΈ Π±ΡƒΠ΄ΡƒΡ‚ записаны Π² +ΠΎΠ΄ΠΈΠ½ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚. ΠžΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²Ρ‹Π΅ ΠΏΠΎΠ²Ρ‚ΠΎΡ€ΡΡŽΡ‰ΠΈΠ΅ΡΡ скалярныС значСния ΡƒΠ΄Π°Π»ΡΡŽΡ‚ΡΡ ΠΈΠ· +ΠΏΠΎΠ²Ρ‚ΠΎΡ€ΠΎΠ², Π° структуры Π±Π΅Π· Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚ΠΎΠ² с ΠΎΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²Ρ‹ΠΌ ΠΈΠΌΠ΅Π½Π΅ΠΌ ΠΎΠ±ΡŠΠ΅Π΄ΠΈΠ½ΡΡŽΡ‚ΡΡ Π² ΠΎΠ΄ΠΈΠ½ +Π±Π»ΠΎΠΊ. ΠšΠ°Ρ€Ρ‚Ρ‹, срСзы ΠΈ значСния с Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚Π°ΠΌΠΈ структурно Π½Π΅ ΠΎΠ±ΡŠΠ΅Π΄ΠΈΠ½ΡΡŽΡ‚ΡΡ. ```go package main diff --git a/decode.go b/decode.go index ec34509..d2296eb 100644 --- a/decode.go +++ b/decode.go @@ -237,7 +237,9 @@ func decodeBlockMap(dv reflect.Value, n *node, path string) error { if err := decodeValue(el, fl.val, joinPath(path, fl.key)); err != nil { return err } - dv.SetMapIndex(reflect.ValueOf(fl.key), el) + key := reflect.New(dv.Type().Key()).Elem() + key.SetString(fl.key) + dv.SetMapIndex(key, el) } return nil } diff --git a/decode_test.go b/decode_test.go index 1f4fcfa..e771459 100644 --- a/decode_test.go +++ b/decode_test.go @@ -73,6 +73,26 @@ func TestUnit_HelpersEmptyNodeAndAsAny(t *testing.T) { } } +func TestUnit_UnmarshalBlockMapNamedStringKey(t *testing.T) { + t.Parallel() + + type key string + type item struct { + Value int `unic:"value"` + } + type config struct { + Items map[key]item `unic:"items"` + } + + var got config + if err := Unmarshal([]byte("items { alpha { value 7; } }"), &got); err != nil { + t.Fatal(err) + } + if got.Items[key("alpha")].Value != 7 { + t.Fatalf("items=%v", got.Items) + } +} + type ioStrError string func (e ioStrError) Error() string { return string(e) } diff --git a/docs/skills/osspkg-lib-unic-usage/SKILL.md b/docs/skills/osspkg-lib-unic-usage/SKILL.md new file mode 100644 index 0000000..ec56e90 --- /dev/null +++ b/docs/skills/osspkg-lib-unic-usage/SKILL.md @@ -0,0 +1,41 @@ +--- +name: osspkg-lib-unic-usage +description: Use the go.osspkg.com/unic library correctly in Go code for UNIC configuration parsing and serialization. +--- + +# UNIC usage skill + +Use this skill when writing, reviewing, or explaining Go code that reads or +writes UNIC configuration with `go.osspkg.com/unic`. + +## Core rules + +- Decode into a non-nil pointer to a struct: + `unic.Unmarshal(data, &cfg)`. +- Encode a struct or a non-nil pointer to a struct: + `unic.Marshal(cfg)` or `unic.Marshal(&cfg)`. +- Maps, slices, scalar values, and `any` are supported as struct fields. A map + or scalar cannot be passed as the top-level argument to `Marshal`. +- Exported fields need a `unic` tag to participate in encoding/decoding. Use + `unic:"-"` or omit the tag to exclude a field. +- Use `default=value` for missing fields, `omitempty` to skip zero values, + `attr=N` for positional block attributes, and `desc=value` for comments. +- Check and return errors from both `Unmarshal` and `Marshal`; malformed input, + type mismatches, invalid tags, and invalid UTF-8 are reported as errors. +- Keep configuration structs explicit and typed. Use `map[string]any` only when + the configuration shape is intentionally dynamic. + +## Workflow + +1. Define an exported configuration struct with stable `unic` names. +2. Read the bytes from the chosen source and call `Unmarshal` with `&cfg`. +3. Validate application-specific invariants after decoding; tags only perform + format-level conversion and defaults. +4. For output, call `Marshal`, handle the error, then write the returned bytes. +5. Add a round-trip test for non-trivial nested configurations and tests for + defaults, optional fields, maps, and attributes when they are used. + +Read [references/code-examples.md](references/code-examples.md) for concrete +correct and incorrect patterns before implementing or reviewing integration +code. + diff --git a/docs/skills/osspkg-lib-unic-usage/references/code-examples.md b/docs/skills/osspkg-lib-unic-usage/references/code-examples.md new file mode 100644 index 0000000..426db01 --- /dev/null +++ b/docs/skills/osspkg-lib-unic-usage/references/code-examples.md @@ -0,0 +1,169 @@ +# go-unic code references + +The examples below are intentionally small patterns for code review and +implementation. They reflect the current public API. + +## Correct: UNIC configuration shape + +This configuration matches the typed example above. Repeated block names map +to a slice, values before `{` map to fields tagged with `attr=N`, and maps use +an even sequence of key/value items. + +```unic +log_level info; +server api 8080 { + tags [public, http]; + env (MODE, production, REGION, eu-west); +} +server admin 9090 { + tags [internal]; + env (MODE, staging); +} +``` + +Strings containing spaces or syntax characters must be quoted. Triple +backticks are used when both quote styles or line breaks are needed. + +```unic +title 'Production service'; +path '/srv/app;current'; +message ```line one +line two```; +``` + +Comments start with `#` and continue to the end of the line: + +```unic +server api 8080 { # public HTTP server + port 8080; # override the default +} +``` + +## Incorrect: malformed or mismatched configuration + +```unic +# Wrong: a block field needs a closing brace. +server api 8080 { + port 8080; + +# Wrong: a list requires commas and a terminating semicolon. +tags [public internal] + +# Wrong: map items must be key/value pairs. +env (MODE, production, REGION); +``` + +Other common mistakes are using an attribute without a matching `attr=N` tag, +putting a scalar where the Go field is a struct, and using a field name that is +not present in the target type when the value is expected to be decoded. Unknown +fields are ignored by the decoder, so application-required fields must be +validated after `Unmarshal`. + +```unic +# Wrong for `Name string `unic:"name,attr=1"```: the first attribute is missing. +server { port 8080; } + +# Wrong for `Tags []string`: this is a scalar, not a list. +tags public; +``` + +## Correct: typed configuration loading + +```go +type Server struct { + Name string `unic:"name,attr=1"` + Port int `unic:"port,default=8080"` + Tags []string `unic:"tags,omitempty"` + Env map[string]string `unic:"env,omitempty"` +} + +type Config struct { + LogLevel string `unic:"log_level,default='info'"` + Servers []Server `unic:"server"` +} + +func loadConfig(data []byte) (Config, error) { + var cfg Config + if err := unic.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("load config: %w", err) + } + if len(cfg.Servers) == 0 { + return Config{}, errors.New("config must define at least one server") + } + return cfg, nil +} +``` + +## Correct: serialization with error handling + +```go +data, err := unic.Marshal(&cfg) +if err != nil { + return fmt.Errorf("serialize config: %w", err) +} +if err := os.WriteFile("config.unic", data, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) +} +``` + +## Correct: dynamic data only where needed + +```go +type Config struct { + Metadata map[string]any `unic:"metadata,omitempty"` +} + +var cfg Config +if err := unic.Unmarshal(data, &cfg); err != nil { + return err +} +``` + +## Incorrect: passing a struct value to `Unmarshal` + +```go +var cfg Config +_ = unic.Unmarshal(data, cfg) // wrong: Unmarshal requires *struct +``` + +The decoder must be able to update the destination. Use `&cfg` and do not pass +`nil` or a non-struct pointer. + +## Incorrect: assuming a top-level map can be marshaled + +```go +data, err := unic.Marshal(map[string]string{"host": "localhost"}) // wrong +``` + +Wrap the map in a tagged struct if it is the document's field: + +```go +type Document struct { + Values map[string]string `unic:"values"` +} + +data, err := unic.Marshal(Document{ + Values: map[string]string{"host": "localhost"}, +}) +``` + +## Incorrect: ignoring conversion and I/O errors + +```go +data, _ := unic.Marshal(cfg) +_ = os.WriteFile("config.unic", data, 0o600) +``` + +Ignoring errors can turn an unsupported field type, invalid tag, or failed +write into a silently incomplete configuration. Return or handle each error. + +## Incorrect: treating tags as application validation + +```go +type Config struct { + Port int `unic:"port,default=8080"` +} +``` + +The default supplies a missing value, but does not enforce a valid port range. +Validate conditions such as `1 <= Port <= 65535` after unmarshalling. diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..6321598 --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,79 @@ +package unic + +import ( + "testing" + + "go.osspkg.com/bb" +) + +func FuzzParseDocument(f *testing.F) { + for _, seed := range []string{ + "", + "name value;", + "server web 80 { host localhost; }", + "items [1, 2, [3, 4],];", + "labels (one, 1, two, 2);", + "text ```line 1\nline 2```;", + "broken { [ ( ;", + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, data []byte) { + buf := bb.FromBytes(data) + if _, err := buf.Seek(0, bb.SeekStart); err != nil { + t.Fatal(err) + } + _, _ = parseDocument(buf) + }) +} + +func FuzzScanDocument(f *testing.F) { + f.Add([]byte{0xff, 0xfe, 0xfd}) + for _, seed := range []string{ + "a;", + "# comment\na 'value';", + "a ```quoted ` value```;", + "'key with spaces' [true, false, 1.5];", + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, data []byte) { + buf := bb.FromBytes(data) + if _, err := buf.Seek(0, bb.SeekStart); err != nil { + t.Fatal(err) + } + s := newScanner(buf) + for i := 0; i < 10000; i++ { + tok, err := s.next() + if err != nil || tok.kind == tokEOF { + return + } + } + t.Fatal("scanner did not terminate") + }) +} + +func FuzzUnmarshalDocument(f *testing.F) { + for _, seed := range []string{ + "value 1;", + "nested { value 'text'; }", + "values [1, 2, 3];", + "data (name, service, enabled, true);", + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, data []byte) { + var dst struct { + Value int `unic:"value"` + Nested struct { + Value string `unic:"value"` + } `unic:"nested"` + Values []int `unic:"values"` + Data map[string]any `unic:"data"` + } + _ = Unmarshal(data, &dst) + }) +} diff --git a/merge.go b/merge.go index 087c240..a097c40 100644 --- a/merge.go +++ b/merge.go @@ -36,23 +36,6 @@ func mergeValues(a, b any) (any, error) { 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 []any{a, b}, nil @@ -116,7 +99,7 @@ func mergeStruct(va, vb reflect.Value) (any, error) { } if result.Field(i).CanSet() { - result.Field(i).Set(reflect.ValueOf(merged)) + setMerged(result.Field(i), merged, fvB) } } } @@ -136,7 +119,7 @@ func mergeMap(va, vb reflect.Value) (any, error) { if err != nil { return nil, err } - result.SetMapIndex(key, reflect.ValueOf(merged)) + result.SetMapIndex(key, mergedValue(merged, valB)) } else { result.SetMapIndex(key, valA) } @@ -171,11 +154,30 @@ func mergeArray(va, vb reflect.Value) (any, error) { if err != nil { return nil, err } - result.Index(i).Set(reflect.ValueOf(merged)) + setMerged(result.Index(i), merged, vb.Index(i)) } return result.Interface(), nil } +//nolint:unused +func setMerged(dst reflect.Value, merged any, fallback reflect.Value) { + value := mergedValue(merged, fallback) + if value.IsValid() && value.Type().AssignableTo(dst.Type()) { + dst.Set(value) + } +} + +//nolint:unused +func mergedValue(merged any, fallback reflect.Value) reflect.Value { + if merged != nil { + value := reflect.ValueOf(merged) + if value.IsValid() && value.Type().AssignableTo(fallback.Type()) { + return value + } + } + return fallback +} + //nolint:unused func structToMap(v reflect.Value) (map[string]any, error) { v = derefValue(v) diff --git a/merge_test.go b/merge_test.go new file mode 100644 index 0000000..7152fd8 --- /dev/null +++ b/merge_test.go @@ -0,0 +1,48 @@ +package unic + +import "testing" + +func TestUnitMergeStructKeepsType(t *testing.T) { + type config struct { + Name string + Port int + } + + got, err := mergeValues(config{Name: "old"}, config{Name: "old", Port: 8080}) + if err != nil { + t.Fatal(err) + } + merged, ok := got.(config) + if !ok || merged.Name != "old" || merged.Port != 8080 { + t.Fatalf("merged=%#v", got) + } +} + +func TestUnitMergePointers(t *testing.T) { + type config struct{ Value int } + a, b := &config{Value: 1}, &config{Value: 2} + + got, err := mergeValues(a, b) + if err != nil { + t.Fatal(err) + } + merged, ok := got.(*config) + if !ok || merged.Value != 2 { + t.Fatalf("merged=%#v", got) + } +} + +func TestUnitMergeTypedMapWithAny(t *testing.T) { + a := map[string]any{"nested": map[string]any{"a": 1}} + b := map[string]any{"nested": map[string]any{"b": 2}} + + got, err := mergeValues(a, b) + if err != nil { + t.Fatal(err) + } + merged := got.(map[string]any) + nested := merged["nested"].(map[string]any) + if nested["a"] != 1 || nested["b"] != 2 { + t.Fatalf("merged=%#v", got) + } +} diff --git a/scan.go b/scan.go index 2cc58dd..7e7a7d1 100644 --- a/scan.go +++ b/scan.go @@ -250,10 +250,13 @@ func (s *scanner) skipComment() error { } func (s *scanner) peekRune() (rune, error) { - r, _, err := s.buf.ReadRune() + r, size, err := s.buf.ReadRune() if err != nil { return 0, err } + if r == utf8.RuneError && size == 1 { + return 0, fmt.Errorf("invalid UTF-8 sequence") + } if err = s.buf.UnreadRune(); err != nil { return 0, err } @@ -261,10 +264,13 @@ func (s *scanner) peekRune() (rune, error) { } func (s *scanner) readRune() (rune, error) { - r, _, err := s.buf.ReadRune() + r, size, err := s.buf.ReadRune() if err != nil { return 0, err } + if r == utf8.RuneError && size == 1 { + return 0, fmt.Errorf("invalid UTF-8 sequence") + } s.prevLine, s.prevCol = s.line, s.col if r == '\n' { s.line++