Files
five/hbrtl/json.go
Charles KWON OhJun c7ac4044f7 feat(json): hb_jsonDecode 2-arg byref form (Harbour-spec compatible)
Previously hb_jsonDecode took only (cJSON) and returned the value.
That covers most uses but not the Harbour-spec second form

    nBytesParsed := hb_jsonDecode( cJSON, @xOut )

which mod_harbour / fivenode PRG (e.g. bridge_context.prg's
ctx_get / ctx_set) and any other code that wants the parse-length
relies on. The byref output was silently dropped, so a hash lookup
went through the @hOut path that was always NIL and fell back to
the default value — looking like a hash key was missing even
though the JSON parsed fine.

Now PCount() == 1 keeps the legacy return-value form; PCount() >= 2
writes the decoded value into local-2 via SetLocal (which is
already byref-aware) and returns the byte count (0 on parse error).

Verified: hb_jsonDecode('{"x":1,"y":2}', @h) writes the hash and
returns 13; the 1-arg form still returns the value as before;
Compat 56/56 + go test ./compiler/... ./hbrt/... ./hbrtl/... all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:44:12 +09:00

399 lines
9.1 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// JSON functions — Harbour compatible API + Go-native extensions.
//
// Harbour compatible:
// hb_jsonEncode(xValue [, lHumanReadable]) → cJSON
// hb_jsonDecode(cJSON) → xValue
//
// Five extensions (Go-native, beyond Harbour):
// JsonPretty(xValue [, cIndent]) → cPrettyJSON
// JsonTo(xValue, cFile) → lSuccess
// JsonFrom(cFile) → xValue
// JsonPath(xValue, cPath) → xResult — $.key.sub[0]
// JsonMerge(hDest, hSrc) → hMerged — deep merge
// JsonType(cJSON) → cType
// JsonValid(cJSON) → lValid
// JsonHttpGet(cURL [, nTimeout]) → hResult — HTTP GET + JSON
// JsonHttpPost(cURL, xBody [, nTimeout]) — HTTP POST + JSON
package hbrtl
import (
"encoding/json"
"five/hbrt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// === Harbour Compatible ===
// HB_JSONENCODE(xValue [, lHumanReadable]) → cJSON
func HbJsonEncode(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProc()
v := t.Local(1)
pretty := false
if nParams >= 2 && !t.Local(2).IsNil() {
pretty = t.Local(2).AsBool()
}
goVal := valueToGo(v)
var data []byte
var err error
if pretty {
data, err = json.MarshalIndent(goVal, "", " ")
} else {
data, err = json.Marshal(goVal)
}
if err != nil {
t.RetString("")
return
}
t.RetString(string(data))
}
// HB_JSONDECODE(cJSON [, @xOut]) → xValue | nBytesParsed
//
// Two Harbour-spec calling forms:
//
// - 1 arg : returns the decoded value (or NIL on parse error).
// - 2 args: writes the decoded value into the byref @xOut and
// returns the number of bytes parsed (0 on error). This is the
// form mod_harbour / fivenode PRG code uses for ctx_get and the
// like. Five previously implemented only the 1-arg form, so any
// PRG that relied on the byref output saw NIL and silently fell
// through to a default.
func HbJsonDecode(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProc()
s := t.Local(1).AsString()
var raw interface{}
if err := json.Unmarshal([]byte(s), &raw); err != nil {
if nParams >= 2 {
t.SetLocal(2, hbrt.MakeNil())
t.RetInt(0)
} else {
t.RetNil()
}
return
}
v := goToValue(raw)
if nParams >= 2 {
t.SetLocal(2, v)
t.RetInt(int64(len(s)))
return
}
t.RetVal(v)
}
// === Five Extensions ===
// JSONPRETTY(xValue [, cIndent]) → cPrettyJSON
func JsonPretty(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProc()
indent := " "
if nParams >= 2 && !t.Local(2).IsNil() {
indent = t.Local(2).AsString()
}
data, _ := json.MarshalIndent(valueToGo(t.Local(1)), "", indent)
t.RetString(string(data))
}
// JSONTO(xValue, cFile) → lSuccess
func JsonTo(t *hbrt.Thread) {
t.Frame(2, 0)
defer t.EndProc()
data, err := json.MarshalIndent(valueToGo(t.Local(1)), "", " ")
if err != nil {
t.RetBool(false)
return
}
t.RetBool(os.WriteFile(t.Local(2).AsString(), data, 0644) == nil)
}
// JSONFROM(cFile) → xValue
func JsonFrom(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
data, err := os.ReadFile(t.Local(1).AsString())
if err != nil {
t.RetNil()
return
}
var raw interface{}
if json.Unmarshal(data, &raw) != nil {
t.RetNil()
return
}
t.RetVal(goToValue(raw))
}
// JSONPATH(xValue, cPath) → xResult — $.key.sub[0]
func JsonPath(t *hbrt.Thread) {
t.Frame(2, 0)
defer t.EndProc()
root := t.Local(1)
path := strings.TrimPrefix(strings.TrimPrefix(t.Local(2).AsString(), "$."), "$")
if path == "" {
t.RetVal(root)
return
}
t.RetVal(navigatePath(root, path))
}
func navigatePath(v hbrt.Value, path string) hbrt.Value {
for _, part := range splitPath(path) {
if v.IsNil() {
return hbrt.MakeNil()
}
if strings.HasPrefix(part, "[") && strings.HasSuffix(part, "]") {
idx, _ := strconv.Atoi(part[1 : len(part)-1])
if v.IsArray() {
arr := v.AsArray()
if idx >= 0 && idx < len(arr.Items) {
v = arr.Items[idx]
continue
}
}
return hbrt.MakeNil()
}
if v.IsHash() {
h := v.AsHash()
if i := h.Lookup(hbrt.MakeString(part)); i >= 0 {
v = h.Values[i]
} else {
return hbrt.MakeNil()
}
} else {
return hbrt.MakeNil()
}
}
return v
}
func splitPath(path string) []string {
var parts []string
cur := ""
for i := 0; i < len(path); i++ {
if path[i] == '.' {
if cur != "" {
parts = append(parts, cur)
cur = ""
}
} else if path[i] == '[' {
if cur != "" {
parts = append(parts, cur)
cur = ""
}
j := i
for j < len(path) && path[j] != ']' {
j++
}
parts = append(parts, path[i:j+1])
i = j
} else {
cur += string(path[i])
}
}
if cur != "" {
parts = append(parts, cur)
}
return parts
}
// JSONMERGE(hDest, hSrc) → hMerged
func JsonMerge(t *hbrt.Thread) {
t.Frame(2, 0)
defer t.EndProc()
dest, src := t.Local(1), t.Local(2)
if !dest.IsHash() || !src.IsHash() {
t.RetVal(src)
return
}
dh, sh := dest.AsHash(), src.AsHash()
result := &hbrt.HbHash{
Keys: make([]hbrt.Value, len(dh.Keys)),
Values: make([]hbrt.Value, len(dh.Values)),
}
copy(result.Keys, dh.Keys)
copy(result.Values, dh.Values)
for i, sk := range sh.Keys {
result.Set(sk, sh.Values[i])
}
t.RetVal(hbrt.MakeHashFrom(result))
}
// JSONTYPE(cJSON) → "object"|"array"|"string"|"number"|"boolean"|"null"|"invalid"
func JsonType(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
s := strings.TrimSpace(t.Local(1).AsString())
if s == "" {
t.RetString("invalid")
return
}
switch s[0] {
case '{':
t.RetString("object")
case '[':
t.RetString("array")
case '"':
t.RetString("string")
case 't', 'f':
t.RetString("boolean")
case 'n':
t.RetString("null")
default:
if (s[0] >= '0' && s[0] <= '9') || s[0] == '-' {
t.RetString("number")
} else {
t.RetString("invalid")
}
}
}
// JSONVALID(cJSON) → lValid
func JsonValid(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
t.RetBool(json.Valid([]byte(t.Local(1).AsString())))
}
// JSONHTTPGET(cURL [, nTimeout]) → {"status":n, "body":c, "error":c}
func JsonHttpGet(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProc()
url := t.Local(1).AsString()
timeout := 30
if nParams >= 2 && !t.Local(2).IsNil() {
timeout = t.Local(2).AsInt()
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Get(url)
if err != nil {
t.RetVal(makeHttpResult(0, "", err.Error()))
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.RetVal(makeHttpResult(resp.StatusCode, string(body), ""))
}
// JSONHTTPPOST(cURL, xBody [, nTimeout]) → {"status":n, "body":c, "error":c}
func JsonHttpPost(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProc()
url := t.Local(1).AsString()
bodyVal := t.Local(2)
timeout := 30
if nParams >= 3 && !t.Local(3).IsNil() {
timeout = t.Local(3).AsInt()
}
var bodyStr string
if bodyVal.IsString() {
bodyStr = bodyVal.AsString()
} else {
data, _ := json.Marshal(valueToGo(bodyVal))
bodyStr = string(data)
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Post(url, "application/json", strings.NewReader(bodyStr))
if err != nil {
t.RetVal(makeHttpResult(0, "", err.Error()))
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.RetVal(makeHttpResult(resp.StatusCode, string(body), ""))
}
func makeHttpResult(status int, body, errMsg string) hbrt.Value {
h := &hbrt.HbHash{
Keys: []hbrt.Value{hbrt.MakeString("status"), hbrt.MakeString("body"), hbrt.MakeString("error")},
Values: []hbrt.Value{hbrt.MakeInt(status), hbrt.MakeString(body), hbrt.MakeString(errMsg)},
}
return hbrt.MakeHashFrom(h)
}
// === Conversion: Five Value ↔ Go interface{} ===
func valueToGo(v hbrt.Value) interface{} {
if v.IsNil() {
return nil
}
if v.IsLogical() {
return v.AsBool()
}
if v.IsNumInt() {
return v.AsNumInt()
}
if v.IsNumeric() {
return v.AsNumDouble()
}
if v.IsString() {
return v.AsString()
}
if v.IsArray() {
arr := v.AsArray()
result := make([]interface{}, len(arr.Items))
for i, item := range arr.Items {
result[i] = valueToGo(item)
}
return result
}
if v.IsHash() {
h := v.AsHash()
result := make(map[string]interface{}, len(h.Keys))
for i := range h.Keys {
result[h.Keys[i].AsString()] = valueToGo(h.Values[i])
}
return result
}
return nil
}
func goToValue(v interface{}) hbrt.Value {
if v == nil {
return hbrt.MakeNil()
}
switch val := v.(type) {
case bool:
return hbrt.MakeBool(val)
case float64:
if val == float64(int64(val)) {
return hbrt.MakeNumInt(int64(val))
}
return hbrt.MakeDouble(val, 0, 0)
case string:
return hbrt.MakeString(val)
case []interface{}:
items := make([]hbrt.Value, len(val))
for i, item := range val {
items[i] = goToValue(item)
}
return hbrt.MakeArrayFrom(items)
case map[string]interface{}:
h := &hbrt.HbHash{
Keys: make([]hbrt.Value, 0, len(val)),
Values: make([]hbrt.Value, 0, len(val)),
}
for k, v := range val {
h.Keys = append(h.Keys, hbrt.MakeString(k))
h.Values = append(h.Values, goToValue(v))
}
return hbrt.MakeHashFrom(h)
}
return hbrt.MakeNil()
}