phpantom_lsp 0.7.0

Fast PHP language server with deep type intelligence. Generics, Laravel, PHPStan annotations. Ready in an instant.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
# PHPantom — Code Actions

Items are ordered by **impact** (descending), then **effort** (ascending)
within the same impact tier.

| Label      | Scale                                                                                                                  |
| ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| **Impact** | **Critical**, **High**, **Medium-High**, **Medium**, **Low-Medium**, **Low**                                           |
| **Effort** | **Low** (≤ 1 day), **Medium** (2-5 days), **Medium-High** (1-2 weeks), **High** (2-4 weeks), **Very High** (> 1 month) |

**Refactoring code actions overview:** A2 (Extract Function) depends on
forward-pass variable usage tracking with byte offsets across function
scopes.

## A34. Unified code action handler architecture

**Impact: Medium · Effort: Medium-High**

Refactor the code action system to use a unified handler architecture
inspired by rust-analyzer's assist system. Currently each code action
has a separate `collect_*` method called from a hand-maintained list in
`handle_code_action`, and deferred actions have a separate `resolve_*`
method dispatched via a string match in `resolve_code_action`. PHPStan
quick-fixes and refactoring actions use different code paths.

### Changes

1. **Unified handler signature.** Each code action becomes a function
   `fn(&mut Actions, &ActionContext) -> Option<()>`. Handlers are
   collected in a static array. `handle_code_action` iterates the array
   instead of calling methods one by one.

2. **Closure-based lazy resolve.** Handlers call
   `actions.add(id, label, range, |builder| { ... })`. The closure
   only runs when the action is being resolved, eliminating separate
   `collect_*` / `resolve_*` method pairs. The same handler function
   serves both Phase 1 (applicability check + lightweight stub) and
   Phase 2 (compute edit).

3. **Unified type for actions and diagnostic fixes.** Use the same
   struct for PHPStan quick-fixes and refactoring actions. The LSP
   layer gets one conversion path. Diagnostic fixes attach the same
   type as their quick-fix data.

4. **Sort by target range size.** Sort results by `target.len()` as
   a tiebreaker. Smaller target = more specific = higher priority.
   No manual priority numbers needed.

### When to implement

Do this when the next batch of code actions is added (A25, A28, etc.).
The refactoring pays for itself by making each subsequent action
cheaper to add: write one function, append it to an array.

---

## A3. Switch → match conversion

**Impact: Low · Effort: Medium**

Offer a code action to convert a `switch` statement to a `match`
expression when the conversion is safe (PHP 8.0+).

### When the conversion is safe

- Every `case` body is a single expression statement (assignment to the
  same variable, or a `return`).
- No `case` body falls through to the next (every case ends with
  `break`, `return`, or `throw`).
- The switch subject is a simple expression (variable, property access,
  method call) — not something with side effects that shouldn't be
  evaluated multiple times.

### Implementation

- Walk the AST for `Statement::Switch` nodes.
- Check each arm against the safety criteria above.
- If all arms pass, build the `match` expression text:
  - Each `case VALUE:` becomes `VALUE =>`.
  - `default:` becomes `default =>`.
  - The body expression (minus the trailing `break;`) becomes the arm's
    RHS.
  - If all arms assign to the same variable, hoist the assignment:
    `$result = match ($x) { ... };`.
  - If all arms return, convert to `return match ($x) { ... };`.
- Offer as `refactor.rewrite` code action kind.
- Only offer when `php_version >= 8.0`.

**Note:** This is a structural AST transformation with no type
resolution dependency, but the safety checks for fall-through and
side-effect-free subjects require careful AST inspection. Not trivial,
but bounded in scope.

---



## A8. Update Docblock to Match Signature

**Impact: Medium · Effort: Medium**

When a function or method signature changes (parameters added, removed,
reordered, or type hints updated), the docblock often falls out of sync.
This code action regenerates or patches the `@param`, `@return`, and
`@throws` tags to match the current signature.

