srcmetrics 0.2.0

Language-independent source code metrics (size, complexity, nesting, Halstead, duplication, dependencies, documentation) for C, C++, Go, Java, JavaScript, Python, Rust and TypeScript
Documentation
//! Size Metrics.
//!
//! Size metrics are textual: at function scope they include the text of nested functions.

use super::common::{LineClass, is_statement, line_classes};
use super::{Calculator, FileMetrics, MetricSpec, MetricValue, Metrics, ProgramMetrics, Scope::*};
use crate::ir::{File, Function, Program, TokenKind};

pub struct SizeCalculator;

static SPECS: &[MetricSpec] = &[
    MetricSpec {
        id: "size.loc",
        scopes: &[File, Project],
    },
    MetricSpec {
        id: "size.sloc",
        scopes: &[Function, File, Project],
    },
    MetricSpec {
        id: "size.comment_loc",
        scopes: &[File, Project],
    },
    MetricSpec {
        id: "size.blank_loc",
        scopes: &[File, Project],
    },
    MetricSpec {
        id: "size.comment_ratio",
        scopes: &[File, Project],
    },
    MetricSpec {
        id: "size.statement_count",
        scopes: &[Function, File, Project],
    },
    MetricSpec {
        id: "size.token_count",
        scopes: &[Function, File, Project],
    },
    MetricSpec {
        id: "size.function_count",
        scopes: &[File, Project],
    },
    MetricSpec {
        id: "size.function_length",
        scopes: &[Function],
    },
    MetricSpec {
        id: "size.avg_function_length",
        scopes: &[File, Project],
    },
    MetricSpec {
        id: "size.max_function_length",
        scopes: &[File, Project],
    },
];

impl Calculator for SizeCalculator {
    fn specs(&self) -> &'static [MetricSpec] {
        SPECS
    }

    fn compute(&self, program: &Program) -> ProgramMetrics {
        let classified: Vec<Classified> = program.files.iter().map(|f| (f, classify(f))).collect();
        let files = classified
            .iter()
            .map(|(file, lines)| FileMetrics {
                metrics: totals(&[(file, lines.clone())]),
                functions: file
                    .functions
                    .iter()
                    .map(|f| function_metrics(file, lines, f))
                    .collect(),
            })
            .collect();
        ProgramMetrics {
            project: totals(&classified),
            files,
        }
    }
}

/// A file with its line classes, or the reason they are unavailable.
type Classified<'a> = (&'a File, Result<Vec<LineClass>, String>);

fn classify(file: &File) -> Result<Vec<LineClass>, String> {
    line_classes(file).map_err(|e| format!("{}: {e}", file.path))
}

fn count(lines: &[LineClass], class: LineClass) -> usize {
    lines.iter().filter(|c| **c == class).count()
}

/// Line metrics summed over files; an error in any file makes them all errors.
fn line_metrics(files: &[Classified]) -> Metrics {
    let ids = [
        "size.loc",
        "size.sloc",
        "size.comment_loc",
        "size.blank_loc",
    ];
    let mut sums = [0usize; 4];
    for (_, lines) in files {
        match lines {
            Ok(lines) => {
                let counts = [
                    lines.len(),
                    count(lines, LineClass::Code),
                    count(lines, LineClass::Comment),
                    count(lines, LineClass::Blank),
                ];
                sums.iter_mut().zip(counts).for_each(|(s, c)| *s += c);
            }
            Err(e) => {
                let error = MetricValue::Error(e.clone());
                return ids
                    .iter()
                    .chain(["size.comment_ratio"].iter())
                    .map(|id| (*id, error.clone()))
                    .collect();
            }
        }
    }
    let mut m: Metrics = ids
        .iter()
        .zip(sums)
        .map(|(id, v)| (*id, v.into()))
        .collect();
    m.insert(
        "size.comment_ratio",
        MetricValue::ratio(sums[2] as f64, sums[0] as f64),
    );
    m
}

/// File or project totals: sums over `files`, with ratios and function statistics recomputed.
fn totals(files: &[Classified]) -> Metrics {
    let mut m = line_metrics(files);
    let statements: usize = files
        .iter()
        .map(|(f, _)| f.nodes.iter().filter(|n| is_statement(n.kind)).count())
        .sum();
    let tokens: usize = files.iter().map(|(f, _)| code_tokens(&f.tokens)).sum();
    let lengths: Vec<usize> = files
        .iter()
        .flat_map(|(f, _)| &f.functions)
        .map(|f| f.range.line_count())
        .collect();
    m.insert("size.statement_count", statements.into());
    m.insert("size.token_count", tokens.into());
    m.insert("size.function_count", lengths.len().into());
    m.insert("size.avg_function_length", MetricValue::mean(&lengths));
    m.insert("size.max_function_length", MetricValue::max(&lengths));
    m
}

