Skip to content

Commit 9402e73

Browse files
committed
Fix conditional return type resolution for chained calls with complex
arguments
1 parent eeaedb4 commit 9402e73

3 files changed

Lines changed: 91 additions & 3 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
107107
- **False-positive diagnostics on startup.** Files opened while the project was still indexing could produce spurious "class not found" errors. Diagnostics are now deferred until initialization completes.
108108
- **Analyzer and LSP no longer hang on files with deeply nested loops.**
109109
- **Infinite loop on array key reassignment patterns.** Files containing `$arr['key'] = f($arr['key'])` no longer hang the analyzer.
110+
- **Chained calls with complex arguments resolve the correct return type.** Calling `redirect($string . $var)->with(...)` now resolves to `RedirectResponse` as expected. Complex argument expressions (concatenation, method calls, etc.) were previously serialized as empty, causing conditional return types to take the wrong branch.
110111
- **Stack overflow on large codebases and large files.** The `analyze` command no longer crashes with stack overflows on large files.
111112
- **Non-deterministic diagnostic counts eliminated.** Projects with heavy use of generics no longer see false positives that vary between runs.
112113
- **Pull-diagnostic reliability.** Editors that support pull diagnostics no longer show duplicate or stale diagnostics.

src/symbol_map/extraction.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3069,9 +3069,12 @@ fn format_all_call_args(args: &TokenSeparatedSequence<'_, Argument<'_>>) -> Stri
30693069
let text = format_arg_expr(arg_expr);
30703070
parts.push(text);
30713071
}
3072-
// Trim trailing `...` placeholders so that simple single-arg calls
3073-
// (the common case) don't produce `method(Foo::class, ...)`.
3074-
while parts.last().is_some_and(|p| p == "...") {
3072+
// Trim trailing `...` placeholders beyond the first argument so
3073+
// that multi-arg calls like `method(Foo::class, ...)` don't grow
3074+
// a long tail of placeholders, but a single unknown argument still
3075+
// produces `func(...)` rather than `func()` (which would look like
3076+
// a no-arg call and break conditional return-type resolution).
3077+
while parts.len() > 1 && parts.last().is_some_and(|p| p == "...") {
30753078
parts.pop();
30763079
}
30773080
parts.join(", ")

tests/integration/diagnostics_unknown_members.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4877,3 +4877,87 @@ class Test {
48774877
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
48784878
);
48794879
}
4880+
4881+
// ═══════════════════════════════════════════════════════════════════════════
4882+
// Conditional return types — redirect() helper
4883+
// ═══════════════════════════════════════════════════════════════════════════
4884+
4885+
/// `redirect($to)` has `@return ($to is null ? Redirector : RedirectResponse)`.
4886+
/// When called with a non-null argument (including string concatenation),
4887+
/// the return type must resolve to `RedirectResponse`, which carries `with()`
4888+
/// and `withErrors()`. No `unknown_member` diagnostic should fire.
4889+
#[test]
4890+
fn redirect_with_concat_arg_resolves_to_redirect_response() {
4891+
let (backend, _dir) = create_psr4_workspace(
4892+
r#"{"autoload":{"psr-4":{"App\\":"/src/"}}}"#,
4893+
&[
4894+
(
4895+
"helpers.php",
4896+
r#"<?php
4897+
namespace {
4898+
use Illuminate\Routing\Redirector;
4899+
use Illuminate\Http\RedirectResponse;
4900+
4901+
/**
4902+
* @return ($to is null ? \Illuminate\Routing\Redirector : \Illuminate\Http\RedirectResponse)
4903+
*/
4904+
function redirect(?string $to = null): Redirector|RedirectResponse
4905+
{
4906+
return new RedirectResponse();
4907+
}
4908+
}
4909+
"#,
4910+
),
4911+
(
4912+
"src/Routing/Redirector.php",
4913+
r#"<?php
4914+
namespace Illuminate\Routing;
4915+
class Redirector {}
4916+
"#,
4917+
),
4918+
(
4919+
"src/Http/RedirectResponse.php",
4920+
r#"<?php
4921+
namespace Illuminate\Http;
4922+
class RedirectResponse {
4923+
public function with(string $key, mixed $value = null): static {}
4924+
public function withErrors(mixed $provider, string $key = 'default'): static {}
4925+
}
4926+
"#,
4927+
),
4928+
(
4929+
"src/Controller.php",
4930+
r#"<?php
4931+
namespace App;
4932+
class Customer { public int $id = 0; }
4933+
class MyController {
4934+
public function action(Customer $customer): void {
4935+
// String concatenation arg — must resolve to RedirectResponse.
4936+
redirect('/users/' . $customer->id . '#tab')->with('msg', 'ok');
4937+
redirect('/users/' . $customer->id)->withErrors(['e']);
4938+
// Assigned form works too (baseline sanity check).
4939+
$r = redirect('/users/' . $customer->id);
4940+
$r->with('msg', 'ok');
4941+
}
4942+
}
4943+
"#,
4944+
),
4945+
],
4946+
);
4947+
4948+
let uri = "file:///src/Controller.php";
4949+
let content = std::fs::read_to_string(
4950+
std::path::Path::new(_dir.path()).join("src/Controller.php"),
4951+
)
4952+
.unwrap();
4953+
let diags = unknown_member_diagnostics_with_scope_cache(&backend, uri, &content);
4954+
let with_diags: Vec<_> = diags
4955+
.iter()
4956+
.filter(|d| d.message.contains("with") || d.message.contains("withErrors"))
4957+
.collect();
4958+
assert!(
4959+
with_diags.is_empty(),
4960+
"redirect()->with()/withErrors() should resolve to RedirectResponse. Got: {:?}",
4961+
with_diags
4962+
);
4963+
}

0 commit comments

Comments
 (0)