🧞

Scala 3 マクロを Markdown で動かす

に公開

Scala 3 マクロのサンプルプログラムを動作確認したい時に Scala CLI の Markdown サポートを使うと捗ったので紹介したい。

どうゆうことよ

要点は次のとおり。

  • Scala CLI を使ってコンパイル・実行する。(あるいは Scala 3.5.0 以上の scala コマンド)
  • ソースファイル形式として Markdown (*.md)を使う。scala プログラムはマークダウンのコードブロックに記述する。
  • マクロを記述したコードブロックには raw を指定する。1ファイルにマクロとその呼び出し側を併記できるようになる。
  • コンパイルサーバーを使わない(--server=false)。scala-cli コマンドのプロセスで直接コンパイルすることで、マクロコンパイル時のデバッグログをコンソールに出せる。

例えば以下のようなマークダウン文書を hello_macro.md として保存する。

## マクロサンプル

何もしないマクロ
```scala raw
//> using scala 3.3.5
import scala.quoted.*

object MyMacro:
    inline def hello(s: String): String =
        ${ helloImpl('s) }
    private def helloImpl(x: Expr[String])(using Quotes): Expr[String] =
        println("Hello, " + $x.show)
        x
```
マクロは raw 指定のコードブロックに記述する。
マクロ実装は静的関数である必要があるため、object か package に持たせる必要がある。

## 動作確認

マクロの呼び出し
```scala
MyMacro.hello("Macro")
```

これを scala-cli コマンドで実行する。

$ scala-cli --server=false hello_macro.md
Hello, "Macro"

このメッセージ出力はコンパイル時のもの。

Scala CLI × Markdown を使ってみての所感をまとめると以下のようなところか。

  • うれしみ

    • マクロとその呼び出しを一つのファイルに記述できる
    • マクロコンパイル時のデバッグログを確認できる
    • ソース更新なしでもマクロからのコンパイルが走る
    • 文書が主体(地)になるので、学習ノートやメモを残すときにコメント内よりも読みやすい
      また、文書としてそのまま Zenn などに公開できる
    • 設定ファイルやプロジェクト構成の管理が不要
  • つらみ

    • エディタのコード補完や参照ジャンプが効かない
      (VSCode がダメで IntelliJ ならある程度有効、Scala API ソースには飛べない)
    • ブレークポイントを設定できない
    • IDE から実行できない(ターミナルから実行)

学習用のサンプルプログラムの動作確認程度のコードならこれで十分。

以降、解説。

Scala 3 マクロ学習のあるある

Scala の学習は色々と敷居が高い。

それでも scala worksheet(*.sc)[1] を使うと、ちょっとした文法の検証や API の挙動の動作確認が気軽にできて便利だ。得た知識をコメントとしてノート的にも残せるので重宝している。

これが Scala 3 の マクロ のお勉強となると、使えない。

マクロと動作確認のプログラムは 1 ファイルに書けないからだ。マクロの実装とその呼び出しコードは別々のコンパイル単位(=ソースファイル)になっている必要がある。ちょっとしたサンプルコードでも複数ファイルに別れるとなると、学習ノートとしての整理や後の読み返しに面倒だ。

もちろん腰を据えて、IntelliJ や VSCode でお勉強用プロジェクトを構成し、両者の .scala の配置を綺麗に整頓すれば済むことだ。それでも、それらの環境でマクロのお勉強を進めると次のような不都合があった。

  • マクロの修正がすぐに反映されないことがある。 特に IntelliJ では、呼び出し側のソースも毎度再コンパイルする必要がある。
  • コンパイル時のデバッグ出力を確認できない。 マクロ内の println()report.info() による出力はコンソールウィンドウに出てこない。

どこかの設定でなんとかなるのかもしれないけど、よくわからない。

これらの問題はコンソールから sbt でのコンパイル・実行していれば発生しないようだ。なんのことはない、Scala プログラマなら sbt を使えばいんじゃね?ということ。ただそれ自体が別の敷居で気が重い・・・

Scala CLI ✖️ Markdown

ナウでヤングな Scala プログラマは、代わりに Scala CLI を常用する。非常に便利で高機能でしかもお手軽な Scala コマンドラインツールで、 Scala の初学者への敷居もかなり下げてくれる。インストールも簡単だ[2]

これはそのうち sbt を置き換えるかも?と思っていたら、 scala コマンドの方が置き換わってしまった。Scala 3.5.0 以降の scala コマンドは scala-cli がそのまま採用されている。

