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
data/src/rules/mod.rs ADDED
@@ -0,0 +1,380 @@
1
+ use std::collections::HashMap;
2
+ use std::ops::Range;
3
+
4
+ use tree_sitter::Node;
5
+
6
+ use crate::config::Config;
7
+ use crate::diagnostic::{Offense, Severity};
8
+ use crate::ruby_version::RubyVersion;
9
+ use crate::source::SourceFile;
10
+
11
+ /// Registers one department's cops. Each entry names the module the cop lives in, the cop's own
12
+ /// name within the department, and the severity it reports at unless the configuration overrides
13
+ /// it.
14
+ ///
15
+ /// This is the department's only source of truth: the module declaration, the qualified cop name
16
+ /// and the default severity all come from the single line here, so a cop file never repeats its
17
+ /// own name. A cop that spelled its name a second time could disagree with the registry, and
18
+ /// nothing in the type system would catch it -- the offense would simply be attributed to a cop
19
+ /// that never ran, and directives and severity overrides would both consult the wrong entry.
20
+ macro_rules! department_rules {
21
+ ($department:literal; $($module:ident => ($cop:literal, $severity:ident)),+ $(,)?) => {
22
+ $(mod $module;)+
23
+
24
+ pub(crate) static RULES: &[$crate::rules::Rule] = &[
25
+ $($crate::rules::Rule::new(
26
+ concat!($department, "/", $cop),
27
+ $crate::diagnostic::Severity::$severity,
28
+ $module::check,
29
+ ),)+
30
+ ];
31
+ };
32
+ }
33
+
34
+ mod layout;
35
+ mod lint;
36
+ mod metrics;
37
+ mod naming;
38
+ mod security;
39
+ mod style;
40
+ mod support;
41
+
42
+ pub(crate) use support::{first_identifier, push_named_children, walk_named};
43
+
44
+ /// A cop: its qualified name, the severity it reports at by default, and the function that
45
+ /// inspects one file.
46
+ #[derive(Clone, Copy)]
47
+ pub(crate) struct Rule {
48
+ pub name: &'static str,
49
+ pub severity: Severity,
50
+ pub check: fn(&RuleContext<'_>, &mut Vec<Offense>),
51
+ }
52
+
53
+ impl Rule {
54
+ pub(crate) const fn new(
55
+ name: &'static str,
56
+ severity: Severity,
57
+ check: fn(&RuleContext<'_>, &mut Vec<Offense>),
58
+ ) -> Self {
59
+ Self {
60
+ name,
61
+ severity,
62
+ check,
63
+ }
64
+ }
65
+ }
66
+
67
+ /// Every department's registry, in the order cops run. Offenses are sorted before they are
68
+ /// reported, so this order is not user-visible; it only has to stay deterministic.
69
+ static RULE_GROUPS: &[&[Rule]] = &[
70
+ layout::RULES,
71
+ lint::RULES,
72
+ metrics::RULES,
73
+ naming::RULES,
74
+ security::RULES,
75
+ style::RULES,
76
+ ];
77
+
78
+ pub(crate) fn rules() -> impl Iterator<Item = &'static Rule> {
79
+ RULE_GROUPS.iter().copied().flatten()
80
+ }
81
+
82
+ pub fn rule_names() -> impl Iterator<Item = &'static str> {
83
+ rules().map(|rule| rule.name)
84
+ }
85
+
86
+ /// What one cop sees of one file: the source, the indexed syntax tree, and the configuration
87
+ /// resolved for that cop.
88
+ ///
89
+ /// The cop's identity lives here rather than in the cop's own code, so [`Self::setting`] and
90
+ /// [`Self::offense`] address the right configuration key and stamp the right name without the cop
91
+ /// ever naming itself.
92
+ pub(crate) struct RuleContext<'a> {
93
+ pub source: &'a SourceFile,
94
+ ast: &'a AstIndex<'a>,
95
+ config: &'a Config,
96
+ rule: &'static Rule,
97
+ /// `rule.severity` unless the configuration overrode it for this cop.
98
+ severity: Severity,
99
+ }
100
+
101
+ impl<'a> RuleContext<'a> {
102
+ pub(crate) fn new(
103
+ source: &'a SourceFile,
104
+ ast: &'a AstIndex<'a>,
105
+ config: &'a Config,
106
+ rule: &'static Rule,
107
+ severity: Severity,
108
+ ) -> Self {
109
+ Self {
110
+ source,
111
+ ast,
112
+ config,
113
+ rule,
114
+ severity,
115
+ }
116
+ }
117
+ }
118
+
119
+ impl RuleContext<'_> {
120
+ /// One of the cop's own configuration parameters, such as `Max` or `EnforcedStyle`.
121
+ pub fn setting<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
122
+ self.config.cop_value(self.rule.name, key)
123
+ }
124
+
125
+ /// The Ruby version the run analyzes as, which version-gated cops compare against.
126
+ pub fn target_ruby_version(&self) -> RubyVersion {
127
+ self.config.target_ruby_version()
128
+ }
129
+
130
+ /// Reports `range` under this cop's name and severity. Every cop offense is built here.
131
+ pub fn offense(&self, message: impl Into<String>, range: Range<usize>) -> Offense {
132
+ Offense::new(
133
+ self.rule.name,
134
+ self.severity,
135
+ message,
136
+ range.start,
137
+ range.end,
138
+ )
139
+ }
140
+
141
+ pub fn root_node(&self) -> Node<'_> {
142
+ self.ast.root
143
+ }
144
+
145
+ pub fn nodes(&self) -> impl Iterator<Item = Node<'_>> + '_ {
146
+ self.ast.nodes.iter().copied()
147
+ }
148
+
149
+ /// The named nodes of one kind, in source order. A cop that inspects a single kind should
150
+ /// reach for this rather than filtering every node in the file: with hundreds of cops running
151
+ /// per file, a full walk each is what turns inspection quadratic.
152
+ pub fn nodes_of(&self, kind: &str) -> impl Iterator<Item = Node<'_>> + '_ {
153
+ self.ast
154
+ .of_kind(kind)
155
+ .map(|index| self.ast.named_node(index))
156
+ }
157
+
158
+ /// The named nodes of any of `kinds`, in source order. The kinds are indexed separately, so
159
+ /// their positions have to be merged to put the nodes back in the order a cop that scans the
160
+ /// whole file would have seen them in.
161
+ pub fn nodes_of_any(&self, kinds: &[&str]) -> impl Iterator<Item = Node<'_>> + '_ {
162
+ let mut indices: Vec<u32> = kinds
163
+ .iter()
164
+ .flat_map(|kind| self.ast.of_kind(kind))
165
+ .collect();
166
+ indices.sort_unstable();
167
+ indices.into_iter().map(|index| self.ast.named_node(index))
168
+ }
169
+
170
+ pub fn protected_ranges(&self) -> &[Range<usize>] {
171
+ &self.ast.protected_ranges
172
+ }
173
+
174
+ pub fn in_heredoc(&self, range: Range<usize>) -> bool {
175
+ self.heredoc_count(range) > 0
176
+ }
177
+
178
+ pub fn heredoc_count(&self, range: Range<usize>) -> usize {
179
+ self.ast
180
+ .heredoc_ranges
181
+ .iter()
182
+ .filter(|heredoc| heredoc.start < range.end && range.start < heredoc.end)
183
+ .count()
184
+ }
185
+ }
186
+
187
+ /// Node kinds whose byte range spans literal text rather than code. The
188
+ /// byte-scanning cops (`Style/Semicolon`, `Layout/SpaceAfterComma`,
189
+ /// `Layout/SpaceInsideParens`) must not report punctuation found inside them.
190
+ const PROTECTED_LITERAL_KINDS: &[&str] = &[
191
+ "comment",
192
+ "string",
193
+ "symbol",
194
+ "simple_symbol",
195
+ "heredoc_body",
196
+ "regex",
197
+ "subshell",
198
+ "bare_string",
199
+ ];
200
+
201
+ pub(crate) struct AstIndex<'tree> {
202
+ root: Node<'tree>,
203
+ nodes: Vec<Node<'tree>>,
204
+ named_nodes: Vec<Node<'tree>>,
205
+ /// Positions in `named_nodes` grouped by node kind, each list in source order. Indices rather
206
+ /// than nodes because a `Node` is eight times the size of the `u32` that finds it.
207
+ by_kind: HashMap<&'static str, Vec<u32>>,
208
+ protected_ranges: Vec<Range<usize>>,
209
+ heredoc_ranges: Vec<Range<usize>>,
210
+ comment_ranges: Vec<Range<usize>>,
211
+ }
212
+
213
+ impl<'tree> AstIndex<'tree> {
214
+ pub fn new(root: Node<'tree>) -> Self {
215
+ let mut index = Self {
216
+ root,
217
+ nodes: Vec::new(),
218
+ named_nodes: Vec::new(),
219
+ by_kind: HashMap::new(),
220
+ protected_ranges: Vec::new(),
221
+ heredoc_ranges: Vec::new(),
222
+ comment_ranges: Vec::new(),
223
+ };
224
+ index.collect(root);
225
+ index.protected_ranges.sort_by_key(|range| range.start);
226
+ merge_touching_ranges(&mut index.protected_ranges);
227
+ index.heredoc_ranges.sort_by_key(|range| range.start);
228
+ index
229
+ }
230
+
231
+ pub fn comment_ranges(&self) -> &[Range<usize>] {
232
+ &self.comment_ranges
233
+ }
234
+
235
+ fn of_kind(&self, kind: &str) -> impl Iterator<Item = u32> + '_ {
236
+ self.by_kind
237
+ .get(kind)
238
+ .map_or(&[][..], Vec::as_slice)
239
+ .iter()
240
+ .copied()
241
+ }
242
+
243
+ fn named_node(&self, index: u32) -> Node<'tree> {
244
+ self.named_nodes[index as usize]
245
+ }
246
+
247
+ /// Visits every node in depth-first pre-order. Iterative on purpose: rayon
248
+ /// worker stacks are far smaller than the main thread's, and a recursive
249
+ /// walk aborts the whole process on deeply nested input.
250
+ fn collect(&mut self, root: Node<'tree>) {
251
+ let mut cursor = root.walk();
252
+ loop {
253
+ self.visit(cursor.node());
254
+ if cursor.goto_first_child() {
255
+ continue;
256
+ }
257
+ loop {
258
+ if cursor.goto_next_sibling() {
259
+ break;
260
+ }
261
+ if !cursor.goto_parent() {
262
+ return;
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ fn visit(&mut self, node: Node<'tree>) {
269
+ self.nodes.push(node);
270
+ if node.is_named() {
271
+ // A file with more than u32::MAX named nodes would need tens of gigabytes of source;
272
+ // the cast cannot lose information for anything a parser will accept.
273
+ let index = self.named_nodes.len() as u32;
274
+ self.named_nodes.push(node);
275
+ self.by_kind.entry(node.kind()).or_default().push(index);
276
+ }
277
+ if PROTECTED_LITERAL_KINDS.contains(&node.kind()) {
278
+ self.protected_ranges.push(node.byte_range());
279
+ }
280
+ if node.kind() == "heredoc_body" {
281
+ self.heredoc_ranges.push(node.byte_range());
282
+ }
283
+ if node.kind() == "comment" {
284
+ self.comment_ranges.push(node.byte_range());
285
+ }
286
+ }
287
+ }
288
+
289
+ /// Collapses overlapping and touching ranges of a start-sorted list so that no
290
+ /// offset is covered by more than one entry. `source::is_protected` inspects
291
+ /// only the last range starting at or before its offset, so an inner range that
292
+ /// outlived its enclosing one would make the enclosed offsets look unprotected.
293
+ fn merge_touching_ranges(ranges: &mut Vec<Range<usize>>) {
294
+ if ranges.is_empty() {
295
+ return;
296
+ }
297
+ let mut merged = 0;
298
+ for index in 1..ranges.len() {
299
+ if ranges[index].start <= ranges[merged].end {
300
+ ranges[merged].end = ranges[merged].end.max(ranges[index].end);
301
+ } else {
302
+ merged += 1;
303
+ ranges[merged] = ranges[index].clone();
304
+ }
305
+ }
306
+ ranges.truncate(merged + 1);
307
+ }
308
+
309
+ #[cfg(test)]
310
+ mod tests {
311
+ use std::collections::HashSet;
312
+
313
+ use super::{merge_touching_ranges, rule_names, rules};
314
+ use crate::config::Config;
315
+ use crate::source::is_protected;
316
+
317
+ #[test]
318
+ fn merges_nested_and_touching_ranges() {
319
+ let mut ranges = vec![0..10, 3..6, 10..14, 20..25];
320
+ merge_touching_ranges(&mut ranges);
321
+ assert_eq!(ranges, vec![0..14, 20..25]);
322
+ }
323
+
324
+ #[test]
325
+ fn leaves_disjoint_ranges_alone() {
326
+ let mut ranges = vec![0..2, 5..7];
327
+ merge_touching_ranges(&mut ranges);
328
+ assert_eq!(ranges, vec![0..2, 5..7]);
329
+ }
330
+
331
+ // `is_protected` only consults the last range starting at or before the
332
+ // offset, so an unmerged inner range hides the offsets after it.
333
+ #[test]
334
+ fn merging_keeps_offsets_under_an_inner_range_protected() {
335
+ let nested = vec![0..10, 3..6];
336
+ assert!(!is_protected(7, &nested));
337
+ let mut merged = nested;
338
+ merge_touching_ranges(&mut merged);
339
+ assert!(is_protected(7, &merged));
340
+ assert!(!is_protected(10, &merged));
341
+ }
342
+
343
+ #[test]
344
+ fn every_cop_is_registered_once() {
345
+ let mut seen = HashSet::new();
346
+ for name in rule_names() {
347
+ assert!(seen.insert(name), "{name} is registered twice");
348
+ }
349
+ }
350
+
351
+ /// A registered name that the bundled RuboCop configuration does not know would be
352
+ /// unreachable: `--only` rejects it, and it would carry no defaults.
353
+ #[test]
354
+ fn every_registered_cop_exists_in_the_default_configuration() {
355
+ let directory = tempfile::tempdir().unwrap();
356
+ let config = Config::load(None, directory.path()).unwrap();
357
+ let known: HashSet<&str> = config.known_cop_names().collect();
358
+ for name in rule_names() {
359
+ assert!(known.contains(name), "{name} is not a RuboCop cop");
360
+ }
361
+ }
362
+
363
+ #[test]
364
+ fn every_cop_name_is_qualified_by_a_department() {
365
+ for name in rule_names() {
366
+ let (department, cop) = name.split_once('/').expect("{name} has no department");
367
+ assert!(!department.is_empty(), "{name} has an empty department");
368
+ assert!(!cop.is_empty(), "{name} has an empty cop name");
369
+ }
370
+ }
371
+
372
+ /// The registry is a static built from the department tables, so iteration order cannot vary
373
+ /// between runs; autocorrect ordering and `--debug` output both rely on that.
374
+ #[test]
375
+ fn registration_order_is_stable() {
376
+ let first: Vec<&str> = rule_names().collect();
377
+ let second: Vec<&str> = rules().map(|rule| rule.name).collect();
378
+ assert_eq!(first, second);
379
+ }
380
+ }
@@ -0,0 +1,17 @@
1
+ use crate::diagnostic::Offense;
2
+ use crate::rules::RuleContext;
3
+
4
+ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
5
+ for node in context.nodes_of_any(&[
6
+ "identifier",
7
+ "constant",
8
+ "instance_variable",
9
+ "class_variable",
10
+ "global_variable",
11
+ ]) {
12
+ if context.source.node_text(node).is_ascii() {
13
+ continue;
14
+ }
15
+ offenses.push(context.offense("Use only ASCII symbols in identifiers.", node.byte_range()));
16
+ }
17
+ }
@@ -0,0 +1,49 @@
1
+ use std::sync::LazyLock;
2
+
3
+ use regex::Regex;
4
+ use tree_sitter::Node;
5
+
6
+ use crate::diagnostic::Offense;
7
+ use crate::rules::RuleContext;
8
+
9
+ static CONSTANT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Z][A-Z0-9_]*$").unwrap());
10
+
11
+ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
12
+ for node in context.nodes_of("assignment") {
13
+ let Some(left) = node.child_by_field_name("left") else {
14
+ continue;
15
+ };
16
+ if left.kind() != "constant" {
17
+ continue;
18
+ }
19
+ let name = context.source.node_text(left);
20
+ // Only a literal makes the name a constant in RuboCop's sense; anything computed may well
21
+ // be a class or module the author named in CamelCase on purpose.
22
+ let allowed_assignment = node
23
+ .child_by_field_name("right")
24
+ .is_none_or(|right| !literal_constant_value(right));
25
+ if allowed_assignment || CONSTANT.is_match(name) {
26
+ continue;
27
+ }
28
+ offenses
29
+ .push(context.offense("Use SCREAMING_SNAKE_CASE for constants.", left.byte_range()));
30
+ }
31
+ }
32
+
33
+ fn literal_constant_value(node: Node<'_>) -> bool {
34
+ matches!(
35
+ node.kind(),
36
+ "integer"
37
+ | "float"
38
+ | "rational"
39
+ | "complex"
40
+ | "string"
41
+ | "symbol"
42
+ | "array"
43
+ | "hash"
44
+ | "true"
45
+ | "false"
46
+ | "nil"
47
+ | "regex"
48
+ )
49
+ }
@@ -0,0 +1,51 @@
1
+ use super::support::valid_name;
2
+ use crate::diagnostic::Offense;
3
+ use crate::rules::RuleContext;
4
+
5
+ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
6
+ let style: String = context
7
+ .setting("EnforcedStyle")
8
+ .unwrap_or_else(|| "snake_case".to_owned());
9
+ for node in context.nodes_of_any(&["method", "singleton_method"]) {
10
+ let Some(name_node) = node.child_by_field_name("name") else {
11
+ continue;
12
+ };
13
+ let name = context.source.node_text(name_node);
14
+ if operator_method(name) || valid_name(name, &style) {
15
+ continue;
16
+ }
17
+ offenses.push(context.offense(
18
+ format!("Use {style} for method names."),
19
+ name_node.byte_range(),
20
+ ));
21
+ }
22
+ }
23
+
24
+ /// Operators are defined with `def` too, and none of them can be spelled in the enforced style.
25
+ fn operator_method(name: &str) -> bool {
26
+ matches!(
27
+ name,
28
+ "+" | "-"
29
+ | "*"
30
+ | "/"
31
+ | "%"
32
+ | "**"
33
+ | "=="
34
+ | "==="
35
+ | "!="
36
+ | "<=>"
37
+ | "<"
38
+ | "<="
39
+ | ">"
40
+ | ">="
41
+ | "[]"
42
+ | "[]="
43
+ | "<<"
44
+ | ">>"
45
+ | "&"
46
+ | "|"
47
+ | "^"
48
+ | "~"
49
+ | "`"
50
+ )
51
+ }
@@ -0,0 +1,9 @@
1
+ department_rules! {
2
+ "Naming";
3
+ ascii_identifiers => ("AsciiIdentifiers", Convention),
4
+ constant_name => ("ConstantName", Convention),
5
+ method_name => ("MethodName", Convention),
6
+ variable_name => ("VariableName", Convention),
7
+ }
8
+
9
+ mod support;
@@ -0,0 +1,22 @@
1
+ //! Identifier spelling shared by the cops that enforce `EnforcedStyle`.
2
+
3
+ use std::sync::LazyLock;
4
+
5
+ use regex::Regex;
6
+
7
+ static SNAKE_CASE: LazyLock<Regex> =
8
+ LazyLock::new(|| Regex::new(r"^[a-z_][a-zA-Z0-9_]*[!?=]?$").unwrap());
9
+ static CAMEL_CASE: LazyLock<Regex> =
10
+ LazyLock::new(|| Regex::new(r"^[a-z][a-zA-Z0-9]*[!?=]?$").unwrap());
11
+
12
+ pub(super) fn valid_name(name: &str, style: &str) -> bool {
13
+ if style == "camelCase" {
14
+ CAMEL_CASE.is_match(name) && !name.contains('_')
15
+ } else {
16
+ SNAKE_CASE.is_match(name)
17
+ && !name
18
+ .trim_matches(['?', '!', '='])
19
+ .chars()
20
+ .any(char::is_uppercase)
21
+ }
22
+ }
@@ -0,0 +1,50 @@
1
+ use std::collections::HashSet;
2
+
3
+ use tree_sitter::Node;
4
+
5
+ use super::support::valid_name;
6
+ use crate::diagnostic::Offense;
7
+ use crate::rules::{RuleContext, first_identifier};
8
+
9
+ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
10
+ let style: String = context
11
+ .setting("EnforcedStyle")
12
+ .unwrap_or_else(|| "snake_case".to_owned());
13
+ let mut seen = HashSet::new();
14
+ for node in context.nodes_of("identifier") {
15
+ if !is_variable_definition(node) || !seen.insert(node.start_byte()) {
16
+ continue;
17
+ }
18
+ let name = context.source.node_text(node);
19
+ if valid_name(name, &style) {
20
+ continue;
21
+ }
22
+ offenses.push(context.offense(
23
+ format!("Use {style} for variable names."),
24
+ node.byte_range(),
25
+ ));
26
+ }
27
+ }
28
+
29
+ /// Whether the identifier introduces a variable rather than reading one.
30
+ fn is_variable_definition(node: Node<'_>) -> bool {
31
+ let Some(parent) = node.parent() else {
32
+ return false;
33
+ };
34
+ match parent.kind() {
35
+ "assignment" | "operator_assignment" => parent
36
+ .child_by_field_name("left")
37
+ .is_some_and(|left| left.byte_range() == node.byte_range()),
38
+ "method_parameters" | "block_parameters" | "lambda_parameters" => true,
39
+ "optional_parameter"
40
+ | "keyword_parameter"
41
+ | "splat_parameter"
42
+ | "hash_splat_parameter"
43
+ | "block_parameter"
44
+ | "destructured_parameter"
45
+ | "rescue" => {
46
+ first_identifier(parent).is_some_and(|first| first.byte_range() == node.byte_range())
47
+ }
48
+ _ => false,
49
+ }
50
+ }