1use serde_json::{Value, json};
21
22use crate::types::Type;
23
24pub type AliasExpander<'a> = &'a dyn Fn(&Type) -> Type;
30
31fn no_expand(ty: &Type) -> Type {
35 ty.peel().clone()
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct SchemaReject {
46 pub path: String,
47 pub reason: &'static str,
48 pub cascading: bool,
55}
56
57impl SchemaReject {
58 pub fn describe(&self) -> String {
61 if self.path.is_empty() {
62 self.reason.to_string()
63 } else {
64 format!("field `{}`: {}", self.path, self.reason)
65 }
66 }
67}
68
69pub fn json_schema(ty: &Type) -> Result<Value, SchemaReject> {
76 json_schema_with(ty, &no_expand)
77}
78
79pub fn json_schema_with(ty: &Type, expand: AliasExpander<'_>) -> Result<Value, SchemaReject> {
83 walk(ty, expand, &mut String::new(), &mut Vec::new())
84}
85
86fn walk(
92 ty: &Type,
93 expand: AliasExpander<'_>,
94 path: &mut String,
95 seen: &mut Vec<Type>,
96) -> Result<Value, SchemaReject> {
97 let peeled = ty.peel();
98 match peeled {
99 Type::Number => Ok(json!({ "type": "number" })),
100 Type::NumberLiteral(n) => Ok(json!({ "const": n.0 })),
101 Type::String => Ok(json!({ "type": "string" })),
102 Type::TemplateLiteral { .. } => {
103 if contains_number_domain(peeled) {
104 return Err(reject(
105 path,
106 "numeric template substitutions cannot be represented faithfully in this JSON Schema dialect",
107 ));
108 }
109 let pattern = crate::property_keys::pattern(peeled)
110 .ok_or_else(|| reject(path, "unsupported template substitution in schema"))?;
111 Ok(json!({ "type": "string", "pattern": format!("^(?:{pattern})$(?![\\s\\S])") }))
112 }
113 Type::StringLiteral(s) => Ok(json!({ "const": s })),
114 Type::Boolean => Ok(json!({ "type": "boolean" })),
115 Type::BooleanLiteral(b) => Ok(json!({ "const": b })),
116 Type::Null => Ok(json!({ "type": "null" })),
117 Type::Object { fields, index } => {
118 let mut properties = serde_json::Map::new();
121 let mut required = Vec::new();
122 for (name, field) in fields {
123 let restore = path.len();
124 push_field(path, name);
125 properties.insert(name.clone(), walk(&field.ty, expand, path, seen)?);
126 path.truncate(restore);
127 if !field.optional {
131 required.push(Value::String(name.clone()));
132 }
133 }
134 let mut schema = json!({
135 "type": "object",
136 "properties": Value::Object(properties),
137 "required": Value::Array(required),
138 "additionalProperties": false,
139 });
140 if let Some(index) = index {
141 let value = walk(&index.value, expand, path, seen)?;
142 if matches!(index.key.peel(), Type::String) {
143 schema["additionalProperties"] = value;
144 } else if contains_number_domain(&index.key) {
145 return Err(reject(
146 path,
147 "number index signatures require canonical JavaScript number keys, which this JSON Schema dialect cannot express; use Record<string, V> for structured output",
148 ));
149 } else {
150 let pattern = crate::property_keys::pattern(&index.key)
151 .ok_or_else(|| reject(path, "unsupported index key domain in schema"))?;
152 let mut patterns = serde_json::Map::new();
153 patterns.insert(format!("^(?:{pattern})$(?![\\s\\S])"), value);
154 schema["patternProperties"] = Value::Object(patterns);
155 schema["additionalProperties"] = Value::Bool(true);
156 }
157 }
158 Ok(schema)
159 }
160 Type::Array(elem) => {
161 let restore = path.len();
162 path.push_str("[]");
163 let items = walk(elem, expand, path, seen)?;
164 path.truncate(restore);
165 Ok(json!({ "type": "array", "items": items }))
166 }
167 Type::Tuple(elems) => {
168 let mut items = Vec::with_capacity(elems.len());
169 for (i, elem) in elems.iter().enumerate() {
170 let restore = path.len();
171 path.push_str(&format!("[{i}]"));
172 items.push(walk(elem, expand, path, seen)?);
173 path.truncate(restore);
174 }
175 let len = elems.len();
176 Ok(json!({
177 "type": "array",
178 "prefixItems": Value::Array(items),
179 "items": false,
180 "minItems": elems.required_len(),
181 "maxItems": len,
182 }))
183 }
184 Type::Union(members) => {
185 let mut any_of = Vec::with_capacity(members.len());
186 for member in members {
187 if matches!(member.peel(), Type::Undefined | Type::Void) {
188 continue;
189 }
190 any_of.push(walk(member, expand, path, seen)?);
191 }
192 match any_of.len() {
193 0 => Err(reject(
194 path,
195 "undefined has no JSON value; include a JSON-representable type",
196 )),
197 1 => any_of
198 .pop()
199 .ok_or_else(|| reject(path, "missing JSON union schema")),
200 _ => Ok(json!({ "anyOf": Value::Array(any_of) })),
201 }
202 }
203 Type::Undefined => Err(reject(
204 path,
205 "undefined has no JSON value; include a JSON-representable type",
206 )),
207 Type::AliasRef { .. } => {
211 if seen.iter().any(|s| s == peeled) {
212 return Err(reject(path, RECURSIVE));
213 }
214 let expanded = expand(peeled);
215 if matches!(expanded.peel(), Type::AliasRef { .. }) {
216 return Err(reject(path, RECURSIVE));
217 }
218 seen.push(peeled.clone());
219 let schema = walk(&expanded, expand, path, seen);
220 seen.pop();
221 schema
222 }
223 Type::Error => Err(SchemaReject {
225 cascading: true,
226 ..reject(path, "this type failed to resolve")
227 }),
228 Type::Alias { ty, .. } | Type::Refined { ty, .. } | Type::Readonly(ty) => {
229 walk(ty, expand, path, seen)
230 }
231 Type::Unknown => Err(reject(
233 path,
234 "`unknown` has no JSON Schema — an unconstrained schema would let the model return anything; name the shape you expect",
235 )),
236 Type::NonPrimitive => Err(reject(
237 path,
238 "`object` has no JSON Schema — it admits any object, array or function; name the shape you expect",
239 )),
240 Type::Function { .. } => Err(reject(path, "functions have no JSON form")),
241 Type::BigInt | Type::BigIntLiteral(_) => Err(reject(
242 path,
243 "`bigint` has no JSON number form — use `number` or a `string` encoding",
244 )),
245 Type::Uint8Array => Err(reject(
246 path,
247 "`Uint8Array` has no JSON form — use a `string` encoding such as base64",
248 )),
249 Type::ClassRef { .. } => Err(reject(
250 path,
251 "class types are nominal — use an object type or a data interface",
252 )),
253 Type::InterfaceRef { .. } => Err(reject(
254 path,
255 "this interface has no data shape — method-bearing interfaces are nominal, and recursive ones cannot be inlined",
256 )),
257 Type::NumberEnum { .. } | Type::StringEnum { .. } => Err(reject(
258 path,
259 "enums need their variants resolved — use a union of literals",
260 )),
261 Type::TypeVar(_)
262 | Type::GenericParam { .. }
263 | Type::KeyOf(_)
264 | Type::IndexedAccess { .. } => Err(reject(
265 path,
266 "generic type parameters are erased — the schema is emitted at compile time",
267 )),
268 Type::Never => Err(reject(path, "`never` has no values")),
269 Type::Void => Err(reject(path, "`void` is not a value type")),
270 }
271}
272
273const RECURSIVE: &str = "recursive types have no finite JSON Schema — \
275 providers drop `$ref`, so every definition must be inlined";
276
277fn reject(path: &str, reason: &'static str) -> SchemaReject {
278 SchemaReject {
279 path: path.to_string(),
280 reason,
281 cascading: false,
282 }
283}
284
285fn push_field(path: &mut String, name: &str) {
288 if !path.is_empty() {
289 path.push('.');
290 }
291 path.push_str(name);
292}
293
294fn contains_number_domain(ty: &Type) -> bool {
295 match ty.peel() {
296 Type::Number | Type::BigInt => true,
297 Type::TemplateLiteral {
298 substitutions: members,
299 ..
300 }
301 | Type::Union(members) => members.iter().any(contains_number_domain),
302 _ => false,
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use std::collections::BTreeMap;
309
310 use super::*;
311 use crate::mangle;
312 use crate::types::{LiteralF64, ObjectField, Package};
313
314 fn obj(fields: &[(&str, Type, bool)]) -> Type {
315 Type::Object {
316 index: None,
317 fields: fields
318 .iter()
319 .map(|(name, ty, optional)| {
320 (
321 (*name).to_string(),
322 if *optional {
323 ObjectField::optional(ty.clone())
324 } else {
325 ObjectField::required(ty.clone())
326 },
327 )
328 })
329 .collect::<BTreeMap<_, _>>(),
330 }
331 }
332
333 fn s_lit(s: &str) -> Type {
334 Type::StringLiteral(s.to_string())
335 }
336
337 fn alias_ref(name: &str) -> Type {
338 Type::AliasRef {
339 mangled: mangle::prelude(name),
340 package: Package::prelude(),
341 name: name.to_string(),
342 args: Vec::new(),
343 }
344 }
345
346 #[test]
347 fn template_schema_preserves_adjacent_holes_and_surrogate_literals() {
348 let mut prefix = "id_".to_string();
349 crate::literal_units::push_lone_surrogate(&mut prefix, 0xd800);
350 let template = Type::TemplateLiteral {
351 parts: vec![prefix, String::new(), "end".into()],
352 substitutions: vec![Type::String, Type::String],
353 };
354 let schema = json_schema(&template).unwrap();
355 assert_eq!(
356 schema["pattern"],
357 r"^(?:\u0069\u0064\u005f\ud800[\s\S][\s\S]*\u0065\u006e\u0064)$(?![\s\S])"
358 );
359 let record = Type::Object {
360 fields: Default::default(),
361 index: Some(crate::IndexSignature {
362 string_keyof_includes_number: false,
363 key: Box::new(template),
364 value: Box::new(Type::Boolean),
365 readonly: false,
366 }),
367 };
368 let schema = json_schema(&record).unwrap();
369 assert_eq!(schema["patternProperties"].as_object().unwrap().len(), 1);
370 assert_eq!(schema["additionalProperties"], true);
371 }
372
373 #[test]
374 fn numeric_domains_refuse_inexact_schema_patterns() {
375 let template = Type::TemplateLiteral {
376 parts: vec!["id_".into(), String::new()],
377 substitutions: vec![Type::Number],
378 };
379 assert!(
380 json_schema(&template)
381 .unwrap_err()
382 .reason
383 .contains("faithfully")
384 );
385 let record = Type::Object {
386 fields: Default::default(),
387 index: Some(crate::IndexSignature {
388 string_keyof_includes_number: false,
389 key: Box::new(Type::Number),
390 value: Box::new(Type::Boolean),
391 readonly: false,
392 }),
393 };
394 assert!(
395 json_schema(&record)
396 .unwrap_err()
397 .reason
398 .contains("canonical")
399 );
400 }
401
402 #[test]
403 fn flat_object() {
404 let schema = json_schema(&obj(&[
405 ("age", Type::Number, false),
406 ("name", Type::String, false),
407 ]))
408 .expect("emits");
409 assert_eq!(
410 schema,
411 json!({
412 "type": "object",
413 "properties": {
414 "age": { "type": "number" },
415 "name": { "type": "string" },
416 },
417 "required": ["age", "name"],
418 "additionalProperties": false,
419 })
420 );
421 }
422
423 #[test]
424 fn nested_object() {
425 let inner = obj(&[("city", Type::String, false)]);
426 let schema = json_schema(&obj(&[("address", inner, false)])).expect("emits");
427 assert_eq!(
428 schema["properties"]["address"]["properties"]["city"],
429 json!({ "type": "string" })
430 );
431 assert_eq!(schema["properties"]["address"]["required"], json!(["city"]));
432 }
433
434 #[test]
435 fn optional_field_is_omitted_from_required_and_is_not_nullable() {
436 let schema = json_schema(&obj(&[
437 ("id", Type::String, false),
438 ("note", Type::String, true),
439 ]))
440 .expect("emits");
441 assert_eq!(schema["required"], json!(["id"]));
442 assert_eq!(schema["properties"]["note"], json!({ "type": "string" }));
445 }
446
447 #[test]
448 fn undefined_projection_preserves_property_requiredness() {
449 let maybe_string = Type::union(vec![Type::String, Type::Undefined]);
450 let schema = json_schema(&obj(&[
451 ("required", maybe_string.clone(), false),
452 ("optional", maybe_string, true),
453 (
454 "nullable",
455 Type::union(vec![Type::String, Type::Null, Type::Undefined]),
456 false,
457 ),
458 ]))
459 .expect("JSON-representable branches remain");
460 assert_eq!(schema["required"], json!(["nullable", "required"]));
461 assert_eq!(schema["properties"]["required"], json!({"type":"string"}));
462 assert_eq!(schema["properties"]["optional"], json!({"type":"string"}));
463 assert_eq!(
464 schema["properties"]["nullable"],
465 json!({"anyOf":[{"type":"string"},{"type":"null"}]})
466 );
467 }
468
469 #[test]
470 fn undefined_only_schema_reports_the_property_path() {
471 let error = json_schema(&obj(&[("missing", Type::Undefined, true)]))
472 .expect_err("undefined is not JSON");
473 assert_eq!(error.path, "missing");
474 assert!(error.reason.contains("undefined has no JSON value"));
475 assert!(json_schema(&Type::Undefined).is_err());
476 }
477
478 #[test]
479 fn optional_tuple_schema_keeps_lengths_and_excludes_null() {
480 let tuple = Type::Tuple(crate::types::TupleType {
481 elements: vec![
482 Type::Number,
483 Type::union(vec![Type::String, Type::Undefined]),
484 ],
485 optional: 1,
486 });
487 let schema = json_schema(&tuple).expect("optional tuple schema");
488 assert_eq!(schema["minItems"], json!(1));
489 assert_eq!(schema["maxItems"], json!(2));
490 assert_eq!(schema["prefixItems"][1], json!({"type":"string"}));
491 assert_eq!(schema["items"], json!(false));
492 }
493
494 #[test]
495 fn array_and_tuple() {
496 let arr = json_schema(&Type::Array(Box::new(Type::Number))).expect("emits");
497 assert_eq!(arr["type"], json!("array"));
498 assert_eq!(arr["items"], json!({ "type": "number" }));
499
500 let tup = json_schema(&Type::tuple(vec![Type::String, Type::Boolean])).expect("emits");
501 assert_eq!(tup["type"], json!("array"));
502 assert_eq!(
503 tup["prefixItems"],
504 json!([{ "type": "string" }, { "type": "boolean" }])
505 );
506 assert_eq!(tup["minItems"], json!(2));
507 assert_eq!(tup["maxItems"], json!(2));
508 }
509
510 #[test]
511 fn union_becomes_any_of() {
512 let ty = Type::union(vec![Type::String, Type::Null]);
513 let schema = json_schema(&ty).expect("emits");
514 let any_of = schema["anyOf"].as_array().expect("anyOf array");
515 assert_eq!(any_of.len(), 2);
516 assert!(any_of.contains(&json!({ "type": "null" })));
517 assert!(any_of.contains(&json!({ "type": "string" })));
518 }
519
520 #[test]
521 fn literals_become_const() {
522 assert_eq!(
523 json_schema(&s_lit("red")).expect("emits"),
524 json!({ "const": "red" })
525 );
526 assert_eq!(
527 json_schema(&Type::NumberLiteral(LiteralF64(3.0))).expect("emits"),
528 json!({ "const": 3.0 })
529 );
530 }
531
532 #[test]
533 fn reduced_interface_matches_the_literal_object_type() {
534 let shape = obj(&[("id", Type::String, false), ("n", Type::Number, true)]);
538 let literal = obj(&[("id", Type::String, false), ("n", Type::Number, true)]);
539 assert_eq!(
540 json_schema(&shape).expect("emits"),
541 json_schema(&literal).expect("emits")
542 );
543 }
544
545 #[test]
546 fn recursive_alias_is_rejected_rather_than_emitting_a_ref() {
547 let node = alias_ref("Node");
550 let body = obj(&[("next", Type::union(vec![node.clone(), Type::Null]), false)]);
551 let expand = |ty: &Type| {
552 if matches!(ty.peel(), Type::AliasRef { name, .. } if name == "Node") {
553 body.clone()
554 } else {
555 ty.peel().clone()
556 }
557 };
558 let err = json_schema_with(&node, &expand).expect_err("rejects");
559 assert_eq!(err.path, "next");
560 assert!(err.reason.contains("recursive"), "reason: {}", err.reason);
561 }
562
563 #[test]
564 fn unexpandable_alias_ref_is_rejected() {
565 let err = json_schema(&alias_ref("Missing")).expect_err("rejects");
566 assert!(err.reason.contains("recursive"), "reason: {}", err.reason);
567 }
568
569 #[test]
570 fn function_field_is_rejected_and_the_message_names_the_field() {
571 let handler = Type::Function {
574 params: vec![Type::Number],
575 ret: Box::new(Type::Number),
576 predicate: None,
577 has_rest: false,
578 optional: 0,
579 };
580 let err = json_schema(&obj(&[
581 ("handler", handler, false),
582 ("id", Type::String, false),
583 ]))
584 .expect_err("rejects");
585 assert_eq!(err.path, "handler");
586 assert!(
587 err.describe().contains("field `handler`"),
588 "{}",
589 err.describe()
590 );
591 assert!(err.reason.contains("function"), "reason: {}", err.reason);
592 }
593
594 #[test]
595 fn nested_field_path_is_built_through_objects_and_arrays() {
596 let handler = Type::Function {
597 params: Vec::new(),
598 ret: Box::new(Type::Void),
599 predicate: None,
600 has_rest: false,
601 optional: 0,
602 };
603 let item = obj(&[("run", handler, false)]);
604 let ty = obj(&[("items", Type::Array(Box::new(item)), false)]);
605 let err = json_schema(&ty).expect_err("rejects");
606 assert_eq!(err.path, "items[].run");
607 }
608
609 #[test]
610 fn tuple_element_path_names_its_index() {
611 let ty = obj(&[("pair", Type::tuple(vec![Type::String, Type::BigInt]), false)]);
612 let err = json_schema(&ty).expect_err("rejects");
613 assert_eq!(err.path, "pair[1]");
614 }
615
616 #[test]
617 fn bigint_and_uint8array_have_distinct_reasons() {
618 let big = json_schema(&obj(&[("n", Type::BigInt, false)])).expect_err("rejects");
619 let bytes = json_schema(&obj(&[("b", Type::Uint8Array, false)])).expect_err("rejects");
620 assert_eq!(big.path, "n");
621 assert_eq!(bytes.path, "b");
622 assert!(big.reason.contains("bigint"), "reason: {}", big.reason);
623 assert!(
624 bytes.reason.contains("Uint8Array"),
625 "reason: {}",
626 bytes.reason
627 );
628 assert_ne!(big.reason, bytes.reason);
629 }
630
631 #[test]
632 fn nested_unknown_is_rejected_though_the_cast_gate_accepts_it() {
633 let err = json_schema(&obj(&[("payload", Type::Unknown, false)])).expect_err("rejects");
637 assert_eq!(err.path, "payload");
638 assert!(err.reason.contains("unknown"), "reason: {}", err.reason);
639 }
640
641 #[test]
642 fn an_already_failed_type_rejects_without_asking_for_a_second_diagnostic() {
643 let err = json_schema(&obj(&[("bad", Type::Error, false)])).expect_err("rejects");
647 assert_eq!(err.path, "bad");
648 assert!(
649 err.cascading,
650 "an already-failed type is a cascading reject"
651 );
652 let real = json_schema(&obj(&[("n", Type::BigInt, false)])).expect_err("rejects");
654 assert!(!real.cascading, "a genuine rejection is reportable");
655 }
656
657 #[test]
658 fn emitted_schema_uses_only_the_safe_subset_of_keys() {
659 const SAFE: &[&str] = &[
660 "type",
661 "properties",
662 "required",
663 "additionalProperties",
664 "items",
665 "prefixItems",
666 "minItems",
667 "maxItems",
668 "anyOf",
669 "const",
670 ];
671
672 fn assert_safe(value: &Value, safe: &[&str]) {
673 match value {
674 Value::Object(map) => {
675 for (key, child) in map {
676 assert!(safe.contains(&key.as_str()), "unsafe schema key `{key}`");
677 if key == "properties" {
679 for nested in map[key].as_object().expect("properties object").values()
680 {
681 assert_safe(nested, safe);
682 }
683 } else {
684 assert_safe(child, safe);
685 }
686 }
687 }
688 Value::Array(items) => items.iter().for_each(|i| assert_safe(i, safe)),
689 _ => {}
690 }
691 }
692
693 let ty = obj(&[
694 ("flag", Type::Boolean, true),
695 ("kind", Type::union(vec![s_lit("a"), s_lit("b")]), false),
696 (
697 "rows",
698 Type::Array(Box::new(obj(&[("v", Type::Number, false)]))),
699 false,
700 ),
701 ("span", Type::tuple(vec![Type::Number, Type::Number]), false),
702 ]);
703 assert_safe(&json_schema(&ty).expect("emits"), SAFE);
704 }
705
706 #[test]
707 fn emission_is_deterministic() {
708 let ty = obj(&[
709 ("zeta", Type::String, false),
710 ("alpha", Type::Number, true),
711 ("mid", Type::Array(Box::new(Type::Boolean)), false),
712 ]);
713 let first = serde_json::to_string(&json_schema(&ty).expect("emits")).expect("serializes");
714 for _ in 0..8 {
715 let again =
716 serde_json::to_string(&json_schema(&ty).expect("emits")).expect("serializes");
717 assert_eq!(first, again, "schema emission must be byte-identical");
718 }
719 }
720}