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
127/// String literal text with its non-UTF-8 bytes written as `\xNN`.
128pub struct LiteralText<'a>(pub &'a str);
129
130impl fmt::Display for LiteralText<'_> {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        if !self
133            .0
134            .chars()
135            .any(|c| ('\u{F700}'..='\u{F7FF}').contains(&c))
136        {
137            return f.write_str(self.0);
138        }
139        for c in self.0.chars() {
140            match u32::from(c) {
141                // Bytes that are not valid UTF-8 are stored as U+F700 + byte.
142                b @ 0xF700..=0xF7FF => write!(f, "\\x{:02x}", b - 0xF700)?,
143                _ => write!(f, "{c}")?,
144            }
145        }
146        Ok(())
147    }
148}
149
150impl fmt::Display for Atomic {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        match self {
153            Atomic::TString => write!(f, "string"),
154            Atomic::TLiteralString(s) => write!(f, "\"{}\"", LiteralText(s)),
155            Atomic::TCallableString => write!(f, "callable-string"),
156            Atomic::TClassString(None) => write!(f, "class-string"),
157            Atomic::TClassString(Some(cls)) => write!(f, "class-string<{cls}>"),
158            Atomic::TNonEmptyString => write!(f, "non-empty-string"),
159            Atomic::TNumericString => write!(f, "numeric-string"),
160
161            Atomic::TInt => write!(f, "int"),
162            Atomic::TLiteralInt(n) => write!(f, "{n}"),
163            Atomic::TIntRange { min, max } => match (min, max) {
164                (None, None) => write!(f, "int"),
165                (lo, hi) => {
166                    let lo = lo.map_or_else(|| "min".to_string(), |n| n.to_string());
167                    let hi = hi.map_or_else(|| "max".to_string(), |n| n.to_string());
168                    write!(f, "int<{lo}, {hi}>")
169                }
170            },
171            Atomic::TPositiveInt => write!(f, "positive-int"),
172            Atomic::TNegativeInt => write!(f, "negative-int"),
173            Atomic::TNonNegativeInt => write!(f, "non-negative-int"),
174
175            Atomic::TFloat | Atomic::TIntegralFloat => write!(f, "float"),
176            Atomic::TLiteralFloat(high, low) => {
177                let bits = ((*high as u64) << 32) | (*low as u32 as u64);
178                let value = f64::from_bits(bits);
179                write!(f, "{value}")
180            }
181
182            Atomic::TBool => write!(f, "bool"),
183            Atomic::TTrue => write!(f, "true"),
184            Atomic::TFalse => write!(f, "false"),
185
186            Atomic::TNull => write!(f, "null"),
187            Atomic::TVoid => write!(f, "void"),
188            Atomic::TNever => write!(f, "never"),
189            Atomic::TMixed => write!(f, "mixed"),
190            Atomic::TScalar => write!(f, "scalar"),
191            Atomic::TNumeric => write!(f, "numeric"),
192
193            Atomic::TObject => write!(f, "object"),
194            Atomic::TNamedObject { fqcn, type_params } => {
195                // `Traversable<mixed, mixed>`/`Generator<mixed, mixed, mixed, mixed>`
196                // carry no more information than the bare class name — every
197                // param resolving to the unconstrained default is exactly the
198                // case an omitted type-param list would represent.
199                if type_params.is_empty() || type_params.iter().all(is_exactly_mixed) {
200                    write!(f, "{fqcn}")
201                } else {
202                    write!(f, "{fqcn}<")?;
203                    write_joined(f, type_params.iter(), ", ")?;
204                    f.write_str(">")
205                }
206            }
207            Atomic::TStaticObject { fqcn } => write!(f, "static({fqcn})"),
208            Atomic::TSelf { fqcn } => write!(f, "self({fqcn})"),
209            Atomic::TParent { fqcn } => write!(f, "parent({fqcn})"),
210
211            Atomic::TCallable {
212                params,
213                return_type,
214                is_pure,
215            } => {
216                if *is_pure {
217                    f.write_str("pure-")?;
218                }
219                match (params, return_type) {
220                    (None, None) => f.write_str("callable"),
221                    (Some(params), return_type) => {
222                        f.write_str("callable(")?;
223                        write_param_types(f, params)?;
224                        match return_type {
225                            Some(r) => write!(f, "): {r}"),
226                            None => f.write_str("): mixed"),
227                        }
228                    }
229                    (None, Some(ret)) => write!(f, "callable(): {ret}"),
230                }
231            }
232            Atomic::TClosure { data } => {
233                if data.is_pure {
234                    f.write_str("pure-")?;
235                }
236                f.write_str("Closure(")?;
237                write_param_types(f, &data.params)?;
238                write!(f, "): {}", data.return_type)
239            }
240
241            Atomic::TArray { key, value } => {
242                // `array<mixed, mixed>` and `array<array-key, mixed>` are both
243                // just `array` — `array-key` (int|string) is already the
244                // maximal legal key domain, so it's as much a "default" key
245                // as `mixed` is.
246                if is_exactly_mixed(value) && (is_exactly_mixed(key) || key.is_array_key()) {
247                    write!(f, "array")
248                } else {
249                    write!(f, "array<{key}, {value}>")
250                }
251            }
252            Atomic::TList { value } => {
253                if is_exactly_mixed(value) {
254                    write!(f, "list")
255                } else {
256                    write!(f, "list<{value}>")
257                }
258            }
259            Atomic::TNonEmptyArray { key, value } => {
260                if is_exactly_mixed(value) && (is_exactly_mixed(key) || key.is_array_key()) {
261                    write!(f, "non-empty-array")
262                } else {
263                    write!(f, "non-empty-array<{key}, {value}>")
264                }
265            }
266            Atomic::TNonEmptyList { value } => {
267                if is_exactly_mixed(value) {
268                    write!(f, "non-empty-list")
269                } else {
270                    write!(f, "non-empty-list<{value}>")
271                }
272            }
273            Atomic::TObjectShape { properties } => {
274                f.write_str("object{")?;
275                for (i, (k, v)) in properties.iter().enumerate() {
276                    if i > 0 {
277                        f.write_str(", ")?;
278                    }
279                    match k {
280                        crate::atomic::ArrayKey::String(s) => f.write_str(s)?,
281                        crate::atomic::ArrayKey::Int(n) => write!(f, "{n}")?,
282                    }
283                    if v.optional {
284                        f.write_str("?")?;
285                    }
286                    write!(f, ": {}", v.ty)?;
287                }
288                f.write_str("}")
289            }
290            Atomic::TKeyedArray { properties, .. } => {
291                f.write_str("array{")?;
292                for (i, (k, v)) in properties.iter().enumerate() {
293                    if i > 0 {
294                        f.write_str(", ")?;
295                    }
296                    match k {
297                        crate::atomic::ArrayKey::String(s) => write!(f, "'{}'", LiteralText(s))?,
298                        crate::atomic::ArrayKey::Int(n) => write!(f, "{n}")?,
299                    }
300                    if v.optional {
301                        f.write_str("?")?;
302                    }
303                    write!(f, ": {}", v.ty)?;
304                }
305                f.write_str("}")
306            }
307
308            Atomic::TTemplateParam { name, .. } => write!(f, "{name}"),
309            Atomic::TKeyOf { target } => write!(f, "key-of<{target}>"),
310            Atomic::TValueOf { target } => write!(f, "value-of<{target}>"),
311            Atomic::TConditional { data } => {
312                let (subject, if_true, if_false) = (&data.subject, &data.if_true, &data.if_false);
313                match &data.param_name {
314                    Some(name) => write!(f, "(${name} is {subject} ? {if_true} : {if_false})"),
315                    None => write!(f, "({subject} is ? {if_true} : {if_false})"),
316                }
317            }
318
319            Atomic::TInterfaceString(None) => write!(f, "interface-string"),
320            Atomic::TInterfaceString(Some(iface)) => write!(f, "interface-string<{iface}>"),
321            Atomic::TEnumString => write!(f, "enum-string"),
322            Atomic::TTraitString => write!(f, "trait-string"),
323            Atomic::TLiteralEnumCase {
324                enum_fqcn,
325                case_name,
326            } => {
327                write!(f, "{enum_fqcn}::{case_name}")
328            }
329
330            Atomic::TIntersection { parts } => {
331                // Skip exact-duplicate parts (e.g. a redundant `Foo&Foo`) —
332                // hierarchy-aware redundancy (like `Iterator&Traversable`)
333                // needs class info this crate doesn't have, so only drop
334                // parts that are structurally identical to an earlier one.
335                let mut printed: Vec<&Type> = Vec::new();
336                let mut first = true;
337                for part in parts.iter() {
338                    if printed.contains(&part) {
339                        continue;
340                    }
341                    printed.push(part);
342                    if !first {
343                        f.write_str("&")?;
344                    }
345                    first = false;
346                    write!(f, "{part}")?;
347                }
348                Ok(())
349            }
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn int_range_unbounded_displays_as_int() {
360        assert_eq!(
361            format!(
362                "{}",
363                Atomic::TIntRange {
364                    min: None,
365                    max: None
366                }
367            ),
368            "int"
369        );
370    }
371
372    #[test]
373    fn int_range_bounded_min_displays_range() {
374        assert_eq!(
375            format!(
376                "{}",
377                Atomic::TIntRange {
378                    min: Some(0),
379                    max: None
380                }
381            ),
382            "int<0, max>"
383        );
384    }
385
386    #[test]
387    fn int_range_bounded_max_displays_range() {
388        assert_eq!(
389            format!(
390                "{}",
391                Atomic::TIntRange {
392                    min: None,
393                    max: Some(100)
394                }
395            ),
396            "int<min, 100>"
397        );
398    }
399
400    #[test]
401    fn int_range_fully_bounded_displays_range() {
402        assert_eq!(
403            format!(
404                "{}",
405                Atomic::TIntRange {
406                    min: Some(1),
407                    max: Some(10)
408                }
409            ),
410            "int<1, 10>"
411        );
412    }
413
414    #[test]
415    fn unbounded_int_range_in_union_displays_as_int() {
416        let mut u = Type::empty();
417        u.add_type(Atomic::TIntRange {
418            min: None,
419            max: None,
420        });
421        u.add_type(Atomic::TFalse);
422        assert_eq!(format!("{u}"), "int|false");
423    }
424
425    #[test]
426    fn array_of_mixed_mixed_collapses_to_array() {
427        let atomic = Atomic::TArray {
428            key: Box::new(Type::mixed()),
429            value: Box::new(Type::mixed()),
430        };
431        assert_eq!(format!("{atomic}"), "array");
432    }
433
434    #[test]
435    fn array_of_array_key_mixed_collapses_to_array() {
436        let atomic = Atomic::TArray {
437            key: Box::new(Type::array_key()),
438            value: Box::new(Type::mixed()),
439        };
440        assert_eq!(format!("{atomic}"), "array");
441    }
442
443    #[test]
444    fn array_of_int_mixed_does_not_collapse() {
445        let atomic = Atomic::TArray {
446            key: Box::new(Type::int()),
447            value: Box::new(Type::mixed()),
448        };
449        assert_eq!(format!("{atomic}"), "array<int, mixed>");
450    }
451
452    #[test]
453    fn array_of_mixed_string_does_not_collapse() {
454        let atomic = Atomic::TArray {
455            key: Box::new(Type::mixed()),
456            value: Box::new(Type::string()),
457        };
458        assert_eq!(format!("{atomic}"), "array<mixed, string>");
459    }
460
461    #[test]
462    fn non_empty_array_of_mixed_mixed_collapses() {
463        let atomic = Atomic::TNonEmptyArray {
464            key: Box::new(Type::mixed()),
465            value: Box::new(Type::mixed()),
466        };
467        assert_eq!(format!("{atomic}"), "non-empty-array");
468    }
469
470    #[test]
471    fn list_of_mixed_collapses_to_list() {
472        let atomic = Atomic::TList {
473            value: Box::new(Type::mixed()),
474        };
475        assert_eq!(format!("{atomic}"), "list");
476    }
477
478    #[test]
479    fn list_of_string_does_not_collapse() {
480        let atomic = Atomic::TList {
481            value: Box::new(Type::string()),
482        };
483        assert_eq!(format!("{atomic}"), "list<string>");
484    }
485
486    #[test]
487    fn non_empty_list_of_mixed_collapses() {
488        let atomic = Atomic::TNonEmptyList {
489            value: Box::new(Type::mixed()),
490        };
491        assert_eq!(format!("{atomic}"), "non-empty-list");
492    }
493
494    #[test]
495    fn named_object_all_mixed_params_collapses_to_bare_name() {
496        let atomic = Atomic::TNamedObject {
497            fqcn: "Traversable".into(),
498            type_params: crate::union::vec_to_type_params(vec![Type::mixed(), Type::mixed()]),
499        };
500        assert_eq!(format!("{atomic}"), "Traversable");
501    }
502
503    #[test]
504    fn named_object_with_one_concrete_param_does_not_collapse() {
505        let atomic = Atomic::TNamedObject {
506            fqcn: "Traversable".into(),
507            type_params: crate::union::vec_to_type_params(vec![Type::int(), Type::mixed()]),
508        };
509        assert_eq!(format!("{atomic}"), "Traversable<int, mixed>");
510    }
511
512    #[test]
513    fn named_object_with_mixed_bounded_template_param_does_not_collapse() {
514        // An unresolved `T` template parameter (even one bounded by `mixed`)
515        // is a meaningful part of a generic signature and must never be
516        // confused with a literal, information-free `mixed` default.
517        let template_param = Atomic::TTemplateParam {
518            name: "T".into(),
519            as_type: Box::new(Type::mixed()),
520            defining_entity: "MyClass".into(),
521        };
522        let atomic = Atomic::TNamedObject {
523            fqcn: "MyClass".into(),
524            type_params: crate::union::vec_to_type_params(vec![Type::single(template_param)]),
525        };
526        assert_eq!(format!("{atomic}"), "MyClass<T>");
527    }
528
529    fn bare_traversable() -> Atomic {
530        Atomic::TNamedObject {
531            fqcn: "Traversable".into(),
532            type_params: crate::union::empty_type_params(),
533        }
534    }
535
536    #[test]
537    fn array_mixed_mixed_or_traversable_collapses_to_iterable() {
538        let mut u = Type::single(Atomic::TArray {
539            key: Box::new(Type::array_key()),
540            value: Box::new(Type::mixed()),
541        });
542        u.add_type(bare_traversable());
543        assert_eq!(format!("{u}"), "iterable");
544    }
545
546    #[test]
547    fn parameterized_array_or_matching_traversable_collapses_to_iterable() {
548        let key = Type::string();
549        let value = Type::int();
550        let mut u = Type::single(Atomic::TArray {
551            key: Box::new(key.clone()),
552            value: Box::new(value.clone()),
553        });
554        u.add_type(Atomic::TNamedObject {
555            fqcn: "Traversable".into(),
556            type_params: crate::union::vec_to_type_params(vec![key, value]),
557        });
558        assert_eq!(format!("{u}"), "iterable<string, int>");
559    }
560
561    #[test]
562    fn iterable_collapse_preserves_other_union_members() {
563        let mut u = Type::single(Atomic::TArray {
564            key: Box::new(Type::array_key()),
565            value: Box::new(Type::mixed()),
566        });
567        u.add_type(bare_traversable());
568        u.add_type(Atomic::TNull);
569        assert_eq!(format!("{u}"), "iterable|null");
570    }
571
572    #[test]
573    fn array_with_mismatched_bare_traversable_does_not_collapse() {
574        // A bare (unparameterized) `Traversable` carries no key/value
575        // guarantee, so pairing it with a *more specific* array must not be
576        // rendered as `iterable<int, string>` — that would overclaim that the
577        // `Traversable` side is bound to the same key/value types too.
578        let mut u = Type::single(Atomic::TArray {
579            key: Box::new(Type::int()),
580            value: Box::new(Type::string()),
581        });
582        u.add_type(bare_traversable());
583        assert_eq!(format!("{u}"), "array<int, string>|Traversable");
584    }
585
586    #[test]
587    fn array_with_non_matching_parameterized_traversable_does_not_collapse() {
588        let mut u = Type::single(Atomic::TArray {
589            key: Box::new(Type::int()),
590            value: Box::new(Type::string()),
591        });
592        u.add_type(Atomic::TNamedObject {
593            fqcn: "Traversable".into(),
594            type_params: crate::union::vec_to_type_params(vec![Type::string(), Type::int()]),
595        });
596        assert_eq!(
597            format!("{u}"),
598            "array<int, string>|Traversable<string, int>"
599        );
600    }
601
602    fn named_object(fqcn: &str) -> Atomic {
603        Atomic::TNamedObject {
604            fqcn: fqcn.into(),
605            type_params: crate::union::empty_type_params(),
606        }
607    }
608
609    #[test]
610    fn intersection_drops_exact_duplicate_part() {
611        let atomic = Atomic::TIntersection {
612            parts: crate::union::vec_to_type_params(vec![
613                Type::single(named_object("Foo")),
614                Type::single(named_object("Foo")),
615            ]),
616        };
617        assert_eq!(format!("{atomic}"), "Foo");
618    }
619
620    #[test]
621    fn intersection_keeps_distinct_parts() {
622        let atomic = Atomic::TIntersection {
623            parts: crate::union::vec_to_type_params(vec![
624                Type::single(named_object("Countable")),
625                Type::single(named_object("Traversable")),
626            ]),
627        };
628        assert_eq!(format!("{atomic}"), "Countable&Traversable");
629    }
630
631    #[test]
632    fn intersection_drops_duplicate_among_three_parts() {
633        let atomic = Atomic::TIntersection {
634            parts: crate::union::vec_to_type_params(vec![
635                Type::single(named_object("Foo")),
636                Type::single(named_object("Bar")),
637                Type::single(named_object("Foo")),
638            ]),
639        };
640        assert_eq!(format!("{atomic}"), "Foo&Bar");
641    }
642}