Takuji->find;

株式会社はてなでアプリケーションエンジニアやってます、技術的な記事を書いているつもり

KotlinのSealed Classを使いこなす

こんにちは、三度の飯よりKotlinが好きな id:takuji31 です。

※ これは、はてなエンジニア Advent Calendar 2017 7日目の記事です。6日目は id:hayajo_77 さんの「Webオペレーションエンジニアとし研修して1ヶ月経ちました」でした。

hayajo.hatenablog.jp

今日は私が愛してやまないKotlinのSealed Classの使い方について紹介します。

Sealed Classについて

Sealed Classとは継承を制限して、階層を明確にできるクラスです。

詳しくは公式ドキュメントを参照してください。

Sealed Classes - Kotlin Programming Language

もう少し分かりやすく言うと、1つのファイル内でだけ継承できるクラスです。

Sealed Classを使う利点

普通の(final or open)クラスと比べて

外部からの継承を制限できるので、コンパイル時に子クラスの階層が全て明らかになります。

これで何ができるかというと、when式とis演算子の組み合わせで網羅性のチェックができます。

例えば下のコードのように、通常の A を継承する B/C/D というクラスがある時に

open class A
class B : A()
class C : A()
class D : A()

このAの子クラスのインスタンスがどのクラスか判定して何か値を返す関数は

fun isSomething(obj : A) : Boolean {
  return when (obj) {
    is B -> true
    is C -> true
    is D -> false
  }
}

と書くとコンパイルエラーになる(B/C/D以外の可能性がないことを判別できない)ので、以下のように書くと思います。

fun isSomething(obj : A) : Boolean {
  return when (obj) {
    is B -> true
    is C -> true
    is D -> false
    else -> throw RuntimeException("Not reached!!!!")
  }
}

elseは(自分達が書いているコード的には)絶対通らないはずだけど、例外を投げるか何らかの値を返す必要があります。

もちろんこのコードがライブラリーにあったとして、利用者がAを継承したEというクラスを作って渡すと、このコードはelseに到達しますね。

Sealed Classにすることで、コンパイラーがこのAの子クラスはB/C/Dしかないことを知られるので、最初のコードでも問題なくなります。

こうすることで、後で自分でAを継承する別のクラスを作ると、ここに選択肢を追加しないとコンパイルが通らなくなり、処理の追加漏れがなくなります。

Enumと比べて

前の項で書いている内容は、Enumでも実現できますね。

Enumと比べてSealed Classを利用する利点は、「Sealed Classを継承するのはobjectでもclassでもよい」ということではないでしょうか。

Sealed Classの子クラスそれぞれをEnumの値のように扱うことで、「Enumの各値に可変の値を持たせるような仕組み」ができます。

SwitftだとAssociated Valueと呼ばれるものですね。

使い方

使い方は簡単で、class宣言の前にsealedと書き、子クラスを全て同じファイル内で宣言するだけです。

sealed class A
class B : A()
class C : A()
class D : A()
object Z : A()

こうすることで、クラスAはこのファイル内でしか継承できなくなります。

また、Aはabstractなクラスかつ、コンストラクターがprivateになるので、直接インスタンス生成はできなくなります。

あとは通常のクラスと同じように使います。

どういう使い方をしている?

私が実際にSealed Classを使うシチュエーションを紹介します。

決まった文字列(全てが定数ではない)を受け取りたい時

これはSealed Classの「ファイル外で継承できない」仕組みを活用する例です。

例えばAndroidアプリでGoogle Analyticsにスクリーン名を送りたい時、スクリーンは定められた値なので、大半が定数で定義可能です。

しかし、完全な定数で済めばいいのですが、ページングをしている時にパラメーターとしてページ数を送りたいかもしれません。

また、普通にStringを送る運用だとミスって意味の分からない文字列を送ってしまうかもしれません。

こういった場合にそれぞれのスクリーン用にSealed Classを継承したobjectやclassを作り、Google Analyticsの送信用のラッパーを用意して、そこで値を分解して送るとこの問題を防ぐことができます。

sealed class GAScreen() {
  abstract val screenName: String

  open class Simple(override val screenName: String) : GAScreen()

  object Top : Simple("top")
  object Settings : Simple("settings")
  object List(private val page: Int) : GAScreen() {
    override val screenName: String = "list[page=$page]"
  }
}

