Update plugins to support standalone components

- ThemedComponent wrappers should always import their base component. This ensures that it's always enough to only import the wrapper when we use it.
- This implies that all themeable components must be standalone

→ added rules to enforce this
→ updated usage rule to improve declaration/import handling
This commit is contained in:
Yury Bondarenko
2024-03-21 10:37:20 +01:00
parent 568574585b
commit e40b6ae612
14 changed files with 835 additions and 55 deletions

View File

@@ -245,6 +245,7 @@
"rxjs/no-nested-subscribe": "off", // todo: go over _all_ cases "rxjs/no-nested-subscribe": "off", // todo: go over _all_ cases
// Custom DSpace Angular rules // Custom DSpace Angular rules
"dspace-angular-ts/themed-component-classes": "error",
"dspace-angular-ts/themed-component-selectors": "error", "dspace-angular-ts/themed-component-selectors": "error",
"dspace-angular-ts/themed-component-usages": "error" "dspace-angular-ts/themed-component-usages": "error"
} }

View File

@@ -10,12 +10,14 @@ import {
RuleExports, RuleExports,
} from '../../util/structure'; } from '../../util/structure';
/* eslint-disable import/no-namespace */ /* eslint-disable import/no-namespace */
import * as themedComponentClasses from './themed-component-classes';
import * as themedComponentSelectors from './themed-component-selectors'; import * as themedComponentSelectors from './themed-component-selectors';
import * as themedComponentUsages from './themed-component-usages'; import * as themedComponentUsages from './themed-component-usages';
const index = [ const index = [
themedComponentUsages, themedComponentClasses,
themedComponentSelectors, themedComponentSelectors,
themedComponentUsages,
] as unknown as RuleExports[]; ] as unknown as RuleExports[];
export = { export = {

View File

@@ -0,0 +1,378 @@
/**
* 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 {
ESLintUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { fixture } from '../../../test/fixture';
import {
getComponentImportNode,
getComponentInitializer,
getComponentStandaloneNode,
} from '../../util/angular';
import { appendObjectProperties } from '../../util/fix';
import { DSpaceESLintRuleInfo } from '../../util/structure';
import {
getBaseComponentClassName,
inThemedComponentOverrideFile,
isThemeableComponent,
isThemedComponentWrapper,
} from '../../util/theme-support';
import { getFilename } from '../../util/typescript';
export enum Message {
NOT_STANDALONE = 'mustBeStandalone',
NOT_STANDALONE_IMPORTS_BASE = 'mustBeStandaloneAndImportBase',
WRAPPER_IMPORTS_BASE = 'wrapperShouldImportBase',
}
export const info = {
name: 'themed-component-classes',
meta: {
docs: {
description: `Formatting rules for themeable component classes`,
},
type: 'problem',
fixable: 'code',
schema: [],
messages: {
[Message.NOT_STANDALONE]: 'Themeable components must be standalone',
[Message.NOT_STANDALONE_IMPORTS_BASE]: 'Themeable component wrapper classes must be standalone and import the base class',
[Message.WRAPPER_IMPORTS_BASE]: 'Themed component wrapper classes must import the base class',
},
},
defaultOptions: [],
} as DSpaceESLintRuleInfo;
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
const filename = getFilename(context);
if (filename.endsWith('.spec.ts')) {
return {};
}
function enforceStandalone(decoratorNode: TSESTree.Decorator, withBaseImport = false) {
const standaloneNode = getComponentStandaloneNode(decoratorNode);
if (standaloneNode === undefined) {
// We may need to add these properties in one go
if (!withBaseImport) {
context.report({
messageId: Message.NOT_STANDALONE,
node: decoratorNode,
fix(fixer) {
const initializer = getComponentInitializer(decoratorNode);
return appendObjectProperties(context, fixer, initializer, ['standalone: true']);
},
});
}
} else if (!standaloneNode.value) {
context.report({
messageId: Message.NOT_STANDALONE,
node: standaloneNode,
fix(fixer) {
return fixer.replaceText(standaloneNode, 'true');
},
});
}
if (withBaseImport) {
const baseClass = getBaseComponentClassName(decoratorNode);
if (baseClass === undefined) {
return;
}
const importsNode = getComponentImportNode(decoratorNode);
if (importsNode === undefined) {
if (standaloneNode === undefined) {
context.report({
messageId: Message.NOT_STANDALONE_IMPORTS_BASE,
node: decoratorNode,
fix(fixer) {
const initializer = getComponentInitializer(decoratorNode);
return appendObjectProperties(context, fixer, initializer, ['standalone: true', `imports: [${baseClass}]`]);
},
});
} else {
context.report({
messageId: Message.WRAPPER_IMPORTS_BASE,
node: decoratorNode,
fix(fixer) {
const initializer = getComponentInitializer(decoratorNode);
return appendObjectProperties(context, fixer, initializer, [`imports: [${baseClass}]`]);
},
});
}
} else {
// If we have an imports node, standalone: true will be enforced by another rule
const imports = importsNode.elements.map(e => (e as TSESTree.Identifier).name);
if (!imports.includes(baseClass) || imports.length > 1) {
// The wrapper should _only_ import the base component
context.report({
messageId: Message.WRAPPER_IMPORTS_BASE,
node: importsNode,
fix(fixer) {
// todo: this may leave unused imports, but that's better than mangling things
return fixer.replaceText(importsNode, `[${baseClass}]`);
},
});
}
}
}
}
return {
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) {
const classNode = node.parent as TSESTree.ClassDeclaration;
const className = classNode.id?.name;
if (className === undefined) {
return;
}
if (isThemedComponentWrapper(node)) {
enforceStandalone(node, true);
} else if (inThemedComponentOverrideFile(filename)) {
enforceStandalone(node);
} else if (isThemeableComponent(className)) {
enforceStandalone(node);
}
},
};
},
});
export const tests = {
plugin: info.name,
valid: [
{
name: 'Regular non-themeable component',
code: `
@Component({
selector: 'ds-something',
standalone: true,
})
class Something {
}
`,
},
{
name: 'Base component',
code: `
@Component({
selector: 'ds-base-test-themable',
standalone: true,
})
class TestThemeableTomponent {
}
`,
},
{
name: 'Wrapper component',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [
TestThemeableComponent,
],
})
class ThemedTestThemeableTomponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'Override component',
filename: fixture('src/themes/test/app/test/test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-themed-test-themable',
standalone: true,
})
class Override extends BaseComponent {
}
`,
},
],
invalid: [
{
name: 'Base component must be standalone',
code: `
@Component({
selector: 'ds-base-test-themable',
})
class TestThemeableComponent {
}
`,
errors:[
{
messageId: Message.NOT_STANDALONE,
},
],
output: `
@Component({
selector: 'ds-base-test-themable',
standalone: true,
})
class TestThemeableComponent {
}
`,
},
{
name: 'Wrapper component must be standalone and import base component',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-test-themable',
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
errors:[
{
messageId: Message.NOT_STANDALONE_IMPORTS_BASE,
},
],
output: `
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'Wrapper component must import base component (array present but empty)',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
errors:[
{
messageId: Message.WRAPPER_IMPORTS_BASE,
},
],
output: `
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'Wrapper component must import base component (array is wrong)',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
import { SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [
SomethingElse,
],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
errors:[
{
messageId: Message.WRAPPER_IMPORTS_BASE,
},
],
output: `
import { SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
}, {
name: 'Wrapper component must import base component (array is wrong)',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
import { Something, SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [
SomethingElse,
],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
errors:[
{
messageId: Message.WRAPPER_IMPORTS_BASE,
},
],
output: `
import { Something, SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'Override component must be standalone',
filename: fixture('src/themes/test/app/test/test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-themed-test-themable',
})
class Override extends BaseComponent {
}
`,
errors:[
{
messageId: Message.NOT_STANDALONE,
},
],
output: `
@Component({
selector: 'ds-themed-test-themable',
standalone: true,
})
class Override extends BaseComponent {
}
`,
},
],
};

View File

@@ -12,6 +12,10 @@ import {
} from '@typescript-eslint/utils'; } from '@typescript-eslint/utils';
import { fixture } from '../../../test/fixture'; import { fixture } from '../../../test/fixture';
import {
removeWithCommas,
replaceOrRemoveArrayIdentifier,
} from '../../util/fix';
import { DSpaceESLintRuleInfo } from '../../util/structure'; import { DSpaceESLintRuleInfo } from '../../util/structure';
import { import {
allThemeableComponents, allThemeableComponents,
@@ -22,14 +26,18 @@ import {
isAllowedUnthemedUsage, isAllowedUnthemedUsage,
} from '../../util/theme-support'; } from '../../util/theme-support';
import { import {
findImportSpecifier,
findUsages, findUsages,
findUsagesByName,
getFilename, getFilename,
relativePath,
} from '../../util/typescript'; } from '../../util/typescript';
export enum Message { export enum Message {
WRONG_CLASS = 'mustUseThemedWrapperClass', WRONG_CLASS = 'mustUseThemedWrapperClass',
WRONG_IMPORT = 'mustImportThemedWrapper', WRONG_IMPORT = 'mustImportThemedWrapper',
WRONG_SELECTOR = 'mustUseThemedWrapperSelector', WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
BASE_IN_MODULE = 'baseComponentNotNeededInModule',
} }
export const info = { export const info = {
@@ -53,6 +61,7 @@ There are a few exceptions where the base class can still be used:
[Message.WRONG_CLASS]: 'Themeable components should be used via their ThemedComponent wrapper', [Message.WRONG_CLASS]: 'Themeable components should be used via their ThemedComponent wrapper',
[Message.WRONG_IMPORT]: 'Themeable components should be used via their ThemedComponent wrapper', [Message.WRONG_IMPORT]: 'Themeable components should be used via their ThemedComponent wrapper',
[Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper', [Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper',
[Message.BASE_IN_MODULE]: 'Base themeable components shouldn\'t be declared in modules',
}, },
}, },
defaultOptions: [], defaultOptions: [],
@@ -79,7 +88,11 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
messageId: Message.WRONG_CLASS, messageId: Message.WRONG_CLASS,
node: node, node: node,
fix(fixer) { fix(fixer) {
if (node.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) {
return replaceOrRemoveArrayIdentifier(context, fixer, node, entry.wrapperClass);
} else {
return fixer.replaceText(node, entry.wrapperClass); return fixer.replaceText(node, entry.wrapperClass);
}
}, },
}); });
} }
@@ -118,18 +131,36 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
fix(fixer) { fix(fixer) {
const ops = []; const ops = [];
const oldImportSource = declarationNode.source.value; const wrapperImport = findImportSpecifier(context, entry.wrapperClass);
const newImportLine = `import { ${entry.wrapperClass} } from '${oldImportSource.replace(entry.baseFileName, entry.wrapperFileName)}';`;
if (findUsagesByName(context, entry.wrapperClass).length === 0) {
// Wrapper is not present in this file, safe to add import
const newImportLine = `import { ${entry.wrapperClass} } from '${relativePath(filename, entry.wrapperPath)}';`;
if (declarationNode.specifiers.length === 1) { if (declarationNode.specifiers.length === 1) {
if (allUsages.length === badUsages.length) { if (allUsages.length === badUsages.length) {
ops.push(fixer.replaceText(declarationNode, newImportLine)); ops.push(fixer.replaceText(declarationNode, newImportLine));
} else { } else if (wrapperImport === undefined) {
ops.push(fixer.insertTextAfter(declarationNode, newImportLine)); ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine));
} }
} else { } else {
ops.push(fixer.replaceText(specifierNode, entry.wrapperClass)); ops.push(...removeWithCommas(context, fixer, specifierNode));
ops.push(fixer.insertTextAfter(declarationNode, newImportLine)); if (wrapperImport === undefined) {
ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine));
}
}
} else {
// Wrapper already present in the file, remove import instead
if (allUsages.length === badUsages.length) {
if (declarationNode.specifiers.length === 1) {
// Make sure we remove the newline as well
ops.push(fixer.removeRange([declarationNode.range[0], declarationNode.range[1] + 1]));
} else {
ops.push(...removeWithCommas(context, fixer, specifierNode));
}
}
} }
return ops; return ops;
@@ -147,9 +178,8 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
[`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests, [`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
}; };
} else if ( } else if (
filename.match(/(?!routing).module.ts$/) filename.match(/(?!src\/themes\/).*(?!routing).module.ts$/)
|| filename.match(/themed-.+\.component\.ts$/) || filename.match(/themed-.+\.component\.ts$/)
|| inThemedComponentFile(context)
) { ) {
// do nothing // do nothing
return {}; return {};
@@ -174,7 +204,7 @@ export const tests = {
{ {
name: 'allow wrapper class usages', name: 'allow wrapper class usages',
code: ` code: `
import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts'; import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
const config = { const config = {
a: ThemedTestThemeableComponent, a: ThemedTestThemeableComponent,
@@ -192,7 +222,7 @@ export class TestThemeableComponent {
{ {
name: 'allow inheriting from base class', name: 'allow inheriting from base class',
code: ` code: `
import { TestThemeableComponent } from '../test/test-themeable.component.ts'; import { TestThemeableComponent } from './app/test/test-themeable.component';
export class ThemedAdminSidebarComponent extends ThemedComponent<TestThemeableComponent> { export class ThemedAdminSidebarComponent extends ThemedComponent<TestThemeableComponent> {
} }
@@ -201,7 +231,7 @@ export class ThemedAdminSidebarComponent extends ThemedComponent<TestThemeableCo
{ {
name: 'allow base class in ViewChild', name: 'allow base class in ViewChild',
code: ` code: `
import { TestThemeableComponent } from '../test/test-themeable.component.ts'; import { TestThemeableComponent } from './app/test/test-themeable.component';
export class Something { export class Something {
@ViewChild(TestThemeableComponent) test: TestThemeableComponent; @ViewChild(TestThemeableComponent) test: TestThemeableComponent;
@@ -229,8 +259,8 @@ By.Css('#test > ds-themeable > #nest');
{ {
name: 'disallow direct usages of base class', name: 'disallow direct usages of base class',
code: ` code: `
import { TestThemeableComponent } from '../test/test-themeable.component.ts'; import { TestThemeableComponent } from './app/test/test-themeable.component';
import { TestComponent } from '../test/test.component.ts'; import { TestComponent } from './app/test/test.component';
const config = { const config = {
a: TestThemeableComponent, a: TestThemeableComponent,
@@ -246,8 +276,8 @@ const config = {
}, },
], ],
output: ` output: `
import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts'; import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
import { TestComponent } from '../test/test.component.ts'; import { TestComponent } from './app/test/test.component';
const config = { const config = {
a: ThemedTestThemeableComponent, a: ThemedTestThemeableComponent,
@@ -255,6 +285,61 @@ const config = {
} }
`, `,
}, },
{
name: 'disallow direct usages of base class, keep other imports',
code: `
import { Something, TestThemeableComponent } from './app/test/test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: TestThemeableComponent,
b: TestComponent,
c: Something,
}
`,
errors: [
{
messageId: Message.WRONG_IMPORT,
},
{
messageId: Message.WRONG_CLASS,
},
],
output: `
import { Something } from './app/test/test-themeable.component';
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: ThemedTestThemeableComponent,
b: TestComponent,
c: Something,
}
`,
},
{
name: 'handle array replacements correctly',
code: `
const DECLARATIONS = [
Something,
TestThemeableComponent,
Something,
ThemedTestThemeableComponent,
];
`,
errors: [
{
messageId: Message.WRONG_CLASS,
},
],
output: `
const DECLARATIONS = [
Something,
Something,
ThemedTestThemeableComponent,
];
`,
},
{ {
name: 'disallow override selector in test queries', name: 'disallow override selector in test queries',
filename: fixture('src/app/test/test.component.spec.ts'), filename: fixture('src/app/test/test.component.spec.ts'),
@@ -337,30 +422,18 @@ cy.get('#test > ds-themeable > #nest');
}, },
{ {
name: 'edge case: unable to find usage node through usage token, but import is still flagged and fixed', name: 'edge case: unable to find usage node through usage token, but import is still flagged and fixed',
filename: fixture('src/themes/test/app/test/other-themeable.component.ts'),
code: ` code: `
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Context } from '../../core/shared/context.model'; import { Context } from './app/core/shared/context.model';
import { TestThemeableComponent } from '../test/test-themeable.component.ts'; import { TestThemeableComponent } from '../../../../app/test/test-themeable.component';
@Component({ @Component({
selector: 'ds-admin-search-page',
templateUrl: './admin-search-page.component.html',
styleUrls: ['./admin-search-page.component.scss'],
standalone: true, standalone: true,
imports: [ imports: [TestThemeableComponent],
TestThemeableComponent
],
}) })
export class UsageComponent {
/**
* Component that represents a search page for administrators
*/
export class AdminSearchPageComponent {
/**
* The context of this page
*/
context: Context = Context.AdminSearch;
} }
`, `,
errors: [ errors: [
@@ -374,27 +447,53 @@ export class AdminSearchPageComponent {
output: ` output: `
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Context } from '../../core/shared/context.model'; import { Context } from './app/core/shared/context.model';
import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts'; import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
@Component({ @Component({
selector: 'ds-admin-search-page',
templateUrl: './admin-search-page.component.html',
styleUrls: ['./admin-search-page.component.scss'],
standalone: true, standalone: true,
imports: [ imports: [ThemedTestThemeableComponent],
ThemedTestThemeableComponent
],
}) })
export class UsageComponent {
}
`,
},
{
name: 'edge case edge case: both are imported, only wrapper is retained',
filename: fixture('src/themes/test/app/test/other-themeable.component.ts'),
code: `
import { Component } from '@angular/core';
/** import { Context } from './app/core/shared/context.model';
* Component that represents a search page for administrators import { TestThemeableComponent } from '../../../../app/test/test-themeable.component';
*/ import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
export class AdminSearchPageComponent {
/** @Component({
* The context of this page standalone: true,
*/ imports: [TestThemeableComponent, ThemedTestThemeableComponent],
context: Context = Context.AdminSearch; })
export class UsageComponent {
}
`,
errors: [
{
messageId: Message.WRONG_IMPORT,
},
{
messageId: Message.WRONG_CLASS,
},
],
output: `
import { Component } from '@angular/core';
import { Context } from './app/core/shared/context.model';
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
@Component({
standalone: true,
imports: [ThemedTestThemeableComponent],
})
export class UsageComponent {
} }
`, `,
}, },

