code-gauge 3.1.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 (63) hide show
  1. package/README.md +16 -15
  2. package/dist/crossFileDuplication.cjs +1 -1
  3. package/dist/crossFileDuplication.cjs.map +1 -1
  4. package/dist/crossFileDuplication.js +1 -1
  5. package/dist/crossFileDuplication.js.map +1 -1
  6. package/dist/diffCommand.cjs +1 -1
  7. package/dist/diffCommand.cjs.map +1 -1
  8. package/dist/diffCommand.js +1 -1
  9. package/dist/diffCommand.js.map +1 -1
  10. package/dist/duplication.cjs +1 -1
  11. package/dist/duplication.cjs.map +1 -1
  12. package/dist/duplication.d.ts +14 -28
  13. package/dist/duplication.js +1 -1
  14. package/dist/duplication.js.map +1 -1
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.d.ts +0 -1
  17. package/dist/index.js +1 -1
  18. package/dist/languages.cjs +1 -1
  19. package/dist/languages.cjs.map +1 -1
  20. package/dist/languages.d.ts +5 -0
  21. package/dist/languages.js +1 -1
  22. package/dist/languages.js.map +1 -1
  23. package/dist/metrics.cjs +1 -1
  24. package/dist/metrics.cjs.map +1 -1
  25. package/dist/metrics.d.ts +14 -12
  26. package/dist/metrics.js +1 -1
  27. package/dist/metrics.js.map +1 -1
  28. package/dist/nativeMetrics.cjs +3 -1
  29. package/dist/nativeMetrics.cjs.map +1 -1
  30. package/dist/nativeMetrics.d.ts +24 -9
  31. package/dist/nativeMetrics.js +3 -1
  32. package/dist/nativeMetrics.js.map +1 -1
  33. package/dist/scan.cjs +1 -1
  34. package/dist/scan.cjs.map +1 -1
  35. package/dist/scan.js +1 -1
  36. package/dist/scan.js.map +1 -1
  37. package/dist/types.d.ts +5 -13
  38. package/native/Cargo.lock +523 -0
  39. package/native/Cargo.toml +45 -0
  40. package/native/build.rs +3 -0
  41. package/native/src/complexity.rs +627 -0
  42. package/native/src/dep_degree.rs +253 -0
  43. package/native/src/duplication.rs +2007 -0
  44. package/native/src/functions.rs +345 -0
  45. package/native/src/languages.rs +647 -0
  46. package/native/src/lib.rs +101 -0
  47. package/native/src/measure.rs +590 -0
  48. package/native/src/ncss.rs +263 -0
  49. package/native/src/types.rs +135 -0
  50. package/native/src/util.rs +139 -0
  51. package/package.json +16 -19
  52. package/scripts/buildNative.mjs +25 -0
  53. package/scripts/installNative.mjs +96 -0
  54. package/dist/depDegree.cjs +0 -2
  55. package/dist/depDegree.cjs.map +0 -1
  56. package/dist/depDegree.d.ts +0 -12
  57. package/dist/depDegree.js +0 -2
  58. package/dist/depDegree.js.map +0 -1
  59. package/dist/ncss.cjs +0 -2
  60. package/dist/ncss.cjs.map +0 -1
  61. package/dist/ncss.d.ts +0 -17
  62. package/dist/ncss.js +0 -2
  63. package/dist/ncss.js.map +0 -1
