From a9e2eae596ab972618de42088d1a07cab09d7bc0 Mon Sep 17 00:00:00 2001 From: JamesHenry Date: Sun, 9 Nov 2025 23:07:26 +0400 Subject: [PATCH] feat(eslint-plugin): support multiple configs for component-selector and directive-selector --- .../docs/rules/component-selector.md | 600 +++++++++++++++++- .../docs/rules/directive-selector.md | 600 +++++++++++++++++- .../src/rules/component-selector.ts | 125 +++- .../src/rules/directive-selector.ts | 136 +++- .../tests/rules/component-selector/cases.ts | 199 ++++++ .../tests/rules/directive-selector/cases.ts | 199 ++++++ .../utils/src/eslint-plugin/selector-utils.ts | 109 +++- 7 files changed, 1877 insertions(+), 91 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/component-selector.md b/packages/eslint-plugin/docs/rules/component-selector.md index 0b9592789..de8bff869 100644 --- a/packages/eslint-plugin/docs/rules/component-selector.md +++ b/packages/eslint-plugin/docs/rules/component-selector.md @@ -32,20 +32,67 @@ Consistent component selector naming conventions provide several benefits: they The rule accepts an options object with the following properties: ```ts -interface Options { - /** - * Default: `""` - */ - type?: string | ("element" | "attribute")[]; - /** - * Default: `""` - */ - prefix?: string | unknown[]; - /** - * Default: `""` - */ - style?: "camelCase" | "kebab-case"; -} +type Options = + | { + /** + * Default: `""` + */ + type?: string | ("element" | "attribute")[]; + /** + * Default: `""` + */ + prefix?: string | unknown[]; + /** + * Default: `""` + */ + style?: "camelCase" | "kebab-case"; + } + | [ + { + /** + * Default: `""` + */ + type: "element" | "attribute"; + /** + * Default: `""` + */ + prefix?: string | unknown[]; + /** + * Default: `""` + */ + style?: "camelCase" | "kebab-case"; + } + ] + | [ + { + /** + * Default: `""` + */ + type: "element" | "attribute"; + /** + * Default: `""` + */ + prefix?: string | unknown[]; + /** + * Default: `""` + */ + style?: "camelCase" | "kebab-case"; + }, + { + /** + * Default: `""` + */ + type: "element" | "attribute"; + /** + * Default: `""` + */ + prefix?: string | unknown[]; + /** + * Default: `""` + */ + style?: "camelCase" | "kebab-case"; + } + ]; ``` @@ -571,6 +618,248 @@ class Test {} class Test {} ``` +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({ + selector: 'appFooBar' + ~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({ + selector: '[app-foo-bar]' + ~~~~~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({ + selector: 'appFooBar' + ~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({ + selector: '[app-foo-bar]' + ~~~~~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({ + selector: 'lib-foo-bar' + ~~~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({ + selector: '[libFooBar]' + ~~~~~~~~~~~~~ +}) +class Test {} +``` +
@@ -1249,6 +1538,289 @@ class Test {} class Test {} ``` +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({ + selector: 'app-foo-bar' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({ + selector: '[appFooBar]' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({ + selector: 'app-foo-bar' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({ + selector: '[appFooBar]' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": [ + "app", + "lib" + ], + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({ + selector: 'lib-foo-bar' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": [ + "app", + "lib" + ], + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({ + selector: '[libFooBar]' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/component-selector": [ + "error", + [ + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + }, + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({ + selector: '[appFooBar]' +}) +class Test {} +``` +
diff --git a/packages/eslint-plugin/docs/rules/directive-selector.md b/packages/eslint-plugin/docs/rules/directive-selector.md index 1e2adfb54..8f658b9c4 100644 --- a/packages/eslint-plugin/docs/rules/directive-selector.md +++ b/packages/eslint-plugin/docs/rules/directive-selector.md @@ -32,20 +32,67 @@ Consistent directive selector naming conventions help identify which directives The rule accepts an options object with the following properties: ```ts -interface Options { - /** - * Default: `""` - */ - type?: string | ("element" | "attribute")[]; - /** - * Default: `""` - */ - prefix?: string | unknown[]; - /** - * Default: `""` - */ - style?: "camelCase" | "kebab-case"; -} +type Options = + | { + /** + * Default: `""` + */ + type?: string | ("element" | "attribute")[]; + /** + * Default: `""` + */ + prefix?: string | unknown[]; + /** + * Default: `""` + */ + style?: "camelCase" | "kebab-case"; + } + | [ + { + /** + * Default: `""` + */ + type: "element" | "attribute"; + /** + * Default: `""` + */ + prefix?: string | unknown[]; + /** + * Default: `""` + */ + style?: "camelCase" | "kebab-case"; + } + ] + | [ + { + /** + * Default: `""` + */ + type: "element" | "attribute"; + /** + * Default: `""` + */ + prefix?: string | unknown[]; + /** + * Default: `""` + */ + style?: "camelCase" | "kebab-case"; + }, + { + /** + * Default: `""` + */ + type: "element" | "attribute"; + /** + * Default: `""` + */ + prefix?: string | unknown[]; + /** + * Default: `""` + */ + style?: "camelCase" | "kebab-case"; + } + ]; ``` @@ -349,6 +396,248 @@ class Test {} class Test {} ``` +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({ + selector: 'appFooBar' + ~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({ + selector: '[app-foo-bar]' + ~~~~~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({ + selector: 'appFooBar' + ~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({ + selector: '[app-foo-bar]' + ~~~~~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({ + selector: 'lib-foo-bar' + ~~~~~~~~~~~~~ +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({ + selector: '[libFooBar]' + ~~~~~~~~~~~~~ +}) +class Test {} +``` +
@@ -872,6 +1161,289 @@ class Test {} class Test {} ``` +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({ + selector: 'app-foo-bar' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({ + selector: '[appFooBar]' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({ + selector: 'app-foo-bar' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({ + selector: '[appFooBar]' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": [ + "app", + "lib" + ], + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({ + selector: 'lib-foo-bar' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + }, + { + "type": "attribute", + "prefix": [ + "app", + "lib" + ], + "style": "camelCase" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({ + selector: '[libFooBar]' +}) +class Test {} +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/directive-selector": [ + "error", + [ + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + }, + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({ + selector: '[appFooBar]' +}) +class Test {} +``` +
diff --git a/packages/eslint-plugin/src/rules/component-selector.ts b/packages/eslint-plugin/src/rules/component-selector.ts index 72339afdc..d96536011 100644 --- a/packages/eslint-plugin/src/rules/component-selector.ts +++ b/packages/eslint-plugin/src/rules/component-selector.ts @@ -8,7 +8,7 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { ASTUtils as TSESLintASTUtils } from '@typescript-eslint/utils'; import { createESLintRule } from '../utils/create-eslint-rule'; -export type Options = SelectorUtils.Options; +export type Options = SelectorUtils.RuleOptions; export type MessageIds = | 'prefixFailure' | 'styleFailure' @@ -33,35 +33,70 @@ export default createESLintRule({ }, schema: [ { - type: 'object', - properties: { - type: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { + oneOf: [ + // Single config object + { + type: 'object', + properties: { + type: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { + type: 'string', + enum: [ + SelectorUtils.OPTION_TYPE_ELEMENT, + SelectorUtils.OPTION_TYPE_ATTRIBUTE, + ], + }, + }, + ], + }, + prefix: { + oneOf: [{ type: 'string' }, { type: 'array' }], + }, + style: { + type: 'string', + enum: [ + ASTUtils.OPTION_STYLE_CAMEL_CASE, + ASTUtils.OPTION_STYLE_KEBAB_CASE, + ], + }, + }, + additionalProperties: false, + }, + // Array of 1-2 config objects + { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: [ SelectorUtils.OPTION_TYPE_ELEMENT, SelectorUtils.OPTION_TYPE_ATTRIBUTE, ], }, + prefix: { + oneOf: [{ type: 'string' }, { type: 'array' }], + }, + style: { + type: 'string', + enum: [ + ASTUtils.OPTION_STYLE_CAMEL_CASE, + ASTUtils.OPTION_STYLE_KEBAB_CASE, + ], + }, }, - ], - }, - prefix: { - oneOf: [{ type: 'string' }, { type: 'array' }], - }, - style: { - type: 'string', - enum: [ - ASTUtils.OPTION_STYLE_CAMEL_CASE, - ASTUtils.OPTION_STYLE_KEBAB_CASE, - ], + additionalProperties: false, + required: ['type'], + }, + minItems: 1, + maxItems: 2, }, - }, - additionalProperties: false, + ], }, ], messages: { @@ -79,7 +114,10 @@ export default createESLintRule({ style: '', }, ], - create(context, [{ type, prefix, style }]) { + create(context, [options]) { + // Normalize options to a consistent format using shared utility + const configByType = SelectorUtils.normalizeOptionsToConfigs(options); + return { [Selectors.COMPONENT_CLASS_DECORATOR](node: TSESTree.Decorator) { const rawSelectors = ASTUtils.getDecoratorPropertyValue( @@ -91,17 +129,52 @@ export default createESLintRule({ return; } + // Parse selectors once for reuse + const parsedSelectors = SelectorUtils.parseSelectorNode(rawSelectors); + if (!parsedSelectors || parsedSelectors.length === 0) { + return; + } + + // For multiple configs, determine the actual selector type + let applicableConfig: SelectorUtils.SelectorConfig | null = null; + + if (configByType.size > 1) { + // Multiple configs - need to determine which one applies + const actualType = SelectorUtils.getActualSelectorType(rawSelectors); + if (!actualType) { + return; + } + + const config = configByType.get(actualType); + if (!config) { + // No config defined for this selector type + return; + } + applicableConfig = config; + } else { + // Single config or single type extracted from array + const firstEntry = configByType.entries().next(); + if (!firstEntry.done) { + applicableConfig = firstEntry.value[1]; + } + } + + if (!applicableConfig) { + return; + } + + const { type, prefix, style } = applicableConfig; + const isValidOptions = SelectorUtils.checkValidOptions( type, prefix, style, ); - if (!isValidOptions) { return; } - // override `style` for ShadowDom-encapsulated components. See https://github.com/angular-eslint/angular-eslint/issues/534. + // Override `style` for ShadowDom-encapsulated components. See https://github.com/angular-eslint/angular-eslint/issues/534. const overrideStyle = style !== ASTUtils.OPTION_STYLE_KEBAB_CASE && hasEncapsulationShadowDomProperty(node) @@ -113,12 +186,14 @@ export default createESLintRule({ type, arrayify(prefix), overrideStyle as ASTUtils.SelectorStyle, + parsedSelectors, ); if (hasExpectedSelector === null) { return; } + // Component-specific validation logic (includes styleAndPrefixFailure) if (!hasExpectedSelector.hasExpectedType) { SelectorUtils.reportTypeError(rawSelectors, type, context); } else if (!hasExpectedSelector.hasExpectedStyle) { diff --git a/packages/eslint-plugin/src/rules/directive-selector.ts b/packages/eslint-plugin/src/rules/directive-selector.ts index 0bdbb5d8e..4acb843ff 100644 --- a/packages/eslint-plugin/src/rules/directive-selector.ts +++ b/packages/eslint-plugin/src/rules/directive-selector.ts @@ -7,57 +7,91 @@ import { import type { TSESTree } from '@typescript-eslint/utils'; import { createESLintRule } from '../utils/create-eslint-rule'; -export type Options = SelectorUtils.Options; +export type Options = SelectorUtils.RuleOptions; export type MessageIds = 'prefixFailure' | 'styleFailure' | 'typeFailure'; export const RULE_NAME = 'directive-selector'; -const STYLE_GUIDE_LINK = - 'https://angular.dev/style-guide#choosing-directive-selectors'; - export default createESLintRule({ name: RULE_NAME, meta: { type: 'suggestion', docs: { - description: `Directive selectors should follow given naming rules. See more at ${STYLE_GUIDE_LINK}.`, + description: + 'Directive selectors should follow given naming rules. See more at https://angular.dev/style-guide#choosing-directive-selectors.', }, schema: [ { - type: 'object', - properties: { - type: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { + oneOf: [ + // Single config object + { + type: 'object', + properties: { + type: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { + type: 'string', + enum: [ + SelectorUtils.OPTION_TYPE_ELEMENT, + SelectorUtils.OPTION_TYPE_ATTRIBUTE, + ], + }, + }, + ], + }, + prefix: { + oneOf: [{ type: 'string' }, { type: 'array' }], + }, + style: { + type: 'string', + enum: [ + ASTUtils.OPTION_STYLE_CAMEL_CASE, + ASTUtils.OPTION_STYLE_KEBAB_CASE, + ], + }, + }, + additionalProperties: false, + }, + // Array of 1-2 config objects + { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: [ SelectorUtils.OPTION_TYPE_ELEMENT, SelectorUtils.OPTION_TYPE_ATTRIBUTE, ], }, + prefix: { + oneOf: [{ type: 'string' }, { type: 'array' }], + }, + style: { + type: 'string', + enum: [ + ASTUtils.OPTION_STYLE_CAMEL_CASE, + ASTUtils.OPTION_STYLE_KEBAB_CASE, + ], + }, }, - ], + additionalProperties: false, + required: ['type'], + }, + minItems: 1, + maxItems: 2, }, - prefix: { - oneOf: [{ type: 'string' }, { type: 'array' }], - }, - style: { - type: 'string', - enum: [ - ASTUtils.OPTION_STYLE_CAMEL_CASE, - ASTUtils.OPTION_STYLE_KEBAB_CASE, - ], - }, - }, - additionalProperties: false, + ], }, ], messages: { - prefixFailure: `The selector should start with one of these prefixes: {{prefix}} (${STYLE_GUIDE_LINK})`, - styleFailure: `The selector should be {{style}} (${STYLE_GUIDE_LINK})`, - typeFailure: `The selector should be used as an {{type}} (${STYLE_GUIDE_LINK})`, + prefixFailure: + 'The selector should start with one of these prefixes: {{prefix}}', + styleFailure: 'The selector should be {{style}}', + typeFailure: 'The selector should be used as an {{type}}', }, }, defaultOptions: [ @@ -67,7 +101,10 @@ export default createESLintRule({ style: '', }, ], - create(context, [{ type, prefix, style }]) { + create(context, [options]) { + // Normalize options to a consistent format using shared utility + const configByType = SelectorUtils.normalizeOptionsToConfigs(options); + return { [Selectors.DIRECTIVE_CLASS_DECORATOR](node: TSESTree.Decorator) { const rawSelectors = ASTUtils.getDecoratorPropertyValue( @@ -79,12 +116,47 @@ export default createESLintRule({ return; } + // Parse selectors once for reuse + const parsedSelectors = SelectorUtils.parseSelectorNode(rawSelectors); + if (!parsedSelectors || parsedSelectors.length === 0) { + return; + } + + // For multiple configs, determine the actual selector type + let applicableConfig: SelectorUtils.SelectorConfig | null = null; + + if (configByType.size > 1) { + // Multiple configs - need to determine which one applies + const actualType = SelectorUtils.getActualSelectorType(rawSelectors); + if (!actualType) { + return; + } + + const config = configByType.get(actualType); + if (!config) { + // No config defined for this selector type + return; + } + applicableConfig = config; + } else { + // Single config or single type extracted from array + const firstEntry = configByType.entries().next(); + if (!firstEntry.done) { + applicableConfig = firstEntry.value[1]; + } + } + + if (!applicableConfig) { + return; + } + + const { type, prefix, style } = applicableConfig; + const isValidOptions = SelectorUtils.checkValidOptions( type, prefix, style, ); - if (!isValidOptions) { return; } @@ -94,12 +166,14 @@ export default createESLintRule({ type, arrayify(prefix), style as ASTUtils.SelectorStyle, + parsedSelectors, ); if (hasExpectedSelector === null) { return; } + // Directive-specific validation logic (simpler than component) if (!hasExpectedSelector.hasExpectedType) { SelectorUtils.reportTypeError(rawSelectors, type, context); } else if (!hasExpectedSelector.hasExpectedStyle) { diff --git a/packages/eslint-plugin/tests/rules/component-selector/cases.ts b/packages/eslint-plugin/tests/rules/component-selector/cases.ts index 30d32d5b7..8bb2768e2 100644 --- a/packages/eslint-plugin/tests/rules/component-selector/cases.ts +++ b/packages/eslint-plugin/tests/rules/component-selector/cases.ts @@ -240,6 +240,101 @@ export const valid: readonly (string | ValidTestCase)[] = [ `, options: [{ type: 'element', style: 'kebab-case', prefix: '' }], }, + // Single config array - element only + { + code: ` + @Component({ + selector: 'app-foo-bar' + }) + class Test {} + `, + options: [[{ type: 'element', prefix: 'app', style: 'kebab-case' }]], + }, + // Single config array - attribute only + { + code: ` + @Component({ + selector: '[appFooBar]' + }) + class Test {} + `, + options: [[{ type: 'attribute', prefix: 'app', style: 'camelCase' }]], + }, + // Multiple configs - element with kebab-case + { + code: ` + @Component({ + selector: 'app-foo-bar' + }) + class Test {} + `, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + }, + // Multiple configs - attribute with camelCase + { + code: ` + @Component({ + selector: '[appFooBar]' + }) + class Test {} + `, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + }, + // Multiple configs - element with array of prefixes + { + code: ` + @Component({ + selector: 'lib-foo-bar' + }) + class Test {} + `, + options: [ + [ + { type: 'element', prefix: ['app', 'lib'], style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + }, + // Multiple configs - attribute with array of prefixes + { + code: ` + @Component({ + selector: '[libFooBar]' + }) + class Test {} + `, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: ['app', 'lib'], style: 'camelCase' }, + ], + ], + }, + // Multiple configs - config order shouldn't matter + { + code: ` + @Component({ + selector: '[appFooBar]' + }) + class Test {} + `, + options: [ + [ + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + { type: 'element', prefix: 'app', style: 'kebab-case' }, + ], + ], + }, ]; export const invalid: readonly InvalidTestCase[] = [ @@ -431,4 +526,108 @@ export const invalid: readonly InvalidTestCase[] = [ options: [{ type: 'element', prefix: 'sg', style: 'kebab-case' }], data: { prefix: '"sg"' }, }), + // Single config array - element with wrong style + convertAnnotatedSourceToFailureCase({ + description: `should fail if an element selector doesn't match when using single config array`, + annotatedSource: ` + @Component({ + selector: 'appFooBar' + ~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdStyleAndPrefixFailure, + options: [[{ type: 'element', prefix: 'app', style: 'kebab-case' }]], + data: { style: 'kebab-case', prefix: '"app"' }, + }), + // Single config array - attribute with wrong style + convertAnnotatedSourceToFailureCase({ + description: `should fail if an attribute selector doesn't match when using single config array`, + annotatedSource: ` + @Component({ + selector: '[app-foo-bar]' + ~~~~~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdStyleFailure, + options: [[{ type: 'attribute', prefix: 'app', style: 'camelCase' }]], + data: { style: 'camelCase' }, + }), + // Multiple configs - element with wrong style + convertAnnotatedSourceToFailureCase({ + description: `should fail if an element selector doesn't match kebab-case when using multiple configs`, + annotatedSource: ` + @Component({ + selector: 'appFooBar' + ~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdStyleAndPrefixFailure, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + data: { style: 'kebab-case', prefix: '"app"' }, + }), + // Multiple configs - attribute with wrong style + convertAnnotatedSourceToFailureCase({ + description: `should fail if an attribute selector doesn't match camelCase when using multiple configs`, + annotatedSource: ` + @Component({ + selector: '[app-foo-bar]' + ~~~~~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdStyleFailure, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + data: { style: 'camelCase' }, + }), + // Multiple configs - element with wrong prefix + convertAnnotatedSourceToFailureCase({ + description: `should fail if an element selector has wrong prefix when using multiple configs`, + annotatedSource: ` + @Component({ + selector: 'lib-foo-bar' + ~~~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdPrefixFailure, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + data: { prefix: '"app"' }, + }), + // Multiple configs - attribute with wrong prefix + convertAnnotatedSourceToFailureCase({ + description: `should fail if an attribute selector has wrong prefix when using multiple configs`, + annotatedSource: ` + @Component({ + selector: '[libFooBar]' + ~~~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdPrefixFailure, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + data: { prefix: '"app"' }, + }), ]; diff --git a/packages/eslint-plugin/tests/rules/directive-selector/cases.ts b/packages/eslint-plugin/tests/rules/directive-selector/cases.ts index a040d5188..94bd1c765 100644 --- a/packages/eslint-plugin/tests/rules/directive-selector/cases.ts +++ b/packages/eslint-plugin/tests/rules/directive-selector/cases.ts @@ -181,6 +181,101 @@ export const valid: readonly (string | ValidTestCase)[] = [ }, ], }, + // Single config array - element only + { + code: ` + @Directive({ + selector: 'app-foo-bar' + }) + class Test {} + `, + options: [[{ type: 'element', prefix: 'app', style: 'kebab-case' }]], + }, + // Single config array - attribute only + { + code: ` + @Directive({ + selector: '[appFooBar]' + }) + class Test {} + `, + options: [[{ type: 'attribute', prefix: 'app', style: 'camelCase' }]], + }, + // Multiple configs - element with kebab-case + { + code: ` + @Directive({ + selector: 'app-foo-bar' + }) + class Test {} + `, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + }, + // Multiple configs - attribute with camelCase + { + code: ` + @Directive({ + selector: '[appFooBar]' + }) + class Test {} + `, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + }, + // Multiple configs - element with array of prefixes + { + code: ` + @Directive({ + selector: 'lib-foo-bar' + }) + class Test {} + `, + options: [ + [ + { type: 'element', prefix: ['app', 'lib'], style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + }, + // Multiple configs - attribute with array of prefixes + { + code: ` + @Directive({ + selector: '[libFooBar]' + }) + class Test {} + `, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: ['app', 'lib'], style: 'camelCase' }, + ], + ], + }, + // Multiple configs - config order shouldn't matter + { + code: ` + @Directive({ + selector: '[appFooBar]' + }) + class Test {} + `, + options: [ + [ + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + { type: 'element', prefix: 'app', style: 'kebab-case' }, + ], + ], + }, ]; export const invalid: readonly InvalidTestCase[] = [ @@ -292,4 +387,108 @@ export const invalid: readonly InvalidTestCase[] = [ options: [{ type: 'element', prefix: ['app', 'ng'], style: 'camelCase' }], data: { type: 'element' }, }), + // Single config array - element with wrong style + convertAnnotatedSourceToFailureCase({ + description: `should fail if an element selector doesn't match when using single config array`, + annotatedSource: ` + @Directive({ + selector: 'appFooBar' + ~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdStyleFailure, + options: [[{ type: 'element', prefix: 'app', style: 'kebab-case' }]], + data: { style: 'kebab-case' }, + }), + // Single config array - attribute with wrong style + convertAnnotatedSourceToFailureCase({ + description: `should fail if an attribute selector doesn't match when using single config array`, + annotatedSource: ` + @Directive({ + selector: '[app-foo-bar]' + ~~~~~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdStyleFailure, + options: [[{ type: 'attribute', prefix: 'app', style: 'camelCase' }]], + data: { style: 'camelCase' }, + }), + // Multiple configs - element with wrong style + convertAnnotatedSourceToFailureCase({ + description: `should fail if an element selector doesn't match kebab-case when using multiple configs`, + annotatedSource: ` + @Directive({ + selector: 'appFooBar' + ~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdStyleFailure, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + data: { style: 'kebab-case' }, + }), + // Multiple configs - attribute with wrong style + convertAnnotatedSourceToFailureCase({ + description: `should fail if an attribute selector doesn't match camelCase when using multiple configs`, + annotatedSource: ` + @Directive({ + selector: '[app-foo-bar]' + ~~~~~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdStyleFailure, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + data: { style: 'camelCase' }, + }), + // Multiple configs - element with wrong prefix + convertAnnotatedSourceToFailureCase({ + description: `should fail if an element selector has wrong prefix when using multiple configs`, + annotatedSource: ` + @Directive({ + selector: 'lib-foo-bar' + ~~~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdPrefixFailure, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + data: { prefix: '"app"' }, + }), + // Multiple configs - attribute with wrong prefix + convertAnnotatedSourceToFailureCase({ + description: `should fail if an attribute selector has wrong prefix when using multiple configs`, + annotatedSource: ` + @Directive({ + selector: '[libFooBar]' + ~~~~~~~~~~~~~ + }) + class Test {} + `, + messageId: messageIdPrefixFailure, + options: [ + [ + { type: 'element', prefix: 'app', style: 'kebab-case' }, + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + ], + data: { prefix: '"app"' }, + }), ]; diff --git a/packages/utils/src/eslint-plugin/selector-utils.ts b/packages/utils/src/eslint-plugin/selector-utils.ts index 61ca281f9..e9885561e 100644 --- a/packages/utils/src/eslint-plugin/selector-utils.ts +++ b/packages/utils/src/eslint-plugin/selector-utils.ts @@ -22,6 +22,17 @@ export type SelectorTypeInternal = | typeof OPTION_TYPE_ATTRS | typeof OPTION_TYPE_ELEMENT; +// Shared type definitions for selector rules +export type SelectorConfig = { + readonly type: SelectorTypeOption; + readonly prefix: string | readonly string[]; + readonly style: SelectorStyleOption; +}; + +export type SingleConfigOption = Options[number]; +export type MultipleConfigOption = readonly SelectorConfig[]; +export type RuleOptions = readonly [SingleConfigOption | MultipleConfigOption]; + const SELECTOR_TYPE_MAPPER: Record = { [OPTION_TYPE_ATTRIBUTE]: OPTION_TYPE_ATTRS, [OPTION_TYPE_ELEMENT]: OPTION_TYPE_ELEMENT, @@ -148,6 +159,47 @@ export const reportTypeError = ( }); }; +export const parseSelectorNode = ( + node: TSESTree.Node, +): readonly CssSelector[] | null => { + if (isLiteral(node)) { + return CssSelector.parse(node.raw); + } else if (isTemplateLiteral(node) && node.quasis[0]) { + return CssSelector.parse(node.quasis[0].value.raw); + } + return null; +}; + +export const getActualSelectorType = ( + node: TSESTree.Node, +): SelectorTypeOption | null => { + const listSelectors = parseSelectorNode(node); + + if (!listSelectors || listSelectors.length === 0) { + return null; + } + + // Check the first selector to determine type + const firstSelector = listSelectors[0]; + + // Attribute selectors have attrs populated (e.g., [appFoo]) + // CssSelector.attrs is an array where each attribute is stored as [name, value] + if (Array.isArray(firstSelector.attrs) && firstSelector.attrs.length > 0) { + return OPTION_TYPE_ATTRIBUTE; + } + + // Element selectors have a non-null, non-empty element (e.g., app-foo) + if ( + firstSelector.element != null && + firstSelector.element !== '' && + firstSelector.element !== '*' + ) { + return OPTION_TYPE_ELEMENT; + } + + return null; +}; + export const checkValidOptions = ( type: SelectorTypeOption | readonly SelectorTypeOption[], prefix: string | readonly string[], @@ -180,6 +232,7 @@ export const checkSelector = ( typeOption: SelectorTypeOption | readonly SelectorTypeOption[], prefixOption: readonly string[], styleOption: SelectorStyle, + parsedSelectors?: readonly CssSelector[] | null, ): { readonly hasExpectedPrefix: boolean; readonly hasExpectedType: boolean; @@ -199,13 +252,8 @@ export const checkSelector = ( ? SelectorValidator.kebabCase : SelectorValidator.camelCase; - let listSelectors = null; - - if (node && isLiteral(node)) { - listSelectors = CssSelector.parse(node.raw); - } else if (node && isTemplateLiteral(node) && node.quasis[0]) { - listSelectors = CssSelector.parse(node.quasis[0].value.raw); - } + // Use provided parsed selectors or parse them + const listSelectors = parsedSelectors ?? parseSelectorNode(node); if (!listSelectors) { return null; @@ -231,3 +279,50 @@ export const checkSelector = ( hasExpectedStyle, }; }; + +// Type guard for multiple configs +export const isMultipleConfigOption = ( + option: SingleConfigOption | MultipleConfigOption, +): option is MultipleConfigOption => { + return ( + Array.isArray(option) && + option.length >= 1 && + option.length <= 2 && + option.every((config) => typeof config.type === 'string') + ); +}; + +// Normalize options to a consistent format +export const normalizeOptionsToConfigs = ( + option: SingleConfigOption | MultipleConfigOption, +): Map => { + const configByType = new Map(); + + if (isMultipleConfigOption(option)) { + // Validate no duplicate types + const types = option.map((config) => config.type); + if (new Set(types).size !== types.length) { + throw new Error( + 'Invalid rule config: Each config object in the options array must have a unique "type" property (either "element" or "attribute")', + ); + } + + // Build lookup map by type + for (const config of option) { + configByType.set(config.type, config); + } + } else { + // Single config - normalize to map format + // Handle both single type and array of types + const types = arrayify(option.type); + for (const type of types) { + configByType.set(type, { + type, + prefix: option.prefix, + style: option.style, + }); + } + } + + return configByType; +};