View File

@@ -10,8 +10,7 @@ import { TSESTree } from '@typescript-eslint/utils';
import { getObjectPropertyNodeByName } from './typescript'; import { getObjectPropertyNodeByName } from './typescript';
export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined { export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined {
const initializer = (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression; const property = getComponentInitializerNodeByName(componentDecoratorNode, 'selector');
const property = getObjectPropertyNodeByName(initializer, 'selector');
if (property !== undefined) { if (property !== undefined) {
// todo: support template literals as well // todo: support template literals as well
@@ -23,6 +22,62 @@ export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decora
return undefined; return undefined;
} }
export function getComponentStandaloneNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.BooleanLiteral | undefined {
const property = getComponentInitializerNodeByName(componentDecoratorNode, 'standalone');
if (property !== undefined) {
if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'boolean') {
return property as TSESTree.BooleanLiteral;
}
}
return undefined;
}
export function getComponentImportNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.ArrayExpression | undefined {
const property = getComponentInitializerNodeByName(componentDecoratorNode, 'imports');
if (property !== undefined) {
if (property.type === TSESTree.AST_NODE_TYPES.ArrayExpression) {
return property as TSESTree.ArrayExpression;
}
}
return undefined;
}
export function getComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined {
if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) {
return undefined;
}
if (decoratorNode.parent.id?.type !== TSESTree.AST_NODE_TYPES.Identifier) {
return undefined;
}
return decoratorNode.parent.id.name;
}
export function getComponentSuperClassName(decoratorNode: TSESTree.Decorator): string | undefined {
if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) {
return undefined;
}
if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) {
return undefined;
}
return decoratorNode.parent.superClass.name;
}
export function getComponentInitializer(componentDecoratorNode: TSESTree.Decorator): TSESTree.ObjectExpression {
return (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression;
}
export function getComponentInitializerNodeByName(componentDecoratorNode: TSESTree.Decorator, name: string): TSESTree.Node | undefined {
const initializer = getComponentInitializer(componentDecoratorNode);
return getObjectPropertyNodeByName(initializer, name);
}
export function isPartOfViewChild(node: TSESTree.Identifier): boolean { export function isPartOfViewChild(node: TSESTree.Identifier): boolean {
return (node.parent as any)?.callee?.name === 'ViewChild'; return (node.parent as any)?.callee?.name === 'ViewChild';
} }

125
lint/src/util/fix.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* 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 { TSESTree } from '@typescript-eslint/utils';
import {
RuleContext,
RuleFix,
RuleFixer,
} from '@typescript-eslint/utils/ts-eslint';
import { getSourceCode } from './typescript';
export function appendObjectProperties(context: RuleContext<any, any>, fixer: RuleFixer, objectNode: TSESTree.ObjectExpression, properties: string[]): RuleFix {
// todo: may not handle empty objects too well
const lastProperty = objectNode.properties[objectNode.properties.length - 1];
const source = getSourceCode(context);
const nextToken = source.getTokenAfter(lastProperty);
// todo: newline & indentation are hardcoded for @Component({})
// todo: we're assuming that we need trailing commas, what if we don't?
const newPart = '\n' + properties.map(p => ` ${p},`).join('\n');
if (nextToken !== null && nextToken.value === ',') {
return fixer.insertTextAfter(nextToken, newPart);
} else {
return fixer.insertTextAfter(lastProperty, ',' + newPart);
}
}
export function appendArrayElement(context: RuleContext<any, any>, fixer: RuleFixer, arrayNode: TSESTree.ArrayExpression, value: string): RuleFix {
const source = getSourceCode(context);
if (arrayNode.elements.length === 0) {
// This is the first element
const openArray = source.getTokenByRangeStart(arrayNode.range[0]);
if (openArray == null) {
throw new Error('Unexpected null token for opening square bracket');
}
// safe to assume the list is single-line
return fixer.insertTextAfter(openArray, `${value}`);
} else {
const lastElement = arrayNode.elements[arrayNode.elements.length - 1];
if (lastElement == null) {
throw new Error('Unexpected null node in array');
}
const nextToken = source.getTokenAfter(lastElement);
// todo: we don't know if the list is chopped or not, so we can't make any assumptions -- may produce output that will be flagged by other rules on the next run!
// todo: we're assuming that we need trailing commas, what if we don't?
if (nextToken !== null && nextToken.value === ',') {
return fixer.insertTextAfter(nextToken, ` ${value},`);
} else {
return fixer.insertTextAfter(lastElement, `, ${value},`);
}
}
}
export function isLast(elementNode: TSESTree.Node): boolean {
if (!elementNode.parent) {
return false;
}
let siblingNodes: (TSESTree.Node | null)[] = [null];
if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) {
siblingNodes = elementNode.parent.elements;
} else if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ImportDeclaration) {
siblingNodes = elementNode.parent.specifiers;
}
return elementNode === siblingNodes[siblingNodes.length - 1];
}
export function removeWithCommas(context: RuleContext<any, any>, fixer: RuleFixer, elementNode: TSESTree.Node): RuleFix[] {
const ops = [];
const source = getSourceCode(context);
let nextToken = source.getTokenAfter(elementNode);
let prevToken = source.getTokenBefore(elementNode);
if (nextToken !== null && prevToken !== null) {
if (nextToken.value === ',') {
nextToken = source.getTokenAfter(nextToken);
if (nextToken !== null) {
ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]]));
}
}
if (isLast(elementNode) && prevToken.value === ',') {
prevToken = source.getTokenBefore(prevToken);
if (prevToken !== null) {
ops.push(fixer.removeRange([prevToken.range[1], elementNode.range[1]]));
}
}
} else if (nextToken !== null) {
ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]]));
}
return ops;
}
export function replaceOrRemoveArrayIdentifier(context: RuleContext<any, any>, fixer: RuleFixer, identifierNode: TSESTree.Identifier, newValue: string): RuleFix[] {
if (identifierNode.parent.type !== TSESTree.AST_NODE_TYPES.ArrayExpression) {
throw new Error('Parent node is not an array expression!');
}
const array = identifierNode.parent as TSESTree.ArrayExpression;
for (const element of array.elements) {
if (element !== null && element.type === TSESTree.AST_NODE_TYPES.Identifier && element.name === newValue) {
return removeWithCommas(context, fixer, identifierNode);
}
}
return [fixer.replaceText(identifierNode, newValue)];
}

