パーサーコンビネーターを使って自然言語風のテキストからパラメーターを抽出する

CLI と違い、引数を渡す方法が標準化しておらず、パースを実装する必要があった。Slack の /remind スラッシュコマンドのように、自然言語風に Bot へのパラメーターを抽出したい。

/remind me to drink water at 3pm every day
/remind me on June 1st to wish Linda happy birthday
/remind #team-alpha to update the project status every Monday at 9am
/remind @jessica about the interview in 3 hours

正規表現を使うと複雑になりがちな上のようなパラメーター抽出のため、パーサーコンビネーターを使って、パーサーを実装してみる。

実装したいこと

例えば、CircleCI の特定のジョブを Bot を通じて Slack から起動するために、自然言語風のテキストからパラメーターを抽出したい。

post build to (username)/(reponame) [on (ブランチ名)] [at (コミットハッシュ)] [for (ジョブ名)]

パラメーター抽出例

post build to namikingsoft/namikingsoft.github.io on master for deploy
{
  "repo": "namikingsoft/namikingsoft.github.io",
  "branch": "master",
  "job": "deploy"
}

実装してみる

上の要件を実装したコード例と軽い解説。

00. ソースコード全体

const P = require('parsimmon');
const R = require('ramda');

// map functions
const mapToThirdArg = (_1, _2, _3) => _3;
const mapToRepo = (_1, _2, _3, _4, _5) => `${_1}/${_5}`;
const reduceNodeToObj = R.reduce((acc, x) => R.merge({ [x.name]: x.value })(acc), {});
const transForSentence = R.pipe(mapToThirdArg, reduceNodeToObj);

// atoms
const _ = P.whitespace;
const _o = P.optWhitespace;
const to = P.string('to');
const on = P.string('on');
const at = P.string('at');
const fr = P.string('for'); // TODO: alt `for`
const slash = P.string('/');
const command = P.regex(/post +build/i);
const digit = P.digit;
const letterSmall = P.range('a', 'z');
const letterLarge = P.range('A', 'Z');
const letter = P.alt(letterSmall, letterLarge);
const hex = P.alt(P.range('a', 'f'), digit);
const symbolForSep = P.oneOf('._-');
const symbolForBranch = P.oneOf('._-/#+');

// parameters
const username = P.alt(letter, digit, symbolForSep).many().tie();
const reponame = P.alt(letter, digit, symbolForSep).many().tie();
const branch = P.alt(letter, digit, symbolForBranch).many().tie();
const job = P.alt(letter, digit, symbolForSep).many().tie();
const repo = P.seqMap(username, _o, slash, _o, reponame, mapToRepo);
const revision = hex.many().tie();

// nodes
const nodeRepo = P.seqMap(to, _, repo, mapToThirdArg).node('repo');
const nodeJob = P.seqMap(fr, _, job, mapToThirdArg).node('job');
const nodeBranch = P.seqMap(on, _, branch, mapToThirdArg).node('branch');
const nodeRevision = P.seqMap(at, _, revision, mapToThirdArg).node('revision');
const node = P.alt(nodeRepo, nodeJob, nodeBranch, nodeRevision);
const sentence = P.seqMap(command, _, node.sepBy(_), transForSentence);

// parse
const text1 =
  'post build to namikingsoft/namikingsoft.github.io on master at abcd1234 for deploy';
const text2 =
  'pOSt  BuilD   for   deploy on   master to  namikingsoft  /  namikingsoft.github.io';

sentence.tryParse(text1);
// {
//   "job": "deploy",
//   "revision": "abcd1234",
//   "branch": "master",
//   "repo":"namikingsoft/namikingsoft.github.io"
// }

sentence.tryParse(text2);
// {
//   "job": "deploy",
//   "branch": "master",
//   "repo":"namikingsoft/namikingsoft.github.io"
// }

sentence.tryParse('illegal text example');
// -> Exception!

RunKit でコードを実行する
https://runkit.com/namikingsoft/parse-text-for-bot-using-parsimmon

01. 使っている npm モジュール

const P = require('parsimmon');
const R = require('ramda');
Parsimmon - パーサーコンビネーターライブラリ

JS のパーサーコンビネーターライブラリの1つ。Haskell の Parserc ライクに使える。

GitHub: jneen/parsimmon
https://github.com/jneen/parsimmon

Ramda - 関数型プログラミング支援ライブラリ

