blob: ed94b08045da484ceca6cffd671429a822c0cc37 [file] [log] [blame]
"use strict";
var _ = require("underscore");
var fs = require("fs");
var cli = require("cli");
var path = require("path");
var shjs = require("shelljs");
var minimatch = require("minimatch");
var htmlparser = require("htmlparser2");
var exit = require("exit");
var stripJsonComments = require("strip-json-comments");
var JSHINT = require("./jshint.js").JSHINT;
var defReporter = require("./reporters/default").reporter;
var OPTIONS = {
"config": ["c", "Custom configuration file", "string", false ],
"reporter": ["reporter", "Custom reporter (<PATH>|jslint|checkstyle)", "string", undefined ],
"exclude": ["exclude",
"Exclude files matching the given filename pattern (same as .jshintignore)", "string", null],
"exclude-path": ["exclude-path", "Pass in a custom jshintignore file path", "string", null],
"filename": ["filename",
"Pass in a filename when using STDIN to emulate config lookup for that file name",
"string", null],
"verbose": ["verbose", "Show message codes"],
"show-non-errors": ["show-non-errors", "Show additional data generated by jshint"],
"extra-ext": ["e",
"Comma-separated list of file extensions to use (default is .js)", "string", ""],
"extract": [
"extract",
"Extract inline scripts contained in HTML (auto|always|never, default to never)",
"string",
"never"
],
// Deprecated options.
"jslint-reporter": [
"jslint-reporter",
deprecated("Use a jslint compatible reporter", "--reporter=jslint")
],
"checkstyle-reporter": [
"checkstyle-reporter",
deprecated("Use a CheckStyle compatible XML reporter", "--reporter=checkstyle")
]
};
/**
* Returns the same text but with a deprecation notice.
* Useful for options descriptions.
*
* @param {string} text
* @param {string} alt (optional) Alternative command to include in the
* deprecation notice.
*
* @returns {string}
*/
function deprecated(text, alt) {
if (!alt) {
return text + " (DEPRECATED)";
}
return text + " (DEPRECATED, use " + alt + " instead)";
}
/**
* Tries to find a configuration file in either project directory
* or in the home directory. Configuration files are named
* '.jshintrc'.
*
* @param {string} file path to the file to be linted
* @returns {string} a path to the config file
*/
function findConfig(file) {
var dir = path.dirname(path.resolve(file));
var envs = getHomeDir();
if (!envs)
return home;
var home = path.normalize(path.join(envs, ".jshintrc"));
var proj = findFile(".jshintrc", dir);
if (proj)
return proj;
if (shjs.test("-e", home))
return home;
return null;
}
function getHomeDir() {
var homePath = "";
var environment = global.process.env;
var paths = [
environment.USERPROFILE,
environment.HOME,
environment.HOMEPATH,
environment.HOMEDRIVE + environment.HOMEPATH
];
while (paths.length) {
homePath = paths.shift();
if (fs.existsSync(homePath)) {
return homePath;
}
}
}
/**
* Tries to find JSHint configuration within a package.json file
* (if any). It search in the current directory and then goes up
* all the way to the root just like findFile.
*
* @param {string} file path to the file to be linted
* @returns {object} config object
*/
function loadNpmConfig(file) {
var dir = path.dirname(path.resolve(file));
var fp = findFile("package.json", dir);
if (!fp)
return null;
try {
return require(fp).jshintConfig;
} catch (e) {
return null;
}
}
/**
* Tries to import a reporter file and returns its reference.
*
* @param {string} fp a path to the reporter file
* @returns {object} imported module for the reporter or 'null'
* if a module cannot be imported.
*/
function loadReporter(fp) {
try {
return require(fp).reporter;
} catch (err) {
return null;
}
}
// Storage for memoized results from find file
// Should prevent lots of directory traversal &
// lookups when liniting an entire project
var findFileResults = {};
/**
* Searches for a file with a specified name starting with
* 'dir' and going all the way up either until it finds the file
* or hits the root.
*
* @param {string} name filename to search for (e.g. .jshintrc)
* @param {string} dir directory to start search from (default:
* current working directory)
*
* @returns {string} normalized filename
*/
function findFile(name, cwd) {
cwd = cwd || process.cwd();
var filename = path.normalize(path.join(cwd, name));
if (findFileResults[filename] !== undefined) {
return findFileResults[filename];
}
var parent = path.resolve(cwd, "../");
if (shjs.test("-e", filename)) {
findFileResults[filename] = filename;
return filename;
}
if (cwd === parent) {
findFileResults[filename] = null;
return null;
}
return findFile(name, parent);
}
/**
* Loads a list of files that have to be skipped. JSHint assumes that
* the list is located in a file called '.jshintignore'.
*
* @return {array} a list of files to ignore.
*/
function loadIgnores(params) {
var file = findFile(params.excludePath || ".jshintignore", params.cwd);
if (!file && !params.exclude) {
return [];
}
var lines = (file ? shjs.cat(file) : "").split("\n");
lines.unshift(params.exclude || "");
return lines
.filter(function (line) {
return !!line.trim();
})
.map(function (line) {
if (line[0] === "!")
return "!" + path.resolve(path.dirname(file), line.substr(1).trim());
return path.join(path.dirname(file), line.trim());
});
}
/**
* Checks whether we should ignore a file or not.
*
* @param {string} fp a path to a file
* @param {array} patterns a list of patterns for files to ignore
*
* @return {boolean} 'true' if file should be ignored, 'false' otherwise.
*/
function isIgnored(fp, patterns) {
return patterns.some(function (ip) {
if (minimatch(path.resolve(fp), ip, { nocase: true })) {
return true;
}
if (path.resolve(fp) === ip) {
return true;
}
if (shjs.test("-d", fp) && ip.match(/^[^\/]*\/?$/) &&
fp.match(new RegExp("^" + ip + ".*"))) {
return true;
}
});
}
/**
* Extract JS code from a given source code. The source code my be either HTML
* code or JS code. In the latter case, no extraction will be done unless
* 'always' is given.
*
* @param {string} code a piece of code
* @param {string} when 'always' will extract the JS code, no matter what.
* 'never' won't do anything. 'auto' will check if the code looks like HTML
* before extracting it.
*
* @return {string} the extracted code
*/
function extract(code, when) {
// A JS file won't start with a less-than character, whereas a HTML file
// should always start with that.
if (when !== "always" && (when !== "auto" || !/^\s*</.test(code)))
return code;
var inscript = false;
var index = 0;
var js = [];
var startOffset;
// Test if current tag is a valid <script> tag.
function onopen(name, attrs) {
if (name !== "script")
return;
if (attrs.type && !/text\/javascript/.test(attrs.type.toLowerCase()))
return;
// Mark that we're inside a <script> a tag and push all new lines
// in between the last </script> tag and this <script> tag to preserve
// location information.
inscript = true;
js.push.apply(js, code.slice(index, parser.endIndex).match(/\n\r|\n|\r/g));
startOffset = null;
}
function onclose(name) {
if (name !== "script" || !inscript)
return;
inscript = false;
index = parser.startIndex;
startOffset = null;
}
function ontext(data) {
if (!inscript)
return;
var lines = data.split(/\n\r|\n|\r/);
if (!startOffset) {
lines.some(function (line) {
if (!line) return;
startOffset = /^(\s*)/.exec(line)[1];
return true;
});
}
// check for startOffset again to remove leading white space from first line
if (startOffset) {
lines = lines.map(function (line) {
return line.replace(startOffset, "");
});
data = lines.join("\n");
}
js.push(data); // Collect JavaScript code.
}
var parser = new htmlparser.Parser({ onopentag: onopen, onclosetag: onclose, ontext: ontext });
parser.parseComplete(code);
return js.join("");
}
/**
* Crude version of source maps: extract how much JavaSscript in HTML
* was shifted based on first JS line. For example if first js line
* is offset by 4 spaces, each line in this js fragment will have offset 4
* to restore the original column.
*
* @param {string} code a piece of code
* @param {string} when 'always' will extract the JS code, no matter what.
* 'never' won't do anything. 'auto' will check if the code looks like HTML
* before extracting it.
*
* @return {Array} extracted offsets
*/
function extractOffsets(code, when) {
// A JS file won't start with a less-than character, whereas a HTML file
// should always start with that.
if (when !== "always" && (when !== "auto" || !/^\s*</.test(code)))
return;
var inscript = false;
var index = 0;
var lineCounter = 0;
var startOffset;
var offsets = [];
// Test if current tag is a valid <script> tag.
function onopen(name, attrs) {
if (name !== "script")
return;
if (attrs.type && !/text\/javascript/.test(attrs.type.toLowerCase()))
return;
// Mark that we're inside a <script> a tag and push all new lines
// in between the last </script> tag and this <script> tag to preserve
// location information.
inscript = true;
var fragment = code.slice(index, parser.endIndex);
var n = fragment.match(/\n\r|\n|\r/g).length;
lineCounter += n;
startOffset = null;
}
function onclose(name) {
if (name !== "script" || !inscript)
return;
inscript = false;
index = parser.startIndex;
startOffset = null;
}
function ontext(data) {
if (!inscript)
return;
var lines = data.split(/\n\r|\n|\r/);
if (!startOffset) {
lines.some(function (line) {
if (!line) return;
startOffset = /^(\s*)/.exec(line)[1];
return true;
});
}
// check for startOffset again to remove leading white space from first line
lines.forEach(function () {
lineCounter += 1;
if (startOffset) {
offsets[lineCounter] = startOffset.length;
} else {
offsets[lineCounter] = 0;
}
});
}
var parser = new htmlparser.Parser({ onopentag: onopen, onclosetag: onclose, ontext: ontext });
parser.parseComplete(code);
return offsets;
}
/**
* Recursively gather all files that need to be linted,
* excluding those that user asked to ignore.
*
* @param {string} fp a path to a file or directory to lint
* @param {array} files a pointer to an array that stores a list of files
* @param {array} ignores a list of patterns for files to ignore
* @param {array} ext a list of non-dot-js extensions to lint
*/
function collect(fp, files, ignores, ext) {
if (ignores && isIgnored(fp, ignores)) {
return;
}
if (!shjs.test("-e", fp)) {
cli.error("Can't open " + fp);
return;
}
if (shjs.test("-d", fp)) {
shjs.ls(fp).forEach(function (item) {
var itempath = path.join(fp, item);
if (shjs.test("-d", itempath) || item.match(ext)) {
collect(itempath, files, ignores, ext);
}
});
return;
}
files.push(fp);
}
/**
* Runs JSHint against provided file and saves the result
*
* @param {string} code code that needs to be linted
* @param {object} results a pointer to an object with results
* @param {object} config an object with JSHint configuration
* @param {object} data a pointer to an object with extra data
* @param {string} file (optional) file name that is being linted
*/
function lint(code, results, config, data, file) {
var globals;
var lintData;
var buffer = [];
config = config || {};
config = JSON.parse(JSON.stringify(config));
if (config.prereq) {
config.prereq.forEach(function (fp) {
fp = path.join(config.dirname, fp);
if (shjs.test("-e", fp))
buffer.push(shjs.cat(fp));
});
delete config.prereq;
}
if (config.globals) {
globals = config.globals;
delete config.globals;
}
if (config.overrides) {
if (file) {
_.each(config.overrides, function (options, pattern) {
if (minimatch(path.normalize(file), pattern, { nocase: true, matchBase: true })) {
if (options.globals) {
globals = _.extend(globals || {}, options.globals);
delete options.globals;
}
_.extend(config, options);
}
});
}
delete config.overrides;
}
delete config.dirname;
buffer.push(code);
buffer = buffer.join("\n");
buffer = buffer.replace(/^\uFEFF/, ""); // Remove potential Unicode BOM.
if (!JSHINT(buffer, config, globals)) {
JSHINT.errors.forEach(function (err) {
if (err) {
results.push({ file: file || "stdin", error: err });
}
});
}
lintData = JSHINT.data();
if (lintData) {
lintData.file = file || "stdin";
data.push(lintData);
}
}
var exports = {
extract: extract,
exit: exit,
/**
* Returns a configuration file or nothing, if it can't be found.
*/
getConfig: function (fp) {
return loadNpmConfig(fp) || exports.loadConfig(findConfig(fp));
},
/**
* Loads and parses a configuration file.
*
* @param {string} fp a path to the config file
* @returns {object} config object
*/
loadConfig: function (fp) {
if (!fp) {
return {};
}
if (!shjs.test("-e", fp)) {
cli.error("Can't find config file: " + fp);
exports.exit(1);
}
try {
var config = JSON.parse(stripJsonComments(shjs.cat(fp)));
config.dirname = path.dirname(fp);
if (config['extends']) {
var baseConfig = exports.loadConfig(path.resolve(config.dirname, config['extends']));
config.globals = _.extend({}, baseConfig.globals, config.globals);
_.defaults(config, baseConfig);
delete config['extends'];
}
return config;
} catch (err) {
cli.error("Can't parse config file: " + fp);
exports.exit(1);
}
},
/**
* Gathers all files that need to be linted
*
* @param {object} post-processed options from 'interpret':
* args - CLI arguments
* ignores - A list of files/dirs to ignore (defaults to .jshintignores)
* extensions - A list of non-dot-js extensions to check
*/
gather: function (opts) {
var files = [];
var reg = new RegExp("\\.(js" +
(!opts.extensions ? "" : "|" +
opts.extensions.replace(/,/g, "|").replace(/[\. ]/g, "")) + ")$");
var ignores = !opts.ignores ? loadIgnores({cwd: opts.cwd}) :
opts.ignores.map(function (target) {
return path.resolve(target);
});
opts.args.forEach(function (target) {
collect(target, files, ignores, reg);
});
return files;
},
/**
* Gathers all files that need to be linted, lints them, sends them to
* a reporter and returns the overall result.
*
* @param {object} post-processed options from 'interpret':
* args - CLI arguments
* config - Configuration object
* reporter - Reporter function
* ignores - A list of files/dirs to ignore
* extensions - A list of non-dot-js extensions to check
* @param {function} cb a callback to call when function is finished
* asynchronously.
*
* @returns {bool} 'true' if all files passed, 'false' otherwise and 'null'
* when function will be finished asynchronously.
*/
run: function (opts, cb) {
var files = exports.gather(opts);
var results = [];
var data = [];
if (opts.useStdin) {
cli.withStdin(function (code) {
var config = opts.config;
var filename;
// There is an if(filename) check in the lint() function called below.
// passing a filename of undefined is the same as calling the function
// without a filename. If there is no opts.filename, filename remains
// undefined and lint() is effectively called with 4 parameters.
if (opts.filename) {
filename = path.resolve(opts.filename);
}
if (filename && !config) {
config = loadNpmConfig(filename) ||
exports.loadConfig(findConfig(filename));
}
config = config || {};
lint(extract(code, opts.extract), results, config, data, filename);
(opts.reporter || defReporter)(results, data, { verbose: opts.verbose });
cb(results.length === 0);
});
return null;
}
files.forEach(function (file) {
var config = opts.config || exports.getConfig(file);
var code;
try {
code = shjs.cat(file);
} catch (err) {
cli.error("Can't open " + file);
exports.exit(1);
}
lint(extract(code, opts.extract), results, config, data, file);
if (results.length) {
var offsets = extractOffsets(code, opts.extract);
if (offsets && offsets.length) {
results.forEach(function (errorInfo) {
var line = errorInfo.error.line;
if (line >= 0 && line < offsets.length) {
var offset = +offsets[line];
errorInfo.error.character += offset;
}
});
}
}
});
(opts.reporter || defReporter)(results, data, { verbose: opts.verbose });
return results.length === 0;
},
/**
* Helper exposed for testing.
* Used to determine is stdout has any buffered output before exiting the program
*/
getBufferSize: function () {
return process.stdout.bufferSize;
},
/**
* Main entrance function. Parses arguments and calls 'run' when
* its done. This function is called from bin/jshint file.
*
* @param {object} args, arguments in the process.argv format.
*/
interpret: function (args) {
cli.setArgv(args);
cli.options = {};
cli.enable("version", "glob", "help");
cli.setApp(path.resolve(__dirname + "/../package.json"));
var options = cli.parse(OPTIONS);
// Use config file if specified
var config;
if (options.config) {
config = exports.loadConfig(options.config);
}
switch (true) {
// JSLint reporter
case options.reporter === "jslint":
case options["jslint-reporter"]:
options.reporter = "./reporters/jslint_xml.js";
break;
// CheckStyle (XML) reporter
case options.reporter === "checkstyle":
case options["checkstyle-reporter"]:
options.reporter = "./reporters/checkstyle.js";
break;
// Reporter that displays additional JSHint data
case options["show-non-errors"]:
options.reporter = "./reporters/non_error.js";
break;
// Custom reporter
case options.reporter !== undefined:
options.reporter = path.resolve(process.cwd(), options.reporter);
}
var reporter;
if (options.reporter) {
reporter = loadReporter(options.reporter);
if (reporter === null) {
cli.error("Can't load reporter file: " + options.reporter);
exports.exit(1);
}
}
// This is a hack. exports.run is both sync and async function
// because I needed stdin support (and cli.withStdin is async)
// and was too lazy to change tests.
function done(passed) {
/*jshint eqnull:true */
if (passed == null)
return;
exports.exit(passed ? 0 : 2);
}
done(exports.run({
args: cli.args,
config: config,
reporter: reporter,
ignores: loadIgnores({exclude: options.exclude, excludePath: options["exclude-path"]}),
extensions: options["extra-ext"],
verbose: options.verbose,
extract: options.extract,
filename: options.filename,
useStdin: {"-": true, "/dev/stdin": true}[args[args.length - 1]]
}, done));
}
};
module.exports = exports;