diff --git a/lib/parsers/gjs-gts-parser.js b/lib/parsers/gjs-gts-parser.js
index 38cf081088..3106db2712 100644
--- a/lib/parsers/gjs-gts-parser.js
+++ b/lib/parsers/gjs-gts-parser.js
@@ -291,6 +291,13 @@ function preprocessGlimmerTemplates(info, code) {
n.type = `Glimmer${n.type}`;
allNodeTypes.add(n.type);
}
+ // ast should not contain comment nodes
+ for (const comment of comments) {
+ const parentBody = comment.parent.body || comment.parent.children;
+ const idx = parentBody.indexOf(comment);
+ parentBody.splice(idx, 1);
+ comment.type = code.slice(...comment.range).match(/^({{|<)!--/) ? 'Block' : 'Line';
+ }
// tokens should not contain tokens of comments
ast.tokens = ast.tokens.filter(
(t) => !comments.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
diff --git a/lib/rules/template-indent.js b/lib/rules/template-indent.js
new file mode 100644
index 0000000000..c47acbd9fa
--- /dev/null
+++ b/lib/rules/template-indent.js
@@ -0,0 +1,178 @@
+const { builtinRules } = require('eslint/use-at-your-own-risk');
+
+const baseRule = builtinRules.get('indent');
+const IGNORED_ELEMENTS = new Set(['pre', 'script', 'style', 'textarea']);
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ ERROR_MESSAGE: baseRule.meta.messages.wrongIndentation,
+ name: 'indent',
+ meta: {
+ type: 'layout',
+ docs: {
+ description: 'enforce consistent indentation',
+ // too opinionated to be recommended
+ extendsBaseRule: true,
+ recommended: true,
+ 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: baseRule.meta.schema,
+ messages: baseRule.meta.messages,
+ },
+
+ create: (context) => {
+ const rules = baseRule.create(context);
+ 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 }));
+ },
+ });
+ },
+};
diff --git a/tests/lib/rules/template-indent.js b/tests/lib/rules/template-indent.js
new file mode 100644
index 0000000000..4e209bc515
--- /dev/null
+++ b/tests/lib/rules/template-indent.js
@@ -0,0 +1,270 @@
+//------------------------------------------------------------------------------
+// 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' },
+});
+ruleTester.run('template-indent', rule, {
+ valid: [
+ `
+
+
+
+
+
+ doesnt matter
+ where we
+ write
+
+
+
+
+ `,
+ ],
+
+ invalid: [
+ {
+ code: `
+
+
+ {{#if x}}
+ {{test}}
+ {{/if}}
+
+
+
+ doesnt matter
+ where we
+write
+
+
+
+
+
+
+ doesnt matter
+ where we
+ write
+
+
+
+
+
+ `,
+ output: `
+
+
+ {{#if x}}
+ {{test}}
+ {{/if}}
+
+
+
+ doesnt matter
+ where we
+write
+
+
+
+
+
+
+ doesnt matter
+ where we
+ write
+
+
+
+
+
+ `,
+ 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,
+ },
+ ],
+ },
+ ],
+});