diff --git a/README.md b/README.md index 89313f5df0..bd54ca9e24 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 | | 🔧 | | ### jQuery diff --git a/docs/rules/template-indent.md b/docs/rules/template-indent.md new file mode 100644 index 0000000000..8413b154e9 --- /dev/null +++ b/docs/rules/template-indent.md @@ -0,0 +1,66 @@ +# ember/template-indent + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +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 + + + +| Name | Type | Default | +| :--------------- | :------- | :------ | +| `ignoreComments` | Boolean | `false` | +| `ignoredNodes` | String[] | | + + + +## Rule Details + +Enforce consistent indentation for fcct templates + +```js +const rules = { + 'ember/template-indent': [ + 'error', + 2, // or 'tab' + { + ignoreComments: false, + ignoredNodes: [] + } + ] +}; +``` + +## Examples + +Examples of **incorrect** code for this rule: + +```gjs +// my-octane-component.gjs + +} +``` + +Examples of **correct** code for this rule: + +```gjs +// my-component.gjs + +``` + +## 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 0000000000..0c5aa542e7 --- /dev/null +++ b/lib/rules/template-indent.js @@ -0,0 +1,200 @@ +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 = { + ERROR_MESSAGE: baseRule.meta.messages.wrongIndentation, + name: 'indent', + meta: { + type: 'layout', + docs: { + description: 'enforce consistent indentation', + extendsBaseRule: true, + // 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 })); + }, + }); + }, +}; diff --git a/tests/lib/rules/template-indent.js b/tests/lib/rules/template-indent.js new file mode 100644 index 0000000000..622515e3c0 --- /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: [ + ` + `, + ` +class MyClass { + sad=2; + +} + `, + ` +const tpl = + `, + ], + + invalid: [ + { + code: ` + `, + output: ` + `, + 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; + +} + `, + ], + invalid: [ + { + code: ` +class MyClass { + sad=2; + +} + `, + output: ` +class MyClass { + sad=2; + +} + `, + 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, + }, + ], + }, + ], +});