PEG.js(peggy)でzod schemaを解析してみよう

PEGとはパーサージェネレーターです。パーサージェネレーターとはなんやねんというと、文を解析するプログラムを作成するプログラムです。ややこしいですね。
PEGを使用することで、たとえばcsvとかJSONとか一定の構文を持った文字列を解析し、JavaScriptのObjectにすることができます。

PEGのnpmライブラリは更新が途絶しているようなので、peggyという後継のライブラリを使用します。

https://peggyjs.org/


今回、行いたいことは……。

```ts
export const todoListSchema = z.object({
  id: z.number(),
  task: z.string(),
  description: z.string().nullish(),
  due_date: z.date().nullish(),
  created_at: z.date().nullish(),
  updated_at: z.date().nullish(),
});
```

このようなzod schemaを以下のようなJavaScriptのオブジェクトにパースします。

```js
[
   {
      "tableName": "todoListSchema",
      "properties": [
         {
            "name": "id",
            "schema": "z.number()"
         },
         {
            "name": "task",
            "schema": "z.string()"
         },
         {
            "name": "description",
            "schema": "z.string().nullish()"
         },
         {
            "name": "due_date",
            "schema": "z.date().nullish()"
         },
         {
            "name": "created_at",
            "schema": "z.date().nullish()"
         },
         {
            "name": "updated_at",
            "schema": "z.date().nullish()"
         }
      ]
   }
]
```

なんでそんなことをしたいのかはさておき、ひとまずPEGの練習としてちょうどよいのでやっていきましょう。

PEG.jsはオンラインでいろいろ書いて試すことができます。

https://peggyjs.org/online.html

ここでやっていきましょう。

## なにはともあれstart

PEGは、ひとまずstartを定義することからはじまります。中身はあとで考えましょう。

```
start = 
```

## 文字の解析

文字を解析してみましょう。
1文字だけ認識して、そのまま1文字返します。

```pegjs
start = character
character = c:. {return c;}
```
こうすることで、characterとして「あ」が入力されたら”あ”が返ってきます。
まだ1文字しか解析できないので、「ああ」や「あ」＋改行などが入力されるとエラーになります。
=の左辺にあるcharacterというのがルール名です。
これは好きにつけて良いので、charとかにしても良いです。
次に```c:```という部分は変数宣言みたいなものです。cに「.」を束縛しています。
「.」は任意の一文字を表します。
ということで、「.」はなんらかの1文字と合致するので、「あ」と合致するわけですね。
{return c}で、ここでさっきの束縛したcがリターンされています。
これはべつにcではなくaでもbでもなんでも良いのですが、characterのcを今回は使いました。
長々と書きましたが、なんらかの1文字が見つかったら、それをそのまま返す、というだけのことです。

## 文字列の解析
次は「Hello,World」と入力されたら、そのまま「Hello, World」と返ってくるようなものをつくってみましょう。

```
start = text
text = text:$(character*) {return text;}
character = c:. {return c;}
```

characterを使用したtextというのを定義しています。
startがcharacterからtextに変更されていることに注意してください。
小さい定義から少し大きな定義をつくり、それをstartに渡すようにつくっていくのが良いかと思います。

text=の部分はルール宣言、text:は変数宣言ですね。
```*```は0回以上の繰り返しです。
```$()```は()のなかにマッチした文字列全体を取得します。
ということで、characterで1文字取得を*で何回も繰り返して、文字をどんどんパースしていくわけです。
これで「Hello, World」と入力すると「'Hello, World'」と解析できるようになりました。

## key, valueの解析

zodSchemaでもObjectでも良いのですが、key:valueのペアを解析してみましょう。
入力は
```
id: 1
```
としてみましょう。これを
```
{
  key: "id",
  value: "1"
}
```
とパースするのが目標です。

```
start = propertyDefinition
propertyDefinition =  key:text ":"  value:text  {return {key, value}}
text = text:$(character*) {return text;}
character = !(":") c:. {return c;}
```

```:```は特別な構文になるので、characterのときに無視するようにします。
```!(”:”)```とすることで除外できます。
次に、propertyDefinitionを考えます。
key:textの部分は何度も出てきましたが変数宣言ですね。
key + “:” + valueに分解することができました。
startは新しく作成したpropertyDefinitionに変更します。
こうすると、解析結果は以下の通りになります。

