Skip to main content

harn_kernel/compiler/
schema_types.rs

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