Skip to content

Commit

Permalink
feat: add lintDirtyModulesOnly option (#53)
Browse files Browse the repository at this point in the history
* resolves #30 - add lint dirty files only flag

* Separate logic to extract changed style files.

 - Add tests for lintDirtyFilesOnly flag

* Added a test case for non style file change

* Add unit test for getChangedFiles

* Remove unused dependencies

* Quiet Stylelint during tests

* Refactore lint dirty modules into plugin in separate file

* Add testdouble

* Fix lint warnings
  • Loading branch information
sergesemashko authored and JaKXz committed Jan 16, 2017
1 parent 96781a3 commit 7e60e9b
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 13 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
});
}
}

/**
Expand Down
74 changes: 74 additions & 0 deletions lib/lint-dirty-modules-plugin.js
Original file line number Diff line number Diff line change
@@ -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 <function(options, compilitaion)> - 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;
4 changes: 3 additions & 1 deletion lib/run-compilation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -48,5 +48,7 @@ module.exports = function runCompilation (options, compiler, done) {
compilation.errors.push(chalk.red(options.formatter(errors)));
errors = [];
}

callback();
});
};
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
28 changes: 26 additions & 2 deletions test/index.js
Original file line number Diff line number Diff line change
@@ -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 () {
Expand Down Expand Up @@ -106,7 +106,8 @@ describe('stylelint-webpack-plugin', function () {
entry: './index',
plugins: [
new StyleLintPlugin({
configFile: configFilePath
configFile: configFilePath,
quiet: true
})
]
};
Expand Down Expand Up @@ -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);
});
});
});
});
141 changes: 141 additions & 0 deletions test/lib/lint-dirty-modules-plugin.js
Original file line number Diff line number Diff line change
@@ -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'
]
);
});
});
});
});
Loading

0 comments on commit 7e60e9b

Please sign in to comment.