```
{
  key: 'id ',
  value: ' 1'
}
```
悪くはないのですが、idのあとの空白、1の前の空白があるので、これを取り除いてみましょう。

空白を定義します。
```
indentation = " "*
```

```“ ”*``` は半角スペース(空白)を*なので0回以上繰り返すことを意味します。
半角スペースが2個でも4個でも対応できるわけですね。

characterに空白が含まれないようにします。
```
character = !(":" / " ") c:. {return c;}
```
!()は除外する構文なのですが、「/」とすることでor条件になります。
よって「:」あるいは「 」のときはcharacterに含みませんよ、ということになります。

あとはpropertyDefinitionを調整する必要があります。
```
propertyDefinition = indentation? key:text indentation?  ":" indentation? value:text  {return {key, value}}
```

空白文字は、あるかもしれないし、ないかもしれません。
そういうときは?とつけることで、あったりなかったりすることを表現できます。
空白かも＋key(文字列)＋空白かも＋「：」＋空白かも＋value(文字列)ということですね。
そして最終的にはkey: valueで返しています。
結果は以下の通りです。

```
{
  key: 'id',
  value: '1'
}
```
## indentについて考える
さて、indentは空白派の人もいますが、tab派の人もいます。
どちらにも対応できるようにindentを改善してみましょう。

```
indentation = [ \t]*
```

ここで```[]```というものが出てきました。これは文字クラスと呼びます。
文字クラスとは、[]のなかにあるいずれか1文字にマッチする、というものです。
たとえば、[abc] は a、b、c のいずれか一文字にマッチします。また、[a-z] のようにハイフンを使用すると、範囲を指定することができます。この場合、a から z までの任意の一文字にマッチします。
\tはタブを意味しています。その前に半角スペースがあります。
よって、半角スペースあるいはタブに合致するもの、それの0回以上の繰り返し、ということになります。
これで無駄にタブがたくさんあっても問題なくパースできるようになりました。

## 複数行の解析に対応する
いまは1行だけしか解析できませんが、複数行に対応できるようにしてみましょう。

入力
```
id: 1,
name: "TARO"
```

末尾のカンマが邪魔なので取り除きましょう。
commaを定義します。

```
comma = ","
```

propertiDefinitionの最後にcommaがきても良いことにします。

```
propertyDefinition = indentation? key:text indentation?  ":" indentation? comma? value:text  {return {key, value}}
```

読み飛ばしを定義したあとは、必ずcharacterの除外に入れるのを忘れないようにしましょう。

```
character = !(":" / " " / comma) c:. {return c;}
```

複数行に対応するには、定義を```*```で0回以上繰り返すだけです。

```
start = lines
lines = properties:propertyDefinition*
```

これでpropertyDefinition列を複数回繰り返すことができました。

全体
```
start = lines
lines = properties:propertyDefinition*
propertyDefinition = indentation? key:text indentation?  ":" indentation? value:text comma?  {return {key, value}}
text = text:$(character*) {return text;}
comma = ","
character = !(":" / " " / comma) c:. {return c;}
indentation = [ \t]*
```

入力
```
id: 1,
age: 2
```

結果
```
[
  {
    key: 'id',
    value: '1'
  },
  {
    key: '\nage',
    value: '2'
  }
]
```

## 改行とクォーテーションに対応する
2個目のkeyに\nという改行コードが入ってしまっています。
lineBreakに対応しましょう。

```
lineBreak = "\n" / "\r\n" / "\r"
character = !(":" / " " / comma / lineBreak) c:. {return c;}
```
また読み飛ばしを定義したのでcharacterに追加しています。

```
propertyDefinition = indentation? key:text indentation?  ":" indentation? value:text comma? lineBreak?  {return {key, value}}
```

カンマの次に改行が来る可能性があるので、lineBreak?としておきます。
これで結果は以下の通りとなります。
```
[
  {
    key: 'id',
    value: '1'
  },
  {
    key: 'age',
    value: '2'
  }
]
```

次に、stringの入力があった場合を考えます。
以下のような入力だったとしましょう。
```
id: 1,
"name": "Taro",
"job": 'none'
```
keyやvalueがシングルクォーテーションやダブルクォーテーションでくくられているかもしれません。これに対応してみます。

