🚀

AST(抽象構文木)を構築せず文字列ベースの加工だけを行うPrettierプラグインの作り方

2024/11/11に公開

はじめに

https://zenn.dev/ttskch/articles/db73d0703f93dc

こちらの記事で、拙作の @ttskch/prettier-plugin-tailwindcss-anywhere というPrettierプラグインをご紹介しました。

このプラグインは、多くのPrettierプラグインとは異なり、コードをパースしてASTを構築するということをしていません。

コード全体の文字列のうち必要な箇所を文字列ベースで加工しているのみなので、実装コード を見ていただくと分かるように、コード量はごくわずかです。

私はこのプラグインを作るまで、Prettierプラグインを作る=対象言語のASTを構築するパーサーを丸ごと実装するのが前提、だと思っていたのですが、

https://www.shufo.dev/posts/created-prettier-plugin-for-blade/

こちらのブログ記事を拝読し、文字列ベースで加工するだけのPrettierプラグインも作れるということを知りました。(大感謝)

しかしPrettierのPluginを実装する際は実はPlugin側がASTを返さなくてもフォーマット後のstringさえ返せれば問題なく、例えば prettier-plugin-elm などはそうなっており、I/O形式さえ理解すれば文字列操作だけでも完結するのでPrettier Plugin作成はそう難しくはない

実際、上記のブログ記事で言及されている

の実装を参考にさせていただき、とても簡単にプラグインを作ることができました。

この記事では、これから同種のPrettierプラグインを作ろうとする方の理解の助けになることを願い、簡単に作り方を解説します。

実装すべきAPIは5つだけ

https://prettier.io/docs/en/plugins#developing-plugins

公式ドキュメントに書かれているとおり、Prettierプラグインの正体は以下の5つのエクスポートを持つピュアJavaScriptオブジェクトです。

  • languages
  • parsers
  • printers
  • options
  • defaultOptions

以下、@ttskch/prettier-plugin-tailwindcss-anywhere の実装コードを掲載しつつ、何がどういう意味なのかの簡単な解説を添えていきます。

今回はTypeScriptで実装したので、掲載しているコードはTypeScript(module: NodeNext)のものとなっています。

languages

index.ts
import type { SupportLanguage } from "prettier";

export const languages: Partial<SupportLanguage>[] = [
  {
    name: "Any HTML-like Languages",
    parsers: ["anywhere"],
  },
];

languages は、プラグインがPrettierに対して提供する言語定義の配列です。SupportLanguage 型のうち nameparsers の2つのプロパティのみが必須です。

nameに は対象の言語の名称を任意の文字列として設定し[1]parsers にはこの言語のパースに使用されたいパーサーの名称を配列で設定します。パーサーの名称は次項の parsers の定義に使用するものと同一である必要があります。

なお、今回は任意の言語を対象に実行可能なプラグインなので省略していますが、多くの場合は特定の拡張子を持つファイルのみを対象とする必要があると思います。その場合は、extensions プロパティで [".html.twig", ".twig"] などのように対象の拡張子を設定します。

parsers

Prettierにおけるパーサーは、与えられたコードの文字列をAST(抽象構文木)に変換する処理です。

ただし、生成したASTに型の制約はまったくなく、任意の構造を持つJavaScriptオブジェクトを返してよい(もっと言うとオブジェクトですらなくプリミティブ値を返しても構いません)ので、整形後のコード全体を文字列として保持する単一のオブジェクトを生成して返す処理を書いてもパーサーとして成立します。

index.ts
import type { Parser } from "prettier";
import { parse } from "./parser.js";
import type { AnywhereNode } from "./types.js";

export const parsers: Record<string, Parser> = {
  anywhere: {
    parse,
    astFormat: "anywhere",
    locStart: (node: AnywhereNode) => node.start,
    locEnd: (node: AnywhereNode) => node.end,
  },
};

ルートのキー anywhere は、languagesparsers に指定した名称と同一である必要があります。

値のほうは、Parser 型のうち parse astFormat locStart locEnd の4つのプロパティが必須です。

parse プロパティにパーサー関数を渡します。ここではモジュール化しているので実際のパーサーの実装については後述します。

astFormat プロパティでは、パーサーによって生成されるASTに任意の名称をつけます。ここでつけた名称は次項の printers の定義に使用するものと同一である必要があります。

locStartlocEnd には、ASTのノードが与えられた場合に、整形後のコードの文字列におけるそのノードの位置を示す整数を返す関数を設定します。

ここでは、以下のようにパーサーが返すノード(整形後のコード全体を文字列として保持する単一のノード)に start end というプロパティを持たせることにし、単にこれらの値を返すだけの関数としてあります。

