PHPantom — Code Actions¶
Items are ordered by impact (descending), then complexity (ascending) within the same impact tier.
| Label | Scale |
|---|---|
| Impact | Critical, High, Medium-High, Medium, Low-Medium, Low |
| Complexity | Low (mechanical/boilerplate, no design decisions), Medium (self-contained, follows an existing pattern), Medium-High (spans modules, some new design), High (shared/core subsystem, correctness or performance tradeoffs), Very High (cross-cutting architecture, wide blast radius) |
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 · Complexity: Very 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¶
-
Unified handler signature. Each code action becomes a function
fn(&mut Actions, &ActionContext) -> Option<()>. Handlers are collected in a static array.handle_code_actioniterates the array instead of calling methods one by one. -
Closure-based lazy resolve. Handlers call
actions.add(id, label, range, |builder| { ... }). The closure only runs when the action is being resolved, eliminating separatecollect_*/resolve_*method pairs. The same handler function serves both Phase 1 (applicability check + lightweight stub) and Phase 2 (compute edit). -
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.
-
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.
A46. Honor context.only in code action responses¶
Impact: Medium · Complexity: Medium
We advertise codeActionKinds (quickfix, refactor.extract,
refactor.inline, source.organizeImports) in code_action_provider
(src/server.rs), and every collector already tags its CodeAction
with a kind, but handle_code_action in src/code_actions/mod.rs
never reads params.context.only and never filters its result against
it. A client that asks for only quick-fixes (its Ctrl+.-style menu) or
only source.organizeImports (an on-save automation like
editor.codeActionsOnSave) gets every action mixed together instead,
which breaks the menus that are supposed to be filtered and can make
an auto-run-on-save hook unable to identify the one action it should
apply.
The kind/position interaction to get right¶
context.only is a pure kind filter and must stay orthogonal to
the position/selection validity checks each collector already does
(a diagnostic overlapping params.range for quickfixes, a real
non-collapsed selection over a literal for extract_constant, a
cursor on an inlineable variable for inline_variable, etc.). Two
failure modes to design against explicitly:
- Don't let a kind mismatch masquerade as a position mismatch, or
vice versa. Implement the kind check as a separate, earlier gate
than the position check, never folded into the same conditional —
e.g.
if kind_requested(&only, KIND) { collect_x(...) }, wherecollect_xstill runs its own unmodified position logic once called. If the two checks get combined, a future edit to one is liable to silently change the other's behavior. - Filter before running a collector, not just after, where cheap to
do so. Every collector's kind is fixed and known statically (see
the table below), so
context.onlycan skip whole collector calls before they touch the AST, rather than computing every action and discarding some after the fact. This isn't just an optimization —handle_code_actionruns on effectively every cursor move, so a client that always sends a narrowonly(several editors default the automatic lightbulb request toquickfixonly) currently pays for every refactor/extract collector on every keystroke for nothing.
only matching must be hierarchical, not exact-string: a client
requesting the bare "refactor" kind must match any of our
refactor.extract/refactor.inline/refactor.rewrite actions
(requested == actual || actual.starts_with(&format!("{requested}."))),
per the LSP spec's kind-hierarchy semantics — an exact-equality check
would silently return nothing for that request.
A second gap this surfaces¶
Several collectors already emit kinds we don't declare in
code_action_provider's code_action_kinds list: refactor.rewrite
(convert_switch_to_match, convert_to_arrow_function,
convert_to_closure, convert_to_interpolation,
promote_constructor_param, simplify_null), refactor.rewrite via
CodeActionKind::REFACTOR_REWRITE (generate_constructor), and the
bare CodeActionKind::REFACTOR (generate_getter_setter,
generate_property_hooks, replace_fqcn). Reconcile these as part of
the same task: either add the missing kinds to the advertised list, or
fold each action under an already-declared parent kind — a client
that trusts our advertised capability list and asks for exactly what
we declared should not miss actions we're actually capable of
returning.
Scope boundary: codeAction/resolve is unaffected¶
context.only belongs to the textDocument/codeAction request only.
codeAction/resolve (resolve_code_action) receives the exact
CodeAction the client already picked (via its data field) with no
context to filter against — do not add any kind-filtering logic
there.
Where to look: handle_code_action in src/code_actions/mod.rs
(the ~30 collect_*_actions call list — each call site is where the
early kind-gate belongs); code_action_provider in src/server.rs
for the advertised kind list; A34 above is the natural long-term home
for a per-handler kind table if that refactor lands first, but this
doesn't need to wait for it — the gate works fine bolted onto the
current call list.
A16. Snippet Placeholder for Extracted Method Name¶
Impact: Medium · Complexity: Medium
Blocked: Requires
SnippetTextEditsupport inlsp-types. Upstream issue: gluon-lang/lsp-types#310 (open).SnippetTextEditis an LSP 3.18@proposedfeature — it's in the spec but not yet implemented in any Rust LSP-types crate, including the actively maintainedtower-lsp-server/ls-typesfork that F20 migrates to (checked directly against itsmainbranch;ls-typesremoved its generic 3.18"proposed"feature flag in 0.0.4). F20 alone does not resolve this. Revisit once the upstream issue is resolved in whichever crate we depend on at the time.
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
SnippetTextEditwith${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 regularTextEdit(current behaviour — no snippet, no cursor placement).
Implementation¶
-
Store client capabilities at initialisation. In
initialize, save theInitializeParams.capabilities(or at least the snippet edit flag) on theBackendstruct. -
Check the flag in
collect_extract_function_actions. When the client supports snippet edits, build the workspace edit withDocumentChanges::OperationscontainingSnippetTextEditentries instead of plainTextEdit. The new-text for the method name uses${1:name}syntax. -
Linked edit ranges (optional enhancement). If the client supports
workspace.workspaceEdit.changeAnnotationSupportor 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 · Complexity: Medium
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 · Complexity: 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 · Complexity: 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 · Complexity: Medium-High
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.
A37. Simplify with ?-> (nullsafe operator)¶
Impact: Low-Medium · Complexity: Medium-High
Replace null-checked method/property chains with PHP 8.0's nullsafe operator:
// 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), orisset($var)(for a single variable, not array access). - There is no
else/elseifbranch. 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()producesnullwhen$varis null, which matches the pre-existing state (the assignment was skipped entirely, so$xwas either unset or previously null).
Implementation¶
- Walk the AST for
Statement::Ifnodes 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 · Complexity: Medium-High
Convert an if/elseif chain that compares the same variable or
expression against different values into a switch statement:
// 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::Ifnodes that have at least oneelseifbranch. - 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
switchstatement: each condition value becomes acase, the if/elseif body becomes the case body withbreak;appended (unless the body ends withreturn,throw, orcontinue). - If the chain has a trailing
else, convert it todefault:. - Replace the entire if/elseif/else block with the switch.
Code action kind: refactor.rewrite.
A40. Generate method from call¶
Impact: High · Complexity: Medium-High
When invoking an undefined method (e.g. $foo->newMethod($a, $b)),
offer a code action to generate a method stub on the target class
with the correct signature inferred from the call-site arguments.
High-impact rapid-prototyping workflow. Phpactor has this.
- Resolve the type of the subject to find the target class and file.
- Infer parameter types from the argument expressions at the call site (literal types, variable types, class hints).
- Infer return type as
voidby default; if the call is used in an assignment or return context, usemixed. - Insert the generated method at the end of the class body (before
the closing
}). - Visibility defaults to
public; offer a choice if the call is within the same class (private/protected).
Code action kind: quickfix.
Trigger: Unknown-member diagnostic on a method call.
A41. Create class from non-existing name¶
Impact: High · Complexity: Medium-High
When a class name cannot be resolved, offer a code action to generate a new class file with the correct namespace based on PSR-4 mapping. Pairs naturally with the unknown-class diagnostic. Phpactor has this.
- Use the PSR-4 autoload map from
composer.jsonto determine the file path and namespace for the new class. - Create the file with a minimal class skeleton (
<?phpdeclaration,namespace, empty class body). - If the unresolved name is used in an
extendsorimplementsclause, generate the appropriateclassorinterfacekeyword. - Add a
useimport at the call site if necessary.
Code action kind: quickfix.
Trigger: Unknown-class diagnostic.
A43. Update docblock generics¶
Impact: Medium · Complexity: Medium
Auto-update or add @extends/@implements tags to match the actual
class hierarchy when a class extends a generic parent. Phpactor has
this as a transformer.
- Inspect the
extendsandimplementsclauses of the class under the cursor. - For each parent/interface that declares
@templateparameters, check whether the current class has a matching@extendsor@implementstag. - If the tag is missing, generate one with placeholder type
parameters (e.g.
@extends Collection<mixed>). - If the tag exists but the template parameter count has changed, update it to match.
Code action kind: quickfix.
A45. Simplify with ?: (Elvis operator)¶
Impact: Low-Medium · Complexity: Medium
Replace $x ? $x : $y with $x ?: $y (PHP's short ternary / "Elvis"
operator), the mirror image of the null-coalescing simplifications
src/code_actions/simplify_null.rs already offers. That module's
try_simplify_ternary currently handles isset($x) ? $x : $default and
$x !== null ? $x : $default (both → ??) and the nullsafe patterns,
but not the plain case where the condition and the then-branch are
literally the same expression — its doc comment even notes short ternary
is only handled as something to leave alone on the input side, not
something to produce as output.
- Match a full ternary (
Conditionalwith athenbranch) whose condition's source text equals the then-branch's source text exactly, the same string-equality heuristic the existing??patterns use. - Emit
<condition> ?: <else>, dropping the duplicated then-branch. ??already covers the null-specific version of this ($x !== null ? $x : $default→$x ?? $default); this pattern is for the truthiness version ($x ? $x : $default→$x ?: $default), which is a different operator with different semantics (falsy values other thannull, like0,'', orfalse, also take the else-branch) and should not be conflated with it.- Natural home is alongside the existing patterns in
try_simplify_ternary, reusing its cursor-position walk andSimplificationenum rather than a new module.
Code action kind: refactor.rewrite.