Enforcing Consistent Placement of the @action Decorator with ESLint

Last reviewed on February 9, 2022

In an Ember app, I personally dislike seeing inconsistent placement of the @action decorator. For example, some may write it like this:

class MyComponent extends Component {
  @action  foo() {    // do stuff
  }
}

and others may write it like this:

class MyComponent extends Component {
  @action foo() {    // do stuff
  }
}

I figured an ESLint rule would be a perfect way to solve this. However, I wasn't able to find an ESLint plugin that worked. Therefore, I decided to write one myself to ensure the @action decorator wasn't placed on the same line as the method.

If you've never written a custom ESLint rule, I recommend going through the Abstract Syntax Forestry workshop by Simplabs for EmberConf2020.

I started by creating an ESLint plugin in my Ember app under lib/eslint-plugin-acme:

lib/eslint-plugin-acme/package.json
{
  "name": "eslint-plugin-acme",
  "description": "Custom ESLint rules",
  "main": "index.js"
}

Next, I created the index.js file that is referenced under the main key in the above code. In this file, I declared my new ESLint rule which I called no-inline-action.

lib/eslint-plugin-acme/index.js
module.exports = {
  rules: {
    "no-inline-action": require("./no-inline-action"),
  },
};

I then created lib/eslint-plugin-acme/no-inline-action.js. The code below checks to see if the placement of the @action decorator is on the same line as the method, and if it is, it'll publish a warning or error (depending on the configuration being used).

lib/eslint-plugin-acme/no-inline-action.js
module.exports = {
  create(context) {
    const sourceCode = context.getSourceCode();

    return {
      Decorator(node) {
        const { name } = node.expression;

        if (name === "action" && node.parent.type === "MethodDefinition") {
          const actionDecoratorTokens = sourceCode.getTokens(node);
          const methodToken = sourceCode.getTokenAfter(node);

          const actionDecoratorStartLine =
            actionDecoratorTokens[1].loc.start.line;
          const methodStartLine = methodToken.loc.start.line;

          if (actionDecoratorStartLine === methodStartLine) {
            context.report({
              node,
              message: `
                The @action decorator and ${methodToken.value} method
                are on the same line. Place the @action decorator
                above the method declaration.
              `.trim(),
            });
          }
        }
      },
    };
  },
};

At the time when I wrote this ESLint rule, I noticed that the AST wasn't taking into account spaces and thus the line number of the decorator and the line number of the method weren't what I expected. Thus, I had to use the context object to access the source code to see if the line numbers were the same or not. To be honest, I don't know if this had to do with the specific Ember app I was working in or what I stated previously is how the AST parsing typically works.

To wire up my custom ESLint plugin to the Ember app, I referenced this local package from the Ember app's package.json. I have intentionally left out all of other package.json keys in the code below so it is easier to understand.

package.json
{
  "devDependencies": {
    "eslint-plugin-acme": "file:lib/eslint-plugin-acme"
  },
}

Lastly, I declared how I wanted my new rule to be reported in .eslintrc.js.

.eslintrc.js
module.exports = {
  plugins: ["ember", "acme"],
  rules: {
    "acme/no-inline-action": "error",
  },
};