Rust 公式 linter の clippy に新しいルールを実装した

Rust 公式の linter,clippy に新しいルールを足すプルリクを出してマージされた時のメモです.

github.com

dbg! マクロ

Rust 1.32 で dbg! というマクロが追加されました.

これは値を1つ引数にとってその値を返すマクロで,受け取った値とソースコード上での位置を print します.

fn factorial(n: u32) -> u32 {
    if dbg!(n <= 1) {
        dbg!(1)
    } else {
        dbg!(n * factorial(n - 1))
    }
}

dbg!(factorial(4));

名前の通り,いわゆる print debug 用途のマクロなので,デバッグが終わってリポジトリに commit する前にはコードから dbg! マクロを削除しておくのがベストプラクティスです. 公式ドキュメントにも

Note that the macro is intended as a debugging tool and therefore you should avoid having uses of it in version control for longer periods.

と書いてあります.

ちょっとしたデバッグに非常に便利なのですが,値を move で受け取ってその値を返すだけなのでうっかり消し忘れるとテストを壊すこともなく気付けないケースがあることに気付きました.

dbg_macro ルール

そこで,コード内の dbg! マクロを検出できる dbg_macro ルールをつくりました.

#![deny(clippy::dbg_macro)]

fn main() {
    dbg!(42);
}

のようなコードに対して,

cargo clippy -- -W clippy::dbg_macro

のように実行すると

error: `dbg!` macro is intended as a debugging tool
 --> foo.rs:4:5
  |
4 |     dbg!(42);
  |     ^^^^^^^^
  |
help: ensure to avoid having uses of it in version control
  |
4 |     42;
  |     ^^

のように警告を出します.

デフォルトで有効になっているとデバッグ中にエディタが警告しまくってうるさいので,restriction カテゴリでデフォルトは無効になっています. restriction カテゴリは README には載っていませんが,「unimplemented! を使わない」や今回のルールのような特定の場面(production 前のチェックなど)で有効なルールや,「mem::forgetDrop を実装した型で使わない」といった万人向けではないキツめのルールが登録されています.

clippy のリポジトリを restriction で検索するとざっと見渡すことができます.

新しいルールの提案と追加

まずは issue で「こういうルールあると良いと思う」と提案し,特に反対もなくメイン開発者の upvote も付きました.'good first issue'(初めて contribute する人が取り組むと良い易しい issue)のラベルがついたので,自分で実装してみて,プルリクを出しました.あとは一般的なプルリクと同じで何度かレビューしてもらって OK が出てマージという流れでした.

clippy の実装

基本的にはまず CONTRIBUTING.md を読めば大体分かるようになっており,必読です.

