Casting and runtime compatibility
Two related but distinct operations live in their own modules:
cast; produces a new type that the source type would coerce to in a given target context.compatibility; answers a boolean: does the source type, in any of its parts, share runtime compatibility with the target?
Both exist because PHP's runtime has rules that the static lattice doesn't fully capture. int does not refine float in the static sense (int(0) is not a float), but int can be passed to a float parameter (PHP coerces). The cast and compatibility operations let the analyser model those.
cast
use suffete::cast::{self, CastTarget};
let result = cast::cast(input, CastTarget::Bool, &world);
let ty = result.ty; // the post-cast TypeId
let flags = result.flags; // CastFlags
There is a single entry point, cast::cast(input, target, &world), where target is a CastTarget selecting one of PHP's six explicit cast operators:
CastTarget | Operator |
|---|---|
Int | (int)$x |
Float | (float)$x |
String | (string)$x |
Bool | (bool)$x |
Array | (array)$x |
Object | (object)$x |
The call returns a CastResult { ty: TypeId, flags: CastFlags }. flags is a bitset of CastFlags::{NONE, LOSSY, MAY_THROW}, read via flags.lossy() and flags.may_throw(). LOSSY marks a cast that discarded information (float truncation, non-numeric string to 0); MAY_THROW marks one that can error at runtime (e.g. an array-to-string cast).
Each follows PHP's runtime cast semantics. The result is typically a single Element of the target kind, with as much refinement as can be preserved (e.g. casting int(0) to bool gives false; casting int<-∞,-1>|int<1,∞> to bool gives true).
use suffete::{TypeBuilder, prelude::{INT, INT_ZERO}};
use suffete::cast::{self, CastTarget};
let zero = TypeBuilder::new().push(INT_ZERO).build();
let one = TypeBuilder::new().push(suffete::ElementId::int_literal(1)).build();
let any_int = TypeBuilder::new().push(INT).build();
let zero_bool = cast::cast(zero, CastTarget::Bool, &world); // .ty == false
let one_bool = cast::cast(one, CastTarget::Bool, &world); // .ty == true
let any_bool = cast::cast(any_int, CastTarget::Bool, &world); // .ty == bool (could be either)
The cast operation is deterministic for refined inputs (a literal cast to a type produces a literal output) and conservative for unrefined inputs (an int cast to bool produces bool, since the analyser can't statically know which way it'll go).
compatibility
use suffete::compatibility;
let compatible: bool = compatibility::runtime_compatible(a, b, &world, options, &mut report);
Asks: is there some pair of runtime values, one in a and one in b, that PHP would consider compatible? The answer is a boolean. Like the lattice operations, it takes a LatticeOptions and a &mut LatticeReport. The module also re-exports statically_compatible(a, b, &world, options, &mut report), which is identical to lattice::overlaps.
This is not the same as overlaps. overlaps is the static type-set intersection: do a and b share a value in the kind sense? runtime_compatible is broader: does PHP's runtime allow a value of a to be used where a value of b is expected?
Examples where runtime_compatible differs from overlaps:
intandfloat:overlapsreturnsfalse(no integer is a float);runtime_compatiblereturnstrue(PHP coerces).numeric-stringandint:overlapsreturnsfalse;runtime_compatiblereturnstrue(PHP coerces).intandbool:overlapsreturnsfalse;runtime_compatiblereturnstruein non-strict mode (PHP coerces 0 to false, non-zero to true).Fooandclass-string<Foo>:overlapsreturnsfalse(one is an object, the other a string);runtime_compatiblereturnstrueif the analyser is checking parameter passing where a class-string can produce an instance.
Use cases:
- The analyser is checking a function call boundary in non-strict mode and wants to know whether to warn about the argument type.
- The analyser is checking a
switchstatement againstcasevalues and wants to know which cases are reachable under PHP's loose comparison. - The analyser is checking an
==(loose equality) operator and wants to know whether the comparison can possibly returntrue.
For strict questions ("could this value, statically, be both?"), use overlaps instead.
How the two relate
The runtime_compatible operation is a superset of overlaps:
overlaps(a, b) → runtime_compatible(a, b). (If they share a value, they're compatible.)runtime_compatible(a, b) does not imply overlaps(a, b). (Compatibility includes coercion edges.)
refines(a, b) → runtime_compatible(a, b) (assuming a is inhabited).
How cast / compatibility relate to refines
refines is always the strict static order: it never admits PHP runtime coercion edges. There is no LatticeOptions toggle that makes refines(int, float) true; it is false unconditionally. Runtime coercion lives entirely in cast and compatibility::runtime_compatible, which exist precisely to model what the static order leaves out.
So the division of labour is fixed, not mode-dependent: when the analyser wants the strict answer (including under declare(strict_types=1)) it calls refines / overlaps; when it wants the loose, runtime-coercion-aware answer it calls runtime_compatible or cast. The LatticeOptions flags (ignore_null, ignore_false, inside_assertion) tune null/false handling and assertion permissiveness; none of them switch coercion on or off.
A worked example
use suffete::{TypeBuilder, prelude::{INT, FLOAT}, lattice::{self, LatticeOptions, LatticeReport}, world::NullWorld, compatibility, cast::{self, CastTarget}};
let world = NullWorld;
let opts = LatticeOptions::default();
let mut rep = LatticeReport::new();
let int_t = TypeBuilder::new().push(INT).build();
let float_t = TypeBuilder::new().push(FLOAT).build();
// Static refines: int does not refine float.
assert!(!lattice::refines(int_t, float_t, &world, opts, &mut rep));
// But runtime allows the coercion.
assert!(compatibility::runtime_compatible(int_t, float_t, &world, opts, &mut rep));
// Casting int to float gives float.
let casted = cast::cast(int_t, CastTarget::Float, &world);
assert_eq!(casted.ty, float_t);
When to use which
| Question | Operation |
|---|---|
Is a a strict subtype of b? | lattice::refines |
Do a and b share a value statically? | lattice::overlaps |
Is a runtime-compatible with b (including coercions)? | compatibility::runtime_compatible |
What does a coerce to when forced to a target type? | cast::cast(a, target, &world) |
What is the smallest type containing both a and b? | join::compute |
What is the type of a's and b's shared values? | meet::compute |
A subtle case: refines, overlaps, and runtime_compatible disagree
For int and float:
refines(int, float)=false(strictly: an int is not a float).overlaps(int, float)=false(no integer is a float as a value).runtime_compatible(int, float)=true(PHP coerces).meet(int, float)=never(no shared values).join(int, float)=int|float(no canonical merge).
The strict static analysis (refines, overlaps, meet) treats them as disjoint. The runtime model (runtime_compatible, cast) acknowledges the coercion. The analyser chooses which to consult based on the diagnostic it's producing.
See also: refines, overlaps for the strict static answers; Lattice options and reports for the
LatticeOptionsflags andCoercionCauses.