sonicop 26.8.113 → 26.9.100

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +35 -43
  3. data/Cargo.toml +5 -5
  4. data/lib/sonicop/version.rb +1 -1
  5. data/src/rules/gemspec/require_mfa.rs +1 -1
  6. data/src/rules/layout/access_modifier_indentation.rs +1 -1
  7. data/src/rules/layout/block_alignment.rs +1 -1
  8. data/src/rules/layout/block_end_newline.rs +1 -1
  9. data/src/rules/layout/empty_lines_around_arguments.rs +1 -1
  10. data/src/rules/layout/empty_lines_around_exception_handling_keywords.rs +1 -1
  11. data/src/rules/layout/first_argument_indentation.rs +1 -1
  12. data/src/rules/layout/first_array_element_indentation.rs +1 -1
  13. data/src/rules/layout/first_hash_element_indentation.rs +1 -1
  14. data/src/rules/layout/heredoc_argument_closing_parenthesis.rs +1 -1
  15. data/src/rules/layout/indentation_width.rs +1 -1
  16. data/src/rules/layout/multiline_brace.rs +1 -1
  17. data/src/rules/layout/multiline_expression.rs +2 -2
  18. data/src/rules/layout/space_inside_array_percent_literal.rs +1 -1
  19. data/src/rules/layout/space_inside_block_braces.rs +1 -1
  20. data/src/rules/layout/space_inside_hash_literal_braces.rs +1 -1
  21. data/src/rules/layout/space_inside_percent_literal_delimiters.rs +1 -1
  22. data/src/rules/layout/space_inside_string_interpolation.rs +1 -1
  23. data/src/rules/layout/support.rs +2 -5
  24. data/src/rules/layout/tokens.rs +1 -3
  25. data/src/rules/lint/access_modifier.rs +1 -1
  26. data/src/rules/lint/empty_conditional_body.rs +1 -1
  27. data/src/rules/lint/literal_in_interpolation.rs +2 -2
  28. data/src/rules/lint/literals.rs +89 -48
  29. data/src/rules/lint/loop.rs +1 -2
  30. data/src/rules/lint/nil_receiver.rs +1 -1
  31. data/src/rules/lint/node_equality.rs +5 -2
  32. data/src/rules/lint/non_atomic_file_operation.rs +1 -1
  33. data/src/rules/lint/regexp.rs +1 -1
  34. data/src/rules/lint/suppressed_exception.rs +1 -1
  35. data/src/rules/lint/syntax.rs +1 -1
  36. data/src/rules/lint/unreachable_loop.rs +27 -11
  37. data/src/rules/lint/variable_force.rs +1 -1
  38. data/src/rules/metrics/locals.rs +1 -1
  39. data/src/rules/mod.rs +83 -4
  40. data/src/rules/naming/support.rs +1 -1
  41. data/src/rules/node_ext.rs +45 -14
  42. data/src/rules/send_node.rs +2 -2
  43. data/src/rules/style/access_modifier_declarations.rs +11 -4
  44. data/src/rules/style/class_and_module_children.rs +2 -2
  45. data/src/rules/style/command_literal.rs +1 -1
  46. data/src/rules/style/concat_array_literals.rs +1 -1
  47. data/src/rules/style/conditional_assignment.rs +1 -1
  48. data/src/rules/style/def_with_parentheses.rs +1 -1
  49. data/src/rules/style/empty_else.rs +1 -1
  50. data/src/rules/style/format_string_token.rs +2 -2
  51. data/src/rules/style/hash_as_last_array_item.rs +1 -1
  52. data/src/rules/style/hash_conversion.rs +1 -1
  53. data/src/rules/style/hash_fetch_chain.rs +1 -1
  54. data/src/rules/style/infinite_loop.rs +1 -1
  55. data/src/rules/style/lambda.rs +1 -1
  56. data/src/rules/style/literal.rs +2 -2
  57. data/src/rules/style/missing_else.rs +3 -3
  58. data/src/rules/style/multiline_block_chain.rs +1 -1
  59. data/src/rules/style/multiline_memoization.rs +1 -1
  60. data/src/rules/style/non_nil_check.rs +1 -1
  61. data/src/rules/style/percent.rs +1 -1
  62. data/src/rules/style/percent_array.rs +3 -3
  63. data/src/rules/style/redundant_format.rs +2 -2
  64. data/src/rules/style/redundant_regexp_character_class.rs +1 -1
  65. data/src/rules/style/redundant_regexp_constructor.rs +1 -1
  66. data/src/rules/style/redundant_regexp_escape.rs +1 -1
  67. data/src/rules/style/regexp_literal.rs +1 -1
  68. data/src/rules/style/require_order.rs +6 -2
  69. data/src/rules/style/single_line_methods.rs +1 -1
  70. data/src/rules/style/string_concatenation.rs +2 -2
  71. data/src/rules/style/struct_inheritance.rs +1 -1
  72. data/src/rules/style/trailing_body_on_method_definition.rs +1 -1
  73. data/src/rules/style/trailing_comma.rs +1 -1
  74. data/src/rules/style/trailing_method_end_statement.rs +1 -1
  75. metadata +1 -1
