Repository: lesshint/lesshint
Pull request #499 (2018-10-28 23:19:38)

New rule "pseudo-elements"
**Which issue, if any, does this resolve?**

#480 


**Is there anything in this PR that needs extra explaining or should something specific be focused on?**

* I added a watch task to simplify development, it run a test task on every js file modification. 
* I only wanted to try to add a new rule, I never used postcss parser before, so my code may not be so optimal
* Pseudo elements list is from MDN


jwilsson: Thanks for contributing! 🎉 

Let's wait a few days and see if AppVeyor adds Node 11 (that's why it's failing there). Otherwise, we can merge it anyway and test it manually on Windows.

Patch:
diff --git a/README.md b/README.md
index 671261e..d6ecf74 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,8 @@ These codes were chosen with regards to the [preferable exit codes](http://www.g
 * [AtomLinter plugin](https://atom.io/packages/linter-lesshint) - A Linter plugin for [Atom](https://atom.io/).
 * [autolesshint](https://www.npmjs.com/package/autolesshint) - Automagically fix errors reported by `lesshint`.
 * [Gulp plugin](https://www.npmjs.com/package/gulp-lesshint) - A gulp plugin for `lesshint`.
-* [Grunt plugin](https://www.npmjs.com/package/grunt-lesshint) - A Grunt for plugin `lesshint`.
+* [Grunt plugin](https://www.npmjs.com/package/grunt-lesshint) - A Grunt plugin for `lesshint`.
+* [vscode-lesshint](https://github.com/tohjustin/vscode-lesshint) - A VS Code extension for `lesshint`.
 
 ## Linters
 In addition to the linters included with `lesshint`, it's also possible to include custom ones. For example to check something team or project specific.
diff --git a/gulpfile.js b/gulpfile.js
index 982d829..7c9cfa9 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -1,3 +1,5 @@
+'use strict';
+
 const gulp = require('gulp');
 
 gulp.task('lint', () => {
@@ -11,10 +13,11 @@ gulp.task('lint', () => {
 
 gulp.task('test', ['lint'], () => {
     const mocha = require('gulp-mocha');
+    const options = {
+        read: false
+    };
 
-    return gulp.src(['./test/specs/**/*.js', '!./test/specs/util.js'], {
-            read: false
-        })
+    return gulp.src(['./test/specs/**/*.js', '!./test/specs/util.js'], options)
         .pipe(mocha());
 });
 
@@ -25,4 +28,8 @@ gulp.task('coveralls', ['test'], () => {
         .pipe(coveralls());
 });
 
+gulp.task('watch', () => {
+    gulp.watch(['./test/**/*.js', './lib/**/*.js'], ['test']);
+});
+
 gulp.task('default', ['test']);
diff --git a/lib/config/defaults.json b/lib/config/defaults.json
index 4496f3e..be9cbcb 100644
--- a/lib/config/defaults.json
+++ b/lib/config/defaults.json
@@ -105,6 +105,11 @@
         "properties": {}
     },
 
+    "pseudoElements": {
+        "enable": true,
+        "exclude": []
+    },
+
     "qualifyingElement": {
         "enabled": true,
         "allowWithId": false,
diff --git a/lib/linters/README.md b/lib/linters/README.md
index c201ff3..d30bd58 100644
--- a/lib/linters/README.md
+++ b/lib/linters/README.md
@@ -19,6 +19,7 @@
 * [newlineAfterBlock](#newlineafterblock)
 * [propertyOrdering](#propertyordering)
 * [propertyUnits](#propertyunits)
+* [pseudoElements](#pseudoelements)
 * [qualifyingElement](#qualifyingelement)
 * [selectorNaming](#selectornaming)
 * [singleLinePerProperty](#singlelineperproperty)
@@ -476,6 +477,36 @@ Option        | Description
 }
 ```
 
+## pseudoElements
+Pseudo-elements should be written two colons (CSS 3 style) and pseudo-classes should have only one.
+
+Option               | Description
+-------------------- | ----------
+`exclude`            | `array` additional "pseudo" to exclude (without colons)
+
+### invalid
+```less
+.foo:before {
+    color: red;
+}
+
+.bar::focus {
+    color: red;
+}
+```
+
+### valid
+
+```less
+.foo::before {
+    color: red;
+}
+
+.bar:focus {
+    color: red;
+}
+```
+
 ## qualifyingElement
 Selectors should not include a qualifying element since this will just add unnecessary specificity.
 
diff --git a/lib/linters/pseudo_elements.js b/lib/linters/pseudo_elements.js
new file mode 100644
index 0000000..222d30c
--- /dev/null
+++ b/lib/linters/pseudo_elements.js
@@ -0,0 +1,69 @@
+'use strict';
+
+const parseSelector = require('../utils/parse-selector');
+
+const elements = [
+    'after',
+    'backdrop',
+    'before',
+    'cue',
+    'first-letter',
+    'first-line',
+    'grammar-error',
+    'marker',
+    'placeholder',
+    'selection',
+    'slotted',
+    'spelling-error',
+];
+
+module.exports = {
+    name: 'pseudoElements',
+    nodeTypes: ['rule'],
+    message: {
+        classes: 'Pseudo-classes should have 1 colon.',
+        elements: 'Pseudo-elements should have 2 colons.'
+    },
+
+    lint: function pseudoElementsLinter (config, node) {
+        const tree = parseSelector(node);
+        const results = [];
+        const exclude = config.exclude || [];
+
+        tree.each((selector) => {
+            selector.nodes.forEach((elem) => {
+                if (elem.type === 'pseudo') {
+                    const pseudo = elem.value.replace(/:/g, '');
+                    const colons = elem.value.match(/:/g).length;
+                    const { column, line } = node.positionBy({
+                        word: elem
+                    });
+
+                    if (exclude.indexOf(pseudo) !== -1) {
+                        return;
+                    }
+
+                    if (elements.indexOf(pseudo) !== -1) {
+                        if (colons !== 2) {
+                            results.push({
+                                column,
+                                line,
+                                message: this.message.elements
+                            });
+                        }
+                    } else if (colons !== 1) {
+                        results.push({
+                            column,
+                            line,
+                            message: this.message.classes
+                        });
+                    }
+                }
+            });
+        });
+
+        if (results.length) {
+            return results;
+        }
+    }
+};


Test patch:
diff --git a/test/specs/linters/pseudo_elements.js b/test/specs/linters/pseudo_elements.js
new file mode 100644
index 0000000..36aa44c
--- /dev/null
+++ b/test/specs/linters/pseudo_elements.js
@@ -0,0 +1,172 @@
+'use strict';
+
+const { expect } = require('chai');
+const spec = require('../util.js').setup();
+
+describe('lesshint', function () {
+    describe('#pseudoElements()', function () {
+        it('should allow simple selectors', function () {
+            const source = '.foo {}';
+            const options = {
+                exclude: []
+            };
+
+            return spec.parse(source, function (ast) {
+                const result = spec.linter.lint(options, ast.root.first);
+
+                expect(result).to.be.undefined;
+            });
+        });
+
+        describe('with pseudo-elements', function () {
+            const elements = [
+                'after',
+                'backdrop',
+                'before',
+                'cue',
+                'first-letter',
+                'first-line',
+                'grammar-error',
+                'marker',
+                'placeholder',
+                'selection',
+                'slotted',
+                'spelling-error',
+            ];
+
+            it('should have the proper node types', function () {
+                const source = '.foo::before {}';
+
+                return spec.parse(source, function (ast) {
+                    expect(spec.linter.nodeTypes).to.include(ast.root.first.type);
+                });
+            });
+
+            elements.forEach((element) => {
+                it('should allow ::' + element, function () {
+                    const source = '.foo::' + element + ' {}';
+
+                    return spec.parse(source, function (ast) {
+                        const result = spec.linter.lint({}, ast.root.first);
+
+                        expect(result).to.be.undefined;
+                    });
+                });
+            });
+
+            it('should not allow incorrect number of colons', function () {
+                const source = '.foo:before {}';
+                const expected = [{
+                    column: 5,
+                    line: 1,
+                    message: 'Pseudo-elements should have 2 colons.'
+                }];
+
+                const options = {};
+
+                return spec.parse(source, function (ast) {
+                    const result = spec.linter.lint(options, ast.root.first);
+
+                    expect(result).to.deep.equal(expected);
+                });
+            });
+        });
+
+        describe('with pseudo-classes', function () {
+            it('should have the proper node types', function () {
+                const source = '.foo:hover {}';
+
+                return spec.parse(source, function (ast) {
+                    expect(spec.linter.nodeTypes).to.include(ast.root.first.type);
+                });
+            });
+
+            it('should allow pseudo-classes', function () {
+                const source = '.foo:hover {}';
+
+                return spec.parse(source, function (ast) {
+                    const result = spec.linter.lint({}, ast.root.first);
+
+                    expect(result).to.be.undefined;
+                });
+            });
+
+            it('should not allow incorrect number of colons', function () {
+                const source = '.foo::hover {}';
+                const expected = [{
+                    column: 5,
+                    line: 1,
+                    message: 'Pseudo-classes should have 1 colon.'
+                }];
+
+                const options = {};
+
+                return spec.parse(source, function (ast) {
+                    const result = spec.linter.lint(options, ast.root.first);
+
+                    expect(result).to.deep.equal(expected);
+                });
+            });
+
+            it('should report all errors at once', function () {
+                const source = '.foo::hover, .foo::focus {}';
+                const expected = [{
+                    column: 5,
+                    line: 1,
+                    message: 'Pseudo-classes should have 1 colon.'
+                }, {
+                    column: 18,
+                    line: 1,
+                    message: 'Pseudo-classes should have 1 colon.'
+                }];
+
+                const options = {};
+
+                return spec.parse(source, function (ast) {
+                    const result = spec.linter.lint(options, ast.root.first);
+
+                    expect(result).to.deep.equal(expected);
+                });
+            });
+        });
+
+        describe('with both', function () {
+            it('should report both if chained', function () {
+                const source = '.foo:before::hover {}';
+                const expected = [{
+                    column: 5,
+                    line: 1,
+                    message: 'Pseudo-elements should have 2 colons.'
+                }, {
+                    column: 12,
+                    line: 1,
+                    message: 'Pseudo-classes should have 1 colon.'
+                }];
+
+                const options = {};
+
+                return spec.parse(source, function (ast) {
+                    const result = spec.linter.lint(options, ast.root.first);
+
+                    expect(result).to.deep.equal(expected);
+                });
+            });
+
+            it('should exclude configurable "pseudo"', function () {
+                const source = '.foo:bar::baz {}';
+                const config = {
+                    exclude: [
+                        'bar',
+                        'baz',
+                    ],
+                };
+
+                return spec.parse(source, function (ast) {
+                    const result = spec.linter.lint(config, ast.root.first);
+
+                    expect(result).to.be.undefined;
+                });
+            });
+        });
+    });
+});