Great original post: Megaparsec 8

一年が経ち、Megaparsecの新しいメジャーバージョンが再び登場する時がきました。 今回の変更は、これまでのメジャーリリースの中で最も破壊的ではない変更です。 実際、ほとんどのユーザはアップグレードのために何もする必要はないと思います。

これには次の理由があります。

  • それほど多くのissue が開かれておらず、バグも報告されていません。それはMegaparsecが最近、そしてほとんど満足のいく方法で「うまくいく」という事実と関係があると思います。
  • ライブラリは現在幅広く使用されています。この記事の執筆時点で、Megaparsecに直接依存するHackageのパッケージは146個あります。Megaparsecの上に構築することを選択した新しい刺激的なライブラリも現れました。IdrisDhallなどのプロジェクトでは、Megaparsecを使用してパースの問題を解決しています。

これらはライブラリが枯れ、成熟したことを示しているので、動作しているものを壊さないようにしましょう。 とはいえ、常に改善の余地があります。

Nixによる品質保証

バージョン8の作業を開始する前に、Nixを使用して品質保証を強化することにしました。 現在のMegaparsecに依存するプロジェクトの数を把握し、Nixを使用してOrmoluのバグを発見したという成功体験から、依存パッケージによって引き起こされる破壊的変更、パフォーマンスの変更、およびバグのチェックにNixを用いることにしました。

結果をMegaparsecのリポジトリにあるHACKING.mdに文書化しました。 開発時のshellとは別に、Nix expressionは以下のターゲットグループを提供します。

  • baseparser-combinatorshspec-megaparsec などの密接に関連したパッケージとそのテストです。nix-build -A base --no-out-link を実行することにより、開発者はこれらすべてをビルドし、テストすることができます。
  • deps は選択された依存関係のもとでビルドの破壊とテストスイートの失敗が起きないことを確認します。
  • benches はベンチマークのコレクションです。これには、Megaparsecのマイクロベンチマークと、ライブラリが実際のタスクでどのように実行されるかを示すいくつかのパッケージが含まれます。

これらコマンドのそれぞれで、特定のパッケージまたはベンチマークに「ズームイン」できます。 たとえば、nix-build -A benches.parsers-bench を実行して、parsers-bench のベンチマークを確認できます。 要するに、ほとんどのパッケージは新しい変更でも引き続き動作し、修正が容易ではないものでも動作します。 実際、システムを使い続けるためには不備のあるパッケージにパッチを適用する必要があり、そのためのアップグレード用のパッチも利用可能です。

ロジックやパフォーマンスの低下は見つかりませんでした。

パースエラー位置の制御

新機能について話しましょう。 プリミティブ failurefancyFailureparseError に置き換わりました。

parseError :: MonadParsec e s m => ParseError s e -> m a

-- 現在の 'failure' と 'fancyFailure' は普通の関数:

failure
  :: MonadParsec e s m
  => Maybe (ErrorItem (Token s)) -- ^ 期待しないアイテム (あれば)
  -> Set (ErrorItem (Token s)) -- ^ 期待するアイテム
  -> m a
failure us ps = do
  o <- getOffset
  parseError (TrivialError o us ps)

fancyFailure
  :: MonadParsec e s m
  => Set (ErrorFancy e) -- ^ Fancy error components
  -> m a
fancyFailure xs = do
  o <- getOffset
  parseError (FancyError o xs)

