// Copyright 2025 Chris Bunting
// Brief: Metric Calculator for Code Complexity Analyzer MCP Server
// Scope: Calculates various complexity metrics including cyclomatic, cognitive, and Halstead metrics
import { ASTNode } from './ASTProcessor.js';
import { Logger } from '../utils/Logger.js';
import { HalsteadMetrics } from '@mcp-code-analysis/shared-types';
export interface ComplexityMetrics {
cyclomaticComplexity: number;
cognitiveComplexity: number;
halsteadMetrics?: HalsteadMetrics;
linesOfCode: number;
commentLines: number;
maintainabilityIndex: number;
nestingDepth: number;
functionCount: number;
classCount: number;
averageFunctionSize: number;
coupling: number;
cohesion: number;
}
export interface MetricCalculationResult {
filePath: string;
metrics: ComplexityMetrics;
functionMetrics: Map<string, Partial<ComplexityMetrics>>;
classMetrics: Map<string, Partial<ComplexityMetrics>>;
calculationTime: number;
errors: string[];
}
export class MetricCalculator {
private logger: Logger;
constructor() {
this.logger = new Logger();
}
public async calculateMetrics(
ast: ASTNode,
filePath: string,
sourceCode?: string
): Promise<MetricCalculationResult> {
const startTime = Date.now();
const errors: string[] = [];
const functionMetrics = new Map<string, Partial<ComplexityMetrics>>();
const classMetrics = new Map<string, Partial<ComplexityMetrics>>();
try {
// Calculate basic metrics
const linesOfCode = this.calculateLinesOfCode(sourceCode || '');
const commentLines = this.calculateCommentLines(sourceCode || '');
// Calculate function and class counts
const functions = this.findFunctions(ast);
const classes = this.findClasses(ast);
const functionCount = functions.length;
const classCount = classes.length;
// Calculate average function size
const averageFunctionSize = functionCount > 0
? linesOfCode / functionCount
: 0;
// Calculate function-level metrics
for (const func of functions) {
const funcMetrics = this.calculateFunctionMetrics(func, sourceCode || '');
functionMetrics.set(func.name || `anonymous_${func.start.line}`, funcMetrics);
}
// Calculate class-level metrics
for (const cls of classes) {
const clsMetrics = this.calculateClassMetrics(cls, sourceCode || '');
classMetrics.set(cls.name || `anonymous_${cls.start.line}`, clsMetrics);
}
// Calculate overall metrics
const cyclomaticComplexity = this.calculateCyclomaticComplexity(ast);
const cognitiveComplexity = this.calculateCognitiveComplexity(ast);
const halsteadMetrics = this.calculateHalsteadMetrics(ast, sourceCode || '');
const maintainabilityIndex = this.calculateMaintainabilityIndex(
cyclomaticComplexity,
linesOfCode,
halsteadMetrics
);
const nestingDepth = this.calculateMaximumNestingDepth(ast);
const coupling = this.calculateCoupling(ast);
const cohesion = this.calculateCohesion(ast);
const metrics: ComplexityMetrics = {
cyclomaticComplexity,
cognitiveComplexity,
halsteadMetrics,
linesOfCode,
commentLines,
maintainabilityIndex,
nestingDepth,
functionCount,
classCount,
averageFunctionSize,
coupling,
cohesion
};
const calculationTime = Date.now() - startTime;
this.logger.info(`Successfully calculated metrics for: ${filePath}`, {
calculationTime,
cyclomaticComplexity,
cognitiveComplexity,
maintainabilityIndex
});
return {
filePath,
metrics,
functionMetrics,
classMetrics,
calculationTime,
errors
};
} catch (error) {
const calculationTime = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
errors.push(errorMessage);
this.logger.error(`Failed to calculate metrics for: ${filePath}`, { error: errorMessage });
return {
filePath,
metrics: this.getDefaultMetrics(),
functionMetrics,
classMetrics,
calculationTime,
errors
};
}
}
private calculateLinesOfCode(sourceCode: string): number {
const lines = sourceCode.split('\n');
return lines.filter(line => line.trim() !== '').length;
}
private calculateCommentLines(sourceCode: string): number {
const lines = sourceCode.split('\n');
let commentCount = 0;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('//') ||
trimmed.startsWith('#') ||
trimmed.startsWith('/*') ||
trimmed.startsWith('*') ||
trimmed.startsWith('"""') ||
trimmed.startsWith("'''")) {
commentCount++;
}
}
return commentCount;
}
private findFunctions(ast: ASTNode): ASTNode[] {
const functions: ASTNode[] = [];
this.traverseAST(ast, (node) => {
if (node.type === 'function' || node.type === 'method') {
functions.push(node);
}
});
return functions;
}
private findClasses(ast: ASTNode): ASTNode[] {
const classes: ASTNode[] = [];
this.traverseAST(ast, (node) => {
if (node.type === 'class') {
classes.push(node);
}
});
return classes;
}
private calculateFunctionMetrics(func: ASTNode, sourceCode: string): Partial<ComplexityMetrics> {
const cyclomaticComplexity = this.calculateCyclomaticComplexity(func);
const cognitiveComplexity = this.calculateCognitiveComplexity(func);
const nestingDepth = this.calculateMaximumNestingDepth(func);
// Calculate function size
const funcStartLine = func.start.line - 1;
const funcEndLine = func.end.line - 1;
const funcLines = sourceCode.split('\n').slice(funcStartLine, funcEndLine + 1);
const funcSize = funcLines.filter(line => line.trim() !== '').length;
return {
cyclomaticComplexity,
cognitiveComplexity,
nestingDepth,
linesOfCode: funcSize
};
}
private calculateClassMetrics(cls: ASTNode, sourceCode: string): Partial<ComplexityMetrics> {
const methods = this.findFunctions(cls);
const methodCount = methods.length;
// Calculate class size
const classStartLine = cls.start.line - 1;
const classEndLine = cls.end.line - 1;
const classLines = sourceCode.split('\n').slice(classStartLine, classEndLine + 1);
const classSize = classLines.filter(line => line.trim() !== '').length;
// Calculate average method size
const averageMethodSize = methodCount > 0 ? classSize / methodCount : 0;
return {
functionCount: methodCount,
averageFunctionSize: averageMethodSize,
linesOfCode: classSize
};
}
private calculateCyclomaticComplexity(ast: ASTNode): number {
let complexity = 1; // Base complexity
this.traverseAST(ast, (node) => {
// Add complexity for decision points
switch (node.type) {
case 'IfStatement':
case 'ConditionalExpression':
case 'SwitchStatement':
case 'ForStatement':
case 'ForInStatement':
case 'ForOfStatement':
case 'WhileStatement':
case 'DoWhileStatement':
case 'CatchClause':
complexity += 1;
break;
case 'LogicalExpression':
// Each && or || operator adds complexity
if (node.value && (node.value.includes('&&') || node.value.includes('||'))) {
const operators = (node.value.match(/&&/g) || []).length +
(node.value.match(/\|\|/g) || []).length;
complexity += operators;
}
break;
}
});
return complexity;
}
private calculateCognitiveComplexity(ast: ASTNode): number {
let complexity = 0;
let nestingLevel = 0;
this.traverseAST(ast, (node) => {
// Reset nesting level for top-level structures
if (node.type === 'function' || node.type === 'method' || node.type === 'class') {
nestingLevel = 0;
}
// Add complexity for cognitive load
switch (node.type) {
case 'IfStatement':
case 'ConditionalExpression':
complexity += 1 + nestingLevel;
nestingLevel += 1;
break;
case 'SwitchStatement':
complexity += 1;
nestingLevel += 1;
break;
case 'ForStatement':
case 'ForInStatement':
case 'ForOfStatement':
case 'WhileStatement':
case 'DoWhileStatement':
complexity += 1 + nestingLevel;
nestingLevel += 1;
break;
case 'CatchClause':
complexity += 1 + nestingLevel;
break;
case 'LogicalExpression':
// Each && or || operator adds cognitive complexity
if (node.value && (node.value.includes('&&') || node.value.includes('||'))) {
const operators = (node.value.match(/&&/g) || []).length +
(node.value.match(/\|\|/g) || []).length;
complexity += operators * (1 + nestingLevel);
}
break;
case 'BreakStatement':
case 'ContinueStatement':
complexity += 1 + nestingLevel;
break;
}
// Reduce nesting level when leaving blocks
if (node.body && Array.isArray(node.body)) {
// This will be handled by the traversal
}
});
return complexity;
}
private calculateHalsteadMetrics(_ast: ASTNode, sourceCode: string): HalsteadMetrics | undefined {
try {
// Extract operators and operands
const operators = this.extractOperators(sourceCode);
const operands = this.extractOperands(sourceCode);
// Calculate unique operators and operands
const uniqueOperators = new Set(operators);
const uniqueOperands = new Set(operands);
const n1 = uniqueOperators.size; // Unique operators
const n2 = uniqueOperands.size; // Unique operands
const N1 = operators.length; // Total operators
const N2 = operands.length; // Total operands
// Calculate Halstead metrics
const vocabulary = n1 + n2;
const length = N1 + N2;
const volume = length * Math.log2(vocabulary);
const difficulty = (n1 / 2) * (N2 / n2);
const effort = difficulty * volume;
const time = effort / 18; // Seconds
const bugs = volume / 3000; // Estimated bugs
return {
difficulty,
effort,
time,
bugs,
vocabulary,
length,
volume
};
} catch (error) {
this.logger.warn('Failed to calculate Halstead metrics', { error });
return undefined;
}
}
private extractOperators(sourceCode: string): string[] {
const operators: string[] = [];
// Common operators across languages
const operatorPatterns = [
/\+/g, /-/g, /\*/g, /\//g, /%/g, // Arithmetic
/==/g, /!=/g, /</g, />/g, /<=/g, />=/g, // Comparison
/&&/g, /\|\|/g, /!/g, // Logical
/&/g, /\|/g, /\^/g, /~/g, // Bitwise
/=/g, /\+=/g, /-=/g, /\*=/g, /\/=/g, /%=/g, // Assignment
/<<</g, />>>/g, /<</g, />>/g, // Shift
/\+\+/g, /--/g, // Increment/Decrement
/\->/g, /\./g, /::/g, // Access
];
for (const pattern of operatorPatterns) {
let match;
while ((match = pattern.exec(sourceCode)) !== null) {
operators.push(match[0]);
}
}
return operators;
}
private extractOperands(sourceCode: string): string[] {
const operands: string[] = [];
// Extract numbers
const numbers = sourceCode.match(/\b\d+\.?\d*\b/g) || [];
operands.push(...numbers);
// Extract identifiers (simplified)
const identifiers = sourceCode.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\b/g) || [];
// Filter out keywords
const keywords = new Set([
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default',
'break', 'continue', 'return', 'function', 'class', 'interface',
'public', 'private', 'protected', 'static', 'final', 'abstract',
'try', 'catch', 'finally', 'throw', 'new', 'this', 'super',
'import', 'export', 'from', 'as', 'const', 'let', 'var',
'true', 'false', 'null', 'undefined', 'void', 'typeof',
'instanceof', 'in', 'delete', 'with', 'yield', 'await'
]);
const filteredIdentifiers = identifiers.filter(id => !keywords.has(id));
operands.push(...filteredIdentifiers);
return operands;
}
private calculateMaintainabilityIndex(
cyclomaticComplexity: number,
linesOfCode: number,
halsteadMetrics?: HalsteadMetrics
): number {
// Maintainability Index calculation
let volume = 0;
if (halsteadMetrics) {
volume = halsteadMetrics.volume;
}
// MI = 171 - 5.2 * ln(avgVol) - 0.23 * avgCyclomatic - 16.2 * ln(avgLOC)
// Simplified version for single file
const avgVol = volume;
const avgCyclomatic = cyclomaticComplexity;
const avgLOC = linesOfCode;
let mi = 171;
if (avgVol > 0) {
mi -= 5.2 * Math.log(avgVol);
}
mi -= 0.23 * avgCyclomatic;
if (avgLOC > 0) {
mi -= 16.2 * Math.log(avgLOC);
}
// Normalize to 0-100 scale
mi = Math.max(0, Math.min(100, mi));
return mi;
}
private calculateMaximumNestingDepth(ast: ASTNode): number {
let maxDepth = 0;
let currentDepth = 0;
this.traverseAST(ast, (node) => {
// Reset depth for top-level structures
if (node.type === 'function' || node.type === 'method' || node.type === 'class') {
currentDepth = 0;
}
// Increase depth for nested structures
switch (node.type) {
case 'IfStatement':
case 'SwitchStatement':
case 'ForStatement':
case 'ForInStatement':
case 'ForOfStatement':
case 'WhileStatement':
case 'DoWhileStatement':
case 'TryStatement':
case 'CatchClause':
currentDepth += 1;
maxDepth = Math.max(maxDepth, currentDepth);
break;
}
});
return maxDepth;
}
private calculateCoupling(ast: ASTNode): number {
let coupling = 0;
const dependencies = new Set<string>();
this.traverseAST(ast, (node) => {
// Look for external dependencies (simplified)
if (node.type === 'statement' && node.value) {
// Check for method calls, property access, etc.
const methodCalls = node.value.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*\b/g);
if (methodCalls) {
methodCalls.forEach((call: string) => {
const objName = call.split('.')[0];
if (!this.isLocalVariable(objName)) {
dependencies.add(objName);
}
});
}
}
});
coupling = dependencies.size;
return coupling;
}
private calculateCohesion(ast: ASTNode): number {
// Simplified cohesion calculation
// LCOM (Lack of Cohesion of Methods) metric
const classes = this.findClasses(ast);
let totalCohesion = 0;
let classCount = 0;
for (const cls of classes) {
const methods = this.findFunctions(cls);
if (methods.length < 2) {
continue;
}
// Calculate method similarity (simplified)
let similarPairs = 0;
let totalPairs = 0;
for (let i = 0; i < methods.length; i++) {
for (let j = i + 1; j < methods.length; j++) {
totalPairs++;
// Simple similarity based on shared variables
const similarity = this.calculateMethodSimilarity(methods[i], methods[j]);
if (similarity > 0.5) {
similarPairs++;
}
}
}
const cohesion = totalPairs > 0 ? similarPairs / totalPairs : 0;
totalCohesion += cohesion;
classCount++;
}
return classCount > 0 ? totalCohesion / classCount : 0;
}
private calculateMethodSimilarity(method1: ASTNode, method2: ASTNode): number {
// Simplified method similarity calculation
const vars1 = this.extractVariables(method1);
const vars2 = this.extractVariables(method2);
const intersection = new Set([...vars1].filter(x => vars2.has(x)));
const union = new Set([...vars1, ...vars2]);
return union.size > 0 ? intersection.size / union.size : 0;
}
private extractVariables(node: ASTNode): Set<string> {
const variables = new Set<string>();
this.traverseAST(node, (n) => {
if (n.type === 'statement' && n.value) {
// Extract variable declarations and usage
const varDeclarations = n.value.match(/\b(?:var|let|const)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/g);
if (varDeclarations) {
varDeclarations.forEach((decl: string) => {
const match = decl.match(/\b(?:var|let|const)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/);
if (match) {
variables.add(match[1]);
}
});
}
// Extract variable usage
const varUsage = n.value.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\b/g);
if (varUsage) {
varUsage.forEach((variable: string) => {
if (!this.isKeyword(variable)) {
variables.add(variable);
}
});
}
}
});
return variables;
}
private isLocalVariable(name: string): boolean {
// Simple heuristic to determine if a variable is local
const commonLocalVars = new Set([
'i', 'j', 'k', 'x', 'y', 'z', 'temp', 'result', 'count', 'index',
'sum', 'total', 'avg', 'max', 'min', 'flag', 'found', 'data'
]);
return commonLocalVars.has(name.toLowerCase());
}
private isKeyword(word: string): boolean {
const keywords = new Set([
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default',
'break', 'continue', 'return', 'function', 'class', 'interface',
'public', 'private', 'protected', 'static', 'final', 'abstract',
'try', 'catch', 'finally', 'throw', 'new', 'this', 'super',
'import', 'export', 'from', 'as', 'const', 'let', 'var',
'true', 'false', 'null', 'undefined', 'void', 'typeof',
'instanceof', 'in', 'delete', 'with', 'yield', 'await',
'int', 'string', 'boolean', 'float', 'double', 'char', 'byte'
]);
return keywords.has(word);
}
private traverseAST(ast: ASTNode, visitor: (node: ASTNode) => void): void {
visitor(ast);
if (ast.body) {
for (const child of ast.body) {
this.traverseAST(child, visitor);
}
}
if (ast.children) {
for (const child of ast.children) {
this.traverseAST(child, visitor);
}
}
}
private getDefaultMetrics(): ComplexityMetrics {
return {
cyclomaticComplexity: 1,
cognitiveComplexity: 0,
linesOfCode: 0,
commentLines: 0,
maintainabilityIndex: 100,
nestingDepth: 0,
functionCount: 0,
classCount: 0,
averageFunctionSize: 0,
coupling: 0,
cohesion: 0
};
}
}