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,10 +1,8 @@
1
- use std::collections::{HashMap, HashSet};
1
+ use rustc_hash::{FxHashMap, FxHashSet};
2
2
  use std::sync::OnceLock;
3
3
  use tree_sitter::Node;
4
4
 
5
- use crate::complexity::{
6
- is_lambda_body_block, measure_complexity, measure_function_body_metrics, LanguageSets,
7
- };
5
+ use crate::complexity::{is_lambda_body_block, measure_function_body_metrics, LanguageSets};
8
6
  use crate::dep_degree::measure_dep_degree;
9
7
  use crate::duplication::{
10
8
  collect_cross_file_file_data, hash_text, measure_duplication, tokenize, DuplicationSettings,
@@ -14,13 +12,11 @@ use crate::functions::{
14
12
  collect_nodes, count_parameters, find_function_name, is_implemented_function,
15
13
  };
16
14
  use crate::languages::LanguageDefinition;
15
+ use crate::tree_index::{NodeExt, TreeIndex};
17
16
  use crate::types::{
18
17
  CrossFileFileData, FunctionMetrics, HalsteadCounts, LineMetrics, NativeMetrics,
19
18
  };
20
- use crate::util::{
21
- all_children, is_identifier_leaf, is_js_whitespace, named_children, node_text, split_lines,
22
- Source,
23
- };
19
+ use crate::util::{all_children, is_js_whitespace, named_children, node_text, split_lines, Source};
24
20
 
25
21
  pub fn measure(
26
22
  code: &str,
@@ -31,13 +27,23 @@ pub fn measure(
31
27
  ) -> Result<NativeMetrics, String> {
32
28
  let source = Source::new(code);
33
29
  let tree = parse_source(&source, language)?;
30
+ let _index = TreeIndex::new(&tree, language)?;
34
31
  let root = tree.root_node();
35
32
  let code = &source;
36
33
  let sets = LanguageSets::new(language);
37
34
 
38
- let functions: Vec<Node<'_>> = collect_nodes(root, &sets.function_nodes)
35
+ // One walk collects both function and initializer-block candidates (Ruby's `block` is both).
36
+ let candidates = collect_nodes(root, |kind| {
37
+ sets.function_nodes.contains(kind) || INITIALIZER_NODE_TYPES.contains(&kind)
38
+ });
39
+ let initializer_block_count = count_initializer_blocks(&candidates);
40
+ let functions: Vec<Node<'_>> = candidates
39
41
  .into_iter()
40
- .filter(|node| !is_lambda_body_block(*node) && is_implemented_function(*node))
42
+ .filter(|node| {
43
+ sets.function_nodes.contains(node.kind_name())
44
+ && !is_lambda_body_block(*node)
45
+ && is_implemented_function(*node)
46
+ })
41
47
  .collect();
42
48
 
43
49
  let body_metrics = measure_function_body_metrics(root, &sets, code);
@@ -50,7 +56,7 @@ pub fn measure(
50
56
  .expect("every collected function node opens a frame in the body-metrics pass");
51
57
  FunctionMetrics {
52
58
  name: find_function_name(*node, code),
53
- node_type: node.kind().to_string(),
59
+ node_type: node.kind_name().to_string(),
54
60
  start_line: node.start_position().row + 1,
55
61
  // The tree is parsed from UTF-16, so columns are UTF-16 code units x 2 — halving
56
62
  // yields the JavaScript string (UTF-16 code unit) column.
@@ -71,7 +77,6 @@ pub fn measure(
71
77
  })
72
78
  .collect();
73
79
 
74
- let global_complexity = measure_complexity(root, &sets, code);
75
80
  let (lines, code_line_numbers) = classify_lines(code, root);
76
81
  let halstead_counts = measure_halstead(root, code);
77
82
  let tokenized = tokenize(root, code);
@@ -89,15 +94,15 @@ pub fn measure(
89
94
  .sum::<u64>()
90
95
  + body_metrics.top_level_decisions
91
96
  + u64::from(language.executes_top_level || has_top_level_statements(root, language))
92
- + count_initializer_blocks(root),
93
- cognitive_complexity: global_complexity.cognitive_complexity,
97
+ + initializer_block_count,
98
+ cognitive_complexity: body_metrics.cognitive_complexity,
94
99
  max_cognitive_complexity: function_metrics
95
100
  .iter()
96
101
  .map(|function| function.cognitive_complexity)
97
102
  .max()
98
103
  .unwrap_or(0),
99
- nesting_depth: global_complexity.nesting_depth,
100
- ncss_count: crate::ncss::count_ncss(root, &sets.ncss_nodes, &sets.ncss_containers),
104
+ nesting_depth: body_metrics.nesting_depth,
105
+ ncss_count: body_metrics.ncss,
101
106
  duplication: measure_duplication(&tokenized, &code_line_numbers, duplication_settings),
102
107
  cross_file_data: include_cross_file_data.then(|| {
103
108
  to_cross_file_data(
@@ -119,22 +124,22 @@ pub fn measure(
119
124
  /// Initializer blocks run code of their own, so each is a component like a function: Java static
120
125
  /// and instance initializers, Kotlin `init` blocks, and JavaScript/TypeScript class `static`
121
126
  /// blocks. Their decisions already count as decisions outside functions.
122
- fn count_initializer_blocks(root: Node<'_>) -> u64 {
123
- let initializer_types: HashSet<&'static str> = [
124
- "static_initializer",
125
- "anonymous_initializer",
126
- "class_static_block",
127
- "block",
128
- ]
129
- .into_iter()
130
- .collect();
131
- collect_nodes(root, &initializer_types)
132
- .into_iter()
127
+ const INITIALIZER_NODE_TYPES: &[&str] = &[
128
+ "static_initializer",
129
+ "anonymous_initializer",
130
+ "class_static_block",
131
+ "block",
132
+ ];
133
+
134
+ fn count_initializer_blocks(candidates: &[Node<'_>]) -> u64 {
135
+ candidates
136
+ .iter()
137
+ .filter(|node| INITIALIZER_NODE_TYPES.contains(&node.kind_name()))
133
138
  .filter(|node| {
134
139
  // A bare block is an initializer only as a direct member of a Java class or enum body.
135
- node.kind() != "block"
136
- || node.parent().is_some_and(|parent| {
137
- matches!(parent.kind(), "class_body" | "enum_body_declarations")
140
+ node.kind_name() != "block"
141
+ || node.parent_node().is_some_and(|parent| {
142
+ matches!(parent.kind_name(), "class_body" | "enum_body_declarations")
138
143
  })
139
144
  })
140
145
  .count() as u64
@@ -143,14 +148,14 @@ fn count_initializer_blocks(root: Node<'_>) -> u64 {
143
148
  /// A C# top-level statement: a `global_statement`, or a statement inside a top-level `#if` block,
144
149
  /// which the grammar does not wrap in `global_statement`; preprocessor blocks are transparent.
145
150
  fn is_csharp_top_level_statement(node: Node<'_>) -> bool {
146
- if node.kind().starts_with("preproc_") {
151
+ if node.kind_name().starts_with("preproc_") {
147
152
  return named_children(node)
148
153
  .into_iter()
149
154
  .any(is_csharp_top_level_statement);
150
155
  }
151
- node.kind() == "global_statement"
152
- || node.kind() == "block"
153
- || node.kind().ends_with("_statement")
156
+ node.kind_name() == "global_statement"
157
+ || node.kind_name() == "block"
158
+ || node.kind_name().ends_with("_statement")
154
159
  }
155
160
 
156
161
  /// Whether a C# or Kotlin file runs top-level code: C# top-level statements, or a Kotlin script's
@@ -158,28 +163,25 @@ fn is_csharp_top_level_statement(node: Node<'_>) -> bool {
158
163
  fn has_top_level_statements(root: Node<'_>, language: &LanguageDefinition) -> bool {
159
164
  const KOTLIN_DECLARATIONS: &[&str] = &[
160
165
  "package_header",
161
- "import_list",
166
+ "import",
162
167
  "class_declaration",
163
168
  "object_declaration",
164
169
  "function_declaration",
165
170
  "property_declaration",
166
171
  "type_alias",
167
- "shebang_line",
172
+ "shebang",
168
173
  "file_annotation",
169
- "getter",
170
- "setter",
171
174
  ];
172
175
  let children = named_children(root);
173
176
  match language.name {
174
177
  "csharp" => children.into_iter().any(is_csharp_top_level_statement),
175
- // The grammar mis-parses some valid declarations (non-empty companion objects, `fun
176
- // interface`) and leaves recovery residue at the top level, so a file with parse errors is
177
- // never taken for a script.
178
+ // Error recovery leaves residue at the top level, so a file with parse errors is never taken
179
+ // for a script.
178
180
  "kotlin" => {
179
181
  !root.has_error()
180
182
  && children.iter().any(|child| {
181
- !KOTLIN_DECLARATIONS.contains(&child.kind())
182
- && !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind())
183
+ !KOTLIN_DECLARATIONS.contains(&child.kind_name())
184
+ && !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind_name())
183
185
  })
184
186
  }
185
187
  _ => false,
@@ -194,6 +196,7 @@ pub fn collect_cross_file_data(
194
196
  ) -> Result<CrossFileFileData, String> {
195
197
  let source = Source::new(code);
196
198
  let tree = parse_source(&source, language)?;
199
+ let _index = TreeIndex::new(&tree, language)?;
197
200
  let root = tree.root_node();
198
201
  let (_, code_line_numbers) = classify_lines(&source, root);
199
202
  Ok(to_cross_file_data(
@@ -205,7 +208,7 @@ pub fn collect_cross_file_data(
205
208
 
206
209
  fn to_cross_file_data(
207
210
  tokenized: &TokenizedSource<'_>,
208
- code_line_numbers: &HashSet<usize>,
211
+ code_line_numbers: &FxHashSet<usize>,
209
212
  min_tokens: usize,
210
213
  ) -> CrossFileFileData {
211
214
  let (candidates, tokens, container_statements, near_miss_blocks) =
@@ -224,8 +227,6 @@ fn to_cross_file_data(
224
227
  /// Name-carrying leaf types anonymized by tokenize_function so consistent renames still match.
225
228
  const IDENTIFIER_LEAF_NODE_TYPES: &[&str] = &[
226
229
  "identifier",
227
- "simple_identifier",
228
- "interpolated_identifier",
229
230
  "implicit_parameter",
230
231
  "property_identifier",
231
232
  "field_identifier",
@@ -244,43 +245,50 @@ pub fn collect_function_token_sequences(
244
245
  ) -> Result<Vec<Vec<i32>>, String> {
245
246
  let source = Source::new(code);
246
247
  let tree = parse_source(&source, language)?;
248
+ let _index = TreeIndex::new(&tree, language)?;
247
249
  let root = tree.root_node();
248
250
  let sets = LanguageSets::new(language);
249
- Ok(collect_nodes(root, &sets.function_nodes)
250
- .into_iter()
251
- .filter(|node| !is_lambda_body_block(*node) && is_implemented_function(*node))
252
- .map(|node| {
253
- let mut symbols = Vec::new();
254
- let mut id_index_by_name: HashMap<String, usize> = HashMap::new();
255
- collect_token_symbols(node, &source, &mut symbols, &mut id_index_by_name);
256
- symbols
257
- })
258
- .collect())
251
+ Ok(
252
+ collect_nodes(root, |kind| sets.function_nodes.contains(kind))
253
+ .into_iter()
254
+ .filter(|node| !is_lambda_body_block(*node) && is_implemented_function(*node))
255
+ .map(|node| {
256
+ let mut symbols = Vec::new();
257
+ let mut id_index_by_name: FxHashMap<String, usize> = FxHashMap::default();
258
+ collect_token_symbols(node, &source, &mut symbols, &mut id_index_by_name);
259
+ symbols
260
+ })
261
+ .collect(),
262
+ )
259
263
  }
260
264
 
261
265
  fn collect_token_symbols(
262
266
  node: Node<'_>,
263
267
  code: &Source<'_>,
264
268
  symbols: &mut Vec<i32>,
265
- id_index_by_name: &mut HashMap<String, usize>,
269
+ id_index_by_name: &mut FxHashMap<String, usize>,
266
270
  ) {
267
271
  if matches!(
268
- node.kind(),
269
- "comment" | "line_comment" | "block_comment" | "multiline_comment"
272
+ node.kind_name(),
273
+ "comment" | "line_comment" | "block_comment"
270
274
  ) {
271
275
  return;
272
276
  }
273
- if atomic_operand_node_types().contains(node.kind()) {
274
- symbols.push(hash_text(node.kind()));
277
+ if atomic_operand_node_types().contains(node.kind_name()) {
278
+ symbols.push(hash_text(node.kind_name()));
275
279
  return;
276
280
  }
277
- if !is_identifier_leaf(node) {
281
+ if node.child_count() != 0 {
278
282
  for child in all_children(node) {
279
283
  collect_token_symbols(child, code, symbols, id_index_by_name);
280
284
  }
281
285
  return;
282
286
  }
283
- if IDENTIFIER_LEAF_NODE_TYPES.contains(&node.kind()) {
287
+ if crate::util::is_kotlin_keyword_literal(node, code) {
288
+ symbols.push(hash_text(node_text(node, code)));
289
+ return;
290
+ }
291
+ if IDENTIFIER_LEAF_NODE_TYPES.contains(&node.kind_name()) {
284
292
  let next_index = id_index_by_name.len();
285
293
  let index = *id_index_by_name
286
294
  .entry(node_text(node, code).to_string())
@@ -290,17 +298,17 @@ fn collect_token_symbols(
290
298
  }
291
299
  // Remaining operand leaves are literals, normalized by kind; everything else (keywords,
292
300
  // operators, punctuation) is kept verbatim.
293
- symbols.push(hash_text(if operand_node_types().contains(node.kind()) {
294
- node.kind()
295
- } else {
296
- node_text(node, code)
297
- }));
301
+ symbols.push(hash_text(
302
+ if operand_node_types().contains(node.kind_name()) {
303
+ node.kind_name()
304
+ } else {
305
+ node_text(node, code)
306
+ },
307
+ ));
298
308
  }
299
309
 
300
- /// Parses the source from UTF-16 (matching node-tree-sitter's JavaScript string semantics:
301
- /// tree-sitter's error recovery differs between input encodings for malformed non-ASCII source)
302
- /// and refuses pathologically deep trees: the metric passes recurse per tree level and would
303
- /// overflow the native stack (a process-killing SIGSEGV, not a catchable error) around depth ~20k.
310
+ /// Parses the source from UTF-16, matching node-tree-sitter's JavaScript string semantics:
311
+ /// tree-sitter's error recovery differs between input encodings for malformed non-ASCII source.
304
312
  fn parse_source(
305
313
  source: &Source<'_>,
306
314
  language: &LanguageDefinition,
@@ -309,38 +317,9 @@ fn parse_source(
309
317
  parser
310
318
  .set_language(&language.grammar())
311
319
  .map_err(|error| error.to_string())?;
312
- let tree = parser
313
- .parse_utf16(source.to_utf16(), None)
314
- .ok_or_else(|| "parse failed".to_string())?;
315
- if tree_depth(tree.root_node()) > MAX_TREE_DEPTH {
316
- return Err(format!("tree depth exceeds {MAX_TREE_DEPTH}"));
317
- }
318
- Ok(tree)
319
- }
320
-
321
- /// See the depth check in parse_source(); computed iteratively so the check itself cannot overflow.
322
- const MAX_TREE_DEPTH: usize = 5_000;
323
-
324
- fn tree_depth(root: Node<'_>) -> usize {
325
- let mut cursor = root.walk();
326
- let mut depth = 0;
327
- let mut max_depth = 0;
328
- loop {
329
- if cursor.goto_first_child() {
330
- depth += 1;
331
- max_depth = max_depth.max(depth);
332
- continue;
333
- }
334
- loop {
335
- if cursor.goto_next_sibling() {
336
- break;
337
- }
338
- if !cursor.goto_parent() {
339
- return max_depth;
340
- }
341
- depth -= 1;
342
- }
343
- }
320
+ parser
321
+ .parse_utf16_le(source.to_utf16(), None)
322
+ .ok_or_else(|| "parse failed".to_string())
344
323
  }
345
324
 
346
325
  struct CommentSpan {
@@ -352,10 +331,10 @@ struct CommentSpan {
352
331
  /// Line metrics plus the 1-based numbers of lines that are neither blank nor comment-only, shared
353
332
  /// by the line counts and duplication line coverage so the coverage and its code-line denominator
354
333
  /// agree.
355
- fn classify_lines(code: &Source<'_>, root: Node<'_>) -> (LineMetrics, HashSet<usize>) {
334
+ fn classify_lines(code: &Source<'_>, root: Node<'_>) -> (LineMetrics, FxHashSet<usize>) {
356
335
  let source_lines = split_lines(code.code);
357
336
  // Spans are bucketed by line so classification stays linear.
358
- let mut comment_spans_by_line: HashMap<usize, Vec<CommentSpan>> = HashMap::new();
337
+ let mut comment_spans_by_line: FxHashMap<usize, Vec<CommentSpan>> = FxHashMap::default();
359
338
  for span in collect_comment_spans(root) {
360
339
  comment_spans_by_line
361
340
  .entry(span.line)
@@ -364,7 +343,7 @@ fn classify_lines(code: &Source<'_>, root: Node<'_>) -> (LineMetrics, HashSet<us
364
343
  }
365
344
  let mut blank = 0;
366
345
  let mut comment = 0;
367
- let mut code_line_numbers = HashSet::new();
346
+ let mut code_line_numbers = FxHashSet::default();
368
347
 
369
348
  for (index, line) in source_lines.iter().enumerate() {
370
349
  if line.chars().all(is_js_whitespace) {
@@ -396,8 +375,8 @@ fn collect_comment_spans(root: Node<'_>) -> Vec<CommentSpan> {
396
375
 
397
376
  fn visit(node: Node<'_>, spans: &mut Vec<CommentSpan>) {
398
377
  if matches!(
399
- node.kind(),
400
- "comment" | "line_comment" | "block_comment" | "multiline_comment"
378
+ node.kind_name(),
379
+ "comment" | "line_comment" | "block_comment"
401
380
  ) {
402
381
  for row in node.start_position().row..=node.end_position().row {
403
382
  // Node columns are UTF-16 code units x 2 (the tree is parsed from UTF-16);
@@ -566,8 +545,6 @@ const OPERATOR_TEXTS: &[&str] = &[
566
545
 
567
546
  const OPERAND_NODE_TYPES: &[&str] = &[
568
547
  "identifier",
569
- "simple_identifier",
570
- "interpolated_identifier",
571
548
  "implicit_parameter",
572
549
  "property_identifier",
573
550
  "field_identifier",
@@ -595,8 +572,6 @@ const OPERAND_NODE_TYPES: &[&str] = &[
595
572
  "integer_literal",
596
573
  "float_literal",
597
574
  "real_literal",
598
- "hex_literal",
599
- "bin_literal",
600
575
  "int_literal",
601
576
  "rune_literal",
602
577
  "imaginary_literal",
@@ -631,13 +606,10 @@ const OPERAND_NODE_TYPES: &[&str] = &[
631
606
  ];
632
607
 
633
608
  /// Non-leaf literals counted as one Halstead operand without descending.
634
- /// `character_literal` is a leaf in Java and Kotlin but wraps a content node in C#; Kotlin's
635
- /// suffixed numbers (`1L`, `1u`) wrap the bare literal, so `1` and `1L` stay distinct.
609
+ /// `character_literal` is a leaf in Java but wraps its content in C# and Kotlin.
636
610
  const ATOMIC_OPERAND_NODE_TYPES: &[&str] = &[
637
611
  "interpreted_string_literal",
638
612
  "character_literal",
639
- "long_literal",
640
- "unsigned_literal",
641
613
  "regex",
642
614
  "user_defined_literal",
643
615
  "integral_type",
@@ -646,60 +618,63 @@ const ATOMIC_OPERAND_NODE_TYPES: &[&str] = &[
646
618
  "placeholder_type_specifier",
647
619
  ];
648
620
 
649
- fn operator_texts() -> &'static HashSet<&'static str> {
650
- static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
621
+ fn operator_texts() -> &'static FxHashSet<&'static str> {
622
+ static SET: OnceLock<FxHashSet<&'static str>> = OnceLock::new();
651
623
  SET.get_or_init(|| OPERATOR_TEXTS.iter().copied().collect())
652
624
  }
653
625
 
654
- fn operand_node_types() -> &'static HashSet<&'static str> {
655
- static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
626
+ fn operand_node_types() -> &'static FxHashSet<&'static str> {
627
+ static SET: OnceLock<FxHashSet<&'static str>> = OnceLock::new();
656
628
  SET.get_or_init(|| OPERAND_NODE_TYPES.iter().copied().collect())
657
629
  }
658
630
 
659
- fn atomic_operand_node_types() -> &'static HashSet<&'static str> {
660
- static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
631
+ fn atomic_operand_node_types() -> &'static FxHashSet<&'static str> {
632
+ static SET: OnceLock<FxHashSet<&'static str>> = OnceLock::new();
661
633
  SET.get_or_init(|| ATOMIC_OPERAND_NODE_TYPES.iter().copied().collect())
662
634
  }
663
635
 
664
636
  fn measure_halstead(root: Node<'_>, code: &Source<'_>) -> HalsteadCounts {
665
- let mut operators: HashMap<String, u64> = HashMap::new();
666
- let mut operands: HashMap<String, u64> = HashMap::new();
637
+ let mut operators: FxHashMap<&str, u64> = FxHashMap::default();
638
+ let mut operands: FxHashMap<&str, u64> = FxHashMap::default();
667
639
 
668
- fn visit(
640
+ fn visit<'a>(
669
641
  node: Node<'_>,
670
- code: &Source<'_>,
671
- operators: &mut HashMap<String, u64>,
672
- operands: &mut HashMap<String, u64>,
642
+ code: &Source<'a>,
643
+ operators: &mut FxHashMap<&'a str, u64>,
644
+ operands: &mut FxHashMap<&'a str, u64>,
673
645
  ) {
674
646
  if matches!(
675
- node.kind(),
676
- "comment" | "line_comment" | "block_comment" | "multiline_comment"
647
+ node.kind_name(),
648
+ "comment" | "line_comment" | "block_comment"
677
649
  ) {
678
650
  return;
679
651
  }
680
652
 
681
- if atomic_operand_node_types().contains(node.kind()) {
682
- *operands
683
- .entry(node_text(node, code).to_string())
684
- .or_insert(0) += 1;
653
+ if atomic_operand_node_types().contains(node.kind_name()) {
654
+ *operands.entry(node_text(node, code)).or_insert(0) += 1;
685
655
  return;
686
656
  }
687
657
 
688
658
  // Operators are counted from leaf tokens only: keyword-named nodes always contain a
689
659
  // same-text anonymous keyword leaf, so counting the named node as well would double-count.
690
- if is_identifier_leaf(node) {
660
+ if node.child_count() == 0 {
691
661
  let text = node_text(node, code);
692
662
  // Operands win over text matches so identifiers spelled like word operators stay operands;
693
663
  // C# `nameof(x)` is the one keyword operator the grammar parses as a plain callee.
694
664
  if is_csharp_nameof_callee(node, text) {
695
- *operators.entry(text.to_string()).or_insert(0) += 1;
696
- } else if operand_node_types().contains(node.kind()) {
697
- *operands.entry(text.to_string()).or_insert(0) += 1;
698
- } else if (operator_texts().contains(text) || operator_texts().contains(node.kind()))
665
+ *operators.entry(text).or_insert(0) += 1;
666
+ } else if operand_node_types().contains(node.kind_name()) {
667
+ *operands.entry(text).or_insert(0) += 1;
668
+ } else if (operator_texts().contains(text)
669
+ || operator_texts().contains(node.kind_name()))
699
670
  && is_countable_contextual_token(node, text)
700
671
  {
701
- let key = if text.is_empty() { node.kind() } else { text };
702
- *operators.entry(key.to_string()).or_insert(0) += 1;
672
+ let key = if text.is_empty() {
673
+ node.kind_name()
674
+ } else {
675
+ text
676
+ };
677
+ *operators.entry(key).or_insert(0) += 1;
703
678
  }
704
679
  return;
705
680
  }
@@ -722,9 +697,9 @@ fn measure_halstead(root: Node<'_>, code: &Source<'_>) -> HalsteadCounts {
722
697
  /// tree-sitter-c-sharp parses `nameof(x)` as an invocation of an identifier named `nameof`.
723
698
  fn is_csharp_nameof_callee(node: Node<'_>, text: &str) -> bool {
724
699
  text == "nameof"
725
- && node.kind() == "identifier"
726
- && node.parent().is_some_and(|parent| {
727
- parent.kind() == "invocation_expression"
700
+ && node.kind_name() == "identifier"
701
+ && node.parent_node().is_some_and(|parent| {
702
+ parent.kind_name() == "invocation_expression"
728
703
  && parent
729
704
  .child_by_field_name("function")
730
705
  .is_some_and(|callee| callee.id() == node.id())
@@ -746,18 +721,18 @@ const QUESTION_OPERATOR_PARENT_TYPES: &[&str] = &[
746
721
  fn is_countable_contextual_token(node: Node<'_>, text: &str) -> bool {
747
722
  if text == "@" {
748
723
  // Python matrix multiplication only; decorator/annotation `@` marks are not operators.
749
- let parent_type = node.parent().map(|parent| parent.kind());
724
+ let parent_type = node.parent_node().map(|parent| parent.kind_name());
750
725
  return parent_type == Some("binary_operator")
751
726
  || parent_type == Some("augmented_assignment");
752
727
  }
753
728
  if text == "default" {
754
729
  return node
755
- .parent()
756
- .is_some_and(|parent| parent.kind() == "default_expression");
730
+ .parent_node()
731
+ .is_some_and(|parent| parent.kind_name() == "default_expression");
757
732
  }
758
733
  if text != "?" {
759
734
  return true;
760
735
  }
761
- node.parent()
762
- .is_some_and(|parent| QUESTION_OPERATOR_PARENT_TYPES.contains(&parent.kind()))
736
+ node.parent_node()
737
+ .is_some_and(|parent| QUESTION_OPERATOR_PARENT_TYPES.contains(&parent.kind_name()))
763
738
  }
@@ -18,16 +18,75 @@ pub fn measure_code_native(
18
18
  min_similarity_percent: Option<u32>,
19
19
  include_cross_file_data: Option<bool>,
20
20
  ) -> Result<String> {
21
- crate::measure_code(
21
+ measure_code(
22
22
  &code,
23
23
  &language,
24
+ include_syntax_tree,
25
+ min_tokens,
26
+ max_gap_tokens,
27
+ min_similarity_percent,
28
+ include_cross_file_data,
29
+ )
30
+ .map_err(Error::from_reason)
31
+ }
32
+
33
+ /// measure_code_native on the worker pool, resolving with the same JSON payload, so callers can
34
+ /// measure several files in parallel.
35
+ #[napi(ts_return_type = "Promise<string>")]
36
+ #[allow(clippy::too_many_arguments)]
37
+ pub fn measure_code_native_async(
38
+ env: &Env,
39
+ code: String,
40
+ language: String,
41
+ include_syntax_tree: Option<bool>,
42
+ min_tokens: Option<u32>,
43
+ max_gap_tokens: Option<u32>,
44
+ min_similarity_percent: Option<u32>,
45
+ include_cross_file_data: Option<bool>,
46
+ ) -> Result<Object<'_>> {
47
+ let (deferred, promise) = env.create_deferred()?;
48
+ crate::worker_pool::spawn(move || {
49
+ // A panic must reject the promise: otherwise it would stay pending and keep the event
50
+ // loop alive forever.
51
+ let result = std::panic::catch_unwind(|| {
52
+ measure_code(
53
+ &code,
54
+ &language,
55
+ include_syntax_tree,
56
+ min_tokens,
57
+ max_gap_tokens,
58
+ min_similarity_percent,
59
+ include_cross_file_data,
60
+ )
61
+ })
62
+ .unwrap_or_else(|_| Err("measurement panicked".to_string()));
63
+ match result {
64
+ Ok(json) => deferred.resolve(move |_| Ok(json)),
65
+ Err(reason) => deferred.reject(Error::from_reason(reason)),
66
+ }
67
+ });
68
+ Ok(promise)
69
+ }
70
+
71
+ /// The arguments' defaults, applied in one place so the sync and async bindings measure alike.
72
+ fn measure_code(
73
+ code: &str,
74
+ language: &str,
75
+ include_syntax_tree: Option<bool>,
76
+ min_tokens: Option<u32>,
77
+ max_gap_tokens: Option<u32>,
78
+ min_similarity_percent: Option<u32>,
79
+ include_cross_file_data: Option<bool>,
80
+ ) -> std::result::Result<String, String> {
81
+ crate::measure_code(
82
+ code,
83
+ language,
24
84
  include_syntax_tree.unwrap_or(false),
25
85
  min_tokens,
26
86
  max_gap_tokens,
27
87
  min_similarity_percent,
28
88
  include_cross_file_data.unwrap_or(false),
29
89
  )
30
- .map_err(Error::from_reason)
31
90
  }
32
91
 
33
92
  #[napi]