// A component instance as JSON, and back — the TAGGED projection, beside the
// untagged one in value_json.mbt.
//
// `Value::to_json` answers "what does this instance HOLD"; it walks the
// declared fields and drops everything else, including which component it is.
// That is the right answer for a state dump and the wrong one for a document
// somebody means to read back, because a reader holding `{"count": 3}` has no
// way to know what to build. So this pair adds the one thing that was missing —
// a `$component` key naming the component, resolved through whatever the
// caller uses as a namespace — and walks BY THE DECLARED TYPE rather than by
// what a value happens to be.
//
// Walking by type is the whole difference. A codec that asks "is this field an
// instance?" cannot tell an empty component slot from a null, or an empty list
// of children from a scalar, because at that moment neither holds an instance
// to recognize. `FieldInfo.ty` says so without having to look.
//
// ## The shape
//
// { "$component": "Tabs", "active": 0,
// "tabs": [ { "$component": "TabPage", "label": "notes",
// "body": { "$component": "Textarea", "text": "hi" } } ] }
//
// Flat: the fields sit at the top level and the identity is a reserved key
// beside them. A declared field can never collide with one — `$` is not an
// identifier start in the state language and not one in WIT — and flatness is
// what makes a document interchangeable with the JSON Schema projection of the
// same component, so a generated form's output is already a constructor
// document.
//
// ## Where a round trip is EXACT
//
// Every declared field whose type this codec can name, at every depth, plus the
// component's identity. In particular an empty slot survives: the encoder
// always writes the key, and a PRESENT null is a different answer from an
// ABSENT one on the way back (see `from_component_json`).
//
// ## Where it is NOT
//
// 1. `Fn` fields — always null, at every depth. JSON has no shape for a
// function and a structured clone would throw on one too.
// 2. An `Obj` with no schema — an ad-hoc object standing in for a record. With
// no field list there is nothing to project and no name to rebuild it by.
// (Not the same as a schema with NO fields, which is complete: it says the
// name, and the name is the whole instruction.)
// 3. Anything the schema does not name — the `FAny` extras a component keeps
// beside its declared fields, and whatever a hand-written instance holds
// privately. `Obj::obj_schema` already says this is the deal.
// 4. A guest's private bytes. The contract says the host does not know their
// shape; they ride in a side channel its owner threads through `claim~`.
// 5. `TyRecord` / `TyVariant` / `TyEnum` / `TyTable` payloads are DATA. An
// instance smuggled inside one encodes untagged and returns as a plain Map.
// Deliberate: the schema knows those types' names and nothing more, so
// walking into one could only ever hydrate something a hostile document
// planted.
// 6. Integers are Doubles, because `Value::Num` is and JSON has nothing else.
// Exact to 2^53.
// 7. A plain Map whose key is literally `$component`, in a position that
// permits an instance, comes back as an instance. The reserved keys are
// collision-free against declared FIELD NAMES, not against map CONTENTS.
// 8. Identity. A restored instance is a new instance with a new `ObjId`.
///|
/// The discriminator: the component a tagged object names.
///
/// Collision-free by construction. A declared field name cannot begin with `$`
/// — the state language's identifier start is alpha-or-underscore — and neither
/// can a WIT identifier, so no component can declare a field that shadows this.
pub let component_key : String = "$component"
///|
/// The bundle a tagged object came from, when its writer knew.
///
/// A separate key rather than a `module/Component` join, because a module name
/// may contain a slash — which is exactly why `ComponentRef::to_id` is a label
/// with no way back. Absent means "resolve in whatever namespace you are",
/// which is the only thing a document written without a host can mean.
pub let module_key : String = "$module"
///|
/// Where instances come from when a document is read back.
///
/// A trait rather than a pair of closures for two reasons. A function TYPE
/// cannot carry labelled parameters, so `build`'s four arguments would be
/// positional and two of them are `String`; and a source that can build a name
/// it cannot describe — a foreign guest — wants to answer only one of the two,
/// which a defaulted method gives it for free.
pub(open) trait ComponentSource {
/// What the named component DECLARES, so a tagged object's fields decode by
/// TYPE rather than by guessing at their shape.
///
/// `None` — the default — decodes them structurally instead. That is exactly
/// right for a document this codec wrote, since the encoder never puts a tag
/// anywhere the decoder would not look, and it is the honest answer for a
/// source whose components are opaque to it.
fn describe(Self, module_ : String?, name : String) -> SchemaInfo? = _
/// Build it.
///
/// `at` is the RFC 6901 pointer of the object being built, so a caller with a
/// side table keyed by position — a session's per-instance snapshots — finds
/// its entry without a second walk over the document.
///
/// `None` means "not here", and the field being filled then falls to its
/// declared default. Deliberately the same answer a wrong-shaped value gets:
/// one rule, and no error channel to thread through a page that loads its
/// bundles by hand.
fn build(
Self,
module_ : String?,
name : String,
args : Map[String, Value],
at~ : String,
) -> Value?
}
///|
/// A source that cannot describe what it builds. See `describe`.
impl ComponentSource with fn describe(_self, _module_, _name) {
None
}
///|
/// A `ComponentSource` out of plain functions, for a caller with no type to
/// hang them on — a test, a one-off, a page that resolves two ways.
pub struct FnSource {
priv build_ : (String?, String, Map[String, Value], String) -> Value?
priv describe_ : ((String?, String) -> SchemaInfo?)?
}
///|
pub fn FnSource::new(
build : (String?, String, Map[String, Value], String) -> Value?,
describe? : (String?, String) -> SchemaInfo?,
) -> FnSource {
{ build_: build, describe_: describe }
}
///|
pub impl ComponentSource for FnSource with fn describe(self, module_, name) {
match self.describe_ {
Some(f) => f(module_, name)
None => None
}
}
///|
pub impl ComponentSource for FnSource with fn build(
self,
module_,
name,
args,
at~,
) {
(self.build_)(module_, name, args, at)
}
// --- pointers ---------------------------------------------------------------
///|
/// A child pointer, RFC 6901. Keys holding `~` or `/` are escaped, which
/// matters more than it looks: a component names its own fields, so `a/b` is a
/// name an untrusted manifest can produce.
fn ptr(at : String, key : String) -> String {
let escaped = key
.replace_all(old="~", new="~0")
.replace_all(old="/", new="~1")
"\{at}/\{escaped}"
}
// --- encode -----------------------------------------------------------------
///|
/// This value as a tagged JSON document, recursively and schema-driven.
///
/// `claim~` sees every instance and its pointer BEFORE any field of it is
/// read; answering `Some(j)` writes `j` in place of the walk. It runs first
/// rather than after because the case it exists for is a component whose state
/// is not the caller's to read, and reading a foreign guest's fields would
/// cross a boundary once per field before the hook could say no.
pub fn Value::to_component_json(
self : Value,
claim? : (Value, String) -> Json?,
) -> Json {
encode_any(self, "", claim)
}
///|
/// One instance. Null for anything that is not a described one — with no field
/// list there is nothing to project and no name to rebuild it by.
fn encode_instance(
v : Value,
at : String,
claim : ((Value, String) -> Json?)?,
) -> Json {
if claim is Some(f) && f(v, at) is Some(j) {
return j
}
guard v is Obj(o) else { return Json::null() }
guard o.obj_schema() is Some(sc) else { return Json::null() }
let out : Map[String, Json] = Map([])
out[component_key] = Json::string(sc.name)
for f in sc.fields {
let fv = o.obj_field(f.name).unwrap_or(Null)
out[f.name] = encode_field(fv, f.ty, ptr(at, f.name), claim)
}
Json::object(out)
}
///|
/// One field, by its DECLARED type. Every declared field is written, including
/// an empty list and an empty slot: a container's constructor fills in what a
/// document leaves out, so an omitted-because-empty field would come back
/// holding something nobody put there.
fn encode_field(
v : Value,
ty : TyInfo,
at : String,
claim : ((Value, String) -> Json?)?,
) -> Json {
match (ty, v) {
// An empty slot is `null`, and the key is still written. That is what makes
// it distinguishable from a slot the document never mentioned.
(TyComp(_), Null) => Json::null()
(TyComp(_), Obj(_)) => encode_instance(v, at, claim)
// A slot holding something that is not an instance is data nobody declared;
// let the untagged projection say what it can.
(TyComp(_), _) => v.to_json()
(TyAny, _) => encode_any(v, at, claim)
// An option is its payload or nothing, so it is written as its payload's
// type would be — which is what keeps a `Set[X]?` spelled the way a `Set[X]`
// is, rather than as whatever the value happens to look like.
(TyOption(_), Null) => Json::null()
(TyOption(inner), _) => encode_field(v, inner, at, claim)
(TyList(e), List(items)) =>
Json::array(
items.mapi((i, it) => encode_field(it, e, ptr(at, i.to_string()), claim)),
)
(TyTuple(ts), List(items)) => {
let out : Array[Json] = []
for i, it in items {
out.push(
if i < ts.length() {
encode_field(it, ts[i], ptr(at, i.to_string()), claim)
} else {
it.to_json()
},
)
}
Json::array(out)
}
(TyOMap(e), Map(m)) => {
let out : Map[String, Json] = Map([])
for k, it in m {
out[k] = encode_field(it, e, ptr(at, k), claim)
}
Json::object(out)
}
// A set is held as a Map and travels as an ARRAY, which is the spelling the
// JSON Schema projection describes and the one `coerce_or_default` already
// reads back (a List at an FSet field coerces into the Map form). Writing
// the Map would make this codec's document and a generated form's document
// two different things for one type.
(TySet, Map(m)) | (TyFlags(_, _), Map(m)) => {
let out : Array[Json] = []
for k, _ in m {
out.push(Json::string(k))
}
Json::array(out)
}
_ => v.to_json()
}
}
///|
/// A value whose declared type says nothing about it — `any`, an option's
/// payload, or an element of either. An instance here is still tagged, because
/// this is exactly where a document has to say what it holds.
fn encode_any(
v : Value,
at : String,
claim : ((Value, String) -> Json?)?,
) -> Json {
match v {
Obj(o) =>
if o.obj_schema() is Some(_) {
encode_instance(v, at, claim)
} else {
Json::null()
}
List(items) =>
Json::array(
items.mapi((i, it) => encode_any(it, ptr(at, i.to_string()), claim)),
)
Map(m) => {
let out : Map[String, Json] = Map([])
for k, it in m {
out[k] = encode_any(it, ptr(at, k), claim)
}
Json::object(out)
}
_ => v.to_json()
}
}
// --- decode -----------------------------------------------------------------
///|
/// A tagged document back into an instance, through `src`.
///
/// None when the root names nothing `src` can build — a bundle that has not
/// been loaded, a component that was renamed, or a document that is not a
/// tagged object at all.
pub fn Value::from_component_json(j : Json, src : &ComponentSource) -> Value? {
decode_instance(j, "", src, fallback="")
}
///|
/// One declared field. `None` means OMIT IT, which is the whole mechanism: a
/// field absent from the args map takes the component's own default, and that
/// is what an unresolvable `$component` and a value the type cannot hold both
/// come back as.
///
/// A present `null` is NOT omitted. It decodes to `Null`, which for a slot is
/// how an empty one survives a round trip.
fn decode_field(
j : Json,
ty : TyInfo,
at : String,
src : &ComponentSource,
) -> Value? {
match (ty, j) {
(TyComp(c), Object(_)) =>
decode_instance(j, at, src, fallback=c.unwrap_or(""))
(TyComp(_), Null) => Some(Null)
(TyAny, _) => Some(decode_any(j, at, src))
(TyOption(_), Null) => Some(Null)
(TyOption(inner), _) => decode_field(j, inner, at, src)
(TyList(e), Array(items)) =>
Some(
List(
items.mapi((i, it) => decode_in(it, e, ptr(at, i.to_string()), src)),
),
)
(TyTuple(ts), Array(items)) => {
let out : Array[Value] = []
for i, it in items {
out.push(
if i < ts.length() {
decode_in(it, ts[i], ptr(at, i.to_string()), src)
} else {
Value::from_json(it)
},
)
}
Some(List(out))
}
(TyOMap(e), Object(m)) => {
let out : Map[String, Value] = Map([])
for k, it in m {
out[k] = decode_in(it, e, ptr(at, k), src)
}
Some(Map(out))
}
// Everything else — scalars, sets, and the four named shapes the schema
// knows only by name — is taken as it stands. `coerce_or_default` is what
// decides whether it fits, and a set arriving as an array coerces there.
_ => Some(Value::from_json(j))
}
}
///|
/// The same, inside a container, where there is no "omit" to answer with.
///
/// An unresolvable instance in a list becomes `Null` rather than disappearing:
/// a grid's cells are row-major, and dropping one would move every cell after
/// it.
fn decode_in(
j : Json,
ty : TyInfo,
at : String,
src : &ComponentSource,
) -> Value {
decode_field(j, ty, at, src).unwrap_or(Null)
}
///|
/// A value whose declared type says nothing about it. A tagged object here is
/// an instance; anything else is walked as plain data, so a `$component` deeper
/// inside still resolves.
fn decode_any(j : Json, at : String, src : &ComponentSource) -> Value {
match j {
Object(m) =>
if m.get(component_key) is Some(String(_)) {
decode_instance(j, at, src, fallback="").unwrap_or(Null)
} else {
let out : Map[String, Value] = Map([])
for k, it in m {
out[k] = decode_any(it, ptr(at, k), src)
}
Map(out)
}
Array(items) =>
List(items.mapi((i, it) => decode_any(it, ptr(at, i.to_string()), src)))
_ => Value::from_json(j)
}
}
///|
/// One tagged object. `fallback` is the component to assume when the document
/// does not name one, which is what lets a slot whose TYPE names its component
/// hold an untagged object — the tag is required only where the type cannot
/// say. A tag that disagrees with the fallback wins: the document is the more
/// specific statement, and the constructor re-checks the result anyway.
fn decode_instance(
j : Json,
at : String,
src : &ComponentSource,
fallback~ : String,
) -> Value? {
guard j is Object(m) else { return None }
let name = match m.get(component_key) {
Some(String(s)) => s
_ => fallback
}
guard name != "" else { return None }
let module_ = match m.get(module_key) {
Some(String(s)) => Some(s)
_ => None
}
let args : Map[String, Value] = Map([])
let schema = src.describe(module_, name)
for k, v in m {
if k == component_key || k == module_key {
continue
}
match schema.bind(sc => sc.field(k)) {
Some(fi) =>
match decode_field(v, fi.ty, ptr(at, k), src) {
Some(dv) => args[k] = dv
// Omitted, so the component's own default applies.
None => ()
}
// A field the schema does not declare, or a source that cannot describe
// what it builds. Decoded structurally and passed on; a constructor that
// does not know the name ignores it.
None => args[k] = decode_any(v, ptr(at, k), src)
}
}
src.build(module_, name, args, at~)
}