Skip to content

Commit

Permalink
Add no-unnecessary-polyfills rule
Browse files Browse the repository at this point in the history
Fixes: #36
  • Loading branch information
Mesteery committed Feb 16, 2022
1 parent 9de8a44 commit 3b61fc1
Show file tree
Hide file tree
Showing 6 changed files with 482 additions and 0 deletions.
1 change: 1 addition & 0 deletions configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ module.exports = {
'unicorn/no-static-only-class': 'error',
'unicorn/no-thenable': 'error',
'unicorn/no-this-assignment': 'error',
'unicorn/no-unnecessary-polyfills': 'error',
'unicorn/no-unreadable-array-destructuring': 'error',
'unicorn/no-unsafe-regex': 'off',
'unicorn/no-unused-properties': 'off',
Expand Down
76 changes: 76 additions & 0 deletions docs/rules/no-unnecessary-polyfills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Enforce the use of built-in methods instead of unnecessary polyfills

<!-- Do not manually modify RULE_NOTICE part. Run: `npm run generate-rule-notices` -->
<!-- RULE_NOTICE -->
*This rule is part of the [recommended](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config) config.*
<!-- /RULE_NOTICE -->

This rules helps to use existing methods instead of using extra polyfills.

## Fail

package.json

```json
{
"engines": {
"node": ">=8"
}
}
```

```js
const assign = require('object-assign');
```

## Pass

package.json

```json
{
"engines": {
"node": "4"
}
}
```

```js
const assign = require('object-assign'); // passes as Object.assign is not supported
```

## Options

Type: `object`

### targets

Type: `string | string[] | object`

The `targets` option allows to specify the target versions. This option could be a Browserlist query or a targets object, see [core-js-compat `targets` option](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js-compat#targets-option) for more informations. It could also be a Node.js target version (SemVer syntax supported) if the [`treatsTargetsAsSemver` option](#treatsTargetsAsSemver) is set to `true`.

If the option is unspecified, the targets are taken from the `package.json`, from the `engines.node` field (as SemVer Node.js target version), or alternatively, from the `browserlist` field (as Browserlist query). This logic can be reversed by setting the [`useBrowserlistFieldByDefault` option](#useBrowserlistFieldByDefault) to `true`.

```js
"unicorn/no-unnecessary-polyfills": ["error", { "targets": "node >=12" }]
```

```js
"unicorn/no-unnecessary-polyfills": ["error", { "targets": ["node 14.1.0", "chrome 95"] }]
```

```js
"unicorn/no-unnecessary-polyfills": ["error", { "targets": { "node": "current", "firefox": "15" } }]
```

### treatsTargetsAsSemver

Type: `boolean`

By default, the `targets` option is treated as a Browserlist query or a targets object. If you want to treat it as a Node.js target version (SemVer syntax supported), set this option to `true`.

### useBrowserlistFieldByDefault

Type: `boolean`

When the `targets` option is not specified, the Node.js target version is taken from the `package.json` file, in `engines.node` field, and fallback to Browserlist targets, in `browserlist` field. If this option is set to `true`, the logic is reversed: the targets will be taken from the `browserlist` field, and fallback to `engines.node` field.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@babel/helper-validator-identifier": "^7.15.7",
"ci-info": "^3.3.0",
"clean-regexp": "^1.0.0",
"core-js-compat": "^3.21.0",
"eslint-utils": "^3.0.0",
"esquery": "^1.4.0",
"indent-string": "^4.0.0",
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Each rule has emojis denoting:
| [no-static-only-class](docs/rules/no-static-only-class.md) | Forbid classes that only have static members. || 🔧 | |
| [no-thenable](docs/rules/no-thenable.md) | Disallow `then` property. || | |
| [no-this-assignment](docs/rules/no-this-assignment.md) | Disallow assigning `this` to a variable. || | |
| [no-unnecessary-polyfills](docs/rules/no-unnecessary-polyfills.md) | Enforce the use of built-in methods instead of unnecessary polyfills. || | |
| [no-unreadable-array-destructuring](docs/rules/no-unreadable-array-destructuring.md) | Disallow unreadable array destructuring. || 🔧 | |
| [no-unsafe-regex](docs/rules/no-unsafe-regex.md) | Disallow unsafe regular expressions. | | | |
| [no-unused-properties](docs/rules/no-unused-properties.md) | Disallow unused object properties. | | | |
Expand Down
251 changes: 251 additions & 0 deletions rules/no-unnecessary-polyfills.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
'use strict';
const semver = require('semver');
const readPkgUp = require('read-pkg-up');
const coreJsCompat = require('core-js-compat');
const {camelCase, upperFirst} = require('lodash');

const {data: compatData, entries: coreJsEntries} = coreJsCompat;

const MESSAGE_ID_POLYFILL = 'unnecessaryPolyfill';
const MESSAGE_ID_CORE_JS = 'unnecessaryCoreJsModule';
const messages = {
[MESSAGE_ID_POLYFILL]: 'Use the built-in `{{featureName}}`.',
[MESSAGE_ID_CORE_JS]: 'All polyfilled features imported from `{{coreJsModule}}` are disponible as built-ins. Use the built-ins instead.',
};

function getTargetsFromPkg(cwd, useBrowserlistFieldByDefault) {
const result = readPkgUp.sync({cwd});
if (!result || !result.pkg) {
return;
}

const {browserlist} = result.pkg;
const nodeEngine = result.pkg.engines && result.pkg.engines.node && new SemverNodeVersion(result.pkg.engines.node);
return useBrowserlistFieldByDefault ? browserlist || nodeEngine : nodeEngine || browserlist;
}

const constructorCaseExceptions = {
regexp: 'RegExp',
};
function constructorCase(name) {
return constructorCaseExceptions[name] || upperFirst(camelCase(name));
}

const additionalPolyfillPatterns = {
'es.promise.finally': '|(p-finally)',
'es.object.set-prototype-of': '|(setprototypeof)',
'es.string.code-point-at': '|(code-point-at)',
};

const prefixes = '(mdn-polyfills|polyfill-)';
const suffixes = '(-polyfill)';
const delimiter = '(\\.|-|\\.prototype\\.|/)?';

const polyfills = Object.keys(compatData).map(feature => {
let [ecmaVersion, constructorName, methodName = ''] = feature.split('.');

if (ecmaVersion === 'es') {
ecmaVersion = `(${ecmaVersion}\\d*)`;
}

constructorName = `(${constructorName}|${camelCase(constructorName)})`;
if (methodName) {
methodName = `(${methodName}|${camelCase(methodName)})`;
}

const methodOrConstructor = methodName || constructorName;

return {
feature,
pattern: new RegExp(
`^((${prefixes}?`
+ `(${ecmaVersion}${delimiter}${constructorName}${delimiter}${methodName}|` // Ex: es6-array-copy-within
+ `${constructorName}${delimiter}${methodName}|` // Ex: array-copy-within
+ `${ecmaVersion}${delimiter}${constructorName})` // Ex: es6-array
+ `${suffixes}?)|`
+ `(${prefixes}${methodOrConstructor}|${methodOrConstructor}${suffixes})` // Ex: polyfill-copy-within / polyfill-promise
+ `${additionalPolyfillPatterns[feature] || ''})$`,
'i',
),
};
});

function report(context, node, feature) {
let [ecmaVersion, namespace, method = ''] = feature.split('.');
if (namespace === 'typed-array' && method.endsWith('-array')) {
namespace = method;
method = '';
}

const delimiter = method && (ecmaVersion === 'node' ? '.' : '#');

context.report({
node,
messageId: MESSAGE_ID_POLYFILL,
data: {
featureName: `${constructorCase(namespace)}${delimiter}${camelCase(method)}`,
},
});
}

class SemverNodeVersion {
constructor(nodeVersion) {
this.nodeVersion = nodeVersion;
this.validNodeVersion = semver.coerce(nodeVersion);
}

compare(featureVersion) {
const supportedNodeVersion = semver.coerce(featureVersion);
return this.validNodeVersion
? semver.lte(supportedNodeVersion, this.validNodeVersion)
: semver.ltr(supportedNodeVersion, this.nodeVersion);
}
}

function processRule(context, node, moduleName, targets) {
if (!moduleName || typeof moduleName !== 'string') {
return;
}

const nodeVersion = targets[0] instanceof SemverNodeVersion && targets[0];
const importedModule = moduleName.replace(/([/\\].+?)\.[^.]+$/, '$1');

const unavailableFeatures = coreJsCompat({targets}).list;
const coreJsModuleFeatures = coreJsEntries[importedModule.replace('core-js-pure', 'core-js')];

const checkFeatures = features => nodeVersion
? features.every(feature => compatData[feature].node && nodeVersion.compare(compatData[feature].node))
: !features.every(feature => unavailableFeatures.includes(feature));

if (coreJsModuleFeatures) {
if (coreJsModuleFeatures.length === 1) {
if (nodeVersion ? nodeVersion.compare(compatData[coreJsModuleFeatures[0]].node) : !unavailableFeatures.includes(coreJsModuleFeatures[0])) {
report(context, node, coreJsModuleFeatures[0]);
}
} else if (checkFeatures(coreJsModuleFeatures)) {
context.report({
node,
messageId: MESSAGE_ID_CORE_JS,
data: {
coreJsModule: moduleName,
},
});
}

return;
}

const polyfill = polyfills.find(({pattern}) => pattern.test(importedModule));
if (polyfill) {
const [, namespace, method = ''] = polyfill.feature.split('.');
const [, features] = Object.entries(coreJsEntries).find(it => it[0] === `core-js/actual/${namespace}${method && '/'}${method}`);
if (checkFeatures(features)) {
report(context, node, polyfill.feature);
}
}
}

function create(context) {
const options = context.options[0];
let targets = options && options.targets;
if (!targets) {
getTargetsFromPkg(context.getFilename(), options && options.useBrowserlistFieldByDefault);
} else if (options.treatsTargetsAsSemver) {
targets = new SemverNodeVersion(targets);
}

if (!targets) {
return {};
}

return {
'CallExpression[callee.name="require"]'(node) {
processRule(context, node, node.arguments[0].value, targets);
},
'ImportDeclaration, ImportExpression'(node) {
processRule(context, node, node.source.value, targets);
},
};
}

const schema = [
{
type: 'object',
additionalProperties: false,
required: ['targets'],
properties: {
useBrowserlistFieldByDefault: {type: 'boolean'},
treatsTargetsAsSemver: {type: 'boolean'},
targets: {
oneOf: [
{
type: 'string',
minLength: 1,
},
{
type: 'array',
minItems: 1,
items: {
type: 'string',
},
},
{
type: 'object',
minProperties: 1,
properties: {
android: {type: 'string'},
chrome: {type: 'string'},
deno: {type: 'string'},
edge: {type: 'string'},
electron: {type: 'string'},
firefox: {type: 'string'},
ie: {type: 'string'},
ios: {type: 'string'},
node: {type: 'string'},
opera: {type: 'string'},
// eslint-disable-next-line camelcase
opera_mobile: {type: 'string'},
phantom: {type: 'string'},
rhino: {type: 'string'},
safari: {type: 'string'},
samsung: {type: 'string'},
esmodules: {type: 'boolean'},
browsers: {
oneOf: [
{
type: 'string',
minLength: 1,
},
{
type: 'array',
minItems: 1,
items: {
type: 'string',
},
},
{
type: 'object',
minProperties: 1,
},
],
},
},
},
],
},
},
},
];

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce the use of built-in methods instead of unnecessary polyfills.',
},
schema,
messages,
},
};
Loading

0 comments on commit 3b61fc1

Please sign in to comment.