ラッパー側は以下のようになります。

class GoogleAnalyticsWrapper() {
  fun sendScreen(screen: GAScreen) {
    val screenName = screen.screenName
    // ここでスクリーン名を送る
  }
}

こうすることで、うっかり変な文字列を送ることがなくなります。

色々な値を受け取って表示するActivityを起動するIntentに渡すパラメーターオブジェクト

こちらは"すごいEnum"っぽく使う例ですね。

Intentに複数のパラメーターをセットしたい時、全部を別々に手で受け取るのは面倒ですよね。

そのパラメーターの中にNullableなものがあって、別のパラメーターの値によって値が入ってなかったりすると思います。

こういう時にパラメーターオブジェクトを作って、Parcelableにして渡すのが有効と考えられます。

sealed class MainScreen() {
  object Top : MainScreen()
  object Settings : MainScreen()
  class Feed(val skipTutorial: Boolean) : MainScreen()
  class Category(val category: Category) : MainScreen()
  class EntryList(val initialTab: Tab) : MainScreen()
}

これをActivity側で受け取ってwhen式を使うなどして処理してやります。

when(screen) {
  is Top -> {
    openTop()
  }
  is Settings -> {
    openSettings()
  }
  is Feed -> {
    openFeed(screen.skipTutorial)
  }
  is Category -> {
    openCategory(screen.category)
  }
  is EntryList -> {
    openEntryList(screen.initialTab)
  }
}

最後に

KotlinのSealed Classを使って堅牢な設計のアプリを作りましょう。

明日の担当は id:aereal さんです!

Androidアプリの非同期なテストを書く時の悩みをEspresso Idling Resourceで解決する #関モバ

こんにちは、最近TDDに回帰している id:takuji31 です。

最近 Espresso Idling Resource を使い始めたので紹介します。

なお、この記事の一部は本日開催の関西モバイルアプリ研究会#24で発表された内容です。

UIテストを書く時の悩み

  • 何かが切り替わる時にUIのアニメーションが発生して、その後表示が切り替わるようなものをテストしたい
  • APIからデーターを非同期に取得して、画面に表示される値が正しいかテストしたい

↑のようなテストをする時、 “テストが可能になるまで待つ” という動作を実装するのは結構面倒です。

雑な解決方法

面倒なのでよくやるのは Thread.sleep(1000) みたいな感じで十分な時間待つことですね。

ですがこの対処方法には「待ったところで実は終わってないかもしれない = テストがたまにコケる」 という問題があります。

ちょっと賢い方法

われわれはかしこいので、RxJavaなんかを使って完了を待つObservableを作ったり、完了を待ち受けるListenerを作ったりするかもしれません。

これは確実に完了まで待つことができますが

  • 複数のコンポーネントの完了を全て待つ時の実装が複雑になりがち
  • そもそもテストのためにそんなものを手で時間をかけて実装するのは本質からそれているのではないか

といった問題があります。

Espresso Idling Resource

↑のような問題を解決するのが Espresso Idling Resource です。

これはEspressoのプラグインのようなもので、アイドリングの仕組みを提供してくれます。

このライブラリーで用意されている IdlingRegistry に自前の IdlingResource を登録すれば、そのリソースがアイドリング状態になるまで、テストが先に進むのを止めてくれます。

使い方

セットアップ

build.gradleに設定

compile 'com.android.support.test.espresso:espresso-idling-resource:3.0.0'

IdlingResource

まずは IdlingResource を作ります。

fun getName(): String

このIdlingResourceの名前を返します。この名前はロギングや二重登録のチェックに使われるようです。

fun isIdleNow(): Boolean

このリソースがアイドル状態かどうかを返します。

fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?)

アイドル状態になった時に実行するCallbackを登録します。

callbackはnullableのようです。

実装

ここではめちゃくちゃ簡単にしたいので、propertyにBooleanの値をセットするだけのものにします。

class SimpleIdlingResource(private val name: String) : IdlingResource {

    private var callback: IdlingResource.ResourceCallback? = null
    var isIdle: Boolean = false
        set(value) {
            field = value
            if (value) {
                callback?.run { onTransitionToIdle() }
            }
        }

