Skip to content

Commit a6b1917

Browse files
committed
feat(linter): add prefer-first-class-callable rule and new autofixes
This commit introduces a new linter rule to promote modern PHP syntax and adds autofix capabilities to several existing rules to improve code quality and developer experience. A new, auto-fixable rule `prefer-first-class-callable` has been added to the `best_practices` category. It detects closures and arrow functions that simply forward their arguments and automatically converts them to the more concise first-class callable syntax (`...`), which is available from PHP 8.1 onwards. Automated fixes have also been implemented for the following rules: * **`no-empty-comment`**: Now automatically removes empty block comments. * **`array-style`**: Can now automatically convert between short `[]` and long `array()` syntax. These additions help automate code cleanup and enforce modern standards with minimal effort. Signed-off-by: azjezz <[email protected]>
1 parent 2a13fba commit a6b1917

File tree

7 files changed

+290
-23
lines changed

7 files changed

+290
-23
lines changed

crates/linter/src/rule/best_practices/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod loop_does_not_iterate;
33
pub mod middleware_in_routes;
44
pub mod no_sprintf_concat;
55
pub mod prefer_anonymous_migration;
6+
pub mod prefer_first_class_callable;
67
pub mod prefer_interface;
78
pub mod prefer_view_array;
89
pub mod prefer_while_loop;
@@ -22,6 +23,7 @@ pub use loop_does_not_iterate::*;
2223
pub use middleware_in_routes::*;
2324
pub use no_sprintf_concat::*;
2425
pub use prefer_anonymous_migration::*;
26+
pub use prefer_first_class_callable::*;
2527
pub use prefer_interface::*;
2628
pub use prefer_view_array::*;
2729
pub use prefer_while_loop::*;
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
use indoc::indoc;
2+
use mago_fixer::SafetyClassification;
3+
use mago_php_version::PHPVersion;
4+
use serde::Deserialize;
5+
use serde::Serialize;
6+
7+
use mago_php_version::PHPVersionRange;
8+
use mago_reporting::Annotation;
9+
use mago_reporting::Issue;
10+
use mago_reporting::Level;
11+
use mago_span::HasSpan;
12+
use mago_syntax::ast::*;
13+
14+
use crate::category::Category;
15+
use crate::context::LintContext;
16+
use crate::integration::IntegrationSet;
17+
use crate::rule::Config;
18+
use crate::rule::LintRule;
19+
use crate::rule_meta::RuleMeta;
20+
use crate::settings::RuleSettings;
21+
22+
#[derive(Debug, Clone)]
23+
pub struct PreferFirstClassCallableRule {
24+
meta: &'static RuleMeta,
25+
cfg: PreferFirstClassCallableConfig,
26+
}
27+
28+
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
29+
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
30+
pub struct PreferFirstClassCallableConfig {
31+
pub level: Level,
32+
}
33+
34+
impl Default for PreferFirstClassCallableConfig {
35+
fn default() -> Self {
36+
Self { level: Level::Warning }
37+
}
38+
}
39+
40+
impl Config for PreferFirstClassCallableConfig {
41+
fn level(&self) -> Level {
42+
self.level
43+
}
44+
}
45+
46+
impl LintRule for PreferFirstClassCallableRule {
47+
type Config = PreferFirstClassCallableConfig;
48+
49+
fn meta() -> &'static RuleMeta {
50+
const META: RuleMeta = RuleMeta {
51+
name: "Prefer First Class Callable",
52+
code: "prefer-first-class-callable",
53+
description: indoc! {r#"
54+
Promotes the use of first-class callable syntax (`...`) for creating closures.
55+
56+
This rule identifies closures and arrow functions that do nothing but forward their arguments to another function or method.
57+
In such cases, the more concise and modern first-class callable syntax, introduced in PHP 8.1, can be used instead.
58+
This improves readability by reducing boilerplate code.
59+
"#},
60+
good_example: indoc! {r#"
61+
<?php
62+
63+
$names = ['Alice', 'Bob', 'Charlie'];
64+
$uppercased_names = array_map(strtoupper(...), $names);
65+
"#},
66+
bad_example: indoc! {r#"
67+
<?php
68+
69+
$names = ['Alice', 'Bob', 'Charlie'];
70+
$uppercased_names = array_map(fn($name) => strtoupper($name), $names);
71+
"#},
72+
category: Category::BestPractices,
73+
php: PHPVersionRange::from(PHPVersion::PHP81),
74+
requires: IntegrationSet::empty(),
75+
};
76+
&META
77+
}
78+
79+
fn targets() -> &'static [NodeKind] {
80+
const TARGETS: &[NodeKind] = &[NodeKind::ArrowFunction, NodeKind::Closure];
81+
82+
TARGETS
83+
}
84+
85+
fn build(settings: RuleSettings<Self::Config>) -> Self {
86+
Self { meta: Self::meta(), cfg: settings.config }
87+
}
88+
89+
fn check<'ast, 'arena>(&self, ctx: &mut LintContext<'_, 'arena>, node: Node<'ast, 'arena>) {
90+
if let Node::ArrowFunction(arrow_function) = node {
91+
let Expression::Call(call) = arrow_function.expression else {
92+
return;
93+
};
94+
95+
if !is_call_forwarding(&arrow_function.parameter_list, call) {
96+
return;
97+
}
98+
99+
let span = arrow_function.span();
100+
101+
let issue = Issue::new(
102+
self.cfg.level(),
103+
"Use first-class callable syntax `...` instead of a arrow function.",
104+
)
105+
.with_code(self.meta.code)
106+
.with_annotation(Annotation::primary(span).with_message("This arrow function can be simplified to the `...` syntax."))
107+
.with_annotation(Annotation::secondary(arrow_function.parameter_list.span()).with_message("These parameters..."))
108+
.with_annotation(Annotation::secondary(call.get_argument_list().span()).with_message("...are directly forwarded here."))
109+
.with_note("This closure only forwards its arguments to another function or method, which can be expressed more concisely.")
110+
.with_help("Replace the arrow function with the first-class callable syntax (e.g., `strlen(...)`).");
111+
112+
ctx.collector.propose(issue, |p| {
113+
p.delete(span.to_end(call.start_position()).to_range(), SafetyClassification::Safe);
114+
p.replace(call.get_argument_list().span().to_range(), "(...)", SafetyClassification::Safe);
115+
});
116+
};
117+
118+
if let Node::Closure(closure) = node {
119+
let Some(return_stmt) = get_single_return_statement(&closure.body) else {
120+
return;
121+
};
122+
123+
let Some(value) = &return_stmt.value else {
124+
return;
125+
};
126+
127+
let Expression::Call(call) = value else {
128+
return;
129+
};
130+
131+
if !is_call_forwarding(&closure.parameter_list, call) {
132+
return;
133+
}
134+
135+
let issue = Issue::new(
136+
self.cfg.level(),
137+
"Use first-class callable syntax `...` instead of a closure.",
138+
)
139+
.with_code(self.meta.code)
140+
.with_annotation(Annotation::primary(node.span()).with_message("This closure can be simplified to the `...` syntax."))
141+
.with_annotation(Annotation::secondary(closure.parameter_list.span()).with_message("These parameters..."))
142+
.with_annotation(Annotation::secondary(call.get_argument_list().span()).with_message("...are directly forwarded here."))
143+
.with_note("This closure only forwards its arguments to another function or method, which can be expressed more concisely.")
144+
.with_help("Replace the closure with the first-class callable syntax (e.g., `strlen(...)`).");
145+
146+
ctx.collector.propose(issue, |p| {
147+
let closure_end = closure.end_position();
148+
149+
p.delete(closure.span().to_end(value.start_position()).to_range(), SafetyClassification::Safe);
150+
p.delete(return_stmt.terminator.span().to_end(closure_end).to_range(), SafetyClassification::Safe);
151+
p.replace(call.get_argument_list().span().to_range(), "(...)", SafetyClassification::Safe);
152+
});
153+
}
154+
}
155+
}
156+
157+
fn is_call_forwarding<'ast, 'arena>(
158+
parameter_list: &'ast FunctionLikeParameterList<'arena>,
159+
call: &'ast Call<'arena>,
160+
) -> bool {
161+
let argument_list = call.get_argument_list();
162+
163+
if parameter_list.parameters.len() != argument_list.arguments.len() {
164+
return false;
165+
}
166+
167+
for (idx, parameter) in parameter_list.parameters.iter().enumerate() {
168+
let Some(argument) = argument_list.arguments.get(idx) else {
169+
return false;
170+
};
171+
172+
let Argument::Positional(PositionalArgument { value, .. }) = argument else {
173+
return false;
174+
};
175+
176+
let Expression::Variable(Variable::Direct(direct_variable)) = value else {
177+
return false;
178+
};
179+
180+
if direct_variable.name != parameter.variable.name {
181+
return false;
182+
}
183+
}
184+
185+
// Same number of parameters and arguments, and all arguments are direct references to the corresponding parameters
186+
// -> it's a call forwarding
187+
true
188+
}
189+
190+
#[inline]
191+
fn get_single_return_statement<'ast, 'arena>(block: &'ast Block<'arena>) -> Option<&'ast Return<'arena>> {
192+
let statements = block.statements.as_slice();
193+
194+
if statements.len() != 1 {
195+
return None;
196+
}
197+
198+
let Statement::Return(return_stmt) = &statements[0] else {
199+
return None;
200+
};
201+
202+
Some(return_stmt)
203+
}

crates/linter/src/rule/consistency/array_style.rs

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use indoc::indoc;
22
use serde::Deserialize;
33
use serde::Serialize;
44

5+
use mago_fixer::SafetyClassification;
56
use mago_php_version::PHPVersionRange;
67
use mago_reporting::Annotation;
78
use mago_reporting::Issue;
@@ -94,26 +95,32 @@ impl LintRule for ArrayStyleRule {
9495
fn check<'ast, 'arena>(&self, ctx: &mut LintContext<'_, 'arena>, node: Node<'ast, 'arena>) {
9596
match node {
9697
Node::LegacyArray(arr) if ArrayStyleOption::Short == self.cfg.style => {
97-
ctx.collector.report(
98-
Issue::new(self.cfg.level(), "Short array style `[..]` is preferred over `array(..)`.")
99-
.with_code(self.meta.code)
100-
.with_annotation(
101-
Annotation::primary(arr.span())
102-
.with_message("This array uses the long array style `array(..)`"),
103-
)
104-
.with_help("Use the short array style `[..]` instead."),
105-
);
98+
let issue = Issue::new(self.cfg.level(), "Short array style `[..]` is preferred over `array(..)`.")
99+
.with_code(self.meta.code)
100+
.with_annotation(
101+
Annotation::primary(arr.span())
102+
.with_message("This array uses the long array style `array(..)`"),
103+
)
104+
.with_help("Use the short array style `[..]` instead.");
105+
106+
ctx.collector.propose(issue, |plan| {
107+
plan.delete(arr.array.span.to_range(), SafetyClassification::Safe);
108+
plan.replace(arr.left_parenthesis.to_range(), "[", SafetyClassification::Safe);
109+
plan.replace(arr.right_parenthesis.to_range(), "]", SafetyClassification::Safe);
110+
});
106111
}
107112
Node::Array(arr) if ArrayStyleOption::Long == self.cfg.style => {
108-
ctx.collector.report(
109-
Issue::new(self.cfg.level(), "Long array style `array(..)` is preferred over `[..]`.")
110-
.with_code(self.meta.code)
111-
.with_annotation(
112-
Annotation::primary(arr.span())
113-
.with_message("This array uses the short array style `[..]`"),
114-
)
115-
.with_help("Use the long array style `array(..)` instead."),
116-
);
113+
let issue = Issue::new(self.cfg.level(), "Long array style `array(..)` is preferred over `[..]`.")
114+
.with_code(self.meta.code)
115+
.with_annotation(
116+
Annotation::primary(arr.span()).with_message("This array uses the short array style `[..]`"),
117+
)
118+
.with_help("Use the long array style `array(..)` instead.");
119+
120+
ctx.collector.propose(issue, |plan| {
121+
plan.replace(arr.left_bracket.to_range(), "array(", SafetyClassification::Safe);
122+
plan.replace(arr.right_bracket.to_range(), ")", SafetyClassification::Safe);
123+
});
117124
}
118125
_ => {}
119126
}

crates/linter/src/rule/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ define_rules! {
155155
OptionalParamOrder(optional_param_order @ OptionalParamOrderRule),
156156
PreferInterface(prefer_interface @ PreferInterfaceRule),
157157
PreferAnonymousMigration(prefer_anonymous_migration @ PreferAnonymousMigrationRule),
158+
PreferFirstClassCallable(prefer_first_class_callable @ PreferFirstClassCallableRule),
158159
NoVoidReferenceReturn(no_void_reference_return @ NoVoidReferenceReturnRule),
159160
NoUnderscoreClass(no_underscore_class @ NoUnderscoreClassRule),
160161
NoTrailingSpace(no_trailing_space @ NoTrailingSpaceRule),

crates/linter/src/rule/redundancy/no_empty_comment.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use indoc::indoc;
22
use serde::Deserialize;
33
use serde::Serialize;
44

5+
use mago_fixer::SafetyClassification;
56
use mago_php_version::PHPVersionRange;
67
use mago_reporting::Annotation;
78
use mago_reporting::Issue;
@@ -103,15 +104,24 @@ impl LintRule for NoEmptyCommentRule {
103104
}
104105

105106
let is_empty = comment_lines(trivia).iter().all(|(_, line)| line.trim().is_empty());
107+
if !is_empty {
108+
continue;
109+
}
106110

107-
if is_empty {
108-
let issue = Issue::new(self.cfg.level(), "Empty comments are not allowed.")
109-
.with_code(self.meta.code)
110-
.with_annotation(Annotation::primary(trivia.span).with_message("This is an empty comment"))
111-
.with_help("Consider removing this comment.");
111+
let issue = Issue::new(self.cfg.level(), "Empty comments are not allowed.")
112+
.with_code(self.meta.code)
113+
.with_annotation(Annotation::primary(trivia.span).with_message("This is an empty comment"))
114+
.with_help("Consider removing this comment.");
112115

116+
if trivia.kind.is_single_line_comment() {
113117
ctx.collector.report(issue);
118+
119+
continue;
114120
}
121+
122+
ctx.collector.propose(issue, |plan| {
123+
plan.delete(trivia.span.to_range(), SafetyClassification::Safe);
124+
});
115125
}
116126
}
117127
}

crates/linter/src/settings.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub struct RulesSettings {
5555
pub no_sprintf_concat: RuleSettings<NoSprintfConcatConfig>,
5656
pub optional_param_order: RuleSettings<OptionalParamOrderConfig>,
5757
pub prefer_anonymous_migration: RuleSettings<PreferAnonymousMigrationConfig>,
58+
pub prefer_first_class_callable: RuleSettings<PreferFirstClassCallableConfig>,
5859
pub no_void_reference_return: RuleSettings<NoVoidReferenceReturnConfig>,
5960
pub no_underscore_class: RuleSettings<NoUnderscoreClassConfig>,
6061
pub no_trailing_space: RuleSettings<NoTrailingSpaceConfig>,

docs/tools/linter/rules/best-practices.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This document details the rules available in the `BestPractices` category.
1515
| Middleware In Routes | [`middleware-in-routes`](#middleware-in-routes) |
1616
| No Sprintf Concat | [`no-sprintf-concat`](#no-sprintf-concat) |
1717
| Prefer Anonymous Migration | [`prefer-anonymous-migration`](#prefer-anonymous-migration) |
18+
| Prefer First Class Callable | [`prefer-first-class-callable`](#prefer-first-class-callable) |
1819
| Prefer Interface | [`prefer-interface`](#prefer-interface) |
1920
| Prefer View Array | [`prefer-view-array`](#prefer-view-array) |
2021
| Prefer While Loop | [`prefer-while-loop`](#prefer-while-loop) |
@@ -275,6 +276,48 @@ return new MyMigration();
275276

276277
---
277278

279+
## <a id="prefer-first-class-callable"></a>`prefer-first-class-callable`
280+
281+
Promotes the use of first-class callable syntax (`...`) for creating closures.
282+
283+
This rule identifies closures and arrow functions that do nothing but forward their arguments to another function or method.
284+
In such cases, the more concise and modern first-class callable syntax, introduced in PHP 8.1, can be used instead.
285+
This improves readability by reducing boilerplate code.
286+
287+
288+
### Requirements
289+
290+
- **PHP Version:** PHP `>= 8.1.0`
291+
292+
### Configuration
293+
294+
| Option | Type | Default |
295+
| :--- | :--- | :--- |
296+
| `enabled` | `boolean` | `true` |
297+
| `level` | `string` | `"warning"` |
298+
299+
### Examples
300+
301+
#### Correct Code
302+
303+
```php
304+
<?php
305+
306+
$names = ['Alice', 'Bob', 'Charlie'];
307+
$uppercased_names = array_map(strtoupper(...), $names);
308+
```
309+
310+
#### Incorrect Code
311+
312+
```php
313+
<?php
314+
315+
$names = ['Alice', 'Bob', 'Charlie'];
316+
$uppercased_names = array_map(fn($name) => strtoupper($name), $names);
317+
```
318+
319+
---
320+
278321
## <a id="prefer-interface"></a>`prefer-interface`
279322

280323
Detects when an implementation class is used instead of the interface.

0 commit comments

Comments
 (0)