SBT Pluginの作り方

  • 18
    いいね
  • 6
    コメント
この記事は最終更新日から1年以上が経過しています。

SBT Pluginの作り方を調べてみても断片的な情報で、ちょっと苦労したのでまとめてみました。

1.基本形

単独のPluginとして動作する最低限の形で説明します。他のPluginを利用したりするケースもありますが、それらの説明は参考リンク先に委ねることにします。
ベーシックなサンプルとしては、sbt-cloverが一番分かりやすかったです。
ただ、後述のテストまで含めるとxsbt-web-pluginの方が読みやすいかもしれません。

プロジェクト設定

SBTのバージョンは2016/1/1時点で0.13.9が最新ですが、0.13.8あたりを指定しておけば良さそうです。

project/build.properties
sbt.version=0.13.8

build.sbtで大事なのはsbtPlugin := trueの部分です。それ以外は通常のSBTプロジェクトと変わりません。

build.sbt
// general
organization  := "your.organization"
name          := "your-plugin"
version       := "0.0.1-SNAPSHOT"
sbtPlugin     := true
scalacOptions ++= Seq("-feature", "-deprecation")

追記 ↑xuwei_kさんからコメント頂いたのでscalaVersionの記述を削除しました。

sbtの0.13.xは Scala 2.10.x で作られているので、 scalaVersion に 2.11.x を指定したらダメです。基本的に指定しないほうがいいです(指定しなければデフォルトのsbtで使われてるものと同じになるので)

コード

SBT PluginはKeyの定義をして、xxxSettingsの中でそのKeyに対して振る舞いをセットする、みたいな形で作っていきます。KeyはsettingKey/taskKey/inputKeyの3種類があります。

  • settingKey
    属性のようなものを定義するときに使う。タスクの中で使いたい可変の属性など。
  • taskKey
    ただの値ではなく動作を伴うタスクを定義するときに使う。
  • inputKey
    taskKeyで実行時引数(Arguments)を受け取れるようにしたいときに使う。

なので、まずはKeyを定義しましょう。

src/main/scala/your/organization/YourKeys.scala
import sbt._

object YourKeys {
  val message = settingKey[String]("表示メッセージ") // ジェネリックにはKeyの型を、引数にはKeyの説明を指定
  val hello3times = taskKey[Unit]("messageを3回表示する")
  val echo3times = inputKey[Unit]("タスク起動時に渡された文字列を3回表示する")
}

次に、Taskの振る舞いを定義します。
出力先はprintlnでもテストには十分なのですが、sbt.Keys.streams.value.logで取得できるロガーを使ってみます。
inputTaskの方は、Parserが必要になりますが、大抵はBuilt-inされたParserで十分でしょう。TaskもInputTaskもsbt.Defのメソッドから定義します。これはTaskを分割して書く方法で、本当に小さなPluginの場合などは後述のPlugin定義の中でhello3times := { ... }のように直接定義することも可能です。
その他、Commandというものもあるようですが、公式ドキュメントのベストプラクティスを参照するとUse settings and tasks. Avoid commands.とあるので、ここでは触れないことにした方が恐らく幸せでしょう...

src/main/scala/your/organization/YourTasks.scala
import sbt._
import sbt.Keys._
import YourKeys._

object YourTasks extends HelloTask with EchoTask

trait HelloTask {

  lazy val helloTask = Def.task {
    val log = streams.value.log
    for (i <- 0 to 2) {
      log.info(s"$i:${message.value}")
    }
  }
}

trait EchoTask {
  // Argumentsを受け取るにはParserが必要
  import sbt.complete.DefaultParsers._

  lazy val echoTask = Def.inputTask {
    val args: Seq[String] = spaceDelimited("<arg>").parsed
    val log = streams.value.log
    for (i <- 0 to 2) {
      args.foreach(arg => log.info(s"$i:$arg"))
    }
  }
}

