中安拓也のブログ

プログラミングについて書くブログ。 Twitterやってます @takuya_nakayasu

【ESLint】Angularのファイル名の命名規則をチェックするESLintのカスタムルールを作成した

はじめに

Angularプロジェクトで作成したファイル名Angularコーディングスタイルガイドに沿っているかチェックするESLintのカスタムルール『eslint-plugin-angular-file-naming』を作成してnpmパッケージとして公開しました。

  • GitHubリポジトリ

github.com

  • npmパッケージ

www.npmjs.com

eslint-plugin-angular-file-namingの概要

eslint-plugin-angular-file-namingでは、5件のESLintのカスタムルールを提供していて、それぞれのルールでAngularのファイルタイプ(Component, Directive, Module, Pipe, Service)に適したsuffix(.component.ts, .directive.ts, .module.ts, .pipe.ts, .service.ts...)がファイル名に設定されているかを確認します。

また、suffixについてはデフォルトで設定されている値(.component.ts, .directive.ts, .module.ts, .pipe.ts, .service.ts...)とは別にオプションでsuffixの値を設定することもできます。

使用例

例えば、以下のように.eslintrcを設定した場合は、

// .eslintrc.json
module.exports = {
  "plugins": [
    ...,
    "angular-file-naming"
  ],
  "rules": [
    ...,
    "angular-file-naming/component-filename-suffix": [
      "error",
      {
        "suffixes": ["component", "page", "view"]
      }
    ],
    "angular-file-naming/directive-filename-suffix": "error",
    "angular-file-naming/module-filename-suffix": "error",
    "angular-file-naming/pipe-filename-suffix": "error",
    "angular-file-naming/service-filename-suffix": [
      "error",
      {
        "suffixes": ["service", "guard", "store"]
      }
    ],
  ]
  ...,
}

以下のファイル名が誤ったファイル名になり、

app.comp.ts
sample.ts
test.filter.ts

以下のファイル名が正しいファイル名になります。

app.component.ts
app.page.ts
app.view.ts
test.directive.ts
app.module.ts
sample.pipe.ts
test.service.ts
test.guard.ts
test.store.ts

f:id:l08084:20210724171604p:plain
誤ったファイル名を設定している場合、Lintエラーメッセージが表示される

環境

以下のバージョンのライブラリを使用して、ESLintのカスタムルールのnpmパッケージを作成しました。

  • eslint v7.28.0
  • typescript v4.3.2
  • jest v27.0.4

Angular v12のプロジェクトで動作確認をしています。

実装

eslint-plugin-angular-file-namingのカスタムルールの一つ、component-filename-suffixの実装内容について説明していきます。

カスタムルールのコード

component-filename-suffixは、@Componentデコレーターを持つクラスのファイル名のsuffixが.component.ts(もしくはオプションで設定したsuffix)に設定されているかをチェックするルールになります。

以下2件のソースコードのうち、src/rules/component-filename-suffix.tscomponent-filename-suffixのソースコード、src/utils/utils.tsは、component-filename-suffixが参照しているユーティリティになります。

src/rules/component-filename-suffix.ts

import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
import {
  COMPONENT_CLASS_DECORATOR,
  getFilenameSuffix,
  STYLE_GUIDE_LINK,
} from '../utils/utils';

type Options = { suffixes: string[] };

export const componentFilenameSuffix: TSESLint.RuleModule<
  'componentFilenameSuffix',
  [Options]
