Skip to content
Merged
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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use yarn add minimatch so the yarn.lock file is updated.

Copy link
Contributor

@JaKXz JaKXz Jan 5, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sergesemashko just curious, is the filter fn that you're using from minimatch available from node-glob? could remove unnecessary dependencies :)

Copy link
Contributor Author

@sergesemashko sergesemashko Jan 7, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. Minimatch is actually a dependency of node-glob which doesn't export any kind of function to use here :( But I ran yarn add minimatch, surprisingly, I did not cause any updates of yarn.lock, because latest minimatch version is already installed in ./node_modules.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great!

"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