ディレクトリ構成

  • src/*: clippy のコマンドライン部分とドライバ(実行の前段部分)の実装のみ
  • clippy_lints/: linter の各ルールの実装
    • clippy_lints/lib.rs: ルールすべてを import して登録しているところ
    • clippy_lints/*.rs: ルールの実装(lib.rs 以外)
    • clippy_lints/utils: ルールの実装に使うあれこれ
  • clippy_dev/: 開発時に使うツール群(ルールリストの更新など)
  • clippy_dummy/: テスト向け
  • tests/: pass を走らせる部分のテストや各ルールを適用して正しく警告が出るかどうかのテスト

新しいルールを足すには基本的に clippy_lints crate および tests/ に追加・修正を加えることになります.

Early Pass と Later Pass

clippy のルールは構文木syntax::ast)もしくは HIR(rustc::hir)に対する pass として実装します.HIR はパースした構文木コンパイラ向けの情報を足したもので,Rust RFC 1191に詳しく書いてあります.

自前の pass を実装して登録しておくと,clippy が構文木または HIR をトラバースしたときに各ノードにその pass を適用して,pass に実装したコールバックメソッドがマッチすると適宜呼び出されます.

構文木にマッチさせる pass を early pass,HIR にマッチさせる pass を later pass といい,下記のような順序で適用されます.

  1. parse して構文木を得る
  2. 構文木をトラバースして early pass を適用
  3. HIR への変換と型チェック
  4. HIR をトラバースして later pass を適用

early pass は構文木のみ,later pass は HIR,型情報,コンパイラコンフィグ(cfg!)などにアクセスできます.

構文木をパースしたり pass を適用するなどの処理は rustc コンパイラ本体に linter 向けの汎用的な API があり(rust/src/librustc/lint),rustc コンパイラ自体の unused 警告などもこれを利用しています(rust/src/librustc_lint).rust-clippy リポジトリではそれを利用してルール集とコマンドライン部分のみを実装しています.なので,clippy の実装時には rustcrustc_*syntax といった rustc コンパイラ API を知る必要があります.

具体的に early pass と later pass でどのパスを通しているかは rust/src/librustc/lint/mod.rs に実装があります.

https://github.com/rust-lang/rust/blob/master/src/librustc/lint/mod.rs

新しい Early Pass を追加する

まず clippy_lints/src 内にルール向けのソースファイルを作成します.ここでは clippy_lints/src/my_rule.rs としたとします.

declare_clippy_lint! {
    pub MY_RULE,
    style,
    "my rule for clippy"
}

第1引数が linter インスタンス,2引数目が perf, correctness, complexity, style などのカテゴリです. declare_clippy_lintrustc::lint が提供する declare_tool_lint! マクロの薄い wrapper になっていて,カテゴリを見てデフォルトの警告レベルをセットするなどの設定を行っています.

次に pass オブジェクトを定義します.

#[derive(Copy, Clone, Debug)]
pub struct MyRule;

impl LintPass for MyRule {
    fn get_lints(&self) -> LintArray {
        lint_array!(MY_RULE)
    }

    fn name(&self) -> &'static str {
        "MyRule"
    }
}

impl EarlyLintPass for MyRule {
    fn check_expr(&mut self, cx: &EarlyContext<'_>, e: &syntax::Expr) {
        // ここにチェック処理を実装
    }
}

rustc::lint::LintPass の実装ではその pass の共通情報を記述します.1つの pass で複数のルールをチェックすることができ,rustc::lint::lint_array! マクロで指定します.

early pass の実装本体は rustc::lint::EarlyLintPass を実装することで実装します.もともと EarlyLintPass にある check_* メソッドをオーバーライドすると,そのメソッドが対応する構文木ノードを visit したときに適用されます.rustc::lint::EarlyContext を通じて lint のコンテキスト情報を受け取ることができます. 上記では式に対する処理 check_expr をオーバーライドしていますが,一覧はrustc のソース内で確認できます.

構文木のパターンがチェックしたいパターンにマッチしているかをチェックし必要な情報を抜き出すには matchif let などのパターンマッチをネストさせまくることになるので,if_chain::if_chain! マクロが便利です. 例えば let x = EXPR; x を取り出す処理はこんな感じに書けます.

if_chain! {
    if let Some(retexpr) = it.next_back();
    if let ast::StmtKind::Expr(ref retexpr) = retexpr.node;
    if let Some(stmt) = it.next_back();
    if let ast::StmtKind::Local(ref local) = stmt.node;
    if let Some(ref initexpr) = local.init;
    if let ast::PatKind::Ident(_, ident, _) = local.pat.node;
    if let ast::ExprKind::Path(_, ref path) = retexpr.node;
    if !in_external_macro(cx.sess(), initexpr.span);
    then {
        // ここでマッチしたときの処理
    }
}

警告すべきコードをパターンマッチで見つけたら警告を出す処理を書きます.警告を出す方法は clippy_lints/src/utils に便利関数群があり,

  • 警告メッセージのみ: span_lint(), span_lint_node()
  • 警告メッセージとヘルプ: span_help_and_lint()
  • 警告メッセージとノート: span_note_and_lint()
  • 警告メッセージとヘルプと修正提案: span_lint_and_sugg()

などが使えます.span とはソースコード片のことで,開始位置・終了位置・コンテキスト情報がエンコードされた u32 の値で,ソースコードのうち警告を出す部分を指定するのに使えます.各構文木ノードは syntax::Spanned で wrap されて check_* メソッドに渡されるので,ノードに対応する span は簡単に取得できます.

span_lint_and_sugg() ではソースコードの修正提案を String で渡せます.rustc_errors::Applicability::MachineApplicable を指定することで,自動修正機能(おそらく rustfix?)で自動修正できます.渡した文字列で span で指定した範囲を置き換えます.span は utils::snippet() を使ってコード片(スニペット)として文字列化することができ,修正提案のための文字列が楽につくれるケースがあります.

最後に作成したルールを clippy に登録します.clippy_lints/src/lib.rs 内で下記のように対応するカテゴリに linter 情報を追加します.

// ...
pub mod my_rule;

// ...

reg.register_lint_group("clippy::style", Some("clippy_style"), vec![
    // ...
    my_rule::MY_RULE
    // ...
]);

// ...

最後につくった pass を登録するのですが、early pass の場合はここで注意が必要です。

// マクロ展開後で OK な pass は普通に early pass として登録
pub fn register_plugins(reg: &mut rustc_plugin::Registry<'_>, conf: &Conf) {
    // ...
    reg.register_early_lint_pass(box reference::Pass);
    // ...
}

// マクロに対する lint など,マクロの展開前でないと動かない pass はこっちに登録
pub fn register_pre_expansion_lints(...) {
    // ...
    store.register_pre_expansion_pass(Some(session), true, false, box dbg_macro::Pass);
}

マクロの構文木ノードにマッチさせる check_mac() などを使う場合は後者の register_pre_expansion_pass 側に pass を登録する必要があります. early pass の適用順序は

  1. pre_expansion_pass
  2. マクロの展開処理
  3. early_lint_pass

となっています。

新しい Later Pass を追加する

early pass と基本的には同じで,rustc::lint::EarlyLintPass<'a> の代わりに rustc::lint::LateLintPass<'a, 'tcx> を実装します.tcx は型情報のコンテキストの寿命を表しているようです.

impl<'a, 'tcx> LateLintPass<'a, 'tcx> for MyRule {
    fn check_expr(&mut self, cx: &LateContext<'a, 'tcx>, expr: &'tcx rustc::hir::Expr) {
        // ルールの実装
    }
}

オーバーライドできるメソッドはrustc のソース内で確認できます.

early pass と違い,check_expr などのメソッドに rustc::hir::* が渡され,型情報のコンテキストの寿命 tcx が制約として付きます. 型情報には LateContexttcx フィールドからアクセスできます.(e.g. cx.tcx.fn_sigコンパイラコンフィグ(cfg)は cx.tcx.sess.parse_sess.config にある rustc::session::config::Config な値にアクセスすることでチェックできるようです.構造体のサイズなどターゲット依存で変わるものをチェックする際に使われます.

また,later pass では call flow graph も rustc::cfg::CFG を使って取得できます.

impl<'a, 'tcx> LateLintPass<'a, 'tcx> for MyRule {
    fn check_fn(
        &mut self,
        cx: &LateContext<'a, 'tcx>,
        kind: intravisit::FnKind<'tcx>,
        decl: &'tcx FnDecl,
        body: &'tcx Body,
        span: Span,
        node_id: NodeId,
    ) {
        // call flow graph を生成
        let cfg = CFG::new(cx.tcx, body);
        // ...
    }
}

例えば cyclomatic complexity を計算する際に使われているようです.

pass の登録について,later pass の場合はマクロに対するテストかどうかで pass の登録先を分ける必要はありません.ある HIR ノードがマクロ展開結果生成されたものかは,そのノードの span を使って clippy_lints/src/utilsutils::is_expn_of で分かります.

テストの実装

テストは UI テスト(問題があるコードに実装したルールを適用して,結果として期待する警告の出力がコマンドライン出力として得られるかどうか)のみで行います.

CONTRIBUTING.md によると,実装前にまずは警告を出してほしいコードを tests/ui/my_rule.rs に書き,TESTNAME=ui/my_rule cargo test --test compile-test と実行すると,そのコードの構文木にマッチする linter 実装コードの雛形をつくってくれるらしいのですが,僕の場合は dbg! が展開された後の構文木にマッチするようなコードが吐かれてしまったため使えませんでした.

linter の実装が終わったら CLIPPY_TESTS=true cargo run --bin clippy-driver -- -L ./target/debug tests/ui/my_rule.rs で警告が意図通り出力されていることを確認し,その結果を多少整形して tests/ui/my_rule.stderr として保存します.最後に TESTNAME=ui/my_rule cargo test --test compile-testmy_rule 向けのテストを実行して結果を確認できます.

dbg_macro ルールの実装

今回実装したルールは dbg!(expr) マクロの使用箇所を検知して警告として表示し,dbg!(expr) の代わりに expr を修正提案として表示する小さなものです.

rustc::lint::EarlyLintPass として実装し,pre-expansion pass として登録します.マクロ呼出しにマッチする EarlyLintPass::check_mac メソッドをオーバーライドして実装し,渡された ast::Mac のパスが "dbg" かどうかチェックするだけです.

impl EarlyLintPass for Pass {
    fn check_mac(&mut self, cx: &EarlyContext<'_>, mac: &ast::Mac) {
        if mac.node.path == "dbg" {
            // 警告を表示
        }
    }
}

ast::Macsyntax::Spaned で wrap された型なので mac.span でマクロ呼出しのソースコード上での範囲(span)は簡単に取得できます.

あとは修正提案用の文字列を生成できれば終わりです. マクロは引数にトークン列を取るので,展開前は引数のトークン列のみが構文木ノードに格納されています(mac.node.tts). dbg!(expr) マクロの中身 expr の span が取得できれば,その範囲を utils::snippet でコード片化できます.

引数の span を取る方法は

  1. トークン列の最初のトークンの span の始まり位置とトークン列の最後のトークンの span の終わり位置からトークン列全体の範囲を表す span を生成する
  2. dbg!() は仕様として1つの値を取るので,トークン列を syntax::ast::Expr にパースした結果のノードから span を取り出す
  3. early pass をやめ,later pass にして dbg!() の引数部分から展開された HIR ノードを特定して span を取り出す

のざっくり3通りが考えられます.3. は大変そうだし 2. は式としてパースできなかった場合のエラーハンドリングやパースのコストがあるので,1. が一番良さそうです.

Rust のトークン列は syntax::tokenstream::TokenStream という型で表され,syntax::tokenstream::TokenTree の列で表現されています.どうやらこれらは proc macro の実装などで使う proc_macro::TokenStreamproc_macro::TokenTree とは別物のようです.

TokenStream.trees() で(clone した)TokenTreeイテレータを返せるので,そこから最初のトークンツリーと最後のトークンツリーを取得し,Span::to を使って最初のトークンツリーの span から最後のトークンツリーの span までの範囲を表す span を新たに生成します.

let mut cursor = mac.node.tts.trees();
let first = cursor.next().unwrap();
let last = cursor.last().unwrap_or(first);
let entire_span = first.span().to(last.span());

最後にスニペット文字列を span から生成すれば修正提案に使う文字列が生成できます.

let snip = utils::snippet_opt(cx, entire_span).unwrap().to_string();

最後に clippy_devcargo fmt を実行しておきます.clippy_devclippy_lints/src/lib.rs で登録されている pass をチェックしたり,CHANGELOG.md と README.md を更新したりしてくれます.

cd clippy_dev/
cargo run -- update_lints
cargo +nightly fmt --all

まとめ

dbg_macro ルールを Rust 公式 linter の clippy に追加しました.次の clippy のリリース時に入ると思うので,良ければ CI や Git の pre-commit もしくは pre-push フックなどで

cargo clippy -- -W clippy::dbg_macro

と実行して使ってみてください.

また clippy に新しいルールを追加する方法についても簡単に紹介しました.rustcrustc_*syntax あたりの nightly でしか使えない rustc コンパイラ API を使ったり読んだりする必要はなかなか無いので,実際使ったのはごく一部ですが良い機会だったと思います.ちなみにこれらの crate は rustc コンパイラの内部実装で頻繁に変更されるので,上記の紹介した内容もいずれ正しくなくなる可能性が高いです.

Vim の構文ハイライトでクリスマスツリー🎄を飾ってメリクリする

Vim Advent Calendar 2018 の24日目の記事です.昨日は Kaoriya さんのVim に VOICEROID で喋らせたでした.

もうすぐクリスマスなので,クリスマスツリーを飾りたいと思います.ただ飾るだけだと Vim のネタにならないので,CrystalWastvim-gfm-sytnax, vim-github-actions といったファイルタイププラグインをつくった経験を活かし,構文ハイライトを使って Vim の中でクリスマスツリーを飾っていきたいと思います.

Vim の構文ハイライトについての情報は下記の help ドキュメントを読んでいただければ,ある程度網羅的な情報が手に入るので,実際に何かのファイルタイプを追加するプラグイン(ファイルタイププラグイン)を実装する時は,まずざっと一通りドキュメントを眺めることをおすすめします.

この記事はまだファイルタイプを書いたことが無い人を対象とします.網羅的な情報ではなく,ハイライトをつけていく過程を実例とともにコードで説明して,ファイルタイププラグインを実装する大まかな手順を把握していただくのを目的として進めていきます.今回はインデントについては時間の都合(や題材の都合)で省略します.

今回使ったコードはすべてこのリポジトリにあります.

github.com

ファイルタイププラグインとは

本題に入る前にファイルタイププラグインを知らない方向けにざっくりとした説明をしておきます.

特定の拡張子のファイルを開いたときなど,set filetype=... のようにファイルタイプをセットしたときにそのファイルタイプの構文ハイライトやインデント設定などを行ってくれるプラグインをファイルタイププラグインと呼びます(:help filetype-plugin). Vimcvim をはじめ,デフォルトで多くのファイルタイプをサポートしていますが,自分で新しいファイルタイプを足すこともできます.有名どころだと typescript-vimvim-toml など無数にあります.

また,vim-css3-syntaxvim-jsx-pretty, vim-gfm-syntax のように,自前でファイルタイプを定義するのではなく,既存のハイライトに独自のハイライトを足すようなプラグインもあります.

下記のような場合にファイルタイププラグインを自作する必要があります

  1. Vim が公式で対応していない言語のコードをハイライトしたい(e.g. 自作言語,自作設定ファイル)
  2. 既存のファイルタイププラグインではうまく動かない(or メンテされていない)などの事情で,自分でつくりたい
  3. アプリのログを読むときに重要な箇所をハイライトしたい

0. まずは対象の仕様を把握する

ファイルタイププラグインを書き始める前に,まずは対象を把握します.特定の言語のファイルタイププラグインをつくる場合は,まずはその言語の構文の仕様を眺めたりします.例えば vim-wasm をつくる際には WebAssembly の Text Format の仕様を読んだりしました.

今回はクリスマスなので,下記のクリスマスツリーをハイライト(飾り付け)していきたいと思います.

asciiart.eu の Laura T さんの作品をベースにしてつくってみました.

        *       ☆           *
   *            .o      *      *
     *         .o.'.
              .'.'o'.       *
        *    o'.*.'.o.
  *         .'.o.'.'.*.   *    *
      *    .*.'.o.'.o.'.     *
*         o'.'.'.'*'.'.'.        *
      *  .'.'*'.'o'.'.'*'o  *
  *           [_____]           *
        *      \___/      *       *

          Merry Christmas!

ツリーのてっぺんには☆が飾られており,o* で装飾された木だと思ってください.周りの * はクリスマスなので雪が降っています.このままでも綺麗ですが(?),色を付けて飾りつけていきましょう.

新しいファイルタイプとして christmastree を定義することにします.このファイルタイプが定義されている時は Vim はバッファにクリスマスツリーが描かれているとしてハイライトするようにこれから実装していきます.

1. リポジトリとファイルタイプの認識

まずは Vim プラグインリポジトリを用意します.ディレクトリを runtimepath に直接追加する(.vimrc 内で set rtp+=/path/to/vim-syntax-christmas-tree)か,お好みのプラグインマネージャでロードするプラグインのリストに追加してください.

mkdir vim-syntax-christmas-tree && cd vim-syntax-christmas-tree
git init .

ディレクトリ構成はこのようになっています. christmas-tree.txt は上記のアスキーアートが描かれたテキストファイルです.

vim-syntax-christmas-tree/
├── christmas-tree.txt
├── ftdetect/
│   └── christmastree.vim
├── syntax/

まずはファイルタイプを認識させます.Vimftdetect/*.vim の中身を起動時にロードしてくれるので,ftdetect/christmastree.vim をつくり,この中にファイルタイプを認識するための :autocmd を書きます.

autocmd BufNewFile,BufReadPost christmas-tree.txt setlocal filetype=christmastree

今回は christmas-tree.txt という固定のファイル名のファイルを読んだときのみ,ファイルタイプ christmastree をセットするようにしています.実際は TypeScript なら *.ts のように拡張子でパターンを指定することが多いです.

ここで一旦 Vim を新たに開き,christmas-tree.txt を開いて :set ft で今のファイルタイプが christmastree になっているのを確認します.

2. 構文ハイライトファイルをつくる

Vim は構文ハイライトファイルとして syntax/{filetype}.vimsyntax/{filetype}/*.vim を探すようになっているので,syntax/christmastree.vim をつくります. まだ何もハイライトしない空の状態です.

if exists("b:current_syntax")
    finish
endif

" ここにハイライト処理の実装を書いていく

let b:current_syntax = "christmastree"

変数 b:current_syntax はそのバッファをハイライトしている構文の名前です.すでにハイライト済みかどうかはその変数があるかどうかでチェックし,まだハイライトされていなければハイライト処理を行うという構造になっています.ハイライトを行った後は,すでにハイライトしたということが分かるように,b:current_syntax を定義しておきます.

3. はじめてのハイライト: :syn match

まずはてっぺんの☆をハイライトしてみます.

これは単に という文字列を色をつけてハイライトすれば良さそうです.

syn match christmastreeStar "☆"

hi def link christmastreeStar Identifier

:syn match {ハイライトグループ} {パターン} を使っています.これにより,指定したパターン(正規表現)にマッチする文字列を指定したハイライトグループでハイライトできます.

ここでは christmastreeStar というハイライトグループをつくることにし,"☆" というパターン(正規表現)にマッチする箇所とそのハイライトグループを紐づけます.この時点ではハイライトグループを紐づけただけなのでまだ色はつきません.ハイライトグループ名は小文字始まりのファイルタイプ名をプレフィックスとしてつけます.これはハイライトグループ名が一意でないといけない(他とかぶると上書きされてしまう)ためです.

次の行の hi def link {ハイライトグループ} {別のハイライトグループ} という行で,christmastreeStarIdentifier というハイライトグループに紐づけています.

Vim はデフォルトでいくつかのハイライトグループを定義しています.例えば (Normal: 通常のテキスト,Visual: ビジュアルモード選択 など).また,習慣として,同じ構文を同じグループにまとめたものが推奨されるハイライトグループ(Identifier: 変数名,Statement: 文 など)として明記されています.いずれも :help :hi で一覧を確認できるので,ざっと見ておいたほうが良いです.カラースキームプラグインはこれらの推奨ハイライトグループやデフォルトのハイライトグループに対して色を指定することで好きな色合いを実現しています.(カラースキームについては,もしよければフルスクラッチからさいきょうの Vim カラースキームをつくろう!を参照してみてください)

Vim:hi と打つと今のハイライトグループとそれに指定されている色がリストで表示されるので,そこで実際の色を確認できます.

:hi def link はデフォルトでハイライトグループを別のハイライトグループにリンクします.なので,ここでは christmastreeStar というハイライトグループにマッチするテキストは Identifier と同じ色でハイライトされます.

実際にプログラミング言語のハイライトを書く場合は,対象の構文が何にあたるかでリンクするハイライトグループを決めます.例えば if 文なら Statement,関数定義なら Function など.今回は残念ながら(?)クリスマスツリーに適した推奨ハイライトグループが無いため,適当に手元のカラースキームで合いそうな色を選択しました.

ちなみに

syn match Identifier "☆"

のように新しいハイライトグループを定義せず直接共通のハイライトグループを指定することもできますが,カラースキームがハイライト色を上書きできなくなるためやめておいたほうが良いです.:hi def はすでにハイライトがリンクされているときに何もしないので,カラースキーム側で先に :hi link してやることで,ハイライト色を上書きできるようになります.

実際に Vim で開いて確認してみます.:term ++close vim christmas-tree.txt とすることで,Vim の中で直接 Vim を開いて最新のハイライトを確認できます.

f:id:rhysd:20181223221759p:plain

やりました! がハイライトされていることが分かります.

ちなみに本来は短い特定の文字列は :syn keyword でキーワードとしてハイライトするのが一般的ですが,:syn keyword'iskeyword' に指定されている文字でないとハイライトしてくれないので, には使えず :syn match を使いました.

4. 葉をハイライトする: :syn region

次にツリーの葉を緑でハイライトしてみます.先程の :syn match を使っても良いですが,今回は :syn region を使ってハイライトします.周りには雪が舞っていてそれらは葉に含めたくないので,行ごとにハイライトするのが良さそうです.

syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ oneline

hi def link christmastreeLeaves String

新たなハイライトグループ christmastreeLeaves を定義しています. syn region はハイライトするテキストの始め(start=)と終わり(end=)によってハイライトする範囲を指定できます.始まりと終わりがダブルクォートな文字列リテラル"...")や,{...} によるブロック構文などは :syn region を使うことが多いです. ここでは,木は .o で始まり,.o で終わっていることに目をつけて,空白の後に .o が続く場合を始めとし,空白または改行に続く .o を終端とします. 最後に,葉は行ごとにハイライトすると決めたので,oneline オプションを指定しておきます.これによって,ハイライトが次の行に継続してハイライトされることがなくなります.

実際に Vim で開いて確認してみます.

f:id:rhysd:20181223221829p:plain

無事,意図通り緑色(私のローカルのカラースキームでは文字列が緑なので String を指定している)でハイライトされています.少し木っぽくなってきた気がします.

5. ハイライトをネストさせる: contained, containedin=, contains=

飾り * をハイライトしてみます. のとき同様に * にマッチするハイライトを新しいハイライトグループ christmastreeGlitter で指定し,

syn match christmastreeGlitter /\*/

