moonbit で json パーサーを書いてみた 感想

2024/04/16に公開

エアプにならないために、実際に moonbit を使ってコードを書いてみた感想を書く。

https://zenn.dev/mizchi/articles/introduce-moonbit

JSON Parser を書いた

パッケージレジストリである https://mooncakes.io を見た限り、使いやすい json parser がなさそうなので、とりあえず自分用のをでっち上げた。

https://github.com/mizchi/moonbit_json

mooncakes.io に publish してあるので、 moon add mizchi/json で使える。品質が良くなくても ネームスペース付きで publish するので別に邪魔にならない気がした。

なんで作ったかというと、公式 example の cloudflare workers の example は単純なフィボナッチを計算するだけで、構造的なデータを返すことができない。

moonbit と js 間の文字列の受け渡しについては、あとで別の記事を書く。

使い方

fn main {
  let input =
    #| {
    #|  "items": [1],
    #|  "nested": {
    #|    "items": [1, 2, 3, 4.5]
    #|  },
    #|  "items2": [{"a": 1}, {"b": 2}],
    #|  "next": null
    #| }

  let parsed = @json.parse(input).unwrap()
  debug(parsed) // debuggable
  println(parsed.stringify())
  //=> {items:[1],nested:{items:[1,2,3,4.5]},items2:[{a:1},{b:2}],next:null}

  // readable json like JSON.stringify(obj, null, 2)
  println(parsed.stringify(~spaces=2, ~newline=true))

  // build json tree
  let data = @json.JSONValue::Object(
    List::[
      ("key", @json.JSONValue::String("val")),
      ("items", @json.JSONValue::Array(List::[@json.JSONValue::IntNumber(1)])),
    ],
  )
  // you can stringify
  println(data.stringify())

  // handle parse error
  match @json.parse("{}}") {
    Err(err) => debug(err)
    _ => println("unreachable")
  }
}

後々拡張できるように、素朴な構造体でデータを返している。 パフォーマンスチューニングもしてないので、たぶん tokenizer に工夫の余地がある。

次は Serde みたいな Seriarize/Deserialize Trait を作れないか調べてみる。

Moonbit の書き味

  • Pros
    • wasm 自体は低水準なはずなのに、かなり融通が効く
      • TypeScript を Rust の文法で書いてる感覚
    • enum と struct が本当に使いやすい
    • Rust 風の Option | Result 型が便利。 Result スコープの x? もある。だいたい Rust と同じ雰囲気で使える
    • 意外と補完が効く
    • moon test が十分使いやすい
  • Cons
    • unwrap で例外を吐いた時、ランタイムエラーのエラー行が出ないので、どの unwrap なのか調査が大変だった
    • 外部モジュールへの補完が弱くて、本当に使えるメソッドなのか不安になる
      • @vec.Vec::[1,2,3]
    • moon test --filter="..." のようなフィルターがないので、手動でコメントアウトしていた
    • moon test <file> がないので、同じくコメントアウトしていた
    • とにかくドキュメントがないので、標準ライブラリのコードを読みながら書くことになる
    • 文字列操作周りにメソッドが足りないかも
      • str.slice(1, -1) したかった
    • loop 周りの構文が物足りない
      • .iter.map を使うのもいいが、もう少し構文的に組み込まれてほしい。for (const i of items) {} 的なやつ
      • スコープに対する明示的なキャプチャがほしいかも fn[mut counter](){ counter++ }
    • 書いてる途中でコアライブラリの List.nth の返り値が変わって大変だった

バグ: mutable ではない変数を再代入した時に静的解析エラーが出ない。

これがランタイムエラーになる。

let x = 1
x = 2 // No IDE Error

ビルドサイズ周り

mizchi/json で、moon build --wasm-gc でビルドした mizchi/json/main/main.mbt を簡単に計測した。

parse + stringify で 32k, parse だけで 22k。

大きいとは言えないが、小さくもない。データ構造を工夫すると減りそうではあるが、現状調査する手がかりが少ない。
おそらく Rust と同じでジェネリクスの引数のパターンだけコードが展開されているので、そこをなんとかしたい。

プロダクションで Debug トレイトを落とせるといいんだろうか。プロダクションで分岐するフラグがほしい。

moonbit 版 twiggy がほしい。

https://github.com/rustwasm/twiggy

要望: vscode code jump 対応

vscode のターミナルでフルパスをクリックするとコードにジャンプにできるが、テスト結果の行数表示が、vscode のコードジャンプに対応していない。

test mizchi/json/lib/tokenize.mbt::invalid json #5 failed: FAILED:/Users/kotaro.chikuba/sandbox/json/lib/parse.mbt:365:3-372:4 `Err(ExpectToken(4, ["]", ","])) == Err(ExpectToken(3, ["]", ","]))`

