Crystal 言語で CLI ツールを書いてみる

f:id:rhysd:20151005225303p:plain

Crystal はつくりかけの言語です.この記事は 0.8.0 を元に書きましたが,今後かなり変更されることが予想されます

CrystalRuby に強くインスパイアされたコンパイル言語です.結構前から気になっていて,Crystal のコンパイラRuby で書かれていた頃(今はセルフホストしています)から見ていて最近はぼちぼちコントリビュートしたりしてます.インスパイアされているというだけで,色々 Ruby と違うところもあります(例えばいま話題の文字列は Crystal では全て immutable です)

僕は Ruby が好きなので,ちょっとしたツールや書き捨てのスクリプトは特に理由が無い場合は Ruby で書いています.それもあって Crystal でちょっとしたツールを書くのは次のような利点があります.

  • Ruby と同じように楽に(そして雑に)書ける
  • Ruby とは違い実行速度が速い(まだあまり実感してないですが)
  • Ruby とは違いコンパイル時に型チェックが入る(各式が取り得る型の union type で型付けします)
  • Ruby とは違いバイナリファイル1つになる(共有ライブラリなどを使わなければ)

コマンドラインツールCrisp (Crystal で書かれた Lisp 処理系)や crdocコマンドラインから Crystal のドキュメントを検索&開くツール)をつくってみたので,今回は自分なりのやり方の手順をまとめてみます.

といっても大体 Ruby と同じですが…

準備

Go 言語における go コマンドのように,Crystal にも crystal コマンドがあります. 公式のインストールページ に従ってインストールします.残念ながらまだ Windows では利用できません(対応はぼちぼち進んでいるようです).

次に開発環境を入れます.お好みに合わせて導入してください.(もし需要があれば開発ツール周りもブログに書きたい.)

リポジトリ生成

試しにファイルの中身を表示するだけのコマンド mycat をつくってみます.

まずは crystal init コマンドで初期のリポジトリを生成します.

$ crystal init app mycat

次のようなディレクトリ構成が自動でつくられます.

--- mycat
     |--- .gitignore
     |--- LICENSE
     |--- README.md
     |--- .travis.yml
     |--- shard.yml
     |--- src
     |     |--- mycat.cr
     |     |--- mycat/version.cr
     |
     |--- spec
     |     |--- spec_helper.cr
     |     |--- mycat_spec.cr
     |
     |--- .gitignore

僕はコマンドラインツールを書く時はなるべくライブラリとしても使えるようにつくるので,src/mycat.crAPI だけ書いて,bin/mycat.cr にコマンド実行処理を書きます.そのほうがテストもしやすいです.

最終的にはこんな感じです.

--- mycat
     |--- .gitignore
     |--- LICENSE
     |--- README.md
     |--- .travis.yml
     |--- shard.yml
     |--- bin
     |     |--- mycat.cr
     |
     |--- src
     |     |--- mycat.cr
     |     |--- mycat/version.cr
     |
     |--- spec
     |     |--- spec_helper.cr
     |     |--- mycat_spec.cr
     |
     |--- .gitignore

ドキュメントを書く

最初の時点で脳内で決まっている範囲で簡単なドキュメント(intro,コマンドの引数など)を README.md に書きます.生成された README.mdスニペットになっているので,GitHub のユーザ名などを自分で埋めます.

また,(なぜか)LICENSE が MIT で決め打ちになっているので,必要があれば修正します.

shard.yml を書く

npm の package.json と同じように,パッケージ情報を shard.yml に書きます.これは半標準(いずれ本体に取り込まれる?)のパッケージマネージャ shards を使う前提で生成されています.

name: mycat
version: 0.0.1

authors:
  - 名前 <mail@address>

license: MIT

依存するパッケージが出てきた時はここに書きます.

スペックを書く

まずは mycat ディレクトリでおもむろに crystal spec と実行してみます.

次のように失敗すると思います.

F

Failures:

  1) Mycat works
     Failure/Error: false.should eq(true)

       expected: true
            got: false

     # ./spec/mycat_spec.cr:7

Finished in 0.55 milliseconds
1 examples, 1 failures, 0 errors, 0 pending

Failed examples:

crystal spec ./spec/mycat_spec.cr:6 # Mycat works

それも当然です.spec/mycat_spec.cr は次のようになっています.

require "./spec_helper"

describe Mycat do
  # TODO: Write tests

  it "works" do
    false.should eq(true)
  end
end