hi def link christmastreeGlitter Constant

実際に Vim で開いて確認してみます.

f:id:rhysd:20181223221901p:plain

雪のほうがハイライトされてしまいました.一方でハイライトしたほうの木の * のほうはハイライトされていません.これは,葉のハイライトのほうが優先されてしまっているためです. ここでやりたいことは,葉の中の * だけをハイライトするということです.

これはハイライトを「ネストさせる」ことで実現できます.Vim のハイライトは正規表現でマッチする領域を重ねた層のような構造になっており,contains=, containedin=, contained などの :syn のオプションでそれらを制御します.

今回の例では

syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ contains=christmastreeGlitter oneline keepend
syn match christmastreeGlitter /\*/ contained containedin=christmastreeLeaves

hi def link christmastreeLeaves String
hi def link christmastreeGlitter Constant

のようにすることで実現できます.

contained をつけたハイライトはトップレベルではハイライトされなくなり,後述の contains= でネストしてマッチした場合のみハイライトされるようになります.これによって,外の雪は白いままになります.

ハイライトをネストさせるには,別のハイライトを含む側に contains= で含むハイライトのグループ名をコンマ区切りで書き,含まれる側のハイライトに含むハイライトグループを containedin= で指定します. containedin= は無くても良いですが,あったほうが意図しないハイライトを防げて良いと思います.

