use std::collections::{BTreeMap, BTreeSet};
use crate::stdlib::json_schema::MAX_SCHEMA_BYTES;
use crate::{FieldNarrowingTest, IndexSignature, ObjectField, Type, TypedAst};
pub(crate) fn schema_of(ta: &TypedAst, ty: &Type) -> Option<String> {
let mut walk = Walk {
ta,
active: BTreeSet::new(),
bytes_left: MAX_SCHEMA_BYTES,
};
let structural = walk.structural(ty)?;
let schema = crate::typechecker::json_schema::json_schema(&structural)
.ok()?
.to_string();
(schema.len() <= MAX_SCHEMA_BYTES).then_some(schema)
}
struct Walk<'a> {
ta: &'a TypedAst,
active: BTreeSet<Type>,
bytes_left: usize,
}
const OBJECT_BYTES: usize = 64;
impl Walk<'_> {
fn structural(&mut self, ty: &Type) -> Option<Type> {
self.charge(1)?;
Some(match ty {
Type::InterfaceRef { .. } => return self.interface(ty),
Type::Object { fields, index } => self.object(fields, index.as_ref())?,
Type::Array(element) => Type::Array(Box::new(self.structural(element)?)),
Type::Tuple(tuple) => Type::Tuple(
tuple
.try_map(|element| self.structural(element).ok_or(()))
.ok()?,
),
Type::Union(members) => Type::Union(
members
.iter()
.map(|member| self.structural(member))
.collect::<Option<Vec<_>>>()?,
),
Type::Alias { ty: body, .. }
| Type::Refined { ty: body, .. }
| Type::Readonly(body) => self.structural(body)?,
other => other.clone(),
})
}
fn interface(&mut self, ty: &Type) -> Option<Type> {
let key = ty.lowered().clone();
let Some(FieldNarrowingTest::Interface(test)) = self.ta.runtime_type_tests.get(&key) else {
return None;
};
if !test.methods.is_empty() || !test.shape_allowed || !self.active.insert(key.clone()) {
return None;
}
let object = self.object(&test.members, test.index.as_ref());
self.active.remove(&key);
object
}
fn charge(&mut self, bytes: usize) -> Option<()> {
self.bytes_left = self.bytes_left.checked_sub(bytes)?;
Some(())
}
fn object(
&mut self,
fields: &BTreeMap<String, ObjectField>,
index: Option<&IndexSignature>,
) -> Option<Type> {
self.charge(OBJECT_BYTES)?;
let mut structural_fields = BTreeMap::new();
for (name, field) in fields {
self.charge(name.len() + 3)?;
let ty = self.structural(&field.ty)?;
structural_fields.insert(
name.clone(),
ObjectField {
ty,
optional: field.optional,
readonly: field.readonly,
method: field.method,
},
);
}
let index = match index {
Some(index) => Some(IndexSignature {
string_keyof_includes_number: index.string_keyof_includes_number,
key: index.key.clone(),
value: Box::new(self.structural(&index.value)?),
readonly: index.readonly,
}),
None => None,
};
Some(Type::Object {
fields: structural_fields,
index,
})
}
}
#[cfg(test)]
mod tests {
use super::schema_of;
use crate::{Type, TypedAst, TypedExprKind};
fn type_arguments(source: &str) -> (TypedAst, Vec<Type>) {
let ta = super::super::tests::type_check(source);
let mut out = Vec::new();
for i in 0..ta.exprs_len() {
if let TypedExprKind::GenericCall { type_args, .. } =
&ta.try_expr(crate::ExprId(i as u32)).unwrap().kind
{
out.extend(type_args.iter().cloned());
}
}
(ta, out)
}
fn schema(ta: &TypedAst, ty: &Type) -> Option<serde_json::Value> {
schema_of(ta, ty).map(|text| serde_json::from_str(&text).expect("schema is JSON"))
}
#[test]
fn data_interfaces_are_inlined_with_their_members() {
let (ta, args) = type_arguments(
"interface Inner { tag: string; } \
interface Outer { inner: Inner; note?: string; counts: Record<string, number>; } \
function id<T>(x: unknown): unknown { return x; } \
function main(): void { id<Outer>(1); }",
);
let outer = schema(&ta, &args[0]).expect("Outer has a schema");
assert_eq!(
outer["properties"]["inner"]["properties"]["tag"]["type"],
"string"
);
assert_eq!(
outer["properties"]["counts"]["additionalProperties"]["type"],
"number"
);
let required: Vec<&str> = outer["required"]
.as_array()
.expect("required")
.iter()
.map(|v| v.as_str().expect("name"))
.collect();
assert!(
required.contains(&"inner") && !required.contains(&"note"),
"{outer}"
);
}
#[test]
fn structural_types_map_directly() {
let (ta, args) = type_arguments(
"function id<T>(x: unknown): unknown { return x; } \
function main(): void { id<string | null>(1); id<[number, \"a\" | \"b\"]>(1); }",
);
assert_eq!(
schema(&ta, &args[0]).expect("a union"),
serde_json::json!({ "anyOf": [{ "type": "string" }, { "type": "null" }] })
);
let tuple = schema(&ta, &args[1]).expect("a tuple");
assert_eq!(tuple["prefixItems"][0]["type"], "number", "{tuple}");
}
#[test]
fn a_schema_over_the_bound_is_none_and_stops_early() {
let mut source = String::from("interface A0 { v: number; } ");
for level in 1..=40 {
source.push_str(&format!(
"interface A{level} {{ a: A{prev}; b: A{prev}; }} ",
prev = level - 1
));
}
source.push_str(
"function id<T>(x: unknown): unknown { return x; } \
function main(): void { id<A40>(1); id<A3>(1); }",
);
let (ta, args) = type_arguments(&source);
let started = std::time::Instant::now();
assert_eq!(schema_of(&ta, &args[0]), None, "the DAG is over the bound");
assert!(started.elapsed() < std::time::Duration::from_secs(5));
assert!(
schema_of(&ta, &args[1]).is_some(),
"a small one still has a schema"
);
}
#[test]
fn types_without_a_json_form_have_no_schema() {
let (ta, args) = type_arguments(
"interface WithMethod { name: string; greet(): string; } \
interface Node { next: Node | null; } \
function id<T>(x: unknown): unknown { return x; } \
function main(): void { id<unknown>(1); id<Uint8Array>(1); id<bigint>(1); \
id<WithMethod>(1); id<Node>(1); }",
);
for ty in &args {
assert_eq!(schema_of(&ta, ty), None, "{ty} has no JSON Schema");
}
}
}