code-gauge 4.7.0 → 4.7.1

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.
@@ -1,3 +1,4 @@
1
+ use std::sync::OnceLock;
1
2
  use tree_sitter::Language;
2
3
 
3
4
  /// Per-language node-type configuration mirroring src/languages.ts.
@@ -32,6 +33,8 @@ enum GrammarId {
32
33
  Tsx,
33
34
  }
34
35
 
36
+ const GRAMMAR_COUNT: usize = GrammarId::Tsx as usize + 1;
37
+
35
38
  impl LanguageDefinition {
36
39
  pub fn grammar(&self) -> Language {
37
40
  match self.grammar_id {
@@ -49,6 +52,23 @@ impl LanguageDefinition {
49
52
  GrammarId::Tsx => tree_sitter_typescript::language_tsx(),
50
53
  }
51
54
  }
55
+
56
+ /// The grammar's node kind names, indexed by kind id.
57
+ pub fn kind_names(&self) -> &'static [&'static str] {
58
+ static KIND_NAMES: [OnceLock<Vec<&'static str>>; GRAMMAR_COUNT] =
59
+ [const { OnceLock::new() }; GRAMMAR_COUNT];
60
+ KIND_NAMES[self.grammar_id as usize].get_or_init(|| {
61
+ let grammar = self.grammar();
62
+ (0..grammar.node_kind_count())
63
+ .map(|id| {
64
+ u16::try_from(id)
65
+ .ok()
66
+ .and_then(|id| grammar.node_kind_for_id(id))
67
+ .unwrap_or_default()
68
+ })
69
+ .collect()
70
+ })
71
+ }
52
72
  }
53
73
 
54
74
  const COMMON_FUNCTION_NODES: &[&str] = &[
package/native/src/lib.rs CHANGED
@@ -12,18 +12,23 @@ mod measure;
12
12
  mod napi;
13
13
  mod ncss;
14
14
  mod near_miss;
15
+ mod tree_index;
15
16
  mod types;
16
17
  mod util;
17
18
  #[cfg(target_family = "wasm")]
18
19
  mod wasm;
20
+ #[cfg(not(target_family = "wasm"))]
21
+ mod worker_pool;
19
22
 
20
- /// Version of the native payload schema. The TypeScript wrapper refuses a binding whose version
21
- /// differs from the one it expects, so a stale prebuilt addon fails with a clear rebuild message
22
- /// instead of silently returning an incompatible payload. Bump on every payload-shape change,
23
- /// together with `expectedPayloadVersion` in src/nativeMetrics.ts.
23
+ /// Version of the native payload schema and binding functions. The TypeScript wrapper refuses a
24
+ /// binding whose version differs from the one it expects, so a stale prebuilt addon fails with a
25
+ /// clear rebuild message instead of silently returning an incompatible payload or lacking a
26
+ /// function (a failure the scan would only report per file). Bump on every payload-shape change
27
+ /// and every change to the exported binding functions, together with `expectedPayloadVersion` in
28
+ /// src/nativeMetrics.ts.
24
29
  /// scripts/installNative.mjs parses the literal from this function's source.
25
30
  pub fn payload_version() -> u32 {
26
- 7
31
+ 8
27
32
  }
28
33
 
29
34
  /// Measures code metrics for the given source, returning the NativeMetrics payload as JSON; with
@@ -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,6 +12,7 @@ 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
  };
@@ -31,13 +30,23 @@ pub fn measure(
31
30
  ) -> Result<NativeMetrics, String> {
32
31
  let source = Source::new(code);
33
32
  let tree = parse_source(&source, language)?;
33
+ let _index = TreeIndex::new(&tree, language)?;
34
34
  let root = tree.root_node();
35
35
  let code = &source;
36
36
  let sets = LanguageSets::new(language);
37
37
 
38
- let functions: Vec<Node<'_>> = collect_nodes(root, &sets.function_nodes)
38
+ // One walk collects both function and initializer-block candidates (Ruby's `block` is both).
39
+ let candidates = collect_nodes(root, |kind| {
40
+ sets.function_nodes.contains(kind) || INITIALIZER_NODE_TYPES.contains(&kind)
41
+ });
42
+ let initializer_block_count = count_initializer_blocks(&candidates);
43
+ let functions: Vec<Node<'_>> = candidates
39
44
  .into_iter()
40
- .filter(|node| !is_lambda_body_block(*node) && is_implemented_function(*node))
45
+ .filter(|node| {
46
+ sets.function_nodes.contains(node.kind_name())
47
+ && !is_lambda_body_block(*node)
48
+ && is_implemented_function(*node)
49
+ })
41
50
  .collect();
42
51
 
43
52
  let body_metrics = measure_function_body_metrics(root, &sets, code);
@@ -50,7 +59,7 @@ pub fn measure(
50
59
  .expect("every collected function node opens a frame in the body-metrics pass");
51
60
  FunctionMetrics {
52
61
  name: find_function_name(*node, code),
53
- node_type: node.kind().to_string(),
62
+ node_type: node.kind_name().to_string(),
54
63
  start_line: node.start_position().row + 1,
55
64
  // The tree is parsed from UTF-16, so columns are UTF-16 code units x 2 — halving
56
65
  // yields the JavaScript string (UTF-16 code unit) column.
@@ -71,7 +80,6 @@ pub fn measure(
71
80
  })
72
81
  .collect();
73
82
 
74
- let global_complexity = measure_complexity(root, &sets, code);
75
83
  let (lines, code_line_numbers) = classify_lines(code, root);
76
84
  let halstead_counts = measure_halstead(root, code);
77
85
  let tokenized = tokenize(root, code);
@@ -89,15 +97,15 @@ pub fn measure(
89
97
  .sum::<u64>()
90
98
  + body_metrics.top_level_decisions
91
99
  + u64::from(language.executes_top_level || has_top_level_statements(root, language))
92
- + count_initializer_blocks(root),
93
- cognitive_complexity: global_complexity.cognitive_complexity,
100
+ + initializer_block_count,
101
+ cognitive_complexity: body_metrics.cognitive_complexity,
94
102
  max_cognitive_complexity: function_metrics
95
103
  .iter()
96
104
  .map(|function| function.cognitive_complexity)
97
105
  .max()
98
106
  .unwrap_or(0),
99
- nesting_depth: global_complexity.nesting_depth,
100
- ncss_count: crate::ncss::count_ncss(root, &sets.ncss_nodes, &sets.ncss_containers),
107
+ nesting_depth: body_metrics.nesting_depth,
108
+ ncss_count: body_metrics.ncss,
101
109
  duplication: measure_duplication(&tokenized, &code_line_numbers, duplication_settings),
102
110
  cross_file_data: include_cross_file_data.then(|| {
103
111
  to_cross_file_data(
@@ -119,22 +127,22 @@ pub fn measure(
119
127
  /// Initializer blocks run code of their own, so each is a component like a function: Java static
120
128
  /// and instance initializers, Kotlin `init` blocks, and JavaScript/TypeScript class `static`
121
129
  /// 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()
130
+ const INITIALIZER_NODE_TYPES: &[&str] = &[
131
+ "static_initializer",
132
+ "anonymous_initializer",
133
+ "class_static_block",
134
+ "block",
135
+ ];
136
+
137
+ fn count_initializer_blocks(candidates: &[Node<'_>]) -> u64 {
138
+ candidates
139
+ .iter()
140
+ .filter(|node| INITIALIZER_NODE_TYPES.contains(&node.kind_name()))
133
141
  .filter(|node| {
134
142
  // 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")
143
+ node.kind_name() != "block"
144
+ || node.parent_node().is_some_and(|parent| {
145
+ matches!(parent.kind_name(), "class_body" | "enum_body_declarations")
138
146
  })
139
147
  })
140
148
  .count() as u64
@@ -143,14 +151,14 @@ fn count_initializer_blocks(root: Node<'_>) -> u64 {
143
151
  /// A C# top-level statement: a `global_statement`, or a statement inside a top-level `#if` block,
144
152
  /// which the grammar does not wrap in `global_statement`; preprocessor blocks are transparent.
145
153
  fn is_csharp_top_level_statement(node: Node<'_>) -> bool {
146
- if node.kind().starts_with("preproc_") {
154
+ if node.kind_name().starts_with("preproc_") {
147
155
  return named_children(node)
148
156
  .into_iter()
149
157
  .any(is_csharp_top_level_statement);
150
158
  }
151
- node.kind() == "global_statement"
152
- || node.kind() == "block"
153
- || node.kind().ends_with("_statement")
159
+ node.kind_name() == "global_statement"
160
+ || node.kind_name() == "block"
161
+ || node.kind_name().ends_with("_statement")
154
162
  }
155
163
 
156
164
  /// Whether a C# or Kotlin file runs top-level code: C# top-level statements, or a Kotlin script's
@@ -178,8 +186,8 @@ fn has_top_level_statements(root: Node<'_>, language: &LanguageDefinition) -> bo
178
186
  "kotlin" => {
179
187
  !root.has_error()
180
188
  && children.iter().any(|child| {
181
- !KOTLIN_DECLARATIONS.contains(&child.kind())
182
- && !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind())
189
+ !KOTLIN_DECLARATIONS.contains(&child.kind_name())
190
+ && !crate::ncss::COMMENT_NODE_TYPES.contains(&child.kind_name())
183
191
  })
184
192
  }
185
193
  _ => false,
@@ -194,6 +202,7 @@ pub fn collect_cross_file_data(
194
202
  ) -> Result<CrossFileFileData, String> {
195
203
  let source = Source::new(code);
196
204
  let tree = parse_source(&source, language)?;
205
+ let _index = TreeIndex::new(&tree, language)?;
197
206
  let root = tree.root_node();
198
207
  let (_, code_line_numbers) = classify_lines(&source, root);
199
208
  Ok(to_cross_file_data(
@@ -205,7 +214,7 @@ pub fn collect_cross_file_data(
205
214
 
206
215
  fn to_cross_file_data(
207
216
  tokenized: &TokenizedSource<'_>,
208
- code_line_numbers: &HashSet<usize>,
217
+ code_line_numbers: &FxHashSet<usize>,
209
218
  min_tokens: usize,
210
219
  ) -> CrossFileFileData {
211
220
  let (candidates, tokens, container_statements, near_miss_blocks) =
@@ -244,34 +253,37 @@ pub fn collect_function_token_sequences(
244
253
  ) -> Result<Vec<Vec<i32>>, String> {
245
254
  let source = Source::new(code);
246
255
  let tree = parse_source(&source, language)?;
256
+ let _index = TreeIndex::new(&tree, language)?;
247
257
  let root = tree.root_node();
248
258
  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())
259
+ Ok(
260
+ collect_nodes(root, |kind| sets.function_nodes.contains(kind))
261
+ .into_iter()
262
+ .filter(|node| !is_lambda_body_block(*node) && is_implemented_function(*node))
263
+ .map(|node| {
264
+ let mut symbols = Vec::new();
265
+ let mut id_index_by_name: FxHashMap<String, usize> = FxHashMap::default();
266
+ collect_token_symbols(node, &source, &mut symbols, &mut id_index_by_name);
267
+ symbols
268
+ })
269
+ .collect(),
270
+ )
259
271
  }
260
272
 
261
273
  fn collect_token_symbols(
262
274
  node: Node<'_>,
263
275
  code: &Source<'_>,
264
276
  symbols: &mut Vec<i32>,
265
- id_index_by_name: &mut HashMap<String, usize>,
277
+ id_index_by_name: &mut FxHashMap<String, usize>,
266
278
  ) {
267
279
  if matches!(
268
- node.kind(),
280
+ node.kind_name(),
269
281
  "comment" | "line_comment" | "block_comment" | "multiline_comment"
270
282
  ) {
271
283
  return;
272
284
  }
273
- if atomic_operand_node_types().contains(node.kind()) {
274
- symbols.push(hash_text(node.kind()));
285
+ if atomic_operand_node_types().contains(node.kind_name()) {
286
+ symbols.push(hash_text(node.kind_name()));
275
287
  return;
276
288
  }
277
289
  if !is_identifier_leaf(node) {
@@ -280,7 +292,7 @@ fn collect_token_symbols(
280
292
  }
281
293
  return;
282
294
  }
283
- if IDENTIFIER_LEAF_NODE_TYPES.contains(&node.kind()) {
295
+ if IDENTIFIER_LEAF_NODE_TYPES.contains(&node.kind_name()) {
284
296
  let next_index = id_index_by_name.len();
285
297
  let index = *id_index_by_name
286
298
  .entry(node_text(node, code).to_string())
@@ -290,17 +302,17 @@ fn collect_token_symbols(
290
302
  }
291
303
  // Remaining operand leaves are literals, normalized by kind; everything else (keywords,
292
304
  // 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
- }));
305
+ symbols.push(hash_text(
306
+ if operand_node_types().contains(node.kind_name()) {
307
+ node.kind_name()
308
+ } else {
309
+ node_text(node, code)
310
+ },
311
+ ));
298
312
  }
299
313
 
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.
314
+ /// Parses the source from UTF-16, matching node-tree-sitter's JavaScript string semantics:
315
+ /// tree-sitter's error recovery differs between input encodings for malformed non-ASCII source.
304
316
  fn parse_source(
305
317
  source: &Source<'_>,
306
318
  language: &LanguageDefinition,
@@ -309,38 +321,9 @@ fn parse_source(
309
321
  parser
310
322
  .set_language(&language.grammar())
311
323
  .map_err(|error| error.to_string())?;
312
- let tree = parser
324
+ parser
313
325
  .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
- }
326
+ .ok_or_else(|| "parse failed".to_string())
344
327
  }
345
328
 
346
329
  struct CommentSpan {
@@ -352,10 +335,10 @@ struct CommentSpan {
352
335
  /// Line metrics plus the 1-based numbers of lines that are neither blank nor comment-only, shared
353
336
  /// by the line counts and duplication line coverage so the coverage and its code-line denominator
354
337
  /// agree.
355
- fn classify_lines(code: &Source<'_>, root: Node<'_>) -> (LineMetrics, HashSet<usize>) {
338
+ fn classify_lines(code: &Source<'_>, root: Node<'_>) -> (LineMetrics, FxHashSet<usize>) {
356
339
  let source_lines = split_lines(code.code);
357
340
  // Spans are bucketed by line so classification stays linear.
358
- let mut comment_spans_by_line: HashMap<usize, Vec<CommentSpan>> = HashMap::new();
341
+ let mut comment_spans_by_line: FxHashMap<usize, Vec<CommentSpan>> = FxHashMap::default();
359
342
  for span in collect_comment_spans(root) {
360
343
  comment_spans_by_line
361
344
  .entry(span.line)
@@ -364,7 +347,7 @@ fn classify_lines(code: &Source<'_>, root: Node<'_>) -> (LineMetrics, HashSet<us
364
347
  }
365
348
  let mut blank = 0;
366
349
  let mut comment = 0;
367
- let mut code_line_numbers = HashSet::new();
350
+ let mut code_line_numbers = FxHashSet::default();
368
351
 
369
352
  for (index, line) in source_lines.iter().enumerate() {
370
353
  if line.chars().all(is_js_whitespace) {
@@ -396,7 +379,7 @@ fn collect_comment_spans(root: Node<'_>) -> Vec<CommentSpan> {
396
379
 
397
380
  fn visit(node: Node<'_>, spans: &mut Vec<CommentSpan>) {
398
381
  if matches!(
399
- node.kind(),
382
+ node.kind_name(),
400
383
  "comment" | "line_comment" | "block_comment" | "multiline_comment"
401
384
  ) {
402
385
  for row in node.start_position().row..=node.end_position().row {
@@ -646,42 +629,40 @@ const ATOMIC_OPERAND_NODE_TYPES: &[&str] = &[
646
629
  "placeholder_type_specifier",
647
630
  ];
648
631
 
649
- fn operator_texts() -> &'static HashSet<&'static str> {
650
- static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
632
+ fn operator_texts() -> &'static FxHashSet<&'static str> {
633
+ static SET: OnceLock<FxHashSet<&'static str>> = OnceLock::new();
651
634
  SET.get_or_init(|| OPERATOR_TEXTS.iter().copied().collect())
652
635
  }
653
636
 
654
- fn operand_node_types() -> &'static HashSet<&'static str> {
655
- static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
637
+ fn operand_node_types() -> &'static FxHashSet<&'static str> {
638
+ static SET: OnceLock<FxHashSet<&'static str>> = OnceLock::new();
656
639
  SET.get_or_init(|| OPERAND_NODE_TYPES.iter().copied().collect())
657
640
  }
658
641
 
659
- fn atomic_operand_node_types() -> &'static HashSet<&'static str> {
660
- static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
642
+ fn atomic_operand_node_types() -> &'static FxHashSet<&'static str> {
643
+ static SET: OnceLock<FxHashSet<&'static str>> = OnceLock::new();
661
644
  SET.get_or_init(|| ATOMIC_OPERAND_NODE_TYPES.iter().copied().collect())
662
645
  }
663
646
 
664
647
  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();
648
+ let mut operators: FxHashMap<&str, u64> = FxHashMap::default();
649
+ let mut operands: FxHashMap<&str, u64> = FxHashMap::default();
667
650
 
668
- fn visit(
651
+ fn visit<'a>(
669
652
  node: Node<'_>,
670
- code: &Source<'_>,
671
- operators: &mut HashMap<String, u64>,
672
- operands: &mut HashMap<String, u64>,
653
+ code: &Source<'a>,
654
+ operators: &mut FxHashMap<&'a str, u64>,
655
+ operands: &mut FxHashMap<&'a str, u64>,
673
656
  ) {
674
657
  if matches!(
675
- node.kind(),
658
+ node.kind_name(),
676
659
  "comment" | "line_comment" | "block_comment" | "multiline_comment"
677
660
  ) {
678
661
  return;
679
662
  }
680
663
 
681
- if atomic_operand_node_types().contains(node.kind()) {
682
- *operands
683
- .entry(node_text(node, code).to_string())
684
- .or_insert(0) += 1;
664
+ if atomic_operand_node_types().contains(node.kind_name()) {
665
+ *operands.entry(node_text(node, code)).or_insert(0) += 1;
685
666
  return;
686
667
  }
687
668
 
@@ -692,14 +673,19 @@ fn measure_halstead(root: Node<'_>, code: &Source<'_>) -> HalsteadCounts {
692
673
  // Operands win over text matches so identifiers spelled like word operators stay operands;
693
674
  // C# `nameof(x)` is the one keyword operator the grammar parses as a plain callee.
694
675
  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()))
676
+ *operators.entry(text).or_insert(0) += 1;
677
+ } else if operand_node_types().contains(node.kind_name()) {
678
+ *operands.entry(text).or_insert(0) += 1;
679
+ } else if (operator_texts().contains(text)
680
+ || operator_texts().contains(node.kind_name()))
699
681
  && is_countable_contextual_token(node, text)
700
682
  {
701
- let key = if text.is_empty() { node.kind() } else { text };
702
- *operators.entry(key.to_string()).or_insert(0) += 1;
683
+ let key = if text.is_empty() {
684
+ node.kind_name()
685
+ } else {
686
+ text
687
+ };
688
+ *operators.entry(key).or_insert(0) += 1;
703
689
  }
704
690
  return;
705
691
  }
@@ -722,9 +708,9 @@ fn measure_halstead(root: Node<'_>, code: &Source<'_>) -> HalsteadCounts {
722
708
  /// tree-sitter-c-sharp parses `nameof(x)` as an invocation of an identifier named `nameof`.
723
709
  fn is_csharp_nameof_callee(node: Node<'_>, text: &str) -> bool {
724
710
  text == "nameof"
725
- && node.kind() == "identifier"
726
- && node.parent().is_some_and(|parent| {
727
- parent.kind() == "invocation_expression"
711
+ && node.kind_name() == "identifier"
712
+ && node.parent_node().is_some_and(|parent| {
713
+ parent.kind_name() == "invocation_expression"
728
714
  && parent
729
715
  .child_by_field_name("function")
730
716
  .is_some_and(|callee| callee.id() == node.id())
@@ -746,18 +732,18 @@ const QUESTION_OPERATOR_PARENT_TYPES: &[&str] = &[
746
732
  fn is_countable_contextual_token(node: Node<'_>, text: &str) -> bool {
747
733
  if text == "@" {
748
734
  // Python matrix multiplication only; decorator/annotation `@` marks are not operators.
749
- let parent_type = node.parent().map(|parent| parent.kind());
735
+ let parent_type = node.parent_node().map(|parent| parent.kind_name());
750
736
  return parent_type == Some("binary_operator")
751
737
  || parent_type == Some("augmented_assignment");
752
738
  }
753
739
  if text == "default" {
754
740
  return node
755
- .parent()
756
- .is_some_and(|parent| parent.kind() == "default_expression");
741
+ .parent_node()
742
+ .is_some_and(|parent| parent.kind_name() == "default_expression");
757
743
  }
758
744
  if text != "?" {
759
745
  return true;
760
746
  }
761
- node.parent()
762
- .is_some_and(|parent| QUESTION_OPERATOR_PARENT_TYPES.contains(&parent.kind()))
747
+ node.parent_node()
748
+ .is_some_and(|parent| QUESTION_OPERATOR_PARENT_TYPES.contains(&parent.kind_name()))
763
749
  }
@@ -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]