```
quote = '"' / "'"
character = !(":" / " " / comma / lineBreak / quote) c:. {return c;}
```

ダブルクォーテーションでシングルクォーテーションを囲み、
シングルクォーテーションでダブルクォーテーションを囲んでいます。
また、読み飛ばしが追加されたのでcharacterを更新しています。

```
propertyDefinition = indentation? quote? key:text quote? indentation?  ":" indentation? quote? value:text quote? comma? lineBreak?  {return {key, value}}
```

quoteが来る可能性があるkeyとvalueの前をquote?で挟み込んでいます。

全体

```
start = lines
lines = properties:propertyDefinition*
propertyDefinition = indentation? quote? key:text quote? indentation?  ":" indentation? quote? value:text quote? comma? lineBreak?  {return {key, value}}
text = text:$(character*) {return text;}
comma = ","
lineBreak = "\n" / "\r\n" / "\r"
quote = '"' / "'"
character = !(":" / " " / comma / lineBreak / quote) c:. {return c;}
indentation = [ \t]*
```

入力
```
id: 1,
"name": "Taro",
"job": 'none'
```

出力
```
[
  {
    key: 'id',
    value: '1'
  },
  {
    key: 'name',
    value: 'Taro'
  },
  {
    key: 'job',
    value: 'none'
  }
]
```
いい感じですね。

## export const ……schema = z.object({を処理する。
key:valueペアは処理できるようになったので、開始の文を処理できるようにしてみましょう。

入力
```
export const aaaSchema = z.object({
```

出力
```
aaaSchema
```

