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,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,6 @@
1
+ department_rules! {
2
+ "Security";
3
+ // `Security/Eval` inherits `Base#default_severity`, which is `:convention` for every
4
+ // department except `Lint`, and `config/default.yml` gives it no `Severity:` override.
5
+ eval => ("Eval", Convention),
6
+ }
@@ -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
+ }