Skip to content

Commit 7e60e9b

Browse files
sergesemashkoJaKXz
authored andcommitted
feat: add lintDirtyModulesOnly option (#53)
* 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
1 parent 96781a3 commit 7e60e9b

File tree

8 files changed

+291
-13
lines changed

8 files changed

+291
-13
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ See [stylelint options](http://stylelint.io/user-guide/node-api/#options) for th
4242

4343
* `configFile`: You can change the config file location. Default: (`undefined`), handled by [stylelint's cosmiconfig module](http://stylelint.io/user-guide/configuration/).
4444
* `context`: String indicating the root of your SCSS files. Default: inherits from webpack config.
45+
* `failOnError`: Have Webpack's build process die on error. Default: `false`
4546
* `files`: Change the glob pattern for finding files. Default: (`['**/*.s?(a|c)ss']`)
46-
* `syntax`: Use `'scss'` to lint .scss files. Default (`undefined`)
4747
* `formatter`: Use a custom formatter to print errors to the console. Default: (`require('stylelint').formatters.string`)
48-
* `failOnError`: Have Webpack's build process die on error. Default: `false`
48+
* `lintDirtyModulesOnly`: Lint only changed files, skip lint on start. Default (`false`)
49+
* `syntax`: Use `'scss'` to lint .scss files. Default (`undefined`)
4950
* `quiet`: Don't print stylelint output to the console. Default: `false`
5051

5152
## Errors

index.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ var formatter = require('stylelint').formatters.string;
88

99
// Modules
1010
var runCompilation = require('./lib/run-compilation');
11+
var LintDirtyModulesPlugin = require('./lib/lint-dirty-modules-plugin');
1112

1213
function apply (options, compiler) {
1314
options = options || {};
1415
var context = options.context || compiler.context;
15-
1616
options = assign({
1717
formatter: formatter,
1818
quiet: false
@@ -27,10 +27,14 @@ function apply (options, compiler) {
2727

2828
var runner = runCompilation.bind(this, options);
2929

30-
compiler.plugin('run', runner);
31-
compiler.plugin('watch-run', function onWatchRun (watcher, callback) {
32-
runner(watcher.compiler, callback);
33-
});
30+
if (options.lintDirtyModulesOnly) {
31+
new LintDirtyModulesPlugin(compiler, options); // eslint-disable-line no-new
32+
} else {
33+
compiler.plugin('run', runner);
34+
compiler.plugin('watch-run', function onWatchRun (watcher, callback) {
35+
runner(watcher.compiler, callback);
36+
});
37+
}
3438
}
3539

3640
/**

lib/lint-dirty-modules-plugin.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use strict';
2+
var minimatch = require('minimatch');
3+
var reduce = require('lodash.reduce');
4+
var assign = require('object-assign');
5+
var runCompilation = require('./run-compilation');
6+
7+
/**
8+
* Binds callback with provided options and stores initial values.
9+
*
10+
* @param compiler - webpack compiler object
11+
* @param options - stylelint nodejs options
12+
* @param callback <function(options, compilitaion)> - callback to call on emit
13+
*/
14+
function LintDirtyModulesPlugin (compiler, options) {
15+
this.startTime = Date.now();
16+
this.prevTimestamps = {};
17+
this.isFirstRun = true;
18+
this.compiler = compiler;
19+
this.options = options;
20+
compiler.plugin('emit',
21+
this.lint.bind(this) // bind(this) is here to prevent context overriding by webpack
22+
);
23+
}
24+
25+
/**
26+
* Lints changed files provided by compilation object.
27+
* Fully executed only after initial run.
28+
*
29+
* @param options - stylelint options
30+
* @param compilation - webpack compilation object
31+
* @param callback - to be called when execution is done
32+
* @returns {*}
33+
*/
34+
LintDirtyModulesPlugin.prototype.lint = function (compilation, callback) {
35+
if (this.isFirstRun) {
36+
this.isFirstRun = false;
37+
this.prevTimestamps = compilation.fileTimestamps;
38+
return callback();
39+
}
40+
var dirtyOptions = assign({}, this.options);
41+
var glob = dirtyOptions.files.join('|');
42+
var changedFiles = this.getChangedFiles(compilation.fileTimestamps, glob);
43+
this.prevTimestamps = compilation.fileTimestamps;
44+
if (changedFiles.length) {
45+
dirtyOptions.files = changedFiles;
46+
runCompilation.call(this, dirtyOptions, this.compiler, callback);
47+
} else {
48+
callback();
49+
}
50+
};
51+
52+
/**
53+
* Returns an array of changed files comparing current timestamps
54+
* against cached timestamps from previous run.
55+
*
56+
* @param plugin - stylelint-webpack-plugin this scopr
57+
* @param fileTimestamps - an object with keys as filenames and values as their timestamps.
58+
* e.g. {'/filename.scss': 12444222000}
59+
* @param glob - glob pattern to match files
60+
*/
61+
LintDirtyModulesPlugin.prototype.getChangedFiles = function (fileTimestamps, glob) {
62+
return reduce(fileTimestamps, function (changedStyleFiles, timestamp, filename) {
63+
// Check if file has been changed first ...
64+
if ((this.prevTimestamps[filename] || this.startTime) < (fileTimestamps[filename] || Infinity) &&
65+
// ... then validate by the glob pattern.
66+
minimatch(filename, glob, {matchBase: true})
67+
) {
68+
changedStyleFiles = changedStyleFiles.concat(filename);
69+
}
70+
return changedStyleFiles;
71+
}.bind(this), []);
72+
};
73+
74+
module.exports = LintDirtyModulesPlugin;

lib/run-compilation.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ module.exports = function runCompilation (options, compiler, done) {
3838
})
3939
.catch(done);
4040

41-
compiler.plugin('compilation', function onCompilation (compilation) {
41+
compiler.plugin('after-emit', function onCompilation (compilation, callback) {
4242
if (warnings.length) {
4343
compilation.warnings.push(chalk.yellow(options.formatter(warnings)));
4444
warnings = [];
@@ -48,5 +48,7 @@ module.exports = function runCompilation (options, compiler, done) {
4848
compilation.errors.push(chalk.red(options.formatter(errors)));
4949
errors = [];
5050
}
51+
52+
callback();
5153
});
5254
};

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
"dependencies": {
3434
"arrify": "^1.0.1",
3535
"chalk": "^1.1.3",
36+
"lodash.reduce": "^4.6.0",
37+
"minimatch": "^3.0.3",
3638
"object-assign": "^4.1.0",
3739
"stylelint": "^7.7.0"
3840
},
@@ -45,7 +47,8 @@
4547
"mocha": "^3.1.0",
4648
"npm-install-version": "^6.0.1",
4749
"nyc": "^10.0.0",
48-
"semistandard": "^9.2.1"
50+
"semistandard": "^9.2.1",
51+
"testdouble": "^1.10.2"
4952
},
5053
"scripts": {
5154
"pretest": "semistandard",

test/index.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
'use strict';
22

33
var assign = require('object-assign');
4-
54
var StyleLintPlugin = require('../');
65
var pack = require('./helpers/pack');
76
var webpack = require('./helpers/webpack');
87
var baseConfig = require('./helpers/base-config');
98

109
var configFilePath = getPath('./.stylelintrc');
10+
require('./lib/lint-dirty-modules-plugin');
1111

1212
describe('stylelint-webpack-plugin', function () {
1313
it('works with a simple file', function () {
@@ -106,7 +106,8 @@ describe('stylelint-webpack-plugin', function () {
106106
entry: './index',
107107
plugins: [
108108
new StyleLintPlugin({
109-
configFile: configFilePath
109+
configFile: configFilePath,
110+
quiet: true
110111
})
111112
]
112113
};
@@ -177,4 +178,27 @@ describe('stylelint-webpack-plugin', function () {
177178
expect(err.message).to.contain('Failed to parse').and.contain('as JSON');
178179
});
179180
});
181+
182+
context('lintDirtyModulesOnly flag is enabled', function () {
183+
it('skips linting on initial run', function () {
184+
var config = {
185+
context: './test/fixtures/test3',
186+
entry: './index',
187+
plugins: [
188+
new StyleLintPlugin({
189+
configFile: configFilePath,
190+
quiet: true,
191+
lintDirtyModulesOnly: true
192+
}),
193+
new webpack.NoErrorsPlugin()
194+
]
195+
};
196+
197+
return pack(assign({}, baseConfig, config))
198+
.then(function (stats) {
199+
expect(stats.compilation.errors).to.have.length(0);
200+
expect(stats.compilation.warnings).to.have.length(0);
201+
});
202+
});
203+
});
180204
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use strict';
2+
3+
var td = require('testdouble');
4+
var formatter = require('stylelint').formatters.string;
5+
6+
var runCompilation = td.replace('../../lib/run-compilation');
7+
8+
var LintDirtyModulesPlugin = require('../../lib/lint-dirty-modules-plugin');
9+
10+
var configFilePath = getPath('./.stylelintrc');
11+
var glob = '/**/*.s?(c|a)ss';
12+
13+
describe('lint-dirty-modules-plugin', function () {
14+
context('lintDirtyModulesOnly flag is enabled', function () {
15+
var LintDirtyModulesPluginCloned;
16+
var compilerMock;
17+
var optionsMock;
18+
19+
beforeEach(function () {
20+
LintDirtyModulesPluginCloned = function () {
21+
LintDirtyModulesPlugin.apply(this, arguments);
22+
};
23+
LintDirtyModulesPluginCloned.prototype = Object.create(LintDirtyModulesPlugin.prototype);
24+
LintDirtyModulesPluginCloned.prototype.constructor = LintDirtyModulesPlugin;
25+
26+
compilerMock = {
27+
callback: null,
28+
plugin: function plugin (event, callback) {
29+
this.callback = callback;
30+
}
31+
};
32+
33+
optionsMock = {
34+
configFile: configFilePath,
35+
lintDirtyModulesOnly: true,
36+
fomatter: formatter,
37+
files: [glob]
38+
};
39+
});
40+
41+
it('lint is called on \'emit\'', function () {
42+
var lintStub = td.function();
43+
var doneStub = td.function();
44+
LintDirtyModulesPluginCloned.prototype.lint = lintStub;
45+
var plugin = new LintDirtyModulesPluginCloned(compilerMock, optionsMock);
46+
47+
var compilationMock = {
48+
fileTimestamps: {
49+
'/udpated.scss': 5
50+
}
51+
};
52+
compilerMock.callback(compilationMock, doneStub);
53+
54+
expect(plugin.isFirstRun).to.eql(true);
55+
td.verify(lintStub(compilationMock, doneStub));
56+
});
57+
58+
context('LintDirtyModulesPlugin.prototype.lint()', function () {
59+
var getChangedFilesStub;
60+
var doneStub;
61+
var compilationMock;
62+
var fileTimestamps = {
63+
'/test/changed.scss': 5,
64+
'/test/newly-created.scss': 5
65+
};
66+
var pluginMock;
67+
beforeEach(function () {
68+
getChangedFilesStub = td.function();
69+
doneStub = td.function();
70+
compilationMock = {
71+
fileTimestamps: {}
72+
};
73+
td.when(getChangedFilesStub({}, glob)).thenReturn([]);
74+
td.when(getChangedFilesStub(fileTimestamps, glob)).thenReturn(Object.keys(fileTimestamps));
75+
pluginMock = {
76+
getChangedFiles: getChangedFilesStub,
77+
compiler: compilerMock,
78+
options: optionsMock,
79+
isFirstRun: true
80+
};
81+
});
82+
83+
it('skips compilation on first run', function () {
84+
expect(pluginMock.isFirstRun).to.eql(true);
85+
LintDirtyModulesPluginCloned.prototype.lint.call(pluginMock, compilationMock, doneStub);
86+
td.verify(doneStub());
87+
expect(pluginMock.isFirstRun).to.eql(false);
88+
td.verify(getChangedFilesStub, {times: 0, ignoreExtraArgs: true});
89+
td.verify(runCompilation, {times: 0, ignoreExtraArgs: true});
90+
});
91+
92+
it('runCompilation is not called if files are not changed', function () {
93+
pluginMock.isFirstRun = false;
94+
LintDirtyModulesPluginCloned.prototype.lint.call(pluginMock, compilationMock, doneStub);
95+
td.verify(doneStub());
96+
td.verify(runCompilation, {times: 0, ignoreExtraArgs: true});
97+
});
98+
99+
it('runCompilation is called if styles are changed', function () {
100+
pluginMock.isFirstRun = false;
101+
compilationMock = {
102+
fileTimestamps: fileTimestamps
103+
};
104+
LintDirtyModulesPluginCloned.prototype.lint.call(pluginMock, compilationMock, doneStub);
105+
optionsMock.files = Object.keys(fileTimestamps);
106+
td.verify(runCompilation(optionsMock, compilerMock, doneStub));
107+
});
108+
});
109+
110+
context('LintDirtyModulesPlugin.prototype.getChangedFiles()', function () {
111+
var pluginMock;
112+
before(function () {
113+
pluginMock = {
114+
compiler: compilerMock,
115+
options: optionsMock,
116+
isFirstRun: true,
117+
startTime: 10,
118+
prevTimestamps: {
119+
'/test/changed.scss': 5,
120+
'/test/removed.scss': 5,
121+
'/test/changed.js': 5
122+
}
123+
};
124+
});
125+
it('returns changed style files', function () {
126+
var fileTimestamps = {
127+
'/test/changed.scss': 20,
128+
'/test/changed.js': 20,
129+
'/test/newly-created.scss': 15
130+
};
131+
132+
expect(
133+
LintDirtyModulesPluginCloned.prototype.getChangedFiles.call(pluginMock, fileTimestamps, glob)).to.eql([
134+
'/test/changed.scss',
135+
'/test/newly-created.scss'
136+
]
137+
);
138+
});
139+
});
140+
});
141+
});

0 commit comments

Comments
 (0)