Skip to content

Commit

Permalink
fix: extend no-deprecated-classes to handle expressions
Browse files Browse the repository at this point in the history
  • Loading branch information
kelsos committed Jan 23, 2024
1 parent f6dbec0 commit 2b78b71
Show file tree
Hide file tree
Showing 6 changed files with 289 additions and 49 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@typescript-eslint/parser": "6.19.0",
"@typescript-eslint/rule-tester": "6.19.0",
"bumpp": "9.3.0",
"debug": "4.3.4",
"eslint": "8.56.0",
"husky": "8.0.3",
"lint-staged": "15.2.0",
Expand Down
227 changes: 179 additions & 48 deletions src/rules/no-deprecated-classes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { createEslintRule, defineTemplateBodyVisitor, getSourceCode } from '../utils';
import type { Range } from '../types';
import debugFactory from 'debug';
import { createEslintRule, defineTemplateBodyVisitor, getSourceCode, getStaticPropertyName } from '../utils';
import type {
ESLintExpression,
VExpressionContainer,
VFilterSequenceExpression,
VForExpression,
VGenericExpression,
VOnExpression,
VSlotScopeExpression,
} from 'vue-eslint-parser/ast/nodes';
import type { Range, RuleContext } from '../types';
import type { AST as VAST } from 'vue-eslint-parser';

export const RULE_NAME = 'no-deprecated-classes';
Expand All @@ -8,6 +18,8 @@ export type MessageIds = 'replacedWith';

export type Options = [];

const debug = debugFactory('@rotki/eslint-plugin:no-deprecated-classes');

type StringReplacer = [string, string];

type RegexReplacer = [RegExp, (args: string[]) => string];
Expand Down Expand Up @@ -46,57 +58,176 @@ function isRegex(replacement: Replacer): replacement is RegexReplacer {
return replacement[0] instanceof RegExp;
}

function findReplacement(className: string): string | undefined {
for (const replacement of replacements) {
if (isString(replacement) && replacement[0] === className)
return replacement[1];

if (isRegex(replacement)) {
const matches = (replacement[0].exec(className) || []).slice(1);
const replace = replacement[1];
if (matches.length > 0 && typeof replace === 'function')
return replace(matches);
}
}
return undefined;
}

function getRange(node: VAST.VAttribute | ExpressionType | VAST.ESLintTemplateElement): VAST.OffsetRange {
if (node.type === 'VAttribute' && node.value && node.value.range)
return node.value.range;

return node.range;
}

function reportReplacement(
className: string,
replacement: string,
node: VAST.VAttribute | ExpressionType | VAST.ESLintTemplateElement,
context: RuleContext<MessageIds, Options>,
position: number = 1,
): void {
debug(`found replacement ${replacement} for ${className}`);

const source = getSourceCode(context);

const initialRange = getRange(node);

const range: Range = [
initialRange[0] + position,
initialRange[0] + position + className.length,
];

const loc = {
end: source.getLocFromIndex(range[1]),
start: source.getLocFromIndex(range[0]),
};

context.report({
data: {
className,
replacement,
},
fix(fixer) {
return fixer.replaceTextRange(range, replacement);
},
loc,
messageId: 'replacedWith',
});
}

type ExpressionType =
ESLintExpression
| VFilterSequenceExpression
| VForExpression
| VOnExpression
| VSlotScopeExpression
| VGenericExpression;

interface FoundClass {
className: string;
reportNode: ExpressionType | VAST.ESLintTemplateElement;
position: number;
}

function* extractClassNames(
node: ExpressionType,
textOnly: boolean = false,
): IterableIterator<FoundClass> {
if (node.type === 'Literal') {
const classNames = `${node.value}`;
yield * classNames
.split(/\s+/)
.map(className => ({ className, position: classNames.indexOf(className) + 1, reportNode: node }));
return;
}
if (node.type === 'TemplateLiteral') {
for (const templateElement of node.quasis) {
const classNames = templateElement.value.cooked;
if (classNames === null)
continue;

yield * classNames
.split(/\s+/)
.map(className => ({ className, position: classNames.indexOf(className) + 1, reportNode: templateElement }));
}
for (const expr of node.expressions)
yield * extractClassNames(expr, true);

return;
}
if (node.type === 'BinaryExpression') {
if (node.operator !== '+')
return;

yield * extractClassNames(node.left as ESLintExpression, true);
yield * extractClassNames(node.right, true);
return;
}
if (textOnly)
return;

if (node.type === 'ObjectExpression') {
for (const prop of node.properties) {
if (prop.type !== 'Property')
continue;

const classNames = getStaticPropertyName(prop);
if (!classNames)
continue;

yield * classNames
.split(/\s+/)
.map(className => ({ className, position: classNames.indexOf(className) + 1, reportNode: prop.key }));
}
return;
}
if (node.type === 'ArrayExpression') {
for (const element of node.elements) {
if (element == null)
continue;

if (element.type === 'SpreadElement')
continue;

yield * extractClassNames(element);
}
}

if (node.type === 'ConditionalExpression') {
yield * extractClassNames(node.consequent);
yield * extractClassNames(node.alternate);
}
}

