diff --git a/.eslintrc.json b/.eslintrc.json index 9e9a2b55366..335cfa3495d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -296,6 +296,15 @@ "dataService" ] } + ], + "dspace-angular-ts/sort-standalone-imports": [ + "error", + { + "locale": "en-US", + "maxItems": 1, + "indent": " ", + "trailingComma": true + } ] } }, diff --git a/lint/src/rules/ts/index.ts b/lint/src/rules/ts/index.ts index 639afbb0675..531f0b3b9f7 100644 --- a/lint/src/rules/ts/index.ts +++ b/lint/src/rules/ts/index.ts @@ -11,6 +11,7 @@ import { } from '../../util/structure'; /* eslint-disable import/no-namespace */ import * as aliasImports from './alias-imports'; +import * as sortStandaloneImports from './sort-standalone-imports'; import * as themedComponentClasses from './themed-component-classes'; import * as themedComponentSelectors from './themed-component-selectors'; import * as themedComponentUsages from './themed-component-usages'; @@ -20,6 +21,7 @@ import * as uniqueDecorators from './unique-decorators'; const index = [ aliasImports, + sortStandaloneImports, themedComponentClasses, themedComponentSelectors, themedComponentUsages, diff --git a/lint/src/rules/ts/sort-standalone-imports.ts b/lint/src/rules/ts/sort-standalone-imports.ts new file mode 100644 index 00000000000..181453836f2 --- /dev/null +++ b/lint/src/rules/ts/sort-standalone-imports.ts @@ -0,0 +1,262 @@ +import { + ASTUtils as TSESLintASTUtils, + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +import { + DSpaceESLintRuleInfo, + NamedTests, + OptionDoc, +} from '../../util/structure'; + +const DEFAULT_LOCALE = 'en-US'; +const DEFAULT_MAX_SIZE = 1; +const DEFAULT_INDENT = ' '; +const DEFAULT_TRAILING_COMMA = true; + +export enum Message { + SORT_STANDALONE_IMPORTS_ARRAYS = 'sortStandaloneImportsArrays', +} + +export interface UniqueDecoratorsOptions { + locale: string; + maxItems: number; + indent: string; + trailingComma: boolean; +} + +export interface UniqueDecoratorsDocOptions { + locale: OptionDoc; + maxItems: OptionDoc; + indent: OptionDoc; + trailingComma: OptionDoc; +} + +export const info: DSpaceESLintRuleInfo<[UniqueDecoratorsOptions], [UniqueDecoratorsDocOptions]> = { + name: 'sort-standalone-imports', + meta: { + docs: { + description: 'Sorts the standalone `@Component` imports alphabetically', + }, + messages: { + [Message.SORT_STANDALONE_IMPORTS_ARRAYS]: 'Standalone imports should be sorted alphabetically', + }, + fixable: 'code', + type: 'problem', + schema: [ + { + type: 'object', + properties: { + locale: { + type: 'string', + }, + maxItems: { + type: 'number', + }, + indent: { + type: 'string', + }, + trailingComma: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + }, + optionDocs: [ + { + locale: { + title: '`locale`', + description: 'The locale used to sort the imports.', + }, + maxItems: { + title: '`maxItems`', + description: 'The maximum number of imports that should be displayed before each import is separated onto its own line.', + }, + indent: { + title: '`indent`', + description: 'The indent used for the project.', + }, + trailingComma: { + title: '`trailingComma`', + description: 'Whether the last import should have a trailing comma (only applicable for multiline imports).', + }, + }, + ], + defaultOptions: [ + { + locale: DEFAULT_LOCALE, + maxItems: DEFAULT_MAX_SIZE, + indent: DEFAULT_INDENT, + trailingComma: DEFAULT_TRAILING_COMMA, + }, + ], +}; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: TSESLint.RuleContext, [{ locale, maxItems, indent, trailingComma }]: any) { + return { + ['ClassDeclaration > Decorator > CallExpression[callee.name="Component"] > ObjectExpression > Property[key.name="imports"] > ArrayExpression']: (node: TSESTree.ArrayExpression) => { + const identifiers = node.elements.filter(TSESLintASTUtils.isIdentifier); + const sortedNames: string[] = identifiers + .map((identifier) => identifier.name) + .sort((a: string, b: string) => a.localeCompare(b, locale)); + + const isSorted: boolean = identifiers.every((identifier, index) => identifier.name === sortedNames[index]); + + const requiresMultiline: boolean = maxItems < node.elements.length; + const isMultiline: boolean = /\n/.test(context.sourceCode.getText(node)); + + const incorrectFormat: boolean = requiresMultiline !== isMultiline; + + if (isSorted && !incorrectFormat) { + return; + } + + context.report({ + node, + messageId: Message.SORT_STANDALONE_IMPORTS_ARRAYS, + fix: (fixer: TSESLint.RuleFixer) => { + if (requiresMultiline) { + const multilineImports: string = sortedNames + .map((name: string) => `${indent}${indent}${name}${trailingComma ? ',' : ''}`) + .join(trailingComma ? '\n' : ',\n'); + + return fixer.replaceText(node, `[\n${multilineImports}\n${indent}]`); + } else { + return fixer.replaceText(node, `[${sortedNames.join(', ')}]`); + } + }, + }); + }, + }; + }, +}); + +export const tests: NamedTests = { + plugin: info.name, + valid: [ + { + name: 'should sort multiple imports on separate lines', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + RootComponent, + ], +}) +export class AppComponent {}`, + }, + { + name: 'inlines singular imports', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [RootComponent], +}) +export class AppComponent {}`, + }, + ], + invalid: [ + { + name: 'should sort multiple imports alphabetically', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + AsyncPipe, + ], +}) +export class AppComponent {}`, + errors: [ + { + messageId: Message.SORT_STANDALONE_IMPORTS_ARRAYS, + }, + ], + output: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + RootComponent, + ], +}) +export class AppComponent {}`, + }, + { + name: 'should not put singular imports on a separate line', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + ], +}) +export class AppComponent {}`, + errors: [ + { + messageId: Message.SORT_STANDALONE_IMPORTS_ARRAYS, + }, + ], + output: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [RootComponent], +}) +export class AppComponent {}`, + }, + { + name: 'should display multiple imports on separate lines', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [AsyncPipe, RootComponent], +}) +export class AppComponent {}`, + errors: [ + { + messageId: Message.SORT_STANDALONE_IMPORTS_ARRAYS, + }, + ], + output: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + RootComponent, + ], +}) +export class AppComponent {}`, + }, + ], +};