追記(2023-04-04): Scala CLIを使いましょう
Ammoniteをベースにより洗練されたScala CLIが開発され、Scalaのデファクトスタンダードとして定着しつつあります。公式にscala
と入力した際の標準アプリケーションとなることも予見されているため、こちらを学ぶとよいでしょう。
Ammonite文法最速マスター
この記事は、Scalaのスクリプティング環境でありREPLでもあるAmmoniteの基礎的な利用方法をマスターし、ちょっと凝ったシェルスクリプトをAmmoniteスクリプトに書き換えられる程度のAmmonite力を手に入れるためのものです。
想定読者
- Scalaの文法を理解している
- 基本的なシェルスクリプトを書ける
Ammoniteってなに?
Ammoniteは、ScalaをLLみたいに使うためのツールです。Scalaプログラミングを行うためには、通常であればsbtといったプロジェクト管理ツールを一緒に使いますが、ちょっとしたスクリプティングやシェルスクリプトの置換として使うには大変すぎます。その一方で、Scalaを普段から利用している人ならば書き慣れたScalaでスクリプティングを行うことで、既存の知識と強力な言語機能・型システムを活用できるので有利です。Ammoniteは、このギャップを埋めるツールです。おおまかには、REPL機能・スクリプト実行機能・コマンド呼び出しやファイル操作等を透過的に行うための小さなライブラリから成り立っています。
Ammoniteを触ってみる
Ammoniteをインストールするには、公式ページの手順に従います:
$ sudo sh -c '(echo "#!/usr/bin/env sh" && curl -L https://github.com/com-lihaoyi/Ammonite/releases/download/2.5.4/2.13-2.5.4) > /usr/local/bin/amm && chmod +x /usr/local/bin/amm' && amm
もしくは、Scalaの公式インストール手順を踏んだユーザは勝手にAmmoniteもインストールされるので、何もしなくて大丈夫です:
Ammoniteは、amm
コマンドをユーザに提供します。amm
を単独で使うとREPLが起動します:
$ amm Loading... Welcome to the Ammonite Repl 2.5.4 (Scala 2.13.8 Java 1.8.0_342) @
amm
のREPLでは、@
がプロンプトです。アンモナイトですね!
REPLにScalaの式を与えると、それが評価されます:
@ def fib(n: Int): Int = n match { case 1 => 1 case 2 => 1 case n => fib(n-1) + fib(n-2) } defined function fib @ fib(10) res1: Int = 55
REPLは、ヒストリ機能や補完機能、カラー表示機能を備えており高機能です。Ctrl+Rといった操作にも対応します。Ctrl+DでREPLを終了できます。
@ Bye! $
Scala script
先程はamm
を単独で使うとREPLが起動するという説明を行いました。そして、amm
にファイルを渡すとスクリプトを実行できます:
@main def main() = { println("Hello, Ammonite!") }
$ amm hello.sc Compiling /home/windymelt/hello.sc Hello, Ammonite! $
amm
は、渡されたファイルを Scalaスクリプト として認識し、@main
アノテーションを持つメソッドをエントリポイントとして実行します。Scalaスクリプトは、@main
が要求されるといった小さな差異を除けば、何の変哲も無いScalaのファイルです。
Scalaスクリプトは、シェルスクリプト同様、shellbangに対応しています:
#!/usr/bin/env amm @main def main() = { println("Hello, Ammonite!") }
$ chmod u+x hello.sc $ ./hello.sc Hello, Ammonite!
amm
はScalaスクリプトを実行できる- Scalaスクリプトの拡張子は
.sc
- shellbangに対応している
スクリプトの引数宣言
@main
アノテーションを持つメソッドが引数を持つ場合、これはそのままスクリプトの引数を宣言したことになります:
@main def main(name: String, n: Int) = { println(s"My name is $name, n is $n") }
$ amm hello.sc Compiling /home/windymelt/hello.sc Missing arguments: --name <str> -n <int> Expected Signature: main --name <str> -n <int> $ amm hello.sc --name Windymelt -n 666 My name is Windymelt, n is 666 $
しかも、型チェックもしてもらえました!二回目以降の実行はAmmoniteがコンパイル結果をキャッシュしているので、再コンパイルは行われていないことにも注目してください。
- エントリポイントとなるメソッドに引数を設定すると、そのままスクリプトの実行時引数を宣言できる
- 実行時には
--名前 値
の形で与える(--名前=値
では動かない様子) - 引数の型を
Option[A]
にすると、それが指定されなかったときはNone
がフィルインされる
import $ivy
記法による依存ライブラリの自動インストール
スクリプトが外部ライブラリに依存している場合、import $ivy
記法で自動的にamm
にライブラリをダウンロードしてもらうことができます:
import $ivy.`org.typelevel::cats-core:2.8.0`, cats._, cats.implicits._ @main def main() = { // catsの機能を使える val lis = List(1,2,3) |+| List(4,5,6) println(lis) }
$ amm script.sc List(1, 2, 3, 4, 5, 6)
ライブラリは、以下のような順で変形した表記で指定します:
build.sbt
にlibraryDependencies +=
で書くときの記法から、ダブルクオートを取り除く%
を:
で置換する(%%
は::
で置換する)- スペースを取り除いて、バッククオートで括る
import $ivy.
を接頭する
例
元々の形:
"com.chuusai" %% "shapeless" % "2.3.3"
ダブルクオートを除去:
com.chuusai %% shapeless % 2.3.3
%
を:
で置換する:
com.chuusai :: shapeless : 2.3.3
スペースを除去してバックオートで括り、import $ivy.
を前に置く:
import $ivy.`com.chuusai::shapeless:2.3.3`
完成!
import $file
記法による外部スクリプトの読み込み
import $file.Foo
と書くことで、同じディレクトリにあるFoo.sc
の内容をFoo
名前空間以下に読み込むことができます:
// Basic.sc val basicValue = 31337
// FileImport.sc import $file.Basic val fileImportVal = Basic.basicValue + 1
上の階層のファイルを指定するには、^
を使ってimport $file.^.Filename
のように指定します。
使用されるJVMやScalaのバージョンなどを指定する
今のところ、amm
はシステムデフォルトのJVMを起動し、
固定されたバージョンのScalaを起動するようですが、
サードパーティのツールであるammonite-runnerを使うことで実行されるAmmoniteとScalaのバージョンとを固定できるようです:
// Ammonite 2.1.4, scala 2.12.11 // ↑Scalaコードよりも前にバージョンを記述する @main def main() = { ... }
# csはCoursierコマンド $ cs launch \ io.github.alexarchambault.ammonite::ammonite-runner-cli:latest.release \ -- script.sc
しかし、このやり方だと毎回コンパイルが走るようなので、一長一短という感じ。
ちなみにamm
コマンドは、JAVA_OPTS
環境変数を受け付けます:
$ JAVA_OPTS="-Xmx1G" amm ...
シェルスクリプトっぽくする
この節では、Scalaスクリプトで既存のシェルスクリプトを置換する際に必要になるであろう仕草を紹介します。
基礎
シェルスクリプトで使うであろう多数の機能、例えば一時ファイルの作成やファイルの削除、ディレクトリトラバーサルなどはos.
名前空間に定義されています。
これはos-libライブラリによって提供されています。
外部コマンド実行
外部コマンドはos.proc(...).call()
で起動し、終了するまでブロックされます:
val proc = os.proc("curl", "-sSL", "https://example.com").call()
ブロッキングさせない場合はspawn()
をcall()
の代わりに使います。
デフォルトではコマンドの出力は見えないので、call()
に引数を与えることでstdin / stdoutの扱いや、cwd
の扱いを変更します:
val proc = os.proc("curl", "-sSL", "https://example.com").call(stdout = os.Inherit)
$ amm curl.sc <!doctype html> <html> <head> <title>Example Domain</title> ... $
call()
/ spawn()
には多様な引数を与えることができます。よく使うものを以下に示します:
stdout
stdout = os.Pipe
- 親プロセスである
amm
に対してパイプで接続され、.stdout
といったメソッドで取り出せるようになる - デフォルトの挙動(
stderr
/stdin
でも同様)
- 親プロセスである
stdout = os.Inherit
- 実行中の
amm
コマンドのものを再利用する - 振舞いとしては、標準出力が今の標準出力に吐き出される
- 実行中の
stderr
mergeErrIntoOut
true
にするとシェルスクリプトにおける2>&1
と同義になる- デフォルトは
false
stdin
cwd
- コマンドが起動する際に渡される環境変数
$PWD
に渡すディレクトリ。これを指定するとcd
してからコマンドを実行したのと同義になる - Scala Scriptには
cd
するという概念が無い(状態を持たせないため?)
- コマンドが起動する際に渡される環境変数
check
- コマンドが失敗(exit code != 0)した場合に例外を投げるかどうか
- デフォルトは
true
(例外を投げる)
コマンドの出力を文字列として得る
シェルスクリプトにおける$()
記法(コマンドの標準出力を文字列で得る)は、
os.proc(...).call(...).out.trim
を使います:
val result = os.proc("curl", "-sSL", "https://example.com").call().out.trim
.trim
しなければ、末尾の改行コードがそのまま保持されます。
また、out.lines
を使うとVector[String]
が得られます:
val lines = os.proc("sha256sum", "hello.sc", "photo.jpg").call().out.lines
out.bytes
はArray[Byte]
を返します:
val bytes = os.proc("gzip", "-f", "-").call(stdin = "This text should be compressed").out.bytes
複数のコマンドを組み合わせる
val curl = os.proc("curl", "-sSL", "https://example.com").spawn() val xmllint = os.proc("xmllint", "--html", "--xpath", "//h1/text()", "-").call(stdin = curl.stdout, stdout = os.Inherit)
$ amm curl2.sc Example Domain $
spawn()
したコマンドのstdout
を他のコマンドをspawn
/call
するときにstdin
として渡すことで、パイプ同様の働きができます。
パイプラインの前段で起動するコマンドはspawn()
する必要があります(call()
するとそこでブロックしてしまうため)。
個人的には、このへんはモナドとかにして扱えるようになっていると面白そうだけれど、os-lib
はcats
もscalaz
も使っていない(そういう方針?)。アダプタを書くと面白そう。
環境変数
環境変数を扱うための仕組みは用意されていないが、sys.env.get()
すればよいはず:
@ sys.env.get("HOME") res0: Option[String] = Some(value = "/home/windymelt")
Option
で環境変数が得られるので、このあたりはシェルスクリプトよりも安全だな〜と思う。
スクリプトのパスを得る / cdする
シェルスクリプトでは、$0
を使ってスクリプトがあるディレクトリにcd
するという手法が頻出します。これをAmmoniteで行うにはどうすればよいでしょう?
cd $(dirname "$0")
os-lib
では、$0
の代わりにsourcecode.File()
によって実行中のスクリプトの絶対パスを得ることができます。
val scriptPathString: String = sourcecode.File()
そして、os.up
と組み合わせることでスクリプトが存在するディレクトリの絶対パスを得ることができます。
val scriptDirectory: os.Path = os.Path(sourcecode.File()) / os.up
ちなみにScalaスクリプトには「現在のディレクトリ」の概念は無いので、コマンドが実行されるときにcwd
としてディレクトリを渡すという手法を使う必要があります:
val bytes = os.proc("gzip", "file.txt").call(cwd = scriptDirectory).out.bytes
エラーハンドリング
ScalaスクリプトはScalaのコードとして実行されるので、全ての実行時エラーは例外を投げ、そこで実行は中断します(シェルスクリプトでは、set -e
しない限り次のコマンドを実行します)。
@ ??? scala.NotImplementedError: an implementation is missing scala.Predef$.$qmark$qmark$qmark(Predef.scala:344) ammonite.$sess.cmd3$.<clinit>(cmd3.sc:1)
エラーを意図的に無視するには、scala.util.Try
を使うことになりそう:
@ import scala.util.Try import scala.util.Try @ import scala.language.postfixOps import scala.language.postfixOps @ Try {???} toOption res2: Option[Nothing] = None
他のツールとのかかわり
ちなみに、Metalsの機能を使ったworksheetというものがあり、 スクリプトではなく逐行評価して様子を確認したいときにはこちらのほうが便利です。
動機
いかがでしたか?(アフィサイト)
Ammoniteを試しに使ってみるにあたって、日本語で入手可能なbootstrap的なドキュメントが存在しなかった事、そしてAmmoniteの公式ドキュメントがちょっと古びていてそのままでは使えない箇所があったので、all-in-one的なドキュメントとして文法最速マスターを書くことにした。
具体的に古びている箇所とは、Ammonite-opsというサブモジュールは既に存在せずos-libというライブラリで置換されているが、ドキュメントではそのままになっているという点である。
これはどうするの?といった点はブコメなどで指摘いただけると幸いです。