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.
- package/README.md +83 -21
- package/dist/cli.cjs +3 -3
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +3 -3
- package/dist/cli.js.map +1 -1
- package/dist/cliConfig.cjs +1 -1
- package/dist/cliConfig.cjs.map +1 -1
- package/dist/cliConfig.d.ts +11 -0
- package/dist/cliConfig.js +1 -1
- package/dist/cliConfig.js.map +1 -1
- package/dist/crossFileDuplication.cjs +1 -1
- package/dist/crossFileDuplication.cjs.map +1 -1
- package/dist/crossFileDuplication.js +1 -1
- package/dist/crossFileDuplication.js.map +1 -1
- package/dist/diffCommand.cjs +5 -0
- package/dist/diffCommand.cjs.map +1 -0
- package/dist/diffCommand.d.ts +17 -0
- package/dist/diffCommand.js +5 -0
- package/dist/diffCommand.js.map +1 -0
- package/dist/duplication.cjs +1 -1
- package/dist/duplication.cjs.map +1 -1
- package/dist/duplication.d.ts +20 -24
- package/dist/duplication.js +1 -1
- package/dist/duplication.js.map +1 -1
- package/dist/git.cjs +2 -0
- package/dist/git.cjs.map +1 -0
- package/dist/git.d.ts +27 -0
- package/dist/git.js +2 -0
- package/dist/git.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/languages.cjs +1 -1
- package/dist/languages.cjs.map +1 -1
- package/dist/languages.d.ts +5 -0
- package/dist/languages.js +1 -1
- package/dist/languages.js.map +1 -1
- package/dist/metrics.cjs +1 -1
- package/dist/metrics.cjs.map +1 -1
- package/dist/metrics.d.ts +18 -5
- package/dist/metrics.js +1 -1
- package/dist/metrics.js.map +1 -1
- package/dist/nativeMetrics.cjs +3 -1
- package/dist/nativeMetrics.cjs.map +1 -1
- package/dist/nativeMetrics.d.ts +29 -10
- package/dist/nativeMetrics.js +3 -1
- package/dist/nativeMetrics.js.map +1 -1
- package/dist/regressionGate.cjs +2 -0
- package/dist/regressionGate.cjs.map +1 -0
- package/dist/regressionGate.d.ts +106 -0
- package/dist/regressionGate.js +2 -0
- package/dist/regressionGate.js.map +1 -0
- package/dist/scan.cjs +2 -0
- package/dist/scan.cjs.map +1 -0
- package/dist/scan.d.ts +55 -0
- package/dist/scan.js +2 -0
- package/dist/scan.js.map +1 -0
- package/dist/types.d.ts +18 -13
- package/native/Cargo.lock +523 -0
- package/native/Cargo.toml +45 -0
- package/native/build.rs +3 -0
- package/native/src/complexity.rs +627 -0
- package/native/src/dep_degree.rs +253 -0
- package/native/src/duplication.rs +2007 -0
- package/native/src/functions.rs +345 -0
- package/native/src/languages.rs +647 -0
- package/native/src/lib.rs +101 -0
- package/native/src/measure.rs +590 -0
- package/native/src/ncss.rs +263 -0
- package/native/src/types.rs +135 -0
- package/native/src/util.rs +139 -0
- package/package.json +16 -19
- package/scripts/buildNative.mjs +25 -0
- package/scripts/installNative.mjs +96 -0
- package/dist/ncss.cjs +0 -2
- package/dist/ncss.cjs.map +0 -1
- package/dist/ncss.d.ts +0 -17
- package/dist/ncss.js +0 -2
- 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
|
+
}
|