これだけを目指してみましょう。
export ,const, equal, z.object({を読み飛ばします。

```
constKeyword = "const"
exportKeyword = "export"
zodSchemaStart = "z.object({"
zodSchemaHeader = exportKeyword indentation constKeyword indentation name:text indentation "=" indentation zodSchemaStart indentation? lineBreak? {return name}
```
const ,export, z.object({を定義した部分は大丈夫でしょう。
あとはexport, space, const, space, name, space, “=”, space, zodSchemastart, space, lineBreakというように解析していきます。
export const schemaName = z.object({ を解析するように、そのまま素直に書いているだけですね。
そしてreturn nameなのでschema名が返ってくるわけです。

zodSchemaHeaderとpropertyDefinitionを繋げてみましょう。

```
schemaDefinition = header:zodSchemaHeader lineBreak? properties:propertyDefinition* {return {header, properties}}
```

こうすることで、さきほどのheaderを解析したあと、改行後、key:valueのペアの繰り返しを解析していきます。新しい構文は特にないですね。

peggy
```
start = schemaDefinition
schemaDefinition = header:zodSchemaHeader lineBreak? properties:propertyDefinition* {return {header, properties}}

propertyDefinition = indentation? quote? key:text quote? indentation?  ":" indentation? quote? value:text quote? comma? lineBreak?  {return {key, value}}
text = text:$(character*) {return text;}
comma = ","
lineBreak = "\n" / "\r\n" / "\r"
quote = '"' / "'"
character = !(":" / " " / comma / lineBreak / quote) c:. {return c;}
indentation = [ \t]*

constKeyword = "const"
exportKeyword = "export"
zodSchemaStart = "z.object({"
zodSchemaHeader = exportKeyword indentation constKeyword indentation name:text indentation "=" indentation zodSchemaStart indentation? lineBreak? {return name}
```

入力
```
export const aaaSchema = z.object({
id: 1,
name: "TAKEDA"
```

出力
```
{
  header: 'aaaSchema',
  properties: [
    {
      key: 'id',
      value: '1'
    },
    {
      key: 'name',
      value: 'TAKEDA'
    }
  ]
}
```

そろそろいい感じになってきたので、headerとかkey:valueの部分をちょこっと変更します。

peggy
```
schemaDefinition = tableName:zodSchemaHeader lineBreak? properties:propertyDefinition* {return {tableName, properties}}

propertyDefinition = indentation? quote? name:text quote? indentation?  ":" indentation? quote? schema:text quote? comma? lineBreak?  {return {name, schema}}
```

こんな感じでreturnのときの名前を変更しただけです。
header → tableName
key → name, value->schema

## 終了をパースする
z.object({の終わり部分をパースしましょう。

```
endOfLine = "});"

propertyDefinition = indentation? quote? name:text quote? indentation?  ":" indentation? quote? schema:text quote? comma? lineBreak? endOfLine?  {return {name, schema}}
```

最後にendOfLine?を入れておいてあげましょう。

peggy
```
start = schemaDefinition
schemaDefinition = tableName:zodSchemaHeader lineBreak? properties:propertyDefinition* {return {tableName, properties}}

propertyDefinition = indentation? quote? name:text quote? indentation?  ":" indentation? quote? schema:text quote? comma? lineBreak? endOfLine?  {return {name, schema}}
text = text:$(character*) {return text;}
comma = ","
lineBreak = "\n" / "\r\n" / "\r"
quote = '"' / "'"
character = !(":" / " " / comma / lineBreak / quote) c:. {return c;}
indentation = [ \t]*

endOfLine = "});"
constKeyword = "const"
exportKeyword = "export"
zodSchemaStart = "z.object({"
zodSchemaHeader = exportKeyword indentation constKeyword indentation name:text indentation "=" indentation zodSchemaStart indentation? lineBreak? {return name}
```

入力
```
export const aaaSchema = z.object({
  id: 1,
  name: "YAMADA"
});
```

出力
```
{
  tableName: 'aaaSchema',
  properties: [
    {
      name: 'id',
      schema: '1'
    },
    {
      name: 'name',
      schema: 'YAMADA'
    }
  ]
}
```

## 複数のschemaに対応する

入力
```
export const aaaSchema = z.object({
  id: 1,
  name: "YAMADA"
});

export const bbbSchema = z.object({
  id: 2,
  name: "YAMADAMAMA"
});
```
こんな感じの複数のスキーマに対応できるようにします。

空行、schemaではないlineを処理できるようにしてみます。

```
nonSchemaLine = (!schemaDefinition.)+ lineBreak? {return null }
```
これでschemDefinitionではない行になります。
. は任意の一文字を表します。しかし、この . は !schemaDefinition の直後にあるため、schemaDefinition ルールにマッチしない任意の一文字を表します。
ということで、schemaDefinitionに対応しない任意の文字の少なくとも1個の繰り返し、みたいな感じです。
結局のところschemaDefinition以外の列については読み飛ばしてnullにしているということです。
これでconst add = (num1:number, num2:number):number => num1 + num2;みたいな関数も読み飛ばすことができるようになったわけです。

```
start = line:(schemaDefinition / nonSchemaLine)* {return line.filter(x => x !== null)}
```

最後にstartをschemaDefinitionあるいはnonSchemaLineとして、それを0回以上の繰り返しとします。lineという変数に束縛し、あとはlineでnullのものについてはfilterするという通常のJavaScriptで処理して終わりです。

ということで完成形のpeggy
```
start = line:(schemaDefinition / nonSchemaLine)* {return line.filter(x => x !== null)}
nonSchemaLine = (!schemaDefinition.)+ lineBreak? {return null }
schemaDefinition = tableName:zodSchemaHeader lineBreak? properties:propertyDefinition* {return {tableName, properties}}
propertyDefinition = indentation? quote? name:text quote? indentation?  ":" indentation? quote? schema:text quote? comma? lineBreak? endOfLine?  {return {name, schema}}
text = text:$(character*) {return text;}
comma = ","
lineBreak = "\n" / "\r\n" / "\r"
quote = '"' / "'"
character = !(":" / " " / comma / lineBreak / quote) c:. {return c;}
indentation = [ \t]*

endOfLine = "});"
constKeyword = "const"
exportKeyword = "export"
zodSchemaStart = "z.object({"
zodSchemaHeader = exportKeyword indentation constKeyword indentation name:text indentation "=" indentation zodSchemaStart indentation? lineBreak? {return name}
```

ひとつずつ処理を書き進めてみました。あとは、ここで学んだことの組み合わせでなんとかなるでしょう。なんとなく、こんな感じで進めていけば解析を進めていける、という雰囲気はつかめたでしょうか……。

実はいろいろ非対応の部分があり、コメントアウトが処理できていなかったりします。このあたりは//とか/**/とかのルールを追加していけば処理できるようになるでしょう。きっと。