TypeScriptでDSLを解析する
今回はJavaScriptの字句解析ツールmooとパーサーツールキットnearleyを使い,TypeScriptでドメイン固有言語 (DSL) の解析を行います.
背景
テキストで作曲する手法 (MML) では,音色の定義もテキストで行います.例えば以下のように数値を記述することで,音色パラメーターが設定されます.
'@ FA 0 ; Flute
'@ 31, 0, 0, 0, 0,64, 0, 0, 0, 0
'@ 31, 0, 0, 0, 0,64, 0, 0, 0, 0
'@ 31,14, 8,12, 3,28, 0, 2, 3, 0
'@ 18, 8, 8,10, 4, 0, 0, 1, 0, 0
'@ 4, 0
今開発しているWebサービスに,このようなテキストデータを解析して音色データをオブジェクトとして出力する処理を実装しようとしました.
C言語ではflexとyaccの組み合わせでパーサーを作ったりしますが,今回はWebブラウザー上で解析を行いたいため,TypeScriptで動作するライブラリーを探しました.いくつか候補があった中で,導入がしやすそうだったmooとnearleyを組み合わせた方法を採りました.
pnpm add nearley moo
DSL解析のステップ
DSLの解析は以下のステップで行われます.
- 字句解析: 文字列をトークンと呼ばれる文字のまとまりに分割する.
- 構文解析: 文法規則に基づいてトークンの関係を構文木として構造化する.
- 意味解析: 型や値の整合性など文の構造の意味的な妥当性を確認する.
- データ生成: 解析した文に応じてオブジェクトなどのデータ生成を行う.
今回の場合,各ステップの処理の担当は以下のようになります.
- 字句解析: moo
- 構文解析: nearley
- 意味解析・データ生成: 自作のTypeScriptの処理
BNFによるトークンと文法の定義
BNF(バッカス・ナウア記法)は文法を表現する記法です.文法ルールを定義し,複数のルールを組み合わせることで文全体の構造を定義します.
先の例の音色定義の文法は拡張BNF (EBNF) 風に表現すると以下のようになります.
instrument = header_line eol operator_line eol operator_line eol operator_line eol operator_line eol al_fb_line
header_line = line_head space id spaced_number space comment?
operator_line = line_head operator_params10 space comment?
al_fb_line = line_head al_fb_params space comment?
operator_params10 = spaced_number comma spaced_number comma spaced_number comma spaced_number comma spaced_number comma spaced_number comma spaced_number comma spaced_number comma spaced_number comma spaced_number
al_fb_params = spaced_number comma spaced_number
line_head = "'@"
type = "FA"
comma = ","
spaced_number = space number
comment = ";" character*
space = (" " | "\t")*
number = digit+
character = "\w"
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
eol = "\n" | "\r" | "\r\n"
moo + nearleyでルールを記述
このEBNFをもとにmooとnearleyでルールを記述します.ルールは拡張子が.neのファイルに書いていきます.今回はgrammar.neファイルを準備しました.
@{%
const moo = require("moo");
const lexer = moo.compile({
space: /[ \t]+/,
comment: /;[^\n]*/,
comma: ",",
number: /[0-9]+/,
line_head: /'@/,
format_type: /FA/,
eol: { match: /\r?\n/, lineBreaks: true },
});
function parseBool(n) { return n === 1; }
%}
@lexer lexer
# 戻り値の型
# type Op = {
# ar: number;
# dr: number;
# sr: number;
# rr: number;
# sl: number;
# tl: number;
# ks: number;
# ml: number;
# dt: number;
# am: boolean;
# ssgEg: number;
# };
#
# type Return = {
# al: number;
# fb: number;
# op: [Op, Op, Op, Op];
# lfoFreq: number;
# ams: number;
# pms: number;
# };
instrument ->
header_line %eol operator_line %eol operator_line %eol operator_line %eol operator_line %eol al_fb_line {%
d => ({
op: [d[2], d[4], d[6], d[8]],
al: d[10].al,
fb: d[10].fb,
lfoFreq: 0,
ams: 0,
pms: 0
})
%}
header_line -> %line_head %space:? %format_type %space:? number %space:? %comment:? {% d => null %}
operator_line -> %line_head %space:? operator_params10 %space:? %comment:? {%
d => {
const [ar, dr, sr, rr, sl, tl, ks, ml, dt, am] = d[2];
return { ar, dr, sr, rr, sl, tl, ks, ml, dt, am: parseBool(am), ssgEg: 0 };
}
%}
al_fb_line -> %line_head %space:? al_fb_params %space:? %comment:? {%
d => ({ al: d[2][0], fb: d[2][1] })
%}
operator_params10 ->
number comma number comma number comma number comma number comma number comma number comma number comma number comma number {%
d => {
const numbers = [];
for (let i = 0; i < 10; i++) {
numbers.push(d[i * 2]);
}
return numbers;
}
%}
al_fb_params -> number comma number {%
d => [d[0], d[2]]
%}
comma -> %space:? %comma %space:?
number -> %number {% d => parseInt(d, 10) %}
最初の@{% %}
で囲まれた部分でmooによるトークン定義を行っています.このトークン定義は先ほどのEBNFによるものを少し簡略化しています.
以降の部分ではルール名 -> パターン
の記法で文法を記述します.パターン部分でほかのルールを使用するときはそのルール名を記述します.またmooで定義済みのトークンを使用する場合はトークン名の先頭に%
をつけて記述します.
パターン部分の後には{% %}
で囲んだブロック内にルールにマッチしたサブパターンに対してその値を変換して返す処理を記述できます.例えばルールnumber
ではマッチした文字列を数値に変換しています.そのほかのルールではサブパターンでマッチした値を新たなオブジェクトにまとめて返しています.
なおnearleyはあいまいな文法に対応しています.あいまいな文法とは1つの文で複数の解釈ができる文法のことです.例えば"1 + 2 - 3"は"(1 + 2) - 3"とも"1 + (2 - 3)"とも解釈できます.nearleyはアーリー法という解析手法をベースにしてあいまいな文法を扱います.
ルールのコンパイル
nearleyに用意されているnearleycを使い,作成した.neファイルからnearleyのパーサーで利用する文法オブジェクトをコンパイルします.
pnpm nearleyc grammar.ne -o grammar.cjs
コマンドを実行するとファイルgrammar.cjsが生成されます.このファイルではコンパイルした文法オブジェクトが定義されています.
パーサーの利用
TypeScript側ではnearleyと先ほど生成したgrammar.cjsを利用します.
import nearley from "nearley";
import grammar from "./grammar.cjs";
const parser = new nearley.Parser(nearley.Grammar.fromCompiled(grammar));
// 解析対象のテキスト
const text = `'@ FA 0
'@ 31,8,0,7,12,20,0,2,7,0
'@ 31,5,2,7,6,4,0,2,3,0
'@ 31,7,4,7,10,18,0,2,3,0
'@ 31,0,4,7,0,0,0,2,0,0
'@ 4,5`;
try {
parser.feed(text); // 解析
const results = parser.results;
if (results.length === 0) {
console.error("マッチしませんでした");
} else {
// 1つ目のマッチ候補を取得
const instrument = results[0];
// ...
}
} catch (err) {
console.error("マッチしませんでした");
}
nearley.Parser
にgrammar.cjsでエクスポートされている文法オブジェクトを渡してパーサーをセットアップします.その後パーサーのfeed
メソッドで解析を行い,results
プロパティで解析結果を取得します.
results
が配列になっているのは,nearleyがあいまいな文法を許容しており,複数のパターンにマッチする可能性があるためです.今回は合致するパターンが1つのみなので,results[0]
で解析結果のデータを取得しています.
便利なところ・つらいところ
個人的に便利と感じたのは以下の点でした.
- BNFを知っていればほぼ同じ記法のnearleyのDSLで文法を記述できる.
- .neファイルをコンパイルしたオブジェクトをパーサーに渡すだけで文字列の解析ができる.
逆につらいと感じたところは以下の点でした.
- nearleyのルールの記法のドキュメントがまとまっておらず,他の利用例を確認しないとどう書けばいいかわからない.
- mooのトークンに
(空文字)を定義できないため,\varepsilon *
や?
を使うとエラーが発生することがある.- いちおうnearleyのルール側でトークン末尾に
:*
や:?
をつけて対応できる.
- いちおうnearleyのルール側でトークン末尾に
まとめ
この記事ではmooとnearleyを使ってTypeScriptでDSLの字句解析・構文解析を行いました.BNFに近い記法で文法を記述することで,DSLのパーサーを作成することができました.
TypeScriptで字句解析・構文解析を行っている記事をZennであまり見かけなかったので書いてみました.私も情報を調べながら記事にしていったので,BNFからmoo,nearleyの記法へ書き換えがうまくいかず四苦八苦していました.上にあげた.neファイルの例もあまりきれいに書けていないので,パーサーをきちんと設計するには形式言語関連の知識を学んでおいたほうがよさそうです.
今回はmoo + nearleyの組み合わせを試しましたが,ほかにもChevrotainを利用する方法もあります.こちらはLL法による解析なのでnearleyとは違いあいまいさのない文法で記述する必要がありますが,文法をDSLではなくTypeScriptのオブジェクトとして表現することが可能です.場合によってはこちらを採用するのがいいかもしれません.
Discussion