export default createEslintRule<Options, MessageIds>({
create(context) {
return defineTemplateBodyVisitor(context, {
'VAttribute[key.name="class"]': function (node: VAST.VAttribute) {
'VAttribute[directive=false][key.name="class"]': function (node: VAST.VAttribute) {
if (!node.value || !node.value.value)
return;

const classes = node.value.value.split(/\s+/).filter(s => !!s);
const source = getSourceCode(context);

const replaced: StringReplacer[] = [];

classes.forEach((className) => {
for (const replacement of replacements) {
if (isString(replacement) && replacement[0] === className)
replaced.push([className, replacement[1]]);

if (isRegex(replacement)) {
const matches = (replacement[0].exec(className) || []).slice(1);
const replace = replacement[1];
if (matches.length > 0 && typeof replace === 'function')
return replaced.push([className, replace(matches)]);
}
}
});

replaced.forEach((replacement) => {
if (!node.value)
return;

const idx = node.value.value.indexOf(replacement[0]) + 1;
const range: Range = [
node.value.range[0] + idx,
node.value.range[0] + idx + replacement[0].length,
];
const loc = {
end: source.getLocFromIndex(range[1]),
start: source.getLocFromIndex(range[0]),
};
context.report({
data: {
a: replacement[0],
b: replacement[1],
},
fix(fixer) {
return fixer.replaceTextRange(range, replacement[1]);
},
loc,
messageId: 'replacedWith',
});
});
for (const className of node.value.value.split(/\s+/).filter(s => !!s)) {
const replacement = findReplacement(className);
const position = node.value.value.indexOf(className) + 1;

if (!replacement)
continue;

reportReplacement(className, replacement, node, context, position);
}
},
'VAttribute[directive=true][key.name.name=\'bind\'][key.argument.name=\'class\'] > VExpressionContainer.value': function (node: VExpressionContainer) {
if (!node.expression)
return;

for (const { className, position, reportNode } of extractClassNames(node.expression)) {
const replacement = findReplacement(className);
if (!replacement)
continue;

reportReplacement(className, replacement, reportNode, context, position);
}
},
});
},
Expand All @@ -108,7 +239,7 @@ export default createEslintRule<Options, MessageIds>({
},
fixable: 'code',
messages: {
replacedWith: `'{{ a }}' has been replaced with '{{ b }}'`,
replacedWith: `'{{ className }}' has been replaced with '{{ replacement }}'`,
},
schema: [],
type: 'problem',
Expand Down
3 changes: 3 additions & 0 deletions src/utils/array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function removeDuplicates<T>(array: T[]): T[] {
return [...new Set(array)];
}
6 changes: 6 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ export * from './compat';
export * from './rule';

export * from './visitor';

export * from './node';

export * from './config';

export * from './array';
51 changes: 51 additions & 0 deletions src/utils/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { ESLintNode } from 'vue-eslint-parser/ast/nodes';

function getStringLiteralValue(node: ESLintNode, stringOnly: boolean = false) {
if (node.type === 'Literal') {
if (node.value == null) {
if (!stringOnly && node.bigint != null)
return node.bigint;

return null;
}
if (typeof node.value === 'string')
return node.value;

if (!stringOnly)
return String(node.value);

return null;
}
if (
node.type === 'TemplateLiteral'
&& node.expressions.length === 0
&& node.quasis.length === 1
)
return node.quasis[0].value.cooked;

return null;
}

export function getStaticPropertyName(node: ESLintNode) {
if (node.type === 'Property' || node.type === 'MethodDefinition') {
if (!node.computed) {
const key = node.key;
if (key.type === 'Identifier')
return key.name;
}
const key = node.key;
return getStringLiteralValue(key);
}
else if (node.type === 'MemberExpression') {
if (!node.computed) {
const property = node.property;
if (property.type === 'Identifier')
return property.name;

return null;
}
const property = node.property;
return getStringLiteralValue(property);
}
return null;
}
50 changes: 49 additions & 1 deletion tests/rules/no-deprecated-classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const vueParser = require.resolve('vue-eslint-parser');

const tester = new RuleTester({
parser: vueParser,
parserOptions: { ecmaVersion: 2015 },
parserOptions: { ecmaVersion: 2021, sourceType: 'module' },
});

tester.run(RULE_NAME, rule as never, {
Expand Down Expand Up @@ -71,5 +71,53 @@ tester.run(RULE_NAME, rule as never, {
output: '<template><div class="uppercase"/></template>',
errors: [{ messageId: 'replacedWith' }],
},
{
code: `
<script>
export default {
data() {
return {
classA: 'align-self-center',
classB: 'text-uppercase',
enable: false
}
}
}
</script>
<template>
<div>
<span :class="['abc text-uppercase cl', classA]"/>
<span :class="{ 'abc text-uppercase cl': true, 'font-weight-bold': true, [classB]: true }"/>
<span :class="enable ? 'abc text-uppercase cl' : 'lower'"/>
</div>
</template>
`,
output: `
<script>
export default {
data() {
return {
classA: 'align-self-center',
classB: 'text-uppercase',
enable: false
}
}
}
</script>
<template>
<div>
<span :class="['abc uppercase cl', classA]"/>
<span :class="{ 'abc uppercase cl': true, 'font-bold': true, [classB]: true }"/>
<span :class="enable ? 'abc uppercase cl' : 'lower'"/>
</div>
</template>
`,
errors: [
{ messageId: 'replacedWith' },
{ messageId: 'replacedWith' },
{ messageId: 'replacedWith' },
{ messageId: 'replacedWith' },
],
},
],
});

0 comments on commit 2b78b71

Please sign in to comment.