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;
+};