AST(抽象構文木)を構築せず文字列ベースの加工だけを行うPrettierプラグインの作り方
はじめに
こちらの記事で、拙作の @ttskch/prettier-plugin-tailwindcss-anywhere というPrettierプラグインをご紹介しました。
このプラグインは、多くのPrettierプラグインとは異なり、コードをパースしてASTを構築するということをしていません。
コード全体の文字列のうち必要な箇所を文字列ベースで加工しているのみなので、実装コード を見ていただくと分かるように、コード量はごくわずかです。
私はこのプラグインを作るまで、Prettierプラグインを作る=対象言語のASTを構築するパーサーを丸ごと実装するのが前提、だと思っていたのですが、
こちらのブログ記事を拝読し、文字列ベースで加工するだけのPrettierプラグインも作れるということを知りました。(大感謝)
しかしPrettierのPluginを実装する際は実はPlugin側がASTを返さなくてもフォーマット後のstringさえ返せれば問題なく、例えば prettier-plugin-elm などはそうなっており、I/O形式さえ理解すれば文字列操作だけでも完結するのでPrettier Plugin作成はそう難しくはない
実際、上記のブログ記事で言及されている
の実装を参考にさせていただき、とても簡単にプラグインを作ることができました。
この記事では、これから同種のPrettierプラグインを作ろうとする方の理解の助けになることを願い、簡単に作り方を解説します。
実装すべきAPIは5つだけ
公式ドキュメントに書かれているとおり、Prettierプラグインの正体は以下の5つのエクスポートを持つピュアJavaScriptオブジェクトです。
languages
parsers
printers
options
defaultOptions
以下、@ttskch/prettier-plugin-tailwindcss-anywhere
の実装コードを掲載しつつ、何がどういう意味なのかの簡単な解説を添えていきます。
今回はTypeScriptで実装したので、掲載しているコードはTypeScript(
module: NodeNext
)のものとなっています。
languages
import type { SupportLanguage } from "prettier";
export const languages: Partial<SupportLanguage>[] = [
{
name: "Any HTML-like Languages",
parsers: ["anywhere"],
},
];
languages
は、プラグインがPrettierに対して提供する言語定義の配列です。SupportLanguage
型のうち name
と parsers
の2つのプロパティのみが必須です。
name
に は対象の言語の名称を任意の文字列として設定し[1]、parsers
にはこの言語のパースに使用されたいパーサーの名称を配列で設定します。パーサーの名称は次項の parsers
の定義に使用するものと同一である必要があります。
なお、今回は任意の言語を対象に実行可能なプラグインなので省略していますが、多くの場合は特定の拡張子を持つファイルのみを対象とする必要があると思います。その場合は、extensions
プロパティで [".html.twig", ".twig"]
などのように対象の拡張子を設定します。
parsers
Prettierにおけるパーサーは、与えられたコードの文字列をAST(抽象構文木)に変換する処理です。
ただし、生成したASTに型の制約はまったくなく、任意の構造を持つJavaScriptオブジェクトを返してよい(もっと言うとオブジェクトですらなくプリミティブ値を返しても構いません)ので、整形後のコード全体を文字列として保持する単一のオブジェクトを生成して返す処理を書いてもパーサーとして成立します。
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
は、languages
で parsers
に指定した名称と同一である必要があります。
値のほうは、Parser
型のうち parse
astFormat
locStart
locEnd
の4つのプロパティが必須です。
parse
プロパティにパーサー関数を渡します。ここではモジュール化しているので実際のパーサーの実装については後述します。
astFormat
プロパティでは、パーサーによって生成されるASTに任意の名称をつけます。ここでつけた名称は次項の printers
の定義に使用するものと同一である必要があります。
locStart
と locEnd
には、ASTのノードが与えられた場合に、整形後のコードの文字列におけるそのノードの位置を示す整数を返す関数を設定します。
ここでは、以下のようにパーサーが返すノード(整形後のコード全体を文字列として保持する単一のノード)に start
end
というプロパティを持たせることにし、単にこれらの値を返すだけの関数としてあります。
export type AnywhereNode = {
body: string;
start: number;
end: number;
};
また、パーサーの実装は以下のとおりです。
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
のうち、start
と end
が先ほど parsers
の locStart
locEnd
で使用したものです。body
は次項の printers
の定義に使用する printer
の実装で独自に利用します。
printers
プリンターは、ASTをPrettier独自の中間表現(「Doc」と呼ばれる)に変換する処理です。
import type { Printer } from "prettier";
export const printers: Record<string, Printer> = {
anywhere: {
print,
},
};
ルートのキー anywhere
は、parsers
で astFormat
に指定した名称と同一である必要があります。
値のほうは、Printer
型のうち print
プロパティのみが必須で、ここにプリンター関数を渡します。ここではモジュール化しています。プリンターの実装は以下のとおりです。
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
には、プラグイン自身がサポートするカスタムオプションを定義します。
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
オプションを定義しています。type
と category
の2つのプロパティのみが必須です。
type
の型は以下のとおり、
category
の型は以下のとおりです[2]。任意の文字列を設定できますが、通常はPrettierのコアで使われているカテゴリ名を使用すればよいようです。
defaultOptions
プラグインがPrettierのコアオプションの一部に異なるデフォルト値を必要とする場合は、defaultOptions
を使ってそれを指定することができます(公式ドキュメントの例)。
今回は特に必要なかったので空にしています。
import type { Options } from "prettier";
export const defaultOptions: Options = {};
おわりに
以上です。これを読んで「これなら自分もこういうプラグイン作ってみようかな」と思う方が出てきたら嬉しいです!
Discussion