実際に Vim で開いて確認してみます.

f:id:rhysd:20181223221920p:plain

無事,木の飾りの * だけに色をつけることができました.

実際のファイルタイププラグインでは,このようにハイライトをネストさせることで特定の構文のみに適用されるハイライトを定義します.例えば if 文が関数ブロックの中でしか書けないような言語であれば,関数ブロック全体にマッチするハイライトを定義し,その中にネストして if 文のハイライトをマッチさせるような実装が考えられます.

ちなみに,しれっと keepend というオプションを足していることに気付いたでしょうか?:syn region は同じ構文でネストしたハイライトをうまく扱う(例えば { { ... } } のようにネストしていても内側と外側にうまくハイライトマッチできる)ような挙動になっているのですが,これだと外側(christmastreeLeaves)のマッチするテキストの末尾で内側(christmastreeGlitter)が同時にマッチするとき,内側が優先されて外側のマッチが終了しなくなります.この挙動を変えるのが keepend で,内側のハイライトと外側のハイライトが同時に終了するようになります.

6. 複数のハイライトをまとめる

木の飾りは *o の2種類あります.それぞれに違う色を割り当てられたほうが華やかになるので,o* とは別のハイライトを割り当てます. ここで先程のセクションと同様にして o もハイライトしても良いですが,せっかくなので1つの「クラスタ」にまとめることにします.

