Skip to content

Auto-fixable missing invokable rule #2272

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [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 for gts/gjs templates | | 🔧 | |
| [template-missing-invokable](docs/rules/template-missing-invokable.md) | disallow missing helpers, modifiers, or components in \<template\> with auto-fix to import them | | 🔧 | |
| [template-no-let-reference](docs/rules/template-no-let-reference.md) | disallow referencing let variables in \<template\> | ![gjs logo](/docs/svgs/gjs.svg) ![gts logo](/docs/svgs/gts.svg) | | |

### jQuery
Expand Down
30 changes: 30 additions & 0 deletions docs/rules/template-missing-invokable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# ember/template-missing-invokable

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

Auto-fixes missing imports for helpers, modifiers, and components in your \<template> tags.

If you refer to `on` without importing it:

```gjs
<template>
<button {{on "click" doSomething}}>Do Something</button>
</template>
```

The auto-fix will create the import:

```gjs
import { on } from '@ember/modifier';
<template>
<button {{on "click" doSomething}}>Do Something</button>
</template>
```

## Examples

## Config

- invokables
88 changes: 88 additions & 0 deletions lib/rules/template-missing-invokable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow missing helpers, modifiers, or components in \\<template\\> with auto-fix to import them',
category: 'Ember Octane',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-missing-invokable.md',
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
invokables: {
type: 'object',
additionalProperties: {
type: 'array',
prefixItems: [
{ type: 'string', description: 'The name to import from the module' },
{ type: 'string', description: 'The module to import from' },
],
},
},
},
},
],
messages: {
'missing-invokable':
'Not in scope. Did you forget to import this? Auto-fix may be configured.',
},
},

create: (context) => {
const sourceCode = context.sourceCode;

// takes a node with a `.path` property
function checkInvokable(node) {
if (node.path.type === 'GlimmerPathExpression' && node.path.tail.length === 0) {
if (!isBound(node.path.head, sourceCode.getScope(node.path))) {
const matched = context.options[0]?.invokables?.[node.path.head.name];
if (matched) {
const [name, module] = matched;
const importStatement = buildImportStatement(node.path.head.name, name, module);
context.report({
node: node.path,
messageId: 'missing-invokable',
fix(fixer) {
return fixer.insertTextBeforeRange([0, 0], `${importStatement};\n`);
},
});
}
}
}
}

return {
GlimmerSubExpression(node) {
return checkInvokable(node);
},
GlimmerElementModifierStatement(node) {
return checkInvokable(node);
},
GlimmerMustacheStatement(node) {
return checkInvokable(node);
},
};
},
};

function isBound(node, scope) {
const ref = scope.references.find((v) => v.identifier === node);
if (!ref) {
return false;
}
return Boolean(ref.resolved);
}

function buildImportStatement(consumedName, exportedName, module) {
if (exportedName === 'default') {
return `import ${consumedName} from '${module}'`;
} else {
return consumedName === exportedName
? `import { ${consumedName} } from '${module}'`
: `import { ${exportedName} as ${consumedName} } from '${module}'`;
}
}
247 changes: 247 additions & 0 deletions tests/lib/rules/template-missing-invokable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const rule = require('../../../lib/rules/template-missing-invokable');
const RuleTester = require('eslint').RuleTester;

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});
ruleTester.run('template-missing-invokable', rule, {
valid: [
// Subexpression Invocations
`
import { eq } from 'somewhere';
<template>
{{#if (eq 1 1)}}
They're equal
{{/if}}
</template>
`,
`
function eq() {}
<template>
{{#if (eq 1 1)}}
They're equal
{{/if}}
</template>
`,
`
function x(eq) {
<template>
{{#if (eq 1 1)}}
They're equal
{{/if}}
</template>
}
`,

// Mustache Invocations
`
import { eq } from 'somewhere';
<template>
{{eq 1 1}}
</template>
`,
`
import { eq } from 'somewhere';
import MyComponent from 'somewhere';
<template>
<MyComponent @flag={{eq 1 1}} />
</template>
`,

// Modifier Invocations
`
import { on } from 'somewhere';
function doSomething() {}
<template>
<button {{on "click" doSomething}}>Go</button>
</template>
`,
],

invalid: [
// Subexpression invocations
{
code: `
<template>
{{#if (eq 1 1)}}
They're equal
{{/if}}
</template>
`,
output: `import { eq } from 'ember-truth-helpers';

<template>
{{#if (eq 1 1)}}
They're equal
{{/if}}
</template>
`,
options: [
{
invokables: {
eq: ['eq', 'ember-truth-helpers'],
},
},
],

errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
},

// Mustache Invocations
{
code: `
<template>
{{eq 1 1}}
</template>
`,
output: `import { eq } from 'ember-truth-helpers';

<template>
{{eq 1 1}}
</template>
`,
options: [
{
invokables: {
eq: ['eq', 'ember-truth-helpers'],
},
},
],
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
},
{
code: `
import MyComponent from 'somewhere';
<template>
<MyComponent @flag={{eq 1 1}} />
</template>
`,
output: `import { eq } from 'ember-truth-helpers';

import MyComponent from 'somewhere';
<template>
<MyComponent @flag={{eq 1 1}} />
</template>
`,
options: [
{
invokables: {
eq: ['eq', 'ember-truth-helpers'],
},
},
],
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
},

// Modifier Inovcations
{
code: `
function doSomething() {}
<template>
<button {{on "click" doSomething}}>Go</button>
</template>
`,
output: `import { on } from '@ember/modifier';

function doSomething() {}
<template>
<button {{on "click" doSomething}}>Go</button>
</template>
`,
options: [
{
invokables: {
on: ['on', '@ember/modifier'],
},
},
],
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
},

// Multiple copies of a fixable invocation
{
code: `
let other = <template>
{{#if (eq 3 3) }}
three is three
{{/if}}
</template>

<template>
{{#if (eq 1 1) }}
one is one
{{/if}}
{{#if (eq 2 2) }}
two is two
{{/if}}
</template>
`,
output: `import { eq } from 'ember-truth-helpers';

let other = <template>
{{#if (eq 3 3) }}
three is three
{{/if}}
</template>

<template>
{{#if (eq 1 1) }}
one is one
{{/if}}
{{#if (eq 2 2) }}
two is two
{{/if}}
</template>
`,
options: [
{
invokables: {
eq: ['eq', 'ember-truth-helpers'],
},
},
],
errors: [
{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] },
{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] },
{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] },
],
},

// Auto-fix with a default export
{
code: `
<template>
{{#if (eq 1 1)}}
They're equal
{{/if}}
</template>
`,
output: `import eq from 'ember-truth-helpers/helpers/equal';

<template>
{{#if (eq 1 1)}}
They're equal
{{/if}}
</template>
`,
options: [
{
invokables: {
eq: ['default', 'ember-truth-helpers/helpers/equal'],
},
},
],

errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
},
],
});