Skip to content

Commit f55df2a

Browse files
Merge branch 'main' into esc01-rawffi-adr
2 parents b783e54 + adb177b commit f55df2a

2 files changed

Lines changed: 235 additions & 1 deletion

File tree

docs/TECH-DEBT.adoc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,12 @@ coherence checking. |S2 |partial
131131
joint-closed. Deno-ESM (#226) + typed-wasm CPS line PR1..PR3d
132132
(#227/#233/#236/#237/#238/#266) all merged; ADR-013 delivery plan
133133
complete; convergence ABI shared w/ Ephapax
134-
|STDLIB-02 |Portable `Json` primitive |S2 |open #161
134+
|STDLIB-02 |Portable `Json` primitive |S2 |*LANDED* (Refs #161 #246):
135+
`stdlib/json.affine` — pure recursive `Json` ADT + encoders/decoders/
136+
`get_field`/`stringify`, AOT-gated (#136, gate 278→279). Target-agnostic
137+
(no host dep); String→Json parse is the `Http` typed-boundary bridge
138+
(ADR-018), not a hand-rolled parser. Closes ESC-02 #246 (the #229
139+
`JSON.t` target)
135140
|STDLIB-03 |`Dict`/`Map` keyed container |S2 |open #162
136141
|STDLIB-04 |Residual `extern` builtins → real implementations |S3 |open
137142
|===

stdlib/json.affine

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2025 hyperpolymath
3+
//
4+
// Json - JSON value type, decoders, encoders, and serialisation (echidna#63)
5+
//
6+
// Backs the ReScript->AffineScript migration's `Json` requirement
7+
// (echidna `[migration-roadmap.rescript-to-affinescript]`, Client.res):
8+
// request bodies are built with the encoders + `stringify`, and backend
9+
// responses are inspected with the decoders.
10+
//
11+
// `Json` is a pure recursive sum type (not the opaque `Deno.Json`
12+
// host handle): the decoders need an inspectable structure, and a pure
13+
// ADT keeps the module self-contained and exercised by the #136 AOT
14+
// gate. Object payloads use the assoc-list shape `[(String, Json)]` —
15+
// the same representation as `dict.affine` (echidna#64), so a decoded
16+
// object feeds `dict::get` directly.
17+
//
18+
// Scope (echidna#63 "What is needed"): the `Json` type, the decode_*
19+
// and encode_* combinators, and `stringify`. The String->Json *parse*
20+
// bridge is deliberately out of scope here — it belongs at the
21+
// echidna#61 `Http` boundary (`Response.json : Async[Json]`), where the
22+
// host fetch result crosses in; tracked there, not duplicated as a
23+
// hand-rolled parser in stdlib.
24+
25+
module json;
26+
27+
use prelude::{ Option, Some, None };
28+
use string::{ join };
29+
30+
// ============================================================================
31+
// The JSON value
32+
// ============================================================================
33+
34+
pub type Json =
35+
JNull
36+
| JBool(Bool)
37+
| JInt(Int)
38+
| JFloat(Float)
39+
| JString(String)
40+
| JArray([Json])
41+
| JObject([(String, Json)])
42+
43+
// ============================================================================
44+
// Encoders (typed value -> Json)
45+
// ============================================================================
46+
47+
/// JSON `null`.
48+
pub fn encode_null() -> Json {
49+
JNull
50+
}
51+
52+
pub fn encode_bool(b: Bool) -> Json {
53+
JBool(b)
54+
}
55+
56+
pub fn encode_int(n: Int) -> Json {
57+
JInt(n)
58+
}
59+
60+
pub fn encode_float(f: Float) -> Json {
61+
JFloat(f)
62+
}
63+
64+
pub fn encode_string(s: String) -> Json {
65+
JString(s)
66+
}
67+
68+
pub fn encode_array(xs: [Json]) -> Json {
69+
JArray(xs)
70+
}
71+
72+
/// Build an object from `(key, Json)` pairs (same shape as `dict`).
73+
pub fn encode_object(fields: [(String, Json)]) -> Json {
74+
JObject(fields)
75+
}
76+
77+
// ============================================================================
78+
// Decoders (Json -> Option<typed value>)
79+
//
80+
// Each returns `None` on a type mismatch so callers can fail softly on
81+
// malformed backend data (Client.res pattern).
82+
// ============================================================================
83+
84+
/// `Some(())`-style null check: `true` iff the value is JSON `null`.
85+
pub fn decode_null(j: Json) -> Bool {
86+
match j {
87+
JNull => true,
88+
_ => false
89+
}
90+
}
91+
92+
pub fn decode_bool(j: Json) -> Option<Bool> {
93+
match j {
94+
JBool(b) => Some(b),
95+
_ => None
96+
}
97+
}
98+
99+
pub fn decode_int(j: Json) -> Option<Int> {
100+
match j {
101+
JInt(n) => Some(n),
102+
_ => None
103+
}
104+
}
105+
106+
pub fn decode_float(j: Json) -> Option<Float> {
107+
match j {
108+
JFloat(f) => Some(f),
109+
_ => None
110+
}
111+
}
112+
113+
pub fn decode_string(j: Json) -> Option<String> {
114+
match j {
115+
JString(s) => Some(s),
116+
_ => None
117+
}
118+
}
119+
120+
pub fn decode_array(j: Json) -> Option<[Json]> {
121+
match j {
122+
JArray(xs) => Some(xs),
123+
_ => None
124+
}
125+
}
126+
127+
/// Decode an object to its `(key, Json)` pairs — feed straight into
128+
/// `dict::get` for field lookup.
129+
pub fn decode_object(j: Json) -> Option<[(String, Json)]> {
130+
match j {
131+
JObject(fields) => Some(fields),
132+
_ => None
133+
}
134+
}
135+
136+
/// Look up a single object field by key (`None` if not an object or the
137+
/// key is absent). Convenience for the common `obj["field"]` pattern.
138+
pub fn get_field(j: Json, key: String) -> Option<Json> {
139+
match j {
140+
JObject(fields) => {
141+
for (k, v) in fields {
142+
if k == key {
143+
return Some(v);
144+
}
145+
}
146+
None
147+
},
148+
_ => None
149+
}
150+
}
151+
152+
// ============================================================================
153+
// Serialisation (Json -> String)
154+
// ============================================================================
155+
156+
/// Map a nibble (0-15) to its lowercase hex digit.
157+
fn hex_digit(n: Int) -> String {
158+
let table = "0123456789abcdef";
159+
if n >= 0 && n < 16 {
160+
string_sub(table, n, 1)
161+
} else {
162+
"0"
163+
}
164+
}
165+
166+
/// Escape one source character (given by its code point) for inclusion
167+
/// in a JSON string literal. Handles the JSON-mandatory escapes plus
168+
/// `\u00XX` for the remaining C0 control characters.
169+
fn escape_char(s: String, i: Int) -> String {
170+
let code = char_to_int(string_get(s, i));
171+
if code == 34 {
172+
"\""
173+
} else if code == 92 {
174+
"\\"
175+
} else if code == 8 {
176+
"\b"
177+
} else if code == 12 {
178+
"\f"
179+
} else if code == 10 {
180+
"\n"
181+
} else if code == 13 {
182+
"\r"
183+
} else if code == 9 {
184+
"\t"
185+
} else if code < 32 {
186+
let hi = code / 16;
187+
let lo = code - hi * 16;
188+
"\\u00" ++ hex_digit(hi) ++ hex_digit(lo)
189+
} else {
190+
string_sub(s, i, 1)
191+
}
192+
}
193+
194+
/// Quote and escape a string as a JSON string literal.
195+
fn escape_string(s: String) -> String {
196+
let n = len(s);
197+
let mut out = "\"";
198+
let mut i = 0;
199+
while i < n {
200+
out = out ++ escape_char(s, i);
201+
i = i + 1;
202+
}
203+
out ++ "\""
204+
}
205+
206+
/// Serialise a `Json` value to a compact JSON string.
207+
pub fn stringify(j: Json) -> String {
208+
match j {
209+
JNull => "null",
210+
JBool(b) => if b { "true" } else { "false" },
211+
JInt(n) => int_to_string(n),
212+
JFloat(f) => float_to_string(f),
213+
JString(s) => escape_string(s),
214+
JArray(xs) => {
215+
let mut parts = [];
216+
for x in xs {
217+
parts = parts ++ [stringify(x)];
218+
}
219+
"[" ++ join(parts, ",") ++ "]"
220+
},
221+
JObject(fields) => {
222+
let mut parts = [];
223+
for (k, v) in fields {
224+
parts = parts ++ [escape_string(k) ++ ":" ++ stringify(v)];
225+
}
226+
"{" ++ join(parts, ",") ++ "}"
227+
}
228+
}
229+
}

0 commit comments

Comments
 (0)