複数のハイライトグループをひとまとめにする :syn cluster を使います.

syn cluster christmastreeGlitters contains=christmastreeGlitterSmall,christmastreeGlitterLarge
syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ contains=@christmastreeGlitters oneline keepend
syn match christmastreeGlitterLarge /o/ contained containedin=christmastreeLeaves
syn match christmastreeGlitterSmall /\*/ contained containedin=christmastreeLeaves

hi def link christmastreeLeaves String
hi def link christmastreeGlitterSmall Constant
hi def link christmastreeGlitterLarge Special

まずは *christmastreeGlitterSmallochristmastreeGlitterLarge として別のハイライトグループにし,それぞれに別のハイライト色を指定できるようにします. 次に :syn cluster {クラスタ名} contains={コンマ区切りのハイライトグループ} を使って @christmastreeGlitters をひとまとめにします. クラスタ:syn regioncontains= のように,コンマ区切りで複数のハイライトグループを指定する箇所で使えます.

実際に Vim で開いて確認してみます.

f:id:rhysd:20181223221944p:plain

今回の例では,まとめずに *o で別個にハイライトを指定するのに比べてあまり嬉しさが分かりませんが,数が増えたり,階層構造が深くなったりしたときに管理がかなり楽になります.

実際のファイルタイププラグインでは,例えば関数ブロックの中に含められるハイライトグループをひとまとめにして1つのクラスタにしたりといった管理をしているのを見ます.vim-wasmでは,WebAssembly のテキストフォーマットはS式で記述するので,(...) で囲まれた中のみでハイライトが効く(逆に括弧の外であるトップレベルではコメント以外ハイライトされない)ように,括弧の中だけでハイライトすべきハイライトグループを1つのクラスタにしています.

7. 完成

最後に忘れていた植木鉢を :syn match でハイライトさせて完成です.[___]\___/ に分けてマッチさせていますが,それぞれ同じ構文の一部として同じハイライトグループ名 christmastreeFlowerpot を指定しています. このように,1つのハイライトグループを複数の :syn で構成することもできます.

これでようやく一通り飾り終えることができました. syntax/christmastree.vim の全体像は下記の通りです.

if exists("b:current_syntax")
    finish
endif

syn cluster christmastreeGlitters contains=christmastreeGlitterSmall,christmastreeGlitterLarge
syn match christmastreeStar /☆/
syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ contains=@christmastreeGlitters oneline keepend
syn match christmastreeGlitterLarge /o/ contained containedin=christmastreeLeaves
syn match christmastreeGlitterSmall /\*/ contained containedin=christmastreeLeaves
syn match christmastreeFlowerpot /\[_\+\]/
syn match christmastreeFlowerpot /\\_\+\//

hi def link christmastreeStar Identifier
hi def link christmastreeLeaves String
hi def link christmastreeGlitterSmall Constant
hi def link christmastreeGlitterLarge Special
hi def link christmastreeFlowerpot PreProc

let b:current_syntax = "christmastree"

実際に Vim で開いて確認してみます.

f:id:rhysd:20181223222000p:plain

無事綺麗に飾りつけられました.お疲れ様でした.

8. さらにピカピカさせる

構文ハイライトだけでも綺麗ですが,ftplugin を使ってさらに華やかにしてみます.