ここで実装を考えながら必要なメソッドのテストを書きます.ファイル名を受け取って IO に出力に出すメソッド meow を持ったクラス Cat をつくることにするとこんな感じでしょうか.test.txt は spec ファイルと同じディレクトリに適当につくります.__DIR__ はそのファイルの親ディレクトリを表す大域定数(みたいなもの)です.

require "./spec_helper"

describe Mycat do
  describe Mycat::Cat do
    describe "meow" do
        it "writes the content of file to specified IO" do
        io = StringIO.new
        c = Mycat::Cat.new io
        c.meow "#{__DIR__}/test.txt"
        io.to_s.should eq("foo\nbar\nbaz\n")
        end
    end
  end
end

まだ実装が無いのでもちろんこれも失敗します.

実装を書く

require "./mycat/*"

module Mycat
  # TODO Put your code here
end

最初はこうなっていると思います.

require "./mycat/*"

module Mycat
  class Cat
    def initialize(@mouse)
    end

    def meow(file_name)
      File.open file_name do |f|
        @mouse << f.read
      end
    end
  end
end

できました.受け取った IO にファイルの中身を吐くだけなので簡単ですね.コンストラクタの引数のように,直接インスタンス変数名を指定してその変数に代入することができます.(コンストラクタ以外のメソッドでも使えます) 試しにビルドしてみましょう.crystal build コマンドを使います.

$ crystal build src/mycat.cr

問題が無ければ,先ほどと同じ方法で spec を実行します.今度はテストが通ると思います.

コマンド実行処理をつくる

bin/mycat.cr を実装します.

require "../src/mycat"
require "option_parser"

begin
  OptionParser.parse! do |parser|
    parser.banner = "Usage: mycat file_name"
    parser.on("-v", "--version", "Show version") { puts Mycat::VERSION; exit 0 }
    parser.on("-h", "--help", "Show this help") { puts parser; exit 0 }
  end

  cat = Mycat::Cat.new STDOUT
  ARGV.first?.try do |arg|
    cat.meow arg
  end
rescue e
  STDERR.puts e
  exit 1
end

オプションの実装は Ruby でもおなじみの OptionParser が使えます.今回はバージョンとヘルプだけをパースしています.

Enumerable#first? はリストの最初の要素があればそれを返し,無ければ nil を返します.Object#tryRuby ではお馴染みの,nil で無い値で実行すればブロック部分が実行されるメソッドです.例えばここで間違えて cat.meow ARGV.first? など書くとコンパイラエラーになってくれます.(コンパイラCat#meow(f: String) に推論したのに String? な値を渡しているため)

試しに実行してみましょう.crystal コマンドはデフォルトでファイルを直接実行する(実際はコンパイルして一時ファイルを生成しそれを実行する)ので,下記のようにします.crystal コマンドで実行されるプログラム側に引数を与えるには -- オプションの後に置く必要があります.

$ crystal ./bin/mycat.cr -- README.md
$ crystal ./bin/mycat.cr -- -v
$ crystal ./bin/mycat.cr -- --help

正しく動いていることが確認できたらリリースビルドで最適化されたバイナリを作成します.

$ crystal build --release ./bin/mycat.cr

カレントディレクトに mycat というバイナリファイルができていると思うので,それをパスの通ったところに置くなりします.

Travis での CI

デフォルトで .travis.yml が生成されますが,中身はほぼ空っぽです. 次のようにテストスクリプトを書きます. Crystal の CI 環境は Crystal コミュニティによって提供されているので,こちらで頑張って用意する必要はありません.

language: crystal
script:
    - crystal spec
    - crystal build ./bin/mycat.cr

まとめ

プログラミング言語 Crystalコマンドラインツールを書く自分なりの手順をまとめました.まだ開発途中の言語なのでこれを使ってでかいツールを…とはなかなかいかないかもしれませんが,僕みたいに Ruby 好きな人は書いてみるとハードルも低く新鮮で楽しいかもしれません.

GitHub のトレンドリポジトリを見逃さない,Trendy をつくりました

僕が1日に1回ぐらいの頻度で見ているページの中の1つに GitHubTrending repositories のページがあります.このページには言語ごとに日毎・週毎・月毎の単位で GitHub 上で人気のリポジトリがランキング形式で表示されます.

話題になっているライブラリやソフトウェアの一次ソースとして便利なのですが,微妙にアクセスが悪い位置にあり,言語ごとにしか見られません.また,ランキングには常に人気な「常連」リポジトリが多々いるので,新しく話題になっているリポジトリはその中に埋もれがちになってしまいます.

