"use strict"; var cli = require("cli"); var path = require("path"); var shjs = require("shelljs"); var minimatch = require("minimatch"); 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 (|jslint|checkstyle)", "string", undefined ], "exclude": ["exclude", "Exclude files matching the given filename pattern (same as .jshintignore)", "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", ""], // 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)"; } /** * Removes JavaScript comments from a string by replacing * everything between block comments and everything after * single-line comments in a non-greedy way. * * English version of the regex: * match '/*' * then match zero or more instances of any character (incl. \n) * except for instances of '* /' (without a space, obv.) * then match '* /' (again, without a space) * * @param {string} str a string with potential JavaScript comments. * @returns {string} a string without JavaScript comments. */ function removeComments(str) { str = str || ""; str = str.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\//g, ""); str = str.replace(/\/\/[^\n\r]*/g, ""); // Everything after '//' return str; } /** * 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 = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE; 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; } /** * 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; return require(fp).jshintConfig || 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, dir) { dir = dir || process.cwd(); var filename = path.normalize(path.join(dir, name)); if (findFileResults[filename] !== undefined) { return findFileResults[filename]; } var parent = path.resolve(dir, "../"); if (shjs.test("-e", filename)) { findFileResults[filename] = filename; return filename; } if (dir === 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(exclude) { var file = findFile(".jshintignore"); if (!file && !exclude) { return []; } var lines = (file ? shjs.cat(file) : "").split("\n"); lines.unshift(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.resolve(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; } }); } /** * 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; } 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 = { /** * 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); process.exit(1); } try { var config = JSON.parse(removeComments(shjs.cat(fp))); config.dirname = path.dirname(fp); return config; } catch (err) { cli.error("Can't parse config file: " + fp); process.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() : 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) { lint(code, results, opts.config || {}, data); (opts.reporter || defReporter)(results, data, { verbose: opts.verbose }); cb(results.length === 0); }); return null; } files.forEach(function (file) { var config = opts.config || loadNpmConfig(file) || exports.loadConfig(findConfig(file)); var code; try { code = shjs.cat(file); } catch (err) { cli.error("Can't open " + file); process.exit(1); } lint(code, results, config, data, file); }); (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); process.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; // Patch as per https://github.com/visionmedia/mocha/issues/333 // fixes issues with piped output on Windows. // Root issue is here https://github.com/joyent/node/issues/3584 function exit() { process.exit(passed ? 0 : 2); } try { if (exports.getBufferSize()) { process.stdout.once('drain', exit); } else { exit(); } } catch (err) { exit(); } } done(exports.run({ args: cli.args, config: config, reporter: reporter, ignores: loadIgnores(options.exclude), extensions: options["extra-ext"], verbose: options.verbose, useStdin: {"-": true, "/dev/stdin": true}[args[args.length - 1]] }, done)); } }; module.exports = exports;