跳到正文
cccskills
免费在 GitHub 公开

json_serialization

Chemical JSON serialization — the `json` runtime library (JsonValue, JsonEncoder/JsonDecoder, TypeDecoder decode dispatch), the `#json(Struct)` compiler-plugin macro (`json_cbi`) that auto-generates `std::Serializer`/`std::Deserializer` impls, the std serialization interfaces in `serialization.ch`, the CBI bindings the macro relies on, and the hard-won codegen/symres pitfalls. Load before touching anything under `lang/libs/json/`, `lang/libs/json_cbi/`, or the `#json` macro.

中文简介正在准备中。当前显示原文说明。

chemicallang/chemical星标 31(GitHub 31 + cccskills 0)

实测评分 未实测

查看安装方法

包含的文件(1)

  • SKILL.md58.7 KB

SKILL.md(原文)

安装前可以查看交给 AI 代理的指令内容。

JSON Serialization (json library + json_cbi #json macro)

Two layers work together to make a Chemical struct serializable to/from JSON:

  1. lang/libs/json — a pure-Chemical runtime library: JsonValue, JsonParser/ASTJsonHandler, JsonEncoder (writes JSON text) and JsonDecoder (reads a parsed JsonValue). It also provides the concrete types the macro's generated code calls (JsonEncoder, JsonDecoder, JsonObjectDecoder, JsonArrayDecoder, TypeDecoder<T>, __non_gen_se_repl).
  2. lang/libs/json_cbi — a compiler plugin that implements the #json(StructName) top-level macro. During symbol resolution it generates, symres-links, indexes, and codegen-replaces two impls for the struct:
impl std::Serializer<JsonValue, JsonEncoder> for Point { ... }
impl std::Deserializer<Point> for TypeDecoder<Point> { ... }

The handwritten reference for what the macro should produce lives in lang/compiled/refmod/src/main.ch (scratch, git-ignored): Sample/SampleChild structs with hand-written impl std::Serializer<...> for ... + impl std::Deserializer<...> for TypeDecoder<...> blocks that exercise nested structs.


0. TL;DR / entry points

struct Point { var x : int  var y : int }
#json(Point)                                  // order: #json(Child) BEFORE #json(Parent)

// typed, by reference, no copies:
var er = json::encode<Point>(&p)              // Result<std::string, SerializationError>  (api.ch:57)
var dr = json::decode<Point>(&value)          // Result<Point, SerializationError>          (api.ch:86)
var pr = json::parse(text)                    // Result<JsonValue, JsonParseError>           (api.ch:27)
var s  = json::stringify(&value)              // compact std::string                         (api.ch:43)
var p  = json::decode_str<Point>(text)        // parse + typed decode                        (api.ch:93)
  • Wire format: one key per struct member, in declaration order; keys are the member names.
  • Supported field types today: all scalar ints, bool, float, double, char/uchar, std::string/std::string_view, nested structs (that have their own #json/impl), and enum (as the member-name string). NOT supported: std::vector, std::ordered_map/ unordered_map, Option, arrays, pointers/references, unions, and variants (see §4.2).

1. std serialization interfaces — lang/libs/std/src/serialization.ch

All under public namespace std. These are the interfaces; the json library provides concrete implementations and the macro generates user impls against them.

InterfacePurpose
Encoder<T>primitive encoders (encode_bool, encode_i64, encode_u64, encode_double, encode_float, encode_str, encode_char, ...) + array()/object()/map() returning ArrayEncoder<T>/ObjectEncoder<T>/MapEncoder<T>
Serializer<T, E : Encoder<T>>func serialize(&self, encoder : &E) : std::Result<Unit, SerializationError> — what #json generates an impl of
Decoderprimitive decoders (decode_bool, decode_i64, decode_u64, decode_double, decode_float, decode_str, decode_char, ...) + array()/object()/map() returning std::Result<...Decoder, SerializationError>
ObjectEncoder<T> / ArrayEncoder<T> / MapEncoder<T>containers' write side; the generic encoders take values by reference: func <V : Serializer<T>> field(&self, name : std::string_view, value : &V) : Result<Unit, SerializationError> (same for ArrayEncoder.encode(&self, value : &K) / MapEncoder.encode(&self, key : &K, value : &V)). By-reference values mean owning/destructible members encode without copies — moving a member out of &self by value is illegal
ObjectDecoder / ArrayDecoder / MapDecodercontainers' read side; ObjectDecoder.item_decoder(&mut self) returns Result<std::pair<std::string_view, Decoder>, SerializationError>
Deserializer<T>func deserialize(&self) : std::Result<T, SerializationError> — what #json generates an impl of for TypeDecoder<T>

SerializationError has kind : SerializationErrorKind + message : std::string. std::Result<T, E> is the standard 2-arg variant result (Ok/Err).

Key consequences:

  • Encoding dispatch: encoder.encode<T>(value) (generic on JsonEncoder, see below) requires T : std::Serializer<JsonValue, JsonEncoder>. The macro's generated impl std::Serializer<JsonValue, JsonEncoder> for Point is what makes p (and p nested inside another struct) encodable. Nested encodes go through the generic field<V> on the object encoder; nested values must therefore have their Serializer impl already indexed when the parent's generated code is instantiated — that is why a nested struct's #json(Child) must appear before #json(Parent) in the file.
  • Decoding dispatch: d.decode<T>() (extension on &JsonDecoder, see below) ultimately requires TypeDecoder<T> : std::Deserializer<T>, which the macro provides.
  • The macro never implements raw std::Decoder/std::Encoder — the json runtime library already does that for JsonDecoder/JsonEncoder. The macro only implements Serializer<...> and Deserializer<T>.

2. json runtime library — lang/libs/json/src/

  • value.ch — public variant JsonValue { Null(), Bool(value: bool), Number(value: std::string), String(value: std::string), Object(values : std::ordered_map<std::string, JsonValue>), Array(values : std::vector<JsonValue>) }. Numbers are kept as raw decimal strings; decoding parses them.
  • types.ch — impl std::Serializer<JsonValue, JsonEncoder> for bool/int/.../std::string_view/std::string (each forwards to a primitive encoder.encode_*) and impl std::Deserializer<T> for TypeDecoder<T> for each primitive (bool, int, uint, double, float, string, ...). These are handwritten; the macro only generates impls for user structs, and primitive fields are encoded/decoded through JsonEncoder/JsonDecoder primitives directly.
  • encode.ch — JsonEncoder { buffer : *mut std::string, counts : *mut std::vector<u64> } (raw pointer members: only construct via JsonEncoder { buffer : &raw mut output, counts : &raw mut counts }). Contains impl std::Encoder<JsonValue> for JsonEncoder (writes into buffer), JsonObjectEncoder/JsonArrayEncoder/JsonMapEncoder with the container impls, the single string escaper json_escape_into (+ append_escaped_char), and the generic entry point:
func <T : std::Serializer<JsonValue, JsonEncoder>> (e : &JsonEncoder) encode(value : T) : std::Result<std::Unit, std::SerializationError> {
    // casts &JsonEncoder to &std::Encoder<JsonValue> via __unsafe_cast_json_encoder
    return value.serialize(__unsafe_cast_json_encoder<JsonValue>(e)) as std::Result<std::Unit, std::SerializationError>
}
  • decode.ch — decoder side:
    • JsonDecoder { value : &JsonValue } + impl std::Decoder for JsonDecoder (every primitive decode_*, plus object() → JsonObjectDecoder, array() → JsonArrayDecoder, map() → JsonMapDecoder).
    • JsonObjectDecoder { iterator : std::ordered_map_iterator<std::string, JsonValue>, _total : u64 } with item_decoder(&mut self) returning Result<std::pair<std::string_view, JsonDecoder>, SerializationError>; each call advances the iterator and hands back the next key + a fresh JsonDecoder for that field's value.
    • TypeDecoder<T> { var decoder : &JsonDecoder } — a placeholder type that exists only so Deserializer<T> impls can be attached per T.
    • Dispatch chain: func <T> (decoder : &JsonDecoder) decode() : Result<T, SerializationError> builds TypeDecoder<T> and routes through decode_it_2<T, TypeDecoder<T>> → decode_it_1<T, K : Deserializer<T>> → k.deserialize() (interface dispatch). This is why adding a new Deserializer<T> impl (via #json) makes d.decode<T>() work without touching the runtime.
    • __non_gen_se_repl(value : &mut std::SerializationError, repl : std::SerializationError) : std::SerializationError — non-generic helper that stores repl into value and returns the original error. The #json macro calls it from generated deserialize error paths instead of generic std::replace (see pitfalls).
    • take_ok<T>(t : &mut std::Result<T, std::SerializationError>) : T — mirrors std::Option.take: memcpy's the Ok payload out of the Result and re-initializes the slot to an Err state (bitwise, no payload destructor runs), returning an owned local. The #json macro's generated deserialize uses it for composite/string fields: a pattern-bound Ok payload cannot be moved onward into an aggregate literal ("cannot move this value without re-initializing memory"), but the taken local can — no copies, no double-destroy of the dead Result. Inside the generic body the std::SerializationError literal must be std::-qualified (unqualified names fail to resolve during generic instantiation).
  • handler.ch — SAX handlers (ASTJsonHandler builds a JsonValue AST; DebugJsonSaxHandler prints).
  • json.ch — JsonParser(bufferSize, ...) → .parse(text, len, &mut handler) returning ParseResult { ok, ... }. parse_string_value/parse_string_key free the growable heap buffer on every path (including parse errors).
  • emit.ch — string-based JSON emission: (output : &mut std::string) append_value(value) / append_value_pretty(value, indent) share one append_value_inner (pretty flag + indent). The old JsonStringEmitter interface / JsonStringBuilder / JsonStringPrinter / escape_string_into were removed — escaping is the single json_escape_into in encode.ch.

2.1 Object model — value.ch

public variant JsonValue {                     // value.ch:1-8
    Null()
    Bool(value : bool)
    Number(value : std::string)                // raw decimal text, NOT a number type
    String(value : std::string)
    Object(values : std::ordered_map<std::string, JsonValue>)   // ordered => insertion order preserved
    Array(values : std::vector<JsonValue>)
}
  • Numbers are kept as raw strings (value.ch:4): the parser slices the source text and the handler copies it (handler.ch:111-115), so precision is not lost on parse; decode_i64/u64/double/float parse the text on demand (encode.ch:151-209).
  • Object uses std::ordered_map, so encode/decode/stringify preserve key insertion order. Tests pin this: lang/tests/libs/json/main.ch:3-4.
  • Big integers beyond 64-bit are preserved verbatim by parse→stringify (main.ch:36-40).
  • The parser/decoder API only exposes this one tree type; json::parse returns it, and json::stringify consumes it.

2.2 Parsing — JsonParser + SAX handlers — json.ch, handler.ch

JsonParser (json.ch:40) is a @make struct (so JsonParser(128, 4096) is the constructor, json.ch:49-63):

FieldDefaultMeaning
max_depth128nesting depth cap; exceeding it errors exceeded max depth (json.ch:100)
max_string4096max unescaped string length; exceeding it errors (json.ch:214, :323)
scratch[4096]charfast path buffer for short strings, no allocation
heap_buf/heap_cap/str_bufprivategrowable buffer used only when the string needs > 4096 bytes
  • Entry: parser.parse(buffer, length, &mut handler) : ParseResult (json.ch:65-73) — resets state, parses one value, skips trailing whitespace, and rejects trailing data.
  • ParseResult { ok, pos, msg } (json.ch:2-20): pos is the byte offset of the error, msg a static *char.
  • SAX interface JsonSaxHandler (json.ch:22-38, @static): on_null, on_bool, on_number(data,len), on_string(data,len), on_object_begin/end, on_array_begin/end, on_key(data,len).
  • Strictness: rejects control chars below 0x20 in strings (json.ch:315), unterminated escapes/strings, invalid escapes, leading zeros (json.ch:140), bad fractions/exponents (json.ch:147,153), missing :/comma/} (json.ch:176,186).
  • Unicode: four-hex-digit uXXXX escapes are decoded and re-encoded as UTF-8; UTF-16 surrogate pairs (D800-DBFF + DC00-DFFF) are combined (json.ch:338-372). Non-ASCII source bytes pass through as-is (assumed UTF-8, json.ch:375-376).
  • Heap buffer is freed on every path — both success and error call free_str_buf() (json.ch:289-305). This fixed a leak on parse errors.
  • ASTJsonHandler (handler.ch:55-100) builds a JsonValue tree: it keeps stack : vector<JsonValue> + key_stack : vector<string>; attach_value pushes into the top Array/Object or sets root; root starts as JsonValue.Null().
  • DebugJsonSaxHandler (handler.ch:1-53) prints events to stdout.

2.3 Encoding — JsonEncoder + containers — encode.ch

public struct JsonEncoder {                    // encode.ch:3-6
    var buffer : *mut std::string              // raw pointers -> build with JsonEncoder { buffer : ... }
    var counts : *mut std::vector<u64>         // per-open-container item count stack
}
  • Container encoders JsonArrayEncoder/JsonObjectEncoder/JsonMapEncoder (encode.ch:13-44) each hold the same two raw pointers and have a @delete destructor that appends the closing bracket and pops counts. The closing bracket is emitted by RAII at scope end — the container object must stay alive until every element/field is written.
  • __container_begin_item(buffer, counts) (encode.ch:48-54) writes the comma separator when the current count > 0 and increments the count. Shared by all three container impls to dodge an interface-resolution bug (see pitfall #19).
  • impl std::Encoder<JsonValue> for JsonEncoder (encode.ch:213-307): encode_null/bool/char/ u64/i64/double/float/str_of_len/str; encode_bytes returns an Err (:265). array()/object()/map() and their _of_len variants append the opening bracket, push a 0 onto counts, and return the matching container struct.
  • json_escape_into(output, str, len) (encode.ch:82-88) is the single escaper (shared by encode.ch and emit.ch); append_escaped_char (encode.ch:59-79) handles quote, backslash, b/f/n/r/t, and u00XX for other control chars. Non-ASCII bytes pass through unchanged.
  • encode_char emits a single-char JSON string with the char escaped via json_escape_into (encode.ch:225-231); uchar casts to char first (types.ch:13-17).
  • Numeric text: ints via append_integer/append_uinteger (encode.ch:90-96); doubles via %.17g (write_double_raw, :98-110) and floats via %.9g (write_float_raw, :125-137) — the exact round-trip precisions. NaN/+/-Inf emit null (write_double, :112-123); 0.0 emits 0.
  • Generic entry: func <T : Serializer<JsonValue, JsonEncoder>> (e : &JsonEncoder) encode(value : T) (encode.ch:351-354) routes through __unsafe_cast_json_encoder<JsonValue>(e) (:313-315) because the compiler can't satisfy the Result return type otherwise.
  • The three container impls (:317-348) call value.serialize(__unsafe_cast_json_encoder<JsonValue>(&encoder)); MapEncoder.encode writes the key, then :, then the value.

2.4 Decoding — JsonDecoder + TypeDecoder<T> — decode.ch

  • JsonDecoder { value : &JsonValue } (decode.ch:4-6) borrows the parsed tree.
  • impl std::Decoder for JsonDecoder (decode.ch:64-163): primitives + array()/object()/ map() returning JsonArrayDecoder/JsonObjectDecoder/JsonMapDecoder. Notes:
    • decode_i64 also accepts a Bool (true→1) (:104-107); decode_u64 does not.
    • decode_char requires a single-char String (:80-89).
    • Errors are se_err("expected ...") (:55-60), a plain SerializationError{kind: Generic}.
  • JsonArrayDecoder { elements : std::span<JsonValue>, index : u64 } (:167) — item_decoder yields a fresh JsonDecoder per element; remaining()/total().
  • JsonObjectDecoder { iterator : ordered_map_iterator<string,JsonValue>, _total } (:195) — item_decoder returns pair<string_view, JsonDecoder> and advances; the key name is handed back but the generated code ignores it (positional decode).
  • JsonMapDecoder (:243) — item_decoder returns pair<JsonStringDecoder, JsonDecoder>; JsonStringDecoder (:269-322) only supports decode_str.
  • TypeDecoder<T> { var decoder : &JsonDecoder } (:222) — a placeholder whose only purpose is to let a Deserializer<T> impl be attached per concrete T.
  • Dispatch chain (decode.ch:228-239): JsonDecoder.decode<T>() builds TypeDecoder<T> then __decode_dispatch_2<T, TypeDecoder<T>> → __decode_dispatch_1<T, K> → k.deserialize() (interface dispatch). Adding a Deserializer<T> impl (via #json) makes d.decode<T>() work with zero runtime changes.
  • take_ok<T> (decode.ch:34-50): memcpy's the Ok payload out and re-initializes the slot to an Err value (no payload destructor runs), returning an owned local. Generated deserialize uses it for composite/destructible fields (see §3.4).
  • __non_gen_se_repl (decode.ch:12-20): deliberately non-generic helper that stores a replacement SerializationError and returns the original. Generated error paths use this instead of generic std::replace (pitfall #5).
  • Primitive/string Serializer/Deserializer impls are handwritten in types.ch: encode for bool, char, uchar, short, ushort, int, uint, long, ulong, bigint, ubigint, float, double, string_view, string (types.ch:1-89); decode for the same set (types.ch:93-223). The macro only generates impls for user structs.

2.5 Errors & options (quick reference)

Type / optionLocationNotes
ParseResult { ok, pos, msg }json.ch:2-20parser status; msg is a static *char
JsonParseError { pos, message }api.ch:21-24high-level json::parse error (owns a std::string)
SerializationError { kind, message }serialization.ch:9-12encode/decode error; default kind = Generic
JsonParser(max_depth=128, max_string=4096)json.ch:49-63depth cap, max unescaped string length
JsonEncoder { buffer, counts }encode.ch:3-6raw-pointer state, no clone
compact/pretty emitemit.ch:71-77append_value / append_value_pretty(value, indent=0) share append_value_inner

2.6 High-level API — api.ch (public namespace json)

The beginner entry points (called as json::parse(...) etc.; the pre-existing types stay module-top-level and unqualified):

var r = json::parse(text)                    // Result<JsonValue, JsonParseError> (JsonParseError { pos, message })
var s = json::stringify(&value)              // std::string (compact)
var s = json::stringify_pretty(&value)       // std::string (indented)
var e = json::encode<Point>(&point)          // Result<std::string, SerializationError> (by-ref, no copies)
var d = json::decode<Point>(&value)          // Result<Point, SerializationError>
var p = json::decode_str<Point>(text)        // parse + typed decode in one call

Implementation notes / compiler quirks:

  • encode<T> / decode<T> are public generic functions, so every callee they reach must be public too (retention rule): __unsafe_cast_json_encoder, se_err, __decode_dispatch_1/2, the decode extension method are public even though __-prefixed.
  • &raw mut of a generic-instantiation local (std::vector<u64>) inside a generic function hits a type-alias quirk — the encoder state is created in non-generic helpers __new_counts() / __new_encoder(&mut output, &mut counts).
  • Extracting the Err payload of a Result inside a generic function leaks the generic E and cannot be passed to non-generic code (pattern-match var Err(e) = r inside json::encode<T> fails). The high-level encode<T> therefore returns a static error message on failure; the low-level container API (ObjectEncoder.field etc.) still propagates real errors.
  • decode_str<T> is generic, so its Result<JsonValue, JsonParseError> pattern binds would mis-type in the generic context — the parse + payload extraction lives in the non-generic __try_parse(text) : std::Option<JsonValue> (module-level, None on parse error), and the generic body takes the payload out with std::Option.take() and borrows it for d.decode<T>(). Parse-error detail is dropped (static "json parse error" message).

2.7 Canonical round-trip usage (what tests do)

// beginner path (new)
var e = json::encode<Point>(&point)          // Result<string, SerializationError>
var Ok(text) = e else unreachable
var r = json::parse(text.to_view())          // Result<JsonValue, JsonParseError>
var Ok(value) = r else unreachable
var d = json::decode<Point>(&value)          // Result<Point, SerializationError>

// low-level path (advanced / manual)
var output = std::string()
var counts = std::vector<u64>()
var encoder = JsonEncoder { buffer : &raw mut output, counts : &raw mut counts }
var e = encoder.encode<Point>(Point { x : 10, y : 20 })   // or p.serialize(&encoder)
// output now contains {"x":10,"y":20}

var ph = ASTJsonHandler()
var parser = JsonParser(128, 4096)
var r = parser.parse(output.data(), output.size(), &mut ph)   // r.ok

var d = JsonDecoder { value : &ph.root }
var res = d.decode<Point>()                                   // Result<Point, SerializationError>
var Ok(v) = res else unreachable

3. #json(Struct) macro — lang/libs/json_cbi/

Module json_cbi (chemical.mod: source "src", imports cstd, std, compiler). Two source files:

  • src/types.ch — SerializableInfo (per-macro-node state; everything the codegen phases need, incl. pre-instantiated generic types) + the three no-op callback stubs (known_type_fn, child_res_fn, cross_mod_sym_decl_proxy_fn) required by make_top_level_embedded_node.
  • src/main.ch — the actual plugin: parse hook, symres link-body hook, codegen replacement hooks, and the AST builders.

3.1 Registration & phases

The macro is wired through build.lab/chemical.mod in lang/libs/json_cbi/ (registered as CBI "json" with a marker annotation + build_cbi), following the same shape as the other *_cbi plugins. A #json(Point) occurrence parses to an EmbeddedNode carrying a SerializableInfo in its data pointer. Phase hooks:

Hook (@no_mangle public func)Role
json_parseMacroTopLevelNode(parser, builder, spec)parse #json(<type>), join the path segments (Type or ns::Type / a::b::Type) into one ::-separated struct_name, allocate SerializableInfo, return make_top_level_embedded_node(...) with the 3 no-op callback fns
json_symResDeclareNode / json_symResLinkSigNodeintentionally empty — everything happens in the link-body phase
json_symResNode(visitor, node)does all the work (see below)
json_replacementNodeDeclare(builder, node)returns info.replacement_scope (used by the 2c declare pass so prototypes/vtables are emitted)
json_replacementNode(builder, node)returns the same info.replacement_scope (2c body pass emits the definitions)

SerializableInfo — the plugin's working state

SerializableInfo (json_cbi/src/types.ch:1-62) is carried in the EmbeddedNode's data pointer from parse through both codegen passes. Important fields:

Field groupFieldsPurpose
targetstruct_name, struct_node, field_names, field_typesresolved struct + member snapshot
std/json nodesserializer_node, deserializer_node, result_node(+Ok/Err), unit_node, serialization_error_node, std_string_nodepre-linked AST nodes the builders reference
helpersse_repl_node (__non_gen_se_repl), take_ok_node, se_err_node, fnv1_hash_view_noderuntime helpers resolved from the json lib
decoder methodsdecoder_decode_i64/u64/double/float/str/bool/char_method, decoder_object_method, decoder_decode_generic_method, object_decoder_item_method, pair_second_memberscalar vs generic decode dispatch
generic typesserializer_iface_type, deserializer_iface_type, type_decoder_type, result_type, ser_result_type, field_result_typesbuilt + visitType-instantiated in symres, reused in codegen
codegenserialize_fn, deserialize_fn, replacement_scopethe linked impl scope returned to both 2c passes

The build.lab wiring (json_cbi/build.lab): CBI name "json" (:18-22), marker annotation "serializable" (:24-25, see §5), and the six indexed hooks (:53-58).

3.2 json_symResNode — step by step

  1. Resolve the target struct via resolve_type_path(resolver, &struct_name) — the first path segment through resolver.resolve (a namespace resolves to its root scope), remaining segments through node.child(name) walking; supports #json(Point) and #json(ns::Point). Then snapshot its members: field_names (allocated views), field_types (*mut BaseType per member).
  2. resolve_types(resolver, info, loc) — resolve every symbol the generated code will reference and cache the AST nodes on info:
    • std nodes: std::Serializer, std::Deserializer, std::Result + Ok/Err members, std::Unit, std::SerializationError, std::Encoder.object, std::ObjectEncoder.field, std::pair.second. (No std::replace — dead since generated error paths use __non_gen_se_repl.)
    • json-lib nodes: JsonEncoder, JsonValue, JsonDecoder + its decode_i64/u64/double/float/str/bool/char, object, and the generic decode extension; JsonObjectDecoder.item_decoder; TypeDecoder; and __non_gen_se_repl.
    • Nodes are found with std_node.child(name) / resolver.resolve(name); impl-contained methods (e.g. JsonDecoder.decode_i64, which lives in impl std::Decoder for JsonDecoder) are discoverable through child() on the struct node.
  3. Build + visit the generic types (visitor.visitType(...)), caching them on info for codegen reuse: Serializer<JsonValue, JsonEncoder>, Result<Struct, SE>, Result<Unit, SE> (fresh LinkedTypes — see pitfalls), Deserializer<Struct>, TypeDecoder<Struct>, and per-struct-field Result<field_type, SE> for fields that decode via decode<T> (info.field_result_types).
  4. Build the replacement tree: one Scope (make_scope) containing two ImplDefinitions (make_impl_def) — impl Serializer<JsonValue,JsonEncoder> for Point and impl Deserializer<Point> for TypeDecoder<Point>.
  5. build_serialize_fn / build_deserialize_fn create the contained functions with make_function + make_function_param + add_body/body.push, then ser_impl.add_function(...)/deser_impl.add_function(...) (binding ImplDefinitionadd_function).
  6. resolver.index_impl(impl) — mirrors what the module index phase does for parsed impls: registers the impl in the interface's implementations index and adopts its contained functions into the struct/type member tables (so point.serialize(&e) and interface dispatch decode<T> can find them). Without this, method calls resolve to the bare interface name and links fail.
  7. visitor.visitNode(ser_impl) / visitor.visitNode(deser_impl) — symres-link the generated functions right here, during the macro's link-body phase (all identifiers are pre-linked; chain members are re-resolved by symres against concrete receiver types).
  8. Store info.replacement_scope = impl_scope for the codegen hooks.

3.3 Generated serialize (target shape)

impl std::Serializer<JsonValue, JsonEncoder> for Point {
    func serialize(&self, encoder : &JsonEncoder) : std::Result<std::Unit, std::SerializationError> {
        var obj = encoder.object()                       // JsonObjectEncoder
        obj.field<int>("x", &self.x)                     // one per field; T = concrete field type
        obj.field<int>("y", &self.y)
        return std::Result.Ok<std::Unit, std::SerializationError>(std::Unit())
    }
}
  • self is &Point; encoder is &JsonEncoder.
  • Field calls are emitted as AccessChainNode statements built from the method chain (make_function_call_node(chain, ...)), never by wrapping a pre-built FunctionCall (that would nest a second call on the first call's result). The explicit generic arg (field_stmt.add_generic_arg(field_type, loc)) is added on the statement; args are the field-name StringValue and a &self.<field> reference (make_reference_of_value over the member chain, matching the value : &V field signature).
  • Known TODO: unlike the handwritten reference, the generated serialize currently does not inspect each field(...) result for Err — it always returns Ok(Unit()). Error propagation on the encode side is unfinished work.

3.4 Generated deserialize (target shape)

impl std::Deserializer<Point> for TypeDecoder<Point> {
    func deserialize(&self) : std::Result<Point, std::SerializationError> {
        var __dr = self.decoder.object()                          // Result<JsonObjectDecoder, SE>
        if(__dr is std::Result.Err) { ...Err-return... }
        var Ok(__obj) = __dr else unreachable
        // per field, in struct order:
        var __q0 = __obj.item_decoder()                           // consume one key/value pair
        if(__q0 is std::Result.Err) { ...Err-return... }
        var Ok(__p0) = __q0 else unreachable                      // pair<string_view, JsonDecoder>
        var __v0 = __p0.second.decode_i64()                       // scalar → decode_XXX
        if(__v0 is std::Result.Err) { ...Err-return... }
        var Ok(__f0) = __v0 else unreachable
        // composite fields (nested structs + std::string/string_view) instead:
        var __v1 = __p1.second.decode<T>()                        // decode<T> → Result<T, SE>
        if(__v1 is std::Result.Err) { ...Err-return... }
        var __f1 : T = take_ok(&mut __v1)                         // move payload out (owned local)
        ...
        return std::Result.Ok<Point, std::SerializationError>(Point { x : __f0, child : __f1 })
    }
}
  • self is &TypeDecoder<Point>; self.decoder is the member &JsonDecoder.
  • Flat, negative-check-first structure (early returns), exactly like the handwritten reference — there is no if Ok { nest next field } nesting.
  • append_err_check(...) is a shared helper that emits the if(<var> is std::Result.Err) { var Err(__e) = <var> else unreachable; return std::Result.Err<T, SE>(__non_gen_se_repl(&mut __e, SerializationError())) } block.
  • Scalar fields: find_decoder_method maps the field's BaseType to a JsonDecoder.decode_* method node (ints/longs → decode_i64 + a cast in the struct init; uints → decode_u64; char/uchar → decode_char (chars encode as single-char JSON strings, not numbers — routing them to decode_u64 would reject the encoded form); float/double/bool → their methods; note std::string/string_view are NOT routed to decode_str — they return null here so they decode through the generic path below).
  • Composite fields (nested structs + strings): no scalar method → build the chain __p<i>.second.decode<T>() with an explicit generic arg = the field type, then take_ok(&mut __v<i>) moves the Ok payload out (the Result is left in an Err state and its destructor won't touch the payload). A pattern-bound Ok payload cannot be moved onward into an aggregate literal; the taken owned local can. See pitfalls for the explicit result var type requirement.
  • Struct construction: make_struct_value(struct_node) + add_value(field_name, ...) where scalar fields are wrapped in casted_value(pattern-var-id, field_type) but composite fields are added as plain identifiers — a CastedValue around a destructible value makes codegen bitwise-copy it into the member and then still destroy the local at scope end (double free).
  • Per-field temp names are formatted with snprintf into allocator buffers (alloc_indexed_view(builder, "__q%d", i)).

4. Full worked example

4.1 Supported struct: nested struct + enum + string + scalars

// user code
enum Status { Active, Paused, Done }

struct Address {
    var street : std::string
    var zip : int
}
#json(Address)                     // must come before Person

struct Person {
    var name : std::string         // owning string
    var age : int
    var score : float
    var active : bool
    var home : Address             // nested struct (its own #json)
    var status : Status            // enum -> name string
    var initial : char             // single-char JSON string
}
#json(Person)

p.serialize(&encoder) (or json::encode<Person>(&p)) produces exactly:

{"name":"Ada","age":36,"score":1.5,"active":true,"home":{"street":"Main St","zip":12345},"status":"Paused","initial":"A"}

Decoding the same text via json::decode<Person>(&v) yields an equal struct. This shape is exercised by lang/tests/compiler_plugins/json/src/test.ch (JMParent nested + JMEnumPoint enum + JMChar char + JMDPoint/JMDChild/JMDHolder destructible) and by the handwritten reference lang/tests/libs/json/struct_tests.ch.

What the macro generates for Person (abridged — see §3.3/§3.4 for the full shape):

impl std::Serializer<JsonValue, JsonEncoder> for Person {
    func serialize(&self, encoder : &JsonEncoder) : std::Result<std::Unit, std::SerializationError> {
        var obj = encoder.object()
        obj.field<std::string>("name", &self.name)
        obj.field<int>("age", &self.age)
        obj.field<float>("score", &self.score)
        obj.field<bool>("active", &self.active)
        obj.field<Address>("home", &self.home)          // nested impl from #json(Address)
        var __en5 : std::string = std::string()          // enum -> name lookup
        if((self.status as int) == 0) { __en5 = std::string("Active") }
        else if((self.status as int) == 1) { __en5 = std::string("Paused") }
        else if((self.status as int) == 2) { __en5 = std::string("Done") }
        else { __en5 = std::string("") }
        obj.field<std::string>("status", &__en5)
        obj.field<char>("initial", &self.initial)
        return std::Result.Ok<std::Unit, std::SerializationError>(std::Unit())
    }
}

impl std::Deserializer<Person> for TypeDecoder<Person> {
    func deserialize(&self) : std::Result<Person, std::SerializationError> {
        var __dr = self.decoder.object()
        // ... err-check + `var Ok(__obj) = __dr else unreachable`
        // name : std::string  -> composite path
        var __v0 = __p0.second.decode<std::string>()
        var __f0 : std::string = take_ok<std::string>(&mut __v0)
        // age : int -> decode_i64 + cast
        var __v1 = __p1.second.decode_i64()
        var Ok(__f1) = __v1 else unreachable
        // home : Address -> composite path
        var __v4 = __p4.second.decode<Address>()
        var __f4 : Address = take_ok<Address>(&mut __v4)
        // status : Status -> decode_str + fnv1 hash dispatch
        var __v5 = __p5.second.decode_str()
        var Ok(__s5) = __v5 else unreachable
        const __h5 = fnv1_hash_view(&__s5)
        var __f5 : Status
        if(__h5 == <hash("Active")>) { __f5 = 0 as Status }
        else if(__h5 == <hash("Paused")>) { __f5 = 1 as Status }
        else if(__h5 == <hash("Done")>) { __f5 = 2 as Status }
        else { return std::Result.Err<Person, std::SerializationError>(se_err("unknown enum member name")) }
        // initial : char -> decode_char, Ok-bind
        var __v6 = __p6.second.decode_char()
        var Ok(__f6) = __v6 else unreachable
        return std::Result.Ok<Person, std::SerializationError>(Person {
            name : __f0, age : __f1 as int, score : __f2 as float, active : __f3,
            home : __f4, status : __f5, initial : __f6
        })
    }
}

Note the composite members (name, home) are passed as plain identifiers into the literal (move), while scalars (age, score, ...) are cast. This is the destructor-safety rule from §3.4.

4.2 Unsupported field shapes (clean compile error at the #json site)

unsupported_field_reason (main.ch:246-291) rejects these before any impl is built:

Field typeReason emitted
std::vector<T> / std::ordered_map<K,V> / Option<T>generic container field types ... are not supported yet (main.ch:253-255) — see pitfall #14: already-monomorphized containers can slip past this check
[N]T arrayan array field type (not supported) (main.ch:256-258)
*T / &Tpointer/reference field type (main.ch:259-264)
unionunion field type (main.ch:265-267, :279-281)
varianta variant field type (#json variant support is not implemented yet) (main.ch:276-278)
dynamic/function/void/complex/long doubleno Serializer/Deserializer implementation (main.ch:284-287)

std::string/string_view, nested structs, and enums are supported (main.ch:249-252, :272-275). #json(Variant) and container impls are follow-up work (see pitfall #14).

4.3 Field order is positional, keys are not matched

Decode consumes item_decoder() pairs in order and never compares the key name to the member name. Encode writes in declaration order. Extra trailing JSON pairs are ignored; missing pairs produce Err (item_decoder runs out). Tests: struct_tests.ch:610-639 (test_manual_json_impl_decode_missing_field, test_manual_json_impl_decode_extra_fields_ignored).

5. Attribute / field-level behavior

  • No field attributes exist yet. The parser accepts only #json(<TypePath>) (main.ch:7-33): (, one identifier, zero or more :: identifier, ). There is no syntax for skip, rename, default, or per-field options.
  • The JSON key for a member is always the member name, and the order is always declaration order.
  • A marker annotation "serializable" is created in build.lab:25 (controller.createMarkerAnnotation), but there are currently zero consumers of it in json_cbi or the json runtime (searching lang/libs/json* for serializable matches only that creation). It is reserved for future field-level/marker-driven behavior; do not assume it does anything today.
  • If you need to exclude a field, the only current option is to hand-write the Serializer/Deserializer impl (as in struct_tests.ch) instead of using #json, or restructure the struct.

6. CBI bindings the macro depends on

The plugin needed several new compiler-CBI bindings. All live in compiler/cbi/bindings/ (+ the Chemical wrappers in lang/libs/compiler/src/*.ch and symbol registration in compiler/cbi/bindings/CBI.cpp). If a builder API is missing for a future macro, these are the templates to copy:

BindingFilePurpose
ASTBuildermake_generic_type_with_argsASTBuilderCBI.cpp:228GenericType from a linked decl + (type, loc) argument spans
ASTBuildermake_reference_of_valueASTBuilderCBI.cpp:294&mut-style reference value (mirrors parser &mut)
ASTBuildermake_pattern_match_expr / make_pattern_match_node / PatternMatchExprset_expression / set_else_unreachable / add_param_nameASTBuilderCBI.cpp:718build var Ok(x) = expr else unreachable statements
FunctionCalladd_generic_arg / FunctionCallNodeadd_generic_argASTBuilderCBI.cpp:714, 764explicit generic args on calls / call statements
ASTBuildermake_identifier (null-safe)ASTBuilderCBI.cpptolerate null links; skip known_type() for PatternMatchId
ASTBuildermake_function (sets signature_resolved)ASTBuilderCBI.cppauto-created fn decls count as signature-resolved
ImplDefinitionadd_function / StructDefinitionadd_functionASTBuilderCBI.cppattach contained funcs to impls/structs
SymResLinkBodyvisitTypeSymbolResolverCBI.cpp:56visitor.visitType(BaseType*, loc) from plugins (instantiation trigger)
SymbolResolverindex_implSymbolResolverCBI.cpp + LinkSignature.cpp wrapperregister + adopt a macro-generated impl (see above)
ParserparseTypeParserCBI.*parse a type expression from the macro parser (used by future macros)
EmbeddedNode replacement callbacks (known_type_fn, child_res_fn, cross_mod_sym_decl_proxy_fn)existing EmbeddedNode machineryrequired params of make_top_level_embedded_node; the json macro passes no-ops
CTopLevelDeclarationVisitor::VisitScopepreprocess/2c/2cASTVisitor.cpp + CTopLevelDeclVisitor.hdeclare pass must recurse into the replacement Scope (macro-generated impls otherwise get bodies but no C prototypes → cross-module ordering errors in the generated C)

Also note the symres change in compiler/symres/SymResLinkBody.cpp: VisitVariableIdentifier honors a pre-set identifier->linked (plugin-generated identifiers) instead of only resolving by name — chain members are still re-resolved by find_link_in_parent against concrete receiver types.

7. Gotchas & battle scars (read before changing anything)

  1. GenericType::instantiate() mutates referenced->linked. Two different generic types built from the same LinkedType base (e.g. Result<Point, SE> and Result<Unit, SE>) alias each other after instantiation. Always build fresh make_linked_type instances per generic type (result_linked/result_linked2, se_linked/se_linked2, ...).

  2. Pre-link every identifier to a real node; never pass null blindly. Plugin identifiers are visited by symres outside normal name resolution; identifiers with no link crash later (notably function-call "parent" identifiers like Ok(...)/Err(...)/replace(...)/SerializationError(), which must link to the resolved VariantMember/function decl). The one safe exception: identifiers that will become access-chain members (positions ≥ 1 in a chain) are unconditionally re-linked by find_link_in_parent against the receiver's concrete type.

  3. Method-call statements must be AccessChainNodes over the chain, not ValueNodes. make_function_call_node(chain, ...) mirrors the parser's bare-call statement and terminates with ; + destructor handling; a make_value_node wrapping a call emits no ; and produces invalid C (...})) (*({...}) parses as a call). Never pass a pre-built FunctionCall as the chain (the binding wraps it in a second call whose parent is the first call's result).

  4. &mut must be ReferenceOfValue, not AddrOfValue — AddrOfValue (&raw mut) routes through an extra temp during call codegen and emits an extra &.

  5. Generic std::replace from generated code corrupts under nested instantiation. If a generated (non-generic) function calls std::replace<T> after a statement that triggered a generic function instantiation (e.g. nested decode<T>), the replace call can register a bogus instantiation with an unspecialized generic arg → [GENERIC_TYPE_PARAMETER_NOT_SPECIALIZED_COMPILER_BUG] in the generated C. Fix: use the non-generic __non_gen_se_repl (in lang/libs/json/src/decode.ch); the handwritten reference does the same on purpose.

  6. decode<T>() calls leave the generic master Result type on later statements. While the decode<Child> instantiation finalizes (asynchronously), statements after the call (the Ok/Err pattern-match temps) stay typed as the un-instantiated std::Result → the pattern-match pointer temp emits as std_stdResult* (undeclared C symbol). Fix: pre-instantiate Result<field_type, SE> per struct-typed field and give the decode-result var an explicit concrete type (make_varinit_stmt(..., type, ...)); identifiers linked to that var then inherit the concrete type at creation.

  7. Pattern-match node move bug: make_pattern_match_node moves the configured PatternMatchExpr into the node's embedded expression. After the move, each PatternMatchIdentifier's matchExpr must be re-pointed at &node->value (done in the binding) or codegen's local_allocated lookup fails and destructured names emit as bare C identifiers.

  8. Contained functions must have the ImplDefinition as AST parent, so the mangler emits the impl-context name (std_stdSerializer__cgs__0_Point_serialize) that vtable generation expects — exactly like parsed impl code. Building the fn under the struct decl instead yields main_Pointserialize and the vtable/interface links break.

  9. Macro impls are not indexed automatically. Parsed impls get indexed by the module's index phase (index_implementation → container->adopt(impl) + implsIndex.add_interface). Macro-generated impls must be indexed explicitly via resolver.index_impl or p.serialize(...)/decode<T>() resolution fails.

  10. The 2c declare pass must see the replacement Scope. Without CTopLevelDeclarationVisitor::VisitScope recursing into replacement nodes' scopes, generated impls get definitions but no C prototypes → error: declaration expected / implicit-declaration clashes in Translated.c when json-module instantiated code calls them.

  11. Field decode order = JSON key order = struct member order. The decoder consumes pairs by position (item_decoder() per field) and never compares the key name — matches the handwritten reference. Keep struct member order stable between encode and decode.

  12. Unsupported field shapes get a clean #json-site error. json_symResNode validates each member type (unsupported_field_reason) and calls resolver.error + aborts that #json for shapes that clearly can't serialize: arrays, pointers/references, unions, Linked→VariantDecl/UnionDecl, dynamic/function/void kinds. std::string/string_view, nested structs (own #json/impl) and enum fields are supported; resolver.error must receive a static/literal string_view (a view into a stack-local std::string dangles and the error is silently dropped — the field then vanished with no diagnostic, fixed by returning string_view literals from the reason helper).

  13. Enum fields serialize as the member NAME string. A #json struct member of an enum type encodes as "Red" (if/else chain over (self.field as int) == <ordinal> assigning std::string("Red"), then obj.field<std::string>("name", &__en<i>)) and decodes by hashing the decoded name with std::fnv1_hash_view and dispatching over precomputed name hashes (fnv1a64 computed in the plugin at macro time, compared via make_ubigint_value cast to the fn's return type) back to 0 as EnumType-style values; an unknown name returns Err<Struct, SE>(se_err(...)). Impls on enums are impossible (the compiler rejects impl X for SomeEnum), so this is macro per-field codegen only.

  14. #json does NOT support vector/map/Option fields yet. Struct members typed with a generic container still break the json module's own symres (unresolved child 'serialize' in parent 'value' cascade) rather than producing the clean error above — by the time the macro reads member types the container has been monomorphized into an ordinary-looking decl that the BaseTypeKind/linked-node-kind check can't distinguish from a plain struct (verified empirically: the member-kind check returns "supported"). Needs container-name/decl-origin detection or Serializer/Deserializer impls for the containers. Variants are likewise not yet supported (see the follow-up: #json(Variant) with case-name-object wire format needs case-param introspection through the CBI).

  15. Destructible/owning members are supported end-to-end (owning strings, nested structs with @delete, structs whose members own resources). Encode passes &self.<field> by reference (never moves/copies members out of &self); decode moves payloads out of their Results with take_ok and adds composite members to the final literal without a CastedValue — a cast around a destructible value bitwise-copies it into the member and the source local is still destructed at scope end (double free). Always verify a destructible round trip with destructor-count checks.

  16. Public-generic retention rule. Public generic functions (the container impl methods, json::encode<T>, the decode dispatch chain) may only call public functions — internal helpers hit "calling a non-retained function in a public generic declaration". The __-prefixed helpers (__unsafe_cast_json_encoder, se_err, __decode_dispatch_1/2, __container_begin_item) are public for exactly this reason.

  17. Float/double precision. write_double_raw uses %.17g and write_float_raw uses %.9g — the exact round-trip precisions for IEEE-754 double/float. The old %.6g silently corrupted values like 0.1+0.2 (emitted 0.3). Expect non-pretty float text like 3.1400001 for 3.14f.

  18. encode_char escapes. A char is a single-char JSON string and the char itself is escaped (quotes/backslashes/control chars) via json_escape_into over a 1-char buffer — '"' emits "\"".

  19. One container struct cannot implement all three encoder interfaces. A single JsonContainerEncoder implementing ArrayEncoder/ObjectEncoder/MapEncoder breaks interface-constraint resolution for the two-generic-param MapEncoder.encode<K, V> (unresolved serialize on the V param). The three structs stay separate; the shared comma/bookkeeping is the __container_begin_item free function.

  20. The parser only allocates the heap string buffer when needed, and always frees it. Strings at or below max_string but above 4096 bytes move from scratch into heap_buf (json.ch:212-237); parse_string_value/parse_string_key call free_str_buf() on error and on success (json.ch:289-305). Setting max_string too small makes long strings fail with a max_string error — raise it via JsonParser(_, max_string).

  21. JsonValue.Number is text, not a number. Do not read it as an integer/double directly; go through JsonDecoder.decode_i64/u64/double/float (decode.ch:91-125), which parse the text (encode.ch:151-209).

7.1 Troubleshooting table

SymptomLikely causeFix / reference
[GENERIC_TYPE_PARAMETER_NOT_SPECIALIZED_COMPILER_BUG] in generated Cgeneric std::replace called from generated code while a decode<T> instantiation is in flight, or two generic types sharing one LinkedType baseuse __non_gen_se_repl; build fresh LinkedTypes (pitfalls #5, #1)
std_stdResult* (no __cgs__) in generated Cdecode-result var left at the generic master Result typepre-instantiate Result<field_type, SE> + explicit var type (pitfall #6)
cannot move this value without re-initializing memorypattern-bound Ok payload moved into the aggregate, or a CastedValue around a destructible valueuse take_ok; pass composite members as plain identifiers (pitfall #15)
destructor count higher than constructed objects / double freeCastedValue bitwise-copied a destructible member, or Result payload copiedremove the cast; use take_ok (pitfall #15)
unresolved child 'serialize' in parent 'value'vector/map/Option/variant field, or #json(Child) placed after #json(Parent)not supported yet / reorder #json calls (pitfall #14, §1)
p.serialize(...) or d.decode<T>() unresolvedimpl not adopted/indexedensure resolver.index_impl ran (pitfall #9)
#json error silently disappears, field vanishesresolver.error given a dangling stack string_viewreturn literal string_views (pitfall #12)
error: declaration expected / implicit-declaration in Translated.c2c declare pass did not visit the replacement scopeCTopLevelDeclarationVisitor::VisitScope must recurse (pitfall #10)
vtable / interface link failuregenerated fn built under the struct instead of the implparent must be the ImplDefinition (pitfall #8)
destructured names emit as bare C identifierspattern-match move bugre-point PatternMatchIdentifier::matchExpr (pitfall #7)
char field fails to decode (expected number)char routed through decode_u64find_decoder_method must map char/uchar to decode_char (§3.4)
float text looks odd (3.1400001)intended %.9g/%.17g round-trip precisionexpected (pitfall #17)
long JSON string rejectedmax_string too smallraise JsonParser(_, max_string) (pitfall #20)
encode<T> returns generic encode failedErr payload extraction impossible in a generic fninspect the lower-level container API for the real error (§2.6)

8. Testing & debugging

8.1 Committed test suites

SuiteFilesWhat it covers
json runtime (lib tests)lang/tests/libs/json/main.ch (parser, stringify, encoder/decoder primitives, unicode/surrogates, long strings), struct_tests.ch (handwritten Serializer/Deserializer impls + json::encode<T>/decode<T>/decode_str<T>), utils.ch (helpers)the runtime contract the macro must satisfy, without the plugin
#json macro testslang/tests/compiler_plugins/json/src/test.ch (scalar/nested/wide/namespaced/char structs, error paths, destructible round trips with destructor counters, enum round trip + unknown name)parse → symres → codegen → runtime for #json

lang/tests/libs/chemical.mod imports json and uses source ".", so every .ch under lang/tests/libs/json/ is compiled into the lib_tests application (main.ch calls test_runner). The macro suite is a separate module (lang/tests/compiler_plugins/json/chemical.mod, import json_cbi).

⚠️ lang/tests/build.lab:29 imports ./compiler_plugins/json/build.lab as jsonTestMod, but that build.lab does not exist in this checkout (the directory only has chemical.mod + src/test.ch). The jsonTestMod.build(...) call at build.lab:235 (inside test_plugin_exe(..., "all")) is therefore dangling. The @test functions in src/test.ch can still be linked directly from a scratch app that imports the module plus a test_runner(argc, argv) main, or once the missing build.lab is added.

8.2 Running the tests

# Library suite (includes lang/tests/libs/json/*): builds `chemical-lib-tests`
./scripts/test.sh --tcc --libs

# Plugin suite (includes compiler_plugins/json once wired): builds `chemical-plugins-tests`
./scripts/test.sh --tcc --plugins

# Compile-only / skip rebuild variants
./scripts/test.sh --tcc --libs --no-run
./scripts/test.sh --tcc --libs --no-build

# Run specific @test functions by name (executable is lang/tests/build/tests-tcc.exe)
./lang/tests/build/tests-tcc.exe --test-names test_high_level_parse_stringify,test_manual_json_impl_nested_struct_roundtrip

Handy names to target: test_manual_json_impl_scalar_struct_roundtrip, test_manual_json_impl_nested_struct_roundtrip, test_manual_json_impl_string_escapes_roundtrip, test_high_level_typed_encode_decode, test_decode_str_parse_and_decode, json_macro_scalar_struct_roundtrip, json_macro_nested_struct_roundtrip, json_macro_destructor_scalar_struct_roundtrip, json_macro_enum_struct_roundtrip, json_macro_enum_unknown_name_rejected.

Scratch packages live under lang/compiled/ (git-ignored) so they never pollute the repo:

  • lang/compiled/temp/chemical.mod — single-file test app. Its source "src/<file>.ch" line selects the active test:
    • json2.ch — #json(Point) scalar round trip (ROUND TRIP OK).
    • json3.ch — #json(Child) then #json(Parent) nested round trip (NESTED ROUND TRIP OK). Requires Child's #json above Parent's.
  • lang/compiled/refmod/chemical.mod — the handwritten reference (Sample/SampleChild impls by hand; no #json). Compiles/runs the same checks and validates the runtime library + bindings without the macro.
  • lang/tests/libs/json/ — committed json runtime tests (main.ch parser/encoder/decoder + high-level API; struct_tests.ch handwritten impls + json::encode<T>/json::decode<T>; utils.ch helper). Part of the library test suite (./scripts/test.sh --tcc --libs); run individual tests with ./lang/tests/build/tests-tcc.exe --test-names <comma-separated-func-names>.
  • lang/tests/compiler_plugins/json/src/test.ch — committed #json macro tests (scalar/nested/wide structs, error paths, destructible round trips with destructor counters, #json(jmns::JMNs) namespaced, char/uchar fields). Built via ./cmake-build-debug/TCCCompiler lang/tests/build.lab -o <exe> --mode debug_complete -arg test-plugins (the import "./compiler_plugins/json/build.lab" is currently dangling — the plugin module links only if a matching build.lab exists; the test module's @test fns can also be linked directly from a scratch app importing it + a test_runner(argc, argv) main).

Compile + run a scratch package:

cmake-build-debug/TCCCompiler lang/compiled/temp/chemical.mod -o lang/compiled/temp/temp.exe -v -bm-modules
./lang/compiled/temp/temp.exe

After touching compiler C++: ./scripts/build.sh --tcc. Full regression: ./scripts/test.sh --tcc (expect Total <N> Passed <N> Failed 0).

Generated C lands at lang/compiled/temp/build/Translated.c. Useful search anchors when the C is wrong:

  • std_stdSerializer__cgs__0_<Struct>_serialize / std_stdDeserializer__cgs__<N>_TypeDecoder__cgs__<N>_deserialize — the macro-generated functions.
  • std_stdDecoder_JsonDecoder_object, std_stdObjectDecoder_JsonObjectDecoder_item_decoder, json_JsonDecoderdecode__cfg_<N>, json___non_gen_se_repl — runtime calls the generated code must resolve to.
  • [GENERIC_TYPE_PARAMETER_NOT_SPECIALIZED_COMPILER_BUG] — pitfall #5/#6 happened.
  • std_stdResult* without a __cgs__ suffix — pitfall #6 happened (pattern temp typed at the generic master).

Debugging loop: fix → rebuild plugin-only changes need no C++ rebuild (the plugin .ch is JIT-compiled at runtime) → recompile scratch package → inspect Translated.c/run exe.

9. Cross-links

  • Language Syntax (chemical_source) — variants/enum/std::string/std::Result/move semantics needed to read generated code.
  • Compiler Plugin API (CBI) (cbi_plugin_api), Compiler API (compiler_api), Macro Codegen (macro_code_gen) — how json_cbi registers/indexes hooks, the ASTBuilder/SymbolResolver bindings, the EmbeddedNode lifecycle, the Enum Sync Rule, and sibling macro patterns.
  • Generics (generics) and Symbol Resolution (symres) — GenericType::instantiate() aliasing, asynchronous decode<T> finalization, index_impl, visitType, pre-set identifier->linked.
  • Type Verification (type_verification) — why cannot move this value ... / definite-assignment errors surface as they do.
  • C Codegen (2c) (c_codegen) and LLVM Backend (llvm_backend) — sret/declare-body passes the replacement scope feeds; the struct temp+memcpy destructor pattern behind the double-free pitfalls.
  • Testing Guide (testing) — @test dispatch, test_env, and lang/tests/build.lab wiring.

评价

还没有评价。欢迎分享你的使用感受。

评论

无需登录即可发表。欢迎分享使用感受、提问,或说明在哪些环境下无法运行。

还没有评论。来写第一条吧。

提交后会立即公开。请勿填写邮箱、电话等个人信息。URL 最多 2 个。

0 / 1000 字

此表单受 reCAPTCHA 保护,适用 Google 的隐私权政策和服务条款。

同一仓库的技能

Comprehensive guide to the Chemical compiler's annotation system (`@name`, `@name(args)`) — how annotations are lexed, parsed, registered in the `AnnotationController`, stored and queried centrally, consumed by the compiler and CBI plugins, and how to add a new one. Load when working on annotations, `AnnotationController`, compiler plugins that define/read annotations, or node attribute flags such as `@extern`, `@test`, `@make`, `@deprecated`, `@no_mangle`, `@inline`, and `@volatile`.

中文简介正在准备中。当前显示原文说明。

chemicallang/chemical312026年10月26日 更新

The C++ AST node framework of the Chemical compiler — the ASTAny/ASTNode/Value/BaseType hierarchy, node/value/type kind enums and range predicates, the visitor dispatch framework, the arena allocator, TypeBuilder canonicalization, member containers, and cross-reference resolution. Load when adding or modifying AST nodes, writing compiler passes/codegen visitors, debugging kind mismatches or arena/lifetime issues, or extending the compiler API bindings.

中文简介正在准备中。当前显示原文说明。

chemicallang/chemical312026年10月26日 更新

The Chemical compiler benchmark & release analytics system — how the static GitHub Pages dashboard works, the JSON data model (manifest/daily/release records), the bash collection scripts (bench-*.sh), the GitHub Actions workflows (benchmark-daily/release), and the critical pitfalls (hidden upload dirs, worktree submodules, gh-pages-only data). Load when working on the dashboard, benchmark collection, or analytics workflows.

中文简介正在准备中。当前显示原文说明。

chemicallang/chemical312026年10月26日 更新

Comprehensive deep-dive into the Chemical build pipeline — how chemical.mod is converted to build.lab, how build scripts are JIT-compiled via TinyCC, how jobs are created and executed, and how ASTProcessor orchestrates the parallel compilation passes.

中文简介正在准备中。当前显示原文说明。

chemicallang/chemical312026年10月26日 更新

Building

免费

All documentation related to building and running Project or Tests

中文简介正在准备中。当前显示原文说明。

chemicallang/chemical312026年10月26日 更新

Comprehensive guide to the C translation backend (2c) — how Chemical AST is translated to C code, key patterns, gotchas, and optimization strategies.

中文简介正在准备中。当前显示原文说明。

chemicallang/chemical312026年10月26日 更新

查看 chemicallang 的全部技能

报告此技能的问题