fixme
Version:
Scan for NOTE, OPTIMIZE, TODO, HACK, XXX, FIXME, and BUG comments within your source, and print them to stdout so you can deal with them.
424 lines (365 loc) • 12.7 kB
JavaScript
;
var byline = require("byline"),
chalk = require("chalk"),
eventStream = require("event-stream"),
fs = require("fs"),
isBinaryFile = require("isbinaryfile"),
minimatch = require("minimatch"),
readdirp = require("readdirp");
var ignoredDirectories = ["node_modules/**", ".git/**", ".hg/**"],
filesToScan = require("./fileTypes"),
scanPath = process.cwd(),
fileEncoding = "utf8",
lineLengthLimit = 1000,
skipChecks = [],
messageChecks = {
note: {
regex:
/(?:^|[^:])(\/\/|\{\{\!|\!|\{\#|\*)(\-\-)?\s*@?NOTE\b\s*(?:\(([^:]*)\))*\s*:?\s*(.*)/i,
label: " ✐ NOTE",
colorer: chalk.green,
},
optimize: {
regex:
/(?:^|[^:])(\/\/|\{\{\!|\!|\{\#|\*)(\-\-)?\s*@?OPTIMIZE\b\s*(?:\(([^:]*)\))*\s*:?\s*(.*)/i,
label: " ↻ OPTIMIZE",
colorer: chalk.blue,
},
todo: {
regex:
/(?:^|[^:])(\/\/|\{\{\!|\!|\{\#|\*)(\-\-)?\s*@?TODO\b\s*(?:\(([^:]*)\))*\s*:?\s*(.*)/i,
label: " ✓ TODO",
colorer: chalk.magenta,
},
hack: {
regex:
/(?:^|[^:])(\/\/|\{\{\!|\!|\{\#|\*)(\-\-)?\s*@?HACK\b\s*(?:\(([^:]*)\))*\s*:?\s*(.*)/i,
label: " ✄ HACK",
colorer: chalk.yellow,
},
xxx: {
regex:
/(?:^|[^:])(\/\/|\{\{\!|\!|\{\#|\*)(\-\-)?\s*@?XXX\b\s*(?:\(([^:]*)\))*\s*:?\s*(.*)/i,
label: " ✗ XXX",
colorer: chalk.black.bgYellow,
},
fixme: {
regex:
/(?:^|[^:])(\/\/|\{\{\!|\!|\{\#|\*)(\-\-)?\s*@?FIXME\b\s*(?:\(([^:]*)\))*\s*:?\s*(.*)/i,
label: " ☠ FIXME",
colorer: chalk.red,
},
bug: {
regex:
/(?:^|[^:])(\/\/|\{\{\!|\!|\{\#|\*)(\-\-)?\s*@?BUG\b\s*(?:\(([^:]*)\))*\s*:?\s*(.*)/i,
label: " ☢ BUG",
colorer: chalk.white.bgRed,
},
};
/**
* Determines whether or not to let the file through. by ensuring that the
* file name does not match one of the excluded directories, and ensuring it
* matches one of the file filters.
*
* It will also ensure that even if a binary file matches the filter patterns,
* it will not let it through as searching binary file contents for string
* matches will never make sense.
*
* @param {String} fileInformation
*
* @return {Boolean}
*/
// TODO: This could be simpler using minimatch negation patterns in one set, instead disparate ones for files and directories.
function fileFilterer(fileInformation) {
var shouldIgnoreDirectory = false,
shouldIgnoreFile = true,
letTheFileThrough;
ignoredDirectories.forEach(function (directoryPattern) {
if (shouldIgnoreDirectory) return;
shouldIgnoreDirectory = minimatch(fileInformation.path, directoryPattern, {
dot: true,
});
});
if (!shouldIgnoreDirectory) {
filesToScan.forEach(function (filePattern) {
if (!shouldIgnoreFile) return;
shouldIgnoreFile = !minimatch(fileInformation.path, filePattern);
});
}
letTheFileThrough = !(
shouldIgnoreDirectory ||
(!shouldIgnoreDirectory && shouldIgnoreFile)
);
// Never let binary files through, searching them for comments will make no sense...
if (
letTheFileThrough &&
isBinaryFile.isBinaryFileSync(fileInformation.fullPath)
) {
letTheFileThrough = false;
}
return letTheFileThrough;
}
/**
* Takes a line of a file and the line number, and returns an array of all of
* the messages found in that line. Can return multiple messages per line, for
* example, if a message was annotated with more than one type. EG: FIXME TODO
*
* Each message in the array will have a label, a line_number, a colorer, and a
* message. Will also include an author property if one is found on the
* message.
*
* @param {String} lineString The
* @param {Number} lineNumber
*
* @return {Array}
*/
function retrieveMessagesFromLine(lineString, lineNumber) {
var messageFormat = {
author: null,
message: null,
label: null,
colorer: null,
line_number: lineNumber,
},
messages = [];
lineString = removeCommentEnd(
lineString,
["-->", "#}}", "*/", "--}}", "}}", "#}"],
"",
);
Object.keys(messageChecks).forEach(function (checkName) {
var matchResults = lineString.match(messageChecks[checkName].regex),
checker = messageChecks[checkName],
thisMessage;
if (matchResults && matchResults.length) {
thisMessage = JSON.parse(JSON.stringify(messageFormat)); // Clone the above structure.
thisMessage.label = checker.label;
thisMessage.colorer = checker.colorer;
if (matchResults[3] && matchResults[3].length) {
thisMessage.author = matchResults[3].trim();
}
if (matchResults[4] && matchResults[4].length) {
thisMessage.message = matchResults[4].trim();
}
}
if (thisMessage) messages.push(thisMessage);
});
return messages;
}
/**
* Removes the end of html, twig and handlebar comments. EG: -->, --}}, etc.
*
* @param {String} str
* @param {Array} find
* @param {String} replace
*
* @return {String}
*/
function removeCommentEnd(str, find, replace) {
var replaceString = str;
for (var i = 0; i < find.length; i++) {
replaceString = replaceString.replace(find[i], replace);
}
return replaceString;
}
/**
* Takes a line number and returns a padded string matching the total number of
* characters in totalLinesNumber. EG: A lineNumber of 12 and a
* totalLinesNumber of 1323 will return the string ' 12'.
*
* @param {Number} lineNumber
* @param {Number} totalLinesNumber
*
* @return {String}
*/
function getPaddedLineNumber(lineNumber, totalLinesNumber) {
var paddedLineNumberString = "" + lineNumber;
while (paddedLineNumberString.length < ("" + totalLinesNumber).length) {
paddedLineNumberString = " " + paddedLineNumberString;
}
return paddedLineNumberString;
}
/**
* Takes an individual message object, as output from retrieveMessagesFromLine
* and formats it for output.
*
* @param {Object} individualMessage
* @property {String} individualMessage.author
* @property {String} individualMessage.message
* @property {String} individualMessage.label
* @property {Function} individualMessage.colorer
* @property {Number} individualMessage.line_number
* @param {Number} totalNumberOfLines
*
* @return {String} The formatted message string.
*/
function formatMessageOutput(individualMessage, totalNumberOfLines) {
var paddedLineNumber = getPaddedLineNumber(
individualMessage.line_number,
totalNumberOfLines,
),
finalLabelString,
finalNoteString;
finalNoteString = chalk.gray(" [Line " + paddedLineNumber + "] ");
finalLabelString = individualMessage.label;
if (individualMessage.author) {
finalLabelString += " from " + individualMessage.author + ": ";
} else {
finalLabelString += ": ";
}
finalLabelString = chalk.bold(individualMessage.colorer(finalLabelString));
finalNoteString += finalLabelString;
if (individualMessage.message && individualMessage.message.length) {
finalNoteString += individualMessage.colorer(individualMessage.message);
} else {
finalNoteString += chalk.grey("[[no message to display]]");
}
return finalNoteString;
}
/**
* Formatter function for the file name. Takes a file path, and the total
* number of messages in the file, and formats this information for display as
* the heading for the file messages.
*
* @param {String} filePath
* @param {Number} numberOfMessages
*
* @return {String}
*/
function formatFilePathOutput(filePath, numberOfMessages) {
var filePathOutput = chalk.bold.white("\n* " + filePath + " "),
messagesString = "messages";
if (numberOfMessages === 1) {
messagesString = "message";
}
filePathOutput += chalk.grey(
"[" + numberOfMessages + " " + messagesString + "]:",
);
return filePathOutput;
}
/**
* Takes an object representing the messages and other meta-info for the file
* and calls off to the formatters for the messages, as well as logs the
* formatted result.
*
* @param {Object} messagesInfo
* @property {String} messagesInfo.path The file path
* @property {Array} messagesInfo.messages All of the message objects for the file.
* @property {String} messagesInfo.total_lines Total number of lines in the file.
*/
function logMessages(messagesInfo) {
if (messagesInfo.messages.length) {
console.log(
formatFilePathOutput(messagesInfo.path, messagesInfo.messages.length),
);
messagesInfo.messages.forEach(function (message) {
var formattedMessage = formatMessageOutput(
message,
messagesInfo.total_lines,
);
console.log(formattedMessage);
});
}
}
/**
* Reads through the configured path scans the matching files for messages.
*/
function scanAndProcessMessages() {
var stream = readdirp(scanPath, {
fileFilter: fileFilterer,
});
// Remove skipped checks for our mapping
skipChecks.forEach(function (checkName) {
delete messageChecks[checkName];
});
// TODO: Actually do something meaningful/useful with these handlers.
stream.on("warn", console.warn).on("error", console.error);
stream.pipe(
eventStream.map(function (fileInformation, callback) {
var input = fs.createReadStream(fileInformation.fullPath, {
encoding: fileEncoding,
}),
// lineStream = byline.createStream(input, { encoding: fileEncoding }),
fileMessages = { path: null, total_lines: 0, messages: [] },
currentFileLineNumber = 1;
fileMessages.path = fileInformation.path;
input.pipe(eventStream.split()).pipe(
eventStream.map(function (fileLineString, cb) {
var messages, lengthError;
if (fileLineString.length < lineLengthLimit) {
messages = retrieveMessagesFromLine(
fileLineString,
currentFileLineNumber,
);
messages.forEach(function (message) {
fileMessages.messages.push(message);
});
} else if (skipChecks.indexOf("line") === -1) {
lengthError =
"Fixme is skipping this line because its length is " +
"greater than the maximum line-length of " +
lineLengthLimit +
".";
fileMessages.messages.push({
message: lengthError,
line_number: currentFileLineNumber,
label: " ⚠ SKIPPING CHECK",
colorer: chalk.underline.red,
});
}
currentFileLineNumber += 1;
}),
);
input.on("end", function () {
fileMessages.total_lines = currentFileLineNumber;
logMessages(fileMessages);
});
callback();
}),
);
}
/**
* Takes an options object and over-writes the defaults, then calls off to the
* scanner to scan the files for messages.
*
* @param {Object} options
* @property {String} options.path The base directory to recursively scan for messages. Defaults to process.cwd()
* @property {Array} options.ignored_directories An array of minimatch glob patterns for directories to ignore scanning entirely.
* @property {Array} options.file_patterns An array of minimatch glob patterns for files to scan for messages.
* @property {String} options.file_encoding The encoding the files scanned will be opened with, defaults to 'utf8'.
* @property {Number} options.line_length_limit The number of characters a line can be before it is ignored. Defaults to 1000.
* @property {Array} options.skip An array of names of checks to skip.
*/
// TODO(johnp): Allow custom messageChecks to be added via options.
function parseUserOptionsAndScan(options) {
if (options) {
if (options.path) {
scanPath = options.path;
}
if (
options.ignored_directories &&
Array.isArray(options.ignored_directories) &&
options.ignored_directories.length
) {
ignoredDirectories = options.ignored_directories;
}
if (
options.file_patterns &&
Array.isArray(options.file_patterns) &&
options.file_patterns.length
) {
filesToScan = options.file_patterns;
}
if (options.file_encoding) {
fileEncoding = options.file_encoding;
}
if (options.line_length_limit) {
lineLengthLimit = options.line_length_limit;
}
if (options.skip && Array.isArray(options.skip) && options.skip.length) {
skipChecks = options.skip;
}
}
scanAndProcessMessages();
}
module.exports = parseUserOptionsAndScan;