これはプリミティブの数を減らすという話ではありません (減らすことも良いことですが)。 parseError の主な特徴は、パーサの状態から現在のオフセットを必要とすることなく、任意のオフセットでパースエラーを報告できることです。 これは、パースエラーにしたい位置を既に超えてしまっている場合でも、入力のその位置でパースエラーを作成したい場合に重要です。 これまでは、まず getOffset を介して正しいオフセットを取得し、次にパースエラーを報告する直前に setOffset でオフセットを設定することでしか達成できませんでした。 これは見苦しいだけでなく、エラーが発生しやすくなり、正しいオフセットの復元を忘れることがあります。 mmark実例を次に示します。

  o' <- getOffset
  setOffset o
  (void . hidden . string) "[]"
  -- ↑ これが失敗した場合、これをオフセット「o」で報告する必要があります
  setOffset (o' + 2)

ここでは完全な状況を説明しませんが、"[]"(+ 2の部分)のパース後にオフセットの増分を考慮するのを忘れたため、このコードにはしばらくバグがあったと言えば十分でしょう。 次のように書けば、同じことをよりうまく表現できます。

  region (setErrorOffset o) $
    (void . hidden . string) "[]"

-- 備考

region :: MonadParsec e s m
  => (ParseError s e -> ParseError s e)
     -- ^ 'ParseError' の処理方法
  -> m a
     -- ^ 処理を適用する「region」
  -> m a

regiongetOffset / setOffset ハックと同じ目的で使用されます。 副作用として、エラーが起きた場合にはパースエラーを更新する関数によって現在のオフセットが変更されます。 regionparseError を使用して、古いハックを廃止できます。

region f m = do
  r <- observing m
  case r of
    Left err -> parseError (f err)
    Right x -> return x

いいね.

マルチエラーパーサのより良いストーリー

プロジェクトの最初期から、マルチエラーパーサをサポートする方向にゆっくりと動いていました。 バージョン7では、ParseErrorの代わりにParseErrorBundleを返すようになりました。 マルチエラーをサポートするための準備はすべてが整っていましたが、複数のパースエラーを報告するための方法がまだ提供されていませんでした。

マルチエラーパーサに求められる事前条件は、入力に問題のある部分をスキップして、正常であることがわかっている位置からパースを再開できることです。 この部分は、withRecoveryプリミティブ(Megaparsec 4.4.0以降で使用可能)を使用して実現されます。

-- | @'withRecovery' r p@ は、パーサー @p@ が失敗した場合でも解析を続行できます。
-- この場合、実際の 'ParseError' を引数とする @r@ が呼び出されます。
-- よくある使い方として、特定のオブジェクトのパースの失敗を意味する値を返すことで、
-- その入力の一部を消費し次のオブジェクトの開始位置に移動します。
--
-- @r@ が失敗すると、元のエラーメッセージが 'withRecovery' なしで報告されることに注意してください。
-- パーサ @r@ を回復してもエラーメッセージに影響することはありません。


withRecovery
  :: (ParseError s e -> m a) -- ^ 失敗の回復方法
  -> m a             -- ^ オリジナルのパーサ
  -> m a             -- ^ 失敗から回復できるパーサ

Megaparsec 8 までのユーザーは、成功と失敗の可能性を含む直和型になるように型aを選択する必要がありました。 たとえば、Either (ParseError s e) Result です。 パースエラーを収集し、後で表示する前に手動でParseErrorBundleに追加する必要がありました。 言うまでもなく、これらはすべて、ユーザーフレンドリーではない高度な使用例です。

Megaparsec 8 は、遅延パースエラーのサポートを追加します。

-- | 後で報告するために 'ParseError'を登録します。
-- このアクションはパースを終了せず、パースの最後に考慮される
-- 「遅延」'ParseError'のコレクションに特定の「ParseError」を
-- 追加する以外は効果がありません。 このコレクションが空の場合のみ、
-- パーサは成功します。 これは、複数のパースエラーを一度に報告する
-- 主な方法です。

registerParseError :: MonadParsec e s m => ParseError s e -> m ()

-- | 'failure'に似ていますが、 遅延'ParseError'のためのものです。

registerFailure
  :: MonadParsec e s m
  => Maybe (ErrorItem (Token s)) -- ^ 期待しないアイテム (あれば)
  -> Set (ErrorItem (Token s)) -- ^ 期待するアイテム
  -> m ()

-- | 'fancyFailure'に似ていますが、 遅延'ParseError'のためのものです。

registerFancyFailure
  :: MonadParsec e s m
  => Set (ErrorFancy e) -- ^ Fancy error components
  -> m ()

これらのエラーは withRecovery のエラー処理コールバックに登録でき、結果の型は Maybe Result になります。 これにより、遅延エラーが最終的な ParseErrorBundle に含まれるようになり、遅延エラーのコレクションが空でない場合に パーサが最終的に失敗するようになります。

以上のことから、マルチエラーパーサを書く習慣がユーザ間でより一般的になることを願っています。

その他

  • いつものように、変更の完全なリストについては、chagelog を参照してください。
  • 公式チュートリアルを含むすべてのテキストをバージョン8と互換性があるように更新しました。新しい機能の使用方法を説明するセクションを含めるように拡張しました。
  • hspec-megaparsec などのサテライトパッケージが更新され、バージョン8で動作するようになりました。