diff --git a/README.md b/README.md index 330bed0756..6f1ac87c2a 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-builtin-component-arguments](docs/rules/template-builtin-component-arguments.md) | disallow setting certain attributes on builtin components | | | | | [template-no-action-modifiers](docs/rules/template-no-action-modifiers.md) | disallow usage of {{action}} modifiers | | | | | [template-no-arguments-for-html-elements](docs/rules/template-no-arguments-for-html-elements.md) | disallow @arguments on HTML elements | | | | +| [template-no-array-prototype-extensions](docs/rules/template-no-array-prototype-extensions.md) | disallow usage of Ember Array prototype extensions | | | | | [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | | | [template-no-log](docs/rules/template-no-log.md) | disallow {{log}} in templates | | | | diff --git a/docs/rules/template-no-array-prototype-extensions.md b/docs/rules/template-no-array-prototype-extensions.md new file mode 100644 index 0000000000..639441709c --- /dev/null +++ b/docs/rules/template-no-array-prototype-extensions.md @@ -0,0 +1,71 @@ +# ember/template-no-array-prototype-extensions + + + +💼 This rule is enabled in the following [configs](https://github.com/ember-cli/eslint-plugin-ember#-configurations): `strict-gjs`, `strict-gts`. + +Disallow usage of Ember Array prototype extensions. + +Ember historically provided Array prototype extensions like `firstObject` and `lastObject`. These extensions are deprecated and should be replaced with native JavaScript array methods or computed properties. + +## Rule Details + +This rule disallows using Ember Array prototype extensions in templates: + +- `firstObject` +- `lastObject` +- `@each` +- `[]` + +## Examples + +### Incorrect ❌ + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +### Correct ✅ + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +## Related Rules + +- [no-array-prototype-extensions](./no-array-prototype-extensions.md) + +## References + +- [Ember Deprecations - Array prototype extensions](https://deprecations.emberjs.com/v3.x/#toc_ember-array-prototype-extensions) +- [eslint-plugin-ember template-no-array-prototype-extensions](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-array-prototype-extensions.md) diff --git a/lib/rules/template-no-array-prototype-extensions.js b/lib/rules/template-no-array-prototype-extensions.js new file mode 100644 index 0000000000..0f791d18fb --- /dev/null +++ b/lib/rules/template-no-array-prototype-extensions.js @@ -0,0 +1,112 @@ +const FIRST_OBJECT_PROP_NAME = 'firstObject'; +const LAST_OBJECT_PROP_NAME = 'lastObject'; + +const ERROR_MESSAGES = { + LAST_OBJECT: 'Array prototype extension property lastObject usage is disallowed.', + FIRST_OBJECT: + "Array prototype extension property firstObject usage is disallowed. Please use Ember's get helper instead, e.g. `(get @list '0')`.", +}; + +/** + * Check if the path should be allowed. `@firstObject.test`, `@lastObject`, and + * `this.firstObject` are allowed (they are property names, not extensions). + */ +function isAllowed(originalStr, matchedStr) { + // allow `@firstObject.test`, `@lastObject` + if (originalStr.startsWith(`@${matchedStr}`)) { + return true; + } + + const originalParts = originalStr.split('.'); + const matchStrIndex = originalParts.indexOf(matchedStr); + + // if not found + if (matchStrIndex === -1) { + return true; + } + // allow this.firstObject (direct property, not extension) + return !matchStrIndex || originalParts[matchStrIndex - 1] === 'this'; +} + +/** + * Check if current node is a `get` helper and its string literal contains matchedStr. + * For example `{{get this 'list.firstObject'}}` returns true, + * but `{{get this 'firstObject'}}` returns false (that's a direct property). + */ +function isGetHelperWithMatchedLiteral(node, matchedStr) { + if (node.original !== 'get') { + return false; + } + + const parent = node.parent; + if ( + parent && + (parent.type === 'GlimmerMustacheStatement' || parent.type === 'GlimmerSubExpression') && + parent.params && + parent.params[1] && + parent.params[1].type === 'GlimmerStringLiteral' + ) { + const literal = parent.params[1].value || parent.params[1].original; + const parts = literal.split('.'); + const matchStrIndex = parts.indexOf(matchedStr); + + // matchedStr is found and not the `{{get this 'firstObject'}}` case + return ( + matchStrIndex !== -1 && + !(matchStrIndex === 0 && parent.params[0] && parent.params[0].original === 'this') + ); + } + return false; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow usage of Ember Array prototype extensions', + category: 'Best Practices', + strictGjs: true, + strictGts: true, + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-array-prototype-extensions.md', + }, + fixable: null, + schema: [], + messages: { + lastObject: ERROR_MESSAGES.LAST_OBJECT, + firstObject: ERROR_MESSAGES.FIRST_OBJECT, + }, + }, + + create(context) { + return { + GlimmerPathExpression(node) { + if (!node.original) { + return; + } + + // Handle lastObject — no fixer available + if ( + !isAllowed(node.original, LAST_OBJECT_PROP_NAME) || + isGetHelperWithMatchedLiteral(node, LAST_OBJECT_PROP_NAME) + ) { + context.report({ + node, + messageId: 'lastObject', + }); + } + + // Handle firstObject + if ( + !isAllowed(node.original, FIRST_OBJECT_PROP_NAME) || + isGetHelperWithMatchedLiteral(node, FIRST_OBJECT_PROP_NAME) + ) { + context.report({ + node, + messageId: 'firstObject', + }); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-no-array-prototype-extensions.js b/tests/lib/rules/template-no-array-prototype-extensions.js new file mode 100644 index 0000000000..529d8dda5c --- /dev/null +++ b/tests/lib/rules/template-no-array-prototype-extensions.js @@ -0,0 +1,31 @@ +const rule = require('../../../lib/rules/template-no-array-prototype-extensions'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-no-array-prototype-extensions', rule, { + valid: [ + '', + '', + '', + '', + '', + '', + ], + + invalid: [ + { + code: '', + output: null, + errors: [{ messageId: 'firstObject' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'lastObject' }], + }, + ], +});