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.10.4" resolvers += Resolver.url("typesafe-ivy-repo", url("http://typesafe.artifactoryonline.com/typesafe/releases"))(Resolver.ivyStylePatterns) libraryDependencies += "org.scala-sbt" % "io" % "0.13.5-RC2" */ 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.10.4" resolvers += Resolver.url("typesafe-ivy-repo", url("http://typesafe.artifactoryonline.com/typesafe/releases"))(Resolver.ivyStylePatterns) libraryDependencies += "org.scala-sbt" % "io" % "0.13.5-RC2" */ 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
まとめ
sbt の script runner と IO
モジュールを使うことで、Scala を使って静的型付けされたシェルスクリプトを書くことができる。script.scala の gist。
- Login to post comments