Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/io/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ function buildOptions(cli: CommanderStatic): Options {
target,
directory: parseDirectory(target),
format: cli.format ? (String(cli.format) as Format) : "table",
complexityStrategy: cli.complexityStrategy
? (String(cli.complexityStrategy) as Format)
: "sloc",
filter: cli.filter || [],
limit: cli.limit ? Number(cli.limit) : undefined,
since: cli.since ? String(cli.since) : undefined,
Expand Down
26 changes: 19 additions & 7 deletions src/lib/complexity.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
// FIXME: use something else than node-sloc, it's not widely used
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
import * as nodeSloc from "node-sloc";
import { resolve } from "path";

import { Options, Path } from "./types";
import { buildDebugger, withDuration } from "../utils";
import { calculate as calcCyclomaticComplexity } from "./complexity/cyclomatic-complexity";
import { calculate as calcHalsteadComplexity } from "./complexity/halstead";
import { calculate as calcSlocComplexity } from "./complexity/sloc";

type ComplexityEntry = { path: Path; complexity: number };
const internal = { debug: buildDebugger("complexity") };
Expand Down Expand Up @@ -41,6 +39,20 @@ async function compute(

async function getComplexity(path: Path, options: Options): Promise<number> {
const absolutePath = resolve(options.directory, path);
const result = await nodeSloc({ path: absolutePath });
return result.sloc.sloc || 1;

try {
switch (options.complexityStrategy) {
case "sloc":
return calcSlocComplexity(absolutePath);
case "cyclomatic":
return calcCyclomaticComplexity(absolutePath);
case "halstead":
return calcHalsteadComplexity(absolutePath);
default:
return calcSlocComplexity(absolutePath);
}
} catch (e) {
console.error(`${absolutePath} ${e?.toString()}`);
return 1;
}
}
72 changes: 72 additions & 0 deletions src/lib/complexity/cyclomatic-complexity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as fs from "fs";
import {
forEachChild,
SyntaxKind,
createSourceFile,
ScriptTarget,
} from "typescript";
import { isFunctionWithBody } from "tsutils";
import { getNodeName } from "../../utils";

const increasesComplexity = (node: any): boolean => {
switch (node.kind) {
case SyntaxKind.CaseClause:
return node.statements.length > 0;
case SyntaxKind.CatchClause:
case SyntaxKind.ConditionalExpression:
case SyntaxKind.DoStatement:
case SyntaxKind.ForStatement:
case SyntaxKind.ForInStatement:
case SyntaxKind.ForOfStatement:
case SyntaxKind.IfStatement:
case SyntaxKind.WhileStatement:
return true;

case SyntaxKind.BinaryExpression:
switch (node.operatorToken.kind) {
case SyntaxKind.BarBarToken:
case SyntaxKind.AmpersandAmpersandToken:
return true;
default:
return false;
}

default:
return false;
}
};

export function calculateFromSource(ctx: any): any {
let complexity = 0;
const output: any = {};
forEachChild(ctx, function cb(node) {
if (isFunctionWithBody(node)) {
const old = complexity;
complexity = 1;
forEachChild(node, cb);
const name = getNodeName(node);
output[name] = complexity;
complexity = old;
} else {
if (increasesComplexity(node)) {
complexity += 1;
}
forEachChild(node, cb);
}
});

return output;
}

export function calculate(path: string): any {
const contents = fs.readFileSync(path).toString("utf8");
const source = createSourceFile(path, contents, ScriptTarget.ES2015);
const result = calculateFromSource(source);
const counts: Array<number> = Object.values(result);
const complexity = counts?.reduce(
(accum: number, complexity: number): number => accum + complexity,
0
);

return complexity ?? 1;
}
108 changes: 108 additions & 0 deletions src/lib/complexity/halstead.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as fs from "fs";
import {
forEachChild,
SyntaxKind,
createSourceFile,
ScriptTarget,
} from "typescript";
import { isFunctionWithBody } from "tsutils";
import { getNodeName } from "../../utils";
import { HalsteadOutput } from "../types";

const isIdentifier = (kind: SyntaxKind): boolean =>
kind === SyntaxKind.Identifier;
const isLiteral = (kind: SyntaxKind): boolean =>
kind >= SyntaxKind.FirstLiteralToken && kind <= SyntaxKind.LastLiteralToken;

const isToken = (kind: SyntaxKind): boolean =>
kind >= SyntaxKind.FirstPunctuation && kind <= SyntaxKind.LastPunctuation;

const isKeyword = (kind: SyntaxKind): boolean =>
kind >= SyntaxKind.FirstKeyword && kind <= SyntaxKind.LastKeyword;

const isAnOperator = (node: any): boolean =>
isToken(node.kind) || isKeyword(node.kind);
const isAnOperand = (node: any): boolean =>
isIdentifier(node.kind) || isLiteral(node.kind);

const getOperatorsAndOperands = (node: any): any => {
const output: HalsteadOutput = {
operators: { total: 0, _unique: new Set([]), unique: 0 },
operands: { total: 0, _unique: new Set([]), unique: 0 },
};
forEachChild(node, function cb(currentNode: any) {
if (isAnOperand(currentNode)) {
output.operands.total++;
output.operands._unique.add(currentNode.text || currentNode.escapedText);
} else if (isAnOperator(currentNode)) {
output.operators.total++;
output.operators._unique.add(currentNode.text || currentNode.kind);
}
forEachChild(currentNode, cb);
});
output.operands.unique = output.operands._unique.size;
output.operators.unique = output.operators._unique.size;

return output;
};

const getHalstead = (node: any): any => {
if (isFunctionWithBody(node)) {
const { operands, operators } = getOperatorsAndOperands(node);
const length = operands.total + operators.total;
const vocabulary = operands.unique + operators.unique;

// If legnth is 0, all other values will be NaN
if (length === 0 || vocabulary === 1) return {};

const volume = length * Math.log2(vocabulary);
const difficulty =
(operators.unique / 2) * (operands.total / operands.unique);
const effort = volume * difficulty;
const time = effort / 18;
const bugsDelivered = effort ** (2 / 3) / 3000;

return {
length,
vocabulary,
volume,
difficulty,
effort,
time,
bugsDelivered,
operands,
operators,
};
}

return {};
};

// Returns the halstead volume for a function
// If passed node is not a function, returns empty object
const calculateFromSource = (ctx: any): any => {
const output: any = {};
forEachChild(ctx, function cb(node) {
if (isFunctionWithBody(node)) {
const name = getNodeName(node);
output[name] = getHalstead(node);
}
forEachChild(node, cb);
});

return output;
};

export function calculate(path: string): number {
const contents = fs.readFileSync(path).toString("utf8");
const source = createSourceFile(path, contents, ScriptTarget.ES2015);
const result = calculateFromSource(source);
const counts: Array<number> = Object.values(result);
const complexity = counts?.reduce(
(accum: number, stats: any): number =>
accum + Math.round(stats.volume ?? 1),
0
);

return complexity ?? 1;
}
27 changes: 27 additions & 0 deletions src/lib/complexity/sloc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as fs from "fs";

const getComments = (text: string): Array<string> => {
// Regex that matches all the strigns starting with //
const singleLineCommentsRegex = /\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm;
return text ? text.match(singleLineCommentsRegex) || [] : [];
};

const calculateFromSourceCode = (sourceText: string): number => {
const comments = getComments(sourceText);
for (let i = 0; i < comments.length; i++) {
const aMatched = comments[i];
sourceText = sourceText.replace(aMatched, "").trim();
}

return sourceText
.split("\n")
.map((aLine) => aLine.trim())
.filter((aLine) => !!aLine).length;
};

export function calculate(path: string): number {
const contents = fs.readFileSync(path).toString("utf8");
const result = calculateFromSourceCode(contents);

return result;
}
10 changes: 10 additions & 0 deletions src/lib/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,19 @@ export type Options = {
target: string | URL;
directory: string;
limit?: number;
complexityStrategy?: string;
since?: string;
until?: string;
sort?: Sort;
filter?: string[];
format?: Format;
};
export type HalsteadOps = {
total: number;
_unique: Set<number>;
unique: number;
};
export type HalsteadOutput = {
operators: HalsteadOps;
operands: HalsteadOps;
};
10 changes: 10 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import debug from "debug";
import { isIdentifier } from "typescript";
const pkg = import("../package.json");

export function withDuration(fn: Function, args: any[], log: Function): any {
Expand All @@ -24,3 +25,12 @@ export async function getPackageJson(): Promise<{
const { description, name, version } = pkg as any;
return { description, name, version };
}

export function getNodeName(node: any): string {
const { name, pos, end } = node;
const key =
name !== undefined && isIdentifier(name)
? name.text
: JSON.stringify({ pos, end });
return key;
}