diff --git a/README.md b/README.md index df001c9acf..fd29191384 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | | | [template-no-chained-this](docs/rules/template-no-chained-this.md) | disallow redundant `this.this` in templates | | 🔧 | | | [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | | +| [template-no-dynamic-subexpression-invocations](docs/rules/template-no-dynamic-subexpression-invocations.md) | disallow dynamic subexpression invocations | | | | | [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | | | [template-no-implicit-this](docs/rules/template-no-implicit-this.md) | require explicit `this` in property access | | | | | [template-no-inline-event-handlers](docs/rules/template-no-inline-event-handlers.md) | disallow DOM event handler attributes | | | | diff --git a/docs/rules/template-no-dynamic-subexpression-invocations.md b/docs/rules/template-no-dynamic-subexpression-invocations.md new file mode 100644 index 0000000000..699f304a4e --- /dev/null +++ b/docs/rules/template-no-dynamic-subexpression-invocations.md @@ -0,0 +1,62 @@ +# ember/template-no-dynamic-subexpression-invocations + + + +Disallow dynamic helper invocations. + +Dynamic helper invocations (where the helper name comes from a property or argument) make code harder to understand and can have performance implications. Use explicit helper names instead. + +## Rule Details + +This rule disallows invoking helpers dynamically using `this` or `@` properties. + +## Examples + +### Incorrect ❌ + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +### Correct ✅ + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +## Related Rules + +- [template-no-implicit-this](./template-no-implicit-this.md) + +## References + +- [Ember Guides - Template Helpers](https://guides.emberjs.com/release/components/helper-functions/) +- [eslint-plugin-ember template-no-dynamic-subexpression-invocations](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-dynamic-subexpression-invocations.md) diff --git a/lib/rules/template-no-dynamic-subexpression-invocations.js b/lib/rules/template-no-dynamic-subexpression-invocations.js new file mode 100644 index 0000000000..78a87413e9 --- /dev/null +++ b/lib/rules/template-no-dynamic-subexpression-invocations.js @@ -0,0 +1,157 @@ +function isInAttrPosition(node) { + let p = node.parent; + while (p) { + if (p.type === 'GlimmerAttrNode') { + return true; + } + if (p.type === 'GlimmerConcatStatement') { + p = p.parent; + continue; + } + if ( + p.type === 'GlimmerElementNode' || + p.type === 'GlimmerTemplate' || + p.type === 'GlimmerBlockStatement' || + p.type === 'GlimmerBlock' + ) { + return false; + } + p = p.parent; + } + return false; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'disallow dynamic subexpression invocations', + category: 'Best Practices', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-dynamic-subexpression-invocations.md', + templateMode: 'both', + }, + fixable: null, + schema: [], + messages: { + noDynamicSubexpressionInvocations: + 'Do not use dynamic helper invocations. Use explicit helper names instead.', + }, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/no-dynamic-subexpression-invocations.js', + docs: 'docs/rule/no-dynamic-subexpression-invocations.md', + tests: 'test/unit/rules/no-dynamic-subexpression-invocations-test.js', + }, + }, + + create(context) { + const localScopes = []; + + function pushLocals(params) { + localScopes.push(new Set(params || [])); + } + + function popLocals() { + localScopes.pop(); + } + + function isLocal(name) { + for (const scope of localScopes) { + if (scope.has(name)) { + return true; + } + } + return false; + } + + function isDynamicPath(path) { + if (!path || path.type !== 'GlimmerPathExpression') { + return false; + } + if (path.head?.type === 'AtHead') { + return true; + } + if (path.head?.type === 'ThisHead') { + return true; + } + if (path.original && path.original.includes('.')) { + return true; + } + if (path.original && isLocal(path.original)) { + return true; + } + return false; + } + + return { + GlimmerBlockStatement(node) { + if (node.program && node.program.blockParams) { + pushLocals(node.program.blockParams); + } + }, + 'GlimmerBlockStatement:exit'(node) { + if (node.program && node.program.blockParams) { + popLocals(); + } + }, + + GlimmerElementNode(node) { + if (node.blockParams && node.blockParams.length > 0) { + pushLocals(node.blockParams); + } + }, + 'GlimmerElementNode:exit'(node) { + if (node.blockParams && node.blockParams.length > 0) { + popLocals(); + } + }, + + GlimmerSubExpression(node) { + if (node.path && node.path.type === 'GlimmerPathExpression' && isDynamicPath(node.path)) { + context.report({ + node, + messageId: 'noDynamicSubexpressionInvocations', + }); + } + }, + + GlimmerElementModifierStatement(node) { + if (node.path && node.path.type === 'GlimmerPathExpression' && isDynamicPath(node.path)) { + context.report({ + node, + messageId: 'noDynamicSubexpressionInvocations', + }); + } + }, + + GlimmerMustacheStatement(node) { + if (node.path && node.path.type === 'GlimmerPathExpression') { + const inAttr = isInAttrPosition(node); + const hasArgs = + (node.params && node.params.length > 0) || + (node.hash && node.hash.pairs && node.hash.pairs.length > 0); + + if (inAttr && isDynamicPath(node.path) && hasArgs) { + // In attribute context, flag dynamic paths with arguments + context.report({ + node, + messageId: 'noDynamicSubexpressionInvocations', + }); + return; + } + + if (!inAttr && hasArgs) { + // In body context, only flag this.* paths (not @args) + if (node.path.head?.type === 'ThisHead') { + context.report({ + node, + messageId: 'noDynamicSubexpressionInvocations', + }); + } + } + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-no-dynamic-subexpression-invocations.js b/tests/lib/rules/template-no-dynamic-subexpression-invocations.js new file mode 100644 index 0000000000..7ae67ef96e --- /dev/null +++ b/tests/lib/rules/template-no-dynamic-subexpression-invocations.js @@ -0,0 +1,253 @@ +const rule = require('../../../lib/rules/template-no-dynamic-subexpression-invocations'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-no-dynamic-subexpression-invocations', rule, { + valid: [ + '', + '', + '', + + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ], + + invalid: [ + { + code: '', + output: null, + errors: [ + { + message: 'Do not use dynamic helper invocations. Use explicit helper names instead.', + type: 'GlimmerSubExpression', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: 'Do not use dynamic helper invocations. Use explicit helper names instead.', + type: 'GlimmerSubExpression', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: 'Do not use dynamic helper invocations. Use explicit helper names instead.', + type: 'GlimmerMustacheStatement', + }, + ], + }, + + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +hbsRuleTester.run('template-no-dynamic-subexpression-invocations', rule, { + valid: [ + '{{something "here"}}', + '{{something}}', + '{{something here="goes"}}', + '', + '{{@thing "somearg"}}', + '', + '', + '', + '', + '', + '', + '', + '{{1}}', + '{{true}}', + '{{null}}', + '{{undefined}}', + '{{"foo"}}', + ], + invalid: [ + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '{{#let "whatever" as |thing|}}{{/let}}', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '{{if (this.foo) "true" "false"}}', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + { + code: '', + output: null, + errors: [ + { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' }, + ], + }, + ], +});