> = {
  meta: {
    type: 'suggestion',
    docs: {
      category: 'Best Practices',
      description: `The file name of Classes decorated with @Component must have suffix "component" (or custom) in their name. See more at ${STYLE_GUIDE_LINK}`,
      recommended: 'error',
      url: 'https://github.com/l08084/eslint-plugin-angular-file-naming/blob/main/docs/rules/component-filename-suffix.md',
    },
    messages: {
      componentFilenameSuffix: `The file name of component class should end with one of these suffixes: {{suffixes}} (${STYLE_GUIDE_LINK})`,
    },
    schema: [
      {
        type: 'object',
        properties: {
          suffixes: {
            type: 'array',
            items: {
              type: 'string',
            },
          },
        },
        additionalProperties: false,
      },
    ],
  },
  create: (context) => {
    return {
      [COMPONENT_CLASS_DECORATOR](node: TSESTree.Decorator) {
        const filename = context.getFilename();
        const suffixes = context.options[0]?.suffixes || ['component'];
        const filenameSuffix = getFilenameSuffix(filename);
        if (
          !filenameSuffix ||
          !(filenameSuffix.length > 1) ||
          !suffixes.find((suffix) => suffix === filenameSuffix[1])
        ) {
          context.report({
            node,
            messageId: 'componentFilenameSuffix',
            data: { suffixes },
          });
        }
      },
    };
  },
};

module.exports = componentFilenameSuffix;
export default componentFilenameSuffix;

src/utils/utils.ts

export const STYLE_GUIDE_LINK =
  'https://angular.io/guide/styleguide#style-02-03';

export const COMPONENT_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Component"]';

export const DIRECTIVE_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Directive"]';

export const PIPE_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Pipe"]';

export const MODULE_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="NgModule"]';

export const INJECTABLE_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Injectable"]';

/**
 * get filename suffix
 *
 * @export
 * @param {string} filename
 * @returns {(RegExpMatchArray | null)}
 */
export function getFilenameSuffix(filename: string): RegExpMatchArray | null {
  return filename.match(/(?:\/|\\).+\.(.+)\.ts$/);
}

ソースコードに@Componentデコレーターが含まれているかどうかのチェックは、ESLintのSelector機能を使用しています。

export const COMPONENT_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Component"]';

ファイル名のsuffixの抽出は、正規表現のグループ機能を使用しています。

return filename.match(/(?:\/|\\).+\.(.+)\.ts$/);

テストコード

component-filename-suffixのテストコードです。

テスティングフレームワークとして、Jestを使用しています。

tests/rules/component-filename-suffix.test.ts

import { TSESLint } from '@typescript-eslint/experimental-utils';
import componentFilenameSuffix from '../../src/rules/component-filename-suffix';

const tester = new TSESLint.RuleTester({
  parser: require.resolve('@typescript-eslint/parser'),
});

tester.run('componentFilenameSuffix', componentFilenameSuffix, {
  valid: [
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.component.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.component.ts',
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.component.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.component.ts',
      options: [{ suffixes: ['component', 'page'] }],
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.page.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.page.ts',
      options: [{ suffixes: ['component', 'page'] }],
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.page.html',
    })
    class TestPage {}
  `,
      filename: '/src/app/test.page.ts',
      options: [{ suffixes: ['page'] }],
    },
    {
      code: `
    @Directive({
      selector: '[myHighlight]'
    })
    class TestDirective {}
  `,
      filename: '/src/app/test.directive.ts',
      options: [{ suffixes: ['page'] }],
    },
  ],
  invalid: [
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.component.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.components.ts',
      errors: [{ messageId: 'componentFilenameSuffix' }],
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.view.html',
    })
    class TestView {}
  `,
      filename: '/src/app/test.view.ts',
      options: [{ suffixes: ['component', 'page'] }],
      errors: [{ messageId: 'componentFilenameSuffix' }],
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.component.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.component.ts',
      options: [{ suffixes: ['view', 'page'] }],
      errors: [{ messageId: 'componentFilenameSuffix' }],
    },
  ],
});

参考サイト

How can I enforce filename and folder name convention in typescript eslint? - Stack Overflow

eslint-plugin-unicorn/filename-case.md at main · sindresorhus/eslint-plugin-unicorn · GitHub

Angular: ESLintサポートの現状 2020 Autumn | lacolaco/tech

Selectors - ESLint - Pluggable JavaScript linter