View File

@@ -11,7 +11,10 @@ import { readFileSync } from 'fs';
import { basename } from 'path'; import { basename } from 'path';
import ts, { Identifier } from 'typescript'; import ts, { Identifier } from 'typescript';
import { isPartOfViewChild } from './angular'; import {
getComponentClassName,
isPartOfViewChild,
} from './angular';
import { import {
AnyRuleContext, AnyRuleContext,
getFilename, getFilename,
@@ -74,12 +77,14 @@ function findImportDeclaration(source: ts.SourceFile, identifierName: string): t
class ThemeableComponentRegistry { class ThemeableComponentRegistry {
public readonly entries: Set<ThemeableComponentRegistryEntry>; public readonly entries: Set<ThemeableComponentRegistryEntry>;
public readonly byBaseClass: Map<string, ThemeableComponentRegistryEntry>; public readonly byBaseClass: Map<string, ThemeableComponentRegistryEntry>;
public readonly byWrapperClass: Map<string, ThemeableComponentRegistryEntry>;
public readonly byBasePath: Map<string, ThemeableComponentRegistryEntry>; public readonly byBasePath: Map<string, ThemeableComponentRegistryEntry>;
public readonly byWrapperPath: Map<string, ThemeableComponentRegistryEntry>; public readonly byWrapperPath: Map<string, ThemeableComponentRegistryEntry>;
constructor() { constructor() {
this.entries = new Set(); this.entries = new Set();
this.byBaseClass = new Map(); this.byBaseClass = new Map();
this.byWrapperClass = new Map();
this.byBasePath = new Map(); this.byBasePath = new Map();
this.byWrapperPath = new Map(); this.byWrapperPath = new Map();
} }
@@ -157,6 +162,7 @@ class ThemeableComponentRegistry {
private add(entry: ThemeableComponentRegistryEntry) { private add(entry: ThemeableComponentRegistryEntry) {
this.entries.add(entry); this.entries.add(entry);
this.byBaseClass.set(entry.baseClass, entry); this.byBaseClass.set(entry.baseClass, entry);
this.byWrapperClass.set(entry.wrapperClass, entry);
this.byBasePath.set(entry.basePath, entry); this.byBasePath.set(entry.basePath, entry);
this.byWrapperPath.set(entry.wrapperPath, entry); this.byWrapperPath.set(entry.wrapperPath, entry);
} }
@@ -206,6 +212,23 @@ export function isThemedComponentWrapper(decoratorNode: TSESTree.Decorator): boo
return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent'; return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent';
} }
export function getBaseComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined {
const wrapperClass = getComponentClassName(decoratorNode);
if (wrapperClass === undefined) {
return;
}
themeableComponents.initialize();
const entry = themeableComponents.byWrapperClass.get(wrapperClass);
if (entry === undefined) {
return undefined;
}
return entry.baseClass;
}
export function isThemeableComponent(className: string): boolean { export function isThemeableComponent(className: string): boolean {
themeableComponents.initialize(); themeableComponents.initialize();
return themeableComponents.byBaseClass.has(className); return themeableComponents.byBaseClass.has(className);

View File

@@ -64,6 +64,24 @@ export function findUsages(context: AnyRuleContext, localNode: TSESTree.Identifi
return usages; return usages;
} }
export function findUsagesByName(context: AnyRuleContext, identifier: string): TSESTree.Identifier[] {
const source = getSourceCode(context);
const usages: TSESTree.Identifier[] = [];
for (const token of source.ast.tokens) {
if (token.type === 'Identifier' && token.value === identifier) {
const node = source.getNodeByRangeIndex(token.range[0]);
// todo: in some cases, the resulting node can actually be the whole program (!)
if (node !== null) {
usages.push(node as TSESTree.Identifier);
}
}
}
return usages;
}
export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean { export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean {
return node.parent?.type?.startsWith('TSType'); return node.parent?.type?.startsWith('TSType');
} }
@@ -71,3 +89,59 @@ export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean {
export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean { export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean {
return node.parent?.type === 'ClassDeclaration'; return node.parent?.type === 'ClassDeclaration';
} }
function fromSrc(path: string): string {
const m = path.match(/^.*(src\/.+)(\.(ts|json|js)?)$/);
if (m) {
return m[1];
} else {
throw new Error(`Can't infer project-absolute TS/resource path from: ${path}`);
}
}
export function relativePath(thisFile: string, importFile: string): string {
const fromParts = fromSrc(thisFile).split('/');
const toParts = fromSrc(importFile).split('/');
let lastCommon = 0;
for (let i = 0; i < fromParts.length - 1; i++) {
if (fromParts[i] === toParts[i]) {
lastCommon++;
} else {
break;
}
}
const path = toParts.slice(lastCommon, toParts.length).join('/');
const backtrack = fromParts.length - lastCommon - 1;
let prefix: string;
if (backtrack > 0) {
prefix = '../'.repeat(backtrack);
} else {
prefix = './';
}
return prefix + path;
}
export function findImportSpecifier(context: AnyRuleContext, identifier: string): TSESTree.ImportSpecifier | undefined {
const source = getSourceCode(context);
const usages: TSESTree.Identifier[] = [];
for (const token of source.ast.tokens) {
if (token.type === 'Identifier' && token.value === identifier) {
const node = source.getNodeByRangeIndex(token.range[0]);
// todo: in some cases, the resulting node can actually be the whole program (!)
if (node && node.parent && node.parent.type === TSESTree.AST_NODE_TYPES.ImportSpecifier) {
return node.parent;
}
}
}
return undefined;
}

View File

@@ -10,6 +10,7 @@ import { Component } from '@angular/core';
@Component({ @Component({
selector: 'ds-base-test-themeable', selector: 'ds-base-test-themeable',
template: '', template: '',
standalone: true,
}) })
export class TestThemeableComponent { export class TestThemeableComponent {
} }

View File

@@ -13,6 +13,8 @@ import { TestThemeableComponent } from './test-themeable.component';
@Component({ @Component({
selector: 'ds-test-themeable', selector: 'ds-test-themeable',
template: '', template: '',
standalone: true,
imports: [TestThemeableComponent],
}) })
export class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> { export class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
protected getComponentName(): string { protected getComponentName(): string {

View File

@@ -0,0 +1,16 @@
/**
* 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 { Component } from '@angular/core';
@Component({
selector: 'ds-themed-test-themeable',
template: '',
})
export class OtherThemeableComponent {
}

View File

@@ -8,11 +8,13 @@
// @ts-ignore // @ts-ignore
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { OtherThemeableComponent } from './app/test/other-themeable.component';
import { TestThemeableComponent } from './app/test/test-themeable.component'; import { TestThemeableComponent } from './app/test/test-themeable.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
TestThemeableComponent, TestThemeableComponent,
OtherThemeableComponent,
], ],
}) })
export class TestModule { export class TestModule {

View File

@@ -1,6 +1,7 @@
{ {
"extends": "../../../tsconfig.json", "extends": "../../../tsconfig.json",
"include": [ "include": [
"src/**/*.ts",
"src/**/*.ts" "src/**/*.ts"
], ],
"exclude": ["dist"] "exclude": ["dist"]

View File

@@ -20,6 +20,7 @@ import {
themeableComponents.initialize(FIXTURE); themeableComponents.initialize(FIXTURE);
TypeScriptRuleTester.itOnly = fit; TypeScriptRuleTester.itOnly = fit;
TypeScriptRuleTester.itSkip = xit;
export const tsRuleTester = new TypeScriptRuleTester({ export const tsRuleTester = new TypeScriptRuleTester({
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',