Files
go-toml/errors.go
Thomas Pelletier 003aa0993b 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 <noreply@anthropic.com>
2026-01-09 11:08:31 -05:00

284 lines
6.7 KiB
Go

package toml
import (
"fmt"
"reflect"
"strconv"
"strings"
"github.com/pelletier/go-toml/v2/unstable"
)
// DecodeError represents an error encountered during the parsing or decoding
// of a TOML document.
//
// In addition to the error message, it contains the position in the document
// where it happened, as well as a human-readable representation that shows
// where the error occurred in the document.
type DecodeError struct {
message string
line int
column int
key Key
human string
}
// StrictMissingError occurs in a TOML document that does not have a
// corresponding field in the target value. It contains all the missing fields
// in Errors.
//
// Emitted by Decoder when DisallowUnknownFields() was called.
type StrictMissingError struct {
// One error per field that could not be found.
Errors []DecodeError
}
// Error returns the canonical string for this error.
func (s *StrictMissingError) Error() string {
return "strict mode: fields in the document are missing in the target struct"
}
// String returns a human readable description of all errors.
func (s *StrictMissingError) String() string {
var buf strings.Builder
for i, e := range s.Errors {
if i > 0 {
buf.WriteString("\n---\n")
}
buf.WriteString(e.String())
}
return buf.String()
}
// Unwrap returns wrapped decode errors
//
// Implements errors.Join() interface.
func (s *StrictMissingError) Unwrap() []error {
errs := make([]error, len(s.Errors))
for i := range s.Errors {
errs[i] = &s.Errors[i]
}
return errs
}
// Key represents a TOML key as a sequence of key parts.
type Key []string
// Error returns the error message contained in the DecodeError.
func (e *DecodeError) Error() string {
return "toml: " + e.message
}
// String returns the human-readable contextualized error. This string is multi-line.
func (e *DecodeError) String() string {
return e.human
}
// Position returns the (line, column) pair indicating where the error
// occurred in the document. Positions are 1-indexed.
func (e *DecodeError) Position() (row int, column int) {
return e.line, e.column
}
// Key that was being processed when the error occurred. The key is present only
// if this DecodeError is part of a StrictMissingError.
func (e *DecodeError) Key() Key {
return e.key
}
// wrapDecodeError creates a DecodeError referencing a highlighted
// range of bytes from document.
//
// highlight needs to be a sub-slice of document, or this function panics.
//
// The function copies all bytes used in DecodeError, so that document and
// highlight can be freely deallocated.
//
//nolint:funlen
func wrapDecodeError(document []byte, de *unstable.ParserError) *DecodeError {
offset := subsliceOffset(document, de.Highlight)
errMessage := de.Error()
errLine, errColumn := positionAtEnd(document[:offset])
before, after := linesOfContext(document, de.Highlight, offset, 3)
var buf strings.Builder
maxLine := errLine + len(after) - 1
lineColumnWidth := len(strconv.Itoa(maxLine))
// Write the lines of context strictly before the error.
for i := len(before) - 1; i > 0; i-- {
line := errLine - i
buf.WriteString(formatLineNumber(line, lineColumnWidth))
buf.WriteString("|")
if len(before[i]) > 0 {
buf.WriteString(" ")
buf.Write(before[i])
}
buf.WriteRune('\n')
}
// Write the document line that contains the error.
buf.WriteString(formatLineNumber(errLine, lineColumnWidth))
buf.WriteString("| ")
if len(before) > 0 {
buf.Write(before[0])
}
buf.Write(de.Highlight)
if len(after) > 0 {
buf.Write(after[0])
}
buf.WriteRune('\n')
// Write the line with the error message itself (so it does not have a line
// number).
buf.WriteString(strings.Repeat(" ", lineColumnWidth))
buf.WriteString("| ")
if len(before) > 0 {
buf.WriteString(strings.Repeat(" ", len(before[0])))
}
buf.WriteString(strings.Repeat("~", len(de.Highlight)))
if len(errMessage) > 0 {
buf.WriteString(" ")
buf.WriteString(errMessage)
}
// Write the lines of context strictly after the error.
for i := 1; i < len(after); i++ {
buf.WriteRune('\n')
line := errLine + i
buf.WriteString(formatLineNumber(line, lineColumnWidth))
buf.WriteString("|")
if len(after[i]) > 0 {
buf.WriteString(" ")
buf.Write(after[i])
}
}
return &DecodeError{
message: errMessage,
line: errLine,
column: errColumn,
key: de.Key,
human: buf.String(),
}
}
func formatLineNumber(line int, width int) string {
format := "%" + strconv.Itoa(width) + "d"
return fmt.Sprintf(format, line)
}
func linesOfContext(document []byte, highlight []byte, offset int, linesAround int) ([][]byte, [][]byte) {
return beforeLines(document, offset, linesAround), afterLines(document, highlight, offset, linesAround)
}
func beforeLines(document []byte, offset int, linesAround int) [][]byte {
var beforeLines [][]byte
// Walk the document backward from the highlight to find previous lines
// of context.
rest := document[:offset]
backward:
for o := len(rest) - 1; o >= 0 && len(beforeLines) <= linesAround && len(rest) > 0; {
switch {
case rest[o] == '\n':
// handle individual lines
beforeLines = append(beforeLines, rest[o+1:])
rest = rest[:o]
o = len(rest) - 1
case o == 0:
// add the first line only if it's non-empty
beforeLines = append(beforeLines, rest)
break backward
default:
o--
}
}
return beforeLines
}
func afterLines(document []byte, highlight []byte, offset int, linesAround int) [][]byte {
var afterLines [][]byte
// Walk the document forward from the highlight to find the following
// lines of context.
rest := document[offset+len(highlight):]
forward:
for o := 0; o < len(rest) && len(afterLines) <= linesAround; {
switch {
case rest[o] == '\n':
// handle individual lines
afterLines = append(afterLines, rest[:o])
rest = rest[o+1:]
o = 0
case o == len(rest)-1:
// add last line only if it's non-empty
afterLines = append(afterLines, rest)
break forward
default:
o++
}
}
return afterLines
}
func positionAtEnd(b []byte) (row int, column int) {
row = 1
column = 1
for _, c := range b {
if c == '\n' {
row++
column = 1
} else {
column++
}
}
return row, column
}
// subsliceOffset returns the byte offset of subslice within data.
// subslice must share the same backing array as data.
func subsliceOffset(data []byte, subslice []byte) int {
if len(subslice) == 0 {
return 0
}
// Use reflect to get the data pointers of both slices.
// This is safe because we're only reading the pointer values for comparison.
dataPtr := reflect.ValueOf(data).Pointer()
subPtr := reflect.ValueOf(subslice).Pointer()
offset := int(subPtr - dataPtr)
if offset < 0 || offset > len(data) {
panic("subslice is not within data")
}
return offset
}