そこで,今回はこれらの問題を解決すべく,GitHub のトレンドクライアント Trendy を Electron ベースでつくりました.

Trendy - Menubar App to Keep You in the Trend

logo

TrendyGitHub のトレンドページを監視し,トレンドに新しいリポジトリが現れた時に通知したり,過去にトレンドに挙がったリポジトリを管理して後から検索したりできます.メニューバーの中に常駐するので,いつでもメニューバーからアクセスできます.

README を書いたり,今回は勉強もかねて Landing Page 書いたりしましたが,せっかくなので簡単に紹介します.

インストール

GitHub のリリースページ からお使いの OS に合うファイルをダウンロードできます.OS XTrendy.app が入っているので,それをそのまま使ってください.Windows の場合は trendy.exe を,Linux の場合は trendy という executable があるのでそれらをそれぞれ使ってください.インストールは不要なので好きな場所に置いて OK です.

ファイルを実行すると,Trendy のメニューバーアイコンが出ると同時に初回は監視したいトレンドを選択するウィンドウが立ち上がります.

lang picker

上にあるフォームでインクリメンタル検索ができるので,気になる言語をいくつか選んだら Go! ボタンを押します.

Trendy が各トレンドページをスクレイピングして GitHub API を使ってリポジトリの情報を収集した後,メニューバーの中にあるアイコンが赤くなると思います. それ以降は1時間に1回のペースでトレンドページを見に行って新着リポジトリを探します.

使い方

main screen shot

'New' タブ

新着のリポジトリが表示されます.チェック済みのリポジトリはマウスオーバーすると左の方に表示されるでかいチェックマークボタンを押すと 'New' タブから削除されます.

'Current' タブ

現在のトレンドページのランキングです.

'All' タブ

Trendy が今まで出てきた収集したすべてのリポジトリ情報一覧です.

通知機能

Trendy は1時間に1度スクレイピングを行い,新しいリポジトリを見つけた時はメニューバーのアイコンの色を変えて通知します.緊急性のある通知ではないので,この程度の目立たなさの通知が一番気に入っています.

通知時 通常時
notified menubar normal menubar

組み込みブラウザ

リポジトリリポジトリ作者のページなどへのリンクをクリックすると,外部のブラウザではなく,モバイルアプリのようにその場でブラウザを開きます.開くページは,ウィンドウが小さい場合はモバイルページ,大きい場合は PC ページになります.

サイドメニュー

右上の3本線ボタンを押すとサイドメニューが開きます.メニュー内では次の3つの機能が使えます.

  • 検索フォームからリポジトリを検索できます.検索ワードを入れて Enter を押すと,現在のタブ内のリポジトリが絞り込み検索されます.
  • 現在のタブをトレンドでフィルタリングすることができます.普段はすべてのトレンドページの結果がタブ内に表示されています.
  • 最下段では設定ファイルを開いたり,手動で更新したり,アプリを終了したりできます.

メニューウィンドウとノーマルウィンドウ

ここまで Trendy はメニューバーに統合されたアプリだと紹介しましたが,普通のウィンドウで使うことも出来ます.

isolated window

Windows ではタスクバーが下に無いとメニューウィンドウの位置がおかしくなってしまうので,デフォルトでこの設定になっています. 後述する config.json を書き換えるとメニューウィンドウとノーマルウィンドウを切り替えられます.

ノーマルウィンドウ時は横幅が充分あることを想定して,サイドメニューは常に表示するようになっています.

GitHub API リミット

スクレイピングは対象ページの構成に依存して追従しないといけないため,極力スクレイピングに頼らないような設計になっています.Trendy では最低限の情報(リポジトリ名と作者名)だけスクレイピングで収集し,リポジトリのその他の情報は GitHub API を使っています.

ログインしていない場合は1時間に60回しか API を呼べないため,スクレイピング対象が少し多いとこの制限にひっかかってしまいます.その場合はウィンドウ内に赤いアラートダイアログが表示され,ログインを促されます.

API limit error

'login' リンクをクリックするとログインウィンドウが開くので,ログイン情報を入力してログインしてください.2段階認証にも対応しています.これで API リミットが 60 から 5000 まで増えるので,上記エラーは出なくなるはずです.

カスタマイズ

上記の2タイプのウィンドウを含め,いくつかの項目がカスタマイズ可能です.詳しくは README に書きました.

Electron + TypeScript + React