さてここで注目するのは、Scala CLI がソースファイル形式として Markdown を試験的にサポートしている点だ。

どういうことかというと、Markdown のコードブロックに記述した Scala プログラムを実行できる。つまり .md ファイルを、ノートを取りながら動作確認もできるような、動くドキュメントとして扱えるようになる。

hello.md

## 初めての Scala
標準出力にメッセージを表示する。
```scala
println("Hello, Scala")
```

$ scala-cli hello.md
Hello, Scala

もし ScalaSDK 3.5.0 以降が使えるシステム環境を整えているなら、scala-cli の代わりに scala コマンドでも同様にそのまま使えるはず。

IntelliJ や VSCode の Scala 拡張は .md をコンパイル対象として認めていないので、残念ながら UI やショートカットからは実行できない。その代わり Scala CLI には --watch(-w) や --restart といったオプションも用意されていて、ソースの更新を監視と自動再実行(re-run)をさせることも可能。一応これで毎度コマンドラインを叩く手間は省ける。

$ scala-cli hello.md -w
Hello, Scala
Program exited with return code 0.
Watching sources, press Ctrl+C to exit, or press Enter to re-run.

注意点として、Markdown をいきなりコードブロックから描き始めるとなぜかエラーになるので、コードブロック前に少なくとも1行の本文か空行を入れて置くようにする。

またコードブロックでは main() メソッドからプログラムを実行できない。main は変換後のラッパー側で定義されているからだ。main を持つプログラムをコピペして動かしたければ、main の明示的な呼び出しを追加する必要がある。

hello.md

先頭に少なくとも1行本文を入れる。

main のあるサンプルプログラム。
```scala
//> using scala 3.3.5

// インデント構文を使わない方が安全かもしれない
object Hello {
    @main
    def main() = println("Hello, Scala")
}
// main を実行
Hello.main()
```

コードブロックにマクロを書く

.md ファイル内に複数のコードブロックを書いたとしても、ソースは基本的に 1 ファイルのラッパー(object)にまとめて展開され、各コードブロックはスコープを共有することになる。

当然このままではマクロを書いても動かせない。

そこでコードブロックに raw という指示子を追加する。raw の指定を付与されたコードブロックは元とは別の .scala ファイルにそのまま(ラッパーなしで)書き出される。

つまりマクロプログラムを raw コードブロックに書けば別ソースファイルとなるので、同じ .md 内の通常ブロックからでも呼び出しできるようになる。

マクロは raw ブロックとする
```scala raw
// マクロプログラム
```

呼び出し側は通常ブロック
```scala
// マクロ検証プログラム
```

ここでも注意点があって、普通にブロックの地のレベルにマクロ定義を書いてしまうと、なぜかコンパイルできない。回避策としていったん object を切ってその中で定義すると動く。

`raw` を追加する
```scala raw
//> using scala 3.3.5

// 地ではなく、object 内にマクロを定義する
object MyMacro:
    inline def hello(s: String):String = ${ helloImpl('s) }
    private def helloImpl(x: Expr[String]): Expr[String] = ???
```

```scala
println(MyMacro.hello("Macro"))
```

コードブロックを無効化する

コードブロックに ignore をつけると、そのブロックの実行を除外することができる。

ignore をつけるとコンパイルされない
```scala raw ignore
inline def hoge = ??? // というマクロのがあったとして...
```
もちろん呼び出し側も無効化しておく
```scala ignore
hoge() // マクロを呼ぶ
```

動かない不完全なコードや試行錯誤の途中のコードでも残しておきたいことがよくあるが、コメントアウトするより分かりやすい。またドキュメントとしてコード片を例示する場合にも便利だ。

デバッグ用出力

マクロコード中から println() しても何も表示されない。この出力はコンパイル時に行われ、コンパイルは Scala CLI とは別のプロセスで実行されるからだ。

scala-cli コマンドは最初の run でコンパイルサーバー(BSP サーバー、デフォルトで Bloop)を起動し、コンパイルはこのサーバープロセスで行われる。これは IDE や Editor (Metal) との連携に使われる便利な仕掛けなのだが、おかげでコンパイルメッセージがコンソールに出力されない。

そこで Bloop ではなく scala-cli コマンドのプロセスでコンパイルさせたい。それにはコマンドオプションに --server=false を追加する。

$ scala --server=false hello_macro.md

