use super::util::{Provenance, VERSION, doc_lines, int_bounds};
use rspyts_core::ir::{FieldDecl, Manifest, Ty, TypeDecl};
use serde_json::{Map, Value, json};
pub fn emit(m: &Manifest, provenance: &Provenance<'_>) -> Vec<(&'static str, String)> {
let mut root = Map::new();
root.insert(
"$schema".to_string(),
json!("https://json-schema.org/draft/2020-12/schema"),
);
root.insert(
"$comment".to_string(),
json!(format!(
"Code generated by rspyts v{VERSION}. DO NOT EDIT THIS FILE. Edit the Rust source tree instead: {}. Then regenerate these bindings with rspyts.",
provenance.rust_source
)),
);
let mut meta = Map::new();
meta.insert("version".to_string(), json!(m.crate_version));
meta.insert("crate".to_string(), json!(m.crate_name));
meta.insert("generatorVersion".to_string(), json!(VERSION));
meta.insert("rustSource".to_string(), json!(provenance.rust_source));
meta.insert(
"manifestHash".to_string(),
json!(format!("sha256:{}", provenance.manifest_hash)),
);
root.insert("x-rspyts".to_string(), Value::Object(meta));
let mut defs = Map::new();
for decl in &m.types {
match decl {
TypeDecl::Newtype {
name, docs, inner, ..
} => {
let mut def = Map::new();
add_description(&mut def, docs);
extend_with(&mut def, ty_schema(inner));
defs.insert(name.clone(), Value::Object(def));
}
TypeDecl::Struct {
name, docs, fields, ..
} => {
let mut def = Map::new();
add_description(&mut def, docs);
object_schema(&mut def, None, fields);
defs.insert(name.clone(), Value::Object(def));
}
TypeDecl::StringEnum {
name,
docs,
variants,
..
} => {
let mut def = Map::new();
add_description(&mut def, docs);
def.insert("type".to_string(), json!("string"));
def.insert(
"enum".to_string(),
Value::Array(variants.iter().map(|v| json!(v.wire_name)).collect()),
);
defs.insert(name.clone(), Value::Object(def));
}
TypeDecl::Enum {
name,
docs,
tag,
variants,
..
} => {
let mut def = Map::new();
add_description(&mut def, docs);
let one_of: Vec<Value> = variants
.iter()
.map(|v| {
let mut variant = Map::new();
object_schema(&mut variant, Some((tag, &v.wire_name)), &v.fields);
Value::Object(variant)
})
.collect();
def.insert("oneOf".to_string(), Value::Array(one_of));
defs.insert(name.clone(), Value::Object(def));
}
TypeDecl::ErrorEnum { .. } => {}
}
}
root.insert("$defs".to_string(), Value::Object(defs));
let mut text = serde_json::to_string_pretty(&Value::Object(root))
.expect("schema serialization cannot fail");
text.push('\n');
vec![("schema.json", text)]
}
fn object_schema(def: &mut Map<String, Value>, tag: Option<(&str, &str)>, fields: &[FieldDecl]) {
def.insert("type".to_string(), json!("object"));
let mut properties = Map::new();
let mut required: Vec<Value> = Vec::new();
if let Some((tag_key, tag_value)) = tag {
let mut prop = Map::new();
prop.insert("const".to_string(), json!(tag_value));
properties.insert(tag_key.to_string(), Value::Object(prop));
required.push(json!(tag_key));
}
for f in fields {
let mut prop = Map::new();
add_description(&mut prop, &f.docs);
extend_with(&mut prop, ty_schema(&f.ty));
properties.insert(f.wire_name.clone(), Value::Object(prop));
if f.required {
required.push(json!(f.wire_name.clone()));
}
}
def.insert("properties".to_string(), Value::Object(properties));
if !required.is_empty() {
def.insert("required".to_string(), Value::Array(required));
}
def.insert("additionalProperties".to_string(), json!(false));
}
fn add_description(map: &mut Map<String, Value>, docs: &str) {
let lines = doc_lines(docs);
if !lines.is_empty() {
map.insert("description".to_string(), json!(lines.join("\n")));
}
}
fn extend_with(map: &mut Map<String, Value>, value: Value) {
let Value::Object(entries) = value else {
unreachable!("ty_schema always returns an object")
};
for (k, v) in entries {
map.entry(k).or_insert(v);
}
}
fn ty_schema(ty: &Ty) -> Value {
if let Some((lo, hi)) = int_bounds(ty) {
let mut map = Map::new();
map.insert("type".to_string(), json!("integer"));
map.insert("minimum".to_string(), json!(lo));
map.insert("maximum".to_string(), json!(hi));
return Value::Object(map);
}
match ty {
Ty::Bool => json!({"type": "boolean"}),
Ty::I64 => json!({
"type": "string",
"format": "int64",
"pattern": "^(?:0|-?[1-9][0-9]*)$",
"x-rspyts-minimum": i64::MIN.to_string(),
"x-rspyts-maximum": i64::MAX.to_string()
}),
Ty::U64 => json!({
"type": "string",
"format": "uint64",
"pattern": "^(?:0|[1-9][0-9]*)$",
"x-rspyts-minimum": "0",
"x-rspyts-maximum": u64::MAX.to_string()
}),
Ty::F32 | Ty::F64 => json!({"type": "number"}),
Ty::String => json!({"type": "string"}),
Ty::Bytes => attachment_schema("bytes"),
Ty::Unit => json!({"type": "null"}),
Ty::Null => json!({"type": "null"}),
Ty::Option { inner } => {
json!({"anyOf": [ty_schema(inner), {"type": "null"}]})
}
Ty::List { inner } => {
let mut map = Map::new();
map.insert("type".to_string(), json!("array"));
map.insert("items".to_string(), ty_schema(inner));
Value::Object(map)
}
Ty::Map { value } => {
let mut map = Map::new();
map.insert("type".to_string(), json!("object"));
map.insert("additionalProperties".to_string(), ty_schema(value));
Value::Object(map)
}
Ty::Tuple { items } => json!({
"type": "array",
"prefixItems": items.iter().map(ty_schema).collect::<Vec<_>>(),
"minItems": items.len(),
"maxItems": items.len()
}),
Ty::Ref { name } => json!({"$ref": format!("#/$defs/{name}")}),
Ty::Json => json!({"description": "schemaless"}),
Ty::Buf { dt } => attachment_schema(dt.wire_name()),
Ty::Slice { .. } => unreachable!("slices are param-only; validation rejects them here"),
Ty::U8 | Ty::U16 | Ty::U32 | Ty::I8 | Ty::I16 | Ty::I32 => unreachable!(),
}
}
fn attachment_schema(dt: &str) -> Value {
json!({
"type": "object",
"properties": {
"__rspyts_buf__": {
"type": "object",
"properties": {
"off": {"type": "integer", "minimum": 0},
"len": {"type": "integer", "minimum": 0},
"dt": {"const": dt}
},
"required": ["off", "len", "dt"],
"additionalProperties": false
}
},
"required": ["__rspyts_buf__"],
"additionalProperties": false
})
}
#[cfg(test)]
mod tests {
use super::super::test_manifest::{binary_manifest, exact_manifest, manifest, manifest_hash};
use super::*;
fn provenance(hash: &str) -> Provenance<'_> {
Provenance {
manifest_hash: hash,
rust_source: "../rust/src",
}
}
#[test]
fn schema_json_matches_golden() {
let m = manifest();
let hash = manifest_hash(&m);
let (name, actual) = emit(&m, &provenance(&hash)).remove(0);
assert_eq!(name, "schema.json");
let expected = r#"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$comment": "Code generated by rspyts v@VERSION@. DO NOT EDIT THIS FILE. Edit the Rust source tree instead: ../rust/src. Then regenerate these bindings with rspyts.",
"x-rspyts": {
"version": "0.1.0",
"crate": "demo-crate",
"generatorVersion": "@VERSION@",
"rustSource": "../rust/src",
"manifestHash": "sha256:@HASH@"
},
"$defs": {
"QueryOptions": {
"description": "Options controlling value processing.",
"type": "object",
"properties": {
"minimumValue": {
"description": "Minimum value to include.",
"type": "number"
},
"tolerance": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
},
"metadata": {
"description": "schemaless"
}
},
"required": [
"minimumValue",
"metadata"
],
"additionalProperties": false
},
"SourceInfo": {
"description": "Description of an input source.",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"fieldCount": {
"type": "integer",
"minimum": 0,
"maximum": 65535
}
},
"required": [
"name",
"fieldCount"
],
"additionalProperties": false
},
"Severity": {
"type": "string",
"enum": [
"low",
"medium",
"high"
]
},
"ValueEvent": {
"description": "Value-processing transitions.",
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"const": "accepted"
},
"index": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295
},
"value": {
"type": "number"
}
},
"required": [
"kind",
"index",
"value"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"const": "rejected"
},
"index": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295
}
},
"required": [
"kind",
"index"
],
"additionalProperties": false
}
]
}
}
}
"#
.replace("@HASH@", &hash)
.replace("@VERSION@", VERSION);
if actual != expected {
let diff = similar::TextDiff::from_lines(expected.as_str(), actual.as_str());
panic!(
"schema.json does not match its golden:\n{}",
diff.unified_diff()
.context_radius(3)
.header("expected", "actual")
);
}
}
#[test]
fn buf_schema_is_the_placeholder_shape() {
let v = ty_schema(&Ty::Buf {
dt: rspyts_core::ir::Dtype::F32,
});
assert_eq!(
v["properties"]["__rspyts_buf__"]["properties"]["dt"]["const"],
"f32"
);
assert_eq!(v["additionalProperties"], false);
}
#[test]
fn binary_newtype_fixture_preserves_named_inner_shapes() {
let m = binary_manifest();
let hash = manifest_hash(&m);
let (_, text) = emit(&m, &provenance(&hash)).remove(0);
let schema: Value = serde_json::from_str(&text).unwrap();
let defs = &schema["$defs"];
assert_eq!(defs["PacketId"]["type"], "integer");
assert_eq!(defs["PacketId"]["minimum"], 0);
assert_eq!(defs["PacketId"]["maximum"], 4_294_967_295_u64);
assert_eq!(
defs["BinaryPacket"]["properties"]["payload"]["properties"]["__rspyts_buf__"]["properties"]
["dt"]["const"],
"bytes"
);
assert_eq!(
defs["BinaryPacket"]["properties"]["channels"]["additionalProperties"]["properties"]["__rspyts_buf__"]
["properties"]["dt"]["const"],
"u8"
);
}
#[test]
fn exact_tuple_and_mixed_fixture_has_closed_wire_schemas() {
let m = exact_manifest();
let hash = manifest_hash(&m);
let (_, text) = emit(&m, &provenance(&hash)).remove(0);
let schema: Value = serde_json::from_str(&text).unwrap();
let defs = &schema["$defs"];
assert_eq!(defs["SequenceId"]["type"], "string");
assert_eq!(defs["SequenceId"]["format"], "uint64");
assert_eq!(
defs["ExactRecord"]["properties"]["pair"]["prefixItems"][0]["format"],
"int64"
);
assert_eq!(defs["ExactRecord"]["properties"]["pair"]["minItems"], 2);
assert_eq!(defs["ExactRecord"]["properties"]["pair"]["maxItems"], 2);
assert_eq!(defs["MixedResult"]["oneOf"][0]["required"], json!(["type"]));
assert_eq!(
defs["MixedResult"]["oneOf"][1]["properties"]["total"]["format"],
"uint64"
);
}
#[test]
fn integer_bounds_are_emitted() {
let v = ty_schema(&Ty::I16);
assert_eq!(v["minimum"], -32768);
assert_eq!(v["maximum"], 32767);
}
#[test]
fn nullable_property_allows_null_but_remains_required() {
let fields = vec![
FieldDecl {
name: "omittable".to_string(),
wire_name: "omittable".to_string(),
docs: String::new(),
ty: Ty::Option {
inner: Box::new(Ty::String),
},
required: false,
},
FieldDecl {
name: "required_nullable".to_string(),
wire_name: "requiredNullable".to_string(),
docs: String::new(),
ty: Ty::Option {
inner: Box::new(Ty::U64),
},
required: true,
},
FieldDecl {
name: "unavailable".to_string(),
wire_name: "unavailable".to_string(),
docs: String::new(),
ty: Ty::Null,
required: true,
},
];
let mut definition = Map::new();
object_schema(&mut definition, None, &fields);
let schema = Value::Object(definition);
assert_eq!(
schema["required"],
json!(["requiredNullable", "unavailable"])
);
assert_eq!(
schema["properties"]["requiredNullable"]["anyOf"][1],
json!({"type": "null"})
);
assert_eq!(schema["properties"]["unavailable"], json!({"type": "null"}));
}
#[test]
fn json_is_the_empty_schema_with_a_marker_description() {
let v = ty_schema(&Ty::Json);
assert_eq!(v, json!({"description": "schemaless"}));
}
#[test]
fn field_docs_win_over_the_json_marker_description() {
let mut prop = Map::new();
add_description(&mut prop, "What the caller sent.");
extend_with(&mut prop, ty_schema(&Ty::Json));
assert_eq!(prop["description"], "What the caller sent.");
}
#[test]
fn foreign_origin_types_are_always_inlined() {
let m = manifest();
let hash = manifest_hash(&m);
let (_, text) = emit(&m, &provenance(&hash)).remove(0);
assert!(text.contains("\"SourceInfo\""), "{text}");
}
}