types.ts
export type AnywhereNode = {
  body: string;
  start: number;
  end: number;
};

また、パーサーの実装は以下のとおりです。

parser.ts
import { type ParserOptions, format } from "prettier";
import * as prettierPluginTailwindcss from "prettier-plugin-tailwindcss";
import type { AnywhereNode } from "./types.js";

export const parse = async (
  text: string,
  options: ParserOptions,
): Promise<AnywhereNode> => {
  let formattedText = text;
  const regex = options.regex as string;

  const matches = text.matchAll(new RegExp(regex, "g"));

  const map = new Map();

  for (const match of matches) {
    const original = match[0];
    const value = match[1];

    const fixedValue = (
      await format(`<div class="${value}"></div>`, {
        parser: "html",
        plugins: [prettierPluginTailwindcss],
      })
    ).match(/class="([^"]*)"/)?.[1];

    const fixed = original.replace(value, fixedValue);

    map.set(original, fixed);
  }

  for (const [original, fixed] of map) {
    formattedText = formattedText.replace(original, fixed);
  }

  return {
    body: formattedText,
    start: 0,
    end: text.length,
  };
};

こちらは本筋ではないので詳細な解説は省きますが、

  • もとのコードを text 引数で受け取る
  • Prettierに対して与えられたオプションを options 引数で受け取る
  • それらを使って文字列の置換によって整形後のコードを生成する
  • AnywhereNode の形で返す

ということを行っています。

最後に return している AnywhereNode のうち、startend が先ほど parserslocStart locEnd で使用したものです。body は次項の printers の定義に使用する printer の実装で独自に利用します。

printers

プリンターは、ASTをPrettier独自の中間表現(「Doc」と呼ばれる)に変換する処理です。

index.ts
import type { Printer } from "prettier";

export const printers: Record<string, Printer> = {
  anywhere: {
    print,
  },
};

ルートのキー anywhere は、parsersastFormat に指定した名称と同一である必要があります。

値のほうは、Printer 型のうち print プロパティのみが必須で、ここにプリンター関数を渡します。ここではモジュール化しています。プリンターの実装は以下のとおりです。

parser.ts
import type { AstPath, Doc } from "prettier";
import type { AnywhereNode } from "./types.js";

export const print = (path: AstPath): Doc => {
  const node: AnywhereNode = path.node;
  return node.body;
};

AstPath という、ASTに対する再帰処理の中で、現在着目しているノードを取得することのできるオブジェクトpath 引数にとり、path.node でノードを取得します。

繰り返しになりますが、今回は整形後のコード全体を文字列として保持する単一のノードしか生成されないため、実際には再帰処理は発生せず、この関数は1回しか実行されません。

AnywhereNode では body プロパティに整形後のコードが文字列として丸ごと入っているので、単にこれを返しておしまいです。

Docの型は type Doc = string | Doc[] | DocCommand; なので、単なる文字列をDocとして返しても問題ありません。

options

options には、プラグイン自身がサポートするカスタムオプションを定義します。

index.ts
import type { SupportOption } from "prettier";

export const options: Record<string, SupportOption> = {
  regex: {
    type: "string",
    category: "Format",
    default: 'class="([^"]*)"',
    description: "regex to match class attribute",
  },
};

ここでは regex オプションを定義しています。typecategory の2つのプロパティのみが必須です。

type の型は以下のとおり、

https://github.com/prettier/prettier/blob/3.3.3/src/index.d.ts#L674-L679

category の型は以下のとおりです[2]。任意の文字列を設定できますが、通常はPrettierのコアで使われているカテゴリ名を使用すればよいようです。

https://github.com/prettier/prettier/blob/3.3.3/src/index.d.ts#L681-L695

defaultOptions

プラグインがPrettierのコアオプションの一部に異なるデフォルト値を必要とする場合は、defaultOptions を使ってそれを指定することができます(公式ドキュメントの例)。

今回は特に必要なかったので空にしています。

index.ts
import type { Options } from "prettier";

export const defaultOptions: Options = {};

おわりに

以上です。これを読んで「これなら自分もこういうプラグイン作ってみようかな」と思う方が出てきたら嬉しいです!

脚注
  1. この設定値がいつどこで使われるのか、調べても分かりませんでした…有識者の方、やさしく教えていただけると嬉しいです。 ↩︎

  2. この設定値がいつどこで使われるのか、調べても分かりませんでした…有識者の方、やさしく教えていただけると嬉しいです。 ↩︎

GitHubで編集を提案

Discussion