    override fun getName(): String = this.name

    override fun isIdleNow(): Boolean = isIdle

    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
        this.callback = callback
    }
}

使う

アプリ側

アプリ側で SimpleIdlingResource を使うコードを書きましょう

class ViewModel {

    var artists: List<Artist> = emptyList()

    var loadingDisposable: Disposable? = null

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    val loadingIdlingResource : SimpleIdlingResource = SimpleIdlingResource("IdlingResourceViewModelLoading")

    fun onCreate() {
        // fake API request
        fakeReload()
    }

    private fun fakeReload() {
        loadingDisposable = Single
                .timer(3, TimeUnit.SECONDS)
                .doOnSubscribe { loadingIdlingResource.isIdle = false }
                .doFinally { loadingIdlingResource.isIdle = true }
                .subscribe { _, _ -> artists = Artist.list }
    }

    fun onDestroy() {
        loadingDisposable?.run {
            dispose()
            loadingDisposable = null
        }
    }
}

APIを叩いてデーターを取得したような感じで3秒待ってリストを更新する処理を書きました。

処理の開始と終了時に loadingIdlingResource.isIdle の値を更新しています。

テストコード

テストでActivityを起動する前に IdlingRegistryIdlingResource を登録します。

@Before なメソッドでやってやるのがよいでしょう。

class IdlingResourceActivityTest {
    @JvmField
    @Rule
    val rule : IntentsTestRule<IdlingResourceActivity> = IntentsTestRule(IdlingResourceActivity::class.java, true, true)

    var initializeIdlingResource : IdlingResource? = null

    @Before
    fun setUp() {
        initializeIdlingResource = rule.activity.viewModel.loadingIdlingResource
        IdlingRegistry.getInstance().register(initializeIdlingResource)
    }
    // ...
}

テスト後には IdlingRegistry から IdlingResource を削除してやります。

@After なメソッドでやってやるのがよいでしょう。

class IdlingResourceActivityTest {
    // ...
    @After
    fun tearDown() {
        initializeIdlingResource?.run {
            IdlingRegistry.getInstance().unregister(this)
            initializeIdlingResource = null
        }
    }
}

テストは普通にEspressoで書きます。

class IdlingResourceActivityTest {
    @Test
    fun testReload() {

        // remove 3 items
        repeat(3) {
            openActionBarOverflowOrOptionsMenu(rule.activity)
            onView(withText("Remove first")).perform(click())
        }

        onView(withId(R.id.swipeRefreshLayout)).perform(swipeDown())

        onView(withId(R.id.recyclerView)).check { view, _ ->
            val recyclerView = view as RecyclerView
            assertEquals(recyclerView.childCount, Artist.list.size)
            assertEquals(recyclerView.adapter.itemCount, Artist.list.size)

        }
    }
}

用意されているMatcherやViewActionを使わない場合は Espresso.onIdle() を呼ぶとアイドル状態になるまで同期的に待ってくれます。

デフォルトで用意されているIdlingResource

CountingIdlingResource

セマフォっぽいやつ、 increment()/decrement() を呼んでカウントが増減し、0ならアイドルになる、1以上ならアイドルじゃないとなるようです。

CountingIdlingResource | Android Developers

IdlingScheduledThreadPoolExecutor / IdlingThreadPoolExecutor

ThreadPoolExecutor、IdlingScheduledThreadPoolExecutorは遅延実行できる。

IdlingScheduledThreadPoolExecutor | Android Developers

UriIdlingResource

UriをキーにできるIdlingResource、ドキュメントを見た感じWebViewと使うことを想定してるっぽい?

UriIdlingResource | Android Developers

ハマりどころ

Proguardを有効にした状態でテストを実行すると、IdlingRegistryがなくてコケる。

Proguardの設定を書きましょう。

-keep class android.support.test.espresso.** {
    public *;
}

最後に

割と簡単に実装できるので、Thread.sleepやめてIdlingResource使いましょう。

KotlinでJavaの予約語をメソッド名に使うとstubでメソッドが生成されない

ここで言うstubとは、Kotlinのstub生成機能で、Pluggable Annotation ProcessingでKotlinのクラスを処理するために必要になってくるもの。コンパイル時にJavaコンパイル前にKotlinのファイルからJavaソースコードを生成してstubとして利用する。

