diff --git a/packages/eslint-plugin-template/docs/rules/attributes-order.md b/packages/eslint-plugin-template/docs/rules/attributes-order.md
new file mode 100644
index 000000000..382438bd1
--- /dev/null
+++ b/packages/eslint-plugin-template/docs/rules/attributes-order.md
@@ -0,0 +1,507 @@
+
+
+
+
+# `@angular-eslint/template/attributes-order`
+
+Ensures that HTML attributes and Angular bindings are sorted based on an expected order
+
+- Type: layout
+- 🔧 Supports autofix (`--fix`)
+
+
+
+## Rule Options
+
+The rule accepts an options object with the following properties:
+
+```ts
+interface Options {
+ alphabetical?: boolean;
+ /**
+ * Default: `["STRUCTURAL_DIRECTIVE","TEMPLATE_REFERENCE","ATTRIBUTE_BINDING","INPUT_BINDING","TWO_WAY_BINDING","OUTPUT_BINDING"]`
+ *
+ * @minItems 6
+ */
+ order?: [
+ (
+ | "STRUCTURAL_DIRECTIVE"
+ | "TEMPLATE_REFERENCE"
+ | "ATTRIBUTE_BINDING"
+ | "INPUT_BINDING"
+ | "TWO_WAY_BINDING"
+ | "OUTPUT_BINDING"
+ ),
+ (
+ | "STRUCTURAL_DIRECTIVE"
+ | "TEMPLATE_REFERENCE"
+ | "ATTRIBUTE_BINDING"
+ | "INPUT_BINDING"
+ | "TWO_WAY_BINDING"
+ | "OUTPUT_BINDING"
+ ),
+ (
+ | "STRUCTURAL_DIRECTIVE"
+ | "TEMPLATE_REFERENCE"
+ | "ATTRIBUTE_BINDING"
+ | "INPUT_BINDING"
+ | "TWO_WAY_BINDING"
+ | "OUTPUT_BINDING"
+ ),
+ (
+ | "STRUCTURAL_DIRECTIVE"
+ | "TEMPLATE_REFERENCE"
+ | "ATTRIBUTE_BINDING"
+ | "INPUT_BINDING"
+ | "TWO_WAY_BINDING"
+ | "OUTPUT_BINDING"
+ ),
+ (
+ | "STRUCTURAL_DIRECTIVE"
+ | "TEMPLATE_REFERENCE"
+ | "ATTRIBUTE_BINDING"
+ | "INPUT_BINDING"
+ | "TWO_WAY_BINDING"
+ | "OUTPUT_BINDING"
+ ),
+ (
+ | "STRUCTURAL_DIRECTIVE"
+ | "TEMPLATE_REFERENCE"
+ | "ATTRIBUTE_BINDING"
+ | "INPUT_BINDING"
+ | "TWO_WAY_BINDING"
+ | "OUTPUT_BINDING"
+ ),
+ ...(
+ | "STRUCTURAL_DIRECTIVE"
+ | "TEMPLATE_REFERENCE"
+ | "ATTRIBUTE_BINDING"
+ | "INPUT_BINDING"
+ | "TWO_WAY_BINDING"
+ | "OUTPUT_BINDING"
+ )[]
+ ];
+}
+
+```
+
+
+
+## Usage Examples
+
+> The following examples are generated automatically from the actual unit tests within the plugin, so you can be assured that their behavior is accurate based on the current commit.
+
+
+
+
+❌ - Toggle examples of incorrect code for this rule
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ ~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error",
+ {
+ "order": []
+ }
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error",
+ {
+ "alphabetical": true,
+ "order": []
+ }
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ ~~~~~~~~~~~~~~~~
+```
+
+
+
+
+
+---
+
+
+
+
+✅ - Toggle examples of correct code for this rule
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/attributes-order": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+```
+
+
+
+
diff --git a/packages/eslint-plugin-template/src/configs/all.json b/packages/eslint-plugin-template/src/configs/all.json
index 846befb7e..83ce809c7 100644
--- a/packages/eslint-plugin-template/src/configs/all.json
+++ b/packages/eslint-plugin-template/src/configs/all.json
@@ -9,7 +9,9 @@
"@angular-eslint/template/accessibility-role-has-required-aria": "error",
"@angular-eslint/template/accessibility-table-scope": "error",
"@angular-eslint/template/accessibility-valid-aria": "error",
+ "@angular-eslint/template/attributes-order": "error",
"@angular-eslint/template/banana-in-box": "error",
+ "@angular-eslint/template/button-has-type": "error",
"@angular-eslint/template/click-events-have-key-events": "error",
"@angular-eslint/template/conditional-complexity": "error",
"@angular-eslint/template/cyclomatic-complexity": "error",
@@ -23,7 +25,6 @@
"@angular-eslint/template/no-duplicate-attributes": "error",
"@angular-eslint/template/no-negated-async": "error",
"@angular-eslint/template/no-positive-tabindex": "error",
- "@angular-eslint/template/use-track-by-function": "error",
- "@angular-eslint/template/button-has-type": "error"
+ "@angular-eslint/template/use-track-by-function": "error"
}
}
diff --git a/packages/eslint-plugin-template/src/index.ts b/packages/eslint-plugin-template/src/index.ts
index a306b2536..ef923dd1e 100644
--- a/packages/eslint-plugin-template/src/index.ts
+++ b/packages/eslint-plugin-template/src/index.ts
@@ -27,9 +27,15 @@ import accessibilityTableScope, {
import accessibilityValidAria, {
RULE_NAME as accessibilityValidAriaRuleName,
} from './rules/accessibility-valid-aria';
+import attributesOrder, {
+ RULE_NAME as attributesOrderRuleName,
+} from './rules/attributes-order';
import bananaInBox, {
RULE_NAME as bananaInBoxRuleName,
} from './rules/banana-in-box';
+import buttonHasType, {
+ RULE_NAME as buttonHasTypeRuleName,
+} from './rules/button-has-type';
import clickEventsHaveKeyEvents, {
RULE_NAME as clickEventsHaveKeyEventsRuleName,
} from './rules/click-events-have-key-events';
@@ -66,9 +72,6 @@ import noPositiveTabindex, {
import useTrackByFunction, {
RULE_NAME as useTrackByFunctionRuleName,
} from './rules/use-track-by-function';
-import buttonHasType, {
- RULE_NAME as buttonHasTypeRuleName,
-} from './rules/button-has-type';
export default {
configs: {
@@ -90,7 +93,9 @@ export default {
accessibilityRoleHasRequiredAria,
[accessibilityTableScopeRuleName]: accessibilityTableScope,
[accessibilityValidAriaRuleName]: accessibilityValidAria,
+ [attributesOrderRuleName]: attributesOrder,
[bananaInBoxRuleName]: bananaInBox,
+ [buttonHasTypeRuleName]: buttonHasType,
[conditionalComplexityRuleName]: conditionalComplexity,
[clickEventsHaveKeyEventsRuleName]: clickEventsHaveKeyEvents,
[cyclomaticComplexityRuleName]: cyclomaticComplexity,
@@ -105,6 +110,5 @@ export default {
[noNegatedAsyncRuleName]: noNegatedAsync,
[noPositiveTabindexRuleName]: noPositiveTabindex,
[useTrackByFunctionRuleName]: useTrackByFunction,
- [buttonHasTypeRuleName]: buttonHasType,
},
};
diff --git a/packages/eslint-plugin-template/src/rules/attributes-order.ts b/packages/eslint-plugin-template/src/rules/attributes-order.ts
new file mode 100644
index 000000000..d52b353f5
--- /dev/null
+++ b/packages/eslint-plugin-template/src/rules/attributes-order.ts
@@ -0,0 +1,370 @@
+import type {
+ TmplAstElement,
+ TmplAstBoundAttribute,
+ TmplAstBoundEvent,
+ TmplAstReference,
+ TmplAstTextAttribute,
+ TmplAstNode,
+} from '@angular-eslint/bundled-angular-compiler';
+import { TmplAstTemplate } from '@angular-eslint/bundled-angular-compiler';
+import type { TSESTree } from '@typescript-eslint/utils';
+import {
+ createESLintRule,
+ getTemplateParserServices,
+} from '../utils/create-eslint-rule';
+
+export const enum OrderType {
+ TemplateReferenceVariable = 'TEMPLATE_REFERENCE',
+ StructuralDirective = 'STRUCTURAL_DIRECTIVE',
+ AttributeBinding = 'ATTRIBUTE_BINDING',
+ InputBinding = 'INPUT_BINDING',
+ OutputBinding = 'OUTPUT_BINDING',
+ TwoWayBinding = 'TWO_WAY_BINDING',
+}
+
+type Options = [
+ {
+ readonly alphabetical: boolean;
+ readonly order: readonly OrderType[];
+ },
+];
+
+type HasOrderType = Readonly<{
+ orderType: Type;
+}>;
+
+interface HasTemplateParent {
+ parent: TmplAstTemplate;
+}
+
+type ExtendedTmplAstBoundAttribute = TmplAstBoundAttribute &
+ HasOrderType<
+ | OrderType.InputBinding
+ | OrderType.TwoWayBinding
+ | OrderType.StructuralDirective
+ >;
+type ExtendedTmplAstBoundEvent = TmplAstBoundEvent &
+ HasOrderType;
+type ExtendedTmplAstTextAttribute = TmplAstTextAttribute &
+ HasOrderType;
+type ExtendedTmplAstReference = TmplAstReference &
+ HasOrderType;
+type ExtendedTmplAstElement = TmplAstElement & HasTemplateParent;
+type ExtendedAttribute =
+ | ExtendedTmplAstBoundAttribute
+ | ExtendedTmplAstBoundEvent
+ | ExtendedTmplAstTextAttribute
+ | ExtendedTmplAstReference;
+
+export type MessageIds = 'attributesOrder';
+export const RULE_NAME = 'attributes-order';
+
+const DEFAULT_ORDER = [
+ OrderType.StructuralDirective,
+ OrderType.TemplateReferenceVariable,
+ OrderType.AttributeBinding,
+ OrderType.InputBinding,
+ OrderType.TwoWayBinding,
+ OrderType.OutputBinding,
+];
+
+const DEFAULT_OPTIONS: Options[number] = {
+ alphabetical: false,
+ order: [...DEFAULT_ORDER],
+};
+
+export default createESLintRule({
+ name: RULE_NAME,
+ meta: {
+ type: 'layout',
+ docs: {
+ description:
+ 'Ensures that HTML attributes and Angular bindings are sorted based on an expected order',
+ recommended: false,
+ },
+ fixable: 'code',
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ alphabetical: {
+ type: 'boolean',
+ default: DEFAULT_OPTIONS.alphabetical,
+ },
+ order: {
+ type: 'array',
+ items: {
+ enum: DEFAULT_OPTIONS.order,
+ },
+ default: DEFAULT_OPTIONS.order,
+ minItems: DEFAULT_OPTIONS.order.length,
+ uniqueItems: true,
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ attributesOrder: `The element's attributes/bindings did not match the expected order: expected {{expected}} instead of {{actual}}`,
+ },
+ },
+ defaultOptions: [DEFAULT_OPTIONS],
+ create(context, [{ alphabetical, order }]) {
+ const parserServices = getTemplateParserServices(context);
+
+ function getLocation(attr: ExtendedAttribute): TSESTree.SourceLocation {
+ const loc = parserServices.convertNodeSourceSpanToLoc(attr.sourceSpan);
+
+ switch (attr.orderType) {
+ case OrderType.StructuralDirective:
+ return {
+ start: {
+ line: loc.start.line,
+ column: loc.start.column - 1,
+ },
+ end: {
+ line: loc.end.line,
+ column: loc.end.column + 1,
+ },
+ };
+ default:
+ return loc;
+ }
+ }
+
+ return {
+ Element$1(node: ExtendedTmplAstElement) {
+ const { attributes, inputs, outputs, references } = node;
+ const { extractedBananaBoxes, extractedInputs, extractedOutputs } =
+ normalizeInputsOutputs(
+ inputs.map(toInputBindingOrderType),
+ outputs.map(toOutputBindingOrderType),
+ );
+ const allAttributes = [
+ ...extractTemplateAttrs(node),
+ ...attributes.map(toAttributeBindingOrderType),
+ ...references.map(toTemplateReferenceVariableOrderType),
+ ...extractedBananaBoxes,
+ ...extractedInputs,
+ ...extractedOutputs,
+ ] as const;
+
+ if (allAttributes.length < 2) {
+ return;
+ }
+
+ const sortedAttributes = [...allAttributes].sort(byLocation);
+
+ const expectedAttributes = [...allAttributes].sort(
+ byOrder(order, alphabetical),
+ );
+
+ let errorRange: [number, number] | undefined;
+
+ for (let i = 0; i < sortedAttributes.length; i++) {
+ if (sortedAttributes[i] !== expectedAttributes[i]) {
+ errorRange = [errorRange?.[0] ?? i, i];
+ }
+ }
+
+ if (errorRange) {
+ const [startIndex, endIndex] = errorRange;
+ const sourceCode = context.getSourceCode();
+
+ const { start } = getLocation(sortedAttributes[startIndex]);
+ const { end } = getLocation(sortedAttributes[endIndex]);
+ const loc = { start, end };
+
+ const range = [
+ getStartPos(sortedAttributes[startIndex]),
+ getEndPos(sortedAttributes[endIndex]),
+ ] as const;
+
+ let replacementText = '';
+ let lastPos = range[0];
+ for (let i = startIndex; i <= endIndex; i++) {
+ const oldAttr = sortedAttributes[i];
+ const oldStart = getStartPos(oldAttr);
+ const oldEnd = getEndPos(oldAttr);
+ const newAttr = expectedAttributes[i];
+ const newStart = getStartPos(newAttr);
+ const newEnd = getEndPos(newAttr);
+
+ replacementText += sourceCode.text.slice(lastPos, oldStart);
+ replacementText += sourceCode.text.slice(newStart, newEnd);
+
+ lastPos = oldEnd;
+ }
+
+ context.report({
+ loc,
+ messageId: 'attributesOrder',
+ data: {
+ expected: expectedAttributes
+ .slice(startIndex, endIndex + 1)
+ .map((a) => `\`${getMessageName(a)}\``)
+ .join(', '),
+ actual: sortedAttributes
+ .slice(startIndex, endIndex + 1)
+ .map((a) => `\`${getMessageName(a)}\``)
+ .join(', '),
+ },
+ fix: (fixer) => fixer.replaceTextRange(range, replacementText),
+ });
+ }
+ },
+ };
+ },
+});
+
+function byLocation(one: ExtendedAttribute, other: ExtendedAttribute) {
+ return one.sourceSpan.start.line === other.sourceSpan.start.line
+ ? one.sourceSpan.start.col - other.sourceSpan.start.col
+ : one.sourceSpan.start.line - other.sourceSpan.start.line;
+}
+
+function byOrder(order: readonly OrderType[], alphabetical: boolean) {
+ return function (one: ExtendedAttribute, other: ExtendedAttribute) {
+ const orderComparison =
+ getOrderIndex(one, order) - getOrderIndex(other, order);
+
+ if (alphabetical && orderComparison === 0) {
+ return one.name > other.name ? 1 : -1;
+ }
+
+ return orderComparison;
+ };
+}
+
+function getOrderIndex(attr: ExtendedAttribute, order: readonly OrderType[]) {
+ return order.indexOf(attr.orderType);
+}
+
+function toAttributeBindingOrderType(attribute: TmplAstTextAttribute) {
+ return {
+ ...attribute,
+ orderType: OrderType.AttributeBinding,
+ } as ExtendedTmplAstTextAttribute;
+}
+function toInputBindingOrderType(input: TmplAstBoundAttribute) {
+ return {
+ ...input,
+ orderType: OrderType.InputBinding,
+ } as ExtendedTmplAstBoundAttribute;
+}
+function toStructuralDirectiveOrderType(
+ attributeOrInput: TmplAstBoundAttribute | TmplAstTextAttribute,
+) {
+ return {
+ ...attributeOrInput,
+ orderType: OrderType.StructuralDirective,
+ } as ExtendedTmplAstBoundAttribute | ExtendedTmplAstTextAttribute;
+}
+function toOutputBindingOrderType(output: TmplAstBoundEvent) {
+ return {
+ ...output,
+ orderType: OrderType.OutputBinding,
+ } as ExtendedTmplAstBoundEvent;
+}
+function toTwoWayBindingOrderType(output: TmplAstBoundAttribute) {
+ return {
+ ...output,
+ orderType: OrderType.TwoWayBinding,
+ } as ExtendedTmplAstBoundAttribute;
+}
+function toTemplateReferenceVariableOrderType(reference: TmplAstReference) {
+ return {
+ ...reference,
+ orderType: OrderType.TemplateReferenceVariable,
+ } as ExtendedTmplAstReference;
+}
+
+function extractTemplateAttrs(
+ node: ExtendedTmplAstElement,
+): (ExtendedTmplAstBoundAttribute | ExtendedTmplAstTextAttribute)[] {
+ return isTmplAstTemplate(node.parent)
+ ? node.parent.templateAttrs.map(toStructuralDirectiveOrderType)
+ : [];
+}
+
+function normalizeInputsOutputs(
+ inputs: readonly TmplAstBoundAttribute[],
+ outputs: readonly TmplAstBoundEvent[],
+) {
+ const extractedInputs: readonly ExtendedTmplAstBoundAttribute[] = inputs
+ .filter(
+ (input) => !outputs.some((output) => isOnSameLocation(input, output)),
+ )
+ .map(toInputBindingOrderType);
+ const { extractedBananaBoxes, extractedOutputs } = outputs.reduce<{
+ extractedOutputs: readonly ExtendedTmplAstBoundEvent[];
+ extractedBananaBoxes: readonly ExtendedTmplAstBoundAttribute[];
+ }>(
+ ({ extractedBananaBoxes, extractedOutputs }, output) => {
+ const boundInput = inputs.find((input) =>
+ isOnSameLocation(input, output),
+ );
+
+ return {
+ extractedBananaBoxes: extractedBananaBoxes.concat(
+ boundInput ? toTwoWayBindingOrderType(boundInput) : [],
+ ),
+ extractedOutputs: extractedOutputs.concat(
+ boundInput ? [] : toOutputBindingOrderType(output),
+ ),
+ };
+ },
+ { extractedBananaBoxes: [], extractedOutputs: [] },
+ );
+
+ return { extractedBananaBoxes, extractedInputs, extractedOutputs } as const;
+}
+
+function isTmplAstTemplate(node: TmplAstNode): node is TmplAstTemplate {
+ return node instanceof TmplAstTemplate;
+}
+
+function isOnSameLocation(
+ input: TmplAstBoundAttribute,
+ output: TmplAstBoundEvent,
+) {
+ return (
+ input.sourceSpan.start === output.sourceSpan.start &&
+ input.sourceSpan.end === output.sourceSpan.end
+ );
+}
+
+function getMessageName(expected: ExtendedAttribute): string {
+ switch (expected.orderType) {
+ case OrderType.StructuralDirective:
+ return `*${expected.name}`;
+ case OrderType.TemplateReferenceVariable:
+ return `#${expected.name}`;
+ case OrderType.InputBinding:
+ return `[${expected.name}]`;
+ case OrderType.OutputBinding:
+ return `(${expected.name})`;
+ case OrderType.TwoWayBinding:
+ return `[(${expected.name})]`;
+ default:
+ return expected.name;
+ }
+}
+
+function getStartPos(expected: ExtendedAttribute): number {
+ switch (expected.orderType) {
+ case OrderType.StructuralDirective:
+ return expected.sourceSpan.start.offset - 1;
+ default:
+ return expected.sourceSpan.start.offset;
+ }
+}
+
+function getEndPos(expected: ExtendedAttribute): number {
+ switch (expected.orderType) {
+ case OrderType.StructuralDirective:
+ return expected.sourceSpan.end.offset + 1;
+ default:
+ return expected.sourceSpan.end.offset;
+ }
+}
diff --git a/packages/eslint-plugin-template/tests/rules/attributes-order/cases.ts b/packages/eslint-plugin-template/tests/rules/attributes-order/cases.ts
new file mode 100644
index 000000000..533d68775
--- /dev/null
+++ b/packages/eslint-plugin-template/tests/rules/attributes-order/cases.ts
@@ -0,0 +1,198 @@
+import { convertAnnotatedSourceToFailureCase } from '@angular-eslint/utils';
+import type { MessageIds } from '../../../src/rules/attributes-order';
+import { OrderType } from '../../../src/rules/attributes-order';
+
+const messageId: MessageIds = 'attributesOrder';
+
+export const valid = [
+ ``,
+ ``,
+ ``,
+ '',
+ ``,
+ '',
+];
+
+export const invalid = [
+ convertAnnotatedSourceToFailureCase({
+ messageId,
+ description: 'should fail if structural directive is not first',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ data: { expected: '`*ngIf`, `#inputRef`', actual: '`#inputRef`, `*ngIf`' },
+ annotatedOutput: `
+
+
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ messageId,
+ description: 'should fail if attribute is in wrong place',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ data: {
+ expected: '`#inputRef`, `class`',
+ actual: '`class`, `#inputRef`',
+ },
+ annotatedOutput: `
+
+
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ messageId,
+ description: 'should fail if input is in wrong place',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ data: {
+ expected: '`class`, `[binding]`',
+ actual: '`[binding]`, `class`',
+ },
+ annotatedOutput: `
+
+
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ messageId,
+ description: 'should fail if two way binding is in wrong place',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ data: {
+ expected: '`[binding]`, `[(ngModel)]`',
+ actual: '`[(ngModel)]`, `[binding]`',
+ },
+ annotatedOutput: `
+
+
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ messageId,
+ description: 'should fail if output is in wrong place',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ data: {
+ expected: '`[binding]`, `(output)`',
+ actual: '`(output)`, `[binding]`',
+ },
+ annotatedOutput: `
+
+
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ messageId,
+ description:
+ 'should fail if structural directive is in wrong place with custom order',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ options: [
+ {
+ order: [
+ OrderType.TemplateReferenceVariable,
+ OrderType.AttributeBinding,
+ OrderType.StructuralDirective,
+ OrderType.InputBinding,
+ OrderType.OutputBinding,
+ OrderType.TwoWayBinding,
+ ],
+ },
+ ],
+ data: {
+ expected: '`class`, `*ngIf`',
+ actual: '`*ngIf`, `class`',
+ },
+ annotatedOutput: `
+
+
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ messageId,
+ description: 'should work with custom order',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ options: [
+ {
+ alphabetical: true,
+ order: [
+ OrderType.TemplateReferenceVariable,
+ OrderType.StructuralDirective,
+ OrderType.AttributeBinding,
+ OrderType.InputBinding,
+ OrderType.OutputBinding,
+ OrderType.TwoWayBinding,
+ ],
+ },
+ ],
+ data: {
+ expected:
+ '`#inputRef`, `*ngIf`, `class`, `id`, `[binding]`, `(output)`, `[(ngModel)]`',
+ actual:
+ '`*ngIf`, `[(ngModel)]`, `#inputRef`, `id`, `class`, `[binding]`, `(output)`',
+ },
+ annotatedOutput: `
+
+
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ messageId,
+ description: 'should work for multi-line',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~
+ `,
+ data: {
+ expected:
+ '`*ngIf`, `#inputRef`, `id`, `class`, `[binding]`, `[(ngModel)]`, `(output)`',
+ actual:
+ '`[(ngModel)]`, `*ngIf`, `#inputRef`, `id`, `class`, `(output)`, `[binding]`',
+ },
+ annotatedOutput: `
+
+
+ `,
+ }),
+];
diff --git a/packages/eslint-plugin-template/tests/rules/attributes-order/spec.ts b/packages/eslint-plugin-template/tests/rules/attributes-order/spec.ts
new file mode 100644
index 000000000..424155ee5
--- /dev/null
+++ b/packages/eslint-plugin-template/tests/rules/attributes-order/spec.ts
@@ -0,0 +1,12 @@
+import { RuleTester } from '@angular-eslint/utils';
+import rule, { RULE_NAME } from '../../../src/rules/attributes-order';
+import { invalid, valid } from './cases';
+
+const ruleTester = new RuleTester({
+ parser: '@angular-eslint/template-parser',
+});
+
+ruleTester.run(RULE_NAME, rule, {
+ valid,
+ invalid,
+});