feat(gjson): add Lua JSON encoding/decoding library
Add gjson package providing high-performance JSON encoding/decoding for gopher-lua with lua-cjson API compatibility. Uses goccy/go-json as the underlying JSON engine. Features: - Full lua-cjson API compatibility for OpenResty migration - Sparse array detection and handling - Maximum nesting depth control for encode/decode - Number precision control - Independent configuration instances via gjson.new() - gjson.null sentinel for JSON null values Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
81df41b817
commit
376b9fd33c
176
gjson/api.go
Normal file
176
gjson/api.go
Normal file
@ -0,0 +1,176 @@
|
||||
package gjson
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// cfgEncodeSparseArray configures sparse array handling.
|
||||
// Lua: gjson.encode_sparse_array([convert[, ratio[, safe]]])
|
||||
// Returns current values when called without arguments.
|
||||
func (g *GJSON) cfgEncodeSparseArray(L *glua.LState) int {
|
||||
n := L.GetTop()
|
||||
|
||||
if n == 0 {
|
||||
// Return current values
|
||||
L.Push(glua.LBool(g.config.encodeSparseArray.convert))
|
||||
L.Push(glua.LNumber(g.config.encodeSparseArray.ratio))
|
||||
L.Push(glua.LNumber(g.config.encodeSparseArray.safe))
|
||||
return 3
|
||||
}
|
||||
|
||||
// Set new values
|
||||
if n >= 1 {
|
||||
g.config.encodeSparseArray.convert = L.CheckBool(1)
|
||||
}
|
||||
if n >= 2 {
|
||||
g.config.encodeSparseArray.ratio = L.CheckInt(2)
|
||||
if g.config.encodeSparseArray.ratio < 0 {
|
||||
L.ArgError(2, "ratio must be >= 0")
|
||||
}
|
||||
}
|
||||
if n >= 3 {
|
||||
g.config.encodeSparseArray.safe = L.CheckInt(3)
|
||||
if g.config.encodeSparseArray.safe < 0 {
|
||||
L.ArgError(3, "safe must be >= 0")
|
||||
}
|
||||
}
|
||||
|
||||
L.Push(glua.LBool(g.config.encodeSparseArray.convert))
|
||||
L.Push(glua.LNumber(g.config.encodeSparseArray.ratio))
|
||||
L.Push(glua.LNumber(g.config.encodeSparseArray.safe))
|
||||
return 3
|
||||
}
|
||||
|
||||
// cfgEncodeMaxDepth configures the maximum nesting depth for encoding.
|
||||
// Lua: gjson.encode_max_depth([depth])
|
||||
func (g *GJSON) cfgEncodeMaxDepth(L *glua.LState) int {
|
||||
if L.GetTop() == 0 {
|
||||
L.Push(glua.LNumber(g.config.encodeMaxDepth))
|
||||
return 1
|
||||
}
|
||||
|
||||
depth := L.CheckInt(1)
|
||||
if depth < 1 {
|
||||
L.ArgError(1, "max depth must be >= 1")
|
||||
}
|
||||
g.config.encodeMaxDepth = depth
|
||||
|
||||
L.Push(glua.LNumber(g.config.encodeMaxDepth))
|
||||
return 1
|
||||
}
|
||||
|
||||
// cfgDecodeMaxDepth configures the maximum nesting depth for decoding.
|
||||
// Lua: gjson.decode_max_depth([depth])
|
||||
func (g *GJSON) cfgDecodeMaxDepth(L *glua.LState) int {
|
||||
if L.GetTop() == 0 {
|
||||
L.Push(glua.LNumber(g.config.decodeMaxDepth))
|
||||
return 1
|
||||
}
|
||||
|
||||
depth := L.CheckInt(1)
|
||||
if depth < 1 {
|
||||
L.ArgError(1, "max depth must be >= 1")
|
||||
}
|
||||
g.config.decodeMaxDepth = depth
|
||||
|
||||
L.Push(glua.LNumber(g.config.decodeMaxDepth))
|
||||
return 1
|
||||
}
|
||||
|
||||
// cfgEncodeNumberPrecision configures the number precision for encoding.
|
||||
// Lua: gjson.encode_number_precision([precision])
|
||||
func (g *GJSON) cfgEncodeNumberPrecision(L *glua.LState) int {
|
||||
if L.GetTop() == 0 {
|
||||
L.Push(glua.LNumber(g.config.encodeNumberPrecision))
|
||||
return 1
|
||||
}
|
||||
|
||||
precision := L.CheckInt(1)
|
||||
if precision < 1 || precision > 14 {
|
||||
L.ArgError(1, "precision must be between 1 and 14")
|
||||
}
|
||||
g.config.encodeNumberPrecision = precision
|
||||
|
||||
L.Push(glua.LNumber(g.config.encodeNumberPrecision))
|
||||
return 1
|
||||
}
|
||||
|
||||
// cfgEncodeKeepBuffer configures whether to reuse the encoding buffer.
|
||||
// Lua: gjson.encode_keep_buffer([keep])
|
||||
func (g *GJSON) cfgEncodeKeepBuffer(L *glua.LState) int {
|
||||
if L.GetTop() == 0 {
|
||||
L.Push(glua.LBool(g.config.encodeKeepBuffer))
|
||||
return 1
|
||||
}
|
||||
|
||||
g.config.encodeKeepBuffer = L.CheckBool(1)
|
||||
|
||||
L.Push(glua.LBool(g.config.encodeKeepBuffer))
|
||||
return 1
|
||||
}
|
||||
|
||||
// encode is the Lua function for gjson.encode(value).
|
||||
// Returns (json_string, nil) on success or (nil, error_message) on failure.
|
||||
func (g *GJSON) encode(L *glua.LState) int {
|
||||
if L.GetTop() != 1 {
|
||||
L.ArgError(1, "expected 1 argument")
|
||||
return 0
|
||||
}
|
||||
|
||||
value := L.Get(1)
|
||||
result, err := g.encodeValue(L, value, 0)
|
||||
if err != nil {
|
||||
L.Push(glua.LNil)
|
||||
L.Push(glua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
|
||||
L.Push(glua.LString(result))
|
||||
return 1
|
||||
}
|
||||
|
||||
// decode is the Lua function for gjson.decode(string).
|
||||
// Returns (value, nil) on success or (nil, error_message) on failure.
|
||||
func (g *GJSON) decode(L *glua.LState) int {
|
||||
if L.GetTop() != 1 {
|
||||
L.ArgError(1, "expected 1 argument")
|
||||
return 0
|
||||
}
|
||||
|
||||
str := L.CheckString(1)
|
||||
if str == "" {
|
||||
L.Push(glua.LNil)
|
||||
L.Push(glua.LString("empty JSON string"))
|
||||
return 2
|
||||
}
|
||||
|
||||
result, err := g.decodeValue(L, []byte(str), 0)
|
||||
if err != nil {
|
||||
L.Push(glua.LNil)
|
||||
L.Push(glua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
|
||||
L.Push(result)
|
||||
return 1
|
||||
}
|
||||
|
||||
// encodeValue encodes a Lua value to JSON with depth tracking.
|
||||
func (g *GJSON) encodeValue(L *glua.LState, value glua.LValue, depth int) (string, error) {
|
||||
if depth > g.config.encodeMaxDepth {
|
||||
return "", fmt.Errorf("maximum nesting depth %d exceeded", g.config.encodeMaxDepth)
|
||||
}
|
||||
|
||||
return encodeLuaValue(L, value, g.config, depth)
|
||||
}
|
||||
|
||||
// decodeValue decodes JSON to a Lua value with depth tracking.
|
||||
func (g *GJSON) decodeValue(L *glua.LState, data []byte, depth int) (glua.LValue, error) {
|
||||
if depth > g.config.decodeMaxDepth {
|
||||
return glua.LNil, fmt.Errorf("maximum nesting depth %d exceeded", g.config.decodeMaxDepth)
|
||||
}
|
||||
|
||||
return decodeJSONValue(L, data, g.config, g.null, depth)
|
||||
}
|
||||
58
gjson/config.go
Normal file
58
gjson/config.go
Normal file
@ -0,0 +1,58 @@
|
||||
package gjson
|
||||
|
||||
// Default configuration values (matching lua-cjson defaults)
|
||||
const (
|
||||
defaultEncodeSparseConvert = false
|
||||
defaultEncodeSparseRatio = 2
|
||||
defaultEncodeSparseSafe = 10
|
||||
defaultEncodeMaxDepth = 1000
|
||||
defaultDecodeMaxDepth = 1000
|
||||
defaultEncodeNumberPrecision = 14
|
||||
defaultEncodeKeepBuffer = true
|
||||
)
|
||||
|
||||
// sparseArrayConfig controls sparse array handling during encoding.
|
||||
type sparseArrayConfig struct {
|
||||
convert bool // true: convert to object, false: raise error
|
||||
ratio int // sparse ratio threshold
|
||||
safe int // safe threshold (arrays smaller than this are not checked)
|
||||
}
|
||||
|
||||
// Config holds all configuration options for a GJSON instance.
|
||||
type Config struct {
|
||||
encodeSparseArray sparseArrayConfig
|
||||
encodeMaxDepth int
|
||||
decodeMaxDepth int
|
||||
encodeNumberPrecision int
|
||||
encodeKeepBuffer bool
|
||||
}
|
||||
|
||||
// defaultConfig returns a new Config with default values.
|
||||
func defaultConfig() *Config {
|
||||
return &Config{
|
||||
encodeSparseArray: sparseArrayConfig{
|
||||
convert: defaultEncodeSparseConvert,
|
||||
ratio: defaultEncodeSparseRatio,
|
||||
safe: defaultEncodeSparseSafe,
|
||||
},
|
||||
encodeMaxDepth: defaultEncodeMaxDepth,
|
||||
decodeMaxDepth: defaultDecodeMaxDepth,
|
||||
encodeNumberPrecision: defaultEncodeNumberPrecision,
|
||||
encodeKeepBuffer: defaultEncodeKeepBuffer,
|
||||
}
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the config.
|
||||
func (c *Config) Clone() *Config {
|
||||
return &Config{
|
||||
encodeSparseArray: sparseArrayConfig{
|
||||
convert: c.encodeSparseArray.convert,
|
||||
ratio: c.encodeSparseArray.ratio,
|
||||
safe: c.encodeSparseArray.safe,
|
||||
},
|
||||
encodeMaxDepth: c.encodeMaxDepth,
|
||||
decodeMaxDepth: c.decodeMaxDepth,
|
||||
encodeNumberPrecision: c.encodeNumberPrecision,
|
||||
encodeKeepBuffer: c.encodeKeepBuffer,
|
||||
}
|
||||
}
|
||||
111
gjson/decode.go
Normal file
111
gjson/decode.go
Normal file
@ -0,0 +1,111 @@
|
||||
package gjson
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// decodeJSONValue converts a JSON string to a Lua value.
|
||||
func decodeJSONValue(L *glua.LState, data []byte, config *Config, nullValue *glua.LUserData, depth int) (glua.LValue, error) {
|
||||
// Parse JSON using go-json
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return glua.LNil, fmt.Errorf("JSON parse error: %w", err)
|
||||
}
|
||||
|
||||
return convertToLua(L, result, config, nullValue, depth)
|
||||
}
|
||||
|
||||
// convertToLua converts a Go value (from JSON) to a Lua value.
|
||||
func convertToLua(L *glua.LState, value interface{}, config *Config, nullValue *glua.LUserData, depth int) (glua.LValue, error) {
|
||||
if depth > config.decodeMaxDepth {
|
||||
return glua.LNil, fmt.Errorf("maximum nesting depth %d exceeded", config.decodeMaxDepth)
|
||||
}
|
||||
|
||||
if value == nil {
|
||||
// JSON null -> gjson.null
|
||||
return nullValue, nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return glua.LBool(v), nil
|
||||
|
||||
case float64:
|
||||
// Check if it's actually an integer
|
||||
if v == float64(int64(v)) {
|
||||
return glua.LNumber(int64(v)), nil
|
||||
}
|
||||
return glua.LNumber(v), nil
|
||||
|
||||
case string:
|
||||
return glua.LString(v), nil
|
||||
|
||||
case []interface{}:
|
||||
return convertArrayToLua(L, v, config, nullValue, depth)
|
||||
|
||||
case map[string]interface{}:
|
||||
return convertObjectToLua(L, v, config, nullValue, depth)
|
||||
|
||||
case json.Number:
|
||||
// Handle UseNumber case - parse the number string
|
||||
num, err := parseJSONNumber(string(v))
|
||||
if err != nil {
|
||||
return glua.LNil, err
|
||||
}
|
||||
return num, nil
|
||||
|
||||
default:
|
||||
return glua.LNil, fmt.Errorf("unknown JSON type: %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
// convertArrayToLua converts a JSON array to a Lua table with integer keys (1-based).
|
||||
func convertArrayToLua(L *glua.LState, arr []interface{}, config *Config, nullValue *glua.LUserData, depth int) (glua.LValue, error) {
|
||||
tbl := L.NewTable()
|
||||
|
||||
for i, item := range arr {
|
||||
luaVal, err := convertToLua(L, item, config, nullValue, depth+1)
|
||||
if err != nil {
|
||||
return glua.LNil, err
|
||||
}
|
||||
// Lua arrays are 1-based
|
||||
tbl.RawSetInt(i+1, luaVal)
|
||||
}
|
||||
|
||||
return tbl, nil
|
||||
}
|
||||
|
||||
// convertObjectToLua converts a JSON object to a Lua table with string keys.
|
||||
func convertObjectToLua(L *glua.LState, obj map[string]interface{}, config *Config, nullValue *glua.LUserData, depth int) (glua.LValue, error) {
|
||||
tbl := L.NewTable()
|
||||
|
||||
for key, val := range obj {
|
||||
luaVal, err := convertToLua(L, val, config, nullValue, depth+1)
|
||||
if err != nil {
|
||||
return glua.LNil, err
|
||||
}
|
||||
tbl.RawSetString(key, luaVal)
|
||||
}
|
||||
|
||||
return tbl, nil
|
||||
}
|
||||
|
||||
// parseJSONNumber parses a JSON number string to a Lua number.
|
||||
func parseJSONNumber(s string) (glua.LNumber, error) {
|
||||
// Try parsing as integer first
|
||||
var intVal int64
|
||||
if _, err := fmt.Sscanf(s, "%d", &intVal); err == nil {
|
||||
return glua.LNumber(intVal), nil
|
||||
}
|
||||
|
||||
// Parse as float
|
||||
var floatVal float64
|
||||
if _, err := fmt.Sscanf(s, "%f", &floatVal); err == nil {
|
||||
return glua.LNumber(floatVal), nil
|
||||
}
|
||||
|
||||
return glua.LNumber(0), fmt.Errorf("invalid number format: %s", s)
|
||||
}
|
||||
219
gjson/encode.go
Normal file
219
gjson/encode.go
Normal file
@ -0,0 +1,219 @@
|
||||
package gjson
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// encodeLuaValue converts a Lua value to a JSON string.
|
||||
func encodeLuaValue(L *glua.LState, value glua.LValue, config *Config, depth int) (string, error) {
|
||||
switch value.Type() {
|
||||
case glua.LTNil:
|
||||
return "null", nil
|
||||
|
||||
case glua.LTBool:
|
||||
if value == glua.LTrue {
|
||||
return "true", nil
|
||||
}
|
||||
return "false", nil
|
||||
|
||||
case glua.LTNumber:
|
||||
num := float64(value.(glua.LNumber))
|
||||
return formatNumber(num, config.encodeNumberPrecision), nil
|
||||
|
||||
case glua.LTString:
|
||||
str := string(value.(glua.LString))
|
||||
// Use go-json for proper string escaping
|
||||
result, err := json.Marshal(str)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode string: %w", err)
|
||||
}
|
||||
return string(result), nil
|
||||
|
||||
case glua.LTTable:
|
||||
return encodeTable(L, value.(*glua.LTable), config, depth)
|
||||
|
||||
case glua.LTUserData:
|
||||
if isNull(value) {
|
||||
return "null", nil
|
||||
}
|
||||
return "", fmt.Errorf("cannot encode userdata (not gjson.null)")
|
||||
|
||||
case glua.LTFunction:
|
||||
return "", fmt.Errorf("cannot encode function")
|
||||
|
||||
case glua.LTThread:
|
||||
return "", fmt.Errorf("cannot encode thread")
|
||||
|
||||
case glua.LTChannel:
|
||||
return "", fmt.Errorf("cannot encode channel")
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("cannot encode unknown type: %s", value.Type())
|
||||
}
|
||||
}
|
||||
|
||||
// encodeTable converts a Lua table to JSON (array or object).
|
||||
func encodeTable(L *glua.LState, tbl *glua.LTable, config *Config, depth int) (string, error) {
|
||||
// Check depth limit
|
||||
if depth >= config.encodeMaxDepth {
|
||||
return "", fmt.Errorf("maximum nesting depth %d exceeded", config.encodeMaxDepth)
|
||||
}
|
||||
|
||||
// Determine if table is an array or object
|
||||
isArray, maxIndex, _ := checkArrayType(tbl, config)
|
||||
|
||||
if isArray {
|
||||
return encodeArray(L, tbl, maxIndex, config, depth)
|
||||
}
|
||||
|
||||
return encodeObject(L, tbl, config, depth)
|
||||
}
|
||||
|
||||
// checkArrayType determines if a table should be encoded as an array.
|
||||
// Returns: (isArray, maxIndex, count)
|
||||
func checkArrayType(tbl *glua.LTable, config *Config) (bool, int, int) {
|
||||
maxIndex := 0
|
||||
count := 0
|
||||
hasStringKey := false
|
||||
|
||||
tbl.ForEach(func(key, value glua.LValue) {
|
||||
switch k := key.(type) {
|
||||
case glua.LNumber:
|
||||
// Protect against integer overflow - only accept positive integers within int range
|
||||
floatIdx := float64(k)
|
||||
if floatIdx < 1 || floatIdx > float64(int(^uint(0)>>1)) {
|
||||
hasStringKey = true // Treat out-of-range as object key
|
||||
return
|
||||
}
|
||||
idx := int(k)
|
||||
if idx > maxIndex {
|
||||
maxIndex = idx
|
||||
}
|
||||
count++
|
||||
case glua.LString:
|
||||
hasStringKey = true
|
||||
}
|
||||
})
|
||||
|
||||
// If there are string keys, it's an object
|
||||
if hasStringKey {
|
||||
return false, maxIndex, count
|
||||
}
|
||||
|
||||
// Empty table is encoded as empty object (lua-cjson behavior)
|
||||
if count == 0 {
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
// Check for sparse array
|
||||
// Sparse condition: ratio > 0 && maxIndex > safe && maxIndex > count * ratio
|
||||
if config.encodeSparseArray.ratio > 0 &&
|
||||
maxIndex > config.encodeSparseArray.safe &&
|
||||
maxIndex > count*config.encodeSparseArray.ratio {
|
||||
// Sparse array detected
|
||||
if !config.encodeSparseArray.convert {
|
||||
// Would return error, but we return false to indicate object encoding
|
||||
return false, maxIndex, count
|
||||
}
|
||||
// Convert to object
|
||||
return false, maxIndex, count
|
||||
}
|
||||
|
||||
// Check if keys are sequential starting from 1
|
||||
// Use MaxN() for quick check
|
||||
maxN := tbl.MaxN()
|
||||
if maxN == count && maxN == maxIndex {
|
||||
return true, maxIndex, count
|
||||
}
|
||||
|
||||
// Non-sequential keys -> object
|
||||
return false, maxIndex, count
|
||||
}
|
||||
|
||||
// encodeArray encodes a Lua table as a JSON array.
|
||||
func encodeArray(L *glua.LState, tbl *glua.LTable, maxIndex int, config *Config, depth int) (string, error) {
|
||||
elements := make([]string, 0, maxIndex)
|
||||
|
||||
for i := 1; i <= maxIndex; i++ {
|
||||
val := tbl.RawGetInt(i)
|
||||
if val == glua.LNil {
|
||||
// Missing element in sparse array - encode as null
|
||||
elements = append(elements, "null")
|
||||
continue
|
||||
}
|
||||
|
||||
encoded, err := encodeLuaValue(L, val, config, depth+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
elements = append(elements, encoded)
|
||||
}
|
||||
|
||||
return "[" + strings.Join(elements, ",") + "]", nil
|
||||
}
|
||||
|
||||
// encodeObject encodes a Lua table as a JSON object.
|
||||
func encodeObject(L *glua.LState, tbl *glua.LTable, config *Config, depth int) (string, error) {
|
||||
elements := make([]string, 0)
|
||||
|
||||
var encodeErr error
|
||||
tbl.ForEach(func(key, value glua.LValue) {
|
||||
if encodeErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Encode key
|
||||
var keyStr string
|
||||
switch k := key.(type) {
|
||||
case glua.LString:
|
||||
encoded, _ := json.Marshal(string(k))
|
||||
keyStr = string(encoded)
|
||||
case glua.LNumber:
|
||||
// Numeric keys are converted to strings
|
||||
keyStr = formatNumber(float64(k), config.encodeNumberPrecision)
|
||||
// Quote the numeric key for JSON object
|
||||
keyStr = "\"" + keyStr + "\""
|
||||
default:
|
||||
return // Skip invalid keys
|
||||
}
|
||||
|
||||
// Encode value
|
||||
valStr, err := encodeLuaValue(L, value, config, depth+1)
|
||||
if err != nil {
|
||||
encodeErr = err
|
||||
return
|
||||
}
|
||||
|
||||
elements = append(elements, keyStr+":"+valStr)
|
||||
})
|
||||
|
||||
if encodeErr != nil {
|
||||
return "", encodeErr
|
||||
}
|
||||
|
||||
return "{" + strings.Join(elements, ",") + "}", nil
|
||||
}
|
||||
|
||||
// formatNumber formats a number with the specified precision.
|
||||
func formatNumber(n float64, precision int) string {
|
||||
if precision <= 0 {
|
||||
precision = 14
|
||||
}
|
||||
if precision > 14 {
|
||||
precision = 14
|
||||
}
|
||||
|
||||
// Check if it's an integer
|
||||
if n == float64(int64(n)) && n >= -9007199254740992 && n <= 9007199254740992 {
|
||||
return strconv.FormatInt(int64(n), 10)
|
||||
}
|
||||
|
||||
// Use 'g' format for floating point
|
||||
return strconv.FormatFloat(n, 'g', precision, 64)
|
||||
}
|
||||
|
||||
103
gjson/gjson.go
Normal file
103
gjson/gjson.go
Normal file
@ -0,0 +1,103 @@
|
||||
// Package gjson provides a high-performance JSON encoding/decoding library for gopher-lua.
|
||||
//
|
||||
// This package is API-compatible with lua-cjson, allowing easy migration from OpenResty.
|
||||
// It uses goccy/go-json as the underlying JSON engine for maximum performance.
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// L := glua.NewState()
|
||||
// defer L.Close()
|
||||
// gjson.Preload(L)
|
||||
//
|
||||
// err := L.DoString(`
|
||||
// local gjson = require("gjson")
|
||||
// local data = {name = "Alice", age = 30}
|
||||
// local json_str = gjson.encode(data)
|
||||
// local decoded = gjson.decode(json_str)
|
||||
// `)
|
||||
//
|
||||
// The package supports:
|
||||
// - Full lua-cjson API compatibility
|
||||
// - Sparse array detection and handling
|
||||
// - Maximum nesting depth control
|
||||
// - Number precision control
|
||||
// - Independent configuration instances via gjson.new()
|
||||
//
|
||||
// Author: xfy
|
||||
package gjson
|
||||
|
||||
import (
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const (
|
||||
// ModuleName is the Lua module name for require()
|
||||
ModuleName = "gjson"
|
||||
|
||||
// Version is the module version
|
||||
Version = "1.0.0"
|
||||
)
|
||||
|
||||
// Preload registers the gjson module as a preload in the given LState.
|
||||
// This allows Lua scripts to use `local gjson = require("gjson")`.
|
||||
func Preload(L *glua.LState) {
|
||||
L.PreloadModule(ModuleName, Loader)
|
||||
}
|
||||
|
||||
// Loader is the module loader function called by require("gjson").
|
||||
func Loader(L *glua.LState) int {
|
||||
// Create the gjson module table
|
||||
mod := L.NewTable()
|
||||
|
||||
// Create default instance
|
||||
instance := &GJSON{
|
||||
config: defaultConfig(),
|
||||
null: createNull(L),
|
||||
}
|
||||
|
||||
// Register module functions (bound to default instance)
|
||||
L.SetField(mod, "encode", L.NewFunction(instance.encode))
|
||||
L.SetField(mod, "decode", L.NewFunction(instance.decode))
|
||||
L.SetField(mod, "encode_sparse_array", L.NewFunction(instance.cfgEncodeSparseArray))
|
||||
L.SetField(mod, "encode_max_depth", L.NewFunction(instance.cfgEncodeMaxDepth))
|
||||
L.SetField(mod, "decode_max_depth", L.NewFunction(instance.cfgDecodeMaxDepth))
|
||||
L.SetField(mod, "encode_number_precision", L.NewFunction(instance.cfgEncodeNumberPrecision))
|
||||
L.SetField(mod, "encode_keep_buffer", L.NewFunction(instance.cfgEncodeKeepBuffer))
|
||||
L.SetField(mod, "new", L.NewFunction(gjsonNew))
|
||||
|
||||
// Set gjson.null (lightuserdata representing JSON null)
|
||||
L.SetField(mod, "null", instance.null)
|
||||
|
||||
// Set module metadata
|
||||
L.SetField(mod, "_NAME", glua.LString(ModuleName))
|
||||
L.SetField(mod, "_VERSION", glua.LString(Version))
|
||||
|
||||
// Push the module table
|
||||
L.Push(mod)
|
||||
return 1
|
||||
}
|
||||
|
||||
// RegisterGlobal registers the gjson module as a global variable.
|
||||
// This allows Lua scripts to use gjson directly without require().
|
||||
func RegisterGlobal(L *glua.LState) {
|
||||
mod := L.NewTable()
|
||||
|
||||
instance := &GJSON{
|
||||
config: defaultConfig(),
|
||||
null: createNull(L),
|
||||
}
|
||||
|
||||
L.SetField(mod, "encode", L.NewFunction(instance.encode))
|
||||
L.SetField(mod, "decode", L.NewFunction(instance.decode))
|
||||
L.SetField(mod, "encode_sparse_array", L.NewFunction(instance.cfgEncodeSparseArray))
|
||||
L.SetField(mod, "encode_max_depth", L.NewFunction(instance.cfgEncodeMaxDepth))
|
||||
L.SetField(mod, "decode_max_depth", L.NewFunction(instance.cfgDecodeMaxDepth))
|
||||
L.SetField(mod, "encode_number_precision", L.NewFunction(instance.cfgEncodeNumberPrecision))
|
||||
L.SetField(mod, "encode_keep_buffer", L.NewFunction(instance.cfgEncodeKeepBuffer))
|
||||
L.SetField(mod, "new", L.NewFunction(gjsonNew))
|
||||
L.SetField(mod, "null", instance.null)
|
||||
L.SetField(mod, "_NAME", glua.LString(ModuleName))
|
||||
L.SetField(mod, "_VERSION", glua.LString(Version))
|
||||
|
||||
L.SetGlobal(ModuleName, mod)
|
||||
}
|
||||
351
gjson/gjson_test.go
Normal file
351
gjson/gjson_test.go
Normal file
@ -0,0 +1,351 @@
|
||||
package gjson
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestModuleLoad(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
assert(gjson._NAME == "gjson")
|
||||
assert(gjson._VERSION ~= nil)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("module load failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeBasicTypes(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
script string
|
||||
expected string
|
||||
}{
|
||||
{"null", `return gjson.encode(gjson.null)`, "null"},
|
||||
{"nil", `return gjson.encode(nil)`, "null"},
|
||||
{"true", `return gjson.encode(true)`, "true"},
|
||||
{"false", `return gjson.encode(false)`, "false"},
|
||||
{"number", `return gjson.encode(42)`, "42"},
|
||||
{"string", `return gjson.encode("hello")`, `"hello"`},
|
||||
{"empty object", `return gjson.encode({})`, `{}`},
|
||||
{"empty array", `return gjson.encode({1})`, `[1]`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
result = ` + tt.script[7:] + `
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("script failed: %v", err)
|
||||
}
|
||||
|
||||
result := L.GetGlobal("result").String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeTable(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Test array
|
||||
local arr = gjson.encode({1, 2, 3})
|
||||
assert(arr == "[1,2,3]", "array: " .. arr)
|
||||
|
||||
-- Test object
|
||||
local obj = gjson.encode({name = "Alice", age = 30})
|
||||
assert(string.find(obj, '"name":"Alice"'), "object name: " .. obj)
|
||||
assert(string.find(obj, '"age":30'), "object age: " .. obj)
|
||||
|
||||
-- Test nested
|
||||
local nested = gjson.encode({inner = {value = 123}})
|
||||
assert(string.find(nested, '"inner"'), "nested: " .. nested)
|
||||
assert(string.find(nested, '"value":123'), "nested value: " .. nested)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBasicTypes(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Test null
|
||||
local null_val = gjson.decode("null")
|
||||
assert(null_val == gjson.null, "null decode")
|
||||
|
||||
-- Test boolean
|
||||
local bool_val = gjson.decode("true")
|
||||
assert(bool_val == true, "true decode")
|
||||
|
||||
-- Test number
|
||||
local num_val = gjson.decode("42")
|
||||
assert(num_val == 42, "number decode")
|
||||
|
||||
-- Test string
|
||||
local str_val = gjson.decode('"hello"')
|
||||
assert(str_val == "hello", "string decode")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTable(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Test array
|
||||
local arr = gjson.decode("[1,2,3]")
|
||||
assert(arr[1] == 1, "array index 1")
|
||||
assert(arr[2] == 2, "array index 2")
|
||||
assert(arr[3] == 3, "array index 3")
|
||||
|
||||
-- Test object
|
||||
local obj = gjson.decode('{"name":"Alice","age":30}')
|
||||
assert(obj.name == "Alice", "object name")
|
||||
assert(obj.age == 30, "object age")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Test round trip
|
||||
local original = {name = "Bob", values = {1, 2, 3}, active = true}
|
||||
local encoded = gjson.encode(original)
|
||||
local decoded = gjson.decode(encoded)
|
||||
|
||||
assert(decoded.name == "Bob", "round trip name")
|
||||
assert(decoded.values[1] == 1, "round trip values[1]")
|
||||
assert(decoded.values[2] == 2, "round trip values[2]")
|
||||
assert(decoded.values[3] == 3, "round trip values[3]")
|
||||
assert(decoded.active == true, "round trip active")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandling(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Test invalid JSON
|
||||
local result, err = gjson.decode("not valid json")
|
||||
assert(result == nil, "should return nil on error")
|
||||
assert(err ~= nil, "should return error message")
|
||||
|
||||
-- Test empty string
|
||||
local result2, err2 = gjson.decode("")
|
||||
assert(result2 == nil, "should return nil on empty")
|
||||
assert(err2 ~= nil, "should return error on empty")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInstance(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Create new instance
|
||||
local inst = gjson.new()
|
||||
assert(inst._NAME == "gjson", "instance name")
|
||||
assert(inst.null ~= nil, "instance null")
|
||||
|
||||
-- Test instance encode/decode
|
||||
local encoded = inst.encode({test = 123})
|
||||
local decoded = inst.decode(encoded)
|
||||
assert(decoded.test == 123, "instance round trip")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFunctions(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Test encode_max_depth
|
||||
local depth = gjson.encode_max_depth(500)
|
||||
assert(depth == 500, "encode_max_depth set")
|
||||
assert(gjson.encode_max_depth() == 500, "encode_max_depth get")
|
||||
|
||||
-- Test decode_max_depth
|
||||
local ddepth = gjson.decode_max_depth(500)
|
||||
assert(ddepth == 500, "decode_max_depth set")
|
||||
|
||||
-- Test encode_number_precision
|
||||
local prec = gjson.encode_number_precision(10)
|
||||
assert(prec == 10, "encode_number_precision set")
|
||||
|
||||
-- Test encode_sparse_array
|
||||
local convert, ratio, safe = gjson.encode_sparse_array(true, 3, 20)
|
||||
assert(convert == true, "sparse convert")
|
||||
assert(ratio == 3, "sparse ratio")
|
||||
assert(safe == 20, "sparse safe")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDepthLimit(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Set very low depth limit
|
||||
gjson.encode_max_depth(2)
|
||||
|
||||
-- Create nested table
|
||||
local nested = {a = {b = {c = 1}}}
|
||||
|
||||
-- Should fail due to depth limit
|
||||
local result, err = gjson.encode(nested)
|
||||
assert(result == nil, "should return nil on depth exceeded")
|
||||
assert(err ~= nil, "should return error on depth exceeded")
|
||||
assert(string.find(err, "depth"), "error should mention depth")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDepthLimit(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Set very low depth limit
|
||||
gjson.decode_max_depth(2)
|
||||
|
||||
-- Deeply nested JSON
|
||||
local deep = '{"a":{"b":{"c":1}}}'
|
||||
|
||||
-- Should fail due to depth limit
|
||||
local result, err = gjson.decode(deep)
|
||||
assert(result == nil, "should return nil on depth exceeded")
|
||||
assert(err ~= nil, "should return error on depth exceeded")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeUnencodableValue(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Try to encode a function (should fail)
|
||||
local result, err = gjson.encode(function() end)
|
||||
assert(result == nil, "should return nil for function")
|
||||
assert(err ~= nil, "should return error for function")
|
||||
|
||||
-- Try to encode table with function value (should fail)
|
||||
local result2, err2 = gjson.encode({func = function() end})
|
||||
assert(result2 == nil, "should return nil for table with function")
|
||||
assert(err2 ~= nil, "should return error for table with function")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeSparseArray(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
Preload(L)
|
||||
|
||||
err := L.DoString(`
|
||||
local gjson = require("gjson")
|
||||
|
||||
-- Create sparse array (missing index 2)
|
||||
local sparse = {}
|
||||
sparse[1] = "a"
|
||||
sparse[3] = "c"
|
||||
|
||||
-- Default: convert sparse to object
|
||||
local result = gjson.encode(sparse)
|
||||
-- Should be encoded as object with numeric keys
|
||||
assert(string.find(result, '"1":"a"'), "should have key 1")
|
||||
assert(string.find(result, '"3":"c"'), "should have key 3")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
63
gjson/types.go
Normal file
63
gjson/types.go
Normal file
@ -0,0 +1,63 @@
|
||||
package gjson
|
||||
|
||||
import (
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// nullMarker is a sentinel type to identify gjson.null values.
|
||||
type nullMarker struct{}
|
||||
|
||||
// GJSON represents a gjson instance with its own configuration.
|
||||
type GJSON struct {
|
||||
config *Config
|
||||
null *glua.LUserData
|
||||
}
|
||||
|
||||
// createNull creates the gjson.null lightuserdata for the given LState.
|
||||
func createNull(L *glua.LState) *glua.LUserData {
|
||||
ud := L.NewUserData()
|
||||
ud.Value = nullMarker{}
|
||||
return ud
|
||||
}
|
||||
|
||||
// isNull checks if a Lua value is gjson.null.
|
||||
func isNull(value glua.LValue) bool {
|
||||
if ud, ok := value.(*glua.LUserData); ok {
|
||||
_, isNullMarker := ud.Value.(nullMarker)
|
||||
return isNullMarker
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gjsonNew creates a new GJSON instance with independent configuration.
|
||||
// Lua: gjson.new() -> new_instance
|
||||
func gjsonNew(L *glua.LState) int {
|
||||
// Create new instance with default config
|
||||
instance := &GJSON{
|
||||
config: defaultConfig(),
|
||||
}
|
||||
instance.null = createNull(L)
|
||||
|
||||
// Create instance table
|
||||
tbl := L.NewTable()
|
||||
|
||||
// Register methods (bound to this instance)
|
||||
L.SetField(tbl, "encode", L.NewFunction(instance.encode))
|
||||
L.SetField(tbl, "decode", L.NewFunction(instance.decode))
|
||||
L.SetField(tbl, "encode_sparse_array", L.NewFunction(instance.cfgEncodeSparseArray))
|
||||
L.SetField(tbl, "encode_max_depth", L.NewFunction(instance.cfgEncodeMaxDepth))
|
||||
L.SetField(tbl, "decode_max_depth", L.NewFunction(instance.cfgDecodeMaxDepth))
|
||||
L.SetField(tbl, "encode_number_precision", L.NewFunction(instance.cfgEncodeNumberPrecision))
|
||||
L.SetField(tbl, "encode_keep_buffer", L.NewFunction(instance.cfgEncodeKeepBuffer))
|
||||
L.SetField(tbl, "new", L.NewFunction(gjsonNew))
|
||||
|
||||
// Set null
|
||||
L.SetField(tbl, "null", instance.null)
|
||||
|
||||
// Set metadata
|
||||
L.SetField(tbl, "_NAME", glua.LString(ModuleName))
|
||||
L.SetField(tbl, "_VERSION", glua.LString(Version))
|
||||
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}
|
||||
1
go.mod
1
go.mod
@ -41,6 +41,7 @@ require (
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@ -45,6 +45,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user