From 0876691954925c06cc6a8c079060d5f331ad6b12 Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Fri, 15 Mar 2024 13:19:47 +0100 Subject: [PATCH] Make rules more type-safe --- lint/generate-docs.ts | 2 +- lint/src/rules/html/index.ts | 2 +- .../src/rules/html/themed-component-usages.ts | 52 ++++--- lint/src/rules/ts/index.ts | 10 +- .../rules/ts/themed-component-selectors.ts | 49 +++--- lint/src/rules/ts/themed-component-usages.ts | 38 +++-- lint/src/util/angular.ts | 20 ++- lint/src/util/misc.ts | 30 +--- lint/src/util/structure.ts | 41 ++--- lint/src/util/theme-support.ts | 141 +++++++++++++----- lint/src/util/typescript.ts | 75 ++++++++++ lint/test/rules.spec.ts | 2 +- lint/test/testing.ts | 4 +- package.json | 1 + yarn.lock | 5 + 15 files changed, 314 insertions(+), 158 deletions(-) create mode 100644 lint/src/util/typescript.ts diff --git a/lint/generate-docs.ts b/lint/generate-docs.ts index b3a798628f5..ade2edea651 100644 --- a/lint/generate-docs.ts +++ b/lint/generate-docs.ts @@ -10,9 +10,9 @@ import { existsSync, mkdirSync, readFileSync, + rmSync, writeFileSync, } from 'fs'; -import { rmSync } from 'node:fs'; import { join } from 'path'; import { default as htmlPlugin } from './src/rules/html'; diff --git a/lint/src/rules/html/index.ts b/lint/src/rules/html/index.ts index 0ea42a3c2be..7c1370ae2d4 100644 --- a/lint/src/rules/html/index.ts +++ b/lint/src/rules/html/index.ts @@ -5,7 +5,7 @@ * * http://www.dspace.org/license/ */ - +/* eslint-disable import/no-namespace */ import { bundle, RuleExports, diff --git a/lint/src/rules/html/themed-component-usages.ts b/lint/src/rules/html/themed-component-usages.ts index 0c083f185df..82cfded2804 100644 --- a/lint/src/rules/html/themed-component-usages.ts +++ b/lint/src/rules/html/themed-component-usages.ts @@ -5,12 +5,23 @@ * * http://www.dspace.org/license/ */ +import { TmplAstElement } from '@angular-eslint/bundled-angular-compiler'; +import { getTemplateParserServices } from '@angular-eslint/utils'; +import { + ESLintUtils, + TSESLint, +} from '@typescript-eslint/utils'; + import { fixture } from '../../../test/fixture'; -import { DSpaceESLintRuleInfo } from '../../util/structure'; +import { + DSpaceESLintRuleInfo, + NamedTests, +} from '../../util/structure'; import { DISALLOWED_THEME_SELECTORS, fixSelectors, } from '../../util/theme-support'; +import { getFilename } from '../../util/typescript'; export enum Message { WRONG_SELECTOR = 'mustUseThemedWrapperSelector', @@ -36,39 +47,38 @@ The only exception to this rule are unit tests, where we may want to use the bas defaultOptions: [], } as DSpaceESLintRuleInfo; -export const rule = { +export const rule = ESLintUtils.RuleCreator.withoutDocs({ ...info, - create(context: any) { - if (context.getFilename().includes('.spec.ts')) { + create(context: TSESLint.RuleContext) { + if (getFilename(context).includes('.spec.ts')) { // skip inline templates in unit tests return {}; } + const parserServices = getTemplateParserServices(context as any); + return { - [`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: any) { + [`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: TmplAstElement) { + const { startSourceSpan, endSourceSpan } = node; + const openStart = startSourceSpan.start.offset as number; + context.report({ messageId: Message.WRONG_SELECTOR, - node, - fix(fixer: any) { + loc: parserServices.convertNodeSourceSpanToLoc(startSourceSpan), + fix(fixer) { const oldSelector = node.name; const newSelector = fixSelectors(oldSelector); - const openTagRange = [ - node.startSourceSpan.start.offset + 1, - node.startSourceSpan.start.offset + 1 + oldSelector.length, - ]; - const ops = [ - fixer.replaceTextRange(openTagRange, newSelector), + fixer.replaceTextRange([openStart + 1, openStart + 1 + oldSelector.length], newSelector), ]; // make sure we don't mangle self-closing tags - if (node.startSourceSpan.end.offset !== node.endSourceSpan.end.offset) { - const closeTagRange = [ - node.endSourceSpan.start.offset + 2, - node.endSourceSpan.end.offset - 1, - ]; - ops.push(fixer.replaceTextRange(closeTagRange, newSelector)); + if (endSourceSpan !== null && startSourceSpan.end.offset !== endSourceSpan.end.offset) { + const closeStart = endSourceSpan.start.offset as number; + const closeEnd = endSourceSpan.end.offset as number; + + ops.push(fixer.replaceTextRange([closeStart + 2, closeEnd - 1], newSelector)); } return ops; @@ -77,7 +87,7 @@ export const rule = { }, }; }, -}; +}); export const tests = { plugin: info.name, @@ -167,6 +177,6 @@ class Test { `, }, ], -}; +} as NamedTests; export default rule; diff --git a/lint/src/rules/ts/index.ts b/lint/src/rules/ts/index.ts index 2983d94386e..4ff38bd0c30 100644 --- a/lint/src/rules/ts/index.ts +++ b/lint/src/rules/ts/index.ts @@ -1,9 +1,17 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ import { bundle, RuleExports, } from '../../util/structure'; -import * as themedComponentUsages from './themed-component-usages'; +/* eslint-disable import/no-namespace */ import * as themedComponentSelectors from './themed-component-selectors'; +import * as themedComponentUsages from './themed-component-usages'; const index = [ themedComponentUsages, diff --git a/lint/src/rules/ts/themed-component-selectors.ts b/lint/src/rules/ts/themed-component-selectors.ts index 5c455bf1de3..d02883de742 100644 --- a/lint/src/rules/ts/themed-component-selectors.ts +++ b/lint/src/rules/ts/themed-component-selectors.ts @@ -5,9 +5,13 @@ * * http://www.dspace.org/license/ */ -import { ESLintUtils } from '@typescript-eslint/utils'; -import { fixture } from '../../../test/fixture'; +import { + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; +import { fixture } from '../../../test/fixture'; import { getComponentSelectorNode } from '../../util/angular'; import { stringLiteral } from '../../util/misc'; import { DSpaceESLintRuleInfo } from '../../util/structure'; @@ -16,6 +20,7 @@ import { isThemeableComponent, isThemedComponentWrapper, } from '../../util/theme-support'; +import { getFilename } from '../../util/typescript'; export enum Message { BASE = 'wrongSelectorUnthemedComponent', @@ -53,41 +58,43 @@ Unit tests are exempt from this rule, because they may redefine components using export const rule = ESLintUtils.RuleCreator.withoutDocs({ ...info, - create(context: any): any { - if (context.getFilename()?.endsWith('.spec.ts')) { + create(context: TSESLint.RuleContext) { + const filename = getFilename(context); + + if (filename.endsWith('.spec.ts')) { return {}; } - function enforceWrapperSelector(selectorNode: any) { + function enforceWrapperSelector(selectorNode: TSESTree.StringLiteral) { if (selectorNode?.value.startsWith('ds-themed-')) { context.report({ messageId: Message.WRAPPER, node: selectorNode, - fix(fixer: any) { + fix(fixer) { return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-'))); }, }); } } - function enforceBaseSelector(selectorNode: any) { + function enforceBaseSelector(selectorNode: TSESTree.StringLiteral) { if (!selectorNode?.value.startsWith('ds-base-')) { context.report({ messageId: Message.BASE, node: selectorNode, - fix(fixer: any) { + fix(fixer) { return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-'))); }, }); } } - function enforceThemedSelector(selectorNode: any) { + function enforceThemedSelector(selectorNode: TSESTree.StringLiteral) { if (!selectorNode?.value.startsWith('ds-themed-')) { context.report({ messageId: Message.THEMED, node: selectorNode, - fix(fixer: any) { + fix(fixer) { return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-'))); }, }); @@ -95,11 +102,15 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({ } return { - 'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: any) { - // keep track of all @Component nodes by their selector + 'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) { const selectorNode = getComponentSelectorNode(node); + + if (selectorNode === undefined) { + return; + } + const selector = selectorNode?.value; - const classNode = node.parent; + const classNode = node.parent as TSESTree.ClassDeclaration; const className = classNode.id?.name; if (selector === undefined || className === undefined) { @@ -108,7 +119,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({ if (isThemedComponentWrapper(node)) { enforceWrapperSelector(selectorNode); - } else if (inThemedComponentOverrideFile(context)) { + } else if (inThemedComponentOverrideFile(filename)) { enforceThemedSelector(selectorNode); } else if (isThemeableComponent(className)) { enforceBaseSelector(selectorNode); @@ -124,11 +135,11 @@ export const tests = { { name: 'Regular non-themeable component selector', code: ` - @Component({ - selector: 'ds-something', - }) - class Something { - } +@Component({ + selector: 'ds-something', +}) +class Something { +} `, }, { diff --git a/lint/src/rules/ts/themed-component-usages.ts b/lint/src/rules/ts/themed-component-usages.ts index 54b93363cb6..d9cc3127ed8 100644 --- a/lint/src/rules/ts/themed-component-usages.ts +++ b/lint/src/rules/ts/themed-component-usages.ts @@ -5,9 +5,13 @@ * * http://www.dspace.org/license/ */ -import { ESLintUtils } from '@typescript-eslint/utils'; +import { + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + import { fixture } from '../../../test/fixture'; -import { findUsages } from '../../util/misc'; import { DSpaceESLintRuleInfo } from '../../util/structure'; import { allThemeableComponents, @@ -17,6 +21,10 @@ import { inThemedComponentFile, isAllowedUnthemedUsage, } from '../../util/theme-support'; +import { + findUsages, + getFilename, +} from '../../util/typescript'; export enum Message { WRONG_CLASS = 'mustUseThemedWrapperClass', @@ -52,8 +60,10 @@ There are a few exceptions where the base class can still be used: export const rule = ESLintUtils.RuleCreator.withoutDocs({ ...info, - create(context: any, options: any): any { - function handleUnthemedUsagesInTypescript(node: any) { + create(context: TSESLint.RuleContext) { + const filename = getFilename(context); + + function handleUnthemedUsagesInTypescript(node: TSESTree.Identifier) { if (isAllowedUnthemedUsage(node)) { return; } @@ -68,24 +78,24 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({ context.report({ messageId: Message.WRONG_CLASS, node: node, - fix(fixer: any) { + fix(fixer) { return fixer.replaceText(node, entry.wrapperClass); }, }); } - function handleThemedSelectorQueriesInTests(node: any) { + function handleThemedSelectorQueriesInTests(node: TSESTree.Literal) { context.report({ node, messageId: Message.WRONG_SELECTOR, - fix(fixer: any){ + fix(fixer){ const newSelector = fixSelectors(node.raw); return fixer.replaceText(node, newSelector); }, }); } - function handleUnthemedImportsInTypescript(specifierNode: any) { + function handleUnthemedImportsInTypescript(specifierNode: TSESTree.ImportSpecifier) { const allUsages = findUsages(context, specifierNode.local); const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage)); @@ -94,7 +104,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({ } const importedNode = specifierNode.imported; - const declarationNode = specifierNode.parent; + const declarationNode = specifierNode.parent as TSESTree.ImportDeclaration; const entry = getThemeableComponentByBaseClass(importedNode.name); if (entry === undefined) { @@ -105,7 +115,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({ context.report({ messageId: Message.WRONG_IMPORT, node: importedNode, - fix(fixer: any) { + fix(fixer) { const ops = []; const oldImportSource = declarationNode.source.value; @@ -128,17 +138,17 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({ } // ignore tests and non-routing modules - if (context.getFilename()?.endsWith('.spec.ts')) { + if (filename.endsWith('.spec.ts')) { return { [`CallExpression[callee.object.name = "By"][callee.property.name = "css"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests, }; - } else if (context.getFilename()?.endsWith('.cy.ts')) { + } else if (filename.endsWith('.cy.ts')) { return { [`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests, }; } else if ( - context.getFilename()?.match(/(?!routing).module.ts$/) - || context.getFilename()?.match(/themed-.+\.component\.ts$/) + filename.match(/(?!routing).module.ts$/) + || filename.match(/themed-.+\.component\.ts$/) || inThemedComponentFile(context) ) { // do nothing diff --git a/lint/src/util/angular.ts b/lint/src/util/angular.ts index cb122a16dcc..7bff24718c0 100644 --- a/lint/src/util/angular.ts +++ b/lint/src/util/angular.ts @@ -5,12 +5,24 @@ * * http://www.dspace.org/license/ */ +import { TSESTree } from '@typescript-eslint/utils'; -export function getComponentSelectorNode(componentDecoratorNode: any): any | undefined { - for (const property of componentDecoratorNode.expression.arguments[0].properties) { - if (property.key?.name === 'selector') { - return property?.value; +import { getObjectPropertyNodeByName } from './typescript'; + +export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined { + const initializer = (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression; + const property = getObjectPropertyNodeByName(initializer, 'selector'); + + if (property !== undefined) { + // todo: support template literals as well + if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'string') { + return property as TSESTree.StringLiteral; } } + return undefined; } + +export function isPartOfViewChild(node: TSESTree.Identifier): boolean { + return (node.parent as any)?.callee?.name === 'ViewChild'; +} diff --git a/lint/src/util/misc.ts b/lint/src/util/misc.ts index 1cd610fcd7e..47357e7cd31 100644 --- a/lint/src/util/misc.ts +++ b/lint/src/util/misc.ts @@ -6,37 +6,11 @@ * http://www.dspace.org/license/ */ -export function stringLiteral(value: string): string { - return `'${value}'`; -} - export function match(rangeA: number[], rangeB: number[]) { return rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1]; } -export function findUsages(context: any, localNode: any): any[] { - const ast = context.getSourceCode().ast; - - const usages: any[] = []; - - for (const token of ast.tokens) { - if (token.type === 'Identifier' && token.value === localNode.name && !match(token.range, localNode.range)) { - usages.push(context.getSourceCode().getNodeByRangeIndex(token.range[0])); - } - } - - return usages; -} - -export function isPartOfTypeExpression(node: any): boolean { - return node.parent.type.startsWith('TSType'); -} - -export function isClassDeclaration(node: any): boolean { - return node.parent.type === 'ClassDeclaration'; -} - -export function isPartOfViewChild(node: any): boolean { - return node.parent?.callee?.name === 'ViewChild'; +export function stringLiteral(value: string): string { + return `'${value}'`; } diff --git a/lint/src/util/structure.ts b/lint/src/util/structure.ts index 13535bfe17e..bfbf7ec7f27 100644 --- a/lint/src/util/structure.ts +++ b/lint/src/util/structure.ts @@ -10,53 +10,42 @@ import { RuleTester } from 'eslint'; import { EnumType } from 'typescript'; export type Meta = TSESLint.RuleMetaData; -export type Valid = RuleTester.ValidTestCase | TSESLint.ValidTestCase; -export type Invalid = RuleTester.InvalidTestCase | TSESLint.InvalidTestCase; - +export type Valid = TSESLint.ValidTestCase | RuleTester.ValidTestCase; +export type Invalid = TSESLint.InvalidTestCase | RuleTester.InvalidTestCase; export interface DSpaceESLintRuleInfo { name: string; meta: Meta, - defaultOptions: any[], + defaultOptions: unknown[], } -export interface DSpaceESLintTestInfo { - rule: string; +export interface NamedTests { + plugin: string; valid: Valid[]; invalid: Invalid[]; } -export interface DSpaceESLintPluginInfo { - name: string; - description: string; - rules: DSpaceESLintRuleInfo; - tests: DSpaceESLintTestInfo; -} - -export interface DSpaceESLintInfo { - html: DSpaceESLintPluginInfo; - ts: DSpaceESLintPluginInfo; -} - export interface RuleExports { Message: EnumType, info: DSpaceESLintRuleInfo, - rule: any, - tests: any, - default: any, + rule: TSESLint.RuleModule, + tests: NamedTests, + default: unknown, } -export function bundle( +export interface PluginExports { name: string, language: string, + rules: Record, index: RuleExports[], -): { +} + +export function bundle( name: string, language: string, - rules: Record, index: RuleExports[], -} { - return index.reduce((o: any, i: any) => { +): PluginExports { + return index.reduce((o: PluginExports, i: RuleExports) => { o.rules[i.info.name] = i.rule; return o; }, { diff --git a/lint/src/util/theme-support.ts b/lint/src/util/theme-support.ts index 18eed48452d..6a3807a536b 100644 --- a/lint/src/util/theme-support.ts +++ b/lint/src/util/theme-support.ts @@ -6,17 +6,18 @@ * http://www.dspace.org/license/ */ +import { TSESTree } from '@typescript-eslint/utils'; import { readFileSync } from 'fs'; import { basename } from 'path'; -import ts from 'typescript'; +import ts, { Identifier } from 'typescript'; +import { isPartOfViewChild } from './angular'; import { - isClassDeclaration, + AnyRuleContext, + getFilename, + isPartOfClassDeclaration, isPartOfTypeExpression, - isPartOfViewChild, -} from './misc'; - -const glob = require('glob'); +} from './typescript'; /** * Couples a themeable Component to its ThemedComponent wrapper @@ -31,6 +32,42 @@ export interface ThemeableComponentRegistryEntry { wrapperClass: string; } +function isAngularComponentDecorator(node: ts.Node) { + if (node.kind === ts.SyntaxKind.Decorator && node.parent.kind === ts.SyntaxKind.ClassDeclaration) { + const decorator = node as ts.Decorator; + + if (decorator.expression.kind === ts.SyntaxKind.CallExpression) { + const method = decorator.expression as ts.CallExpression; + + if (method.expression.kind === ts.SyntaxKind.Identifier) { + return (method.expression as Identifier).escapedText === 'Component'; + } + } + } + + return false; +} + +function findImportDeclaration(source: ts.SourceFile, identifierName: string): ts.ImportDeclaration | undefined { + return ts.forEachChild(source, (topNode: ts.Node) => { + if (topNode.kind === ts.SyntaxKind.ImportDeclaration) { + const importDeclaration = topNode as ts.ImportDeclaration; + + if (importDeclaration.importClause?.namedBindings?.kind === ts.SyntaxKind.NamedImports) { + const namedImports = importDeclaration.importClause?.namedBindings as ts.NamedImports; + + for (const element of namedImports.elements) { + if (element.name.escapedText === identifierName) { + return importDeclaration; + } + } + } + } + + return undefined; + }); +} + /** * Listing of all themeable Components */ @@ -55,32 +92,45 @@ class ThemeableComponentRegistry { function registerWrapper(path: string) { const source = getSource(path); - function traverse(node: any) { - if (node.kind === ts.SyntaxKind.Decorator && node.expression.expression.escapedText === 'Component' && node.parent.kind === ts.SyntaxKind.ClassDeclaration) { - const wrapperClass = node.parent.name.escapedText; + function traverse(node: ts.Node) { + if (node.parent !== undefined && isAngularComponentDecorator(node)) { + const classNode = node.parent as ts.ClassDeclaration; + + if (classNode.name === undefined || classNode.heritageClauses === undefined) { + return; + } - for (const heritageClause of node.parent.heritageClauses) { + const wrapperClass = classNode.name?.escapedText as string; + + for (const heritageClause of classNode.heritageClauses) { for (const type of heritageClause.types) { - if (type.expression.escapedText === 'ThemedComponent') { - const baseClass = type.typeArguments[0].typeName?.escapedText; - - ts.forEachChild(source, (topNode: any) => { - if (topNode.kind === ts.SyntaxKind.ImportDeclaration) { - for (const element of topNode.importClause.namedBindings.elements) { - if (element.name.escapedText === baseClass) { - const basePath = resolveLocalPath(topNode.moduleSpecifier.text, path); - - themeableComponents.add({ - baseClass, - basePath: basePath.replace(new RegExp(`^${prefix}`), ''), - baseFileName: basename(basePath).replace(/\.ts$/, ''), - wrapperClass, - wrapperPath: path.replace(new RegExp(`^${prefix}`), ''), - wrapperFileName: basename(path).replace(/\.ts$/, ''), - }); - } - } - } + if ((type as any).expression.escapedText === 'ThemedComponent') { + if (type.kind !== ts.SyntaxKind.ExpressionWithTypeArguments || type.typeArguments === undefined) { + continue; + } + + const firstTypeArg = type.typeArguments[0] as ts.TypeReferenceNode; + const baseClass = (firstTypeArg.typeName as ts.Identifier)?.escapedText; + + if (baseClass === undefined) { + continue; + } + + const importDeclaration = findImportDeclaration(source, baseClass); + + if (importDeclaration === undefined) { + continue; + } + + const basePath = resolveLocalPath((importDeclaration.moduleSpecifier as ts.StringLiteral).text, path); + + themeableComponents.add({ + baseClass, + basePath: basePath.replace(new RegExp(`^${prefix}`), ''), + baseFileName: basename(basePath).replace(/\.ts$/, ''), + wrapperClass, + wrapperPath: path.replace(new RegExp(`^${prefix}`), ''), + wrapperFileName: basename(path).replace(/\.ts$/, ''), }); } } @@ -95,6 +145,8 @@ class ThemeableComponentRegistry { traverse(source); } + const glob = require('glob'); + const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found; for (const wrapper of wrappers) { @@ -142,8 +194,16 @@ function resolveLocalPath(path: string, relativeTo: string) { } } -export function isThemedComponentWrapper(node: any): boolean { - return node.parent.superClass?.name === 'ThemedComponent'; +export function isThemedComponentWrapper(decoratorNode: TSESTree.Decorator): boolean { + if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) { + return false; + } + + if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) { + return false; + } + + return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent'; } export function isThemeableComponent(className: string): boolean { @@ -151,8 +211,8 @@ export function isThemeableComponent(className: string): boolean { return themeableComponents.byBaseClass.has(className); } -export function inThemedComponentOverrideFile(context: any): boolean { - const match = context.getFilename().match(/src\/themes\/[^\/]+\/(app\/.*)/); +export function inThemedComponentOverrideFile(filename: string): boolean { + const match = filename.match(/src\/themes\/[^\/]+\/(app\/.*)/); if (!match) { return false; @@ -162,13 +222,14 @@ export function inThemedComponentOverrideFile(context: any): boolean { return themeableComponents.byBasePath.has(`src/${match[1]}`); } -export function inThemedComponentFile(context: any): boolean { +export function inThemedComponentFile(context: AnyRuleContext): boolean { themeableComponents.initialize(); + const filename = getFilename(context); return [ - () => themeableComponents.byBasePath.has(context.getFilename()), - () => themeableComponents.byWrapperPath.has(context.getFilename()), - () => inThemedComponentOverrideFile(context), + () => themeableComponents.byBasePath.has(filename), + () => themeableComponents.byWrapperPath.has(filename), + () => inThemedComponentOverrideFile(filename), ].some(predicate => predicate()); } @@ -182,8 +243,8 @@ export function getThemeableComponentByBaseClass(baseClass: string): ThemeableCo return themeableComponents.byBaseClass.get(baseClass); } -export function isAllowedUnthemedUsage(usageNode: any) { - return isClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode); +export function isAllowedUnthemedUsage(usageNode: TSESTree.Identifier) { + return isPartOfClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode); } export const DISALLOWED_THEME_SELECTORS = 'ds-(base|themed)-'; diff --git a/lint/src/util/typescript.ts b/lint/src/util/typescript.ts new file mode 100644 index 00000000000..5b7bdb858dd --- /dev/null +++ b/lint/src/util/typescript.ts @@ -0,0 +1,75 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +import { match } from './misc'; + +export type AnyRuleContext = TSESLint.RuleContext; + +export function getFilename(context: AnyRuleContext): string { + // TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?) + // eslint-disable-next-line deprecation/deprecation + return context.getFilename(); +} + +export function getSourceCode(context: AnyRuleContext): TSESLint.SourceCode { + // TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?) + // eslint-disable-next-line deprecation/deprecation + return context.getSourceCode(); +} + +export function getObjectPropertyNodeByName(objectNode: TSESTree.ObjectExpression, propertyName: string): TSESTree.Node | undefined { + for (const propertyNode of objectNode.properties) { + if ( + propertyNode.type === TSESTree.AST_NODE_TYPES.Property + && ( + ( + propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Identifier + && propertyNode.key?.name === propertyName + ) || ( + propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Literal + && propertyNode.key?.value === propertyName + ) + ) + ) { + return propertyNode.value; + } + } + return undefined; +} + +export function findUsages(context: AnyRuleContext, localNode: TSESTree.Identifier): TSESTree.Identifier[] { + const source = getSourceCode(context); + + const usages: TSESTree.Identifier[] = []; + + for (const token of source.ast.tokens) { + if (token.type === 'Identifier' && token.value === localNode.name && !match(token.range, localNode.range)) { + const node = source.getNodeByRangeIndex(token.range[0]); + if (node !== null) { + usages.push(node as TSESTree.Identifier); + } + } + } + + return usages; +} + +export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean { + return node.parent.type.startsWith('TSType'); +} + +export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean { + if (node.parent === undefined) { + return false; + } + return node.parent.type === 'ClassDeclaration'; +} diff --git a/lint/test/rules.spec.ts b/lint/test/rules.spec.ts index a8c1b382b22..11c9bec46cf 100644 --- a/lint/test/rules.spec.ts +++ b/lint/test/rules.spec.ts @@ -15,7 +15,7 @@ import { describe('TypeScript rules', () => { for (const { info, rule, tests } of tsPlugin.index) { - tsRuleTester.run(info.name, rule, tests); + tsRuleTester.run(info.name, rule, tests as any); } }); diff --git a/lint/test/testing.ts b/lint/test/testing.ts index f4f92a0e631..f86870ec29e 100644 --- a/lint/test/testing.ts +++ b/lint/test/testing.ts @@ -8,13 +8,13 @@ import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester'; import { RuleTester } from 'eslint'; + +import { themeableComponents } from '../src/util/theme-support'; import { FIXTURE, fixture, } from './fixture'; -import { themeableComponents } from '../src/util/theme-support'; - // Register themed components from test fixture themeableComponents.initialize(FIXTURE); diff --git a/package.json b/package.json index 5ef876c5600..9351afde7b5 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "@angular-builders/custom-webpack": "~15.0.0", "@angular-devkit/build-angular": "^15.2.6", "@angular-eslint/builder": "15.2.1", + "@angular-eslint/bundled-angular-compiler": "^17.2.1", "@angular-eslint/eslint-plugin": "15.2.1", "@angular-eslint/eslint-plugin-template": "15.2.1", "@angular-eslint/schematics": "15.2.1", diff --git a/yarn.lock b/yarn.lock index a137a12cbd1..e01899307c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -291,6 +291,11 @@ resolved "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-15.2.1.tgz" integrity sha512-LO7Am8eVCr7oh6a0VmKSL7K03CnQEQhFO7Wt/YtbfYOxVjrbwmYLwJn+wZPOT7A02t/BttOD/WXuDrOWtSMQ/Q== +"@angular-eslint/bundled-angular-compiler@^17.2.1": + version "17.2.1" + resolved "https://registry.yarnpkg.com/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-17.2.1.tgz#d849b0845371b41856b9f598af81ce5bf799bca0" + integrity sha512-puC0itsZv2QlrDOCcWtq1KZH+DvfrpV+mV78HHhi6+h25R5iIhr8ARKcl3EQxFjvrFq34jhG8pSupxKvFbHVfA== + "@angular-eslint/eslint-plugin-template@15.2.1": version "15.2.1" resolved "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-15.2.1.tgz"