Skip to content

Commit 581da7e

Browse files
committed
Early return narrowing
1 parent db0fd47 commit 581da7e

4 files changed

Lines changed: 1703 additions & 139 deletions

File tree

example.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,34 @@ function handleIntersection(User&Loggable $entity): void {
180180
}
181181

182182

183+
// ── Guard Clause Narrowing (Early Return / Throw) ──────────────────────────
184+
185+
$m = findOrFail(1); // User|AdminUser
186+
if (!$m instanceof User) {
187+
return; // early return — guard clause
188+
}
189+
$m->getEmail(); // narrowed to User after guard
190+
191+
$n = findOrFail(1);
192+
if ($n instanceof AdminUser) {
193+
throw new Exception('no admins'); // early throw — guard clause
194+
}
195+
$n->getEmail(); // narrowed to User (AdminUser excluded)
196+
197+
$o = findOrFail(1);
198+
if ($o instanceof User) {
199+
return;
200+
}
201+
if ($o instanceof AdminUser) {
202+
return;
203+
}
204+
// $o has been fully narrowed by sequential guards
205+
206+
$q = getUnknownValue();
207+
if (!$q instanceof User) return; // single-statement guard (no braces)
208+
$q->getEmail(); // narrowed to User
209+
210+
183211
// ── Ternary Narrowing ──────────────────────────────────────────────────────
184212

185213
$model = findOrFail(1);

src/completion/type_narrowing.rs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
/// - `@phpstan-assert` / `@psalm-assert` — custom type guard functions
1414
/// - `match(true) { $var instanceof Foo => … }` — match-arm narrowing
1515
/// - `$var instanceof Foo ? $var->method() : …` — ternary narrowing
16+
/// - Guard clauses: `if (!$var instanceof Foo) { return; }` — narrows
17+
/// after the if block when the body unconditionally exits via
18+
/// `return`, `throw`, `continue`, or `break`.
1619
use mago_span::HasSpan;
1720
use mago_syntax::ast::*;
1821

