From 003aa0993b0dcb7fbd49d1ad59e2f30a19031c92 Mon Sep 17 00:00:00 2001 From: Thomas Pelletier Date: Fri, 9 Jan 2026 11:08:31 -0500 Subject: [PATCH] Fix nil pointer map values not being marshaled (#1025) When marshaling a map with nil pointer values, the keys were being silently dropped, breaking round-trip fidelity. For example: map[string]*struct{}{"foo": nil} Would produce an empty TOML document instead of "[foo]". This change converts nil pointer values in maps to their zero values (consistent with how nil pointers in slices are handled), allowing the keys to be preserved as empty tables. Nil interface values (map[string]any{"foo": nil}) are still skipped since there's no type information to derive a zero value. Fixes #975 Also, pin golangci-lint version to v2.8.0 in CI and document in AGENTS.md - Explicitly set golangci-lint version in lint.yml to ensure consistent behavior across CI runs - Update AGENTS.md with instructions to use the same linter version locally --------- Co-authored-by: Claude --- .github/workflows/lint.yml | 2 + AGENTS.md | 7 +++ errors.go | 2 +- .../imported_tests/unmarshal_imported_test.go | 2 +- marshaler.go | 9 +++- marshaler_test.go | 48 ++++++++++++++++++- 6 files changed, 66 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ded0f37..8b24df5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,3 +18,5 @@ jobs: go-version: "1.24" - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 + with: + version: v2.8.0 diff --git a/AGENTS.md b/AGENTS.md index 14a9d34..dafe44d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,13 @@ go-toml is a TOML library for Go. The goal is to provide an easy-to-use and effi - Follow existing code format and structure - Code must pass `go fmt` +- Code must pass linting with the same golangci-lint version as CI (see version in `.github/workflows/lint.yml`): + ```bash + # Install specific version (check lint.yml for current version) + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin + # Run linter + golangci-lint run ./... + ``` ### Commit Messages diff --git a/errors.go b/errors.go index 4c5b137..d68835d 100644 --- a/errors.go +++ b/errors.go @@ -260,7 +260,7 @@ func positionAtEnd(b []byte) (row int, column int) { } } - return + return row, column } // subsliceOffset returns the byte offset of subslice within data. diff --git a/internal/imported_tests/unmarshal_imported_test.go b/internal/imported_tests/unmarshal_imported_test.go index 91c69fc..ebead59 100644 --- a/internal/imported_tests/unmarshal_imported_test.go +++ b/internal/imported_tests/unmarshal_imported_test.go @@ -1996,7 +1996,7 @@ func TestDecoderStrict(t *testing.T) { var se *toml.StrictMissingError assert.True(t, errors.As(err, &se)) - keys := []toml.Key{} + keys := make([]toml.Key, 0, len(se.Errors)) for _, e := range se.Errors { keys = append(keys, e.Key()) diff --git a/marshaler.go b/marshaler.go index ba4f042..9ff3a75 100644 --- a/marshaler.go +++ b/marshaler.go @@ -705,7 +705,14 @@ func (enc *Encoder) encodeMap(b []byte, ctx encoderCtx, v reflect.Value) ([]byte v := iter.Value() if isNil(v) { - continue + // For nil pointers, convert to zero value of the element type. + // This allows round-trip marshaling of maps with nil pointer values. + // For nil interfaces and nil maps, skip since we can't derive a type. + if v.Kind() == reflect.Ptr { + v = reflect.Zero(v.Type().Elem()) + } else { + continue + } } k, err := enc.keyToString(iter.Key()) diff --git a/marshaler_test.go b/marshaler_test.go index 057b0a5..5617e74 100644 --- a/marshaler_test.go +++ b/marshaler_test.go @@ -619,12 +619,36 @@ hello = 'world' expected: ``, }, { - desc: "nil value in map is ignored", + desc: "nil interface value in map is ignored", v: map[string]interface{}{ "A": nil, }, expected: ``, }, + { + desc: "nil pointer to struct in map produces empty table", + v: map[string]*struct{}{ + "A": nil, + }, + expected: `[A] +`, + }, + { + desc: "nil pointer to int in map produces zero value", + v: map[string]*int{ + "A": nil, + }, + expected: `A = 0 +`, + }, + { + desc: "nil pointer to string in map produces empty string", + v: map[string]*string{ + "A": nil, + }, + expected: `A = '' +`, + }, { desc: "new line in table key", v: map[string]interface{}{ @@ -2193,3 +2217,25 @@ port = 4242 ` assert.Equal(t, expected, string(out)) } + +// TestMarshalIssue975 tests that nil pointer values in maps are marshaled as +// empty tables, allowing round-trip marshaling to work correctly. +// See https://github.com/pelletier/go-toml/issues/975 +func TestMarshalIssue975(t *testing.T) { + // Test case from the issue: map[string]*struct{} + oldMap := map[string]*struct{}{ + "foo": nil, + } + + doc, err := toml.Marshal(&oldMap) + assert.NoError(t, err) + assert.Equal(t, "[foo]\n", string(doc)) + + var newMap map[string]*struct{} + err = toml.Unmarshal(doc, &newMap) + assert.NoError(t, err) + + // Verify the key is preserved after round-trip + _, exists := newMap["foo"] + assert.True(t, exists, "key 'foo' should exist after round-trip") +}