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,13 @@
|
|
|
1
|
+
department_rules! {
|
|
2
|
+
"Layout";
|
|
3
|
+
empty_line_after_magic_comment => ("EmptyLineAfterMagicComment", Convention),
|
|
4
|
+
end_of_line => ("EndOfLine", Convention),
|
|
5
|
+
line_length => ("LineLength", Convention),
|
|
6
|
+
space_after_comma => ("SpaceAfterComma", Convention),
|
|
7
|
+
space_around_operators => ("SpaceAroundOperators", Convention),
|
|
8
|
+
space_inside_parens => ("SpaceInsideParens", Convention),
|
|
9
|
+
trailing_empty_lines => ("TrailingEmptyLines", Convention),
|
|
10
|
+
trailing_whitespace => ("TrailingWhitespace", Convention),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
mod support;
|
|
@@ -0,0 +1,32 @@
|
|
|
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 ranges = context.protected_ranges();
|
|
7
|
+
let bytes = context.source.text().as_bytes();
|
|
8
|
+
for index in 0..bytes.len() {
|
|
9
|
+
if bytes[index] != b',' || is_protected(index, ranges) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
let next = bytes.get(index + 1).copied();
|
|
13
|
+
if next.is_none_or(|byte| {
|
|
14
|
+
matches!(
|
|
15
|
+
byte,
|
|
16
|
+
b' ' | b'\t' | b'\r' | b'\n' | b')' | b']' | b'}' | b'|'
|
|
17
|
+
)
|
|
18
|
+
}) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
offenses.push(
|
|
22
|
+
context
|
|
23
|
+
.offense("Space missing after comma.", index..index + 1)
|
|
24
|
+
.corrected_by(Edit {
|
|
25
|
+
start: index + 1,
|
|
26
|
+
end: index + 1,
|
|
27
|
+
replacement: " ".to_owned(),
|
|
28
|
+
safe: true,
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
use tree_sitter::Node;
|
|
2
|
+
|
|
3
|
+
use super::support::{whitespace_after, whitespace_before};
|
|
4
|
+
use crate::diagnostic::{Edit, Offense};
|
|
5
|
+
use crate::rules::RuleContext;
|
|
6
|
+
|
|
7
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
8
|
+
for node in context.nodes_of_any(&["binary", "assignment", "operator_assignment"]) {
|
|
9
|
+
match node.kind() {
|
|
10
|
+
"binary" => {
|
|
11
|
+
let Some(operator) = node.child_by_field_name("operator") else {
|
|
12
|
+
continue;
|
|
13
|
+
};
|
|
14
|
+
let text = context.source.node_text(operator);
|
|
15
|
+
if matches!(text, "+" | "-")
|
|
16
|
+
&& node
|
|
17
|
+
.child_by_field_name("left")
|
|
18
|
+
.is_some_and(|left| matches!(left.kind(), "return" | "break" | "next"))
|
|
19
|
+
{
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
let require_space = text != "**";
|
|
23
|
+
check_operator(context, offenses, operator, require_space);
|
|
24
|
+
}
|
|
25
|
+
_ => {
|
|
26
|
+
if let Some(operator) = operator_child(node) {
|
|
27
|
+
check_operator(context, offenses, operator, true);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn operator_child(node: Node<'_>) -> Option<Node<'_>> {
|
|
35
|
+
let left = node.child_by_field_name("left")?;
|
|
36
|
+
let right = node.child_by_field_name("right")?;
|
|
37
|
+
let mut cursor = node.walk();
|
|
38
|
+
node.children(&mut cursor).find(|child| {
|
|
39
|
+
child.start_byte() >= left.end_byte() && child.end_byte() <= right.start_byte()
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn check_operator(
|
|
44
|
+
context: &RuleContext<'_>,
|
|
45
|
+
offenses: &mut Vec<Offense>,
|
|
46
|
+
operator: Node<'_>,
|
|
47
|
+
require_space: bool,
|
|
48
|
+
) {
|
|
49
|
+
if context.in_heredoc(operator.byte_range()) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let source = context.source.text();
|
|
53
|
+
let before = whitespace_before(source, operator.start_byte());
|
|
54
|
+
let after = whitespace_after(source, operator.end_byte());
|
|
55
|
+
let operator_text = context.source.node_text(operator);
|
|
56
|
+
if operator_text == "=" && source.as_bytes().get(operator.end_byte()) == Some(&b'~') {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let touches_line_break = operator
|
|
60
|
+
.start_byte()
|
|
61
|
+
.checked_sub(1)
|
|
62
|
+
.and_then(|index| source.as_bytes().get(index))
|
|
63
|
+
.is_some_and(|byte| matches!(byte, b'\r' | b'\n'))
|
|
64
|
+
|| source
|
|
65
|
+
.as_bytes()
|
|
66
|
+
.get(operator.end_byte())
|
|
67
|
+
.is_some_and(|byte| matches!(byte, b'\r' | b'\n'));
|
|
68
|
+
let alignment_allowed: bool = context.setting("AllowForAlignment").unwrap_or(true);
|
|
69
|
+
if touches_line_break || (alignment_allowed && (before.len() > 1 || after.len() > 1)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let correct = if require_space {
|
|
73
|
+
before.len() == 1
|
|
74
|
+
&& &source[before.clone()] == " "
|
|
75
|
+
&& after.len() == 1
|
|
76
|
+
&& &source[after.clone()] == " "
|
|
77
|
+
} else {
|
|
78
|
+
before.is_empty() && after.is_empty()
|
|
79
|
+
};
|
|
80
|
+
if correct {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let message = if require_space {
|
|
85
|
+
format!("Surrounding space missing for operator `{operator_text}`.")
|
|
86
|
+
} else {
|
|
87
|
+
format!("Space around operator `{operator_text}` detected.")
|
|
88
|
+
};
|
|
89
|
+
let replacement = if require_space {
|
|
90
|
+
format!(" {operator_text} ")
|
|
91
|
+
} else {
|
|
92
|
+
operator_text.to_owned()
|
|
93
|
+
};
|
|
94
|
+
offenses.push(
|
|
95
|
+
context
|
|
96
|
+
.offense(message, operator.start_byte()..operator.end_byte())
|
|
97
|
+
.corrected_by(Edit {
|
|
98
|
+
start: before.start,
|
|
99
|
+
end: after.end,
|
|
100
|
+
replacement,
|
|
101
|
+
safe: true,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
use std::ops::Range;
|
|
3
|
+
|
|
4
|
+
use super::support::{whitespace_after, whitespace_before};
|
|
5
|
+
use crate::diagnostic::{Edit, Offense};
|
|
6
|
+
use crate::rules::RuleContext;
|
|
7
|
+
use crate::source::is_protected;
|
|
8
|
+
|
|
9
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
10
|
+
let ranges = context.protected_ranges();
|
|
11
|
+
let text = context.source.text();
|
|
12
|
+
let bytes = text.as_bytes();
|
|
13
|
+
let percent_literal_parens = percent_literal_parens(text, ranges);
|
|
14
|
+
for index in 0..bytes.len() {
|
|
15
|
+
if is_protected(index, ranges) || percent_literal_parens.contains(&index) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
match bytes[index] {
|
|
19
|
+
b'(' => {
|
|
20
|
+
let spaces = whitespace_after(text, index + 1);
|
|
21
|
+
if !spaces.is_empty()
|
|
22
|
+
&& bytes.get(spaces.end) != Some(&b')')
|
|
23
|
+
&& bytes.get(spaces.end) != Some(&b'#')
|
|
24
|
+
{
|
|
25
|
+
offenses.push(paren_space_offense(context, spaces));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
b')' => {
|
|
29
|
+
let spaces = whitespace_before(text, index);
|
|
30
|
+
let starts_after_line_break =
|
|
31
|
+
spaces.start > 0 && matches!(bytes[spaces.start - 1], b'\r' | b'\n');
|
|
32
|
+
if !spaces.is_empty()
|
|
33
|
+
&& !starts_after_line_break
|
|
34
|
+
&& bytes.get(spaces.start.wrapping_sub(1)) != Some(&b'(')
|
|
35
|
+
{
|
|
36
|
+
offenses.push(paren_space_offense(context, spaces));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
_ => {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn percent_literal_parens(text: &str, protected: &[Range<usize>]) -> HashSet<usize> {
|
|
45
|
+
let bytes = text.as_bytes();
|
|
46
|
+
let mut parens = HashSet::new();
|
|
47
|
+
let mut index = 0;
|
|
48
|
+
while index < bytes.len() {
|
|
49
|
+
if bytes[index] != b'%' || is_protected(index, protected) {
|
|
50
|
+
index += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let opening = if bytes.get(index + 1) == Some(&b'(') {
|
|
54
|
+
index + 1
|
|
55
|
+
} else if bytes.get(index + 1).is_some_and(u8::is_ascii_alphabetic)
|
|
56
|
+
&& bytes.get(index + 2) == Some(&b'(')
|
|
57
|
+
{
|
|
58
|
+
index + 2
|
|
59
|
+
} else {
|
|
60
|
+
index += 1;
|
|
61
|
+
continue;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
let mut depth = 1;
|
|
65
|
+
let mut cursor = opening + 1;
|
|
66
|
+
while cursor < bytes.len() {
|
|
67
|
+
if bytes[cursor] == b'\\' {
|
|
68
|
+
cursor = (cursor + 2).min(bytes.len());
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
match bytes[cursor] {
|
|
72
|
+
b'(' => depth += 1,
|
|
73
|
+
b')' => {
|
|
74
|
+
depth -= 1;
|
|
75
|
+
if depth == 0 {
|
|
76
|
+
parens.insert(opening);
|
|
77
|
+
parens.insert(cursor);
|
|
78
|
+
index = cursor;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
_ => {}
|
|
83
|
+
}
|
|
84
|
+
cursor += 1;
|
|
85
|
+
}
|
|
86
|
+
index += 1;
|
|
87
|
+
}
|
|
88
|
+
parens
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fn paren_space_offense(context: &RuleContext<'_>, spaces: Range<usize>) -> Offense {
|
|
92
|
+
context
|
|
93
|
+
.offense("Space inside parentheses detected.", spaces.clone())
|
|
94
|
+
.corrected_by(Edit {
|
|
95
|
+
start: spaces.start,
|
|
96
|
+
end: spaces.end,
|
|
97
|
+
replacement: String::new(),
|
|
98
|
+
safe: true,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//! Horizontal whitespace scanning shared by the spacing cops.
|
|
2
|
+
|
|
3
|
+
use std::ops::Range;
|
|
4
|
+
|
|
5
|
+
/// The run of spaces and tabs ending at `offset`.
|
|
6
|
+
pub(super) fn whitespace_before(source: &str, offset: usize) -> Range<usize> {
|
|
7
|
+
let bytes = source.as_bytes();
|
|
8
|
+
let mut start = offset;
|
|
9
|
+
while start > 0 && matches!(bytes[start - 1], b' ' | b'\t') {
|
|
10
|
+
start -= 1;
|
|
11
|
+
}
|
|
12
|
+
start..offset
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// The run of spaces and tabs starting at `offset`.
|
|
16
|
+
pub(super) fn whitespace_after(source: &str, offset: usize) -> Range<usize> {
|
|
17
|
+
let bytes = source.as_bytes();
|
|
18
|
+
let mut end = offset;
|
|
19
|
+
while end < bytes.len() && matches!(bytes[end], b' ' | b'\t') {
|
|
20
|
+
end += 1;
|
|
21
|
+
}
|
|
22
|
+
offset..end
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 text = context.source.text();
|
|
6
|
+
if text.is_empty() {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
let style: String = context
|
|
10
|
+
.setting("EnforcedStyle")
|
|
11
|
+
.unwrap_or_else(|| "final_newline".to_owned());
|
|
12
|
+
let expected_newlines = usize::from(style == "final_blank_line") + 1;
|
|
13
|
+
let without_newlines = text.trim_end_matches(['\r', '\n']);
|
|
14
|
+
let actual_start = without_newlines.len();
|
|
15
|
+
let actual = &text[actual_start..];
|
|
16
|
+
// Only `\n` is counted, as RuboCop does. A carriage return is `Layout/EndOfLine`'s business;
|
|
17
|
+
// reporting CRLF here as well would make the two cops rewrite the same bytes in opposite
|
|
18
|
+
// directions on Windows, where the expected ending is CRLF.
|
|
19
|
+
let newline_count = actual.bytes().filter(|byte| *byte == b'\n').count();
|
|
20
|
+
if newline_count == expected_newlines {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
let message = if newline_count < expected_newlines {
|
|
24
|
+
"Final newline missing."
|
|
25
|
+
} else {
|
|
26
|
+
"Extra blank line detected at file end."
|
|
27
|
+
};
|
|
28
|
+
offenses.push(
|
|
29
|
+
context
|
|
30
|
+
.offense(message, actual_start.saturating_sub(1)..text.len())
|
|
31
|
+
.corrected_by(Edit {
|
|
32
|
+
start: actual_start,
|
|
33
|
+
end: text.len(),
|
|
34
|
+
replacement: "\n".repeat(expected_newlines),
|
|
35
|
+
safe: true,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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 text = context.source.text();
|
|
6
|
+
for line_number in 1..=context.source.line_count() {
|
|
7
|
+
let range = context.source.line_range(line_number);
|
|
8
|
+
let line = &text[range.clone()];
|
|
9
|
+
let content_end = line.trim_end_matches(['\r', '\n']).len();
|
|
10
|
+
let trimmed_end = line[..content_end].trim_end_matches([' ', '\t']).len();
|
|
11
|
+
if trimmed_end < content_end {
|
|
12
|
+
let start = range.start + trimmed_end;
|
|
13
|
+
let end = range.start + content_end;
|
|
14
|
+
offenses.push(
|
|
15
|
+
context
|
|
16
|
+
.offense("Trailing whitespace detected.", start..end)
|
|
17
|
+
.corrected_by(Edit {
|
|
18
|
+
start,
|
|
19
|
+
end,
|
|
20
|
+
replacement: String::new(),
|
|
21
|
+
safe: true,
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
|
|
3
|
+
use tree_sitter::Node;
|
|
4
|
+
|
|
5
|
+
use crate::diagnostic::Offense;
|
|
6
|
+
use crate::rules::{RuleContext, push_named_children};
|
|
7
|
+
|
|
8
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
9
|
+
inspect_method_scope(context.root_node(), context, offenses);
|
|
10
|
+
for node in context.nodes_of_any(&["class", "module", "singleton_class"]) {
|
|
11
|
+
inspect_method_scope(node, context, offenses);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
fn inspect_method_scope(scope: Node<'_>, context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
16
|
+
let mut methods: HashMap<(bool, String), usize> = HashMap::new();
|
|
17
|
+
collect_scope_methods(scope, scope, &mut |method| {
|
|
18
|
+
if inside_ignored_method_context(method, scope) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
let Some(name) = method.child_by_field_name("name") else {
|
|
22
|
+
return;
|
|
23
|
+
};
|
|
24
|
+
let singleton = method.kind() == "singleton_method";
|
|
25
|
+
let key = (singleton, context.source.node_text(name).to_owned());
|
|
26
|
+
if let Some(first_line) = methods.insert(key.clone(), name.start_position().row + 1) {
|
|
27
|
+
offenses.push(context.offense(
|
|
28
|
+
format!(
|
|
29
|
+
"Method `{}` is defined at both line {first_line} and line {}.",
|
|
30
|
+
key.1,
|
|
31
|
+
name.start_position().row + 1
|
|
32
|
+
),
|
|
33
|
+
name.byte_range(),
|
|
34
|
+
));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fn inside_ignored_method_context(mut node: Node<'_>, scope: Node<'_>) -> bool {
|
|
40
|
+
while let Some(parent) = node.parent() {
|
|
41
|
+
if parent == scope {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if matches!(
|
|
45
|
+
parent.kind(),
|
|
46
|
+
"block" | "do_block" | "if" | "unless" | "if_modifier" | "unless_modifier" | "rescue"
|
|
47
|
+
) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
node = parent;
|
|
51
|
+
}
|
|
52
|
+
false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fn collect_scope_methods<'tree>(
|
|
56
|
+
node: Node<'tree>,
|
|
57
|
+
root: Node<'tree>,
|
|
58
|
+
callback: &mut impl FnMut(Node<'tree>),
|
|
59
|
+
) {
|
|
60
|
+
let mut stack = Vec::new();
|
|
61
|
+
push_named_children(node, &mut stack);
|
|
62
|
+
while let Some(current) = stack.pop() {
|
|
63
|
+
if current != root && matches!(current.kind(), "class" | "module" | "singleton_class") {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if matches!(current.kind(), "method" | "singleton_method") {
|
|
67
|
+
callback(current);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
push_named_children(current, &mut stack);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
use tree_sitter::Node;
|
|
2
|
+
|
|
3
|
+
use crate::diagnostic::Offense;
|
|
4
|
+
use crate::ruby_version::RubyVersion;
|
|
5
|
+
use crate::rules::RuleContext;
|
|
6
|
+
|
|
7
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
8
|
+
enum SyntaxFeature {
|
|
9
|
+
BeginlessRange,
|
|
10
|
+
ArgumentForwarding,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
#[derive(Clone, Copy, Debug)]
|
|
14
|
+
struct SyntaxFeatureSpec {
|
|
15
|
+
feature: SyntaxFeature,
|
|
16
|
+
available_since: RubyVersion,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SYNTAX_FEATURES: &[SyntaxFeatureSpec] = &[
|
|
20
|
+
SyntaxFeatureSpec {
|
|
21
|
+
feature: SyntaxFeature::BeginlessRange,
|
|
22
|
+
available_since: RubyVersion::new(2, 7),
|
|
23
|
+
},
|
|
24
|
+
SyntaxFeatureSpec {
|
|
25
|
+
feature: SyntaxFeature::ArgumentForwarding,
|
|
26
|
+
available_since: RubyVersion::new(2, 7),
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
31
|
+
if context.root_node().has_error() {
|
|
32
|
+
for node in context.nodes() {
|
|
33
|
+
let nested_error = node.parent().is_some_and(|parent| parent.is_error());
|
|
34
|
+
if (!node.is_error() && !node.is_missing()) || nested_error {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
let token = context.source.node_text(node).trim();
|
|
38
|
+
let message = if node.is_missing() {
|
|
39
|
+
format!("unexpected end-of-input; expected {}", node.kind())
|
|
40
|
+
} else if token.is_empty() {
|
|
41
|
+
"unexpected token".to_owned()
|
|
42
|
+
} else {
|
|
43
|
+
let display: String = token.chars().take(24).collect();
|
|
44
|
+
format!("unexpected token `{display}`")
|
|
45
|
+
};
|
|
46
|
+
offenses.push(
|
|
47
|
+
context.offense(
|
|
48
|
+
syntax_message(&message, context.target_ruby_version()),
|
|
49
|
+
node.start_byte()
|
|
50
|
+
..node
|
|
51
|
+
.end_byte()
|
|
52
|
+
.max(node.start_byte() + usize::from(!context.source.is_empty())),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
version_gated_syntax(context, offenses);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fn version_gated_syntax(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
|
|
61
|
+
let target = context.target_ruby_version();
|
|
62
|
+
for node in context.nodes() {
|
|
63
|
+
let Some((feature, start, end, token_name)) = feature_use(node, context) else {
|
|
64
|
+
continue;
|
|
65
|
+
};
|
|
66
|
+
let Some(spec) = SYNTAX_FEATURES.iter().find(|spec| spec.feature == feature) else {
|
|
67
|
+
continue;
|
|
68
|
+
};
|
|
69
|
+
if target >= spec.available_since {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
offenses.push(context.offense(
|
|
73
|
+
syntax_message(&format!("unexpected token {token_name}"), target),
|
|
74
|
+
start..end,
|
|
75
|
+
));
|
|
76
|
+
if feature == SyntaxFeature::ArgumentForwarding && node.kind() == "forward_parameter" {
|
|
77
|
+
legacy_forwarding_recovery(node, context, offenses);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn feature_use(
|
|
83
|
+
node: Node<'_>,
|
|
84
|
+
context: &RuleContext<'_>,
|
|
85
|
+
) -> Option<(SyntaxFeature, usize, usize, &'static str)> {
|
|
86
|
+
match node.kind() {
|
|
87
|
+
"range" if node.child_by_field_name("begin").is_none() => {
|
|
88
|
+
let text = context.source.node_text(node);
|
|
89
|
+
if text.starts_with("...") {
|
|
90
|
+
Some((
|
|
91
|
+
SyntaxFeature::BeginlessRange,
|
|
92
|
+
node.start_byte(),
|
|
93
|
+
node.start_byte() + 3,
|
|
94
|
+
"tDOT3",
|
|
95
|
+
))
|
|
96
|
+
} else if text.starts_with("..") {
|
|
97
|
+
Some((
|
|
98
|
+
SyntaxFeature::BeginlessRange,
|
|
99
|
+
node.start_byte(),
|
|
100
|
+
node.start_byte() + 2,
|
|
101
|
+
"tDOT2",
|
|
102
|
+
))
|
|
103
|
+
} else {
|
|
104
|
+
None
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
"forward_parameter" | "forward_argument" => Some((
|
|
108
|
+
SyntaxFeature::ArgumentForwarding,
|
|
109
|
+
node.start_byte(),
|
|
110
|
+
node.end_byte(),
|
|
111
|
+
"tDOT3",
|
|
112
|
+
)),
|
|
113
|
+
_ => None,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn legacy_forwarding_recovery(
|
|
118
|
+
parameter: Node<'_>,
|
|
119
|
+
context: &RuleContext<'_>,
|
|
120
|
+
offenses: &mut Vec<Offense>,
|
|
121
|
+
) {
|
|
122
|
+
let Some(method) = ancestor_matching(parameter, |node| {
|
|
123
|
+
matches!(node.kind(), "method" | "singleton_method")
|
|
124
|
+
}) else {
|
|
125
|
+
return;
|
|
126
|
+
};
|
|
127
|
+
let Some(container) =
|
|
128
|
+
ancestor_matching(method, |node| matches!(node.kind(), "class" | "module"))
|
|
129
|
+
else {
|
|
130
|
+
return;
|
|
131
|
+
};
|
|
132
|
+
let Some(body) = container.child_by_field_name("body") else {
|
|
133
|
+
return;
|
|
134
|
+
};
|
|
135
|
+
let later_nodes = significant_named_children(body)
|
|
136
|
+
.filter(|node| node.start_byte() >= method.end_byte())
|
|
137
|
+
.collect::<Vec<_>>();
|
|
138
|
+
if later_nodes.is_empty() {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let (keyword, reason) = if container.kind() == "class" {
|
|
143
|
+
("class", "class definition in method body")
|
|
144
|
+
} else {
|
|
145
|
+
("module", "module definition in method body")
|
|
146
|
+
};
|
|
147
|
+
offenses.push(context.offense(
|
|
148
|
+
syntax_message(reason, context.target_ruby_version()),
|
|
149
|
+
container.start_byte()..container.start_byte() + keyword.len(),
|
|
150
|
+
));
|
|
151
|
+
|
|
152
|
+
let has_preceding_top_level_statement =
|
|
153
|
+
std::iter::successors(container.prev_named_sibling(), |node| {
|
|
154
|
+
node.prev_named_sibling()
|
|
155
|
+
})
|
|
156
|
+
.any(|node| node.kind() != "comment");
|
|
157
|
+
let later_nonempty_method = later_nodes.iter().any(|node| {
|
|
158
|
+
matches!(node.kind(), "method" | "singleton_method")
|
|
159
|
+
&& node
|
|
160
|
+
.child_by_field_name("body")
|
|
161
|
+
.is_some_and(|body| significant_named_children(body).next().is_some())
|
|
162
|
+
});
|
|
163
|
+
if container.kind() != "module" || !has_preceding_top_level_statement || !later_nonempty_method
|
|
164
|
+
{
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let end = container.end_byte();
|
|
169
|
+
let start = end.saturating_sub(3);
|
|
170
|
+
if context.source.slice(start..end) == "end" {
|
|
171
|
+
offenses.push(context.offense(
|
|
172
|
+
syntax_message("unexpected token kEND", context.target_ruby_version()),
|
|
173
|
+
start..end,
|
|
174
|
+
));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn significant_named_children(node: Node<'_>) -> impl Iterator<Item = Node<'_>> {
|
|
179
|
+
let mut cursor = node.walk();
|
|
180
|
+
node.named_children(&mut cursor)
|
|
181
|
+
.filter(|child| child.kind() != "comment")
|
|
182
|
+
.collect::<Vec<_>>()
|
|
183
|
+
.into_iter()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn ancestor_matching(mut node: Node<'_>, predicate: impl Fn(Node<'_>) -> bool) -> Option<Node<'_>> {
|
|
187
|
+
while let Some(parent) = node.parent() {
|
|
188
|
+
if predicate(parent) {
|
|
189
|
+
return Some(parent);
|
|
190
|
+
}
|
|
191
|
+
node = parent;
|
|
192
|
+
}
|
|
193
|
+
None
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fn syntax_message(reason: &str, target: RubyVersion) -> String {
|
|
197
|
+
format!(
|
|
198
|
+
"{reason}\n(Using Ruby {target} parser; configure using `TargetRubyVersion` parameter, under `AllCops`)"
|
|
199
|
+
)
|
|
200
|
+
}
|