Skip to main content

harn_kernel/compiler/
schema_types.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use harn_parser::{substitute_type_expr, Node, SNode, ShapeField, TypeExpr};
4
5use crate::chunk::{Chunk, Op};
6use crate::value::VmValue;
7
8use super::{peel_node, CompileError, Compiler};
9
10/// Backstop bound on how deeply alias materialization may nest before giving
11/// up. Ordinary cycles are caught structurally by the `visiting` stack; this
12/// guards the pathological case of a generic whose argument keeps growing on
13/// each instantiation (e.g. `type Grow<T> = {next?: Grow<list<T>>}`), where no
14/// two instantiations are ever equal, so the structural check alone would never
15/// terminate.
16const MAX_SCHEMA_ALIAS_NEST: usize = 128;
17
18/// Ceiling on the number of fragment nodes a single alias schema may emit.
19///
20/// Fragments are expanded inline at every emission site (value-position uses,
21/// schema guards, the module type-schema initializer), and a function body can
22/// address at most 64 KiB of bytecode. A deep alias graph — a real-world
23/// OpenAPI document model is ~40 mutually nested aliases — can otherwise lower
24/// to hundreds of KiB. Over-budget fragments are pruned from the deepest
25/// levels to permissive empty schemas: validation gets coarser (exactly the
26/// pre-#5275 behavior, which degraded nested positions far more aggressively),
27/// never wrong, and the emitted size is bounded at every site.
28const SCHEMA_FRAGMENT_NODE_BUDGET: usize = 4096;
29
30/// A schema projection is available only when its lexical schema inputs exist.
31#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
32pub struct TypeSchemaInitializer<C> {
33    pub name: String,
34    pub required_bindings: Vec<String>,
35    pub chunk: C,
36}
37
38impl<C> TypeSchemaInitializer<C> {
39    pub fn map_chunk<T>(self, map: impl FnOnce(C) -> T) -> TypeSchemaInitializer<T> {
40        TypeSchemaInitializer {
41            name: self.name,
42            required_bindings: self.required_bindings,
43            chunk: map(self.chunk),
44        }
45    }
46}
47
48impl SchemaFragment {
49    fn required_bindings(&self) -> BTreeSet<String> {
50        match self {
51            Self::Value(_) => BTreeSet::new(),
52            Self::Ref(name) => BTreeSet::from([name.clone()]),
53            Self::Apply { name, args } => std::iter::once(name.clone())
54                .chain(args.iter().flat_map(Self::required_bindings))
55                .collect(),
56            Self::Dict(entries) => entries.values().flat_map(Self::required_bindings).collect(),
57            Self::List(items) => items.iter().flat_map(Self::required_bindings).collect(),
58        }
59    }
60
61    /// A position that constrains nothing.
62    ///
63    /// The validator dispatches on which keys a schema carries, so a schema
64    /// with none of them reaches no check and accepts any value. This is the
65    /// representation for a slot the compiler deliberately declines to
66    /// constrain, as opposed to one it failed to lower.
67    fn unconstrained() -> Self {
68        SchemaFragment::Dict(BTreeMap::new())
69    }
70
71    /// Emission weight: the number of bytecode-emitting nodes in the fragment.
72    /// A `Value` containing a dict or list still emits per-element construction
73    /// ops, so its internal nodes count toward the budget.
74    fn node_count(&self) -> usize {
75        match self {
76            SchemaFragment::Value(value) => vm_value_node_count(value),
77            SchemaFragment::Ref(_) => 1,
78            SchemaFragment::Apply { args, .. } => {
79                2 + args.iter().map(SchemaFragment::node_count).sum::<usize>()
80            }
81            SchemaFragment::Dict(entries) => {
82                1 + entries
83                    .values()
84                    .map(SchemaFragment::node_count)
85                    .sum::<usize>()
86            }
87            SchemaFragment::List(items) => {
88                1 + items.iter().map(SchemaFragment::node_count).sum::<usize>()
89            }
90        }
91    }
92
93    /// Nesting depth in SCHEMA levels (each schema-position descent costs 1;
94    /// structural carriers like a `properties` map or a `union` list are part
95    /// of their schema level, not a level of their own).
96    fn schema_depth(&self) -> usize {
97        match self {
98            SchemaFragment::Value(_) | SchemaFragment::Ref(_) => 1,
99            SchemaFragment::Apply { args, .. } => {
100                1 + args
101                    .iter()
102                    .map(SchemaFragment::schema_depth)
103                    .max()
104                    .unwrap_or(0)
105            }
106            SchemaFragment::Dict(entries) => {
107                1 + entries
108                    .iter()
109                    .map(|(key, value)| match (key.as_str(), value) {
110                        ("properties", SchemaFragment::Dict(fields)) => fields
111                            .values()
112                            .map(SchemaFragment::schema_depth)
113                            .max()
114                            .unwrap_or(0),
115                        ("union" | "all_of", SchemaFragment::List(members)) => members
116                            .iter()
117                            .map(SchemaFragment::schema_depth)
118                            .max()
119                            .unwrap_or(0),
120                        ("items" | "additional_properties", schema) => schema.schema_depth(),
121                        _ => 0,
122                    })
123                    .max()
124                    .unwrap_or(0)
125            }
126            SchemaFragment::List(_) => 1,
127        }
128    }
129
130    /// Replace every nested SCHEMA position deeper than `depth_limit` with the
131    /// permissive empty schema (`{}` accepts any value). Only positions the
132    /// validator reads as a schema are replaced — the value under
133    /// `properties.<field>`, each `union`/`all_of` member, `items`, and
134    /// `additional_properties` — so structural carriers keep their required
135    /// shape (a `union` value stays a list) and data keys (`type`, `required`)
136    /// are never touched.
137    fn pruned_schema(&self, depth_limit: usize) -> SchemaFragment {
138        if depth_limit == 0 {
139            return SchemaFragment::Value(VmValue::dict(std::collections::BTreeMap::<
140                String,
141                VmValue,
142            >::new()));
143        }
144        match self {
145            SchemaFragment::Value(_) | SchemaFragment::Ref(_) => self.clone(),
146            SchemaFragment::Apply { name, args } => SchemaFragment::Apply {
147                name: name.clone(),
148                args: args
149                    .iter()
150                    .map(|arg| arg.pruned_schema(depth_limit - 1))
151                    .collect(),
152            },
153            SchemaFragment::Dict(entries) => SchemaFragment::Dict(
154                entries
155                    .iter()
156                    .map(|(key, value)| {
157                        let pruned = match (key.as_str(), value) {
158                            ("properties", SchemaFragment::Dict(fields)) => SchemaFragment::Dict(
159                                fields
160                                    .iter()
161                                    .map(|(field, schema)| {
162                                        (field.clone(), schema.pruned_schema(depth_limit - 1))
163                                    })
164                                    .collect(),
165                            ),
166                            ("union" | "all_of", SchemaFragment::List(members)) => {
167                                SchemaFragment::List(
168                                    members
169                                        .iter()
170                                        .map(|member| member.pruned_schema(depth_limit - 1))
171                                        .collect(),
172                                )
173                            }
174                            ("items" | "additional_properties", schema) => {
175                                schema.pruned_schema(depth_limit - 1)
176                            }
177                            _ => value.clone(),
178                        };
179                        (key.clone(), pruned)
180                    })
181                    .collect(),
182            ),
183            SchemaFragment::List(_) => self.clone(),
184        }
185    }
186
187    /// Bound the fragment to [`SCHEMA_FRAGMENT_NODE_BUDGET`] nodes by pruning
188    /// the deepest schema levels to permissive empty schemas. Pruning only
189    /// ever weakens validation (a permissive subtree accepts what the full
190    /// subtree accepted, and more), so a bounded fragment stays a sound schema
191    /// for the alias — the tradeoff the pre-#5275 lowering also made, far more
192    /// coarsely.
193    fn bounded(self) -> SchemaFragment {
194        if self.node_count() <= SCHEMA_FRAGMENT_NODE_BUDGET {
195            return self;
196        }
197        let mut limit = self.schema_depth();
198        while limit > 1 {
199            limit -= 1;
200            let candidate = self.pruned_schema(limit);
201            if candidate.node_count() <= SCHEMA_FRAGMENT_NODE_BUDGET {
202                return candidate;
203            }
204        }
205        self.pruned_schema(1)
206    }
207}
208
209/// Count the construction nodes a folded constant expands to at emission.
210fn vm_value_node_count(value: &VmValue) -> usize {
211    match value {
212        VmValue::Dict(entries) => 1 + entries.values().map(vm_value_node_count).sum::<usize>(),
213        VmValue::List(items) => 1 + items.iter().map(vm_value_node_count).sum::<usize>(),
214        _ => 1,
215    }
216}
217
218#[derive(Clone)]
219pub(super) enum SchemaFragment {
220    Value(VmValue),
221    Ref(String),
222    /// An imported generic exports a schema factory in its declaring lexical
223    /// environment. Arguments are schemas resolved in the consumer's scope.
224    Apply {
225        name: String,
226        args: Vec<SchemaFragment>,
227    },
228    Dict(BTreeMap<String, SchemaFragment>),
229    List(Vec<SchemaFragment>),
230}
231
232impl Compiler {
233    /// Compile each exported type schema into an independently addressable
234    /// module initializer. Running these after imports makes referenced
235    /// imported schemas ordinary lexical inputs while keeping the cached
236    /// artifact immutable and relocatable. A module may export more schema
237    /// bytecode than one u16-addressed chunk can hold; declaration-sized
238    /// chunks remove that package-wide limit without weakening any schema.
239    pub fn compile_public_type_schema_initializers(
240        program: &[SNode],
241        source_file: Option<String>,
242    ) -> Result<Vec<TypeSchemaInitializer<Chunk>>, CompileError> {
243        Self::new().compile_type_schema_initializers(program, source_file, None)
244    }
245
246    /// Compile the selected exported type schemas. `None` preserves the
247    /// caller-independent full-module behavior; a closed-program linker passes
248    /// the exact runtime type names its consumers can observe.
249    pub fn compile_selected_public_type_schema_initializers(
250        program: &[SNode],
251        source_file: Option<String>,
252        selected_names: Option<&std::collections::BTreeSet<String>>,
253    ) -> Result<Vec<TypeSchemaInitializer<Chunk>>, CompileError> {
254        Self::new().compile_type_schema_initializers(program, source_file, selected_names)
255    }
256
257    /// Materialize schemas with this compiler's resolved import catalog and
258    /// options. The source-only entry points delegate to the same lowering.
259    pub fn compile_type_schema_initializers(
260        mut self,
261        program: &[SNode],
262        source_file: Option<String>,
263        selected_names: Option<&std::collections::BTreeSet<String>>,
264    ) -> Result<Vec<TypeSchemaInitializer<Chunk>>, CompileError> {
265        let compiler = &mut self;
266        compiler.collect_type_aliases(program);
267        let mut chunks = Vec::new();
268        for sn in program {
269            let Node::TypeDecl {
270                name, is_pub: true, ..
271            } = peel_node(sn)
272            else {
273                continue;
274            };
275            if selected_names.is_some_and(|selected| !selected.contains(name)) {
276                continue;
277            }
278            compiler.chunk = Chunk::new();
279            compiler.string_constants.clear();
280            compiler.chunk.source_file.clone_from(&source_file);
281            let required = if let Some(fragment) = compiler.schema_fragment_for_alias(name) {
282                let required = fragment.required_bindings().into_iter().collect();
283                compiler.emit_schema_fragment(&fragment);
284                Some(required)
285            } else {
286                compiler.emit_generic_schema_factory(name, source_file.clone())?
287            };
288            if let Some(required_bindings) = required {
289                compiler.emit_define_binding(name, false);
290                compiler.chunk.emit(Op::Nil, compiler.line);
291                compiler.chunk.emit(Op::Return, compiler.line);
292                super::ensure_chunk_addressable(
293                    &compiler.chunk,
294                    &format!("the public type-schema initializer for `{name}`"),
295                    compiler.line,
296                )?;
297                chunks.push(TypeSchemaInitializer {
298                    name: name.clone(),
299                    required_bindings,
300                    chunk: std::mem::take(&mut compiler.chunk),
301                });
302            }
303        }
304        Ok(chunks)
305    }
306
307    pub(super) fn schema_fragment_for_alias(&self, name: &str) -> Option<SchemaFragment> {
308        self.unbounded_schema_fragment_for_alias(name)
309            .map(SchemaFragment::bounded)
310    }
311
312    fn unbounded_schema_fragment_for_alias(&self, name: &str) -> Option<SchemaFragment> {
313        let alias = self.type_aliases.get(name)?;
314        if !alias.type_params.is_empty() {
315            return None;
316        }
317        match alias.body.as_ref() {
318            Some(body) => self.schema_fragment_for_type(body, &mut Vec::new()),
319            None => Some(SchemaFragment::Ref(name.to_string())),
320        }
321    }
322
323    /// Recurse into an alias body under a cycle/depth guard keyed on the alias
324    /// NAME (as a `TypeExpr::Named`). Name-keying deliberately treats a generic
325    /// alias reused at distinct arguments on one path — e.g. `RefOr<Header>`
326    /// nesting `RefOr<Example>` — as a cycle, so the inner reuse degrades at
327    /// the nearest tolerant position (an unconstrained dict) instead of
328    /// materializing. That is the pre-regression contract: fragments are
329    /// expanded INLINE, so materializing every distinct instantiation
330    /// duplicates subtrees combinatorially (a real-world OpenAPI alias graph
331    /// compiled to ~417 KiB of initializer bytecode, past the 64 KiB chunk a
332    /// jump operand can address). Keying on the applied form is only viable
333    /// once fragments can be shared/interned instead of inlined — tracked on
334    /// the materialization issue.
335    fn schema_fragment_under_guard(
336        &self,
337        guard_key: &TypeExpr,
338        body: &TypeExpr,
339        visiting: &mut Vec<TypeExpr>,
340    ) -> Option<SchemaFragment> {
341        // A cycle-broken position lowers to no runtime constraint, the same
342        // answer `expand_alias` already gives for the same cycle. Abandoning
343        // the whole fragment instead meant a recursive alias published no
344        // schema at all, and an importing module — which registers a selective
345        // import body-less and so lowers it to a runtime load by name — found
346        // nothing to load and failed at load time as `Undefined variable` on a
347        // name that is a type.
348        //
349        // The outer shape stays constrained; only the recursive slot opens.
350        if visiting.contains(guard_key) {
351            return Some(SchemaFragment::unconstrained());
352        }
353        // The nesting cap is a different condition. It says the alias graph is
354        // too deep to lower, not that it closed a cycle, so it stays a refusal
355        // and a genuinely unmaterializable alias still fails at compile time
356        // under a message naming it.
357        if visiting.len() >= MAX_SCHEMA_ALIAS_NEST {
358            return None;
359        }
360        visiting.push(guard_key.clone());
361        let fragment = self.schema_fragment_for_type(body, visiting);
362        visiting.pop();
363        fragment
364    }
365
366    fn schema_fragment_for_type(
367        &self,
368        ty: &TypeExpr,
369        visiting: &mut Vec<TypeExpr>,
370    ) -> Option<SchemaFragment> {
371        if let Some(value) = Self::type_expr_to_schema_value(ty) {
372            return Some(SchemaFragment::Value(value));
373        }
374        match ty {
375            TypeExpr::Named(name) => {
376                let alias = self.type_aliases.get(name)?;
377                let Some(body) = alias.body.as_ref() else {
378                    return Some(SchemaFragment::Ref(name.clone()));
379                };
380                if !alias.type_params.is_empty() {
381                    return None;
382                }
383                self.schema_fragment_under_guard(ty, body, visiting)
384            }
385            TypeExpr::Shape(fields) => self.schema_fragment_for_shape(fields, None, visiting),
386            TypeExpr::OpenShape { fields, rests } => {
387                let mut additional = None;
388                let mut row_fragments = Vec::new();
389                for rest in rests {
390                    if let TypeExpr::DictType(key, value) = rest {
391                        if matches!(key.as_ref(), TypeExpr::Named(name) if name == "string") {
392                            additional = Some(value.as_ref());
393                            continue;
394                        }
395                    }
396                    // A free row variable (`{..., ...rest}`) or a bare `dict`
397                    // tail has no concrete schema to merge — it only marks the
398                    // record open, so unknown fields of any type validate. Skip
399                    // it rather than failing to lower the whole alias. Bound
400                    // alias tails (`...SomeShape`) still merge as an `all_of`
401                    // branch below.
402                    if let TypeExpr::Named(name) = rest {
403                        let is_bound_alias = self
404                            .type_aliases
405                            .get(name)
406                            .is_some_and(|alias| alias.body.is_some());
407                        if !is_bound_alias {
408                            continue;
409                        }
410                    }
411                    row_fragments.push(self.schema_fragment_for_type(rest, visiting)?);
412                }
413                let base = self.schema_fragment_for_shape(fields, additional, visiting)?;
414                if row_fragments.is_empty() {
415                    Some(base)
416                } else {
417                    let mut branches = Vec::with_capacity(row_fragments.len() + 1);
418                    branches.push(base);
419                    branches.extend(row_fragments);
420                    Some(SchemaFragment::Dict(BTreeMap::from([(
421                        "all_of".to_string(),
422                        SchemaFragment::List(branches),
423                    )])))
424                }
425            }
426            TypeExpr::List(inner) => Some(SchemaFragment::Dict(BTreeMap::from([
427                (
428                    "type".to_string(),
429                    SchemaFragment::Value(VmValue::String(arcstr::ArcStr::from("list"))),
430                ),
431                (
432                    "items".to_string(),
433                    self.schema_fragment_for_type(inner, visiting)?,
434                ),
435            ]))),
436            TypeExpr::DictType(key, value) if matches!(key.as_ref(), TypeExpr::Named(name) if name == "string") =>
437            {
438                let mut entries = BTreeMap::from([(
439                    "type".to_string(),
440                    SchemaFragment::Value(VmValue::String(arcstr::ArcStr::from("dict"))),
441                )]);
442                // A dict whose value type cannot be lowered degrades to an
443                // unconstrained dict instead of collapsing the whole alias, so
444                // an alias that merely nests an un-materializable value still
445                // yields a runtime schema (and keeps its value binding).
446                if let Some(value_fragment) = self.schema_fragment_for_type(value, visiting) {
447                    entries.insert("additional_properties".to_string(), value_fragment);
448                }
449                Some(SchemaFragment::Dict(entries))
450            }
451            TypeExpr::Union(members) | TypeExpr::Intersection(members) => {
452                let key = if matches!(ty, TypeExpr::Union(_)) {
453                    "union"
454                } else {
455                    "all_of"
456                };
457                let branches = members
458                    .iter()
459                    .map(|member| self.schema_fragment_for_type(member, visiting))
460                    .collect::<Option<Vec<_>>>()?;
461                Some(SchemaFragment::Dict(BTreeMap::from([(
462                    key.to_string(),
463                    SchemaFragment::List(branches),
464                )])))
465            }
466            // `Schema<T>` stores a schema dictionary, not a value of T. Its
467            // static payload relation is owned by the typechecker. Preserve
468            // that runtime representation without shadowing a local alias.
469            TypeExpr::Applied { name, args }
470                if name == "Schema" && args.len() == 1 && !self.type_aliases.contains_key(name) =>
471            {
472                Self::type_expr_to_schema_value(&TypeExpr::Named("dict".to_string()))
473                    .map(SchemaFragment::Value)
474            }
475            TypeExpr::Applied { name, args } => {
476                let alias = self.type_aliases.get(name)?;
477                let Some(body) = alias.body.as_ref() else {
478                    if args.len() > u8::MAX as usize {
479                        return None;
480                    }
481                    return Some(SchemaFragment::Apply {
482                        name: name.clone(),
483                        args: args
484                            .iter()
485                            .map(|arg| self.schema_fragment_for_type(arg, visiting))
486                            .collect::<Option<Vec<_>>>()?,
487                    });
488                };
489                if alias.type_params.len() != args.len() {
490                    return None;
491                }
492                let bindings = alias
493                    .type_params
494                    .iter()
495                    .zip(args)
496                    .map(|(param, arg)| (param.name.clone(), arg.clone()))
497                    .collect();
498                let instantiated = substitute_type_expr(body, &bindings);
499                // Key on the alias name, not the applied form — see
500                // `schema_fragment_under_guard` for why distinct instantiations
501                // must not each materialize inline.
502                let guard_key = TypeExpr::Named(name.clone());
503                self.schema_fragment_under_guard(&guard_key, &instantiated, visiting)
504            }
505            TypeExpr::Owned(inner) => self.schema_fragment_for_type(inner, visiting),
506            _ => None,
507        }
508    }
509
510    fn schema_fragment_for_shape(
511        &self,
512        fields: &[ShapeField],
513        additional: Option<&TypeExpr>,
514        visiting: &mut Vec<TypeExpr>,
515    ) -> Option<SchemaFragment> {
516        let mut properties = BTreeMap::new();
517        let mut required = Vec::new();
518        for field in fields {
519            let mut field_schema = self.schema_fragment_for_type(&field.type_expr, visiting)?;
520            if field.optional {
521                field_schema = SchemaFragment::Dict(BTreeMap::from([(
522                    "union".to_string(),
523                    SchemaFragment::List(vec![
524                        field_schema,
525                        SchemaFragment::Value(VmValue::dict(BTreeMap::from([(
526                            "type".to_string(),
527                            VmValue::String(arcstr::ArcStr::from("nil")),
528                        )]))),
529                    ]),
530                )]));
531            } else {
532                required.push(SchemaFragment::Value(VmValue::String(
533                    arcstr::ArcStr::from(field.name.as_str()),
534                )));
535            }
536            properties.insert(field.name.clone(), field_schema);
537        }
538        let mut schema = BTreeMap::from([
539            (
540                "type".to_string(),
541                SchemaFragment::Value(VmValue::String(arcstr::ArcStr::from("dict"))),
542            ),
543            ("properties".to_string(), SchemaFragment::Dict(properties)),
544        ]);
545        if !required.is_empty() {
546            schema.insert("required".to_string(), SchemaFragment::List(required));
547        }
548        if let Some(additional) = additional {
549            schema.insert(
550                "additional_properties".to_string(),
551                self.schema_fragment_for_type(additional, visiting)?,
552            );
553        }
554        Some(SchemaFragment::Dict(schema))
555    }
556
557    pub(super) fn emit_schema_fragment(&mut self, fragment: &SchemaFragment) {
558        match fragment {
559            SchemaFragment::Value(value) => self.emit_vm_value_literal(value),
560            SchemaFragment::Ref(name) => self.emit_get_binding(name),
561            SchemaFragment::Apply { name, args } => {
562                self.emit_get_binding(name);
563                for arg in args {
564                    self.emit_schema_fragment(arg);
565                }
566                self.chunk.emit_u8(Op::Call, args.len() as u8, self.line);
567            }
568            SchemaFragment::Dict(entries) => {
569                for (key, value) in entries {
570                    let key_idx = self.string_constant(key);
571                    self.chunk.emit_u16(Op::Constant, key_idx, self.line);
572                    self.emit_schema_fragment(value);
573                }
574                self.chunk
575                    .emit_u16(Op::BuildDict, entries.len() as u16, self.line);
576            }
577            SchemaFragment::List(items) => {
578                for item in items {
579                    self.emit_schema_fragment(item);
580                }
581                self.chunk
582                    .emit_u16(Op::BuildList, items.len() as u16, self.line);
583            }
584        }
585    }
586
587    pub(super) fn emit_schema_for_alias(&mut self, name: &str) -> bool {
588        let Some(fragment) = self.schema_fragment_for_alias(name) else {
589            return false;
590        };
591        self.emit_schema_fragment(&fragment);
592        true
593    }
594
595    /// Export generic aliases as ordinary lexical closures, using the same
596    /// schema lowering as local concrete aliases. Private helper names stay
597    /// owned by this module; no AST or duplicate type table crosses the import.
598    pub(super) fn emit_generic_schema_factory(
599        &mut self,
600        name: &str,
601        source_file: Option<String>,
602    ) -> Result<Option<Vec<String>>, super::CompileError> {
603        let Some(alias) = self.type_aliases.get(name).cloned() else {
604            return Ok(None);
605        };
606        let Some(body) = alias.body else {
607            return Ok(None);
608        };
609        if alias.type_params.is_empty() || alias.type_params.len() > u8::MAX as usize {
610            return Ok(None);
611        }
612        let params = alias
613            .type_params
614            .iter()
615            .map(|param| harn_parser::TypedParam::untyped(&param.name))
616            .collect::<Vec<_>>();
617        let mut compiler = self.nested_body();
618        compiler.type_aliases = self.type_aliases.clone();
619        for param in &params {
620            compiler.type_aliases.insert(
621                param.name.clone(),
622                super::TypeAliasDefinition {
623                    type_params: Vec::new(),
624                    body: None,
625                },
626            );
627        }
628        compiler.declare_param_slots(&params);
629        let Some(fragment) =
630            compiler.schema_fragment_for_type(&body, &mut vec![TypeExpr::Named(name.to_string())])
631        else {
632            return Ok(None);
633        };
634        let fragment = fragment.bounded();
635        let required_bindings = fragment
636            .required_bindings()
637            .into_iter()
638            .filter(|name| !params.iter().any(|param| param.name == *name))
639            .collect();
640        compiler.emit_schema_fragment(&fragment);
641        compiler.chunk.emit(Op::Return, self.line);
642        compiler.chunk.source_file = source_file;
643        super::ensure_chunk_addressable(
644            &compiler.chunk,
645            &format!("generic schema `{name}`"),
646            self.line,
647        )?;
648        let function = crate::CompiledFunction {
649            name: format!("<schema:{name}>"),
650            type_params: Vec::new(),
651            nominal_type_names: Vec::new(),
652            params: compiler.compile_param_slots(&params),
653            default_start: None,
654            chunk: std::sync::Arc::new(compiler.chunk),
655            is_generator: false,
656            is_stream: false,
657            has_rest_param: false,
658            has_runtime_type_checks: false,
659        };
660        let index = self.chunk.functions.len();
661        self.chunk.functions.push(std::sync::Arc::new(function));
662        self.chunk.emit_u16(Op::Closure, index as u16, self.line);
663        Ok(Some(required_bindings))
664    }
665}