Vim はファイルタイプをセットしたときに ftplugin/{filetype}.vim または ftplugin/{filetype}/*.vim を読みに行くため,そのファイルの中にコードを書いておけば,特定のファイルタイプをセットしたときに処理を行うことができます.

今回は ftplugin/christmastree.vim を作成し,構文ハイライトの b:current_syntax のように,もう ftplugin を読み終えたことを示す b:did_ftplugin をチェックする処理を最初に書きます.これにより,意図せず複数の ftplugin スクリプトが読まれてしまうことを防ぎます. こうすることで,例えば別の ftplugin を作成して既存の ftplugin を置き換えると言ったことができるようになります.

if exists("b:did_ftplugin")
    finish
endif
let b:did_ftplugin = 1

今回はタイマーを使って,一定時間ごとに飾りの色が変わるようにしてみました.

function! s:christmas_glitter_tick(timer) abort
    let small = get(b:, 'christmastree_glitter_small_colors', ['Constant', 'Statement', 'Special', 'Ignore'])
    let large = get(b:, 'christmastree_glitter_large_colors', ['Special', 'Keyword', 'Identifier', 'Normal', 'Statement', 'Constant', 'Ignore'])

    let idx = s:tick % len(small)
    execute 'hi link christmastreeGlitterSmall' small[idx]

    let idx = s:tick % len(large)
    execute 'hi link christmastreeGlitterLarge' large[idx]

    let s:tick += 1
endfunction

function! s:christmas_glitter_start() abort
    if exists('s:timer_id')
        return
    endif
    let s:tick = 1
    let s:timer_id = timer_start(1000, function('s:christmas_glitter_tick'), {'repeat': -1})
endfunction

function! s:christmas_glitter_stop() abort
    if !exists('s:timer_id')
        return
    endif
    call timer_stop(s:timer_id)
    unlet! s:timer_id
    unlet! s:tick
endfunction

if get(g:, 'christmastree_glitter_update', 1)
    call s:christmas_glitter_start()
    autocmd BufWipeout <buffer> call <SID>christmas_glitter_stop()
endif

command! -nargs=0 -bar -buffer ChristmasTreeTurnOn call <SID>christmas_glitter_start()
command! -nargs=0 -bar -buffer ChristmasTreeTurnOff call <SID>christmas_glitter_stop()

詳しくは説明しませんが,timer_start() を使って1秒毎に christmastreeGlitterSmallchristmastreeGlitterLarge のリンクするハイライトグループを書き換えることでハイライト色を変更しています.

JavaScriptsetInterval のようにタイマーは非同期に呼ばれるので,s:christmas_glitter_tick() が実行されるわずかな時間しかユーザの操作をブロックしません. なので,Vim の中にクリスマスツリーをピカピカ光らせながら普段のコーディングができます.

https://github.com/rhysd/ss/blob/master/vim-syntax-christmas-tree/demo.gif?raw=true

ftplugin/christmastree.vim はファイルタイプ christmastree がセットされるたびに毎回読まれるので,その中に直に処理を書けば良く, g:christmastree_glitter_update0 にセットされていないときのみタイマーを開始するようにしています.

また,手動でタイマーを止めたり開始したりできるように,:ChristmasTreeTurnOn:ChristmasTreeTurnOff も定義しています.-buffer 付きで定義しているので,そのバッファ内のみで使えるコマンドとして定義されます.

これでクリスマスの準備もバッチリですね!(?)

GitHub Actions (beta) が使えるようになったので調査した

GitHub Actions Open Beta に申し込んで1ヶ月以上経ち,ようやく使えるようになったみたいなので実際にどう使うのか調査してみたメモ. Beta 版は GitHub のプライベートリポジトリにしか使えないため,公開リポジトリに使うにはもう少し待つ必要がありそう.

GitHub Actions とは

GitHub Universe 2018 で発表されたときにメディアが記事にしているので,そちらを読んでください.

公式ドキュメント

hello world 的なのは下記のリンクから action をつくるチュートリアルと workflow をつくるチュートリアルをやれば良いです.

全体的な流れは,

  1. Dockerfileentrypoint.sh をつくって欲しいアクションを定義する(プリセットのアクションしか使わない場合はこのステップは不要)
  2. GitHub の Actions タブに行き,workflow をビジュアルエディタで定義する.ワークフローの起点と,そこからのアクションの連なりをグラフエディタでつくれる.直接 .github/ 以下を書いても良いけど,こっちのほうがチェックもしてくれるし良さそう
  3. 実際に起点となるイベントを起こしてみる(例えば git push
  4. 再び Actions タブに行くと,各ワークフローがどう走って結果がどうだったか(ログなど)が確認できる
  5. ワークフローを編集したい時は .github/ 以下の .workflow ファイルの Edit ボタンをクリックすると再びビジュアルエディタが立ち上がる

参考リンク:

基本

各アクションはアクションを走らせる Docker コンテナのための Dockerfile と処理のエントリポイントになるシェルスクリプト entrypoint.sh の組み合わせで定義できます.

Dockerfile のラベルでメタ情報(description とか action name とか)を記述します.実際の処理は entrypoint.sh(もしくは entrypoint.sh から呼ばれるスクリプトなど)でシェルスクリプトで定義します.依存しているツール(例えば JSON を扱うなら jq とか)は Dockerfile をビルドするときにコンテナに apt install などでインストールしておきます.

コンテナ実行時の情報はスクリプトの引数か,環境変数で与えられます.環境変数はアクションをワークフローエディタで編集する時に指定でき,スクリプト側からそれらが参照できます.またスクリプトの引数もアクションの編集で指定でき,entrypoint.sh の引数としてアクション側に渡ってきます.秘密の情報 (secrets) はリポジトリページの settings から secrets タブを選択してキー・バリューで秘密の情報を入力しておき,アクションの設定でどのキーを使うかを指定しておくと,Docker コンテナから環境変数としてそれらが見える?ようです(env コマンドの出力はフィルタされているのか確認できなかった)

アクションの起点は GitHub の公式ドキュメントに乗っている一覧のイベント から選べます.例えば on: "push" と指定するとコミットをプッシュするたびにワークフローが走ります.

どうやってアクションを書くのか

注:特に明記しない限り,実動作ベースでの調査をしたので,今後動作が変わったり,勘違いしている箇所があるかもしれません

公式のアクション

actions organization に各アクションごとにリポジトリが置かれているので,それを参考にできます.

アクションが実行される環境

pwd で確認すると,アクションのエントリポイントとなる entrypoint.sh/github/workspace というディレクトリ内で実行されています.

このディレクトリについては下記の公式ドキュメントに詳細がありました:

developer.github.com

どうやら対象のリポジトリのルートディレクトリになっているらしいです.試しに ls -la してみると

total 24
drwxr-xr-x 6 root root 4096 Dec  9 13:54 .
drwxr-xr-x 5 root root 4096 Dec  9 13:55 ..
drwxr-xr-x 8 root root 4096 Dec  9 13:54 .git
drwxr-xr-x 2 root root 4096 Dec  9 13:54 .github
drwxr-xr-x 2 root root 4096 Dec  9 13:54 action-a
drwxr-xr-x 2 root root 4096 Dec  9 13:54 action-b

.git ディレクトリが置かれており,リポジトリのルートにいると分かります.

すでにリポジトリはクローンされた状態で実行されるので,自前で対象のリポジトリをクローンしてくる必要は無さそうです.

アクション内で参照できる環境変数

どうやら $GITHUB_* という環境変数に情報が入っているようです.一覧は公式ドキュメントにあり,

developer.github.com

例えば,on: "push"リポジトリへの push を行った際の環境変数は下記です:

GITHUB_EVENT_PATH=/github/workflow/event.json
GITHUB_WORKFLOW=hello
GITHUB_ACTION=Hello World
GITHUB_REPOSITORY=rhysd/hello-github-actions
GITHUB_WORKSPACE=/github/workspace
GITHUB_SHA=52875f0b1ed9882770c0cfddbcfe95607e4b2986
GITHUB_ACTOR=rhysd
GITHUB_REF=refs/heads/master
GITHUB_EVENT_NAME=push

これでどのワークフローやアクションとして自身が実行されているかを知ることができます.

アクション内で参照できるフックイベントの情報

ちなみに $GITHUB_EVENT_PATH/github/workflow/event.json には起点になったイベントの情報が JSON で入っています.各イベントごとに入っている情報はGitHub の公式ドキュメントで知ることができます.詳細なイベントフックの情報が欲しい場合はこっちを見たほうが良さそうです.

例えば on: "push" での event.json の中身は下記です:

{
  "after": "c776e7146a031950fa579791f71448336a07880c",
  "base_ref": null,
  "before": "52875f0b1ed9882770c0cfddbcfe95607e4b2986",
  "commits": [
    {
      "added": [],
      "author": {
        "email": "my-email@example.com",
        "name": "rhysd",
        "username": "rhysd"
      },
      "committer": {
        "email": "my-email@example.com",
        "name": "rhysd",
        "username": "rhysd"
      },
      "distinct": true,
      "id": "c776e7146a031950fa579791f71448336a07880c",
      "message": "check event.json",
      "modified": [
        "action-a/entrypoint.sh"
      ],
      "removed": [],
      "timestamp": "2018-12-09T21:39:47+09:00",
      "tree_id": "7d4c96c54a304cd3af1bdbac684099bbbeb1dcc9",
      "url": "https://github.com/rhysd/hello-github-actions/commit/c776e7146a031950fa579791f71448336a07880c"
    }
  ],
  "compare": "https://github.com/rhysd/hello-github-actions/compare/52875f0b1ed9...c776e7146a03",
  "created": false,
  "deleted": false,
  "forced": false,
  "head_commit": {
    "added": [],
    "author": {
      "email": "my-email@example.com",
      "name": "rhysd",
      "username": "rhysd"
    },
    "committer": {
      "email": "my-email@example.com",
      "name": "rhysd",
      "username": "rhysd"
    },
    "distinct": true,
    "id": "c776e7146a031950fa579791f71448336a07880c",
    "message": "check event.json",
    "modified": [
      "action-a/entrypoint.sh"
    ],
    "removed": [],
    "timestamp": "2018-12-09T21:39:47+09:00",
    "tree_id": "7d4c96c54a304cd3af1bdbac684099bbbeb1dcc9",
    "url": "https://github.com/rhysd/hello-github-actions/commit/c776e7146a031950fa579791f71448336a07880c"
  },
  "pusher": {
    "email": "rhysd@users.noreply.github.com",
    "name": "rhysd"
  },
  "ref": "refs/heads/master",
  "repository": {
    "archive_url": "https://api.github.com/repos/rhysd/hello-github-actions/{archive_format}{/ref}",
    "archived": false,
    "assignees_url": "https://api.github.com/repos/rhysd/hello-github-actions/assignees{/user}",
    "blobs_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/blobs{/sha}",
    "branches_url": "https://api.github.com/repos/rhysd/hello-github-actions/branches{/branch}",
    "clone_url": "https://github.com/rhysd/hello-github-actions.git",
    "collaborators_url": "https://api.github.com/repos/rhysd/hello-github-actions/collaborators{/collaborator}",
    "comments_url": "https://api.github.com/repos/rhysd/hello-github-actions/comments{/number}",
    "commits_url": "https://api.github.com/repos/rhysd/hello-github-actions/commits{/sha}",
    "compare_url": "https://api.github.com/repos/rhysd/hello-github-actions/compare/{base}...{head}",
    "contents_url": "https://api.github.com/repos/rhysd/hello-github-actions/contents/{+path}",
    "contributors_url": "https://api.github.com/repos/rhysd/hello-github-actions/contributors",
    "created_at": 1544355055,
    "default_branch": "master",
    "deployments_url": "https://api.github.com/repos/rhysd/hello-github-actions/deployments",
    "description": null,
    "downloads_url": "https://api.github.com/repos/rhysd/hello-github-actions/downloads",
    "events_url": "https://api.github.com/repos/rhysd/hello-github-actions/events",
    "fork": false,
    "forks": 0,
    "forks_count": 0,
    "forks_url": "https://api.github.com/repos/rhysd/hello-github-actions/forks",
    "full_name": "rhysd/hello-github-actions",
    "git_commits_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/commits{/sha}",
    "git_refs_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/refs{/sha}",
    "git_tags_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/tags{/sha}",
    "git_url": "git://github.com/rhysd/hello-github-actions.git",
    "has_downloads": true,
    "has_issues": true,
    "has_pages": false,
    "has_projects": true,
    "has_wiki": true,
    "homepage": null,
    "hooks_url": "https://api.github.com/repos/rhysd/hello-github-actions/hooks",
    "html_url": "https://github.com/rhysd/hello-github-actions",
    "id": 161032314,
    "issue_comment_url": "https://api.github.com/repos/rhysd/hello-github-actions/issues/comments{/number}",
    "issue_events_url": "https://api.github.com/repos/rhysd/hello-github-actions/issues/events{/number}",
    "issues_url": "https://api.github.com/repos/rhysd/hello-github-actions/issues{/number}",
    "keys_url": "https://api.github.com/repos/rhysd/hello-github-actions/keys{/key_id}",
    "labels_url": "https://api.github.com/repos/rhysd/hello-github-actions/labels{/name}",
    "language": "Dockerfile",
    "languages_url": "https://api.github.com/repos/rhysd/hello-github-actions/languages",
    "license": null,
    "master_branch": "master",
    "merges_url": "https://api.github.com/repos/rhysd/hello-github-actions/merges",
    "milestones_url": "https://api.github.com/repos/rhysd/hello-github-actions/milestones{/number}",
    "mirror_url": null,
    "name": "hello-github-actions",
    "node_id": "MDEwOlJlcG9zaXRvcnkxNjEwMzIzMTQ=",
    "notifications_url": "https://api.github.com/repos/rhysd/hello-github-actions/notifications{?since,all,participating}",
    "open_issues": 0,
    "open_issues_count": 0,
    "owner": {
      "avatar_url": "https://avatars3.githubusercontent.com/u/823277?v=4",
      "email": "rhysd@users.noreply.github.com",
      "events_url": "https://api.github.com/users/rhysd/events{/privacy}",
      "followers_url": "https://api.github.com/users/rhysd/followers",
      "following_url": "https://api.github.com/users/rhysd/following{/other_user}",
      "gists_url": "https://api.github.com/users/rhysd/gists{/gist_id}",
      "gravatar_id": "",
      "html_url": "https://github.com/rhysd",
      "id": 823277,
      "login": "rhysd",
      "name": "rhysd",
      "node_id": "MDQ6VXNlcjgyMzI3Nw==",
      "organizations_url": "https://api.github.com/users/rhysd/orgs",
      "received_events_url": "https://api.github.com/users/rhysd/received_events",
      "repos_url": "https://api.github.com/users/rhysd/repos",
      "site_admin": false,
      "starred_url": "https://api.github.com/users/rhysd/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/rhysd/subscriptions",
      "type": "User",
      "url": "https://api.github.com/users/rhysd"
    },
    "private": true,
    "pulls_url": "https://api.github.com/repos/rhysd/hello-github-actions/pulls{/number}",
    "pushed_at": 1544359187,
    "releases_url": "https://api.github.com/repos/rhysd/hello-github-actions/releases{/id}",
    "size": 3,
    "ssh_url": "git@github.com:rhysd/hello-github-actions.git",
    "stargazers": 0,
    "stargazers_count": 0,
    "stargazers_url": "https://api.github.com/repos/rhysd/hello-github-actions/stargazers",
    "statuses_url": "https://api.github.com/repos/rhysd/hello-github-actions/statuses/{sha}",
    "subscribers_url": "https://api.github.com/repos/rhysd/hello-github-actions/subscribers",
    "subscription_url": "https://api.github.com/repos/rhysd/hello-github-actions/subscription",
    "svn_url": "https://github.com/rhysd/hello-github-actions",
    "tags_url": "https://api.github.com/repos/rhysd/hello-github-actions/tags",
    "teams_url": "https://api.github.com/repos/rhysd/hello-github-actions/teams",
    "trees_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/trees{/sha}",
    "updated_at": "2018-12-09T12:34:31Z",
    "url": "https://github.com/rhysd/hello-github-actions",
    "watchers": 0,
    "watchers_count": 0
  },
  "sender": {
    "avatar_url": "https://avatars3.githubusercontent.com/u/823277?v=4",
    "events_url": "https://api.github.com/users/rhysd/events{/privacy}",
    "followers_url": "https://api.github.com/users/rhysd/followers",
    "following_url": "https://api.github.com/users/rhysd/following{/other_user}",
    "gists_url": "https://api.github.com/users/rhysd/gists{/gist_id}",
    "gravatar_id": "",
    "html_url": "https://github.com/rhysd",
    "id": 823277,
    "login": "rhysd",
    "node_id": "MDQ6VXNlcjgyMzI3Nw==",
    "organizations_url": "https://api.github.com/users/rhysd/orgs",
    "received_events_url": "https://api.github.com/users/rhysd/received_events",
    "repos_url": "https://api.github.com/users/rhysd/repos",
    "site_admin": false,
    "starred_url": "https://api.github.com/users/rhysd/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/rhysd/subscriptions",
    "type": "User",
    "url": "https://api.github.com/users/rhysd"
  }
}

アクション間で情報を受け渡しする

各アクションで毎回リポジトリが clone されるわけではなく,共通のワークスペースが使われます.なので前段のアクションで作成したファイルは後段のアクションでもアクセスすることができます.これによって,あるアクションでビルドした生成物を使って,後段で linter やテスト,デプロイを走らせるといったことができそうです.

サードパーティのアクションが後段にいる場合にはアクセストークンなどの秘密情報をファイルに保存しないように気をつける必要があります.試した限りでは環境変数は後段のアクションに受け継がれないので,前述の secrets 機能で環境変数に置いておくのが良さそうです.

Custom GitHub Action をつくる

アクションは別リポジトリや Docker コンテナに切り出して再利用することができます.actions organization に置かれている公式のアクション集が参考になります.

アクション1つのみを公開するとき

つくるリポジトリに対して公開するアクションが1つのときはリポジトリのルートにそのまま Dockerfileentrypoint.sh を置きます

your-awesome-action/
├── Dockerfile
└── entrypoint.sh

使う側のリポジトリ.github/*.workflow には usesowner/repo@ref を指定すると使えるようになります.ref はブランチ名か commit SHA1 の初め7桁を指定します(タグ名でも良い?).uses は他にも docker:// で始めて直接 Docker コンテナを指定することもできるようです.

action "Awesome Action" {
  uses = "your-name/your-awesome-action@master"
}

workflow "hello" {
  on = "push"
  resolves = ["Awesome Action"]
}

例: https://github.com/actions/npm

アクションを複数公開する時

1つのリポジトリで複数のアクションを公開したい時は,Dockerfileentrypoint.sh をサブディレクトリに置きます.

your-awesome-action/
├── action-a
│   ├── Dockerfile
│   └── entrypoint.sh
└── action-b
    ├── Dockerfile
    └── entrypoint.sh

使う側のリポジトリ.github/*.workflow には usesowner/repo/subdir@ref を指定すると使えるようになります(ref はアクション1つのみの場合と同じ).

action "Awesome Action A" {
  uses = "your-name/your-awesome-action/action-a@master"
}

workflow "hello" {
  on = "push"
  resolves = ["Awesome Action A"]
}

例: https://github.com/actions/bin

感想

ざっと見た感じ,GitHub Action は下記の場合に便利そうです

  • 開発のワークフローを自動化したいとき
    • 自動でラベルを貼り替える
    • 自動で issue を close する
    • パッケージングしてデプロイしたりライブラリをリリースしたり
  • ちょっとした CI を回したい
    • Docker で実行すれば十分な(WindowsmacOS でのテストが要らない)場合
    • linter ぐらいはかけておきたい

ただし下記の点は気をつけておいたほうが良さそうです

  • アクションへの入力のインターフェースが貧弱
    • 現状,前段のアクションで行ったビルド結果を利用したりする必要があるが,それらの前提条件を記述・チェックするような仕組みはない(各アクションで走るスクリプト内で自前でチェックする必要がある)
    • 動的に情報を与える口はスクリプトの引数と環境変数しかないので,あまり凝った入力をさせられない(せいぜい JSONシリアライズして渡すなど).アクションをできるだけ小さく保つことで入力のインターフェースを複雑にしないように気をつけたほうが良さそうに感じました