Prettierのプラグインとは、具体的にどういうものなのか?を真に理解したく。
ESLintほど、独自プラグインを作る文化もないと思う。
基本デザイン
Plugins · Prettier https://prettier.io/docs/plugins
まず冒頭に書いてある通り。
Plugins are ways of adding new languages or formatting rules to Prettier.
・・・というわけで、基本的には、Prettierがビルトインでサポートしていない拡張子をサポートするための仕組み。
プラグインという言葉から、JS/TSみたくビルトインでサポートされてるファイルをフォーマットする過程に介入できる?と思いがちだが、そうではない。 いや、もちろんやれないことはないと思うけども。
実装を見ると、以下をexportするJSのモジュールがプラグインである、と書いてある。
languages: どういう拡張子に対して利用したいか、どのパーサーを使うかparsers: どういうASTを出力するのか、実際のパース関数printers: どのASTに対応するのか、事前処理、実際のプリント関数options: サポートするオプションのスキーマdefaultOptions: デフォルトオプション
parsersの型で、特に気になるところはこう。
function preprocess(text: string, options: object): string;
function parse(text: string, options: object): Promise<AST> | AST;
printersはこう。
function preprocess(ast: AST, options: Options): AST | Promise<AST>;
function getVisitorKeys(node, nonTraversableKeys: Set<string>): string[];
function print(
// Path to the AST node to print
path: AstPath,
options: object,
// Recursively print a child node
print: (selector?: string | number | Array<string | number> | AstPath) => Doc,
): Doc;
function embed(
// Path to the current AST node
path: AstPath,
// Current options
options: Options,
):
| ((
// Parses and prints the passed text using a different parser.
// You should set `options.parser` to specify which parser to use.
textToDoc: (text: string, options: Options) => Promise<Doc>,
// Prints the current node or its descendant node with the current printer
print: (
selector?: string | number | Array<string | number> | AstPath,
) => Doc,
// The following two arguments are passed for convenience.
// They're the same `path` and `options` that are passed to `embed`.
path: AstPath,
options: Options,
) => Promise<Doc | undefined> | Doc | undefined)
| Doc
| undefined;
そういう意味では、ここにある単位でしかプラグインとして介入できない。
Prettier本体とプラグインの責務の分担としては、
- プラグイン: まず実装する
- 本体: そのプラグインを読み込み、サポートする拡張子リストや使用する実装を更新
- 本体: ファイルを処理するとき、その拡張子を担当するプラグインを選ぶ
- 本体: そのプラグインのパーサーを使う
- プラグイン: そのファイルを
parse()してASTを返す - 本体: パースされたASTを走査しながら、
Docを貯めていく - プラグイン:
AstPath形式で特定のノードが渡されるので、それをPrettierのIRであるDocにして返す - 本体:
Docを文字列に変換して、フォーマット完了
という感じ。
AstPathは、どんなASTでも走査できるようにデザインされた汎用的なクラス。
公開されてるAPIではなくて、内部的にprintAstToDoc()するときに生成される。
各print()は、ASTの該当ノードを含むpath: AstPathを受け取って、Docを返す処理を実装する。
function print(path, options, print) {
const node = path.node;
switch (node.type) {
// ...
return group(["..."]);
}
}
Prettier内部でやってるのと同じで、コールバックとしてのprint()を呼び戻す必要があるところまで一緒。
で、Docを返すためには、Prettier IRの形を知っている必要があるので、そのためのヘルパーとしてビルダー関数たちが公開されてる。
人気プラグイン
https://github.com/wooorm/npm-high-impact/blob/main/lib/top.js
今年の6月のデータなので、少し古いかもしれないが。
- 1st / 3543:
prettier-plugin-tailwindcss - 2nd / 5458:
@trivago/prettier-plugin-sort-imports - 3rd / 5539:
prettier-plugin-organize-imports - 4th / 6522:
prettier-plugin-packagejson - 5th / 6815:
@ianvs/prettier-plugin-sort-imports - 6th / 6822:
prettier-plugin-svelte - 7th / 7626:
prettier-plugin-solidity - 8th / 7656:
prettier-plugin-java - 9th / 7717:
prettier-plugin-jsdoc
Tailwindが人気すぎ・・。
とはいえimportを並べ替えるプラグインが3つもあったりと、まあそのへんか。
人気プラグインの仕組み
- prettier-plugin-astro
- prettier-plugin-svelte
- @ianvs/prettier-plugin-sort-imports
- prettier-plugin-tailwindcss
このあたりがどういう実装になってるかみておく。
prettier-plugin-astro
withastro/prettier-plugin-astro: Prettier plugin for Astro https://github.com/withastro/prettier-plugin-astro
- https://github.com/withastro/prettier-plugin-astro/blob/834519c5185fa4a2b0a93ff03c82b332d8eb99ad/src/index.ts
- シンプルに
.astroファイルに対して、astroパーサーを指定 - パーサーとしては、メインの
astroパーサーと、embed()の実装で使うastroExpressionParserの2つ @astrojs/compiler/syncのparse()を使う
- シンプルに
- https://github.com/withastro/prettier-plugin-astro/blob/834519c5185fa4a2b0a93ff03c82b332d8eb99ad/src/printer/index.ts
- メインのプリンターとしての処理は、HTMLライクな構造を扱うところ
- https://github.com/withastro/prettier-plugin-astro/blob/834519c5185fa4a2b0a93ff03c82b332d8eb99ad/src/printer/embed.ts
- 埋め込みは、
.astro内のfrontmatterや、scriptタグ、属性などのexpression-likeな部分など - JSライクな部分は、Prettierビルトインのパーサーを使うので、Astro ASTとの差分を吸収する処理がある
- もちろん
styleタグも、CSS系のパーサーを使ってtextToDoc()を呼ぶ
- 埋め込みは、
特筆すべきはこれくらい。
Prettierビルトインのパーサーを使ってるのもあって、プラグイン実装としてはそんなに多くない。 お作法に則ってるだけ。
prettier-plugin-svelte
sveltejs/prettier-plugin-svelte: Format your svelte components using prettier. https://github.com/sveltejs/prettier-plugin-svelte
- https://github.com/sveltejs/prettier-plugin-svelte/blob/d509d0fdfe5a6ba679a7efb28583272dc74e7a81/src/index.ts
- Astro同様に、
.svelteファイルにsvelteパーサーを指定 - ただ、
preprocess()で、Svelteコンパイラがscriptとstyleをパースしてしまわないよう、独自の属性値にbase64化して避難させてる- https://github.com/sveltejs/prettier-plugin-svelte/blob/d509d0fdfe5a6ba679a7efb28583272dc74e7a81/src/lib/snipTagContent.ts#L11
<sciprt>...</sciprt>を、<sciprt prettier:content="...">{}</sciprt>にしてる
- Astro同様に、
- https://github.com/sveltejs/prettier-plugin-svelte/blob/d509d0fdfe5a6ba679a7efb28583272dc74e7a81/src/embed.ts#L329
embed()を処理する過程で、base64を文字列に戻して、それをPrettierビルトインでフォーマット
parsersで、svelte(TS)ExpressionParserも指定してるが、こっちはSvelteテンプレ内のexpression-likeな部分で使う- JSでは
babel、TSではbabel-tsを使ってる - しかし
script部は、lang="ts"のように書かれてればtypescriptパーサー、明示がない場合は、jsonかbabel-tsにフォールバック
- JSでは
Astroとだいたい同じかと思って読み進めてたけど、ぜんぜん違ったし、どちらも単にscriptとstyleを埋め込みで処理して終わり、ではない。
とはいえ、プラグインとしてはこれがスタンダードで、他の言語向けのプラグインも同様の構成になってる。 埋め込み言語がないだけ、もっとシンプルなくらい。
@ianvs/prettier-plugin-sort-imports
IanVS/prettier-plugin-sort-imports: An opinionated but flexible prettier plugin to sort import statements https://github.com/ianvs/prettier-plugin-sort-imports
ここからは毛色がまた違う。 新たな拡張子をサポートするものがプラグインなら、既知の拡張子に対する処理へ介入する”メタ”プラグインといったところか。
- https://github.com/IanVS/prettier-plugin-sort-imports/blob/89894c12f0a5645ee03799faac1a5776c89846df/src/index.ts#L85
- というわけで、処理の肝は、
parsersをexportしなおしてるところ - 既存のパーサーすべてをre-exportしつつ、
preprocess()を足してるのがポイント
- というわけで、処理の肝は、
- https://github.com/IanVS/prettier-plugin-sort-imports/blob/89894c12f0a5645ee03799faac1a5776c89846df/src/preprocessors/preprocessor.ts#L16
preprocess()はコード文字列を受け取って、コード文字列を返す- なので、Prettierのメイン処理とは別に、コードをASTにして、ソートして、ASTをコードに戻してるってこと
- ソートのためのASTは、Babelを使ってて、
@babel/generatorでコードに戻してる
.vueは、@vue/compiler-sfcを使って同様の処理<script>と<script setup>のブロックをパースして、中身をpreprocess()して、文字列にして戻す
.astroや.svelteでも、このプラグインが最後に読み込まれている限り、自動的に動作する- ビルトインのパーサーの
preprocess()が拡張されてて、ソート機能を有してることになるから
- ビルトインのパーサーの
元コード > ソート用AST > ソート済AST > ソート済みコード > フォーマット用AST > フォーマット済みコードという流れ。
最大のポイントは、“同じ名前でparsersを登録することで、既存のビルトインの挙動を変える”ってところ!
そのため、最後に読み込ませる必要がある。
prettier-plugin-tailwindcss
tailwindlabs/prettier-plugin-tailwindcss: A Prettier plugin for Tailwind CSS that automatically sorts classes based on our recommended class order. https://github.com/tailwindlabs/prettier-plugin-tailwindcss
これも、いわゆるメタなプラグインで、プラグインの最後に読み込むことを要求してる。 が、そうすると、同様のsort-importsみたいなプラグインと競合することになる。
という問題を解決するために、このプラグインは内部で互換リストを持ってて、それぞれに応じたワークアラウンドを実装として持ってるとのこと・・・。
さて。
- https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/dd02e91c60c7b07dbf22b0fda134db9707899a22/src/index.ts#L24
preprocess()を定義するときに、既存プラグインが拡張したpreprocess()を呼んでくれる- tailwindの並べ替えは、
preprocess()ではなく、ASTに対して行うので、parse()を拡張してるところがメイン - ここでも既存プラグインが拡張したかもしれない
parse()を呼んで、そのASTに対して並べ替えのtransform()を行う - この
transform()は、パーサーごとに定義されてる
- https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/dd02e91c60c7b07dbf22b0fda134db9707899a22/src/index.ts#L658
- JSの場合は
transformJavaScriptがエントリー - ASTを直接いじって並べ替え
- JSの場合は
- https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/dd02e91c60c7b07dbf22b0fda134db9707899a22/src/sorting.ts#L5
- 並べ替えは、
tailwindcss-v(3|4)を使って、内部から巨大なリストをもらってきて、それを使ってる
- 並べ替えは、
Svelteの場合だけ特別な処理フローになってた。
- https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/dd02e91c60c7b07dbf22b0fda134db9707899a22/src/index.ts#L69
- ASTを並べ替えるのではなく、差分を記録しておく
- https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/dd02e91c60c7b07dbf22b0fda134db9707899a22/src/index.ts#L1080
printersを定義して、差分を反映する
npmのダウンロード数ランキングなんかもみてみたけど、これがおそらくPrettierプラグイン界隈の親玉みたいな存在になってる。
まとめ
独自プラグインを作る文化がない理由がおわかりいただけただろうか・・・!
プラグインはともかく、メタプラグインに関しては、もはやハックに近いな〜というのが正直な感想。
oxfmtでどうやってこのあたりを統合するかもずっと考えてるけど、まだ道筋が見えてない。
特にTailwindは需要も多いけど、やってる内容が一番アクロバティックなので・・・。