Repository: ember-cli/eslint-plugin-ember
Pull request #1943 (2023-08-23 13:26:00)

Add new rule `template-indent`
uses the base eslint indent rule to make indents for templates.

similar implementation like typescript/indent https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/src/rules/indent.ts

depends on #1942 


Patch:
diff --git a/README.md b/README.md
index 89313f5d..6702fa81 100644
--- a/README.md
+++ b/README.md
@@ -240,6 +240,7 @@ rules in templates can be disabled with eslint directives with mustache or html
 | [no-ember-super-in-es-classes](docs/rules/no-ember-super-in-es-classes.md)                 | disallow use of `this._super` in ES class methods                                                              | ✅  | 🔧 |    |
 | [no-empty-glimmer-component-classes](docs/rules/no-empty-glimmer-component-classes.md)     | disallow empty backing classes for Glimmer components                                                          | ✅  |    |    |
 | [no-tracked-properties-from-args](docs/rules/no-tracked-properties-from-args.md)           | disallow creating @tracked properties from this.args                                                           | ✅  |    |    |
+| [template-indent](docs/rules/template-indent.md)                                           | enforce consistent indentation for gts/gjs templates                                                           |    | 🔧 |    |
 
 ### jQuery
 
diff --git a/docs/rules/template-indent.md b/docs/rules/template-indent.md
new file mode 100644
index 00000000..cc7c3187
--- /dev/null
+++ b/docs/rules/template-indent.md
@@ -0,0 +1,53 @@
+# ember/template-indent
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+<!-- end auto-generated rule header -->
+
+## Rule Details
+
+Enforce consistent indentation for fcct templates.
+
+This rule extends the base [eslint indent](https://eslint.org/docs/latest/rules/indent) rule, but only applies the indents to Glimmer Nodes.
+
+Otherwise, it receives the same options as the original and can run together with the base rule.
+
+## Configuration
+
+<!-- begin auto-generated rule options list -->
+
+| Name             | Type     | Default |
+| :--------------- | :------- | :------ |
+| `ignoreComments` | Boolean  | `false` |
+| `ignoredNodes`   | String[] |         |
+
+<!-- end auto-generated rule options list -->
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```gjs
+// my-octane-component.gjs
+<template>
+  <div>
+    
+        </div>
+</template>
+}
+```
+
+Examples of **correct** code for this rule:
+
+```gjs
+// my-component.gjs
+<template>
+  <div>
+
+  </div>
+</template>
+```
+
+## References
+
+- [eslint indent](https://eslint.org/docs/latest/rules/indent)
diff --git a/lib/rules/template-indent.js b/lib/rules/template-indent.js
new file mode 100644
index 00000000..e658b86c
--- /dev/null
+++ b/lib/rules/template-indent.js
@@ -0,0 +1,198 @@
+const { builtinRules } = require('eslint/use-at-your-own-risk');
+
+const baseRule = builtinRules.get('indent');
+const IGNORED_ELEMENTS = new Set(['pre', 'script', 'style', 'textarea']);
+
+const schema = baseRule.meta.schema.map((s) => ({ ...s }));
+schema[1].properties = {
+  ignoredNodes: schema[1].properties.ignoredNodes,
+  ignoreComments: schema[1].properties.ignoreComments,
+};
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+  name: 'indent',
+  meta: {
+    type: 'layout',
+    docs: {
+      description: 'enforce consistent indentation for gts/gjs templates',
+      // too opinionated to be recommended
+      recommended: false,
+      category: 'Ember Octane',
+      url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-indent.md',
+    },
+    fixable: 'whitespace',
+    hasSuggestions: baseRule.meta.hasSuggestions,
+    schema,
+    messages: baseRule.meta.messages,
+  },
+
+  create: (context) => {
+    const ctx = Object.create(context, {
+      report: {
+        writable: false,
+        configurable: false,
+        value: (info) => {
+          const node = context.sourceCode.getNodeByRangeIndex(info.node.range[0]);
+          if (!node.type.startsWith('Glimmer')) {
+            return;
+          }
+          context.report(info);
+        },
+      },
+    });
+    const rules = baseRule.create(ctx);
+    const sourceCode = context.sourceCode;
+
+    function JSXElement(node) {
+      let closingElement;
+      let openingElement;
+      if (node.type === 'GlimmerElementNode') {
+        const tokens = sourceCode.getTokens(node);
+        const openEnd = tokens.find((t) => t.value === '>');
+        const closeStart = tokens.findLast((t) => t.value === '<');
+        if (!node.selfClosing) {
+          closingElement = {
+            type: 'JSXClosingElement',
+            parent: node,
+            range: [closeStart.range[0], node.range[1]],
+            loc: {
+              start: Object.assign({}, node.loc.start),
+              end: Object.assign({}, node.loc.end),
+            },
+          };
+          closingElement.loc.start = sourceCode.getLocFromIndex(closeStart.range[0]);
+          closingElement.name = { ...closingElement, type: 'JSXIdentifier' };
+          closingElement.name.range = [
+            closingElement.name.range[0] + 1,
+            closingElement.name.range[1] - 1,
+          ];
+        }
+
+        openingElement = {
+          type: 'JSXOpeningElement',
+          selfClosing: node.selfClosing,
+          attributes: node.attributes,
+          parent: node,
+          range: [node.range[0], openEnd.range[1]],
+          loc: {
+            start: Object.assign({}, node.loc.start),
+            end: Object.assign({}, node.loc.end),
+          },
+        };
+        openingElement.loc.end = sourceCode.getLocFromIndex(openEnd.range[1]);
+        openingElement.name = { ...openingElement, type: 'JSXIdentifier' };
+        openingElement.name.range = [
+          openingElement.name.range[0] + 1,
+          openingElement.name.range[1] - 1,
+        ];
+      }
+      if (node.type === 'GlimmerBlockStatement') {
+        const tokens = sourceCode.getTokens(node);
+        let openEndIdx = tokens.findIndex((t) => t.value === '}');
+        while (tokens[openEndIdx + 1].value === '}') {
+          openEndIdx += 1;
+        }
+        const openEnd = tokens[openEndIdx];
+        let closeStartIdx = tokens.findLastIndex((t) => t.value === '{');
+        while (tokens[closeStartIdx - 1].value === '{') {
+          closeStartIdx -= 1;
+        }
+        const closeStart = tokens[closeStartIdx];
+        closingElement = {
+          type: 'JSXClosingElement',
+          parent: node,
+          range: [closeStart.range[0], node.range[1]],
+          loc: {
+            start: Object.assign({}, node.loc.start),
+            end: Object.assign({}, node.loc.end),
+          },
+        };
+        closingElement.loc.start = sourceCode.getLocFromIndex(closeStart.range[0]);
+
+        openingElement = {
+          type: 'JSXOpeningElement',
+          attributes: node.params,
+          parent: node,
+          range: [node.range[0], openEnd.range[1]],
+          loc: {
+            start: Object.assign({}, node.loc.start),
+            end: Object.assign({}, node.loc.end),
+          },
+        };
+        openingElement.loc.end = sourceCode.getLocFromIndex(openEnd.range[1]);
+      }
+      return {
+        type: 'JSXElement',
+        openingElement,
+        closingElement,
+        children: node.children || node.body,
+        parent: node.parent,
+        range: node.range,
+        loc: node.loc,
+      };
+    }
+
+    const ignoredStack = new Set();
+
+    return Object.assign({}, rules, {
+      // overwrite the base rule here so we can use our KNOWN_NODES list instead
+      '*:exit'(node) {
+        // For nodes we care about, skip the default handling, because it just marks the node as ignored...
+        if (
+          !node.type.startsWith('Glimmer') ||
+          (ignoredStack.size > 0 && !ignoredStack.has(node))
+        ) {
+          rules['*:exit'](node);
+        }
+        if (ignoredStack.has(node)) {
+          ignoredStack.delete(node);
+        }
+      },
+      'GlimmerTemplate:exit'(node) {
+        if (!node.parent) {
+          rules['Program:exit'](node);
+        }
+      },
+      GlimmerElementNode(node) {
+        if (ignoredStack.size > 0) {
+          return;
+        }
+        if (IGNORED_ELEMENTS.has(node.tag)) {
+          ignoredStack.add(node);
+        }
+        const jsx = JSXElement(node);
+        rules['JSXElement'](jsx);
+        rules['JSXOpeningElement'](jsx.openingElement);
+        if (jsx.closingElement) {
+          rules['JSXClosingElement'](jsx.closingElement);
+        }
+      },
+      GlimmerAttrNode(node) {
+        if (ignoredStack.size > 0 || !node.value) {
+          return;
+        }
+        rules['JSXAttribute[value]']({
+          ...node,
+          type: 'JSXAttribute',
+          name: {
+            type: 'JSXIdentifier',
+            name: node.name,
+            range: [node.range[0], node.range[0] + node.name.length - 1],
+          },
+        });
+      },
+      GlimmerTemplate(node) {
+        if (!node.parent) {
+          return;
+        }
+        const jsx = JSXElement({ ...node, tag: 'template', type: 'GlimmerElementNode' });
+        rules['JSXElement'](jsx);
+      },
+      GlimmerBlockStatement(node) {
+        const body = [...node.program.body, ...(node.inverse?.body || [])];
+        rules['JSXElement'](JSXElement({ ...node, body }));
+      },
+    });
+  },
+};


Test patch:
diff --git a/tests/lib/rules/template-indent.js b/tests/lib/rules/template-indent.js
new file mode 100644
index 00000000..b84da8b0
--- /dev/null
+++ b/tests/lib/rules/template-indent.js
@@ -0,0 +1,349 @@
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/template-indent');
+const RuleTester = require('eslint').RuleTester;
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+  parser: require.resolve('../../../lib/parsers/gjs-gts-parser.js'),
+  parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
+});
+
+const ruleTesterWithBaseIntent = new RuleTester({
+  parser: require.resolve('../../../lib/parsers/gjs-gts-parser.js'),
+  parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
+  rules: {
+    indent: 'warn',
+  },
+});
+
+ruleTester.run('template-indent', rule, {
+  valid: [
+    `<template>
+    <div>
+        <div></div>
+    </div>
+    <div>
+    </div>
+    <pre>
+          <here />
+        doesnt matter
+              where we
+    write
+    </pre>
+    <div
+        a='s'
+        b='s'
+        c='s'
+    />
+    <div a='s'
+        b='s'
+        c='s'
+    />
+</template>
+    `,
+    `
+class MyClass {
+   sad=2;
+    <template>
+        <div></div>
+        {{test}}
+    </template>
+}
+    `,
+    `
+const tpl = <template>
+    <div></div>
+    {{test}}
+</template>
+    `,
+  ],
+
+  invalid: [
+    {
+      code: `<template>
+<div>
+    <div></div>
+</div>
+<div>
+    {{#if x}}
+  {{test}}
+        {{/if}}
+</div>
+<pre>
+      <here />
+    doesnt matter
+          where we
+write
+</pre>
+<div
+    a='s'
+b='s'
+  c='s'
+/>
+<div a='s'
+    b='s'
+      c='s'
+/>
+    <like-pre>
+        <!-- eslint-disable -->
+          <here />
+        doesnt matter
+              where we
+        write
+        <!-- eslint-enable -->
+    </like-pre>
+<MyComp
+ as | abc |>
+ </MyComp>
+</template>
+      `,
+      output: `<template>
+    <div>
+        <div></div>
+    </div>
+    <div>
+        {{#if x}}
+            {{test}}
+        {{/if}}
+    </div>
+    <pre>
+      <here />
+    doesnt matter
+          where we
+write
+    </pre>
+    <div
+        a='s'
+        b='s'
+        c='s'
+    />
+    <div a='s'
+        b='s'
+        c='s'
+    />
+    <like-pre>
+        <!-- eslint-disable -->
+          <here />
+        doesnt matter
+              where we
+        write
+        <!-- eslint-enable -->
+    </like-pre>
+    <MyComp
+        as | abc |>
+    </MyComp>
+</template>
+      `,
+      errors: [
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 2,
+          column: 1,
+          endLine: 2,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 8 spaces but found 4.',
+          line: 3,
+          column: 1,
+          endLine: 3,
+          endColumn: 5,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 4,
+          column: 1,
+          endLine: 4,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 5,
+          column: 1,
+          endLine: 5,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 8 spaces but found 4.',
+          line: 6,
+          column: 1,
+          endLine: 6,
+          endColumn: 5,
+        },
+        {
+          message: 'Expected indentation of 12 spaces but found 2.',
+          line: 7,
+          column: 1,
+          endLine: 7,
+          endColumn: 3,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 9,
+          column: 1,
+          endLine: 9,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 10,
+          column: 1,
+          endLine: 10,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 15,
+          column: 1,
+          endLine: 15,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 16,
+          column: 1,
+          endLine: 16,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 8 spaces but found 4.',
+          line: 17,
+          column: 1,
+          endLine: 17,
+          endColumn: 5,
+        },
+        {
+          message: 'Expected indentation of 8 spaces but found 0.',
+          line: 18,
+          column: 1,
+          endLine: 18,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 8 spaces but found 2.',
+          line: 19,
+          column: 1,
+          endLine: 19,
+          endColumn: 3,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 20,
+          column: 1,
+          endLine: 20,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 21,
+          column: 1,
+          endLine: 21,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 8 spaces but found 4.',
+          line: 22,
+          column: 1,
+          endLine: 22,
+          endColumn: 5,
+        },
+        {
+          message: 'Expected indentation of 8 spaces but found 6.',
+          line: 23,
+          column: 1,
+          endLine: 23,
+          endColumn: 7,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 24,
+          column: 1,
+          endLine: 24,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 0.',
+          line: 33,
+          column: 1,
+          endLine: 33,
+          endColumn: 1,
+        },
+        {
+          message: 'Expected indentation of 8 spaces but found 1.',
+          line: 34,
+          column: 1,
+          endLine: 34,
+          endColumn: 2,
+        },
+        {
+          message: 'Expected indentation of 4 spaces but found 1.',
+          line: 35,
+          column: 1,
+          endLine: 35,
+          endColumn: 2,
+        },
+      ],
+    },
+  ],
+});
+
+// make sure this works together with the base indent rule
+ruleTesterWithBaseIntent.run('template-indent-with-base.indent', rule, {
+  valid: [
+    `
+class MyClass {
+    sad=2;
+    <template>
+        <div></div>
+        {{test}}
+    </template>
+}
+    `,
+  ],
+  invalid: [
+    {
+      code: `
+class MyClass {
+      sad=2;
+    <template>
+        <div></div>
+          {{test}}
+    </template>
+}
+    `,
+      output: `
+class MyClass {
+    sad=2;
+    <template>
+        <div></div>
+        {{test}}
+    </template>
+}
+    `,
+      errors: [
+        {
+          type: 'Identifier',
+          message: 'Expected indentation of 4 spaces but found 6.',
+          line: 3,
+          column: 1,
+          endLine: 3,
+          endColumn: 7,
+        },
+        {
+          type: 'Punctuator',
+          message: 'Expected indentation of 8 spaces but found 10.',
+          line: 6,
+          column: 1,
+          endLine: 6,
+          endColumn: 11,
+        },
+      ],
+    },
+  ],
+});