@@ -716,4 +719,196 @@ impl Backend {
716719
_ => {}
717720
}
718721
}
722+
723+
// ── Guard clause narrowing (early return / throw) ────────────────
724+
725+
/// Check whether a statement unconditionally exits the current scope.
726+
///
727+
/// A statement unconditionally exits if every code path through it
728+
/// ends with `return`, `throw`, `continue`, or `break`. This is used
729+
/// to detect guard clause patterns like:
730+
///
731+
/// ```text
732+
/// if (!$var instanceof Foo) {
733+
/// return;
734+
/// }
735+
/// // $var is Foo here
736+
/// ```
737+
pub(super) fn statement_unconditionally_exits(stmt: &Statement<'_>) -> bool {
738+
match stmt {
739+
Statement::Return(_) => true,
740+
Statement::Continue(_) => true,
741+
Statement::Break(_) => true,
742+
// `throw new …;` is parsed as an expression statement
743+
// containing a Throw expression.
744+
Statement::Expression(es) => matches!(es.expression, Expression::Throw(_)),
745+
// A block exits if its last statement exits.
746+
Statement::Block(block) => block
747+
.statements
748+
.last()
749+
.is_some_and(Self::statement_unconditionally_exits),
750+
// An if/else exits if ALL branches exist and ALL exit.
751+
Statement::If(if_stmt) => Self::if_body_unconditionally_exits(&if_stmt.body),
752+
_ => false,
753+
}
754+
}
755+
756+
/// Check whether an `if` body (including all branches) unconditionally
757+
/// exits. This requires:
758+
/// - The then-body exits, AND
759+
/// - All elseif bodies exit, AND
760+
/// - An else clause exists and exits.
761+
fn if_body_unconditionally_exits(body: &IfBody<'_>) -> bool {
762+
match body {
763+
IfBody::Statement(stmt_body) => {
764+
// Then-body must exit
765+
if !Self::statement_unconditionally_exits(stmt_body.statement) {
766+
return false;
767+
}
768+
// All elseif bodies must exit
769+
if !stmt_body
770+
.else_if_clauses
771+
.iter()
772+
.all(|ei| Self::statement_unconditionally_exits(ei.statement))
773+
{
774+
return false;
775+
}
776+
// Else must exist and exit
777+
stmt_body
778+
.else_clause
779+
.as_ref()
780+
.is_some_and(|ec| Self::statement_unconditionally_exits(ec.statement))
781+
}
782+
IfBody::ColonDelimited(colon_body) => {
783+
// Then-body: last statement must exit
784+
if !colon_body
785+
.statements
786+
.last()
787+
.is_some_and(Self::statement_unconditionally_exits)
788+
{
789+
return false;
790+
}
791+
// All elseif bodies must exit
792+
if !colon_body.else_if_clauses.iter().all(|ei| {
793+
ei.statements
794+
.last()
795+
.is_some_and(Self::statement_unconditionally_exits)
796+
}) {
797+
return false;
798+
}
799+
// Else must exist and exit
800+
colon_body.else_clause.as_ref().is_some_and(|ec| {
801+
ec.statements
802+
.last()
803+
.is_some_and(Self::statement_unconditionally_exits)
804+
})
805+
}
806+
}
807+
}
808+
809+
/// Check whether an `if` body's then-branch unconditionally exits.
810+
/// Used for guard clause detection where we only need the then-body
811+
/// to exit (no else clause required).
812+
fn then_body_unconditionally_exits(body: &IfBody<'_>) -> bool {
813+
match body {
814+
IfBody::Statement(stmt_body) => {
815+
Self::statement_unconditionally_exits(stmt_body.statement)
816+
}
817+
IfBody::ColonDelimited(colon_body) => colon_body
818+
.statements
819+
.last()
820+
.is_some_and(Self::statement_unconditionally_exits),
821+
}
822+
}
823+
824+
/// Apply guard clause narrowing after an `if` statement whose
825+
/// then-body unconditionally exits (return/throw/continue/break)
826+
/// and which has no else/elseif clauses.
827+
///
828+
/// When a guard clause like:
829+
/// ```text
830+
/// if (!$var instanceof Foo) { return; }
831+
/// ```
832+
/// appears before the cursor, the code after it can only be reached
833+
/// when the condition was *false* — so we apply the inverse narrowing.
834+
///
835+
/// This handles:
836+
/// - `instanceof` / `is_a()` / `get_class()` / `::class` checks
837+
/// - `@phpstan-assert-if-true` / `@phpstan-assert-if-false` guards
838+
pub(super) fn apply_guard_clause_narrowing(
839+
if_stmt: &If<'_>,
840+
ctx: &VarResolutionCtx<'_>,
841+
results: &mut Vec<ClassInfo>,
842+
) {
843+
// Only applies when the then-body exits and there are no
844+
// elseif/else branches (simple guard clause pattern).
845+
if !Self::then_body_unconditionally_exits(&if_stmt.body) {
846+
return;
847+
}
848+
if if_stmt.body.has_else_clause() || if_stmt.body.has_else_if_clauses() {
849+
return;
850+
}
851+
852+
// ── instanceof / is_a / get_class / ::class narrowing ──
853+
// The then-body exits, so subsequent code is the "else" — apply
854+
// the inverse of the condition.
855+
if let Some((cls_name, negated)) =
856+
Self::try_extract_instanceof_with_negation(if_stmt.condition, ctx.var_name)
857+
{
858+
// Positive instanceof + exit → exclude after (var is NOT that class)
859+
// Negated instanceof + exit → include after (var IS that class)
860+
if negated {
861+
Self::apply_instanceof_inclusion(&cls_name, ctx, results);
862+
} else {
863+
Self::apply_instanceof_exclusion(&cls_name, ctx, results);
864+
}
865+
}
866+
867+
// ── @phpstan-assert-if-true / @phpstan-assert-if-false ──
868+
// When a function with assert-if-true/false is the condition and
869+
// the then-body exits, the code after runs when the function
870+
// returned the opposite boolean — apply the inverse narrowing.
871+
let (func_call_expr, condition_negated) =
872+
Self::unwrap_condition_negation(if_stmt.condition);
873+
874+
if let Expression::Call(Call::Function(func_call)) = func_call_expr {
875+
let func_name = match func_call.function {
876+
Expression::Identifier(ident) => ident.value().to_string(),
877+
_ => return,
878+
};
879+
let func_info = match ctx.function_loader {
880+
Some(fl) => match fl(&func_name) {
881+
Some(fi) => fi,
882+
None => return,
883+
},
884+
None => return,
885+
};
886+
887+
// The then-body exits, so we're in the "else" conceptually.
888+
// inverted=true, same logic as try_apply_assert_condition_narrowing
889+
let function_returned_true = condition_negated;
890+
891+
for assertion in &func_info.type_assertions {
892+
let applies_positively = match assertion.kind {
893+
AssertionKind::IfTrue => function_returned_true,
894+
AssertionKind::IfFalse => !function_returned_true,
895+
AssertionKind::Always => continue,
896+
};
897+
898+
if let Some(arg_var) = Self::find_assertion_arg_variable(
899+
&func_call.argument_list,
900+
&assertion.param_name,
901+
&func_info.parameters,
902+
) && arg_var == ctx.var_name
903+
{
904+
let should_exclude = assertion.negated ^ !applies_positively;
905+
if should_exclude {
906+
Self::apply_instanceof_exclusion(&assertion.asserted_type, ctx, results);
907+
} else {
908+
Self::apply_instanceof_inclusion(&assertion.asserted_type, ctx, results);
909+
}
910+
}
911+
}
912+
}
913+
}
719914
}

0 commit comments

Comments
 (0)