当たり前といえば当たり前なんだけど、Kotlin側ではメソッドが作れてしまうのでうっかり作ってしまってハマりそう

@Module
object SharedPreferencesModule {
  @Provides @JvmStatic fun default(context: Context) : SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
}

これのstubはこうなる(一部省略)

@dagger.Module()
public final class SharedPreferencesModule {
    public static final com.github.takuji31.di.SharedPreferencesModule INSTANCE = null;
    private SharedPreferencesModule() {
        super();
    }
}

何もない😇

↓のようにするとよい

@Module
object SharedPreferencesModule {
  @Provides @JvmStatic fun defaultSharedPreferences(context: Context) : SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
}

こうなる

@dagger.Module()
public final class SharedPreferencesModule {
    public static final com.github.takuji31.di.SharedPreferencesModule INSTANCE = null;
    
    @org.jetbrains.annotations.NotNull()
    @dagger.Provides()
    public static final android.content.SharedPreferences defaultSharedPreferences(@org.jetbrains.annotations.NotNull()
    android.content.Context context) {
        return null;
    }
    
    private SharedPreferencesModule() {
        super();
    }
}

JSR330のQualifierを付与したAnnotation classを作る場合はちゃんとTargetを指定した方がよい

Kotlin+Dagger2で起きた問題、おそらくJavaではほとんど起こらないだろう問題ではあるが全く起きないわけではなさそう。

前提

Dagger2(に限らずJSR330に準拠したDIコンテナー)で目的の違う同じ型のインスタンスを注入したい場合には @Named を使うか、 @Qualifier アノテーションを付与したアノテーションを定義してやる必要がある。

Dagger2の場合、Moduleの @Provides を付与した依存提供用のメソッドに @Qualifier を付与したアノテーションを付与する

data class User(val name: String)

@Qualifier
annotation class PrimaryUser

@Qualifier
annotation class SecondaryUser

class Module {
  @Provides
  @PrimaryUser
  fun providePrimaryUser() : User = User("takuji31")

  @Provides
  @SecondaryUser
  fun provideSecondaryUser() : User = User("takuji24884")
}

この提供されている依存をpropertyに注入する時、次のように書きたくなるはずである。

class UserActivity : AppCompatActivity() {
  @Inject
  @PrimaryUser
  lateinit var primaryUser: User

  @Inject
  @SecondaryUser
  lateinit var secondaryUser: User
}

こうすることで、fieldがJavaからはpublicに見えるので(internalにしてpackage privateにした方がよいかもしれないが)、Dagger2のfield injectionが使えるはず。

ところがこれをビルドすると、どちらも依存が解決できないと言われてエラーになる。

何が起こっているか

Kotlinのpropertyは基本的に backing field + getter + setter が組み合わさってできている。

そこで、propertyにアノテーションを付与するとコンパイラーはどこに指定すればいいか分からず、getterにアノテーションを付与するのである。

getterの次にfield、最後にsetterになるようだ。

この結果はstub生成の様子を見ると分かる build/tmp/(kapt|kapt3)/stub)

今回はfield injectionを想定しているので、fieldに @PrimaryUser@SecondaryUser が付与されている必要があるのだが、これだとfieldではなくgetterにアノテーションが付与されてしまうので、依存が解決できずビルドエラーになるのだ。

回避策

もしこのアノテーションがライブラリーで用意されているものだとしたら、リポジトリに修正のPRを投げつつ以下のようにするとよいだろう。

class UserActivity : AppCompatActivity() {
  @field:[Inject PrimaryUser]
  lateinit var primaryUser: User

  @field:[Inject SecondaryUser]
  lateinit var secondaryUser: User
}

こうすることで明示的にfieldにアノテーションを付与できる。

根本的な解決

そもそもQualifierを付与したアノテーションを付与できる先を限定しておくべきである(意図しないところで使われる可能性がある)ので、そのために @Target を指定しておくべきである。

@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION)
@Qualifier
annotation class PrimaryUser

ここで使っている @Target は Kotlin側で用意されているもので、Javaのものより柔軟にターゲットを指定できる。

この場合はフィールド、メソッドやコンストラクターの引数、関数(メソッド、setter、getter)そのものに指定できるようになる。

