Scala を用いたスクリプティング
現実問題として正規表現が必要になることがある。いくつかのテキストファイルに変換をかけたりする度に find
コマンド、zsh のドキュメントや Perl 関連の StackOverflow の質問を手探りしながら作業することになる。苦労しながら Perl を書くよりは Scala を使いたい。結局、僕個人の慣れの問題だ。
例えば、今手元に 100以上の reStructuredText ファイルがあって、それを markdown に変換する必要がある。まずは pandoc を試してみて、それはそれなりにうまくいった。だけど、中身をよく読んでみるとコードリテラルの多くがちゃんとフォーマットされてないことに気づいた。これは単一のバッククォート (backtick) で囲まれていたり、Interpreted Text を使っているからみたいだ。このテキストをいくつかの正規表現で前処理してやればうまくと思う。
コマンドライン scalas
僕の現在の開発マシンには scala
へのパスが通っていない。zip ファイルを一回ダウンロードするのは大した作業じゃないけども、将来的に jar とスクリプトの管理をしなきゃいけないのが面倒な気がする。普通なら僕は sbt を使って Scala の jar をダウンロードさせる。それでもいいけども、単一のファイルのみを使った解法が欲しいとする。
そこで今試してるのが conscript を使って入れることができる sbt の script runnerだ。
$ cs sbt/sbt --branch 0.13.2b
注意: 上を実行すると ~/bin/sbt
が上書きされる。~/bin/
以下にインストールされるものの一つに scalas
スクリプトがある。script.scala
を以下のように書く:
#!/usr/bin/env scalas /*** scalaVersion := "2.10.4" */ println("hello")
次に、
$ chmod +x script.scala
$ export CONSCRIPT_OPTS="-XX:MaxPermSize=512M -Dfile.encoding=UTF-8"
$ ./script.scala
[info] Loading global plugins from /Users/eugene/dotfiles/sbt/0.13/plugins
[info] Set current project to root-4dcd3aa66723522a07c4 (in build file:/Users/eugene/.conscript/boot/4dcd3aa66723522a07c4/)
hello
これで自分の Scala version を 2.10.4 に指定するスクリプトができた。コンパイルを含めて "hello" が表示されるまで 12秒かかるから、サクサクとは程遠い感じだけど、個人的には許容範囲内だと思う。
sbt.IO
まず最初にやりたいのは、find
を使わずに src/
以下の全サブディレクトリの *.rst
ファイルを走査することだ。sbt の sbt.IO
はこういうのが得意だし、使い方も分かってる。
#!/usr/bin/env scalas /*** scalaVersion := "2.11.7" resolvers += Resolver.typesafeIvyRepo("releases") libraryDependencies += "org.scala-sbt" % "io" % "0.13.8" */ import sbt._, Path._ import java.io.File import java.net.{URI, URL} def file(s: String): File = new File(s) def uri(s: String): URI = new URI(s) val srcDir = file("./src/") val fs: Seq[File] = (srcDir ** "*.rst").get fs foreach { x => println(x.toString) }
Path
オブジェクトに File
から PathFinder
への暗黙の変換が含まれていて、PathFinder
は **
メソッドを実装する。これがサブディレクトリ内のファイルパターンを参照する。script.scala
を実行するとこんな感じになる:
$ ./foo.scala
[info] Loading global plugins from /Users/eugene/dotfiles/sbt/0.13/plugins
[info] Set current project to root-4dcd3aa66723522a07c4 (in build file:/Users/eugene/.conscript/boot/4dcd3aa66723522a07c4/)
./src/sphinx/faq.rst
./src/sphinx/home.rst
./src/sphinx/index.rst
....
src から target への rebase
ファイルのリストが得られたところで、各ファイルから行を読み込んで target/
ディレクトリ以下に書き出してみよう。このようなファイルパスの操作は Path.rebase
として提供されていて、これは File => Option[File]
関数を返す。
行の読み書きはそれぞれ IO.readLines
と IO.writeLines
と呼ばれている。各行末に "!" を追加するスクリプトはこうなる:
#!/usr/bin/env scalas /*** scalaVersion := "2.11.7" resolvers += Resolver.typesafeIvyRepo("releases") libraryDependencies += "org.scala-sbt" % "io" % "0.13.8" */ import sbt._, Path._ import java.io.File import java.net.{URI, URL} import sys.process._ def file(s: String): File = new File(s) def uri(s: String): URI = new URI(s) val targetDir = file("./target/") val srcDir = file("./src/") val toTarget = rebase(srcDir, targetDir) def processFile(f: File): Unit = { val newParent = toTarget(f.getParentFile) getOrElse {sys.error("wat")} val file1 = newParent / f.name println(s"""$f => $file1""") val xs = IO.readLines(f) map { _ + "!" } IO.writeLines(file1, xs) } val fs: Seq[File] = (srcDir ** "*.rst").get fs foreach { processFile }
これがアウトプットだ:
./src/sphinx/faq.rst => ./target/sphinx/faq.rst
./src/sphinx/home.rst => ./target/sphinx/home.rst
./src/sphinx/index.rst => ./target/sphinx/index.rst
純粋関数型行変換
行の読み書きというガワができた所で各行の処理という実際の作業に移ることができる。これは String
を受け取って String
を返す関数となる。
今取り扱っている reStructuredText ファイルは 3種類の interpreted text の role (doc
、key
、ref
) があって以下のような書式になっている
:role:`some text here`
まずは、単一の role を取り除く純粋な関数生成器を作る:
def removeRole(role: String): String => String = _.replaceAll("""(:""" + role + """:)(\`[^`]+\`)""", """$2""")
次に、Function1
の andThen
メソッドを使ってそれを連鎖する:
val processRest: String => String = removeRole("doc") andThen removeRole("key") andThen removeRole("ref")
単一のバッククォートとダブルのバッククォートを統一するためには、一度全部単一にしてから、全部をダブルにする。
def nTicks(n: Int): String = """(\`{""" + n.toString + """})""" def toSingleTicks: String => String = _.replaceAll(nTicks(2), "`") def toDoubleTicks: String => String = _.replaceAll(nTicks(1), "``") val preprocessRest: String => String = removeRole("doc") andThen removeRole("key") andThen removeRole("ref") andThen toSingleTicks andThen toDoubleTicks
sys.process
シェルスクリプトでよくある操作の一つに他のプログラムの呼び出しがある。sbt の Process
は今では標準ライブラリに sys.process
パッケージに含まれている。詳しくは ProcessBuilder
を参照。
Seq[String]
から ProcessBuilder
へと暗黙の変換があって、これは渡されたシェルコマンドを実行して結果の行を返す lines
メソッドを提供する。例えば、以下のようにして pandoc
を実行できる:
def runPandoc(f: File): Seq[String] = Seq("pandoc", "-f", "rst", "-t", "markdown", f.toString).lines.toSeq
引数の処理
Scala を使う動機の一つが Unix コマンドへの依存を減らすことだったけど、多くの場合ファイルのリストを受け取って結果を標準出力に返すといったスクリプトが望ましい。そうすることで、まず少数のファイルでテストすることができるからだ。script runner は引数を args
という名前の変数に保存するため、それを processFile
に渡してやればいい。
以下は最近書いた別のスクリプトでカスタムの howto
タグを抽出している。
#!/usr/bin/env scalas /*** scalaVersion := "2.11.7" resolvers += Resolver.typesafeIvyRepo("releases") libraryDependencies += "org.scala-sbt" % "io" % "0.13.8" */ // $ script/extracthowto.scala ../sbt/src/sphinx/Howto/*.rst import sbt._, Path._ import java.io.File import java.net.{URI, URL} def file(s: String): File = new File(s) def uri(s: String): URI = new URI(s) /* A how to tag looks like this: .. howto:: :id: unmanaged-base-directory :title: Change the default (unmanaged) library directory :type: setting unmanagedBase := baseDirectory.value / "jars" */ def extractId(line: String): String = line.replaceAll(":id:", "").trim def extractTitle(line: String): String = line.replaceAll(":title:", "").trim def processLine(num: Int, line1: String, line2: String, line3: String): Option[String] = line1 match { case x if x.trim == ".. howto::" => Some(s"""<a name="""${extractId(line2)}"></a> ### ${extractTitle(line3)}""") case _ => None } def processFile(f: File): Unit = { if (!f.exists) sys.error(s"$f does not exist!") val lines0: Vector[String] = IO.readLines(f).toVector val size = lines0.size val xs: Vector[String] = (0 to size - 3).toVector flatMap { i => processLine(i, lines0(i), lines0(i + 1), lines0(i + 2)) } println("-------------------\n") println(xs.mkString("\n\n")) println("\n") } args foreach { x => processFile(file(x)) }
まとめ
sbt の script runner と IO
モジュールを使うことで、Scala を使って静的型付けされたシェルスクリプトを書くことができる。script.scala の gist。
- Login to post comments