From cab476d378cdfafefac249886fe38525f4db045b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:22:14 -0400 Subject: [PATCH 1/2] Extract rule: template-attribute-indentation --- README.md | 23 +- docs/rules/template-attribute-indentation.md | 91 ++ lib/rules/template-attribute-indentation.js | 509 +++++++++ .../rules/template-attribute-indentation.js | 992 ++++++++++++++++++ 4 files changed, 1604 insertions(+), 11 deletions(-) create mode 100644 docs/rules/template-attribute-indentation.md create mode 100644 lib/rules/template-attribute-indentation.js create mode 100644 tests/lib/rules/template-attribute-indentation.js diff --git a/README.md b/README.md index 632f199a40..b8a2adf221 100644 --- a/README.md +++ b/README.md @@ -383,17 +383,18 @@ rules in templates can be disabled with eslint directives with mustache or html ### Stylistic Issues -| Name | Description | 💼 | 🔧 | 💡 | -| :--------------------------------------------------------------------------- | :--------------------------------------------------------------------- | :- | :- | :- | -| [order-in-components](docs/rules/order-in-components.md) | enforce proper order of properties in components | | 🔧 | | -| [order-in-controllers](docs/rules/order-in-controllers.md) | enforce proper order of properties in controllers | | 🔧 | | -| [order-in-models](docs/rules/order-in-models.md) | enforce proper order of properties in models | | 🔧 | | -| [order-in-routes](docs/rules/order-in-routes.md) | enforce proper order of properties in routes | | 🔧 | | -| [template-attribute-order](docs/rules/template-attribute-order.md) | enforce consistent ordering of attributes in template elements | | | | -| [template-block-indentation](docs/rules/template-block-indentation.md) | enforce consistent indentation for block statements and their children | | | | -| [template-eol-last](docs/rules/template-eol-last.md) | require or disallow newline at the end of template files | | 🔧 | | -| [template-linebreak-style](docs/rules/template-linebreak-style.md) | enforce consistent linebreaks in templates | | 🔧 | | -| [template-no-only-default-slot](docs/rules/template-no-only-default-slot.md) | disallow using only the default slot | | 🔧 | | +| Name | Description | 💼 | 🔧 | 💡 | +| :----------------------------------------------------------------------------- | :----------------------------------------------------------------------------- | :- | :- | :- | +| [order-in-components](docs/rules/order-in-components.md) | enforce proper order of properties in components | | 🔧 | | +| [order-in-controllers](docs/rules/order-in-controllers.md) | enforce proper order of properties in controllers | | 🔧 | | +| [order-in-models](docs/rules/order-in-models.md) | enforce proper order of properties in models | | 🔧 | | +| [order-in-routes](docs/rules/order-in-routes.md) | enforce proper order of properties in routes | | 🔧 | | +| [template-attribute-indentation](docs/rules/template-attribute-indentation.md) | enforce proper indentation of attributes and arguments in multi-line templates | | | | +| [template-attribute-order](docs/rules/template-attribute-order.md) | enforce consistent ordering of attributes in template elements | | | | +| [template-block-indentation](docs/rules/template-block-indentation.md) | enforce consistent indentation for block statements and their children | | | | +| [template-eol-last](docs/rules/template-eol-last.md) | require or disallow newline at the end of template files | | 🔧 | | +| [template-linebreak-style](docs/rules/template-linebreak-style.md) | enforce consistent linebreaks in templates | | 🔧 | | +| [template-no-only-default-slot](docs/rules/template-no-only-default-slot.md) | disallow using only the default slot | | 🔧 | | ### Testing diff --git a/docs/rules/template-attribute-indentation.md b/docs/rules/template-attribute-indentation.md new file mode 100644 index 0000000000..3e8ee3e743 --- /dev/null +++ b/docs/rules/template-attribute-indentation.md @@ -0,0 +1,91 @@ +# ember/template-attribute-indentation + + + +Migrated from [ember-template-lint/attribute-indentation](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/attribute-indentation.md). + +## Rule Details + +This rule requires the positional params, attributes, and block params of helpers/components to be indented by moving them to multiple lines when the open invocation has more than 80 characters (configurable). + +## Configuration + + + +| Name | Type | Choices | +| :------------------------ | :------ | :--------------------------- | +| `as-indentation` | | `attribute`, `closing-brace` | +| `element-open-end` | | `new-line`, `last-attribute` | +| `indentation` | Integer | | +| `mustache-open-end` | | `new-line`, `last-attribute` | +| `open-invocation-max-len` | Integer | | +| `process-elements` | Boolean | | + + + +## Examples + +Examples of **incorrect** code for this rule: + +Non-block form (> 80 characters): + +```hbs +{{employee-details firstName=firstName lastName=lastName age=age avatarUrl=avatarUrl}} +``` + +Block form (> 80 characters): + +```hbs +{{#employee-details + firstName=firstName lastName=lastName age=age avatarUrl=avatarUrl + as |employee| +}} + {{employee.fullName}} +{{/employee-details}} +``` + +HTML element (> 80 characters): + +```hbs + +``` + +Examples of **correct** code for this rule: + +Non-block form (attributes on separate lines): + +```hbs +{{employee-details firstName=firstName lastName=lastName age=age avatarUrl=avatarUrl}} +``` + +Block form (attributes on separate lines): + +```hbs +{{#employee-details + firstName=firstName lastName=lastName age=age avatarUrl=avatarUrl + as |employee| +}} + {{employee.fullName}} +{{/employee-details}} +``` + +HTML element (attributes on separate lines): + +```hbs + +``` + +Short invocations (< 80 characters) are allowed on a single line: + +```hbs +{{employee-details firstName=firstName lastName=lastName}} +``` + +## Options + +- `open-invocation-max-len` (integer, default `80`): Maximum length of the opening invocation before attributes must be on separate lines. +- `indentation` (integer, default `2`): Number of spaces for attribute indentation. +- `process-elements` (boolean, default `true`): Also validate indentation of HTML/SVG element attributes. +- `element-open-end` (`"new-line"` | `"last-attribute"`, default `"new-line"`): Position of the closing `>` bracket. +- `mustache-open-end` (`"new-line"` | `"last-attribute"`, default `"new-line"`): Position of the closing `}}` braces. +- `as-indentation` (`"attribute"` | `"closing-brace"`, default `"closing-brace"`): Position of `as |param|` block params relative to attributes or closing brace. diff --git a/lib/rules/template-attribute-indentation.js b/lib/rules/template-attribute-indentation.js new file mode 100644 index 0000000000..3774cc669e --- /dev/null +++ b/lib/rules/template-attribute-indentation.js @@ -0,0 +1,509 @@ +'use strict'; + +function getWhiteSpaceLength(statement) { + const whiteSpace = statement.match(/^\s+/) || []; + return (whiteSpace[0] || '').length; +} + +function getEndLocationForOpen(node) { + return node.type === 'GlimmerBlockStatement' ? node.program.loc.start : node.loc.end; +} + +function canApplyRule(node, config, sourceCode) { + let end; + if (node.type === 'GlimmerElementNode') { + // Use the first `>` token to find the end of the opening tag + const tokens = sourceCode.getTokens(node); + const openEnd = tokens.find((t) => t.value === '>'); + end = openEnd ? openEnd.loc.end : node.loc.end; + } else { + end = getEndLocationForOpen(node); + } + const start = node.loc.start; + if (start.line === end.line) { + return end.column - start.column > config.maxLength; + } + return true; +} + +function getSourceForLoc(sourceLines, loc) { + const startLine = loc.start.line; + const startColumn = loc.start.column; + const endLine = loc.end?.line || startLine; + const endColumn = loc.end?.column; + + if (startLine === endLine) { + return endColumn === undefined + ? sourceLines[startLine - 1].slice(startColumn) + : sourceLines[startLine - 1].slice(startColumn, endColumn); + } + + const lines = []; + for (let i = startLine; i <= endLine; i++) { + if (i === startLine) { + lines.push(sourceLines[i - 1].slice(startColumn)); + } else if (i === endLine && endColumn !== undefined) { + lines.push(sourceLines[i - 1].slice(0, endColumn)); + } else { + lines.push(sourceLines[i - 1]); + } + } + return lines.join('\n'); +} + +function getSourceForNode(sourceLines, node) { + return getSourceForLoc(sourceLines, node.loc); +} + +function parseOptions(options) { + if (!options || typeof options !== 'object') { + return { + maxLength: 80, + indentation: 2, + processElements: true, + mustacheOpenEnd: 'new-line', + elementOpenEnd: 'new-line', + }; + } + + const result = { + maxLength: 80, + indentation: 2, + mustacheOpenEnd: 'new-line', + elementOpenEnd: 'new-line', + }; + + if ('open-invocation-max-len' in options) { + result.maxLength = options['open-invocation-max-len']; + } + if ('indentation' in options) { + result.indentation = options.indentation; + } + if ('process-elements' in options) { + result.processElements = options['process-elements']; + } + if ('mustache-open-end' in options) { + result.mustacheOpenEnd = options['mustache-open-end']; + } + if ('element-open-end' in options) { + result.processElements = true; + result.elementOpenEnd = options['element-open-end']; + } + if ('as-indentation' in options) { + result.asIndentation = options['as-indentation']; + } + return result; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'layout', + docs: { + description: 'enforce proper indentation of attributes and arguments in multi-line templates', + category: 'Stylistic Issues', + recommended: false, + recommendedGjs: false, + recommendedGts: false, + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-attribute-indentation.md', + templateMode: 'both', + }, + fixable: null, + schema: [ + { + type: 'object', + properties: { + 'open-invocation-max-len': { type: 'integer', minimum: 0 }, + indentation: { type: 'integer', minimum: 0 }, + 'process-elements': { type: 'boolean' }, + 'mustache-open-end': { enum: ['new-line', 'last-attribute'] }, + 'element-open-end': { enum: ['new-line', 'last-attribute'] }, + 'as-indentation': { enum: ['attribute', 'closing-brace'] }, + }, + additionalProperties: false, + }, + ], + messages: { + incorrectParamIndentation: + "Incorrect indentation of {{paramType}} '{{paramName}}' beginning at L{{actualLine}}:C{{actualColumn}}. Expected '{{paramName}}' to be at L{{expectedLine}}:C{{expectedColumn}}.", + incorrectCloseBrace: + "Incorrect indentation of close curly braces '}}' for the component '{{{{componentName}}}}' beginning at L{{actualLine}}:C{{actualColumn}}. Expected '{{{{componentName}}}}' to be at L{{expectedLine}}:C{{expectedColumn}}.", + incorrectCloseBracket: + "Incorrect indentation of close bracket '>' for the element '<{{tagName}}>' beginning at L{{actualLine}}:C{{actualColumn}}. Expected '<{{tagName}}>' to be at L{{expectedLine}}:C{{expectedColumn}}.", + incorrectBlockParamIndentation: + "Incorrect indentation of block params '{{blockParamStatement}}' beginning at L{{actualLine}}:C{{actualColumn}}. Expecting the block params to be at L{{expectedLine}}:C{{expectedColumn}}.", + }, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/attribute-indentation.js', + docs: 'docs/rule/attribute-indentation.md', + tests: 'test/unit/rules/attribute-indentation-test.js', + }, + }, + + create(context) { + const config = parseOptions(context.options[0]); + const sourceCode = context.sourceCode; + const sourceLines = sourceCode.getText().split('\n'); + + function getLineIndentation(node) { + const currentLine = sourceLines[node.loc.start.line - 1]; + const leadingWhitespace = getWhiteSpaceLength(currentLine); + if (leadingWhitespace === 0) { + return node.loc.start.column; + } + return leadingWhitespace; + } + + function getBlockParamStartLoc(node) { + let actual, expected; + const actualProgramStartLine = /^\s*}}/.test(sourceLines[node.program.loc.start.line - 1]) + ? 1 + : 0; + const programStartLoc = { + line: node.program.loc.start.line - actualProgramStartLine, + column: node.program.loc.start.column, + }; + const nodeStart = node.loc.start; + if (node.params.length === 0 && (!node.hash || node.hash.pairs.length === 0)) { + expected = { + line: nodeStart.line + 1, + column: nodeStart.column, + }; + if (nodeStart.line === programStartLoc.line) { + const displayName = `{{#${node.path.original}`; + actual = { + line: nodeStart.line, + column: displayName.length, + }; + } else { + const source = getSourceForLoc(sourceLines, { + start: { + line: programStartLoc.line, + column: 0, + }, + end: programStartLoc, + }); + actual = { + line: programStartLoc.line, + column: getWhiteSpaceLength(source), + }; + } + } else { + let paramOrHashPairEndLoc; + + if (node.params.length > 0) { + paramOrHashPairEndLoc = node.params.at(-1).loc.end; + } + + if (node.hash && node.hash.pairs.length > 0) { + paramOrHashPairEndLoc = node.hash.loc.end; + } + + const indentation = config.asIndentation === 'attribute' ? 2 : 0; + expected = { + line: paramOrHashPairEndLoc.line + 1, + column: node.loc.start.column + indentation, + }; + if (paramOrHashPairEndLoc.line === programStartLoc.line) { + actual = paramOrHashPairEndLoc; + } else if (paramOrHashPairEndLoc.line < programStartLoc.line) { + const loc = { + start: paramOrHashPairEndLoc, + end: { + line: paramOrHashPairEndLoc.line, + }, + }; + + const hashPairLineEndSource = getSourceForLoc(sourceLines, loc).trim(); + + actual = hashPairLineEndSource + ? paramOrHashPairEndLoc + : { + line: programStartLoc.line, + column: getWhiteSpaceLength(sourceLines[programStartLoc.line - 1]), + }; + } + } + return { actual, expected }; + } + + function validateBlockParams(node) { + const location = getBlockParamStartLoc(node); + const actual = location.actual; + const expected = location.expected; + + if (actual.line !== expected.line || actual.column !== expected.column) { + const blockParamStatement = getSourceForLoc(sourceLines, { + start: actual, + end: node.program.loc.start, + }).trim(); + + context.report({ + node, + messageId: 'incorrectBlockParamIndentation', + loc: { line: actual.line, column: actual.column }, + data: { + blockParamStatement, + actualLine: actual.line, + actualColumn: actual.column, + expectedLine: expected.line, + expectedColumn: expected.column, + }, + }); + } + const expectedColumnNextLocation = + node.type === 'GlimmerElementNode' && !node.selfClosing ? 1 : 2; + return { + line: expected.line + 1, + column: expected.column + node.program.loc.start.column - expectedColumnNextLocation, + }; + } + + function iterateParams(params, type, initialExpectedLineStart, expectedColumnStart, node) { + let expectedLineStart = initialExpectedLineStart; + let paramType = type; + let namePath; + + switch (type) { + case 'positional': { + paramType = 'positional param'; + namePath = 'original'; + break; + } + case 'htmlAttribute': { + paramType = 'htmlAttribute'; + namePath = 'name'; + break; + } + case 'element modifier': { + paramType = 'element modifier'; + break; + } + default: { + paramType = type; + namePath = 'key'; + } + } + + let nextColumn = expectedColumnStart; + for (const param of params) { + const actualStartLocation = param.loc.start; + nextColumn = param.loc.end.column; + if ( + expectedLineStart !== actualStartLocation.line || + expectedColumnStart !== actualStartLocation.column + ) { + const paramName = param[namePath] || param.path?.original; + context.report({ + node: param, + messageId: 'incorrectParamIndentation', + loc: { line: actualStartLocation.line, column: actualStartLocation.column }, + data: { + paramType, + paramName, + actualLine: actualStartLocation.line, + actualColumn: actualStartLocation.column, + expectedLine: expectedLineStart, + expectedColumn: expectedColumnStart, + }, + }); + } + + const paramValueType = param.value ? param.value.type : param.type; + if (paramValueType === 'GlimmerSubExpression' || paramValueType === 'SubExpression') { + if (param.loc.start.line !== param.loc.end.line) { + expectedLineStart = param.loc.end.line; + } + } else if ( + paramValueType === 'GlimmerMustacheStatement' || + paramValueType === 'MustacheStatement' + ) { + expectedLineStart = param.value.loc.end.line; + nextColumn = param.value.loc.end.column; + } + + expectedLineStart++; + } + + return { + line: expectedLineStart, + column: nextColumn, + }; + } + + function validateParams(node) { + const leadingWhitespace = getLineIndentation(node); + const expectedColumnStart = leadingWhitespace + config.indentation; + const expectedLineStart = node.loc.start.line + 1; + + let nextLocation = { + line: expectedLineStart, + column: node.loc.start.column, + }; + + if (node.type === 'GlimmerElementNode') { + if (node.attributes.length > 0) { + nextLocation = iterateParams( + node.attributes, + 'htmlAttribute', + expectedLineStart, + expectedColumnStart, + node + ); + } + + if (node.modifiers.length > 0) { + nextLocation = iterateParams( + node.modifiers, + 'element modifier', + nextLocation.line, + expectedColumnStart, + node + ); + } + } else { + if (node.params.length > 0) { + nextLocation = iterateParams( + node.params, + 'positional', + expectedLineStart, + expectedColumnStart, + node + ); + } + if (node.hash && node.hash.pairs.length > 0) { + nextLocation = iterateParams( + node.hash.pairs, + 'attribute', + nextLocation.line, + expectedColumnStart, + node + ); + } + } + + return nextLocation; + } + + function validateCloseBrace(node, nextLocation) { + const openIndentation = getLineIndentation(node); + + let actualStartLocation; + + if (node.type === 'GlimmerElementNode') { + // Use tokens to find the actual `>` position + const tokens = sourceCode.getTokens(node); + const openEnd = tokens.find((t) => t.value === '>'); + if (!openEnd) { + return; + } + // For self-closing `/>`, the `>` is preceded by `/` + if (node.selfClosing) { + const slashToken = tokens.find((t) => t.value === '/' && t.range[1] === openEnd.range[0]); + actualStartLocation = slashToken ? slashToken.loc.start : openEnd.loc.start; + } else { + actualStartLocation = openEnd.loc.start; + } + } else { + const end = getEndLocationForOpen(node); + const actualColumnStartLocation = + node.type === 'GlimmerMustacheStatement' && node.trusting ? 3 : 2; + + actualStartLocation = { + line: end.line, + column: end.column - actualColumnStartLocation, + }; + } + + const endPosition = + node.type === 'GlimmerElementNode' ? config.elementOpenEnd : config.mustacheOpenEnd; + const expectedStartLocation = { + line: endPosition === 'last-attribute' ? nextLocation.line - 1 : nextLocation.line, + column: endPosition === 'last-attribute' ? nextLocation.column : openIndentation, + }; + + if ( + actualStartLocation.line !== expectedStartLocation.line || + actualStartLocation.column !== expectedStartLocation.column + ) { + if (node.type === 'GlimmerElementNode') { + const tagName = node.tag; + context.report({ + node, + messageId: 'incorrectCloseBracket', + loc: { line: actualStartLocation.line, column: actualStartLocation.column }, + data: { + tagName, + actualLine: actualStartLocation.line, + actualColumn: actualStartLocation.column, + expectedLine: expectedStartLocation.line, + expectedColumn: expectedStartLocation.column, + }, + }); + } else { + const componentName = node.path.original; + context.report({ + node, + messageId: 'incorrectCloseBrace', + loc: { line: actualStartLocation.line, column: actualStartLocation.column }, + data: { + componentName, + actualLine: actualStartLocation.line, + actualColumn: actualStartLocation.column, + expectedLine: expectedStartLocation.line, + expectedColumn: expectedStartLocation.column, + }, + }); + } + } + } + + function validateNonBlockForm(node) { + if (node.params.length > 0 || (node.hash && node.hash.pairs.length > 0)) { + const nextLocation = validateParams(node); + validateCloseBrace(node, nextLocation); + return nextLocation; + } + return undefined; + } + + function validateBlockForm(node) { + let nextLocation; + if (node.params.length > 0 || (node.hash && node.hash.pairs.length > 0)) { + nextLocation = validateParams(node); + } + if (node.program?.blockParams && node.program.blockParams.length > 0) { + nextLocation = validateBlockParams(node); + } + if (nextLocation) { + validateCloseBrace(node, nextLocation); + } + } + + return { + GlimmerBlockStatement(node) { + if (canApplyRule(node, config, sourceCode)) { + validateBlockForm(node); + } + }, + + GlimmerMustacheStatement(node) { + if (canApplyRule(node, config, sourceCode)) { + validateNonBlockForm(node); + } + }, + + GlimmerElementNode(node) { + if (config.processElements) { + if (canApplyRule(node, config, sourceCode)) { + if (node.modifiers.length > 0 || node.attributes.length > 0) { + const expectedCloseBraceLocation = validateParams(node); + validateCloseBrace(node, expectedCloseBraceLocation); + } + } + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-attribute-indentation.js b/tests/lib/rules/template-attribute-indentation.js new file mode 100644 index 0000000000..fc3f4b4c14 --- /dev/null +++ b/tests/lib/rules/template-attribute-indentation.js @@ -0,0 +1,992 @@ +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/template-attribute-indentation'); +const RuleTester = require('eslint').RuleTester; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +const gjsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +// ---- HBS tests ---- + +hbsRuleTester.run('template-attribute-indentation', rule, { + valid: [ + // Short invocations on a single line are fine (< 80 chars) + '{{employee-details firstName=firstName}}', + '', + + // Non-block with no params + '{{contact-details}}', + // Default config with open-invocation (< 80 chars) - positional params + '{{contact-details firstName lastName}}', + // named params + '{{contact-details firstName=firstName lastName=lastName}}', + + // Mustache non-block with proper indentation and new-line close + { + code: ['{{contact-details', ' firstName=firstName', ' lastName=lastName', '}}'].join('\n'), + options: [{ 'mustache-open-end': 'new-line' }], + }, + // Mustache non-block with last-attribute close + { + code: ['{{contact-details', ' firstName=firstName', ' lastName=lastName}}'].join('\n'), + options: [{ 'mustache-open-end': 'last-attribute' }], + }, + + // Open-invocation with multiple lines + ['{{contact-details', ' firstName=firstName', ' lastName=lastName', '}}'].join('\n'), + // Positional params multi-line + ['{{contact-details', ' firstName', ' lastName', '}}'].join('\n'), + // Helper + [ + '{{if', + ' (or logout.isRunning (not session.isAuthenticated))', + ' "Logging Out..."', + ' "Log Out"', + '}}', + ].join('\n'), + // Helper unfolded + [ + '{{if', + ' (or ', + ' logout.isRunning', + ' (not session.isAuthenticated)', + ' )', + ' "Logging Out..."', + ' "Log Out"', + '}}', + ].join('\n'), + // Positional null + ['{{contact-null', ' null', '}}'].join('\n'), + // Component helper + ['{{component', ' field', ' action=(action reaction)', '}}'].join('\n'), + // Multiple open-invocations + [ + '{{contact-details', + ' firstName=firstName', + ' lastName=lastName', + '}}', + '{{contact-details', + ' firstName=firstName', + ' lastName=lastName', + '}}', + ].join('\n'), + // Component from hash + ['{{t.body', ' canExpand=true', '}}'].join('\n'), + // With helper + ['{{print-debug', ' foo=(or', ' foo', ' bar', ' )', ' baz=baz', '}}'].join('\n'), + // With positional helper + ['{{print-debug', ' (hash', ' foo="bar"', ' )', ' title="baz"', '}}'].join('\n'), + // yield with hash + [ + '{{yield', + ' (hash', + ' header=(component "x-very-long-name-header")', + ' body=(component "x-very-long-name-body")', + ' )', + '}}', + ].join('\n'), + + // Block form within 80 characters - positional params + ['{{#contact-details firstName lastName}}', ' {{contactImage}}', '{{/contact-details}}'].join( + '\n' + ), + // Block form with named params + [ + '{{#contact-details firstName=firstName lastName=lastName}}', + ' {{contactImage}}', + '{{/contact-details}}', + ].join('\n'), + // Component from hash block form + [ + '{{#t.body', + ' canExpand=true', + ' multiRowExpansion=false', + '}}', + ' {{foo}}', + '{{/t.body}}', + ].join('\n'), + // Block form with block params + [ + '{{#contact-details firstName=firstName lastName=lastName as |contact|}}', + ' {{contact.fullName}}', + '{{/contact-details}}', + ].join('\n'), + // Component from positional block form + [ + '{{#t.body', + ' canExpand=(helper help)', + ' multiRowExpansion=false', + 'as |body|', + '}}', + ' {{foo}}', + '{{/t.body}}', + ].join('\n'), + // Indented block params + [ + ' {{#t.body', + ' canExpand=(helper help)', + ' multiRowExpansion=false', + ' as |body|', + ' }}', + ' {{foo}}', + ' {{/t.body}}', + ].join('\n'), + + // Non-block form with open-invocation-max-len + { + code: '{{contact-details firstName=firstName lastName=lastName avatarUrl=avatarUrl age=age address=address phoneNo=phoneNo}}', + options: [{ 'open-invocation-max-len': 120 }], + }, + + // Block form with open-invocation > 80, config allows 120 + { + code: [ + '{{#contact-details firstName=firstName lastName=lastName age=age avatarUrl=avatarUrl as |contact|}}', + ' {{contact.fullName}}', + '{{/contact-details}}', + ].join('\n'), + options: [{ 'mustache-open-end': 'last-attribute', 'open-invocation-max-len': 120 }], + }, + + // Block form with multiple line invocation + [ + '{{#contact-details', + ' firstName=firstName', + ' lastName=lastName', + 'as |fullName|', + '}}', + ' {{fullName}}', + '{{/contact-details}}', + ].join('\n'), + // Block form with no params + [ + '{{#contact-details', + 'as |contact|', + '}}', + ' {{contact.fullName}}', + '{{/contact-details}}', + ].join('\n'), + + // Nested elements sanity check + '