なお、AnnotationTargetの指定順に付与される模様?

最後に

今回はQualifierに限ったような書き方になったが、基本的にアノテーションのターゲットはちゃんと指定してやるべきだろう。

ExifInterface Support Libraryについて調べてみた #関モバ

こんにちは、最近カメラにハマっているid:takuji31です。

なお、この記事の一部は本日開催の関西モバイルアプリ研究会#21で発表された内容です。

Exifについて

ExifはExchangeable image file formatの略で、写真のメタデータを画像に埋め込む仕組みでJPEGTIFFに使われます。

Exchangeable image file format - Wikipedia

記録内容は

など多岐にわたる一方で、GPSの情報がそのまま記録されることもあり個人情報の漏洩リスクも懸念されています。

最新版のバージョンは2.31で、CIPAのWebサイトから確認できます。

CIPA 一般社団法人カメラ映像機器工業会: CIPA規格類

http://www.cipa.jp/std/documents/j/DC-008-2016-J.pdf

Exif Support Library

Exif Support LibraryはExifの情報の操作を行えるSupport Libraryです。

Support Library 25.1.0から追加されました。

ExifInterafce自体はAPI5からあるのですが、対応しているタグがバージョンごとに違っていたりするので、Support Libraryが用意されたようですね。

対応フォーマット

以下の画像形式に対応しています。

有名どころのファイル形式は一通り対応していますね。なぜかTIFFには対応していなさそうです。

使ってみる

ExifInterface Support Libraryには ExifInterface というクラスだけが含まれています(正確にはprivateなinnerクラスがいくつか含まれていますが)。

ExifInterfaceインスタンスを作成して、いくつか用意されているメソッドを使って操作する感じですね。

  • getAttribute
  • getAttributeInt
  • getAttributeDouble

それぞれ引数に属性の名前を指定するのですが、名前は定数で用意されています。以下はその一部です。

  • TAG_DATETIME
  • TAG_MODEL
  • TAG_ORIENTATION
  • TAG_APERTURE_VALUE
  • TAG_SHUTTER_SPEED_VALUE
  • TAG_ISO_SPEED_RATINGS
  • TAG_FOCAL_LENGTH
  • TAG_GPS_LATITUDE

試しに先日買ったニコンのD500で撮影した写真の情報を抜き出してみました。

f:id:takuji31:20161226195750p:plain

  • RAWのサムネイルの色がぶっ壊れてる
  • 絞り値とシャッター速度が取れない

😇

まとめ

AndroidExifいじろうぜ!

サンプルコードはこちらです。

github.com

Atomの自動補完プラグイン「autocomplete-plus」のPerl用Providerを書いている話

これは はてなエンジニアアドベントカレンダー2016 23日目の記事です。

qiita.com

developer.hatenastaff.com

昨日は id:takuya-a さんの文字列アルゴリズムの学びかたでした。

こんにちは、はてなでアプリケーションエンジニアとしてWebサービスAndroidアプリ(たまにiOSアプリ)を開発しているid:takuji31です。

私は普段VimPerlを書いているのですが、最近AtomPerlを書きたくなってAtomの自動補完プラグインである「autocomplete-plus」のPerl用Providerを書いているので、今日はそのことについて話します。

先に謝罪しておきますと、本来はこのエントリーで公開しましたと告知する予定でしたが、まだ実用的なクオリティーには達していないので、開発中とお知らせするだけになります 🙇

github.com

Atomとは

Atomとは(恐らくこの記事を読むような方はご存知だとは思いますが)、Githubが公開しているオープンソーステキストエディターです。

atom.io

A hackable text editorとある通り、プラグインを書くことでかなり自由にHackすることができます。

autocomplete-plus

autocomplete-plusはAtomの自動補完プラグインです。

github.com

最近のバージョンのAtomに標準でバンドルされていて、インストールするだけで様々な言語のコード補完を行うことができます。

また、標準で用意されている言語以外にも、Providerを用意することで自分でコード補完の候補を作ることができます。

既に存在するProviderの一覧はGitHubのautocomplete-plusのWikiに一覧があります

github.com

見てみるとPerl用がないですね、ググってみた感じもないようでしたので、これは作るしかないと思いました。

Providerを作る

雛形を自動生成する

