code-gauge 3.0.0 → 4.0.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 (79) hide show
  1. package/README.md +83 -21
  2. package/dist/cli.cjs +3 -3
  3. package/dist/cli.cjs.map +1 -1
  4. package/dist/cli.js +3 -3
  5. package/dist/cli.js.map +1 -1
  6. package/dist/cliConfig.cjs +1 -1
  7. package/dist/cliConfig.cjs.map +1 -1
  8. package/dist/cliConfig.d.ts +11 -0
  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/diffCommand.cjs +5 -0
  16. package/dist/diffCommand.cjs.map +1 -0
  17. package/dist/diffCommand.d.ts +17 -0
  18. package/dist/diffCommand.js +5 -0
  19. package/dist/diffCommand.js.map +1 -0
  20. package/dist/duplication.cjs +1 -1
  21. package/dist/duplication.cjs.map +1 -1
  22. package/dist/duplication.d.ts +20 -24
  23. package/dist/duplication.js +1 -1
  24. package/dist/duplication.js.map +1 -1
  25. package/dist/git.cjs +2 -0
  26. package/dist/git.cjs.map +1 -0
  27. package/dist/git.d.ts +27 -0
  28. package/dist/git.js +2 -0
  29. package/dist/git.js.map +1 -0
  30. package/dist/index.cjs +1 -1
  31. package/dist/index.d.ts +3 -2
  32. package/dist/index.js +1 -1
  33. package/dist/languages.cjs +1 -1
  34. package/dist/languages.cjs.map +1 -1
  35. package/dist/languages.d.ts +5 -0
  36. package/dist/languages.js +1 -1
  37. package/dist/languages.js.map +1 -1
  38. package/dist/metrics.cjs +1 -1
  39. package/dist/metrics.cjs.map +1 -1
  40. package/dist/metrics.d.ts +18 -5
  41. package/dist/metrics.js +1 -1
  42. package/dist/metrics.js.map +1 -1
  43. package/dist/nativeMetrics.cjs +3 -1
  44. package/dist/nativeMetrics.cjs.map +1 -1
  45. package/dist/nativeMetrics.d.ts +29 -10
  46. package/dist/nativeMetrics.js +3 -1
  47. package/dist/nativeMetrics.js.map +1 -1
  48. package/dist/regressionGate.cjs +2 -0
  49. package/dist/regressionGate.cjs.map +1 -0
  50. package/dist/regressionGate.d.ts +106 -0
  51. package/dist/regressionGate.js +2 -0
  52. package/dist/regressionGate.js.map +1 -0
  53. package/dist/scan.cjs +2 -0
  54. package/dist/scan.cjs.map +1 -0
  55. package/dist/scan.d.ts +55 -0
  56. package/dist/scan.js +2 -0
  57. package/dist/scan.js.map +1 -0
  58. package/dist/types.d.ts +18 -13
  59. package/native/Cargo.lock +523 -0
  60. package/native/Cargo.toml +45 -0
  61. package/native/build.rs +3 -0
  62. package/native/src/complexity.rs +627 -0
  63. package/native/src/dep_degree.rs +253 -0
  64. package/native/src/duplication.rs +2007 -0
  65. package/native/src/functions.rs +345 -0
  66. package/native/src/languages.rs +647 -0
  67. package/native/src/lib.rs +101 -0
  68. package/native/src/measure.rs +590 -0
  69. package/native/src/ncss.rs +263 -0
  70. package/native/src/types.rs +135 -0
  71. package/native/src/util.rs +139 -0
  72. package/package.json +16 -19
  73. package/scripts/buildNative.mjs +25 -0
  74. package/scripts/installNative.mjs +96 -0
  75. package/dist/ncss.cjs +0 -2
  76. package/dist/ncss.cjs.map +0 -1
  77. package/dist/ncss.d.ts +0 -17
  78. package/dist/ncss.js +0 -2
  79. package/dist/ncss.js.map +0 -1
