Skip to main content

submilli_engine/typechecker/
json_strategy.rs

1//! JSON.parse union-target strategy resolver.
2use std::collections::{BTreeMap, BTreeSet};
3
4use crate::compiler_error::CompilerFailure;
5use crate::typechecker::infer::narrowing::LiteralValue;
6use crate::types::{ObjectField, Type};
7
8/// Booleans excluded — no `BooleanLiteral` in the type system.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum PrimBase {
11    String,
12    Number,
13}
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct JsonUnionStrategy {
17    pub string: PerTagStrategy,
18    pub number: PerTagStrategy,
19    pub boolean: PerTagStrategy,
20    pub null: PerTagStrategy,
21    pub object: PerTagStrategy,
22    pub array: PerTagStrategy,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum PerTagStrategy {
27    Empty,
28    Single(Type),
29    LiteralSet {
30        base: PrimBase,
31        values: Vec<LiteralValue>,
32    },
33    ObjectCascade(ObjectDispatch),
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum ObjectDispatch {
38    ByLiteralTagValue {
39        field: String,
40        arms: Vec<(LiteralValue, Type)>,
41    },
42    ByRequiredFieldName {
43        variants: Vec<Type>,
44    },
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub enum JsonNarrowReason {
49    Internal(CompilerFailure),
50    UnsupportedTarget {
51        ty: Type,
52    },
53    ArrayVsArray {
54        variants: Vec<Type>,
55    },
56    PrimitiveOverlap {
57        base: PrimBase,
58        variants: Vec<Type>,
59    },
60    /// Subset variant ("winner") matches first, making superset ("loser") unreachable.
61    ObjectAmbiguous {
62        winner: Type,
63        loser: Type,
64    },
65    OptionalDiscriminator {
66        field: String,
67        variants: Vec<Type>,
68    },
69}
70
71/// One-level expander for recursion back-edges. Given a `Type::AliasRef`, returns
72/// the alias body (peeled, args substituted); for any other type returns `ty.peel()`
73/// cloned. Lets the union resolver classify a recursive member by its underlying
74/// JSON kind without the resolver itself owning the type namespace.
75pub type AliasExpander<'a> = &'a dyn Fn(&Type) -> Type;
76
77/// Identity expander — leaves `AliasRef` unexpanded. Used by call sites that never
78/// pass recursion back-edges (unit tests, non-recursive unions).
79fn no_expand(ty: &Type) -> Type {
80    ty.peel().clone()
81}
82
83/// Preconditions: every member is a valid `JSON.parse` target (no `Unknown`,
84/// `Function`, `InterfaceRef`, etc.) and the union is canonical (sorted,
85/// deduplicated, flattened, no `Never`/`Error`).
86pub fn resolve_json_union_strategy(
87    members: &[Type],
88) -> Result<JsonUnionStrategy, Vec<JsonNarrowReason>> {
89    resolve_json_union_strategy_with(members, &no_expand)
90}
91
92/// Like [`resolve_json_union_strategy`] but classifies recursion back-edges
93/// (`Type::AliasRef`) by expanding them one level via `expand`.
94pub fn resolve_json_union_strategy_with(
95    members: &[Type],
96    expand: AliasExpander<'_>,
97) -> Result<JsonUnionStrategy, Vec<JsonNarrowReason>> {
98    let mut buckets = Buckets::default();
99    for m in members {
100        buckets.push(m, expand, &mut Vec::new())?;
101    }
102
103    let mut reasons = Vec::new();
104    let string = resolve_primitive_bucket(PrimBase::String, &buckets.string, &mut reasons);
105    let number = resolve_primitive_bucket(PrimBase::Number, &buckets.number, &mut reasons);
106    let boolean = resolve_singleton_bucket(&buckets.boolean)?;
107    let null = resolve_singleton_bucket(&buckets.null)?;
108    let object = resolve_object_bucket(&buckets.object, &mut reasons, expand)?;
109    let array = resolve_array_bucket(&buckets.array, &mut reasons);
110
111    if reasons.is_empty() {
112        Ok(JsonUnionStrategy {
113            string,
114            number,
115            boolean,
116            null,
117            object,
118            array,
119        })
120    } else {
121        Err(reasons)
122    }
123}
124
125#[derive(Default)]
126struct Buckets {
127    string: Vec<Type>,
128    number: Vec<Type>,
129    boolean: Vec<Type>,
130    null: Vec<Type>,
131    object: Vec<Type>,
132    array: Vec<Type>,
133}
134
135impl Buckets {
136    /// `ty` is the member as written (possibly a recursion back-edge); `kind`
137    /// is its expanded JSON shape used only for bucket selection. A back-edge
138    /// whose body is an object/array is bucketed there but kept as the original
139    /// `AliasRef` so emission routes through its recursive validator. A back-edge
140    /// whose body is itself a union is flattened one level — its members carry
141    /// concrete kinds (the recursion lives under their constructors).
142    fn push(
143        &mut self,
144        ty: &Type,
145        expand: AliasExpander<'_>,
146        active: &mut Vec<Type>,
147    ) -> Result<(), Vec<JsonNarrowReason>> {
148        if active.contains(ty) {
149            return Err(vec![JsonNarrowReason::UnsupportedTarget { ty: ty.clone() }]);
150        }
151        active.push(ty.clone());
152        let kind = expand(ty);
153        match kind.peel() {
154            Type::String | Type::StringLiteral(_) | Type::StringEnum { .. } => {
155                self.string.push(ty.clone());
156            }
157            Type::Number | Type::NumberLiteral(_) | Type::NumberEnum { .. } => {
158                self.number.push(ty.clone());
159            }
160            Type::Boolean | Type::BooleanLiteral(_) => self.boolean.push(ty.clone()),
161            Type::Null => self.null.push(ty.clone()),
162            Type::Object { .. } => self.object.push(ty.clone()),
163            Type::Array(_) => self.array.push(ty.clone()),
164            // A back-edge expanding to a union: flatten its members (concrete-kinded).
165            Type::Union(members) if !matches!(ty.peel(), Type::Union(_)) => {
166                for m in members {
167                    self.push(m, expand, active)?;
168                }
169            }
170            _ => return Err(vec![JsonNarrowReason::UnsupportedTarget { ty: ty.clone() }]),
171        }
172        active.pop();
173        Ok(())
174    }
175}
176
177fn resolve_singleton_bucket(variants: &[Type]) -> Result<PerTagStrategy, Vec<JsonNarrowReason>> {
178    Ok(match variants {
179        [] => PerTagStrategy::Empty,
180        [t] => PerTagStrategy::Single(t.clone()),
181        _ => {
182            return Err(vec![JsonNarrowReason::UnsupportedTarget {
183                ty: Type::union(variants.to_vec()),
184            }]);
185        }
186    })
187}
188
189fn resolve_primitive_bucket(
190    base: PrimBase,
191    variants: &[Type],
192    reasons: &mut Vec<JsonNarrowReason>,
193) -> PerTagStrategy {
194    match variants {
195        [] => PerTagStrategy::Empty,
196        [t] => PerTagStrategy::Single(t.clone()),
197        _ => {
198            let values: Option<Vec<LiteralValue>> = variants
199                .iter()
200                .map(|t| literal_value_of(t.peel()))
201                .collect();
202            if let Some(values) = values {
203                PerTagStrategy::LiteralSet { base, values }
204            } else {
205                reasons.push(JsonNarrowReason::PrimitiveOverlap {
206                    base,
207                    variants: variants.to_vec(),
208                });
209                PerTagStrategy::Empty
210            }
211        }
212    }
213}
214
215fn resolve_array_bucket(variants: &[Type], reasons: &mut Vec<JsonNarrowReason>) -> PerTagStrategy {
216    match variants {
217        [] => PerTagStrategy::Empty,
218        [t] => PerTagStrategy::Single(t.clone()),
219        _ => {
220            reasons.push(JsonNarrowReason::ArrayVsArray {
221                variants: variants.to_vec(),
222            });
223            PerTagStrategy::Empty
224        }
225    }
226}
227
228fn resolve_object_bucket(
229    variants: &[Type],
230    reasons: &mut Vec<JsonNarrowReason>,
231    expand: AliasExpander<'_>,
232) -> Result<PerTagStrategy, Vec<JsonNarrowReason>> {
233    match variants {
234        [] => return Ok(PerTagStrategy::Empty),
235        [t] => return Ok(PerTagStrategy::Single(t.clone())),
236        _ => {}
237    }
238
239    if let Some(dispatch) = try_literal_tag_value(variants, expand)? {
240        return Ok(PerTagStrategy::ObjectCascade(dispatch));
241    }
242
243    Ok(match try_required_field_name(variants, expand) {
244        Ok(dispatch) => PerTagStrategy::ObjectCascade(dispatch),
245        Err(object_reasons) => {
246            reasons.extend(object_reasons);
247            PerTagStrategy::Empty
248        }
249    })
250}
251
252fn literal_value_of(ty: &Type) -> Option<LiteralValue> {
253    match ty.peel() {
254        Type::StringLiteral(s) => Some(LiteralValue::String(s.clone())),
255        Type::NumberLiteral(n) => Some(LiteralValue::Number(*n)),
256        Type::BooleanLiteral(b) => Some(LiteralValue::Boolean(*b)),
257        _ => None,
258    }
259}
260
261fn try_literal_tag_value(
262    variants: &[Type],
263    expand: AliasExpander<'_>,
264) -> Result<Option<ObjectDispatch>, Vec<JsonNarrowReason>> {
265    // Expand back-edges so a recursive object variant contributes its fields to
266    // the discriminator search; emission keeps the original `variants` types.
267    let expanded: Vec<Type> = variants.iter().map(expand).collect();
268    let field_maps: Vec<&BTreeMap<String, ObjectField>> = expanded
269        .iter()
270        .map(|t| match t.peel() {
271            Type::Object { fields, .. } => Ok(fields),
272            _ => Err(invalid_object_bucket()),
273        })
274        .collect::<Result<_, _>>()?;
275
276    // Required fields only — optional fields may be absent at runtime.
277    let first = field_maps.first().ok_or_else(invalid_object_bucket)?;
278    let candidates: Vec<&String> = first
279        .iter()
280        .filter(|(_, f)| !f.optional && literal_value_of(&f.ty).is_some())
281        .map(|(name, _)| name)
282        .collect();
283
284    'fields: for name in candidates {
285        let mut arms: Vec<(LiteralValue, Type)> = Vec::with_capacity(variants.len());
286        let mut seen_values: BTreeSet<LiteralValue> = BTreeSet::new();
287        for (variant, fields) in variants.iter().zip(&field_maps) {
288            let field = match fields.get(name) {
289                Some(f) if !f.optional => f,
290                _ => continue 'fields,
291            };
292            let Some(lit) = literal_value_of(&field.ty) else {
293                continue 'fields;
294            };
295            if !seen_values.insert(lit.clone()) {
296                continue 'fields;
297            }
298            arms.push((lit, variant.clone()));
299        }
300        return Ok(Some(ObjectDispatch::ByLiteralTagValue {
301            field: name.clone(),
302            arms,
303        }));
304    }
305    Ok(None)
306}
307
308fn try_required_field_name(
309    variants: &[Type],
310    expand: AliasExpander<'_>,
311) -> Result<ObjectDispatch, Vec<JsonNarrowReason>> {
312    let expanded: Vec<Type> = variants.iter().map(expand).collect();
313    let required_sets: Vec<BTreeSet<&str>> = expanded
314        .iter()
315        .map(required_field_names)
316        .collect::<Result<_, _>>()?;
317    let all_sets: Vec<BTreeSet<&str>> = expanded
318        .iter()
319        .map(all_field_names)
320        .collect::<Result<_, _>>()?;
321
322    let mut reasons = Vec::new();
323    for i in 0..variants.len() {
324        for j in (i + 1)..variants.len() {
325            let ri = &required_sets[i];
326            let rj = &required_sets[j];
327            let i_sub_j = ri.is_subset(rj);
328            let j_sub_i = rj.is_subset(ri);
329            if !(i_sub_j || j_sub_i) {
330                continue;
331            }
332
333            // If all-fields would be disjoint, the overlap is via an optional field —
334            // surface OptionalDiscriminator so the error suggests marking it required.
335            let ai = &all_sets[i];
336            let aj = &all_sets[j];
337            let all_disjoint = !ai.is_subset(aj) && !aj.is_subset(ai);
338            if all_disjoint {
339                let optional_diff: Vec<&str> = ai
340                    .symmetric_difference(aj)
341                    .filter(|n| !ri.contains(*n) && !rj.contains(*n))
342                    .copied()
343                    .collect();
344                let field = optional_diff
345                    .first()
346                    .map(std::string::ToString::to_string)
347                    .unwrap_or_default();
348                reasons.push(JsonNarrowReason::OptionalDiscriminator {
349                    field,
350                    variants: vec![variants[i].clone(), variants[j].clone()],
351                });
352            } else {
353                let (winner, loser) = if i_sub_j {
354                    (variants[i].clone(), variants[j].clone())
355                } else {
356                    (variants[j].clone(), variants[i].clone())
357                };
358                reasons.push(JsonNarrowReason::ObjectAmbiguous { winner, loser });
359            }
360        }
361    }
362
363    if reasons.is_empty() {
364        Ok(ObjectDispatch::ByRequiredFieldName {
365            variants: variants.to_vec(),
366        })
367    } else {
368        Err(reasons)
369    }
370}
371
372fn required_field_names(ty: &Type) -> Result<BTreeSet<&str>, Vec<JsonNarrowReason>> {
373    Ok(match ty.peel() {
374        Type::Object { fields, .. } => fields
375            .iter()
376            .filter(|(_, f)| !f.optional)
377            .map(|(k, _)| k.as_str())
378            .collect(),
379        _ => return Err(invalid_object_bucket()),
380    })
381}
382
383fn all_field_names(ty: &Type) -> Result<BTreeSet<&str>, Vec<JsonNarrowReason>> {
384    Ok(match ty.peel() {
385        Type::Object { fields, .. } => fields.keys().map(std::string::String::as_str).collect(),
386        _ => return Err(invalid_object_bucket()),
387    })
388}
389
390fn invalid_object_bucket() -> Vec<JsonNarrowReason> {
391    vec![JsonNarrowReason::Internal(super::invariant_failure(
392        "JSON object bucket contains inconsistent expanded metadata",
393    ))]
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::types::LiteralF64;
400
401    fn s_lit(s: &str) -> Type {
402        Type::StringLiteral(s.to_string())
403    }
404    fn n_lit(n: f64) -> Type {
405        Type::NumberLiteral(LiteralF64(n))
406    }
407    fn obj(fields: &[(&str, Type, bool)]) -> Type {
408        let mut map = BTreeMap::new();
409        for (name, ty, optional) in fields {
410            map.insert(
411                (*name).to_string(),
412                ObjectField {
413                    ty: ty.clone(),
414                    optional: *optional,
415                    readonly: false,
416                    method: false,
417                },
418            );
419        }
420        Type::Object {
421            index: None,
422            fields: map,
423        }
424    }
425
426    fn canonical(members: Vec<Type>) -> Vec<Type> {
427        match Type::union(members) {
428            Type::Union(m) => m,
429            single => vec![single],
430        }
431    }
432
433    #[test]
434    fn primitive_mix_string_number() {
435        let members = canonical(vec![Type::String, Type::Number]);
436        let strat = resolve_json_union_strategy(&members).expect("resolves");
437        assert_eq!(strat.string, PerTagStrategy::Single(Type::String));
438        assert_eq!(strat.number, PerTagStrategy::Single(Type::Number));
439    }
440
441    #[test]
442    fn literal_set_strings() {
443        let members = canonical(vec![s_lit("a"), s_lit("b"), s_lit("c")]);
444        let strat = resolve_json_union_strategy(&members).expect("resolves");
445        match strat.string {
446            PerTagStrategy::LiteralSet { base, values } => {
447                assert_eq!(base, PrimBase::String);
448                assert_eq!(values.len(), 3);
449            }
450            other => panic!("expected LiteralSet, got {other:?}"),
451        }
452    }
453
454    #[test]
455    fn literal_set_numbers() {
456        let members = canonical(vec![n_lit(200.0), n_lit(404.0), n_lit(500.0)]);
457        let strat = resolve_json_union_strategy(&members).expect("resolves");
458        match strat.number {
459            PerTagStrategy::LiteralSet { base, .. } => assert_eq!(base, PrimBase::Number),
460            other => panic!("expected LiteralSet, got {other:?}"),
461        }
462    }
463
464    #[test]
465    fn literal_subsumed_by_base() {
466        let members = canonical(vec![s_lit("foo"), Type::String]);
467        let reasons = resolve_json_union_strategy(&members).expect_err("fails");
468        assert!(matches!(
469            reasons.as_slice(),
470            [JsonNarrowReason::PrimitiveOverlap {
471                base: PrimBase::String,
472                ..
473            }]
474        ));
475    }
476
477    #[test]
478    fn t_or_null_folds_into_strategy() {
479        let user = obj(&[("id", Type::Number, false)]);
480        let members = canonical(vec![user.clone(), Type::Null]);
481        let strat = resolve_json_union_strategy(&members).expect("resolves");
482        assert_eq!(strat.null, PerTagStrategy::Single(Type::Null));
483        assert!(matches!(strat.object, PerTagStrategy::Single(_)));
484    }
485
486    #[test]
487    fn object_cascade_by_literal_tag() {
488        let v1 = obj(&[("kind", s_lit("ok"), false), ("data", Type::String, false)]);
489        let v2 = obj(&[("kind", s_lit("err"), false), ("code", Type::Number, false)]);
490        let members = canonical(vec![v1, v2]);
491        let strat = resolve_json_union_strategy(&members).expect("resolves");
492        match strat.object {
493            PerTagStrategy::ObjectCascade(ObjectDispatch::ByLiteralTagValue { field, arms }) => {
494                assert_eq!(field, "kind");
495                assert_eq!(arms.len(), 2);
496            }
497            other => panic!("expected ByLiteralTagValue, got {other:?}"),
498        }
499    }
500
501    #[test]
502    fn object_cascade_by_required_field_name() {
503        let v1 = obj(&[("a", Type::Number, false)]);
504        let v2 = obj(&[("b", Type::String, false)]);
505        let members = canonical(vec![v1, v2]);
506        let strat = resolve_json_union_strategy(&members).expect("resolves");
507        assert!(matches!(
508            strat.object,
509            PerTagStrategy::ObjectCascade(ObjectDispatch::ByRequiredFieldName { .. })
510        ));
511    }
512
513    #[test]
514    fn shared_shape_with_different_tag_values() {
515        let v1 = obj(&[("kind", s_lit("a"), false), ("x", Type::Number, false)]);
516        let v2 = obj(&[("kind", s_lit("b"), false), ("x", Type::Number, false)]);
517        let members = canonical(vec![v1, v2]);
518        let strat = resolve_json_union_strategy(&members).expect("resolves");
519        assert!(matches!(
520            strat.object,
521            PerTagStrategy::ObjectCascade(ObjectDispatch::ByLiteralTagValue { .. })
522        ));
523    }
524
525    #[test]
526    fn object_ambiguous_subset_required() {
527        let v1 = obj(&[("a", Type::Number, false)]);
528        let v2 = obj(&[("a", Type::Number, false), ("b", Type::String, false)]);
529        let members = canonical(vec![v1, v2]);
530        let reasons = resolve_json_union_strategy(&members).expect_err("fails");
531        assert!(matches!(
532            reasons.as_slice(),
533            [JsonNarrowReason::ObjectAmbiguous { .. }]
534        ));
535    }
536
537    #[test]
538    fn optional_discriminator() {
539        let v1 = obj(&[("kind", s_lit("a"), true), ("x", Type::Number, false)]);
540        let v2 = obj(&[("tag", s_lit("b"), true), ("x", Type::Number, false)]);
541        let members = canonical(vec![v1, v2]);
542        let reasons = resolve_json_union_strategy(&members).expect_err("fails");
543        assert!(matches!(
544            reasons.as_slice(),
545            [JsonNarrowReason::OptionalDiscriminator { .. }]
546        ));
547    }
548
549    #[test]
550    fn array_vs_array() {
551        let members = canonical(vec![
552            Type::Array(Box::new(Type::Number)),
553            Type::Array(Box::new(Type::String)),
554        ]);
555        let reasons = resolve_json_union_strategy(&members).expect_err("fails");
556        assert!(matches!(
557            reasons.as_slice(),
558            [JsonNarrowReason::ArrayVsArray { .. }]
559        ));
560    }
561
562    #[test]
563    fn mixed_buckets_resolve() {
564        let circle = obj(&[("kind", s_lit("circle"), false), ("r", Type::Number, false)]);
565        let rect = obj(&[
566            ("kind", s_lit("rect"), false),
567            ("w", Type::Number, false),
568            ("h", Type::Number, false),
569        ]);
570        let members = canonical(vec![Type::Number, circle, rect, Type::Null]);
571        let strat = resolve_json_union_strategy(&members).expect("resolves");
572        assert_eq!(strat.number, PerTagStrategy::Single(Type::Number));
573        assert_eq!(strat.null, PerTagStrategy::Single(Type::Null));
574        assert!(matches!(
575            strat.object,
576            PerTagStrategy::ObjectCascade(ObjectDispatch::ByLiteralTagValue { .. })
577        ));
578        assert_eq!(strat.boolean, PerTagStrategy::Empty);
579        assert_eq!(strat.string, PerTagStrategy::Empty);
580        assert_eq!(strat.array, PerTagStrategy::Empty);
581    }
582}
583
584#[cfg(test)]
585mod invariant_tests {
586    use super::*;
587
588    #[test]
589    fn unsupported_and_duplicate_bucket_members_are_rejected() {
590        for members in [
591            vec![Type::Unknown],
592            vec![Type::Null, Type::Null],
593            vec![Type::BooleanLiteral(true), Type::BooleanLiteral(false)],
594        ] {
595            assert!(resolve_json_union_strategy(&members).is_err());
596        }
597    }
598
599    #[test]
600    fn inconsistent_object_expansion_is_an_internal_failure() {
601        let calls = std::cell::Cell::new(0);
602        let object = Type::Object {
603            fields: BTreeMap::new(),
604            index: None,
605        };
606        let expand = |_: &Type| {
607            calls.set(calls.get() + 1);
608            if calls.get() <= 2 {
609                object.clone()
610            } else {
611                Type::Number
612            }
613        };
614        let error = resolve_json_union_strategy_with(&[object.clone(), object.clone()], &expand)
615            .unwrap_err();
616        assert!(matches!(
617            error.as_slice(),
618            [JsonNarrowReason::Internal(CompilerFailure::Internal { .. })]
619        ));
620    }
621}