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/package.json b/package.json index dfab669402..e176c7cd3a 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "snake-case": "^3.0.3" }, "devDependencies": { + "@types/eslint": "^8.44.2", "@babel/plugin-proposal-class-properties": "^7.13.0", "@babel/plugin-proposal-decorators": "^7.15.8", "@types/eslint": "^8.44.2", @@ -99,7 +100,7 @@ "eslint-plugin-import": "^2.26.0", "eslint-plugin-jest": "^27.0.1", "eslint-plugin-markdown": "^3.0.0", - "eslint-plugin-node": "^11.1.0", + "eslint-plugin-n": "^16.0.2", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-unicorn": "^46.0.1", "eslint-remote-tester": "^3.0.0", diff --git a/tests/lib/rules/template-indent.js b/tests/lib/rules/template-indent.js new file mode 100644 index 0000000000..cb06fb2a96 --- /dev/null +++ b/tests/lib/rules/template-indent.js @@ -0,0 +1,256 @@ +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/template-indent'); +const RuleTester = require('eslint').RuleTester; + +const { ERROR_MESSAGE } = rule; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parser: require.resolve('../../../lib/parsers/gjs-parser.js'), + parserOptions: { ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true } }, +}); +ruleTester.run('template-indent', rule, { + valid: [ + ` + `, + ], + + 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: 25, + column: 1, + endLine: 25, + endColumn: 1, + }, + { + message: 'Expected indentation of 8 spaces but found 1.', + line: 26, + column: 1, + endLine: 26, + endColumn: 2, + }, + { + message: 'Expected indentation of 4 spaces but found 1.', + line: 27, + column: 1, + endLine: 27, + endColumn: 2, + } + ], + }, + ] +});