@@ -0,0 +1,627 @@
1
+ use std::collections::{HashMap, HashSet};
2
+ use tree_sitter::Node;
3
+
4
+ use crate::util::{all_children, node_text, Source};
5
+
6
+ pub struct ComplexityResult {
7
+ pub cognitive_complexity: u64,
8
+ pub nesting_depth: u64,
9
+ }
10
+
11
+ /// Node-type lookup sets built once per measurement from the language definition.
12
+ 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>,
18
+ }
19
+
20
+ impl LanguageSets {
21
+ pub fn new(language: &crate::languages::LanguageDefinition) -> Self {
22
+ LanguageSets {
23
+ function_nodes: language.function_node_types.iter().copied().collect(),
24
+ decision_nodes: language.decision_node_types.iter().copied().collect(),
25
+ nesting_nodes: language.nesting_node_types.iter().copied().collect(),
26
+ ncss_nodes: language.ncss_node_types.iter().copied().collect(),
27
+ ncss_containers: language.ncss_container_node_types.iter().copied().collect(),
28
+ }
29
+ }
30
+ }
31
+
32
+ const BOOLEAN_OPERATORS: &[&str] = &["&&", "||", "and", "or"];
33
+ /// Parents under which `&&`/`||`/`and`/`or` tokens are actual boolean operators.
34
+ const BOOLEAN_OPERATOR_PARENT_TYPES: &[&str] = &["binary_expression", "binary", "boolean_operator"];
35
+
36
+ /// A Ruby stabby lambda's body block is part of the lambda, not a separate function.
37
+ pub fn is_lambda_body_block(node: Node<'_>) -> bool {
38
+ (node.kind() == "block" || node.kind() == "do_block")
39
+ && node
40
+ .parent()
41
+ .is_some_and(|parent| parent.kind() == "lambda")
42
+ }
43
+
44
+ pub fn is_function_boundary(node: Node<'_>, function_nodes: &HashSet<&'static str>) -> bool {
45
+ function_nodes.contains(node.kind()) && !is_lambda_body_block(node)
46
+ }
47
+
48
+ // Sonar cognitive complexity charges a switch/match once as a whole, not per case label. Only
49
+ // named nodes are consulted, so anonymous keyword tokens never match.
50
+ const SWITCH_LIKE_NODE_TYPES: &[&str] = &[
51
+ "switch_statement",
52
+ "switch_expression",
53
+ "expression_switch_statement",
54
+ "type_switch_statement",
55
+ "select_statement",
56
+ "match_expression",
57
+ "match_statement",
58
+ "case",
59
+ "case_match",
60
+ ];
61
+
62
+ // Per-case decision nodes add no cognitive point because the switch itself carries the cost.
63
+ const CASE_CLAUSE_NODE_TYPES: &[&str] = &[
64
+ "case_clause",
65
+ "switch_case",
66
+ "switch_block_statement_group",
67
+ "switch_rule",
68
+ "case_statement",
69
+ "expression_case",
70
+ "type_case",
71
+ "communication_case",
72
+ "match_arm",
73
+ "when",
74
+ "in_clause",
75
+ ];
76
+
77
+ const IF_LIKE_NODE_TYPES: &[&str] = &["if_statement", "if_expression", "if", "unless"];
78
+
79
+ pub struct FunctionBodyMetrics {
80
+ pub cognitive_complexity: u64,
81
+ pub nesting_depth: u64,
82
+ pub ncss: u64,
83
+ }
84
+
85
+ /// Accumulator for one function body during measure_function_body_metrics' post-order pass.
86
+ struct FunctionBodyFrame {
87
+ cognitive_complexity: u64,
88
+ /// Count of `1 + nesting` cognitive increments, for re-basing on hoist into the parent frame.
89
+ nesting_sensitive_count: u64,
90
+ nesting_depth: u64,
91
+ ncss: u64,
92
+ has_own_ncss_contribution: bool,
93
+ /// Cognitive nesting (structural nesting + function/class bonuses) carried into this body.
94
+ entry_cognitive_nesting: u64,
95
+ /// Structural nesting carried into this body (bonuses excluded), for nesting depth.
96
+ entry_structural_nesting: u64,
97
+ }
98
+
99
+ impl FunctionBodyFrame {
100
+ fn new(entry_cognitive_nesting: u64, entry_structural_nesting: u64) -> Self {
101
+ FunctionBodyFrame {
102
+ cognitive_complexity: 0,
103
+ nesting_sensitive_count: 0,
104
+ nesting_depth: 0,
105
+ ncss: 0,
106
+ has_own_ncss_contribution: false,
107
+ entry_cognitive_nesting,
108
+ entry_structural_nesting,
109
+ }
110
+ }
111
+ }
112
+
113
+ struct FunctionBodyPass<'sets, 'code, 'source> {
114
+ sets: &'sets LanguageSets,
115
+ code: &'code Source<'source>,
116
+ frames: Vec<FunctionBodyFrame>,
117
+ results: HashMap<usize, FunctionBodyMetrics>,
118
+ }
119
+
120
+ /// Per-function complexity and NCSS for every function boundary, in one post-order pass so each
121
+ /// node is visited once instead of once per enclosing function (issue #35). A function's metrics
122
+ /// are its own-body contributions plus, per directly nested function, that function's
123
+ /// already-computed totals: NCSS hoists as-is; cognitive complexity re-bases the nested function's
124
+ /// nesting-sensitive increments (each worth `1 + nesting`) by the nesting offset at the embedding
125
+ /// site, while flat increments (else branches, boolean-operator sequences, chain continuations,
126
+ /// jumps, guards) hoist unchanged; nesting depth describes the own body only, so it does not
127
+ /// hoist.
128
+ pub fn measure_function_body_metrics(
129
+ root: Node<'_>,
130
+ sets: &LanguageSets,
131
+ code: &Source<'_>,
132
+ ) -> HashMap<usize, FunctionBodyMetrics> {
133
+ let mut pass = FunctionBodyPass {
134
+ sets,
135
+ code,
136
+ // frames[0] is a sentinel for top-level code; its accumulation is discarded.
137
+ frames: vec![FunctionBodyFrame::new(0, 0)],
138
+ results: HashMap::new(),
139
+ };
140
+ pass.visit(root, 0, 0, false, false, false);
141
+ pass.results
142
+ }
143
+
144
+ impl FunctionBodyPass<'_, '_, '_> {
145
+ fn visit(
146
+ &mut self,
147
+ current: Node<'_>,
148
+ current_nesting: u64,
149
+ mut function_nesting_bonus: u64,
150
+ mut inside_function: bool,
151
+ mut inside_nested_region: bool,
152
+ inside_charged_class_body: bool,
153
+ ) {
154
+ // A class body nested in a function (anonymous/local classes) raises the cognitive nesting
155
+ // level once for everything inside it — PMD charges the class body, not the methods it
156
+ // holds, so methods directly inside a charged class body skip the function-boundary bonus.
157
+ let is_charged_class_body = current.kind() == "class_body" && inside_function;
158
+ if is_charged_class_body {
159
+ inside_nested_region = true;
160
+ function_nesting_bonus += 1;
161
+ }
162
+ let opens_frame = is_function_boundary(current, &self.sets.function_nodes);
163
+ if opens_frame {
164
+ if inside_function && !inside_charged_class_body {
165
+ function_nesting_bonus += 1;
166
+ }
167
+ inside_function = true;
168
+ }
169
+ // The node's own increments target the frame it is embedded in, not the one it opens; a
170
+ // frame-opening or charged-class-body node contributes nothing to that frame's own body
171
+ // (nesting), matching the per-function traversal this pass replaces.
172
+ let entry_cognitive_nesting = self.top_frame().entry_cognitive_nesting;
173
+ let entry_structural_nesting = self.top_frame().entry_structural_nesting;
174
+ let relative_nesting = current_nesting + function_nesting_bonus - entry_cognitive_nesting;
175
+ let counts_for_own_body = !inside_nested_region && !opens_frame;
176
+
177
+ // Anonymous keyword tokens can share a type with named nodes (Ruby's `if` node contains an
178
+ // `if` keyword token), so only named nodes count as decisions.
179
+ let is_decision = current.is_named()
180
+ && self.sets.decision_nodes.contains(current.kind())
181
+ && !is_default_switch_branch(current);
182
+ let is_case_clause = current.is_named() && CASE_CLAUSE_NODE_TYPES.contains(&current.kind());
183
+ // Ruby's `case ... else` arm is an `else` node; like every other language's default branch
184
+ // it nests its contents inside the switch (it cannot go in the Ruby nesting set because
185
+ // `if`/`begin` else branches would then double-nest under their already-nesting parent).
186
+ let is_nesting = current.is_named()
187
+ && (self.sets.nesting_nodes.contains(current.kind())
188
+ || (current.kind() == "else"
189
+ && current.parent().is_some_and(|parent| {
190
+ parent.kind() == "case" || parent.kind() == "case_match"
191
+ })));
192
+ // `elsif`/`elif`/`else if` continue a flat chain: they add a decision without a nesting
193
+ // surcharge (Sonar cognitive-complexity semantics).
194
+ let is_continuation = is_decision && is_flat_chain_continuation(current);
195
+
196
+ if is_decision && !is_case_clause {
197
+ if is_continuation {
198
+ self.top_frame().cognitive_complexity += 1;
199
+ } else {
200
+ self.top_frame().cognitive_complexity += 1 + relative_nesting;
201
+ self.top_frame().nesting_sensitive_count += 1;
202
+ }
203
+ }
204
+ if current.is_named() && SWITCH_LIKE_NODE_TYPES.contains(&current.kind()) {
205
+ self.top_frame().cognitive_complexity += 1 + relative_nesting;
206
+ self.top_frame().nesting_sensitive_count += 1;
207
+ }
208
+ // A plain `else` branch adds one flat cognitive point; `else if` chains are charged on the
209
+ // nested if instead.
210
+ self.top_frame().cognitive_complexity += count_plain_else_branches(current);
211
+ // Sonar charges flow-breaking jumps: goto and labeled break/continue add one flat point.
212
+ if is_flow_breaking_jump(current) {
213
+ self.top_frame().cognitive_complexity += 1;
214
+ }
215
+
216
+ // A sequence of identical boolean operators reads as one condition, so only the operator
217
+ // starting a sequence adds a cognitive point (Sonar spec).
218
+ if is_boolean_operator(current, self.code)
219
+ && starts_boolean_operator_sequence(current, self.code)
220
+ {
221
+ self.top_frame().cognitive_complexity += 1;
222
+ }
223
+
224
+ // Pattern guards add one independent execution path without nesting.
225
+ if is_pattern_guard(current) {
226
+ self.top_frame().cognitive_complexity += 1;
227
+ }
228
+
229
+ let child_nesting = if is_nesting && !is_continuation {
230
+ current_nesting + 1
231
+ } else {
232
+ current_nesting
233
+ };
234
+ if counts_for_own_body {
235
+ let frame = self.top_frame();
236
+ frame.nesting_depth = frame
237
+ .nesting_depth
238
+ .max(child_nesting - entry_structural_nesting);
239
+ }
240
+
241
+ if opens_frame {
242
+ self.frames.push(FunctionBodyFrame::new(
243
+ child_nesting + function_nesting_bonus,
244
+ child_nesting,
245
+ ));
246
+ }
247
+ // The node's NCSS contribution belongs to the innermost frame whose subtree holds it — the
248
+ // frame the node opens, if any (per-function NCSS includes the declaration node itself).
249
+ let own_ncss = crate::ncss::ncss_contribution(
250
+ current,
251
+ &self.sets.ncss_nodes,
252
+ &self.sets.ncss_containers,
253
+ );
254
+ let ncss_frame = self.top_frame();
255
+ ncss_frame.ncss += own_ncss;
256
+ if opens_frame && own_ncss > 0 {
257
+ ncss_frame.has_own_ncss_contribution = true;
258
+ }
259
+
260
+ for child in all_children(current) {
261
+ self.visit(
262
+ child,
263
+ child_nesting,
264
+ function_nesting_bonus,
265
+ inside_function,
266
+ if opens_frame {
267
+ false
268
+ } else {
269
+ inside_nested_region
270
+ },
271
+ is_charged_class_body,
272
+ );
273
+ }
274
+
275
+ if opens_frame {
276
+ let closed = self.frames.pop().expect("frame opened above");
277
+ self.results.insert(
278
+ current.id(),
279
+ FunctionBodyMetrics {
280
+ cognitive_complexity: closed.cognitive_complexity,
281
+ nesting_depth: closed.nesting_depth,
282
+ // A function node without a countable declaration of its own (arrow functions,
283
+ // lambdas, blocks) still counts 1 for the declaration itself.
284
+ ncss: closed.ncss + u64::from(!closed.has_own_ncss_contribution),
285
+ },
286
+ );
287
+ let parent = self.top_frame();
288
+ parent.cognitive_complexity += closed.cognitive_complexity
289
+ + closed.nesting_sensitive_count
290
+ * (closed.entry_cognitive_nesting - parent.entry_cognitive_nesting);
291
+ parent.nesting_sensitive_count += closed.nesting_sensitive_count;
292
+ parent.ncss += closed.ncss;
293
+ }
294
+ }
295
+
296
+ fn top_frame(&mut self) -> &mut FunctionBodyFrame {
297
+ self.frames
298
+ .last_mut()
299
+ .expect("sentinel frame always present")
300
+ }
301
+ }
302
+
303
+ /// File-level complexity over the whole tree. Cognitive complexity charges nested function/lambda
304
+ /// content one nesting level deeper per function boundary crossed (Sonar spec); nesting depth
305
+ /// counts every node once.
306
+ pub fn measure_complexity(
307
+ node: Node<'_>,
308
+ sets: &LanguageSets,
309
+ code: &Source<'_>,
310
+ ) -> ComplexityResult {
311
+ let mut result = ComplexityResult {
312
+ cognitive_complexity: 0,
313
+ nesting_depth: 0,
314
+ };
315
+
316
+ #[allow(clippy::too_many_arguments)]
317
+ fn visit(
318
+ current: Node<'_>,
319
+ current_nesting: u64,
320
+ mut function_nesting_bonus: u64,
321
+ mut inside_function: bool,
322
+ inside_charged_class_body: bool,
323
+ sets: &LanguageSets,
324
+ code: &Source<'_>,
325
+ result: &mut ComplexityResult,
326
+ ) {
327
+ // A class body nested in a function (anonymous/local classes) raises the cognitive nesting
328
+ // level once for everything inside it — PMD charges the class body, not the methods it
329
+ // holds, so methods directly inside a charged class body skip the function-boundary bonus.
330
+ let is_charged_class_body = current.kind() == "class_body" && inside_function;
331
+ if is_charged_class_body {
332
+ function_nesting_bonus += 1;
333
+ }
334
+ if is_function_boundary(current, &sets.function_nodes) {
335
+ if inside_function && !inside_charged_class_body {
336
+ function_nesting_bonus += 1;
337
+ }
338
+ inside_function = true;
339
+ }
340
+ let cognitive_nesting = current_nesting + function_nesting_bonus;
341
+
342
+ // Anonymous keyword tokens can share a type with named nodes (Ruby's `if` node contains an
343
+ // `if` keyword token), so only named nodes count as decisions.
344
+ let is_decision = current.is_named()
345
+ && sets.decision_nodes.contains(current.kind())
346
+ && !is_default_switch_branch(current);
347
+ let is_case_clause = current.is_named() && CASE_CLAUSE_NODE_TYPES.contains(&current.kind());
348
+ // Ruby's `case ... else` arm is an `else` node; like every other language's default branch
349
+ // it nests its contents inside the switch (it cannot go in the Ruby nesting set because
350
+ // `if`/`begin` else branches would then double-nest under their already-nesting parent).
351
+ let is_nesting = current.is_named()
352
+ && (sets.nesting_nodes.contains(current.kind())
353
+ || (current.kind() == "else"
354
+ && current.parent().is_some_and(|parent| {
355
+ parent.kind() == "case" || parent.kind() == "case_match"
356
+ })));
357
+ // `elsif`/`elif`/`else if` continue a flat chain: they add a decision without a nesting
358
+ // surcharge (Sonar cognitive-complexity semantics).
359
+ let is_continuation = is_decision && is_flat_chain_continuation(current);
360
+
361
+ if is_decision && !is_case_clause {
362
+ result.cognitive_complexity += if is_continuation {
363
+ 1
364
+ } else {
365
+ 1 + cognitive_nesting
366
+ };
367
+ }
368
+ if current.is_named() && SWITCH_LIKE_NODE_TYPES.contains(&current.kind()) {
369
+ result.cognitive_complexity += 1 + cognitive_nesting;
370
+ }
371
+ // A plain `else` branch adds one flat cognitive point; `else if` chains are charged on the
372
+ // nested if instead.
373
+ result.cognitive_complexity += count_plain_else_branches(current);
374
+ // Sonar charges flow-breaking jumps: goto and labeled break/continue add one flat point.
375
+ if is_flow_breaking_jump(current) {
376
+ result.cognitive_complexity += 1;
377
+ }
378
+
379
+ // A sequence of identical boolean operators reads as one condition, so only the operator
380
+ // starting a sequence adds a cognitive point (Sonar spec).
381
+ if is_boolean_operator(current, code) && starts_boolean_operator_sequence(current, code) {
382
+ result.cognitive_complexity += 1;
383
+ }
384
+
385
+ // Pattern guards add one independent execution path without nesting.
386
+ if is_pattern_guard(current) {
387
+ result.cognitive_complexity += 1;
388
+ }
389
+
390
+ let child_nesting = if is_nesting && !is_continuation {
391
+ current_nesting + 1
392
+ } else {
393
+ current_nesting
394
+ };
395
+ result.nesting_depth = result.nesting_depth.max(child_nesting);
396
+
397
+ for child in all_children(current) {
398
+ visit(
399
+ child,
400
+ child_nesting,
401
+ function_nesting_bonus,
402
+ inside_function,
403
+ is_charged_class_body,
404
+ sets,
405
+ code,
406
+ result,
407
+ );
408
+ }
409
+ }
410
+
411
+ for child in all_children(node) {
412
+ visit(child, 0, 0, false, false, sets, code, &mut result);
413
+ }
414
+
415
+ result
416
+ }
417
+
418
+ /// Plain else branches attached to `current`: an `else_clause`/Ruby `else` whose branch is not an
419
+ /// `else if` continuation, or a bare Java/Go `alternative:` statement without a clause wrapper.
420
+ fn count_plain_else_branches(current: Node<'_>) -> u64 {
421
+ if !current.is_named() {
422
+ return 0;
423
+ }
424
+ let kind = current.kind();
425
+ if kind == "else" {
426
+ // A Ruby `case ... else` is the default arm of a switch, which already counts as a whole
427
+ // (sonar-ruby models it as a match case, not an else branch); `if`/`unless`/`begin` else
428
+ // branches count one point each.
429
+ return if current
430
+ .parent()
431
+ .is_some_and(|parent| parent.kind() == "case" || parent.kind() == "case_match")
432
+ {
433
+ 0
434
+ } else {
435
+ 1
436
+ };
437
+ }
438
+ if kind == "else_clause" {
439
+ let has_if_like_child = crate::util::named_children(current)
440
+ .iter()
441
+ .any(|child| IF_LIKE_NODE_TYPES.contains(&child.kind()));
442
+ return if has_if_like_child { 0 } else { 1 };
443
+ }
444
+ if kind != "if_statement" && kind != "if_expression" {
445
+ return 0;
446
+ }
447
+ // Extras (comments) inherit the preceding sibling's field in find_children_by_field_name, so a
448
+ // comment between an `elif_clause` and `else_clause` must not be miscounted as a bare branch.
449
+ crate::util::find_children_by_field_name(current, "alternative")
450
+ .iter()
451
+ .filter(|child| {
452
+ !child.is_extra()
453
+ && child.kind() != "else_clause"
454
+ && child.kind() != "elif_clause"
455
+ && !IF_LIKE_NODE_TYPES.contains(&child.kind())
456
+ })
457
+ .count() as u64
458
+ }
459
+
460
+ /// goto, and break/continue that jump to a label (their only named child is the label).
461
+ fn is_flow_breaking_jump(node: Node<'_>) -> bool {
462
+ if !node.is_named() {
463
+ return false;
464
+ }
465
+ if node.kind() == "goto_statement" {
466
+ return true;
467
+ }
468
+ // Rust jumps are expressions; `break value` carries a named expression child, so only an
469
+ // explicit `label` child marks a labeled jump.
470
+ if node.kind() == "break_expression" || node.kind() == "continue_expression" {
471
+ return crate::util::named_children(node)
472
+ .iter()
473
+ .any(|child| child.kind() == "label" || child.kind() == "loop_label");
474
+ }
475
+ // Comments are named children too (`break /* done */;`), so only non-comment children mark a
476
+ // label.
477
+ (node.kind() == "break_statement" || node.kind() == "continue_statement")
478
+ && crate::util::named_children(node)
479
+ .iter()
480
+ .any(|child| !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind()))
481
+ }
482
+
483
+ /// Wrappers that are transparent when locating the enclosing boolean operation: PMD/Sonar keep a
484
+ /// sequence continuous across parentheses (`a && (b && c)` costs one point).
485
+ const PARENTHESIZED_NODE_TYPES: &[&str] = &["parenthesized_expression", "parenthesized_statements"];
486
+
487
+ /// Whether this boolean operator token starts a new sequence, i.e. its binary node is the root of a
488
+ /// run of same-operator binaries (possibly through parentheses). Only the root operator counts one
489
+ /// cognitive point: `a && b && c` and `a && (b && c)` cost one, `a && b || c` costs two, matching
490
+ /// the Sonar specification and PMD 7.26.0.
491
+ fn starts_boolean_operator_sequence(token: Node<'_>, code: &Source<'_>) -> bool {
492
+ let Some(binary) = token.parent() else {
493
+ return true;
494
+ };
495
+ let mut ancestor = binary.parent();
496
+ while let Some(node) = ancestor {
497
+ if !PARENTHESIZED_NODE_TYPES.contains(&node.kind()) {
498
+ break;
499
+ }
500
+ ancestor = node.parent();
501
+ }
502
+ let Some(ancestor) = ancestor else {
503
+ return true;
504
+ };
505
+ if ancestor.kind() != binary.kind() {
506
+ return true;
507
+ }
508
+ find_boolean_operator_text(ancestor, code).map(normalize_boolean_operator)
509
+ != Some(normalize_boolean_operator(node_text(token, code)))
510
+ }
511
+
512
+ /// C++ `and`/`or` are alternative spellings of `&&`/`||`, so mixing them keeps one sequence.
513
+ fn normalize_boolean_operator(text: &str) -> &str {
514
+ match text {
515
+ "and" => "&&",
516
+ "or" => "||",
517
+ _ => text,
518
+ }
519
+ }
520
+
521
+ fn find_boolean_operator_text<'a>(binary_node: Node<'_>, code: &Source<'a>) -> Option<&'a str> {
522
+ if let Some(operator) = binary_node.child_by_field_name("operator") {
523
+ return Some(node_text(operator, code));
524
+ }
525
+ all_children(binary_node)
526
+ .into_iter()
527
+ .find(|child| !child.is_named() && BOOLEAN_OPERATORS.contains(&node_text(*child, code)))
528
+ .map(|child| node_text(child, code))
529
+ }
530
+
531
+ /// Java `guard`, Ruby `if_guard`, Python `if_clause`, and Rust guards inside `match_pattern`.
532
+ fn is_pattern_guard(node: Node<'_>) -> bool {
533
+ if !node.is_named() {
534
+ return false;
535
+ }
536
+ let kind = node.kind();
537
+ if kind == "guard" || kind == "if_guard" || kind == "unless_guard" || kind == "if_clause" {
538
+ return true;
539
+ }
540
+ kind == "match_pattern"
541
+ && all_children(node)
542
+ .iter()
543
+ .any(|child| !child.is_named() && child.kind() == "if")
544
+ }
545
+
546
+ /// Ruby `elsif`, Python `elif`, and `else if` (an if node in an else/alternative position).
547
+ fn is_flat_chain_continuation(node: Node<'_>) -> bool {
548
+ let kind = node.kind();
549
+ if kind == "elsif" || kind == "elif_clause" {
550
+ return true;
551
+ }
552
+ if kind != "if_statement" && kind != "if_expression" && kind != "if" {
553
+ return false;
554
+ }
555
+ let Some(parent) = node.parent() else {
556
+ return false;
557
+ };
558
+ // JS/C/C++/Rust wrap `else if` in an else clause; Java/Go put it directly in `alternative`.
559
+ parent.kind() == "else_clause"
560
+ || parent
561
+ .child_by_field_name("alternative")
562
+ .is_some_and(|alternative| alternative.id() == node.id())
563
+ }
564
+
565
+ /// Default branches of switch-like constructs add no decision.
566
+ fn is_default_switch_branch(node: Node<'_>) -> bool {
567
+ let kind = node.kind();
568
+ if kind == "case_statement" {
569
+ return node.child_by_field_name("value").is_none();
570
+ }
571
+
572
+ if kind == "switch_block_statement_group" || kind == "switch_rule" {
573
+ let label = crate::util::named_children(node)
574
+ .into_iter()
575
+ .find(|child| child.kind() == "switch_label");
576
+ return label.is_some_and(|label| label.named_child_count() == 0);
577
+ }
578
+
579
+ // Python `case _:` / `case y:` and Rust `_ =>` fallback arms are unconditional like `default`.
580
+ if kind == "case_clause" || kind == "match_arm" {
581
+ let pattern = crate::util::named_children(node)
582
+ .into_iter()
583
+ .find(|child| child.kind() == "case_pattern" || child.kind() == "match_pattern");
584
+ let Some(pattern) = pattern else {
585
+ return false;
586
+ };
587
+ if pattern.child(0).is_some_and(|first| first.kind() == "_")
588
+ && (pattern.child_count() == 1
589
+ || pattern.child(1).is_some_and(|second| second.kind() == "if"))
590
+ {
591
+ return true;
592
+ }
593
+ let sole_child = if pattern.named_child_count() == 1 {
594
+ pattern.named_child(0)
595
+ } else {
596
+ None
597
+ };
598
+ return kind == "case_clause"
599
+ && sole_child.is_some_and(|child| {
600
+ child.kind() == "dotted_name"
601
+ && child.named_child_count() == 1
602
+ && child
603
+ .named_child(0)
604
+ .is_some_and(|inner| inner.kind() == "identifier")
605
+ });
606
+ }
607
+
608
+ // Ruby `in y` binds unconditionally.
609
+ if kind == "in_clause" {
610
+ return node
611
+ .named_child(0)
612
+ .is_some_and(|first| first.kind() == "identifier");
613
+ }
614
+
615
+ false
616
+ }
617
+
618
+ /// The parent guard is required because the same tokens appear in non-boolean syntax (C++ `int&&`,
619
+ /// `operator&&`, Rust's empty closure parameter list `|| 5`).
620
+ fn is_boolean_operator(node: Node<'_>, code: &Source<'_>) -> bool {
621
+ if node.is_named() || !BOOLEAN_OPERATORS.contains(&node_text(node, code)) {
622
+ return false;
623
+ }
624
+
625
+ node.parent()
626
+ .is_some_and(|parent| BOOLEAN_OPERATOR_PARENT_TYPES.contains(&parent.kind()))
627
+ }