1use std::collections::HashSet;
9
10use ast_grep_core::{AstGrep, Doc};
11use serde::{Deserialize, Serialize};
12use strum::Display;
13
14use crate::rule::Language;
15use crate::test_filter::CfgTestRanges;
16
17#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20#[strum(serialize_all = "snake_case")]
21pub enum Metric {
22 FileLines,
23 CodeLines,
24 ImportCount,
25 FunctionCount,
26 CommentRatio,
27}
28
29fn import_kinds(lang: &Language) -> &'static [&'static str] {
31 match lang {
32 Language::Rust => &["use_declaration", "extern_crate_declaration"],
33 Language::TypeScript => &["import_statement"],
34 }
35}
36
37fn function_kinds(lang: &Language) -> &'static [&'static str] {
41 match lang {
42 Language::Rust => &["function_item"],
43 Language::TypeScript => &[
44 "function_declaration",
45 "generator_function_declaration",
46 "function_expression",
47 "arrow_function",
48 "method_definition",
49 ],
50 }
51}
52
53fn comment_kinds(lang: &Language) -> &'static [&'static str] {
55 match lang {
56 Language::Rust => &["line_comment", "block_comment", "doc_comment"],
57 Language::TypeScript => &["comment"],
58 }
59}
60
61pub fn compute<D: Doc>(
67 metric: Metric,
68 lang: &Language,
69 root: &AstGrep<D>,
70 source: &str,
71 exclude_tests: Option<&CfgTestRanges>,
72) -> f64 {
73 match metric {
74 Metric::FileLines => count_lines(source, exclude_tests) as f64,
75 Metric::CodeLines => {
76 count_code_lines(root, comment_kinds(lang), source, exclude_tests) as f64
77 }
78 Metric::ImportCount => count_kinds(root, import_kinds(lang), exclude_tests) as f64,
79 Metric::FunctionCount => count_kinds(root, function_kinds(lang), exclude_tests) as f64,
80 Metric::CommentRatio => {
81 let total_lines = count_lines(source, exclude_tests);
82 if total_lines == 0 {
83 return 0.0;
84 }
85 comment_lines(root, comment_kinds(lang), exclude_tests) as f64 / total_lines as f64
86 }
87 }
88}
89
90fn in_test(exclude_tests: Option<&CfgTestRanges>, zero_indexed_line: usize) -> bool {
93 exclude_tests.is_some_and(|r| r.contains_line(zero_indexed_line + 1))
94}
95
96fn count_lines(source: &str, exclude_tests: Option<&CfgTestRanges>) -> usize {
101 source
102 .lines()
103 .enumerate()
104 .filter(|(i, _)| !in_test(exclude_tests, *i))
105 .count()
106}
107
108fn count_code_lines<D: Doc>(
116 root: &AstGrep<D>,
117 kinds: &[&str],
118 source: &str,
119 exclude_tests: Option<&CfgTestRanges>,
120) -> usize {
121 let mut code = source.as_bytes().to_vec();
122 for node in root.root().dfs() {
123 if !kinds.contains(&node.kind().as_ref()) {
124 continue;
125 }
126 if let Some(comment) = code.get_mut(node.range()) {
127 for byte in comment.iter_mut().filter(|b| **b != b'\n') {
128 *byte = b' ';
129 }
130 }
131 }
132 code.split(|b| *b == b'\n')
133 .enumerate()
134 .filter(|(i, line)| {
135 !in_test(exclude_tests, *i) && line.iter().any(|b| !b.is_ascii_whitespace())
136 })
137 .count()
138}
139
140fn count_kinds<D: Doc>(
141 root: &AstGrep<D>,
142 kinds: &[&str],
143 exclude_tests: Option<&CfgTestRanges>,
144) -> usize {
145 let mut count = 0;
146 for node in root.root().dfs() {
147 if kinds.contains(&node.kind().as_ref()) && !in_test(exclude_tests, node.start_pos().line())
148 {
149 count += 1;
150 }
151 }
152 count
153}
154
155fn comment_lines<D: Doc>(
164 root: &AstGrep<D>,
165 kinds: &[&str],
166 exclude_tests: Option<&CfgTestRanges>,
167) -> usize {
168 let mut lines = HashSet::new();
169 let mut doc_lines = HashSet::new();
170 for node in root.root().dfs() {
171 let kind = node.kind();
172 if kind.as_ref() == "doc_comment" {
173 doc_lines.extend(node.start_pos().line()..=node.end_pos().line());
174 continue;
175 }
176 if !kinds.contains(&kind.as_ref()) {
177 continue;
178 }
179 let range = node.start_pos().line()..=node.end_pos().line();
180 if node.text().trim_start().starts_with("/**") {
181 doc_lines.extend(range);
182 } else {
183 lines.extend(range);
184 }
185 }
186 lines
187 .into_iter()
188 .filter(|line| !doc_lines.contains(line) && !in_test(exclude_tests, *line))
189 .count()
190}
191
192impl Metric {
193 pub fn format_value(&self, value: f64) -> String {
196 match self {
197 Metric::CommentRatio => format!("{value:.2}"),
198 _ => (value as u64).to_string(),
199 }
200 }
201
202 pub fn describe(&self, value: f64) -> String {
205 let rendered = self.format_value(value);
206 match self {
207 Metric::FileLines => format!("{rendered} lines"),
208 Metric::CodeLines => format!("{rendered} lines of code"),
209 Metric::ImportCount => format!("{rendered} imports"),
210 Metric::FunctionCount => format!("{rendered} functions"),
211 Metric::CommentRatio => format!("{rendered} comment ratio"),
212 }
213 }
214}
215
216pub fn exceeds(value: f64, threshold: f64) -> bool {
219 value > threshold
220}
221
222#[cfg(test)]
223mod tests {
224 use ast_grep_core::tree_sitter::LanguageExt;
225 use ast_grep_language::SupportLang;
226
227 use super::*;
228
229 fn rust(metric: Metric, source: &str) -> f64 {
230 let root = SupportLang::Rust.ast_grep(source);
231 compute(metric, &Language::Rust, &root, source, None)
232 }
233
234 fn typescript(metric: Metric, source: &str) -> f64 {
235 let root = SupportLang::TypeScript.ast_grep(source);
236 compute(metric, &Language::TypeScript, &root, source, None)
237 }
238
239 fn cfg_ranges(source: &str) -> CfgTestRanges {
240 CfgTestRanges::from_root(&SupportLang::Rust.ast_grep(source))
241 }
242
243 fn rust_no_tests(metric: Metric, source: &str) -> f64 {
244 let root = SupportLang::Rust.ast_grep(source);
245 compute(
246 metric,
247 &Language::Rust,
248 &root,
249 source,
250 Some(&cfg_ranges(source)),
251 )
252 }
253
254 #[test]
255 fn file_lines_counts_lines() {
256 let source = "fn a() {}\nfn b() {}\nfn c() {}\n";
257 assert_eq!(rust(Metric::FileLines, source), 3.0);
258 assert_eq!(
259 rust(Metric::FileLines, "fn a() {}\n"),
260 1.0,
261 "a trailing newline terminates the last line"
262 );
263 }
264
265 #[test]
266 fn file_lines_empty_file_is_zero() {
267 assert_eq!(rust(Metric::FileLines, ""), 0.0);
268 }
269
270 #[test]
271 fn code_lines_skips_blank_and_comment_only_lines() {
272 let source = "\
273//! module doc
274use std::fmt;
275
276/// doc for a
277/// still doc
278fn a() {} // trailing comment keeps the line
279/* block
280 comment */
281// narration
282fn b() {
283 let url = \"https://example.com\"; /* inline */ let x = 1;
284}
285";
286 assert_eq!(rust(Metric::FileLines, source), 12.0);
287 assert_eq!(rust(Metric::CodeLines, source), 5.0);
289 }
290
291 #[test]
292 fn code_lines_typescript() {
293 let source = "\
294/** jsdoc
295 * more jsdoc
296 */
297// narration
298export function f(): string {
299 return \"// not a comment\";
300}
301";
302 assert_eq!(typescript(Metric::CodeLines, source), 3.0);
304 }
305
306 #[test]
307 fn code_lines_excludes_cfg_test() {
308 let source = "\
309fn prod() {}
310
311#[cfg(test)]
312mod tests {
313 // test comment
314 fn t() {}
315}
316";
317 assert_eq!(rust(Metric::CodeLines, source), 5.0);
318 assert_eq!(rust_no_tests(Metric::CodeLines, source), 1.0);
319 }
320
321 #[test]
322 fn code_lines_empty_or_comment_only_file_is_zero() {
323 assert_eq!(rust(Metric::CodeLines, ""), 0.0);
324 assert_eq!(rust(Metric::CodeLines, "// only\n\n/// docs\n"), 0.0);
325 }
326
327 #[test]
328 fn import_count_rust() {
329 let source = "\
330use std::fmt;
331use std::io::Write;
332extern crate serde;
333
334fn f() {
335 use std::collections::HashMap;
336 let _: HashMap<u8, u8> = HashMap::new();
337}
338";
339 assert_eq!(
340 rust(Metric::ImportCount, source),
341 4.0,
342 "a nested `use` inside a function still counts"
343 );
344 }
345
346 #[test]
347 fn import_count_typescript() {
348 let source = "\
349import { a } from \"./a\";
350import b from \"./b\";
351const c = require(\"./c\");
352";
353 assert_eq!(
354 typescript(Metric::ImportCount, source),
355 2.0,
356 "a require() call is not an import statement"
357 );
358 }
359
360 #[test]
361 fn function_count_rust() {
362 let source = "\
363trait T {
364 fn signature_only(&self);
365}
366
367struct S;
368
369impl S {
370 fn method(&self) {}
371 fn other(&self) {}
372}
373
374fn free() {}
375";
376 assert_eq!(
377 rust(Metric::FunctionCount, source),
378 3.0,
379 "a trait signature has no body and does not count"
380 );
381 }
382
383 #[test]
384 fn function_count_typescript() {
385 let source = "\
386function declared() {}
387const arrow = () => 1;
388class C {
389 method() {}
390}
391";
392 assert_eq!(typescript(Metric::FunctionCount, source), 3.0);
393 }
394
395 #[test]
396 fn excludes_cfg_test_lines_and_functions() {
397 let source = "\
398use std::fmt;
399
400fn prod_one() {}
401fn prod_two() {}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 fn helper() {}
408 fn t_one() {}
409 fn t_two() {}
410}
411";
412 assert_eq!(rust(Metric::FunctionCount, source), 5.0);
414 assert_eq!(rust(Metric::ImportCount, source), 2.0);
415 assert_eq!(rust(Metric::FileLines, source), 13.0);
416 assert_eq!(rust_no_tests(Metric::FunctionCount, source), 2.0);
419 assert_eq!(rust_no_tests(Metric::ImportCount, source), 1.0);
420 assert_eq!(rust_no_tests(Metric::FileLines, source), 5.0);
421 }
422
423 #[test]
424 fn excludes_cfg_test_comment_lines() {
425 let source = "\
426// prod comment
427fn prod() {}
428#[cfg(test)]
429mod tests {
430 // test comment
431 // another test comment
432 fn t() {}
433}
434";
435 assert_eq!(rust(Metric::CommentRatio, source), 3.0 / 8.0);
437 assert_eq!(rust_no_tests(Metric::CommentRatio, source), 1.0 / 2.0);
439 }
440
441 #[test]
442 fn no_cfg_test_leaves_metrics_unchanged() {
443 let source = "use std::fmt;\nfn a() {}\nfn b() {}\n";
444 assert_eq!(
445 rust_no_tests(Metric::FunctionCount, source),
446 rust(Metric::FunctionCount, source)
447 );
448 assert_eq!(
449 rust_no_tests(Metric::FileLines, source),
450 rust(Metric::FileLines, source)
451 );
452 }
453
454 #[test]
455 fn comment_ratio_line_comments() {
456 let source = "\
457// one
458// two
459fn a() {}
460// three
461fn b() {}
462// four
463fn c() {}
464fn d() {}
465";
466 assert_eq!(rust(Metric::CommentRatio, source), 0.5);
467 }
468
469 #[test]
470 fn comment_ratio_block_comment_spans_lines() {
471 let source = "\
472/* one
473 two
474 three
475 four
476 five */
477fn a() {}
478fn b() {}
479fn c() {}
480fn d() {}
481fn e() {}
482";
483 assert_eq!(
484 rust(Metric::CommentRatio, source),
485 0.5,
486 "a 5-line block comment contributes 5 comment lines out of 10"
487 );
488 }
489
490 #[test]
491 fn comment_ratio_ignores_doc_comments() {
492 let source = "\
494//! module doc
495/// outer doc for a
496/// still doc
497fn a() {}
498/** block doc */
499fn b() {}
500";
501 assert_eq!(
502 rust(Metric::CommentRatio, source),
503 0.0,
504 "a file with only doc comments has a zero narration ratio"
505 );
506 }
507
508 #[test]
509 fn comment_ratio_counts_narration_but_not_docs() {
510 let source = "\
513// narrate one
514// narrate two
515/// doc one
516/// doc two
517fn a() {}
518fn b() {}
519";
520 assert_eq!(rust(Metric::CommentRatio, source), 2.0 / 6.0);
521 }
522
523 #[test]
524 fn comment_ratio_typescript_ignores_jsdoc() {
525 let source = "\
526/** jsdoc line one
527 * jsdoc line two
528 */
529// real narration
530function f() {}
531";
532 assert_eq!(typescript(Metric::CommentRatio, source), 1.0 / 5.0);
534 }
535
536 #[test]
537 fn comment_ratio_empty_file_is_zero() {
538 assert_eq!(rust(Metric::CommentRatio, ""), 0.0);
539 }
540
541 #[test]
542 fn comment_ratio_no_comments_is_zero() {
543 let source = "fn a() {}\nfn b() {}\n";
544 assert_eq!(rust(Metric::CommentRatio, source), 0.0);
545 }
546
547 #[test]
548 fn format_value_and_describe() {
549 assert_eq!(Metric::FileLines.format_value(547.0), "547");
550 assert_eq!(Metric::FileLines.describe(547.0), "547 lines");
551 assert_eq!(Metric::CodeLines.describe(412.0), "412 lines of code");
552 assert_eq!(Metric::ImportCount.describe(42.0), "42 imports");
553 assert_eq!(Metric::FunctionCount.describe(31.0), "31 functions");
554 assert_eq!(Metric::CommentRatio.format_value(0.617), "0.62");
555 let ratio = Metric::CommentRatio.describe(0.617);
556 assert_eq!(ratio, "0.62 comment ratio");
557 }
558
559 #[test]
560 fn exceeds_is_strict() {
561 assert!(!exceeds(500.0, 500.0));
562 assert!(exceeds(501.0, 500.0));
563 assert!(!exceeds(499.0, 500.0));
564 }
565
566 #[test]
567 fn metric_display_is_snake_case() {
568 assert_eq!(Metric::FileLines.to_string(), "file_lines");
569 assert_eq!(Metric::CodeLines.to_string(), "code_lines");
570 assert_eq!(Metric::CommentRatio.to_string(), "comment_ratio");
571 }
572}