Skip to main content

mir_types/
display.rs

1use std::fmt;
2
3use crate::atomic::Atomic;
4use crate::union::Type;
5
6/// Write `items` separated by `sep` straight into the formatter, avoiding the
7/// intermediate `Vec<String>` + `join` allocations.
8fn write_joined<T: fmt::Display>(
9    f: &mut fmt::Formatter<'_>,
10    items: impl IntoIterator<Item = T>,
11    sep: &str,
12) -> fmt::Result {
13    for (i, item) in items.into_iter().enumerate() {
14        if i > 0 {
15            f.write_str(sep)?;
16        }
17        write!(f, "{item}")?;
18    }
19    Ok(())
20}
21
22/// True when `t` is precisely `mixed` and nothing else — deliberately
23/// stricter than [`Type::is_mixed`], which also treats an unconstrained
24/// template parameter as mixed. A template placeholder is a meaningful part
25/// of a generic signature (e.g. `array<TKey, TValue>`) and must still be
26/// printed, whereas a literal, unconstrained `mixed` is a default that adds
27/// no information and can be collapsed away.
28fn is_exactly_mixed(t: &Type) -> bool {
29    matches!(t.types.as_slice(), [Atomic::TMixed])
30}
31
32/// Write a comma-separated callable/closure parameter type list, printing
33/// `mixed` for untyped params.
34fn write_param_types(f: &mut fmt::Formatter<'_>, params: &[crate::atomic::FnParam]) -> fmt::Result {
35    for (i, p) in params.iter().enumerate() {
36        if i > 0 {
37            f.write_str(", ")?;
38        }
39        match &p.ty {
40            Some(ty) => write!(f, "{ty}")?,
41            None => f.write_str("mixed")?,
42        }
43    }
44    Ok(())
45}
46
47/// PHP's `iterable` is defined as exactly `array|Traversable`, so a union
48/// containing a matching `TArray{key, value}` + `Traversable` pair prints no
49/// more information as those two members than it would as `iterable`. Returns
50/// the pair's indices (lower, higher) and the rendered replacement text.
51///
52/// The `Traversable` member only counts as a match when it's unparameterized
53/// (the bare-`iterable` decomposition) or when its type params are exactly
54/// `[key, value]` (the `iterable<K, V>` decomposition) — a bare `Traversable`
55/// paired with a *more specific* array (e.g. `array<int, string>|Traversable`)
56/// must NOT collapse, since the bare `Traversable` side carries no such
57/// key/value guarantee and collapsing would overclaim precision.
58fn iterable_span(types: &[Atomic]) -> Option<(usize, usize, String)> {
59    for (ai, a) in types.iter().enumerate() {
60        let Atomic::TArray { key, value } = a else {
61            continue;
62        };
63        for (ti, t) in types.iter().enumerate() {
64            if ti == ai {
65                continue;
66            }
67            let Atomic::TNamedObject { fqcn, type_params } = t else {
68                continue;
69            };
70            if fqcn.as_ref() != "Traversable" {
71                continue;
72            }
73            let is_default_key_value =
74                is_exactly_mixed(value) && (is_exactly_mixed(key) || key.is_array_key());
75            let matches = if type_params.is_empty() {
76                // A bare (unparameterized) Traversable makes no key/value
77                // guarantee, so it only pairs safely with the fully generic
78                // `array` — pairing it with a more specific array would
79                // claim the Traversable side shares that specificity too.
80                is_default_key_value
81            } else {
82                type_params.len() == 2
83                    && &type_params[0] == key.as_ref()
84                    && &type_params[1] == value.as_ref()
85            };
86            if !matches {
87                continue;
88            }
89            let rendered = if is_default_key_value {
90                "iterable".to_string()
91            } else {
92                format!("iterable<{key}, {value}>")
93            };
94            return Some((ai.min(ti), ai.max(ti), rendered));
95        }
96    }
97    None
98}
99
100impl fmt::Display for Type {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        if self.types.is_empty() {
103            return write!(f, "never");
104        }
105        if let Some((lo, hi, rendered)) = iterable_span(&self.types) {
106            let mut first = true;
107            for (i, t) in self.types.iter().enumerate() {
108                if i == hi {
109                    continue;
110                }
111                if !first {
112                    f.write_str("|")?;
113                }
114                first = false;
115                if i == lo {
116                    f.write_str(&rendered)?;
117                } else {
118                    write!(f, "{t}")?;
119                }
120            }
121            return Ok(());
122        }
123        write_joined(f, self.types.iter(), "|")
124    }
125}
126
127impl fmt::Display for Atomic {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            Atomic::TString => write!(f, "string"),
131            Atomic::TLiteralString(s) => write!(f, "\"{s}\""),
132            Atomic::TCallableString => write!(f, "callable-string"),
133            Atomic::TClassString(None) => write!(f, "class-string"),
134            Atomic::TClassString(Some(cls)) => write!(f, "class-string<{cls}>"),
135            Atomic::TNonEmptyString => write!(f, "non-empty-string"),
136            Atomic::TNumericString => write!(f, "numeric-string"),
137
138            Atomic::TInt => write!(f, "int"),
139            Atomic::TLiteralInt(n) => write!(f, "{n}"),
140            Atomic::TIntRange { min, max } => match (min, max) {
141                (None, None) => write!(f, "int"),
142                (lo, hi) => {
143                    let lo = lo.map_or_else(|| "min".to_string(), |n| n.to_string());
144                    let hi = hi.map_or_else(|| "max".to_string(), |n| n.to_string());
145                    write!(f, "int<{lo}, {hi}>")
146                }
147            },
148            Atomic::TPositiveInt => write!(f, "positive-int"),
149            Atomic::TNegativeInt => write!(f, "negative-int"),
150            Atomic::TNonNegativeInt => write!(f, "non-negative-int"),
151
152            Atomic::TFloat | Atomic::TIntegralFloat => write!(f, "float"),
153            Atomic::TLiteralFloat(high, low) => {
154                let bits = ((*high as u64) << 32) | (*low as u32 as u64);
155                let value = f64::from_bits(bits);
156                write!(f, "{value}")
157            }
158
159            Atomic::TBool => write!(f, "bool"),
160            Atomic::TTrue => write!(f, "true"),
161            Atomic::TFalse => write!(f, "false"),
162
163            Atomic::TNull => write!(f, "null"),
164            Atomic::TVoid => write!(f, "void"),
165            Atomic::TNever => write!(f, "never"),
166            Atomic::TMixed => write!(f, "mixed"),
167            Atomic::TScalar => write!(f, "scalar"),
168            Atomic::TNumeric => write!(f, "numeric"),
169
170            Atomic::TObject => write!(f, "object"),
171            Atomic::TNamedObject { fqcn, type_params } => {
172                // `Traversable<mixed, mixed>`/`Generator<mixed, mixed, mixed, mixed>`
173                // carry no more information than the bare class name — every
174                // param resolving to the unconstrained default is exactly the
175                // case an omitted type-param list would represent.
176                if type_params.is_empty() || type_params.iter().all(is_exactly_mixed) {
177                    write!(f, "{fqcn}")
178                } else {
179                    write!(f, "{fqcn}<")?;
180                    write_joined(f, type_params.iter(), ", ")?;
181                    f.write_str(">")
182                }
183            }
184            Atomic::TStaticObject { fqcn } => write!(f, "static({fqcn})"),
185            Atomic::TSelf { fqcn } => write!(f, "self({fqcn})"),
186            Atomic::TParent { fqcn } => write!(f, "parent({fqcn})"),
187
188            Atomic::TCallable {
189                params,
190                return_type,
191                is_pure,
192            } => {
193                if *is_pure {
194                    f.write_str("pure-")?;
195                }
196                match (params, return_type) {
197                    (None, None) => f.write_str("callable"),
198                    (Some(params), return_type) => {
199                        f.write_str("callable(")?;
200                        write_param_types(f, params)?;
201                        match return_type {
202                            Some(r) => write!(f, "): {r}"),
203                            None => f.write_str("): mixed"),
204                        }
205                    }
206                    (None, Some(ret)) => write!(f, "callable(): {ret}"),
207                }
208            }
209            Atomic::TClosure { data } => {
210                if data.is_pure {
211                    f.write_str("pure-")?;
212                }
213                f.write_str("Closure(")?;
214                write_param_types(f, &data.params)?;
215                write!(f, "): {}", data.return_type)
216            }
217
218            Atomic::TArray { key, value } => {
219                // `array<mixed, mixed>` and `array<array-key, mixed>` are both
220                // just `array` — `array-key` (int|string) is already the
221                // maximal legal key domain, so it's as much a "default" key
222                // as `mixed` is.
223                if is_exactly_mixed(value) && (is_exactly_mixed(key) || key.is_array_key()) {
224                    write!(f, "array")
225                } else {
226                    write!(f, "array<{key}, {value}>")
227                }
228            }
229            Atomic::TList { value } => {
230                if is_exactly_mixed(value) {
231                    write!(f, "list")
232                } else {
233                    write!(f, "list<{value}>")
234                }
235            }
236            Atomic::TNonEmptyArray { key, value } => {
237                if is_exactly_mixed(value) && (is_exactly_mixed(key) || key.is_array_key()) {
238                    write!(f, "non-empty-array")
239                } else {
240                    write!(f, "non-empty-array<{key}, {value}>")
241                }
242            }
243            Atomic::TNonEmptyList { value } => {
244                if is_exactly_mixed(value) {
245                    write!(f, "non-empty-list")
246                } else {
247                    write!(f, "non-empty-list<{value}>")
248                }
249            }
250            Atomic::TKeyedArray { properties, .. } => {
251                f.write_str("array{")?;
252                for (i, (k, v)) in properties.iter().enumerate() {
253                    if i > 0 {
254                        f.write_str(", ")?;
255                    }
256                    match k {
257                        crate::atomic::ArrayKey::String(s) => write!(f, "'{s}'")?,
258                        crate::atomic::ArrayKey::Int(n) => write!(f, "{n}")?,
259                    }
260                    if v.optional {
261                        f.write_str("?")?;
262                    }
263                    write!(f, ": {}", v.ty)?;
264                }
265                f.write_str("}")
266            }
267
268            Atomic::TTemplateParam { name, .. } => write!(f, "{name}"),
269            Atomic::TKeyOf { target } => write!(f, "key-of<{target}>"),
270            Atomic::TValueOf { target } => write!(f, "value-of<{target}>"),
271            Atomic::TConditional { data } => {
272                let (subject, if_true, if_false) = (&data.subject, &data.if_true, &data.if_false);
273                match &data.param_name {
274                    Some(name) => write!(f, "(${name} is {subject} ? {if_true} : {if_false})"),
275                    None => write!(f, "({subject} is ? {if_true} : {if_false})"),
276                }
277            }
278
279            Atomic::TInterfaceString(None) => write!(f, "interface-string"),
280            Atomic::TInterfaceString(Some(iface)) => write!(f, "interface-string<{iface}>"),
281            Atomic::TEnumString => write!(f, "enum-string"),
282            Atomic::TTraitString => write!(f, "trait-string"),
283            Atomic::TLiteralEnumCase {
284                enum_fqcn,
285                case_name,
286            } => {
287                write!(f, "{enum_fqcn}::{case_name}")
288            }
289
290            Atomic::TIntersection { parts } => {
291                // Skip exact-duplicate parts (e.g. a redundant `Foo&Foo`) —
292                // hierarchy-aware redundancy (like `Iterator&Traversable`)
293                // needs class info this crate doesn't have, so only drop
294                // parts that are structurally identical to an earlier one.
295                let mut printed: Vec<&Type> = Vec::new();
296                let mut first = true;
297                for part in parts.iter() {
298                    if printed.contains(&part) {
299                        continue;
300                    }
301                    printed.push(part);
302                    if !first {
303                        f.write_str("&")?;
304                    }
305                    first = false;
306                    write!(f, "{part}")?;
307                }
308                Ok(())
309            }
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn int_range_unbounded_displays_as_int() {
320        assert_eq!(
321            format!(
322                "{}",
323                Atomic::TIntRange {
324                    min: None,
325                    max: None
326                }
327            ),
328            "int"
329        );
330    }
331
332    #[test]
333    fn int_range_bounded_min_displays_range() {
334        assert_eq!(
335            format!(
336                "{}",
337                Atomic::TIntRange {
338                    min: Some(0),
339                    max: None
340                }
341            ),
342            "int<0, max>"
343        );
344    }
345
346    #[test]
347    fn int_range_bounded_max_displays_range() {
348        assert_eq!(
349            format!(
350                "{}",
351                Atomic::TIntRange {
352                    min: None,
353                    max: Some(100)
354                }
355            ),
356            "int<min, 100>"
357        );
358    }
359
360    #[test]
361    fn int_range_fully_bounded_displays_range() {
362        assert_eq!(
363            format!(
364                "{}",
365                Atomic::TIntRange {
366                    min: Some(1),
367                    max: Some(10)
368                }
369            ),
370            "int<1, 10>"
371        );
372    }
373
374    #[test]
375    fn unbounded_int_range_in_union_displays_as_int() {
376        let mut u = Type::empty();
377        u.add_type(Atomic::TIntRange {
378            min: None,
379            max: None,
380        });
381        u.add_type(Atomic::TFalse);
382        assert_eq!(format!("{u}"), "int|false");
383    }
384
385    #[test]
386    fn array_of_mixed_mixed_collapses_to_array() {
387        let atomic = Atomic::TArray {
388            key: Box::new(Type::mixed()),
389            value: Box::new(Type::mixed()),
390        };
391        assert_eq!(format!("{atomic}"), "array");
392    }
393
394    #[test]
395    fn array_of_array_key_mixed_collapses_to_array() {
396        let atomic = Atomic::TArray {
397            key: Box::new(Type::array_key()),
398            value: Box::new(Type::mixed()),
399        };
400        assert_eq!(format!("{atomic}"), "array");
401    }
402
403    #[test]
404    fn array_of_int_mixed_does_not_collapse() {
405        let atomic = Atomic::TArray {
406            key: Box::new(Type::int()),
407            value: Box::new(Type::mixed()),
408        };
409        assert_eq!(format!("{atomic}"), "array<int, mixed>");
410    }
411
412    #[test]
413    fn array_of_mixed_string_does_not_collapse() {
414        let atomic = Atomic::TArray {
415            key: Box::new(Type::mixed()),
416            value: Box::new(Type::string()),
417        };
418        assert_eq!(format!("{atomic}"), "array<mixed, string>");
419    }
420
421    #[test]
422    fn non_empty_array_of_mixed_mixed_collapses() {
423        let atomic = Atomic::TNonEmptyArray {
424            key: Box::new(Type::mixed()),
425            value: Box::new(Type::mixed()),
426        };
427        assert_eq!(format!("{atomic}"), "non-empty-array");
428    }
429
430    #[test]
431    fn list_of_mixed_collapses_to_list() {
432        let atomic = Atomic::TList {
433            value: Box::new(Type::mixed()),
434        };
435        assert_eq!(format!("{atomic}"), "list");
436    }
437
438    #[test]
439    fn list_of_string_does_not_collapse() {
440        let atomic = Atomic::TList {
441            value: Box::new(Type::string()),
442        };
443        assert_eq!(format!("{atomic}"), "list<string>");
444    }
445
446    #[test]
447    fn non_empty_list_of_mixed_collapses() {
448        let atomic = Atomic::TNonEmptyList {
449            value: Box::new(Type::mixed()),
450        };
451        assert_eq!(format!("{atomic}"), "non-empty-list");
452    }
453
454    #[test]
455    fn named_object_all_mixed_params_collapses_to_bare_name() {
456        let atomic = Atomic::TNamedObject {
457            fqcn: "Traversable".into(),
458            type_params: crate::union::vec_to_type_params(vec![Type::mixed(), Type::mixed()]),
459        };
460        assert_eq!(format!("{atomic}"), "Traversable");
461    }
462
463    #[test]
464    fn named_object_with_one_concrete_param_does_not_collapse() {
465        let atomic = Atomic::TNamedObject {
466            fqcn: "Traversable".into(),
467            type_params: crate::union::vec_to_type_params(vec![Type::int(), Type::mixed()]),
468        };
469        assert_eq!(format!("{atomic}"), "Traversable<int, mixed>");
470    }
471
472    #[test]
473    fn named_object_with_mixed_bounded_template_param_does_not_collapse() {
474        // An unresolved `T` template parameter (even one bounded by `mixed`)
475        // is a meaningful part of a generic signature and must never be
476        // confused with a literal, information-free `mixed` default.
477        let template_param = Atomic::TTemplateParam {
478            name: "T".into(),
479            as_type: Box::new(Type::mixed()),
480            defining_entity: "MyClass".into(),
481        };
482        let atomic = Atomic::TNamedObject {
483            fqcn: "MyClass".into(),
484            type_params: crate::union::vec_to_type_params(vec![Type::single(template_param)]),
485        };
486        assert_eq!(format!("{atomic}"), "MyClass<T>");
487    }
488
489    fn bare_traversable() -> Atomic {
490        Atomic::TNamedObject {
491            fqcn: "Traversable".into(),
492            type_params: crate::union::empty_type_params(),
493        }
494    }
495
496    #[test]
497    fn array_mixed_mixed_or_traversable_collapses_to_iterable() {
498        let mut u = Type::single(Atomic::TArray {
499            key: Box::new(Type::array_key()),
500            value: Box::new(Type::mixed()),
501        });
502        u.add_type(bare_traversable());
503        assert_eq!(format!("{u}"), "iterable");
504    }
505
506    #[test]
507    fn parameterized_array_or_matching_traversable_collapses_to_iterable() {
508        let key = Type::string();
509        let value = Type::int();
510        let mut u = Type::single(Atomic::TArray {
511            key: Box::new(key.clone()),
512            value: Box::new(value.clone()),
513        });
514        u.add_type(Atomic::TNamedObject {
515            fqcn: "Traversable".into(),
516            type_params: crate::union::vec_to_type_params(vec![key, value]),
517        });
518        assert_eq!(format!("{u}"), "iterable<string, int>");
519    }
520
521    #[test]
522    fn iterable_collapse_preserves_other_union_members() {
523        let mut u = Type::single(Atomic::TArray {
524            key: Box::new(Type::array_key()),
525            value: Box::new(Type::mixed()),
526        });
527        u.add_type(bare_traversable());
528        u.add_type(Atomic::TNull);
529        assert_eq!(format!("{u}"), "iterable|null");
530    }
531
532    #[test]
533    fn array_with_mismatched_bare_traversable_does_not_collapse() {
534        // A bare (unparameterized) `Traversable` carries no key/value
535        // guarantee, so pairing it with a *more specific* array must not be
536        // rendered as `iterable<int, string>` — that would overclaim that the
537        // `Traversable` side is bound to the same key/value types too.
538        let mut u = Type::single(Atomic::TArray {
539            key: Box::new(Type::int()),
540            value: Box::new(Type::string()),
541        });
542        u.add_type(bare_traversable());
543        assert_eq!(format!("{u}"), "array<int, string>|Traversable");
544    }
545
546    #[test]
547    fn array_with_non_matching_parameterized_traversable_does_not_collapse() {
548        let mut u = Type::single(Atomic::TArray {
549            key: Box::new(Type::int()),
550            value: Box::new(Type::string()),
551        });
552        u.add_type(Atomic::TNamedObject {
553            fqcn: "Traversable".into(),
554            type_params: crate::union::vec_to_type_params(vec![Type::string(), Type::int()]),
555        });
556        assert_eq!(
557            format!("{u}"),
558            "array<int, string>|Traversable<string, int>"
559        );
560    }
561
562    fn named_object(fqcn: &str) -> Atomic {
563        Atomic::TNamedObject {
564            fqcn: fqcn.into(),
565            type_params: crate::union::empty_type_params(),
566        }
567    }
568
569    #[test]
570    fn intersection_drops_exact_duplicate_part() {
571        let atomic = Atomic::TIntersection {
572            parts: crate::union::vec_to_type_params(vec![
573                Type::single(named_object("Foo")),
574                Type::single(named_object("Foo")),
575            ]),
576        };
577        assert_eq!(format!("{atomic}"), "Foo");
578    }
579
580    #[test]
581    fn intersection_keeps_distinct_parts() {
582        let atomic = Atomic::TIntersection {
583            parts: crate::union::vec_to_type_params(vec![
584                Type::single(named_object("Countable")),
585                Type::single(named_object("Traversable")),
586            ]),
587        };
588        assert_eq!(format!("{atomic}"), "Countable&Traversable");
589    }
590
591    #[test]
592    fn intersection_drops_duplicate_among_three_parts() {
593        let atomic = Atomic::TIntersection {
594            parts: crate::union::vec_to_type_params(vec![
595                Type::single(named_object("Foo")),
596                Type::single(named_object("Bar")),
597                Type::single(named_object("Foo")),
598            ]),
599        };
600        assert_eq!(format!("{atomic}"), "Foo&Bar");
601    }
602}