最後にPluginとして、これらを組み合わせます。
Pluginの作成には、AutoPluginという仕組みを利用することが一番お手軽です。(SBT0.13.5以降で使用可能)
参考:sbt テクノロジ・プリビュー : auto plugin
規模の大きなSBT Pluginでは、Settingsをトレイトに切り出して整理したりもしていますが、今回は小さいのでPluginにそのまま書いてしまいます。

src/main/scala/your/organization/YourPlugin.scala
import sbt._
import sbt.Keys._

object YourPlugin extends AutoPlugin {

  val autoImport = YourKeys

  import autoImport._

  val config = config("hoge")  // ※1

  import YourTasks._

  override def projectSettings: Seq[Def.Setting[_]] = inConfig(config)(
    Seq(
      message := "hello world",  // 初期値的な
      hello3times <<= helloTask,
      echo3times <<= echoTask
    )
  )
}

※1 config?

このサンプルではconfig("hoge")と用意して、projectSettingsにはinConfig(config) { ... }と指定しています。こうすることで、hogeスコープのKeyとして定義することができます。例えばsbt hoge:hello3timesのようにconfigで指定した名前の子として使えるようになる感じです。 ただし、SBT Pluginのベストプラクティスでは、このような単に名前空間としてconfigを使うことは推奨されていません
もちろん、sbt hello3timesのようにすることも可能で、以下のように設定します。

src/main/scala/your/organization/YourPlugin.scala
override def projectSettings: Seq[Def.Setting[_]] = Seq(
  message := "hello world",
  hello3times <<= helloTask,
  echo3times <<= echoTask
)

追記 choplinさんからコメント頂いたので訂正

configurationは依存関係のセットを指定するのに使用するもので名前空間として使わないほうがよい、とオフィシャルのPlugins Best Practicesにあります。この例だと名前空間として利用しているので、Plugins Best Practicesではこう言われていると触れた方がいいかもしれません。http://www.scala-sbt.org/0.13/docs/Plugins-Best-Practices.html#Configuration+advices

追記の追記 xuwei_kさんからコメント頂いて検討したので追記

名前空間的なものを用意したい場合はどうするのがベターなのでしょう?
http://www.scala-sbt.org/0.13/docs/Plugins-Best-Practices.html#Avoid+namespace+clashes
によると、プリフィックスを付ける、という感じですがダサい・・・

それで済むならそうするという、ある意味ダサい慣習が現状はデフォルトだと思います。いずれにせよ、scopeを単に名前空間的に使っても、scope名が衝突したら同じですし

名前の衝突は完全には避けることができないものの、他のSBT Pluginを見ている感じだと、

  • sourceDirectoryなどKeyを共有する(?)ような場合はconfigなどでscopeをつくる
  • そうでない場合はプリフィックスを付ける

というところなのかなと思います。(正解のない話なので個人的見解。これがベスト!があれば教えて下さい。)
ちなみにですが、sbt-dockerというPluginでは以下のようにtaskKeyを使ってスコープを作っています。これだとhoge::hello3timesのようにコロン2つで呼び出すような形で、configとは違ってTaskなので、こちらの方が良いのかも?という気がしていますがどうなのでしょう。

val hoge = taskKey[Unit]("hoge task")
// ...
hello3times in hoge <<= helloTask

追記の追記の追記 SBT ベストプラクティスをよくよく読むと、

Instead, reuse an existing one or scope by the main task

とあるので、すでにあるKeyを再利用するか、メインタスクのスコープを使いましょう、と書いてありました・・・。ので、今回のような単に名前空間的にKeyを扱いたいときは、それを利用するメインタスクのスコープに入れる、が良さそうです。サンプルコード

利用してみる

作ったPluginを利用できるようにしてみましょう。
後述のテストの布石的にsrc/sbt-test/your-plugin/simpleというディレクトリを作って、ここにPluginを利用できるようにSBTプロジェクトを設定してみます。

