diff --git a/README.md b/README.md index fd984c8..68bd0fb 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,11 @@ See [stylelint options](http://stylelint.io/user-guide/node-api/#options) for th * `configFile`: You can change the config file location. Default: (`undefined`), handled by [stylelint's cosmiconfig module](http://stylelint.io/user-guide/configuration/). * `context`: String indicating the root of your SCSS files. Default: inherits from webpack config. +* `failOnError`: Have Webpack's build process die on error. Default: `false` * `files`: Change the glob pattern for finding files. Default: (`['**/*.s?(a|c)ss']`) -* `syntax`: Use `'scss'` to lint .scss files. Default (`undefined`) * `formatter`: Use a custom formatter to print errors to the console. Default: (`require('stylelint').formatters.string`) -* `failOnError`: Have Webpack's build process die on error. Default: `false` +* `lintDirtyModulesOnly`: Lint only changed files, skip lint on start. Default (`false`) +* `syntax`: Use `'scss'` to lint .scss files. Default (`undefined`) * `quiet`: Don't print stylelint output to the console. Default: `false` ## Errors diff --git a/index.js b/index.js index d04aeea..bacd9ce 100644 --- a/index.js +++ b/index.js @@ -8,11 +8,11 @@ var formatter = require('stylelint').formatters.string; // Modules var runCompilation = require('./lib/run-compilation'); +var LintDirtyModulesPlugin = require('./lib/lint-dirty-modules-plugin'); function apply (options, compiler) { options = options || {}; var context = options.context || compiler.context; - options = assign({ formatter: formatter, quiet: false @@ -27,10 +27,14 @@ function apply (options, compiler) { var runner = runCompilation.bind(this, options); - compiler.plugin('run', runner); - compiler.plugin('watch-run', function onWatchRun (watcher, callback) { - runner(watcher.compiler, callback); - }); + if (options.lintDirtyModulesOnly) { + new LintDirtyModulesPlugin(compiler, options); // eslint-disable-line no-new + } else { + compiler.plugin('run', runner); + compiler.plugin('watch-run', function onWatchRun (watcher, callback) { + runner(watcher.compiler, callback); + }); + } } /** diff --git a/lib/lint-dirty-modules-plugin.js b/lib/lint-dirty-modules-plugin.js new file mode 100644 index 0000000..6c37463 --- /dev/null +++ b/lib/lint-dirty-modules-plugin.js @@ -0,0 +1,74 @@ +'use strict'; +var minimatch = require('minimatch'); +var reduce = require('lodash.reduce'); +var assign = require('object-assign'); +var runCompilation = require('./run-compilation'); + +/** + * Binds callback with provided options and stores initial values. + * + * @param compiler - webpack compiler object + * @param options - stylelint nodejs options + * @param callback - callback to call on emit + */ +function LintDirtyModulesPlugin (compiler, options) { + this.startTime = Date.now(); + this.prevTimestamps = {}; + this.isFirstRun = true; + this.compiler = compiler; + this.options = options; + compiler.plugin('emit', + this.lint.bind(this) // bind(this) is here to prevent context overriding by webpack + ); +} + +/** + * Lints changed files provided by compilation object. + * Fully executed only after initial run. + * + * @param options - stylelint options + * @param compilation - webpack compilation object + * @param callback - to be called when execution is done + * @returns {*} + */ +LintDirtyModulesPlugin.prototype.lint = function (compilation, callback) { + if (this.isFirstRun) { + this.isFirstRun = false; + this.prevTimestamps = compilation.fileTimestamps; + return callback(); + } + var dirtyOptions = assign({}, this.options); + var glob = dirtyOptions.files.join('|'); + var changedFiles = this.getChangedFiles(compilation.fileTimestamps, glob); + this.prevTimestamps = compilation.fileTimestamps; + if (changedFiles.length) { + dirtyOptions.files = changedFiles; + runCompilation.call(this, dirtyOptions, this.compiler, callback); + } else { + callback(); + } +}; + +/** + * Returns an array of changed files comparing current timestamps + * against cached timestamps from previous run. + * + * @param plugin - stylelint-webpack-plugin this scopr + * @param fileTimestamps - an object with keys as filenames and values as their timestamps. + * e.g. {'/filename.scss': 12444222000} + * @param glob - glob pattern to match files + */ +LintDirtyModulesPlugin.prototype.getChangedFiles = function (fileTimestamps, glob) { + return reduce(fileTimestamps, function (changedStyleFiles, timestamp, filename) { + // Check if file has been changed first ... + if ((this.prevTimestamps[filename] || this.startTime) < (fileTimestamps[filename] || Infinity) && + // ... then validate by the glob pattern. + minimatch(filename, glob, {matchBase: true}) + ) { + changedStyleFiles = changedStyleFiles.concat(filename); + } + return changedStyleFiles; + }.bind(this), []); +}; + +module.exports = LintDirtyModulesPlugin; diff --git a/lib/run-compilation.js b/lib/run-compilation.js index 6306239..9bd60dd 100644 --- a/lib/run-compilation.js +++ b/lib/run-compilation.js @@ -38,7 +38,7 @@ module.exports = function runCompilation (options, compiler, done) { }) .catch(done); - compiler.plugin('compilation', function onCompilation (compilation) { + compiler.plugin('after-emit', function onCompilation (compilation, callback) { if (warnings.length) { compilation.warnings.push(chalk.yellow(options.formatter(warnings))); warnings = []; @@ -48,5 +48,7 @@ module.exports = function runCompilation (options, compiler, done) { compilation.errors.push(chalk.red(options.formatter(errors))); errors = []; } + + callback(); }); }; diff --git a/package.json b/package.json index 6c5d94a..31ef907 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "dependencies": { "arrify": "^1.0.1", "chalk": "^1.1.3", + "lodash.reduce": "^4.6.0", + "minimatch": "^3.0.3", "object-assign": "^4.1.0", "stylelint": "^7.7.0" }, @@ -45,7 +47,8 @@ "mocha": "^3.1.0", "npm-install-version": "^6.0.1", "nyc": "^10.0.0", - "semistandard": "^9.2.1" + "semistandard": "^9.2.1", + "testdouble": "^1.10.2" }, "scripts": { "pretest": "semistandard", diff --git a/test/index.js b/test/index.js index 6d816d8..aff180c 100644 --- a/test/index.js +++ b/test/index.js @@ -1,13 +1,13 @@ 'use strict'; var assign = require('object-assign'); - var StyleLintPlugin = require('../'); var pack = require('./helpers/pack'); var webpack = require('./helpers/webpack'); var baseConfig = require('./helpers/base-config'); var configFilePath = getPath('./.stylelintrc'); +require('./lib/lint-dirty-modules-plugin'); describe('stylelint-webpack-plugin', function () { it('works with a simple file', function () { @@ -106,7 +106,8 @@ describe('stylelint-webpack-plugin', function () { entry: './index', plugins: [ new StyleLintPlugin({ - configFile: configFilePath + configFile: configFilePath, + quiet: true }) ] }; @@ -177,4 +178,27 @@ describe('stylelint-webpack-plugin', function () { expect(err.message).to.contain('Failed to parse').and.contain('as JSON'); }); }); + + context('lintDirtyModulesOnly flag is enabled', function () { + it('skips linting on initial run', function () { + var config = { + context: './test/fixtures/test3', + entry: './index', + plugins: [ + new StyleLintPlugin({ + configFile: configFilePath, + quiet: true, + lintDirtyModulesOnly: true + }), + new webpack.NoErrorsPlugin() + ] + }; + + return pack(assign({}, baseConfig, config)) + .then(function (stats) { + expect(stats.compilation.errors).to.have.length(0); + expect(stats.compilation.warnings).to.have.length(0); + }); + }); + }); }); diff --git a/test/lib/lint-dirty-modules-plugin.js b/test/lib/lint-dirty-modules-plugin.js new file mode 100644 index 0000000..ea4e65c --- /dev/null +++ b/test/lib/lint-dirty-modules-plugin.js @@ -0,0 +1,141 @@ +'use strict'; + +var td = require('testdouble'); +var formatter = require('stylelint').formatters.string; + +var runCompilation = td.replace('../../lib/run-compilation'); + +var LintDirtyModulesPlugin = require('../../lib/lint-dirty-modules-plugin'); + +var configFilePath = getPath('./.stylelintrc'); +var glob = '/**/*.s?(c|a)ss'; + +describe('lint-dirty-modules-plugin', function () { + context('lintDirtyModulesOnly flag is enabled', function () { + var LintDirtyModulesPluginCloned; + var compilerMock; + var optionsMock; + + beforeEach(function () { + LintDirtyModulesPluginCloned = function () { + LintDirtyModulesPlugin.apply(this, arguments); + }; + LintDirtyModulesPluginCloned.prototype = Object.create(LintDirtyModulesPlugin.prototype); + LintDirtyModulesPluginCloned.prototype.constructor = LintDirtyModulesPlugin; + + compilerMock = { + callback: null, + plugin: function plugin (event, callback) { + this.callback = callback; + } + }; + + optionsMock = { + configFile: configFilePath, + lintDirtyModulesOnly: true, + fomatter: formatter, + files: [glob] + }; + }); + + it('lint is called on \'emit\'', function () { + var lintStub = td.function(); + var doneStub = td.function(); + LintDirtyModulesPluginCloned.prototype.lint = lintStub; + var plugin = new LintDirtyModulesPluginCloned(compilerMock, optionsMock); + + var compilationMock = { + fileTimestamps: { + '/udpated.scss': 5 + } + }; + compilerMock.callback(compilationMock, doneStub); + + expect(plugin.isFirstRun).to.eql(true); + td.verify(lintStub(compilationMock, doneStub)); + }); + + context('LintDirtyModulesPlugin.prototype.lint()', function () { + var getChangedFilesStub; + var doneStub; + var compilationMock; + var fileTimestamps = { + '/test/changed.scss': 5, + '/test/newly-created.scss': 5 + }; + var pluginMock; + beforeEach(function () { + getChangedFilesStub = td.function(); + doneStub = td.function(); + compilationMock = { + fileTimestamps: {} + }; + td.when(getChangedFilesStub({}, glob)).thenReturn([]); + td.when(getChangedFilesStub(fileTimestamps, glob)).thenReturn(Object.keys(fileTimestamps)); + pluginMock = { + getChangedFiles: getChangedFilesStub, + compiler: compilerMock, + options: optionsMock, + isFirstRun: true + }; + }); + + it('skips compilation on first run', function () { + expect(pluginMock.isFirstRun).to.eql(true); + LintDirtyModulesPluginCloned.prototype.lint.call(pluginMock, compilationMock, doneStub); + td.verify(doneStub()); + expect(pluginMock.isFirstRun).to.eql(false); + td.verify(getChangedFilesStub, {times: 0, ignoreExtraArgs: true}); + td.verify(runCompilation, {times: 0, ignoreExtraArgs: true}); + }); + + it('runCompilation is not called if files are not changed', function () { + pluginMock.isFirstRun = false; + LintDirtyModulesPluginCloned.prototype.lint.call(pluginMock, compilationMock, doneStub); + td.verify(doneStub()); + td.verify(runCompilation, {times: 0, ignoreExtraArgs: true}); + }); + + it('runCompilation is called if styles are changed', function () { + pluginMock.isFirstRun = false; + compilationMock = { + fileTimestamps: fileTimestamps + }; + LintDirtyModulesPluginCloned.prototype.lint.call(pluginMock, compilationMock, doneStub); + optionsMock.files = Object.keys(fileTimestamps); + td.verify(runCompilation(optionsMock, compilerMock, doneStub)); + }); + }); + + context('LintDirtyModulesPlugin.prototype.getChangedFiles()', function () { + var pluginMock; + before(function () { + pluginMock = { + compiler: compilerMock, + options: optionsMock, + isFirstRun: true, + startTime: 10, + prevTimestamps: { + '/test/changed.scss': 5, + '/test/removed.scss': 5, + '/test/changed.js': 5 + } + }; + }); + it('returns changed style files', function () { + var fileTimestamps = { + '/test/changed.scss': 20, + '/test/changed.js': 20, + '/test/newly-created.scss': 15 + }; + + expect( + LintDirtyModulesPluginCloned.prototype.getChangedFiles.call(pluginMock, fileTimestamps, glob)).to.eql([ + '/test/changed.scss', + '/test/newly-created.scss' + ] + ); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 170224f..cd77570 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1774,6 +1774,10 @@ is-path-inside@^1.0.0: dependencies: path-is-inside "^1.0.1" +is-plain-obj@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + is-posix-bracket@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" @@ -2111,6 +2115,10 @@ lodash.merge@^4.0.2: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.0.tgz#69884ba144ac33fe699737a6086deffadd0f89c5" +lodash.reduce@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b" + lodash.template@^4.0.2: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0" @@ -2136,7 +2144,7 @@ lodash.without@~4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.without/-/lodash.without-4.4.0.tgz#3cd4574a00b67bae373a94b748772640507b7aac" -lodash@^4.0.0, lodash@^4.1.0, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0: +lodash@^4.0.0, lodash@^4.1.0, lodash@^4.15.0, lodash@^4.17.2, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" @@ -2248,7 +2256,7 @@ mime@^1.2.11: version "1.3.4" resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" -"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2: +"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774" dependencies: @@ -2880,6 +2888,12 @@ qs@~6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.0.tgz#f403b264f23bc01228c74131b407f18d5ea5d442" +quibble@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/quibble/-/quibble-0.4.0.tgz#a1535c4a80b3d3617d23c5d770f1ec2c7b5523a1" + dependencies: + lodash "^4.17.2" + randomatic@^1.1.3: version "1.1.6" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.6.tgz#110dcabff397e9dcff7c0789ccc0a49adf1ec5bb" @@ -3381,6 +3395,13 @@ string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" +stringify-object@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-2.4.0.tgz#c62d11023eb21fe2d9b087be039a26df3b22a09d" + dependencies: + is-plain-obj "^1.0.0" + is-regexp "^1.0.0" + stringstream@~0.0.4: version "0.0.5" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" @@ -3540,6 +3561,14 @@ test-exclude@^3.3.0: read-pkg-up "^1.0.1" require-main-filename "^1.0.1" +testdouble@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/testdouble/-/testdouble-1.10.2.tgz#7c41d34598b0b4f7649f1447a804cbb01654171a" + dependencies: + lodash "^4.15.0" + quibble "^0.4.0" + stringify-object "^2.4.0" + text-extensions@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/text-extensions/-/text-extensions-1.4.0.tgz#c385d2e80879fe6ef97893e1709d88d9453726e9"