用 effect handler 實作 parser combinator [VSMS]

這些程式碼都是使用 OCaml 實現,並使用了 algeff 與 asai 程式庫

解析器需要完成的任務就是根據規則把一系列 tokens 變成文法樹,並且回報為何解析失敗,一般大學作業等級的編譯器都會讓學生直接使用解析器生成器,如 menhir 這種工具。然而實際上開發真實語言的解析器時,往往會遇到需要錯誤恢復並繼續解析,最後再回報多個錯誤的要求,這時候生成器往往給出很差的結果,甚至乾脆就是做不到的。

然而,手工編寫的解析器通常可維護性相當差劣。一種折衷的方案是所謂的解析器組合子,我過去也曾經介紹過如何利用組合子抽象掉重複的解析規則。但這種方案有個問題,就是回溯必須手動使用 try 組合子來在失敗時恢復狀態,但實務上這創造了相當難以理解的各種 try 安插,當我意識到這是因為 API 需要是兩階段的問題時,我就發現其實 effect handler 正是解決這個問題的好方法。

我本來是直接使用 Effect.Deep 的,但後來發現這種情境直接用 algeff 就足夠了,因此改採這個方案。實際程式碼如下

module Tokens = struct
  type t = Lexer.token Asai.Range.located list
end
module TokenState = Algaeff.State.Make (Tokens)

首先,我假設了 Lexer.token 這個型別存在,並且使用者會輸入一個標記過位置的 token list。接著建立一個狀態模組,這個模組的只有三個用法:

let next_token () =
  match TokenState.get () with
  | [ eof ] -> eof
  | tok :: buf ->
      TokenState.set buf;
      tok
  | [] -> raise Impossible

let shift pos = TokenState.set pos
let current_position () = TokenState.get ()
這段程式巧妙的使用了 OCaml 的不可變 list 的特性,也就是持頭就可以保證資料不被回收

並且提供啟動函數

let run (init : Lexer.token Asai.Range.located list) (f : unit -> 'a) : 'a =
  TokenState.run ~init @@ fun () -> f ()

輔助函數 Asai 的例外捕捉 [local-0]

let catch_parse_error (p : unit -> 'a) : 'a option =
  let pos = current_position () in
  Reporter.try_with
    ~fatal:(fun d ->
      match d.message with
      | Parse_error ->
          shift pos;
          None
      | _ -> Reporter.fatal_diagnostic d)
    (fun () -> Some (p ()))

其中 Parse_error 跟 Reporter 都是使用者自訂的錯誤回報模組內容,如何定義可以參考 asai 的文件。這段輔助函數之所以出現只是為了讓讀者知道這個函數的存在,這個函數只捕捉解析失敗,讓其他錯誤繼續往上走。

常見的組合子 [OQKM]

直接消耗一個符合預測的 token consume [local-0]

let consume (predict : Lexer.token) : unit =
  let tok = next_token () in
  if tok.value == predict then ()
  else
    (* raise ... *)

重複解析到失敗為止 many [local-1]

let rec many (p : unit -> 'a) () : 'a list =
  let x = catch_parse_error p in
  match x with
  | None -> []
  | Some x -> x :: many p ()

若失敗就解析第二個規則 [local-2]

let ( <|> ) (p1 : unit -> 'a) (p2 : unit -> 'a) () : 'a =
  match catch_parse_error p1 with
  | None -> p2 ()
  | Some x -> x

此外也有更複雜的錯誤恢復機制

如果最上層的定義解析失敗就紀錄失敗訊息並跳到下一個 start token 繼續解析 [local-1]

let rec program () =
  let x = catch_parse_error top in
  match x with
  | Some x -> x :: program ()
  | None ->
      let pos = next_start_token () in
      shift pos;
      program ()