code-gauge 4.7.0 → 4.8.0

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 (78) hide show
  1. package/README.md +1 -1
  2. package/THIRD-PARTY-NOTICES.txt +595 -29
  3. package/dist/cli.cjs +1 -1
  4. package/dist/cli.cjs.map +1 -1
  5. package/dist/cli.js +1 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/cliConfig.cjs +1 -1
  8. package/dist/cliConfig.cjs.map +1 -1
  9. package/dist/cliConfig.js +1 -1
  10. package/dist/cliConfig.js.map +1 -1
  11. package/dist/crossFileDuplication.cjs +1 -1
  12. package/dist/crossFileDuplication.cjs.map +1 -1
  13. package/dist/crossFileDuplication.js +1 -1
  14. package/dist/crossFileDuplication.js.map +1 -1
  15. package/dist/crossFileNearMiss.cjs +1 -1
  16. package/dist/crossFileNearMiss.cjs.map +1 -1
  17. package/dist/crossFileNearMiss.js +1 -1
  18. package/dist/crossFileNearMiss.js.map +1 -1
  19. package/dist/diffCommand.cjs +3 -3
  20. package/dist/diffCommand.cjs.map +1 -1
  21. package/dist/diffCommand.js +3 -3
  22. package/dist/diffCommand.js.map +1 -1
  23. package/dist/duplicateSelection.cjs +1 -1
  24. package/dist/duplicateSelection.cjs.map +1 -1
  25. package/dist/duplicateSelection.js +1 -1
  26. package/dist/duplicateSelection.js.map +1 -1
  27. package/dist/duplication.cjs +1 -1
  28. package/dist/duplication.cjs.map +1 -1
  29. package/dist/duplication.js +1 -1
  30. package/dist/duplication.js.map +1 -1
  31. package/dist/git.cjs +1 -1
  32. package/dist/git.cjs.map +1 -1
  33. package/dist/git.js +1 -1
  34. package/dist/git.js.map +1 -1
  35. package/dist/languages.cjs +1 -1
  36. package/dist/languages.cjs.map +1 -1
  37. package/dist/languages.js +1 -1
  38. package/dist/languages.js.map +1 -1
  39. package/dist/metrics.cjs +1 -1
  40. package/dist/metrics.cjs.map +1 -1
  41. package/dist/metrics.d.ts +8 -3
  42. package/dist/metrics.js +1 -1
  43. package/dist/metrics.js.map +1 -1
  44. package/dist/nativeMetrics.cjs +3 -3
  45. package/dist/nativeMetrics.cjs.map +1 -1
  46. package/dist/nativeMetrics.d.ts +19 -3
  47. package/dist/nativeMetrics.js +3 -3
  48. package/dist/nativeMetrics.js.map +1 -1
  49. package/dist/regressionGate.cjs +1 -1
  50. package/dist/regressionGate.cjs.map +1 -1
  51. package/dist/regressionGate.js +1 -1
  52. package/dist/regressionGate.js.map +1 -1
  53. package/dist/scan.cjs +1 -1
  54. package/dist/scan.cjs.map +1 -1
  55. package/dist/scan.d.ts +2 -2
  56. package/dist/scan.js +1 -1
  57. package/dist/scan.js.map +1 -1
  58. package/dist/wasmBinding.cjs +1 -1
  59. package/dist/wasmBinding.cjs.map +1 -1
  60. package/dist/wasmBinding.js +1 -1
  61. package/dist/wasmBinding.js.map +1 -1
  62. package/native/Cargo.lock +48 -15
  63. package/native/Cargo.toml +11 -10
  64. package/native/code-gauge.wasm +0 -0
  65. package/native/src/complexity.rs +152 -255
  66. package/native/src/dep_degree.rs +79 -65
  67. package/native/src/duplication.rs +114 -97
  68. package/native/src/functions.rs +181 -206
  69. package/native/src/languages.rs +48 -7
  70. package/native/src/lib.rs +10 -5
  71. package/native/src/measure.rs +125 -150
  72. package/native/src/napi.rs +61 -2
  73. package/native/src/ncss.rs +108 -104
  74. package/native/src/near_miss.rs +7 -7
  75. package/native/src/tree_index.rs +119 -0
  76. package/native/src/util.rs +15 -55
  77. package/native/src/worker_pool.rs +53 -0
  78. package/package.json +10 -10
@@ -1,20 +1,17 @@
1
- use std::collections::{HashMap, HashSet};
1
+ use rustc_hash::{FxHashMap, FxHashSet};
2
2
  use tree_sitter::Node;
3
3
 
4
+ use crate::tree_index::NodeExt;
4
5
  use crate::util::{all_children, node_text, Source};
5
6
 
6
- pub struct ComplexityResult {
7
- pub cognitive_complexity: u64,
8
- pub nesting_depth: u64,
9
- }
10
-
11
7
  /// Node-type lookup sets built once per measurement from the language definition.
12
8
  pub struct LanguageSets {
13
- pub function_nodes: HashSet<&'static str>,
14
- pub decision_nodes: HashSet<&'static str>,
15
- pub nesting_nodes: HashSet<&'static str>,
16
- pub ncss_nodes: HashSet<&'static str>,
17
- pub ncss_containers: HashSet<&'static str>,
9
+ pub function_nodes: FxHashSet<&'static str>,
10
+ pub decision_nodes: FxHashSet<&'static str>,
11
+ pub nesting_nodes: FxHashSet<&'static str>,
12
+ pub ncss_nodes: FxHashSet<&'static str>,
13
+ pub ncss_containers: FxHashSet<&'static str>,
14
+ pub ncss_bare_body_parents: FxHashSet<&'static str>,
18
15
  }
