Skip to content

Commit

Permalink
Add prefer-query-selector rule (#198)
Browse files Browse the repository at this point in the history
Fixes #171
  • Loading branch information
janowsiany authored and sindresorhus committed Jan 14, 2019
1 parent db6f62e commit a44e16c
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 2 deletions.
25 changes: 25 additions & 0 deletions docs/rules/prefer-query-selector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Prefer `querySelector` over `getElementById`, `querySelectorAll` over `getElementsByClassName` and `getElementsByTagName`

They are not faster than `querySelector` and it's better to be consistent.


## Fail

```js
document.getElementById('foo');
document.getElementsByClassName('foo bar');
document.getElementsByTagName('main');
document.getElementsByClassName(fn());
```


## Pass

```js
document.querySelector('#foo');
document.querySelector('.bar');
document.querySelector('main #foo .bar');
document.querySelectorAll('.foo .bar');
document.querySelectorAll('li a');
document.querySelector('li').querySelectorAll('a');
```
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ module.exports = {
'unicorn/no-console-spaces': 'error',
'unicorn/no-unreadable-array-destructuring': 'error',
'unicorn/no-unused-properties': 'off',
'unicorn/prefer-node-append': 'error'
'unicorn/prefer-node-append': 'error',
'unicorn/prefer-query-selector': 'error'
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ Configure it in `package.json`.
"unicorn/no-console-spaces": "error",
"unicorn/no-unreadable-array-destructuring": "error",
"unicorn/no-unused-properties": "off",
"unicorn/prefer-node-append": "error"
"unicorn/prefer-node-append": "error",
"unicorn/prefer-query-selector": "error"
}
}
}
Expand Down Expand Up @@ -106,6 +107,7 @@ Configure it in `package.json`.
- [no-unreadable-array-destructuring](docs/rules/no-unreadable-array-destructuring.md) - Disallow unreadable array destructuring.
- [no-unused-properties](docs/rules/no-unused-properties.md) - Disallow unused object properties.
- [prefer-node-append](docs/rules/prefer-node-append.md) - Prefer `append` over `appendChild`. *(fixable)*
- [prefer-query-selector](docs/rules/prefer-query-selector.md) - Prefer `querySelector` over `getElementById`, `querySelectorAll` over `getElementsByClassName` and `getElementsByTagName`. *(partly fixable)*


## Recommended config
Expand Down
117 changes: 117 additions & 0 deletions rules/prefer-query-selector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
'use strict';
const getDocsUrl = require('./utils/get-docs-url');

const forbiddenIdentifierNames = new Map([
['getElementById', 'querySelector'],
['getElementsByClassName', 'querySelectorAll'],
['getElementsByTagName', 'querySelectorAll']
]);

const getReplacementForId = value => `#${value}`;
const getReplacementForClass = value => value.match(/\S+/g).map(e => `.${e}`).join('');
const getQuotedReplacement = (node, value) => {
const leftQuote = node.raw.charAt(0);
const rightQuote = node.raw.charAt(node.raw.length - 1);
return `${leftQuote}${value}${rightQuote}`;
};

const getLiteralFix = (fixer, node, identifierName) => {
let replacement = node.raw;
if (identifierName === 'getElementById') {
replacement = getQuotedReplacement(node, getReplacementForId(node.value));
}

if (identifierName === 'getElementsByClassName') {
replacement = getQuotedReplacement(node, getReplacementForClass(node.value));
}

return [fixer.replaceText(node, replacement)];
};

const getTemplateLiteralFix = (fixer, node, identifierName) => {
const fix = [fixer.insertTextAfter(node, '`'), fixer.insertTextBefore(node, '`')];

node.quasis.forEach(templateElement => {
if (identifierName === 'getElementById') {
fix.push(fixer.replaceText(templateElement, getReplacementForId(templateElement.value.cooked)));
}

if (identifierName === 'getElementsByClassName') {
fix.push(fixer.replaceText(templateElement, getReplacementForClass(templateElement.value.cooked)));
}
});

return fix;
};

const canBeFixed = node => {
if (node.type === 'Literal') {
return node.value === null || Boolean(node.value.trim());
}

if (node.type === 'TemplateLiteral') {
return node.expressions.length === 0 &&
node.quasis.some(templateElement => templateElement.value.cooked.trim());
}

return false;
};

const hasValue = node => {
if (node.type === 'Literal') {
return node.value;
}

return true;
};

