sonicop 26.8.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CONFORMANCE.md +109 -0
- data/Cargo.lock +975 -0
- data/Cargo.toml +40 -0
- data/LICENSE +21 -0
- data/NOTICE +7 -0
- data/README.ja.md +161 -0
- data/README.md +173 -0
- data/config/default.yml +6305 -0
- data/exe/sonicop +7 -0
- data/ext/sonicop/extconf.rb +38 -0
- data/lib/sonicop/runner.rb +67 -0
- data/lib/sonicop/version.rb +6 -0
- data/lib/sonicop.rb +8 -0
- data/licenses/RUBOCOP.txt +20 -0
- data/licenses/TREE_SITTER_RUBY.txt +21 -0
- data/src/cli.rs +940 -0
- data/src/config/inheritance.rs +332 -0
- data/src/config/loader.rs +117 -0
- data/src/config/mod.rs +461 -0
- data/src/config/paths.rs +436 -0
- data/src/config/plugin.rs +247 -0
- data/src/config/store.rs +141 -0
- data/src/cop_name.rs +71 -0
- data/src/diagnostic.rs +205 -0
- data/src/directives.rs +305 -0
- data/src/engine.rs +734 -0
- data/src/formatter.rs +684 -0
- data/src/lib.rs +42 -0
- data/src/main.rs +3 -0
- data/src/ruby_version.rs +393 -0
- data/src/rules/layout/empty_line_after_magic_comment.rs +49 -0
- data/src/rules/layout/end_of_line.rs +35 -0
- data/src/rules/layout/line_length.rs +167 -0
- data/src/rules/layout/mod.rs +13 -0
- data/src/rules/layout/space_after_comma.rs +32 -0
- data/src/rules/layout/space_around_operators.rs +104 -0
- data/src/rules/layout/space_inside_parens.rs +100 -0
- data/src/rules/layout/support.rs +23 -0
- data/src/rules/layout/trailing_empty_lines.rs +38 -0
- data/src/rules/layout/trailing_whitespace.rs +26 -0
- data/src/rules/lint/duplicate_methods.rs +72 -0
- data/src/rules/lint/mod.rs +7 -0
- data/src/rules/lint/syntax.rs +200 -0
- data/src/rules/lint/unused_block_argument.rs +74 -0
- data/src/rules/lint/useless_assignment.rs +104 -0
- data/src/rules/metrics/block_length.rs +39 -0
- data/src/rules/metrics/class_length.rs +34 -0
- data/src/rules/metrics/method_length.rs +10 -0
- data/src/rules/metrics/mod.rs +10 -0
- data/src/rules/metrics/module_length.rs +17 -0
- data/src/rules/metrics/parameter_lists.rs +21 -0
- data/src/rules/metrics/support.rs +105 -0
- data/src/rules/mod.rs +380 -0
- data/src/rules/naming/ascii_identifiers.rs +17 -0
- data/src/rules/naming/constant_name.rs +49 -0
- data/src/rules/naming/method_name.rs +51 -0
- data/src/rules/naming/mod.rs +9 -0
- data/src/rules/naming/support.rs +22 -0
- data/src/rules/naming/variable_name.rs +50 -0
- data/src/rules/security/eval.rs +170 -0
- data/src/rules/security/mod.rs +6 -0
- data/src/rules/style/frozen_string_literal_comment.rs +56 -0
- data/src/rules/style/hash_syntax.rs +63 -0
- data/src/rules/style/mod.rs +9 -0
- data/src/rules/style/numeric_literals.rs +59 -0
- data/src/rules/style/redundant_return.rs +143 -0
- data/src/rules/style/semicolon.rs +48 -0
- data/src/rules/style/string_literals.rs +74 -0
- data/src/rules/support.rs +31 -0
- data/src/source.rs +118 -0
- metadata +117 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
use tree_sitter::Node;
|
|
2
|
+
|
|
3
|
+
use crate::diagnostic::Offense;
|
|
4
|
+
use crate::rules::RuleContext;
|
|
5
|
+
|
|
6
|
+
/// Operators RuboCop's `Node#recursive_literal?` looks through
|
|
7
|
+
/// (`LITERAL_RECURSIVE_METHODS`): comparisons plus `*`, `!` and `<=>`. Note the
|
|
8
|
+
/// absence of `+`, so `"#{1 + 1}"` counts as dynamic while `"#{1 * 2}"` does not.
|
|
9
|
+
const LITERAL_RECURSIVE_OPERATORS: &[&str] =
|
|
10
|
+
&["==", "===", "!=", "<=", ">=", ">", "<", "*", "!", "<=>"];
|
|
11
|
+
|
|
12
|
+
const NUMERIC_LEAF_KINDS: &[&str] = &["integer", "float", "rational", "complex"];
|
|
13
|
+
|
|
14
|
+
/// Leaf nodes that are literals outright (`BASIC_LITERALS`, plus the fragments
|
|
15
|
+
/// tree-sitter splits literal text into).
|
|
16
|
+
const LITERAL_LEAF_KINDS: &[&str] = &[
|
|
17
|
+
"integer",
|
|
18
|
+
"float",
|
|
19
|
+
"rational",
|
|
20
|
+
"complex",
|
|
21
|
+
"true",
|
|
22
|
+
"false",
|
|
23
|
+
"nil",
|
|
24
|
+
"simple_symbol",
|
|
25
|
+
"hash_key_symbol",
|
|
26
|
+
"character",
|
|
27
|
+
"string_content",
|
|
28
|
+
"escape_sequence",
|
|
29
|
+
"heredoc_content",
|
|
30
|
+
"heredoc_end",
|
|
31
|
+
"regex_options",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/// Literals built out of other nodes (`COMPOSITE_LITERALS`, plus `begin`/`pair`
|
|
35
|
+
/// and tree-sitter's own grouping nodes): literal only if every part is.
|
|
36
|
+
const LITERAL_COMPOSITE_KINDS: &[&str] = &[
|
|
37
|
+
"string",
|
|
38
|
+
"bare_string",
|
|
39
|
+
"chained_string",
|
|
40
|
+
"delimited_symbol",
|
|
41
|
+
"subshell",
|
|
42
|
+
"regex",
|
|
43
|
+
"array",
|
|
44
|
+
"string_array",
|
|
45
|
+
"symbol_array",
|
|
46
|
+
"hash",
|
|
47
|
+
"pair",
|
|
48
|
+
"range",
|
|
49
|
+
"interpolation",
|
|
50
|
+
"heredoc_body",
|
|
51
|
+
"parenthesized_statements",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
55
|
+
for node in context.nodes_of("call") {
|
|
56
|
+
let Some(method) = node.child_by_field_name("method") else {
|
|
57
|
+
continue;
|
|
58
|
+
};
|
|
59
|
+
if context.source.node_text(method) != "eval" || !receiver_is_eval_scope(node, context) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let Some(arguments) = node.child_by_field_name("arguments") else {
|
|
63
|
+
continue;
|
|
64
|
+
};
|
|
65
|
+
let Some(argument) = arguments.named_child(0) else {
|
|
66
|
+
continue;
|
|
67
|
+
};
|
|
68
|
+
if literal_code(argument, context) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
offenses.push(context.offense(
|
|
72
|
+
"The use of `eval` is a serious security risk.",
|
|
73
|
+
method.byte_range(),
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// RuboCop only matches `eval`, `binding.eval` and `Kernel.eval` - the receivers
|
|
79
|
+
/// that evaluate in the caller's own scope. `::Kernel` is the same constant, but
|
|
80
|
+
/// `Binding` and any other constant are different methods entirely.
|
|
81
|
+
fn receiver_is_eval_scope(call: Node<'_>, context: &RuleContext<'_>) -> bool {
|
|
82
|
+
let Some(receiver) = call.child_by_field_name("receiver") else {
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
match receiver.kind() {
|
|
86
|
+
"constant" => context.source.node_text(receiver) == "Kernel",
|
|
87
|
+
// `::Kernel`, but not a `Foo::Kernel` that merely ends in the name.
|
|
88
|
+
"scope_resolution" => {
|
|
89
|
+
receiver.child_by_field_name("scope").is_none()
|
|
90
|
+
&& receiver
|
|
91
|
+
.child_by_field_name("name")
|
|
92
|
+
.is_some_and(|name| context.source.node_text(name) == "Kernel")
|
|
93
|
+
}
|
|
94
|
+
"identifier" => context.source.node_text(receiver) == "binding",
|
|
95
|
+
_ => false,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Whether the evaluated argument is a literal chunk of code. RuboCop exempts a
|
|
100
|
+
/// plain string outright, and an interpolated one whose interpolations are all
|
|
101
|
+
/// recursively literal, because neither can smuggle in new code. Only those two
|
|
102
|
+
/// shapes are exempt: a backtick command or a symbol still counts as an offense
|
|
103
|
+
/// even though both are literals.
|
|
104
|
+
fn literal_code(argument: Node<'_>, context: &RuleContext<'_>) -> bool {
|
|
105
|
+
match argument.kind() {
|
|
106
|
+
"string" | "chained_string" => recursive_literal(argument, context),
|
|
107
|
+
// A heredoc's body is a sibling of the enclosing statement rather than a
|
|
108
|
+
// child of the opener, so it has to be looked up separately.
|
|
109
|
+
"heredoc_beginning" => {
|
|
110
|
+
heredoc_body(argument, context).is_some_and(|body| recursive_literal(body, context))
|
|
111
|
+
}
|
|
112
|
+
_ => false,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// The `heredoc_body` opened by `beginning`. Bodies appear after the statement in
|
|
117
|
+
/// the same order as their openers, so the nth opener owns the nth body.
|
|
118
|
+
fn heredoc_body<'a>(beginning: Node<'_>, context: &'a RuleContext<'_>) -> Option<Node<'a>> {
|
|
119
|
+
let position = context
|
|
120
|
+
.nodes_of("heredoc_beginning")
|
|
121
|
+
.position(|node| node.id() == beginning.id())?;
|
|
122
|
+
context.nodes_of("heredoc_body").nth(position)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Mirrors RuboCop's `Node#recursive_literal?`.
|
|
126
|
+
fn recursive_literal(node: Node<'_>, context: &RuleContext<'_>) -> bool {
|
|
127
|
+
let kind = node.kind();
|
|
128
|
+
if LITERAL_LEAF_KINDS.contains(&kind) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if LITERAL_COMPOSITE_KINDS.contains(&kind) {
|
|
132
|
+
return named_children(node).all(|child| recursive_literal(child, context));
|
|
133
|
+
}
|
|
134
|
+
if matches!(kind, "unary" | "binary" | "boolean") {
|
|
135
|
+
let Some(operator) = node
|
|
136
|
+
.child_by_field_name("operator")
|
|
137
|
+
.map(|operator| context.source.node_text(operator))
|
|
138
|
+
.or_else(|| operator_token(node, context))
|
|
139
|
+
else {
|
|
140
|
+
return false;
|
|
141
|
+
};
|
|
142
|
+
// A signed number is a single numeric literal to RuboCop's parser rather
|
|
143
|
+
// than a call, so `-1` is literal while `-foo` is not.
|
|
144
|
+
if kind == "unary" && matches!(operator, "-" | "+") {
|
|
145
|
+
return node
|
|
146
|
+
.child_by_field_name("operand")
|
|
147
|
+
.is_some_and(|operand| NUMERIC_LEAF_KINDS.contains(&operand.kind()));
|
|
148
|
+
}
|
|
149
|
+
return (LITERAL_RECURSIVE_OPERATORS.contains(&operator)
|
|
150
|
+
|| matches!(operator, "and" | "or"))
|
|
151
|
+
&& named_children(node).all(|child| recursive_literal(child, context));
|
|
152
|
+
}
|
|
153
|
+
false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// The operator of a node that exposes it as an anonymous token rather than a
|
|
157
|
+
/// named `operator` field.
|
|
158
|
+
fn operator_token<'a>(node: Node<'_>, context: &'a RuleContext<'_>) -> Option<&'a str> {
|
|
159
|
+
let mut cursor = node.walk();
|
|
160
|
+
node.children(&mut cursor)
|
|
161
|
+
.find(|child| !child.is_named())
|
|
162
|
+
.map(|child| context.source.node_text(child))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fn named_children<'tree>(node: Node<'tree>) -> impl Iterator<Item = Node<'tree>> {
|
|
166
|
+
let mut cursor = node.walk();
|
|
167
|
+
node.named_children(&mut cursor)
|
|
168
|
+
.collect::<Vec<_>>()
|
|
169
|
+
.into_iter()
|
|
170
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
use crate::diagnostic::{Edit, Offense};
|
|
2
|
+
use crate::rules::RuleContext;
|
|
3
|
+
|
|
4
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
5
|
+
if context.source.text().trim().is_empty() {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
let style: String = context
|
|
9
|
+
.setting("EnforcedStyle")
|
|
10
|
+
.unwrap_or_else(|| "always".to_owned());
|
|
11
|
+
let lines: Vec<&str> = context.source.text().lines().take(4).collect();
|
|
12
|
+
let existing = lines
|
|
13
|
+
.iter()
|
|
14
|
+
.position(|line| line.trim_start().starts_with("# frozen_string_literal:"));
|
|
15
|
+
|
|
16
|
+
if style == "never" {
|
|
17
|
+
if let Some(index) = existing {
|
|
18
|
+
let start = context.source.line_start(index + 1);
|
|
19
|
+
let end = context.source.line_range(index + 1).end;
|
|
20
|
+
offenses.push(
|
|
21
|
+
context
|
|
22
|
+
.offense("Remove the frozen string literal comment.", start..end)
|
|
23
|
+
.corrected_by(Edit {
|
|
24
|
+
start,
|
|
25
|
+
end,
|
|
26
|
+
replacement: String::new(),
|
|
27
|
+
safe: false,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if existing.is_some() {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let first = context.source.line(1);
|
|
38
|
+
let insertion = if first.starts_with("#!") {
|
|
39
|
+
context.source.line_range(1).end
|
|
40
|
+
} else {
|
|
41
|
+
0
|
|
42
|
+
};
|
|
43
|
+
offenses.push(
|
|
44
|
+
context
|
|
45
|
+
.offense(
|
|
46
|
+
"Missing frozen string literal comment.",
|
|
47
|
+
insertion..insertion,
|
|
48
|
+
)
|
|
49
|
+
.corrected_by(Edit {
|
|
50
|
+
start: insertion,
|
|
51
|
+
end: insertion,
|
|
52
|
+
replacement: "# frozen_string_literal: true\n\n".to_owned(),
|
|
53
|
+
safe: false,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
use std::sync::LazyLock;
|
|
2
|
+
|
|
3
|
+
use regex::Regex;
|
|
4
|
+
use tree_sitter::Node;
|
|
5
|
+
|
|
6
|
+
use crate::diagnostic::{Edit, Offense};
|
|
7
|
+
use crate::rules::RuleContext;
|
|
8
|
+
|
|
9
|
+
static HASH_ROCKET: LazyLock<Regex> =
|
|
10
|
+
LazyLock::new(|| Regex::new(r"^:([A-Za-z_][A-Za-z0-9_]*)\s*=>").unwrap());
|
|
11
|
+
|
|
12
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
13
|
+
let style: String = context
|
|
14
|
+
.setting("EnforcedStyle")
|
|
15
|
+
.unwrap_or_else(|| "ruby19".to_owned());
|
|
16
|
+
if style != "ruby19" && style != "ruby19_no_mixed_keys" {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
for node in context.nodes_of("pair") {
|
|
20
|
+
let text = context.source.node_text(node);
|
|
21
|
+
let Some(captures) = HASH_ROCKET.captures(text) else {
|
|
22
|
+
continue;
|
|
23
|
+
};
|
|
24
|
+
// `ruby19` leaves a hash alone unless every key can take the new syntax, so that one
|
|
25
|
+
// rocket that has to stay does not leave the hash in two styles at once.
|
|
26
|
+
if style == "ruby19" && !all_hash_keys_are_symbols(node, context) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
let whole = captures.get(0).unwrap();
|
|
30
|
+
let name = captures.get(1).unwrap().as_str();
|
|
31
|
+
let start = node.start_byte() + whole.start();
|
|
32
|
+
let end = node.start_byte() + whole.end();
|
|
33
|
+
offenses.push(
|
|
34
|
+
context
|
|
35
|
+
.offense("Use the new Ruby 1.9 hash syntax.", start..end)
|
|
36
|
+
.corrected_by(Edit {
|
|
37
|
+
start,
|
|
38
|
+
end,
|
|
39
|
+
replacement: format!("{name}:"),
|
|
40
|
+
safe: true,
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn all_hash_keys_are_symbols(node: Node<'_>, context: &RuleContext<'_>) -> bool {
|
|
47
|
+
let Some(container) = node.parent() else {
|
|
48
|
+
return false;
|
|
49
|
+
};
|
|
50
|
+
let mut cursor = container.walk();
|
|
51
|
+
let pairs = container
|
|
52
|
+
.named_children(&mut cursor)
|
|
53
|
+
.filter(|child| child.kind() == "pair")
|
|
54
|
+
.collect::<Vec<_>>();
|
|
55
|
+
!pairs.is_empty()
|
|
56
|
+
&& pairs.iter().all(|pair| {
|
|
57
|
+
let Some(key) = pair.child_by_field_name("key") else {
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
context.source.node_text(key).starts_with(':')
|
|
61
|
+
|| context.source.text().as_bytes().get(key.end_byte()) == Some(&b':')
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
department_rules! {
|
|
2
|
+
"Style";
|
|
3
|
+
frozen_string_literal_comment => ("FrozenStringLiteralComment", Convention),
|
|
4
|
+
hash_syntax => ("HashSyntax", Convention),
|
|
5
|
+
numeric_literals => ("NumericLiterals", Convention),
|
|
6
|
+
redundant_return => ("RedundantReturn", Convention),
|
|
7
|
+
semicolon => ("Semicolon", Convention),
|
|
8
|
+
string_literals => ("StringLiterals", Convention),
|
|
9
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
use crate::diagnostic::{Edit, Offense};
|
|
2
|
+
use crate::rules::RuleContext;
|
|
3
|
+
|
|
4
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
5
|
+
let min_digits: usize = context.setting("MinDigits").unwrap_or(5);
|
|
6
|
+
for node in context.nodes_of("integer") {
|
|
7
|
+
let text = context.source.node_text(node);
|
|
8
|
+
if text.len() < min_digits
|
|
9
|
+
|| text.contains('_')
|
|
10
|
+
|| text.starts_with('0')
|
|
11
|
+
|| !text.bytes().all(|byte| byte.is_ascii_digit())
|
|
12
|
+
{
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
let replacement = grouped_number(text);
|
|
16
|
+
offenses.push(
|
|
17
|
+
context
|
|
18
|
+
.offense(
|
|
19
|
+
"Use underscores(_) as thousands separator and separate every 3 digits with them.",
|
|
20
|
+
node.byte_range(),
|
|
21
|
+
)
|
|
22
|
+
.corrected_by(Edit {
|
|
23
|
+
start: node.start_byte(),
|
|
24
|
+
end: node.end_byte(),
|
|
25
|
+
replacement,
|
|
26
|
+
safe: true,
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn grouped_number(number: &str) -> String {
|
|
33
|
+
let first = number.len() % 3;
|
|
34
|
+
let mut output = String::with_capacity(number.len() + number.len() / 3);
|
|
35
|
+
let mut index = 0;
|
|
36
|
+
if first != 0 {
|
|
37
|
+
output.push_str(&number[..first]);
|
|
38
|
+
index = first;
|
|
39
|
+
}
|
|
40
|
+
while index < number.len() {
|
|
41
|
+
if !output.is_empty() {
|
|
42
|
+
output.push('_');
|
|
43
|
+
}
|
|
44
|
+
output.push_str(&number[index..index + 3]);
|
|
45
|
+
index += 3;
|
|
46
|
+
}
|
|
47
|
+
output
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[cfg(test)]
|
|
51
|
+
mod tests {
|
|
52
|
+
use super::grouped_number;
|
|
53
|
+
|
|
54
|
+
#[test]
|
|
55
|
+
fn groups_decimal_digits() {
|
|
56
|
+
assert_eq!(grouped_number("12345"), "12_345");
|
|
57
|
+
assert_eq!(grouped_number("1234567"), "1_234_567");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
use tree_sitter::Node;
|
|
2
|
+
|
|
3
|
+
use crate::diagnostic::{Edit, Offense};
|
|
4
|
+
use crate::rules::RuleContext;
|
|
5
|
+
|
|
6
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
7
|
+
let allow_multiple_return_values: bool = context
|
|
8
|
+
.setting("AllowMultipleReturnValues")
|
|
9
|
+
.unwrap_or(false);
|
|
10
|
+
for node in context.nodes_of_any(&["method", "singleton_method"]) {
|
|
11
|
+
let Some(body) = node.child_by_field_name("body") else {
|
|
12
|
+
continue;
|
|
13
|
+
};
|
|
14
|
+
let Some(last) = last_body_statement(body) else {
|
|
15
|
+
continue;
|
|
16
|
+
};
|
|
17
|
+
if last.kind() != "return" {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
let arguments = return_arguments(last);
|
|
21
|
+
let multiple_values = arguments.len() > 1 && !braceless_hash(&arguments);
|
|
22
|
+
if allow_multiple_return_values && multiple_values {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
let message = if multiple_values {
|
|
26
|
+
"Redundant `return` detected. To return multiple values, use an array."
|
|
27
|
+
} else {
|
|
28
|
+
"Redundant `return` detected."
|
|
29
|
+
};
|
|
30
|
+
offenses.push(
|
|
31
|
+
context
|
|
32
|
+
.offense(
|
|
33
|
+
message,
|
|
34
|
+
last.start_byte()..last.start_byte() + "return".len(),
|
|
35
|
+
)
|
|
36
|
+
.corrected_by(redundant_return_edit(
|
|
37
|
+
context,
|
|
38
|
+
last,
|
|
39
|
+
&arguments,
|
|
40
|
+
multiple_values,
|
|
41
|
+
)),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// The expression a method body evaluates last. A trailing comment is a named
|
|
47
|
+
/// node here but absent from RuboCop's AST, so it must not stand in for the
|
|
48
|
+
/// final expression and hide the `return` behind it.
|
|
49
|
+
fn last_body_statement(body: Node<'_>) -> Option<Node<'_>> {
|
|
50
|
+
let mut cursor = body.walk();
|
|
51
|
+
body.named_children(&mut cursor)
|
|
52
|
+
.filter(|child| child.kind() != "comment")
|
|
53
|
+
.last()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// The values a `return` yields. RuboCop reads them off the `return` node
|
|
57
|
+
/// itself, where a braceless trailing hash has already been folded into one
|
|
58
|
+
/// `hash` argument, while tree-sitter keeps its `pair`s separate.
|
|
59
|
+
fn return_arguments<'tree>(node: Node<'tree>) -> Vec<Node<'tree>> {
|
|
60
|
+
let Some(list) = node
|
|
61
|
+
.named_child(0)
|
|
62
|
+
.filter(|child| child.kind() == "argument_list")
|
|
63
|
+
else {
|
|
64
|
+
return Vec::new();
|
|
65
|
+
};
|
|
66
|
+
let mut cursor = list.walk();
|
|
67
|
+
list.named_children(&mut cursor).collect()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn braceless_hash(arguments: &[Node<'_>]) -> bool {
|
|
71
|
+
!arguments.is_empty() && arguments.iter().all(|argument| argument.kind() == "pair")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Mirrors RuboCop's autocorrection: an argument-less `return` becomes `nil`,
|
|
75
|
+
/// multiple values gain `[]`, a braceless hash gains `{}`, a leading splat is
|
|
76
|
+
/// unwrapped, and the keyword plus its trailing space goes away. Dropping the
|
|
77
|
+
/// keyword alone would leave `return a, b` as the syntax error `a, b`.
|
|
78
|
+
fn redundant_return_edit(
|
|
79
|
+
context: &RuleContext<'_>,
|
|
80
|
+
node: Node<'_>,
|
|
81
|
+
arguments: &[Node<'_>],
|
|
82
|
+
multiple_values: bool,
|
|
83
|
+
) -> Edit {
|
|
84
|
+
let (Some(first), Some(last)) = (arguments.first(), arguments.last()) else {
|
|
85
|
+
return Edit {
|
|
86
|
+
start: node.start_byte(),
|
|
87
|
+
end: node.end_byte(),
|
|
88
|
+
replacement: "nil".to_owned(),
|
|
89
|
+
safe: true,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
let wrapper = if multiple_values {
|
|
93
|
+
Some(('[', ']'))
|
|
94
|
+
} else if braceless_hash(arguments) {
|
|
95
|
+
Some(('{', '}'))
|
|
96
|
+
} else {
|
|
97
|
+
None
|
|
98
|
+
};
|
|
99
|
+
let splat = arguments
|
|
100
|
+
.iter()
|
|
101
|
+
.any(|argument| argument.kind() == "splat_argument");
|
|
102
|
+
|
|
103
|
+
let text = context.source.node_text(node);
|
|
104
|
+
let keyword_end = node.start_byte() + "return".len();
|
|
105
|
+
let whitespace_end = keyword_end
|
|
106
|
+
+ text["return".len()..]
|
|
107
|
+
.bytes()
|
|
108
|
+
.take_while(|byte| matches!(byte, b' ' | b'\t'))
|
|
109
|
+
.count();
|
|
110
|
+
if wrapper.is_none() && !splat {
|
|
111
|
+
return Edit {
|
|
112
|
+
start: node.start_byte(),
|
|
113
|
+
end: whitespace_end,
|
|
114
|
+
replacement: String::new(),
|
|
115
|
+
safe: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Rebuilt rather than spliced so that text the arguments do not cover -
|
|
120
|
+
// `return(1, 2)`'s parentheses - survives verbatim.
|
|
121
|
+
let mut replacement = String::new();
|
|
122
|
+
replacement.push_str(context.source.slice(whitespace_end..first.start_byte()));
|
|
123
|
+
if let Some((open, _)) = wrapper {
|
|
124
|
+
replacement.push(open);
|
|
125
|
+
}
|
|
126
|
+
let first_text = context.source.node_text(*first);
|
|
127
|
+
replacement.push_str(if splat {
|
|
128
|
+
first_text.strip_prefix('*').unwrap_or(first_text)
|
|
129
|
+
} else {
|
|
130
|
+
first_text
|
|
131
|
+
});
|
|
132
|
+
replacement.push_str(context.source.slice(first.end_byte()..last.end_byte()));
|
|
133
|
+
if let Some((_, close)) = wrapper {
|
|
134
|
+
replacement.push(close);
|
|
135
|
+
}
|
|
136
|
+
replacement.push_str(context.source.slice(last.end_byte()..node.end_byte()));
|
|
137
|
+
Edit {
|
|
138
|
+
start: node.start_byte(),
|
|
139
|
+
end: node.end_byte(),
|
|
140
|
+
replacement,
|
|
141
|
+
safe: true,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
use crate::diagnostic::{Edit, Offense};
|
|
2
|
+
use crate::rules::RuleContext;
|
|
3
|
+
use crate::source::is_protected;
|
|
4
|
+
|
|
5
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
6
|
+
let allow_as_expression_separator: bool = context
|
|
7
|
+
.setting("AllowAsExpressionSeparator")
|
|
8
|
+
.unwrap_or(false);
|
|
9
|
+
let ranges = context.protected_ranges();
|
|
10
|
+
let text = context.source.text();
|
|
11
|
+
let bytes = text.as_bytes();
|
|
12
|
+
for (index, byte) in bytes.iter().enumerate() {
|
|
13
|
+
if *byte != b';' || is_protected(index, ranges) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
let rest = &text[index + 1..];
|
|
17
|
+
let until_newline = rest.split_once('\n').map_or(rest, |(line, _)| line);
|
|
18
|
+
if until_newline.trim_start().starts_with("end") {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
let only_comment =
|
|
22
|
+
until_newline.trim().is_empty() || until_newline.trim_start().starts_with('#');
|
|
23
|
+
let before_on_line = text[..index]
|
|
24
|
+
.rsplit_once('\n')
|
|
25
|
+
.map_or(&text[..index], |(_, line)| line);
|
|
26
|
+
let adjacent_to_curly =
|
|
27
|
+
until_newline.trim_start().starts_with('}') || before_on_line.trim_end().ends_with('{');
|
|
28
|
+
if allow_as_expression_separator && !only_comment && !adjacent_to_curly {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let offense = context.offense(
|
|
32
|
+
"Do not use semicolons to terminate expressions.",
|
|
33
|
+
index..index + 1,
|
|
34
|
+
);
|
|
35
|
+
// Only a semicolon with nothing but a comment after it can be dropped outright; removing
|
|
36
|
+
// one that separates two expressions would join them into a single statement.
|
|
37
|
+
offenses.push(if only_comment {
|
|
38
|
+
offense.corrected_by(Edit {
|
|
39
|
+
start: index,
|
|
40
|
+
end: index + 1,
|
|
41
|
+
replacement: String::new(),
|
|
42
|
+
safe: true,
|
|
43
|
+
})
|
|
44
|
+
} else {
|
|
45
|
+
offense
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
use tree_sitter::Node;
|
|
2
|
+
|
|
3
|
+
use crate::diagnostic::{Edit, Offense};
|
|
4
|
+
use crate::rules::RuleContext;
|
|
5
|
+
|
|
6
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
7
|
+
let style: String = context
|
|
8
|
+
.setting("EnforcedStyle")
|
|
9
|
+
.unwrap_or_else(|| "single_quotes".to_owned());
|
|
10
|
+
for node in context.nodes_of("string") {
|
|
11
|
+
if inside_interpolation(node) || quoted_label_key(node, context) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
let text = context.source.node_text(node);
|
|
15
|
+
let (from, to, message) = if style == "single_quotes" {
|
|
16
|
+
(
|
|
17
|
+
'"',
|
|
18
|
+
'\'',
|
|
19
|
+
"Prefer single-quoted strings when you don't need string interpolation or special symbols.",
|
|
20
|
+
)
|
|
21
|
+
} else {
|
|
22
|
+
(
|
|
23
|
+
'\'',
|
|
24
|
+
'"',
|
|
25
|
+
"Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.",
|
|
26
|
+
)
|
|
27
|
+
};
|
|
28
|
+
if !text.starts_with(from) || !text.ends_with(from) || text.len() < 2 {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let content = &text[1..text.len() - 1];
|
|
32
|
+
if content.contains(to)
|
|
33
|
+
|| content.contains('\\')
|
|
34
|
+
|| content.contains("#{")
|
|
35
|
+
|| content.contains('\n')
|
|
36
|
+
{
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
let replacement = format!("{to}{content}{to}");
|
|
40
|
+
offenses.push(
|
|
41
|
+
context
|
|
42
|
+
.offense(message, node.byte_range())
|
|
43
|
+
.corrected_by(Edit {
|
|
44
|
+
start: node.start_byte(),
|
|
45
|
+
end: node.end_byte(),
|
|
46
|
+
replacement,
|
|
47
|
+
safe: true,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn inside_interpolation(mut node: Node<'_>) -> bool {
|
|
54
|
+
while let Some(parent) = node.parent() {
|
|
55
|
+
if parent.kind() == "interpolation" {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
node = parent;
|
|
59
|
+
}
|
|
60
|
+
false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// A quoted hash key such as `'a': 1` is a symbol rather than a string, so re-quoting it would
|
|
64
|
+
/// change what it means.
|
|
65
|
+
fn quoted_label_key(node: Node<'_>, context: &RuleContext<'_>) -> bool {
|
|
66
|
+
let Some(parent) = node.parent() else {
|
|
67
|
+
return false;
|
|
68
|
+
};
|
|
69
|
+
parent.kind() == "pair"
|
|
70
|
+
&& parent
|
|
71
|
+
.child_by_field_name("key")
|
|
72
|
+
.is_some_and(|key| key.byte_range() == node.byte_range())
|
|
73
|
+
&& context.source.text().as_bytes().get(node.end_byte()) == Some(&b':')
|
|
74
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//! Tree walks shared by cops in more than one department.
|
|
2
|
+
|
|
3
|
+
use tree_sitter::Node;
|
|
4
|
+
|
|
5
|
+
/// Pushes `node`'s named children so that popping the stack yields them in
|
|
6
|
+
/// source order, making a `pop`-driven loop reproduce depth-first pre-order.
|
|
7
|
+
pub(crate) fn push_named_children<'tree>(node: Node<'tree>, stack: &mut Vec<Node<'tree>>) {
|
|
8
|
+
let start = stack.len();
|
|
9
|
+
let mut cursor = node.walk();
|
|
10
|
+
stack.extend(node.named_children(&mut cursor));
|
|
11
|
+
stack[start..].reverse();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
pub(crate) fn walk_named(node: Node<'_>, callback: &mut impl FnMut(Node<'_>)) {
|
|
15
|
+
let mut stack = vec![node];
|
|
16
|
+
while let Some(current) = stack.pop() {
|
|
17
|
+
callback(current);
|
|
18
|
+
push_named_children(current, &mut stack);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
pub(crate) fn first_identifier(node: Node<'_>) -> Option<Node<'_>> {
|
|
23
|
+
let mut stack = vec![node];
|
|
24
|
+
while let Some(current) = stack.pop() {
|
|
25
|
+
if current.kind() == "identifier" {
|
|
26
|
+
return Some(current);
|
|
27
|
+
}
|
|
28
|
+
push_named_children(current, &mut stack);
|
|
29
|
+
}
|
|
30
|
+
None
|
|
31
|
+
}
|