GitHub Actions の JavaScript Action を TypeScript で書いた
GitHub Action を TypeScript で作成したので,覚え書きがてらどうやって作ったかについて書きます. github-action-benchmark という Action をつくりました.
紹介記事:継続的にベンチマークを取るための GitHub Action をつくった
Action とは
今年9月に GitHub Action v2 がリリースされました.GitHub Action は GitHub が提供する CI/CD サービスです. 既存のサービスと大きく違う点は,処理を汎用的に Action として切り出して再利用できることです. 例えば,GitHub からのリポジトリのクローン actions/fetch や Node.js のセットアップ actions/setup-node などの基本的な実行ステップも Action として実装されています.
Action の種類
GitHub Action には JavaScript Action と Docker Action があります.下記のページに公式の解説があります.
https://help.github.com/en/actions/automating-your-workflow-with-github-actions/about-actions
- Docker Action: Docker コンテナを書くと,そのコンテナが GitHub Action のステップで実行されます
- Docker コンテナとして実行されるので,実行環境は Linux オンリーです
- ちょっとしたシェルコマンドをまとめた Action ならシェルスクリプトで書いて,簡単な Dockerfile を書いてエントリポイントから実行するだけで実現できます
- Docker コンテナのイメージに任意のツールや言語のランタイムを入れておけば,好きなツールや言語で Action がつくれます
- Action の入力は環境変数,出力は標準出力で行います
- 公式ヘルプ: https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-a-docker-container-action
- JavaScript Action: Node.js のパッケージを書くと,そのパッケージが GitHub Action のステップとして実行されます
- Node.js で実行されるので,好きな npm パッケージがそのまま使えます
- 公式の npm パッケージ群があって,Action の入出力はライブラリ
@actions/*
で行えます.TypeScript に公式で対応しています - コンテナで実行されないので,Docker Action に比べて高速らしいです
- 公式ヘルプ: https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-a-javascript-action
この記事では後者のみに絞ります.
Hello world
まず JavaScript で雛形を書きます.
Creating a JavaScript action を見て順番に試していけば良いので,ここで解説することは特にありません.
action.yml
を置き,npm init
して @actions/*
パッケージを入れ,index.js
を書きます.
次に npm install --save-dev typescript
して TypeScript コンパイラを入れ,index.js
を index.ts
に書き換え,tsconfig.json
を用意します.
{ "compilerOptions": { "module": "commonjs", "moduleResolution": "node", "preserveConstEnums": true, "noImplicitAny": true, "noImplicitReturns": true, "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, "noEmitOnError": true, "strictNullChecks": true, "target": "es2019", "sourceMap": true, "esModuleInterop": true, "resolveJsonModule": true }, "files": [ "index.ts" ] }
もしくは公式のテンプレートを使っても良いです.
公式の @actions/*
パッケージは TypeScript で書かれていて型定義を持っているのでこのままでほぼ問題ありませんが,残念ながら一発ではコンパイルが通りません.
@actions/github
が使っている @octokit/graphql
は v4 から TypeScript に対応しているのですが,@actions/github
が使っているのは v2 なので型定義がありません.
少し調べてみると,@actions/github
は @octokit/graphql
の型定義を自前で抱えていることが分かったので,v4 に対応するまではそこのものをコピーしてくるか,下記のように回避するための型定義ファイルを書いて自分のプロジェクトに含めておく必要がありました.
// Workaround for @actions/github until @octokit/graphql v4 is used declare module '@octokit/graphql' { type GraphQlQueryResponse = unknown; type Variables = unknown; }
最後にテスト用の workflow ファイルを更新します.TypeScript のコンパイルが必要になるので,リポジトリを checkout してきて node をセットアップし,npm install
して tsc
でコンパイルします.
実行する Action を ./
で始まる相対パスにしておくとローカルのアクションを実行してくれます.
ちなみに .
ではダメで ./
にする必要があって,少しハマりました.
runs-on: ubuntu-latest name: A job to say hello steps: + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + - run: npm install + - run: ./node_modules/.bin/tsc -p . + - name: Hello world action step id: hello - uses: actions/hello-world-javascript-action@master + uses: ./ with: who-to-greet: 'Mona the Octocat' # Use the output from the `hello` step
後は自分の好きなように入力を定義し,処理を書いて結果を出力するだけなので,基本的に下記のドキュメントを見て進めれば OK です.
action.yml
の書き方: https://help.github.com/en/actions/automating-your-workflow-with-github-actions/metadata-syntax-for-github-actions.workflow
ファイルの書き方: https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions- actions/toolkit リポジトリの README
この記事では今回 github-action-benchmark を作成する際に引っかかったポイントや考えたポイントだけ書きます.
Action の入力
Action への入力は workflow ファイルでは with:
に key: value
で複数していします.JavaScript Action では @actions/core
パッケージの getInput(name: string): string
で値を取得できます.
少し注意する必要があるのは,入力は文字列型オンリーな点です.この入力には array や map が使えません.なので,例えば下記のようなリスト形式やキーバリュー形式の入力を定義することはできません.
uses: ./ with: awesome-array-option: - one - two - three awesome-map-option: key: value key2: value2
これは入力が Docker Action で環境変数 $INPUT_*
にマッピングされていることに由来していそうですが,理由は不明です.
リストを使いたい場合はどうすれば良いのかなといくつか公式 Action を調べてみると,
with: awesome-array-option: | one two three
のように改行区切りの文字列で指定させて Action 側で処理しているものがありました.
Action のデバッグ
GitHub Action の実行環境と同じ環境変数(例えば入力 foo-bar
は $INPUT_FOO_BAR
)を与えて node index.js
で実行するか,Action をプッシュしてテスト用の workflow の実行結果を見てデバッグすることになります.簡単なものであれば前者だけで事足りますが,最終的に GitHub Action で実行して結果を確認することになると思います.
@actions/core
の core.debug()
でデバッグ情報を埋め込んでおくのですが,これは(デバッグ情報なので当然ですが)デフォルトでは表示されません.
デバッグ情報は特定の secret に true
をセットすることで初めて表示されます.secret は本来リポジトリ固有の秘密の情報(API トークンとか)を保存する場所ですが,デバッグ情報の設定にも利用されています.リポジトリページ → Settings → Secrets タブ で設定できます.
ACTIONS_RUNNER_DEBUG
: workflow を実行する Runner の実行ログACTIONS_STEP_DEBUG
: 各ステップの実行ログ
Action のデバッグ情報を見るには後者が必要です.
Action のテスト
単体テスト
普通の npm パッケージと同じようにテストすれば OK です. GitHub Action と同じ環境変数を与えてテストを実行すれば OK です.
github-action-benchmark では単体テスト中に git
コマンドを実行されると困るのと,@actions/core
の実装のロジックに依存するのが嫌だったので @actions/core
を mock-require で mock してしまってます.
真面目にやるならテスト用の Git リポジトリを作ってその中でテストケースを走らせるべきですが,その辺はサボってます.
E2Eテスト
実際に Action を走らせて期待した動作になっているかは GitHub Action で走らせて結果を見るのが一番良いと思います. github-action-benchmark では CI 用の workflow の中で action をビルドして実行し,実行後に期待した状態になっているかをスクリプトでチェックしています.
これだと「正しく fail するか」をテストすることができない(action が fail するとその時点で workflow が止まってしまうので)のですが,どうやれば良いかはまだよく分かっていないです…(誰かご存知なら教えてもらえると嬉しいです)
Action の公開
Action は GitHub の公開リポジトリに置いておくと即使えるようになります.例えば user/foo-action
というリポジトリをつくると
name: user/foo-action@ref
のように書くだけで使えるようになります.ここで ref
は Git リポジトリの ref で,ブランチ名やタグ名,コミットハッシュなどが使えます.
なので,この ref
で Action のバージョンを制御するのですが,セマンティックバージョニングをサポートするような仕組みは無いので,自前でルールを定める必要があります.
github-action-benchmark ではセマンティックバージョニングに従ってリリースすることを決めました.
rhysd/github-action-benchmark@v1
は v1.x.y の最新を表すrhysd/github-action-benchmark@v1.1.0
は v1.0.0 に固定することを表す- マイナーバージョンまでの固定や範囲指定はサポートしない(必要性を感じなかったので)
としました.
また,JavaScript Action は Action のリポジトリを checkout した時点で即 node .
で実行できるようになっていないといけないので,TypeScript や babel を使っている場合は事前にコンパイルした JavaScript コードをリポジトリに置いておく必要があります.
また,Action 実行時に npm install
も行わないので node_modules
も丸ごと置いておく必要があります.native extension を利用しているパッケージが node_modules
に含まれている時にどうすれば良いのかはよく分かりません…
master
ブランチにビルドした生成物と依存パッケージを直置きしたくなかったので,下記のように運用することにしました
- 各メジャーバージョンごとに orphan ブランチを切る.例えば v1.x.y 向けは
v1
ブランチ - 新しいバージョンのリリースは対応するメジャーバージョンのブランチで行う.例えば現在の
master
ブランチをv1.0.2
にリリースする場合は,まずはmaster
ブランチで TypeScript コードを JavaScript にコンパイルし,v1
ブランチに切り替えてそれらを add して commit し,v1.0.2
タグをつけて push します - 次のメジャーバージョン(例えば
v2
)をリリースする時は前バージョンの開発用ブランチ(例えばdev/v1
)を作っておいて v1.x.y 向けの修正はこちらからv1
ブランチに入れる
ちなみに公式の推奨では release/v1
ブランチをつくって v1
タグを毎回最新に張り替えることを推奨しています.これはリリース前に release/v1
上でテストを行う前提なんだと思いますが,毎回張り替えないといけないのと,master
でテストしてから v1
ブランチに成果物を置く前提だったのでブランチ名を v1
としています.
ちなみに master
→ v1
へは簡単なスクリプトを使って成果物を置いてます.
https://github.com/rhysd/github-action-benchmark/blob/master/scripts/prepare-release.sh
Action で発行される GitHub API トークンの注意
GitHub Action では実行ごとに GitHub API のトークンが自動生成され secrets.GITHUB_TOKEN
に格納されます.基本的には普通の API トークンのように使えるのですが,どうやらパーソナルアクセストークンとは異なりイベントを起こす権限を持っていないようです.
例えば GitHub Pages のブランチに push することはできますが,GitHub Pages のデプロイをすることはできません.
この制限のため,github-action-benchmark ではパーソナルアクセストークンが必要になってしまっています.
ちなみにこの制限はなぜか public repo のみで,private repo では問題なく GitHub Pages のデプロイができました.
継続的にベンチマークを取るための GitHub Action をつくった
今年9月に GitHub Action v2 がリリースされました.GitHub Action は GitHub が提供する CI/CD サービスです.
既存のサービスと大きく違う点は,処理を汎用的に Action として切り出して再利用できることです.
例えば,GitHub からのリポジトリのクローン actions/fetch
や
Node.js のセットアップ actions/setup-node
などの基本的な実行ステップも
Action として実装されています.
今回はこの GitHub Action を利用して,前々からあると良いなと思っていたベンチマークを継続的に取るための Action をつくりました.
github-action-benchmark はベンチマークの実行の出力からベンチマーク結果を抽出し,GitHub pages のブランチに JSON で保存します. 保存された JSON は GitHub pages でグラフチャートで確認できます.
実際にベンチマークを取って表示しているデモは下記の URL で確認できます:
https://rhysd.github.io/github-action-benchmark/dev/bench/
ベンチマークツールの出力からベンチマーク結果を抽出するところでツール固有のロジックが必要で,現在は下記のツールに対応しています.
cargo bench
(Rust)go test -bench
(Go)- benchmark.js (JavaScript/TypeScript)
解決したい問題
アプリやライブラリの実行パフォーマンスを見るための手段としてベンチマークがあります. ベンチマークの結果は最適化の際に効果を検証するのに使ったり,開発の中で意図せずパフォーマンスがデグレしていないことを確認したりするために使うのが一般的だと思います. ここでは後者にフォーカスを当てます.
それなりにメジャーな言語には大抵ベンチマークを取るための公式ライブラリやデファクトなライブラリがあります. それらのツールはベンチマークを実行して結果を表示したり,指定したリビジョン間での比較を行ったりはできますが,開発の中で継続的に計測結果をモニタするような仕組みは提供してくれません.
ですが,意図しないパフォーマンスのデグレを防ぐには継続的にベンチマーク結果をチェックする必要があります. それを GitHub 上のリポジトリに絞って行うための Action が github-action-benchmark です.
使い方
Examples
リポジトリ内に Rust, Go, JavaScript のそれぞれの実際に動くデモを置いてあります.これらのデモは実際に github-action-benchmark のリポジトリで CI の一部として実行されています.
- Rust:
- Go:
- JavaScript:
これらのワークフローを実行して収集されたログが github-action-benchmark の GitHub pages のページにホストされています.
github-action-benchmark は複数のベンチマーク結果を1箇所に集められるため,複数の言語で開発されているリポジトリや monorepo な構成のリポジトリでも問題なく使えます.
余談ですが,全てのプロジェクトでフィボナッチ数を求めるベンチマークを回していて,各言語のフィボナッチ数計算に対する実行パフォーマンスが何となく分かりますね.この超簡単な例では Rust が一番速く,Go は Rust に比べて1.6倍ほど遅く,Node.js は Go に比べて2倍ほど遅いです.
ワークフローの書き方
上記の Examples のワークフロー設定(YAML)を真似すれば問題ないと思いますが,一応ワークフローの書き方を説明します.どの言語でもほぼ変わらないので,ここでは Go プロジェクトを仮定します.
まずはベンチマークの出力を取得するステップを追加します.github-action-benchmark はベンチマーク出力を含むテキストファイルを入力とするので,tee
コマンドなどを使ってベンチマーク結果をログに出しつつ保存します.
- name: Run benchmark run: go test -bench 'Benchmark' | tee output.txt
ここで得た output.txt
を使って github-action-benchmark を実行します.
- name: Store benchmark result to gh-pages uses: rhysd/github-action-benchmark@v1 with: name: My Project Go Benchmark tool: 'go' output-file-path: output.txt
ここで,uses: rhysd/github-action-benchmark@v1
はリポジトリ https://github.com/rhysd/github-action-benchmark の v1
をチェックアウトして使いますという宣言です.github-action-benchmark では v1
ブランチに v1.x.y の最新のバージョンをデプロイしているので,それがチェックアウトされます.もしバージョンを固定したければ v1.0.2
のようなタグも提供しています.
with:
のセクションはこのアクションへの入力です.
name
はベンチマークの名前です.複数のベンチマークを取る場合はこの値がリポジトリ内で一意になるようにしてください. 指定しない場合のデフォルト値は"Benhcmark"
です.tool
はどのツールを使ってベンチマーク出力を取得したかを示すものです."cargo"
,"go"
,"benchmarkjs"
の中のいずれかの値を指定する必要があります.output-file-path
はベンチマークの出力が保存されているファイルへのパスを指定します.リポジトリからの相対パスで記述できます.
これ以外にも,入力や出力をカスタマイズするためにいくつかの入力が定義されています.詳しくは下記の README の表を参照してください.
https://github.com/rhysd/github-action-benchmark#action-inputs
github-action-benchmark を実行すると,gh-pages
ブランチの dev/bench/
以下に結果が JavaScript ファイル data.js
として出力され,(もし既に無ければ)同じディレクトリにグラフチャートを見るための index.html
が生成され,それらを含むコミットが作られます.
ブランチや生成場所のディレクトリパスは上記の with:
に書く入力で変えられます.
最後にブランチをリモートに push します.github-action-benchmark がコミットの生成まで行い push をしないのは,Action は(GitHub API トークンを入力で受け取るなどしない限り)リモートに push する権限を持っていないからです. ここでは安全側に倒して github-action-benchmark 内では push しない設計にしました.
追記(2019/11/11 20:28): どうやら Action は現在 private リポジトリのみで GitHub pages をデプロイすることが可能なようです.いただいた情報によるとこれは GitHub Action 側の問題らしいので,いずれ解消されて GitHub pages への push は github-action-benchmark 側でできるようになるかもしれません(thanks to @pris314 さん)
GitHub Action ではワークフローの実行で GitHub API トークンを自動で発行してくれるので,push するのは簡単です.
- name: Push benchmark result run: git push 'https://you:${{ secrets.GITHUB_TOKEN }}@github.com/you/repo-name.git' gh-pages:gh-pages
トークンは secrets に保存されているのでそれを展開して git push
します.もちろん実行ログでは secrets の展開部分は隠されます.
ちなみに複数のベンチマークを複数ワークフローで並列で実行している場合は,他のワークフローによって GitHub pages のブランチが更新されていて直接 push できない場合があるので,push 前に git pull --rebase
などを挟んでください.
また,github-push-action を使う手もあります(push は自前でやるのがおすすめですが).
- name: Push changes uses: ad-m/github-push-action@master with: branch: gh-pages github_token: ${{ secrets.GITHUB_TOKEN }}
gh-pages
ブランチがリモートにまだ無い場合は作成しておいてください.
$ git checkout -b --orphan gh-pages $ git commit -m 'first commit' --allow-empty $ git push origin gh-pages
これでワークフローが走ったときにベンチマーク結果が gh-pages
内に保存され,https://you.github.io/repo-name/dev/bench
で確認できるようになります.
ページを開くとベンチマークのテストケースごとにグラフが表示されます.縦軸がベンチマークの値,横軸がコミットで,右に行くほど新しいコミットを示しています.これを確認することで,ベンチマーク結果の変化が確認できます.
グラフのデータポイント上にマウスカーソルを置くと
- コミットハッシュ
- コミットメッセージ
- コミットの作成日時とコミッター
- ベンチマークの値
が表示されます.
さらにここでクリックすると GitHub 上でそのコミットのページが開くので,そこでコードの変更内容を確認できます.
また,最下部にベンチマークデータをダウンロードできるボタンを置いてあるので,それをクリックするとベンチマーク結果を JSON としてダウンロードできます.
もし GitHub pages のブランチにデフォルトで生成される index.html
が気に入らなければ,好きに変更したり自前で作ったものに差し替えて OK です.github-action-benchmark は既に index.html
がある場合は関与しませんし,データは全て data.js
側にあって window.BENCHMARK_DATA
に格納されています.
使用の注意点
上記で説明したとおり,github-action-benchmark はリポジトリ上のブランチを変更し,それをワークフロー側でリモートに push するユースケースを想定しています.
なので,pull request ではこの Action が実行されないように注意してください.万一悪意あるユーザが pull request を作成し,gh-pages に悪意ある変更を加える処理を入れて pull request を投げると,それが反映されてしまいます.
ワークフローの指定で master
のみで実行するように制限するか,
on: push: branches: - master
ジョブの実行ステップに記述できる if:
セクションで pull request で gh-pages
ブランチを push するステップを実行しないようにガードしてください.if:
の書き方は公式ドキュメントを読むと分かります:
GitHub Action 実行環境の注意
そもそも GitHub Action の実行環境がベンチマークに適しているのか?という疑問が残っていると思います.
ずっと同じ内容の単純なベンチマークを実行しているデモページでは大体 10~20% のブレがどのベンチマークでも出ています. そのブレを許容できない場合は使えませんし,許容できるなら使えます.そこはプロジェクト次第だと思います.また,あまり無いと思いますが,ネットワークなどのリソースが絡むともっとブレ幅が大きくなる可能性があります.
安定した環境を自前で用意し,self-hosted runner として使うのも手だと思います.
今後
- 以前のベンチマーク結果と比較して結果が極端に悪くなっている(e.g. 2倍以上遅くなっているなど)時にコミットへのコメントで通知する機能
- ベンチマークツールの出力フォーマットに JSON を追加し,ユーザがベンチマーク結果を JSON で用意することで,どんなベンチマークツールでも対応できるようにする
- ベンチマーク結果を GitHub pages にデプロイする代わりに Elasticsearch や Mackerel のようなサービスにアップロードする機能
- GitHub pages のページとして出力する以外に生の JSON データを生成するオプションを追加する.ベンチマーク結果だけ JSON で欲しくて,後の処理は自前でやりたいときに便利
などがアイデアとしてありますが,今のところ機能的に自分のユースケースでは満足しているため,あまり追加の予定はありません. もし何かアイデアがあれば,issue や pull request で提案していただくのは歓迎です.
感想
GitHub Action のための Action を初めて作成しました.kiro-editor のためにつくったので,早速活用していきたいと思います.
ちなみに JavaScript Action として,TypeScript で作ってみました.GitHub Action 自体まだリリースされたところで情報がほとんどありませんが,実行は速くバグにも逢わなかったので,手探りでもあまり困ったことはありませんでした.微妙にハマったポイントもありましたが,それは別エントリで書こうかなと思います.
ターミナル用 UTF-8 テキストエディタを Rust でスクラッチからつくった
言語処理系やテキストエディタなどのプログラミングツールが好きなので,その周辺を趣味で触ってます.Vim を Wasm にポートするために Vim の実装を読んだりはしているのですが,フルスクラッチでテキストエディタをつくったことはありませんでした.
今年のお盆はめちゃ暑かったので,引きこもって夏休みの自由工作的に Rust でテキストエディタをつくっていたという話です.普段ターミナルで作業しているので,つくるのもターミナル向けテキストエディタです.最近 vim.wasm で C と TypeScript ばかりだったので,そろそろまた Rust か Go を書きたかったのですが,Go はすでに micro という良さそうなテキストエディタ実装があったので,Rust で書いてみることにしました.
まずは Build Your Own Text Editor というガイドを利用して,1000行の C で書かれたエディタ kilo を Rust で再実装し,それをリファクタリング・拡張していく形で実装しています.
目次
Kiro エディタ
今回作成したエディタはこちらのリポジトリにあります.kilo をベースにしているので,名前は安直に Kiro にしました.
現時点で下記のような機能があります.
- UTF-8 のテキストファイルを開いて読み込む・新規作成する・保存する(複数ファイル対応)
- UTF-8 テキストを編集する(文字の追加・削除,行の追加・削除など)
- キーショートカット(カーソル移動やテキスト編集など)
- 24-bit 色 (true color) によるシンプルな構文ハイライト(C, Rust, Go, JavaScript, C++)
- インクリメンタルな単純テキスト検索
- ターミナルウィンドウのリサイズ対応
Kiro は xterm 系のターミナルと Unix 系の OS を対象にしています.xterm, urxvt, Gnome-Terminal, Terminal.app, iTerm2, あとできれば Windows Terminal (WSL) で動かしたいと思ってます.今はまだ 24-bit 色の環境として iTerm2,256色の環境として Terminal.app でしか試せてません(もし動かないターミナルがあれば教えてくれるとありがたいです).
使い方
使い方について簡単に説明します.もし実装だけに興味がある場合は,この章は飛ばしてください.
現時点ではバイナリリリースはしていないので, cargo install kiro-editor
で crate をインストールします.小さい数個の crate に依存しているだけなので,ビルドはすぐに終わるはずです.インストールすると kiro
コマンドが使えるようになります.
$ kiro # Start with an empty text buffer $ kiro file1 file2... # Open files to edit
kiro --help
するとオプション一覧(といっても --help
と --version
だけですが)とキーマップ一覧が表示されます.
キーマップは Ctrl-?
でほぼいつでも確認できるので,それ以外を覚えていなくても大丈夫です.
引数無しで開くと無名のテキストバッファ(後にファイルとして保存可)を開くことができます.引数にファイル(複数指定可)を指定するとそれをエディタ内で開きます.構文ハイライトの種類はファイルの拡張子から自動で特定されます.
以下は新しいファイルを開いて,それを Go として保存する例です:
基本的にはメモ帳などのテキストエディタと同じで,キーを入力するとテキストを入力することができます.kilo とは異なり,Kiro は UTF-8 のテキストを編集することができます.
ユニコード文字単位で編集が行なえます.例えばマルチバイト文字をバックスペースキーで消すとその文字が一気に消えますし,カーソルは文字単位で移動します.また,行がターミナルウィンドウの幅に対して長すぎると wrap せず切れて水平スクロールするようになっているのですが,そのスクロールも文字単位で行われます.倍幅文字の途中で切れたりといったことはありません.ただし,まだ家族絵文字👪のようなzero width joiner を使った文字には対応できていません.
また,Ctrl-G
でインクリメンタル単純テキスト検索を行えます.
文字を入力するとそのテキストが検索されてハイライトされ,Ctrl-N
(または ↓
)および Ctrl-P
(または ↑
)で次/前のマッチに移動できます.
カーソルを移動する
下記のようなショートカットが用意されています.
キーマップ | 説明 |
---|---|
Ctrl-F or → |
カーソルを1つ右に移動 |
Ctrl-B or ← |
カーソルを1つ左に移動 |
Ctrl-N or ↓ |
カーソルを1つ下に移動 |
Ctrl-P or ↑ |
カーソルを1つ上に移動 |
Ctrl-A or Alt-← or HOME |
カーソルを行の頭に移動 |
Ctrl-E or Alt-→ or END |
カーソルを行の末尾(最後の文字の次)に移動 |
Ctrl-[ or Ctrl-V or PAGE DOWN |
カーソルを1ページ分下に移動 |
Ctrl-] or Alt-V or PAGE UP |
カーソルを1ページ分上に移動 |
Alt-F or Ctrl-→ |
カーソルを1ワード分右に移動 |
Alt-B or Ctrl-← |
カーソルを1ワード分左に移動 |
Alt-N or Ctrl-↓ |
カーソルを1段落分下に移動 |
Alt-P or Ctrl-↑ |
カーソルを1段落分上に移動 |
Alt-< |
カーソルをファイルの先頭行に移動 |
Alt-> |
カーソルをファイルの末尾行に移動 |
テキストを編集する
下記のようなショートカットが用意されています
キーマップ | 説明 |
---|---|
Ctrl-H or BACKSPACE |
カーソルの左1文字を削除 |
Ctrl-D or DELETE |
カーソルの右1文字を削除 |
Ctrl-W |
カーソルの左1ワードを削除 |
Ctrl-J |
カーソルの左行頭までを削除 |
Ctrl-K |
カーソルの右行末までを削除 |
Ctrl-M or ENTER |
カーソル位置に改行を挿入 |
その他の操作
その他の操作です.複数ファイル開いている場合は Ctrl-Z
/Ctrl-X
で編集するファイルを切り替えます.
キーマップ | 説明 |
---|---|
Ctrl-? |
キーマップ一覧を表示します |
Ctrl-Q |
エディタを終了します.保存していない変更がある場合は確認のため2回入力する必要があります |
Ctrl-S |
編集中のテキストをファイルに保存します.無名バッファの場合はファイル名を入力するためのプロンプトが出ます |
Ctrl-G |
インクリメンタル単純テキスト検索を開始します |
Ctrl-O |
新たにファイルを開くか,空のバッファを開きます |
Ctrl-X |
次のファイルを表示します |
Alt-X |
前のファイルを表示します |
Ctrl-L |
画面を再描画します(万一描画が壊れた時用) |
カラースキームと構文ハイライト
現時点では,カラースキームは gruvbox 固定です.お使いのターミナルが 24-bit 色(true color)対応ならそれを使い,使えなければ 256 色に,それもダメなら 16 色に自動でフォールバックします.
- 24-bit 色
- 256 色
- 16 色
構文ハイライトは下記のものに簡易に対応しています.単純なパターンに基づいてハイライトします.
ベース実装
実装は今時点で全部で3000行ほどです.
Write Your Own Text Editor
Build Your Own Text Editor というチュートリアルガイドがあります.これは1000行の C で実装されたミニマルなテキストエディタ kilo をフルスクラッチから実装していく実装解説です.まずはこれに従ってベースになる実装をつくっていきました.
全部で7つのステップがあり,全てのコード追加・修正が解説されています.英語ですが,中身は非常に平穏な解説なので特に困ることはありませんでした.どうしても日本語で読みたい人は非公式に日本語訳されたものもあるようです.
下記のような構成になっていて,一通りやると C っぽいハイライト(16色)と単純テキスト検索を備えた,ファイルを1つだけ編集できる ASCII テキストエディタを C で作成できます.
- Step1: C コンパイラと Makefile のセットアップ
- Step2: ターミナルを canonical モードから raw モードに変更する実装
- Step3: キー入力のハンドリングと空のウィンドウの描画
- Step4: ウィンドウ内でのカーソル移動と指定したファイルの中身をスクロール表示するための画面描画の実装(+ステータスバー)
- Step5: 行や文字の追加・削除といったテキスト編集機能の実装とファイルへの書き出し実装
- Step6: インクリメンタルな単純テキスト検索の実装
- Step7: 色付きの文字を出力することで構文ハイライトを実装
トータルで1ファイル1000行ほどの C 実装で,git log
によるとお盆の暇な時に進めて5日ほどで Step7 まで実装できたようです.
具体的な実装についてはそれぞれのステップを読んでいただければ良いので,ここでは 'Write Your Own Text Editor' を読むとざっくりどんなことが分かるのか紹介します.
ターミナル上で interactive に動く UI の実装
テキストエディタはターミナル上で interactive な UI を提供します.これは一般的なコマンドラインツール(実行すると何か出力が出たり出なかったりしてその後終了する)とはかなり異なっており,ウィンドウ内でカーソルを自由に移動できます.
'Build Your Own Text Editor' では ncurses などのライブラリを使わず,どういう仕組みでこの UI が実装できるのかを知ることができます.
- termios(3) を使って端末を設定し,インタラクティブな UI を実装するのに邪魔になる入力の echo back や canonical モードを無効にするなどの設定を行います(ターミナル Raw モード)
- ターミナルとプログラムの間でのやりとり(カーソル移動や特殊キー入力など)は制御シーケンスで標準入出力を介して行います
特に 2. に関しては今までまともに触ったことが無かったので色々勉強になりました.例えば下記のようなものがあります.\x1b
は ASCII コード 0x1b エスケープ文字です.
- プログラム → ターミナル に送られる制御シーケンス
\x1b[2J
: 画面全体をクリアする\x1b[{n}H
: カーソルを{n}
行目の行頭に移動する\x1b[K
: カーソル位置から行末までをクリアする
- ターミナル → プログラム に送られる制御シーケンス
\x1b[C
: '←' キー(特殊キー)\x1b[{r};{c}R
: カーソル位置レポート.現在のカーソル位置が{r}
行目{c}
桁目にいることを伝える
例えばターミナルを開いて echo "\e[2J"
とすると画面がクリアされるのが分かります.
\x1b[1H
を出力して1行目に移動し,\x1b[K
を出力して行末までクリアし,その後でテキストを出力することで,1行目を任意のテキストに書き換えることができます.
また,標準出力から(矢印キーや Home/End など)特殊キーのシーケンスをパースすることでユーザが特殊キーを入力したことが分かります.
これらの制御シーケンスは VT100 ユーザガイド や Xterm Control Sequences などで仕様を確認することができます.'Build Your Own Text Editor' では主に VT100 で定義されたシーケンスを使います.
なお,Vim で <Tab>
と <C-i>
や <Esc>
キーと <C-[>
がなぜ同じキーとして扱われているか,<C-`>
がマップできないのかなどの理由も分かります.
ユーザからの入力とターミナルの描画処理を同時に捌く
一般的なプログラムでは stdin からの read はブロッキングです.ですが,テキストエディタで入力でブロッキングしてしまうと,ユーザからの入力を受けていないタイミングでは一切の処理ができなくなってしまいます. そこで,kilo では termios(3) で VTIME をセットすることで100ミリ秒ごとに read をタイムアウトさせることで,ユーザの入力の間にもエディタ側の処理を挟むことができるようにしています.
このタイムアウトは特殊キーなどのターミナルからの制御シーケンスを賢くパースするのにも役立ちます.
例えば \x1b[C
という入力が標準入力に来た時,エディタ側からはこれがターミナルから送られたのか,ユーザが ESC
, [
, C
を入力したものなのか区別できません.
ですが,例えば ESC
と [
の間に1回タイムアウトが挟まると,これはターミナルが送ったのではなくユーザが送ったのだなと判断して ESC
, [
, C
をそれぞれ普通のキー入力として処理できます.もしこのシーケンスがターミナルから送られたのなら,間に100ミリ秒もの間隔が空くことはありえないからです.
これは主に ESC キーの入力がユーザからなのか,ターミナルの制御シーケンスの一部なのかを判別するのに役立ちます.
テキストエディタのテキストの持ち方
kilo では各行を単純に char *
の配列で持っているので,あまり特筆することはありませんが,テキストエディタで編集対象のテキストバッファと表示されるテキストが別に管理されていることが分かります.カーソル位置についても,編集対象のテキスト上での位置とエディタのスクリーンの表示上での位置を分けて管理しています.これは例えばタブ文字など,表示サイズが半角1文字分でない文字に対応するために必須です.
また,UTF-8 対応(後述)で,テキスト上の文字の位置は (1) テキストバッファ上のバイトインデックス,(2) 文字単位で数えたインデックス,(3) スクリーンの表示上の位置,の3種類を管理しなければいけないことが分かりました.
kilo 実装を拡張する
Kiro は kilo からさらに実践的なエディタとして実装を色々拡張しています. かなり色々手を加えて,実装は元の2倍以上になっているのでここでは全てを解説できませんが,その中で面白いと思ったものをいくつか紹介します.
効率的な描画
kilo では実装の簡略化のため,ハイライトの計算と画面の描画を毎回行う実装になっていました.ですが,これは効率的ではありません.実際に10000行ぐらいの C コードを PageDown キーリピートでスクロールすると数千行あたりで処理がカクカクになってしまいました.
そこで,Kiro ではもう少しハイライトの計算範囲と再描画範囲を行単位で絞って必要がある時のみ再描画しています.
例えば,次のような C コードを編集するとします.
int main() { printf("hello\n"); }
ここで hello\n
に !
を挿入して hello!\n
にしたとします.この時,
printf
より上の行は変わっていないので再描画する必要はありません.一方で,printf
の行だけでなく}
の行も再描画する必要があります.これは,例えばブロックコメントの/*
が挿入された時など,次の行以降のハイライトが変更され再描画が必要になるケースがあるためです- ハイライトについては,(何かをキャッシュしない限り)頭から順にパースする必要があるため,毎回ファイルの頭から計算し直す必要があります.ただし,スクリーンの下端以降の行は表示されないため,ハイライトの計算は不要なのでそこで処理を打ち切ります
さらに,カーソル移動のみでスクロールが発生しない時など,描画するテキストに一切変更が無いときは描画自体をスキップします.
再描画する範囲の管理など処理が少し煩雑になりますが,画面全体を毎回描画しなおすのに比べるとかなり改善します.
UTF-8 対応
kilo は編集するテキストとして ASCII のみを対象としています.例えば日本語のテキストを編集しようとするとカーソルが行末を突き抜けたり,配列の範囲外にアクセスしてプログラムがクラッシュしたりします.
Kiro では UTF-8 テキストを ASCII テキストと同様に編集できるよう,対応を行いました.これにより,カーソルは1文字ごとに移動でき,バックスペースキーは1文字単位で文字を削除でき,横方向のスクロールでも表示が壊れたりしません.
kilo の「編集対象を ASCII 文字のみにする」というのは非常に強力な前提です.ASCII 文字は1文字1バイト固定なので
バイトインデックスと文字のインデックスが一致し,
s[n]
のように添字アクセスするとs
のn
文字目に O(1) でアクセスできますバイト数と文字数が一致するので,文字列内の文字数は O(1) で分かります
- タブ文字を除く表示可能文字は半角幅で描画できるので,桁位置を別途考慮する必要がありません(タブ文字は kilo では特別扱いされてアドホックに処理されています)
ですが,UTF-8 は1文字のバイトサイズは(一部の特別な文字を除いて)1〜4バイトの可変長です.なので,
また,文字によって表示幅が半角1文字になったり2文字分になったりします.
ランダムアクセスや文字数の取得はテキストの編集処理やカーソル移動なので頻繁に行うので,全てを毎回 O(n) で計算するのは効率的ではありません.
そこで,Kiro では必要に応じて文字列中の各文字のバイトインデックスをキャッシュしておくことで,任意の文字のバイトインデックスを O(1) で計算できるようにします.
1行のテキストを表す Row
構造体は以下のような構造になっています
struct Row { buffer: String, // 編集対象の生の UTF-8 文字列 render: String, // 表示用の文字列 indices: Vec<usize>, // 各文字のバイトインデックスを持つ配列 }
具体的に例を見てみます.今,"Rust🦀良い"
という文字列があるとします.
蟹絵文字は4バイト,後ろの2文字はそれぞれ3バイトなので,それぞれのバイトインデックスを indices
フィールドに格納しておきます.indices
の各要素の添字は文字列中の各文字に対応しており,要素の値はそれぞれの文字のバイトインデックスです.これにより,
let i = 3; // 4文字目にアクセスしたい let c = self.buffer[self.indices[3]..].chars().next().unwrap(); // O(1) でアクセス
のようにアクセスすることができ,ASCII 文字のときと同じ効率で文字アクセスや文字列サイズの取得が行なえます.
インデックスのキャッシュ(self.indices
の構築)は表示用文字列 self.render
を計算する際に一緒に構築できるので効率的です.
しかし,毎回このインデックスを全ての行でキャッシュするのはメモリ効率が非常に悪いです.char
1文字4バイトのために usize
1つ8バイトを消費しています.
Kiro はプログラムの編集を主に考えてつくられているため,ASCII 以外の文字が登場する行は多くないと想定できます.
そもそも ASCII 文字のみを含んでいる文字列はバイトインデックスと文字のインデックスが一致するためキャッシュをする必要はありません.そこで,self.indices
にはキャパシティが 0 の Vec
をセットしておきます.
これにより,キャッシュが存在しない(self.indices
の長さが 0)ときはそのまま文字インデックスでアクセスし,キャッシュが存在するときは文字インデックスをバイトインデックスに変換する処理を挟んでアクセスすれば良いです.
Vec
はキャパシティが 0 の時,ヒープメモリの確保を行わないことが明記されているので,ASCII 文字のみを含む行におけるメモリのオーバーヘッドはわずか 8 (ptr) + 8 (cap) + 8 (len) = 24 バイトで済みます.
24-bit 色(true color)対応
kilo は16色のみをハイライトに使っていますが,最近のモダンなターミナルでは 24-bit 色がサポートされているものも多いです. そこで,24-bit 色のカラーシーケンスを使ってリッチなカラースキーム(今回はレトロな色合いの gruvbox を採用しました)を実装しました.256 色しか使えない Terminal.app のようなターミナルでは 256 色に,万一それも使えない場合は16色にフォールバックするようにしています.
端末に色を伝えるには各色ごとに決められたコントロールシーケンス(カラーシーケンス)を送ります(\x1b
は ESC).詳しくは英語版の Wikipediaに載っています.
- 16 色 →
\x1b[{n}m
ただし{n}
は 30〜37, 90〜97, 40〜47, 100〜107 - 256 色 →
\x1b[{n}m
16色と同じシーケンスだが{n}
は 0〜255 - 24-bit 色 → foreground は
\x1b[38;2;{r};{g};{b}m
,background は\x1b[48;2;{r};{g};{b}m
後は端末がどの色数に対応しているかを知る必要があり,下記の方法で知ることができます
- 256 色対応 → terminfo(5) の
colors
の値が256
- 24-bit 色 →
$COLORTERM
環境変数の値がtruecolor
これらのチェックを使って対応している色数を判断し,出力するカラーシーケンスを出し分ければ OK です.
Rust による実装
kilo は「1000行でテキストエディタを実装する」というプロジェクトなので,処理は極力簡潔になるように実装されています. ですが,そういった実装は拡張時やテスト時の足かせになります.そこで,Rust で実装するにあたりいくつかリファクタリングを加えながら実装を行いました.あまり特筆する箇所は無いですが,いくつかポイントを紹介します.
ロジックごとにモジュール分割
kilo は全ての状態を E
というグローバル変数に持ち,あらゆるところからそれにアクセスするようなコードになっています.
Kiro ではそれをエディタのロジックごとにモジュールに分割し,モジュール内の構造体に状態を持たせています.
エディタは下記の9つのモジュールからなります.
editor.rs
: エディタ全体のライフサイクル(キー入力を読んでパース→テキストバッファを更新→ハイライトを更新→画面を描画 のループ)を管理するEditor
構造体を export します.1つのファイルごとに1つのテキストバッファを作成し,バッファの切替も管理しています.text_buffer.rs
: 編集対象のテキストをVec<Row>
として持ち,文字や行の挿入・削除などのテキスト変更処理を管理するTextBuffer
構造体を export します.row.rs
: 1行のテキストを管理するRow
構造体を export します.Row
では編集対象の文字列がTextBuffer
に変更された時に表示用の文字列とバイトインデックスを更新します.input.rs
: ターミナルの設定を管理するStdinRawMode
構造体と,ターミナルからのバイト列の入力をパースしてキー入力や制御シーケンスの列に変換するInputSequences
構造体を export します.highlight.rs
: 各文字ごとのハイライト情報を持つHighlighting
構造体を export します.ハイライトの更新が必要かどうかを管理するフラグを持ち,必要な範囲を必要な時にハイライト再計算します.screen.rs
:TextBuffer
を画面に描画するScreen
構造体を export します.どの行から再描画すべきかを管理して,効率的に再描画します.ansi_color.rs
: ターミナルの対応する色数に応じたカラーシーケンスを生成するAnsiColor
構造体を export します.language.rs
: 各言語を表すLanguage
enum を export します.ファイルの拡張子から言語を検知するロジックもここで持っています.signal.rs
: ターミナルのリサイズに必要な SIGWINCH シグナルを補足してScreen
に通知するための構造体SigwinchWatcher
を export します.
エラーハンドリングとリソースの解放
kilo ではエラーハンドリングは perror()
でメッセージを出して即 exit()
で終了する実装でしたが,Kiro では Rust のイディオムに従い,Result
と後置 ?
で処理しています.
また,kilo では atexit でリソース解放を行っていましたが,Kiro では Drop
trait を使って後処理を行っています.特に標準入力を Raw モードに変更し最後に元に戻す StdinRawMode
構造体では Drop
と Deref
,DerefMut
を使い,
struct StdinRawMode { stdin: io::Stdin, // ... } impl StdinRawMode { fn new() -> io::Result<StdinRawMode> { // ここで termios(3) によりターミナルを Raw モードに設定 // ... } } impl Drop for StdinRawMode { fn drop(&mut self) { // 元の状態に復元 } } impl Deref for StdinRawMode { type Target = io::Stdin; fn deref(&self) -> &Self::Target { &self.stdin } } impl DerefMut for StdinRawMode { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.stdin } }
のようにして,元の io::Stdin
のように使えるがターミナル設定の変更と復元も行うような実装になっています.
入力と出力の抽象化
pub struct Editor<I, W> where I: Iterator<Item = io::Result<InputSeq>>, W: Write, { // ... } impl<I, W> Editor<I, W> where I: Iterator<Item = io::Result<InputSeq>>, W: Write, { // Initialize Editor struct with given input and output pub fn new(input: I, output: W) -> io::Result<Editor<I, W>> { // ... } }
エディタの入力はキーシーケンスの列なので,Iterator
trait を用いて実際にキーシーケンスを生成する値を抽象化しています.InputSeq
はキー入力または制御シーケンスを表しており,io::Result<InputSeq>
はシーケンスのパース結果を表しています.
また,エディタの出力はバイト列を書き込む stdout なので,Write
trait を用いて書き出す対象の値を抽象化しています.
これらの trait を用いた抽象化はテストを書く時に重要になります.入力と出力をすげ替えられるようになるので,標準入出力から切り離してロジックだけをテストすることができるようになります.
例えば入力については Iterator<Item = io::Result<InputSeq>>
を実装したダミーの構造体を用意します.
struct DummyInput(Vec<InputSeq>); impl Iterator for DummyInput { type Item = io::Result<InputSeq>; fn next(&mut self) -> Option<Self::Item> { if self.0.is_empty() { None } else { Some(Ok(self.0.remove(0))) } } } // Ctrl-Q を1回だけ入力(エディタをすぐ閉じる) let dummy_input = DummyInput(vec![ InputSeq::ctrl(b'q') ]);
また,出力は何もせずに捨てる処理を Write
trait を実装することで実装できます.
struct Discard; impl Write for Discard { fn write(&mut self, buf: &[u8]) -> io::Result<usize> { Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } }
これらを使って,下記のようなテストを書けます.
#[test] fn test_editor_welcome_screen() { // ダミーの入出力でエディタインスタンスを生成 let mut editor = Editor::new(dummy_input, Discard).unwrap(); // ロジックを実行 editor.edit().unwrap(); for line in editor.lines() { // 結果の各行をチェック } }
デバッグ
普段 macOS で開発しているので,公式の rust-lldb を使いたいところですが,エディタを起動した後に rust-lldb -p {プロセスID}
でアタッチすると Interrupted system call (os error 4)
で read が失敗してエディタが終了してしまうのでうまくいきませんでした(要調査)。
今のところは次のように標準エラー出力でプリントデバッグしています.普通に出力を吐いてもエディタがすぐスクリーンを上書きしてしまうので,
cargo run -- 2>log.txt
のようにファイルにデバッグログを吐いておき,
tail -f log.txt
のように tail
でファイルの中身を読むことでファイル経由で別のターミナルにログを吐いています.
今後
直近では下記のような実装を考えています
これくらいあればとりあえずエディタとしてはそこそこ使えるかな…と思ってます.
さらに,今後の課題として
- テキストエディタのテキストの持ち方として,頻繁な変更に対して効率的なデータ構造が一般に知られています.例えば Rope), Gap Buffer, Piece Table など
- 現在の適当なパースによるハイライトを,まともなパーサに置き換えます.ただ,普通のパーサではエディタのハイライトの更新頻度的にパフォーマンスが厳しいことが知られていて,その対策として incremental parsing のアルゴリズムが知られています(paper)
- WebAssembly にビルドして xterm.js でブラウザに描画
- EditorConfig などのエディタ設定の反映
- zero width joiner を使った文字の対応
- マウスサポート
特に 1. と 2. はかなり楽しそうなのでどこかで時間を見つけてやりたいです.
感想
超ミニマルなテキストエディタ kilo と 'Write Your Own Text Editor' チュートリアルによる実装をベースに,UTF-8 テキストエディタ Kiro を実装しました. テキストエディタをフルスクラッチでつくるのは初めてだったので,少しずつテキストエディタっぽくなっていくのがかなり楽しかったです.(編集できるようになるのが Step5 なので少し遅いですが…)
今回の知識を生かして,いつか「ぼくのかんがえたさいきょうのターミナルテキストエディタ」をつくりたいと思ってます.が,実装言語含めて予定は未定です.
ちなみに今回は Rust で実装しましたが,Go で実装するのも楽しそうな題材でした.例えば入力のパースとハイライトの更新と画面への描画を別ゴルーチンが担当して,間を channel でつないでパイプラインにすると,おそらく画面の更新のレスポンスがさらに改善するのではないかとか思いました.興味がある人がもしいれば是非トライしてみてほしいです.