const fix = (node, identifierName, preferedSelector) => {
const nodeToBeFixed = node.arguments[0];
if (identifierName === 'getElementsByTagName' || !hasValue(nodeToBeFixed)) {
return fixer => fixer.replaceText(node.callee.property, preferedSelector);
}

const getArgumentFix = nodeToBeFixed.type === 'Literal' ? getLiteralFix : getTemplateLiteralFix;
return fixer => [
...getArgumentFix(fixer, nodeToBeFixed, identifierName),
fixer.replaceText(node.callee.property, preferedSelector)
];
};

const create = context => {
return {
CallExpression(node) {
const {callee: {property, type}} = node;
if (!property || type !== 'MemberExpression') {
return;
}

const identifierName = property.name;
const preferedSelector = forbiddenIdentifierNames.get(identifierName);
if (!preferedSelector) {
return;
}

const report = {
node,
message: `Prefer \`${preferedSelector}\` over \`${identifierName}\`.`
};

if (canBeFixed(node.arguments[0])) {
report.fix = fix(node, identifierName, preferedSelector);
}

context.report(report);
}
};
};

module.exports = {
create,
meta: {
docs: {
url: getDocsUrl(__filename)
},
fixable: 'code'
}
};
124 changes: 124 additions & 0 deletions test/prefer-query-selector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import test from 'ava';
import avaRuleTester from 'eslint-ava-rule-tester';
import rule from '../rules/prefer-query-selector';

const ruleTester = avaRuleTester(test, {
env: {
es6: true
}
});

ruleTester.run('prefer-query-selector', rule, {
valid: [
'document.querySelector("#foo");',
'document.querySelector(".bar");',
'document.querySelector("main #foo .bar");',
'document.querySelectorAll(".foo .bar");',
'document.querySelectorAll("li a");',
'document.querySelector("li").querySelectorAll("a");'
],
invalid: [
{
code: 'document.getElementById("foo");',
errors: [{message: 'Prefer `querySelector` over `getElementById`.'}],
output: 'document.querySelector("#foo");'
},
{
code: 'document.getElementsByClassName("foo");',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}],
output: 'document.querySelectorAll(".foo");'
},
{
code: 'document.getElementsByClassName("foo bar");',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}],
output: 'document.querySelectorAll(".foo.bar");'
},
{
code: 'document.getElementsByTagName("foo");',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByTagName`.'}],
output: 'document.querySelectorAll("foo");'
},
{
code: 'document.getElementById("");',
errors: [{message: 'Prefer `querySelector` over `getElementById`.'}]
},
{
code: 'document.getElementById(\'foo\');',
errors: [{message: 'Prefer `querySelector` over `getElementById`.'}],
output: 'document.querySelector(\'#foo\');'
},
{
code: 'document.getElementsByClassName(\'foo\');',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}],
output: 'document.querySelectorAll(\'.foo\');'
},
{
code: 'document.getElementsByClassName(\'foo bar\');',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}],
output: 'document.querySelectorAll(\'.foo.bar\');'
},
{
code: 'document.getElementsByTagName(\'foo\');',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByTagName`.'}],
output: 'document.querySelectorAll(\'foo\');'
},
{
code: 'document.getElementsByClassName(\'\');',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}]
},
{
code: 'document.getElementById(`foo`);',
errors: [{message: 'Prefer `querySelector` over `getElementById`.'}],
output: 'document.querySelector(`#foo`);'
},
{
code: 'document.getElementsByClassName(`foo`);',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}],
output: 'document.querySelectorAll(`.foo`);'
},
{
code: 'document.getElementsByClassName(`foo bar`);',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}],
output: 'document.querySelectorAll(`.foo.bar`);'
},
{
code: 'document.getElementsByTagName(`foo`);',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByTagName`.'}],
output: 'document.querySelectorAll(`foo`);'
},
{
code: 'document.getElementsByTagName(``);',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByTagName`.'}]
},
{
code: 'document.getElementsByClassName(`${fn()}`);', // eslint-disable-line no-template-curly-in-string
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}]
},
{
code: 'document.getElementsByClassName(`foo ${undefined}`);', // eslint-disable-line no-template-curly-in-string
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}]
},
{
code: 'document.getElementsByClassName(null);',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}],
output: 'document.querySelectorAll(null);'
},
{
code: 'document.getElementsByTagName(null);',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByTagName`.'}],
output: 'document.querySelectorAll(null);'
},
{
code: 'document.getElementsByClassName(fn());',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}]
},
{
code: 'document.getElementsByClassName("foo" + fn());',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}]
},
{
code: 'document.getElementsByClassName(foo + "bar");',
errors: [{message: 'Prefer `querySelectorAll` over `getElementsByClassName`.'}]
}
]
});

0 comments on commit a44e16c

Please sign in to comment.