19
16
 
20
17
  impl LanguageSets {
@@ -25,6 +22,11 @@ impl LanguageSets {
25
22
  nesting_nodes: language.nesting_node_types.iter().copied().collect(),
26
23
  ncss_nodes: language.ncss_node_types.iter().copied().collect(),
27
24
  ncss_containers: language.ncss_container_node_types.iter().copied().collect(),
25
+ ncss_bare_body_parents: language
26
+ .ncss_bare_body_parent_node_types
27
+ .iter()
28
+ .copied()
29
+ .collect(),
28
30
  }
29
31
  }
30
32
  }
@@ -35,8 +37,6 @@ const BOOLEAN_OPERATOR_PARENT_TYPES: &[&str] = &[
35
37
  "binary_expression",
36
38
  "binary",
37
39
  "boolean_operator",
38
- "conjunction_expression",
39
- "disjunction_expression",
40
40
  // C# pattern combinators (`is > 0 and <= 10`) sequence like `&&`/`||` (SonarC#).
41
41
  "and_pattern",
42
42
  "or_pattern",
@@ -44,17 +44,17 @@ const BOOLEAN_OPERATOR_PARENT_TYPES: &[&str] = &[
44
44
 
45
45
  /// A Ruby stabby lambda's body block is part of the lambda, not a separate function.
46
46
  pub fn is_lambda_body_block(node: Node<'_>) -> bool {
47
- (node.kind() == "block" || node.kind() == "do_block")
47
+ (node.kind_name() == "block" || node.kind_name() == "do_block")
48
48
  && node
49
- .parent()
50
- .is_some_and(|parent| parent.kind() == "lambda")
49
+ .parent_node()
50
+ .is_some_and(|parent| parent.kind_name() == "lambda")
51
51
  }
52
52
 
53
53
  /// A function node with a body of its own: bodyless declarations (abstract methods, auto-property
54
54
  /// accessors, accessor-list properties) open no nesting frame, so members inside them are not
55
55
  /// charged as nested functions.
56
- pub fn is_function_boundary(node: Node<'_>, function_nodes: &HashSet<&'static str>) -> bool {
57
- function_nodes.contains(node.kind())
56
+ pub fn is_function_boundary(node: Node<'_>, function_nodes: &FxHashSet<&'static str>) -> bool {
57
+ function_nodes.contains(node.kind_name())
58
58
  && !is_lambda_body_block(node)
59
59
  && crate::functions::is_implemented_function(node)
60
60
  }
@@ -135,17 +135,25 @@ struct FunctionBodyPass<'sets, 'code, 'source> {
135
135
  sets: &'sets LanguageSets,
136
136
  code: &'code Source<'source>,
137
137
  frames: Vec<FunctionBodyFrame>,
138
- results: HashMap<usize, FunctionBodyMetrics>,
138
+ results: FxHashMap<usize, FunctionBodyMetrics>,
139
139
  /// Cyclomatic decisions inside class bodies nested in functions, which no function owns.
140
140
  nested_class_decisions: u64,
141
+ /// Deepest structural nesting anywhere in the file.
142
+ max_nesting: u64,
141
143
  }
142
144
 
143
- /// Per-function body metrics, plus the cyclomatic decisions no function body owns.
145
+ /// Per-function body metrics, plus the file-level totals and the cyclomatic decisions no function
146
+ /// body owns.
144
147
  pub struct BodyMetrics {
145
- pub by_function: HashMap<usize, FunctionBodyMetrics>,
148
+ pub by_function: FxHashMap<usize, FunctionBodyMetrics>,
146
149
  /// Cyclomatic decisions outside every function body (top-level statements, field initializers,
147
150
  /// including those of classes nested in functions).
148
151
  pub top_level_decisions: u64,
152
+ /// File-level cognitive complexity: nested function/lambda content is charged one nesting
153
+ /// level deeper per function boundary crossed (Sonar spec).
154
+ pub cognitive_complexity: u64,
155
+ pub nesting_depth: u64,
156
+ pub ncss: u64,
149
157
  }
150
158
 
151
159
  /// Per-function complexity and NCSS for every function boundary, in one post-order pass so each
@@ -164,15 +172,22 @@ pub fn measure_function_body_metrics(
164
172
  let mut pass = FunctionBodyPass {
165
173
  sets,
166
174
  code,
167
- // frames[0] is a sentinel for top-level code; only its cyclomatic decisions are kept.
175
+ // frames[0] is a sentinel for top-level code, which ends up holding the file's totals.
168
176
  frames: vec![FunctionBodyFrame::new(0, 0)],
169
- results: HashMap::new(),
177
+ results: FxHashMap::default(),
170
178
  nested_class_decisions: 0,
179
+ max_nesting: 0,
171
180
  };
172
181
  pass.visit(root, 0, 0, false, false, false);
182
+ // Every closed frame hoists into its parent, so the sentinel's cognitive increments are re-based
183
+ // to absolute nesting (its entry nesting is 0).
184
+ let file_frame = &pass.frames[0];
173
185
  BodyMetrics {
186
+ top_level_decisions: file_frame.cyclomatic_complexity - 1 + pass.nested_class_decisions,
187
+ cognitive_complexity: file_frame.cognitive_complexity,
188
+ nesting_depth: pass.max_nesting,
189
+ ncss: file_frame.ncss,
174
190
  by_function: pass.results,
175
- top_level_decisions: pass.frames[0].cyclomatic_complexity - 1 + pass.nested_class_decisions,
176
191
  }
177
192
  }
178
193
 
@@ -189,7 +204,8 @@ impl FunctionBodyPass<'_, '_, '_> {
189
204
  // A class body nested in a function (anonymous/local classes) raises the cognitive nesting
190
205
  // level once for everything inside it — PMD charges the class body, not the methods it
191
206
  // holds, so methods directly inside a charged class body skip the function-boundary bonus.
192
- let is_charged_class_body = current.kind() == "class_body" && inside_function;
207
+ let parent = current.parent_node();
208
+ let is_charged_class_body = current.kind_name() == "class_body" && inside_function;
193
209
  if is_charged_class_body {
194
210
  inside_nested_region = true;
195
211
  function_nesting_bonus += 1;
@@ -212,25 +228,25 @@ impl FunctionBodyPass<'_, '_, '_> {
212
228
  // Anonymous keyword tokens can share a type with named nodes (Ruby's `if` node contains an
213
229
  // `if` keyword token), so only named nodes count as decisions.
214
230
  let is_decision = current.is_named()
215
- && self.sets.decision_nodes.contains(current.kind())
231
+ && self.sets.decision_nodes.contains(current.kind_name())
216
232
  && !is_pathless_switch_branch(current, self.code);
217
- let is_case_clause = current.is_named() && CASE_CLAUSE_NODE_TYPES.contains(&current.kind());
233
+ let is_case_clause =
234
+ current.is_named() && CASE_CLAUSE_NODE_TYPES.contains(&current.kind_name());
218
235
  // Ruby's `case ... else` arm is an `else` node; like every other language's default branch
219
236
  // it nests its contents inside the switch (it cannot go in the Ruby nesting set because
220
237
  // `if`/`begin` else branches would then double-nest under their already-nesting parent).
221
238
  let is_nesting = current.is_named()
222
- && (self.sets.nesting_nodes.contains(current.kind())
223
- || (current.kind() == "else"
224
- && current.parent().is_some_and(|parent| {
225
- parent.kind() == "case" || parent.kind() == "case_match"
226
- })));
239
+ && (self.sets.nesting_nodes.contains(current.kind_name())
240
+ || (current.kind_name() == "else" && is_case_else_parent(parent)));
227
241
  // `elsif`/`elif`/`else if` continue a flat chain: they add a decision without a nesting
228
242
  // surcharge (Sonar cognitive-complexity semantics).
229
- let is_continuation = is_decision && is_flat_chain_continuation(current);
243
+ let is_continuation = is_decision && is_flat_chain_continuation(current, parent);
244
+ let is_boolean_operator = is_boolean_operator(current, parent, self.code);
245
+ let is_pattern_guard = is_pattern_guard(current, parent);
230
246
 
231
247
  // Each branch, short-circuit operator, and pattern guard adds one path (McCabe; NIST SP
232
248
  // 500-235 §4); `else` adds none.
233
- if is_decision || is_boolean_operator(current, self.code) || is_pattern_guard(current) {
249
+ if is_decision || is_boolean_operator || is_pattern_guard {
234
250
  if counts_for_own_body {
235
251
  self.top_frame().cyclomatic_complexity += 1;
236
252
  } else if !opens_frame {
@@ -245,13 +261,13 @@ impl FunctionBodyPass<'_, '_, '_> {
245
261
  self.top_frame().nesting_sensitive_count += 1;
246
262
  }
247
263
  }
248
- if current.is_named() && SWITCH_LIKE_NODE_TYPES.contains(&current.kind()) {
264
+ if current.is_named() && SWITCH_LIKE_NODE_TYPES.contains(&current.kind_name()) {
249
265
  self.top_frame().cognitive_complexity += 1 + relative_nesting;
250
266
  self.top_frame().nesting_sensitive_count += 1;
251
267
  }
252
268
  // A plain `else` branch adds one flat cognitive point; `else if` chains are charged on the
253
269
  // nested if instead.
254
- self.top_frame().cognitive_complexity += count_plain_else_branches(current);
270
+ self.top_frame().cognitive_complexity += count_plain_else_branches(current, parent);
255
271
  // Sonar charges flow-breaking jumps: goto and labeled break/continue add one flat point.
256
272
  if is_flow_breaking_jump(current) {
257
273
  self.top_frame().cognitive_complexity += 1;
@@ -259,14 +275,12 @@ impl FunctionBodyPass<'_, '_, '_> {
259
275
 
260
276
  // A sequence of identical boolean operators reads as one condition, so only the operator
261
277
  // starting a sequence adds a cognitive point (Sonar spec).
262
- if is_boolean_operator(current, self.code)
263
- && starts_boolean_operator_sequence(current, self.code)
264
- {
278
+ if is_boolean_operator && starts_boolean_operator_sequence(parent, self.code, current) {
265
279
  self.top_frame().cognitive_complexity += 1;
266
280
  }
267
281
 
268
282
  // Pattern guards add one independent execution path without nesting.
269
- if is_pattern_guard(current) {
283
+ if is_pattern_guard {
270
284
  self.top_frame().cognitive_complexity += 1;
271
285
  }
272
286
 
@@ -275,6 +289,7 @@ impl FunctionBodyPass<'_, '_, '_> {
275
289
  } else {
276
290
  current_nesting
277
291
  };
292
+ self.max_nesting = self.max_nesting.max(child_nesting);
278
293
  if counts_for_own_body {
279
294
  let frame = self.top_frame();
280
295
  frame.nesting_depth = frame
@@ -292,8 +307,10 @@ impl FunctionBodyPass<'_, '_, '_> {
292
307
  // frame the node opens, if any (per-function NCSS includes the declaration node itself).
293
308
  let own_ncss = crate::ncss::ncss_contribution(
294
309
  current,
310
+ parent,
295
311
  &self.sets.ncss_nodes,
296
312
  &self.sets.ncss_containers,
313
+ &self.sets.ncss_bare_body_parents,
297
314
  );
298
315
  let ncss_frame = self.top_frame();
299
316
  ncss_frame.ncss += own_ncss;
@@ -301,6 +318,9 @@ impl FunctionBodyPass<'_, '_, '_> {
301
318
  ncss_frame.has_own_ncss_contribution = true;
302
319
  }
303
320
 
321
+ // Kotlin accessors hang off their property, yet are members of the class body.
322
+ let is_charged_property =
323
+ inside_charged_class_body && current.kind_name() == "property_declaration";
304
324
  for child in all_children(current) {
305
325
  self.visit(
306
326
  child,
@@ -312,7 +332,8 @@ impl FunctionBodyPass<'_, '_, '_> {
312
332
  } else {
313
333
  inside_nested_region
314
334
  },
315
- is_charged_class_body,
335
+ is_charged_class_body
336
+ || (is_charged_property && matches!(child.kind_name(), "getter" | "setter")),
316
337
  );
317
338
  }
318
339
 
@@ -345,196 +366,66 @@ impl FunctionBodyPass<'_, '_, '_> {
345
366
  }
346
367
  }
347
368
 
348
- /// File-level complexity over the whole tree. Cognitive complexity charges nested function/lambda
349
- /// content one nesting level deeper per function boundary crossed (Sonar spec); nesting depth
350
- /// counts every node once.
351
- pub fn measure_complexity(
352
- node: Node<'_>,
353
- sets: &LanguageSets,
354
- code: &Source<'_>,
355
- ) -> ComplexityResult {
356
- let mut result = ComplexityResult {
357
- cognitive_complexity: 0,
358
- nesting_depth: 0,
359
- };
360
-
361
- #[allow(clippy::too_many_arguments)]
362
- fn visit(
363
- current: Node<'_>,
364
- current_nesting: u64,
365
- mut function_nesting_bonus: u64,
366
- mut inside_function: bool,
367
- inside_charged_class_body: bool,
368
- sets: &LanguageSets,
369
- code: &Source<'_>,
370
- result: &mut ComplexityResult,
371
- ) {
372
- // A class body nested in a function (anonymous/local classes) raises the cognitive nesting
373
- // level once for everything inside it — PMD charges the class body, not the methods it
374
- // holds, so methods directly inside a charged class body skip the function-boundary bonus.
375
- let is_charged_class_body = current.kind() == "class_body" && inside_function;
376
- if is_charged_class_body {
377
- function_nesting_bonus += 1;
378
- }
379
- if is_function_boundary(current, &sets.function_nodes) {
380
- if inside_function && !inside_charged_class_body {
381
- function_nesting_bonus += 1;
382
- }
383
- inside_function = true;
384
- }
385
- let cognitive_nesting = current_nesting + function_nesting_bonus;
386
-
387
- // Anonymous keyword tokens can share a type with named nodes (Ruby's `if` node contains an
388
- // `if` keyword token), so only named nodes count as decisions.
389
- let is_decision = current.is_named()
390
- && sets.decision_nodes.contains(current.kind())
391
- && !is_pathless_switch_branch(current, code);
392
- let is_case_clause = current.is_named() && CASE_CLAUSE_NODE_TYPES.contains(&current.kind());
393
- // Ruby's `case ... else` arm is an `else` node; like every other language's default branch
394
- // it nests its contents inside the switch (it cannot go in the Ruby nesting set because
395
- // `if`/`begin` else branches would then double-nest under their already-nesting parent).
396
- let is_nesting = current.is_named()
397
- && (sets.nesting_nodes.contains(current.kind())
398
- || (current.kind() == "else"
399
- && current.parent().is_some_and(|parent| {
400
- parent.kind() == "case" || parent.kind() == "case_match"
401
- })));
402
- // `elsif`/`elif`/`else if` continue a flat chain: they add a decision without a nesting
403
- // surcharge (Sonar cognitive-complexity semantics).
404
- let is_continuation = is_decision && is_flat_chain_continuation(current);
405
-
406
- if is_decision && !is_case_clause {
407
- result.cognitive_complexity += if is_continuation {
408
- 1
409
- } else {
410
- 1 + cognitive_nesting
411
- };
412
- }
413
- if current.is_named() && SWITCH_LIKE_NODE_TYPES.contains(&current.kind()) {
414
- result.cognitive_complexity += 1 + cognitive_nesting;
415
- }
416
- // A plain `else` branch adds one flat cognitive point; `else if` chains are charged on the
417
- // nested if instead.
418
- result.cognitive_complexity += count_plain_else_branches(current);
419
- // Sonar charges flow-breaking jumps: goto and labeled break/continue add one flat point.
420
- if is_flow_breaking_jump(current) {
421
- result.cognitive_complexity += 1;
422
- }
423
-
424
- // A sequence of identical boolean operators reads as one condition, so only the operator
425
- // starting a sequence adds a cognitive point (Sonar spec).
426
- if is_boolean_operator(current, code) && starts_boolean_operator_sequence(current, code) {
427
- result.cognitive_complexity += 1;
428
- }
429
-
430
- // Pattern guards add one independent execution path without nesting.
431
- if is_pattern_guard(current) {
432
- result.cognitive_complexity += 1;
433
- }
434
-
435
- let child_nesting = if is_nesting && !is_continuation {
436
- current_nesting + 1
437
- } else {
438
- current_nesting
439
- };
440
- result.nesting_depth = result.nesting_depth.max(child_nesting);
441
-
442
- for child in all_children(current) {
443
- visit(
444
- child,
445
- child_nesting,
446
- function_nesting_bonus,
447
- inside_function,
448
- is_charged_class_body,
449
- sets,
450
- code,
451
- result,
452
- );
453
- }
454
- }
455
-
456
- for child in all_children(node) {
457
- visit(child, 0, 0, false, false, sets, code, &mut result);
458
- }
459
-
460
- result
461
- }
462
-
463
369
  /// Plain else branches attached to `current`: an `else_clause`/Ruby `else` whose branch is not an
464
- /// `else if` continuation, or a bare Java/Go `alternative:` statement without a clause wrapper.
465
- fn count_plain_else_branches(current: Node<'_>) -> u64 {
370
+ /// `else if` continuation, or a bare Java/Go/Kotlin `alternative:` branch without a clause wrapper.
371
+ fn count_plain_else_branches(current: Node<'_>, parent: Option<Node<'_>>) -> u64 {
466
372
  if !current.is_named() {
467
373
  return 0;
468
374
  }
469
- let kind = current.kind();
375
+ let kind = current.kind_name();
470
376
  if kind == "else" {
471
377
  // A Ruby `case ... else` is the default arm of a switch, which already counts as a whole
472
378
  // (sonar-ruby models it as a match case, not an else branch); `if`/`unless`/`begin` else
473
379
  // branches count one point each.
474
- return if current
475
- .parent()
476
- .is_some_and(|parent| parent.kind() == "case" || parent.kind() == "case_match")
477
- {
478
- 0
479
- } else {
480
- 1
481
- };
380
+ return u64::from(!is_case_else_parent(parent));
482
381
  }
483
382
  if kind == "else_clause" {
484
383
  let has_if_like_child = crate::util::named_children(current)
485
384
  .iter()
486
- .any(|child| IF_LIKE_NODE_TYPES.contains(&child.kind()));
385
+ .any(|child| IF_LIKE_NODE_TYPES.contains(&child.kind_name()));
487
386
  return if has_if_like_child { 0 } else { 1 };
488
387
  }
489
388
  if kind != "if_statement" && kind != "if_expression" {
490
389
  return 0;
491
390
  }
492
- // Kotlin's `else` is a bare keyword token followed by the branch body (no clause wrapper and
493
- // no grammar field); an `else if` continuation is charged on the nested if instead.
494
- if let Some(else_body) = crate::util::kotlin_else_body(current) {
495
- return u64::from(!crate::util::is_kotlin_else_if_body(else_body));
496
- }
497
391
  // Extras (comments) inherit the preceding sibling's field in find_children_by_field_name, so a
498
392
  // comment between an `elif_clause` and `else_clause` must not be miscounted as a bare branch.
499
393
  crate::util::find_children_by_field_name(current, "alternative")
500
394
  .iter()
501
395
  .filter(|child| {
502
396
  !child.is_extra()
503
- && child.kind() != "else_clause"
504
- && child.kind() != "elif_clause"
505
- && !IF_LIKE_NODE_TYPES.contains(&child.kind())
397
+ && child.kind_name() != "else_clause"
398
+ && child.kind_name() != "elif_clause"
399
+ && !IF_LIKE_NODE_TYPES.contains(&child.kind_name())
506
400
  })
507
401
  .count() as u64
508
402
  }
509
403
 
510
- /// goto, and break/continue that jump to a label (their only named child is the label).
404
+ /// goto, and break/continue that jump to a label.
405
+ // Inlined into the recursive visit, its field lookup enlarges every frame enough to cut the depth the
406
+ // Workers runtime's stack can measure by ~5%.
407
+ #[inline(never)]
511
408
  fn is_flow_breaking_jump(node: Node<'_>) -> bool {
512
409
  if !node.is_named() {
513
410
  return false;
514
411
  }
515
- if node.kind() == "goto_statement" {
412
+ if node.kind_name() == "goto_statement" {
516
413
  return true;
517
414
  }
518
- // Rust jumps are expressions; `break value` carries a named expression child, so only an
519
- // explicit `label` child marks a labeled jump.
520
- if node.kind() == "break_expression" || node.kind() == "continue_expression" {
521
- return crate::util::named_children(node)
522
- .iter()
523
- .any(|child| child.kind() == "label" || child.kind() == "loop_label");
524
- }
525
- // Kotlin folds every jump into `jump_expression`; the grammar tokenizes a labeled break or
526
- // continue as `break@`/`continue@` followed by the label (`return@label` is a plain return).
527
- if node.kind() == "jump_expression" {
528
- return node
529
- .child(0)
530
- .is_some_and(|keyword| keyword.kind() == "break@" || keyword.kind() == "continue@");
415
+ // Rust and Kotlin jumps are expressions; a Rust `break value` carries a named expression child,
416
+ // so only an explicit Rust `label` child or Kotlin `label` field marks a labeled jump.
417
+ if node.kind_name() == "break_expression" || node.kind_name() == "continue_expression" {
418
+ return node.child_by_field_name("label").is_some()
419
+ || crate::util::named_children(node)
420
+ .iter()
421
+ .any(|child| child.kind_name() == "label" || child.kind_name() == "loop_label");
531
422
  }
532
423
  // Comments are named children too (`break /* done */;`), so only non-comment children mark a
533
424
  // label.
534
- (node.kind() == "break_statement" || node.kind() == "continue_statement")
425
+ (node.kind_name() == "break_statement" || node.kind_name() == "continue_statement")
535
426
  && crate::util::named_children(node)
536
427
  .iter()
537
- .any(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind()))
428
+ .any(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind_name()))
538
429
  }
539
430
 
540
431
  /// Wrappers that are transparent when locating the enclosing boolean operation: PMD/Sonar keep a
@@ -549,21 +440,25 @@ const PARENTHESIZED_NODE_TYPES: &[&str] = &[
549
440
  /// run of same-operator binaries (possibly through parentheses). Only the root operator counts one
550
441
  /// cognitive point: `a && b && c` and `a && (b && c)` cost one, `a && b || c` costs two, matching
551
442
  /// the Sonar specification and PMD 7.26.0.
552
- fn starts_boolean_operator_sequence(token: Node<'_>, code: &Source<'_>) -> bool {
553
- let Some(binary) = token.parent() else {
443
+ fn starts_boolean_operator_sequence(
444
+ binary: Option<Node<'_>>,
445
+ code: &Source<'_>,
446
+ token: Node<'_>,
447
+ ) -> bool {
448
+ let Some(binary) = binary else {
554
449
  return true;
555
450
  };
556
- let mut ancestor = binary.parent();
451
+ let mut ancestor = binary.parent_node();
557
452
  while let Some(node) = ancestor {
558
- if !PARENTHESIZED_NODE_TYPES.contains(&node.kind()) {
453
+ if !PARENTHESIZED_NODE_TYPES.contains(&node.kind_name()) {
559
454
  break;
560
455
  }
561
- ancestor = node.parent();
456
+ ancestor = node.parent_node();
562
457
  }
563
458
  let Some(ancestor) = ancestor else {
564
459
  return true;
565
460
  };
566
- if ancestor.kind() != binary.kind() {
461
+ if ancestor.kind_name() != binary.kind_name() {
567
462
  return true;
568
463
  }
569
464
  find_boolean_operator_text(ancestor, code).map(normalize_boolean_operator)
@@ -596,46 +491,38 @@ fn find_boolean_operator_text<'a>(binary_node: Node<'_>, code: &Source<'a>) -> O
596
491
  /// ternaries and conditions, which are charged. A C# exception filter (`catch (E e) when (...)`, a `catch_filter_clause`) is
597
492
  /// deliberately not charged: the catch itself already counts, and the filter is part of the same
598
493
  /// handler condition rather than an extra path (SonarC# does not charge it either).
599
- fn is_pattern_guard(node: Node<'_>) -> bool {
494
+ fn is_pattern_guard(node: Node<'_>, parent: Option<Node<'_>>) -> bool {
600
495
  if !node.is_named() {
601
496
  return false;
602
497
  }
603
- let kind = node.kind();
498
+ let kind = node.kind_name();
604
499
  if kind == "guard" || kind == "when_clause" || kind == "if_guard" || kind == "unless_guard" {
605
500
  return true;
606
501
  }
607
502
  if kind == "if_clause" {
608
- return node
609
- .parent()
610
- .is_some_and(|parent| parent.kind() == "case_clause");
503
+ return parent.is_some_and(|parent| parent.kind_name() == "case_clause");
611
504
  }
612
505
  kind == "match_pattern"
613
506
  && all_children(node)
614
507
  .iter()
615
- .any(|child| !child.is_named() && child.kind() == "if")
508
+ .any(|child| !child.is_named() && child.kind_name() == "if")
616
509
  }
617
510
 
618
511
  /// Ruby `elsif`, Python `elif`, and `else if` (an if node in an else/alternative position).
619
- fn is_flat_chain_continuation(node: Node<'_>) -> bool {
620
- let kind = node.kind();
512
+ fn is_flat_chain_continuation(node: Node<'_>, parent: Option<Node<'_>>) -> bool {
513
+ let kind = node.kind_name();
621
514
  if kind == "elsif" || kind == "elif_clause" {
622
515
  return true;
623
516
  }
624
517
  if kind != "if_statement" && kind != "if_expression" && kind != "if" {
625
518
  return false;
626
519
  }
627
- let Some(parent) = node.parent() else {
520
+ let Some(parent) = parent else {
628
521
  return false;
629
522
  };
630
- // Kotlin puts a braceless `else if` directly in the else branch's control_structure_body.
631
- if parent.kind() == "control_structure_body" {
632
- return parent
633
- .parent()
634
- .and_then(crate::util::kotlin_else_body)
635
- .is_some_and(|else_body| else_body.id() == parent.id());
636
- }
637
- // JS/C/C++/Rust/C# wrap `else if` in an else clause or put it directly in `alternative`.
638
- parent.kind() == "else_clause"
523
+ // JS/C/C++/Rust/C# wrap `else if` in an else clause; Java/Go/Kotlin put it directly in
524
+ // `alternative`.
525
+ parent.kind_name() == "else_clause"
639
526
  || parent
640
527
  .child_by_field_name("alternative")
641
528
  .is_some_and(|alternative| alternative.id() == node.id())
@@ -653,7 +540,7 @@ fn is_pathless_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
653
540
  let mut stacked = vec![node];
654
541
  let mut previous = node.prev_named_sibling();
655
542
  while let Some(sibling) = previous {
656
- if !crate::ncss::COMMENT_NODE_TYPES.contains(&sibling.kind()) {
543
+ if !crate::ncss::COMMENT_NODE_TYPES.contains(&sibling.kind_name()) {
657
544
  if !is_label_only_case(sibling) {
658
545
  break;
659
546
  }
@@ -673,10 +560,10 @@ fn is_pathless_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
673
560
  /// pattern (Java `when`, Rust `if`).
674
561
  fn has_pattern_guard(node: Node<'_>) -> bool {
675
562
  crate::util::named_children(node).into_iter().any(|child| {
676
- is_pattern_guard(child)
563
+ is_pattern_guard(child, Some(node))
677
564
  || crate::util::named_children(child)
678
565
  .into_iter()
679
- .any(is_pattern_guard)
566
+ .any(|grandchild| is_pattern_guard(grandchild, Some(child)))
680
567
  })
681
568
  }
682
569
 
@@ -684,28 +571,28 @@ fn has_pattern_guard(node: Node<'_>) -> bool {
684
571
  /// stacked label as its own case node.
685
572
  fn is_label_only_case(node: Node<'_>) -> bool {
686
573
  let children = non_comment_children(node);
687
- match node.kind() {
574
+ match node.kind_name() {
688
575
  "case_statement" => {
689
576
  let value = node.child_by_field_name("value").map(|value| value.id());
690
577
  children.iter().all(|child| Some(child.id()) == value)
691
578
  }
692
579
  "switch_case" | "switch_default" => node.child_by_field_name("body").is_none(),
693
- "switch_block_statement_group" => {
694
- children.iter().all(|child| child.kind() == "switch_label")
695
- }
580
+ "switch_block_statement_group" => children
581
+ .iter()
582
+ .all(|child| child.kind_name() == "switch_label"),
696
583
  // A C# label is a pattern (`case 1:` parses as a constant pattern) plus an optional `when`
697
584
  // guard; anything else, a `#if` block included, is the section's body.
698
585
  "switch_section" => children.iter().all(|child| {
699
- child.kind().ends_with("pattern")
700
- || child.kind() == "discard"
701
- || child.kind() == "when_clause"
586
+ child.kind_name().ends_with("pattern")
587
+ || child.kind_name() == "discard"
588
+ || child.kind_name() == "when_clause"
702
589
  }),
703
590
  _ => false,
704
591
  }
705
592
  }
706
593
 
707
594
  fn is_default_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
708
- let kind = node.kind();
595
+ let kind = node.kind_name();
709
596
  if kind == "switch_default" {
710
597
  return true;
711
598
  }
@@ -718,12 +605,12 @@ fn is_default_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
718
605
  if kind == "switch_block_statement_group" || kind == "switch_rule" {
719
606
  return non_comment_children(node)
720
607
  .into_iter()
721
- .filter(|child| child.kind() == "switch_label")
608
+ .filter(|child| child.kind_name() == "switch_label")
722
609
  .any(|label| {
723
610
  let parts = non_comment_children(label);
724
611
  parts.is_empty()
725
612
  || parts.iter().any(|part| {
726
- part.kind() == "identifier" && node_text(*part, code) == "default"
613
+ part.kind_name() == "identifier" && node_text(*part, code) == "default"
727
614
  })
728
615
  });
729
616
  }
@@ -733,7 +620,9 @@ fn is_default_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
733
620
  // branches, which is_pattern_guard charges, like Python's `case _ if cond:` and Rust's
734
621
  // `_ if cond =>`.
735
622
  if kind == "switch_section" {
736
- return node.child(0).is_some_and(|first| first.kind() == "default")
623
+ return node
624
+ .child(0)
625
+ .is_some_and(|first| first.kind_name() == "default")
737
626
  || crate::util::named_children(node)
738
627
  .into_iter()
739
628
  .any(is_csharp_catch_all_pattern);
@@ -744,9 +633,7 @@ fn is_default_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
744
633
  .is_some_and(|first| is_csharp_catch_all_pattern(*first));
745
634
  }
746
635
  if kind == "when_entry" {
747
- return !crate::util::named_children(node)
748
- .iter()
749
- .any(|child| child.kind() == "when_condition");
636
+ return node.child_by_field_name("condition").is_none();
750
637
  }
751
638
 
752
639
  // Python arms with an irrefutable pattern are unconditional like `default`.
@@ -755,24 +642,26 @@ fn is_default_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
755
642
  if kind == "case_clause" {
756
643
  let patterns: Vec<Node<'_>> = crate::util::named_children(node)
757
644
  .into_iter()
758
- .filter(|child| child.kind() == "case_pattern")
645
+ .filter(|child| child.kind_name() == "case_pattern")
759
646
  .collect();
760
- return !all_children(node).iter().any(|child| child.kind() == ",")
647
+ return !all_children(node)
648
+ .iter()
649
+ .any(|child| child.kind_name() == ",")
761
650
  && matches!(patterns[..], [pattern] if is_python_irrefutable_pattern(pattern));
762
651
  }
763
652
  // Rust `_ =>` (optionally guarded) fallback arms.
764
653
  if kind == "match_arm" {
765
654
  return crate::util::named_children(node)
766
655
  .into_iter()
767
- .find(|child| child.kind() == "match_pattern")
656
+ .find(|child| child.kind_name() == "match_pattern")
768
657
  .is_some_and(|pattern| {
769
658
  // The guard keyword is anonymous, so filter all children, not just named ones.
770
659
  let parts: Vec<Node<'_>> = all_children(pattern)
771
660
  .into_iter()
772
- .filter(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind()))
661
+ .filter(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind_name()))
773
662
  .collect();
774
- matches!(parts[..], [first, ..] if first.kind() == "_")
775
- && parts.get(1).is_none_or(|second| second.kind() == "if")
663
+ matches!(parts[..], [first, ..] if first.kind_name() == "_")
664
+ && parts.get(1).is_none_or(|second| second.kind_name() == "if")
776
665
  });
777
666
  }
778
667
 
@@ -780,7 +669,7 @@ fn is_default_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
780
669
  if kind == "in_clause" {
781
670
  return node
782
671
  .named_child(0)
783
- .is_some_and(|first| first.kind() == "identifier");
672
+ .is_some_and(|first| first.kind_name() == "identifier");
784
673
  }
785
674
 
786
675
  false
@@ -790,19 +679,23 @@ fn is_default_switch_branch(node: Node<'_>, code: &Source<'_>) -> bool {
790
679
  /// `p | q` when `p` (or, for `|`, any alternative) is irrefutable. A group parses as a one-element
791
680
  /// tuple pattern that differs from the real tuple `(p,)` only by the comma token.
792
681
  fn is_python_irrefutable_pattern(node: Node<'_>) -> bool {
793
- match node.kind() {
682
+ match node.kind_name() {
794
683
  "_" => true,
795
684
  "case_pattern" => match non_comment_children(node)[..] {
796
- [] => all_children(node).iter().any(|child| child.kind() == "_"),
685
+ [] => all_children(node)
686
+ .iter()
687
+ .any(|child| child.kind_name() == "_"),
797
688
  [inner] => is_python_irrefutable_pattern(inner),
798
689
  _ => false,
799
690
  },
800
691
  "dotted_name" => matches!(
801
692
  non_comment_children(node)[..],
802
- [name] if name.kind() == "identifier"
693
+ [name] if name.kind_name() == "identifier"
803
694
  ),
804
695
  "tuple_pattern" => {
805
- !all_children(node).iter().any(|child| child.kind() == ",")
696
+ !all_children(node)
697
+ .iter()
698
+ .any(|child| child.kind_name() == ",")
806
699
  && matches!(
807
700
  non_comment_children(node)[..],
808
701
  [inner] if is_python_irrefutable_pattern(inner)
@@ -821,36 +714,40 @@ fn is_python_irrefutable_pattern(node: Node<'_>) -> bool {
821
714
  fn non_comment_children<'t>(node: Node<'t>) -> Vec<Node<'t>> {
822
715
  crate::util::named_children(node)
823
716
  .into_iter()
824
- .filter(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind()))
717
+ .filter(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind_name()))
825
718
  .collect()
826
719
  }
827
720
 
828
721
  /// C# patterns that match every value: the discard `_` and `var x`/`var _`, possibly parenthesized
829
722
  /// (but not a `var (a, b)` deconstruction, which requires a deconstructible value).
830
723
  fn is_csharp_catch_all_pattern(node: Node<'_>) -> bool {
831
- if node.kind() == "parenthesized_pattern" {
724
+ if node.kind_name() == "parenthesized_pattern" {
832
725
  return crate::util::named_children(node)
833
726
  .into_iter()
834
- .find(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind()))
727
+ .find(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind_name()))
835
728
  .is_some_and(is_csharp_catch_all_pattern);
836
729
  }
837
- node.kind() == "discard"
838
- || (node.kind() == "declaration_pattern"
730
+ node.kind_name() == "discard"
731
+ || (node.kind_name() == "declaration_pattern"
839
732
  && node
840
733
  .child_by_field_name("type")
841
- .is_some_and(|ty| ty.kind() == "implicit_type")
734
+ .is_some_and(|ty| ty.kind_name() == "implicit_type")
842
735
  && !crate::util::named_children(node)
843
736
  .iter()
844
- .any(|child| child.kind() == "parenthesized_variable_designation"))
737
+ .any(|child| child.kind_name() == "parenthesized_variable_designation"))
845
738
  }
846
739
 
847
740
  /// The parent guard is required because the same tokens appear in non-boolean syntax (C++ `int&&`,
848
741
  /// `operator&&`, Rust's empty closure parameter list `|| 5`).
849
- fn is_boolean_operator(node: Node<'_>, code: &Source<'_>) -> bool {
742
+ fn is_boolean_operator(node: Node<'_>, parent: Option<Node<'_>>, code: &Source<'_>) -> bool {
850
743
  if node.is_named() || !BOOLEAN_OPERATORS.contains(&node_text(node, code)) {
851
744
  return false;
852
745
  }
853
746
 
854
- node.parent()
855
- .is_some_and(|parent| BOOLEAN_OPERATOR_PARENT_TYPES.contains(&parent.kind()))
747
+ parent.is_some_and(|parent| BOOLEAN_OPERATOR_PARENT_TYPES.contains(&parent.kind_name()))
748
+ }
749
+
750
+ /// A Ruby `case ... else` arm (see count_plain_else_branches).
751
+ fn is_case_else_parent(parent: Option<Node<'_>>) -> bool {
752
+ parent.is_some_and(|parent| parent.kind_name() == "case" || parent.kind_name() == "case_match")
856
753
  }