encoder.go

// The encoder in this file is an allocation-aware alternative to
// encoding/json for the narrow value shapes that log lines usually contain:
// strings, numbers, booleans, errors, and shallow maps/slices. It is not a
// general JSON encoder -- anything it doesn't recognise falls through to
// encoding/json so behaviour remains correct for unusual value types.
package lambdalog

import (
	"bytes"
	"encoding/json"
	"strconv"
	"sync"
	"time"
	"unicode/utf8"
)

// encPool reuses scratch buffers across encode calls.
var encPool = sync.Pool{
	New: func() any { return new(bytes.Buffer) },
}

// encodeValue appends a JSON representation of v to dst and returns the new
// slice. Bytes are appended directly; the caller owns the backing array.
func encodeValue(dst []byte, v any) []byte {
	switch x := v.(type) {
	case nil:
		return append(dst, 'n', 'u', 'l', 'l')
	case string:
		return appendQuoted(dst, x)
	case []byte:
		return appendQuoted(dst, string(x))
	case bool:
		if x {
			return append(dst, 't', 'r', 'u', 'e')
		}
		return append(dst, 'f', 'a', 'l', 's', 'e')
	case int:
		return strconv.AppendInt(dst, int64(x), 10)
	case int32:
		return strconv.AppendInt(dst, int64(x), 10)
	case int64:
		return strconv.AppendInt(dst, x, 10)
	case uint:
		return strconv.AppendUint(dst, uint64(x), 10)
	case uint32:
		return strconv.AppendUint(dst, uint64(x), 10)
	case uint64:
		return strconv.AppendUint(dst, x, 10)
	case float32:
		return strconv.AppendFloat(dst, float64(x), 'f', -1, 32)
	case float64:
		return strconv.AppendFloat(dst, x, 'f', -1, 64)
	case time.Time:
		return appendQuoted(dst, x.UTC().Format(time.RFC3339Nano))
	case time.Duration:
		return appendQuoted(dst, x.String())
	case error:
		return appendQuoted(dst, x.Error())
	case []string:
		return encodeStringSlice(dst, x)
	case map[string]any:
		return encodeShallowMap(dst, x)
	default:
		b, err := json.Marshal(v)
		if err != nil {
			return appendQuoted(dst, "<unmarshallable>")
		}
		return append(dst, b...)
	}
}

// appendQuoted writes s as a JSON string, escaping the minimal set of
// control characters that the spec requires. The fast path -- all bytes
// printable ASCII and none of them " or \ -- avoids the rune loop.
func appendQuoted(dst []byte, s string) []byte {
	dst = append(dst, '"')
	if isPlainASCII(s) {
		dst = append(dst, s...)
		return append(dst, '"')
	}
	for i := 0; i < len(s); {
		r, size := utf8.DecodeRuneInString(s[i:])
		switch r {
		case '"', '\\':
			dst = append(dst, '\\', byte(r))
		case '\b':
			dst = append(dst, '\\', 'b')
		case '\f':
			dst = append(dst, '\\', 'f')
		case '\n':
			dst = append(dst, '\\', 'n')
		case '\r':
			dst = append(dst, '\\', 'r')
		case '\t':
			dst = append(dst, '\\', 't')
		default:
			if r < 0x20 {
				const hex = "0123456789abcdef"
				dst = append(dst, '\\', 'u', '0', '0', hex[r>>4], hex[r&0xF])
			} else {
				dst = append(dst, s[i:i+size]...)
			}
		}
		i += size
	}
	return append(dst, '"')
}

func isPlainASCII(s string) bool {
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c < 0x20 || c > 0x7e || c == '"' || c == '\\' {
			return false
		}
	}
	return true
}

func encodeStringSlice(dst []byte, xs []string) []byte {
	dst = append(dst, '[')
	for i, s := range xs {
		if i > 0 {
			dst = append(dst, ',')
		}
		dst = appendQuoted(dst, s)
	}
	return append(dst, ']')
}

// encodeShallowMap emits a map[string]any with keys in sorted order so logs
// are diff-stable across runs. Nested values recurse through encodeValue.
func encodeShallowMap(dst []byte, m map[string]any) []byte {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sortStrings(keys)
	dst = append(dst, '{')
	for i, k := range keys {
		if i > 0 {
			dst = append(dst, ',')
		}
		dst = appendQuoted(dst, k)
		dst = append(dst, ':')
		dst = encodeValue(dst, m[k])
	}
	return append(dst, '}')
}

// Minimal insertion sort to avoid pulling in sort for small key slices.
func sortStrings(xs []string) {
	for i := 1; i < len(xs); i++ {
		for j := i; j > 0 && xs[j-1] > xs[j]; j-- {
			xs[j-1], xs[j] = xs[j], xs[j-1]
		}
	}
}

// encodeRecord builds a full log line into a pooled buffer. The returned
// slice is valid until the next call that puts the buffer back.
func encodeRecord(level Level, msg string, attrs []attr, extra []any) []byte {
	buf := encPool.Get().(*bytes.Buffer)
	buf.Reset()
	defer encPool.Put(buf)

	line := buf.Bytes()
	line = append(line, '{')
	line = appendQuoted(line, "level")
	line = append(line, ':')
	line = appendQuoted(line, string(level))
	line = append(line, ',')
	line = appendQuoted(line, "msg")
	line = append(line, ':')
	line = appendQuoted(line, msg)
	for _, a := range attrs {
		line = append(line, ',')
		line = appendQuoted(line, a.Key)
		line = append(line, ':')
		line = encodeValue(line, a.Value)
	}
	for i := 0; i+1 < len(extra); i += 2 {
		line = append(line, ',')
		k, ok := extra[i].(string)
		if !ok {
			k = "MISSING"
		}
		line = appendQuoted(line, k)
		line = append(line, ':')
		line = encodeValue(line, extra[i+1])
	}
	line = append(line, '}', '\n')

	out := make([]byte, len(line))
	copy(out, line)
	return out
}