@@ -0,0 +1,101 @@
1
+ #![deny(clippy::all)]
2
+
3
+ use napi::bindgen_prelude::*;
4
+ use napi_derive::napi;
5
+
6
+ use crate::duplication::DuplicationSettings;
7
+
8
+ mod complexity;
9
+ mod dep_degree;
10
+ mod duplication;
11
+ mod functions;
12
+ mod languages;
13
+ mod measure;
14
+ mod ncss;
15
+ mod types;
16
+ mod util;
17
+
18
+ /// Version of the native payload schema. The TypeScript wrapper refuses a binding whose version
19
+ /// differs from the one it expects, so a stale prebuilt addon fails with a clear rebuild message
20
+ /// instead of silently returning an incompatible payload. Bump on every payload-shape change,
21
+ /// together with `expectedPayloadVersion` in src/nativeMetrics.ts.
22
+ #[napi]
23
+ pub fn payload_version() -> u32 {
24
+ 4
25
+ }
26
+
27
+ /// Measures code metrics for the given source, returning the NativeMetrics payload as JSON.
28
+ /// The TypeScript wrapper derives the remaining float metrics (Halstead volume/effort/...): they
29
+ /// involve transcendental functions whose last-bit results can differ between V8 and Rust's libm,
30
+ /// and results must not depend on which side computes them.
31
+ #[napi]
32
+ pub fn measure_code_native(
33
+ code: String,
34
+ language: String,
35
+ include_syntax_tree: Option<bool>,
36
+ min_tokens: Option<u32>,
37
+ max_gap_tokens: Option<u32>,
38
+ min_similarity_percent: Option<u32>,
39
+ ) -> Result<String> {
40
+ let definition = find_language(&language)?;
41
+ let settings = to_duplication_settings(min_tokens, max_gap_tokens, min_similarity_percent);
42
+ let metrics = measure::measure(
43
+ &code,
44
+ definition,
45
+ include_syntax_tree.unwrap_or(false),
46
+ &settings,
47
+ )
48
+ .map_err(Error::from_reason)?;
49
+ serde_json::to_string(&metrics).map_err(|error| Error::from_reason(error.to_string()))
50
+ }
51
+
52
+ /// Collects one file's cross-file clone-detection contribution (candidates, normalized token
53
+ /// stream, statement structure, and code line numbers) as JSON; see CrossFileFileData.
54
+ #[napi]
55
+ pub fn collect_cross_file_data_native(
56
+ code: String,
57
+ language: String,
58
+ min_tokens: Option<u32>,
59
+ ) -> Result<String> {
60
+ let definition = find_language(&language)?;
61
+ let min_tokens = min_tokens
62
+ .map(|value| value as usize)
63
+ .unwrap_or(DuplicationSettings::default().min_tokens);
64
+ let data = measure::collect_cross_file_data(&code, definition, min_tokens)
65
+ .map_err(Error::from_reason)?;
66
+ serde_json::to_string(&data).map_err(|error| Error::from_reason(error.to_string()))
67
+ }
68
+
69
+ /// Collects normalized token hash sequences of every function as JSON (number[][]),
70
+ /// index-parallel to the functions array of measure_code_native.
71
+ #[napi]
72
+ pub fn collect_function_token_sequences_native(code: String, language: String) -> Result<String> {
73
+ let definition = find_language(&language)?;
74
+ let sequences =
75
+ measure::collect_function_token_sequences(&code, definition).map_err(Error::from_reason)?;
76
+ serde_json::to_string(&sequences).map_err(|error| Error::from_reason(error.to_string()))
77
+ }
78
+
79
+ fn find_language(language: &str) -> Result<&'static languages::LanguageDefinition> {
80
+ languages::find_language(language)
81
+ .ok_or_else(|| Error::from_reason(format!("Unsupported language: {language}")))
82
+ }
83
+
84
+ fn to_duplication_settings(
85
+ min_tokens: Option<u32>,
86
+ max_gap_tokens: Option<u32>,
87
+ min_similarity_percent: Option<u32>,
88
+ ) -> DuplicationSettings {
89
+ let defaults = DuplicationSettings::default();
90
+ DuplicationSettings {
91
+ min_tokens: min_tokens
92
+ .map(|value| value as usize)
93
+ .unwrap_or(defaults.min_tokens),
94
+ max_gap_tokens: max_gap_tokens
95
+ .map(|value| value as usize)
96
+ .unwrap_or(defaults.max_gap_tokens),
97
+ min_similarity_percent: min_similarity_percent
98
+ .map(|value| value as usize)
99
+ .unwrap_or(defaults.min_similarity_percent),
100
+ }
101
+ }
@@ -0,0 +1,590 @@
1
+ use std::collections::{HashMap, HashSet};
2
+ use std::sync::OnceLock;
3
+ use tree_sitter::Node;
4
+
5
+ use crate::complexity::{
6
+ is_lambda_body_block, measure_complexity, measure_function_body_metrics, LanguageSets,
7
+ };
8
+ use crate::dep_degree::measure_dep_degree;
9
+ use crate::duplication::{
10
+ collect_cross_file_file_data, hash_text, measure_duplication, DuplicationSettings,
11
+ };
12
+ use crate::functions::{
13
+ collect_nodes, count_parameters, find_function_name, is_implemented_function,
14
+ };
15
+ use crate::languages::LanguageDefinition;
16
+ use crate::types::{
17
+ CrossFileFileData, FunctionMetrics, HalsteadCounts, LineMetrics, NativeMetrics,
18
+ };
19
+ use crate::util::{all_children, is_js_whitespace, named_children, node_text, split_lines, Source};
20
+
21
+ pub fn measure(
22
+ code: &str,
23
+ language: &LanguageDefinition,
24
+ include_syntax_tree: bool,
25
+ duplication_settings: &DuplicationSettings,
26
+ ) -> Result<NativeMetrics, String> {
27
+ let source = Source::new(code);
28
+ let tree = parse_source(&source, language)?;
29
+ let root = tree.root_node();
30
+ let code = &source;
31
+ let sets = LanguageSets::new(language);
32
+
33
+ let functions: Vec<Node<'_>> = collect_nodes(root, &sets.function_nodes)
34
+ .into_iter()
35
+ .filter(|node| !is_lambda_body_block(*node) && is_implemented_function(*node))
36
+ .collect();
37
+
38
+ let body_metrics_by_node_id = measure_function_body_metrics(root, &sets, code);
39
+ let function_metrics: Vec<FunctionMetrics> = functions
40
+ .iter()
41
+ .map(|node| {
42
+ let body_metrics = body_metrics_by_node_id
43
+ .get(&node.id())
44
+ .expect("every collected function node opens a frame in the body-metrics pass");
45
+ FunctionMetrics {
46
+ name: find_function_name(*node, code),
47
+ node_type: node.kind().to_string(),
48
+ start_line: node.start_position().row + 1,
49
+ // The tree is parsed from UTF-16, so columns are UTF-16 code units x 2 — halving
50
+ // yields the JavaScript string (UTF-16 code unit) column.
51
+ start_column: node.start_position().column / 2,
52
+ end_line: node.end_position().row + 1,
53
+ // Sonar's written spec adds +1 cognitive complexity per function in a recursion
54
+ // cycle, but this is intentionally not implemented (issue #22): mainstream
55
+ // implementations (PMD, SonarQube analyzers) omit it.
56
+ cognitive_complexity: body_metrics.cognitive_complexity,
57
+ nesting_depth: body_metrics.nesting_depth,
58
+ ncss: body_metrics.ncss,
59
+ parameter_count: count_parameters(*node, code),
60
+ halstead_counts: measure_halstead(*node, code),
61
+ dep_degree: measure_dep_degree(*node, code, &sets.function_nodes),
62
+ }
63
+ })
64
+ .collect();
65
+
66
+ let global_complexity = measure_complexity(root, &sets, code);
67
+ let (lines, code_line_numbers) = classify_lines(code, root);
68
+ let halstead_counts = measure_halstead(root, code);
69
+
70
+ Ok(NativeMetrics {
71
+ language: language.name.to_string(),
72
+ bytes: code.code.len(),
73
+ lines,
74
+ cognitive_complexity: global_complexity.cognitive_complexity,
75
+ max_cognitive_complexity: function_metrics
76
+ .iter()
77
+ .map(|function| function.cognitive_complexity)
78
+ .max()
79
+ .unwrap_or(0),
80
+ nesting_depth: global_complexity.nesting_depth,
81
+ ncss_count: crate::ncss::count_ncss(root, &sets.ncss_nodes, &sets.ncss_containers),
82
+ duplication: measure_duplication(root, &code_line_numbers, code, duplication_settings),
83
+ halstead_counts,
84
+ functions: function_metrics,
85
+ syntax_tree: if include_syntax_tree {
86
+ Some(root.to_sexp())
87
+ } else {
88
+ None
89
+ },
90
+ })
91
+ }
92
+
93
+ /// Collects one file's cross-file clone-detection contribution; see CrossFileFileData.
94
+ pub fn collect_cross_file_data(
95
+ code: &str,
96
+ language: &LanguageDefinition,
97
+ min_tokens: usize,
98
+ ) -> Result<CrossFileFileData, String> {
99
+ let source = Source::new(code);
100
+ let tree = parse_source(&source, language)?;
101
+ let root = tree.root_node();
102
+ let (candidates, tokens, container_statements) =
103
+ collect_cross_file_file_data(root, &source, min_tokens);
104
+ let (_, code_line_numbers) = classify_lines(&source, root);
105
+ let mut code_line_numbers: Vec<usize> = code_line_numbers.into_iter().collect();
106
+ code_line_numbers.sort_unstable();
107
+ Ok(CrossFileFileData {
108
+ candidates,
109
+ tokens,
110
+ container_statements,
111
+ code_line_numbers,
112
+ })
113
+ }
114
+
115
+ /// Name-carrying leaf types anonymized by tokenize_function so consistent renames still match.
116
+ const IDENTIFIER_LEAF_NODE_TYPES: &[&str] = &[
117
+ "identifier",
118
+ "property_identifier",
119
+ "field_identifier",
120
+ "type_identifier",
121
+ "constant",
122
+ "instance_variable",
123
+ "class_variable",
124
+ "global_variable",
125
+ ];
126
+
127
+ /// Normalized token hash sequences of every function, index-parallel to the functions array of
128
+ /// measure(); a faithful port of tokenizeFunction in src/metrics.ts.
129
+ pub fn collect_function_token_sequences(
130
+ code: &str,
131
+ language: &LanguageDefinition,
132
+ ) -> Result<Vec<Vec<i32>>, String> {
133
+ let source = Source::new(code);
134
+ let tree = parse_source(&source, language)?;
135
+ let root = tree.root_node();
136
+ let sets = LanguageSets::new(language);
137
+ Ok(collect_nodes(root, &sets.function_nodes)
138
+ .into_iter()
139
+ .filter(|node| !is_lambda_body_block(*node) && is_implemented_function(*node))
140
+ .map(|node| {
141
+ let mut symbols = Vec::new();
142
+ let mut id_index_by_name: HashMap<String, usize> = HashMap::new();
143
+ collect_token_symbols(node, &source, &mut symbols, &mut id_index_by_name);
144
+ symbols
145
+ })
146
+ .collect())
147
+ }
148
+
149
+ fn collect_token_symbols(
150
+ node: Node<'_>,
151
+ code: &Source<'_>,
152
+ symbols: &mut Vec<i32>,
153
+ id_index_by_name: &mut HashMap<String, usize>,
154
+ ) {
155
+ if matches!(node.kind(), "comment" | "line_comment" | "block_comment") {
156
+ return;
157
+ }
158
+ if atomic_operand_node_types().contains(node.kind()) {
159
+ symbols.push(hash_text(node.kind()));
160
+ return;
161
+ }
162
+ if node.child_count() > 0 {
163
+ for child in all_children(node) {
164
+ collect_token_symbols(child, code, symbols, id_index_by_name);
165
+ }
166
+ return;
167
+ }
168
+ if IDENTIFIER_LEAF_NODE_TYPES.contains(&node.kind()) {
169
+ let next_index = id_index_by_name.len();
170
+ let index = *id_index_by_name
171
+ .entry(node_text(node, code).to_string())
172
+ .or_insert(next_index);
173
+ symbols.push(hash_text(&format!("id{index}")));
174
+ return;
175
+ }
176
+ // Remaining operand leaves are literals, normalized by kind; everything else (keywords,
177
+ // operators, punctuation) is kept verbatim.
178
+ symbols.push(hash_text(if operand_node_types().contains(node.kind()) {
179
+ node.kind()
180
+ } else {
181
+ node_text(node, code)
182
+ }));
183
+ }
184
+
185
+ /// Parses the source from UTF-16 (matching node-tree-sitter's JavaScript string semantics:
186
+ /// tree-sitter's error recovery differs between input encodings for malformed non-ASCII source)
187
+ /// and refuses pathologically deep trees: the metric passes recurse per tree level and would
188
+ /// overflow the native stack (a process-killing SIGSEGV, not a catchable error) around depth ~20k.
189
+ fn parse_source(
190
+ source: &Source<'_>,
191
+ language: &LanguageDefinition,
192
+ ) -> Result<tree_sitter::Tree, String> {
193
+ let mut parser = tree_sitter::Parser::new();
194
+ parser
195
+ .set_language(&language.grammar())
196
+ .map_err(|error| error.to_string())?;
197
+ let tree = parser
198
+ .parse_utf16(source.to_utf16(), None)
199
+ .ok_or_else(|| "parse failed".to_string())?;
200
+ if tree_depth(tree.root_node()) > MAX_TREE_DEPTH {
201
+ return Err(format!("tree depth exceeds {MAX_TREE_DEPTH}"));
202
+ }
203
+ Ok(tree)
204
+ }
205
+
206
+ /// See the depth check in parse_source(); computed iteratively so the check itself cannot overflow.
207
+ const MAX_TREE_DEPTH: usize = 5_000;
208
+
209
+ fn tree_depth(root: Node<'_>) -> usize {
210
+ let mut cursor = root.walk();
211
+ let mut depth = 0;
212
+ let mut max_depth = 0;
213
+ loop {
214
+ if cursor.goto_first_child() {
215
+ depth += 1;
216
+ max_depth = max_depth.max(depth);
217
+ continue;
218
+ }
219
+ loop {
220
+ if cursor.goto_next_sibling() {
221
+ break;
222
+ }
223
+ if !cursor.goto_parent() {
224
+ return max_depth;
225
+ }
226
+ depth -= 1;
227
+ }
228
+ }
229
+ }
230
+
231
+ struct CommentSpan {
232
+ line: usize,
233
+ start_column: usize,
234
+ end_column: usize,
235
+ }
236
+
237
+ /// 1-based numbers of lines that are neither blank nor comment-only, matching classifyLines in
238
+ /// metrics.ts so duplication line coverage and its code-line denominator agree.
239
+ fn classify_lines(code: &Source<'_>, root: Node<'_>) -> (LineMetrics, HashSet<usize>) {
240
+ let source_lines = split_lines(code.code);
241
+ // Spans are bucketed by line so classification stays linear.
242
+ let mut comment_spans_by_line: HashMap<usize, Vec<CommentSpan>> = HashMap::new();
243
+ for span in collect_comment_spans(root) {
244
+ comment_spans_by_line
245
+ .entry(span.line)
246
+ .or_default()
247
+ .push(span);
248
+ }
249
+ let mut blank = 0;
250
+ let mut comment = 0;
251
+ let mut code_line_numbers = HashSet::new();
252
+
253
+ for (index, line) in source_lines.iter().enumerate() {
254
+ if line.chars().all(is_js_whitespace) {
255
+ blank += 1;
256
+ continue;
257
+ }
258
+ let empty_spans = Vec::new();
259
+ let relevant_spans = comment_spans_by_line.get(&index).unwrap_or(&empty_spans);
260
+ if is_comment_only_line(line, relevant_spans) {
261
+ comment += 1;
262
+ } else {
263
+ code_line_numbers.insert(index + 1);
264
+ }
265
+ }
266
+
267
+ (
268
+ LineMetrics {
269
+ total: source_lines.len(),
270
+ code: code_line_numbers.len(),
271
+ comment,
272
+ blank,
273
+ },
274
+ code_line_numbers,
275
+ )
276
+ }
277
+
278
+ fn collect_comment_spans(root: Node<'_>) -> Vec<CommentSpan> {
279
+ let mut spans = Vec::new();
280
+
281
+ fn visit(node: Node<'_>, spans: &mut Vec<CommentSpan>) {
282
+ if matches!(node.kind(), "comment" | "line_comment" | "block_comment") {
283
+ for row in node.start_position().row..=node.end_position().row {
284
+ // Node columns are UTF-16 code units x 2 (the tree is parsed from UTF-16);
285
+ // halving matches the code-unit columns the line scan below counts.
286
+ spans.push(CommentSpan {
287
+ line: row,
288
+ start_column: if row == node.start_position().row {
289
+ node.start_position().column / 2
290
+ } else {
291
+ 0
292
+ },
293
+ end_column: if row == node.end_position().row {
294
+ node.end_position().column / 2
295
+ } else {
296
+ usize::MAX
297
+ },
298
+ });
299
+ }
300
+ }
301
+
302
+ for child in named_children(node) {
303
+ visit(child, spans);
304
+ }
305
+ }
306
+
307
+ visit(root, &mut spans);
308
+ spans
309
+ }
310
+
311
+ fn is_comment_only_line(line: &str, relevant_spans: &[CommentSpan]) -> bool {
312
+ if relevant_spans.is_empty() {
313
+ return false;
314
+ }
315
+
316
+ // A line may hold several comments (`/* one */ /* two */`), so every non-whitespace column must
317
+ // be covered by the UNION of spans, not by a single span. Columns are UTF-16 code units,
318
+ // matching the span columns derived from the UTF-16 parse.
319
+ let mut column = 0;
320
+ for character in line.chars() {
321
+ if !is_js_whitespace(character)
322
+ && !relevant_spans
323
+ .iter()
324
+ .any(|span| span.start_column <= column && column < span.end_column)
325
+ {
326
+ return false;
327
+ }
328
+ column += character.len_utf16();
329
+ }
330
+ true
331
+ }
332
+
333
+ const OPERATOR_TEXTS: &[&str] = &[
334
+ "+",
335
+ "-",
336
+ "*",
337
+ "/",
338
+ "%",
339
+ "**",
340
+ "=",
341
+ "+=",
342
+ "-=",
343
+ "*=",
344
+ "/=",
345
+ "%=",
346
+ "==",
347
+ "!=",
348
+ "===",
349
+ "!==",
350
+ "<",
351
+ "<=",
352
+ ">",
353
+ ">=",
354
+ "!",
355
+ "~",
356
+ "&",
357
+ "|",
358
+ "^",
359
+ "++",
360
+ "--",
361
+ "<<",
362
+ ">>",
363
+ ">>>",
364
+ "=>",
365
+ "**=",
366
+ "<<=",
367
+ ">>=",
368
+ ">>>=",
369
+ "&=",
370
+ "|=",
371
+ "^=",
372
+ "&&=",
373
+ "||=",
374
+ "??=",
375
+ "??",
376
+ "?.",
377
+ "?",
378
+ "//",
379
+ "//=",
380
+ "@",
381
+ "@=",
382
+ ":=",
383
+ "<-",
384
+ "<=>",
385
+ "=~",
386
+ "..",
387
+ "...",
388
+ "..=",
389
+ "&&",
390
+ "||",
391
+ "!~",
392
+ "&^",
393
+ "&^=",
394
+ "&.",
395
+ // Member access/qualification are classical Halstead operators; `->` also captures
396
+ // Python/Rust return-type arrows, consistent with the counted `=>`.
397
+ ".",
398
+ "->",
399
+ "::",
400
+ "->*",
401
+ ".*",
402
+ "sizeof",
403
+ "alignof",
404
+ "defined?",
405
+ "as",
406
+ // C++ alternative operator tokens parse as anonymous leaves like their symbolic forms.
407
+ "bitand",
408
+ "bitor",
409
+ "xor",
410
+ "compl",
411
+ "and_eq",
412
+ "or_eq",
413
+ "xor_eq",
414
+ "not_eq",
415
+ "and",
416
+ "or",
417
+ "not",
418
+ "in",
419
+ "is",
420
+ "instanceof",
421
+ "typeof",
422
+ "new",
423
+ "delete",
424
+ "return",
425
+ "throw",
426
+ "raise",
427
+ "yield",
428
+ "await",
429
+ "co_await",
430
+ "co_yield",
431
+ "co_return",
432
+ "break",
433
+ "continue",
434
+ ];
435
+
436
+ const OPERAND_NODE_TYPES: &[&str] = &[
437
+ "identifier",
438
+ "property_identifier",
439
+ "field_identifier",
440
+ "type_identifier",
441
+ "constant",
442
+ "instance_variable",
443
+ "class_variable",
444
+ "global_variable",
445
+ "simple_symbol",
446
+ "self",
447
+ "this",
448
+ "super",
449
+ // C/C++/Rust built-in types are leaves of their own node type, unlike Go's `type_identifier`.
450
+ "primitive_type",
451
+ "boolean_type",
452
+ "void_type",
453
+ "auto",
454
+ "number",
455
+ "integer",
456
+ "float",
457
+ "integer_literal",
458
+ "float_literal",
459
+ "int_literal",
460
+ "rune_literal",
461
+ "imaginary_literal",
462
+ "number_literal",
463
+ "decimal_integer_literal",
464
+ "hex_integer_literal",
465
+ "octal_integer_literal",
466
+ "binary_integer_literal",
467
+ "decimal_floating_point_literal",
468
+ "hex_floating_point_literal",
469
+ "string",
470
+ "string_literal",
471
+ // Go raw strings are leaves with no content child, unlike Rust/C++ `raw_string_literal`s.
472
+ "raw_string_literal",
473
+ "string_fragment",
474
+ "multiline_string_fragment",
475
+ "string_content",
476
+ "raw_string_content",
477
+ "template_string",
478
+ "character_literal",
479
+ "char_literal",
480
+ "character",
481
+ "true",
482
+ "false",
483
+ "null",
484
+ "null_literal",
485
+ "undefined",
486
+ "nil",
487
+ "none",
488
+ ];
489
+
490
+ /// Non-leaf literals counted as one Halstead operand without descending; see metrics.ts.
491
+ const ATOMIC_OPERAND_NODE_TYPES: &[&str] = &[
492
+ "interpreted_string_literal",
493
+ "regex",
494
+ "user_defined_literal",
495
+ "integral_type",
496
+ "floating_point_type",
497
+ "sized_type_specifier",
498
+ "placeholder_type_specifier",
499
+ ];
500
+
501
+ fn operator_texts() -> &'static HashSet<&'static str> {
502
+ static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
503
+ SET.get_or_init(|| OPERATOR_TEXTS.iter().copied().collect())
504
+ }
505
+
506
+ fn operand_node_types() -> &'static HashSet<&'static str> {
507
+ static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
508
+ SET.get_or_init(|| OPERAND_NODE_TYPES.iter().copied().collect())
509
+ }
510
+
511
+ fn atomic_operand_node_types() -> &'static HashSet<&'static str> {
512
+ static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
513
+ SET.get_or_init(|| ATOMIC_OPERAND_NODE_TYPES.iter().copied().collect())
514
+ }
515
+
516
+ fn measure_halstead(root: Node<'_>, code: &Source<'_>) -> HalsteadCounts {
517
+ let mut operators: HashMap<String, u64> = HashMap::new();
518
+ let mut operands: HashMap<String, u64> = HashMap::new();
519
+
520
+ fn visit(
521
+ node: Node<'_>,
522
+ code: &Source<'_>,
523
+ operators: &mut HashMap<String, u64>,
524
+ operands: &mut HashMap<String, u64>,
525
+ ) {
526
+ if matches!(node.kind(), "comment" | "line_comment" | "block_comment") {
527
+ return;
528
+ }
529
+
530
+ if atomic_operand_node_types().contains(node.kind()) {
531
+ *operands
532
+ .entry(node_text(node, code).to_string())
533
+ .or_insert(0) += 1;
534
+ return;
535
+ }
536
+
537
+ // Operators are counted from leaf tokens only: keyword-named nodes always contain a
538
+ // same-text anonymous keyword leaf, so counting the named node as well would double-count.
539
+ if node.child_count() == 0 {
540
+ let text = node_text(node, code);
541
+ // Operands win over text matches so identifiers spelled like word operators stay operands.
542
+ if operand_node_types().contains(node.kind()) {
543
+ *operands.entry(text.to_string()).or_insert(0) += 1;
544
+ } else if (operator_texts().contains(text) || operator_texts().contains(node.kind()))
545
+ && is_countable_contextual_token(node, text)
546
+ {
547
+ let key = if text.is_empty() { node.kind() } else { text };
548
+ *operators.entry(key.to_string()).or_insert(0) += 1;
549
+ }
550
+ return;
551
+ }
552
+
553
+ for child in all_children(node) {
554
+ visit(child, code, operators, operands);
555
+ }
556
+ }
557
+
558
+ visit(root, code, &mut operators, &mut operands);
559
+
560
+ HalsteadCounts {
561
+ distinct_operators: operators.len(),
562
+ distinct_operands: operands.len(),
563
+ total_operators: operators.values().sum(),
564
+ total_operands: operands.values().sum(),
565
+ }
566
+ }
567
+
568
+ /// Ternary/conditional and Rust try parents make `?` an operator; TS optional markers do not.
569
+ const QUESTION_OPERATOR_PARENT_TYPES: &[&str] = &[
570
+ "ternary_expression",
571
+ "conditional_expression",
572
+ "conditional",
573
+ "try_expression",
574
+ // TypeScript conditional types (`T extends U ? X : Y`) select like a ternary.
575
+ "conditional_type",
576
+ ];
577
+
578
+ fn is_countable_contextual_token(node: Node<'_>, text: &str) -> bool {
579
+ if text == "@" {
580
+ // Python matrix multiplication only; decorator/annotation `@` marks are not operators.
581
+ let parent_type = node.parent().map(|parent| parent.kind());
582
+ return parent_type == Some("binary_operator")
583
+ || parent_type == Some("augmented_assignment");
584
+ }
585
+ if text != "?" {
586
+ return true;
587
+ }
588
+ node.parent()
589
+ .is_some_and(|parent| QUESTION_OPERATOR_PARENT_TYPES.contains(&parent.kind()))
590
+ }