@@ -55,58 +55,99 @@ const RECURSIVE_METHODS: &[&str] = &["==", "===", "!=", "<=", ">=", ">", "<", "*
55
55
  /// Reachable from `style` too: `Style/YodaCondition` asks the same question of a comparison's two
56
56
  /// operands.
57
57
  pub(crate) fn recursive_basic_literal(node: Node<'_>, context: &RuleContext<'_>) -> bool {
58
- match node.kind_str() {
59
- kind if BASIC.contains(&kind) => true,
60
- // `emit_file_line_as_literals`: the parser resolves these before a cop sees them, so what
61
- // reaches one is the `str` or the `int` they stood for rather than the keyword.
62
- "identifier" => matches!(context.source.node_text(node), "__FILE__" | "__LINE__"),
63
- // A quoted literal interpolates or it does not, and only the plain one is basic -- but a
64
- // `dstr` and a `dsym` are composite literals, so both answers come out the same here as
65
- // long as everything interpolated into them is a literal too.
66
- "string" | "delimited_symbol" => !has_interpolation(node) || all_children(node, context),
67
- kind if COMPOSITE.contains(&kind) => all_children(node, context),
68
- // `a && b` and `a || b` are `and`/`or` upstream, which recurse; every other binary operator
69
- // is a `send`, which recurses only for the ten that keep a literal literal.
70
- "binary" => {
71
- let Some(operator) = node.field("operator") else {
72
- return false;
73
- };
74
- let text = context.source.node_text(operator);
75
- (matches!(text, "&&" | "and" | "||" | "or") || RECURSIVE_METHODS.contains(&text))
76
- && all_children(node, context)
77
- }
78
- // The parser folds a leading sign into the literal it precedes; `!x` stays a `send`.
79
- "unary" => {
80
- let Some(operator) = node.field("operator") else {
81
- return false;
82
- };
83
- matches!(context.source.node_text(operator), "-" | "+" | "!")
84
- && node
85
- .field("operand")
86
- .is_some_and(|operand| recursive_basic_literal(operand, context))
87
- }
88
- "call" => {
89
- node.field("method")
90
- .is_some_and(|method| RECURSIVE_METHODS.contains(&context.source.node_text(method)))
91
- && all_children(node, context)
58
+ // The predicate is a conjunction over a subtree, so it is answered with a stack rather than by
59
+ // recursing: a nested literal nests the tree once per bracket, and this is asked of every hash
60
+ // key of every file. `AstIndex::collect` is iterative for the same reason -- a rayon worker's
61
+ // stack is far smaller than the main thread's, and a recursion deep enough to exhaust it aborts
62
+ // the process rather than failing one file. Nothing below has a side effect, so answering the
63
+ // queued questions in any order gives the same answer the nested `&&` gave.
64
+ let mut stack = vec![Question::Literal(node)];
65
+ while let Some(question) = stack.pop() {
66
+ match question {
67
+ Question::Literal(node) => match node.kind_str() {
68
+ kind if BASIC.contains(&kind) => {}
69
+ // `emit_file_line_as_literals`: the parser resolves these before a cop sees them,
70
+ // so what reaches one is the `str` or the `int` they stood for rather than the
71
+ // keyword.
72
+ "identifier" => {
73
+ if !matches!(context.source.node_text(node), "__FILE__" | "__LINE__") {
74
+ return false;
75
+ }
76
+ }
77
+ // A quoted literal interpolates or it does not, and only the plain one is basic --
78
+ // but a `dstr` and a `dsym` are composite literals, so both answers come out the
79
+ // same here as long as everything interpolated into them is a literal too.
80
+ "string" | "delimited_symbol" => {
81
+ if has_interpolation(node) {
82
+ stack.push(Question::Children(node));
83
+ }
84
+ }
85
+ kind if COMPOSITE.contains(&kind) => stack.push(Question::Children(node)),
86
+ // `a && b` and `a || b` are `and`/`or` upstream, which recurse; every other binary
87
+ // operator is a `send`, which recurses only for the ten that keep a literal
88
+ // literal.
89
+ "binary" => {
90
+ let Some(operator) = node.field("operator") else {
91
+ return false;
92
+ };
93
+ let text = context.source.node_text(operator);
94
+ if !matches!(text, "&&" | "and" | "||" | "or")
95
+ && !RECURSIVE_METHODS.contains(&text)
96
+ {
97
+ return false;
98
+ }
99
+ stack.push(Question::Children(node));
100
+ }
101
+ // The parser folds a leading sign into the literal it precedes; `!x` stays a
102
+ // `send`.
103
+ "unary" => {
104
+ let Some(operator) = node.field("operator") else {
105
+ return false;
106
+ };
107
+ if !matches!(context.source.node_text(operator), "-" | "+" | "!") {
108
+ return false;
109
+ }
110
+ let Some(operand) = node.field("operand") else {
111
+ return false;
112
+ };
113
+ stack.push(Question::Literal(operand));
114
+ }
115
+ "call" => {
116
+ let literal_carrying = node.field("method").is_some_and(|method| {
117
+ RECURSIVE_METHODS.contains(&context.source.node_text(method))
118
+ });
119
+ if !literal_carrying {
120
+ return false;
121
+ }
122
+ stack.push(Question::Children(node));
123
+ }
124
+ _ => return false,
125
+ },
126
+ // `children.compact.all?(&:recursive_basic_literal?)`.
127
+ Question::Children(node) => {
128
+ for child in named_children_of(node, context) {
129
+ match child.kind_str() {
130
+ "comment" => {}
131
+ // The parts a quoted literal is written from are not nodes upstream at all:
132
+ // the text between the delimiters is the value, and only what is
133
+ // interpolated is a child.
134
+ "string_content" | "escape_sequence" | "heredoc_content" => {}
135
+ "interpolation" => stack.push(Question::Children(child)),
136
+ _ => stack.push(Question::Literal(child)),
137
+ }
138
+ }
139
+ }
92
140
  }
93
- _ => false,
94
141
  }
142
+ true
95
143
  }
96
144
 
97
- /// `children.compact.all?(&:recursive_basic_literal?)`.
98
- fn all_children(node: Node<'_>, context: &RuleContext<'_>) -> bool {
99
- let _cursor = node.walk();
100
- named_children_of(node, context)
101
- .into_iter()
102
- .filter(|child| child.kind_str() != "comment")
103
- .all(|child| match child.kind_str() {
104
- // The parts a quoted literal is written from are not nodes upstream at all: the text
105
- // between the delimiters is the value, and only what is interpolated is a child.
106
- "string_content" | "escape_sequence" | "heredoc_content" => true,
107
- "interpolation" => all_children(child, context),
108
- _ => recursive_basic_literal(child, context),
109
- })
145
+ /// One half of [`recursive_basic_literal`]'s question, queued rather than recursed into.
146
+ enum Question<'tree> {
147
+ /// `node.recursive_basic_literal?`.
148
+ Literal(Node<'tree>),
149
+ /// `node.children.compact.all?(&:recursive_basic_literal?)`.
150
+ Children(Node<'tree>),
110
151
  }
111
152
 
112
153
  /// `node.const_type?`: a constant, however it was reached.
@@ -24,8 +24,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
24
24
  Some(keyword) => keyword,
25
25
  None => continue,
26
26
  };
27
- let last = u32::try_from(body.child_count())
28
- .unwrap_or(0)
27
+ let last = body.child_count()
29
28
  .saturating_sub(1);
30
29
  let (Some(open), Some(close)) = (body.child(0), body.child(last)) else {
31
30
  continue;
@@ -394,7 +394,7 @@ fn csend_root_receiver<'tree>(node: Node<'tree>, context: &RuleContext<'_>) -> O
394
394
  fn is_safe_navigation(node: Node<'_>, context: &RuleContext<'_>) -> bool {
395
395
  node.kind_str() == "call"
396
396
  && (0..node.child_count())
397
- .filter_map(|index| node.child(index as u32))
397
+ .filter_map(|index| node.child(index))
398
398
  .any(|child| context.source.node_text(child) == "&.")
399
399
  }
400
400
 
@@ -174,7 +174,10 @@ fn named_children_with_fields<'tree>(
174
174
  }
175
175
  loop {
176
176
  if cursor.node().is_named() {
177
- children.push((cursor.field_name(), cursor.node()));
177
+ children.push((
178
+ super::super::field_name_for_id(cursor.field_id()),
179
+ cursor.node(),
180
+ ));
178
181
  }
179
182
  if !cursor.goto_next_sibling() {
180
183
  return children;
@@ -299,7 +302,7 @@ fn quoted_value(node: Node<'_>, context: &RuleContext<'_>) -> Option<Vec<u8>> {
299
302
  return None;
300
303
  }
301
304
  let open = node.child(0)?;
302
- let close = node.child(u32::try_from(node.child_count()).ok()?.saturating_sub(1))?;
305
+ let close = node.child(node.child_count().saturating_sub(1))?;
303
306
  if open.id() == close.id() || close.start_byte() < open.end_byte() {
304
307
  return None;
305
308
  }
@@ -176,7 +176,7 @@ fn corrections(
176
176
  safe: true,
177
177
  });
178
178
  } else if let Some(end) = conditional
179
- .child(conditional.child_count().saturating_sub(1) as u32)
179
+ .child(conditional.child_count().saturating_sub(1))
180
180
  .filter(|end| context.source.node_text(*end) == "end")
181
181
  {
182
182
  edits.push(Edit {
@@ -20,7 +20,7 @@ pub(super) struct Captures {
20
20
  /// The text between a regexp literal's delimiters, and whether it was written with the `x` flag.
21
21
  pub(super) fn pattern<'a>(node: Node<'_>, context: &'a RuleContext<'_>) -> Option<(&'a str, bool)> {
22
22
  let opening = node.child(0)?;
23
- let closing = node.child(u32::try_from(node.child_count()).ok()?.checked_sub(1)?)?;
23
+ let closing = node.child(node.child_count().checked_sub(1)?)?;
24
24
  if closing.start_byte() < opening.end_byte() {
25
25
  return None;
26
26
  }
@@ -28,7 +28,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
28
28
  // The offense covers the `rescue` keyword and what follows it, not the guarded body.
29
29
  "rescue_modifier" => {
30
30
  let keyword = (0..node.child_count())
31
- .filter_map(|index| node.child(index as u32))
31
+ .filter_map(|index| node.child(index))
32
32
  .find(|child| context.source.node_text(*child) == "rescue");
33
33
  match keyword {
34
34
  Some(keyword) => keyword.start_byte()..node.end_byte(),
@@ -833,7 +833,7 @@ fn endless_in_block_recovery(node: Node<'_>, context: &RuleContext<'_>, out: &mu
833
833
  return;
834
834
  };
835
835
  let Some(brace) = block
836
- .child(block.child_count().saturating_sub(1) as u32)
836
+ .child(block.child_count().saturating_sub(1))
837
837
  .filter(|last| last.kind_str() == "}")
838
838
  else {
839
839
  return;
@@ -268,11 +268,24 @@ fn is_loop_shape(node: Node<'_>, context: &RuleContext<'_>, allowed: &[&'static
268
268
  }
269
269
 
270
270
  /// `each_descendant(:next, :redo).any?`, without descending into `skip`.
271
+ ///
272
+ /// Iterative rather than recursive, for the reason `AstIndex::collect` is: a rayon worker's stack
273
+ /// is far smaller than the main thread's, and a `while` condition written as one long chain nests
274
+ /// as deeply as it is long -- a recursive walk aborts the whole process on it rather than failing
275
+ /// one file.
271
276
  fn has_continue(node: Node<'_>, skip: Option<Node<'_>>) -> bool {
272
- let mut cursor = node.walk();
273
- node.named_children(&mut cursor)
274
- .filter(|child| skip.is_none_or(|skip| skip.id() != child.id()))
275
- .any(|child| CONTINUE.contains(&child.kind_str()) || has_continue(child, skip))
277
+ let mut stack = Vec::new();
278
+ crate::rules::push_named_children(node, &mut stack);
279
+ while let Some(current) = stack.pop() {
280
+ if skip.is_some_and(|skip| skip.id() == current.id()) {
281
+ continue;
282
+ }
283
+ if CONTINUE.contains(&current.kind_str()) {
284
+ return true;
285
+ }
286
+ crate::rules::push_named_children(current, &mut stack);
287
+ }
288
+ false
276
289
  }
277
290
 
278
291
  /// `conditional_continue_keyword?`: the last `or` written anywhere in the break statement, when its
@@ -285,16 +298,19 @@ fn conditional_continue(node: Node<'_>) -> bool {
285
298
  .is_some_and(|right| CONTINUE.contains(&right.kind_str()))
286
299
  }
287
300
 
301
+ /// The last `or` of `node`'s subtree in depth-first pre-order, `node` itself excluded.
302
+ ///
303
+ /// Iterative for the same reason [`has_continue`] is: `a or b or c or …` nests once per operand,
304
+ /// and the recursion this replaced was as deep as the chain is long.
288
305
  fn last_or<'tree>(node: Node<'tree>) -> Option<Node<'tree>> {
289
306
  let mut found = None;
290
- let mut cursor = node.walk();
291
- for child in node.named_children(&mut cursor) {
292
- if is_or(child) {
293
- found = Some(child);
294
- }
295
- if let Some(inner) = last_or(child) {
296
- found = Some(inner);
307
+ let mut stack = Vec::new();
308
+ crate::rules::push_named_children(node, &mut stack);
309
+ while let Some(current) = stack.pop() {
310
+ if is_or(current) {
311
+ found = Some(current);
297
312
  }
313
+ crate::rules::push_named_children(current, &mut stack);
298
314
  }
299
315
  found
300
316
  }
@@ -1603,7 +1603,7 @@ fn field_name(
1603
1603
  }
1604
1604
  loop {
1605
1605
  if cursor.node().id() == child.id() {
1606
- return cursor.field_name();
1606
+ return super::super::field_name_for_id(cursor.field_id());
1607
1607
  }
1608
1608
  if !cursor.goto_next_sibling() {
1609
1609
  return None;
@@ -507,7 +507,7 @@ pub(super) fn field_name(
507
507
  }
508
508
  loop {
509
509
  if cursor.node().id() == child.id() {
510
- return cursor.field_name();
510
+ return super::super::field_name_for_id(cursor.field_id());
511
511
  }
512
512
  if !cursor.goto_next_sibling() {
513
513
  return None;
data/src/rules/mod.rs CHANGED
@@ -1,5 +1,6 @@
1
1
  use std::cell::OnceCell;
2
2
  use std::collections::HashMap;
3
+ use std::num::NonZeroU16;
3
4
  use std::ops::Range;
4
5
  use std::sync::LazyLock;
5
6
 
@@ -572,12 +573,20 @@ impl<'a> Iterator for Children<'a> {
572
573
  /// Every field name the grammar has, by field id, so a recorded id can be turned back into the
573
574
  /// `&'static str` the cops compare against.
574
575
  static FIELD_NAMES: LazyLock<Vec<Option<&'static str>>> = LazyLock::new(|| {
575
- let language = node_ext::language();
576
- (0..=language.field_count() as u16)
577
- .map(|id| language.field_name_for_id(id))
576
+ (0..=node_ext::LANGUAGE.field_count() as u16)
577
+ .map(|id| node_ext::LANGUAGE.field_name_for_id(id))
578
578
  .collect()
579
579
  });
580
580
 
581
+ /// The field name a field id stands for, for a node the index does not know.
582
+ ///
583
+ /// `TreeCursor::field_name` answers the same question, but since tree-sitter 0.27 its answer
584
+ /// borrows from the tree, and the callers hand the name on as `&'static str`. Going through the
585
+ /// table keeps the lifetime and skips the C lookup besides.
586
+ pub(in crate::rules) fn field_name_for_id(id: Option<NonZeroU16>) -> Option<&'static str> {
587
+ FIELD_NAMES.get(id?.get() as usize).copied().flatten()
588
+ }
589
+
581
590
  /// The value [`AstIndex::parent_of`] carries for the root.
582
591
  const NO_PARENT: u32 = u32::MAX;
583
592
 
@@ -917,7 +926,9 @@ fn merge_touching_ranges(ranges: &mut Vec<Range<usize>>) {
917
926
  mod tests {
918
927
  use std::collections::HashSet;
919
928
 
920
- use super::{AstIndex, merge_touching_ranges, rule_names, rules};
929
+ use super::{
930
+ AstIndex, RuleContext, field_name_for_id, merge_touching_ranges, rule_names, rules,
931
+ };
921
932
  use crate::config::Config;
922
933
  use crate::rules::node_ext::NodeExt;
923
934
  use crate::source::is_protected;
@@ -1079,6 +1090,27 @@ mod tests {
1079
1090
  index.parent_in_tree(call).map(|found| found.id()),
1080
1091
  call.parent().map(|found| found.id())
1081
1092
  );
1093
+
1094
+ // The same question asked through a context rather than the index. `named_children_of`
1095
+ // recursed into itself here rather than walking, so a cop handing it a fragment node never
1096
+ // returned.
1097
+ let source = crate::source::SourceFile::new("test.rb", "foo(1)\n".to_owned());
1098
+ let config = Config::load_with_options(None, std::path::Path::new("/"), true)
1099
+ .expect("the vendored default configuration loads");
1100
+ let rule = rules().next().expect("the registry is not empty");
1101
+ let context = RuleContext::new(
1102
+ &source,
1103
+ &index,
1104
+ &config,
1105
+ rule,
1106
+ crate::diagnostic::Severity::Convention,
1107
+ false,
1108
+ );
1109
+ assert!(context.named_children(call).is_none());
1110
+ assert_eq!(
1111
+ crate::rules::send_node::named_children_of(call, &context),
1112
+ expected
1113
+ );
1082
1114
  }
1083
1115
 
1084
1116
  /// The registry is a static built from the department tables, so iteration order cannot vary
@@ -1089,4 +1121,51 @@ mod tests {
1089
1121
  let second: Vec<&str> = rules().map(|rule| rule.name).collect();
1090
1122
  assert_eq!(first, second);
1091
1123
  }
1124
+
1125
+ /// The table has to answer what `TreeCursor::field_name` answers, for every node of a real
1126
+ /// file and for a node of a tree the index never saw.
1127
+ ///
1128
+ /// Five cops read the field a node fills out of the table rather than off the cursor, because
1129
+ /// since tree-sitter 0.27 the cursor's answer borrows from the tree while theirs is
1130
+ /// `&'static str`. A field the table named differently would make a cop tell the two sides of
1131
+ /// a range apart wrongly, which is what the field is there for.
1132
+ #[test]
1133
+ fn the_field_table_answers_what_the_cursor_answers() {
1134
+ let mut parser = tree_sitter::Parser::new();
1135
+ parser
1136
+ .set_language(&tree_sitter_ruby::LANGUAGE.into())
1137
+ .expect("the Ruby grammar loads");
1138
+ let tree = parser
1139
+ .parse(
1140
+ "class Foo\n def bar(a = 1, &block)\n @x ||= a.map { |v| v[1..] }\n\
1141
+ rescue StandardError => e\n raise e if 10.. === a\n end\nend\n",
1142
+ None,
1143
+ )
1144
+ .expect("the source parses");
1145
+
1146
+ let mut stack = vec![tree.root_node()];
1147
+ let (mut seen, mut named) = (0, 0);
1148
+ while let Some(node) = stack.pop() {
1149
+ let mut cursor = node.walk();
1150
+ if !cursor.goto_first_child() {
1151
+ continue;
1152
+ }
1153
+ loop {
1154
+ assert_eq!(
1155
+ field_name_for_id(cursor.field_id()),
1156
+ cursor.field_name(),
1157
+ "field of {:?} under {node:?}",
1158
+ cursor.node()
1159
+ );
1160
+ seen += 1;
1161
+ named += usize::from(cursor.field_name().is_some());
1162
+ stack.push(cursor.node());
1163
+ if !cursor.goto_next_sibling() {
1164
+ break;
1165
+ }
1166
+ }
1167
+ }
1168
+ assert!(seen > 30, "the sample has to reach a variety of children");
1169
+ assert!(named > 10, "and enough of them have to fill a field");
1170
+ }
1092
1171
  }
@@ -47,7 +47,7 @@ fn field(node: Node<'_>) -> Option<&'static str> {
47
47
  }
48
48
  loop {
49
49
  if cursor.node().id() == node.id() {
50
- return cursor.field_name();
50
+ return super::super::field_name_for_id(cursor.field_id());
51
51
  }
52
52
  if !cursor.goto_next_sibling() {
53
53
  return None;
@@ -17,9 +17,14 @@ use tree_sitter::{Language, Node};
17
17
 
18
18
  use crate::rules::RuleContext;
19
19
 
20
- pub(in crate::rules) fn language() -> Language {
21
- tree_sitter_ruby::LANGUAGE.into()
22
- }
20
+ /// The grammar itself, resolved once and kept for the life of the process.
21
+ ///
22
+ /// Since tree-sitter 0.27 the name tables borrow from the `Language` they were read out of rather
23
+ /// than being `&'static str` outright, so a `Language` built per call cannot outlive the name it
24
+ /// hands back. Holding it in a `static` is what lets [`NodeExt::kind_str`] and `FIELD_NAMES` keep
25
+ /// answering `&'static str`, which the several thousand call sites compare against.
26
+ pub(in crate::rules) static LANGUAGE: LazyLock<Language> =
27
+ LazyLock::new(|| tree_sitter_ruby::LANGUAGE.into());
23
28
 
24
29
  /// The field ids the codebase asks for, resolved from the grammar once.
25
30
  ///
@@ -31,9 +36,8 @@ macro_rules! field_ids {
31
36
  struct FieldIds { $($field: u16,)+ }
32
37
 
33
38
  static FIELD_IDS: LazyLock<FieldIds> = LazyLock::new(|| {
34
- let language = language();
35
39
  FieldIds {
36
- $($field: language
40
+ $($field: LANGUAGE
37
41
  .field_id_for_name($name)
38
42
  .map_or(0, NonZeroU16::get),)+
39
43
  }
@@ -79,10 +83,9 @@ field_ids! {
79
83
 
80
84
  /// Every node kind the grammar can produce, indexed by the id `Node::kind_id` answers with.
81
85
  static KIND_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
82
- let language = language();
83
- (0..language.node_kind_count())
86
+ (0..LANGUAGE.node_kind_count())
84
87
  .map(|id| {
85
- language
88
+ LANGUAGE
86
89
  .node_kind_for_id(id as u16)
87
90
  .expect("every id below the count names a kind")
88
91
  })
@@ -106,10 +109,14 @@ impl<'tree> NodeExt<'tree> for Node<'tree> {
106
109
  fn kind_str(&self) -> &'static str {
107
110
  match KIND_NAMES.get(self.kind_id() as usize) {
108
111
  Some(kind) => kind,
109
- // A kind the table does not carry cannot happen for a tree this grammar produced, but
110
- // answering from the C API rather than panicking keeps a grammar upgrade from taking
111
- // the run down.
112
- None => self.kind(),
112
+ // `ERROR` and `_ERROR` carry ids above the kind count -- 65535 and 65534 against 368
113
+ // kinds -- so this arm is the ordinary path for them, and `ts_language_symbol_name`
114
+ // names both. It asks the grammar the same question `Node::kind` does, through the
115
+ // `static` so the answer is still `'static`. The `unwrap_or` is for an id neither the
116
+ // table nor the grammar knows, which no node this grammar produced can carry: `ERROR`
117
+ // is the honest name for a node that cannot be placed, and `Node::kind` would abort
118
+ // the run there.
119
+ None => LANGUAGE.node_kind_for_id(self.kind_id()).unwrap_or("ERROR"),
113
120
  }
114
121
  }
115
122
 
@@ -130,12 +137,12 @@ impl<'tree> NodeExt<'tree> for Node<'tree> {
130
137
 
131
138
  #[cfg(test)]
132
139
  mod tests {
133
- use super::{NodeExt, language};
140
+ use super::{LANGUAGE, NodeExt};
134
141
  use tree_sitter::Parser;
135
142
 
136
143
  fn tree(source: &str) -> tree_sitter::Tree {
137
144
  let mut parser = Parser::new();
138
- parser.set_language(&language()).unwrap();
145
+ parser.set_language(&LANGUAGE).unwrap();
139
146
  parser.parse(source, None).unwrap()
140
147
  }
141
148
 
@@ -187,4 +194,28 @@ mod tests {
187
194
  let tree = tree("a.b(1)\n");
188
195
  assert!(tree.root_node().field("nonexistent").is_none());
189
196
  }
197
+
198
+ /// `ERROR` carries a kind id above the kind count, so it is the fallback arm of
199
+ /// [`NodeExt::kind_str`] that names it -- and `Lint/Syntax` rests on that name. The arm was a
200
+ /// call to `Node::kind` until tree-sitter 0.27 tied that answer's lifetime to the tree; this
201
+ /// holds the grammar's table to the same answer.
202
+ #[test]
203
+ fn an_error_node_is_named_the_way_the_parser_names_it() {
204
+ let tree = tree("def foo(\n");
205
+ let mut stack = vec![tree.root_node()];
206
+ let mut errors = 0;
207
+ while let Some(node) = stack.pop() {
208
+ if node.is_error() {
209
+ assert!(
210
+ (node.kind_id() as usize) >= LANGUAGE.node_kind_count(),
211
+ "an id the table carries would not reach the fallback arm"
212
+ );
213
+ assert_eq!(node.kind_str(), node.kind(), "kind of {node:?}");
214
+ errors += 1;
215
+ }
216
+ let mut cursor = node.walk();
217
+ stack.extend(node.children(&mut cursor));
218
+ }
219
+ assert!(errors > 0, "the sample has to reach an ERROR node");
220
+ }
190
221
  }
@@ -207,7 +207,7 @@ pub(crate) fn string_text<'a>(node: Node<'_>, context: &'a RuleContext<'_>) -> &
207
207
  let text = context.source.node_text(node);
208
208
  let (Some(open), Some(close)) = (
209
209
  node.child(0),
210
- node.child(node.child_count().saturating_sub(1) as u32),
210
+ node.child(node.child_count().saturating_sub(1)),
211
211
  ) else {
212
212
  return text;
213
213
  };
@@ -419,7 +419,7 @@ pub(crate) fn named_children_of<'tree>(
419
419
  ) -> Vec<Node<'tree>> {
420
420
  match context.named_children(node) {
421
421
  Some(children) => children.to_vec(),
422
- None => named_children_of(node, context),
422
+ None => named_children(node),
423
423
  }
424
424
  }
425
425
 
@@ -142,7 +142,10 @@ impl<'tree> Cop<'_, 'tree> {
142
142
  if splat.kind_str() != "splat_argument" {
143
143
  return false;
144
144
  }
145
- let Some(value) = send_node::named_children_of(*splat, self.context).first().copied() else {
145
+ let Some(value) = send_node::named_children_of(*splat, self.context)
146
+ .first()
147
+ .copied()
148
+ else {
146
149
  return false;
147
150
  };
148
151
  match value.kind_str() {
@@ -384,7 +387,7 @@ impl<'tree> Cop<'_, 'tree> {
384
387
  let mut current = node;
385
388
  while let Some(parent) = current.parent() {
386
389
  if matches!(parent.kind_str(), "class" | "module" | "singleton_class") {
387
- let last = u32::try_from(parent.child_count()).ok()?.checked_sub(1)?;
390
+ let last = parent.child_count().checked_sub(1)?;
388
391
  return parent.child(last).filter(|end| end.kind_str() == "end");
389
392
  }
390
393
  current = parent;
@@ -459,8 +462,12 @@ impl<'tree> Cop<'_, 'tree> {
459
462
  /// code instead and never travels forward.
460
463
  fn leading_comments(&self, node: Node<'tree>) -> Vec<Range<usize>> {
461
464
  let source = self.context.source;
462
- let (line, column) = source.line_column(node.start_byte());
463
- if !source.line(line)[..column - 1].trim().is_empty() {
465
+ let (line, _) = source.line_column(node.start_byte());
466
+ // The column `line_column` reports counts characters, and the line is sliced by bytes, so
467
+ // what stands before the node is measured against the line's own start rather than that
468
+ // column -- a line opening with a multi-byte character would otherwise be sliced inside it.
469
+ let before = &source.line(line)[..node.start_byte() - source.line_start(line)];
470
+ if !before.trim().is_empty() {
464
471
  return Vec::new();
465
472
  }
466
473
  let mut comments = Vec::new();
@@ -164,7 +164,7 @@ fn nested_correction(
164
164
  name: Node<'_>,
165
165
  ) -> Option<Vec<Edit>> {
166
166
  let keyword = node.child(0)?;
167
- let closing = node.child(node.child_count().saturating_sub(1) as u32)?;
167
+ let closing = node.child(node.child_count().saturating_sub(1))?;
168
168
  if closing.kind_str() != "end" {
169
169
  return None;
170
170
  }
@@ -229,7 +229,7 @@ fn compact_correction(
229
229
  }
230
230
  let keyword = node.child(0)?;
231
231
  let inner_name = inner.field("name")?;
232
- let inner_end = inner.child(inner.child_count().saturating_sub(1) as u32)?;
232
+ let inner_end = inner.child(inner.child_count().saturating_sub(1))?;
233
233
  if inner_end.kind_str() != "end" {
234
234
  return None;
235
235
  }
@@ -18,7 +18,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
18
18
  // `node.heredoc?`: a `<<`CMD`` opens a heredoc, which the grammar spells as its own node.
19
19
  let (Some(open), Some(close)) = (
20
20
  node.child(0),
21
- node.child(node.child_count().saturating_sub(1) as u32),
21
+ node.child(node.child_count().saturating_sub(1)),
22
22
  ) else {
23
23
  continue;
24
24
  };
@@ -128,7 +128,7 @@ fn preferred_method(arrays: &[(ArrayKind, Node<'_>)], context: &RuleContext<'_>)
128
128
  /// The `[` and the `]` of an array literal, which the correction drops.
129
129
  fn brackets(node: Node<'_>) -> Option<(std::ops::Range<usize>, std::ops::Range<usize>)> {
130
130
  let open = node.child(0)?;
131
- let close = node.child(node.child_count().checked_sub(1)? as u32)?;
131
+ let close = node.child(node.child_count().checked_sub(1)?)?;
132
132
  (open.id() != close.id()).then(|| (open.byte_range(), close.byte_range()))
133
133
  }
134
134