関数型プログラミングライブラリの1つ。今回はパーサーの戻り値調整のみに使った。

Ramda Documentation
http://ramdajs.com/

02. 字句の定義

入力文を構成する要素を BNF のような感覚で字句の定義を行っていく。

// atoms
const _ = P.whitespace;
const _o = P.optWhitespace;
const to = P.string('to');
const on = P.string('on');
const at = P.string('at');
const fr = P.string('for'); // TODO: alt `for`
const slash = P.string('/');
const command = P.regex(/post +build/i);
const digit = P.digit;
const letterSmall = P.range('a', 'z');
const letterLarge = P.range('A', 'Z');
const letter = P.alt(letterSmall, letterLarge);
const hex = P.alt(P.range('a', 'f'), digit);
const symbolForSep = P.oneOf('._-');
const symbolForBranch = P.oneOf('._-/#+');

// parameters
const username = P.alt(letter, digit, symbolForSep).many().tie();
const reponame = P.alt(letter, digit, symbolForSep).many().tie();
const branch = P.alt(letter, digit, symbolForBranch).many().tie();
const job = P.alt(letter, digit, symbolForSep).many().tie();
const repo = P.seqMap(username, _o, slash, _o, reponame, mapToRepo);
const revision = hex.many().tie();

03. パーサーの構築

パーサーを構成する字句を組み合わせたり、関数出力の調整を行う。ノード定義は alt を使っても、どの要素が該当したか識別できるするために node で名前をつけていくイメージ。

// map functions
const mapToThirdArg = (_1, _2, _3) => _3;
const mapToRepo = (_1, _2, _3, _4, _5) => `${_1}/${_5}`;
const reduceNodeToObj = R.reduce((acc, x) => R.merge({ [x.name]: x.value })(acc), {});
const transForSentence = R.pipe(mapToThirdArg, reduceNodeToObj);

// nodes
const nodeRepo = P.seqMap(to, _, repo, mapToThirdArg).node('repo');
const nodeJob = P.seqMap(fr, _, job, mapToThirdArg).node('job');
const nodeBranch = P.seqMap(on, _, branch, mapToThirdArg).node('branch');
const nodeRevision = P.seqMap(at, _, revision, mapToThirdArg).node('revision');
const node = P.alt(nodeRepo, nodeJob, nodeBranch, nodeRevision);
const sentence = P.seqMap(command, _, node.sepBy(_), transForSentence);

04. パーサーを使う

定義した構文に沿ったテキストが入力された場合は、ノードの名前をキーとしたオブジェクトとして返され、そうでない場合は例外が発生する。

// parse
const text1 =
  'post build to namikingsoft/namikingsoft.github.io on master at abcd1234 for deploy';
const text2 =
  'pOSt  BuilD   for   deploy on   master to  namikingsoft  /  namikingsoft.github.io';

sentence.tryParse(text1);
// {
//   "job": "deploy",
//   "revision": "abcd1234",
//   "branch": "master",
//   "repo":"namikingsoft/namikingsoft.github.io"
// }

sentence.tryParse(text2);
// {
//   "job": "deploy",
//   "branch": "master",
//   "repo":"namikingsoft/namikingsoft.github.io"
// }

sentence.tryParse('illegal text example');
// -> Exception!

まとめ

頑張れば正規表現で書けなくもなさそうなテキストパースを、パーサーコンビネーターで書いてみて、思ったこといくつか。

各要素に名前を付けられる & 組み合わせることができる

正規表現で複雑なパーサーを書くと、意味不明な文字列の羅列になりやすく、リーダブルに書くことが難しいが、字句レベルから変数にできて、再利用もしやすい点が良いと感じた。

Parsimmon ドキュメントが Parsec より簡潔でわかりやすい

Haskell の Parsec でパーサーを実装していたときは、Hackage を見ても、ググっても、いまいち使い方がわからず、入門用にまとまっているドキュメントを探すのに苦労したが、Parsimmon ドキュメントはコード例とともに簡潔にまとまっていて、実装がしやすかった。Parsec の練習用にも良いかもしれない。

Examples には、軽量スクリプト言語のパーサーや JS Linter の実装例もあったので、より複雑なパーサーを構築したくなったときに参照したい。

comments powered by Disqus

この記事について

書いた人
Written by

namikingsoft

何かを残して逝きたい
フロントエンドエンジニア