fn function_metrics(
    file: &File,
    lines: &Result<Vec<LineClass>, String>,
    function: &Function,
) -> Metrics {
    let statements = file
        .descendants_pruned(function.node, |_| false)
        .filter(|n| is_statement(n.kind))
        .count();
    let r = function.range;
    let sloc = match lines {
        Ok(lines) => count(&lines[r.first_line() - 1..r.last_line()], LineClass::Code).into(),
        Err(e) => MetricValue::Error(e.clone()),
    };
    Metrics::from([
        ("size.function_length", r.line_count().into()),
        ("size.sloc", sloc),
        ("size.statement_count", statements.into()),
        ("size.token_count", code_tokens(file.tokens_in(r)).into()),
    ])
}

fn code_tokens(tokens: &[crate::ir::Token]) -> usize {
    tokens
        .iter()
        .filter(|t| t.kind != TokenKind::Comment)
        .count()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ir::builder::*;
    use crate::ir::{NodeKind, Program, TokenKind::*};
    use crate::metrics::{Calculator, MetricValue, ProgramMetrics};

    /// ```text
    /// 1 int x; // c
    /// 2
    /// 3 /* a
    /// 4    b */
    /// 5 void f() {
    /// 6   g();
    /// 7 }
    /// ```
    fn sample() -> Program {
        let source = "int x; // c\n\n/* a\n   b */\nvoid f() {\n  g();\n}\n";
        let mut b = FileBuilder::new(source);
        let root = b.root();
        b.node(root, NodeKind::Declaration);
        let body = b.function(root, "f", 0, lines(5, 7));
        b.node(body, NodeKind::Statement);
        b.token(Keyword, "int", 1)
            .token(Identifier, "x", 1)
            .token(Punctuation, ";", 1)
            .token(Comment, "// c", 1);
        b.token_span(Comment, "/* a b */", 3, 4);
        b.token(Keyword, "void", 5)
            .token(Identifier, "f", 5)
            .token(Punctuation, "(", 5);
        b.token(Punctuation, ")", 5).token(Punctuation, "{", 5);
        b.token(Identifier, "g", 6)
            .token(Punctuation, "(", 6)
            .token(Punctuation, ")", 6);
        b.token(Punctuation, ";", 6);
        b.token(Punctuation, "}", 7);
        Program {
            files: vec![b.build()],
        }
    }

    fn run(program: &Program) -> ProgramMetrics {
        SizeCalculator.compute(program)
    }

    fn v(x: f64) -> MetricValue {
        MetricValue::Available(x)
    }

    #[test]
    fn file_line_counts() {
        let m = &run(&sample()).files[0].metrics;
        assert_eq!(m["size.loc"], v(7.0));
        assert_eq!(m["size.sloc"], v(4.0));
        assert_eq!(m["size.comment_loc"], v(2.0));
        assert_eq!(m["size.blank_loc"], v(1.0));
        assert_eq!(m["size.comment_ratio"], v(2.0 / 7.0));
    }

    #[test]
    fn file_structure_counts() {
        let m = &run(&sample()).files[0].metrics;
        assert_eq!(m["size.statement_count"], v(2.0));
        assert_eq!(m["size.token_count"], v(13.0));
        assert_eq!(m["size.function_count"], v(1.0));
        assert_eq!(m["size.avg_function_length"], v(3.0));
        assert_eq!(m["size.max_function_length"], v(3.0));
    }

    #[test]
    fn function_counts() {
        let m = &run(&sample()).files[0].functions[0];
        assert_eq!(m["size.function_length"], v(3.0));
        assert_eq!(m["size.sloc"], v(3.0));
        assert_eq!(m["size.statement_count"], v(1.0));
        assert_eq!(m["size.token_count"], v(10.0));
    }

    #[test]
    fn project_sums_files_and_recomputes_ratios() {
        let mut program = sample();
        program.files.push(program.files[0].clone());
        let m = run(&program).project;
        assert_eq!(m["size.loc"], v(14.0));
        assert_eq!(m["size.comment_ratio"], v(4.0 / 14.0));
        assert_eq!(m["size.function_count"], v(2.0));
        assert_eq!(m["size.max_function_length"], v(3.0));
    }

    #[test]
    fn empty_file_has_no_ratio_and_no_function_averages() {
        let program = Program {
            files: vec![FileBuilder::new("").build()],
        };
        let m = &run(&program).files[0].metrics;
        assert_eq!(m["size.loc"], v(0.0));
        assert_eq!(m["size.comment_ratio"], MetricValue::NotApplicable);
        assert_eq!(m["size.avg_function_length"], MetricValue::NotApplicable);
        assert_eq!(m["size.max_function_length"], MetricValue::NotApplicable);
    }

    #[test]
    fn text_not_covered_by_tokens_is_an_error_not_a_guess() {
        let program = Program {
            files: vec![FileBuilder::new("???\n").build()],
        };
        let m = &run(&program).files[0].metrics;
        assert!(
            matches!(m["size.sloc"], MetricValue::Error(_)),
            "{:?}",
            m["size.sloc"]
        );
    }
}