ProviderはAtomのPackageとして作る必要があります。

Atomでは開発用にPackageを生成してくれるメニューがあります、メニューの Packages -> PackageGenerator -> Generate Atom Package を実行しましょう。

コマンドパレットを開いて Package Generator: Generate Package を探して実行してもよいです。

f:id:takuji31:20161222183702p:plain

実行するとPackageの作成先を指定するダイアログが出ますので、適当な名前を決めて入力します。

決定すると指定したパスにサンプルコードと共にPackageが生成されます。

hatena-advent-calendar-2016/
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── keymaps
│   └── hatena-advent-calendar-2016.json
├── lib
│   ├── hatena-advent-calendar-2016-view.js
│   └── hatena-advent-calendar-2016.js
├── menus
│   └── hatena-advent-calendar-2016.json
├── package.json
├── spec
│   ├── hatena-advent-calendar-2016-spec.js
│   └── hatena-advent-calendar-2016-view-spec.js
└── styles
    └── hatena-advent-calendar-2016.less

以下のファイルとディレクトリはProviderには不要なので消します。

  • keymaps
    • このPackageのキーマップを定義するファイル
  • menus
    • メニューのエントリーを定義するファイル
  • styles
    • Packageのスタイルを定義するファイル
  • lib/*.js
    • サンプルコードですが(Providerでは)一切使わないので消します。

Packageに必要な設定

不要なファイルを整理したら、 package.json の一番上の階層に以下の設定を追加します。

{
  "providedServices": {
    "autocomplete.provider": {
      "versions": {
        "2.0.0": "provide"
      }
    }
  }
}

provide の部分はJS側で定義するメソッド名なので、自由に決めて構いません。

Packageのメインオブジェクトを作る

lib 以下のPackage名と同じjsファイルからexportしたオブジェクトがプラグイン機構の本体になります。

JavaScript以外にもCoffeeScriptも直接使うことができますが、私は業務でTypeScriptを使っていることもあり、TypeScriptで書いています。

/// <reference path="../typings/bundle.d.ts" />

import {Config} from "./config";
import {Provider} from "./provider";
import {UseAndRequireCompletionProvider} from "./use-and-require-completion-provider";

class AutocompletPerlProvider {
  providers: Provider[] = null
  config =  Config.config
  activate() {

  }
  deactivate() {
    this.providers = null
  }
  provide() {
    if (this.providers === null) {
      this.providers = [new UseAndRequireCompletionProvider()];
    }
    return this.providers
  }

}
export = new AutocompletPerlProvider()

このように provide メソッドの中で実際の補完候補を提供するProviderのインスタンスを作って返してやります。Providerは複数指定可能です。

設定項目を追加

メインのオブジェクトに config フィールドを追加することで、そこに定義されている設定項目がAtomの設定画面に自動的に追加されます。

先ほどのコードでは config フィールドの中身は Config.config を参照しているので、その中身を見てみましょう。

class Config {
  static config = {
    perlPath: {
      type: 'string',
      default: 'perl'
    },
    cartonPath: {
      type: 'string',
      default: 'carton'
    },
    useCarton: {
      type: 'boolean',
      description: 'Use carton when building completion suggestions. only effects when cpanfile and carton executable exists.',
      default: false,
    }
  }
  static get perlPath():String {
    return atom.config.get('autocomplete-perl.perlPath');
  }
  static get cartonPath():String {
    return atom.config.get('autocomplete-perl.cartonPath');
  }
  static get useCarton():boolean {
    return atom.config.get('autocomplete-perl.useCarton');
  }
}
export {
  Config
}

上記のコードでは3つの設定項目が指定されています。

このような定義を作成することで、Atomの設定画面にあるPackageの設定項目が自動的に生成されて以下のようになります。

f:id:takuji31:20161223090629p:plain

このコードでは設定の定義以外にも、設定値を取り出すのに便利なプロパティーを定義しています。このようにしておくと、実際のコード側で簡単に取り出せて便利でしょう。

Providerを作る

Providerは決まったメソッドとフィールドを持ったクラスである必要があります。

その仕様については、autocomplete-plusのWikiにまとめられています。

Provider API · atom/autocomplete-plus Wiki · GitHub

最低限 selector フィールドと getSuggestions メソッドがあればProviderとしては機能します。

/// <reference path="../typings/bundle.d.ts" />

import {Provider, SuggestionInfo} from "./provider";
import {ISuggestion} from "./suggestion";
import {Range, Point} from "atom";

class UseAndRequireCompletionProvider extends Provider {
  selector : string = ".source.perl"
  getSuggestions(info : SuggestionInfo) : Promise<ISuggestion> {
    return new Promise((resolve) => {
        var suggestions =  // ここで補完候補を生成する
        resolve(suggestions)
    });
  }
}
export {
  UseAndRequireCompletionProvider
}

atom-autocomplete-perl では型でこの辺りの制約をはっきりさせておきたかったので、ベースのクラスやインターフェイスを別ファイルで定義しました。

provider.ts

/// <reference path="../typings/bundle.d.ts" />
import {ISuggestion} from './suggestion';

interface SuggestionInfo {
  editor: AtomCore.IEditor;
  bufferPosition: TextBuffer.IPoint;
  scopeDescriptor: AtomCore.ScopeDescriptor;
  prefix: string;
  activatedManually: boolean;
}

abstract class Provider {
  selector : string = ".source.perl"
  abstract getSuggestions(info : SuggestionInfo) : Promise<ISuggestion[]>
}
export {
  Provider,
  SuggestionInfo
 }

suggestion.ts

/// <reference path="../typings/bundle.d.ts" />

type SuggestionType = 'variable'|'constant'|'property'|'value'|'method'|'function'|'class'|'type'|'keyword'|'tag'|'snippet'|'import'|'require';

interface ISuggestion {
  displayText?: string
  replacementPrefix?: string
  type?: SuggestionType;
  leftLabel?: string
  leftLabelHTML?: string
  rightLabel?: string
  rightLabelHTML?: string
  className?: string
  iconHTML?: string
  description?: string
  descriptionMoreURL?: string
}

class TextSuggestion implements ISuggestion {
  constructor(public snippet: string, public type: SuggestionType) {
  }
}
class SnippetSuggestion implements ISuggestion {
  constructor(public text: string) {

  }
}

export {
  ISuggestion,
  TextSuggestion,
  SnippetSuggestion,
  SuggestionType
}

この定義のおかげで、簡単にProviderを増やすことができますね。

あとはひたすら getSuggestions に渡ってくる入力の情報から非同期に補完候補を生成してresolveするだけです。

とは言うものの、今の時点でまだこれは使っていません、なぜなら簡単なキーワードだけなら自動的に補完候補を生成してくれる便利APIがあるのです。

SymbolProvider Config API

簡単なキーワードなら、SymbolProvider Config API を使うことで補完候補を生成できます。

SymbolProvider Config API · atom/autocomplete-plus Wiki · GitHub

たとえばPerlのビルトイン関数は settings/language-perl.cson に以下のように書くだけです。

'.source.perl':
  autocomplete:
    symbols:
      builtin:
        suggestions: [
          'abs'
          'accept'
          'alarm'
          'atan2'
          'bind'
          'binmode'
          'bless'
          // ...
          'waitpid'
          'wantarray'
          'warn'
          'write'
          'y'
        ]

これだけであとは勝手に補完できるようになります。

f:id:takuji31:20161223093650p:plain

ビルトイン関数は最初Providerを作って補完できるようにしていましたが、こちらの設定に変えました。

atom-autocomplete-perl の今後について

このProviderは、今のところビルトイン関数の補完だけ動きます。

今後以下のような機能の提供を予定しています。

  • package名補完
  • クラスメソッド補完
  • (構想段階ですが) Smart::ArgsData::Validator でバリデーションした変数の型を雑に推測してそれっぽい補完候補を返す

ひとまずpackage名補完だけ追加したら、全国のPerl Mongerの皆様が利用できる状態にしたいなぁと思っています。

まとめ

AtomのPackageは簡単に作ることができました。node.jsを使うことができますので、npmにある大量の資産を活かしたり、他の言語のコードをシステム経由で実行することもできます。

あなたもこの機会にAtomをHackしてみませんか?

はてなではJavaScriptやAltJSが好きなエンジニアを募集しています!

hatenacorp.jp

明日の担当は id:tarao です、お楽しみに!