### Behaviour

- **Trigger:** Cursor is on a function/method declaration that has an
  existing docblock. The code action appears when the docblock's `@param`
  tags don't match the signature's parameters (by name, count, or order),
  or when the `@return` tag contradicts the return type hint.
- **Code action kind:** `quickfix` (when tags are clearly wrong) or
  `source.fixAll.docblock` for a broader sweep.

### What gets updated

1. **`@param` tags:**
   - Add missing `@param` for parameters present in the signature but
     absent from the docblock.
   - Remove `@param` for parameters no longer in the signature.
   - Reorder `@param` tags to match signature order.
   - Update the type if the signature has a type hint and the docblock
     type contradicts it (e.g. docblock says `string`, signature says
     `int`). If the docblock type is _more specific_ than the signature
     (e.g. docblock says `non-empty-string`, signature says `string`),
     keep the docblock type (it's a refinement, not a contradiction).
   - Preserve existing descriptions after the type and variable name.

2. **`@return` tag:**
   - If the signature has a return type hint and the docblock `@return`
     contradicts it, update the type. Same refinement rule: keep the
     docblock type if it's more specific.
   - If the signature has a return type but no `@return` tag exists,
     do not add one (the type hint is sufficient). Only update or
     remove existing tags.
   - Remove `@return void` if redundant with a `: void` return type.

3. **Preserve other tags:** `@throws`, `@template`, `@deprecated`,
   `@see`, and any other tags are left untouched.

### Edge cases

- **Promoted constructor parameters:** Treat the same as regular
  parameters for `@param` purposes.
- **Variadic parameters:** `...$args` matches `@param type ...$args`.
- **No existing docblock:** This action only patches existing docblocks.
  PHPDoc generation on `/**` (F1) handles creating new ones.

### Implementation

- Parse the function signature to extract parameter names, types, and
  order, plus the return type.
- Parse the existing docblock to extract `@param` and `@return` tags
  with their positions, types, variable names, and descriptions.
- Diff the two lists to determine additions, removals, reorderings,
  and type updates.
- Build a `WorkspaceEdit` with targeted `TextEdit`s that modify only
  the changed lines within the docblock, preserving formatting,
  indentation, and unchanged tags.

### Prerequisites

| Feature                                   | What it contributes                                                 |
| ----------------------------------------- | ------------------------------------------------------------------- |
| Docblock tag parsing (`docblock/tags.rs`) | Extracts existing `@param`/`@return` tags with positions            |
| Parser (`parser/functions.rs`)            | Extracts parameter names, types, and return type from the signature |

---

## A10. Generate Interface from Class

**Impact: Low-Medium · Effort: Medium**

Extract an interface from an existing class. The new interface contains
method signatures for all public methods in the class, and the class is
updated to implement it.

### Behaviour

- **Trigger:** Cursor is on a class declaration. The code action
  "Extract interface" appears.
- **Code action kind:** `refactor.extract`.
- **Result:** A new file is created containing the interface, and the
  original class is updated to add `implements InterfaceName`.

### What gets extracted

- All `public` methods (excluding the constructor) become interface
  method signatures: visibility, name, parameters with types and
  defaults, and return type.
- PHPDoc blocks from the extracted methods are copied to the interface
  (they often contain `@param`, `@return`, and `@template` tags that
  are essential for type information).
- Class-level `@template` tags are copied if any extracted method
  references those template parameters.
- Public constants are **not** extracted (interface constants have
  different semantics and this is rarely what users want).

### Naming

Default interface name: `{ClassName}Interface`. Place it in the same
namespace and directory as the class. If the file uses PSR-4, the
interface file path is derived from the namespace.

### Implementation

- Parse the class to collect public method signatures and their
  docblocks.
- Collect class-level `@template` tags if referenced by extracted
  methods.
- Generate the interface source: namespace declaration, use imports
  needed by the method signatures, interface declaration with method
  stubs.
- Build a `WorkspaceEdit` with two operations:
  1. `CreateFile` + `TextEdit` for the new interface file.
  2. `TextEdit` on the original class to add `implements InterfaceName`
     (and a `use` import if the interface is in a different file, though
     by default it's the same namespace).
- Format the generated interface to match the project's indentation
  style (detect from the source class).

### Edge cases

- **Class already implements interfaces:** Append to the existing
  `implements` list rather than replacing it.
- **Abstract methods:** Include them in the interface (they're already
  stubs).
- **Static methods:** Include them. Interfaces can declare static method
  signatures.
- **Generic classes:** If the class has `@template T` and a method
  returns `T`, the interface needs the same `@template` tag.

### Prerequisites

| Feature                             | What it contributes                                                               |
| ----------------------------------- | --------------------------------------------------------------------------------- |
| Parser (`parser/classes.rs`)        | Extracts public method signatures with full type information                      |
| Implement missing methods (shipped) | Shared infrastructure for generating method stubs and `implements` clause editing |

---

## A16. Snippet Placeholder for Extracted Method Name

**Impact: Medium · Effort: Low-Medium**

> **Blocked:** Requires `SnippetTextEdit` support in `lsp-types`.
> Upstream issue: [gluon-lang/lsp-types#310]https://github.com/gluon-lang/lsp-types/issues/310.
> The current `lsp-types` (0.94, pinned by `tower-lsp` 0.20) only
> covers LSP 3.17. `SnippetTextEdit` is an LSP 3.18 proposed feature.
> Revisit once the upstream issue is resolved and `tower-lsp` picks up
> the new version.

After an Extract Function/Method code action is applied, let the user
immediately rename the generated name by placing a snippet tab-stop on
it.  The contextual name (`createUsers`, `validateGuard`, …) serves as
the default, but the cursor lands directly on it so the user can type
over it without an extra rename step.

### Behaviour

- **Trigger:** User applies "Extract method 'createUsers'" (or any
  extract function/method action).
- **Result:** The workspace edit uses a `SnippetTextEdit` with
  `${1:createUsers}` for the method name at both the definition site
  and every call site.  The editor enters snippet mode and the user
  can type a new name that updates all locations simultaneously.
- **Fallback:** When the client does not advertise
  `workspace.workspaceEdit.snippetEditSupport`, emit a regular
  `TextEdit` (current behaviour — no snippet, no cursor placement).

### Implementation

1. **Store client capabilities at initialisation.**  In `initialize`,
   save the `InitializeParams.capabilities` (or at least the snippet
   edit flag) on the `Backend` struct.

2. **Check the flag in `collect_extract_function_actions`.**  When
   the client supports snippet edits, build the workspace edit with
   `DocumentChanges::Operations` containing `SnippetTextEdit` entries
   instead of plain `TextEdit`.  The new-text for the method name
   uses `${1:name}` syntax.

3. **Linked edit ranges (optional enhancement).**  If the client
   supports `workspace.workspaceEdit.changeAnnotationSupport` or
   linked edit groups, use those so that editing the name at the
   definition also updates the call site in real time.

### Prerequisites

| Feature                          | What it contributes                                       |
| -------------------------------- | --------------------------------------------------------- |
| Client capability storage        | Need to know whether the client supports snippet edits    |
| `SnippetTextEdit` in tower-lsp   | Verify tower-lsp exposes the snippet edit type            |
| Extract Function (shipped)       | The code action that this enhances                        |

---

## IDE-expected code actions

The following actions are offered by competing PHP IDEs (PHPStorm,
Intelephense) and are expected by users. Identified by cross-referencing
Rector, PHP-CS-Fixer, and Phpactor rule libraries against what major
IDEs actually surface as on-demand code actions.

Micro-simplifications (array_push→$arr[], strlen→==='', flip ternary,
etc.) are intentionally excluded. They are better served by batch tools
like Rector or PHP-CS-Fixer. An LSP should focus on actions that
benefit from editor context (cursor position, file state) rather than
competing with CLI transformers.

---

### A25. `strpos``str_contains` (PHP 8.0+)

**Impact: Medium · Effort: Low**

Convert `strpos($haystack, $needle) !== false` to
`str_contains($haystack, $needle)` and the negated form
`strpos($haystack, $needle) === false` to
`!str_contains($haystack, $needle)`.

Also handle `strstr($haystack, $needle) !== false`.

PHPStorm offers this as an inspection with quick-fix. PHP-CS-Fixer's
`ModernizeStrposFixer` is the reference implementation. Edge case:
must verify exactly 2 arguments to `strpos` (the 3-argument form with
offset has different semantics).

**Code action kind:** `refactor.rewrite`.
**Guard:** `php_version >= 8.0`.

---

### A28. Explicit nullable parameter type (PHP 8.4 deprecation)

**Impact: Medium · Effort: Low**

Convert implicit nullable parameters to explicit nullable syntax:
`function foo(string $p = null)` → `function foo(?string $p = null)`.

PHP 8.4 deprecates the implicit nullable form. PHPStorm flags this.
PHP-CS-Fixer's `NullableTypeDeclarationForDefaultNullValueFixer`
handles union types, intersection types (DNF), and constructor
property promotion.

Only offer when the parameter has a type hint, a `= null` default, and
the type does not already include `null` (no `?` prefix, no `|null`
in a union).

**Code action kind:** `quickfix`.

---

### A29. Simplify boolean return

**Impact: Low-Medium · Effort: Medium**

Convert if-return-boolean patterns to direct boolean returns:

- `if ($a === $b) { return true; } return false;``return $a === $b;`
- `if ($a === $b) { return false; } return true;``return $a !== $b;`

PHPStorm offers this. When the condition is not already boolean-typed,
wrap with `(bool)`.

Guard conditions:
- The if must have exactly one statement (a return of `true` or `false`)
  and no else/elseif.
- The next sibling statement must be `return` of the opposite boolean.

**Code action kind:** `refactor.rewrite`.

---

### A31. Remove always-else (extract guard clause)

**Impact: Low-Medium · Effort: Medium**

When an if-body ends with a flow-breaking statement (`return`, `throw`,
`continue`, `exit`), the `else` keyword is redundant. Promote the else
body to the same nesting level.

PHPStorm marks this as "unnecessary else". PHP-CS-Fixer's
`NoUselessElseFixer` is the reference. Edge case: don't remove else
blocks containing named function or class declarations (PHP evaluates
these eagerly, removing the else changes semantics).

**Code action kind:** `refactor.rewrite`.

---

### A35. Convert to arrow function

**Impact: Low-Medium · Effort: Low**

Convert a single-expression anonymous function to an arrow function:
`function($x) { return $x * 2; }` → `fn($x) => $x * 2`.

Only offer the conversion when ALL of the following are true:

- The closure body contains exactly one statement, and that statement
  is a `return` with an expression.
- The closure does not have a `use()` clause that captures by reference
  (`&$var`). Arrow functions capture by value automatically; by-ref
  semantics differ.
- The closure's return type (native hint, `@return` docblock, or
  inferred from callable context) is NOT `void` or `never`. Arrow
  functions always return their expression value. Converting a
  `void`-returning closure produces code that silently changes
  behaviour when the caller inspects the return value (e.g.
  `array_walk`, `register_shutdown_function`, `Event::listen`).
- The closure does not have a return type hint of `void` or `never`
  explicitly declared.
- `php_version >= 7.4`.

**Why the void/never guard matters:** Several PHP functions and
framework APIs behave differently depending on whether a callback
returns a value. `array_walk` ignores the return, but `array_filter`
uses it. A closure typed `function(): void { doSomething(); }` makes
the intent explicit. Converting it to `fn() => doSomething()` changes
the return value from `null` (void) to whatever `doSomething()` returns,
which can silently break filtering, mapping, or event-handling logic.

**Code action kind:** `refactor.rewrite`.

---

### A36. Import all missing classes

**Impact: Medium · Effort: Low**

Bulk code action that imports all unresolvable class names in a file at
once, instead of requiring the user to trigger "Import class" on each
name individually.

### Behaviour

- **Trigger:** File contains one or more unresolved class names (the
  same condition that triggers the existing single-class import action).
  The bulk action appears in the source action menu.
- **Code action kind:** `source.organizeImports` or
  `source.importAll`.
- **Result:** For each unresolved class name, resolve candidates using
  the same logic as the existing import action. When exactly one
  candidate exists, import it. When multiple candidates exist, pick the
  one with the highest namespace affinity (same ranking the single
  import action uses). Insert all new `use` statements in alphabetical
  order.

### Edge cases

- If any class name has zero candidates (truly unknown), skip it
  silently.
- If two unresolved names would import different classes with the same
  short name, import the first and skip the second (a conflict that
  requires manual resolution).
- Already-imported names are excluded.

---

### A37. Simplify with `?->` (nullsafe operator)

**Impact: Low-Medium · Effort: Medium**

Replace null-checked method/property chains with PHP 8.0's nullsafe
operator:

```php
// Before
if ($user !== null) {
    $name = $user->getName();
}

$city = null;
if ($user !== null) {
    $city = $user->getAddress()->getCity();
}

// After
$name = $user?->getName();

$city = $user?->getAddress()?->getCity();
```

#### When the conversion is safe

- The if-body contains exactly one statement: an assignment or a
  standalone expression statement using the checked variable.
- The null check is `$var !== null`, `$var !== null`, `!is_null($var)`,
  or `isset($var)` (for a single variable, not array access).
- There is no `else` / `elseif` branch. An else branch means the
  developer wants to handle the null case explicitly, which `?->`
  cannot express.
- The variable is used only with `->` access in the body (not passed
  to a function, not reassigned, not used in a binary expression).
- For chained access (`$a->b()->c()`), every intermediate `->` must
  also be converted to `?->` because the nullsafe operator
  short-circuits the entire chain.
- If the body assigns to a variable (`$x = $var->foo()`), the
  resulting `$x = $var?->foo()` produces `null` when `$var` is null,
  which matches the pre-existing state (the assignment was skipped
  entirely, so `$x` was either unset or previously null).

#### Implementation

- Walk the AST for `Statement::If` nodes where the condition is a
  null check on a single variable.
- Verify the body meets the safety criteria above.
- Replace the entire if-block with the body statement, substituting
  every `->` on the checked variable's access chain with `?->`.
- When the if-block only contains a standalone expression (no
  assignment), emit just the expression with `?->`.

**Code action kind:** `refactor.rewrite`.
**Guard:** `php_version >= 8.0`.

---

### A38. Convert if/elseif chain to switch

**Impact: Low-Medium · Effort: Medium**

Convert an if/elseif chain that compares the same variable or
expression against different values into a `switch` statement:

```php
// Before
if ($status === 'active') {
    doActive();
} elseif ($status === 'inactive') {
    doInactive();
} elseif ($status === 'pending') {
    doPending();
} else {
    doDefault();
}

// After
switch ($status) {
    case 'active':
        doActive();
        break;
    case 'inactive':
        doInactive();
        break;
    case 'pending':
        doPending();
        break;
    default:
        doDefault();
        break;
}
```

#### When the conversion is safe

- Every condition in the chain compares the same subject expression
  against a constant value using `===` or `==` (all arms must use the
  same comparison operator).
- The subject expression is a simple expression (variable, property
  access, method call) that should not have side effects when evaluated
  once in the switch head instead of repeatedly in each condition.
- With `===`, the conversion is semantically exact only for scalar
  values. Switch uses loose comparison internally, so strict-equality
  chains are converted with a comment noting the semantic difference,
  or the action is only offered for `==` chains.

#### Implementation

- Walk the AST for `Statement::If` nodes that have at least one
  `elseif` branch.
- Extract the subject from the first condition's comparison. Verify
  all subsequent conditions compare the same subject (by source text
  or AST structure equality).
- Build a `switch` statement: each condition value becomes a `case`,
  the if/elseif body becomes the case body with `break;` appended
  (unless the body ends with `return`, `throw`, or `continue`).
- If the chain has a trailing `else`, convert it to `default:`.
- Replace the entire if/elseif/else block with the switch.

**Code action kind:** `refactor.rewrite`.

---

### A39. Convert to string interpolation

**Impact: Low-Medium · Effort: Low**

Replace simple string concatenation with double-quoted string
interpolation:

```php
// Before
$greeting = 'Hello ' . $name . ', welcome!';
$msg = "Total: " . $order->getTotal();

// After
$greeting = "Hello {$name}, welcome!";
$msg = "Total: {$order->getTotal()}";
```

#### When the conversion is safe

- The concatenation contains at least one variable or simple
  expression (`$var`, `$var->prop`, `$arr['key']`) and at least one
  string literal.
- No interpolated part contains characters that would need escaping
  in a double-quoted string (`$`, `"`, `\`) beyond what is already
  escaped, unless the tool handles the escaping.
- Existing single-quoted string literals in the concatenation are
  re-quoted as double-quoted, with `$` and `"` characters escaped.
- Method calls like `$obj->method()` require curly-brace syntax
  (`{$obj->method()}`), which is valid in PHP.
- Integer, float, and boolean literals are left as concatenation
  (they don't benefit from interpolation and `true`/`false` would
  print as `1`/empty string).
- The concatenation must be a top-level expression or RHS of an
  assignment, not nested inside a function call argument where
  readability is subjective.

#### Implementation

- Walk the AST for `Expression::Concat` (binary `.` operator) nodes.
- Collect the flattened chain of concat operands (recursively unwrap
  nested concats).
- If the chain is all literals or all variables (no mix), skip.
- Build a double-quoted string: literal parts are inserted verbatim
  (with `$` and `"` escaped), variable/expression parts are wrapped
  in `{...}`.
- Replace the entire concat expression with the interpolated string.

**Code action kind:** `refactor.rewrite`.

---

### A40. Convert to instance variable

**Impact: Medium · Effort: Medium**

Convert a local variable inside a method body into an instance property
on the enclosing class, updating all references within the method.

#### Trigger

Cursor is on a local variable assignment (e.g. `$result = ...`) inside
a method body.

#### Behaviour

1. Create a new `private` property declaration on the enclosing class
   (placed after the last existing property, or before the first
   method if no properties exist).
2. Replace `$result = expr` with `$this->result = expr`.
3. Replace all other occurrences of `$result` within the same method
   scope with `$this->result`.
4. If the variable's type can be inferred (from type hints, docblocks,
   or assignment context), add a type declaration to the property.

#### Edge cases

- If a property with the same name already exists, do not offer the
  action.
- Variables used across multiple methods (via separate assignments)
  should only convert the occurrences in the current method scope.
- Closure-captured variables (`use ($result)`) inside the method need
  their capture updated to `use ($this)` or the reference replaced
  with `$this->result` depending on context (closures can access
  `$this` implicitly in non-static contexts).
- Static methods: offer a `private static` property and use
  `self::$result` instead of `$this->result`.
- Constructor promoted parameters should not be offered this action
  (they are already instance variables).

#### Implementation

- Detect that the cursor is on a variable assignment inside a method.
- Check the enclosing class for an existing property with the same
  name.
- Generate the property declaration with the inferred type.
- Produce a `WorkspaceEdit` with edits for the property declaration,
  the assignment rewrite, and all reference replacements within the
  method.

**Code action kind:** `refactor.extract`.