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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/CONFORMANCE.md +109 -0
  3. data/Cargo.lock +975 -0
  4. data/Cargo.toml +40 -0
  5. data/LICENSE +21 -0
  6. data/NOTICE +7 -0
  7. data/README.ja.md +161 -0
  8. data/README.md +173 -0
  9. data/config/default.yml +6305 -0
  10. data/exe/sonicop +7 -0
  11. data/ext/sonicop/extconf.rb +38 -0
  12. data/lib/sonicop/runner.rb +67 -0
  13. data/lib/sonicop/version.rb +6 -0
  14. data/lib/sonicop.rb +8 -0
  15. data/licenses/RUBOCOP.txt +20 -0
  16. data/licenses/TREE_SITTER_RUBY.txt +21 -0
  17. data/src/cli.rs +940 -0
  18. data/src/config/inheritance.rs +332 -0
  19. data/src/config/loader.rs +117 -0
  20. data/src/config/mod.rs +461 -0
  21. data/src/config/paths.rs +436 -0
  22. data/src/config/plugin.rs +247 -0
  23. data/src/config/store.rs +141 -0
  24. data/src/cop_name.rs +71 -0
  25. data/src/diagnostic.rs +205 -0
  26. data/src/directives.rs +305 -0
  27. data/src/engine.rs +734 -0
  28. data/src/formatter.rs +684 -0
  29. data/src/lib.rs +42 -0
  30. data/src/main.rs +3 -0
  31. data/src/ruby_version.rs +393 -0
  32. data/src/rules/layout/empty_line_after_magic_comment.rs +49 -0
  33. data/src/rules/layout/end_of_line.rs +35 -0
  34. data/src/rules/layout/line_length.rs +167 -0
  35. data/src/rules/layout/mod.rs +13 -0
  36. data/src/rules/layout/space_after_comma.rs +32 -0
  37. data/src/rules/layout/space_around_operators.rs +104 -0
  38. data/src/rules/layout/space_inside_parens.rs +100 -0
  39. data/src/rules/layout/support.rs +23 -0
  40. data/src/rules/layout/trailing_empty_lines.rs +38 -0
  41. data/src/rules/layout/trailing_whitespace.rs +26 -0
  42. data/src/rules/lint/duplicate_methods.rs +72 -0
  43. data/src/rules/lint/mod.rs +7 -0
  44. data/src/rules/lint/syntax.rs +200 -0
  45. data/src/rules/lint/unused_block_argument.rs +74 -0
  46. data/src/rules/lint/useless_assignment.rs +104 -0
  47. data/src/rules/metrics/block_length.rs +39 -0
  48. data/src/rules/metrics/class_length.rs +34 -0
  49. data/src/rules/metrics/method_length.rs +10 -0
  50. data/src/rules/metrics/mod.rs +10 -0
  51. data/src/rules/metrics/module_length.rs +17 -0
  52. data/src/rules/metrics/parameter_lists.rs +21 -0
  53. data/src/rules/metrics/support.rs +105 -0
  54. data/src/rules/mod.rs +380 -0
  55. data/src/rules/naming/ascii_identifiers.rs +17 -0
  56. data/src/rules/naming/constant_name.rs +49 -0
  57. data/src/rules/naming/method_name.rs +51 -0
  58. data/src/rules/naming/mod.rs +9 -0
  59. data/src/rules/naming/support.rs +22 -0
  60. data/src/rules/naming/variable_name.rs +50 -0
  61. data/src/rules/security/eval.rs +170 -0
  62. data/src/rules/security/mod.rs +6 -0
  63. data/src/rules/style/frozen_string_literal_comment.rs +56 -0
  64. data/src/rules/style/hash_syntax.rs +63 -0
  65. data/src/rules/style/mod.rs +9 -0
  66. data/src/rules/style/numeric_literals.rs +59 -0
  67. data/src/rules/style/redundant_return.rs +143 -0
  68. data/src/rules/style/semicolon.rs +48 -0
  69. data/src/rules/style/string_literals.rs +74 -0
  70. data/src/rules/support.rs +31 -0
  71. data/src/source.rs +118 -0
  72. 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,7 @@
1
+ department_rules! {
2
+ "Lint";
3
+ duplicate_methods => ("DuplicateMethods", Warning),
4
+ syntax => ("Syntax", Fatal),
5
+ unused_block_argument => ("UnusedBlockArgument", Warning),
6
+ useless_assignment => ("UselessAssignment", Warning),
7
+ }
@@ -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
+ }