## マクロに式を渡す
マクロに式を渡してASTを確認する。
```scala raw
//> using scala 3.3.5
object PrintMacro:
    import scala.quoted.*
// 引数を inline にする
    inline def print(inline x: Any): Any =
        ${printImpl('x)}
    
    def printImpl(x: Expr[Any])(using Quotes): Expr[Any] =
        import quotes.reflect.*
        println(x.show)
        println(x.asTerm)
        println(Printer.TreeAnsiCode.show(x.asTerm)) // 色がつく
        println(Printer.TreeCode.show(x.asTerm))
        println(Printer.TreeShortCode.show(x.asTerm))
        println(Printer.TreeStructure.show(x.asTerm))
        report.info("ここです")
        '{$x; $x}
```
## 動作確認
単純な式を渡してみる。
```scala
val a = 1
println(PrintMacro.print( a + a ))
```

$ scala-cli --server=false print.md 
print_md.Scope.a.+(print_md.Scope.a)
Inlined(EmptyTree,List(),Apply(Select(Ident(a),+),List(Ident(a))))
print_md.Scope.a.+(print_md.Scope.a)
print_md.Scope.a.+(print_md.Scope.a)
a.+(a)
Inlined(None, Nil, Apply(Select(Ident("a"), "+"), List(Ident("a"))))
-- Info: /Users/kumazo/Projects/macro01/.scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.scala:24:24 
24 |println(PrintMacro.print( a + a ))
   |        ^^^^^^^^^^^^^^^^^^^^^^^^^
   |        ここです
2

ちなみにマクロに式を渡してそのコードを確認したい時、上記のように単純に Expr#show を使うと識別子に package などスコープのパスが含まれて判り辛くなる。呼び出し側をメソッドにするとこれを回避できて、断然読みやすい。

```scala
def test =
    val a = 1
    println(PrintMacro.print( a + a ))
test
```

$ scala-cli --server=false print.md 
a.+(a)
Inlined(EmptyTree,List(),Apply(Select(Ident(a),+),List(Ident(a))))
a.+(a)
a.+(a)
a.+(a)
Inlined(None, Nil, Apply(Select(Ident("a"), "+"), List(Ident("a"))))
-- Info: /Users/kumazo/Projects/macro01/.scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.scala:24:24 
23 |    println(PrintMacro.print( a + a ))
   |            ^^^^^^^^^^^^^^^^^^^^^^^^^
   |            ここです
2

もっとも、show を含めた Expr[T] への extension はいつからか deprecated 扱いになっているので、Printer.TreeShortCode などを使うのが今後は正しいのだろう。

def printImpl(expr: Expr[Any])(using Quotes): Expr[Any] =
    import quotes.reflect.*

    // 非推奨(deprecated)
    val a = x.show

    // scala.quoted.Quotes.reflectModule.Printer を使う
    val b = Printer.TreeShortCode.show(expr.asTerm))
    val c = expr.asTerm.show(using Printer.TreeShortCode) 

    // あるいは
    given Printer[Tree] = Printer.TreeShortCode
    vac d = expr.asTerm.show

マクロプログラミングではデバッグ用のコンパイラオプションもつけたい。
Scala CLI ではコンパイラオプションもソースのコメント内ディレクティブで指定できて便利だ。

```scala
//> using option -explain
//> using option -explain-cyclic
//> using option -Xprint:postInlining
//> using option -Xmax-inlines:1000 // default 32
//> using option -Xprint-suspension
//> using javaOpt --sun-misc-unsafe-memory-access=allow // 効かない?

def test =
    val a = 1
    println(PrintMacro.print( a + a ))
test
```

