230 lines
5.4 KiB
JavaScript
230 lines
5.4 KiB
JavaScript
'use strict';
|
|
const {isSemicolonToken} = require('@eslint-community/eslint-utils');
|
|
const getClassHeadLocation = require('./utils/get-class-head-location.js');
|
|
const assertToken = require('./utils/assert-token.js');
|
|
const {removeSpacesAfter} = require('./fix/index.js');
|
|
|
|
const MESSAGE_ID = 'no-static-only-class';
|
|
const messages = {
|
|
[MESSAGE_ID]: 'Use an object instead of a class with only static members.',
|
|
};
|
|
|
|
const selector = [
|
|
':matches(ClassDeclaration, ClassExpression)',
|
|
':not([superClass], [decorators.length>0])',
|
|
'[body.type="ClassBody"]',
|
|
'[body.body.length>0]',
|
|
].join('');
|
|
|
|
const isEqualToken = ({type, value}) => type === 'Punctuator' && value === '=';
|
|
const isDeclarationOfExportDefaultDeclaration = node =>
|
|
node.type === 'ClassDeclaration'
|
|
&& node.parent.type === 'ExportDefaultDeclaration'
|
|
&& node.parent.declaration === node;
|
|
|
|
const isPropertyDefinition = node => node.type === 'PropertyDefinition';
|
|
const isMethodDefinition = node => node.type === 'MethodDefinition';
|
|
|
|
function isStaticMember(node) {
|
|
const {
|
|
private: isPrivate,
|
|
static: isStatic,
|
|
declare: isDeclare,
|
|
readonly: isReadonly,
|
|
accessibility,
|
|
decorators,
|
|
key,
|
|
} = node;
|
|
|
|
// Avoid matching unexpected node. For example: https://github.com/tc39/proposal-class-static-block
|
|
if (!isPropertyDefinition(node) && !isMethodDefinition(node)) {
|
|
return false;
|
|
}
|
|
|
|
if (!isStatic || isPrivate || key.type === 'PrivateIdentifier') {
|
|
return false;
|
|
}
|
|
|
|
// TypeScript class
|
|
if (
|
|
isDeclare
|
|
|| isReadonly
|
|
|| accessibility !== undefined
|
|
|| (Array.isArray(decorators) && decorators.length > 0)
|
|
// TODO: Remove this when we drop support for `@typescript-eslint/parser` v4
|
|
|| key.type === 'TSPrivateIdentifier'
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function * switchClassMemberToObjectProperty(node, sourceCode, fixer) {
|
|
const staticToken = sourceCode.getFirstToken(node);
|
|
assertToken(staticToken, {
|
|
expected: {type: 'Keyword', value: 'static'},
|
|
ruleId: 'no-static-only-class',
|
|
});
|
|
|
|
yield fixer.remove(staticToken);
|
|
yield removeSpacesAfter(staticToken, sourceCode, fixer);
|
|
|
|
const maybeSemicolonToken = isPropertyDefinition(node)
|
|
? sourceCode.getLastToken(node)
|
|
: sourceCode.getTokenAfter(node);
|
|
const hasSemicolonToken = isSemicolonToken(maybeSemicolonToken);
|
|
|
|
if (isPropertyDefinition(node)) {
|
|
const {key, value} = node;
|
|
|
|
if (value) {
|
|
// Computed key may have `]` after `key`
|
|
const equalToken = sourceCode.getTokenAfter(key, isEqualToken);
|
|
yield fixer.replaceText(equalToken, ':');
|
|
} else if (hasSemicolonToken) {
|
|
yield fixer.insertTextBefore(maybeSemicolonToken, ': undefined');
|
|
} else {
|
|
yield fixer.insertTextAfter(node, ': undefined');
|
|
}
|
|
}
|
|
|
|
yield (
|
|
hasSemicolonToken
|
|
? fixer.replaceText(maybeSemicolonToken, ',')
|
|
: fixer.insertTextAfter(node, ',')
|
|
);
|
|
}
|
|
|
|
function switchClassToObject(node, sourceCode) {
|
|
const {
|
|
type,
|
|
id,
|
|
body,
|
|
declare: isDeclare,
|
|
abstract: isAbstract,
|
|
implements: classImplements,
|
|
parent,
|
|
} = node;
|
|
|
|
if (
|
|
isDeclare
|
|
|| isAbstract
|
|
|| (Array.isArray(classImplements) && classImplements.length > 0)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (type === 'ClassExpression' && id) {
|
|
return;
|
|
}
|
|
|
|
const isExportDefault = isDeclarationOfExportDefaultDeclaration(node);
|
|
|
|
if (isExportDefault && id) {
|
|
return;
|
|
}
|
|
|
|
for (const node of body.body) {
|
|
if (
|
|
isPropertyDefinition(node)
|
|
&& (
|
|
node.typeAnnotation
|
|
// This is a stupid way to check if `value` of `PropertyDefinition` uses `this`
|
|
|| (node.value && sourceCode.getText(node.value).includes('this'))
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
return function * (fixer) {
|
|
const classToken = sourceCode.getFirstToken(node);
|
|
/* c8 ignore next */
|
|
assertToken(classToken, {
|
|
expected: {type: 'Keyword', value: 'class'},
|
|
ruleId: 'no-static-only-class',
|
|
});
|
|
|
|
if (isExportDefault || type === 'ClassExpression') {
|
|
/*
|
|
There are comments after return, and `{` is not on same line
|
|
|
|
```js
|
|
function a() {
|
|
return class // comment
|
|
{
|
|
static a() {}
|
|
}
|
|
}
|
|
```
|
|
*/
|
|
if (
|
|
type === 'ClassExpression'
|
|
&& parent.type === 'ReturnStatement'
|
|
&& body.loc.start.line !== parent.loc.start.line
|
|
&& sourceCode.text.slice(classToken.range[1], body.range[0]).trim()
|
|
) {
|
|
yield fixer.replaceText(classToken, '{');
|
|
|
|
const openingBraceToken = sourceCode.getFirstToken(body);
|
|
yield fixer.remove(openingBraceToken);
|
|
} else {
|
|
yield fixer.replaceText(classToken, '');
|
|
|
|
/*
|
|
Avoid breaking case like
|
|
|
|
```js
|
|
return class
|
|
{};
|
|
```
|
|
*/
|
|
yield removeSpacesAfter(classToken, sourceCode, fixer);
|
|
}
|
|
|
|
// There should not be ASI problem
|
|
} else {
|
|
yield fixer.replaceText(classToken, 'const');
|
|
yield fixer.insertTextBefore(body, '= ');
|
|
yield fixer.insertTextAfter(body, ';');
|
|
}
|
|
|
|
for (const node of body.body) {
|
|
yield * switchClassMemberToObjectProperty(node, sourceCode, fixer);
|
|
}
|
|
};
|
|
}
|
|
|
|
function create(context) {
|
|
const sourceCode = context.getSourceCode();
|
|
|
|
return {
|
|
[selector](node) {
|
|
if (node.body.body.some(node => !isStaticMember(node))) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
node,
|
|
loc: getClassHeadLocation(node, sourceCode),
|
|
messageId: MESSAGE_ID,
|
|
fix: switchClassToObject(node, sourceCode),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
module.exports = {
|
|
create,
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description: 'Disallow classes that only have static members.',
|
|
},
|
|
fixable: 'code',
|
|
messages,
|
|
},
|
|
};
|