const fs = require('fs');
const { spawn } = require('child_process');
const os = require('os');
const escomplex = require('typhonjs-escomplex');
const {isText} = require('istextorbinary');
const mkLogger = require('./log.js');
const logger = mkLogger({label: __filename});
/**
* A namespace containing analyser functions.
* @namespace
*/
const analyse = {
/**
* Summary for a generic file extension
* @typedef {object} GenericAnalysisReport
* @property {number} numberOfFiles - The number of files with the extension
* @property {number} numberOfLines
* The total number of physical lines with the extension
*/
/**
* Analyses a generic text files.
* @param {Array<string>} paths - An array of absolute file paths
*
* @return {GenericAnalysisReport} A report of all analyses performed on the given extension
*/
generic: function(paths) {
const readFile = path => fs.readFileSync(path, 'utf8');
const countLines = contents => contents.split('\n').length;
const getLinesFromFile = path => {
let contents = readFile(path);
if (isText(path, contents)) {
return countLines(contents);
} else {
return 0;
}
};
const totalLines = paths
.map(getLinesFromFile)
.reduce((a, b) => a + b, 0);
return Promise.resolve({
numberOfFiles: paths.length,
numberOfLines: totalLines
});
},
/**
* Static Analysis report for Javascript code
* @typedef {object} JsAnalysisReport
* @property {number} numberOfFiles
* @property {number} numberOfLines
* @property {number} numberOfLogicalLines
* @property {number} numberOfComments
* @property {number} cyclomaticComplexity
* @property {number} maintabilityIndex
* @property {number} halsteadEffort
* @property {number} halsteadBugs
* @property {number} halsteadLength
* @property {number} halsteadDifficulty
* @property {number} halsteadTime
* @property {number} halsteadVocabulary
* @property {number} halsteadVolume
*/
/**
* Performs static code analysis on a set of javascript files
* @param {Array<string>} paths - An array of absolute file paths
*
* @return {JsAnalysisReport} A report of static analysis performed on javascript code
*/
javascript: function(paths) {
return new Promise((resolve, reject) => {
let source = [];
let totalComments = 0;
for (let path of paths) {
let code = fs.readFileSync(path, 'utf8');
// Add file and contents to source object for later digestion by escomplex
source.push({
code: code,
srcPath: path,
filePath: path
});
// Count comments in each file
let commentPattern = /(?:(?<!\\)("|'|`)[\s\S]*?(?<!\\)\1)|(?:\/(?!\*)[^\r\n\f]+(?<!\\)\/)|(\/\*[\s\S]*?\*\/)|(\/\/[^\r\n\f]*)/g;
let commentLines = 0;
let match;
while(match = commentPattern.exec(code)) {
// Group 2 = block comments
if (match[2]) {
commentLines += match[2].split('\n').length;
}
// Group 3 = line comments
if (match[3]) {
commentLines++;
}
}
totalComments += commentLines;
}
let escomplexReport;
try {
escomplexReport = escomplex.analyzeProject(source, {});
} catch (e) {
// Parse failure, skip and return basic analysis
logger.warn(e);
resolve(analyse.generic(paths));
}
// Total physical lines of code
let sloc = 0;
// Total logical lines of code
let lsloc = 0;
// Non unique dependencies
let dependencies = 0;
for (let report of escomplexReport.modules) {
sloc += report.aggregate.sloc.physical;
lsloc += report.aggregate.sloc.logical;
dependencies += report.dependencies.length;
}
let finalReport = {
numberOfFiles: paths.length,
numberOfLines: sloc,
numberOfLogicalLines: lsloc,
numberOfComments: totalComments,
cyclomaticComplexity: escomplexReport.moduleAverage.methodAverage.cyclomatic,
maintainabilityIndex: escomplexReport.moduleAverage.maintainability,
changeCost: escomplexReport.changeCost,
avgDependencies: dependencies / escomplexReport.modules.length,
halsteadEffort: escomplexReport.moduleAverage.methodAverage.halstead.effort,
halsteadBugs: escomplexReport.moduleAverage.methodAverage.halstead.bugs,
halsteadLength: escomplexReport.moduleAverage.methodAverage.halstead.length,
halsteadDifficulty: escomplexReport.moduleAverage.methodAverage.halstead.difficulty,
halsteadTime: escomplexReport.moduleAverage.methodAverage.halstead.time,
halsteadVocabulary: escomplexReport.moduleAverage.methodAverage.halstead.vocabulary,
halsteadVolume: escomplexReport.moduleAverage.methodAverage.halstead.volume
};
resolve(finalReport);
});
},
/**
* Static Analysis report for Python code
* @typedef {object} PyAnalysisReport
* @property {number} numberOfFiles
* @property {number} numberOfLines
* @property {number} numberOfLogicalLines
* @property {number} numberOfComments
* @property {number} cyclomaticComplexity
* @property {number} maintabilityIndex
* @property {number} halsteadEffort
* @property {number} halsteadBugs
* @property {number} halsteadLength
* @property {number} halsteadDifficulty
* @property {number} halsteadTime
* @property {number} halsteadVocabulary
* @property {number} halsteadVolume
*/
/**
* Performs static code analysis on a set of javascript files
* @param {Array<string>} paths - An array of absolute file paths
*
* @return {PyAnalysisReport} A report of static analysis performed on javascript code
*/
python: function(paths) {
return new Promise((resolve, reject) => {
try {
// By default macOS `python` is v2.*
// call user aliased `python3`
const pythonExe = os.platform() === 'darwin' ? 'python3' : 'python';
const scriptName = 'external/analyse.py';
// spawn separate process to analyse python
const program = spawn(pythonExe, [scriptName, ...paths]);
// store partialy complete JSON strings
let buffer = '';
// on every output, add chunk to buffer and try to parse buffer
// if JSON incomplete, wait for nexxt chunk
// otherwise resolve buffer
program.stdout.on('data', chunk => {
buffer += chunk.toString('utf-8');
try {
const parsed = JSON.parse(buffer);
resolve(parsed);
} catch (err) {
if (!(err instanceof SyntaxError)) {
throw err;
}
}
});
// on error, reject
program.stderr.on('data', bytes => {
reject(bytes.toString('utf-8'));
});
} catch (e) {
logger.warn("Python analysis failed. Falling back to default. Details:");
logger.warn(e);
resolve(analyse.generic(paths));
}
});
}
};
module.exports = analyse;