出力
$ scala-cli --server=false print.md
[[syntax trees at end of              postInlining]] // /Users/kumazo/Projects/macro01/.scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.raw.scala
package <empty> {
  final lazy module val PrintMacro: PrintMacro = new PrintMacro()
  @SourceFile(
    
      ".scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.raw.scala"
      
  ) final module class PrintMacro() extends Object() { this: PrintMacro.type =>
    private def writeReplace(): AnyRef =
      new scala.runtime.ModuleSerializationProxy(classOf[PrintMacro.type])
    import scala.quoted.*
    inline def print(inline x: Any): Any =
      ${(using contextual$1: scala.quoted.Quotes) =>
        PrintMacro.printImpl('{x}.apply(contextual$1))(contextual$1)}:Any
    def printImpl(x: scala.quoted.Expr[Any])(using x$2: scala.quoted.Quotes):
      scala.quoted.Expr[Any] =
      {
        import x$2.reflect.*
        println(x$2.show[Any](x))
        println(x$2.reflect.asTerm(x))
        println(x$2.reflect.Printer.TreeAnsiCode.show(x$2.reflect.asTerm(x)))
        println(x$2.reflect.Printer.TreeCode.show(x$2.reflect.asTerm(x)))
        println(x$2.reflect.Printer.TreeShortCode.show(x$2.reflect.asTerm(x)))
        println(x$2.reflect.Printer.TreeStructure.show(x$2.reflect.asTerm(x)))
        println(
          x$2.reflect.TreeMethods.show(x$2.reflect.asTerm(x))(using
            x$2.reflect.Printer.TreeAnsiCode)
        )
        println(
          x$2.reflect.TreeMethods.show(x$2.reflect.asTerm(x))(using
            x$2.reflect.Printer.TreeCode)
        )
        println(
          x$2.reflect.TreeMethods.show(x$2.reflect.asTerm(x))(using
            x$2.reflect.Printer.TreeShortCode)
        )
        println(
          x$2.reflect.TreeMethods.show(x$2.reflect.asTerm(x))(using
            x$2.reflect.Printer.TreeStructure)
        )
        x$2.reflect.report.info("ここです")
        '{
          {
            ${(using contextual$2: scala.quoted.Quotes) => x}
            ${(using contextual$3: scala.quoted.Quotes) => x}
          }
        }.apply(x$2)
      }
  }
}

compiling suspended /Users/kumazo/Projects/macro01/.scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.scala
  /Users/kumazo/Projects/macro01/.scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.scala at inlining: suspension triggered by macro call to method printImpl in object PrintMacro in /Users/kumazo/Projects/macro01/.scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.raw.scala
a.+(a)
Inlined(EmptyTree,List(),Apply(Select(Ident(a),+),List(Ident(a))))
a.+(a)
a.+(a)
a.+(a)
Inlined(None, Nil, Apply(Select(Ident("a"), "+"), List(Ident("a"))))
a.+(a)
a.+(a)
a.+(a)
Inlined(None, Nil, Apply(Select(Ident("a"), "+"), List(Ident("a"))))
-- Info: /Users/kumazo/Projects/macro01/.scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.scala:37:28 
37 |    println(PrintMacro.print( a + a ))
   |            ^^^^^^^^^^^^^^^^^^^^^^^^^
   |            ここです
[[syntax trees at end of              postInlining]] // /Users/kumazo/Projects/macro01/.scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.scala
package <empty> {
  final lazy module val print_md: print_md = new print_md()
  @SourceFile(
    
      ".scala-build/macro01_aaabefc6e1-7e902b754d/src_generated/main/print.md.scala"
      
  ) final module class print_md() extends Object() { this: print_md.type =>
    private def writeReplace(): AnyRef =
      new scala.runtime.ModuleSerializationProxy(classOf[print_md.type])
    @nowarn("msg=pure expression does nothing") def main(args: Array[String]):
      Unit =
      {
        {
          print_md.Scope
          ()
        }
      }
    final lazy module val Scope: print_md.Scope = new print_md.Scope()
    final module class Scope() extends Object() { this: print_md.Scope.type =>
      private def writeReplace(): AnyRef =
        new scala.runtime.ModuleSerializationProxy(classOf[print_md.Scope.type])
      def test: Unit =
        {
          val a: Int = 1
          println(
            {
              a.+(a)
              a.+(a)
            }:Any
          )
        }
      print_md.Scope.test
    }
  }
}

2

まとめ

Scala CLI の Markdown サポートを利用して、Scala 3 マクロのサンプルプログラムを書いてみた。

ちょっとした文法の確認や動作検証などが気軽にできるのは素晴らしい。

ただし、InteliJ や VSCode の支援が限定されるので、うまく動かなかった時の原因究明はもどかしい。特に、Scala API のソースにワンクリックでジャンプできないのは辛かった。

動くドキュメントとして Scala 学習やフィジビリティスタディにはお勧めできるが、トリッキーなことをしたり行数がある程度の規模になるようなら素直にプロジェクトを構成した方がいいだろう。

参考資料

脚注
  1. この文書では拡張子として .worksheet.sc を使うことにになっている。とはいえ IntellJ では(確か Eclipse でも) *.sc だけで動く。一方 VSCode では *.worksheet.sc としないと Worksheet として認識されない。 ↩︎

  2. scala-cli のインストールは brew や winget といったシステムツールで簡単に入るが、本体は単独の実行ファイルなのでマニュアルで落としてきてもいい。JDK や ScalaSDK も Scala CLI が良きに計らってくれるので、システム環境に用意する必要はない。
    ::: ↩︎

Discussion