ハック: Copliot 用プロンプト

このファイルを usage.mbt として vscode にピン留めしている。 copilot は補完に開いているファイルをみるので、このファイルで Rust との差分を教えている。

/// `.mbt` は rust によく似た moonbit 言語です。
/// moonbit は、以下の特徴を持ちます
/// 
/// - GC はありません。ライフタイムを書く必要がありません。
/// - ジェネリクスは `T<X>` ではなく `T[X]` と記述します
/// - テストは `test { ... }` で記述します。
/// - *_test.mbt はテストファイルとして扱われます
/// - コアライブラリ
///   - Array: Immutable Array
///   - List[T]: Immutable List
///   - @vec.Vec[T]: Mutable List
///   - @map.Map[K, V]: Immutable Map
///   - @hashmap.HashMap[K, V]: Mutable Map
///   - Result[T, E]: だいたい Rust の Result と同じ
///   - Option[T]: だいたい Rust の Option と同じ
///   - @assertion.assert_eq(a, b): テスト用のアサーション
/// - モジュールシステム
///   - 同じディレクトリに存在する `*.mbt` の pub fn を呼び出すことができます
///   - 他のモジュールを使うには moon.pkg.json の import に記述します
///     - 例: { "import": ["mizchi/json/lib"] }
///     - 例: { "import": [{ "path": "mizchi/other/lib", "alias": "other" }] }
///   - 外部モジュールを使うには、moon.pkg.json の deps に記述します

// function
fn add(a : Int, b : Int) -> Int {
  return a + b
}

// generics
fn _self[T](v : T) -> T {
  return v
}

// generics trait
fn _lge[X : Compare](a : X, b : X) -> Bool {
  return a >= b
}

// result unwrap
fn _try_xxx() -> Result[Int, Int] {
  let v : Result[Int, Int] = Ok(1)
  let x = v?
  Ok(x)
}

// data structure
enum T {
  A
  B
}

// complex enum
enum T2[X] {
  A(Int)
  B(X)
} derive(Debug)

struct Point {
  x : Int
  y : Int
} derive(Debug)

fn Point::new(x : Int, y : Int) -> Point {
  return Point::{ x, y }
}

fn distance(self : Point, other : Point) -> Double {
  sqrt((self.x * other.x + self.y * other.y).to_double())
}

// Generics and derived trait
struct Point3d[N] {
  x : N
  y : N
  z : N
} derive(Debug)

// newtype
// type Point3dInt Point3d[Int]

// trait
trait Animal {
  speak(Self) -> Unit
}

struct Duck {
  name : String
}

fn speak(self : Duck) -> Unit {
  let name = self.name
  println("\(name): Quak!")
}

fn _usage_example() -> Unit {
  println("Hello, World!")

  // variable
  let _x = "hello"
  let mut y : Int = 2
  let _multiline_text =
    #| hello
    #| world
    #| multiline

  y = 3
  let p = Point::{ x: 1, y: 2 }
  println(p.distance(Point::{ x: 3, y: 4 }))
  debug(p)

  // function and call
  let _ = add(1, 2)
  // pipeline
  let _ = 1 |> add(2) |> add(4)

  // call ./foo.mbt: pub fn foo() -> Int { 1 }
  // let _ = foo()

  // array and iterator
  let list = [1, p.x, p.y]
  let mapped = list.map(fn(x) -> Int { return x + 1 })
  println(mapped)

  // inference
  let _ : Int = _self(1) // as Int

  // trait
  let duck = Duck::{ name: "Donald" } as Animal
  duck.speak()

  // if else
  if y == 2 {
    println("y is 1")
  } else {
    println("y is not 1")
  }

  // match
  let mut m = T::A
  m = T::B
  let _ = match m {
    T::A => 1
    T::B => 2
  }

  // for
  for i = 1; i < 5; i = i + 1 {
    print(i)
  }
  println("")

  // while
  let mut i = 0
  while i > 0 {
    i = i - 1
  }

  // loop
  let xs = List::[1, 2, 3]
  loop xs {
    Nil => break
    Cons(x, Nil) => {
      println(x)
      continue Nil
    }
    Cons(x, xs) => {
      println(x)
      continue xs
    }
  }
}

// inline test
test {
  let a = 1
  let b = 2
  let c = add(a, b)
  @assertion.assert_eq(c, 3)?
}

おわり

次は Serialize Trait を作るか、パーサコンビネータを作るか、wit からTypeScript とのブリッジを作るか、 RegExp とのバインディングを作る。

もっと開拓していくぞ

Discussion