TypeScript 1.6 が beta になった頃につくりはじめたので,今回は renderer プロセス側も main プロセス側も両方 TypeScript でつくってみました.使ってみたうえでの感想は Qiita のほうにも簡単に書きましたが,

Qiita - TypeScript1.6 + React 書いてみてハマったポイントとか

メソッド名やキー名の間違いなど,うっかりミスを大体拾ってくれるので概ね良い感じです.

まとめ

GitHub のトレンドページクライアント Trendy をつくりました.

元は 最強のTwitterクライアント戦争 というのでぼちぼちアプリをつくっていて,ちょっと息抜きに前々から困っていたトレンドページを改善するか程度の気持ちだったのですが,なんだかんだで時間がかかってしまいました…

OS XUbuntu で使っている感じでは,今のところ問題なく動いているようです.よければお試しください.

GitHub のアレコレを補完する github-complete.vim をつくりました

GitHub のユーザ名やリポジトリ名,絵文字,リンクURLを GitHub API を使って補完する github-complete.vim をつくりました.

https://github.com/rhysd/github-complete.vim

Vim では Markdown 編集中のオムニ補完は HTML のものになっていて使わないので,どうせなら GitHub のアレコレが補完できれば便利だなと思ってつくってみました. 5カ月前ぐらいに8割方できていたんですが,最後のリンク URL 補完を完成させずに放置してしまっていたので,yokohama.vim #6 で完成させました.使えるのは下記の5種類の補完です.

  • 絵文字補完
  • ユーザ名補完
  • リポジトリ名補完
  • issue 番号補完
  • リンクURL補完

markdown および gitcommit ファイルタイプでのオムニ補完として実装されていますので,Markdown なファイルや git のコミットメッセージの編集中に <C-x><C-o> (Ctrl + x → Ctrl + o)で補完を発動できます.

また,一応 neocomplete.vim のソースも同梱されており,g:github_complete_enable_neocomplete1 をセットすることで使えますが,自動補完で大量の API 呼び出しが発生すると思うので呼び出し制限にひっかかる恐れがあります.

せっかくなので各補完を簡単に紹介します.

絵文字補完

emoji completion

":" の後にカーソルがある時に補完できます.GitHub の絵文字が補完できます.スクリーンショットでは絵文字が表示できていますが,これは Terminal.app が対応しているからで,それ以外の環境では表示できません.(現状 Vim の制限.gVim はフォント設定で何とかなる?)

ただ,これだけだと OS X + ターミナルでないと補完候補が分かりにくかったので,日本語での説明を頑張って追加しました.g:github_complete_emoji_japanese_workaround を 1 にセットすると,このように候補に日本語の説明が付きます.

Japanese workaround

ユーザ名補完

user name completion

"@何か" か "github.com/何か" の後ろにカーソルがある時に "何か" をユーザ名として検索して補完できます.僕のようにツイッターGitHub でユーザ名が違う場合など,ユーザ名思い出せない時に便利です.

リポジトリ名補完

repo name completion

"ユーザ名/何か" の後ろにカーソルがある時に "何か" をリポジトリ名として補完します.上のユーザ名と合わせて,GitHub の URL をユーザ名,リポジトリ名と順に補完することでリポジトリ名とか覚えていなくてもブラウザを開いて確認する必要が無くなります.

Issue 番号補完

issue number completion

"#番号" で参照できる issue/pull request 番号ですが,活発なリポジトリだとよく忘れます.そこで,"#" の後ろにカーソルがある時に補完できるようにしてみました.番号と一緒に issue のタイトルも表示されます. コミットから issue を閉じるためにタイトルに Close #1 とか書くことも多いかと思いますが,issue 番号を忘れてもその場で補完できるようになります.

リンクURL補完

link completion

[何か]( の後ろにカーソルがあるときに "何か" で GitHub を検索した結果の URL を候補として補完できます. 記事内やコミットメッセージで使ったライブラリや紹介したいツールなどのリポジトリへのリンクを書く時に,リンクタイトルにそのライブラリやツールの名前を書いておけば URL が分からなくても補完できます(特に作者名はよく忘れますね…). Vim 関連の記事を書く時に [プラグイン名](プラグイン URL) とかよく使うので,そこで重宝しています.

まとめ

GitHub のアレコレを補完できる github-complete.vim をつくってみました.

今この記事を書いている時もリンク補完などを使っていますが,補完速度も1〜2秒程度で手動補完で使う分にはかなり良い感じです.もしよろしければお試しください.