src/sbt-test/your-plugin/simple/project/plugins.sbt
addSbtPlugin("your.organization" % "your-plugin" % "0.0.1-SNAPSHOT")
src/sbt-test/your-plugin/simple/build.sbt
name := "sbt-test"
version := "0.0.1-SNAPSHOT"

enablePlugins(YourPlugin)

これで、以下のようにPluginのタスクを実行できるようになります。
※後述のテスト項にも書いていますが、手動でpublishLocalするこの方法はハマりやすいので非推奨です。

$ cd your-plugins-directory
$ sbt compile
$ sbt publishLocal
$ cd your-plugins-directory/src/sbt-test/your-plugin/simple
$ sbt hoge:hello3times

2.テスト

scripted-pluginがsbtには付属していて、これを利用するのが事故が少なそうです。
参考:sbt プラグインをテストする
手動でsbt publishLocalして試すこともできるんですが、これはよくローカルIvyのキャッシュが上手くクリアされずにハマることが多いので、素直にscripted-pluginを使いましょう。(私もPluginのlibraryDependenciesを足したらNoClassDefFoundErrorが発生するようになってハマりました...)
※このテストはあくまでもPluginが動作するか?という点のテストなので、ロジックのテストは別途Specs2なりを使ってテストしましょう。

追記 よくよく試すと、scriptedでもpublishLocalすることには変わらないようです?ので、build.sbtに isSnapshot := trueを設定してあげて、 ライブラリ依存の追加をした場合は大人しくversionを上げた方が良いかもしれません。
追記の追記 xuwei_kさんにコメント頂いたので訂正

isSnapshot は通常は明示的に指定しない(どちらかといえば参照するためにある)というか、デフォルトではversionの文字列が-SNAPSHOTで終わっているかどうかによって変わったり します。いずれにせよ、isSnapshotを指定したからといって、今回触れているキャッシュの問題が解決するか微妙だと思うので、設定しないほうがいいと思います。 (というか、まず、開発中は必ずversionを-SNAPSHOTで終わらせておくようにしておけばいいはず)

プロジェクト設定

まずは、scriptedを使えるようにします。

project/plugins.sbt
// scripted for plugin testing
libraryDependencies <+= sbtVersion(v => "org.scala-sbt" % "scripted-plugin" % v)

次に、build.sbtでscriptedを有効化して、-Dplugin.versionをscripted起動時に渡すように設定しておきます。(上記参考リンクの"ステップ 3: src/sbt-test"参照)

build.sbt
// 追記
// scripted-plugin
ScriptedPlugin.scriptedSettings
scriptedBufferLog  := false
scriptedLaunchOpts <+= version { "-Dplugin.version=" + _ }
watchSources       <++= sourceDirectory map { path => (path ** "*").get }

そして、sbt-test側の設定も一部修正します。

src/sbt-test/your-plugin/simple/project/plugins.sbt
Option(System.getProperty("plugin.version")) match {
  case None =>
    throw new RuntimeException(
      """|The system property 'plugin.version' is not defined.
        |Please specify this property using the SBT flag -D.""".stripMargin)
  case Some(pluginVersion) =>
    addSbtPlugin("your.organization" % "your-plugin" % pluginVersion)
}

テストコード

scriptedによるテストではtestというファイルを用意して、そこにSBTコマンドを記述するような形で定義します。これも詳しくはsbt プラグインをテストするの"ステップ 4: スクリプトを書く"を参照してください。
ただ、そこまで詳細なテストはできないので、もう少し踏み込んだテストは"ステップ 6: カスタムアサーション"の辺りを参照してScalaコードを書くような感じになるようです。

> hoge:hello3times
> hoge:echo3times a b c

これで以下のようにテストを実行できるようになります。
scriptedでは、実行の度にpublishからテストプロジェクトのコピーからが行われるようで、テストが実行されるディレクトリはどこかの一次ディレクトリで実行されるようです。なので、一部ファイルを書き換えてテストを実行する、みたいなことも前後処理などを書かなくても可能なはず(未検証)。

$ sbt scripted

参考